From 8be9c8e152cd5afa4c3a5f85251fc0354fa2f85f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 11:07:10 -0600 Subject: [PATCH 01/16] fix: exempt agent__check_inbox and agent__task_list from loop --- src/function/mod.rs | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/function/mod.rs b/src/function/mod.rs index c2b73dc..638ec33 100644 --- a/src/function/mod.rs +++ b/src/function/mod.rs @@ -2518,13 +2518,13 @@ fn polyfill_cmd_name>(cmd_name: &str, bin_dir: &[T]) -> String { cmd_name } -// Polling tools are expected to repeat; recording them would also let them -// break up detection of a real loop in the calls they interleave with. -const LOOP_TRACKER_EXEMPT_TOOLS: [&str; 4] = [ +const LOOP_TRACKER_EXEMPT_TOOLS: [&str; 6] = [ "job__check", "job__list", "agent__check", "agent__list_running", + "agent__task_list", + "agent__check_inbox", ]; fn is_loop_tracker_exempt(name: &str) -> bool { @@ -3328,10 +3328,12 @@ mod tests { "job__list", "agent__check", "agent__list_running", + "agent__task_list", + "agent__check_inbox", ] .into_iter() .collect(); - assert_eq!(LOOP_TRACKER_EXEMPT_TOOLS.len(), 4); + assert_eq!(LOOP_TRACKER_EXEMPT_TOOLS.len(), 6); assert_eq!(actual, expected); } @@ -3368,6 +3370,29 @@ mod tests { assert!(tracker.check_loop(&x).is_some()); } + /// Zero-argument polls (`agent__check_inbox`, `agent__task_list`) are + /// byte-identical on every call, so the protocol-mandated "check your inbox + /// after each round" cadence must never read as a loop — even when the + /// polls are back-to-back and well past the repeat threshold. + #[test] + fn tracker_zero_arg_polls_never_trip_even_when_consecutive() { + for name in ["agent__check_inbox", "agent__task_list"] { + let mut tracker = ToolCallTracker::default(); + let poll = call_with_args(name, json!({})); + for _ in 0..10 { + assert!( + tracker.check_loop(&poll).is_none(), + "{name} must never trip the loop tracker" + ); + tracker.record_call(poll.clone()); + } + assert!( + tracker.last_calls.is_empty(), + "{name} must not be recorded in the call history" + ); + } + } + #[test] fn tracker_non_exempt_behavior_unchanged() { let mut tracker = ToolCallTracker::default(); From 57c1a861953c7f641ec6095654f91e2c9b874477 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 11:29:31 -0600 Subject: [PATCH 02/16] feat(TASK-041): add WireApi enum and wire-aware Responses dispatch Introduce a WireApi enum (responses|chat) with an optional wire_api config field on the openai and openai-compatible clients, resolved through a single resolve_wire_api fn: codex forces the responses wire (an explicit wire_api: chat there is a hard config error), an explicit wire_api wins everywhere else, and the default stays chat for now. The openai dispatch generalizes from (RequestData, bool) to (RequestData, WireApi), and the openai-compatible client gains opt-in Responses dispatch (URL, body, extractor, and streaming forks). Both patch layers are now wire-aware: RequestData carries its wire, RequestPatch gains a responses section, and patch_request_data selects patches per wire. This deliberately FIXES a codex bug: chat-shaped model patches (e.g. o3-high's max_tokens/temperature/top_p/ reasoning_effort tweaks) previously merged wire-blind into codex responses bodies, poisoning them with chat-only keys. On the responses wire only an explicit responses sub-patch applies; chat-wire patching is byte-identical to before (pinned by a regression test). --- src/client/bedrock.rs | 2 + src/client/common.rs | 266 +++++++++++++++++++++++++++++++- src/client/oauth.rs | 2 + src/client/openai.rs | 244 ++++++++++++++++++++++++++--- src/client/openai_compatible.rs | 176 +++++++++++++++++++-- 5 files changed, 646 insertions(+), 44 deletions(-) diff --git a/src/client/bedrock.rs b/src/client/bedrock.rs index 1a94f5d..cabcb42 100644 --- a/src/client/bedrock.rs +++ b/src/client/bedrock.rs @@ -73,6 +73,7 @@ impl BedrockClient { url: _, headers, body, + .. } = request_data; let builder = aws_fetch( @@ -126,6 +127,7 @@ impl BedrockClient { url: _, headers, body, + .. } = request_data; let builder = aws_fetch( diff --git a/src/client/common.rs b/src/client/common.rs index 7a223cb..53f255f 100644 --- a/src/client/common.rs +++ b/src/client/common.rs @@ -221,23 +221,39 @@ pub trait Client: Sync + Send { request_data.into_builder(client) } + /// Patches are chat-shaped unless keyed otherwise: on the Responses wire + /// only the `responses` sub-patch of a model patch and the `responses` + /// section of a client patch apply, so chat-only keys never leak into a + /// responses body. fn patch_request_data(&self, request_data: &mut RequestData) { let model_type = self.model().model_type(); + let wire = request_data.wire; if let Some(patch) = self.model().patch() { - request_data.apply_patch(patch.clone()); + match wire { + WireApi::Chat => request_data.apply_patch(patch.clone()), + WireApi::Responses => { + if let Some(patch) = patch.get("responses") { + request_data.apply_patch(patch.clone()); + } + } + } } + let api_name = match wire { + WireApi::Responses => "responses", + WireApi::Chat => model_type.api_name(), + }; let patch_map = std::env::var(get_env_name(&format!( - "patch_{}_{}", + "patch_{}_{api_name}", self.model().client_name(), - model_type.api_name(), ))) .ok() .and_then(|v| serde_json::from_str(&v).ok()) .or_else(|| { - self.patch_config() - .and_then(|v| model_type.extract_patch(v)) - .cloned() + self.patch_config().and_then(|v| match wire { + WireApi::Responses => v.responses.clone(), + WireApi::Chat => model_type.extract_patch(v).cloned(), + }) }); let patch_map = match patch_map { Some(v) => v, @@ -271,16 +287,26 @@ pub struct ExtraConfig { #[derive(Debug, Clone, Deserialize, Default)] pub struct RequestPatch { pub chat_completions: Option, + pub responses: Option, pub embeddings: Option, pub rerank: Option, } pub type ApiPatch = IndexMap; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum WireApi { + Responses, + Chat, +} + +#[derive(Debug)] pub struct RequestData { pub url: String, pub headers: IndexMap, pub body: Value, + pub wire: WireApi, } impl RequestData { @@ -292,9 +318,15 @@ impl RequestData { url: url.to_string(), headers: Default::default(), body, + wire: WireApi::Chat, } } + pub fn wire(mut self, wire: WireApi) -> Self { + self.wire = wire; + self + } + pub fn bearer_auth(&mut self, auth: T) where T: std::fmt::Display, @@ -312,7 +344,9 @@ impl RequestData { } pub fn into_builder(self, client: &ReqwestClient) -> RequestBuilder { - let RequestData { url, headers, body } = self; + let RequestData { + url, headers, body, .. + } = self; debug!("Request {url} {body}"); let mut builder = client.post(url); @@ -1524,4 +1558,222 @@ mod tests { assert_eq!(client.streaming_calls.load(Ordering::SeqCst), 1); assert_paced_reply(&text, &results); } + + /// Minimal client for exercising `patch_request_data`: only the model + /// and the client-level patch config matter. + struct PatchProbeClient { + config: AppConfig, + model: Model, + patch: Option, + } + + #[async_trait::async_trait] + impl Client for PatchProbeClient { + fn app_config(&self) -> &AppConfig { + &self.config + } + + fn extra_config(&self) -> Option<&ExtraConfig> { + None + } + + fn patch_config(&self) -> Option<&RequestPatch> { + self.patch.as_ref() + } + + fn name(&self) -> &str { + self.model.client_name() + } + + fn model(&self) -> &Model { + &self.model + } + + async fn chat_completions_inner( + &self, + _client: &ReqwestClient, + _data: ChatCompletionsData, + ) -> Result { + bail!("the patch probe makes no requests") + } + + async fn chat_completions_streaming_inner( + &self, + _client: &ReqwestClient, + _handler: &mut SseHandler, + _data: ChatCompletionsData, + ) -> Result<()> { + bail!("the patch probe makes no requests") + } + } + + fn patch_probe( + client_name: &str, + model_patch: Option, + patch: Option, + ) -> PatchProbeClient { + let mut data = ModelData::new("gpt-test"); + data.patch = model_patch; + PatchProbeClient { + config: AppConfig::default(), + model: Model::from_config(client_name, &[data]).remove(0), + patch, + } + } + + fn client_patch(json: Value) -> RequestPatch { + serde_json::from_value(json).unwrap() + } + + fn wire_request(wire: WireApi) -> RequestData { + RequestData::new("https://example.test/v1", json!({"model": "gpt-test"})).wire(wire) + } + + #[test] + fn request_patch_accepts_a_responses_section() { + let patch = client_patch(json!({ + "responses": {".*": {"body": {"store": true}}}, + "chat_completions": {".*": {"body": {"temperature": 0.5}}}, + })); + + assert!(patch.responses.is_some()); + assert!(patch.chat_completions.is_some()); + } + + #[test] + fn chat_client_patch_never_touches_the_responses_wire() { + let client = patch_probe( + "patchprobechatiso", + None, + Some(client_patch(json!({ + "chat_completions": {".*": {"body": {"temperature": 0.5}}} + }))), + ); + let mut request_data = wire_request(WireApi::Responses); + let before = request_data.body.clone(); + + client.patch_request_data(&mut request_data); + + assert_eq!(request_data.body, before); + } + + #[test] + fn responses_client_patch_never_touches_the_chat_wire() { + let patch = Some(client_patch(json!({ + "responses": {".*": {"body": {"store": true}}} + }))); + let client = patch_probe("patchproberespiso", None, patch); + + let mut chat_request = wire_request(WireApi::Chat); + let before = chat_request.body.clone(); + client.patch_request_data(&mut chat_request); + assert_eq!(chat_request.body, before); + + let mut responses_request = wire_request(WireApi::Responses); + client.patch_request_data(&mut responses_request); + assert_eq!(responses_request.body["store"], json!(true)); + } + + #[test] + fn chat_shaped_model_patch_never_touches_the_responses_wire() { + let client = patch_probe( + "patchprobemodeliso", + Some(json!({"body": {"reasoning_effort": "high"}})), + None, + ); + let mut request_data = wire_request(WireApi::Responses); + let before = request_data.body.clone(); + + client.patch_request_data(&mut request_data); + + assert_eq!(request_data.body, before); + } + + #[test] + fn model_patch_responses_subkey_is_selected_per_wire() { + let model_patch = json!({ + "body": {"temperature": 0.2}, + "responses": {"body": {"store": true}}, + }); + let client = patch_probe("patchprobesubkey", Some(model_patch), None); + + let mut responses_request = wire_request(WireApi::Responses); + client.patch_request_data(&mut responses_request); + assert_eq!(responses_request.body["store"], json!(true)); + assert!(responses_request.body.get("temperature").is_none()); + + let mut chat_request = wire_request(WireApi::Chat); + client.patch_request_data(&mut chat_request); + assert_eq!(chat_request.body["temperature"], json!(0.2)); + assert!(chat_request.body.get("store").is_none()); + } + + #[test] + fn env_patch_override_is_selected_per_wire() { + // Unique client name so the env vars cannot race parallel tests. + let client_name = "patchprobeenvwire"; + let responses_env = get_env_name(&format!("patch_{client_name}_responses")); + let chat_env = get_env_name(&format!("patch_{client_name}_chat_completions")); + unsafe { + std::env::set_var(&responses_env, r#"{".*": {"body": {"store": true}}}"#); + std::env::set_var(&chat_env, r#"{".*": {"body": {"temperature": 0.7}}}"#); + } + + let client = patch_probe(client_name, None, None); + let mut responses_request = wire_request(WireApi::Responses); + client.patch_request_data(&mut responses_request); + let mut chat_request = wire_request(WireApi::Chat); + client.patch_request_data(&mut chat_request); + + unsafe { + std::env::remove_var(&responses_env); + std::env::remove_var(&chat_env); + } + + assert_eq!(responses_request.body["store"], json!(true)); + assert!(responses_request.body.get("temperature").is_none()); + assert_eq!(chat_request.body["temperature"], json!(0.7)); + assert!(chat_request.body.get("store").is_none()); + } + + #[test] + fn chat_wire_patching_is_unchanged_by_wire_awareness() { + let client = patch_probe( + "patchproberegress", + Some(json!({"body": {"max_tokens": null}, "headers": {"x-model": "m"}})), + Some(client_patch(json!({ + "chat_completions": {".*": { + "url": "https://patched.example/v1/chat/completions", + "body": {"temperature": 0.9}, + }} + }))), + ); + let mut request_data = RequestData::new( + "https://example.test/v1/chat/completions", + json!({"model": "gpt-test", "max_tokens": 100}), + ); + request_data.bearer_auth("sk-test"); + + client.patch_request_data(&mut request_data); + + assert_eq!( + request_data.url, + "https://patched.example/v1/chat/completions" + ); + assert_eq!( + request_data.body, + json!({"model": "gpt-test", "temperature": 0.9}) + ); + assert_eq!( + request_data.headers.get("x-model").map(String::as_str), + Some("m") + ); + assert_eq!( + request_data + .headers + .get("authorization") + .map(String::as_str), + Some("Bearer sk-test") + ); + } } diff --git a/src/client/oauth.rs b/src/client/oauth.rs index e456591..a03c05d 100644 --- a/src/client/oauth.rs +++ b/src/client/oauth.rs @@ -1460,6 +1460,7 @@ echo_pkce_in_token_exchange: true api_key: None, auth: auth.map(str::to_string), oauth: oauth.map(Box::new), + wire_api: None, models: vec![], patch: None, extra: None, @@ -1496,6 +1497,7 @@ echo_pkce_in_token_exchange: true organization_id: None, auth: auth.map(str::to_string), oauth: oauth.map(Box::new), + wire_api: None, models: vec![], patch: None, extra: None, diff --git a/src/client/openai.rs b/src/client/openai.rs index 5011ca5..eef437a 100644 --- a/src/client/openai.rs +++ b/src/client/openai.rs @@ -21,6 +21,7 @@ pub struct OpenAIConfig { pub organization_id: Option, pub auth: Option, pub oauth: Option>, + pub wire_api: Option, #[serde(default)] pub models: Vec, pub patch: Option, @@ -47,12 +48,11 @@ impl Client for OpenAIClient { client: &ReqwestClient, data: ChatCompletionsData, ) -> Result { - let (request_data, uses_codex) = prepare_chat_completions(self, client, data).await?; + let (request_data, wire) = prepare_chat_completions(self, client, data).await?; let builder = self.request_builder(client, request_data); - if uses_codex { - openai_responses_chat_completions(builder, self.model()).await - } else { - openai_chat_completions(builder, self.model()).await + match wire { + WireApi::Responses => openai_responses_chat_completions(builder, self.model()).await, + WireApi::Chat => openai_chat_completions(builder, self.model()).await, } } @@ -62,13 +62,14 @@ impl Client for OpenAIClient { handler: &mut SseHandler, data: ChatCompletionsData, ) -> Result<()> { - let (request_data, uses_codex) = prepare_chat_completions(self, client, data).await?; + let (request_data, wire) = prepare_chat_completions(self, client, data).await?; let builder = self.request_builder(client, request_data); - if uses_codex { - openai_responses_streaming(builder, handler).await - } else { - openai_chat_completions_streaming(builder, handler, self.model()).await + match wire { + WireApi::Responses => openai_responses_streaming(builder, handler).await, + WireApi::Chat => { + openai_chat_completions_streaming(builder, handler, self.model()).await + } } } @@ -93,11 +94,29 @@ impl Client for OpenAIClient { } } +/// Codex only speaks the Responses API, so it forces the responses wire and +/// rejects an explicit `wire_api: chat`. Anywhere else an explicit `wire_api` +/// wins and the default is chat. +pub fn resolve_wire_api( + explicit: Option, + uses_codex: bool, + _stock_openai_without_api_base: bool, +) -> Result { + match (uses_codex, explicit) { + (true, Some(WireApi::Chat)) => bail!( + "the Codex backend only speaks the Responses API; remove `wire_api: chat` or configure an `api_base`" + ), + (true, _) => Ok(WireApi::Responses), + (false, Some(wire)) => Ok(wire), + (false, None) => Ok(WireApi::Chat), + } +} + async fn prepare_chat_completions( self_: &OpenAIClient, client: &ReqwestClient, data: ChatCompletionsData, -) -> Result<(RequestData, bool)> { +) -> Result<(RequestData, WireApi)> { let uses_oauth = self_.config.auth.as_deref() == Some("oauth"); if !uses_oauth && self_.config.oauth.is_some() { @@ -116,21 +135,28 @@ async fn prepare_chat_completions( let uses_stock_provider = matches!(oauth_provider, Some((_, true))); // Stock oauth with no `api_base` routes to the ChatGPT codex backend (Responses API). let uses_codex = uses_stock_provider && self_.get_api_base().is_err(); + let wire = resolve_wire_api( + self_.config.wire_api, + uses_codex, + self_.get_api_base().is_err(), + )?; let url = if uses_codex { CODEX_API_ENDPOINT.to_string() } else { let api_base = resolve_api_base(self_)?; - format!("{}/chat/completions", api_base.trim_end_matches('/')) + match wire { + WireApi::Responses => format!("{}/responses", api_base.trim_end_matches('/')), + WireApi::Chat => format!("{}/chat/completions", api_base.trim_end_matches('/')), + } }; - let body = if uses_codex { - openai_build_responses_body(data, &self_.model) - } else { - openai_build_chat_completions_body(data, &self_.model) + let body = match wire { + WireApi::Responses => openai_build_responses_body(data, &self_.model), + WireApi::Chat => openai_build_chat_completions_body(data, &self_.model), }; - let mut request_data = RequestData::new(url, body); + let mut request_data = RequestData::new(url, body).wire(wire); if let Some((provider, _)) = oauth_provider { let ready = oauth::prepare_oauth_access_token(client, &*provider, self_.name()).await?; @@ -167,7 +193,7 @@ async fn prepare_chat_completions( request_data.header("OpenAI-Organization", organization_id); } - Ok((request_data, uses_codex)) + Ok((request_data, wire)) } async fn prepare_embeddings( @@ -909,7 +935,11 @@ mod tests { .unwrap() } - fn prepare(client: &OpenAIClient) -> Result<(RequestData, bool)> { + fn prepare(client: &OpenAIClient) -> Result<(RequestData, WireApi)> { + prepare_with_stream(client, false) + } + + fn prepare_with_stream(client: &OpenAIClient, stream: bool) -> Result<(RequestData, WireApi)> { let data = ChatCompletionsData { messages: vec![Message::new( MessageRole::User, @@ -919,7 +949,7 @@ mod tests { top_p: None, reasoning_effort: None, functions: None, - stream: false, + stream, }; tokio::runtime::Builder::new_current_thread() .enable_all() @@ -1006,9 +1036,9 @@ mod tests { let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]); set_access_token(name, "codex-at".into(), Utc::now().timestamp() + 3600, None); - let (request_data, uses_codex) = prepare(&client).unwrap(); + let (request_data, wire) = prepare(&client).unwrap(); - assert!(uses_codex); + assert_eq!(wire, WireApi::Responses); assert_eq!(request_data.url, CODEX_API_ENDPOINT); } @@ -1024,9 +1054,9 @@ mod tests { None, ); - let (request_data, uses_codex) = prepare(&client).unwrap(); + let (request_data, wire) = prepare(&client).unwrap(); - assert!(!uses_codex); + assert_eq!(wire, WireApi::Chat); assert_eq!( request_data.url, "https://gateway.example/v1/chat/completions" @@ -1243,4 +1273,170 @@ mod tests { "bundled models.yaml must not carry an openai oauth block: it would silently disable codex routing for stock ChatGPT oauth users" ); } + + #[test] + fn wire_api_deserializes_on_openai_config() { + let config: OpenAIConfig = serde_yaml::from_str("wire_api: responses").unwrap(); + assert_eq!(config.wire_api, Some(WireApi::Responses)); + + let config: OpenAIConfig = serde_yaml::from_str("wire_api: chat").unwrap(); + assert_eq!(config.wire_api, Some(WireApi::Chat)); + } + + #[test] + fn bogus_wire_api_is_a_config_error() { + let err = serde_yaml::from_str::("wire_api: bogus") + .unwrap_err() + .to_string(); + + assert!( + err.contains("responses") && err.contains("chat"), + "unexpected error: {err}" + ); + } + + #[test] + fn resolver_codex_defaults_to_responses() { + assert_eq!( + resolve_wire_api(None, true, false).unwrap(), + WireApi::Responses + ); + assert_eq!( + resolve_wire_api(Some(WireApi::Responses), true, false).unwrap(), + WireApi::Responses + ); + } + + #[test] + fn resolver_rejects_explicit_chat_on_codex() { + let err = resolve_wire_api(Some(WireApi::Chat), true, false) + .unwrap_err() + .to_string(); + + assert!( + err.contains("only speaks the Responses API"), + "unexpected error: {err}" + ); + } + + #[test] + fn resolver_explicit_wins_off_codex() { + for stock in [false, true] { + assert_eq!( + resolve_wire_api(Some(WireApi::Responses), false, stock).unwrap(), + WireApi::Responses + ); + assert_eq!( + resolve_wire_api(Some(WireApi::Chat), false, stock).unwrap(), + WireApi::Chat + ); + } + } + + #[test] + fn resolver_defaults_to_chat_this_milestone() { + for stock in [false, true] { + assert_eq!(resolve_wire_api(None, false, stock).unwrap(), WireApi::Chat); + } + } + + fn api_key_config(name: &str, wire_api: Option) -> OpenAIConfig { + OpenAIConfig { + name: Some(name.into()), + api_key: Some("sk-test".into()), + wire_api, + ..Default::default() + } + } + + #[test] + fn api_key_openai_defaults_to_the_chat_wire() { + let config = api_key_config("openai-wire-default-test", None); + let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]); + + let (request_data, wire) = prepare(&client).unwrap(); + + assert_eq!(wire, WireApi::Chat); + assert_eq!(request_data.url, format!("{API_BASE}/chat/completions")); + assert!( + request_data.body.get("messages").is_some(), + "body: {}", + request_data.body + ); + } + + #[test] + fn explicit_responses_wire_posts_a_responses_body_to_the_responses_endpoint() { + let config = api_key_config("openai-wire-responses-test", Some(WireApi::Responses)); + let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]); + + let (request_data, wire) = prepare(&client).unwrap(); + + assert_eq!(wire, WireApi::Responses); + assert_eq!(request_data.url, format!("{API_BASE}/responses")); + assert_eq!(request_data.body["store"], json!(false)); + assert!( + request_data.body.get("input").is_some() && request_data.body.get("messages").is_none(), + "body: {}", + request_data.body + ); + } + + #[test] + fn explicit_responses_wire_forks_the_streaming_path_too() { + let config = api_key_config("openai-wire-responses-stream-test", Some(WireApi::Responses)); + let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]); + + let (request_data, wire) = prepare_with_stream(&client, true).unwrap(); + + assert_eq!(wire, WireApi::Responses); + assert_eq!(request_data.url, format!("{API_BASE}/responses")); + assert_eq!(request_data.body["stream"], json!(true)); + assert!( + request_data.body.get("input").is_some(), + "body: {}", + request_data.body + ); + } + + #[test] + fn codex_with_explicit_chat_wire_is_rejected() { + let name = "openai-codex-chat-wire-test"; + let mut config = openai_config(name, Some("oauth"), None); + config.wire_api = Some(WireApi::Chat); + let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]); + + let err = prepare(&client).unwrap_err().to_string(); + + assert!( + err.contains("only speaks the Responses API"), + "unexpected error: {err}" + ); + } + + #[test] + fn codex_responses_body_ignores_a_chat_shaped_model_patch() { + let name = "openai-codex-chat-patch-test"; + let config = openai_config(name, Some("oauth"), None); + let mut model_data = ModelData::new("gpt-test"); + model_data.patch = Some(json!({ + "body": { + "max_tokens": null, + "temperature": null, + "top_p": null, + "reasoning_effort": "high", + } + })); + let mut client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]); + client.model = Model::from_config("openai", &[model_data]).remove(0); + set_access_token(name, "codex-at".into(), Utc::now().timestamp() + 3600, None); + + let (mut request_data, wire) = prepare(&client).unwrap(); + assert_eq!(wire, WireApi::Responses); + let body_before = request_data.body.clone(); + + client.patch_request_data(&mut request_data); + + assert_eq!(request_data.body, body_before, "a chat-shaped model patch must not merge into a responses body"); + } } diff --git a/src/client/openai_compatible.rs b/src/client/openai_compatible.rs index d407864..80c7c36 100644 --- a/src/client/openai_compatible.rs +++ b/src/client/openai_compatible.rs @@ -16,6 +16,7 @@ pub struct OpenAICompatibleConfig { pub api_key: Option, pub auth: Option, pub oauth: Option>, + pub wire_api: Option, #[serde(default)] pub models: Vec, pub patch: Option, @@ -42,10 +43,13 @@ impl Client for OpenAICompatibleClient { client: &ReqwestClient, data: ChatCompletionsData, ) -> Result { - let request_data = prepare_chat_completions(self, client, data).await?; + let (request_data, wire) = prepare_chat_completions(self, client, data).await?; let builder = self.request_builder(client, request_data); - - openai_chat_completions(builder, self.model()).await + + match wire { + WireApi::Responses => openai_responses_chat_completions(builder, self.model()).await, + WireApi::Chat => openai_chat_completions(builder, self.model()).await, + } } async fn chat_completions_streaming_inner( @@ -54,10 +58,15 @@ impl Client for OpenAICompatibleClient { handler: &mut SseHandler, data: ChatCompletionsData, ) -> Result<()> { - let request_data = prepare_chat_completions(self, client, data).await?; + let (request_data, wire) = prepare_chat_completions(self, client, data).await?; let builder = self.request_builder(client, request_data); - - openai_chat_completions_streaming(builder, handler, self.model()).await + + match wire { + WireApi::Responses => openai_responses_streaming(builder, handler).await, + WireApi::Chat => { + openai_chat_completions_streaming(builder, handler, self.model()).await + } + } } async fn embeddings_inner( @@ -87,15 +96,22 @@ async fn prepare_chat_completions( self_: &OpenAICompatibleClient, client: &ReqwestClient, data: ChatCompletionsData, -) -> Result { +) -> Result<(RequestData, WireApi)> { + let wire = resolve_wire_api(self_.config.wire_api, false, false)?; let api_base = get_api_base_ext(self_)?; - let url = format!("{api_base}/chat/completions"); - let body = openai_build_chat_completions_body(data, &self_.model); - let mut request_data = RequestData::new(url, body); - + let url = match wire { + WireApi::Responses => format!("{api_base}/responses"), + WireApi::Chat => format!("{api_base}/chat/completions"), + }; + let body = match wire { + WireApi::Responses => openai_build_responses_body(data, &self_.model), + WireApi::Chat => openai_build_chat_completions_body(data, &self_.model), + }; + let mut request_data = RequestData::new(url, body).wire(wire); + apply_auth(self_, client, &mut request_data).await?; - - Ok(request_data) + + Ok((request_data, wire)) } async fn prepare_embeddings( @@ -246,3 +262,137 @@ pub fn generic_build_rerank_body(data: &RerankData, model: &Model) -> Value { } body } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::AppConfig; + use std::sync::Arc; + + fn compat_config(wire_api: Option) -> OpenAICompatibleConfig { + OpenAICompatibleConfig { + name: Some("compat-test".into()), + api_base: Some("https://compat.example/v1".into()), + api_key: Some("sk-test".into()), + auth: None, + oauth: None, + wire_api, + models: vec![], + patch: None, + extra: None, + } + } + + fn make_client(config: OpenAICompatibleConfig) -> OpenAICompatibleClient { + OpenAICompatibleClient { + app_config: Arc::new(AppConfig::default()), + config, + model: Model::new("compat-test", "gpt-test"), + } + } + + fn prepare( + client: &OpenAICompatibleClient, + stream: bool, + ) -> Result<(RequestData, WireApi)> { + let data = ChatCompletionsData { + messages: vec![Message::new( + MessageRole::User, + MessageContent::Text("hello".to_string()), + )], + temperature: None, + top_p: None, + reasoning_effort: None, + functions: None, + stream, + }; + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(prepare_chat_completions( + client, + &ReqwestClient::new(), + data, + )) + } + + #[test] + fn wire_api_deserializes_on_openai_compatible_config() { + let config: OpenAICompatibleConfig = serde_yaml::from_str("wire_api: responses").unwrap(); + assert_eq!(config.wire_api, Some(WireApi::Responses)); + + let config: OpenAICompatibleConfig = serde_yaml::from_str("wire_api: chat").unwrap(); + assert_eq!(config.wire_api, Some(WireApi::Chat)); + } + + #[test] + fn bogus_wire_api_is_a_config_error() { + let err = serde_yaml::from_str::("wire_api: bogus") + .unwrap_err() + .to_string(); + + assert!( + err.contains("responses") && err.contains("chat"), + "unexpected error: {err}" + ); + } + + #[test] + fn default_wire_is_chat() { + let client = make_client(compat_config(None)); + + let (request_data, wire) = prepare(&client, false).unwrap(); + + assert_eq!(wire, WireApi::Chat); + assert_eq!( + request_data.url, + "https://compat.example/v1/chat/completions" + ); + assert!( + request_data.body.get("messages").is_some(), + "body: {}", + request_data.body + ); + } + + #[test] + fn explicit_responses_wire_posts_a_responses_body_to_the_responses_endpoint() { + let client = make_client(compat_config(Some(WireApi::Responses))); + + let (request_data, wire) = prepare(&client, false).unwrap(); + + assert_eq!(wire, WireApi::Responses); + assert_eq!(request_data.url, "https://compat.example/v1/responses"); + assert_eq!(request_data.body["store"], json!(false)); + assert!( + request_data.body.get("input").is_some() && request_data.body.get("messages").is_none(), + "body: {}", + request_data.body + ); + } + + #[test] + fn explicit_responses_wire_forks_the_streaming_path_too() { + let client = make_client(compat_config(Some(WireApi::Responses))); + + let (request_data, wire) = prepare(&client, true).unwrap(); + + assert_eq!(wire, WireApi::Responses); + assert_eq!(request_data.url, "https://compat.example/v1/responses"); + assert_eq!(request_data.body["stream"], json!(true)); + } + + #[test] + fn explicit_chat_wire_keeps_the_chat_endpoint() { + let client = make_client(compat_config(Some(WireApi::Chat))); + + let (request_data, wire) = prepare(&client, false).unwrap(); + + assert_eq!(wire, WireApi::Chat); + assert_eq!( + request_data.url, + "https://compat.example/v1/chat/completions" + ); + } +} From 003d036e3118e38c2ada56c24b73b124f67024e1 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 11:52:09 -0600 Subject: [PATCH 03/16] docs: restore the loop-tracker exemption comment Completes the loop-tracker exemption change (8be9c8e) riding this branch: the explanatory comment above LOOP_TRACKER_EXEMPT_TOOLS was dropped in that commit; restore it, updated for the new entries. --- src/function/mod.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/function/mod.rs b/src/function/mod.rs index 638ec33..0c7d75d 100644 --- a/src/function/mod.rs +++ b/src/function/mod.rs @@ -2518,6 +2518,9 @@ fn polyfill_cmd_name>(cmd_name: &str, bin_dir: &[T]) -> String { cmd_name } +// Polling tools are expected to repeat with identical arguments (status probes, +// list views, inbox checks); recording them would also let them break up +// detection of a real loop in the calls they interleave with. const LOOP_TRACKER_EXEMPT_TOOLS: [&str; 6] = [ "job__check", "job__list", From 3a2736b6671f2fed27f0d02f07b3f60010f7fd7a Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 11:58:12 -0600 Subject: [PATCH 04/16] test(TASK-041): pin silent-ignore of malformed model-patch responses sub-key --- src/client/common.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/client/common.rs b/src/client/common.rs index 53f255f..0ef8c70 100644 --- a/src/client/common.rs +++ b/src/client/common.rs @@ -1708,6 +1708,38 @@ mod tests { assert!(chat_request.body.get("store").is_none()); } + /// Pins the silent-ignore contract for a malformed `responses` sub-key: + /// model patches are untyped JSON maps and the whole patch layer is + /// deliberately lenient — `apply_patch` only reads object-shaped + /// `url`/`body`/`headers` keys — so a non-object `responses` sub-key + /// degrades to a no-op on the Responses wire (never a request-time + /// error, never a partial merge) and stays inert on the Chat wire, + /// where the patch's top-level keys still apply normally. + #[test] + fn malformed_model_patch_responses_subkey_is_silently_ignored() { + for malformed in [json!("oops"), json!([{"body": {"store": true}}])] { + let model_patch = json!({ + "body": {"temperature": 0.2}, + "responses": malformed, + }); + let client = patch_probe("patchprobemalformed", Some(model_patch), None); + + let mut responses_request = wire_request(WireApi::Responses); + let before_url = responses_request.url.clone(); + let before_body = responses_request.body.clone(); + let before_headers = responses_request.headers.clone(); + client.patch_request_data(&mut responses_request); + assert_eq!(responses_request.url, before_url); + assert_eq!(responses_request.body, before_body); + assert_eq!(responses_request.headers, before_headers); + + let mut chat_request = wire_request(WireApi::Chat); + client.patch_request_data(&mut chat_request); + assert_eq!(chat_request.body["temperature"], json!(0.2)); + assert!(chat_request.body.get("store").is_none()); + } + } + #[test] fn env_patch_override_is_selected_per_wire() { // Unique client name so the env vars cannot race parallel tests. From d4605794e85e08ba0a07c7334b346b29d9394f3f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 12:13:21 -0600 Subject: [PATCH 05/16] feat(TASK-042): Responses reasoning-item replay, strict:false, cross-client portability Add ThinkingBlock::Reasoning to carry OpenAI Responses reasoning items verbatim. The Responses body builder now requests reasoning.encrypted_content, marks tool defs strict:false, and replays each round's reasoning items ahead of the assistant text and function calls. Both extraction paths (non-streaming and streaming) capture reasoning output items. Claude and Bedrock replay paths drop the OpenAI-only variant so mid-session model switches keep working. --- src/client/bedrock.rs | 49 +++++-- src/client/claude.rs | 36 ++++- src/client/message.rs | 78 ++++++++++- src/client/openai.rs | 308 +++++++++++++++++++++++++++++++++++++----- 4 files changed, 424 insertions(+), 47 deletions(-) diff --git a/src/client/bedrock.rs b/src/client/bedrock.rs index cabcb42..a8da844 100644 --- a/src/client/bedrock.rs +++ b/src/client/bedrock.rs @@ -429,7 +429,9 @@ fn build_chat_completions_body( let mut user_parts = vec![]; for (index, tool_result) in tool_results.iter().enumerate() { for block in &tool_result.thinking { - assistant_parts.push(converse_reasoning_block(block)); + if let Some(part) = converse_reasoning_block(block) { + assistant_parts.push(part); + } } let round_text = if index == 0 && !text.is_empty() { Some(text.as_str()) @@ -498,7 +500,9 @@ fn build_chat_completions_body( chunk_ids.insert(id); } for block in &tool_result.thinking { - assistant_parts.push(converse_reasoning_block(block)); + if let Some(part) = converse_reasoning_block(block) { + assistant_parts.push(part); + } } let round_text = if index == 0 && !text.is_empty() { Some(text.as_str()) @@ -663,23 +667,26 @@ fn bedrock_tool_result_content(output: &Value) -> Value { } /// Maps a ThinkingBlock (Anthropic tagged shape) to the Converse -/// reasoningContent union so signed blocks replay verbatim. -fn converse_reasoning_block(block: &ThinkingBlock) -> Value { +/// reasoningContent union so signed blocks replay verbatim. OpenAI Responses +/// reasoning items have no Converse encoding, so they are dropped when the +/// session moves to Bedrock. +fn converse_reasoning_block(block: &ThinkingBlock) -> Option { match block { ThinkingBlock::Thinking { thinking, signature, - } => json!({ + } => Some(json!({ "reasoningContent": { "reasoningText": { "text": thinking, "signature": signature, } } - }), - ThinkingBlock::RedactedThinking { data } => json!({ + })), + ThinkingBlock::RedactedThinking { data } => Some(json!({ "reasoningContent": { "redactedContent": data } - }), + })), + ThinkingBlock::Reasoning { .. } => None, } } @@ -1187,6 +1194,32 @@ mod tests { } } + #[test] + fn skips_openai_reasoning_blocks_in_converse_shape() { + let reasoning = ToolResult { + call: ToolCall::new( + "fs_read".into(), + json!({"path": "x"}), + Some("tool_A".into()), + ), + output: json!("ok"), + text: None, + thinking: vec![ThinkingBlock::Reasoning { + id: "rs_1".into(), + summary: json!([{ "type": "summary_text", "text": "thinking" }]), + encrypted_content: Some("enc123".into()), + }], + }; + + for sequence in [false, true] { + let body = build_body_with(vec![reasoning.clone()], "", sequence); + + let content = body["messages"][1]["content"].as_array().unwrap(); + assert_eq!(content.len(), 1, "body: {body}"); + assert!(content[0].get("toolUse").is_some(), "body: {body}"); + } + } + #[test] fn extract_chat_completions_captures_reasoning_content() { let data = json!({ diff --git a/src/client/claude.rs b/src/client/claude.rs index 2cb67e0..78c7861 100644 --- a/src/client/claude.rs +++ b/src/client/claude.rs @@ -407,7 +407,11 @@ pub fn claude_build_chat_completions_body( let mut user_parts = vec![]; for (index, tool_result) in tool_results.iter().enumerate() { for block in &tool_result.thinking { - assistant_parts.push(json!(block)); + // Responses reasoning items are OpenAI-only; + // drop them when the session moves to Claude. + if !matches!(block, ThinkingBlock::Reasoning { .. }) { + assistant_parts.push(json!(block)); + } } let round_text = if index == 0 && !text.is_empty() { Some(text.as_str()) @@ -475,7 +479,9 @@ pub fn claude_build_chat_completions_body( chunk_ids.insert(id); } for block in &tool_result.thinking { - assistant_parts.push(json!(block)); + if !matches!(block, ThinkingBlock::Reasoning { .. }) { + assistant_parts.push(json!(block)); + } } let round_text = if index == 0 && !text.is_empty() { Some(text.as_str()) @@ -875,6 +881,32 @@ mod tests { assert_eq!(messages.len(), 1, "body: {body}"); } + #[test] + fn skips_openai_reasoning_blocks_in_replay() { + let reasoning = ToolResult { + call: ToolCall::new( + "fs_read".into(), + json!({"path": "x"}), + Some("toolu_A".into()), + ), + output: json!("ok"), + text: None, + thinking: vec![ThinkingBlock::Reasoning { + id: "rs_1".into(), + summary: json!([{ "type": "summary_text", "text": "thinking" }]), + encrypted_content: Some("enc123".into()), + }], + }; + + for sequence in [false, true] { + let body = build_body_with(vec![reasoning.clone()], "", sequence, false); + + let content = body["messages"][1]["content"].as_array().unwrap(); + assert_eq!(content.len(), 1, "body: {body}"); + assert_eq!(content[0]["type"], "tool_use", "body: {body}"); + } + } + #[test] fn prompt_cache_places_breakpoints() { let functions = vec![function_declaration("a"), function_declaration("b")]; diff --git a/src/client/message.rs b/src/client/message.rs index ddad394..87154ff 100644 --- a/src/client/message.rs +++ b/src/client/message.rs @@ -188,15 +188,27 @@ pub struct ImageUrl { pub url: String, } -/// An extended-thinking block returned by Anthropic-protocol models. -/// Serialized to match the API wire format (`type: thinking` / `type: redacted_thinking`) +/// An extended-thinking block returned by Anthropic-protocol models or an +/// OpenAI Responses reasoning item. Serialized to match each API's wire +/// format (`type: thinking` / `type: redacted_thinking` / `type: reasoning`) /// so blocks can be replayed verbatim, signature intact, in subsequent -/// tool-loop rounds as the API requires. +/// tool-loop rounds as the APIs require. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ThinkingBlock { - Thinking { thinking: String, signature: String }, - RedactedThinking { data: String }, + Thinking { + thinking: String, + signature: String, + }, + RedactedThinking { + data: String, + }, + Reasoning { + id: String, + summary: serde_json::Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + encrypted_content: Option, + }, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -263,3 +275,59 @@ pub fn extract_system_message(messages: &mut Vec) -> Option { None } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn old_session_thinking_blocks_still_deserialize() { + let json = json!([ + { "type": "thinking", "thinking": "hmm", "signature": "sig123" }, + { "type": "redacted_thinking", "data": "b64data" }, + ]); + + let blocks: Vec = serde_json::from_value(json).unwrap(); + + assert!( + matches!(&blocks[0], ThinkingBlock::Thinking { thinking, signature } if thinking == "hmm" && signature == "sig123") + ); + assert!( + matches!(&blocks[1], ThinkingBlock::RedactedThinking { data } if data == "b64data") + ); + } + + #[test] + fn reasoning_block_round_trips() { + let json = json!({ + "type": "reasoning", + "id": "rs_1", + "summary": [{ "type": "summary_text", "text": "thinking about it" }], + "encrypted_content": "enc123", + }); + + let block: ThinkingBlock = serde_json::from_value(json.clone()).unwrap(); + + assert!( + matches!(&block, ThinkingBlock::Reasoning { id, encrypted_content, .. } if id == "rs_1" && encrypted_content.as_deref() == Some("enc123")) + ); + assert_eq!(serde_json::to_value(&block).unwrap(), json); + } + + #[test] + fn reasoning_block_without_encrypted_content_round_trips() { + let json = json!({ + "type": "reasoning", + "id": "rs_1", + "summary": [], + }); + + let block: ThinkingBlock = serde_json::from_value(json.clone()).unwrap(); + + assert!( + matches!(&block, ThinkingBlock::Reasoning { encrypted_content, .. } if encrypted_content.is_none()) + ); + assert_eq!(serde_json::to_value(&block).unwrap(), json); + } +} diff --git a/src/client/openai.rs b/src/client/openai.rs index eef437a..6252009 100644 --- a/src/client/openai.rs +++ b/src/client/openai.rs @@ -685,6 +685,11 @@ pub fn openai_build_responses_body(data: ChatCompletionsData, model: &Model) -> tool_result.text.clone() }; let mut items = vec![]; + for block in &tool_result.thinking { + if matches!(block, ThinkingBlock::Reasoning { .. }) { + items.push(json!(block)); + } + } if let Some(round_text) = round_text { items.push(json!({ "role": MessageRole::Assistant, @@ -717,6 +722,7 @@ pub fn openai_build_responses_body(data: ChatCompletionsData, model: &Model) -> "model": &model.real_name(), "input": input, "store": false, + "include": ["reasoning.encrypted_content"], }); if let Some(v) = model.max_tokens_param() { @@ -740,6 +746,7 @@ pub fn openai_build_responses_body(data: ChatCompletionsData, model: &Model) -> .map(|v| { let mut tool = serde_json::to_value(v).unwrap_or_default(); tool["type"] = "function".into(); + tool["strict"] = false.into(); tool }) .collect(); @@ -766,6 +773,7 @@ pub async fn openai_responses_chat_completions( pub fn openai_extract_responses(data: &Value) -> Result { let mut text = String::new(); let mut tool_calls = vec![]; + let mut thinking = vec![]; if let Some(output) = data["output"].as_array() { for item in output { @@ -797,6 +805,11 @@ pub fn openai_extract_responses(data: &Value) -> Result { )); } } + Some("reasoning") => { + if let Some(block) = reasoning_thinking_block(item) { + thinking.push(block); + } + } _ => {} } } @@ -808,10 +821,22 @@ pub fn openai_extract_responses(data: &Value) -> Result { Ok(ChatCompletionsOutput { text, tool_calls, + thinking, ..Default::default() }) } +/// Captures a Responses reasoning item verbatim (summary included) so it can +/// be replayed in later tool-loop rounds; `store: false` makes the replayed +/// `encrypted_content` the model's only access to its prior reasoning. +fn reasoning_thinking_block(item: &Value) -> Option { + Some(ThinkingBlock::Reasoning { + id: item["id"].as_str()?.to_string(), + summary: item["summary"].clone(), + encrypted_content: item["encrypted_content"].as_str().map(|v| v.to_string()), + }) +} + pub async fn openai_responses_streaming( builder: RequestBuilder, handler: &mut SseHandler, @@ -822,48 +847,60 @@ pub async fn openai_responses_streaming( } let data: Value = serde_json::from_str(&message.data)?; debug!("stream-data: {data}"); - - match data["type"].as_str() { - Some("response.output_text.delta") => { - if let Some(delta) = data["delta"].as_str().filter(|v| !v.is_empty()) { - handler.text(delta)?; - } - } - Some("response.output_item.done") => { - let item = &data["item"]; - if item["type"].as_str() == Some("function_call") - && let (Some(name), Some(arguments_str), Some(call_id)) = ( - item["name"].as_str(), - item["arguments"].as_str(), - item["call_id"].as_str(), - ) - { - let arguments: Value = arguments_str.parse().with_context(|| { - format!("Tool call '{name}' has non-JSON arguments '{arguments_str}'") - })?; - handler.tool_call(ToolCall::new( - name.to_string(), - arguments, - Some(call_id.to_string()), - ))?; - } - } - Some("response.completed") => { - return Ok(true); - } - _ => {} - } - Ok(false) + openai_responses_handle_event(&data, handler) }; sse_stream(builder, handle).await } +fn openai_responses_handle_event(data: &Value, handler: &mut SseHandler) -> Result { + match data["type"].as_str() { + Some("response.output_text.delta") => { + if let Some(delta) = data["delta"].as_str().filter(|v| !v.is_empty()) { + handler.text(delta)?; + } + } + Some("response.output_item.done") => { + let item = &data["item"]; + match item["type"].as_str() { + Some("function_call") => { + if let (Some(name), Some(arguments_str), Some(call_id)) = ( + item["name"].as_str(), + item["arguments"].as_str(), + item["call_id"].as_str(), + ) { + let arguments: Value = arguments_str.parse().with_context(|| { + format!("Tool call '{name}' has non-JSON arguments '{arguments_str}'") + })?; + handler.tool_call(ToolCall::new( + name.to_string(), + arguments, + Some(call_id.to_string()), + ))?; + } + } + Some("reasoning") => { + if let Some(block) = reasoning_thinking_block(item) { + handler.thinking_block(block); + } + } + _ => {} + } + } + Some("response.completed") => { + return Ok(true); + } + _ => {} + } + Ok(false) +} + #[cfg(test)] mod tests { use super::*; use crate::client::access_token::set_access_token; use crate::config::AppConfig; + use crate::function::{FunctionDeclaration, ToolResult}; use chrono::Utc; use std::sync::Arc; @@ -907,6 +944,213 @@ mod tests { assert_eq!(messages.len(), 1, "body: {body}"); } + fn reasoning_block(id: &str) -> ThinkingBlock { + ThinkingBlock::Reasoning { + id: id.to_string(), + summary: json!([{ "type": "summary_text", "text": "thinking" }]), + encrypted_content: Some("enc123".to_string()), + } + } + + fn responses_tool_result( + id: &str, + text: Option<&str>, + thinking: Vec, + ) -> ToolResult { + ToolResult { + call: ToolCall::new("fs_read".into(), json!({"path": "x"}), Some(id.into())), + output: json!("ok"), + text: text.map(|t| t.to_string()), + thinking, + } + } + + fn build_responses_body( + tool_results: Vec, + functions: Option>, + ) -> Value { + let data = ChatCompletionsData { + messages: vec![ + Message::new(MessageRole::User, MessageContent::Text("hello".to_string())), + Message::new( + MessageRole::Assistant, + MessageContent::ToolCalls(MessageContentToolCalls { + tool_results, + text: "first round".to_string(), + sequence: false, + }), + ), + ], + temperature: None, + top_p: None, + reasoning_effort: None, + functions, + stream: false, + }; + openai_build_responses_body(data, &Model::new("openai", "gpt-test")) + } + + #[test] + fn responses_body_sets_include_and_strict_false() { + let functions = vec![ + FunctionDeclaration { + name: "a".to_string(), + description: "a description".to_string(), + parameters: Default::default(), + agent: false, + }, + FunctionDeclaration { + name: "b".to_string(), + description: "b description".to_string(), + parameters: Default::default(), + agent: false, + }, + ]; + let body = build_responses_body(vec![], Some(functions)); + + assert_eq!( + body["include"], + json!(["reasoning.encrypted_content"]), + "body: {body}" + ); + let tools = body["tools"].as_array().unwrap(); + assert_eq!(tools.len(), 2, "body: {body}"); + for tool in tools { + assert_eq!(tool["strict"], json!(false), "body: {body}"); + } + } + + #[test] + fn responses_replay_orders_reasoning_first_per_round() { + let body = build_responses_body( + vec![ + responses_tool_result("call_A", None, vec![reasoning_block("rs_1")]), + responses_tool_result("call_B", Some("round two"), vec![reasoning_block("rs_2")]), + ], + None, + ); + + let input = body["input"].as_array().unwrap(); + let types: Vec<_> = input + .iter() + .map(|item| item["type"].as_str().unwrap_or("text")) + .collect(); + assert_eq!( + types, + [ + "text", + "reasoning", + "text", + "function_call", + "function_call_output", + "reasoning", + "text", + "function_call", + "function_call_output", + ], + "body: {body}" + ); + assert_eq!(input[1]["id"], "rs_1", "body: {body}"); + assert_eq!(input[1]["encrypted_content"], "enc123", "body: {body}"); + assert_eq!(input[2]["content"], "first round", "body: {body}"); + assert_eq!(input[3]["call_id"], "call_A", "body: {body}"); + assert_eq!(input[5]["id"], "rs_2", "body: {body}"); + assert_eq!(input[6]["content"], "round two", "body: {body}"); + assert_eq!(input[7]["call_id"], "call_B", "body: {body}"); + } + + #[test] + fn responses_body_skips_anthropic_thinking_blocks() { + let body = build_responses_body( + vec![responses_tool_result( + "call_A", + None, + vec![ + ThinkingBlock::Thinking { + thinking: "hmm".to_string(), + signature: "sig123".to_string(), + }, + ThinkingBlock::RedactedThinking { + data: "b64data".to_string(), + }, + ], + )], + None, + ); + + let input = body["input"].as_array().unwrap(); + assert_eq!(input.len(), 4, "body: {body}"); + assert!( + input.iter().all(|item| !matches!( + item["type"].as_str(), + Some("thinking" | "redacted_thinking" | "reasoning") + )), + "body: {body}" + ); + } + + #[test] + fn extract_responses_captures_reasoning_items() { + let data = json!({ + "output": [ + { + "type": "reasoning", + "id": "rs_1", + "summary": [{ "type": "summary_text", "text": "thinking" }], + "encrypted_content": "enc123", + }, + { "type": "message", "content": [{ "type": "output_text", "text": "answer" }] }, + ] + }); + + let output = openai_extract_responses(&data).unwrap(); + + assert_eq!(output.text, "answer"); + assert_eq!(output.thinking.len(), 1); + match &output.thinking[0] { + ThinkingBlock::Reasoning { + id, + summary, + encrypted_content, + } => { + assert_eq!(id, "rs_1"); + assert_eq!( + summary, + &json!([{ "type": "summary_text", "text": "thinking" }]) + ); + assert_eq!(encrypted_content.as_deref(), Some("enc123")); + } + other => panic!("unexpected block: {other:?}"), + } + } + + #[test] + fn responses_stream_delivers_reasoning_block() { + let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel(); + let abort_signal = crate::utils::create_abort_signal(); + let mut handler = SseHandler::new(sender, abort_signal); + let event = json!({ + "type": "response.output_item.done", + "item": { + "type": "reasoning", + "id": "rs_1", + "summary": [{ "type": "summary_text", "text": "thinking" }], + "encrypted_content": "enc123", + } + }); + + let done = openai_responses_handle_event(&event, &mut handler).unwrap(); + + assert!(!done); + let (_, _, thinking, _) = handler.take(); + assert_eq!(thinking.len(), 1); + assert!(matches!( + &thinking[0], + ThinkingBlock::Reasoning { id, encrypted_content, .. } + if id == "rs_1" && encrypted_content.as_deref() == Some("enc123") + )); + } + fn openai_config(name: &str, auth: Option<&str>, oauth: Option) -> OpenAIConfig { OpenAIConfig { name: Some(name.into()), From 688ababecc921697553067da286d61ff20957ed7 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 12:24:30 -0600 Subject: [PATCH 06/16] test(TASK-042): prove serialize leg for old-session blocks and YAML round-trip - assert old Thinking/RedactedThinking blocks re-serialize to the original wire shape, proving both legs of old-session compat - add serde_yaml round-trip test for Vec covering the on-disk session format, including a Reasoning block with summary payload and the encrypted_content: None omitted-field case --- src/client/message.rs | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/client/message.rs b/src/client/message.rs index 87154ff..46e6b7d 100644 --- a/src/client/message.rs +++ b/src/client/message.rs @@ -288,7 +288,7 @@ mod tests { { "type": "redacted_thinking", "data": "b64data" }, ]); - let blocks: Vec = serde_json::from_value(json).unwrap(); + let blocks: Vec = serde_json::from_value(json.clone()).unwrap(); assert!( matches!(&blocks[0], ThinkingBlock::Thinking { thinking, signature } if thinking == "hmm" && signature == "sig123") @@ -296,6 +296,7 @@ mod tests { assert!( matches!(&blocks[1], ThinkingBlock::RedactedThinking { data } if data == "b64data") ); + assert_eq!(serde_json::to_value(&blocks).unwrap(), json); } #[test] @@ -330,4 +331,31 @@ mod tests { ); assert_eq!(serde_json::to_value(&block).unwrap(), json); } + + #[test] + fn thinking_blocks_round_trip_through_yaml() { + let json = json!([ + { "type": "thinking", "thinking": "hmm", "signature": "sig123" }, + { "type": "redacted_thinking", "data": "b64data" }, + { + "type": "reasoning", + "id": "rs_1", + "summary": [{ "type": "summary_text", "text": "thinking about it" }], + "encrypted_content": "enc123", + }, + { + "type": "reasoning", + "id": "rs_2", + "summary": [], + }, + ]); + + let blocks: Vec = serde_json::from_value(json.clone()).unwrap(); + + let yaml = serde_yaml::to_string(&blocks).unwrap(); + assert!(!yaml.contains("encrypted_content: null")); + + let restored: Vec = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), json); + } } From 9825ccd31ec26d290ad43e2e4dc365c3ba629455 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 12:51:44 -0600 Subject: [PATCH 07/16] feat(TASK-043): Responses usage parsing, failure hardening, responses default for stock openai Parse Responses-API token usage on both wires: non-streaming responses fill ChatCompletionsOutput.usage and response.completed events feed the SseHandler accumulator, with cached input mapped from input_tokens_details.cached_tokens. Harden failure paths: bail with the payload message on response.failed and error stream events, and surface incomplete_details.reason when a non-streaming response comes back incomplete and empty. Replay hardening: id-only Reasoning blocks (no encrypted_content) are dropped at replay time since the API 400s on them under store:false, which would permanently wedge a persisted session; a null summary is normalized to [] at the same site. Claude's replay guards flip to a positive Thinking/RedactedThinking allow-list. Flip the default wire for stock openai (api.openai.com, no custom api_base) to Responses. The resolver call site now passes a genuine stock-openai predicate: api-key clients or stock oauth without api_base qualify, config-oauth gateways never do. Custom api_base and openai-compatible clients keep defaulting to chat; explicit wire_api still always wins. Also reject a non-map responses section in RequestPatch via test. --- src/client/claude.rs | 56 +++++-- src/client/common.rs | 9 ++ src/client/openai.rs | 356 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 395 insertions(+), 26 deletions(-) diff --git a/src/client/claude.rs b/src/client/claude.rs index 78c7861..db31271 100644 --- a/src/client/claude.rs +++ b/src/client/claude.rs @@ -407,9 +407,14 @@ pub fn claude_build_chat_completions_body( let mut user_parts = vec![]; for (index, tool_result) in tool_results.iter().enumerate() { for block in &tool_result.thinking { - // Responses reasoning items are OpenAI-only; - // drop them when the session moves to Claude. - if !matches!(block, ThinkingBlock::Reasoning { .. }) { + // Only Anthropic thinking blocks replay here; + // Responses reasoning items (and any future + // variant) are dropped. + if matches!( + block, + ThinkingBlock::Thinking { .. } + | ThinkingBlock::RedactedThinking { .. } + ) { assistant_parts.push(json!(block)); } } @@ -479,7 +484,14 @@ pub fn claude_build_chat_completions_body( chunk_ids.insert(id); } for block in &tool_result.thinking { - if !matches!(block, ThinkingBlock::Reasoning { .. }) { + // Only Anthropic thinking blocks replay here; + // Responses reasoning items (and any future + // variant) are dropped. + if matches!( + block, + ThinkingBlock::Thinking { .. } + | ThinkingBlock::RedactedThinking { .. } + ) { assistant_parts.push(json!(block)); } } @@ -882,8 +894,8 @@ mod tests { } #[test] - fn skips_openai_reasoning_blocks_in_replay() { - let reasoning = ToolResult { + fn replays_only_anthropic_thinking_blocks() { + let mixed = ToolResult { call: ToolCall::new( "fs_read".into(), json!({"path": "x"}), @@ -891,19 +903,35 @@ mod tests { ), output: json!("ok"), text: None, - thinking: vec![ThinkingBlock::Reasoning { - id: "rs_1".into(), - summary: json!([{ "type": "summary_text", "text": "thinking" }]), - encrypted_content: Some("enc123".into()), - }], + thinking: vec![ + ThinkingBlock::Thinking { + thinking: "hmm".into(), + signature: "sig123".into(), + }, + ThinkingBlock::RedactedThinking { + data: "b64data".into(), + }, + ThinkingBlock::Reasoning { + id: "rs_1".into(), + summary: json!([{ "type": "summary_text", "text": "thinking" }]), + encrypted_content: Some("enc123".into()), + }, + ], }; for sequence in [false, true] { - let body = build_body_with(vec![reasoning.clone()], "", sequence, false); + let body = build_body_with(vec![mixed.clone()], "", sequence, false); let content = body["messages"][1]["content"].as_array().unwrap(); - assert_eq!(content.len(), 1, "body: {body}"); - assert_eq!(content[0]["type"], "tool_use", "body: {body}"); + let types: Vec<_> = content + .iter() + .map(|block| block["type"].as_str().unwrap()) + .collect(); + assert_eq!( + types, + ["thinking", "redacted_thinking", "tool_use"], + "body: {body}" + ); } } diff --git a/src/client/common.rs b/src/client/common.rs index 0ef8c70..3cb6944 100644 --- a/src/client/common.rs +++ b/src/client/common.rs @@ -1640,6 +1640,15 @@ mod tests { assert!(patch.chat_completions.is_some()); } + #[test] + fn non_map_request_patch_responses_section_is_rejected() { + let err = serde_json::from_value::(json!({"responses": "bogus"})) + .unwrap_err() + .to_string(); + + assert!(err.contains("expected a map"), "unexpected error: {err}"); + } + #[test] fn chat_client_patch_never_touches_the_responses_wire() { let client = patch_probe( diff --git a/src/client/openai.rs b/src/client/openai.rs index 6252009..3f1cad6 100644 --- a/src/client/openai.rs +++ b/src/client/openai.rs @@ -96,11 +96,12 @@ impl Client for OpenAIClient { /// Codex only speaks the Responses API, so it forces the responses wire and /// rejects an explicit `wire_api: chat`. Anywhere else an explicit `wire_api` -/// wins and the default is chat. +/// wins; without one, stock openai (api.openai.com, no custom `api_base`) +/// defaults to responses and everything else defaults to chat. pub fn resolve_wire_api( explicit: Option, uses_codex: bool, - _stock_openai_without_api_base: bool, + stock_openai_without_api_base: bool, ) -> Result { match (uses_codex, explicit) { (true, Some(WireApi::Chat)) => bail!( @@ -108,6 +109,7 @@ pub fn resolve_wire_api( ), (true, _) => Ok(WireApi::Responses), (false, Some(wire)) => Ok(wire), + (false, None) if stock_openai_without_api_base => Ok(WireApi::Responses), (false, None) => Ok(WireApi::Chat), } } @@ -135,10 +137,15 @@ async fn prepare_chat_completions( let uses_stock_provider = matches!(oauth_provider, Some((_, true))); // Stock oauth with no `api_base` routes to the ChatGPT codex backend (Responses API). let uses_codex = uses_stock_provider && self_.get_api_base().is_err(); + // Stock-openai traffic means api.openai.com: an api-key client (no oauth + // at all) or stock oauth, either way without a custom `api_base`. A + // config-oauth gateway missing its required `api_base` must not qualify. + let stock_openai_without_api_base = + (oauth_provider.is_none() || uses_stock_provider) && self_.get_api_base().is_err(); let wire = resolve_wire_api( self_.config.wire_api, uses_codex, - self_.get_api_base().is_err(), + stock_openai_without_api_base, )?; let url = if uses_codex { @@ -686,8 +693,25 @@ pub fn openai_build_responses_body(data: ChatCompletionsData, model: &Model) -> }; let mut items = vec![]; for block in &tool_result.thinking { - if matches!(block, ThinkingBlock::Reasoning { .. }) { - items.push(json!(block)); + if let ThinkingBlock::Reasoning { + id, + summary, + encrypted_content, + } = block + { + // Under `store: false` the API 400s on an + // id-only reasoning item, which would wedge a + // persisted session; it also rejects a null + // summary, so normalize it to an empty array. + if encrypted_content.is_none() { + debug!("dropping reasoning item '{id}': no encrypted_content"); + continue; + } + let mut item = json!(block); + if summary.is_null() { + item["summary"] = json!([]); + } + items.push(item); } } if let Some(round_text) = round_text { @@ -816,13 +840,19 @@ pub fn openai_extract_responses(data: &Value) -> Result { } if text.is_empty() && tool_calls.is_empty() { + if data["status"].as_str() == Some("incomplete") { + match data["incomplete_details"]["reason"].as_str() { + Some(reason) => bail!("The response was cut off: {reason}"), + None => bail!("The response was cut off: {data}"), + } + } bail!("Invalid response data: {data}"); } Ok(ChatCompletionsOutput { text, tool_calls, thinking, - ..Default::default() + usage: openai_parse_responses_usage(&data["usage"]), }) } @@ -837,6 +867,21 @@ fn reasoning_thinking_block(item: &Value) -> Option { }) } +/// OpenAI reports cached input under `input_tokens_details` and has no +/// cache-creation concept, so that field stays `None`. +fn openai_parse_responses_usage(usage: &Value) -> Option { + if !usage.is_object() { + return None; + } + + Some(TokenUsage { + input_tokens: usage["input_tokens"].as_u64(), + output_tokens: usage["output_tokens"].as_u64(), + cache_creation_input_tokens: None, + cache_read_input_tokens: usage["input_tokens_details"]["cached_tokens"].as_u64(), + }) +} + pub async fn openai_responses_streaming( builder: RequestBuilder, handler: &mut SseHandler, @@ -888,8 +933,20 @@ fn openai_responses_handle_event(data: &Value, handler: &mut SseHandler) -> Resu } } Some("response.completed") => { + if let Some(usage) = openai_parse_responses_usage(&data["response"]["usage"]) { + debug!("token-usage: {usage:?}"); + handler.usage(usage); + } return Ok(true); } + Some("response.failed") => match data["response"]["error"]["message"].as_str() { + Some(message) => bail!("Response failed: {message}"), + None => bail!("Response failed: {data}"), + }, + Some("error") => match data["message"].as_str() { + Some(message) => bail!("Stream error: {message}"), + None => bail!("Stream error: {data}"), + }, _ => {} } Ok(false) @@ -1089,6 +1146,51 @@ mod tests { ); } + #[test] + fn responses_replay_skips_reasoning_without_encrypted_content() { + let body = build_responses_body( + vec![responses_tool_result( + "call_A", + None, + vec![ThinkingBlock::Reasoning { + id: "rs_1".to_string(), + summary: json!([]), + encrypted_content: None, + }], + )], + None, + ); + + let input = body["input"].as_array().unwrap(); + assert!( + input.iter().all(|item| item["type"] != "reasoning"), + "body: {body}" + ); + } + + #[test] + fn responses_replay_normalizes_null_summary_to_empty_array() { + let body = build_responses_body( + vec![responses_tool_result( + "call_A", + None, + vec![ThinkingBlock::Reasoning { + id: "rs_1".to_string(), + summary: Value::Null, + encrypted_content: Some("enc123".to_string()), + }], + )], + None, + ); + + let input = body["input"].as_array().unwrap(); + let item = input + .iter() + .find(|item| item["type"] == "reasoning") + .unwrap(); + assert_eq!(item["summary"], json!([]), "body: {body}"); + } + #[test] fn extract_responses_captures_reasoning_items() { let data = json!({ @@ -1124,6 +1226,64 @@ mod tests { } } + #[test] + fn extract_responses_parses_usage() { + let data = json!({ + "output": [ + { "type": "message", "content": [{ "type": "output_text", "text": "answer" }] }, + ], + "usage": { + "input_tokens": 100, + "output_tokens": 20, + "input_tokens_details": { "cached_tokens": 60 }, + } + }); + + let output = openai_extract_responses(&data).unwrap(); + + let usage = output.usage.unwrap(); + assert_eq!(usage.input_tokens, Some(100)); + assert_eq!(usage.output_tokens, Some(20)); + assert_eq!(usage.cache_creation_input_tokens, None); + assert_eq!(usage.cache_read_input_tokens, Some(60)); + } + + #[test] + fn extract_responses_without_usage_leaves_it_none() { + let data = json!({ + "output": [ + { "type": "message", "content": [{ "type": "output_text", "text": "answer" }] }, + ] + }); + + assert!(openai_extract_responses(&data).unwrap().usage.is_none()); + } + + #[test] + fn extract_responses_incomplete_status_surfaces_reason() { + let data = json!({ + "status": "incomplete", + "incomplete_details": { "reason": "max_output_tokens" }, + "output": [] + }); + + let err = openai_extract_responses(&data).unwrap_err().to_string(); + + assert!( + err.contains("cut off") && err.contains("max_output_tokens"), + "unexpected error: {err}" + ); + } + + #[test] + fn extract_responses_incomplete_without_reason_still_bails() { + let data = json!({ "status": "incomplete", "output": [] }); + + let err = openai_extract_responses(&data).unwrap_err().to_string(); + + assert!(err.contains("cut off"), "unexpected error: {err}"); + } + #[test] fn responses_stream_delivers_reasoning_block() { let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel(); @@ -1151,6 +1311,73 @@ mod tests { )); } + #[test] + fn responses_stream_completed_delivers_usage() { + let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel(); + let abort_signal = crate::utils::create_abort_signal(); + let mut handler = SseHandler::new(sender, abort_signal); + let event = json!({ + "type": "response.completed", + "response": { + "usage": { + "input_tokens": 100, + "output_tokens": 20, + "input_tokens_details": { "cached_tokens": 60 }, + } + } + }); + + let done = openai_responses_handle_event(&event, &mut handler).unwrap(); + + assert!(done); + let (_, _, _, usage) = handler.take(); + let usage = usage.unwrap(); + assert_eq!(usage.input_tokens, Some(100)); + assert_eq!(usage.output_tokens, Some(20)); + assert_eq!(usage.cache_creation_input_tokens, None); + assert_eq!(usage.cache_read_input_tokens, Some(60)); + } + + #[test] + fn responses_stream_failed_event_bails_with_message() { + let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel(); + let abort_signal = crate::utils::create_abort_signal(); + let mut handler = SseHandler::new(sender, abort_signal); + let event = json!({ + "type": "response.failed", + "response": { + "error": { "code": "server_error", "message": "The model had an issue" } + } + }); + + let err = openai_responses_handle_event(&event, &mut handler) + .unwrap_err() + .to_string(); + + assert!( + err.contains("The model had an issue"), + "unexpected error: {err}" + ); + } + + #[test] + fn responses_stream_error_event_bails_with_message() { + let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel(); + let abort_signal = crate::utils::create_abort_signal(); + let mut handler = SseHandler::new(sender, abort_signal); + let event = json!({ + "type": "error", + "code": "rate_limit_exceeded", + "message": "Rate limit reached", + }); + + let err = openai_responses_handle_event(&event, &mut handler) + .unwrap_err() + .to_string(); + + assert!(err.contains("Rate limit reached"), "unexpected error: {err}"); + } + fn openai_config(name: &str, auth: Option<&str>, oauth: Option) -> OpenAIConfig { OpenAIConfig { name: Some(name.into()), @@ -1307,6 +1534,27 @@ mod tests { ); } + /// A config-oauth gateway missing its required `api_base` must never + /// count as stock-openai traffic: its provider resolves as non-stock, so + /// the wire resolver sees `stock=false`, and the request dies on the + /// missing-api_base config error instead of adopting codex routing. + #[test] + fn config_oauth_without_api_base_is_not_stock_openai_traffic() { + let name = "openai-gate-no-apibase-wire-test"; + let mut config = openai_config(name, Some("oauth"), Some(minimal_oauth_config())); + config.api_base = None; + let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]); + + let (_, is_stock) = resolve_oauth_provider(&client).unwrap(); + assert!(!is_stock, "an inline oauth block is a config provider"); + + let err = prepare(&client).unwrap_err().to_string(); + assert!( + err.contains("refusing to fall back"), + "unexpected error: {err}" + ); + } + #[test] fn oauth_block_without_auth_oauth_is_rejected() { let name = "openai-gate-contradiction-test"; @@ -1518,6 +1766,64 @@ mod tests { ); } + #[test] + fn shipped_catalog_o_series_selects_responses_sub_patches() { + let bundled: Vec = serde_yaml::from_str(MODELS_YAML).unwrap(); + let openai = bundled + .iter() + .find(|p| p.provider == "openai") + .expect("bundled models.yaml must carry an openai provider"); + + for (name, effort) in [ + ("o4-mini", None), + ("o3", None), + ("o3-mini", None), + ("o4-mini-high", Some("high")), + ("o3-high", Some("high")), + ("o3-mini-high", Some("high")), + ] { + let data = openai + .models + .iter() + .find(|m| m.name == name) + .unwrap_or_else(|| panic!("model '{name}' missing from bundled models.yaml")); + let config = api_key_config("openai", None); + let mut client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]); + client.model = Model::from_config("openai", std::slice::from_ref(data)).remove(0); + let mut request_data = RequestData::new( + format!("{API_BASE}/responses"), + json!({ "model": name, "temperature": 0.5, "top_p": 0.9 }), + ) + .wire(WireApi::Responses); + + client.patch_request_data(&mut request_data); + + assert!( + request_data.body.get("temperature").is_none(), + "{name}: {}", + request_data.body + ); + assert!( + request_data.body.get("top_p").is_none(), + "{name}: {}", + request_data.body + ); + match effort { + Some(effort) => assert_eq!( + request_data.body["reasoning"]["effort"], + json!(effort), + "{name}: {}", + request_data.body + ), + None => assert!( + request_data.body.get("reasoning").is_none(), + "{name}: {}", + request_data.body + ), + } + } + } + #[test] fn wire_api_deserializes_on_openai_config() { let config: OpenAIConfig = serde_yaml::from_str("wire_api: responses").unwrap(); @@ -1578,10 +1884,16 @@ mod tests { } #[test] - fn resolver_defaults_to_chat_this_milestone() { - for stock in [false, true] { - assert_eq!(resolve_wire_api(None, false, stock).unwrap(), WireApi::Chat); - } + fn resolver_defaults_stock_openai_to_responses() { + assert_eq!( + resolve_wire_api(None, false, true).unwrap(), + WireApi::Responses + ); + } + + #[test] + fn resolver_defaults_to_chat_off_stock() { + assert_eq!(resolve_wire_api(None, false, false).unwrap(), WireApi::Chat); } fn api_key_config(name: &str, wire_api: Option) -> OpenAIConfig { @@ -1594,14 +1906,34 @@ mod tests { } #[test] - fn api_key_openai_defaults_to_the_chat_wire() { + fn api_key_openai_defaults_to_the_responses_wire() { let config = api_key_config("openai-wire-default-test", None); let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]); let (request_data, wire) = prepare(&client).unwrap(); + assert_eq!(wire, WireApi::Responses); + assert_eq!(request_data.url, format!("{API_BASE}/responses")); + assert!( + request_data.body.get("input").is_some() && request_data.body.get("messages").is_none(), + "body: {}", + request_data.body + ); + } + + #[test] + fn api_key_openai_with_custom_api_base_defaults_to_the_chat_wire() { + let mut config = api_key_config("openai-wire-custom-base-test", None); + config.api_base = Some("https://gateway.example/v1".into()); + let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]); + + let (request_data, wire) = prepare(&client).unwrap(); + assert_eq!(wire, WireApi::Chat); - assert_eq!(request_data.url, format!("{API_BASE}/chat/completions")); + assert_eq!( + request_data.url, + "https://gateway.example/v1/chat/completions" + ); assert!( request_data.body.get("messages").is_some(), "body: {}", From 0dc9dd91c399762579accfd34d517df7ed40fdb9 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 12:51:49 -0600 Subject: [PATCH 08/16] chore(TASK-043): regenerate model catalog Add responses sub-patches to the six o-series quirks entries (temperature and top_p removal; reasoning.effort high for the -high aliases) and regenerate models.yaml from live models.dev/openrouter data, picking up expected upstream price and model drift. --- models.yaml | 88 ++++++++++++++++++++++++++++++++++++++--------------- quirks.yaml | 30 ++++++++++++++++++ 2 files changed, 93 insertions(+), 25 deletions(-) diff --git a/models.yaml b/models.yaml index a002557..b2e1673 100644 --- a/models.yaml +++ b/models.yaml @@ -351,6 +351,10 @@ max_tokens: null temperature: null top_p: null + responses: + body: + temperature: null + top_p: null max_output_tokens: 100000 supports_vision: true supports_function_calling: true @@ -370,6 +374,10 @@ max_tokens: null temperature: null top_p: null + responses: + body: + temperature: null + top_p: null max_output_tokens: 100000 supports_vision: true supports_function_calling: true @@ -429,6 +437,10 @@ max_tokens: null temperature: null top_p: null + responses: + body: + temperature: null + top_p: null max_output_tokens: 100000 supports_function_calling: true reasoning_levels: @@ -540,6 +552,12 @@ max_tokens: null temperature: null top_p: null + responses: + body: + temperature: null + top_p: null + reasoning: + effort: high max_output_tokens: 100000 supports_vision: true supports_function_calling: true @@ -556,6 +574,12 @@ max_tokens: null temperature: null top_p: null + responses: + body: + temperature: null + top_p: null + reasoning: + effort: high supports_vision: true supports_function_calling: true system_prompt_prefix: Formatting re-enabled @@ -571,6 +595,12 @@ max_tokens: null temperature: null top_p: null + responses: + body: + temperature: null + top_p: null + reasoning: + effort: high max_output_tokens: 100000 supports_vision: true supports_function_calling: true @@ -6947,9 +6977,9 @@ - name: deepseek/deepseek-v4-flash type: chat max_input_tokens: 1048576 - input_price: 0.0886 - output_price: 0.1772 - cache_read_price: 0.0177 + input_price: 0.084 + output_price: 0.168 + cache_read_price: 0.0168 max_output_tokens: 384000 supports_function_calling: true - name: deepseek/deepseek-v4-flash-0731 @@ -6997,10 +7027,10 @@ - name: deepseek/deepseek-v4-pro-0813 type: chat max_input_tokens: 1048576 - input_price: 0.9834 - output_price: 2.9502 - cache_read_price: 0.0328 - max_output_tokens: 384000 + input_price: 0.5795 + output_price: 1.7384 + cache_read_price: 0.0184 + max_output_tokens: 393216 supports_function_calling: true - name: deepseek/deepseek-v4-pro-0813:batch type: chat @@ -8022,9 +8052,9 @@ - name: moonshotai/kimi-k3 type: chat max_input_tokens: 1048576 - input_price: 2.6481 - output_price: 13.2827 - cache_read_price: 0.3026 + input_price: 3.0 + output_price: 15.0 + cache_read_price: 0.3 max_output_tokens: 943718 supports_vision: true supports_function_calling: true @@ -9405,9 +9435,9 @@ - name: qwen/qwen3-vl-30b-a3b-instruct type: chat max_input_tokens: 262144 - input_price: 0.15 - output_price: 0.6 - max_output_tokens: 16384 + input_price: 0.13 + output_price: 0.52 + max_output_tokens: 32768 supports_vision: true supports_function_calling: true - name: qwen/qwen3-vl-30b-a3b-thinking @@ -9716,6 +9746,14 @@ input_price: 0.65 output_price: 0.75 max_output_tokens: 16384 + - name: stealth/union-alpha + type: chat + max_input_tokens: 262144 + input_price: 0.0 + output_price: 0.0 + max_output_tokens: 131072 + supports_vision: true + supports_function_calling: true - name: stepfun/step-3.5-flash type: chat max_input_tokens: 262144 @@ -9759,9 +9797,9 @@ - name: tencent/hy3 type: chat max_input_tokens: 262144 - input_price: 0.132 - output_price: 0.528 - cache_read_price: 0.033 + input_price: 0.0825 + output_price: 0.33 + cache_read_price: 0.0206 max_output_tokens: 128000 supports_function_calling: true - name: tencent/hy3-preview @@ -10037,7 +10075,7 @@ input_price: 1.4 output_price: 4.4 cache_read_price: 0.14 - max_output_tokens: 128000 + max_output_tokens: 131072 supports_function_calling: true - name: z-ai/glm-5.2:batch type: chat @@ -10142,16 +10180,16 @@ input_price: 0.15 output_price: 0.6 cache_read_price: 0.015 - max_output_tokens: 943718 + max_output_tokens: 393216 supports_vision: true supports_function_calling: true - name: ~deepseek/deepseek-pro-latest type: chat max_input_tokens: 1048576 - input_price: 0.66 - output_price: 1.98 - cache_read_price: 0.022 - max_output_tokens: 384000 + input_price: 0.5795 + output_price: 1.7384 + cache_read_price: 0.0184 + max_output_tokens: 393216 supports_function_calling: true - name: ~deepseek/deepseek-v4-flash-latest type: chat @@ -10184,9 +10222,9 @@ - name: ~moonshotai/kimi-latest type: chat max_input_tokens: 1048576 - input_price: 2.1 - output_price: 10.95 - cache_read_price: 0.23 + input_price: 2.0 + output_price: 11.2 + cache_read_price: 0.232 max_output_tokens: 943718 supports_vision: true supports_function_calling: true diff --git a/quirks.yaml b/quirks.yaml index 3db303c..fdc65d8 100644 --- a/quirks.yaml +++ b/quirks.yaml @@ -51,6 +51,10 @@ max_tokens: null temperature: null top_p: null + responses: + body: + temperature: null + top_p: null - match: "o3" system_prompt_prefix: Formatting re-enabled patch: @@ -58,6 +62,10 @@ max_tokens: null temperature: null top_p: null + responses: + body: + temperature: null + top_p: null - match: "o3-mini" system_prompt_prefix: Formatting re-enabled patch: @@ -65,6 +73,10 @@ max_tokens: null temperature: null top_p: null + responses: + body: + temperature: null + top_p: null models: - name: text-embedding-3-large type: embedding @@ -94,6 +106,12 @@ max_tokens: null temperature: null top_p: null + responses: + body: + temperature: null + top_p: null + reasoning: + effort: high - name: o3-high real_name: o3 max_input_tokens: 200000 @@ -108,6 +126,12 @@ max_tokens: null temperature: null top_p: null + responses: + body: + temperature: null + top_p: null + reasoning: + effort: high - name: o3-mini-high max_output_tokens: 100000 real_name: o3-mini @@ -123,6 +147,12 @@ max_tokens: null temperature: null top_p: null + responses: + body: + temperature: null + top_p: null + reasoning: + effort: high - name: chat-latest max_input_tokens: 400000 max_output_tokens: 128000 From 3b6610136122dd6d8db9acb6dcd9396fb7e777a4 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 13:21:10 -0600 Subject: [PATCH 09/16] feat(TASK-044): surface the streaming response.incomplete event with its reason --- src/client/openai.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/client/openai.rs b/src/client/openai.rs index 3f1cad6..73a84fb 100644 --- a/src/client/openai.rs +++ b/src/client/openai.rs @@ -943,6 +943,12 @@ fn openai_responses_handle_event(data: &Value, handler: &mut SseHandler) -> Resu Some(message) => bail!("Response failed: {message}"), None => bail!("Response failed: {data}"), }, + Some("response.incomplete") => { + match data["response"]["incomplete_details"]["reason"].as_str() { + Some(reason) => bail!("The response was cut off: {reason}"), + None => bail!("The response was cut off: {data}"), + } + } Some("error") => match data["message"].as_str() { Some(message) => bail!("Stream error: {message}"), None => bail!("Stream error: {data}"), @@ -1378,6 +1384,28 @@ mod tests { assert!(err.contains("Rate limit reached"), "unexpected error: {err}"); } + #[test] + fn responses_stream_incomplete_event_bails_with_reason() { + let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel(); + let abort_signal = crate::utils::create_abort_signal(); + let mut handler = SseHandler::new(sender, abort_signal); + let event = json!({ + "type": "response.incomplete", + "response": { + "incomplete_details": { "reason": "max_output_tokens" } + } + }); + + let err = openai_responses_handle_event(&event, &mut handler) + .unwrap_err() + .to_string(); + + assert!( + err.contains("The response was cut off: max_output_tokens"), + "unexpected error: {err}" + ); + } + fn openai_config(name: &str, auth: Option<&str>, oauth: Option) -> OpenAIConfig { OpenAIConfig { name: Some(name.into()), From 9e7c957a3db4229b12e3b85bee6ed110a1c930f9 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 13:21:10 -0600 Subject: [PATCH 10/16] docs(TASK-044): document wire_api selection, defaults, and wire-aware patch migration --- CHANGELOG.md | 21 +++++++++++++++++++++ assets/config-template.yaml | 24 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad3bd9b..a24f88d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,24 @@ +## Unreleased + +### BREAKING CHANGE + +- The stock `openai` client (no custom `api_base`) now defaults to the OpenAI Responses API +(`/v1/responses`) instead of Chat Completions. Pin the old wire by adding `wire_api: chat` to the +client block. Openai clients with a custom `api_base` and `openai-compatible` clients still +default to `chat`; Codex OAuth always uses `responses` (`wire_api: chat` there is a config error). +- Request patches are now wire-aware and chat-keyed patches never apply to the responses wire: if +your stock openai client relied on `patch.chat_completions` blocks (or top-level model patches), +migrate them to the sibling `patch.responses` key / the model patch's `responses:` sub-key +(env override: `COYOTE_PATCH__RESPONSES`). +- Sessions saved after this change may contain the new `reasoning` thinking blocks (encrypted +reasoning items from the Responses API); older coyote binaries cannot load those sessions. + +### Feat + +- support `wire_api: responses | chat` on the openai and openai-compatible clients, with wire-aware request patching on both patch layers +- report token usage (including cached input tokens) on the openai responses path +- opting a third-party endpoint into `wire_api: responses` sends `include: ["reasoning.encrypted_content"]` and `"strict": false` tool definitions — strict `/v1/responses` servers may reject these as unknown params + ## v0.10.3 (2026-09-15) ### Feat diff --git a/assets/config-template.yaml b/assets/config-template.yaml index 4ac410d..0e6ae22 100644 --- a/assets/config-template.yaml +++ b/assets/config-template.yaml @@ -290,4 +290,28 @@ sync_models_ttl_hours: 24 # Hours before the synced model catalog is cons # # The claude, bedrock, and vertexai clients also support: # prompt_cache: true # Enable Anthropic prompt caching (default: true) +# +# The openai and openai-compatible clients also support: +# wire_api: responses # Which OpenAI wire protocol to use; Possible values: responses and chat +# # Defaults when unset: stock openai (no api_base) -> responses; +# # openai with a custom api_base -> chat; openai-compatible -> chat. +# # Codex OAuth always uses responses (`wire_api: chat` there is a config error) +# +# Patches are selected per wire: `chat_completions` patches only ever touch chat bodies. To patch +# requests on the responses wire, use the `responses` key on the client `patch` block (same +# -> url/body/headers shape as chat_completions): +# patch: +# responses: +# : +# body: +# +# Model patches likewise: the top-level `patch` keys apply to the chat wire only, and a +# `responses:` sub-key applies to the responses wire only: +# models: +# - name: xxxx +# patch: +# responses: +# body: +# +# Env override per wire: COYOTE_PATCH__RESPONSES / COYOTE_PATCH__CHAT_COMPLETIONS __CLIENTS_BLOCK__ From ce20927713e525ed619e19177579d53ab6e69c2e Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 13:31:52 -0600 Subject: [PATCH 11/16] =?UTF-8?q?fix(TASK-044):=20streaming=20response.inc?= =?UTF-8?q?omplete=20parity=20=E2=80=94=20keep=20partial=20output,=20recor?= =?UTF-8?q?d=20usage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings: the streaming arm now mirrors the non-streaming twin (openai_extract_responses): partial text/tool calls are returned rather than discarded by a hard error; only an empty truncated response bails. Billed usage on truncated turns is recorded before either outcome. Also: CHANGELOG notes the streaming-incomplete surfacing and corrects the include/strict phrasing (sent on every responses-wire body). --- CHANGELOG.md | 3 ++- src/client/openai.rs | 59 ++++++++++++++++++++++++++++++++++++++++++++ src/client/stream.rs | 8 ++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a24f88d..1665b76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,8 @@ reasoning items from the Responses API); older coyote binaries cannot load those - support `wire_api: responses | chat` on the openai and openai-compatible clients, with wire-aware request patching on both patch layers - report token usage (including cached input tokens) on the openai responses path -- opting a third-party endpoint into `wire_api: responses` sends `include: ["reasoning.encrypted_content"]` and `"strict": false` tool definitions — strict `/v1/responses` servers may reject these as unknown params +- streaming responses that end with `response.incomplete` now surface the truncation reason (e.g. `max_output_tokens`) when no output was delivered — matching the non-streaming behavior — and record token usage for truncated turns instead of ending silently +- the responses wire always sends `include: ["reasoning.encrypted_content"]` and `"strict": false` tool definitions; strict third-party `/v1/responses` servers may reject these as unknown params when opted in via `wire_api: responses` (workaround: a `responses`-keyed client patch, e.g. `include: null` for Groq) ## v0.10.3 (2026-09-15) diff --git a/src/client/openai.rs b/src/client/openai.rs index 73a84fb..c3853b2 100644 --- a/src/client/openai.rs +++ b/src/client/openai.rs @@ -944,6 +944,18 @@ fn openai_responses_handle_event(data: &Value, handler: &mut SseHandler) -> Resu None => bail!("Response failed: {data}"), }, Some("response.incomplete") => { + // Truncated turns are the most expensive ones; record their + // billed usage before deciding how to surface the truncation. + if let Some(usage) = openai_parse_responses_usage(&data["response"]["usage"]) { + debug!("token-usage: {usage:?}"); + handler.usage(usage); + } + // Parity with the non-streaming path (`openai_extract_responses`): + // partial text or tool calls are returned rather than discarded + // by a hard error; only an empty truncated response bails. + if handler.has_received_visible_output() { + return Ok(true); + } match data["response"]["incomplete_details"]["reason"].as_str() { Some(reason) => bail!("The response was cut off: {reason}"), None => bail!("The response was cut off: {data}"), @@ -1406,6 +1418,53 @@ mod tests { ); } + #[test] + fn responses_stream_incomplete_without_reason_still_bails() { + let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel(); + let abort_signal = crate::utils::create_abort_signal(); + let mut handler = SseHandler::new(sender, abort_signal); + let event = json!({ + "type": "response.incomplete", + "response": {} + }); + + let err = openai_responses_handle_event(&event, &mut handler) + .unwrap_err() + .to_string(); + + assert!(err.contains("The response was cut off"), "unexpected error: {err}"); + } + + #[test] + fn responses_stream_incomplete_with_partial_output_ends_stream_and_records_usage() { + let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel(); + let abort_signal = crate::utils::create_abort_signal(); + let mut handler = SseHandler::new(sender, abort_signal); + handler.set_silent(true); + handler.text("partial answer").unwrap(); + let event = json!({ + "type": "response.incomplete", + "response": { + "incomplete_details": { "reason": "max_output_tokens" }, + "usage": { + "input_tokens": 50, + "output_tokens": 10, + "input_tokens_details": { "cached_tokens": 25 } + } + } + }); + + let done = openai_responses_handle_event(&event, &mut handler).unwrap(); + + assert!(done, "partial output should end the stream, not bail"); + let (text, _, _, usage) = handler.take(); + assert_eq!(text, "partial answer"); + let usage = usage.expect("usage should be recorded for truncated turns"); + assert_eq!(usage.input_tokens, Some(50)); + assert_eq!(usage.output_tokens, Some(10)); + assert_eq!(usage.cache_read_input_tokens, Some(25)); + } + fn openai_config(name: &str, auth: Option<&str>, oauth: Option) -> OpenAIConfig { OpenAIConfig { name: Some(name.into()), diff --git a/src/client/stream.rs b/src/client/stream.rs index 2d9d3ab..960eedf 100644 --- a/src/client/stream.rs +++ b/src/client/stream.rs @@ -215,6 +215,14 @@ impl SseHandler { !self.buffer.is_empty() || !self.tool_calls.is_empty() || !self.thinking.is_empty() } + /// Whether visible output (text or tool calls) has been accumulated. + /// Unlike [`Self::has_received_content`], thinking blocks do not count: + /// a stream truncated before any visible output is still an empty + /// response to the caller. + pub fn has_received_visible_output(&self) -> bool { + !self.buffer.is_empty() || !self.tool_calls.is_empty() + } + pub fn abort(&self) -> AbortSignal { self.abort_signal.clone() } From a0cc5f291e76756898a36f79e12594896879a26d Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 13:42:42 -0600 Subject: [PATCH 12/16] =?UTF-8?q?docs(TASK-044):=20review=20polish=20?= =?UTF-8?q?=E2=80=94=20visible-output=20wording,=20web-search=20wire=20pin?= =?UTF-8?q?=20note,=20truncation=20debug=20line,=20thinking-exclusion=20pi?= =?UTF-8?q?n=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 5 ++++- src/client/openai.rs | 4 ++++ src/client/stream.rs | 17 +++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1665b76..1aac5f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,12 +12,15 @@ migrate them to the sibling `patch.responses` key / the model patch's `responses (env override: `COYOTE_PATCH__RESPONSES`). - Sessions saved after this change may contain the new `reasoning` thinking blocks (encrypted reasoning items from the Responses API); older coyote binaries cannot load those sessions. +- OpenAI native web search (the bundled `web_search_coyote` tool) uses the chat-only +`gpt-4o-*search-preview` mechanism, which the responses wire does not serve: until the tool is +updated, pin `wire_api: chat` on the openai client when relying on it. ### Feat - support `wire_api: responses | chat` on the openai and openai-compatible clients, with wire-aware request patching on both patch layers - report token usage (including cached input tokens) on the openai responses path -- streaming responses that end with `response.incomplete` now surface the truncation reason (e.g. `max_output_tokens`) when no output was delivered — matching the non-streaming behavior — and record token usage for truncated turns instead of ending silently +- streaming responses that end with `response.incomplete` now surface the truncation reason (e.g. `max_output_tokens`) when no visible output (text or tool calls) was delivered — matching the non-streaming behavior — and record token usage for truncated turns instead of ending silently - the responses wire always sends `include: ["reasoning.encrypted_content"]` and `"strict": false` tool definitions; strict third-party `/v1/responses` servers may reject these as unknown params when opted in via `wire_api: responses` (workaround: a `responses`-keyed client patch, e.g. `include: null` for Groq) ## v0.10.3 (2026-09-15) diff --git a/src/client/openai.rs b/src/client/openai.rs index c3853b2..ffc0933 100644 --- a/src/client/openai.rs +++ b/src/client/openai.rs @@ -954,6 +954,10 @@ fn openai_responses_handle_event(data: &Value, handler: &mut SseHandler) -> Resu // partial text or tool calls are returned rather than discarded // by a hard error; only an empty truncated response bails. if handler.has_received_visible_output() { + debug!( + "response truncated ({}); keeping partial output", + data["response"]["incomplete_details"]["reason"] + ); return Ok(true); } match data["response"]["incomplete_details"]["reason"].as_str() { diff --git a/src/client/stream.rs b/src/client/stream.rs index 960eedf..9f2acb9 100644 --- a/src/client/stream.rs +++ b/src/client/stream.rs @@ -552,6 +552,23 @@ mod tests { assert!(handler.has_received_content()); } + /// Pins the deliberate exclusion: thinking blocks count as received + /// content but not as visible output, so a thinking-only stream is + /// still an empty response to the caller. + #[test] + fn thinking_blocks_do_not_count_as_visible_output() { + let (mut handler, _rx) = new_handler(); + handler.thinking_block(ThinkingBlock::Thinking { + thinking: "hmm".to_string(), + signature: "sig".to_string(), + }); + assert!(handler.has_received_content()); + assert!(!handler.has_received_visible_output()); + + handler.text("hello").unwrap(); + assert!(handler.has_received_visible_output()); + } + /// Claude splits usage across events: `message_start` carries the input /// and cache counts with a provisional output count, `message_delta` /// carries only the final output count. Later non-None fields must win. From 5495813baf7ac02f8d7388b379eed19bda18af75 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 14:41:05 -0600 Subject: [PATCH 13/16] feat(TASK-057): web_search_coyote works on both OpenAI wire APIs - openai searches run an attempt chain: the chat-completions mechanism (search-preview model + web_search_options patch) first, then the Responses-native web_search tool on a responses-capable model; the search fails only when both attempts fail - both wire-keyed patches (COYOTE_PATCH_OPENAI_CHAT_COMPLETIONS / COYOTE_PATCH_OPENAI_RESPONSES) are exported per attempt; the client's resolved wire applies exactly one - supports_native_search accepts responses-capable openai families (gpt-4o*/gpt-4.1*/gpt-5*/o3*/o4*) - stub-coyote harness pins attempt order, patch export, model mapping, hard failure, and untouched non-openai paths (scripts/test-web-search-tool.sh) - CHANGELOG: responses-default breakage caveat replaced with the both-wire feature note --- CHANGELOG.md | 4 +- assets/functions/tools/web_search_coyote.sh | 76 ++++++- scripts/test-web-search-tool.sh | 222 ++++++++++++++++++++ 3 files changed, 289 insertions(+), 13 deletions(-) create mode 100755 scripts/test-web-search-tool.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aac5f8..255ddb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,12 +12,10 @@ migrate them to the sibling `patch.responses` key / the model patch's `responses (env override: `COYOTE_PATCH__RESPONSES`). - Sessions saved after this change may contain the new `reasoning` thinking blocks (encrypted reasoning items from the Responses API); older coyote binaries cannot load those sessions. -- OpenAI native web search (the bundled `web_search_coyote` tool) uses the chat-only -`gpt-4o-*search-preview` mechanism, which the responses wire does not serve: until the tool is -updated, pin `wire_api: chat` on the openai client when relying on it. ### Feat +- the bundled `web_search_coyote` tool serves openai native web search on both wires: it tries the chat-completions mechanism (the chat-only `gpt-4o-*search-preview` models + `web_search_options`) first and falls back to the Responses-native `web_search` tool on responses-capable models (`gpt-4o*`/`gpt-4.1*`/`gpt-5*`/`o3*`/`o4*`), failing only when both attempts fail — the stock responses default needs no `wire_api: chat` pin - support `wire_api: responses | chat` on the openai and openai-compatible clients, with wire-aware request patching on both patch layers - report token usage (including cached input tokens) on the openai responses path - streaming responses that end with `response.incomplete` now surface the truncation reason (e.g. `max_output_tokens`) when no visible output (text or tool calls) was delivered — matching the non-streaming behavior — and record token usage for truncated turns instead of ending silently diff --git a/assets/functions/tools/web_search_coyote.sh b/assets/functions/tools/web_search_coyote.sh index 1ee3ace..68e51c4 100755 --- a/assets/functions/tools/web_search_coyote.sh +++ b/assets/functions/tools/web_search_coyote.sh @@ -23,10 +23,16 @@ set -e # - perplexity:* # - ernie:* # - claude:* (Anthropic native web_search server tool) -# - openai:gpt-4o-search-preview (and -mini-; requires an api-key openai -# client — the codex OAuth path uses the -# Responses API where this parameter -# does not exist) +# - openai:gpt-4o*/gpt-4.1*/gpt-5*/o3*/o4* +# OpenAI serves web search through two different mechanisms depending on +# the wire API the client resolves (the script cannot pick the wire): +# Chat Completions serves it via `web_search_options` on the chat-only +# *-search-preview models, while the Responses API serves it via its +# native `web_search` tool on regular models. Both request patches are +# exported (request patching is wire-aware, so the resolved wire applies +# exactly one) and openai searches run as an attempt chain: the +# chat-completions mechanism first, then the Responses mechanism, and +# the search fails only when both attempts fail. # @env WEB_SEARCH_USE_CURRENT_MODEL=true Prefer the running session's model when it natively supports web search. # @env LLM_OUTPUT=/dev/stdout The output path @@ -34,7 +40,7 @@ set -e supports_native_search() { case "$1" in gemini:* | vertexai:gemini-* | perplexity:* | ernie:* | claude:*) return 0 ;; - openai:gpt-4o*search-preview*) return 0 ;; + openai:gpt-4o* | openai:gpt-4.1* | openai:gpt-5* | openai:o3* | openai:o4*) return 0 ;; *) return 1 ;; esac } @@ -46,7 +52,8 @@ export_search_patch() { COYOTE_PATCH_VERTEXAI_CHAT_COMPLETIONS \ COYOTE_PATCH_ERNIE_CHAT_COMPLETIONS \ COYOTE_PATCH_CLAUDE_CHAT_COMPLETIONS \ - COYOTE_PATCH_OPENAI_CHAT_COMPLETIONS + COYOTE_PATCH_OPENAI_CHAT_COMPLETIONS \ + COYOTE_PATCH_OPENAI_RESPONSES case "${1%%:*}" in gemini) @@ -65,18 +72,67 @@ export_search_patch() { export COYOTE_PATCH_CLAUDE_CHAT_COMPLETIONS='{".*":{"body":{"tools":[{"type":"web_search_20250305","name":"web_search","max_uses":5}]}}}' ;; openai) - # Chat Completions native search exists only on the search-preview - # models; the regex scopes the patch so other OpenAI models run - # unpatched instead of erroring on an unsupported parameter. + # Both patches are exported; request patching is wire-aware, so the + # client's resolved wire applies exactly one of them. Each regex + # scopes its patch to the models that actually support that + # mechanism, so other OpenAI models run unpatched instead of + # erroring on an unsupported parameter: + # - Chat Completions native search exists only on the chat-only + # *-search-preview models (`web_search_options`). + # - The Responses API serves search through its native + # `web_search` tool, which only some model families support. export COYOTE_PATCH_OPENAI_CHAT_COMPLETIONS='{"gpt-4o.*search-preview.*":{"body":{"web_search_options":{}}}}' + export COYOTE_PATCH_OPENAI_RESPONSES='{"(gpt-4o|gpt-4\\.1|gpt-5|o3|o4).*":{"body":{"tools":[{"type":"web_search"}]}}}' ;; esac } +# Maps an openai model to the one used for the chat-completions attempt: +# Chat Completions web search is served only by the *-search-preview models. +openai_chat_search_model() { + case "${1#openai:}" in + gpt-4o*search-preview*) echo "$1" ;; + gpt-4o-mini*) echo "openai:gpt-4o-mini-search-preview" ;; + *) echo "openai:gpt-4o-search-preview" ;; + esac +} + +# Maps an openai model to the one used for the Responses attempt: the +# *-search-preview models are chat-only and do not exist on /v1/responses, +# so they map back to their base models. +openai_responses_search_model() { + case "${1#openai:}" in + gpt-4o-mini-search-preview*) echo "openai:gpt-4o-mini" ;; + gpt-4o*search-preview*) echo "openai:gpt-4o" ;; + *) echo "$1" ;; + esac +} + +# OpenAI attempt chain: try the chat-completions mechanism first (works when +# the client's wire is chat); if that fails — e.g. the client resolved the +# responses wire, where the search-preview models do not exist — retry with +# the Responses mechanism on a responses-capable model. Fails when both +# attempts fail. +# shellcheck disable=SC2154 +run_openai_search() { + if coyote -m "$(openai_chat_search_model "$1")" "$argc_query" >>"$LLM_OUTPUT"; then + return 0 + fi + echo "web_search: openai chat-completions attempt failed; retrying via the Responses API" >&2 + coyote -m "$(openai_responses_search_model "$1")" "$argc_query" >>"$LLM_OUTPUT" +} + # shellcheck disable=SC2154 run_search() { export_search_patch "$1" - coyote -m "$1" "$argc_query" >>"$LLM_OUTPUT" + case "$1" in + openai:*) + run_openai_search "$1" + ;; + *) + coyote -m "$1" "$argc_query" >>"$LLM_OUTPUT" + ;; + esac } main() { diff --git a/scripts/test-web-search-tool.sh b/scripts/test-web-search-tool.sh new file mode 100755 index 0000000..5f9ca57 --- /dev/null +++ b/scripts/test-web-search-tool.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# Regression harness for assets/functions/tools/web_search_coyote.sh. +# +# Runs the tool against a fake `coyote` executable on PATH that records every +# invocation (model + the search-patch env vars it received) and returns +# scripted per-call exit codes, pinning: +# A) openai attempt chain order: the chat-completions mechanism (search-preview +# model) runs first, the Responses mechanism (responses-capable model) +# runs second, and the run fails only when both attempts fail; +# B) both openai patch env vars are exported with the expected values so the +# client's resolved wire (chat vs responses) picks the matching one; +# C) model mapping between the two attempts (search-preview <-> base models); +# D) non-openai providers are untouched: single invocation, their own patch, +# openai patch keys cleared; +# E) the WEB_SEARCH_MODEL fallback and WEB_SEARCH_USE_CURRENT_MODEL +# semantics are unchanged. +# +# No network or credentials required. Usage: scripts/test-web-search-tool.sh + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TOOL="$REPO_ROOT/assets/functions/tools/web_search_coyote.sh" + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT +export STUB_DIR="$WORK_DIR" + +# Expected patch values (must stay in sync with the tool script). +EXPECTED_OPENAI_CHAT_PATCH='{"gpt-4o.*search-preview.*":{"body":{"web_search_options":{}}}}' +EXPECTED_OPENAI_RESPONSES_PATCH='{"(gpt-4o|gpt-4\\.1|gpt-5|o3|o4).*":{"body":{"tools":[{"type":"web_search"}]}}}' +EXPECTED_CLAUDE_PATCH='{".*":{"body":{"tools":[{"type":"web_search_20250305","name":"web_search","max_uses":5}]}}}' + +# Fake coyote: logs "call=N model=M chat=... responses=... own=..." per +# invocation and exits with the N-th code from COYOTE_STUB_RESULTS. +cat >"$WORK_DIR/coyote" <<'STUB' +#!/usr/bin/env bash +n=$(( $(cat "$STUB_DIR/count" 2>/dev/null || echo 0) + 1 )) +echo "$n" >"$STUB_DIR/count" +model="" +[[ "${1:-}" == "-m" ]] && model="${2:-}" +{ + printf 'call=%s model=%s\n' "$n" "$model" + printf 'call=%s chat_patch=%s\n' "$n" "${COYOTE_PATCH_OPENAI_CHAT_COMPLETIONS-}" + printf 'call=%s responses_patch=%s\n' "$n" "${COYOTE_PATCH_OPENAI_RESPONSES-}" + printf 'call=%s gemini_patch=%s\n' "$n" "${COYOTE_PATCH_GEMINI_CHAT_COMPLETIONS-}" + printf 'call=%s claude_patch=%s\n' "$n" "${COYOTE_PATCH_CLAUDE_CHAT_COMPLETIONS-}" +} >>"$STUB_DIR/log" +read -r -a results <<<"${COYOTE_STUB_RESULTS:-}" +rc="${results[$((n - 1))]:-0}" +[[ "$rc" == "0" ]] && echo "stub-output-$n" +exit "$rc" +STUB +chmod +x "$WORK_DIR/coyote" + +FAILED=0 +CASE="" + +fail() { + echo "FAIL [$CASE]: $*" >&2 + echo "--- invocation log ---" >&2 + cat "$STUB_DIR/log" 2>/dev/null >&2 || true + echo "----------------------" >&2 + FAILED=1 +} + +# run_tool [VAR=value ...] +# Sources the tool with the argc runtime emulated (argc_query/LLM_OUTPUT set) +# and runs main(). Sets TOOL_RC to the tool's exit code. +run_tool() { + local results="$1" + shift + rm -f "$STUB_DIR/log" "$STUB_DIR/count" + : >"$STUB_DIR/out" + TOOL_RC=0 + ( + export PATH="$STUB_DIR:$PATH" + export COYOTE_STUB_RESULTS="$results" + export argc_query="test query" + export LLM_OUTPUT="$STUB_DIR/out" + unset WEB_SEARCH_MODEL COYOTE_CURRENT_MODEL WEB_SEARCH_USE_CURRENT_MODEL + local kv + for kv in "$@"; do + # shellcheck disable=SC2163 + export "${kv?}" + done + # shellcheck disable=SC1090 + source "$TOOL" + main + ) || TOOL_RC=$? +} + +assert_calls() { + local expected="$1" actual + actual="$(cat "$STUB_DIR/count" 2>/dev/null || echo 0)" + [[ "$actual" == "$expected" ]] || fail "expected $expected coyote invocation(s), got $actual" +} + +assert_log() { + local line="$1" + grep -qF -- "$line" "$STUB_DIR/log" || fail "missing log line: $line" +} + +assert_rc() { + local expected="$1" + if [[ "$expected" == "0" ]]; then + [[ "$TOOL_RC" -eq 0 ]] || fail "expected success, got exit code $TOOL_RC" + else + [[ "$TOOL_RC" -ne 0 ]] || fail "expected failure, tool exited 0" + fi +} + +# --- A/B: stock success — chat mechanism runs first with both patches set --- +CASE="openai current model, first attempt succeeds" +run_tool "0" COYOTE_CURRENT_MODEL=openai:gpt-4o +assert_rc 0 +assert_calls 1 +assert_log "call=1 model=openai:gpt-4o-search-preview" +assert_log "call=1 chat_patch=$EXPECTED_OPENAI_CHAT_PATCH" +assert_log "call=1 responses_patch=$EXPECTED_OPENAI_RESPONSES_PATCH" + +CASE="search-preview current model kept as-is on the chat attempt (pinned wire_api: chat behavior)" +run_tool "0" COYOTE_CURRENT_MODEL=openai:gpt-4o-search-preview +assert_rc 0 +assert_calls 1 +assert_log "call=1 model=openai:gpt-4o-search-preview" +assert_log "call=1 chat_patch=$EXPECTED_OPENAI_CHAT_PATCH" + +# --- A/C: chat attempt fails -> Responses attempt on a responses-capable model --- +CASE="openai chain: chat fails, Responses succeeds (current model responses-capable)" +run_tool "1 0" COYOTE_CURRENT_MODEL=openai:gpt-4o +assert_rc 0 +assert_calls 2 +assert_log "call=1 model=openai:gpt-4o-search-preview" +assert_log "call=2 model=openai:gpt-4o" +assert_log "call=2 responses_patch=$EXPECTED_OPENAI_RESPONSES_PATCH" + +CASE="openai chain: search-preview model maps to its base model on the Responses attempt" +run_tool "1 0" COYOTE_CURRENT_MODEL=openai:gpt-4o-search-preview +assert_rc 0 +assert_calls 2 +assert_log "call=1 model=openai:gpt-4o-search-preview" +assert_log "call=2 model=openai:gpt-4o" + +CASE="openai chain: mini variants map both ways" +run_tool "1 0" COYOTE_CURRENT_MODEL=openai:gpt-4o-mini-search-preview +assert_rc 0 +assert_calls 2 +assert_log "call=1 model=openai:gpt-4o-mini-search-preview" +assert_log "call=2 model=openai:gpt-4o-mini" + +CASE="openai chain: gpt-5 current model runs the Responses attempt on itself" +run_tool "1 0" COYOTE_CURRENT_MODEL=openai:gpt-5 +assert_rc 0 +assert_calls 2 +assert_log "call=1 model=openai:gpt-4o-search-preview" +assert_log "call=2 model=openai:gpt-5" + +# --- A/E: both openai attempts fail -> existing WEB_SEARCH_MODEL fallback --- +CASE="openai chain exhausted falls back to WEB_SEARCH_MODEL with openai patches cleared" +run_tool "1 1 0" COYOTE_CURRENT_MODEL=openai:gpt-4o +assert_rc 0 +assert_calls 3 +assert_log "call=3 model=gemini:gemini-2.5-flash" +assert_log "call=3 chat_patch=" +assert_log "call=3 responses_patch=" +assert_log 'call=3 gemini_patch={".*":{"body":{"tools":[{"google_search":{}}]}}}' + +# --- A: hard failure when both attempts of the last resort fail --- +CASE="openai fallback model: both attempts fail -> tool fails" +run_tool "1 1" WEB_SEARCH_MODEL=openai:gpt-4o +assert_rc 1 +assert_calls 2 +assert_log "call=1 model=openai:gpt-4o-search-preview" +assert_log "call=2 model=openai:gpt-4o" + +# --- D: non-openai providers untouched --- +CASE="claude current model: single invocation, own patch, openai keys cleared" +run_tool "0" COYOTE_CURRENT_MODEL=claude:claude-sonnet-4-20250514 \ + COYOTE_PATCH_OPENAI_CHAT_COMPLETIONS=stale COYOTE_PATCH_OPENAI_RESPONSES=stale +assert_rc 0 +assert_calls 1 +assert_log "call=1 model=claude:claude-sonnet-4-20250514" +assert_log "call=1 claude_patch=$EXPECTED_CLAUDE_PATCH" +assert_log "call=1 chat_patch=" +assert_log "call=1 responses_patch=" + +CASE="default fallback (gemini): single invocation" +run_tool "0" +assert_rc 0 +assert_calls 1 +assert_log "call=1 model=gemini:gemini-2.5-flash" +assert_log 'call=1 gemini_patch={".*":{"body":{"tools":[{"google_search":{}}]}}}' + +# --- E: unsupported / opted-out current models skip straight to fallback --- +CASE="unsupported openai model (o1) goes straight to fallback" +run_tool "0" COYOTE_CURRENT_MODEL=openai:o1-mini +assert_rc 0 +assert_calls 1 +assert_log "call=1 model=gemini:gemini-2.5-flash" + +CASE="WEB_SEARCH_USE_CURRENT_MODEL=false ignores the current model" +run_tool "0" COYOTE_CURRENT_MODEL=openai:gpt-4o WEB_SEARCH_USE_CURRENT_MODEL=false +assert_rc 0 +assert_calls 1 +assert_log "call=1 model=gemini:gemini-2.5-flash" + +# --- E: current-model failure still falls back (chain counts as one failure) --- +CASE="openai current chain exhausted, non-openai fallback succeeds, output appended" +run_tool "1 1 0" COYOTE_CURRENT_MODEL=openai:gpt-4.1 WEB_SEARCH_MODEL=gemini:gemini-2.5-pro +assert_rc 0 +assert_calls 3 +assert_log "call=1 model=openai:gpt-4o-search-preview" +assert_log "call=2 model=openai:gpt-4.1" +assert_log "call=3 model=gemini:gemini-2.5-pro" +grep -qF "stub-output-3" "$STUB_DIR/out" || fail "fallback output missing from LLM_OUTPUT" + +if [[ "$FAILED" -ne 0 ]]; then + echo "web_search_coyote harness: FAILED" >&2 + exit 1 +fi +echo "web_search_coyote harness: all cases passed" From 23fe2c12310c066b89295240b100001c37a1c8db Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 15:14:49 -0600 Subject: [PATCH 14/16] chore(TASK-057): drop the web-search stub harness The harness served as one-time review evidence for the tool's attempt chain; nothing in CI executes bash harnesses and the tool script rarely changes, so it is removed rather than left to rot (user decision). --- scripts/test-web-search-tool.sh | 222 -------------------------------- 1 file changed, 222 deletions(-) delete mode 100755 scripts/test-web-search-tool.sh diff --git a/scripts/test-web-search-tool.sh b/scripts/test-web-search-tool.sh deleted file mode 100755 index 5f9ca57..0000000 --- a/scripts/test-web-search-tool.sh +++ /dev/null @@ -1,222 +0,0 @@ -#!/usr/bin/env bash -# Regression harness for assets/functions/tools/web_search_coyote.sh. -# -# Runs the tool against a fake `coyote` executable on PATH that records every -# invocation (model + the search-patch env vars it received) and returns -# scripted per-call exit codes, pinning: -# A) openai attempt chain order: the chat-completions mechanism (search-preview -# model) runs first, the Responses mechanism (responses-capable model) -# runs second, and the run fails only when both attempts fail; -# B) both openai patch env vars are exported with the expected values so the -# client's resolved wire (chat vs responses) picks the matching one; -# C) model mapping between the two attempts (search-preview <-> base models); -# D) non-openai providers are untouched: single invocation, their own patch, -# openai patch keys cleared; -# E) the WEB_SEARCH_MODEL fallback and WEB_SEARCH_USE_CURRENT_MODEL -# semantics are unchanged. -# -# No network or credentials required. Usage: scripts/test-web-search-tool.sh - -set -euo pipefail - -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -TOOL="$REPO_ROOT/assets/functions/tools/web_search_coyote.sh" - -WORK_DIR="$(mktemp -d)" -trap 'rm -rf "$WORK_DIR"' EXIT -export STUB_DIR="$WORK_DIR" - -# Expected patch values (must stay in sync with the tool script). -EXPECTED_OPENAI_CHAT_PATCH='{"gpt-4o.*search-preview.*":{"body":{"web_search_options":{}}}}' -EXPECTED_OPENAI_RESPONSES_PATCH='{"(gpt-4o|gpt-4\\.1|gpt-5|o3|o4).*":{"body":{"tools":[{"type":"web_search"}]}}}' -EXPECTED_CLAUDE_PATCH='{".*":{"body":{"tools":[{"type":"web_search_20250305","name":"web_search","max_uses":5}]}}}' - -# Fake coyote: logs "call=N model=M chat=... responses=... own=..." per -# invocation and exits with the N-th code from COYOTE_STUB_RESULTS. -cat >"$WORK_DIR/coyote" <<'STUB' -#!/usr/bin/env bash -n=$(( $(cat "$STUB_DIR/count" 2>/dev/null || echo 0) + 1 )) -echo "$n" >"$STUB_DIR/count" -model="" -[[ "${1:-}" == "-m" ]] && model="${2:-}" -{ - printf 'call=%s model=%s\n' "$n" "$model" - printf 'call=%s chat_patch=%s\n' "$n" "${COYOTE_PATCH_OPENAI_CHAT_COMPLETIONS-}" - printf 'call=%s responses_patch=%s\n' "$n" "${COYOTE_PATCH_OPENAI_RESPONSES-}" - printf 'call=%s gemini_patch=%s\n' "$n" "${COYOTE_PATCH_GEMINI_CHAT_COMPLETIONS-}" - printf 'call=%s claude_patch=%s\n' "$n" "${COYOTE_PATCH_CLAUDE_CHAT_COMPLETIONS-}" -} >>"$STUB_DIR/log" -read -r -a results <<<"${COYOTE_STUB_RESULTS:-}" -rc="${results[$((n - 1))]:-0}" -[[ "$rc" == "0" ]] && echo "stub-output-$n" -exit "$rc" -STUB -chmod +x "$WORK_DIR/coyote" - -FAILED=0 -CASE="" - -fail() { - echo "FAIL [$CASE]: $*" >&2 - echo "--- invocation log ---" >&2 - cat "$STUB_DIR/log" 2>/dev/null >&2 || true - echo "----------------------" >&2 - FAILED=1 -} - -# run_tool [VAR=value ...] -# Sources the tool with the argc runtime emulated (argc_query/LLM_OUTPUT set) -# and runs main(). Sets TOOL_RC to the tool's exit code. -run_tool() { - local results="$1" - shift - rm -f "$STUB_DIR/log" "$STUB_DIR/count" - : >"$STUB_DIR/out" - TOOL_RC=0 - ( - export PATH="$STUB_DIR:$PATH" - export COYOTE_STUB_RESULTS="$results" - export argc_query="test query" - export LLM_OUTPUT="$STUB_DIR/out" - unset WEB_SEARCH_MODEL COYOTE_CURRENT_MODEL WEB_SEARCH_USE_CURRENT_MODEL - local kv - for kv in "$@"; do - # shellcheck disable=SC2163 - export "${kv?}" - done - # shellcheck disable=SC1090 - source "$TOOL" - main - ) || TOOL_RC=$? -} - -assert_calls() { - local expected="$1" actual - actual="$(cat "$STUB_DIR/count" 2>/dev/null || echo 0)" - [[ "$actual" == "$expected" ]] || fail "expected $expected coyote invocation(s), got $actual" -} - -assert_log() { - local line="$1" - grep -qF -- "$line" "$STUB_DIR/log" || fail "missing log line: $line" -} - -assert_rc() { - local expected="$1" - if [[ "$expected" == "0" ]]; then - [[ "$TOOL_RC" -eq 0 ]] || fail "expected success, got exit code $TOOL_RC" - else - [[ "$TOOL_RC" -ne 0 ]] || fail "expected failure, tool exited 0" - fi -} - -# --- A/B: stock success — chat mechanism runs first with both patches set --- -CASE="openai current model, first attempt succeeds" -run_tool "0" COYOTE_CURRENT_MODEL=openai:gpt-4o -assert_rc 0 -assert_calls 1 -assert_log "call=1 model=openai:gpt-4o-search-preview" -assert_log "call=1 chat_patch=$EXPECTED_OPENAI_CHAT_PATCH" -assert_log "call=1 responses_patch=$EXPECTED_OPENAI_RESPONSES_PATCH" - -CASE="search-preview current model kept as-is on the chat attempt (pinned wire_api: chat behavior)" -run_tool "0" COYOTE_CURRENT_MODEL=openai:gpt-4o-search-preview -assert_rc 0 -assert_calls 1 -assert_log "call=1 model=openai:gpt-4o-search-preview" -assert_log "call=1 chat_patch=$EXPECTED_OPENAI_CHAT_PATCH" - -# --- A/C: chat attempt fails -> Responses attempt on a responses-capable model --- -CASE="openai chain: chat fails, Responses succeeds (current model responses-capable)" -run_tool "1 0" COYOTE_CURRENT_MODEL=openai:gpt-4o -assert_rc 0 -assert_calls 2 -assert_log "call=1 model=openai:gpt-4o-search-preview" -assert_log "call=2 model=openai:gpt-4o" -assert_log "call=2 responses_patch=$EXPECTED_OPENAI_RESPONSES_PATCH" - -CASE="openai chain: search-preview model maps to its base model on the Responses attempt" -run_tool "1 0" COYOTE_CURRENT_MODEL=openai:gpt-4o-search-preview -assert_rc 0 -assert_calls 2 -assert_log "call=1 model=openai:gpt-4o-search-preview" -assert_log "call=2 model=openai:gpt-4o" - -CASE="openai chain: mini variants map both ways" -run_tool "1 0" COYOTE_CURRENT_MODEL=openai:gpt-4o-mini-search-preview -assert_rc 0 -assert_calls 2 -assert_log "call=1 model=openai:gpt-4o-mini-search-preview" -assert_log "call=2 model=openai:gpt-4o-mini" - -CASE="openai chain: gpt-5 current model runs the Responses attempt on itself" -run_tool "1 0" COYOTE_CURRENT_MODEL=openai:gpt-5 -assert_rc 0 -assert_calls 2 -assert_log "call=1 model=openai:gpt-4o-search-preview" -assert_log "call=2 model=openai:gpt-5" - -# --- A/E: both openai attempts fail -> existing WEB_SEARCH_MODEL fallback --- -CASE="openai chain exhausted falls back to WEB_SEARCH_MODEL with openai patches cleared" -run_tool "1 1 0" COYOTE_CURRENT_MODEL=openai:gpt-4o -assert_rc 0 -assert_calls 3 -assert_log "call=3 model=gemini:gemini-2.5-flash" -assert_log "call=3 chat_patch=" -assert_log "call=3 responses_patch=" -assert_log 'call=3 gemini_patch={".*":{"body":{"tools":[{"google_search":{}}]}}}' - -# --- A: hard failure when both attempts of the last resort fail --- -CASE="openai fallback model: both attempts fail -> tool fails" -run_tool "1 1" WEB_SEARCH_MODEL=openai:gpt-4o -assert_rc 1 -assert_calls 2 -assert_log "call=1 model=openai:gpt-4o-search-preview" -assert_log "call=2 model=openai:gpt-4o" - -# --- D: non-openai providers untouched --- -CASE="claude current model: single invocation, own patch, openai keys cleared" -run_tool "0" COYOTE_CURRENT_MODEL=claude:claude-sonnet-4-20250514 \ - COYOTE_PATCH_OPENAI_CHAT_COMPLETIONS=stale COYOTE_PATCH_OPENAI_RESPONSES=stale -assert_rc 0 -assert_calls 1 -assert_log "call=1 model=claude:claude-sonnet-4-20250514" -assert_log "call=1 claude_patch=$EXPECTED_CLAUDE_PATCH" -assert_log "call=1 chat_patch=" -assert_log "call=1 responses_patch=" - -CASE="default fallback (gemini): single invocation" -run_tool "0" -assert_rc 0 -assert_calls 1 -assert_log "call=1 model=gemini:gemini-2.5-flash" -assert_log 'call=1 gemini_patch={".*":{"body":{"tools":[{"google_search":{}}]}}}' - -# --- E: unsupported / opted-out current models skip straight to fallback --- -CASE="unsupported openai model (o1) goes straight to fallback" -run_tool "0" COYOTE_CURRENT_MODEL=openai:o1-mini -assert_rc 0 -assert_calls 1 -assert_log "call=1 model=gemini:gemini-2.5-flash" - -CASE="WEB_SEARCH_USE_CURRENT_MODEL=false ignores the current model" -run_tool "0" COYOTE_CURRENT_MODEL=openai:gpt-4o WEB_SEARCH_USE_CURRENT_MODEL=false -assert_rc 0 -assert_calls 1 -assert_log "call=1 model=gemini:gemini-2.5-flash" - -# --- E: current-model failure still falls back (chain counts as one failure) --- -CASE="openai current chain exhausted, non-openai fallback succeeds, output appended" -run_tool "1 1 0" COYOTE_CURRENT_MODEL=openai:gpt-4.1 WEB_SEARCH_MODEL=gemini:gemini-2.5-pro -assert_rc 0 -assert_calls 3 -assert_log "call=1 model=openai:gpt-4o-search-preview" -assert_log "call=2 model=openai:gpt-4.1" -assert_log "call=3 model=gemini:gemini-2.5-pro" -grep -qF "stub-output-3" "$STUB_DIR/out" || fail "fallback output missing from LLM_OUTPUT" - -if [[ "$FAILED" -ne 0 ]]; then - echo "web_search_coyote harness: FAILED" >&2 - exit 1 -fi -echo "web_search_coyote harness: all cases passed" From 3dc84f5aefa4ec75cfba810ef73bbc1521f29fcc Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 15:33:35 -0600 Subject: [PATCH 15/16] fix: revert manual changelog update, as it should only be updated by CI/CD --- CHANGELOG.md | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 255ddb9..ad3bd9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,26 +1,3 @@ -## Unreleased - -### BREAKING CHANGE - -- The stock `openai` client (no custom `api_base`) now defaults to the OpenAI Responses API -(`/v1/responses`) instead of Chat Completions. Pin the old wire by adding `wire_api: chat` to the -client block. Openai clients with a custom `api_base` and `openai-compatible` clients still -default to `chat`; Codex OAuth always uses `responses` (`wire_api: chat` there is a config error). -- Request patches are now wire-aware and chat-keyed patches never apply to the responses wire: if -your stock openai client relied on `patch.chat_completions` blocks (or top-level model patches), -migrate them to the sibling `patch.responses` key / the model patch's `responses:` sub-key -(env override: `COYOTE_PATCH__RESPONSES`). -- Sessions saved after this change may contain the new `reasoning` thinking blocks (encrypted -reasoning items from the Responses API); older coyote binaries cannot load those sessions. - -### Feat - -- the bundled `web_search_coyote` tool serves openai native web search on both wires: it tries the chat-completions mechanism (the chat-only `gpt-4o-*search-preview` models + `web_search_options`) first and falls back to the Responses-native `web_search` tool on responses-capable models (`gpt-4o*`/`gpt-4.1*`/`gpt-5*`/`o3*`/`o4*`), failing only when both attempts fail — the stock responses default needs no `wire_api: chat` pin -- support `wire_api: responses | chat` on the openai and openai-compatible clients, with wire-aware request patching on both patch layers -- report token usage (including cached input tokens) on the openai responses path -- streaming responses that end with `response.incomplete` now surface the truncation reason (e.g. `max_output_tokens`) when no visible output (text or tool calls) was delivered — matching the non-streaming behavior — and record token usage for truncated turns instead of ending silently -- the responses wire always sends `include: ["reasoning.encrypted_content"]` and `"strict": false` tool definitions; strict third-party `/v1/responses` servers may reject these as unknown params when opted in via `wire_api: responses` (workaround: a `responses`-keyed client patch, e.g. `include: null` for Groq) - ## v0.10.3 (2026-09-15) ### Feat From 6804f198bc23bbce8e2b4e735ca9e5b766dcb057 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:50:55 +0000 Subject: [PATCH 16/16] chore: refresh generated model catalog [skip ci] --- models.yaml | 51 +++++++++++++++++++++++++++++---------------------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/models.yaml b/models.yaml index b2e1673..9ce4a19 100644 --- a/models.yaml +++ b/models.yaml @@ -2264,6 +2264,14 @@ - high - xhigh - max + - name: zai-org/glm-5.2-maas + type: chat + max_input_tokens: 1000000 + input_price: 1.4 + output_price: 4.4 + cache_read_price: 0.14 + max_output_tokens: 64000 + supports_function_calling: true - name: claude-fable-5@default type: chat max_input_tokens: 1000000 @@ -6977,9 +6985,9 @@ - name: deepseek/deepseek-v4-flash type: chat max_input_tokens: 1048576 - input_price: 0.084 - output_price: 0.168 - cache_read_price: 0.0168 + input_price: 0.0886 + output_price: 0.1772 + cache_read_price: 0.0177 max_output_tokens: 384000 supports_function_calling: true - name: deepseek/deepseek-v4-flash-0731 @@ -7027,10 +7035,10 @@ - name: deepseek/deepseek-v4-pro-0813 type: chat max_input_tokens: 1048576 - input_price: 0.5795 - output_price: 1.7384 - cache_read_price: 0.0184 - max_output_tokens: 393216 + input_price: 0.66 + output_price: 1.98 + cache_read_price: 0.022 + max_output_tokens: 384000 supports_function_calling: true - name: deepseek/deepseek-v4-pro-0813:batch type: chat @@ -8116,9 +8124,8 @@ - name: nvidia/nemotron-3-nano-30b-a3b type: chat max_input_tokens: 262144 - input_price: 0.05 - output_price: 0.2 - cache_read_price: 0.03 + input_price: 0.06 + output_price: 0.24 max_output_tokens: 235929 supports_function_calling: true - name: nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free @@ -9797,9 +9804,9 @@ - name: tencent/hy3 type: chat max_input_tokens: 262144 - input_price: 0.0825 - output_price: 0.33 - cache_read_price: 0.0206 + input_price: 0.132 + output_price: 0.528 + cache_read_price: 0.033 max_output_tokens: 128000 supports_function_calling: true - name: tencent/hy3-preview @@ -10180,16 +10187,16 @@ input_price: 0.15 output_price: 0.6 cache_read_price: 0.015 - max_output_tokens: 393216 + max_output_tokens: 943718 supports_vision: true supports_function_calling: true - name: ~deepseek/deepseek-pro-latest type: chat max_input_tokens: 1048576 - input_price: 0.5795 - output_price: 1.7384 - cache_read_price: 0.0184 - max_output_tokens: 393216 + input_price: 0.66 + output_price: 1.98 + cache_read_price: 0.022 + max_output_tokens: 384000 supports_function_calling: true - name: ~deepseek/deepseek-v4-flash-latest type: chat @@ -10298,10 +10305,10 @@ - name: ~z-ai/glm-latest type: chat max_input_tokens: 1310720 - input_price: 0.8775 - output_price: 2.97 - cache_read_price: 0.1755 - max_output_tokens: 235929 + input_price: 0.833 + output_price: 2.618 + cache_read_price: 0.1369 + max_output_tokens: 943718 supports_function_calling: true - provider: github models: