Files
infosave2007 0b071279ac
CI / lint (push) Canceled after 0s
CI / test (macos-14) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
Release / crates-io (push) Canceled after 0s
Release / release (macos-14, aarch64-apple-darwin) (push) Canceled after 0s
Release / release (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Canceled after 0s
Release / release (windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s
Docker / publish (push) Canceled after 0s
fix(stream): no choice-less chunks unless the client asks for usage
Every stream used to carry two chunks with an empty choices array: usage,
which the upstream emits BEFORE the stop reason, and a cortiq_cost_usd chunk
the gateway added itself. OpenAI ships the usage chunk only when the request
sets stream_options: {"include_usage": true}, and chat UIs do not set it -
which is why the same tool, in the same Open WebUI, sees neither chunk from
Groq and two from us.

Honour the flag. With no stream_options a stream is now exactly
role -> content... -> tool_calls -> finish_reason -> [DONE], with no
choice-less chunks at all. When usage is requested it ships once, after the
stop reason, with cortiq_cost_usd folded into the same chunk rather than
trailing behind it.

Token accounting, cost and the X-Cortiq-* headers are untouched: those numbers
were never read back off the wire.

Verified: the default stream matches the reference shape byte for byte, the
opt-in stream matches OpenAI's, the official OpenAI SDK reassembles the
streamed tool call, and the full Open WebUI 0.11.0 tool loop still runs the
tool and answers from its result.
2026-08-21 21:24:11 +03:00

18 KiB
Raw Permalink Blame History

Read this in English.

Протоколы Cortiq Gateway

Контракты входящих протоколов и кросс-стандартных механизмов. Каждый протокол включается флагом в [protocols]. Внутри всё приводится к канонической модели (см. ARCHITECTURE.md).

Общее правило маршрутизации во всех протоколах:

  • model = "cortiq-auto" → включить интеллектуальный роутинг через cortiq-router.
  • model = "cortiq-auto:quality-first" → роутинг с указанным policy-профилем (cost-saver | balanced | quality-first).
  • model = "<реальный id из конфига>" → passthrough к этой модели без роутинга.

Оглавление

  1. OpenAI Chat Completions
  2. OpenAI Completions (legacy)
  3. OpenAI Embeddings
  4. OpenAI Models
  5. Anthropic Messages
  6. MCP (Model Context Protocol)
  7. Native passthrough
  8. Кросс-стандарты

1. OpenAI Chat Completions

POST /v1/chat/completions — основной endpoint, де-факто стандарт для агентов.

Запрос

{
  "model": "cortiq-auto",                 // роутинг; или реальный id для passthrough
  "messages": [
    { "role": "system", "content": "You are a helpful assistant." },
    { "role": "user", "content": "Solve x^2 - 5x + 6 = 0" }
  ],
  "temperature": 0.7,
  "max_tokens": 1024,
  "stream": false,
  "tools": [ /* function-calling, проксируется к провайдеру как есть */ ],
  "tool_choice": "auto",
  "stop": ["\n\n"],
  "top_p": 1.0,
  "response_format": { "type": "json_object" }
}

Поддерживаются стандартные поля OpenAI; неизвестные поля проксируются к провайдеру без изменений (forward-compatible) — кроме служебных ключей, которые чат-интерфейсы добавляют в payload для собственных нужд (chat_id, session_id, metadata, features, …): они отбрасываются, чтобы строгий upstream не отклонил вызов.

Парсер намеренно терпим к тем формам, которые реально шлют клиенты: content может быть строкой, null или массивом content-частей (текстовые части склеиваются, изображения и прочие нетекстовые пропускаются); tools может быть null; reasoning_effort — строкой (low | medium | high) или бюджетом токенов; max_completion_tokens принимается как синоним max_tokens. Если тело всё-таки не читается, возвращается OpenAI-конверт ошибки с HTTP 400 — но не «голый» 422, который OpenAI-клиенты не умеют разбирать (для пользователя это выглядит как отсутствие ответа).

tools трактуется как предложение, а не требование. Шлюз предпочитает модель с поддержкой tools, но если такой нет — отвечает без них, а не падает, и сообщает об этом заголовком X-Cortiq-Tools-Dropped: true (чат-интерфейсы добавляют свои встроенные tools в каждое сообщение, поэтому строгое прочтение делало обычный чат на локальном пуле без tools невозможным). tool_choice: "required" — или именованная функция — это уже настоящее требование, и оно по-прежнему завершается 502 no_healthy_model, если его некому выполнить.

Ответ (без стриминга)

{
  "id": "chatcmpl-9f...",
  "object": "chat.completion",
  "created": 1718800000,
  "model": "cortiq-auto",                 // модель, которую ЗАПРОСИЛ клиент — возвращается как есть
  "choices": [
    {
      "index": 0,
      "message": { "role": "assistant", "content": "x = 2 или x = 3" },
      "finish_reason": "stop"               // всегда одно из stop|length|tool_calls|content_filter
    }
  ],
  "usage": {
    "prompt_tokens": 24,
    "completion_tokens": 11,
    "total_tokens": 35
  },
  "cortiq": {                             // расширение (включается cortiq.echo=true)
    "task_label": "math",
    "complexity": { "score": 0.22, "tier": "low" },
    "selected_model": "local-qwen",
    "route_source": "router",             // router | cache | fallback | pinned
    "router_request_id": "req_...",
    "cost_usd": 0.0
  }
}

model возвращает тот id, который запросил клиент, — ровно как это делает OpenAI: клиенты сопоставляют ответ с отправленным id. Модель, которая реально ответила, — в cortiq.selected_model и в заголовке X-Cortiq-Selected-Model.

Необязательные поля не сериализуются вместо того, чтобы приходить как null: если tool-вызовов нет, ключа tool_calls в ответе нет вообще. "tool_calls": null не входит в схему OpenAI и ломает строгие клиенты.

Поле cortiq — необязательное расширение. OpenAI-SDK его игнорируют. Если клиент не хочет «лишних» полей — выключите cortiq.echo, метаданные останутся в заголовках.

Стриминг (stream: true) отдаёт строгие события chat.completion.chunk: поток всегда открывается дельтой с role, содержит ровно один финальный finish_reason и завершается data: [DONE] — даже если upstream оборвался или упал на середине (тогда ошибка приходит SSE-событием error). Клиент никогда не остаётся ждать недосказанный поток.

В стриме нет чанков без choices, если их не просили: usage отдаётся, только когда запрос содержит stream_options: {"include_usage": true}, и тогда ровно один раз — после чанка с finish_reason, с вложенным cortiq_cost_usd. Без него поток выглядит как role → content… → tool_calls → finish_reason → [DONE] и ничего больше — ровно так же, как у хостовых провайдеров.

Tool calls

tools и tool_calls проксируются, но провод держится чистым:

  • сырая разметка <tool_call>…</tool_call> (так Qwen-подобные шаблоны выражают вызов прямо в тексте) вырезается из content — клиент никогда не получает один и тот же вызов дважды, прозой и структурой. Маркер, разорванный между SSE-чанками, обрабатывается корректно; обычный текст с символом < не трогается;
  • если апстрим эту разметку не распарсил, она повышается до настоящего элемента tool_calls со сгенерированным call_… id — вызов становится исполнимым, а не декоративным;
  • в стриме у каждого элемента всегда есть index, id/type — ровно один раз на вызов, а function.arguments — всегда строка, даже если рантайм прислал объект;
  • ответ с tool-вызовами всегда завершается finish_reason: "tool_calls".

Метаданные в заголовках (всегда)

X-Cortiq-Request-Id: req_...
X-Cortiq-Task-Label: math
X-Cortiq-Complexity-Score: 0.22
X-Cortiq-Complexity-Tier: low
X-Cortiq-Selected-Model: local-qwen
X-Cortiq-Route-Source: router
X-Cortiq-Cost-Usd: 0.000000

Ответ (стриминг, stream: true)

SSE, формат OpenAI: серия чанков chat.completion.chunk, завершается data: [DONE]. Шлюз проксирует чанки провайдера один-в-один (без буферизации), добавив метаданные роутинга в HTTP-заголовки до начала потока.

data: {"id":"chatcmpl-9f","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant"},"index":0}]}

