Compare commits

..
36 Commits
Author SHA1 Message Date
infosave2007 34093330f6 chore: bump v0.2.44
Docker / publish (push) Canceled after 0s
Release / crates-io (push) Canceled after 0s
Release / release (macos-14, aarch64-apple-darwin) (push) Canceled after 0s
Release / release (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Canceled after 0s
Release / release (windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-08 17:58:25 +03:00
infosave2007 faddf945d7 fix(config): a foreign project must not capture the config path
default_config_path treated any directory holding Cargo.toml plus a
config/ folder as a gateway checkout, so running cortiq-gateway inside
an unrelated project died with "cannot read config config/gateway.toml"
instead of using the per-user home. Now ./config/gateway.toml counts
only when the file is actually readable, the repo-checkout heuristic
requires Cargo.toml to be cortiq-gateway's own, and everything else
falls through to $CORTIQ_GATEWAY_HOME / ~/.cortiq-gateway — typing
cortiq-gateway in any directory picks up the same configuration.
2026-08-08 17:57:45 +03:00
infosave2007 255d989e83 feat(admin): power controls — stop everything & power off, restart everything
Settings gains a second service button next to restart: stop every managed
local model server and exit the gateway process (nothing is respawned; in
Docker the restart policy decides). The SPA replaces itself with a static
"gateway is off" screen since the API is gone. The restart button is
relabeled "Restart everything" to say what it always did — the gateway
re-execs itself and managed servers come back with it. Translated in all
seven languages.
2026-08-08 17:33:56 +03:00
infosave2007 0d9d24125a style: rustfmt after resolve() signature change 2026-08-08 16:26:07 +03:00
infosave2007 1c59906b49 chore: bump v0.2.43
Docker / publish (push) Canceled after 0s
Release / crates-io (push) Canceled after 0s
Release / release (macos-14, aarch64-apple-darwin) (push) Canceled after 0s
Release / release (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Canceled after 0s
Release / release (windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-08 16:12:17 +03:00
infosave2007 19f666e7b1 feat(cmf): auto-detect tool support of managed local servers
cortiq serve >=0.5.62 advertises the loaded model's capabilities in
/healthz ({"capabilities":{"tools":true}} when the chat template has a
tools branch). The gateway now reads that handshake when the managed
server becomes healthy and treats detected tool support as equivalent
to declared caps in the candidate filter — so a low-tier chain made of
a local model no longer widens agent (tools) traffic to cloud models
once the local server can actually drive tools.

Also adds an optional manual "caps" override to [[cmf.servers]] for
older serve builds that do not advertise capabilities.
2026-08-08 15:52:20 +03:00
infosave2007 dbcd9827f6 fix(pipeline): capability filtering widens the chain instead of killing the request
OpenCode (like every coding agent) sends tool definitions with each request.
The router rated the prompt low → the low-tier chain contained only the
local model → the tools filter emptied the chain → hard failure: "No
healthy model available that satisfies required capabilities", with the
agent stuck in a retry loop.

On an AUTO route whose filtered chain comes up empty, the pipeline now
widens to any pool model satisfying the required capabilities — preferring
the routing table's own medium→high order (an agentic request deserves a
capable model), then the rest of the pool — and logs the widening. Pinned
requests still fail loudly as before. Verified with OpenCode's exact
request shape: cortiq-auto + tools at tier low now answers from
deepseek in 1.2s instead of erroring.
2026-08-08 14:25:00 +03:00
infosave2007 b689180717 fix(keys): snippets survive being pasted into real zsh
The copyable text opened with a "# hint" line — but default interactive
zsh has interactivecomments off, so the line was parsed as a command, and
the "!" inside «(перезапишет!)» fired history expansion: pasting the
OpenCode snippet died with "zsh: event not found: )" before doing
anything. Hints now live in the UI above the snippet; the clipboard gets
commands only. Verified by piping the actual rendered snippet through an
interactive zsh: the config file lands intact.
2026-08-08 14:16:22 +03:00
infosave2007 f385c2254b fix(ui): install-command chip and bands slider readable in the light theme
The chip used var(--panel-2, #23252c) — a variable that doesn't exist with
a hard dark fallback, so in the light theme the install command was a dark
box with dark text. Same class of bug in the bands slider track and handle
labels (invented --panel-2/--fg vars). All moved to the real theme
variables (--panel/--border/--text) via a .cmd-chip class; verified by
computed styles in the light theme (text rgb(15,23,42) on white) and
screenshots of both views.
2026-08-08 14:12:39 +03:00
infosave2007 9607a38792 feat(keys): paste-your-key field — snippets carry the real key instantly
Next to the copy button: a key input and an «Insert key» button (Enter works
too). The pasted key substitutes into every harness/SDK snippet immediately
and survives tab and OS switches; a freshly created key pre-fills the field.
No more copying a snippet and then hand-editing the placeholder. 2 strings
×7 languages.
2026-08-08 14:07:11 +03:00
infosave2007 239ddc767c feat(keys): harness snippets for every OS + install command when missing
The harness auto-setup scripts were bash-only (heredocs, mkdir -p) — useless
on Windows. Every scripty snippet now exists in two flavors behind a
🐧🍎/🪟 switch (auto-detected from the browser): bash heredocs for
Linux/macOS and PowerShell here-strings ($env:, New-Item, @'..'@,
curl.exe with backtick continuations) for Windows. SDK snippets and GUI
instructions stay OS-independent.

And when detection says a harness is NOT installed on the gateway machine,
the status line now offers its one-line install command for the selected OS
(claude.ai install.sh / install.ps1, npm -g for Codex and OpenCode-on-
Windows, code --install-extension for Continue/Cline) with a copy button.
1 string ×7 languages.
2026-08-08 14:05:47 +03:00
infosave2007 057abaa95a feat!: API keys actually gate the gateway — auth, attribution, rate limits, budgets
The office-readiness audit found the foundation missing: configured API keys
were decorative. Any bearer — or none — passed every model endpoint, per-key
rate limits existed only in the form, and all real traffic was recorded as
"anonymous", leaving the per-key spend table permanently blank.

A middleware on the model surface (/v1/*, /route, /mcp) now enforces keys:
- no keys configured → open mode as before (the console already warns);
- keys exist → Bearer or x-api-key must match (401 invalid_api_key);
- per-key rate_per_min → fixed one-minute window, 429 with Retry-After;
- NEW per-key monthly_budget_usd → 429 insufficient_quota once the account's
  month-to-date spend crosses the ceiling;
- the matched account rides the request into stats — the keys table's spend
  columns finally see real traffic.

Month-to-date spend is tracked per account in stats and persisted in the
totals sidecar with a watermark (no double counting across restarts). The
key dialog gains a budget field; the table shows "budget $N/mo" in red once
exhausted. All verified live: 401 wrong/missing key, 200 + attribution with
the right key, 429 + Retry-After over the limit, 429 insufficient_quota on
the second request past a $0.000001 budget.

Also: the dashboard shows the OpenRouter credit balance (GET
/api/v1/credits via the stored key, cached 5 min, hidden without a key, red
under $5 — live: $16.18), the server drains connections on Ctrl-C instead
of dropping them, request bodies are capped at 32 MB, and docs/DEPLOY(.ru)
cover office deployment: systemd/launchd/Windows service, nginx/caddy TLS,
security checklist, backups. 10 strings ×7 languages.
2026-08-08 14:01:16 +03:00
infosave2007 dd864eed22 fix(cache): honest settings card — clean threshold, live stats, no-embedder warning
Full audit of the semantic cache against a live instance (mock embedder,
identical requests): the mechanism works — miss at 3.1s, then hits at 2ms
with entries/hits/saved-$ counting. Two honest gaps fixed:

- the threshold round-tripped through f32 and rendered as
  0.9200000166893005 in both the TOML and the settings input — the config
  field is f64 now (writes exactly what you typed) and the input rounds
  legacy values to 2 decimals;
- the card now shows proof of life (entries / hits / hit-rate / saved $)
  and, when the cache is enabled with NO embedding model available, a
  warning that it is silently doing nothing and how to fix it — previously
  that state looked identical to a working cache.

Also audited in the same pass, no changes needed: Prometheus /metrics
(toggle on/off applies live, 404 when off), and all 7 protocol switches
(each gates its endpoint instantly both ways). 4 strings ×7 languages.
2026-08-08 13:21:43 +03:00
infosave2007 c1ff675d80 chore: release 0.2.42
Docker / publish (push) Canceled after 0s
Release / crates-io (push) Canceled after 0s
Release / release (macos-14, aarch64-apple-darwin) (push) Canceled after 0s
Release / release (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Canceled after 0s
Release / release (windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-08 12:18:04 +03:00
infosave2007 9ef1af3ae1 feat(ui): the continuous complexity score shows next to the tier
Benchmark (routing mode) and the dashboard's recent-requests list now print
the router's raw 0..1 score under/next to the tier badge — people can see
what different prompts actually weigh and tune the boundary sliders from
real data. Tooltip explains the link to the Routing sliders (7 languages).

Caught before commit: naming the field row.score collided with the answer-
correctness fraction from evaluate() and dragged the benchmark's quality KPI
down to the average complexity — renamed to cscore, KPI verified back at
100%. Dashboard tier badges also render translated names now, and the
Russian locale calls the dashboard «Рабочий стол».
2026-08-08 12:11:07 +03:00
infosave2007 6f4d83ce13 chore: release 0.2.41
Docker / publish (push) Canceled after 0s
Release / crates-io (push) Canceled after 0s
Release / release (macos-14, aarch64-apple-darwin) (push) Canceled after 0s
Release / release (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Canceled after 0s
Release / release (windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-08 09:55:20 +03:00
infosave2007 0040bfd7e9 fix(routing): the high zone is unmistakably red
At 35% opacity over the dark background the amber medium zone and the
light-red high zone read as the same color right of the medium handle.
Saturation stepped up per zone (45/55/65%) and high uses a deeper red —
green / amber / red now read at a glance.
2026-08-08 09:53:25 +03:00
infosave2007 10b5101757 fix(i18n): complexity tier names are translated everywhere they're shown
low/medium/high leaked as raw English into the bands legend, the tier badges
on the Routing page, the pair complexity selects, the benchmark's tier column
and the Playground decision panel. New trTier() helper renders the human name
(ru: лёгкий/средний/сложный, de: leicht/mittel/schwer, fr/es/zh/tr likewise);
custom tier names pass through untouched, badge colors stay keyed by the raw
tier. Verified in the browser in Russian and German.
2026-08-08 09:51:38 +03:00
infosave2007 788c87c565 feat(routing): linked complexity-boundary sliders — your own low/medium/high
The tier boundaries lived on the router and were global for every customer
of a hosted router; per-gateway tuning meant hand-editing score ranges. The
Routing page now has one track with two draggable handles (the low|medium
and medium|high boundaries) over three colored zones: handles push each
other (low can't cross medium — it shoves it along, and vice versa), the
numbers ride on the handles and the legend updates live, so nobody types
ranges by hand. Unchecked = the router's own bands, untouched behavior.

Under the hood the gateway re-maps the router's CONTINUOUS complexity score
with the user's boundaries ([routing.bands] low/medium in gateway.toml,
validated 0 < low < medium < 1) before tier chains, escalation and stats —
verified live: with bands at 0.70/0.72 a score-0.27 request that used to be
medium routed low, and back after restoring the defaults.

2 new strings in all 7 languages.
2026-08-08 09:48:36 +03:00
infosave2007 179c414a0e fix(benchmark): retry the SAME hard prompt after a pause before reaching for a fresh one
Difficulty is judged in the background: the first-ever occurrence of a hard
prompt routes by prior (often low/medium), the oracle's judgement lands in
the cache a couple of seconds later. The route-judged retry fired instantly
with a FRESH template — which has the same first-encounter fate — so the
test could go red exactly when the system was working as designed. The retry
now re-asks the same prompt after 4s (steady-state answer from the cache),
and only then tries a fresh template.
2026-08-08 09:23:53 +03:00
infosave2007 115b04f20e chore: release 0.2.40
Docker / publish (push) Canceled after 0s
Release / crates-io (push) Canceled after 0s
Release / release (macos-14, aarch64-apple-darwin) (push) Canceled after 0s
Release / release (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Canceled after 0s
Release / release (windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-08 09:13:12 +03:00
infosave2007 b24d4cdbab fix(routing): the task-rule badge only when the rule actually chose the head
route_source reported "+task-rule" whenever a rule existed for the task —
so a math prompt routed low by the ordinary tier chain still wore the badge
next to the local model, reading as "the rule picked this" when it hadn't
(the rule only defines math@high). matched now means the head of the chain
came from the rule's tier-specific or any-tier list; verified live: the same
low math request now reports route_source "router".
2026-08-08 09:02:25 +03:00
infosave2007 06813b3e97 feat(routing)!: task rules become per-complexity model maps
The single per-task «Complexity» pin was the wrong shape — what you actually
decide is WHICH model serves a task at WHICH complexity. A task rule is now a
per-tier map, edited in the console as rows of “model ▾ + complexity ▾” pairs
(plus ×/add) inside each task:

  [routing.tasks.code]
  low  = ["cheap-coder"]
  high = ["best-coder"]
  any  = ["generic-coder"]   # leads at every tier after the tier pick

Chain order: the task's tier-specific list → (at high: the global high chain,
so escalation still beats any-tier pins) → the task's any-tier list → the
global tier chain → default as the last resort; deduplicated. Plain-array
rules keep meaning "any tier" unchanged, and the short-lived {models, tier}
form from the previous commit is normalized into the map on load.

Verified live: legacy config migrated visibly in the editor, pairs survive
save+reload, a math prompt at low ignored the math@high pair, per-tier leads
confirmed by unit tests (19 passing).
2026-08-08 08:54:27 +03:00
infosave2007 95e560dc72 feat: per-key spend accounting and per-task complexity pinning
API keys page: the table now answers "which department spends what" — per-key
requests, tokens in/out and cost for a selectable window (24h/7d/30d) plus a
cumulative all-time cost column, joined from stats by the key's account.

The stats log stays bounded while the accounting survives: once stats.jsonl
exceeds max_file_mb (default 16, 0 disables), records older than the
retention window fold into cumulative per-account totals in a small sidecar
file (tmp+rename on both) and leave the log. alltime = folded + in-file, a
record is never counted twice, verified end-to-end on synthetic data
(1.2 MB → 301 KB, totals identical before/after compaction and restarts).

Routing: a task rule can now PIN the complexity tier —
`[routing.tasks.legal] models=[...] tier="high"` (old plain-array rules parse
unchanged). The pinned tier replaces the router's verdict before the
escalation check and reports itself as route_source "router+task-rule@high".
The Routing page grows a per-task «Complexity» selector (Auto/low/medium/
high); verified live: a code prompt with a pinned high went
router+task-rule@high → the expensive model.

13 new strings in all 7 languages; docs/ROUTING(.ru) updated.
2026-08-08 08:42:11 +03:00
infosave2007 ef28a6633d fix(benchmark): hard probes survive their own popularity
The router deliberately scores familiar text as less novel, so every
benchmark run slightly burns the hard-prompt pool — with 3 templates per
test a variant would drift below the high band within a handful of runs
and flip the test red for no real reason.

Two-part fix: pools doubled to 6 interdisciplinary families each (science:
+ quantum error correction, soil-carbon MRV, cryo-EM; ops: + ED triage +
staffing, port berth/tides/rail, agri-drone fleets — all probed high against
the live router), and a route-judged test that lands non-high gets ONE retry
with a freshly generated template before failing. A single burnt wording no
longer fails the assertion; a genuinely unreachable high tier still does.
2026-08-07 22:47:28 +03:00
infosave2007 b4d36334bf chore: release 0.2.39
Docker / publish (push) Canceled after 0s
Release / crates-io (push) Canceled after 0s
Release / release (macos-14, aarch64-apple-darwin) (push) Canceled after 0s
Release / release (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Canceled after 0s
Release / release (windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-07 22:25:52 +03:00
infosave2007 dd9ccaea94 fix(shell): stale tabs now know the gateway was updated
Recurring confusion all day: the SPA lives in an open tab until reload, so
after a gateway rebuild/update users kept seeing (and benchmarking with!) an
old interface — "where is the feature", "why are the test ids old". The meta
endpoint now carries the process start timestamp; the existing 20s health
poll compares it, and when the gateway has restarted the tab shows a fixed
banner: "The gateway was updated — this page is running an older interface"
with a one-click Reload. 2 strings in all 7 languages.
2026-08-07 22:14:08 +03:00
infosave2007 ff8415d7d4 feat(routing): high tier escalates over task rules; benchmark generates hard prompts that stay hard
Routing semantics fix observed live: a task rule ("code → cheap model") was
pinning even high-complexity prompts to the cheap model — the tier system
never got a say. At tier=high the tier chain now leads and the task rule
demotes to fallback; low/medium keep task-rule-first. Unit-tested both ways.

The benchmark's hard probes are now GENERATED per run from two pools
(science: protein folding / telescope scheduling / materials discovery;
ops: epidemic logistics / grid markets / climate risk) with rotating domains
and numbers. Two findings against the live router drove this: ambiguity
carries the biggest weight in the complexity score, so interdisciplinary
prompts rate high reliably (0.52-0.66) while single-domain "hard" prompts
classify too confidently and sink to medium; and any fixed wording loses
novelty on every repeat. Their labels are null — straddling task labels is
precisely what makes them high, so classification is not judged.

Verified end-to-end: full run 100% answers, 13/13 classification, both hard
probes high → routed to the expensive model (one of them over a code task
rule), at $0.0018 each thanks to the tiny route-judge token budget.
2026-08-07 21:34:24 +03:00
infosave2007 7479cc51c2 feat(benchmark): hard tests that light up the high tier — judged on routing, not essays
Two "hard" probes (full suite only): a compiler-backend design and a security
protocol design, each dense enough for the router's complexity model to rate
them high. They are judged on the ROUTER's decision (judge: "route") — pass
means tier=high was reached and the request went to the big model; the answer
itself is not scored (open-ended essays aren't keyword-judgeable, and
reasoning models happily burn 3000+ thinking tokens on them — measured). With
max_tokens kept tiny the probes cost cents even on the priciest model, while
the cost column tells the whole story: $0.0148 for the high-tier request next
to $0.00005 for the easy ones.

Each run picks a random variant of the prompt: the router deliberately scores
familiar text as less novel, so a fixed wording would decay to medium over
repeated runs. In pinned-models mode route-judged tests are skipped.

Also: creative-writing budget 96→512 (deepseek was spending the whole cap
thinking and returning an empty poem), and when a model does burn its entire
budget inside <think> the table now says so ("budget spent on reasoning")
instead of the misleading "empty answer".

Server-side (cortiq-router prod, both replicas rolling-restarted): the high
band started at 0.66 while the complexity model tops out ~0.65 on the hardest
single prompts — high was unreachable. Band boundary moved to 0.50 based on
measured distribution (easy 0.25-0.36, medium 0.37-0.49, hard 0.50-0.65);
config backed up as config.prod.toml.bak-tiers.
2026-08-07 20:15:46 +03:00
infosave2007 b1e0dc76ec fix(benchmark): fair to reasoning models, fair to router degradation, real sub-cent costs
Three false signals in benchmark results, all seen on a live run:

- Reasoning models were scored ❌ because the per-test max_tokens cap cut
  them mid-<think> before the actual answer. Every run now gets +256 tokens
  of headroom; non-thinking models stop at EOS as before, thinking ones get
  to finish (math-mult on nanbeige: false 0/1 → honest 1/1).
- When the router degraded (timeout → fallback chain) the table showed
  "✗ degraded" and counted it against classification accuracy. There was no
  classification to judge — such rows now show a muted "⚠ router degraded"
  with an explanatory tooltip and are excluded from the accuracy KPI.
- Costs below $0.0001 (cheap flash models) collapsed to "$0.0000". money
  formatting now grows precision until two significant digits survive
  ($0.000035 instead of $0.0000).

2 new strings in all 7 languages.
2026-08-07 19:48:03 +03:00
infosave2007 c3dd251cce fix(keys): drop the allowed-models picker from the new-key dialog
Creating a key now asks only account, rate limit and optional custom value —
allow_models is sent empty (= all models). Per-key model restrictions stay
editable in Settings for whoever actually needs them.
2026-08-07 19:41:56 +03:00
infosave2007 9af51b679e fix(bench): newer cortiq pretty-prints --json — parse it, and report honest tok/s
The one-click model benchmark showed "error" even though cortiq bench ran
fine: the parser expected the JSON result on a single line, while newer
cortiq releases pretty-print it across many. Extraction now finds the last
balanced JSON object anywhere in the mixed CLI output (both formats work).

Also hardened tok/s selection: steady decode speed (decode_tok_s_steady)
is preferred over incl-prefill, and the loose "contains tok+per" fallback —
which would happily report allocs_per_token=769 as tok/s — now only matches
*tok_s* / *tokens_per* fields.
2026-08-07 19:32:47 +03:00
infosave2007 1bba427042 feat(keys): one-paste harness setup — Claude Code, Codex, OpenCode, Continue, Cline
The "connect your app" card in the API-keys view now leads with auto-setup
scripts for popular coding harnesses, each pre-filled with this gateway's URL
and the freshly created key:

  Claude Code — env exports (the gateway speaks Anthropic /v1/messages
      natively; the protocol is now ON by default, and when a config has it
      off the tab shows a warning with a one-click Enable button)
  Codex — appends a [model_providers.cortiq] block to ~/.codex/config.toml
  OpenCode — writes the provider into opencode.json
  Continue — appends the model to ~/.continue/config.yaml
  Cline — the exact values to paste into the extension settings

The gateway also detects which harnesses are already present on its machine
(binaries across PATH + common install dirs, config files under home) via
GET /admin/api/harnesses: installed tabs get a ✓, and the status line under
the snippet says "installed" or "already configured for this gateway" when
the harness config mentions cortiq. Works on Linux/macOS/Windows.

curl / python / javascript SDK snippets stay as before. 16 new strings in
all 7 languages.
2026-08-07 19:26:54 +03:00
infosave2007 54b73f1365 fix: honest costs in streams, oracle-sized timeouts, history & log cleanup
Cost showed $0 for every streamed request: the X-Cortiq-Cost-USD header is
written before generation, when the cost cannot be known. The gateway now
ships the final number in a closing SSE event (`cortiq_cost_usd`, alongside
the token estimate when the provider sent no usage), and the console prefers
it over the header. Money renders with 4 decimals, the last two in a smaller
font — $0.0006 stays readable instead of collapsing to $0.

Timeouts, both ends: the router default was 800 ms, silently degrading every
prompt the hosted router escalates to its oracle (~10 s) — now 15 s. Provider
clients had NO timeout at all (a dead OpenRouter host hung requests forever) —
now a 10 s connect timeout, with no overall cap so long generations and
streams are never cut.

Benchmark history: per-run × and a Clear-all button. Logs view: a clear
button backed by DELETE /admin/api/logs.
2026-08-07 19:15:27 +03:00
infosave2007 ec92bdddb7 feat(benchmark): smart-routing mode is the point — live results, per-model cost
The benchmark now leads with the product's actual feature. Two explicit modes:

  🧭 Smart routing (default) — every probe goes through cortiq-auto; each row
     shows the router's task label judged against the expected one (✓/✗ with
     "expected: …"), the complexity tier, the chosen model with what that
     request cost, and a task-rule badge when a routing rule fired. KPIs:
     answer quality, classification accuracy, TTFT, tok/s and money spent,
     plus a "requests went to" distribution with per-model cost.
  🎯 Individual models — the old head-to-head comparison, now as the clearly
     separate option (model checkboxes appear only here).

The suite covers all 8 taxonomy labels (added summarization, creative-writing
and chitchat probes; expected labels annotated per test). Results are LIVE:
the table grows a row after every test (with a small fade-in), the progress
card shows the prompt being asked, and a 🏁 verdict toast lands at the end
with the score and the total spend.

12 new strings in all 7 languages.
2026-08-07 19:04:41 +03:00
infosave2007 1120d8f11a feat(benchmark): the built-in benchmark, redesigned into the console
The old standalone benchmark.html (Russian-only, Tailwind+Chart.js from CDNs,
never shipped in the crate) becomes a proper sidebar view. Same scoring ideas,
reimplemented: keyword checks with |-synonyms, think-tag stripping, tail-loop
detection, pass/partial/fail — plus what the old page never had: runs go
through the LIVE pipeline (pinned per model, streamed), so every test also
measures tok/s and time-to-first-token with the gateway's real token counts.

A built-in suite of 12 language-neutral probes (math, logic, code, QA,
instruction-following, extraction, translation, JSON formatting); the ⚡ quick
mode runs the 6 short ones — about a minute on a local model. Results: score
bars per model sorted best-first, a per-test grid (click a cell for the full
answer), previous runs and custom tests kept in the browser.

22 new strings in all 7 languages.
2026-08-07 18:56:47 +03:00
48 changed files with 3317 additions and 186 deletions
+149
View File
@@ -3,6 +3,155 @@
All notable changes to this project are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/).
## [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
- **Linked complexity-boundary sliders** on the Routing page: one track, two draggable handles
(the low|medium and medium|high boundaries) over green/amber/red zones. Handles push each
other, the numbers ride on the handles and the legend updates live — no hand-typed score
ranges. The gateway re-maps the router's continuous complexity score with YOUR boundaries
(`[routing.bands]`, validated `0 < low < medium < 1`) before tier chains, escalation and
stats; unchecked = the router's own bands, untouched behavior.
### Fixed
- Complexity tier names are translated everywhere they appear (bands legend, tier badges, pair
selects, benchmark column, Playground decision): лёгкий/средний/сложный, leicht/mittel/schwer,
facile/moyen/difficile, fácil/medio/difícil, 简单/中等/困难, kolay/orta/zor.
- Benchmark route-judged retries re-ask the SAME prompt after a pause before reaching for a
fresh template — difficulty is judged in the background, so the second occurrence is the
honest steady-state answer.
## [0.2.40] - 2026-08-08
### Added
- **Per-key spend accounting** on the API-keys page: requests, tokens in/out and cost per key
for a selectable window (24h / 7d / 30d) plus a cumulative all-time cost column — "which
department spends what" at a glance.
- **Bounded stats on disk**: once `stats.jsonl` outgrows `stats.max_file_mb` (default 16, 0
disables), records older than the retention window fold into cumulative per-account totals in
a small sidecar file (tmp+rename on both) and leave the log. All-time accounting survives
compaction and restarts; nothing is ever counted twice.
- **Per-complexity models inside a task rule**: each task is edited as rows of
"model + complexity" pairs — `[routing.tasks.code] low=["cheap"] high=["best"] any=[...]`.
The tier-specific pick leads its own tier, `any` (and legacy plain arrays) leads everywhere,
the global tier chain and `default` always remain as fallback, and high verdicts still
escalate over any-tier pins.
### Changed
- Benchmark hard probes are generated from wider interdisciplinary pools (6 families each) and
retry once with a fresh template — the router's novelty decay no longer flips them red.
### Fixed
- The `task-rule` badge is honest: it appears only when the rule actually chose the head of the
candidate chain, not whenever a rule merely exists for the task.
## [0.2.39] - 2026-08-07
### Added
- **Benchmark, redesigned into the console** (⏱ sidebar entry): two explicit modes. **Smart
routing** (default) sends every probe through `cortiq-auto` and judges the ROUTER — task label
vs expected (✓/✗), complexity tier, chosen model with per-request cost, task-rule badge —
with KPIs for answer quality, classification accuracy, TTFT, tok/s and money spent, plus a
"requests went to" per-model distribution. **Individual models** compares pinned models
head-to-head. Results render live row-by-row, runs land in a browser-side history (per-run ×
and Clear-all), custom tests supported, ⚡ quick subset (7) vs full suite (17).
- **Hard probes that exercise the high tier**: two route-judged tests (science / ops pools)
generated fresh each run — interdisciplinary prompts rate high reliably while fixed wordings
decay with the router's novelty signal. Pass = the router said `high` and the request escalated;
the tiny token budget keeps them at cents even on the priciest model.
- **One-paste harness setup** on the API-keys page: Claude Code (native Anthropic `/v1/messages`,
now on by default, one-click enable for older configs), Codex, OpenCode, Continue and Cline —
each pre-filled with the gateway URL and the freshly created key. The gateway detects harnesses
already installed on its machine (✓ on the tab, "already configured for this gateway" status).
- **Stale-tab detection**: open consoles notice a gateway restart via the meta start marker and
show a reload banner instead of silently running an old interface.
- Log viewer Clear button (`DELETE /admin/api/logs`).
### Changed
- **High complexity escalates over task rules**: at tier `high` the tier chain leads and the task
rule demotes to fallback — a `code → cheap-model` rule no longer pins the hardest prompts to
the cheap model. Low/medium keep task-rule-first semantics (documented in docs/ROUTING).
- **Honest stream costs**: the final cost rides a closing SSE event (`cortiq_cost_usd`) since the
initial header is written before generation; money renders with 4 decimals (last two smaller)
and grows precision below $0.0001 instead of collapsing to $0.0000.
- **Oracle-sized timeouts**: router default 800 ms → 15 s (hosted escalations take ~10 s);
provider clients gained a 10 s connect timeout (dead upstreams fail over fast) with no overall
cap so long generations and streams are never cut.
- Benchmark fairness: +256-token reasoning headroom (thinking models aren't cut mid-`<think>`),
router degradation shown as "⚠ router degraded" and excluded from accuracy, "budget spent on
reasoning" instead of a misleading "empty answer", creative-writing budget 96 → 512.
- New-key dialog simplified to account / rate limit / optional custom value.
### Fixed
- `cortiq bench` results showed "error" with newer cortiq releases that pretty-print `--json`:
the parser now finds the last balanced JSON object anywhere in the output, and tok/s prefers
steady decode speed (the old heuristic could report `allocs_per_token` as tok/s).
## [0.2.38] - 2026-08-07
### Added
Generated
+1 -1
View File
@@ -285,7 +285,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "cortiq-gateway"
version = "0.2.38"
version = "0.2.44"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cortiq-gateway"
version = "0.2.38"
version = "0.2.44"
edition = "2021"
rust-version = "1.75"
license = "Apache-2.0"
+2
View File
@@ -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
+2
View File
@@ -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).
---
## 🖥️ Панель управления
+5 -1
View File
@@ -71,7 +71,7 @@ openai_chat = true
openai_completions = true
openai_embeddings = true
openai_models = true
anthropic_messages = false
anthropic_messages = true
mcp = false
native_passthrough = true
@@ -119,6 +119,10 @@ enabled = true
file = "config/stats.jsonl" # empty = in-memory only
ring_size = 500 # how many recent requests to keep in the feed
retention = "7d" # aggregate/series depth (replayed on startup)
# stats.jsonl is compacted once it exceeds this size: old records fold into
# cumulative per-account totals (sidecar file), so per-key accounting survives
# while the disk stays bounded. 0 disables.
max_file_mb = 16
# ============================================================================
# Semantic cache — returns a cached answer for prompts semantically near a
+122
View File
@@ -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.
+123
View File
@@ -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` ограничивает диск.
+22 -1
View File
@@ -177,7 +177,28 @@ code = ["claude-sonnet", "gpt"] # code goes to the strongest coder first
A matching rule's models go **first** in the candidate chain; the complexity
chain follows as fallback, so a task pin never weakens failover. Tasks without
a rule route by complexity alone. The available labels come from the router
a rule route by complexity alone.
**Per-complexity models for a task.** A task rule also accepts a per-tier
map — "which model serves this task at each complexity":
```toml
[routing.tasks.code]
low = ["cheap-coder"] # routine code stays cheap
high = ["best-coder"] # hard code goes to the strongest coder
any = ["generic-coder"] # leads at every tier after the tier-specific pick
```
The tier-specific list leads its own tier; `any` (and the legacy plain-array
form) leads at every tier; the global tier chain and `default` always remain
as fallback, so a task rule never weakens failover. The admin console's
Routing page edits this as rows of “model + complexity” pairs per task.
**Exception — high complexity escalates.** When the router rates a request
`high` (and a `high` chain is configured), the tier chain leads and the task
rule demotes to fallback. Otherwise a rule like `code → cheap-model` would pin
even the hardest prompts to the cheap model and the tier system would never
get a say. Low/medium keep task-rule-first semantics. The available labels come from the router
(`GET /v1/taxonomies`) — the admin console's Routing page builds the editor
from your account's live taxonomy and shows each task's share of recent
traffic. When a rule supplied the head of the chain the response reports
+22 -1
View File
@@ -175,7 +175,28 @@ code = ["claude-sonnet", "gpt"] # код — сильнейшему ко
Модели подходящего правила идут **первыми** в цепочке кандидатов; цепочка по
сложности следует за ними как резерв — правило по задаче не ослабляет failover.
Задачи без правила маршрутизируются только по сложности. Список меток приходит
Задачи без правила маршрутизируются только по сложности.
**Модели по сложности внутри задачи.** Правило задачи принимает и карту по
уровням — «какая модель обслуживает задачу на какой сложности»:
```toml
[routing.tasks.code]
low = ["cheap-coder"] # рутинный код — дёшево
high = ["best-coder"] # сложный код — сильнейшему кодеру
any = ["generic-coder"] # ведёт на всех уровнях после уровневого выбора
```
Список конкретного уровня ведёт на своём уровне; `any` (и старая форма-массив)
ведёт на всех; глобальная цепочка уровня и `default` всегда остаются резервом —
правило задачи не ослабляет failover. На вкладке Роутинг это редактируется
строками пар «модель + сложность» у каждой задачи.
**Исключение — высокая сложность эскалирует.** Когда роутер оценил запрос как
`high` (и цепочка `high` настроена), цепочка уровня идёт первой, а правило
задачи становится резервом. Иначе правило вида `code → дешёвая-модель`
приковало бы к дешёвой модели даже самые сложные запросы, и система уровней
не имела бы права голоса. Для low/medium правило задачи ведёт, как и раньше. Список меток приходит
с роутера (`GET /v1/taxonomies`) — вкладка Роутинг строит редактор по живой
таксономии аккаунта и показывает долю каждой задачи в трафике. Когда цепочку
возглавило правило задачи, ответ сообщает `route_source: "router+task-rule"`.
+191 -23
View File
@@ -116,13 +116,7 @@ fn tiers_to_map(routing: &RoutingCfg) -> HashMap<String, Vec<String>> {
routing
.tiers
.iter()
.map(|(k, v)| {
let list = match v {
TierTargets::List(l) => l.clone(),
TierTargets::One(s) => vec![s.clone()],
};
(k.clone(), list)
})
.map(|(k, v)| (k.clone(), v.models()))
.collect()
}
@@ -160,6 +154,8 @@ pub fn api_routes(admin_token: String) -> Router<SharedState> {
"/admin/api/protocols",
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))
@@ -185,9 +181,10 @@ pub fn api_routes(admin_token: String) -> Router<SharedState> {
.route("/admin/api/requests", get(get_requests))
.route("/admin/api/test", post(run_test))
.route("/admin/api/test/stream", post(run_test_stream))
.route("/admin/api/logs", get(get_logs))
.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))
@@ -599,8 +596,18 @@ async fn provider_models(
}
async fn meta(State(state): State<SharedState>) -> ApiResult {
// process start marker: open tabs poll this and offer a reload when the
// gateway (and thus the embedded SPA) has been restarted/updated
static STARTED: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
let started = *STARTED.get_or_init(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
});
ok(json!({
"version": env!("CARGO_PKG_VERSION"),
"started": started,
"update_available": state.gateway_update.lock().unwrap().clone(),
"admin_open_public": state
.admin_open_public
@@ -747,6 +754,13 @@ fn strip_id_from_routing(routing: &mut RoutingCfg, id: &str) {
}
for tt in routing.tiers.values_mut().chain(routing.tasks.values_mut()) {
match tt {
crate::config::TierTargets::Map(m) => {
for v in m.values_mut() {
if let crate::config::TaskTierVal::List(l) = v {
l.retain(|x| x != id);
}
}
}
crate::config::TierTargets::List(v) => v.retain(|x| x != id),
crate::config::TierTargets::One(s) if s == id => {
*tt = crate::config::TierTargets::List(Vec::new());
@@ -755,10 +769,7 @@ fn strip_id_from_routing(routing: &mut RoutingCfg, id: &str) {
}
}
// an emptied task rule means "no rule", not "no candidates"
routing.tasks.retain(|_, tt| match tt {
crate::config::TierTargets::List(v) => !v.is_empty(),
crate::config::TierTargets::One(s) => !s.is_empty(),
});
routing.tasks.retain(|_, tt| !tt.models().is_empty());
}
/// Whether any managed server or static model in `cfg` still uses `path` as its
@@ -899,23 +910,30 @@ async fn probe_model(State(state): State<SharedState>, Path(id): Path<String>) -
async fn get_routing(State(state): State<SharedState>) -> ApiResult {
let live = state.live();
let tasks: HashMap<String, Vec<String>> = live
let tasks: HashMap<String, serde_json::Value> = live
.cfg
.routing
.tasks
.iter()
.map(|(k, v)| {
(
k.clone(),
match v {
TierTargets::List(l) => l.clone(),
TierTargets::One(s) => vec![s.clone()],
},
)
(k.clone(), {
let (any, per) = v.task_rule();
let mut o = serde_json::Map::new();
if !any.is_empty() {
o.insert("any".into(), json!(any));
}
for (tier, models) in per {
if !models.is_empty() {
o.insert(tier, json!(models));
}
}
serde_json::Value::Object(o)
})
})
.collect();
ok(json!({
"tiers": tiers_to_map(&live.cfg.routing),
"bands": live.cfg.routing.bands,
"tasks": tasks,
"default": live.cfg.routing.default,
"policy": live.cfg.routing.policy,
@@ -962,7 +980,9 @@ struct RoutingBody {
#[serde(default)]
tiers: HashMap<String, Vec<String>>,
#[serde(default)]
tasks: HashMap<String, Vec<String>>,
tasks: HashMap<String, TierTargets>,
#[serde(default)]
bands: Option<crate::config::TierBands>,
default: String,
#[serde(default)]
policy: RoutingPolicy,
@@ -979,9 +999,10 @@ async fn put_routing(State(state): State<SharedState>, Json(body): Json<RoutingB
tasks: body
.tasks
.into_iter()
.filter(|(_, v)| !v.is_empty()) // empty rule = "по сложности", not a rule
.map(|(k, v)| (k, TierTargets::List(v)))
// empty rule (no models AND no forced tier) = "по сложности"
.filter(|(_, v)| !v.models().is_empty())
.collect(),
bands: body.bands,
default: body.default,
policy: body.policy,
};
@@ -989,6 +1010,129 @@ 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".
// Detection is best-effort: binaries in PATH + common install dirs, config
// files under the user's home. Works on Linux/macOS/Windows.
fn harness_home() -> Option<std::path::PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(std::path::PathBuf::from)
}
fn find_bin(name: &str) -> bool {
let exts: &[&str] = if cfg!(windows) {
&[".exe", ".cmd", ".bat", ""]
} else {
&[""]
};
let mut dirs: Vec<std::path::PathBuf> = std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).collect())
.unwrap_or_default();
if let Some(home) = harness_home() {
// GUI-launched processes often miss the shell PATH — add usual suspects
for extra in [
".local/bin",
".cargo/bin",
"bin",
".bun/bin",
".npm-global/bin",
"AppData/Roaming/npm",
] {
dirs.push(home.join(extra));
}
}
dirs.extend(["/usr/local/bin", "/opt/homebrew/bin"].map(std::path::PathBuf::from));
dirs.iter()
.any(|d| exts.iter().any(|e| d.join(format!("{name}{e}")).is_file()))
}
/// Does the harness config mention cortiq (i.e. was it pointed at this gateway)?
fn config_mentions_cortiq(rel: &str) -> bool {
harness_home()
.map(|h| h.join(rel))
.and_then(|p| std::fs::read_to_string(p).ok())
.map(|c| c.to_lowercase().contains("cortiq"))
.unwrap_or(false)
}
async fn get_harnesses() -> ApiResult {
let harnesses = tokio::task::spawn_blocking(|| {
let vscode_ext_glob = |marker: &str| -> bool {
let Some(home) = harness_home() else {
return false;
};
for extdir in [".vscode/extensions", ".vscode-server/extensions"] {
if let Ok(rd) = std::fs::read_dir(home.join(extdir)) {
for e in rd.flatten() {
if e.file_name().to_string_lossy().starts_with(marker) {
return true;
}
}
}
}
false
};
json!({
"Claude Code": { "installed": find_bin("claude"), "configured": serde_json::Value::Null },
"Codex": { "installed": find_bin("codex"), "configured": config_mentions_cortiq(".codex/config.toml") },
"OpenCode": { "installed": find_bin("opencode"), "configured": config_mentions_cortiq(".config/opencode/opencode.json") },
"Continue": { "installed": harness_home().map(|h| h.join(".continue").is_dir()).unwrap_or(false), "configured": config_mentions_cortiq(".continue/config.yaml") },
"Cline": { "installed": vscode_ext_glob("saoudrizwan.claude-dev"), "configured": serde_json::Value::Null },
})
})
.await
.unwrap_or_else(|_| json!({}));
ok(harnesses)
}
// ───────────────────────────── protocols ───────────────────────────────────
async fn get_protocols(State(state): State<SharedState>) -> ApiResult {
@@ -1260,6 +1404,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,
})
})
@@ -1275,6 +1420,8 @@ struct NewKey {
#[serde(default)]
rate_per_min: u32,
#[serde(default)]
monthly_budget_usd: Option<f64>,
#[serde(default)]
allow_models: Vec<String>,
}
@@ -1293,6 +1440,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
@@ -2050,6 +2198,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)
}
@@ -2113,12 +2262,31 @@ async fn get_logs(Query(q): Query<RecentQuery>) -> ApiResult {
ok(json!({ "lines": crate::logbuf::snapshot(limit) }))
}
async fn clear_logs() -> ApiResult {
crate::logbuf::clear();
ok(json!({ "ok": true }))
}
/// Host metrics snapshot: CPU, RAM, free disk at the models dir, gateway RSS.
async fn get_system(State(state): State<SharedState>) -> ApiResult {
let snap = state.sysmon.snapshot();
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
+71 -12
View File
@@ -32,11 +32,19 @@ fn now() -> u64 {
.as_secs()
}
/// tok/s out of the benchmark's JSON: first numeric field whose key mentions
/// tokens-per-second (the exact name has varied across cortiq releases).
/// tok/s out of the benchmark's JSON: known field names first (steady decode
/// speed is the honest number), then any `*tok_s*` field as a fallback. The
/// old loose "contains tok + per" heuristic could grab `allocs_per_token`.
fn extract_tok_s(v: &serde_json::Value) -> Option<f64> {
let obj = v.as_object()?;
for want in ["tok_s", "tok_per_s", "tokens_per_sec", "decode_tok_s"] {
for want in [
"decode_tok_s_steady",
"decode_tok_s_incl_prefill",
"decode_tok_s",
"tok_s",
"tok_per_s",
"tokens_per_sec",
] {
if let Some(n) = obj.get(want).and_then(|x| x.as_f64()) {
return Some(n);
}
@@ -44,12 +52,44 @@ fn extract_tok_s(v: &serde_json::Value) -> Option<f64> {
obj.iter()
.find(|(k, val)| {
let k = k.to_ascii_lowercase();
(k.contains("tok") && (k.contains("/s") || k.contains("_s") || k.contains("per")))
&& val.as_f64().is_some()
(k.contains("tok_s") || k.contains("tokens_per")) && val.as_f64().is_some()
})
.and_then(|(_, val)| val.as_f64())
}
/// Last parseable JSON object in mixed CLI output. Newer cortiq releases
/// pretty-print the `--json` result over many lines, older ones emit a single
/// line — this handles both, skipping any log noise around it.
fn last_json_object(text: &str) -> Option<serde_json::Value> {
let mut end = text.len();
while let Some(close) = text[..end].rfind('}') {
let mut depth = 0i32;
let mut start = None;
for (i, c) in text[..=close].char_indices().rev() {
match c {
'}' => depth += 1,
'{' => {
depth -= 1;
if depth == 0 {
start = Some(i);
break;
}
}
_ => {}
}
}
if let Some(st) = start {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text[st..=close]) {
if v.is_object() {
return Some(v);
}
}
}
end = close;
}
None
}
impl BenchStore {
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
@@ -125,13 +165,7 @@ pub fn start(
if n > 30 {
run.log.drain(0..n - 30);
}
// the --json contract: one JSON object on its own line
let parsed = text
.lines()
.rev()
.map(str::trim)
.find(|l| l.starts_with('{') && l.ends_with('}'))
.and_then(|l| serde_json::from_str::<serde_json::Value>(l).ok());
let parsed = last_json_object(&text);
match parsed {
Some(v) if o.status.success() => {
run.tok_s = extract_tok_s(&v);
@@ -163,4 +197,29 @@ mod tests {
let v = serde_json::json!({"decode_tokens_per_sec": 31.0});
assert_eq!(extract_tok_s(&v), Some(31.0));
}
#[test]
fn steady_decode_wins_and_allocs_never_matches() {
let v = serde_json::json!({
"allocs_per_token": 769.4,
"decode_tok_s_incl_prefill": 9.98,
"decode_tok_s_steady": 11.86,
"prefill_tok_s": 25.5,
});
assert_eq!(extract_tok_s(&v), Some(11.86));
let v = serde_json::json!({"allocs_per_token": 769.4, "seq_len": 140});
assert_eq!(extract_tok_s(&v), None);
}
#[test]
fn json_found_single_line_and_pretty() {
let single = "noise\n{\"tok_s\": 5.0}\nmore noise";
assert_eq!(last_json_object(single).unwrap()["tok_s"], 5.0);
let pretty = "INFO loading\n{\n \"decode_tok_s_steady\": 11.86,\n \"seq_len\": 140\n}\nINFO done {not json";
assert_eq!(
last_json_object(pretty).unwrap()["decode_tok_s_steady"],
11.86
);
assert!(last_json_object("no braces here").is_none());
}
}
+1 -1
View File
@@ -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
View File
@@ -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
+188 -15
View File
@@ -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()
@@ -305,7 +310,9 @@ fn default_router_enabled() -> bool {
true
}
fn default_router_timeout() -> u64 {
800
// the hosted router escalates hard prompts to an oracle (~10 s);
// an 800 ms budget silently degraded every one of those to the fallback
15_000
}
fn default_verify_tls() -> bool {
true
@@ -402,17 +409,143 @@ pub struct RoutingCfg {
/// called "tasks" — serde extracts it before the flattened tier map.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub tasks: HashMap<String, TierTargets>,
/// Local complexity bands: re-map the router's continuous score into
/// low/medium/high with YOUR OWN boundaries. The hosted router's bands
/// are global for all customers; these are per-gateway. `None` = keep
/// the router's tier verdict as delivered.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bands: Option<TierBands>,
}
/// Score boundaries: `score ≤ low` → low, `≤ medium` → medium, else high.
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
pub struct TierBands {
pub low: f32,
pub medium: f32,
}
impl TierBands {
pub fn tier_of(&self, score: f32) -> &'static str {
if score <= self.low {
"low"
} else if score <= self.medium {
"medium"
} else {
"high"
}
}
}
/// A routing tier is either a list of model_ids or the string `default`.
/// (`default` is handled by a separate field; only tier lists appear here.)
///
/// TASK rules additionally accept a per-complexity map — "which model serves
/// this task at each tier":
///
/// ```toml
/// [routing.tasks.code]
/// low = ["cheap-coder"]
/// high = ["big-model"]
/// any = ["fallback"] # leads at every tier after the tier-specific list
/// ```
///
/// The plain-array form stays valid and means "leads at any tier". The
/// transitional `{ models = [...], tier = "high" }` form is normalized into
/// the map on load. Untagged: map is tried first, arrays keep parsing as is.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum TierTargets {
Map(HashMap<String, TaskTierVal>),
List(Vec<String>),
One(String),
}
/// A value inside a task's per-tier map: a model list, a single model, or the
/// transitional string value of the legacy `tier = "high"` key.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum TaskTierVal {
List(Vec<String>),
Str(String),
}
impl TaskTierVal {
fn list(&self) -> Vec<String> {
match self {
TaskTierVal::List(v) => v.clone(),
TaskTierVal::Str(s) => vec![s.clone()],
}
}
}
pub const TASK_TIER_KEYS: [&str; 4] = ["any", "low", "medium", "high"];
impl TierTargets {
/// Every referenced model id, whatever the syntactic form (validation).
pub fn models(&self) -> Vec<String> {
match self {
TierTargets::Map(m) => {
let mut out = Vec::new();
for (k, v) in m {
if k == "tier" {
continue; // legacy marker, not a model list
}
out.extend(v.list());
}
out
}
TierTargets::List(v) => v.clone(),
TierTargets::One(s) => vec![s.clone()],
}
}
/// Normalized task rule: `(any_tier_models, per_tier_models)`.
/// Legacy `{models, tier}` becomes `per[tier] = models`; a plain array
/// becomes `any`.
pub fn task_rule(&self) -> (Vec<String>, HashMap<String, Vec<String>>) {
match self {
TierTargets::Map(m) => {
let mut any: Vec<String> = Vec::new();
let mut per: HashMap<String, Vec<String>> = HashMap::new();
let legacy_tier = m.get("tier").and_then(|v| match v {
TaskTierVal::Str(s) => Some(s.clone()),
_ => None,
});
for (k, v) in m {
match k.as_str() {
"tier" => {}
"any" | "models" => any.extend(v.list()),
"low" | "medium" | "high" => {
per.entry(k.clone()).or_default().extend(v.list())
}
_ => {}
}
}
if let Some(tier) = legacy_tier {
// legacy pin: the models were meant for that tier
let moved = std::mem::take(&mut any);
per.entry(tier).or_default().extend(moved);
}
(any, per)
}
TierTargets::List(v) => (v.clone(), HashMap::new()),
TierTargets::One(s) => (vec![s.clone()], HashMap::new()),
}
}
/// Unknown keys inside a task map (validation).
pub fn invalid_task_keys(&self) -> Vec<String> {
match self {
TierTargets::Map(m) => m
.keys()
.filter(|k| !TASK_TIER_KEYS.contains(&k.as_str()) && *k != "models" && *k != "tier")
.cloned()
.collect(),
_ => Vec::new(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RoutingPolicy {
#[serde(default = "default_mode")]
@@ -492,6 +625,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>,
}
@@ -585,6 +721,12 @@ pub struct StatsCfg {
/// Retention window for aggregates/time-series replayed on startup, e.g. `7d`.
#[serde(default = "default_stats_retention")]
pub retention: String,
/// Compact the JSONL file once it exceeds this size: records older than
/// `retention` fold into cumulative per-account totals (a small sidecar
/// file) and are removed, so per-key accounting survives while the disk
/// stays bounded. 0 disables compaction.
#[serde(default = "default_stats_max_file_mb")]
pub max_file_mb: u64,
}
impl Default for StatsCfg {
fn default() -> Self {
@@ -593,6 +735,7 @@ impl Default for StatsCfg {
file: default_stats_file(),
ring_size: default_stats_ring(),
retention: default_stats_retention(),
max_file_mb: default_stats_max_file_mb(),
}
}
}
@@ -608,6 +751,9 @@ fn default_stats_ring() -> usize {
fn default_stats_retention() -> String {
"7d".into()
}
fn default_stats_max_file_mb() -> u64 {
16
}
/// Self-warming shadow loop (docs/SELF_WARMING_GATEWAY.md). While a task
/// label is not yet promoted, the client is served the CLOUD answer; in
@@ -691,9 +837,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,
@@ -715,7 +862,7 @@ impl Default for CacheCfg {
}
}
}
fn default_semcache_threshold() -> f32 {
fn default_semcache_threshold() -> f64 {
0.92
}
fn default_semcache_ttl() -> String {
@@ -736,7 +883,7 @@ impl Default for Config {
router: RouterCfg {
enabled: true,
url: "https://138.226.222.209".to_string(),
timeout_ms: 12_000, // hard prompts escalate to an oracle (~10 s)
timeout_ms: 15_000, // hard prompts escalate to an oracle (~10 s)
verify_tls: false, // the hosted endpoint is IP-bound
api_key_env: Some("CORTIQ_ROUTER_KEY".to_string()),
taxonomy_id: Some("data-assistant".to_string()),
@@ -748,6 +895,7 @@ impl Default for Config {
default: String::new(),
policy: RoutingPolicy::default(),
tasks: HashMap::new(),
bands: None,
},
breaker: BreakerCfg::default(),
protocols: ProtocolsCfg {
@@ -755,7 +903,9 @@ impl Default for Config {
openai_completions: true,
openai_embeddings: true,
openai_models: true,
anthropic_messages: false,
// on by default: the API-keys view ships a Claude Code
// auto-setup snippet that targets /v1/messages
anthropic_messages: true,
mcp: false,
native_passthrough: true,
},
@@ -774,14 +924,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()
@@ -859,6 +1018,7 @@ impl Config {
}
for (tier, targets) in &self.routing.tiers {
let list = match targets {
TierTargets::Map(_) => targets.models(),
TierTargets::List(v) => v.clone(),
TierTargets::One(s) => vec![s.clone()],
};
@@ -872,12 +1032,17 @@ impl Config {
}
}
}
if let Some(b) = &self.routing.bands {
if !(b.low > 0.0 && b.low < b.medium && b.medium < 1.0) {
anyhow::bail!(
"config: routing.bands must satisfy 0 < low < medium < 1 (got {} / {})",
b.low,
b.medium
);
}
}
for (task, targets) in &self.routing.tasks {
let list = match targets {
TierTargets::List(v) => v.clone(),
TierTargets::One(s) => vec![s.clone()],
};
for id in &list {
for id in &targets.models() {
if !ids.contains(id.as_str()) {
anyhow::bail!(
"config: routing.tasks.{} references unknown model id '{}'",
@@ -886,6 +1051,13 @@ impl Config {
);
}
}
if let Some(bad) = targets.invalid_task_keys().first() {
anyhow::bail!(
"config: routing.tasks.{}.{} is not a tier (use any/low/medium/high)",
task,
bad
);
}
}
Ok(())
}
@@ -977,6 +1149,7 @@ min_class = { low = "cheap", medium = "mid" }
let low = back.routing.tiers.get("low").expect("low tier present");
match low {
TierTargets::Map(_) => assert_eq!(low.models(), vec!["local-qwen".to_string()]),
TierTargets::List(v) => assert_eq!(v, &vec!["local-qwen".to_string()]),
TierTargets::One(s) => assert_eq!(s, "local-qwen"),
}
+7
View File
@@ -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",
+5
View File
@@ -23,6 +23,11 @@ pub fn snapshot(limit: usize) -> Vec<String> {
.collect()
}
/// Drop everything in the ring (the admin console's "clear" button).
pub fn clear() {
ring().lock().unwrap().clear();
}
pub fn push_line(line: &str) {
let line = line.trim_end();
if line.is_empty() {
+15 -3
View File
@@ -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(())
}
+93 -27
View File
@@ -183,6 +183,9 @@ impl Pipeline {
&self,
req: &ChatRequest,
live: &crate::state::Live,
// 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>)> {
// 1. Extract text for classification
let text = self.extract_text(
@@ -231,6 +234,19 @@ impl Pipeline {
let prof = profile.as_deref().unwrap_or(&live.cfg.route.profile);
match live.router.route(&text, prof).await {
Ok(Some(dec)) => {
let mut dec = dec;
// per-gateway complexity bands: re-map the
// router's continuous score with the user's
// own boundaries (sliders on the Routing page)
if let Some(b) = &live.cfg.routing.bands {
if matches!(
dec.complexity_tier.as_str(),
"low" | "medium" | "high"
) {
dec.complexity_tier =
b.tier_of(dec.complexity_score).to_string();
}
}
let src = dec.source.clone();
let (cands, task_rule) = live
.routing
@@ -261,10 +277,13 @@ impl Pipeline {
// Filter candidates by capabilities (e.g. tools support)
let needs_tools = !req.tools.is_empty();
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
@@ -293,13 +312,49 @@ 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.
if final_candidates.is_empty() {
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());
}
}
for id in widened {
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"
);
}
}
if final_candidates.is_empty() {
return Err(GatewayError::UpstreamUnavailable(
"No healthy model available that satisfies required capabilities (e.g. tools)"
@@ -311,7 +366,9 @@ impl Pipeline {
async fn run_inner(&self, 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) =
self.resolve(&req, &live, &detected_tools).await?;
// Serving-when-promoted (gated): a promoted task-label is served by the
// local model FIRST (failover to cloud on error). Per-request VETO uses
@@ -458,13 +515,15 @@ 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) =
match self.resolve(&req, &live, &detected_tools).await {
Ok(t) => t,
Err(e) => {
record_error(state, &account, &protocol, &directive, started, &e);
return Err(e);
}
};
let mut last_err = None;
let mut attempt = 0usize;
@@ -798,28 +857,35 @@ fn tap_stream(
// inject one final OpenAI-shaped usage event with the gateway's
// estimate (~4 chars/token), flagged `cortiq_estimated` so nobody
// mistakes it for the tokenizer's truth.
if done_marker && !got_usage {
let est_completion = if delta_chunks > 0 {
delta_chunks
} else {
(delta_chars / 4) as u32
};
let est_prompt = (prompt_chars / 4) as u32;
let evt = serde_json::json!({
if done_marker {
// The initial X-Cortiq-Cost-USD header is always 0 — cost is
// only known after generation. Ship the final number (and the
// token estimate when the provider sent none) ahead of [DONE].
if !got_usage {
prompt_tokens = (prompt_chars / 4) as u32;
completion_tokens = if delta_chunks > 0 {
delta_chunks
} else {
(delta_chars / 4) as u32
};
}
let cost = provider.price(prompt_tokens, completion_tokens);
let mut evt = serde_json::json!({
"object": "chat.completion.chunk",
"model": info.selected_model,
"choices": [],
"usage": {
"prompt_tokens": est_prompt,
"completion_tokens": est_completion,
"total_tokens": est_prompt + est_completion,
},
"cortiq_estimated": true,
"cortiq_cost_usd": cost,
});
if !got_usage {
evt["usage"] = serde_json::json!({
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
});
evt["cortiq_estimated"] = serde_json::json!(true);
got_usage = true;
}
yield Ok(bytes::Bytes::from(format!("data: {evt}\n\n")));
prompt_tokens = est_prompt;
completion_tokens = est_completion;
got_usage = true;
}
yield item;
}
+2
View File
@@ -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 {
@@ -108,6 +109,7 @@ 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()
},
};
+51 -1
View File
@@ -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))
}
+2
View File
@@ -75,6 +75,7 @@ fn parse_routing(model: &str) -> RoutingDirective {
async fn handler(
State(state): State<SharedState>,
acct: Option<axum::Extension<super::AccountTag>>,
Json(req): Json<OpenAiChatRequest>,
) -> Result<Response> {
// hot protocol toggle: if the adapter is disabled in config — return 404
@@ -104,6 +105,7 @@ async fn handler(
stream: req.stream,
meta: RequestMeta {
protocol: "openai_chat".into(),
account: acct.map(|e| e.0 .0.clone()).unwrap_or_default(),
..Default::default()
},
};
+2
View File
@@ -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 {
@@ -89,6 +90,7 @@ 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()
},
};
+7 -1
View File
@@ -32,7 +32,13 @@ impl AnthropicProvider {
caps: Caps::from_list(&cfg.caps),
price_in: cfg.price_in,
price_out: cfg.price_out,
http: reqwest::Client::new(),
// connect timeout: a dead host fails over in seconds instead of
// hanging the request; NO overall timeout — that would cut long
// generations and streams (OpenRouter can be slow legitimately)
http: reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| reqwest::Client::new()),
})
}
+7 -1
View File
@@ -28,7 +28,13 @@ impl OpenAiProvider {
caps: Caps::from_list(&cfg.caps),
price_in: cfg.price_in,
price_out: cfg.price_out,
http: reqwest::Client::new(),
// connect timeout: a dead host fails over in seconds instead of
// hanging the request; NO overall timeout — that would cut long
// generations and streams (OpenRouter can be slow legitimately)
http: reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| reqwest::Client::new()),
})
}
+1 -1
View File
@@ -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,
+147 -27
View File
@@ -4,16 +4,21 @@
use crate::config::{Config, TierTargets};
fn to_list(t: &TierTargets) -> Vec<String> {
match t {
TierTargets::List(v) => v.clone(),
TierTargets::One(s) => vec![s.clone()],
}
t.models()
}
/// A task rule, normalized: per-tier model lists ("which model serves this
/// task at each complexity") plus any-tier models that lead regardless.
#[derive(Clone, Default)]
pub struct TaskRule {
pub any: Vec<String>,
pub per: std::collections::HashMap<String, Vec<String>>,
}
pub struct RoutingTable {
tiers: std::collections::HashMap<String, Vec<String>>,
/// Per-task rules — a matching rule's models lead the candidate chain.
tasks: std::collections::HashMap<String, Vec<String>>,
tasks: std::collections::HashMap<String, TaskRule>,
default: String,
}
@@ -30,36 +35,78 @@ impl RoutingTable {
.routing
.tasks
.iter()
.map(|(task, targets)| (task.clone(), to_list(targets)))
.filter(|(_, v)| !v.is_empty())
.map(|(task, targets)| {
let (any, per) = targets.task_rule();
(task.clone(), TaskRule { any, per })
})
.filter(|(_, r)| !r.any.is_empty() || r.per.values().any(|v| !v.is_empty()))
.collect(),
default: cfg.routing.default.clone(),
}
}
/// Candidates for `(task, tier)`, in preference order, deduplicated:
/// the task rule's models first (when one exists), then the tier chain —
/// so a task pin adds preference without ever weakening failover.
/// Returns `(candidates, task_rule_matched)`.
/// Candidate chain for `(task, tier)` and whether a task rule shaped it.
///
/// Order of preference:
/// 1. the task's models FOR THIS TIER (the user said exactly which model
/// serves this task at this complexity);
/// 2. at tier=high — the global high chain (escalation: any-tier task
/// models must not undercut it), then the task's any-tier models;
/// otherwise the task's any-tier models, then the global tier chain;
/// 3. `default` as the last resort. Deduplicated, empties dropped.
pub fn candidates_for(&self, task: &str, tier: &str) -> (Vec<String>, bool) {
fn push(out: &mut Vec<String>, id: &str) {
if !id.is_empty() && !out.iter().any(|x| x == id) {
out.push(id.to_string());
}
}
let mut out: Vec<String> = Vec::new();
let matched = if let Some(rule) = self.tasks.get(task) {
out.extend(rule.iter().cloned());
true
} else {
false
};
let rule = self.tasks.get(task);
let tier_chain = self
.tiers
.get(tier)
.cloned()
.unwrap_or_else(|| vec![self.default.clone()]);
for id in tier_chain {
if !out.contains(&id) {
out.push(id);
if let Some(r) = rule {
if let Some(list) = r.per.get(tier) {
for id in list {
push(&mut out, id);
}
}
}
out.retain(|id| !id.is_empty());
let escalate = tier == "high" && self.tiers.contains_key("high");
if escalate {
// high verdicts escalate over any-tier task pins
for id in &tier_chain {
push(&mut out, id);
}
if let Some(r) = rule {
for id in &r.any {
push(&mut out, id);
}
}
} else {
if let Some(r) = rule {
for id in &r.any {
push(&mut out, id);
}
}
for id in &tier_chain {
push(&mut out, id);
}
}
let d = self.default.clone();
push(&mut out, &d);
// "task-rule" must mean "the rule chose the head of this chain" — a
// rule that exists but lost to the tier chain (math has a high pick,
// the request came in low) must not claim the credit.
let matched = match (rule, out.first()) {
(Some(r), Some(head)) => {
r.per.get(tier).map(|v| v.contains(head)).unwrap_or(false)
|| r.any.first() == Some(head)
}
_ => false,
};
(out, matched)
}
@@ -91,12 +138,33 @@ mod tests {
.collect(),
tasks: tasks
.iter()
.map(|(k, v)| (k.to_string(), v.iter().map(|s| s.to_string()).collect()))
.map(|(k, v)| {
(
k.to_string(),
TaskRule {
any: v.iter().map(|s| s.to_string()).collect(),
per: Default::default(),
},
)
})
.collect(),
default: default.to_string(),
}
}
fn with_task_tier_models(
mut t: RoutingTable,
task: &str,
tier: &str,
models: &[&str],
) -> RoutingTable {
t.tasks.entry(task.to_string()).or_default().per.insert(
tier.to_string(),
models.iter().map(|s| s.to_string()).collect(),
);
t
}
#[test]
fn task_rule_leads_tier_chain_follows() {
let t = table(
@@ -104,18 +172,70 @@ mod tests {
&[("low", &["cheap", "local"]), ("high", &["big"])],
"cheap",
);
let (c, matched) = t.candidates_for("translation", "high");
assert!(matched);
assert_eq!(c, vec!["local", "big"]);
// dedup: rule model already in the tier chain appears once, first
let (c, _) = t.candidates_for("translation", "low");
let (c, matched) = t.candidates_for("translation", "low");
assert!(matched);
assert_eq!(c, vec!["local", "cheap"]);
}
#[test]
fn high_tier_escalates_over_task_rule() {
let t = table(
&[("code", &["cheap-coder"])],
&[("low", &["cheap"]), ("high", &["big"])],
"cheap",
);
// medium (no chain defined → default): task rule still leads
let (c, m) = t.candidates_for("code", "medium");
assert!(m);
assert_eq!(c[0], "cheap-coder");
// high: tier chain leads, task rule follows as fallback
let (c, m) = t.candidates_for("code", "high");
// escalation put the global high chain first → the rule did not pick
// the head, so no task-rule credit
assert!(!m);
// default rides along as the very last fallback
assert_eq!(c, vec!["big", "cheap-coder", "cheap"]);
}
#[test]
fn per_tier_models_lead_their_tier() {
let base = table(
&[("code", &["generic-coder"])],
&[("low", &["cheap"]), ("high", &["big"])],
"cheap",
);
let t = with_task_tier_models(base, "code", "high", &["best-coder"]);
// at high: the task's HIGH pick leads, escalation chain next,
// any-tier task models after it, default last
let (c, m) = t.candidates_for("code", "high");
assert!(m);
assert_eq!(c, vec!["best-coder", "big", "generic-coder", "cheap"]);
// at low: any-tier task models lead as before (and the badge is
// honest — the head came from the rule)
let (c, m) = t.candidates_for("code", "low");
assert!(m);
assert_eq!(c, vec!["generic-coder", "cheap"]);
// a tier with an explicit pick but no any-models
let t2 = with_task_tier_models(
table(&[], &[("low", &["cheap"]), ("high", &["big"])], "cheap"),
"legal",
"high",
&["strong"],
);
let (c, m) = t2.candidates_for("legal", "high");
assert!(m);
assert_eq!(c, vec!["strong", "big", "cheap"]);
// the same rule at LOW did not shape the head → no task-rule credit
let (c, m) = t2.candidates_for("legal", "low");
assert!(!m);
assert_eq!(c, vec!["cheap"]);
}
#[test]
fn no_rule_falls_back_to_tier_then_default() {
let t = table(&[], &[("low", &["a"])], "d");
assert_eq!(t.candidates_for("qa", "low").0, vec!["a"]);
assert_eq!(t.candidates_for("qa", "low").0, vec!["a", "d"]);
let (c, matched) = t.candidates_for("qa", "unknown-tier");
assert!(!matched);
assert_eq!(c, vec!["d"]);
+39
View File
@@ -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(),
+257 -1
View File
@@ -55,7 +55,7 @@ pub struct RequestRecord {
pub error: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
#[derive(Clone, Debug, Default, Serialize, serde::Deserialize)]
pub struct Totals {
pub requests: u64,
pub ok: u64,
@@ -130,6 +130,20 @@ struct Inner {
/// Per-minute buckets — the single source for the windowed admin snapshot.
buckets: VecDeque<Bucket>,
recent: VecDeque<RequestRecord>,
/// Per-account totals over EVERY record still in the JSONL file (no
/// retention gate) — per-key accounting for the keys page.
by_account_alltime: HashMap<String, Totals>,
/// Per-account totals folded out of the file by compaction. alltime(key)
/// = 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,
}
pub struct Stats {
@@ -137,6 +151,7 @@ pub struct Stats {
file: Option<String>,
ring_size: usize,
retention_secs: u64,
max_file_bytes: u64,
inner: Mutex<Inner>,
}
@@ -152,9 +167,12 @@ impl Stats {
file,
ring_size: cfg.ring_size.max(1),
retention_secs: parse_duration_secs(&cfg.retention).unwrap_or(7 * 86_400),
max_file_bytes: cfg.max_file_mb.saturating_mul(1024 * 1024),
inner: Mutex::new(Inner::default()),
};
s.load_folded();
s.replay();
s.maybe_compact(true);
std::sync::Arc::new(s)
}
@@ -175,6 +193,12 @@ impl Stats {
// push everything to the "recent" ring (it self-limits by size),
// but apply to aggregates/series only entries within the window.
Self::push_recent(&mut inner, &rec, self.ring_size);
inner
.by_account_alltime
.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);
}
@@ -182,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 {
@@ -233,6 +284,13 @@ impl Stats {
let mut inner = self.inner.lock().unwrap();
Self::apply_aggregates(&mut inner, &rec, self.retention_secs);
Self::push_recent(&mut inner, &rec, self.ring_size);
inner
.by_account_alltime
.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 {
if let Ok(line) = serde_json::to_string(&rec) {
@@ -245,6 +303,141 @@ impl Stats {
}
}
}
self.maybe_compact(false);
}
fn totals_path(&self) -> Option<String> {
self.file.as_ref().map(|f| format!("{f}.totals.json"))
}
fn load_folded(&self) {
let Some(path) = self.totals_path() else {
return;
};
let Ok(content) = std::fs::read_to_string(&path) else {
return;
};
#[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
/// a crash mid-compaction loses nothing). `force` checks size regardless
/// of the write counter (startup).
fn maybe_compact(&self, force: bool) {
if self.max_file_bytes == 0 {
return;
}
let Some(path) = &self.file else { return };
{
let mut inner = self.inner.lock().unwrap();
if !force {
if inner.writes_since_check < 256 {
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);
if size <= self.max_file_bytes {
return;
}
let Ok(content) = std::fs::read_to_string(path) else {
return;
};
let cutoff = now_secs().saturating_sub(self.retention_secs);
let mut kept = String::with_capacity(content.len() / 2);
let mut inner = self.inner.lock().unwrap();
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
match serde_json::from_str::<RequestRecord>(line) {
Ok(rec) if rec.ts < cutoff => {
inner
.folded
.entry(rec.account.clone())
.or_default()
.apply(&rec);
// fold = leave by_account_alltime (record leaves the file,
// its share moves from "alltime" into "folded")
if let Some(t) = inner.by_account_alltime.get_mut(&rec.account) {
// subtract by rebuilding is costly; instead track the
// invariant additively: remove the record's share
t.requests = t.requests.saturating_sub(1);
if rec.outcome == "ok" {
t.ok = t.ok.saturating_sub(1);
} else {
t.errors = t.errors.saturating_sub(1);
}
if rec.failover {
t.failovers = t.failovers.saturating_sub(1);
}
t.prompt_tokens = t.prompt_tokens.saturating_sub(rec.prompt_tokens as u64);
t.completion_tokens = t
.completion_tokens
.saturating_sub(rec.completion_tokens as u64);
t.cost_usd = (t.cost_usd - rec.cost_usd).max(0.0);
t.latency_ms_sum = t.latency_ms_sum.saturating_sub(rec.latency_ms);
}
}
Ok(_) => {
kept.push_str(line);
kept.push('\n');
}
Err(_) => { /* unparseable line — drop */ }
}
}
// persist the folded totals FIRST, then swap the trimmed file in
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);
}
tracing::info!(
new_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0),
"stats log compacted (older records folded into cumulative totals)"
);
}
/// Snapshot for the admin API: aggregates + time series for `range_secs` + breakdown by `groupby`.
@@ -303,7 +496,27 @@ impl Stats {
.cmp(&a["requests"].as_u64().unwrap_or(0))
});
let mut alltime: HashMap<String, Totals> = inner.folded.clone();
for (k, t) in &inner.by_account_alltime {
alltime.entry(k.clone()).or_default().merge(t);
}
let alltime_by_account: serde_json::Map<String, serde_json::Value> = alltime
.iter()
.map(|(k, t)| {
(
k.clone(),
serde_json::json!({
"requests": t.requests,
"prompt_tokens": t.prompt_tokens,
"completion_tokens": t.completion_tokens,
"cost_usd": t.cost_usd,
}),
)
})
.collect();
serde_json::json!({
"alltime_by_account": alltime_by_account,
"totals": {
"requests": win.requests,
"ok": win.ok,
@@ -327,6 +540,9 @@ impl Stats {
/// Reset all in-memory stats and truncate the JSONL log — the "clear logs"
/// admin action.
pub fn clear(&self) {
if let Some(tpath) = self.totals_path() {
let _ = std::fs::remove_file(tpath);
}
{
let mut inner = self.inner.lock().unwrap();
*inner = Inner::default();
@@ -416,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)
}
+45
View File
@@ -585,6 +585,46 @@ body.modal-open { overflow: hidden; }
/* Featured gallery: the list grows as new models are published, so give it more
room than the search grid before it starts scrolling. */
.featured-section .hf-grid { max-height: min(62vh, 760px); }
/* Money: 4 decimals, last two de-emphasized */
.money-tail { font-size: .72em; opacity: .75; margin-left: 1px; }
.seg { flex-wrap: wrap; }
.ok { color: var(--good); }
.pairs { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
.pair-row { display: flex; gap: 6px; align-items: center; }
.pair-model { min-width: 220px; max-width: 340px; padding: 4px 8px; font-size: 12.5px; }
.pair-tier { max-width: 150px; padding: 4px 8px; font-size: 12.5px; }
/* Complexity bands slider */
.bands-wrap { margin: 4px 0 16px; }
/* 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: #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(--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; }
/* Benchmark view */
@keyframes bm-in { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } }
tbody tr:last-child { animation: bm-in .25s ease; }
.bm-sum { margin-bottom: 10px; }
.bm-cell { cursor: pointer; white-space: nowrap; }
.bm-cell:hover { background: var(--hover); }
.badge.ok { background: rgba(52,211,153,.15); color: var(--good); }
/* Routing: per-task rule rows */
.taskrow { align-items: flex-start; }
.taskname { min-width: 190px; line-height: 1.35; }
@@ -666,3 +706,8 @@ body.modal-open { overflow: hidden; }
@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; }
+7 -1
View File
@@ -73,6 +73,8 @@ export const api = {
// protocols
getProtocols: () => j("GET", "/protocols"),
harnesses: () => j("GET", "/harnesses"),
balance: () => j("GET", "/balance"),
putProtocols: (b) => j("PUT", "/protocols", b),
// settings
@@ -130,7 +132,9 @@ export const api = {
// system / logs / lifecycle
system: () => j("GET", "/system"),
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"),
@@ -190,6 +194,7 @@ export async function testStream(body, onDelta) {
let ttftMs = null; // time to first content token
let usage = null; // OpenAI-style final-chunk usage, when the provider sends it
let usageEstimated = false; // gateway-injected estimate rather than tokenizer truth
let costUsd = null; // final cost from the gateway's closing event (headers carry 0)
for (;;) {
const { done, value } = await reader.read();
if (done) break;
@@ -207,6 +212,7 @@ export async function testStream(body, onDelta) {
usage = chunk.usage;
usageEstimated = !!chunk.cortiq_estimated;
}
if (chunk.cortiq_cost_usd != null) costUsd = chunk.cortiq_cost_usd;
const d = chunk.choices?.[0]?.delta?.content;
if (d) {
if (ttftMs == null) ttftMs = Math.round(performance.now() - t0);
@@ -224,7 +230,7 @@ export async function testStream(body, onDelta) {
score: parseFloat(h.get("x-cortiq-complexity-score") || "0"),
selected_model: h.get("x-cortiq-selected-model") || "",
route_source: h.get("x-cortiq-route-source") || "",
cost_usd: parseFloat(h.get("x-cortiq-cost-usd") || "0"),
cost_usd: costUsd != null ? costUsd : parseFloat(h.get("x-cortiq-cost-usd") || "0"),
usage,
usage_estimated: usageEstimated,
ttft_ms: ttftMs,
+20
View File
@@ -12,6 +12,7 @@ import { renderKeys } from "./views/keys.js";
import { renderPlayground } from "./views/playground.js";
import { renderSettings } from "./views/settings.js";
import { renderLogs } from "./views/logs.js";
import { renderBenchmark } from "./views/benchmark.js";
import { renderOnboarding } from "./views/onboarding.js";
const ROUTES = [
@@ -22,6 +23,7 @@ const ROUTES = [
{ id: "protocols", icon: "⇆", view: renderProtocols },
{ id: "keys", icon: "🔑", view: renderKeys },
{ id: "playground", icon: "✦", view: renderPlayground },
{ id: "benchmark", icon: "⏱", view: renderBenchmark },
{ id: "logs", icon: "📜", view: renderLogs },
{ id: "settings", icon: "⚙", view: renderSettings },
{ id: "onboarding", icon: "✦", view: renderOnboarding, hidden: true },
@@ -254,12 +256,30 @@ function showLogin() {
mount(document.getElementById("app"), renderLogin(boot));
}
let staleNotified = false;
async function checkUiFresh() {
// the SPA lives in the tab until reload; if the gateway restarted (dev
// rebuild, update, config restart) the embedded UI may have changed —
// tell the user instead of letting them stare at stale views
try {
const m = await api.meta();
if (appState.meta?.started && m.started && m.started !== appState.meta.started && !staleNotified) {
staleNotified = true;
const bar = h("div", { class: "callout warn", style: "position:fixed; left:50%; transform:translateX(-50%); bottom:18px; z-index:99; box-shadow:0 8px 24px rgba(0,0,0,.35)" },
h("span", {}, "🔄 " + t("shell.uiStale")), " ",
h("button", { class: "btn sm primary", onClick: () => location.reload() }, t("shell.reload")));
document.body.append(bar);
}
} catch {}
}
async function refreshHealth() {
try {
appState.health = await api.health();
} catch {
appState.health = null;
}
checkUiFresh();
// re-render topbar only if shell present
const route = currentRoute();
const main = document.querySelector(".main");
+5
View File
@@ -48,6 +48,11 @@ export function onLangChange(fn) {
return () => listeners.delete(fn);
}
// Human name for a complexity tier; custom tier names pass through as-is.
export function trTier(tier) {
return ["low", "medium", "high"].includes(tier) ? t("tier." + tier) : tier;
}
export function t(key, vars) {
const d = DICTS[lang] || en;
let s = d[key] ?? en[key] ?? key;
+89 -2
View File
@@ -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",
@@ -563,4 +567,87 @@ export default {
"routing.tasks.builtin": "Router nicht erreichbar — eingebauter Labelsatz",
"routing.tasks.addCustom": "Aufgabe hinzufügen",
"routing.tasks.addPlaceholder": "eigenes Label…",
// benchmark view
"nav.benchmark": "Benchmark",
"bm.title": "Benchmark",
"bm.subtitle": "Qualitäts- und Tempo-Proben durch die Live-Pipeline: Antworten per Schlüsselwort geprüft, Schleifen erkannt, tok/s und Zeit bis zum ersten Token je Modell.",
"bm.models": "Zu testende Modelle",
"bm.quickMode": "Schnelltest",
"bm.quickHint": "Die schnelle Teilmenge mit kurzen Antworten — etwa eine Minute pro lokalem Modell.",
"bm.suiteInfo": "Eingebaute Suite: {q} schnelle / {f} volle Tests, plus Ihre eigenen.",
"bm.run": "Starten",
"bm.pickModel": "Wählen Sie mindestens ein Modell.",
"bm.testOf": "Test {i} von {n}",
"bm.results": "Ergebnisse",
"bm.quick": "schnell",
"bm.test": "Test",
"bm.cellHint": "Klick auf eine Zelle zeigt die vollständige Antwort.",
"bm.history": "Frühere Läufe (in diesem Browser gespeichert)",
"bm.custom": "Eigene Tests",
"bm.customHint": "Prompt + kommagetrennte Schlüsselwörter, die die Antwort enthalten muss (| für Synonyme). Im Browser gespeichert.",
"bm.promptPh": "Prompt…",
"bm.checkPh": "Schlüsselwörter, kommagetrennt",
"bm.addTest": "Test hinzufügen",
"bm.emptyAnswer": "leere Antwort",
"bm.loopDetected": "Schleife erkannt",
// benchmark: routing mode, cost, live
"bm.mode.routing": "Smartes Routing",
"bm.mode.models": "Einzelne Modelle",
"bm.mode.routingDesc": "Das Kernfeature des Produkts im Test: jede Probe läuft über cortiq-auto — Sie sehen, welche Aufgabe der Router erkannt hat, welches Modell er wählte, ob das den Erwartungen entspricht und was es gekostet hat.",
"bm.mode.modelsDesc": "Dieselbe Suite, an gewählte Modelle gepinnt — Qualität, Tempo und Kosten im direkten Vergleich, am Router vorbei.",
"bm.answersOk": "Antworten korrekt",
"bm.classAcc": "Aufgaben-Klassifikation",
"bm.modelDist": "Anfragen gingen an",
"bm.expectedLbl": "erwartet:",
"bm.answer": "Antwort",
"bm.liveRunning": "läuft…",
"bm.spent": "ausgegeben",
"bm.verdict": "🏁 Fertig: Antworten {p}, ausgegeben {s}",
"bm.clearHistory": "Alle löschen",
// harness auto-setup (API keys view)
"keys.h.claude": "Claude Code — ins Terminal einfügen (das Gateway spricht das Anthropic-Protokoll nativ):",
"keys.h.codex": "Codex — hängt den Provider an ~/.codex/config.toml an und startet:",
"keys.h.opencode": "OpenCode — schreibt ~/.config/opencode/opencode.json (überschreibt!) und startet:",
"keys.h.continue": "Continue (VS Code / JetBrains) — hängt das Modell an ~/.continue/config.yaml an:",
"keys.h.cline1": "Cline (VS Code) — in den Erweiterungseinstellungen wählen:",
"keys.h.cline2": "Einstellungen → API Provider → OpenAI Compatible, dann die Werte oben einfügen.",
"keys.h.note": "cortiq-auto = smartes Routing: der Router wählt das Modell pro Anfrage. Für ein festes Modell den exakten Namen angeben. Unter Windows die Skripte in Git Bash / WSL ausführen oder dieselben Variablen über die Systemumgebung setzen.",
"keys.h.protoOff": "Das Anthropic-Protokoll (/v1/messages) ist deaktiviert — Claude Code kann sich nicht verbinden.",
"keys.h.enableProto": "Aktivieren",
"keys.h.st.installed": "Auf der Gateway-Maschine installiert",
"keys.h.st.configured": "Bereits für dieses Gateway konfiguriert",
"keys.h.st.notFound": "Auf der Gateway-Maschine nicht gefunden",
"bm.degraded": "Router degradiert",
"bm.routerDegraded": "Der Router hat nicht rechtzeitig geantwortet — die Anfrage nutzte die Fallback-Kette; die Klassifikation wird nicht gewertet.",
"bm.cutByCap": "Budget ging fürs Denken drauf — keine Antwort mehr",
"shell.uiStale": "Das Gateway wurde aktualisiert — diese Seite zeigt eine ältere Oberfläche.",
"shell.reload": "Neu laden",
"keys.col.requests": "Anfragen",
"keys.col.tokens": "Tokens ein/aus",
"keys.col.costAll": "$ gesamt",
"routing.tasks.tier": "Komplexität",
"routing.tasks.tierAuto": "Auto (Router)",
"routing.tasks.tierHint": "Welches Modell diese Aufgabe bei welcher Komplexität bedient; \"jede\" führt auf allen Stufen. Reihenfolge = Fallback-Reihenfolge. High eskaliert weiterhin über \"jede\"-Einträge.",
"routing.tasks.tierAny": "jede Komplexität",
"routing.tasks.addModel": "Modell",
"routing.bands.enable": "Eigene Komplexitätsgrenzen",
"routing.bands.hint": "Ziehen Sie die Griffe: wo low endet und high beginnt — die Griffe schieben einander, die Zahlen aktualisieren sich live. Der kontinuierliche Router-Score wird mit IHREN Grenzen neu eingeteilt; ohne Häkchen gelten die Grenzen des Routers.",
"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",
};
+89 -2
View File
@@ -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",
@@ -556,4 +560,87 @@ export default {
"routing.tasks.builtin": "router unreachable — built-in label set",
"routing.tasks.addCustom": "Add task",
"routing.tasks.addPlaceholder": "custom label…",
// benchmark view
"nav.benchmark": "Benchmark",
"bm.title": "Benchmark",
"bm.subtitle": "Quality and speed probes through the live pipeline: keyword-checked answers, anti-loop detection, tok/s and time-to-first-token per model.",
"bm.models": "Models to test",
"bm.quickMode": "Quick test",
"bm.quickHint": "The fast subset with short answers — about a minute per local model.",
"bm.suiteInfo": "Built-in suite: {q} quick / {f} full tests, plus your custom ones.",
"bm.run": "Run",
"bm.pickModel": "Pick at least one model.",
"bm.testOf": "test {i} of {n}",
"bm.results": "Results",
"bm.quick": "quick",
"bm.test": "Test",
"bm.cellHint": "Click a cell to see the model's full answer.",
"bm.history": "Previous runs (stored in this browser)",
"bm.custom": "Custom tests",
"bm.customHint": "Prompt + comma-separated keywords the answer must contain (use | for synonyms). Stored in this browser.",
"bm.promptPh": "prompt…",
"bm.checkPh": "keywords, comma-separated",
"bm.addTest": "Add test",
"bm.emptyAnswer": "empty answer",
"bm.loopDetected": "loop detected",
// benchmark: routing mode, cost, live
"bm.mode.routing": "Smart routing",
"bm.mode.models": "Individual models",
"bm.mode.routingDesc": "The product's core feature under test: every probe goes through cortiq-auto — you see which task the router detected, which model it picked, whether that matches expectations, and what it cost.",
"bm.mode.modelsDesc": "The same suite pinned to selected models — compare their quality, speed and cost head-to-head, bypassing the router.",
"bm.answersOk": "answers correct",
"bm.classAcc": "task classification",
"bm.modelDist": "Requests went to",
"bm.expectedLbl": "expected:",
"bm.answer": "Answer",
"bm.liveRunning": "running…",
"bm.spent": "spent",
"bm.verdict": "🏁 Done: answers {p}, spent {s}",
"bm.clearHistory": "Clear all",
// harness auto-setup (API keys view)
"keys.h.claude": "Claude Code — paste into the terminal (the gateway speaks the Anthropic protocol natively):",
"keys.h.codex": "Codex — appends the provider to ~/.codex/config.toml and starts:",
"keys.h.opencode": "OpenCode — writes ~/.config/opencode/opencode.json (overwrites!) and starts:",
"keys.h.continue": "Continue (VS Code / JetBrains) — appends the model to ~/.continue/config.yaml:",
"keys.h.cline1": "Cline (VS Code) — in extension settings pick:",
"keys.h.cline2": "Settings → API Provider → OpenAI Compatible, then paste the values above.",
"keys.h.note": "cortiq-auto = smart routing: the router picks the model per request. Use an exact model name to pin one. On Windows run the scripts in Git Bash / WSL, or set the same variables via the system environment settings.",
"keys.h.protoOff": "The Anthropic protocol (/v1/messages) is disabled — Claude Code will not connect.",
"keys.h.enableProto": "Enable",
"keys.h.st.installed": "Installed on the gateway machine",
"keys.h.st.configured": "Already configured for this gateway",
"keys.h.st.notFound": "Not found on the gateway machine",
"bm.degraded": "router degraded",
"bm.routerDegraded": "The router did not answer in time — the request used the fallback chain; classification is not judged.",
"bm.cutByCap": "budget spent on reasoning — answer never came",
"shell.uiStale": "The gateway was updated — this page is running an older interface.",
"shell.reload": "Reload",
"keys.col.requests": "Requests",
"keys.col.tokens": "Tokens in/out",
"keys.col.costAll": "$ all-time",
"routing.tasks.tier": "Complexity",
"routing.tasks.tierAuto": "Auto (router)",
"routing.tasks.tierHint": "Which model serves this task at which complexity; \"any\" leads at every tier. Order = fallback order. High verdicts still escalate over any-tier pins.",
"routing.tasks.tierAny": "any complexity",
"routing.tasks.addModel": "model",
"routing.bands.enable": "My complexity boundaries",
"routing.bands.hint": "Drag the handles to set where low ends and where high begins — the handles push each other, the numbers update live. The router's continuous score is re-mapped with YOUR boundaries; unchecked = the router's own bands.",
"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",
};
+89 -2
View File
@@ -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",
@@ -563,4 +567,87 @@ export default {
"routing.tasks.builtin": "router inaccesible — conjunto integrado",
"routing.tasks.addCustom": "Añadir tarea",
"routing.tasks.addPlaceholder": "etiqueta propia…",
// benchmark view
"nav.benchmark": "Benchmark",
"bm.title": "Benchmark",
"bm.subtitle": "Pruebas de calidad y velocidad a través del pipeline en vivo: respuestas verificadas por palabras clave, detección de bucles, tok/s y tiempo del primer token por modelo.",
"bm.models": "Modelos a probar",
"bm.quickMode": "Prueba rápida",
"bm.quickHint": "El subconjunto rápido con respuestas cortas — alrededor de un minuto por modelo local.",
"bm.suiteInfo": "Suite integrada: {q} pruebas rápidas / {f} completas, más las tuyas.",
"bm.run": "Ejecutar",
"bm.pickModel": "Elige al menos un modelo.",
"bm.testOf": "prueba {i} de {n}",
"bm.results": "Resultados",
"bm.quick": "rápida",
"bm.test": "Prueba",
"bm.cellHint": "Haz clic en una celda para ver la respuesta completa.",
"bm.history": "Ejecuciones anteriores (guardadas en este navegador)",
"bm.custom": "Pruebas propias",
"bm.customHint": "Prompt + palabras clave separadas por comas que la respuesta debe contener (| para sinónimos). Se guardan en este navegador.",
"bm.promptPh": "prompt…",
"bm.checkPh": "palabras clave, separadas por comas",
"bm.addTest": "Añadir prueba",
"bm.emptyAnswer": "respuesta vacía",
"bm.loopDetected": "bucle detectado",
// benchmark: routing mode, cost, live
"bm.mode.routing": "Enrutado inteligente",
"bm.mode.models": "Modelos individuales",
"bm.mode.routingDesc": "La función clave del producto a prueba: cada sonda pasa por cortiq-auto — ves qué tarea detectó el router, qué modelo eligió, si coincide con lo esperado y cuánto costó.",
"bm.mode.modelsDesc": "La misma suite fijada a los modelos elegidos — compara su calidad, velocidad y coste directamente, sin pasar por el router.",
"bm.answersOk": "respuestas correctas",
"bm.classAcc": "clasificación de tareas",
"bm.modelDist": "Las peticiones fueron a",
"bm.expectedLbl": "esperado:",
"bm.answer": "Respuesta",
"bm.liveRunning": "en curso…",
"bm.spent": "gastado",
"bm.verdict": "🏁 Listo: respuestas {p}, gastado {s}",
"bm.clearHistory": "Borrar todo",
// harness auto-setup (API keys view)
"keys.h.claude": "Claude Code — pega en la terminal (la pasarela habla el protocolo Anthropic de forma nativa):",
"keys.h.codex": "Codex — añade el proveedor a ~/.codex/config.toml y arranca:",
"keys.h.opencode": "OpenCode — escribe ~/.config/opencode/opencode.json (¡sobrescribe!) y arranca:",
"keys.h.continue": "Continue (VS Code / JetBrains) — añade el modelo a ~/.continue/config.yaml:",
"keys.h.cline1": "Cline (VS Code) — en los ajustes de la extensión elige:",
"keys.h.cline2": "Ajustes → API Provider → OpenAI Compatible, luego pega los valores de arriba.",
"keys.h.note": "cortiq-auto = enrutado inteligente: el router elige el modelo en cada petición. Para fijar uno, indica su nombre exacto. En Windows ejecuta los scripts en Git Bash / WSL o define las mismas variables en el entorno del sistema.",
"keys.h.protoOff": "El protocolo Anthropic (/v1/messages) está desactivado — Claude Code no podrá conectarse.",
"keys.h.enableProto": "Activar",
"keys.h.st.installed": "Instalado en la máquina de la pasarela",
"keys.h.st.configured": "Ya configurado para esta pasarela",
"keys.h.st.notFound": "No encontrado en la máquina de la pasarela",
"bm.degraded": "router degradado",
"bm.routerDegraded": "El router no respondió a tiempo — la petición usó la cadena de reserva; la clasificación no se cuenta.",
"bm.cutByCap": "presupuesto gastado en razonar — sin respuesta final",
"shell.uiStale": "La pasarela se actualizó — esta página usa una interfaz antigua.",
"shell.reload": "Recargar",
"keys.col.requests": "Peticiones",
"keys.col.tokens": "Tokens ent/sal",
"keys.col.costAll": "$ total",
"routing.tasks.tier": "Complejidad",
"routing.tasks.tierAuto": "Auto (router)",
"routing.tasks.tierHint": "Qué modelo atiende esta tarea en qué complejidad; «cualquiera» lidera en todos los niveles. El orden = orden de respaldo. Un veredicto high sigue escalando sobre las entradas «cualquiera».",
"routing.tasks.tierAny": "cualquier complejidad",
"routing.tasks.addModel": "modelo",
"routing.bands.enable": "Mis límites de complejidad",
"routing.bands.hint": "Arrastra los tiradores: dónde termina low y dónde empieza high — se empujan entre sí y las cifras se actualizan en vivo. La puntuación continua del router se re-mapea con TUS límites; sin marcar = las bandas del propio router.",
"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",
};
+89 -2
View File
@@ -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",
@@ -563,4 +567,87 @@ export default {
"routing.tasks.builtin": "routeur injoignable — jeu d'étiquettes intégré",
"routing.tasks.addCustom": "Ajouter une tâche",
"routing.tasks.addPlaceholder": "étiquette personnalisée…",
// benchmark view
"nav.benchmark": "Benchmark",
"bm.title": "Benchmark",
"bm.subtitle": "Sondes de qualité et de vitesse via le pipeline en direct : réponses vérifiées par mots-clés, boucles détectées, tok/s et temps du premier token par modèle.",
"bm.models": "Modèles à tester",
"bm.quickMode": "Test rapide",
"bm.quickHint": "Le sous-ensemble rapide aux réponses courtes — environ une minute par modèle local.",
"bm.suiteInfo": "Suite intégrée : {q} tests rapides / {f} complets, plus les vôtres.",
"bm.run": "Lancer",
"bm.pickModel": "Choisissez au moins un modèle.",
"bm.testOf": "test {i} sur {n}",
"bm.results": "Résultats",
"bm.quick": "rapide",
"bm.test": "Test",
"bm.cellHint": "Cliquez sur une cellule pour voir la réponse complète.",
"bm.history": "Exécutions précédentes (stockées dans ce navigateur)",
"bm.custom": "Tests personnalisés",
"bm.customHint": "Prompt + mots-clés séparés par des virgules que la réponse doit contenir (| pour les synonymes). Stockés dans ce navigateur.",
"bm.promptPh": "prompt…",
"bm.checkPh": "mots-clés, séparés par des virgules",
"bm.addTest": "Ajouter un test",
"bm.emptyAnswer": "réponse vide",
"bm.loopDetected": "boucle détectée",
// benchmark: routing mode, cost, live
"bm.mode.routing": "Routage intelligent",
"bm.mode.models": "Modèles individuels",
"bm.mode.routingDesc": "La fonctionnalité clé du produit à l'épreuve : chaque sonde passe par cortiq-auto — vous voyez quelle tâche le routeur a détectée, quel modèle il a choisi, si cela correspond aux attentes et combien cela a coûté.",
"bm.mode.modelsDesc": "La même suite épinglée aux modèles choisis — comparez leur qualité, vitesse et coût en direct, sans passer par le routeur.",
"bm.answersOk": "réponses correctes",
"bm.classAcc": "classification des tâches",
"bm.modelDist": "Requêtes envoyées vers",
"bm.expectedLbl": "attendu :",
"bm.answer": "Réponse",
"bm.liveRunning": "en cours…",
"bm.spent": "dépensé",
"bm.verdict": "🏁 Terminé : réponses {p}, dépensé {s}",
"bm.clearHistory": "Tout effacer",
// harness auto-setup (API keys view)
"keys.h.claude": "Claude Code — collez dans le terminal (la passerelle parle nativement le protocole Anthropic) :",
"keys.h.codex": "Codex — ajoute le fournisseur à ~/.codex/config.toml et démarre :",
"keys.h.opencode": "OpenCode — écrit ~/.config/opencode/opencode.json (écrase !) et démarre :",
"keys.h.continue": "Continue (VS Code / JetBrains) — ajoute le modèle à ~/.continue/config.yaml :",
"keys.h.cline1": "Cline (VS Code) — dans les réglages de l'extension, choisissez :",
"keys.h.cline2": "Réglages → API Provider → OpenAI Compatible, puis collez les valeurs ci-dessus.",
"keys.h.note": "cortiq-auto = routage intelligent : le routeur choisit le modèle à chaque requête. Pour en fixer un, indiquez son nom exact. Sous Windows, lancez les scripts dans Git Bash / WSL ou définissez les mêmes variables via l'environnement système.",
"keys.h.protoOff": "Le protocole Anthropic (/v1/messages) est désactivé — Claude Code ne pourra pas se connecter.",
"keys.h.enableProto": "Activer",
"keys.h.st.installed": "Installé sur la machine de la passerelle",
"keys.h.st.configured": "Déjà configuré pour cette passerelle",
"keys.h.st.notFound": "Introuvable sur la machine de la passerelle",
"bm.degraded": "routeur dégradé",
"bm.routerDegraded": "Le routeur n'a pas répondu à temps — la requête a suivi la chaîne de secours ; la classification n'est pas comptée.",
"bm.cutByCap": "budget épuisé en raisonnement — pas de réponse",
"shell.uiStale": "La passerelle a été mise à jour — cette page utilise une ancienne interface.",
"shell.reload": "Recharger",
"keys.col.requests": "Requêtes",
"keys.col.tokens": "Tokens in/out",
"keys.col.costAll": "$ au total",
"routing.tasks.tier": "Complexité",
"routing.tasks.tierAuto": "Auto (routeur)",
"routing.tasks.tierHint": "Quel modèle sert cette tâche à quelle complexité ; « toute » mène à tous les niveaux. L'ordre = ordre de repli. Un verdict high escalade toujours au-dessus des entrées « toute ».",
"routing.tasks.tierAny": "toute complexité",
"routing.tasks.addModel": "modèle",
"routing.bands.enable": "Mes limites de complexité",
"routing.bands.hint": "Faites glisser les poignées : où finit low et où commence high — elles se poussent l'une l'autre, les chiffres se mettent à jour en direct. Le score continu du routeur est re-découpé avec VOS limites ; décoché = les bandes du routeur.",
"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é",
};
+91 -4
View File
@@ -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": "Замерить",
@@ -555,4 +559,87 @@ export default {
"routing.tasks.builtin": "роутер недоступен — встроенный набор меток",
"routing.tasks.addCustom": "Добавить задачу",
"routing.tasks.addPlaceholder": "своя метка…",
// benchmark view
"nav.benchmark": "Бенчмарк",
"bm.title": "Бенчмарк",
"bm.subtitle": "Проверки качества и скорости через живой пайплайн: ответы сверяются по ключевым словам, ловится зацикливание, замеряются tok/s и время первого токена.",
"bm.models": "Какие модели тестировать",
"bm.quickMode": "Быстрый тест",
"bm.quickHint": "Короткое подмножество с краткими ответами — около минуты на локальную модель.",
"bm.suiteInfo": "Встроенный набор: {q} быстрых / {f} полных тестов, плюс ваши собственные.",
"bm.run": "Запустить",
"bm.pickModel": "Выберите хотя бы одну модель.",
"bm.testOf": "тест {i} из {n}",
"bm.results": "Результаты",
"bm.quick": "быстрый",
"bm.test": "Тест",
"bm.cellHint": "Клик по ячейке — полный ответ модели.",
"bm.history": "Прошлые прогоны (хранятся в этом браузере)",
"bm.custom": "Свои тесты",
"bm.customHint": "Промпт + ключевые слова через запятую, которые должны быть в ответе (| — синонимы). Хранятся в этом браузере.",
"bm.promptPh": "промпт…",
"bm.checkPh": "ключевые слова через запятую",
"bm.addTest": "Добавить тест",
"bm.emptyAnswer": "пустой ответ",
"bm.loopDetected": "зацикливание",
// benchmark: routing mode, cost, live
"bm.mode.routing": "Умный роутинг",
"bm.mode.models": "Отдельные модели",
"bm.mode.routingDesc": "Проверка главной фишки продукта: каждый тест идёт через cortiq-auto — видно, какую задачу распознал роутер, какую модель выбрал, совпало ли это с ожиданием и сколько это стоило.",
"bm.mode.modelsDesc": "Тот же набор тестов, но закреплённый за выбранными моделями — сравнение их качества, скорости и стоимости напрямую, мимо роутера.",
"bm.answersOk": "ответы верны",
"bm.classAcc": "классификация задач",
"bm.modelDist": "Запросы ушли в",
"bm.expectedLbl": "ожидалось:",
"bm.answer": "Ответ",
"bm.liveRunning": "идёт…",
"bm.spent": "потрачено",
"bm.verdict": "🏁 Готово: ответы {p}, потрачено {s}",
"bm.clearHistory": "Очистить всё",
// harness auto-setup (API keys view)
"keys.h.claude": "Claude Code — вставьте в терминал (шлюз говорит на протоколе Anthropic нативно):",
"keys.h.codex": "Codex — добавит провайдера в ~/.codex/config.toml и запустит:",
"keys.h.opencode": "OpenCode — запишет ~/.config/opencode/opencode.json (перезапишет!) и запустит:",
"keys.h.continue": "Continue (VS Code / JetBrains) — добавит модель в ~/.continue/config.yaml:",
"keys.h.cline1": "Cline (VS Code) — в настройках расширения укажите:",
"keys.h.cline2": "Настройки → API Provider → OpenAI Compatible, затем вставьте значения выше.",
"keys.h.note": "cortiq-auto — умный роутинг: роутер сам выбирает модель под каждый запрос. Чтобы закрепить конкретную, укажите её точное имя. На Windows запускайте скрипты в Git Bash / WSL или задайте те же переменные через настройки окружения системы.",
"keys.h.protoOff": "Протокол Anthropic (/v1/messages) выключен — Claude Code не подключится.",
"keys.h.enableProto": "Включить",
"keys.h.st.installed": "Установлен на машине шлюза",
"keys.h.st.configured": "Уже настроен на этот шлюз",
"keys.h.st.notFound": "Не найден на машине шлюза",
"bm.degraded": "роутер деградировал",
"bm.routerDegraded": "Роутер не успел ответить — запрос ушёл по резервной цепочке; классификация не засчитывается.",
"bm.cutByCap": "бюджет ушёл на размышления — ответ не успел",
"shell.uiStale": "Шлюз обновился — на этой странице старый интерфейс.",
"shell.reload": "Перезагрузить",
"keys.col.requests": "Запросы",
"keys.col.tokens": "Токены вх/исх",
"keys.col.costAll": "$ за всё время",
"routing.tasks.tier": "Сложность",
"routing.tasks.tierAuto": "Авто (по роутеру)",
"routing.tasks.tierHint": "Какая модель обслуживает задачу на какой сложности; «любая» ведёт на всех уровнях. Порядок = порядок фолбэка. Вердикт high по-прежнему эскалирует поверх «любых».",
"routing.tasks.tierAny": "любая сложность",
"routing.tasks.addModel": "модель",
"routing.bands.enable": "Мои границы сложности",
"routing.bands.hint": "Тяните ручки: где кончается low и где начинается high — ручки толкают друг друга, цифры обновляются на лету. Непрерывный score роутера пересчитывается ВАШИМИ границами; галочка снята — действуют пороги самого роутера.",
"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": "Вставить ключ",
};
+89 -2
View File
@@ -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ç",
@@ -563,4 +567,87 @@ export default {
"routing.tasks.builtin": "yönlendiriciye ulaşılamıyor — yerleşik etiket seti",
"routing.tasks.addCustom": "Görev ekle",
"routing.tasks.addPlaceholder": "özel etiket…",
// benchmark view
"nav.benchmark": "Kıyaslama",
"bm.title": "Kıyaslama",
"bm.subtitle": "Canlı hat üzerinden kalite ve hız probları: yanıtlar anahtar kelimeyle doğrulanır, döngü tespit edilir, model başına tok/s ve ilk token süresi ölçülür.",
"bm.models": "Test edilecek modeller",
"bm.quickMode": "Hızlı test",
"bm.quickHint": "Kısa yanıtlı hızlı alt küme — yerel model başına yaklaşık bir dakika.",
"bm.suiteInfo": "Yerleşik küme: {q} hızlı / {f} tam test, artı kendi testleriniz.",
"bm.run": "Çalıştır",
"bm.pickModel": "En az bir model seçin.",
"bm.testOf": "{n} testten {i}.",
"bm.results": "Sonuçlar",
"bm.quick": "hızlı",
"bm.test": "Test",
"bm.cellHint": "Modelin tam yanıtı için hücreye tıklayın.",
"bm.history": "Önceki çalıştırmalar (bu tarayıcıda saklanır)",
"bm.custom": "Özel testler",
"bm.customHint": "İstem + yanıtın içermesi gereken virgülle ayrılmış anahtar kelimeler (| eşanlamlılar için). Bu tarayıcıda saklanır.",
"bm.promptPh": "istem…",
"bm.checkPh": "anahtar kelimeler, virgülle",
"bm.addTest": "Test ekle",
"bm.emptyAnswer": "boş yanıt",
"bm.loopDetected": "döngü tespit edildi",
// benchmark: routing mode, cost, live
"bm.mode.routing": "Akıllı yönlendirme",
"bm.mode.models": "Tek tek modeller",
"bm.mode.routingDesc": "Ürünün temel özelliği testte: her prob cortiq-auto üzerinden gider — yönlendiricinin hangi görevi algıladığını, hangi modeli seçtiğini, bunun beklentiyle örtüşüp örtüşmediğini ve maliyetini görürsünüz.",
"bm.mode.modelsDesc": "Aynı küme seçilen modellere sabitlenir — yönlendiriciyi atlayarak kalite, hız ve maliyeti doğrudan karşılaştırın.",
"bm.answersOk": "yanıtlar doğru",
"bm.classAcc": "görev sınıflandırması",
"bm.modelDist": "İstekler şuraya gitti",
"bm.expectedLbl": "beklenen:",
"bm.answer": "Yanıt",
"bm.liveRunning": "sürüyor…",
"bm.spent": "harcanan",
"bm.verdict": "🏁 Bitti: yanıtlar {p}, harcanan {s}",
"bm.clearHistory": "Tümünü temizle",
// harness auto-setup (API keys view)
"keys.h.claude": "Claude Code — terminale yapıştırın (ağ geçidi Anthropic protokolünü doğal olarak konuşur):",
"keys.h.codex": "Codex — sağlayıcıyı ~/.codex/config.toml dosyasına ekler ve başlatır:",
"keys.h.opencode": "OpenCode — ~/.config/opencode/opencode.json dosyasını yazar (üzerine yazar!) ve başlatır:",
"keys.h.continue": "Continue (VS Code / JetBrains) — modeli ~/.continue/config.yaml dosyasına ekler:",
"keys.h.cline1": "Cline (VS Code) — eklenti ayarlarında şunları seçin:",
"keys.h.cline2": "Ayarlar → API Provider → OpenAI Compatible, ardından yukarıdaki değerleri yapıştırın.",
"keys.h.note": "cortiq-auto = akıllı yönlendirme: yönlendirici her istek için modeli kendisi seçer. Belirli bir model için tam adını yazın. Windows'ta betikleri Git Bash / WSL içinde çalıştırın veya aynı değişkenleri sistem ortamından ayarlayın.",
"keys.h.protoOff": "Anthropic protokolü (/v1/messages) kapalı — Claude Code bağlanamaz.",
"keys.h.enableProto": "Etkinleştir",
"keys.h.st.installed": "Ağ geçidi makinesinde kurulu",
"keys.h.st.configured": "Bu ağ geçidi için zaten yapılandırılmış",
"keys.h.st.notFound": "Ağ geçidi makinesinde bulunamadı",
"bm.degraded": "yönlendirici yedeğe düştü",
"bm.routerDegraded": "Yönlendirici zamanında yanıt vermedi — istek yedek zincire gitti; sınıflandırma sayılmaz.",
"bm.cutByCap": "bütçe düşünmeye gitti — yanıt gelemedi",
"shell.uiStale": "Ağ geçidi güncellendi — bu sayfa eski arayüzü çalıştırıyor.",
"shell.reload": "Yeniden yükle",
"keys.col.requests": "İstekler",
"keys.col.tokens": "Token giriş/çıkış",
"keys.col.costAll": "$ toplam",
"routing.tasks.tier": "Karmaşıklık",
"routing.tasks.tierAuto": "Otomatik (yönlendirici)",
"routing.tasks.tierHint": "Bu görevi hangi karmaşıklıkta hangi model karşılar; \"her\" tüm katmanlarda önde gider. Sıra = yedekleme sırası. High kararı \"her\" girdilerinin üzerine yine tırmanır.",
"routing.tasks.tierAny": "her karmaşıklık",
"routing.tasks.addModel": "model",
"routing.bands.enable": "Kendi karmaşıklık sınırlarım",
"routing.bands.hint": "Tutamaçları sürükleyin: low nerede biter, high nerede başlar — tutamaçlar birbirini iter, sayılar anlık güncellenir. Yönlendiricinin sürekli puanı SİZİN sınırlarınızla yeniden bölümlenir; işaret kaldırılırsa yönlendiricinin kendi bantları geçerli.",
"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",
};
+89 -2
View File
@@ -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": "测速",
@@ -563,4 +567,87 @@ export default {
"routing.tasks.builtin": "路由器不可达 — 使用内置标签集",
"routing.tasks.addCustom": "添加任务",
"routing.tasks.addPlaceholder": "自定义标签……",
// benchmark view
"nav.benchmark": "基准测试",
"bm.title": "基准测试",
"bm.subtitle": "通过实时管线做质量与速度探测:按关键词核对答案、检测循环输出,并按模型测量 tok/s 与首个 token 时间。",
"bm.models": "要测试的模型",
"bm.quickMode": "快速测试",
"bm.quickHint": "答案简短的快速子集 — 每个本地模型约一分钟。",
"bm.suiteInfo": "内置套件:{q} 个快速 / {f} 个完整测试,另加你自己的。",
"bm.run": "运行",
"bm.pickModel": "请至少选择一个模型。",
"bm.testOf": "第 {i}/{n} 个测试",
"bm.results": "结果",
"bm.quick": "快速",
"bm.test": "测试",
"bm.cellHint": "点击单元格查看模型的完整回答。",
"bm.history": "历史运行(保存在本浏览器)",
"bm.custom": "自定义测试",
"bm.customHint": "提示词 + 逗号分隔的关键词,回答必须包含它们(| 表示同义词)。保存在本浏览器。",
"bm.promptPh": "提示词……",
"bm.checkPh": "关键词,逗号分隔",
"bm.addTest": "添加测试",
"bm.emptyAnswer": "回答为空",
"bm.loopDetected": "检测到循环",
// benchmark: routing mode, cost, live
"bm.mode.routing": "智能路由",
"bm.mode.models": "单独模型",
"bm.mode.routingDesc": "测试产品核心能力:每个探测都经过 cortiq-auto — 可以看到路由器识别的任务、选中的模型、是否符合预期,以及花费了多少。",
"bm.mode.modelsDesc": "同一套测试固定在所选模型上 — 绕过路由器,直接对比它们的质量、速度与成本。",
"bm.answersOk": "回答正确",
"bm.classAcc": "任务分类",
"bm.modelDist": "请求流向",
"bm.expectedLbl": "预期:",
"bm.answer": "回答",
"bm.liveRunning": "进行中……",
"bm.spent": "花费",
"bm.verdict": "🏁 完成:回答 {p},花费 {s}",
"bm.clearHistory": "全部清除",
// harness auto-setup (API keys view)
"keys.h.claude": "Claude Code — 粘贴到终端(网关原生支持 Anthropic 协议):",
"keys.h.codex": "Codex — 将提供商追加到 ~/.codex/config.toml 并启动:",
"keys.h.opencode": "OpenCode — 写入 ~/.config/opencode/opencode.json(会覆盖!)并启动:",
"keys.h.continue": "Continue(VS Code / JetBrains)— 将模型追加到 ~/.continue/config.yaml:",
"keys.h.cline1": "Cline(VS Code)— 在扩展设置中选择:",
"keys.h.cline2": "设置 → API Provider → OpenAI Compatible,然后粘贴上面的值。",
"keys.h.note": "cortiq-auto = 智能路由:路由器为每个请求选择模型。要固定某个模型,请填写其精确名称。Windows 上请在 Git Bash / WSL 中运行脚本,或通过系统环境变量设置相同的值。",
"keys.h.protoOff": "Anthropic 协议(/v1/messages)已关闭 — Claude Code 无法连接。",
"keys.h.enableProto": "启用",
"keys.h.st.installed": "已安装在网关所在机器上",
"keys.h.st.configured": "已配置为使用此网关",
"keys.h.st.notFound": "网关所在机器上未找到",
"bm.degraded": "路由器降级",
"bm.routerDegraded": "路由器未及时响应 — 请求走了备用链路;分类不计入统计。",
"bm.cutByCap": "预算全用于思考——未能给出答案",
"shell.uiStale": "网关已更新 — 此页面运行的是旧界面。",
"shell.reload": "重新加载",
"keys.col.requests": "请求数",
"keys.col.tokens": "Token 入/出",
"keys.col.costAll": "$ 累计",
"routing.tasks.tier": "复杂度",
"routing.tasks.tierAuto": "自动(路由器)",
"routing.tasks.tierHint": "哪个模型在哪个复杂度上处理该任务;「任意」在所有层级领先。顺序即回退顺序。high 判定仍会越过「任意」条目升级。",
"routing.tasks.tierAny": "任意复杂度",
"routing.tasks.addModel": "模型",
"routing.bands.enable": "自定义复杂度边界",
"routing.bands.hint": "拖动手柄设定 low 到哪里结束、high 从哪里开始 — 手柄会相互推动,数字实时更新。路由器的连续得分将按你的边界重新划分;取消勾选则使用路由器自身的边界。",
"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": "插入密钥",
};
+16 -2
View File
@@ -95,11 +95,25 @@ export function confirmDialog(message) {
}
// number formatting
// Money as a DOM node: 4 decimals, the last two rendered smaller — tiny
// per-request prices stay readable without shouting six zeros.
export function moneyEl(n) {
if (n == null) return document.createTextNode("—");
if (n === 0) return document.createTextNode("$0");
// sub-0.0001 costs (cheap flash models) would collapse to $0.0000 — grow
// precision until two significant digits survive
const s = Math.abs(n) >= 0.0001 ? n.toFixed(4) : n.toFixed(Math.min(8, 1 - Math.floor(Math.log10(Math.abs(n)))));
return h("span", { class: "money" }, "$" + s.slice(0, -2), h("small", { class: "money-tail" }, s.slice(-2)));
}
export function money(n) {
if (n == null) return "—";
if (n === 0) return "$0";
if (Math.abs(n) < 0.01) return "$" + n.toFixed(6);
return "$" + n.toFixed(4);
if (Math.abs(n) > 0 && Math.abs(n) < 0.0001) return "$" + n.toFixed(Math.min(8, 1 - Math.floor(Math.log10(Math.abs(n)))));
// up to 4 decimals, trailing zeros trimmed (but keep money-like 2 minimum)
let s = n.toFixed(4).replace(/0+$/, "").replace(/\.$/, "");
if (/\.\d$/.test(s)) s += "0";
return "$" + s;
}
export function ms(n) {
return n == null ? "—" : `${Math.round(Number(n))} ms`;
+530
View File
@@ -0,0 +1,530 @@
// Benchmark — two modes over the live pipeline:
// 🧭 Smart routing (default): the product's core promise under test — every
// probe goes through cortiq-auto and the router's task/tier decision and
// chosen model are judged alongside the answer itself.
// 🎯 Individual models: the same suite pinned to selected models — compare
// quality and speed head-to-head.
// A redesign of the old standalone benchmark.html: same scoring ideas
// (keyword checks with | synonyms, think-tag stripping, anti-loop detection,
// pass/partial/fail), now embedded, localized and CDN-free.
import { h, mount, toast, ms, money, moneyEl } from "../ui.js";
import { t, trTier } from "../i18n.js";
import { api, testStream } from "../api.js";
// ── built-in suite ──────────────────────────────────────────────────────────
// Checks are language-neutral where possible (digits, identifiers, emails);
// `check` entries may hold |-separated synonyms — one hit per entry counts.
// `quick: true` marks the fast subset.
// `label`: router task labels accepted as a correct classification (null = not
// judged — the prompt has no natural label in the taxonomy).
const SUITE = [
{ id: "math-mult", cat: "math", label: ["math"], quick: true, max: 96, prompt: "What is 17 * 23? Answer with the number only.", check: ["391"] },
{ id: "math-eq", cat: "math", label: ["math"], max: 128, prompt: "Solve 2x + 6 = 14. Answer with the value of x only.", check: ["4"] },
{ id: "logic-syll", cat: "logic", label: ["qa", "math"], max: 96, prompt: "If all bloops are razzies and all razzies are lazzies, are all bloops lazzies? Answer yes or no.", check: ["yes|да|oui|ja|sí|si|evet|是"] },
{ id: "count-letters", cat: "logic", label: ["qa", "math"], quick: true, max: 64, prompt: "How many letters are in the word 'cat'? Answer with a digit.", check: ["3"] },
{ id: "apples", cat: "logic", label: ["math", "qa"], quick: true, max: 96, prompt: "John has 3 apples, buys 2 more, then eats 1. How many apples does he have? Answer with a digit.", check: ["4"] },
{ id: "code-fact", cat: "code", label: ["code"], max: 256, prompt: "Write a Python function factorial(n) that returns n!.", check: ["def", "factorial", "return"] },
{ id: "qa-capital", cat: "qa", label: ["qa"], quick: true, max: 48, prompt: "What is the capital of France? One word.", check: ["paris|париж"] },
{ id: "primes", cat: "qa", label: ["math", "qa"], max: 96, prompt: "List the first five prime numbers, comma-separated.", check: ["2", "3", "5", "7", "11"] },
{ id: "instr-ok", cat: "instruction", label: null, quick: true, max: 32, prompt: "Reply with exactly the word OK and nothing else.", check: ["ok"] },
{ id: "extract-email", cat: "extraction", label: ["extraction"], quick: true, max: 64, prompt: "Extract the email address from: 'Contact us at info@example.com today.' Reply with the address only.", check: ["info@example.com"] },
{ id: "translate", cat: "translation", label: ["translation"], max: 64, prompt: "Translate to English: «Доброе утро». Reply with the translation only.", check: ["good morning"] },
{ id: "format-json", cat: "instruction", label: null, max: 96, prompt: 'Return a JSON object with a single key "n" whose value is 7. JSON only.', check: ['"n"', "7"] },
{ id: "summarize", cat: "summarization", label: ["summarization"], max: 96, prompt: "Summarize in one short sentence: 'The cat sat on the warm mat by the window. After a while it curled up and fell asleep in the sun.'", check: ["cat|кот|кошк"] },
{ id: "creative", cat: "creative-writing", label: ["creative-writing"], max: 512, prompt: "Write a two-line poem about the sea.", check: ["sea|море|ocean"] },
{ id: "chitchat", cat: "chitchat", label: ["chitchat"], quick: true, max: 48, prompt: "Hi! How are you today?", check: [] },
// hard probes: dense enough that the router's complexity model rates them
// high tier — this is where tier-based routing actually earns its keep.
// Prompts are GENERATED fresh each run (domain + numbers rotate): the
// router deliberately scores familiar text as less novel, so any fixed
// wording would decay to medium/low over repeated runs.
// label: null — ambiguity between task labels is exactly what makes these high
{ id: "hard-science", cat: "hard", label: null, judge: "route", max: 8, check: [], gen: "science" },
{ id: "hard-ops", cat: "hard", label: null, judge: "route", max: 8, check: [], gen: "ops" },
];
// Fresh hard prompts per run. Two findings drive the design (measured against
// the live router): ambiguity carries the biggest weight in the complexity
// score, so INTERDISCIPLINARY prompts (science + engineering + planning) rate
// high reliably, while single-domain ones classify too confidently and drop
// to medium; and familiar text loses novelty on every repeat, so domains and
// numbers rotate to keep each run lexically fresh.
function hardPrompt(kind) {
const pick = (a) => a[(Math.random() * a.length) | 0];
const n = (lo, hi) => lo + ((Math.random() * (hi - lo)) | 0);
const science = [
() => `Architect a protein-folding inference pipeline for a wet lab: ensemble MSA generation with taxonomy-aware subsampling, template selection under a ${n(30, 60)} percent identity ceiling, distributed attention across ${n(4, 16)} GPUs with activation checkpointing, confidence calibration against experimental B-factors, and an active-learning loop that decides which of ${n(300, 2000)} candidate mutants to synthesize next week.`,
() => `Schedule observations for a network of ${n(8, 30)} robotic telescopes chasing gravitational-wave counterparts: probability skymaps updated every ${n(5, 20)} minutes, slew-time-aware target assignment with decaying rewards, cloud-cover forecasts folded in as stochastic availability, spectroscopy triggered only above a ${n(40, 85)} percent kilonova likelihood, and a plan to validate the scheduler against the one historical multi-messenger event.`,
() => `Run a closed-loop materials-discovery campaign for solid-state electrolytes: DFT screening of ${n(2000, 9000)} candidates, Bayesian optimization driving ${n(3, 12)} parallel synthesis robots, CNN-based XRD phase identification, an ionic-conductivity target of ${n(2, 25)} mS/cm, and an explicit decision policy for when simulation is trusted over synthesis.`,
() => `Design a surface-code error-correction pipeline for a ${n(400, 3000)}-qubit superconducting processor: choose a code distance for a logical error target of 1e-${n(9, 14)} at physical error rate 1e-3, syndrome extraction avoiding hook errors, a real-time decoder under a ${n(1, 4)} microsecond budget, and the classical co-processor layout with feedback for lattice surgery.`,
() => `Design a monitoring-reporting-verification pipeline for soil-carbon credits across ${n(500, 5000)} farms: satellite spectral indices calibrated against ${n(800, 4000)} physical core samples, hierarchical Bayesian estimation with shrinkage, additionality argued against county-level counterfactuals, uncertainty discounts priced into issuance, and an audit sampling plan catching over-reporting with ${n(90, 99)} percent power.`,
() => `Plan a cryo-EM structure-determination campaign for a ${n(300, 900)} kDa membrane complex: grid-screening strategy across ${n(4, 12)} detergent conditions, particle picking with a distilled transformer at ${n(2, 9)} million particles, ab-initio reconstruction ambiguity resolved by tilt-pair validation, local-resolution-aware model building, and a decision rule for when to stop collecting and publish.`,
];
const ops = [
() => `Model a dengue outbreak across ${n(20, 90)} districts by coupling an SEIR compartment model to vaccine cold-chain optimization: transmission parameters estimated from noisy case counts with a particle filter, uncertainty propagated into weekly demand forecasts, refrigerated routing under a ${n(120, 400)}-dose-per-crate constraint, and a defended ethical weighting for which ${n(3, 12)} districts get priority when supply covers ${n(20, 60)} percent of demand.`,
() => `Co-optimize a regional power grid's day-ahead market with a fleet of ${n(200, 2000)} grid-scale batteries: bid curves learned from ${n(2, 6)} years of price data, unit commitment as mixed-integer programming with ramp constraints, degradation amortized into marginal costs, N-1 contingency screening after every dispatch, and a mechanism-design argument that the bidding rules cannot be gamed by a coalition controlling ${n(10, 30)} percent of storage.`,
() => `Couple regional climate downscaling at ${n(2, 12)} km resolution to catastrophe-bond pricing: bias-correct ${n(10, 40)} CMIP ensemble members, generate stochastic hurricane tracks, propagate landfall distributions into a portfolio of ${n(30, 200)} bonds, price the attachment probabilities, and defend the discount rate applied to century-scale tail risk.`,
() => `Co-design a hospital emergency department's triage policy with its nurse staffing schedule: arrivals as a time-varying Poisson process fitted to ${n(2, 6)} years of data, acuity drift as a hidden Markov chain, staffing as integer programming over ${n(8, 12)}-hour shifts, a queueing bound on door-to-doctor time at the ${n(90, 99)}th percentile, and a fairness constraint preventing systematic delays for low-acuity elderly patients.`,
() => `Optimize a container port's berth allocation jointly with tidal windows and rail departures: vessel ETAs with heavy-tailed uncertainty, berth-crane assignment as a mixed-integer program re-solved hourly, tide-constrained draft limits for the ${n(3, 8)} deepest berths, a queueing model of the truck gate coupled to city traffic, and a robustness argument for surviving a ${n(24, 72)}-hour crane outage.`,
() => `Schedule observations for a fleet of ${n(6, 40)} agricultural drones across ${n(200, 2000)} fields: multispectral revisit deadlines driven by crop growth-stage models, battery-swap logistics as a vehicle-routing problem with time windows, rain forecasts as stochastic field availability, disease-outbreak alerts pre-empting the plan, and a proof the replanner never starves a field past its agronomic deadline.`,
];
return pick(kind === "science" ? science : ops)();
}
const LS_CUSTOM = "allaigate_bench_custom";
const LS_HISTORY = "allaigate_bench_history";
function customTests() {
try {
return JSON.parse(localStorage.getItem(LS_CUSTOM) || "[]");
} catch {
return [];
}
}
function saveCustom(list) {
localStorage.setItem(LS_CUSTOM, JSON.stringify(list));
}
function history() {
try {
return JSON.parse(localStorage.getItem(LS_HISTORY) || "[]");
} catch {
return [];
}
}
function pushHistory(run) {
const l = history();
l.unshift(run);
localStorage.setItem(LS_HISTORY, JSON.stringify(l.slice(0, 12)));
}
function saveHistory(l) {
localStorage.setItem(LS_HISTORY, JSON.stringify(l));
}
// ── evaluation (ported from the old benchmark, trimmed) ─────────────────────
function stripThink(text) {
return text.replace(/<(?:\|)?(?:think|thought)(?:\|)?>[\s\S]*?(?:<\/(?:\|)?(?:think|thought)(?:\|)?>|$)/gi, "").trim();
}
// tail loop: the same 12+ char chunk repeated 3+ times back to back
function looksLooped(text) {
const tail = text.slice(-360);
for (let len = 12; len <= 60; len += 8) {
const probe = tail.slice(-len);
if (probe && tail.split(probe).length - 1 >= 3) return true;
}
return false;
}
function evaluate(raw, test) {
let text = stripThink(raw);
if (!text && raw) text = raw; // max_tokens cut inside <think> — judge the raw
const resp = text.toLowerCase();
if (!resp.trim()) return { status: "fail", score: 0, note: t("bm.emptyAnswer") };
if (looksLooped(resp)) return { status: "fail", score: 0, note: t("bm.loopDetected") };
if (!test.check.length) return { status: "pass", score: 1, note: "✓" };
let hit = 0;
for (const cond of test.check) {
const opts = cond.toLowerCase().split("|").map((s) => s.trim());
if (opts.some((o) => o && resp.includes(o))) hit++;
}
const frac = hit / test.check.length;
const status = frac >= 1 ? "pass" : frac >= 0.5 ? "partial" : "fail";
return { status, score: frac, note: `${hit}/${test.check.length}` };
}
export async function renderBenchmark() {
const modelsData = await api.listModels();
const ids = (modelsData.models || []).map((m) => m.id);
const root = h("div");
let running = false;
let stopFlag = false;
let mode = "routing"; // routing (cortiq-auto, the product's feature) | models
const modelCbs = ids.map((id) => ({ id, cb: h("input", { type: "checkbox", checked: ids.length === 1 ? true : null }) }));
const quickCb = h("input", { type: "checkbox", checked: true });
const resultsHost = h("div", {});
const progressHost = h("div", {});
const historyHost = h("div", {});
const modeDesc = h("p", { class: "muted", style: "margin:8px 0 0" }, t("bm.mode.routingDesc"));
const modelsPick = h("div", { class: "flex wrap", style: "gap:10px; margin-top:12px; display:none" },
...modelCbs.map(({ id, cb }) => h("label", { class: "check" }, cb, h("span", { class: "mono" }, id))));
const modeSeg = h("div", { class: "seg" },
h("button", { class: "active" }, "🧭 " + t("bm.mode.routing")),
h("button", {}, "🎯 " + t("bm.mode.models")));
modeSeg.querySelectorAll("button").forEach((b, i) =>
b.addEventListener("click", () => {
mode = i === 0 ? "routing" : "models";
modeSeg.querySelectorAll("button").forEach((x, j) => x.classList.toggle("active", j === i));
modelsPick.style.display = mode === "models" ? "" : "none";
modeDesc.textContent = t(mode === "routing" ? "bm.mode.routingDesc" : "bm.mode.modelsDesc");
}));
function suite() {
const base = SUITE.concat(customTests());
return quickCb.checked ? base.filter((x) => x.quick || x.custom) : base;
}
const runBtn = h("button", { class: "btn primary" }, "▶ " + t("bm.run"));
const stopBtn = h("button", { class: "btn ghost", style: "display:none" }, "✕ " + t("import.cancel"));
stopBtn.addEventListener("click", () => {
stopFlag = true;
stopBtn.disabled = true;
});
runBtn.addEventListener("click", async () => {
const models = mode === "routing" ? ["cortiq-auto"] : modelCbs.filter((m) => m.cb.checked).map((m) => m.id);
if (!models.length) {
toast(t("bm.pickModel"), "warn");
return;
}
running = true;
stopFlag = false;
runBtn.disabled = true;
stopBtn.style.display = "";
stopBtn.disabled = false;
const tests = suite().filter((x) => mode === "routing" || x.judge !== "route");
const run = { ts: Date.now(), mode, quick: quickCb.checked, models: {}, nTests: tests.length };
for (const model of models) {
const rec = { results: [], score: 0, tokS: [], ttft: [], failed: false };
run.models[model] = rec;
for (let i = 0; i < tests.length; i++) {
if (stopFlag) break;
const test = tests[i];
const prompt = test.gen ? hardPrompt(test.gen) : test.prompt;
mount(
progressHost,
h("div", { class: "card" },
h("div", { class: "flex" },
h("span", { class: "spinner" }), " ",
h("b", { class: "mono" }, mode === "routing" ? t("bm.mode.routing") : model), " · ",
t("bm.testOf", { i: i + 1, n: tests.length }), " · ",
h("span", { class: "muted" }, test.id)),
h("div", { class: "muted small", style: "margin-top:6px; font-style:italic" }, "«" + prompt.slice(0, 110) + (prompt.length > 110 ? "…»" : "»")),
h("div", { class: "pbar", style: "margin-top:8px" },
h("div", { class: "pbar-fill det", style: `width:${Math.round((i / tests.length) * 100)}%` })))
);
const started = performance.now();
let text = "";
try {
const info = await testStream(
// +256: headroom for reasoning models — a tight cap cuts them
// mid-<think> and turns a correct model into a false ❌
{ model, messages: [{ role: "user", content: prompt }], temperature: 0, max_tokens: (test.max || 128) + 256 },
(d) => (text += d)
);
const latency = performance.now() - started;
let ev;
if (test.judge === "route") {
// the test asserts the ROUTER's decision: did it rate this high?
// Two honest retries. (1) The SAME prompt after a pause: the
// router judges difficulty in the background — the first-ever
// occurrence routes by prior, the judgement lands in the cache a
// couple of seconds later, so the second occurrence is the real
// steady-state answer. (2) A fresh template, in case this one's
// wording clustered into a burnt region.
if (info.tier !== "high" && test.gen) {
const attempts = [
{ prompt, delayMs: 4000 },
{ prompt: hardPrompt(test.gen), delayMs: 0 },
];
for (const a of attempts) {
if (info.tier === "high") break;
if (a.delayMs) await new Promise((r) => setTimeout(r, a.delayMs));
try {
const again = await testStream(
{ model, messages: [{ role: "user", content: a.prompt }], temperature: 0, max_tokens: (test.max || 128) + 256 },
() => {}
);
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;
}
} catch { /* keep previous attempt */ }
}
}
ev = info.tier === "high"
? { status: "pass", score: 1, note: "high ✓" }
: { status: "fail", score: 0, note: (info.tier || "—") + " ≠ high" };
} else {
ev = evaluate(text, test);
}
const compl = info.usage?.completion_tokens;
if (test.judge !== "route" && ev.status === "fail" && !stripThink(text).trim() && compl >= (test.max || 128) + 250) {
ev.note = t("bm.cutByCap"); // the whole budget went into <think>
}
if (compl && latency > 0) {
const gen = info.ttft_ms != null ? Math.max(1, latency - info.ttft_ms) : latency;
rec.tokS.push(compl / (gen / 1000));
}
if (info.ttft_ms != null) rec.ttft.push(info.ttft_ms);
const row = { id: test.id, cat: test.cat, ...ev, latency: Math.round(latency), cost: info.cost_usd || 0, answer: stripThink(text).slice(0, 300) };
if (mode === "routing") {
// router timed out / fell back — there was no classification to
// judge, so it must not count against accuracy
row.degraded = !info.task_label
|| info.task_label === "degraded"
|| 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
row.taskOk = test.label && !row.degraded ? test.label.includes(info.task_label) : null;
}
rec.results.push(row);
} catch (e) {
rec.results.push({ id: test.id, cat: test.cat, status: "fail", score: 0, note: String(e.message || e).slice(0, 80), latency: 0, answer: "" });
rec.failed = true;
}
renderResults(run, true); // live: строка появляется сразу после теста
}
if (stopFlag) break;
}
mount(progressHost);
running = false;
runBtn.disabled = false;
stopBtn.style.display = "none";
if (!stopFlag) {
pushHistory(run);
// финальный аккорд: короткий вердикт с процентами и деньгами
const first = run.models[Object.keys(run.models)[0]];
if (first) {
const st = stats(first);
const judged = first.results.filter((r) => r.taskOk != null);
const cls = judged.length ? ` · ${judged.filter((r) => r.taskOk).length}/${judged.length}` : "";
const total = Object.values(run.models).reduce((a, rec) => a + stats(rec).cost, 0);
toast(t("bm.verdict", { p: Math.round(st.score * 100) + "%" + cls, s: money(total) }), "ok", 6000);
}
}
renderResults(run);
renderHistory();
});
function statusIcon(s) {
return s === "pass" ? "✅" : s === "partial" ? "◐" : "❌";
}
function stats(rec) {
const n = rec.results.length || 1;
return {
score: rec.results.reduce((a, r) => a + r.score, 0) / n,
tokS: rec.tokS.length ? rec.tokS.reduce((a, b) => a + b, 0) / rec.tokS.length : null,
ttft: rec.ttft.length ? rec.ttft.reduce((a, b) => a + b, 0) / rec.ttft.length : null,
cost: rec.results.reduce((a, r) => a + (r.cost || 0), 0),
};
}
function renderResults(run, live) {
const models = Object.keys(run.models);
if (!models.length) {
mount(resultsHost);
return;
}
const routing = run.mode === "routing";
const detail = h("div", {});
let summary;
if (routing) {
const rec = run.models[models[0]];
const st = stats(rec);
const judged = rec.results.filter((r) => r.taskOk != null);
const okCount = judged.filter((r) => r.taskOk).length;
// какие модели реально выбирал роутер
const dist = {};
for (const r of rec.results) {
if (!r.chosen) continue;
dist[r.chosen] = dist[r.chosen] || { n: 0, cost: 0 };
dist[r.chosen].n++;
dist[r.chosen].cost += r.cost || 0;
}
const pct = Math.round(st.score * 100);
summary = h("div", {},
h("div", { class: "kpis", style: "grid-template-columns: repeat(auto-fit, minmax(150px, 1fr))" },
h("div", { class: "kpi" }, h("div", { class: "big" }, pct + "%"), h("div", { class: "lbl" }, t("bm.answersOk"))),
h("div", { class: "kpi" }, h("div", { class: "big" }, judged.length ? `${okCount}/${judged.length}` : "—"), h("div", { class: "lbl" }, t("bm.classAcc"))),
h("div", { class: "kpi" }, h("div", { class: "big" }, st.ttft != null ? ms(Math.round(st.ttft)) : "—"), h("div", { class: "lbl" }, t("pg.ttft"))),
h("div", { class: "kpi" }, h("div", { class: "big" }, st.tokS != null ? st.tokS.toFixed(1) : "—"), h("div", { class: "lbl" }, "tok/s")),
h("div", { class: "kpi" }, h("div", { class: "big" }, moneyEl(st.cost)), h("div", { class: "lbl" }, t("bm.spent")))),
h("div", { class: "flex wrap", style: "gap:6px; margin-top:4px" },
h("span", { class: "muted small" }, t("bm.modelDist") + ":"),
...Object.entries(dist).map(([m, d]) =>
h("span", { class: "badge" }, `${m} × ${d.n}`, d.cost > 0 ? " · " : "", d.cost > 0 ? moneyEl(d.cost) : ""))));
} else {
const sorted = models.slice().sort((a, b) => stats(run.models[b]).score - stats(run.models[a]).score);
summary = h("div", {},
...sorted.map((m) => {
const st = stats(run.models[m]);
const pct = Math.round(st.score * 100);
return h("div", { class: "bm-sum" },
h("div", { class: "flex" },
h("b", { class: "mono grow" }, m),
h("span", { class: "badge " + (pct >= 80 ? "ok" : pct >= 50 ? "" : "bad") }, pct + "%"),
st.tokS != null ? h("span", { class: "muted small", style: "margin-left:8px" }, st.tokS.toFixed(1) + " tok/s") : null,
st.ttft != null ? h("span", { class: "muted small", style: "margin-left:8px" }, "TTFT " + ms(Math.round(st.ttft))) : null,
st.cost > 0 ? h("span", { class: "muted small", style: "margin-left:8px" }, t("bm.spent") + " ", moneyEl(st.cost)) : null),
h("div", { class: "sysbar" }, h("i", { class: pct < 50 ? "bad" : "", style: `width:${pct}%` })));
}));
}
function answerCell(r, m) {
const cell = h("td", { class: "bm-cell", title: r.answer || r.note },
statusIcon(r.status) + " ",
h("span", { class: "muted small" }, r.note + (r.latency ? ` · ${(r.latency / 1000).toFixed(1)}s` : "")));
cell.addEventListener("click", () => {
mount(detail, h("div", { class: "card" },
h("div", { class: "card-title" }, `${r.id} · ${m} · ${statusIcon(r.status)}`),
h("pre", { class: "conv-log", style: "max-height:200px" }, r.answer || "—")));
});
return cell;
}
let header, rows;
if (routing) {
const rec = run.models[models[0]];
header = h("tr", {},
h("th", {}, t("bm.test")),
h("th", {}, t("pg.task")),
h("th", {}, t("pg.tier")),
h("th", {}, t("pg.model")),
h("th", {}, t("bm.answer")));
rows = rec.results.map((r) =>
h("tr", {},
h("td", {}, h("div", {}, r.id), h("div", { class: "muted small" }, r.cat)),
h("td", {},
r.degraded
? h("span", { class: "muted", title: t("bm.routerDegraded") }, "⚠ " + t("bm.degraded"))
: r.taskOk == null
? h("span", { class: "muted" }, r.task || "—")
: 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)) : "—",
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,
r.source && r.source.includes("task-rule") ? h("div", { class: "badge", style: "margin-top:2px" }, "task-rule") : null),
answerCell(r, r.chosen || "cortiq-auto")));
} else {
header = h("tr", {}, h("th", {}, t("bm.test")), ...models.map((m) => h("th", { class: "mono" }, m)));
rows = (run.models[models[0]].results || []).map((_, i) =>
h("tr", {},
h("td", {},
h("div", {}, run.models[models[0]].results[i].id),
h("div", { class: "muted small" }, run.models[models[0]].results[i].cat)),
...models.map((m) => {
const r = run.models[m].results[i];
return r ? answerCell(r, m) : h("td", {}, "—");
})));
}
mount(resultsHost,
h("div", { class: "card" },
h("div", { class: "card-head" },
h("h3", {}, (routing ? "🧭 " : "🎯 ") + t("bm.results")),
h("span", { class: "card-head sub" },
(live ? t("bm.liveRunning") + " · " : "") + new Date(run.ts).toLocaleString() + (run.quick ? " · " + t("bm.quick") : ""))),
summary,
h("div", { class: "table-wrap", style: "margin-top:14px" }, h("table", {}, h("thead", {}, header), h("tbody", {}, ...rows))),
h("div", { class: "muted small", style: "margin-top:6px" }, t("bm.cellHint"))),
detail);
}
function renderHistory() {
const l = history();
if (!l.length) {
mount(historyHost);
return;
}
const clearAll = h("button", { class: "btn ghost sm danger" }, "🗑 " + t("bm.clearHistory"));
clearAll.addEventListener("click", () => {
saveHistory([]);
renderHistory();
});
mount(historyHost, h("div", { class: "card" },
h("div", { class: "card-head" },
h("h3", {}, t("bm.history")),
h("div", { class: "right" }, clearAll)),
...l.map((run, idx) => {
const models = Object.keys(run.models);
const scoreOf = (m) => {
const rs = run.models[m].results || [];
return rs.length ? rs.reduce((a, r) => a + r.score, 0) / rs.length : 0;
};
const best = models.slice().sort((a, b) => scoreOf(b) - scoreOf(a))[0];
const row = h("div", { class: "flex bench-row", style: "cursor:pointer" },
h("span", {}, run.mode === "routing" ? "🧭" : "🎯"),
h("span", { class: "muted small" }, new Date(run.ts).toLocaleString()),
h("span", { class: "grow mono small" }, run.mode === "routing" ? t("bm.mode.routing") : models.join(", ")),
run.quick ? h("span", { class: "badge" }, t("bm.quick")) : null,
best ? h("b", {}, Math.round(scoreOf(best) * 100) + "%") : null,
h("button", { class: "btn sm ghost danger", title: t("common.delete"), onClick: (e) => {
e.stopPropagation();
l.splice(idx, 1);
saveHistory(l);
renderHistory();
} }, "×"));
row.addEventListener("click", () => renderResults(run));
return row;
})));
}
// ── custom tests (stored in the browser) ──
function customCard() {
const host = h("div", { class: "card" });
function paint() {
const list = customTests();
const promptIn = h("input", { placeholder: t("bm.promptPh") });
const checkIn = h("input", { placeholder: t("bm.checkPh") });
const addBtn = h("button", { class: "btn ghost sm" }, "+ " + t("bm.addTest"));
addBtn.addEventListener("click", () => {
const prompt = promptIn.value.trim();
const check = checkIn.value.split(",").map((s) => s.trim()).filter(Boolean);
if (!prompt || !check.length) return;
list.push({ id: "custom-" + (list.length + 1), cat: "custom", custom: true, max: 128, prompt, check });
saveCustom(list);
paint();
});
mount(host,
h("div", { class: "card-title" }, t("bm.custom")),
h("div", { class: "muted small", style: "margin-bottom:8px" }, t("bm.customHint")),
...list.map((tst, i) =>
h("div", { class: "flex bench-row" },
h("span", { class: "grow small" }, tst.prompt.slice(0, 90)),
h("span", { class: "muted small mono" }, tst.check.join(", ").slice(0, 40)),
h("button", { class: "btn sm ghost danger", onClick: () => { list.splice(i, 1); saveCustom(list); paint(); } }, "×"))),
h("div", { class: "flex", style: "gap:8px; margin-top:8px" }, promptIn, checkIn, addBtn));
}
paint();
return host;
}
mount(root,
h("div", { class: "page-head" },
h("h2", {}, t("bm.title")),
h("p", {}, t("bm.subtitle"))),
ids.length === 0 ? h("div", { class: "callout" }, t("routing.empty")) : null,
h("div", { class: "card" },
modeSeg,
modeDesc,
modelsPick,
h("div", { class: "flex", style: "justify-content:space-between; margin-top:14px" },
h("label", { class: "check", style: "margin:0", title: t("bm.quickHint") }, quickCb, "⚡ " + t("bm.quickMode")),
h("div", { class: "flex", style: "gap:8px" }, stopBtn, runBtn)),
h("div", { class: "muted small", style: "margin-top:6px" },
t("bm.suiteInfo", { q: SUITE.filter((x) => x.quick).length, f: SUITE.length }))),
progressHost,
resultsHost,
customCard(),
historyHost);
renderHistory();
return root;
}
+14 -6
View File
@@ -1,6 +1,6 @@
// Dashboard — KPIs, time-series charts, breakdown, recent requests, health.
import { h, mount, money, num, ms, pct, timeAgo, toast, confirmDialog } from "../ui.js";
import { t } from "../i18n.js";
import { h, mount, money, moneyEl, num, ms, pct, timeAgo, toast, confirmDialog } from "../ui.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)));
@@ -161,7 +162,12 @@ export async function renderDashboard() {
{ class: "kpis" },
kpi(num(tot.requests), t("dash.kpi.requests")),
kpi(num(tot.total_tokens), t("dash.kpi.tokens")),
kpi(money(tot.cost_usd), t("dash.kpi.cost")),
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)),
+273 -21
View File
@@ -1,30 +1,23 @@
// API keys — list, create (with copy-once), revoke.
import { h, mount, modal, toast, confirmDialog } from "../ui.js";
import { h, mount, modal, toast, confirmDialog, moneyEl, num } from "../ui.js";
import { t } from "../i18n.js";
import { api } from "../api.js";
export async function renderKeys() {
const modelsData = await api.listModels();
const ids = (modelsData.models || []).map((m) => m.id);
const root = h("div");
let usageRange = "7d";
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 allowInputs = ids.map((id) => ({ id, cb: h("input", { type: "checkbox" }) }));
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.allow")),
h("div", { class: "flex wrap" }, ...allowInputs.map(({ id, cb }) => h("label", { class: "check" }, cb, id))),
h("div", { class: "hint" }, t("keys.form.allowHint"))
),
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 () => {
@@ -33,7 +26,8 @@ export async function renderKeys() {
key: customIn.value.trim(),
account: accountIn.value.trim() || "default",
rate_per_min: parseInt(rateIn.value) || 0,
allow_models: allowInputs.filter(({ cb }) => cb.checked).map(({ id }) => id),
monthly_budget_usd: parseFloat(budgetIn.value) > 0 ? parseFloat(budgetIn.value) : null,
allow_models: [],
});
reload();
showCreated(r.key);
@@ -58,9 +52,120 @@ export async function renderKeys() {
// Keys are stored hashed-masked in the list, so the key itself is a
// placeholder unless one was just created on this page.
function snippetsCard(openMode) {
const base = location.origin + "/v1";
const key = lastCreatedKey || (openMode ? "sk-anything" : "<" + t("keys.snip.keyPh") + ">");
const snips = {
const origin = location.origin;
const base = origin + "/v1";
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 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: `mkdir -p ~/.codex && cat >> ~/.codex/config.toml <<'EOF'
model = "cortiq-auto"
model_provider = "cortiq"
[model_providers.cortiq]
name = "Cortiq Gateway"
base_url = "${base}"
env_key = "CORTIQ_API_KEY"
wire_api = "chat"
EOF
export CORTIQ_API_KEY="${key}"
codex`,
OpenCode: `mkdir -p ~/.config/opencode && cat > ~/.config/opencode/opencode.json <<'EOF'
{
"$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)" } }
}
}
}
EOF
opencode`,
Continue: `mkdir -p ~/.continue && cat >> ~/.continue/config.yaml <<'EOF'
models:
- name: Cortiq Auto
provider: openai
model: cortiq-auto
apiBase: ${base}
apiKey: ${key}
EOF`,
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 ${base}/chat/completions \\
-H "Authorization: Bearer ${key}" \\
-H "Content-Type: application/json" \\
@@ -82,20 +187,133 @@ const resp = await client.chat.completions.create({
});
console.log(resp.choices[0].message.content);`,
};
let cur = "curl";
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" },
...Object.keys(snips).map((k, i) =>
h("button", { class: i === 0 ? "active" : "", onClick: (e) => {
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"));
e.target.classList.add("active");
e.currentTarget.classList.add("active");
} }, k)
)
);
// Claude Code needs the anthropic_messages protocol — offer a one-click
// enable when it is off, right where the user will hit the problem.
const protoWarn = h("div", { style: "display:none; margin-top:8px" });
api.getProtocols().then((prot) => {
if (prot.anthropic_messages) return;
const fixBtn = h("button", { class: "btn sm primary" }, t("keys.h.enableProto"));
fixBtn.addEventListener("click", async () => {
try {
await api.putProtocols({ ...prot, anthropic_messages: true });
protoWarn.style.display = "none";
toast(t("common.saved"), "ok");
} catch (e) {
toast(String(e.message || e), "err");
}
});
mount(protoWarn, h("div", { class: "callout warn" }, h("span", {}, "⚠ " + t("keys.h.protoOff")), " ", fixBtn));
if (cur === "Claude Code") protoWarn.style.display = "";
}).catch(() => {});
// Detected harnesses: ✓ on the tab when installed on the gateway machine,
// 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() {
const d = detected[cur];
if (!d) {
statusHost.textContent = "";
return;
}
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 {
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 || {};
seg.querySelectorAll("button").forEach((b) => {
const st = detected[b.dataset.snip];
if (st && st.installed) b.append(" ✓");
});
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);
@@ -107,14 +325,27 @@ 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" }, seg, h("div", { class: "grow" }), copy),
pre
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,
h("div", { class: "muted small", style: "margin-top:6px" }, t("keys.h.note"))
);
}
async function reload() {
const data = await api.listKeys();
const keys = data.keys || [];
// per-account usage: window breakdown + cumulative-forever totals — the
// "who spends what" view for departments/developers sharing one gateway
let usage = {};
let alltime = {};
try {
const st = await api.stats(usageRange, "account");
for (const b of st.breakdown || []) usage[b.key] = b;
alltime = st.alltime_by_account || {};
} catch { /* stats disabled — columns show dashes */ }
mount(
root,
h(
@@ -124,6 +355,9 @@ console.log(resp.choices[0].message.content);`,
"div",
{ class: "flex" },
h("div", { class: "grow" }, h("h2", {}, t("keys.title")), h("p", {}, t("keys.subtitle"))),
h("div", { class: "seg", style: "margin-right:10px" },
...["24h", "7d", "30d"].map((r) =>
h("button", { class: r === usageRange ? "active" : "", onClick: () => { usageRange = r; reload(); } }, r))),
h("button", { class: "btn primary", onClick: () => openCreate(reload) }, "+ " + t("keys.add"))
)
),
@@ -142,7 +376,16 @@ console.log(resp.choices[0].message.content);`,
h(
"thead",
{},
h("tr", {}, h("th", {}, t("keys.col.key")), h("th", {}, t("keys.col.account")), h("th", {}, t("keys.col.rate")), h("th", {}, t("keys.col.models")), h("th", { class: "right-align" }, t("common.actions")))
h("tr", {},
h("th", {}, t("keys.col.key")),
h("th", {}, t("keys.col.account")),
h("th", {}, t("keys.col.rate")),
h("th", {}, t("keys.col.models")),
h("th", { class: "right-align" }, t("keys.col.requests") + " (" + usageRange + ")"),
h("th", { class: "right-align" }, t("keys.col.tokens") + " (" + usageRange + ")"),
h("th", { class: "right-align" }, "$ (" + usageRange + ")"),
h("th", { class: "right-align" }, t("keys.col.costAll")),
h("th", { class: "right-align" }, t("common.actions")))
),
h(
"tbody",
@@ -155,6 +398,15 @@ console.log(resp.choices[0].message.content);`,
h("td", {}, k.account),
h("td", { class: "mono" }, k.rate_per_min || "—"),
h("td", {}, (k.allow_models && k.allow_models.length) ? k.allow_models.join(", ") : h("span", { class: "muted" }, t("keys.allowAll"))),
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) : "—",
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" },
+12 -1
View File
@@ -21,6 +21,17 @@ export function renderLogs() {
);
const pauseBtn = h("button", { class: "btn ghost sm" }, "⏸ " + t("logs.pause"));
const copyBtn = h("button", { class: "btn ghost sm" }, "⧉ " + t("common.copy"));
const clearBtn = h("button", { class: "btn ghost sm danger" }, "🗑 " + t("dash.clear"));
clearBtn.addEventListener("click", async () => {
try {
await api.clearLogs();
lines = [];
paint();
toast(t("dash.cleared"), "ok");
} catch (e) {
toast(String(e.message || e), "err");
}
});
let lines = [];
function paint() {
@@ -76,7 +87,7 @@ export function renderLogs() {
h(
"div",
{ class: "card" },
h("div", { class: "flex", style: "gap:8px; flex-wrap:wrap; margin-bottom:10px" }, filterIn, levelSel, pauseBtn, copyBtn),
h("div", { class: "flex", style: "gap:8px; flex-wrap:wrap; margin-bottom:10px" }, filterIn, levelSel, pauseBtn, copyBtn, clearBtn),
pre
)
);
+4 -4
View File
@@ -4,8 +4,8 @@
// video → `cortiq animate` over a local text-to-video .cmf (MiniMax-H3)
// Media results are decoded in the browser (PPM → canvas, MJPEG AVI → canvas
// player synced to the wav) — the same no-ffmpeg stance as the engine itself.
import { h, mount, toast, money, ms } from "../ui.js";
import { t } from "../i18n.js";
import { h, mount, toast, money, moneyEl, ms } from "../ui.js";
import { t, trTier } from "../i18n.js";
import { api, testStream, mediaFileUrl, mediaFileBytes } from "../api.js";
import { appState } from "../app.js";
import { ppmToCanvas, fileToPpmB64, aviJpegFrames, mjpegPlayer } from "../media-utils.js";
@@ -206,7 +206,7 @@ export async function renderPlayground() {
decision,
h("div", { class: "card-head" }, h("h3", {}, t("pg.decision"))),
kv(t("pg.task"), c.task_label || "—"),
kv(t("pg.tier"), c.complexity?.tier ? h("span", { class: "badge tier-" + c.complexity.tier }, c.complexity.tier) : "—"),
kv(t("pg.tier"), c.complexity?.tier ? h("span", { class: "badge tier-" + c.complexity.tier }, trTier(c.complexity.tier)) : "—"),
h(
"div",
{ class: "kv", style: "display:block" },
@@ -218,7 +218,7 @@ export async function renderPlayground() {
kv(t("pg.tokens"), haveUsage ? `${r.usage_estimated ? "≈ " : ""}${u.prompt_tokens ?? 0} / ${u.completion_tokens ?? 0}` : "—"),
kv(t("pg.tokSpeed"), tokS != null ? tokS + " tok/s" : "—"),
kv(t("pg.ttft"), r.ttft_ms != null ? ms(r.ttft_ms) : "—"),
kv(t("pg.cost"), money(c.cost_usd)),
kv(t("pg.cost"), moneyEl(c.cost_usd)),
kv(t("pg.latency"), ms(r.latency_ms)),
kv(t("pg.failover"), c.failover ? "yes" : "no")
);
+153 -10
View File
@@ -1,6 +1,6 @@
// Routing — tier editor (ordered model chips), default model, and policy.
import { h, mount, toast } from "../ui.js";
import { t } from "../i18n.js";
import { t, trTier } from "../i18n.js";
import { api } from "../api.js";
import { appState } from "../app.js";
@@ -54,10 +54,24 @@ export async function renderRouting() {
// local editable state — ensure standard tiers exist for a friendly editor
const tiers = Object.assign({ low: [], medium: [], high: [] }, routing.tiers || {});
const tasks = {};
for (const l of taskLabels) tasks[l] = (routing.tasks || {})[l] || [];
// A task rule is edited as ordered PAIRS (model, tier): "this model serves
// this task at this complexity" (tier "any" = at every complexity).
// GET returns {any:[], low:[], medium:[], high:[]}; legacy arrays = any.
const taskPairs = {};
for (const l of taskLabels) {
const v = (routing.tasks || {})[l];
const pairs = [];
if (Array.isArray(v)) for (const m of v) pairs.push({ model: m, tier: "any" });
else if (v && typeof v === "object") {
for (const tier of ["low", "medium", "high", "any"])
for (const m of v[tier] || []) pairs.push({ model: m, tier });
}
taskPairs[l] = pairs;
}
const st = {
tiers,
tasks,
taskPairs,
bands: routing.bands || null, // null = пороги роутера как есть
default: routing.default || ids[0] || "",
policy: routing.policy || { mode: "fixed_table", min_class: {} },
};
@@ -107,11 +121,97 @@ export async function renderRouting() {
);
}
// Linked boundary sliders: one track, two handles (low|medium boundaries),
// three colored zones. Handles push each other, numbers update live —
// nobody types score ranges by hand.
function bandsCard() {
const DEF = { low: 0.33, medium: 0.48 };
const enabled = !!st.bands;
const b = st.bands || { ...DEF };
const MIN_GAP = 0.02;
const track = h("div", { class: "bands-track" });
const zoneLow = h("div", { class: "bands-zone z-low" });
const zoneMed = h("div", { class: "bands-zone z-med" });
const zoneHigh = h("div", { class: "bands-zone z-high" });
const hLow = h("div", { class: "bands-handle", tabindex: "0" }, h("span", { class: "bands-val" }));
const hMed = h("div", { class: "bands-handle", tabindex: "0" }, h("span", { class: "bands-val" }));
const legend = h("div", { class: "bands-legend" });
track.append(zoneLow, zoneMed, zoneHigh, hLow, hMed);
function paint() {
const lo = b.low, me = b.medium;
zoneLow.style.width = lo * 100 + "%";
zoneMed.style.left = lo * 100 + "%";
zoneMed.style.width = (me - lo) * 100 + "%";
zoneHigh.style.left = me * 100 + "%";
zoneHigh.style.width = (1 - me) * 100 + "%";
hLow.style.left = lo * 100 + "%";
hMed.style.left = me * 100 + "%";
hLow.querySelector(".bands-val").textContent = lo.toFixed(2);
hMed.querySelector(".bands-val").textContent = me.toFixed(2);
// подписи не наезжают, когда ручки рядом
hMed.querySelector(".bands-val").classList.toggle("raised", me - lo < 0.09);
mount(legend,
h("span", { class: "badge tier-low" }, `${trTier("low")} ≤ ${lo.toFixed(2)}`),
h("span", { class: "badge tier-medium" }, `${trTier("medium")} ≤ ${me.toFixed(2)}`),
h("span", { class: "badge tier-high" }, `${trTier("high")} > ${me.toFixed(2)}`));
track.classList.toggle("disabled", !st.bands);
}
function drag(handle, which) {
handle.addEventListener("pointerdown", (e) => {
if (!st.bands) return;
handle.setPointerCapture(e.pointerId);
const rect = track.getBoundingClientRect();
const move = (ev) => {
let v = (ev.clientX - rect.left) / rect.width;
v = Math.min(0.98, Math.max(0.02, Math.round(v * 100) / 100));
if (which === "low") {
b.low = v;
// связанность: low толкает medium перед собой
if (b.medium < b.low + MIN_GAP) b.medium = Math.min(0.99, b.low + MIN_GAP);
} else {
b.medium = v;
// и medium толкает low назад
if (b.low > b.medium - MIN_GAP) b.low = Math.max(0.01, b.medium - MIN_GAP);
}
st.bands = { low: b.low, medium: b.medium };
paint();
};
const up = () => {
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", up);
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", up);
});
}
drag(hLow, "low");
drag(hMed, "medium");
const toggle = h("input", { type: "checkbox", checked: enabled ? "" : null });
toggle.checked = enabled;
toggle.addEventListener("change", () => {
st.bands = toggle.checked ? { low: b.low, medium: b.medium } : null;
paint();
});
paint();
return h(
"div",
{ class: "bands-wrap" },
h("label", { class: "check", style: "margin-bottom:8px" }, toggle, t("routing.bands.enable")),
h("div", { class: "muted small", style: "margin-bottom:10px" }, t("routing.bands.hint")),
track,
legend
);
}
function tierRow(tier) {
return h(
"div",
{ class: "tierrow" },
h("div", { class: "tname" }, h("span", { class: "badge tier-" + tier }, tier)),
h("div", { class: "tname" }, h("span", { class: "badge tier-" + tier }, trTier(tier))),
chipEditor(st.tiers, tier)
);
}
@@ -130,17 +230,52 @@ export async function renderRouting() {
h("div", { class: "muted small mono" }, label),
share != null ? h("div", { class: "muted small" }, t("routing.tasks.share", { p: Math.round(share * 100) })) : null
),
chipEditor(st.tasks, label, t("routing.tasks.inherit"))
pairsEditor(label)
);
}
// Pairs editor: each row is [model ▾][complexity ▾][×]; "any" serves every
// tier. Order matters — it is the fallback order inside the tier.
function pairsEditor(label) {
const pairs = st.taskPairs[label];
const wrap = h("div", { class: "pairs grow" });
function paint() {
mount(
wrap,
...pairs.map((p, i) => {
const modelSel = h(
"select",
{ class: "pair-model" },
...ids.map((id) => h("option", { value: id }, id))
);
modelSel.value = p.model;
modelSel.addEventListener("change", () => { p.model = modelSel.value; });
const tierSel = h(
"select",
{ class: "pair-tier", title: t("routing.tasks.tierHint") },
h("option", { value: "any" }, t("routing.tasks.tierAny")),
...Object.keys(st.tiers).map((tr) => h("option", { value: tr }, trTier(tr)))
);
tierSel.value = p.tier;
tierSel.addEventListener("change", () => { p.tier = tierSel.value; });
const del = h("button", { class: "btn sm ghost danger", onClick: () => { pairs.splice(i, 1); paint(); } }, "×");
return h("div", { class: "pair-row" }, modelSel, tierSel, del);
}),
pairs.length === 0 ? h("div", { class: "muted small", style: "align-self:center" }, t("routing.tasks.inherit")) : null,
h("button", { class: "btn ghost sm", onClick: () => { pairs.push({ model: ids[0] || "", tier: "any" }); paint(); } }, "+ " + t("routing.tasks.addModel"))
);
}
paint();
return wrap;
}
function tasksCard() {
const addIn = h("input", { placeholder: t("routing.tasks.addPlaceholder"), style: "max-width:220px" });
const addBtn = h("button", { class: "btn ghost sm" }, "+ " + t("routing.tasks.addCustom"));
addBtn.addEventListener("click", () => {
const v = addIn.value.trim().toLowerCase();
if (!v || st.tasks[v]) return;
st.tasks[v] = [];
if (!v || st.taskPairs[v]) return;
st.taskPairs[v] = [];
render();
});
return h(
@@ -151,7 +286,7 @@ export async function renderRouting() {
h("span", { class: "card-head sub" },
labelsSource === "router" ? t("routing.tasks.fromRouter") : t("routing.tasks.builtin"))),
h("div", { class: "muted small", style: "margin-bottom:10px" }, t("routing.tasks.hint")),
...Object.keys(st.tasks).map(taskRow),
...Object.keys(st.taskPairs).map(taskRow),
h("div", { class: "flex", style: "gap:8px; margin-top:10px" }, addIn, addBtn)
);
}
@@ -201,8 +336,15 @@ export async function renderRouting() {
saveBtn.disabled = true;
try {
const tasks = {};
for (const [k, v] of Object.entries(st.tasks)) if (v.length) tasks[k] = v;
await api.putRouting({ tiers: st.tiers, tasks, default: st.default, policy: st.policy });
for (const [k, pairs] of Object.entries(st.taskPairs)) {
const rule = {};
for (const p of pairs) {
if (!p.model) continue;
(rule[p.tier] = rule[p.tier] || []).push(p.model);
}
if (Object.keys(rule).length) tasks[k] = rule;
}
await api.putRouting({ tiers: st.tiers, tasks, bands: st.bands, default: st.default, policy: st.policy });
toast(t("toast.saved"), "good");
} catch (e) {
toast(String(e.message || e), "bad");
@@ -227,6 +369,7 @@ export async function renderRouting() {
"div",
{ class: "card" },
h("div", { class: "card-head" }, h("h3", {}, t("routing.tiers")), h("span", { class: "card-head sub" }, t("routing.tierHint"))),
bandsCard(),
...Object.keys(st.tiers).map(tierRow)
),
tasksCard(),
+51 -2
View File
@@ -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
)
)
);