feat(catalog): persist exact routes and settle auxiliary usage once

Bind provider catalogs to provider kind, configured identity and endpoint.
Keep account rosters in memory, reject superseded refreshes, and preserve
frozen admission quotes across every accounting consumer. Route runtime,
picker, inventory and API defaults through the same catalog authority.

Keep parent and auxiliary usage distinct; retain compaction usage through
failure/cancellation and deduplicate metadata replay. Preserve healthy
transcripts under ledger damage, retire deleted origins across processes,
reject stale snapshot/checkpoint saves, and retain late leased receipts at
terminal settlement. Complete Runtime model pagination without truncation.
Preserve current guardian re-review behavior and explicit model choices.

Validation on this isolated 0d3433cbf669-based worktree:
- Selected codewhale-tui lib suite: 3474 passed, 0 failed, 8659 skipped.
- codewhale-config lib: 649 passed, 0 failed, 1 skipped.
- codewhale-protocol lib: 68 passed, 0 failed.
- Runtime browser JavaScript: 36 node +29 Vitest passed, 0 failed.
  All 16 direct web dependencies match the worktree lockfile.
- 4256 passing tests total across these separate scopes; no full-workspace
  test claim. First TUI run 3464/8 retained, then all 8 failures repaired.
- Final cargo check of TUI/config/protocol with --tests passed.
- cargo fmt --all -- --check and scoped git diff --check passed.
- Three guard-removal controls failed as expected (0/3), including actual
  deleted-origin cost accrual, duplicate gaps and overwritten late receipts;
  all source hashes restored, then the selected suite passed again.
  Separate browser pagination negative control failed as expected (0/1).
- Required root npm test and npm run check:web both failed: Missing script.
  The root package defines neither command. Logs retained; no gate claimed.

All request tests used isolated local fixtures on macOS. No real provider
call, hosted CI rerun, public push, merge, deployment or billing operation.
Evidence: /private/tmp/cw-5726-verification-20260907/REPORT.md

Harvested from PR #5726 by @Hmbown.
Original feature commit: 1a03b23b62
Donor head: 04ba303471
Scoped source migration; stale main-sync history was not imported.

Refs #5726, #5848

Co-authored-by: Hmbown <[email protected]>
This commit is contained in:
CodeWhale BotandHmbown committed 2026-09-07 22:03:23 -07:00
1 parent 0d3433cbf6
commit 7c4bab4f11
52 files changed
+16049 -960

No files matched your search

+35 -5
View File
@@ -145,6 +145,29 @@ impl RouteResolver {
/// Returns [`RouteError`] when the model is empty, the provider is invalid,
/// or a clearly-foreign model is requested for a strict direct provider.
pub fn resolve(&self, req: &RouteRequest) -> Result<ReadyRouteCandidate, RouteError> {
self.resolve_inner(req, false)
}
/// Resolve with catalog facts authenticated against this exact endpoint.
///
/// The ordinary [`Self::resolve`] path strips capabilities and pricing when
/// a route uses a custom base URL, because a same-named first-party model is
/// not evidence about an arbitrary proxy. Callers may use this seam only
/// when the injected offering came from the selected provider identity's
/// own endpoint and its base-URL fingerprint matches the request endpoint.
/// All normal routing and protocol validation still applies.
pub fn resolve_with_endpoint_catalog_authority(
&self,
req: &RouteRequest,
) -> Result<ReadyRouteCandidate, RouteError> {
self.resolve_inner(req, true)
}
fn resolve_inner(
&self,
req: &RouteRequest,
endpoint_catalog_authoritative: bool,
) -> Result<ReadyRouteCandidate, RouteError> {
// 1. Provider scope from explicit choice only; default otherwise.
// The provider is NEVER inferred from a model prefix.
let provider_kind = req.explicit_provider.unwrap_or_default();
@@ -240,14 +263,21 @@ impl RouteResolver {
selected.endpoint_key = "responses".to_string();
}
}
if custom_endpoint {
if custom_endpoint && !endpoint_catalog_authoritative {
// Capabilities and pricing belong to the exact provider endpoint
// offering that reported them. Reusing a provider enum and a
// first-party model id against a custom compatible endpoint does
// not prove that proxy serves the same modality, tool, reasoning,
// or billing contract. Keep the caller's model id and Chat
// pass-through above, but clear every unowned offering fact at the
// authority boundary instead of presenting it as verified.
// not prove that proxy serves the same canonical model, limits,
// modality, tool, reasoning, or billing contract. Keep the
// caller's wire model id, but clear every unowned offering fact at
// the authority boundary instead of presenting it as verified.
// The endpoint_key/protocol stays: it is the provider adapter's
// wire contract (a model-aware roster row or fixed policy), not an
// endpoint-catalog fact, and coercing it to Chat would silently
// change how a Responses- or Messages-bound route speaks.
// Deepseek's custom-endpoint Chat pass-through is handled above.
selected.canonical_model = None;
selected.limits = RouteLimits::default();
selected.capabilities = RouteCapabilities::default();
selected.pricing = PricingSku::UnknownOrStale;
}
+27
View File
@@ -1793,6 +1793,33 @@ fn custom_endpoint_does_not_inherit_first_party_pricing() {
);
}
#[test]
fn exact_endpoint_catalog_may_carry_its_own_pricing_on_a_custom_base_url() {
use super::candidate::PricingSku;
let request = RouteRequest {
explicit_provider: Some(ProviderKind::Deepseek),
model_selector: Some(LogicalModelRef::from("deepseek-v4-pro")),
saved_provider_model: None,
base_url_override: Some("https://authenticated-catalog.example.test/v1".to_string()),
limit_overrides: Vec::new(),
};
let out = priced_deepseek_resolver()
.resolve_with_endpoint_catalog_authority(&request)
.expect("exact endpoint-owned catalog route resolves");
match out.pricing() {
Some(PricingSku::Token {
input_per_mtok,
output_per_mtok,
}) => {
assert_eq!(*input_per_mtok, Some(0.28));
assert_eq!(*output_per_mtok, Some(0.42));
}
other => panic!("expected exact endpoint pricing, got {other:?}"),
}
}
#[test]
fn unpriced_offering_stays_unknown() {
use super::candidate::PricingSku;
+40 -3
View File
@@ -103,8 +103,8 @@ pub enum RouteProduct {
Metered,
}
/// Dispatch-time billing evidence, stamped at the wire boundary. Absent for a
/// route that was planned but never sent.
/// Billing evidence captured at application admission before the provider permit.
/// This does not attest network delivery. Absent before admission.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RouteBillingEnvelope {
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -113,6 +113,9 @@ pub struct RouteBillingEnvelope {
pub billing_surface: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint_fingerprint: Option<String>,
/// Validated frozen provider-live quote serialized by the runtime owner.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_live_pricing: Option<Value>,
/// `RouteBillingMode` in snake_case.
pub billing_mode: String,
pub dispatched_at: DateTime<Utc>,
@@ -375,7 +378,7 @@ pub enum EventMsg {
session_id: SessionId,
snapshot: Value,
},
/// Immutable billing route captured at the real provider dispatch boundary.
/// Immutable billing route captured at application admission.
RouteDispatched {
thread_id: ThreadId,
session_id: SessionId,
@@ -393,6 +396,11 @@ pub enum EventMsg {
#[serde(default, skip_serializing_if = "Option::is_none")]
error: Option<String>,
usage: TokenUsage,
/// Parent-route subset; absent in legacy events, whose split is unknown.
#[serde(default, skip_serializing_if = "Option::is_none")]
parent_route_usage: Option<TokenUsage>,
#[serde(default)]
routed_usage_dropped_records: u64,
/// Tool catalog sent with this turn's model request (`Tool` serialized).
#[serde(default, skip_serializing_if = "Option::is_none")]
tool_catalog: Option<Vec<Value>>,
@@ -411,6 +419,18 @@ pub enum EventMsg {
request_ms: Option<u64>,
},
/// Child-call telemetry; cost belongs to its own routed receipt.
RoutedTurnUsage {
thread_id: ThreadId,
session_id: SessionId,
usage: TokenUsage,
duration_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
first_token_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
request_ms: Option<u64>,
},
// === Goals ===
/// Runtime goal state changed (`GoalSnapshot` serialized).
GoalUpdated {
@@ -716,6 +736,7 @@ pub const EVENT_KINDS: &[&str] = &[
"route_dispatched",
"turn_complete",
"turn_usage",
"routed_turn_usage",
"goal_updated",
"goal_continuation_waiting",
"goal_continuation_wait_ended",
@@ -768,6 +789,7 @@ impl EventMsg {
Self::RouteDispatched { .. } => "route_dispatched",
Self::TurnComplete { .. } => "turn_complete",
Self::TurnUsage { .. } => "turn_usage",
Self::RoutedTurnUsage { .. } => "routed_turn_usage",
Self::GoalUpdated { .. } => "goal_updated",
Self::GoalContinuationWaiting { .. } => "goal_continuation_waiting",
Self::GoalContinuationWaitEnded { .. } => "goal_continuation_wait_ended",
@@ -820,6 +842,7 @@ impl EventMsg {
| Self::RouteDispatched { thread_id, .. }
| Self::TurnComplete { thread_id, .. }
| Self::TurnUsage { thread_id, .. }
| Self::RoutedTurnUsage { thread_id, .. }
| Self::GoalUpdated { thread_id, .. }
| Self::GoalContinuationWaiting { thread_id, .. }
| Self::GoalContinuationWaitEnded { thread_id, .. }
@@ -872,6 +895,7 @@ impl EventMsg {
| Self::RouteDispatched { session_id, .. }
| Self::TurnComplete { session_id, .. }
| Self::TurnUsage { session_id, .. }
| Self::RoutedTurnUsage { session_id, .. }
| Self::GoalUpdated { session_id, .. }
| Self::GoalContinuationWaiting { session_id, .. }
| Self::GoalContinuationWaitEnded { session_id, .. }
@@ -941,6 +965,7 @@ mod tests {
openrouter_vendor: None,
billing_surface: None,
endpoint_fingerprint: Some("fp".into()),
provider_live_pricing: None,
billing_mode: "metered".into(),
dispatched_at: DateTime::<Utc>::from_timestamp(0, 0).unwrap(),
}),
@@ -1029,10 +1054,20 @@ mod tests {
status: TurnOutcomeStatus::Failed,
error: Some("boom".into()),
usage: usage.clone(),
parent_route_usage: Some(usage.clone()),
routed_usage_dropped_records: 0,
tool_catalog: Some(vec![json!({"name": "read_file"})]),
base_url: None,
},
EventMsg::TurnUsage {
thread_id: t.clone(),
session_id: s.clone(),
usage: usage.clone(),
duration_ms: 10,
first_token_ms: Some(2),
request_ms: None,
},
EventMsg::RoutedTurnUsage {
thread_id: t.clone(),
session_id: s.clone(),
usage,
@@ -1365,6 +1400,8 @@ mod tests {
status: TurnOutcomeStatus::Completed,
error: None,
usage: TokenUsage::default(),
parent_route_usage: None,
routed_usage_dropped_records: 0,
tool_catalog: None,
base_url: None,
};
+1172 -109
View File
File diff suppressed because it is too large. Load diff
+111 -17
View File
@@ -10,7 +10,7 @@ use crate::core::model_client::ModelClient;
use crate::logging;
use crate::models::Role;
use crate::models::{
CacheControl, ContentBlock, Message, MessageRequest, SystemBlock, SystemPrompt,
CacheControl, ContentBlock, Message, MessageRequest, SystemBlock, SystemPrompt, Usage,
};
#[path = "compaction/last_round.rs"]
@@ -1062,11 +1062,15 @@ fn is_context_window_error_message(text: &str) -> bool {
/// - Never panics
/// - Never corrupts the original messages (returns error instead)
/// - Only retries on transient errors (network, rate limit, etc.)
///
/// `invocation_usage` retains every decoded response, including rejected
/// summaries, across retries and cancellation of this future.
pub async fn compact_messages_safe(
client: &dyn ModelClient,
messages: &[Message],
system_prompt: Option<&SystemPrompt>,
prepared: &PreparedCompactionEnvelope,
invocation_usage: &mut Usage,
) -> Result<CompactionResult> {
const MAX_RETRIES: u32 = 3;
const BASE_DELAY_MS: u64 = 1000;
@@ -1136,8 +1140,14 @@ pub async fn compact_messages_safe(
tokio::time::sleep(delay).await;
}
match compact_messages_with_metadata(client, compaction_input, config, &mut quality_retries)
.await
match compact_messages_with_metadata(
client,
compaction_input,
config,
&mut quality_retries,
invocation_usage,
)
.await
{
Ok((msgs, prompt, mut coverage)) => {
let kept = sanitize_retained_messages(msgs);
@@ -1234,8 +1244,15 @@ async fn compact_messages(
config: &CompactionConfig,
) -> Result<(Vec<Message>, Option<SystemPrompt>, Vec<Message>)> {
let mut quality_retries = 0;
let (messages, summary_prompt, _coverage) =
compact_messages_with_metadata(client, messages, config, &mut quality_retries).await?;
let mut invocation_usage = Usage::default();
let (messages, summary_prompt, _coverage) = compact_messages_with_metadata(
client,
messages,
config,
&mut quality_retries,
&mut invocation_usage,
)
.await?;
Ok((messages, summary_prompt, Vec::new()))
}
@@ -1244,12 +1261,14 @@ async fn compact_messages_with_metadata(
messages: &[Message],
config: &CompactionConfig,
quality_retries: &mut u32,
invocation_usage: &mut Usage,
) -> Result<(Vec<Message>, Option<SystemPrompt>, CompactionCoverage)> {
if messages.is_empty() {
return Ok((Vec::new(), None, CompactionCoverage::default()));
}
let summary = create_summary(client, messages, config, quality_retries).await?;
let summary =
create_summary(client, messages, config, quality_retries, invocation_usage).await?;
let anchors = user_anchors_section(config.workspace.as_deref());
let checkpoint_text = build_compaction_summary_block_text(&summary, &anchors);
let summary_block = SystemBlock {
@@ -1409,6 +1428,7 @@ async fn create_summary(
messages: &[Message],
config: &CompactionConfig,
quality_retries: &mut u32,
invocation_usage: &mut Usage,
) -> Result<String> {
// The summarization request IS the live conversation plus one final user
// message asking for the handoff summary, so the provider's prefix cache
@@ -1479,6 +1499,10 @@ async fn create_summary(
Err(err) => return Err(err),
};
// Keep the caller's total before any validation or subsequent await.
// A rejected summary or canceled retry still consumed these tokens.
crate::core::turn::add_usage_to(invocation_usage, &response.usage);
// Compaction summary calls are billed; route the tokens through the
// side-channel so the dashboard total matches the website (#526).
crate::cost_status::report_effective_route_for_runtime(
@@ -1903,6 +1927,7 @@ mod tests {
struct ScriptedSummaryClient {
responses: std::sync::Mutex<std::collections::VecDeque<anyhow::Result<Vec<ContentBlock>>>>,
requests: std::sync::Mutex<Vec<MessageRequest>>,
retry_started: Option<std::sync::Arc<tokio::sync::Notify>>,
}
impl ScriptedSummaryClient {
@@ -1914,6 +1939,7 @@ mod tests {
Self {
responses: std::sync::Mutex::new(responses.into()),
requests: std::sync::Mutex::new(Vec::new()),
retry_started: None,
}
}
}
@@ -1940,9 +1966,15 @@ mod tests {
.responses
.lock()
.expect("read scripted summary response")
.pop_front()
.ok_or_else(|| anyhow::anyhow!("scripted summary responses exhausted"))?;
let content = outcome?;
.pop_front();
if outcome.is_none()
&& let Some(retry_started) = &self.retry_started
{
retry_started.notify_one();
return std::future::pending().await;
}
let content = outcome
.ok_or_else(|| anyhow::anyhow!("scripted summary responses exhausted"))??;
Ok(crate::models::MessageResponse {
id: "summary-scripted".to_string(),
r#type: "message".to_string(),
@@ -1952,7 +1984,13 @@ mod tests {
stop_reason: None,
stop_sequence: None,
container: None,
usage: crate::models::Usage::default(),
usage: Usage {
input_tokens: 17,
output_tokens: 3,
prompt_cache_hit_tokens: Some(5),
reasoning_tokens: Some(2),
..Usage::default()
},
})
}
@@ -2170,9 +2208,20 @@ mod tests {
..Default::default()
};
let result = compact_messages_safe(&client, &original, None, &prepared(&config))
.await
.expect("the conservative retry should recover a usable summary");
let mut invocation_usage = Usage::default();
let result = compact_messages_safe(
&client,
&original,
None,
&prepared(&config),
&mut invocation_usage,
)
.await
.expect("the conservative retry should recover a usable summary");
assert_eq!(invocation_usage.input_tokens, 34);
assert_eq!(invocation_usage.output_tokens, 6);
assert_eq!(invocation_usage.prompt_cache_hit_tokens, Some(10));
assert_eq!(invocation_usage.reasoning_tokens, Some(4));
let requests = client
.requests
@@ -2222,14 +2271,20 @@ mod tests {
..Default::default()
};
let mut invocation_usage = Usage::default();
let result = compact_messages_safe(
&client,
&[msg("user", "Preserve the current migration state.")],
None,
&prepared(&config),
&mut invocation_usage,
)
.await
.expect("the outer retry should recover after the transient failure");
assert_eq!(invocation_usage.input_tokens, 34);
assert_eq!(invocation_usage.output_tokens, 6);
assert_eq!(invocation_usage.prompt_cache_hit_tokens, Some(10));
assert_eq!(invocation_usage.reasoning_tokens, Some(4));
assert_eq!(
result.retries_used, 2,
@@ -2246,6 +2301,35 @@ mod tests {
);
}
#[tokio::test]
async fn compaction_usage_survives_cancellation_during_quality_retry() {
let _cost_scope = crate::cost_status::test_scope();
let retry_started = std::sync::Arc::new(tokio::sync::Notify::new());
let mut client = ScriptedSummaryClient::new(vec![vec![ContentBlock::Text {
text: "...".to_string(),
cache_control: None,
}]]);
client.retry_started = Some(std::sync::Arc::clone(&retry_started));
let messages = vec![msg("user", "Preserve the migration state.")];
let prepared = prepared(&CompactionConfig::default());
let mut invocation_usage = Usage::default();
{
let compaction =
compact_messages_safe(&client, &messages, None, &prepared, &mut invocation_usage);
tokio::pin!(compaction);
tokio::select! {
result = &mut compaction => panic!("retry must remain pending: {result:?}"),
_ = retry_started.notified() => {},
_ = tokio::time::sleep(Duration::from_secs(10)) => panic!("quality retry did not start"),
}
}
assert_eq!(invocation_usage.input_tokens, 17);
assert_eq!(invocation_usage.output_tokens, 3);
assert_eq!(invocation_usage.prompt_cache_hit_tokens, Some(5));
assert_eq!(invocation_usage.reasoning_tokens, Some(2));
assert_eq!(client.requests.lock().unwrap().len(), 2);
}
#[tokio::test]
async fn non_text_summary_failure_preserves_history_after_one_retry() {
let original = vec![
@@ -2265,9 +2349,20 @@ mod tests {
..Default::default()
};
let error = compact_messages_safe(&client, &original, None, &prepared(&config))
.await
.expect_err("two non-text responses must not replace history");
let mut invocation_usage = Usage::default();
let error = compact_messages_safe(
&client,
&original,
None,
&prepared(&config),
&mut invocation_usage,
)
.await
.expect_err("two non-text responses must not replace history");
assert_eq!(invocation_usage.input_tokens, 34);
assert_eq!(invocation_usage.output_tokens, 6);
assert_eq!(invocation_usage.prompt_cache_hit_tokens, Some(10));
assert_eq!(invocation_usage.reasoning_tokens, Some(4));
assert!(
error
@@ -2296,7 +2391,6 @@ mod tests {
"borrowed source history must remain byte-for-byte unchanged"
);
}
#[tokio::test]
async fn compaction_uses_the_resolved_route_output_allowance() {
for (route_label, provider, model) in [
+168 -27
View File
@@ -1951,6 +1951,8 @@ impl Engine {
.tx_event
.send(Event::TurnComplete {
usage: Usage::default(),
parent_route_usage: Usage::default(),
routed_usage_dropped_records: 0,
status,
error,
tool_catalog: None,
@@ -2515,6 +2517,7 @@ impl Engine {
self.current_mode,
route,
self.config.compaction.clone(),
crate::cost_status::RuntimeUsageBatch::default(),
self.config.goal_objective.clone(),
self.config.goal_token_budget,
self.config.goal_status,
@@ -2576,6 +2579,7 @@ impl Engine {
mode,
route,
compaction,
initial_routed_usage,
goal_objective,
goal_token_budget,
goal_status,
@@ -2598,6 +2602,7 @@ impl Engine {
mode,
*route,
*compaction,
*initial_routed_usage,
goal_objective,
goal_token_budget,
goal_status,
@@ -2702,6 +2707,7 @@ impl Engine {
self.current_mode,
route,
self.config.compaction.clone(),
crate::cost_status::RuntimeUsageBatch::default(),
goal_snapshot.objective,
goal_snapshot.token_budget,
GoalStatus::Active,
@@ -3249,6 +3255,7 @@ impl Engine {
mode,
route,
self.config.compaction.clone(),
crate::cost_status::RuntimeUsageBatch::default(),
self.config.goal_objective.clone(),
self.config.goal_token_budget,
self.config.goal_status,
@@ -3897,6 +3904,7 @@ impl Engine {
self.current_mode,
route,
self.config.compaction.clone(),
crate::cost_status::RuntimeUsageBatch::default(),
self.config.goal_objective.clone(),
self.config.goal_token_budget,
self.config.goal_status,
@@ -4019,6 +4027,8 @@ impl Engine {
.tx_event
.send(Event::TurnComplete {
usage: Usage::default(),
parent_route_usage: Usage::default(),
routed_usage_dropped_records: 0,
status: TurnOutcomeStatus::Failed,
error: Some(message.clone()),
tool_catalog: None,
@@ -4675,6 +4685,7 @@ impl Engine {
mode: AppMode,
route: ResolvedRuntimeRoute,
compaction: CompactionConfig,
initial_routed_usage: crate::cost_status::RuntimeUsageBatch,
goal_objective: Option<String>,
goal_token_budget: Option<u32>,
goal_status: GoalStatus,
@@ -4695,6 +4706,7 @@ impl Engine {
let mut goal_objective = goal_objective;
let mut goal_token_budget = goal_token_budget;
let mut goal_status = goal_status;
let initial_usage_owner = compaction.runtime_cost_owner.clone();
// A literal natural-language `/goal` declaration is control-plane
// intent, not a suggestion that each provider may acknowledge or
@@ -4779,6 +4791,12 @@ impl Engine {
let dispatched_product =
crate::route_billing::capture_product(&route.config, effective_provider);
if let Err(err) = self.install_resolved_runtime_route(route) {
let cost_scope = crate::cost_status::scope_token();
crate::cost_status::report_runtime_usage_batch(
cost_scope,
initial_usage_owner.as_deref(),
&initial_routed_usage,
);
let _ = self
.tx_event
.send(Event::error(ErrorEnvelope::fatal_auth(format!(
@@ -4890,8 +4908,8 @@ impl Engine {
model: model.clone(),
auto_model,
receipt: route_receipt,
// A start is not a dispatch. The billing envelope is attached
// below, on the route held for the wire boundary only.
// A start is not an application dispatch. The billing envelope is
// attached below, then stamped at the pre-permit admission boundary.
billing: None,
// The classification receipt, by contrast, is frozen here at the
// client-freeze boundary and is readable from `TurnStarted` on.
@@ -4916,10 +4934,14 @@ impl Engine {
)
.map(str::to_string),
endpoint_fingerprint: route_base_url.and_then(crate::cost_status::endpoint_fingerprint),
// A live rate is not evidence at turn creation. `turn_loop`
// freezes it from the exact fresh cache scope at CodeWhale's
// pre-permit application-dispatch boundary.
provider_live_pricing: None,
// Classified from this turn's own frozen receipt, not from a
// second ambient `for_route` read. Both halves of the route then
// answer from the same captured endpoint + credential product, so
// the envelope stamped on the wire and the receipt carried on
// the application-dispatch envelope and the receipt carried on
// `TurnRoute` cannot disagree about how this turn bills.
billing_mode: crate::route_billing::for_dispatched_receipt(
crate::route_billing::DispatchedReceipt {
@@ -4930,8 +4952,8 @@ impl Engine {
},
)
.into(),
// Provisional. Replaced with the true wire-boundary instant
// when `run_turn` emits `Event::RouteDispatched`.
// Provisional. Replaced with the pre-permit application-dispatch
// instant when `run_turn` emits `Event::RouteDispatched`.
dispatched_at: turn_started_at,
};
turn.pending_route = Some(TurnRoute {
@@ -4952,30 +4974,57 @@ impl Engine {
.await;
self.emit_pending_snapshot_notices().await;
// Auto's classifier completed before this parent turn was admitted.
// Bind its exact routed records to the now-accepted turn: total tokens
// and model-call telemetry include the work, while parent_route_usage
// remains untouched so the parent quote can never price it.
turn.add_routed_usages(
initial_routed_usage
.records
.iter()
.map(|record| &record.usage.usage),
);
// Exact missing-usage records are persisted route-aware by the runtime
// sink. TurnComplete carries only any count whose exact route record
// was truncated, otherwise the terminal merge would count the same
// provider response twice and misclassify subscription/local calls.
let residual_dropped_records = initial_routed_usage.dropped_records.saturating_sub(
u64::try_from(initial_routed_usage.drop_records.len()).unwrap_or(u64::MAX),
);
turn.add_routed_usage_dropped_records(residual_dropped_records);
let initial_cost_scope = crate::cost_status::scope_token();
for record in &initial_routed_usage.records {
crate::cost_status::report_effective_route_for_runtime(
initial_cost_scope,
initial_usage_owner.as_deref(),
&record.source_id,
&record.usage.route,
&record.usage.usage,
);
let _ = self
.tx_event
.send(Event::RoutedTurnUsage {
usage: record.usage.usage.clone(),
duration_ms: 0,
first_token_ms: None,
request_ms: None,
})
.await;
}
for record in &initial_routed_usage.drop_records {
crate::cost_status::report_unreceipted_provider_success(
initial_cost_scope,
initial_usage_owner.as_deref(),
&record.source_id,
&record.route,
);
}
// Apply the host-resolved route budget before building the request.
// The model, limits, and compaction policy arrive in one operation so
// no provider request can observe a partially updated route.
self.active_route_limits = route_limits;
self.config.compaction = compaction;
// Headless/runtime hosts supply their durable turn owner. Interactive
// turns historically supplied none, leaving a detached child with
// only the soon-to-be-sealed mailbox. Give this turn an owner whose
// sink folds into the existing session cost pool; cloned child leases
// keep it live beyond TurnComplete without reopening the mailbox.
let interactive_runtime_cost_owner = if self.config.terminal_chrome_enabled
&& self.config.compaction.runtime_cost_owner.is_none()
{
let owner = format!("interactive:{}:{}", self.session.id, turn.id);
crate::cost_status::register_interactive_runtime_usage_sink(
&owner,
crate::cost_status::scope_token(),
);
self.config.compaction.runtime_cost_owner = Some(owner.clone());
Some(owner)
} else {
None
};
// Snapshot the workspace BEFORE we touch a single tool. Run the git
// work on the blocking pool so the async runtime stays responsive;
// failure is non-fatal (the helper logs at WARN).
@@ -5026,6 +5075,8 @@ impl Engine {
.tx_event
.send(Event::TurnComplete {
usage: turn.usage.clone(),
parent_route_usage: turn.parent_route_usage.clone(),
routed_usage_dropped_records: turn.routed_usage_dropped_records,
status: TurnOutcomeStatus::Failed,
error: Some(message.clone()),
tool_catalog: None,
@@ -5044,6 +5095,27 @@ impl Engine {
return outcome;
}
// Headless/runtime hosts supply their durable turn owner. Interactive
// turns historically supplied none, leaving a detached child with
// only the soon-to-be-sealed mailbox. Install this turn-local sink
// only after every pre-dispatch failure return, and retire/clear it at
// settlement so the next turn always receives a fresh owner.
let interactive_runtime_cost_owner = if self.config.terminal_chrome_enabled
&& self.config.compaction.runtime_cost_owner.is_none()
{
let owner = format!("interactive:{}:{}", self.session.id, turn.id);
crate::cost_status::register_persistent_interactive_runtime_usage_sink(
&owner,
crate::cost_status::scope_token(),
&self.session.id,
&turn.id,
);
self.config.compaction.runtime_cost_owner = Some(owner.clone());
Some(owner)
} else {
None
};
let previous_goal_objective = self.config.goal_objective.clone();
let previous_goal_token_budget = self.config.goal_token_budget;
let previous_goal_status = self.config.goal_status;
@@ -5256,8 +5328,25 @@ impl Engine {
barrier.cancel_and_flush().await;
}
}
// The advisor is dispatched after TurnComplete, but its usage still
// belongs to this originating turn. Acquire the owner lease before an
// interactive owner is marked terminal so a late provider response
// retains its exact sink instead of falling into a later session.
let advisor_usage_context = (self.config.advisor_config.enabled
&& status == TurnOutcomeStatus::Completed
&& self.deepseek_client.is_some())
.then(|| {
crate::tools::subagent::advisor::AdvisorUsageContext::capture(
self.config.compaction.runtime_cost_owner.as_deref(),
)
});
if let Some(owner) = interactive_runtime_cost_owner.as_deref() {
crate::cost_status::finish_runtime_usage_owner(owner);
// This owner is turn-local. Leaving it in the reusable engine
// config makes the next interactive turn skip registration and
// route background usage into a retired sink/journal. Host-owned
// runtime turn ids never enter this branch and remain untouched.
self.config.compaction.runtime_cost_owner = None;
}
// Emit turn complete event — after all post-turn bookkeeping so
@@ -5270,6 +5359,8 @@ impl Engine {
.tx_event
.send(Event::TurnComplete {
usage: turn.usage,
parent_route_usage: turn.parent_route_usage,
routed_usage_dropped_records: turn.routed_usage_dropped_records,
status,
error: error.clone(),
tool_catalog: tool_catalog_for_event,
@@ -5315,6 +5406,7 @@ impl Engine {
if self.config.advisor_config.enabled
&& matches!(status, TurnOutcomeStatus::Completed)
&& let Some(client) = self.deepseek_client.clone()
&& let Some(usage_context) = advisor_usage_context
{
// Lazily create the shared emission guard on first use.
let guard = self
@@ -5328,6 +5420,10 @@ impl Engine {
let advisor_messages: Vec<crate::models::Message> = self.session.messages.to_vec();
let advisor_config = self.config.advisor_config.clone();
// This clone is frozen before the detached task starts and keeps
// every configured provider route available for an explicit
// cross-provider advisor model without consulting later UI state.
let advisor_route_config = self.api_config.clone();
let advisor_model = self.session.model.clone();
let advisor_tx = self.tx_event.clone();
let advisor_turn_id = turn.id.clone();
@@ -5341,7 +5437,9 @@ impl Engine {
advisor_messages,
advisor_config,
client,
advisor_route_config,
advisor_model,
usage_context,
guard,
advisor_tx,
)
@@ -5433,6 +5531,8 @@ impl Engine {
.tx_event
.send(Event::TurnComplete {
usage: Usage::default(),
parent_route_usage: Usage::default(),
routed_usage_dropped_records: 0,
status: TurnOutcomeStatus::Interrupted,
error: None,
tool_catalog: None,
@@ -5457,6 +5557,21 @@ impl Engine {
self.handle_manual_compaction(id, cancel_token).await;
}
async fn emit_compaction_usage(&self, usage: &Usage, elapsed: Duration) {
if *usage == Usage::default() {
return;
}
let _ = self
.tx_event
.send(Event::RoutedTurnUsage {
usage: usage.clone(),
duration_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
first_token_ms: None,
request_ms: None,
})
.await;
}
async fn handle_manual_compaction(&mut self, id: String, cancel_token: CancellationToken) {
let zero_usage = Usage {
input_tokens: 0,
@@ -5476,6 +5591,8 @@ impl Engine {
.tx_event
.send(Event::TurnComplete {
usage: zero_usage,
parent_route_usage: Usage::default(),
routed_usage_dropped_records: 0,
status: TurnOutcomeStatus::Failed,
error: Some(message),
tool_catalog: None,
@@ -5495,6 +5612,8 @@ impl Engine {
let prepared = self.prepare_compaction_envelope(self.config.compaction.clone());
let started = Instant::now();
let mut compaction_usage = Usage::default();
let compaction_result = tokio::select! {
biased;
_ = cancel_token.cancelled() => None,
@@ -5503,8 +5622,13 @@ impl Engine {
&self.session.messages,
self.session.system_prompt.as_ref(),
&prepared,
&mut compaction_usage,
) => Some(result),
};
self.session.total_usage.add(&compaction_usage);
self.record_goal_usage_for_turn(&compaction_usage, started.elapsed());
self.emit_compaction_usage(&compaction_usage, started.elapsed())
.await;
let Some(compaction_result) = compaction_result else {
self.finish_compaction(&id);
@@ -5517,7 +5641,9 @@ impl Engine {
let _ = self
.tx_event
.send(Event::TurnComplete {
usage: zero_usage,
usage: compaction_usage,
parent_route_usage: Usage::default(),
routed_usage_dropped_records: 0,
status: TurnOutcomeStatus::Interrupted,
error: None,
tool_catalog: None,
@@ -5544,7 +5670,9 @@ impl Engine {
let _ = self
.tx_event
.send(Event::TurnComplete {
usage: zero_usage,
usage: compaction_usage,
parent_route_usage: Usage::default(),
routed_usage_dropped_records: 0,
status: TurnOutcomeStatus::Interrupted,
error: None,
tool_catalog: None,
@@ -5609,7 +5737,9 @@ impl Engine {
let _ = self
.tx_event
.send(Event::TurnComplete {
usage: zero_usage,
usage: compaction_usage,
parent_route_usage: Usage::default(),
routed_usage_dropped_records: 0,
status: turn_status,
error: turn_error,
tool_catalog: None,
@@ -5635,6 +5765,8 @@ impl Engine {
.tx_event
.send(Event::TurnComplete {
usage: zero_usage,
parent_route_usage: Usage::default(),
routed_usage_dropped_records: 0,
status: TurnOutcomeStatus::Failed,
error: Some(message),
tool_catalog: None,
@@ -5693,6 +5825,8 @@ impl Engine {
.tx_event
.send(Event::TurnComplete {
usage: zero_usage,
parent_route_usage: Usage::default(),
routed_usage_dropped_records: 0,
status,
error,
tool_catalog: None,
@@ -5770,6 +5904,7 @@ impl Engine {
&mut self,
client: &dyn crate::core::model_client::ModelClient,
reason: &str,
turn: &mut TurnContext,
) -> bool {
let Some(target_budget) = context_input_budget_for_route(
self.api_provider,
@@ -5811,6 +5946,8 @@ impl Engine {
.max(1);
let prepared = self.prepare_compaction_envelope(forced_config);
let started = Instant::now();
let mut compaction_usage = Usage::default();
let (compaction_result, turn_was_canceled) = tokio::select! {
biased;
_ = turn_cancel.cancelled() => (None, true),
@@ -5820,8 +5957,12 @@ impl Engine {
&self.session.messages,
self.session.system_prompt.as_ref(),
&prepared,
&mut compaction_usage,
) => (Some(result), false),
};
turn.add_usage(&compaction_usage);
self.emit_compaction_usage(&compaction_usage, started.elapsed())
.await;
let Some(compaction_result) = compaction_result else {
self.finish_compaction(&id);
let message = if turn_was_canceled {
@@ -962,6 +962,7 @@ async fn assert_preview_matches_first_wire_body(
AppMode::Agent,
production_route,
compaction,
crate::cost_status::RuntimeUsageBatch::default(),
goal_objective,
None,
goal_status,
@@ -1926,6 +1927,7 @@ async fn provider_reported_usage_is_unavailable_until_a_response_reports_it() {
AppMode::Agent,
production_route,
compaction,
crate::cost_status::RuntimeUsageBatch::default(),
None,
None,
GoalStatus::Active,
+269 -7
View File
@@ -253,6 +253,106 @@ struct BlockingEmergencyCompactionModelClient {
request_dropped: std::sync::Arc<std::sync::atomic::AtomicBool>,
}
#[tokio::test]
async fn manual_compaction_accounts_accepted_and_rejected_responses_once() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let _env_lock = lock_test_env();
let _cost_scope = crate::cost_status::test_scope();
let workspace = tempdir().expect("isolated compaction workspace");
let _home = EnvVarGuard::set("CODEWHALE_HOME", workspace.path());
for (finish_reason, expected_status) in [
("stop", TurnOutcomeStatus::Completed),
("length", TurnOutcomeStatus::Failed),
] {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": format!("compaction-{finish_reason}"),
"object": "chat.completion",
"model": crate::config::DEFAULT_TEXT_MODEL,
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": "Primary request: preserve the session migration. Completed: inspected the existing store. Constraints: keep every user message and failing test. Next: finish the transactional migration and rerun session_store::roundtrip." },
"finish_reason": finish_reason,
}],
"usage": { "prompt_tokens": 41, "completion_tokens": 7, "total_tokens": 48 },
})))
.expect(1)
.mount(&server)
.await;
let route_config = Config {
provider: Some("deepseek".to_string()),
api_key: Some("fixture-key".to_string()),
base_url: Some(format!("{}/v1", server.uri())),
..Config::default()
};
let (mut engine, handle) = Engine::new(
EngineConfig {
workspace: workspace.path().to_path_buf(),
snapshots_enabled: false,
subagents_enabled: false,
..EngineConfig::default()
},
&route_config,
);
engine.session.messages.push(Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: "Preserve the transactional session migration.".to_string(),
cache_control: None,
}],
});
engine.config.goal_state.lock().unwrap().replace(
"Finish the session migration",
Some(1000),
None,
);
engine
.handle_manual_compaction("compact-accounting".to_string(), CancellationToken::new())
.await;
assert_eq!(engine.session.total_usage.input_tokens, 41);
assert_eq!(engine.session.total_usage.output_tokens, 7);
assert_eq!(
engine
.config
.goal_state
.lock()
.unwrap()
.snapshot()
.tokens_used,
48
);
let mut events = handle.rx_event.write().await;
let mut telemetry_count = 0;
let mut terminal_count = 0;
while let Ok(event) = events.try_recv() {
match event {
Event::RoutedTurnUsage { usage, .. } => {
telemetry_count += 1;
assert_eq!((usage.input_tokens, usage.output_tokens), (41, 7));
}
Event::TurnComplete {
usage,
parent_route_usage,
status,
..
} => {
terminal_count += 1;
assert_eq!((usage.input_tokens, usage.output_tokens), (41, 7));
assert_eq!(parent_route_usage, Usage::default());
assert_eq!(status, expected_status);
}
_ => {}
}
}
assert_eq!((telemetry_count, terminal_count), (1, 1));
}
}
#[async_trait::async_trait]
impl crate::core::model_client::ModelClient for BlockingEmergencyCompactionModelClient {
fn provider_name(&self) -> &str {
@@ -316,8 +416,9 @@ async fn emergency_compaction_cancellation_drops_provider_and_never_mutates_cont
});
let recovery = tokio::spawn(async move {
let mut turn = TurnContext::new(1);
let recovered = engine
.recover_context_overflow(client.as_ref(), "cancellation regression")
.recover_context_overflow(client.as_ref(), "cancellation regression", &mut turn)
.await;
(engine, recovered)
});
@@ -677,6 +778,7 @@ async fn exact_turn_snapshot_restores_custom_endpoint_and_turn_receipt_after_bui
.expect("resolve exact custom route"),
),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: None,
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
@@ -1047,6 +1149,7 @@ async fn goal_continuation_preserves_goal_and_resolves_updated_authoritative_rou
mode: AppMode::Agent,
route: resolved_route_for_test(&config, "local-model"),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: Some("keep going".to_string()),
goal_token_budget: Some(50_000),
goal_status: crate::tools::goal::GoalStatus::Active,
@@ -1326,6 +1429,7 @@ async fn saturated_mailbox_does_not_deadlock_goal_continuation_self_dispatch() {
mode: AppMode::Agent,
route: resolved_route_for_test(&config, "local-model"),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: Some("survive a saturated mailbox".to_string()),
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
@@ -1454,6 +1558,7 @@ async fn queued_ordinary_turn_does_not_multiply_engine_goal_continuations() {
mode: AppMode::Agent,
route: resolved_route_for_test(&config, "local-model"),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: Some("coalesce queued goal turns".to_string()),
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
@@ -2651,6 +2756,7 @@ async fn cross_turn_token_budget_exhaustion_does_not_pause_goal() {
mode: AppMode::Agent,
route: resolved_route_for_test(&config, crate::config::DEFAULT_TEXT_MODEL),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: Some("finish within budget".to_string()),
goal_token_budget: Some(10),
goal_status: crate::tools::goal::GoalStatus::Active,
@@ -3108,6 +3214,7 @@ async fn explicit_natural_goal_activates_before_provider_request() {
mode: AppMode::Agent,
route: resolved_route_for_test(&config, "local-model"),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: None,
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
@@ -3218,6 +3325,7 @@ async fn operate_goal_probe(mode: AppMode, prompt: &str) -> (Option<String>, boo
mode,
route: resolved_route_for_test(&config, "local-model"),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: None,
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
@@ -3348,6 +3456,7 @@ async fn operate_contract_is_appended_once_and_an_existing_goal_is_never_replace
mode: AppMode::Operate,
route: resolved_route_for_test(&config, "local-model"),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective,
goal_token_budget: None,
goal_status,
@@ -4033,6 +4142,7 @@ async fn host_managed_engine_does_not_self_dispatch_goal_continuation() {
mode: AppMode::Agent,
route: resolved_route_for_test(&config, "local-model"),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: Some("keep going".to_string()),
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
@@ -4157,6 +4267,7 @@ async fn host_managed_engine_defers_idle_subagent_completion_to_explicit_turn()
mode: AppMode::Agent,
route: resolved_route_for_test(&config, "local-model"),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: None,
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
@@ -4362,14 +4473,14 @@ fn subagent_mailbox_keeps_lifecycle_events_reliable() {
&MailboxMessage::TokenUsage {
agent_id: "agent_a".to_string(),
source_id: "response-a".to_string(),
route: crate::cost_status::EffectiveRouteEnvelope::capture(
route: Box::new(crate::cost_status::EffectiveRouteEnvelope::capture(
None,
ApiProvider::Deepseek,
"deepseek",
"model",
Some(ApiProvider::Deepseek.default_base_url()),
chrono::Utc::now(),
),
)),
usage: Usage::default(),
}
));
@@ -4456,14 +4567,14 @@ fn subagent_mailbox_never_samples_lifecycle_or_usage_events() {
&MailboxMessage::TokenUsage {
agent_id: "agent_a".to_string(),
source_id: "response-a".to_string(),
route: crate::cost_status::EffectiveRouteEnvelope::capture(
route: Box::new(crate::cost_status::EffectiveRouteEnvelope::capture(
None,
ApiProvider::Deepseek,
"deepseek",
"model",
Some(ApiProvider::Deepseek.default_base_url()),
chrono::Utc::now(),
),
)),
usage: Usage::default(),
},
start,
@@ -5666,6 +5777,7 @@ fn active_goal_message_op(
mode: AppMode::Agent,
route: resolved_route_for_test(config, "local-model"),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: Some(objective.to_string()),
goal_token_budget: token_budget,
goal_status: crate::tools::goal::GoalStatus::Active,
@@ -5702,6 +5814,7 @@ fn external_user_message_op(content: &str, mode: AppMode, config: &Config) -> Op
mode,
route: resolved_route_for_test(config, crate::config::DEFAULT_TEXT_MODEL),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: None,
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
@@ -5727,6 +5840,7 @@ fn auto_review_message_op(content: &str, config: &Config) -> Op {
mode: AppMode::Agent,
route: resolved_route_for_test(config, crate::config::DEFAULT_TEXT_MODEL),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: None,
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
@@ -6003,8 +6117,11 @@ async fn normal_repl_kernel_persists_across_user_turns() {
assert_eq!(first_turn.usage.output_tokens, 11);
let child_usage_event = {
let mut events = handle.rx_event.write().await;
// Kernel child calls carry their own routed cost receipt, so their
// per-call telemetry arrives as `RoutedTurnUsage` rather than the
// parent-route `TurnUsage` receipt.
std::iter::from_fn(|| events.try_recv().ok()).find_map(|event| match event {
Event::TurnUsage { usage, .. }
Event::RoutedTurnUsage { usage, .. }
if usage.input_tokens == 7 && usage.output_tokens == 11 =>
{
Some(usage)
@@ -6358,6 +6475,131 @@ fn deterministic_engine_config(workspace: &Path) -> EngineConfig {
}
}
#[tokio::test]
async fn initial_routed_usage_is_total_only_emitted_once_and_keeps_parent_route_separate() {
use crate::llm_client::mock::{MockLlmClient, canned};
let _cost_scope = crate::cost_status::test_scope();
let workspace = tempdir().expect("tempdir");
let parent_usage = Usage {
input_tokens: 11,
output_tokens: 3,
..Usage::default()
};
let classifier_usage = Usage {
input_tokens: 7,
output_tokens: 5,
..Usage::default()
};
let mock = std::sync::Arc::new(MockLlmClient::new(vec![vec![
canned::message_start("parent-response"),
canned::text_block_start(0),
canned::text_delta(0, "done"),
canned::block_stop(0),
canned::message_delta("end_turn", Some(parent_usage.clone())),
canned::message_stop(),
]]));
let client: crate::core::model_client::SharedModelClient = mock;
let api_config = Config::default();
let (engine, handle) = Engine::new_with_model_client(
deterministic_engine_config(workspace.path()),
&api_config,
client,
);
let task = tokio::spawn(engine.run());
let mut op = external_user_message_op("account for classifier", AppMode::Agent, &api_config);
let Op::SendMessage {
initial_routed_usage,
..
} = &mut op
else {
unreachable!("external_user_message_op always builds SendMessage");
};
let mut missing_usage_route = crate::cost_status::EffectiveRouteEnvelope::capture(
None,
ApiProvider::Openai,
"openai",
"classifier-model",
Some(ApiProvider::Openai.default_base_url()),
chrono::Utc::now(),
);
missing_usage_route.billing_mode = crate::cost_status::RouteBillingMode::Metered;
**initial_routed_usage = crate::cost_status::RuntimeUsageBatch {
records: vec![crate::cost_status::RuntimeUsageRecord {
source_id: "auto-router:engine-fixture".to_string(),
usage: crate::cost_status::EffectiveRouteUsage {
route: crate::cost_status::EffectiveRouteEnvelope::capture(
None,
ApiProvider::Openai,
"openai",
"classifier-model",
Some(ApiProvider::Openai.default_base_url()),
chrono::Utc::now(),
),
usage: classifier_usage.clone(),
},
}],
drop_records: vec![crate::cost_status::RuntimeUsageDropRecord {
source_id: "auto-router:engine-fixture:missing-usage".to_string(),
route: missing_usage_route,
}],
// One exact route-aware missing receipt plus two residual gaps whose
// route identity was truncated upstream.
dropped_records: 3,
};
handle.send(op).await.expect("send routed-usage turn");
let mut routed_events = Vec::new();
let mut rx = handle.rx_event.write().await;
let (total_usage, terminal_parent_usage, dropped_records) = loop {
let event = tokio::time::timeout(model_turn_event_timeout(), rx.recv())
.await
.expect("timed out waiting for routed-usage turn")
.expect("engine event stream closed");
match event {
Event::RoutedTurnUsage { usage, .. } => routed_events.push(usage),
Event::TurnComplete {
usage,
parent_route_usage,
routed_usage_dropped_records,
status,
error,
..
} => {
assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
break (usage, parent_route_usage, routed_usage_dropped_records);
}
_ => {}
}
};
drop(rx);
assert_eq!(routed_events, vec![classifier_usage.clone()]);
assert_eq!(terminal_parent_usage, parent_usage);
assert_eq!(total_usage.input_tokens, 18);
assert_eq!(total_usage.output_tokens, 8);
assert_eq!(dropped_records, 2);
let initial_cost = crate::cost_status::drain();
assert!(
initial_cost.usage_source_fingerprints.contains(
&crate::cost_status::usage_source_fingerprint(
"auto-router:engine-fixture:missing-usage"
)
),
"exact missing-usage response identity was not settled"
);
assert!(
initial_cost
.unpriced_reasons
.contains("provider_success_missing_usage"),
"metered missing-usage route was not marked incomplete"
);
handle.send(Op::Shutdown).await.expect("shutdown engine");
task.await.expect("engine task");
}
#[tokio::test]
async fn isolated_runtime_chat_provider_request_contains_no_host_context_or_tools() {
use crate::llm_client::mock::{MockLlmClient, canned};
@@ -7747,7 +7989,12 @@ async fn collect_guardian_journey_with_receipts(
"duplicate tool result"
);
}
Event::TurnUsage { usage, .. } => usage_events.push(usage),
// Guardian consults carry their own routed receipt; both the
// parent-route and routed per-call telemetry count as reaching
// the cost UI.
Event::TurnUsage { usage, .. } | Event::RoutedTurnUsage { usage, .. } => {
usage_events.push(usage);
}
Event::ToolGateDecision {
tool_id,
gate,
@@ -11697,6 +11944,7 @@ async fn operate_model_shell_uses_normal_approval_and_workspace_sandbox() {
mode: AppMode::Operate,
route: resolved_route_for_test(&api_config, crate::config::DEFAULT_TEXT_MODEL),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: None,
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
@@ -11851,6 +12099,7 @@ async fn full_access_subagent_handoff_keeps_model_shell_free_of_approval_prompts
mode: AppMode::Agent,
route: resolved_route_for_test(&api_config, crate::config::DEFAULT_TEXT_MODEL),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: None,
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
@@ -11987,6 +12236,7 @@ async fn assert_full_access_model_tool_batch_is_blocked(
mode: AppMode::Agent,
route: resolved_route_for_test(&api_config, crate::config::DEFAULT_TEXT_MODEL),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: None,
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
@@ -12192,6 +12442,7 @@ async fn assert_full_access_model_tool_batch_runs(
mode: AppMode::Agent,
route: resolved_route_for_test(&api_config, crate::config::DEFAULT_TEXT_MODEL),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: None,
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
@@ -12468,6 +12719,7 @@ async fn auto_review_auto_resolves_hallucinated_question_without_prompting() {
mode: AppMode::Agent,
route: resolved_route_for_test(&api_config, crate::config::DEFAULT_TEXT_MODEL),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: None,
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
@@ -12654,6 +12906,7 @@ async fn full_access_permission_allow_cannot_bypass_background_catastrophic_floo
mode: AppMode::Agent,
route: resolved_route_for_test(&api_config, crate::config::DEFAULT_TEXT_MODEL),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: None,
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
@@ -12794,6 +13047,7 @@ async fn yolo_mode_does_not_prompt_for_background_shell() {
mode: AppMode::Agent,
route: resolved_route_for_test(&api_config, crate::config::DEFAULT_TEXT_MODEL),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: None,
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
@@ -12930,6 +13184,7 @@ async fn yolo_mode_executes_publish_like_shell_without_prompt() {
mode: AppMode::Agent,
route: resolved_route_for_test(&api_config, crate::config::DEFAULT_TEXT_MODEL),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: None,
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
@@ -13070,6 +13325,7 @@ async fn yolo_mode_does_not_prompt_for_mcp_action() {
mode: AppMode::Agent,
route: resolved_route_for_test(&api_config, crate::config::DEFAULT_TEXT_MODEL),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: None,
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
@@ -19449,6 +19705,7 @@ async fn run_headless_turn_with_flaky_network(
mode: AppMode::Agent,
route: resolved_route_for_test(&config, crate::config::DEFAULT_TEXT_MODEL),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: None,
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
@@ -19574,6 +19831,7 @@ async fn terminal_output_limit_followed_by_stream_error_is_charged_and_not_retri
mode: AppMode::Agent,
route: resolved_route_for_test(&config, crate::config::DEFAULT_TEXT_MODEL),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: None,
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
@@ -19678,6 +19936,7 @@ async fn midstream_error_frame_stops_the_stream_and_drops_trailing_deltas() {
mode: AppMode::Agent,
route: resolved_route_for_test(&config, crate::config::DEFAULT_TEXT_MODEL),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: None,
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
@@ -19925,6 +20184,7 @@ async fn run_interactive_turn_with_flaky_network(
mode: AppMode::Agent,
route: resolved_route_for_test(&config, crate::config::DEFAULT_TEXT_MODEL),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: None,
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
@@ -20153,6 +20413,7 @@ async fn interactive_thinking_only_drop_preserves_nothing_and_never_claims_it_di
mode: AppMode::Agent,
route: resolved_route_for_test(&config, crate::config::DEFAULT_TEXT_MODEL),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: None,
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
@@ -20400,6 +20661,7 @@ async fn run_reasoning_only_turn_with_reprompts(
mode: AppMode::Agent,
route: resolved_route_for_test(&config, crate::config::DEFAULT_TEXT_MODEL),
compaction: Box::new(CompactionConfig::default()),
initial_routed_usage: Box::default(),
goal_objective: None,
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
+105 -11
View File
@@ -579,16 +579,25 @@ impl Engine {
context.tool_name
)))
.await;
let cost_scope = crate::cost_status::scope_token();
let review_route = client.effective_route_envelope(client.model(), chrono::Utc::now());
let started = Instant::now();
let review =
super::reviewer::consult_reviewer(client, &context_text, &self.cancel_token).await;
if let Some(usage) = &review.usage {
turn.add_usage(usage);
crate::cost_status::report_effective_route_for_runtime(
cost_scope,
self.config.compaction.runtime_cost_owner.as_deref(),
&format!("auto-review:{}:{tool_id}", turn.id),
&review_route,
usage,
);
if usage_has_reported_data(usage) {
let request_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
let _ = self
.tx_event
.send(Event::TurnUsage {
.send(Event::RoutedTurnUsage {
usage: usage.clone(),
duration_ms: request_ms,
first_token_ms: None,
@@ -596,6 +605,12 @@ impl Engine {
})
.await;
}
} else if matches!(
&review.outcome,
super::reviewer::ReviewerOutcome::Unavailable { reason }
if reason == "the reviewer timed out" || reason == "the reviewer request failed"
) {
turn.add_routed_usage_dropped_records(1);
}
let decision = review.outcome.audit_decision();
let risk = review.outcome.audit_risk();
@@ -1033,6 +1048,8 @@ impl Engine {
let auto_messages_before = self.session.messages.len();
let auto_tokens_before = self.estimated_input_tokens();
let turn_cancel = self.cancel_token.clone();
let started = Instant::now();
let mut compaction_usage = Usage::default();
let (compaction_result, turn_was_canceled) = tokio::select! {
biased;
_ = turn_cancel.cancelled() => (None, true),
@@ -1042,8 +1059,12 @@ impl Engine {
&self.session.messages,
self.session.system_prompt.as_ref(),
&prepared,
&mut compaction_usage,
) => (Some(result), false),
};
turn.add_usage(&compaction_usage);
self.emit_compaction_usage(&compaction_usage, started.elapsed())
.await;
let Some(compaction_result) = compaction_result else {
self.finish_compaction(&compaction_id);
let message = if turn_was_canceled {
@@ -1201,7 +1222,7 @@ impl Engine {
}
if self
.recover_context_overflow(client.as_ref(), "preflight token budget")
.recover_context_overflow(client.as_ref(), "preflight token budget", turn)
.await
{
context_recovery_attempts = context_recovery_attempts.saturating_add(1);
@@ -1442,7 +1463,26 @@ impl Engine {
.await;
if let Some(mut route) = turn.pending_route.take() {
if let Some(billing) = route.billing.as_mut() {
billing.dispatched_at = chrono::Utc::now();
// Freeze the exact provider-live row at CodeWhale's
// pre-permit application-dispatch boundary. This is an
// admission contract, not provider invoice-time evidence;
// a later cancellation/preparation failure has no usage
// and therefore contributes no usage cost.
let dispatched_at = chrono::Utc::now();
billing.dispatched_at = dispatched_at;
billing.provider_live_pricing = u64::try_from(dispatched_at.timestamp())
.ok()
.and_then(|dispatched_at_unix| {
billing.endpoint_fingerprint.as_deref().and_then(|fingerprint| {
crate::provider_catalog_live::fresh_provider_live_pricing_quote_at(
route.provider,
&route.provider_identity,
&route.model,
fingerprint,
dispatched_at_unix,
)
})
});
}
let _ = self
.tx_event
@@ -1479,6 +1519,7 @@ impl Engine {
.recover_context_overflow(
client.as_ref(),
"provider context-length rejection",
turn,
)
.await
{
@@ -2080,8 +2121,7 @@ impl Engine {
1,
)
});
let bridge_usage_handle =
bridge.as_ref().map(crate::rlm::RlmBridge::usage_handle);
let repl_cost_scope = crate::cost_status::scope_token();
let repl_started = Instant::now();
let mut final_result: Option<String> = None;
@@ -2219,14 +2259,18 @@ impl Engine {
// into the parent turn exactly once, including failures
// after a partial fan-out, so `/cost`, goals, and the
// final receipt cannot undercount the working kernel.
if let Some(usage_handle) = bridge_usage_handle {
let child_usage = usage_handle.lock().await.clone();
turn.add_usage(&child_usage);
if usage_has_reported_data(&child_usage) {
if let Some(bridge) = bridge.as_ref() {
let snapshot = bridge.usage_snapshot().await;
turn.add_usage(&snapshot.usage);
let residual_dropped_records = snapshot.dropped_records.saturating_sub(
u64::try_from(snapshot.drop_records.len()).unwrap_or(u64::MAX),
);
turn.add_routed_usage_dropped_records(residual_dropped_records);
if usage_has_reported_data(&snapshot.usage) {
let _ = self
.tx_event
.send(Event::TurnUsage {
usage: child_usage,
.send(Event::RoutedTurnUsage {
usage: snapshot.usage.clone(),
duration_ms: u64::try_from(repl_started.elapsed().as_millis())
.unwrap_or(u64::MAX),
first_token_ms: None,
@@ -2234,6 +2278,23 @@ impl Engine {
})
.await;
}
for record in snapshot.records {
crate::cost_status::report_effective_route_for_runtime(
repl_cost_scope,
self.config.compaction.runtime_cost_owner.as_deref(),
&record.source_id,
&record.usage.route,
&record.usage.usage,
);
}
for record in snapshot.drop_records {
crate::cost_status::report_unreceipted_provider_success(
repl_cost_scope,
self.config.compaction.runtime_cost_owner.as_deref(),
&record.source_id,
&record.route,
);
}
}
if let Some(final_val) = final_result {
@@ -2558,6 +2619,7 @@ impl Engine {
self.process_tool_results(
outcomes,
turn,
&mut tool_catalog,
&mut active_tool_names,
&hook_contexts,
@@ -4073,6 +4135,7 @@ impl Engine {
async fn process_tool_results(
&mut self,
outcomes: Vec<Option<ToolExecOutcome>>,
turn: &mut TurnContext,
tool_catalog: &mut Vec<crate::models::Tool>,
active_tool_names: &mut std::collections::HashSet<String>,
hook_contexts: &std::collections::HashMap<String, String>,
@@ -4090,12 +4153,43 @@ impl Engine {
let tool_input = outcome.input.clone();
let tool_name_for_ws = outcome.name.clone();
let terminal_status = outcome.terminal.status;
let routed_duration_ms =
u64::try_from(outcome.started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
let result = outcome.terminal.into_legacy_result();
if matches!(outcome.name.as_str(), "create_goal" | "update_goal") {
goal_tool_ran = true;
}
match result {
Ok(output) => {
let routed_usage = if let Some(metadata) = output.metadata.as_ref()
&& let Some(batch) =
crate::cost_status::child_usage_records_from_metadata(metadata)
{
let residual_dropped_records = batch.dropped_records.saturating_sub(
u64::try_from(batch.drop_records.len()).unwrap_or(u64::MAX),
);
turn.add_routed_usage_dropped_records(residual_dropped_records);
turn.add_routed_usages(
batch.records.iter().map(|record| &record.usage.usage),
)
} else if let Some(metadata) = output.metadata.as_ref()
&& let Some(usage) = crate::cost_status::child_usage_from_metadata(metadata)
{
turn.add_routed_usages(std::iter::once(&usage))
} else {
Usage::default()
};
if usage_has_reported_data(&routed_usage) {
let _ = self
.tx_event
.send(Event::RoutedTurnUsage {
usage: routed_usage,
duration_ms: routed_duration_ms,
first_token_ms: None,
request_ms: None,
})
.await;
}
let mut tool_surface_changed =
super::tool_catalog::activate_result_dependencies(
tool_catalog,
+38 -12
View File
@@ -48,13 +48,15 @@ pub struct TurnRoute {
/// `None` when no concrete client was installed (injected-client engines,
/// or a client that failed to construct).
pub receipt: Option<crate::route_receipt::TurnRouteReceipt>,
/// Billing evidence for the request that was actually put on the wire.
/// Billing evidence for a request admitted to application dispatch.
///
/// `None` at `TurnStarted`: a lifecycle start is not a dispatch, and a
/// route that has not been sent has no billing time, no metering surface,
/// and no endpoint to attest. Populated exactly once, at the wire
/// boundary, and delivered on `RouteDispatched`. Consumers that price a
/// turn must treat `None` as *unknown*, never as a zero-cost turn.
/// route that has not reached admission has no billing time, no metering
/// surface, and no endpoint to attest. Populated exactly once at the
/// pre-permit application-dispatch boundary and delivered on
/// `RouteDispatched`. This does not attest network delivery or a provider
/// invoice-time rate. Consumers that price a turn must treat `None` as
/// *unknown*, never as a zero-cost turn.
pub billing: Option<RouteBillingEnvelope>,
/// Endpoint this turn's client was frozen against, verbatim.
///
@@ -92,15 +94,17 @@ pub struct TurnRoute {
/// it bill* — a [`crate::route_billing::DispatchedReceipt`]. They must be
/// readable from `TurnStarted` onward so a child turn arriving mid-flight
/// can be billed against the parent's frozen route.
/// - This envelope is stamped at the **wire** boundary and answers *what was
/// actually put on the wire, when*. A planned-but-unsent route has no
/// metering surface and no dispatch instant, so it must be structurally
/// absent rather than defaulted.
/// - This envelope is stamped at the **pre-permit application-dispatch**
/// boundary and answers *what CodeWhale admitted for provider execution,
/// when*. It does not claim network delivery or provider invoice-time
/// pricing. A merely planned route has no metering surface or dispatch
/// instant, so it must be structurally absent rather than defaulted.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RouteBillingEnvelope {
pub openrouter_vendor: Option<String>,
pub billing_surface: Option<String>,
pub endpoint_fingerprint: Option<String>,
pub provider_live_pricing: Option<crate::provider_catalog_live::ProviderLivePricingQuote>,
pub billing_mode: crate::cost_status::RouteBillingMode,
pub dispatched_at: DateTime<Utc>,
}
@@ -119,6 +123,7 @@ impl TurnRoute {
openrouter_vendor: billing.openrouter_vendor.clone(),
billing_surface: billing.billing_surface.clone(),
endpoint_fingerprint: billing.endpoint_fingerprint.clone(),
provider_live_pricing: billing.provider_live_pricing.clone(),
billing_mode: billing.billing_mode,
dispatched_at: billing.dispatched_at,
})
@@ -225,7 +230,7 @@ pub enum Event {
turn_id: String,
created_at: DateTime<Utc>,
/// Legacy/non-model hosts may still attach a route at start. Model
/// turns emit it separately at the real provider dispatch boundary.
/// turns emit it separately at the application dispatch boundary.
route: Option<TurnRoute>,
},
@@ -235,13 +240,23 @@ pub enum Event {
snapshot: crate::tool_inspection::ToolInspectionSnapshot,
},
/// Immutable billing route captured immediately before the first provider
/// request, after snapshots and other potentially slow pre-dispatch work.
/// Immutable billing route captured at CodeWhale's pre-permit application
/// dispatch boundary, after request preparation. This is admission-time
/// evidence, not proof of network delivery or provider invoice-time rates.
RouteDispatched { turn_id: String, route: TurnRoute },
/// The turn is complete (no more tool calls)
TurnComplete {
/// Total usage for session/goal/token metrics, including programmatic
/// child calls performed inline during this turn.
usage: Usage,
/// Usage served by the parent turn's frozen route only. Consumers
/// price this under the parent quote and price routed children from
/// their own receipts, avoiding double billing without subtraction.
parent_route_usage: Usage,
/// Provider calls whose execution/usage could not be receipted.
/// Non-zero makes cost coverage explicitly incomplete.
routed_usage_dropped_records: u64,
status: TurnOutcomeStatus,
error: Option<String>,
/// Tool catalog sent with this turn's model request.
@@ -275,6 +290,17 @@ pub enum Event {
request_ms: Option<u64>,
},
/// Usage telemetry for a programmatic provider call whose cost is carried
/// by its own routed receipt rather than the active parent route. TUI
/// consumers fold this into model-call metrics only; `TurnComplete.usage`
/// remains the authoritative total-token reconciliation.
RoutedTurnUsage {
usage: Usage,
duration_ms: u64,
first_token_ms: Option<u64>,
request_ms: Option<u64>,
},
/// Runtime goal state changed inside the engine, usually from model-visible
/// `create_goal` or `update_goal` tool calls.
GoalUpdated { snapshot: GoalSnapshot },
+5
View File
@@ -117,6 +117,11 @@ pub enum Op {
/// Compaction policy derived from the same provider route. Carrying it
/// atomically avoids a model/limit mismatch before `SendMessage`.
compaction: Box<CompactionConfig>,
/// Auxiliary provider calls completed while planning this exact turn
/// (currently Auto's classifier), bounded and paired with their own
/// immutable routes. The engine folds their tokens into total usage
/// only; they never enter the parent route's billing aggregate.
initial_routed_usage: Box<crate::cost_status::RuntimeUsageBatch>,
goal_objective: Option<String>,
goal_token_budget: Option<u32>,
goal_status: GoalStatus,
+119 -1
View File
@@ -178,6 +178,11 @@ fn billing_to_wire(billing: &RouteBillingEnvelope) -> wire::RouteBillingEnvelope
.map(crate::cost_status::sanitize_persisted_route_label),
billing_surface: billing.billing_surface.clone(),
endpoint_fingerprint: billing.endpoint_fingerprint.clone(),
provider_live_pricing: billing
.provider_live_pricing
.as_ref()
.map(to_value)
.filter(|v| !v.is_null()),
billing_mode: billing_mode_str(billing.billing_mode).to_string(),
dispatched_at: billing.dispatched_at,
}
@@ -503,6 +508,8 @@ pub fn event_to_protocol(event: &Event, ids: &ProtocolIds) -> wire::EventMsg {
},
Event::TurnComplete {
usage,
parent_route_usage,
routed_usage_dropped_records,
status,
error,
tool_catalog,
@@ -514,6 +521,8 @@ pub fn event_to_protocol(event: &Event, ids: &ProtocolIds) -> wire::EventMsg {
status: outcome_status_to_wire(*status),
error: error.clone(),
usage: usage_to_wire(usage),
parent_route_usage: Some(usage_to_wire(parent_route_usage)),
routed_usage_dropped_records: *routed_usage_dropped_records,
tool_catalog: tool_catalog
.as_ref()
.map(|tools| tools.iter().map(to_value).collect()),
@@ -532,6 +541,19 @@ pub fn event_to_protocol(event: &Event, ids: &ProtocolIds) -> wire::EventMsg {
first_token_ms: *first_token_ms,
request_ms: *request_ms,
},
Event::RoutedTurnUsage {
usage,
duration_ms,
first_token_ms,
request_ms,
} => wire::EventMsg::RoutedTurnUsage {
thread_id,
session_id,
usage: usage_to_wire(usage),
duration_ms: *duration_ms,
first_token_ms: *first_token_ms,
request_ms: *request_ms,
},
Event::GoalUpdated { snapshot } => wire::EventMsg::GoalUpdated {
thread_id,
session_id,
@@ -912,6 +934,7 @@ pub fn op_to_protocol(op: &Op) -> wire_op::Op {
mode,
route,
compaction,
initial_routed_usage: _, // Host-owned accounting, never model input.
goal_objective,
goal_token_budget,
goal_status,
@@ -1198,6 +1221,89 @@ mod tests {
}
}
#[test]
fn wire_accounting_preserves_parent_total_and_distinct_routed_telemetry() {
let ids = ids();
let total = Usage {
input_tokens: 49,
output_tokens: 19,
..Usage::default()
};
let parent = Usage {
input_tokens: 7,
output_tokens: 5,
..Usage::default()
};
let complete = event_to_protocol(
&Event::TurnComplete {
usage: total.clone(),
parent_route_usage: parent.clone(),
routed_usage_dropped_records: 3,
status: TurnOutcomeStatus::Completed,
error: None,
tool_catalog: None,
base_url: None,
},
&ids,
);
let json = serde_json::to_value(&complete).unwrap();
assert_eq!(json["usage"]["input_tokens"], 49);
assert_eq!(json["usage"]["output_tokens"], 19);
assert_eq!(json["parent_route_usage"]["input_tokens"], 7);
assert_eq!(json["parent_route_usage"]["output_tokens"], 5);
assert_eq!(json["routed_usage_dropped_records"], 3);
assert_eq!(
serde_json::from_value::<wire::EventMsg>(json.clone()).unwrap(),
complete
);
// A legacy terminal receipt has no parent subset, which differs from
// an explicitly reported zero parent on a compaction-only turn.
let mut legacy = json;
legacy.as_object_mut().unwrap().remove("parent_route_usage");
legacy
.as_object_mut()
.unwrap()
.remove("routed_usage_dropped_records");
assert!(matches!(
serde_json::from_value::<wire::EventMsg>(legacy).unwrap(),
wire::EventMsg::TurnComplete {
parent_route_usage: None,
routed_usage_dropped_records: 0,
..
}
));
for (event, tag) in [
(
Event::TurnUsage {
usage: parent,
duration_ms: 12,
first_token_ms: Some(2),
request_ms: Some(10),
},
"turn_usage",
),
(
Event::RoutedTurnUsage {
usage: total,
duration_ms: 27,
first_token_ms: None,
request_ms: None,
},
"routed_turn_usage",
),
] {
let projected = event_to_protocol(&event, &ids);
let json = serde_json::to_value(&projected).unwrap();
assert_eq!(json["event"], tag);
assert_eq!(
serde_json::from_value::<wire::EventMsg>(json).unwrap(),
projected
);
}
}
/// The guard is the exhaustive `match` in `event_to_protocol`: this test
/// exists so the guard has a name in the test log and so the projection
/// is proven to agree with the protocol's wire-tag table.
@@ -1242,11 +1348,19 @@ mod tests {
},
Event::TurnComplete {
usage: usage.clone(),
parent_route_usage: usage.clone(),
routed_usage_dropped_records: 0,
status: TurnOutcomeStatus::Interrupted,
error: Some("stopped".into()),
tool_catalog: None,
base_url: Some("https://example.invalid".into()),
},
Event::RoutedTurnUsage {
usage: usage.clone(),
duration_ms: 12,
first_token_ms: Some(3),
request_ms: None,
},
Event::TurnUsage {
usage,
duration_ms: 12,
@@ -1321,7 +1435,11 @@ mod tests {
serde_json::to_value(events[8].to_protocol(&ids)).unwrap()["status"],
"interrupted"
);
let error = serde_json::to_value(events[10].to_protocol(&ids)).unwrap();
let error = events
.iter()
.find(|event| matches!(event, Event::Error { .. }))
.unwrap();
let error = serde_json::to_value(error.to_protocol(&ids)).unwrap();
assert_eq!(error["category"], "rate_limit");
assert_eq!(error["severity"], "warning");
}
+62 -27
View File
@@ -72,6 +72,16 @@ pub struct TurnContext {
/// Usage for this turn
pub usage: Usage,
/// Subset of `usage` served by the parent turn's frozen route. Programmatic
/// reviewer/RLM calls remain in the total above but are billed only from
/// their own routed receipts.
pub parent_route_usage: Usage,
/// Provider calls whose usage became ambiguous after dispatch (for
/// example an RLM timeout). A non-zero value makes cost coverage
/// explicitly incomplete instead of inventing a zero-usage receipt.
pub routed_usage_dropped_records: u64,
/// Input tokens reported for the most recent parent-route model request.
/// This is deliberately separate from `usage`, which accumulates every
/// parent step and programmatic child call for billing.
@@ -108,6 +118,8 @@ impl TurnContext {
output_tokens: 0,
..Usage::default()
},
parent_route_usage: Usage::default(),
routed_usage_dropped_records: 0,
latest_parent_input_tokens: None,
compaction_refusal_notified: false,
pending_route: None,
@@ -145,33 +157,7 @@ impl TurnContext {
/// Add usage from an API response
pub fn add_usage(&mut self, usage: &Usage) {
self.usage.input_tokens = self.usage.input_tokens.saturating_add(usage.input_tokens);
self.usage.output_tokens = self.usage.output_tokens.saturating_add(usage.output_tokens);
self.usage.prompt_cache_hit_tokens = add_optional_usage(
self.usage.prompt_cache_hit_tokens,
usage.prompt_cache_hit_tokens,
);
self.usage.prompt_cache_miss_tokens = add_optional_usage(
self.usage.prompt_cache_miss_tokens,
usage.prompt_cache_miss_tokens,
);
self.usage.prompt_cache_write_tokens = add_optional_usage(
self.usage.prompt_cache_write_tokens,
usage.prompt_cache_write_tokens,
);
self.usage.reasoning_tokens =
add_optional_usage(self.usage.reasoning_tokens, usage.reasoning_tokens);
self.usage.reasoning_replay_tokens = add_optional_usage(
self.usage.reasoning_replay_tokens,
usage.reasoning_replay_tokens,
);
if let Some(delta) = usage.server_tool_use.as_ref() {
let total = self.usage.server_tool_use.get_or_insert_default();
total.code_execution_requests =
add_optional_usage(total.code_execution_requests, delta.code_execution_requests);
total.tool_search_requests =
add_optional_usage(total.tool_search_requests, delta.tool_search_requests);
}
add_usage_to(&mut self.usage, usage);
}
/// Record one parent-route response for both billing and live-context
@@ -180,8 +166,57 @@ impl TurnContext {
pub fn add_parent_usage(&mut self, usage: &Usage) {
self.latest_parent_input_tokens = (usage.input_tokens > 0).then_some(usage.input_tokens);
self.add_usage(usage);
add_usage_to(&mut self.parent_route_usage, usage);
}
pub fn add_routed_usage_dropped_records(&mut self, dropped_records: u64) {
self.routed_usage_dropped_records = self
.routed_usage_dropped_records
.saturating_add(dropped_records);
}
/// Add programmatic child-call usage to the authoritative total and
/// return the same batch aggregate for telemetry emission.
pub fn add_routed_usages<'a>(&mut self, usages: impl IntoIterator<Item = &'a Usage>) -> Usage {
let mut aggregate = Usage::default();
for usage in usages {
self.add_usage(usage);
add_usage_to(&mut aggregate, usage);
}
aggregate
}
}
pub(crate) fn add_usage_to(total: &mut Usage, delta: &Usage) {
total.input_tokens = total.input_tokens.saturating_add(delta.input_tokens);
total.output_tokens = total.output_tokens.saturating_add(delta.output_tokens);
total.prompt_cache_hit_tokens =
add_optional_usage(total.prompt_cache_hit_tokens, delta.prompt_cache_hit_tokens);
total.prompt_cache_miss_tokens = add_optional_usage(
total.prompt_cache_miss_tokens,
delta.prompt_cache_miss_tokens,
);
total.prompt_cache_write_tokens = add_optional_usage(
total.prompt_cache_write_tokens,
delta.prompt_cache_write_tokens,
);
total.reasoning_tokens = add_optional_usage(total.reasoning_tokens, delta.reasoning_tokens);
total.reasoning_replay_tokens =
add_optional_usage(total.reasoning_replay_tokens, delta.reasoning_replay_tokens);
if let Some(delta) = delta.server_tool_use.as_ref() {
let server_total = total.server_tool_use.get_or_insert_default();
server_total.code_execution_requests = add_optional_usage(
server_total.code_execution_requests,
delta.code_execution_requests,
);
server_total.tool_search_requests = add_optional_usage(
server_total.tool_search_requests,
delta.tool_search_requests,
);
}
}
impl TurnContext {
/// Billed prompt the compaction gate should honor: this turn's latest
/// parent request, else the session-carried receipt from the previous
/// turn. A fresh `TurnContext` starts empty, so without the session
File diff suppressed because it is too large. Load diff
+1
View File
@@ -432,6 +432,7 @@ pub(crate) async fn run_exec_agent(
mode,
route: Box::new(validated_route.into_resolved()),
compaction: Box::new(compaction.clone()),
initial_routed_usage: Box::default(),
goal_objective: None,
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
+1
View File
@@ -93,6 +93,7 @@ mod project_context;
mod project_context_cache;
mod prompt_zones;
mod prompts;
mod provider_catalog_live;
mod provider_lake;
mod provider_readiness;
mod purge;
+5 -1
View File
@@ -1,9 +1,13 @@
//! Offline model metadata catalog (#3072).
//! Legacy offline model metadata compatibility catalog (#3072).
//!
//! This module adds a secret-free metadata layer in front of the legacy model
//! tables. It is intentionally conservative: startup reads a local cache plus a
//! bundled snapshot, never performs a network refresh, and only overrides a
//! legacy fact when the active catalog entry actually carries that field.
//! The unscoped `catalog/openrouter.json` file cannot safely own a live provider
//! roster; new provider/base-URL-scoped refreshes and runtime consumers use
//! `provider_catalog_live` + `provider_lake`. Keep this reader only until the
//! remaining `models`, `pricing`, and safe-label compatibility callers migrate.
use std::collections::BTreeMap;
use std::path::PathBuf;
+101 -62
View File
@@ -9,7 +9,7 @@ use serde::Serialize;
use crate::config::{
ApiProvider, Config, has_api_key_for, normalize_model_name_for_provider, provider_capability,
};
use crate::provider_lake::{all_catalog_models_for_provider, models_for_provider};
use crate::provider_lake::models_for_provider;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
@@ -344,6 +344,9 @@ impl ModelInventory {
}
fn push_model(models: &mut Vec<String>, provider: ApiProvider, model: &str) {
if provider == ApiProvider::Ollama && crate::config::is_unresolved_local_ollama_model(model) {
return;
}
let Some(model) = normalize_model_name_for_provider(provider, model)
.or_else(|| crate::config::normalize_custom_model_id(model))
else {
@@ -365,55 +368,29 @@ fn configured_model_for_provider(config: &Config, provider: ApiProvider) -> Opti
.filter(|model| !model.is_empty())
}
fn provider_default_model(config: &Config, provider: ApiProvider) -> String {
if provider == ApiProvider::Ollama {
let configured = if provider == config.api_provider() {
Some(config.default_model())
} else {
configured_model_for_provider(config, provider)
};
let unresolved = configured.as_deref().is_none_or(|model| {
model.trim().eq_ignore_ascii_case("auto")
|| crate::config::is_unresolved_local_ollama_model(model)
});
if unresolved
&& let Some(live) = crate::provider_lake::live_per_provider_models(provider)
.into_iter()
.next()
{
return live;
}
if let Some(model) = configured.filter(|model| {
!model.trim().eq_ignore_ascii_case("auto")
&& !crate::config::is_unresolved_local_ollama_model(model)
}) {
return model;
}
}
if provider == config.api_provider() {
let model = config.default_model();
if !model.trim().eq_ignore_ascii_case("auto") {
return model;
}
}
if provider == ApiProvider::Moonshot
&& config
.provider_config_for(provider)
.is_some_and(crate::config::provider_config_uses_kimi_imported_token)
{
return crate::config::DEFAULT_KIMI_CODE_MODEL.to_string();
}
all_catalog_models_for_provider(provider)
.first()
.map(|model| model.as_str())
.unwrap_or(match provider {
ApiProvider::Ollama => crate::config::DEFAULT_OLLAMA_MODEL,
ApiProvider::OllamaCloud => crate::config::DEFAULT_OLLAMA_CLOUD_MODEL,
ApiProvider::Sglang => crate::config::DEFAULT_SGLANG_MODEL,
ApiProvider::Vllm => crate::config::DEFAULT_VLLM_MODEL,
_ => crate::config::DEFAULT_TEXT_MODEL,
pub(crate) fn provider_default_model(config: &Config, provider: ApiProvider) -> String {
let configured = configured_model_for_provider(config, provider).or_else(|| {
(provider == config.api_provider() && config.default_text_model.is_some())
.then(|| config.default_model())
});
let selector = configured.as_deref().filter(|model| {
!model.trim().eq_ignore_ascii_case("auto")
&& !(provider == ApiProvider::Ollama
&& crate::config::is_unresolved_local_ollama_model(model))
});
// Inventory labels must use the executable route's exact endpoint default,
// not whichever provider-wide snapshot happened to refresh most recently.
crate::route_runtime::resolve_runtime_route(config, provider, selector)
.map(|route| route.model)
.unwrap_or_else(|_| {
configured.unwrap_or_else(|| {
provider
.kind()
.map(|kind| kind.provider().default_model())
.unwrap_or(crate::config::DEFAULT_TEXT_MODEL)
.to_string()
})
})
.to_string()
}
fn auth_source_for_provider(config: &Config, provider: ApiProvider) -> Option<ModelAuthSource> {
@@ -541,7 +518,8 @@ mod tests {
fn inventory_marks_local_providers_keyless() {
let _env_lock = crate::test_support::lock_test_env();
let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
let config = Config::default();
let mut config = Config::default();
config.set_provider_model_override(ApiProvider::Ollama, Some("local-tag:latest".into()));
let inventory = ModelInventory::from_config(&config);
@@ -1133,31 +1111,92 @@ mod tests {
}
#[test]
fn ollama_default_prefers_live_local_tags_over_the_unresolved_marker() {
fn ollama_inventory_default_uses_only_the_fresh_exact_endpoint_roster() {
use codewhale_config::catalog::{
CatalogOffering, CatalogRefreshError, CatalogSource, ProviderCatalogDelta,
base_url_fingerprint, now_unix,
};
let _env = crate::test_support::lock_test_env();
let _live = crate::provider_lake::lock_live_snapshot();
let home = tempfile::tempdir().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
crate::provider_catalog_live::reset_cache_for_test();
crate::provider_lake::clear_live_snapshot();
let config = Config {
let mut config = Config {
provider: Some("ollama".to_string()),
..Default::default()
};
let endpoint = "http://localhost:11445/v1";
config.provider_config_for_mut(ApiProvider::Ollama).base_url = Some(endpoint.into());
assert_eq!(
provider_default_model(&config, ApiProvider::Ollama),
crate::config::DEFAULT_OLLAMA_MODEL
"unknown"
);
crate::provider_lake::merge_live_offerings(vec![
codewhale_config::catalog::CatalogOffering {
provider: "ollama".to_string(),
wire_model_id: "qwen2.5:0.5b".to_string(),
endpoint_key: "chat".to_string(),
default_for_provider: true,
..Default::default()
assert!(
ModelInventory::from_config(&config)
.candidates
.iter()
.all(|row| { row.provider != ApiProvider::Ollama || row.model != "unknown" })
);
let fingerprint = base_url_fingerprint(endpoint);
let now = now_unix();
let ticket = crate::provider_catalog_live::begin_refresh_for_identity(
ApiProvider::Ollama,
"ollama",
endpoint,
);
crate::provider_catalog_live::record_success_if_current(
&ticket,
ProviderCatalogDelta {
provider: "ollama".into(),
base_url_fingerprint: fingerprint.clone(),
fetched_at: now,
offerings: vec![CatalogOffering {
provider: "ollama".into(),
wire_model_id: "qwen2.5:0.5b".into(),
endpoint_key: "chat".into(),
source: CatalogSource::Live {
base_url_fingerprint: fingerprint.clone(),
fetched_at: now,
},
..Default::default()
}],
},
]);
);
assert_eq!(
provider_default_model(&config, ApiProvider::Ollama),
"qwen2.5:0.5b"
);
let inventory = ModelInventory::from_config(&config);
assert!(inventory.candidates.iter().any(|row| {
row.provider == ApiProvider::Ollama
&& row.model == "qwen2.5:0.5b"
&& row.default_for_provider
}));
let mut other = config.clone();
other.provider_config_for_mut(ApiProvider::Ollama).base_url =
Some("http://localhost:11446/v1".into());
assert_eq!(
provider_default_model(&other, ApiProvider::Ollama),
"unknown"
);
crate::provider_catalog_live::record_failure_if_current(
&ticket,
"ollama",
&fingerprint,
CatalogRefreshError::Network,
);
assert_eq!(
provider_default_model(&config, ApiProvider::Ollama),
"unknown"
);
config.set_provider_model_override(ApiProvider::Ollama, Some("chosen:tag".into()));
assert_eq!(
provider_default_model(&config, ApiProvider::Ollama),
"chosen:tag"
);
crate::provider_catalog_live::reset_cache_for_test();
crate::provider_lake::clear_live_snapshot();
}
}
+519 -16
View File
@@ -10,6 +10,9 @@ use serde::{Deserialize, Serialize};
use crate::client::DeepSeekClient;
use crate::config::{ApiProvider, Config, normalize_model_name_for_provider};
use crate::cost_status::{
EffectiveRouteEnvelope, EffectiveRouteUsage, RuntimeUsageDropRecord, RuntimeUsageRecord,
};
use crate::llm_client::LlmClient;
use crate::model_inventory::ModelInventory;
use crate::models::Role;
@@ -514,6 +517,21 @@ pub(crate) struct AutoRouteSelection {
/// Present for Auto decisions; explicit inventory lookups intentionally do
/// not pretend to be Auto routing receipts.
pub(crate) receipt: Option<AutoRouteReceipt>,
/// Provider calls made to choose this route. These are deliberately kept
/// separate from the selected parent route: a classifier may run on a
/// different provider/model/quote, so pricing it under the eventual turn
/// would double-charge the parent and lose the classifier's real route.
///
/// Auto currently admits at most one classifier request per selection.
pub(crate) routed_usage: Vec<RuntimeUsageRecord>,
/// Exact frozen routes for admitted classifier calls whose provider
/// response omitted usage. The count below remains authoritative and may
/// exceed this bounded vector after overflow.
pub(crate) routed_usage_drop_records: Vec<RuntimeUsageDropRecord>,
/// Classifier requests admitted to dispatch whose response usage could not
/// be recovered (timeout/transport failure). Consumers must surface this
/// as incomplete coverage rather than silently treating it as zero spend.
pub(crate) routed_usage_dropped_records: u64,
}
fn extract_first_json_object(raw: &str) -> Option<&str> {
@@ -605,6 +623,17 @@ struct InventoryAutoRouteRecommendation {
reasoning_effort: Option<ReasoningEffort>,
}
/// One provider-backed classifier attempt. A provider-success response always
/// reaches this shape before its content is interpreted, so invalid JSON and
/// provider-declared incomplete output retain their exact routed usage.
#[derive(Debug, Clone, PartialEq, Eq)]
struct InventoryAutoRouteAttempt {
recommendation: Option<InventoryAutoRouteRecommendation>,
routed_usage: Vec<RuntimeUsageRecord>,
routed_usage_drop_records: Vec<RuntimeUsageDropRecord>,
routed_usage_dropped_records: u64,
}
pub(crate) async fn resolve_auto_route_with_inventory(
config: &Config,
latest_request: &str,
@@ -679,8 +708,11 @@ pub(crate) async fn resolve_auto_route_with_inventory_for_session_and_cache_poli
)
.await
{
Ok(Some(recommendation)) => auto_route_from_classifier(&inventory, recommendation),
Ok(None) | Err(_) => auto_route_classifier_fallback(heuristic, &inventory),
Ok(attempt) => auto_route_from_classifier_attempt(heuristic, &inventory, attempt),
// Client construction/preparation failed before a provider request was
// admitted. There is no provider usage to invent and no dropped
// response receipt to claim.
Err(_) => auto_route_classifier_fallback(heuristic, &inventory),
};
Ok(normalize_auto_route_selection_for_config(config, selection))
}
@@ -714,6 +746,9 @@ pub(crate) fn resolve_explicit_route_with_inventory(
}),
source: AutoRouteSource::Heuristic,
receipt: None,
routed_usage: Vec::new(),
routed_usage_drop_records: Vec::new(),
routed_usage_dropped_records: 0,
});
}
@@ -739,6 +774,9 @@ pub(crate) fn resolve_explicit_route_with_inventory(
}),
source: AutoRouteSource::Heuristic,
receipt: None,
routed_usage: Vec::new(),
routed_usage_drop_records: Vec::new(),
routed_usage_dropped_records: 0,
})
}
@@ -794,6 +832,9 @@ fn auto_route_from_inventory_heuristic(
model,
reasoning_effort: Some(crate::auto_reasoning::select(false, latest_request)),
source: AutoRouteSource::Heuristic,
routed_usage: Vec::new(),
routed_usage_drop_records: Vec::new(),
routed_usage_dropped_records: 0,
};
};
// Use the candidates' cheap/big info for complexity-based routing.
@@ -829,6 +870,9 @@ fn auto_route_from_inventory_heuristic(
model: decision.model,
reasoning_effort: Some(crate::auto_reasoning::select(false, latest_request)),
source: AutoRouteSource::Heuristic,
routed_usage: Vec::new(),
routed_usage_drop_records: Vec::new(),
routed_usage_dropped_records: 0,
}
}
@@ -860,9 +904,33 @@ fn auto_route_from_classifier(
model: recommendation.model,
reasoning_effort: recommendation.reasoning_effort,
source: AutoRouteSource::FlashRouter,
routed_usage: Vec::new(),
routed_usage_drop_records: Vec::new(),
routed_usage_dropped_records: 0,
}
}
fn auto_route_from_classifier_attempt(
heuristic: AutoRouteSelection,
inventory: &ModelInventory,
attempt: InventoryAutoRouteAttempt,
) -> AutoRouteSelection {
let InventoryAutoRouteAttempt {
recommendation,
routed_usage,
routed_usage_drop_records,
routed_usage_dropped_records,
} = attempt;
let mut selection = recommendation.map_or_else(
|| auto_route_classifier_fallback(heuristic, inventory),
|recommendation| auto_route_from_classifier(inventory, recommendation),
);
selection.routed_usage = routed_usage;
selection.routed_usage_drop_records = routed_usage_drop_records;
selection.routed_usage_dropped_records = routed_usage_dropped_records;
selection
}
fn auto_route_classifier_fallback(
mut heuristic: AutoRouteSelection,
inventory: &ModelInventory,
@@ -970,6 +1038,129 @@ fn auto_route_pair(
AutoRoutePair { strong, fast }
}
fn auto_route_usage_has_reported_data(usage: &crate::models::Usage) -> bool {
usage.input_tokens > 0
|| usage.output_tokens > 0
|| usage.prompt_cache_hit_tokens.is_some()
|| usage.prompt_cache_miss_tokens.is_some()
|| usage.prompt_cache_write_tokens.is_some()
|| usage.reasoning_tokens.is_some()
|| usage.reasoning_replay_tokens.is_some()
|| usage.server_tool_use.is_some()
}
/// Stable, persistence-safe identity for one classifier response. The raw
/// provider response id is hashed with the frozen dispatch route and instant;
/// neither it nor any custom route label crosses into telemetry/persistence.
fn auto_route_usage_source_id(route: &EffectiveRouteEnvelope, response_id: &str) -> String {
use sha2::{Digest as _, Sha256};
let mut digest = Sha256::new();
let dispatched_at = route.dispatched_at.to_rfc3339();
for part in [
b"codewhale:auto-route-classifier:v1".as_slice(),
route.provider.as_str().as_bytes(),
route.provider_identity.as_bytes(),
route.model.as_bytes(),
route
.endpoint_fingerprint
.as_deref()
.unwrap_or_default()
.as_bytes(),
dispatched_at.as_bytes(),
response_id.as_bytes(),
] {
digest.update((part.len() as u64).to_le_bytes());
digest.update(part);
}
format!(
"auto-router:{}",
crate::hashing::hex_bytes(digest.finalize())
)
}
fn auto_route_attempt_from_response(
request_route: EffectiveRouteEnvelope,
response: &MessageResponse,
inventory: &ModelInventory,
) -> InventoryAutoRouteAttempt {
// All-zero usage cannot price a routed segment. The dispatch caller owns
// the stronger cache/provenance context and must explicitly classify this
// as either a proven cache replay or missing provider billing evidence.
let routed_usage = auto_route_usage_has_reported_data(&response.usage)
.then(|| RuntimeUsageRecord {
source_id: auto_route_usage_source_id(&request_route, &response.id),
usage: EffectiveRouteUsage {
route: request_route.sanitized_for_persistence(),
usage: response.usage.clone(),
},
})
.into_iter()
.collect();
let recommendation =
(!crate::models::is_incomplete_stop_reason(response.stop_reason.as_deref()))
.then(|| {
parse_inventory_auto_route_recommendation(
&message_response_text(response),
inventory,
)
})
.flatten();
InventoryAutoRouteAttempt {
recommendation,
routed_usage,
routed_usage_drop_records: Vec::new(),
routed_usage_dropped_records: 0,
}
}
fn auto_route_attempt_from_provider_response(
request_route: EffectiveRouteEnvelope,
response: &MessageResponse,
inventory: &ModelInventory,
) -> InventoryAutoRouteAttempt {
let drop_route = request_route.sanitized_for_persistence();
let mut attempt = auto_route_attempt_from_response(request_route, response, inventory);
// This classifier request is currently not cacheable (temperature is
// provider-default, not the deterministic Some(0.0) cache contract), so a
// decoded all-zero response is missing provider billing evidence even when
// the caller permits response-cache use. Do not silently reinterpret the
// policy boolean as cache-hit provenance.
if attempt.routed_usage.is_empty() {
attempt.routed_usage_drop_records = vec![RuntimeUsageDropRecord {
source_id: auto_route_usage_source_id(
&drop_route,
&format!("missing-usage:{}", response.id),
),
route: drop_route,
}];
attempt.routed_usage_dropped_records = 1;
}
attempt
}
fn auto_route_attempt_with_dropped_response(
request_route: EffectiveRouteEnvelope,
) -> InventoryAutoRouteAttempt {
let request_route = request_route.sanitized_for_persistence();
InventoryAutoRouteAttempt {
recommendation: None,
routed_usage: Vec::new(),
routed_usage_drop_records: vec![RuntimeUsageDropRecord {
source_id: auto_route_usage_source_id(&request_route, "transport-error"),
route: request_route,
}],
routed_usage_dropped_records: 1,
}
}
/// Prove that the deterministic request seam accepts this classifier request
/// before capturing a quote or entering any provider permit/network path.
fn preflight_auto_route_request(client: &DeepSeekClient, request: &MessageRequest) -> Result<()> {
client.prepare_outbound_request(request.clone(), false)?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn auto_route_inventory_recommendation(
config: &Config,
@@ -980,7 +1171,7 @@ async fn auto_route_inventory_recommendation(
selected_model_mode: &str,
selected_thinking_mode: &str,
allow_response_cache: bool,
) -> Result<Option<InventoryAutoRouteRecommendation>> {
) -> Result<InventoryAutoRouteAttempt> {
let mut router_config = config.clone();
// The classifier runs on the inventory's router route: the explicit
// [auto.router] route when configured, else the DeepSeek flash default.
@@ -997,8 +1188,7 @@ async fn auto_route_inventory_recommendation(
selected_model_mode,
selected_thinking_mode,
);
let request_route =
client.effective_route_envelope(&inventory.router_model, chrono::Utc::now());
let max_tokens = client.effective_max_output_tokens(&inventory.router_model);
let request = MessageRequest {
model: inventory.router_model.to_string(),
messages: vec![Message {
@@ -1008,7 +1198,7 @@ async fn auto_route_inventory_recommendation(
cache_control: None,
}],
}],
max_tokens: client.effective_max_output_tokens(&request_route.model),
max_tokens,
system: Some(SystemPrompt::Text(router_system)),
tools: None,
tool_choice: None,
@@ -1025,27 +1215,49 @@ async fn auto_route_inventory_recommendation(
top_p: None,
};
// Freeze pricing at the last application seam before the provider future
// starts. Prompt shaping above may be slow and may overlap a catalog
// refresh; completion-time mutable catalog state must never reprice this
// already-admitted classifier request.
preflight_auto_route_request(&client, &request)?;
let request_route =
client.effective_route_envelope(&inventory.router_model, chrono::Utc::now());
let response = if allow_response_cache {
tokio::time::timeout(
Duration::from_secs(inventory.router_timeout_secs),
client.create_message(request),
)
.await??
.await
} else {
tokio::time::timeout(
Duration::from_secs(inventory.router_timeout_secs),
client.create_message_without_response_cache(request),
)
.await??
.await
};
if crate::models::is_incomplete_stop_reason(response.stop_reason.as_deref()) {
anyhow::bail!(
"auto-route classifier response incomplete: provider stop reason `{}`",
crate::models::stop_reason_detail(response.stop_reason.as_deref())
);
}
Ok(parse_inventory_auto_route_recommendation(
&message_response_text(&response),
let response = match response {
Ok(Ok(response)) => response,
// The request crossed Codewhale's dispatch boundary, but no exact
// provider usage came back. Preserve the fallback while explicitly
// failing cost coverage closed.
Ok(Err(_)) => return Ok(auto_route_attempt_with_dropped_response(request_route)),
// The local deadline cancels the future and can fire while the request
// is still waiting on an application/provider permit. With no response
// evidence we must not invent a provider call or a missing-usage
// receipt. Transport errors returned by the client remain the
// conservative explicit-dropped path above.
Err(_) => {
return Ok(InventoryAutoRouteAttempt {
recommendation: None,
routed_usage: Vec::new(),
routed_usage_drop_records: Vec::new(),
routed_usage_dropped_records: 0,
});
}
};
Ok(auto_route_attempt_from_provider_response(
request_route,
&response,
inventory,
))
}
@@ -1217,6 +1429,297 @@ fn truncate_for_auto_router(text: &str, max_chars: usize) -> String {
mod tests {
use super::*;
struct ProviderCatalogReset;
impl Drop for ProviderCatalogReset {
fn drop(&mut self) {
crate::provider_catalog_live::reset_cache_for_test();
crate::provider_lake::clear_live_snapshot();
}
}
fn priced_openrouter_delta(
model: &str,
fingerprint: &str,
fetched_at: u64,
input: f64,
output: f64,
) -> codewhale_config::catalog::ProviderCatalogDelta {
use codewhale_config::catalog::{CatalogOffering, CatalogSource, ProviderCatalogDelta};
ProviderCatalogDelta {
provider: ApiProvider::Openrouter.as_str().to_string(),
base_url_fingerprint: fingerprint.to_string(),
fetched_at,
offerings: vec![CatalogOffering {
provider: ApiProvider::Openrouter.as_str().to_string(),
wire_model_id: model.to_string(),
endpoint_key: "chat".to_string(),
source: CatalogSource::Live {
base_url_fingerprint: fingerprint.to_string(),
fetched_at,
},
cost: Some(codewhale_config::models_dev::ModelsDevCost {
input: Some(input),
output: Some(output),
cache_read: Some(input / 2.0),
cache_write: None,
}),
..CatalogOffering::default()
}],
}
}
fn classifier_response(
id: &str,
text: &str,
stop_reason: &str,
usage: crate::models::Usage,
) -> MessageResponse {
MessageResponse {
id: id.to_string(),
r#type: "message".to_string(),
role: "assistant".to_string(),
content: vec![ContentBlock::Text {
text: text.to_string(),
cache_control: None,
}],
model: "router-response-alias-must-not-price".to_string(),
stop_reason: Some(stop_reason.to_string()),
stop_sequence: None,
container: None,
usage,
}
}
#[test]
fn classifier_semantic_fallbacks_keep_exact_quotes_and_replay_once() {
let _env_lock = crate::test_support::lock_test_env();
let _live = crate::provider_lake::lock_live_snapshot();
let home = tempfile::tempdir().expect("test home");
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
let _reset = ProviderCatalogReset;
crate::provider_catalog_live::reset_cache_for_test();
crate::provider_lake::clear_live_snapshot();
let model = "synthetic/openrouter-auto-classifier";
let config = Config {
provider: Some("openrouter".to_string()),
providers: Some(crate::config::ProvidersConfig {
openrouter: crate::config::ProviderConfig {
api_key: Some("test-openrouter-key".to_string()),
base_url: Some(crate::config::DEFAULT_OPENROUTER_BASE_URL.to_string()),
model: Some(model.to_string()),
..Default::default()
},
..Default::default()
}),
auto: Some(crate::config::AutoConfig {
cost_saving: None,
cross_provider: None,
router: Some(crate::config::AutoRouterConfig {
provider: Some("openrouter".to_string()),
model: Some(model.to_string()),
thinking: Some("off".to_string()),
timeout_secs: None,
}),
}),
..Default::default()
};
let inventory = ModelInventory::from_config(&config);
assert!(
inventory
.candidate(ApiProvider::Openrouter, model)
.is_some()
);
let client = DeepSeekClient::new(&config).expect("OpenRouter classifier client");
let fingerprint = codewhale_config::catalog::base_url_fingerprint(
crate::config::DEFAULT_OPENROUTER_BASE_URL,
);
let first_at = chrono::Utc::now();
let fetched_at = u64::try_from(first_at.timestamp()).expect("nonnegative timestamp");
crate::provider_catalog_live::record_success(priced_openrouter_delta(
model,
&fingerprint,
fetched_at,
1.0,
4.0,
));
let first_route = client.effective_route_envelope(model, first_at);
let valid = auto_route_attempt_from_response(
first_route,
&classifier_response(
"same-provider-response-id",
&format!(r#"{{"provider":"openrouter","model":"{model}","thinking":"off"}}"#),
"stop",
crate::models::Usage {
input_tokens: 10,
output_tokens: 2,
prompt_cache_hit_tokens: Some(3),
..Default::default()
},
),
&inventory,
);
// Replace the live row in the same Unix second. The first routed
// record must keep its old immutable revision and the new attempt must
// freeze a distinct one at its own dispatch boundary.
crate::provider_catalog_live::record_success(priced_openrouter_delta(
model,
&fingerprint,
fetched_at,
9.0,
19.0,
));
let second_at = first_at + chrono::Duration::nanoseconds(1);
let invalid = auto_route_attempt_from_response(
client.effective_route_envelope(model, second_at),
&classifier_response(
"same-provider-response-id",
"not valid route json",
"stop",
crate::models::Usage {
input_tokens: 11,
output_tokens: 3,
..Default::default()
},
),
&inventory,
);
let incomplete = auto_route_attempt_from_response(
client.effective_route_envelope(model, second_at + chrono::Duration::nanoseconds(1)),
&classifier_response(
"same-provider-response-id",
&format!(r#"{{"provider":"openrouter","model":"{model}"}}"#),
"length",
crate::models::Usage {
input_tokens: 12,
output_tokens: 4,
..Default::default()
},
),
&inventory,
);
let missing_usage_route =
client.effective_route_envelope(model, second_at + chrono::Duration::nanoseconds(2));
let missing_usage = auto_route_attempt_from_provider_response(
missing_usage_route.clone(),
&classifier_response(
"missing-usage-response-id",
"not valid route json",
"stop",
crate::models::Usage::default(),
),
&inventory,
);
assert!(missing_usage.routed_usage.is_empty());
assert_eq!(missing_usage.routed_usage_dropped_records, 1);
assert_eq!(missing_usage.routed_usage_drop_records.len(), 1);
assert_eq!(
missing_usage.routed_usage_drop_records[0].route,
missing_usage_route.sanitized_for_persistence()
);
assert!(
missing_usage.routed_usage_drop_records[0]
.source_id
.starts_with("auto-router:")
);
assert!(
!missing_usage.routed_usage_drop_records[0]
.source_id
.contains("missing-usage-response-id")
);
let transport = auto_route_attempt_with_dropped_response(
client.effective_route_envelope(model, second_at + chrono::Duration::nanoseconds(3)),
);
assert_eq!(transport.routed_usage_dropped_records, 1);
assert_eq!(transport.routed_usage_drop_records.len(), 1);
assert!(transport.routed_usage.is_empty());
let heuristic = auto_route_from_inventory_heuristic(&config, "quick status", &inventory);
let valid = auto_route_from_classifier_attempt(heuristic.clone(), &inventory, valid);
let invalid = auto_route_from_classifier_attempt(heuristic.clone(), &inventory, invalid);
let incomplete = auto_route_from_classifier_attempt(heuristic, &inventory, incomplete);
assert_eq!(valid.source, AutoRouteSource::FlashRouter);
for fallback in [&invalid, &incomplete] {
assert_eq!(fallback.source, AutoRouteSource::Heuristic);
assert!(matches!(
fallback.receipt.as_ref().map(|receipt| receipt.reason),
Some(AutoRouteReason::ClassifierFallback(_))
));
assert_eq!(fallback.routed_usage.len(), 1);
assert_eq!(fallback.routed_usage_dropped_records, 0);
}
assert_eq!(valid.routed_usage.len(), 1);
assert_eq!(valid.routed_usage[0].usage.usage.input_tokens, 10);
assert_eq!(
valid.routed_usage[0].usage.usage.prompt_cache_hit_tokens,
Some(3)
);
let first_quote = valid.routed_usage[0]
.usage
.route
.provider_live_pricing
.as_ref()
.expect("first exact quote");
let second_quote = invalid.routed_usage[0]
.usage
.route
.provider_live_pricing
.as_ref()
.expect("replacement exact quote");
assert_ne!(first_quote.catalog_revision, second_quote.catalog_revision);
assert_eq!(first_quote.input_per_million.as_deref(), Some("1"));
assert_eq!(second_quote.input_per_million.as_deref(), Some("9"));
let records = valid
.routed_usage
.iter()
.chain(&invalid.routed_usage)
.chain(&incomplete.routed_usage)
.cloned()
.collect::<Vec<_>>();
assert_eq!(records.len(), 3);
assert!(records.iter().all(|record| {
record.source_id.starts_with("auto-router:")
&& record.source_id.len() == "auto-router:".len() + 64
&& !record.source_id.contains("same-provider-response-id")
}));
// Exercise the canonical sink exactly as selection consumers do:
// replaying any record cannot add parent-route spend or a second
// routed segment, while distinct dispatches remain distinct.
let _cost_scope = crate::cost_status::test_scope();
let owner = "auto-router-selection-test-owner";
crate::cost_status::register_interactive_runtime_usage_sink(
owner,
crate::cost_status::scope_token(),
);
let lease = crate::cost_status::acquire_runtime_usage_lease(owner)
.expect("runtime usage owner lease");
for record in &records {
for _ in 0..2 {
crate::cost_status::report_effective_route_for_runtime(
crate::cost_status::scope_token(),
Some(lease.owner()),
&record.source_id,
&record.usage.route,
&record.usage.usage,
);
}
}
crate::cost_status::finish_runtime_usage_owner(owner);
drop(lease);
let pending = crate::cost_status::drain();
assert_eq!(pending.usage_source_fingerprints.len(), records.len());
assert_eq!(pending.priced_turns, records.len() as u32);
assert_eq!(pending.unpriced_turns, 0);
}
#[test]
fn auto_model_reasoning_keeps_model_and_thinking_choices_independent() {
assert_eq!(
+334 -5
View File
@@ -1240,6 +1240,10 @@ pub enum UnpricedReason {
RoutingDependentPrice,
/// Saved usage predates cost coverage, or carries an unknown reason code.
UnrecordedCoverage,
/// Saved background accounting could not be recovered.
LateUsageUnavailable,
/// The bounded saved accounting ledger reached its capacity.
LateUsageOverflow,
/// A row exists, but a token class this turn actually used has no published
/// price, so the estimate fails closed rather than under-reporting.
MissingClassPrice,
@@ -1275,6 +1279,8 @@ impl UnpricedReason {
"invalid_pricing_row" => Self::InvalidPricingRow,
"unsupported_currency" | "currency_not_published" => Self::UnsupportedCurrency,
"inconsistent_usage" => Self::InconsistentUsage,
"late_usage_ledger_unavailable" => Self::LateUsageUnavailable,
"late_usage_ledger_overflow" => Self::LateUsageOverflow,
_ => Self::UnrecordedCoverage,
}
}
@@ -1293,7 +1299,9 @@ impl UnpricedReason {
Self::RetiredAlias => MessageId::CostReasonRetiredAlias,
Self::UnrepresentedTier => MessageId::CostReasonTierMissing,
Self::RoutingDependentPrice => MessageId::CostReasonRoutingDependent,
Self::UnrecordedCoverage => MessageId::CostReasonCoverageMissing,
Self::UnrecordedCoverage | Self::LateUsageUnavailable | Self::LateUsageOverflow => {
MessageId::CostReasonCoverageMissing
}
Self::MissingClassPrice => MessageId::CostReasonTokenRateMissing,
Self::InvalidPricingRow => MessageId::CostReasonInvalidRate,
Self::UnsupportedCurrency => MessageId::CostReasonCurrencyMissing,
@@ -1316,6 +1324,8 @@ impl UnpricedReason {
Self::NoPricingRow => "no_pricing_row",
Self::RoutingDependentPrice => "routing_dependent_price",
Self::UnrecordedCoverage => "unrecorded_coverage",
Self::LateUsageUnavailable => "late_usage_ledger_unavailable",
Self::LateUsageOverflow => "late_usage_ledger_overflow",
Self::MissingClassPrice => "missing_class_price",
Self::InvalidPricingRow => "invalid_pricing_row",
Self::UnsupportedCurrency => "unsupported_currency",
@@ -1485,6 +1495,32 @@ pub(crate) fn audit_turn_cost_for_provider_on_endpoint_at(
endpoint_fingerprint: Option<&str>,
usage: &Usage,
recorded_at: DateTime<Utc>,
) -> TurnCostAudit {
audit_turn_cost_for_provider_on_endpoint_for_identity_at(
provider,
None,
model,
endpoint_fingerprint,
usage,
recorded_at,
)
}
/// Identity-aware provider audit for named compatible routes.
///
/// `ApiProvider::Custom` is only a transport family, so it is never sufficient
/// pricing provenance on its own. Baseten is the first reviewed compatible
/// provider whose authenticated live catalog can price actual usage; every
/// other custom identity stays unknown until it receives an equivalent
/// provider/endpoint contract.
#[must_use]
fn audit_turn_cost_for_provider_on_endpoint_for_identity_at(
provider: ApiProvider,
provider_identity: Option<&str>,
model: &str,
endpoint_fingerprint: Option<&str>,
usage: &Usage,
recorded_at: DateTime<Utc>,
) -> TurnCostAudit {
if !usage_cache_partition_is_consistent(usage) {
return TurnCostAudit::unpriced(UnpricedReason::InconsistentUsage);
@@ -1492,6 +1528,12 @@ pub(crate) fn audit_turn_cost_for_provider_on_endpoint_at(
if provider == ApiProvider::OpenaiCodex {
return TurnCostAudit::unpriced(UnpricedReason::NotMoneyMetered);
}
if provider == ApiProvider::Custom {
// A transport family plus current mutable catalog state is not a
// billing receipt. Reviewed custom routes are priced only by the
// frozen dispatch quote handled in the route-audit path below.
return TurnCostAudit::unpriced(UnpricedReason::UnknownBillingBasis);
}
if route_requires_billing_surface(provider, model) {
return TurnCostAudit::unpriced(UnpricedReason::AmbiguousBillingSurface);
}
@@ -1567,6 +1609,7 @@ pub(crate) fn audit_turn_cost_for_provider_on_endpoint_at(
let mut live_defect = None;
let offering = match verified_catalog_offering(
provider,
provider_identity,
&catalog_model,
endpoint_fingerprint,
recorded_at,
@@ -1580,6 +1623,9 @@ pub(crate) fn audit_turn_cost_for_provider_on_endpoint_at(
live_defect = Some(defect);
None
}
VerifiedOffering::FutureEffective => {
return TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing);
}
VerifiedOffering::Absent => None,
};
@@ -1667,6 +1713,9 @@ enum VerifiedOffering {
},
/// The live row could not be verified and no bundled row exists.
Unusable(LivePricingDefect),
/// The row claims it was fetched after this turn was dispatched. Clock
/// saturation must never turn a future price into an age-zero price.
FutureEffective,
/// No catalog row for this provider/model at all.
Absent,
}
@@ -1681,12 +1730,16 @@ enum VerifiedOffering {
/// billing against a rate whose endpoint scope is unproven.
fn verified_catalog_offering(
provider: ApiProvider,
provider_identity: Option<&str>,
catalog_model: &str,
endpoint_fingerprint: Option<&str>,
recorded_at: DateTime<Utc>,
) -> VerifiedOffering {
let Some(offering) = crate::provider_lake::catalog_offering_for_model(provider, catalog_model)
else {
let Some(offering) = crate::provider_lake::catalog_offering_for_model_identity(
provider,
provider_identity,
catalog_model,
) else {
return VerifiedOffering::Absent;
};
// Models.dev is a capabilities catalog. A live overlay from that fetch
@@ -1711,6 +1764,14 @@ fn verified_catalog_offering(
// `recorded_at` is the turn's own clock, which is the right reference for
// "was this price current when the turn happened".
let now_unix = u64::try_from(recorded_at.timestamp()).ok();
if pricing.provenance == PricingProvenance::ProviderLive
&& pricing
.effective_at
.zip(now_unix)
.is_some_and(|(effective_at, dispatched_at)| effective_at > dispatched_at)
{
return VerifiedOffering::FutureEffective;
}
let Some(defect) =
pricing.live_pricing_defect(endpoint_fingerprint, now_unix, LIVE_PRICING_MAX_AGE_SECS)
else {
@@ -1798,6 +1859,34 @@ pub(crate) fn audit_turn_cost_for_route_on_endpoint_at(
usage: &Usage,
recorded_at: DateTime<Utc>,
) -> TurnCostAudit {
audit_turn_cost_for_route_on_endpoint_for_identity_at(
provider,
None,
model,
billing_surface,
endpoint_fingerprint,
None,
usage,
recorded_at,
)
}
/// Identity-aware route audit for an immutable dispatch receipt.
#[must_use]
pub(crate) fn audit_turn_cost_for_route_on_endpoint_for_identity_at(
provider: ApiProvider,
provider_identity: Option<&str>,
model: &str,
billing_surface: Option<&str>,
endpoint_fingerprint: Option<&str>,
provider_live_pricing: Option<&crate::provider_catalog_live::ProviderLivePricingQuote>,
usage: &Usage,
recorded_at: DateTime<Utc>,
) -> TurnCostAudit {
let reviewed_custom_metered =
reviewed_custom_route_is_metered(provider, provider_identity, endpoint_fingerprint);
let reviewed_provider_live =
reviewed_provider_live_route_is_metered(provider, provider_identity, endpoint_fingerprint);
// An explicitly recorded surface is evidence. Exact non-metered surfaces
// override provider guesses; an explicit unknown/unrecognized surface must
// fail closed and may never fall through to a familiar model's hand row.
@@ -1805,7 +1894,7 @@ pub(crate) fn audit_turn_cost_for_route_on_endpoint_at(
EndpointMetering::ExactSubscription | EndpointMetering::LocalNoBill => {
return TurnCostAudit::unpriced(UnpricedReason::NotMoneyMetered);
}
EndpointMetering::Unknown if billing_surface.is_some() => {
EndpointMetering::Unknown if billing_surface.is_some() && !reviewed_custom_metered => {
return TurnCostAudit::unpriced(UnpricedReason::UnknownBillingBasis);
}
EndpointMetering::Unknown | EndpointMetering::Money => {}
@@ -1849,8 +1938,79 @@ pub(crate) fn audit_turn_cost_for_route_on_endpoint_at(
if billing_surface.is_none() {
return TurnCostAudit::unpriced(UnpricedReason::UnestablishedEndpoint);
}
audit_turn_cost_for_provider_on_endpoint_at(
if reviewed_provider_live {
let Some(provider_identity) = provider_identity.map(str::trim).filter(|id| !id.is_empty())
else {
return TurnCostAudit::unpriced(UnpricedReason::UnknownBillingBasis);
};
let Some(endpoint_fingerprint) = endpoint_fingerprint else {
return TurnCostAudit::unpriced(UnpricedReason::UnknownBillingBasis);
};
let Some(dispatched_at_unix) = u64::try_from(recorded_at.timestamp()).ok() else {
return TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing);
};
let pricing = match provider_live_pricing {
Some(quote) => {
let Some(pricing) = quote.pricing_for_route(
provider,
provider_identity,
model,
endpoint_fingerprint,
dispatched_at_unix,
) else {
return TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing);
};
pricing
}
None if provider == ApiProvider::Openrouter => {
// An offline/startup OpenRouter dispatch has no mutable live
// quote to freeze. Audit it only against the immutable bundled
// snapshot (and provider-owned hand rows, if one is added), so
// a refresh that lands after dispatch cannot retro-price it.
return audit_openrouter_immutable_pricing(model, usage, recorded_at);
}
None => {
// Baseten has no reviewed immutable price card. Its compatible
// custom route therefore requires the exact frozen live quote.
return TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing);
}
};
let classes = token_usage_for_pricing(usage);
let unpriced_classes = pricing.unpriced_used_classes(&classes);
if !unpriced_classes.is_empty() {
return TurnCostAudit::missing_classes(pricing.provenance, unpriced_classes);
}
let Some(amount) = pricing.estimate_cost(&classes) else {
return TurnCostAudit::unpriced(UnpricedReason::InvalidPricingRow);
};
let (estimate, usd_priced, cny_priced) = match pricing.currency {
Currency::Usd => (CostEstimate::usd_only(amount), true, false),
Currency::Cny => (
CostEstimate {
usd: 0.0,
cny: amount,
},
false,
true,
),
Currency::Other(_) => {
return TurnCostAudit::unpriced(UnpricedReason::UnsupportedCurrency);
}
};
return TurnCostAudit::priced(estimate, pricing.provenance, usd_priced, cny_priced);
}
if provider == ApiProvider::Openrouter && provider_identity.is_some() {
// A persisted built-in OpenRouter receipt that is missing the exact
// official identity/endpoint binding (or its frozen quote) must not
// fall through to the mutable process-wide provider lake.
return TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing);
}
if provider == ApiProvider::Custom {
return TurnCostAudit::unpriced(UnpricedReason::UnknownBillingBasis);
}
audit_turn_cost_for_provider_on_endpoint_for_identity_at(
provider,
provider_identity,
model,
endpoint_fingerprint,
usage,
@@ -1858,6 +2018,119 @@ pub(crate) fn audit_turn_cost_for_route_on_endpoint_at(
)
}
/// Price an exact official OpenRouter route without consulting mutable live
/// catalog state. This is the no-quote application-dispatch fallback used when
/// CodeWhale starts offline or the provider refresh has not completed yet.
fn audit_openrouter_immutable_pricing(
model: &str,
usage: &Usage,
recorded_at: DateTime<Utc>,
) -> TurnCostAudit {
let Some(canonical_model) = canonical_model_id_for_provider(ApiProvider::Openrouter, model)
else {
return TurnCostAudit::unpriced(UnpricedReason::NoPricingRow);
};
let classes = token_usage_for_pricing(usage);
if let Some(offering) = crate::provider_lake::bundled_catalog_offering_for_model(
ApiProvider::Openrouter,
&canonical_model,
) {
if let Some(audit) = invalid_catalog_pricing_audit(&offering) {
return audit;
}
if let Some(pricing) = effective_offering_pricing(
ApiProvider::Openrouter,
&canonical_model,
&offering,
&classes,
) {
let unpriced_classes = pricing.unpriced_used_classes(&classes);
if !unpriced_classes.is_empty() {
return TurnCostAudit::missing_classes(pricing.provenance, unpriced_classes);
}
let Some(estimate) = catalog_cost_estimate_for_route(
ApiProvider::Openrouter,
&canonical_model,
&offering,
usage,
) else {
return TurnCostAudit::unpriced(UnpricedReason::UnsupportedCurrency);
};
let (usd_priced, cny_priced) = match pricing.currency {
Currency::Usd => (true, false),
Currency::Cny => (false, true),
Currency::Other(_) => {
return TurnCostAudit::unpriced(UnpricedReason::UnsupportedCurrency);
}
};
return TurnCostAudit::priced(estimate, pricing.provenance, usd_priced, cny_priced);
}
}
hand_priced_audit(
provider_owned_hand_pricing_at(ApiProvider::Openrouter, &canonical_model, recorded_at),
usage,
)
}
/// Whether a named compatible route has a reviewed per-token billing contract.
///
/// Baseten is accepted only through its setup-template identity (including the
/// aliases that resolve to that canonical template) and the fingerprint of its
/// documented Model APIs endpoint. A generic custom table, a Baseten-like name,
/// or a Baseten identity pointed at another host cannot become metered merely by
/// publishing a priced `/models` row.
#[must_use]
pub(crate) fn reviewed_custom_route_is_metered(
provider: ApiProvider,
provider_identity: Option<&str>,
endpoint_fingerprint: Option<&str>,
) -> bool {
if provider != ApiProvider::Custom {
return false;
}
let is_baseten = provider_identity
.and_then(codewhale_config::provider_setup_template)
.is_some_and(|template| template.id == codewhale_config::BASETEN_TEMPLATE_ID);
if !is_baseten {
return false;
}
endpoint_fingerprint.is_some_and(|fingerprint| {
fingerprint
== codewhale_config::catalog::base_url_fingerprint(codewhale_config::BASETEN_BASE_URL)
})
}
/// Exact routes whose mutable provider-live rates must be frozen at the
/// pre-permit application-dispatch boundary.
///
/// OpenRouter is accepted only as the built-in identity on its official API;
/// a custom table shadowing that name or an endpoint override is a different
/// billing contract. Baseten-compatible custom identities follow the reviewed
/// setup template but retain their exact, case-sensitive cache ownership.
#[must_use]
fn reviewed_provider_live_route_is_metered(
provider: ApiProvider,
provider_identity: Option<&str>,
endpoint_fingerprint: Option<&str>,
) -> bool {
match provider {
ApiProvider::Openrouter => {
provider_identity.map(str::trim) == Some(ApiProvider::Openrouter.as_str())
&& endpoint_fingerprint.is_some_and(|fingerprint| {
fingerprint
== codewhale_config::catalog::base_url_fingerprint(
crate::config::DEFAULT_OPENROUTER_BASE_URL,
)
})
}
ApiProvider::Custom => {
reviewed_custom_route_is_metered(provider, provider_identity, endpoint_fingerprint)
}
_ => false,
}
}
/// Audit a turn against the route's billing presentation.
///
/// The three non-metered presentations are **not** interchangeable, and
@@ -4804,6 +5077,62 @@ mod tests {
);
}
#[test]
fn future_effective_provider_live_rate_is_not_treated_as_age_zero() {
let _live = crate::provider_lake::lock_live_snapshot();
crate::provider_lake::clear_live_snapshot();
let dispatched_at = Utc::now();
let future_fetched_at = u64::try_from(dispatched_at.timestamp())
.expect("timestamp")
.saturating_add(1);
let fingerprint = codewhale_config::catalog::base_url_fingerprint(
crate::config::DEFAULT_FIREWORKS_BASE_URL,
);
crate::provider_lake::set_live_snapshot(
codewhale_config::catalog::CatalogSnapshot {
offerings: vec![codewhale_config::catalog::CatalogOffering {
provider: "fireworks".to_string(),
wire_model_id: "accounts/fireworks/models/future-price-only".to_string(),
endpoint_key: "chat".to_string(),
cost: Some(codewhale_config::models_dev::ModelsDevCost {
input: Some(9.0),
output: Some(18.0),
cache_read: Some(1.0),
cache_write: None,
}),
source: codewhale_config::catalog::CatalogSource::Live {
base_url_fingerprint: fingerprint.clone(),
fetched_at: future_fetched_at,
},
..Default::default()
}],
},
crate::provider_lake::LiveSource::PerProvider,
);
let audit = audit_turn_cost_for_route_on_endpoint_at(
ApiProvider::Fireworks,
"accounts/fireworks/models/future-price-only",
billing_surface_for_route(
ApiProvider::Fireworks,
Some(crate::config::DEFAULT_FIREWORKS_BASE_URL),
),
Some(&fingerprint),
&million_input_usage(),
dispatched_at,
);
crate::provider_lake::clear_live_snapshot();
assert!(
!audit.is_priced(),
"future price must fail closed: {audit:?}"
);
assert_eq!(
audit.unpriced_reason,
Some(UnpricedReason::UnverifiedLivePricing)
);
}
#[test]
fn models_dev_live_overlay_does_not_replace_bundled_catalog_rates() {
let _live = crate::provider_lake::lock_live_snapshot();
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+30 -1
View File
@@ -684,7 +684,24 @@ pub async fn run_purge(
// Report the route, not just the provider name: the endpoint decides
// whether this is a metered public API, a plan quota, or a local runtime.
crate::cost_status::report_effective_route(cost_scope, &cost_route, &response.usage);
// Purge currently has TUI admission only. Freeze that session origin rather
// than borrowing a previous Runtime turn's owner from ambient config.
let source_id = format!(
"purge:{}:{}",
cost_route
.dispatched_at
.timestamp_nanos_opt()
.unwrap_or_default(),
response.id
);
crate::cost_status::report_effective_route_for_interactive_origin(
cost_scope,
session_id,
&source_id,
&source_id,
&cost_route,
&response.usage,
);
// A truncated response can still carry a complete-looking `purge_context`
// call; executing it would mutate the session from incomplete output.
@@ -1117,6 +1134,9 @@ mod tests {
#[tokio::test]
async fn run_purge_removes_message() {
let _env = crate::test_support::lock_test_env();
let home = tempfile::tempdir().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
let _cost_guard = crate::cost_status::test_scope();
let mock = MockLlmClient::new(vec![]);
mock.push_message_response(msg_response_with_tool_call(json!([
@@ -1164,6 +1184,9 @@ mod tests {
#[tokio::test]
async fn run_purge_replace_condenses_text() {
let _env = crate::test_support::lock_test_env();
let home = tempfile::tempdir().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
let _cost_guard = crate::cost_status::test_scope();
let mock = MockLlmClient::new(vec![]);
mock.push_message_response(msg_response_with_tool_call(json!([
@@ -1198,6 +1221,9 @@ mod tests {
#[tokio::test]
async fn run_purge_errors_when_no_tool_call() {
let _env = crate::test_support::lock_test_env();
let home = tempfile::tempdir().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
let _cost_guard = crate::cost_status::test_scope();
let mock = MockLlmClient::new(vec![]);
mock.push_message_response(msg_response_without_tool_call("nothing to clean up"));
@@ -1219,6 +1245,9 @@ mod tests {
#[tokio::test]
async fn run_purge_errors_on_api_failure() {
let _env = crate::test_support::lock_test_env();
let home = tempfile::tempdir().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
let _cost_guard = crate::cost_status::test_scope();
// No canned response — MockLlmClient returns an error.
let mock = MockLlmClient::new(vec![]);
+4
View File
@@ -7557,6 +7557,8 @@ mod tests {
fn turn_complete_event() -> EngineEvent {
EngineEvent::TurnComplete {
usage: crate::models::Usage::default(),
parent_route_usage: crate::models::Usage::default(),
routed_usage_dropped_records: 0,
status: TurnOutcomeStatus::Completed,
error: None,
tool_catalog: None,
@@ -8043,6 +8045,8 @@ mod tests {
};
controller.observe_engine_event(&EngineEvent::TurnComplete {
usage: usage.clone(),
parent_route_usage: usage.clone(),
routed_usage_dropped_records: 0,
status: TurnOutcomeStatus::Completed,
error: None,
tool_catalog: None,
+373 -23
View File
@@ -18,6 +18,7 @@ use std::{future::Future, pin::Pin};
use anyhow::Result;
use futures_util::future::join_all;
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::llm_client::LlmClient;
use crate::models::Role;
@@ -28,6 +29,183 @@ use crate::models::{
use crate::repl::runtime::{BatchResp, RpcDispatcher, RpcRequest, RpcResponse, SingleResp};
use crate::utils::spawn_supervised;
/// One pre-dispatch reservation in the shared routed-usage ledger.
#[derive(Debug, Clone, Copy)]
pub(crate) struct RlmUsageReservation {
index: usize,
}
#[derive(Debug, Default)]
struct RlmUsageState {
ledger_id: String,
usage: Usage,
records: Vec<Option<RlmUsageSlot>>,
drop_records: Vec<crate::cost_status::RuntimeUsageDropRecord>,
dropped_records: u64,
}
#[derive(Debug)]
struct RlmUsageSlot {
record: crate::cost_status::RuntimeUsageRecord,
completed: bool,
}
/// Shared, bounded provider-call ledger for one complete RLM tree.
///
/// Every root, child, batch member, and recursive call reserves one slot
/// before invoking a provider. A distinct call is never coalesced merely
/// because it used the same route: its dispatch instant and frozen quote are
/// independent accounting evidence. Sharing one accumulator across recursion
/// makes the bound global instead of allowing every nested bridge to reset it.
#[derive(Debug, Clone)]
pub(crate) struct RlmUsageAccumulator {
state: Arc<Mutex<RlmUsageState>>,
}
/// Atomic snapshot returned after all RPC work for a round has settled.
#[derive(Debug, Clone, Default)]
pub(crate) struct RlmUsageSnapshot {
pub usage: Usage,
pub records: Vec<crate::cost_status::RuntimeUsageRecord>,
/// Exact frozen routes for provider-success responses that did not carry
/// authoritative usage. Keeping these separate prevents a missing payload
/// from becoming a priced zero-usage receipt.
pub drop_records: Vec<crate::cost_status::RuntimeUsageDropRecord>,
/// Calls whose execution/usage became ambiguous (for example a timeout).
/// They are never represented as authoritative zero-usage responses.
pub dropped_records: u64,
}
impl RlmUsageAccumulator {
#[must_use]
pub(crate) fn new() -> Self {
Self {
state: Arc::new(Mutex::new(RlmUsageState {
ledger_id: Uuid::new_v4().simple().to_string(),
..RlmUsageState::default()
})),
}
}
/// Reserve durable accounting capacity before a provider request.
/// Definite transport failure cancels the slot; ambiguous cancellation is
/// explicit incomplete coverage. Reaching the cap rejects before any
/// unreceipted provider work can occur.
pub(crate) async fn reserve(
&self,
route: crate::cost_status::EffectiveRouteEnvelope,
) -> std::result::Result<RlmUsageReservation, String> {
let mut state = self.state.lock().await;
if state.records.len() == crate::cost_status::MAX_CHILD_USAGE_RECORDS {
return Err(format!(
"RLM provider-call receipt limit reached ({}); request rejected before dispatch",
crate::cost_status::MAX_CHILD_USAGE_RECORDS
));
}
let index = state.records.len();
let source_id = format!("rlm:{}:request:{index}", state.ledger_id);
state.records.push(Some(RlmUsageSlot {
record: crate::cost_status::RuntimeUsageRecord {
source_id,
usage: crate::cost_status::EffectiveRouteUsage {
route: route.sanitized_for_persistence(),
usage: Usage::default(),
},
},
completed: false,
}));
Ok(RlmUsageReservation { index })
}
/// Attach a provider's reported usage to its already-reserved exact route.
pub(crate) async fn complete(&self, reservation: RlmUsageReservation, usage: &Usage) {
let mut state = self.state.lock().await;
let completed = if let Some(Some(slot)) = state.records.get_mut(reservation.index)
&& !slot.completed
{
super::add_usage_with_prompt_cache(&mut slot.record.usage.usage, usage);
slot.completed = true;
true
} else {
false
};
if completed {
super::add_usage_with_prompt_cache(&mut state.usage, usage);
}
}
/// Settle a decoded provider-success response without inventing usage.
/// `MessageResponse::usage == Usage::default()` is also what adapters
/// produce when the provider omitted the payload, so it is not proof of a
/// genuine zero-token request.
pub(crate) async fn settle_provider_success(
&self,
reservation: RlmUsageReservation,
usage: &Usage,
) {
if usage == &Usage::default() {
self.cancel(reservation, true).await;
} else {
self.complete(reservation, usage).await;
}
}
/// Remove a reservation that never produced provider-reported usage.
/// Ambiguous execution increments explicit incomplete coverage instead of
/// being persisted as a priced-zero response.
pub(crate) async fn cancel(&self, reservation: RlmUsageReservation, coverage_unknown: bool) {
let mut state = self.state.lock().await;
let cancelled = state.records.get_mut(reservation.index).and_then(|slot| {
if slot.as_ref().is_some_and(|slot| !slot.completed) {
slot.take()
} else {
None
}
});
if let Some(slot) = cancelled
&& coverage_unknown
{
state
.drop_records
.push(crate::cost_status::RuntimeUsageDropRecord {
source_id: slot.record.source_id,
route: slot.record.usage.route,
});
state.dropped_records = state.dropped_records.saturating_add(1);
}
}
pub(crate) async fn snapshot(&self) -> RlmUsageSnapshot {
let state = self.state.lock().await;
let pending = state
.records
.iter()
.flatten()
.filter(|slot| !slot.completed)
.map(|slot| crate::cost_status::RuntimeUsageDropRecord {
source_id: slot.record.source_id.clone(),
route: slot.record.usage.route.clone(),
})
.collect::<Vec<_>>();
let mut drop_records = state.drop_records.clone();
drop_records.extend(pending.iter().cloned());
RlmUsageSnapshot {
usage: state.usage.clone(),
records: state
.records
.iter()
.flatten()
.filter(|slot| slot.completed)
.map(|slot| slot.record.clone())
.collect(),
drop_records,
dropped_records: state
.dropped_records
.saturating_add(u64::try_from(pending.len()).unwrap_or(u64::MAX)),
}
}
}
/// Object-safe runtime-model adapter for a working kernel.
///
/// The normal turn loop owns a `SharedModelClient`, while the original RLM
@@ -124,7 +302,7 @@ pub struct RlmBridge {
/// Recursion budget remaining for `Rlm` / `RlmBatch` requests. When
/// zero, those requests fall back to plain `Llm` completions.
depth_remaining: u32,
usage: Arc<Mutex<Usage>>,
usage: RlmUsageAccumulator,
}
impl RlmBridge {
@@ -132,17 +310,31 @@ impl RlmBridge {
client: Arc<dyn RlmLlmClient>,
child_model: String,
depth_remaining: u32,
) -> Self {
Self::with_usage_accumulator(
client,
child_model,
depth_remaining,
RlmUsageAccumulator::new(),
)
}
pub(crate) fn with_usage_accumulator(
client: Arc<dyn RlmLlmClient>,
child_model: String,
depth_remaining: u32,
usage: RlmUsageAccumulator,
) -> Self {
Self {
client,
child_model,
depth_remaining,
usage: Arc::new(Mutex::new(Usage::default())),
usage,
}
}
pub fn usage_handle(&self) -> Arc<Mutex<Usage>> {
Arc::clone(&self.usage)
pub(crate) async fn usage_snapshot(&self) -> RlmUsageSnapshot {
self.usage.snapshot().await
}
async fn dispatch_llm(
@@ -155,6 +347,15 @@ impl RlmBridge {
let request_route = self
.client
.effective_route_envelope(&self.child_model, chrono::Utc::now());
let reservation = match self.usage.reserve(request_route.clone()).await {
Ok(reservation) => reservation,
Err(error) => {
return SingleResp {
text: String::new(),
error: Some(error),
};
}
};
let route_max_tokens = self
.client
.effective_max_output_tokens(&request_route.model);
@@ -191,12 +392,14 @@ impl RlmBridge {
match tokio::time::timeout(Duration::from_secs(CHILD_TIMEOUT_SECS), fut).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
self.usage.cancel(reservation, false).await;
return SingleResp {
text: String::new(),
error: Some(format!("llm_query failed: {e}")),
};
}
Err(_) => {
self.usage.cancel(reservation, true).await;
return SingleResp {
text: String::new(),
error: Some(format!("llm_query timed out after {CHILD_TIMEOUT_SECS}s")),
@@ -204,10 +407,12 @@ impl RlmBridge {
}
};
{
let mut u = self.usage.lock().await;
super::add_usage_with_prompt_cache(&mut u, &response.usage);
}
// Incomplete output is rejected below, but it is still a successful
// provider response and therefore billed. Complete the reserved route
// before inspecting the stop reason.
self.usage
.settle_provider_success(reservation, &response.usage)
.await;
if is_incomplete_stop_reason(response.stop_reason.as_deref()) {
return SingleResp {
@@ -279,7 +484,7 @@ impl RlmBridge {
// Recursive call. The dyn-erasure on `run_rlm_turn_inner` breaks
// the `bridge → turn → bridge` opaque-future cycle.
let result = super::turn::run_rlm_turn_inner(
let result = super::turn::run_rlm_turn_inner_with_usage(
Arc::clone(&self.client),
child_model.clone(),
prompt,
@@ -287,16 +492,12 @@ impl RlmBridge {
child_model,
tx,
self.depth_remaining.saturating_sub(1),
self.usage.clone(),
)
.await;
drain.abort();
{
let mut u = self.usage.lock().await;
super::add_usage_with_prompt_cache(&mut u, &result.usage);
}
SingleResp {
text: result.answer,
error: result.error,
@@ -518,9 +719,87 @@ mod tests {
Some(SystemPrompt::Text("child system".to_string()))
);
let usage = bridge.usage.lock().await;
assert_eq!(usage.input_tokens, 7);
assert_eq!(usage.output_tokens, 11);
let snapshot = bridge.usage_snapshot().await;
assert_eq!(snapshot.usage.input_tokens, 7);
assert_eq!(snapshot.usage.output_tokens, 11);
assert_eq!(snapshot.records.len(), 1);
assert_eq!(snapshot.records[0].usage.usage, snapshot.usage);
assert!(snapshot.drop_records.is_empty());
assert_eq!(snapshot.dropped_records, 0);
}
#[tokio::test]
async fn llm_dispatch_keeps_semantic_success_but_marks_missing_usage_once() {
let mock = Arc::new(MockLlmClient::new(Vec::new()));
mock.push_message_response(mock_response_with_usage(
"usable child answer",
Usage::default(),
));
let bridge = bridge_for(Arc::clone(&mock), 1);
let response = bridge
.dispatch(RpcRequest::Llm {
prompt: "child prompt".to_string(),
model: None,
max_tokens: None,
system: None,
})
.await;
let RpcResponse::Single(response) = response else {
panic!("expected single response");
};
assert_eq!(response.text, "usable child answer");
assert!(response.error.is_none());
let first = bridge.usage_snapshot().await;
let replay = bridge.usage_snapshot().await;
assert_eq!(first.usage, Usage::default());
assert!(first.records.is_empty());
assert_eq!(first.drop_records.len(), 1);
assert_eq!(first.dropped_records, 1);
assert_eq!(replay.drop_records, first.drop_records);
assert_eq!(replay.dropped_records, 1);
assert_eq!(first.drop_records[0].route.model, "child-model");
assert!(first.drop_records[0].source_id.starts_with("rlm:"));
}
#[tokio::test]
async fn repeated_reservation_settlement_cannot_duplicate_usage_or_missing_coverage() {
let mock = Arc::new(MockLlmClient::new(Vec::new()));
let bridge = bridge_for(Arc::clone(&mock), 1);
let route = RlmLlmClient::effective_route_envelope(
mock.as_ref(),
"child-model",
chrono::Utc::now(),
);
let usage_reservation = bridge
.usage
.reserve(route.clone())
.await
.expect("usage reservation");
let reported = Usage {
input_tokens: 3,
output_tokens: 5,
..Usage::default()
};
bridge.usage.complete(usage_reservation, &reported).await;
bridge.usage.complete(usage_reservation, &reported).await;
let missing_reservation = bridge
.usage
.reserve(route)
.await
.expect("missing reservation");
bridge.usage.cancel(missing_reservation, true).await;
bridge.usage.cancel(missing_reservation, true).await;
let snapshot = bridge.usage_snapshot().await;
assert_eq!(snapshot.usage, reported);
assert_eq!(snapshot.records.len(), 1);
assert_eq!(snapshot.drop_records.len(), 1);
assert_eq!(snapshot.dropped_records, 1);
}
#[tokio::test]
@@ -555,7 +834,7 @@ mod tests {
other => panic!("expected single response, got {other:?}"),
}
let usage = bridge.usage.lock().await;
let usage = bridge.usage_snapshot().await.usage;
assert_eq!(usage.input_tokens, 1000);
assert_eq!(usage.output_tokens, 100);
assert_eq!(usage.prompt_cache_hit_tokens, Some(800));
@@ -601,7 +880,10 @@ mod tests {
other => panic!("expected single response, got {other:?}"),
}
assert_eq!(*bridge.usage.lock().await, usage);
let snapshot = bridge.usage_snapshot().await;
assert_eq!(snapshot.usage, usage);
assert_eq!(snapshot.records.len(), 1);
assert_eq!(snapshot.records[0].usage.usage, usage);
assert_eq!(mock.call_count(), 1, "truncation must not retry");
}
@@ -643,9 +925,77 @@ mod tests {
.all(|request| request.model == "child-model")
);
let usage = bridge.usage.lock().await;
assert_eq!(usage.input_tokens, 9);
assert_eq!(usage.output_tokens, 12);
let snapshot = bridge.usage_snapshot().await;
assert_eq!(snapshot.usage.input_tokens, 9);
assert_eq!(snapshot.usage.output_tokens, 12);
assert_eq!(snapshot.records.len(), 3);
assert_ne!(
snapshot.records[0].source_id, snapshot.records[1].source_id,
"distinct provider calls must keep distinct stable identities"
);
}
#[tokio::test]
async fn shared_accumulator_rejects_the_first_unreceipted_request_before_provider_work() {
let mock = Arc::new(MockLlmClient::new(Vec::new()));
let client: Arc<dyn RlmLlmClient> = mock.clone();
let usage = RlmUsageAccumulator::new();
let bridge = RlmBridge::with_usage_accumulator(
Arc::clone(&client),
"child-model".to_string(),
1,
usage.clone(),
);
let nested_bridge =
RlmBridge::with_usage_accumulator(client, "child-model".to_string(), 1, usage);
let route = RlmLlmClient::effective_route_envelope(
mock.as_ref(),
"child-model",
chrono::Utc::now(),
);
for _ in 0..crate::cost_status::MAX_CHILD_USAGE_RECORDS {
let reservation = bridge
.usage
.reserve(route.clone())
.await
.expect("receipt slot below cap");
bridge
.usage
.complete(
reservation,
&Usage {
input_tokens: 1,
..Usage::default()
},
)
.await;
}
let response = nested_bridge
.dispatch(RpcRequest::Llm {
prompt: "must not reach provider".to_string(),
model: None,
max_tokens: None,
system: None,
})
.await;
let RpcResponse::Single(response) = response else {
panic!("expected single response");
};
assert!(
response
.error
.as_deref()
.is_some_and(|error| error.contains("rejected before dispatch"))
);
assert_eq!(mock.call_count(), 0);
let snapshot = bridge.usage_snapshot().await;
assert_eq!(
snapshot.records.len(),
crate::cost_status::MAX_CHILD_USAGE_RECORDS
);
assert_eq!(snapshot.dropped_records, 0);
assert!(snapshot.drop_records.is_empty());
}
#[tokio::test]
@@ -669,7 +1019,7 @@ mod tests {
other => panic!("expected single response, got {other:?}"),
}
let usage = bridge.usage.lock().await;
let usage = bridge.usage_snapshot().await.usage;
assert_eq!(usage.input_tokens, 3);
assert_eq!(usage.output_tokens, 5);
+157 -16
View File
@@ -16,7 +16,7 @@ use crate::models::{
};
use crate::repl::PythonRuntime;
use super::bridge::{RlmBridge, RlmLlmClient};
use super::bridge::{RlmBridge, RlmLlmClient, RlmUsageAccumulator};
use super::prompt::rlm_system_prompt;
use crate::models::Role;
@@ -79,6 +79,13 @@ pub struct RlmTurnResult {
pub duration: Duration,
pub error: Option<String>,
pub usage: Usage,
/// One exact frozen route/quote receipt per admitted provider request.
/// Distinct calls are never coalesced, even when they share a route.
pub routed_usage: Vec<crate::cost_status::RuntimeUsageRecord>,
/// Exact routes for provider-success responses that omitted authoritative
/// usage metadata.
pub routed_usage_drop_records: Vec<crate::cost_status::RuntimeUsageDropRecord>,
pub routed_usage_dropped_records: u64,
pub termination: RlmTermination,
/// Per-round trace. Empty when the loop never reached the REPL.
pub trace: Vec<RlmRoundTrace>,
@@ -145,7 +152,7 @@ pub(crate) fn run_rlm_turn_inner(
tx_event: mpsc::Sender<Event>,
max_depth: u32,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = RlmTurnResult> + Send>> {
Box::pin(run_rlm_turn_impl(
run_rlm_turn_inner_with_usage(
client,
model,
prompt,
@@ -153,7 +160,41 @@ pub(crate) fn run_rlm_turn_inner(
child_model,
tx_event,
max_depth,
))
RlmUsageAccumulator::new(),
)
}
/// Recursive entry point that keeps one pre-dispatch receipt bound across the
/// entire nested/batched RLM tree.
pub(crate) fn run_rlm_turn_inner_with_usage(
client: Arc<dyn RlmLlmClient>,
model: String,
prompt: String,
root_prompt: Option<String>,
child_model: String,
tx_event: mpsc::Sender<Event>,
max_depth: u32,
usage: RlmUsageAccumulator,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = RlmTurnResult> + Send>> {
Box::pin(async move {
let mut result = run_rlm_turn_impl(
client,
model,
prompt,
root_prompt,
child_model,
tx_event,
max_depth,
usage.clone(),
)
.await;
let snapshot = usage.snapshot().await;
result.usage = snapshot.usage;
result.routed_usage = snapshot.records;
result.routed_usage_drop_records = snapshot.drop_records;
result.routed_usage_dropped_records = snapshot.dropped_records;
result
})
}
/// RLM turns are long-running background-style work. Do not kill the whole
@@ -175,6 +216,7 @@ async fn run_rlm_turn_impl(
child_model: String,
tx_event: mpsc::Sender<Event>,
max_depth: u32,
routed_usage: RlmUsageAccumulator,
) -> RlmTurnResult {
let start = Instant::now();
let mut total_usage = Usage::default();
@@ -193,6 +235,9 @@ async fn run_rlm_turn_impl(
duration: start.elapsed(),
error: Some(format!("rlm: failed to stage context: {e}")),
usage: total_usage,
routed_usage: Vec::new(),
routed_usage_drop_records: Vec::new(),
routed_usage_dropped_records: 0,
termination: RlmTermination::Error,
trace,
total_rpcs,
@@ -211,6 +256,9 @@ async fn run_rlm_turn_impl(
duration: start.elapsed(),
error: Some(format!("rlm: failed to spawn REPL: {e}")),
usage: total_usage,
routed_usage: Vec::new(),
routed_usage_drop_records: Vec::new(),
routed_usage_dropped_records: 0,
termination: RlmTermination::Error,
trace,
total_rpcs,
@@ -219,8 +267,12 @@ async fn run_rlm_turn_impl(
};
// 3. Build the bridge that services llm_query / rlm_query RPCs.
let bridge = RlmBridge::new(Arc::clone(&client), child_model.clone(), max_depth);
let usage_handle = bridge.usage_handle();
let bridge = RlmBridge::with_usage_accumulator(
Arc::clone(&client),
child_model.clone(),
max_depth,
routed_usage.clone(),
);
let _ = tx_event
.send(Event::status(format!(
@@ -254,6 +306,9 @@ async fn run_rlm_turn_impl(
duration: start.elapsed(),
error: Some(format!("RLM turn timed out after {}s", timeout.as_secs())),
usage: total_usage,
routed_usage: Vec::new(),
routed_usage_drop_records: Vec::new(),
routed_usage_dropped_records: 0,
termination: RlmTermination::Error,
trace: trace.clone(),
total_rpcs,
@@ -270,6 +325,24 @@ async fn run_rlm_turn_impl(
// 4a. Root LLM generates code from metadata-only context.
let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
let reservation = match routed_usage.reserve(request_route.clone()).await {
Ok(reservation) => reservation,
Err(error) => {
break 'turn RlmTurnResult {
answer: String::new(),
iterations: iteration,
duration: start.elapsed(),
error: Some(error),
usage: total_usage,
routed_usage: Vec::new(),
routed_usage_drop_records: Vec::new(),
routed_usage_dropped_records: 0,
termination: RlmTermination::Error,
trace: trace.clone(),
total_rpcs,
};
}
};
let request = build_root_request(
&model,
&messages,
@@ -280,12 +353,16 @@ async fn run_rlm_turn_impl(
let response = match client.create_message_boxed(request).await {
Ok(r) => r,
Err(e) => {
routed_usage.cancel(reservation, false).await;
break 'turn RlmTurnResult {
answer: String::new(),
iterations: iteration + 1,
duration: start.elapsed(),
error: Some(format!("Root LLM call failed: {e}")),
usage: total_usage,
routed_usage: Vec::new(),
routed_usage_drop_records: Vec::new(),
routed_usage_dropped_records: 0,
termination: RlmTermination::Error,
trace: trace.clone(),
total_rpcs,
@@ -293,6 +370,11 @@ async fn run_rlm_turn_impl(
}
};
// Preserve billed usage even when the response is incomplete and
// its partial FINAL/REPL output is rejected below.
routed_usage
.settle_provider_success(reservation, &response.usage)
.await;
super::add_usage_with_prompt_cache(&mut total_usage, &response.usage);
if is_incomplete_stop_reason(response.stop_reason.as_deref()) {
@@ -305,6 +387,9 @@ async fn run_rlm_turn_impl(
"RLM root model response incomplete: provider stop reason `{reason}`; partial FINAL/REPL output was not accepted."
)),
usage: total_usage,
routed_usage: Vec::new(),
routed_usage_drop_records: Vec::new(),
routed_usage_dropped_records: 0,
termination: RlmTermination::Error,
trace: trace.clone(),
total_rpcs,
@@ -331,6 +416,9 @@ async fn run_rlm_turn_impl(
duration: start.elapsed(),
error: None,
usage: total_usage,
routed_usage: Vec::new(),
routed_usage_drop_records: Vec::new(),
routed_usage_dropped_records: 0,
termination: RlmTermination::NoCode,
trace: trace.clone(),
total_rpcs,
@@ -369,6 +457,9 @@ async fn run_rlm_turn_impl(
duration: start.elapsed(),
error: None,
usage: total_usage,
routed_usage: Vec::new(),
routed_usage_drop_records: Vec::new(),
routed_usage_dropped_records: 0,
termination: RlmTermination::Final,
trace: trace.clone(),
total_rpcs,
@@ -393,6 +484,9 @@ async fn run_rlm_turn_impl(
"RLM: model failed to emit ```repl after {MAX_CONSECUTIVE_NO_CODE} consecutive rounds"
)),
usage: total_usage,
routed_usage: Vec::new(),
routed_usage_drop_records: Vec::new(),
routed_usage_dropped_records: 0,
termination: RlmTermination::NoCode,
trace: trace.clone(),
total_rpcs,
@@ -441,6 +535,9 @@ async fn run_rlm_turn_impl(
duration: start.elapsed(),
error: Some(format!("REPL execution failed: {e}")),
usage: total_usage,
routed_usage: Vec::new(),
routed_usage_drop_records: Vec::new(),
routed_usage_dropped_records: 0,
termination: RlmTermination::Error,
trace: trace.clone(),
total_rpcs,
@@ -484,6 +581,9 @@ async fn run_rlm_turn_impl(
duration: start.elapsed(),
error: None,
usage: total_usage,
routed_usage: Vec::new(),
routed_usage_drop_records: Vec::new(),
routed_usage_dropped_records: 0,
termination: RlmTermination::Final,
trace: trace.clone(),
total_rpcs,
@@ -534,6 +634,9 @@ async fn run_rlm_turn_impl(
"RLM: {MAX_CONSECUTIVE_NO_CODE} consecutive empty REPL rounds"
)),
usage: total_usage,
routed_usage: Vec::new(),
routed_usage_drop_records: Vec::new(),
routed_usage_dropped_records: 0,
termination: RlmTermination::NoCode,
trace: trace.clone(),
total_rpcs,
@@ -608,24 +711,17 @@ async fn run_rlm_turn_impl(
"RLM loop exhausted after {MAX_RLM_ITERATIONS} iterations without FINAL"
)),
usage: total_usage,
routed_usage: Vec::new(),
routed_usage_drop_records: Vec::new(),
routed_usage_dropped_records: 0,
termination: RlmTermination::Exhausted,
trace: trace.clone(),
total_rpcs,
}
};
// Fold bridge usage (children + nested sub_rlm) into totals.
let bridge_usage = usage_handle.lock().await;
let mut final_usage = result.usage.clone();
super::add_usage_with_prompt_cache(&mut final_usage, &bridge_usage);
drop(bridge_usage);
repl.shutdown().await;
RlmTurnResult {
usage: final_usage,
..result
}
result
}
// ---------------------------------------------------------------------------
@@ -956,6 +1052,9 @@ mod tests {
assert!(error.contains("incomplete"), "{error}");
assert!(error.contains("max_tokens"), "{error}");
assert_eq!(result.usage, usage, "billed usage must still be charged");
assert_eq!(result.routed_usage.len(), 1);
assert_eq!(result.routed_usage[0].usage.usage, usage);
assert_eq!(result.routed_usage_dropped_records, 0);
assert_eq!(mock.call_count(), 1, "truncation must not retry");
assert!(
!marker.exists(),
@@ -963,6 +1062,48 @@ mod tests {
);
}
#[tokio::test]
async fn root_provider_success_without_usage_retains_exact_missing_receipt() {
let mock = Arc::new(MockLlmClient::new(Vec::new()));
mock.push_message_response(MessageResponse {
id: "mock_missing_rlm_usage".to_string(),
r#type: "message".to_string(),
role: "assistant".to_string(),
content: vec![ContentBlock::Text {
text: "partial".to_string(),
cache_control: None,
}],
model: "mock-model".to_string(),
stop_reason: Some("max_tokens".to_string()),
stop_sequence: None,
container: None,
usage: Usage::default(),
});
let client: Arc<dyn RlmLlmClient> = mock;
let (tx, _rx) = mpsc::channel(8);
let result = run_rlm_turn_inner(
client,
"root-model".to_string(),
"long context".to_string(),
None,
"child-model".to_string(),
tx,
0,
)
.await;
assert_eq!(result.termination, RlmTermination::Error);
assert_eq!(result.usage, Usage::default());
assert!(result.routed_usage.is_empty());
assert_eq!(result.routed_usage_drop_records.len(), 1);
assert_eq!(result.routed_usage_dropped_records, 1);
assert_eq!(
result.routed_usage_drop_records[0].route.model,
"root-model"
);
}
#[test]
fn extract_repl_code_finds_simple_block() {
let text = "Here:\n```repl\nprint('hi')\n```\nEnd.";
+525 -18
View File
@@ -425,6 +425,28 @@ pub(crate) fn resolve_route_candidate_with_context_metadata(
base_url_override: Option<String>,
context_window_override: Option<u32>,
provider_reported_context: Option<ProviderReportedKimiCodeContext>,
) -> Result<RouteCandidateResolution, String> {
resolve_route_candidate_with_catalog_resolver(
provider,
model_selector,
saved_provider_model,
base_url_override,
context_window_override,
provider_reported_context,
&RouteResolver::new(),
false,
)
}
fn resolve_route_candidate_with_catalog_resolver(
provider: ApiProvider,
model_selector: Option<&str>,
saved_provider_model: Option<&str>,
base_url_override: Option<String>,
context_window_override: Option<u32>,
provider_reported_context: Option<ProviderReportedKimiCodeContext>,
resolver: &RouteResolver,
endpoint_catalog_authoritative: bool,
) -> Result<RouteCandidateResolution, String> {
let effective_base_url = base_url_override
.as_deref()
@@ -432,7 +454,6 @@ pub(crate) fn resolve_route_candidate_with_context_metadata(
if let Some(model) = model_selector.or(saved_provider_model) {
validate_kimi_code_api_model_id(provider, effective_base_url, model)?;
}
let resolver = RouteResolver::new();
let base_request = RouteRequest {
explicit_provider: provider.kind(),
model_selector: model_selector.map(|model| LogicalModelRef::from(model.to_string())),
@@ -447,9 +468,14 @@ pub(crate) fn resolve_route_candidate_with_context_metadata(
// requested through `RouteRequest::limit_overrides` on a second pass; the
// resolver applies them BEFORE minting the final candidate and records
// their provenance on it.
let resolved = resolver
.resolve(&base_request)
.map_err(|err| err.to_string())?;
let resolve = |request: &RouteRequest| {
if endpoint_catalog_authoritative {
resolver.resolve_with_endpoint_catalog_authority(request)
} else {
resolver.resolve(request)
}
};
let resolved = resolve(&base_request).map_err(|err| err.to_string())?;
let plan = plan_limit_overrides(
provider,
&resolved,
@@ -459,12 +485,11 @@ pub(crate) fn resolve_route_candidate_with_context_metadata(
let candidate = if plan.overrides.is_empty() {
resolved
} else {
resolver
.resolve(&RouteRequest {
limit_overrides: plan.overrides,
..base_request
})
.map_err(|err| err.to_string())?
resolve(&RouteRequest {
limit_overrides: plan.overrides,
..base_request
})
.map_err(|err| err.to_string())?
};
Ok(RouteCandidateResolution {
candidate,
@@ -669,16 +694,57 @@ pub(crate) fn resolve_runtime_route_for_identity(
.then(|| model_roster().preferred_model_id().map(str::to_string))
.flatten();
let model_selector = model_selector.or(roster_preferred.as_deref());
let resolution = resolve_route_candidate_with_context_metadata(
provider,
model_selector,
saved_provider_model,
Some(route_config.deepseek_base_url()),
route_config.context_window_for_provider_config(provider),
None,
)?;
let base_url = route_config.deepseek_base_url();
// Every refreshed provider shares the same exact identity/endpoint gate.
// Codex keeps its separate authenticated account roster and protocol seam.
let resolution = if provider != ApiProvider::OpenaiCodex {
let status =
crate::provider_catalog_live::status_for_route(provider, &identity.key, &base_url);
let catalog = crate::provider_lake::runtime_catalog_resolver_for_identity(
provider,
Some(&identity.key),
&base_url,
status,
);
// Local Ollama's placeholder is never an executable model. Resolve
// an unset/auto/placeholder selection from this endpoint's fresh roster,
// while preserving an explicit or saved real tag verbatim.
let needs_local_default = provider == ApiProvider::Ollama
&& model_selector.or(saved_provider_model).is_none_or(|model| {
model.trim().eq_ignore_ascii_case("auto")
|| crate::config::is_unresolved_local_ollama_model(model)
});
if needs_local_default && !catalog.endpoint_catalog_authoritative {
return Err(
"Local Ollama has no fresh model catalog for this endpoint; select an explicit model or refresh its catalog."
.to_string(),
);
}
resolve_route_candidate_with_catalog_resolver(
provider,
model_selector.filter(|_| !needs_local_default),
saved_provider_model.filter(|_| !needs_local_default),
Some(base_url),
route_config.context_window_for_provider_config(provider),
None,
&catalog.resolver,
catalog.endpoint_catalog_authoritative,
)?
} else {
resolve_route_candidate_with_context_metadata(
provider,
model_selector,
saved_provider_model,
Some(base_url),
route_config.context_window_for_provider_config(provider),
None,
)?
};
let candidate = resolution.candidate;
let model = candidate.wire_model_id().as_str().to_string();
if provider == ApiProvider::Ollama && crate::config::is_unresolved_local_ollama_model(&model) {
return Err("Local Ollama did not report an executable default model.".to_string());
}
set_model_for_route(&mut route_config, provider, &model);
Ok(ResolvedRuntimeRoute {
@@ -1521,6 +1587,447 @@ mod tests {
assert_eq!(custom.wire_model_id().as_str(), "deepseek-v4-pro");
}
fn live_catalog_offering(
provider: &str,
model: &str,
base_url: &str,
) -> codewhale_config::catalog::CatalogOffering {
codewhale_config::catalog::CatalogOffering {
provider: provider.to_string(),
wire_model_id: model.to_string(),
endpoint_key: "chat".to_string(),
default_for_provider: true,
limit: Some(codewhale_config::models_dev::ModelsDevLimit {
context: Some(654_321),
input: Some(600_000),
output: Some(54_321),
}),
cost: Some(codewhale_config::models_dev::ModelsDevCost {
input: Some(1.25),
output: Some(3.5),
cache_read: None,
cache_write: None,
}),
modalities: Some(codewhale_config::models_dev::ModelsDevModalities {
input: vec!["text".to_string(), "image".to_string()],
output: vec!["text".to_string()],
}),
attachment: Some(true),
reasoning: Some(true),
tool_call: Some(true),
structured_output: Some(true),
source: codewhale_config::catalog::CatalogSource::Live {
base_url_fingerprint: codewhale_config::catalog::base_url_fingerprint(base_url),
fetched_at: codewhale_config::catalog::now_unix(),
},
..Default::default()
}
}
fn assert_live_catalog_route_facts(route: &ResolvedRuntimeRoute) {
use codewhale_config::route::{CapabilityState, PricingSku};
assert_eq!(route.candidate.limits().context_tokens, Some(654_321));
assert_eq!(route.candidate.limits().input_tokens, Some(600_000));
assert_eq!(route.candidate.limits().output_tokens, Some(54_321));
assert_eq!(route.context_window.tokens, 654_321);
assert_eq!(route.context_window.source, ContextWindowSource::Catalog);
let capabilities = route.candidate.capabilities();
assert_eq!(capabilities.attachments, CapabilityState::Supported);
assert_eq!(capabilities.image_input, CapabilityState::Supported);
assert_eq!(capabilities.reasoning, CapabilityState::Supported);
assert_eq!(capabilities.native_tool_calls, CapabilityState::Supported);
assert_eq!(capabilities.structured_output, CapabilityState::Supported);
match route.candidate.pricing() {
Some(PricingSku::Token {
input_per_mtok,
output_per_mtok,
}) => {
assert_eq!(*input_per_mtok, Some(1.25));
assert_eq!(*output_per_mtok, Some(3.5));
}
other => panic!("expected provider-live token pricing, got {other:?}"),
}
}
#[test]
fn live_only_openrouter_model_facts_reach_runtime_and_fail_closed_on_refresh_error() {
use codewhale_config::catalog::{CatalogRefreshError, ProviderCatalogDelta};
use codewhale_config::route::{CapabilityState, PricingSku};
let _env = crate::test_support::lock_test_env();
let _live = crate::provider_lake::lock_live_snapshot();
let home = tempfile::tempdir().expect("home");
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
crate::provider_catalog_live::reset_cache_for_test();
crate::provider_lake::clear_live_snapshot();
let base_url = "https://synthetic.openrouter.invalid/api/v1";
let model = "synthetic/live-only-openrouter-model";
let config = Config {
provider: Some("openrouter".to_string()),
providers: Some(ProvidersConfig {
openrouter: ProviderConfig {
base_url: Some(base_url.to_string()),
model: Some(model.to_string()),
..Default::default()
},
..Default::default()
}),
..Default::default()
};
let fingerprint = codewhale_config::catalog::base_url_fingerprint(base_url);
crate::provider_catalog_live::record_success(ProviderCatalogDelta {
provider: "openrouter".to_string(),
base_url_fingerprint: fingerprint.clone(),
fetched_at: codewhale_config::catalog::now_unix(),
offerings: vec![live_catalog_offering("openrouter", model, base_url)],
});
let route = resolve_runtime_route(&config, ApiProvider::Openrouter, Some(model))
.expect("live-only OpenRouter route resolves");
assert_eq!(route.model, model);
assert_live_catalog_route_facts(&route);
crate::provider_catalog_live::record_failure(
"openrouter",
&fingerprint,
CatalogRefreshError::Network,
);
let failed = resolve_runtime_route(&config, ApiProvider::Openrouter, Some(model))
.expect("wire id remains routable after a failed refresh");
assert!(!failed.candidate.limits().has_known_limit());
assert_eq!(
failed.candidate.capabilities().image_input,
CapabilityState::Unknown
);
assert!(matches!(
failed.candidate.pricing(),
Some(PricingSku::UnknownOrStale)
));
crate::provider_catalog_live::reset_cache_for_test();
crate::provider_lake::clear_live_snapshot();
}
#[test]
fn unrelated_live_roster_cannot_change_direct_model_ownership() {
use codewhale_config::catalog::ProviderCatalogDelta;
let _env = crate::test_support::lock_test_env();
let _live = crate::provider_lake::lock_live_snapshot();
let home = tempfile::tempdir().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
crate::provider_catalog_live::reset_cache_for_test();
crate::provider_lake::clear_live_snapshot();
let config = Config {
provider: Some("deepseek".into()),
..Default::default()
};
let model = "unlisted-future-direct-model";
let before = resolve_runtime_route(&config, ApiProvider::Deepseek, Some(model)).unwrap();
let endpoint = "https://other-provider.catalog.invalid/v1";
let ticket = crate::provider_catalog_live::begin_refresh_for_identity(
ApiProvider::Telecomjs,
"telecomjs",
endpoint,
);
crate::provider_catalog_live::record_success_if_current(
&ticket,
ProviderCatalogDelta {
provider: "telecomjs".into(),
base_url_fingerprint: codewhale_config::catalog::base_url_fingerprint(endpoint),
fetched_at: codewhale_config::catalog::now_unix(),
offerings: vec![live_catalog_offering("telecomjs", model, endpoint)],
},
);
let after = resolve_runtime_route(&config, ApiProvider::Deepseek, Some(model)).unwrap();
assert_eq!(after.model, before.model);
assert_eq!(
after.candidate.endpoint().base_url,
before.candidate.endpoint().base_url
);
assert_eq!(
after.candidate.endpoint().endpoint_key,
before.candidate.endpoint().endpoint_key
);
assert_eq!(
after.candidate.endpoint().protocol,
before.candidate.endpoint().protocol
);
assert_eq!(after.candidate.limits(), before.candidate.limits());
crate::provider_catalog_live::reset_cache_for_test();
crate::provider_lake::clear_live_snapshot();
}
#[test]
fn every_refreshed_provider_uses_fresh_exact_endpoint_route_facts() {
use codewhale_config::catalog::{CatalogRefreshError, ProviderCatalogDelta};
let _env = crate::test_support::lock_test_env();
let _live = crate::provider_lake::lock_live_snapshot();
let home = tempfile::tempdir().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
crate::provider_catalog_live::reset_cache_for_test();
crate::provider_lake::clear_live_snapshot();
for provider in [
ApiProvider::Ollama,
ApiProvider::Codewhale,
ApiProvider::Concentrate,
ApiProvider::Telecomjs,
ApiProvider::Edenai,
] {
let identity = provider.as_str();
let endpoint = format!("https://{identity}.catalog.invalid/v1");
let model = "synthetic-live-model";
let mut config = Config {
provider: Some(identity.into()),
..Default::default()
};
config.provider_config_for_mut(provider).base_url = Some(endpoint.clone());
let ticket = crate::provider_catalog_live::begin_refresh_for_identity(
provider, identity, &endpoint,
);
let fingerprint = codewhale_config::catalog::base_url_fingerprint(&endpoint);
assert!(
crate::provider_catalog_live::record_success_if_current(
&ticket,
ProviderCatalogDelta {
provider: identity.into(),
base_url_fingerprint: fingerprint.clone(),
fetched_at: codewhale_config::catalog::now_unix(),
offerings: vec![live_catalog_offering(identity, model, &endpoint)],
}
)
.is_some()
);
let route = resolve_runtime_route(&config, provider, Some(model)).unwrap();
assert_eq!(route.candidate.endpoint().base_url, endpoint);
assert_live_catalog_route_facts(&route);
let default = resolve_runtime_route(&config, provider, None).unwrap();
assert_eq!(
default.model, model,
"{identity} default must come from its own roster"
);
let mut other = config.clone();
other.provider_config_for_mut(provider).base_url =
Some("https://other.catalog.invalid/v1".into());
let unowned = resolve_runtime_route(&other, provider, Some(model)).unwrap();
assert!(
!unowned.candidate.limits().has_known_limit(),
"{identity} must not reuse another endpoint's limits"
);
crate::provider_catalog_live::record_failure_if_current(
&ticket,
identity,
&fingerprint,
CatalogRefreshError::Network,
);
let failed = resolve_runtime_route(&config, provider, Some(model)).unwrap();
assert!(
!failed.candidate.limits().has_known_limit(),
"{identity} failed refresh must revoke executable live facts"
);
}
crate::provider_catalog_live::reset_cache_for_test();
crate::provider_lake::clear_live_snapshot();
}
#[test]
fn ollama_default_requires_fresh_endpoint_tags_and_preserves_explicit_choices() {
use codewhale_config::catalog::{CatalogRefreshError, ProviderCatalogDelta};
let _env = crate::test_support::lock_test_env();
let _live = crate::provider_lake::lock_live_snapshot();
let home = tempfile::tempdir().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
crate::provider_catalog_live::reset_cache_for_test();
crate::provider_lake::clear_live_snapshot();
let endpoint = "http://localhost:11451/v1";
let mut config = Config {
provider: Some("ollama".into()),
..Default::default()
};
config.provider_config_for_mut(ApiProvider::Ollama).base_url = Some(endpoint.into());
for selector in [None, Some("auto"), Some("unknown")] {
assert!(resolve_runtime_route(&config, ApiProvider::Ollama, selector).is_err());
}
assert_eq!(
resolve_runtime_route(&config, ApiProvider::Ollama, Some("chosen:tag"))
.unwrap()
.model,
"chosen:tag"
);
let fingerprint = codewhale_config::catalog::base_url_fingerprint(endpoint);
let ticket = crate::provider_catalog_live::begin_refresh_for_identity(
ApiProvider::Ollama,
"ollama",
endpoint,
);
let offerings = ["zeta:tag", "alpha:tag"]
.into_iter()
.map(|model| {
let mut row = live_catalog_offering("ollama", model, endpoint);
row.default_for_provider = false; // Real Ollama tags have no default flag.
row
})
.collect();
crate::provider_catalog_live::record_success_if_current(
&ticket,
ProviderCatalogDelta {
provider: "ollama".into(),
base_url_fingerprint: fingerprint.clone(),
fetched_at: codewhale_config::catalog::now_unix(),
offerings,
},
);
for selector in [None, Some("auto"), Some("unknown")] {
assert_eq!(
resolve_runtime_route(&config, ApiProvider::Ollama, selector)
.unwrap()
.model,
"alpha:tag"
);
}
config.set_provider_model_override(ApiProvider::Ollama, Some("saved:tag".into()));
assert_eq!(
resolve_runtime_route(&config, ApiProvider::Ollama, None)
.unwrap()
.model,
"saved:tag"
);
assert_eq!(
resolve_runtime_route(&config, ApiProvider::Ollama, Some("explicit:tag"))
.unwrap()
.model,
"explicit:tag"
);
config.set_provider_model_override(ApiProvider::Ollama, Some("unknown".into()));
assert_eq!(
resolve_runtime_route(&config, ApiProvider::Ollama, None)
.unwrap()
.model,
"alpha:tag"
);
crate::provider_catalog_live::record_failure_if_current(
&ticket,
"ollama",
&fingerprint,
CatalogRefreshError::Network,
);
assert!(resolve_runtime_route(&config, ApiProvider::Ollama, None).is_err());
assert_eq!(
resolve_runtime_route(&config, ApiProvider::Ollama, Some("explicit:tag"))
.unwrap()
.model,
"explicit:tag"
);
crate::provider_catalog_live::reset_cache_for_test();
crate::provider_lake::clear_live_snapshot();
}
#[test]
fn named_baseten_live_facts_reach_exact_custom_runtime_without_leaking() {
use codewhale_config::catalog::ProviderCatalogDelta;
let _env = crate::test_support::lock_test_env();
let _live = crate::provider_lake::lock_live_snapshot();
let home = tempfile::tempdir().expect("home");
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
crate::provider_catalog_live::reset_cache_for_test();
crate::provider_lake::clear_live_snapshot();
let base_url = codewhale_config::BASETEN_BASE_URL;
let model = "synthetic-live-baseten-model";
let mut custom = std::collections::HashMap::new();
custom.insert(
codewhale_config::BASETEN_TEMPLATE_ID.to_string(),
ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some(base_url.to_string()),
model: Some(model.to_string()),
..Default::default()
},
);
let config = Config {
provider: Some(codewhale_config::BASETEN_TEMPLATE_ID.to_string()),
providers: Some(ProvidersConfig {
custom,
..Default::default()
}),
..Default::default()
};
crate::provider_catalog_live::record_success(ProviderCatalogDelta {
provider: codewhale_config::BASETEN_TEMPLATE_ID.to_string(),
base_url_fingerprint: codewhale_config::catalog::base_url_fingerprint(base_url),
fetched_at: codewhale_config::catalog::now_unix(),
offerings: vec![live_catalog_offering(
codewhale_config::BASETEN_TEMPLATE_ID,
model,
base_url,
)],
});
let route = resolve_runtime_route(&config, ApiProvider::Custom, Some(model))
.expect("named Baseten route resolves");
assert_eq!(route.identity.key, codewhale_config::BASETEN_TEMPLATE_ID);
assert_eq!(route.model, model);
assert_live_catalog_route_facts(&route);
let alias_identity = "base-ten";
let alias_model = "synthetic-alias-baseten-model";
let mut alias_custom = std::collections::HashMap::new();
alias_custom.insert(
alias_identity.to_string(),
ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some(base_url.to_string()),
model: Some(alias_model.to_string()),
..Default::default()
},
);
let alias_config = Config {
provider: Some(alias_identity.to_string()),
providers: Some(ProvidersConfig {
custom: alias_custom,
..Default::default()
}),
..Default::default()
};
crate::provider_catalog_live::record_success(ProviderCatalogDelta {
provider: alias_identity.to_string(),
base_url_fingerprint: codewhale_config::catalog::base_url_fingerprint(base_url),
fetched_at: codewhale_config::catalog::now_unix(),
offerings: vec![live_catalog_offering(alias_identity, alias_model, base_url)],
});
let alias_route =
resolve_runtime_route(&alias_config, ApiProvider::Custom, Some(alias_model))
.expect("Baseten schema alias route resolves");
assert_eq!(alias_route.identity.key, alias_identity);
assert_live_catalog_route_facts(&alias_route);
assert!(
crate::provider_lake::catalog_offering_for_model_identity(
ApiProvider::Custom,
Some(codewhale_config::BASETEN_TEMPLATE_ID),
alias_model,
)
.is_none(),
"a Baseten schema alias must not share another exact table's live roster"
);
let unrelated = custom_config("https://other-compatible.invalid/v1", model);
let unrelated_route = resolve_runtime_route(&unrelated, ApiProvider::Custom, Some(model))
.expect("another compatible provider remains routable");
assert!(!unrelated_route.candidate.limits().has_known_limit());
assert_eq!(
unrelated_route.candidate.capabilities(),
codewhale_config::route::RouteCapabilities::default()
);
crate::provider_catalog_live::reset_cache_for_test();
crate::provider_lake::clear_live_snapshot();
}
fn custom_config(base_url: &str, model: &str) -> Config {
let mut custom = std::collections::HashMap::new();
custom.insert(
+248 -49
View File
@@ -19,6 +19,8 @@ use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post, put};
use axum::{Json, Router};
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::Utc;
use codewhale_protocol::agent_mail::{
AgentMailDeliveryMode, AgentMailEnvelope, AgentMailMessageId, AgentMailSendRequest,
@@ -35,6 +37,7 @@ use codewhale_secrets::account::{
use codewhale_secrets::account::{AccountSessionStore, secure_account_session_secrets};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
@@ -842,7 +845,9 @@ pub async fn run_http_server(
) -> Result<()> {
validate_runtime_listener_security(&options)?;
let task_default_model = config.default_model();
// Keep the server usable before a local catalog arrives. Omitted API
// requests are checked at admission; background tasks keep the auto sentinel.
let task_default_model = runtime_request_model(&config, None).unwrap_or_else(|_| "auto".into());
let task_cfg = TaskManagerConfig::from_runtime(
&config,
workspace.clone(),
@@ -1514,8 +1519,18 @@ async fn health() -> Json<HealthResponse> {
})
}
fn runtime_request_model(config: &Config, requested: Option<&str>) -> String {
requested.map_or_else(|| config.default_model(), str::to_string)
fn runtime_request_model(config: &Config, requested: Option<&str>) -> Result<String, ApiError> {
if let Some(model) = requested {
return Ok(model.to_string());
}
let provider = config.api_provider();
let model = provider_default_model_for_api(config, provider, provider);
if model.is_empty() {
return Err(ApiError::bad_request(
"The active provider has no available default model; refresh its catalog or select an explicit model.",
));
}
Ok(model)
}
async fn create_task(
@@ -1529,7 +1544,7 @@ async fn create_task(
req.workspace = Some(state.workspace.clone());
}
if req.model.is_none() && req.model_provider.is_none() && req.model_provider_id.is_none() {
req.model = Some(runtime_request_model(&state.config.read(), None));
req.model = Some(runtime_request_model(&state.config.read(), None)?);
}
let task = state
.task_manager
@@ -2505,18 +2520,21 @@ fn open_fleet_manager(state: &RuntimeApiState) -> Result<FleetManager, ApiError>
(
exec_config,
config.fleet_config(),
config.default_model(),
runtime_request_model(&config, None).ok(),
config.clone(),
)
};
FleetManager::open(&state.workspace)
.map(|manager| {
manager
let manager = manager
.with_exec_config(exec_config)
.with_fleet_config(fleet_config)
.with_sub_agent_manager(state.sub_agent_manager.clone())
.with_session_model(session_model)
.with_route_config(route_config)
.with_route_config(route_config);
match session_model {
Some(model) => manager.with_session_model(model),
None => manager,
}
})
.map_err(|err| ApiError::internal(format!("Failed to open Fleet manager: {err}")))
}
@@ -5224,7 +5242,7 @@ async fn stream_turn(
return Err(ApiError::bad_request("prompt is required"));
}
let model = runtime_request_model(&state.config.read(), req.model.as_deref());
let model = runtime_request_model(&state.config.read(), req.model.as_deref())?;
let workspace = req
.workspace
.clone()
@@ -6021,6 +6039,154 @@ struct ProviderModelEntry {
struct ProviderModelsResponse {
provider: String,
models: Vec<ProviderModelEntry>,
total: usize,
#[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")]
next_cursor: Option<String>,
}
const DEFAULT_PROVIDER_MODELS_PAGE_SIZE: usize = 100;
const MAX_PROVIDER_MODELS_PAGE_SIZE: usize = 250;
const MAX_PROVIDER_MODELS_CATALOG_SIZE: usize = 10_000;
const PROVIDER_MODELS_CURSOR_VERSION: u8 = 1;
const MAX_PROVIDER_MODELS_CURSOR_BYTES: usize = 1_024;
const MAX_PROVIDER_MODELS_FILTER_CHARS: usize = 128;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ProviderModelsCursor {
version: u8,
provider: String,
filter: String,
catalog_fingerprint: String,
offset: usize,
}
fn normalized_provider_model_filter(filter: Option<&str>) -> Result<String, ApiError> {
let filter = filter.unwrap_or_default().trim();
if filter.chars().count() > MAX_PROVIDER_MODELS_FILTER_CHARS {
return Err(ApiError::bad_request(format!(
"Provider model filter exceeds {MAX_PROVIDER_MODELS_FILTER_CHARS} characters"
)));
}
Ok(filter.to_lowercase())
}
fn encode_provider_models_cursor(cursor: &ProviderModelsCursor) -> Result<String, ApiError> {
let bytes = serde_json::to_vec(cursor)
.map_err(|error| ApiError::internal(format!("Could not encode model cursor: {error}")))?;
if bytes.len() > MAX_PROVIDER_MODELS_CURSOR_BYTES {
return Err(ApiError::internal(
"Provider model cursor exceeds the safe size limit",
));
}
Ok(URL_SAFE_NO_PAD.encode(bytes))
}
fn decode_provider_models_cursor(value: &str) -> Result<ProviderModelsCursor, ApiError> {
if value.is_empty() || value.len() > MAX_PROVIDER_MODELS_CURSOR_BYTES.div_ceil(3) * 4 {
return Err(ApiError::bad_request("Invalid provider model cursor"));
}
let bytes = URL_SAFE_NO_PAD
.decode(value)
.map_err(|_| ApiError::bad_request("Invalid provider model cursor"))?;
if bytes.len() > MAX_PROVIDER_MODELS_CURSOR_BYTES {
return Err(ApiError::bad_request("Invalid provider model cursor"));
}
let cursor: ProviderModelsCursor = serde_json::from_slice(&bytes)
.map_err(|_| ApiError::bad_request("Invalid provider model cursor"))?;
if cursor.version != PROVIDER_MODELS_CURSOR_VERSION
|| cursor.provider.is_empty()
|| cursor.offset == 0
|| cursor.offset > MAX_PROVIDER_MODELS_CATALOG_SIZE
|| cursor.catalog_fingerprint.len() != 64
|| !cursor
.catalog_fingerprint
.bytes()
.all(|byte| byte.is_ascii_hexdigit())
{
return Err(ApiError::bad_request("Invalid provider model cursor"));
}
Ok(cursor)
}
fn paginate_provider_models(
provider: &str,
mut models: Vec<ProviderModelEntry>,
params: &ListProviderModelsParams,
) -> Result<ProviderModelsResponse, ApiError> {
let filter = normalized_provider_model_filter(params.filter.as_deref())?;
let limit = params.limit.unwrap_or(DEFAULT_PROVIDER_MODELS_PAGE_SIZE);
if limit == 0 || limit > MAX_PROVIDER_MODELS_PAGE_SIZE {
return Err(ApiError::bad_request(format!(
"Provider model page limit must be between 1 and {MAX_PROVIDER_MODELS_PAGE_SIZE}"
)));
}
models.sort_by(|left, right| {
left.id
.to_lowercase()
.cmp(&right.id.to_lowercase())
.then_with(|| left.id.cmp(&right.id))
});
models.dedup_by(|left, right| left.id.eq_ignore_ascii_case(&right.id));
if models.len() > MAX_PROVIDER_MODELS_CATALOG_SIZE {
return Err(ApiError::internal(format!(
"Provider model catalog exceeds the safe {MAX_PROVIDER_MODELS_CATALOG_SIZE}-row limit"
)));
}
if !filter.is_empty() {
models.retain(|entry| entry.id.to_lowercase().contains(&filter));
}
// A live catalog can refresh between requests. Bind the opaque position
// to the exact sorted projection so additions before the cursor cannot
// disappear silently from a multi-page response.
let catalog_bytes = serde_json::to_vec(&models).map_err(|error| {
ApiError::internal(format!("Could not fingerprint model catalog: {error}"))
})?;
let catalog_fingerprint = Sha256::digest(catalog_bytes)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
let start = if let Some(encoded) = params.cursor.as_deref() {
let cursor = decode_provider_models_cursor(encoded)?;
if cursor.provider != provider || cursor.filter != filter {
return Err(ApiError::bad_request(
"Provider model cursor does not match this provider and filter",
));
}
if cursor.catalog_fingerprint != catalog_fingerprint {
return Err(ApiError::bad_request(
"Provider model cursor is stale; restart from the first page",
));
}
cursor.offset
} else {
0
};
let total = models.len();
let end = start.saturating_add(limit).min(total);
let page = models
.get(start..end)
.ok_or_else(|| ApiError::bad_request("Provider model cursor is outside the catalog"))?
.to_vec();
let next_cursor = if end < total {
Some(encode_provider_models_cursor(&ProviderModelsCursor {
version: PROVIDER_MODELS_CURSOR_VERSION,
provider: provider.to_string(),
filter,
catalog_fingerprint,
offset: end,
})?)
} else {
None
};
Ok(ProviderModelsResponse {
provider: provider.to_string(),
models: page,
total,
next_cursor,
})
}
fn push_unique_model(models: &mut Vec<String>, model: &str) {
@@ -6034,10 +6200,6 @@ fn push_unique_model(models: &mut Vec<String>, model: &str) {
}
}
fn provider_uses_custom_route_for_api(config: &Config, provider: ApiProvider) -> bool {
config.provider_uses_custom_endpoint(provider)
}
fn provider_models_for_api(
config: &Config,
active_provider: ApiProvider,
@@ -6051,19 +6213,26 @@ fn provider_models_for_api(
push_unique_model(&mut models, model);
}
if provider == active_provider {
let active_model = config.default_model();
let active_model = provider_default_model_for_api(config, active_provider, provider);
if !active_model.trim().eq_ignore_ascii_case("auto") {
push_unique_model(&mut models, &active_model);
}
if config.model_ids_pass_through() {
return models;
}
let exact_catalog = crate::provider_catalog_live::cached_entry_for_route(
provider,
&config.provider_identity_for(provider),
&config.base_url_for_route(provider),
)
.ok()
.flatten()
.is_some_and(|entry| entry.fetched_at > 0);
if !config.model_ids_pass_through_for_provider(provider) || exact_catalog {
for model in crate::provider_lake::models_for_provider(config, active_provider, provider) {
push_unique_model(&mut models, &model);
}
}
if provider_uses_custom_route_for_api(config, provider) {
return models;
}
for model in crate::provider_lake::models_for_provider(config, active_provider, provider) {
push_unique_model(&mut models, &model);
if provider == ApiProvider::Ollama {
models.retain(|model| !crate::config::is_unresolved_local_ollama_model(model));
}
models
}
@@ -6080,16 +6249,15 @@ fn provider_model_image_input_for_api(
fn provider_default_model_for_api(
config: &Config,
active_provider: ApiProvider,
_active_provider: ApiProvider,
provider: ApiProvider,
) -> String {
if provider == active_provider {
return config.default_model();
let model = crate::model_inventory::provider_default_model(config, provider);
if provider == ApiProvider::Ollama && crate::config::is_unresolved_local_ollama_model(&model) {
String::new()
} else {
model
}
provider_models_for_api(config, active_provider, provider)
.into_iter()
.next()
.unwrap_or_default()
}
pub(crate) fn runtime_chat_model_id_is_safe(value: &str) -> bool {
@@ -6143,6 +6311,21 @@ pub(crate) fn runtime_chat_route_id_is_safe(value: &str) -> bool {
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
}
fn runtime_chat_safe_models(mut models: Vec<String>) -> Result<Vec<String>, String> {
models.retain(|model| runtime_chat_model_id_is_safe(model));
models.sort();
models.dedup();
if models.len() > MAX_PROVIDER_MODELS_CATALOG_SIZE {
return Err(format!(
"The active Runtime provider catalog exceeds the safe {MAX_PROVIDER_MODELS_CATALOG_SIZE}-model relay limit."
));
}
if models.is_empty() {
return Err("The active Runtime provider has no safe model catalog.".to_string());
}
Ok(models)
}
/// Build the deliberately narrow provider projection used by the account-owned
/// Runtime Chat relay. This is the same active-route truth exposed by the
/// authenticated native `/v1/runtime/info`, `/v1/providers`, and
@@ -6180,15 +6363,11 @@ pub(crate) fn runtime_chat_relay_catalog(
}
};
let mut models = provider_models_for_api(config, provider, provider);
models.retain(|model| runtime_chat_model_id_is_safe(model));
models.sort();
models.dedup();
models.truncate(256);
if models.is_empty() {
return Err("The active Runtime provider has no safe model catalog.".to_string());
}
let models = runtime_chat_safe_models(provider_models_for_api(config, provider, provider))?;
let requested_default = provider_default_model_for_api(config, provider, provider);
if provider == ApiProvider::Ollama && requested_default.is_empty() {
return Err("The active local provider has no fresh default model catalog.".to_string());
}
let default_model = models
.iter()
.find(|model| model.as_str() == requested_default)
@@ -6248,8 +6427,11 @@ async fn list_providers(
let mut providers = Vec::new();
for api_provider in ApiProvider::sorted_for_display() {
let default_model = provider_default_model_for_api(&config, active_provider, api_provider);
let identity = config.provider_identity_for(api_provider);
let base_url = config.base_url_for_route_identity(api_provider, &identity);
let has_model_catalog =
!crate::provider_lake::all_catalog_models_for_provider(api_provider).is_empty();
!crate::provider_lake::catalog_models_for_route(api_provider, &identity, &base_url)
.is_empty();
providers.push(ProviderEntry {
id: api_provider.as_str().to_string(),
model_provider_id: (api_provider == active_provider)
@@ -6270,18 +6452,21 @@ async fn list_providers(
#[derive(Debug, Deserialize)]
struct ListProviderModelsParams {
/// Optional filter: when provided, models whose id contains this
/// substring (case-insensitive) are returned. Currently informational —
/// the catalog is small enough to filter client-side.
/// Optional case-insensitive substring filter applied before pagination.
#[serde(default)]
#[allow(dead_code)]
filter: Option<String>,
/// Opaque continuation cursor returned as `nextCursor` by the prior page.
#[serde(default)]
cursor: Option<String>,
/// Page size. The bounded default is 100 and the maximum is 250.
#[serde(default)]
limit: Option<usize>,
}
async fn list_provider_models(
State(state): State<RuntimeApiState>,
Path(id): Path<String>,
_params: Query<ListProviderModelsParams>,
Query(params): Query<ListProviderModelsParams>,
) -> Result<Json<ProviderModelsResponse>, ApiError> {
let config = state.config.read().clone();
let active_provider = config.api_provider();
@@ -6301,10 +6486,7 @@ async fn list_provider_models(
id,
})
.collect();
Ok(Json(ProviderModelsResponse {
provider: api_provider.as_str().to_string(),
models,
}))
paginate_provider_models(api_provider.as_str(), models, &params).map(Json)
}
/// Request body for `POST /v1/providers/{id}/switch`.
@@ -6334,6 +6516,8 @@ struct SwitchProviderResponse {
/// not `ProviderEntry.default_model`, to avoid showing the catalog
/// default when the user has configured a different model.
model: String,
/// False while the selected local endpoint has no executable default.
model_available: bool,
/// Human-readable status message for logging/toasts.
message: String,
/// Whether the new provider + model were persisted to config.toml.
@@ -6454,10 +6638,20 @@ async fn switch_provider(
// default and NOT the previously-active model.
let (active_provider, active_model) = {
let config = state.config.read();
(config.api_provider(), config.default_model())
let provider = config.api_provider();
(
provider,
provider_default_model_for_api(&config, provider, provider),
)
};
let message = if model_override.is_some() {
let model_available = !active_model.is_empty();
let message = if !model_available {
format!(
"Provider switched to {}; refresh its catalog or select an explicit model.",
active_provider.as_str()
)
} else if model_override.is_some() {
format!(
"Provider switched to {} (model: {}).",
active_provider.as_str(),
@@ -6474,6 +6668,7 @@ async fn switch_provider(
Ok(Json(SwitchProviderResponse {
provider: active_provider.as_str().to_string(),
model: active_model,
model_available,
message,
persisted: true,
}))
@@ -6485,6 +6680,7 @@ async fn switch_provider(
#[derive(Debug, Clone, Serialize)]
struct GuiConfigResponse {
model: String,
model_available: bool,
provider: String,
approval_mode: String,
reasoning_effort: String,
@@ -6559,7 +6755,9 @@ async fn get_config(
let settings = crate::settings::Settings::load_persisted().unwrap_or_default();
let mcp_config_path = config.mcp_config_path().display().to_string();
let model = config.default_model();
let resolved_model = runtime_request_model(&config, None);
let model_available = resolved_model.is_ok();
let model = resolved_model.unwrap_or_default();
let provider = config.provider_identity_for(config.api_provider());
@@ -6582,6 +6780,7 @@ async fn get_config(
Ok(Json(GuiConfigResponse {
model,
model_available,
provider,
approval_mode,
reasoning_effort,
+632 -28
View File
@@ -121,12 +121,12 @@ fn provider_default_model_cases() -> Vec<(&'static str, Config, &'static str)> {
fn runtime_request_model_uses_the_active_provider_default() {
for (label, config, expected) in provider_default_model_cases() {
assert_eq!(
runtime_request_model(&config, None),
runtime_request_model(&config, None).expect("configured default"),
expected,
"{label} omitted-model resolution"
);
assert_eq!(
runtime_request_model(&config, Some("explicit-model")),
runtime_request_model(&config, Some("explicit-model")).expect("explicit model"),
"explicit-model",
"{label} explicit model"
);
@@ -444,16 +444,20 @@ fn messages_from_thread_detail_batches_tool_results() {
ended_at: Some(now),
duration_ms: Some(0),
usage: None,
routing_settlement: false,
effective_route_usage: None,
permission_posture: Some("ask".to_string()),
effective_provider: None,
effective_provider_id: None,
effective_openrouter_vendor: None,
effective_billing_surface: None,
effective_endpoint_fingerprint: None,
effective_provider_live_pricing: None,
effective_billing_mode: None,
effective_dispatched_at: None,
effective_model: None,
routed_usage: Vec::new(),
routed_usage_drop_records: Vec::new(),
routed_usage_source_ids: Vec::new(),
routed_usage_dropped_records: 0,
error: None,
@@ -980,6 +984,155 @@ async fn spawn_test_server_with_root_token_mobile_workspace_and_overrides(
SharedRuntimeThreadManager,
tokio::task::JoinHandle<()>,
)>,
> {
let (setup_tx, setup_rx) = oneshot::channel();
// If this test sealed the process environment (`lock_test_env`), the
// server thread must join that scope: its `Config::load` reads env through
// `with_test_env_lock`, which would otherwise block on the mutex the test
// holds while awaiting this very setup — a deadlock.
let env_ticket = crate::test_support::env_scope_ticket();
spawn_product_stack_server(
root,
sessions_dir,
runtime_token,
mobile_enabled,
workspace,
overrides,
env_ticket,
setup_tx,
);
let Some((addr, runtime_threads, shutdown_tx)) = setup_rx
.await
.context("runtime-api test server thread ended during setup")??
else {
return Ok(None);
};
// Owns the shutdown side for as long as the test keeps the handle alive:
// aborting it (or the test runtime dropping it) closes the listener and
// ends the server thread.
let handle = tokio::spawn(async move {
let _shutdown = shutdown_tx;
std::future::pending::<()>().await
});
Ok(Some((addr, runtime_threads, handle)))
}
/// What the server thread hands back once the router is bound: everything the
/// test body needs, plus the shutdown side of the server.
type TestServerSetup = (SocketAddr, SharedRuntimeThreadManager, oneshot::Sender<()>);
/// Builds and serves the Runtime API router where the product builds and
/// serves it: on a thread with the product's `CODEWHALE_MAIN_STACK_BYTES`
/// stack.
///
/// `#[tokio::test]` drives its current-thread runtime on the 2 MiB libtest
/// thread, so a harness that built its state and spawned its server onto that
/// runtime ran every product path — config load and reload (the serde
/// `toml::de::visit_map` frames for the full `Config`), manager construction,
/// thread lifecycle, streaming — on a stack the product never gives it
/// (`lib.rs` sizes the runtime workers with `CODEWHALE_MAIN_STACK_BYTES`).
/// Config load under a profile and the thread-lifecycle path marginally
/// overflowed 2 MiB in debug builds (`has overflowed its stack`, SIGABRT for
/// the whole lib suite), which CI masked with `RUST_MIN_STACK`. Running setup
/// *and* serving on one product-sized thread removes the class: the libtest
/// thread keeps only the test body and its HTTP client, and no product frame
/// depth can overflow it.
///
/// Setup results come back through `setup_tx`; the caller wraps the shutdown
/// sender in the `JoinHandle` the call sites expect. Nothing here runs on the
/// test's runtime, so the server thread must outlive setup: it serves until
/// the shutdown sender is dropped.
///
/// `env_ticket` adopts the thread into the calling test's sealed env scope
/// (`test_env_lock::join_env_scope`), so its `Config::load` env reads see the
/// test's environment instead of blocking on the mutex the test holds while
/// awaiting setup. `None` when the caller sealed nothing.
fn spawn_product_stack_server(
root: PathBuf,
sessions_dir: PathBuf,
runtime_token: Option<String>,
mobile_enabled: bool,
workspace: PathBuf,
overrides: TestServerOverrides,
env_ticket: Option<crate::test_support::EnvScopeTicket>,
setup_tx: oneshot::Sender<Result<Option<TestServerSetup>>>,
) {
std::thread::Builder::new()
.name("runtime-api-test-server".to_string())
.stack_size(crate::CODEWHALE_MAIN_STACK_BYTES)
.spawn(move || {
// Adopted for the thread's lifetime; the scope's generation check
// refuses enrollment once the sealing test has ended.
let _membership = crate::test_support::join_env_scope(env_ticket);
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime-api test server runtime");
runtime.block_on(async move {
match build_test_server(
root,
sessions_dir,
runtime_token,
mobile_enabled,
workspace,
overrides,
)
.await
{
Ok(Some((listener, app, addr, runtime_threads))) => {
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
if setup_tx
.send(Ok(Some((addr, runtime_threads, shutdown_tx))))
.is_err()
{
// The test gave up waiting; do not serve.
return;
}
listener
.set_nonblocking(true)
.expect("nonblocking test listener");
let listener =
TcpListener::from_std(listener).expect("register test listener");
tokio::select! {
_ = async {
let _ = axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await;
} => {}
_ = shutdown_rx => {}
}
}
Ok(None) => {
let _ = setup_tx.send(Ok(None));
}
Err(err) => {
let _ = setup_tx.send(Err(err));
}
}
});
})
.expect("spawn runtime-api test server thread");
}
/// The whole harness body — config load, managers, router build — formerly
/// inline in `spawn_test_server_with_root_token_mobile_workspace_and_overrides`.
/// Runs on the product-stack server thread (see `spawn_product_stack_server`).
async fn build_test_server(
root: PathBuf,
sessions_dir: PathBuf,
runtime_token: Option<String>,
mobile_enabled: bool,
workspace: PathBuf,
overrides: TestServerOverrides,
) -> Result<
Option<(
std::net::TcpListener,
axum::Router,
SocketAddr,
SharedRuntimeThreadManager,
)>,
> {
let _ = rustls::crypto::ring::default_provider().install_default();
fs::create_dir_all(&sessions_dir)?;
@@ -995,8 +1148,6 @@ async fn spawn_test_server_with_root_token_mobile_workspace_and_overrides(
..Config::default()
}
};
config.mcp_config_path = Some(root.join("mcp.json").to_string_lossy().to_string());
config.mcp_config_path = Some(root.join("mcp.json").to_string_lossy().to_string());
let manager = TaskManager::start_with_executor(
TaskManagerConfig {
@@ -1027,7 +1178,9 @@ async fn spawn_test_server_with_root_token_mobile_workspace_and_overrides(
let sub_agent_manager = overrides
.sub_agent_manager
.unwrap_or_else(|| runtime_api_sub_agent_manager(&workspace, 2));
let listener = match TcpListener::bind("127.0.0.1:0").await {
// A std listener: the server thread registers it with its own runtime
// after setup is reported (see `spawn_product_stack_server`).
let listener = match std::net::TcpListener::bind("127.0.0.1:0") {
Ok(listener) => listener,
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return Ok(None),
Err(err) => return Err(err.into()),
@@ -1073,14 +1226,7 @@ async fn spawn_test_server_with_root_token_mobile_workspace_and_overrides(
compat_stream_test_hook: overrides.compat_stream_test_hook,
};
let app = build_router(state);
let handle = tokio::spawn(async move {
let _ = axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await;
});
Ok(Some((addr, runtime_threads, handle)))
Ok(Some((listener, app, addr, runtime_threads)))
}
async fn spawn_test_server() -> Result<
@@ -2604,6 +2750,12 @@ async fn compatibility_stream_closes_losslessly_across_replay_live_handoff() ->
output_tokens: 1,
..Usage::default()
},
parent_route_usage: Usage {
input_tokens: 3,
output_tokens: 1,
..Usage::default()
},
routed_usage_dropped_records: 0,
status: TurnOutcomeStatus::Completed,
error: None,
tool_catalog: None,
@@ -2852,6 +3004,8 @@ async fn compatibility_stream_exposes_and_resolves_user_input_without_answer_ech
.tx_event
.send(EngineEvent::TurnComplete {
usage: Usage::default(),
parent_route_usage: Usage::default(),
routed_usage_dropped_records: 0,
status: TurnOutcomeStatus::Completed,
error: None,
tool_catalog: None,
@@ -3215,6 +3369,12 @@ async fn thread_endpoints_expose_lifecycle_contract() -> Result<()> {
output_tokens: 5,
..Usage::default()
},
parent_route_usage: Usage {
input_tokens: 10,
output_tokens: 5,
..Usage::default()
},
routed_usage_dropped_records: 0,
status: TurnOutcomeStatus::Completed,
error: None,
tool_catalog: None,
@@ -3230,6 +3390,8 @@ async fn thread_endpoints_expose_lifecycle_contract() -> Result<()> {
output_tokens: 0,
..Usage::default()
},
parent_route_usage: Usage::default(),
routed_usage_dropped_records: 0,
status: TurnOutcomeStatus::Completed,
error: None,
tool_catalog: None,
@@ -3367,6 +3529,8 @@ async fn turn_endpoint_operation_key_returns_original_and_conflicts_on_mismatch(
let _ = tx_event
.send(EngineEvent::TurnComplete {
usage: Usage::default(),
parent_route_usage: Usage::default(),
routed_usage_dropped_records: 0,
status: TurnOutcomeStatus::Completed,
error: None,
tool_catalog: None,
@@ -3475,6 +3639,12 @@ async fn events_endpoint_respects_since_seq_cursor() -> Result<()> {
output_tokens: 3,
..Usage::default()
},
parent_route_usage: Usage {
input_tokens: 5,
output_tokens: 3,
..Usage::default()
},
routed_usage_dropped_records: 0,
status: TurnOutcomeStatus::Completed,
error: None,
tool_catalog: None,
@@ -3694,6 +3864,12 @@ async fn steer_and_interrupt_endpoints_work_on_active_turn() -> Result<()> {
output_tokens: 1,
..Usage::default()
},
parent_route_usage: Usage {
input_tokens: 2,
output_tokens: 1,
..Usage::default()
},
routed_usage_dropped_records: 0,
status: TurnOutcomeStatus::Completed,
error: None,
tool_catalog: None,
@@ -4051,6 +4227,12 @@ async fn stream_endpoint_remains_backward_compatible() -> Result<()> {
output_tokens: 2,
..Usage::default()
},
parent_route_usage: Usage {
input_tokens: 4,
output_tokens: 2,
..Usage::default()
},
routed_usage_dropped_records: 0,
status: TurnOutcomeStatus::Completed,
error: None,
tool_catalog: None,
@@ -4740,6 +4922,12 @@ async fn session_create_from_thread_rejects_active_turn() -> Result<()> {
output_tokens: 1,
..Usage::default()
},
parent_route_usage: Usage {
input_tokens: 2,
output_tokens: 1,
..Usage::default()
},
routed_usage_dropped_records: 0,
status: TurnOutcomeStatus::Completed,
error: None,
tool_catalog: None,
@@ -4883,7 +5071,10 @@ async fn session_summary_route_projects_rows_and_honours_archive_filters() -> Re
let client = crate::tls::reqwest_client();
let active: Vec<serde_json::Value> = client
.get(format!("http://{addr}/v1/sessions/summary"))
.get(format!(
"http://127.0.0.1:{}/v1/sessions/summary",
addr.port()
))
.send()
.await?
.error_for_status()?
@@ -4913,7 +5104,8 @@ async fn session_summary_route_projects_rows_and_honours_archive_filters() -> Re
let archived: Vec<serde_json::Value> = client
.get(format!(
"http://{addr}/v1/sessions/summary?archived_only=true"
"http://127.0.0.1:{}/v1/sessions/summary?archived_only=true",
addr.port()
))
.send()
.await?
@@ -4944,7 +5136,10 @@ async fn session_patch_route_renames_archives_and_reports_real_changes() -> Resu
let client = crate::tls::reqwest_client();
let patched: serde_json::Value = client
.patch(format!("http://{addr}/v1/sessions/sess-patch"))
.patch(format!(
"http://127.0.0.1:{}/v1/sessions/sess-patch",
addr.port()
))
.json(&json!({ "title": "After", "archived": true }))
.send()
.await?
@@ -4964,7 +5159,10 @@ async fn session_patch_route_renames_archives_and_reports_real_changes() -> Resu
// A re-patch to the same state changes nothing, and says so.
let repeat: serde_json::Value = client
.patch(format!("http://{addr}/v1/sessions/sess-patch"))
.patch(format!(
"http://127.0.0.1:{}/v1/sessions/sess-patch",
addr.port()
))
.json(&json!({ "archived": true }))
.send()
.await?
@@ -4981,7 +5179,10 @@ async fn session_patch_route_renames_archives_and_reports_real_changes() -> Resu
// An empty body is a client error, not a silent no-op.
let empty = client
.patch(format!("http://{addr}/v1/sessions/sess-patch"))
.patch(format!(
"http://127.0.0.1:{}/v1/sessions/sess-patch",
addr.port()
))
.json(&json!({}))
.send()
.await?;
@@ -4989,7 +5190,10 @@ async fn session_patch_route_renames_archives_and_reports_real_changes() -> Resu
// A blank title is rejected with the reason, not accepted.
let blank = client
.patch(format!("http://{addr}/v1/sessions/sess-patch"))
.patch(format!(
"http://127.0.0.1:{}/v1/sessions/sess-patch",
addr.port()
))
.json(&json!({ "title": " " }))
.send()
.await?;
@@ -5016,7 +5220,10 @@ async fn session_patch_route_refuses_a_live_session_with_a_conflict() -> Result<
crate::session_manager::set_live_session(Some("sess-live"));
let conflict = client
.patch(format!("http://{addr}/v1/sessions/sess-live"))
.patch(format!(
"http://127.0.0.1:{}/v1/sessions/sess-live",
addr.port()
))
.json(&json!({ "title": "Renamed from the dashboard" }))
.send()
.await?;
@@ -5024,7 +5231,10 @@ async fn session_patch_route_refuses_a_live_session_with_a_conflict() -> Result<
crate::session_manager::set_live_session(None);
let allowed = client
.patch(format!("http://{addr}/v1/sessions/sess-live"))
.patch(format!(
"http://127.0.0.1:{}/v1/sessions/sess-live",
addr.port()
))
.json(&json!({ "title": "Renamed from the dashboard" }))
.send()
.await?;
@@ -5047,7 +5257,8 @@ async fn session_detail_route_serves_a_bounded_redacted_peek_on_request() -> Res
let peek: serde_json::Value = client
.get(format!(
"http://{addr}/v1/sessions/sess-peek?peek=true&entries=12"
"http://127.0.0.1:{}/v1/sessions/sess-peek?peek=true&entries=12",
addr.port()
))
.send()
.await?
@@ -5069,7 +5280,10 @@ async fn session_detail_route_serves_a_bounded_redacted_peek_on_request() -> Res
}
let detail: serde_json::Value = client
.get(format!("http://{addr}/v1/sessions/sess-peek"))
.get(format!(
"http://127.0.0.1:{}/v1/sessions/sess-peek",
addr.port()
))
.send()
.await?
.error_for_status()?
@@ -5553,12 +5767,15 @@ async fn session_save_merges_thread_cost_split_and_records_coverage() -> Result<
output_tokens: 1_000,
..Usage::default()
}),
routing_settlement: false,
effective_route_usage: None,
permission_posture: None,
effective_provider: None,
effective_provider_id: None,
effective_openrouter_vendor: None,
effective_billing_surface: None,
effective_endpoint_fingerprint: None,
effective_provider_live_pricing: None,
effective_billing_mode: None,
effective_dispatched_at: None,
effective_model: None,
@@ -5570,6 +5787,7 @@ async fn session_save_merges_thread_cost_split_and_records_coverage() -> Result<
model: "deepseek-v4-flash".to_string(),
billing_surface: Some(crate::pricing::FIRST_PARTY_PAYG_BILLING_SURFACE.to_string()),
endpoint_fingerprint: None,
provider_live_pricing: None,
billing_mode: crate::cost_status::RouteBillingMode::Metered,
dispatched_at: now,
},
@@ -5579,6 +5797,7 @@ async fn session_save_merges_thread_cost_split_and_records_coverage() -> Result<
..Usage::default()
},
}],
routed_usage_drop_records: Vec::new(),
routed_usage_source_ids: Vec::new(),
routed_usage_dropped_records: 0,
error: None,
@@ -5740,12 +5959,15 @@ async fn session_save_persists_parent_cny_unpriced_reasons_without_double_count(
output_tokens: 1_000,
..Usage::default()
}),
routing_settlement: false,
effective_route_usage: None,
permission_posture: None,
effective_provider: None,
effective_provider_id: None,
effective_openrouter_vendor: None,
effective_billing_surface: None,
effective_endpoint_fingerprint: None,
effective_provider_live_pricing: None,
effective_billing_mode: None,
effective_dispatched_at: None,
effective_model: None,
@@ -5757,6 +5979,7 @@ async fn session_save_persists_parent_cny_unpriced_reasons_without_double_count(
model: "deepseek-v4-flash".to_string(),
billing_surface: Some(crate::pricing::FIRST_PARTY_PAYG_BILLING_SURFACE.to_string()),
endpoint_fingerprint: None,
provider_live_pricing: None,
billing_mode: crate::cost_status::RouteBillingMode::Metered,
dispatched_at: now,
},
@@ -5766,6 +5989,7 @@ async fn session_save_persists_parent_cny_unpriced_reasons_without_double_count(
..Usage::default()
},
}],
routed_usage_drop_records: Vec::new(),
routed_usage_source_ids: Vec::new(),
routed_usage_dropped_records: 0,
error: None,
@@ -6741,16 +6965,20 @@ fn seed_summary_search_transcript(
ended_at: Some(created_at),
duration_ms: Some(0),
usage: None,
routing_settlement: false,
effective_route_usage: None,
permission_posture: None,
effective_provider: None,
effective_provider_id: None,
effective_openrouter_vendor: None,
effective_billing_surface: None,
effective_endpoint_fingerprint: None,
effective_provider_live_pricing: None,
effective_billing_mode: None,
effective_dispatched_at: None,
effective_model: None,
routed_usage: Vec::new(),
routed_usage_drop_records: Vec::new(),
routed_usage_source_ids: Vec::new(),
routed_usage_dropped_records: 0,
error: None,
@@ -7617,6 +7845,209 @@ async fn get_provider_models(
.expect("GET /v1/providers/{id}/models should return valid JSON")
}
#[test]
fn provider_model_catalog_paginates_all_six_hundred_rows_without_truncation() {
let models = (0..600)
.rev()
.map(|index| ProviderModelEntry {
id: format!("openrouter/model-{index:03}"),
image_input: codewhale_config::route::CapabilityState::Unknown,
})
.collect::<Vec<_>>();
let mut cursor = None;
let mut observed = Vec::new();
let mut page_count = 0usize;
loop {
let response = paginate_provider_models(
"openrouter",
models.clone(),
&ListProviderModelsParams {
filter: None,
cursor,
limit: Some(MAX_PROVIDER_MODELS_PAGE_SIZE),
},
)
.expect("page should be valid");
page_count += 1;
assert_eq!(response.provider, "openrouter");
assert_eq!(response.total, 600);
assert!(!response.models.is_empty());
assert!(response.models.len() <= MAX_PROVIDER_MODELS_PAGE_SIZE);
observed.extend(response.models.into_iter().map(|entry| entry.id));
cursor = response.next_cursor;
if cursor.is_none() {
break;
}
}
assert_eq!(page_count, 3);
assert_eq!(observed.len(), 600);
assert_eq!(
observed.first().map(String::as_str),
Some("openrouter/model-000")
);
assert_eq!(
observed.last().map(String::as_str),
Some("openrouter/model-599")
);
let unique = observed.iter().collect::<std::collections::BTreeSet<_>>();
assert_eq!(unique.len(), 600);
}
#[test]
fn provider_model_catalog_applies_filter_before_cursor_and_rejects_cross_scope_replay() {
let models = ["Alpha-One", "alpha-two", "beta"]
.into_iter()
.map(|id| ProviderModelEntry {
id: id.to_string(),
image_input: codewhale_config::route::CapabilityState::Unknown,
})
.collect::<Vec<_>>();
let first = paginate_provider_models(
"openrouter",
models.clone(),
&ListProviderModelsParams {
filter: Some(" ALPHA ".to_string()),
cursor: None,
limit: Some(1),
},
)
.expect("filtered first page should be valid");
assert_eq!(first.total, 2);
assert_eq!(first.models[0].id, "Alpha-One");
let cursor = first.next_cursor.expect("a second filtered row remains");
let second = paginate_provider_models(
"openrouter",
models.clone(),
&ListProviderModelsParams {
filter: Some("alpha".to_string()),
cursor: Some(cursor.clone()),
limit: Some(1),
},
)
.expect("matching filter should continue");
assert_eq!(second.models[0].id, "alpha-two");
assert!(second.next_cursor.is_none());
let replay = paginate_provider_models(
"baseten",
models,
&ListProviderModelsParams {
filter: Some("alpha".to_string()),
cursor: Some(cursor),
limit: Some(1),
},
);
assert!(replay.is_err(), "a cursor cannot cross provider ownership");
}
#[test]
fn provider_model_cursor_rejects_catalog_change_between_pages() {
let models = ["bravo", "charlie"]
.into_iter()
.map(|id| ProviderModelEntry {
id: id.to_string(),
image_input: codewhale_config::route::CapabilityState::Unknown,
})
.collect::<Vec<_>>();
let first = paginate_provider_models(
"openrouter",
models.clone(),
&ListProviderModelsParams {
filter: None,
cursor: None,
limit: Some(1),
},
)
.expect("first page");
let cursor = first.next_cursor.expect("second page remains");
let mut changed = models.clone();
changed.push(ProviderModelEntry {
id: "alpha".to_string(),
image_input: codewhale_config::route::CapabilityState::Unknown,
});
assert!(
paginate_provider_models(
"openrouter",
changed,
&ListProviderModelsParams {
filter: None,
cursor: Some(cursor.clone()),
limit: Some(1),
}
)
.is_err(),
"an insertion before the cursor must force a restart, not disappear"
);
let mut changed = models;
changed[1].image_input = codewhale_config::route::CapabilityState::Supported;
assert!(
paginate_provider_models(
"openrouter",
changed,
&ListProviderModelsParams {
filter: None,
cursor: Some(cursor),
limit: Some(1),
}
)
.is_err(),
"capability changes also invalidate a catalog snapshot"
);
}
#[test]
fn provider_model_cursor_round_trips_multibyte_filter_at_the_allowed_limit() {
let filter = "😀".repeat(MAX_PROVIDER_MODELS_FILTER_CHARS);
let models = ["a", "b"]
.into_iter()
.map(|suffix| ProviderModelEntry {
id: format!("{filter}{suffix}"),
image_input: codewhale_config::route::CapabilityState::Unknown,
})
.collect::<Vec<_>>();
let first = paginate_provider_models(
"openrouter",
models.clone(),
&ListProviderModelsParams {
filter: Some(filter.clone()),
cursor: None,
limit: Some(1),
},
)
.expect("first page");
let second = paginate_provider_models(
"openrouter",
models,
&ListProviderModelsParams {
filter: Some(filter),
cursor: first.next_cursor,
limit: Some(1),
},
)
.expect("all emitted cursors must be accepted, including multibyte filters");
assert_eq!(second.models.len(), 1);
assert!(second.next_cursor.is_none());
}
#[test]
fn runtime_chat_relay_projection_keeps_six_hundred_safe_models_without_silent_cutoff() {
let models = (0..600)
.map(|index| format!("openrouter/model-{index:03}"))
.collect::<Vec<_>>();
let projected = runtime_chat_safe_models(models.clone()).expect("600 rows are safely bounded");
assert_eq!(projected, models);
let oversized = (0..=MAX_PROVIDER_MODELS_CATALOG_SIZE)
.map(|index| format!("provider/model-{index:05}"))
.collect::<Vec<_>>();
let error = runtime_chat_safe_models(oversized).expect_err("oversized relay must fail loudly");
assert!(error.contains("safe"));
assert!(error.contains(&MAX_PROVIDER_MODELS_CATALOG_SIZE.to_string()));
}
#[tokio::test]
async fn provider_catalog_and_switch_preserve_each_listed_route_identity() -> Result<()> {
let _lock = lock_test_env();
@@ -7920,7 +8351,7 @@ fn provider_catalog_keeps_official_deepseek_facts_but_not_custom_proxy_claims()
provider_config.model = Some("deepseek-v4-pro".to_string());
assert!(
!provider_uses_custom_route_for_api(&config, ApiProvider::Deepseek),
!config.provider_uses_custom_endpoint(ApiProvider::Deepseek),
"official DeepSeek endpoint must retain the shared model catalog: {official_base_url}"
);
let models = provider_models_for_api(&config, ApiProvider::Deepseek, ApiProvider::Deepseek);
@@ -7941,10 +8372,7 @@ fn provider_catalog_keeps_official_deepseek_facts_but_not_custom_proxy_claims()
provider_config.base_url = Some("https://deepseek-proxy.example.test/v1".to_string());
provider_config.model = Some("private-deepseek-deployment".to_string());
assert!(provider_uses_custom_route_for_api(
&custom,
ApiProvider::Deepseek
));
assert!(custom.provider_uses_custom_endpoint(ApiProvider::Deepseek));
assert_eq!(
provider_models_for_api(&custom, ApiProvider::Deepseek, ApiProvider::Deepseek),
vec!["private-deepseek-deployment".to_string()],
@@ -11465,3 +11893,179 @@ vendor = "{vendor}"
handle.abort();
Ok(())
}
#[test]
fn api_provider_default_and_model_list_follow_exact_local_catalog() {
use codewhale_config::catalog::{
CatalogOffering, CatalogRefreshError, CatalogSource, ProviderCatalogDelta,
base_url_fingerprint, now_unix,
};
let _env = crate::test_support::lock_test_env();
let _live = crate::provider_lake::lock_live_snapshot();
let home = tempfile::tempdir().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
crate::provider_catalog_live::reset_cache_for_test();
crate::provider_lake::clear_live_snapshot();
let provider = ApiProvider::Ollama;
let endpoint = "http://localhost:11455/v1";
let mut config = Config {
provider: Some("ollama".into()),
..Default::default()
};
config.provider_config_for_mut(provider).base_url = Some(endpoint.into());
assert_eq!(
provider_default_model_for_api(&config, provider, provider),
""
);
assert!(!provider_models_for_api(&config, provider, provider).contains(&"unknown".to_string()));
assert!(runtime_request_model(&config, None).is_err());
assert_eq!(
runtime_request_model(&config, Some("auto")).unwrap(),
"auto"
);
assert_eq!(
runtime_request_model(&config, Some("chosen:tag")).unwrap(),
"chosen:tag"
);
let fingerprint = base_url_fingerprint(endpoint);
let fetched_at = now_unix();
let ticket =
crate::provider_catalog_live::begin_refresh_for_identity(provider, "ollama", endpoint);
crate::provider_catalog_live::record_success_if_current(
&ticket,
ProviderCatalogDelta {
provider: "ollama".into(),
base_url_fingerprint: fingerprint.clone(),
fetched_at,
offerings: vec![CatalogOffering {
provider: "ollama".into(),
wire_model_id: "local-default:tag".into(),
endpoint_key: "chat".into(),
source: CatalogSource::Live {
base_url_fingerprint: fingerprint.clone(),
fetched_at,
},
..Default::default()
}],
},
);
assert_eq!(
provider_default_model_for_api(&config, provider, provider),
"local-default:tag"
);
assert_eq!(
provider_models_for_api(&config, provider, provider),
vec!["local-default:tag"]
);
assert_eq!(
runtime_request_model(&config, None).unwrap(),
"local-default:tag"
);
let mut other = config.clone();
other.provider_config_for_mut(provider).base_url = Some("http://localhost:11456/v1".into());
assert_eq!(
provider_default_model_for_api(&other, provider, provider),
""
);
assert!(provider_models_for_api(&other, provider, provider).is_empty());
crate::provider_catalog_live::record_failure_if_current(
&ticket,
"ollama",
&fingerprint,
CatalogRefreshError::Network,
);
assert_eq!(
provider_default_model_for_api(&config, provider, provider),
""
);
assert!(runtime_request_model(&config, None).is_err());
config.set_provider_model_override(provider, Some("chosen:tag".into()));
assert_eq!(
provider_default_model_for_api(&config, provider, provider),
"chosen:tag"
);
crate::provider_catalog_live::reset_cache_for_test();
crate::provider_lake::clear_live_snapshot();
}
#[tokio::test(flavor = "current_thread")]
async fn api_config_reports_local_default_availability_without_blocking_config_reads() -> Result<()>
{
use codewhale_config::catalog::{
CatalogOffering, CatalogSource, ProviderCatalogDelta, base_url_fingerprint, now_unix,
};
let _env = lock_test_env();
let _live = crate::provider_lake::lock_live_snapshot();
let home = tempfile::tempdir()?;
let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
crate::provider_catalog_live::reset_cache_for_test();
crate::provider_lake::clear_live_snapshot();
let config_file = home.path().join("config.toml");
let endpoint = "http://localhost:11457/v1";
fs::write(
&config_file,
format!("provider = \"ollama\"\n[providers.ollama]\nbase_url = \"{endpoint}\"\n"),
)?;
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_config_path(config_file).await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
let unavailable = get_config(&client, &addr).await;
assert_eq!(unavailable["model"], "");
assert_eq!(unavailable["model_available"], false);
assert_eq!(
unavailable["default_model"], DEFAULT_TEXT_MODEL,
"legacy root field stays unchanged"
);
for path in ["tasks", "stream"] {
let response = client
.post(format!("http://{addr}/v1/{path}"))
.json(&json!({"prompt": "missing local model"}))
.send()
.await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert!(
response
.text()
.await?
.contains("no available default model")
);
}
let fetched_at = now_unix();
let fingerprint = base_url_fingerprint(endpoint);
let ticket = crate::provider_catalog_live::begin_refresh_for_identity(
ApiProvider::Ollama,
"ollama",
endpoint,
);
crate::provider_catalog_live::record_success_if_current(
&ticket,
ProviderCatalogDelta {
provider: "ollama".into(),
base_url_fingerprint: fingerprint.clone(),
fetched_at,
offerings: vec![CatalogOffering {
provider: "ollama".into(),
wire_model_id: "available:tag".into(),
endpoint_key: "chat".into(),
source: CatalogSource::Live {
base_url_fingerprint: fingerprint,
fetched_at,
},
..Default::default()
}],
},
);
let available = get_config(&client, &addr).await;
assert_eq!(available["model"], "available:tag");
assert_eq!(available["model_available"], true);
assert_eq!(available["default_model"], DEFAULT_TEXT_MODEL);
handle.abort();
crate::provider_catalog_live::reset_cache_for_test();
crate::provider_lake::clear_live_snapshot();
Ok(())
}
+586 -66
View File
@@ -37,10 +37,12 @@ use crate::core::engine::{
use crate::core::events::{Event as EngineEvent, TurnOutcomeStatus};
use crate::core::ops::Op;
use crate::cost_status::{
EffectiveRouteEnvelope, EffectiveRouteUsage, RouteBillingMode, RuntimeUsageRecord,
EffectiveRouteEnvelope, EffectiveRouteUsage, RouteBillingMode, RuntimeUsageDropRecord,
RuntimeUsageRecord,
};
use crate::models::Role;
use crate::models::{ContentBlock, Message, SystemPrompt, Usage};
use crate::provider_catalog_live::ProviderLivePricingQuote;
use crate::route_budget::{
auto_compact_default_for_route, compaction_threshold_for_route_at_percent, known_route_limits,
route_context_window_tokens,
@@ -532,12 +534,32 @@ where
{
values
.iter()
.map(|value| {
if value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
value.to_ascii_lowercase()
} else {
codewhale_config::catalog::base_url_fingerprint(value)
}
.map(|value| routed_usage_source_fingerprint(value))
.collect::<Vec<_>>()
.serialize(serializer)
}
fn routed_usage_source_fingerprint(source_id: &str) -> String {
let source_id = source_id.trim();
if source_id.len() == 64 && source_id.bytes().all(|byte| byte.is_ascii_hexdigit()) {
source_id.to_ascii_lowercase()
} else {
crate::cost_status::usage_source_fingerprint(source_id)
}
}
fn serialize_routed_usage_drop_records<S>(
values: &[RuntimeUsageDropRecord],
serializer: S,
) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
values
.iter()
.map(|record| RuntimeUsageDropRecord {
source_id: routed_usage_source_fingerprint(&record.source_id),
route: record.route.sanitized_for_persistence(),
})
.collect::<Vec<_>>()
.serialize(serializer)
@@ -732,6 +754,16 @@ pub struct TurnRecord {
pub duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
/// Accounting-only receipt for auxiliary work completed before parent
/// admission. It stays visible in history and cost totals, but never
/// advances the thread's latest accepted turn, including after restart.
#[serde(default)]
pub routing_settlement: bool,
/// Portion of `usage` served by this turn's persisted effective route.
/// New records use this for parent cost/token accumulation, then add each
/// routed child independently. Legacy absence falls back to `usage`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effective_route_usage: Option<Usage>,
/// Canonical posture that governed this turn. New records always carry
/// this receipt; old records deserialize with no fabricated value.
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -773,6 +805,15 @@ pub struct TurnRecord {
serialize_with = "serialize_endpoint_fingerprint_option"
)]
pub effective_endpoint_fingerprint: Option<String>,
/// Immutable provider-live rate receipt frozen at CodeWhale's pre-permit
/// application-dispatch boundary. Missing on legacy records; Baseten then
/// stays unpriced while OpenRouter may use only its immutable bundled row.
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::provider_catalog_live::deserialize_optional_provider_live_pricing"
)]
pub effective_provider_live_pricing: Option<ProviderLivePricingQuote>,
/// Immutable billing classification captured before dispatch.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effective_billing_mode: Option<RouteBillingMode>,
@@ -788,10 +829,20 @@ pub struct TurnRecord {
)]
pub effective_model: Option<String>,
/// Model calls made beneath this parent turn, each paired with its own
/// immutable route. These are exclusive of `usage`, which is only the
/// parent engine turn.
/// immutable route. These are exclusive of `effective_route_usage`;
/// `usage` remains the authoritative all-token turn total and may include
/// inline RLM/guardian calls.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub routed_usage: Vec<EffectiveRouteUsage>,
/// Exact missing-usage provider responses paired with their frozen route.
/// Sources are persisted only as fingerprints, routes are sanitized, and
/// the shared source ledger bounds usage plus missing-usage records.
#[serde(
default,
skip_serializing_if = "Vec::is_empty",
serialize_with = "serialize_routed_usage_drop_records"
)]
pub routed_usage_drop_records: Vec<RuntimeUsageDropRecord>,
/// Fingerprints of provider-call identities already appended to this turn.
/// This durable ledger makes mailbox delivery, direct sinks, fallback
/// recovery, and process restart idempotent without persisting raw ids.
@@ -837,6 +888,7 @@ impl TurnRecord {
self.effective_openrouter_vendor = route.openrouter_vendor;
self.effective_billing_surface = route.billing_surface;
self.effective_endpoint_fingerprint = route.endpoint_fingerprint;
self.effective_provider_live_pricing = route.provider_live_pricing;
self.effective_billing_mode = Some(route.billing_mode);
self.effective_dispatched_at = Some(route.dispatched_at);
self.effective_model = Some(route.model);
@@ -868,6 +920,7 @@ impl TurnRecord {
openrouter_vendor: self.effective_openrouter_vendor.clone(),
billing_surface: self.effective_billing_surface.clone(),
endpoint_fingerprint: self.effective_endpoint_fingerprint.clone(),
provider_live_pricing: self.effective_provider_live_pricing.clone(),
billing_mode: self
.effective_billing_mode
.unwrap_or(RouteBillingMode::Unknown),
@@ -887,7 +940,16 @@ fn append_routed_usage_record(
source_id: &str,
usage: EffectiveRouteUsage,
) -> bool {
let source_fingerprint = crate::cost_status::usage_source_fingerprint(source_id);
if usage.usage == Usage::default() {
return append_routed_usage_drop_record(
turn,
RuntimeUsageDropRecord {
source_id: source_id.to_string(),
route: usage.route,
},
);
}
let source_fingerprint = routed_usage_source_fingerprint(source_id);
if turn
.routed_usage_source_ids
.iter()
@@ -895,11 +957,18 @@ fn append_routed_usage_record(
{
return false;
}
turn.routed_usage_source_ids.push(source_fingerprint);
if turn.routed_usage.len() == MAX_ROUTED_USAGE_RECORDS_PER_TURN {
turn.routed_usage.remove(0);
turn.routed_usage_dropped_records = turn.routed_usage_dropped_records.saturating_add(1);
if turn.routed_usage_source_ids.len() >= MAX_ROUTED_USAGE_RECORDS_PER_TURN {
// Stop admitting new records once both the record and exact-dedupe
// ledgers reach their shared bound. Retaining the first accepted set
// makes replay idempotent; one explicit incompleteness marker is safer
// than evicting fingerprints and later counting a replay twice.
if turn.routed_usage_dropped_records == 0 {
turn.routed_usage_dropped_records = 1;
return true;
}
return false;
}
turn.routed_usage_source_ids.push(source_fingerprint);
turn.routed_usage.push(EffectiveRouteUsage {
route: usage.route.sanitized_for_persistence(),
usage: usage.usage,
@@ -907,6 +976,230 @@ fn append_routed_usage_record(
true
}
fn append_routed_usage_drop_record(turn: &mut TurnRecord, record: RuntimeUsageDropRecord) -> bool {
let source_fingerprint = routed_usage_source_fingerprint(&record.source_id);
if turn
.routed_usage_source_ids
.iter()
.any(|persisted| persisted == &source_fingerprint)
{
return false;
}
if turn.routed_usage_source_ids.len() >= MAX_ROUTED_USAGE_RECORDS_PER_TURN {
if turn.routed_usage_dropped_records == 0 {
turn.routed_usage_dropped_records = 1;
return true;
}
return false;
}
turn.routed_usage_source_ids
.push(source_fingerprint.clone());
turn.routed_usage_drop_records.push(RuntimeUsageDropRecord {
source_id: source_fingerprint,
route: record.route.sanitized_for_persistence(),
});
true
}
/// Bind pre-parent auxiliary calls to a reserved Runtime turn before the
/// engine accepts the parent operation. Exact records are persisted now so a
/// crash cannot erase them; the engine's terminal event remains the sole
/// owner of `dropped_records`, preventing the same incompleteness count from
/// being added both before and after the turn.
fn append_initial_routed_usage_to_turn(
turn: &mut TurnRecord,
batch: &crate::cost_status::RuntimeUsageBatch,
) {
for record in &batch.records {
append_routed_usage_record(turn, &record.source_id, record.usage.clone());
}
for record in &batch.drop_records {
append_routed_usage_drop_record(turn, record.clone());
}
}
/// Displayed title of a settlement record. Deliberately fixed text: no turn
/// was accepted, so no prompt content belongs in a record that exists only to
/// keep an already-incurred auxiliary provider call.
const UNACCEPTED_TURN_SUMMARY: &str = "Routing failed before the turn started";
const UNACCEPTED_TURN_REASON: &str =
"Turn was not accepted; this record keeps the completed pre-turn provider call";
fn routed_usage_batch_is_empty(batch: &crate::cost_status::RuntimeUsageBatch) -> bool {
batch.records.is_empty() && batch.drop_records.is_empty() && batch.dropped_records == 0
}
/// Durable identity of the settlement record for one completed pre-turn
/// provider call.
///
/// Derived from the batch's own exact response fingerprints, so settling the
/// same completed call again — a raced operation replay, a retried start after
/// a restart, a reload of the same store — lands on the one record instead of
/// minting a second charge. A batch with no identifiable receipt has no
/// identity to be idempotent on, so it takes a fresh id rather than silently
/// collapsing two distinct coverage gaps into one.
fn unaccepted_routed_usage_turn_id(
thread_id: &str,
batch: &crate::cost_status::RuntimeUsageBatch,
) -> String {
let mut identities = batch
.records
.iter()
.map(|record| routed_usage_source_fingerprint(&record.source_id))
.chain(
batch
.drop_records
.iter()
.map(|record| routed_usage_source_fingerprint(&record.source_id)),
)
.collect::<Vec<_>>();
if identities.is_empty() {
return format!("turn_unaccepted_{}", &Uuid::new_v4().to_string()[..8]);
}
identities.sort_unstable();
identities.dedup();
let digest = routed_usage_source_fingerprint(&format!("{thread_id}:{}", identities.join(":")));
format!("turn_unaccepted_{}", &digest[..32])
}
/// Settle a completed pre-turn provider call through the same durable receipt
/// authority an accepted turn uses: a Runtime turn record whose bounded
/// `routed_usage` / `routed_usage_drop_records` / `routed_usage_source_ids`
/// ledger is exact-once by source fingerprint.
///
/// The record is terminal on creation and deliberately carries no parent
/// `usage`, no parent route, and no thread-pointer update: the parent turn was
/// never accepted, so only the auxiliary call's own frozen route may be
/// charged. Because no engine `TurnComplete` will ever arrive for it, this
/// record — unlike an accepted turn — also owns the coverage gap its bounded
/// ledger could not represent. Every persisted field is re-derived from the
/// batch, so replaying the same settlement rewrites the same values.
fn settle_unaccepted_routed_usage(
store: &RuntimeThreadStore,
thread_id: &str,
batch: &crate::cost_status::RuntimeUsageBatch,
) -> Result<String> {
let turn_id = unaccepted_routed_usage_turn_id(thread_id, batch);
let _turn_mutation = store.turn_mutation.lock();
let mut turn = if store.turn_path(&turn_id)?.exists() {
let existing = store.load_turn(&turn_id)?;
if existing.thread_id != thread_id {
bail!("settlement turn {turn_id} already belongs to another thread");
}
existing
} else {
let now = Utc::now();
TurnRecord {
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
id: turn_id.clone(),
thread_id: thread_id.to_string(),
status: RuntimeTurnStatus::Failed,
input_summary: UNACCEPTED_TURN_SUMMARY.to_string(),
created_at: now,
started_at: Some(now),
ended_at: Some(now),
duration_ms: Some(0),
usage: None,
routing_settlement: true,
effective_route_usage: None,
permission_posture: None,
effective_provider: None,
effective_provider_id: None,
effective_openrouter_vendor: None,
effective_billing_surface: None,
effective_endpoint_fingerprint: None,
effective_provider_live_pricing: None,
effective_billing_mode: None,
effective_dispatched_at: None,
effective_model: None,
routed_usage: Vec::new(),
routed_usage_drop_records: Vec::new(),
routed_usage_source_ids: Vec::new(),
routed_usage_dropped_records: 0,
error: Some(UNACCEPTED_TURN_REASON.to_string()),
item_ids: Vec::new(),
steer_count: 0,
agent_mail_message_id: None,
}
};
append_initial_routed_usage_to_turn(&mut turn, batch);
let reported = batch.records.len().saturating_add(batch.drop_records.len());
let retained = turn
.routed_usage
.len()
.saturating_add(turn.routed_usage_drop_records.len());
let unretained = u64::try_from(reported.saturating_sub(retained)).unwrap_or(u64::MAX);
let residual = batch
.dropped_records
.saturating_sub(u64::try_from(batch.drop_records.len()).unwrap_or(u64::MAX));
turn.routed_usage_dropped_records = residual.saturating_add(unretained);
store.save_turn(&turn)?;
Ok(turn_id)
}
struct InitialRoutedUsageSettlementGuard {
/// The Runtime store that owns the thread whose `start_turn` incurred the
/// call. It is the durability authority for runtime accounting, exactly as
/// it is for an accepted turn's routed usage. Ownerless in-process
/// accounting is only the last resort below: a headless Runtime has no
/// foreground session to drain it, and a closed cost scope (`/new`,
/// session load) rejects a stale report outright.
store: RuntimeThreadStore,
thread_id: String,
cost_scope: crate::cost_status::CostScopeToken,
batch: crate::cost_status::RuntimeUsageBatch,
armed: bool,
}
impl InitialRoutedUsageSettlementGuard {
fn new(
store: RuntimeThreadStore,
thread_id: &str,
cost_scope: crate::cost_status::CostScopeToken,
batch: &crate::cost_status::RuntimeUsageBatch,
) -> Self {
Self {
store,
thread_id: thread_id.to_string(),
cost_scope,
batch: batch.clone(),
armed: true,
}
}
fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for InitialRoutedUsageSettlementGuard {
fn drop(&mut self) {
if !self.armed || routed_usage_batch_is_empty(&self.batch) {
return;
}
match settle_unaccepted_routed_usage(&self.store, &self.thread_id, &self.batch) {
Ok(turn_id) => tracing::debug!(
thread_id = %self.thread_id,
turn_id = %turn_id,
"Settled a completed pre-turn provider call into the Runtime store"
),
Err(error) => {
// The durable authority is unreachable. In-process accounting
// keeps the receipt for whatever is still draining this scope
// rather than discarding it, but it does not survive the
// process, so say so once at warn level.
tracing::warn!(
thread_id = %self.thread_id,
error = %error,
"Failed to persist a completed pre-turn provider call; \
falling back to in-process accounting"
);
crate::cost_status::report_runtime_usage_batch(self.cost_scope, None, &self.batch);
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TurnItemRecord {
#[serde(default = "default_runtime_schema_version")]
@@ -3249,6 +3542,86 @@ fn accumulate_truncated_runtime_usage(
.insert("runtime_usage_journal_truncated".to_string());
}
fn accumulate_runtime_child_usage_record(
totals: &mut UsageTotals,
buckets: &mut std::collections::BTreeMap<String, UsageBucket>,
group_by: UsageGroupBy,
child: &EffectiveRouteUsage,
turn: &TurnRecord,
thread: &ThreadRecord,
) {
// Legacy persisted routed records may predate admission normalization.
// Interpret their absent usage without rewriting or duplicating receipts.
if child.usage == Usage::default() {
accumulate_exact_runtime_usage_drop(totals, buckets, group_by, &child.route, turn, thread);
} else {
accumulate_runtime_usage_record(
totals,
buckets,
group_by,
Some(&child.route),
&child.usage,
turn,
thread,
);
}
}
fn accumulate_exact_runtime_usage_drop(
totals: &mut UsageTotals,
buckets: &mut std::collections::BTreeMap<String, UsageBucket>,
group_by: UsageGroupBy,
route: &EffectiveRouteEnvelope,
turn: &TurnRecord,
thread: &ThreadRecord,
) {
let nonmetered = matches!(
route.billing_mode,
RouteBillingMode::Subscription | RouteBillingMode::Local
);
totals.dropped_usage_records = totals.dropped_usage_records.saturating_add(1);
totals.turns = totals.turns.saturating_add(1);
if nonmetered {
totals.nonmetered_turns = totals.nonmetered_turns.saturating_add(1);
} else {
totals.unpriced_turns = totals.unpriced_turns.saturating_add(1);
totals.cny_unpriced_turns = totals.cny_unpriced_turns.saturating_add(1);
totals
.unpriced_reasons
.insert("provider_success_missing_usage".to_string());
totals
.cny_unpriced_reasons
.insert("provider_success_missing_usage".to_string());
}
let audit = route.audit(&Usage::default());
totals
.route_receipts
.insert(format!("{} usage=missing", route.receipt(&audit)));
let key = runtime_usage_bucket_key(group_by, Some(route), turn, thread);
let bucket = buckets.entry(key.clone()).or_insert_with(|| UsageBucket {
key,
..UsageBucket::default()
});
bucket.dropped_usage_records = bucket.dropped_usage_records.saturating_add(1);
bucket.turns = bucket.turns.saturating_add(1);
if nonmetered {
bucket.nonmetered_turns = bucket.nonmetered_turns.saturating_add(1);
} else {
bucket.unpriced_turns = bucket.unpriced_turns.saturating_add(1);
bucket.cny_unpriced_turns = bucket.cny_unpriced_turns.saturating_add(1);
bucket
.unpriced_reasons
.insert("provider_success_missing_usage".to_string());
bucket
.cny_unpriced_reasons
.insert("provider_success_missing_usage".to_string());
}
bucket
.route_receipts
.insert(format!("{} usage=missing", route.receipt(&audit)));
}
fn resolve_runtime_thread_route(
config: &Config,
provider: ApiProvider,
@@ -6021,7 +6394,7 @@ impl RuntimeThreadManager {
let parent_dispatched_at = parent_route
.as_ref()
.map_or(turn.created_at, |route| route.dispatched_at);
if let Some(usage) = turn.usage.as_ref()
if let Some(usage) = turn.effective_route_usage.as_ref().or(turn.usage.as_ref())
&& usage_timestamp_in_range(parent_dispatched_at, since, until)
{
accumulate_runtime_usage_record(
@@ -6036,12 +6409,23 @@ impl RuntimeThreadManager {
}
for child in &turn.routed_usage {
if usage_timestamp_in_range(child.route.dispatched_at, since, until) {
accumulate_runtime_usage_record(
accumulate_runtime_child_usage_record(
&mut totals,
&mut buckets,
group_by,
Some(&child.route),
&child.usage,
child,
&turn,
&thread,
);
}
}
for drop_record in &turn.routed_usage_drop_records {
if usage_timestamp_in_range(drop_record.route.dispatched_at, since, until) {
accumulate_exact_runtime_usage_drop(
&mut totals,
&mut buckets,
group_by,
&drop_record.route,
&turn,
&thread,
);
@@ -6117,7 +6501,7 @@ impl RuntimeThreadManager {
std::collections::BTreeMap::new();
for turn in &turns {
let parent_route = turn.effective_route_envelope();
if let Some(usage) = turn.usage.as_ref() {
if let Some(usage) = turn.effective_route_usage.as_ref().or(turn.usage.as_ref()) {
accumulate_runtime_usage_record(
&mut split.parent,
&mut buckets,
@@ -6129,12 +6513,21 @@ impl RuntimeThreadManager {
);
}
for child in &turn.routed_usage {
accumulate_runtime_usage_record(
accumulate_runtime_child_usage_record(
&mut split.routed_children,
&mut buckets,
UsageGroupBy::Thread,
Some(&child.route),
&child.usage,
child,
turn,
&thread,
);
}
for drop_record in &turn.routed_usage_drop_records {
accumulate_exact_runtime_usage_drop(
&mut split.routed_children,
&mut buckets,
UsageGroupBy::Thread,
&drop_record.route,
turn,
&thread,
);
@@ -6500,7 +6893,9 @@ impl RuntimeThreadManager {
cloned_turn.item_ids.push(cloned_item.id.clone());
cloned_items.push(cloned_item);
}
forked.latest_turn_id = Some(cloned_turn.id.clone());
if !cloned_turn.routing_settlement {
forked.latest_turn_id = Some(cloned_turn.id.clone());
}
forked.updated_at = now;
cloned_records.push((cloned_turn, cloned_items));
}
@@ -6617,7 +7012,9 @@ impl RuntimeThreadManager {
cloned_turn.item_ids.push(cloned_item.id.clone());
cloned_items.push(cloned_item);
}
forked.latest_turn_id = Some(cloned_turn.id.clone());
if !cloned_turn.routing_settlement {
forked.latest_turn_id = Some(cloned_turn.id.clone());
}
forked.updated_at = now;
cloned_records.push((cloned_turn, cloned_items));
}
@@ -6999,16 +7396,20 @@ impl RuntimeThreadManager {
ended_at: Some(turn_at),
duration_ms: Some(0),
usage: None,
routing_settlement: false,
effective_route_usage: None,
permission_posture: None,
effective_provider: None,
effective_provider_id: None,
effective_openrouter_vendor: None,
effective_billing_surface: None,
effective_endpoint_fingerprint: None,
effective_provider_live_pricing: None,
effective_billing_mode: None,
effective_dispatched_at: None,
effective_model: None,
routed_usage: Vec::new(),
routed_usage_drop_records: Vec::new(),
routed_usage_source_ids: Vec::new(),
routed_usage_dropped_records: 0,
error: None,
@@ -7250,9 +7651,15 @@ impl RuntimeThreadManager {
for record in background_usage.records.iter().cloned() {
append_routed_usage_record(&mut turn, &record.source_id, record.usage);
}
for record in background_usage.drop_records.iter().cloned() {
append_routed_usage_drop_record(&mut turn, record);
}
let background_residual = background_usage.dropped_records.saturating_sub(
u64::try_from(background_usage.drop_records.len()).unwrap_or(u64::MAX),
);
turn.routed_usage_dropped_records = turn
.routed_usage_dropped_records
.saturating_add(background_usage.dropped_records);
.saturating_add(background_residual);
if turn.status == RuntimeTurnStatus::InProgress {
turn.status = RuntimeTurnStatus::Failed;
turn.ended_at = Some(now);
@@ -7713,8 +8120,15 @@ impl RuntimeThreadManager {
let mut thread_config = cfg_snapshot.clone();
thread_config.scope_to_provider_identity(&identity);
let verbosity = thread_config.verbosity.clone();
let (route, reasoning_effort, auto_controls_reasoning) = if auto_model {
let selection = crate::model_routing::resolve_auto_route_with_inventory(
let (
route,
reasoning_effort,
auto_controls_reasoning,
initial_routed_usage,
mut initial_routed_usage_settlement,
) = if auto_model {
let classifier_cost_scope = crate::cost_status::scope_token();
let mut selection = crate::model_routing::resolve_auto_route_with_inventory(
&thread_config,
&prompt,
"",
@@ -7722,6 +8136,27 @@ impl RuntimeThreadManager {
"auto",
)
.await?;
// The classifier call has already completed. Move its immutable
// receipts out before resolving the selected parent route so a
// malformed/stale parent catalog entry cannot erase real
// auxiliary spend on the error path.
let initial_routed_usage = crate::cost_status::RuntimeUsageBatch {
records: std::mem::take(&mut selection.routed_usage),
drop_records: std::mem::take(&mut selection.routed_usage_drop_records),
dropped_records: std::mem::take(
&mut selection.routed_usage_dropped_records,
),
};
// Until the new turn is durably persisted and handed to the
// engine, every early return must settle this completed
// classifier call against its captured origin. The bounded clone
// is intentionally kept out of the original/replayed turn.
let settlement = InitialRoutedUsageSettlementGuard::new(
self.store.clone(),
thread_id,
classifier_cost_scope,
&initial_routed_usage,
);
let route = resolve_runtime_thread_route(
&thread_config,
selection.provider,
@@ -7742,7 +8177,13 @@ impl RuntimeThreadManager {
.as_setting()
.to_string()
});
(route, reasoning_effort, auto_controls_reasoning)
(
route,
reasoning_effort,
auto_controls_reasoning,
initial_routed_usage,
Some(settlement),
)
} else {
let route = resolve_runtime_thread_route_for_identity(
&cfg_snapshot,
@@ -7770,7 +8211,13 @@ impl RuntimeThreadManager {
.as_setting()
.to_string()
});
(route, reasoning_effort, auto_controls_reasoning)
(
route,
reasoning_effort,
auto_controls_reasoning,
crate::cost_status::RuntimeUsageBatch::default(),
None,
)
};
let route = if client_preflight_required {
route
@@ -7815,6 +8262,8 @@ impl RuntimeThreadManager {
ended_at: None,
duration_ms: None,
usage: None,
routing_settlement: false,
effective_route_usage: None,
permission_posture: Some(policy.permission_wire().to_string()),
effective_provider: Some(provider.as_str().to_string()),
effective_provider_id: provider_identity
@@ -7824,10 +8273,12 @@ impl RuntimeThreadManager {
effective_openrouter_vendor: None,
effective_billing_surface: None,
effective_endpoint_fingerprint: None,
effective_provider_live_pricing: None,
effective_billing_mode: None,
effective_dispatched_at: None,
effective_model: Some(crate::cost_status::sanitize_persisted_route_label(&model)),
routed_usage: Vec::new(),
routed_usage_drop_records: Vec::new(),
routed_usage_source_ids: Vec::new(),
routed_usage_dropped_records: 0,
error: None,
@@ -7835,6 +8286,10 @@ impl RuntimeThreadManager {
steer_count: 0,
agent_mail_message_id: input_source.mail_message_id().map(str::to_string),
};
append_initial_routed_usage_to_turn(&mut turn, &initial_routed_usage);
// The engine's TurnComplete owns synchronous dropped coverage,
// including this classifier batch. Pre-persisting the count here and
// adding TurnComplete at settlement would count the same gap twice.
let user_item_id = format!("item_{}", &Uuid::new_v4().to_string()[..8]);
let user_item = TurnItemRecord {
@@ -7879,6 +8334,7 @@ impl RuntimeThreadManager {
mode,
route: Box::new(route),
compaction: Box::new(compaction),
initial_routed_usage: Box::new(initial_routed_usage),
goal_objective: turn_goal_objective,
goal_token_budget: turn_goal_token_budget,
goal_status: turn_goal_status,
@@ -8014,6 +8470,9 @@ impl RuntimeThreadManager {
configured_sandbox_mode,
);
let _sender = permit.send(op);
if let Some(settlement) = initial_routed_usage_settlement.as_mut() {
settlement.disarm();
}
touch_lru(&mut active.lru, thread_id);
self.spawn_claimed_turn_monitor(
turn.clone(),
@@ -8235,6 +8694,8 @@ impl RuntimeThreadManager {
ended_at: None,
duration_ms: None,
usage: None,
routing_settlement: false,
effective_route_usage: None,
permission_posture: Some(
RuntimePolicyProjection::from_persisted(
&thread.mode,
@@ -8252,12 +8713,14 @@ impl RuntimeThreadManager {
effective_openrouter_vendor: None,
effective_billing_surface: None,
effective_endpoint_fingerprint: None,
effective_provider_live_pricing: None,
effective_billing_mode: None,
effective_dispatched_at: None,
effective_model: Some(crate::cost_status::sanitize_persisted_route_label(
&route_model,
)),
routed_usage: Vec::new(),
routed_usage_drop_records: Vec::new(),
routed_usage_source_ids: Vec::new(),
routed_usage_dropped_records: 0,
error: None,
@@ -8960,7 +9423,9 @@ impl RuntimeThreadManager {
fn register_runtime_usage_sink(&self, turn_id: &str) {
let store = self.store.clone();
let sink_turn_id = turn_id.to_string();
crate::cost_status::register_runtime_usage_sink(
let drop_store = self.store.clone();
let drop_turn_id = turn_id.to_string();
crate::cost_status::register_runtime_usage_sink_with_drop(
turn_id,
Arc::new(move |record: RuntimeUsageRecord| {
let _turn_mutation = store.turn_mutation.lock();
@@ -8972,6 +9437,16 @@ impl RuntimeThreadManager {
}
store.save_turn(&turn).is_ok()
}),
Some(Arc::new(move |record: RuntimeUsageDropRecord| {
let _turn_mutation = drop_store.turn_mutation.lock();
let Ok(mut turn) = drop_store.load_turn(&drop_turn_id) else {
return false;
};
if !append_routed_usage_drop_record(&mut turn, record) {
return true;
}
drop_store.save_turn(&turn).is_ok()
})),
);
}
@@ -8986,6 +9461,8 @@ impl RuntimeThreadManager {
let mut tool_items: HashMap<String, String> = HashMap::new();
let mut compaction_items: HashMap<String, String> = HashMap::new();
let mut turn_usage: Option<Usage> = None;
let mut turn_effective_route_usage: Option<Usage> = None;
let mut turn_routed_usage_dropped_records = 0_u64;
let mut turn_status: Option<RuntimeTurnStatus> = None;
let mut turn_error: Option<String> = None;
let mut saw_engine_activity = false;
@@ -9316,16 +9793,40 @@ impl RuntimeThreadManager {
EngineEvent::ToolCallComplete { id, name, result } => {
if let Ok(output) = &result
&& let Some(metadata) = output.metadata.as_ref()
&& let Some(route) =
crate::cost_status::child_route_envelope_from_metadata(metadata)
&& let Some(usage) = crate::cost_status::child_usage_from_metadata(metadata)
{
let source = format!("tool:{id}");
self.append_routed_usage_to_turn(
&turn_id,
&source,
EffectiveRouteUsage { route, usage },
)?;
if let Some(batch) =
crate::cost_status::child_usage_records_from_metadata(metadata)
{
for record in batch.records {
self.append_routed_usage_to_turn(
&turn_id,
&record.source_id,
record.usage,
)?;
}
if !batch.drop_records.is_empty() {
let _turn_mutation = self.store.turn_mutation.lock();
let mut turn = self.store.load_turn(&turn_id)?;
for record in batch.drop_records {
append_routed_usage_drop_record(&mut turn, record);
}
self.store.save_turn(&turn)?;
}
// Exact records share the direct sink's source
// ledger. Only residual/unidentified coverage is
// owned by TurnComplete, so it is not added here.
} else if let Some(route) =
crate::cost_status::child_route_envelope_from_metadata(metadata)
&& let Some(usage) =
crate::cost_status::child_usage_from_metadata(metadata)
{
let source = format!("tool:{id}");
self.append_routed_usage_to_turn(
&turn_id,
&source,
EffectiveRouteUsage { route, usage },
)?;
}
}
if let Some(item_id) = tool_items.remove(&id) {
let mut item = self.store.load_item(&item_id)?;
@@ -9425,7 +9926,10 @@ impl RuntimeThreadManager {
self.append_routed_usage_to_turn(
&turn_id,
&source_id,
EffectiveRouteUsage { route, usage },
EffectiveRouteUsage {
route: *route,
usage,
},
)?;
}
}
@@ -10065,12 +10569,16 @@ impl RuntimeThreadManager {
}
EngineEvent::TurnComplete {
usage,
parent_route_usage,
routed_usage_dropped_records,
status,
error,
tool_catalog,
..
} => {
turn_usage = Some(usage);
turn_effective_route_usage = Some(parent_route_usage);
turn_routed_usage_dropped_records = routed_usage_dropped_records;
if tool_catalog.is_some() {
turn_tool_catalog = tool_catalog;
}
@@ -10226,22 +10734,6 @@ impl RuntimeThreadManager {
let ended_at = Utc::now();
crate::cost_status::finish_runtime_usage_owner(&turn_id);
let background_usage = crate::cost_status::take_runtime_usage(&turn_id);
let turn = {
let _turn_mutation = self.store.turn_mutation.lock();
let mut turn = self.store.load_turn(&turn_id)?;
turn.status = turn_status;
turn.ended_at = Some(ended_at);
turn.duration_ms = turn.started_at.map(|start| duration_ms(start, ended_at));
turn.usage = turn_usage;
for record in background_usage.records {
append_routed_usage_record(&mut turn, &record.source_id, record.usage);
}
turn.routed_usage_dropped_records = turn
.routed_usage_dropped_records
.saturating_add(background_usage.dropped_records);
turn.error = turn_error;
turn
};
// A terminal turn can no longer answer an outstanding prompt. Commit
// each cancellation while the request remains snapshot-authoritative,
@@ -10258,10 +10750,36 @@ impl RuntimeThreadManager {
// active-claim cleanup are ordered.
let projection_lock = self.projection_lock(&thread_id);
let _projection = projection_lock.lock().await;
{
// A leased child may persist a late receipt while pending requests
// settle above. Read the latest turn only now and commit its terminal
// fields under the same mutation lock, so no stale snapshot can
// overwrite that successfully persisted receipt.
let turn = {
let _turn_mutation = self.store.turn_mutation.lock();
let mut turn = self.store.load_turn(&turn_id)?;
turn.status = turn_status;
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_route_usage = turn_effective_route_usage;
for record in background_usage.records {
append_routed_usage_record(&mut turn, &record.source_id, record.usage);
}
let background_exact_drop_count = background_usage.drop_records.len();
for record in background_usage.drop_records {
append_routed_usage_drop_record(&mut turn, record);
}
let background_residual = background_usage
.dropped_records
.saturating_sub(u64::try_from(background_exact_drop_count).unwrap_or(u64::MAX));
turn.routed_usage_dropped_records = turn
.routed_usage_dropped_records
.saturating_add(background_residual)
.saturating_add(turn_routed_usage_dropped_records);
turn.error = turn_error;
self.store.save_turn(&turn)?;
}
turn
};
{
let _thread_mutation = self.store.thread_mutation.lock();
let mut thread = self.store.load_thread(&thread_id)?;
@@ -10401,14 +10919,16 @@ impl RuntimeThreadManager {
// in the same one-pass grouping so already-terminal records whose
// completion append failed are reconciled too.
for mut turn in self.store.list_all_turns()? {
latest_turn_by_thread
.entry(turn.thread_id.clone())
.and_modify(|latest| {
if (turn.created_at, turn.id.as_str()) > (latest.0, latest.1.as_str()) {
*latest = (turn.created_at, turn.id.clone());
}
})
.or_insert_with(|| (turn.created_at, turn.id.clone()));
if !turn.routing_settlement {
latest_turn_by_thread
.entry(turn.thread_id.clone())
.and_modify(|latest| {
if (turn.created_at, turn.id.as_str()) > (latest.0, latest.1.as_str()) {
*latest = (turn.created_at, turn.id.clone());
}
})
.or_insert_with(|| (turn.created_at, turn.id.clone()));
}
let mut thread_changed = false;
let interrupted_candidate = matches!(
turn.status,
File diff suppressed because it is too large. Load diff
+78 -2
View File
@@ -632,6 +632,76 @@ function appendItemDelta(state, itemId, payload) {
});
}
const PROVIDER_MODELS_PAGE_SIZE = 250;
const MAX_PROVIDER_MODELS = 10_000;
const MAX_PROVIDER_MODEL_PAGES = Math.ceil(
MAX_PROVIDER_MODELS / PROVIDER_MODELS_PAGE_SIZE,
);
/**
* Load every bounded page of one provider catalog.
*
* `fetchPage` is injected so the browser client can retain its authenticated
* Runtime API boundary and tests can prove catalogs larger than one page are
* not silently truncated. Cursors are opaque and may never repeat.
*/
export async function collectProviderModelPages(providerId, fetchPage) {
const provider = String(providerId || "").trim();
if (!provider || typeof fetchPage !== "function") {
throw new Error("A provider and page loader are required.");
}
const entries = [];
const seenCursors = new Set();
let expectedTotal;
let cursor = "";
for (let page = 0; page < MAX_PROVIDER_MODEL_PAGES; page += 1) {
const query = new URLSearchParams({ limit: String(PROVIDER_MODELS_PAGE_SIZE) });
if (cursor) query.set("cursor", cursor);
const response = await fetchPage(
`/v1/providers/${encodeURIComponent(provider)}/models?${query.toString()}`,
);
if (String(response?.provider || "") !== provider) {
throw new Error("The Runtime returned a model page for a different provider.");
}
if (!Array.isArray(response?.models)
|| response.models.length > PROVIDER_MODELS_PAGE_SIZE
|| !Number.isSafeInteger(response.total)
|| response.total < 0
|| response.total > MAX_PROVIDER_MODELS) {
throw new Error("The Runtime returned an invalid provider model page.");
}
if (expectedTotal !== undefined && expectedTotal !== response.total) {
throw new Error("The provider catalog changed; restart loading its models.");
}
expectedTotal = response.total;
const pageEntries = response.models;
if (entries.length + pageEntries.length > MAX_PROVIDER_MODELS) {
throw new Error(`The provider catalog exceeds ${MAX_PROVIDER_MODELS} models.`);
}
entries.push(...pageEntries);
const nextCursor = typeof response?.nextCursor === "string"
? response.nextCursor.trim()
: "";
if (!nextCursor) {
if (entries.length !== expectedTotal) {
throw new Error("The Runtime returned an incomplete provider model catalog.");
}
return entries;
}
if (pageEntries.length === 0 || seenCursors.has(nextCursor)) {
throw new Error("The Runtime returned a non-progressing model cursor.");
}
if (entries.length >= expectedTotal) {
throw new Error("The Runtime returned a cursor beyond its provider model catalog.");
}
seenCursors.add(nextCursor);
cursor = nextCursor;
}
throw new Error(`The provider catalog exceeds ${MAX_PROVIDER_MODELS} models.`);
}
function startBrowserClient() {
const dom = {
shell: document.querySelector("#app-shell"),
@@ -1856,11 +1926,17 @@ function startBrowserClient() {
setNewThreadStatus("Loading models…");
syncNewThreadControls();
try {
const response = await api(`/v1/providers/${encodeURIComponent(provider.id)}/models`);
const modelEntries = await collectProviderModelPages(provider.id, async (path) => {
const page = await api(path);
if (generation !== app.newThreadGeneration || !dom.newThreadDialog.open) {
throw new Error("The model request was superseded.");
}
return page;
});
if (generation !== app.newThreadGeneration || !dom.newThreadDialog.open) return;
const seen = new Set();
const models = [];
for (const entry of Array.isArray(response?.models) ? response.models : []) {
for (const entry of modelEntries) {
const id = String(entry?.id || "").trim();
const key = id.toLowerCase();
if (!id || seen.has(key)) continue;
+61
View File
@@ -16,6 +16,7 @@ import { describe, expect, it } from "vitest";
import {
NO_TARGET,
canReply,
collectProviderModelPages,
refusalMessage,
receiptPresentation,
resolveApprovalTarget,
@@ -26,6 +27,66 @@ import {
workflowReceiptPresentation,
} from "./app.mjs";
describe("collectProviderModelPages", () => {
it("loads a 600-model catalog through every opaque page", async () => {
const all = Array.from({ length: 600 }, (_, index) => ({
id: `openrouter/model-${String(index).padStart(3, "0")}`,
image_input: "unknown",
}));
const cursors = new Map([
["", { start: 0, nextCursor: "page-2" }],
["page-2", { start: 250, nextCursor: "page-3" }],
["page-3", { start: 500, nextCursor: "" }],
]);
const paths = [];
const models = await collectProviderModelPages("openrouter", async (path) => {
paths.push(path);
const url = new URL(path, "http://runtime.local");
const cursor = url.searchParams.get("cursor") || "";
const page = cursors.get(cursor);
expect(url.searchParams.get("limit")).toBe("250");
expect(page).toBeDefined();
return {
provider: "openrouter",
models: all.slice(page.start, page.start + 250),
total: all.length,
...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
};
});
expect(models).toEqual(all);
expect(paths).toHaveLength(3);
});
it("rejects truncated pages, malformed rows and changing totals", async () => {
for (const response of [
{ provider: "openrouter", models: [{ id: "one" }], total: 2 },
{ provider: "openrouter", models: null, total: 0 },
{ provider: "another-provider", models: [], total: 0 },
]) {
await expect(collectProviderModelPages("openrouter", async () => response)).rejects.toThrow();
}
let count = 0;
await expect(collectProviderModelPages("openrouter", async () => ({
provider: "openrouter",
models: [{ id: String(count) }],
total: ++count === 1 ? 2 : 3,
nextCursor: "next",
}))).rejects.toThrow("catalog changed");
});
it("rejects a repeated or non-progressing cursor", async () => {
await expect(
collectProviderModelPages("openrouter", async () => ({
provider: "openrouter",
models: [{ id: "model-a", image_input: "unknown" }],
total: 2,
nextCursor: "same-page",
})),
).rejects.toThrow("non-progressing model cursor");
});
});
describe("receiptPresentation", () => {
it("keeps a failed MCP transport compact while preserving the raw receipt", () => {
const raw = "Failed to connect MCP server 'github': Stdio transport closed MCP server stderr (last 1 line): Docker is not running";
File diff suppressed because it is too large. Load diff
+46 -26
View File
@@ -45,8 +45,7 @@ const ALL_ACTIONS: &[&str] = &["session_objects", "open", "eval", "configure", "
fn rlm_kernel_error_result(
error: &str,
elapsed: Duration,
route: &crate::cost_status::EffectiveRouteEnvelope,
usage: &crate::models::Usage,
usage_batch: &crate::cost_status::RuntimeUsageBatch,
) -> ToolResult {
let mut metadata = json!({
// The registered tool is `rlm`; `eval` is its action. Naming a
@@ -57,7 +56,7 @@ fn rlm_kernel_error_result(
"duration_ms": elapsed.as_millis() as u64,
"kernel_error": true,
});
crate::cost_status::attach_child_usage_metadata(&mut metadata, route, usage);
crate::cost_status::attach_child_usage_batch_metadata(&mut metadata, usage_batch);
ToolResult::error(format!("rlm action='eval': {error}")).with_metadata(metadata)
}
@@ -463,16 +462,14 @@ impl RlmTool {
};
let started = Instant::now();
let (round, child_usage, child_route) = if let Some(client) = self.client.clone() {
let route = client.effective_route_envelope(&self.root_model, chrono::Utc::now());
let (round, child_usage_batch) = if let Some(client) = self.client.clone() {
let bridge = RlmBridge::new(
Arc::new(client),
self.root_model.clone(),
config.sub_rlm_max_depth.min(HARD_SUB_RLM_DEPTH_CAP),
);
let usage_handle = bridge.usage_handle();
let round_result = kernel.run(code, Some(&bridge)).await;
let usage = usage_handle.lock().await.clone();
let usage = bridge.usage_snapshot().await;
let round = match round_result {
Ok(round) => round,
Err(error) => {
@@ -485,18 +482,28 @@ impl RlmTool {
return Ok(rlm_kernel_error_result(
&error.to_string(),
started.elapsed(),
&route,
&usage,
&crate::cost_status::RuntimeUsageBatch {
records: usage.records,
drop_records: usage.drop_records,
dropped_records: usage.dropped_records,
},
));
}
};
(round, usage, Some(route))
(
round,
crate::cost_status::RuntimeUsageBatch {
records: usage.records,
drop_records: usage.drop_records,
dropped_records: usage.dropped_records,
},
)
} else {
let round = kernel
.run(code, None::<&RlmBridge>)
.await
.map_err(|e| ToolError::execution_failed(format!("rlm_eval: {e}")))?;
(round, Default::default(), None)
(round, crate::cost_status::RuntimeUsageBatch::default())
};
session.rpc_count = session.rpc_count.saturating_add(round.rpc_count);
@@ -595,11 +602,10 @@ impl RlmTool {
"tool": "rlm_eval",
"duration_ms": started.elapsed().as_millis() as u64,
});
// RLM fans out dozens of child rounds, so an undercounted class here
// scales; report every billable class from the shared producer (#4318).
if let Some(route) = child_route.as_ref() {
crate::cost_status::attach_child_usage_metadata(&mut metadata, route, &child_usage);
}
// Every RLM provider call keeps its own dispatch timestamp and frozen
// quote. The preferred batch format prevents a fan-out from being
// retroactively priced as one aggregate call on the first route.
crate::cost_status::attach_child_usage_batch_metadata(&mut metadata, &child_usage_batch);
Ok(ToolResult::json(&output)
.map_err(|e| ToolError::execution_failed(e.to_string()))?
@@ -964,25 +970,39 @@ mod tests {
reasoning_replay_tokens: Some(7),
..Default::default()
};
let record = crate::cost_status::RuntimeUsageRecord {
source_id: "rlm:test:request:0".to_string(),
usage: crate::cost_status::EffectiveRouteUsage {
route: route.clone(),
usage: usage.clone(),
},
};
let drop_record = crate::cost_status::RuntimeUsageDropRecord {
source_id: "rlm:test:request:1".to_string(),
route: route.clone(),
};
let result = rlm_kernel_error_result(
"kernel stdout closed",
Duration::from_millis(11),
&route,
&usage,
&crate::cost_status::RuntimeUsageBatch {
records: vec![record],
drop_records: vec![drop_record],
dropped_records: 1,
},
);
assert!(!result.success);
let metadata = result
.metadata
.expect("usage metadata on failed tool result");
assert_eq!(
crate::cost_status::child_route_envelope_from_metadata(&metadata),
Some(route)
);
assert_eq!(
crate::cost_status::child_usage_from_metadata(&metadata),
Some(usage)
);
let batch = crate::cost_status::child_usage_records_from_metadata(&metadata)
.expect("preferred routed batch");
assert_eq!(batch.dropped_records, 1);
assert_eq!(batch.records.len(), 1);
assert_eq!(batch.drop_records.len(), 1);
assert_eq!(batch.records[0].usage.route, route);
assert_eq!(batch.records[0].usage.usage, usage);
assert_eq!(batch.drop_records[0].route, route);
}
#[test]
+363 -1
View File
@@ -2,7 +2,7 @@
//!
//! When enabled, the advisor wakes on turn boundaries, reads a bounded slice
//! of recent tool calls from the session transcript, makes a concise LLM
//! advisory call (reusing the same `DeepSeekClient` as the parent turn), and
//! advisory call on an exactly resolved provider/model client, and
//! emits an [`Event::AdvisoryNote`] fire-and-forget.
//!
//! Key design properties:
@@ -26,6 +26,7 @@ use tokio::sync::mpsc;
use tracing::debug;
use crate::client::DeepSeekClient;
use crate::config::Config;
use crate::core::events::Event;
use crate::llm_client::LlmClient;
use crate::models::Role;
@@ -109,6 +110,60 @@ pub struct EmissionGuard {
last_note_hash_at: Option<Instant>,
}
/// Accounting ownership captured while the originating turn is still live.
///
/// Runtime turns retain their synchronous durable sink through the lease;
/// ordinary interactive turns fall back to the exact session cost generation
/// captured here. Neither path can spill into a later session.
#[derive(Debug)]
pub(crate) struct AdvisorUsageContext {
cost_scope: crate::cost_status::CostScopeToken,
runtime_usage_lease: Option<crate::cost_status::RuntimeUsageLease>,
}
impl AdvisorUsageContext {
#[must_use]
pub(crate) fn capture(runtime_owner: Option<&str>) -> Self {
Self {
cost_scope: crate::cost_status::scope_token(),
runtime_usage_lease: runtime_owner
.and_then(crate::cost_status::acquire_runtime_usage_lease),
}
}
fn report(
&self,
source_id: &str,
route: &crate::cost_status::EffectiveRouteEnvelope,
usage: &crate::models::Usage,
) {
crate::cost_status::report_effective_route_for_runtime(
self.cost_scope,
self.runtime_usage_lease
.as_ref()
.map(crate::cost_status::RuntimeUsageLease::owner),
source_id,
route,
usage,
);
}
fn report_unreceipted(
&self,
source_id: &str,
route: &crate::cost_status::EffectiveRouteEnvelope,
) {
crate::cost_status::report_unreceipted_provider_success(
self.cost_scope,
self.runtime_usage_lease
.as_ref()
.map(crate::cost_status::RuntimeUsageLease::owner),
source_id,
route,
);
}
}
impl EmissionGuard {
/// Create a fresh guard with no emission history.
#[must_use]
@@ -259,7 +314,9 @@ pub async fn run_advisor_for_turn(
messages: Vec<Message>,
config: AdvisorConfig,
client: DeepSeekClient,
route_config: Config,
session_model: String,
usage_context: AdvisorUsageContext,
guard: std::sync::Arc<tokio::sync::Mutex<EmissionGuard>>,
tx_event: mpsc::Sender<Event>,
) {
@@ -292,6 +349,15 @@ pub async fn run_advisor_for_turn(
.clone()
.unwrap_or_else(|| session_model.clone());
let (client, model) = match exact_advisor_client(&route_config, client, &session_model, &model)
{
Ok(route) => route,
Err(error) => {
tracing::warn!(target: "advisor", "advisor route resolution failed for turn {turn_id}: {error}");
return;
}
};
let route = client.effective_route_envelope(&model, chrono::Utc::now());
let request = MessageRequest {
model: model.clone(),
messages: vec![Message {
@@ -323,6 +389,19 @@ pub async fn run_advisor_for_turn(
}
};
// A decoded provider response is billable even when its partial/empty
// content is rejected below or the emission guard suppresses a duplicate.
let usage_source_id = format!("advisor:{turn_id}:provider-response:0");
if response.usage == crate::models::Usage::default() {
usage_context.report_unreceipted(&usage_source_id, &route);
tracing::warn!(
target: "advisor",
"advisor provider response omitted usage for turn {turn_id}; cost coverage is unknown"
);
} else {
usage_context.report(&usage_source_id, &route, &response.usage);
}
if crate::models::is_incomplete_stop_reason(response.stop_reason.as_deref()) {
tracing::warn!(
target: "advisor",
@@ -374,6 +453,62 @@ pub async fn run_advisor_for_turn(
debug!(target: "advisor", "advisory note emitted for turn {turn_id} ({tool_call_count} tool calls reviewed)");
}
fn exact_advisor_client(
config: &Config,
parent_client: DeepSeekClient,
session_model: &str,
requested_model: &str,
) -> anyhow::Result<(DeepSeekClient, String)> {
if requested_model
.trim()
.eq_ignore_ascii_case(session_model.trim())
{
return Ok((parent_client, session_model.trim().to_string()));
}
if config.providers.as_ref().is_some_and(|providers| {
providers.custom.values().any(|provider| {
provider
.model
.as_deref()
.is_some_and(|model| model.trim().eq_ignore_ascii_case(requested_model.trim()))
})
}) {
anyhow::bail!(
"advisor model `{}` belongs to a custom provider but no exact provider identity is carried",
requested_model.trim()
);
}
let selection =
crate::model_routing::resolve_explicit_route_with_inventory(config, requested_model);
let (provider, model) = if let Some(selection) = selection {
if selection.provider == crate::config::ApiProvider::Custom {
anyhow::bail!(
"advisor model `{}` resolved only to a custom provider kind without an exact provider identity",
requested_model.trim()
);
}
(selection.provider, selection.model)
} else {
let candidates =
crate::model_routing::explicit_route_candidate_providers(config, requested_model);
if !candidates.is_empty() && !candidates.contains(&config.api_provider()) {
anyhow::bail!(
"advisor model `{}` is not owned by the originating provider and has no unique exact route",
requested_model.trim()
);
}
(config.api_provider(), requested_model.trim().to_string())
};
let client = crate::route_runtime::resolve_runtime_route(config, provider, Some(&model))
.map_err(anyhow::Error::msg)?
.validate()
.map(|route| route.client)
.map_err(anyhow::Error::msg)?;
Ok((client, model))
}
fn hash_str(s: &str) -> u64 {
let mut h = DefaultHasher::new();
s.hash(&mut h);
@@ -385,7 +520,10 @@ fn hash_str(s: &str) -> u64 {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{ProviderConfig, ProvidersConfig};
use std::time::Duration;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn test_config() -> AdvisorConfig {
AdvisorConfig {
@@ -603,4 +741,228 @@ mod tests {
"prompt must include the tool input"
);
}
#[test]
fn advisor_model_override_builds_the_owning_provider_client() {
let config = Config {
provider: Some("deepseek".to_string()),
providers: Some(ProvidersConfig {
deepseek: ProviderConfig {
api_key: Some("sk-deepseek-advisor-test".to_string()),
model: Some("deepseek-chat".to_string()),
..ProviderConfig::default()
},
zai: ProviderConfig {
api_key: Some("zai-advisor-test-key".to_string()),
model: Some(crate::config::DEFAULT_ZAI_MODEL.to_string()),
..ProviderConfig::default()
},
..ProvidersConfig::default()
}),
..Config::default()
};
let parent = DeepSeekClient::new(&config).expect("parent client");
let (advisor, resolved_model) = exact_advisor_client(
&config,
parent,
"deepseek-chat",
crate::config::DEFAULT_ZAI_MODEL,
)
.expect("cross-provider advisor route");
let route = advisor.effective_route_envelope(&resolved_model, chrono::Utc::now());
assert_eq!(route.provider, crate::config::ApiProvider::Zai);
assert_eq!(route.provider_identity, "zai");
assert_eq!(route.model, crate::config::DEFAULT_ZAI_MODEL);
}
#[test]
fn advisor_foreign_custom_override_fails_closed_without_exact_identity() {
let config = Config {
provider: Some("deepseek".to_string()),
providers: Some(ProvidersConfig {
deepseek: ProviderConfig {
api_key: Some("sk-deepseek-advisor-test".to_string()),
model: Some("deepseek-chat".to_string()),
..ProviderConfig::default()
},
custom: [(
"private-route".to_string(),
ProviderConfig {
api_key: Some("custom-advisor-test-key".to_string()),
base_url: Some("https://custom.invalid/v1".to_string()),
model: Some("private-advisor-model".to_string()),
..ProviderConfig::default()
},
)]
.into_iter()
.collect(),
..ProvidersConfig::default()
}),
..Config::default()
};
let parent = DeepSeekClient::new(&config).expect("parent client");
let error =
match exact_advisor_client(&config, parent, "deepseek-chat", "private-advisor-model") {
Ok(_) => panic!("generic custom kind cannot identify the exact foreign route"),
Err(error) => error,
};
assert!(
error.to_string().contains("exact provider identity"),
"{error}"
);
}
#[test]
fn advisor_named_custom_a_cannot_route_model_owned_by_custom_b() {
let config = Config {
provider: Some("custom-a".to_string()),
providers: Some(ProvidersConfig {
custom: [
(
"custom-a".to_string(),
ProviderConfig {
api_key: Some("custom-a-advisor-test-key".to_string()),
base_url: Some("https://custom-a.invalid/v1".to_string()),
model: Some("custom-a-model".to_string()),
kind: Some("openai-compatible".to_string()),
..ProviderConfig::default()
},
),
(
"custom-b".to_string(),
ProviderConfig {
api_key: Some("custom-b-advisor-test-key".to_string()),
base_url: Some("https://custom-b.invalid/v1".to_string()),
model: Some("custom-b-model".to_string()),
kind: Some("openai-compatible".to_string()),
..ProviderConfig::default()
},
),
]
.into_iter()
.collect(),
..ProvidersConfig::default()
}),
..Config::default()
};
let parent = DeepSeekClient::new(&config).expect("active custom-a client");
let error = match exact_advisor_client(&config, parent, "custom-a-model", "custom-b-model")
{
Ok(_) => panic!("custom-b must not reuse custom-a's endpoint or credential"),
Err(error) => error,
};
assert!(
error.to_string().contains("exact provider identity"),
"{error}"
);
}
async fn run_billed_advisor_fixture(
note: &str,
stop_reason: &str,
suppress_as_duplicate: bool,
include_usage: bool,
) -> (crate::cost_status::PendingBackgroundCost, Option<Event>) {
let _scope = crate::cost_status::test_scope();
let server = MockServer::start().await;
let mut provider_response = serde_json::json!({
"id": "advisor-provider-response",
"model": "deepseek-chat",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": note},
"finish_reason": stop_reason
}]
});
if include_usage {
provider_response["usage"] = serde_json::json!({
"prompt_tokens": 9,
"completion_tokens": 3,
"total_tokens": 12
});
}
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(provider_response))
.expect(1)
.mount(&server)
.await;
let route_config = Config {
provider: Some("deepseek".to_string()),
providers: Some(ProvidersConfig {
deepseek: ProviderConfig {
api_key: Some("sk-deepseek-advisor-test".to_string()),
model: Some("deepseek-chat".to_string()),
..ProviderConfig::default()
},
..ProvidersConfig::default()
}),
..Config::default()
};
let mut client = DeepSeekClient::new(&route_config).expect("advisor client");
client.set_test_chat_transport_base_url(server.uri());
let mut emission_guard = EmissionGuard::new();
if suppress_as_duplicate {
emission_guard.record_emission(note);
}
let guard = std::sync::Arc::new(tokio::sync::Mutex::new(emission_guard));
let (tx, mut rx) = mpsc::channel(1);
run_advisor_for_turn(
"advisor-turn".to_string(),
make_messages_with_n_tool_calls(1),
AdvisorConfig {
enabled: true,
max_tool_calls: 5,
rate_limit: Duration::ZERO,
dedup_window: Duration::from_secs(60),
model: None,
},
client,
route_config,
"deepseek-chat".to_string(),
AdvisorUsageContext::capture(None),
guard,
tx,
)
.await;
(crate::cost_status::drain(), rx.try_recv().ok())
}
#[tokio::test]
async fn advisor_incomplete_and_dedup_suppressed_responses_are_each_billed_once() {
let (incomplete, incomplete_event) =
run_billed_advisor_fixture("partial note", "max_tokens", false, true).await;
assert!(incomplete_event.is_none());
assert_eq!(
incomplete
.priced_turns
.saturating_add(incomplete.unpriced_turns),
1
);
let (dedup, dedup_event) =
run_billed_advisor_fixture("same advisory", "stop", true, true).await;
assert!(dedup_event.is_none());
assert_eq!(dedup.priced_turns.saturating_add(dedup.unpriced_turns), 1);
}
#[tokio::test]
async fn advisor_provider_success_without_usage_marks_unknown_once() {
let (pending, event) =
run_billed_advisor_fixture("use a smaller focused slice", "stop", false, false).await;
assert!(
event.is_some(),
"the semantic advisor response remains usable"
);
assert_eq!(pending.priced_turns, 0);
assert_eq!(pending.unpriced_turns, 1);
assert_eq!(pending.cny_unpriced_turns, 1);
assert!(
pending
.unpriced_reasons
.contains("provider_success_missing_usage")
);
}
}
+6 -4
View File
@@ -84,8 +84,9 @@ pub enum MailboxMessage {
/// this across direct durability, mailbox replay, and restart dedupe.
source_id: String,
/// Immutable provider/model/billing evidence captured before the
/// child request was sent.
route: crate::cost_status::EffectiveRouteEnvelope,
/// child request was sent. Boxed: the envelope dwarfs every other
/// variant, and mailboxes queue many messages.
route: Box<crate::cost_status::EffectiveRouteEnvelope>,
/// Provider usage payload, including cache-hit/cache-miss fields.
usage: Usage,
},
@@ -141,7 +142,7 @@ impl MailboxMessage {
Self::TokenUsage {
agent_id: agent_id.into(),
source_id: source_id.into(),
route,
route: Box::new(route),
usage,
}
}
@@ -618,7 +619,7 @@ mod tests {
MailboxMessage::TokenUsage {
agent_id: "a9".into(),
source_id: "response-a9".into(),
route: test_route(ApiProvider::Deepseek, "deepseek-v4-flash"),
route: Box::new(test_route(ApiProvider::Deepseek, "deepseek-v4-flash")),
usage: Usage {
input_tokens: 100,
output_tokens: 50,
@@ -642,6 +643,7 @@ mod tests {
model: "k3".to_string(),
billing_surface: Some(crate::pricing::MOONSHOT_KIMI_CODE_BILLING_SURFACE.to_string()),
endpoint_fingerprint: Some("a".repeat(64)),
provider_live_pricing: None,
billing_mode: crate::cost_status::RouteBillingMode::Subscription,
dispatched_at: chrono::DateTime::<chrono::Utc>::from_timestamp(1_234, 0)
.expect("timestamp"),
+186 -17
View File
@@ -942,6 +942,17 @@ fn usage_total_tokens(usage: &Usage) -> u64 {
u64::from(usage.input_tokens).saturating_add(u64::from(usage.output_tokens))
}
fn usage_has_reported_data(usage: &Usage) -> bool {
usage.input_tokens > 0
|| usage.output_tokens > 0
|| usage.prompt_cache_hit_tokens.is_some()
|| usage.prompt_cache_miss_tokens.is_some()
|| usage.prompt_cache_write_tokens.is_some()
|| usage.reasoning_tokens.is_some()
|| usage.reasoning_replay_tokens.is_some()
|| usage.server_tool_use.is_some()
}
/// Convert an authoritative USD audit into the workflow IR's integer
/// microdollar receipt. Route coverage stays on the cost-status path; this
/// narrow projection deliberately preserves only a priced subtotal.
@@ -957,9 +968,85 @@ fn priced_usd_microusd(audit: &crate::pricing::TurnCostAudit) -> Option<u64> {
Some(microusd.round() as u64)
}
/// Immutable accounting ownership of one sub-agent dispatch.
///
/// Captured once, when the runtime is built (`SubAgentRuntime::new`: the
/// engine's per-turn runtime, its off-turn continuation runtime, the direct
/// Workflow runtime), and inherited unchanged by every `child_runtime` /
/// `background_runtime` descendant and by the child tool registry's guardian
/// runtime. It replaces the completion-time `cost_status::scope_token()` read
/// `record_provider_response_usage` used to perform: a provider response that
/// lands after `/new` or a session load settles against the session it was
/// dispatched from, never against whichever session is live when it arrives.
///
/// Runtime-owned dispatches (a `RuntimeUsageLease`) already carry their origin
/// inside the owner sink. This origin governs the ownerless paths: off-turn
/// continuations, direct Workflow runtimes, and their missing-usage receipts.
#[derive(Debug, Clone)]
pub(crate) struct SubAgentAccountingOrigin {
/// Cost-scope generation at dispatch. The live pool rejects a stale token;
/// it is never re-derived at completion.
cost_scope: crate::cost_status::CostScopeToken,
/// Root session of the dispatch: the tool context's state namespace, the
/// same identity the terminal fan-in and coordination paths use. A receipt
/// whose scope has been retired is appended to this session's durable
/// late-usage sidecar.
session_id: String,
}
impl SubAgentAccountingOrigin {
fn capture(context: &ToolContext) -> Self {
Self {
cost_scope: crate::cost_status::scope_token(),
session_id: context.state_namespace.clone(),
}
}
/// Settle one reported provider response that has no runtime owner. A
/// live origin scope charges the interactive pool exactly once per source
/// id; a retired one appends the exact frozen receipt to the origin
/// session's sidecar, labelled by the child run that dispatched it. Either
/// way the replacement session never sees it.
fn report_ownerless_usage(
&self,
agent_id: &str,
source_id: &str,
route: &crate::cost_status::EffectiveRouteEnvelope,
usage: &Usage,
) {
crate::cost_status::report_effective_route_for_interactive_origin(
self.cost_scope,
&self.session_id,
agent_id,
source_id,
route,
usage,
);
}
/// Same settlement for a provider-success response without a usage
/// payload: one route-aware missing-coverage receipt for the origin.
fn report_ownerless_missing_usage(
&self,
agent_id: &str,
source_id: &str,
route: &crate::cost_status::EffectiveRouteEnvelope,
) {
crate::cost_status::report_unreceipted_for_interactive_origin(
self.cost_scope,
&self.session_id,
agent_id,
source_id,
route,
);
}
}
/// Publish one child provider response into every projection that owns it.
/// The stable source id is the shared exactly-once key: runtime/session cost
/// and the durable worker record hash it with the same canonical function.
/// Accounting ownership is the runtime's dispatch-time
/// [`SubAgentAccountingOrigin`]; nothing here consults the live cost scope.
async fn record_provider_response_usage(
runtime: &SubAgentRuntime,
agent_id: &str,
@@ -967,23 +1054,53 @@ async fn record_provider_response_usage(
route: crate::cost_status::EffectiveRouteEnvelope,
usage: &Usage,
) {
let priced_cost_microusd = priced_usd_microusd(&route.audit(usage));
if let Some(lease) = runtime.runtime_usage_lease.as_ref() {
crate::cost_status::report_effective_route_for_runtime(
crate::cost_status::scope_token(),
Some(lease.owner()),
source_id,
&route,
usage,
);
}
if let Some(mailbox) = runtime.mailbox.as_ref() {
let _ = mailbox.send(MailboxMessage::token_usage(
agent_id,
source_id,
route,
usage.clone(),
));
let has_reported_usage = usage_has_reported_data(usage);
let priced_cost_microusd = has_reported_usage
.then(|| priced_usd_microusd(&route.audit(usage)))
.flatten();
let origin = &runtime.accounting_origin;
let runtime_owner = runtime
.runtime_usage_lease
.as_ref()
.map(crate::cost_status::RuntimeUsageLease::owner);
if has_reported_usage {
if let Some(owner) = runtime_owner {
crate::cost_status::report_effective_route_for_runtime(
origin.cost_scope,
Some(owner),
source_id,
&route,
usage,
);
} else if runtime.mailbox.is_none() {
// Off-turn continuations and direct Workflow runtimes have no
// turn mailbox or durable turn owner. Their provider responses
// belong to the session that dispatched them; do not silently
// retain tokens only in the worker projection.
origin.report_ownerless_usage(agent_id, source_id, &route, usage);
}
if let Some(mailbox) = runtime.mailbox.as_ref() {
let _ = mailbox.send(MailboxMessage::token_usage(
agent_id,
source_id,
route,
usage.clone(),
));
}
} else {
// A decoded provider-success response with the default Usage shape is
// indistinguishable from an omitted payload. Preserve its exact frozen
// route as one missing-coverage receipt; do not also publish a legacy
// priced-zero mailbox message under the same logical response.
match runtime_owner {
Some(owner) => crate::cost_status::report_unreceipted_provider_success(
origin.cost_scope,
Some(owner),
source_id,
&route,
),
None => origin.report_ownerless_missing_usage(agent_id, source_id, &route),
}
}
runtime.manager.write().await.record_worker_usage(
agent_id,
@@ -993,6 +1110,27 @@ async fn record_provider_response_usage(
);
}
/// One logical held child-tool call gets one guardian usage identity even if a
/// mailbox/monitor replays its receipt. Raw agent/tool ids can be model-owned,
/// so only this fixed-length digest crosses telemetry or persistence seams.
fn child_guardian_usage_source_id(agent_id: &str, tool_id: &str) -> String {
use sha2::{Digest as _, Sha256};
let mut digest = Sha256::new();
for part in [
b"codewhale:subagent-auto-review-guardian:v1".as_slice(),
agent_id.as_bytes(),
tool_id.as_bytes(),
] {
digest.update((part.len() as u64).to_le_bytes());
digest.update(part);
}
format!(
"subagent-guardian:{}",
crate::hashing::hex_bytes(digest.finalize())
)
}
fn refresh_usage_note(usage: &mut AgentRunUsage) {
let worker_total = usage.total_tokens.unwrap_or(0);
if let Some(limit) = usage.token_budget {
@@ -2398,6 +2536,10 @@ pub struct SubAgentRuntime {
/// runtimes clone this guard, keeping the sink alive after the parent UI
/// mailbox closes until the final child response has been persisted.
pub(crate) runtime_usage_lease: Option<crate::cost_status::RuntimeUsageLease>,
/// Dispatch-time accounting ownership for every provider response this
/// runtime or its descendants settle. Captured in [`Self::new`], cloned
/// by [`Self::child_runtime`], never refreshed from the live cost scope.
pub(crate) accounting_origin: SubAgentAccountingOrigin,
/// Wakeup channel for this runtime's immediate parent (issue #756). For
/// the engine's direct children this points at the engine turn loop. While
/// a sub-agent is running, its tool registry swaps this for a local inbox
@@ -2476,6 +2618,9 @@ impl SubAgentRuntime {
event_tx: Option<mpsc::Sender<Event>>,
manager: SharedSubAgentManager,
) -> Self {
// Accounting ownership is fixed here, at dispatch, before any child
// provider request exists; see `SubAgentAccountingOrigin`.
let accounting_origin = SubAgentAccountingOrigin::capture(&context);
Self {
client,
api_config: None,
@@ -2502,6 +2647,7 @@ impl SubAgentRuntime {
foreground_children: None,
mailbox: None,
runtime_usage_lease: None,
accounting_origin,
parent_completion_tx: None,
fork_context: None,
mcp_pool: None,
@@ -2797,6 +2943,7 @@ impl SubAgentRuntime {
foreground_children: self.foreground_children.clone(),
mailbox: self.mailbox.clone(),
runtime_usage_lease: self.runtime_usage_lease.clone(),
accounting_origin: self.accounting_origin.clone(),
parent_completion_tx: self.parent_completion_tx.clone(),
fork_context: self.fork_context.clone(),
mcp_pool: self.mcp_pool.clone(),
@@ -14339,12 +14486,34 @@ impl SubAgentToolRegistry {
let context_text =
crate::tui::auto_review::build_reviewer_context(review_context, held_reason, input);
// Capture the child guardian's own immutable quote immediately before
// its provider future starts. It is a routed auxiliary call, never
// billable under the parent turn's model/quote.
let review_route = self
.gate_runtime
.client
.effective_route_envelope(self.gate_runtime.client.model(), chrono::Utc::now());
let review = consult_reviewer(
&self.gate_runtime.client,
&context_text,
&self.gate_runtime.cancel_token,
)
.await;
// A provider-success reply carries usage even when it is incomplete or
// semantically invalid. Record before interpreting the verdict so the
// fail-closed path cannot erase spend. Pre-dispatch cancellation and
// transport failure expose no usage and therefore mint no receipt.
if let Some(usage) = review.usage.as_ref() {
let source_id = child_guardian_usage_source_id(agent_id, tool_id);
record_provider_response_usage(
&self.gate_runtime,
agent_id,
&source_id,
review_route,
usage,
)
.await;
}
let risk = review.outcome.audit_risk();
let (verdict, reason) = match &review.outcome {
ReviewerOutcome::Allow { reason, .. } => (ToolGateVerdict::Allowed, reason.clone()),
+636 -6
View File
@@ -2240,6 +2240,381 @@ async fn detached_interactive_usage_after_mailbox_seal_reaches_session_accountin
}
}
#[tokio::test]
async fn child_guardian_usage_source_is_sanitized_and_replay_idempotent() {
let _cost_scope = crate::cost_status::test_scope();
let tmp = tempdir().expect("tempdir");
let manager = Arc::new(RwLock::new(SubAgentManager::new(
tmp.path().to_path_buf(),
1,
)));
manager.write().await.register_worker_for_session(
make_worker_spec("agent_guardian", tmp.path().to_path_buf()),
"guardian-usage-session",
);
let runtime_owner = "interactive:guardian-usage-session:turn-parent";
crate::cost_status::register_interactive_runtime_usage_sink(
runtime_owner,
crate::cost_status::scope_token(),
);
let mut runtime = stub_runtime();
runtime.manager = Arc::clone(&manager);
runtime.runtime_usage_lease = crate::cost_status::acquire_runtime_usage_lease(runtime_owner);
let source_id = child_guardian_usage_source_id("agent_guardian", "tool-fixed");
assert_eq!(
source_id,
child_guardian_usage_source_id("agent_guardian", "tool-fixed"),
"replaying one logical held call must preserve its accounting identity"
);
assert_ne!(
source_id,
child_guardian_usage_source_id("agent_guardian", "tool-other")
);
assert_eq!(source_id.len(), "subagent-guardian:".len() + 64);
assert!(!source_id.contains("agent_guardian"));
assert!(!source_id.contains("tool-fixed"));
let route = crate::cost_status::EffectiveRouteEnvelope::capture(
None,
ApiProvider::Deepseek,
"deepseek-direct",
"deepseek-v4-flash",
Some(ApiProvider::Deepseek.default_base_url()),
chrono::Utc::now(),
);
let usage = Usage {
input_tokens: 7,
output_tokens: 5,
..Usage::default()
};
record_provider_response_usage(
&runtime,
"agent_guardian",
&source_id,
route.clone(),
&usage,
)
.await;
// A mailbox or durable-record replay must not charge the routed guardian
// call a second time in either the session or worker projection.
record_provider_response_usage(&runtime, "agent_guardian", &source_id, route, &usage).await;
crate::cost_status::finish_runtime_usage_owner(runtime_owner);
let session_usage = crate::cost_status::drain();
let fingerprint = crate::cost_status::usage_source_fingerprint(&source_id);
assert_eq!(
session_usage.usage_source_fingerprints,
[fingerprint.clone()].into()
);
assert_eq!(
session_usage
.priced_turns
.saturating_add(session_usage.unpriced_turns),
1,
"the routed guardian response contributes exactly one cost receipt"
);
let worker = manager
.read()
.await
.get_worker_record("agent_guardian")
.expect("guardian worker record")
.clone();
assert_eq!(worker.usage.input_tokens, Some(7));
assert_eq!(worker.usage.output_tokens, Some(5));
assert_eq!(worker.usage_source_fingerprints, [fingerprint].into());
}
#[tokio::test]
async fn ownerless_no_mailbox_provider_usage_reaches_accounting_once() {
let _cost_scope = crate::cost_status::test_scope();
let tmp = tempdir().expect("tempdir");
let manager = Arc::new(RwLock::new(SubAgentManager::new(
tmp.path().to_path_buf(),
1,
)));
manager.write().await.register_worker_for_session(
make_worker_spec("agent_direct", tmp.path().to_path_buf()),
"direct-usage-session",
);
let mut runtime = stub_runtime();
runtime.manager = Arc::clone(&manager);
assert!(runtime.runtime_usage_lease.is_none());
assert!(runtime.mailbox.is_none());
let source_id = "subagent:agent_direct:step:1:response:direct";
let route = crate::cost_status::EffectiveRouteEnvelope::capture(
None,
ApiProvider::Deepseek,
"deepseek-direct",
"deepseek-v4-flash",
Some(ApiProvider::Deepseek.default_base_url()),
chrono::Utc::now(),
);
let usage = Usage {
input_tokens: 19,
output_tokens: 7,
..Usage::default()
};
for _ in 0..2 {
record_provider_response_usage(&runtime, "agent_direct", source_id, route.clone(), &usage)
.await;
}
let session_usage = crate::cost_status::drain();
let fingerprint = crate::cost_status::usage_source_fingerprint(source_id);
assert_eq!(
session_usage.usage_source_fingerprints,
[fingerprint.clone()].into()
);
assert_eq!(
session_usage
.priced_turns
.saturating_add(session_usage.unpriced_turns),
1,
"a replayed ownerless provider response must have one cost receipt"
);
assert!(session_usage.estimate.is_positive());
let worker = manager
.read()
.await
.get_worker_record("agent_direct")
.expect("direct worker record")
.clone();
assert_eq!(worker.usage.input_tokens, Some(19));
assert_eq!(worker.usage.output_tokens, Some(7));
assert_eq!(worker.usage_source_fingerprints, [fingerprint].into());
}
/// Dispatch-time accounting ownership must survive `/new`. An off-turn
/// continuation runtime (no turn owner, no mailbox) is dispatched in the
/// origin session; a later provider response, its monitor replay, and a
/// guardian reply without a usage payload all land after the origin scope has
/// been retired. Each must settle exactly once against the origin session's
/// durable sidecar and never against the replacement scope.
#[tokio::test]
async fn ownerless_child_usage_crossing_new_settles_to_its_dispatch_origin_once() {
let _env = crate::test_support::lock_test_env();
let _cost_scope = crate::cost_status::test_scope();
let tmp = tempdir().expect("tempdir");
let home = tmp.path().join("home");
let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
let sessions =
crate::session_manager::SessionManager::default_location().expect("session store");
assert!(
sessions.sessions_dir().starts_with(&home),
"the sidecar must resolve into the guarded temporary home, not the real store: {}",
sessions.sessions_dir().display()
);
let origin_session_id = "origin-session";
let replacement_session_id = "replacement-session";
for session_id in [origin_session_id, replacement_session_id] {
let session = crate::session_manager::create_saved_session_with_id_and_mode(
session_id.to_string(),
&[],
"deepseek-v4-flash",
tmp.path(),
0,
None,
Some("agent"),
);
sessions.save_session(&session).expect("save session");
}
let agent_id = "agent_off_turn";
let manager = Arc::new(RwLock::new(SubAgentManager::new(
tmp.path().to_path_buf(),
1,
)));
manager.write().await.register_worker_for_session(
make_worker_spec(agent_id, tmp.path().to_path_buf()),
origin_session_id,
);
// Dispatch in the origin session: the engine's off-turn continuation
// runtime carries neither a runtime owner lease nor a turn mailbox.
let mut root = stub_runtime();
root.manager = Arc::clone(&manager);
root.context = ToolContext::new(tmp.path()).with_state_namespace(origin_session_id);
root.accounting_origin = SubAgentAccountingOrigin::capture(&root.context);
let child = root.background_runtime();
assert!(child.runtime_usage_lease.is_none());
assert!(child.mailbox.is_none());
let route = crate::cost_status::EffectiveRouteEnvelope::capture(
None,
ApiProvider::Deepseek,
"deepseek-direct",
"deepseek-v4-flash",
Some(ApiProvider::Deepseek.default_base_url()),
chrono::Utc::now(),
);
let usage = Usage {
input_tokens: 19,
output_tokens: 7,
..Usage::default()
};
let first_source = "subagent:agent_off_turn:step:1:response:before-new";
let late_source = "subagent:agent_off_turn:step:2:response:after-new";
let missing_source = child_guardian_usage_source_id(agent_id, "tool-after-new");
let first_fingerprint = crate::cost_status::usage_source_fingerprint(first_source);
let late_fingerprint = crate::cost_status::usage_source_fingerprint(late_source);
let missing_fingerprint = crate::cost_status::usage_source_fingerprint(&missing_source);
// Step 1 settles while the origin scope is live.
record_provider_response_usage(&child, agent_id, first_source, route.clone(), &usage).await;
// `/new` retires the origin scope exactly as the TUI does, while the
// child keeps running.
let settled_origin = crate::cost_status::close_current_scope();
assert_eq!(
settled_origin.usage_source_fingerprints,
[first_fingerprint.clone()].into()
);
assert_eq!(settled_origin.priced_turns, 1);
assert!(settled_origin.estimate.is_positive());
// The next response, its monitor replay, and a guardian reply without a
// usage payload all arrive after the scope generation moved on.
for _ in 0..2 {
record_provider_response_usage(&child, agent_id, late_source, route.clone(), &usage).await;
record_provider_response_usage(
&child,
agent_id,
&missing_source,
route.clone(),
&Usage::default(),
)
.await;
}
let replacement_live = crate::cost_status::drain();
assert!(
replacement_live.is_empty(),
"late origin receipts charged the replacement scope: {replacement_live:?}"
);
let origin = sessions
.load_session_snapshot(origin_session_id)
.expect("origin session");
assert_eq!(origin.metadata.total_tokens, 26);
assert_eq!(origin.metadata.cost.priced_turns, 1);
assert_eq!(origin.metadata.cost.unpriced_turns, 1);
assert!(
origin
.metadata
.cost
.unpriced_reasons
.contains("provider_success_missing_usage")
);
assert_eq!(
origin.metadata.cost.usage_source_fingerprints,
[late_fingerprint.clone(), missing_fingerprint.clone()].into()
);
let replacement = sessions
.load_session_snapshot(replacement_session_id)
.expect("replacement session");
assert_eq!(replacement.metadata.total_tokens, 0);
assert_eq!(replacement.metadata.cost.priced_turns, 0);
assert_eq!(replacement.metadata.cost.unpriced_turns, 0);
assert!(
replacement
.metadata
.cost
.usage_source_fingerprints
.is_empty()
);
let worker = manager
.read()
.await
.get_worker_record(agent_id)
.expect("off-turn worker record")
.clone();
assert_eq!(worker.usage.input_tokens, Some(38));
assert_eq!(worker.usage.output_tokens, Some(14));
assert_eq!(
worker.usage_source_fingerprints,
[first_fingerprint, late_fingerprint, missing_fingerprint].into()
);
}
#[tokio::test]
async fn provider_success_without_usage_records_one_route_aware_gap_and_no_zero_mail() {
let _cost_scope = crate::cost_status::test_scope();
let tmp = tempdir().expect("tempdir");
let manager = Arc::new(RwLock::new(SubAgentManager::new(
tmp.path().to_path_buf(),
1,
)));
manager.write().await.register_worker_for_session(
make_worker_spec("agent_missing_usage", tmp.path().to_path_buf()),
"missing-usage-session",
);
let runtime_owner = "interactive:missing-usage-session:turn-parent";
crate::cost_status::register_interactive_runtime_usage_sink(
runtime_owner,
crate::cost_status::scope_token(),
);
let (mailbox, mut mailbox_rx) = Mailbox::new(CancellationToken::new());
let mut runtime = stub_runtime();
runtime.manager = Arc::clone(&manager);
runtime.mailbox = Some(mailbox);
runtime.runtime_usage_lease = crate::cost_status::acquire_runtime_usage_lease(runtime_owner);
let source_id = child_guardian_usage_source_id("agent_missing_usage", "tool-fixed");
let route = crate::cost_status::EffectiveRouteEnvelope::capture(
None,
ApiProvider::Deepseek,
"deepseek-direct",
"deepseek-v4-flash",
Some(ApiProvider::Deepseek.default_base_url()),
chrono::Utc::now(),
);
for _ in 0..2 {
record_provider_response_usage(
&runtime,
"agent_missing_usage",
&source_id,
route.clone(),
&Usage::default(),
)
.await;
}
assert!(
!mailbox_rx.has_pending(),
"missing usage must not also publish a priced-zero TokenUsage message"
);
crate::cost_status::finish_runtime_usage_owner(runtime_owner);
let session_usage = crate::cost_status::drain();
let fingerprint = crate::cost_status::usage_source_fingerprint(&source_id);
assert_eq!(
session_usage.usage_source_fingerprints,
[fingerprint.clone()].into()
);
assert_eq!(session_usage.priced_turns, 0);
assert_eq!(session_usage.unpriced_turns, 1);
assert!(
session_usage
.unpriced_reasons
.contains("provider_success_missing_usage")
);
let worker = manager
.read()
.await
.get_worker_record("agent_missing_usage")
.expect("missing-usage worker record")
.clone();
assert_eq!(worker.usage.total_tokens, Some(0));
assert_eq!(worker.usage.cost_microusd, None);
assert_eq!(worker.usage_source_fingerprints, [fingerprint].into());
}
/// Like [`delayed_chat_client`] but delays *every* attempt, so the per-step
/// API timeout fires on the first call and on every retry — the shape needed
/// to drive the timeout-retry budget to exhaustion.
@@ -12884,6 +13259,7 @@ pub(crate) fn stub_runtime() -> SubAgentRuntime {
api_key: Some("test-key".to_string()),
..crate::config::Config::default()
};
let accounting_origin = SubAgentAccountingOrigin::capture(&context);
SubAgentRuntime {
client: stub_client(),
api_config: Some(std::sync::Arc::new(stub_config)),
@@ -12907,6 +13283,7 @@ pub(crate) fn stub_runtime() -> SubAgentRuntime {
foreground_children: None,
mailbox: None,
runtime_usage_lease: None,
accounting_origin,
parent_agent_id: None,
parent_completion_tx: None,
fork_context: None,
@@ -13163,7 +13540,9 @@ fn stub_client_for_provider(provider: &str) -> DeepSeekClient {
};
}
// Ollama is keyless (local runtime); extend per-provider as needed.
"ollama" => {}
"ollama" => {
providers.ollama.model = Some("fixture-local:tag".to_string());
}
"sakana" => {
providers.sakana = crate::config::ProviderConfig {
api_key: Some("test-key".to_string()),
@@ -20054,9 +20433,9 @@ mod child_permission_gate {
if let Some(client) = client {
runtime.client = client;
}
let workspace = tmp.path().to_path_buf();
let session_id = format!("child_gate_{}", uuid::Uuid::new_v4().simple());
runtime.context =
ToolContext::new(tmp.path().to_path_buf()).with_state_namespace(session_id);
runtime.context = ToolContext::new(workspace.clone()).with_state_namespace(session_id);
runtime.context.auto_approve = auto_approve;
runtime.allow_shell = true;
runtime.event_tx = Some(tx);
@@ -20069,6 +20448,15 @@ mod child_permission_gate {
parent_can_prompt,
);
let manager = Arc::clone(&runtime.manager);
{
let mut manager = manager
.try_write()
.expect("guardian test worker registry is uncontended");
manager.register_worker_for_session(
make_worker_spec("agent_gate", workspace),
"guardian-test-session",
);
}
// Keep the tempdir alive for the registry's lifetime by leaking it
// into the workspace path (tests are short-lived).
std::mem::forget(tmp);
@@ -20141,8 +20529,12 @@ mod child_permission_gate {
assert!(replay.unmatched_asks.is_empty());
}
/// A chat-completions mock that answers every request with `content`.
async fn guardian_mock(content: &str) -> (wiremock::MockServer, DeepSeekClient) {
/// A chat-completions mock that answers every request with `content` and
/// the requested semantic completion state.
async fn guardian_mock_with_stop(
content: &str,
finish_reason: &str,
) -> (wiremock::MockServer, DeepSeekClient) {
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
let _ = rustls::crypto::ring::default_provider().install_default();
@@ -20155,7 +20547,7 @@ mod child_permission_gate {
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": content},
"finish_reason": "stop"
"finish_reason": finish_reason
}],
"usage": {"prompt_tokens": 9, "completion_tokens": 3, "total_tokens": 12}
})))
@@ -20170,6 +20562,37 @@ mod child_permission_gate {
(server, client)
}
async fn guardian_mock(content: &str) -> (wiremock::MockServer, DeepSeekClient) {
guardian_mock_with_stop(content, "stop").await
}
async fn guardian_mock_without_usage(content: &str) -> (wiremock::MockServer, DeepSeekClient) {
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
let _ = rustls::crypto::ring::default_provider().install_default();
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "chatcmpl-guardian-missing-usage",
"object": "chat.completion",
"model": "deepseek-v4-pro",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": content},
"finish_reason": "stop"
}]
})))
.mount(&server)
.await;
let config = crate::config::Config {
api_key: Some("test-key".to_string()),
base_url: Some(server.uri()),
..crate::config::Config::default()
};
let client = DeepSeekClient::new(&config).expect("mock-backed client");
(server, client)
}
fn unreachable_client() -> DeepSeekClient {
let _ = rustls::crypto::ring::default_provider().install_default();
let config = crate::config::Config {
@@ -20458,6 +20881,19 @@ mod child_permission_gate {
#[cfg(not(windows))]
const GUARDIAN_PIPELINE: &str = "echo built | cat";
/// [`GUARDIAN_PIPELINE`] with a caller-chosen marker, for tests that
/// need to recognize their own output.
fn guardian_marker_pipeline(marker: &str) -> String {
#[cfg(windows)]
{
format!("echo {marker} | Out-String")
}
#[cfg(not(windows))]
{
format!("echo {marker} | cat")
}
}
#[tokio::test]
async fn auto_review_consults_the_guardian_and_runs_an_allowed_call_with_a_receipt() {
let (_server, client) = guardian_mock(
@@ -20502,6 +20938,200 @@ mod child_permission_gate {
assert_eq!(receipts[0].2.as_deref(), Some("high"));
}
#[tokio::test]
async fn auto_review_guardian_semantic_failures_still_record_provider_usage() {
for (content, finish_reason, expected_reason) in [
("not a guardian verdict", "stop", "answer was unparseable"),
(
r#"{"risk_level":"low","decision":"allow","reason":"truncated"}"#,
"length",
"answer was incomplete",
),
] {
let (_server, client) = guardian_mock_with_stop(content, finish_reason).await;
let (registry, mut rx, manager) =
worker_registry(ApprovalMode::Auto, false, true, Some(client));
// Label the two provider-success failures independently.
let command = format!("{GUARDIAN_PIPELINE} # {finish_reason}");
let err = registry
.execute("agent_gate", "bash", json!({"command": command}))
.await
.expect_err("a semantically unusable guardian response fails closed");
assert!(err.to_string().contains(expected_reason), "{err}");
let worker = manager
.read()
.await
.get_worker_record("agent_gate")
.expect("guardian usage reaches the durable worker record")
.clone();
assert_eq!(worker.usage.input_tokens, Some(9));
assert_eq!(worker.usage.output_tokens, Some(3));
assert_eq!(
worker.usage_source_fingerprints.len(),
1,
"one provider-success guardian call records exactly once"
);
let receipts = drain_gate_receipts(&mut rx);
assert_eq!(receipts.len(), 1, "{receipts:?}");
assert_eq!(receipts[0].0, ToolGate::AutoReviewGuardian);
assert_eq!(receipts[0].1, ToolGateVerdict::Unavailable);
}
}
#[tokio::test]
async fn auto_review_guardian_success_without_usage_records_one_missing_coverage_receipt() {
let _cost_scope = crate::cost_status::test_scope();
let (_server, client) = guardian_mock_without_usage(
r#"{"risk_level":"low","decision":"allow","reason":"missing usage regression"}"#,
)
.await;
let (registry, mut rx, manager) =
worker_registry(ApprovalMode::Auto, false, true, Some(client));
let output = registry
.execute(
"agent_gate",
"bash",
json!({"command": guardian_marker_pipeline("guardian-missing-usage")}),
)
.await
.expect("semantic guardian success remains usable");
assert!(output.contains("guardian-missing-usage"), "{output}");
let pending = crate::cost_status::drain();
assert_eq!(pending.priced_turns, 0);
assert_eq!(pending.unpriced_turns, 1);
assert!(
pending
.unpriced_reasons
.contains("provider_success_missing_usage")
);
assert_eq!(pending.usage_source_fingerprints.len(), 1);
let worker = manager
.read()
.await
.get_worker_record("agent_gate")
.expect("guardian missing usage reaches the worker ledger")
.clone();
assert_eq!(worker.usage.total_tokens, Some(0));
assert_eq!(worker.usage.cost_microusd, None);
assert_eq!(worker.usage_source_fingerprints.len(), 1);
let receipts = drain_gate_receipts(&mut rx);
assert_eq!(receipts.len(), 1, "{receipts:?}");
assert_eq!(receipts[0].0, ToolGate::AutoReviewGuardian);
assert_eq!(receipts[0].1, ToolGateVerdict::Allowed);
}
#[tokio::test]
async fn auto_review_guardian_rechecks_identical_calls_and_records_both_provider_receipts() {
use std::sync::atomic::{AtomicUsize, Ordering};
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
let _cost_scope = crate::cost_status::test_scope();
let _ = rustls::crypto::ring::default_provider().install_default();
let server = MockServer::start().await;
let calls = AtomicUsize::new(0);
Mock::given(method("POST"))
.respond_with(move |_: &wiremock::Request| {
let (decision, risk) = if calls.fetch_add(1, Ordering::SeqCst) == 0 {
("allow", "low")
} else {
("deny", "high")
};
let verdict = json!({
"decision": decision,
"risk_level": risk,
"reason": "current authorization evidence",
});
ResponseTemplate::new(200).set_body_json(json!({
"id": "chatcmpl-guardian-fresh",
"object": "chat.completion",
"model": "deepseek-v4-pro",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": verdict.to_string()},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 9, "completion_tokens": 3, "total_tokens": 12}
}))
})
.mount(&server)
.await;
let client = DeepSeekClient::new(&crate::config::Config {
api_key: Some("test-guardian-fresh-key".to_string()),
base_url: Some(server.uri()),
..Default::default()
})
.expect("mock-backed client");
let (registry, mut rx, manager) =
worker_registry(ApprovalMode::Auto, false, true, Some(client));
let marker = registry
.gate_runtime
.context
.workspace
.join("guardian-review-executed.txt");
let input = json!({"command": format!(
"{} > guardian-review-executed.txt",
guardian_marker_pipeline("guardian-fresh-review")
)});
// Distinct provider tool IDs match two real held calls. The convenience
// execute helper supplies an empty ID, which denotes one usage source.
registry
.execute_full("agent_gate", "guardian-call-1", "bash", input.clone())
.await
.expect("the first fresh verdict allows execution");
assert!(marker.is_file(), "the allowed command ran in the workspace");
std::fs::remove_file(&marker).expect("clear the execution marker before the denied call");
let err = registry
.execute_full("agent_gate", "guardian-call-2", "bash", input)
.await
.expect_err("an identical call must honor the fresh denial");
assert!(
err.to_string().contains("current authorization evidence"),
"{err}"
);
assert!(!marker.exists(), "the denied command must not execute");
let requests = server
.received_requests()
.await
.expect("guardian provider requests are recorded");
assert_eq!(
requests.len(),
2,
"each authorization decision requires a fresh provider review"
);
assert_eq!(
requests[0].body, requests[1].body,
"the review context is identical"
);
let worker = manager
.read()
.await
.get_worker_record("agent_gate")
.expect("guardian usage reaches the durable worker record")
.clone();
assert_eq!(worker.usage.input_tokens, Some(18));
assert_eq!(worker.usage.output_tokens, Some(6));
assert_eq!(
worker.usage_source_fingerprints.len(),
2,
"both provider-success reviews accrue once, including the denial"
);
let receipts = drain_gate_receipts(&mut rx);
assert_eq!(receipts.len(), 2, "both gate decisions remain auditable");
assert_eq!(receipts[0].0, ToolGate::AutoReviewGuardian);
assert_eq!(receipts[0].1, ToolGateVerdict::Allowed);
assert_eq!(receipts[1].0, ToolGate::AutoReviewGuardian);
assert_eq!(receipts[1].1, ToolGateVerdict::Denied);
}
#[tokio::test]
async fn auto_review_with_an_unreachable_guardian_fails_closed_with_a_receipt() {
let (registry, mut rx, _) =
+1
View File
@@ -1109,6 +1109,7 @@ fn active_turn_zai_receipt_overrides_all_mutable_parallel_route_metadata() {
openrouter_vendor: None,
billing_surface: None,
endpoint_fingerprint: None,
provider_live_pricing: None,
billing_mode: crate::cost_status::RouteBillingMode::Unknown,
dispatched_at: chrono::Utc::now(),
}),
+352 -48
View File
@@ -20,7 +20,7 @@ use ratatui::{
widgets::{Block, Paragraph, Widget},
};
use codewhale_config::catalog::CatalogSource;
use codewhale_config::catalog::{CatalogRefreshError, CatalogSource, CatalogStatus};
use codewhale_config::model_reference::ModelReferenceCard;
use codewhale_config::pricing::OfferingPricing;
@@ -36,7 +36,9 @@ use crate::model_profile::{
use crate::model_registry;
use crate::models_dev_live::{self, ModelsDevFreshness};
use crate::palette;
use crate::provider_lake::{catalog_offering_for_model, configured_providers};
use crate::provider_lake::{
catalog_offering_for_model, catalog_offering_for_model_identity, configured_providers,
};
use crate::settings::PinnedModel;
use crate::tui::app::{App, ReasoningEffort};
use crate::tui::menu_style;
@@ -559,8 +561,13 @@ impl ModelPickerView {
},
family: grouped
.then(|| {
row.provider
.and_then(|provider| catalog_family_for(provider, &row.id))
row.provider.and_then(|provider| {
catalog_family_for_identity(
provider,
row.provider_identity.as_deref(),
&row.id,
)
})
})
.flatten(),
active: row.id == self.initial_model
@@ -1594,6 +1601,7 @@ fn picker_model_rows_for_app(app: &App, config: &Config) -> Vec<ModelPickerRow>
push_provider_model_rows(
&mut rows,
app.api_provider,
(app.api_provider == ApiProvider::Custom).then(|| app.provider_identity_for_persistence()),
active_model_ids,
app.api_provider,
config,
@@ -1633,6 +1641,7 @@ fn picker_model_rows_for_app(app: &App, config: &Config) -> Vec<ModelPickerRow>
push_provider_model_rows(
&mut rows,
provider,
None,
model_ids,
app.api_provider,
config,
@@ -1641,16 +1650,6 @@ fn picker_model_rows_for_app(app: &App, config: &Config) -> Vec<ModelPickerRow>
);
}
// `ApiProvider::Custom` is shared by every named custom route. Preserve
// the concrete active route key on rows so exact pins cannot collide.
let active_custom_identity = (app.api_provider == ApiProvider::Custom)
.then(|| app.provider_identity_for_persistence().to_string());
for row in &mut rows {
if row.provider == Some(ApiProvider::Custom) {
row.provider_identity = active_custom_identity.clone();
}
}
// The fleet comes first (design §10 F1): every model the person added
// to the selected Fleet rides the pin machinery ahead of their own pins,
// labelled with the roles it fills, so the list leads with what they
@@ -1659,8 +1658,7 @@ fn picker_model_rows_for_app(app: &App, config: &Config) -> Vec<ModelPickerRow>
for row in &mut rows {
row.enabled = model_row_enabled_for_app(app, config, row);
if let Some(pin) = pins.iter().find(|pin| {
row_provider_identity(row)
.is_some_and(|provider| provider.eq_ignore_ascii_case(&pin.provider))
row_provider_identity(row).is_some_and(|provider| provider == pin.provider)
&& row.id.eq_ignore_ascii_case(&pin.model)
}) {
let label = pin.label.as_deref().unwrap_or("pinned");
@@ -1674,13 +1672,17 @@ fn picker_model_rows_for_app(app: &App, config: &Config) -> Vec<ModelPickerRow>
for pin in &pins {
let provider = ApiProvider::parse(&pin.provider).unwrap_or(ApiProvider::Custom);
if rows.iter().any(|row| {
row_provider_identity(row)
.is_some_and(|identity| identity.eq_ignore_ascii_case(&pin.provider))
row_provider_identity(row).is_some_and(|identity| identity == pin.provider)
&& row.id.eq_ignore_ascii_case(&pin.model)
}) {
continue;
}
let metadata = effective_picker_metadata(config, Some(provider), &pin.model);
let metadata = effective_picker_metadata_for_identity(
config,
Some(provider),
Some(&pin.provider),
&pin.model,
);
// Bypass the ordinary `(enum provider, model)` de-duplication here:
// two named Custom routes may intentionally expose the same model id.
rows.push(ModelPickerRow {
@@ -1712,11 +1714,13 @@ fn model_row_enabled_for_app(app: &App, config: &Config, row: &ModelPickerRow) -
return true;
}
}
let provider_identity = if provider == app.api_provider {
app.provider_identity_for_persistence()
} else {
provider.as_str()
};
let provider_identity = row_provider_identity(row).unwrap_or_else(|| {
if provider == app.api_provider {
app.provider_identity_for_persistence()
} else {
provider.as_str()
}
});
if app.provider_model_is_enabled(provider_identity, &row.id)
|| app
.provider_models
@@ -1756,6 +1760,7 @@ fn model_row_enabled_for_app(app: &App, config: &Config, row: &ModelPickerRow) -
fn push_provider_model_rows(
rows: &mut Vec<ModelPickerRow>,
provider: ApiProvider,
provider_identity: Option<&str>,
model_ids: Vec<String>,
active_provider: ApiProvider,
config: &Config,
@@ -1781,9 +1786,26 @@ fn push_provider_model_rows(
None
};
let codex_freshness = roster_entry.map(|_| codex_roster.freshness);
let metadata =
effective_picker_metadata_with_codex(config, Some(provider), &id, codex_metadata);
let mut hint = render_picker_model_hint(&id, Some(provider), &metadata, codex_freshness);
let metadata = effective_picker_metadata_with_codex(
config,
Some(provider),
provider_identity,
&id,
codex_metadata,
);
let provider_catalog_receipt = provider_catalog_receipt_for_route(
provider,
provider_identity,
config,
metadata.source.as_ref(),
);
let mut hint = render_picker_model_hint(
&id,
Some(provider),
&metadata,
codex_freshness,
provider_catalog_receipt.as_ref(),
);
hint = format!("{readiness_label} · {hint}");
if provider != active_provider {
hint = format!("switch route · {hint}");
@@ -1793,6 +1815,7 @@ fn push_provider_model_rows(
rows,
id.clone(),
Some(provider),
provider_identity.map(str::to_string),
hint,
metadata,
selectable,
@@ -1801,6 +1824,40 @@ fn push_provider_model_rows(
}
}
fn provider_catalog_receipt_for_route(
provider: ApiProvider,
provider_identity: Option<&str>,
config: &Config,
source: Option<&CatalogSource>,
) -> Option<(CatalogStatus, bool)> {
let identity = provider_identity.unwrap_or_else(|| provider.as_str());
let owns_provider_catalog = matches!(
provider,
ApiProvider::Openrouter | ApiProvider::Telecomjs | ApiProvider::Edenai
) || (provider == ApiProvider::Custom
&& codewhale_config::provider_setup_template(identity)
.is_some_and(|template| template.is_compatible()));
if !owns_provider_catalog {
return None;
}
let base_url = config.base_url_for_route_identity(provider, identity);
let endpoint_matches = match source {
Some(CatalogSource::Live {
base_url_fingerprint,
..
}) => *base_url_fingerprint == codewhale_config::catalog::base_url_fingerprint(&base_url),
// A bundled/template fallback has no endpoint claim to compare. Its
// exact-scope status still matters: a first refresh failure must be
// visible even though no live row exists yet.
_ => true,
};
Some((
crate::provider_catalog_live::status_for_route(provider, identity, &base_url),
endpoint_matches,
))
}
fn push_auto_model_row(rows: &mut Vec<ModelPickerRow>, app: &App, config: &Config, hint: &str) {
let readiness = crate::provider_readiness::resolve_for_model(
config,
@@ -1815,6 +1872,7 @@ fn push_auto_model_row(rows: &mut Vec<ModelPickerRow>, app: &App, config: &Confi
rows,
"auto".to_string(),
None,
None,
format!("{} · {hint}", readiness.label()),
metadata,
selectable,
@@ -1985,21 +2043,30 @@ fn push_model_row(
rows: &mut Vec<ModelPickerRow>,
id: String,
provider: Option<ApiProvider>,
provider_identity: Option<String>,
hint: String,
metadata: EffectivePickerMetadata,
selectable: bool,
blocked_reason: Option<String>,
) {
if rows
.iter()
.any(|row| row.id == id && row.provider == provider)
{
if rows.iter().any(|row| {
row.id == id
&& row.provider == provider
&& match (
row.provider_identity.as_deref(),
provider_identity.as_deref(),
) {
(Some(left), Some(right)) => left == right,
(None, None) => true,
_ => false,
}
}) {
return;
}
rows.push(ModelPickerRow {
id,
provider,
provider_identity: None,
provider_identity,
hint,
metadata,
selectable,
@@ -2081,9 +2148,13 @@ fn normalize_picker_search_text(text: &str) -> String {
/// offer, the provider's own id — the `[providers.<id>]` key the user would
/// edit — supplies the discriminator, with the leading run it already shares
/// with the display name removed so the suffix is the part that differs.
fn route_labels_for_rows(rows: &[&ModelPickerRow]) -> BTreeMap<&'static str, String> {
fn route_labels_for_rows(rows: &[&ModelPickerRow]) -> BTreeMap<String, String> {
let mut by_display: BTreeMap<&'static str, Vec<ApiProvider>> = BTreeMap::new();
for provider in rows.iter().filter_map(|row| row.provider) {
for provider in rows
.iter()
.filter_map(|row| row.provider)
.filter(|provider| *provider != ApiProvider::Custom)
{
let bucket = by_display.entry(provider.display_name()).or_default();
if !bucket.contains(&provider) {
bucket.push(provider);
@@ -2101,9 +2172,21 @@ fn route_labels_for_rows(rows: &[&ModelPickerRow]) -> BTreeMap<&'static str, Str
// distinct.
Some(None) | None => display.to_string(),
};
labels.insert(provider.as_str(), label);
labels.insert(provider.as_str().to_string(), label);
}
}
for row in rows
.iter()
.filter(|row| row.provider == Some(ApiProvider::Custom))
{
let Some(identity) = row_provider_identity(row) else {
continue;
};
let label = codewhale_config::provider_setup_template(identity)
.map(|template| template.display_name.to_string())
.unwrap_or_else(|| identity.to_string());
labels.entry(identity.to_string()).or_insert(label);
}
labels
}
@@ -2156,11 +2239,15 @@ fn route_discriminator(display: &str, provider_id: &str) -> Option<String> {
/// DeepSeek has published both `deepseek` and `deepseek-thinking` as family
/// values for its current V4 models, so keep its picker heading stable and
/// provider-facing rather than exposing either implementation detail.
fn catalog_family_for(provider: ApiProvider, model_id: &str) -> Option<String> {
fn catalog_family_for_identity(
provider: ApiProvider,
provider_identity: Option<&str>,
model_id: &str,
) -> Option<String> {
if provider == ApiProvider::Deepseek {
return Some(provider.display_name().to_string());
}
crate::provider_lake::catalog_offering_for_model(provider, model_id)
catalog_offering_for_model_identity(provider, provider_identity, model_id)
.and_then(|offering| offering.family)
}
@@ -2405,7 +2492,9 @@ fn row_group_key(row: &ModelPickerRow) -> (String, String) {
.unwrap_or_default();
let family = row
.provider
.and_then(|provider| catalog_family_for(provider, &row.id))
.and_then(|provider| {
catalog_family_for_identity(provider, row.provider_identity.as_deref(), &row.id)
})
.unwrap_or_default()
.to_ascii_lowercase();
(provider, family)
@@ -2449,7 +2538,7 @@ fn row_provider_identity(row: &ModelPickerRow) -> Option<&str> {
fn offering_for_row(row: &ModelPickerRow) -> Option<codewhale_config::catalog::CatalogOffering> {
let provider = row.provider?;
catalog_offering_for_model(provider, &row.id)
catalog_offering_for_model_identity(provider, row.provider_identity.as_deref(), &row.id)
}
fn offering_fetched_at(row: &ModelPickerRow) -> u64 {
@@ -2506,16 +2595,32 @@ fn effective_picker_metadata(
provider: Option<ApiProvider>,
id: &str,
) -> EffectivePickerMetadata {
effective_picker_metadata_with_codex(config, provider, id, None)
effective_picker_metadata_for_identity(config, provider, None, id)
}
fn effective_picker_metadata_for_identity(
config: &Config,
provider: Option<ApiProvider>,
provider_identity: Option<&str>,
id: &str,
) -> EffectivePickerMetadata {
effective_picker_metadata_with_codex(config, provider, provider_identity, id, None)
}
fn effective_picker_metadata_with_codex(
config: &Config,
provider: Option<ApiProvider>,
provider_identity: Option<&str>,
id: &str,
codex_metadata: Option<&CodexModelMetadata>,
) -> EffectivePickerMetadata {
let offering = provider.and_then(|provider| catalog_offering_for_model(provider, id));
let offering = provider.and_then(|provider| {
let identity = provider_identity
.map(str::to_string)
.unwrap_or_else(|| config.provider_identity_for(provider));
let base_url = config.base_url_for_route_identity(provider, &identity);
crate::provider_lake::catalog_offering_for_route(provider, &identity, &base_url, id)
});
let card = offering.as_ref().map(ModelReferenceCard::from_offering);
let registry = model_registry::lookup(id);
@@ -2539,7 +2644,31 @@ fn effective_picker_metadata_with_codex(
};
};
let context_override = config.context_window_for_provider_config(provider);
let identity = provider_identity
.map(str::to_string)
.unwrap_or_else(|| config.provider_identity_for(provider));
let context_override = if provider == ApiProvider::Custom {
config
.providers
.as_ref()
.and_then(|providers| providers.custom_provider_config(&identity))
.and_then(|entry| entry.context_window)
.filter(|window| *window > 0)
} else {
config.context_window_for_provider_config(provider)
};
let base_url = config.base_url_for_route_identity(provider, &identity);
if offering.is_none()
&& provider != ApiProvider::OpenaiCodex
&& provider.kind().is_none_or(|kind| {
codewhale_config::provider_preserves_custom_base_url_model(kind, &base_url)
})
{
return EffectivePickerMetadata {
context_window: context_override,
..EffectivePickerMetadata::default()
};
}
let overrides = CapabilityOverride {
context_window: context_override,
..CapabilityOverride::default()
@@ -2666,6 +2795,7 @@ fn render_picker_model_hint(
provider: Option<ApiProvider>,
metadata: &EffectivePickerMetadata,
codex_freshness: Option<CodexModelCacheFreshness>,
provider_catalog_receipt: Option<&(CatalogStatus, bool)>,
) -> String {
debug_assert_ne!(id, "auto", "Auto rows use the context-aware picker hint");
@@ -2753,12 +2883,14 @@ fn render_picker_model_hint(
PickerPricing::Known(label) => parts.push(label.clone()),
PickerPricing::Unknown => parts.push("price unknown".to_string()),
}
let provider_live_source = matches!(metadata.source.as_ref(), Some(CatalogSource::Live { .. }));
match metadata.source.as_ref() {
Some(
CatalogSource::Live { .. }
| CatalogSource::ModelsDevLive { .. }
| CatalogSource::CodewhaleLive { .. },
) => parts.push("live".to_string()),
Some(CatalogSource::Live { .. }) => {
parts.push(provider_catalog_source_label(provider_catalog_receipt))
}
Some(CatalogSource::ModelsDevLive { .. } | CatalogSource::CodewhaleLive { .. }) => {
parts.push("live".to_string())
}
Some(CatalogSource::Bundled | CatalogSource::CodewhaleBundled { .. }) => {
parts.push("bundled".to_string())
}
@@ -2767,6 +2899,14 @@ fn render_picker_model_hint(
}
None => {}
}
if !provider_live_source
&& let Some((CatalogStatus::Failed { reason }, _)) = provider_catalog_receipt
{
parts.push(format!(
"refresh failed ({})",
catalog_refresh_error_label(*reason)
));
}
if provider == Some(ApiProvider::OpenaiCodex) {
parts.push(match codex_freshness {
Some(freshness) => freshness.picker_label().to_string(),
@@ -2781,6 +2921,38 @@ fn render_picker_model_hint(
}
}
fn provider_catalog_source_label(receipt: Option<&(CatalogStatus, bool)>) -> String {
let Some((status, endpoint_matches)) = receipt else {
return "catalog freshness unknown".to_string();
};
if !endpoint_matches {
return "catalog from different endpoint".to_string();
}
match status {
CatalogStatus::Fresh => "live".to_string(),
CatalogStatus::Stale { age_secs } => {
let age_hours = age_secs.saturating_add(3_599) / 3_600;
format!("stale catalog ({age_hours}h)")
}
CatalogStatus::Failed { reason } => {
format!("refresh failed ({})", catalog_refresh_error_label(*reason))
}
CatalogStatus::Unknown => "catalog freshness unknown".to_string(),
}
}
fn catalog_refresh_error_label(error: CatalogRefreshError) -> &'static str {
match error {
CatalogRefreshError::Unauthorized => "unauthorized",
CatalogRefreshError::Forbidden => "forbidden",
CatalogRefreshError::NotFound => "not found",
CatalogRefreshError::RateLimited => "rate limited",
CatalogRefreshError::InvalidResponse => "invalid response",
CatalogRefreshError::EmptyList => "empty list",
CatalogRefreshError::Network => "network error",
}
}
pub(crate) fn format_picker_context_window(tokens: u64) -> String {
if tokens >= 1_000_000 {
if tokens.is_multiple_of(1_000_000) {
@@ -3530,7 +3702,7 @@ mod tests {
#[test]
fn deepseek_picker_heading_hides_legacy_family_metadata() {
assert_eq!(
catalog_family_for(ApiProvider::Deepseek, "deepseek-v4-pro").as_deref(),
catalog_family_for_identity(ApiProvider::Deepseek, None, "deepseek-v4-pro").as_deref(),
Some("DeepSeek")
);
}
@@ -3905,4 +4077,136 @@ mod tests {
}
}
}
#[test]
fn baseten_picker_models_use_exact_identity_and_direct_provider_label() {
let _live = crate::provider_lake::lock_live_snapshot();
crate::provider_lake::clear_live_snapshot();
let models = provider_catalog_model_ids(
ApiProvider::Custom,
codewhale_config::BASETEN_TEMPLATE_ID,
codewhale_config::BASETEN_BASE_URL,
);
assert_eq!(
models,
vec![codewhale_config::BASETEN_DEFAULT_MODEL.to_string()]
);
assert!(models.contains(&codewhale_config::BASETEN_DEFAULT_MODEL.to_string()));
let row = ModelPickerRow {
id: codewhale_config::BASETEN_DEFAULT_MODEL.to_string(),
provider: Some(ApiProvider::Custom),
provider_identity: Some(codewhale_config::BASETEN_TEMPLATE_ID.to_string()),
hint: String::new(),
metadata: EffectivePickerMetadata::default(),
selectable: true,
blocked_reason: None,
enabled: true,
};
let labels = route_labels_for_rows(&[&row]);
assert_eq!(labels.get("baseten").map(String::as_str), Some("Baseten"));
}
#[test]
fn provider_catalog_hint_never_calls_failed_or_mismatched_rows_live() {
assert_eq!(
provider_catalog_source_label(Some(&(CatalogStatus::Fresh, true))),
"live"
);
assert_eq!(
provider_catalog_source_label(Some(&(
CatalogStatus::Failed {
reason: CatalogRefreshError::Unauthorized,
},
true,
))),
"refresh failed (unauthorized)"
);
assert_eq!(
provider_catalog_source_label(Some(&(CatalogStatus::Fresh, false))),
"catalog from different endpoint"
);
assert_eq!(
provider_catalog_source_label(None),
"catalog freshness unknown"
);
}
#[test]
fn first_provider_catalog_failure_is_visible_on_bundled_fallback_rows() {
let _env = crate::test_support::lock_test_env();
let _live = crate::provider_lake::lock_live_snapshot();
let home = tempfile::tempdir().expect("test home");
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
crate::provider_catalog_live::reset_cache_for_test();
crate::provider_lake::clear_live_snapshot();
let config = Config {
provider: Some("openrouter".to_string()),
..Config::default()
};
let base_url = config.base_url_for_route_identity(ApiProvider::Openrouter, "openrouter");
let fingerprint = codewhale_config::catalog::base_url_fingerprint(&base_url);
crate::provider_catalog_live::record_failure(
"openrouter",
&fingerprint,
CatalogRefreshError::Unauthorized,
);
let model = provider_catalog_model_ids(
ApiProvider::Openrouter,
ApiProvider::Openrouter.as_str(),
crate::config::DEFAULT_OPENROUTER_BASE_URL,
)
.into_iter()
.next()
.expect("bundled OpenRouter fallback");
let mut rows = Vec::new();
let codex_roster = CodexModelRoster {
models: Vec::new(),
freshness: CodexModelCacheFreshness::Missing,
fetched_at: None,
observed_at: None,
observation_persisted: false,
source: "codex_cli_cache",
};
push_provider_model_rows(
&mut rows,
ApiProvider::Openrouter,
None,
vec![model],
ApiProvider::Openrouter,
&config,
&codex_roster,
&crate::provider_readiness::ProviderReadinessSnapshot::default(),
);
assert_eq!(rows.len(), 1);
assert!(
rows[0].hint.contains("refresh failed (unauthorized)"),
"{}",
rows[0].hint
);
crate::provider_catalog_live::reset_cache_for_test();
crate::provider_lake::clear_live_snapshot();
}
#[test]
fn model_rows_keep_case_distinct_custom_identities() {
let mut rows = Vec::new();
for identity in ["CustomA", "customa"] {
push_model_row(
&mut rows,
"shared-model".to_string(),
Some(ApiProvider::Custom),
Some(identity.to_string()),
String::new(),
EffectivePickerMetadata::default(),
true,
None,
);
}
assert_eq!(rows.len(), 2);
}
}
+2
View File
@@ -1392,6 +1392,7 @@ mod tests {
billing_surface: None,
endpoint_fingerprint: None,
openrouter_vendor: None,
provider_live_pricing: None,
billing_mode: crate::cost_status::RouteBillingMode::Unknown,
dispatched_at: chrono::Utc::now(),
}),
@@ -1739,6 +1740,7 @@ mod tests {
billing_surface: None,
endpoint_fingerprint: None,
openrouter_vendor: None,
provider_live_pricing: None,
billing_mode: crate::cost_status::RouteBillingMode::Unknown,
dispatched_at: chrono::Utc::now(),
}),
+6 -3
View File
@@ -1376,7 +1376,10 @@ mod tests {
&MailboxMessage::TokenUsage {
agent_id: "agent_route".to_string(),
source_id: "response-route".to_string(),
route: test_route(crate::config::ApiProvider::Openrouter, "vendor/model-real"),
route: Box::new(test_route(
crate::config::ApiProvider::Openrouter,
"vendor/model-real",
)),
usage: crate::models::Usage::default(),
},
);
@@ -1398,7 +1401,7 @@ mod tests {
&MailboxMessage::TokenUsage {
agent_id: "agent_spend".to_string(),
source_id: "response-1".to_string(),
route: route.clone(),
route: Box::new(route.clone()),
usage: crate::models::Usage {
input_tokens: 1_000,
output_tokens: 40,
@@ -1412,7 +1415,7 @@ mod tests {
&MailboxMessage::TokenUsage {
agent_id: "agent_spend".to_string(),
source_id: "response-2".to_string(),
route,
route: Box::new(route),
usage: crate::models::Usage {
input_tokens: 2_000,
output_tokens: 60,
+35 -2
View File
@@ -454,6 +454,32 @@ fn accrue_child_token_cost_if_any(app: &mut App, result: &Result<ToolResult, Too
let Some(metadata) = tool_result.metadata.as_ref() else {
return;
};
if let Some(batch) = crate::cost_status::child_usage_records_from_metadata(metadata) {
for record in &batch.records {
accrue_child_route_usage(app, &record.usage);
}
for record in &batch.drop_records {
let pending = crate::cost_status::background_cost_for_runtime_drop(record);
app.absorb_pending_background_cost(&pending);
}
let residual_dropped_records = batch
.dropped_records
.saturating_sub(u64::try_from(batch.drop_records.len()).unwrap_or(u64::MAX));
if residual_dropped_records > 0 {
let dropped = u32::try_from(residual_dropped_records).unwrap_or(u32::MAX);
app.session.cost_unpriced_turns =
app.session.cost_unpriced_turns.saturating_add(dropped);
app.session.cost_cny_unpriced_turns =
app.session.cost_cny_unpriced_turns.saturating_add(dropped);
app.session
.cost_unpriced_reasons
.insert("routed_usage_receipt_missing".to_string());
app.session
.cost_cny_unpriced_reasons
.insert("routed_usage_receipt_missing".to_string());
}
return;
}
let Some(route) = crate::cost_status::child_route_envelope_from_metadata(metadata) else {
return;
};
@@ -464,6 +490,13 @@ fn accrue_child_token_cost_if_any(app: &mut App, result: &Result<ToolResult, Too
let Some(usage) = crate::cost_status::child_usage_from_metadata(metadata) else {
return;
};
accrue_child_route_usage(
app,
&crate::cost_status::EffectiveRouteUsage { route, usage },
);
}
fn accrue_child_route_usage(app: &mut App, routed: &crate::cost_status::EffectiveRouteUsage) {
// `route` is the child's own dispatch receipt, rehydrated from the
// complete `child_*` metadata `attach_child_usage_metadata` emits at the
// child's wire boundary (review/verify/rlm are the three producers). An
@@ -474,9 +507,9 @@ fn accrue_child_token_cost_if_any(app: &mut App, result: &Result<ToolResult, Too
// Sub-agent spend lands in the same displayed total as parent turns, so it
// has to feed the same completeness counters — otherwise `/cost` would call
// a total complete while an unpriced child turn is missing from it.
let audit = route.audit(&usage);
let audit = routed.route.audit(&routed.usage);
app.record_turn_cost_audit(&audit);
app.record_turn_cost_route_receipt(route.receipt(&audit));
app.record_turn_cost_route_receipt(routed.route.receipt(&audit));
if let Some(cost) = audit.estimate {
app.accrue_subagent_cost_estimate(cost);
}
+6 -26
View File
@@ -10,14 +10,12 @@
//!
//! - `needs_translation()` — heuristic to detect if text is predominantly
//! English and should be translated.
//! - `translate_text()` — calls the current session model through a
//! shared `DeepSeekClient` to translate text to the current locale. The dedicated
//! translation agent receives only the source text and returns only the
//! translation — no tool calls, no conversation history.
use anyhow::Result;
use crate::client::DeepSeekClient;
//!
//! The event loop dispatches focused translation requests through the exact
//! provider/model client frozen for the originating turn. The dedicated agent
//! receives only the source text and returns only the translation — no tool
//! calls or conversation history.
//!
/// Heuristic threshold: if more than this fraction of alphabetic characters
/// are Latin (A-Z / a-z), the text is considered English.
@@ -81,24 +79,6 @@ fn is_cjk(ch: char) -> bool {
)
}
/// Translate text to the requested target language using a dedicated
/// translation agent.
///
/// This is a lightweight, focused API call — no streaming, no tool calls,
/// no conversation history. The agent's only role is translation.
///
/// # Errors
///
/// Returns an error if the API call fails or the response is malformed.
pub async fn translate_text(
text: &str,
client: &DeepSeekClient,
model: &str,
target_language: &str,
) -> Result<String> {
client.translate(text, model, target_language).await
}
#[cfg(test)]
mod tests {
use super::*;
+6
View File
@@ -268,15 +268,21 @@ type PendingToolUses = Vec<(String, String, serde_json::Value)>;
#[derive(Debug)]
enum TranslationEvent {
AssistantMessage {
origin_session_fingerprint: Option<String>,
origin_turn_fingerprint: Option<String>,
history_index: Option<usize>,
original_text: String,
translated: anyhow::Result<String>,
usage: Option<crate::models::Usage>,
thinking: Option<String>,
tool_uses: PendingToolUses,
},
Thinking {
origin_session_fingerprint: Option<String>,
origin_turn_fingerprint: Option<String>,
placeholder: String,
translated: anyhow::Result<String>,
usage: Option<crate::models::Usage>,
},
}
+12
View File
@@ -712,6 +712,10 @@ pub(crate) async fn spawned_dispatch_execute(
completion_permit.send(apply);
}
pub(super) fn settle_failed_dispatch_routed_usage(batch: &crate::cost_status::RuntimeUsageBatch) {
crate::cost_status::report_runtime_usage_batch(crate::cost_status::scope_token(), None, batch);
}
pub(crate) async fn spawned_dispatch_inner(
prepare: UserDispatchPrepare,
recovery: DispatchRecovery,
@@ -754,6 +758,7 @@ pub(crate) async fn spawned_dispatch_inner(
effective_reasoning_effort,
auto_controls_reasoning,
auto_selection,
initial_routed_usage,
routing_source: _,
} = planned;
let effective_reasoning_tier = selected_reasoning_effort
@@ -770,12 +775,18 @@ pub(crate) async fn spawned_dispatch_inner(
&turn_route.model,
);
// Retain one fallback copy until mailbox acceptance. The classifier has
// already run; if the engine channel closes now, its exact spend still
// belongs to this interactive session rather than disappearing with the
// unstarted parent turn.
let send_failure_routed_usage = initial_routed_usage.clone();
if let Err(err) = engine_handle
.send(Op::SendMessage {
content: prepare.content.clone(),
mode: prepare.mode,
route: Box::new(turn_route),
compaction: Box::new(turn_compaction.clone()),
initial_routed_usage: Box::new(initial_routed_usage),
goal_objective: prepare.goal_objective.clone(),
goal_token_budget: prepare.goal_token_budget,
goal_status: prepare.goal_status,
@@ -795,6 +806,7 @@ pub(crate) async fn spawned_dispatch_inner(
})
.await
{
settle_failed_dispatch_routed_usage(&send_failure_routed_usage);
return build_dispatch_error_closure(prepare, recovery, err.to_string());
}
+533 -38
View File
@@ -113,6 +113,198 @@ fn current_session_fleet_workers_status(
.replace("{count}", &count.to_string())
}
#[derive(Debug)]
struct TranslationAccountingContext {
cost_scope: crate::cost_status::CostScopeToken,
origin_session_id: Option<String>,
origin_turn_id: Option<String>,
source_id: String,
}
struct SettledTranslation {
translated: anyhow::Result<String>,
usage: Option<crate::models::Usage>,
}
impl TranslationAccountingContext {
fn capture(app: &App, kind: &str, sequence: u64) -> Self {
let raw_source = format!(
"translation:{}:{}:{kind}:{sequence}",
app.current_session_id.as_deref().unwrap_or("no-session"),
app.runtime_turn_id.as_deref().unwrap_or("no-turn")
);
Self {
cost_scope: crate::cost_status::scope_token(),
origin_session_id: app.current_session_id.clone(),
origin_turn_id: app.runtime_turn_id.clone(),
source_id: format!(
"translation:{}",
crate::cost_status::usage_source_fingerprint(&raw_source)
),
}
}
fn settle(
self,
response: anyhow::Result<crate::client::TranslationProviderResponse>,
) -> SettledTranslation {
let response = match response {
Ok(response) => response,
Err(error) => {
return SettledTranslation {
translated: Err(error),
usage: None,
};
}
};
if let Some(usage) = response.usage.as_ref() {
if let (Some(session_id), Some(turn_id)) = (
self.origin_session_id.as_deref(),
self.origin_turn_id.as_deref(),
) {
crate::cost_status::report_effective_route_for_interactive_origin(
self.cost_scope,
session_id,
turn_id,
&self.source_id,
&response.route,
usage,
);
} else {
crate::cost_status::report_effective_route_for_runtime(
self.cost_scope,
None,
&self.source_id,
&response.route,
usage,
);
}
} else {
if let (Some(session_id), Some(turn_id)) = (
self.origin_session_id.as_deref(),
self.origin_turn_id.as_deref(),
) {
crate::cost_status::report_unreceipted_for_interactive_origin(
self.cost_scope,
session_id,
turn_id,
&self.source_id,
&response.route,
);
} else {
crate::cost_status::report_unreceipted_provider_success(
self.cost_scope,
None,
&self.source_id,
&response.route,
);
}
}
SettledTranslation {
translated: response.translated,
usage: response.usage,
}
}
}
fn accrue_translation_usage(app: &mut App, usage: &crate::models::Usage) {
let turn_tokens = usage.input_tokens.saturating_add(usage.output_tokens);
app.session.total_tokens = app.session.total_tokens.saturating_add(turn_tokens);
app.session.total_conversation_tokens = app
.session
.total_conversation_tokens
.saturating_add(turn_tokens);
app.session.total_input_tokens = app
.session
.total_input_tokens
.saturating_add(usage.input_tokens);
app.session.total_output_tokens = app
.session
.total_output_tokens
.saturating_add(usage.output_tokens);
if usage.prompt_cache_hit_tokens.is_some()
|| usage.prompt_cache_miss_tokens.is_some()
|| usage.prompt_cache_write_tokens.is_some()
{
let classes = crate::pricing::token_usage_for_pricing(usage);
app.session.total_cache_hit_tokens = app
.session
.total_cache_hit_tokens
.saturating_add(u32::try_from(classes.cache_read).unwrap_or(u32::MAX));
app.session.total_cache_miss_tokens = app
.session
.total_cache_miss_tokens
.saturating_add(u32::try_from(classes.input).unwrap_or(u32::MAX));
app.session.total_cache_write_tokens = app
.session
.total_cache_write_tokens
.saturating_add(u32::try_from(classes.cache_write).unwrap_or(u32::MAX));
}
}
fn translation_origin(app: &App) -> (Option<String>, Option<String>) {
// Fixed-size one-way identities avoid retaining raw imported ids in a
// detached completion envelope without introducing truncation aliases.
let fingerprint = |value: Option<&str>| value.map(crate::cost_status::usage_source_fingerprint);
(
fingerprint(app.current_session_id.as_deref()),
fingerprint(app.runtime_turn_id.as_deref()),
)
}
fn translation_origin_is_current(
app: &App,
origin_session_fingerprint: Option<&str>,
origin_turn_fingerprint: Option<&str>,
) -> bool {
let current = translation_origin(app);
current.0.as_deref() == origin_session_fingerprint
&& current.1.as_deref() == origin_turn_fingerprint
}
fn translation_session_is_current(app: &App, origin_session_fingerprint: Option<&str>) -> bool {
translation_origin(app).0.as_deref() == origin_session_fingerprint
}
fn exact_translation_client(
config: &Config,
route: &crate::core::events::TurnRoute,
) -> anyhow::Result<Arc<DeepSeekClient>> {
let identity = config
.resolve_persisted_provider_identity(
Some(route.provider.as_str()),
Some(&route.provider_identity),
)
.map_err(anyhow::Error::msg)?;
let validated = crate::route_runtime::resolve_runtime_route_for_identity(
config,
&identity,
Some(&route.model),
)
.map_err(anyhow::Error::msg)?
.validate()
.map_err(anyhow::Error::msg)?;
if validated.identity.key != route.provider_identity
|| validated.model != route.model
|| validated.candidate.endpoint().base_url != route.base_url
{
anyhow::bail!(
"translation route changed after turn dispatch; refusing to reuse a different provider client"
);
}
if let Some(receipt) = route.receipt.as_ref()
&& &validated
.client
.turn_route_receipt(&route.provider_identity)
!= receipt
{
anyhow::bail!(
"translation credential or endpoint changed after turn dispatch; refusing stale completion ownership"
);
}
Ok(Arc::new(validated.client))
}
/// Bind the Runtime thread store to a session before the process-owner lock
/// is taken, so a second Codewhale on the same machine does not collide on
/// the default root (#5630). Resume reuses the loaded id; a fresh session
@@ -1259,6 +1451,10 @@ pub(crate) async fn run_event_loop(
let mut stream_display_clock = StreamDisplayClock::default();
let (translation_tx, mut translation_rx) =
tokio::sync::mpsc::unbounded_channel::<TranslationEvent>();
let fallback_translation_client = translation_client;
let mut active_translation_client = fallback_translation_client.clone();
let mut active_translation_route: Option<crate::core::events::TurnRoute> = None;
let mut translation_sequence = 0_u64;
let mut pending_translations = 0usize;
// #5931: the background runtime's own store faults arrive on its event
// channel, which nothing else in this loop reads.
@@ -1459,14 +1655,31 @@ pub(crate) async fn run_event_loop(
while let Ok(event) = translation_rx.try_recv() {
match event {
TranslationEvent::AssistantMessage {
origin_session_fingerprint,
origin_turn_fingerprint,
history_index,
original_text,
translated,
usage,
thinking,
tool_uses,
} => {
pending_translations = pending_translations.saturating_sub(1);
pending_thinking_translations = pending_thinking_translations.saturating_sub(1);
if translation_session_is_current(app, origin_session_fingerprint.as_deref())
&& let Some(usage) = usage.as_ref()
{
accrue_translation_usage(app, usage);
}
if !translation_origin_is_current(
app,
origin_session_fingerprint.as_deref(),
origin_turn_fingerprint.as_deref(),
) {
tracing::debug!(
"discarded assistant translation completed for a stale session/turn"
);
continue;
}
let text = match translated {
Ok(text) => {
app.status_message = Some(
@@ -1511,10 +1724,29 @@ pub(crate) async fn run_event_loop(
app.needs_redraw = true;
}
TranslationEvent::Thinking {
origin_session_fingerprint,
origin_turn_fingerprint,
placeholder,
translated,
usage,
} => {
pending_translations = pending_translations.saturating_sub(1);
pending_thinking_translations = pending_thinking_translations.saturating_sub(1);
if translation_session_is_current(app, origin_session_fingerprint.as_deref())
&& let Some(usage) = usage.as_ref()
{
accrue_translation_usage(app, usage);
}
if !translation_origin_is_current(
app,
origin_session_fingerprint.as_deref(),
origin_turn_fingerprint.as_deref(),
) {
tracing::debug!(
"discarded thinking translation completed for a stale session/turn"
);
continue;
}
let text = match translated {
Ok(text) => {
app.status_message = Some(
@@ -1780,7 +2012,7 @@ pub(crate) async fn run_event_loop(
if app.translation_enabled
&& !current_streaming_text.is_empty()
&& crate::tui::translation::needs_translation(&current_streaming_text)
&& let Some(translation_client) = translation_client.as_ref()
&& let Some(translation_client) = active_translation_client.as_ref()
{
app.status_message = Some(
crate::localization::tr(
@@ -1794,24 +2026,38 @@ pub(crate) async fn run_event_loop(
let tx = translation_tx.clone();
let client = translation_client.clone();
let original_text = current_streaming_text.clone();
let translation_model = app
.last_effective_model
.clone()
let translation_model = active_translation_route
.as_ref()
.map(|route| route.model.clone())
.or_else(|| app.last_effective_model.clone())
.unwrap_or_else(|| app.model.clone());
translation_sequence = translation_sequence.saturating_add(1);
let accounting = TranslationAccountingContext::capture(
app,
"assistant",
translation_sequence,
);
let (origin_session_fingerprint, origin_turn_fingerprint) =
translation_origin(app);
let target_language =
app.ui_locale.translation_target_name().to_string();
tokio::spawn(async move {
let translated = crate::tui::translation::translate_text(
&original_text,
&client,
&translation_model,
&target_language,
)
.await;
let settled = accounting.settle(
client
.translate_with_usage(
&original_text,
&translation_model,
&target_language,
)
.await,
);
let _ = tx.send(TranslationEvent::AssistantMessage {
origin_session_fingerprint,
origin_turn_fingerprint,
history_index,
original_text,
translated,
translated: settled.translated,
usage: settled.usage,
thinking,
tool_uses,
});
@@ -1868,7 +2114,7 @@ pub(crate) async fn run_event_loop(
}
if !original_thinking.is_empty()
&& crate::tui::translation::needs_translation(&original_thinking)
&& let Some(translation_client) = translation_client.as_ref()
&& let Some(translation_client) = active_translation_client.as_ref()
{
app.status_message = Some(
crate::localization::thinking_translation_in_progress(
@@ -1882,10 +2128,19 @@ pub(crate) async fn run_event_loop(
pending_thinking_translations.saturating_add(1);
let tx = translation_tx.clone();
let client = translation_client.clone();
let translation_model = app
.last_effective_model
.clone()
let translation_model = active_translation_route
.as_ref()
.map(|route| route.model.clone())
.or_else(|| app.last_effective_model.clone())
.unwrap_or_else(|| app.model.clone());
translation_sequence = translation_sequence.saturating_add(1);
let accounting = TranslationAccountingContext::capture(
app,
"thinking",
translation_sequence,
);
let (origin_session_fingerprint, origin_turn_fingerprint) =
translation_origin(app);
let placeholder =
crate::localization::thinking_translation_placeholder(
app.ui_locale,
@@ -1894,16 +2149,21 @@ pub(crate) async fn run_event_loop(
let target_language =
app.ui_locale.translation_target_name().to_string();
tokio::spawn(async move {
let translated = crate::tui::translation::translate_text(
&original_thinking,
&client,
&translation_model,
&target_language,
)
.await;
let settled = accounting.settle(
client
.translate_with_usage(
&original_thinking,
&translation_model,
&target_language,
)
.await,
);
let _ = tx.send(TranslationEvent::Thinking {
origin_session_fingerprint,
origin_turn_fingerprint,
placeholder,
translated,
translated: settled.translated,
usage: settled.usage,
});
});
} else {
@@ -2061,7 +2321,7 @@ pub(crate) async fn run_event_loop(
subagent_list_refresh_requested = true;
}
}
EngineEvent::TurnStarted { turn_id, .. } => {
EngineEvent::TurnStarted { turn_id, route, .. } => {
// A prior turn that died without its `TurnComplete`
// must not leak its provisional estimate into this one.
app.clear_pending_turn_cost();
@@ -2101,6 +2361,19 @@ pub(crate) async fn run_event_loop(
if app.status_message.is_none() {
app.status_message = Some("Press Esc or Ctrl+C to cancel".to_string());
}
active_translation_client = match route.as_ref() {
Some(route) => match exact_translation_client(config, route) {
Ok(client) => Some(client),
Err(error) => {
tracing::warn!(
"translation client rejected the frozen turn route: {error}"
);
None
}
},
None => fallback_translation_client.clone(),
};
active_translation_route = route;
app.runtime_turn_id = Some(turn_id);
app.runtime_turn_status = Some("in_progress".to_string());
app.turn_counter = app.turn_counter.saturating_add(1);
@@ -2130,9 +2403,26 @@ pub(crate) async fn run_event_loop(
EngineEvent::ToolRequestSnapshot { snapshot } => {
app.session.last_tool_request_snapshot = Some(snapshot);
}
EngineEvent::RouteDispatched { .. } => {}
EngineEvent::RouteDispatched { turn_id, route } => {
if app.runtime_turn_id.as_deref() == Some(turn_id.as_str()) {
active_translation_client = match exact_translation_client(
config, &route,
) {
Ok(client) => Some(client),
Err(error) => {
tracing::warn!(
"translation client rejected the dispatched turn route: {error}"
);
None
}
};
active_translation_route = Some(route);
}
}
EngineEvent::TurnComplete {
usage,
parent_route_usage,
routed_usage_dropped_records,
status,
error,
tool_catalog,
@@ -2353,19 +2643,19 @@ pub(crate) async fn run_event_loop(
.as_ref()
.and_then(|turn| turn.route.as_ref())
.and_then(crate::core::events::TurnRoute::cost_envelope)
.map(|route| route.audit(&usage));
.map(|route| route.audit(&parent_route_usage));
app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {
provider,
provider_identity,
model,
auto_model,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cache_hit_tokens: usage.prompt_cache_hit_tokens,
cache_miss_tokens: usage.prompt_cache_miss_tokens,
reasoning_replay_tokens: usage.reasoning_replay_tokens,
cache_write_tokens: usage.prompt_cache_write_tokens,
reasoning_tokens: usage.reasoning_tokens,
input_tokens: parent_route_usage.input_tokens,
output_tokens: parent_route_usage.output_tokens,
cache_hit_tokens: parent_route_usage.prompt_cache_hit_tokens,
cache_miss_tokens: parent_route_usage.prompt_cache_miss_tokens,
reasoning_replay_tokens: parent_route_usage.reasoning_replay_tokens,
cache_write_tokens: parent_route_usage.prompt_cache_write_tokens,
reasoning_tokens: parent_route_usage.reasoning_tokens,
cost_audit: cost_audit.clone(),
recorded_at: Instant::now(),
});
@@ -2384,8 +2674,9 @@ pub(crate) async fn run_event_loop(
// *not* cover so `/cost` can stay honest about it.
//
// `cost_audit` above came from `cost_envelope()`, i.e.
// the billing envelope stamped at the wire boundary
// and classified from this turn's frozen receipt. It
// the billing envelope frozen at CodeWhale's
// pre-permit application-dispatch boundary and
// classified from this turn's frozen receipt. It
// is `None` for a route that was never dispatched, and
// a route whose receipt named no product classified as
// Unknown — either way nothing accrues. A `/provider`
@@ -2408,6 +2699,20 @@ pub(crate) async fn run_event_loop(
if let Some(cost) = turn_cost {
app.accrue_session_cost_estimate(cost);
}
if routed_usage_dropped_records > 0 {
let dropped =
u32::try_from(routed_usage_dropped_records).unwrap_or(u32::MAX);
app.session.cost_unpriced_turns =
app.session.cost_unpriced_turns.saturating_add(dropped);
app.session.cost_cny_unpriced_turns =
app.session.cost_cny_unpriced_turns.saturating_add(dropped);
app.session
.cost_unpriced_reasons
.insert("routed_usage_receipt_missing".to_string());
app.session
.cost_cny_unpriced_reasons
.insert("routed_usage_receipt_missing".to_string());
}
// Emit OSC 9 / BEL desktop notification for long turns, and
// always stop the title animation that began on TurnStarted.
@@ -3657,6 +3962,24 @@ pub(crate) async fn run_event_loop(
}
app.session.accrue_pending_turn_usage(&usage);
}
EngineEvent::RoutedTurnUsage {
usage,
duration_ms,
first_token_ms,
request_ms,
} => {
// Routed calls own separate immutable cost receipts.
// Preserve model-call telemetry without pricing them
// provisionally under the active parent route or
// incrementally adding tokens that TurnComplete will
// reconcile authoritatively.
app.session_metrics.record_model_call(
usage.output_tokens,
duration_ms,
first_token_ms,
request_ms,
);
}
EngineEvent::AdvisoryNote { note, .. } => {
// Advisor background watcher note. Display as a
// concise system message in the transcript so the
@@ -6666,6 +6989,178 @@ mod session_boot_event_tests {
Some("fresh")
);
}
fn translation_test_route() -> crate::cost_status::EffectiveRouteEnvelope {
crate::cost_status::EffectiveRouteEnvelope {
provider: crate::config::ApiProvider::Deepseek,
provider_identity: "deepseek".to_string(),
model: "deepseek-chat".to_string(),
openrouter_vendor: None,
billing_surface: crate::pricing::billing_surface_for_route(
crate::config::ApiProvider::Deepseek,
Some("https://api.deepseek.com/v1"),
)
.map(str::to_string),
endpoint_fingerprint: crate::cost_status::endpoint_fingerprint(
"https://api.deepseek.com/v1",
),
provider_live_pricing: None,
billing_mode: crate::cost_status::RouteBillingMode::Metered,
dispatched_at: chrono::Utc::now(),
}
}
#[test]
fn assistant_and_thinking_translation_usage_each_accrue_once() {
let _scope = crate::cost_status::test_scope();
let mut app = test_app();
app.current_session_id = Some("session-translation".to_string());
app.runtime_turn_id = Some("turn-translation".to_string());
let usage_a = crate::models::Usage {
input_tokens: 5,
output_tokens: 2,
..crate::models::Usage::default()
};
let usage_b = crate::models::Usage {
input_tokens: 3,
output_tokens: 1,
..crate::models::Usage::default()
};
let assistant = TranslationAccountingContext::capture(&app, "assistant", 1).settle(Ok(
crate::client::TranslationProviderResponse {
translated: Ok("助理".to_string()),
route: translation_test_route(),
usage: Some(usage_a.clone()),
},
));
let thinking = TranslationAccountingContext::capture(&app, "thinking", 2).settle(Ok(
crate::client::TranslationProviderResponse {
translated: Err(anyhow::anyhow!("incomplete: max_tokens")),
route: translation_test_route(),
usage: Some(usage_b.clone()),
},
));
assert_eq!(assistant.usage.as_ref(), Some(&usage_a));
assert_eq!(thinking.usage.as_ref(), Some(&usage_b));
assert!(
thinking.translated.is_err(),
"semantic rejection is preserved"
);
accrue_translation_usage(&mut app, assistant.usage.as_ref().expect("assistant usage"));
accrue_translation_usage(&mut app, thinking.usage.as_ref().expect("thinking usage"));
assert_eq!(app.session.total_input_tokens, 8);
assert_eq!(app.session.total_output_tokens, 3);
assert_eq!(app.session.total_tokens, 11);
let pending = crate::cost_status::drain();
assert_eq!(
pending.priced_turns.saturating_add(pending.unpriced_turns),
2,
"each decoded provider response is audited exactly once"
);
}
#[test]
fn translation_unreceipted_success_is_marked_once_but_transport_failure_is_not() {
let _scope = crate::cost_status::test_scope();
let mut app = test_app();
app.current_session_id = Some("session-translation-missing-usage".to_string());
app.runtime_turn_id = Some("turn-translation-missing-usage".to_string());
for _ in 0..2 {
let settled = TranslationAccountingContext::capture(&app, "assistant", 7).settle(Ok(
crate::client::TranslationProviderResponse {
translated: Ok("translation remains usable".to_string()),
route: translation_test_route(),
usage: None,
},
));
assert_eq!(
settled.translated.expect("semantic output remains usable"),
"translation remains usable"
);
assert_eq!(settled.usage, None);
}
let transport = TranslationAccountingContext::capture(&app, "assistant", 8)
.settle(Err(anyhow::anyhow!("HTTP 429")));
assert!(transport.translated.is_err());
assert_eq!(transport.usage, None);
let pending = crate::cost_status::drain();
assert_eq!(pending.priced_turns, 0);
assert_eq!(
pending.unpriced_turns, 1,
"stable response id dedupes replay"
);
assert_eq!(pending.cny_unpriced_turns, 1);
assert!(
pending
.unpriced_reasons
.contains("provider_success_missing_usage")
);
}
#[test]
fn late_translation_delivery_isolated_from_new_session_or_turn() {
let mut app = test_app();
app.current_session_id = Some("session-a".to_string());
app.runtime_turn_id = Some("turn-a".to_string());
let (session, turn) = translation_origin(&app);
assert!(translation_origin_is_current(
&app,
session.as_deref(),
turn.as_deref()
));
app.current_session_id = Some("session-b".to_string());
assert!(!translation_session_is_current(&app, session.as_deref()));
assert!(!translation_origin_is_current(
&app,
session.as_deref(),
turn.as_deref()
));
app.current_session_id = Some("session-a".to_string());
app.runtime_turn_id = Some("turn-b".to_string());
assert!(
translation_session_is_current(&app, session.as_deref()),
"same-session late usage still belongs in session totals"
);
assert!(!translation_origin_is_current(
&app,
session.as_deref(),
turn.as_deref()
));
let usage = crate::models::Usage {
input_tokens: 4,
output_tokens: 2,
..crate::models::Usage::default()
};
if translation_session_is_current(&app, session.as_deref()) {
accrue_translation_usage(&mut app, &usage);
}
assert_eq!(app.session.total_tokens, 6);
app.current_session_id = Some("session-b".to_string());
if translation_session_is_current(&app, session.as_deref()) {
accrue_translation_usage(&mut app, &usage);
}
assert_eq!(
app.session.total_tokens, 6,
"cross-session late usage must not pollute the new session"
);
let shared_prefix = "x".repeat(300);
app.current_session_id = Some(format!("{shared_prefix}:old"));
app.runtime_turn_id = Some("turn-long".to_string());
let (long_session, long_turn) = translation_origin(&app);
app.current_session_id = Some(format!("{shared_prefix}:new"));
assert!(
!translation_origin_is_current(&app, long_session.as_deref(), long_turn.as_deref()),
"fixed fingerprints must distinguish ids with the same long prefix"
);
}
}
#[cfg(test)]
@@ -6677,7 +7172,7 @@ mod fleet_workers_status_tests {
fn current_session_fleet_worker_status_keeps_the_english_session_boundary() {
assert_eq!(
current_session_fleet_workers_status(Locale::En, 3),
"Fleet workers this session: 3"
"Current-session fleet workers: 3 total"
);
}
}
+206 -6
View File
@@ -56,6 +56,42 @@ use crate::models::Role;
use crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};
use tempfile::TempDir;
#[test]
fn failed_engine_channel_settles_classifier_batch_once() {
let _cost_scope = crate::cost_status::test_scope();
let route = crate::cost_status::EffectiveRouteEnvelope::capture(
None,
ApiProvider::Deepseek,
"deepseek",
"classifier-model",
Some(ApiProvider::Deepseek.default_base_url()),
chrono::Utc::now(),
);
let batch = crate::cost_status::RuntimeUsageBatch {
records: vec![crate::cost_status::RuntimeUsageRecord {
source_id: "auto-router:dispatch-usage".to_string(),
usage: crate::cost_status::EffectiveRouteUsage {
route: route.clone(),
usage: crate::models::Usage {
input_tokens: 6,
output_tokens: 2,
..Default::default()
},
},
}],
drop_records: vec![crate::cost_status::RuntimeUsageDropRecord {
source_id: "auto-router:dispatch-drop".to_string(),
route,
}],
dropped_records: 1,
};
super::dispatch::settle_failed_dispatch_routed_usage(&batch);
super::dispatch::settle_failed_dispatch_routed_usage(&batch);
let pending = crate::cost_status::drain();
assert_eq!(pending.usage_source_fingerprints.len(), 2);
}
#[test]
fn session_shell_area_fills_the_host_terminal_at_every_width() {
// #5322: transcript / composer share the full host width — no wide-terminal
@@ -538,6 +574,7 @@ fn completed_turn_cost_receipt_uses_the_captured_effective_route() {
// receipt fails closed on anything that is not one, so a
// hand-written placeholder here would have tested nothing.
endpoint_fingerprint: served_endpoint_fingerprint(),
provider_live_pricing: None,
billing_mode: crate::cost_status::RouteBillingMode::Metered,
dispatched_at: chrono::Utc::now(),
}),
@@ -8429,7 +8466,7 @@ async fn provider_switch_clears_turn_cache_history() {
&mut engine.handle,
&mut config,
ApiProvider::Ollama,
None,
Some("fixture-local:tag".to_string()),
)
.await;
@@ -11918,6 +11955,7 @@ fn turn_liveness_recovers_stalled_in_progress_turn() {
openrouter_vendor: None,
billing_surface: Some(crate::pricing::FIRST_PARTY_PAYG_BILLING_SURFACE.to_string()),
endpoint_fingerprint: Some("openai-endpoint".to_string()),
provider_live_pricing: None,
billing_mode: crate::cost_status::RouteBillingMode::Metered,
dispatched_at: chrono::Utc::now(),
}),
@@ -11970,6 +12008,7 @@ fn engine_event_disconnect_recovers_live_turn_immediately() {
openrouter_vendor: None,
billing_surface: Some(crate::pricing::FIRST_PARTY_PAYG_BILLING_SURFACE.to_string()),
endpoint_fingerprint: Some("openai-endpoint".to_string()),
provider_live_pricing: None,
billing_mode: crate::cost_status::RouteBillingMode::Metered,
dispatched_at: chrono::Utc::now(),
}),
@@ -12046,6 +12085,7 @@ fn engine_event_disconnect_cleans_cancelled_turn_metadata() {
openrouter_vendor: None,
billing_surface: Some(crate::pricing::FIRST_PARTY_PAYG_BILLING_SURFACE.to_string()),
endpoint_fingerprint: Some("openai-endpoint".to_string()),
provider_live_pricing: None,
billing_mode: crate::cost_status::RouteBillingMode::Metered,
dispatched_at: chrono::Utc::now(),
}),
@@ -12939,7 +12979,10 @@ fn subagent_token_usage_updates_live_cost_counter_without_card_change() {
&crate::tools::subagent::MailboxMessage::TokenUsage {
agent_id: "agent-a".to_string(),
source_id: "response-a".to_string(),
route: test_mailbox_route(ApiProvider::Deepseek, "deepseek-v4-flash"),
route: Box::new(test_mailbox_route(
ApiProvider::Deepseek,
"deepseek-v4-flash",
)),
usage: crate::models::Usage {
input_tokens: 10_000,
output_tokens: 1_000,
@@ -12966,7 +13009,7 @@ fn subagent_token_usage_prices_the_child_route_not_the_parent_route() {
&crate::tools::subagent::MailboxMessage::TokenUsage {
agent_id: "agent-codex".to_string(),
source_id: "response-codex".to_string(),
route: test_mailbox_route(ApiProvider::OpenaiCodex, "gpt-5.5"),
route: Box::new(test_mailbox_route(ApiProvider::OpenaiCodex, "gpt-5.5")),
usage: crate::models::Usage {
input_tokens: 10_000,
output_tokens: 1_000,
@@ -12987,7 +13030,10 @@ fn subagent_token_usage_is_deduped_by_response_source() {
let usage = crate::tools::subagent::MailboxMessage::TokenUsage {
agent_id: "agent-a".to_string(),
source_id: "response-a".to_string(),
route: test_mailbox_route(ApiProvider::Deepseek, "deepseek-v4-flash"),
route: Box::new(test_mailbox_route(
ApiProvider::Deepseek,
"deepseek-v4-flash",
)),
usage: crate::models::Usage {
input_tokens: 10_000,
output_tokens: 1_000,
@@ -13008,13 +13054,108 @@ fn subagent_token_usage_is_deduped_by_response_source() {
assert!(app.session.subagent_cost > first);
}
#[test]
fn active_deleted_origin_mailbox_usage_does_not_accrue_or_restore_the_session() {
let _cost_scope = crate::cost_status::test_scope();
let mut app = create_test_app();
let tmp = tempfile::tempdir().expect("tempdir");
let manager = crate::session_manager::SessionManager::new(tmp.path().join("sessions"))
.expect("session manager");
let session = crate::session_manager::create_saved_session_with_id_and_mode(
"active-deleted-origin".to_string(),
&[],
"deepseek-v4-flash",
tmp.path(),
0,
None,
Some("agent"),
);
manager.save_session(&session).expect("save origin");
app.current_session_id = Some(session.metadata.id.clone());
app.current_session_metadata = Some(session.metadata.clone());
let origin_scope = crate::cost_status::scope_token();
let owner = "interactive:active-deleted-origin:origin-turn";
crate::cost_status::register_persistent_interactive_runtime_usage_sink_for_test(
owner,
origin_scope,
&session.metadata.id,
"origin-turn",
&manager,
);
let route = test_mailbox_route(ApiProvider::Deepseek, "deepseek-v4-flash");
let usage = crate::models::Usage {
input_tokens: 10_000,
output_tokens: 1_000,
..Default::default()
};
assert!(
route
.audit(&usage)
.estimate
.is_some_and(|cost| cost.usd > 0.0)
);
let source_id = "subagent:deleted-origin:response";
let mailbox = crate::tools::subagent::MailboxMessage::token_usage(
"agent-deleted-origin",
source_id,
route.clone(),
usage.clone(),
);
manager
.delete_session(&session.metadata.id)
.expect("delete active origin");
// The generation and active App deliberately remain unchanged. This is
// an active-origin callback, not the already-covered stale-scope path.
assert_eq!(crate::cost_status::scope_token(), origin_scope);
for seq in 1..=2 {
crate::cost_status::report_effective_route_for_runtime(
origin_scope,
Some(owner),
source_id,
&route,
&usage,
);
handle_subagent_mailbox_for_turn(&mut app, "origin-turn", seq, &mailbox);
}
assert!(crate::cost_status::drain().is_empty());
let fallback = crate::cost_status::take_runtime_usage(owner);
assert!(fallback.records.is_empty());
assert!(fallback.drop_records.is_empty());
assert_eq!(fallback.dropped_records, 0);
crate::cost_status::finish_runtime_usage_owner(owner);
assert_eq!(app.session.subagent_cost, 0.0);
assert_eq!(app.session.subagent_cost_cny, 0.0);
assert_eq!(app.session.cost_priced_turns, 0);
assert_eq!(app.session.cost_unpriced_turns, 0);
assert_eq!(app.session.cost_cny_priced_turns, 0);
assert_eq!(app.session.cost_cny_unpriced_turns, 0);
assert!(app.session.subagent_usage_sources.is_empty());
assert!(app.session.cost_route_receipts.is_empty());
assert!(app.history.is_empty());
assert!(crate::cost_status::usage_source_seen(source_id));
assert!(manager.list_sessions().expect("sessions").is_empty());
assert!(
!manager
.sessions_dir()
.join(".late-usage/active-deleted-origin.json")
.exists()
);
assert!(
manager.save_session(&session).is_err(),
"a stale autosave cannot restore the deleted origin"
);
}
#[test]
fn subagent_token_usage_source_is_stable_across_engine_turns() {
let mut app = create_test_app();
let usage = crate::tools::subagent::MailboxMessage::TokenUsage {
agent_id: "agent-a".to_string(),
source_id: "response-a".to_string(),
route: test_mailbox_route(ApiProvider::Deepseek, "deepseek-v4-flash"),
route: Box::new(test_mailbox_route(
ApiProvider::Deepseek,
"deepseek-v4-flash",
)),
usage: crate::models::Usage {
input_tokens: 10_000,
output_tokens: 1_000,
@@ -14180,6 +14321,8 @@ fn local_cancel_marks_late_stream_events_for_suppression() {
assert!(!suppress_engine_event_after_local_cancel(
&EngineEvent::TurnComplete {
usage: Usage::default(),
parent_route_usage: Usage::default(),
routed_usage_dropped_records: 0,
status: crate::core::events::TurnOutcomeStatus::Interrupted,
error: None,
tool_catalog: None,
@@ -14225,6 +14368,7 @@ fn turn_started_route_is_captured_before_cancel_suppression() {
openrouter_vendor: None,
billing_surface: Some(crate::pricing::FIRST_PARTY_PAYG_BILLING_SURFACE.to_string()),
endpoint_fingerprint: Some("openai-endpoint".to_string()),
provider_live_pricing: None,
billing_mode: crate::cost_status::RouteBillingMode::Metered,
dispatched_at: created_at,
}),
@@ -14290,6 +14434,7 @@ fn turn_started_suggestion_authority_comes_from_the_route_receipt_not_config() {
openrouter_vendor: None,
billing_surface: None,
endpoint_fingerprint: None,
provider_live_pricing: None,
billing_mode: crate::cost_status::RouteBillingMode::Unknown,
dispatched_at: chrono::Utc::now(),
}),
@@ -14333,6 +14478,7 @@ fn turn_started_without_a_route_receipt_captures_no_suggestion_authority() {
openrouter_vendor: None,
billing_surface: None,
endpoint_fingerprint: None,
provider_live_pricing: None,
billing_mode: crate::cost_status::RouteBillingMode::Unknown,
dispatched_at: chrono::Utc::now(),
}),
@@ -14370,6 +14516,7 @@ fn engine_error_health_accounting_uses_active_turn_route() {
openrouter_vendor: None,
billing_surface: Some(crate::pricing::FIRST_PARTY_PAYG_BILLING_SURFACE.to_string()),
endpoint_fingerprint: Some("openai-endpoint".to_string()),
provider_live_pricing: None,
billing_mode: crate::cost_status::RouteBillingMode::Metered,
dispatched_at: chrono::Utc::now(),
}),
@@ -17520,6 +17667,7 @@ fn legacy_child_usage_metadata_fails_closed_without_parent_route_fallback() {
endpoint_fingerprint: crate::cost_status::endpoint_fingerprint(
crate::config::DEFAULT_DEEPSEEK_BASE_URL,
),
provider_live_pricing: None,
billing_mode: crate::cost_status::RouteBillingMode::Metered,
dispatched_at: chrono::Utc::now(),
}),
@@ -17584,6 +17732,7 @@ fn child_usage_metadata_carries_cache_write_and_reasoning_end_to_end() {
endpoint_fingerprint: crate::cost_status::endpoint_fingerprint(
"https://api.anthropic.com/v1",
),
provider_live_pricing: None,
billing_mode: crate::cost_status::RouteBillingMode::Metered,
dispatched_at: chrono::DateTime::<chrono::Utc>::from_timestamp(0, 0).expect("epoch"),
};
@@ -17638,6 +17787,7 @@ fn child_usage_metadata_carries_cache_write_and_reasoning_end_to_end() {
model: "kimi-k2.7-code".to_string(),
billing_surface: Some(crate::pricing::FIRST_PARTY_PAYG_BILLING_SURFACE.to_string()),
endpoint_fingerprint: Some("test-moonshot-endpoint".to_string()),
provider_live_pricing: None,
billing_mode: crate::cost_status::RouteBillingMode::Metered,
dispatched_at: chrono::DateTime::<chrono::Utc>::from_timestamp(0, 0).expect("epoch"),
};
@@ -17714,6 +17864,53 @@ fn zero_usage_model_child_still_records_priced_receipt() {
assert_eq!(app.session.cost_route_receipts.len(), 1);
}
#[test]
fn routed_missing_usage_batch_prices_exact_routes_and_only_residual_as_generic() {
let mut app = create_test_app();
let mut metered_route = test_mailbox_route(ApiProvider::Deepseek, "deepseek-v4-flash");
metered_route.billing_mode = crate::cost_status::RouteBillingMode::Metered;
let mut local_route = metered_route.clone();
local_route.provider_identity = "local-computer".to_string();
local_route.billing_mode = crate::cost_status::RouteBillingMode::Local;
let batch = crate::cost_status::RuntimeUsageBatch {
records: Vec::new(),
drop_records: vec![
crate::cost_status::RuntimeUsageDropRecord {
source_id: "rlm:missing:metered".to_string(),
route: metered_route,
},
crate::cost_status::RuntimeUsageDropRecord {
source_id: "rlm:missing:local".to_string(),
route: local_route,
},
],
// Two exact routes plus one overflowed/route-less residual.
dropped_records: 3,
};
let mut metadata = serde_json::json!({});
crate::cost_status::attach_child_usage_batch_metadata(&mut metadata, &batch);
let result = Ok(crate::tools::spec::ToolResult::success("ok").with_metadata(metadata));
handle_tool_call_complete(&mut app, "rlm-missing", "rlm", &result);
assert_eq!(app.session.cost_priced_turns, 0);
assert_eq!(
app.session.cost_unpriced_turns, 2,
"metered exact drop and one residual gap count; local exact drop does not"
);
assert!(
app.session
.cost_unpriced_reasons
.contains("provider_success_missing_usage")
);
assert!(
app.session
.cost_unpriced_reasons
.contains("routed_usage_receipt_missing")
);
assert_eq!(app.session.subagent_usage_sources.len(), 2);
}
#[test]
fn picker_renamed_active_title_survives_automatic_snapshot() {
let mut app = create_test_app();
@@ -22426,7 +22623,10 @@ fn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {
let usage = crate::tools::subagent::MailboxMessage::TokenUsage {
agent_id: "agent-x".to_string(),
source_id: "response-x".to_string(),
route: test_mailbox_route(ApiProvider::Deepseek, "deepseek-v4-flash"),
route: Box::new(test_mailbox_route(
ApiProvider::Deepseek,
"deepseek-v4-flash",
)),
usage: crate::models::Usage {
input_tokens: 10_000,
output_tokens: 1_000,
+1 -1
View File
@@ -1361,7 +1361,7 @@ mod tests {
let usage = |source_id: &str, input_tokens, output_tokens| MailboxMessage::TokenUsage {
agent_id: "agent_stream".to_string(),
source_id: source_id.to_string(),
route: route.clone(),
route: Box::new(route.clone()),
usage: crate::models::Usage {
input_tokens,
output_tokens,
+89 -3
View File
@@ -69,6 +69,11 @@ pub(crate) struct PlannedTurnRoute {
pub(crate) effective_reasoning_effort: Option<String>,
pub(crate) auto_controls_reasoning: bool,
pub(crate) auto_selection: Option<crate::model_routing::AutoRouteSelection>,
/// Bounded auxiliary classifier usage that must enter the accepted turn
/// under its own frozen routes. It is moved out of `auto_selection` so a
/// UI-only receipt consumer cannot accidentally become the accounting
/// owner or price it under the parent route.
pub(crate) initial_routed_usage: crate::cost_status::RuntimeUsageBatch,
/// Why this concrete route was selected. This is captured by the planner,
/// not inferred later from the resulting provider/model pair.
pub(crate) routing_source: TurnRoutingSource,
@@ -108,6 +113,18 @@ fn reasoning_effort_for_route_selection(
}
}
fn settle_failed_parent_route(
error: String,
initial_routed_usage: &crate::cost_status::RuntimeUsageBatch,
) -> String {
crate::cost_status::report_runtime_usage_batch(
crate::cost_status::scope_token(),
None,
initial_routed_usage,
);
error
}
/// Resolve the route for one turn.
///
/// This is *the* route planner (#1004). `spawned_dispatch_inner` calls it to
@@ -125,7 +142,7 @@ fn reasoning_effort_for_route_selection(
pub(crate) async fn plan_turn_route(
request: TurnRoutePlanRequest<'_>,
) -> Result<PlannedTurnRoute, String> {
let auto_selection = if request.should_auto_resolve {
let mut auto_selection = if request.should_auto_resolve {
Some(
crate::model_routing::resolve_auto_route_with_inventory_for_session_and_cache_policy(
request.route_config,
@@ -163,6 +180,18 @@ pub(crate) async fn plan_turn_route(
request.app_model.to_string()
};
// Move classifier accounting out immediately. Every later parent-route
// failure must settle this already-incurred auxiliary call instead of
// returning an error that silently drops its exact quote/usage.
let initial_routed_usage = auto_selection
.as_mut()
.map(|selection| crate::cost_status::RuntimeUsageBatch {
records: std::mem::take(&mut selection.routed_usage),
drop_records: std::mem::take(&mut selection.routed_usage_drop_records),
dropped_records: std::mem::take(&mut selection.routed_usage_dropped_records),
})
.unwrap_or_default();
let turn_route = if effective_provider == request.app_route_identity.provider {
resolve_runtime_route_for_identity(
request.route_config,
@@ -177,9 +206,22 @@ pub(crate) async fn plan_turn_route(
)
};
let turn_route = turn_route.map_err(|err| err.to_string())?;
let turn_route = match turn_route {
Ok(route) => route,
Err(err) => {
return Err(settle_failed_parent_route(
err.to_string(),
&initial_routed_usage,
));
}
};
let turn_route = if request.preflight_required {
turn_route.preflight()?
match turn_route.preflight() {
Ok(route) => route,
Err(err) => {
return Err(settle_failed_parent_route(err, &initial_routed_usage));
}
}
} else {
turn_route
};
@@ -266,6 +308,7 @@ pub(crate) async fn plan_turn_route(
effective_reasoning_effort,
auto_controls_reasoning,
auto_selection,
initial_routed_usage,
routing_source,
})
}
@@ -284,6 +327,49 @@ mod tests {
}
}
#[test]
fn failed_parent_route_settles_classifier_batch_once() {
let _cost_scope = crate::cost_status::test_scope();
let route = crate::cost_status::EffectiveRouteEnvelope::capture(
None,
ApiProvider::Deepseek,
"deepseek",
"classifier-model",
Some(ApiProvider::Deepseek.default_base_url()),
chrono::Utc::now(),
);
let batch = crate::cost_status::RuntimeUsageBatch {
records: vec![crate::cost_status::RuntimeUsageRecord {
source_id: "auto-router:plan-usage".to_string(),
usage: crate::cost_status::EffectiveRouteUsage {
route: route.clone(),
usage: crate::models::Usage {
input_tokens: 4,
output_tokens: 2,
..Default::default()
},
},
}],
drop_records: vec![crate::cost_status::RuntimeUsageDropRecord {
source_id: "auto-router:plan-drop".to_string(),
route,
}],
dropped_records: 1,
};
assert_eq!(
settle_failed_parent_route("route failed".to_string(), &batch),
"route failed"
);
settle_failed_parent_route("route failed".to_string(), &batch);
let pending = crate::cost_status::drain();
assert_eq!(
pending.usage_source_fingerprints.len(),
2,
"both exact classifier outcomes persist, and replay is idempotent"
);
}
#[test]
fn auto_model_route_selection_keeps_raw_reasoning_preference() {
assert_eq!(
+4 -1
View File
@@ -407,7 +407,10 @@ test("new thread dialog labels exact vision capability without exposing attachme
assert.match(html, /does not change your Runtime defaults/);
assert.doesNotMatch(html, /type="file"/);
assert.match(source, /api\("\/v1\/providers"\)/);
assert.match(source, /\/v1\/providers\/\$\{encodeURIComponent\(provider\.id\)\}\/models/);
// The dialog loads the catalog through the bounded, paginated collector
// keyed by provider.id; the wire endpoint stays /v1/providers/<id>/models.
assert.match(source, /collectProviderModelPages\(provider\.id/);
assert.match(source, /\/v1\/providers\/\$\{encodeURIComponent\(provider\)\}\/models\?\$\{query\.toString\(\)\}/);
assert.match(source, /body: JSON\.stringify\(request\)/);
assert.match(source, /function trapFocusWithin\(event, container\)/);
assert.match(source, /dom\.newThreadCancel\.focus\(\{ preventScroll: true \}\)/);