# LangChain (Python) > ChatOpenAI 는 base_url 에 /v1 까지, ChatAnthropic 은 /v1 없이. > 원문: https://oneport.kr/docs/guides/langchain > **이 문서를 받은 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/langchain.full.md`)과 「에러」 장(`https://oneport.kr/docs/errors.md`)에 있어요. LangChain 의 ChatOpenAI(langchain-openai)와 ChatAnthropic(langchain-anthropic)에 주소와 키만 넣으면 돼요. 회사를 섞거나 claude-fable-5-1 · claude-opus-5-5 · claude-sonnet-5-5 로 구조화 출력을 받으시려면 ChatOpenAI 를 쓰세요 — 그 밖의 Claude(claude-sonnet-5 · claude-opus-5 · claude-haiku-5-5 등)는 ChatAnthropic 의 with_structured_output 도 돼요. thinking · Claude 서버 도구(웹 검색 등)처럼 Anthropic 칸을 그대로 쓰시려면 ChatAnthropic 을 쓰세요 — ChatOpenAI 가 부르는 채팅 문은 서버 도구를 400 으로 돌려보내요. cache_control 은 ChatOpenAI 로 부른 Claude 에도 저희가 옮겨요(글 조각 · system · 도구 정의 · 도구 결과 · 몸통 맨 위의 칸). **pip install -U "langchain-openai>=1.0" "langchain-anthropic>=1.0"** ```python import os from langchain_openai import ChatOpenAI from langchain_anthropic import ChatAnthropic key = os.environ["ONEPORT_API_KEY"] # 원포트 키(sk-so-...) # OpenAI 형식 — base_url 에 /v1 까지. Claude · Gemini id 를 넣어도 돼요. # gpt-6-sol 같은 생각하는 GPT 는 생각한 토큰도 max_tokens 에 들어가요(벤더 동작) — 너무 작으면 답 없이 finish_reason length 로 끝나요. # max_retries=0 — SDK 가 연결 오류 · 시간 초과를 스스로 다시 보내 두 번 사용하지 않게요. 시간 초과는 300초(.invoke 는 120초 안에 답이 다 안 오면 422 · 긴 답은 .stream()). gpt = ChatOpenAI(model="gpt-6-sol", base_url="https://oneport.kr/v1", api_key=key, max_tokens=2000, max_retries=0, timeout=300) print(gpt.invoke("한 문장으로 자기소개해 주세요").text) # .text 는 langchain-core 1.0 부터 속성이에요(0.3 은 .text()) # Anthropic 형식 — base_url 에 /v1 을 붙이지 않아요(Anthropic SDK 가 /v1/messages 를 붙여요). 시간 초과 칸 이름이 달라요. claude = ChatAnthropic(model="claude-sonnet-5", base_url="https://oneport.kr", api_key=key, max_tokens=2000, max_retries=0, default_request_timeout=300) for chunk in claude.stream("한 문장으로 자기소개해 주세요"): print(chunk.text, end="", flush=True) ``` - ChatOpenAI 는 reasoning={…} 딕셔너리나 내장 도구(웹 검색 · 파일 검색 등)를 쓰거나 use_responses_api=True 면 Responses API(/v1/responses)로 보내요(SDK 동작) — 그 문은 OpenAI 모델만 받아서 Claude · Gemini 는 400 이에요. 회사를 섞을 땐 이 셋을 빼세요. reasoning_effort="low" 는 /v1/chat/completions 로 그대로 가서 섞어도 돼요 — Claude 로는 저희가 effort 로 옮기고, effort 를 안 받는 모델이면 빼고 응답 머리글 x-gateway-ignored-params 에 적어요. 다만 도구를 붙인 GPT 호출엔 주지 마세요(아래 「구조화 출력」 절의 GPT 도구 줄). - base_url 을 안 넘기면 ChatOpenAI 는 OPENAI_API_BASE · OPENAI_BASE_URL, ChatAnthropic 은 ANTHROPIC_API_URL · ANTHROPIC_BASE_URL 환경변수를 읽어요(SDK 동작). ChatAnthropic 쪽 값도 /v1 없이예요. - max_tokens 를 적어 두세요 — ChatAnthropic 은 안 적으면 모델 정보로 정하고(SDK 동작), 큰 값이면 저희가 그만큼 미리 잡아 잔액이 적을 때 402 가 날 수 있어요(끝나면 쓴 만큼만 사용해요). - ChatOpenAI 든 ChatAnthropic 이든 claude-sonnet-5 · claude-opus-5-5 를 스트림 없이(.invoke) 짧은 user 글 하나로 부르면(SystemMessage 하나가 같이 있어도 같아요) 지갑이 미리 잡는 몫이 커요 — ChatOpenAI 로 보낸 것도 저희가 Claude 몸통으로 옮긴 뒤에 재서 같은 특례가 걸려요. 짧다는 건 Claude 몸통으로 옮긴 JSON 이 16,384바이트 이하라는 뜻이에요. 자료를 붙인 RAG 질문도 그 안이면 걸려요(한글 약 5,000자). claude-sonnet-5 에 max_tokens 2,000 이면 약 80 크레딧이에요(claude-opus-5-5 는 약 160 크레딧 · 10월 기준 · 정본은 GET https://oneport.kr/v1/models). 이 특례는 도구나 with_structured_output 을 붙인 호출과 .stream(), anthropic-version 을 2023-06-01 아닌 판으로 싣거나 anthropic-beta · openai-beta 를 실은 호출엔 걸리지 않고, 걸리면 max_tokens 를 줄여도 거의 안 줄어요. 잔액이 적어 402 가 나면 .stream() 으로 받으세요 — 스트림은 보통 셈으로 잡아요. 조건과 셈은 「남은 한도」 장(https://oneport.kr/docs/reference/key.md)의 지갑 특례에 있어요. - 모델 id 는 GET https://oneport.kr/v1/models 의 id 를 글자 그대로 넣어요. ChatOpenAI 에 넣을 글 모델은 kind 가 "chat" 인 줄이에요 — pricing.rates 는 임베딩 · 그림 · 소리 · 영상 줄에도 있어서 rates 로는 못 가려요. 오류 갈래는 「에러」 장(https://oneport.kr/docs/errors.md)과 같아요 — ChatOpenAI 는 openai SDK, ChatAnthropic 은 anthropic SDK 의 예외를 그대로 올려요(SDK 동작). ## 재시도 · 시간 초과 · 중복 방지 **요청 하나에 Idempotency-Key 하나 — 객체에 실려서 호출마다 새로 만들어요** ```python import os, uuid from langchain_openai import ChatOpenAI key = os.environ["ONEPORT_API_KEY"] def keyed(model: str, idem: str) -> ChatOpenAI: return ChatOpenAI(model=model, base_url="https://oneport.kr/v1", api_key=key, max_tokens=2000, max_retries=0, timeout=300, default_headers={"Idempotency-Key": idem}) idem = f"lc-{uuid.uuid4().hex}" # 같은 요청을 다시 보낼 땐 이 값 그대로 · 고친 요청은 새 값 answer = keyed("gpt-6-sol", idem).invoke("한 문장으로 자기소개해 주세요") ``` - 같은 값으로 만든 객체로 다시 부르면 같은 키 · 같은 본문이에요 — 「에러」 장 표의 잠시 뒤 다시 보낼 갈래만 Retry-After 뒤 그렇게 보내요. x-should-retry: false 여도 504 error.reason "timeout" · 조회 주소와 같이 온 503(request_state_unavailable · request_store_unavailable)은 이 갈래예요 — 둘 다 Retry-After 가 없으니 간격은 직접 정해요. max_tokens 를 줄이는 등 고친 요청은 새 값으로 만든 객체로 보내요(같은 값에 본문이 바뀌면 409 idempotency_conflict). - ChatAnthropic 도 default_headers={"Idempotency-Key": idem} 로 실어요(SDK 동작) — /v1/messages 도 같은 규칙이에요. 키를 실은 객체는 max_retries 를 켜 둬도 두 번 실행되지 않아요 — 앞 요청이 접수됐으면 409 request_already_exists 로 멈춰요(x-should-retry: false 라 SDK 도 다시 안 보내요). 위 예제의 0 은 첫 예제와 맞춘 값이에요. - 409 request_already_exists · 422 upstream_execution_unconfirmed 면 다시 보내지 말고 응답 머리글 x-gateway-status-url(예외의 e.response.headers — 같은 값이 Location 머리글 · 몸통 error.status_url 에도 와요)을 같은 API 키로 GET 해요 — /v1/requests/ 꼴 상대 주소라 앞에 https://oneport.kr 를 붙여요. 실행 · 결과 · 정산 칸은 「요청 상태」 장(https://oneport.kr/docs/reference/requests.md)이에요. 답 본문은 다시 못 받아요 — 꼭 필요하면 새 값으로 새로 부르는 거라 새로 사용해요. - ChatOpenAI 의 .stream() 이 도중에 끊기면, 저희 쪽 끊김은 상태 코드 없는 openai.APIError 로, 손님 쪽 연결이 끊기거나 읽기 시간을 넘기면 httpx 예외(httpx.RemoteProtocolError · httpx.ReadTimeout 등)로 그대로 올라와요(SDK 동작). 조각을 하나라도 받았으면 어느 쪽이든 잘린 답이니 「에러」 장 「잘린 답 이어 받기」로 이어 받으세요. - .with_retry() · .with_fallbacks() 는 기본으로 모든 예외에 다시 보내요(SDK 동작) — retry_if_exception_type · exceptions_to_handle 로 좁혀 쓰세요. ## 구조화 출력(with_structured_output) **ChatOpenAI 기본 방식 — Claude · GPT 둘 다 같은 코드** ```python import os from pydantic import BaseModel from langchain_openai import ChatOpenAI class RuleAnswer(BaseModel): title: str summary: str key = os.environ["ONEPORT_API_KEY"] llm = ChatOpenAI(model="claude-sonnet-5-5", base_url="https://oneport.kr/v1", api_key=key, max_tokens=2000) answer = llm.with_structured_output(RuleAnswer).invoke("수습기간 규정을 제목과 한 줄 요약으로 정리해 주세요") print(answer.title, answer.summary) ``` - ChatOpenAI 의 기본 방식(json_schema · SDK 동작)은 Claude id 로 보내도 저희가 Claude 구조화 출력(output_config.format)으로 옮겨서 그 스키마대로 답해요. GPT 는 그대로 가요. - 도구 방식(ChatOpenAI 의 method="function_calling" · ChatAnthropic 의 with_structured_output)은 도구를 강제로 부르게 해요(SDK 동작). claude-fable-5-1 · claude-opus-5-5 · claude-sonnet-5-5 는 강제 도구 호출을 받지 않아요 — ChatOpenAI 로 보내면 저희가 auto 로 낮춰 보내서(응답 머리글 x-gateway-ignored-params 에 tool_choice) 모델이 도구를 안 부르면 읽을 게 없고, ChatAnthropic 으로 보내면 몸통을 그대로 넘겨 400 이에요. 이 세 모델은 ChatOpenAI 기본 방식으로 쓰세요. claude-sonnet-5 와 claude-sonnet-5-5 는 다른 모델이에요(10월 기준 값은 같아요) — claude-sonnet-5 는 강제 도구 호출을 받아요. - GPT 에 도구를 실으면(bind_tools 든 with_structured_output 의 도구 방식이든) — 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 을 채워 보내고 응답 머리글 x-gateway-mapped-params 에 reasoning_effort->none 을 적어요(none 밖의 값을 주시면 그대로 가서 벤더가 400 · 벤더 동작). gpt-6-astra · gpt-6.1-sol 은 이 문에서 도구를 못 받아 400 이라 구조화 출력은 기본 방식으로 쓰세요. Claude 는 ChatOpenAI · ChatAnthropic 둘 다 bind_tools 의 함수 도구를 받아요. - 구조화 출력에서 토큰 수도 같이 보시려면 with_structured_output(RuleAnswer, include_raw=True) 로 받으세요 — 결과의 "raw" 가 원래 메시지라 usage_metadata · response_metadata 가 있고, 답은 "parsed" 에 와요(SDK 동작). ## 쓴 토큰 · 크레딧 확인 스트림으로 받아도 끝에 토큰 수가 와요 — ChatOpenAI 경로는 모델과 상관없이 저희가 usage 조각을 싣고, ChatAnthropic 스트림은 끝 message_delta 이벤트의 usage 에 와요(LangChain 에선 usage_metadata 로 읽어요 · SDK 동작). 호출 한 번에 쓴 크레딧은 원포트 응답 usage 의 cost_credits 로 와요(스트림이면 끝 usage · 정산이 2초 안에 안 끝나면 그 칸 없이 와요). cost_credits 는 usage_metadata 엔 안 옮겨져요 — .invoke 결과면 원응답 usage 가 response_metadata 에 남아요(ChatOpenAI 는 "token_usage", ChatAnthropic 은 "usage" · SDK 동작 · 판마다 달라요). 스트림으로 받았거나 그 칸이 없으면 토큰 × GET https://oneport.kr/v1/models 의 pricing.rates 단가 ÷ 1,000,000 으로 셈하세요(100만 토큰당 크레딧 · 1 크레딧 = 2원 · usage_metadata.input_tokens 는 두 클래스 모두 캐시 읽기 · 쓰기를 포함한 합계예요(SDK 동작) · 캐시 토큰 셈은 「글 — /v1/chat/completions」 장 — Claude 1시간 캐시 쓰기는 cache_write 가 아니라 입력 단가의 2배예요). ChatOpenAI 로 부른 Claude 의 캐시 쓰기 수는 usage_metadata 에 안 와요(SDK 동작) — .invoke 면 response_metadata["token_usage"]["prompt_tokens_details"] 의 cache_write_tokens(1시간 몫은 cache_write_1h_tokens)에서 읽어요. 잔액은 GET https://oneport.kr/v1/credits 의 balance_credits, 이 키에 남은 몫은 GET https://oneport.kr/v1/key 의 month · day 예요. 둘 다 같은 키를 머리글에 실어 부르고 요금이 안 붙어요. 잔액은 답이 끝난 뒤에 줄고 1 크레딧 아래를 버려서, 잔액 앞뒤 차이로 호출 값을 재지 마세요. ## 임베딩(OpenAIEmbeddings) 임베딩은 OpenAIEmbeddings(model="text-embedding-3-small", base_url="https://oneport.kr/v1", api_key=key, max_retries=0) 로 붙여요 — 모델은 GET https://oneport.kr/v1/models 에서 kind 가 "embedding" 인 줄이에요. OpenAIEmbeddings 는 기본으로 글을 토큰 번호 배열로 바꿔 보내요(SDK 동작) — text-embedding-… 은 그 배열을 그대로 받고, gemini-embedding-… 이나 전송 정책을 켠 스페이스 키라면 check_embedding_ctx_length=False 를 주어 글 그대로 보내세요(그 스페이스 키는 토큰 번호(숫자) 배열 input 을 403 space_content_policy 로 거절해요 — error.reason 이 token_input 이면 이 경우예요 · 글 배열은 받아요). OpenAIEmbeddings 는 응답의 usage 를 돌려주지 않아요(SDK 동작) — 쓴 몫을 세려면 openai SDK 의 client.embeddings.create(…) 로 불러 usage.prompt_tokens 를 읽어요(임베딩 응답엔 usage.cost_credits 가 없어요). 위 「재시도 · 시간 초과 · 중복 방지」 절의 글 문 규칙(Idempotency-Key · 시간 제한 300초 · 504 timeout 다시 보내기)은 임베딩 문엔 안 맞아요 — 402 hold_exceeds_balance 나 504 면 chunk_size(한 요청에 넣는 개수 · SDK 동작)를 줄이고, timeout 칸에 넣을 시간 · 쓴 몫 셈 · 묶음이 잡는 몫은 「임베딩」 장(https://oneport.kr/docs/reference/embeddings.md)을 따라요.