From 92fbbd56eb4af1924bd3123200dbf9619f5a126a Mon Sep 17 00:00:00 2001 From: asto Date: Tue, 29 Sep 2026 20:44:38 +0800 Subject: [PATCH 01/18] fix(route): honor custom wire on runtime routes A named [providers.] table with wire = "responses" (or "anthropic") minted a Chat Completions candidate: RouteRequest had no wire channel, so candidate.protocol() came from Custom's backward-compatible static policy and every per-turn client built by DeepSeekClient::from_candidate bound Chat regardless of the table's dialect. The ambient spawn-time client (DeepSeekClient::new) and provider_capability_with_wire honored the override, so hosts that resolve routes per turn - the #3384 architecture - silently talked to {base}/chat/completions while their config said Responses. RouteRequest gains a wire_override honored only for ProviderKind::Custom (built-ins keep their descriptor policy); the tui route layer populates it from the active table's dialect (custom_wire_override_for, the same source provider_wire_format_for_config reads), and the client-internal rebind/request/limit resolutions pin the transport's own wire so an engine-internal model switch cannot downgrade it. The minted candidate is now wire-true end to end: receipts, preflight, and the per-turn client all read the same protocol. Pinvou PR #625 routes catalog GPT models through such a table and hit exactly this gap (three review rounds missed it because no test pinned the wire on the runtime path). Signed-off-by: asto --- crates/app-server/src/chat_completions.rs | 2 + crates/config/src/lib.rs | 2 + crates/config/src/route/authority.rs | 1 + crates/config/src/route/conformance_tests.rs | 3 + crates/config/src/route/resolver.rs | 34 +++++- crates/config/src/route/tests.rs | 102 +++++++++++++++++ crates/config/src/tests.rs | 2 + crates/tui/src/client.rs | 112 +++++++++++++++++++ crates/tui/src/config.rs | 4 +- crates/tui/src/provider_readiness.rs | 2 + crates/tui/src/route_runtime.rs | 110 +++++++++++++++++- 11 files changed, 368 insertions(+), 6 deletions(-) diff --git a/crates/app-server/src/chat_completions.rs b/crates/app-server/src/chat_completions.rs index f767714b38..f8e0b237e6 100644 --- a/crates/app-server/src/chat_completions.rs +++ b/crates/app-server/src/chat_completions.rs @@ -118,6 +118,8 @@ fn resolve_endpoint( saved_provider_model: None, base_url_override: Some(base_url.clone()), limit_overrides: Vec::new(), + + wire_override: None, })?; let model = route.wire_model_id().as_str().to_string(); diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 68bd01ed87..e0180dee09 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -3385,6 +3385,8 @@ impl ConfigToml { saved_provider_model: None, base_url_override: Some(base_url.clone()), limit_overrides: Vec::new(), + + wire_override: None, }) .ok(); diff --git a/crates/config/src/route/authority.rs b/crates/config/src/route/authority.rs index b20c379d94..f23171188e 100644 --- a/crates/config/src/route/authority.rs +++ b/crates/config/src/route/authority.rs @@ -212,6 +212,7 @@ mod tests { saved_provider_model: None, base_url_override: None, limit_overrides: Vec::new(), + wire_override: None, } } diff --git a/crates/config/src/route/conformance_tests.rs b/crates/config/src/route/conformance_tests.rs index e2caeb887f..f16629a4c0 100644 --- a/crates/config/src/route/conformance_tests.rs +++ b/crates/config/src/route/conformance_tests.rs @@ -19,6 +19,7 @@ fn none_request(kind: ProviderKind) -> RouteRequest { saved_provider_model: None, base_url_override: None, limit_overrides: Vec::new(), + wire_override: None, } } @@ -125,6 +126,8 @@ fn every_provider_kind_resolves_the_auto_selector() { saved_provider_model: None, base_url_override: None, limit_overrides: Vec::new(), + + wire_override: None, }; let candidate = resolver .resolve(&request) diff --git a/crates/config/src/route/resolver.rs b/crates/config/src/route/resolver.rs index 82574373b6..2470df1f59 100644 --- a/crates/config/src/route/resolver.rs +++ b/crates/config/src/route/resolver.rs @@ -40,7 +40,7 @@ use super::errors::RouteError; use super::ids::{LogicalModelRef, ModelId, ProviderId, WireModelId}; use super::offering::{ProviderModelOffering, RouteLimits, bundled_offerings}; use crate::catalog::{CatalogOffering, bundled_catalog_offerings}; -use crate::provider::WirePolicy; +use crate::provider::{WireFormat, WirePolicy}; use crate::{ProviderKind, opencode_go_chat_model_id, provider_preserves_custom_base_url_model}; /// A request to resolve into an executable route. @@ -62,6 +62,15 @@ pub struct RouteRequest { /// for adjusting a route's effective limits: the candidate itself is /// immutable once minted. pub limit_overrides: Vec, + /// Explicit wire-format override for `ProviderKind::Custom` routes. + /// + /// The Custom descriptor's static policy is Chat Completions for backward + /// compatibility; a per-config `[providers.] wire = "responses" | + /// "anthropic" | "chat"` table must mint a wire-true candidate so the + /// billing receipt, preflight, and the per-turn client binding all read + /// the same protocol. Ignored for every other kind: built-ins keep their + /// descriptor policy. + pub wire_override: Option, } /// Resolves [`RouteRequest`]s into [`ReadyRouteCandidate`]s. @@ -251,6 +260,23 @@ impl RouteResolver { selected.capabilities = RouteCapabilities::default(); selected.pricing = PricingSku::UnknownOrStale; } + if provider_kind == ProviderKind::Custom { + // A per-config `wire` override names the endpoint the custom + // table actually serves; the static descriptor policy stays Chat + // Completions for backward compatibility, so the candidate's + // endpoint key and protocol must come from the override (the + // tui route layer reads `[providers.] wire` into the + // request; see `route_runtime::custom_wire_override_for`). + match req.wire_override { + Some(WireFormat::Responses) => { + selected.endpoint_key = "responses".to_string(); + } + Some(WireFormat::AnthropicMessages) => { + selected.endpoint_key = "messages".to_string(); + } + Some(WireFormat::ChatCompletions) | None => {} + } + } if provider_kind == ProviderKind::Zai { let effective_base_url = req .base_url_override @@ -274,8 +300,10 @@ impl RouteResolver { ); } - let protocol = descriptor - .protocol_for_endpoint(&selected.endpoint_key) + let protocol = (provider_kind == ProviderKind::Custom) + .then_some(req.wire_override) + .flatten() + .or_else(|| descriptor.protocol_for_endpoint(&selected.endpoint_key)) .ok_or_else(|| RouteError::UnsupportedModelProtocol { provider: provider_id.clone(), model: selected.wire_model_id.as_str().to_string(), diff --git a/crates/config/src/route/tests.rs b/crates/config/src/route/tests.rs index 5247707593..5ccfb9405f 100644 --- a/crates/config/src/route/tests.rs +++ b/crates/config/src/route/tests.rs @@ -19,6 +19,7 @@ fn req(provider: Option, model: Option<&str>) -> RouteRequest { saved_provider_model: None, base_url_override: None, limit_overrides: Vec::new(), + wire_override: None, } } @@ -431,6 +432,8 @@ fn resolver_routes_only_official_deepseek_flash_over_responses() { saved_provider_model: None, base_url_override: Some("https://compatible.example/v1".to_string()), limit_overrides: Vec::new(), + + wire_override: None, }) .expect("custom compatible Flash route remains pass-through"); assert_eq!(custom.protocol(), RequestProtocol::ChatCompletions); @@ -455,6 +458,8 @@ fn resolver_routes_deepseek_vision_exp_over_chat_with_image_input() { saved_provider_model: None, base_url_override: base_url_override.map(str::to_string), limit_overrides: Vec::new(), + + wire_override: None, }) .expect("experimental vision route resolves"); @@ -493,6 +498,8 @@ fn resolver_keeps_custom_deepseek_same_name_capabilities_unverified() { saved_provider_model: None, base_url_override: Some("https://deepseek-proxy.example.test/v1".to_string()), limit_overrides: Vec::new(), + + wire_override: None, }) .expect("same-name custom proxy route resolves"); @@ -775,6 +782,8 @@ fn resolver_direct_owned_row_match_survives_casing_mismatch() { saved_provider_model: None, base_url_override: Some("https://compatible.example.test/v1".to_string()), limit_overrides: Vec::new(), + + wire_override: None, }; let out = r .resolve(&custom) @@ -841,6 +850,8 @@ fn resolver_custom_endpoint_allows_namespaced_selector_for_strict_provider() { saved_provider_model: None, base_url_override: Some("https://example.local/v1".to_string()), limit_overrides: Vec::new(), + + wire_override: None, }; let out = r .resolve(&request) @@ -864,6 +875,8 @@ fn resolver_treats_every_official_deepseek_endpoint_as_strict_direct() { saved_provider_model: None, base_url_override: Some(base_url.to_string()), limit_overrides: Vec::new(), + + wire_override: None, }; assert!( matches!( @@ -884,6 +897,8 @@ fn resolver_does_not_trust_deepseek_hostname_substrings() { saved_provider_model: None, base_url_override: Some("https://api.deepseek.com.evil.example/v1".to_string()), limit_overrides: Vec::new(), + + wire_override: None, }; let route = resolver .resolve(&request) @@ -903,6 +918,8 @@ fn resolver_explicit_custom_with_base_url_override_passes_model_through_verbatim saved_provider_model: None, base_url_override: Some("https://api.example.com/v1".to_string()), limit_overrides: Vec::new(), + + wire_override: None, }; let out = r .resolve(&request) @@ -1054,6 +1071,8 @@ fn together_custom_endpoint_preserves_its_explicit_model_id() { saved_provider_model: None, base_url_override: Some("http://127.0.0.1:8000/v1".to_string()), limit_overrides: Vec::new(), + + wire_override: None, }) .expect("custom Together-compatible endpoint should resolve"); @@ -1089,6 +1108,8 @@ fn openrouter_custom_endpoint_preserves_qwen37_alias() { saved_provider_model: None, base_url_override: Some("https://gateway.example.test/v1".to_string()), limit_overrides: Vec::new(), + + wire_override: None, }) .expect("custom OpenRouter-compatible endpoint should resolve"); @@ -1144,6 +1165,8 @@ fn opencode_go_resolver_rejects_messages_models_even_on_custom_base_urls() { saved_provider_model: None, base_url_override, limit_overrides: Vec::new(), + + wire_override: None, }; assert!( matches!( @@ -1228,6 +1251,8 @@ fn opencode_zen_resolver_fails_closed_for_unproven_protocols() { saved_provider_model: None, base_url_override, limit_overrides: Vec::new(), + + wire_override: None, }; assert!( matches!( @@ -1284,6 +1309,8 @@ fn resolver_empty_saved_provider_model_is_empty_model_error() { saved_provider_model: Some(WireModelId::from("")), base_url_override: None, limit_overrides: Vec::new(), + + wire_override: None, }; assert!(matches!(r.resolve(&request), Err(RouteError::EmptyModel))); } @@ -1479,6 +1506,8 @@ fn provider_native_web_search_requires_exact_direct_endpoint_offering() { saved_provider_model: None, base_url_override: Some("https://gateway.example.test/v1".to_string()), limit_overrides: Vec::new(), + + wire_override: None, }) .expect("custom compatible endpoint resolves"); assert_eq!( @@ -1520,6 +1549,8 @@ fn mimo_native_search_is_exact_to_documented_chat_models() { saved_provider_model: None, base_url_override: Some("https://compatible.example.test/v1".to_string()), limit_overrides: Vec::new(), + + wire_override: None, }) .expect("custom MiMo-compatible route resolves"); assert_eq!( @@ -1544,6 +1575,8 @@ fn zai_native_search_requires_exact_general_api_product() { saved_provider_model: None, base_url_override: Some(base_url.to_string()), limit_overrides: Vec::new(), + + wire_override: None, }) .expect("general API route resolves"); assert_eq!( @@ -1565,6 +1598,8 @@ fn zai_native_search_requires_exact_general_api_product() { saved_provider_model: None, base_url_override: Some(base_url.to_string()), limit_overrides: Vec::new(), + + wire_override: None, }) .expect("adjacent route resolves"); assert_eq!( @@ -1614,6 +1649,8 @@ fn qwen_native_search_is_exact_to_token_plan_responses_routes() { saved_provider_model: None, base_url_override: Some(base_url.to_string()), limit_overrides: Vec::new(), + + wire_override: None, }) .expect("alternate product route resolves"); assert_eq!( @@ -1643,6 +1680,8 @@ fn moonshot_native_search_requires_exact_product_model_pair() { saved_provider_model: None, base_url_override: Some(base_url.to_string()), limit_overrides: Vec::new(), + + wire_override: None, }) .expect("documented Moonshot/Kimi route resolves"); assert_eq!( @@ -1665,6 +1704,8 @@ fn moonshot_native_search_requires_exact_product_model_pair() { saved_provider_model: None, base_url_override: Some(base_url.to_string()), limit_overrides: Vec::new(), + + wire_override: None, }) .expect("adjacent Moonshot/Kimi route resolves"); assert_eq!( @@ -1707,6 +1748,8 @@ fn custom_endpoint_does_not_inherit_first_party_pricing() { saved_provider_model: None, base_url_override: Some("https://deepseek-proxy.example.test/v1".to_string()), limit_overrides: Vec::new(), + + wire_override: None, }) .expect("same-name custom proxy route resolves"); @@ -1756,6 +1799,7 @@ fn req_with_base(provider: ProviderKind, model: &str, base_url: &str) -> RouteRe saved_provider_model: None, base_url_override: Some(base_url.to_string()), limit_overrides: Vec::new(), + wire_override: None, } } @@ -1823,3 +1867,61 @@ fn https_endpoint_has_no_warning() { out.validation().messages ); } + +/// A `Custom` route's per-config `wire` override must mint a wire-true +/// candidate: the protocol and endpoint key come from the override, not the +/// descriptor's backward-compatible Chat Completions static policy. +#[test] +fn custom_wire_override_mints_a_wire_true_candidate() { + let resolver = RouteResolver::new(); + let base = |wire: Option| RouteRequest { + explicit_provider: Some(ProviderKind::Custom), + model_selector: Some(LogicalModelRef::from("gpt-6-sol".to_string())), + saved_provider_model: None, + base_url_override: Some("https://api.openai.com/v1".to_string()), + limit_overrides: Vec::new(), + wire_override: wire, + }; + + let responses = resolver + .resolve(&base(Some(RequestProtocol::Responses))) + .expect("responses override resolves"); + assert_eq!(responses.protocol(), RequestProtocol::Responses); + assert_eq!(responses.endpoint().endpoint_key, "responses"); + assert_eq!(responses.endpoint().base_url, "https://api.openai.com/v1"); + assert_eq!(responses.wire_model_id().as_str(), "gpt-6-sol"); + + let anthropic = resolver + .resolve(&base(Some(RequestProtocol::AnthropicMessages))) + .expect("anthropic override resolves"); + assert_eq!(anthropic.protocol(), RequestProtocol::AnthropicMessages); + assert_eq!(anthropic.endpoint().endpoint_key, "messages"); + + // No override keeps the documented backward-compatible default. + let chat = resolver.resolve(&base(None)).expect("default resolves"); + assert_eq!(chat.protocol(), RequestProtocol::ChatCompletions); + assert_eq!(chat.endpoint().endpoint_key, "chat"); +} + +/// The override channel is Custom-only: built-ins keep their descriptor +/// policy even when a request carries a wire override, so a stray override +/// cannot silently rewire a first-party route. +#[test] +fn wire_override_is_ignored_for_builtin_kinds() { + let resolver = RouteResolver::new(); + let out = resolver + .resolve(&RouteRequest { + explicit_provider: Some(ProviderKind::Openai), + model_selector: Some(LogicalModelRef::from("gpt-6-sol".to_string())), + saved_provider_model: None, + base_url_override: None, + limit_overrides: Vec::new(), + wire_override: Some(RequestProtocol::Responses), + }) + .expect("builtin route resolves"); + assert_eq!( + out.protocol(), + RequestProtocol::ChatCompletions, + "the builtin openai policy stays Chat Completions" + ); +} diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index 63466aea5d..9ee9b6fc7e 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -5198,6 +5198,8 @@ model = "gpt-5.5" saved_provider_model: None, base_url_override: None, limit_overrides: Vec::new(), + + wire_override: None, }) .expect("documented Zen model must resolve"); assert_eq!(route.protocol(), crate::route::RequestProtocol::Responses); diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 3578514409..a1e1969590 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -1441,6 +1441,12 @@ impl DeepSeekClient { saved_provider_model: None, base_url_override: Some(self.base_url.clone()), limit_overrides: Vec::new(), + // Engine-internal rebinds keep this client's own wire: the + // transport is endpoint-scoped, and a fresh config-aware + // resolution would only reproduce it (both read the same + // named-table `wire`). + wire_override: (self.api_provider == ApiProvider::Custom) + .then_some(self.wire_format), }) .map_err(anyhow::Error::msg)?; let candidate_limits = crate::route_budget::known_route_limits(candidate.limits()); @@ -1493,6 +1499,10 @@ impl DeepSeekClient { saved_provider_model: None, base_url_override: Some(self.base_url.clone()), limit_overrides: Vec::new(), + // Same-client request routing: keep this transport's own wire + // (endpoint-scoped, mirrors `rebound_for_model_protocol`). + wire_override: (self.api_provider == ApiProvider::Custom) + .then_some(self.wire_format), }) { Ok(candidate) => candidate, Err(error) if model_aware => return Err(anyhow::Error::msg(error)), @@ -2224,6 +2234,8 @@ impl DeepSeekClient { saved_provider_model: None, base_url_override: Some(self.base_url.clone()), limit_overrides: Vec::new(), + wire_override: (self.api_provider == ApiProvider::Custom) + .then_some(self.wire_format), }) .ok() .and_then(|candidate| crate::route_budget::known_route_limits(candidate.limits())) @@ -11826,4 +11838,104 @@ mod tests { ); assert_eq!(route.candidate.wire_model_id().as_str(), "custom-model-v1"); } + + /// The per-turn client for a `wire = "responses"` Custom table must POST + /// the generic `/responses` endpoint: the turn path is + /// resolve_runtime_route → from_candidate, so the candidate's protocol — + /// not the ambient spawn-time client — decides the wire (Pinvou PR #625). + #[tokio::test] + async fn forkguard_custom_responses_route_turn_client_posts_to_the_responses_endpoint() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("Content-Type", "text/event-stream") + .set_body_string(concentrate_sse_fixture("gpt-6-sol")), + ) + .expect(1) + .mount(&server) + .await; + + let _env_lock = crate::test_support::lock_test_env(); + let config = Config { + provider: Some("pinvou_responses".to_string()), + providers: Some(ProvidersConfig { + custom: [( + "pinvou_responses".to_string(), + ProviderConfig { + kind: Some("openai-compatible".to_string()), + wire: Some("responses".to_string()), + base_url: Some(format!("{}/v1", server.uri())), + api_key: Some("custom-responses-key".to_string()), + model: Some("gpt-6-sol".to_string()), + ..ProviderConfig::default() + }, + )] + .into_iter() + .collect(), + ..ProvidersConfig::default() + }), + ..Config::default() + }; + let route = crate::route_runtime::resolve_runtime_route( + &config, + ApiProvider::Custom, + Some("gpt-6-sol"), + ) + .expect("named table resolves"); + let client = DeepSeekClient::from_candidate(&config, &route.candidate) + .expect("per-turn client builds"); + assert_eq!( + client.wire_format, + WireFormat::Responses, + "from_candidate binds the wire-true candidate protocol" + ); + assert_eq!(client.api_provider, ApiProvider::Custom); + + let mut stream = client + .create_message_stream(minimal_zen_request("gpt-6-sol")) + .await + .expect("Custom Responses request should start"); + let mut text = String::new(); + while let Some(event) = stream.next().await { + if let StreamEvent::ContentBlockDelta { + delta: Delta::TextDelta { text: piece }, + .. + } = event.expect("Custom stream event") + { + text.push_str(&piece); + } + } + assert_eq!(text, "ok from stub"); + + let requests = server.received_requests().await.expect("recorded request"); + assert_eq!(requests.len(), 1); + let request = &requests[0]; + assert_eq!( + request.url.path(), + "/v1/responses", + "the turn must hit the Responses endpoint, not /chat/completions" + ); + assert_eq!( + request + .headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some("Bearer custom-responses-key") + ); + let body: Value = serde_json::from_slice(&request.body).expect("Responses JSON body"); + assert_eq!(body["model"], "gpt-6-sol", "model id verbatim: {body}"); + assert_eq!(body["stream"], true); + assert_eq!(body["store"], false, "stateless Custom route: {body}"); + assert_eq!( + body["include"], + json!(["reasoning.encrypted_content"]), + "encrypted reasoning replay stays available on this route: {body}" + ); + assert!( + body.get("messages").is_none(), + "Responses body, not Chat: {body}" + ); + } } diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 2d2e095ea7..93d78cbd56 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -9702,7 +9702,7 @@ fn xiaomi_mimo_env_api_key_for_runtime( xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS).or_else(|| xiaomi_mimo_env_var(STANDARD_ENV_VARS)) } -fn wire_config_prefers_anthropic(wire: Option<&str>) -> bool { +pub(crate) fn wire_config_prefers_anthropic(wire: Option<&str>) -> bool { let Some(raw) = wire.map(str::trim).filter(|value| !value.is_empty()) else { return false; }; @@ -9718,7 +9718,7 @@ fn wire_config_prefers_anthropic(wire: Option<&str>) -> bool { ) } -fn wire_config_prefers_responses(wire: Option<&str>) -> bool { +pub(crate) fn wire_config_prefers_responses(wire: Option<&str>) -> bool { let Some(raw) = wire.map(str::trim).filter(|value| !value.is_empty()) else { return false; }; diff --git a/crates/tui/src/provider_readiness.rs b/crates/tui/src/provider_readiness.rs index 7a9fba4f13..3c77aacf86 100644 --- a/crates/tui/src/provider_readiness.rs +++ b/crates/tui/src/provider_readiness.rs @@ -397,6 +397,8 @@ pub(crate) fn route_is_valid_for_model( .map(str::to_string) }, limit_overrides: Vec::new(), + + wire_override: None, }; RouteResolver::new() .resolve(&request) diff --git a/crates/tui/src/route_runtime.rs b/crates/tui/src/route_runtime.rs index f3be5e85eb..38be1790c2 100644 --- a/crates/tui/src/route_runtime.rs +++ b/crates/tui/src/route_runtime.rs @@ -1,4 +1,5 @@ use chrono::{DateTime, Duration, Utc}; +use codewhale_config::provider::WireFormat; use codewhale_config::route::{ LimitField, LogicalModelRef, OverrideSource, ReadyRouteCandidate, RouteLimits, RouteRequest, RouteResolver, SourcedLimitOverride, WireModelId, @@ -10,7 +11,7 @@ use crate::codex_model_cache::{CodexModelCacheFreshness, model_roster}; use crate::config::{ ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL, KIMI_CODE_K3_CONTEXT_WINDOW_TOKENS, ProviderIdentity, is_exact_direct_moonshot_k3_route, is_exact_kimi_code_bare_k3_route, - validate_kimi_code_api_model_id, + validate_kimi_code_api_model_id, wire_config_prefers_anthropic, wire_config_prefers_responses, }; use crate::models::DIRECT_KIMI_K3_MAX_OUTPUT_TOKENS; @@ -443,9 +444,33 @@ pub(crate) fn resolve_route_candidate_with_context_metadata( context_window_override, provider_reported_context, None, + // Config-free convenience wrapper: callers here resolve non-Custom + // routes (or want the static policy), so no wire override. + None, ) } +#[allow(clippy::too_many_arguments)] +/// Wire-format override a `Custom` route's named table asks for via its +/// per-config `wire = "responses" | "anthropic" | "chat"` dialect. +/// +/// The Custom descriptor's static policy stays Chat Completions for backward +/// compatibility; the override must flow into the resolver so the minted +/// candidate is wire-true (receipts, preflight, and the per-turn +/// `from_candidate` binding all read `candidate.protocol()`). `None` means +/// "no preference" — the static policy applies, matching +/// `provider_wire_format_for_config` and `provider_capability_with_wire`. +pub(crate) fn custom_wire_override_for(config: &Config) -> Option { + let wire = config.provider_wire_dialect(ApiProvider::Custom)?; + if wire_config_prefers_responses(Some(wire)) { + Some(WireFormat::Responses) + } else if wire_config_prefers_anthropic(Some(wire)) { + Some(WireFormat::AnthropicMessages) + } else { + None + } +} + #[allow(clippy::too_many_arguments)] fn resolve_route_candidate_with_context_metadata_and_host_limits( provider: ApiProvider, @@ -455,6 +480,7 @@ fn resolve_route_candidate_with_context_metadata_and_host_limits( context_window_override: Option, provider_reported_context: Option, host_limits: Option, + custom_wire_override: Option, ) -> Result { let effective_base_url = base_url_override .as_deref() @@ -470,6 +496,7 @@ fn resolve_route_candidate_with_context_metadata_and_host_limits( .map(|model| WireModelId::from(model.to_string())), base_url_override, limit_overrides: Vec::new(), + wire_override: custom_wire_override, }; // First pass: resolve the route without overrides to learn the effective // endpoint, wire model id, and catalog limits. Candidates are immutable, so @@ -783,6 +810,7 @@ fn resolve_runtime_route_for_identity_with_limits( route_config.context_window_for_provider_config(provider), None, host_limits, + custom_wire_override_for(config), )?; let candidate = resolution.candidate; let model = candidate.wire_model_id().as_str().to_string(); @@ -1777,3 +1805,83 @@ mod tests { assert_eq!(host_overrides, 3); } } + +/// The named-custom table's per-config `wire` dialect must reach the runtime +/// route candidate: `resolve_runtime_route` is the per-turn authority, and +/// the client it binds reads `candidate.protocol()`. A `wire = "responses"` +/// table therefore resolves a Responses candidate (Pinvou PR #625), an +/// `anthropic` table a Messages candidate, and absent/`chat` keep the +/// backward-compatible Chat Completions default. +#[cfg(test)] +mod custom_wire_override_tests { + use super::*; + use crate::config::{ProviderConfig, ProvidersConfig}; + + fn custom_table_config(wire: Option<&str>, base_url: &str, model: &str) -> Config { + let mut custom = std::collections::HashMap::new(); + custom.insert( + "pinvou_responses".to_string(), + ProviderConfig { + kind: Some("openai-compatible".to_string()), + base_url: Some(base_url.to_string()), + model: Some(model.to_string()), + api_key: Some("test-key".to_string()), + wire: wire.map(str::to_string), + ..ProviderConfig::default() + }, + ); + Config { + provider: Some("pinvou_responses".to_string()), + providers: Some(ProvidersConfig { + custom, + ..ProvidersConfig::default() + }), + ..Config::default() + } + } + + #[test] + fn forkguard_named_table_wire_responses_reaches_the_runtime_candidate() { + let config = + custom_table_config(Some("responses"), "https://api.openai.com/v1", "gpt-6-sol"); + let route = resolve_runtime_route(&config, ApiProvider::Custom, Some("gpt-6-sol")) + .expect("named table resolves"); + assert_eq!( + route.candidate.protocol(), + WireFormat::Responses, + "the per-turn candidate must carry the table's Responses wire" + ); + assert_eq!( + route.candidate.endpoint().base_url, + "https://api.openai.com/v1" + ); + assert_eq!(route.candidate.wire_model_id().as_str(), "gpt-6-sol"); + assert_eq!(route.candidate.endpoint().endpoint_key, "responses"); + } + + #[test] + fn forkguard_named_table_wire_anthropic_reaches_the_runtime_candidate() { + let config = custom_table_config( + Some("anthropic"), + "https://relay.example.test/v1", + "claude-sonnet", + ); + let route = resolve_runtime_route(&config, ApiProvider::Custom, Some("claude-sonnet")) + .expect("named table resolves"); + assert_eq!(route.candidate.protocol(), WireFormat::AnthropicMessages); + } + + #[test] + fn forkguard_named_table_without_wire_keeps_the_chat_default() { + for wire in [None, Some("chat")] { + let config = custom_table_config(wire, "https://relay.example.test/v1", "vendor-model"); + let route = resolve_runtime_route(&config, ApiProvider::Custom, Some("vendor-model")) + .expect("named table resolves"); + assert_eq!( + route.candidate.protocol(), + WireFormat::ChatCompletions, + "wire {wire:?} keeps the static Chat default" + ); + } + } +} From 3a2d5ed415fdeb365dfb98452902c41e88107bd8 Mon Sep 17 00:00:00 2001 From: asto Date: Tue, 29 Sep 2026 20:44:52 +0800 Subject: [PATCH 02/18] feat(tui): capture reasoning on custom responses Encrypted reasoning-item capture was gated on the Codex provider, so a wire = "responses" Custom table streamed reasoning items that were never persisted and replayed id-less function_call continuations without their paired reasoning items - the per-round reasoning-continuity loss the OpenAI cookbook documents for store:false. Widen the capture gate to every Responses route that sends include: ["reasoning.encrypted_content"] (Codex plus Custom tables; DeepSeek and Concentrate stay excluded with their own contracts). The replay gate already matches the captured provider tag, so Custom states replay unchanged; both directions are pinned for the Custom route. Signed-off-by: asto --- crates/tui/src/client/responses.rs | 17 ++- crates/tui/src/client/responses/tests.rs | 128 +++++++++++++++++++++++ 2 files changed, 142 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/client/responses.rs b/crates/tui/src/client/responses.rs index 0d4d07a46b..7d6f1f1c1e 100644 --- a/crates/tui/src/client/responses.rs +++ b/crates/tui/src/client/responses.rs @@ -8,6 +8,7 @@ //! (`client/chat.rs`) to avoid protocol hacks. use anyhow::{Context, Result}; +use codewhale_config::provider::WireFormat; use serde_json::{Value, json}; use crate::config::ApiProvider; @@ -136,8 +137,10 @@ pub(super) fn build_responses_body_for_provider( }; } - // OpenAI Codex can replay encrypted reasoning. DeepSeek exposes plain - // `reasoning_text` and does not support `include`. + // OpenAI Codex and `wire = "responses"` Custom tables can replay + // encrypted reasoning (mirrors the capture gate in + // `handle_responses_stream`). DeepSeek exposes plain `reasoning_text` + // and does not support `include`. if !is_deepseek && !is_concentrate { body["include"] = json!(["reasoning.encrypted_content"]); } @@ -162,7 +165,15 @@ impl DeepSeekClient { // remapping — rather than borrowing the request that no longer exists // at this layer. let wire_model = prepared.wire_model.clone(); - let reasoning_origin = (self.api_provider == ApiProvider::OpenaiCodex) + // Encrypted-reasoning capture applies to every Responses route whose + // request carries `include: ["reasoning.encrypted_content"]` and + // replays by provider tag: the Codex OAuth backend, and any + // wire = "responses" Custom table (the same include is sent there). + // Chat-wire Custom tables and DeepSeek (plain reasoning_text) stay + // excluded. + let reasoning_origin = (self.api_provider == ApiProvider::OpenaiCodex + || (self.api_provider == ApiProvider::Custom + && self.wire_format == WireFormat::Responses)) .then(|| (self.api_provider.as_str().to_string(), wire_model.clone())); // The bearer Authorization header is already installed as a default diff --git a/crates/tui/src/client/responses/tests.rs b/crates/tui/src/client/responses/tests.rs index 652619abcb..6ba42339e4 100644 --- a/crates/tui/src/client/responses/tests.rs +++ b/crates/tui/src/client/responses/tests.rs @@ -1358,3 +1358,131 @@ fn responses_input_keeps_system_role_history_messages() { }) ); } + +/// A `wire = "responses"` Custom table captures encrypted reasoning exactly +/// like the Codex backend: the stream yields an opaque reasoning-state delta +/// tagged with the Custom provider string, so the replay gate +/// (`state.provider == provider.as_str()`) matches on the next turn +/// (Pinvou PR #625). +#[tokio::test] +async fn forkguard_custom_responses_stream_captures_encrypted_reasoning_as_opaque_state() { + let server = MockServer::start().await; + let sse_body = concat!( + "data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"reasoning\",\"id\":\"rs_custom\"}}\n\n", + "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"reasoning\",\"id\":\"rs_custom\",\"summary\":[],\"encrypted_content\":\"enc_custom_state\"}}\n\n", + "data: [DONE]\n\n", + ); + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("Content-Type", "text/event-stream") + .set_body_string(sse_body), + ) + .mount(&server) + .await; + + let client = { + let _env_lock = crate::test_support::lock_test_env(); + let config = Config { + provider: Some("pinvou_responses".to_string()), + providers: Some(ProvidersConfig { + custom: [( + "pinvou_responses".to_string(), + ProviderConfig { + kind: Some("openai-compatible".to_string()), + wire: Some("responses".to_string()), + base_url: Some(format!("{}/v1", server.uri())), + api_key: Some("custom-responses-key".to_string()), + model: Some("gpt-6-sol".to_string()), + ..ProviderConfig::default() + }, + )] + .into_iter() + .collect(), + ..ProvidersConfig::default() + }), + ..Config::default() + }; + DeepSeekClient::new(&config).expect("Custom responses client should resolve") + // `DeepSeekClient::new` reads the table's `wire` dialect + // (`provider_wire_format_for_config`), so this ambient client speaks + // Responses; after the runtime-route fix the per-turn + // `from_candidate` client carries the same wire. + }; + assert_eq!(client.wire_format, WireFormat::Responses); + let mut stream = client + .handle_responses_stream( + &client + .prepare_outbound_request(minimal_responses_request(), true) + .expect("responses request prepares"), + ) + .await + .unwrap(); + let mut captured = None; + while let Some(event) = stream.next().await { + if let StreamEvent::ContentBlockDelta { + delta: Delta::ReasoningStateDelta { state }, + .. + } = event.unwrap() + { + captured = Some(state); + } + } + + let state = captured.expect("encrypted reasoning state delta on the Custom route"); + assert_eq!(state.provider, ApiProvider::Custom.as_str()); + assert_eq!(state.api, "openai-responses"); + assert_eq!(state.id.as_deref(), Some("rs_custom")); + assert_eq!(state.encrypted_content, "enc_custom_state"); +} + +/// The replay gate matches Custom-tagged reasoning state by provider string +/// and exact model: an exact match replays the encrypted item, a model +/// switch or a different provider must not. +#[test] +fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() { + const SENTINEL: &str = "readable private reasoning must not be replayed"; + let state = OpaqueReasoningState { + provider: ApiProvider::Custom.as_str().to_string(), + api: "openai-responses".to_string(), + model: "gpt-6-sol".to_string(), + id: Some("rs_custom".to_string()), + encrypted_content: "enc_custom_payload".to_string(), + }; + let mut request = minimal_responses_request(); + request.model = "gpt-6-sol".to_string(); + request.messages.insert( + 0, + Message { + role: Role::Assistant, + content: vec![ContentBlock::Thinking { + thinking: SENTINEL.to_string(), + signature: None, + state: Some(state), + }], + }, + ); + + let exact = build_responses_body_for_provider(&request, ApiProvider::Custom); + let exact_wire = exact.to_string(); + assert!(!exact_wire.contains(SENTINEL), "{exact}"); + assert_eq!(exact.pointer("/input/0/type"), Some(&json!("reasoning"))); + assert_eq!(exact.pointer("/input/0/id"), Some(&json!("rs_custom"))); + assert_eq!(exact.pointer("/input/0/summary"), Some(&json!([]))); + assert_eq!( + exact.pointer("/input/0/encrypted_content"), + Some(&json!("enc_custom_payload")) + ); + + request.model = "gpt-6-luna".to_string(); + let switched_model = build_responses_body_for_provider(&request, ApiProvider::Custom); + assert!(!switched_model.to_string().contains(SENTINEL)); + assert!( + switched_model + .get("input") + .and_then(Value::as_array) + .is_some_and(|items| items.iter().all(|item| item["type"] != "reasoning")), + "{switched_model}" + ); +} From ac270bfc816bbd5423eb5f5bd67d376062a21ecb Mon Sep 17 00:00:00 2001 From: asto Date: Tue, 29 Sep 2026 15:42:18 +0000 Subject: [PATCH 03/18] fix(route): scope the custom wire override to the resolved identity Review round 1 of #79 (B1, S1): the wire override was read from the ambient `Config` while `resolve_runtime_route_for_identity*` resolves the endpoint from the identity-scoped clone, so on every pinned turn path (per-thread routing, `reload_config`, fleet pins, session restore) the override could name a different `[providers.]` table than the one supplying the endpoint: a pinned responses table rode Chat, and an ambient responses table wired Chat-only relays for `/responses` (a regression against the static-policy base for multi-table setups). The override now comes from the scoped clone and is computed only for `ProviderKind::Custom`. The per-config `wire` dialect parse moves into the config crate next to the field it reads (`wire_dialect_override` plus the alias predicates), so the tui wire-format/capability readers share one alias list with the resolver instead of byte-identical private copies. The app-server `/v1/chat/completions` pass-through threads the same override from the `provider_cfg` that supplies its endpoint, so a `wire = "responses"` table now fails closed at the handler's ChatCompletions-only guard instead of silently receiving a chat body. Also pins the explicit `chat` dialect arm and the `messages` endpoint key in tests, and drops a copy-pasted `#[allow(clippy::too_many_arguments)]` on the one-argument helper. Signed-off-by: asto --- crates/app-server/src/chat_completions.rs | 45 +++++++- crates/config/src/lib.rs | 8 +- crates/config/src/provider.rs | 64 +++++++++++- crates/config/src/route/tests.rs | 9 ++ crates/tui/src/config.rs | 48 ++------- crates/tui/src/route_runtime.rs | 121 +++++++++++++++++++--- 6 files changed, 236 insertions(+), 59 deletions(-) diff --git a/crates/app-server/src/chat_completions.rs b/crates/app-server/src/chat_completions.rs index f8e0b237e6..92c087c001 100644 --- a/crates/app-server/src/chat_completions.rs +++ b/crates/app-server/src/chat_completions.rs @@ -19,7 +19,7 @@ use codewhale_agent::ModelRegistry; use codewhale_config::{ ConfigApiKeyValueKind, ConfigToml, ProviderKind, auth_mode_disables_api_key, classify_config_api_key_value, is_upstream_auth_header, - provider::WireFormat, + provider::{WireFormat, wire_dialect_override}, provider_base_url_is_official, provider_preserves_custom_base_url_model, route::{LogicalModelRef, RouteError, RouteRequest, RouteResolver}, }; @@ -118,8 +118,14 @@ fn resolve_endpoint( saved_provider_model: None, base_url_override: Some(base_url.clone()), limit_overrides: Vec::new(), - - wire_override: None, + // The wire dialect rides the same provider_cfg that supplied the + // endpoint and key, so a `wire = "responses"` table cannot be silently + // served as chat here: the resolver mints a Responses candidate and + // the handler's ChatCompletions-only guard rejects it (fail closed) + // instead of forwarding to `{base}/chat/completions`. + wire_override: (provider_kind == ProviderKind::Custom) + .then(|| wire_dialect_override(provider_cfg.wire.as_deref())) + .flatten(), })?; let model = route.wire_model_id().as_str().to_string(); @@ -1046,6 +1052,39 @@ api_key = {provider_api_key:?} )); } + /// A `wire = "responses"` custom table must reach this pass-through's + /// resolution: the endpoint then carries the Responses wire and the + /// handler's ChatCompletions-only guard rejects the request (fail closed) + /// instead of silently forwarding a chat body to `{base}/chat/completions` + /// — the same mis-route the runtime-route fix removed from per-turn + /// clients. + #[test] + fn custom_table_wire_override_reaches_the_app_route() { + let mut config = ConfigToml { + provider: ProviderKind::Custom, + ..ConfigToml::default() + }; + config.providers.custom.base_url = Some("https://relay.example.test/v1".to_string()); + config.providers.custom.model = Some("gpt-6-sol".to_string()); + config.providers.custom.wire = Some("responses".to_string()); + + let endpoint = resolve_endpoint(&config, &ModelRegistry::default(), Some("gpt-6-sol")) + .expect("custom responses table resolves"); + assert_eq!(endpoint.provider, ProviderKind::Custom); + assert_eq!(endpoint.model, "gpt-6-sol"); + assert_eq!( + endpoint.wire_format, + WireFormat::Responses, + "the table's wire dialect rides the same provider_cfg as its endpoint" + ); + + // An ordinary chat table keeps the forwardable Chat wire. + config.providers.custom.wire = None; + let endpoint = resolve_endpoint(&config, &ModelRegistry::default(), Some("gpt-6-sol")) + .expect("custom chat table resolves"); + assert_eq!(endpoint.wire_format, WireFormat::ChatCompletions); + } + #[test] fn foreign_model_is_rejected_before_credentials_or_headers_can_cross() { let mut config = ConfigToml { diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index e0180dee09..76650dd69a 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -3385,8 +3385,12 @@ impl ConfigToml { saved_provider_model: None, base_url_override: Some(base_url.clone()), limit_overrides: Vec::new(), - - wire_override: None, + // provider_cfg above is the active custom table (named or + // legacy); its dialect is the same fact the tui route layer + // threads, so this receipt cannot disagree with the turn. + wire_override: (provider == ProviderKind::Custom) + .then(|| provider::wire_dialect_override(provider_cfg.wire.as_deref())) + .flatten(), }) .ok(); diff --git a/crates/config/src/provider.rs b/crates/config/src/provider.rs index 686150f36b..e6b5bdd50c 100644 --- a/crates/config/src/provider.rs +++ b/crates/config/src/provider.rs @@ -1718,15 +1718,73 @@ impl Provider for Custom { fn wire_policy(&self) -> WirePolicy { // Static default remains Chat Completions for backward compatibility. // Per-config `wire = "responses" | "anthropic" | "chat"` overrides are - // honored in `crates/tui/src/client.rs::provider_wire_format_for_config` - // and `crates/tui/src/config.rs::provider_capability`, which read - // `ProviderConfig::wire` for the `Custom` catalog identity. This keeps + // parsed by [`wire_dialect_override`] here in the config crate and + // honored by every consumer that reads `ProviderConfig::wire` for the + // `Custom` catalog identity (the tui wire-format/capability readers + // and the route resolver's `RouteRequest::wire_override`). This keeps // the `Provider` trait `Fixed` while giving custom endpoints the same // three-way switch (`responses` / `anthropic` / `chat`) as built-ins. WirePolicy::Fixed(WireFormat::ChatCompletions) } } +/// Whether a per-config `wire` dialect string names the Anthropic Messages +/// endpoint. Canonical parse shared by the tui wire-format/capability readers, +/// the route resolver, and the app-server pass-through, so one alias list +/// cannot drift from another. +#[must_use] +pub fn wire_dialect_prefers_anthropic(wire: Option<&str>) -> bool { + let Some(raw) = wire.map(str::trim).filter(|value| !value.is_empty()) else { + return false; + }; + let normalized = raw.to_ascii_lowercase().replace(['_', ' '], "-"); + matches!( + normalized.as_str(), + "anthropic" + | "anthropic-messages" + | "messages" + | "claude" + | "anthropic-compatible" + | "anthropic-compat" + ) +} + +/// Whether a per-config `wire` dialect string names the OpenAI Responses +/// endpoint. See [`wire_dialect_prefers_anthropic`] for the sharing contract. +#[must_use] +pub fn wire_dialect_prefers_responses(wire: Option<&str>) -> bool { + let Some(raw) = wire.map(str::trim).filter(|value| !value.is_empty()) else { + return false; + }; + let normalized = raw.to_ascii_lowercase().replace(['_', ' '], "-"); + matches!( + normalized.as_str(), + "responses" + | "responses-api" + | "openai-responses" + | "openai-responses-api" + | "response" + | "response-api" + | "openai-responses-compat" + | "responses-compat" + ) +} + +/// The wire override a per-config `wire` dialect asks for: `Some(Responses)` / +/// `Some(AnthropicMessages)` when the string names that endpoint, `None` for +/// `chat` / absent / unrecognized values (the static descriptor policy +/// applies). The resolver honors the override only for `ProviderKind::Custom`. +#[must_use] +pub fn wire_dialect_override(wire: Option<&str>) -> Option { + if wire_dialect_prefers_responses(wire) { + Some(WireFormat::Responses) + } else if wire_dialect_prefers_anthropic(wire) { + Some(WireFormat::AnthropicMessages) + } else { + None + } +} + static DEEPSEEK: Deepseek = Deepseek; static DEEPSEEK_ANTHROPIC: DeepseekAnthropic = DeepseekAnthropic; static NVIDIA_NIM: NvidiaNim = NvidiaNim; diff --git a/crates/config/src/route/tests.rs b/crates/config/src/route/tests.rs index 5ccfb9405f..579d3651dc 100644 --- a/crates/config/src/route/tests.rs +++ b/crates/config/src/route/tests.rs @@ -1897,6 +1897,15 @@ fn custom_wire_override_mints_a_wire_true_candidate() { assert_eq!(anthropic.protocol(), RequestProtocol::AnthropicMessages); assert_eq!(anthropic.endpoint().endpoint_key, "messages"); + // An explicit `chat` override is the third arm of the dialect: it must + // resolve exactly like the absent case (the static Chat policy), not fall + // out of the override block with a stale endpoint key. + let explicit_chat = resolver + .resolve(&base(Some(RequestProtocol::ChatCompletions))) + .expect("explicit chat override resolves"); + assert_eq!(explicit_chat.protocol(), RequestProtocol::ChatCompletions); + assert_eq!(explicit_chat.endpoint().endpoint_key, "chat"); + // No override keeps the documented backward-compatible default. let chat = resolver.resolve(&base(None)).expect("default resolves"); assert_eq!(chat.protocol(), RequestProtocol::ChatCompletions); diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 93d78cbd56..3c6ea26876 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -43,6 +43,10 @@ pub use models::*; #[cfg(test)] pub(crate) use codewhale_config::API_KEYRING_SENTINEL; pub(crate) use codewhale_config::{ConfigApiKeyValueKind, classify_config_api_key_value}; +// The per-config `wire` dialect parse is owned by the config crate next to the +// `ProviderConfigToml::wire` field it reads; local copies would let an alias +// added there silently miss the ambient wire/capability readers. +use codewhale_config::provider::{wire_dialect_prefers_anthropic, wire_dialect_prefers_responses}; pub const DEFAULT_ZAI_PROVIDER_MAX_CONCURRENCY: usize = 3; pub const MAX_PROVIDER_REQUEST_CONCURRENCY: usize = 64; @@ -676,7 +680,7 @@ pub fn provider_capability_with_wire( // Custom wire overrides must be checked before the generic fallback so // `[providers.] wire = "responses"` / `"anthropic"` is honored. if provider == ApiProvider::Custom { - if wire_config_prefers_anthropic(wire) { + if wire_dialect_prefers_anthropic(wire) { return ProviderCapability { provider, resolved_model: resolved_model.to_string(), @@ -689,7 +693,7 @@ pub fn provider_capability_with_wire( alias_deprecation: None, }; } - if wire_config_prefers_responses(wire) { + if wire_dialect_prefers_responses(wire) { return ProviderCapability { provider, resolved_model: resolved_model.to_string(), @@ -9702,40 +9706,6 @@ fn xiaomi_mimo_env_api_key_for_runtime( xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS).or_else(|| xiaomi_mimo_env_var(STANDARD_ENV_VARS)) } -pub(crate) fn wire_config_prefers_anthropic(wire: Option<&str>) -> bool { - let Some(raw) = wire.map(str::trim).filter(|value| !value.is_empty()) else { - return false; - }; - let normalized = raw.to_ascii_lowercase().replace(['_', ' '], "-"); - matches!( - normalized.as_str(), - "anthropic" - | "anthropic-messages" - | "messages" - | "claude" - | "anthropic-compatible" - | "anthropic-compat" - ) -} - -pub(crate) fn wire_config_prefers_responses(wire: Option<&str>) -> bool { - let Some(raw) = wire.map(str::trim).filter(|value| !value.is_empty()) else { - return false; - }; - let normalized = raw.to_ascii_lowercase().replace(['_', ' '], "-"); - matches!( - normalized.as_str(), - "responses" - | "responses-api" - | "openai-responses" - | "openai-responses-api" - | "response" - | "response-api" - | "openai-responses-compat" - | "responses-compat" - ) -} - fn modelstudio_mode_is_coding_plan(provider: ApiProvider, mode: Option<&str>) -> bool { if matches!( provider, @@ -9766,7 +9736,7 @@ fn resolve_modelstudio_base_url_for_tui( let anthropic = matches!( provider, ApiProvider::ModelstudioTokenPlanAnthropic | ApiProvider::ModelstudioCodingPlanAnthropic - ) || wire_config_prefers_anthropic(wire); + ) || wire_dialect_prefers_anthropic(wire); match (coding, anthropic) { (true, true) => MODELSTUDIO_CODING_PLAN_ANTHROPIC_BASE_URL.to_string(), (true, false) => DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL.to_string(), @@ -9783,7 +9753,7 @@ fn resolve_minimax_base_url_for_tui( if let Some(url) = configured.filter(|value| !value.trim().is_empty()) { return url; } - if matches!(provider, ApiProvider::MinimaxAnthropic) || wire_config_prefers_anthropic(wire) { + if matches!(provider, ApiProvider::MinimaxAnthropic) || wire_dialect_prefers_anthropic(wire) { DEFAULT_MINIMAX_ANTHROPIC_BASE_URL.to_string() } else { DEFAULT_MINIMAX_BASE_URL.to_string() @@ -9798,7 +9768,7 @@ fn resolve_deepseek_base_url_for_tui( if let Some(url) = configured.filter(|value| !value.trim().is_empty()) { return url; } - if matches!(provider, ApiProvider::DeepseekAnthropic) || wire_config_prefers_anthropic(wire) { + if matches!(provider, ApiProvider::DeepseekAnthropic) || wire_dialect_prefers_anthropic(wire) { DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL.to_string() } else { DEFAULT_DEEPSEEK_BASE_URL.to_string() diff --git a/crates/tui/src/route_runtime.rs b/crates/tui/src/route_runtime.rs index 38be1790c2..a14e768163 100644 --- a/crates/tui/src/route_runtime.rs +++ b/crates/tui/src/route_runtime.rs @@ -1,5 +1,5 @@ use chrono::{DateTime, Duration, Utc}; -use codewhale_config::provider::WireFormat; +use codewhale_config::provider::{WireFormat, wire_dialect_override}; use codewhale_config::route::{ LimitField, LogicalModelRef, OverrideSource, ReadyRouteCandidate, RouteLimits, RouteRequest, RouteResolver, SourcedLimitOverride, WireModelId, @@ -11,7 +11,7 @@ use crate::codex_model_cache::{CodexModelCacheFreshness, model_roster}; use crate::config::{ ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL, KIMI_CODE_K3_CONTEXT_WINDOW_TOKENS, ProviderIdentity, is_exact_direct_moonshot_k3_route, is_exact_kimi_code_bare_k3_route, - validate_kimi_code_api_model_id, wire_config_prefers_anthropic, wire_config_prefers_responses, + validate_kimi_code_api_model_id, }; use crate::models::DIRECT_KIMI_K3_MAX_OUTPUT_TOKENS; @@ -450,7 +450,6 @@ pub(crate) fn resolve_route_candidate_with_context_metadata( ) } -#[allow(clippy::too_many_arguments)] /// Wire-format override a `Custom` route's named table asks for via its /// per-config `wire = "responses" | "anthropic" | "chat"` dialect. /// @@ -460,15 +459,13 @@ pub(crate) fn resolve_route_candidate_with_context_metadata( /// `from_candidate` binding all read `candidate.protocol()`). `None` means /// "no preference" — the static policy applies, matching /// `provider_wire_format_for_config` and `provider_capability_with_wire`. +/// +/// Callers must pass the config that actually scopes the route's endpoint: +/// for identity-pinned resolution that is the identity-scoped clone (whose +/// `provider` names the pinned table), never the ambient selection — the two +/// can name different tables on every thread/pin/restore path. pub(crate) fn custom_wire_override_for(config: &Config) -> Option { - let wire = config.provider_wire_dialect(ApiProvider::Custom)?; - if wire_config_prefers_responses(Some(wire)) { - Some(WireFormat::Responses) - } else if wire_config_prefers_anthropic(Some(wire)) { - Some(WireFormat::AnthropicMessages) - } else { - None - } + wire_dialect_override(config.provider_wire_dialect(ApiProvider::Custom)) } #[allow(clippy::too_many_arguments)] @@ -802,6 +799,14 @@ fn resolve_runtime_route_for_identity_with_limits( .then(|| model_roster().preferred_model_id().map(str::to_string)) .flatten(); let model_selector = model_selector.or(roster_preferred.as_deref()); + // The override must come from the identity-scoped clone: its `provider` + // names the pinned table that also supplies the endpoint below, while the + // ambient `config` may select a different table (per-thread routing, + // fleet pins, session restore). Reading the dialect from the ambient + // config wired one table's protocol onto another table's endpoint. + let custom_wire_override = (provider == ApiProvider::Custom) + .then(|| custom_wire_override_for(&route_config)) + .flatten(); let resolution = resolve_route_candidate_with_context_metadata_and_host_limits( provider, model_selector, @@ -810,7 +815,7 @@ fn resolve_runtime_route_for_identity_with_limits( route_config.context_window_for_provider_config(provider), None, host_limits, - custom_wire_override_for(config), + custom_wire_override, )?; let candidate = resolution.candidate; let model = candidate.wire_model_id().as_str().to_string(); @@ -1869,6 +1874,7 @@ mod custom_wire_override_tests { let route = resolve_runtime_route(&config, ApiProvider::Custom, Some("claude-sonnet")) .expect("named table resolves"); assert_eq!(route.candidate.protocol(), WireFormat::AnthropicMessages); + assert_eq!(route.candidate.endpoint().endpoint_key, "messages"); } #[test] @@ -1884,4 +1890,95 @@ mod custom_wire_override_tests { ); } } + + /// Build one config carrying two named custom tables: the ambient + /// selection (`config.provider`) and a second table a persisted identity + /// can pin. Each table has a distinct base URL so the test can prove the + /// wire came from the same table as the endpoint. + fn two_table_config(ambient: (&str, Option<&str>), pinned: (&str, Option<&str>)) -> Config { + let table = |name: &str, wire: Option<&str>| { + ( + name.to_string(), + ProviderConfig { + kind: Some("openai-compatible".to_string()), + base_url: Some(format!("https://{name}.example.test/v1")), + model: Some("shared-model".to_string()), + api_key: Some("test-key".to_string()), + wire: wire.map(str::to_string), + ..ProviderConfig::default() + }, + ) + }; + let (ambient_name, ambient_wire) = ambient; + let (pinned_name, pinned_wire) = pinned; + let custom = [ + table(ambient_name, ambient_wire), + table(pinned_name, pinned_wire), + ] + .into_iter() + .collect(); + Config { + provider: Some(ambient_name.to_string()), + providers: Some(ProvidersConfig { + custom, + ..ProvidersConfig::default() + }), + ..Config::default() + } + } + + /// Direction (i): a thread pinned to a responses-wire table must keep the + /// override even while the ambient selection is an ordinary chat table. + /// The override previously read the ambient config, so pinned turns rode + /// Chat Completions — the exact mis-route this feature fixes. + #[test] + fn forkguard_identity_pinned_route_reads_the_pinned_tables_wire() { + let config = two_table_config( + ("ambient_chat", None), + ("pinned_responses", Some("responses")), + ); + let identity = config + .resolve_persisted_provider_identity(Some("custom"), Some("pinned_responses")) + .expect("pinned identity resolves"); + let route = resolve_runtime_route_for_identity(&config, &identity, Some("shared-model")) + .expect("pinned table resolves"); + assert_eq!( + route.candidate.protocol(), + WireFormat::Responses, + "the pinned table's wire, not the ambient table's" + ); + assert_eq!(route.candidate.endpoint().endpoint_key, "responses"); + assert_eq!( + route.candidate.endpoint().base_url, + "https://pinned_responses.example.test/v1", + "wire and endpoint must come from the same table" + ); + } + + /// Direction (ii): an ambient responses-wire selection must not wire its + /// protocol onto a pinned chat-only relay. The override previously read + /// the ambient config, so reload/restore installed a Responses candidate + /// for a `{base}/chat/completions` endpoint — a regression against the + /// static-policy base for every multi-table setup. + #[test] + fn forkguard_ambient_wire_override_does_not_leak_onto_pinned_chat_tables() { + let config = two_table_config( + ("ambient_responses", Some("responses")), + ("pinned_chat", None), + ); + let identity = config + .resolve_persisted_provider_identity(Some("custom"), Some("pinned_chat")) + .expect("pinned identity resolves"); + let route = resolve_runtime_route_for_identity(&config, &identity, Some("shared-model")) + .expect("pinned table resolves"); + assert_eq!( + route.candidate.protocol(), + WireFormat::ChatCompletions, + "a chat-only relay keeps its static wire under a responses ambient selection" + ); + assert_eq!( + route.candidate.endpoint().base_url, + "https://pinned_chat.example.test/v1" + ); + } } From a0d1e392c25c3cbe2a2269c7160909ab711245c9 Mon Sep 17 00:00:00 2001 From: asto Date: Tue, 29 Sep 2026 15:42:32 +0000 Subject: [PATCH 04/18] fix(responses): endpoint-scope reasoning replay; capture zen responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 of #79 (S2, S3): every named Custom table shares the `custom` provider slug, so Custom-tagged opaque reasoning state replayed onto any table resolving the same model id — table A's encrypted reasoning rode table B's wire after a provider switch. The replay gate now compares an endpoint-scoped tag (`custom/` from the client's frozen provider identity); no released build mints the old bare tag, so nothing stops replaying. Capture also widens to the OpenCode Zen Responses roster, which sends `include: ["reasoning.encrypted_content"]` and `store: false` but never captured — the same unpaired-function_call replay loss this PR fixed for Custom tables. Include and capture now cover the same route set by construction, and the turn-path pin expressions collapse into `pinned_wire_override` / `reasoning_provider_tag` helpers. Signed-off-by: asto --- crates/tui/src/client.rs | 132 +++++++++++++++-------- crates/tui/src/client/responses.rs | 47 +++++--- crates/tui/src/client/responses/tests.rs | 120 +++++++++++++++++---- crates/tui/src/client/role_placement.rs | 1 + 4 files changed, 221 insertions(+), 79 deletions(-) diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index a1e1969590..cbac53763f 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -23,7 +23,9 @@ use codewhale_config::catalog::{ CatalogOffering, CatalogRefreshError, CatalogSnapshot, CatalogSource, CatalogStatus, ProviderCatalogCache, ProviderCatalogDelta, base_url_fingerprint, now_unix, }; -use codewhale_config::provider::WireFormat; +use codewhale_config::provider::{ + WireFormat, wire_dialect_prefers_anthropic, wire_dialect_prefers_responses, +}; use codewhale_config::route::{ LogicalModelRef, ReadyRouteCandidate, RouteLimits, RouteRequest, RouteResolver, }; @@ -1421,6 +1423,30 @@ impl DeepSeekClient { redact_model_bound_text(text, &self.model_bound_secret_values) } + /// The wire override that pins this transport's own endpoint on internal + /// re-resolutions: Custom clients are endpoint-scoped, so a fresh + /// config-aware resolution must reproduce — never downgrade — the wire + /// they were built with. Non-Custom transports resolve by descriptor + /// policy (`None`). + fn pinned_wire_override(&self) -> Option { + (self.api_provider == ApiProvider::Custom).then_some(self.wire_format) + } + + /// Provider tag minted into captured [`OpaqueReasoningState`] and required + /// by the replay gate. Non-Custom backends are single-endpoint, so the + /// provider slug suffices; every named Custom table shares the `custom` + /// slug, so the tag carries the frozen table identity — encrypted + /// reasoning captured for one table must never replay onto another. + pub(super) fn reasoning_provider_tag(&self) -> String { + if self.api_provider != ApiProvider::Custom { + return self.api_provider.as_str().to_string(); + } + match self.provider_identity.as_str() { + "custom" | "" => ApiProvider::Custom.as_str().to_string(), + table => format!("custom/{table}"), + } + } + /// Resolve `model` through the central route resolver and rebuild this /// client whenever its exact wire identity, limits, or protocol differs /// from the route bound at construction (#5042). `Ok(None)` means the @@ -1445,8 +1471,7 @@ impl DeepSeekClient { // transport is endpoint-scoped, and a fresh config-aware // resolution would only reproduce it (both read the same // named-table `wire`). - wire_override: (self.api_provider == ApiProvider::Custom) - .then_some(self.wire_format), + wire_override: self.pinned_wire_override(), }) .map_err(anyhow::Error::msg)?; let candidate_limits = crate::route_budget::known_route_limits(candidate.limits()); @@ -1501,8 +1526,7 @@ impl DeepSeekClient { limit_overrides: Vec::new(), // Same-client request routing: keep this transport's own wire // (endpoint-scoped, mirrors `rebound_for_model_protocol`). - wire_override: (self.api_provider == ApiProvider::Custom) - .then_some(self.wire_format), + wire_override: self.pinned_wire_override(), }) { Ok(candidate) => candidate, Err(error) if model_aware => return Err(anyhow::Error::msg(error)), @@ -1773,7 +1797,7 @@ fn provider_wire_format_for_config( | ApiProvider::MinimaxAnthropic | ApiProvider::ModelstudioTokenPlanAnthropic | ApiProvider::ModelstudioCodingPlanAnthropic - ) || wire_config_prefers_anthropic(wire); + ) || wire_dialect_prefers_anthropic(wire); if prefers_anthropic && matches!( @@ -1799,10 +1823,10 @@ fn provider_wire_format_for_config( // anthropic: "anthropic" | "messages" | "claude" | "anthropic-messages" | ... // responses: "responses" | "responses-api" | "openai-responses" | "openai_responses" | ... if api_provider == ApiProvider::Custom { - if wire_config_prefers_anthropic(wire) { + if wire_dialect_prefers_anthropic(wire) { return WireFormat::AnthropicMessages; } - if wire_config_prefers_responses(wire) { + if wire_dialect_prefers_responses(wire) { return WireFormat::Responses; } } @@ -1823,40 +1847,6 @@ fn provider_wire_format_for_config( }) } -fn wire_config_prefers_anthropic(wire: Option<&str>) -> bool { - let Some(raw) = wire.map(str::trim).filter(|value| !value.is_empty()) else { - return false; - }; - let normalized = raw.to_ascii_lowercase().replace(['_', ' '], "-"); - matches!( - normalized.as_str(), - "anthropic" - | "anthropic-messages" - | "messages" - | "claude" - | "anthropic-compatible" - | "anthropic-compat" - ) -} - -fn wire_config_prefers_responses(wire: Option<&str>) -> bool { - let Some(raw) = wire.map(str::trim).filter(|value| !value.is_empty()) else { - return false; - }; - let normalized = raw.to_ascii_lowercase().replace(['_', ' '], "-"); - matches!( - normalized.as_str(), - "responses" - | "responses-api" - | "openai-responses" - | "openai-responses-api" - | "response" - | "response-api" - | "openai-responses-compat" - | "responses-compat" - ) -} - fn api_provider_skips_models_probe(api_provider: ApiProvider) -> bool { // Concentrate's `GET /v1/models` is explicitly unauthenticated // (docs/PROVIDERS.md): a 2xx proves nothing about the key, so guided @@ -2157,8 +2147,11 @@ impl DeepSeekClient { )) } WireFormat::Responses => { - let body = - responses::build_responses_body_for_provider(&request, self.api_provider); + let body = responses::build_responses_body_for_provider( + &request, + self.api_provider, + &self.reasoning_provider_tag(), + ); let is_codex = self.api_provider == ApiProvider::OpenaiCodex; let url = if is_codex { format!("{}{}", self.base_url, responses::CODEX_RESPONSES_PATH) @@ -2234,8 +2227,7 @@ impl DeepSeekClient { saved_provider_model: None, base_url_override: Some(self.base_url.clone()), limit_overrides: Vec::new(), - wire_override: (self.api_provider == ApiProvider::Custom) - .then_some(self.wire_format), + wire_override: self.pinned_wire_override(), }) .ok() .and_then(|candidate| crate::route_budget::known_route_limits(candidate.limits())) @@ -6810,6 +6802,54 @@ mod tests { assert!(body.get("messages").is_none(), "Responses body: {body}"); } + /// Zen's Responses roster sends `include: ["reasoning.encrypted_content"]` + /// and `store: false`, so multi-turn tool continuations replay only if the + /// stream captured the encrypted reasoning items — the same discipline as + /// the Codex backend, tagged with the Zen provider slug. + #[tokio::test] + async fn opencode_zen_responses_stream_captures_encrypted_reasoning() { + let server = MockServer::start().await; + let sse_body = concat!( + "data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"reasoning\",\"id\":\"rs_zen\"}}\n\n", + "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"reasoning\",\"id\":\"rs_zen\",\"summary\":[],\"encrypted_content\":\"enc_zen_state\"}}\n\n", + "data: [DONE]\n\n", + ); + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("Content-Type", "text/event-stream") + .set_body_string(sse_body), + ) + .expect(1) + .mount(&server) + .await; + + let client = opencode_zen_client(&server, "gpt-5.5"); + assert_eq!(client.wire_format, WireFormat::Responses); + let mut stream = client + .create_message_stream(minimal_zen_request("gpt-5.5")) + .await + .expect("Zen Responses request should start"); + let mut captured = None; + while let Some(event) = stream.next().await { + if let StreamEvent::ContentBlockDelta { + delta: Delta::ReasoningStateDelta { state }, + .. + } = event.expect("Zen Responses stream event") + { + captured = Some(state); + } + } + + let state = captured.expect("encrypted reasoning state delta on the Zen route"); + assert_eq!(state.provider, ApiProvider::OpencodeZen.as_str()); + assert_eq!(state.api, "openai-responses"); + assert_eq!(state.id.as_deref(), Some("rs_zen")); + assert_eq!(state.encrypted_content, "enc_zen_state"); + assert_eq!(state.model, "gpt-5.5"); + } + #[tokio::test] async fn opencode_zen_messages_request_shape_uses_api_key_anthropic_route() { let server = MockServer::start().await; diff --git a/crates/tui/src/client/responses.rs b/crates/tui/src/client/responses.rs index 7d6f1f1c1e..a5748d1af9 100644 --- a/crates/tui/src/client/responses.rs +++ b/crates/tui/src/client/responses.rs @@ -34,7 +34,11 @@ pub(super) const CODEX_RESPONSES_PATH: &str = "/codex/responses"; /// Build the Responses API request body from a `MessageRequest`. #[cfg(test)] pub(super) fn build_responses_body(request: &MessageRequest) -> Value { - build_responses_body_for_provider(request, ApiProvider::OpenaiCodex) + build_responses_body_for_provider( + request, + ApiProvider::OpenaiCodex, + ApiProvider::OpenaiCodex.as_str(), + ) } /// Build a provider-aware Responses API request body. @@ -43,9 +47,16 @@ pub(super) fn build_responses_body(request: &MessageRequest) -> Value { /// and exposes plain reasoning text rather than OpenAI encrypted summaries. /// Keep those exact-route differences here instead of leaking them into the /// provider-neutral message model. +/// +/// `reasoning_provider_tag` is the tag the caller's capture side mints into +/// [`OpaqueReasoningState`] (see `DeepSeekClient::reasoning_provider_tag`); +/// the replay gate below only reattaches reasoning items whose state carries +/// that exact tag, so encrypted reasoning minted by one endpoint — a named +/// Custom table — is never replayed to another. pub(super) fn build_responses_body_for_provider( request: &MessageRequest, provider: ApiProvider, + reasoning_provider_tag: &str, ) -> Value { let is_deepseek = matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN); // Concentrate documents `model`, `input`, `stream`, `max_output_tokens`, @@ -92,7 +103,7 @@ pub(super) fn build_responses_body_for_provider( .unwrap_or_else(|| "You are a helpful assistant.".to_string()); // Convert messages to Responses input items. - let mut input = convert_messages_to_responses_input(request, provider); + let mut input = convert_messages_to_responses_input(request, provider, reasoning_provider_tag); if is_concentrate { input.insert( 0, @@ -137,10 +148,11 @@ pub(super) fn build_responses_body_for_provider( }; } - // OpenAI Codex and `wire = "responses"` Custom tables can replay - // encrypted reasoning (mirrors the capture gate in - // `handle_responses_stream`). DeepSeek exposes plain `reasoning_text` - // and does not support `include`. + // Every Responses route that receives this builder can replay encrypted + // reasoning — OpenAI Codex, OpenCode Zen's Responses roster, and + // `wire = "responses"` Custom tables (mirrors the capture gate in + // `handle_responses_stream`, so include and capture stay in lockstep). + // DeepSeek exposes plain `reasoning_text` and does not support `include`. if !is_deepseek && !is_concentrate { body["include"] = json!(["reasoning.encrypted_content"]); } @@ -166,15 +178,16 @@ impl DeepSeekClient { // at this layer. let wire_model = prepared.wire_model.clone(); // Encrypted-reasoning capture applies to every Responses route whose - // request carries `include: ["reasoning.encrypted_content"]` and - // replays by provider tag: the Codex OAuth backend, and any - // wire = "responses" Custom table (the same include is sent there). - // Chat-wire Custom tables and DeepSeek (plain reasoning_text) stay - // excluded. + // request carries `include: ["reasoning.encrypted_content"]` — the + // Codex OAuth backend, OpenCode Zen's Responses roster, and any + // wire = "responses" Custom table — and replays by the endpoint-scoped + // provider tag from `reasoning_provider_tag`. Chat-wire Custom tables + // and DeepSeek (plain reasoning_text) stay excluded. let reasoning_origin = (self.api_provider == ApiProvider::OpenaiCodex + || self.api_provider == ApiProvider::OpencodeZen || (self.api_provider == ApiProvider::Custom && self.wire_format == WireFormat::Responses)) - .then(|| (self.api_provider.as_str().to_string(), wire_model.clone())); + .then(|| (self.reasoning_provider_tag(), wire_model.clone())); // The bearer Authorization header is already installed as a default // header on both the dual and the HTTP/1.1 twin client (resolved from @@ -722,9 +735,13 @@ pub(super) fn responses_tool_output(content: &str, content_blocks: Option<&[Valu } /// Convert Codewhale messages to Responses API input items. +/// +/// `reasoning_provider_tag` scopes opaque-reasoning replay to the endpoint +/// that minted the state; see [`build_responses_body_for_provider`]. pub(super) fn convert_messages_to_responses_input( request: &MessageRequest, provider: ApiProvider, + reasoning_provider_tag: &str, ) -> Vec { let is_deepseek = matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN); let mut items = Vec::new(); @@ -819,7 +836,11 @@ pub(super) fn convert_messages_to_responses_input( thinking, state, .. } => { if let Some(state) = state { - if state.provider == provider.as_str() + // Endpoint-scoped replay: the tag must match + // the endpoint this request is bound to + // (provider slug, plus the table identity for + // Custom), alongside api shape and exact model. + if state.provider == reasoning_provider_tag && state.api == "openai-responses" && state.model == request.model { diff --git a/crates/tui/src/client/responses/tests.rs b/crates/tui/src/client/responses/tests.rs index 6ba42339e4..abc8160e1a 100644 --- a/crates/tui/src/client/responses/tests.rs +++ b/crates/tui/src/client/responses/tests.rs @@ -568,7 +568,11 @@ fn concentrate_responses_body_sends_only_documented_fields() { cache_control: None, }]); - let body = build_responses_body_for_provider(&request, ApiProvider::Concentrate); + let body = build_responses_body_for_provider( + &request, + ApiProvider::Concentrate, + ApiProvider::Concentrate.as_str(), + ); let documented = [ "model", "input", @@ -617,7 +621,11 @@ fn concentrate_responses_body_sends_only_documented_fields() { // The same request on the generic Responses path still carries the // OpenAI-only fields, so the Concentrate branch is a deliberate subset. - let generic = build_responses_body_for_provider(&request, ApiProvider::Openai); + let generic = build_responses_body_for_provider( + &request, + ApiProvider::Openai, + ApiProvider::Openai.as_str(), + ); assert!( generic.get("store").is_some() && generic.get("include").is_some() @@ -644,7 +652,11 @@ fn deepseek_flash_responses_body_uses_stateless_0731_contract() { }, ); - let body = build_responses_body_for_provider(&request, ApiProvider::Deepseek); + let body = build_responses_body_for_provider( + &request, + ApiProvider::Deepseek, + ApiProvider::Deepseek.as_str(), + ); assert_eq!(body["model"], "deepseek-v4-flash"); assert_eq!(body["max_output_tokens"], 128); @@ -677,7 +689,11 @@ fn codex_responses_body_omits_the_output_cap_the_backend_rejects() { let mut request = minimal_responses_request(); request.max_tokens = 4_096; - let codex = build_responses_body_for_provider(&request, ApiProvider::OpenaiCodex); + let codex = build_responses_body_for_provider( + &request, + ApiProvider::OpenaiCodex, + ApiProvider::OpenaiCodex.as_str(), + ); assert!( codex.get("max_output_tokens").is_none(), "Codex Responses body names a parameter its backend rejects: {codex}" @@ -687,7 +703,11 @@ fn codex_responses_body_omits_the_output_cap_the_backend_rejects() { "no alternate output-cap spelling may sneak onto the Codex wire: {codex}" ); - let deepseek = build_responses_body_for_provider(&request, ApiProvider::Deepseek); + let deepseek = build_responses_body_for_provider( + &request, + ApiProvider::Deepseek, + ApiProvider::Deepseek.as_str(), + ); assert_eq!(deepseek["max_output_tokens"], json!(4_096)); } @@ -714,7 +734,11 @@ fn codex_replays_only_exact_model_opaque_reasoning_state() { }, ); - let exact = build_responses_body_for_provider(&request, ApiProvider::OpenaiCodex); + let exact = build_responses_body_for_provider( + &request, + ApiProvider::OpenaiCodex, + ApiProvider::OpenaiCodex.as_str(), + ); let exact_wire = exact.to_string(); assert!(!exact_wire.contains(SENTINEL), "{exact}"); assert_eq!(exact.pointer("/input/0/type"), Some(&json!("reasoning"))); @@ -726,7 +750,11 @@ fn codex_replays_only_exact_model_opaque_reasoning_state() { ); request.model = "gpt-5.6".to_string(); - let switched_model = build_responses_body_for_provider(&request, ApiProvider::OpenaiCodex); + let switched_model = build_responses_body_for_provider( + &request, + ApiProvider::OpenaiCodex, + ApiProvider::OpenaiCodex.as_str(), + ); assert!(!switched_model.to_string().contains(SENTINEL)); assert!( switched_model @@ -736,7 +764,11 @@ fn codex_replays_only_exact_model_opaque_reasoning_state() { "{switched_model}" ); - let switched_provider = build_responses_body_for_provider(&request, ApiProvider::Deepseek); + let switched_provider = build_responses_body_for_provider( + &request, + ApiProvider::Deepseek, + ApiProvider::Deepseek.as_str(), + ); let switched_wire = switched_provider.to_string(); assert!(!switched_wire.contains(SENTINEL), "{switched_provider}"); assert!( @@ -1090,7 +1122,11 @@ fn responses_input_includes_user_role_tool_results() { top_p: None, }; - let input = convert_messages_to_responses_input(&request, ApiProvider::OpenaiCodex); + let input = convert_messages_to_responses_input( + &request, + ApiProvider::OpenaiCodex, + ApiProvider::OpenaiCodex.as_str(), + ); assert_eq!(input[0]["type"], "function_call"); assert_eq!(input[0]["call_id"], "call_abc"); @@ -1126,7 +1162,11 @@ fn responses_input_encodes_tool_call_names() { top_p: None, }; - let input = convert_messages_to_responses_input(&request, ApiProvider::OpenaiCodex); + let input = convert_messages_to_responses_input( + &request, + ApiProvider::OpenaiCodex, + ApiProvider::OpenaiCodex.as_str(), + ); assert_eq!(input[0]["type"], "function_call"); assert_eq!(input[0]["name"], to_api_tool_name("web.run")); @@ -1253,7 +1293,11 @@ fn user_image_becomes_an_input_image_item() { }, }); - let items = convert_messages_to_responses_input(&request, ApiProvider::OpenaiCodex); + let items = convert_messages_to_responses_input( + &request, + ApiProvider::OpenaiCodex, + ApiProvider::OpenaiCodex.as_str(), + ); let user = items .iter() @@ -1305,7 +1349,11 @@ fn tool_result_image_becomes_native_function_output_content() { }, ]; - let items = convert_messages_to_responses_input(&request, ApiProvider::OpenaiCodex); + let items = convert_messages_to_responses_input( + &request, + ApiProvider::OpenaiCodex, + ApiProvider::OpenaiCodex.as_str(), + ); let output = items .iter() .find(|item| item["type"] == "function_call_output") @@ -1343,7 +1391,11 @@ fn responses_input_keeps_system_role_history_messages() { }, ); - let items = convert_messages_to_responses_input(&request, ApiProvider::OpenaiCodex); + let items = convert_messages_to_responses_input( + &request, + ApiProvider::OpenaiCodex, + ApiProvider::OpenaiCodex.as_str(), + ); let system = items .iter() @@ -1431,20 +1483,29 @@ async fn forkguard_custom_responses_stream_captures_encrypted_reasoning_as_opaqu } let state = captured.expect("encrypted reasoning state delta on the Custom route"); - assert_eq!(state.provider, ApiProvider::Custom.as_str()); + assert_eq!( + state.provider, "custom/pinvou_responses", + "the tag must carry the minting table, not the shared `custom` slug" + ); assert_eq!(state.api, "openai-responses"); assert_eq!(state.id.as_deref(), Some("rs_custom")); assert_eq!(state.encrypted_content, "enc_custom_state"); + assert_eq!( + state.model, "gpt-5.5", + "the captured wire model is the replay gate's other key" + ); } -/// The replay gate matches Custom-tagged reasoning state by provider string -/// and exact model: an exact match replays the encrypted item, a model -/// switch or a different provider must not. +/// The replay gate matches Custom-tagged reasoning state by endpoint-scoped +/// provider tag and exact model: an exact table+model match replays the +/// encrypted item, while a model switch, a different table, or a different +/// provider must not — table A's encrypted reasoning never rides to table B. #[test] fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() { const SENTINEL: &str = "readable private reasoning must not be replayed"; + const MINTING_TABLE: &str = "custom/pinvou_responses"; let state = OpaqueReasoningState { - provider: ApiProvider::Custom.as_str().to_string(), + provider: MINTING_TABLE.to_string(), api: "openai-responses".to_string(), model: "gpt-6-sol".to_string(), id: Some("rs_custom".to_string()), @@ -1464,7 +1525,7 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() }, ); - let exact = build_responses_body_for_provider(&request, ApiProvider::Custom); + let exact = build_responses_body_for_provider(&request, ApiProvider::Custom, MINTING_TABLE); let exact_wire = exact.to_string(); assert!(!exact_wire.contains(SENTINEL), "{exact}"); assert_eq!(exact.pointer("/input/0/type"), Some(&json!("reasoning"))); @@ -1475,8 +1536,27 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() Some(&json!("enc_custom_payload")) ); + // Same model on a DIFFERENT named table: the shared `custom` slug must + // not match, so table A's opaque state never rides table B's wire. + let switched_table = + build_responses_body_for_provider(&request, ApiProvider::Custom, "custom/other_relay"); + let switched_table_wire = switched_table.to_string(); + assert!(!switched_table_wire.contains(SENTINEL)); + assert!( + !switched_table_wire.contains("enc_custom_payload"), + "cross-table replay must drop the foreign encrypted item: {switched_table}" + ); + assert!( + switched_table + .get("input") + .and_then(Value::as_array) + .is_some_and(|items| items.iter().all(|item| item["type"] != "reasoning")), + "{switched_table}" + ); + request.model = "gpt-6-luna".to_string(); - let switched_model = build_responses_body_for_provider(&request, ApiProvider::Custom); + let switched_model = + build_responses_body_for_provider(&request, ApiProvider::Custom, MINTING_TABLE); assert!(!switched_model.to_string().contains(SENTINEL)); assert!( switched_model diff --git a/crates/tui/src/client/role_placement.rs b/crates/tui/src/client/role_placement.rs index 54257f5df9..79fd14b8cd 100644 --- a/crates/tui/src/client/role_placement.rs +++ b/crates/tui/src/client/role_placement.rs @@ -377,6 +377,7 @@ mod adapter_agreement_tests { let items = responses::convert_messages_to_responses_input( &request(transcript()), ApiProvider::Openai, + ApiProvider::Openai.as_str(), ); assert_eq!( roles(&items), From a773fab1d0ca1324b26d29d747755433f064570a Mon Sep 17 00:00:00 2001 From: asto Date: Tue, 29 Sep 2026 16:52:39 +0000 Subject: [PATCH 05/18] fix(route): bind the custom wire gate once; pin the override's edges Round-1 review residue: the resolver's Custom wire channel was gated by two `provider_kind == Custom` checks forty lines apart - the endpoint-key remap and the protocol selection - so a future edit to one gate could silently desync the key from the protocol. The override is now bound once above both consumers. The new field is also spelled `RequestProtocol` per the route module's naming convention (same alias; request-side wire shapes are spelled that way there). Test pins: the override mints the wire even on the descriptor's loopback placeholder base URL; a wire-true custom candidate still carries default capabilities and UnknownOrStale pricing (no first-party fact resurrection); a Chat override cannot demote a Responses-descriptor builtin (OpenCode Zen); and Codex-minted opaque reasoning state never replays onto a Custom route. The protocol-mismatch tail of `rebound_for_model_protocol` now documents that the pinned override makes it unreachable for Custom clients. Signed-off-by: asto --- crates/config/src/route/resolver.rs | 33 ++++++------ crates/config/src/route/tests.rs | 67 ++++++++++++++++++++++++ crates/tui/src/client.rs | 4 ++ crates/tui/src/client/responses/tests.rs | 23 ++++++++ 4 files changed, 112 insertions(+), 15 deletions(-) diff --git a/crates/config/src/route/resolver.rs b/crates/config/src/route/resolver.rs index 2470df1f59..e54c26a1c5 100644 --- a/crates/config/src/route/resolver.rs +++ b/crates/config/src/route/resolver.rs @@ -27,6 +27,7 @@ //! There is deliberately no prompt-text / freeform field on [`RouteRequest`], //! which structurally bars prompt-content routing. +use super::RequestProtocol; use super::candidate::{ LimitField, PricingSku, ReadyRouteCandidate, ResolvedAuthSource, ResolvedEndpoint, SourcedLimitOverride, ValidationReport, @@ -40,7 +41,7 @@ use super::errors::RouteError; use super::ids::{LogicalModelRef, ModelId, ProviderId, WireModelId}; use super::offering::{ProviderModelOffering, RouteLimits, bundled_offerings}; use crate::catalog::{CatalogOffering, bundled_catalog_offerings}; -use crate::provider::{WireFormat, WirePolicy}; +use crate::provider::WirePolicy; use crate::{ProviderKind, opencode_go_chat_model_id, provider_preserves_custom_base_url_model}; /// A request to resolve into an executable route. @@ -70,7 +71,7 @@ pub struct RouteRequest { /// billing receipt, preflight, and the per-turn client binding all read /// the same protocol. Ignored for every other kind: built-ins keep their /// descriptor policy. - pub wire_override: Option, + pub wire_override: Option, } /// Resolves [`RouteRequest`]s into [`ReadyRouteCandidate`]s. @@ -260,22 +261,26 @@ impl RouteResolver { selected.capabilities = RouteCapabilities::default(); selected.pricing = PricingSku::UnknownOrStale; } - if provider_kind == ProviderKind::Custom { + // The Custom-only wire channel, bound once: the endpoint-key remap + // here and the protocol selection further down must read the same + // override, or a future edit to one `Custom` gate silently desyncs + // the key from the protocol. Built-ins keep their descriptor policy + // even if a stray override is set. + let wire_override = (provider_kind == ProviderKind::Custom) + .then_some(req.wire_override) + .flatten(); + if let Some(protocol_override) = wire_override { // A per-config `wire` override names the endpoint the custom // table actually serves; the static descriptor policy stays Chat // Completions for backward compatibility, so the candidate's // endpoint key and protocol must come from the override (the // tui route layer reads `[providers.] wire` into the // request; see `route_runtime::custom_wire_override_for`). - match req.wire_override { - Some(WireFormat::Responses) => { - selected.endpoint_key = "responses".to_string(); - } - Some(WireFormat::AnthropicMessages) => { - selected.endpoint_key = "messages".to_string(); - } - Some(WireFormat::ChatCompletions) | None => {} - } + selected.endpoint_key = match protocol_override { + RequestProtocol::Responses => "responses".to_string(), + RequestProtocol::AnthropicMessages => "messages".to_string(), + RequestProtocol::ChatCompletions => selected.endpoint_key, + }; } if provider_kind == ProviderKind::Zai { let effective_base_url = req @@ -300,9 +305,7 @@ impl RouteResolver { ); } - let protocol = (provider_kind == ProviderKind::Custom) - .then_some(req.wire_override) - .flatten() + let protocol = wire_override .or_else(|| descriptor.protocol_for_endpoint(&selected.endpoint_key)) .ok_or_else(|| RouteError::UnsupportedModelProtocol { provider: provider_id.clone(), diff --git a/crates/config/src/route/tests.rs b/crates/config/src/route/tests.rs index 579d3651dc..f980663426 100644 --- a/crates/config/src/route/tests.rs +++ b/crates/config/src/route/tests.rs @@ -1934,3 +1934,70 @@ fn wire_override_is_ignored_for_builtin_kinds() { "the builtin openai policy stays Chat Completions" ); } + +/// The override's edges: it mints the wire even with the descriptor's +/// placeholder base URL, it never resurrects unowned offering facts on a +/// custom endpoint, and a Responses-descriptor built-in ignores a Chat +/// override exactly like the Chat-policy kind ignores a Responses override. +#[test] +fn custom_wire_override_edges_stay_fail_closed() { + let resolver = RouteResolver::new(); + + // No base_url_override: the wire still comes from the override while the + // endpoint stays the Custom descriptor's loopback placeholder — failing + // closed locally instead of guessing a public host. + let placeholder = resolver + .resolve(&RouteRequest { + explicit_provider: Some(ProviderKind::Custom), + model_selector: Some(LogicalModelRef::from("gpt-6-sol".to_string())), + saved_provider_model: None, + base_url_override: None, + limit_overrides: Vec::new(), + wire_override: Some(RequestProtocol::Responses), + }) + .expect("override resolves without a base URL override"); + assert_eq!(placeholder.protocol(), RequestProtocol::Responses); + assert_eq!(placeholder.endpoint().endpoint_key, "responses"); + assert_eq!(placeholder.endpoint().base_url, "http://localhost/v1"); + + // A wire-true custom candidate must not restore capability or pricing + // facts the custom endpoint never proved, even when the override makes + // the route look first-party Responses. + let wire_true = resolver + .resolve(&RouteRequest { + explicit_provider: Some(ProviderKind::Custom), + model_selector: Some(LogicalModelRef::from("gpt-6-sol".to_string())), + saved_provider_model: None, + base_url_override: Some("https://api.openai.com/v1".to_string()), + limit_overrides: Vec::new(), + wire_override: Some(RequestProtocol::Responses), + }) + .expect("wire-true custom candidate resolves"); + assert_eq!( + wire_true.capabilities(), + RouteCapabilities::default(), + "the override must not resurrect capability facts" + ); + assert!(matches!( + wire_true.pricing(), + Some(super::candidate::PricingSku::UnknownOrStale) + )); + + // Demotion is structurally impossible: a built-in whose descriptor + // already serves Responses keeps its protocol under a Chat override. + let zen = resolver + .resolve(&RouteRequest { + explicit_provider: Some(ProviderKind::OpencodeZen), + model_selector: Some(LogicalModelRef::from("gpt-5.6-sol".to_string())), + saved_provider_model: None, + base_url_override: None, + limit_overrides: Vec::new(), + wire_override: Some(RequestProtocol::ChatCompletions), + }) + .expect("zen route resolves"); + assert_eq!( + zen.protocol(), + RequestProtocol::Responses, + "a Chat override cannot demote a Responses-descriptor builtin" + ); +} diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index cbac53763f..b73371b839 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -1492,6 +1492,10 @@ impl DeepSeekClient { rebound.route_limits = candidate_limits; return Ok(Some(rebound)); } + // A Custom client never reaches this rebuild/error tail: its pinned + // wire override makes `candidate.protocol() == self.wire_format` by + // construction, so only built-in model-aware kinds can demand a + // protocol the bound transport cannot speak. let config = config.ok_or_else(|| { anyhow::anyhow!( "{} model {:?} uses {:?}, but this client is bound to {:?} and no configuration is available to rebuild it", diff --git a/crates/tui/src/client/responses/tests.rs b/crates/tui/src/client/responses/tests.rs index abc8160e1a..ce127ed8f4 100644 --- a/crates/tui/src/client/responses/tests.rs +++ b/crates/tui/src/client/responses/tests.rs @@ -1554,6 +1554,29 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() "{switched_table}" ); + // State minted by a DIFFERENT provider (Codex) must not replay on a + // Custom route even with the same model and api shape: the tag is exact. + request.model = "gpt-6-sol".to_string(); + request.messages[0].content = vec![ContentBlock::Thinking { + thinking: SENTINEL.to_string(), + signature: None, + state: Some(OpaqueReasoningState { + provider: ApiProvider::OpenaiCodex.as_str().to_string(), + api: "openai-responses".to_string(), + model: "gpt-6-sol".to_string(), + id: Some("rs_codex".to_string()), + encrypted_content: "enc_codex_payload".to_string(), + }), + }]; + let codex_state_on_custom = + build_responses_body_for_provider(&request, ApiProvider::Custom, MINTING_TABLE); + let codex_state_wire = codex_state_on_custom.to_string(); + assert!(!codex_state_wire.contains(SENTINEL)); + assert!( + !codex_state_wire.contains("enc_codex_payload"), + "foreign-provider state must not replay onto a Custom route: {codex_state_on_custom}" + ); + request.model = "gpt-6-luna".to_string(); let switched_model = build_responses_body_for_provider(&request, ApiProvider::Custom, MINTING_TABLE); From 53ebf7672475eaec72c59264941338edd2289a3f Mon Sep 17 00:00:00 2001 From: asto18089 Date: Wed, 30 Sep 2026 01:07:40 +0800 Subject: [PATCH 06/18] fix(tui): pin wire-capture edges; readiness reads the table wire Round-1 residue pins for the custom wire channel, rebased onto the identity-scoped override work: - A Chat-wire Custom table must not mint opaque reasoning state even when a Responses-shaped stream reaches handle_responses_stream - the capture gate keys on the transport's wire, not the event shape. Deleting the wire half of the gate fails exactly this pin. - Missing or empty encrypted_content must not be captured (an empty blob would poison every later turn) while the stream keeps flowing and both reasoning blocks close. Deleting the empty-content filter fails exactly this pin. - A typo'd wire = "respones" dialect degrades to the legacy Chat default at both the override reader and the runtime candidate, so the silent-degrade contract of the shared dialect parser stays deliberate. - route_is_valid_for_model now validates the wire the per-turn route would mint: the dialect of the same table provider_config_for resolves, which also supplies the validated base URL. Outcome-identical today (Custom route validation is protocol-independent), but any future protocol-conditional validation would otherwise silently drift from the turn path. Signed-off-by: asto --- crates/tui/src/client/responses/tests.rs | 145 +++++++++++++++++++++++ crates/tui/src/provider_readiness.rs | 10 +- crates/tui/src/route_runtime.rs | 17 +++ 3 files changed, 171 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/client/responses/tests.rs b/crates/tui/src/client/responses/tests.rs index ce127ed8f4..6b9b7234e4 100644 --- a/crates/tui/src/client/responses/tests.rs +++ b/crates/tui/src/client/responses/tests.rs @@ -1496,6 +1496,151 @@ async fn forkguard_custom_responses_stream_captures_encrypted_reasoning_as_opaqu ); } +/// A Chat-wire Custom table must not capture encrypted reasoning even if a +/// Responses-shaped stream reaches `handle_responses_stream`: the capture +/// gate keys on the transport's wire, not just on the stream's event shape. +#[tokio::test] +async fn forkguard_custom_chat_stream_does_not_capture_encrypted_reasoning() { + let server = MockServer::start().await; + let sse_body = concat!( + "data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"reasoning\",\"id\":\"rs_chat\"}}\n\n", + "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"reasoning\",\"id\":\"rs_chat\",\"summary\":[],\"encrypted_content\":\"enc_chat_state\"}}\n\n", + "data: [DONE]\n\n", + ); + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("Content-Type", "text/event-stream") + .set_body_string(sse_body), + ) + .mount(&server) + .await; + + let client = { + let _env_lock = crate::test_support::lock_test_env(); + let config = Config { + provider: Some("pinvou_chat_table".to_string()), + providers: Some(ProvidersConfig { + custom: [( + "pinvou_chat_table".to_string(), + ProviderConfig { + kind: Some("openai-compatible".to_string()), + // No `wire`: the legacy table default is the Chat + // transport. + base_url: Some(format!("{}/v1", server.uri())), + api_key: Some("custom-chat-key".to_string()), + model: Some("vendor-model".to_string()), + ..ProviderConfig::default() + }, + )] + .into_iter() + .collect(), + ..ProvidersConfig::default() + }), + ..Config::default() + }; + DeepSeekClient::new(&config).expect("Custom chat client should resolve") + }; + assert_eq!(client.wire_format, WireFormat::ChatCompletions); + let mut stream = client + .handle_responses_stream( + &client + .prepare_outbound_request(minimal_responses_request(), true) + .expect("request prepares"), + ) + .await + .unwrap(); + let mut captured = None; + while let Some(event) = stream.next().await { + if let StreamEvent::ContentBlockDelta { + delta: Delta::ReasoningStateDelta { state }, + .. + } = event.unwrap() + { + captured = Some(state); + } + } + assert!( + captured.is_none(), + "a Chat-wire Custom table must not mint opaque reasoning state: {captured:?}" + ); +} + +/// A reasoning item without (or with an empty) `encrypted_content` must not +/// be captured — replaying an empty blob would poison every later turn — +/// but the stream itself keeps flowing and closes the block. +#[tokio::test] +async fn forkguard_custom_responses_capture_tolerates_missing_or_empty_encrypted_content() { + let server = MockServer::start().await; + let sse_body = concat!( + "data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"reasoning\",\"id\":\"rs_missing\"}}\n\n", + "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"reasoning\",\"id\":\"rs_missing\",\"summary\":[]}}\n\n", + "data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"reasoning\",\"id\":\"rs_empty\"}}\n\n", + "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"reasoning\",\"id\":\"rs_empty\",\"summary\":[],\"encrypted_content\":\"\"}}\n\n", + "data: [DONE]\n\n", + ); + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("Content-Type", "text/event-stream") + .set_body_string(sse_body), + ) + .mount(&server) + .await; + + let client = { + let _env_lock = crate::test_support::lock_test_env(); + let config = Config { + provider: Some("pinvou_responses".to_string()), + providers: Some(ProvidersConfig { + custom: [( + "pinvou_responses".to_string(), + ProviderConfig { + kind: Some("openai-compatible".to_string()), + wire: Some("responses".to_string()), + base_url: Some(format!("{}/v1", server.uri())), + api_key: Some("custom-responses-key".to_string()), + model: Some("gpt-6-sol".to_string()), + ..ProviderConfig::default() + }, + )] + .into_iter() + .collect(), + ..ProvidersConfig::default() + }), + ..Config::default() + }; + DeepSeekClient::new(&config).expect("Custom responses client should resolve") + }; + let mut stream = client + .handle_responses_stream( + &client + .prepare_outbound_request(minimal_responses_request(), true) + .expect("responses request prepares"), + ) + .await + .unwrap(); + let mut captured = None; + let mut blocks_closed = 0; + while let Some(event) = stream.next().await { + match event.unwrap() { + StreamEvent::ContentBlockDelta { + delta: Delta::ReasoningStateDelta { state }, + .. + } => captured = Some(state), + StreamEvent::ContentBlockStop { .. } => blocks_closed += 1, + _ => {} + } + } + assert!( + captured.is_none(), + "missing or empty encrypted_content must not be captured: {captured:?}" + ); + assert_eq!(blocks_closed, 2, "both reasoning blocks still close"); +} + /// The replay gate matches Custom-tagged reasoning state by endpoint-scoped /// provider tag and exact model: an exact table+model match replays the /// encrypted item, while a model switch, a different table, or a different diff --git a/crates/tui/src/provider_readiness.rs b/crates/tui/src/provider_readiness.rs index 3c77aacf86..d227356186 100644 --- a/crates/tui/src/provider_readiness.rs +++ b/crates/tui/src/provider_readiness.rs @@ -398,7 +398,15 @@ pub(crate) fn route_is_valid_for_model( }, limit_overrides: Vec::new(), - wire_override: None, + // Validate the same wire the per-turn route would mint: read the + // validated provider's own table dialect, not the global selection. + wire_override: (kind == codewhale_config::ProviderKind::Custom) + .then(|| { + configured.and_then(|entry| { + codewhale_config::provider::wire_dialect_override(entry.wire.as_deref()) + }) + }) + .flatten(), }; RouteResolver::new() .resolve(&request) diff --git a/crates/tui/src/route_runtime.rs b/crates/tui/src/route_runtime.rs index a14e768163..4e0c12b6d5 100644 --- a/crates/tui/src/route_runtime.rs +++ b/crates/tui/src/route_runtime.rs @@ -1891,6 +1891,23 @@ mod custom_wire_override_tests { } } + /// A typo'd dialect (`wire = "respones"`) must degrade to the legacy + /// Chat default, not fail the config and not half-resolve to another + /// wire. Pinned so the silent-degrade contract in the shared dialect + /// parser stays deliberate. + #[test] + fn forkguard_named_table_unrecognized_wire_keeps_the_chat_default() { + let config = custom_table_config( + Some("respones"), + "https://relay.example.test/v1", + "vendor-model", + ); + assert_eq!(custom_wire_override_for(&config), None); + let route = resolve_runtime_route(&config, ApiProvider::Custom, Some("vendor-model")) + .expect("named table resolves"); + assert_eq!(route.candidate.protocol(), WireFormat::ChatCompletions); + } + /// Build one config carrying two named custom tables: the ambient /// selection (`config.provider`) and a second table a persisted identity /// can pin. Each table has a distinct base URL so the test can prove the From 21b6f71efbf02b2d2a09991e6fb3a3f4c2474083 Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 30 Sep 2026 10:47:09 +0800 Subject: [PATCH 07/18] fix(responses): bind reasoning replay to the capture endpoint The endpoint-scoped replay key compared the provider tag, api shape, and model - but the tag only pins the named Custom table's NAME, not the URL behind it. A table whose base_url is edited between sessions (proxy to vendor-direct, relay swap) replayed the old endpoint's encrypted blobs to the new one, producing sticky 400s whose cause was invisible. OpaqueReasoningState gains a serde-default endpoint fingerprint (the catalog's base_url_fingerprint of the client's frozen base URL). Capture mints it; the replay gate requires it to match, while fingerprint-less states from fixed-endpoint providers and pre-existing sessions keep replaying. The custom capture/replay/turn tests pin capture binding, mismatch drop, and legacy replay. Signed-off-by: asto --- crates/core/src/request.rs | 8 ++ crates/tui/src/client.rs | 16 +++ crates/tui/src/client/responses.rs | 47 +++++++-- crates/tui/src/client/responses/tests.rs | 118 ++++++++++++++++++++--- crates/tui/src/client/role_placement.rs | 1 + 5 files changed, 171 insertions(+), 19 deletions(-) diff --git a/crates/core/src/request.rs b/crates/core/src/request.rs index 92e0650270..a27b5a9482 100644 --- a/crates/core/src/request.rs +++ b/crates/core/src/request.rs @@ -130,6 +130,14 @@ pub struct OpaqueReasoningState { #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, pub encrypted_content: String, + /// Fingerprint of the endpoint URL the state was captured from. Fixed + /// endpoint providers and states minted before this field existed carry + /// `None` (which keeps replaying); a present fingerprint must equal the + /// requesting client's, so editing a named Custom table's `base_url` + /// stops replaying the previous endpoint's opaque blobs — the provider + /// tag alone pins the table name, not the URL behind it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub endpoint: Option, } /// A single content block inside a message. diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index b73371b839..5314272991 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -1447,6 +1447,16 @@ impl DeepSeekClient { } } + /// Endpoint fingerprint minted into captured [`OpaqueReasoningState`] and + /// required by the replay gate: the tag pins the table name, not the URL + /// behind it, so a table whose `base_url` is edited between sessions must + /// stop replaying the previous endpoint's opaque blobs. Both capture and + /// replay derive this from the same frozen `base_url`, so an unchanged + /// endpoint always matches itself. + pub(super) fn reasoning_endpoint_fingerprint(&self) -> String { + base_url_fingerprint(&self.base_url) + } + /// Resolve `model` through the central route resolver and rebuild this /// client whenever its exact wire identity, limits, or protocol differs /// from the route bound at construction (#5042). `Ok(None)` means the @@ -2155,6 +2165,7 @@ impl DeepSeekClient { &request, self.api_provider, &self.reasoning_provider_tag(), + &self.reasoning_endpoint_fingerprint(), ); let is_codex = self.api_provider == ApiProvider::OpenaiCodex; let url = if is_codex { @@ -6852,6 +6863,11 @@ mod tests { assert_eq!(state.id.as_deref(), Some("rs_zen")); assert_eq!(state.encrypted_content, "enc_zen_state"); assert_eq!(state.model, "gpt-5.5"); + assert_eq!( + state.endpoint, + Some(base_url_fingerprint(&client.base_url)), + "the captured state is bound to the Zen endpoint" + ); } #[tokio::test] diff --git a/crates/tui/src/client/responses.rs b/crates/tui/src/client/responses.rs index a5748d1af9..46ad17e1a0 100644 --- a/crates/tui/src/client/responses.rs +++ b/crates/tui/src/client/responses.rs @@ -38,6 +38,7 @@ pub(super) fn build_responses_body(request: &MessageRequest) -> Value { request, ApiProvider::OpenaiCodex, ApiProvider::OpenaiCodex.as_str(), + "fp-codex-endpoint", ) } @@ -52,11 +53,17 @@ pub(super) fn build_responses_body(request: &MessageRequest) -> Value { /// [`OpaqueReasoningState`] (see `DeepSeekClient::reasoning_provider_tag`); /// the replay gate below only reattaches reasoning items whose state carries /// that exact tag, so encrypted reasoning minted by one endpoint — a named -/// Custom table — is never replayed to another. +/// Custom table — is never replayed to another. `reasoning_endpoint_fingerprint` +/// is the same client's endpoint fingerprint (see +/// `DeepSeekClient::reasoning_endpoint_fingerprint`): the tag pins the table +/// name, not the URL behind it, so a state captured before the table's +/// `base_url` was edited stops replaying. States minted before fingerprints +/// existed carry no endpoint and keep replaying. pub(super) fn build_responses_body_for_provider( request: &MessageRequest, provider: ApiProvider, reasoning_provider_tag: &str, + reasoning_endpoint_fingerprint: &str, ) -> Value { let is_deepseek = matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN); // Concentrate documents `model`, `input`, `stream`, `max_output_tokens`, @@ -103,7 +110,12 @@ pub(super) fn build_responses_body_for_provider( .unwrap_or_else(|| "You are a helpful assistant.".to_string()); // Convert messages to Responses input items. - let mut input = convert_messages_to_responses_input(request, provider, reasoning_provider_tag); + let mut input = convert_messages_to_responses_input( + request, + provider, + reasoning_provider_tag, + reasoning_endpoint_fingerprint, + ); if is_concentrate { input.insert( 0, @@ -181,13 +193,20 @@ impl DeepSeekClient { // request carries `include: ["reasoning.encrypted_content"]` — the // Codex OAuth backend, OpenCode Zen's Responses roster, and any // wire = "responses" Custom table — and replays by the endpoint-scoped - // provider tag from `reasoning_provider_tag`. Chat-wire Custom tables - // and DeepSeek (plain reasoning_text) stay excluded. + // provider tag from `reasoning_provider_tag` plus the endpoint + // fingerprint from `reasoning_endpoint_fingerprint`. Chat-wire Custom + // tables and DeepSeek (plain reasoning_text) stay excluded. let reasoning_origin = (self.api_provider == ApiProvider::OpenaiCodex || self.api_provider == ApiProvider::OpencodeZen || (self.api_provider == ApiProvider::Custom && self.wire_format == WireFormat::Responses)) - .then(|| (self.reasoning_provider_tag(), wire_model.clone())); + .then(|| { + ( + self.reasoning_provider_tag(), + self.reasoning_endpoint_fingerprint(), + wire_model.clone(), + ) + }); // The bearer Authorization header is already installed as a default // header on both the dual and the HTTP/1.1 twin client (resolved from @@ -509,7 +528,7 @@ impl DeepSeekClient { } "response.output_item.done" => { if let Some(idx) = current_block_index { - if let (Some((provider, model)), Some(item)) = + if let (Some((provider, endpoint, model)), Some(item)) = (reasoning_origin.as_ref(), event.get("item")) && item.get("type").and_then(Value::as_str) == Some("reasoning") @@ -530,6 +549,7 @@ impl DeepSeekClient { .and_then(Value::as_str) .map(str::to_string), encrypted_content: encrypted_content.to_string(), + endpoint: Some(endpoint.clone()), }, }, }); @@ -742,6 +762,7 @@ pub(super) fn convert_messages_to_responses_input( request: &MessageRequest, provider: ApiProvider, reasoning_provider_tag: &str, + reasoning_endpoint_fingerprint: &str, ) -> Vec { let is_deepseek = matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN); let mut items = Vec::new(); @@ -839,10 +860,22 @@ pub(super) fn convert_messages_to_responses_input( // Endpoint-scoped replay: the tag must match // the endpoint this request is bound to // (provider slug, plus the table identity for - // Custom), alongside api shape and exact model. + // Custom), alongside api shape, exact model, + // and the endpoint fingerprint — a table whose + // base_url was edited stops replaying the + // previous endpoint's blobs. States minted + // before fingerprints existed carry none and + // keep replaying. + let endpoint_matches = match &state.endpoint { + None => true, + Some(captured) => { + captured == reasoning_endpoint_fingerprint + } + }; if state.provider == reasoning_provider_tag && state.api == "openai-responses" && state.model == request.model + && endpoint_matches { let mut item = json!({ "type": "reasoning", diff --git a/crates/tui/src/client/responses/tests.rs b/crates/tui/src/client/responses/tests.rs index 6b9b7234e4..b8cfb5277c 100644 --- a/crates/tui/src/client/responses/tests.rs +++ b/crates/tui/src/client/responses/tests.rs @@ -572,6 +572,7 @@ fn concentrate_responses_body_sends_only_documented_fields() { &request, ApiProvider::Concentrate, ApiProvider::Concentrate.as_str(), + "fp-concentrate-endpoint", ); let documented = [ "model", @@ -625,6 +626,7 @@ fn concentrate_responses_body_sends_only_documented_fields() { &request, ApiProvider::Openai, ApiProvider::Openai.as_str(), + "fp-generic-endpoint", ); assert!( generic.get("store").is_some() @@ -656,6 +658,7 @@ fn deepseek_flash_responses_body_uses_stateless_0731_contract() { &request, ApiProvider::Deepseek, ApiProvider::Deepseek.as_str(), + "fp-deepseek-endpoint", ); assert_eq!(body["model"], "deepseek-v4-flash"); @@ -693,6 +696,7 @@ fn codex_responses_body_omits_the_output_cap_the_backend_rejects() { &request, ApiProvider::OpenaiCodex, ApiProvider::OpenaiCodex.as_str(), + "fp-codex-endpoint", ); assert!( codex.get("max_output_tokens").is_none(), @@ -707,6 +711,7 @@ fn codex_responses_body_omits_the_output_cap_the_backend_rejects() { &request, ApiProvider::Deepseek, ApiProvider::Deepseek.as_str(), + "fp-deepseek-endpoint", ); assert_eq!(deepseek["max_output_tokens"], json!(4_096)); } @@ -714,12 +719,14 @@ fn codex_responses_body_omits_the_output_cap_the_backend_rejects() { #[test] fn codex_replays_only_exact_model_opaque_reasoning_state() { const SENTINEL: &str = "readable private reasoning must not be replayed"; + const ENDPOINT_FP: &str = "fp-codex-endpoint"; let state = OpaqueReasoningState { provider: ApiProvider::OpenaiCodex.as_str().to_string(), api: "openai-responses".to_string(), model: "gpt-5.5".to_string(), id: Some("rs_opaque".to_string()), encrypted_content: "enc_opaque_payload".to_string(), + endpoint: Some(ENDPOINT_FP.to_string()), }; let mut request = minimal_responses_request(); request.messages.insert( @@ -738,6 +745,7 @@ fn codex_replays_only_exact_model_opaque_reasoning_state() { &request, ApiProvider::OpenaiCodex, ApiProvider::OpenaiCodex.as_str(), + ENDPOINT_FP, ); let exact_wire = exact.to_string(); assert!(!exact_wire.contains(SENTINEL), "{exact}"); @@ -754,6 +762,7 @@ fn codex_replays_only_exact_model_opaque_reasoning_state() { &request, ApiProvider::OpenaiCodex, ApiProvider::OpenaiCodex.as_str(), + ENDPOINT_FP, ); assert!(!switched_model.to_string().contains(SENTINEL)); assert!( @@ -768,6 +777,7 @@ fn codex_replays_only_exact_model_opaque_reasoning_state() { &request, ApiProvider::Deepseek, ApiProvider::Deepseek.as_str(), + ENDPOINT_FP, ); let switched_wire = switched_provider.to_string(); assert!(!switched_wire.contains(SENTINEL), "{switched_provider}"); @@ -828,6 +838,11 @@ async fn codex_stream_captures_encrypted_reasoning_as_opaque_state() { assert_eq!(state.model, "gpt-5.5"); assert_eq!(state.id.as_deref(), Some("rs_1")); assert_eq!(state.encrypted_content, "enc_state"); + assert_eq!( + state.endpoint, + Some(codewhale_config::catalog::base_url_fingerprint(&client.base_url)), + "the captured state is bound to the capturing endpoint" + ); } #[test] @@ -1126,6 +1141,7 @@ fn responses_input_includes_user_role_tool_results() { &request, ApiProvider::OpenaiCodex, ApiProvider::OpenaiCodex.as_str(), + "fp-codex-endpoint", ); assert_eq!(input[0]["type"], "function_call"); @@ -1166,6 +1182,7 @@ fn responses_input_encodes_tool_call_names() { &request, ApiProvider::OpenaiCodex, ApiProvider::OpenaiCodex.as_str(), + "fp-codex-endpoint", ); assert_eq!(input[0]["type"], "function_call"); @@ -1297,6 +1314,7 @@ fn user_image_becomes_an_input_image_item() { &request, ApiProvider::OpenaiCodex, ApiProvider::OpenaiCodex.as_str(), + "fp-codex-endpoint", ); let user = items @@ -1353,6 +1371,7 @@ fn tool_result_image_becomes_native_function_output_content() { &request, ApiProvider::OpenaiCodex, ApiProvider::OpenaiCodex.as_str(), + "fp-codex-endpoint", ); let output = items .iter() @@ -1395,6 +1414,7 @@ fn responses_input_keeps_system_role_history_messages() { &request, ApiProvider::OpenaiCodex, ApiProvider::OpenaiCodex.as_str(), + "fp-codex-endpoint", ); let system = items @@ -1494,6 +1514,11 @@ async fn forkguard_custom_responses_stream_captures_encrypted_reasoning_as_opaqu state.model, "gpt-5.5", "the captured wire model is the replay gate's other key" ); + assert_eq!( + state.endpoint, + Some(codewhale_config::catalog::base_url_fingerprint(&client.base_url)), + "the captured state is bound to the minting table's endpoint" + ); } /// A Chat-wire Custom table must not capture encrypted reasoning even if a @@ -1642,19 +1667,23 @@ async fn forkguard_custom_responses_capture_tolerates_missing_or_empty_encrypted } /// The replay gate matches Custom-tagged reasoning state by endpoint-scoped -/// provider tag and exact model: an exact table+model match replays the -/// encrypted item, while a model switch, a different table, or a different -/// provider must not — table A's encrypted reasoning never rides to table B. +/// provider tag, endpoint fingerprint, and exact model: an exact +/// table+endpoint+model match replays the encrypted item, while a model +/// switch, a different table, a different provider, or an edited `base_url` +/// must not — table A's encrypted reasoning never rides to table B or to +/// whatever endpoint table A later points at. #[test] fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() { const SENTINEL: &str = "readable private reasoning must not be replayed"; const MINTING_TABLE: &str = "custom/pinvou_responses"; + const MINTING_ENDPOINT_FP: &str = "fp-pinvou-responses-endpoint"; let state = OpaqueReasoningState { provider: MINTING_TABLE.to_string(), api: "openai-responses".to_string(), model: "gpt-6-sol".to_string(), id: Some("rs_custom".to_string()), encrypted_content: "enc_custom_payload".to_string(), + endpoint: Some(MINTING_ENDPOINT_FP.to_string()), }; let mut request = minimal_responses_request(); request.model = "gpt-6-sol".to_string(); @@ -1670,7 +1699,12 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() }, ); - let exact = build_responses_body_for_provider(&request, ApiProvider::Custom, MINTING_TABLE); + let exact = build_responses_body_for_provider( + &request, + ApiProvider::Custom, + MINTING_TABLE, + MINTING_ENDPOINT_FP, + ); let exact_wire = exact.to_string(); assert!(!exact_wire.contains(SENTINEL), "{exact}"); assert_eq!(exact.pointer("/input/0/type"), Some(&json!("reasoning"))); @@ -1681,14 +1715,66 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() Some(&json!("enc_custom_payload")) ); + // Same table and model, but the table's base_url was edited: the tag + // alone pins the table NAME, so the endpoint fingerprint must stop the + // old endpoint's opaque blobs from riding to the new one. + let switched_endpoint = build_responses_body_for_provider( + &request, + ApiProvider::Custom, + MINTING_TABLE, + "fp-new-endpoint-after-base-url-edit", + ); + assert!( + !switched_endpoint.to_string().contains("enc_custom_payload"), + "state must not replay onto a re-pointed endpoint: {switched_endpoint}" + ); + assert!( + switched_endpoint + .get("input") + .and_then(Value::as_array) + .is_some_and(|items| items.iter().all(|item| item["type"] != "reasoning")), + "{switched_endpoint}" + ); + + // States minted before endpoint fingerprints existed carry none; they + // keep replaying on the tagged table so old sessions survive the upgrade. + request.messages[0].content = vec![ContentBlock::Thinking { + thinking: SENTINEL.to_string(), + signature: None, + state: Some(OpaqueReasoningState { + provider: MINTING_TABLE.to_string(), + api: "openai-responses".to_string(), + model: "gpt-6-sol".to_string(), + id: Some("rs_legacy".to_string()), + encrypted_content: "enc_legacy_payload".to_string(), + endpoint: None, + }), + }]; + let legacy_state = build_responses_body_for_provider( + &request, + ApiProvider::Custom, + MINTING_TABLE, + MINTING_ENDPOINT_FP, + ); + assert_eq!( + legacy_state.pointer("/input/0/encrypted_content"), + Some(&json!("enc_legacy_payload")), + "fingerprint-less legacy state must keep replaying: {legacy_state}" + ); + // Same model on a DIFFERENT named table: the shared `custom` slug must // not match, so table A's opaque state never rides table B's wire. - let switched_table = - build_responses_body_for_provider(&request, ApiProvider::Custom, "custom/other_relay"); + let switched_table = build_responses_body_for_provider( + &request, + ApiProvider::Custom, + "custom/other_relay", + MINTING_ENDPOINT_FP, + ); let switched_table_wire = switched_table.to_string(); assert!(!switched_table_wire.contains(SENTINEL)); assert!( - !switched_table_wire.contains("enc_custom_payload"), + !switched_table_wire.contains("enc_custom_payload") + && !switched_table_wire.contains("enc_legacy_payload"), "cross-table replay must drop the foreign encrypted item: {switched_table}" ); assert!( @@ -1701,7 +1787,6 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() // State minted by a DIFFERENT provider (Codex) must not replay on a // Custom route even with the same model and api shape: the tag is exact. - request.model = "gpt-6-sol".to_string(); request.messages[0].content = vec![ContentBlock::Thinking { thinking: SENTINEL.to_string(), signature: None, @@ -1711,10 +1796,15 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() model: "gpt-6-sol".to_string(), id: Some("rs_codex".to_string()), encrypted_content: "enc_codex_payload".to_string(), + endpoint: Some(MINTING_ENDPOINT_FP.to_string()), }), }]; - let codex_state_on_custom = - build_responses_body_for_provider(&request, ApiProvider::Custom, MINTING_TABLE); + let codex_state_on_custom = build_responses_body_for_provider( + &request, + ApiProvider::Custom, + MINTING_TABLE, + MINTING_ENDPOINT_FP, + ); let codex_state_wire = codex_state_on_custom.to_string(); assert!(!codex_state_wire.contains(SENTINEL)); assert!( @@ -1723,8 +1813,12 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() ); request.model = "gpt-6-luna".to_string(); - let switched_model = - build_responses_body_for_provider(&request, ApiProvider::Custom, MINTING_TABLE); + let switched_model = build_responses_body_for_provider( + &request, + ApiProvider::Custom, + MINTING_TABLE, + MINTING_ENDPOINT_FP, + ); assert!(!switched_model.to_string().contains(SENTINEL)); assert!( switched_model diff --git a/crates/tui/src/client/role_placement.rs b/crates/tui/src/client/role_placement.rs index 79fd14b8cd..4787251c2a 100644 --- a/crates/tui/src/client/role_placement.rs +++ b/crates/tui/src/client/role_placement.rs @@ -378,6 +378,7 @@ mod adapter_agreement_tests { &request(transcript()), ApiProvider::Openai, ApiProvider::Openai.as_str(), + "fp-openai-endpoint", ); assert_eq!( roles(&items), From e698b135f64fd79fba3e0b0e43fd3429e800e326 Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 30 Sep 2026 10:50:45 +0800 Subject: [PATCH 08/18] fix(responses): derive the capture gate from the include predicate The capture gate enumerated its providers positively (Codex, Zen, Custom Responses) while the body builder's include gate was a negative list (not DeepSeek, not Concentrate). They coincided only through the current provider roster: a future Responses-wire builtin would silently start sending include: ["reasoning.encrypted_content"] without capturing, re-creating the unpaired function_call replay loss on multi-turn tool continuations. Both gates now derive from one shared predicate (responses_route_sends_encrypted_reasoning_include), so the lockstep the PR body claims holds by construction. The transport-wire half stays in the capture gate; the current route set is unchanged. Signed-off-by: asto --- crates/tui/src/client/responses.rs | 39 +++++++++++++++++++----------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/crates/tui/src/client/responses.rs b/crates/tui/src/client/responses.rs index 46ad17e1a0..6d87890eea 100644 --- a/crates/tui/src/client/responses.rs +++ b/crates/tui/src/client/responses.rs @@ -42,6 +42,19 @@ pub(super) fn build_responses_body(request: &MessageRequest) -> Value { ) } +/// Whether a Responses route's request body carries +/// `include: ["reasoning.encrypted_content"]`: every Responses-wire provider +/// except DeepSeek (stateless, plain `reasoning_text`, no `include`) and +/// Concentrate (documented fields only). The capture gate in +/// `handle_responses_stream` derives from this same predicate, so include and +/// capture cover the same route set by construction. +fn responses_route_sends_encrypted_reasoning_include(provider: ApiProvider) -> bool { + !matches!( + provider, + ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::Concentrate + ) +} + /// Build a provider-aware Responses API request body. /// /// DeepSeek-V4-Flash-0731 implements the Responses wire shape but is stateless @@ -161,11 +174,11 @@ pub(super) fn build_responses_body_for_provider( } // Every Responses route that receives this builder can replay encrypted - // reasoning — OpenAI Codex, OpenCode Zen's Responses roster, and - // `wire = "responses"` Custom tables (mirrors the capture gate in - // `handle_responses_stream`, so include and capture stay in lockstep). - // DeepSeek exposes plain `reasoning_text` and does not support `include`. - if !is_deepseek && !is_concentrate { + // reasoning. The include predicate is the same one the capture gate in + // `handle_responses_stream` derives from, so include and capture cover + // the same route set by construction — a future Responses-wire provider + // cannot silently start sending `include` without capturing. + if responses_route_sends_encrypted_reasoning_include(provider) { body["include"] = json!(["reasoning.encrypted_content"]); } @@ -191,15 +204,13 @@ impl DeepSeekClient { let wire_model = prepared.wire_model.clone(); // Encrypted-reasoning capture applies to every Responses route whose // request carries `include: ["reasoning.encrypted_content"]` — the - // Codex OAuth backend, OpenCode Zen's Responses roster, and any - // wire = "responses" Custom table — and replays by the endpoint-scoped - // provider tag from `reasoning_provider_tag` plus the endpoint - // fingerprint from `reasoning_endpoint_fingerprint`. Chat-wire Custom - // tables and DeepSeek (plain reasoning_text) stay excluded. - let reasoning_origin = (self.api_provider == ApiProvider::OpenaiCodex - || self.api_provider == ApiProvider::OpencodeZen - || (self.api_provider == ApiProvider::Custom - && self.wire_format == WireFormat::Responses)) + // same predicate the body builder uses to send the include — and + // replays by the endpoint-scoped provider tag from + // `reasoning_provider_tag` plus the endpoint fingerprint from + // `reasoning_endpoint_fingerprint`. The transport-wire half keeps + // Chat-wire Custom tables (and any other dialect) excluded. + let reasoning_origin = (self.wire_format == WireFormat::Responses + && responses_route_sends_encrypted_reasoning_include(self.api_provider)) .then(|| { ( self.reasoning_provider_tag(), From d8e92f4efe5e076e862c751dc7872b25cdf59fca Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 30 Sep 2026 10:52:28 +0800 Subject: [PATCH 09/18] fix(config): warn on unrecognized wire dialects; pin the parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A typo'd wire value ("respones") degraded silently to the default Chat Completions policy on every surface, so a user who typo'd the one string that switches their endpoint's protocol saw an opaque wrong-wire failure at request time with no diagnostic anywhere. wire_dialect_override now logs a warning for non-empty unrecognized values; recognized explicit chat spellings (chat, openai, ...) stay silent, the parse stays total, and the degrade contract is unchanged. Also pins the canonical alias sets, the normalization (case, whitespace, underscore/hyphen), and the degrade behavior with direct unit tests in the crate that owns them — coverage was previously indirect via the tui. Signed-off-by: asto --- crates/config/src/provider.rs | 106 ++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/crates/config/src/provider.rs b/crates/config/src/provider.rs index e6b5bdd50c..3efdd0286e 100644 --- a/crates/config/src/provider.rs +++ b/crates/config/src/provider.rs @@ -1774,6 +1774,9 @@ pub fn wire_dialect_prefers_responses(wire: Option<&str>) -> bool { /// `Some(AnthropicMessages)` when the string names that endpoint, `None` for /// `chat` / absent / unrecognized values (the static descriptor policy /// applies). The resolver honors the override only for `ProviderKind::Custom`. +/// A non-empty unrecognized value is most likely a typo of the one string +/// that switches the endpoint's protocol, so it is logged before degrading to +/// the default policy — the parse stays total and forward-compatible. #[must_use] pub fn wire_dialect_override(wire: Option<&str>) -> Option { if wire_dialect_prefers_responses(wire) { @@ -1781,6 +1784,18 @@ pub fn wire_dialect_override(wire: Option<&str>) -> Option { } else if wire_dialect_prefers_anthropic(wire) { Some(WireFormat::AnthropicMessages) } else { + if let Some(raw) = wire.map(str::trim).filter(|value| !value.is_empty()) { + let normalized = raw.to_ascii_lowercase().replace(['_', ' '], "-"); + if !matches!( + normalized.as_str(), + "chat" | "chat-completions" | "openai" | "openai-chat" | "openai-chat-completions" + ) { + tracing::warn!( + dialect = %raw, + "unrecognized provider wire dialect; using the default Chat Completions policy" + ); + } + } None } } @@ -1958,6 +1973,97 @@ pub fn provider_for_kind(kind: ProviderKind) -> &'static dyn Provider { mod tests { use super::*; + #[test] + fn wire_dialect_override_parses_the_canonical_alias_sets() { + // The exact alias sets are the cross-crate contract: the tui route + // layer, the app-server pass-through, the ambient client, and the + // route receipt all resolve dialects through these two lists. + for dialect in [ + "responses", + "responses-api", + "openai-responses", + "openai-responses-api", + "response", + "response-api", + "openai-responses-compat", + "responses-compat", + ] { + assert_eq!( + wire_dialect_override(Some(dialect)), + Some(WireFormat::Responses), + "{dialect} must parse as the Responses dialect" + ); + } + for dialect in [ + "anthropic", + "anthropic-messages", + "messages", + "claude", + "anthropic-compatible", + "anthropic-compat", + ] { + assert_eq!( + wire_dialect_override(Some(dialect)), + Some(WireFormat::AnthropicMessages), + "{dialect} must parse as the Anthropic Messages dialect" + ); + } + } + + #[test] + fn wire_dialect_override_normalizes_case_whitespace_and_separators() { + assert_eq!( + wire_dialect_override(Some(" Responses ")), + Some(WireFormat::Responses) + ); + assert_eq!( + wire_dialect_override(Some("OPENAI_RESPONSES")), + Some(WireFormat::Responses) + ); + assert_eq!( + wire_dialect_override(Some("Anthropic Messages")), + Some(WireFormat::AnthropicMessages) + ); + assert_eq!( + wire_dialect_override(Some("anthropic-messages")), + Some(WireFormat::AnthropicMessages) + ); + assert_eq!( + wire_dialect_override(Some("CLAUDE")), + Some(WireFormat::AnthropicMessages) + ); + } + + #[test] + fn wire_dialect_override_defaults_chat_and_degrades_unknowns() { + assert_eq!(wire_dialect_override(None), None); + assert_eq!(wire_dialect_override(Some("")), None); + assert_eq!(wire_dialect_override(Some(" ")), None); + + // Recognized explicit-chat spellings degrade silently to the static + // policy — they are deliberate, not typos. + for dialect in [ + "chat", + "chat-completions", + "openai", + "openai-chat", + "openai-chat-completions", + ] { + assert_eq!( + wire_dialect_override(Some(dialect)), + None, + "{dialect} must stay a silent no-preference value" + ); + } + + // A typo of the one string that switches the endpoint's protocol + // degrades to the default Chat policy (the tui route layer pins the + // same contract at the runtime candidate). + assert_eq!(wire_dialect_override(Some("respones")), None); + assert!(!wire_dialect_prefers_responses(Some("respones"))); + assert!(!wire_dialect_prefers_anthropic(Some("respones"))); + } + #[test] fn credential_help_covers_every_provider_without_guessing_non_key_urls() { for provider in all_providers() { From 8caceb8e8775c6da95561badf1e84d92751f9eb8 Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 30 Sep 2026 11:13:51 +0800 Subject: [PATCH 10/18] fix(tui): thread the table wire into candidate display receipts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /provider picker resolved rows through the config-free candidate wrapper, which pins the static Chat policy — so after the runtime fix a wire = "responses" row bound Responses per turn while the picker still displayed Chat Completions as its supported protocol. The wrapper now takes the wire override explicitly; the picker passes the dialect of the same row-scoped table that supplied its base URL, and the session-state and model-switch receipts thread the ambient table's dialect the same way. Callers that never consume candidate.protocol() (unpinned child admission, the /model receipt) pass None with that reason stated. Also corrects three inaccurate comments left by earlier rounds: the two-pass resolver note (the wire override rides both passes), the readiness claim (it reads the ambient selection, the same table as its base URL — identity-pinned tables are the route layer's job), and the app-server claim (this ingress reads the literal [providers.custom] field, not the named-table map), plus the ProviderConfigToml::wire field doc, which still described only the built-in dual-wire vendors. Signed-off-by: asto --- crates/app-server/src/chat_completions.rs | 6 +++-- crates/config/src/lib.rs | 10 +++++--- crates/tui/src/commands/groups/core/core.rs | 3 +++ crates/tui/src/provider_readiness.rs | 8 +++++- crates/tui/src/route_runtime.rs | 27 +++++++++++++++++---- crates/tui/src/tui/app/init.rs | 5 ++++ crates/tui/src/tui/provider_picker.rs | 6 +++++ crates/tui/src/tui/ui/apply.rs | 5 ++++ crates/tui/src/tui/ui/session_state.rs | 5 ++++ 9 files changed, 64 insertions(+), 11 deletions(-) diff --git a/crates/app-server/src/chat_completions.rs b/crates/app-server/src/chat_completions.rs index 92c087c001..c1b2104d62 100644 --- a/crates/app-server/src/chat_completions.rs +++ b/crates/app-server/src/chat_completions.rs @@ -118,8 +118,10 @@ fn resolve_endpoint( saved_provider_model: None, base_url_override: Some(base_url.clone()), limit_overrides: Vec::new(), - // The wire dialect rides the same provider_cfg that supplied the - // endpoint and key, so a `wire = "responses"` table cannot be silently + // The wire dialect rides the same literal `[providers.custom]` table + // that supplied the endpoint and key above (this ingress reads the + // legacy field, not the named-table map the tui route layer + // resolves), so a `wire = "responses"` table cannot be silently // served as chat here: the resolver mints a Responses candidate and // the handler's ChatCompletions-only guard rejects it (fail closed) // instead of forwarding to `{base}/chat/completions`. diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 76650dd69a..c00a87e4be 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -159,9 +159,13 @@ pub struct ProviderConfigToml { pub context_window: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub mode: Option, - /// Wire dialect preference for dual-protocol vendors (DeepSeek, MiniMax, - /// Model Studio): `openai` (Chat Completions, default) or `anthropic` - /// (Messages). Not a separate catalog provider — a power-user toggle. + /// Wire dialect override. Named custom-provider tables accept + /// `responses`, `anthropic` (or `messages`/`claude`), and `chat` or + /// `openai` (the Chat Completions default); dual-protocol built-in + /// vendors (DeepSeek, MiniMax, Model Studio) accept `openai` (default) + /// or `anthropic` (Messages). Unrecognized values fall back to the + /// default Chat Completions policy with a warning. Not a separate + /// catalog provider — a power-user toggle. #[serde( default, skip_serializing_if = "Option::is_none", diff --git a/crates/tui/src/commands/groups/core/core.rs b/crates/tui/src/commands/groups/core/core.rs index caf371354f..cb77e7cb93 100644 --- a/crates/tui/src/commands/groups/core/core.rs +++ b/crates/tui/src/commands/groups/core/core.rs @@ -335,6 +335,9 @@ pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult { route_base_url, app.active_context_window_override, None, + // This receipt feeds only base_url/limits below; the static + // policy is fine because protocol is never consumed here. + None, ) { Ok(resolution) => Some(resolution), Err(reason) => return CommandResult::error(reason), diff --git a/crates/tui/src/provider_readiness.rs b/crates/tui/src/provider_readiness.rs index d227356186..15adaac410 100644 --- a/crates/tui/src/provider_readiness.rs +++ b/crates/tui/src/provider_readiness.rs @@ -399,7 +399,13 @@ pub(crate) fn route_is_valid_for_model( limit_overrides: Vec::new(), // Validate the same wire the per-turn route would mint: read the - // validated provider's own table dialect, not the global selection. + // dialect of the same table `provider_config_for` resolves above (the + // ambient selection, which also supplies the base URL in every arm), + // so preflight cannot disagree with the client this readiness + // describes. Identity-pinned tables are validated by the route + // layer's identity-scoped resolution instead. Outcome-identical + // today (Custom validation is protocol-independent), pinned against + // future drift. wire_override: (kind == codewhale_config::ProviderKind::Custom) .then(|| { configured.and_then(|entry| { diff --git a/crates/tui/src/route_runtime.rs b/crates/tui/src/route_runtime.rs index 4e0c12b6d5..6cf3f866df 100644 --- a/crates/tui/src/route_runtime.rs +++ b/crates/tui/src/route_runtime.rs @@ -379,6 +379,10 @@ pub(crate) fn resolve_route_candidate( base_url_override, context_window_override, None, + // Config-free by contract: this wrapper's callers (unpinned child + // admission) consume only the wire model id and limits, never + // `candidate.protocol()`, so the static policy applies. + None, ) .map(|resolution| resolution.candidate) } @@ -428,6 +432,13 @@ pub(crate) fn resolve_unpinned_model_candidate( /// Code bare-K3 endpoint, only at the documented 1M entitlement, and only /// while fresh; this prevents generic Moonshot or stale metadata from being /// inherited by a membership-plan route. +/// +/// `custom_wire_override` must carry the dialect of the same table that +/// supplied `base_url_override` when `provider` is +/// [`ApiProvider::Custom`] — pass [`custom_wire_override_for`] on the config +/// the base URL came from. A `None` here resolves a Custom route under the +/// static Chat policy, which is only correct for callers that never consume +/// `candidate.protocol()`. pub(crate) fn resolve_route_candidate_with_context_metadata( provider: ApiProvider, model_selector: Option<&str>, @@ -435,6 +446,7 @@ pub(crate) fn resolve_route_candidate_with_context_metadata( base_url_override: Option, context_window_override: Option, provider_reported_context: Option, + custom_wire_override: Option, ) -> Result { resolve_route_candidate_with_context_metadata_and_host_limits( provider, @@ -444,9 +456,7 @@ pub(crate) fn resolve_route_candidate_with_context_metadata( context_window_override, provider_reported_context, None, - // Config-free convenience wrapper: callers here resolve non-Custom - // routes (or want the static policy), so no wire override. - None, + custom_wire_override, ) } @@ -495,8 +505,10 @@ fn resolve_route_candidate_with_context_metadata_and_host_limits( limit_overrides: Vec::new(), wire_override: custom_wire_override, }; - // First pass: resolve the route without overrides to learn the effective - // endpoint, wire model id, and catalog limits. Candidates are immutable, so + // First pass: resolve the route without limit overrides to learn the + // effective endpoint, wire model id, and catalog limits (the wire + // override rides both passes, so both mint the same protocol and + // endpoint key). Candidates are immutable, so // limit adjustments are planned from this read-only resolution and then // requested through `RouteRequest::limit_overrides` on a second pass; the // resolver applies them BEFORE minting the final candidate and records @@ -1303,6 +1315,7 @@ mod tests { base.clone(), None, None, + None, ) .expect("Kimi Code route"); assert_eq!(static_floor.context_window.tokens, 262_144); @@ -1321,6 +1334,7 @@ mod tests { context_tokens: 1_048_576, observed_at: Utc::now(), }), + None, ) .expect("configured route"); assert_eq!(configured.context_window.tokens, 1_048_576); @@ -1339,6 +1353,7 @@ mod tests { context_tokens: 1_048_576, observed_at: Utc::now(), }), + None, ) .expect("fresh documented provider metadata"); assert_eq!(reported.context_window.tokens, 1_048_576); @@ -1357,6 +1372,7 @@ mod tests { context_tokens: 1_048_576, observed_at: Utc::now() - Duration::hours(25), }), + None, ) .expect("stale metadata falls back safely"); assert_eq!( @@ -1374,6 +1390,7 @@ mod tests { context_tokens: 1_048_576, observed_at: Utc::now(), }), + None, ) .expect_err("bare k3 is rejected on the direct Moonshot endpoint (#4687)"); assert!( diff --git a/crates/tui/src/tui/app/init.rs b/crates/tui/src/tui/app/init.rs index 41699f5ede..c7bfb7e364 100644 --- a/crates/tui/src/tui/app/init.rs +++ b/crates/tui/src/tui/app/init.rs @@ -402,6 +402,11 @@ impl App { Some(configured_route_base_url.clone()), active_context_window_override, None, + // The ambient table's dialect — the same table + // `configured_route_base_url` above resolves. + (provider == ApiProvider::Custom) + .then(|| crate::route_runtime::custom_wire_override_for(config)) + .flatten(), ) .map(|resolution| { ( diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index 29a36ea9cf..2c1a9a22de 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -697,6 +697,12 @@ impl ProviderDashboardRow { .flatten(), config.context_window_for_provider_config(provider), None, + // The dialect of the same table that supplied the base URL above + // (this row's scoped config), so the row's supported-protocol + // display matches what a turn on this row would bind. + (provider == ApiProvider::Custom) + .then(|| crate::route_runtime::custom_wire_override_for(config)) + .flatten(), ); let ( base_url, diff --git a/crates/tui/src/tui/ui/apply.rs b/crates/tui/src/tui/ui/apply.rs index 4f5b8fb87e..4a4ec1c022 100644 --- a/crates/tui/src/tui/ui/apply.rs +++ b/crates/tui/src/tui/ui/apply.rs @@ -810,6 +810,11 @@ pub(crate) async fn apply_model_picker_choice( Some(config.deepseek_base_url()), config.context_window_for_provider_config(app.api_provider), None, + // The ambient table's dialect — the same table `deepseek_base_url` + // above resolves — so this receipt cannot disagree with the turn. + (app.api_provider == ApiProvider::Custom) + .then(|| crate::route_runtime::custom_wire_override_for(config)) + .flatten(), ) { Ok(resolution) => { resolved_model = resolution.candidate.wire_model_id().as_str().to_string(); diff --git a/crates/tui/src/tui/ui/session_state.rs b/crates/tui/src/tui/ui/session_state.rs index 68cab94ab0..7c2ad636e5 100644 --- a/crates/tui/src/tui/ui/session_state.rs +++ b/crates/tui/src/tui/ui/session_state.rs @@ -948,6 +948,11 @@ pub(crate) fn resolve_loaded_session_route(app: &mut App, config: &Config) { Some(config.deepseek_base_url()), context_override, None, + // The ambient table's dialect — the same table `deepseek_base_url` + // above resolves — so this receipt cannot disagree with the turn. + (app.api_provider == ApiProvider::Custom) + .then(|| crate::route_runtime::custom_wire_override_for(config)) + .flatten(), ) { Ok(resolution) => { app.set_active_route_resolution( From 672a2d993096b9c5ac80d6f2d29ee958b8388360 Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 30 Sep 2026 11:20:57 +0800 Subject: [PATCH 11/18] test: pin the wire plumbs the audit found untested Three seams could regress silently because nothing bound them: - config: the runtime receipt's wire_override (deleting it changed no receipt a test asserted) - the new test resolves responses/anthropic/ chat tables through resolve_runtime_options and pins protocol plus endpoint key. - tui: route_is_valid_for_model's dialect threading now has a resolution pin for a wire table. The bool interface cannot observe protocol directly (validation is protocol-independent); the resolver and receipt pins above carry the protocol assertions. - app-server: the ChatCompletions-only guard is now posted through the real router and asserted as a 400 provider_wire_format_unsupported. This replaces non_chat_completions_provider_rejected, which built a struct and asserted a field against itself without invoking the handler. Signed-off-by: asto --- crates/app-server/src/chat_completions.rs | 71 +++++++++++++++++------ crates/config/src/tests.rs | 40 +++++++++++++ crates/tui/src/provider_readiness.rs | 34 +++++++++++ 3 files changed, 128 insertions(+), 17 deletions(-) diff --git a/crates/app-server/src/chat_completions.rs b/crates/app-server/src/chat_completions.rs index c1b2104d62..19b23cbc99 100644 --- a/crates/app-server/src/chat_completions.rs +++ b/crates/app-server/src/chat_completions.rs @@ -1525,25 +1525,62 @@ api_key = {provider_api_key:?} assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + /// The headline fail-closed path for a `wire = "responses"` custom + /// table: the pass-through must reject with + /// `provider_wire_format_unsupported` instead of silently forwarding a + /// chat body to `{base}/chat/completions`. Posted through the real + /// router so the resolver→guard composite is pinned end to end. #[tokio::test] - async fn non_chat_completions_provider_rejected() { - // Use the test to verify WireFormat checks work for non-ChatCompletions providers. - // Anthropic's wire format is AnthropicMessages; OpenaiCodex is Responses. - let endpoint = ResolvedModelEndpoint { - provider: ProviderKind::Anthropic, - base_url: "https://api.anthropic.com".to_string(), - model: "claude-sonnet-4-20250514".to_string(), - api_key: Some("sk-ant-test".to_string()), - auth_disabled: false, - http_headers: BTreeMap::new(), - path_suffix: None, - insecure_skip_tls_verify: false, - wire_format: WireFormat::AnthropicMessages, - }; + async fn custom_responses_wire_table_is_rejected_fail_closed() { + install_crypto_provider(); + let tmp = tempfile::tempdir().expect("tempdir"); + let config_path = tmp.path().join("config.toml"); + // The base URL is never contacted: the guard fires before any + // upstream I/O. + fs::write( + &config_path, + r#" +provider = "custom" + +[providers.custom] +wire = "responses" +base_url = "https://relay.example/v1" +api_key = "custom-responses-key" +model = "gpt-6-sol" +"#, + ) + .expect("write config"); + let state = build_state(Some(config_path), None).expect("state"); + let app = app_router(state, &[]); + + let body = serde_json::json!({ + "messages": [{"role": "user", "content": "hello"}] + }); + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/v1/chat/completions") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(), + ) + .await + .unwrap(); - assert_ne!(endpoint.wire_format, WireFormat::ChatCompletions); - // The handler would reject this; we verify the wire format here. - assert_eq!(endpoint.wire_format, WireFormat::AnthropicMessages); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024) + .await + .expect("error body"); + let payload: serde_json::Value = serde_json::from_slice(&bytes).expect("json error body"); + assert_eq!(payload["error"]["code"], "provider_wire_format_unsupported"); + assert_eq!(payload["error"]["type"], "unsupported_provider"); + assert!( + payload["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("Responses")), + "the error names the offending dialect: {payload}" + ); } #[test] diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index 9ee9b6fc7e..4987ff833e 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -8774,6 +8774,46 @@ fn resolved_runtime_options_mints_a_route_candidate() { assert_eq!(route.endpoint().base_url, resolved.base_url); } +/// The runtime receipt for a custom table resolves that table's own `wire` +/// dialect, so `codewhale model resolve` receipts and readiness surfaces read +/// the same protocol the per-turn client binds. Deleting the receipt's +/// `wire_override` must fail this pin. +#[test] +fn resolved_runtime_options_threads_the_custom_tables_wire() { + let resolved = |wire: &str| -> crate::route::ReadyRouteCandidate { + let config: ConfigToml = toml::from_str(&format!( + r#" +provider = "custom" + +[providers.custom] +wire = "{wire}" +base_url = "https://relay.example/v1" +api_key = "receipt-wire-test-key" +model = "gpt-6-sol" +"# + )) + .expect("custom table config parses"); + config + .resolve_runtime_options(&CliRuntimeOverrides::default()) + .route + .expect("RouteResolver is the runtime path") + }; + + let responses = resolved("responses"); + assert_eq!(responses.protocol(), crate::provider::WireFormat::Responses); + assert_eq!(responses.endpoint().endpoint_key, "responses"); + assert_eq!(responses.endpoint().base_url, "https://relay.example/v1"); + + let anthropic = resolved("anthropic"); + assert_eq!(anthropic.protocol(), crate::provider::WireFormat::AnthropicMessages); + assert_eq!(anthropic.endpoint().endpoint_key, "messages"); + + // Explicit chat (and unset) stay on the static Chat policy. + let chat = resolved("chat"); + assert_eq!(chat.protocol(), crate::provider::WireFormat::ChatCompletions); + assert_eq!(chat.endpoint().endpoint_key, "chat"); +} + /// #5441: the runtime receipt carries the same source the surfaces print. #[test] fn resolved_runtime_options_reports_telemetry_source() { diff --git a/crates/tui/src/provider_readiness.rs b/crates/tui/src/provider_readiness.rs index 15adaac410..0540c5ccf2 100644 --- a/crates/tui/src/provider_readiness.rs +++ b/crates/tui/src/provider_readiness.rs @@ -761,6 +761,40 @@ mod tests { ); } + /// A `wire = "responses"` custom table must keep validating through + /// `route_is_valid_for_model`: the wire threading must never turn into a + /// resolution failure. Outcome-identical with the override today + /// (validation is protocol-independent), pinned against future drift. + #[test] + fn custom_wire_tables_validate_through_the_route_resolver() { + let _lock = crate::test_support::lock_test_env(); + let config = crate::config::Config { + provider: Some("pinvou_responses".to_string()), + providers: Some(crate::config::ProvidersConfig { + custom: [( + "pinvou_responses".to_string(), + crate::config::ProviderConfig { + kind: Some("openai-compatible".to_string()), + wire: Some("responses".to_string()), + base_url: Some("https://relay.example/v1".to_string()), + api_key: Some("readiness-wire-test-key".to_string()), + model: Some("gpt-6-sol".to_string()), + ..Default::default() + }, + )] + .into_iter() + .collect(), + ..Default::default() + }), + ..Default::default() + }; + assert!(route_is_valid_for_model( + &config, + ApiProvider::Custom, + Some("gpt-6-sol") + )); + } + #[test] fn deepseek_cn_compatibility_alias_uses_real_key_readiness() { let _lock = crate::test_support::lock_test_env(); From 32da3057979f60fddff7e813cb46dd79cc281a53 Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 30 Sep 2026 11:30:14 +0800 Subject: [PATCH 12/18] test: pin the capture-to-replay seam and the custom Anthropic transport Two coverage gaps the audit found: - Capture was tested at the client and replay at the pure builder, so an asymmetric edit to the tag or endpoint fingerprint derivation would only be caught by luck. The new seam test captures off a real stream on the per-turn client, places the state into history the way the turn loop commits it, and asserts turn 2's prepared request body leads with the paired reasoning item. - wire = "anthropic" custom tables were pinned only at the candidate level, one layer short of the wire. The new transport test drives resolve_runtime_route -> from_candidate against a loopback mock and asserts the POST path, x-api-key/anthropic-version headers, and the verbatim model. Also builds the custom responses turn-path client from the resolved route's identity-scoped config instead of the ambient config, mirroring the production install path - in a two-table setup the ambient table would freeze the wrong identity onto the pinned endpoint. Signed-off-by: asto --- crates/tui/src/client.rs | 99 +++++++++++++++++++- crates/tui/src/client/responses/tests.rs | 114 +++++++++++++++++++++++ 2 files changed, 212 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 5314272991..78a58083d6 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -11944,7 +11944,11 @@ mod tests { Some("gpt-6-sol"), ) .expect("named table resolves"); - let client = DeepSeekClient::from_candidate(&config, &route.candidate) + // The production turn path builds from the resolved route's + // identity-scoped config, not the ambient config — mirror it here so + // the pin would catch an ambient/identity divergence (in a two-table + // setup the ambient table would freeze the wrong identity). + let client = DeepSeekClient::from_candidate(&route.config, &route.candidate) .expect("per-turn client builds"); assert_eq!( client.wire_format, @@ -11998,4 +12002,97 @@ mod tests { "Responses body, not Chat: {body}" ); } + + /// A `wire = "anthropic"` Custom table must reach a real + /// Messages-protocol transport: the per-turn client + /// (resolve_runtime_route → from_candidate) POSTs `{base}/v1/messages` + /// with the Anthropic credential and version headers. The Responses + /// wire has the full transport pin above; this closes the same gap for + /// the Messages wire. + #[tokio::test] + async fn forkguard_custom_anthropic_route_turn_client_posts_to_the_messages_endpoint() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/messages")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "msg_custom", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "ok from stub"}], + "model": "custom-claude", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": {"input_tokens": 1, "output_tokens": 1} + }))) + .expect(1) + .mount(&server) + .await; + + let _env_lock = crate::test_support::lock_test_env(); + let config = Config { + provider: Some("pinvou_messages".to_string()), + providers: Some(ProvidersConfig { + custom: [( + "pinvou_messages".to_string(), + ProviderConfig { + kind: Some("openai-compatible".to_string()), + wire: Some("anthropic".to_string()), + base_url: Some(format!("{}/v1", server.uri())), + api_key: Some("custom-anthropic-key".to_string()), + model: Some("custom-claude".to_string()), + ..ProviderConfig::default() + }, + )] + .into_iter() + .collect(), + ..ProvidersConfig::default() + }), + ..Config::default() + }; + let route = crate::route_runtime::resolve_runtime_route( + &config, + ApiProvider::Custom, + Some("custom-claude"), + ) + .expect("named table resolves"); + assert_eq!( + route.candidate.protocol(), + WireFormat::AnthropicMessages, + "the minted candidate speaks Messages" + ); + assert_eq!(route.candidate.endpoint().endpoint_key, "messages"); + let client = DeepSeekClient::from_candidate(&route.config, &route.candidate) + .expect("per-turn client builds"); + assert_eq!(client.wire_format, WireFormat::AnthropicMessages); + + client + .create_message(minimal_zen_request("custom-claude")) + .await + .expect("Custom Messages request should succeed"); + + let requests = server.received_requests().await.expect("recorded request"); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].url.path(), + "/v1/messages", + "the turn must hit the Messages endpoint, not /chat/completions" + ); + assert_eq!( + requests[0] + .headers + .get("x-api-key") + .and_then(|value| value.to_str().ok()), + Some("custom-anthropic-key"), + "the Messages transport authenticates via x-api-key" + ); + assert_eq!( + requests[0] + .headers + .get("anthropic-version") + .and_then(|value| value.to_str().ok()), + Some("2023-06-01") + ); + let body: Value = serde_json::from_slice(&requests[0].body).expect("Messages JSON body"); + assert_eq!(body["model"], "custom-claude", "model id verbatim: {body}"); + } } diff --git a/crates/tui/src/client/responses/tests.rs b/crates/tui/src/client/responses/tests.rs index b8cfb5277c..acf12dd692 100644 --- a/crates/tui/src/client/responses/tests.rs +++ b/crates/tui/src/client/responses/tests.rs @@ -1828,3 +1828,117 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() "{switched_model}" ); } + +/// Capture and replay must agree through the real per-turn client: the state +/// captured off turn 1's stream, placed back into history the way the turn +/// loop commits it (a Thinking block ahead of any tool call), reaches turn +/// 2's prepared request body as the paired reasoning item. The pure replay +/// test above pins the gate in isolation; this pins the seam where the +/// capture side's tag and endpoint fingerprint must equal the replay side's. +#[tokio::test] +async fn forkguard_custom_responses_captured_state_replays_on_the_next_turn() { + let server = MockServer::start().await; + let sse_body = concat!( + "data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"reasoning\",\"id\":\"rs_custom\"}}\n\n", + "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"reasoning\",\"id\":\"rs_custom\",\"summary\":[],\"encrypted_content\":\"enc_custom_state\"}}\n\n", + "data: [DONE]\n\n", + ); + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("Content-Type", "text/event-stream") + .set_body_string(sse_body), + ) + .expect(1) + .mount(&server) + .await; + + let _env_lock = crate::test_support::lock_test_env(); + let config = Config { + provider: Some("pinvou_responses".to_string()), + providers: Some(ProvidersConfig { + custom: [( + "pinvou_responses".to_string(), + ProviderConfig { + kind: Some("openai-compatible".to_string()), + wire: Some("responses".to_string()), + base_url: Some(format!("{}/v1", server.uri())), + api_key: Some("custom-responses-key".to_string()), + model: Some("gpt-6-sol".to_string()), + ..ProviderConfig::default() + }, + )] + .into_iter() + .collect(), + ..ProvidersConfig::default() + }), + ..Config::default() + }; + let route = crate::route_runtime::resolve_runtime_route( + &config, + ApiProvider::Custom, + Some("gpt-6-sol"), + ) + .expect("named table resolves"); + let client = DeepSeekClient::from_candidate(&route.config, &route.candidate) + .expect("per-turn client builds"); + + let mut first = minimal_responses_request(); + first.model = "gpt-6-sol".to_string(); + let mut stream = client + .handle_responses_stream( + &client + .prepare_outbound_request(first, true) + .expect("first request prepares"), + ) + .await + .unwrap(); + let mut captured = None; + while let Some(event) = stream.next().await { + if let StreamEvent::ContentBlockDelta { + delta: Delta::ReasoningStateDelta { state }, + .. + } = event.expect("first-turn stream event") + { + captured = Some(state); + } + } + let state = captured.expect("state captured on turn 1"); + assert_eq!(state.provider, "custom/pinvou_responses"); + assert_eq!(state.encrypted_content, "enc_custom_state"); + + // Turn loop placement: the Thinking block (state included) precedes any + // tool call in the committed history. + let mut follow_up = minimal_responses_request(); + follow_up.model = "gpt-6-sol".to_string(); + follow_up.messages.insert( + 0, + Message { + role: Role::Assistant, + content: vec![ContentBlock::Thinking { + thinking: String::new(), + signature: None, + state: Some(state), + }], + }, + ); + let prepared = client + .prepare_outbound_request(follow_up, true) + .expect("second request prepares"); + let second = &prepared.body; + assert_eq!( + second.pointer("/input/0/type"), + Some(&serde_json::json!("reasoning")), + "the reasoning item must lead the replayed input: {second}" + ); + assert_eq!( + second.pointer("/input/0/encrypted_content"), + Some(&serde_json::json!("enc_custom_state")), + "turn 2's wire carries the state captured on turn 1: {second}" + ); + assert_eq!( + second.pointer("/input/0/id"), + Some(&serde_json::json!("rs_custom")) + ); +} From 1a429af28aa898b874f1b806433fba9d7f2df480 Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 30 Sep 2026 11:40:52 +0800 Subject: [PATCH 13/18] style: cargo fmt Signed-off-by: asto --- crates/config/src/tests.rs | 10 ++++++++-- crates/tui/src/client/responses.rs | 18 ++++++++---------- crates/tui/src/client/responses/tests.rs | 8 ++++++-- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index 4987ff833e..50944f28ff 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -8805,12 +8805,18 @@ model = "gpt-6-sol" assert_eq!(responses.endpoint().base_url, "https://relay.example/v1"); let anthropic = resolved("anthropic"); - assert_eq!(anthropic.protocol(), crate::provider::WireFormat::AnthropicMessages); + assert_eq!( + anthropic.protocol(), + crate::provider::WireFormat::AnthropicMessages + ); assert_eq!(anthropic.endpoint().endpoint_key, "messages"); // Explicit chat (and unset) stay on the static Chat policy. let chat = resolved("chat"); - assert_eq!(chat.protocol(), crate::provider::WireFormat::ChatCompletions); + assert_eq!( + chat.protocol(), + crate::provider::WireFormat::ChatCompletions + ); assert_eq!(chat.endpoint().endpoint_key, "chat"); } diff --git a/crates/tui/src/client/responses.rs b/crates/tui/src/client/responses.rs index 6d87890eea..bc24e89441 100644 --- a/crates/tui/src/client/responses.rs +++ b/crates/tui/src/client/responses.rs @@ -211,13 +211,13 @@ impl DeepSeekClient { // Chat-wire Custom tables (and any other dialect) excluded. let reasoning_origin = (self.wire_format == WireFormat::Responses && responses_route_sends_encrypted_reasoning_include(self.api_provider)) - .then(|| { - ( - self.reasoning_provider_tag(), - self.reasoning_endpoint_fingerprint(), - wire_model.clone(), - ) - }); + .then(|| { + ( + self.reasoning_provider_tag(), + self.reasoning_endpoint_fingerprint(), + wire_model.clone(), + ) + }); // The bearer Authorization header is already installed as a default // header on both the dual and the HTTP/1.1 twin client (resolved from @@ -879,9 +879,7 @@ pub(super) fn convert_messages_to_responses_input( // keep replaying. let endpoint_matches = match &state.endpoint { None => true, - Some(captured) => { - captured == reasoning_endpoint_fingerprint - } + Some(captured) => captured == reasoning_endpoint_fingerprint, }; if state.provider == reasoning_provider_tag && state.api == "openai-responses" diff --git a/crates/tui/src/client/responses/tests.rs b/crates/tui/src/client/responses/tests.rs index acf12dd692..25ea99ffb9 100644 --- a/crates/tui/src/client/responses/tests.rs +++ b/crates/tui/src/client/responses/tests.rs @@ -840,7 +840,9 @@ async fn codex_stream_captures_encrypted_reasoning_as_opaque_state() { assert_eq!(state.encrypted_content, "enc_state"); assert_eq!( state.endpoint, - Some(codewhale_config::catalog::base_url_fingerprint(&client.base_url)), + Some(codewhale_config::catalog::base_url_fingerprint( + &client.base_url + )), "the captured state is bound to the capturing endpoint" ); } @@ -1516,7 +1518,9 @@ async fn forkguard_custom_responses_stream_captures_encrypted_reasoning_as_opaqu ); assert_eq!( state.endpoint, - Some(codewhale_config::catalog::base_url_fingerprint(&client.base_url)), + Some(codewhale_config::catalog::base_url_fingerprint( + &client.base_url + )), "the captured state is bound to the minting table's endpoint" ); } From 5e7166c0b32de56a9a884e0c19e259a989ffcc14 Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 30 Sep 2026 18:22:53 +0800 Subject: [PATCH 14/18] fix(responses): fail closed on fingerprint-less Custom reasoning replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on this PR flagged the legacy-state arm of the endpoint gate: state.endpoint == None matched unconditionally, so a Custom-table state saved before the endpoint field existed kept replaying after the table's base_url was edited — exactly the cross-endpoint leak the fingerprint was added to close, and one that bricks the old session against the new endpoint (the undecryptable blob stays in history and keeps matching). Custom tags (the legacy root 'custom' and named 'custom/
') are the only tags whose endpoint can move under a stable tag, so they now fail closed: no proof of origin, no replay. Built-in providers have fixed catalog URLs, so their legacy states provably came from the only endpoint the tag ever had and keep replaying. The upgrade cost on Custom is one turn of reasoning continuity; fresh captures carry the fingerprint and replay resumes. Pins: the legacy Custom state assertion is flipped to require a reasoning-free request body, the legacy-root 'custom' tag gets the same pin, and a fixed-endpoint (Codex) legacy state pins the carve-out side. Signed-off-by: asto --- crates/core/src/request.rs | 14 +-- crates/tui/src/client/responses.rs | 28 +++++- crates/tui/src/client/responses/tests.rs | 112 +++++++++++++++++++++-- 3 files changed, 136 insertions(+), 18 deletions(-) diff --git a/crates/core/src/request.rs b/crates/core/src/request.rs index a27b5a9482..26f09b2692 100644 --- a/crates/core/src/request.rs +++ b/crates/core/src/request.rs @@ -130,12 +130,14 @@ pub struct OpaqueReasoningState { #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, pub encrypted_content: String, - /// Fingerprint of the endpoint URL the state was captured from. Fixed - /// endpoint providers and states minted before this field existed carry - /// `None` (which keeps replaying); a present fingerprint must equal the - /// requesting client's, so editing a named Custom table's `base_url` - /// stops replaying the previous endpoint's opaque blobs — the provider - /// tag alone pins the table name, not the URL behind it. + /// Fingerprint of the endpoint URL the state was captured from. A present + /// fingerprint must equal the requesting client's, so editing a named + /// Custom table's `base_url` stops replaying the previous endpoint's + /// opaque blobs — the provider tag alone pins the table name, not the URL + /// behind it. States minted before this field existed carry `None`: + /// those fail closed on Custom tags (no proof of origin, and the endpoint + /// can move under a stable tag) and keep replaying on fixed-endpoint + /// providers, whose URL cannot have changed. #[serde(default, skip_serializing_if = "Option::is_none")] pub endpoint: Option, } diff --git a/crates/tui/src/client/responses.rs b/crates/tui/src/client/responses.rs index bc24e89441..6285f2deb5 100644 --- a/crates/tui/src/client/responses.rs +++ b/crates/tui/src/client/responses.rs @@ -55,6 +55,17 @@ fn responses_route_sends_encrypted_reasoning_include(provider: ApiProvider) -> b ) } +/// Whether a captured provider tag names endpoint-scoped Custom identity: the +/// legacy root table (`custom`) or a named table (`custom/`), exactly +/// the tags `DeepSeekClient::reasoning_provider_tag` mints for Custom. These +/// are the only tags whose endpoint can change while the tag stays put, so +/// they are the only fingerprint-less (pre-fingerprint) states that fail +/// closed instead of replaying; a built-in provider's URL is fixed, so its +/// legacy states have nowhere else to have come from. +fn is_custom_reasoning_tag(tag: &str) -> bool { + tag == "custom" || tag.starts_with("custom/") +} + /// Build a provider-aware Responses API request body. /// /// DeepSeek-V4-Flash-0731 implements the Responses wire shape but is stateless @@ -71,7 +82,9 @@ fn responses_route_sends_encrypted_reasoning_include(provider: ApiProvider) -> b /// `DeepSeekClient::reasoning_endpoint_fingerprint`): the tag pins the table /// name, not the URL behind it, so a state captured before the table's /// `base_url` was edited stops replaying. States minted before fingerprints -/// existed carry no endpoint and keep replaying. +/// existed carry no proof of origin: Custom endpoints can move under a stable +/// tag, so those fail closed, while a fixed-endpoint provider's URL cannot +/// have changed and its old sessions keep replaying. pub(super) fn build_responses_body_for_provider( request: &MessageRequest, provider: ApiProvider, @@ -874,11 +887,16 @@ pub(super) fn convert_messages_to_responses_input( // Custom), alongside api shape, exact model, // and the endpoint fingerprint — a table whose // base_url was edited stops replaying the - // previous endpoint's blobs. States minted - // before fingerprints existed carry none and - // keep replaying. + // previous endpoint's blobs. Legacy states + // minted before fingerprints existed carry no + // proof of which endpoint produced them: + // Custom endpoints can move under a stable + // tag, so those fail closed instead of riding + // a re-pointed table's wire, while a + // fixed-endpoint provider's URL cannot have + // changed and its old sessions keep replaying. let endpoint_matches = match &state.endpoint { - None => true, + None => !is_custom_reasoning_tag(&state.provider), Some(captured) => captured == reasoning_endpoint_fingerprint, }; if state.provider == reasoning_provider_tag diff --git a/crates/tui/src/client/responses/tests.rs b/crates/tui/src/client/responses/tests.rs index 25ea99ffb9..55f363249e 100644 --- a/crates/tui/src/client/responses/tests.rs +++ b/crates/tui/src/client/responses/tests.rs @@ -1675,7 +1675,10 @@ async fn forkguard_custom_responses_capture_tolerates_missing_or_empty_encrypted /// table+endpoint+model match replays the encrypted item, while a model /// switch, a different table, a different provider, or an edited `base_url` /// must not — table A's encrypted reasoning never rides to table B or to -/// whatever endpoint table A later points at. +/// whatever endpoint table A later points at. A pre-fingerprint state (no +/// endpoint field) carries no proof of origin, so on a Custom tag it fails +/// closed too: it must not ride a table's wire even at the table's current +/// URL, because the URL may not be the one that minted it. #[test] fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() { const SENTINEL: &str = "readable private reasoning must not be replayed"; @@ -1740,8 +1743,12 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() "{switched_endpoint}" ); - // States minted before endpoint fingerprints existed carry none; they - // keep replaying on the tagged table so old sessions survive the upgrade. + // States minted before endpoint fingerprints existed carry none and fail + // closed on Custom: with no proof of which endpoint minted the blob, a + // stable table tag proves nothing about the URL behind it, so the blob + // must not ride the wire even at the table's current URL. The upgrade + // cost is one turn of reasoning continuity; fresh captures carry + // fingerprints and replay resumes from the next turn. request.messages[0].content = vec![ContentBlock::Thinking { thinking: SENTINEL.to_string(), signature: None, @@ -1760,14 +1767,59 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() MINTING_TABLE, MINTING_ENDPOINT_FP, ); - assert_eq!( - legacy_state.pointer("/input/0/encrypted_content"), - Some(&json!("enc_legacy_payload")), - "fingerprint-less legacy state must keep replaying: {legacy_state}" + let legacy_wire = legacy_state.to_string(); + assert!(!legacy_wire.contains(SENTINEL), "{legacy_state}"); + assert!( + !legacy_wire.contains("enc_legacy_payload"), + "fingerprint-less Custom state must fail closed, not replay: {legacy_state}" + ); + assert!( + legacy_state + .get("input") + .and_then(Value::as_array) + .is_some_and(|items| items.iter().all(|item| item["type"] != "reasoning")), + "{legacy_state}" + ); + + // The legacy-root custom table (identity "custom", tag "custom") is + // endpoint-scoped Custom identity too and fails closed the same way. + request.messages[0].content = vec![ContentBlock::Thinking { + thinking: SENTINEL.to_string(), + signature: None, + state: Some(OpaqueReasoningState { + provider: "custom".to_string(), + api: "openai-responses".to_string(), + model: "gpt-6-sol".to_string(), + id: Some("rs_legacy_root".to_string()), + encrypted_content: "enc_legacy_root_payload".to_string(), + endpoint: None, + }), + }]; + let legacy_root = build_responses_body_for_provider( + &request, + ApiProvider::Custom, + "custom", + MINTING_ENDPOINT_FP, + ); + assert!( + !legacy_root.to_string().contains("enc_legacy_root_payload"), + "fingerprint-less legacy-root Custom state must fail closed: {legacy_root}" ); // Same model on a DIFFERENT named table: the shared `custom` slug must // not match, so table A's opaque state never rides table B's wire. + request.messages[0].content = vec![ContentBlock::Thinking { + thinking: SENTINEL.to_string(), + signature: None, + state: Some(OpaqueReasoningState { + provider: MINTING_TABLE.to_string(), + api: "openai-responses".to_string(), + model: "gpt-6-sol".to_string(), + id: Some("rs_custom".to_string()), + encrypted_content: "enc_custom_payload".to_string(), + endpoint: Some(MINTING_ENDPOINT_FP.to_string()), + }), + }]; let switched_table = build_responses_body_for_provider( &request, ApiProvider::Custom, @@ -1833,6 +1885,52 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() ); } +/// The legacy migration is fail-closed only where the endpoint can move. A +/// fingerprint-less state from a fixed-endpoint provider predates the +/// endpoint field entirely, and that provider's URL cannot change under its +/// stable tag, so the state provably came from the only endpoint the tag +/// ever had — it keeps replaying so pre-upgrade sessions on built-in +/// providers keep their reasoning continuity. +#[test] +fn forkguard_fixed_endpoint_legacy_state_without_fingerprint_keeps_replaying() { + const SENTINEL: &str = "readable private reasoning must not be replayed"; + const ENDPOINT_FP: &str = "fp-codex-endpoint"; + let mut request = minimal_responses_request(); + request.messages.insert( + 0, + Message { + role: Role::Assistant, + content: vec![ContentBlock::Thinking { + thinking: SENTINEL.to_string(), + signature: None, + state: Some(OpaqueReasoningState { + provider: ApiProvider::OpenaiCodex.as_str().to_string(), + api: "openai-responses".to_string(), + model: request.model.clone(), + id: Some("rs_legacy_codex".to_string()), + encrypted_content: "enc_legacy_codex_payload".to_string(), + endpoint: None, + }), + }], + }, + ); + let body = build_responses_body_for_provider( + &request, + ApiProvider::OpenaiCodex, + ApiProvider::OpenaiCodex.as_str(), + ENDPOINT_FP, + ); + assert_eq!( + body.pointer("/input/0/type"), + Some(&json!("reasoning")), + "fingerprint-less fixed-endpoint state must keep replaying: {body}" + ); + assert_eq!( + body.pointer("/input/0/encrypted_content"), + Some(&json!("enc_legacy_codex_payload")) + ); +} + /// Capture and replay must agree through the real per-turn client: the state /// captured off turn 1's stream, placed back into history the way the turn /// loop commits it (a Thinking block ahead of any tool call), reaches turn From a0c1643494925c385f9d1b852504e662e6903c12 Mon Sep 17 00:00:00 2001 From: asto Date: Fri, 2 Oct 2026 14:00:56 +0800 Subject: [PATCH 15/18] fix(responses): gate fingerprint-less reasoning replay on the official endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Built-in base URLs are not fixed: config, env (OPENAI_CODEX_BASE_URL / CODEX_BASE_URL), and CLI flags can re-point any built-in provider while its capture tag stays put. The legacy arm of the replay gate therefore treated fingerprint-less built-in states as provably local when they are nothing of the sort — the exact cross-endpoint replay the endpoint fingerprint was added to close, flagged in review. The gate's `state.endpoint == None` arm now additionally requires provider_base_url_is_official(kind, current_base_url): legacy states keep replaying on the official route (pre-upgrade sessions keep their continuity) and fail closed the moment the client points elsewhere. The client threads its frozen base URL into the body builder; the comments that carried the false fixed-URL premise are rewritten, and the migration-cost claim is corrected (a fail-closed state stays in history and is dropped every turn until compaction, not one turn). Tests: a re-pointed built-in legacy state now fails closed (the reviewer-found hole), the official-route replay pin keeps passing, and the named-table fixtures these files hand-rolled collapse into one test_support::custom_named_table_config helper. Signed-off-by: asto --- crates/core/src/request.rs | 6 +- crates/tui/src/client.rs | 64 +++----- crates/tui/src/client/responses.rs | 52 +++++-- crates/tui/src/client/responses/tests.rs | 183 ++++++++++++++--------- crates/tui/src/client/role_placement.rs | 3 + crates/tui/src/test_support.rs | 37 +++++ 6 files changed, 216 insertions(+), 129 deletions(-) diff --git a/crates/core/src/request.rs b/crates/core/src/request.rs index 26f09b2692..7cc811dca8 100644 --- a/crates/core/src/request.rs +++ b/crates/core/src/request.rs @@ -136,8 +136,10 @@ pub struct OpaqueReasoningState { /// opaque blobs — the provider tag alone pins the table name, not the URL /// behind it. States minted before this field existed carry `None`: /// those fail closed on Custom tags (no proof of origin, and the endpoint - /// can move under a stable tag) and keep replaying on fixed-endpoint - /// providers, whose URL cannot have changed. + /// can move under a stable tag), and built-in tags keep replaying only + /// while the requesting client still points at the provider's official + /// endpoint — a re-pointed client has no proof of where an old state was + /// captured, so it fails closed too. #[serde(default, skip_serializing_if = "Option::is_none")] pub endpoint: Option, } diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 78a58083d6..0c394fb06f 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -1433,10 +1433,11 @@ impl DeepSeekClient { } /// Provider tag minted into captured [`OpaqueReasoningState`] and required - /// by the replay gate. Non-Custom backends are single-endpoint, so the - /// provider slug suffices; every named Custom table shares the `custom` - /// slug, so the tag carries the frozen table identity — encrypted - /// reasoning captured for one table must never replay onto another. + /// by the replay gate. Built-in backends replay under their provider slug + /// (the gate pairs it with the official-endpoint rule for fingerprint-less + /// states); every named Custom table shares the `custom` slug, so the tag + /// carries the frozen table identity — encrypted reasoning captured for + /// one table must never replay onto another. pub(super) fn reasoning_provider_tag(&self) -> String { if self.api_provider != ApiProvider::Custom { return self.api_provider.as_str().to_string(); @@ -2166,6 +2167,7 @@ impl DeepSeekClient { self.api_provider, &self.reasoning_provider_tag(), &self.reasoning_endpoint_fingerprint(), + &self.base_url, ); let is_codex = self.api_provider == ApiProvider::OpenaiCodex; let url = if is_codex { @@ -11918,26 +11920,13 @@ mod tests { .await; let _env_lock = crate::test_support::lock_test_env(); - let config = Config { - provider: Some("pinvou_responses".to_string()), - providers: Some(ProvidersConfig { - custom: [( - "pinvou_responses".to_string(), - ProviderConfig { - kind: Some("openai-compatible".to_string()), - wire: Some("responses".to_string()), - base_url: Some(format!("{}/v1", server.uri())), - api_key: Some("custom-responses-key".to_string()), - model: Some("gpt-6-sol".to_string()), - ..ProviderConfig::default() - }, - )] - .into_iter() - .collect(), - ..ProvidersConfig::default() - }), - ..Config::default() - }; + let config = crate::test_support::custom_named_table_config( + "pinvou_responses", + Some("responses"), + &format!("{}/v1", server.uri()), + "custom-responses-key", + "gpt-6-sol", + ); let route = crate::route_runtime::resolve_runtime_route( &config, ApiProvider::Custom, @@ -12029,26 +12018,13 @@ mod tests { .await; let _env_lock = crate::test_support::lock_test_env(); - let config = Config { - provider: Some("pinvou_messages".to_string()), - providers: Some(ProvidersConfig { - custom: [( - "pinvou_messages".to_string(), - ProviderConfig { - kind: Some("openai-compatible".to_string()), - wire: Some("anthropic".to_string()), - base_url: Some(format!("{}/v1", server.uri())), - api_key: Some("custom-anthropic-key".to_string()), - model: Some("custom-claude".to_string()), - ..ProviderConfig::default() - }, - )] - .into_iter() - .collect(), - ..ProvidersConfig::default() - }), - ..Config::default() - }; + let config = crate::test_support::custom_named_table_config( + "pinvou_messages", + Some("anthropic"), + &format!("{}/v1", server.uri()), + "custom-anthropic-key", + "custom-claude", + ); let route = crate::route_runtime::resolve_runtime_route( &config, ApiProvider::Custom, diff --git a/crates/tui/src/client/responses.rs b/crates/tui/src/client/responses.rs index 6285f2deb5..40b3538507 100644 --- a/crates/tui/src/client/responses.rs +++ b/crates/tui/src/client/responses.rs @@ -9,6 +9,7 @@ use anyhow::{Context, Result}; use codewhale_config::provider::WireFormat; +use codewhale_config::provider_base_url_is_official; use serde_json::{Value, json}; use crate::config::ApiProvider; @@ -39,6 +40,10 @@ pub(super) fn build_responses_body(request: &MessageRequest) -> Value { ApiProvider::OpenaiCodex, ApiProvider::OpenaiCodex.as_str(), "fp-codex-endpoint", + // The catalog Codex endpoint — the only URL fingerprint-less legacy + // states may replay onto (see the gate in + // `convert_messages_to_responses_input`). + "https://chatgpt.com/backend-api", ) } @@ -58,10 +63,11 @@ fn responses_route_sends_encrypted_reasoning_include(provider: ApiProvider) -> b /// Whether a captured provider tag names endpoint-scoped Custom identity: the /// legacy root table (`custom`) or a named table (`custom/`), exactly /// the tags `DeepSeekClient::reasoning_provider_tag` mints for Custom. These -/// are the only tags whose endpoint can change while the tag stays put, so -/// they are the only fingerprint-less (pre-fingerprint) states that fail -/// closed instead of replaying; a built-in provider's URL is fixed, so its -/// legacy states have nowhere else to have come from. +/// are the tags whose endpoint can change while the tag stays put, so they +/// are the fingerprint-less (pre-fingerprint) states that always fail closed. +/// Built-in tags take the narrower rule at the replay gate: a fingerprint-less +/// state may only ride the provider's official endpoint — without a +/// fingerprint it is the one origin this gate still vouches for. fn is_custom_reasoning_tag(tag: &str) -> bool { tag == "custom" || tag.starts_with("custom/") } @@ -82,14 +88,20 @@ fn is_custom_reasoning_tag(tag: &str) -> bool { /// `DeepSeekClient::reasoning_endpoint_fingerprint`): the tag pins the table /// name, not the URL behind it, so a state captured before the table's /// `base_url` was edited stops replaying. States minted before fingerprints -/// existed carry no proof of origin: Custom endpoints can move under a stable -/// tag, so those fail closed, while a fixed-endpoint provider's URL cannot -/// have changed and its old sessions keep replaying. +/// existed carry no proof of origin: Custom endpoints can move under a +/// stable tag, so those fail closed, and built-in tags keep replaying only +/// while `current_base_url` is still the provider's official endpoint — a +/// client re-pointed by config has no way to prove where an old state was +/// captured, so it fails closed too. +/// +/// `current_base_url` is the endpoint this request is about to be POSTed to +/// (the client's frozen base URL); it decides that legacy arm. pub(super) fn build_responses_body_for_provider( request: &MessageRequest, provider: ApiProvider, reasoning_provider_tag: &str, reasoning_endpoint_fingerprint: &str, + current_base_url: &str, ) -> Value { let is_deepseek = matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN); // Concentrate documents `model`, `input`, `stream`, `max_output_tokens`, @@ -141,6 +153,7 @@ pub(super) fn build_responses_body_for_provider( provider, reasoning_provider_tag, reasoning_endpoint_fingerprint, + current_base_url, ); if is_concentrate { input.insert( @@ -787,8 +800,19 @@ pub(super) fn convert_messages_to_responses_input( provider: ApiProvider, reasoning_provider_tag: &str, reasoning_endpoint_fingerprint: &str, + current_base_url: &str, ) -> Vec { let is_deepseek = matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN); + // Fingerprint-less (pre-fingerprint) states carry no proof of which + // endpoint minted them, so they may only replay where the endpoint cannot + // have moved: a built-in on its official endpoint family. Custom is + // excluded by its tag at the gate below (and + // `provider_base_url_is_official` rejects it outright), and a built-in + // re-pointed by config or env loses the credit — it has no way to prove + // an old state came from the new URL. + let fingerprintless_replay_official = provider + .kind() + .is_some_and(|kind| provider_base_url_is_official(kind, current_base_url)); let mut items = Vec::new(); for msg in &request.messages { @@ -891,12 +915,16 @@ pub(super) fn convert_messages_to_responses_input( // minted before fingerprints existed carry no // proof of which endpoint produced them: // Custom endpoints can move under a stable - // tag, so those fail closed instead of riding - // a re-pointed table's wire, while a - // fixed-endpoint provider's URL cannot have - // changed and its old sessions keep replaying. + // tag, so those fail closed, and a built-in + // keeps replaying only while the client still + // points at the provider's official endpoint + // (`fingerprintless_replay_official` above) — + // a re-pointed client gets no such credit. let endpoint_matches = match &state.endpoint { - None => !is_custom_reasoning_tag(&state.provider), + None => { + !is_custom_reasoning_tag(&state.provider) + && fingerprintless_replay_official + } Some(captured) => captured == reasoning_endpoint_fingerprint, }; if state.provider == reasoning_provider_tag diff --git a/crates/tui/src/client/responses/tests.rs b/crates/tui/src/client/responses/tests.rs index 55f363249e..1bd53b181b 100644 --- a/crates/tui/src/client/responses/tests.rs +++ b/crates/tui/src/client/responses/tests.rs @@ -12,6 +12,15 @@ use crate::models::SystemPrompt; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; +/// The catalog Codex endpoint: `provider_base_url_is_official` matches the +/// exact URL, so this literal pins the official side of the fingerprint-less +/// replay arm (the config crate keeps the constant crate-private). +const OFFICIAL_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api"; + +/// Placeholder endpoint for body-builder tests that do not exercise the +/// fingerprint-less replay arm — its officialness only matters there. +const BODY_TEST_BASE_URL: &str = "https://relay.example.test/v1"; + #[derive(Clone)] struct RetryThenSuccess { attempts: Arc, @@ -573,6 +582,7 @@ fn concentrate_responses_body_sends_only_documented_fields() { ApiProvider::Concentrate, ApiProvider::Concentrate.as_str(), "fp-concentrate-endpoint", + BODY_TEST_BASE_URL, ); let documented = [ "model", @@ -627,6 +637,7 @@ fn concentrate_responses_body_sends_only_documented_fields() { ApiProvider::Openai, ApiProvider::Openai.as_str(), "fp-generic-endpoint", + BODY_TEST_BASE_URL, ); assert!( generic.get("store").is_some() @@ -659,6 +670,7 @@ fn deepseek_flash_responses_body_uses_stateless_0731_contract() { ApiProvider::Deepseek, ApiProvider::Deepseek.as_str(), "fp-deepseek-endpoint", + BODY_TEST_BASE_URL, ); assert_eq!(body["model"], "deepseek-v4-flash"); @@ -697,6 +709,7 @@ fn codex_responses_body_omits_the_output_cap_the_backend_rejects() { ApiProvider::OpenaiCodex, ApiProvider::OpenaiCodex.as_str(), "fp-codex-endpoint", + OFFICIAL_CODEX_BASE_URL, ); assert!( codex.get("max_output_tokens").is_none(), @@ -712,6 +725,7 @@ fn codex_responses_body_omits_the_output_cap_the_backend_rejects() { ApiProvider::Deepseek, ApiProvider::Deepseek.as_str(), "fp-deepseek-endpoint", + BODY_TEST_BASE_URL, ); assert_eq!(deepseek["max_output_tokens"], json!(4_096)); } @@ -746,6 +760,7 @@ fn codex_replays_only_exact_model_opaque_reasoning_state() { ApiProvider::OpenaiCodex, ApiProvider::OpenaiCodex.as_str(), ENDPOINT_FP, + OFFICIAL_CODEX_BASE_URL, ); let exact_wire = exact.to_string(); assert!(!exact_wire.contains(SENTINEL), "{exact}"); @@ -763,6 +778,7 @@ fn codex_replays_only_exact_model_opaque_reasoning_state() { ApiProvider::OpenaiCodex, ApiProvider::OpenaiCodex.as_str(), ENDPOINT_FP, + OFFICIAL_CODEX_BASE_URL, ); assert!(!switched_model.to_string().contains(SENTINEL)); assert!( @@ -778,6 +794,7 @@ fn codex_replays_only_exact_model_opaque_reasoning_state() { ApiProvider::Deepseek, ApiProvider::Deepseek.as_str(), ENDPOINT_FP, + BODY_TEST_BASE_URL, ); let switched_wire = switched_provider.to_string(); assert!(!switched_wire.contains(SENTINEL), "{switched_provider}"); @@ -1144,6 +1161,7 @@ fn responses_input_includes_user_role_tool_results() { ApiProvider::OpenaiCodex, ApiProvider::OpenaiCodex.as_str(), "fp-codex-endpoint", + OFFICIAL_CODEX_BASE_URL, ); assert_eq!(input[0]["type"], "function_call"); @@ -1185,6 +1203,7 @@ fn responses_input_encodes_tool_call_names() { ApiProvider::OpenaiCodex, ApiProvider::OpenaiCodex.as_str(), "fp-codex-endpoint", + OFFICIAL_CODEX_BASE_URL, ); assert_eq!(input[0]["type"], "function_call"); @@ -1317,6 +1336,7 @@ fn user_image_becomes_an_input_image_item() { ApiProvider::OpenaiCodex, ApiProvider::OpenaiCodex.as_str(), "fp-codex-endpoint", + OFFICIAL_CODEX_BASE_URL, ); let user = items @@ -1374,6 +1394,7 @@ fn tool_result_image_becomes_native_function_output_content() { ApiProvider::OpenaiCodex, ApiProvider::OpenaiCodex.as_str(), "fp-codex-endpoint", + OFFICIAL_CODEX_BASE_URL, ); let output = items .iter() @@ -1417,6 +1438,7 @@ fn responses_input_keeps_system_role_history_messages() { ApiProvider::OpenaiCodex, ApiProvider::OpenaiCodex.as_str(), "fp-codex-endpoint", + OFFICIAL_CODEX_BASE_URL, ); let system = items @@ -1458,26 +1480,13 @@ async fn forkguard_custom_responses_stream_captures_encrypted_reasoning_as_opaqu let client = { let _env_lock = crate::test_support::lock_test_env(); - let config = Config { - provider: Some("pinvou_responses".to_string()), - providers: Some(ProvidersConfig { - custom: [( - "pinvou_responses".to_string(), - ProviderConfig { - kind: Some("openai-compatible".to_string()), - wire: Some("responses".to_string()), - base_url: Some(format!("{}/v1", server.uri())), - api_key: Some("custom-responses-key".to_string()), - model: Some("gpt-6-sol".to_string()), - ..ProviderConfig::default() - }, - )] - .into_iter() - .collect(), - ..ProvidersConfig::default() - }), - ..Config::default() - }; + let config = crate::test_support::custom_named_table_config( + "pinvou_responses", + Some("responses"), + &format!("{}/v1", server.uri()), + "custom-responses-key", + "gpt-6-sol", + ); DeepSeekClient::new(&config).expect("Custom responses client should resolve") // `DeepSeekClient::new` reads the table's `wire` dialect // (`provider_wire_format_for_config`), so this ambient client speaks @@ -1548,27 +1557,14 @@ async fn forkguard_custom_chat_stream_does_not_capture_encrypted_reasoning() { let client = { let _env_lock = crate::test_support::lock_test_env(); - let config = Config { - provider: Some("pinvou_chat_table".to_string()), - providers: Some(ProvidersConfig { - custom: [( - "pinvou_chat_table".to_string(), - ProviderConfig { - kind: Some("openai-compatible".to_string()), - // No `wire`: the legacy table default is the Chat - // transport. - base_url: Some(format!("{}/v1", server.uri())), - api_key: Some("custom-chat-key".to_string()), - model: Some("vendor-model".to_string()), - ..ProviderConfig::default() - }, - )] - .into_iter() - .collect(), - ..ProvidersConfig::default() - }), - ..Config::default() - }; + // No `wire`: the legacy table default is the Chat transport. + let config = crate::test_support::custom_named_table_config( + "pinvou_chat_table", + None, + &format!("{}/v1", server.uri()), + "custom-chat-key", + "vendor-model", + ); DeepSeekClient::new(&config).expect("Custom chat client should resolve") }; assert_eq!(client.wire_format, WireFormat::ChatCompletions); @@ -1621,26 +1617,13 @@ async fn forkguard_custom_responses_capture_tolerates_missing_or_empty_encrypted let client = { let _env_lock = crate::test_support::lock_test_env(); - let config = Config { - provider: Some("pinvou_responses".to_string()), - providers: Some(ProvidersConfig { - custom: [( - "pinvou_responses".to_string(), - ProviderConfig { - kind: Some("openai-compatible".to_string()), - wire: Some("responses".to_string()), - base_url: Some(format!("{}/v1", server.uri())), - api_key: Some("custom-responses-key".to_string()), - model: Some("gpt-6-sol".to_string()), - ..ProviderConfig::default() - }, - )] - .into_iter() - .collect(), - ..ProvidersConfig::default() - }), - ..Config::default() - }; + let config = crate::test_support::custom_named_table_config( + "pinvou_responses", + Some("responses"), + &format!("{}/v1", server.uri()), + "custom-responses-key", + "gpt-6-sol", + ); DeepSeekClient::new(&config).expect("Custom responses client should resolve") }; let mut stream = client @@ -1678,7 +1661,9 @@ async fn forkguard_custom_responses_capture_tolerates_missing_or_empty_encrypted /// whatever endpoint table A later points at. A pre-fingerprint state (no /// endpoint field) carries no proof of origin, so on a Custom tag it fails /// closed too: it must not ride a table's wire even at the table's current -/// URL, because the URL may not be the one that minted it. +/// URL, because the URL may not be the one that minted it. (Built-in tags +/// take the narrower official-endpoint rule, pinned by the two +/// legacy-state tests further down.) #[test] fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() { const SENTINEL: &str = "readable private reasoning must not be replayed"; @@ -1711,6 +1696,7 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() ApiProvider::Custom, MINTING_TABLE, MINTING_ENDPOINT_FP, + BODY_TEST_BASE_URL, ); let exact_wire = exact.to_string(); assert!(!exact_wire.contains(SENTINEL), "{exact}"); @@ -1730,6 +1716,7 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() ApiProvider::Custom, MINTING_TABLE, "fp-new-endpoint-after-base-url-edit", + BODY_TEST_BASE_URL, ); assert!( !switched_endpoint.to_string().contains("enc_custom_payload"), @@ -1746,9 +1733,10 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() // States minted before endpoint fingerprints existed carry none and fail // closed on Custom: with no proof of which endpoint minted the blob, a // stable table tag proves nothing about the URL behind it, so the blob - // must not ride the wire even at the table's current URL. The upgrade - // cost is one turn of reasoning continuity; fresh captures carry - // fingerprints and replay resumes from the next turn. + // must not ride the wire even at the table's current URL. The state + // stays in history and is dropped on every turn until compaction gives + // the session a clean slate; fresh captures carry fingerprints and + // replay resumes from the next captured turn. request.messages[0].content = vec![ContentBlock::Thinking { thinking: SENTINEL.to_string(), signature: None, @@ -1766,6 +1754,7 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() ApiProvider::Custom, MINTING_TABLE, MINTING_ENDPOINT_FP, + BODY_TEST_BASE_URL, ); let legacy_wire = legacy_state.to_string(); assert!(!legacy_wire.contains(SENTINEL), "{legacy_state}"); @@ -1800,6 +1789,7 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() ApiProvider::Custom, "custom", MINTING_ENDPOINT_FP, + BODY_TEST_BASE_URL, ); assert!( !legacy_root.to_string().contains("enc_legacy_root_payload"), @@ -1825,6 +1815,7 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() ApiProvider::Custom, "custom/other_relay", MINTING_ENDPOINT_FP, + BODY_TEST_BASE_URL, ); let switched_table_wire = switched_table.to_string(); assert!(!switched_table_wire.contains(SENTINEL)); @@ -1860,6 +1851,7 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() ApiProvider::Custom, MINTING_TABLE, MINTING_ENDPOINT_FP, + BODY_TEST_BASE_URL, ); let codex_state_wire = codex_state_on_custom.to_string(); assert!(!codex_state_wire.contains(SENTINEL)); @@ -1874,6 +1866,7 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() ApiProvider::Custom, MINTING_TABLE, MINTING_ENDPOINT_FP, + BODY_TEST_BASE_URL, ); assert!(!switched_model.to_string().contains(SENTINEL)); assert!( @@ -1885,12 +1878,11 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() ); } -/// The legacy migration is fail-closed only where the endpoint can move. A -/// fingerprint-less state from a fixed-endpoint provider predates the -/// endpoint field entirely, and that provider's URL cannot change under its -/// stable tag, so the state provably came from the only endpoint the tag -/// ever had — it keeps replaying so pre-upgrade sessions on built-in -/// providers keep their reasoning continuity. +/// The legacy migration is fail-closed wherever the endpoint can move. A +/// fingerprint-less state from a built-in provider keeps replaying only on +/// that provider's official endpoint — the one origin the gate vouches for +/// without a fingerprint — so pre-upgrade sessions on the official route +/// keep their reasoning continuity. #[test] fn forkguard_fixed_endpoint_legacy_state_without_fingerprint_keeps_replaying() { const SENTINEL: &str = "readable private reasoning must not be replayed"; @@ -1919,6 +1911,7 @@ fn forkguard_fixed_endpoint_legacy_state_without_fingerprint_keeps_replaying() { ApiProvider::OpenaiCodex, ApiProvider::OpenaiCodex.as_str(), ENDPOINT_FP, + OFFICIAL_CODEX_BASE_URL, ); assert_eq!( body.pointer("/input/0/type"), @@ -1931,6 +1924,54 @@ fn forkguard_fixed_endpoint_legacy_state_without_fingerprint_keeps_replaying() { ); } +/// The carve-out above must not extend to a re-pointed built-in: config and +/// env can move a built-in provider's base URL while its tag stays put +/// (`OPENAI_CODEX_BASE_URL`, a root/base_url override, a CLI flag), so a +/// fingerprint-less state carries no proof it came from the new endpoint. +/// The same tag that keeps replaying on the official route must fail closed +/// the moment the client points elsewhere — same rule, same direction as the +/// Custom fail-closed arm above. +#[test] +fn forkguard_repointed_builtin_legacy_state_without_fingerprint_fails_closed() { + const SENTINEL: &str = "readable private reasoning must not be replayed"; + let mut request = minimal_responses_request(); + request.messages.insert( + 0, + Message { + role: Role::Assistant, + content: vec![ContentBlock::Thinking { + thinking: SENTINEL.to_string(), + signature: None, + state: Some(OpaqueReasoningState { + provider: ApiProvider::OpenaiCodex.as_str().to_string(), + api: "openai-responses".to_string(), + model: request.model.clone(), + id: Some("rs_legacy_codex".to_string()), + encrypted_content: "enc_legacy_codex_payload".to_string(), + endpoint: None, + }), + }], + }, + ); + let body = build_responses_body_for_provider( + &request, + ApiProvider::OpenaiCodex, + ApiProvider::OpenaiCodex.as_str(), + "fp-codex-endpoint", + "https://proxy.example.test/backend-api", + ); + assert!( + !body.to_string().contains("enc_legacy_codex_payload"), + "a fingerprint-less built-in state must not replay onto a re-pointed endpoint: {body}" + ); + assert!( + body.get("input") + .and_then(Value::as_array) + .is_some_and(|items| items.iter().all(|item| item["type"] != "reasoning")), + "{body}" + ); +} + /// Capture and replay must agree through the real per-turn client: the state /// captured off turn 1's stream, placed back into history the way the turn /// loop commits it (a Thinking block ahead of any tool call), reaches turn diff --git a/crates/tui/src/client/role_placement.rs b/crates/tui/src/client/role_placement.rs index 4787251c2a..490c12147c 100644 --- a/crates/tui/src/client/role_placement.rs +++ b/crates/tui/src/client/role_placement.rs @@ -379,6 +379,9 @@ mod adapter_agreement_tests { ApiProvider::Openai, ApiProvider::Openai.as_str(), "fp-openai-endpoint", + // Inert here: this test carries no fingerprint-less reasoning + // state, so the endpoint's officialness never enters the gate. + "https://relay.example.test/v1", ); assert_eq!( roles(&items), diff --git a/crates/tui/src/test_support.rs b/crates/tui/src/test_support.rs index 138bb0cab1..4620e34291 100644 --- a/crates/tui/src/test_support.rs +++ b/crates/tui/src/test_support.rs @@ -319,6 +319,43 @@ pub(crate) fn test_tui_options(workspace: impl AsRef) -> crate::tui::app:: } } +/// A one-table Custom test config: `provider` selects the named +/// `[providers.]` table with the given wire dialect, endpoint, +/// credential, and model. `wire = None` keeps the legacy Chat default. +/// +/// Consolidates the per-test `[providers.]` literals (wire-override +/// route tests, transport pins, readiness, prompt suggestion); tests that +/// need a second table or extra fields mutate the returned value at the call +/// site, per the same convention as [`test_tui_options`]. +pub(crate) fn custom_named_table_config( + name: &str, + wire: Option<&str>, + base_url: &str, + api_key: &str, + model: &str, +) -> crate::config::Config { + crate::config::Config { + provider: Some(name.to_string()), + providers: Some(crate::config::ProvidersConfig { + custom: [( + name.to_string(), + crate::config::ProviderConfig { + kind: Some("openai-compatible".to_string()), + wire: wire.map(str::to_string), + base_url: Some(base_url.to_string()), + api_key: Some(api_key.to_string()), + model: Some(model.to_string()), + ..crate::config::ProviderConfig::default() + }, + )] + .into_iter() + .collect(), + ..crate::config::ProvidersConfig::default() + }), + ..crate::config::Config::default() + } +} + /// Build an `App` whose observable state does not depend on the developer's /// machine. /// From 6b22aaa0e8f9230f09a6d5e012d39e1e90b50d9c Mon Sep 17 00:00:00 2001 From: asto Date: Fri, 2 Oct 2026 14:01:20 +0800 Subject: [PATCH 16/18] fix(tui): gate the prompt suggestion on the resolved route's protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit route_is_supported_suggestion_provider is a static pre-filter that cannot see a named table's wire dialect, so a wire = "responses" / "anthropic" Custom table passed it. Before the runtime-route fix the turn itself also rode Chat, so the opt-in ghost-text suggestion and the turn agreed; with wire-true turns it became the one remaining path still POSTing a hard-coded Chat body to {base}/chat/completions on a table whose resolved route answers on another protocol. resolve_credentials_for_identity now fails closed unless the resolved route candidate speaks Chat Completions — the same identity-scoped resolution the turn path uses, so the suggestion can never disagree with the wire the turn ran on. Pinned both ways: a wire=responses table resolves nothing, the same table on its legacy Chat wire still does. Signed-off-by: asto --- crates/tui/src/tui/prompt_suggestion.rs | 71 +++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 5 deletions(-) diff --git a/crates/tui/src/tui/prompt_suggestion.rs b/crates/tui/src/tui/prompt_suggestion.rs index 0dfe5343b0..de4ba4f63e 100644 --- a/crates/tui/src/tui/prompt_suggestion.rs +++ b/crates/tui/src/tui/prompt_suggestion.rs @@ -14,6 +14,7 @@ use tracing::debug; use crate::config::{ApiProvider, Config}; use crate::core::events::TurnRoute; use crate::route_receipt::{TurnRouteReceipt, endpoint_identity}; +use codewhale_config::provider::WireFormat; /// The exact route authority a turn was launched against. /// @@ -149,11 +150,13 @@ impl fmt::Debug for SuggestionLaunch { } } -/// Whether a provider speaks the ordinary OpenAI-compatible +/// Whether a provider can ever carry the ordinary OpenAI-compatible /// `/chat/completions` shape [`generate_suggestion`] hardcodes. /// -/// Gate on wire protocol, not a vendor enum: Anthropic Messages and the -/// OpenAI Responses API are different request shapes and stay out. +/// Static pre-filter only: it reads the provider's default wire and cannot +/// see a named Custom table's `wire` dialect, so a Custom provider passes it +/// here and [`resolve_credentials_for_identity`] applies the authoritative +/// gate on the resolved route candidate's protocol. #[must_use] pub fn route_is_supported_suggestion_provider(provider: ApiProvider) -> bool { crate::client::provider_speaks_chat_completions(provider) @@ -164,8 +167,10 @@ pub fn route_is_supported_suggestion_provider(provider: ApiProvider) -> bool { /// The identity is revalidated against live config and then scoped with /// `resolve_runtime_route_for_identity`, so the key and endpoint come from that /// route's own configuration rather than from whichever provider happens to be -/// selected now. An identity that no longer resolves, or that now resolves to a -/// different provider kind, yields `None`. +/// selected now. An identity that no longer resolves, that now resolves to a +/// different provider kind, or whose resolved candidate no longer speaks Chat +/// Completions (a named table's `wire` dialect — invisible to the static +/// provider gate) yields `None`. /// /// The returned `base_url` is the **resolved route candidate's** endpoint, not /// `Config::deepseek_base_url()`. Those two are not the same string: the config @@ -195,6 +200,15 @@ fn resolve_credentials_for_identity( if resolved.identity.provider != provider { return None; } + // The route candidate is the authority on this identity's wire. The + // static provider gate above cannot see a named table's `wire` dialect, + // so a `wire = "responses"` / `"anthropic"` Custom table passes it and + // must be stopped here: this helper only speaks the ordinary Chat + // Completions shape, and a hard-coded Chat body must never reach a route + // the turn itself runs on another protocol. + if resolved.candidate.protocol() != WireFormat::ChatCompletions { + return None; + } // This helper intentionally sends the ordinary Chat Completions shape. // A configured path override may describe a provider-specific transport // contract that this bounded feature does not implement, so fail closed @@ -1742,4 +1756,51 @@ mod tests { "an absent turn endpoint must fail closed" ); } + + /// A named table's `wire` dialect is invisible to the static provider + /// gate (Custom's default wire is Chat), so the authoritative protocol + /// gate must live on the resolved route candidate: a `wire = "responses"` + /// table serves its turns on `/responses` and must never receive this + /// helper's hard-coded Chat body, while the same table without the + /// override keeps resolving suggestion credentials. + #[test] + fn forkguard_wire_responses_table_gets_no_suggestion_chat_body() { + let _env_lock = crate::test_support::lock_test_env(); + let responses_config = crate::test_support::custom_named_table_config( + "pinvou_responses", + Some("responses"), + "https://relay.example/v1", + "custom-responses-key", + "gpt-6-sol", + ); + assert!( + resolve_credentials_for_identity( + &responses_config, + ApiProvider::Custom, + "pinvou_responses", + "gpt-6-sol", + ) + .is_none(), + "a wire=responses table must not receive the Chat Completions suggestion body" + ); + + // Control: the same table on its legacy Chat wire still resolves, so + // the gate above (not the identity resolution) is what failed closed. + let chat_config = crate::test_support::custom_named_table_config( + "pinvou_chat", + None, + "https://relay.example/v1", + "custom-chat-key", + "vendor-model", + ); + let credentials = resolve_credentials_for_identity( + &chat_config, + ApiProvider::Custom, + "pinvou_chat", + "vendor-model", + ) + .expect("a plain Chat table still resolves suggestion credentials"); + assert_eq!(credentials.base_url, "https://relay.example/v1"); + assert_eq!(credentials.api_key, "custom-chat-key"); + } } From cd1762ae2fa255eedb29de0d850f2f72970a2a88 Mon Sep 17 00:00:00 2001 From: asto Date: Fri, 2 Oct 2026 14:01:20 +0800 Subject: [PATCH 17/18] test: route the last named-table fixture through the helper; pin the anthropic dialect on the pass-through guard The readiness wire test now builds its table through test_support::custom_named_table_config like every other named-table test, and the app-server fail-closed pin gains the wire = "anthropic" arm next to the responses one: the Chat-Completions-only guard must reject both dialects through the real router. Signed-off-by: asto --- crates/app-server/src/chat_completions.rs | 57 +++++++++++++++++++++++ crates/tui/src/provider_readiness.rs | 27 +++-------- 2 files changed, 64 insertions(+), 20 deletions(-) diff --git a/crates/app-server/src/chat_completions.rs b/crates/app-server/src/chat_completions.rs index 19b23cbc99..22fbf4780c 100644 --- a/crates/app-server/src/chat_completions.rs +++ b/crates/app-server/src/chat_completions.rs @@ -1583,6 +1583,63 @@ model = "gpt-6-sol" ); } + /// Same fail-closed contract for the Anthropic dialect: a + /// `wire = "anthropic"` custom table resolves to a Messages-protocol + /// route and must be rejected by the Chat-Completions-only guard, not + /// forwarded a chat body. + #[tokio::test] + async fn custom_anthropic_wire_table_is_rejected_fail_closed() { + install_crypto_provider(); + let tmp = tempfile::tempdir().expect("tempdir"); + let config_path = tmp.path().join("config.toml"); + // The base URL is never contacted: the guard fires before any + // upstream I/O. + fs::write( + &config_path, + r#" +provider = "custom" + +[providers.custom] +wire = "anthropic" +base_url = "https://relay.example/v1" +api_key = "custom-anthropic-key" +model = "custom-claude" +"#, + ) + .expect("write config"); + let state = build_state(Some(config_path), None).expect("state"); + let app = app_router(state, &[]); + + let body = serde_json::json!({ + "messages": [{"role": "user", "content": "hello"}] + }); + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/v1/chat/completions") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024) + .await + .expect("error body"); + let payload: serde_json::Value = serde_json::from_slice(&bytes).expect("json error body"); + assert_eq!(payload["error"]["code"], "provider_wire_format_unsupported"); + assert_eq!(payload["error"]["type"], "unsupported_provider"); + assert!( + payload["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("AnthropicMessages")), + "the error names the offending dialect: {payload}" + ); + } + #[test] fn upstream_url_defaults_to_v1_chat_completions() { let endpoint = ResolvedModelEndpoint { diff --git a/crates/tui/src/provider_readiness.rs b/crates/tui/src/provider_readiness.rs index 0540c5ccf2..a8d91b6f52 100644 --- a/crates/tui/src/provider_readiness.rs +++ b/crates/tui/src/provider_readiness.rs @@ -768,26 +768,13 @@ mod tests { #[test] fn custom_wire_tables_validate_through_the_route_resolver() { let _lock = crate::test_support::lock_test_env(); - let config = crate::config::Config { - provider: Some("pinvou_responses".to_string()), - providers: Some(crate::config::ProvidersConfig { - custom: [( - "pinvou_responses".to_string(), - crate::config::ProviderConfig { - kind: Some("openai-compatible".to_string()), - wire: Some("responses".to_string()), - base_url: Some("https://relay.example/v1".to_string()), - api_key: Some("readiness-wire-test-key".to_string()), - model: Some("gpt-6-sol".to_string()), - ..Default::default() - }, - )] - .into_iter() - .collect(), - ..Default::default() - }), - ..Default::default() - }; + let config = crate::test_support::custom_named_table_config( + "pinvou_responses", + Some("responses"), + "https://relay.example/v1", + "readiness-wire-test-key", + "gpt-6-sol", + ); assert!(route_is_valid_for_model( &config, ApiProvider::Custom, From 86dd8e8d271921380a70f8b99309d35766d9c2c0 Mon Sep 17 00:00:00 2001 From: asto Date: Fri, 2 Oct 2026 14:01:20 +0800 Subject: [PATCH 18/18] fix(config): share the anthropic alias list; scope the dialect warning to custom tables wire_prefers_anthropic (built-in dual-wire base-URL resolution) kept a byte-identical copy of the anthropic alias list that provider::wire_dialect_prefers_anthropic now owns; it delegates to the canonical parse so one alias list genuinely cannot drift. The unrecognized-dialect warning and the wire field doc are scoped to what actually happens: every consumer gates the override on ProviderKind::Custom, so the warning can only fire for a custom table, while a built-in vendor's dialect space degrades silently. Signed-off-by: asto --- crates/config/src/lib.rs | 25 +++++++++---------------- crates/config/src/provider.rs | 16 +++++++++------- 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index c00a87e4be..090e9fb7e1 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -163,9 +163,10 @@ pub struct ProviderConfigToml { /// `responses`, `anthropic` (or `messages`/`claude`), and `chat` or /// `openai` (the Chat Completions default); dual-protocol built-in /// vendors (DeepSeek, MiniMax, Model Studio) accept `openai` (default) - /// or `anthropic` (Messages). Unrecognized values fall back to the - /// default Chat Completions policy with a warning. Not a separate - /// catalog provider — a power-user toggle. + /// or `anthropic` (Messages). An unrecognized value falls back to the + /// default policy — custom tables log a warning as they degrade, while + /// a built-in vendor's dialect space is silently `openai`/`anthropic`. + /// Not a separate catalog provider — a power-user toggle. #[serde( default, skip_serializing_if = "Option::is_none", @@ -4589,6 +4590,10 @@ fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool { } /// Dual-wire vendors: dialect is config (`wire`), not a separate ProviderKind. +/// The `anthropic` alias tail is the canonical parse in +/// [`provider::wire_dialect_prefers_anthropic`] — the built-in dialect space +/// only ever branches openai/anthropic, so it shares that alias list rather +/// than keeping a second one that can drift. fn wire_prefers_anthropic(kind: ProviderKind, wire: Option<&str>) -> bool { if matches!( kind, @@ -4599,19 +4604,7 @@ fn wire_prefers_anthropic(kind: ProviderKind, wire: Option<&str>) -> bool { ) { return true; } - let Some(raw) = wire.map(str::trim).filter(|value| !value.is_empty()) else { - return false; - }; - let normalized = raw.to_ascii_lowercase().replace(['_', ' '], "-"); - matches!( - normalized.as_str(), - "anthropic" - | "anthropic-messages" - | "messages" - | "claude" - | "anthropic-compatible" - | "anthropic-compat" - ) + provider::wire_dialect_prefers_anthropic(wire) } fn modelstudio_mode_is_coding_plan(kind: ProviderKind, mode: Option<&str>) -> bool { diff --git a/crates/config/src/provider.rs b/crates/config/src/provider.rs index 3efdd0286e..d8461d90b6 100644 --- a/crates/config/src/provider.rs +++ b/crates/config/src/provider.rs @@ -1730,8 +1730,8 @@ impl Provider for Custom { /// Whether a per-config `wire` dialect string names the Anthropic Messages /// endpoint. Canonical parse shared by the tui wire-format/capability readers, -/// the route resolver, and the app-server pass-through, so one alias list -/// cannot drift from another. +/// the route resolver, the app-server pass-through, and the built-in +/// dual-wire base-URL resolvers, so one alias list cannot drift from another. #[must_use] pub fn wire_dialect_prefers_anthropic(wire: Option<&str>) -> bool { let Some(raw) = wire.map(str::trim).filter(|value| !value.is_empty()) else { @@ -1773,10 +1773,12 @@ pub fn wire_dialect_prefers_responses(wire: Option<&str>) -> bool { /// The wire override a per-config `wire` dialect asks for: `Some(Responses)` / /// `Some(AnthropicMessages)` when the string names that endpoint, `None` for /// `chat` / absent / unrecognized values (the static descriptor policy -/// applies). The resolver honors the override only for `ProviderKind::Custom`. -/// A non-empty unrecognized value is most likely a typo of the one string -/// that switches the endpoint's protocol, so it is logged before degrading to -/// the default policy — the parse stays total and forward-compatible. +/// applies). The resolver honors the override only for `ProviderKind::Custom`, +/// and every other consumer gates the same way, so the warning below can only +/// fire for a custom table. A non-empty unrecognized value is most likely a +/// typo of the one string that switches the endpoint's protocol, so it is +/// logged before degrading to the default policy — the parse stays total and +/// forward-compatible. #[must_use] pub fn wire_dialect_override(wire: Option<&str>) -> Option { if wire_dialect_prefers_responses(wire) { @@ -1792,7 +1794,7 @@ pub fn wire_dialect_override(wire: Option<&str>) -> Option { ) { tracing::warn!( dialect = %raw, - "unrecognized provider wire dialect; using the default Chat Completions policy" + "unrecognized custom-provider wire dialect; using the default Chat Completions policy" ); } }