diff --git a/CHANGELOG.md b/CHANGELOG.md index f5380aaf3..2f5d5fd17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Make offline `scorecard` pricing provider-aware: `turn_end` records carry the + effective route and a non-secret billing surface, runtime exports and + supported aliases ingest cleanly, legacy/unknown routes remain explicitly + unpriced, and route-scoped cache and recorded-time pricing replace model-only + guesses. Historical runtime aggregates use each turn's recorded time; + costless catalog routes fail closed while exact provider-owned hand-price + rows remain available. StepFun PAYG and Step Plan usage now stay distinct + without persisting raw endpoint URLs, so subscription quota is never reported + as token spend (#4335). Completion-only shell, manual-compaction, and purge + events remain visible to `turn_end` observers as explicitly non-model + lifecycle records. This builds on the scorecard introduced by @findshan in + #3388. + ## [0.8.68] - 2026-07-13 Release-candidate notes for the underwater release: the TUI's default shell is diff --git a/config.example.toml b/config.example.toml index 0906d3b38..fb44938e4 100644 --- a/config.example.toml +++ b/config.example.toml @@ -499,12 +499,12 @@ max_subagents = 10 # optional (1-20) # # base_url = "https://api.z.ai/api/paas/v4" # model = "GLM-5.2" # default; GLM-5.1 is the smaller model, GLM-5-Turbo the fast sub-agent sibling -# StepFun / StepFlash direct OpenAI-compatible endpoint (https://platform.stepfun.com) +# StepFun / StepFlash direct OpenAI-compatible endpoint (https://platform.stepfun.ai) [providers.stepfun] # api_key = "YOUR_STEPFUN_API_KEY" # or STEP_API_KEY # base_url = "https://api.stepfun.ai/v1" # or STEP_BASE_URL # # Coding Plan endpoint: -# # base_url = "https://api.stepfun.com/step_plan/v1" +# # base_url = "https://api.stepfun.ai/step_plan/v1" # model = "step-3.7-flash" # or STEPFUN_MODEL / STEP_MODEL # MiniMax direct OpenAI-compatible endpoint (https://platform.minimax.io) diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index b5b2981d9..141f73cce 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -423,6 +423,10 @@ struct LaneArgs { } #[derive(Debug, Subcommand)] +// Clap constructs this command enum once at process startup. Keeping the +// fields inline makes the generated CLI shape explicit; boxing them only to +// reduce this transient value would add indirection without runtime benefit. +#[allow(clippy::large_enum_variant)] enum LaneCommand { /// List known lanes (newest first). List { diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 6faad39b6..99715b9d0 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Make offline `scorecard` pricing provider-aware: `turn_end` records carry the + effective route and a non-secret billing surface, runtime exports and + supported aliases ingest cleanly, legacy/unknown routes remain explicitly + unpriced, and route-scoped cache and recorded-time pricing replace model-only + guesses. Historical runtime aggregates use each turn's recorded time; + costless catalog routes fail closed while exact provider-owned hand-price + rows remain available. StepFun PAYG and Step Plan usage now stay distinct + without persisting raw endpoint URLs, so subscription quota is never reported + as token spend (#4335). Completion-only shell, manual-compaction, and purge + events remain visible to `turn_end` observers as explicitly non-model + lifecycle records. This builds on the scorecard introduced by @findshan in + #3388. + ## [0.8.68] - 2026-07-13 Release-candidate notes for the underwater release: the TUI's default shell is diff --git a/crates/tui/src/compaction.rs b/crates/tui/src/compaction.rs index 61b48535f..248caa809 100644 --- a/crates/tui/src/compaction.rs +++ b/crates/tui/src/compaction.rs @@ -927,6 +927,9 @@ fn truncate_retained_block(label: &str, content: &mut String, max_chars: usize) true } +// A match guard cannot mutably borrow `content`; keeping the mutation inside +// the arm updates both retained representations together without indirection. +#[allow(clippy::collapsible_match)] fn sanitize_retained_messages(mut messages: Vec) -> Vec { for message in &mut messages { for block in &mut message.content { diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 85d241784..491e4549a 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -66,7 +66,7 @@ use crate::working_set::WorkingSet; #[cfg(test)] use super::authority::agent_approval_mode_for_turn; use super::authority::{TurnAuthority, effective_input_policy, shell_policy_for_mode}; -use super::events::{Event, TurnOutcomeStatus}; +use super::events::{Event, TurnOutcomeStatus, TurnRoute}; use super::ops::{ Op, ProviderRuntimeStatus, SessionSnapshot, USER_SHELL_TOOL_ID_PREFIX, UserInputProvenance, }; @@ -1184,6 +1184,8 @@ impl Engine { .tx_event .send(Event::TurnStarted { turn_id: turn_id.clone(), + created_at: chrono::Utc::now(), + route: None, }) .await; @@ -2466,6 +2468,11 @@ impl Engine { // Create turn context first so start event includes a stable turn id. let mut turn = TurnContext::new(self.config.max_steps); self.turn_counter = self.turn_counter.saturating_add(1); + let turn_route = TurnRoute { + provider: provider.unwrap_or(self.api_provider), + model: model.clone(), + auto_model, + }; // Emit turn started event IMMEDIATELY so the UI knows the turn is // active. The snapshot below can take 30+ seconds on slow filesystems @@ -2474,6 +2481,8 @@ impl Engine { .tx_event .send(Event::TurnStarted { turn_id: turn.id.clone(), + created_at: chrono::Utc::now(), + route: Some(turn_route), }) .await; diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 23c807c8f..ed14ec401 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -773,12 +773,20 @@ async fn operate_admission_blocks_unready_nontrivial_but_allows_act_and_trivial( let mut saw_blocker = false; let mut saw_operate_complete = false; + let mut saw_operate_route = false; let mut operate_rx = operate_handle.rx_event.write().await; while let Some(event) = tokio::time::timeout(model_turn_event_timeout(), operate_rx.recv()) .await .expect("timed out waiting for Operate blocker") { match event { + Event::TurnStarted { route, .. } => { + let route = route.expect("model turn route"); + assert_eq!(route.provider, ApiProvider::Deepseek); + assert_eq!(route.model, crate::config::DEFAULT_TEXT_MODEL); + assert!(!route.auto_model); + saw_operate_route = true; + } Event::Error { envelope, .. } => { saw_blocker = true; assert!( @@ -814,6 +822,10 @@ async fn operate_admission_blocks_unready_nontrivial_but_allows_act_and_trivial( saw_blocker, "Operate must surface an actionable readiness blocker" ); + assert!( + saw_operate_route, + "model turns must publish route provenance" + ); assert!( saw_operate_complete, "blocked Operate turn must terminate cleanly" @@ -2953,8 +2965,9 @@ async fn run_shell_command_op_requests_approval_and_executes_shell() { let mut rx = handle.rx_event.write().await; while let Some(event) = rx.recv().await { match event { - Event::TurnStarted { turn_id } => { + Event::TurnStarted { turn_id, route, .. } => { assert!(turn_id.starts_with(USER_SHELL_TOOL_ID_PREFIX)); + assert!(route.is_none()); } Event::ToolCallStarted { id, name, input } => { saw_started = true; diff --git a/crates/tui/src/core/events.rs b/crates/tui/src/core/events.rs index 3f09673e7..dce82de6b 100644 --- a/crates/tui/src/core/events.rs +++ b/crates/tui/src/core/events.rs @@ -5,8 +5,10 @@ use std::{path::PathBuf, sync::Arc}; +use chrono::{DateTime, Utc}; use serde_json::Value; +use crate::config::ApiProvider; use crate::error_taxonomy::ErrorEnvelope; use crate::models::{Message, SystemPrompt, Tool, Usage}; use crate::tools::goal::GoalSnapshot; @@ -22,6 +24,18 @@ pub enum TurnOutcomeStatus { Failed, } +/// Provider/model route resolved for a model-backed turn. +/// +/// Carried with `TurnStarted` so hosts can retain provenance until the matching +/// `TurnComplete` without relying on mutable global selection state. Non-model +/// turns such as composer `!` shell commands use no route. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TurnRoute { + pub provider: ApiProvider, + pub model: String, + pub auto_model: bool, +} + /// Events emitted by the engine to update the UI. #[derive(Debug, Clone)] pub enum Event { @@ -81,7 +95,11 @@ pub enum Event { // === Turn Lifecycle === /// A new turn has started (user sent a message) - TurnStarted { turn_id: String }, + TurnStarted { + turn_id: String, + created_at: DateTime, + route: Option, + }, /// The turn is complete (no more tool calls) TurnComplete { diff --git a/crates/tui/src/cost_status.rs b/crates/tui/src/cost_status.rs index 345f81a01..b37e44974 100644 --- a/crates/tui/src/cost_status.rs +++ b/crates/tui/src/cost_status.rs @@ -36,7 +36,8 @@ fn cell() -> &'static Mutex { /// cost via [`crate::pricing::calculate_turn_cost_estimate_for_provider`] and /// adds it to the pending pool. Cheap; takes a short-lived lock /// and returns. No-op when the provider does not expose authoritative runtime -/// pricing (for example ChatGPT/Codex OAuth) or the model is unknown. +/// pricing (for example ChatGPT/Codex OAuth), needs route provenance that this +/// side channel does not carry (StepFun PAYG vs Plan), or the model is unknown. pub fn report(provider: ApiProvider, model: &str, usage: &Usage) { let Some(cost) = crate::pricing::calculate_turn_cost_estimate_for_provider(provider, model, usage) @@ -127,6 +128,15 @@ mod tests { assert_eq!(drain(), CostEstimate::default()); } + #[test] + fn report_skips_stepfun_without_billing_surface() { + let _g = serial_lock(); + reset_for_tests(); + report(ApiProvider::Stepfun, "step-3.7-flash", &small_usage()); + report(ApiProvider::Openrouter, "step-3.7-flash", &small_usage()); + assert_eq!(drain(), CostEstimate::default()); + } + #[test] fn report_accumulates_across_multiple_calls() { let _g = serial_lock(); diff --git a/crates/tui/src/hooks.rs b/crates/tui/src/hooks.rs index 046417a1e..7fe0cbe51 100644 --- a/crates/tui/src/hooks.rs +++ b/crates/tui/src/hooks.rs @@ -14,6 +14,7 @@ // Note: anyhow is available if needed for future error handling #[allow(unused_imports)] use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::json; use std::collections::HashMap; @@ -613,7 +614,12 @@ pub struct TurnEndTotals { /// Input used to build the structured `turn_end` observer payload. pub struct TurnEndPayloadInput<'a> { pub context: &'a HookContext, - pub turn_id: Option<&'a str>, + pub created_at: DateTime, + pub model_backed: bool, + pub provider: Option<&'a str>, + pub billing_surface: Option<&'a str>, + pub model: Option<&'a str>, + pub turn_id: &'a str, pub status: &'a str, pub error: Option<&'a str>, pub duration: Duration, @@ -1291,7 +1297,11 @@ pub fn turn_end_payload(input: TurnEndPayloadInput<'_>) -> serde_json::Value { "session_id": input.context.session_id.as_deref(), "workspace": input.context.workspace.as_ref().map(|path| path.display().to_string()), "mode": input.context.mode.as_deref(), - "model": input.context.model.as_deref(), + "created_at": input.created_at.to_rfc3339(), + "model_backed": input.model_backed, + "provider": input.provider, + "billing_surface": input.billing_surface, + "model": input.model.or(input.context.model.as_deref()), "turn_id": input.turn_id, "status": input.status, "error": input.error, @@ -1576,7 +1586,12 @@ NOEQUAL line dropped let payload = super::turn_end_payload(TurnEndPayloadInput { context: &context, - turn_id: Some("turn_123"), + created_at: "2026-07-12T10:30:00Z".parse().expect("timestamp"), + model_backed: true, + provider: Some("deepseek"), + billing_surface: Some("test-payg"), + model: Some("deepseek-v4-pro"), + turn_id: "turn_123", status: "completed", error: None, duration: Duration::from_millis(321), @@ -1595,7 +1610,12 @@ NOEQUAL line dropped assert_eq!(payload["session_id"], "sess_test"); assert_eq!(payload["workspace"], "/tmp/codewhale"); assert_eq!(payload["mode"], "agent"); - assert_eq!(payload["model"], "deepseek-v4"); + assert_eq!(payload["created_at"], "2026-07-12T10:30:00+00:00"); + assert_eq!(payload["model_backed"], true); + assert_eq!(payload["provider"], "deepseek"); + assert_eq!(payload["billing_surface"], "test-payg"); + assert!(payload.get("base_url").is_none()); + assert_eq!(payload["model"], "deepseek-v4-pro"); assert_eq!(payload["turn_id"], "turn_123"); assert_eq!(payload["status"], "completed"); assert_eq!(payload["error"], serde_json::Value::Null); @@ -1883,7 +1903,12 @@ printf '%s\n' '{{"text":"stdout is not a mutation contract"}}' let context = submit_context(&dir).with_tokens(15); let payload = super::turn_end_payload(TurnEndPayloadInput { context: &context, - turn_id: Some("turn_observed"), + created_at: "2026-07-12T10:30:00Z".parse().expect("timestamp"), + model_backed: true, + provider: Some("openai"), + billing_surface: None, + model: Some("gpt-5.5"), + turn_id: "turn_observed", status: "completed", error: None, duration: Duration::from_millis(7), @@ -1912,6 +1937,9 @@ printf '%s\n' '{{"text":"stdout is not a mutation contract"}}' serde_json::from_str(&std::fs::read_to_string(out).expect("payload written")) .expect("valid JSON payload"); assert_eq!(captured["event"], "turn_end"); + assert_eq!(captured["created_at"], "2026-07-12T10:30:00+00:00"); + assert_eq!(captured["provider"], "openai"); + assert_eq!(captured["model"], "gpt-5.5"); assert_eq!(captured["turn_id"], "turn_observed"); assert_eq!(captured["totals"]["input_tokens"], 12); assert_eq!(captured["totals"]["output_tokens"], 3); diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 8ecc072fe..b6b838244 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -810,7 +810,12 @@ struct SessionDiagnosticsArgs { #[derive(Args, Debug, Clone)] struct ScorecardArgs { /// JSON file with the recorded turns to score: an array of - /// `{ "turn_id", "model", "usage": {…} }` (the shape the TurnEnd hook emits). + /// `{ "turn_id", "provider", "model", "billing_surface", "usage": {…} }`. + /// `turn_end` hooks emit this route provenance plus `created_at`; persisted + /// runtime exports may instead use `id`, `effective_provider`, + /// `effective_model`, and `effective_billing_surface`. + /// Shell-only hook rows marked `model_backed: false` are excluded. Legacy + /// rows without provider remain readable but their cost is unavailable. #[arg(long, value_name = "FILE")] input: PathBuf, /// Optional baseline scorecard-metrics JSON to compare against. When set, @@ -1664,22 +1669,14 @@ fn run_eval(args: EvalArgs) -> Result<()> { /// when a baseline is supplied and a metric regresses past the threshold, so it /// can be wired as a release gate (#3388). fn run_scorecard(args: ScorecardArgs) -> Result<()> { - use crate::scorecard::{RecordedTurn, Scorecard, ScorecardMetrics, TurnInput}; + use crate::scorecard::{RecordedTurn, Scorecard, ScorecardMetrics}; let raw = std::fs::read_to_string(&args.input) .with_context(|| format!("failed to read scorecard input {}", args.input.display()))?; let recorded: Vec = serde_json::from_str(&raw) .with_context(|| format!("failed to parse scorecard input {}", args.input.display()))?; - let inputs: Vec> = recorded - .iter() - .map(|r| TurnInput { - turn_id: r.turn_id.clone(), - model: r.model.clone(), - usage: &r.usage, - }) - .collect(); - let card = Scorecard::from_turns(&inputs); + let card = Scorecard::from_recorded_turns(&recorded); let regressions = match &args.baseline { Some(path) => { diff --git a/crates/tui/src/oauth.rs b/crates/tui/src/oauth.rs index d74167781..ee5e58ae0 100644 --- a/crates/tui/src/oauth.rs +++ b/crates/tui/src/oauth.rs @@ -431,6 +431,7 @@ mod tests { #[test] fn missing_auth_message_mentions_oauth_checked_locations() { + let _lock = crate::test_support::lock_test_env(); let message = missing_auth_message(); assert!(message.contains("OpenAI Codex OAuth credentials not found")); diff --git a/crates/tui/src/pricing.rs b/crates/tui/src/pricing.rs index a3d87529b..e87c32def 100644 --- a/crates/tui/src/pricing.rs +++ b/crates/tui/src/pricing.rs @@ -6,9 +6,12 @@ //! reliable balance endpoint exists. use chrono::{DateTime, TimeZone, Utc}; -use codewhale_config::pricing::{OfferingPricing, TokenUsage}; +use codewhale_config::pricing::{Currency, OfferingPricing, TokenUsage}; -use crate::config::ApiProvider; +use crate::config::{ + ApiProvider, DEEPSEEK_ALIAS_REPLACEMENT, DEEPSEEK_ALIAS_RETIREMENT_UTC, + DEFAULT_STEPFUN_BASE_URL, DEFAULT_STEPFUN_MODEL, canonical_model_id_for_provider, +}; use crate::models::Usage; /// Cost display currency. @@ -111,6 +114,76 @@ struct ModelPricing { cny: Option, } +pub(crate) const STEPFUN_PAYG_BILLING_SURFACE: &str = "stepfun-payg"; +pub(crate) const STEPFUN_PLAN_BILLING_SURFACE: &str = "stepfun-plan"; +const STEPFUN_PLAN_BASE_URL: &str = "https://api.stepfun.ai/step_plan/v1"; +const LEGACY_STEPFUN_PLAN_BASE_URL: &str = "https://api.stepfun.com/step_plan/v1"; + +/// Reduce a concrete request endpoint to non-secret billing provenance. +/// Unknown/custom endpoints stay unclassified so offline reports fail closed. +pub(crate) fn billing_surface_for_route( + provider: ApiProvider, + base_url: Option<&str>, +) -> Option<&'static str> { + if provider != ApiProvider::Stepfun { + return None; + } + let parsed = reqwest::Url::parse(base_url?.trim()).ok()?; + if parsed.scheme() != "https" + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return None; + } + let default = reqwest::Url::parse(DEFAULT_STEPFUN_BASE_URL).ok()?; + let official_host = default.host_str()?; + let host = parsed.host_str()?; + let path = parsed.path().trim_end_matches('/'); + if parsed.port_or_known_default() != Some(443) { + return None; + } + if host.eq_ignore_ascii_case(official_host) && matches!(path, "" | "/v1") { + Some(STEPFUN_PAYG_BILLING_SURFACE) + } else if [STEPFUN_PLAN_BASE_URL, LEGACY_STEPFUN_PLAN_BASE_URL] + .iter() + .filter_map(|url| reqwest::Url::parse(url).ok()) + .any(|plan| { + plan.host_str() + .is_some_and(|plan_host| host.eq_ignore_ascii_case(plan_host)) + && matches!(path, "/step_plan" | "/step_plan/v1") + }) + { + Some(STEPFUN_PLAN_BILLING_SURFACE) + } else { + None + } +} + +fn pricing_for_billing_surface( + provider: ApiProvider, + model: &str, + billing_surface: Option<&str>, +) -> Option { + if provider == ApiProvider::Stepfun + && model.trim().eq_ignore_ascii_case(DEFAULT_STEPFUN_MODEL) + && billing_surface + .is_some_and(|surface| surface.eq_ignore_ascii_case(STEPFUN_PAYG_BILLING_SURFACE)) + { + // StepFun standard API pricing (2026-07-13 audit). Step Plan uses a + // separate subscription quota and must never reach this token rate. + // https://platform.stepfun.ai/docs/en/guides/pricing/details + Some(usd_only_pricing(0.04, 0.20, 1.15)) + } else { + None + } +} + +fn route_requires_billing_surface(provider: ApiProvider, model: &str) -> bool { + provider == ApiProvider::Stepfun || model.trim().eq_ignore_ascii_case(DEFAULT_STEPFUN_MODEL) +} + /// Look up pricing for a model name. fn pricing_for_model(model: &str) -> Option { pricing_for_model_at(model, Utc::now()) @@ -123,12 +196,22 @@ pub fn has_pricing_for_model(model: &str) -> bool { } /// Return whether the selected provider route exposes authoritative dollar -/// pricing for this model. ChatGPT/Codex OAuth usage is subscription/account -/// scoped, so the same model id can be priced on the OpenAI API route while -/// remaining intentionally unpriced on the OAuth route. +/// pricing for this model without endpoint provenance. ChatGPT/Codex OAuth is +/// subscription/account scoped, while StepFun needs PAYG-vs-Plan provenance. #[must_use] pub fn has_pricing_for_provider(provider: ApiProvider, model: &str) -> bool { - provider != ApiProvider::OpenaiCodex && has_pricing_for_model(model) + calculate_turn_cost_estimate_for_provider(provider, model, &Usage::default()).is_some() +} + +/// Return whether a provider/model route has authoritative pricing for an +/// already-classified billing surface. +#[must_use] +pub(crate) fn has_pricing_for_billing_surface( + provider: ApiProvider, + model: &str, + billing_surface: Option<&str>, +) -> bool { + pricing_for_billing_surface(provider, model, billing_surface).is_some() } fn pricing_for_model_at(model: &str, now: DateTime) -> Option { @@ -228,7 +311,6 @@ fn known_pricing_for_model(model_lower: &str) -> Option { // rate equals the input rate. // https://developers.openai.com/api/docs/models/gpt-5.5-pro "openai/gpt-5.5-pro" | "gpt-5.5-pro" => Some(usd_only_pricing(30.00, 30.00, 180.00)), - "qwen/qwen3.6-flash" => Some(usd_only_pricing(0.1875, 0.1875, 1.125)), "qwen/qwen3.6-35b-a3b" => Some(usd_only_pricing(0.05, 0.14, 1.00)), "qwen/qwen3.6-max-preview" => Some(usd_only_pricing(1.04, 1.04, 6.24)), @@ -370,32 +452,33 @@ pub fn calculate_turn_cost_from_usage(model: &str, usage: &Usage) -> Option /// Calculate cost from provider usage in both official currencies. #[must_use] +#[cfg(test)] pub fn calculate_turn_cost_estimate_from_usage(model: &str, usage: &Usage) -> Option { let pricing = pricing_for_model_and_usage(model, usage)?; - Some(CostEstimate { + Some(cost_estimate_with_pricing(pricing, usage)) +} + +fn cost_estimate_with_pricing(pricing: ModelPricing, usage: &Usage) -> CostEstimate { + CostEstimate { usd: calculate_turn_cost_from_usage_with_pricing(pricing.usd, usage), cny: pricing .cny .map(|pricing| calculate_turn_cost_from_usage_with_pricing(pricing, usage)) .unwrap_or(0.0), - }) + } } -/// Calculate cost from provider usage when the provider's billing surface is -/// known. ChatGPT/Codex OAuth does not expose authoritative dollar pricing to -/// this runtime, so usage is shown without fabricating a spend estimate. +/// Calculate cost from provider/model usage when that pair identifies a single +/// billing surface. ChatGPT/Codex OAuth has no authoritative API dollar price, +/// while StepFun needs endpoint-derived PAYG-vs-Plan provenance; both stay +/// unpriced here rather than fabricating spend. #[must_use] pub fn calculate_turn_cost_estimate_for_provider( provider: ApiProvider, model: &str, usage: &Usage, ) -> Option { - let billing = if provider == ApiProvider::OpenaiCodex { - crate::route_billing::BillingPresentation::Subscription("Codex OAuth quota") - } else { - crate::route_billing::BillingPresentation::Metered - }; - calculate_turn_cost_estimate_for_route(provider, model, usage, billing) + calculate_turn_cost_estimate_for_provider_at(provider, model, usage, Utc::now()) } /// Calculate cost only for routes that are actually money-metered. OAuth and @@ -411,30 +494,234 @@ pub fn calculate_turn_cost_estimate_for_route( if !billing.shows_money() { return None; } - if usage.prompt_cache_write_tokens.unwrap_or(0) > 0 - && let Some(estimate) = crate::provider_lake::catalog_offering_for_model(provider, model) - .as_ref() - .and_then(|offering| catalog_cost_estimate_from_offering(offering, usage)) + calculate_turn_cost_estimate_for_provider(provider, model, usage) +} + +/// Estimate a turn when endpoint-derived billing provenance is available. +/// StepFun's standard API and Step Plan share provider/model text but not a +/// billing system, so that route fails closed unless the PAYG surface is known. +#[must_use] +#[cfg(test)] +pub(crate) fn calculate_turn_cost_estimate_for_billing_surface( + provider: ApiProvider, + model: &str, + billing_surface: Option<&str>, + usage: &Usage, +) -> Option { + calculate_turn_cost_estimate_for_route_at(provider, model, billing_surface, usage, Utc::now()) +} + +/// Deterministic provider-aware estimate at the turn's recorded time. +#[must_use] +pub(crate) fn calculate_turn_cost_estimate_for_provider_at( + provider: ApiProvider, + model: &str, + usage: &Usage, + recorded_at: DateTime, +) -> Option { + if provider == ApiProvider::OpenaiCodex || route_requires_billing_surface(provider, model) { + return None; + } + let normalized_model = model.trim(); + let model_lower = normalized_model.to_ascii_lowercase(); + let direct_deepseek = matches!( + provider, + ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic + ); + let canonical_model = canonical_model_id_for_provider(provider, normalized_model)?; + let catalog_model = if direct_deepseek + && matches!(model_lower.as_str(), "deepseek-chat" | "deepseek-reasoner") { - return Some(estimate); + let retirement = DateTime::parse_from_rfc3339(DEEPSEEK_ALIAS_RETIREMENT_UTC) + .ok()? + .with_timezone(&Utc); + if recorded_at >= retirement { + return None; + } + DEEPSEEK_ALIAS_REPLACEMENT.to_string() + } else { + canonical_model + }; + + // MiniMax-M3 doubles its published rates above 512K total input. The + // catalog row is necessarily static, so retain the usage-aware first-party + // table for both direct wire protocols after provider/model provenance has + // been canonicalized. + if matches!( + provider, + ApiProvider::Minimax | ApiProvider::MinimaxAnthropic + ) && catalog_model.eq_ignore_ascii_case("minimax-m3") + { + let pricing = pricing_for_model_and_usage(&catalog_model, usage)?; + return Some(cost_estimate_with_pricing(pricing, usage)); + } + + // Direct DeepSeek pricing carries an authoritative CNY row, and Sonnet 5 + // has a recorded-time introductory window that a static catalog row cannot + // represent. These exact first-party routes intentionally override the + // catalog; no other provider/model text match is allowed to do so. + if direct_deepseek + || (provider == ApiProvider::Anthropic + && catalog_model.eq_ignore_ascii_case("claude-sonnet-5")) + { + let pricing = provider_owned_hand_pricing_at(provider, &catalog_model, recorded_at)?; + return Some(cost_estimate_with_pricing(pricing, usage)); + } + + if let Some(offering) = + crate::provider_lake::catalog_offering_for_model(provider, &catalog_model) + && OfferingPricing::from_catalog_offering(&offering).is_some() + { + if let Some(estimate) = + catalog_cost_estimate_for_route(provider, &catalog_model, &offering, usage) + { + return Some(estimate); + } + if catalog_gap_uses_documented_hand_price(provider, &catalog_model, &offering, usage) { + let pricing = provider_owned_hand_pricing_at(provider, &catalog_model, recorded_at)?; + return Some(cost_estimate_with_pricing(pricing, usage)); + } + return None; + } + + // A few first-party rows predate or intentionally omit a Models.dev entry + // (for example OpenAI API `gpt-5-codex`, Arcee `trinity-mini`, and MiniMax + // `minimax-m2.7`). Preserve only an explicit provider-owned allowlist here; + // a costless foreign/catalog route must remain unpriced. + let pricing = provider_owned_hand_pricing_at(provider, &catalog_model, recorded_at)?; + Some(cost_estimate_with_pricing(pricing, usage)) +} + +/// Recorded-time variant with explicit billing-surface provenance. +#[must_use] +pub(crate) fn calculate_turn_cost_estimate_for_route_at( + provider: ApiProvider, + model: &str, + billing_surface: Option<&str>, + usage: &Usage, + recorded_at: DateTime, +) -> Option { + if provider == ApiProvider::Stepfun { + let pricing = pricing_for_billing_surface(provider, model, billing_surface)?; + return Some(cost_estimate_with_pricing(pricing, usage)); + } + if model.trim().eq_ignore_ascii_case(DEFAULT_STEPFUN_MODEL) { + return None; + } + calculate_turn_cost_estimate_for_provider_at(provider, model, usage, recorded_at) +} + +fn provider_owned_hand_pricing_at( + provider: ApiProvider, + model: &str, + recorded_at: DateTime, +) -> Option { + let model_lower = model.trim().to_ascii_lowercase(); + let provider_owns_row = match provider { + ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic => { + matches!( + model_lower.as_str(), + "deepseek-v4-pro" | "deepseek-v4-flash" + ) + } + ApiProvider::Openai => matches!( + model_lower.as_str(), + "gpt-5-codex" + | "gpt-5.3-codex" + | "gpt-5.5" + | "gpt-5.5-pro" + | "gpt-5.6" + | "gpt-5.6-sol" + | "gpt-5.6-terra" + | "gpt-5.6-luna" + ), + ApiProvider::Anthropic => matches!( + model_lower.as_str(), + "claude-opus-4-8" + | "claude-sonnet-4-6" + | "claude-haiku-4-5" + | "claude-fable-5" + | "claude-sonnet-5" + ), + ApiProvider::Zai => matches!(model_lower.as_str(), "glm-5.1" | "glm-5.2" | "glm-5-turbo"), + ApiProvider::Moonshot => { + matches!(model_lower.as_str(), "kimi-k2.6" | "kimi-k2.7-code") + } + ApiProvider::Minimax | ApiProvider::MinimaxAnthropic => { + matches!(model_lower.as_str(), "minimax-m3" | "minimax-m2.7") + } + ApiProvider::Arcee => { + matches!( + model_lower.as_str(), + "trinity-mini" | "trinity-large-thinking" + ) + } + ApiProvider::Meta => model_lower == "muse-spark-1.1", + _ => false, + }; + provider_owns_row + .then(|| pricing_for_model_at(&model_lower, recorded_at)) + .flatten() +} + +/// Whether a failed catalog estimate is missing only a class whose billing is +/// explicitly documented by the provider-owned row. Keep this narrow: a hand +/// row must not fill unrelated catalog gaps (for example an unpublished cache +/// read rate) merely because the model name is known locally. +fn catalog_gap_uses_documented_hand_price( + provider: ApiProvider, + model: &str, + offering: &codewhale_config::catalog::CatalogOffering, + usage: &Usage, +) -> bool { + if provider != ApiProvider::Openai || !model.eq_ignore_ascii_case("gpt-5.5") { + return false; } - calculate_turn_cost_estimate_from_usage(model, usage) + let Some(pricing) = OfferingPricing::from_catalog_offering(offering) else { + return false; + }; + let usage = token_usage_for_pricing(usage); + usage.cache_write > 0 + && pricing.cache_write_per_million.is_none() + && (usage.input == 0 || pricing.input_per_million.is_some()) + && (usage.output == 0 || pricing.output_per_million.is_some()) + && (usage.cache_read == 0 || pricing.cache_read_per_million.is_some()) } -/// Estimate cache-write usage from a sourced catalog row when it publishes the -/// separate write tier. Other usage continues through the legacy table, which -/// retains CNY estimates and compatibility fallbacks. -fn catalog_cost_estimate_from_offering( +/// Estimate usage only from the exact provider offering. Missing prices for a +/// used token class fail closed, except on the two documented first-party +/// routes where cache tokens are explicitly billed at the input rate. +fn catalog_cost_estimate_for_route( + provider: ApiProvider, + model: &str, offering: &codewhale_config::catalog::CatalogOffering, usage: &Usage, ) -> Option { let usage = token_usage_for_pricing(usage); - let pricing = OfferingPricing::from_catalog_offering(offering)?; - if usage.cache_write == 0 || pricing.cache_write_per_million.is_none() { - return None; + let mut pricing = OfferingPricing::from_catalog_offering(offering)?; + let model_lower = model.trim().to_ascii_lowercase(); + let cache_uses_input_rate = matches!( + (provider, model_lower.as_str()), + (ApiProvider::Openai, "gpt-5.5-pro") | (ApiProvider::Arcee, "trinity-large-thinking") + ); + if cache_uses_input_rate { + if usage.cache_read > 0 && pricing.cache_read_per_million.is_none() { + pricing.cache_read_per_million = pricing.input_per_million; + } + if usage.cache_write > 0 && pricing.cache_write_per_million.is_none() { + pricing.cache_write_per_million = pricing.input_per_million; + } } - pricing.estimate_cost(&usage).map(CostEstimate::usd_only) + let amount = pricing.estimate_cost(&usage)?; + match pricing.currency { + Currency::Usd => Some(CostEstimate::usd_only(amount)), + Currency::Cny => Some(CostEstimate { + usd: 0.0, + cny: amount, + }), + Currency::Other(_) => None, + } } /// Project provider-normalized turn usage into canonical billable token @@ -488,6 +775,7 @@ fn calculate_turn_cost_from_usage_with_pricing(pricing: CurrencyPricing, usage: /// when the model's pricing is unknown or the number of cache-hit tokens is /// zero (nothing to save). #[must_use] +#[cfg(test)] pub fn calculate_cache_savings(model: &str, cache_hit_tokens: u32) -> Option { if cache_hit_tokens == 0 { return None; @@ -513,16 +801,36 @@ pub fn calculate_cache_savings(model: &str, cache_hit_tokens: u32) -> Option Option { - if provider == ApiProvider::OpenaiCodex { + if cache_hit_tokens == 0 { return None; } - calculate_cache_savings(model, cache_hit_tokens) + let cached = Usage { + input_tokens: cache_hit_tokens, + prompt_cache_hit_tokens: Some(cache_hit_tokens), + prompt_cache_miss_tokens: Some(0), + ..Usage::default() + }; + let uncached = Usage { + input_tokens: cache_hit_tokens, + prompt_cache_hit_tokens: Some(0), + prompt_cache_miss_tokens: Some(cache_hit_tokens), + ..Usage::default() + }; + let cached = calculate_turn_cost_estimate_for_provider(provider, model, &cached)?; + let uncached = calculate_turn_cost_estimate_for_provider(provider, model, &uncached)?; + Some(CostEstimate { + usd: uncached.usd - cached.usd, + cny: uncached.cny - cached.cny, + }) } /// Format a cost amount for compact display in the chosen currency. @@ -565,6 +873,170 @@ mod tests { assert!(!has_pricing_for_model("deepseek-ai/deepseek-v4-pro")); } + #[test] + fn stepfun_billing_surface_keeps_payg_separate_from_step_plan() { + for base_url in [ + "https://api.stepfun.ai", + "https://api.stepfun.ai/", + "https://api.stepfun.ai/v1", + "https://API.STEPFUN.AI/v1/", + ] { + assert_eq!( + billing_surface_for_route(ApiProvider::Stepfun, Some(base_url)), + Some(STEPFUN_PAYG_BILLING_SURFACE), + "{base_url}" + ); + } + for base_url in [ + "https://api.stepfun.ai/step_plan", + "https://api.stepfun.ai/step_plan/v1/", + "https://api.stepfun.com/step_plan/v1", + ] { + assert_eq!( + billing_surface_for_route(ApiProvider::Stepfun, Some(base_url)), + Some(STEPFUN_PLAN_BILLING_SURFACE), + "{base_url}" + ); + } + for base_url in [ + "http://api.stepfun.ai/v1", + "https://token@api.stepfun.ai/v1", + "https://api.stepfun.ai/v1?account=other", + "https://api.stepfun.ai/STEP_PLAN/v1", + "https://stepfun.example/v1", + ] { + assert_eq!( + billing_surface_for_route(ApiProvider::Stepfun, Some(base_url)), + None, + "{base_url}" + ); + } + assert_eq!( + billing_surface_for_route(ApiProvider::Openrouter, Some(DEFAULT_STEPFUN_BASE_URL)), + None + ); + + let usage = Usage { + input_tokens: 1_000_000, + output_tokens: 500_000, + prompt_cache_hit_tokens: Some(250_000), + ..Default::default() + }; + let payg = calculate_turn_cost_estimate_for_billing_surface( + ApiProvider::Stepfun, + DEFAULT_STEPFUN_MODEL, + Some(STEPFUN_PAYG_BILLING_SURFACE), + &usage, + ) + .expect("standard StepFun API has an authoritative token price"); + assert!((payg.usd - 0.735).abs() < 1e-12); + assert_eq!(payg.cny, 0.0); + + // Provider/model-only callers (background compaction, sub-agents, and + // legacy records) cannot distinguish PAYG from Step Plan and must not + // add either route to spend or savings totals. + assert!( + calculate_turn_cost_estimate_for_provider( + ApiProvider::Stepfun, + DEFAULT_STEPFUN_MODEL, + &usage, + ) + .is_none() + ); + assert!( + calculate_turn_cost_estimate_for_provider_at( + ApiProvider::Stepfun, + DEFAULT_STEPFUN_MODEL, + &usage, + Utc::now(), + ) + .is_none() + ); + assert!( + calculate_cache_savings_for_provider( + ApiProvider::Stepfun, + DEFAULT_STEPFUN_MODEL, + 250_000, + ) + .is_none() + ); + assert!(!has_pricing_for_provider( + ApiProvider::Stepfun, + DEFAULT_STEPFUN_MODEL + )); + + for surface in [None, Some(STEPFUN_PLAN_BILLING_SURFACE)] { + assert!( + calculate_turn_cost_estimate_for_billing_surface( + ApiProvider::Stepfun, + DEFAULT_STEPFUN_MODEL, + surface, + &usage, + ) + .is_none() + ); + } + assert!( + calculate_turn_cost_estimate_for_billing_surface( + ApiProvider::Stepfun, + "step-3.5-flash", + Some(STEPFUN_PAYG_BILLING_SURFACE), + &usage, + ) + .is_none() + ); + for provider in [ + ApiProvider::Openrouter, + ApiProvider::Ollama, + ApiProvider::Custom, + ] { + assert!( + calculate_turn_cost_estimate_for_billing_surface( + provider, + DEFAULT_STEPFUN_MODEL, + Some(STEPFUN_PAYG_BILLING_SURFACE), + &usage, + ) + .is_none(), + "{provider:?}" + ); + assert!( + calculate_turn_cost_estimate_for_provider(provider, DEFAULT_STEPFUN_MODEL, &usage,) + .is_none(), + "{provider:?}" + ); + assert!( + calculate_turn_cost_estimate_for_provider_at( + provider, + DEFAULT_STEPFUN_MODEL, + &usage, + Utc::now(), + ) + .is_none(), + "{provider:?}" + ); + assert!( + calculate_cache_savings_for_provider(provider, DEFAULT_STEPFUN_MODEL, 250_000,) + .is_none(), + "{provider:?}" + ); + assert!( + !has_pricing_for_provider(provider, DEFAULT_STEPFUN_MODEL), + "{provider:?}" + ); + } + + let recorded = calculate_turn_cost_estimate_for_route_at( + ApiProvider::Stepfun, + DEFAULT_STEPFUN_MODEL, + Some(STEPFUN_PAYG_BILLING_SURFACE), + &usage, + Utc::now(), + ) + .expect("recorded PAYG route retains provider-scoped pricing"); + assert_eq!(recorded, payg); + } + #[test] fn catalog_sourced_models_have_usd_pricing() { for (model, input, output) in [ @@ -600,6 +1072,27 @@ mod tests { assert!(calculate_cache_savings("MiniMax-M3", 1).is_none()); } + #[test] + fn provider_scoped_minimax_m3_keeps_usage_tiers_for_both_wire_protocols() { + for provider in [ApiProvider::Minimax, ApiProvider::MinimaxAnthropic] { + for (input_tokens, input_rate) in [(512_000, 0.30), (512_001, 0.60)] { + let usage = Usage { + input_tokens, + ..Usage::default() + }; + let estimate = calculate_turn_cost_estimate_for_provider_at( + provider, + "MiniMax-M3", + &usage, + Utc::now(), + ) + .expect("direct MiniMax route has authoritative pricing"); + let expected = f64::from(input_tokens) / 1_000_000.0 * input_rate; + assert!((estimate.usd - expected).abs() < 1e-12, "{provider:?}"); + } + } + } + #[test] fn minimax_m2_7_preserves_cache_read_and_write_rates() { let pricing = pricing_for_model_at("MiniMax-M2.7", Utc::now()).expect("M2.7 pricing"); @@ -739,12 +1232,191 @@ mod tests { ..Default::default() }; - let estimate = - catalog_cost_estimate_from_offering(&offering, &usage).expect("catalog cost estimate"); + let estimate = catalog_cost_estimate_for_route( + ApiProvider::Anthropic, + "catalog-priced-model", + &offering, + &usage, + ) + .expect("catalog cost estimate"); assert!((estimate.usd - 0.000_382).abs() < 1e-15); assert_eq!(estimate.cny, 0.0); } + #[test] + fn recorded_time_provider_cost_keeps_catalog_cache_write_tier() { + let usage = Usage { + input_tokens: 1_000_000, + output_tokens: 0, + prompt_cache_hit_tokens: Some(0), + prompt_cache_miss_tokens: Some(0), + prompt_cache_write_tokens: Some(1_000_000), + ..Default::default() + }; + + let estimate = calculate_turn_cost_estimate_for_provider_at( + ApiProvider::Openrouter, + "qwen/qwen3.7-plus", + &usage, + Utc::now(), + ) + .expect("provider catalog write price"); + + assert!((estimate.usd - 0.40).abs() < f64::EPSILON); + assert_eq!(estimate.cny, 0.0); + } + + #[test] + fn recorded_time_provider_cost_rejects_foreign_model_ids() { + let usage = Usage { + input_tokens: 1_000, + output_tokens: 100, + ..Default::default() + }; + + assert!( + calculate_turn_cost_estimate_for_provider_at( + ApiProvider::Ollama, + "gpt-5.5", + &usage, + Utc::now(), + ) + .is_none() + ); + } + + #[test] + fn provider_cost_keeps_owned_hand_price_without_catalog_offering() { + let usage = Usage { + input_tokens: 1_000_000, + output_tokens: 0, + ..Default::default() + }; + assert!( + crate::provider_lake::catalog_offering_for_model(ApiProvider::Openai, "gpt-5-codex") + .is_none(), + "regression fixture must exercise the hand-price fallback" + ); + + let estimate = calculate_turn_cost_estimate_for_provider_at( + ApiProvider::Openai, + "gpt-5-codex", + &usage, + Utc::now(), + ) + .expect("OpenAI API owns the hand-priced model"); + + assert!((estimate.usd - 1.25).abs() < f64::EPSILON); + assert_eq!(estimate.cny, 0.0); + assert!(has_pricing_for_provider(ApiProvider::Openai, "gpt-5-codex")); + } + + #[test] + fn provider_hand_price_fills_catalog_missing_used_class() { + let offering = + crate::provider_lake::catalog_offering_for_model(ApiProvider::Openai, "gpt-5.5") + .expect("bundled OpenAI route"); + let catalog_pricing = + OfferingPricing::from_catalog_offering(&offering).expect("catalog pricing"); + assert!(catalog_pricing.cache_write_per_million.is_none()); + let usage = Usage { + input_tokens: 1_000_000, + output_tokens: 0, + prompt_cache_miss_tokens: Some(0), + prompt_cache_write_tokens: Some(1_000_000), + ..Default::default() + }; + + let estimate = calculate_turn_cost_estimate_for_provider_at( + ApiProvider::Openai, + "gpt-5.5", + &usage, + Utc::now(), + ) + .expect("provider hand price supplies the missing cache-write class"); + + assert!((estimate.usd - 5.0).abs() < f64::EPSILON); + assert_eq!(estimate.cny, 0.0); + } + + #[test] + fn provider_cost_does_not_fabricate_price_for_costless_catalog_route() { + let offering = crate::provider_lake::catalog_offering_for_model( + ApiProvider::Openai, + "deepseek-v4-pro", + ) + .expect("bundled OpenAI-compatible route"); + assert!(OfferingPricing::from_catalog_offering(&offering).is_none()); + let usage = Usage { + input_tokens: 1_000_000, + output_tokens: 0, + ..Default::default() + }; + + assert!( + calculate_turn_cost_estimate_for_provider_at( + ApiProvider::Openai, + "deepseek-v4-pro", + &usage, + Utc::now(), + ) + .is_none() + ); + assert!( + calculate_turn_cost_estimate_for_provider( + ApiProvider::Openai, + "deepseek-v4-pro", + &usage, + ) + .is_none() + ); + assert!(!has_pricing_for_provider( + ApiProvider::Openai, + "deepseek-v4-pro" + )); + assert!( + calculate_cache_savings_for_provider( + ApiProvider::Openai, + "deepseek-v4-pro", + 1_000_000, + ) + .is_none() + ); + } + + #[test] + fn recorded_time_provider_cost_bounds_deepseek_compatibility_aliases() { + let usage = Usage { + input_tokens: 1_000, + output_tokens: 100, + ..Default::default() + }; + let before_retirement: DateTime = + "2026-07-24T15:58:59Z".parse().expect("pre-retirement time"); + let at_retirement: DateTime = DEEPSEEK_ALIAS_RETIREMENT_UTC + .parse() + .expect("retirement time"); + + assert!( + calculate_turn_cost_estimate_for_provider_at( + ApiProvider::Deepseek, + "deepseek-chat", + &usage, + before_retirement, + ) + .is_some() + ); + assert!( + calculate_turn_cost_estimate_for_provider_at( + ApiProvider::Deepseek, + "deepseek-reasoner", + &usage, + at_retirement, + ) + .is_none() + ); + } + #[test] fn token_usage_for_pricing_maps_cache_and_reasoning_classes() { let usage = Usage { diff --git a/crates/tui/src/route_billing.rs b/crates/tui/src/route_billing.rs index da477ea87..5875949b0 100644 --- a/crates/tui/src/route_billing.rs +++ b/crates/tui/src/route_billing.rs @@ -79,6 +79,7 @@ pub fn for_route(config: &Config, provider: ApiProvider) -> BillingPresentation let provider_config = config.provider_config_for(provider); match provider { + ApiProvider::Stepfun => stepfun_billing(provider_config), // Z.ai's dedicated Coding endpoint is the GLM Coding Plan route. Its // quota is subscription-backed, so a public API price estimate is not // truthful spend and must not appear as dollars in the UI. @@ -104,6 +105,19 @@ pub fn for_route(config: &Config, provider: ApiProvider) -> BillingPresentation } } +fn stepfun_billing(config: Option<&ProviderConfig>) -> BillingPresentation { + let base_url = config + .and_then(|config| config.base_url.as_deref()) + .unwrap_or(crate::config::DEFAULT_STEPFUN_BASE_URL); + match crate::pricing::billing_surface_for_route(ApiProvider::Stepfun, Some(base_url)) { + Some(crate::pricing::STEPFUN_PAYG_BILLING_SURFACE) => BillingPresentation::Metered, + Some(crate::pricing::STEPFUN_PLAN_BILLING_SURFACE) => { + BillingPresentation::Subscription("StepFun Step Plan quota") + } + _ => BillingPresentation::Unknown, + } +} + fn uses_zai_coding_plan(config: Option<&ProviderConfig>) -> bool { // The configured URL is optional because the Coding Plan endpoint is also // CodeWhale's Z.ai default. A credentials-only `[providers.zai]` entry @@ -138,7 +152,7 @@ pub fn for_child_route( | ApiProvider::Anthropic | ApiProvider::XiaomiMimo | ApiProvider::Zai => BillingPresentation::Subscription("provider quota"), - ApiProvider::Custom => BillingPresentation::Unknown, + ApiProvider::Stepfun | ApiProvider::Custom => BillingPresentation::Unknown, _ => BillingPresentation::Metered, } } @@ -154,7 +168,16 @@ pub fn has_priced_metered_basis( provider: ApiProvider, model: &str, ) -> bool { - billing.shows_money() && crate::pricing::has_pricing_for_provider(provider, model) + billing.shows_money() + && if provider == ApiProvider::Stepfun { + crate::pricing::has_pricing_for_billing_surface( + provider, + model, + Some(crate::pricing::STEPFUN_PAYG_BILLING_SURFACE), + ) + } else { + crate::pricing::has_pricing_for_provider(provider, model) + } } /// Build the truthful usage chip for session surfaces. @@ -401,6 +424,52 @@ mod tests { ); } + #[test] + fn stepfun_payg_shows_money_but_step_plan_stays_subscription_billed() { + let payg_billing = for_route(&Config::default(), ApiProvider::Stepfun); + assert_eq!(payg_billing, BillingPresentation::Metered); + let payg_chip = usage_chip( + payg_billing, + ApiProvider::Stepfun, + crate::config::DEFAULT_STEPFUN_MODEL, + 0.42, + CostCurrency::Usd, + None, + ); + assert_eq!(format_usage_chip(&payg_chip).as_deref(), Some("$0.42")); + + let plan_config = config_with( + ApiProvider::Stepfun, + ProviderConfig { + base_url: Some("https://api.stepfun.ai/step_plan/v1".to_string()), + ..ProviderConfig::default() + }, + ); + let plan_billing = for_route(&plan_config, ApiProvider::Stepfun); + assert_eq!( + plan_billing, + BillingPresentation::Subscription("StepFun Step Plan quota") + ); + let plan_chip = usage_chip( + plan_billing, + ApiProvider::Stepfun, + crate::config::DEFAULT_STEPFUN_MODEL, + 0.42, + CostCurrency::Usd, + None, + ); + assert!(!format_usage_line(&plan_chip).contains('$')); + + assert_eq!( + for_child_route( + ApiProvider::Deepseek, + BillingPresentation::Metered, + ApiProvider::Stepfun, + ), + BillingPresentation::Unknown + ); + } + #[test] fn routed_zai_child_never_claims_api_dollars_without_full_route_config() { assert_eq!( diff --git a/crates/tui/src/runtime_api/tests.rs b/crates/tui/src/runtime_api/tests.rs index 1d91111cc..5b4a699c6 100644 --- a/crates/tui/src/runtime_api/tests.rs +++ b/crates/tui/src/runtime_api/tests.rs @@ -238,6 +238,7 @@ fn messages_from_thread_detail_batches_tool_results() { duration_ms: Some(0), usage: None, effective_provider: None, + effective_billing_surface: None, effective_model: None, error: None, item_ids: vec![ @@ -1705,6 +1706,8 @@ async fn thread_endpoints_expose_lifecycle_contract() -> Result<()> { let _ = tx_event .send(EngineEvent::TurnStarted { turn_id: "mock_lifecycle".to_string(), + created_at: chrono::Utc::now(), + route: None, }) .await; let _ = tx_event @@ -1879,6 +1882,8 @@ async fn events_endpoint_respects_since_seq_cursor() -> Result<()> { let _ = tx_event .send(EngineEvent::TurnStarted { turn_id: "mock_cursor".to_string(), + created_at: chrono::Utc::now(), + route: None, }) .await; let _ = tx_event @@ -2001,6 +2006,8 @@ async fn steer_and_interrupt_endpoints_work_on_active_turn() -> Result<()> { let _ = tx_event .send(EngineEvent::TurnStarted { turn_id: "engine_turn_api".to_string(), + created_at: chrono::Utc::now(), + route: None, }) .await; let _ = tx_event @@ -2218,6 +2225,8 @@ async fn stream_endpoint_remains_backward_compatible() -> Result<()> { let _ = tx_event .send(EngineEvent::TurnStarted { turn_id: "mock_stream".to_string(), + created_at: chrono::Utc::now(), + route: None, }) .await; let _ = tx_event @@ -2812,6 +2821,8 @@ async fn session_create_from_thread_rejects_active_turn() -> Result<()> { let _ = tx_event .send(EngineEvent::TurnStarted { turn_id: "mock_active_session_save".to_string(), + created_at: chrono::Utc::now(), + route: None, }) .await; let _ = tx_event diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 1ed71da74..e368c4abb 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -250,6 +250,10 @@ pub struct TurnRecord { /// deserialize without inventing provider provenance. #[serde(default, skip_serializing_if = "Option::is_none")] pub effective_provider: Option, + /// Non-secret discriminator for routes whose provider/model pair spans + /// different billing systems (for example StepFun PAYG vs Step Plan). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effective_billing_surface: Option, /// Concrete wire model selected for this turn (especially important when /// the thread is configured as `auto`). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1444,8 +1448,12 @@ impl RuntimeThreadManager { let provider = ApiProvider::parse(provider_label); let cost = provider .and_then(|provider| { - crate::pricing::calculate_turn_cost_estimate_for_provider( - provider, model, usage, + crate::pricing::calculate_turn_cost_estimate_for_route_at( + provider, + model, + turn.effective_billing_surface.as_deref(), + usage, + turn.created_at, ) }) .map(|estimate| estimate.usd) @@ -2170,6 +2178,7 @@ impl RuntimeThreadManager { duration_ms: Some(0), usage: None, effective_provider: None, + effective_billing_surface: None, effective_model: None, error: None, item_ids, @@ -2280,6 +2289,7 @@ impl RuntimeThreadManager { duration_ms: None, usage: None, effective_provider: Some(provider.as_str().to_string()), + effective_billing_surface: None, effective_model: Some(model.clone()), error: None, item_ids: Vec::new(), @@ -2574,6 +2584,7 @@ impl RuntimeThreadManager { duration_ms: None, usage: None, effective_provider: Some(route_provider.as_str().to_string()), + effective_billing_surface: None, effective_model: Some(route_model), error: None, item_ids: Vec::new(), @@ -3026,6 +3037,7 @@ impl RuntimeThreadManager { let mut tool_items: HashMap = HashMap::new(); let mut compaction_items: HashMap = HashMap::new(); let mut turn_usage: Option = None; + let mut turn_base_url: Option = None; let mut turn_status = RuntimeTurnStatus::Completed; let mut turn_error: Option = None; let mut saw_engine_activity = false; @@ -3729,9 +3741,11 @@ impl RuntimeThreadManager { usage, status, error, + base_url, .. } => { turn_usage = Some(usage); + turn_base_url = base_url; turn_status = match status { TurnOutcomeStatus::Completed => RuntimeTurnStatus::Completed, TurnOutcomeStatus::Interrupted => RuntimeTurnStatus::Interrupted, @@ -3838,6 +3852,14 @@ impl RuntimeThreadManager { turn.ended_at = Some(ended_at); turn.duration_ms = turn.started_at.map(|start| duration_ms(start, ended_at)); turn.usage = turn_usage; + turn.effective_billing_surface = turn + .effective_provider + .as_deref() + .and_then(ApiProvider::parse) + .and_then(|provider| { + crate::pricing::billing_surface_for_route(provider, turn_base_url.as_deref()) + }) + .map(str::to_string); turn.error = turn_error; self.store.save_turn(&turn)?; diff --git a/crates/tui/src/runtime_threads/tests.rs b/crates/tui/src/runtime_threads/tests.rs index b4b559da3..8767b35b5 100644 --- a/crates/tui/src/runtime_threads/tests.rs +++ b/crates/tui/src/runtime_threads/tests.rs @@ -79,6 +79,7 @@ fn sample_turn(thread_id: &str, turn_id: &str, status: RuntimeTurnStatus) -> Tur duration_ms: None, usage: None, effective_provider: None, + effective_billing_surface: None, effective_model: None, error: None, item_ids: Vec::new(), @@ -113,13 +114,31 @@ fn legacy_turn_record_has_no_invented_route_provenance() { let mut value = serde_json::to_value(turn).expect("serialize turn"); let object = value.as_object_mut().expect("turn object"); object.remove("effective_provider"); + object.remove("effective_billing_surface"); object.remove("effective_model"); let restored: TurnRecord = serde_json::from_value(value).expect("deserialize legacy turn"); assert_eq!(restored.effective_provider, None); + assert_eq!(restored.effective_billing_surface, None); assert_eq!(restored.effective_model, None); } +#[test] +fn turn_record_persists_billing_surface_without_raw_endpoint() { + let mut turn = sample_turn("thr_surface", "turn_surface", RuntimeTurnStatus::Completed); + turn.effective_provider = Some(ApiProvider::Stepfun.as_str().to_string()); + turn.effective_billing_surface = Some(crate::pricing::STEPFUN_PAYG_BILLING_SURFACE.to_string()); + turn.effective_model = Some("step-3.7-flash".to_string()); + + let value = serde_json::to_value(turn).expect("serialize turn"); + assert_eq!( + value["effective_billing_surface"], + crate::pricing::STEPFUN_PAYG_BILLING_SURFACE + ); + assert!(value.get("base_url").is_none()); + assert!(value.get("effective_base_url").is_none()); +} + #[tokio::test] async fn aggregate_usage_keeps_codex_tokens_without_api_dollar_pricing() -> Result<()> { let manager = test_manager(test_runtime_dir())?; @@ -168,6 +187,80 @@ async fn aggregate_usage_keeps_codex_tokens_without_api_dollar_pricing() -> Resu Ok(()) } +#[tokio::test] +async fn aggregate_usage_prices_each_turn_at_its_recorded_time() -> Result<()> { + let manager = test_manager(test_runtime_dir())?; + let mut thread = sample_thread("thr_historical_pricing"); + thread.model = "claude-sonnet-5".to_string(); + manager.store.save_thread(&thread)?; + + let usage = Usage { + input_tokens: 1_000_000, + output_tokens: 0, + ..Usage::default() + }; + for (turn_id, created_at) in [ + ("turn_intro", "2026-08-31T23:59:59Z"), + ("turn_standard", "2026-09-01T00:00:00Z"), + ] { + let mut turn = sample_turn(&thread.id, turn_id, RuntimeTurnStatus::Completed); + turn.created_at = created_at.parse().expect("recorded turn time"); + turn.usage = Some(usage.clone()); + turn.effective_provider = Some(ApiProvider::Anthropic.as_str().to_string()); + turn.effective_model = Some("claude-sonnet-5".to_string()); + manager.store.save_turn(&turn)?; + } + + let report = manager + .aggregate_usage(None, None, UsageGroupBy::Model) + .await?; + + assert_eq!(report.totals.turns, 2); + assert!((report.totals.cost_usd - 5.0).abs() < f64::EPSILON); + assert_eq!(report.buckets.len(), 1); + assert!((report.buckets[0].cost_usd - 5.0).abs() < f64::EPSILON); + Ok(()) +} + +#[tokio::test] +async fn aggregate_usage_prices_only_stepfun_payg_surface() -> Result<()> { + let manager = test_manager(test_runtime_dir())?; + let mut thread = sample_thread("thr_stepfun_surfaces"); + thread.model = "step-3.7-flash".to_string(); + manager.store.save_thread(&thread)?; + + let usage = Usage { + input_tokens: 1_000_000, + output_tokens: 500_000, + prompt_cache_hit_tokens: Some(250_000), + ..Usage::default() + }; + for (turn_id, surface) in [ + ( + "turn_stepfun_payg", + crate::pricing::STEPFUN_PAYG_BILLING_SURFACE, + ), + ( + "turn_stepfun_plan", + crate::pricing::STEPFUN_PLAN_BILLING_SURFACE, + ), + ] { + let mut turn = sample_turn(&thread.id, turn_id, RuntimeTurnStatus::Completed); + turn.usage = Some(usage.clone()); + turn.effective_provider = Some(ApiProvider::Stepfun.as_str().to_string()); + turn.effective_billing_surface = Some(surface.to_string()); + turn.effective_model = Some("step-3.7-flash".to_string()); + manager.store.save_turn(&turn)?; + } + + let report = manager + .aggregate_usage(None, None, UsageGroupBy::Provider) + .await?; + assert_eq!(report.totals.turns, 2); + assert!((report.totals.cost_usd - 0.735).abs() < 1e-12); + Ok(()) +} + fn sample_item(turn_id: &str, item_id: &str, status: TurnItemLifecycleStatus) -> TurnItemRecord { TurnItemRecord { schema_version: CURRENT_RUNTIME_SCHEMA_VERSION, @@ -742,6 +835,8 @@ async fn thread_lifecycle_persists_across_restart() -> Result<()> { let _ = tx_event .send(EngineEvent::TurnStarted { turn_id: "engine_turn_1".to_string(), + created_at: chrono::Utc::now(), + route: None, }) .await; let _ = tx_event @@ -832,6 +927,8 @@ async fn completed_turn_without_engine_output_fails() -> Result<()> { let _ = tx_event .send(EngineEvent::TurnStarted { turn_id: "engine_empty_turn".to_string(), + created_at: chrono::Utc::now(), + route: None, }) .await; let _ = tx_event @@ -1286,6 +1383,8 @@ async fn multi_turn_continuity_same_thread() -> Result<()> { let _ = tx_event .send(EngineEvent::TurnStarted { turn_id: format!("engine_turn_{turn_index}"), + created_at: chrono::Utc::now(), + route: None, }) .await; let _ = tx_event @@ -1486,6 +1585,8 @@ async fn interrupt_turn_marks_interrupted_after_cleanup() -> Result<()> { let _ = tx_event .send(EngineEvent::TurnStarted { turn_id: "engine_turn_interrupt".to_string(), + created_at: chrono::Utc::now(), + route: None, }) .await; let _ = tx_event @@ -2260,6 +2361,8 @@ async fn steer_turn_on_active_turn_records_item_and_event() -> Result<()> { let _ = tx_event .send(EngineEvent::TurnStarted { turn_id: "engine_turn_steer".to_string(), + created_at: chrono::Utc::now(), + route: None, }) .await; if let Some(steer) = rx_steer.recv().await { @@ -2373,6 +2476,8 @@ async fn compaction_lifecycle_emits_item_events_with_compaction_counts() -> Resu let _ = tx_event .send(EngineEvent::TurnStarted { turn_id: "engine_turn_auto".to_string(), + created_at: chrono::Utc::now(), + route: None, }) .await; let _ = tx_event @@ -2641,6 +2746,7 @@ fn opening_manager_recovers_stale_queued_and_in_progress_work() -> Result<()> { duration_ms: None, usage: None, effective_provider: None, + effective_billing_surface: None, effective_model: None, error: None, item_ids: vec![completed_item.id.clone(), in_progress_item.id.clone()], @@ -2658,6 +2764,7 @@ fn opening_manager_recovers_stale_queued_and_in_progress_work() -> Result<()> { duration_ms: None, usage: None, effective_provider: None, + effective_billing_surface: None, effective_model: None, error: None, item_ids: vec![queued_item.id.clone()], @@ -2905,6 +3012,7 @@ fn seed_turns_with_user_messages( duration_ms: Some(0), usage: None, effective_provider: None, + effective_billing_surface: None, effective_model: None, error: None, item_ids: vec![user_item_id, asst_item_id], diff --git a/crates/tui/src/scorecard.rs b/crates/tui/src/scorecard.rs index 0825e44d0..4eff71252 100644 --- a/crates/tui/src/scorecard.rs +++ b/crates/tui/src/scorecard.rs @@ -11,15 +11,31 @@ //! scorecard, reusing the existing pricing layer rather than reinventing cost //! math. The `scorecard` subcommand is a thin I/O wrapper over this module. +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use crate::config::ApiProvider; +#[cfg(test)] +use crate::config::{DEEPSEEK_ALIAS_REPLACEMENT, DEEPSEEK_ALIAS_RETIREMENT_UTC}; use crate::models::Usage; -use crate::pricing::{calculate_turn_cost_estimate_from_usage, token_usage_for_pricing}; +use crate::pricing::{calculate_turn_cost_estimate_for_route_at, token_usage_for_pricing}; /// One turn's normalized token economics. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct TurnScore { pub turn_id: String, + /// Timestamp used for historical/time-window pricing. `None` means the + /// recorder did not preserve when the turn occurred. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option>, + /// Effective provider recorded for this turn. `None` means legacy or + /// otherwise unknown provenance, so cost must remain unpriced. + #[serde(default)] + pub provider: Option, + /// Non-secret discriminator when one provider/model pair spans multiple + /// billing systems. Missing provenance keeps ambiguous routes unpriced. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub billing_surface: Option, pub model: String, /// Non-cached (billable) input tokens. pub input_tokens: u64, @@ -29,15 +45,37 @@ pub struct TurnScore { pub cache_read_tokens: u64, pub cost_usd: f64, pub cost_cny: f64, - /// True when no pricing row exists for `model`: cost is reported as 0 but is - /// not meaningful, so the summary can flag it rather than imply "$0.00". + /// True when provider provenance is missing/unknown or no authoritative USD + /// pricing row exists: numeric cost stays 0 for compatibility, while this + /// flag prevents it from being represented as a real zero-dollar charge. pub cost_unpriced: bool, + /// Same availability marker for CNY. Most catalog offerings publish only + /// USD, so their CNY value is unavailable rather than a real zero. + #[serde(default)] + pub cost_cny_unpriced: bool, } /// Aggregate metrics for a run. Serializes/deserializes as the baseline file. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] pub struct ScorecardMetrics { pub turns: usize, + /// Turns whose provider/model route could not be priced authoritatively in + /// USD. + /// Defaults to zero so existing baseline JSON remains readable. + #[serde(default)] + pub unpriced_turns: usize, + /// Turns without authoritative CNY pricing. + #[serde(default)] + pub cny_unpriced_turns: usize, + /// Whether every turn contributed authoritative USD pricing. Legacy + /// baselines lack this field and therefore default to `false`, preventing + /// comparisons against totals that may have been inferred from model ids + /// alone. + #[serde(default)] + pub cost_complete: bool, + /// Whether every turn contributed authoritative CNY pricing. + #[serde(default)] + pub cny_cost_complete: bool, pub total_input_tokens: u64, pub total_output_tokens: u64, pub total_cache_read_tokens: u64, @@ -67,41 +105,171 @@ pub struct Scorecard { /// One row of input to the scorecard: a turn id, the model that served it, and /// the turn's recorded usage. +#[cfg(test)] pub struct TurnInput<'a> { pub turn_id: String, + pub created_at: Option<&'a DateTime>, + pub provider: Option<&'a str>, pub model: String, pub usage: &'a Usage, } +#[derive(Debug, Clone, Copy)] +struct ScorecardTurnRef<'a> { + turn_id: &'a str, + created_at: Option<&'a DateTime>, + provider: Option<&'a str>, + billing_surface: Option<&'a str>, + model: &'a str, + usage: &'a Usage, +} + /// A recorded turn as read from a scorecard input file (a JSON array of these). -/// Matches the per-turn data the `TurnEnd` hook already emits (`model` + `usage`), -/// so a run's turns can be captured and scored offline. +/// The base shape matches the per-turn data a `TurnEnd` hook emits. Recorders +/// and persisted runtime exports can add `provider` / `effective_provider` plus +/// non-secret billing-surface provenance. Legacy model-only recordings remain +/// readable but deliberately unpriced. #[derive(Debug, Clone, Deserialize)] pub struct RecordedTurn { - #[serde(default)] + #[serde(default, alias = "id")] pub turn_id: String, + #[serde(default)] + pub created_at: Option>, + /// New `turn_end` hooks mark shell-only lifecycle records false so the + /// model-cost scorecard can ignore them. Missing stays compatible with + /// legacy hook rows and persisted runtime turns, which are model-backed. + #[serde(default)] + pub model_backed: Option, + #[serde(default, alias = "effective_provider")] + pub provider: Option, + #[serde(default, alias = "effective_billing_surface")] + pub billing_surface: Option, + #[serde(default, alias = "effective_model")] pub model: String, - pub usage: Usage, + #[serde(default)] + pub usage: Option, +} + +impl RecordedTurn { + #[must_use] + pub fn contributes_to_scorecard(&self) -> bool { + self.model_backed.unwrap_or(true) && self.usage.is_some() && !self.model.trim().is_empty() + } +} + +#[derive(Debug, Clone, Copy, Default)] +struct AvailableCost { + usd: Option, + cny: Option, +} + +fn provider_scoped_cost( + provider: ApiProvider, + model: &str, + usage: &Usage, + created_at: Option<&DateTime>, + billing_surface: Option<&str>, +) -> AvailableCost { + let direct_deepseek = matches!( + provider, + ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic + ); + let normalized_model = model.trim(); + let model_lower = normalized_model.to_ascii_lowercase(); + let needs_recorded_time = (direct_deepseek + && matches!(model_lower.as_str(), "deepseek-chat" | "deepseek-reasoner")) + || (provider == ApiProvider::Anthropic && model_lower == "claude-sonnet-5"); + let recorded_at = match (created_at, needs_recorded_time) { + (Some(recorded_at), _) => recorded_at.to_owned(), + (None, true) => return AvailableCost::default(), + (None, false) => Utc::now(), + }; + + // The pricing layer owns the exact provider/model catalog gate, explicit + // first-party hand-price allowlist, cache-class completeness checks, and + // endpoint-derived StepFun surface. Keeping one route-aware path prevents + // the scorecard from drifting back to model-only pricing. + calculate_turn_cost_estimate_for_route_at( + provider, + normalized_model, + billing_surface, + usage, + recorded_at, + ) + .map_or_else(AvailableCost::default, |cost| AvailableCost { + usd: Some(cost.usd), + cny: direct_deepseek.then_some(cost.cny), + }) } impl Scorecard { /// Build a scorecard from recorded per-turn usage. Pure + offline; cost is /// computed via the shared pricing layer (`None` pricing → unpriced, 0 cost). #[must_use] + #[cfg(test)] pub fn from_turns(turns: &[TurnInput<'_>]) -> Self { - let mut per_turn = Vec::with_capacity(turns.len()); + Self::from_turn_refs(turns.iter().map(|turn| ScorecardTurnRef { + turn_id: &turn.turn_id, + created_at: turn.created_at, + provider: turn.provider, + billing_surface: None, + model: &turn.model, + usage: turn.usage, + })) + } + + /// Build directly from hook/runtime records, retaining billing provenance + /// while excluding explicitly non-model lifecycle rows. + #[must_use] + pub fn from_recorded_turns(turns: &[RecordedTurn]) -> Self { + Self::from_turn_refs(turns.iter().filter_map(|turn| { + if !turn.contributes_to_scorecard() { + return None; + } + let usage = turn.usage.as_ref()?; + Some(ScorecardTurnRef { + turn_id: &turn.turn_id, + created_at: turn.created_at.as_ref(), + provider: turn.provider.as_deref(), + billing_surface: turn.billing_surface.as_deref(), + model: &turn.model, + usage, + }) + })) + } + + fn from_turn_refs<'a>(turns: impl IntoIterator>) -> Self { + let turns = turns.into_iter(); + let mut per_turn = Vec::with_capacity(turns.size_hint().0); let mut metrics = ScorecardMetrics::default(); for turn in turns { // Normalize provider usage into canonical billable classes once. let classes = token_usage_for_pricing(turn.usage); - let cost = calculate_turn_cost_estimate_from_usage(&turn.model, turn.usage); - let (cost_usd, cost_cny, cost_unpriced) = match cost { - Some(c) => (c.usd, c.cny, false), - None => (0.0, 0.0, true), - }; + let provider = turn + .provider + .map(str::trim) + .filter(|value| !value.is_empty()); + let cost = provider.and_then(ApiProvider::parse).map_or_else( + AvailableCost::default, + |provider| { + provider_scoped_cost( + provider, + turn.model, + turn.usage, + turn.created_at, + turn.billing_surface, + ) + }, + ); + let cost_unpriced = cost.usd.is_none(); + let cost_cny_unpriced = cost.cny.is_none(); + let cost_usd = cost.usd.unwrap_or(0.0); + let cost_cny = cost.cny.unwrap_or(0.0); metrics.turns += 1; + metrics.unpriced_turns += usize::from(cost_unpriced); + metrics.cny_unpriced_turns += usize::from(cost_cny_unpriced); metrics.total_input_tokens += classes.input; metrics.total_output_tokens += classes.output; metrics.total_cache_read_tokens += classes.cache_read; @@ -109,14 +277,18 @@ impl Scorecard { metrics.total_cost_cny += cost_cny; per_turn.push(TurnScore { - turn_id: turn.turn_id.clone(), - model: turn.model.clone(), + turn_id: turn.turn_id.to_string(), + created_at: turn.created_at.cloned(), + provider: provider.map(str::to_string), + billing_surface: turn.billing_surface.map(str::to_string), + model: turn.model.to_string(), input_tokens: classes.input, output_tokens: classes.output, cache_read_tokens: classes.cache_read, cost_usd, cost_cny, cost_unpriced, + cost_cny_unpriced, }); } @@ -126,6 +298,8 @@ impl Scorecard { } else { 0.0 }; + metrics.cost_complete = metrics.unpriced_turns == 0; + metrics.cny_cost_complete = metrics.cny_unpriced_turns == 0; Self { per_turn, metrics } } @@ -134,7 +308,6 @@ impl Scorecard { #[must_use] pub fn to_summary(&self) -> String { let m = &self.metrics; - let unpriced = self.per_turn.iter().filter(|t| t.cost_unpriced).count(); let mut out = String::new(); out.push_str("Token / cache / cost scorecard\n"); out.push_str(&format!("turns: {}\n", m.turns)); @@ -146,19 +319,58 @@ impl Scorecard { "cache_hit_ratio: {:.1}%\n", m.cache_hit_ratio * 100.0 )); - out.push_str(&format!( - "cost_usd: ${:.4} cost_cny: ¥{:.4}\n", - m.total_cost_usd, m.total_cost_cny - )); - if unpriced > 0 { + append_currency_summary( + &mut out, + "cost_usd", + "priced_cost_subtotal_usd", + "$", + m.total_cost_usd, + m.unpriced_turns, + m.turns, + ); + append_currency_summary( + &mut out, + "cost_cny", + "priced_cost_subtotal_cny", + "¥", + m.total_cost_cny, + m.cny_unpriced_turns, + m.turns, + ); + if m.unpriced_turns > 0 { + out.push_str(&format!( + "note: {} turn(s) had missing/unknown provider provenance or no authoritative USD pricing row; their USD cost is unavailable and excluded.\n", + m.unpriced_turns + )); + } + if m.cny_unpriced_turns > 0 { out.push_str(&format!( - "note: {unpriced} turn(s) had no pricing row; their cost is excluded.\n" + "note: {} turn(s) had no authoritative CNY pricing row; their CNY cost is unavailable and excluded.\n", + m.cny_unpriced_turns )); } out } } +fn append_currency_summary( + out: &mut String, + complete_label: &str, + subtotal_label: &str, + symbol: &str, + total: f64, + unpriced_turns: usize, + turns: usize, +) { + if unpriced_turns == 0 { + out.push_str(&format!("{complete_label}: {symbol}{total:.4}\n")); + } else if unpriced_turns == turns { + out.push_str(&format!("{complete_label}: unavailable\n")); + } else { + out.push_str(&format!("{subtotal_label}: {symbol}{total:.4}\n")); + } +} + impl ScorecardMetrics { /// Flag metrics that grew more than `threshold_pct` over `baseline`. Cost /// and token counts are "lower is better", so only *increases* are @@ -170,13 +382,42 @@ impl ScorecardMetrics { threshold_pct: f64, ) -> Vec { let mut out = Vec::new(); - push_regression( - &mut out, - "total_cost_usd", - baseline.total_cost_usd, - self.total_cost_usd, - threshold_pct, - ); + // A partial/unknown subtotal is not comparable to a complete baseline, + // but losing completeness is itself a regression. Otherwise removing + // provider provenance could turn real spend into a smaller subtotal + // and silently bypass the release gate. + if baseline.cost_complete && !self.cost_complete { + out.push(Regression { + metric: "cost_completeness_drop".to_string(), + baseline: 1.0, + current: 0.0, + pct_increase: 100.0, + }); + } else if self.cost_complete && baseline.cost_complete { + push_regression( + &mut out, + "total_cost_usd", + baseline.total_cost_usd, + self.total_cost_usd, + threshold_pct, + ); + } + if baseline.cny_cost_complete && !self.cny_cost_complete { + out.push(Regression { + metric: "cny_cost_completeness_drop".to_string(), + baseline: 1.0, + current: 0.0, + pct_increase: 100.0, + }); + } else if self.cny_cost_complete && baseline.cny_cost_complete { + push_regression( + &mut out, + "total_cost_cny", + baseline.total_cost_cny, + self.total_cost_cny, + threshold_pct, + ); + } push_regression( &mut out, "total_input_tokens", @@ -259,11 +500,15 @@ mod tests { let turns = [ TurnInput { turn_id: "t1".into(), + created_at: None, + provider: None, model: "unpriced-x".into(), usage: &u1, }, TurnInput { turn_id: "t2".into(), + created_at: None, + provider: None, model: "unpriced-x".into(), usage: &u2, }, @@ -274,6 +519,7 @@ mod tests { assert_eq!(card.metrics.total_input_tokens, 800 + 1200); assert_eq!(card.metrics.total_output_tokens, 600); // 500 + 100 assert_eq!(card.metrics.total_cache_read_tokens, 1000); // 200 + 800 + assert_eq!(card.metrics.unpriced_turns, 2); // cache_read / (input + cache_read) = 1000 / (2000 + 1000) let expected = 1000.0 / 3000.0; assert!((card.metrics.cache_hit_ratio - expected).abs() < 1e-9); @@ -284,6 +530,8 @@ mod tests { let u = usage(1000, 500, 0); let turns = [TurnInput { turn_id: "t1".into(), + created_at: None, + provider: Some("openai"), model: "definitely-not-a-real-model".into(), usage: &u, }]; @@ -291,13 +539,618 @@ mod tests { assert!(card.per_turn[0].cost_unpriced); assert_eq!(card.per_turn[0].cost_usd, 0.0); assert_eq!(card.metrics.total_cost_usd, 0.0); - assert!(card.to_summary().contains("no pricing row")); + assert!(card.to_summary().contains("cost_usd: unavailable")); + } + + #[test] + fn same_model_is_priced_only_for_its_authoritative_provider_route() { + let u = usage(1000, 500, 0); + let turns = [ + TurnInput { + turn_id: "api".into(), + created_at: None, + provider: Some("openai"), + model: "gpt-5.5".into(), + usage: &u, + }, + TurnInput { + turn_id: "oauth".into(), + created_at: None, + provider: Some("openai-codex"), + model: "gpt-5.5".into(), + usage: &u, + }, + TurnInput { + turn_id: "local".into(), + created_at: None, + provider: Some("ollama"), + model: "gpt-5.5".into(), + usage: &u, + }, + ]; + + let card = Scorecard::from_turns(&turns); + + assert!(!card.per_turn[0].cost_unpriced); + assert!(card.per_turn[0].cost_usd > 0.0); + assert!(card.per_turn[1].cost_unpriced); + assert_eq!(card.per_turn[1].cost_usd, 0.0); + assert!(card.per_turn[2].cost_unpriced); + assert_eq!(card.per_turn[2].cost_usd, 0.0); + assert_eq!(card.metrics.unpriced_turns, 2); + assert_eq!(card.metrics.cny_unpriced_turns, 3); + assert!(!card.metrics.cost_complete); + assert!(!card.metrics.cny_cost_complete); + assert!(card.to_summary().contains("priced_cost_subtotal_usd")); + assert!(card.to_summary().contains("cost_cny: unavailable")); + + let json = serde_json::to_value(&card).expect("serialize scorecard"); + assert_eq!(json["per_turn"][0]["provider"], "openai"); + assert_eq!(json["per_turn"][1]["provider"], "openai-codex"); + assert_eq!(json["per_turn"][2]["provider"], "ollama"); + assert_eq!(json["metrics"]["unpriced_turns"], 2); + assert_eq!(json["metrics"]["cost_complete"], false); + assert_eq!(json["metrics"]["cny_cost_complete"], false); + } + + #[test] + fn first_party_hand_price_survives_a_missing_catalog_offering() { + let u = usage(1_000_000, 0, 0); + let turns = [ + TurnInput { + turn_id: "openai-api".into(), + created_at: None, + provider: Some("openai"), + model: "gpt-5-codex".into(), + usage: &u, + }, + TurnInput { + turn_id: "foreign-route".into(), + created_at: None, + provider: Some("ollama"), + model: "gpt-5-codex".into(), + usage: &u, + }, + ]; + + let card = Scorecard::from_turns(&turns); + + assert!(!card.per_turn[0].cost_unpriced); + assert!((card.per_turn[0].cost_usd - 1.25).abs() < f64::EPSILON); + assert!(card.per_turn[1].cost_unpriced); + } + + #[test] + fn documented_no_cache_discount_uses_input_without_generalizing_missing_rates() { + let u = Usage { + input_tokens: 1_000_000, + output_tokens: 0, + prompt_cache_hit_tokens: Some(250_000), + prompt_cache_write_tokens: Some(100_000), + ..Default::default() + }; + let turns = [ + TurnInput { + turn_id: "documented-no-discount".into(), + created_at: None, + provider: Some("openai"), + model: "gpt-5.5-pro".into(), + usage: &u, + }, + TurnInput { + turn_id: "missing-cache-rate".into(), + created_at: None, + provider: Some("meta"), + model: "muse-spark-1.1".into(), + usage: &u, + }, + ]; + + let card = Scorecard::from_turns(&turns); + + assert!(!card.per_turn[0].cost_unpriced); + assert!((card.per_turn[0].cost_usd - 30.0).abs() < f64::EPSILON); + assert!(card.per_turn[1].cost_unpriced); + assert!(!card.metrics.cost_complete); + } + + #[test] + fn anthropic_sonnet_5_uses_the_recorded_turn_time() { + let u = Usage { + input_tokens: 1_000_000, + output_tokens: 500_000, + prompt_cache_hit_tokens: Some(250_000), + prompt_cache_write_tokens: Some(100_000), + ..Default::default() + }; + let intro_at: DateTime = "2026-08-31T23:59:59Z".parse().expect("intro time"); + let standard_at: DateTime = "2026-09-01T00:00:00Z".parse().expect("standard time"); + let turns = [ + TurnInput { + turn_id: "sonnet-intro".into(), + created_at: Some(&intro_at), + provider: Some("anthropic"), + model: " claude-sonnet-5 ".into(), + usage: &u, + }, + TurnInput { + turn_id: "sonnet-standard".into(), + created_at: Some(&standard_at), + provider: Some("anthropic"), + model: "claude-sonnet-5".into(), + usage: &u, + }, + TurnInput { + turn_id: "sonnet-missing-time".into(), + created_at: None, + provider: Some("anthropic"), + model: "claude-sonnet-5".into(), + usage: &u, + }, + ]; + + let card = Scorecard::from_turns(&turns); + + assert!(!card.per_turn[0].cost_unpriced); + assert!((card.per_turn[0].cost_usd - 6.60).abs() < 1e-12); + assert_eq!(card.per_turn[0].created_at.as_ref(), Some(&intro_at)); + assert!(card.per_turn[0].cost_cny_unpriced); + assert!(!card.per_turn[1].cost_unpriced); + assert!((card.per_turn[1].cost_usd - 9.90).abs() < 1e-12); + assert!(card.per_turn[1].cost_cny_unpriced); + assert!(card.per_turn[2].cost_unpriced); + } + + #[test] + fn known_zero_usage_is_zero_cost_not_unavailable() { + let u = usage(0, 0, 0); + let turns = [TurnInput { + turn_id: "zero".into(), + created_at: None, + provider: Some("openai"), + model: "gpt-5.5".into(), + usage: &u, + }]; + + let card = Scorecard::from_turns(&turns); + + assert!(!card.per_turn[0].cost_unpriced); + assert_eq!(card.per_turn[0].cost_usd, 0.0); + assert!(card.per_turn[0].cost_cny_unpriced); + assert_eq!(card.metrics.unpriced_turns, 0); + assert_eq!(card.metrics.cny_unpriced_turns, 1); + assert!(card.metrics.cost_complete); + assert!(!card.metrics.cny_cost_complete); + assert!(card.to_summary().contains("cost_usd: $0.0000")); + assert!(card.to_summary().contains("cost_cny: unavailable")); + } + + #[test] + fn direct_deepseek_route_keeps_authoritative_dual_currency_pricing() { + let u = usage(1000, 500, 0); + let turns = [TurnInput { + turn_id: "deepseek".into(), + created_at: None, + provider: Some("deepseek"), + model: "deepseek-v4-pro".into(), + usage: &u, + }]; + + let card = Scorecard::from_turns(&turns); + + assert!(!card.per_turn[0].cost_unpriced); + assert!(!card.per_turn[0].cost_cny_unpriced); + assert!(card.per_turn[0].cost_usd > 0.0); + assert!(card.per_turn[0].cost_cny > 0.0); + assert!(card.metrics.cost_complete); + assert!(card.metrics.cny_cost_complete); + } + + #[test] + fn direct_deepseek_compact_aliases_use_canonical_pricing() { + let u = usage(1000, 500, 100); + let models = [ + "deepseek-v4-pro", + "pro", + " DeepSeek-V4Pro ", + "deepseek-v4-flash", + "flash", + "DEEPSEEK-V4FLASH", + ]; + let turns: Vec<_> = models + .iter() + .map(|model| TurnInput { + turn_id: (*model).into(), + created_at: None, + provider: Some("deepseek"), + model: (*model).into(), + usage: &u, + }) + .collect(); + + let card = Scorecard::from_turns(&turns); + + for alias in [1, 2] { + assert_eq!(card.per_turn[alias].cost_usd, card.per_turn[0].cost_usd); + assert_eq!(card.per_turn[alias].cost_cny, card.per_turn[0].cost_cny); + } + for alias in [4, 5] { + assert_eq!(card.per_turn[alias].cost_usd, card.per_turn[3].cost_usd); + assert_eq!(card.per_turn[alias].cost_cny, card.per_turn[3].cost_cny); + } + assert!(card.per_turn.iter().all(|turn| !turn.cost_unpriced)); + assert!(card.per_turn.iter().all(|turn| !turn.cost_cny_unpriced)); + } + + #[test] + fn direct_deepseek_compatibility_aliases_use_the_flash_route() { + let u = usage(1000, 500, 100); + let before_retirement: DateTime = + "2026-07-24T15:58:59Z".parse().expect("pre-retirement time"); + let at_retirement: DateTime = DEEPSEEK_ALIAS_RETIREMENT_UTC + .parse() + .expect("retirement time"); + let turns = [ + TurnInput { + turn_id: "chat-alias".into(), + created_at: Some(&before_retirement), + provider: Some("deepseek"), + model: "deepseek-chat".into(), + usage: &u, + }, + TurnInput { + turn_id: "reasoner-alias".into(), + created_at: Some(&before_retirement), + provider: Some("deepseek"), + model: "deepseek-reasoner".into(), + usage: &u, + }, + TurnInput { + turn_id: "canonical".into(), + created_at: None, + provider: Some("deepseek"), + model: DEEPSEEK_ALIAS_REPLACEMENT.into(), + usage: &u, + }, + TurnInput { + turn_id: "retired-alias".into(), + created_at: Some(&at_retirement), + provider: Some("deepseek"), + model: "deepseek-chat".into(), + usage: &u, + }, + TurnInput { + turn_id: "undated-alias".into(), + created_at: None, + provider: Some("deepseek"), + model: "deepseek-reasoner".into(), + usage: &u, + }, + ]; + + let card = Scorecard::from_turns(&turns); + + assert_eq!(card.per_turn[0].cost_usd, card.per_turn[2].cost_usd); + assert_eq!(card.per_turn[1].cost_usd, card.per_turn[2].cost_usd); + assert_eq!(card.per_turn[0].cost_cny, card.per_turn[2].cost_cny); + assert_eq!(card.per_turn[1].cost_cny, card.per_turn[2].cost_cny); + assert!(card.per_turn[..3].iter().all(|turn| !turn.cost_unpriced)); + assert!( + card.per_turn[..3] + .iter() + .all(|turn| !turn.cost_cny_unpriced) + ); + assert!(card.per_turn[3].cost_unpriced); + assert!(card.per_turn[4].cost_unpriced); + } + + #[test] + fn direct_arcee_aliases_do_not_cross_the_openrouter_namespace() { + let u = Usage { + input_tokens: 1_000_000, + output_tokens: 500_000, + prompt_cache_hit_tokens: Some(250_000), + prompt_cache_write_tokens: Some(100_000), + ..Default::default() + }; + let turns = [ + TurnInput { + turn_id: "canonical-direct".into(), + created_at: None, + provider: Some("arcee"), + model: "trinity-large-thinking".into(), + usage: &u, + }, + TurnInput { + turn_id: "direct-alias".into(), + created_at: None, + provider: Some("arcee"), + model: "arcee-trinity-large-thinking".into(), + usage: &u, + }, + TurnInput { + turn_id: "openrouter-namespace".into(), + created_at: None, + provider: Some("arcee"), + model: "arcee-ai/trinity-large-thinking".into(), + usage: &u, + }, + ]; + + let card = Scorecard::from_turns(&turns); + + assert!(!card.per_turn[0].cost_unpriced); + assert!((card.per_turn[0].cost_usd - 0.65).abs() < f64::EPSILON); + assert_eq!(card.per_turn[1].cost_usd, card.per_turn[0].cost_usd); + assert!(!card.per_turn[1].cost_unpriced); + assert!(card.per_turn[2].cost_unpriced); + } + + #[test] + fn costless_catalog_rows_fall_back_only_after_the_exact_route_gate() { + let u = Usage { + input_tokens: 1_000_000, + output_tokens: 500_000, + prompt_cache_hit_tokens: Some(250_000), + prompt_cache_write_tokens: Some(100_000), + ..Default::default() + }; + let turns = [ + TurnInput { + turn_id: "arcee-mini".into(), + created_at: None, + provider: Some("arcee"), + model: "trinity-mini".into(), + usage: &u, + }, + TurnInput { + turn_id: "minimax-m2.7".into(), + created_at: None, + provider: Some("minimax"), + model: "minimax-m2.7".into(), + usage: &u, + }, + TurnInput { + turn_id: "foreign-route".into(), + created_at: None, + provider: Some("ollama"), + model: "trinity-mini".into(), + usage: &u, + }, + TurnInput { + turn_id: "openai-hosted-deepseek".into(), + created_at: None, + provider: Some("openai"), + model: "deepseek-v4-pro".into(), + usage: &u, + }, + TurnInput { + turn_id: "openrouter-hosted-zai".into(), + created_at: None, + provider: Some("openrouter"), + model: "z-ai/glm-5.2".into(), + usage: &u, + }, + ]; + + let card = Scorecard::from_turns(&turns); + + assert!((card.per_turn[0].cost_usd - 0.12).abs() < f64::EPSILON); + // MiniMax-M2.7 publishes a distinct cache-write rate (0.375/M), + // retained by the provider-owned fallback even without a priced + // catalog offering. + assert!((card.per_turn[1].cost_usd - 0.8475).abs() < f64::EPSILON); + assert!(card.per_turn[..2].iter().all(|turn| !turn.cost_unpriced)); + assert!(card.per_turn[..2].iter().all(|turn| turn.cost_cny_unpriced)); + assert!(card.per_turn[2..].iter().all(|turn| turn.cost_unpriced)); + } + + #[test] + fn stepfun_legacy_route_keeps_pricing_without_a_catalog_row() { + let u = usage(1000, 500, 250); + let recorded = |turn_id: &str, + provider: &str, + model: &str, + billing_surface: Option<&str>| RecordedTurn { + turn_id: turn_id.to_string(), + created_at: None, + model_backed: Some(true), + provider: Some(provider.to_string()), + billing_surface: billing_surface.map(str::to_string), + model: model.to_string(), + usage: Some(u.clone()), + }; + let turns = [ + recorded( + "stepfun-default", + "stepfun", + " STEP-3.7-FLASH ", + Some(crate::pricing::STEPFUN_PAYG_BILLING_SURFACE), + ), + recorded( + "stepfun-plan", + "stepfun", + "step-3.7-flash", + Some(crate::pricing::STEPFUN_PLAN_BILLING_SURFACE), + ), + recorded("stepfun-missing-surface", "stepfun", "step-3.7-flash", None), + recorded("stepfun-unknown-model", "stepfun", "step-3.5-flash", None), + recorded( + "openrouter-stepfun-name", + "openrouter", + "step-3.7-flash", + None, + ), + recorded("local-stepfun-name", "ollama", "step-3.7-flash", None), + recorded( + "sakana-incomplete-tier-price", + "sakana", + "fugu-ultra-20260615", + None, + ), + recorded( + "foreign-deepseek-name", + "openmodel", + "deepseek-v4-flash", + None, + ), + ]; + + let card = Scorecard::from_recorded_turns(&turns); + + assert!((card.per_turn[0].cost_usd - 0.000_735).abs() < 1e-12); + assert!(!card.per_turn[0].cost_unpriced); + assert!(card.per_turn[0].cost_cny_unpriced); + assert_eq!( + card.per_turn[0].billing_surface.as_deref(), + Some(crate::pricing::STEPFUN_PAYG_BILLING_SURFACE) + ); + assert!(card.per_turn[1..].iter().all(|turn| turn.cost_unpriced)); + } + + #[test] + fn legacy_model_only_record_is_readable_but_unpriced() { + let recorded: RecordedTurn = serde_json::from_value(serde_json::json!({ + "turn_id": "legacy", + "model": "gpt-5.5", + "usage": { + "input_tokens": 0, + "output_tokens": 0 + } + })) + .expect("parse legacy scorecard turn"); + assert_eq!(recorded.provider, None); + assert_eq!(recorded.billing_surface, None); + + let card = Scorecard::from_recorded_turns(&[recorded]); + + assert!(card.per_turn[0].cost_unpriced); + assert_eq!(card.per_turn[0].cost_usd, 0.0); + assert_eq!(card.metrics.unpriced_turns, 1); + assert!(card.to_summary().contains("cost_usd: unavailable")); + } + + #[test] + fn recorded_turn_accepts_runtime_route_aliases() { + let recorded: RecordedTurn = serde_json::from_value(serde_json::json!({ + "schema_version": 1, + "id": "runtime-turn", + "thread_id": "thread-1", + "status": "completed", + "input_summary": "score this turn", + "created_at": "2026-07-12T10:30:00Z", + "effective_provider": "openai-codex", + "effective_billing_surface": "account-subscription", + "effective_model": "gpt-5.5", + "usage": { + "input_tokens": 1, + "output_tokens": 1 + } + })) + .expect("parse runtime scorecard turn"); + + assert_eq!(recorded.turn_id, "runtime-turn"); + assert_eq!( + recorded.created_at.as_ref().map(DateTime::to_rfc3339), + Some("2026-07-12T10:30:00+00:00".to_string()) + ); + assert_eq!(recorded.provider.as_deref(), Some("openai-codex")); + assert_eq!( + recorded.billing_surface.as_deref(), + Some("account-subscription") + ); + assert_eq!(recorded.model, "gpt-5.5"); + assert!(recorded.contributes_to_scorecard()); + } + + #[test] + fn runtime_turn_without_usage_is_readable_and_filtered() { + let recorded: RecordedTurn = serde_json::from_value(serde_json::json!({ + "schema_version": 1, + "id": "queued-runtime-turn", + "thread_id": "thread-1", + "status": "queued", + "input_summary": "waiting to run", + "created_at": "2026-07-12T10:30:00Z", + "effective_provider": "openai", + "effective_model": "gpt-5.5" + })) + .expect("parse runtime row before usage is recorded"); + + assert!(recorded.usage.is_none()); + assert!(!recorded.contributes_to_scorecard()); + let card = Scorecard::from_recorded_turns(&[recorded]); + assert_eq!(card.metrics.turns, 0); + assert!(card.per_turn.is_empty()); + } + + #[test] + fn recorded_non_model_hook_turn_is_excluded_from_model_scorecard() { + let recorded: RecordedTurn = serde_json::from_value(serde_json::json!({ + "turn_id": "shell-turn", + "created_at": "2026-07-12T10:30:00Z", + "model_backed": false, + "provider": null, + "model": "gpt-5.5", + "usage": { + "input_tokens": 0, + "output_tokens": 0 + } + })) + .expect("parse non-model turn_end record"); + + assert!(!recorded.contributes_to_scorecard()); + } + + #[test] + fn blank_unknown_and_custom_providers_fail_closed_as_unpriced() { + let u = usage(1000, 500, 0); + let turns = [ + TurnInput { + turn_id: "blank".into(), + created_at: None, + provider: Some(" "), + model: "gpt-5.5".into(), + usage: &u, + }, + TurnInput { + turn_id: "named-custom".into(), + created_at: None, + provider: Some("my-openai-proxy"), + model: "gpt-5.5".into(), + usage: &u, + }, + TurnInput { + turn_id: "generic-custom".into(), + created_at: None, + provider: Some("custom"), + model: "gpt-5.5".into(), + usage: &u, + }, + ]; + + let card = Scorecard::from_turns(&turns); + + assert_eq!(card.per_turn[0].provider, None); + assert_eq!( + card.per_turn[1].provider.as_deref(), + Some("my-openai-proxy") + ); + assert_eq!(card.per_turn[2].provider.as_deref(), Some("custom")); + assert!(card.per_turn.iter().all(|turn| turn.cost_unpriced)); + assert_eq!(card.metrics.unpriced_turns, 3); + assert!(!card.metrics.cost_complete); + assert!(card.to_summary().contains("cost_usd: unavailable")); } #[test] fn regression_flags_cost_and_token_increases_over_threshold() { let baseline = ScorecardMetrics { turns: 1, + unpriced_turns: 0, + cny_unpriced_turns: 0, + cost_complete: true, + cny_cost_complete: true, total_input_tokens: 1000, total_output_tokens: 1000, total_cache_read_tokens: 0, @@ -319,6 +1172,87 @@ mod tests { assert!(!names.contains(&"total_input_tokens")); // under threshold } + #[test] + fn regression_flags_loss_of_cost_completeness_without_comparing_subtotals() { + let baseline = ScorecardMetrics { + cost_complete: true, + total_cost_usd: 0.10, + ..Default::default() + }; + let current = ScorecardMetrics { + turns: 1, + unpriced_turns: 1, + total_cost_usd: 0.20, + ..Default::default() + }; + + let regs = current.regressions_against(&baseline, 5.0); + assert!(!regs.iter().any(|r| r.metric == "total_cost_usd")); + assert!(regs.iter().any(|r| r.metric == "cost_completeness_drop")); + } + + #[test] + fn regression_flags_loss_of_cny_cost_completeness() { + let baseline = ScorecardMetrics { + cny_cost_complete: true, + total_cost_cny: 0.70, + ..Default::default() + }; + let current = ScorecardMetrics { + turns: 1, + cny_unpriced_turns: 1, + total_cost_cny: 0.0, + ..Default::default() + }; + + let regs = current.regressions_against(&baseline, 5.0); + assert!( + regs.iter() + .any(|r| r.metric == "cny_cost_completeness_drop") + ); + } + + #[test] + fn regression_flags_complete_cny_cost_increase() { + let baseline = ScorecardMetrics { + cny_cost_complete: true, + total_cost_cny: 0.70, + ..Default::default() + }; + let current = ScorecardMetrics { + total_cost_cny: 1.40, + ..baseline.clone() + }; + + let regs = current.regressions_against(&baseline, 5.0); + assert!(regs.iter().any(|r| r.metric == "total_cost_cny")); + } + + #[test] + fn legacy_baseline_is_readable_but_cost_is_not_comparable() { + let baseline: ScorecardMetrics = serde_json::from_value(serde_json::json!({ + "turns": 1, + "total_input_tokens": 10, + "total_output_tokens": 5, + "total_cache_read_tokens": 0, + "total_cost_usd": 0.10, + "total_cost_cny": 0.0, + "cache_hit_ratio": 0.0 + })) + .expect("parse legacy scorecard baseline"); + assert!(!baseline.cost_complete); + + let current = ScorecardMetrics { + cost_complete: true, + total_cost_usd: 0.20, + total_input_tokens: 10, + total_output_tokens: 5, + ..Default::default() + }; + let regs = current.regressions_against(&baseline, 5.0); + assert!(!regs.iter().any(|r| r.metric == "total_cost_usd")); + } + #[test] fn regression_flags_cache_hit_ratio_drop() { let baseline = ScorecardMetrics { diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 13d77cff0..a296cb99b 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -5,6 +5,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; +use chrono::{DateTime, Utc}; use ratatui::layout::Rect; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -21,6 +22,7 @@ use crate::config::{ }; use crate::config_ui::ConfigUiMode; use crate::core::authority::{ModeSessionPrefs, base_policy_for_mode}; +use crate::core::events::TurnRoute; use crate::hooks::{HookContext, HookEvent, HookExecutor, HookResult}; use crate::localization::{Locale, MessageId, resolve_locale, tr}; use crate::models::{Message, SystemPrompt, Tool}; @@ -50,6 +52,17 @@ use crate::tui::views::ViewStack; // === Types === +/// Lifecycle identity retained until the matching `TurnComplete` arrives. +/// +/// This survives local cancellation clearing the visible runtime status, so +/// observer records still carry a stable id, start time, and effective route. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActiveTurnMetadata { + pub turn_id: String, + pub created_at: DateTime, + pub route: Option, +} + /// State machine for onboarding new users. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OnboardingState { @@ -1762,9 +1775,13 @@ pub struct App { pub last_effective_model: Option, /// Provider that actually served the latest auto-routed turn. pub last_effective_provider: Option, - /// Route selected for the in-flight turn. Consumed by `TurnComplete` to - /// annotate `/cache` telemetry without widening the engine event surface. + /// Route selected for the next turn, retained for in-flight UI details + /// until the engine confirms the authoritative `TurnStarted` route. pub pending_turn_route: Option<(ApiProvider, String, bool)>, + /// Authoritative lifecycle metadata attached to the most recent + /// `TurnStarted`. Kept separate from `pending_turn_route` so a preceding + /// compaction completion cannot consume the next model turn's route. + pub active_turn: Option, /// Current API provider (mirrors `Config::api_provider`). /// Updated by `/provider` switches so the UI/commands can read the /// active backend without re-deriving it from the live config. @@ -2525,6 +2542,7 @@ impl App { self.session.last_reasoning_replay_tokens = None; self.session.turn_cache_history.clear(); self.pending_turn_route = None; + self.active_turn = None; self.last_pinned_prefix_hash = None; } @@ -2974,6 +2992,7 @@ impl App { last_effective_model: None, last_effective_provider: None, pending_turn_route: None, + active_turn: None, api_provider: provider, provider_chain, provider_readiness, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 5760b9cca..35718af7e 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -134,9 +134,10 @@ use crate::tui::workspace_context; use super::key_actions; use super::app::{ - App, AppAction, AppMode, HuntVerdict, OnboardingState, PendingProviderSwitch, QueuedMessage, - ReasoningEffort, SidebarFocus, StatusToastLevel, SubmitDisposition, TaskPanelEntry, - TaskPanelEntryKind, TuiOptions, looks_like_slash_command_input, shell_command_from_bang_input, + ActiveTurnMetadata, App, AppAction, AppMode, HuntVerdict, OnboardingState, + PendingProviderSwitch, QueuedMessage, ReasoningEffort, SidebarFocus, StatusToastLevel, + SubmitDisposition, TaskPanelEntry, TaskPanelEntryKind, TuiOptions, + looks_like_slash_command_input, shell_command_from_bang_input, }; use super::approval::{ ApprovalMode, ApprovalRequest, ApprovalView, ElevationRequest, ElevationView, ReviewDecision, @@ -1226,7 +1227,9 @@ fn execute_subagent_observer_hook( fn execute_turn_end_observer_hook( app: &App, + turn: Option<&ActiveTurnMetadata>, usage: &Usage, + billing_surface: Option<&str>, duration: Duration, error: Option<&str>, ) { @@ -1234,10 +1237,16 @@ fn execute_turn_end_observer_hook( return; } + let metadata = turn_end_observer_metadata(turn); let context = app.base_hook_context(); let payload = crate::hooks::turn_end_payload(TurnEndPayloadInput { context: &context, - turn_id: app.runtime_turn_id.as_deref(), + created_at: metadata.created_at, + model_backed: metadata.route.is_some(), + provider: metadata.route.map(|route| route.provider.as_str()), + billing_surface: metadata.route.and(billing_surface), + model: metadata.route.map(|route| route.model.as_str()), + turn_id: metadata.turn_id.as_ref(), status: app.runtime_turn_status.as_deref().unwrap_or("unknown"), error, duration, @@ -1259,6 +1268,31 @@ fn execute_turn_end_observer_hook( }); } +struct TurnEndObserverMetadata<'a> { + turn_id: std::borrow::Cow<'a, str>, + created_at: chrono::DateTime, + route: Option<&'a crate::core::events::TurnRoute>, +} + +fn turn_end_observer_metadata(turn: Option<&ActiveTurnMetadata>) -> TurnEndObserverMetadata<'_> { + turn.map_or_else( + || TurnEndObserverMetadata { + // Manual compaction, purge, and shell-only completions predate the + // TurnStarted lifecycle event. Preserve their observer contract + // with a distinct non-model identity instead of borrowing a stale + // model turn id. + turn_id: std::borrow::Cow::Owned(format!("lifecycle_{}", uuid::Uuid::new_v4())), + created_at: chrono::Utc::now(), + route: None, + }, + |turn| TurnEndObserverMetadata { + turn_id: std::borrow::Cow::Borrowed(&turn.turn_id), + created_at: turn.created_at, + route: turn.route.as_ref(), + }, + ) +} + fn bounded_subagent_hook_preview(text: &str) -> (String, bool) { if text.len() <= SUBAGENT_HOOK_PREVIEW_LIMIT { return (text.to_string(), false); @@ -2184,6 +2218,7 @@ async fn run_event_loop( // cancel redraws owed to other events in the same batch. let redraw_requested_before_event = received_engine_event; received_engine_event = true; + capture_turn_started_metadata(app, &event); if app.suppress_stream_events_until_turn_complete { if matches!(event, EngineEvent::TurnStarted { .. }) { // Ctrl+C can race with the engine's per-turn token @@ -2499,7 +2534,7 @@ async fn run_event_loop( subagent_list_refresh_requested = true; } } - EngineEvent::TurnStarted { turn_id } => { + EngineEvent::TurnStarted { turn_id, .. } => { app.ocean_completion_started_at = None; app.ocean_receipt_settle_start = None; app.ocean_turn_history_start = app.history.len(); @@ -2551,7 +2586,16 @@ async fn run_event_loop( tool_catalog, base_url, } => { - let pending_turn_route = app.pending_turn_route.take(); + let completed_turn = app.active_turn.take(); + let billing_surface = completed_turn + .as_ref() + .and_then(|turn| turn.route.as_ref()) + .and_then(|route| { + crate::pricing::billing_surface_for_route( + route.provider, + base_url.as_deref(), + ) + }); app.session.last_tool_catalog = tool_catalog; app.session.last_base_url = base_url; let was_locally_cancelled = app.suppress_stream_events_until_turn_complete; @@ -2678,9 +2722,15 @@ async fn run_event_loop( app.session.last_prompt_cache_hit_tokens = usage.prompt_cache_hit_tokens; app.session.last_prompt_cache_miss_tokens = usage.prompt_cache_miss_tokens; app.session.last_reasoning_replay_tokens = usage.reasoning_replay_tokens; - let (provider, model, auto_model) = pending_turn_route - .map(|(provider, model, auto_model)| { - (Some(provider), Some(model), auto_model) + let (provider, model, auto_model) = completed_turn + .as_ref() + .and_then(|turn| turn.route.as_ref()) + .map(|route| { + ( + Some(route.provider), + Some(route.model.clone()), + route.auto_model, + ) }) .unwrap_or((None, None, false)); let effective_turn_provider = provider.unwrap_or(app.api_provider); @@ -2725,14 +2775,25 @@ async fn run_event_loop( } // Update session cost - let billing = - crate::route_billing::for_route(config, effective_turn_provider); - let turn_cost = crate::pricing::calculate_turn_cost_estimate_for_route( - effective_turn_provider, - &effective_turn_model, - &usage, - billing, - ); + let turn_cost = completed_turn + .as_ref() + .and_then(|turn| { + turn.route.as_ref().map(|route| (turn.created_at, route)) + }) + .and_then(|(created_at, route)| { + let billing = + crate::route_billing::for_route(config, route.provider); + if !billing.shows_money() { + return None; + } + crate::pricing::calculate_turn_cost_estimate_for_route_at( + route.provider, + &route.model, + billing_surface, + &usage, + created_at, + ) + }); if let Some(cost) = turn_cost { app.accrue_session_cost_estimate(cost); } @@ -2922,7 +2983,14 @@ async fn run_event_loop( } } - execute_turn_end_observer_hook(app, &usage, turn_elapsed, error.as_deref()); + execute_turn_end_observer_hook( + app, + completed_turn.as_ref(), + &usage, + billing_surface, + turn_elapsed, + error.as_deref(), + ); if queued_to_send.is_none() { queued_to_send = app.pop_queued_message(); @@ -2933,11 +3001,8 @@ async fn run_event_loop( recoverable: _, } => { let provider_before_error = app.api_provider; - let (health_provider, health_model) = app - .pending_turn_route - .as_ref() - .map(|(provider, model, _)| (*provider, model.clone())) - .unwrap_or_else(|| (provider_before_error, app.model.clone())); + let (health_provider, health_model) = + error_health_route(app, provider_before_error); app.provider_health.record_failure( config, health_provider, @@ -5296,11 +5361,10 @@ async fn run_event_loop( .await; } } - KeyCode::BackTab => { - if app.cycle_approval_posture() { - sync_mode_update(app, &engine_handle).await; - } + KeyCode::BackTab if app.cycle_approval_posture() => { + sync_mode_update(app, &engine_handle).await; } + KeyCode::BackTab => {} // Transcript-nav shortcuts now require Alt, leaving most bare // letters free to insert as text. Before v0.8.30, bare `g`, // `G`, `[`, `]`, `?`, and `l` on an empty composer were @@ -6434,6 +6498,9 @@ fn reconcile_turn_liveness(app: &mut App, now: Instant, has_running_agents: bool app.dispatch_started_at = None; app.turn_started_at = None; app.turn_last_activity_at = None; + app.pending_turn_route = None; + app.active_turn = None; + app.suppress_stream_events_until_turn_complete = false; app.push_status_toast( "Turn dispatch timed out; the engine may have stopped. Please try again.", StatusToastLevel::Error, @@ -6455,6 +6522,9 @@ fn reconcile_turn_liveness(app: &mut App, now: Instant, has_running_agents: bool app.dispatch_started_at = None; app.turn_started_at = None; app.turn_last_activity_at = None; + app.pending_turn_route = None; + app.active_turn = None; + app.suppress_stream_events_until_turn_complete = false; app.push_status_toast( "Recovered from an inconsistent busy state.", StatusToastLevel::Warning, @@ -6588,6 +6658,9 @@ fn recover_stalled_runtime_turn(app: &mut App, message: &str, level: StatusToast app.runtime_turn_status = None; app.runtime_turn_id = None; app.dispatch_started_at = None; + app.pending_turn_route = None; + app.active_turn = None; + app.suppress_stream_events_until_turn_complete = false; // Per-turn scroll lock — clear so the next turn auto-scrolls. app.user_scrolled_during_stream = false; app.push_status_toast(message, level, None); @@ -6633,6 +6706,9 @@ fn recover_engine_event_disconnect(app: &mut App) -> bool { || app.is_compacting || app.is_purging || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) + || app.pending_turn_route.is_some() + || app.active_turn.is_some() + || app.suppress_stream_events_until_turn_complete || app.streaming_message_index.is_some() || app.streaming_thinking_active_entry.is_some() || app @@ -6662,6 +6738,9 @@ fn recover_engine_event_disconnect(app: &mut App) -> bool { app.runtime_turn_status = None; app.runtime_turn_id = None; app.dispatch_started_at = None; + app.pending_turn_route = None; + app.active_turn = None; + app.suppress_stream_events_until_turn_complete = false; app.user_scrolled_during_stream = false; for msg in app.drain_pending_steers() { @@ -6681,6 +6760,36 @@ fn recover_engine_event_disconnect(app: &mut App) -> bool { true } +fn capture_turn_started_metadata(app: &mut App, event: &EngineEvent) { + if let EngineEvent::TurnStarted { + turn_id, + created_at, + route, + } = event + { + app.ocean_completion_started_at = None; + app.active_turn = Some(ActiveTurnMetadata { + turn_id: turn_id.clone(), + created_at: *created_at, + route: route.clone(), + }); + app.pending_turn_route = None; + } +} + +fn error_health_route(app: &App, fallback_provider: ApiProvider) -> (ApiProvider, String) { + app.active_turn + .as_ref() + .and_then(|turn| turn.route.as_ref()) + .map(|route| (route.provider, route.model.clone())) + .or_else(|| { + app.pending_turn_route + .as_ref() + .map(|(provider, model, _)| (*provider, model.clone())) + }) + .unwrap_or_else(|| (fallback_provider, app.model.clone())) +} + fn record_turn_activity(app: &mut App, event: &EngineEvent, now: Instant) { if matches!(event, EngineEvent::TurnStarted { .. }) { app.turn_last_activity_at = Some(now); diff --git a/crates/tui/src/tui/ui/activity_detail.rs b/crates/tui/src/tui/ui/activity_detail.rs index 6f5371c60..75be85fd9 100644 --- a/crates/tui/src/tui/ui/activity_detail.rs +++ b/crates/tui/src/tui/ui/activity_detail.rs @@ -1550,9 +1550,16 @@ fn turn_approvals_lines(app: &App) -> Vec { fn turn_route_lines(app: &App) -> Vec { let mut lines = Vec::new(); - let (provider, model) = match app.pending_turn_route.as_ref() { - Some((provider, model, _passthrough)) => (provider.display_name(), model.clone()), - None => (app.api_provider.display_name(), app.model.clone()), + let (provider, model) = if let Some(route) = app + .active_turn + .as_ref() + .and_then(|turn| turn.route.as_ref()) + { + (route.provider.display_name(), route.model.clone()) + } else if let Some((provider, model, _auto_model)) = app.pending_turn_route.as_ref() { + (provider.display_name(), model.clone()) + } else { + (app.api_provider.display_name(), app.model.clone()) }; lines.push(format!("Route: {provider} · {model}")); diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 11983f4cb..b49250823 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -4631,6 +4631,8 @@ fn turn_liveness_watchdog_clears_stale_dispatch() { app.dispatch_started_at = Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1)); app.turn_started_at = Some(Instant::now()); + app.pending_turn_route = Some((ApiProvider::Deepseek, "pending-model".to_string(), false)); + app.suppress_stream_events_until_turn_complete = true; let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false); @@ -4638,6 +4640,9 @@ fn turn_liveness_watchdog_clears_stale_dispatch() { assert!(!app.is_loading); assert!(app.dispatch_started_at.is_none()); assert!(app.turn_started_at.is_none()); + assert!(app.pending_turn_route.is_none()); + assert!(app.active_turn.is_none()); + assert!(!app.suppress_stream_events_until_turn_complete); let toast = app.status_toasts.back().expect("watchdog toast"); assert_eq!(toast.level, StatusToastLevel::Error); assert!(toast.text.contains("Turn dispatch timed out")); @@ -4836,6 +4841,16 @@ fn turn_liveness_recovers_stalled_in_progress_turn() { app.turn_started_at = Some(now - turn_stall_watchdog_timeout(&app) - Duration::from_millis(1)); app.streaming_message_index = Some(0); app.user_scrolled_during_stream = true; + app.pending_turn_route = Some((ApiProvider::Deepseek, "pending-model".to_string(), false)); + app.active_turn = Some(crate::tui::app::ActiveTurnMetadata { + turn_id: "stale-turn-id".to_string(), + created_at: chrono::Utc::now(), + route: Some(crate::core::events::TurnRoute { + provider: ApiProvider::Openai, + model: "gpt-5.5".to_string(), + auto_model: false, + }), + }); let recovered = reconcile_turn_liveness(&mut app, now, false); @@ -4848,6 +4863,9 @@ fn turn_liveness_recovers_stalled_in_progress_turn() { assert!(app.streaming_message_index.is_none()); assert!(app.streaming_thinking_active_entry.is_none()); assert!(!app.user_scrolled_during_stream); + assert!(app.pending_turn_route.is_none()); + assert!(app.active_turn.is_none()); + assert!(!app.suppress_stream_events_until_turn_complete); let toast = app.status_toasts.back().expect("stall toast"); assert_eq!(toast.level, StatusToastLevel::Error); assert!(toast.text.contains("Turn stalled")); @@ -4862,6 +4880,16 @@ fn engine_event_disconnect_recovers_live_turn_immediately() { app.turn_started_at = Some(Instant::now()); app.dispatch_started_at = Some(Instant::now()); app.user_scrolled_during_stream = true; + app.pending_turn_route = Some((ApiProvider::Deepseek, "pending-model".to_string(), false)); + app.active_turn = Some(crate::tui::app::ActiveTurnMetadata { + turn_id: "turn_dead".to_string(), + created_at: chrono::Utc::now(), + route: Some(crate::core::events::TurnRoute { + provider: ApiProvider::Openai, + model: "gpt-5.5".to_string(), + auto_model: false, + }), + }); let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app); crate::tui::streaming_thinking::append(&mut app, thinking_idx, "partial reasoning"); app.push_pending_steer(crate::tui::app::QueuedMessage::new( @@ -4879,6 +4907,9 @@ fn engine_event_disconnect_recovers_live_turn_immediately() { assert!(app.turn_started_at.is_none()); assert!(app.streaming_thinking_active_entry.is_none()); assert!(!app.user_scrolled_during_stream); + assert!(app.pending_turn_route.is_none()); + assert!(app.active_turn.is_none()); + assert!(!app.suppress_stream_events_until_turn_complete); assert_eq!(app.queued_message_count(), 1); assert_eq!( app.queued_messages @@ -4909,6 +4940,32 @@ fn engine_event_disconnect_while_idle_is_noop() { assert!(app.status_toasts.is_empty()); } +#[test] +fn engine_event_disconnect_cleans_cancelled_turn_metadata() { + let mut app = create_test_app(); + app.suppress_stream_events_until_turn_complete = true; + app.active_turn = Some(crate::tui::app::ActiveTurnMetadata { + turn_id: "cancelled-turn".to_string(), + created_at: chrono::Utc::now(), + route: Some(crate::core::events::TurnRoute { + provider: ApiProvider::Openai, + model: "gpt-5.5".to_string(), + auto_model: false, + }), + }); + + let recovered = recover_engine_event_disconnect(&mut app); + + assert!(recovered); + assert!(app.active_turn.is_none()); + assert!(!app.suppress_stream_events_until_turn_complete); + assert!(app.history.iter().any(|cell| matches!( + cell, + HistoryCell::Error { message, .. } + if message.contains("Engine stopped before completing the turn") + ))); +} + #[test] fn fixed_model_auto_thinking_skips_auto_model_router() { let mut app = create_test_app(); @@ -7142,6 +7199,76 @@ fn local_cancel_marks_late_stream_events_for_suppression() { )); } +#[test] +fn turn_started_route_is_captured_before_cancel_suppression() { + let mut app = create_test_app(); + app.suppress_stream_events_until_turn_complete = true; + app.ocean_completion_started_at = Some(Instant::now()); + app.pending_turn_route = Some((ApiProvider::Deepseek, "pending-model".to_string(), false)); + let created_at = chrono::Utc::now(); + let event = EngineEvent::TurnStarted { + turn_id: "turn_cancel_race".to_string(), + created_at, + route: Some(crate::core::events::TurnRoute { + provider: ApiProvider::Openai, + model: "gpt-5.5".to_string(), + auto_model: true, + }), + }; + + capture_turn_started_metadata(&mut app, &event); + + let active_turn = app.active_turn.as_ref().expect("captured turn"); + assert_eq!(active_turn.turn_id, "turn_cancel_race"); + assert_eq!(active_turn.created_at, created_at); + let route = active_turn.route.as_ref().expect("captured route"); + assert_eq!(route.provider, ApiProvider::Openai); + assert_eq!(route.model, "gpt-5.5"); + assert!(route.auto_model); + assert!(app.pending_turn_route.is_none()); + assert!(app.ocean_completion_started_at.is_none()); + + let observer = turn_end_observer_metadata(Some(active_turn)); + assert_eq!(observer.turn_id.as_ref(), "turn_cancel_race"); + assert_eq!(observer.created_at, created_at); + assert_eq!(observer.route, Some(route)); +} + +#[test] +fn engine_error_health_accounting_uses_active_turn_route() { + let mut app = create_test_app(); + app.api_provider = ApiProvider::Deepseek; + app.model = "current-model".to_string(); + let event = EngineEvent::TurnStarted { + turn_id: "routed-turn".to_string(), + created_at: chrono::Utc::now(), + route: Some(crate::core::events::TurnRoute { + provider: ApiProvider::Openai, + model: "gpt-5.5".to_string(), + auto_model: true, + }), + }; + capture_turn_started_metadata(&mut app, &event); + + let route = error_health_route(&app, app.api_provider); + + assert_eq!(route, (ApiProvider::Openai, "gpt-5.5".to_string())); +} + +#[test] +fn completion_only_hook_metadata_is_synthetic_and_non_model() { + let observed_after = chrono::Utc::now(); + let first = turn_end_observer_metadata(None); + let second = turn_end_observer_metadata(None); + + assert!(first.turn_id.starts_with("lifecycle_")); + assert!(second.turn_id.starts_with("lifecycle_")); + assert_ne!(first.turn_id, second.turn_id); + assert!(first.created_at >= observed_after); + assert!(first.route.is_none()); + assert!(second.route.is_none()); +} + #[test] fn issue_2739_stalled_turn_snapshot_preserves_api_messages() { let tmp = tempfile::tempdir().expect("tempdir"); diff --git a/crates/tui/src/tui/widgets/footer.rs b/crates/tui/src/tui/widgets/footer.rs index 90ddba22f..3b51a64e6 100644 --- a/crates/tui/src/tui/widgets/footer.rs +++ b/crates/tui/src/tui/widgets/footer.rs @@ -355,9 +355,9 @@ pub fn footer_permission_chip(app: &App) -> Vec> { fn footer_compact_work_chip(app: &App) -> Vec> { if app.sidebar_focus == SidebarFocus::Hidden - || !app + || app .last_sidebar_host_width - .is_some_and(|width| width < crate::tui::ui::SIDEBAR_VISIBLE_MIN_WIDTH) + .is_none_or(|width| width >= crate::tui::ui::SIDEBAR_VISIBLE_MIN_WIDTH) { return Vec::new(); } diff --git a/crates/tui/src/tui/work_surface/render.rs b/crates/tui/src/tui/work_surface/render.rs index 23fa595cd..44a08b454 100644 --- a/crates/tui/src/tui/work_surface/render.rs +++ b/crates/tui/src/tui/work_surface/render.rs @@ -336,7 +336,7 @@ fn control_zones( stopping: bool, opened: bool, ) -> (Option, Option, Option, Option) { - if stopping || (!row.primary_action.is_some() && !row.stop_action.is_some() && !armed) { + if stopping || (row.primary_action.is_none() && row.stop_action.is_none() && !armed) { return (None, None, None, None); } let width = content_area.width; diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index c93ccd5ef..2f5631409 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -398,7 +398,7 @@ provider = "stepfun" [providers.stepfun] api_key = "YOUR_STEPFUN_API_KEY" -base_url = "https://api.stepfun.com/step_plan/v1" +base_url = "https://api.stepfun.ai/step_plan/v1" model = "step-3.7-flash" ``` @@ -977,7 +977,11 @@ The payload includes common hook metadata plus post-turn accounting: "session_id": "sess_12345678", "workspace": "/path/to/workspace", "mode": "agent", + "created_at": "2026-07-12T10:30:00+00:00", + "model_backed": true, + "provider": "deepseek", "model": "deepseek-chat", + "billing_surface": null, "turn_id": "turn_12345678", "status": "completed", "error": null, @@ -1002,6 +1006,21 @@ The payload includes common hook metadata plus post-turn accounting: } ``` +`created_at` anchors time-window pricing; `provider` and `model` identify the +effective route used for model-backed turns. `billing_surface` is an optional, +non-secret classification derived from the endpoint that actually served the +turn. Recognized StepFun routes emit `stepfun-payg` or `stepfun-plan`; the raw +base URL is never written to hook or runtime records. Runtime `TurnRecord` +exports call the same field `effective_billing_surface`, which `scorecard` +accepts as an alias. This keeps subscription quota separate from token-priced +usage. Unrecognized and custom endpoints remain `null` and unpriced. + +Shell-only lifecycle completions set `model_backed` to `false` and may report a +`null` provider; offline scorecards exclude those records from model token and +cost totals. Completion-only shell, manual-compaction, and purge events that do +not have a matching `TurnStarted` retain the observer notification with a +synthetic `lifecycle_` turn id and the time the completion was observed. + For `interrupted` or `failed` turns, `status` reflects that terminal state and `error` carries the engine error string when one is available. `stop_hook_active` is reserved for future re-entry protection and is diff --git a/docs/CONTRIBUTORS.md b/docs/CONTRIBUTORS.md index a4f235d35..22d62c16c 100644 --- a/docs/CONTRIBUTORS.md +++ b/docs/CONTRIBUTORS.md @@ -85,6 +85,9 @@ in several community provider and bridge contributions with release credit. - **[pkeging](https://github.com/pkeging)** — WeCom Bridge deployment and security documentation, including the approval-timeout configuration surface (#3640, harvested) +- **[Wenshan Deng / findshan](https://github.com/findshan)** — original offline + token/cache/cost scorecard and regression gate (#3388), extended with + provider-aware provenance in #4335 - **[buko](https://github.com/buko)** — precise Ctrl+O external-editor freeze reproduction that shaped the terminal input-pump fix (#3657) - **[cyq1017](https://github.com/cyq1017)** — sub-agent progress-event diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 0793c1cf1..e482bf903 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -268,7 +268,7 @@ the same links where possible. | `arcee` | `[providers.arcee]` | `ARCEE_API_KEY` | `ARCEE_BASE_URL`; default `https://api.arcee.ai/api/v1` | `trinity-large-thinking`, `trinity-large-preview` | Arcee AI direct OpenAI-compatible route, tracked as 256K-context BF16 serving. `ARCEE_MODEL` is accepted. OpenRouter's `arcee-ai/trinity-large-thinking` remains the OpenRouter namespaced model ID; direct Arcee uses the bare `trinity-large-thinking` ID. | | `moonshot` | `[providers.moonshot]` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` | `MOONSHOT_BASE_URL`, `KIMI_BASE_URL`; default `https://api.moonshot.ai/v1` | `kimi-k2.7-code`, `kimi-k2.6`; Kimi Code path uses `kimi-for-coding` at `https://api.kimi.com/coding/v1` | Moonshot/Kimi route. `kimi` and `kimi-k2` aliases select `kimi-k2.7-code`; `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, and `KIMI_MODEL` are accepted. Kimi thinking streams through `reasoning_content`; CodeWhale keeps it in Thinking cells and replays it for thinking/tool-call continuity. `[providers.moonshot] auth_mode = "kimi_oauth"` reads Kimi Code OAuth credentials from `KIMI_CODE_HOME`/`~/.kimi-code`, with legacy `KIMI_SHARE_DIR`/`~/.kimi` fallback. | | `zai` | `[providers.zai]` | `ZAI_API_KEY`, `Z_AI_API_KEY` | `ZAI_BASE_URL`, `Z_AI_BASE_URL`; default `https://api.z.ai/api/coding/paas/v4`; general API `https://api.z.ai/api/paas/v4` | `GLM-5.2` default; `GLM-5.1`, `GLM-5-Turbo` available | Z.AI GLM Coding Plan route. `GLM-5.2` is the default; set `model = "GLM-5.1"` or `ZAI_MODEL=GLM-5.1` for the smaller model, or `GLM-5-Turbo` for the fast variant used by faster/explore sub-agents. | -| `stepfun` | `[providers.stepfun]` | `STEPFUN_API_KEY`, `STEP_API_KEY` | `STEPFUN_BASE_URL`, `STEP_BASE_URL`; default `https://api.stepfun.ai/v1`; Coding Plan endpoint `https://api.stepfun.com/step_plan/v1` | `step-3.7-flash` | StepFun / StepFlash direct OpenAI-compatible route. Set `[providers.stepfun].base_url` or `STEP_BASE_URL` to the Coding Plan URL when using that plan. `STEPFUN_MODEL` and `STEP_MODEL` are accepted. | +| `stepfun` | `[providers.stepfun]` | `STEPFUN_API_KEY`, `STEP_API_KEY` | `STEPFUN_BASE_URL`, `STEP_BASE_URL`; default `https://api.stepfun.ai/v1`; Coding Plan endpoint `https://api.stepfun.ai/step_plan/v1` | `step-3.7-flash` | StepFun / StepFlash direct OpenAI-compatible route. Set `[providers.stepfun].base_url` or `STEP_BASE_URL` to the Coding Plan URL when using that plan. Offline accounting labels recognized routes as `stepfun-payg` or `stepfun-plan` without persisting the raw endpoint, and only the standard PAYG route receives token pricing. `STEPFUN_MODEL` and `STEP_MODEL` are accepted. | | `minimax` | `[providers.minimax]` | `MINIMAX_API_KEY` | `MINIMAX_BASE_URL`; default `https://api.minimax.io/v1`; China `https://api.minimaxi.com/v1` | `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2.1-highspeed`, `MiniMax-M2` | MiniMax direct OpenAI-compatible route. CodeWhale sends `reasoning_split = true` so MiniMax thinking arrives separately from answer text. Official M3 input modalities are text, image, and video; M2.7 is text-only. | | `minimax-anthropic` | `[providers.minimax_anthropic]` | `MINIMAX_API_KEY` | `MINIMAX_ANTHROPIC_BASE_URL`; default `https://api.minimax.io/anthropic`; China `https://api.minimaxi.com/anthropic` | `MiniMax-M3`, `MiniMax-M2.7` | MiniMax direct Anthropic-compatible Messages route. Keep the `/anthropic` suffix because CodeWhale appends `/v1/messages`; the route uses `x-api-key`. M3 supports adaptive or disabled thinking. M2.7 always keeps thinking enabled. | | `sglang` | `[providers.sglang]` | Optional `SGLANG_API_KEY` | `SGLANG_BASE_URL`; default `http://localhost:30000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted OpenAI-compatible route. Localhost deployments commonly omit auth. `SGLANG_MODEL` is accepted. | diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index fbc0db203..1c4c3c778 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -89,7 +89,7 @@ this; the table maps each integration need to where a local client reads it. | Integration need | Where it comes from | Status | |---|---|---| -| Route / effective model | `TurnRecord` + thread `model`; per-run `--provider`/`--model` overrides | available | +| Route / effective model / billing surface | `TurnRecord` + thread `model`; per-run `--provider`/`--model` overrides | available | | Permission / sandbox / approval profile | thread `auto_approve`, sandbox + approval policy | available | | Run / thread / turn IDs | `thread_id`, `turn_id`, SSE event envelope | available | | Event stream | `GET /v1/threads/{id}/events` (replay + live SSE) | available | @@ -461,13 +461,19 @@ The runtime uses a durable Thread/Turn/Item lifecycle. `mode`, `task_id`, `system_prompt`, `latest_turn_id`, `latest_response_bookmark`, `archived` - **TurnRecord** — `id`, `thread_id`, `status` (`queued|in_progress|completed| - failed|interrupted|canceled`), timestamps, duration, usage, error summary + failed|interrupted|canceled`), `effective_provider`, `effective_model`, + `effective_billing_surface`, timestamps, duration, usage, error summary - **TurnItemRecord** — `id`, `turn_id`, `kind` (`user_message|agent_message| tool_call|file_change|command_execution|context_compaction|status|error`), lifecycle `status`, `metadata` Events are append-only with a global monotonic `seq` for replay/resume. +`effective_billing_surface` is a non-secret classification derived from the +endpoint that served the turn. Recognized StepFun routes use `stepfun-payg` or +`stepfun-plan`; unknown and custom endpoints leave it unset. The raw base URL is +not persisted in `TurnRecord`. + ### Restart semantics - If the process restarts while a turn or item is `queued` or `in_progress`,