Compare commits

..
11 Commits
Author SHA1 Message Date
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
30 changed files with 1396 additions and 78 deletions
+42
View File
@@ -3,6 +3,48 @@
All notable changes to this project are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/).
## [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.39"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cortiq-gateway"
version = "0.2.38"
version = "0.2.39"
edition = "2021"
rust-version = "1.75"
license = "Apache-2.0"
+1 -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
+7 -1
View File
@@ -177,7 +177,13 @@ 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.
**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
+7 -1
View File
@@ -175,7 +175,13 @@ code = ["claude-sonnet", "gpt"] # код — сильнейшему ко
Модели подходящего правила идут **первыми** в цепочке кандидатов; цепочка по
сложности следует за ними как резерв — правило по задаче не ослабляет failover.
Задачи без правила маршрутизируются только по сложности. Список меток приходит
Задачи без правила маршрутизируются только по сложности.
**Исключение — высокая сложность эскалирует.** Когда роутер оценил запрос как
`high` (и цепочка `high` настроена), цепочка уровня идёт первой, а правило
задачи становится резервом. Иначе правило вида `code → дешёвая-модель`
приковало бы к дешёвой модели даже самые сложные запросы, и система уровней
не имела бы права голоса. Для low/medium правило задачи ведёт, как и раньше. Список меток приходит
с роутера (`GET /v1/taxonomies`) — вкладка Роутинг строит редактор по живой
таксономии аккаунта и показывает долю каждой задачи в трафике. Когда цепочку
возглавило правило задачи, ответ сообщает `route_source: "router+task-rule"`.
+95 -1
View File
@@ -160,6 +160,7 @@ 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/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,7 +186,7 @@ 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/bench", get(bench_list).post(bench_start))
@@ -599,8 +600,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
@@ -989,6 +1000,84 @@ async fn put_routing(State(state): State<SharedState>, Json(body): Json<RoutingB
ok(json!({ "ok": true }))
}
// ──────────────────────────── 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 {
@@ -2113,6 +2202,11 @@ 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();
+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());
}
}
+7 -3
View File
@@ -305,7 +305,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
@@ -736,7 +738,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()),
@@ -755,7 +757,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,
},
+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() {
+24 -17
View File
@@ -798,28 +798,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;
}
+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()),
})
}
+43 -13
View File
@@ -43,20 +43,35 @@ impl RoutingTable {
/// Returns `(candidates, task_rule_matched)`.
pub fn candidates_for(&self, task: &str, tier: &str) -> (Vec<String>, bool) {
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 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);
// Task rules steer everyday traffic — but a high-complexity verdict
// must escalate ABOVE them, or a "code → cheap-model" rule would pin
// the hardest prompts to the small model forever. At tier=high the
// tier chain leads and the task rule becomes the fallback; otherwise
// the task rule leads as before.
let escalate = tier == "high" && self.tiers.contains_key("high");
if escalate {
out.extend(tier_chain.iter().cloned());
}
let matched = if let Some(rule) = self.tasks.get(task) {
for id in rule {
if !out.contains(id) {
out.push(id.clone());
}
}
true
} else {
false
};
if !escalate {
for id in tier_chain {
if !out.contains(&id) {
out.push(id);
}
}
}
out.retain(|id| !id.is_empty());
@@ -104,14 +119,29 @@ 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");
assert!(m);
assert_eq!(c, vec!["big", "cheap-coder"]);
}
#[test]
fn no_rule_falls_back_to_tier_then_default() {
let t = table(&[], &[("low", &["a"])], "d");
+15
View File
@@ -585,6 +585,21 @@ 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); }
/* 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; }
+5 -1
View File
@@ -73,6 +73,7 @@ export const api = {
// protocols
getProtocols: () => j("GET", "/protocols"),
harnesses: () => j("GET", "/harnesses"),
putProtocols: (b) => j("PUT", "/protocols", b),
// settings
@@ -130,6 +131,7 @@ 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"),
benchStart: (model) => j("POST", "/bench", { model }),
benchList: () => j("GET", "/bench"),
@@ -190,6 +192,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 +210,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 +228,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");
+58
View File
@@ -563,4 +563,62 @@ 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",
};
+58
View File
@@ -556,4 +556,62 @@ 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",
};
+58
View File
@@ -563,4 +563,62 @@ 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",
};
+58
View File
@@ -563,4 +563,62 @@ 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",
};
+58
View File
@@ -555,4 +555,62 @@ 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": "Перезагрузить",
};
+58
View File
@@ -563,4 +563,62 @@ 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",
};
+58
View File
@@ -563,4 +563,62 @@ 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": "重新加载",
};
+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`;
+490
View File
@@ -0,0 +1,490 @@
// 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 } 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.`,
];
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.`,
];
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?
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.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 }, r.tier) : "—"),
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;
}
+2 -2
View File
@@ -1,5 +1,5 @@
// Dashboard — KPIs, time-series charts, breakdown, recent requests, health.
import { h, mount, money, num, ms, pct, timeAgo, toast, confirmDialog } from "../ui.js";
import { h, mount, money, moneyEl, num, ms, pct, timeAgo, toast, confirmDialog } from "../ui.js";
import { t } from "../i18n.js";
import { api } from "../api.js";
import { lineChart, barList } from "../charts.js";
@@ -161,7 +161,7 @@ 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")),
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"))
+110 -17
View File
@@ -4,27 +4,17 @@ 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");
function openCreate(reload) {
const accountIn = h("input", { placeholder: "team-a" });
const rateIn = h("input", { type: "number", value: 600 });
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.custom")), customIn, h("div", { class: "hint" }, t("keys.form.customHint")))
);
modal(t("keys.form.title"), body, async () => {
@@ -33,7 +23,7 @@ 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),
allow_models: [],
});
reload();
showCreated(r.key);
@@ -58,9 +48,64 @@ 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 origin = location.origin;
const base = origin + "/v1";
const key = lastCreatedKey || (openMode ? "sk-anything" : "<" + t("keys.snip.keyPh") + ">");
// Harness auto-setup scripts first (the gateway speaks OpenAI at /v1/* and
// Anthropic at /v1/messages, so Claude Code connects natively), SDKs after.
const snips = {
"Claude Code": `# ${t("keys.h.claude")}
export ANTHROPIC_BASE_URL="${origin}"
export ANTHROPIC_AUTH_TOKEN="${key}"
export ANTHROPIC_MODEL="cortiq-auto"
claude`,
Codex: `# ${t("keys.h.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: `# ${t("keys.h.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: `# ${t("keys.h.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 +127,65 @@ const resp = await client.chat.completions.create({
});
console.log(resp.choices[0].message.content);`,
};
let cur = "curl";
let cur = "Claude Code";
const pre = h("pre", { class: "conv-log snippet" }, snips[cur]);
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];
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").
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 mount(statusHost, h("span", { class: "muted" }, "○ " + t("keys.h.st.notFound")));
}
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(() => {});
const copy = h("button", { class: "btn ghost sm" }, "⧉ " + t("common.copy"));
copy.addEventListener("click", async () => {
await navigator.clipboard.writeText(pre.textContent);
@@ -107,8 +197,11 @@ 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" }), copy),
pre,
statusHost,
protoWarn,
h("div", { class: "muted small", style: "margin-top:6px" }, t("keys.h.note"))
);
}
+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
)
);
+2 -2
View File
@@ -4,7 +4,7 @@
// 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 { h, mount, toast, money, moneyEl, ms } from "../ui.js";
import { t } from "../i18n.js";
import { api, testStream, mediaFileUrl, mediaFileBytes } from "../api.js";
import { appState } from "../app.js";
@@ -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")
);