LLM Registry API가 받는 요청과 돌려주는 응답을 엔드포인트마다 정확히 적은 명세다. 요청 필드, 응답 필드, 상태 코드와 오류 메시지를 실제 응답과 함께 싣는다. 처음 쓰는 사람은 API 설명을 먼저 읽고, 이 페이지에서는 필요한 항목을 찾아 본다.
예시 응답은 모두 2026년 11월 9일 실서버에서 받은 것이다. 모델 이름·등급·제원은 그날의 값이라 바뀔 수 있으니, 지금 값은 GET /v1/models로 확인한다. 긴 값과 이 문서가 다루지 않는 값은 …로 줄였다.
API의 기준 주소(Base URL)는 하나다. 사내망에서도 외부에서도 같은 주소를 쓴다.
https://llm.kephas.kr/v1
이 페이지의 경로는 모두 이 호스트를 기준으로 적는다. GET /v1/models는 https://llm.kephas.kr/v1/models로 보내는 GET 요청이다. 예시의 curl 명령은 키를 환경변수 OPENAI_API_KEY에 넣어 두었다고 가정한다. openai 라이브러리가 키를 읽는 변수 이름과 같다.
키는 요청마다 Authorization 헤더에 Bearer 방식으로 넣는다. 키는 sk-로 시작하는 문자열이다. 이 API에서 키 없이 받는 경로는 GET /healthz 하나뿐이다.
Authorization: Bearer sk-…
키가 없거나, 등록되지 않은 키이거나, 정지·만료된 키면 401이다. 사유마다 메시지가 다르며 401에 모두 적었다. 키마다 쓸 수 있는 모델의 범위가 정해져 있고, 범위 밖의 모델을 부르면 403이다. GET /v1/models는 그 키로 쓸 수 있는 모델만 보여 준다. 키는 쓸 사람이 사내망에서 키 발급·관리 페이지를 열어 직접 발급하고, 정지·재개·연장·삭제도 그 페이지에서 한다. 공인 주소로 연 그 페이지에서는 발급과 관리가 막히고, 사내에서 직접 여는 주소가 안내된다. 발급한 키는 사내에서도 외부에서도 같은 Base URL https://llm.kephas.kr/v1로 쓴다.
요청 본문은 UTF-8 JSON이며 Content-Type: application/json 헤더를 붙여 보낸다. 응답 본문도 UTF-8 JSON이다. 대화 생성을 스트리밍으로 요청했을 때만 응답이 text/event-stream으로 온다(스트리밍).
필드의 이름과 뜻은 OpenAI API와 같다. 이 서비스가 OpenAI API에 더한 것은 셋이다. GET /v1/models의 모델마다 붙는 등급·제원 필드, 등급과 조건으로 모델 하나를 고르는 GET /v1/pick, 그리고 model 필드에 모델 이름 대신 등급을 쓰는 grade: 표기다.
llm.kephas.kr에서 API 요청을 받아 처리하는 앱을 이 페이지에서는 입구라고 부른다. 입구가 직접 답하는 것은 다음뿐이다.
GET /v1/models, GET /v1/models/{id}, GET /v1/pick, GET /healthzmodel이 grade:로 시작하는 호출에서 등급을 검사하고 실제 모델 이름으로 바꾸는 단계나머지는 입구가 손대지 않고 모델 쪽으로 넘기고, 돌아온 응답을 그대로 돌려준다. 모델 이름으로 부르는 대화 생성·프롬프트 이어 쓰기·임베딩, 그리고 grade:를 이름으로 바꾼 뒤의 호출이 여기에 든다.
이 구분은 오류를 읽을 때 쓸모가 있다. 입구가 만든 오류는 메시지가 한국어이고 code가 숫자다. 모델 쪽에서 온 오류는 메시지가 영어이고 code가 문자열이다(오류 형식).
Base URL을 /v1 없이 https://llm.kephas.kr까지만 넣는 도구도 있다. 그런 도구를 위해 다음 다섯 경로는 /v1을 빼고 불러도 붙여 부른 것과 똑같이 동작하며, grade: 표기도 그대로 쓸 수 있다.
| /v1 없는 경로 | 같은 동작 |
|---|---|
GET /models | GET /v1/models |
GET /models/{id} | GET /v1/models/{id} |
POST /chat/completions | POST /v1/chat/completions |
POST /completions | POST /v1/completions |
POST /embeddings | POST /v1/embeddings |
GET /v1/pick은 반드시 /v1을 붙여 부른다. /pick이라는 경로는 없다.
| 요청 본문 | 32MB까지 받는다. 넘으면 입구가 본문을 끝까지 읽지 않고 연결을 끊고, 클라이언트는 본문이 빈 502를 받는다(본문 크기 초과). base64로 넣은 이미지도 본문 크기에 든다. |
|---|---|
| 요청 시간 | 한 요청은 최대 600초까지 처리한다. |
| 입력 길이 | 모델마다 ctx(최대 컨텍스트 토큰 수)가 있다. 대화 생성은 입력과 만들 토큰을 합쳐, 임베딩은 입력 하나하나가 그 안에 들어가야 한다. |
| 속도·비용 한도 | 한도를 둔 키에만 적용된다(429, 422). |
한동안 쓰이지 않은 모델은 처음 부를 때 서버가 모델을 메모리에 올리느라 첫 응답이 수십 초 걸릴 수 있다. 이어지는 호출은 빠르다. 품질 1 모델은 크고 느리다. 모델 서버는 여러 사람이 함께 쓰므로 큰 작업이 몰리면 느려질 수 있다.
| 메서드와 경로 | 하는 일 | 처리하는 곳 |
|---|---|---|
GET /v1/models | 이 키로 쓸 수 있는 모델의 목록과 등급·제원 | 입구 |
GET /v1/models/{id} | 모델 하나의 정보 | 입구 |
GET /v1/pick | 등급과 조건으로 모델 하나 고르기 | 입구 |
POST /v1/chat/completions | 대화 생성. 스트리밍, 도구 호출, 이미지 입력을 포함한다 | 모델 쪽. grade:는 입구가 이름으로 바꿔 넘긴다(등급으로 부르기) |
POST /v1/completions | 프롬프트 이어 쓰기(OpenAI의 예전 형식) | 모델 쪽. grade:는 위와 같다 |
POST /v1/embeddings | 글을 벡터로 바꾸기 | 모델 쪽. grade:는 위와 같다 |
GET /healthz | 입구가 살아 있는지. 키가 필요 없다 | 입구 |
이 표에 없는 경로는 이 서비스가 제공하는 API가 아니다. 응답이 오더라도 형식과 동작을 보장하지 않으며 예고 없이 막힐 수 있다. 없는 /v1 경로는 404, 경로는 있으나 메서드가 맞지 않으면 405다(404, 405).
오류는 HTTP 상태 코드와 함께 다음 모양의 JSON으로 온다.
{"error":{"message":"…","type":"…","code":…}}
| 필드 | 형식 | 뜻 |
|---|---|---|
message | 문자열 | 사람이 읽는 설명. 입구가 만든 오류는 한국어, 모델 쪽에서 온 오류는 영어다. 문구는 바뀔 수 있으므로 프로그램은 문구로 판단하지 않는다. |
type | 문자열 | 오류의 종류. 상태 코드마다 나오는 값은 상태 코드에 있다. |
code | 숫자 또는 문자열 | HTTP 상태 코드와 같은 값. 입구가 만든 오류는 숫자(401), 모델 쪽에서 온 오류는 문자열("401")이다. |
param | 문자열 또는 null | 모델 쪽에서 온 오류에만 붙는다. 문제가 된 요청 필드의 이름("model", "messages" 등)이 들어가고, 해당하는 필드가 없으면 null이나 "None"이다. |
같은 401이라도 어디서 막혔느냐에 따라 두 모양이 다 나온다. 아래는 키 없이 보낸 두 요청의 실제 응답이다. 위는 입구가 답한 것이고, 아래는 모델 이름으로 불러서 모델 쪽이 답한 것이다.
curl https://llm.kephas.kr/v1/models
{"error":{"message":"Authorization: Bearer <키>가 필요하다","type":"authentication_error","code":401}}
curl https://llm.kephas.kr/v1/chat/completions -H "Content-Type: application/json" \
-d '{"model":"qwen3-8b","messages":[{"role":"user","content":"x"}]}'
{"error":{"message":"Authentication Error, No api key passed in.","type":"auth_error","param":"None","code":"401"}}
그러므로 오류는 HTTP 상태 코드로 판단한다. code를 읽어야 한다면 숫자와 문자열을 모두 받도록 짠다.
본문이 32MB를 넘은 요청만 이 모양을 따르지 않는다. 본문이 빈 502로 끝난다.
이 서비스가 뜻을 정해 두고 붙이는 응답 헤더는 다음이다.
| 헤더 | 붙는 때 | 뜻 |
|---|---|---|
x-kephas-picked-model | grade:로 부른 호출 | 입구가 고른 모델의 이름. 예 gemma3-4b |
x-kephas-picked-grade | grade:로 부른 호출 | 고른 모델의 등급. 요청에 쓴 등급이 아니라 고른 모델의 실제 등급이다. 예 member 범위 키로 grade:C?min_ctx=100000을 부르면 C3 |
retry-after | 429 | 다시 불러도 되기까지 기다릴 초. 예 60. 함께 오는 reset_at은 한도가 풀리는 시각(UTC)이다 |
x-kephas-로 시작하는 두 헤더는 모델을 고른 뒤에 붙는다. 그래서 고른 다음 모델 쪽에서 오류가 나도 붙어 있다(429에서 확인했다). 등급 검사나 고르기에서 실패한 400·403·404에는 없고, 모델 이름으로 부른 호출에도 없다.
그 밖의 헤더(date, content-type, content-length 등)는 일반 HTTP 헤더다. 이 표에 없는 헤더에는 의존하지 않는다.
GET /v1/models는 이 키로 부를 수 있는 모델의 목록을 돌려준다. OpenAI의 모델 목록 형식에 등급과 제원 필드가 덧붙는다. 키의 범위가 다르면 목록도 다르다.
curl https://llm.kephas.kr/v1/models -H "Authorization: Bearer $OPENAI_API_KEY"
인자와 본문은 없다.
200과 함께 {"object":"list","data":[…]}가 온다. data의 모델 객체는 다음 필드를 가진다.
| 필드 | 형식 | 뜻 |
|---|---|---|
id | 문자열 | 모델 이름. 호출의 model 필드에 이 값을 그대로 쓴다. |
object | 문자열 | 항상 "model". |
created | 정수 | OpenAI 형식을 맞추려고 넣는 값이다. 모든 모델이 같은 값(1677610602)이며, 모델이 등록된 시각이 아니다. |
owned_by | 문자열 | OpenAI 형식을 맞추려고 넣는 값이다("openai"). 모델을 만든 곳을 뜻하지 않는다. |
grade | 문자열 | 등급. 종류 한 글자와 품질 한 글자다(예 C2). 종류는 C 대화·추론·문서 작성, K 코드 생성·수정, E 임베딩, V 이미지 입력이다. 품질은 1 사내 최상, 2 범용, 3 경량, X 실험이다. 자세한 뜻은 API 설명에 있다. |
ctx | 정수 | 최대 컨텍스트 토큰 수. |
dim | 정수 또는 null | 임베딩 벡터의 길이. 임베딩 모델만 값이 있고, 나머지는 null이다. |
langs | 문자열 배열 | 지원하는 언어 코드. 예 ["ko","en"]. |
tools | 불리언 | 도구 호출(function calling)을 지원하면 true. |
note | 문자열 | 짧은 설명. |
모델에 따라 mode(임베딩 모델은 "embedding"), max_input_tokens, max_output_tokens가 더 붙기도 한다. 모든 모델에 있는 필드가 아니므로, 모델을 판단할 때는 grade·ctx·dim·tools·langs를 쓴다.
grade:C2 같은 등급 항목은 목록에 없다. 목록의 항목은 모두 실제 모델이다.member 범위 키로 받은 실제 응답이다. 가운데 모델 둘은 줄였다.
{"object":"list","data":[
{"id":"qwen3-8b","object":"model","created":1677610602,"owned_by":"openai","grade":"C2","ctx":40960,"dim":null,"langs":["ko","en"],"tools":true,"note":"기본값(Q4_K_M)"},
…,
{"id":"qwen3-embedding-0.6b","object":"model","created":1677610602,"owned_by":"openai","mode":"embedding","grade":"E2","ctx":32768,"dim":1024,"langs":["ko","en"],"tools":false,"note":"다국어 임베딩"}]}
키가 없거나 등록되지 않았거나 정지·만료됐으면 401이다(401). 비용 한도를 넘은 키는 502를 받는다(422).
GET /v1/models/{id}는 모델 이름 하나로 그 모델의 정보를 돌려준다. 이 키로 쓸 수 있는 모델이면 모델 목록의 항목과 같은 객체가 온다.
| 자리 | 이름 | 뜻 |
|---|---|---|
| 경로 | id | 모델 이름. 특수 문자는 URL 인코딩한다. 등급(grade:C2)은 해석하지 않는다. |
curl https://llm.kephas.kr/v1/models/qwen3-8b -H "Authorization: Bearer $OPENAI_API_KEY"
{"id":"qwen3-8b","object":"model","created":1677610602,"owned_by":"openai","grade":"C2","ctx":40960,"dim":null,"langs":["ko","en"],"tools":true,"note":"기본값(Q4_K_M)"}
없는 이름이거나, 있지만 이 키로 쓸 수 없는 모델이면 404다. 둘을 구분하지 않는다. grade:C2처럼 등급을 넣어도 그런 이름의 모델이 없으므로 404다. 아래는 member 범위 키로 그 범위에 없는 gpt-oss-120b를 조회한 실제 응답이다.
HTTP/1.1 404 Not Found
{"error":{"message":"모델이 없거나 이 키로 쓸 수 없다: gpt-oss-120b","type":"invalid_request_error","code":404}}
경로의 퍼센트 인코딩이 깨져 있으면(예 /v1/models/%E0%A4%A) 400이다.
HTTP/1.1 400 Bad Request
{"error":{"message":"모델 id 인코딩이 잘못됐다","type":"invalid_request_error","code":400}}
키 문제는 401이다.
GET /v1/pick은 등급과 조건을 받아, 이 키로 쓸 수 있는 모델 가운데 하나를 골라 그 이름과 제원을 돌려준다. 모델을 부르지는 않는다.
서비스 코드는 시작할 때 한 번 pick해서 고른 이름을 로그에 남기고, 그 뒤로는 그 이름으로 부르기를 권한다. 그러면 실행 중에 고르는 결과가 바뀌어 동작이 달라지는 일이 없고, 어느 모델이 답했는지 로그에 남는다.
모두 쿼리 문자열로 준다. 값이 빈 인자(max_quality=)는 주지 않은 것과 같고, 표에 없는 인자는 무시한다.
| 인자 | 형식과 기본값 | 뜻 | 잘못 주면 |
|---|---|---|---|
grade (필수) | 종류 한 글자(C K E V), 또는 종류와 품질(1 2 3 X) 두 글자. 대소문자를 가리지 않는다(c2는 C2와 같다). | 종류만 주면 그 종류의 모든 품질이 후보다. 품질까지 주면 그 품질만 후보다. | 400 grade는 C/K/E/V 한 글자 또는 종류+품질(C2, E1, CX)이어야 한다 |
max_quality | 1, 2, 3 중 하나. 주지 않으면 조건이 없다. | 이 품질보다 좋은(숫자가 작은) 등급을 후보에서 뺀다. 2면 품질 1을 빼고 2·3 가운데서 고른다. X 등급은 이 인자로 빠지지 않는다. | 400 max_quality는 1·2·3 중 하나 |
min_ctx | 양의 정수. 주지 않으면 조건이 없다. | ctx가 이 값 이상인 모델만 후보다. | 0, 음수, 소수, 숫자가 아닌 값은 400 min_ctx는 양의 정수 |
tools | true 또는 1. 주지 않으면 조건이 없다. | 도구 호출을 지원하는 모델만 후보다. false나 그 밖의 값은 조건을 주지 않은 것과 같다. | 오류 없음 |
langs | 쉼표로 구분한 언어 코드. 예 ko, ko,en. 대소문자를 가린다. | 적은 언어를 모두 지원하는 모델만 후보다. | 오류 없음. 맞는 모델이 없으면 404 |
allow_x | true 또는 1. 기본은 false. | 실험(X) 등급 모델도 후보에 넣는다. grade에 X를 직접 쓰면(CX) 이 인자 없이도 들어간다. | 오류 없음 |
인자 오류인 400의 type은 model_pick_failed다.
grade에 품질까지 적었으면 품질도 같다.allow_x를 켰거나 grade에 X를 적었을 때만 든다.max_quality를 줬으면 그보다 좋은 품질이 아니다.min_ctx를 줬으면 ctx가 그 이상이다.tools를 켰으면 도구 호출을 지원한다.langs를 줬으면 그 언어를 모두 지원한다.그래서 404와 403은 뜻이 다르다. 404는 레지스트리 전체에 그 조건을 만족하는 모델이 없다는 뜻이므로 조건을 완화한다. 403은 조건을 만족하는 모델이 있지만 이 키의 범위 밖이라는 뜻이므로, 조건을 바꾸거나 그 모델을 쓸 수 있는 범위로 새 키를 발급한다.
max_quality는 종류만 적은 grade와 함께 쓰는 인자다. grade=C2&max_quality=3처럼 grade의 품질보다 큰 숫자를 주면 그 품질이 후보에서 빠져 404가 된다.
| 필드 | 형식 | 뜻 |
|---|---|---|
object | 문자열 | 항상 "model.pick". |
id | 문자열 | 고른 모델의 이름. 호출의 model 필드에 그대로 쓴다. |
grade, ctx, dim, langs, tools, note | 모델 목록의 같은 필드와 같다. | |
reason | 문자열 | 이 모델을 고른 까닭. 아래 표의 셋 중 하나다. |
reason | 뜻 |
|---|---|
best-quality | 이 키로 쓸 수 있는 후보 가운데 가장 좋은 품질의 모델이 하나뿐이어서 그것을 골랐다. |
default | 가장 좋은 품질의 후보가 여럿이었고, 그 가운데 레지스트리에서 기본으로 표시된 모델을 골랐다. |
first-registered | 가장 좋은 품질의 후보가 여럿이었는데 그 가운데 기본 표시 모델이 없어서, 레지스트리에 먼저 등록된 모델을 골랐다. |
member 범위 키로 부른 실제 응답이다. 이 키로 쓸 수 있는 C 모델 가운데 가장 좋은 품질은 C2이고 C2 모델이 하나뿐이라 best-quality다.
curl "https://llm.kephas.kr/v1/pick?grade=C" -H "Authorization: Bearer $OPENAI_API_KEY"
{"object":"model.pick","id":"qwen3-8b","grade":"C2","ctx":40960,"dim":null,"langs":["ko","en"],"tools":true,"note":"기본값(Q4_K_M)","reason":"best-quality"}
조건을 더하면 후보가 줄어든다. max_quality=2로 품질 1을 빼고 min_ctx=50000으로 컨텍스트가 짧은 모델을 빼자, ctx가 40960인 C2 모델이 빠지고 C3 모델이 골라졌다.
curl "https://llm.kephas.kr/v1/pick?grade=C&max_quality=2&min_ctx=50000" -H "Authorization: Bearer $OPENAI_API_KEY"
{"object":"model.pick","id":"gemma3-4b","grade":"C3","ctx":131072,"dim":null,"langs":["ko","en"],"tools":false,"note":"경량·빠름(Q4_K_M)","reason":"best-quality"}
같은 키로 부른 다른 요청의 결과다.
| 쿼리 | 상태 | 결과 |
|---|---|---|
grade=c2 | 200 | qwen3-8b, best-quality. 소문자도 같다. |
grade=C&max_quality=3 | 200 | gemma3-4b(C3), best-quality. |
grade=C&langs=ko,en | 200 | qwen3-8b, best-quality. |
grade=E | 200 | qwen3-embedding-0.6b(E2, dim 1024), best-quality. |
grade=V&tools=yes | 200 | qwen2.5-vl-8b(V2, tools false). yes는 조건으로 치지 않는다. |
grade=V&tools=true | 404 | 도구 호출을 지원하는 V 모델이 없다. |
grade=C&langs=KO | 404 | 언어 코드는 대소문자를 가린다. |
grade=C2&max_quality=3 | 404 | C2가 상한 3보다 좋아서 빠졌다. |
grade=K | 404 | 이날 K 등급 모델이 없었다. |
grade=C1 | 403 | C1 모델은 있으나 member 범위 밖이다. |
grade=CX | 403 | CX 모델은 있으나 member 범위 밖이다. |
grade=Z, grade=C22, grade 없음 | 400 | grade 형식 오류. |
grade=C&max_quality=0 | 400 | max_quality 오류. |
grade=C&min_ctx=1.5 | 400 | min_ctx 오류. |
developer 범위 키로 grade=CX를 부르면, CX 모델이 여럿이고 그 가운데 기본 표시 모델이 없어서 먼저 등록된 모델이 골라진다.
curl "https://llm.kephas.kr/v1/pick?grade=CX" -H "Authorization: Bearer $OPENAI_API_KEY"
{"object":"model.pick","id":"exaone-deep-7.8b","grade":"CX","ctx":32768,"dim":null,"langs":["ko","en"],"tools":false,"note":"추론 특화, 검증 전","reason":"first-registered"}
이날 레지스트리에서는 reason이 default로 나오는 조합이 없었다. 기본으로 표시된 모델(qwen3-8b)과 같은 품질의 C 모델이 따로 없었기 때문이다.
pick이 만드는 오류는 401을 빼면 모두 type이 model_pick_failed다. 실제 응답이며, 줄 앞의 숫자는 HTTP 상태 코드다.
400 {"error":{"message":"grade는 C/K/E/V 한 글자 또는 종류+품질(C2, E1, CX)이어야 한다","type":"model_pick_failed","code":400}}
400 {"error":{"message":"max_quality는 1·2·3 중 하나","type":"model_pick_failed","code":400}}
400 {"error":{"message":"min_ctx는 양의 정수","type":"model_pick_failed","code":400}}
404 {"error":{"message":"조건에 맞는 모델이 없다. 조건을 완화하라(품질·min_ctx·tools·langs, 실험 모델은 allow_x=true)","type":"model_pick_failed","code":404}}
403 {"error":{"message":"조건에 맞는 모델은 있으나 이 키로는 쓸 수 없다","type":"model_pick_failed","code":403}}
대화 생성·프롬프트 이어 쓰기·임베딩 호출의 model 필드에 모델 이름 대신 grade:로 시작하는 등급을 쓰면, 입구가 pick과 같은 규칙으로 모델을 골라 그 이름으로 바꿔 넘긴다. SDK나 도구의 설정 화면처럼 모델 이름 칸 하나만 있는 곳에서 등급으로 부를 때 쓴다.
grade:<등급> grade:<등급>?<조건>
grade:C2 grade:E grade:C?max_quality=2&tools=true grade:C?min_ctx=65536&langs=ko
<등급>은 pick의 grade와 같다. 등급 글자는 대소문자를 가리지 않는다(grade:e는 grade:E와 같다).<조건>은 pick의 나머지 인자(max_quality, min_ctx, tools, langs, allow_x)를 쿼리 문자열로 적은 것이다. 뜻과 검사도 pick과 같다.grade:는 소문자로 쓴다. GRADE:C2는 등급으로 읽히지 않고 그런 이름의 모델을 부른 것이 되어 403 key_model_access_denied가 된다.등급의 종류는 엔드포인트와 맞아야 한다. 맞지 않으면 400이다.
| 엔드포인트 | 쓸 수 있는 종류 | 맞지 않을 때의 메시지 |
|---|---|---|
POST /v1/chat/completions, POST /v1/completions | C, K, V | 대화·완성에는 C/K/V 등급만 쓸 수 있다(요청: grade:E) |
POST /v1/embeddings | E | 임베딩에는 E 등급만 쓸 수 있다(요청: grade:C) |
HTTP/1.1 400 Bad Request
{"error":{"message":"임베딩에는 E 등급만 쓸 수 있다(요청: grade:C)","type":"invalid_request_error","code":400}}
메시지의 요청: 뒤에는 보낸 값이 적은 그대로 들어간다(grade:e2로 보내면 grade:e2). /v1 없는 경로에서도 똑같이 동작한다. GET /v1/models/{id}를 비롯한 다른 경로는 grade:를 해석하지 않는다.
model이 grade:로 시작하지 않으면 아무것도 하지 않고 모델 쪽으로 넘긴다.grade:Z) 이 단계를 건너뛰고 4단계에서 400이 된다.type은 model_pick_failed다(pick 오류).model을 고른 모델의 이름으로 바꿔 모델 쪽으로 넘기고, 응답에 x-kephas-picked-model과 x-kephas-picked-grade 헤더를 붙인다. 응답 본문의 model에도 고른 모델의 실제 이름이 들어 있다.호출할 때마다 다시 고른다. 입구는 등급·제원 정보를 60초 동안 기억해 두고 쓰므로, 레지스트리에서 등급이 바뀌면 늦어도 60초 뒤부터 고르는 결과에 반영된다. 같은 grade:C2라도 그사이 등록이 바뀌면 다른 모델이 답할 수 있다. 결과가 일정해야 하는 서비스 코드는 grade: 대신, 시작할 때 한 번 pick한 이름으로 부른다.
curl -i https://llm.kephas.kr/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" -H "Content-Type: application/json" \
-d '{"model":"grade:C3","messages":[{"role":"user","content":"안녕"}],"max_tokens":3}'
HTTP/2 200
content-type: application/json
x-kephas-picked-grade: C3
x-kephas-picked-model: gemma3-4b
…
{"id":"chatcmpl-204","created":1794188230,"model":"gemma3-4b","object":"chat.completion","system_fingerprint":"…","choices":[{"finish_reason":"length","index":0,"message":{"content":"안녕하세요! 무엇","role":"assistant","provider_specific_fields":{"refusal":null}},"provider_specific_fields":{}}],"usage":{"completion_tokens":3,"prompt_tokens":12,"total_tokens":15,"prompt_tokens_details":{"cached_tokens":7}}}
max_tokens를 3으로 줘서 답이 중간에 끊겼고, 그래서 finish_reason이 length다.
POST /v1/chat/completions는 메시지 목록을 받아 다음 답을 만든다. 요청과 응답은 OpenAI Chat Completions와 같다. 입구는 grade:를 이름으로 바꾸는 일 말고는 요청을 고치지 않고 모델 쪽으로 넘긴다.
curl https://llm.kephas.kr/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" -H "Content-Type: application/json" \
-d '{"model":"grade:C3","messages":[{"role":"user","content":"한 단어로 인사해."}],"max_tokens":20}'
자주 쓰는 필드다. 마지막 칸은 이 문서를 쓰면서 실서버에서 직접 확인한 결과다.
| 필드 | 형식 | 뜻 | 확인 |
|---|---|---|---|
model (필수) | 문자열 | 모델 이름(모델 목록의 id) 또는 grade:…. | 확인함 |
messages (필수) | 배열 | {"role":…,"content":…}의 목록. role은 system, user, assistant, tool이다. content는 문자열, 또는 글과 이미지를 담은 배열이다(이미지 입력). | 네 역할 모두 확인함 |
max_tokens | 정수 | 만들 최대 토큰 수. 생각 모델은 생각 토큰도 여기에 든다(생각 모델). | 확인함 |
stream | 불리언 | true면 답을 조각으로 나눠 SSE로 흘려보낸다(스트리밍). | 확인함 |
stream_options | 객체 | {"include_usage":true}면 스트리밍 끝에 토큰 수를 담은 조각이 하나 더 온다. | 확인함 |
temperature, top_p | 수 | 표본을 뽑을 때의 무작위 정도. | 받는 것을 확인함(효과는 재지 않았다) |
stop | 문자열 또는 문자열 배열 | 이 문자열이 나오면 그 앞에서 멈춘다. 멈춘 문자열은 답에 들지 않는다. | 확인함 |
tools, tool_choice | 배열, 문자열 또는 객체 | 도구 호출. tools가 true인 모델에 쓴다(도구 호출). | 확인함 |
response_format | 객체 | {"type":"json_object"}면 JSON 객체로 답한다. | 확인함(프롬프트에서도 JSON을 요구한 요청으로) |
max_completion_tokens | 정수 | OpenAI의 새 이름이지만 gemma3-4b로 확인했을 때 적용되지 않았다. 길이는 max_tokens로 제한한다. | 적용되지 않음 |
| 그 밖의 OpenAI 필드 | 입구는 그대로 넘긴다. | 확인하지 않음 |
위 요청의 실제 응답이다. grade:C3로 불렀으므로 x-kephas-picked-model: gemma3-4b와 x-kephas-picked-grade: C3 헤더가 함께 왔다.
{"id":"chatcmpl-883","created":1794188136,"model":"gemma3-4b","object":"chat.completion","system_fingerprint":"…","choices":[{"finish_reason":"stop","index":0,"message":{"content":"안녕하세요!","role":"assistant","provider_specific_fields":{"refusal":null}},"provider_specific_fields":{}}],"usage":{"completion_tokens":3,"prompt_tokens":16,"total_tokens":19,"prompt_tokens_details":{"cached_tokens":0}}}
| 필드 | 형식 | 뜻 |
|---|---|---|
id | 문자열 | 응답의 id. 모양은 모델마다 다르다(예 chatcmpl-883, chatcmpl-8e368dd2122bd4df). |
object | 문자열 | 항상 "chat.completion". |
created | 정수 | 응답을 만든 시각(유닉스 시간, 초). |
model | 문자열 | 답한 모델의 이름. grade:로 불렀어도 고른 모델의 실제 이름이다. |
choices[].index | 정수 | 0부터 센다. |
choices[].message.role | 문자열 | 항상 "assistant". |
choices[].message.content | 문자열 | 답의 본문. 도구 호출만 했으면 빈 문자열이다. |
choices[].message.reasoning_content | 문자열 | 생각 모델의 생각 과정. 생각 모델일 때만 있다. |
choices[].message.tool_calls | 배열 | 모델이 부르려는 도구. 도구를 부를 때만 있다. |
choices[].finish_reason | 문자열 | stop(끝까지 만들었거나 stop 문자열을 만났다), length(max_tokens에 닿았다), tool_calls(도구를 부른다). |
usage.prompt_tokens, usage.completion_tokens, usage.total_tokens | 정수 | 입력, 출력, 합계 토큰 수. 생각 토큰은 completion_tokens에 든다. |
system_fingerprint, provider_specific_fields, prompt_tokens_details 등 | 모델에 따라 붙는 값이다. 붙는지와 내용이 모델마다 다르고 바뀔 수 있으니 의존하지 않는다. |
생각(reasoning) 모델(예: qwen3-8b, gpt-oss-120b)은 답의 본문을 만들기 전에 생각 과정을 먼저 만든다. 생각은 본문과 따로 온다.
choices[0].message.reasoning_content에 생각이, content에 본문이 들어 있다.delta.reasoning_content 조각으로 먼저 오고, 본문은 그 뒤에 delta.content 조각으로 온다.max_tokens에 든다. max_tokens가 작으면 생각만 하다 끝나서 content가 빈 문자열이고 finish_reason이 length다. 생각 모델에는 max_tokens를 넉넉히 주거나 주지 않는다.reasoning_content가 없다.qwen3-8b에 max_tokens 600으로 보낸 실제 응답이다.
curl https://llm.kephas.kr/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" -H "Content-Type: application/json" \
-d '{"model":"qwen3-8b","messages":[{"role":"user","content":"1+1은? 숫자만 답해."}],"max_tokens":600}'
{"id":"chatcmpl-786","created":1794188138,"model":"qwen3-8b","object":"chat.completion","system_fingerprint":"…","choices":[{"finish_reason":"stop","index":0,"message":{"content":"2","role":"assistant","reasoning_content":"Okay, the user is asking \"1+1은?\" which is Korean for \"What is 1+1?\" … Alright, time to respond.\n","provider_specific_fields":{…}},"provider_specific_fields":{}}],"usage":{"completion_tokens":119,"prompt_tokens":21,"total_tokens":140,"prompt_tokens_details":{"cached_tokens":1}}}
같은 요청을 max_tokens 16으로 보내면 생각만 하다 끝난다.
{"id":"chatcmpl-580","created":1794188138,"model":"qwen3-8b","object":"chat.completion","system_fingerprint":"…","choices":[{"finish_reason":"length","index":0,"message":{"content":"","role":"assistant","reasoning_content":"Okay, the user is asking \"1+1은? 숫자","provider_specific_fields":{…}},"provider_specific_fields":{}}],"usage":{"completion_tokens":16,"prompt_tokens":21,"total_tokens":37,"prompt_tokens_details":{"cached_tokens":3}}}
스트리밍 끝의 토큰 수 조각에 completion_tokens_details.reasoning_tokens가 오기도 한다. 그러나 모델에 따라 생각을 했는데도 0으로 오므로(gpt-oss-120b에서 확인) 생각의 분량을 재는 데 쓰지 않는다.
"stream":true를 주면 응답이 content-type: text/event-stream; charset=utf-8로 온다. 본문은 서버 전송 이벤트(SSE)다. 조각 하나가 data: 로 시작하는 한 줄이고, 조각과 조각 사이에 빈 줄이 하나 있다. 조각의 object는 "chat.completion.chunk"이고, 새로 만든 부분은 choices[0].delta에 들어 있다.
delta에 "role":"assistant"가 온다. 모델에 따라 첫 조각에 생각이나 본문의 첫 부분이 함께 오기도 한다.delta.reasoning_content 조각이 이어진다.delta.content 조각으로 온다. 조각을 차례로 이어 붙이면 답 전체가 된다.delta가 빈 객체이고 finish_reason이 있는 조각이 온다.stream_options에 "include_usage":true를 줬을 때만 그다음에 usage를 담은 조각이 하나 더 온다. 이 조각의 choices에는 빈 delta 하나가 들어 있다. 주지 않으면 이 조각은 없다.data: [DONE]이다.qwen3-8b에 grade:C2로 보낸 실제 응답이다. 가운데의 생각 조각은 줄였다.
curl -N https://llm.kephas.kr/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" -H "Content-Type: application/json" \
-d '{"model":"grade:C2","messages":[{"role":"user","content":"1+1은? 숫자만 답해."}],"max_tokens":600,"stream":true,"stream_options":{"include_usage":true}}'
data: {"id":"chatcmpl-456","created":1794188140,"model":"qwen3-8b","object":"chat.completion.chunk","system_fingerprint":"…","choices":[{"index":0,"delta":{"reasoning_content":"Okay","content":"","role":"assistant"}}]}
data: {"id":"chatcmpl-456","created":1794188140,"model":"qwen3-8b","object":"chat.completion.chunk","system_fingerprint":"…","choices":[{"index":0,"delta":{"reasoning_content":",","content":""}}]}
data: {"id":"chatcmpl-456","created":1794188140,"model":"qwen3-8b","object":"chat.completion.chunk","system_fingerprint":"…","choices":[{"index":0,"delta":{"reasoning_content":" the","content":""}}]}
…
data: {"id":"chatcmpl-456","created":1794188140,"model":"qwen3-8b","object":"chat.completion.chunk","system_fingerprint":"…","choices":[{"index":0,"delta":{"reasoning_content":".\n","content":""}}]}
data: {"id":"chatcmpl-456","created":1794188140,"model":"qwen3-8b","object":"chat.completion.chunk","system_fingerprint":"…","choices":[{"index":0,"delta":{"content":"2"}}]}
data: {"id":"chatcmpl-456","created":1794188140,"model":"qwen3-8b","object":"chat.completion.chunk","system_fingerprint":"…","choices":[{"finish_reason":"stop","index":0,"delta":{}}]}
data: {"id":"chatcmpl-456","created":1794188140,"model":"qwen3-8b","object":"chat.completion.chunk","system_fingerprint":"…","choices":[{"index":0,"delta":{}}],"usage":{"completion_tokens":136,"prompt_tokens":21,"total_tokens":157,"completion_tokens_details":{"reasoning_tokens":130,"text_tokens":6},"prompt_tokens_details":{"cached_tokens":20}}}
data: [DONE]
gpt-oss-120b는 첫 조각에 역할과 빈 본문만 담고, 생각은 둘째 조각부터 보낸다. 같은 질문을 보낸 실제 응답의 앞부분이다.
data: {"id":"chatcmpl-8e368dd2122bd4df","object":"chat.completion.chunk","created":1794188143,"model":"gpt-oss-120b","choices":[{"index":0,"delta":{"role":"assistant","content":""}}]}
data: {"id":"chatcmpl-8e368dd2122bd4df","created":1794188143,"model":"gpt-oss-120b","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"reasoning_content":"User"}}]}
…
스트리밍을 요청했어도 시작 전에 난 오류(키, 등급, 허용 범위 등)는 SSE가 아니라 해당 상태 코드와 일반 JSON 오류로 온다. member 범위 키로 "stream":true와 함께 범위 밖의 gpt-oss-120b를 부른 실제 응답이다.
HTTP/1.1 403 Forbidden
content-type: application/json
{"error":{"message":"The requested model 'gpt-oss-120b' is not available for this API key, or the model name is invalid. Check the models available to you and try again.","type":"key_model_access_denied","param":"model","code":"403"}}
모델 목록에서 tools가 true인 모델(pick에서는 tools=true)에 OpenAI 형식의 tools를 주면, 모델이 도구를 부르기로 했을 때 finish_reason이 tool_calls이고 message.tool_calls에 부를 함수와 인자가 온다. arguments는 JSON을 담은 문자열이다.
curl https://llm.kephas.kr/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" -H "Content-Type: application/json" \
-d '{"model":"qwen3-8b","messages":[{"role":"user","content":"서울 날씨 알려 줘."}],
"tools":[{"type":"function","function":{"name":"get_weather","description":"도시의 현재 날씨",
"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}],
"tool_choice":"auto","max_tokens":800}'
{"id":"chatcmpl-728","created":1794188358,"model":"qwen3-8b","object":"chat.completion","system_fingerprint":"…","choices":[{"finish_reason":"tool_calls","index":0,"message":{"content":"","role":"assistant","tool_calls":[{"index":0,"function":{"arguments":"{\"city\":\"서울\"}","name":"get_weather"},"id":"call_cywfdu6n","type":"function"}],"reasoning_content":"Okay, the user is asking for the weather in Seoul. …","provider_specific_fields":{…}},"provider_specific_fields":{}}],"usage":{"completion_tokens":91,"prompt_tokens":140,"total_tokens":231,"prompt_tokens_details":{"cached_tokens":57}}}
도구는 호출한 쪽이 직접 실행한다. 실행 결과는 다음 요청의 messages에, 모델의 도구 호출(assistant)과 그 결과(role이 tool이고 tool_call_id로 호출을 가리킨다)를 차례로 넣어 보낸다. 확인한 요청에는 tools도 다시 넣었다.
"messages":[
{"role":"user","content":"서울 날씨 알려 줘."},
{"role":"assistant","content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"서울\"}"}}]},
{"role":"tool","tool_call_id":"call_1","content":"{\"temp_c\":12,\"sky\":\"맑음\"}"}
]
이 요청에 qwen3-8b는 결과를 문장으로 옮긴 답을 finish_reason stop과 함께 돌려줬다.
종류가 V인 모델(예: qwen2.5-vl-8b)에는 content를 배열로 주어 글과 이미지를 함께 보낸다. 이미지는 image_url의 url에 data:image/png;base64,… 모양의 data URL로 넣는다. 이 문서에서 확인한 것은 data URL 방식이다.
curl https://llm.kephas.kr/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" -H "Content-Type: application/json" \
-d '{"model":"grade:V","max_tokens":10,"messages":[{"role":"user","content":[
{"type":"text","text":"이 그림은 무슨 색이야? 한 단어로."},
{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo…"}}]}]}'
{"id":"chatcmpl-737","created":1794188361,"model":"qwen2.5-vl-8b","object":"chat.completion","system_fingerprint":"…","choices":[{"finish_reason":"stop","index":0,"message":{"content":"빨간색.","role":"assistant","provider_specific_fields":{"refusal":null}},"provider_specific_fields":{}}],"usage":{"completion_tokens":5,"prompt_tokens":1060,"total_tokens":1065,"prompt_tokens_details":{"cached_tokens":17}}}
보낸 이미지는 16×16 크기의 빨간 PNG였다. 이미지도 입력 토큰으로 세어진다(prompt_tokens 1060).
대화 생성에서 자주 보는 오류의 실제 응답이다. 전체는 상태 코드에 있다.
| 상태 | 원인 | 응답 |
|---|---|---|
| 400 | grade:E처럼 종류가 맞지 않는 등급 | {"error":{"message":"대화·완성에는 C/K/V 등급만 쓸 수 있다(요청: grade:E)","type":"invalid_request_error","code":400}} |
| 400 | messages가 없다 | {"error":{"message":"/chat/completions: Missing required parameter: 'messages'.","type":"invalid_request_error","param":"messages","code":"400"}} |
| 400 | model이 없다 | {"error":{"message":"/chat/completions: Invalid model name passed in model=None. Call `/v1/models` to view available models for your key.","type":"invalid_request_error","param":null,"code":"400",…}} |
| 400 | 본문이 JSON이 아니다 | {"error":{"message":"Invalid JSON payload: unexpected character: line 1 column 22 (char 21)","type":"invalid_request_error","param":"request_body","code":"400"}} |
| 403 | 이 키로 쓸 수 없거나 없는 모델 이름 | {"error":{"message":"The requested model 'gpt-oss-120b' is not available for this API key, or the model name is invalid. Check the models available to you and try again.","type":"key_model_access_denied","param":"model","code":"403"}} |
POST /v1/completions는 OpenAI의 예전 Completions 형식이다. prompt로 받은 글의 뒤를 이어 쓴다. 입구는 grade:(C, K, V 종류)를 이름으로 바꾸는 일 말고는 아무것도 하지 않고 모델 쪽으로 넘긴다. 생각 모델의 reasoning_content, 도구 호출, 이미지 입력은 대화 생성에서만 확인했다.
| 필드 | 형식 | 뜻 |
|---|---|---|
model | 문자열 | 모델 이름 또는 grade:…(C, K, V 종류). E 종류를 쓰면 400 대화·완성에는 C/K/V 등급만 쓸 수 있다(요청: grade:E)다. |
prompt | 문자열 | 이어 쓸 앞부분. |
max_tokens | 정수 | 만들 최대 토큰 수. |
| 그 밖의 OpenAI 필드 | 입구는 그대로 넘긴다. 적용 여부는 확인하지 않았다. |
curl -i https://llm.kephas.kr/v1/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" -H "Content-Type: application/json" \
-d '{"model":"grade:C3","prompt":"대한민국의 수도는","max_tokens":8}'
HTTP/1.1 200 OK
x-kephas-picked-model: gemma3-4b
x-kephas-picked-grade: C3
content-type: application/json
…
{"id":"cmpl-835","object":"text_completion","created":1794188141,"model":"gemma3-4b","choices":[{"finish_reason":"length","index":0,"text":"대한민국의 수도는 **서울특별시**","logprobs":null}],"usage":{"completion_tokens":8,"prompt_tokens":15,"total_tokens":23,"completion_tokens_details":null,"prompt_tokens_details":{…}},"system_fingerprint":"…"}
응답의 object는 "text_completion"이고, 이어 쓴 글은 choices[].text에 있다. model, finish_reason, usage의 뜻은 대화 생성과 같다. 이 예에서 모델은 프롬프트를 되풀이한 다음 이어 썼다. 무엇을 어떻게 이어 쓰는지는 모델에 달렸다.
POST /v1/embeddings는 글을 일정한 길이의 실수 벡터로 바꾼다. 검색과 RAG에서 문서 색인과 질의에 쓴다. 입구는 grade:(E 종류만)를 이름으로 바꾸는 일 말고는 그대로 넘긴다.
| 필드 | 형식 | 뜻 |
|---|---|---|
model (필수) | 문자열 | 임베딩 모델의 이름(모델 목록에서 dim이 있는 모델) 또는 grade:E…. E가 아닌 등급은 400이다. |
input (필수) | 문자열 또는 문자열 배열 | 바꿀 글. 배열로 주면 한 번에 여러 개를 바꾼다. 하나하나가 그 모델의 ctx 이하여야 한다. |
encoding_format | "float" 또는 "base64" | 벡터를 실수 배열로 받을지 base64 문자열로 받을지. 주지 않으면 실수 배열이다. |
curl -i https://llm.kephas.kr/v1/embeddings \
-H "Authorization: Bearer $OPENAI_API_KEY" -H "Content-Type: application/json" \
-d '{"model":"grade:E","input":"사내 LLM 레지스트리"}'
HTTP/1.1 200 OK
x-kephas-picked-model: qwen3-embedding-0.6b
x-kephas-picked-grade: E2
content-type: application/json
…
{"model":"qwen3-embedding-0.6b","data":[{"embedding":[-0.031176517,0.011411778,-0.014389428,…],"index":0,"object":"embedding"}],"object":"list","usage":{"completion_tokens":0,"prompt_tokens":11,"total_tokens":11,"completion_tokens_details":null,"prompt_tokens_details":null}}
| 필드 | 형식 | 뜻 |
|---|---|---|
object | 문자열 | 항상 "list". |
data[].object | 문자열 | 항상 "embedding". |
data[].index | 정수 | 입력 배열에서의 순서(0부터). 결과를 입력과 맞출 때 이 값을 쓴다. |
data[].embedding | 실수 배열 또는 문자열 | 벡터. 길이는 그 모델의 dim이다(위 예는 1024). encoding_format이 "base64"면 base64 문자열이다. |
model | 문자열 | 벡터를 만든 모델의 실제 이름. |
usage.prompt_tokens, usage.total_tokens | 정수 | 입력 토큰 수. |
입력이 배열이면 data도 같은 개수로 온다. ["첫 문장","둘째 문장"]을 보낸 실제 응답이다.
{"model":"qwen3-embedding-0.6b","data":[{"embedding":[0.031238243,-0.05934164,-0.008246947,…],"index":0,"object":"embedding"},{"embedding":[-0.013398432,-0.057792313,-0.010718748,…],"index":1,"object":"embedding"}],"object":"list","usage":{"completion_tokens":0,"prompt_tokens":9,"total_tokens":9,"completion_tokens_details":null,"prompt_tokens_details":null}}
서로 다른 임베딩 모델이 만든 벡터는 서로 비교할 수 없고, 길이(dim)도 다를 수 있다. 문서 색인을 만든 모델로 질의도 임베딩해야 한다. grade:E는 호출마다 다시 고르므로, 색인처럼 오래 두는 벡터는 모델 이름(또는 시작할 때 pick한 이름)으로 만들고 그 이름을 함께 기록해 둔다.
| 상태 | 원인 | 응답 |
|---|---|---|
| 400 | E가 아닌 등급(grade:C) | {"error":{"message":"임베딩에는 E 등급만 쓸 수 있다(요청: grade:C)","type":"invalid_request_error","code":400}} |
| 400 | input이 없다 | {"error":{"message":"/embeddings: Missing required parameter: 'input'.","type":"invalid_request_error","param":"input","code":"400"}} |
| 403 | member 범위 키로 grade:EX(실험 임베딩 모델은 있으나 범위 밖) | {"error":{"message":"조건에 맞는 모델은 있으나 이 키로는 쓸 수 없다","type":"model_pick_failed","code":403}} |
| 404 | grade:E1(이날 E1 모델이 없었다) | {"error":{"message":"조건에 맞는 모델이 없다. 조건을 완화하라(품질·min_ctx·tools·langs, 실험 모델은 allow_x=true)","type":"model_pick_failed","code":404}} |
이 서비스가 돌려주는 상태 코드 전부다. "만든 곳"이 입구면 메시지가 한국어이고 code가 숫자이며, 모델 쪽이면 메시지가 영어이고 code가 문자열이다. 코드마다 자세한 사유와 실제 응답은 표 아래에 있다.
| 코드 | 언제 | type | 만든 곳 |
|---|---|---|---|
| 400 | pick 인자, grade:의 등급·조건이 틀렸다 | model_pick_failed | 입구 |
| 400 | grade:의 종류가 엔드포인트와 맞지 않는다, /v1/models/{id}의 인코딩이 깨졌다 | invalid_request_error | 입구 |
| 400 | 본문이 JSON이 아니다, 필수 필드가 없다 등 요청 본문 검사에 실패했다 | invalid_request_error | 모델 쪽 |
| 401 | 키가 없다, 등록되지 않은 키다, 정지된 키다, 만료된 키다 | authentication_error / auth_error, token_not_found_in_db, expired_key | 입구 / 모델 쪽 |
| 403 | 모델 이름으로 불렀는데 이 키로 쓸 수 없거나 없는 이름이다 | key_model_access_denied | 모델 쪽 |
| 403 | pick·grade:에서 조건에 맞는 모델이 모두 이 키의 범위 밖이다 | model_pick_failed | 입구 |
| 404 | pick·grade:에서 조건에 맞는 모델이 없다 | model_pick_failed | 입구 |
| 404 | /v1/models/{id}에서 없거나 이 키로 쓸 수 없는 모델이다 | invalid_request_error | 입구 |
| 404 | 이 페이지에 없는 /v1 경로다 | not_found | 입구 |
| 405 | 경로는 있으나 메서드가 다르다 | invalid_request_error | 입구 |
| 422 | 비용 한도를 넘은 키로 모델 이름을 불렀다 | budget_exceeded | 모델 쪽 |
| 429 | 속도 한도를 넘었다(한도를 둔 키만) | throttling_error | 모델 쪽 |
| 500 | 입구 내부 오류 | server_error | 입구 |
| 502 | 모델 서버 쪽 오류 | 모델 쪽 | |
| 502 | 입구가 키·모델 정보를 확인하다 예상하지 못한 응답을 받았거나(비용 한도를 넘은 키 포함) 모델 쪽에 연결하지 못했다 | gateway_error, gateway_unavailable | 입구 |
| 502 | 요청 본문이 32MB를 넘었다 | 없음(본문이 비어 있다) | |
| 503 | 모델 서버가 응답하지 않는다(점검·엔진 전환 중 등) | 모델 쪽 |
요청 자체가 틀렸다는 뜻이다. 같은 요청을 다시 보내도 결과가 같으므로 요청을 고친다. 실제 응답이다.
GET /v1/pick?grade=Z
{"error":{"message":"grade는 C/K/E/V 한 글자 또는 종류+품질(C2, E1, CX)이어야 한다","type":"model_pick_failed","code":400}}
POST /v1/chat/completions "model":"grade:C?max_quality=5"
{"error":{"message":"max_quality는 1·2·3 중 하나","type":"model_pick_failed","code":400}}
POST /v1/embeddings "model":"grade:K2"
{"error":{"message":"임베딩에는 E 등급만 쓸 수 있다(요청: grade:K2)","type":"invalid_request_error","code":400}}
GET /v1/models/%E0%A4%A
{"error":{"message":"모델 id 인코딩이 잘못됐다","type":"invalid_request_error","code":400}}
POST /v1/chat/completions 본문 {"model":"qwen3-8b", messages
{"error":{"message":"Invalid JSON payload: unexpected character: line 1 column 22 (char 21)","type":"invalid_request_error","param":"request_body","code":"400"}}
POST /v1/embeddings "input" 없음
{"error":{"message":"/embeddings: Missing required parameter: 'input'.","type":"invalid_request_error","param":"input","code":"400"}}
메시지는 사유에 따라, 그리고 요청을 입구가 처리했는지 모델 쪽이 처리했는지에 따라 다르다. 입구가 처리하는 요청은 GET /v1/models, GET /v1/models/{id}, GET /v1/pick, grade:로 부른 호출이다. 모델 쪽이 처리하는 요청은 모델 이름으로 부른 호출이다. 아래는 사유마다 두 쪽에서 받은 실제 응답이다.
키가 없다. Authorization 헤더가 없을 때다. 입구는 헤더가 Bearer 방식이 아닐 때(예: Basic …)도 같은 응답을 준다.
GET /v1/models
{"error":{"message":"Authorization: Bearer <키>가 필요하다","type":"authentication_error","code":401}}
POST /v1/chat/completions "model":"qwen3-8b"
{"error":{"message":"Authentication Error, No api key passed in.","type":"auth_error","param":"None","code":"401"}}
등록되지 않은 키다. 잘못 복사했거나 삭제된 키다. 입구는 사유를 붙이지 않는다.
GET /v1/models
{"error":{"message":"키가 유효하지 않다","type":"authentication_error","code":401}}
POST /v1/chat/completions "model":"qwen3-8b"
{"error":{"message":"Authentication Error, Invalid proxy server token passed. Received API Key = sk-...0000, Key Hash (Token) =…. Unable to find token in cache or …","type":"token_not_found_in_db","param":"key","code":"401"}}
sk-로 시작하지 않는 값이다. 키가 아닌 값을 넣었을 때다. 보낸 값의 일부가 가려진 채 메시지에 들어간다.
GET /v1/models
{"error":{"message":"키가 유효하지 않다: … Virtual Key expected. Received=docr****-key, expected to start with 'sk-'.","type":"authentication_error","code":401}}
POST /v1/chat/completions "model":"qwen3-8b"
{"error":{"message":"… Virtual Key expected. Received=docr****-key, expected to start with 'sk-'.","type":"auth_error","param":"None","code":"401"}}
정지된 키다. 메시지에 Key is blocked가 들어 있다.
GET /v1/models
{"error":{"message":"키가 유효하지 않다: Authentication Error, Key is blocked. …","type":"authentication_error","code":401}}
POST /v1/chat/completions "model":"gemma3-4b"
{"error":{"message":"Authentication Error, Key is blocked. …","type":"auth_error","param":"None","code":"401"}}
만료된 키다. 메시지에 Expired Key와 만료 시각, 요청 시각이 들어 있다. 시각은 UTC다.
GET /v1/models
{"error":{"message":"키가 유효하지 않다: Authentication Error - Expired Key. Key Expiry time 2026-11-09 01:37:07.622000+00:00 and current time 2026-11-09 01:37:10.049628+00:00","type":"authentication_error","code":401}}
POST /v1/chat/completions "model":"gemma3-4b"
{"error":{"message":"Authentication Error - Expired Key. Key Expiry time 2026-11-09 01:37:07.622000+00:00 and current time 2026-11-09 01:37:10.080499+00:00","type":"expired_key","param":"…","code":"401"}}
키를 정지하거나 만료 시각이 지나면 바로 다음 요청부터 401이다. 사내망에서 연 키 발급·관리 페이지에서 재개하거나 연장하면 즉시 다시 쓸 수 있다. 삭제된 키는 되살릴 수 없으므로, 키를 잃어버렸거나 삭제됐으면 같은 페이지에서 새로 발급한다.
모델 이름으로 불렀을 때는 이름이 이 키의 범위 밖이어도, 그런 이름의 모델이 아예 없어도 같은 403이다. 메시지는 둘을 구분하지 않는다. 오타를 낸 이름과 대문자로 쓴 GRADE: 접두도 여기에 든다.
POST /v1/chat/completions "model":"gpt-oss-120b" (member 범위 키)
{"error":{"message":"The requested model 'gpt-oss-120b' is not available for this API key, or the model name is invalid. Check the models available to you and try again.","type":"key_model_access_denied","param":"model","code":"403"}}
POST /v1/chat/completions "model":"no-such-model"
{"error":{"message":"The requested model 'no-such-model' is not available for this API key, or the model name is invalid. Check the models available to you and try again.","type":"key_model_access_denied","param":"model","code":"403"}}
pick과 grade:에서는 조건에 맞는 모델이 레지스트리에 있지만 모두 이 키의 범위 밖일 때 403이다.
GET /v1/pick?grade=C1 (member 범위 키)
{"error":{"message":"조건에 맞는 모델은 있으나 이 키로는 쓸 수 없다","type":"model_pick_failed","code":403}}
쓸 수 있는 모델은 GET /v1/models로 확인한다. 키 발급·관리 페이지에서는 발급한 키의 범위를 바꿀 수 없으므로, 범위를 넓혀야 하면 그 페이지에서 필요한 범위로 새 키를 발급한다.
GET /v1/pick?grade=K
{"error":{"message":"조건에 맞는 모델이 없다. 조건을 완화하라(품질·min_ctx·tools·langs, 실험 모델은 allow_x=true)","type":"model_pick_failed","code":404}}
GET /v1/models/no-such-model
{"error":{"message":"모델이 없거나 이 키로 쓸 수 없다: no-such-model","type":"invalid_request_error","code":404}}
GET /v1/unknown
{"error":{"message":"없는 API 경로다: /v1/unknown","type":"not_found","code":404}}
첫째는 레지스트리 전체에 조건을 만족하는 모델이 없다는 뜻이므로 조건을 완화한다. 둘째는 이름이 틀렸거나 이 키로 쓸 수 없는 모델이다. 셋째처럼 이 페이지에 없는 /v1 경로는 입구가 막는다. 이 페이지에 적은 경로만 열려 있다.
POST로 부르는 경로를 GET으로 부른 경우 등이다. allow 헤더에 받는 메서드가 적혀 있다.
curl -i https://llm.kephas.kr/v1/chat/completions -H "Authorization: Bearer $OPENAI_API_KEY"
HTTP/2 405
allow: POST
content-type: application/json; charset=utf-8
…
{"error":{"message":"/v1/chat/completions는 POST만 받는다","type":"invalid_request_error","code":405}}
GET으로 부르는 경로(/v1/models, /v1/models/{id}, /v1/pick)를 다른 메서드로 부르면 allow: GET과 함께 같은 모양의 405가 온다.
요청 본문이 32MB를 넘으면 입구는 본문을 끝까지 읽지 않고 연결을 끊는다. 클라이언트에는 본문이 빈 502가 간다. 413 응답이나 JSON 오류 본문은 오지 않는다. 34,000,000바이트 본문을 보낸 실제 응답이다.
HTTP/1.1 502 Bad Gateway Content-Length: 0
본문을 32MB 아래로 줄여서 보낸다. base64로 넣은 이미지도 본문 크기에 든다.
키에는 비용 한도를 둘 수 있다. 사내 모델의 사용량은 비용 0으로 집계되므로, 한도가 0보다 큰 키는 이 한도에 닿지 않는다. 한도를 0으로 둔 키는 처음부터 한도를 넘은 것으로 처리되어 다음처럼 거절된다. 모델 이름으로 부르면 422이고, 입구가 처리하는 요청(GET /v1/models, GET /v1/pick, grade: 호출)은 502 gateway_error다. 모델 목록조차 볼 수 없다.
POST /v1/chat/completions "model":"gemma3-4b"
HTTP/1.1 422 Unprocessable Entity
{"error":{"message":"Budget has been exceeded! Key=… Current cost: 0.0, Max budget: 0.0","type":"budget_exceeded","param":null,"code":"422"}}
GET /v1/models
HTTP/1.1 502 Bad Gateway
{"error":{"message":"게이트웨이 오류: … GET /v1/models → 422","type":"gateway_error","code":502}}
키 발급·관리 페이지에서는 발급한 키의 비용 한도를 바꿀 수 없다. 이 응답을 받으면 그 페이지에서 한도를 비우거나 0보다 큰 값으로 정한 새 키를 발급해 바꿔 쓰고, 이 키는 삭제한다. 발급할 때 한도에 0은 넣을 수 없으므로, 새로 발급한 키가 처음부터 이 상태가 되지는 않는다.
속도 한도(예: 분당 요청 수)를 둔 키가 한도를 넘으면 429다. 속도 한도는 키를 발급할 때 정하는 항목이 아니고, 관리자가 따로 둔 키에만 있다. retry-after 헤더의 초만큼 기다렸다가 다시 부른다. 아래는 분당 1회로 제한한 키로 두 번째 요청을 보낸 실제 응답이다.
HTTP/1.1 429 Too Many Requests
retry-after: 60
rate_limit_type: requests
reset_at: 2026-11-09 01:38:02 UTC
content-type: application/json
…
{"error":{"message":"Rate limit exceeded for api_key: …. Limit type: requests. Current limit: 1, Remaining: 0. Limit resets at: 2026-11-09 01:38:02 UTC","type":"throttling_error","param":null,"code":"429"}}
한도에 걸린 뒤에도 GET /v1/models는 200이었다. 모델 목록 조회와 pick은 이 한도로 막히지 않는다. grade:로 부른 호출은 고르기까지는 되고, 그다음 모델 쪽에서 429가 나므로 x-kephas-picked-model 헤더가 붙은 채로 429를 받는다.
입구에서 예상하지 못한 오류가 나면 500이고 type은 server_error다. 잠시 뒤 다시 부르고, 같은 요청이 계속 500이면 관리자에게 알린다.
502는 다음 경우에 나온다.
type은 gateway_error다. 비용 한도를 넘은 키가 여기에 걸린다(422에 실제 응답이 있다).type은 gateway_unavailable다.본문 크기와 비용 한도 때문이 아니라면 잠시 뒤 다시 부른다. 계속되면 관리자에게 알린다.
모델 서버가 응답하지 않을 때 503이다. 점검이나 엔진 전환 중에는 일부 모델이 1~2분 동안 503을 돌려준다. 잠시 뒤 같은 요청을 다시 보낸다. 이 서비스는 응답하지 않는 모델 대신 다른 모델로 몰래 바꿔 답하게 하지 않는다. 다른 모델로 넘어가야 한다면 호출한 쪽이 직접 다른 모델을 골라 부른다.
GET /healthz는 입구 앱이 요청을 받을 수 있는지 알려 준다. 키 없이 부른다.
curl https://llm.kephas.kr/healthz
{"ok":true}
200과 {"ok":true}는 입구가 살아 있다는 뜻일 뿐, 모델 서버의 상태는 아니다. GET /v1/models도 등록된 정보만 돌려준다. 어떤 모델이 지금 답하는지 보려면 그 모델에 짧은 요청(예: "max_tokens":1)을 보내 본다.