Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c15109f26 | ||
|
|
f65f131c41 | ||
|
|
b339c2b622 | ||
|
|
34093330f6 | ||
|
|
faddf945d7 | ||
|
|
255d989e83 | ||
|
|
0d9d24125a | ||
|
|
1c59906b49 | ||
|
|
19f666e7b1 | ||
|
|
dbcd9827f6 | ||
|
|
b689180717 | ||
|
|
f385c2254b | ||
|
|
9607a38792 | ||
|
|
239ddc767c | ||
|
|
057abaa95a | ||
|
|
dd864eed22 | ||
|
|
c1ff675d80 | ||
|
|
9ef1af3ae1 |
+144
@@ -3,6 +3,150 @@
|
||||
All notable changes to this project are documented here.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [0.2.47] - 2026-08-21
|
||||
|
||||
### Changed
|
||||
- **The hosted allaigate router is addressed by hostname:
|
||||
`https://router.allaigate.com`** is now the default `[router].url`, and
|
||||
`verify_tls` defaults to `true`. The endpoint used to be reachable only by bare IP,
|
||||
whose certificate is issued for the hostname — so every hosted setup had to run with
|
||||
certificate verification switched off. It no longer does.
|
||||
- **Existing configs are migrated on load.** A `[router].url` still pointing at the old
|
||||
`138.226.222.209` (with or without a scheme or trailing slash) is rewritten to the
|
||||
hostname, `verify_tls` is turned back on, and the file is saved — the endpoint is the
|
||||
same machine, and this is the difference between a verified connection and a blind one.
|
||||
A self-hosted router URL is never touched.
|
||||
- README, `docs/ROUTER.md` (both languages), `config/gateway.example.toml` and
|
||||
`bench/accuracy.py` updated to the hostname.
|
||||
|
||||
## [0.2.46] - 2026-08-21
|
||||
|
||||
Verified end to end against a real Open WebUI container talking to the gateway.
|
||||
|
||||
### Fixed
|
||||
- **Offered `tools` no longer make a local model unroutable.** Open WebUI attaches its ~26
|
||||
built-in tools (knowledge bases, notes, calendar, …) to *every* message, and the gateway
|
||||
treated that offer as a hard capability requirement — so a plain "hello" against a pool of
|
||||
tools-less local models failed with «No healthy model available that satisfies required
|
||||
capabilities». In the OpenAI contract `tools` is an offer: only `tool_choice: "required"`
|
||||
(or a named function) makes the call impossible without them. The gateway now still
|
||||
prefers a tool-capable model, but falls back to serving the request with the tools
|
||||
stripped (along with `tool_choice`/`parallel_tool_calls`, which a provider rejects on
|
||||
their own). `tool_choice: "required"` still fails loudly, as it must.
|
||||
- **A pinned model that merely lacks tool support is honoured** instead of being reported as
|
||||
«not available — it is not in the pool (deleted?)». An unknown pin still fails loudly.
|
||||
|
||||
### Added
|
||||
- `X-Cortiq-Tools-Dropped: true` (and `cortiq.tools_dropped` in the echo block) whenever the
|
||||
gateway answers without the tools the client offered, so the downgrade is never silent.
|
||||
|
||||
## [0.2.45] - 2026-08-21
|
||||
|
||||
### Fixed
|
||||
- **Open WebUI (and every strict OpenAI client) can finally chat with the gateway.**
|
||||
`POST /v1/chat/completions` rejected perfectly ordinary payloads with a bare HTTP 422 —
|
||||
a plain-text body no OpenAI client can parse, which is why the chat window just spun with
|
||||
no answer and no error. The request parser now accepts every shape real clients send:
|
||||
`content` as a string, `null`, or an array of content parts (text parts concatenated,
|
||||
images skipped), `tools: null`, `stream: null`, a string `reasoning_effort`
|
||||
(`low`/`medium`/`high`) as well as a numeric budget, `max_completion_tokens` as an alias
|
||||
for `max_tokens`, `messages[].name` and `messages[].tool_call_id` proxied through, and a
|
||||
missing `Content-Type`. A body that genuinely cannot be read now returns an OpenAI error
|
||||
envelope with HTTP 400.
|
||||
- **Strict OpenAI response JSON, no optional `null`s.** Absent tool calls no longer emit
|
||||
`"tool_calls": null` (the key is omitted entirely); `finish_reason` is mapped to the
|
||||
spec's closed set, so `cortiq serve`'s `max_tokens` reaches clients as `length`; the
|
||||
response always carries a non-empty `id`.
|
||||
- **`model` echoes the id the client requested** (`cortiq-auto` stays `cortiq-auto`, not the
|
||||
upstream's `cortiq`), matching OpenAI's own behaviour. The model that actually answered
|
||||
stays in `cortiq.selected_model` and `X-Cortiq-Selected-Model`. Applies to
|
||||
`/v1/chat/completions`, `/v1/completions` and `/v1/messages`.
|
||||
- **Streaming is re-normalised instead of forwarded verbatim.** Every chunk is reissued as a
|
||||
strict `chat.completion.chunk`: the stream opens with a `role` delta, `message`-shaped
|
||||
chunks are converted to `delta`, empty deltas are dropped, and it always closes with one
|
||||
terminal `finish_reason` and `data: [DONE]` — even when the upstream stops early or dies
|
||||
mid-flight, in which case the failure arrives as an SSE `error` event rather than a client
|
||||
waiting forever. SSE responses also carry `X-Accel-Buffering: no` so nginx/Traefik cannot
|
||||
buffer the stream into a hang.
|
||||
- **Client bookkeeping keys are no longer proxied upstream.** `chat_id`, `session_id`,
|
||||
`metadata`, `features`, `background_tasks` and friends (plus a non-string `user`) are
|
||||
stripped before the provider call, so strict backends stop rejecting the request.
|
||||
|
||||
### Added
|
||||
- **`GET /v1/models` lists managed local CMF models.** Every `[[cmf.servers]]` entry now
|
||||
appears alongside `cortiq-auto` and the `[[models]]` pool, with live `running`/`healthy`
|
||||
state in a non-standard `cortiq` field, so Open WebUI and other clients can pin a local
|
||||
`.cmf` directly. Entries also carry `created`.
|
||||
|
||||
### Changed
|
||||
- Dropped leftover `[to_wire]` debug prints from the OpenAI provider — they wrote to stderr
|
||||
on every single request.
|
||||
|
||||
## [0.2.44] - 2026-08-08
|
||||
|
||||
### Added
|
||||
- **Power controls in Settings**: «Stop everything & power off» stops every managed local
|
||||
model server and exits the gateway (the panel shows a clear "gateway is off" screen);
|
||||
«Restart everything» (relabeled) re-execs the gateway with its local servers. Both behind
|
||||
confirm dialogs, translated in all seven languages.
|
||||
|
||||
### Fixed
|
||||
- `cortiq-gateway` typed in any directory picks up your configuration. Resolution order:
|
||||
`--config` → a readable `./config/gateway.toml` → `$CORTIQ_GATEWAY_HOME` →
|
||||
`~/.cortiq-gateway/gateway.toml`. An unrelated project that merely contains `Cargo.toml`
|
||||
and a `config/` folder no longer hijacks the config path ("cannot read config
|
||||
config/gateway.toml").
|
||||
|
||||
## [0.2.43] - 2026-08-08
|
||||
|
||||
### Added
|
||||
- **API keys actually gate the gateway.** Configured `[[api_keys]]` now enforce auth on
|
||||
`/v1`, `/route` and `/mcp` (Bearer or `x-api-key`), attribute every request to the key's
|
||||
account in stats, apply per-key rate limits (`rate_per_min` → 429 with `Retry-After`) and
|
||||
per-key monthly budgets (`monthly_budget_usd` → 429 `insufficient_quota`). No keys
|
||||
configured = open mode, as before.
|
||||
- **Auto-detected tool support for managed local servers**: `cortiq serve` ≥ 0.5.63
|
||||
advertises the loaded model's capabilities in `/healthz`; the gateway reads the handshake
|
||||
when the server becomes healthy, so a local model whose chat template can drive tool
|
||||
calling serves agent (tools) traffic with zero manual configuration. Manual override:
|
||||
`caps = ["tools"]` on the `[[cmf.servers]]` entry (also the fallback for older serves).
|
||||
- **OpenRouter balance on the dashboard** (when an OpenRouter key is configured): the
|
||||
remaining credit is a KPI card, red under $5.
|
||||
- **Harness snippets for every OS** on the API-keys page: Linux/macOS and Windows variants
|
||||
per harness, an install command chip when the harness is missing, and a paste-your-key
|
||||
field that substitutes the real key into every snippet.
|
||||
- Deploy guide (`docs/DEPLOY.md`, Russian mirror): systemd/launchd/Windows service, nginx and
|
||||
Caddy TLS fronting, security checklist, backup list.
|
||||
|
||||
### Fixed
|
||||
- Capability filtering widens the tier chain instead of killing the request: when an AUTO
|
||||
route's chain has no model with the required capabilities (tools), the gateway escalates
|
||||
through medium→high chains and then the rest of the pool instead of returning
|
||||
"No healthy model available". Explicit pins still fail loudly.
|
||||
- Semantic-cache threshold no longer renders as `0.9200000166893005` (f32→f64), the cache
|
||||
settings card shows live entries/hits/saved-$ and warns when no embedding model exists.
|
||||
- Key-page snippets survive being pasted into a real interactive zsh (no `#` comment lines,
|
||||
no history-expansion `!`), and the install chips are readable in the light theme.
|
||||
- Stats log growth is bounded: `[stats] max_file_mb` (default 16) folds old JSONL records
|
||||
into the sidecar totals file — per-account all-time and month spend survive compaction
|
||||
and restarts.
|
||||
|
||||
### Breaking
|
||||
- If your config already lists `[[api_keys]]` but your clients never send keys, requests
|
||||
will now be rejected with 401 — either remove the keys from the config or start sending
|
||||
`Authorization: Bearer <key>`.
|
||||
|
||||
## [0.2.42] - 2026-08-08
|
||||
|
||||
### Added
|
||||
- **The continuous complexity score is visible**: the benchmark's routing table and the
|
||||
dashboard's recent-requests list show the router's raw 0..1 score next to the tier badge —
|
||||
see what different prompts actually weigh and tune the boundary sliders from real traffic.
|
||||
|
||||
### Changed
|
||||
- Dashboard tier badges render translated tier names; the Russian locale calls the dashboard
|
||||
«Рабочий стол».
|
||||
|
||||
## [0.2.41] - 2026-08-08
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+1
-1
@@ -285,7 +285,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "cortiq-gateway"
|
||||
version = "0.2.41"
|
||||
version = "0.2.47"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cortiq-gateway"
|
||||
version = "0.2.41"
|
||||
version = "0.2.47"
|
||||
edition = "2021"
|
||||
rust-version = "1.75"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -87,6 +87,8 @@ docker compose exec ollama ollama pull qwen2.5:0.5b # pull a model once
|
||||
See [`docker-compose.yml`](docker-compose.yml). For smart routing, set `CORTIQ_ROUTER_KEY`
|
||||
and add a `[router]` (see [docs/ROUTER.md](docs/ROUTER.md)).
|
||||
|
||||
Deploying for a team or an office (systemd/launchd/Windows service, TLS reverse proxy, API keys with rate limits and monthly budgets, backups): [docs/DEPLOY.md](docs/DEPLOY.md).
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Admin console
|
||||
@@ -173,9 +175,9 @@ print(resp.choices[0].message.content)
|
||||
`model: "cortiq-auto"` enables routing. Any **real** model name from the config
|
||||
(`"gpt-4o-mini"`, `"local-qwen"`) is a direct passthrough, without routing.
|
||||
|
||||
> **Using the hosted allaigate router?** Set `url = "https://138.226.222.209"`,
|
||||
> `verify_tls = false`, `taxonomy_id = "data-assistant"`, and a `cortiq_…` key in
|
||||
> `CORTIQ_ROUTER_KEY`. On hard prompts the router escalates to an oracle (~10 s), so
|
||||
> **Using the hosted allaigate router?** It is the default: `url =
|
||||
> "https://router.allaigate.com"` with `verify_tls = true`. Set `taxonomy_id =
|
||||
> "data-assistant"` and a `cortiq_…` key in `CORTIQ_ROUTER_KEY`. On hard prompts the router escalates to an oracle (~10 s), so
|
||||
> set `timeout_ms = 12000+` — otherwise the gateway gracefully degrades to the default model.
|
||||
|
||||
---
|
||||
|
||||
+5
-3
@@ -82,6 +82,8 @@ docker compose exec ollama ollama pull qwen2.5:0.5b # один раз скач
|
||||
См. [`docker-compose.yml`](docker-compose.yml). Для умного роутинга задайте
|
||||
`CORTIQ_ROUTER_KEY` и добавьте `[router]` (см. [docs/ROUTER.ru.md](docs/ROUTER.ru.md)).
|
||||
|
||||
Развёртывание для команды/офиса (сервис systemd/launchd/Windows, TLS-прокси, API-ключи с лимитами и месячными бюджетами, бэкапы): [docs/DEPLOY.ru.md](docs/DEPLOY.ru.md).
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Панель управления
|
||||
@@ -165,9 +167,9 @@ print(resp.choices[0].message.content)
|
||||
`model: "cortiq-auto"` включает роутинг. Любое **реальное** имя модели из конфига
|
||||
(`"gpt-4o-mini"`, `"local-qwen"`) — это passthrough напрямую, без роутинга.
|
||||
|
||||
> **Используете хостовый allaigate-роутер?** Укажите `url = "https://138.226.222.209"`,
|
||||
> `verify_tls = false`, `taxonomy_id = "data-assistant"` и ключ `cortiq_…` в
|
||||
> `CORTIQ_ROUTER_KEY`. На сложных запросах роутер эскалирует к oracle (~10 с), поэтому
|
||||
> **Используете хостовый allaigate-роутер?** Он стоит по умолчанию: `url =
|
||||
> "https://router.allaigate.com"`, `verify_tls = true`. Задайте `taxonomy_id =
|
||||
> "data-assistant"` и ключ `cortiq_…` в `CORTIQ_ROUTER_KEY`. На сложных запросах роутер эскалирует к oracle (~10 с), поэтому
|
||||
> ставьте `timeout_ms = 12000+` — иначе шлюз мягко деградирует на default-модель.
|
||||
|
||||
---
|
||||
|
||||
+2
-2
@@ -4,12 +4,12 @@ heuristic (what you get without a semantic classifier, e.g. with LiteLLM/Portkey
|
||||
|
||||
CORTIQ_ROUTER_KEY=cortiq_... python3 bench/accuracy.py
|
||||
|
||||
Env: CORTIQ_ROUTER_KEY (required), ROUTER_URL (default https://138.226.222.209),
|
||||
Env: CORTIQ_ROUTER_KEY (required), ROUTER_URL (default https://router.allaigate.com),
|
||||
TAXONOMY (default data-assistant).
|
||||
"""
|
||||
import json, os, ssl, sys, urllib.request
|
||||
|
||||
ROUTER_URL = os.environ.get("ROUTER_URL", "https://138.226.222.209").rstrip("/")
|
||||
ROUTER_URL = os.environ.get("ROUTER_URL", "https://router.allaigate.com").rstrip("/")
|
||||
TAXONOMY = os.environ.get("TAXONOMY", "data-assistant")
|
||||
KEY = os.environ.get("CORTIQ_ROUTER_KEY", "")
|
||||
CTX = ssl._create_unverified_context()
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
listen = "0.0.0.0:9000"
|
||||
|
||||
# --- connection to cortiq-router (decides task type / complexity) -----------
|
||||
# Hosted allaigate router: url = "https://138.226.222.209", verify_tls = false
|
||||
# Hosted allaigate router: url = "https://router.allaigate.com" (the default)
|
||||
# (cert is bound to an IP), taxonomy_id = "data-assistant", a cortiq_… key in
|
||||
# CORTIQ_ROUTER_KEY.
|
||||
# NOTE: on hard prompts the router escalates to an oracle LLM (~10 s) — for the
|
||||
# hosted router set timeout_ms = 12000+, otherwise the gateway degrades to default.
|
||||
[router]
|
||||
url = "https://138.226.222.209" # hosted allaigate router (or your own cortiq-router URL)
|
||||
url = "https://router.allaigate.com" # hosted allaigate router (or your own cortiq-router URL)
|
||||
api_key_env = "CORTIQ_ROUTER_KEY" # name of the env var holding the router key
|
||||
timeout_ms = 12000 # hard prompts escalate to an oracle (~10 s)
|
||||
taxonomy_id = "data-assistant" # the hosted router's task taxonomy
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
# Deploying cortiq-gateway for a team / office
|
||||
|
||||
One gateway on a small server, API keys per department, local models for the
|
||||
cheap tiers, cloud models for the hard ones. This page covers the ops side.
|
||||
|
||||
## 1. Run as a service
|
||||
|
||||
### Linux (systemd)
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/cortiq-gateway.service
|
||||
[Unit]
|
||||
Description=Cortiq Gateway
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
User=cortiq
|
||||
Environment=CORTIQ_GATEWAY_HOME=/var/lib/cortiq-gateway
|
||||
ExecStart=/usr/local/bin/cortiq-gateway --no-port-search
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now cortiq-gateway
|
||||
```
|
||||
|
||||
### macOS (launchd)
|
||||
|
||||
```xml
|
||||
<!-- ~/Library/LaunchAgents/com.cortiq.gateway.plist -->
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict>
|
||||
<key>Label</key><string>com.cortiq.gateway</string>
|
||||
<key>ProgramArguments</key><array>
|
||||
<string>/usr/local/bin/cortiq-gateway</string>
|
||||
<string>--no-port-search</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>KeepAlive</key><true/>
|
||||
</dict></plist>
|
||||
```
|
||||
|
||||
```bash
|
||||
launchctl load ~/Library/LaunchAgents/com.cortiq.gateway.plist
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
Task Scheduler → Create Task → "Run whether user is logged on or not",
|
||||
trigger At startup, action `cortiq-gateway.exe --no-port-search`. Or use
|
||||
[NSSM](https://nssm.cc): `nssm install cortiq-gateway C:\bin\cortiq-gateway.exe`.
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker run -d --restart unless-stopped -p 9000:9000 \
|
||||
-v cortiq-data:/data ghcr.io/infosave2007/cortiq-gateway:latest
|
||||
```
|
||||
|
||||
## 2. TLS and the outside world
|
||||
|
||||
The gateway itself speaks plain HTTP — put a reverse proxy with TLS in front
|
||||
and bind the gateway to localhost:
|
||||
|
||||
```toml
|
||||
# gateway.toml
|
||||
listen = "127.0.0.1:9000"
|
||||
```
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name ai.example.com;
|
||||
ssl_certificate /etc/letsencrypt/live/ai.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/ai.example.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:9000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off; # SSE streaming
|
||||
proxy_read_timeout 3600s; # long generations
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(`caddy reverse-proxy --from ai.example.com --to 127.0.0.1:9000` does the
|
||||
same with automatic certificates.)
|
||||
|
||||
## 3. Security checklist
|
||||
|
||||
- **Admin token**: set `[admin] token` (or `CORTIQ_ADMIN_TOKEN`) — without it
|
||||
the console warns that it is open to the network.
|
||||
- **API keys**: create one per department/developer on the keys page. With at
|
||||
least one key configured the model APIs require Bearer / x-api-key —
|
||||
requests without a valid key get 401.
|
||||
- **Rate limits**: per-key requests/min → 429 with Retry-After.
|
||||
- **Monthly budgets**: per-key $ ceiling → 429 insufficient_quota when the
|
||||
account's month-to-date spend crosses it.
|
||||
- Bind to 127.0.0.1 behind the proxy; never expose the admin console raw.
|
||||
|
||||
## 4. Backups
|
||||
|
||||
Everything lives under the data directory (`CORTIQ_GATEWAY_HOME`):
|
||||
|
||||
- `gateway.toml` — full config incl. keys (or use the console's TOML export);
|
||||
- `secrets.toml` — provider API keys (chmod 600);
|
||||
- `stats.jsonl` + `stats.jsonl.totals.json` — request accounting incl.
|
||||
cumulative per-key totals and month-to-date budget spend.
|
||||
|
||||
Copy those three and you can rebuild the instance anywhere.
|
||||
|
||||
## 5. Observability
|
||||
|
||||
- Dashboard: KPIs, per-key spend, recent requests with complexity scores,
|
||||
OpenRouter balance (red under $5).
|
||||
- `GET /metrics` (enable `[telemetry] metrics = true`) for Prometheus.
|
||||
- Logs view in the console; `stats.max_file_mb` keeps the disk bounded.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Развёртывание cortiq-gateway для команды / офиса
|
||||
|
||||
Один шлюз на небольшом сервере, API-ключи по отделам, локальные модели для
|
||||
дешёвых уровней, облачные — для сложных. Эта страница про эксплуатацию.
|
||||
|
||||
## 1. Запуск как сервис
|
||||
|
||||
### Linux (systemd)
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/cortiq-gateway.service
|
||||
[Unit]
|
||||
Description=Cortiq Gateway
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
User=cortiq
|
||||
Environment=CORTIQ_GATEWAY_HOME=/var/lib/cortiq-gateway
|
||||
ExecStart=/usr/local/bin/cortiq-gateway --no-port-search
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now cortiq-gateway
|
||||
```
|
||||
|
||||
### macOS (launchd)
|
||||
|
||||
```xml
|
||||
<!-- ~/Library/LaunchAgents/com.cortiq.gateway.plist -->
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict>
|
||||
<key>Label</key><string>com.cortiq.gateway</string>
|
||||
<key>ProgramArguments</key><array>
|
||||
<string>/usr/local/bin/cortiq-gateway</string>
|
||||
<string>--no-port-search</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>KeepAlive</key><true/>
|
||||
</dict></plist>
|
||||
```
|
||||
|
||||
```bash
|
||||
launchctl load ~/Library/LaunchAgents/com.cortiq.gateway.plist
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
Планировщик заданий → Создать задачу → «Выполнять вне зависимости от входа
|
||||
пользователя», триггер «При запуске», действие `cortiq-gateway.exe
|
||||
--no-port-search`. Либо [NSSM](https://nssm.cc):
|
||||
`nssm install cortiq-gateway C:\bin\cortiq-gateway.exe`.
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker run -d --restart unless-stopped -p 9000:9000 \
|
||||
-v cortiq-data:/data ghcr.io/infosave2007/cortiq-gateway:latest
|
||||
```
|
||||
|
||||
## 2. TLS и внешний мир
|
||||
|
||||
Сам шлюз говорит по HTTP — поставьте перед ним reverse-proxy с TLS, а шлюз
|
||||
привяжите к localhost:
|
||||
|
||||
```toml
|
||||
# gateway.toml
|
||||
listen = "127.0.0.1:9000"
|
||||
```
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name ai.example.com;
|
||||
ssl_certificate /etc/letsencrypt/live/ai.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/ai.example.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:9000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off; # SSE-стриминг
|
||||
proxy_read_timeout 3600s; # долгие генерации
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(`caddy reverse-proxy --from ai.example.com --to 127.0.0.1:9000` делает то же
|
||||
с автоматическими сертификатами.)
|
||||
|
||||
## 3. Чек-лист безопасности
|
||||
|
||||
- **Токен админки**: задайте `[admin] token` (или `CORTIQ_ADMIN_TOKEN`) — без
|
||||
него консоль предупреждает, что открыта сети.
|
||||
- **API-ключи**: по ключу на отдел/разработчика на странице ключей. Как только
|
||||
настроен хотя бы один ключ, модельные API требуют Bearer / x-api-key —
|
||||
запросы без валидного ключа получают 401.
|
||||
- **Rate-limit**: запросов/мин на ключ → 429 с Retry-After.
|
||||
- **Месячные бюджеты**: потолок $ на ключ → 429 insufficient_quota, когда
|
||||
траты аккаунта за месяц превысят лимит.
|
||||
- Привязывайте к 127.0.0.1 за прокси; не выставляйте админку наружу голой.
|
||||
|
||||
## 4. Бэкапы
|
||||
|
||||
Всё живёт в data-директории (`CORTIQ_GATEWAY_HOME`):
|
||||
|
||||
- `gateway.toml` — полный конфиг вкл. ключи (или TOML-экспорт из консоли);
|
||||
- `secrets.toml` — ключи провайдеров (chmod 600);
|
||||
- `stats.jsonl` + `stats.jsonl.totals.json` — учёт запросов вкл. вечные
|
||||
тоталы по ключам и месячные траты для бюджетов.
|
||||
|
||||
Скопируйте эти три файла — и инстанс поднимается где угодно.
|
||||
|
||||
## 5. Наблюдаемость
|
||||
|
||||
- Рабочий стол: KPI, расходы по ключам, последние запросы с коэффициентами
|
||||
сложности, баланс OpenRouter (красный ниже $5).
|
||||
- `GET /metrics` (включите `[telemetry] metrics = true`) для Prometheus.
|
||||
- Просмотр логов в консоли; `stats.max_file_mb` ограничивает диск.
|
||||
+47
-9
@@ -53,7 +53,24 @@ Routing rule common to all protocols:
|
||||
```
|
||||
|
||||
Standard OpenAI fields are supported; unknown fields are proxied to the provider unchanged
|
||||
(forward-compatible).
|
||||
(forward-compatible), minus the bookkeeping keys chat UIs attach to the payload
|
||||
(`chat_id`, `session_id`, `metadata`, `features`, …), which are dropped so strict upstreams
|
||||
do not reject the call.
|
||||
|
||||
The parser is deliberately lenient about the shapes real clients send:
|
||||
`content` may be a string, `null`, or an array of content parts (text parts are
|
||||
concatenated, images and other non-text parts are skipped); `tools` may be `null`;
|
||||
`reasoning_effort` may be a string (`low` | `medium` | `high`) or a token budget;
|
||||
`max_completion_tokens` is accepted as an alias for `max_tokens`. A body that still
|
||||
cannot be read comes back as an OpenAI error envelope with HTTP 400 — never a bare 422,
|
||||
which OpenAI clients cannot parse (they show no answer at all).
|
||||
|
||||
`tools` is treated as an **offer**, not a requirement. The gateway prefers a tool-capable
|
||||
model, but when none is available it answers without the tools rather than failing, and says
|
||||
so via `X-Cortiq-Tools-Dropped: true` (chat UIs attach their built-in tools to every message,
|
||||
so the strict reading made ordinary chat impossible on a tools-less local pool).
|
||||
`tool_choice: "required"` — or a named function — is a real requirement and still fails with
|
||||
`502 no_healthy_model` when no model can honour it.
|
||||
|
||||
### Response (non-streaming)
|
||||
|
||||
@@ -62,12 +79,12 @@ Standard OpenAI fields are supported; unknown fields are proxied to the provider
|
||||
"id": "chatcmpl-9f...",
|
||||
"object": "chat.completion",
|
||||
"created": 1718800000,
|
||||
"model": "local-qwen", // the ACTUALLY selected model
|
||||
"model": "cortiq-auto", // the model the CLIENT asked for, echoed back
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": { "role": "assistant", "content": "x = 2 or x = 3" },
|
||||
"finish_reason": "stop"
|
||||
"finish_reason": "stop" // always one of stop|length|tool_calls|content_filter
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
@@ -86,10 +103,24 @@ Standard OpenAI fields are supported; unknown fields are proxied to the provider
|
||||
}
|
||||
```
|
||||
|
||||
> `model` echoes the id the client requested, exactly as OpenAI does — clients match the
|
||||
> reply against the id they sent. The model that actually answered is in
|
||||
> `cortiq.selected_model` and in the `X-Cortiq-Selected-Model` header.
|
||||
|
||||
> Optional fields are omitted rather than sent as `null`: absent tool calls mean no
|
||||
> `tool_calls` key at all. `"tool_calls": null` is not in the OpenAI schema and trips
|
||||
> strict clients.
|
||||
|
||||
> 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.
|
||||
|
||||
Streaming (`stream: true`) emits strict `chat.completion.chunk` events: the stream always
|
||||
opens with a `role` delta, carries exactly one terminal `finish_reason`, and ends with
|
||||
`data: [DONE]` — even when the upstream stops early or dies mid-flight, in which case the
|
||||
failure arrives as an SSE `error` event. A client can therefore never be left waiting on a
|
||||
half-formed stream.
|
||||
|
||||
### Metadata in response headers (always present)
|
||||
|
||||
```
|
||||
@@ -164,20 +195,27 @@ single user message and follows the same pipeline. Response `object` is `"text_c
|
||||
## 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.
|
||||
(`cortiq-auto`, `cortiq-embed`), every model in the `[[models]]` pool, and every managed
|
||||
local `[[cmf.servers]]` model, so a client can pin a local `.cmf` directly.
|
||||
|
||||
```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" }
|
||||
{ "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 } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Managed CMF models are listed as soon as they are configured, with their live state in the
|
||||
non-standard `cortiq` field (clients ignore it). They are not filtered by health: a large
|
||||
`.cmf` takes minutes to load, and a model that disappears from the list while it warms up
|
||||
is worse than one marked "not ready yet".
|
||||
|
||||
---
|
||||
|
||||
## 5. Anthropic Messages
|
||||
@@ -198,7 +236,7 @@ single user message and follows the same pipeline. Response `object` is `"text_c
|
||||
"id": "msg_...",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "local-qwen",
|
||||
"model": "cortiq-auto",
|
||||
"content": [ { "type": "text", "text": "x = 2 or x = 3" } ],
|
||||
"stop_reason": "end_turn",
|
||||
"usage": { "input_tokens": 24, "output_tokens": 11 }
|
||||
|
||||
+45
-8
@@ -53,7 +53,24 @@
|
||||
```
|
||||
|
||||
Поддерживаются стандартные поля OpenAI; неизвестные поля проксируются к провайдеру
|
||||
без изменений (forward-compatible).
|
||||
без изменений (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`, если его некому выполнить.
|
||||
|
||||
### Ответ (без стриминга)
|
||||
|
||||
@@ -62,12 +79,12 @@
|
||||
"id": "chatcmpl-9f...",
|
||||
"object": "chat.completion",
|
||||
"created": 1718800000,
|
||||
"model": "local-qwen", // РЕАЛЬНО выбранная модель
|
||||
"model": "cortiq-auto", // модель, которую ЗАПРОСИЛ клиент — возвращается как есть
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": { "role": "assistant", "content": "x = 2 или x = 3" },
|
||||
"finish_reason": "stop"
|
||||
"finish_reason": "stop" // всегда одно из stop|length|tool_calls|content_filter
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
@@ -86,9 +103,22 @@
|
||||
}
|
||||
```
|
||||
|
||||
> `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`). Клиент никогда не остаётся ждать недосказанный поток.
|
||||
|
||||
### Метаданные в заголовках (всегда)
|
||||
|
||||
```
|
||||
@@ -163,20 +193,27 @@ data: [DONE]
|
||||
## 4. OpenAI Models
|
||||
|
||||
`GET /v1/models` — автодискавери для клиентов. Возвращает виртуальные модели шлюза
|
||||
(`cortiq-auto`, `cortiq-embed`) и все реальные модели пула.
|
||||
(`cortiq-auto`, `cortiq-embed`), все модели пула `[[models]]` и все управляемые локальные
|
||||
модели `[[cmf.servers]]` — чтобы клиент мог напрямую закрепить локальный `.cmf`.
|
||||
|
||||
```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" }
|
||||
{ "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
|
||||
|
||||
+4
-4
@@ -41,11 +41,11 @@ minted and shown to you.
|
||||
|
||||
```toml
|
||||
[router]
|
||||
url = "https://138.226.222.209" # allaigate routing API
|
||||
verify_tls = false # cert is bound to an IP
|
||||
url = "https://router.allaigate.com" # allaigate routing API (the default)
|
||||
verify_tls = true # valid certificate — keep verification on
|
||||
taxonomy_id = "data-assistant"
|
||||
api_key_env = "CORTIQ_ROUTER_KEY" # your cortiq_… key
|
||||
timeout_ms = 15000 # allow for oracle escalation on hard prompts
|
||||
api_key_env = "CORTIQ_ROUTER_KEY" # your cortiq_… key
|
||||
timeout_ms = 15000 # allow for oracle escalation on hard prompts
|
||||
```
|
||||
|
||||
```bash
|
||||
|
||||
+4
-4
@@ -40,11 +40,11 @@ Cortiq Gateway — тонкий и быстрый прокси; **интелле
|
||||
|
||||
```toml
|
||||
[router]
|
||||
url = "https://138.226.222.209" # routing API allaigate
|
||||
verify_tls = false # серт привязан к IP
|
||||
url = "https://router.allaigate.com" # routing API allaigate (значение по умолчанию)
|
||||
verify_tls = true # валидный сертификат — проверку не выключаем
|
||||
taxonomy_id = "data-assistant"
|
||||
api_key_env = "CORTIQ_ROUTER_KEY" # ваш ключ cortiq_…
|
||||
timeout_ms = 15000 # запас под oracle-эскалацию на сложных запросах
|
||||
api_key_env = "CORTIQ_ROUTER_KEY" # ваш ключ cortiq_…
|
||||
timeout_ms = 15000 # запас под oracle-эскалацию на сложных запросах
|
||||
```
|
||||
|
||||
```bash
|
||||
|
||||
@@ -155,6 +155,7 @@ pub fn api_routes(admin_token: String) -> Router<SharedState> {
|
||||
get(get_protocols).put(put_protocols),
|
||||
)
|
||||
.route("/admin/api/harnesses", get(get_harnesses))
|
||||
.route("/admin/api/balance", get(get_balance))
|
||||
.route("/admin/api/settings", get(get_settings).put(put_settings))
|
||||
.route("/admin/api/keys", get(list_keys).post(create_key))
|
||||
.route("/admin/api/keys/:key", axum::routing::delete(delete_key))
|
||||
@@ -183,6 +184,7 @@ pub fn api_routes(admin_token: String) -> Router<SharedState> {
|
||||
.route("/admin/api/logs", get(get_logs).delete(clear_logs))
|
||||
.route("/admin/api/system", get(get_system))
|
||||
.route("/admin/api/restart", post(restart_gateway))
|
||||
.route("/admin/api/shutdown", post(shutdown_gateway))
|
||||
.route("/admin/api/bench", get(bench_list).post(bench_start))
|
||||
.route("/admin/api/config/export", get(config_export))
|
||||
.route("/admin/api/config/import", post(config_import))
|
||||
@@ -868,6 +870,7 @@ async fn probe_model(State(state): State<SharedState>, Path(id): Path<String>) -
|
||||
role: "user".into(),
|
||||
content: "ping".into(),
|
||||
tool_calls: vec![],
|
||||
..Default::default()
|
||||
}],
|
||||
tools: vec![],
|
||||
params: GenParams {
|
||||
@@ -1008,6 +1011,51 @@ async fn put_routing(State(state): State<SharedState>, Json(body): Json<RoutingB
|
||||
ok(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
// ─────────────────────────────── balance ───────────────────────────────────
|
||||
// OpenRouter credit balance for the dashboard. Cached for 5 minutes; absent
|
||||
// (null) when no OpenRouter key is configured — the card hides itself.
|
||||
|
||||
async fn get_balance(State(state): State<SharedState>) -> ApiResult {
|
||||
static CACHE: std::sync::OnceLock<std::sync::Mutex<Option<(std::time::Instant, f64)>>> =
|
||||
std::sync::OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| std::sync::Mutex::new(None));
|
||||
if let Some((at, v)) = *cache.lock().unwrap() {
|
||||
if at.elapsed() < std::time::Duration::from_secs(300) {
|
||||
return ok(json!({ "openrouter_usd": v }));
|
||||
}
|
||||
}
|
||||
let key = state
|
||||
.secrets
|
||||
.resolve("OPENROUTER_API_KEY")
|
||||
.or_else(|| std::env::var("OPENROUTER_API_KEY").ok());
|
||||
let Some(key) = key.filter(|k| !k.is_empty()) else {
|
||||
return ok(json!({ "openrouter_usd": serde_json::Value::Null }));
|
||||
};
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(8))
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
let resp = client
|
||||
.get("https://openrouter.ai/api/v1/credits")
|
||||
.bearer_auth(&key)
|
||||
.send()
|
||||
.await;
|
||||
let balance = match resp {
|
||||
Ok(r) if r.status().is_success() => {
|
||||
let v: serde_json::Value = r.json().await.unwrap_or_default();
|
||||
let total = v["data"]["total_credits"].as_f64().unwrap_or(0.0);
|
||||
let used = v["data"]["total_usage"].as_f64().unwrap_or(0.0);
|
||||
Some(total - used)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let Some(balance) = balance else {
|
||||
return ok(json!({ "openrouter_usd": serde_json::Value::Null }));
|
||||
};
|
||||
*cache.lock().unwrap() = Some((std::time::Instant::now(), balance));
|
||||
ok(json!({ "openrouter_usd": balance }))
|
||||
}
|
||||
|
||||
// ──────────────────────────── harness detection ────────────────────────────
|
||||
// Which coding harnesses are present on the machine the gateway runs on, so the
|
||||
// API-keys view can mark tabs "installed" / "already configured for cortiq".
|
||||
@@ -1357,6 +1405,7 @@ async fn list_keys(State(state): State<SharedState>) -> ApiResult {
|
||||
"key_masked": mask_key(&k.key),
|
||||
"account": k.account,
|
||||
"rate_per_min": k.rate_per_min,
|
||||
"monthly_budget_usd": k.monthly_budget_usd,
|
||||
"allow_models": k.allow_models,
|
||||
})
|
||||
})
|
||||
@@ -1372,6 +1421,8 @@ struct NewKey {
|
||||
#[serde(default)]
|
||||
rate_per_min: u32,
|
||||
#[serde(default)]
|
||||
monthly_budget_usd: Option<f64>,
|
||||
#[serde(default)]
|
||||
allow_models: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -1390,6 +1441,7 @@ async fn create_key(State(state): State<SharedState>, Json(b): Json<NewKey>) ->
|
||||
account: b.account,
|
||||
rate_per_min: b.rate_per_min,
|
||||
allow_models: b.allow_models,
|
||||
monthly_budget_usd: b.monthly_budget_usd,
|
||||
});
|
||||
state.reload(cfg)?;
|
||||
// show the full key only once
|
||||
@@ -2147,6 +2199,7 @@ fn register_cmf_output(cfg: &mut crate::config::Config, output: &str) -> Option<
|
||||
o1_window: None,
|
||||
o1_sink: None,
|
||||
skip_mtp: false,
|
||||
caps: Vec::new(),
|
||||
});
|
||||
Some(id)
|
||||
}
|
||||
@@ -2221,6 +2274,20 @@ async fn get_system(State(state): State<SharedState>) -> ApiResult {
|
||||
ok(serde_json::to_value(snap).unwrap_or_else(|_| json!({})))
|
||||
}
|
||||
|
||||
/// Stop every managed local model server and power the gateway off. The 200
|
||||
/// goes out first so the SPA can show its "gateway is off" screen; nothing is
|
||||
/// respawned — the operator starts the gateway again by hand (or the Docker
|
||||
/// restart policy does, in which case this behaves like a restart).
|
||||
async fn shutdown_gateway(State(state): State<SharedState>) -> ApiResult {
|
||||
state.cmf.stop().await;
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
tracing::info!("shutdown requested from the admin console");
|
||||
std::process::exit(0);
|
||||
});
|
||||
ok(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// Restart the gateway in place: stop managed model servers, spawn a fresh
|
||||
/// copy of ourselves (it retries the bind while our port frees), then exit.
|
||||
/// In Docker we only exit — we are PID 1, a spawned child would die with the
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ impl SemanticCache {
|
||||
pub fn new(cfg: &CacheCfg) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
enabled: cfg.enabled,
|
||||
threshold: cfg.threshold,
|
||||
threshold: cfg.threshold as f32,
|
||||
ttl_secs: parse_duration_secs(&cfg.ttl).unwrap_or(3600),
|
||||
max_entries: cfg.max_entries.max(1),
|
||||
embed_model: cfg.embed_model.clone(),
|
||||
|
||||
+27
-5
@@ -56,6 +56,9 @@ struct Managed {
|
||||
pub struct CmfRuntime {
|
||||
status: Mutex<CmfStatus>,
|
||||
children: Mutex<Vec<Managed>>,
|
||||
/// Server ids whose healthz handshake advertised tool support — lets the
|
||||
/// pipeline route tools traffic to local models with no manual caps.
|
||||
pub detected_tools: Mutex<std::collections::HashSet<String>>,
|
||||
}
|
||||
|
||||
impl CmfRuntime {
|
||||
@@ -63,6 +66,7 @@ impl CmfRuntime {
|
||||
Arc::new(Self {
|
||||
status: Mutex::new(CmfStatus::default()),
|
||||
children: Mutex::new(Vec::new()),
|
||||
detected_tools: Mutex::new(std::collections::HashSet::new()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -193,6 +197,7 @@ impl Default for CmfRuntime {
|
||||
Self {
|
||||
status: Mutex::new(CmfStatus::default()),
|
||||
children: Mutex::new(Vec::new()),
|
||||
detected_tools: Mutex::new(std::collections::HashSet::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -332,8 +337,11 @@ fn spawn_serve(server: &CmfServer, cfg: &CmfCfg) -> Result<Child, String> {
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Poll `/healthz` until the local server answers 200 (or the deadline passes).
|
||||
async fn wait_healthy(host: &str, port: u16, tries: u32) -> bool {
|
||||
/// Poll `/healthz` until the local server answers 200 (or the deadline
|
||||
/// passes). Newer `cortiq serve` advertises the loaded model's capabilities
|
||||
/// in the healthz body ({"capabilities":{"tools":true}}) — returned so the
|
||||
/// caller can register them; older servers return `None` there.
|
||||
async fn wait_healthy(host: &str, port: u16, tries: u32) -> (bool, Option<bool>) {
|
||||
let url = format!("http://{host}:{port}/healthz");
|
||||
let client = reqwest::Client::new();
|
||||
for _ in 0..tries {
|
||||
@@ -344,12 +352,17 @@ async fn wait_healthy(host: &str, port: u16, tries: u32) -> bool {
|
||||
.await
|
||||
{
|
||||
if r.status().is_success() {
|
||||
return true;
|
||||
let tools = r
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|v| v["capabilities"]["tools"].as_bool());
|
||||
return (true, tools);
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
false
|
||||
(false, None)
|
||||
}
|
||||
|
||||
/// Orchestrate the whole lifecycle: ensure installed → maybe update → spawn each
|
||||
@@ -432,8 +445,17 @@ pub async fn manage(rt: Arc<CmfRuntime>, cfg: CmfCfg) {
|
||||
if !rt.is_running(&server.id) {
|
||||
continue; // failed to spawn / file missing
|
||||
}
|
||||
if wait_healthy(&cfg.local_host, server.port, 240).await {
|
||||
let (ok, tools) = wait_healthy(&cfg.local_host, server.port, 240).await;
|
||||
if ok {
|
||||
rt.set_healthy(&server.id, true);
|
||||
// capability handshake: the server said whether its model's chat
|
||||
// template can drive tool calling — no manual caps needed
|
||||
if tools == Some(true) {
|
||||
rt.detected_tools.lock().unwrap().insert(server.id.clone());
|
||||
tracing::info!(id = %server.id, "local server advertises tool support");
|
||||
} else {
|
||||
rt.detected_tools.lock().unwrap().remove(&server.id);
|
||||
}
|
||||
rt.log(format!(
|
||||
"local CMF server ready — '{}' at http://{}:{}/v1",
|
||||
server.id, cfg.local_host, server.port
|
||||
|
||||
+96
-10
@@ -130,6 +130,10 @@ pub struct CmfServer {
|
||||
/// Disable Multi-Token Prediction speculative decoding (CMF_MTP=0).
|
||||
#[serde(default)]
|
||||
pub skip_mtp: bool,
|
||||
/// Manual capability override (e.g. ["tools"]). Usually unnecessary: the
|
||||
/// gateway auto-detects tool support from the server's healthz handshake.
|
||||
#[serde(default)]
|
||||
pub caps: Vec<String>,
|
||||
}
|
||||
|
||||
/// CMF-format model factory: import a HuggingFace model, convert it to a
|
||||
@@ -229,6 +233,7 @@ impl CmfCfg {
|
||||
o1_window: None,
|
||||
o1_sink: None,
|
||||
skip_mtp: false,
|
||||
caps: Vec::new(),
|
||||
}];
|
||||
}
|
||||
Vec::new()
|
||||
@@ -313,6 +318,34 @@ fn default_verify_tls() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// The hosted allaigate routing API.
|
||||
pub const DEFAULT_ROUTER_URL: &str = "https://router.allaigate.com";
|
||||
|
||||
/// The bare IP the hosted router used to be addressed by. It served a
|
||||
/// certificate for `router.allaigate.com`, so every config pointing at it had to
|
||||
/// turn TLS verification off. The hostname now resolves, so configs written
|
||||
/// before it existed are moved over on load — the endpoint is the same machine,
|
||||
/// and this is the difference between a verified connection and a blind one.
|
||||
const LEGACY_ROUTER_HOSTS: &[&str] = &[
|
||||
"https://138.226.222.209",
|
||||
"http://138.226.222.209",
|
||||
"138.226.222.209",
|
||||
];
|
||||
|
||||
impl RouterCfg {
|
||||
/// Point a legacy IP-addressed router config at the hostname and restore TLS
|
||||
/// verification. Returns true when something changed.
|
||||
fn migrate_hosted_endpoint(&mut self) -> bool {
|
||||
let url = self.url.trim().trim_end_matches('/');
|
||||
if !LEGACY_ROUTER_HOSTS.contains(&url) {
|
||||
return false;
|
||||
}
|
||||
self.url = DEFAULT_ROUTER_URL.to_string();
|
||||
self.verify_tls = true;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RouteCfg {
|
||||
#[serde(default = "default_text_strategy")]
|
||||
@@ -620,6 +653,9 @@ pub struct ApiKeyCfg {
|
||||
pub account: String,
|
||||
#[serde(default)]
|
||||
pub rate_per_min: u32,
|
||||
/// Monthly spend ceiling in USD for this key's account; 0/absent = none.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub monthly_budget_usd: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub allow_models: Vec<String>,
|
||||
}
|
||||
@@ -829,9 +865,10 @@ fn default_shadow_soak() -> usize {
|
||||
pub struct CacheCfg {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Cosine-similarity threshold for a hit (0..1).
|
||||
/// Cosine-similarity threshold for a hit (0..1). f64 so the TOML holds
|
||||
/// the number you typed (an f32 round-trips as 0.9200000166893005).
|
||||
#[serde(default = "default_semcache_threshold")]
|
||||
pub threshold: f32,
|
||||
pub threshold: f64,
|
||||
/// Entry time-to-live, e.g. `1h`.
|
||||
#[serde(default = "default_semcache_ttl")]
|
||||
pub ttl: String,
|
||||
@@ -853,7 +890,7 @@ impl Default for CacheCfg {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn default_semcache_threshold() -> f32 {
|
||||
fn default_semcache_threshold() -> f64 {
|
||||
0.92
|
||||
}
|
||||
fn default_semcache_ttl() -> String {
|
||||
@@ -873,9 +910,9 @@ impl Default for Config {
|
||||
// models and the console asks for one; nothing breaks.
|
||||
router: RouterCfg {
|
||||
enabled: true,
|
||||
url: "https://138.226.222.209".to_string(),
|
||||
url: DEFAULT_ROUTER_URL.to_string(),
|
||||
timeout_ms: 15_000, // hard prompts escalate to an oracle (~10 s)
|
||||
verify_tls: false, // the hosted endpoint is IP-bound
|
||||
verify_tls: true,
|
||||
api_key_env: Some("CORTIQ_ROUTER_KEY".to_string()),
|
||||
taxonomy_id: Some("data-assistant".to_string()),
|
||||
},
|
||||
@@ -915,14 +952,23 @@ impl Default for Config {
|
||||
}
|
||||
|
||||
/// Where the config lives when `--config` is not given.
|
||||
/// Repo checkout (`config/gateway.toml` exists or `config/` dir exists) → keep it there;
|
||||
/// installed binary (no repo) → `$data_dir/gateway.toml`.
|
||||
/// A readable `./config/gateway.toml` (a real gateway checkout / install dir)
|
||||
/// wins; otherwise the per-user home (`$CORTIQ_GATEWAY_HOME` or
|
||||
/// `~/.cortiq-gateway`) — so `cortiq-gateway` typed in any directory picks up
|
||||
/// the same configuration it was set up with. A foreign project that merely
|
||||
/// has `Cargo.toml` and a `config/` folder must NOT capture the config
|
||||
/// (that used to shadow the user's real config with a broken path).
|
||||
pub fn default_config_path() -> String {
|
||||
let repo_path = "config/gateway.toml";
|
||||
if std::path::Path::new(repo_path).exists() {
|
||||
// the file itself, readable — not just an entry that exists()
|
||||
if std::fs::read_to_string(repo_path).is_ok() {
|
||||
return repo_path.to_string();
|
||||
}
|
||||
if std::path::Path::new("Cargo.toml").exists() && std::path::Path::new("config").exists() {
|
||||
// our own repo checkout before first run: config/ exists, file not yet
|
||||
let ours = std::fs::read_to_string("Cargo.toml")
|
||||
.map(|t| t.contains("name = \"cortiq-gateway\""))
|
||||
.unwrap_or(false);
|
||||
if ours && std::path::Path::new("config").is_dir() {
|
||||
return repo_path.to_string();
|
||||
}
|
||||
data_dir()
|
||||
@@ -936,7 +982,14 @@ impl Config {
|
||||
pub fn load(path: &str) -> anyhow::Result<Self> {
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.map_err(|e| anyhow::anyhow!("cannot read config {path}: {e}"))?;
|
||||
let cfg: Config = toml::from_str(&raw)?;
|
||||
let mut cfg: Config = toml::from_str(&raw)?;
|
||||
if cfg.router.migrate_hosted_endpoint() {
|
||||
tracing::info!(
|
||||
url = DEFAULT_ROUTER_URL,
|
||||
"router endpoint moved from the bare IP to the hostname; TLS verification is on"
|
||||
);
|
||||
let _ = cfg.save(path);
|
||||
}
|
||||
cfg.validate()?;
|
||||
Ok(cfg)
|
||||
}
|
||||
@@ -1073,6 +1126,39 @@ impl Config {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_hosted_router_defaults_to_the_hostname_with_tls_on() {
|
||||
let cfg = Config::default();
|
||||
assert_eq!(cfg.router.url, "https://router.allaigate.com");
|
||||
assert!(cfg.router.verify_tls);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_legacy_ip_router_config_moves_to_the_hostname() {
|
||||
for legacy in [
|
||||
"https://138.226.222.209",
|
||||
"https://138.226.222.209/",
|
||||
"138.226.222.209",
|
||||
] {
|
||||
let mut r = Config::default().router;
|
||||
r.url = legacy.to_string();
|
||||
r.verify_tls = false;
|
||||
assert!(r.migrate_hosted_endpoint(), "{legacy} must migrate");
|
||||
assert_eq!(r.url, DEFAULT_ROUTER_URL);
|
||||
assert!(r.verify_tls, "TLS verification must be restored");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_self_hosted_router_url_is_left_alone() {
|
||||
let mut r = Config::default().router;
|
||||
r.url = "http://127.0.0.1:8081".to_string();
|
||||
r.verify_tls = false;
|
||||
assert!(!r.migrate_hosted_endpoint());
|
||||
assert_eq!(r.url, "http://127.0.0.1:8081");
|
||||
assert!(!r.verify_tls);
|
||||
}
|
||||
|
||||
const SAMPLE: &str = r#"
|
||||
listen = "0.0.0.0:9000"
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ pub enum GatewayError {
|
||||
Unauthorized(String),
|
||||
#[error("rate limit exceeded")]
|
||||
RateLimited { retry_after_secs: u64 },
|
||||
#[error("monthly budget exhausted (limit ${budget_usd})")]
|
||||
BudgetExhausted { budget_usd: f64 },
|
||||
#[error("upstream unavailable: {0}")]
|
||||
UpstreamUnavailable(String),
|
||||
#[error("internal error: {0}")]
|
||||
@@ -37,6 +39,11 @@ impl GatewayError {
|
||||
"rate_limit_error",
|
||||
"rate_limited",
|
||||
),
|
||||
Self::BudgetExhausted { .. } => (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"insufficient_quota",
|
||||
"insufficient_quota",
|
||||
),
|
||||
Self::UpstreamUnavailable(_) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"upstream_unavailable",
|
||||
|
||||
+15
-3
@@ -258,7 +258,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.route("/readyz", get(|| async { "ready" }))
|
||||
.route("/metrics", get(admin::metrics))
|
||||
.merge(protocols::build_router());
|
||||
.merge(protocols::build_router(state.clone()));
|
||||
|
||||
if admin_enabled {
|
||||
app = app
|
||||
@@ -271,7 +271,14 @@ async fn main() -> anyhow::Result<()> {
|
||||
.allow_methods(tower_http::cors::Any)
|
||||
.allow_headers(tower_http::cors::Any);
|
||||
|
||||
let app = app.with_state(state).layer(cors);
|
||||
// 32 MB request ceiling: enough for base64 keyframes and TOML imports,
|
||||
// small enough that a hostile client can't balloon memory
|
||||
let app =
|
||||
app.with_state(state)
|
||||
.layer(cors)
|
||||
.layer(tower_http::limit::RequestBodyLimitLayer::new(
|
||||
32 * 1024 * 1024,
|
||||
));
|
||||
|
||||
let listener = {
|
||||
let mut attempt = 0u32;
|
||||
@@ -340,6 +347,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
tracing::info!("open this URL to finish setup: {url}");
|
||||
}
|
||||
}
|
||||
axum::serve(listener, app).await?;
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
tracing::info!("shutdown signal received — draining connections");
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+47
-2
@@ -15,13 +15,53 @@ pub enum RoutingDirective {
|
||||
Pinned { model_id: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct Message {
|
||||
pub role: String, // system | user | assistant | tool
|
||||
#[serde(default)]
|
||||
/// Always a plain string internally. On the wire OpenAI clients legitimately
|
||||
/// send `null` (assistant turns that only carry tool calls) or an array of
|
||||
/// content parts (Open WebUI and every other multimodal UI), so
|
||||
/// [`de_content`] flattens both shapes instead of rejecting the request.
|
||||
#[serde(default, deserialize_with = "de_content")]
|
||||
pub content: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tool_calls: Vec<serde_json::Value>,
|
||||
/// Speaker name (`messages[].name`) — proxied through when the client sends it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// Set on `role = "tool"` replies; dropping it breaks multi-turn tool calling.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Deserialize `messages[].content` from any shape an OpenAI-compatible client
|
||||
/// may send: a string, `null`, or an array of content parts. Text parts are
|
||||
/// concatenated; non-text parts (images, audio, files) are skipped — this
|
||||
/// gateway routes text, and a picture must not turn into a 422.
|
||||
fn de_content<'de, D>(d: D) -> std::result::Result<String, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::Deserialize as _;
|
||||
Ok(match serde_json::Value::deserialize(d)? {
|
||||
serde_json::Value::Null => String::new(),
|
||||
serde_json::Value::String(s) => s,
|
||||
serde_json::Value::Array(parts) => {
|
||||
let mut out = String::new();
|
||||
for part in parts {
|
||||
// {"type":"text","text":"..."} / {"type":"input_text","text":"..."}
|
||||
let text = part
|
||||
.get("text")
|
||||
.and_then(|t| t.as_str())
|
||||
.or_else(|| part.as_str());
|
||||
if let Some(t) = text {
|
||||
out.push_str(t);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
other => other.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -73,6 +113,11 @@ pub struct RouteInfo {
|
||||
pub cost_usd: f64,
|
||||
#[serde(default)]
|
||||
pub failover: bool,
|
||||
/// The client offered `tools` but the model that answered cannot use them,
|
||||
/// so they were dropped from the upstream call (see `Pipeline::resolve`).
|
||||
/// Surfaced as `X-Cortiq-Tools-Dropped` so a silent downgrade is debuggable.
|
||||
#[serde(default)]
|
||||
pub tools_dropped: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
|
||||
+195
-14
@@ -183,7 +183,10 @@ impl Pipeline {
|
||||
&self,
|
||||
req: &ChatRequest,
|
||||
live: &crate::state::Live,
|
||||
) -> Result<(RouteDecision, String, Vec<String>)> {
|
||||
// Tool support detected via the local servers' healthz handshake —
|
||||
// treated as equivalent to declared caps when filtering candidates.
|
||||
detected_tools: &std::collections::HashSet<String>,
|
||||
) -> Result<(RouteDecision, String, Vec<String>, bool)> {
|
||||
// 1. Extract text for classification
|
||||
let text = self.extract_text(
|
||||
&req.messages,
|
||||
@@ -274,10 +277,14 @@ impl Pipeline {
|
||||
|
||||
// Filter candidates by capabilities (e.g. tools support)
|
||||
let needs_tools = !req.tools.is_empty();
|
||||
let tools_mandatory = needs_tools && tool_choice_is_mandatory(&req.params.passthrough);
|
||||
let has_tools = |id: &str, prov: &std::sync::Arc<dyn crate::providers::Provider>| {
|
||||
prov.caps().tools || detected_tools.contains(id)
|
||||
};
|
||||
let mut final_candidates = Vec::new();
|
||||
for model_id in candidates {
|
||||
if let Some(prov) = live.registry.get(&model_id) {
|
||||
if needs_tools && !prov.caps().tools {
|
||||
if needs_tools && !has_tools(&model_id, &prov) {
|
||||
tracing::debug!(
|
||||
"Candidate '{}' skipped: lacks support for requested tools",
|
||||
model_id
|
||||
@@ -288,6 +295,31 @@ impl Pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
// `tools` is an OFFER in the OpenAI contract, not a demand: only
|
||||
// `tool_choice: "required"` (or a named function) makes the call
|
||||
// impossible without them. Chat UIs lean on that — Open WebUI attaches
|
||||
// its ~26 built-in tools to every single message — so treating the offer
|
||||
// as a hard requirement made an ordinary "hello" unanswerable on a pool
|
||||
// of tools-less local models. Serve the request without the tools and
|
||||
// flag the downgrade (`X-Cortiq-Tools-Dropped`) instead of failing.
|
||||
let mut tools_dropped = false;
|
||||
|
||||
// A pin stays a pin: when the pinned model exists and merely lacks tool
|
||||
// support, honour the pin and drop the offer rather than reporting the
|
||||
// model as missing.
|
||||
if final_candidates.is_empty() && needs_tools && !tools_mandatory {
|
||||
if let RoutingDirective::Pinned { model_id } = &req.routing {
|
||||
if live.registry.get(model_id).is_some() {
|
||||
final_candidates.push(model_id.clone());
|
||||
tools_dropped = true;
|
||||
tracing::info!(
|
||||
model = %model_id,
|
||||
"pinned model does not support the offered tools — serving without them"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit pin must never silently answer from a different model: if
|
||||
// the pinned id is unknown or was filtered out, fail loudly so the caller
|
||||
// knows their choice was not honoured (instead of getting a stray reply
|
||||
@@ -306,25 +338,86 @@ impl Pipeline {
|
||||
if final_candidates.is_empty() && live.cfg.cmf.manage_server {
|
||||
for id in live.local_candidates() {
|
||||
if let Some(prov) = live.registry.get(&id) {
|
||||
if needs_tools && !prov.caps().tools {
|
||||
if needs_tools && !has_tools(&id, &prov) {
|
||||
continue;
|
||||
}
|
||||
final_candidates.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Capability widening: a tier chain built of tools-less local models
|
||||
// must not kill an agent request. When the filtered chain is empty on
|
||||
// an AUTO route, widen to any pool model that satisfies the required
|
||||
// capabilities — preferring the routing table's own medium→high order
|
||||
// (an agentic request is worth a capable model), then the rest of the
|
||||
// pool. Coding agents (OpenCode, Cline, …) always send tools; without
|
||||
// this every low-tier verdict was a hard failure.
|
||||
let widened_order = || -> Vec<String> {
|
||||
let mut widened: Vec<String> = Vec::new();
|
||||
for tier in ["medium", "high"] {
|
||||
for id in live.routing.candidates(tier) {
|
||||
if !widened.contains(&id) {
|
||||
widened.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in live.registry.ids() {
|
||||
if !widened.contains(id) {
|
||||
widened.push(id.clone());
|
||||
}
|
||||
}
|
||||
widened
|
||||
};
|
||||
if final_candidates.is_empty() {
|
||||
for id in widened_order() {
|
||||
if let Some(prov) = live.registry.get(&id) {
|
||||
if needs_tools && !has_tools(&id, &prov) {
|
||||
continue;
|
||||
}
|
||||
final_candidates.push(id);
|
||||
}
|
||||
}
|
||||
if !final_candidates.is_empty() {
|
||||
tracing::info!(
|
||||
chosen = %final_candidates[0],
|
||||
"capability widening: tier chain had no model with the required capabilities"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Same relaxation for an AUTO route once every narrower pass came up empty.
|
||||
if final_candidates.is_empty() && needs_tools && !tools_mandatory {
|
||||
for id in widened_order() {
|
||||
if live.registry.get(&id).is_some() {
|
||||
final_candidates.push(id);
|
||||
}
|
||||
}
|
||||
if !final_candidates.is_empty() {
|
||||
tools_dropped = true;
|
||||
tracing::info!(
|
||||
chosen = %final_candidates[0],
|
||||
"no model in the pool supports the offered tools — serving without them"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if final_candidates.is_empty() {
|
||||
return Err(GatewayError::UpstreamUnavailable(
|
||||
"No healthy model available that satisfies required capabilities (e.g. tools)"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Ok((decision, route_source, final_candidates))
|
||||
Ok((decision, route_source, final_candidates, tools_dropped))
|
||||
}
|
||||
|
||||
async fn run_inner(&self, req: ChatRequest, state: &SharedState) -> Result<ChatResponse> {
|
||||
async fn run_inner(&self, mut req: ChatRequest, state: &SharedState) -> Result<ChatResponse> {
|
||||
let live = state.live();
|
||||
let (decision, route_source, mut final_candidates) = self.resolve(&req, &live).await?;
|
||||
let detected_tools = state.cmf.detected_tools.lock().unwrap().clone();
|
||||
let (decision, route_source, mut final_candidates, tools_dropped) =
|
||||
self.resolve(&req, &live, &detected_tools).await?;
|
||||
if tools_dropped {
|
||||
strip_tools(&mut req);
|
||||
}
|
||||
|
||||
// Serving-when-promoted (gated): a promoted task-label is served by the
|
||||
// local model FIRST (failover to cloud on error). Per-request VETO uses
|
||||
@@ -386,6 +479,7 @@ impl Pipeline {
|
||||
role: "system".into(),
|
||||
content: sys.clone(),
|
||||
tool_calls: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -413,6 +507,7 @@ impl Pipeline {
|
||||
role: "system".into(),
|
||||
content: sys.clone(),
|
||||
tool_calls: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -432,6 +527,7 @@ impl Pipeline {
|
||||
router_request_id: decision.router_request_id.clone(),
|
||||
cost_usd: cost,
|
||||
failover: attempt > 0,
|
||||
tools_dropped,
|
||||
};
|
||||
return Ok(resp);
|
||||
}
|
||||
@@ -458,7 +554,7 @@ impl Pipeline {
|
||||
/// recorded when the stream completes (tokens taken from the final usage chunk).
|
||||
pub async fn run_stream(
|
||||
&self,
|
||||
req: ChatRequest,
|
||||
mut req: ChatRequest,
|
||||
state: &SharedState,
|
||||
) -> Result<(RouteInfo, crate::providers::ChatStream)> {
|
||||
let started = Instant::now();
|
||||
@@ -471,13 +567,18 @@ impl Pipeline {
|
||||
.to_string();
|
||||
|
||||
let live = state.live();
|
||||
let (decision, route_source, final_candidates) = match self.resolve(&req, &live).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
record_error(state, &account, &protocol, &directive, started, &e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let detected_tools = state.cmf.detected_tools.lock().unwrap().clone();
|
||||
let (decision, route_source, final_candidates, tools_dropped) =
|
||||
match self.resolve(&req, &live, &detected_tools).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
record_error(state, &account, &protocol, &directive, started, &e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
if tools_dropped {
|
||||
strip_tools(&mut req);
|
||||
}
|
||||
|
||||
let mut last_err = None;
|
||||
let mut attempt = 0usize;
|
||||
@@ -518,6 +619,7 @@ impl Pipeline {
|
||||
role: "system".into(),
|
||||
content: sys.clone(),
|
||||
tool_calls: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -545,6 +647,7 @@ impl Pipeline {
|
||||
role: "system".into(),
|
||||
content: sys.clone(),
|
||||
tool_calls: Vec::new(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -562,6 +665,7 @@ impl Pipeline {
|
||||
router_request_id: decision.router_request_id.clone(),
|
||||
cost_usd: 0.0,
|
||||
failover: attempt > 0,
|
||||
tools_dropped,
|
||||
};
|
||||
// for the client-facing usage estimate when the provider
|
||||
// sends none: the prompt size is known here, the reply is
|
||||
@@ -639,6 +743,26 @@ impl Pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the tool offer from a request the chosen model cannot honour. The
|
||||
/// companion fields must go too: an upstream given `tool_choice` with no
|
||||
/// `tools` answers 400.
|
||||
fn strip_tools(req: &mut ChatRequest) {
|
||||
req.tools.clear();
|
||||
req.params.passthrough.remove("tool_choice");
|
||||
req.params.passthrough.remove("parallel_tool_calls");
|
||||
}
|
||||
|
||||
/// Is the client's `tool_choice` a demand rather than a preference?
|
||||
/// `"required"` and `{"type":"function", …}` mean the caller cannot be served
|
||||
/// without tools; `"auto"`, `"none"` and an absent field are just an offer.
|
||||
fn tool_choice_is_mandatory(passthrough: &serde_json::Map<String, serde_json::Value>) -> bool {
|
||||
match passthrough.get("tool_choice") {
|
||||
Some(serde_json::Value::String(s)) => s.eq_ignore_ascii_case("required"),
|
||||
Some(serde_json::Value::Object(_)) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -688,6 +812,7 @@ fn cached_response(hit: &crate::cache::CacheHit) -> ChatResponse {
|
||||
role: "assistant".into(),
|
||||
content: hit.content.clone(),
|
||||
tool_calls: vec![],
|
||||
..Default::default()
|
||||
},
|
||||
finish_reason: "stop".into(),
|
||||
}],
|
||||
@@ -705,6 +830,7 @@ fn cached_response(hit: &crate::cache::CacheHit) -> ChatResponse {
|
||||
router_request_id: None,
|
||||
cost_usd: 0.0,
|
||||
failover: false,
|
||||
tools_dropped: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -910,6 +1036,7 @@ fn shadow_req(model_id: &str, content: String, max_tokens: u32) -> ChatRequest {
|
||||
role: "user".to_string(),
|
||||
content,
|
||||
tool_calls: Vec::new(),
|
||||
..Default::default()
|
||||
}],
|
||||
tools: Vec::new(),
|
||||
params: GenParams {
|
||||
@@ -1015,3 +1142,57 @@ async fn shadow_capture(
|
||||
judge_tokens,
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn map(json: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
|
||||
json.as_object().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_offered_tool_list_is_not_a_demand() {
|
||||
// Open WebUI attaches its built-in tools with no tool_choice at all.
|
||||
assert!(!tool_choice_is_mandatory(&map(serde_json::json!({}))));
|
||||
assert!(!tool_choice_is_mandatory(&map(
|
||||
serde_json::json!({"tool_choice": "auto"})
|
||||
)));
|
||||
assert!(!tool_choice_is_mandatory(&map(
|
||||
serde_json::json!({"tool_choice": "none"})
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_and_named_tool_choice_are_demands() {
|
||||
assert!(tool_choice_is_mandatory(&map(
|
||||
serde_json::json!({"tool_choice": "required"})
|
||||
)));
|
||||
assert!(tool_choice_is_mandatory(&map(
|
||||
serde_json::json!({"tool_choice": {"type": "function", "function": {"name": "f"}}})
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stripping_tools_also_drops_their_companion_fields() {
|
||||
let mut req = ChatRequest {
|
||||
routing: RoutingDirective::Auto { profile: None },
|
||||
messages: vec![],
|
||||
tools: vec![serde_json::json!({"type": "function"})],
|
||||
params: GenParams {
|
||||
passthrough: map(
|
||||
serde_json::json!({"tool_choice": "auto", "parallel_tool_calls": true, "top_k": 40}),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
stream: false,
|
||||
meta: RequestMeta::default(),
|
||||
};
|
||||
strip_tools(&mut req);
|
||||
assert!(req.tools.is_empty());
|
||||
assert!(!req.params.passthrough.contains_key("tool_choice"));
|
||||
assert!(!req.params.passthrough.contains_key("parallel_tool_calls"));
|
||||
// unrelated passthrough must survive
|
||||
assert!(req.params.passthrough.contains_key("top_k"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
use crate::error::{GatewayError, Result};
|
||||
use crate::model::{ChatRequest, GenParams, Message, RequestMeta, RoutingDirective};
|
||||
use crate::protocols::openai_chat::cortiq_headers;
|
||||
use crate::protocols::openai_chat::{cortiq_headers, sse_headers};
|
||||
use crate::state::SharedState;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{extract::State, routing::post, Json, Router};
|
||||
@@ -63,6 +63,7 @@ fn parse_routing(model: &str) -> RoutingDirective {
|
||||
|
||||
async fn handler(
|
||||
State(state): State<SharedState>,
|
||||
acct: Option<axum::Extension<super::AccountTag>>,
|
||||
Json(req): Json<MessagesRequest>,
|
||||
) -> Result<Response> {
|
||||
if !state.live().cfg.protocols.anthropic_messages {
|
||||
@@ -83,6 +84,7 @@ async fn handler(
|
||||
role: "system".into(),
|
||||
content: sys,
|
||||
tool_calls: vec![],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
for m in &req.messages {
|
||||
@@ -90,9 +92,12 @@ async fn handler(
|
||||
role: m["role"].as_str().unwrap_or("user").to_string(),
|
||||
content: content_to_string(&m["content"]),
|
||||
tool_calls: vec![],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
// Echoed back as-is: clients match the reply against the id they asked for.
|
||||
let requested_model = req.model.clone();
|
||||
let canonical = ChatRequest {
|
||||
routing: parse_routing(&req.model),
|
||||
messages,
|
||||
@@ -108,19 +113,19 @@ async fn handler(
|
||||
stream: req.stream,
|
||||
meta: RequestMeta {
|
||||
protocol: "anthropic_messages".into(),
|
||||
account: acct.map(|e| e.0 .0.clone()).unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
if req.stream {
|
||||
let (info, stream) = state.pipeline.run_stream(canonical, &state).await?;
|
||||
let mut headers = cortiq_headers(&info);
|
||||
headers.insert(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
axum::http::HeaderValue::from_static("text/event-stream"),
|
||||
);
|
||||
let translated = openai_to_anthropic_sse(stream);
|
||||
return Ok((headers, axum::body::Body::from_stream(translated)).into_response());
|
||||
let translated = openai_to_anthropic_sse(stream, requested_model);
|
||||
return Ok((
|
||||
sse_headers(&info),
|
||||
axum::body::Body::from_stream(translated),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let resp = state.pipeline.run(canonical, &state).await?;
|
||||
@@ -134,7 +139,7 @@ async fn handler(
|
||||
"id": resp.id,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": resp.model_used,
|
||||
"model": requested_model,
|
||||
"content": [{ "type": "text", "text": answer }],
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": serde_json::Value::Null,
|
||||
@@ -150,6 +155,7 @@ async fn handler(
|
||||
/// events, so Anthropic-native clients can stream regardless of the upstream provider.
|
||||
fn openai_to_anthropic_sse(
|
||||
stream: crate::providers::ChatStream,
|
||||
model: String,
|
||||
) -> impl futures::Stream<Item = Result<bytes::Bytes>> + Send + 'static {
|
||||
use futures::StreamExt;
|
||||
async_stream::stream! {
|
||||
@@ -160,7 +166,7 @@ fn openai_to_anthropic_sse(
|
||||
yield Ok(evt("message_start", serde_json::json!({
|
||||
"type": "message_start",
|
||||
"message": {"id": "msg_gw", "type": "message", "role": "assistant",
|
||||
"content": [], "model": "cortiq-auto", "stop_reason": null,
|
||||
"content": [], "model": model, "stop_reason": null,
|
||||
"usage": {"input_tokens": 0, "output_tokens": 0}}
|
||||
})));
|
||||
yield Ok(evt("content_block_start", serde_json::json!({
|
||||
|
||||
@@ -94,6 +94,7 @@ async fn handle_tool_call(
|
||||
role: "user".into(),
|
||||
content: prompt.to_string(),
|
||||
tool_calls: vec![],
|
||||
..Default::default()
|
||||
}],
|
||||
tools: vec![],
|
||||
params: GenParams::default(),
|
||||
|
||||
+51
-1
@@ -12,10 +12,59 @@ pub mod openai_models;
|
||||
use crate::state::SharedState;
|
||||
use axum::Router;
|
||||
|
||||
/// Account resolved by the API-key middleware, consumed by protocol handlers
|
||||
/// for attribution (stats, budgets). Present only when keys are configured.
|
||||
#[derive(Clone)]
|
||||
pub struct AccountTag(pub String);
|
||||
|
||||
/// API-key gate for the model-facing surface. Open mode (no keys configured)
|
||||
/// passes everything through — the admin console already warns about it.
|
||||
/// With keys: Bearer/x-api-key must match (401), per-key rate_per_min is
|
||||
/// enforced with a fixed one-minute window (429 + Retry-After), and a
|
||||
/// monthly budget, when set, turns into 429 insufficient_quota once the
|
||||
/// account's month-to-date spend crosses it.
|
||||
async fn api_auth(
|
||||
axum::extract::State(state): axum::extract::State<SharedState>,
|
||||
mut req: axum::extract::Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
use axum::response::IntoResponse;
|
||||
let live = state.live();
|
||||
if live.cfg.api_keys.is_empty() {
|
||||
return next.run(req).await;
|
||||
}
|
||||
let provided = req
|
||||
.headers()
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.or_else(|| req.headers().get("x-api-key").and_then(|v| v.to_str().ok()))
|
||||
.map(str::trim)
|
||||
.unwrap_or("");
|
||||
let Some(key) = live.cfg.api_keys.iter().find(|k| k.key == provided) else {
|
||||
return crate::error::GatewayError::Unauthorized("invalid or missing API key".into())
|
||||
.into_response();
|
||||
};
|
||||
if key.rate_per_min > 0 && !state.rate.allow(&key.key, key.rate_per_min) {
|
||||
return crate::error::GatewayError::RateLimited {
|
||||
retry_after_secs: state.rate.retry_after_secs(),
|
||||
}
|
||||
.into_response();
|
||||
}
|
||||
if let Some(budget) = key.monthly_budget_usd {
|
||||
if budget > 0.0 && state.stats.month_spend(&key.account) >= budget {
|
||||
return crate::error::GatewayError::BudgetExhausted { budget_usd: budget }
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
req.extensions_mut().insert(AccountTag(key.account.clone()));
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
/// Build the protocols router. Implemented adapters are **always** mounted;
|
||||
/// each handler checks the live `protocols.*` flag from config itself, so
|
||||
/// toggles in the admin panel take effect without a restart (hot switching).
|
||||
pub fn build_router() -> Router<SharedState> {
|
||||
pub fn build_router(state: SharedState) -> Router<SharedState> {
|
||||
Router::new()
|
||||
.merge(openai_chat::routes())
|
||||
.merge(openai_completions::routes())
|
||||
@@ -24,4 +73,5 @@ pub fn build_router() -> Router<SharedState> {
|
||||
.merge(anthropic_messages::routes())
|
||||
.merge(mcp::routes())
|
||||
.merge(native_passthrough::routes())
|
||||
.route_layer(axum::middleware::from_fn_with_state(state, api_auth))
|
||||
}
|
||||
|
||||
+421
-42
@@ -1,6 +1,17 @@
|
||||
//! Incoming adapter for OpenAI Chat Completions: `POST /v1/chat/completions`.
|
||||
//! Translates the OpenAI request body into a canonical [`ChatRequest`], passes it
|
||||
//! to the pipeline, then converts the canonical [`ChatResponse`] back to OpenAI format.
|
||||
//!
|
||||
//! Wire-compatibility rules this adapter enforces (see docs/PROTOCOLS.md §2):
|
||||
//! * the request body is parsed leniently — every shape a real OpenAI client
|
||||
//! sends (`content: null`, content-part arrays, `tools: null`, a string
|
||||
//! `reasoning_effort`, UI-private extras) is accepted, and a body we truly
|
||||
//! cannot read comes back as an OpenAI error envelope, never a bare 422;
|
||||
//! * the response is strict OpenAI JSON — no optional `null` fields, a
|
||||
//! spec-legal `finish_reason`, and the *requested* model id echoed back;
|
||||
//! * the SSE stream is re-normalised chunk by chunk and always terminates
|
||||
//! with `data: [DONE]`, so a client can never sit waiting on a half-formed
|
||||
//! stream from an upstream that improvises.
|
||||
|
||||
use crate::error::{GatewayError, Result};
|
||||
use crate::model::{ChatRequest, GenParams, Message, RequestMeta, RouteInfo, RoutingDirective};
|
||||
@@ -31,35 +42,119 @@ pub(crate) fn cortiq_headers(c: &RouteInfo) -> axum::http::HeaderMap {
|
||||
if let Some(id) = &c.router_request_id {
|
||||
put(&mut h, "X-Cortiq-Request-Id", id);
|
||||
}
|
||||
if c.tools_dropped {
|
||||
put(&mut h, "X-Cortiq-Tools-Dropped", "true");
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Headers every SSE response carries. `X-Accel-Buffering: no` matters when the
|
||||
/// gateway sits behind nginx/Traefik — without it the proxy buffers the whole
|
||||
/// stream and the client's chat window just spins.
|
||||
pub(crate) fn sse_headers(info: &RouteInfo) -> axum::http::HeaderMap {
|
||||
use axum::http::HeaderValue;
|
||||
let mut headers = cortiq_headers(info);
|
||||
headers.insert(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/event-stream; charset=utf-8"),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-cache"),
|
||||
);
|
||||
headers.insert("X-Accel-Buffering", HeaderValue::from_static("no"));
|
||||
headers
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<SharedState> {
|
||||
Router::new().route("/v1/chat/completions", post(handler))
|
||||
}
|
||||
|
||||
/// `reasoning_effort` is a string in the OpenAI spec (`low` | `medium` | `high`)
|
||||
/// but a token budget in several local runtimes. Accept both.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Effort {
|
||||
Budget(u32),
|
||||
Level(String),
|
||||
}
|
||||
|
||||
impl Effort {
|
||||
fn to_budget(&self) -> Option<u32> {
|
||||
match self {
|
||||
Self::Budget(n) => Some(*n),
|
||||
Self::Level(s) => match s.trim().to_ascii_lowercase().as_str() {
|
||||
"none" | "off" | "minimal" => Some(0),
|
||||
"low" => Some(512),
|
||||
"medium" | "default" | "auto" => Some(2048),
|
||||
"high" | "max" => Some(8192),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OpenAiChatRequest {
|
||||
model: String,
|
||||
model: Option<String>,
|
||||
messages: Vec<Message>,
|
||||
#[serde(default)]
|
||||
temperature: Option<f32>,
|
||||
#[serde(default)]
|
||||
max_tokens: Option<u32>,
|
||||
/// The modern spelling of `max_tokens`; clients send one or the other.
|
||||
#[serde(default)]
|
||||
max_completion_tokens: Option<u32>,
|
||||
#[serde(default)]
|
||||
top_p: Option<f32>,
|
||||
#[serde(default)]
|
||||
think_budget: Option<u32>,
|
||||
#[serde(default)]
|
||||
reasoning_effort: Option<u32>,
|
||||
reasoning_effort: Option<Effort>,
|
||||
/// `null` is a legal value here for several clients, hence `Option`.
|
||||
#[serde(default)]
|
||||
stream: bool,
|
||||
stream: Option<bool>,
|
||||
#[serde(default)]
|
||||
tools: Vec<serde_json::Value>,
|
||||
tools: Option<Vec<serde_json::Value>>,
|
||||
#[serde(flatten)]
|
||||
rest: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Keys chat UIs attach to the payload for their own bookkeeping. They are not
|
||||
/// part of the OpenAI request schema, and forwarding them to a strict upstream
|
||||
/// (llama.cpp, vLLM, `cortiq serve`) is a needless way to earn a 400.
|
||||
const CLIENT_PRIVATE_KEYS: &[&str] = &[
|
||||
"chat_id",
|
||||
"session_id",
|
||||
"id",
|
||||
"metadata",
|
||||
"background_tasks",
|
||||
"features",
|
||||
"variables",
|
||||
"model_item",
|
||||
"tool_ids",
|
||||
"filter_ids",
|
||||
"files",
|
||||
"citations",
|
||||
"params",
|
||||
"direct",
|
||||
];
|
||||
|
||||
/// Strip client-private bookkeeping from the passthrough map, and drop a
|
||||
/// non-string `user` (Open WebUI sends an object there for pipelines, which the
|
||||
/// OpenAI schema does not allow).
|
||||
fn sanitize_passthrough(
|
||||
mut rest: serde_json::Map<String, serde_json::Value>,
|
||||
) -> serde_json::Map<String, serde_json::Value> {
|
||||
for k in CLIENT_PRIVATE_KEYS {
|
||||
rest.remove(*k);
|
||||
}
|
||||
if rest.get("user").map(|u| !u.is_string()).unwrap_or(false) {
|
||||
rest.remove("user");
|
||||
}
|
||||
rest
|
||||
}
|
||||
|
||||
/// Parse the `model` field value into a routing directive.
|
||||
/// `cortiq-auto[:profile]` → Auto; otherwise → Pinned(real id).
|
||||
fn parse_routing(model: &str) -> RoutingDirective {
|
||||
@@ -73,9 +168,43 @@ fn parse_routing(model: &str) -> RoutingDirective {
|
||||
}
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
/// Map whatever an upstream calls its stop reason onto the closed set the
|
||||
/// OpenAI schema defines. Clients that validate the field (LiteLLM, the
|
||||
/// official SDKs, several agent frameworks) reject anything else.
|
||||
pub(crate) fn normalize_finish_reason(raw: &str) -> &'static str {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"length" | "max_tokens" | "max_new_tokens" | "token_limit" => "length",
|
||||
"tool_calls" | "tool_use" | "function_call" => "tool_calls",
|
||||
"content_filter" | "safety" => "content_filter",
|
||||
_ => "stop",
|
||||
}
|
||||
}
|
||||
|
||||
/// A response id every client can key on. Upstreams that omit `id` (or send an
|
||||
/// empty one) would otherwise leave the field blank, which some clients treat
|
||||
/// as a malformed completion.
|
||||
fn response_id(upstream: &str) -> String {
|
||||
let trimmed = upstream.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
format!("chatcmpl-{}", crate::admin::random_token(12))
|
||||
}
|
||||
|
||||
async fn handler(
|
||||
State(state): State<SharedState>,
|
||||
Json(req): Json<OpenAiChatRequest>,
|
||||
acct: Option<axum::Extension<super::AccountTag>>,
|
||||
// Taken as raw bytes rather than `Json<T>`: axum's extractor rejection is a
|
||||
// plain-text 422 that OpenAI clients cannot parse, and a client that cannot
|
||||
// parse the failure has no answer to show — it just hangs.
|
||||
body: axum::body::Bytes,
|
||||
) -> Result<Response> {
|
||||
// hot protocol toggle: if the adapter is disabled in config — return 404
|
||||
if !state.live().cfg.protocols.openai_chat {
|
||||
@@ -83,68 +212,95 @@ async fn handler(
|
||||
"openai_chat protocol is disabled".into(),
|
||||
));
|
||||
}
|
||||
let req: OpenAiChatRequest = serde_json::from_slice(&body)
|
||||
.map_err(|e| GatewayError::InvalidRequest(format!("invalid request body: {e}")))?;
|
||||
|
||||
if req.messages.is_empty() {
|
||||
return Err(GatewayError::InvalidRequest(
|
||||
"messages must not be empty".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Echoed back verbatim: OpenAI returns the model the caller asked for, and
|
||||
// clients (Open WebUI among them) match the reply against their own id. The
|
||||
// model that actually answered travels in `cortiq.selected_model` and the
|
||||
// `X-Cortiq-Selected-Model` header.
|
||||
let requested_model = req
|
||||
.model
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|m| !m.is_empty())
|
||||
.unwrap_or("cortiq-auto")
|
||||
.to_string();
|
||||
let stream = req.stream.unwrap_or(false);
|
||||
|
||||
let canonical = ChatRequest {
|
||||
routing: parse_routing(&req.model),
|
||||
routing: parse_routing(&requested_model),
|
||||
messages: req.messages,
|
||||
tools: req.tools,
|
||||
tools: req.tools.unwrap_or_default(),
|
||||
params: GenParams {
|
||||
temperature: req.temperature,
|
||||
max_tokens: req.max_tokens,
|
||||
max_tokens: req.max_tokens.or(req.max_completion_tokens),
|
||||
top_p: req.top_p,
|
||||
think_budget: req.think_budget.or(req.reasoning_effort),
|
||||
think_budget: req
|
||||
.think_budget
|
||||
.or_else(|| req.reasoning_effort.as_ref().and_then(Effort::to_budget)),
|
||||
stop: Vec::new(),
|
||||
passthrough: req.rest,
|
||||
passthrough: sanitize_passthrough(req.rest),
|
||||
},
|
||||
stream: req.stream,
|
||||
stream,
|
||||
meta: RequestMeta {
|
||||
protocol: "openai_chat".into(),
|
||||
account: acct.map(|e| e.0 .0.clone()).unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
// streaming: forward provider SSE chunks verbatim with X-Cortiq-* headers
|
||||
if req.stream {
|
||||
let (info, stream) = state.pipeline.run_stream(canonical, &state).await?;
|
||||
let mut headers = cortiq_headers(&info);
|
||||
headers.insert(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
axum::http::HeaderValue::from_static("text/event-stream"),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::CACHE_CONTROL,
|
||||
axum::http::HeaderValue::from_static("no-cache"),
|
||||
);
|
||||
return Ok((headers, axum::body::Body::from_stream(stream)).into_response());
|
||||
// streaming: provider SSE is re-normalised into strict OpenAI chunks
|
||||
if stream {
|
||||
let (info, raw) = state.pipeline.run_stream(canonical, &state).await?;
|
||||
let headers = sse_headers(&info);
|
||||
let normalized = normalize_sse(raw, requested_model);
|
||||
return Ok((headers, axum::body::Body::from_stream(normalized)).into_response());
|
||||
}
|
||||
|
||||
let resp = state.pipeline.run(canonical, &state).await?;
|
||||
let headers = cortiq_headers(&resp.cortiq);
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
"id": resp.id,
|
||||
"object": "chat.completion",
|
||||
"created": std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
"model": resp.model_used,
|
||||
"choices": resp.choices.iter().map(|c| {
|
||||
let choices: Vec<serde_json::Value> = resp
|
||||
.choices
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let mut message = serde_json::Map::new();
|
||||
message.insert("role".into(), serde_json::json!(c.message.role));
|
||||
message.insert("content".into(), serde_json::json!(c.message.content));
|
||||
// Absent tool calls are omitted entirely — `"tool_calls": null` is
|
||||
// not in the schema and trips strict clients.
|
||||
if !c.message.tool_calls.is_empty() {
|
||||
message.insert(
|
||||
"tool_calls".into(),
|
||||
serde_json::Value::Array(c.message.tool_calls.clone()),
|
||||
);
|
||||
}
|
||||
let finish = if c.message.tool_calls.is_empty() {
|
||||
normalize_finish_reason(&c.finish_reason)
|
||||
} else {
|
||||
"tool_calls"
|
||||
};
|
||||
serde_json::json!({
|
||||
"index": c.index,
|
||||
"message": {
|
||||
"role": c.message.role,
|
||||
"content": c.message.content,
|
||||
"tool_calls": if c.message.tool_calls.is_empty() { serde_json::Value::Null } else { serde_json::Value::Array(c.message.tool_calls.clone()) },
|
||||
},
|
||||
"finish_reason": c.finish_reason,
|
||||
"message": serde_json::Value::Object(message),
|
||||
"finish_reason": finish,
|
||||
})
|
||||
}).collect::<Vec<_>>(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
"id": response_id(&resp.id),
|
||||
"object": "chat.completion",
|
||||
"created": now_secs(),
|
||||
"model": requested_model,
|
||||
"choices": choices,
|
||||
"usage": {
|
||||
"prompt_tokens": resp.usage.prompt_tokens,
|
||||
"completion_tokens": resp.usage.completion_tokens,
|
||||
@@ -153,7 +309,7 @@ async fn handler(
|
||||
});
|
||||
|
||||
if state.live().cfg.cortiq.echo {
|
||||
body["cortiq"] = serde_json::json!({
|
||||
let mut echo = serde_json::json!({
|
||||
"task_label": resp.cortiq.task_label,
|
||||
"complexity": {
|
||||
"score": resp.cortiq.complexity_score,
|
||||
@@ -161,10 +317,233 @@ async fn handler(
|
||||
},
|
||||
"selected_model": resp.cortiq.selected_model,
|
||||
"route_source": resp.cortiq.route_source,
|
||||
"router_request_id": resp.cortiq.router_request_id,
|
||||
"cost_usd": resp.cortiq.cost_usd,
|
||||
});
|
||||
if let Some(rid) = &resp.cortiq.router_request_id {
|
||||
echo["router_request_id"] = serde_json::json!(rid);
|
||||
}
|
||||
if resp.cortiq.tools_dropped {
|
||||
echo["tools_dropped"] = serde_json::json!(true);
|
||||
}
|
||||
body["cortiq"] = echo;
|
||||
}
|
||||
|
||||
Ok((headers, Json(body)).into_response())
|
||||
}
|
||||
|
||||
/// Rewrite an upstream SSE stream into strict OpenAI `chat.completion.chunk`
|
||||
/// events.
|
||||
///
|
||||
/// Every chunk is reissued with an id, `created`, and the *requested* model id;
|
||||
/// `message`-shaped chunks are converted to `delta`; stop reasons are mapped to
|
||||
/// the spec's closed set; empty deltas are dropped. The stream is guaranteed to
|
||||
/// open with a `role` delta, to carry exactly one terminal `finish_reason`, and
|
||||
/// to end with `data: [DONE]` — even when the upstream dies mid-flight, in
|
||||
/// which case the failure is delivered as an SSE `error` event so the client
|
||||
/// shows a message instead of waiting forever.
|
||||
fn normalize_sse(
|
||||
stream: crate::providers::ChatStream,
|
||||
model: String,
|
||||
) -> impl futures::Stream<Item = Result<bytes::Bytes>> + Send + 'static {
|
||||
use futures::StreamExt;
|
||||
async_stream::stream! {
|
||||
let fallback_id = response_id("");
|
||||
let created = now_secs();
|
||||
let mut buf = String::new();
|
||||
let mut id: Option<String> = None;
|
||||
// Which choice indices already carried a `role` delta (n > 1 gets one each).
|
||||
let mut roled: std::collections::HashSet<u64> = std::collections::HashSet::new();
|
||||
let mut sent_finish = false;
|
||||
let mut failure: Option<String> = None;
|
||||
|
||||
// Emit one normalised event; `None` means "nothing worth sending".
|
||||
macro_rules! chunk {
|
||||
($choices:expr, $extra:expr) => {{
|
||||
let mut evt = serde_json::json!({
|
||||
"id": id.clone().unwrap_or_else(|| fallback_id.clone()),
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": model,
|
||||
"choices": $choices,
|
||||
});
|
||||
for (k, v) in $extra {
|
||||
evt[k] = v;
|
||||
}
|
||||
bytes::Bytes::from(format!("data: {evt}\n\n"))
|
||||
}};
|
||||
}
|
||||
|
||||
futures::pin_mut!(stream);
|
||||
'outer: while let Some(item) = stream.next().await {
|
||||
let bytes = match item {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
failure = Some(e.to_string());
|
||||
break 'outer;
|
||||
}
|
||||
};
|
||||
buf.push_str(&String::from_utf8_lossy(&bytes));
|
||||
|
||||
while let Some(idx) = buf.find("\n\n") {
|
||||
let event: String = buf.drain(..idx + 2).collect();
|
||||
for line in event.lines() {
|
||||
let Some(data) = line.trim_start().strip_prefix("data:") else { continue };
|
||||
let data = data.trim();
|
||||
if data.is_empty() || data == "[DONE]" {
|
||||
continue;
|
||||
}
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(data) else { continue };
|
||||
|
||||
// An upstream error delivered inside the stream: pass it on
|
||||
// and stop — the client needs to see it, not time out.
|
||||
if let Some(err) = v.get("error").filter(|e| !e.is_null()) {
|
||||
failure = Some(err.to_string());
|
||||
break 'outer;
|
||||
}
|
||||
|
||||
if id.is_none() {
|
||||
id = v["id"].as_str().filter(|s| !s.is_empty()).map(str::to_string);
|
||||
}
|
||||
|
||||
let mut choices = Vec::new();
|
||||
if let Some(arr) = v["choices"].as_array() {
|
||||
for (i, c) in arr.iter().enumerate() {
|
||||
// Some upstreams send a full `message` per chunk
|
||||
// instead of a `delta`; both mean the same thing here.
|
||||
let src = if c["delta"].is_object() { &c["delta"] } else { &c["message"] };
|
||||
let index = c["index"].as_u64().unwrap_or(i as u64);
|
||||
let mut delta = serde_json::Map::new();
|
||||
if roled.insert(index) {
|
||||
delta.insert("role".into(), serde_json::json!("assistant"));
|
||||
}
|
||||
if let Some(text) = src["content"].as_str() {
|
||||
if !text.is_empty() {
|
||||
delta.insert("content".into(), serde_json::json!(text));
|
||||
}
|
||||
}
|
||||
// Reasoning traces and citations are not in the base
|
||||
// schema but every client that understands them
|
||||
// ignores them safely, so they are worth keeping.
|
||||
for key in ["reasoning_content", "reasoning", "thinking", "refusal", "annotations"] {
|
||||
if let Some(val) = src.get(key).filter(|v| !v.is_null()) {
|
||||
delta.insert(key.into(), val.clone());
|
||||
}
|
||||
}
|
||||
if let Some(tc) = src["tool_calls"].as_array().filter(|a| !a.is_empty()) {
|
||||
delta.insert("tool_calls".into(), serde_json::Value::Array(tc.clone()));
|
||||
}
|
||||
|
||||
let finish = c["finish_reason"].as_str().map(normalize_finish_reason);
|
||||
if finish.is_none() && delta.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut choice = serde_json::json!({
|
||||
"index": index,
|
||||
"delta": serde_json::Value::Object(delta),
|
||||
});
|
||||
if let Some(f) = finish {
|
||||
choice["finish_reason"] = serde_json::json!(f);
|
||||
sent_finish = true;
|
||||
}
|
||||
choices.push(choice);
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal metadata rides on a choice-less chunk, exactly
|
||||
// as OpenAI ships `usage` when `stream_options` asks for it.
|
||||
let mut extra: Vec<(&str, serde_json::Value)> = Vec::new();
|
||||
if let Some(u) = v.get("usage").filter(|u| !u.is_null()) {
|
||||
extra.push(("usage", u.clone()));
|
||||
}
|
||||
for key in ["cortiq_cost_usd", "cortiq_estimated"] {
|
||||
if let Some(val) = v.get(key).filter(|v| !v.is_null()) {
|
||||
extra.push((key, val.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
if !choices.is_empty() || !extra.is_empty() {
|
||||
yield Ok(chunk!(choices, extra));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(message) = failure {
|
||||
let evt = serde_json::json!({
|
||||
"error": { "message": message, "type": "upstream_unavailable", "code": "stream_failed" }
|
||||
});
|
||||
yield Ok(bytes::Bytes::from(format!("data: {evt}\n\n")));
|
||||
}
|
||||
|
||||
// Close the stream properly whatever the upstream did: a client that
|
||||
// never sees a stop reason and a [DONE] keeps its spinner turning.
|
||||
if !sent_finish {
|
||||
let mut delta = serde_json::Map::new();
|
||||
if roled.insert(0) {
|
||||
delta.insert("role".into(), serde_json::json!("assistant"));
|
||||
}
|
||||
let choices = vec![serde_json::json!({
|
||||
"index": 0,
|
||||
"delta": serde_json::Value::Object(delta),
|
||||
"finish_reason": "stop",
|
||||
})];
|
||||
let extra: Vec<(&str, serde_json::Value)> = Vec::new();
|
||||
yield Ok(chunk!(choices, extra));
|
||||
}
|
||||
yield Ok(bytes::Bytes::from("data: [DONE]\n\n"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn content_parts_and_null_are_accepted() {
|
||||
let body = serde_json::json!({
|
||||
"model": "cortiq-auto",
|
||||
"messages": [
|
||||
{"role": "user", "content": [{"type": "text", "text": "hello "}, {"type": "image_url", "image_url": {"url": "x"}}, {"type": "text", "text": "world"}]},
|
||||
{"role": "assistant", "content": null},
|
||||
{"role": "tool", "content": "42", "tool_call_id": "call_1"}
|
||||
],
|
||||
"tools": null,
|
||||
"reasoning_effort": "high",
|
||||
"chat_id": "owui-123"
|
||||
});
|
||||
let req: OpenAiChatRequest = serde_json::from_value(body).expect("must parse");
|
||||
assert_eq!(req.messages[0].content, "hello world");
|
||||
assert_eq!(req.messages[1].content, "");
|
||||
assert_eq!(req.messages[2].tool_call_id.as_deref(), Some("call_1"));
|
||||
assert!(req.tools.unwrap_or_default().is_empty());
|
||||
assert_eq!(
|
||||
req.reasoning_effort.as_ref().and_then(Effort::to_budget),
|
||||
Some(8192)
|
||||
);
|
||||
assert!(!sanitize_passthrough(req.rest).contains_key("chat_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_reasons_map_to_the_openai_set() {
|
||||
assert_eq!(normalize_finish_reason("max_tokens"), "length");
|
||||
assert_eq!(normalize_finish_reason("eos"), "stop");
|
||||
assert_eq!(normalize_finish_reason("tool_use"), "tool_calls");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_is_closed_even_when_the_upstream_stops_early() {
|
||||
use futures::StreamExt;
|
||||
let upstream = futures::stream::iter(vec![Ok(bytes::Bytes::from(
|
||||
"data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\n",
|
||||
))]);
|
||||
let out: Vec<String> = normalize_sse(Box::pin(upstream), "cortiq-auto".into())
|
||||
.map(|b| String::from_utf8_lossy(&b.unwrap()).to_string())
|
||||
.collect()
|
||||
.await;
|
||||
let joined = out.join("");
|
||||
assert!(joined.contains("\"role\":\"assistant\""));
|
||||
assert!(joined.contains("\"model\":\"cortiq-auto\""));
|
||||
assert!(joined.contains("\"finish_reason\":\"stop\""));
|
||||
assert!(joined.trim_end().ends_with("data: [DONE]"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use crate::error::{GatewayError, Result};
|
||||
use crate::model::{ChatRequest, GenParams, Message, RequestMeta, RoutingDirective};
|
||||
use crate::protocols::openai_chat::cortiq_headers;
|
||||
use crate::protocols::openai_chat::{cortiq_headers, normalize_finish_reason, sse_headers};
|
||||
use crate::state::SharedState;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{extract::State, routing::post, Json, Router};
|
||||
@@ -56,6 +56,7 @@ fn prompt_to_string(v: &serde_json::Value) -> String {
|
||||
|
||||
async fn handler(
|
||||
State(state): State<SharedState>,
|
||||
acct: Option<axum::Extension<super::AccountTag>>,
|
||||
Json(req): Json<CompletionsRequest>,
|
||||
) -> Result<Response> {
|
||||
if !state.live().cfg.protocols.openai_completions {
|
||||
@@ -70,12 +71,15 @@ async fn handler(
|
||||
));
|
||||
}
|
||||
|
||||
// Echoed back as-is: clients match the reply against the id they asked for.
|
||||
let requested_model = req.model.clone();
|
||||
let canonical = ChatRequest {
|
||||
routing: parse_routing(&req.model),
|
||||
messages: vec![Message {
|
||||
role: "user".into(),
|
||||
content: prompt,
|
||||
tool_calls: vec![],
|
||||
..Default::default()
|
||||
}],
|
||||
tools: vec![],
|
||||
params: GenParams {
|
||||
@@ -89,19 +93,15 @@ async fn handler(
|
||||
stream: req.stream,
|
||||
meta: RequestMeta {
|
||||
protocol: "openai_completions".into(),
|
||||
account: acct.map(|e| e.0 .0.clone()).unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
if req.stream {
|
||||
let (info, stream) = state.pipeline.run_stream(canonical, &state).await?;
|
||||
let mut headers = cortiq_headers(&info);
|
||||
headers.insert(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
axum::http::HeaderValue::from_static("text/event-stream"),
|
||||
);
|
||||
return Ok((
|
||||
headers,
|
||||
sse_headers(&info),
|
||||
axum::body::Body::from_stream(chat_to_completions_sse(stream)),
|
||||
)
|
||||
.into_response());
|
||||
@@ -113,11 +113,12 @@ async fn handler(
|
||||
.first()
|
||||
.map(|c| c.message.content.clone())
|
||||
.unwrap_or_default();
|
||||
let finish = resp
|
||||
.choices
|
||||
.first()
|
||||
.map(|c| c.finish_reason.clone())
|
||||
.unwrap_or_else(|| "stop".into());
|
||||
let finish = normalize_finish_reason(
|
||||
resp.choices
|
||||
.first()
|
||||
.map(|c| c.finish_reason.as_str())
|
||||
.unwrap_or("stop"),
|
||||
);
|
||||
let headers = cortiq_headers(&resp.cortiq);
|
||||
let body = serde_json::json!({
|
||||
"id": resp.id,
|
||||
@@ -126,7 +127,7 @@ async fn handler(
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
"model": resp.model_used,
|
||||
"model": requested_model,
|
||||
"choices": [{ "text": answer, "index": 0, "logprobs": serde_json::Value::Null, "finish_reason": finish }],
|
||||
"usage": {
|
||||
"prompt_tokens": resp.usage.prompt_tokens,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
//! Inbound OpenAI Models adapter: `GET /v1/models`.
|
||||
//! Lists the configured pool (plus the virtual `cortiq-auto`) for client discovery.
|
||||
//! Lists everything a client can address: the virtual `cortiq-auto`, the
|
||||
//! configured `[[models]]` pool, and the managed local `[[cmf.servers]]`.
|
||||
//!
|
||||
//! The managed CMF servers belong here because they are routable model ids like
|
||||
//! any other — omitting them left OpenAI clients (Open WebUI among them) unable
|
||||
//! to pin a local model even though the gateway would happily serve it.
|
||||
|
||||
use crate::error::{GatewayError, Result};
|
||||
use crate::state::SharedState;
|
||||
@@ -9,6 +14,13 @@ pub fn routes() -> Router<SharedState> {
|
||||
Router::new().route("/v1/models", get(handler))
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
async fn handler(State(state): State<SharedState>) -> Result<impl IntoResponse> {
|
||||
let live = state.live();
|
||||
if !live.cfg.protocols.openai_models {
|
||||
@@ -16,17 +28,51 @@ async fn handler(State(state): State<SharedState>) -> Result<impl IntoResponse>
|
||||
"openai_models protocol is disabled".into(),
|
||||
));
|
||||
}
|
||||
let created = now_secs();
|
||||
let mut data = vec![serde_json::json!({
|
||||
"id": "cortiq-auto",
|
||||
"object": "model",
|
||||
"created": created,
|
||||
"owned_by": "cortiq",
|
||||
})];
|
||||
let mut seen: std::collections::HashSet<String> =
|
||||
std::collections::HashSet::from(["cortiq-auto".to_string()]);
|
||||
|
||||
for m in &live.cfg.models {
|
||||
if !seen.insert(m.id.clone()) {
|
||||
continue;
|
||||
}
|
||||
data.push(serde_json::json!({
|
||||
"id": m.id,
|
||||
"object": "model",
|
||||
"created": created,
|
||||
"owned_by": m.provider,
|
||||
}));
|
||||
}
|
||||
|
||||
// Managed local CMF servers. Health is reported alongside (a non-standard
|
||||
// field clients ignore) rather than used as a filter: a large `.cmf` takes
|
||||
// minutes to load, and a model that vanishes from the list while it warms up
|
||||
// is worse than one marked "not ready yet".
|
||||
let cmf = state.cmf.status();
|
||||
for s in live.cfg.cmf.effective_servers() {
|
||||
if !seen.insert(s.id.clone()) {
|
||||
continue; // a static [[models]] entry with the same id wins
|
||||
}
|
||||
let status = cmf.servers.iter().find(|st| st.id == s.id);
|
||||
data.push(serde_json::json!({
|
||||
"id": s.id,
|
||||
"object": "model",
|
||||
"created": created,
|
||||
"owned_by": "cortiq-cmf",
|
||||
"cortiq": {
|
||||
"kind": "cmf",
|
||||
"local": true,
|
||||
"running": status.map(|st| st.running).unwrap_or(false),
|
||||
"healthy": status.map(|st| st.healthy).unwrap_or(false),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({ "object": "list", "data": data })))
|
||||
}
|
||||
|
||||
@@ -146,6 +146,7 @@ impl Provider for AnthropicProvider {
|
||||
role: "assistant".into(),
|
||||
content,
|
||||
tool_calls: vec![],
|
||||
..Default::default()
|
||||
},
|
||||
finish_reason: v["stop_reason"].as_str().unwrap_or("stop").to_string(),
|
||||
}],
|
||||
@@ -163,6 +164,7 @@ impl Provider for AnthropicProvider {
|
||||
router_request_id: None,
|
||||
cost_usd: 0.0,
|
||||
failover: false,
|
||||
tools_dropped: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -54,13 +54,11 @@ impl OpenAiProvider {
|
||||
if let Some(m) = req.params.max_tokens {
|
||||
body["max_tokens"] = m.into();
|
||||
}
|
||||
eprintln!("[to_wire] think_budget={:?}", req.params.think_budget);
|
||||
if let Some(tb) = req.params.think_budget {
|
||||
body["think_budget"] = tb.into();
|
||||
body["reasoning_effort"] = tb.into();
|
||||
// cortiq serve uses enable_thinking (bool): 0 = off, >0 = on
|
||||
body["enable_thinking"] = (tb > 0).into();
|
||||
eprintln!("[to_wire] SET enable_thinking={}", tb > 0);
|
||||
}
|
||||
if !req.tools.is_empty() {
|
||||
body["tools"] = serde_json::Value::Array(req.tools.clone());
|
||||
@@ -69,10 +67,6 @@ impl OpenAiProvider {
|
||||
for (k, v) in &req.params.passthrough {
|
||||
body[k] = v.clone();
|
||||
}
|
||||
eprintln!(
|
||||
"[to_wire] enable_thinking={:?}",
|
||||
body.get("enable_thinking")
|
||||
);
|
||||
body
|
||||
}
|
||||
}
|
||||
@@ -141,6 +135,7 @@ impl Provider for OpenAiProvider {
|
||||
role,
|
||||
content,
|
||||
tool_calls,
|
||||
..Default::default()
|
||||
},
|
||||
finish_reason: c["finish_reason"].as_str().unwrap_or("stop").to_string(),
|
||||
});
|
||||
@@ -172,6 +167,7 @@ impl Provider for OpenAiProvider {
|
||||
router_request_id: None,
|
||||
cost_usd: 0.0,
|
||||
failover: false,
|
||||
tools_dropped: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ impl Registry {
|
||||
price_out: 0.0,
|
||||
kind: "chat".into(),
|
||||
api_key_env: None,
|
||||
caps: Vec::new(),
|
||||
caps: s.caps.clone(),
|
||||
temperature: s.temperature,
|
||||
top_p: s.top_p,
|
||||
max_tokens: s.max_tokens,
|
||||
|
||||
@@ -66,6 +66,42 @@ impl Live {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed-window per-key rate limiter (minute buckets). Small and honest:
|
||||
/// at minute boundaries a burst can briefly see up to 2× the limit, which is
|
||||
/// fine for office-grade fairness and costs one HashMap lookup per request.
|
||||
#[derive(Default)]
|
||||
pub struct RateWindows {
|
||||
inner: std::sync::Mutex<std::collections::HashMap<String, (u64, u32)>>,
|
||||
}
|
||||
|
||||
impl RateWindows {
|
||||
pub fn allow(&self, key: &str, per_min: u32) -> bool {
|
||||
let minute = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
/ 60;
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
let e = g.entry(key.to_string()).or_insert((minute, 0));
|
||||
if e.0 != minute {
|
||||
*e = (minute, 0);
|
||||
}
|
||||
if e.1 >= per_min {
|
||||
return false;
|
||||
}
|
||||
e.1 += 1;
|
||||
true
|
||||
}
|
||||
/// Seconds until the current minute window rolls over.
|
||||
pub fn retry_after_secs(&self) -> u64 {
|
||||
60 - (std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
% 60)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
pub live: ArcSwap<Live>,
|
||||
pub stats: Arc<Stats>,
|
||||
@@ -85,6 +121,8 @@ pub struct AppState {
|
||||
/// Managed local CMF model server (install/update/spawn lifecycle + status).
|
||||
pub cmf: Arc<crate::cmf_runtime::CmfRuntime>,
|
||||
pub cache: Arc<SemanticCache>,
|
||||
/// Per-API-key fixed-window rate limiter.
|
||||
pub rate: RateWindows,
|
||||
pub secrets: SecretStore,
|
||||
pub config_path: String,
|
||||
pub pipeline: crate::pipeline::Pipeline,
|
||||
@@ -164,6 +202,7 @@ impl SharedState {
|
||||
admin_open_public: std::sync::atomic::AtomicBool::new(false),
|
||||
cmf,
|
||||
cache,
|
||||
rate: RateWindows::default(),
|
||||
secrets,
|
||||
config_path,
|
||||
pipeline: crate::pipeline::Pipeline::new(),
|
||||
|
||||
+112
-9
@@ -137,6 +137,11 @@ struct Inner {
|
||||
/// = folded + by_account_alltime, with no double counting: a record is
|
||||
/// either in the file or folded, never both.
|
||||
folded: HashMap<String, Totals>,
|
||||
/// Month-to-date spend per account ("YYYY-MM", usd) — budgets check this.
|
||||
month_spend: HashMap<String, (String, f64)>,
|
||||
/// Records with ts ≤ this are already reflected in the persisted
|
||||
/// month_spend (watermark against double counting on replay).
|
||||
month_upto_ts: u64,
|
||||
/// Appends since the last compaction size check.
|
||||
writes_since_check: u32,
|
||||
}
|
||||
@@ -193,6 +198,7 @@ impl Stats {
|
||||
.entry(rec.account.clone())
|
||||
.or_default()
|
||||
.apply(&rec);
|
||||
Self::apply_month(&mut inner, &rec);
|
||||
if rec.ts >= cutoff {
|
||||
Self::apply_aggregates(&mut inner, &rec, self.retention_secs);
|
||||
}
|
||||
@@ -200,6 +206,33 @@ impl Stats {
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_month(inner: &mut Inner, rec: &RequestRecord) {
|
||||
if rec.ts <= inner.month_upto_ts || rec.cost_usd <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let m = month_of(rec.ts);
|
||||
let e = inner
|
||||
.month_spend
|
||||
.entry(rec.account.clone())
|
||||
.or_insert((m.clone(), 0.0));
|
||||
if e.0 != m {
|
||||
*e = (m, 0.0);
|
||||
}
|
||||
e.1 += rec.cost_usd;
|
||||
}
|
||||
|
||||
/// Month-to-date spend for an account (budget checks).
|
||||
pub fn month_spend(&self, account: &str) -> f64 {
|
||||
let inner = self.inner.lock().unwrap();
|
||||
let m = month_of(now_secs());
|
||||
inner
|
||||
.month_spend
|
||||
.get(account)
|
||||
.filter(|(mm, _)| *mm == m)
|
||||
.map(|(_, v)| *v)
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn push_recent(inner: &mut Inner, rec: &RequestRecord, ring_size: usize) {
|
||||
inner.recent.push_back(rec.clone());
|
||||
while inner.recent.len() > ring_size {
|
||||
@@ -256,6 +289,7 @@ impl Stats {
|
||||
.entry(rec.account.clone())
|
||||
.or_default()
|
||||
.apply(&rec);
|
||||
Self::apply_month(&mut inner, &rec);
|
||||
inner.writes_since_check += 1;
|
||||
}
|
||||
if let Some(path) = &self.file {
|
||||
@@ -283,11 +317,42 @@ impl Stats {
|
||||
let Ok(content) = std::fs::read_to_string(&path) else {
|
||||
return;
|
||||
};
|
||||
if let Ok(m) = serde_json::from_str::<HashMap<String, Totals>>(&content) {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Sidecar {
|
||||
#[serde(default)]
|
||||
by_account: HashMap<String, Totals>,
|
||||
#[serde(default)]
|
||||
month_spend: HashMap<String, (String, f64)>,
|
||||
#[serde(default)]
|
||||
month_upto_ts: u64,
|
||||
}
|
||||
// new format {by_account, month_spend, month_upto_ts}; the original
|
||||
// file held the bare by_account map — accept both
|
||||
if let Ok(sc) = serde_json::from_str::<Sidecar>(&content) {
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
inner.folded = sc.by_account;
|
||||
inner.month_spend = sc.month_spend;
|
||||
inner.month_upto_ts = sc.month_upto_ts;
|
||||
} else if let Ok(m) = serde_json::from_str::<HashMap<String, Totals>>(&content) {
|
||||
self.inner.lock().unwrap().folded = m;
|
||||
}
|
||||
}
|
||||
|
||||
fn write_sidecar(&self, inner: &Inner) {
|
||||
let Some(tpath) = self.totals_path() else {
|
||||
return;
|
||||
};
|
||||
let payload = serde_json::json!({
|
||||
"by_account": inner.folded,
|
||||
"month_spend": inner.month_spend,
|
||||
"month_upto_ts": now_secs(),
|
||||
});
|
||||
let tmp = format!("{tpath}.tmp");
|
||||
if std::fs::write(&tmp, payload.to_string()).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, &tpath);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the JSONL bounded: once it outgrows `max_file_bytes`, records
|
||||
/// older than the retention window fold into the cumulative per-account
|
||||
/// sidecar and are dropped from the file (tmp + rename on both files, so
|
||||
@@ -305,6 +370,10 @@ impl Stats {
|
||||
return;
|
||||
}
|
||||
inner.writes_since_check = 0;
|
||||
// piggyback: persist month-to-date spend so budgets survive
|
||||
// restarts even between compactions
|
||||
inner.month_upto_ts = now_secs();
|
||||
self.write_sidecar(&inner);
|
||||
}
|
||||
}
|
||||
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
|
||||
@@ -359,14 +428,8 @@ impl Stats {
|
||||
}
|
||||
}
|
||||
// persist the folded totals FIRST, then swap the trimmed file in
|
||||
if let Some(tpath) = self.totals_path() {
|
||||
if let Ok(json) = serde_json::to_string(&inner.folded) {
|
||||
let tmp = format!("{tpath}.tmp");
|
||||
if std::fs::write(&tmp, json).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, &tpath);
|
||||
}
|
||||
}
|
||||
}
|
||||
inner.month_upto_ts = now_secs();
|
||||
self.write_sidecar(&inner);
|
||||
let tmp = format!("{path}.tmp");
|
||||
if std::fs::write(&tmp, kept).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, path);
|
||||
@@ -569,6 +632,46 @@ fn fill_bucket(b: &mut Bucket, r: &RequestRecord) {
|
||||
b.by_label.entry(r.task_label.clone()).or_default().apply(r);
|
||||
}
|
||||
|
||||
fn month_of(ts: u64) -> String {
|
||||
// календарный месяц UTC без внешних зависимостей: дни с эпохи → (год, месяц)
|
||||
let days = ts / 86_400;
|
||||
let mut year = 1970u64;
|
||||
let mut d = days;
|
||||
loop {
|
||||
let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
|
||||
let len = if leap { 366 } else { 365 };
|
||||
if d < len {
|
||||
break;
|
||||
}
|
||||
d -= len;
|
||||
year += 1;
|
||||
}
|
||||
let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
|
||||
let ml = [
|
||||
31,
|
||||
if leap { 29 } else { 28 },
|
||||
31,
|
||||
30,
|
||||
31,
|
||||
30,
|
||||
31,
|
||||
31,
|
||||
30,
|
||||
31,
|
||||
30,
|
||||
31,
|
||||
];
|
||||
let mut month = 1;
|
||||
for len in ml {
|
||||
if d < len {
|
||||
break;
|
||||
}
|
||||
d -= len;
|
||||
month += 1;
|
||||
}
|
||||
format!("{year:04}-{month:02}")
|
||||
}
|
||||
|
||||
fn avg(sum: u64, n: u64) -> u64 {
|
||||
sum.checked_div(n).unwrap_or(0)
|
||||
}
|
||||
|
||||
Vendored
+11
-3
@@ -599,17 +599,20 @@ body.modal-open { overflow: hidden; }
|
||||
|
||||
/* Complexity bands slider */
|
||||
.bands-wrap { margin: 4px 0 16px; }
|
||||
.bands-track { position: relative; height: 14px; border-radius: 8px; background: var(--panel-2, #23252c); margin: 26px 8px 10px; }
|
||||
/* one-line command chip (install commands etc.) — theme-aware */
|
||||
.cmd-chip { background: var(--panel); border: 1px solid var(--border); color: var(--text); padding: 2px 8px; border-radius: 6px; }
|
||||
|
||||
.bands-track { position: relative; height: 14px; border-radius: 8px; background: var(--border); margin: 26px 8px 10px; }
|
||||
.bands-track.disabled { opacity: .45; pointer-events: none; }
|
||||
.bands-zone { position: absolute; top: 0; bottom: 0; }
|
||||
.bands-zone.z-low { left: 0; background: color-mix(in srgb, var(--good) 45%, transparent); border-radius: 8px 0 0 8px; }
|
||||
.bands-zone.z-med { background: color-mix(in srgb, var(--warn, #f59e0b) 55%, transparent); }
|
||||
.bands-zone.z-high { background: color-mix(in srgb, #dc2626 65%, transparent); border-radius: 0 8px 8px 0; }
|
||||
.bands-handle { position: absolute; top: -5px; width: 24px; height: 24px; margin-left: -12px; border-radius: 50%;
|
||||
background: var(--fg, #fff); border: 3px solid var(--accent, #6c6cf5); cursor: grab; touch-action: none; z-index: 2; }
|
||||
background: #fff; border: 3px solid var(--accent); cursor: grab; touch-action: none; z-index: 2; }
|
||||
.bands-handle:active { cursor: grabbing; }
|
||||
.bands-val { position: absolute; top: -24px; left: 50%; transform: translateX(-50%); font-size: 11.5px;
|
||||
font-family: var(--mono, monospace); color: var(--fg); background: var(--panel, #1b1d23); padding: 1px 6px;
|
||||
font-family: var(--mono, monospace); color: var(--text); background: var(--panel); padding: 1px 6px;
|
||||
border-radius: 6px; border: 1px solid var(--border, #333); white-space: nowrap; }
|
||||
.bands-val.raised { top: -46px; }
|
||||
.bands-legend { display: flex; gap: 8px; margin: 6px 8px 0; flex-wrap: wrap; }
|
||||
@@ -703,3 +706,8 @@ tbody tr:last-child { animation: bm-in .25s ease; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sidebar, .mobile-nav-backdrop { transition: none; }
|
||||
}
|
||||
|
||||
/* full-screen farewell after "stop everything & power off" */
|
||||
.shutdown-screen { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; gap: 12px; text-align: center; padding: 24px; }
|
||||
.shutdown-screen .shutdown-icon { font-size: 64px; color: var(--bad); }
|
||||
.shutdown-screen .hint { max-width: 480px; }
|
||||
|
||||
Vendored
+2
@@ -74,6 +74,7 @@ export const api = {
|
||||
// protocols
|
||||
getProtocols: () => j("GET", "/protocols"),
|
||||
harnesses: () => j("GET", "/harnesses"),
|
||||
balance: () => j("GET", "/balance"),
|
||||
putProtocols: (b) => j("PUT", "/protocols", b),
|
||||
|
||||
// settings
|
||||
@@ -133,6 +134,7 @@ export const api = {
|
||||
logs: (limit = 300) => j("GET", "/logs?limit=" + limit),
|
||||
clearLogs: () => j("DELETE", "/logs"),
|
||||
restart: () => j("POST", "/restart"),
|
||||
shutdown: () => j("POST", "/shutdown"),
|
||||
benchStart: (model) => j("POST", "/bench", { model }),
|
||||
benchList: () => j("GET", "/bench"),
|
||||
|
||||
|
||||
Vendored
+18
-2
@@ -536,9 +536,13 @@ export default {
|
||||
"keys.snip.withKey": "Die Beispiele unten enthalten bereits den gerade erstellten Schlüssel.",
|
||||
"keys.snip.openMode": "Keine API-Schlüssel konfiguriert — das Gateway akzeptiert jeden Wert.",
|
||||
"keys.snip.keyPh": "dein API-Schlüssel",
|
||||
"settings.restart": "Gateway neu starten",
|
||||
"settings.restartConfirm": "Gateway neu starten? Laufende Anfragen brechen ab; lokale Modellserver starten ebenfalls neu.",
|
||||
"settings.restart": "Alles neu starten",
|
||||
"settings.restartConfirm": "Gateway und alle lokalen Modellserver neu starten? Laufende Anfragen werden abgebrochen.",
|
||||
"settings.restarting": "Neustart — die Seite lädt neu, sobald das Gateway wieder da ist…",
|
||||
"settings.shutdown": "Alles stoppen & ausschalten",
|
||||
"settings.shutdownConfirm": "Alle lokalen Modellserver stoppen und das Gateway ausschalten? API und dieses Panel antworten erst wieder, wenn Sie das Gateway neu starten.",
|
||||
"settings.shutdownDone": "Das Gateway ist ausgeschaltet.",
|
||||
"settings.shutdownHint": "Alle lokalen Modellserver sind gestoppt, der Prozess wurde beendet. Starten Sie das Gateway auf dem Server neu, um das Panel zurückzubekommen.",
|
||||
"bench.title": "Geschwindigkeit lokaler Modelle",
|
||||
"bench.hint": "Führt `cortiq bench` auf dieser Maschine aus — echte tok/s, ein Modell nach dem anderen.",
|
||||
"bench.run": "Messen",
|
||||
@@ -634,4 +638,16 @@ export default {
|
||||
"tier.low": "leicht",
|
||||
"tier.medium": "mittel",
|
||||
"tier.high": "schwer",
|
||||
"bm.scoreHint": "Der kontinuierliche Komplexitätsscore des Routers (0..1) — die Grenzregler auf der Routing-Seite teilen ihn in Stufen.",
|
||||
"settings.cache.noEmbed": "Der Cache ist aktiviert, hat aber KEIN Embedding-Modell — er tut nichts. Fügen Sie dem Pool ein Modell mit kind = \"embedding\" hinzu (z. B. OpenAI text-embedding) oder wählen Sie unten eines.",
|
||||
"settings.cache.entries": "Einträge",
|
||||
"settings.cache.hits": "Treffer",
|
||||
"settings.cache.saved": "gespart",
|
||||
"keys.form.budget": "Monatsbudget, $ (0 = unbegrenzt)",
|
||||
"keys.form.budgetHint": "Überschreiten die Monatsausgaben des Kontos die Grenze, werden Anfragen mit 429 insufficient_quota abgelehnt.",
|
||||
"keys.budgetOf": "Budget {b}/Mon.",
|
||||
"dash.kpi.orBalance": "OpenRouter-Guthaben",
|
||||
"keys.h.install": "Installieren:",
|
||||
"keys.snip.keyInputPh": "API-Schlüssel einfügen",
|
||||
"keys.snip.apply": "Schlüssel einsetzen",
|
||||
};
|
||||
|
||||
Vendored
+18
-2
@@ -529,9 +529,13 @@ export default {
|
||||
"keys.snip.withKey": "Snippets below include the key you just created.",
|
||||
"keys.snip.openMode": "No API keys are configured — the gateway accepts any key value.",
|
||||
"keys.snip.keyPh": "your API key",
|
||||
"settings.restart": "Restart gateway",
|
||||
"settings.restartConfirm": "Restart the gateway? Requests in flight will be dropped; local model servers restart too.",
|
||||
"settings.restart": "Restart everything",
|
||||
"settings.restartConfirm": "Restart the gateway and all local model servers? Requests in flight will be dropped.",
|
||||
"settings.restarting": "Restarting — the page reloads when the gateway is back…",
|
||||
"settings.shutdown": "Stop everything & power off",
|
||||
"settings.shutdownConfirm": "Stop all local model servers and power the gateway off? The API and this panel stop answering until you start the gateway again.",
|
||||
"settings.shutdownDone": "The gateway is off.",
|
||||
"settings.shutdownHint": "All local model servers are stopped and the process has exited. Start the gateway again on the server to bring the panel back.",
|
||||
"bench.title": "Local model speed",
|
||||
"bench.hint": "Runs `cortiq bench` on this machine — real tok/s, one model at a time.",
|
||||
"bench.run": "Measure",
|
||||
@@ -627,4 +631,16 @@ export default {
|
||||
"tier.low": "low",
|
||||
"tier.medium": "medium",
|
||||
"tier.high": "high",
|
||||
"bm.scoreHint": "The router's continuous complexity score (0..1) — the boundary sliders on the Routing page cut it into tiers.",
|
||||
"settings.cache.noEmbed": "The cache is enabled but has NO embedding model to work with — it is doing nothing. Add a model with kind = \"embedding\" to the pool (e.g. an OpenAI text-embedding model) or pick one below.",
|
||||
"settings.cache.entries": "entries",
|
||||
"settings.cache.hits": "hits",
|
||||
"settings.cache.saved": "saved",
|
||||
"keys.form.budget": "Monthly budget, $ (0 = unlimited)",
|
||||
"keys.form.budgetHint": "Requests are rejected with 429 insufficient_quota once the account's month-to-date spend crosses this.",
|
||||
"keys.budgetOf": "budget {b}/mo",
|
||||
"dash.kpi.orBalance": "OpenRouter balance",
|
||||
"keys.h.install": "Install:",
|
||||
"keys.snip.keyInputPh": "paste your API key",
|
||||
"keys.snip.apply": "Insert key",
|
||||
};
|
||||
|
||||
Vendored
+18
-2
@@ -536,9 +536,13 @@ export default {
|
||||
"keys.snip.withKey": "Los ejemplos de abajo ya incluyen la clave que acabas de crear.",
|
||||
"keys.snip.openMode": "No hay claves API configuradas — la pasarela acepta cualquier valor.",
|
||||
"keys.snip.keyPh": "tu clave API",
|
||||
"settings.restart": "Reiniciar la pasarela",
|
||||
"settings.restartConfirm": "¿Reiniciar la pasarela? Las peticiones en curso se cortarán; los servidores de modelos locales también se reinician.",
|
||||
"settings.restart": "Reiniciar todo",
|
||||
"settings.restartConfirm": "¿Reiniciar la pasarela y todos los servidores de modelos locales? Las solicitudes en curso se interrumpirán.",
|
||||
"settings.restarting": "Reiniciando — la página se recargará cuando la pasarela vuelva…",
|
||||
"settings.shutdown": "Detener todo y apagar",
|
||||
"settings.shutdownConfirm": "¿Detener todos los servidores de modelos locales y apagar la pasarela? La API y este panel dejarán de responder hasta que vuelva a iniciar la pasarela.",
|
||||
"settings.shutdownDone": "La pasarela está apagada.",
|
||||
"settings.shutdownHint": "Todos los servidores de modelos locales están detenidos y el proceso ha terminado. Inicie de nuevo la pasarela en el servidor para recuperar el panel.",
|
||||
"bench.title": "Velocidad de modelos locales",
|
||||
"bench.hint": "Ejecuta `cortiq bench` en esta máquina — tok/s reales, un modelo a la vez.",
|
||||
"bench.run": "Medir",
|
||||
@@ -634,4 +638,16 @@ export default {
|
||||
"tier.low": "fácil",
|
||||
"tier.medium": "medio",
|
||||
"tier.high": "difícil",
|
||||
"bm.scoreHint": "La puntuación continua de complejidad del router (0..1) — los deslizadores de límites de la página Enrutado la dividen en niveles.",
|
||||
"settings.cache.noEmbed": "La caché está activada pero NO tiene modelo de embeddings — no hace nada. Añade al pool un modelo con kind = \"embedding\" (p. ej. OpenAI text-embedding) o elige uno abajo.",
|
||||
"settings.cache.entries": "entradas",
|
||||
"settings.cache.hits": "aciertos",
|
||||
"settings.cache.saved": "ahorrado",
|
||||
"keys.form.budget": "Presupuesto mensual, $ (0 = sin límite)",
|
||||
"keys.form.budgetHint": "Cuando el gasto del mes supera el techo, las peticiones se rechazan con 429 insufficient_quota.",
|
||||
"keys.budgetOf": "presupuesto {b}/mes",
|
||||
"dash.kpi.orBalance": "Saldo OpenRouter",
|
||||
"keys.h.install": "Instalar:",
|
||||
"keys.snip.keyInputPh": "pega tu clave API",
|
||||
"keys.snip.apply": "Insertar clave",
|
||||
};
|
||||
|
||||
Vendored
+18
-2
@@ -536,9 +536,13 @@ export default {
|
||||
"keys.snip.withKey": "Les exemples ci-dessous incluent la clé que vous venez de créer.",
|
||||
"keys.snip.openMode": "Aucune clé API configurée — la passerelle accepte n'importe quelle valeur.",
|
||||
"keys.snip.keyPh": "votre clé API",
|
||||
"settings.restart": "Redémarrer la passerelle",
|
||||
"settings.restartConfirm": "Redémarrer la passerelle ? Les requêtes en cours seront coupées ; les serveurs de modèles locaux redémarrent aussi.",
|
||||
"settings.restart": "Tout redémarrer",
|
||||
"settings.restartConfirm": "Redémarrer la passerelle et tous les serveurs de modèles locaux ? Les requêtes en cours seront interrompues.",
|
||||
"settings.restarting": "Redémarrage — la page se recharge dès que la passerelle revient…",
|
||||
"settings.shutdown": "Tout arrêter et éteindre",
|
||||
"settings.shutdownConfirm": "Arrêter tous les serveurs de modèles locaux et éteindre la passerelle ? L'API et ce panneau ne répondront plus tant que vous n'aurez pas relancé la passerelle.",
|
||||
"settings.shutdownDone": "La passerelle est éteinte.",
|
||||
"settings.shutdownHint": "Tous les serveurs de modèles locaux sont arrêtés et le processus est terminé. Relancez la passerelle sur le serveur pour retrouver le panneau.",
|
||||
"bench.title": "Vitesse des modèles locaux",
|
||||
"bench.hint": "Exécute `cortiq bench` sur cette machine — de vrais tok/s, un modèle à la fois.",
|
||||
"bench.run": "Mesurer",
|
||||
@@ -634,4 +638,16 @@ export default {
|
||||
"tier.low": "facile",
|
||||
"tier.medium": "moyen",
|
||||
"tier.high": "difficile",
|
||||
"bm.scoreHint": "Le score de complexité continu du routeur (0..1) — les curseurs de limites de la page Routage le découpent en niveaux.",
|
||||
"settings.cache.noEmbed": "Le cache est activé mais n'a AUCUN modèle d'embedding — il ne fait rien. Ajoutez au pool un modèle avec kind = \"embedding\" (p. ex. OpenAI text-embedding) ou choisissez-en un ci-dessous.",
|
||||
"settings.cache.entries": "entrées",
|
||||
"settings.cache.hits": "hits",
|
||||
"settings.cache.saved": "économisé",
|
||||
"keys.form.budget": "Budget mensuel, $ (0 = illimité)",
|
||||
"keys.form.budgetHint": "Quand les dépenses du mois dépassent le plafond, les requêtes sont rejetées avec 429 insufficient_quota.",
|
||||
"keys.budgetOf": "budget {b}/mois",
|
||||
"dash.kpi.orBalance": "Solde OpenRouter",
|
||||
"keys.h.install": "Installer :",
|
||||
"keys.snip.keyInputPh": "collez votre clé API",
|
||||
"keys.snip.apply": "Insérer la clé",
|
||||
};
|
||||
|
||||
Vendored
+20
-4
@@ -25,7 +25,7 @@ export default {
|
||||
"common.all": "все",
|
||||
|
||||
// nav
|
||||
"nav.dashboard": "Дашборд",
|
||||
"nav.dashboard": "Рабочий стол",
|
||||
"nav.models": "Модели",
|
||||
"nav.import": "Импорт модели",
|
||||
"nav.routing": "Роутинг",
|
||||
@@ -56,7 +56,7 @@ export default {
|
||||
"login.failed": "Неверный токен администратора.",
|
||||
|
||||
// dashboard
|
||||
"dash.title": "Дашборд",
|
||||
"dash.title": "Рабочий стол",
|
||||
"dash.subtitle": "Трафик в реальном времени, расходы и аналитика роутинга по пулу моделей.",
|
||||
"dash.clear": "Очистить логи",
|
||||
"dash.clearConfirm": "Очистить всю статистику и логи? Отменить нельзя.",
|
||||
@@ -528,9 +528,13 @@ export default {
|
||||
"keys.snip.withKey": "В примерах ниже уже подставлен только что созданный ключ.",
|
||||
"keys.snip.openMode": "API-ключи не настроены — шлюз принимает любое значение ключа.",
|
||||
"keys.snip.keyPh": "ваш API-ключ",
|
||||
"settings.restart": "Перезапустить шлюз",
|
||||
"settings.restartConfirm": "Перезапустить шлюз? Текущие запросы оборвутся; серверы локальных моделей тоже перезапустятся.",
|
||||
"settings.restart": "Перезапустить всё",
|
||||
"settings.restartConfirm": "Перезапустить шлюз и все серверы локальных моделей? Текущие запросы оборвутся.",
|
||||
"settings.restarting": "Перезапуск — страница обновится, когда шлюз поднимется…",
|
||||
"settings.shutdown": "Остановить всё и выключить",
|
||||
"settings.shutdownConfirm": "Остановить все серверы локальных моделей и выключить шлюз? API и эта панель перестанут отвечать, пока вы не запустите шлюз заново.",
|
||||
"settings.shutdownDone": "Шлюз выключен.",
|
||||
"settings.shutdownHint": "Все серверы локальных моделей остановлены, процесс завершён. Запустите шлюз на сервере заново, чтобы вернуть панель.",
|
||||
"bench.title": "Скорость локальных моделей",
|
||||
"bench.hint": "Запускает `cortiq bench` на этой машине — реальные tok/s, по одной модели за раз.",
|
||||
"bench.run": "Замерить",
|
||||
@@ -626,4 +630,16 @@ export default {
|
||||
"tier.low": "лёгкий",
|
||||
"tier.medium": "средний",
|
||||
"tier.high": "сложный",
|
||||
"bm.scoreHint": "Непрерывный коэффициент сложности роутера (0..1) — ползунки границ на вкладке Роутинг нарезают его на уровни.",
|
||||
"settings.cache.noEmbed": "Кэш включён, но embedding-модели нет — он ничего не делает. Добавьте в пул модель с kind = \"embedding\" (например, OpenAI text-embedding) или выберите её ниже.",
|
||||
"settings.cache.entries": "записей",
|
||||
"settings.cache.hits": "попаданий",
|
||||
"settings.cache.saved": "сэкономлено",
|
||||
"keys.form.budget": "Бюджет в месяц, $ (0 = без лимита)",
|
||||
"keys.form.budgetHint": "Когда траты аккаунта за месяц превысят потолок, запросы отклоняются с 429 insufficient_quota.",
|
||||
"keys.budgetOf": "бюджет {b}/мес",
|
||||
"dash.kpi.orBalance": "Баланс OpenRouter",
|
||||
"keys.h.install": "Установить:",
|
||||
"keys.snip.keyInputPh": "вставьте ваш API-ключ",
|
||||
"keys.snip.apply": "Вставить ключ",
|
||||
};
|
||||
|
||||
Vendored
+18
-2
@@ -536,9 +536,13 @@ export default {
|
||||
"keys.snip.withKey": "Aşağıdaki örnekler az önce oluşturduğun anahtarı içeriyor.",
|
||||
"keys.snip.openMode": "API anahtarı yapılandırılmadı — ağ geçidi her değeri kabul eder.",
|
||||
"keys.snip.keyPh": "API anahtarın",
|
||||
"settings.restart": "Ağ geçidini yeniden başlat",
|
||||
"settings.restartConfirm": "Ağ geçidi yeniden başlatılsın mı? Devam eden istekler kesilir; yerel model sunucuları da yeniden başlar.",
|
||||
"settings.restart": "Tümünü yeniden başlat",
|
||||
"settings.restartConfirm": "Ağ geçidi ve tüm yerel model sunucuları yeniden başlatılsın mı? Devam eden istekler kesilecek.",
|
||||
"settings.restarting": "Yeniden başlatılıyor — ağ geçidi dönünce sayfa yenilenecek…",
|
||||
"settings.shutdown": "Tümünü durdur ve kapat",
|
||||
"settings.shutdownConfirm": "Tüm yerel model sunucuları durdurulup ağ geçidi kapatılsın mı? Ağ geçidini yeniden başlatana kadar API ve bu panel yanıt vermez.",
|
||||
"settings.shutdownDone": "Ağ geçidi kapalı.",
|
||||
"settings.shutdownHint": "Tüm yerel model sunucuları durduruldu ve süreç sonlandı. Paneli geri getirmek için ağ geçidini sunucuda yeniden başlatın.",
|
||||
"bench.title": "Yerel model hızı",
|
||||
"bench.hint": "Bu makinede `cortiq bench` çalıştırır — gerçek tok/s, aynı anda tek model.",
|
||||
"bench.run": "Ölç",
|
||||
@@ -634,4 +638,16 @@ export default {
|
||||
"tier.low": "kolay",
|
||||
"tier.medium": "orta",
|
||||
"tier.high": "zor",
|
||||
"bm.scoreHint": "Yönlendiricinin sürekli karmaşıklık puanı (0..1) — Yönlendirme sayfasındaki sınır kaydırıcıları onu katmanlara böler.",
|
||||
"settings.cache.noEmbed": "Önbellek açık ama çalışacağı bir embedding modeli YOK — hiçbir şey yapmıyor. Havuza kind = \"embedding\" olan bir model ekleyin (örn. OpenAI text-embedding) veya aşağıdan seçin.",
|
||||
"settings.cache.entries": "kayıt",
|
||||
"settings.cache.hits": "isabet",
|
||||
"settings.cache.saved": "tasarruf",
|
||||
"keys.form.budget": "Aylık bütçe, $ (0 = sınırsız)",
|
||||
"keys.form.budgetHint": "Hesabın aylık harcaması tavanı aşınca istekler 429 insufficient_quota ile reddedilir.",
|
||||
"keys.budgetOf": "bütçe {b}/ay",
|
||||
"dash.kpi.orBalance": "OpenRouter bakiyesi",
|
||||
"keys.h.install": "Kurulum:",
|
||||
"keys.snip.keyInputPh": "API anahtarınızı yapıştırın",
|
||||
"keys.snip.apply": "Anahtarı ekle",
|
||||
};
|
||||
|
||||
Vendored
+18
-2
@@ -536,9 +536,13 @@ export default {
|
||||
"keys.snip.withKey": "下面的示例已包含你刚创建的密钥。",
|
||||
"keys.snip.openMode": "未配置 API 密钥 — 网关接受任意密钥值。",
|
||||
"keys.snip.keyPh": "你的 API 密钥",
|
||||
"settings.restart": "重启网关",
|
||||
"settings.restartConfirm": "重启网关?进行中的请求会中断;本地模型服务器也会重启。",
|
||||
"settings.restart": "全部重启",
|
||||
"settings.restartConfirm": "重启网关和所有本地模型服务器?正在处理的请求将被中断。",
|
||||
"settings.restarting": "正在重启 — 网关恢复后页面会自动刷新……",
|
||||
"settings.shutdown": "全部停止并关机",
|
||||
"settings.shutdownConfirm": "停止所有本地模型服务器并关闭网关?在你重新启动网关之前,API 和此面板都将无法响应。",
|
||||
"settings.shutdownDone": "网关已关闭。",
|
||||
"settings.shutdownHint": "所有本地模型服务器已停止,进程已退出。请在服务器上重新启动网关以恢复面板。",
|
||||
"bench.title": "本地模型速度",
|
||||
"bench.hint": "在本机运行 `cortiq bench` — 真实 tok/s,一次一个模型。",
|
||||
"bench.run": "测速",
|
||||
@@ -634,4 +638,16 @@ export default {
|
||||
"tier.low": "简单",
|
||||
"tier.medium": "中等",
|
||||
"tier.high": "困难",
|
||||
"bm.scoreHint": "路由器的连续复杂度得分(0..1)— 路由页面上的边界滑块将其划分为层级。",
|
||||
"settings.cache.noEmbed": "缓存已启用,但没有可用的 embedding 模型 — 它没有任何作用。请向模型池添加 kind = \"embedding\" 的模型(如 OpenAI text-embedding),或在下方选择。",
|
||||
"settings.cache.entries": "条目",
|
||||
"settings.cache.hits": "命中",
|
||||
"settings.cache.saved": "已节省",
|
||||
"keys.form.budget": "每月预算,$(0 = 不限)",
|
||||
"keys.form.budgetHint": "当账户当月支出超过上限时,请求将被拒绝并返回 429 insufficient_quota。",
|
||||
"keys.budgetOf": "预算 {b}/月",
|
||||
"dash.kpi.orBalance": "OpenRouter 余额",
|
||||
"keys.h.install": "安装:",
|
||||
"keys.snip.keyInputPh": "粘贴你的 API 密钥",
|
||||
"keys.snip.apply": "插入密钥",
|
||||
};
|
||||
|
||||
Vendored
+6
-1
@@ -238,6 +238,7 @@ export async function renderBenchmark() {
|
||||
info.cost_usd = (info.cost_usd || 0) + (again.cost_usd || 0);
|
||||
if (again.tier === "high") {
|
||||
info.tier = again.tier;
|
||||
info.score = again.score;
|
||||
info.selected_model = again.selected_model;
|
||||
info.task_label = again.task_label;
|
||||
info.route_source = again.route_source;
|
||||
@@ -269,6 +270,8 @@ export async function renderBenchmark() {
|
||||
|| String(info.route_source || "").includes("degraded");
|
||||
row.task = info.task_label || "—";
|
||||
row.tier = info.tier || "—";
|
||||
row.cscore = info.score || 0; // непрерывный коэффициент сложности
|
||||
// (row.score занят долей правильности ответа из evaluate())
|
||||
row.chosen = info.selected_model || "—";
|
||||
row.source = info.route_source || "—";
|
||||
row.expected = test.label; // null = classification not judged
|
||||
@@ -402,7 +405,9 @@ export async function renderBenchmark() {
|
||||
: h("span", { class: r.taskOk ? "" : "err" },
|
||||
(r.taskOk ? "✓ " : "✗ ") + r.task,
|
||||
r.taskOk ? "" : h("div", { class: "muted small" }, t("bm.expectedLbl") + " " + (r.expected || []).join("/")))),
|
||||
h("td", {}, r.tier ? h("span", { class: "badge tier-" + r.tier }, trTier(r.tier)) : "—"),
|
||||
h("td", {},
|
||||
r.tier ? h("span", { class: "badge tier-" + r.tier }, trTier(r.tier)) : "—",
|
||||
r.cscore > 0 ? h("div", { class: "muted small mono", title: t("bm.scoreHint") }, r.cscore.toFixed(2)) : null),
|
||||
h("td", {},
|
||||
h("span", { class: "mono small" }, r.chosen || "—"),
|
||||
r.cost > 0 ? h("div", { class: "muted small" }, moneyEl(r.cost)) : null,
|
||||
|
||||
Vendored
+12
-4
@@ -1,6 +1,6 @@
|
||||
// Dashboard — KPIs, time-series charts, breakdown, recent requests, health.
|
||||
import { h, mount, money, moneyEl, num, ms, pct, timeAgo, toast, confirmDialog } from "../ui.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { t, trTier } from "../i18n.js";
|
||||
import { api } from "../api.js";
|
||||
import { lineChart, barList } from "../charts.js";
|
||||
import { SITE_URL } from "../app.js";
|
||||
@@ -110,12 +110,13 @@ export async function renderDashboard() {
|
||||
|
||||
async function load() {
|
||||
const rangeSecs = range;
|
||||
let stats, recent, health;
|
||||
let stats, recent, health, balance;
|
||||
try {
|
||||
[stats, recent, health] = await Promise.all([
|
||||
[stats, recent, health, balance] = await Promise.all([
|
||||
api.stats(rangeSecs, groupby),
|
||||
api.requests(40, 0),
|
||||
api.health().catch(() => null),
|
||||
api.balance().catch(() => null),
|
||||
]);
|
||||
} catch (e) {
|
||||
mount(root, h("div", { class: "callout" }, String(e.message || e)));
|
||||
@@ -162,6 +163,11 @@ export async function renderDashboard() {
|
||||
kpi(num(tot.requests), t("dash.kpi.requests")),
|
||||
kpi(num(tot.total_tokens), t("dash.kpi.tokens")),
|
||||
kpi(moneyEl(tot.cost_usd), t("dash.kpi.cost")),
|
||||
balance && balance.openrouter_usd != null
|
||||
? kpi(
|
||||
h("span", { class: balance.openrouter_usd < 5 ? "err" : "" }, "$" + balance.openrouter_usd.toFixed(2)),
|
||||
t("dash.kpi.orBalance"))
|
||||
: null,
|
||||
kpi(ms(tot.avg_latency_ms), t("dash.kpi.latency")),
|
||||
kpi(pct(tot.success_rate), t("dash.kpi.success")),
|
||||
kpi(num(tot.failovers), t("dash.kpi.failovers"))
|
||||
@@ -271,7 +277,9 @@ function recentTable(rows) {
|
||||
{},
|
||||
h("td", { class: "mono" }, timeAgo(r.ts)),
|
||||
h("td", {}, r.model_id || "—", r.failover ? h("span", { class: "badge warn", style: "margin-left:6px" }, "fo") : null),
|
||||
h("td", {}, r.tier ? h("span", { class: "badge tier-" + r.tier }, r.tier) : "—"),
|
||||
h("td", {},
|
||||
r.tier ? h("span", { class: "badge tier-" + r.tier }, trTier(r.tier)) : "—",
|
||||
r.score > 0 ? h("span", { class: "muted small mono", style: "margin-left:6px" }, r.score.toFixed(2)) : null),
|
||||
h("td", {}, r.task_label || "—"),
|
||||
h("td", { class: "mono" }, `${r.prompt_tokens}/${r.completion_tokens}`),
|
||||
h("td", { class: "mono" }, money(r.cost_usd)),
|
||||
|
||||
Vendored
+147
-14
@@ -10,12 +10,14 @@ export async function renderKeys() {
|
||||
function openCreate(reload) {
|
||||
const accountIn = h("input", { placeholder: "team-a" });
|
||||
const rateIn = h("input", { type: "number", value: 600 });
|
||||
const budgetIn = h("input", { type: "number", step: "1", min: "0", placeholder: "0" });
|
||||
const customIn = h("input", { placeholder: "sk-gw-…" });
|
||||
const body = h(
|
||||
"div",
|
||||
{},
|
||||
h("label", { class: "field" }, h("span", {}, t("keys.form.account")), accountIn),
|
||||
h("label", { class: "field" }, h("span", {}, t("keys.form.rate")), rateIn),
|
||||
h("label", { class: "field" }, h("span", {}, t("keys.form.budget")), budgetIn, h("div", { class: "hint" }, t("keys.form.budgetHint"))),
|
||||
h("label", { class: "field" }, h("span", {}, t("keys.form.custom")), customIn, h("div", { class: "hint" }, t("keys.form.customHint")))
|
||||
);
|
||||
modal(t("keys.form.title"), body, async () => {
|
||||
@@ -24,6 +26,7 @@ export async function renderKeys() {
|
||||
key: customIn.value.trim(),
|
||||
account: accountIn.value.trim() || "default",
|
||||
rate_per_min: parseInt(rateIn.value) || 0,
|
||||
monthly_budget_usd: parseFloat(budgetIn.value) > 0 ? parseFloat(budgetIn.value) : null,
|
||||
allow_models: [],
|
||||
});
|
||||
reload();
|
||||
@@ -51,18 +54,76 @@ export async function renderKeys() {
|
||||
function snippetsCard(openMode) {
|
||||
const origin = location.origin;
|
||||
const base = origin + "/v1";
|
||||
const key = lastCreatedKey || (openMode ? "sk-anything" : "<" + t("keys.snip.keyPh") + ">");
|
||||
let key = lastCreatedKey || (openMode ? "sk-anything" : "<" + t("keys.snip.keyPh") + ">");
|
||||
// Every scripty snippet exists in two OS flavors: bash heredocs for
|
||||
// Linux/macOS and PowerShell here-strings for Windows. SDK snippets
|
||||
// (python/js) and GUI instructions are OS-independent.
|
||||
let os = /win/i.test(navigator.platform || "") ? "ps" : "sh";
|
||||
// Harness auto-setup scripts first (the gateway speaks OpenAI at /v1/* and
|
||||
// Anthropic at /v1/messages, so Claude Code connects natively), SDKs after.
|
||||
const snips = {
|
||||
"Claude Code": `# ${t("keys.h.claude")}
|
||||
export ANTHROPIC_BASE_URL="${origin}"
|
||||
const buildSnips = (osKind) => osKind === "ps" ? {
|
||||
"Claude Code": `$env:ANTHROPIC_BASE_URL = "${origin}"
|
||||
$env:ANTHROPIC_AUTH_TOKEN = "${key}"
|
||||
$env:ANTHROPIC_MODEL = "cortiq-auto"
|
||||
|
||||
claude`,
|
||||
Codex: `New-Item -ItemType Directory -Force "$HOME\\.codex" | Out-Null
|
||||
Add-Content "$HOME\\.codex\\config.toml" @'
|
||||
model = "cortiq-auto"
|
||||
model_provider = "cortiq"
|
||||
|
||||
[model_providers.cortiq]
|
||||
name = "Cortiq Gateway"
|
||||
base_url = "${base}"
|
||||
env_key = "CORTIQ_API_KEY"
|
||||
wire_api = "chat"
|
||||
'@
|
||||
$env:CORTIQ_API_KEY = "${key}"
|
||||
|
||||
codex`,
|
||||
OpenCode: `New-Item -ItemType Directory -Force "$HOME\\.config\\opencode" | Out-Null
|
||||
Set-Content "$HOME\\.config\\opencode\\opencode.json" @'
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"cortiq": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "Cortiq Gateway",
|
||||
"options": { "baseURL": "${base}", "apiKey": "${key}" },
|
||||
"models": { "cortiq-auto": { "name": "Cortiq Auto (smart routing)" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
opencode`,
|
||||
Continue: `New-Item -ItemType Directory -Force "$HOME\\.continue" | Out-Null
|
||||
Add-Content "$HOME\\.continue\\config.yaml" @'
|
||||
models:
|
||||
- name: Cortiq Auto
|
||||
provider: openai
|
||||
model: cortiq-auto
|
||||
apiBase: ${base}
|
||||
apiKey: ${key}
|
||||
'@`,
|
||||
Cline: `${t("keys.h.cline1")}
|
||||
API Provider: OpenAI Compatible
|
||||
Base URL: ${base}
|
||||
API Key: ${key}
|
||||
Model ID: cortiq-auto
|
||||
|
||||
${t("keys.h.cline2")}`,
|
||||
curl: `curl.exe ${base}/chat/completions \`
|
||||
-H "Authorization: Bearer ${key}" \`
|
||||
-H "Content-Type: application/json" \`
|
||||
-d '{"model":"cortiq-auto","messages":[{"role":"user","content":"Привет!"}]}'`,
|
||||
} : {
|
||||
"Claude Code": `export ANTHROPIC_BASE_URL="${origin}"
|
||||
export ANTHROPIC_AUTH_TOKEN="${key}"
|
||||
export ANTHROPIC_MODEL="cortiq-auto"
|
||||
|
||||
claude`,
|
||||
Codex: `# ${t("keys.h.codex")}
|
||||
mkdir -p ~/.codex && cat >> ~/.codex/config.toml <<'EOF'
|
||||
Codex: `mkdir -p ~/.codex && cat >> ~/.codex/config.toml <<'EOF'
|
||||
model = "cortiq-auto"
|
||||
model_provider = "cortiq"
|
||||
|
||||
@@ -75,8 +136,7 @@ EOF
|
||||
export CORTIQ_API_KEY="${key}"
|
||||
|
||||
codex`,
|
||||
OpenCode: `# ${t("keys.h.opencode")}
|
||||
mkdir -p ~/.config/opencode && cat > ~/.config/opencode/opencode.json <<'EOF'
|
||||
OpenCode: `mkdir -p ~/.config/opencode && cat > ~/.config/opencode/opencode.json <<'EOF'
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
@@ -91,8 +151,7 @@ mkdir -p ~/.config/opencode && cat > ~/.config/opencode/opencode.json <<'EOF'
|
||||
EOF
|
||||
|
||||
opencode`,
|
||||
Continue: `# ${t("keys.h.continue")}
|
||||
mkdir -p ~/.continue && cat >> ~/.continue/config.yaml <<'EOF'
|
||||
Continue: `mkdir -p ~/.continue && cat >> ~/.continue/config.yaml <<'EOF'
|
||||
models:
|
||||
- name: Cortiq Auto
|
||||
provider: openai
|
||||
@@ -128,8 +187,29 @@ const resp = await client.chat.completions.create({
|
||||
});
|
||||
console.log(resp.choices[0].message.content);`,
|
||||
};
|
||||
let shSnips, psSnips, snips;
|
||||
function rebuildSnips() {
|
||||
shSnips = buildSnips("sh");
|
||||
psSnips = buildSnips("ps");
|
||||
// python/js/Cline одинаковы — дополняем ps-набор из sh
|
||||
for (const k of Object.keys(shSnips)) if (!psSnips[k]) psSnips[k] = shSnips[k];
|
||||
snips = os === "ps" ? psSnips : shSnips;
|
||||
}
|
||||
rebuildSnips();
|
||||
let cur = "Claude Code";
|
||||
// per-harness hint lives OUTSIDE the copyable text: a leading "# comment"
|
||||
// in a pasted snippet is not a comment in default interactive zsh, and a
|
||||
// "!" inside it triggers history expansion ("event not found")
|
||||
const HINTS = {
|
||||
"Claude Code": () => t("keys.h.claude"),
|
||||
Codex: () => t("keys.h.codex"),
|
||||
OpenCode: () => t("keys.h.opencode"),
|
||||
Continue: () => t("keys.h.continue"),
|
||||
};
|
||||
const hintHost = h("div", { class: "muted small", style: "margin-bottom:6px" });
|
||||
const paintHint = () => { hintHost.textContent = HINTS[cur] ? HINTS[cur]() : ""; };
|
||||
const pre = h("pre", { class: "conv-log snippet" }, snips[cur]);
|
||||
paintHint();
|
||||
const seg = h(
|
||||
"div",
|
||||
{ class: "seg" },
|
||||
@@ -137,6 +217,7 @@ console.log(resp.choices[0].message.content);`,
|
||||
h("button", { class: (i === 0 ? "active" : "") + (["curl", "python", "javascript"].includes(k) ? " sdk" : ""), "data-snip": k, onClick: (e) => {
|
||||
cur = k;
|
||||
pre.textContent = snips[k];
|
||||
paintHint();
|
||||
protoWarn.style.display = k === "Claude Code" && protoWarn.childNodes.length ? "" : "none";
|
||||
paintStatus();
|
||||
seg.querySelectorAll("button").forEach((b) => b.classList.remove("active"));
|
||||
@@ -164,7 +245,15 @@ console.log(resp.choices[0].message.content);`,
|
||||
}).catch(() => {});
|
||||
|
||||
// Detected harnesses: ✓ on the tab when installed on the gateway machine,
|
||||
// status line under the snippet ("installed" / "already configured").
|
||||
// status line under the snippet ("installed" / "already configured") —
|
||||
// and a one-line INSTALL command (per OS) when it's missing.
|
||||
const INSTALL = {
|
||||
"Claude Code": { sh: "curl -fsSL https://claude.ai/install.sh | bash", ps: "irm https://claude.ai/install.ps1 | iex" },
|
||||
Codex: { sh: "npm install -g @openai/codex", ps: "npm install -g @openai/codex" },
|
||||
OpenCode: { sh: "curl -fsSL https://opencode.ai/install | bash", ps: "npm install -g opencode-ai" },
|
||||
Continue: { sh: "code --install-extension Continue.continue", ps: "code --install-extension Continue.continue" },
|
||||
Cline: { sh: "code --install-extension saoudrizwan.claude-dev", ps: "code --install-extension saoudrizwan.claude-dev" },
|
||||
};
|
||||
let detected = {};
|
||||
const statusHost = h("div", { class: "small", style: "margin-top:6px" });
|
||||
function paintStatus() {
|
||||
@@ -176,7 +265,19 @@ console.log(resp.choices[0].message.content);`,
|
||||
statusHost.innerHTML = "";
|
||||
if (d.configured) mount(statusHost, h("span", { class: "ok" }, "✓ " + t("keys.h.st.configured")));
|
||||
else if (d.installed) mount(statusHost, h("span", { class: "ok" }, "✓ " + t("keys.h.st.installed")));
|
||||
else mount(statusHost, h("span", { class: "muted" }, "○ " + t("keys.h.st.notFound")));
|
||||
else {
|
||||
const cmd = (INSTALL[cur] || {})[os];
|
||||
const copyInstall = h("button", { class: "btn ghost sm", onClick: async () => {
|
||||
await navigator.clipboard.writeText(cmd);
|
||||
toast(t("common.copied"), "good");
|
||||
} }, "⧉");
|
||||
mount(statusHost,
|
||||
h("span", { class: "muted" }, "○ " + t("keys.h.st.notFound")),
|
||||
cmd ? h("div", { class: "flex", style: "gap:6px; align-items:center; margin-top:4px" },
|
||||
h("span", { class: "muted" }, t("keys.h.install")),
|
||||
h("code", { class: "mono small cmd-chip" }, cmd),
|
||||
copyInstall) : null);
|
||||
}
|
||||
}
|
||||
api.harnesses().then((d) => {
|
||||
detected = d || {};
|
||||
@@ -187,6 +288,32 @@ console.log(resp.choices[0].message.content);`,
|
||||
paintStatus();
|
||||
}).catch(() => {});
|
||||
|
||||
// OS switch: bash for Linux/macOS, PowerShell for Windows (auto-detected)
|
||||
const osSeg = h(
|
||||
"div",
|
||||
{ class: "seg" },
|
||||
h("button", { class: os === "sh" ? "active" : "" }, "🐧🍎 Linux / macOS"),
|
||||
h("button", { class: os === "ps" ? "active" : "" }, "🪟 Windows")
|
||||
);
|
||||
osSeg.querySelectorAll("button").forEach((b, i) =>
|
||||
b.addEventListener("click", () => {
|
||||
os = i === 0 ? "sh" : "ps";
|
||||
rebuildSnips();
|
||||
osSeg.querySelectorAll("button").forEach((x, j) => x.classList.toggle("active", j === (os === "sh" ? 0 : 1)));
|
||||
pre.textContent = snips[cur];
|
||||
paintStatus();
|
||||
}));
|
||||
|
||||
// paste-your-key: substitutes the key into every snippet immediately
|
||||
const keyIn = h("input", { class: "mono", placeholder: t("keys.snip.keyInputPh"), style: "max-width:230px; font-size:12px", value: lastCreatedKey || "" });
|
||||
const applyKey = () => {
|
||||
key = keyIn.value.trim() || (openMode ? "sk-anything" : "<" + t("keys.snip.keyPh") + ">");
|
||||
rebuildSnips();
|
||||
pre.textContent = snips[cur];
|
||||
};
|
||||
const applyBtn = h("button", { class: "btn ghost sm", onClick: applyKey }, "⤵ " + t("keys.snip.apply"));
|
||||
keyIn.addEventListener("keydown", (e) => { if (e.key === "Enter") applyKey(); });
|
||||
|
||||
const copy = h("button", { class: "btn ghost sm" }, "⧉ " + t("common.copy"));
|
||||
copy.addEventListener("click", async () => {
|
||||
await navigator.clipboard.writeText(pre.textContent);
|
||||
@@ -198,7 +325,8 @@ console.log(resp.choices[0].message.content);`,
|
||||
h("div", { class: "card-title" }, "🔌 " + t("keys.snip.title")),
|
||||
h("div", { class: "muted small", style: "margin-bottom:8px" },
|
||||
lastCreatedKey ? t("keys.snip.withKey") : openMode ? t("keys.snip.openMode") : t("keys.snip.hint")),
|
||||
h("div", { class: "flex", style: "gap:8px; margin-bottom:8px; flex-wrap:wrap" }, seg, h("div", { class: "grow" }), copy),
|
||||
h("div", { class: "flex", style: "gap:8px; margin-bottom:8px; flex-wrap:wrap" }, seg, h("div", { class: "grow" }), keyIn, applyBtn, osSeg, copy),
|
||||
hintHost,
|
||||
pre,
|
||||
statusHost,
|
||||
protoWarn,
|
||||
@@ -273,7 +401,12 @@ console.log(resp.choices[0].message.content);`,
|
||||
h("td", { class: "right-align mono" }, String((usage[k.account] || {}).requests ?? "—")),
|
||||
h("td", { class: "right-align mono small" }, usage[k.account] ? num(usage[k.account].prompt_tokens) + " / " + num(usage[k.account].completion_tokens) : "—"),
|
||||
h("td", { class: "right-align" }, usage[k.account] ? moneyEl(usage[k.account].cost_usd) : "—"),
|
||||
h("td", { class: "right-align" }, alltime[k.account] ? moneyEl(alltime[k.account].cost_usd) : "—"),
|
||||
h("td", { class: "right-align" },
|
||||
alltime[k.account] ? moneyEl(alltime[k.account].cost_usd) : "—",
|
||||
k.monthly_budget_usd
|
||||
? h("div", { class: "small " + (((usage[k.account] || {}).cost_usd || 0) >= k.monthly_budget_usd ? "err" : "muted") },
|
||||
t("keys.budgetOf", { b: "$" + k.monthly_budget_usd }))
|
||||
: null),
|
||||
h(
|
||||
"td",
|
||||
{ class: "right-align" },
|
||||
|
||||
Vendored
+51
-2
@@ -194,8 +194,30 @@ export async function renderSettings() {
|
||||
const modelsData = await api.listModels();
|
||||
const embedModels = (modelsData.models || []).filter((m) => m.kind === "embedding").map((m) => m.id);
|
||||
const cacheEnabled = check(t("settings.cache.enabled"), s.cache?.enabled, t("settings.cache.enabledHelp"));
|
||||
const cacheThresh = h("input", { type: "number", step: "0.01", value: s.cache?.threshold ?? 0.92 });
|
||||
const cacheThresh = h("input", { type: "number", step: "0.01", min: "0.5", max: "0.999", value: Math.round(((s.cache?.threshold ?? 0.92) + Number.EPSILON) * 100) / 100 });
|
||||
const cacheTtlIn = h("input", { value: s.cache?.ttl || "1h" });
|
||||
// Live proof-of-life for the cache card: entries/hits/saved — and an
|
||||
// honest warning when the cache is on but has no embedding model to work
|
||||
// with (it silently does nothing in that state).
|
||||
let cacheStats = null;
|
||||
try {
|
||||
cacheStats = (await api.stats("24h", "model")).cache || null;
|
||||
} catch { /* stats off */ }
|
||||
function cacheStatus() {
|
||||
const wrap = h("div", { style: "margin-bottom:10px" });
|
||||
if (s.cache?.enabled && embedModels.length === 0 && !s.cache?.embed_model) {
|
||||
mount(wrap, h("div", { class: "callout warn" }, "⚠ " + t("settings.cache.noEmbed")));
|
||||
return wrap;
|
||||
}
|
||||
if (cacheStats && s.cache?.enabled) {
|
||||
mount(wrap, h("div", { class: "flex wrap", style: "gap:8px" },
|
||||
h("span", { class: "badge" }, t("settings.cache.entries") + ": " + cacheStats.entries),
|
||||
h("span", { class: "badge ok" }, t("settings.cache.hits") + ": " + cacheStats.hits),
|
||||
h("span", { class: "badge" }, "hit-rate " + Math.round((cacheStats.hit_rate || 0) * 100) + "%"),
|
||||
h("span", { class: "badge" }, t("settings.cache.saved") + " $" + (cacheStats.saved_usd || 0).toFixed(4))));
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
const cacheEmbedSel = h(
|
||||
"select",
|
||||
{},
|
||||
@@ -537,6 +559,31 @@ export async function renderSettings() {
|
||||
}
|
||||
});
|
||||
|
||||
const stopBtn = h("button", { class: "btn danger" }, "\u23fb " + t("settings.shutdown"));
|
||||
stopBtn.addEventListener("click", async () => {
|
||||
if (!(await confirmDialog(t("settings.shutdownConfirm")))) return;
|
||||
stopBtn.disabled = true;
|
||||
restartBtn.disabled = true;
|
||||
try {
|
||||
await api.shutdown();
|
||||
// the server is gone in ~300ms — replace the app with a static farewell
|
||||
document.body.innerHTML = "";
|
||||
document.body.appendChild(
|
||||
h(
|
||||
"div",
|
||||
{ class: "shutdown-screen" },
|
||||
h("div", { class: "shutdown-icon" }, "\u23fb"),
|
||||
h("h2", {}, t("settings.shutdownDone")),
|
||||
h("p", { class: "hint" }, t("settings.shutdownHint"))
|
||||
)
|
||||
);
|
||||
} catch (e) {
|
||||
toast(String(e.message || e), "bad");
|
||||
stopBtn.disabled = false;
|
||||
restartBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
mount(
|
||||
root,
|
||||
h(
|
||||
@@ -610,6 +657,7 @@ export async function renderSettings() {
|
||||
"div",
|
||||
{ class: "card" },
|
||||
h("div", { class: "card-head" }, h("h3", {}, t("settings.cache"))),
|
||||
cacheStatus(),
|
||||
cacheEnabled.node,
|
||||
h("div", { class: "row" }, field(t("settings.cache.threshold"), cacheThresh), field(t("settings.cache.ttl"), cacheTtlIn)),
|
||||
field(t("settings.cache.embed"), cacheEmbedSel),
|
||||
@@ -659,7 +707,8 @@ export async function renderSettings() {
|
||||
h("button", { class: "btn", onClick: () => fileIn.click() }, t("settings.import")),
|
||||
fileIn,
|
||||
h("div", { class: "grow" }),
|
||||
restartBtn
|
||||
restartBtn,
|
||||
stopBtn
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user