Docker / publish (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
Qwen-family templates write a function call into the generated text as
<tool_call>{...}</tool_call>. Runtimes disagree on what to do with it: some
parse it into structured tool_calls AND still stream the raw markup as
content - the client then gets the same call twice, once as prose it must not
show and once as the real thing - while others never parse it at all, so a
perfectly good call arrives as text no tool loop will run.
Settle it in the gateway. A streaming-aware filter strips the markup from
content (handling a marker split across SSE chunks, leaving ordinary text
containing "<" alone, and handing an unterminated block back as text), and
when nothing structured came with it the markup is promoted into a real
tool_calls entry with a generated call_... id. Markup that is not a JSON
object with a name is ignored so a stray marker cannot become a bogus call.
Streamed tool_calls are also brought to the OpenAI shape: index on every
entry, id and type exactly once per call so continuation deltas carrying only
an argument fragment stay legal, and function.arguments always a string. A
reply that carries tool calls now always ends with finish_reason "tool_calls",
whatever the upstream called it. The same cleanup runs on the non-streaming
path, so every protocol adapter benefits.
Verified end to end: Open WebUI 0.11.0 -> gateway -> managed CMF/Qwen with
native function calling. The model returns get_weather({"city":"Paris"}), Open
WebUI executes the tool, feeds the result back, and the assistant answers from
it - no markup in content, finish_reason tool_calls, stream closed with [DONE].
374 lines
17 KiB
Markdown
374 lines
17 KiB
Markdown
*Read this in [English](PROTOCOLS.md).*
|
||
|
||
# Протоколы Cortiq Gateway
|
||
|
||
Контракты входящих протоколов и кросс-стандартных механизмов. Каждый протокол
|
||
включается флагом в `[protocols]`. Внутри всё приводится к канонической модели
|
||
(см. [ARCHITECTURE.md](ARCHITECTURE.ru.md#3-каноническая-модель)).
|
||
|
||
Общее правило маршрутизации во всех протоколах:
|
||
|
||
- `model = "cortiq-auto"` → включить интеллектуальный роутинг через cortiq-router.
|
||
- `model = "cortiq-auto:quality-first"` → роутинг с указанным policy-профилем
|
||
(`cost-saver` | `balanced` | `quality-first`).
|
||
- `model = "<реальный id из конфига>"` → passthrough к этой модели без роутинга.
|
||
|
||
---
|
||
|
||
## Оглавление
|
||
|
||
1. [OpenAI Chat Completions](#1-openai-chat-completions)
|
||
2. [OpenAI Completions (legacy)](#2-openai-completions-legacy)
|
||
3. [OpenAI Embeddings](#3-openai-embeddings)
|
||
4. [OpenAI Models](#4-openai-models)
|
||
5. [Anthropic Messages](#5-anthropic-messages)
|
||
6. [MCP (Model Context Protocol)](#6-mcp-model-context-protocol)
|
||
7. [Native passthrough](#7-native-passthrough)
|
||
8. [Кросс-стандарты](#8-кросс-стандарты)
|
||
|
||
---
|
||
|
||
## 1. OpenAI Chat Completions
|
||
|
||
`POST /v1/chat/completions` — основной endpoint, де-факто стандарт для агентов.
|
||
|
||
### Запрос
|
||
|
||
```jsonc
|
||
{
|
||
"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`, если его некому выполнить.
|
||
|
||
### Ответ (без стриминга)
|
||
|
||
```jsonc
|
||
{
|
||
"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`). Клиент никогда не остаётся ждать недосказанный поток.
|
||
|
||
### 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"`.
|
||
|
||
```jsonc
|
||
// запрос
|
||
{ "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).
|
||
|
||
```jsonc
|
||
// запрос
|
||
{ "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`.
|
||
|
||
```jsonc
|
||
{
|
||
"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.
|
||
|
||
```jsonc
|
||
// запрос
|
||
{
|
||
"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`:
|
||
|
||
```jsonc
|
||
// запрос (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-совместимый)
|
||
|
||
```jsonc
|
||
{
|
||
"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` модели в конфиге × токены.
|