← 전체 글 보기

드롭인 OpenAI 호환 API — 두 줄이면 끝나는 마이그레이션

2026-08-20 · 4 분 소요 · api, migration

unbleep은 OpenAI 호환 API입니다. 실무적으로는 마이그레이션이 두 줄이면 끝나고 새 의존성도 없다는 뜻입니다. 공식 SDK, 재시도 로직, 스트리밍 루프, 토큰 집계, 에러 핸들러를 모두 그대로 유지합니다. 바뀌는 것은 요청이 어디로 가는지와 어떤 키로 인증하는지뿐입니다. 이 글은 그 교체 과정을 먼저 다루고, 그다음 모르고 있으면 무언가를 깨뜨릴 만큼 다른 네 가지를 설명합니다.

두 줄로 OpenAI 호환 API로 마이그레이션하기

python
import os

from openai import OpenAI

client = OpenAI(
    base_url="https://unbleep.ai/v1",
    api_key=os.environ["UNBLEEP_API_KEY"],
)

resp = client.chat.completions.create(
    model="unbleep",
    messages=[{"role": "user", "content": "Summarise this incident report."}],
)
print(resp.choices[0].message.content)

코드를 아예 건드리고 싶지 않다면, SDK가 두 값을 모두 환경 변수에서 읽으므로 설정 변경만으로 충분합니다.

bash
export OPENAI_BASE_URL="https://unbleep.ai/v1"
export OPENAI_API_KEY="ub_live_9f2c..."

같은 두 값이 SDK 위에 구축된 생태계 대부분을 커버합니다 — LangChain, LlamaIndex, Instructor, Vercel AI SDK 등 base URL 설정을 노출하는 것이라면 무엇이든. 키에는 접두사가 붙어 있어 유출되면 시크릿 스캐너가 바로 알아챕니다. ub_live_와 ub_test_는 모두 같은 선불 크레딧에서 같은 토큰 단가로 과금됩니다. 테스트 키는 상한이 더 낮은 — 계정 한도인 분당 60회 대신 분당 15회 — 별도의 폐기 가능한 자격 증명이지, 무료 티어가 아닙니다. 둘 다 서버 측에만 두세요.

GET /v1/models도 동작하므로, 모델 목록을 조회해 드롭다운을 채우는 도구를 특별히 처리할 필요가 없습니다.

모델 고르기

티어는 세 가지입니다. 날짜가 붙은 id — unbleep-250811, unbleep-high-250811, unbleep-mini-250811 — 도 별칭으로 받아들이지만, 현재는 날짜 없는 id와 같은 빌드로 해석되고 응답에도 날짜 없는 id가 돌아옵니다. 이는 앞으로의 호환을 위한 표기이지 재현성 보장이 아닙니다. 평가가 반복 가능해야 한다면 모델 id가 아니라 출력을 기록하세요.

| 모델 | 컨텍스트 | 1M당 입력 / 출력 가격 | 비고 | | --- | --- | --- | --- | | unbleep | 256K | $3.00 / $3.00 | 기본값. 추론 티어. | | unbleep-high | 1M* | $5.00 / $5.00 | 가장 큰 작업용. 추론 티어. | | unbleep-mini | 32K | $1.00 / $1.00 | 저렴하고 빠름. 추론 트레이스 없이 바로 답변. |

*요청 본문은 2,000,000바이트 — 대략 50만 토큰 — 로 제한되므로 한 번의 호출로 1M 윈도우를 실제로 채울 수는 없습니다. 이보다 크면 413 payload_too_large가 돌아옵니다.

unbleep-mini의 32K 상한은 128K 컨텍스트 모델에서 넘어오는 사람들이 걸리는 지점입니다. 전에는 들어가던 프롬프트가 이제는 거부됩니다. 비용 기준으로 라우팅한다면 길이 기준으로도 라우팅하세요.

python
def pick_model(prompt_chars: int) -> str:
    """~4 chars/token is a deliberate under-estimate; leave room for the completion."""
    est_tokens = prompt_chars // 4
    if est_tokens < 24_000:
        return "unbleep-mini"
    return "unbleep" if est_tokens < 200_000 else "unbleep-high"

스트리밍

stream=True를 설정하면 표준 Server-Sent Events를 받습니다. 각 이벤트는 delta를 담은 chat.completion.chunk이고, 스트림은 문자 그대로 data: [DONE]으로 끝납니다. 기존 루프가 그대로 동작합니다.

bash
curl -N https://unbleep.ai/v1/chat/completions \
  -H "Authorization: Bearer $UNBLEEP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "unbleep",
    "messages": [{"role": "user", "content": "Explain the residual stream."}],
    "stream": true,
    "stream_options": {"include_usage": true}
  }'

토큰 수는 요청하든 안 하든 받게 됩니다. unbleep은 항상 백엔드에 usage를 요청해 그대로 전달하며, 명시적으로 stream_options: {"include_usage": false}를 보낼 때만 나가는 길에 제거됩니다. 어느 쪽이든 스트림은 choices 배열이 비어 있는 마지막 청크 하나로 끝나고 — 옵트아웃하지 않았다면 채워진 usage 객체를 실어 나릅니다 — 그래서 아래 루프는 choices를 건드리기 전에 먼저 확인합니다.

reasoning_content 필드

스키마에 진짜로 추가된 유일한 항목입니다. unbleep과 unbleep-high는 답하기 전에 생각하며, 그 사고 과정(chain of thought)은 reasoning_content로 반환됩니다. 메시지(비스트리밍) 또는 델타(스트리밍)에서 content와 나란히 놓이는 필드입니다. 업스트림 백엔드마다 이를 reasoning이라 부를지 reasoning_content라 부를지 제각각이지만, API가 reasoning_content로 정규화하므로 한 가지 형태만 처리하면 됩니다.

OpenAI 스키마의 일부가 아니므로 SDK의 타입 스텁에는 없습니다. 응답 모델이 추가 필드를 허용하기 때문에 런타임에는 속성 접근이 동작하지만, 트레이스가 없는 mini 응답에서 예외가 나지 않도록 getattr로 읽으세요.

python
import sys

stream = client.chat.completions.create(
    model="unbleep",
    messages=[{"role": "user", "content": "Why did this detection rule misfire?"}],
    stream=True,
    stream_options={"include_usage": True},
)

usage = None
for chunk in stream:
    if not chunk.choices:          # final usage-only chunk
        usage = chunk.usage
        continue
    delta = chunk.choices[0].delta
    thought = getattr(delta, "reasoning_content", None)
    if thought:                    # trace to stderr, answer to stdout
        sys.stderr.write(thought)
    if delta.content:
        sys.stdout.write(delta.content)

if usage:
    details = usage.completion_tokens_details
    print(f"\nreasoning tokens: {getattr(details, 'reasoning_tokens', 0)}")

실무적인 결과 세 가지:

python
resp = client.chat.completions.create(
    model="unbleep",
    messages=[{"role": "user", "content": "phishing or benign?"}],
    max_tokens=4,
    extra_body={"thinking": False},   # spend the budget on the answer, not the trace
)

reasoning_content를 이후 턴에 assistant 콘텐츠로 되먹이지 마세요. 이는 대화 기록이 아니라 진단용 출력이며, 재생하면 다음 응답의 품질이 떨어집니다.

에러, 그리고 진짜 함정 두 가지

에러는 OpenAI 형식(envelope) — {"error": {"type", "code", "message"}} — 을 따르므로 기존 except 블록이 계속 동작합니다. 상태 코드도 예상대로입니다. 401 잘못된 키, 422 policy: strict에 의해 차단, 429 요청 한도 초과, 5xx 재시도 가능한 업스트림 오류.

아마 한 번도 처리해 본 적 없을 상태 코드는 402, 크레딧 소진입니다. 계정은 선불이라 초과 사용도 청구서도 없습니다. 충전할 때까지 요청이 그냥 멈출 뿐입니다. OpenAI는 할당량 소진을 429로 알리기 때문에, 순진하게 마이그레이션하면 백오프 로직이 402를 영원히 재시도하게 됩니다. 종료 상태로 취급하고 알림을 걸어 두세요.

두 번째 함정: system_fingerprint는 반환되지 않습니다. 서빙 백엔드를 식별하는 값이라 다른 벤더 필드와 함께 제거됩니다. 캐시나 재현성 검사의 키를 여기에 걸고 있다면 자체 버전 마커가 필요합니다. 날짜가 붙은 모델 id는 현재 빌드의 별칭이지 고정된 스냅샷이 아니므로, 백엔드가 바뀌는 시점을 알려주지 않습니다.

요청 한도(rate limit)는 모든 응답의 헤더 — x-ratelimit-limit-requests, x-ratelimit-remaining-requests, x-ratelimit-reset-requests — 로 돌아오므로, 배치 작업은 한도에 부딪혀서 알아내는 대신 요청 속도를 조절할 수 있습니다. 헤더가 알려주지 않는 두 번째 상한이 있습니다. 계정당 동시에 처리 중인 요청은 최대 8개이고, 아홉 번째는 too_many_concurrent_requests 코드와 함께 429로 돌아옵니다. 스트리밍 호출은 스트림이 끝날 때까지 슬롯을 점유하므로, 워커 풀을 8개로 제한하세요.

무엇을 향해 요청을 보내는지

분명히 해 둘 가치가 있습니다. 이 엔드포인트 뒤의 모델은 abliterated 모델, 즉 거부 동작이 가중치 수준에서 제거된 모델입니다. 그것이 핵심입니다 — 거부가 곧 측정 오류인 보안 연구, 레드티밍, 평가를 위한 개발자 API입니다. 동시에 잘못된 프롬프트를 걸러 줄 통상적인 가드레일이 없다는 뜻이기도 하므로, 출력에 책임지는 사람을 두고 허용 사용 정책을 읽어 두세요. 합법적으로 사용할 책임은 여러분에게 있습니다.

API 키를 발급받으세요 — 마이그레이션은 정말로 두 줄입니다.