Compare commits

...
5 Commits
Author SHA1 Message Date
infosave2007 115b04f20e chore: release 0.2.40
Docker / publish (push) Canceled after 0s
Release / crates-io (push) Canceled after 0s
Release / release (macos-14, aarch64-apple-darwin) (push) Canceled after 0s
Release / release (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Canceled after 0s
Release / release (windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-08 09:13:12 +03:00
infosave2007 b24d4cdbab fix(routing): the task-rule badge only when the rule actually chose the head
route_source reported "+task-rule" whenever a rule existed for the task —
so a math prompt routed low by the ordinary tier chain still wore the badge
next to the local model, reading as "the rule picked this" when it hadn't
(the rule only defines math@high). matched now means the head of the chain
came from the rule's tier-specific or any-tier list; verified live: the same
low math request now reports route_source "router".
2026-08-08 09:02:25 +03:00
infosave2007 06813b3e97 feat(routing)!: task rules become per-complexity model maps
The single per-task «Complexity» pin was the wrong shape — what you actually
decide is WHICH model serves a task at WHICH complexity. A task rule is now a
per-tier map, edited in the console as rows of “model ▾ + complexity ▾” pairs
(plus ×/add) inside each task:

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

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

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

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

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

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

Two-part fix: pools doubled to 6 interdisciplinary families each (science:
+ quantum error correction, soil-carbon MRV, cryo-EM; ops: + ED triage +
staffing, port berth/tides/rail, agri-drone fleets — all probed high against
the live router), and a route-judged test that lands non-high gets ONE retry
with a freshly generated template before failing. A single burnt wording no
longer fails the assertion; a genuinely unreachable high tier still does.
2026-08-07 22:47:28 +03:00
21 changed files with 660 additions and 72 deletions
+24
View File
@@ -3,6 +3,30 @@
All notable changes to this project are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/).
## [0.2.40] - 2026-08-08
### Added
- **Per-key spend accounting** on the API-keys page: requests, tokens in/out and cost per key
for a selectable window (24h / 7d / 30d) plus a cumulative all-time cost column — "which
department spends what" at a glance.
- **Bounded stats on disk**: once `stats.jsonl` outgrows `stats.max_file_mb` (default 16, 0
disables), records older than the retention window fold into cumulative per-account totals in
a small sidecar file (tmp+rename on both) and leave the log. All-time accounting survives
compaction and restarts; nothing is ever counted twice.
- **Per-complexity models inside a task rule**: each task is edited as rows of
"model + complexity" pairs — `[routing.tasks.code] low=["cheap"] high=["best"] any=[...]`.
The tier-specific pick leads its own tier, `any` (and legacy plain arrays) leads everywhere,
the global tier chain and `default` always remain as fallback, and high verdicts still
escalate over any-tier pins.
### Changed
- Benchmark hard probes are generated from wider interdisciplinary pools (6 families each) and
retry once with a fresh template — the router's novelty decay no longer flips them red.
### Fixed
- The `task-rule` badge is honest: it appears only when the rule actually chose the head of the
candidate chain, not whenever a rule merely exists for the task.
## [0.2.39] - 2026-08-07
### Added
Generated
+1 -1
View File
@@ -285,7 +285,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "cortiq-gateway"
version = "0.2.39"
version = "0.2.40"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cortiq-gateway"
version = "0.2.39"
version = "0.2.40"
edition = "2021"
rust-version = "1.75"
license = "Apache-2.0"
+4
View File
@@ -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
+15
View File
@@ -179,6 +179,21 @@ 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.
**Per-complexity models for a task.** A task rule also accepts a per-tier
map — "which model serves this task at each complexity":
```toml
[routing.tasks.code]
low = ["cheap-coder"] # routine code stays cheap
high = ["best-coder"] # hard code goes to the strongest coder
any = ["generic-coder"] # leads at every tier after the tier-specific pick
```
The tier-specific list leads its own tier; `any` (and the legacy plain-array
form) leads at every tier; the global tier chain and `default` always remain
as fallback, so a task rule never weakens failover. The admin console's
Routing page edits this as rows of “model + complexity” pairs per task.
**Exception — high complexity escalates.** When the router rates a request
`high` (and a `high` chain is configured), the tier chain leads and the task
rule demotes to fallback. Otherwise a rule like `code → cheap-model` would pin
+15
View File
@@ -177,6 +177,21 @@ code = ["claude-sonnet", "gpt"] # код — сильнейшему ко
сложности следует за ними как резерв — правило по задаче не ослабляет failover.
Задачи без правила маршрутизируются только по сложности.
**Модели по сложности внутри задачи.** Правило задачи принимает и карту по
уровням — «какая модель обслуживает задачу на какой сложности»:
```toml
[routing.tasks.code]
low = ["cheap-coder"] # рутинный код — дёшево
high = ["best-coder"] # сложный код — сильнейшему кодеру
any = ["generic-coder"] # ведёт на всех уровнях после уровневого выбора
```
Список конкретного уровня ведёт на своём уровне; `any` (и старая форма-массив)
ведёт на всех; глобальная цепочка уровня и `default` всегда остаются резервом —
правило задачи не ослабляет failover. На вкладке Роутинг это редактируется
строками пар «модель + сложность» у каждой задачи.
**Исключение — высокая сложность эскалирует.** Когда роутер оценил запрос как
`high` (и цепочка `high` настроена), цепочка уровня идёт первой, а правило
задачи становится резервом. Иначе правило вида `code → дешёвая-модель`
+26 -22
View File
@@ -116,13 +116,7 @@ fn tiers_to_map(routing: &RoutingCfg) -> HashMap<String, Vec<String>> {
routing
.tiers
.iter()
.map(|(k, v)| {
let list = match v {
TierTargets::List(l) => l.clone(),
TierTargets::One(s) => vec![s.clone()],
};
(k.clone(), list)
})
.map(|(k, v)| (k.clone(), v.models()))
.collect()
}
@@ -758,6 +752,13 @@ fn strip_id_from_routing(routing: &mut RoutingCfg, id: &str) {
}
for tt in routing.tiers.values_mut().chain(routing.tasks.values_mut()) {
match tt {
crate::config::TierTargets::Map(m) => {
for v in m.values_mut() {
if let crate::config::TaskTierVal::List(l) = v {
l.retain(|x| x != id);
}
}
}
crate::config::TierTargets::List(v) => v.retain(|x| x != id),
crate::config::TierTargets::One(s) if s == id => {
*tt = crate::config::TierTargets::List(Vec::new());
@@ -766,10 +767,7 @@ fn strip_id_from_routing(routing: &mut RoutingCfg, id: &str) {
}
}
// an emptied task rule means "no rule", not "no candidates"
routing.tasks.retain(|_, tt| match tt {
crate::config::TierTargets::List(v) => !v.is_empty(),
crate::config::TierTargets::One(s) => !s.is_empty(),
});
routing.tasks.retain(|_, tt| !tt.models().is_empty());
}
/// Whether any managed server or static model in `cfg` still uses `path` as its
@@ -910,19 +908,25 @@ async fn probe_model(State(state): State<SharedState>, Path(id): Path<String>) -
async fn get_routing(State(state): State<SharedState>) -> ApiResult {
let live = state.live();
let tasks: HashMap<String, Vec<String>> = live
let tasks: HashMap<String, serde_json::Value> = live
.cfg
.routing
.tasks
.iter()
.map(|(k, v)| {
(
k.clone(),
match v {
TierTargets::List(l) => l.clone(),
TierTargets::One(s) => vec![s.clone()],
},
)
(k.clone(), {
let (any, per) = v.task_rule();
let mut o = serde_json::Map::new();
if !any.is_empty() {
o.insert("any".into(), json!(any));
}
for (tier, models) in per {
if !models.is_empty() {
o.insert(tier, json!(models));
}
}
serde_json::Value::Object(o)
})
})
.collect();
ok(json!({
@@ -973,7 +977,7 @@ struct RoutingBody {
#[serde(default)]
tiers: HashMap<String, Vec<String>>,
#[serde(default)]
tasks: HashMap<String, Vec<String>>,
tasks: HashMap<String, TierTargets>,
default: String,
#[serde(default)]
policy: RoutingPolicy,
@@ -990,8 +994,8 @@ async fn put_routing(State(state): State<SharedState>, Json(body): Json<RoutingB
tasks: body
.tasks
.into_iter()
.filter(|(_, v)| !v.is_empty()) // empty rule = "по сложности", not a rule
.map(|(k, v)| (k, TierTargets::List(v)))
// empty rule (no models AND no forced tier) = "по сложности"
.filter(|(_, v)| !v.models().is_empty())
.collect(),
default: body.default,
policy: body.policy,
+121 -5
View File
@@ -408,13 +408,114 @@ pub struct RoutingCfg {
/// A routing tier is either a list of model_ids or the string `default`.
/// (`default` is handled by a separate field; only tier lists appear here.)
///
/// TASK rules additionally accept a per-complexity map — "which model serves
/// this task at each tier":
///
/// ```toml
/// [routing.tasks.code]
/// low = ["cheap-coder"]
/// high = ["big-model"]
/// any = ["fallback"] # leads at every tier after the tier-specific list
/// ```
///
/// The plain-array form stays valid and means "leads at any tier". The
/// transitional `{ models = [...], tier = "high" }` form is normalized into
/// the map on load. Untagged: map is tried first, arrays keep parsing as is.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum TierTargets {
Map(HashMap<String, TaskTierVal>),
List(Vec<String>),
One(String),
}
/// A value inside a task's per-tier map: a model list, a single model, or the
/// transitional string value of the legacy `tier = "high"` key.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum TaskTierVal {
List(Vec<String>),
Str(String),
}
impl TaskTierVal {
fn list(&self) -> Vec<String> {
match self {
TaskTierVal::List(v) => v.clone(),
TaskTierVal::Str(s) => vec![s.clone()],
}
}
}
pub const TASK_TIER_KEYS: [&str; 4] = ["any", "low", "medium", "high"];
impl TierTargets {
/// Every referenced model id, whatever the syntactic form (validation).
pub fn models(&self) -> Vec<String> {
match self {
TierTargets::Map(m) => {
let mut out = Vec::new();
for (k, v) in m {
if k == "tier" {
continue; // legacy marker, not a model list
}
out.extend(v.list());
}
out
}
TierTargets::List(v) => v.clone(),
TierTargets::One(s) => vec![s.clone()],
}
}
/// Normalized task rule: `(any_tier_models, per_tier_models)`.
/// Legacy `{models, tier}` becomes `per[tier] = models`; a plain array
/// becomes `any`.
pub fn task_rule(&self) -> (Vec<String>, HashMap<String, Vec<String>>) {
match self {
TierTargets::Map(m) => {
let mut any: Vec<String> = Vec::new();
let mut per: HashMap<String, Vec<String>> = HashMap::new();
let legacy_tier = m.get("tier").and_then(|v| match v {
TaskTierVal::Str(s) => Some(s.clone()),
_ => None,
});
for (k, v) in m {
match k.as_str() {
"tier" => {}
"any" | "models" => any.extend(v.list()),
"low" | "medium" | "high" => {
per.entry(k.clone()).or_default().extend(v.list())
}
_ => {}
}
}
if let Some(tier) = legacy_tier {
// legacy pin: the models were meant for that tier
let moved = std::mem::take(&mut any);
per.entry(tier).or_default().extend(moved);
}
(any, per)
}
TierTargets::List(v) => (v.clone(), HashMap::new()),
TierTargets::One(s) => (vec![s.clone()], HashMap::new()),
}
}
/// Unknown keys inside a task map (validation).
pub fn invalid_task_keys(&self) -> Vec<String> {
match self {
TierTargets::Map(m) => m
.keys()
.filter(|k| !TASK_TIER_KEYS.contains(&k.as_str()) && *k != "models" && *k != "tier")
.cloned()
.collect(),
_ => Vec::new(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RoutingPolicy {
#[serde(default = "default_mode")]
@@ -587,6 +688,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 +702,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 +718,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 +974,7 @@ impl Config {
}
for (tier, targets) in &self.routing.tiers {
let list = match targets {
TierTargets::Map(_) => targets.models(),
TierTargets::List(v) => v.clone(),
TierTargets::One(s) => vec![s.clone()],
};
@@ -877,11 +989,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 +998,13 @@ impl Config {
);
}
}
if let Some(bad) = targets.invalid_task_keys().first() {
anyhow::bail!(
"config: routing.tasks.{}.{} is not a tier (use any/low/medium/high)",
task,
bad
);
}
}
Ok(())
}
@@ -981,6 +1096,7 @@ min_class = { low = "cheap", medium = "mid" }
let low = back.routing.tiers.get("low").expect("low tier present");
match low {
TierTargets::Map(_) => assert_eq!(low.models(), vec!["local-qwen".to_string()]),
TierTargets::List(v) => assert_eq!(v, &vec!["local-qwen".to_string()]),
TierTargets::One(s) => assert_eq!(s, "local-qwen"),
}
+123 -33
View File
@@ -4,16 +4,21 @@
use crate::config::{Config, TierTargets};
fn to_list(t: &TierTargets) -> Vec<String> {
match t {
TierTargets::List(v) => v.clone(),
TierTargets::One(s) => vec![s.clone()],
}
t.models()
}
/// A task rule, normalized: per-tier model lists ("which model serves this
/// task at each complexity") plus any-tier models that lead regardless.
#[derive(Clone, Default)]
pub struct TaskRule {
pub any: Vec<String>,
pub per: std::collections::HashMap<String, Vec<String>>,
}
pub struct RoutingTable {
tiers: std::collections::HashMap<String, Vec<String>>,
/// Per-task rules — a matching rule's models lead the candidate chain.
tasks: std::collections::HashMap<String, Vec<String>>,
tasks: std::collections::HashMap<String, TaskRule>,
default: String,
}
@@ -30,51 +35,78 @@ impl RoutingTable {
.routing
.tasks
.iter()
.map(|(task, targets)| (task.clone(), to_list(targets)))
.filter(|(_, v)| !v.is_empty())
.map(|(task, targets)| {
let (any, per) = targets.task_rule();
(task.clone(), TaskRule { any, per })
})
.filter(|(_, r)| !r.any.is_empty() || r.per.values().any(|v| !v.is_empty()))
.collect(),
default: cfg.routing.default.clone(),
}
}
/// Candidates for `(task, tier)`, in preference order, deduplicated:
/// the task rule's models first (when one exists), then the tier chain —
/// so a task pin adds preference without ever weakening failover.
/// Returns `(candidates, task_rule_matched)`.
/// Candidate chain for `(task, tier)` and whether a task rule shaped it.
///
/// Order of preference:
/// 1. the task's models FOR THIS TIER (the user said exactly which model
/// serves this task at this complexity);
/// 2. at tier=high — the global high chain (escalation: any-tier task
/// models must not undercut it), then the task's any-tier models;
/// otherwise the task's any-tier models, then the global tier chain;
/// 3. `default` as the last resort. Deduplicated, empties dropped.
pub fn candidates_for(&self, task: &str, tier: &str) -> (Vec<String>, bool) {
fn push(out: &mut Vec<String>, id: &str) {
if !id.is_empty() && !out.iter().any(|x| x == id) {
out.push(id.to_string());
}
}
let mut out: Vec<String> = Vec::new();
let rule = self.tasks.get(task);
let tier_chain = self
.tiers
.get(tier)
.cloned()
.unwrap_or_else(|| vec![self.default.clone()]);
// 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.
if let Some(r) = rule {
if let Some(list) = r.per.get(tier) {
for id in list {
push(&mut out, id);
}
}
}
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());
// high verdicts escalate over any-tier task pins
for id in &tier_chain {
push(&mut out, id);
}
if let Some(r) = rule {
for id in &r.any {
push(&mut out, id);
}
}
true
} else {
false
};
if !escalate {
for id in tier_chain {
if !out.contains(&id) {
out.push(id);
if let Some(r) = rule {
for id in &r.any {
push(&mut out, id);
}
}
for id in &tier_chain {
push(&mut out, id);
}
}
out.retain(|id| !id.is_empty());
let d = self.default.clone();
push(&mut out, &d);
// "task-rule" must mean "the rule chose the head of this chain" — a
// rule that exists but lost to the tier chain (math has a high pick,
// the request came in low) must not claim the credit.
let matched = match (rule, out.first()) {
(Some(r), Some(head)) => {
r.per.get(tier).map(|v| v.contains(head)).unwrap_or(false)
|| r.any.first() == Some(head)
}
_ => false,
};
(out, matched)
}
@@ -106,12 +138,33 @@ mod tests {
.collect(),
tasks: tasks
.iter()
.map(|(k, v)| (k.to_string(), v.iter().map(|s| s.to_string()).collect()))
.map(|(k, v)| {
(
k.to_string(),
TaskRule {
any: v.iter().map(|s| s.to_string()).collect(),
per: Default::default(),
},
)
})
.collect(),
default: default.to_string(),
}
}
fn with_task_tier_models(
mut t: RoutingTable,
task: &str,
tier: &str,
models: &[&str],
) -> RoutingTable {
t.tasks.entry(task.to_string()).or_default().per.insert(
tier.to_string(),
models.iter().map(|s| s.to_string()).collect(),
);
t
}
#[test]
fn task_rule_leads_tier_chain_follows() {
let t = table(
@@ -138,14 +191,51 @@ mod tests {
assert_eq!(c[0], "cheap-coder");
// high: tier chain leads, task rule follows as fallback
let (c, m) = t.candidates_for("code", "high");
// escalation put the global high chain first → the rule did not pick
// the head, so no task-rule credit
assert!(!m);
// default rides along as the very last fallback
assert_eq!(c, vec!["big", "cheap-coder", "cheap"]);
}
#[test]
fn per_tier_models_lead_their_tier() {
let base = table(
&[("code", &["generic-coder"])],
&[("low", &["cheap"]), ("high", &["big"])],
"cheap",
);
let t = with_task_tier_models(base, "code", "high", &["best-coder"]);
// at high: the task's HIGH pick leads, escalation chain next,
// any-tier task models after it, default last
let (c, m) = t.candidates_for("code", "high");
assert!(m);
assert_eq!(c, vec!["big", "cheap-coder"]);
assert_eq!(c, vec!["best-coder", "big", "generic-coder", "cheap"]);
// at low: any-tier task models lead as before (and the badge is
// honest — the head came from the rule)
let (c, m) = t.candidates_for("code", "low");
assert!(m);
assert_eq!(c, vec!["generic-coder", "cheap"]);
// a tier with an explicit pick but no any-models
let t2 = with_task_tier_models(
table(&[], &[("low", &["cheap"]), ("high", &["big"])], "cheap"),
"legal",
"high",
&["strong"],
);
let (c, m) = t2.candidates_for("legal", "high");
assert!(m);
assert_eq!(c, vec!["strong", "big", "cheap"]);
// the same rule at LOW did not shape the head → no task-rule credit
let (c, m) = t2.candidates_for("legal", "low");
assert!(!m);
assert_eq!(c, vec!["cheap"]);
}
#[test]
fn no_rule_falls_back_to_tier_then_default() {
let t = table(&[], &[("low", &["a"])], "d");
assert_eq!(t.candidates_for("qa", "low").0, vec!["a"]);
assert_eq!(t.candidates_for("qa", "low").0, vec!["a", "d"]);
let (c, matched) = t.candidates_for("qa", "unknown-tier");
assert!(!matched);
assert_eq!(c, vec!["d"]);
+154 -1
View File
@@ -55,7 +55,7 @@ pub struct RequestRecord {
pub error: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
#[derive(Clone, Debug, Default, Serialize, serde::Deserialize)]
pub struct Totals {
pub requests: u64,
pub ok: u64,
@@ -130,6 +130,15 @@ struct Inner {
/// Per-minute buckets — the single source for the windowed admin snapshot.
buckets: VecDeque<Bucket>,
recent: VecDeque<RequestRecord>,
/// Per-account totals over EVERY record still in the JSONL file (no
/// retention gate) — per-key accounting for the keys page.
by_account_alltime: HashMap<String, Totals>,
/// Per-account totals folded out of the file by compaction. alltime(key)
/// = folded + by_account_alltime, with no double counting: a record is
/// either in the file or folded, never both.
folded: HashMap<String, Totals>,
/// Appends since the last compaction size check.
writes_since_check: u32,
}
pub struct Stats {
@@ -137,6 +146,7 @@ pub struct Stats {
file: Option<String>,
ring_size: usize,
retention_secs: u64,
max_file_bytes: u64,
inner: Mutex<Inner>,
}
@@ -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<String> {
self.file.as_ref().map(|f| format!("{f}.totals.json"))
}
fn load_folded(&self) {
let Some(path) = self.totals_path() else {
return;
};
let Ok(content) = std::fs::read_to_string(&path) else {
return;
};
if let Ok(m) = serde_json::from_str::<HashMap<String, Totals>>(&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::<RequestRecord>(line) {
Ok(rec) if rec.ts < cutoff => {
inner
.folded
.entry(rec.account.clone())
.or_default()
.apply(&rec);
// fold = leave by_account_alltime (record leaves the file,
// its share moves from "alltime" into "folded")
if let Some(t) = inner.by_account_alltime.get_mut(&rec.account) {
// subtract by rebuilding is costly; instead track the
// invariant additively: remove the record's share
t.requests = t.requests.saturating_sub(1);
if rec.outcome == "ok" {
t.ok = t.ok.saturating_sub(1);
} else {
t.errors = t.errors.saturating_sub(1);
}
if rec.failover {
t.failovers = t.failovers.saturating_sub(1);
}
t.prompt_tokens = t.prompt_tokens.saturating_sub(rec.prompt_tokens as u64);
t.completion_tokens = t
.completion_tokens
.saturating_sub(rec.completion_tokens as u64);
t.cost_usd = (t.cost_usd - rec.cost_usd).max(0.0);
t.latency_ms_sum = t.latency_ms_sum.saturating_sub(rec.latency_ms);
}
}
Ok(_) => {
kept.push_str(line);
kept.push('\n');
}
Err(_) => { /* unparseable line — drop */ }
}
}
// persist the folded totals FIRST, then swap the trimmed file in
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<String, Totals> = inner.folded.clone();
for (k, t) in &inner.by_account_alltime {
alltime.entry(k.clone()).or_default().merge(t);
}
let alltime_by_account: serde_json::Map<String, serde_json::Value> = alltime
.iter()
.map(|(k, t)| {
(
k.clone(),
serde_json::json!({
"requests": t.requests,
"prompt_tokens": t.prompt_tokens,
"completion_tokens": t.completion_tokens,
"cost_usd": t.cost_usd,
}),
)
})
.collect();
serde_json::json!({
"alltime_by_account": alltime_by_account,
"totals": {
"requests": win.requests,
"ok": win.ok,
@@ -327,6 +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();
+5
View File
@@ -592,6 +592,11 @@ body.modal-open { overflow: hidden; }
.ok { color: var(--good); }
.pairs { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
.pair-row { display: flex; gap: 6px; align-items: center; }
.pair-model { min-width: 220px; max-width: 340px; padding: 4px 8px; font-size: 12.5px; }
.pair-tier { max-width: 150px; padding: 4px 8px; font-size: 12.5px; }
/* 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; }
+8
View File
@@ -621,4 +621,12 @@ 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": "Welches Modell diese Aufgabe bei welcher Komplexität bedient; \"jede\" führt auf allen Stufen. Reihenfolge = Fallback-Reihenfolge. High eskaliert weiterhin über \"jede\"-Einträge.",
"routing.tasks.tierAny": "jede Komplexität",
"routing.tasks.addModel": "Modell",
};
+8
View File
@@ -614,4 +614,12 @@ 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": "Which model serves this task at which complexity; \"any\" leads at every tier. Order = fallback order. High verdicts still escalate over any-tier pins.",
"routing.tasks.tierAny": "any complexity",
"routing.tasks.addModel": "model",
};
+8
View File
@@ -621,4 +621,12 @@ 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": "Qué modelo atiende esta tarea en qué complejidad; «cualquiera» lidera en todos los niveles. El orden = orden de respaldo. Un veredicto high sigue escalando sobre las entradas «cualquiera».",
"routing.tasks.tierAny": "cualquier complejidad",
"routing.tasks.addModel": "modelo",
};
+8
View File
@@ -621,4 +621,12 @@ 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": "Quel modèle sert cette tâche à quelle complexité ; « toute » mène à tous les niveaux. L'ordre = ordre de repli. Un verdict high escalade toujours au-dessus des entrées « toute ».",
"routing.tasks.tierAny": "toute complexité",
"routing.tasks.addModel": "modèle",
};
+8
View File
@@ -613,4 +613,12 @@ 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 по-прежнему эскалирует поверх «любых».",
"routing.tasks.tierAny": "любая сложность",
"routing.tasks.addModel": "модель",
};
+8
View File
@@ -621,4 +621,12 @@ 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örevi hangi karmaşıklıkta hangi model karşılar; \"her\" tüm katmanlarda önde gider. Sıra = yedekleme sırası. High kararı \"her\" girdilerinin üzerine yine tırmanır.",
"routing.tasks.tierAny": "her karmaşıklık",
"routing.tasks.addModel": "model",
};
+8
View File
@@ -621,4 +621,12 @@ 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 判定仍会越过「任意」条目升级。",
"routing.tasks.tierAny": "任意复杂度",
"routing.tasks.addModel": "模型",
};
+25
View File
@@ -56,11 +56,17 @@ function hardPrompt(kind) {
() => `Architect a protein-folding inference pipeline for a wet lab: ensemble MSA generation with taxonomy-aware subsampling, template selection under a ${n(30, 60)} percent identity ceiling, distributed attention across ${n(4, 16)} GPUs with activation checkpointing, confidence calibration against experimental B-factors, and an active-learning loop that decides which of ${n(300, 2000)} candidate mutants to synthesize next week.`,
() => `Schedule observations for a network of ${n(8, 30)} robotic telescopes chasing gravitational-wave counterparts: probability skymaps updated every ${n(5, 20)} minutes, slew-time-aware target assignment with decaying rewards, cloud-cover forecasts folded in as stochastic availability, spectroscopy triggered only above a ${n(40, 85)} percent kilonova likelihood, and a plan to validate the scheduler against the one historical multi-messenger event.`,
() => `Run a closed-loop materials-discovery campaign for solid-state electrolytes: DFT screening of ${n(2000, 9000)} candidates, Bayesian optimization driving ${n(3, 12)} parallel synthesis robots, CNN-based XRD phase identification, an ionic-conductivity target of ${n(2, 25)} mS/cm, and an explicit decision policy for when simulation is trusted over synthesis.`,
() => `Design a surface-code error-correction pipeline for a ${n(400, 3000)}-qubit superconducting processor: choose a code distance for a logical error target of 1e-${n(9, 14)} at physical error rate 1e-3, syndrome extraction avoiding hook errors, a real-time decoder under a ${n(1, 4)} microsecond budget, and the classical co-processor layout with feedback for lattice surgery.`,
() => `Design a monitoring-reporting-verification pipeline for soil-carbon credits across ${n(500, 5000)} farms: satellite spectral indices calibrated against ${n(800, 4000)} physical core samples, hierarchical Bayesian estimation with shrinkage, additionality argued against county-level counterfactuals, uncertainty discounts priced into issuance, and an audit sampling plan catching over-reporting with ${n(90, 99)} percent power.`,
() => `Plan a cryo-EM structure-determination campaign for a ${n(300, 900)} kDa membrane complex: grid-screening strategy across ${n(4, 12)} detergent conditions, particle picking with a distilled transformer at ${n(2, 9)} million particles, ab-initio reconstruction ambiguity resolved by tilt-pair validation, local-resolution-aware model building, and a decision rule for when to stop collecting and publish.`,
];
const ops = [
() => `Model a dengue outbreak across ${n(20, 90)} districts by coupling an SEIR compartment model to vaccine cold-chain optimization: transmission parameters estimated from noisy case counts with a particle filter, uncertainty propagated into weekly demand forecasts, refrigerated routing under a ${n(120, 400)}-dose-per-crate constraint, and a defended ethical weighting for which ${n(3, 12)} districts get priority when supply covers ${n(20, 60)} percent of demand.`,
() => `Co-optimize a regional power grid's day-ahead market with a fleet of ${n(200, 2000)} grid-scale batteries: bid curves learned from ${n(2, 6)} years of price data, unit commitment as mixed-integer programming with ramp constraints, degradation amortized into marginal costs, N-1 contingency screening after every dispatch, and a mechanism-design argument that the bidding rules cannot be gamed by a coalition controlling ${n(10, 30)} percent of storage.`,
() => `Couple regional climate downscaling at ${n(2, 12)} km resolution to catastrophe-bond pricing: bias-correct ${n(10, 40)} CMIP ensemble members, generate stochastic hurricane tracks, propagate landfall distributions into a portfolio of ${n(30, 200)} bonds, price the attachment probabilities, and defend the discount rate applied to century-scale tail risk.`,
() => `Co-design a hospital emergency department's triage policy with its nurse staffing schedule: arrivals as a time-varying Poisson process fitted to ${n(2, 6)} years of data, acuity drift as a hidden Markov chain, staffing as integer programming over ${n(8, 12)}-hour shifts, a queueing bound on door-to-doctor time at the ${n(90, 99)}th percentile, and a fairness constraint preventing systematic delays for low-acuity elderly patients.`,
() => `Optimize a container port's berth allocation jointly with tidal windows and rail departures: vessel ETAs with heavy-tailed uncertainty, berth-crane assignment as a mixed-integer program re-solved hourly, tide-constrained draft limits for the ${n(3, 8)} deepest berths, a queueing model of the truck gate coupled to city traffic, and a robustness argument for surviving a ${n(24, 72)}-hour crane outage.`,
() => `Schedule observations for a fleet of ${n(6, 40)} agricultural drones across ${n(200, 2000)} fields: multispectral revisit deadlines driven by crop growth-stage models, battery-swap logistics as a vehicle-routing problem with time windows, rain forecasts as stochastic field availability, disease-outbreak alerts pre-empting the plan, and a proof the replanner never starves a field past its agronomic deadline.`,
];
return pick(kind === "science" ? science : ops)();
}
@@ -210,6 +216,25 @@ export async function renderBenchmark() {
let ev;
if (test.judge === "route") {
// the test asserts the ROUTER's decision: did it rate this high?
// One retry with a FRESH template: the router deliberately scores
// familiar text lower (novelty), so a single burnt wording must
// not fail the whole assertion.
if (info.tier !== "high" && test.gen) {
const p2 = hardPrompt(test.gen);
try {
const second = await testStream(
{ model, messages: [{ role: "user", content: p2 }], temperature: 0, max_tokens: (test.max || 128) + 256 },
() => {}
);
if (second.tier === "high") {
info.tier = second.tier;
info.selected_model = second.selected_model;
info.task_label = second.task_label;
info.route_source = second.route_source;
info.cost_usd = (info.cost_usd || 0) + (second.cost_usd || 0);
}
} catch { /* keep first attempt */ }
}
ev = info.tier === "high"
? { status: "pass", score: 1, note: "high ✓" }
: { status: "fail", score: 0, note: (info.tier || "—") + " ≠ high" };
+28 -2
View File
@@ -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" },
+62 -7
View File
@@ -54,10 +54,23 @@ export async function renderRouting() {
// local editable state — ensure standard tiers exist for a friendly editor
const tiers = Object.assign({ low: [], medium: [], high: [] }, routing.tiers || {});
const tasks = {};
for (const l of taskLabels) tasks[l] = (routing.tasks || {})[l] || [];
// A task rule is edited as ordered PAIRS (model, tier): "this model serves
// this task at this complexity" (tier "any" = at every complexity).
// GET returns {any:[], low:[], medium:[], high:[]}; legacy arrays = any.
const taskPairs = {};
for (const l of taskLabels) {
const v = (routing.tasks || {})[l];
const pairs = [];
if (Array.isArray(v)) for (const m of v) pairs.push({ model: m, tier: "any" });
else if (v && typeof v === "object") {
for (const tier of ["low", "medium", "high", "any"])
for (const m of v[tier] || []) pairs.push({ model: m, tier });
}
taskPairs[l] = pairs;
}
const st = {
tiers,
tasks,
taskPairs,
default: routing.default || ids[0] || "",
policy: routing.policy || { mode: "fixed_table", min_class: {} },
};
@@ -130,17 +143,52 @@ export async function renderRouting() {
h("div", { class: "muted small mono" }, label),
share != null ? h("div", { class: "muted small" }, t("routing.tasks.share", { p: Math.round(share * 100) })) : null
),
chipEditor(st.tasks, label, t("routing.tasks.inherit"))
pairsEditor(label)
);
}
// Pairs editor: each row is [model ▾][complexity ▾][×]; "any" serves every
// tier. Order matters — it is the fallback order inside the tier.
function pairsEditor(label) {
const pairs = st.taskPairs[label];
const wrap = h("div", { class: "pairs grow" });
function paint() {
mount(
wrap,
...pairs.map((p, i) => {
const modelSel = h(
"select",
{ class: "pair-model" },
...ids.map((id) => h("option", { value: id }, id))
);
modelSel.value = p.model;
modelSel.addEventListener("change", () => { p.model = modelSel.value; });
const tierSel = h(
"select",
{ class: "pair-tier", title: t("routing.tasks.tierHint") },
h("option", { value: "any" }, t("routing.tasks.tierAny")),
...Object.keys(st.tiers).map((tr) => h("option", { value: tr }, tr))
);
tierSel.value = p.tier;
tierSel.addEventListener("change", () => { p.tier = tierSel.value; });
const del = h("button", { class: "btn sm ghost danger", onClick: () => { pairs.splice(i, 1); paint(); } }, "×");
return h("div", { class: "pair-row" }, modelSel, tierSel, del);
}),
pairs.length === 0 ? h("div", { class: "muted small", style: "align-self:center" }, t("routing.tasks.inherit")) : null,
h("button", { class: "btn ghost sm", onClick: () => { pairs.push({ model: ids[0] || "", tier: "any" }); paint(); } }, "+ " + t("routing.tasks.addModel"))
);
}
paint();
return wrap;
}
function tasksCard() {
const addIn = h("input", { placeholder: t("routing.tasks.addPlaceholder"), style: "max-width:220px" });
const addBtn = h("button", { class: "btn ghost sm" }, "+ " + t("routing.tasks.addCustom"));
addBtn.addEventListener("click", () => {
const v = addIn.value.trim().toLowerCase();
if (!v || st.tasks[v]) return;
st.tasks[v] = [];
if (!v || st.taskPairs[v]) return;
st.taskPairs[v] = [];
render();
});
return h(
@@ -151,7 +199,7 @@ export async function renderRouting() {
h("span", { class: "card-head sub" },
labelsSource === "router" ? t("routing.tasks.fromRouter") : t("routing.tasks.builtin"))),
h("div", { class: "muted small", style: "margin-bottom:10px" }, t("routing.tasks.hint")),
...Object.keys(st.tasks).map(taskRow),
...Object.keys(st.taskPairs).map(taskRow),
h("div", { class: "flex", style: "gap:8px; margin-top:10px" }, addIn, addBtn)
);
}
@@ -201,7 +249,14 @@ 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, pairs] of Object.entries(st.taskPairs)) {
const rule = {};
for (const p of pairs) {
if (!p.model) continue;
(rule[p.tier] = rule[p.tier] || []).push(p.model);
}
if (Object.keys(rule).length) tasks[k] = rule;
}
await api.putRouting({ tiers: st.tiers, tasks, default: st.default, policy: st.policy });
toast(t("toast.saved"), "good");
} catch (e) {