AI 에게 주는 입문서 — 첫 요청부터 오류까지
바로 도는 코드부터 — 첫 요청 · 스트림 · 도구 · 오류 갈래. 쓰시는 AI 에게 이 장을 넘기셔도 돼요.
주소 하나(https://oneport.kr/v1 · OpenAI 형식)로 여러 회사 모델을 불러요. 키(sk-so-…)는 코드에 적지 말고 환경변수 ONEPORT_API_KEY 에 두세요 — 브라우저 코드에 넣으면 페이지를 연 사람 누구나 봐요. 모델 id 는 GET https://oneport.kr/v1/models(키 없이 열려요)의 id 를 글자 그대로 써요 — 아래 예의 gpt-5-nano 는 10월 기준 가장 싼 GPT 이고, claude-haiku-5-5(가장 싼 Claude)로 바꿔도 같은 코드가 돌아요. 글 문으로 보낸 글과 답은 저희가 저장하지 않고 사용량만 남겨요(남는 것 전부는 「개요」 장 https://oneport.kr/docs.md) — 모델 회사 쪽 보관은 그 회사 정책이에요(벤더 동작).
1. 첫 요청#
export ONEPORT_API_KEY="sk-so-..." # 받은 키 — 이 창에서만 남아요
curl -s https://oneport.kr/v1/chat/completions \
-H "Authorization: Bearer $ONEPORT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "gpt-5-nano", "max_tokens": 2000, "messages": [{"role": "user", "content": "안녕하세요"}]}' \
| python3 -c 'import json, sys; print(json.load(sys.stdin)["choices"][0]["message"]["content"])'import os
from openai import OpenAI
client = OpenAI(api_key=os.environ["ONEPORT_API_KEY"], base_url="https://oneport.kr/v1", max_retries=0, timeout=300)
resp = client.chat.completions.create(
model="gpt-5-nano", # GET https://oneport.kr/v1/models 의 id 그대로
max_tokens=2000, # 생각하는 모델이라 수백으로 두면 답이 빌 수 있어요
messages=[{"role": "user", "content": "안녕하세요"}],
)
print(resp.choices[0].message.content)
print(resp.choices[0].finish_reason, resp.usage.prompt_tokens, resp.usage.completion_tokens)
print("쓴 크레딧:", getattr(resp.usage, "cost_credits", None)) # SDK 가 모르는 칸도 받아 둬요(SDK 동작) · 정산이 2초 안에 안 끝나면 None
print("요청 ID:", resp._request_id) # 머리글 x-request-id(SDK 동작)import OpenAI from "openai";
const client = new OpenAI({ apiKey: process.env.ONEPORT_API_KEY, baseURL: "https://oneport.kr/v1", maxRetries: 0, timeout: 300_000 });
const resp = await client.chat.completions.create({
model: "gpt-5-nano",
max_tokens: 2000,
messages: [{ role: "user", content: "안녕하세요" }],
});
console.log(resp.choices[0].message.content);
// TS 타입엔 없는 칸이라 형 변환해 읽어요 · 정산이 2초 안에 안 끝나면 undefined
console.log("쓴 크레딧:", (resp.usage as { cost_credits?: number } | undefined)?.cost_credits);
console.log("요청 ID:", resp._request_id); // 머리글 x-request-id(SDK 동작)답은 choices[0].message.content 예요. finish_reason 이 length 면 출력 상한에 닿은 답이라 max_tokens 를 올리세요 — Claude 가 답을 거절하면 content_filter 로 와요. gpt-5-nano 같은 생각하는 GPT 는 생각한 토큰도 이 상한에 들어가요(벤더 동작) — 그래서 2,000 을 뒀어요. GET /v1/models 의 supports_effort 는 reasoning_effort 를 받는 모델이라는 뜻일 뿐이에요 — claude-haiku-5-5 처럼 true 인 Claude 도 reasoning_effort 를 안 보내면 저희가 아무것도 더하지 않아요. 401 error.reason "missing_key"(「API 키가 없어요. …」)면 환경변수가 빈 채로 간 거예요(새 터미널 창이면 대개 이 경우예요). Windows PowerShell 은 「curl」 장(https://oneport.kr/docs/guides/curl.md)의 「Windows PowerShell」 절을 쓰세요. SDK 가 없는 곳(구글 Apps Script 의 UrlFetchApp 등)도 curl 과 같은 POST 예요 — 구글 시트에 붙여 넣을 함수는 「구글 시트에서 쓰기」 장(https://oneport.kr/docs/guides/google-sheets.md)에 있어요. 답을 정해진 JSON 으로 받으려면 response_format 에 {"type": "json_schema", "json_schema": {"name": …, "schema": {…}}} 를 실어요 — Claude 로 갈 때도 저희가 Claude 의 구조화 출력으로 옮겨 그 모양대로 답해요({"type": "json_object"} 는 Claude 에선 모양을 강제하지 않아요 · json_schema 에 "strict": true 를 실으면 스키마를 손대지 않고 넘겨서 Claude 가 못 받는 모양이면 4xx upstream_error 예요 · strict 없이 보내면 길이 · 개수 · 범위 제약(minLength · maxItems · minimum 등)은 빼고 옮기니 받은 뒤 직접 검사하세요 · 열쇠가 정해지지 않은 object(additionalProperties 를 true 나 스키마로 적은 것 · patternProperties)는 강제하지 않고 응답 머리글 x-gateway-ignored-params 에 response_format 을 적어요 · 칸 전부는 「글」 장 https://oneport.kr/docs/reference/chat.md 의 response_format 칸). claude-haiku-5-5 같은 Claude 5 세대에 temperature · top_p 를 실어도 400 이 아니에요 — 저희가 빼고 보내고 응답 머리글 x-gateway-ignored-params 에 적어요.
PDF 는 user content 에 {"type": "file", "file": {"filename": "a.pdf", "file_data": "data:application/pdf;base64,…"}} 로 넣어요 — Claude 로 갈 땐 저희가 Claude 의 PDF 형식으로 옮기고(filename 은 문서 제목으로 실어요 — 「이 PDF 이름이 뭐야」에 답할 수 있어요), GPT 는 그대로 넘겨요. 요청 한 번은 4.5MB 까지예요(base64 라 원본 약 3MB) — 넘으면 JSON 이 아닌 영어 413(FUNCTION_PAYLOAD_TOO_LARGE)이라 error.type 이 없어요. 부르기 전에 잡는 입력 몫은 쪽 수가 아니라 base64 바이트 ÷ 3 토큰이라 실제보다 크게 잡혀요 — 그 수가 pricing.tier 의 above_input_tokens 이상이면 tier 단가로 잡아요. 글자를 뽑아 보내면 어느 모델이든 되고 더 싸요.
usage.cost_credits 가 그 호출에서 뗀 크레딧이에요(소수 넷째 자리까지 올린 값 · 정산이 2초 안에 안 끝나면 칸 없이 와요 — 그땐 토큰 × GET /v1/models 단가로 셈해요). 잔액(GET /v1/credits)의 앞뒤 차이로는 세지 마세요 — 정산이 답 뒤에 따로 돌고 balance_credits 는 1 크레딧 아래를 버려요. 부르기 전에는 입력(요청 몸통 3바이트 = 1토큰) × 입력 단가(Claude 는 cache_control 을 실으면, 그 밖의 모델은 pricing.rates 에 cache_write 가 있으면 캐시 쓰기 단가)와 출력 상한(max_tokens · 안 적으면 8,192 — gpt-5.6-sol · gpt-6-sol · gpt-6-luna 는 몸통이 단순하면 128,000 이고 셈이 따로예요 · 「남은 한도」 장 https://oneport.kr/docs/reference/key.md) × 출력 단가를 더한 몫을 잔액 · 키 한도에서 잡아 두고, 끝나면 쓴 만큼만 떼요 — max_tokens 를 필요한 만큼만 적으면 402 hold_exceeds_balance · 429 key_month_limit 이 덜 나요. 문의할 땐 요청 ID(머리글 request-id · 같은 값이 x-request-id)와 부른 시각 · 모델 이름을 알려 주세요.
2. 스트림으로 받기#
import openai
stream = client.chat.completions.create(
model="gpt-5-nano",
max_tokens=2000,
messages=[{"role": "user", "content": "분수의 덧셈을 세 줄로 설명해 줘"}],
stream=True, # 시작 전 실패(402 · 429 …)는 여기서 openai.APIStatusError 로 와요 — 4번
)
text, finish, usage = [], None, None
try:
for chunk in stream:
if chunk.usage is not None: # 끝 조각 — 토큰 수만 실려 choices 가 빈 배열이에요
usage = chunk.usage
for ch in chunk.choices:
if ch.delta.content:
text.append(ch.delta.content)
print(ch.delta.content, end="", flush=True)
if ch.finish_reason:
finish = ch.finish_reason
except openai.APIError as e: # 도중 error 조각 · 연결 끊김 — 받은 글은 text 에 남아 있어요
print("\n도중 실패:", e)
answer = "".join(text)
if finish is None:
print("\n잘린 답이에요 — 처음부터 다시 부르지 말고 이어 받으세요(「에러」 장 「잘린 답 이어 받기」).")
elif finish == "length":
print("\n출력 상한에 닿았어요 — max_tokens 를 올리거나 이어 받으세요.")
print("\n토큰:", usage)
print("쓴 크레딧:", getattr(usage, "cost_credits", None)) # 끝 usage 조각에만 와요 — 그 조각이 없거나 정산이 2초 안에 안 끝나면 None
print("요청 ID:", stream.response.headers.get("request-id")) # 도중 error 조각엔 없어요 — 머리글에서 읽어요(SDK 동작)정상으로 끝나면 finish_reason 이 든 조각, usage 만 든 조각(choices 가 빈 배열), data: [DONE] 순서로 와요 — 모델 회사와 상관없이 usage 조각이 와서 stream_options 는 안 보내셔도 돼요. 쓴 몫(usage.cost_credits)도 그 끝 usage 조각에 와요. 요청 ID 는 응답 머리글에만 있어요 — Python 은 위처럼 stream.response.headers, Node openai SDK 는 const { data: stream, response } = await client.chat.completions.create({ …, stream: true }).withResponse() 로 받아 response.headers.get("request-id") 로 읽어요(SDK 동작). finish_reason 없이 끝나면 잘린 답이에요. 그때까지 흘러간 토큰은 이미 사용했으니, 받은 글을 assistant 메시지로 붙이고 이어 써 달라는 user 메시지를 붙여 이어 받으세요 — 순서와 코드는 「에러」 장(https://oneport.kr/docs/errors.md) 「잘린 답 이어 받기」에 있어요. Claude 흐름이 도중에 끊기면(벤더 오류 · 저희 시간 제한 · 연결 끊김) error 조각 바로 앞에 그때까지의 usage 조각(choices 가 빈 배열)이 와요 — 정산이 2초 안에 끝나면 거기에 usage.cost_credits 도 실려서, 끊긴 호출의 쓴 몫도 셀 수 있어요(첫 이벤트 전에 실패했거나 손님 쪽에서 먼저 끊은 흐름엔 없어요). Claude 밖 모델은 저희 시간 제한 · 연결 끊김이면 usage 조각 없이 error 조각 하나로 끝나요. 긴 답은 stream: true 로 받으세요 — 스트림 없이 부르면 답이 다 만들어져야 첫 응답이 와서, 모델 회사로 보낸 뒤 첫 응답 제한을 넘으면 422 upstream_execution_unconfirmed 로 끝나요(§4 표). 답을 못 받았다고 무료이거나 실행되지 않았다고 판단하지 마세요.
3. 도구 한 번 왕복#
import json
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "도시의 지금 날씨",
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
},
}]
messages = [{"role": "user", "content": "서울 날씨 알려줘"}]
kw = dict(model="claude-haiku-5-5", max_tokens=2000, tools=tools)
resp = client.chat.completions.create(messages=messages, **kw)
msg = resp.choices[0].message
if msg.tool_calls:
messages.append(msg) # tool_calls 가 든 assistant 메시지 그대로
for call in msg.tool_calls:
args = json.loads(call.function.arguments)
result = {"city": args["city"], "temp_c": 18} # 여기서 실제 함수를 불러요
messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result, ensure_ascii=False)})
resp = client.chat.completions.create(messages=messages, **kw) # 새 호출이라 요금도 따로예요
print(resp.choices[0].message.content)도구 정의 · 결과는 회사가 달라도 OpenAI 꼴 그대로예요 — Claude 로 갈 땐 저희가 번역해요. 한 턴에 도구를 여럿 불렀으면 결과도 tool_call_id 마다 하나씩 다 붙이세요. 스트림이면 도구 호출이 delta.tool_calls 조각으로 와요 — 첫 조각에 index · id · function.name 이, 뒤 조각엔 같은 index 로 function.arguments 글이 이어 와서 index 마다 이어 붙이면 돼요. 도구를 불렀으면 finish_reason 이 tool_calls, 다 답했으면 stop 이에요(Claude 도 같아요). GPT-5.6 · GPT-6 에 tools 를 실을 땐 reasoning_effort 를 빼고 보내세요(「OpenAI SDK」 장 「도구 호출」). pro 등급은 /v1/responses 로만 불러요 — 도구 모양이 달라요(「깊은 추론」 장 https://oneport.kr/docs/reference/responses.md).
4. 오류 — 무엇을 다시 보내나#
| 상태 | error.type | 다시 보내나 | SDK 예외 클래스 |
|---|---|---|---|
| 402 | insufficient_credit | 기다렸다(reason held_by_others · Retry-After 5초) · 고쳐서(hold_exceeds_balance — 상한을 줄여요) · 멈춤(below_minimum_balance — 충전) — SDK 는 402 를 스스로 안 보내요 | 전용 클래스 없음 — Python APIStatusError · Node APIError |
| 429 | key_limit_exceeded | 기다렸다(reason held_by_others — 같은 API 키로 보낸, 아직 끝나지 않은 다른 호출이 잡아 둔 몫 · Retry-After 5초 · x-should-retry: true 라 SDK 재시도를 켜 두면 SDK 가 해요) · 고쳐서(key_month_limit — 상한을 줄여요 · 안 되면 한도를 올리거나 한국 시간 다음 달 1일 0시 · 시각은 error.resets_at) · 멈춤(key_day_limit — 한국 시간 내일 0시에 다시 열려요 · 시각은 error.resets_at · 이미 쓴 몫만 재서 닿기 전에 보낸 호출(동시에 보낸 것 포함)은 다 지나가 그만큼 넘을 수 있어요 — 「남은 한도」 장) — key_month_limit · key_day_limit 은 x-should-retry: false 라 SDK 도 안 보내요 | RateLimitError(세 갈래 다 — type 으로 갈라요) |
| 429 | rate_limit_error | 기다렸다 — 글 문 · 토큰 세기 문 · 임베딩 문은 Retry-After 가 늘 붙으니 그만큼(벤더가 준 값을 1~60초로 맞추고, 벤더 값이 없으면 5초) · SDK 재시도를 켜 두면 SDK 가 해요 | RateLimitError(세 갈래 다 — type 으로 갈라요) |
| 429 | group_limit_exceeded | 고쳐서(reason group_budget — GET /v1/key 의 group.remaining_credits 가 0 보다 크면 상한을 줄여요 · 오류 몸통엔 이 칸이 없어요) · 멈춤(group_budget 인데 남은 몫 0 · group_closed 닫힌 강좌 · group_not_started · group_ended 반 기간 밖) — SDK 도 안 보내요 · 스페이스(강좌 · 반)에 붙은 키에서만 와요 | RateLimitError(세 갈래 다 — type 으로 갈라요) |
| 503 | api_error | 기다렸다 — 글 문 · 토큰 세기 문 · 임베딩 문 · 영상 만들기 문 · 조회 문(/v1/credits · /v1/key · /v1/models) · 관리 키 문(/v1/keys)은 Retry-After 2초(reason provider_unavailable 도 같아요 — 키를 잠깐 못 읽은 503 과 할 일이 같아요) · SDK 재시도를 켜 두면 SDK 가 해요 · 커넥터 문(/v1/connectors …)엔 Retry-After 도 x-should-retry 도 없어 몇 초 쉬고 다시(POST 는 같은 Idempotency-Key · 같은 본문으로) | InternalServerError |
| 4xx · 5xx | upstream_error | 고쳐서(요청 거절 4xx) · 기다렸다(x-should-retry: false 없이 온 5xx — 간격은 Retry-After, 없으면 코드가 정해요 · 완전한 본문으로 확인된 Anthropic overloaded_error 만 529 로 와요) · 멈춤(x-should-retry: false 가 붙은 것 — 이게 먼저예요 · 504 error.reason timeout 은 요금 없이 끝난 거라 직접 한 번 다시 보내도 돼요 — Idempotency-Key 를 실었으면 같은 Idempotency-Key 로) | 상태대로 — 5xx 는 InternalServerError(Python anthropic 은 529 가 OverloadedError) |
| 422 | upstream_execution_unconfirmed | 멈춤 — 실행됐는지 몰라요 · Idempotency-Key 를 실었으면 받은 x-gateway-status-url 로 GET 조회, 안 실었으면 요청 ID 와 부른 시각으로 문의 | UnprocessableEntityError |
| 400 · 404 | invalid_request_error · model_not_found | 고쳐서 — 문장이 고칠 곳을 말해요 | BadRequestError · NotFoundError |
import time
import openai
def create(**params):
"""잠시 뒤 그대로 다시 보낼 갈래만 다시 보내요 — 전체 갈래는 「에러」 장 「그대로 쓰는 감싸개」."""
for _ in range(5):
try:
return client.chat.completions.create(**params)
except openai.APIStatusError as e:
body = e.body if isinstance(e.body, dict) else {} # Python openai SDK 는 error 안쪽 {type, message, code, reason} 을 줘요
kind, reason = body.get("type") or e.type, body.get("reason")
no_retry = e.response.headers.get("x-should-retry") == "false"
wait = float(e.response.headers.get("retry-after") or 0)
if no_retry or kind in ("upstream_execution_unconfirmed", "request_already_exists", "request_state_unavailable", "request_store_unavailable"):
# held_by_others 밖의 한도 429(key_month_limit · key_day_limit · group_limit_exceeded) · 422 · 504 reason timeout 도 여기서 멈춰요(x-should-retry: false)
# key_month_limit · group_budget 을 줄인 새 요청으로 보내려면 이 줄보다 먼저 reason 을 봐요 · 504 timeout 은 요금 없이 끝나 직접 한 번 다시 보내도 돼요
raise RuntimeError(f"자동 재전송 금지 — {e.status_code} {kind} {reason}: {body.get('message')}") from e
if reason == "held_by_others":
time.sleep(wait or 5) # 402 · 429 — 다른 호출이 잡아 둔 몫(429 는 같은 API 키의 호출) — 그대로 다시
elif (e.status_code == 429 and kind == "rate_limit_error") or (e.status_code == 503 and kind == "api_error"):
time.sleep(wait or 2) # 벤더 혼잡 · 일시 장애 — 그대로 다시
else:
# hold_exceeds_balance → max_tokens 를 줄여 한 번 더 · below_minimum_balance → 충전
# 400 · 404 · 4xx upstream_error(422 포함) → 문장(body["message"])대로 고쳐요 — 찍어 두면 고칠 곳이 보여요
raise RuntimeError(f"{e.status_code} {kind} {reason}: {body.get('message')}") from e
raise RuntimeError("다시 보내도 계속 막혀요")
resp = create(model="gpt-5-nano", max_tokens=2000, messages=[{"role": "user", "content": "안녕하세요"}])
print(resp.choices[0].message.content)이 예시는 Idempotency-Key 를 싣지 않지만, 실어도 위 코드가 그대로 돌아요 — 실행 전에 막힌 402 · 429 의 held_by_others · 429 rate_limit_error · 503 api_error 는 Idempotency-Key 를 실어도 x-should-retry: false 없이 Retry-After 가 와서, 같은 Idempotency-Key · 같은 본문으로 다시 보내면 새로 접수돼요. 503 request_state_unavailable · request_store_unavailable 만은 위 코드가 멈추는데, 같은 Idempotency-Key · 같은 본문으로 다시 보내도 돼요. 쓰시려면 요청 하나에 Idempotency-Key 하나를 만들어 create(..., extra_headers={"Idempotency-Key": key}) 로 넘기세요(Node openai SDK 는 client.chat.completions.create 의 둘째 인자 { headers }) — 다시 보낼 때도 같은 Idempotency-Key 예요. 값은 공백 · 제어문자 없는 ASCII(영문 · 숫자 · 기호) 8~200자이고(어기면 400 invalid_request_error), 같은 API 키 안에서만 같은 요청으로 봐요. 409 request_already_exists 는 error.type 으로 와요. 조회의 execution 이 started 면 모델 회사로 보냈다는 뜻이라 끝까지 답한 요청(result.state completed)도 여기예요 — 같은 Idempotency-Key 로는 다시 못 받고 답 본문도 조회로 못 받으니, 받은 답은 그때 저장해 두고 일을 다시 돌릴 땐 답을 받은 단계는 다시 부르지 마세요(다시 부르면 409). 조회가 503 이면 상태를 잠깐 못 읽은 거라 Retry-After(2초) 뒤 다시 조회해요(요금 없음). 받은 응답마다 같은 Idempotency-Key · 새 Idempotency-Key · 조회 · 멈춤을 고르는 표와 조회 결과로 할 일은 「요청 상태」 장(https://oneport.kr/docs/reference/requests.md)에 있어요.
분기는 상태 코드나 e.message 가 아니라 error.type 과 error.reason 으로 하세요. 문장(Python 은 e.body["message"], Node 는 err.error.message)은 API 를 부르는 개발자에게 보일 말이에요 — 400 · 401 · 404 는 고칠 곳이 이 문장에 적혀 오니 꼭 찍어 두세요(SDK 동작). max_tokens · GET /v1/key 같은 API 낱말이 들어 있어서 서비스 손님 화면엔 type · reason 으로 고른 문장을 보여 주세요. SDK 자동 재시도는 꺼 두었고(max_retries=0 · maxRetries: 0), 위 예시는 실행 전 거절이 확인된 갈래만 다시 보내요 — 표의 「SDK 재시도를 켜 두면 SDK 가 해요」는 켰을 때 얘기라, 이 장처럼 껐으면 코드가 Retry-After 만큼 기다렸다 같은 요청을 보내요(위 create()). Node SDK 는 v5 이상 err.headers.get("retry-after"), v4 는 err.headers["retry-after"] 예요(SDK 동작). 알려지지 않은 5xx · 422 실행 불명은 자동 재전송하지 않아요. 200 전 연결 오류 · 시간 초과도 그래요 — Idempotency-Key 를 실었으면 같은 Idempotency-Key · 같은 본문으로 다시 보내요. 422 조회에선 result 와 settlement 를 따로 확인하세요(같은 Idempotency-Key 로 다시 보내 409 가 와도 그 응답에 같은 x-gateway-status-url 이 와요). 일반 요청 ID만으로 조회 주소를 만들지 마세요. upstream_error 인 422 는 벤더 거절이라 고쳐 보내요. 갈래 전부(시간 초과 · 끊긴 스트림 어림 · 오늘 · 이번 달 한도가 다시 열리는 때)와 그대로 쓰는 Python · TypeScript 감싸개는 「에러」 장(https://oneport.kr/docs/errors.md)에 있어요.
다음에 읽을 장#
- OpenAI SDK · Anthropic SDK — https://oneport.kr/docs/guides/openai-sdk.md · https://oneport.kr/docs/guides/anthropic-sdk.md (Anthropic SDK 의 base_url 은 /v1 없이)
- 글 문의 칸 하나하나 — https://oneport.kr/docs/reference/chat.md
- 모델과 단가 — https://oneport.kr/docs/models.md
- 남은 크레딧 · 키 한도 — GET https://oneport.kr/v1/credits · GET https://oneport.kr/v1/key(요금 없음 · https://oneport.kr/docs/reference/key.md) — 키의 월 · 하루 한도는 대시보드 AI API 화면 키 줄의 「한도 수정」에서 걸어요