본문으로 건너뛰기

Vercel AI SDK

createOpenAI 의 baseURL 과 openai.chat(…) — 두 가지만 맞추면 돼요.

AI 에게 넘기기ChatGPT 에서 열기 (새 창)Claude 에서 열기 (새 창)원문 열기

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 동작).

app/api/ask/route.ts — npm i ai @ai-sdk/openai @ai-sdk/anthropic
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 동작).