diff --git a/assets/config-template.yaml b/assets/config-template.yaml index d4b16c9..d66c694 100644 --- a/assets/config-template.yaml +++ b/assets/config-template.yaml @@ -302,4 +302,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__ 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/models.yaml b/models.yaml index a002557..9ce4a19 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 @@ -2234,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 @@ -6997,9 +7035,9 @@ - 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 + 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 @@ -8022,9 +8060,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 @@ -8086,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 @@ -9405,9 +9442,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 +9753,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 @@ -10037,7 +10082,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 @@ -10184,9 +10229,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 @@ -10260,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: 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 diff --git a/src/client/bedrock.rs b/src/client/bedrock.rs index 1a94f5d..a8da844 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( @@ -427,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()) @@ -496,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()) @@ -661,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, } } @@ -1185,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..db31271 100644 --- a/src/client/claude.rs +++ b/src/client/claude.rs @@ -407,7 +407,16 @@ 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)); + // 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)); + } } let round_text = if index == 0 && !text.is_empty() { Some(text.as_str()) @@ -475,7 +484,16 @@ pub fn claude_build_chat_completions_body( chunk_ids.insert(id); } for block in &tool_result.thinking { - assistant_parts.push(json!(block)); + // 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)); + } } let round_text = if index == 0 && !text.is_empty() { Some(text.as_str()) @@ -875,6 +893,48 @@ mod tests { assert_eq!(messages.len(), 1, "body: {body}"); } + #[test] + fn replays_only_anthropic_thinking_blocks() { + let mixed = ToolResult { + call: ToolCall::new( + "fs_read".into(), + json!({"path": "x"}), + Some("toolu_A".into()), + ), + output: json!("ok"), + text: None, + 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![mixed.clone()], "", sequence, false); + + let content = body["messages"][1]["content"].as_array().unwrap(); + let types: Vec<_> = content + .iter() + .map(|block| block["type"].as_str().unwrap()) + .collect(); + assert_eq!( + types, + ["thinking", "redacted_thinking", "tool_use"], + "body: {body}" + ); + } + } + #[test] fn prompt_cache_places_breakpoints() { let functions = vec![function_declaration("a"), function_declaration("b")]; diff --git a/src/client/common.rs b/src/client/common.rs index 8f9c63f..3ab7585 100644 --- a/src/client/common.rs +++ b/src/client/common.rs @@ -222,23 +222,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, @@ -272,16 +288,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 { @@ -293,9 +319,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, @@ -313,7 +345,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); @@ -2010,4 +2044,263 @@ mod tests { Some("500") ); } + + /// 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 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( + "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()); + } + + /// 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. + 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/message.rs b/src/client/message.rs index ddad394..46e6b7d 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,87 @@ 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.clone()).unwrap(); + + assert!( + matches!(&blocks[0], ThinkingBlock::Thinking { thinking, signature } if thinking == "hmm" && signature == "sig123") + ); + assert!( + matches!(&blocks[1], ThinkingBlock::RedactedThinking { data } if data == "b64data") + ); + assert_eq!(serde_json::to_value(&blocks).unwrap(), json); + } + + #[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); + } + + #[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); + } +} 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..ffc0933 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,31 @@ 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; 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, +) -> 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) if stock_openai_without_api_base => Ok(WireApi::Responses), + (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 +137,33 @@ 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, + stock_openai_without_api_base, + )?; 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 +200,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( @@ -659,6 +692,28 @@ 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 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 { items.push(json!({ "role": MessageRole::Assistant, @@ -691,6 +746,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() { @@ -714,6 +770,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(); @@ -740,6 +797,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 { @@ -771,18 +829,56 @@ pub fn openai_extract_responses(data: &Value) -> Result { )); } } + Some("reasoning") => { + if let Some(block) = reasoning_thinking_block(item) { + thinking.push(block); + } + } _ => {} } } } 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, - ..Default::default() + thinking, + usage: openai_parse_responses_usage(&data["usage"]), + }) +} + +/// 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()), + }) +} + +/// 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(), }) } @@ -796,48 +892,94 @@ 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") => { + 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("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() { + debug!( + "response truncated ({}); keeping partial output", + data["response"]["incomplete_details"]["reason"] + ); + 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}"), + } + } + Some("error") => match data["message"].as_str() { + Some(message) => bail!("Stream error: {message}"), + None => bail!("Stream error: {data}"), + }, + _ => {} + } + 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; @@ -881,6 +1023,452 @@ 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 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!({ + "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 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(); + 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") + )); + } + + #[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}"); + } + + #[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}" + ); + } + + #[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()), @@ -909,7 +1497,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 +1511,7 @@ mod tests { top_p: None, reasoning_effort: None, functions: None, - stream: false, + stream, }; tokio::runtime::Builder::new_current_thread() .enable_all() @@ -1006,9 +1598,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,15 +1616,36 @@ 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" ); } + /// 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"; @@ -1243,4 +1856,254 @@ 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 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(); + 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_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 { + OpenAIConfig { + name: Some(name.into()), + api_key: Some("sk-test".into()), + wire_api, + ..Default::default() + } + } + + #[test] + 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, + "https://gateway.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 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" + ); + } +} diff --git a/src/client/stream.rs b/src/client/stream.rs index 2d9d3ab..9f2acb9 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() } @@ -544,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. diff --git a/src/function/mod.rs b/src/function/mod.rs index f8a1785..cfdb650 100644 --- a/src/function/mod.rs +++ b/src/function/mod.rs @@ -2576,13 +2576,16 @@ 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] = [ +// 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", "agent__check", "agent__list_running", + "agent__task_list", + "agent__check_inbox", ]; fn is_loop_tracker_exempt(name: &str) -> bool { @@ -3386,10 +3389,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); } @@ -3426,6 +3431,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();