diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3e0216b..3aa57fa 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2286,6 +2286,7 @@ dependencies = [ "axum", "base64 0.22.1", "bytes", + "chrono", "dirs", "futures", "http-body-util", @@ -3391,7 +3392,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2f91b29..c08319e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,6 +22,7 @@ tauri-plugin-updater = "2" tauri-plugin-process = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +chrono = { version = "0.4", default-features = false, features = ["std"] } json5 = "1.3" tokio = { version = "1", features = ["full"] } axum = { version = "0.8", features = ["http2", "ws"] } diff --git a/src-tauri/src/proxy.rs b/src-tauri/src/proxy.rs index 5886a76..a86495d 100644 --- a/src-tauri/src/proxy.rs +++ b/src-tauri/src/proxy.rs @@ -225,6 +225,10 @@ struct Turn { key_id: Option, } +fn routed_stats_model(provider: &Provider, upstream_model: &str) -> String { + format!("{}/{}", provider.id, upstream_model) +} + impl Turn { fn new( provider: &str, @@ -1063,6 +1067,11 @@ fn sanitize_stateless_responses_payload(payload: &mut Value) { let Some(input) = payload.get_mut("input").and_then(Value::as_array_mut) else { return; }; + translate::repair_tool_exchange_items(input); + if input.is_empty() { + payload["input"] = Value::String(String::new()); + return; + } for item in input.iter_mut() { if let Some(object) = item.as_object_mut() { object.remove("id"); diff --git a/src-tauri/src/proxy/auth.rs b/src-tauri/src/proxy/auth.rs index ec2084b..7bc4a5f 100644 --- a/src-tauri/src/proxy/auth.rs +++ b/src-tauri/src/proxy/auth.rs @@ -1,22 +1,77 @@ use axum::http::StatusCode; use axum::{body::Body, extract::Request, middleware::Next, response::Response}; +use std::path::Path; use std::sync::OnceLock; // The proxy accepts traffic from any local process, so every endpoint needs a -// process-local secret before it can use a configured provider credential. +// user-local secret before it can use a configured provider credential. static LOCAL_TOKEN: OnceLock = OnceLock::new(); /// Shared secret required on every request to the local proxy. -/// Generated once per process from 32 random bytes (two UUIDv4s = 64 hex -/// chars; `uuid` is the only RNG crate already in Cargo.toml). +/// +/// The token is generated once and persisted under `~/.loomrouter`, so a +/// running Codex keeps the same credentials after LoomRouter restarts. +/// Regenerating it per process only worked while Codex reloaded +/// `config.toml`; an already-open app kept sending the old provider token. +#[cfg(not(test))] pub fn local_token() -> &'static str { - LOCAL_TOKEN.get_or_init(|| { - format!( - "{}{}", - uuid::Uuid::new_v4().simple(), - uuid::Uuid::new_v4().simple() - ) - }) + LOCAL_TOKEN.get_or_init(|| load_or_create_local_token(&local_token_path())) +} + +#[cfg(test)] +pub fn local_token() -> &'static str { + LOCAL_TOKEN.get_or_init(generate_local_token) +} + +#[cfg(not(test))] +fn local_token_path() -> std::path::PathBuf { + crate::config::config_dir().join("local-token") +} + +fn managed_token_from(raw: &str) -> Option { + let managed_start = raw.find(crate::codex::BEGIN_MARK)? + crate::codex::BEGIN_MARK.len(); + let managed_end = raw[managed_start..].find(crate::codex::END_MARK)? + managed_start; + let managed = &raw[managed_start..managed_end]; + let needle = "x-loomrouter-token\" = \""; + let start = managed.find(needle)? + needle.len(); + let end = managed[start..].find('"')?; + Some(managed[start..start + end].to_string()) +} + +fn configured_token() -> Option { + let raw = std::fs::read_to_string(crate::codex::codex_home().join("config.toml")).ok()?; + managed_token_from(&raw) +} + +fn generate_local_token() -> String { + format!( + "{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ) +} + +fn valid_local_token(token: &str) -> bool { + token.len() == 64 && token.chars().all(|c| c.is_ascii_hexdigit()) +} + +fn load_or_create_local_token(path: &Path) -> String { + if let Ok(token) = std::fs::read_to_string(path) { + if valid_local_token(&token) { + return token; + } + } + if let Some(token) = configured_token().filter(|token| valid_local_token(token)) { + if let Err(e) = crate::secure_fs::write_private(path, token.as_bytes()) { + tracing::warn!(path = %path.display(), error = %e, "failed to persist migrated local token"); + } + return token; + } + let token = generate_local_token(); + if let Err(e) = crate::secure_fs::write_private(path, token.as_bytes()) { + tracing::warn!(path = %path.display(), error = %e, "failed to persist local token"); + } + token } /// Constant-time token comparison avoids leaking a valid token prefix through @@ -80,3 +135,42 @@ pub(super) async fn auth_gate(req: Request, next: Next) -> Response { )) .expect("a static unauthorized response is valid") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn persisted_local_token_is_reused() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("local-token"); + + let first = load_or_create_local_token(&path); + let second = load_or_create_local_token(&path); + + assert_eq!(first, second); + assert_eq!(first.len(), 64); + } + + #[test] + fn managed_block_token_is_read_without_leaking_adjacent_text() { + let raw = format!( + "# x-loomrouter-token\" = \"{}\"\n{}\nhttp_headers = {{ \"x-loomrouter-token\" = \"abc\", \"Authorization\" = \"Bearer abc\" }}\n{}", + "0".repeat(64), + crate::codex::BEGIN_MARK, + crate::codex::END_MARK, + ); + assert_eq!(managed_token_from(&raw).as_deref(), Some("abc")); + } + + #[test] + fn token_outside_managed_block_is_ignored() { + let raw = format!( + "x-loomrouter-token\" = \"{}\"\n{}\n# no token here\n{}", + "a".repeat(64), + crate::codex::BEGIN_MARK, + crate::codex::END_MARK, + ); + assert_eq!(managed_token_from(&raw), None); + } +} diff --git a/src-tauri/src/proxy/dispatch.rs b/src-tauri/src/proxy/dispatch.rs index e88f775..80a7511 100644 --- a/src-tauri/src/proxy/dispatch.rs +++ b/src-tauri/src/proxy/dispatch.rs @@ -88,12 +88,13 @@ pub(super) async fn dispatch_routed( wire: WireApi, ) -> anyhow::Result { if is_remote_compaction_v2(payload) { - return dispatch_routed_compaction(ctx, provider, upstream_model, model, payload).await; + return dispatch_routed_compaction(ctx, provider, upstream_model, payload).await; } + let stats_model = super::routed_stats_model(provider, upstream_model); if super::routing::codex_request_kind(payload).as_deref() == Some("compaction") { record_problem( &ctx.stats, - &Turn::new(&provider.id, upstream_model, "http", None), + &Turn::new(&provider.id, &stats_model, "http", None), "compaction", &format!( "{BUILD_LABEL}: Codex sent a compaction call without a compaction_trigger item; treating it as a normal turn" @@ -140,7 +141,7 @@ pub(super) async fn dispatch_routed( return Err(anyhow::Error::new(visual_preparation_failure( &ctx.stats, &provider.id, - model, + &stats_model, "http", started, &error, @@ -168,7 +169,8 @@ pub(super) async fn dispatch_routed( build_upstream(provider, &prepared_payload, upstream_model, wire)?; let (upstream, key_id) = send(ctx, provider, path, &body).await?; - let turn = Turn::new(&provider.id, model, "http", Some(started)).with_key(key_id.as_deref()); + let turn = + Turn::new(&provider.id, &stats_model, "http", Some(started)).with_key(key_id.as_deref()); let status = StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); @@ -345,7 +347,8 @@ async fn dispatch_claude_cli( .and_then(Value::as_bool) .unwrap_or(false); let started = std::time::Instant::now(); - let turn = Turn::new(&provider.id, model, "http", Some(started)); + let stats_model = super::routed_stats_model(provider, upstream_model); + let turn = Turn::new(&provider.id, &stats_model, "http", Some(started)); let downstream_kind = wire.downstream(); let (result, id) = super::run_claude_turn(payload, upstream_model, wire).await?; tracing::debug!(%model, input_tokens = result.input_tokens, output_tokens = result.output_tokens, "claude -p turn finished"); @@ -815,11 +818,11 @@ async fn dispatch_routed_compaction( ctx: &ProxyCtx, provider: &Provider, upstream_model: &str, - model: &str, payload: &Value, ) -> anyhow::Result { let started = std::time::Instant::now(); - let turn = Turn::new(&provider.id, model, "http", Some(started)); + let stats_model = super::routed_stats_model(provider, upstream_model); + let turn = Turn::new(&provider.id, &stats_model, "http", Some(started)); let (summary, usage) = match summarize_compaction(ctx, provider, upstream_model, payload).await { Ok(ok) => ok, @@ -875,17 +878,14 @@ pub(super) async fn routed_compaction_events( upstream_model: &str, payload: &Value, ) -> anyhow::Result { - let model = payload - .get("model") - .and_then(Value::as_str) - .unwrap_or(upstream_model); + let stats_model = super::routed_stats_model(provider, upstream_model); let (summary, usage) = match summarize_compaction(ctx, provider, upstream_model, payload).await { Ok(ok) => ok, Err(error) => { record_problem( &ctx.stats, - &Turn::new(&provider.id, model, "ws", None), + &Turn::new(&provider.id, &stats_model, "ws", None), "compaction", &format!("{BUILD_LABEL}: {error}"), ); diff --git a/src-tauri/src/proxy/realtime.rs b/src-tauri/src/proxy/realtime.rs index 11be7e1..62c8351 100644 --- a/src-tauri/src/proxy/realtime.rs +++ b/src-tauri/src/proxy/realtime.rs @@ -593,7 +593,7 @@ async fn ws_session(socket: WebSocket, ctx: ProxyCtx, headers: HeaderMap) { let error = visual_preparation_failure( &ctx.stats, &provider.id, - &model, + &destination_slug, "ws", turn_start, &error, @@ -622,15 +622,15 @@ async fn ws_session(socket: WebSocket, ctx: ProxyCtx, headers: HeaderMap) { // need to be re-created on every loop iteration. let turn_fut = ws_turn_events(&ctx, &headers, payload); tokio::pin!(turn_fut); - let (mut events, final_provider, key_id) = loop { + let (mut events, final_provider, key_id, stats_model) = loop { tokio::select! { result = &mut turn_fut => { match result { Ok(v) => break v, - Err((e, final_provider, key_id)) => { + Err((e, final_provider, key_id, stats_model)) => { record_failure( &ctx.stats, - &Turn::new(&final_provider, &model, "ws", Some(turn_start)) + &Turn::new(&final_provider, &stats_model, "ws", Some(turn_start)) .with_key(key_id.as_deref()), &e.to_string(), ); @@ -706,7 +706,7 @@ async fn ws_session(socket: WebSocket, ctx: ProxyCtx, headers: HeaderMap) { // Canonical Responses frames on this transport. record_payload_usage( &ctx.stats, - &Turn::new(&final_provider, &model, "ws", Some(turn_start)) + &Turn::new(&final_provider, &stats_model, "ws", Some(turn_start)) .with_key(key_id.as_deref()), UpstreamKind::Responses, v, @@ -790,31 +790,42 @@ fn ws_error_frame(status: u16, message: &str) -> Value { /// Run one turn and return a stream of Responses event objects ready to be /// sent as WS text frames. pub(super) type WsEvents = futures::stream::BoxStream<'static, Result>; -type LabeledWsEvents = - Result<(WsEvents, String, Option), (anyhow::Error, String, Option)>; +type LabeledWsEvents = Result< + (WsEvents, String, Option, String), + (anyhow::Error, String, Option, String), +>; fn label_ws_events( result: anyhow::Result, provider_id: String, key_id: Option, + stats_model: String, ) -> LabeledWsEvents { result - .map(|events| (events, provider_id.clone(), key_id.clone())) - .map_err(|error| (error, provider_id, key_id)) + .map(|events| { + ( + events, + provider_id.clone(), + key_id.clone(), + stats_model.clone(), + ) + }) + .map_err(|error| (error, provider_id, key_id, stats_model)) } async fn ws_turn_events(ctx: &ProxyCtx, headers: &HeaderMap, payload: Value) -> LabeledWsEvents { let model = payload .get("model") .and_then(Value::as_str) + .map(str::to_string) .ok_or_else(|| { ( anyhow!("missing 'model' field"), "codex-native".to_string(), None, + String::new(), ) - })? - .to_string(); + })?; let route = { let cfg = ctx.config.read().await; @@ -826,6 +837,7 @@ async fn ws_turn_events(ctx: &ProxyCtx, headers: &HeaderMap, payload: Value) -> ws_native_events(ctx, headers, payload).await, "codex-native".to_string(), None, + model.clone(), ), EffectiveRoute::Routed { provider, @@ -842,11 +854,15 @@ async fn ws_turn_events(ctx: &ProxyCtx, headers: &HeaderMap, payload: Value) -> ws_routed_events(ctx, &provider, &upstream_model, &model, &payload).await }; let attempt = match attempt { - Ok((events, key_id)) => return label_ws_events(Ok(events), provider.id, key_id), + Ok((events, key_id)) => { + let stats_model = super::routed_stats_model(&provider, &upstream_model); + return label_ws_events(Ok(events), provider.id, key_id, stats_model); + } Err(error) => Err(error), }; if !from_fallback { - return label_ws_events(attempt, provider.id, None); + let stats_model = super::routed_stats_model(&provider, &upstream_model); + return label_ws_events(attempt, provider.id, None, stats_model); } // A failed fallback must never break a side call: retry against // the request's original destination (same rule as HTTP). @@ -870,15 +886,19 @@ async fn ws_turn_events(ctx: &ProxyCtx, headers: &HeaderMap, payload: Value) -> } else { ws_routed_events(ctx, &p, &upstream_model, &model, &payload).await }; + let stats_model = super::routed_stats_model(&p, &upstream_model); match retry { - Ok((events, key_id)) => label_ws_events(Ok(events), p.id, key_id), - Err(error) => label_ws_events(Err(error), p.id, None), + Ok((events, key_id)) => { + label_ws_events(Ok(events), p.id, key_id, stats_model.clone()) + } + Err(error) => label_ws_events(Err(error), p.id, None, stats_model), } } Err(_) => label_ws_events( ws_native_events(ctx, headers, payload).await, "codex-native".to_string(), None, + model.clone(), ), } } @@ -915,6 +935,7 @@ async fn ws_routed_events( futures::stream::BoxStream<'static, Result>, Option, )> { + let stats_model = super::routed_stats_model(provider, upstream_model); if super::dispatch::is_remote_compaction_v2(payload) { return super::dispatch::routed_compaction_events(ctx, provider, upstream_model, payload) .await @@ -923,7 +944,7 @@ async fn ws_routed_events( if super::routing::codex_request_kind(payload).as_deref() == Some("compaction") { record_problem( &ctx.stats, - &Turn::new(&provider.id, upstream_model, "ws", None), + &Turn::new(&provider.id, &stats_model, "ws", None), "compaction", &format!( "{BUILD_LABEL}: Codex sent a compaction call without a compaction_trigger item; treating it as a normal turn" @@ -969,6 +990,7 @@ async fn ws_claude_cli_events( model: &str, payload: &Value, ) -> anyhow::Result>> { + let stats_model = super::routed_stats_model(provider, upstream_model); if super::dispatch::is_remote_compaction_v2(payload) { return super::dispatch::routed_compaction_events(ctx, provider, upstream_model, payload) .await; @@ -976,7 +998,7 @@ async fn ws_claude_cli_events( if super::routing::codex_request_kind(payload).as_deref() == Some("compaction") { record_problem( &ctx.stats, - &Turn::new(&provider.id, upstream_model, "ws", None), + &Turn::new(&provider.id, &stats_model, "ws", None), "compaction", &format!( "{BUILD_LABEL}: Codex sent a compaction call without a compaction_trigger item; treating it as a normal turn" diff --git a/src-tauri/src/proxy/tests_keys.rs b/src-tauri/src/proxy/tests_keys.rs index 7a626c3..8682ae7 100644 --- a/src-tauri/src/proxy/tests_keys.rs +++ b/src-tauri/src/proxy/tests_keys.rs @@ -448,6 +448,35 @@ async fn it_011_dispatch_records_the_serving_key_id() { assert_eq!(summary.per_key[0].requests, 1); } +#[tokio::test] +async fn it_013_routed_logs_record_the_actual_upstream_model() { + let upstream = TestUpstream { + statuses: std::collections::HashMap::new(), + hits: Arc::new(Mutex::new(std::collections::HashMap::new())), + }; + let url = spawn_test_upstream(upstream).await; + let ctx = test_ctx(KeyPools::new()); + let provider = keyed_provider(format!("{url}/v1"), vec![key("key-a", "secret-a")], false); + let payload = json!({"model": "gpt-5.6-luna", "input": "hi", "stream": false}); + + let response = dispatch_routed( + &ctx, + &provider, + "deepseek-v4-flash", + "gpt-5.6-luna", + &payload, + WireApi::Responses, + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + + let logged = ctx.stats.read().await.recent(10); + assert_eq!(logged[0].provider, "test"); + assert_eq!(logged[0].model, "test/deepseek-v4-flash"); +} + #[tokio::test] async fn it_012_a_rate_limited_routed_turn_keeps_its_status_and_is_logged() { // Every key rate-limited used to reach the client as a 502 with no row diff --git a/src-tauri/src/proxy/tests_routing.rs b/src-tauri/src/proxy/tests_routing.rs index 6fcad36..cada922 100644 --- a/src-tauri/src/proxy/tests_routing.rs +++ b/src-tauri/src/proxy/tests_routing.rs @@ -327,6 +327,24 @@ fn opencode_go_deepseek_normalizes_an_empty_responses_input() { assert_eq!(body["instructions"], "prewarm"); } +#[test] +fn opencode_go_deepseek_normalizes_input_after_dropping_every_orphan_output() { + let provider = multi_dialect_provider(); + let payload = json!({ + "input": [ + {"type": "function_call_output", "call_id": "orphan-output", "output": "result"} + ], + "instructions": "continue", + "stream": true + }); + + let (_, body, _) = + build_upstream(&provider, &payload, "deepseek-v4-flash", WireApi::Responses).unwrap(); + + assert_eq!(body["input"], ""); + assert_eq!(body["instructions"], "continue"); +} + #[test] fn opencode_go_deepseek_flattens_agent_messages_before_sending_responses() { let provider = multi_dialect_provider(); @@ -424,6 +442,26 @@ fn opencode_go_deepseek_groups_interleaved_calls_before_outputs() { assert_eq!(body["input"][5]["call_id"], "call_2"); } +#[test] +fn opencode_go_deepseek_drops_orphan_tool_output() { + let provider = multi_dialect_provider(); + let payload = json!({ + "input": [ + {"type": "message", "role": "user", "content": "continue"}, + {"type": "function_call_output", "call_id": "orphan-output", "output": "result"} + ], + "stream": true, + "tools": [] + }); + + let (_, body, _) = + build_upstream(&provider, &payload, "deepseek-v4-flash", WireApi::Responses).unwrap(); + + let items = body["input"].as_array().unwrap(); + assert_eq!(items.len(), 1, "{items:?}"); + assert_eq!(items[0]["type"], "message"); +} + #[test] fn opencode_go_deepseek_moves_interleaved_assistant_message_after_tool_output() { let provider = multi_dialect_provider(); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index bd21f5b..a1ea799 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -205,6 +205,8 @@ pub struct QuotaBar { /// 0..100 remaining. pub percent: f64, pub detail: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reset_at: Option, } #[derive(Debug, Clone, Serialize)] @@ -222,6 +224,22 @@ pub struct ProviderBalance { pub error: Option, } +/// Kimi resetTime can omit seconds and the trailing Z; normalize it to a +/// browser-parseable ISO UTC string instead of forwarding a partial timestamp. +fn reset_at_iso(raw: &str) -> Option { + let parsed = chrono::DateTime::parse_from_rfc3339(raw) + .map(|dt| dt.with_timezone(&chrono::Utc)) + .or_else(|_| { + chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%SZ") + .or_else(|_| chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%MZ")) + .or_else(|_| chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S")) + .or_else(|_| chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M")) + .map(|dt| dt.and_utc()) + }) + .ok()?; + Some(parsed.format("%Y-%m-%dT%H:%M:%SZ").to_string()) +} + fn quota_bar(label: &str, detail: &serde_json::Value) -> Option { let parse = |k: &str| { detail @@ -238,24 +256,40 @@ fn quota_bar(label: &str, detail: &serde_json::Value) -> Option { if limit <= 0.0 { return None; } - let reset = detail + let reset_at = detail .get("resetTime") .and_then(serde_json::Value::as_str) - .map(|s| s.chars().take(16).collect::()) - .unwrap_or_default(); + .and_then(reset_at_iso); Some(QuotaBar { label: label.to_string(), percent: (remaining / limit * 100.0).clamp(0.0, 100.0), - detail: format!( - "{} / {} left{}", - remaining as u64, - limit as u64, - if reset.is_empty() { - String::new() - } else { - format!(" · resets {reset}") - } - ), + detail: format!("{} / {} left", remaining as u64, limit as u64), + reset_at, + }) +} + +fn zai_quota_bar(limit: &serde_json::Value) -> Option { + let unit = limit["unit"].as_i64()?; + let number = limit["number"].as_i64()?; + let usage = limit["usage"].as_f64()?; + let remaining = limit["remaining"].as_f64()?; + if usage <= 0.0 { + return None; + } + let label = match (unit, number) { + (3, 5) => "5-hour window".to_string(), + (6, 1) => "Weekly quota".to_string(), + _ => format!("{number} {unit}-unit window"), + }; + let reset_at = limit["nextResetTime"] + .as_i64() + .and_then(chrono::DateTime::from_timestamp_millis) + .map(|time| time.format("%Y-%m-%dT%H:%M:%SZ").to_string()); + Some(QuotaBar { + label, + percent: (remaining / usage * 100.0).clamp(0.0, 100.0), + detail: format!("{} / {} credits left", remaining as u64, usage as u64), + reset_at, }) } @@ -305,6 +339,36 @@ async fn fetch_balance( req }; + if p.id == "zai-coding" { + // The quota endpoint is host-relative under /api/monitor, not under + // the provider base path, and its percentage field is consumed, not + // remaining. + let origin = base + .split_once("/api/") + .map(|(origin, _)| origin) + .unwrap_or(&base); + match get(format!("{origin}/api/monitor/usage/quota/limit")) + .send() + .await + { + Ok(res) if res.status().is_success() => { + result.ok = true; + if let Ok(body) = res.json::().await { + if let Some(limits) = body["data"]["limits"].as_array() { + result.bars = limits + .iter() + .filter(|limit| limit["type"].as_str() == Some("CREDIT_LIMIT")) + .filter_map(zai_quota_bar) + .collect(); + } + } + } + Ok(res) => result.error = Some(format!("quota returned {}", res.status())), + Err(e) => result.error = Some(e.to_string()), + } + return result; + } + match crate::proxy::family_of(&provider) { // Kimi Code quota: weekly allowance + rolling 5-hour window. Only // the Coding Plan endpoint exposes /usages; other Kimi-family @@ -1187,10 +1251,73 @@ mod tests { ) } + async fn zai_balance_probe_handler( + axum::extract::Extension(server): axum::extract::Extension, + headers: axum::http::HeaderMap, + ) -> (axum::http::StatusCode, axum::Json) { + let auth = headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split_whitespace().last()) + .unwrap_or_default(); + if !server.totals.contains_key(auth) { + return ( + axum::http::StatusCode::UNAUTHORIZED, + axum::Json(serde_json::json!({})), + ); + } + ( + axum::http::StatusCode::OK, + axum::Json(serde_json::json!({ + "code": 200, + "success": true, + "data": { + "level": "lite", + "limits": [ + { + "type": "CREDIT_LIMIT", + "unit": 3, + "number": 5, + "usage": 2000, + "currentValue": 57, + "remaining": 1942, + "percentage": 2, + "nextResetTime": 1786588392963_i64 + }, + { + "type": "CREDIT_LIMIT", + "unit": 6, + "number": 1, + "usage": 10000, + "currentValue": 57, + "remaining": 9942, + "percentage": 1, + "nextResetTime": 1787175085997_i64 + }, + { + "type": "TOKENS_LIMIT", + "unit": 1, + "number": 1, + "usage": 10000, + "currentValue": 0, + "remaining": 10000, + "percentage": 0, + "nextResetTime": 1787175085997_i64 + } + ] + } + })), + ) + } + async fn spawn_balance_server(server: BalanceProbeServer) -> String { use axum::routing::get; let app = axum::Router::new() .route("/openrouter/v1/credits", get(balance_probe_handler)) + .route( + "/api/monitor/usage/quota/limit", + get(zai_balance_probe_handler), + ) .layer(axum::Extension(server)); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -1200,8 +1327,9 @@ mod tests { format!("http://{addr}") } - fn balance_provider(base_url: String, keys: Vec) -> AppState { + fn balance_provider_with_id(id: &str, base_url: String, keys: Vec) -> AppState { let mut provider = keyed_provider(keys); + provider.id = id.into(); provider.base_url = base_url; let mut config = AppConfig::default(); config.providers.insert(provider.id.clone(), provider); @@ -1211,6 +1339,10 @@ mod tests { state } + fn balance_provider(base_url: String, keys: Vec) -> AppState { + balance_provider_with_id("test", base_url, keys) + } + #[tokio::test] async fn it_012_per_key_balances_return_rows_with_correct_key_ids() { let url = spawn_balance_server(BalanceProbeServer { @@ -1241,6 +1373,60 @@ mod tests { assert_eq!(balances[1].balance_text.as_deref(), Some("$50.00")); } + #[tokio::test] + async fn it_013_zai_balance_returns_credit_windows() { + let url = spawn_balance_server(BalanceProbeServer { + totals: std::collections::HashMap::from([("zai-secret".to_string(), 0.0)]), + in_flight: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + max_in_flight: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + delay_ms: 0, + }) + .await; + let state = balance_provider_with_id( + "zai-coding", + format!("{url}/api/coding/paas/v4"), + vec![key("key-a", "Alpha", Some("zai-secret"))], + ); + + let balances = state.provider_balances().await; + + assert_eq!(balances.len(), 1); + let balance = &balances[0]; + assert!(balance.ok); + assert_eq!(balance.error, None); + assert_eq!(balance.bars.len(), 2); + assert_eq!(balance.bars[0].label, "5-hour window"); + assert_eq!(balance.bars[1].label, "Weekly quota"); + assert!((balance.bars[0].percent - 97.1).abs() < 0.01); + assert!((balance.bars[1].percent - 99.42).abs() < 0.01); + assert_eq!(balance.bars[0].detail, "1942 / 2000 credits left"); + assert_eq!(balance.bars[1].detail, "9942 / 10000 credits left"); + assert_eq!( + balance.bars[0].reset_at.as_deref(), + Some("2026-08-13T02:33:12Z") + ); + assert_eq!( + balance.bars[1].reset_at.as_deref(), + Some("2026-08-19T21:31:25Z") + ); + } + + #[test] + fn quota_bar_normalizes_partial_reset_time() { + let bar = quota_bar( + "Weekly quota", + &serde_json::json!({ + "limit": 100, + "remaining": 67, + "resetTime": "2026-08-13T02:33Z", + }), + ) + .unwrap(); + + assert_eq!(bar.detail, "67 / 100 left"); + assert_eq!(bar.reset_at.as_deref(), Some("2026-08-13T02:33:00Z")); + } + #[tokio::test] async fn ut_076_provider_balances_returns_one_row_per_api_key() { let url = spawn_balance_server(BalanceProbeServer { diff --git a/src-tauri/src/state/model_discovery.rs b/src-tauri/src/state/model_discovery.rs index d13ab57..429af7f 100644 --- a/src-tauri/src/state/model_discovery.rs +++ b/src-tauri/src/state/model_discovery.rs @@ -231,6 +231,10 @@ fn models_dev_key(provider_id: &str) -> &str { // The preset id follows the provider used by the coding CLI, while // models.dev publishes this catalog as `kimi-for-coding`. "kimi-for-coding" + } else if provider_id == "zai-coding" { + // The coding endpoint advertises the full Z.AI catalog, which + // models.dev publishes as `zai` rather than the provider slug. + "zai" } else { provider_id } @@ -541,6 +545,7 @@ mod tests { } assert_eq!(models_dev_key("openrouter"), "openrouter"); assert_eq!(models_dev_key("kimi-coding"), "kimi-for-coding"); + assert_eq!(models_dev_key("zai-coding"), "zai"); } #[test] diff --git a/src-tauri/src/translate.rs b/src-tauri/src/translate.rs index 93a629a..ec013e4 100644 --- a/src-tauri/src/translate.rs +++ b/src-tauri/src/translate.rs @@ -11,6 +11,7 @@ pub use compaction::{ encode_compaction_summary, COMPACTION_PROMPT, COMPACTION_SUMMARY_PREFIX, OPAQUE_COMPACTION_NOTE, }; +pub(crate) use request::repair_tool_exchange_items; pub use request::{chat_to_anthropic, flatten_agent_messages, responses_to_chat}; pub use response::{ anthropic_to_chat, anthropic_to_responses, apply_namespaces_to_output, diff --git a/src-tauri/src/translate/request.rs b/src-tauri/src/translate/request.rs index 64a4be7..2215aae 100644 --- a/src-tauri/src/translate/request.rs +++ b/src-tauri/src/translate/request.rs @@ -58,6 +58,55 @@ fn flatten_content_parts(content: Option<&mut Value>, part_type: &str) -> usize touched } +/// Drop Responses tool outputs that have no matching call. A context clamp or +/// a lost history entry can leave a `function_call_output` without its +/// `function_call`; Console Go turns that into a Chat `tool` message with no +/// preceding `tool_calls` and rejects the request. +fn is_tool_call_item(item: &Value) -> bool { + matches!( + item.get("type").and_then(Value::as_str), + Some("function_call") | Some("custom_tool_call") + ) +} + +fn is_tool_output_item(item: &Value) -> bool { + matches!( + item.get("type").and_then(Value::as_str), + Some("function_call_output") | Some("custom_tool_call_output") + ) +} + +fn keep_tool_exchange_item( + item: &Value, + seen_calls: &mut BTreeMap, + seen_outputs: &mut BTreeMap, +) -> bool { + let Some(call_id) = item.get("call_id").and_then(Value::as_str) else { + return !is_tool_output_item(item); + }; + if is_tool_call_item(item) { + *seen_calls.entry(call_id.to_string()).or_default() += 1; + return true; + } + if is_tool_output_item(item) { + let seen = seen_outputs.entry(call_id.to_string()).or_default(); + if *seen < seen_calls.get(call_id).copied().unwrap_or(0) { + *seen += 1; + true + } else { + false + } + } else { + true + } +} + +pub(crate) fn repair_tool_exchange_items(items: &mut Vec) { + let mut seen_calls = BTreeMap::new(); + let mut seen_outputs = BTreeMap::new(); + items.retain(|item| keep_tool_exchange_item(item, &mut seen_calls, &mut seen_outputs)); +} + // --------------------------------------------------------------------------- pub fn responses_to_chat(payload: &Value, model: &str, unified_reasoning: bool) -> Result { @@ -85,6 +134,8 @@ pub fn responses_to_chat(payload: &Value, model: &str, unified_reasoning: bool) messages.push(json!({"role": "user", "content": text})); } Some(Value::Array(items)) => { + let mut seen_calls = BTreeMap::new(); + let mut seen_outputs = BTreeMap::new(); // Thinking models require prior reasoning on replay: DeepSeek/Kimi // expect reasoning_content, while MiniMax expects the raw // reasoning_details array. Responses input carries it as reasoning @@ -94,6 +145,9 @@ pub fn responses_to_chat(payload: &Value, model: &str, unified_reasoning: bool) let mut pending_reasoning = String::new(); let mut pending_minimax_details: Option = None; for item in items { + if !keep_tool_exchange_item(item, &mut seen_calls, &mut seen_outputs) { + continue; + } if item.get("type").and_then(Value::as_str) == Some("reasoning") { if let Some(parts) = item.get("summary").and_then(Value::as_array) { for p in parts { diff --git a/src-tauri/src/translate/tests_b.rs b/src-tauri/src/translate/tests_b.rs index 5165831..4f8c9d3 100644 --- a/src-tauri/src/translate/tests_b.rs +++ b/src-tauri/src/translate/tests_b.rs @@ -147,6 +147,43 @@ fn parallel_function_calls_merge_into_one_assistant_message() { assert_eq!(msgs[4]["role"], "assistant"); } +#[test] +fn responses_to_chat_drops_orphan_tool_output() { + // A truncated/lost conversation can replay an output without its call. + // Sending that to Console Go fails with a tool-pairing 400, so the + // translator must drop the incomplete result. + let payload = json!({ + "input": [ + {"role":"user","content":[{"type":"input_text","text":"continue"}]}, + {"type":"function_call_output","call_id":"orphan-output","output":"result"}, + {"role":"assistant","content":[{"type":"output_text","text":"done"}]} + ] + }); + let out = responses_to_chat(&payload, "kimi-k3", false).unwrap(); + let msgs = out["messages"].as_array().unwrap(); + assert_eq!(msgs.len(), 2, "{msgs:?}"); + assert!(msgs + .iter() + .all(|m| m.get("role").and_then(Value::as_str) != Some("tool"))); +} + +#[test] +fn responses_to_chat_drops_tool_output_that_precedes_its_call() { + let payload = json!({ + "input": [ + {"role":"user","content":[{"type":"input_text","text":"continue"}]}, + {"type":"function_call_output","call_id":"late-call","output":"invalid"}, + {"type":"function_call","call_id":"late-call","name":"inspect","arguments":"{}"} + ] + }); + + let out = responses_to_chat(&payload, "kimi-k3", false).unwrap(); + let messages = out["messages"].as_array().unwrap(); + assert!(messages + .iter() + .all(|message| message.get("role").and_then(Value::as_str) != Some("tool"))); +} + #[test] fn interleaved_developer_message_does_not_break_tool_sequence() { // macOS Codex injects a developer (-> system) item between the diff --git a/src/assets/logos/anthropic.svg b/src/assets/logos/anthropic.svg new file mode 100644 index 0000000..fb2a512 --- /dev/null +++ b/src/assets/logos/anthropic.svg @@ -0,0 +1 @@ + diff --git a/src/assets/logos/deepseek.png b/src/assets/logos/deepseek.png new file mode 100644 index 0000000..d2bb649 Binary files /dev/null and b/src/assets/logos/deepseek.png differ diff --git a/src/assets/logos/glm.png b/src/assets/logos/glm.png new file mode 100644 index 0000000..ae79ff8 Binary files /dev/null and b/src/assets/logos/glm.png differ diff --git a/src/assets/logos/grok.png b/src/assets/logos/grok.png new file mode 100644 index 0000000..a50e3fd Binary files /dev/null and b/src/assets/logos/grok.png differ diff --git a/src/assets/logos/hy3.png b/src/assets/logos/hy3.png new file mode 100644 index 0000000..e1d371d Binary files /dev/null and b/src/assets/logos/hy3.png differ diff --git a/src/assets/logos/mimo.png b/src/assets/logos/mimo.png new file mode 100644 index 0000000..624ec66 Binary files /dev/null and b/src/assets/logos/mimo.png differ diff --git a/src/assets/logos/minimax.png b/src/assets/logos/minimax.png new file mode 100644 index 0000000..1cd1649 Binary files /dev/null and b/src/assets/logos/minimax.png differ diff --git a/src/assets/logos/qwen.png b/src/assets/logos/qwen.png new file mode 100644 index 0000000..7397129 Binary files /dev/null and b/src/assets/logos/qwen.png differ diff --git a/src/components/AnalyticsChart.test.tsx b/src/components/AnalyticsChart.test.tsx new file mode 100644 index 0000000..d50eefb --- /dev/null +++ b/src/components/AnalyticsChart.test.tsx @@ -0,0 +1,94 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { AnalyticsChart, paretoFrontier, resolveLabelOverlaps, type ChartPoint } from './AnalyticsChart' + +interface Box { + x: number + y: number + width: number + height: number +} + +const overlaps = (a: Box, b: Box) => + a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y + +describe('paretoFrontier', () => { + it('returns only points not dominated on both cost and latency', () => { + const points = [ + { cost: 10, latency: 100 }, + { cost: 5, latency: 50 }, + { cost: 7, latency: 40 }, + { cost: 4, latency: 80 }, + ] + expect(paretoFrontier(points)).toEqual([3, 1, 2]) + }) + + it('keeps every point when cost and latency trade off monotonically', () => { + const points = [ + { cost: 1, latency: 100 }, + { cost: 2, latency: 80 }, + { cost: 3, latency: 60 }, + ] + expect(paretoFrontier(points)).toEqual([0, 1, 2]) + }) + + it('returns an empty array for no points', () => { + expect(paretoFrontier([])).toEqual([]) + }) +}) + +describe('resolveLabelOverlaps', () => { + it('nudges a label away from an earlier label it would overlap', () => { + const result = resolveLabelOverlaps( + [ + { x: 10, y: 10, width: 20, height: 13 }, + { x: 12, y: 12, width: 20, height: 13 }, + ], + [], + 0, + 100, + 6, + ) + expect(overlaps(result[0], result[1])).toBe(false) + }) + + it('does not treat a label own bubble as an obstacle', () => { + const box = { x: 10, y: 10, width: 20, height: 13 } + const result = resolveLabelOverlaps([box], [box], 0, 100, 6) + expect(result[0]).toEqual(box) + }) +}) + +describe('AnalyticsChart', () => { + const baseProps = { + locale: 'en' as const, + axisCost: 'Avg cost / request (log)', + axisSpeed: 'Speed (faster = up)', + empty: 'No plottable usage yet.', + bubbleLegend: 'Bubble size = requests', + title: 'Cost vs speed by model', + subtitle: 'Each bubble is a model; bigger means more requests.', + frontierLegend: 'Pareto frontier', + } + + it('renders one marker group per point with a logo image or monogram fallback', () => { + const data: ChartPoint[] = [ + { key: 'opencode-go:deepseek-v4-flash', label: 'model-a', sublabel: 'Acme', cost: 1, latencyMs: 100, requests: 10 }, + { key: 'gpt-5.5', label: 'model-b', sublabel: 'Acme', cost: 2, latencyMs: 200, requests: 20 }, + ] + const { container } = render() + + expect(screen.getByTestId('marker-opencode-go:deepseek-v4-flash')).toBeInTheDocument() + expect(screen.getByTestId('marker-gpt-5.5')).toBeInTheDocument() + expect(container.querySelectorAll('image')).toHaveLength(1) + expect(screen.getByText('G')).toBeInTheDocument() + expect(screen.getByText('model-a')).toBeInTheDocument() + }) + + it('renders the empty state without an SVG', () => { + const { container } = render() + + expect(screen.getByText('No plottable usage yet.')).toBeInTheDocument() + expect(container.querySelector('svg')).toBeNull() + }) +}) diff --git a/src/components/AnalyticsChart.tsx b/src/components/AnalyticsChart.tsx new file mode 100644 index 0000000..1087d22 --- /dev/null +++ b/src/components/AnalyticsChart.tsx @@ -0,0 +1,459 @@ +import { useStrings } from '@/i18n' +import type { Locale } from '@/i18n' +import { Card, CardAction, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { modelLogoSrc, modelMonogram } from './modelLogo' + +export interface ChartPoint { + key: string + label: string + sublabel?: string + cost: number + latencyMs: number + requests: number +} + +interface AnalyticsChartProps { + data: ChartPoint[] + locale: Locale + axisCost: string + axisSpeed: string + empty: string + bubbleLegend: string + title?: string + subtitle?: string + frontierLegend?: string +} + +const WIDTH = 720 +const HEIGHT = 460 +const MARGIN = { top: 16, right: 16, bottom: 44, left: 56 } +const MIN_R = 5 +const MAX_R = 18 +// sqrt so bubble area scales linearly with request volume. +const RADIUS_K = (MAX_R - MIN_R) / Math.sqrt(10_000) +const LABEL_CHAR_WIDTH = 6.5 +const LABEL_LINE_HEIGHT = 13 +const LABEL_GAP = 18 +const LABEL_OFFSET_Y = 4 +const LABEL_NUDGE_STEP = 6 + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} + +function fmtLatency(ms: number): string { + return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms` +} + +function truncateModel(label: string): string { + return label.length > 14 ? `${label.slice(0, 13)}...` : label +} + +interface LabelBox { + x: number + y: number + width: number + height: number +} + +function rectsOverlap(a: LabelBox, b: LabelBox): boolean { + return ( + a.x < b.x + b.width && + a.x + a.width > b.x && + a.y < b.y + b.height && + a.y + a.height > b.y + ) +} + +// Nudge label boxes vertically so they avoid each other and every bubble but +// their own, keeping each label as close to its natural spot as possible. +// eslint-disable-next-line react-refresh/only-export-components +export function resolveLabelOverlaps( + labels: LabelBox[], + bubbles: LabelBox[], + minY: number, + maxY: number, + step: number, +): LabelBox[] { + const placed: LabelBox[] = [] + return labels.map((label, index) => { + const otherBubbles = bubbles.filter((_, other) => other !== index) + const bottom = Math.max(minY, maxY - label.height) + let resolved: LabelBox | null = null + for (let distance = 0; distance <= 240; distance += step) { + const offsets = distance === 0 ? [0] : [distance, -distance] + for (const offset of offsets) { + const candidate = { ...label, y: clamp(label.y + offset, minY, bottom) } + if ( + placed.every((box) => !rectsOverlap(candidate, box)) && + otherBubbles.every((box) => !rectsOverlap(candidate, box)) + ) { + resolved = candidate + break + } + } + if (resolved) break + } + const result = resolved ?? { ...label } + placed.push(result) + return result + }) +} + +function niceStep(range: number): number { + const rough = range / 4 + const magnitude = 10 ** Math.floor(Math.log10(rough)) + const normalized = rough / magnitude + const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10 + return step * magnitude +} + +// eslint-disable-next-line react-refresh/only-export-components +export function paretoFrontier(points: { cost: number; latency: number }[]): number[] { + return points + .map((_, index) => index) + .filter((index) => { + const point = points[index] + return !points.some((other, otherIndex) => { + if (index === otherIndex) return false + return ( + other.cost <= point.cost && + other.latency <= point.latency && + (other.cost < point.cost || other.latency < point.latency) + ) + }) + }) + .sort((a, b) => points[a].cost - points[b].cost || points[a].latency - points[b].latency) +} + +export function AnalyticsChart({ + data, + locale, + axisCost, + axisSpeed, + empty, + bubbleLegend, + title = '', + subtitle = '', + frontierLegend = '', +}: AnalyticsChartProps) { + const s = useStrings() + + const legend = ( +
+ + + {bubbleLegend} + + + + {frontierLegend} + +
+ ) + const header = ( + +
+ {title} + {subtitle &&

{subtitle}

} +
+ {frontierLegend ? {legend} : null} +
+ ) + + if (data.length === 0) { + return ( + + {header} + +

{empty}

+
+
+ ) + } + + const currency = new Intl.NumberFormat(locale, { + style: 'currency', + currency: 'USD', + notation: 'compact', + }) + const minCost = Math.min(...data.map((point) => point.cost)) + const maxCost = Math.max(...data.map((point) => point.cost)) + const minLatency = Math.min(...data.map((point) => point.latencyMs)) + const maxLatency = Math.max(...data.map((point) => point.latencyMs)) + const plotW = WIDTH - MARGIN.left - MARGIN.right + const plotH = HEIGHT - MARGIN.top - MARGIN.bottom + + const xRange = Math.log10(maxCost) - Math.log10(minCost) + const xPad = Math.max(xRange * 0.1, 0.1) + const xMin = Math.log10(minCost) - xPad + const xMax = Math.log10(maxCost) + xPad + const x = (cost: number) => + MARGIN.left + ((Math.log10(cost) - xMin) / (xMax - xMin)) * plotW + + const yRange = maxLatency - minLatency + const yPad = Math.max(yRange * 0.1, 1) + const yMin = minLatency - yPad + const yMax = maxLatency + yPad + // Low latency (fast) at top, high latency (slow) at bottom, so "faster = up" matches the axis label. + const y = (latencyMs: number) => + MARGIN.top + ((latencyMs - yMin) / (yMax - yMin)) * plotH + + const frontier = paretoFrontier(data.map((point) => ({ cost: point.cost, latency: point.latencyMs }))) + const radiusFor = (point: ChartPoint) => + clamp(MIN_R + Math.sqrt(point.requests) * RADIUS_K, MIN_R, MAX_R) + const labelPositions = resolveLabelOverlaps( + data.map((point) => { + const label = truncateModel(point.label) + const width = label.length * LABEL_CHAR_WIDTH + return { + x: clamp(x(point.cost) + LABEL_GAP, MARGIN.left, WIDTH - MARGIN.right - width), + y: clamp( + y(point.latencyMs) + LABEL_OFFSET_Y, + MARGIN.top + 8, + HEIGHT - MARGIN.bottom - LABEL_LINE_HEIGHT, + ), + width, + height: LABEL_LINE_HEIGHT, + } + }), + data.map((point) => { + const r = radiusFor(point) + const pad = 2 + return { + x: x(point.cost) - r - pad, + y: y(point.latencyMs) - r - pad, + width: (r + pad) * 2, + height: (r + pad) * 2, + } + }), + MARGIN.top, + HEIGHT - MARGIN.bottom, + LABEL_NUDGE_STEP, + ) + const xTicks: number[] = [] + for (let exp = Math.ceil(Math.log10(minCost)); exp <= Math.floor(Math.log10(maxCost)); exp += 1) { + const value = 10 ** exp + if (value >= minCost && value <= maxCost) xTicks.push(value) + } + + const yStep = yRange > 0 ? niceStep(yRange) : 1 + const yTicks: number[] = [] + for (let value = Math.ceil(minLatency / yStep) * yStep; value <= maxLatency + yStep / 2; value += yStep) { + yTicks.push(Math.round(value)) + } + + const xTickAnchor = (pos: number): 'start' | 'middle' | 'end' => + pos <= MARGIN.left + 20 ? 'start' : pos >= WIDTH - MARGIN.right - 20 ? 'end' : 'middle' + + return ( + + {header} + + + + + + + {data.map((point) => { + const clipId = `badge-${point.key.replace(/[^a-zA-Z0-9_-]/g, '-')}` + return ( + + + + ) + })} + + {xTicks.map((value) => { + const pos = x(value) + return ( + + ) + })} + {yTicks.map((value) => { + const pos = y(value) + return ( + + ) + })} + + + + {xTicks.map((value) => { + const pos = x(value) + return ( + + + + {currency.format(value)} + + + ) + })} + + {yTicks.map((value) => { + const pos = y(value) + return ( + + + + {fmtLatency(value)} + + + ) + })} + + + {axisCost} + + + {axisSpeed} + + + {bubbleLegend} + + + {frontier.length > 1 && ( + `${x(data[index].cost)},${y(data[index].latencyMs)}`) + .join(' ')} + fill="none" + stroke="#ef4444" + strokeWidth={2} + strokeDasharray="5 4" + strokeLinecap="round" + /> + )} + + {data.map((point, index) => { + const cx = x(point.cost) + const cy = y(point.latencyMs) + const radius = radiusFor(point) + const src = modelLogoSrc(point.key) + const clipId = `badge-${point.key.replace(/[^a-zA-Z0-9_-]/g, '-')}` + const label = truncateModel(point.label) + const labelX = labelPositions[index].x + const labelY = labelPositions[index].y + const tooltip = [ + point.label, + point.sublabel ?? '', + currency.format(point.cost), + fmtLatency(point.latencyMs), + `${point.requests} ${s.overview.reqShort}`, + ] + .filter(Boolean) + .join('\n') + return ( + + + {tooltip} + + + + {src ? ( + + ) : ( + + {modelMonogram(point.key)} + + )} + + + {label} + + + ) + })} + + + + ) +} diff --git a/src/components/modelLogo.ts b/src/components/modelLogo.ts new file mode 100644 index 0000000..240c7ba --- /dev/null +++ b/src/components/modelLogo.ts @@ -0,0 +1,41 @@ +const modules = import.meta.glob('../assets/logos/*', { + eager: true, + query: '?url', + import: 'default', +}) + +const LOGOS: Record = {} +for (const [path, mod] of Object.entries(modules)) { + const key = path.split('/').pop()!.replace(/\.(png|svg)$/, '') + LOGOS[key] = mod +} + +const BRAND_PREFIXES: Array<[string, string]> = [ + ['deepseek', 'deepseek'], + ['glm', 'glm'], + ['grok', 'grok'], + ['qwen', 'qwen'], + ['minimax', 'minimax'], + ['mimo', 'mimo'], + ['hy3', 'hy3'], + ['hunyuan', 'hy3'], + ['claude', 'anthropic'], + ['opus', 'anthropic'], + ['sonnet', 'anthropic'], + ['haiku', 'anthropic'], +] + +export function modelLogoSrc(modelId: string): string | null { + const segment = modelId.split(/[/:]/).pop()!.toLowerCase() + for (const [prefix, brand] of BRAND_PREFIXES) { + if (segment.startsWith(prefix)) return LOGOS[brand] ?? null + } + return null +} + +export function modelMonogram(modelId: string): string { + const segment = modelId.split(/[/:]/).pop()?.toLowerCase() ?? '' + if (segment.startsWith('gpt')) return 'G' + if (segment.startsWith('kimi') || segment.startsWith('k3')) return 'K' + return segment.match(/[a-z0-9]/)?.[0]?.toUpperCase() ?? '?' +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 457a7f5..09cb0a4 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -54,6 +54,18 @@ const en = { h24: '24h', d7: '7d', d30: '30d', + tabGeneral: 'General', + tabAnalytics: 'Analytics', + chartTitle: 'Cost per request vs speed by model', + chartSubtitle: + 'Each bubble is a model; bigger means more requests. The dashed line marks the best average cost per request and speed tradeoff.', + frontierLegend: 'Pareto frontier', + chartEmpty: + 'No plottable usage yet. Send a few requests and pick a model to see average cost and speed.', + axisCost: 'Avg cost / request (log)', + axisSpeed: 'Speed (faster = up)', + bubbleLegend: 'Bubble size = requests', + resetsAt: 'resets', requests: 'Requests', inputTokens: 'Input tokens', outputTokens: 'Output tokens', @@ -120,6 +132,7 @@ const en = { claudeCliMissing: 'claude CLI not found on PATH', claudeNoKey: 'No API key needed - this provider uses the login of your local Claude Code CLI.', keys: 'API keys', + modelsLabel: 'Models', addKey: 'Add key', keyName: 'Key name', keyValue: 'API key', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 12b64f3..54c7388 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -53,6 +53,18 @@ const es: DeepPartial = { setupMissingModel: 'Activar un modelo', dismissSetupBanner: 'Descartar recordatorio de configuración', today: 'Hoy', + tabGeneral: 'General', + tabAnalytics: 'Análisis', + chartTitle: 'Coste por petición vs velocidad por modelo', + chartSubtitle: + 'Cada burbuja es un modelo; más grande significa más peticiones. La línea discontinua marca el mejor equilibrio de coste medio por petición y velocidad.', + frontierLegend: 'Frontera de Pareto', + chartEmpty: + 'Aún no hay uso graficable. Envía algunas peticiones y elige un modelo para ver coste medio y velocidad.', + axisCost: 'Coste medio / petición (log)', + axisSpeed: 'Velocidad (más rápido = arriba)', + bubbleLegend: 'Tamaño de la burbuja = peticiones', + resetsAt: 'se reinicia el', requests: 'Solicitudes', inputTokens: 'Tokens de entrada', outputTokens: 'Tokens de salida', @@ -116,6 +128,7 @@ const es: DeepPartial = { claudeCliMissing: 'claude CLI no encontrado en el PATH', claudeNoKey: 'No se necesita clave de API - este proveedor usa el inicio de sesión de tu Claude Code CLI local.', keys: 'Claves de API', + modelsLabel: 'Modelos', addKey: 'Agregar clave', keyName: 'Nombre de la clave', keyValue: 'Clave de API', diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts index d84d5be..30bfeba 100644 --- a/src/i18n/pt.ts +++ b/src/i18n/pt.ts @@ -52,6 +52,18 @@ const pt: DeepPartial = { setupMissingModel: 'Ativar um modelo', dismissSetupBanner: 'Dispensar lembrete de configuração', today: 'Hoje', + tabGeneral: 'Geral', + tabAnalytics: 'Análise', + chartTitle: 'Custo por requisição vs velocidade por modelo', + chartSubtitle: + 'Cada bolha é um modelo; maior significa mais requisições. A linha tracejada marca a melhor relação de custo médio por requisição e velocidade.', + frontierLegend: 'Fronteira de Pareto', + chartEmpty: + 'Ainda não há uso plotável. Envie algumas requisições e selecione um modelo para ver custo médio e velocidade.', + axisCost: 'Custo médio / requisição (log)', + axisSpeed: 'Velocidade (mais rápido = cima)', + bubbleLegend: 'Tamanho da bolha = requisições', + resetsAt: 'reinicia em', requests: 'Requisições', inputTokens: 'Tokens de entrada', outputTokens: 'Tokens de saída', @@ -115,6 +127,7 @@ const pt: DeepPartial = { claudeCliMissing: 'claude CLI não encontrado no PATH', claudeNoKey: 'Sem chave de API - este provedor usa o login do seu Claude Code CLI local.', keys: 'Chaves de API', + modelsLabel: 'Modelos', addKey: 'Adicionar chave', keyName: 'Nome da chave', keyValue: 'Chave de API', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index c3c17a9..e7359d6 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -54,6 +54,16 @@ const zh: DeepPartial = { h24: '24 小时', d7: '7 天', d30: '30 天', + tabGeneral: '总览', + tabAnalytics: '分析', + chartTitle: '每次请求成本与速度对比', + chartSubtitle: '每个气泡代表一个模型; 越大表示请求越多。虚线标记每次请求平均成本与速度的最佳平衡。', + frontierLegend: '帕累托前沿', + chartEmpty: '暂无可绘制的用量。发送一些请求并选择模型即可查看平均成本与速度。', + axisCost: '平均成本 / 请求 (log)', + axisSpeed: '速度 (越快越靠上)', + bubbleLegend: '气泡大小 = 请求数', + resetsAt: '重置于', requests: '请求数', inputTokens: '输入 token', outputTokens: '输出 token', @@ -116,6 +126,7 @@ const zh: DeepPartial = { claudeCliMissing: '在 PATH 中未找到 claude CLI', claudeNoKey: '无需 API 密钥 - 此提供商使用本地 Claude Code CLI 的登录状态。', keys: 'API 密钥', + modelsLabel: '模型', addKey: '添加密钥', keyName: '密钥名称', keyValue: 'API 密钥', diff --git a/src/lib/api.ts b/src/lib/api.ts index e66658d..a64bc43 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -551,7 +551,7 @@ function mock(cmd: string, args?: Record): Promise { key_name: 'Principal', ok: true, bars: [ - { label: 'Weekly quota', percent: 67, detail: '67 / 100 left · resets 2026-03-08T09:20' }, + { label: 'Weekly quota', percent: 67, detail: '67 / 100 left', reset_at: '2026-03-08T09:20:00Z' }, { label: '5-hour window', percent: 93, detail: '93 / 100 left' }, ], balance_text: null, @@ -563,7 +563,7 @@ function mock(cmd: string, args?: Record): Promise { key_name: 'Work', ok: true, bars: [ - { label: 'Weekly quota', percent: 34, detail: '34 / 100 left · resets 2026-03-08T09:20' }, + { label: 'Weekly quota', percent: 34, detail: '34 / 100 left', reset_at: '2026-03-08T09:20:00Z' }, ], balance_text: null, error: null, diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts index 74d00df..e702513 100644 --- a/src/lib/utils.test.ts +++ b/src/lib/utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { formatContextWindow } from './utils' +import { avgCostPerRequest, formatContextWindow } from './utils' describe('formatContextWindow', () => { it('keeps 1M-class windows readable', () => { @@ -32,3 +32,13 @@ describe('formatContextWindow', () => { expect(formatContextWindow(163_000)).toBe('163K') }) }) + +describe('avgCostPerRequest', () => { + it('divides aggregate cost by successful request count', () => { + expect(avgCostPerRequest(3.34, 759)).toBeCloseTo(0.0044, 4) + }) + + it('returns zero instead of dividing by no requests', () => { + expect(avgCostPerRequest(1, 0)).toBe(0) + }) +}) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index f856ad9..ce0f5a9 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -27,3 +27,7 @@ export function formatContextWindow(window: number): string { if (Number.isInteger(binary)) return `${binary}K` return `${Math.round(decimal)}K` } + +export function avgCostPerRequest(costUsd: number, requests: number): number { + return requests > 0 ? costUsd / requests : 0 +} diff --git a/src/pages/Overview.test.tsx b/src/pages/Overview.test.tsx index f4cb8ae..47196df 100644 --- a/src/pages/Overview.test.tsx +++ b/src/pages/Overview.test.tsx @@ -3,7 +3,8 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter, Route, Routes } from 'react-router' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { setLocale } from '@/i18n' import type { ProviderBalance, SetupStatus, StatsSummary } from '@/types' let setupStatus: SetupStatus = { @@ -59,6 +60,7 @@ const renderOverview = () => ) beforeEach(() => { + setLocale('en') setupStatus = { ready: true, missing: [], @@ -87,6 +89,8 @@ beforeEach(() => { } }) +afterEach(() => setLocale('en')) + describe('Overview setup banner', () => { it('UT-081 renders no banner when setup is ready', async () => { renderOverview() @@ -239,6 +243,38 @@ describe('Overview per-key dashboard', () => { expect(screen.getAllByText(/not reported/i).length).toBeGreaterThan(0) }) + it('formats quota reset_at with the active locale', async () => { + setLocale('pt') + mockBalances = [ + { + provider_id: 'acme', + key_id: 'key-a', + key_name: 'Alpha', + ok: true, + bars: [ + { + label: 'Weekly quota', + percent: 67, + detail: '67 / 100 left', + reset_at: '2026-08-13T02:33:12Z', + }, + ], + balance_text: null, + error: null, + }, + ] + + renderOverview() + + const resetAt = new Intl.DateTimeFormat('pt-BR', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(new Date('2026-08-13T02:33:12Z')) + expect(await screen.findByText(`67 / 100 left · reinicia em ${resetAt}`)).toBeInTheDocument() + }) + it('E2E-002 shows the primary key attribution after routing', async () => { mockConfig = { port: 4180, @@ -389,3 +425,121 @@ describe('Overview per-key dashboard', () => { expect(screen.queryByText(/sk-secret/i)).not.toBeInTheDocument() }) }) + +describe('Overview Analytics tab', () => { + it('shows the empty state when stats has no plottable models', async () => { + renderOverview() + await screen.findByText(/no requests in this period/i) + await userEvent.click(screen.getByRole('tab', { name: 'Analytics' })) + + expect(await screen.findByText(/no plottable usage yet/i)).toBeInTheDocument() + }) + + it('plots average cost per request instead of aggregate cost', async () => { + mockStats = { + period_secs: 86_400, + requests: 10, + input_tokens: 1000, + output_tokens: 100, + cached_tokens: 0, + cache_ratio: 0, + cost_usd: 2, + per_provider: [ + { + provider: 'opencode-go', + requests: 10, + input_tokens: 1000, + output_tokens: 100, + cached_tokens: 0, + cost_usd: 2, + models: [ + { + model: 'opencode-go/deepseek-v4-flash', + requests: 10, + errors: 0, + input_tokens: 1000, + output_tokens: 100, + cached_tokens: 0, + cache_ratio: 0, + avg_latency_ms: 1000, + cost_usd: 2, + }, + ], + }, + ], + per_key: [], + } + const { container } = renderOverview() + await screen.findByText('opencode-go/deepseek-v4-flash') + await userEvent.click(screen.getByRole('tab', { name: 'Analytics' })) + + await screen.findByText('Avg cost / request (log)') + const tooltip = container.querySelector('circle title')?.textContent ?? '' + expect(tooltip).toContain('$0.2') + }) + + it('merges the same model served by multiple providers into one bubble', async () => { + mockStats = { + period_secs: 86_400, + requests: 15, + input_tokens: 0, + output_tokens: 0, + cached_tokens: 0, + cache_ratio: 0, + cost_usd: 3, + per_provider: [ + { + provider: 'opencode-go', + requests: 10, + input_tokens: 0, + output_tokens: 0, + cached_tokens: 0, + cost_usd: 2, + models: [ + { + model: 'opencode-go/deepseek-v4-flash', + requests: 10, + errors: 0, + input_tokens: 0, + output_tokens: 0, + cached_tokens: 0, + cache_ratio: 0, + avg_latency_ms: 1000, + cost_usd: 2, + }, + ], + }, + { + provider: 'opencode-zen', + requests: 5, + input_tokens: 0, + output_tokens: 0, + cached_tokens: 0, + cost_usd: 1, + models: [ + { + model: 'opencode-zen/deepseek-v4-flash', + requests: 5, + errors: 0, + input_tokens: 0, + output_tokens: 0, + cached_tokens: 0, + cache_ratio: 0, + avg_latency_ms: 500, + cost_usd: 1, + }, + ], + }, + ], + per_key: [], + } + const { container } = renderOverview() + await userEvent.click(screen.getByRole('tab', { name: 'Analytics' })) + await screen.findByText('Avg cost / request (log)') + + expect(container.querySelectorAll('[data-testid="marker-deepseek-v4-flash"]')).toHaveLength(1) + const tooltip = container.querySelector('circle title')?.textContent ?? '' + expect(tooltip).toContain('$0.2') + expect(tooltip).toContain('15') + }) +}) diff --git a/src/pages/Overview.tsx b/src/pages/Overview.tsx index 60368e8..67b6be8 100644 --- a/src/pages/Overview.tsx +++ b/src/pages/Overview.tsx @@ -3,8 +3,8 @@ import { Link } from 'react-router' import { AlertTriangle, CheckCircle2, X, XCircle } from 'lucide-react' import { api } from '@/lib/api' import { useBackendState } from '@/lib/events' -import { useStrings } from '@/i18n' -import { formatContextWindow } from '@/lib/utils' +import { useLocale, useStrings, type Locale } from '@/i18n' +import { avgCostPerRequest, formatContextWindow } from '@/lib/utils' import type { AppConfig, ContextWindow, @@ -16,15 +16,35 @@ import type { } from '@/types' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { AnalyticsChart, type ChartPoint } from '@/components/AnalyticsChart' import PageShell from '@/components/PageShell' import { Badge } from '@/components/ui/badge' import { Progress } from '@/components/ui/progress' -import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' type PeriodKey = 'today' | 'h24' | 'd7' | 'd30' const SETUP_BANNER_DISMISSED_KEY = 'loomrouter.setup-banner.dismissed' +const QUOTA_LOCALE: Record = { + en: 'en', + pt: 'pt-BR', + es: 'es', + zh: 'zh-CN', +} + +function formatResetAt(resetAt: string | null | undefined, locale: Locale): string | null { + if (!resetAt) return null + const date = new Date(resetAt) + if (Number.isNaN(date.getTime())) return null + return new Intl.DateTimeFormat(QUOTA_LOCALE[locale], { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(date) +} + function periodSecs(key: PeriodKey): number { switch (key) { case 'h24': @@ -129,6 +149,7 @@ function KeyRow({ accountLevel: boolean }) { const s = useStrings() + const locale = useLocale() const tokenReported = usage != null && (usage.requests === 0 || usage.input_tokens > 0 || usage.output_tokens > 0) return ( @@ -162,16 +183,21 @@ function KeyRow({ value={usage != null && tokenReported ? fmt(usage.cached_tokens) : s.overview.notReported} /> - {balance.bars.map((bar) => ( -
-
- {bar.label} - {Math.round(bar.percent)}% + {balance.bars.map((bar) => { + const resetAt = formatResetAt(bar.reset_at, locale) + return ( +
+
+ {bar.label} + {Math.round(bar.percent)}% +
+ +

+ {resetAt ? `${bar.detail} · ${s.overview.resetsAt} ${resetAt}` : bar.detail} +

- -

{bar.detail}

-
- ))} + ) + })} {balance.balance_text ? (

{balance.balance_text} @@ -228,6 +254,7 @@ function OverviewSkeleton() { export default function OverviewPage() { const s = useStrings() + const locale = useLocale() const [period, setPeriod] = useState('h24') const [stats, setStats] = useState(null) const [loading, setLoading] = useState(true) @@ -281,6 +308,52 @@ export default function OverviewPage() { return (id: string) => map.get(id) ?? id }, [config, s]) + const chartData = useMemo(() => { + // The stored model is a `namespace/slug`, and the namespace is not always + // the routing provider, so key by the bare slug to collapse the same + // upstream model served through several gateways into one bubble. + const byModel = new Map< + string, + { requests: number; costUsd: number; latencyWeighted: number; providers: Set } + >() + for (const provider of stats?.per_provider ?? []) { + for (const model of provider.models) { + if ( + model.cost_usd == null || + model.cost_usd <= 0 || + model.avg_latency_ms == null || + model.requests === 0 + ) { + continue + } + const label = model.model.slice(model.model.lastIndexOf('/') + 1) + const agg = byModel.get(label) ?? { + requests: 0, + costUsd: 0, + latencyWeighted: 0, + providers: new Set(), + } + agg.requests += model.requests + agg.costUsd += model.cost_usd + agg.latencyWeighted += model.avg_latency_ms * model.requests + agg.providers.add(providerName(provider.provider)) + byModel.set(label, agg) + } + } + const points: ChartPoint[] = [] + for (const [label, agg] of byModel) { + points.push({ + key: label, + label, + sublabel: [...agg.providers].join(', '), + cost: avgCostPerRequest(agg.costUsd, agg.requests), + latencyMs: Math.round(agg.latencyWeighted / agg.requests), + requests: agg.requests, + }) + } + return points + }, [stats, providerName]) + const balanceGroups = useMemo(() => { const groups = new Map() for (const balance of balances) { @@ -369,7 +442,13 @@ export default function OverviewPage() {

)} - {loading ? ( + + + {s.overview.tabGeneral} + {s.overview.tabAnalytics} + + + {loading ? ( ) : ( <> @@ -477,6 +556,28 @@ export default function OverviewPage() { )} + + + {loading ? ( + - - {fetchError &&

{fetchError}

} - - {totalCount > 8 && ( -
- setQuery(e.target.value)} - className="max-w-xs" - /> - {q && ( - - {s.providers.showingCount - .replace('{{shown}}', String(shownCount)) - .replace('{{total}}', String(totalCount))} - + + + + {provider.id !== 'claude-code' && ( + {s.providers.keys} )} -
- )} -
- {visibleModels.map((m) => ( -
+
+
) diff --git a/src/types/index.ts b/src/types/index.ts index 71e42b8..1fcdad1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -273,6 +273,7 @@ export interface QuotaBar { label: string percent: number detail: string + reset_at?: string | null } export interface ProviderBalance {