Merge pull request #30 from Dark-Alex-17/feat/PLAN-responses-api

PLAN-responses-api: OpenAI Responses API as the default wire format
This commit is contained in:
Alex Clarke authored and GitHub committed 2026-09-16 15:51:37 -06:00
commit f59421844a
13 files changed
+1798 -98

No files matched your search

+24
View File
@@ -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: # The claude, bedrock, and vertexai clients also support:
# prompt_cache: true # Enable Anthropic prompt caching (default: true) # 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
# <regex> -> url/body/headers shape as chat_completions):
# patch:
# responses:
# <regex>:
# body:
# <json>
# 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:
# <json>
# Env override per wire: COYOTE_PATCH_<client>_RESPONSES / COYOTE_PATCH_<client>_CHAT_COMPLETIONS
__CLIENTS_BLOCK__ __CLIENTS_BLOCK__
+65 -9
View File
@@ -23,10 +23,16 @@ set -e
# - perplexity:* # - perplexity:*
# - ernie:* # - ernie:*
# - claude:* (Anthropic native web_search server tool) # - claude:* (Anthropic native web_search server tool)
# - openai:gpt-4o-search-preview (and -mini-; requires an api-key openai # - openai:gpt-4o*/gpt-4.1*/gpt-5*/o3*/o4*
# client — the codex OAuth path uses the # OpenAI serves web search through two different mechanisms depending on
# Responses API where this parameter # the wire API the client resolves (the script cannot pick the wire):
# does not exist) # 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 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 # @env LLM_OUTPUT=/dev/stdout The output path
@@ -34,7 +40,7 @@ set -e
supports_native_search() { supports_native_search() {
case "$1" in case "$1" in
gemini:* | vertexai:gemini-* | perplexity:* | ernie:* | claude:*) return 0 ;; 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 ;; *) return 1 ;;
esac esac
} }
@@ -46,7 +52,8 @@ export_search_patch() {
COYOTE_PATCH_VERTEXAI_CHAT_COMPLETIONS \ COYOTE_PATCH_VERTEXAI_CHAT_COMPLETIONS \
COYOTE_PATCH_ERNIE_CHAT_COMPLETIONS \ COYOTE_PATCH_ERNIE_CHAT_COMPLETIONS \
COYOTE_PATCH_CLAUDE_CHAT_COMPLETIONS \ COYOTE_PATCH_CLAUDE_CHAT_COMPLETIONS \
COYOTE_PATCH_OPENAI_CHAT_COMPLETIONS COYOTE_PATCH_OPENAI_CHAT_COMPLETIONS \
COYOTE_PATCH_OPENAI_RESPONSES
case "${1%%:*}" in case "${1%%:*}" in
gemini) 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}]}}}' export COYOTE_PATCH_CLAUDE_CHAT_COMPLETIONS='{".*":{"body":{"tools":[{"type":"web_search_20250305","name":"web_search","max_uses":5}]}}}'
;; ;;
openai) openai)
# Chat Completions native search exists only on the search-preview # Both patches are exported; request patching is wire-aware, so the
# models; the regex scopes the patch so other OpenAI models run # client's resolved wire applies exactly one of them. Each regex
# unpatched instead of erroring on an unsupported parameter. # 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_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 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 # shellcheck disable=SC2154
run_search() { run_search() {
export_search_patch "$1" export_search_patch "$1"
case "$1" in
openai:*)
run_openai_search "$1"
;;
*)
coyote -m "$1" "$argc_query" >>"$LLM_OUTPUT" coyote -m "$1" "$argc_query" >>"$LLM_OUTPUT"
;;
esac
} }
main() { main() {
+63 -25
View File
@@ -351,6 +351,10 @@
max_tokens: null max_tokens: null
temperature: null temperature: null
top_p: null top_p: null
responses:
body:
temperature: null
top_p: null
max_output_tokens: 100000 max_output_tokens: 100000
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
@@ -370,6 +374,10 @@
max_tokens: null max_tokens: null
temperature: null temperature: null
top_p: null top_p: null
responses:
body:
temperature: null
top_p: null
max_output_tokens: 100000 max_output_tokens: 100000
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
@@ -429,6 +437,10 @@
max_tokens: null max_tokens: null
temperature: null temperature: null
top_p: null top_p: null
responses:
body:
temperature: null
top_p: null
max_output_tokens: 100000 max_output_tokens: 100000
supports_function_calling: true supports_function_calling: true
reasoning_levels: reasoning_levels:
@@ -540,6 +552,12 @@
max_tokens: null max_tokens: null
temperature: null temperature: null
top_p: null top_p: null
responses:
body:
temperature: null
top_p: null
reasoning:
effort: high
max_output_tokens: 100000 max_output_tokens: 100000
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
@@ -556,6 +574,12 @@
max_tokens: null max_tokens: null
temperature: null temperature: null
top_p: null top_p: null
responses:
body:
temperature: null
top_p: null
reasoning:
effort: high
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
system_prompt_prefix: Formatting re-enabled system_prompt_prefix: Formatting re-enabled
@@ -571,6 +595,12 @@
max_tokens: null max_tokens: null
temperature: null temperature: null
top_p: null top_p: null
responses:
body:
temperature: null
top_p: null
reasoning:
effort: high
max_output_tokens: 100000 max_output_tokens: 100000
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
@@ -6947,9 +6977,9 @@
- name: deepseek/deepseek-v4-flash - name: deepseek/deepseek-v4-flash
type: chat type: chat
max_input_tokens: 1048576 max_input_tokens: 1048576
input_price: 0.0886 input_price: 0.084
output_price: 0.1772 output_price: 0.168
cache_read_price: 0.0177 cache_read_price: 0.0168
max_output_tokens: 384000 max_output_tokens: 384000
supports_function_calling: true supports_function_calling: true
- name: deepseek/deepseek-v4-flash-0731 - name: deepseek/deepseek-v4-flash-0731
@@ -6997,10 +7027,10 @@
- name: deepseek/deepseek-v4-pro-0813 - name: deepseek/deepseek-v4-pro-0813
type: chat type: chat
max_input_tokens: 1048576 max_input_tokens: 1048576
input_price: 0.9834 input_price: 0.5795
output_price: 2.9502 output_price: 1.7384
cache_read_price: 0.0328 cache_read_price: 0.0184
max_output_tokens: 384000 max_output_tokens: 393216
supports_function_calling: true supports_function_calling: true
- name: deepseek/deepseek-v4-pro-0813:batch - name: deepseek/deepseek-v4-pro-0813:batch
type: chat type: chat
@@ -8022,9 +8052,9 @@
- name: moonshotai/kimi-k3 - name: moonshotai/kimi-k3
type: chat type: chat
max_input_tokens: 1048576 max_input_tokens: 1048576
input_price: 2.6481 input_price: 3.0
output_price: 13.2827 output_price: 15.0
cache_read_price: 0.3026 cache_read_price: 0.3
max_output_tokens: 943718 max_output_tokens: 943718
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
@@ -9405,9 +9435,9 @@
- name: qwen/qwen3-vl-30b-a3b-instruct - name: qwen/qwen3-vl-30b-a3b-instruct
type: chat type: chat
max_input_tokens: 262144 max_input_tokens: 262144
input_price: 0.15 input_price: 0.13
output_price: 0.6 output_price: 0.52
max_output_tokens: 16384 max_output_tokens: 32768
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
- name: qwen/qwen3-vl-30b-a3b-thinking - name: qwen/qwen3-vl-30b-a3b-thinking
@@ -9716,6 +9746,14 @@
input_price: 0.65 input_price: 0.65
output_price: 0.75 output_price: 0.75
max_output_tokens: 16384 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 - name: stepfun/step-3.5-flash
type: chat type: chat
max_input_tokens: 262144 max_input_tokens: 262144
@@ -9759,9 +9797,9 @@
- name: tencent/hy3 - name: tencent/hy3
type: chat type: chat
max_input_tokens: 262144 max_input_tokens: 262144
input_price: 0.132 input_price: 0.0825
output_price: 0.528 output_price: 0.33
cache_read_price: 0.033 cache_read_price: 0.0206
max_output_tokens: 128000 max_output_tokens: 128000
supports_function_calling: true supports_function_calling: true
- name: tencent/hy3-preview - name: tencent/hy3-preview
@@ -10037,7 +10075,7 @@
input_price: 1.4 input_price: 1.4
output_price: 4.4 output_price: 4.4
cache_read_price: 0.14 cache_read_price: 0.14
max_output_tokens: 128000 max_output_tokens: 131072
supports_function_calling: true supports_function_calling: true
- name: z-ai/glm-5.2:batch - name: z-ai/glm-5.2:batch
type: chat type: chat
@@ -10142,16 +10180,16 @@
input_price: 0.15 input_price: 0.15
output_price: 0.6 output_price: 0.6
cache_read_price: 0.015 cache_read_price: 0.015
max_output_tokens: 943718 max_output_tokens: 393216
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
- name: ~deepseek/deepseek-pro-latest - name: ~deepseek/deepseek-pro-latest
type: chat type: chat
max_input_tokens: 1048576 max_input_tokens: 1048576
input_price: 0.66 input_price: 0.5795
output_price: 1.98 output_price: 1.7384
cache_read_price: 0.022 cache_read_price: 0.0184
max_output_tokens: 384000 max_output_tokens: 393216
supports_function_calling: true supports_function_calling: true
- name: ~deepseek/deepseek-v4-flash-latest - name: ~deepseek/deepseek-v4-flash-latest
type: chat type: chat
@@ -10184,9 +10222,9 @@
- name: ~moonshotai/kimi-latest - name: ~moonshotai/kimi-latest
type: chat type: chat
max_input_tokens: 1048576 max_input_tokens: 1048576
input_price: 2.1 input_price: 2.0
output_price: 10.95 output_price: 11.2
cache_read_price: 0.23 cache_read_price: 0.232
max_output_tokens: 943718 max_output_tokens: 943718
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
+30
View File
@@ -51,6 +51,10 @@
max_tokens: null max_tokens: null
temperature: null temperature: null
top_p: null top_p: null
responses:
body:
temperature: null
top_p: null
- match: "o3" - match: "o3"
system_prompt_prefix: Formatting re-enabled system_prompt_prefix: Formatting re-enabled
patch: patch:
@@ -58,6 +62,10 @@
max_tokens: null max_tokens: null
temperature: null temperature: null
top_p: null top_p: null
responses:
body:
temperature: null
top_p: null
- match: "o3-mini" - match: "o3-mini"
system_prompt_prefix: Formatting re-enabled system_prompt_prefix: Formatting re-enabled
patch: patch:
@@ -65,6 +73,10 @@
max_tokens: null max_tokens: null
temperature: null temperature: null
top_p: null top_p: null
responses:
body:
temperature: null
top_p: null
models: models:
- name: text-embedding-3-large - name: text-embedding-3-large
type: embedding type: embedding
@@ -94,6 +106,12 @@
max_tokens: null max_tokens: null
temperature: null temperature: null
top_p: null top_p: null
responses:
body:
temperature: null
top_p: null
reasoning:
effort: high
- name: o3-high - name: o3-high
real_name: o3 real_name: o3
max_input_tokens: 200000 max_input_tokens: 200000
@@ -108,6 +126,12 @@
max_tokens: null max_tokens: null
temperature: null temperature: null
top_p: null top_p: null
responses:
body:
temperature: null
top_p: null
reasoning:
effort: high
- name: o3-mini-high - name: o3-mini-high
max_output_tokens: 100000 max_output_tokens: 100000
real_name: o3-mini real_name: o3-mini
@@ -123,6 +147,12 @@
max_tokens: null max_tokens: null
temperature: null temperature: null
top_p: null top_p: null
responses:
body:
temperature: null
top_p: null
reasoning:
effort: high
- name: chat-latest - name: chat-latest
max_input_tokens: 400000 max_input_tokens: 400000
max_output_tokens: 128000 max_output_tokens: 128000
+43 -8
View File
@@ -73,6 +73,7 @@ impl BedrockClient {
url: _, url: _,
headers, headers,
body, body,
..
} = request_data; } = request_data;
let builder = aws_fetch( let builder = aws_fetch(
@@ -126,6 +127,7 @@ impl BedrockClient {
url: _, url: _,
headers, headers,
body, body,
..
} = request_data; } = request_data;
let builder = aws_fetch( let builder = aws_fetch(
@@ -427,7 +429,9 @@ fn build_chat_completions_body(
let mut user_parts = vec![]; let mut user_parts = vec![];
for (index, tool_result) in tool_results.iter().enumerate() { for (index, tool_result) in tool_results.iter().enumerate() {
for block in &tool_result.thinking { 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() { let round_text = if index == 0 && !text.is_empty() {
Some(text.as_str()) Some(text.as_str())
@@ -496,7 +500,9 @@ fn build_chat_completions_body(
chunk_ids.insert(id); chunk_ids.insert(id);
} }
for block in &tool_result.thinking { 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() { let round_text = if index == 0 && !text.is_empty() {
Some(text.as_str()) 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 /// Maps a ThinkingBlock (Anthropic tagged shape) to the Converse
/// reasoningContent union so signed blocks replay verbatim. /// reasoningContent union so signed blocks replay verbatim. OpenAI Responses
fn converse_reasoning_block(block: &ThinkingBlock) -> Value { /// reasoning items have no Converse encoding, so they are dropped when the
/// session moves to Bedrock.
fn converse_reasoning_block(block: &ThinkingBlock) -> Option<Value> {
match block { match block {
ThinkingBlock::Thinking { ThinkingBlock::Thinking {
thinking, thinking,
signature, signature,
} => json!({ } => Some(json!({
"reasoningContent": { "reasoningContent": {
"reasoningText": { "reasoningText": {
"text": thinking, "text": thinking,
"signature": signature, "signature": signature,
} }
} }
}), })),
ThinkingBlock::RedactedThinking { data } => json!({ ThinkingBlock::RedactedThinking { data } => Some(json!({
"reasoningContent": { "redactedContent": data } "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] #[test]
fn extract_chat_completions_captures_reasoning_content() { fn extract_chat_completions_captures_reasoning_content() {
let data = json!({ let data = json!({
+60
View File
@@ -407,8 +407,17 @@ pub fn claude_build_chat_completions_body(
let mut user_parts = vec![]; let mut user_parts = vec![];
for (index, tool_result) in tool_results.iter().enumerate() { for (index, tool_result) in tool_results.iter().enumerate() {
for block in &tool_result.thinking { for block in &tool_result.thinking {
// 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)); assistant_parts.push(json!(block));
} }
}
let round_text = if index == 0 && !text.is_empty() { let round_text = if index == 0 && !text.is_empty() {
Some(text.as_str()) Some(text.as_str())
} else { } else {
@@ -475,8 +484,17 @@ pub fn claude_build_chat_completions_body(
chunk_ids.insert(id); chunk_ids.insert(id);
} }
for block in &tool_result.thinking { for block in &tool_result.thinking {
// 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)); assistant_parts.push(json!(block));
} }
}
let round_text = if index == 0 && !text.is_empty() { let round_text = if index == 0 && !text.is_empty() {
Some(text.as_str()) Some(text.as_str())
} else { } else {
@@ -875,6 +893,48 @@ mod tests {
assert_eq!(messages.len(), 1, "body: {body}"); 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] #[test]
fn prompt_cache_places_breakpoints() { fn prompt_cache_places_breakpoints() {
let functions = vec![function_declaration("a"), function_declaration("b")]; let functions = vec![function_declaration("a"), function_declaration("b")];
+299 -6
View File
@@ -222,23 +222,39 @@ pub trait Client: Sync + Send {
request_data.into_builder(client) 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) { fn patch_request_data(&self, request_data: &mut RequestData) {
let model_type = self.model().model_type(); let model_type = self.model().model_type();
let wire = request_data.wire;
if let Some(patch) = self.model().patch() { if let Some(patch) = self.model().patch() {
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()); 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!( let patch_map = std::env::var(get_env_name(&format!(
"patch_{}_{}", "patch_{}_{api_name}",
self.model().client_name(), self.model().client_name(),
model_type.api_name(),
))) )))
.ok() .ok()
.and_then(|v| serde_json::from_str(&v).ok()) .and_then(|v| serde_json::from_str(&v).ok())
.or_else(|| { .or_else(|| {
self.patch_config() self.patch_config().and_then(|v| match wire {
.and_then(|v| model_type.extract_patch(v)) WireApi::Responses => v.responses.clone(),
.cloned() WireApi::Chat => model_type.extract_patch(v).cloned(),
})
}); });
let patch_map = match patch_map { let patch_map = match patch_map {
Some(v) => v, Some(v) => v,
@@ -272,16 +288,26 @@ pub struct ExtraConfig {
#[derive(Debug, Clone, Deserialize, Default)] #[derive(Debug, Clone, Deserialize, Default)]
pub struct RequestPatch { pub struct RequestPatch {
pub chat_completions: Option<ApiPatch>, pub chat_completions: Option<ApiPatch>,
pub responses: Option<ApiPatch>,
pub embeddings: Option<ApiPatch>, pub embeddings: Option<ApiPatch>,
pub rerank: Option<ApiPatch>, pub rerank: Option<ApiPatch>,
} }
pub type ApiPatch = IndexMap<String, Value>; pub type ApiPatch = IndexMap<String, Value>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum WireApi {
Responses,
Chat,
}
#[derive(Debug)]
pub struct RequestData { pub struct RequestData {
pub url: String, pub url: String,
pub headers: IndexMap<String, String>, pub headers: IndexMap<String, String>,
pub body: Value, pub body: Value,
pub wire: WireApi,
} }
impl RequestData { impl RequestData {
@@ -293,9 +319,15 @@ impl RequestData {
url: url.to_string(), url: url.to_string(),
headers: Default::default(), headers: Default::default(),
body, body,
wire: WireApi::Chat,
} }
} }
pub fn wire(mut self, wire: WireApi) -> Self {
self.wire = wire;
self
}
pub fn bearer_auth<T>(&mut self, auth: T) pub fn bearer_auth<T>(&mut self, auth: T)
where where
T: std::fmt::Display, T: std::fmt::Display,
@@ -313,7 +345,9 @@ impl RequestData {
} }
pub fn into_builder(self, client: &ReqwestClient) -> RequestBuilder { pub fn into_builder(self, client: &ReqwestClient) -> RequestBuilder {
let RequestData { url, headers, body } = self; let RequestData {
url, headers, body, ..
} = self;
debug!("Request {url} {body}"); debug!("Request {url} {body}");
let mut builder = client.post(url); let mut builder = client.post(url);
@@ -2012,4 +2046,263 @@ mod tests {
Some("500") 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<RequestPatch>,
}
#[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<ChatCompletionsOutput> {
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<Value>,
patch: Option<RequestPatch>,
) -> 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::<RequestPatch>(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")
);
}
} }
+101 -5
View File
@@ -188,15 +188,27 @@ pub struct ImageUrl {
pub url: String, pub url: String,
} }
/// An extended-thinking block returned by Anthropic-protocol models. /// An extended-thinking block returned by Anthropic-protocol models or an
/// Serialized to match the API wire format (`type: thinking` / `type: redacted_thinking`) /// 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 /// 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)] #[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum ThinkingBlock { pub enum ThinkingBlock {
Thinking { thinking: String, signature: String }, Thinking {
RedactedThinking { data: String }, 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<String>,
},
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
@@ -263,3 +275,87 @@ pub fn extract_system_message(messages: &mut Vec<Message>) -> Option<String> {
None 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<ThinkingBlock> = 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<ThinkingBlock> = serde_json::from_value(json.clone()).unwrap();
let yaml = serde_yaml::to_string(&blocks).unwrap();
assert!(!yaml.contains("encrypted_content: null"));
let restored: Vec<ThinkingBlock> = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(serde_json::to_value(&restored).unwrap(), json);
}
}
+2
View File
@@ -1460,6 +1460,7 @@ echo_pkce_in_token_exchange: true
api_key: None, api_key: None,
auth: auth.map(str::to_string), auth: auth.map(str::to_string),
oauth: oauth.map(Box::new), oauth: oauth.map(Box::new),
wire_api: None,
models: vec![], models: vec![],
patch: None, patch: None,
extra: None, extra: None,
@@ -1496,6 +1497,7 @@ echo_pkce_in_token_exchange: true
organization_id: None, organization_id: None,
auth: auth.map(str::to_string), auth: auth.map(str::to_string),
oauth: oauth.map(Box::new), oauth: oauth.map(Box::new),
wire_api: None,
models: vec![], models: vec![],
patch: None, patch: None,
extra: None, extra: None,
+896 -33
View File
File diff suppressed because it is too large. Load diff
+158 -8
View File
@@ -16,6 +16,7 @@ pub struct OpenAICompatibleConfig {
pub api_key: Option<String>, pub api_key: Option<String>,
pub auth: Option<String>, pub auth: Option<String>,
pub oauth: Option<Box<OAuthConfig>>, pub oauth: Option<Box<OAuthConfig>>,
pub wire_api: Option<WireApi>,
#[serde(default)] #[serde(default)]
pub models: Vec<ModelData>, pub models: Vec<ModelData>,
pub patch: Option<RequestPatch>, pub patch: Option<RequestPatch>,
@@ -42,10 +43,13 @@ impl Client for OpenAICompatibleClient {
client: &ReqwestClient, client: &ReqwestClient,
data: ChatCompletionsData, data: ChatCompletionsData,
) -> Result<ChatCompletionsOutput> { ) -> Result<ChatCompletionsOutput> {
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); 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( async fn chat_completions_streaming_inner(
@@ -54,11 +58,16 @@ impl Client for OpenAICompatibleClient {
handler: &mut SseHandler, handler: &mut SseHandler,
data: ChatCompletionsData, data: ChatCompletionsData,
) -> Result<()> { ) -> 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); let builder = self.request_builder(client, request_data);
match wire {
WireApi::Responses => openai_responses_streaming(builder, handler).await,
WireApi::Chat => {
openai_chat_completions_streaming(builder, handler, self.model()).await openai_chat_completions_streaming(builder, handler, self.model()).await
} }
}
}
async fn embeddings_inner( async fn embeddings_inner(
&self, &self,
@@ -87,15 +96,22 @@ async fn prepare_chat_completions(
self_: &OpenAICompatibleClient, self_: &OpenAICompatibleClient,
client: &ReqwestClient, client: &ReqwestClient,
data: ChatCompletionsData, data: ChatCompletionsData,
) -> Result<RequestData> { ) -> Result<(RequestData, WireApi)> {
let wire = resolve_wire_api(self_.config.wire_api, false, false)?;
let api_base = get_api_base_ext(self_)?; let api_base = get_api_base_ext(self_)?;
let url = format!("{api_base}/chat/completions"); let url = match wire {
let body = openai_build_chat_completions_body(data, &self_.model); WireApi::Responses => format!("{api_base}/responses"),
let mut request_data = RequestData::new(url, body); 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?; apply_auth(self_, client, &mut request_data).await?;
Ok(request_data) Ok((request_data, wire))
} }
async fn prepare_embeddings( async fn prepare_embeddings(
@@ -246,3 +262,137 @@ pub fn generic_build_rerank_body(data: &RerankData, model: &Model) -> Value {
} }
body body
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AppConfig;
use std::sync::Arc;
fn compat_config(wire_api: Option<WireApi>) -> 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::<OpenAICompatibleConfig>("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"
);
}
}
+25
View File
@@ -215,6 +215,14 @@ impl SseHandler {
!self.buffer.is_empty() || !self.tool_calls.is_empty() || !self.thinking.is_empty() !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 { pub fn abort(&self) -> AbortSignal {
self.abort_signal.clone() self.abort_signal.clone()
} }
@@ -544,6 +552,23 @@ mod tests {
assert!(handler.has_received_content()); 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 /// Claude splits usage across events: `message_start` carries the input
/// and cache counts with a provisional output count, `message_delta` /// and cache counts with a provisional output count, `message_delta`
/// carries only the final output count. Later non-None fields must win. /// carries only the final output count. Later non-None fields must win.
+32 -4
View File
@@ -2576,13 +2576,16 @@ fn polyfill_cmd_name<T: AsRef<Path>>(cmd_name: &str, bin_dir: &[T]) -> String {
cmd_name cmd_name
} }
// Polling tools are expected to repeat; recording them would also let them // Polling tools are expected to repeat with identical arguments (status probes,
// break up detection of a real loop in the calls they interleave with. // list views, inbox checks); recording them would also let them break up
const LOOP_TRACKER_EXEMPT_TOOLS: [&str; 4] = [ // detection of a real loop in the calls they interleave with.
const LOOP_TRACKER_EXEMPT_TOOLS: [&str; 6] = [
"job__check", "job__check",
"job__list", "job__list",
"agent__check", "agent__check",
"agent__list_running", "agent__list_running",
"agent__task_list",
"agent__check_inbox",
]; ];
fn is_loop_tracker_exempt(name: &str) -> bool { fn is_loop_tracker_exempt(name: &str) -> bool {
@@ -3386,10 +3389,12 @@ mod tests {
"job__list", "job__list",
"agent__check", "agent__check",
"agent__list_running", "agent__list_running",
"agent__task_list",
"agent__check_inbox",
] ]
.into_iter() .into_iter()
.collect(); .collect();
assert_eq!(LOOP_TRACKER_EXEMPT_TOOLS.len(), 4); assert_eq!(LOOP_TRACKER_EXEMPT_TOOLS.len(), 6);
assert_eq!(actual, expected); assert_eq!(actual, expected);
} }
@@ -3426,6 +3431,29 @@ mod tests {
assert!(tracker.check_loop(&x).is_some()); 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] #[test]
fn tracker_non_exempt_behavior_unchanged() { fn tracker_non_exempt_behavior_unchanged() {
let mut tracker = ToolCallTracker::default(); let mut tracker = ToolCallTracker::default();