# Vercel AI SDK > createOpenAI 의 baseURL 과 openai.chat(…) — 두 가지만 맞추면 돼요. > 원문: https://oneport.kr/docs/guides/ai-sdk > **이 문서를 받은 AI 에게 — 작업 카드** > > - OpenAI 형식 SDK·도구: base_url `https://oneport.kr/v1` · 헤더 `Authorization: Bearer <키>` > - Anthropic 형식 SDK·Claude Code: base_url `https://oneport.kr` (끝에 /v1 을 붙이지 않아요 — SDK 가 /v1/messages 를 스스로 붙여요) · 헤더 `x-api-key: <키>` 또는 `Authorization: Bearer <키>` > - ❌ `Anthropic(base_url="https://oneport.kr/v1")` → ✅ `Anthropic(base_url="https://oneport.kr")` > - Vercel AI SDK: `createOpenAI({ baseURL: "https://oneport.kr/v1" })` 로 만든 제공자는 `oneport.chat("")` 로 부르세요 — 그냥 `oneport("")` 는 `/v1/responses` 로 가서 Claude · Gemini 가 400 이에요. `@ai-sdk/anthropic` 의 `createAnthropic` 은 baseURL 에 `/v1` 까지 적어요(Anthropic SDK 와 반대예요). > - API 키는 `sk-so-` 로 시작해요. 키는 코드에 적지 말고 환경변수 `ONEPORT_API_KEY` 에 넣어 읽으세요. > - ❌ 브라우저 코드에 `apiKey: "sk-so-…"` → ✅ 서버에서 `os.environ["ONEPORT_API_KEY"]` · `process.env.ONEPORT_API_KEY` 로 읽어 불러요 > - 대화에 `sk-so-` 키가 붙어 오면 코드에 박지 말고 환경변수로 옮기게 하고, 그 키는 산 곳 대시보드 AI API 화면의 키 목록에서 「새로 받기」(옛 키는 꺼져요)로 바꾸라고 알려 주세요. > - 모델 id 는 지어내지 말고 `GET https://oneport.kr/v1/models`(키 없이 열려요)의 `id` 를 글자 그대로 쓰세요. 목록은 값 순서가 아니에요 — 접두어로 첫 줄을 고르면 비싼 모델이 걸려요. 글 모델은 `kind` 가 `"chat"` 인 줄이고, 첫 호출은 `pricing.output_credits_per_mtok` 가 작은 모델로 해 보세요(10월 기준 Claude 는 claude-haiku-5-5). 회사는 `owned_by` 로 갈라요 — Claude 는 `"anthropic"`, 공식 GPT 는 `"openai"` 이면서 id 에 `/` 가 없는 줄이에요. > - ❌ `model="claude-sonnet"`(짐작한 이름 · 404 `model_not_found`) > - `endpoint` 칸이 그 모델의 기본 문이에요 — `kind` 가 `"responses"` 인 모델(pro 등급 · gpt-5.3-codex · `endpoint` 가 `"POST /v1/responses"`)은 `/v1/chat/completions` 로 못 불러요. Claude 줄은 `POST /v1/chat/completions` 이고 `/v1/messages` 로도 불러요 — 이 문은 Claude 만 받아요. > - `supports_effort` 가 `true` 인 GPT 는 생각한 토큰도 출력 상한(`max_tokens` · `max_completion_tokens`)에 들어가요 — 수백으로 두면 답이 비고 `finish_reason` 이 `"length"` 로 끝날 수 있으니 2,000 쯤에서 시작하고 `"length"` 면 올리세요. > - 목록 응답은 `{"object": "list", "data": [ … ]}`. 값 어림 — 한 번 값은 (입력 토큰 × `pricing.input_credits_per_mtok` + 출력 토큰 × `pricing.output_credits_per_mtok`) ÷ 100만 크레딧이에요(1 크레딧은 부가세 포함 2원어치예요). > - ❌ `x-should-retry: false` 응답을 반복문으로 다시 보내기 → ✅ 멈추고 `error.type` 을 확인하세요. 몸통은 OpenAI 형식 문이면 `{"error": {"type": "…", "message": "…", "code": "…"}}`, `/v1/messages` 면 `{"type": "error", "error": {"type": "…", "message": "…"}}` 예요 — 402 · 키 한도 · 강좌 몫 429 엔 `error.reason` 이 더 붙어요. 잠시 뒤 그대로 다시 보낼 실패엔 `Retry-After`(정수 초)가 대개 와요. > - ❌ `if "429" in str(e)` · `e.message` 로 분기 → ✅ Python openai 는 `e.type` · `e.body.get("reason")`, Node openai 는 `err.type` · `err.error.reason` — 429 는 키 한도 · 벤더 혼잡 · 강좌 몫 셋이라 상태 코드로 못 갈라요. Python Anthropic SDK 는 `except anthropic.APIStatusError as e:` 에서 `e.status_code` 와 `e.body["error"]["type"]`, Node 는 `err.status` 와 `err.error.error.type` 로 읽어요. 402 · 키 한도 429 의 갈래(`reason`)는 Python Anthropic SDK 면 `e.body["error"].get("reason")`, Node 면 `err.error.error.reason` 이에요. > - 남은 크레딧(지갑 전체)은 `GET https://oneport.kr/v1/credits` 의 `balance_credits` 예요(1 크레딧 아래를 버린 정수라 호출 앞뒤 차이로 쓴 몫을 세지 마세요 — 쓴 몫은 글 문 응답의 `usage.cost_credits` · 정산이 2초 안에 안 끝나면 그 칸 없이 와요). `GET https://oneport.kr/v1/key` 는 이 키에 건 한도(`month` · `day`)만 보여 주고 지갑 잔액은 없어요 — `month` · `day` 는 늘 객체이고 한도를 안 건 키면 `month.remaining_credits` · `day.remaining_credits` 가 null 이에요 · 그땐 지갑 잔액까지 써요. > - 하루 한도는 이미 쓴 몫이 한도에 닿았는지만 봐요 — 이번 달 · 강좌 한도는 이 요청 최대 몫까지 더해 재서 줄이면 통과할 수 있어요. 키 월 한도는 같은 API 키로 아직 끝나지 않은 호출 몫까지 더해 넘었으면 `error.reason` 이 `"held_by_others"` 로 와요. 묶음 처리 문(`/v1/batches`)은 없어요 — 한 건씩 보내요(동시에 보내도 되고 분당 횟수 제한도 없어요). > - 스트림 없이 부르면 답이 다 만들어져야 첫 응답이 와요 — 모델 회사로 보낸 뒤 120초 안에 안 오면 422 `upstream_execution_unconfirmed` 로 끝나요(모델 회사에서는 실행됐을 수 있어요). 그보다 긴 답은 `stream: true` 로 받으세요(스트림은 약 12분 50초에 끊겨요). SDK 시간 제한은 300초로 두세요(Python `OpenAI(…, timeout=300)` · Node `new OpenAI({ …, timeout: 300_000 })` — Node 는 밀리초 · SDK 동작). > - 첫 응답 120초 시계는 스트림이어도 같아요. 이 422 는 `x-should-retry: false` 로 와요 — 같은 요청을 자동으로 다시 보내지 마세요. stream: true 로 바꾸는 것도 새 생성이라 자동으로 하지 않아요. `Idempotency-Key` 를 실어 보낸 요청이면 받은 `x-gateway-status-url` 을 같은 API 키로 GET 조회하세요. `Idempotency-Key` 없이 보낸 요청엔 조회 주소가 안 오고 요청 ID 로 만든 주소로는 조회가 안 돼요 — 요청 ID 와 부른 시각을 admin@schoolorder.kr 로 알려 주세요. 요청 ID 는 머리글 `request-id`(같은 값이 `x-request-id`)와 오류 몸통 `error.request_id` 로 와요. 조회 주소는 `/v1/requests/` 꼴 상대 주소라 앞에 https://oneport.kr 를 붙여요. > - 고친 요청엔 새 Idempotency-Key 를 써요(같은 Idempotency-Key 로 본문을 바꾸면 409 `idempotency_conflict`). `Idempotency-Key` 를 실었으면 200 머리말을 받기 전의 연결 오류 · 시간 초과 뒤에 같은 Idempotency-Key · 같은 본문으로 다시 보내세요(몇 번 보내도 두 번 실행되지 않지만, 간격은 늘려 가며). 200 을 받은 뒤 끊긴 스트림은 잘린 답이라 같은 Idempotency-Key 는 409 예요 — 그때까지 쓴 토큰은 사용했고, 받은 글로 이어 받을 땐 새 Idempotency-Key 예요. > - 경계 사례는 이 장 전체 판(`https://oneport.kr/docs/guides/ai-sdk.full.md`)과 「에러」 장(`https://oneport.kr/docs/errors.md`)에 있어요. Vercel AI SDK(ai · @ai-sdk/openai · @ai-sdk/anthropic)로 Next.js 같은 서버 코드에서 여러 회사 모델을 불러요. 키는 서버 환경변수(ONEPORT_API_KEY=sk-so-…)에만 두세요 — 브라우저 코드에 넣으면 키가 페이지에 실려요. 이 장의 코드와 SDK 동작은 ai 7 · @ai-sdk/openai 4 · @ai-sdk/anthropic 4(10월)로 재 봤어요. ai 7 의 usage 는 캐시 토큰을 usage.inputTokenDetails.cacheReadTokens · cacheWriteTokens 로 줘요 — 예전 판의 cachedInputTokens 는 없어서 타입 검사에서 막혀요(SDK 동작). > **⚠️ oneport("…") 말고 oneport.chat("…")** > > createOpenAI 로 만든 제공자를 그냥 부르면(oneport("모델")) AI SDK 가 Responses API(/v1/responses)로 보내요(SDK 동작). 그 문은 OpenAI 모델만 받아서 Claude · Gemini 는 400 이에요. oneport.chat("모델") 로 부르면 /v1/chat/completions 로 가고, 회사를 섞어 불러요. **app/api/ask/route.ts — npm i ai @ai-sdk/openai @ai-sdk/anthropic** ```typescript import { streamText } from "ai"; import { createOpenAI } from "@ai-sdk/openai"; import { createAnthropic } from "@ai-sdk/anthropic"; // OpenAI 형식 — baseURL 에 /v1 까지 const oneport = createOpenAI({ baseURL: "https://oneport.kr/v1", apiKey: process.env.ONEPORT_API_KEY }); // Anthropic 형식 — 이 제공자도 baseURL 에 /v1 까지 적어요(뒤에 /messages 만 붙여요) const oneportClaude = createAnthropic({ baseURL: "https://oneport.kr/v1", apiKey: process.env.ONEPORT_API_KEY }); export async function POST(req: Request) { const { prompt, useClaude } = (await req.json()) as { prompt: string; useClaude?: boolean }; const result = streamText({ model: useClaude ? oneportClaude("claude-sonnet-5") : oneport.chat("gpt-6-sol"), prompt, maxOutputTokens: 2000, maxRetries: 0, // 다시 보낼지는 직접 가려요 — 아래 목록 abortSignal: req.signal, // 브라우저가 끊으면 저희 호출도 끊어요 — 아래 목록 onError({ error }) { console.error("oneport stream error", error); // 끊긴 답 — 「에러」 장 「잘린 답 이어 받기」 }, }); const encoder = new TextEncoder(); const body = new ReadableStream({ async start(controller) { for await (const text of result.textStream) controller.enqueue(encoder.encode(text)); controller.close(); }, }); return new Response(body, { headers: { "Content-Type": "text/plain; charset=utf-8" } }); } ``` 위 예는 글만 돌려주는 fetch 용이에요. useChat 화면으로 보여 주거나 도구를 붙일 땐 ReadableStream 부분을 지우고 return result.toUIMessageStreamResponse() 를 돌려주세요 — textStream 엔 글만 실리고 도구 호출 · 결과는 안 실려요(SDK 동작). useChat 은 몸통에 prompt 가 아니라 messages(UIMessage 배열)를 보내요 — 라우트에서 const { messages } = await req.json() 으로 받아 streamText 에 prompt 대신 messages: await convertToModelMessages(messages) 를 넣으세요(안 바꾸면 prompt 가 비어 streamText 가 실패해요). useChat 의 기본 주소는 /api/chat 이라, 이 라우트로 보내려면 화면 쪽 useChat 에 transport: new DefaultChatTransport({ api: "/api/ask" }) 를 주세요(DefaultChatTransport · convertToModelMessages 는 "ai" 에서 가져와요 · SDK 동작). ## Claude 를 Anthropic 형식으로 — baseURL 에 /v1 @ai-sdk/anthropic 의 createAnthropic 은 baseURL 뒤에 /messages 만 붙여요(기본값이 https://api.anthropic.com/v1 · SDK 동작). 그래서 https://oneport.kr/v1 까지 적어요 — Anthropic 공식 SDK · Claude Code 에 https://oneport.kr 만 적는 것과 반대예요. 이 제공자는 환경변수 ANTHROPIC_BASE_URL 도 같은 뜻으로 읽어서(SDK 동작), Claude Code 용으로 /v1 없이 넣어 둔 값이 있으면 주소가 틀려 실패해요 — 코드에 baseURL 을 직접 적으세요 — 코드에 적은 baseURL · apiKey 가 환경변수보다 먼저라, 셸의 ANTHROPIC_BASE_URL 은 그대로 두셔도 돼요. 다만 ONEPORT_API_KEY 를 못 읽어 apiKey 가 undefined 면 ANTHROPIC_API_KEY 를 대신 써요(SDK 동작). apiKey 는 x-api-key 머리글로 가요. anthropic-version 머리글은 이 제공자가 2023-06-01 로 늘 실어서(SDK 동작) 따로 안 주셔도 돼요 — curl 로 /v1/messages 를 부를 때 이 머리글이 없으면 저희가 2023-06-01 로 채워요. Claude 를 OpenAI 형식으로 부르셔도 돼요 — oneport.chat("claude-sonnet-5") 처럼요. 다른 점은 하나예요 — oneportClaude(…) 는 Claude 몸통이 그대로 가고, oneport.chat(…) 은 저희가 번역해서 못 옮기는 칸은 400 이거나 빼고 응답 머리글 x-gateway-ignored-params 에 적어요. Claude 칸(캐시 · 생각)을 쓰실 거면 oneportClaude 예요. - 모델 id 는 GET https://oneport.kr/v1/models 의 id 를 글자 그대로 넣어요. endpoint 가 POST /v1/responses 인 모델(pro 등급 · gpt-5.3-codex)만 oneport.responses("…") 로 불러요. - maxOutputTokens 를 적어 두세요 — 저희는 부르기 전에 출력 상한 × 출력 단가만큼 미리 잡아요(끝나면 쓴 만큼만 사용해요). @ai-sdk/anthropic 은 안 적으면 모델 최대 출력을 max_tokens 로 실어 보내서(claude-sonnet-5 면 128,000 · SDK 동작) 출력 몫만 약 1,180 크레딧을 잡아요(10월 기준 · 정본은 GET https://oneport.kr/v1/models) — 잔액이 적으면 402 예요. oneport.chat(…) 은 안 적으면 칸을 비워 보내서 저희가 8,192 토큰으로 잡아요. - AI SDK 는 x-should-retry 머리글을 보지 않고 상태 코드(408 · 409 · 429 · 5xx)만 보고 두 번 더 보내요(maxRetries 기본 2 · SDK 동작). 그래서 다시 보내도 같은 키 한도 429(key_month_limit · key_day_limit) · x-should-retry: false 가 붙은 504(error.reason timeout) · 이미 접수한 요청의 409 도 2초 · 4초 뒤(Retry-After 가 오면 그만큼) 저절로 다시 가요. 그 504 의 type 은 upstream_error 이고, 모델 회사가 실행하기 전에 끝나 요금이 없어서 코드가 몇 초 뒤 한 번 다시 보내도 돼요. 422 upstream_execution_unconfirmed(모델 회사로 보낸 뒤 시간 초과)는 SDK 가 다시 보내지 않아요 — 코드도 자동으로 다시 보내지 마세요. 위 예시처럼 maxRetries: 0 을 주고 직접 가르세요 — 0 이면 onError 의 error 가 APICallError 그대로 와서 error.statusCode 와 error.responseBody(저희 오류 JSON)를 읽을 수 있어요. 1 이상이면 다 실패한 뒤 RetryError 로 싸여 와요(SDK 동작). - maxRetries: 0 이어도 @ai-sdk/openai 4 는 글 · 도구 호출이 한 조각도 오기 전에 온 스트림 error 조각을 APICallError 로 던져요(SDK 동작). 이때 statusCode 는 SDK 가 error.type 낱말로 짐작한 값(upstream_error 는 500 · overloaded_error 는 503)이고, responseHeaders 는 처음 받은 200 응답 것이라 x-should-retry · Retry-After 가 없어요. error.responseBody 를 JSON 으로 풀어 맨 바깥에 error 칸이 있으면 저희 HTTP 오류이고, 없으면(안쪽 type · code · message 만) 200 으로 시작한 스트림이 첫 글 전에 끊긴 거예요 — 상태 코드로 가르지 말고 잘린 답으로 다뤄 자동으로 다시 보내지 마세요. - 쓴 크레딧(usage.cost_credits)은 스트림 끝 usage 조각에 와요(정산이 2초 안에 안 끝나면 그 칸 없이 와요). @ai-sdk/openai 는 usage 의 모르는 칸을 버려서 onStepFinish · onFinish 의 usage(usage.raw 포함)엔 없어요(SDK 동작) — streamText 에 includeRawChunks: true 를 주고 onChunk 에서 chunk.type 이 "raw" 인 조각의 rawValue.usage?.cost_credits 를 읽어요. oneportClaude(…) 면 message_delta 조각의 usage.cost_credits 예요. 도구로 여러 단계를 돌리면(stopWhen) 단계마다 저희 문을 따로 불러 단계마다 따로 오니 더해서 보여 주세요. oneport.chat(…) 으로 부른 Claude 가 도중에 끊기면(error 조각) 바로 앞 usage 조각(choices 가 빈 배열)에 그때까지의 토큰과 cost_credits 가 와요(정산이 2초 안에 끝나면) — 잘린 단계 몫도 그 조각에서 읽어 더하세요. 다른 회사 모델이 저희 시간 제한 · 연결 끊김으로 끊기면 usage 조각 없이 error 조각 하나예요. - 멈추기 — useChat 의 stop() 은 브라우저와 내 라우트 사이만 끊어요. 위 예시처럼 req.signal 을 streamText 의 abortSignal 로 넘기면 저희 호출도 끊기고 저희가 모델 회사 호출도 닫아요(req.signal 이 언제 끊기는지는 Next.js · Vercel 몫이에요). 멈춘 호출도 도중에 끊긴 스트림과 같은 규칙으로 정산해요(전체 판 기본 규칙의 「스트림이 200 으로 시작한 뒤 끊기면」) — Claude 는 시작 때 센 입력 · 캐시 토큰을 모두 세고 출력은 「에러」 장 그 줄대로(끝의 누적 출력 수를 못 받았으면 흘려보낸 글로) 어림해서, 대화 기록이 길면 멈춰도 입력 몫이 그대로 붙어요. 그 밖의 모델은 그때까지 나간 글로 출력만 어림하고 입력은 안 세요. 미리 잡은 몫은 응답이 닫힌 뒤 정산하면서 풀려요 — 그 사이 바로 다시 보내면 잔액 · 월 한도가 빠듯할 때 402 · 429 의 error.reason 이 held_by_others 일 수 있으니 잠시 뒤 그대로 보내요. 끊긴 호출엔 끝 usage 조각이 안 와서 그 호출 하나의 cost_credits 도 안 와요 — 정산되면 이 키의 오늘 합계(GET https://oneport.kr/v1/key 의 day.used_credits)에 같은 키의 다른 호출과 함께 들어가요. - Idempotency-Key 를 streamText 의 headers 에 한 값으로 넣지 마세요 — 도구 단계마다 같은 값이 실려서(SDK 동작) 둘째 단계부터 본문이 달라 409 idempotency_conflict 예요. 호출 하나(멈춘 호출 포함)의 값이 꼭 필요하면 maxRetries: 0 으로 두고 createOpenAI · createAnthropic 의 fetch 칸에서 요청마다 새 값을 붙이고(1 이상이면 SDK 가 다시 보낼 때마다 새 값이 붙어 중복을 못 막아요), 그 응답 머리글 x-gateway-status-url 을 같은 API 키로 GET 하세요 — 정산 뒤 settlement.cost_credits 가 와요(머리글을 읽을 자리도 그 fetch 예요 · SDK 동작). - streamText 는 도중 오류를 던지지 않고 onError 로 넘겨요(지정하지 않으면 콘솔에 찍기만 해요 · SDK 동작). 그 경우 textStream 이 거기서 끝나니 받은 글이 잘린 답인지 onError 로 알아 두세요. - 도구 결과를 받아 답까지 이어 쓰게 하려면 stopWhen: stepCountIs(5) 처럼 단계를 주세요 — 기본은 1단계라 도구 호출에서 멈춰요(SDK 동작). - Vercel 에 올리면 라우트 파일에 export const maxDuration = 300 을 두세요 — 저희 글 문은 첫 응답까지 4분 가까이 걸릴 수 있어요(올릴 수 있는 상한은 Vercel 플랜마다 달라요 · 벤더 동작). 도구로 여러 단계를 돌리면(stopWhen) 단계마다 저희 문을 새로 불러서 저희 시계(첫 응답 120초 · 약 12분 50초)도 단계마다 따로예요. maxDuration 과 브라우저 쪽 시간 제한은 단계를 다 합친 시간이라, 단계가 길면 단계 수를 줄이거나 단계마다 maxOutputTokens 를 작게 두세요. - 오류 몸통 · 갈래는 다른 장과 같아요 — 「에러」 장(https://oneport.kr/docs/errors.md). ## 도구(tools) 붙이기 도구는 streamText 의 tools 에 넣어요. Claude 는 oneportClaude(…) · oneport.chat(…) 둘 다 도구를 받아요. GPT 는 gpt-6-sol · gpt-6-luna · gpt-6.1-terra · gpt-5.6-luna · gpt-5.6-sol · gpt-5.6-terra 에 도구를 실으면 저희가 reasoning_effort 를 none 으로 채워 넘겨요 — providerOptions 로 reasoningEffort 를 주지 마세요(none 밖의 값이면 벤더가 400 · 벤더 동작). ai 7 의 streamText reasoning 칸도 같아요 — none 밖의 값을 주면 SDK 가 reasoning_effort 로 실어 보내요(SDK 동작). 저희는 reasoning_effort 칸이 비어 있을 때만 none 을 채우고(값이 실려 오면 그대로 넘겨요 — 그 400 의 type 은 upstream_error), 채웠으면 응답 머리글 x-gateway-mapped-params 에 reasoning_effort->none 이 적혀요. gpt-6-astra · gpt-6.1-sol 은 /v1/chat/completions 에서 도구를 못 받아 400 이에요 — 도구가 필요하면 다른 GPT 를 고르시거나, gpt-6-astra 는 oneport.responses("gpt-6-astra") 로 부르세요(/v1/responses 에선 함수 도구를 받는 걸 저희가 재 봤어요). GET https://oneport.kr/v1/models 엔 도구를 받는지 알려 주는 칸이 없어서 이 목록으로 가려요. oneport.chat(…) 으로 부른 Claude 도 도구 호출은 OpenAI 꼴 tool_calls 조각으로 와요 — id · name 이 먼저, 그다음 arguments 조각이에요. 다음 단계의 도구 결과(role: tool)는 저희가 Claude 꼴로 바꿔 보내요 — 한 턴에 도구를 여럿 불렀으면 결과도 tool_call_id 마다 하나씩 붙여요(AI SDK 는 알아서 붙여요 · SDK 동작).