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:
+51
-5
@@ -408,13 +408,42 @@ 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 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)]
|
||||
#[serde(untagged)]
|
||||
pub enum TierTargets {
|
||||
Rule {
|
||||
#[serde(default)]
|
||||
models: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
tier: Option<String>,
|
||||
},
|
||||
List(Vec<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)]
|
||||
pub struct RoutingPolicy {
|
||||
#[serde(default = "default_mode")]
|
||||
@@ -587,6 +616,12 @@ pub struct StatsCfg {
|
||||
/// Retention window for aggregates/time-series replayed on startup, e.g. `7d`.
|
||||
#[serde(default = "default_stats_retention")]
|
||||
pub retention: String,
|
||||
/// Compact the JSONL file once it exceeds this size: records older than
|
||||
/// `retention` fold into cumulative per-account totals (a small sidecar
|
||||
/// file) and are removed, so per-key accounting survives while the disk
|
||||
/// stays bounded. 0 disables compaction.
|
||||
#[serde(default = "default_stats_max_file_mb")]
|
||||
pub max_file_mb: u64,
|
||||
}
|
||||
impl Default for StatsCfg {
|
||||
fn default() -> Self {
|
||||
@@ -595,6 +630,7 @@ impl Default for StatsCfg {
|
||||
file: default_stats_file(),
|
||||
ring_size: default_stats_ring(),
|
||||
retention: default_stats_retention(),
|
||||
max_file_mb: default_stats_max_file_mb(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -610,6 +646,9 @@ fn default_stats_ring() -> usize {
|
||||
fn default_stats_retention() -> String {
|
||||
"7d".into()
|
||||
}
|
||||
fn default_stats_max_file_mb() -> u64 {
|
||||
16
|
||||
}
|
||||
|
||||
/// Self-warming shadow loop (docs/SELF_WARMING_GATEWAY.md). While a task
|
||||
/// label is not yet promoted, the client is served the CLOUD answer; in
|
||||
@@ -863,6 +902,7 @@ impl Config {
|
||||
}
|
||||
for (tier, targets) in &self.routing.tiers {
|
||||
let list = match targets {
|
||||
TierTargets::Rule { models, .. } => models.clone(),
|
||||
TierTargets::List(v) => v.clone(),
|
||||
TierTargets::One(s) => vec![s.clone()],
|
||||
};
|
||||
@@ -877,11 +917,7 @@ impl Config {
|
||||
}
|
||||
}
|
||||
for (task, targets) in &self.routing.tasks {
|
||||
let list = match targets {
|
||||
TierTargets::List(v) => v.clone(),
|
||||
TierTargets::One(s) => vec![s.clone()],
|
||||
};
|
||||
for id in &list {
|
||||
for id in &targets.models() {
|
||||
if !ids.contains(id.as_str()) {
|
||||
anyhow::bail!(
|
||||
"config: routing.tasks.{} references unknown model id '{}'",
|
||||
@@ -890,6 +926,15 @@ impl Config {
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(tier) = targets.forced_tier() {
|
||||
if !self.routing.tiers.contains_key(tier) {
|
||||
anyhow::bail!(
|
||||
"config: routing.tasks.{}.tier '{}' is not a defined routing tier",
|
||||
task,
|
||||
tier
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -981,6 +1026,7 @@ min_class = { low = "cheap", medium = "mid" }
|
||||
|
||||
let low = back.routing.tiers.get("low").expect("low tier present");
|
||||
match low {
|
||||
TierTargets::Rule { models, .. } => assert_eq!(models, &vec!["local-qwen".to_string()]),
|
||||
TierTargets::List(v) => assert_eq!(v, &vec!["local-qwen".to_string()]),
|
||||
TierTargets::One(s) => assert_eq!(s, "local-qwen"),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user