# Cubion Gateway > OpenAI 호환 AI 게이트웨이. 키 하나로 OpenAI·Anthropic·Google 등 여러 제공사 모델을 같은 형식으로 호출한다. 선불 원화 과금. - Base URL: https://ai.cubion.kr/v1 (OpenAI SDK 의 baseURL 만 바꾸면 된다) - 인증: Authorization: Bearer <발급 키> - 전체 문서 한 파일: https://ai.cubion.kr/llms-full.txt ## 문서 - [소개](https://ai.cubion.kr/docs.md): 발급한 키로 https://ai.cubion.kr/v1 을 호출하는 방법입니다. OpenAI 호환이라 쓰던 SDK 에서 baseURL 만 바꾸면 됩니다. - [키 발급과 첫 호출](https://ai.cubion.kr/docs/quickstart.md): 키를 발급받고 5분 안에 첫 응답을 받아 봅니다. - [채팅·응답](https://ai.cubion.kr/docs/chat.md): POST /v1/chat/completions — OpenAI·Anthropic·Gemini 를 같은 형식으로 부릅니다. - [임베딩](https://ai.cubion.kr/docs/embeddings.md): POST /v1/embeddings — 문장을 벡터로 바꿉니다. - [이미지 생성](https://ai.cubion.kr/docs/images.md): POST /v1/images/generations — 프롬프트로 이미지를 만듭니다. - [음성 합성](https://ai.cubion.kr/docs/tts.md): POST /v1/audio/speech — 문장을 음성 파일로 바꿉니다. - [모델 목록](https://ai.cubion.kr/docs/models.md): GET /v1/models — 이 키로 쓸 수 있는 모델을 확인합니다. - [기능 프로필](https://ai.cubion.kr/docs/features.md): model 에 feature:<이름> 을 쓰면 모델·기본값·폴백을 게이트웨이에서 정합니다. - [오류 처리](https://ai.cubion.kr/docs/errors.md): 상태 코드와 오류 본문의 형태, 재시도 규칙. - [요금·잔액](https://ai.cubion.kr/docs/billing.md): 무엇으로 얼마가 빠지고, 잔액이 바닥나면 어떻게 되는지. --- # 소개 > 발급한 키로 https://ai.cubion.kr/v1 을 호출하는 방법입니다. OpenAI 호환이라 쓰던 SDK 에서 baseURL 만 바꾸면 됩니다. 원문: https://ai.cubion.kr/docs > **AI 도구에 이 문서를 줄 때** > 링크 하나로 전체를 읽히려면 [/llms-full.txt](https://ai.cubion.kr/llms-full.txt) 를 주세요. 페이지 하나만 줄 때는 주소 뒤에 `.md` 를 붙이면 마크다운 판이 나옵니다(예: [/docs/chat.md](https://ai.cubion.kr/docs/chat.md)). 각 페이지 오른쪽 위 **페이지 복사** 로 붙여 넣을 수도 있습니다. ## 무엇을 하는 서비스인가 발급한 키 하나로 OpenAI·Anthropic·Gemini·Fish Audio 를 **같은 형식**으로 부릅니다. 프로바이더마다 계정을 만들고 카드를 등록하고 서로 다른 SDK 를 붙이는 대신, 키 하나와 주소 하나만 두면 됩니다. 요청은 `https://ai.cubion.kr/v1` 로 보냅니다. 형식이 OpenAI 호환이라 이미 OpenAI SDK 를 쓰고 있다면**baseURL 한 줄**만 바꾸면 그대로 동작합니다. Python: ```python import os from openai import OpenAI client = OpenAI( base_url="https://ai.cubion.kr/v1", # 이 줄만 바뀝니다 api_key=os.environ["GATEWAY_KEY"], ) res = client.chat.completions.create( model="gpt-4o", messages=[{"role": "user", "content": "안녕하세요"}], ) print(res.choices[0].message.content) ``` Node.js: ```javascript import OpenAI from "openai"; const client = new OpenAI({ baseURL: "https://ai.cubion.kr/v1", // 이 줄만 바뀝니다 apiKey: process.env.GATEWAY_KEY, }); const res = await client.chat.completions.create({ model: "gpt-4o", messages: [{ role: "user", content: "안녕하세요" }], }); console.log(res.choices[0].message.content); ``` cURL: ```bash curl https://ai.cubion.kr/v1/chat/completions \ -H "Authorization: Bearer $GATEWAY_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"gpt-4o","messages":[{"role":"user","content":"안녕하세요"}]}' ``` ## 부를 수 있는 것 포털에서 발급한 키로 쓸 수 있는 엔드포인트입니다. 모두 같은 키, 같은 인증 헤더를 씁니다. | 엔드포인트 | 하는 일 | 문서 | | --- | --- | --- | | `POST /v1/chat/completions` | 대화·텍스트 생성 (스트리밍 지원) | [채팅·응답](https://ai.cubion.kr/docs/chat) | | `POST /v1/embeddings` | 문장을 벡터로 | [임베딩](https://ai.cubion.kr/docs/embeddings) | | `POST /v1/images/generations` | 프롬프트로 이미지 생성 | [이미지 생성](https://ai.cubion.kr/docs/images) | | `POST /v1/audio/speech` | 문장을 음성 파일로 | [음성 합성](https://ai.cubion.kr/docs/tts) | | `GET /v1/models` | 이 키로 쓸 수 있는 모델 목록 | [모델 목록](https://ai.cubion.kr/docs/models) | | `GET /v1/balance` | 계정에 남은 잔액 | [요금·잔액](https://ai.cubion.kr/docs/billing#check-balance) | ## 인증 헤더 하나가 전부입니다. 쿠키도 세션도 없습니다. HTTP: ```http Authorization: Bearer sk-proj-... ``` 키는 [API 키](https://ai.cubion.kr/app/keys) 화면에서 발급합니다. 평문 키는 **발급 직후 한 번만** 보이므로 그 자리에서 환경변수로 옮겨 두세요. > **브라우저에서 직접 호출할 때** > `/v1/*` 는 CORS 를 허용하므로 브라우저에서 바로 불러도 동작합니다. 다만 **브라우저에 올린 키는 공개된 키**입니다 — 개인 프로젝트가 아니라면 서버를 거쳐 호출하세요. ## 요금 사용한 만큼 계정 잔액에서 **원화**로 빠집니다. 프로바이더별 결제도, 월 구독도 없습니다. - 잔액이 0원이면 호출이 막힙니다 — 이때 받는 응답은 [오류 처리](https://ai.cubion.kr/docs/errors)에 있습니다. - 충전은 [충전·결제](https://ai.cubion.kr/app/billing), 무엇에 얼마를 썼는지는 [사용량](https://ai.cubion.kr/app/usage)에서 봅니다. ## 다음 키를 아직 안 만들었다면 [키 발급과 첫 호출](https://ai.cubion.kr/docs/quickstart)부터 보세요. 5분이면 첫 응답까지 갑니다. --- # 키 발급과 첫 호출 > 키를 발급받고 5분 안에 첫 응답을 받아 봅니다. 원문: https://ai.cubion.kr/docs/quickstart ## 키 발급하고 설정하기 게이트웨이는 쿠키도 세션도 쓰지 않습니다. [API 키](https://ai.cubion.kr/app/keys) 화면에서 키를 하나 발급받아 `Authorization` 헤더에 담아 보내면 그걸로 끝입니다. ### 키 발급 [API 키](https://ai.cubion.kr/app/keys) 화면에서 **키 발급**을 누릅니다. > **평문 키는 한 번만 보입니다** > 발급 직후 화면에 뜨는 `sk-proj-…` 는 그때 한 번만 보입니다. 못 받아 적었으면 다시 발급하세요 — 이전 키는 그 즉시 막힙니다. ### 주소와 키를 환경변수로 키는 코드에 박지 말고 환경변수로 둡니다. 저장소에 올라간 키는 유출된 키입니다. .env: ```dotenv GATEWAY_URL=https://ai.cubion.kr GATEWAY_KEY=sk-proj-... # /app/keys 에서 발급 — 발급 직후 한 번만 보입니다 ``` ### 첫 호출로 키 확인 아무것도 만들기 전에 `GET /v1/models` 를 한 번 불러 봅니다. 요금이 들지 않고, 키가 살아 있는지와 이 키로 쓸 수 있는 모델이 무엇인지를 한 번에 알려 줍니다. cURL: ```bash curl https://ai.cubion.kr/v1/models \ -H "Authorization: Bearer $GATEWAY_KEY" # 200 이면 키가 살아 있는 것입니다. 401 이면 키가 틀렸거나 폐기된 키입니다. ``` Python: ```python import os from openai import OpenAI client = OpenAI( base_url="https://ai.cubion.kr/v1", # 여기만 바꾸면 됩니다 api_key=os.environ["GATEWAY_KEY"], ) for m in client.models.list().data: print(m.id) ``` Node.js: ```javascript import OpenAI from "openai"; const client = new OpenAI({ baseURL: "https://ai.cubion.kr/v1", // 여기만 바꾸면 됩니다 apiKey: process.env.GATEWAY_KEY, }); const models = await client.models.list(); for (const m of models.data) console.log(m.id); ``` **응답** ``` { "object": "list", "data": [ { "id": "gpt-4o", "object": "model", "owned_by": "openai", "mode": "chat" } ], "key": { "name": "내 키", "scopes": ["ai"], "restricted": false } } ``` ## 인증과 baseURL 게이트웨이는 OpenAI 호환입니다. 이미 OpenAI SDK 를 쓰고 있다면 바꿀 것은 두 줄 — `baseURL` 과 `apiKey` 뿐입니다. - 인증은 헤더 하나입니다: `Authorization: Bearer sk-proj-…` (쿠키·세션 없음) - baseURL 은 `https://ai.cubion.kr/v1` 입니다. 경로와 요청·응답 형식은 OpenAI 와 같습니다. - 키가 유출된 것 같으면 [API 키](https://ai.cubion.kr/app/keys) 에서 바로 폐기하세요. 폐기한 키로는 그 즉시 호출이 되지 않습니다. ## 잔액 확인 모델 목록은 무료지만 모델 호출은 잔액에서 차감됩니다. 잔액이 0원이면 다음 단계의 호출이 `429 insufficient_balance` 로 막히니 먼저 확인하세요. cURL: ```bash curl https://ai.cubion.kr/v1/balance -H "Authorization: Bearer $GATEWAY_KEY" # {"object":"balance","currency":"KRW","balance":3000,"low_balance":false,"topup_url":"…"} ``` > **0원이라면** > [충전·결제](https://ai.cubion.kr/app/billing) 에서 사용량 패키지를 구매하면 즉시 반영됩니다. 키를 다시 발급할 필요는 없습니다. ## 첫 응답 받아 보기 키와 잔액이 확인됐으면 바로 모델을 부를 수 있습니다. 같은 코드에서 모델 이름만 바꾸면 OpenAI·Anthropic·Gemini 가 모두 호출됩니다. cURL: ```bash curl https://ai.cubion.kr/v1/chat/completions \ -H "Authorization: Bearer $GATEWAY_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"gpt-4o","messages":[{"role":"user","content":"안녕"}]}' ``` Python: ```python r = client.chat.completions.create( model="gpt-4o", # "claude-sonnet-4-5", "gemini/gemini-2.5-flash" 등 messages=[{"role": "user", "content": "안녕"}], ) print(r.choices[0].message.content) ``` Node.js: ```javascript const r = await client.chat.completions.create({ model: "gpt-4o", // "claude-sonnet-4-5", "gemini/gemini-2.5-flash" 등 messages: [{ role: "user", content: "안녕" }], }); console.log(r.choices[0].message.content); ``` 모델 이름을 앱에 박아 두지 마세요. 쓸 수 있는 모델은 바뀔 수 있으니 [모델 목록](https://ai.cubion.kr/docs/models) 으로 채우는 편이 안전합니다. ## 브라우저에서 부를 때 `/v1/*` 은 CORS 를 허용하므로 브라우저에서 직접 `fetch` 해도 동작합니다. 다만 동작하는 것과 안전한 것은 다릅니다. > **브라우저에 올린 키는 공개된 키입니다** > 프런트엔드 코드에 넣은 키는 개발자 도구에서 그대로 보입니다. 개인 프로젝트나 내부 도구가 아니라면 키는 서버에만 두고, 브라우저는 여러분의 서버를 부르게 하세요. ## 다음으로 - [채팅·응답](https://ai.cubion.kr/docs/chat) — 요청 본문 필드, 스트리밍, 응답 형태 - [모델 목록](https://ai.cubion.kr/docs/models) — 이 키로 쓸 수 있는 모델을 코드에서 채우기 - [오류 처리](https://ai.cubion.kr/docs/errors) — 401·403·429 가 올 때 무엇을 보여 줄지 - [요금·잔액](https://ai.cubion.kr/docs/billing) — 무엇으로 얼마가 빠지는지 --- # 채팅·응답 > POST /v1/chat/completions — OpenAI·Anthropic·Gemini 를 같은 형식으로 부릅니다. 원문: https://ai.cubion.kr/docs/chat ## 요청 보내기 OpenAI Chat Completions 와 같은 요청·응답 형식입니다. `model` 만 바꾸면 OpenAI·Anthropic·Gemini 를 같은 코드로 호출합니다. baseURL 은 `https://ai.cubion.kr/v1`, 인증은 `Authorization: Bearer sk-proj-…` 헤더 하나입니다([키 발급과 첫 호출](https://ai.cubion.kr/docs/quickstart) 참고). cURL: ```bash curl https://ai.cubion.kr/v1/chat/completions \ -H "Authorization: Bearer $GATEWAY_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"gpt-4o","messages":[{"role":"user","content":"안녕"}]}' ``` Python: ```python import os from openai import OpenAI client = OpenAI(base_url="https://ai.cubion.kr/v1", api_key=os.environ["GATEWAY_KEY"]) r = client.chat.completions.create( model="gpt-4o", # "claude-sonnet-4-5", "gemini/gemini-2.5-flash" 등 messages=[{"role": "user", "content": "안녕"}], ) print(r.choices[0].message.content) ``` Node.js: ```javascript import OpenAI from "openai"; const client = new OpenAI({ baseURL: "https://ai.cubion.kr/v1", apiKey: process.env.GATEWAY_KEY }); const r = await client.chat.completions.create({ model: "gpt-4o", // "claude-sonnet-4-5", "gemini/gemini-2.5-flash" 등 messages: [{ role: "user", content: "안녕" }], }); console.log(r.choices[0].message.content); ``` ## 요청 본문 필드 자주 쓰는 것만 추리면 다음과 같습니다. 나머지 필드는 손대지 않고 모델 쪽으로 그대로 전달됩니다. | 필드 | 필수 | 설명 | | --- | --- | --- | | `model` | 필수 | 부를 모델 이름. `gpt-4o` · `claude-sonnet-4-5` · `gemini/gemini-2.5-flash` 처럼 적습니다. 빠지면 `400 missing model` 입니다. | | `messages` | 필수 | 대화 내용. `role`(`system` · `user` · `assistant`)과 `content` 를 가진 객체의 배열입니다. 지난 대화를 이어 가려면 앞선 주고받음을 그대로 다시 실어 보냅니다. | | `stream` | 선택 | `true` 면 답이 완성되기를 기다리지 않고 오는 대로 흘려보냅니다. 기본값은 `false`. | | `temperature` | 선택 | 답의 들쭉날쭉함. 낮을수록 일정하고 높을수록 다양해집니다. `temperature` 를 받지 않는 추론형 모델에는 게이트웨이가 이 값을 빼고 다시 보냅니다. | | `max_tokens` | 선택 | 답 길이의 상한. 안 보내면 모델 기본값을 씁니다. 짧게 끊어 두면 그만큼 요금도 줄어듭니다. | 요금은 보낸 토큰과 받은 토큰을 합쳐 계산됩니다. 대화가 길어질수록 매 요청에 실리는 지난 대화도 같이 계산되니, 필요 없는 앞부분은 잘라서 보내세요. ## 스트리밍 `stream: true` 를 붙이면 긴 답변을 기다리지 않고 글자가 오는 대로 보여 줄 수 있습니다. 응답은 JSON 한 덩어리가 아니라 SSE(`text/event-stream`)로 옵니다. cURL: ```bash curl -N https://ai.cubion.kr/v1/chat/completions \ -H "Authorization: Bearer $GATEWAY_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"gpt-4o","messages":[{"role":"user","content":"안녕"}],"stream":true}' ``` Python: ```python stream = client.chat.completions.create( model="claude-sonnet-4-5", messages=[{"role": "user", "content": "긴 글을 써 줘"}], stream=True, ) for chunk in stream: print(chunk.choices[0].delta.content or "", end="", flush=True) ``` Node.js: ```javascript const stream = await client.chat.completions.create({ model: "claude-sonnet-4-5", messages: [{ role: "user", content: "긴 글을 써 줘" }], stream: true, }); for await (const chunk of stream) { process.stdout.write(chunk.choices[0]?.delta?.content ?? ""); } ``` **응답 (SSE)** ``` data: {"object":"chat.completion.chunk","choices":[{"delta":{"content":"안"}}]} data: {"object":"chat.completion.chunk","choices":[{"delta":{"content":"녕"}}]} data: [DONE] ``` `data:` 로 시작하는 줄이 이어지다 `data: [DONE]` 으로 끝납니다. SDK 를 쓰면 이 파싱은 SDK 가 합니다. > **스트리밍이어도 요금은 똑같이 집계됩니다** > 중간에 연결을 끊어도 그때까지 쓴 만큼은 차감됩니다. 사용자가 화면을 닫았다고 해서 요금이 사라지지는 않으니, 필요 없어진 요청은 아예 보내지 않는 편이 낫습니다. ## 모델 고르기 모델 이름은 `model` 문자열 하나로 결정됩니다. 코드는 그대로 두고 이름만 바꾸면 다른 회사 모델로 넘어갑니다. - `gpt-4o` — OpenAI 계열 - `claude-sonnet-4-5` — Anthropic 계열 - `gemini/gemini-2.5-flash` — Gemini 계열 쓸 수 있는 모델은 바뀔 수 있으니 목록을 앱에 박아 두지 말고 [모델 목록](https://ai.cubion.kr/docs/models) (`GET /v1/models`)으로 채우세요. 그 목록에 없는 모델을 부르면 [403 model_not_allowed](https://ai.cubion.kr/docs/errors) 가 옵니다. ## 응답 형태 스트리밍이 아닐 때는 모델의 응답이 그대로 옵니다. 답 글자는 `choices[0].message.content` 에 있습니다. **응답** ``` { "id": "chatcmpl-...", "object": "chat.completion", "model": "gpt-4o", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "안녕하세요! 무엇을 도와드릴까요?" }, "finish_reason": "stop" } ], "usage": { "prompt_tokens": 9, "completion_tokens": 12, "total_tokens": 21 } } ``` - `finish_reason` 이 `length` 면 `max_tokens` 에 걸려 답이 잘린 것입니다. - `usage` 는 이번 요청에 쓴 토큰 수입니다. 차감된 금액은 응답에 실리지 않고 [사용량](https://ai.cubion.kr/app/usage) 에서 확인합니다. - 요청이 막혔을 때의 본문 모양(`error` · `message` · `topup_url`)은 [오류 처리](https://ai.cubion.kr/docs/errors) 에 정리해 두었습니다. --- # 임베딩 > POST /v1/embeddings — 문장을 벡터로 바꿉니다. 원문: https://ai.cubion.kr/docs/embeddings ## 호출하기 문장을 숫자 배열(벡터)로 바꿉니다. 검색·유사 문서 찾기·중복 판별처럼 “비슷한 정도”를 계산해야 하는 곳에 씁니다. OpenAI 임베딩 엔드포인트와 같은 형식이라 `baseURL` 만 바꾸면 됩니다. cURL: ```bash curl https://ai.cubion.kr/v1/embeddings \ -H "Authorization: Bearer $GATEWAY_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"text-embedding-3-small","input":"검색할 문장"}' ``` Python: ```python import os from openai import OpenAI ai = OpenAI(base_url="https://ai.cubion.kr/v1", api_key=os.environ["GATEWAY_KEY"]) r = ai.embeddings.create( model="text-embedding-3-small", input="검색할 문장", ) vector = r.data[0].embedding # list[float] ``` Node.js: ```javascript import OpenAI from "openai"; const ai = new OpenAI({ baseURL: "https://ai.cubion.kr/v1", apiKey: process.env.GATEWAY_KEY }); const r = await ai.embeddings.create({ model: "text-embedding-3-small", input: "검색할 문장", }); const vector = r.data[0].embedding; // number[] ``` 모델은 `text-embedding-3-small` 입니다. 이 키로 쓸 수 있는 임베딩 모델은 [모델 목록](https://ai.cubion.kr/docs/models) 에서 확인하세요 — 목록에 없는 모델을 부르면 `403 model_not_allowed` 입니다. ## 응답 응답은 OpenAI 와 같은 모양입니다. 벡터는 `data[0].embedding` 에 들어 있는 숫자 배열이고,`usage` 에는 이번 호출에 쓰인 토큰 수가 옵니다. **응답** ``` { "object": "list", "model": "text-embedding-3-small", "data": [ { "object": "embedding", "index": 0, "embedding": [0.0023, -0.0117, 0.0091, ...] } ], "usage": { "prompt_tokens": 8, "total_tokens": 8 } } ``` ## 여러 문장 한 번에 `input` 에 배열을 넣으면 한 번의 호출로 여러 문장을 처리합니다. 문장마다 따로 부르는 것보다 왕복이 줄어 훨씬 빠릅니다. cURL: ```bash curl https://ai.cubion.kr/v1/embeddings \ -H "Authorization: Bearer $GATEWAY_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"text-embedding-3-small","input":["첫 번째 문장","두 번째 문장"]}' ``` Python: ```python r = ai.embeddings.create( model="text-embedding-3-small", input=["첫 번째 문장", "두 번째 문장"], ) vectors = [d.embedding for d in r.data] # 보낸 순서 그대로 ``` Node.js: ```javascript const r = await ai.embeddings.create({ model: "text-embedding-3-small", input: ["첫 번째 문장", "두 번째 문장"], }); const vectors = r.data.map((d) => d.embedding); // 보낸 순서 그대로 ``` `data` 는 보낸 순서대로 돌아오고, 각 항목의 `index` 로도 어느 입력의 결과인지 확인할 수 있습니다. ## 알아 둘 점 - 요금은 **보낸 토큰 수**로 계산됩니다. 같은 문장을 반복해 임베딩하지 말고 결과를 저장해 두세요 — 문서는 한 번만 임베딩하고, 매번 새로 만드는 건 검색어 쪽뿐이면 충분합니다. - 벡터를 비교하려면 **같은 모델로 만든 것끼리**여야 합니다. 모델을 바꾸면 저장해 둔 벡터도 다시 만들어야 합니다. - 호출이 막혔을 때의 상태 코드와 본문 형태는 [오류 처리](https://ai.cubion.kr/docs/errors) 에 정리해 두었습니다. --- # 이미지 생성 > POST /v1/images/generations — 프롬프트로 이미지를 만듭니다. 원문: https://ai.cubion.kr/docs/images ## 호출하기 프롬프트로 이미지를 만듭니다. OpenAI 이미지 엔드포인트와 같은 형식입니다. `size` 와 `quality` 는 **선택**이라, 안 보내면 모델 기본값으로 만들어집니다. cURL: ```bash curl https://ai.cubion.kr/v1/images/generations \ -H "Authorization: Bearer $GATEWAY_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"gpt-image-1","prompt":"노을 지는 바다, 수채화","size":"1024x1024"}' ``` Python: ```python import base64, os from openai import OpenAI ai = OpenAI(base_url="https://ai.cubion.kr/v1", api_key=os.environ["GATEWAY_KEY"]) r = ai.images.generate( model="gpt-image-1", prompt="노을 지는 바다, 수채화", size="1024x1024", # 선택 — 안 보내면 모델 기본값 ) # 응답은 URL 이 아니라 base64 다 with open("out.png", "wb") as f: f.write(base64.b64decode(r.data[0].b64_json)) ``` Node.js: ```javascript import OpenAI from "openai"; import { writeFile } from "node:fs/promises"; const ai = new OpenAI({ baseURL: "https://ai.cubion.kr/v1", apiKey: process.env.GATEWAY_KEY }); const r = await ai.images.generate({ model: "gpt-image-1", prompt: "노을 지는 바다, 수채화", size: "1024x1024", // 선택 — 안 보내면 모델 기본값 }); // 응답은 URL 이 아니라 base64 다 const png = Buffer.from(r.data[0].b64_json, "base64"); await writeFile("out.png", png); ``` ## 응답은 URL 이 아니라 base64 > **data[0].url 은 없습니다** > 응답은 `{ data: [{ b64_json }] }` 입니다. `b64_json` 은 **base64 로 인코딩된 이미지 바이트**라서, `` 에 그대로 넣거나 디코딩해 파일로 저장해야 합니다. URL 이 올 거라 생각하고 `data[0].url` 을 읽으면 `undefined` 가 나옵니다. **응답** ``` { "data": [ { "b64_json": "iVBORw0KGgoAAAANSUhEUgAA..." } ] } ``` 브라우저에서 바로 보여 줄 때는 데이터 URL 로 감싸면 됩니다. **브라우저에 표시** ``` img.src = "data:image/png;base64," + data[0].b64_json; ``` Gemini 이미지 모델도 같은 형태로 변환돼 옵니다 — 모델을 바꿔도 응답을 읽는 코드는 그대로 둘 수 있습니다. ## 크기와 품질 쓸 수 있는 사이즈·종횡비는 모델마다 다릅니다. 손으로 적어 두지 말고 [모델 목록](https://ai.cubion.kr/docs/models) 에서 받아 선택 버튼을 만드세요 — `mode` 가 `image_generation` 인 항목에 `image.sizes` 가 함께 옵니다. cURL: ```bash curl https://ai.cubion.kr/v1/models -H "Authorization: Bearer $GATEWAY_KEY" # → data[] 중 mode:"image_generation" 인 항목에 image.sizes 가 들어 있습니다 ``` Python: ```python models = ai.models.list() image_models = [m for m in models.data if getattr(m, "mode", None) == "image_generation"] # 각 항목의 image.sizes 로 사이즈 선택 버튼을 만듭니다 ``` Node.js: ```javascript const models = await ai.models.list(); const imageModels = models.data.filter( (m) => (m as { mode?: string }).mode === "image_generation" ); // 각 항목의 image.sizes 로 사이즈 선택 버튼을 만듭니다 ``` ## 알아 둘 점 - `size`·`quality` 는 선택입니다. 확신이 없으면 빼고 부르세요 — 모델이 지원하지 않는 값을 넣는 것보다 안전합니다. - 목록에 없는 모델을 부르면 `403 model_not_allowed` 입니다. 모델명은 앱에 박아 두지 말고 `GET /v1/models` 로 채우세요. - 이미지 생성은 채팅보다 한 번 호출의 비용이 큽니다. 같은 프롬프트를 반복해 부르지 말고 만든 이미지를 저장해 두세요. 차감 내역은 [사용량](https://ai.cubion.kr/app/usage) 에서 확인할 수 있습니다. - 막힌 요청의 상태 코드와 본문 형태는 [오류 처리](https://ai.cubion.kr/docs/errors) 를 보세요. --- # 음성 합성 > POST /v1/audio/speech — 문장을 음성 파일로 바꿉니다. 원문: https://ai.cubion.kr/docs/tts ## 호출하기 문장을 음성으로 합성합니다. 다른 엔드포인트와 달리 **응답이 JSON 이 아니라 오디오 바이트**입니다 — `res.json()` 으로 읽으면 실패합니다. 파일로 저장하거나 그대로 재생하세요. 아래는 Gemini 계열 예제입니다. `voice` 에 **프리셋 목소리 이름**을 넣습니다. cURL: ```bash curl -X POST https://ai.cubion.kr/v1/audio/speech \ -H "Authorization: Bearer $GATEWAY_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "gemini-3.1-flash-tts-preview", "voice": "Kore", "input": "안녕하세요, 반갑습니다.", "instructions": "밝고 따뜻한 톤으로 읽어 주세요.", "response_format": "wav", "language": "ko-KR" }' \ --output out.wav ``` Python: ```python import os from openai import OpenAI ai = OpenAI(base_url="https://ai.cubion.kr/v1", api_key=os.environ["GATEWAY_KEY"]) # 응답은 오디오 바이트다 — 스트리밍 응답으로 받아 그대로 파일에 쓴다 with ai.audio.speech.with_streaming_response.create( model="gemini-3.1-flash-tts-preview", voice="Kore", # 프리셋 목소리 이름 input="안녕하세요, 반갑습니다.", instructions="밝고 따뜻한 톤으로 읽어 주세요.", response_format="wav", # Gemini 는 wav 만 ) as res: res.stream_to_file("out.wav") ``` Node.js: ```javascript import OpenAI from "openai"; import { writeFile } from "node:fs/promises"; const ai = new OpenAI({ baseURL: "https://ai.cubion.kr/v1", apiKey: process.env.GATEWAY_KEY }); const audio = await ai.audio.speech.create({ model: "gemini-3.1-flash-tts-preview", voice: "Kore", // 프리셋 목소리 이름 input: "안녕하세요, 반갑습니다.", instructions: "밝고 따뜻한 톤으로 읽어 주세요.", response_format: "wav", // Gemini 는 wav 만 }); // 응답은 오디오 바이트다 await writeFile("out.wav", Buffer.from(await audio.arrayBuffer())); ``` `language`(예: `ko-KR`)는 선택입니다. SDK 예제에서는 빼 두었지만, cURL 예제처럼 본문에 함께 보내도 됩니다. ## Gemini 와 Fish 의 차이 같은 `POST /v1/audio/speech` 지만, 어느 계열 모델을 고르느냐에 따라 `voice` 에 넣는 값과 쓸 수 있는 포맷·길이가 다릅니다. 여기를 헷갈리면 `400` 이 납니다. | | Gemini | Fish (fish/…) | | --- | --- | --- | | **voice** | 프리셋 이름 (`Kore`, `Achernar` …) | `reference_id` — 등록된 음성 모델의 id | | **response_format** | `wav` 만 | `wav` `mp3` `opus` `pcm` | | **input 길이** | 200자 | 2,000자 | | **instructions** | 억양 지시로 반영됨 | 무시됨 | ### Fish 계열 호출 Fish 계열(`fish/s1` 등)은 `voice` 에 **목소리 이름이 아니라** 내 음성 모델의 `reference_id` 를 넣습니다. `instructions` 를 보내도 반영되지 않고 조용히 버려집니다. cURL: ```bash curl -X POST https://ai.cubion.kr/v1/audio/speech \ -H "Authorization: Bearer $GATEWAY_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"fish/s1","voice":"","input":"안녕하세요","response_format":"mp3"}' \ --output out.mp3 ``` Python: ```python with ai.audio.speech.with_streaming_response.create( model="fish/s1", voice="", # 프리셋 이름이 아니라 등록된 음성 모델의 id input="안녕하세요", response_format="mp3", # wav | mp3 | opus | pcm ) as res: res.stream_to_file("out.mp3") ``` Node.js: ```javascript const audio = await ai.audio.speech.create({ model: "fish/s1", voice: "", // 프리셋 이름이 아니라 등록된 음성 모델의 id input: "안녕하세요", response_format: "mp3", // wav | mp3 | opus | pcm }); await writeFile("out.mp3", Buffer.from(await audio.arrayBuffer())); ``` ## 목소리·포맷 고르기 쓸 수 있는 목소리와 포맷은 [모델 목록](https://ai.cubion.kr/docs/models) 에 함께 실려 옵니다 — `mode` 가 `audio_speech` 인 항목에 `audio` 가 붙습니다. 선택 버튼을 손으로 채우지 말고 이 값으로 만드세요. **GET /v1/models 의 audio_speech 항목** ``` { "id": "fish/s1", "object": "model", "mode": "audio_speech", "audio": { "voices": ["", "..."], "formats": ["wav", "mp3", "opus", "pcm"], "languages": ["ko-KR", "..."] } } ``` ## 길이·포맷 제한 규칙을 넘기면 오디오 대신 JSON 오류가 돌아옵니다. 이 두 가지가 실제로 자주 납니다. | 상태 | error | 언제 | | --- | --- | --- | | 400 | `input_too_long` | `input` 이 상한을 넘었습니다 — Gemini 200자, Fish 2,000자. 응답의 `max` 에 상한이 함께 옵니다. 긴 대본은 문장 단위로 나눠 여러 번 부르고 오디오를 이어 붙이세요. | | 400 | `unsupported_format` | 그 모델이 못 내는 `response_format` 입니다. Gemini 에 `mp3` 를 요청하면 여기서 걸립니다. 응답의 `supported` 에 쓸 수 있는 포맷이 함께 옵니다. | > **보낸 문장은 로그에 남지 않습니다** > `input` 은 요청 로그·미리보기·오류 메시지 어디에도 남기지 않습니다. 기록되는 건 상태 코드·지연과 길이(수)뿐입니다. - `model`·`voice`·`input` 중 하나라도 빠지면 `400` 입니다. - 목록에 없는 모델을 부르면 `403 model_not_allowed` 입니다. - 그 밖의 상태 코드와 재시도 규칙은 [오류 처리](https://ai.cubion.kr/docs/errors) 를 보세요. --- # 모델 목록 > GET /v1/models — 이 키로 쓸 수 있는 모델을 확인합니다. 원문: https://ai.cubion.kr/docs/models ## 호출하기 **내 키가 쓸 수 있는 모델만** 내려옵니다. 키가 살아 있는지 확인하는 일과 모델을 고르는 일을 이 호출 하나로 끝낼 수 있습니다. cURL: ```bash curl https://ai.cubion.kr/v1/models -H "Authorization: Bearer $GATEWAY_KEY" # 200 이면 키가 살아 있는 것입니다. ``` Python: ```python import os from openai import OpenAI ai = OpenAI(base_url="https://ai.cubion.kr/v1", api_key=os.environ["GATEWAY_KEY"]) models = ai.models.list() chat = [m for m in models.data if getattr(m, "mode", None) == "chat"] print([m.id for m in chat]) ``` Node.js: ```javascript import OpenAI from "openai"; const ai = new OpenAI({ baseURL: "https://ai.cubion.kr/v1", apiKey: process.env.GATEWAY_KEY }); // 드롭다운을 손으로 채우지 말고 이 목록으로 만드세요 — // 모델이 늘거나 빠져도 앱을 고칠 필요가 없습니다. const models = await ai.models.list(); const chat = models.data.filter((m) => (m as { mode?: string }).mode === "chat"); ``` 인증에 실패하면 `401` 이 옵니다 — 앱의 “연결 테스트” 버튼을 이 호출로 만들면 됩니다. ## 응답 읽기 OpenAI 와 같은 `{ object, data[] }` 형태에, 이 키에 대한 정보가 `key` 로 덧붙습니다. **응답** ``` { "object": "list", "data": [ { "id": "gpt-4o", "object": "model", "owned_by": "openai", "mode": "chat" }, { "id": "gemini/gemini-2.5-flash", "object": "model", "owned_by": "gemini", "mode": "chat" }, { "id": "gpt-image-1", "object": "model", "owned_by": "openai", "mode": "image_generation" }, { "id": "fish/s1", "object": "model", "owned_by": "fish", "mode": "audio_speech" } ], "key": { "name": "내 키", "scopes": ["ai"], "restricted": false } } ``` ### mode 로 용도 구분 `mode` 는 OpenAI 스펙에는 없는 부가 정보입니다. 채팅용 드롭다운에 이미지 모델이 섞여 들어가지 않게, 목록을 `mode` 로 걸러 쓰세요. | mode | 엔드포인트 | 참고 | | --- | --- | --- | | `chat` | `POST /v1/chat/completions` | [채팅·응답](https://ai.cubion.kr/docs/chat) | | `image_generation` | `POST /v1/images/generations` | `image.sizes` 가 함께 옵니다 — [이미지 생성](https://ai.cubion.kr/docs/images) | | `audio_speech` | `POST /v1/audio/speech` | `audio.voices`·`formats` 가 함께 옵니다 — [음성 합성](https://ai.cubion.kr/docs/tts) | ## 모델명 표기 규칙 목록을 보면 `gpt-4o` 처럼 이름만 있는 모델과 `gemini/gemini-2.5-flash` 처럼 앞에 한 토막이 붙은 모델이 섞여 있습니다. 앞에 붙은 `gemini/`·`fish/` 는 게이트웨이가 어느 제공자로 보낼지 고르는 표시이고, 없는 이름은 그럴 필요가 없는 모델입니다. > **규칙을 외울 필요는 없습니다** > 목록에 적힌 `id` 를 **그대로** 요청의 `model` 에 넣으면 됩니다. 접두사를 임의로 떼거나 붙이면 다른 이름이 되어 `403 model_not_allowed` 가 납니다. **목록의 id 를 그대로 쓴다** ``` { "model": "gpt-4o", ... } // 접두사 없음 { "model": "claude-sonnet-4-5", ... } // 접두사 없음 { "model": "gemini/gemini-2.5-flash", ... } // gemini/ 가 이름의 일부다 { "model": "fish/s1", ... } // fish/ 가 이름의 일부다 ``` ## 알아 둘 점 - **모델명을 앱에 박아 두지 마세요.** 쓸 수 있는 모델은 바뀔 수 있습니다. 목록으로 드롭다운을 채우면 모델이 늘거나 빠져도 앱을 고칠 일이 없습니다. - 목록에 없는 모델을 부르면 `403 model_not_allowed` 입니다 — 키에 허용 목록이 걸려 있으면 그 안의 모델만 내려옵니다. - `key.scopes` 가 `["ai"]` 면 포털에서 발급한 키입니다. `/v1/*` 호출에만 쓸 수 있고, 다른 경로를 부르면 `403 scope_forbidden` 입니다. - 목록 호출 자체에는 요금이 붙지 않습니다. 그래도 앱 켤 때 한 번 받아 두고 쓰는 편이 빠릅니다. - 키 발급·폐기는 [API 키](https://ai.cubion.kr/app/keys) 에서 합니다. 상태 코드 전체는 [오류 처리](https://ai.cubion.kr/docs/errors) 를 보세요. --- # 기능 프로필 > model 에 feature:<이름> 을 쓰면 모델·기본값·폴백을 게이트웨이에서 정합니다. 원문: https://ai.cubion.kr/docs/features ## 무엇인가 `model` 에 실제 모델 이름 대신 `feature:wi-extract` 처럼 **기능 이름**을 쓰면, 운영자가 그 기능에 정해 둔 모델과 기본값이 적용됩니다. 모델을 바꿀 때 앱 코드를 고치고 다시 배포할 필요가 없습니다. **요청** ``` curl https://ai.cubion.kr/v1/chat/completions \ -H "Authorization: Bearer $GATEWAY_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"feature:wi-extract","messages":[{"role":"user","content":"…"}]}' ``` 실제로 처리한 모델은 응답 헤더 `X-Gateway-Model` 로 확인할 수 있습니다([요청 추적 헤더](https://ai.cubion.kr/docs/errors#tracing)). 기능은 운영자가 관리 화면에서 만듭니다 — 필요한 기능 이름과 설정을 운영자에게 요청하세요. ## 기능에 정할 수 있는 것 | 항목 | 동작 | | --- | --- | | 모델 | 이 기능이 부를 실제 모델. | | 시스템 프롬프트 | 요청에 `system` 메시지가 **없을 때만** 맨 앞에 넣습니다. | | `temperature` | 요청에 없을 때만 적용합니다. | | `max_tokens` | 요청에 `max_tokens`·`max_completion_tokens` 가 둘 다 없을 때만 적용합니다. | | `response_format` | 요청에 없을 때만 적용합니다(JSON 출력 강제 등). | | 타임아웃 | 상위 호출 한 번의 제한 시간(5–600초, 기본 120초). 긴 문서 추출처럼 오래 걸리는 기능에 씁니다. | | 폴백 모델 | 주 모델의 제공사가 전부 실패(한도·크레딧 소진·장애·인증)하면 차례로 시도할 모델. | > **요청에 적은 값이 항상 이깁니다** > 기능 설정은 "비어 있을 때의 기본값"입니다. 특정 호출에서만 다르게 하고 싶으면 요청에 값을 적으면 됩니다. ## 폴백 모델 - 먼저 같은 모델을 **다른 키**로 다시 보냅니다(게이트웨이에 같은 제공사 키가 여러 개 있을 때). 그래도 전부 실패하면 폴백 모델로 넘어갑니다. - 요청 자체가 잘못된 오류(`400` 등)에는 넘어가지 않습니다 — 모델을 바꿔도 같은 이유로 실패하고, 원래 원인이 가려지기 때문입니다. - 폴백 모델도 이 키가 쓸 수 있는 모델이어야 합니다. 허용되지 않은 폴백 모델은 건너뜁니다. - 폴백이 일어났는지는 `X-Gateway-Model`(실제 모델)과 `X-Gateway-Attempts`(시도 횟수)로 알 수 있습니다. ## 임베딩 `/v1/embeddings` 에도 `feature:` 이름을 쓸 수 있습니다. 모델 이름만 바뀌고, 폴백 모델은 쓰지 않습니다 — 모델마다 벡터 차원이 달라 색인과 섞이면 검색이 조용히 망가지기 때문입니다. ## 알아 둘 점 - 키에 허용 모델을 정해 두었다면, 기능이 가리키는 **실제 모델**도 허용되어 있어야 합니다. 아니면 `403 model_not_allowed` 가 옵니다. - 등록되지 않았거나 꺼진 기능 이름은 모델이 없는 것으로 처리되어 실패합니다. - Claude 계열 모델은 OpenAI 호환 방식에서 `response_format` 과 도구의 `strict` 를 무시합니다. 스키마가 꼭 지켜져야 하는 추출에는 GPT 계열 모델을 쓰고, 받은 JSON 은 앱에서 한 번 더 검증하세요. 사용량은 실제 모델 이름으로 집계됩니다. 기능별 비용을 따로 보려면 기능마다 키를 나눠 쓰세요. --- # 오류 처리 > 상태 코드와 오류 본문의 형태, 재시도 규칙. 원문: https://ai.cubion.kr/docs/errors ## 오류 본문의 모양 게이트웨이가 상위 모델로 보내기 전에 **직접 막은** 요청은 `429` 와 함께 아래 본문으로 돌아옵니다. 코드 하나만 오는 게 아니라 **사람이 읽을 수 있는 한 문장**이 같이 오기 때문에, 최종 사용자에게 그대로 보여 주면 됩니다. **응답 본문** ``` type GateReason = "insufficient_balance" | "budget_exceeded" | "rate_limited"; interface GateBody { error: GateReason; // 코드 문자열 — === 로 비교하면 된다 message: string; // 사람이 읽는 평문 한 문장 (HTML 아님) topup_url?: string; // 충전으로 풀리는 경우에만 — insufficient_balance 뿐이다 } ``` - `error` 는 코드 문자열입니다 — `=== "insufficient_balance"` 처럼 비교하세요. 문구(`message`)로 분기하면 안내 문장을 다듬는 순간 깨집니다. - `message` 는 **평문**입니다. HTML 이 아니라서 터미널·로그에 찍어도 태그가 섞이지 않고, 화면에 넣을 때는 `textContent` 를 쓰면 됩니다. - `topup_url` 은 **URL 문자열**입니다. 버튼으로 만들지 링크로 둘지는 앱이 정합니다. > **message 를 innerHTML 로 꽂지 마세요** > 응답이 어디로 갈지는 게이트웨이가 모릅니다. 평문으로 주는 이유가 그것이고, `innerHTML` 로 넣으면 게이트웨이 응답이 그대로 DOM 이 되는 통로가 열립니다. **화면에 넣을 때** ``` // 게이트웨이 응답을 화면에 넣을 때 el.textContent = e.message; // ✅ 평문 그대로 — 태그가 섞일 일이 없다 el.innerHTML = e.message; // ❌ 쓰지 말 것 — 응답을 DOM 으로 만드는 XSS 통로가 된다 ``` ## 429 세 가지를 구분하기 같은 `429` 라도 사용자가 할 수 있는 일이 전혀 다릅니다. 셋을 뭉뚱그리면 충전하면 풀리는 상황에도 "잠시 후 다시 시도"만 띄우게 됩니다. - `insufficient_balance` — 잔액이 0원입니다. **충전하면 즉시 재개**되고, 이 코드에만 `topup_url` 이 붙습니다. 키를 다시 발급할 필요는 없습니다. 여기까지 오기 전에 미리 알고 싶다면 [GET /v1/balance](https://ai.cubion.kr/docs/billing#check-balance) 로 잔액을 확인할 수 있습니다. - `budget_exceeded` — 키에 걸린 **월 예산**을 다 썼습니다. 충전으로는 풀리지 않아 `topup_url` 이 없습니다. 관리자에게 문의해야 합니다. - `rate_limited` — 분당 요청 한도입니다. 잠시 기다렸다 다시 보내면 됩니다. **잔액 소진 — topup_url 이 붙는다** ``` HTTP/1.1 429 Too Many Requests Content-Type: application/json { "error": "insufficient_balance", "message": "잔액이 모두 소진되었습니다. 사용량 패키지를 구매하면 즉시 다시 이용할 수 있습니다.", "topup_url": "https://ai.cubion.kr/app/billing" } ``` **월 예산 소진 — topup_url 이 없다** ``` HTTP/1.1 429 Too Many Requests Content-Type: application/json { "error": "budget_exceeded", "message": "이 키에 설정된 월 예산을 모두 사용했습니다. 관리자에게 문의해 주세요." } // topup_url 이 없다 — 충전해도 풀리지 않는 상황이라 일부러 안 보낸다. ``` > **충전 버튼은 topup_url 이 있을 때만** > `topup_url` 유무로 분기하면 세 가지를 따로 외울 필요가 없습니다. 값이 있으면 충전 버튼을 띄우고, 없으면 `message` 만 보여 주세요. 잔액이 어떻게 빠지는지는 [요금·잔액](https://ai.cubion.kr/docs/billing) 에 있습니다. ## 재시도 규칙 `429` 응답에는 기다릴 시간(초)을 알려 주는 `Retry-After` 헤더가 붙습니다. 상위 모델이 내린 429 를 전달할 때도 상위가 준 값을 그대로 넘깁니다. 드물게 상위가 값을 주지 않는 경우가 있으니 `Number(res.headers.get("Retry-After") ?? 5)` 처럼 기본값을 두고 읽으세요. Python: ```python import os, time, requests URL = "https://ai.cubion.kr/v1/chat/completions" HEAD = { "Authorization": f"Bearer {os.environ['GATEWAY_KEY']}", "Content-Type": "application/json", } def call_gateway(body, retried=False): res = requests.post(URL, headers=HEAD, json=body) if res.ok: return res.json() try: e = res.json() except ValueError: e = {} if e.get("error") == "insufficient_balance": show_notice(e.get("message", "")) # 평문이라 그대로 띄워도 안전하다 if e.get("topup_url"): # 이 코드에만 붙는다 show_topup_button(e["topup_url"]) return None if e.get("error") == "budget_exceeded": show_notice(e.get("message", "")) # 충전으로는 안 풀린다 — 충전 버튼을 띄우지 말 것 return None if e.get("error") == "rate_limited" and not retried: # Retry-After 가 없는 경로도 있으므로 기본값을 둔다. 재시도는 한 번만. wait = int(res.headers.get("Retry-After") or 5) time.sleep(wait) return call_gateway(body, retried=True) raise RuntimeError(e.get("message") or e.get("error") or f"HTTP {res.status_code}") ``` Node.js: ```javascript const KEY = process.env.GATEWAY_KEY; // 429 를 한곳에서 처리해 두면, 사용자는 "왜 안 되지"가 아니라 "충전하면 되는구나"를 보게 된다. async function callGateway(body, retried = false) { const res = await fetch("https://ai.cubion.kr/v1/chat/completions", { method: "POST", headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (res.ok) return res.json(); const e = await res.json().catch(() => ({})); if (e.error === "insufficient_balance") { showNotice(e.message); // 평문 — textContent 로 넣는다 if (e.topup_url) showTopupButton(e.topup_url); // 이 코드에만 붙는다 return null; } if (e.error === "budget_exceeded") { showNotice(e.message); // 충전으로는 안 풀린다 — 충전 버튼을 띄우지 말 것 return null; } if (e.error === "rate_limited" && !retried) { // Retry-After 가 없는 경로도 있으므로 기본값을 둔다. 재시도는 한 번만 — // 막힌 채로 계속 두드리면 한도가 풀리지 않는다. const wait = Number(res.headers.get("Retry-After") ?? 5); await new Promise((r) => setTimeout(r, wait * 1000)); return callGateway(body, true); } throw new Error(e.message ?? e.error ?? `HTTP ${res.status}`); } ``` - **재시도는 한 번만.** 막힌 채로 계속 두드리면 한도가 풀리지 않고, 그대로 재시도 폭주가 됩니다. - `insufficient_balance` · `budget_exceeded` 는 **재시도 대상이 아닙니다**. 기다린다고 풀리지 않으니 곧바로 사용자에게 알리세요. - `401` · `403` · `400` 도 재시도하지 마세요. 요청이나 키를 고쳐야 하는 오류입니다. ## 요청 추적 헤더 모든 `/v1` 응답(오류 포함)에 아래 헤더가 붙습니다. 문의할 때 `X-Request-Id` 하나만 알려 주시면 해당 요청의 기록을 바로 찾을 수 있습니다. | 헤더 | 뜻 | | --- | --- | | `X-Request-Id` | 게이트웨이가 요청마다 만든 ID(UUID). 우리 쪽 요청 기록과 같은 값입니다. | | `X-Client-Request-Id` | 요청에 이 헤더를 보내면 기록에 함께 남고 응답에도 그대로 돌아옵니다. 문서 ID·작업 ID 처럼 여러분 쪽 식별자를 넣으세요(영문·숫자·기호 128자까지). | | `X-Gateway-Attempts` | 상위 호출 횟수. 1 보다 크면 다른 키·모델로 폴백했거나 한도 때문에 재시도했다는 뜻입니다. | | `X-Gateway-Model` | 실제로 처리한 모델. `feature:` 이름으로 불렀거나 폴백 모델로 넘어갔을 때 확인하세요. | **예** ``` curl -i https://ai.cubion.kr/v1/chat/completions \ -H "Authorization: Bearer $GATEWAY_KEY" \ -H "X-Client-Request-Id: wi-2026-00412" \ -H "Content-Type: application/json" \ -d '{"model":"gpt-4.1","messages":[{"role":"user","content":"안녕"}]}' HTTP/1.1 200 OK x-request-id: 3f0c2a4e-8a51-4a6f-9b3e-0d2f7c1e9a10 x-client-request-id: wi-2026-00412 x-gateway-attempts: 1 x-gateway-model: gpt-4.1 ``` ## 상태 코드 게이트웨이가 내보내는 오류는 아래가 전부입니다. | 상태 | error | 언제 / 어떻게 | | --- | --- | --- | | 401 | `unauthorized` | 키가 틀렸거나 폐기·비활성 상태입니다. [API 키](https://ai.cubion.kr/app/keys) 에서 다시 발급하세요. | | 403 | `scope_forbidden` | 키 권한 밖의 엔드포인트입니다(`{"error":"scope_forbidden","need":"ai"}`). 포털에서 발급한 키는 `/v1/*` AI 호출만 쓸 수 있습니다. | | 403 | `model_not_allowed` | 이 키로 쓸 수 없는 모델입니다. 응답에 `model` 이 함께 옵니다 — [모델 목록](https://ai.cubion.kr/docs/models) 이 내려주는 범위에서 고르세요. | | 429 | `insufficient_balance` | 잔액이 0원입니다. `topup_url` 로 보내 충전하면 즉시 재개됩니다. | | 429 | `budget_exceeded` | 키에 걸린 월 예산을 다 썼습니다. 충전으로는 풀리지 않아 `topup_url` 이 없습니다 — 관리자에게 문의해 주세요. | | 429 | `rate_limited` | 분당 요청 한도를 넘었습니다. `Retry-After` 초만큼(없으면 몇 초) 기다린 뒤 한 번만 재시도하세요. | | 400 | `invalid_json` · `missing model` | 본문이 JSON 이 아니거나 `model` 이 비었습니다. | | 400 | `input_too_long` · `unsupported_format` | [음성 합성](https://ai.cubion.kr/docs/tts) 에서 입력 길이나 `response_format` 이 모델이 받는 범위를 넘었습니다. 응답에 허용치(`max` · `supported`)가 함께 옵니다. | | 502 | `upstream_unreachable` · `no_upstream_for_model` | 상위 모델 쪽 장애이거나 그 모델을 받아 줄 곳이 없습니다. | | 502 | `upstream_auth_failed` | 게이트웨이 뒤의 제공사 키가 거절됐습니다(운영 쪽 문제). 다른 키가 있으면 자동으로 넘어가고, 모두 거절되면 이 코드가 옵니다. 운영자에게 자동으로 알림이 갑니다 — 몇 분 뒤 다시 시도하세요. | | 503 | `upstream_quota_exhausted` | 게이트웨이 뒤의 AI 제공사 쪽 이용 한도가 소진됐습니다. 여러분의 잔액과는 무관하고, 운영자에게 자동으로 알림이 갔습니다. 운영자가 조치하기 전에는 다시 불러도 풀리지 않습니다. | `400` 은 요청을 고쳐야 풀립니다. `502` 는 잠시 후 다시 부르면 대개 풀리지만, 계속 같은 모델에서만 난다면 다른 모델로 바꿔 보세요. `503 upstream_quota_exhausted` 는 짧은 간격으로 재시도하지 마세요 — 몇 분 단위로 다시 확인하거나, 다른 제공사의 모델로 바꾸면 됩니다. --- # 요금·잔액 > 무엇으로 얼마가 빠지고, 잔액이 바닥나면 어떻게 되는지. 원문: https://ai.cubion.kr/docs/billing ## 어떻게 차감되나 구매한 사용량 패키지에서 **사용한 만큼 원화로** 빠집니다. 월 정액도, 기본료도 없습니다. 호출이 끝날 때마다 그 호출의 금액이 잔액에서 차감되고, 남은 잔액은 화면 오른쪽 위에 늘 떠 있습니다. - **쓴 만큼만.** 호출하지 않는 달에는 아무것도 빠지지 않습니다. - **스트리밍도 똑같이 집계됩니다.** 중간에 연결이 끊겨도 그때까지 오간 만큼은 차감됩니다 — 받다 만 응답이라고 공짜가 되지는 않습니다. - **막힌 요청은 과금되지 않습니다.** `401` · `403` · `429` 처럼 게이트웨이가 상위로 보내기 전에 막은 호출은 모델을 부르지 않았으니 잔액도 건드리지 않습니다. - 한 계정의 잔액은 **키를 나눠 써도 하나**입니다. 키를 여러 개 발급해도 잔액은 계정 단위입니다. 같은 문장을 반복해서 임베딩하거나 같은 프롬프트를 매번 다시 부르면 그만큼 그대로 빠집니다. 결과를 저장해 두는 것이 가장 확실한 절약입니다. ## 충전하기 충전은 [충전·결제](https://ai.cubion.kr/app/billing) 화면에서 합니다. 결제가 끝나면 잔액에 바로 더해집니다. ### 충전·결제 열기 [/app/billing](https://ai.cubion.kr/app/billing) 에서 현재 잔액과 지금까지의 충전·차감 내역을 한 화면에서 봅니다. ### 금액 입력 한 번에 **10,000원 ~ 2,000,000원** 사이로 구매할 수 있습니다. ### 결제 후 바로 재개 충전액은 즉시 잔액에 반영됩니다. 잔액이 없어 막혀 있던 호출도 그대로 다시 나가며, **키를 다시 발급할 필요는 없습니다.** > **바닥나기 전에 알려 드립니다** > 잔액은 화면 오른쪽 위에 늘 떠 있고, 얼마 남지 않으면 배너로도 알립니다. 자동 결제는 없으니 배너가 뜨면 직접 충전하세요. ## 잔액이 0원이 되면 호출이 막힙니다. 이때 돌아오는 응답이 `429` `insufficient_balance` 이고, 본문에는 사람이 읽을 수 있는 한 문장(`message`)과 충전 페이지 주소(`topup_url`)가 함께 옵니다. 앱에서 이 응답을 어떻게 받아 처리하는지는 [오류 처리](https://ai.cubion.kr/docs/errors) 에 있습니다. | 막힌 이유 | error | 푸는 방법 | | --- | --- | --- | | 잔액 소진 | `insufficient_balance` | [충전](https://ai.cubion.kr/app/billing) 하면 즉시 재개됩니다. 응답에 `topup_url` 이 붙습니다. | | 키 월 예산 소진 | `budget_exceeded` | 키에 걸린 월 예산입니다. **충전으로는 풀리지 않아** `topup_url` 도 없습니다 — 관리자에게 문의해 주세요. | | 분당 요청 한도 | `rate_limited` | 잔액과 무관합니다. 잠시 기다렸다 한 번만 다시 보내면 됩니다. | > **잔액이 0원이어도 키는 살아 있습니다** > 막히는 것은 호출이지 키가 아닙니다. 충전 뒤 재발급 없이 쓰던 키를 그대로 쓰면 됩니다. 키를 새로 발급하면 오히려 앱에 박아 둔 키를 전부 바꿔야 합니다. ## 코드에서 잔액 확인하기 `GET /v1/balance` 로 지금 잔액을 읽을 수 있습니다. 호출이 429 로 막히고 나서야 아는 대신, 미리 확인해 사용자에게 안내를 띄울 수 있습니다. cURL: ```bash curl https://ai.cubion.kr/v1/balance \ -H "Authorization: Bearer $GATEWAY_KEY" ``` Python: ```python import os, httpx r = httpx.get( "https://ai.cubion.kr/v1/balance", headers={"Authorization": f"Bearer {os.environ['GATEWAY_KEY']}"}, ) b = r.json() if b["balance"] <= 0: print("잔액이 없습니다:", b["topup_url"]) elif b["low_balance"]: print("잔액이 얼마 남지 않았습니다:", b["balance"], b["currency"]) ``` Node.js: ```javascript const r = await fetch("https://ai.cubion.kr/v1/balance", { headers: { Authorization: `Bearer ${process.env.GATEWAY_KEY}` }, }); const b = await r.json(); if (b.balance <= 0) { console.warn("잔액이 없습니다:", b.topup_url); } else if (b.low_balance) { console.warn("잔액이 얼마 남지 않았습니다:", b.balance, b.currency); } ``` **응답** ``` { "object": "balance", "currency": "KRW", "balance": 12345.67, "low_balance": false, "topup_url": "https://ai.cubion.kr/app/billing" } ``` | 필드 | 설명 | | --- | --- | | `balance` | 남은 잔액. 원 단위이고 소수점 두 자리까지 의미가 있습니다. | | `currency` | 지금은 `KRW` 하나뿐입니다. 금액만 보고 단위를 짐작하지 않도록 함께 보냅니다. | | `low_balance` | 설정된 기준 아래로 내려왔는지. 기준이 꺼져 있으면 항상 false 입니다. | | `topup_url` | 충전 페이지 주소. 429 `insufficient_balance` 응답의 `topup_url` 과 같습니다. | > **잔액이 0원이어도 200 으로 답합니다** > 잔액을 확인하고 싶은 순간이 바로 0원일 때라서, 이 호출만은 잔액 검사에 걸리지 않습니다. 호출 요금도 들지 않고, 분당 한도도 걸리지 않습니다 — 충전이 반영됐는지 몇 초 간격으로 확인해도 됩니다. 스코프와 무관하게 아무 키로나 부를 수 있습니다. 잔액은 키가 아니라 계정에 붙어 있어서, 같은 계정의 어떤 키로 불러도 같은 값이 나옵니다. 관리자가 발급한 키(선불 계정에 묶이지 않은 키)는 `404 no_prepaid_balance` 를 받습니다. ## 사용 내역 확인 [사용량](https://ai.cubion.kr/app/usage) 에서 기간을 바꿔 가며 **모델별·키별** 호출 수와 청구 금액을 볼 수 있습니다. 어떤 키가 얼마를 쓰고 있는지 여기서 먼저 확인하세요. - [사용량](https://ai.cubion.kr/app/usage) — 기간별 호출 수와 금액, 모델·키별로 나눠 봅니다. - [충전·결제](https://ai.cubion.kr/app/billing) — 충전 내역과 차감 내역을 한 건씩 확인합니다. - 금액이 갑자기 늘었다면 키별 사용량부터 보세요. 유출이 의심되면 [API 키](https://ai.cubion.kr/app/keys) 에서 그 키를 폐기하면 즉시 호출이 막힙니다. 집계는 어떤 키가 어떤 모델을 언제 몇 번 불렀고 얼마가 차감됐는지로 쌓입니다. 표의 기간을 좁혀 두면 어제 무엇이 늘었는지가 바로 보입니다. ## 코드에서 사용량 조회하기 `GET /v1/usage` 는 **호출한 키의** 요청 수·토큰·청구 금액(원)을 돌려줍니다. 사내 대시보드에 그대로 그리면 됩니다. 기간은 한국 시간 날짜(`YYYY-MM-DD`)이고, 생략하면 이번 달 1일부터 오늘까지입니다(최대 92일). **요청** ``` curl "https://ai.cubion.kr/v1/usage?from=2026-09-01&to=2026-09-30&group_by=model,day" \ -H "Authorization: Bearer $GATEWAY_KEY" ``` **응답** ``` { "object": "usage", "currency": "KRW", "from": "2026-09-01", "to": "2026-09-30", "group_by": "model,day", "billed_krw_source": "ledger", "total": { "requests": 1240, "prompt_tokens": 1830211, "completion_tokens": 211904, "billed_krw": 18342.55 }, "data": [ { "model": "gpt-4.1", "day": "2026-09-01", "requests": 42, "prompt_tokens": 61210, "completion_tokens": 7102, "billed_krw": 612.40 } ] } ``` | 파라미터 | 설명 | | --- | --- | | `from · to` | 조회 기간(포함). 한국 시간 기준 날짜. | | `group_by` | `model`(기본) · `day` · `model,day` | 금액은 청구 기준 원화뿐입니다. 선불 계정 키는 잔액에서 실제로 빠진 금액(`billed_krw_source: "ledger"`), 관리자가 발급한 키는 같은 요금 공식으로 계산한 금액(`"policy"`)입니다. 키마다 따로 집계되므로 용도별로 키를 나눠 쓰면 용도별 비용이 됩니다. 잔액 조회와 마찬가지로 호출 요금·분당 한도가 걸리지 않습니다.