Документация API go2llm

Питон-бэкенд на 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

Проблемы этого кода, которые проявятся не в тестах, а на проде:

  1. Хост зашит внутрь SDK, переключить провайдера можно только правкой кода.
  2. Строка модели ни с чем не сверяется. Опечатка обнаруживается в рантайме.
  3. Любая временная ошибка апстрима — исключение наружу. Ни Retry-After,

ни разделения «повторять можно» / «повторять нельзя».

  1. Стоимость запроса нигде не фиксируется, расход виден только постфактум

в чужом кабинете.

Что делаем

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КодПовторять
400invalid_request, unsupported_content_type, model_protocol_mismatchнет, чинить запрос
401no-keyнет, чинить ключ
402insufficient_balanceнет, пополнить баланс или уменьшить max_tokens
403model / inactive / expired / ipнет, чинить настройки ключа
404model_not_foundнет, обновить каталог
429rate_limitда, с учётом Retry-After
429quotaнет, это квота ключа, а не частота
502upstream_unavailableда, безопасный текстовый запрос
503product_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 токенов, поэтому арифметика прямая:

МодельinoutВход, $Выход, $Итого за месяц
gpt-5.6-sol0.4022.0160×0.402 = 24.1220×2.01 = 40.20$64.32
claude-sonnet-50.3291.6460×0.329 = 19.7420×1.64 = 32.80$52.54
gpt-5.6-terra0.2011.2160×0.201 = 12.0620×1.21 = 24.20$36.26
gpt-5.6-luna0.040.2460×0.04 = 2.4020×0.24 = 4.80$7.20
deepseek-v4-flash0.02260.067960×0.0226 = 1.35820×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.

Получить ключ и сделать первый запрос за 3 минуты