data: {"id":"chatcmpl-9f","object":"chat.completion.chunk","choices":[{"delta":{"content":"x = 2"},"index":0}]}

data: {"id":"chatcmpl-9f","object":"chat.completion.chunk","choices":[{"delta":{},"finish_reason":"stop","index":0}]}

data: [DONE]

2. OpenAI Completions (legacy)

POST /v1/completions — для старых интеграций. Внутри prompt оборачивается в одно user-сообщение и идёт по тому же пути. Ответ — object: "text_completion".

// запрос
{ "model": "cortiq-auto", "prompt": "Translate to French: hello", "max_tokens": 64 }
// ответ
{ "object": "text_completion", "model": "local-qwen",
  "choices": [ { "text": "bonjour", "index": 0, "finish_reason": "stop" } ],
  "usage": { "prompt_tokens": 6, "completion_tokens": 2, "total_tokens": 8 } }

3. OpenAI Embeddings

POST /v1/embeddings. Два режима (настраивается):

  • provider — проксировать к embedding-модели из пула;
  • router — вернуть эмбеддинг энкодером cortiq-router (тот же вектор, что роутер использует для классификации — удобно для согласованного RAG).
// запрос
{ "model": "cortiq-embed", "input": ["first text", "second text"] }
// ответ
{
  "object": "list",
  "data": [
    { "object": "embedding", "index": 0, "embedding": [0.01, -0.02, /* ... */] },
    { "object": "embedding", "index": 1, "embedding": [0.03,  0.00, /* ... */] }
  ],
  "model": "cortiq-embed",
  "usage": { "prompt_tokens": 4, "total_tokens": 4 }
}

4. OpenAI Models

GET /v1/models — автодискавери для клиентов. Возвращает виртуальные модели шлюза (cortiq-auto, cortiq-embed), все модели пула [[models]] и все управляемые локальные модели [[cmf.servers]] — чтобы клиент мог напрямую закрепить локальный .cmf.

