feat: universal LLM gateway with embedded multilingual admin console
- OpenAI-compatible gateway with intelligent routing via cortiq-router (allaigate) - Embedded build-free admin SPA at /admin: 7 languages (en/ru/de/fr/es/zh/tr), light/dark themes, hot config reload (no restart) - Admin API, per-request statistics + Prometheus /metrics, secret store, playground - English documentation (primary) with Russian translations (*.ru.md) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
*Read this in [Русский](ARCHITECTURE.ru.md).*
|
||||
|
||||
# Cortiq Gateway Architecture
|
||||
|
||||
This document describes the internal design of the gateway, request flow, abstractions, and
|
||||
roadmap. Related documents: [PROTOCOLS.md](PROTOCOLS.md) (protocol contracts),
|
||||
[ROUTING.md](ROUTING.md) (model selection and cost model).
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose and Scope
|
||||
|
||||
**Cortiq Gateway** is an L7 proxy that:
|
||||
|
||||
1. accepts requests in one of the **standard protocols** (OpenAI, Anthropic, MCP);
|
||||
2. asks **cortiq-router** what type of task it is and how complex;
|
||||
3. selects a specific model from the **provider pool** (local + cloud);
|
||||
4. calls the provider, **translating the protocol** when necessary;
|
||||
5. returns the response to the client (streaming and non-streaming), adding routing metadata;
|
||||
6. tracks cost and (optionally) closes the feedback loop back to the router.
|
||||
|
||||
What the gateway does **not** do: it does not store or train models (that is the router's
|
||||
job), and it does not implement the LLMs themselves (that is the providers' job). The gateway
|
||||
is thin, stateless (except for idempotency caches and rate-limit state), and horizontally
|
||||
scalable.
|
||||
|
||||
---
|
||||
|
||||
## 2. Component Diagram
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────┐
|
||||
│ Cortiq Gateway │
|
||||
│ │
|
||||
client (any protocol) │ ┌──────────────┐ ┌───────────────────┐ │
|
||||
───────────────────────▶│──▶│ Inbound │──▶│ Core pipeline │ │
|
||||
│ │ Protocol │ │ │ │
|
||||
│ │ Adapters │ │ 1. extract text │ │
|
||||
│ │ (openai/ │ │ 2. route() │────┼──▶ cortiq-router
|
||||
│ │ anthropic/ │ │ 3. select model │ │ /v1/route
|
||||
│ │ mcp) │ │ 4. translate │ │
|
||||
│ └──────────────┘ │ 5. call provider │────┼──▶ Provider pool
|
||||
│ │ 6. stream back │ │ (openai/anthropic/
|
||||
│ ┌──────────────┐ │ 7. usage/cost │◀───┼── ollama/http)
|
||||
│ │ Cross-cutting│ │ 8. feedback (opt)│────┼──▶ cortiq-router
|
||||
│ │ auth · rate │ └───────────────────┘ │ /v1/feedback
|
||||
│ │ idem · trace │ │
|
||||
│ └──────────────┘ │
|
||||
└───────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Code modules (`src/`):
|
||||
|
||||
| Module | Responsibility |
|
||||
|---|---|
|
||||
| `main.rs` | config loading, route registration for enabled protocols, server startup |
|
||||
| `config.rs` | TOML parsing: model pool, routing table, protocols, keys |
|
||||
| `router_client.rs` | client for `cortiq-router` (`/v1/route`, `/v1/feedback`) |
|
||||
| `registry.rs` | model pool registry + `id → provider+endpoint` resolution |
|
||||
| `routing.rs` | `(label, tier) → ordered model list`; fallback/default |
|
||||
| `pipeline.rs` | core: extract → route → select → translate → call → stream |
|
||||
| `providers/mod.rs` | `Provider` trait (unified LLM call interface) |
|
||||
| `providers/openai.rs` | OpenAI-compatible adapter (covers vLLM/llama.cpp/ollama/openrouter) |
|
||||
| `providers/anthropic.rs` | Anthropic Messages adapter |
|
||||
| `protocols/mod.rs` | inbound adapter trait + shared types for the canonical message model |
|
||||
| `protocols/openai_chat.rs` | `/v1/chat/completions` ↔ canonical model |
|
||||
| `protocols/anthropic_messages.rs` | `/v1/messages` ↔ canonical model |
|
||||
| `protocols/mcp.rs` | MCP server (JSON-RPC), routing exposed as a tool |
|
||||
| `auth.rs` | virtual keys, rate limiting, quotas |
|
||||
| `telemetry.rs` | Prometheus metrics, OpenTelemetry spans, request IDs |
|
||||
| `error.rs` | unified error type + rendering into OpenAI error envelope |
|
||||
|
||||
---
|
||||
|
||||
## 3. Canonical Model (internal neutral format)
|
||||
|
||||
To avoid N incoming protocols × M providers turning into N×M translation paths,
|
||||
everything is normalized internally to a **single canonical request/response model**.
|
||||
|
||||
```
|
||||
ChatRequest {
|
||||
routing: RoutingDirective, // auto | pinned(model_id) | profile
|
||||
messages: Vec<Message>, // role + content (+ tool_calls)
|
||||
tools: Vec<Tool>,
|
||||
params: GenParams, // temperature, max_tokens, top_p, stop, ...
|
||||
stream: bool,
|
||||
meta: RequestMeta, // idempotency key, traceparent, account
|
||||
}
|
||||
|
||||
ChatResponse {
|
||||
id, model_used, choices, usage, finish_reason, cortiq: RouteInfo
|
||||
}
|
||||
```
|
||||
|
||||
- Inbound adapter: `protocol-specific → ChatRequest` and `ChatResponse → protocol-specific`.
|
||||
- Outbound provider: `ChatRequest → provider wire` and `provider wire → ChatResponse`.
|
||||
|
||||
Adding a new protocol = one inbound adapter. Adding a new provider = one outbound adapter.
|
||||
No combinatorial explosion.
|
||||
|
||||
---
|
||||
|
||||
## 4. Extension Traits
|
||||
|
||||
```rust
|
||||
// inbound protocol
|
||||
#[async_trait]
|
||||
trait InboundProtocol {
|
||||
fn routes(&self) -> Router; // axum routes for this protocol
|
||||
fn name(&self) -> &'static str; // "openai_chat", ...
|
||||
}
|
||||
|
||||
// outbound provider
|
||||
#[async_trait]
|
||||
trait Provider: Send + Sync {
|
||||
fn id(&self) -> &str;
|
||||
async fn chat(&self, req: ChatRequest) -> Result<ChatResponse>;
|
||||
async fn chat_stream(&self, req: ChatRequest) -> Result<ChatStream>; // SSE stream
|
||||
async fn embed(&self, req: EmbedRequest) -> Result<EmbedResponse>;
|
||||
fn capabilities(&self) -> Caps; // tools? streaming? vision?
|
||||
}
|
||||
```
|
||||
|
||||
The registry (`registry.rs`) holds a `HashMap<model_id, Arc<dyn Provider>>` built
|
||||
from `[[models]]`. The routing table (`routing.rs`) operates on `model_id`.
|
||||
|
||||
---
|
||||
|
||||
## 5. `route → select → call` Flow in Detail
|
||||
|
||||
1. **Extract.** Text for classification is taken from `messages`. Strategy from config:
|
||||
`last_user` (default) | `concat_all` | `last_user_plus_system`. Long text is
|
||||
truncated to `route.max_chars` (protection against cost/DoS).
|
||||
2. **Route.** `router_client.route(text, profile)` → `{task_label, complexity}`.
|
||||
Cached by text hash for `route.cache_ttl` (minor savings on repeated requests).
|
||||
3. **Select.** `routing.select(label, tier)` → ordered `Vec<model_id>`.
|
||||
Considers: model health, model `capabilities` (e.g. whether tools are needed),
|
||||
and the cost policy (see [ROUTING.md](ROUTING.md)).
|
||||
4. **Translate + Call.** Canonical `ChatRequest` → provider. Streaming is proxied
|
||||
chunk-by-chunk (SSE → SSE) without buffering.
|
||||
5. **Failover.** If a provider returns 5xx / timeout / overloaded — the next model
|
||||
in the list is tried. The circuit breaker marks an unhealthy model to avoid
|
||||
hammering it.
|
||||
6. **Degrade.** If cortiq-router is unavailable, `routing.default` is used (soft
|
||||
degradation — the request does not fail).
|
||||
|
||||
---
|
||||
|
||||
## 6. Reliability
|
||||
|
||||
| Mechanism | Behavior |
|
||||
|---|---|
|
||||
| Timeouts | separate timeouts for router call, provider connect, and provider stream |
|
||||
| Failover | ordered model list within the tier |
|
||||
| Circuit breaker | temporarily excludes a model after repeated errors |
|
||||
| Router down | `routing.default` |
|
||||
| All tier providers down | OpenAI error `503 upstream_unavailable`, `retriable=true` |
|
||||
| Idempotency | repeat with the same `Idempotency-Key` returns the stored response |
|
||||
|
||||
---
|
||||
|
||||
## 7. Security and Multi-tenancy
|
||||
|
||||
- **Key isolation.** Agents hold only a virtual gateway key; provider keys
|
||||
(`*_API_KEY`) live in the gateway environment and are never exposed externally.
|
||||
- **Accounts.** Each `[[api_keys]]` entry maps to an account with its own rate limit,
|
||||
quota, and optionally its own allowed-model subset and routing table.
|
||||
- **PII.** Redaction is performed in the router before escalation to the oracle; the
|
||||
gateway can additionally enable logging without request bodies (`log.bodies = false`
|
||||
by default).
|
||||
- **Audit.** Every request records: `request_id`, account, selected model, tokens, cost,
|
||||
and decision path — without the prompt text by default.
|
||||
|
||||
---
|
||||
|
||||
## 8. Observability
|
||||
|
||||
`GET /metrics` (Prometheus), `GET /healthz`, `GET /readyz`. Metrics:
|
||||
|
||||
```
|
||||
gw_requests_total{protocol,account}
|
||||
gw_route_calls_total # calls to cortiq-router
|
||||
gw_model_selected_total{model_id,tier}
|
||||
gw_provider_calls_total{provider,model_id,outcome}
|
||||
gw_failovers_total
|
||||
gw_tokens_total{model_id,direction}
|
||||
gw_cost_usd_total{account,model_id}
|
||||
gw_latency_seconds{stage} # route | provider | total (histogram)
|
||||
```
|
||||
|
||||
OpenTelemetry spans: `inbound → route → provider`, with `traceparent` forwarding.
|
||||
|
||||
---
|
||||
|
||||
## 9. Roadmap
|
||||
|
||||
| Phase | Contents |
|
||||
|---|---|
|
||||
| **v0.1** | OpenAI Chat/Completions/Embeddings/Models, `openai` provider, routing + fallback, auth, metrics, streaming |
|
||||
| **v0.2** | `anthropic` provider + inbound Anthropic Messages, cost-aware selection, idempotency, circuit breaker |
|
||||
| **v0.3** | MCP server, native `ollama` provider, OpenTelemetry, routing cache |
|
||||
| **v0.4** | Per-account routing tables, feedback loop to router, load tests, Helm chart |
|
||||
| **v1.0** | Contract stabilization, OpenAPI spec, official examples for crewAI/LangChain/Hermes |
|
||||
|
||||
Current phase: **Phase 0** (skeleton + specification in this repository).
|
||||
@@ -0,0 +1,202 @@
|
||||
*Read this in [English](ARCHITECTURE.md).*
|
||||
|
||||
# Архитектура Cortiq Gateway
|
||||
|
||||
Документ описывает внутреннее устройство шлюза, поток запроса, абстракции и
|
||||
дорожную карту. Сопутствующие документы: [PROTOCOLS.md](PROTOCOLS.ru.md) (контракты
|
||||
протоколов), [ROUTING.md](ROUTING.ru.md) (модель выбора моделей и стоимости).
|
||||
|
||||
---
|
||||
|
||||
## 1. Назначение и границы
|
||||
|
||||
**Cortiq Gateway** — это L7-прокси, который:
|
||||
|
||||
1. принимает запрос в одном из **стандартных протоколов** (OpenAI, Anthropic, MCP);
|
||||
2. спрашивает **cortiq-router**, какой это тип задачи и насколько он сложный;
|
||||
3. выбирает конкретную модель из **пула провайдеров** (локальные + облачные);
|
||||
4. вызывает провайдера, **транслируя протокол** при необходимости;
|
||||
5. возвращает ответ клиенту (стриминг и без), добавляя метаданные роутинга;
|
||||
6. учитывает стоимость и (опц.) замыкает петлю обратной связи в роутер.
|
||||
|
||||
Что шлюз **не** делает: не хранит и не обучает модели (это роутер), не реализует
|
||||
сами LLM (это провайдеры). Шлюз — тонкий, без состояния (кроме кэшей идемпотентности
|
||||
и rate-limit), горизонтально масштабируется.
|
||||
|
||||
---
|
||||
|
||||
## 2. Компонентная схема
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────┐
|
||||
│ Cortiq Gateway │
|
||||
│ │
|
||||
client (any protocol) │ ┌──────────────┐ ┌───────────────────┐ │
|
||||
───────────────────────▶│──▶│ Inbound │──▶│ Core pipeline │ │
|
||||
│ │ Protocol │ │ │ │
|
||||
│ │ Adapters │ │ 1. extract text │ │
|
||||
│ │ (openai/ │ │ 2. route() │────┼──▶ cortiq-router
|
||||
│ │ anthropic/ │ │ 3. select model │ │ /v1/route
|
||||
│ │ mcp) │ │ 4. translate │ │
|
||||
│ └──────────────┘ │ 5. call provider │────┼──▶ Provider pool
|
||||
│ │ 6. stream back │ │ (openai/anthropic/
|
||||
│ ┌──────────────┐ │ 7. usage/cost │◀───┼── ollama/http)
|
||||
│ │ Cross-cutting│ │ 8. feedback (opt)│────┼──▶ cortiq-router
|
||||
│ │ auth · rate │ └───────────────────┘ │ /v1/feedback
|
||||
│ │ idem · trace │ │
|
||||
│ └──────────────┘ │
|
||||
└───────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Модули кода (`src/`):
|
||||
|
||||
| Модуль | Ответственность |
|
||||
|---|---|
|
||||
| `main.rs` | загрузка конфига, сборка роутов по включённым протоколам, запуск сервера |
|
||||
| `config.rs` | парсинг TOML: пул моделей, таблица роутинга, протоколы, ключи |
|
||||
| `router_client.rs` | клиент к `cortiq-router` (`/v1/route`, `/v1/feedback`) |
|
||||
| `registry.rs` | реестр моделей пула + резолв `id → провайдер+endpoint` |
|
||||
| `routing.rs` | `(label, tier) → упорядоченный список моделей`; fallback/default |
|
||||
| `pipeline.rs` | ядро: extract → route → select → translate → call → stream |
|
||||
| `providers/mod.rs` | трейт `Provider` (унифицированный вызов LLM) |
|
||||
| `providers/openai.rs` | OpenAI-совместимый адаптер (covers vLLM/llama.cpp/ollama/openrouter) |
|
||||
| `providers/anthropic.rs` | адаптер Anthropic Messages |
|
||||
| `protocols/mod.rs` | трейт входящего адаптера + общие типы канонической модели сообщения |
|
||||
| `protocols/openai_chat.rs` | вход `/v1/chat/completions` ↔ каноническая модель |
|
||||
| `protocols/anthropic_messages.rs` | вход `/v1/messages` ↔ каноническая модель |
|
||||
| `protocols/mcp.rs` | MCP-сервер (JSON-RPC), маршрутизация как инструмент |
|
||||
| `auth.rs` | виртуальные ключи, rate-limit, квоты |
|
||||
| `telemetry.rs` | метрики Prometheus, OpenTelemetry-спаны, request-id |
|
||||
| `error.rs` | единый тип ошибки + рендер в OpenAI-конверт |
|
||||
|
||||
---
|
||||
|
||||
## 3. Каноническая модель (внутренний нейтральный формат)
|
||||
|
||||
Чтобы N входящих протоколов × M провайдеров не превратились в N×M переводов,
|
||||
внутри всё приводится к **одной канонической модели** запроса/ответа.
|
||||
|
||||
```
|
||||
ChatRequest {
|
||||
routing: RoutingDirective, // auto | pinned(model_id) | profile
|
||||
messages: Vec<Message>, // role + content (+ tool_calls)
|
||||
tools: Vec<Tool>,
|
||||
params: GenParams, // temperature, max_tokens, top_p, stop, ...
|
||||
stream: bool,
|
||||
meta: RequestMeta, // idempotency key, traceparent, account
|
||||
}
|
||||
|
||||
ChatResponse {
|
||||
id, model_used, choices, usage, finish_reason, cortiq: RouteInfo
|
||||
}
|
||||
```
|
||||
|
||||
- Входящий адаптер: `protocol-specific → ChatRequest` и `ChatResponse → protocol-specific`.
|
||||
- Исходящий провайдер: `ChatRequest → provider wire` и `provider wire → ChatResponse`.
|
||||
|
||||
Добавить новый протокол = один входящий адаптер. Добавить нового провайдера =
|
||||
один исходящий адаптер. Никакого комбинаторного взрыва.
|
||||
|
||||
---
|
||||
|
||||
## 4. Трейты-расширения
|
||||
|
||||
```rust
|
||||
// входящий протокол
|
||||
#[async_trait]
|
||||
trait InboundProtocol {
|
||||
fn routes(&self) -> Router; // axum-роуты этого протокола
|
||||
fn name(&self) -> &'static str; // "openai_chat", ...
|
||||
}
|
||||
|
||||
// исходящий провайдер
|
||||
#[async_trait]
|
||||
trait Provider: Send + Sync {
|
||||
fn id(&self) -> &str;
|
||||
async fn chat(&self, req: ChatRequest) -> Result<ChatResponse>;
|
||||
async fn chat_stream(&self, req: ChatRequest) -> Result<ChatStream>; // SSE-поток
|
||||
async fn embed(&self, req: EmbedRequest) -> Result<EmbedResponse>;
|
||||
fn capabilities(&self) -> Caps; // tools? streaming? vision?
|
||||
}
|
||||
```
|
||||
|
||||
Реестр (`registry.rs`) держит `HashMap<model_id, Arc<dyn Provider>>`, построенный
|
||||
из `[[models]]`. Таблица роутинга (`routing.rs`) оперирует `model_id`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Поток `route → select → call` детально
|
||||
|
||||
1. **Extract.** Из `messages` берётся текст для классификации. Стратегия из конфига:
|
||||
`last_user` (по умолчанию) | `concat_all` | `last_user_plus_system`. Длинный
|
||||
текст усечётся до `route.max_chars` (защита от стоимости/DoS).
|
||||
2. **Route.** `router_client.route(text, profile)` → `{task_label, complexity}`.
|
||||
Кэшируется по хэшу текста на `route.cache_ttl` (мелкая экономия на повторах).
|
||||
3. **Select.** `routing.select(label, tier)` → упорядоченный `Vec<model_id>`.
|
||||
Учитываются: доступность модели (health), её `capabilities` (нужны ли tools),
|
||||
и cost-политика (см. [ROUTING.md](ROUTING.ru.md)).
|
||||
4. **Translate + Call.** Канонический `ChatRequest` → провайдер. Стриминг проксируется
|
||||
чанк-в-чанк (SSE → SSE) без буферизации.
|
||||
5. **Failover.** Если провайдер вернул 5xx/таймаут/перегрузку — следующий из списка.
|
||||
Circuit breaker помечает «больную» модель, чтобы не долбить её.
|
||||
6. **Degrade.** Если cortiq-router недоступен — берётся `routing.default` (мягкая
|
||||
деградация, запрос не падает).
|
||||
|
||||
---
|
||||
|
||||
## 6. Надёжность
|
||||
|
||||
| Механизм | Поведение |
|
||||
|---|---|
|
||||
| Таймауты | отдельные на router-вызов, connect и стрим провайдера |
|
||||
| Failover | по упорядоченному списку моделей полосы |
|
||||
| Circuit breaker | временно исключает модель после серии ошибок |
|
||||
| Router down | `routing.default` |
|
||||
| Все провайдеры полосы down | OpenAI-ошибка `503 upstream_unavailable`, `retriable=true` |
|
||||
| Идемпотентность | повтор с тем же `Idempotency-Key` отдаёт сохранённый ответ |
|
||||
|
||||
---
|
||||
|
||||
## 7. Безопасность и мультиарендность
|
||||
|
||||
- **Изоляция ключей.** Агенты держат только виртуальный ключ шлюза; ключи провайдеров
|
||||
(`*_API_KEY`) живут в окружении шлюза и наружу не уходят.
|
||||
- **Аккаунты.** Каждый `[[api_keys]]` → аккаунт с rate-limit, квотой, (опц.) своим
|
||||
подмножеством разрешённых моделей и своей таблицей роутинга.
|
||||
- **PII.** Редакция выполняется в роутере перед эскалацией к оракулу; шлюз дополнительно
|
||||
может включить логирование без тел сообщений (`log.bodies = false` по умолчанию).
|
||||
- **Аудит.** Каждый запрос: `request_id`, аккаунт, выбранная модель, токены, стоимость,
|
||||
путь решения — без текста промпта по умолчанию.
|
||||
|
||||
---
|
||||
|
||||
## 8. Наблюдаемость
|
||||
|
||||
`GET /metrics` (Prometheus), `GET /healthz`, `GET /readyz`. Метрики:
|
||||
|
||||
```
|
||||
gw_requests_total{protocol,account}
|
||||
gw_route_calls_total # обращения к cortiq-router
|
||||
gw_model_selected_total{model_id,tier}
|
||||
gw_provider_calls_total{provider,model_id,outcome}
|
||||
gw_failovers_total
|
||||
gw_tokens_total{model_id,direction}
|
||||
gw_cost_usd_total{account,model_id}
|
||||
gw_latency_seconds{stage} # route | provider | total (histogram)
|
||||
```
|
||||
|
||||
OpenTelemetry-спаны: `inbound → route → provider`, с пробросом `traceparent`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Дорожная карта
|
||||
|
||||
| Фаза | Содержимое |
|
||||
|---|---|
|
||||
| **v0.1** | OpenAI Chat/Completions/Embeddings/Models, провайдер `openai`, роутинг + fallback, auth, метрики, стриминг |
|
||||
| **v0.2** | Провайдер `anthropic` + входящий Anthropic Messages, cost-aware выбор, идемпотентность, circuit breaker |
|
||||
| **v0.3** | MCP-сервер, провайдер `ollama` нативный, OpenTelemetry, кэш роутинга |
|
||||
| **v0.4** | Per-account таблицы роутинга, feedback-loop в роутер, нагрузочные тесты, Helm-чарт |
|
||||
| **v1.0** | Стабилизация контракта, OpenAPI-спека, официальные примеры под crewAI/LangChain/Hermes |
|
||||
|
||||
Текущая фаза — **Phase 0** (скелет + спецификация в этом репозитории).
|
||||
@@ -0,0 +1,324 @@
|
||||
*Read this in [Русский](PROTOCOLS.ru.md).*
|
||||
|
||||
# Cortiq Gateway Protocols
|
||||
|
||||
Contracts for inbound protocols and cross-standard mechanisms. Each protocol is enabled
|
||||
by a flag in `[protocols]`. Internally, all requests are normalized to the canonical model
|
||||
(see [ARCHITECTURE.md](ARCHITECTURE.md#3-canonical-model-internal-neutral-format)).
|
||||
|
||||
Routing rule common to all protocols:
|
||||
|
||||
- `model = "cortiq-auto"` → enable intelligent routing via cortiq-router.
|
||||
- `model = "cortiq-auto:quality-first"` → routing with the specified policy profile
|
||||
(`cost-saver` | `balanced` | `quality-first`).
|
||||
- `model = "<real id from config>"` → passthrough to that model without routing.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
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. [Cross-standard mechanisms](#8-cross-standard-mechanisms)
|
||||
|
||||
---
|
||||
|
||||
## 1. OpenAI Chat Completions
|
||||
|
||||
`POST /v1/chat/completions` — the primary endpoint; the de facto standard for agents.
|
||||
|
||||
### Request
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"model": "cortiq-auto", // routing; or a real id for 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, proxied to the provider as-is */ ],
|
||||
"tool_choice": "auto",
|
||||
"stop": ["\n\n"],
|
||||
"top_p": 1.0,
|
||||
"response_format": { "type": "json_object" }
|
||||
}
|
||||
```
|
||||
|
||||
Standard OpenAI fields are supported; unknown fields are proxied to the provider unchanged
|
||||
(forward-compatible).
|
||||
|
||||
### Response (non-streaming)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"id": "chatcmpl-9f...",
|
||||
"object": "chat.completion",
|
||||
"created": 1718800000,
|
||||
"model": "local-qwen", // the ACTUALLY selected model
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": { "role": "assistant", "content": "x = 2 or x = 3" },
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 24,
|
||||
"completion_tokens": 11,
|
||||
"total_tokens": 35
|
||||
},
|
||||
"cortiq": { // extension (enabled with 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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> The `cortiq` field is an optional extension. OpenAI SDKs ignore it. If the client does
|
||||
> not want extra fields, disable `cortiq.echo` — the metadata will remain in the response
|
||||
> headers.
|
||||
|
||||
### Metadata in response headers (always present)
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
### Response (streaming, `stream: true`)
|
||||
|
||||
SSE in OpenAI format: a series of `chat.completion.chunk` events, terminated by
|
||||
`data: [DONE]`. The gateway proxies provider chunks one-to-one (without buffering),
|
||||
adding routing metadata to HTTP headers before the stream begins.
|
||||
|
||||
```
|
||||
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` — for legacy integrations. Internally, `prompt` is wrapped into a
|
||||
single user message and follows the same pipeline. Response `object` is `"text_completion"`.
|
||||
|
||||
```jsonc
|
||||
// request
|
||||
{ "model": "cortiq-auto", "prompt": "Translate to French: hello", "max_tokens": 64 }
|
||||
// response
|
||||
{ "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`. Two modes (configurable):
|
||||
|
||||
- `provider` — proxy to an embedding model from the pool;
|
||||
- `router` — return an embedding from the **cortiq-router encoder** (the same vector the
|
||||
router uses for classification — useful for consistent RAG).
|
||||
|
||||
```jsonc
|
||||
// request
|
||||
{ "model": "cortiq-embed", "input": ["first text", "second text"] }
|
||||
// response
|
||||
{
|
||||
"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` — auto-discovery for clients. Returns the gateway's virtual models
|
||||
(`cortiq-auto`, `cortiq-embed`) and all real models in the pool.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{ "id": "cortiq-auto", "object": "model", "owned_by": "cortiq-gateway" },
|
||||
{ "id": "local-qwen", "object": "model", "owned_by": "cortiq-gateway" },
|
||||
{ "id": "gpt-4o-mini", "object": "model", "owned_by": "cortiq-gateway" },
|
||||
{ "id": "claude-opus", "object": "model", "owned_by": "cortiq-gateway" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Anthropic Messages
|
||||
|
||||
`POST /v1/messages` — for Claude-native agents and the Anthropic SDK.
|
||||
|
||||
```jsonc
|
||||
// request
|
||||
{
|
||||
"model": "cortiq-auto",
|
||||
"max_tokens": 1024,
|
||||
"system": "You are a helpful assistant.",
|
||||
"messages": [ { "role": "user", "content": "Solve x^2 - 5x + 6 = 0" } ],
|
||||
"stream": false
|
||||
}
|
||||
// response
|
||||
{
|
||||
"id": "msg_...",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "local-qwen",
|
||||
"content": [ { "type": "text", "text": "x = 2 or x = 3" } ],
|
||||
"stop_reason": "end_turn",
|
||||
"usage": { "input_tokens": 24, "output_tokens": 11 }
|
||||
}
|
||||
```
|
||||
|
||||
Streaming uses native Anthropic events (`message_start`, `content_block_delta`,
|
||||
`message_delta`, `message_stop`). If the selected model is a non-Anthropic provider,
|
||||
the gateway translates through the canonical model in both directions.
|
||||
|
||||
---
|
||||
|
||||
## 6. MCP (Model Context Protocol)
|
||||
|
||||
The gateway runs an **MCP server** (JSON-RPC 2.0 over HTTP+SSE or stdio) and exposes
|
||||
routing as tools for MCP-native orchestrators.
|
||||
|
||||
Published tools:
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `route_and_complete` | classify + execute on the selected model, return the response |
|
||||
| `classify_task` | classification only (task type + complexity), no execution |
|
||||
|
||||
`tools/list` returns JSON schemas. Example `tools/call` invocation:
|
||||
|
||||
```jsonc
|
||||
// request (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" }
|
||||
}
|
||||
}
|
||||
// response
|
||||
{
|
||||
"jsonrpc": "2.0", "id": 1,
|
||||
"result": {
|
||||
"content": [ { "type": "text", "text": "x = 2 or x = 3" } ],
|
||||
"isError": false,
|
||||
"_meta": { "task_label": "math", "tier": "low", "selected_model": "local-qwen" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Native passthrough
|
||||
|
||||
`POST /route` — a thin wrapper over `cortiq-router /v1/route` for clients that need only
|
||||
the **routing decision** (without execution). The request body and response match the router
|
||||
contract (see `cortiq-router/docs/ФОРМАТ_ЗАПРОСА_И_ОТВЕТА.md`). Useful for debugging and
|
||||
for integrators who want to manage the LLM call themselves.
|
||||
|
||||
---
|
||||
|
||||
## 8. Cross-standard mechanisms
|
||||
|
||||
Common to all HTTP protocols.
|
||||
|
||||
### 8.1 Authentication
|
||||
|
||||
```
|
||||
Authorization: Bearer sk-gw-...
|
||||
```
|
||||
Virtual gateway key. Mapped to an account (`[[api_keys]]`). If no keys are configured,
|
||||
the gateway runs in open mode (for local development). Provider keys are never exposed.
|
||||
|
||||
### 8.2 Idempotency
|
||||
|
||||
```
|
||||
Idempotency-Key: <unique-operation-id>
|
||||
```
|
||||
A repeat request with the same key within the `idempotency.ttl` window returns the
|
||||
**stored** response (safe agent retries, without double-invoking the LLM or double billing).
|
||||
|
||||
### 8.3 Rate limiting
|
||||
|
||||
On `429`, the following headers are returned:
|
||||
|
||||
```
|
||||
Retry-After: 2
|
||||
X-RateLimit-Limit: 600
|
||||
X-RateLimit-Remaining: 0
|
||||
X-RateLimit-Reset: 1718800060
|
||||
```
|
||||
|
||||
### 8.4 Tracing
|
||||
|
||||
W3C `traceparent` is accepted and forwarded; OpenTelemetry spans cover
|
||||
`inbound→route→provider`. The response includes `X-Request-Id`. This enables an
|
||||
end-to-end trace from agent → gateway → provider.
|
||||
|
||||
### 8.5 Error format (OpenAI-compatible)
|
||||
|
||||
```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 SDKs parse this envelope natively. Status code mapping:
|
||||
|
||||
| Type | HTTP | retriable |
|
||||
|---|---|---|
|
||||
| `invalid_request_error` | 400 | no |
|
||||
| `authentication_error` | 401 | no |
|
||||
| `rate_limit_error` | 429 | yes |
|
||||
| `upstream_unavailable` | 502/503 | yes |
|
||||
| `internal_error` | 500 | yes |
|
||||
|
||||
### 8.6 Cost tracking
|
||||
|
||||
`usage` is returned in the protocol format (OpenAI/Anthropic). Additionally, cost is
|
||||
included in the `X-Cortiq-Cost-Usd` header and in `cortiq.cost_usd` (if `cortiq.echo`
|
||||
is enabled). The price is calculated from the model's `price_in`/`price_out` in the config
|
||||
multiplied by the token counts.
|
||||
@@ -0,0 +1,321 @@
|
||||
*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).
|
||||
|
||||
### Ответ (без стриминга)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"id": "chatcmpl-9f...",
|
||||
"object": "chat.completion",
|
||||
"created": 1718800000,
|
||||
"model": "local-qwen", // РЕАЛЬНО выбранная модель
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": { "role": "assistant", "content": "x = 2 или x = 3" },
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
],
|
||||
"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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Поле `cortiq` — необязательное расширение. OpenAI-SDK его игнорируют. Если клиент
|
||||
> не хочет «лишних» полей — выключите `cortiq.echo`, метаданные останутся в заголовках.
|
||||
|
||||
### Метаданные в заголовках (всегда)
|
||||
|
||||
```
|
||||
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`) и все реальные модели пула.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{ "id": "cortiq-auto", "object": "model", "owned_by": "cortiq-gateway" },
|
||||
{ "id": "local-qwen", "object": "model", "owned_by": "cortiq-gateway" },
|
||||
{ "id": "gpt-4o-mini", "object": "model", "owned_by": "cortiq-gateway" },
|
||||
{ "id": "claude-opus", "object": "model", "owned_by": "cortiq-gateway" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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` модели в конфиге × токены.
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
*Read this in [Русский](ROUTING.ru.md).*
|
||||
|
||||
# Routing and Cost
|
||||
|
||||
How Cortiq Gateway translates a router decision (`task_label` + `complexity.tier`) into
|
||||
selection of a specific model from your pool — and how to make this **cost-aware**:
|
||||
cheap tasks stay local, expensive ones go to the cloud.
|
||||
|
||||
---
|
||||
|
||||
## 1. Base model: complexity tier → model pool
|
||||
|
||||
cortiq-router returns a complexity tier (`low` / `medium` / `high`, configurable in the
|
||||
router). The `[routing]` table maps each tier to an **ordered list** of models — which
|
||||
serves simultaneously as a priority order and a fallback chain.
|
||||
|
||||
```toml
|
||||
[routing]
|
||||
low = ["local-qwen"] # simple → local only (free)
|
||||
medium = ["gpt-4o-mini", "local-qwen"] # medium → cheap hosted, fall back to local
|
||||
high = ["claude-opus", "gpt-4o-mini"] # complex → expensive model, fall back to mid
|
||||
default = "gpt-4o-mini" # if the router is unavailable
|
||||
```
|
||||
|
||||
Selection algorithm:
|
||||
|
||||
```
|
||||
1. tier := router.complexity.tier (or the default tier if router is unavailable)
|
||||
2. candidates := routing[tier] (ordered list of model_id)
|
||||
3. for each model_id in order:
|
||||
if the model is healthy AND supports the required capabilities (tools/vision):
|
||||
select it → done
|
||||
4. if all are unavailable: try the next tier down/up per policy, or return error 503
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Universal pool: cheap local + expensive hosted
|
||||
|
||||
The primary developer scenario: mix a **free local** model with a **paid cloud** API,
|
||||
and let the gateway decide which one to use.
|
||||
|
||||
```toml
|
||||
# Local model on vLLM / llama.cpp / Ollama (OpenAI-compatible endpoint)
|
||||
[[models]]
|
||||
id = "local-qwen"
|
||||
provider = "openai"
|
||||
base_url = "http://localhost:8000/v1"
|
||||
model = "qwen2.5-7b-instruct"
|
||||
cost_tier = "cheap"
|
||||
price_in = 0.0 # local → 0
|
||||
price_out = 0.0
|
||||
caps = ["tools"] # capabilities (checked during selection)
|
||||
|
||||
# Cheap cloud
|
||||
[[models]]
|
||||
id = "gpt-4o-mini"
|
||||
provider = "openai"
|
||||
base_url = "https://api.openai.com/v1"
|
||||
model = "gpt-4o-mini"
|
||||
api_key_env = "OPENAI_API_KEY"
|
||||
cost_tier = "mid"
|
||||
price_in = 0.15 # $/1M input tokens
|
||||
price_out = 0.60
|
||||
|
||||
# Expensive cloud for complex tasks
|
||||
[[models]]
|
||||
id = "claude-opus"
|
||||
provider = "anthropic"
|
||||
base_url = "https://api.anthropic.com"
|
||||
model = "claude-opus-4"
|
||||
api_key_env = "ANTHROPIC_API_KEY"
|
||||
cost_tier = "expensive"
|
||||
price_in = 15.0
|
||||
price_out = 75.0
|
||||
caps = ["tools", "vision"]
|
||||
```
|
||||
|
||||
Because both `local-qwen` and `gpt-4o-mini` use `provider = "openai"`, the gateway does
|
||||
not care where the model is physically located — **local servers already speak the OpenAI
|
||||
API**. This is what makes the proxy truly universal.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cost-aware mode (optional)
|
||||
|
||||
Instead of a fixed table, you can enable selection by **budget/price** (v0.2):
|
||||
|
||||
```toml
|
||||
[routing.policy]
|
||||
mode = "cost_aware" # fixed_table | cost_aware
|
||||
# in cost_aware mode the gateway picks the cheapest model for each tier
|
||||
# that meets the minimum quality class for that tier:
|
||||
min_class = { low = "cheap", medium = "mid", high = "expensive" }
|
||||
# upper price bound per request (guard against expensive calls)
|
||||
max_cost_usd_per_request = 0.50
|
||||
```
|
||||
|
||||
Logic: among models that are at least `min_class[tier]` and within the price cap, the
|
||||
cheapest available model is selected. This yields the "minimally sufficient" model for
|
||||
the task complexity.
|
||||
|
||||
---
|
||||
|
||||
## 4. Pinning and passthrough
|
||||
|
||||
- **Passthrough.** `model = "local-qwen"` (a real id) — no router call; direct invocation.
|
||||
Useful when the client already knows what it wants.
|
||||
- **Profile pinning.** `model = "cortiq-auto:cost-saver"` — routing with a specific router
|
||||
policy profile (escalates less often → cheaper).
|
||||
- **Router version pinning** (v0.4). To prevent routing behavior from drifting due to
|
||||
router self-learning, you can pin `model_version` — the agent gets reproducible routing.
|
||||
|
||||
---
|
||||
|
||||
## 5. Text extraction for routing (multi-turn)
|
||||
|
||||
An agent request is not a single string but a system prompt + history + tools. What to
|
||||
send to the router for classification is configurable:
|
||||
|
||||
```toml
|
||||
[route]
|
||||
text_strategy = "last_user" # last_user | concat_all | last_user_plus_system
|
||||
max_chars = 4000 # truncate long context (cost/DoS protection)
|
||||
cache_ttl = "60s" # cache routing decisions by text hash
|
||||
profile = "balanced" # default policy profile
|
||||
```
|
||||
|
||||
| Strategy | What is classified | When to use |
|
||||
|---|---|---|
|
||||
| `last_user` | last user message | default; cheap and accurate for most agents |
|
||||
| `last_user_plus_system` | system prompt + last user message | when the role is set by the system prompt |
|
||||
| `concat_all` | full conversation (truncated) | multi-turn with meaning spread across history |
|
||||
|
||||
---
|
||||
|
||||
## 6. Failover and circuit breaker
|
||||
|
||||
- **Failover.** On 5xx / timeout / `overloaded` from a provider — try the next model in
|
||||
the list.
|
||||
- **Circuit breaker.** After `breaker.threshold` consecutive errors, the model is marked
|
||||
"open" for `breaker.cooldown`; requests skip it. Half-open: one probe request after the
|
||||
cooldown expires.
|
||||
- **Router down.** `routing.default` is used; the request does not fail (soft degradation).
|
||||
|
||||
```toml
|
||||
[breaker]
|
||||
threshold = 5
|
||||
cooldown = "30s"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Feedback loop (optional, v0.4)
|
||||
|
||||
When enabled, the gateway can send a `/v1/feedback` signal to the router after a successful
|
||||
completion (e.g. based on the result of agent-side response validation) — the router will
|
||||
fine-tune the task mask. Over time this makes routing fit your specific pool more precisely.
|
||||
|
||||
```toml
|
||||
[feedback]
|
||||
enabled = false
|
||||
# the ground-truth source is set by the integrator (e.g. the X-Cortiq-Correct-Label header)
|
||||
```
|
||||
@@ -0,0 +1,162 @@
|
||||
*Read this in [English](ROUTING.md).*
|
||||
|
||||
# Маршрутизация и стоимость
|
||||
|
||||
Как Cortiq Gateway превращает решение роутера (`task_label` + `complexity.tier`) в
|
||||
выбор конкретной модели из вашего пула — и как сделать это **cost-aware**:
|
||||
дешёвое локально, дорогое в облаке.
|
||||
|
||||
---
|
||||
|
||||
## 1. Базовая модель: полоса сложности → пул моделей
|
||||
|
||||
cortiq-router возвращает полосу сложности (`low` / `medium` / `high`, настраивается
|
||||
в роутере). Таблица `[routing]` сопоставляет полосе **упорядоченный список** моделей —
|
||||
это одновременно приоритет и цепочка fallback.
|
||||
|
||||
```toml
|
||||
[routing]
|
||||
low = ["local-qwen"] # простое → только локально (бесплатно)
|
||||
medium = ["gpt-4o-mini", "local-qwen"] # средне → дешёвый хост, откат на локальное
|
||||
high = ["claude-opus", "gpt-4o-mini"] # сложно → дорогая модель, откат на средн.
|
||||
default = "gpt-4o-mini" # если роутер недоступен
|
||||
```
|
||||
|
||||
Алгоритм выбора:
|
||||
|
||||
```
|
||||
1. tier := router.complexity.tier (или default-полоса, если роутер недоступен)
|
||||
2. candidates := routing[tier] (упорядоченный список model_id)
|
||||
3. для каждого model_id по порядку:
|
||||
если модель healthy И поддерживает нужные возможности (tools/vision):
|
||||
выбрать её → выход
|
||||
4. если все недоступны: следующая полоса вниз/вверх по политике, либо ошибка 503
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Универсальный пул: локальные дешёвые + хостовые дорогие
|
||||
|
||||
Главный сценарий для разработчика: смешать **бесплатную локальную** модель и
|
||||
**платный облачный** API, и пусть шлюз сам решает, что куда.
|
||||
|
||||
```toml
|
||||
# Локальная модель на vLLM / llama.cpp / Ollama (OpenAI-совместимый endpoint)
|
||||
[[models]]
|
||||
id = "local-qwen"
|
||||
provider = "openai"
|
||||
base_url = "http://localhost:8000/v1"
|
||||
model = "qwen2.5-7b-instruct"
|
||||
cost_tier = "cheap"
|
||||
price_in = 0.0 # локально → 0
|
||||
price_out = 0.0
|
||||
caps = ["tools"] # что умеет (для проверки на этапе выбора)
|
||||
|
||||
# Дешёвый облачный
|
||||
[[models]]
|
||||
id = "gpt-4o-mini"
|
||||
provider = "openai"
|
||||
base_url = "https://api.openai.com/v1"
|
||||
model = "gpt-4o-mini"
|
||||
api_key_env = "OPENAI_API_KEY"
|
||||
cost_tier = "mid"
|
||||
price_in = 0.15 # $/1M входных токенов
|
||||
price_out = 0.60
|
||||
|
||||
# Дорогой облачный для сложного
|
||||
[[models]]
|
||||
id = "claude-opus"
|
||||
provider = "anthropic"
|
||||
base_url = "https://api.anthropic.com"
|
||||
model = "claude-opus-4"
|
||||
api_key_env = "ANTHROPIC_API_KEY"
|
||||
cost_tier = "expensive"
|
||||
price_in = 15.0
|
||||
price_out = 75.0
|
||||
caps = ["tools", "vision"]
|
||||
```
|
||||
|
||||
Поскольку `local-qwen` и `gpt-4o-mini` оба `provider = "openai"`, шлюзу не важно,
|
||||
где модель физически — **локальные серверы уже говорят на OpenAI API**. Это и делает
|
||||
прокси универсальным.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cost-aware режим (опционально)
|
||||
|
||||
Вместо жёсткой таблицы можно включить выбор по **бюджету/цене** (v0.2):
|
||||
|
||||
```toml
|
||||
[routing.policy]
|
||||
mode = "cost_aware" # fixed_table | cost_aware
|
||||
# при cost_aware шлюз для каждой полосы берёт самую дешёвую модель,
|
||||
# удовлетворяющую минимальному «классу качества» полосы:
|
||||
min_class = { low = "cheap", medium = "mid", high = "expensive" }
|
||||
# верхняя граница цены за запрос (страховка от дорогих вызовов)
|
||||
max_cost_usd_per_request = 0.50
|
||||
```
|
||||
|
||||
Логика: среди моделей не ниже `min_class[tier]` и не дороже лимита выбирается самая
|
||||
дешёвая доступная. Это даёт «минимально достаточную» модель под сложность задачи.
|
||||
|
||||
---
|
||||
|
||||
## 4. Pinning и passthrough
|
||||
|
||||
- **Passthrough.** `model = "local-qwen"` (реальный id) — без обращения к роутеру,
|
||||
прямой вызов. Полезно, когда клиент сам знает, что хочет.
|
||||
- **Pinning профиля.** `model = "cortiq-auto:cost-saver"` — роутинг с конкретным
|
||||
policy-профилем роутера (реже эскалирует → дешевле).
|
||||
- **Pinning версии роутера** (v0.4). Чтобы поведение не «плыло» из-за самообучения
|
||||
роутера, можно зафиксировать `model_version` — агент получает воспроизводимый роутинг.
|
||||
|
||||
---
|
||||
|
||||
## 5. Извлечение текста для маршрутизации (мультитёрн)
|
||||
|
||||
Агентский запрос — это не одна строка, а system + история + tools. Что слать роутеру
|
||||
на классификацию, настраивается:
|
||||
|
||||
```toml
|
||||
[route]
|
||||
text_strategy = "last_user" # last_user | concat_all | last_user_plus_system
|
||||
max_chars = 4000 # усечение длинного контекста (стоимость/DoS)
|
||||
cache_ttl = "60s" # кэш решения по хэшу текста
|
||||
profile = "balanced" # дефолтный policy-профиль
|
||||
```
|
||||
|
||||
| Стратегия | Что классифицируем | Когда |
|
||||
|---|---|---|
|
||||
| `last_user` | последнее сообщение пользователя | по умолчанию; дёшево и точно для большинства агентов |
|
||||
| `last_user_plus_system` | system + последнее user | когда роль задаётся системным промптом |
|
||||
| `concat_all` | весь диалог (усечённый) | мультитёрн со смыслом, размазанным по истории |
|
||||
|
||||
---
|
||||
|
||||
## 6. Failover и circuit breaker
|
||||
|
||||
- **Failover.** При 5xx/таймауте/`overloaded` от провайдера — следующая модель списка.
|
||||
- **Circuit breaker.** После `breaker.threshold` ошибок подряд модель помечается
|
||||
«открытой» на `breaker.cooldown`, запросы её минуют. Полупроброс — один пробный
|
||||
запрос после кулдауна.
|
||||
- **Router down.** Берётся `routing.default`, запрос не падает (мягкая деградация).
|
||||
|
||||
```toml
|
||||
[breaker]
|
||||
threshold = 5
|
||||
cooldown = "30s"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Петля обратной связи (опционально, v0.4)
|
||||
|
||||
Если включено, шлюз после успешного выполнения может отправить в роутер
|
||||
`/v1/feedback` сигнал (например, по результату валидации ответа агентом) — роутер
|
||||
дообучит маску задачи. Так маршрутизация со временем точнее ложится на ваш пул.
|
||||
|
||||
```toml
|
||||
[feedback]
|
||||
enabled = false
|
||||
# источник истины задаёт интегратор (например, заголовок X-Cortiq-Correct-Label)
|
||||
```
|
||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 230 KiB |
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
Reference in New Issue
Block a user