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.
This commit is contained in:
@@ -119,6 +119,10 @@ enabled = true
|
|||||||
file = "config/stats.jsonl" # empty = in-memory only
|
file = "config/stats.jsonl" # empty = in-memory only
|
||||||
ring_size = 500 # how many recent requests to keep in the feed
|
ring_size = 500 # how many recent requests to keep in the feed
|
||||||
retention = "7d" # aggregate/series depth (replayed on startup)
|
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
|
# Semantic cache — returns a cached answer for prompts semantically near a
|
||||||
|
|||||||
@@ -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
|
chain follows as fallback, so a task pin never weakens failover. Tasks without
|
||||||
a rule route by complexity alone.
|
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
|
**Exception — high complexity escalates.** When the router rates a request
|
||||||
`high` (and a `high` chain is configured), the tier chain leads and the task
|
`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
|
rule demotes to fallback. Otherwise a rule like `code → cheap-model` would pin
|
||||||
|
|||||||
@@ -177,6 +177,20 @@ code = ["claude-sonnet", "gpt"] # код — сильнейшему ко
|
|||||||
сложности следует за ними как резерв — правило по задаче не ослабляет failover.
|
сложности следует за ними как резерв — правило по задаче не ослабляет failover.
|
||||||
Задачи без правила маршрутизируются только по сложности.
|
Задачи без правила маршрутизируются только по сложности.
|
||||||
|
|
||||||
|
**Закрепление сложности за задачей.** Правило задачи принимает и полную форму
|
||||||
|
с фиксированным уровнем — вердикт роутера для этой задачи заменяется до всех
|
||||||
|
остальных шагов:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[routing.tasks.legal]
|
||||||
|
models = ["strong-model"]
|
||||||
|
tier = "high" # юридические тексты — всегда high, что бы ни решил роутер
|
||||||
|
```
|
||||||
|
|
||||||
|
Ответы с закреплённым уровнем сообщают `route_source: "router+task-rule@high"`.
|
||||||
|
На вкладке Роутинг это селектор «Сложность» у каждой задачи (Авто / low /
|
||||||
|
medium / high).
|
||||||
|
|
||||||
**Исключение — высокая сложность эскалирует.** Когда роутер оценил запрос как
|
**Исключение — высокая сложность эскалирует.** Когда роутер оценил запрос как
|
||||||
`high` (и цепочка `high` настроена), цепочка уровня идёт первой, а правило
|
`high` (и цепочка `high` настроена), цепочка уровня идёт первой, а правило
|
||||||
задачи становится резервом. Иначе правило вида `code → дешёвая-модель`
|
задачи становится резервом. Иначе правило вида `code → дешёвая-модель`
|
||||||
|
|||||||
+8
-15
@@ -116,13 +116,7 @@ fn tiers_to_map(routing: &RoutingCfg) -> HashMap<String, Vec<String>> {
|
|||||||
routing
|
routing
|
||||||
.tiers
|
.tiers
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(k, v)| {
|
.map(|(k, v)| (k.clone(), v.models()))
|
||||||
let list = match v {
|
|
||||||
TierTargets::List(l) => l.clone(),
|
|
||||||
TierTargets::One(s) => vec![s.clone()],
|
|
||||||
};
|
|
||||||
(k.clone(), list)
|
|
||||||
})
|
|
||||||
.collect()
|
.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()) {
|
for tt in routing.tiers.values_mut().chain(routing.tasks.values_mut()) {
|
||||||
match tt {
|
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::List(v) => v.retain(|x| x != id),
|
||||||
crate::config::TierTargets::One(s) if s == id => {
|
crate::config::TierTargets::One(s) if s == id => {
|
||||||
*tt = crate::config::TierTargets::List(Vec::new());
|
*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"
|
// an emptied task rule means "no rule", not "no candidates"
|
||||||
routing.tasks.retain(|_, tt| match tt {
|
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::List(v) => !v.is_empty(),
|
||||||
crate::config::TierTargets::One(s) => !s.is_empty(),
|
crate::config::TierTargets::One(s) => !s.is_empty(),
|
||||||
});
|
});
|
||||||
@@ -910,7 +906,7 @@ async fn probe_model(State(state): State<SharedState>, Path(id): Path<String>) -
|
|||||||
|
|
||||||
async fn get_routing(State(state): State<SharedState>) -> ApiResult {
|
async fn get_routing(State(state): State<SharedState>) -> ApiResult {
|
||||||
let live = state.live();
|
let live = state.live();
|
||||||
let tasks: HashMap<String, Vec<String>> = live
|
let tasks: HashMap<String, serde_json::Value> = live
|
||||||
.cfg
|
.cfg
|
||||||
.routing
|
.routing
|
||||||
.tasks
|
.tasks
|
||||||
@@ -918,10 +914,7 @@ async fn get_routing(State(state): State<SharedState>) -> ApiResult {
|
|||||||
.map(|(k, v)| {
|
.map(|(k, v)| {
|
||||||
(
|
(
|
||||||
k.clone(),
|
k.clone(),
|
||||||
match v {
|
json!({ "models": v.models(), "tier": v.forced_tier() }),
|
||||||
TierTargets::List(l) => l.clone(),
|
|
||||||
TierTargets::One(s) => vec![s.clone()],
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -973,7 +966,7 @@ struct RoutingBody {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
tiers: HashMap<String, Vec<String>>,
|
tiers: HashMap<String, Vec<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
tasks: HashMap<String, Vec<String>>,
|
tasks: HashMap<String, TierTargets>,
|
||||||
default: String,
|
default: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
policy: RoutingPolicy,
|
policy: RoutingPolicy,
|
||||||
@@ -990,8 +983,8 @@ async fn put_routing(State(state): State<SharedState>, Json(body): Json<RoutingB
|
|||||||
tasks: body
|
tasks: body
|
||||||
.tasks
|
.tasks
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|(_, v)| !v.is_empty()) // empty rule = "по сложности", not a rule
|
// empty rule (no models AND no forced tier) = "по сложности"
|
||||||
.map(|(k, v)| (k, TierTargets::List(v)))
|
.filter(|(_, v)| !v.models().is_empty() || v.forced_tier().is_some())
|
||||||
.collect(),
|
.collect(),
|
||||||
default: body.default,
|
default: body.default,
|
||||||
policy: body.policy,
|
policy: body.policy,
|
||||||
|
|||||||
+51
-5
@@ -408,13 +408,42 @@ pub struct RoutingCfg {
|
|||||||
|
|
||||||
/// A routing tier is either a list of model_ids or the string `default`.
|
/// 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.)
|
/// (`default` is handled by a separate field; only tier lists appear here.)
|
||||||
|
/// Task rules additionally accept the full form
|
||||||
|
/// `{ models = [...], tier = "high" }`, where `tier` FORCES the complexity
|
||||||
|
/// tier for that task (e.g. legal review is always high, no matter what the
|
||||||
|
/// router scored). Untagged: the map variant is tried first, so the old
|
||||||
|
/// plain-array form keeps parsing unchanged.
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
pub enum TierTargets {
|
pub enum TierTargets {
|
||||||
|
Rule {
|
||||||
|
#[serde(default)]
|
||||||
|
models: Vec<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
tier: Option<String>,
|
||||||
|
},
|
||||||
List(Vec<String>),
|
List(Vec<String>),
|
||||||
One(String),
|
One(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl TierTargets {
|
||||||
|
/// The ordered model list, whatever the syntactic form.
|
||||||
|
pub fn models(&self) -> Vec<String> {
|
||||||
|
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)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct RoutingPolicy {
|
pub struct RoutingPolicy {
|
||||||
#[serde(default = "default_mode")]
|
#[serde(default = "default_mode")]
|
||||||
@@ -587,6 +616,12 @@ pub struct StatsCfg {
|
|||||||
/// Retention window for aggregates/time-series replayed on startup, e.g. `7d`.
|
/// Retention window for aggregates/time-series replayed on startup, e.g. `7d`.
|
||||||
#[serde(default = "default_stats_retention")]
|
#[serde(default = "default_stats_retention")]
|
||||||
pub retention: String,
|
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 {
|
impl Default for StatsCfg {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
@@ -595,6 +630,7 @@ impl Default for StatsCfg {
|
|||||||
file: default_stats_file(),
|
file: default_stats_file(),
|
||||||
ring_size: default_stats_ring(),
|
ring_size: default_stats_ring(),
|
||||||
retention: default_stats_retention(),
|
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 {
|
fn default_stats_retention() -> String {
|
||||||
"7d".into()
|
"7d".into()
|
||||||
}
|
}
|
||||||
|
fn default_stats_max_file_mb() -> u64 {
|
||||||
|
16
|
||||||
|
}
|
||||||
|
|
||||||
/// Self-warming shadow loop (docs/SELF_WARMING_GATEWAY.md). While a task
|
/// 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
|
/// 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 {
|
for (tier, targets) in &self.routing.tiers {
|
||||||
let list = match targets {
|
let list = match targets {
|
||||||
|
TierTargets::Rule { models, .. } => models.clone(),
|
||||||
TierTargets::List(v) => v.clone(),
|
TierTargets::List(v) => v.clone(),
|
||||||
TierTargets::One(s) => vec![s.clone()],
|
TierTargets::One(s) => vec![s.clone()],
|
||||||
};
|
};
|
||||||
@@ -877,11 +917,7 @@ impl Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (task, targets) in &self.routing.tasks {
|
for (task, targets) in &self.routing.tasks {
|
||||||
let list = match targets {
|
for id in &targets.models() {
|
||||||
TierTargets::List(v) => v.clone(),
|
|
||||||
TierTargets::One(s) => vec![s.clone()],
|
|
||||||
};
|
|
||||||
for id in &list {
|
|
||||||
if !ids.contains(id.as_str()) {
|
if !ids.contains(id.as_str()) {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"config: routing.tasks.{} references unknown model id '{}'",
|
"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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -981,6 +1026,7 @@ min_class = { low = "cheap", medium = "mid" }
|
|||||||
|
|
||||||
let low = back.routing.tiers.get("low").expect("low tier present");
|
let low = back.routing.tiers.get("low").expect("low tier present");
|
||||||
match low {
|
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::List(v) => assert_eq!(v, &vec!["local-qwen".to_string()]),
|
||||||
TierTargets::One(s) => assert_eq!(s, "local-qwen"),
|
TierTargets::One(s) => assert_eq!(s, "local-qwen"),
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-6
@@ -232,15 +232,22 @@ impl Pipeline {
|
|||||||
match live.router.route(&text, prof).await {
|
match live.router.route(&text, prof).await {
|
||||||
Ok(Some(dec)) => {
|
Ok(Some(dec)) => {
|
||||||
let src = dec.source.clone();
|
let src = dec.source.clone();
|
||||||
let (cands, task_rule) = live
|
let mut dec = dec;
|
||||||
|
let (cands, task_rule, forced) = live
|
||||||
.routing
|
.routing
|
||||||
.candidates_for(&dec.task_label, &dec.complexity_tier);
|
.candidates_for(&dec.task_label, &dec.complexity_tier);
|
||||||
// surface which rule chose the head of the chain
|
// surface which rule chose the head of the chain,
|
||||||
let src = if task_rule {
|
// and when the rule pinned the complexity —
|
||||||
format!("{src}+task-rule")
|
// "…+task-rule@high" — reflect it in the
|
||||||
} else {
|
// decision so headers/stats tell the truth
|
||||||
src
|
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)
|
(dec, src, cands)
|
||||||
}
|
}
|
||||||
Ok(None) | Err(_) => {
|
Ok(None) | Err(_) => {
|
||||||
|
|||||||
+77
-18
@@ -4,16 +4,21 @@
|
|||||||
use crate::config::{Config, TierTargets};
|
use crate::config::{Config, TierTargets};
|
||||||
|
|
||||||
fn to_list(t: &TierTargets) -> Vec<String> {
|
fn to_list(t: &TierTargets) -> Vec<String> {
|
||||||
match t {
|
t.models()
|
||||||
TierTargets::List(v) => v.clone(),
|
}
|
||||||
TierTargets::One(s) => vec![s.clone()],
|
|
||||||
}
|
/// 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<String>,
|
||||||
|
pub tier: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct RoutingTable {
|
pub struct RoutingTable {
|
||||||
tiers: std::collections::HashMap<String, Vec<String>>,
|
tiers: std::collections::HashMap<String, Vec<String>>,
|
||||||
/// Per-task rules — a matching rule's models lead the candidate chain.
|
/// 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,
|
default: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,8 +35,16 @@ impl RoutingTable {
|
|||||||
.routing
|
.routing
|
||||||
.tasks
|
.tasks
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(task, targets)| (task.clone(), to_list(targets)))
|
.map(|(task, targets)| {
|
||||||
.filter(|(_, v)| !v.is_empty())
|
(
|
||||||
|
task.clone(),
|
||||||
|
TaskRule {
|
||||||
|
models: targets.models(),
|
||||||
|
tier: targets.forced_tier().map(String::from),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.filter(|(_, r)| !r.models.is_empty() || r.tier.is_some())
|
||||||
.collect(),
|
.collect(),
|
||||||
default: cfg.routing.default.clone(),
|
default: cfg.routing.default.clone(),
|
||||||
}
|
}
|
||||||
@@ -41,11 +54,18 @@ impl RoutingTable {
|
|||||||
/// the task rule's models first (when one exists), then the tier chain —
|
/// the task rule's models first (when one exists), then the tier chain —
|
||||||
/// so a task pin adds preference without ever weakening failover.
|
/// so a task pin adds preference without ever weakening failover.
|
||||||
/// Returns `(candidates, task_rule_matched)`.
|
/// Returns `(candidates, task_rule_matched)`.
|
||||||
pub fn candidates_for(&self, task: &str, tier: &str) -> (Vec<String>, 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<String>, bool, Option<String>) {
|
||||||
let mut out: Vec<String> = Vec::new();
|
let mut out: Vec<String> = 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
|
let tier_chain = self
|
||||||
.tiers
|
.tiers
|
||||||
.get(tier)
|
.get(eff_tier)
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| vec![self.default.clone()]);
|
.unwrap_or_else(|| vec![self.default.clone()]);
|
||||||
// Task rules steer everyday traffic — but a high-complexity verdict
|
// 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
|
// the hardest prompts to the small model forever. At tier=high the
|
||||||
// tier chain leads and the task rule becomes the fallback; otherwise
|
// tier chain leads and the task rule becomes the fallback; otherwise
|
||||||
// the task rule leads as before.
|
// 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 {
|
if escalate {
|
||||||
out.extend(tier_chain.iter().cloned());
|
out.extend(tier_chain.iter().cloned());
|
||||||
}
|
}
|
||||||
let matched = if let Some(rule) = self.tasks.get(task) {
|
let matched = if let Some(rule) = rule {
|
||||||
for id in rule {
|
for id in &rule.models {
|
||||||
if !out.contains(id) {
|
if !out.contains(id) {
|
||||||
out.push(id.clone());
|
out.push(id.clone());
|
||||||
}
|
}
|
||||||
@@ -75,7 +95,7 @@ impl RoutingTable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.retain(|id| !id.is_empty());
|
out.retain(|id| !id.is_empty());
|
||||||
(out, matched)
|
(out, matched, forced)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Candidates for the given complexity tier, in preference order.
|
/// Candidates for the given complexity tier, in preference order.
|
||||||
@@ -106,12 +126,31 @@ mod tests {
|
|||||||
.collect(),
|
.collect(),
|
||||||
tasks: tasks
|
tasks: tasks
|
||||||
.iter()
|
.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(),
|
.collect(),
|
||||||
default: default.to_string(),
|
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]
|
#[test]
|
||||||
fn task_rule_leads_tier_chain_follows() {
|
fn task_rule_leads_tier_chain_follows() {
|
||||||
let t = table(
|
let t = table(
|
||||||
@@ -120,7 +159,7 @@ mod tests {
|
|||||||
"cheap",
|
"cheap",
|
||||||
);
|
);
|
||||||
// dedup: rule model already in the tier chain appears once, first
|
// 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!(matched);
|
||||||
assert_eq!(c, vec!["local", "cheap"]);
|
assert_eq!(c, vec!["local", "cheap"]);
|
||||||
}
|
}
|
||||||
@@ -133,20 +172,40 @@ mod tests {
|
|||||||
"cheap",
|
"cheap",
|
||||||
);
|
);
|
||||||
// medium (no chain defined → default): task rule still leads
|
// 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!(m);
|
||||||
assert_eq!(c[0], "cheap-coder");
|
assert_eq!(c[0], "cheap-coder");
|
||||||
// high: tier chain leads, task rule follows as fallback
|
// 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!(m);
|
||||||
assert_eq!(c, vec!["big", "cheap-coder"]);
|
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]
|
#[test]
|
||||||
fn no_rule_falls_back_to_tier_then_default() {
|
fn no_rule_falls_back_to_tier_then_default() {
|
||||||
let t = table(&[], &[("low", &["a"])], "d");
|
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"]);
|
||||||
let (c, matched) = t.candidates_for("qa", "unknown-tier");
|
let (c, matched, _) = t.candidates_for("qa", "unknown-tier");
|
||||||
assert!(!matched);
|
assert!(!matched);
|
||||||
assert_eq!(c, vec!["d"]);
|
assert_eq!(c, vec!["d"]);
|
||||||
}
|
}
|
||||||
|
|||||||
+154
-1
@@ -55,7 +55,7 @@ pub struct RequestRecord {
|
|||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Serialize)]
|
#[derive(Clone, Debug, Default, Serialize, serde::Deserialize)]
|
||||||
pub struct Totals {
|
pub struct Totals {
|
||||||
pub requests: u64,
|
pub requests: u64,
|
||||||
pub ok: u64,
|
pub ok: u64,
|
||||||
@@ -130,6 +130,15 @@ struct Inner {
|
|||||||
/// Per-minute buckets — the single source for the windowed admin snapshot.
|
/// Per-minute buckets — the single source for the windowed admin snapshot.
|
||||||
buckets: VecDeque<Bucket>,
|
buckets: VecDeque<Bucket>,
|
||||||
recent: VecDeque<RequestRecord>,
|
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 {
|
pub struct Stats {
|
||||||
@@ -137,6 +146,7 @@ pub struct Stats {
|
|||||||
file: Option<String>,
|
file: Option<String>,
|
||||||
ring_size: usize,
|
ring_size: usize,
|
||||||
retention_secs: u64,
|
retention_secs: u64,
|
||||||
|
max_file_bytes: u64,
|
||||||
inner: Mutex<Inner>,
|
inner: Mutex<Inner>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,9 +162,12 @@ impl Stats {
|
|||||||
file,
|
file,
|
||||||
ring_size: cfg.ring_size.max(1),
|
ring_size: cfg.ring_size.max(1),
|
||||||
retention_secs: parse_duration_secs(&cfg.retention).unwrap_or(7 * 86_400),
|
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()),
|
inner: Mutex::new(Inner::default()),
|
||||||
};
|
};
|
||||||
|
s.load_folded();
|
||||||
s.replay();
|
s.replay();
|
||||||
|
s.maybe_compact(true);
|
||||||
std::sync::Arc::new(s)
|
std::sync::Arc::new(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +188,11 @@ impl Stats {
|
|||||||
// push everything to the "recent" ring (it self-limits by size),
|
// push everything to the "recent" ring (it self-limits by size),
|
||||||
// but apply to aggregates/series only entries within the window.
|
// but apply to aggregates/series only entries within the window.
|
||||||
Self::push_recent(&mut inner, &rec, self.ring_size);
|
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 {
|
if rec.ts >= cutoff {
|
||||||
Self::apply_aggregates(&mut inner, &rec, self.retention_secs);
|
Self::apply_aggregates(&mut inner, &rec, self.retention_secs);
|
||||||
}
|
}
|
||||||
@@ -233,6 +251,12 @@ impl Stats {
|
|||||||
let mut inner = self.inner.lock().unwrap();
|
let mut inner = self.inner.lock().unwrap();
|
||||||
Self::apply_aggregates(&mut inner, &rec, self.retention_secs);
|
Self::apply_aggregates(&mut inner, &rec, self.retention_secs);
|
||||||
Self::push_recent(&mut inner, &rec, self.ring_size);
|
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 Some(path) = &self.file {
|
||||||
if let Ok(line) = serde_json::to_string(&rec) {
|
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`.
|
/// 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))
|
.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!({
|
serde_json::json!({
|
||||||
|
"alltime_by_account": alltime_by_account,
|
||||||
"totals": {
|
"totals": {
|
||||||
"requests": win.requests,
|
"requests": win.requests,
|
||||||
"ok": win.ok,
|
"ok": win.ok,
|
||||||
@@ -327,6 +477,9 @@ impl Stats {
|
|||||||
/// Reset all in-memory stats and truncate the JSONL log — the "clear logs"
|
/// Reset all in-memory stats and truncate the JSONL log — the "clear logs"
|
||||||
/// admin action.
|
/// admin action.
|
||||||
pub fn clear(&self) {
|
pub fn clear(&self) {
|
||||||
|
if let Some(tpath) = self.totals_path() {
|
||||||
|
let _ = std::fs::remove_file(tpath);
|
||||||
|
}
|
||||||
{
|
{
|
||||||
let mut inner = self.inner.lock().unwrap();
|
let mut inner = self.inner.lock().unwrap();
|
||||||
*inner = Inner::default();
|
*inner = Inner::default();
|
||||||
|
|||||||
Vendored
+3
@@ -592,6 +592,9 @@ body.modal-open { overflow: hidden; }
|
|||||||
|
|
||||||
.ok { color: var(--good); }
|
.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 */
|
/* Benchmark view */
|
||||||
@keyframes bm-in { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } }
|
@keyframes bm-in { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } }
|
||||||
tbody tr:last-child { animation: bm-in .25s ease; }
|
tbody tr:last-child { animation: bm-in .25s ease; }
|
||||||
|
|||||||
Vendored
+6
@@ -621,4 +621,10 @@ export default {
|
|||||||
"bm.cutByCap": "Budget ging fürs Denken drauf — keine Antwort mehr",
|
"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.uiStale": "Das Gateway wurde aktualisiert — diese Seite zeigt eine ältere Oberfläche.",
|
||||||
"shell.reload": "Neu laden",
|
"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.",
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+6
@@ -614,4 +614,10 @@ export default {
|
|||||||
"bm.cutByCap": "budget spent on reasoning — answer never came",
|
"bm.cutByCap": "budget spent on reasoning — answer never came",
|
||||||
"shell.uiStale": "The gateway was updated — this page is running an older interface.",
|
"shell.uiStale": "The gateway was updated — this page is running an older interface.",
|
||||||
"shell.reload": "Reload",
|
"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.",
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+6
@@ -621,4 +621,10 @@ export default {
|
|||||||
"bm.cutByCap": "presupuesto gastado en razonar — sin respuesta final",
|
"bm.cutByCap": "presupuesto gastado en razonar — sin respuesta final",
|
||||||
"shell.uiStale": "La pasarela se actualizó — esta página usa una interfaz antigua.",
|
"shell.uiStale": "La pasarela se actualizó — esta página usa una interfaz antigua.",
|
||||||
"shell.reload": "Recargar",
|
"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.",
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+6
@@ -621,4 +621,10 @@ export default {
|
|||||||
"bm.cutByCap": "budget épuisé en raisonnement — pas de réponse",
|
"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.uiStale": "La passerelle a été mise à jour — cette page utilise une ancienne interface.",
|
||||||
"shell.reload": "Recharger",
|
"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.",
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+6
@@ -613,4 +613,10 @@ export default {
|
|||||||
"bm.cutByCap": "бюджет ушёл на размышления — ответ не успел",
|
"bm.cutByCap": "бюджет ушёл на размышления — ответ не успел",
|
||||||
"shell.uiStale": "Шлюз обновился — на этой странице старый интерфейс.",
|
"shell.uiStale": "Шлюз обновился — на этой странице старый интерфейс.",
|
||||||
"shell.reload": "Перезагрузить",
|
"shell.reload": "Перезагрузить",
|
||||||
|
"keys.col.requests": "Запросы",
|
||||||
|
"keys.col.tokens": "Токены вх/исх",
|
||||||
|
"keys.col.costAll": "$ за всё время",
|
||||||
|
"routing.tasks.tier": "Сложность",
|
||||||
|
"routing.tasks.tierAuto": "Авто (по роутеру)",
|
||||||
|
"routing.tasks.tierHint": "Закрепить уровень сложности для задачи: вердикт роутера заменяется, high по-прежнему эскалирует поверх списка моделей.",
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+6
@@ -621,4 +621,10 @@ export default {
|
|||||||
"bm.cutByCap": "bütçe düşünmeye gitti — yanıt gelemedi",
|
"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.uiStale": "Ağ geçidi güncellendi — bu sayfa eski arayüzü çalıştırıyor.",
|
||||||
"shell.reload": "Yeniden yükle",
|
"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.",
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+6
@@ -621,4 +621,10 @@ export default {
|
|||||||
"bm.cutByCap": "预算全用于思考——未能给出答案",
|
"bm.cutByCap": "预算全用于思考——未能给出答案",
|
||||||
"shell.uiStale": "网关已更新 — 此页面运行的是旧界面。",
|
"shell.uiStale": "网关已更新 — 此页面运行的是旧界面。",
|
||||||
"shell.reload": "重新加载",
|
"shell.reload": "重新加载",
|
||||||
|
"keys.col.requests": "请求数",
|
||||||
|
"keys.col.tokens": "Token 入/出",
|
||||||
|
"keys.col.costAll": "$ 累计",
|
||||||
|
"routing.tasks.tier": "复杂度",
|
||||||
|
"routing.tasks.tierAuto": "自动(路由器)",
|
||||||
|
"routing.tasks.tierHint": "为该任务固定复杂度层级:替换路由器的判断,high 仍会越过模型列表进行升级。",
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+28
-2
@@ -1,10 +1,11 @@
|
|||||||
// API keys — list, create (with copy-once), revoke.
|
// 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 { t } from "../i18n.js";
|
||||||
import { api } from "../api.js";
|
import { api } from "../api.js";
|
||||||
|
|
||||||
export async function renderKeys() {
|
export async function renderKeys() {
|
||||||
const root = h("div");
|
const root = h("div");
|
||||||
|
let usageRange = "7d";
|
||||||
|
|
||||||
function openCreate(reload) {
|
function openCreate(reload) {
|
||||||
const accountIn = h("input", { placeholder: "team-a" });
|
const accountIn = h("input", { placeholder: "team-a" });
|
||||||
@@ -208,6 +209,15 @@ console.log(resp.choices[0].message.content);`,
|
|||||||
async function reload() {
|
async function reload() {
|
||||||
const data = await api.listKeys();
|
const data = await api.listKeys();
|
||||||
const keys = data.keys || [];
|
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(
|
mount(
|
||||||
root,
|
root,
|
||||||
h(
|
h(
|
||||||
@@ -217,6 +227,9 @@ console.log(resp.choices[0].message.content);`,
|
|||||||
"div",
|
"div",
|
||||||
{ class: "flex" },
|
{ class: "flex" },
|
||||||
h("div", { class: "grow" }, h("h2", {}, t("keys.title")), h("p", {}, t("keys.subtitle"))),
|
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"))
|
h("button", { class: "btn primary", onClick: () => openCreate(reload) }, "+ " + t("keys.add"))
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -235,7 +248,16 @@ console.log(resp.choices[0].message.content);`,
|
|||||||
h(
|
h(
|
||||||
"thead",
|
"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(
|
h(
|
||||||
"tbody",
|
"tbody",
|
||||||
@@ -248,6 +270,10 @@ console.log(resp.choices[0].message.content);`,
|
|||||||
h("td", {}, k.account),
|
h("td", {}, k.account),
|
||||||
h("td", { class: "mono" }, k.rate_per_min || "—"),
|
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", {}, (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(
|
h(
|
||||||
"td",
|
"td",
|
||||||
{ class: "right-align" },
|
{ class: "right-align" },
|
||||||
|
|||||||
Vendored
+33
-3
@@ -54,10 +54,17 @@ export async function renderRouting() {
|
|||||||
// local editable state — ensure standard tiers exist for a friendly editor
|
// local editable state — ensure standard tiers exist for a friendly editor
|
||||||
const tiers = Object.assign({ low: [], medium: [], high: [] }, routing.tiers || {});
|
const tiers = Object.assign({ low: [], medium: [], high: [] }, routing.tiers || {});
|
||||||
const tasks = {};
|
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 = {
|
const st = {
|
||||||
tiers,
|
tiers,
|
||||||
tasks,
|
tasks,
|
||||||
|
taskTiers,
|
||||||
default: routing.default || ids[0] || "",
|
default: routing.default || ids[0] || "",
|
||||||
policy: routing.policy || { mode: "fixed_table", min_class: {} },
|
policy: routing.policy || { mode: "fixed_table", min_class: {} },
|
||||||
};
|
};
|
||||||
@@ -130,10 +137,29 @@ export async function renderRouting() {
|
|||||||
h("div", { class: "muted small mono" }, label),
|
h("div", { class: "muted small mono" }, label),
|
||||||
share != null ? h("div", { class: "muted small" }, t("routing.tasks.share", { p: Math.round(share * 100) })) : null
|
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() {
|
function tasksCard() {
|
||||||
const addIn = h("input", { placeholder: t("routing.tasks.addPlaceholder"), style: "max-width:220px" });
|
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"));
|
const addBtn = h("button", { class: "btn ghost sm" }, "+ " + t("routing.tasks.addCustom"));
|
||||||
@@ -201,7 +227,11 @@ export async function renderRouting() {
|
|||||||
saveBtn.disabled = true;
|
saveBtn.disabled = true;
|
||||||
try {
|
try {
|
||||||
const tasks = {};
|
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 });
|
await api.putRouting({ tiers: st.tiers, tasks, default: st.default, policy: st.policy });
|
||||||
toast(t("toast.saved"), "good");
|
toast(t("toast.saved"), "good");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user