From 95e560dc720b071f7cca6e1575165f73d491ae3f Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 8 Aug 2026 08:42:11 +0300 Subject: [PATCH] feat: per-key spend accounting and per-task complexity pinning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API keys page: the table now answers "which department spends what" — per-key requests, tokens in/out and cost for a selectable window (24h/7d/30d) plus a cumulative all-time cost column, joined from stats by the key's account. The stats log stays bounded while the accounting survives: once stats.jsonl exceeds max_file_mb (default 16, 0 disables), records older than the retention window fold into cumulative per-account totals in a small sidecar file (tmp+rename on both) and leave the log. alltime = folded + in-file, a record is never counted twice, verified end-to-end on synthetic data (1.2 MB → 301 KB, totals identical before/after compaction and restarts). Routing: a task rule can now PIN the complexity tier — `[routing.tasks.legal] models=[...] tier="high"` (old plain-array rules parse unchanged). The pinned tier replaces the router's verdict before the escalation check and reports itself as route_source "router+task-rule@high". The Routing page grows a per-task «Complexity» selector (Auto/low/medium/ high); verified live: a code prompt with a pinned high went router+task-rule@high → the expensive model. 13 new strings in all 7 languages; docs/ROUTING(.ru) updated. --- config/gateway.example.toml | 4 + docs/ROUTING.md | 14 ++++ docs/ROUTING.ru.md | 14 ++++ src/admin/mod.rs | 23 ++---- src/config.rs | 56 +++++++++++-- src/pipeline.rs | 19 +++-- src/routing.rs | 95 +++++++++++++++++----- src/stats.rs | 155 +++++++++++++++++++++++++++++++++++- web/css/styles.css | 3 + web/js/i18n/de.js | 6 ++ web/js/i18n/en.js | 6 ++ web/js/i18n/es.js | 6 ++ web/js/i18n/fr.js | 6 ++ web/js/i18n/ru.js | 6 ++ web/js/i18n/tr.js | 6 ++ web/js/i18n/zh.js | 6 ++ web/js/views/keys.js | 30 ++++++- web/js/views/routing.js | 36 ++++++++- 18 files changed, 441 insertions(+), 50 deletions(-) diff --git a/config/gateway.example.toml b/config/gateway.example.toml index b67e9a3..127d0de 100644 --- a/config/gateway.example.toml +++ b/config/gateway.example.toml @@ -119,6 +119,10 @@ enabled = true file = "config/stats.jsonl" # empty = in-memory only ring_size = 500 # how many recent requests to keep in the feed retention = "7d" # aggregate/series depth (replayed on startup) +# stats.jsonl is compacted once it exceeds this size: old records fold into +# cumulative per-account totals (sidecar file), so per-key accounting survives +# while the disk stays bounded. 0 disables. +max_file_mb = 16 # ============================================================================ # Semantic cache — returns a cached answer for prompts semantically near a diff --git a/docs/ROUTING.md b/docs/ROUTING.md index 22aa2a4..b1940ff 100644 --- a/docs/ROUTING.md +++ b/docs/ROUTING.md @@ -179,6 +179,20 @@ 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. +**Pinning complexity per task.** A task rule also accepts a full form with a +forced tier — the router's verdict is replaced for that task before anything +else happens: + +```toml +[routing.tasks.legal] +models = ["strong-model"] +tier = "high" # legal review is always high, whatever the router scored +``` + +Responses whose tier was pinned report `route_source: "router+task-rule@high"`. +The admin console's Routing page exposes this as a per-task «Complexity» +selector (Auto / low / medium / high). + **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 diff --git a/docs/ROUTING.ru.md b/docs/ROUTING.ru.md index e71d138..6a47250 100644 --- a/docs/ROUTING.ru.md +++ b/docs/ROUTING.ru.md @@ -177,6 +177,20 @@ code = ["claude-sonnet", "gpt"] # код — сильнейшему ко сложности следует за ними как резерв — правило по задаче не ослабляет failover. Задачи без правила маршрутизируются только по сложности. +**Закрепление сложности за задачей.** Правило задачи принимает и полную форму +с фиксированным уровнем — вердикт роутера для этой задачи заменяется до всех +остальных шагов: + +```toml +[routing.tasks.legal] +models = ["strong-model"] +tier = "high" # юридические тексты — всегда high, что бы ни решил роутер +``` + +Ответы с закреплённым уровнем сообщают `route_source: "router+task-rule@high"`. +На вкладке Роутинг это селектор «Сложность» у каждой задачи (Авто / low / +medium / high). + **Исключение — высокая сложность эскалирует.** Когда роутер оценил запрос как `high` (и цепочка `high` настроена), цепочка уровня идёт первой, а правило задачи становится резервом. Иначе правило вида `code → дешёвая-модель` diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 3478586..0800e24 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -116,13 +116,7 @@ fn tiers_to_map(routing: &RoutingCfg) -> HashMap> { routing .tiers .iter() - .map(|(k, v)| { - let list = match v { - TierTargets::List(l) => l.clone(), - TierTargets::One(s) => vec![s.clone()], - }; - (k.clone(), list) - }) + .map(|(k, v)| (k.clone(), v.models())) .collect() } @@ -758,6 +752,7 @@ fn strip_id_from_routing(routing: &mut RoutingCfg, id: &str) { } for tt in routing.tiers.values_mut().chain(routing.tasks.values_mut()) { match tt { + crate::config::TierTargets::Rule { models, .. } => models.retain(|x| x != id), crate::config::TierTargets::List(v) => v.retain(|x| x != id), crate::config::TierTargets::One(s) if s == id => { *tt = crate::config::TierTargets::List(Vec::new()); @@ -767,6 +762,7 @@ fn strip_id_from_routing(routing: &mut RoutingCfg, id: &str) { } // an emptied task rule means "no rule", not "no candidates" routing.tasks.retain(|_, tt| match tt { + crate::config::TierTargets::Rule { models, tier } => !models.is_empty() || tier.is_some(), crate::config::TierTargets::List(v) => !v.is_empty(), crate::config::TierTargets::One(s) => !s.is_empty(), }); @@ -910,7 +906,7 @@ async fn probe_model(State(state): State, Path(id): Path) - async fn get_routing(State(state): State) -> ApiResult { let live = state.live(); - let tasks: HashMap> = live + let tasks: HashMap = live .cfg .routing .tasks @@ -918,10 +914,7 @@ async fn get_routing(State(state): State) -> ApiResult { .map(|(k, v)| { ( k.clone(), - match v { - TierTargets::List(l) => l.clone(), - TierTargets::One(s) => vec![s.clone()], - }, + json!({ "models": v.models(), "tier": v.forced_tier() }), ) }) .collect(); @@ -973,7 +966,7 @@ struct RoutingBody { #[serde(default)] tiers: HashMap>, #[serde(default)] - tasks: HashMap>, + tasks: HashMap, default: String, #[serde(default)] policy: RoutingPolicy, @@ -990,8 +983,8 @@ async fn put_routing(State(state): State, Json(body): Json, + #[serde(default, skip_serializing_if = "Option::is_none")] + tier: Option, + }, List(Vec), One(String), } +impl TierTargets { + /// The ordered model list, whatever the syntactic form. + pub fn models(&self) -> Vec { + match self { + TierTargets::Rule { models, .. } => models.clone(), + TierTargets::List(v) => v.clone(), + TierTargets::One(s) => vec![s.clone()], + } + } + /// Forced complexity tier (task rules only). + pub fn forced_tier(&self) -> Option<&str> { + match self { + TierTargets::Rule { tier, .. } => tier.as_deref(), + _ => None, + } + } +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct RoutingPolicy { #[serde(default = "default_mode")] @@ -587,6 +616,12 @@ pub struct StatsCfg { /// Retention window for aggregates/time-series replayed on startup, e.g. `7d`. #[serde(default = "default_stats_retention")] pub retention: String, + /// Compact the JSONL file once it exceeds this size: records older than + /// `retention` fold into cumulative per-account totals (a small sidecar + /// file) and are removed, so per-key accounting survives while the disk + /// stays bounded. 0 disables compaction. + #[serde(default = "default_stats_max_file_mb")] + pub max_file_mb: u64, } impl Default for StatsCfg { fn default() -> Self { @@ -595,6 +630,7 @@ impl Default for StatsCfg { file: default_stats_file(), ring_size: default_stats_ring(), retention: default_stats_retention(), + max_file_mb: default_stats_max_file_mb(), } } } @@ -610,6 +646,9 @@ fn default_stats_ring() -> usize { fn default_stats_retention() -> String { "7d".into() } +fn default_stats_max_file_mb() -> u64 { + 16 +} /// Self-warming shadow loop (docs/SELF_WARMING_GATEWAY.md). While a task /// label is not yet promoted, the client is served the CLOUD answer; in @@ -863,6 +902,7 @@ impl Config { } for (tier, targets) in &self.routing.tiers { let list = match targets { + TierTargets::Rule { models, .. } => models.clone(), TierTargets::List(v) => v.clone(), TierTargets::One(s) => vec![s.clone()], }; @@ -877,11 +917,7 @@ impl Config { } } for (task, targets) in &self.routing.tasks { - let list = match targets { - TierTargets::List(v) => v.clone(), - TierTargets::One(s) => vec![s.clone()], - }; - for id in &list { + for id in &targets.models() { if !ids.contains(id.as_str()) { anyhow::bail!( "config: routing.tasks.{} references unknown model id '{}'", @@ -890,6 +926,15 @@ impl Config { ); } } + if let Some(tier) = targets.forced_tier() { + if !self.routing.tiers.contains_key(tier) { + anyhow::bail!( + "config: routing.tasks.{}.tier '{}' is not a defined routing tier", + task, + tier + ); + } + } } Ok(()) } @@ -981,6 +1026,7 @@ min_class = { low = "cheap", medium = "mid" } let low = back.routing.tiers.get("low").expect("low tier present"); match low { + TierTargets::Rule { models, .. } => assert_eq!(models, &vec!["local-qwen".to_string()]), TierTargets::List(v) => assert_eq!(v, &vec!["local-qwen".to_string()]), TierTargets::One(s) => assert_eq!(s, "local-qwen"), } diff --git a/src/pipeline.rs b/src/pipeline.rs index 4819c5f..02c324b 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -232,15 +232,22 @@ impl Pipeline { match live.router.route(&text, prof).await { Ok(Some(dec)) => { let src = dec.source.clone(); - let (cands, task_rule) = live + let mut dec = dec; + let (cands, task_rule, forced) = live .routing .candidates_for(&dec.task_label, &dec.complexity_tier); - // surface which rule chose the head of the chain - let src = if task_rule { - format!("{src}+task-rule") - } else { - src + // surface which rule chose the head of the chain, + // and when the rule pinned the complexity — + // "…+task-rule@high" — reflect it in the + // decision so headers/stats tell the truth + let src = match (task_rule, &forced) { + (true, Some(tier)) => format!("{src}+task-rule@{tier}"), + (true, None) => format!("{src}+task-rule"), + _ => src, }; + if let Some(tier) = forced { + dec.complexity_tier = tier; + } (dec, src, cands) } Ok(None) | Err(_) => { diff --git a/src/routing.rs b/src/routing.rs index a3fdefa..9d8e38b 100644 --- a/src/routing.rs +++ b/src/routing.rs @@ -4,16 +4,21 @@ use crate::config::{Config, TierTargets}; fn to_list(t: &TierTargets) -> Vec { - match t { - TierTargets::List(v) => v.clone(), - TierTargets::One(s) => vec![s.clone()], - } + t.models() +} + +/// A task rule: models that lead the chain, plus an optional FORCED tier — +/// "this task is always this complexity", overriding the router's verdict. +#[derive(Clone)] +pub struct TaskRule { + pub models: Vec, + pub tier: Option, } pub struct RoutingTable { tiers: std::collections::HashMap>, /// Per-task rules — a matching rule's models lead the candidate chain. - tasks: std::collections::HashMap>, + tasks: std::collections::HashMap, default: String, } @@ -30,8 +35,16 @@ impl RoutingTable { .routing .tasks .iter() - .map(|(task, targets)| (task.clone(), to_list(targets))) - .filter(|(_, v)| !v.is_empty()) + .map(|(task, targets)| { + ( + task.clone(), + TaskRule { + models: targets.models(), + tier: targets.forced_tier().map(String::from), + }, + ) + }) + .filter(|(_, r)| !r.models.is_empty() || r.tier.is_some()) .collect(), default: cfg.routing.default.clone(), } @@ -41,11 +54,18 @@ impl RoutingTable { /// the task rule's models first (when one exists), then the tier chain — /// so a task pin adds preference without ever weakening failover. /// Returns `(candidates, task_rule_matched)`. - pub fn candidates_for(&self, task: &str, tier: &str) -> (Vec, bool) { + /// Returns `(candidates, rule_matched, forced_tier)`. A rule with a + /// forced tier replaces the router's verdict for this task BEFORE the + /// escalation check — "legal is always high" both escalates and reports + /// itself in route_source. + pub fn candidates_for(&self, task: &str, tier: &str) -> (Vec, bool, Option) { let mut out: Vec = Vec::new(); + let rule = self.tasks.get(task); + let forced = rule.and_then(|r| r.tier.clone()); + let eff_tier = forced.as_deref().unwrap_or(tier); let tier_chain = self .tiers - .get(tier) + .get(eff_tier) .cloned() .unwrap_or_else(|| vec![self.default.clone()]); // Task rules steer everyday traffic — but a high-complexity verdict @@ -53,12 +73,12 @@ impl RoutingTable { // 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"); + let escalate = eff_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 { + let matched = if let Some(rule) = rule { + for id in &rule.models { if !out.contains(id) { out.push(id.clone()); } @@ -75,7 +95,7 @@ impl RoutingTable { } } out.retain(|id| !id.is_empty()); - (out, matched) + (out, matched, forced) } /// Candidates for the given complexity tier, in preference order. @@ -106,12 +126,31 @@ mod tests { .collect(), tasks: tasks .iter() - .map(|(k, v)| (k.to_string(), v.iter().map(|s| s.to_string()).collect())) + .map(|(k, v)| { + ( + k.to_string(), + TaskRule { + models: v.iter().map(|s| s.to_string()).collect(), + tier: None, + }, + ) + }) .collect(), default: default.to_string(), } } + fn with_task_tier(mut t: RoutingTable, task: &str, tier: &str) -> RoutingTable { + t.tasks + .entry(task.to_string()) + .or_insert(TaskRule { + models: Vec::new(), + tier: None, + }) + .tier = Some(tier.to_string()); + t + } + #[test] fn task_rule_leads_tier_chain_follows() { let t = table( @@ -120,7 +159,7 @@ mod tests { "cheap", ); // dedup: rule model already in the tier chain appears once, first - let (c, matched) = t.candidates_for("translation", "low"); + let (c, matched, _) = t.candidates_for("translation", "low"); assert!(matched); assert_eq!(c, vec!["local", "cheap"]); } @@ -133,20 +172,40 @@ mod tests { "cheap", ); // medium (no chain defined → default): task rule still leads - let (c, m) = t.candidates_for("code", "medium"); + 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"); + let (c, m, _) = t.candidates_for("code", "high"); assert!(m); assert_eq!(c, vec!["big", "cheap-coder"]); } + #[test] + fn forced_tier_overrides_router_verdict() { + let base = table( + &[("legal", &["cheap-para"])], + &[("low", &["cheap"]), ("high", &["big"])], + "cheap", + ); + let t = with_task_tier(base, "legal", "high"); + // router said low — the rule forces high: escalation chain leads, + // rule models follow, and the forced tier is reported + let (c, m, forced) = t.candidates_for("legal", "low"); + assert!(m); + assert_eq!(forced.as_deref(), Some("high")); + assert_eq!(c, vec!["big", "cheap-para"]); + // a task without a rule is untouched + let (_, m, forced) = t.candidates_for("qa", "low"); + assert!(!m); + assert!(forced.is_none()); + } + #[test] fn no_rule_falls_back_to_tier_then_default() { let t = table(&[], &[("low", &["a"])], "d"); assert_eq!(t.candidates_for("qa", "low").0, vec!["a"]); - let (c, matched) = t.candidates_for("qa", "unknown-tier"); + let (c, matched, _) = t.candidates_for("qa", "unknown-tier"); assert!(!matched); assert_eq!(c, vec!["d"]); } diff --git a/src/stats.rs b/src/stats.rs index 615f533..fc14106 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -55,7 +55,7 @@ pub struct RequestRecord { pub error: Option, } -#[derive(Clone, Debug, Default, Serialize)] +#[derive(Clone, Debug, Default, Serialize, serde::Deserialize)] pub struct Totals { pub requests: u64, pub ok: u64, @@ -130,6 +130,15 @@ struct Inner { /// Per-minute buckets — the single source for the windowed admin snapshot. buckets: VecDeque, recent: VecDeque, + /// Per-account totals over EVERY record still in the JSONL file (no + /// retention gate) — per-key accounting for the keys page. + by_account_alltime: HashMap, + /// Per-account totals folded out of the file by compaction. alltime(key) + /// = folded + by_account_alltime, with no double counting: a record is + /// either in the file or folded, never both. + folded: HashMap, + /// Appends since the last compaction size check. + writes_since_check: u32, } pub struct Stats { @@ -137,6 +146,7 @@ pub struct Stats { file: Option, ring_size: usize, retention_secs: u64, + max_file_bytes: u64, inner: Mutex, } @@ -152,9 +162,12 @@ impl Stats { file, ring_size: cfg.ring_size.max(1), retention_secs: parse_duration_secs(&cfg.retention).unwrap_or(7 * 86_400), + max_file_bytes: cfg.max_file_mb.saturating_mul(1024 * 1024), inner: Mutex::new(Inner::default()), }; + s.load_folded(); s.replay(); + s.maybe_compact(true); std::sync::Arc::new(s) } @@ -175,6 +188,11 @@ impl Stats { // push everything to the "recent" ring (it self-limits by size), // but apply to aggregates/series only entries within the window. Self::push_recent(&mut inner, &rec, self.ring_size); + inner + .by_account_alltime + .entry(rec.account.clone()) + .or_default() + .apply(&rec); if rec.ts >= cutoff { Self::apply_aggregates(&mut inner, &rec, self.retention_secs); } @@ -233,6 +251,12 @@ impl Stats { let mut inner = self.inner.lock().unwrap(); Self::apply_aggregates(&mut inner, &rec, self.retention_secs); Self::push_recent(&mut inner, &rec, self.ring_size); + inner + .by_account_alltime + .entry(rec.account.clone()) + .or_default() + .apply(&rec); + inner.writes_since_check += 1; } if let Some(path) = &self.file { if let Ok(line) = serde_json::to_string(&rec) { @@ -245,6 +269,112 @@ impl Stats { } } } + self.maybe_compact(false); + } + + fn totals_path(&self) -> Option { + self.file.as_ref().map(|f| format!("{f}.totals.json")) + } + + fn load_folded(&self) { + let Some(path) = self.totals_path() else { + return; + }; + let Ok(content) = std::fs::read_to_string(&path) else { + return; + }; + if let Ok(m) = serde_json::from_str::>(&content) { + self.inner.lock().unwrap().folded = m; + } + } + + /// Keep the JSONL bounded: once it outgrows `max_file_bytes`, records + /// older than the retention window fold into the cumulative per-account + /// sidecar and are dropped from the file (tmp + rename on both files, so + /// a crash mid-compaction loses nothing). `force` checks size regardless + /// of the write counter (startup). + fn maybe_compact(&self, force: bool) { + if self.max_file_bytes == 0 { + return; + } + let Some(path) = &self.file else { return }; + { + let mut inner = self.inner.lock().unwrap(); + if !force { + if inner.writes_since_check < 256 { + return; + } + inner.writes_since_check = 0; + } + } + let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); + if size <= self.max_file_bytes { + return; + } + let Ok(content) = std::fs::read_to_string(path) else { + return; + }; + let cutoff = now_secs().saturating_sub(self.retention_secs); + let mut kept = String::with_capacity(content.len() / 2); + let mut inner = self.inner.lock().unwrap(); + for line in content.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + match serde_json::from_str::(line) { + Ok(rec) if rec.ts < cutoff => { + inner + .folded + .entry(rec.account.clone()) + .or_default() + .apply(&rec); + // fold = leave by_account_alltime (record leaves the file, + // its share moves from "alltime" into "folded") + if let Some(t) = inner.by_account_alltime.get_mut(&rec.account) { + // subtract by rebuilding is costly; instead track the + // invariant additively: remove the record's share + t.requests = t.requests.saturating_sub(1); + if rec.outcome == "ok" { + t.ok = t.ok.saturating_sub(1); + } else { + t.errors = t.errors.saturating_sub(1); + } + if rec.failover { + t.failovers = t.failovers.saturating_sub(1); + } + t.prompt_tokens = t.prompt_tokens.saturating_sub(rec.prompt_tokens as u64); + t.completion_tokens = t + .completion_tokens + .saturating_sub(rec.completion_tokens as u64); + t.cost_usd = (t.cost_usd - rec.cost_usd).max(0.0); + t.latency_ms_sum = t.latency_ms_sum.saturating_sub(rec.latency_ms); + } + } + Ok(_) => { + kept.push_str(line); + kept.push('\n'); + } + Err(_) => { /* unparseable line — drop */ } + } + } + // persist the folded totals FIRST, then swap the trimmed file in + if let Some(tpath) = self.totals_path() { + if let Ok(json) = serde_json::to_string(&inner.folded) { + let tmp = format!("{tpath}.tmp"); + if std::fs::write(&tmp, json).is_ok() { + let _ = std::fs::rename(&tmp, &tpath); + } + } + } + let tmp = format!("{path}.tmp"); + if std::fs::write(&tmp, kept).is_ok() { + let _ = std::fs::rename(&tmp, path); + } + tracing::info!( + new_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0), + "stats log compacted (older records folded into cumulative totals)" + ); } /// Snapshot for the admin API: aggregates + time series for `range_secs` + breakdown by `groupby`. @@ -303,7 +433,27 @@ impl Stats { .cmp(&a["requests"].as_u64().unwrap_or(0)) }); + let mut alltime: HashMap = inner.folded.clone(); + for (k, t) in &inner.by_account_alltime { + alltime.entry(k.clone()).or_default().merge(t); + } + let alltime_by_account: serde_json::Map = alltime + .iter() + .map(|(k, t)| { + ( + k.clone(), + serde_json::json!({ + "requests": t.requests, + "prompt_tokens": t.prompt_tokens, + "completion_tokens": t.completion_tokens, + "cost_usd": t.cost_usd, + }), + ) + }) + .collect(); + serde_json::json!({ + "alltime_by_account": alltime_by_account, "totals": { "requests": win.requests, "ok": win.ok, @@ -327,6 +477,9 @@ impl Stats { /// Reset all in-memory stats and truncate the JSONL log — the "clear logs" /// admin action. pub fn clear(&self) { + if let Some(tpath) = self.totals_path() { + let _ = std::fs::remove_file(tpath); + } { let mut inner = self.inner.lock().unwrap(); *inner = Inner::default(); diff --git a/web/css/styles.css b/web/css/styles.css index 3eeed80..d8641ea 100644 --- a/web/css/styles.css +++ b/web/css/styles.css @@ -592,6 +592,9 @@ body.modal-open { overflow: hidden; } .ok { color: var(--good); } +.task-tier-wrap { display: flex; align-items: center; gap: 6px; margin-left: 10px; white-space: nowrap; } +.task-tier { max-width: 130px; padding: 4px 8px; font-size: 12px; } + /* 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; } diff --git a/web/js/i18n/de.js b/web/js/i18n/de.js index ade3bec..afa2646 100644 --- a/web/js/i18n/de.js +++ b/web/js/i18n/de.js @@ -621,4 +621,10 @@ export default { "bm.cutByCap": "Budget ging fürs Denken drauf — keine Antwort mehr", "shell.uiStale": "Das Gateway wurde aktualisiert — diese Seite zeigt eine ältere Oberfläche.", "shell.reload": "Neu laden", + "keys.col.requests": "Anfragen", + "keys.col.tokens": "Tokens ein/aus", + "keys.col.costAll": "$ gesamt", + "routing.tasks.tier": "Komplexität", + "routing.tasks.tierAuto": "Auto (Router)", + "routing.tasks.tierHint": "Komplexität für diese Aufgabe festlegen: das Router-Urteil wird ersetzt, high eskaliert weiterhin über die Modellliste.", }; diff --git a/web/js/i18n/en.js b/web/js/i18n/en.js index 5efcfc8..9f60b4e 100644 --- a/web/js/i18n/en.js +++ b/web/js/i18n/en.js @@ -614,4 +614,10 @@ export default { "bm.cutByCap": "budget spent on reasoning — answer never came", "shell.uiStale": "The gateway was updated — this page is running an older interface.", "shell.reload": "Reload", + "keys.col.requests": "Requests", + "keys.col.tokens": "Tokens in/out", + "keys.col.costAll": "$ all-time", + "routing.tasks.tier": "Complexity", + "routing.tasks.tierAuto": "Auto (router)", + "routing.tasks.tierHint": "Pin the complexity tier for this task: the router's verdict is replaced, high still escalates over the model list.", }; diff --git a/web/js/i18n/es.js b/web/js/i18n/es.js index bc00ecb..64bfc80 100644 --- a/web/js/i18n/es.js +++ b/web/js/i18n/es.js @@ -621,4 +621,10 @@ export default { "bm.cutByCap": "presupuesto gastado en razonar — sin respuesta final", "shell.uiStale": "La pasarela se actualizó — esta página usa una interfaz antigua.", "shell.reload": "Recargar", + "keys.col.requests": "Peticiones", + "keys.col.tokens": "Tokens ent/sal", + "keys.col.costAll": "$ total", + "routing.tasks.tier": "Complejidad", + "routing.tasks.tierAuto": "Auto (router)", + "routing.tasks.tierHint": "Fijar la complejidad de esta tarea: el veredicto del router se sustituye, high sigue escalando por encima de la lista de modelos.", }; diff --git a/web/js/i18n/fr.js b/web/js/i18n/fr.js index 78c3fa5..c267937 100644 --- a/web/js/i18n/fr.js +++ b/web/js/i18n/fr.js @@ -621,4 +621,10 @@ export default { "bm.cutByCap": "budget épuisé en raisonnement — pas de réponse", "shell.uiStale": "La passerelle a été mise à jour — cette page utilise une ancienne interface.", "shell.reload": "Recharger", + "keys.col.requests": "Requêtes", + "keys.col.tokens": "Tokens in/out", + "keys.col.costAll": "$ au total", + "routing.tasks.tier": "Complexité", + "routing.tasks.tierAuto": "Auto (routeur)", + "routing.tasks.tierHint": "Fixer la complexité de cette tâche : le verdict du routeur est remplacé, high continue d'escalader au-dessus de la liste de modèles.", }; diff --git a/web/js/i18n/ru.js b/web/js/i18n/ru.js index b00bc11..822e780 100644 --- a/web/js/i18n/ru.js +++ b/web/js/i18n/ru.js @@ -613,4 +613,10 @@ export default { "bm.cutByCap": "бюджет ушёл на размышления — ответ не успел", "shell.uiStale": "Шлюз обновился — на этой странице старый интерфейс.", "shell.reload": "Перезагрузить", + "keys.col.requests": "Запросы", + "keys.col.tokens": "Токены вх/исх", + "keys.col.costAll": "$ за всё время", + "routing.tasks.tier": "Сложность", + "routing.tasks.tierAuto": "Авто (по роутеру)", + "routing.tasks.tierHint": "Закрепить уровень сложности для задачи: вердикт роутера заменяется, high по-прежнему эскалирует поверх списка моделей.", }; diff --git a/web/js/i18n/tr.js b/web/js/i18n/tr.js index a4154b8..4895d7c 100644 --- a/web/js/i18n/tr.js +++ b/web/js/i18n/tr.js @@ -621,4 +621,10 @@ export default { "bm.cutByCap": "bütçe düşünmeye gitti — yanıt gelemedi", "shell.uiStale": "Ağ geçidi güncellendi — bu sayfa eski arayüzü çalıştırıyor.", "shell.reload": "Yeniden yükle", + "keys.col.requests": "İstekler", + "keys.col.tokens": "Token giriş/çıkış", + "keys.col.costAll": "$ toplam", + "routing.tasks.tier": "Karmaşıklık", + "routing.tasks.tierAuto": "Otomatik (yönlendirici)", + "routing.tasks.tierHint": "Bu görev için karmaşıklık katmanını sabitle: yönlendiricinin kararı değiştirilir, high yine model listesinin üzerine tırmanır.", }; diff --git a/web/js/i18n/zh.js b/web/js/i18n/zh.js index 2aaf813..9bd7c8c 100644 --- a/web/js/i18n/zh.js +++ b/web/js/i18n/zh.js @@ -621,4 +621,10 @@ export default { "bm.cutByCap": "预算全用于思考——未能给出答案", "shell.uiStale": "网关已更新 — 此页面运行的是旧界面。", "shell.reload": "重新加载", + "keys.col.requests": "请求数", + "keys.col.tokens": "Token 入/出", + "keys.col.costAll": "$ 累计", + "routing.tasks.tier": "复杂度", + "routing.tasks.tierAuto": "自动(路由器)", + "routing.tasks.tierHint": "为该任务固定复杂度层级:替换路由器的判断,high 仍会越过模型列表进行升级。", }; diff --git a/web/js/views/keys.js b/web/js/views/keys.js index 5695e0b..0c63f31 100644 --- a/web/js/views/keys.js +++ b/web/js/views/keys.js @@ -1,10 +1,11 @@ // API keys — list, create (with copy-once), revoke. -import { h, mount, modal, toast, confirmDialog } from "../ui.js"; +import { h, mount, modal, toast, confirmDialog, moneyEl, num } from "../ui.js"; import { t } from "../i18n.js"; import { api } from "../api.js"; export async function renderKeys() { const root = h("div"); + let usageRange = "7d"; function openCreate(reload) { const accountIn = h("input", { placeholder: "team-a" }); @@ -208,6 +209,15 @@ console.log(resp.choices[0].message.content);`, async function reload() { const data = await api.listKeys(); const keys = data.keys || []; + // per-account usage: window breakdown + cumulative-forever totals — the + // "who spends what" view for departments/developers sharing one gateway + let usage = {}; + let alltime = {}; + try { + const st = await api.stats(usageRange, "account"); + for (const b of st.breakdown || []) usage[b.key] = b; + alltime = st.alltime_by_account || {}; + } catch { /* stats disabled — columns show dashes */ } mount( root, h( @@ -217,6 +227,9 @@ console.log(resp.choices[0].message.content);`, "div", { class: "flex" }, h("div", { class: "grow" }, h("h2", {}, t("keys.title")), h("p", {}, t("keys.subtitle"))), + h("div", { class: "seg", style: "margin-right:10px" }, + ...["24h", "7d", "30d"].map((r) => + h("button", { class: r === usageRange ? "active" : "", onClick: () => { usageRange = r; reload(); } }, r))), h("button", { class: "btn primary", onClick: () => openCreate(reload) }, "+ " + t("keys.add")) ) ), @@ -235,7 +248,16 @@ console.log(resp.choices[0].message.content);`, h( "thead", {}, - h("tr", {}, h("th", {}, t("keys.col.key")), h("th", {}, t("keys.col.account")), h("th", {}, t("keys.col.rate")), h("th", {}, t("keys.col.models")), h("th", { class: "right-align" }, t("common.actions"))) + h("tr", {}, + h("th", {}, t("keys.col.key")), + h("th", {}, t("keys.col.account")), + h("th", {}, t("keys.col.rate")), + h("th", {}, t("keys.col.models")), + h("th", { class: "right-align" }, t("keys.col.requests") + " (" + usageRange + ")"), + h("th", { class: "right-align" }, t("keys.col.tokens") + " (" + usageRange + ")"), + h("th", { class: "right-align" }, "$ (" + usageRange + ")"), + h("th", { class: "right-align" }, t("keys.col.costAll")), + h("th", { class: "right-align" }, t("common.actions"))) ), h( "tbody", @@ -248,6 +270,10 @@ console.log(resp.choices[0].message.content);`, h("td", {}, k.account), h("td", { class: "mono" }, k.rate_per_min || "—"), h("td", {}, (k.allow_models && k.allow_models.length) ? k.allow_models.join(", ") : h("span", { class: "muted" }, t("keys.allowAll"))), + h("td", { class: "right-align mono" }, String((usage[k.account] || {}).requests ?? "—")), + h("td", { class: "right-align mono small" }, usage[k.account] ? num(usage[k.account].prompt_tokens) + " / " + num(usage[k.account].completion_tokens) : "—"), + h("td", { class: "right-align" }, usage[k.account] ? moneyEl(usage[k.account].cost_usd) : "—"), + h("td", { class: "right-align" }, alltime[k.account] ? moneyEl(alltime[k.account].cost_usd) : "—"), h( "td", { class: "right-align" }, diff --git a/web/js/views/routing.js b/web/js/views/routing.js index 4de26ac..0fc2a69 100644 --- a/web/js/views/routing.js +++ b/web/js/views/routing.js @@ -54,10 +54,17 @@ export async function renderRouting() { // local editable state — ensure standard tiers exist for a friendly editor const tiers = Object.assign({ low: [], medium: [], high: [] }, routing.tiers || {}); const tasks = {}; - for (const l of taskLabels) tasks[l] = (routing.tasks || {})[l] || []; + const taskTiers = {}; + for (const l of taskLabels) { + const v = (routing.tasks || {})[l]; + // GET returns the full form {models, tier}; older configs may be arrays + tasks[l] = Array.isArray(v) ? v : (v && v.models) || []; + taskTiers[l] = (v && !Array.isArray(v) && v.tier) || ""; + } const st = { tiers, tasks, + taskTiers, default: routing.default || ids[0] || "", policy: routing.policy || { mode: "fixed_table", min_class: {} }, }; @@ -130,10 +137,29 @@ export async function renderRouting() { h("div", { class: "muted small mono" }, label), share != null ? h("div", { class: "muted small" }, t("routing.tasks.share", { p: Math.round(share * 100) })) : null ), - chipEditor(st.tasks, label, t("routing.tasks.inherit")) + chipEditor(st.tasks, label, t("routing.tasks.inherit")), + tierSelect(label) ); } + // Per-task complexity pin: "авто" follows the router's verdict; a fixed + // tier forces it for this task (legal is always high, chitchat always low) + function tierSelect(label) { + const sel = h( + "select", + { class: "task-tier", title: t("routing.tasks.tierHint") }, + h("option", { value: "" }, t("routing.tasks.tierAuto")), + ...Object.keys(st.tiers).map((tr) => + h("option", { value: tr, selected: st.taskTiers[label] === tr ? "" : null }, tr)) + ); + sel.value = st.taskTiers[label] || ""; + sel.addEventListener("change", () => { + st.taskTiers[label] = sel.value; + }); + return h("div", { class: "task-tier-wrap" }, + h("div", { class: "muted small" }, t("routing.tasks.tier")), sel); + } + function tasksCard() { const addIn = h("input", { placeholder: t("routing.tasks.addPlaceholder"), style: "max-width:220px" }); const addBtn = h("button", { class: "btn ghost sm" }, "+ " + t("routing.tasks.addCustom")); @@ -201,7 +227,11 @@ export async function renderRouting() { saveBtn.disabled = true; try { const tasks = {}; - for (const [k, v] of Object.entries(st.tasks)) if (v.length) tasks[k] = v; + for (const [k, v] of Object.entries(st.tasks)) { + const tier = (st.taskTiers || {})[k] || ""; + if (tier) tasks[k] = { models: v, tier }; + else if (v.length) tasks[k] = v; + } await api.putRouting({ tiers: st.tiers, tasks, default: st.default, policy: st.policy }); toast(t("toast.saved"), "good"); } catch (e) {