Files
cortiq-gateway/docs/PROTOCOLS.ru.md
T
infosave2007 9fd4bdaa85
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
fix(tools): never deliver a tool call twice, and make an unparsed one executable
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].
2026-08-21 18:49:19 +03:00

374 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
*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` модели в конфиге × токены.