사내에서 LLM을 쓰게 하면 곧 “누가 얼마나 썼는가"라는 질문이 나온다. LiteLLM은 Virtual Key, 사용자, 로그를 조합해서 이 답을 주지만, 개념이 여러 층으로 나뉘어 있어서 처음에는 어디에 무엇이 쌓이는지 헷갈린다. 특히 LibreChat 같은 앱이 키 하나를 모든 사용자가 공유하는 구조에서는 더 그렇다. 키 단위 spend는 쌓이지만 사용자별로는 보이지 않거나, 보이더라도 사용자 본인 화면(Your Usage)에는 안 나오는 일이 생긴다.
이 글은 두 부분으로 구성했다. 1부에서는 Virtual Key, Internal User/End User, Usage, 로그의 기본 기능을 curl 스크립트로 하나씩 확인한다. 2부에서는 키 하나를 공유하면서도 관리자는 전체와 사용자별 현황을 한곳에서 보고, 각 사용자는 본인 계정에서 본인의 usage를 확인하는 구성을 정리한다. 모든 내용은 실제 클러스터에서 테스트한 결과이고, 테스트하지 못한 부분은 그렇다고 적었다.
목차
1부. LiteLLM 사용량 추적 기본
2부. 키 하나를 공유하면서 전체와 계정별 usage를 함께 보기
1부. LiteLLM 사용량 추적 기본
1. 개요
LiteLLM은 여러 LLM 서비스를 하나의 OpenAI 호환 API로 묶어 주는 프록시다. 모든 호출이 LiteLLM을 거치므로 누가, 어떤 모델을, 얼마나 썼는지를 한곳에서 기록할 수 있다. 요청 한 건이 들어오면 usage는 아래 네 곳에 기록된다.
- Virtual Key: 호출에 쓴 API 키의 spend에 항상 쌓인다.
- Internal User: LiteLLM에 로그인하는 계정이다. 키에 소유자(
user_id)가 있거나 매핑된 헤더(X-User-Id)가 오면 그 사용자에 쌓인다. 헤더가 있으면 소유자 대신 헤더 값의 사용자에 쌓이고, 둘 다 없으면 어느 사용자에도 쌓이지 않는다. - End User (Customer): 서비스를 쓰는 최종 사용자다.
x-litellm-end-user-id헤더나 요청 본문user가 오면 그 값의 customer에 쌓인다. - 로그: 요청 한 건당 한 줄이 남고,
user와end_user칸에 위 값이 함께 기록된다.
2. 테스트 환경
Kubernetes 클러스터에 Keycloak, LiteLLM, LibreChat을 설치해서 테스트 환경을 구축했다. Keycloak을 IdP로 두고 LiteLLM과 LibreChat 양쪽 모두 Keycloak SSO(OIDC)로 로그인하도록 연계했다. 그래서 사용자는 두 서비스에서 Keycloak 계정 하나를 쓴다.
- Keycloak: IdP. LiteLLM과 LibreChat의 로그인을 모두 담당한다.
- LiteLLM:
v1.105.0(이미지ghcr.io/berriai/litellm:main-latest),llm네임스페이스의litellm:4000서비스 - LibreChat:
v0.8.7. LiteLLM을 커스텀 엔드포인트로 호출한다. - 테스트 모델:
gemini-3.5-flash-lite(gemini/gemini-3.5-flash-lite로 라우팅)
이후 모든 스크립트는 아래 환경변수를 전제로 한다. 키 값은 문서나 저장소에 쓰지 않고 셸 환경변수로만 넘긴다.
# 클러스터 내부 LiteLLM에 로컬에서 접속
kubectl -n llm port-forward svc/litellm 14000:4000 &
export LITELLM_URL=http://127.0.0.1:14000
export ADMIN_KEY=... # admin 계정의 virtual key (sk-...)
export SHARED_KEY=... # 앱이 공유해서 쓰는 virtual key (sk-...)
관리 API(/user/list, /customer/*, /spend/logs 등)는 admin 키가 필요하다. 일반 키로 호출하면 Only proxy admins and organization admins can list users 같은 오류가 난다.
접속 확인은 아래처럼 한다.
curl -s $LITELLM_URL/health/liveliness
curl -s $LITELLM_URL/v1/models -H "Authorization: Bearer $SHARED_KEY"
3. Virtual Key
3-1. 개념
Virtual Key는 LiteLLM이 발급하는 API 키로, 호출할 수 있는 모델, 만료, 예산 등을 키 단위로 제한할 수 있다. 사용량은 키 단위로 항상 기록된다. 키에 user_id가 지정되어 있으면 그 사용자(Internal User)의 spend에도 같이 쌓이고, 지정되어 있지 않으면 사용자 쪽에는 쌓이지 않는다.1
키의 소유자는 키를 만든 방식에 따라 달라진다. admin 계정으로 UI에 로그인해서 만든 키는 소유자가 그 계정이다. master key로 API를 직접 호출해서 만든 키는 created_by가 default_user_id이고 소유자가 비어 있을 수 있다. 테스트 환경의 앱 공유 키가 그런 경우였다.
curl -s "$LITELLM_URL/key/list?return_full_object=true&size=100" \
-H "Authorization: Bearer $ADMIN_KEY" \
| python3 -c '
import sys,json
for k in json.load(sys.stdin)["keys"]:
print({x:k.get(x) for x in ("key_alias","user_id","created_by","spend","models","expires")})'
{'key_alias': 'admin_key', 'user_id': 'admin', 'created_by': 'admin', ...}
{'key_alias': 'librechat', 'user_id': None, 'created_by': 'default_user_id', ...}
3-2. 키 해시와 실제 키
UI의 Virtual Keys 화면에서 보이는 64자리 hex 값은 실제 키가 아니라 키 해시(token)다. 이 값으로 호출하면 LiteLLM Virtual Key expected… expected to start with 'sk-' 오류가 난다. 실제 키는 sk-로 시작하고 생성 시점에 한 번만 보인다.
3-3. 키 생성·조회·삭제
# 생성: 소유자 admin, 30분 후 만료, 특정 모델만 허용
curl -s $LITELLM_URL/key/generate \
-H "Authorization: Bearer $ADMIN_KEY" -H 'Content-Type: application/json' \
-d '{"user_id":"admin","key_alias":"demo-key","duration":"30m","models":["gemini-3.5-flash-lite"]}'
# 이 키의 정보 (소유자, spend 등)
curl -s $LITELLM_URL/key/info -H "Authorization: Bearer $SHARED_KEY"
# 별칭으로 삭제
curl -s $LITELLM_URL/key/delete \
-H "Authorization: Bearer $ADMIN_KEY" -H 'Content-Type: application/json' \
-d '{"key_aliases":["demo-key"]}'
3-4. 키 유형(key_type)
키를 만들 때 key_type으로 그 키가 호출할 수 있는 경로의 범위를 정한다. 지정하지 않으면 default다. 같은 키로 모델도 부르고 관리 API도 부르는 것이 늘 바람직하지는 않아서, 용도에 맞게 좁혀서 발급한다.
# 사용량 조회 전용 키: 관리 API만 호출 가능, LLM 호출은 불가
curl -s $LITELLM_URL/key/generate \
-H "Authorization: Bearer $ADMIN_KEY" -H 'Content-Type: application/json' \
-d '{"user_id":"admin","key_alias":"usage-viewer","key_type":"management"}'
default: 모든 경로를 호출할 수 있다. 앱이 공유하는 키와 관리자 키가 보통 이 유형이다.llm_api: 모델 호출 경로만 열린다. 앱에 넣어 두는 키라면 이 유형이 더 안전하다. 키가 유출돼도 관리 API는 호출할 수 없다.read_only: 모델 목록과 해당 키의 정보만 읽을 수 있고 LLM은 호출하지 못한다.management: 관리 API만 열리고 LLM 호출은 못 한다. 사용량 조회용으로 쓴다. 다만/customer/*는 관리 경로에 포함되지 않아 이 유형으로는 Customer 집계를 볼 수 없다. Customer 집계까지 조회하는 키는default여야 한다.
키 유형은 호출할 수 있는 경로만 정한다. 어느 사용자의 데이터까지 볼 수 있는지는 키 소유자의 역할이 정한다(3-5).
3-5. 누가 어떤 키를 만들 수 있나
일반 계정(internal_user)이 만들 수 있는 키에는 제약이 있다.
- 소유자는 본인으로만 지정할 수 있다.
default,llm_api,read_only는 만들 수 있지만management는proxy_admin만 발급한다(403).- 일반 계정이 소유한 키로는 본인 usage만 조회된다. 다른 사용자 지정과
/user/list는 거절된다.
전체 사용자의 usage를 조회하는 키는 proxy_admin이 소유해야 하고, Customer 집계까지 보려면 default 유형이어야 한다.
4. Internal User와 End User
LiteLLM이 사용량을 귀속시키는 사용자는 두 종류다. Internal User는 LiteLLM에 계정이 있는 사용자이고, 가상 키를 소유할 수 있다. End User(Customer)는 키를 가진 계정(서비스 운영자)의 고객, 곧 그 서비스를 쓰는 최종 사용자다. 이 사용자는 LiteLLM 계정이 없고, 요청에 값이 실려 오면 자동으로 만들어진다.
4-1. 차이
| Internal User | End User (Customer) | |
|---|---|---|
| 정체 | LiteLLM에 로그인하는 계정 | 서비스를 쓰는 최종 사용자 (LiteLLM 계정 아님) |
| 생성 | 미리 만들어 둠 (UI, SSO 등) | 요청에 값이 오면 자동 등록 |
| 로그인 | UI 로그인 가능, 역할(admin, viewer 등) | 없음 |
| 지정 방법 | 키의 user_id, 또는 user_header_mappings의 internal_user 헤더 | 본문 user, x-litellm-end-user-id 헤더 |
| 로그의 칸 | user | end_user |
| 조회 API | /user/info, /user/daily/activity | /customer/info, /customer/daily/activity |
End User의 customer ID는 요청에서 아래 순서로 찾는다. 앞에 있는 것이 이긴다. 이 순서는 공식 문서의 설명이고 이 글에서 따로 테스트하지는 않았다.2
x-litellm-customer-id,x-litellm-end-user-id헤더user_header_mappings에서customer로 지정한 헤더- 요청 본문의
user등 필드
4-2. 사용자 목록 확인
curl -s "$LITELLM_URL/user/list?page_size=50" -H "Authorization: Bearer $ADMIN_KEY" \
| python3 -c '
import sys,json
for u in json.load(sys.stdin)["users"]:
print(u["user_id"], "|", u.get("user_email"), "|", u.get("user_role"), "| spend", u.get("spend"))'
john | john@cnapcloud.com | internal_user | spend 0.0
lisa | lisa@cnapcloud.com | internal_user_viewer | spend 0.0
jane | jane@cnapcloud.com | internal_user_viewer | spend 0.0
admin | mirinus@gmail.com | proxy_admin | spend 0.0012381
사용자의 user_id(예: john)와 이메일은 서로 다른 값이다. 요청에 어느 쪽을 실어 보내느냐에 따라 집계 항목이 갈리므로, 이 차이는 10절 1번에서 다시 다룬다.
5. Usage 조회
5-1. 기준별 누적 조회와 기간별 조회
usage는 Virtual Key, 사용자(Internal User), 최종 사용자(End User) 세 가지 기준으로 볼 수 있고, 기준마다 누적 조회와 기간별 조회가 따로 있다. 누적 조회는 기간 개념 없이 처음부터 지금까지의 총액을 돌려주고, 기간별 조회는 start_date~end_date 범위의 일별 값과 범위 합계를 돌려준다.
- Virtual Key
- 누적:
/key/info. 해당 키로 나간 모든 요청의 spend가 나온다. - 기간별:
/user/daily/activity?api_key=<키 해시>. 사용자 지정 여부와 상관없이 그 키로 나간 요청이 모두 포함된다./customer/daily/activity에도api_key가 있지만 End User가 기록된 요청만 세므로 키 전체 합계로는 맞지 않는다.
- 누적:
- 사용자(Internal User)
- 누적:
/user/info?user_id=<user_id> - 기간별:
/user/daily/activity?user_id=<user_id>
- 누적:
- 최종 사용자(End User)
- 누적:
/customer/info?end_user_id=<customer_id> - 기간별:
/customer/daily/activity?end_user_ids=<customer_id>
- 누적:
START=2026-10-01; END=2026-10-31
# --- Virtual Key ---
curl -s $LITELLM_URL/key/info -H "Authorization: Bearer $SHARED_KEY" # 누적
curl -s "$LITELLM_URL/user/daily/activity?start_date=$START&end_date=$END&api_key=<키 해시>" \
-H "Authorization: Bearer $ADMIN_KEY" # 기간별
# --- 사용자(Internal User) ---
curl -s "$LITELLM_URL/user/info?user_id=lisa" -H "Authorization: Bearer $ADMIN_KEY" # 누적
curl -s "$LITELLM_URL/user/daily/activity?start_date=$START&end_date=$END&user_id=lisa" \
-H "Authorization: Bearer $ADMIN_KEY" # 기간별
# --- 최종 사용자(End User) ---
curl -s "$LITELLM_URL/customer/info?end_user_id=lisa" -H "Authorization: Bearer $ADMIN_KEY" # 누적
curl -s "$LITELLM_URL/customer/daily/activity?start_date=$START&end_date=$END&end_user_ids=lisa" \
-H "Authorization: Bearer $ADMIN_KEY" # 기간별
월별 집계가 필요하면 누적 값으로는 안 되고 기간별 조회를 써야 한다. 누적 값으로 월별을 만들려면 매월 스냅샷을 저장해서 차이를 계산해야 하는데, 기간별 조회는 그런 보관 없이 과거 월을 언제든 다시 조회할 수 있다.
키 해시는 /key/list?return_full_object=true의 token 값이다. 테스트에서 librechat 키의 9월 합계(1.841763)와 10월 합계(0.589887)를 더하면 /key/info의 누적 spend(2.431649)와 소수 다섯째 자리까지 같았다. 범위를 길게 잡으면 응답이 페이지로 나뉠 수 있으니 metadata의 has_more가 false인지 확인하거나 page_size=100을 붙인다.
세 기준은 서로 다른 관점이라 값을 더하면 안 된다. 같은 요청이 키 기준으로도, 사용자 기준으로도, 최종 사용자 기준으로도 집계된다.
5-2. 기간별 집계 응답 구조
curl -s "$LITELLM_URL/customer/daily/activity?start_date=2026-10-01&end_date=2026-10-31&end_user_ids=lisa" \
-H "Authorization: Bearer $ADMIN_KEY" \
| python3 -c '
import sys,json
d=json.load(sys.stdin)
print("top keys:", list(d.keys()))
m=d["metadata"]
print({k:m[k] for k in ("total_spend","total_tokens","total_api_requests")})'
results[]: 일별 행. 각 행에 spend, 토큰, 요청 수와breakdown(모델별, 키별, 엔티티별)이 있다.metadata.total_*: 요청한 범위 전체의 합계. 월 범위로 호출하면 이 값이 그 달 합계다.
end_user_ids를 파라미터로 여러 번 반복하면 마지막 값만 적용된다. 사용자별 월간 표는 사용자마다 한 번씩 호출해서 만든다. 지정을 생략하면 전체가 한 번에 나온다(9-1).
5-3. UI
UI의 Usage 화면에는 로그인한 계정에 따라 보이는 항목이 다르다. 일반 계정(viewer)은 “Your Usage"만 보이고, admin 계정은 다른 뷰(Customer 등)도 열린다. “Your Usage"는 키의 user_id나 매핑된 Internal User 기준으로 읽기 때문에, End User로만 기록된 usage는 이 화면에 나오지 않는다.

6. 로그
/spend/logs는 요청 한 건 한 건을 기록한다. 집계 API가 맞게 계산하는지 검증할 때 기준이 된다.
curl -s "$LITELLM_URL/spend/logs?summarize=false&start_date=2026-10-21&end_date=2026-10-23" \
-H "Authorization: Bearer $ADMIN_KEY" \
| python3 -c '
import sys,json
for r in sorted(json.load(sys.stdin), key=lambda r:r["startTime"])[-10:]:
print(r["startTime"][11:19], r.get("model"), repr(r.get("user")), repr(r.get("end_user")),
r["prompt_tokens"], r["completion_tokens"], r["spend"])'
user칸은 Internal User,end_user칸은 End User다. 값이 없으면 빈 문자열이다.- 모델 이름은
gemini/gemini-3.5-flash-lite처럼 provider 접두사가 붙어서 기록된다. 모델명으로 필터링할 때==대신 포함 여부로 비교해야 한다. - 이 글에서 “로그 합계"는 이 응답의 spend를 내가 직접 합산한 값이다. LiteLLM이 합산해 준 값이 아니다.
2부. 키 하나를 공유하면서 전체와 계정별 usage를 함께 보기
7. 요구사항과 설계 방향
LibreChat처럼 앱이 키 하나로 LiteLLM을 호출하는 구조에서는 사용자별로 키를 발급하지 않는다. 그래도 다음이 필요하다.
- 관리자는 전체 사용량과 사용자별 사용량을 한곳에서 본다.
- 각 사용자는 본인 계정에서 본인의 usage를 본다.
앞 절의 규칙을 조합하면 된다. 앱이 요청마다 “누가 보냈는지"를 헤더로 넘기고, LiteLLM이 그 값을 Internal User와 End User 두 곳에 기록하게 한다.
| 보낸 것 | Internal User | End User(Customer) |
|---|---|---|
X-User-Id만 | 집계됨 | 집계 안 됨 |
user 본문 / x-litellm-end-user-id만 | 집계 안 됨 | 집계됨 |
| 둘 다 | 집계됨 | 집계됨 |
두 헤더를 함께 보내면 같은 요청이 두 곳에 각각 한 번씩 기록된다. 서로 더하면 안 되고, 같은 값을 두 관점으로 본 것이다.
위 표의 동작은 2절 환경에서 curl로 호출하고, 호출 전후의 spend와 /spend/logs를 비교해서 확인했다. 호출 한 건의 크기는 prompt 11토큰, completion 12~22토큰 정도로 아주 작다.
7-1. user 본문 필드 → End User만
공유 키로 user 필드에 값을 넣어 보냈다.
send() { # send <user값> <횟수>
for i in $(seq 1 "$2"); do
curl -s $LITELLM_URL/v1/chat/completions \
-H "Authorization: Bearer $SHARED_KEY" -H 'Content-Type: application/json' \
-d "{\"model\":\"gemini-3.5-flash-lite\",\"user\":\"$1\",
\"messages\":[{\"role\":\"user\",\"content\":\"한 문장으로 인사해줘 (#$i)\"}],\"max_tokens\":60}"
done
}
send john 2; send lisa 3; send jane 1
| end_user | 호출 | 로그 합계 spend | /customer/info spend |
|---|---|---|---|
| john | 2 | 0.0000741 | 0.0000741 |
| lisa | 3 | 0.0001374 | 0.0001374 |
| jane | 1 | 0.0000358 | 0.0000358 |
로그의 end_user 칸에만 기록됐고, 이 방식으로는 Internal User의 spend가 오르지 않았다. 이때 lisa로 UI에 로그인해 보면 “Your Usage"에 요청 0건이 나왔다.
7-2. X-User-Id 헤더 → Internal User
8-1의 매핑을 적용한 뒤 헤더만 바꿔서 보냈다.
call() { # call <user_id> <횟수>
for i in $(seq 1 "$2"); do
curl -s $LITELLM_URL/v1/chat/completions \
-H "Authorization: Bearer $SHARED_KEY" -H "X-User-Id: $1" -H 'Content-Type: application/json' \
-d "{\"model\":\"gemini-3.5-flash-lite\",
\"messages\":[{\"role\":\"user\",\"content\":\"한 문장으로 인사해줘 (#$i)\"}],\"max_tokens\":60}"
done
}
call john 2; call lisa 3; call jane 1
| user_id | 호출 | 호출 전 spend | 호출 후 spend | 증가분 | 로그 합계 |
|---|---|---|---|---|---|
| john | 2 | 5.14e-05 | 0.0001205 | 6.91e-05 | 6.91e-05 |
| lisa | 3 | 0.0 | 0.0001274 | 1.274e-04 | 1.274e-04 |
| jane | 1 | 0.0 | 3.58e-05 | 3.58e-05 | 3.58e-05 |
로그의 user 칸에 각 사용자가 기록됐고 end_user는 비어 있었다. /user/daily/activity?user_id=lisa는 요청 3건, 토큰 80, spend 0.0001274로 나왔다. 증가분이 로그 합계와 정확히 일치한다.
7-3. 키에 소유자가 있을 때
소유자가 admin인 임시 키를 만들어서 X-User-Id: lisa를 붙인 호출 3번과 헤더 없는 호출 2번을 보냈다. 헤더를 붙인 3건은 로그의 user 칸에 lisa로 기록되어 lisa에 집계됐고, 헤더가 없는 2건은 admin으로 기록되어 키 소유자인 admin에 집계됐다.
| 대상 | 호출 전 | 호출 후 | 증가분 |
|---|---|---|---|
| admin | 0.0012381 | 0.00129765 | 5.955e-05 (헤더 없는 2건) |
| lisa | 0.0001274 | 0.00023775 | 1.1035e-04 (헤더 있는 3건) |
| 키 자체 spend | - | 0.0001699 | 5건 전부의 합 |
- 헤더가 있으면 헤더가 소유자를 대체한다. 소유자 admin에 이중으로 쌓이지 않는다.
- 헤더가 없는 요청은 키의 소유자에게 쌓인다. 소유자가 비어 있는 키라면 어느 계정에도 안 쌓이고 키 spend에만 남는다.
- 키 spend는 항상 전체의 합이다. 소유 계정의 spend는 전체가 아니다.
따라서 “키 소유 계정에서 전체를 본다"는 방식은 성립하지 않는다. 전체는 키 단위 값이나 customer 집계로 본다. 소유자를 admin이나 서비스 계정으로 두면 헤더를 못 채운 요청이 사라지지 않고 소유자에게 남는 안전망 역할을 한다.
7-4. 두 헤더를 함께 → 양쪽에 집계
curl -s $LITELLM_URL/v1/chat/completions \
-H "Authorization: Bearer $SHARED_KEY" \
-H "X-User-Id: lisa" -H "x-litellm-end-user-id: lisa" \
-H 'Content-Type: application/json' \
-d '{"model":"gemini-3.5-flash-lite","messages":[{"role":"user","content":"인사"}],"max_tokens":60}'
위 명령을 2번 실행했다.
| 대상 | 호출 전 | 호출 후 | 증가분 |
|---|---|---|---|
| lisa (Internal User) | 0.00023775 | 0.00031185 | 7.41e-05 |
| lisa (End User) | 0.0001374 | 0.0002115 | 7.41e-05 |
2회 호출의 로그 합계가 7.41e-05이고, 로그의 user와 end_user 칸에 모두 lisa가 기록됐다. 같은 요청이 두 곳에 한 번씩만 반영되고, 누락되지도, 두 배로 반영되지도 않았다.
8. 구성 방법
8-1. LiteLLM: 헤더를 Internal User에 매핑
general_settings의 user_header_mappings로 헤더를 사용자 역할에 연결한다. 헤더 하나를 internal_user로 지정하면 그 값이 Internal User로 쓰인다.3
general_settings:
# 기존 항목은 그대로 두고 아래만 추가한다 (general_settings 키를 중복해서 만들면 안 된다)
user_header_mappings:
- header_name: X-User-Id
litellm_user_role: internal_user
8-2. LibreChat: 사용자명을 헤더로 전달
LibreChat의 커스텀 엔드포인트 headers에서 사용자 placeholder를 쓴다.
# librechat-config.yaml — LiteLLM 엔드포인트
headers:
x-litellm-end-user-id: "{{LIBRECHAT_USER_USERNAME}}" # End User(Customer) 집계
X-User-Id: "{{LIBRECHAT_USER_USERNAME}}" # Internal User 집계
LibreChat이 LiteLLM으로 모델을 호출할 때 사용자의 user_id가 헤더로 전달되어야 사용자별 추적이 된다. LibreChat의 {{LIBRECHAT_USER_USERNAME}}은 Keycloak preferred_username이고 LiteLLM SSO의 user_id도 같은 값이라, 같은 realm에 연계했다면 이 값을 그대로 보내면 된다.
8-3. 전체 흐름
LibreChat ── 공유 키 + X-User-Id + x-litellm-end-user-id ──> LiteLLM
├─ Internal User: Your Usage, /user/*
├─ End User: admin Customer 뷰, /customer/*
└─ 키: /key/info (전체 합계)
8-4. 앱 연계 구성
앞의 테스트는 curl로 헤더를 직접 넣은 것이다. 실제 서비스에서는 앱이 그 헤더를 대신 보낸다. 앱, LiteLLM, 인증 서버(IdP) 세 곳이 같은 사용자 식별자를 공유하도록 연결하면 된다. 여기서는 앱으로 LibreChat, IdP로 Keycloak을 예로 든다. LiteLLM과 LibreChat은 모두 Keycloak으로 로그인하도록 연계되어 있어서, 사용자는 두 곳에서 Keycloak 계정 하나를 쓴다.
| 구성 요소 | 하는 일 | 설정 |
|---|---|---|
| IdP (Keycloak) | 로그인 사용자의 사용자명을 발급한다 | 사용자명 = LiteLLM user_id |
| 앱 (LibreChat) | 사용자명을 LiteLLM 호출 헤더에 담아 보낸다 | 엔드포인트 headers |
| LiteLLM | 헤더를 Internal User, End User로 기록한다 | general_settings.user_header_mappings |
IdP(preferred_username=lisa) ─ OIDC ─> 앱(username=lisa)
│ 공유 virtual key
│ X-User-Id: lisa
│ x-litellm-end-user-id: lisa
▼
LiteLLM ─> Internal User(lisa) + End User(lisa)
LiteLLM 쪽에 user_header_mappings(8-1)를 먼저 적용하고, LiteLLM을 호출하는 앱의 커스텀 엔드포인트 headers에 두 헤더를 추가한다.
# librechat.yaml
endpoints:
custom:
- name: "LiteLLM"
apiKey: "${LITELLM_API_KEY}" # 모든 사용자가 공유하는 virtual key (환경변수로 참조)
baseURL: "http://<litellm-host>:4000"
models:
default: ["<model-name>"]
headers:
x-litellm-end-user-id: "{{LIBRECHAT_USER_USERNAME}}" # End User(Customer) 집계
X-User-Id: "{{LIBRECHAT_USER_USERNAME}}" # Internal User 집계
apiKey에는 키 값을 직접 쓰지 않고 환경변수나 secret으로 참조한다.
9. 조회 방법
9-1. 관리자: 전체와 사용자별 한곳에서
End User 집계를 쓰면 관리자가 사용자 전체를 한 번에 볼 수 있다. end_user_ids를 생략하면 전체 합계와 사용자별 내역이 함께 온다.
curl -s "$LITELLM_URL/customer/daily/activity?start_date=2026-10-01&end_date=2026-10-31" \
-H "Authorization: Bearer $ADMIN_KEY" -o all.json
python3 - <<'E'
import json
d=json.load(open('all.json'))
m=d["metadata"]
ents={}
for r in d["results"]:
for k,v in (r.get("breakdown",{}).get("entities") or {}).items():
x=v["metrics"]; e=ents.setdefault(k,[0,0,0.0])
e[0]+=x.get("successful_requests",0); e[1]+=x.get("total_tokens",0); e[2]+=x.get("spend",0)
def row(k,req,tok,spend): print(f"{k:24} requests={req:3} tokens={tok:7} spend={spend:.7f}")
row("Total",m["total_successful_requests"],m["total_tokens"],m["total_spend"])
print("-"*65)
for k,(req,tok,spend) in sorted(ents.items()): row(k,req,tok,spend)
E
- 전체 합계:
metadata.total_* - 사용자별 내역:
results[].breakdown.entities
2026년 10월 한 달을 조회한 결과다.
Total requests= 61 tokens= 175700 spend=0.0584680
--------------------------------------------------------------------
jane requests= 1 tokens= 24 spend=0.0000358
john requests= 4 tokens= 3323 spend=0.0014523
lisa requests= 5 tokens= 133 spend=0.0002115
...
키 단위 전체 합계는 /key/info의 spend로도 볼 수 있다. 테스트에서 키 spend는 그 키로 나간 모든 요청의 합과 정확히 일치했다. UI에서는 Usage 화면의 Top Virtual Keys 패널이나 Key Activity 탭에서 키 기준으로 볼 수 있다.

9-2. 사용자: 본인 usage
사용자 본인은 UI의 “Your Usage"에서 본인의 usage를 본다. API로는 본인 소유 키로 /user/daily/activity를 호출한다. user_id는 생략하면 키 소유자(본인)로 정해진다.
# LISA_KEY: lisa 소유의 default 키 (read_only 키는 이 경로를 호출할 수 없다)
curl -s "$LITELLM_URL/user/daily/activity?start_date=2026-10-01&end_date=2026-10-31" \
-H "Authorization: Bearer $LISA_KEY" \
| python3 -c '
import sys,json
m=json.load(sys.stdin)["metadata"]
print({k:m[k] for k in ("total_api_requests","total_tokens","total_spend")})'
{'total_api_requests': 9, 'total_tokens': 213, 'total_spend': 0.00031495000000000004}

9-3. 월별 리포트
MONTH_START=2026-10-01; MONTH_END=2026-10-31
for u in john lisa jane; do
echo -n "$u "
curl -s "$LITELLM_URL/customer/daily/activity?start_date=$MONTH_START&end_date=$MONTH_END&end_user_ids=$u" \
-H "Authorization: Bearer $ADMIN_KEY" \
| python3 -c 'import sys,json; m=json.load(sys.stdin)["metadata"]; print(m["total_api_requests"], m["total_tokens"], m["total_spend"])'
done
john 4 3323 0.0014523
lisa 5 133 0.0002115
jane 1 24 3.58e-05
출력 열은 요청 수, 토큰 수, spend 순이다.
9-2의 lisa(9건)와 9-3의 lisa(5건)가 다른 것은 정상이다. 7절의 테스트에서 X-User-Id만 보낸 요청은 Internal User에만, user 필드만 보낸 요청은 End User에만 기록됐기 때문이다. 앱이 두 헤더를 항상 함께 보내면 두 값이 같아진다.
10. 운영 시 주의점
1. 식별자는 하나로 고정한다.
Keycloak 사용자명, LiteLLM user_id, LibreChat이 보내는 값이 모두 같아야 한다. 이메일을 쓰다가 사용자명으로 바꾸면 기존 이메일 이력은 이메일 항목에 남고 이후는 사용자명 항목에 쌓인다. 월별 합계를 낼 때 전환된 달은 두 항목을 합쳐야 한다.
2. 새 사용자는 LiteLLM에도 등록한다.X-User-Id 매핑은 같은 ID의 Internal User가 이미 있어야 집계되는 것으로 보고되어 있다.4 Keycloak에 새 사용자를 만들면 같은 이름의 LiteLLM 사용자도 만든다. 매핑이 실패해도 x-litellm-end-user-id로 End User 쪽에는 기록이 남을 것이므로 두 헤더를 함께 보내는 구성이 안전망이 된다(매핑 실패 상황은 테스트하지 않았다).
3. 같은 요청이 두 곳에 기록되는 것은 정상이다.
Internal User 집계와 End User 집계는 같은 요청을 다른 관점으로 센 값이라 서로 더하지 않는다. 관리자의 전체와 사용자별 현황은 End User 집계(/customer/*)로, 사용자 본인 화면은 Internal User 집계(/user/*)로 본다. 같은 사용자의 두 값이 다르면 한쪽 헤더가 빠진 요청이 있다는 신호이므로 headers 설정을 점검한다.
4. 키 소유자를 비워 두지 않는다.X-User-Id 헤더가 없는 요청은 사용자를 알 수 없다. 키에 소유자가 없으면 이런 요청은 키 spend에만 쌓이고 어느 사용자 usage에서도 보이지 않는다. 서비스 계정을 키 소유자로 두면 사용자가 식별되지 않은 요청이 그 계정의 usage에 모여서 따로 확인할 수 있다.
11. 마치며
LiteLLM의 사용량 추적은 키 하나가 아니라 키, Internal User, End User, 로그의 네 층이 같은 요청을 다른 방식으로 기록한다는 모델로 이해하면 쉽다. 키 하나를 공유해도 요청마다 “누구"를 담은 헤더를 두 가지로 보내면, 관리자는 End User 집계로 전체와 사용자별을 한곳에서 보고, 사용자는 Internal User 집계로 본인 계정 화면에서 본인의 usage를 볼 수 있다.
한 가지 한계는 남는다. 키 소유 계정에는 전체가 합산되지 않는다. 전체는 키 단위 spend나 customer 집계로 본다. 그리고 이 구조는 모든 요청에 두 헤더가 빠짐없이 들어간다는 전제 위에 있다. 앱 설정(headers)이 이 전제를 지키는 지점이므로, 사용자 계정 체계가 바뀔 때 가장 먼저 점검해야 한다.
12. 참고문헌
LiteLLM, Virtual Keys — 키의 spend는 키 테이블에, 키에
user_id나team_id가 있으면 그 사용자나 팀의 spend도 함께 추적된다. ↩︎LiteLLM, Customers / End-Users — customer ID를 찾는 순서(전용 헤더,
user_header_mappings, 요청 본문 필드)와/customer/info사용법을 설명한다. ↩︎LiteLLM, Open WebUI 튜토리얼 —
general_settings.user_header_mappings로 헤더를internal_user나customer역할에 매핑하는 설정 예시를 제공한다. ↩︎BerriAI/litellm, Issue #14667 —
user_header_mappings가 의도대로 동작하지 않는다는 보고. 헤더 값과 같은 ID의 LiteLLM 사용자가 미리 있어야 집계됐다는 댓글이 있으며, 메인테이너 답변 없이 닫혔다. ↩︎