From af494b7a81c86e5a99b769da5559dcf30e9a2c88 Mon Sep 17 00:00:00 2001 From: asto Date: Fri, 6 Nov 2026 17:56:31 +0800 Subject: [PATCH] feat(engine): expose route output budget to hosts Hosts issuing their own one-shot requests (the Pinvou review summon) must put the same max_tokens on the wire as the main turn. Reusing the route-effective cap via a public wrapper mirrors the context_input_budget_for_route precedent: share the math instead of re-deriving constants and silently drifting from the engine. Signed-off-by: asto --- crates/tui/src/core/engine.rs | 3 ++ crates/tui/src/core/engine/context.rs | 75 +++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index f9d0c55661..ca6e2ba0fd 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -9649,6 +9649,9 @@ pub(crate) use context::compact_tool_result_for_route; pub use context::context_input_budget_for_route; #[cfg(test)] use context::route_context_budget_for_provider; +/// Public for the same reason: hosts issuing one-shot requests put the same +/// `max_tokens` on the wire as the main turn (see the wrapper's doc). +pub use context::route_effective_max_output_tokens; use context::{ MAX_CONTEXT_RECOVERY_ATTEMPTS, MIN_RECENT_MESSAGES_TO_KEEP, effective_max_output_tokens_for_route, emergency_trim_budget, diff --git a/crates/tui/src/core/engine/context.rs b/crates/tui/src/core/engine/context.rs index 9ee3ca81ae..07a0f1e9e6 100644 --- a/crates/tui/src/core/engine/context.rs +++ b/crates/tui/src/core/engine/context.rs @@ -951,6 +951,34 @@ pub(super) fn context_input_budget_for_provider( context_input_budget_for_route(provider, model, None, 0) } +/// Public for the same reason as [`context_input_budget_for_route`]: hosts +/// issuing their own one-shot requests (e.g. the Pinvou review summon) must +/// put the *same* `max_tokens` on the wire as the main turn — the route-effective +/// request cap after explicit overrides, the documented ceiling intersected with +/// the automatic per-request output cap, compatibility/route ceilings, and the +/// route window are intersected (a concrete route cap replaces only the generic +/// uncatalogued guess) — instead of hard-coding a constant and silently +/// drifting from the engine. The wire model is normalized exactly as +/// the transport normalizes it (`wire_model_for_provider_route`). +/// +/// Pass the same effective [`RouteLimits`] the engine session resolved for the +/// route (host-declared facts included); `None` falls back to catalogue +/// defaults and diverges wherever the route carries declared facts. Ambient +/// `CODEWHALE_MAX_OUTPUT_TOKENS` / `DEEPSEEK_MAX_OUTPUT_TOKENS` overrides move +/// this value exactly as they move the main turn's. Per-dialect field shape +/// (the Codex Responses route omits the field; some OpenAI reasoning routes +/// rename it `max_completion_tokens`) remains transport knowledge. +pub fn route_effective_max_output_tokens( + provider: ApiProvider, + base_url: &str, + requested_model: &str, + route_limits: Option, +) -> u32 { + let wire_model = + crate::config::wire_model_for_provider_route(provider, base_url, requested_model); + crate::route_budget::effective_max_output_tokens_for_route(provider, &wire_model, route_limits) +} + /// Public so external callers (e.g. a host/bridge deriving its own compaction /// trigger line) can reuse the *exact* same internal input-budget math — window /// minus the route-effective output reservation @@ -1034,6 +1062,53 @@ mod tests { assert_eq!(emergency_trim_budget(0), 0); } + #[test] + fn route_effective_max_output_tokens_matches_the_transport_composition() { + let _lock = crate::test_support::lock_test_env(); + // Concrete-value assertions require the override envs gone — an + // ambient `CODEWHALE_MAX_OUTPUT_TOKENS` would move the wrapper (and + // only the wrapper's first assertion) spuriously. Same hygiene every + // route_budget sibling documents as load-bearing. + let _override = crate::test_support::EnvVarGuard::remove("CODEWHALE_MAX_OUTPUT_TOKENS"); + let _legacy = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MAX_OUTPUT_TOKENS"); + // The wrapper is the host-facing extract of the transport clamp + // (client.rs `effective_max_output_tokens_with_limits`): normalize the + // requested id exactly as the transport does, then intersect. The k3 + // route is the hard-code drift incident that motivated the wrapper: + // documented 131_072 clamped by the 65_536 per-request API cap. + let provider = crate::config::ApiProvider::Moonshot; + let base_url = "https://api.moonshot.ai/v1"; + let via_wrapper = + super::route_effective_max_output_tokens(provider, base_url, "kimi-k3", None); + assert_eq!(via_wrapper, 65_536); + assert_eq!( + via_wrapper, + crate::route_budget::effective_max_output_tokens_for_route( + provider, + &crate::config::wire_model_for_provider_route(provider, base_url, "kimi-k3"), + None, + ) + ); + // A compact DeepSeek alias must reach the same cap as its canonical + // spelling: hosts may hold either form. + let deepseek = crate::config::ApiProvider::Deepseek; + let deepseek_url = "https://api.deepseek.com/v1"; + assert_eq!( + super::route_effective_max_output_tokens( + deepseek, + deepseek_url, + "deepseek-v4pro", + None + ), + super::route_effective_max_output_tokens( + deepseek, + deepseek_url, + "deepseek-v4-pro", + None, + ) + ); + } + #[test] fn image_rejection_classifier_matches_provider_400s() { assert!(is_image_input_rejection_message(