{
  "object": "list",
  "data": [
    { "id": "cortiq-auto", "object": "model", "created": 1718800000, "owned_by": "cortiq" },
    { "id": "gpt-4o-mini", "object": "model", "created": 1718800000, "owned_by": "openai" },
    { "id": "claude-opus", "object": "model", "created": 1718800000, "owned_by": "anthropic" },
    { "id": "cmf-qwen3-4b", "object": "model", "created": 1718800000, "owned_by": "cortiq-cmf",
      "cortiq": { "kind": "cmf", "local": true, "running": true, "healthy": true } }
  ]
}

Управляемые CMF-модели попадают в список сразу, как только настроены; их живое состояние — в нестандартном поле cortiq (клиенты его игнорируют). По здоровью они не фильтруются: крупный .cmf грузится минутами, и модель, исчезающая из списка на время прогрева, хуже модели, помеченной «ещё не готова».


5. Anthropic Messages

POST /v1/messages — для Claude-нативных агентов и Anthropic SDK.

// запрос
{
  "model": "cortiq-auto",
  "max_tokens": 1024,
  "system": "You are a helpful assistant.",
  "messages": [ { "role": "user", "content": "Solve x^2 - 5x + 6 = 0" } ],
  "stream": false
}
// ответ
{
  "id": "msg_...",
  "type": "message",
  "role": "assistant",
  "model": "local-qwen",
  "content": [ { "type": "text", "text": "x = 2 или x = 3" } ],
  "stop_reason": "end_turn",
  "usage": { "input_tokens": 24, "output_tokens": 11 }
}

Стриминг — нативные Anthropic-события (message_start, content_block_delta, message_delta, message_stop). Если выбранная модель — не-Anthropic-провайдер, шлюз транслирует через каноническую модель в обе стороны.


6. MCP (Model Context Protocol)

Шлюз поднимает MCP-сервер (JSON-RPC 2.0 поверх HTTP+SSE или stdio) и публикует маршрутизацию как инструменты для MCP-нативных оркестраторов.

Публикуемые инструменты:

Инструмент Назначение
route_and_complete классифицировать + выполнить на выбранной модели, вернуть ответ
classify_task только классификация (тип задачи + сложность), без выполнения

tools/list отдаёт JSON-схемы. Пример вызова tools/call:

// запрос (JSON-RPC)
{
  "jsonrpc": "2.0", "id": 1, "method": "tools/call",
  "params": {
    "name": "route_and_complete",
    "arguments": { "prompt": "Solve x^2 - 5x + 6 = 0", "profile": "balanced" }
  }
}
// ответ
{
  "jsonrpc": "2.0", "id": 1,
  "result": {
    "content": [ { "type": "text", "text": "x = 2 или x = 3" } ],
    "isError": false,
    "_meta": { "task_label": "math", "tier": "low", "selected_model": "local-qwen" }
  }
}

7. Native passthrough

POST /route — тонкая обёртка над cortiq-router /v1/route для клиентов, которым нужно только решение (без выполнения). Тело и ответ совпадают с контрактом роутера (см. cortiq-router/docs/ФОРМАТ_ЗАПРОСА_И_ОТВЕТА.md). Полезно для отладки и для тех, кто хочет сам управлять вызовом LLM.


8. Кросс-стандарты

Едины для всех HTTP-протоколов.

8.1 Аутентификация

Authorization: Bearer sk-gw-...

Виртуальный ключ шлюза. Маппится на аккаунт ([[api_keys]]). Если ключи не настроены — открытый режим (для локальной разработки). Ключи провайдеров наружу не отдаются.

8.2 Идемпотентность

Idempotency-Key: <уникальный-id-операции>

Повтор с тем же ключом в окне idempotency.ttl отдаёт сохранённый ответ (безопасные ретраи агентов, без двойного вызова LLM и двойного биллинга).

8.3 Rate-limit

На 429 возвращаются:

Retry-After: 2
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1718800060

8.4 Трассировка

Приём и проброс W3C traceparent; OpenTelemetry-спаны inbound→route→provider. В ответе — X-Request-Id. Это позволяет сшить сквозной трейс агент → шлюз → провайдер.

8.5 Формат ошибки (OpenAI-совместимый)

{
  "error": {
    "message": "no healthy model available for tier 'high'",
    "type": "upstream_unavailable",      // invalid_request_error | authentication_error | rate_limit_error | upstream_unavailable | internal_error
    "code": "no_healthy_model",
    "param": null
  }
}

OpenAI/Anthropic SDK парсят такой конверт нативно. Сопоставление статусов:

Тип HTTP retriable
invalid_request_error 400 нет
authentication_error 401 нет
rate_limit_error 429 да
upstream_unavailable 502/503 да
internal_error 500 да

8.6 Учёт стоимости

usage отдаётся в формате протокола (OpenAI/Anthropic). Дополнительно — стоимость в заголовке X-Cortiq-Cost-Usd и в cortiq.cost_usd (если включено cortiq.echo). Цена считается из price_in/price_out модели в конфиге × токены.