Питон-бэкенд на OpenAI SDK: перенос без изменения бизнес-логики
Технический разбор от команды go2llm, а не отзыв клиента. Код ниже рабочий, шаги воспроизводимы. Все суммы — расчёт по опубликованному прайсу (каталог v38, 2026-09-27), а не замер чужого счёта.
Задача
Есть питон-сервис, который через официальный OpenAI SDK делает суммаризацию, классификацию и генерацию текста. Вызов LLM спрятан за одним классом-обёрткой. Нужно:
- перевести трафик на другой провайдер, не трогая бизнес-логику;
- не ловить
NameErrorиз-за того, что в новом каталоге другой набор моделей; - перестать падать в 500 на прод, когда апстрим отдаёт временную ошибку.
Ограничение: релиз должен быть откатываемым одной переменной окружения.
Как было
Классический вариант — SDK смотрит в дефолтный хост, модель захардкожена, ретраев нет, ошибки летят наружу.
# llm.py — ДО
import os
from openai import OpenAI
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
class LLM:
MODEL = "gpt-4o-mini" # захардкожено, никем не проверяется
def complete(self, system: str, user: str, max_tokens: int = 600) -> str:
resp = client.chat.completions.create(
model=self.MODEL,
max_tokens=max_tokens,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
)
return resp.choices[0].message.content
Проблемы этого кода, которые проявятся не в тестах, а на проде:
- Хост зашит внутрь SDK, переключить провайдера можно только правкой кода.
- Строка модели ни с чем не сверяется. Опечатка обнаруживается в рантайме.
- Любая временная ошибка апстрима — исключение наружу. Ни
Retry-After,
ни разделения «повторять можно» / «повторять нельзя».
- Стоимость запроса нигде не фиксируется, расход виден только постфактум
в чужом кабинете.
Что делаем
1. Выносим base_url и ключ в конфиг
Никакого «поменяю строчку перед деплоем». Переключение провайдера — это переменная окружения, а значит откат — тоже переменная окружения.
# .env
LLM_BASE_URL=https://go2llm.tech/v1
LLM_API_KEY=gk_sk_ВАШ_КЛЮЧ
LLM_MODEL=gpt-5.6-sol
Для OpenAI SDK base URL — https://go2llm.tech/v1, с /v1. Это отличается от Anthropic SDK, где base URL идёт без суффикса. Путаница здесь даёт 404, который легко принять за проблему с ключом.
2. Фиксируем model ID из каталога
Каталог отдаётся без авторизации, поэтому его можно дёрнуть и руками, и в CI:
curl -sS https://go2llm.tech/v1/models | jq -r '.data[].id' | sort
Дальше — проверка в старте приложения. Она стоит один HTTP-запрос при деплое и экономит инцидент с 404 model_not_found в 3 часа ночи.
# model_guard.py
import os
import httpx
BASE_URL = os.environ["LLM_BASE_URL"].rstrip("/")
def assert_model_published(model: str) -> None:
"""Падаем на старте, а не на первом запросе пользователя."""
r = httpx.get(f"{BASE_URL}/models", timeout=10.0)
r.raise_for_status()
published = {m["id"] for m in r.json()["data"]}
if model not in published:
near = sorted(p for p in published if p.split("-")[0] == model.split("-")[0])
raise RuntimeError(
f"model {model!r} не опубликована. Похожие: {near or 'нет'}"
)
Вызывается один раз на старте процесса, например в lifespan-хуке FastAPI. Каталог — источник правды, а не README.
3. Разделяем ошибки на «повторять» и «не повторять»
Это главное отличие рабочего клиента от примера из документации. Коды и их семантика опубликованы, полный список — /docs/errors.
| HTTP | Код | Повторять |
|---|---|---|
| 400 | invalid_request, unsupported_content_type, model_protocol_mismatch | нет, чинить запрос |
| 401 | no-key | нет, чинить ключ |
| 402 | insufficient_balance | нет, пополнить баланс или уменьшить max_tokens |
| 403 | model / inactive / expired / ip | нет, чинить настройки ключа |
| 404 | model_not_found | нет, обновить каталог |
| 429 | rate_limit | да, с учётом Retry-After |
| 429 | quota | нет, это квота ключа, а не частота |
| 502 | upstream_unavailable | да, безопасный текстовый запрос |
| 503 | product_paused / no_eligible_route | да, с задержкой |
402 в списке «не повторять» намеренно. Ретраить запрос при нулевом балансе — это просто сжечь свой rate limit: баланс от повторов не появится, а шлюз всё равно не уйдёт ниже нуля.
4. Пишем backoff, который умеет читать Retry-After
# backoff.py
import random
import time
from typing import Callable, TypeVar
import openai
T = TypeVar("T")
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
NON_RETRYABLE_429_CODES = {"quota"}
def _error_code(exc: openai.APIStatusError) -> str:
try:
return (exc.response.json().get("error") or {}).get("code") or ""
except Exception:
return ""
def _retry_after(exc: openai.APIStatusError) -> float | None:
raw = exc.response.headers.get("retry-after")
if not raw:
return None
try:
return float(raw)
except ValueError:
return None
def with_backoff(
fn: Callable[[], T],
*,
attempts: int = 4,
base: float = 0.8,
cap: float = 20.0,
) -> T:
"""Экспоненциальный backoff с полным джиттером и уважением к Retry-After."""
last: Exception | None = None
for i in range(attempts):
try:
return fn()
except openai.APIStatusError as exc:
code = _error_code(exc)
if exc.status_code == 402:
raise # insufficient_balance: повтор не поможет
if exc.status_code == 429 and code in NON_RETRYABLE_429_CODES:
raise # квота ключа исчерпана
if exc.status_code not in RETRYABLE_STATUS:
raise # 400/401/403/404 — ошибка запроса, не транспорта
last = exc
delay = _retry_after(exc)
if delay is None:
# полный джиттер: 0.8, 1.6, 3.2, ... с разбросом
delay = random.uniform(0.0, min(cap, base * (2 ** i)))
except (openai.APIConnectionError, openai.APITimeoutError) as exc:
last = exc
delay = random.uniform(0.0, min(cap, base * (2 ** i)))
if i == attempts - 1:
break
time.sleep(delay)
raise last # type: ignore[misc]
Полный джиттер, а не sleep(2**i), потому что при синхронном ретрае из десяти воркеров вы получите десять одновременных повторов ровно через 2 секунды — и снова 429.
Как стало
Тот же класс, та же сигнатура метода, тот же вызывающий код. Изменились: источник конфига, обработка ошибок и учёт стоимости.
# llm.py — ПОСЛЕ
import logging
import os
from dataclasses import dataclass
from openai import OpenAI
from backoff import with_backoff
from model_guard import assert_model_published
log = logging.getLogger(__name__)
client = OpenAI(
api_key=os.environ["LLM_API_KEY"],
base_url=os.environ["LLM_BASE_URL"], # https://go2llm.tech/v1
max_retries=0, # ретраи делаем сами, чтобы читать Retry-After и error.code
timeout=60.0,
)
@dataclass(slots=True)
class Completion:
text: str
cost_usd: float | None
class LLM:
def __init__(self, model: str | None = None, verify: bool = True) -> None:
self.model = model or os.environ["LLM_MODEL"]
if verify:
assert_model_published(self.model)
def complete(self, system: str, user: str, max_tokens: int = 600) -> Completion:
def call():
return client.chat.completions.with_raw_response.create(
model=self.model,
max_tokens=max_tokens,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
)
raw = with_backoff(call)
resp = raw.parse()
usage = resp.usage
cost = getattr(usage, "cost_usd", None) if usage else None
log.info(
"llm.call model=%s in=%s out=%s cost_usd=%s",
self.model,
getattr(usage, "prompt_tokens", None),
getattr(usage, "completion_tokens", None),
cost,
)
return Completion(
text=resp.choices[0].message.content,
cost_usd=cost,
)
Бизнес-логика, которая вызывает LLM().complete(...), не изменилась ни на строку. Обёртка теперь возвращает ещё и фактическую стоимость запроса: шлюз кладёт её в usage.cost_usd. Это не оценка по токенам, а списанная сумма.
Смоук-тест, который стоит держать в CI:
curl -sS https://go2llm.tech/v1/chat/completions \
-H "Authorization: Bearer $LLM_API_KEY" \
-H "content-type: application/json" \
-d '{"model":"gpt-5.6-sol","max_tokens":120,
"messages":[{"role":"user","content":"Ответь одним словом: работает"}]}' \
| jq '{text: .choices[0].message.content, cost: .usage.cost_usd}'
Откат — одна переменная: вернуть прежний LLM_BASE_URL и ключ, перезапустить процесс. Код не участвует.
Сколько это стоит
Расчёт по опубликованному прайсу, каталог v38 от 2026-09-27, а не замер у клиента. Проверить: /pricing и GET /api/pricing.
Модельный профиль сервиса: 50 000 запросов в месяц, в среднем 1 200 входных и 400 выходных токенов на запрос. Это значит:
- вход:
50 000 × 1 200 = 60 000 000токенов = 60M; - выход:
50 000 × 400 = 20 000 000токенов = 20M.
Цены даны за 1M токенов, поэтому арифметика прямая:
| Модель | in | out | Вход, $ | Выход, $ | Итого за месяц |
|---|---|---|---|---|---|
gpt-5.6-sol | 0.402 | 2.01 | 60×0.402 = 24.12 | 20×2.01 = 40.20 | $64.32 |
claude-sonnet-5 | 0.329 | 1.64 | 60×0.329 = 19.74 | 20×1.64 = 32.80 | $52.54 |
gpt-5.6-terra | 0.201 | 1.21 | 60×0.201 = 12.06 | 20×1.21 = 24.20 | $36.26 |
gpt-5.6-luna | 0.04 | 0.24 | 60×0.04 = 2.40 | 20×0.24 = 4.80 | $7.20 |
deepseek-v4-flash | 0.0226 | 0.0679 | 60×0.0226 = 1.358 | 20×0.0679 = 1.358 | $2.716 |
Справочный рублёвый эквивалент по курсу ЦБ 86,3793 ₽ за $1 на 01.09.2026: $64.32 ≈ 5 557 ₽, $2.72 ≈ 235 ₽. Баланс и списания ведутся в USD, рубль тут только для оценки.
Смысл таблицы не в том, чтобы выбрать самое дешёвое. Смысл в том, что перед переносом видно вилку в 24× между верхом и низом на одной и той же нагрузке — и решение «какая модель на каком маршруте» принимается по цифрам, а не на глаз. Разбор маршрутизации по сложности — в отдельном кейсе.
Тарификация — pay-as-you-go, подписок и обязательной месячной платы нет. Минимальное пополнение $1, при регистрации на баланс начисляется $1.
Грабли
1. /v1 в base_url для OpenAI SDK обязателен. https://go2llm.tech/v1. Для Anthropic SDK — наоборот, https://go2llm.tech без суффикса. Ошибка даёт 404, который читается как «неверный ключ».
2. max_retries=0 в SDK — не лень, а необходимость. Встроенные ретраи OpenAI SDK не различают 429 quota и 429 rate_limit и не знают, что 402 повторять бессмысленно. Либо свой backoff, либо чужие повторы поверх ваших.
3. 404 на /v1/responses. Обновление 2026-09-14: Responses API опубликован. client.responses.create(...) с base_url="https://go2llm.tech/v1" работает, состояние шлюз не хранит — previous_response_id и store: true отклоняются с 400 stateful_responses_unsupported, историю надо слать в input. По тому же маршруту подключается Codex CLI (wire_api = "responses"), проверено живым прогоном codex exec 0.151.0.
4. 400 unsupported_content_type при попытке отправить файл. В текстовые endpoint'ы изображения, PDF, аудио и бинарь не принимаются. Embeddings, image edits, video и audio также не опубликованы. Если ваш сервис делает RAG на embeddings того же провайдера — это отдельная зависимость, её перенос сюда не входит.
5. 429 rate_limit на 20 RPM. По умолчанию на ключ действует лимит 20 RPM. Пул воркеров на 32 потока упрётся в него сразу. Ставьте семафор на стороне приложения — это дешевле, чем ретраить.
6. 409 idempotency_conflict. Актуально, если сервис заодно генерирует изображения: Idempotency-Key обязателен для image-запросов (8–128 печатных ASCII), и один и тот же ключ нельзя переиспользовать для разных тел запроса.
Заголовков X-RateLimit-* шлюз не отдаёт. Ни X-RateLimit-Remaining, ни X-RateLimit-Reset в ответе нет — остаток лимита на стороне клиента не виден, поэтому семафор и backoff это не подстраховка, а единственный способ не упереться в 20 RPM. Retry-After ставится только на 429, на 409 request_in_progress и на 503 product_paused. На 502 upstream_unavailable его нет — для этого класса ошибок задержку выбирает сам клиент, как в with_backoff выше.
Заголовка с идентификатором запроса на текстовых endpoint'ах нет. Ни /v1/messages, ни /v1/chat/completions его не возвращают: идентификатор приходит только в теле ошибки, в поле error.request_id, и начинается с префикса gw_. Именно его имеет смысл логировать в обработчике ошибок и нести в поддержку. Заголовок есть только у /v1/images/generations — там он называется x-go2llm-request-id.
Про embeddings рекомендованной замены у нас пока нет. Endpoint не опубликован, и подменять его чем-то «по аналогии» мы не станем — если RAG-часть вашего пайплайна завязана на embeddings, напишите в @go2llm_support: ответим и дополним страницу. До тех пор эта зависимость остаётся у прежнего провайдера, и переносится только генеративная часть.
Таймаут upstream-запроса — 180 000 мс (3 минуты) на большинстве маршрутов. Публично он нигде не объявлен и может отличаться на части маршрутов, а при срабатывании клиент получает 502 без указания причины. Практический вывод: не выставляйте клиентский таймаут заметно больше и не считайте каждый 502 проблемой поставщика — длинный запрос мог просто не уложиться.
Итог
Перенос питон-бэкенда — это одна переменная окружения плюс два файла инфраструктурного кода, которые стоило написать в любом случае: проверка model ID по каталогу и backoff, различающий классы ошибок. Бизнес-логика не трогается, откат делается перезапуском с прежним LLM_BASE_URL.
Что появляется сверх переноса: фактическая стоимость каждого запроса в usage.cost_usd в логе приложения, один ключ на 38 опубликованных моделей, один баланс и возможность поставить на ключ квоту в долларах, список разрешённых моделей и срок действия.
Документация — /docs, цены — /pricing, коды ошибок — /docs/errors.