Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions crates/cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions crates/tui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions crates/tui/src/compaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Message>) -> Vec<Message> {
for message in &mut messages {
for block in &mut message.content {
Expand Down
11 changes: 10 additions & 1 deletion crates/tui/src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -1184,6 +1184,8 @@ impl Engine {
.tx_event
.send(Event::TurnStarted {
turn_id: turn_id.clone(),
created_at: chrono::Utc::now(),
route: None,
})
.await;

Expand Down Expand Up @@ -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
Expand All @@ -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;

Expand Down
15 changes: 14 additions & 1 deletion crates/tui/src/core/engine/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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;
Expand Down
20 changes: 19 additions & 1 deletion crates/tui/src/core/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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<Utc>,
route: Option<TurnRoute>,
},

/// The turn is complete (no more tool calls)
TurnComplete {
Expand Down
12 changes: 11 additions & 1 deletion crates/tui/src/cost_status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ fn cell() -> &'static Mutex<CostEstimate> {
/// 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)
Expand Down Expand Up @@ -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();
Expand Down
38 changes: 33 additions & 5 deletions crates/tui/src/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Utc>,
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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand All @@ -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);
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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);
Expand Down
19 changes: 8 additions & 11 deletions crates/tui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<RecordedTurn> = serde_json::from_str(&raw)
.with_context(|| format!("failed to parse scorecard input {}", args.input.display()))?;

let inputs: Vec<TurnInput<'_>> = 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) => {
Expand Down
1 change: 1 addition & 0 deletions crates/tui/src/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
Loading
Loading