Skip to content

Commit eb4e143

Browse files
authored
Merge pull request #4351 from nightt5879/nightt5879/issue-4335-provider-aware-scorecard
fix(scorecard): bind costs to provider routes
2 parents 45a3b90 + 2c80103 commit eb4e143

28 files changed

Lines changed: 2350 additions & 131 deletions

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- Make offline `scorecard` pricing provider-aware: `turn_end` records carry the
13+
effective route and a non-secret billing surface, runtime exports and
14+
supported aliases ingest cleanly, legacy/unknown routes remain explicitly
15+
unpriced, and route-scoped cache and recorded-time pricing replace model-only
16+
guesses. Historical runtime aggregates use each turn's recorded time;
17+
costless catalog routes fail closed while exact provider-owned hand-price
18+
rows remain available. StepFun PAYG and Step Plan usage now stay distinct
19+
without persisting raw endpoint URLs, so subscription quota is never reported
20+
as token spend (#4335). Completion-only shell, manual-compaction, and purge
21+
events remain visible to `turn_end` observers as explicitly non-model
22+
lifecycle records. This builds on the scorecard introduced by @findshan in
23+
#3388.
24+
1025
## [0.8.68] - 2026-07-13
1126

1227
Release-candidate notes for the underwater release: the TUI's default shell is

config.example.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -499,12 +499,12 @@ max_subagents = 10 # optional (1-20)
499499
# # base_url = "https://api.z.ai/api/paas/v4"
500500
# model = "GLM-5.2" # default; GLM-5.1 is the smaller model, GLM-5-Turbo the fast sub-agent sibling
501501

502-
# StepFun / StepFlash direct OpenAI-compatible endpoint (https://platform.stepfun.com)
502+
# StepFun / StepFlash direct OpenAI-compatible endpoint (https://platform.stepfun.ai)
503503
[providers.stepfun]
504504
# api_key = "YOUR_STEPFUN_API_KEY" # or STEP_API_KEY
505505
# base_url = "https://api.stepfun.ai/v1" # or STEP_BASE_URL
506506
# # Coding Plan endpoint:
507-
# # base_url = "https://api.stepfun.com/step_plan/v1"
507+
# # base_url = "https://api.stepfun.ai/step_plan/v1"
508508
# model = "step-3.7-flash" # or STEPFUN_MODEL / STEP_MODEL
509509

510510
# MiniMax direct OpenAI-compatible endpoint (https://platform.minimax.io)

crates/cli/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,10 @@ struct LaneArgs {
423423
}
424424

425425
#[derive(Debug, Subcommand)]
426+
// Clap constructs this command enum once at process startup. Keeping the
427+
// fields inline makes the generated CLI shape explicit; boxing them only to
428+
// reduce this transient value would add indirection without runtime benefit.
429+
#[allow(clippy::large_enum_variant)]
426430
enum LaneCommand {
427431
/// List known lanes (newest first).
428432
List {

crates/tui/CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- Make offline `scorecard` pricing provider-aware: `turn_end` records carry the
13+
effective route and a non-secret billing surface, runtime exports and
14+
supported aliases ingest cleanly, legacy/unknown routes remain explicitly
15+
unpriced, and route-scoped cache and recorded-time pricing replace model-only
16+
guesses. Historical runtime aggregates use each turn's recorded time;
17+
costless catalog routes fail closed while exact provider-owned hand-price
18+
rows remain available. StepFun PAYG and Step Plan usage now stay distinct
19+
without persisting raw endpoint URLs, so subscription quota is never reported
20+
as token spend (#4335). Completion-only shell, manual-compaction, and purge
21+
events remain visible to `turn_end` observers as explicitly non-model
22+
lifecycle records. This builds on the scorecard introduced by @findshan in
23+
#3388.
24+
1025
## [0.8.68] - 2026-07-13
1126

1227
Release-candidate notes for the underwater release: the TUI's default shell is

crates/tui/src/compaction.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -927,6 +927,9 @@ fn truncate_retained_block(label: &str, content: &mut String, max_chars: usize)
927927
true
928928
}
929929

930+
// A match guard cannot mutably borrow `content`; keeping the mutation inside
931+
// the arm updates both retained representations together without indirection.
932+
#[allow(clippy::collapsible_match)]
930933
fn sanitize_retained_messages(mut messages: Vec<Message>) -> Vec<Message> {
931934
for message in &mut messages {
932935
for block in &mut message.content {

crates/tui/src/core/engine.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ use crate::working_set::WorkingSet;
6666
#[cfg(test)]
6767
use super::authority::agent_approval_mode_for_turn;
6868
use super::authority::{TurnAuthority, effective_input_policy, shell_policy_for_mode};
69-
use super::events::{Event, TurnOutcomeStatus};
69+
use super::events::{Event, TurnOutcomeStatus, TurnRoute};
7070
use super::ops::{
7171
Op, ProviderRuntimeStatus, SessionSnapshot, USER_SHELL_TOOL_ID_PREFIX, UserInputProvenance,
7272
};
@@ -1184,6 +1184,8 @@ impl Engine {
11841184
.tx_event
11851185
.send(Event::TurnStarted {
11861186
turn_id: turn_id.clone(),
1187+
created_at: chrono::Utc::now(),
1188+
route: None,
11871189
})
11881190
.await;
11891191

@@ -2466,6 +2468,11 @@ impl Engine {
24662468
// Create turn context first so start event includes a stable turn id.
24672469
let mut turn = TurnContext::new(self.config.max_steps);
24682470
self.turn_counter = self.turn_counter.saturating_add(1);
2471+
let turn_route = TurnRoute {
2472+
provider: provider.unwrap_or(self.api_provider),
2473+
model: model.clone(),
2474+
auto_model,
2475+
};
24692476

24702477
// Emit turn started event IMMEDIATELY so the UI knows the turn is
24712478
// active. The snapshot below can take 30+ seconds on slow filesystems
@@ -2474,6 +2481,8 @@ impl Engine {
24742481
.tx_event
24752482
.send(Event::TurnStarted {
24762483
turn_id: turn.id.clone(),
2484+
created_at: chrono::Utc::now(),
2485+
route: Some(turn_route),
24772486
})
24782487
.await;
24792488

crates/tui/src/core/engine/tests.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -773,12 +773,20 @@ async fn operate_admission_blocks_unready_nontrivial_but_allows_act_and_trivial(
773773

774774
let mut saw_blocker = false;
775775
let mut saw_operate_complete = false;
776+
let mut saw_operate_route = false;
776777
let mut operate_rx = operate_handle.rx_event.write().await;
777778
while let Some(event) = tokio::time::timeout(model_turn_event_timeout(), operate_rx.recv())
778779
.await
779780
.expect("timed out waiting for Operate blocker")
780781
{
781782
match event {
783+
Event::TurnStarted { route, .. } => {
784+
let route = route.expect("model turn route");
785+
assert_eq!(route.provider, ApiProvider::Deepseek);
786+
assert_eq!(route.model, crate::config::DEFAULT_TEXT_MODEL);
787+
assert!(!route.auto_model);
788+
saw_operate_route = true;
789+
}
782790
Event::Error { envelope, .. } => {
783791
saw_blocker = true;
784792
assert!(
@@ -814,6 +822,10 @@ async fn operate_admission_blocks_unready_nontrivial_but_allows_act_and_trivial(
814822
saw_blocker,
815823
"Operate must surface an actionable readiness blocker"
816824
);
825+
assert!(
826+
saw_operate_route,
827+
"model turns must publish route provenance"
828+
);
817829
assert!(
818830
saw_operate_complete,
819831
"blocked Operate turn must terminate cleanly"
@@ -2953,8 +2965,9 @@ async fn run_shell_command_op_requests_approval_and_executes_shell() {
29532965
let mut rx = handle.rx_event.write().await;
29542966
while let Some(event) = rx.recv().await {
29552967
match event {
2956-
Event::TurnStarted { turn_id } => {
2968+
Event::TurnStarted { turn_id, route, .. } => {
29572969
assert!(turn_id.starts_with(USER_SHELL_TOOL_ID_PREFIX));
2970+
assert!(route.is_none());
29582971
}
29592972
Event::ToolCallStarted { id, name, input } => {
29602973
saw_started = true;

crates/tui/src/core/events.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
66
use std::{path::PathBuf, sync::Arc};
77

8+
use chrono::{DateTime, Utc};
89
use serde_json::Value;
910

11+
use crate::config::ApiProvider;
1012
use crate::error_taxonomy::ErrorEnvelope;
1113
use crate::models::{Message, SystemPrompt, Tool, Usage};
1214
use crate::tools::goal::GoalSnapshot;
@@ -22,6 +24,18 @@ pub enum TurnOutcomeStatus {
2224
Failed,
2325
}
2426

27+
/// Provider/model route resolved for a model-backed turn.
28+
///
29+
/// Carried with `TurnStarted` so hosts can retain provenance until the matching
30+
/// `TurnComplete` without relying on mutable global selection state. Non-model
31+
/// turns such as composer `!` shell commands use no route.
32+
#[derive(Debug, Clone, PartialEq, Eq)]
33+
pub struct TurnRoute {
34+
pub provider: ApiProvider,
35+
pub model: String,
36+
pub auto_model: bool,
37+
}
38+
2539
/// Events emitted by the engine to update the UI.
2640
#[derive(Debug, Clone)]
2741
pub enum Event {
@@ -81,7 +95,11 @@ pub enum Event {
8195

8296
// === Turn Lifecycle ===
8397
/// A new turn has started (user sent a message)
84-
TurnStarted { turn_id: String },
98+
TurnStarted {
99+
turn_id: String,
100+
created_at: DateTime<Utc>,
101+
route: Option<TurnRoute>,
102+
},
85103

86104
/// The turn is complete (no more tool calls)
87105
TurnComplete {

crates/tui/src/cost_status.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ fn cell() -> &'static Mutex<CostEstimate> {
3636
/// cost via [`crate::pricing::calculate_turn_cost_estimate_for_provider`] and
3737
/// adds it to the pending pool. Cheap; takes a short-lived lock
3838
/// and returns. No-op when the provider does not expose authoritative runtime
39-
/// pricing (for example ChatGPT/Codex OAuth) or the model is unknown.
39+
/// pricing (for example ChatGPT/Codex OAuth), needs route provenance that this
40+
/// side channel does not carry (StepFun PAYG vs Plan), or the model is unknown.
4041
pub fn report(provider: ApiProvider, model: &str, usage: &Usage) {
4142
let Some(cost) =
4243
crate::pricing::calculate_turn_cost_estimate_for_provider(provider, model, usage)
@@ -127,6 +128,15 @@ mod tests {
127128
assert_eq!(drain(), CostEstimate::default());
128129
}
129130

131+
#[test]
132+
fn report_skips_stepfun_without_billing_surface() {
133+
let _g = serial_lock();
134+
reset_for_tests();
135+
report(ApiProvider::Stepfun, "step-3.7-flash", &small_usage());
136+
report(ApiProvider::Openrouter, "step-3.7-flash", &small_usage());
137+
assert_eq!(drain(), CostEstimate::default());
138+
}
139+
130140
#[test]
131141
fn report_accumulates_across_multiple_calls() {
132142
let _g = serial_lock();

crates/tui/src/hooks.rs

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
// Note: anyhow is available if needed for future error handling
1515
#[allow(unused_imports)]
1616
use anyhow::{Context, Result};
17+
use chrono::{DateTime, Utc};
1718
use serde::{Deserialize, Serialize};
1819
use serde_json::json;
1920
use std::collections::HashMap;
@@ -613,7 +614,12 @@ pub struct TurnEndTotals {
613614
/// Input used to build the structured `turn_end` observer payload.
614615
pub struct TurnEndPayloadInput<'a> {
615616
pub context: &'a HookContext,
616-
pub turn_id: Option<&'a str>,
617+
pub created_at: DateTime<Utc>,
618+
pub model_backed: bool,
619+
pub provider: Option<&'a str>,
620+
pub billing_surface: Option<&'a str>,
621+
pub model: Option<&'a str>,
622+
pub turn_id: &'a str,
617623
pub status: &'a str,
618624
pub error: Option<&'a str>,
619625
pub duration: Duration,
@@ -1291,7 +1297,11 @@ pub fn turn_end_payload(input: TurnEndPayloadInput<'_>) -> serde_json::Value {
12911297
"session_id": input.context.session_id.as_deref(),
12921298
"workspace": input.context.workspace.as_ref().map(|path| path.display().to_string()),
12931299
"mode": input.context.mode.as_deref(),
1294-
"model": input.context.model.as_deref(),
1300+
"created_at": input.created_at.to_rfc3339(),
1301+
"model_backed": input.model_backed,
1302+
"provider": input.provider,
1303+
"billing_surface": input.billing_surface,
1304+
"model": input.model.or(input.context.model.as_deref()),
12951305
"turn_id": input.turn_id,
12961306
"status": input.status,
12971307
"error": input.error,
@@ -1576,7 +1586,12 @@ NOEQUAL line dropped
15761586

15771587
let payload = super::turn_end_payload(TurnEndPayloadInput {
15781588
context: &context,
1579-
turn_id: Some("turn_123"),
1589+
created_at: "2026-07-12T10:30:00Z".parse().expect("timestamp"),
1590+
model_backed: true,
1591+
provider: Some("deepseek"),
1592+
billing_surface: Some("test-payg"),
1593+
model: Some("deepseek-v4-pro"),
1594+
turn_id: "turn_123",
15801595
status: "completed",
15811596
error: None,
15821597
duration: Duration::from_millis(321),
@@ -1595,7 +1610,12 @@ NOEQUAL line dropped
15951610
assert_eq!(payload["session_id"], "sess_test");
15961611
assert_eq!(payload["workspace"], "/tmp/codewhale");
15971612
assert_eq!(payload["mode"], "agent");
1598-
assert_eq!(payload["model"], "deepseek-v4");
1613+
assert_eq!(payload["created_at"], "2026-07-12T10:30:00+00:00");
1614+
assert_eq!(payload["model_backed"], true);
1615+
assert_eq!(payload["provider"], "deepseek");
1616+
assert_eq!(payload["billing_surface"], "test-payg");
1617+
assert!(payload.get("base_url").is_none());
1618+
assert_eq!(payload["model"], "deepseek-v4-pro");
15991619
assert_eq!(payload["turn_id"], "turn_123");
16001620
assert_eq!(payload["status"], "completed");
16011621
assert_eq!(payload["error"], serde_json::Value::Null);
@@ -1883,7 +1903,12 @@ printf '%s\n' '{{"text":"stdout is not a mutation contract"}}'
18831903
let context = submit_context(&dir).with_tokens(15);
18841904
let payload = super::turn_end_payload(TurnEndPayloadInput {
18851905
context: &context,
1886-
turn_id: Some("turn_observed"),
1906+
created_at: "2026-07-12T10:30:00Z".parse().expect("timestamp"),
1907+
model_backed: true,
1908+
provider: Some("openai"),
1909+
billing_surface: None,
1910+
model: Some("gpt-5.5"),
1911+
turn_id: "turn_observed",
18871912
status: "completed",
18881913
error: None,
18891914
duration: Duration::from_millis(7),
@@ -1912,6 +1937,9 @@ printf '%s\n' '{{"text":"stdout is not a mutation contract"}}'
19121937
serde_json::from_str(&std::fs::read_to_string(out).expect("payload written"))
19131938
.expect("valid JSON payload");
19141939
assert_eq!(captured["event"], "turn_end");
1940+
assert_eq!(captured["created_at"], "2026-07-12T10:30:00+00:00");
1941+
assert_eq!(captured["provider"], "openai");
1942+
assert_eq!(captured["model"], "gpt-5.5");
19151943
assert_eq!(captured["turn_id"], "turn_observed");
19161944
assert_eq!(captured["totals"]["input_tokens"], 12);
19171945
assert_eq!(captured["totals"]["output_tokens"], 3);

0 commit comments

Comments
 (0)