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:
13 files changed
+1831
-131
No files matched your search
@@ -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
|
||||
# <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__
|
||||
@@ -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() {
|
||||
|
||||
+63
-25
@@ -351,6 +351,10 @@
|
||||
max_tokens: null
|
||||
temperature: null
|
||||
top_p: null
|
||||
responses:
|
||||
body:
|
||||
temperature: null
|
||||
top_p: null
|
||||
max_output_tokens: 100000
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
@@ -370,6 +374,10 @@
|
||||
max_tokens: null
|
||||
temperature: null
|
||||
top_p: null
|
||||
responses:
|
||||
body:
|
||||
temperature: null
|
||||
top_p: null
|
||||
max_output_tokens: 100000
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
@@ -429,6 +437,10 @@
|
||||
max_tokens: null
|
||||
temperature: null
|
||||
top_p: null
|
||||
responses:
|
||||
body:
|
||||
temperature: null
|
||||
top_p: null
|
||||
max_output_tokens: 100000
|
||||
supports_function_calling: true
|
||||
reasoning_levels:
|
||||
@@ -540,6 +552,12 @@
|
||||
max_tokens: null
|
||||
temperature: null
|
||||
top_p: null
|
||||
responses:
|
||||
body:
|
||||
temperature: null
|
||||
top_p: null
|
||||
reasoning:
|
||||
effort: high
|
||||
max_output_tokens: 100000
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
@@ -556,6 +574,12 @@
|
||||
max_tokens: null
|
||||
temperature: null
|
||||
top_p: null
|
||||
responses:
|
||||
body:
|
||||
temperature: null
|
||||
top_p: null
|
||||
reasoning:
|
||||
effort: high
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
system_prompt_prefix: Formatting re-enabled
|
||||
@@ -571,6 +595,12 @@
|
||||
max_tokens: null
|
||||
temperature: null
|
||||
top_p: null
|
||||
responses:
|
||||
body:
|
||||
temperature: null
|
||||
top_p: null
|
||||
reasoning:
|
||||
effort: high
|
||||
max_output_tokens: 100000
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
@@ -6947,9 +6977,9 @@
|
||||
- name: deepseek/deepseek-v4-flash
|
||||
type: chat
|
||||
max_input_tokens: 1048576
|
||||
input_price: 0.0886
|
||||
output_price: 0.1772
|
||||
cache_read_price: 0.0177
|
||||
input_price: 0.084
|
||||
output_price: 0.168
|
||||
cache_read_price: 0.0168
|
||||
max_output_tokens: 384000
|
||||
supports_function_calling: true
|
||||
- name: deepseek/deepseek-v4-flash-0731
|
||||
@@ -6997,10 +7027,10 @@
|
||||
- name: deepseek/deepseek-v4-pro-0813
|
||||
type: chat
|
||||
max_input_tokens: 1048576
|
||||
input_price: 0.9834
|
||||
output_price: 2.9502
|
||||
cache_read_price: 0.0328
|
||||
max_output_tokens: 384000
|
||||
input_price: 0.5795
|
||||
output_price: 1.7384
|
||||
cache_read_price: 0.0184
|
||||
max_output_tokens: 393216
|
||||
supports_function_calling: true
|
||||
- name: deepseek/deepseek-v4-pro-0813:batch
|
||||
type: chat
|
||||
@@ -8022,9 +8052,9 @@
|
||||
- name: moonshotai/kimi-k3
|
||||
type: chat
|
||||
max_input_tokens: 1048576
|
||||
input_price: 2.6481
|
||||
output_price: 13.2827
|
||||
cache_read_price: 0.3026
|
||||
input_price: 3.0
|
||||
output_price: 15.0
|
||||
cache_read_price: 0.3
|
||||
max_output_tokens: 943718
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
@@ -9405,9 +9435,9 @@
|
||||
- name: qwen/qwen3-vl-30b-a3b-instruct
|
||||
type: chat
|
||||
max_input_tokens: 262144
|
||||
input_price: 0.15
|
||||
output_price: 0.6
|
||||
max_output_tokens: 16384
|
||||
input_price: 0.13
|
||||
output_price: 0.52
|
||||
max_output_tokens: 32768
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: qwen/qwen3-vl-30b-a3b-thinking
|
||||
@@ -9716,6 +9746,14 @@
|
||||
input_price: 0.65
|
||||
output_price: 0.75
|
||||
max_output_tokens: 16384
|
||||
- name: stealth/union-alpha
|
||||
type: chat
|
||||
max_input_tokens: 262144
|
||||
input_price: 0.0
|
||||
output_price: 0.0
|
||||
max_output_tokens: 131072
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: stepfun/step-3.5-flash
|
||||
type: chat
|
||||
max_input_tokens: 262144
|
||||
@@ -9759,9 +9797,9 @@
|
||||
- name: tencent/hy3
|
||||
type: chat
|
||||
max_input_tokens: 262144
|
||||
input_price: 0.132
|
||||
output_price: 0.528
|
||||
cache_read_price: 0.033
|
||||
input_price: 0.0825
|
||||
output_price: 0.33
|
||||
cache_read_price: 0.0206
|
||||
max_output_tokens: 128000
|
||||
supports_function_calling: true
|
||||
- name: tencent/hy3-preview
|
||||
@@ -10037,7 +10075,7 @@
|
||||
input_price: 1.4
|
||||
output_price: 4.4
|
||||
cache_read_price: 0.14
|
||||
max_output_tokens: 128000
|
||||
max_output_tokens: 131072
|
||||
supports_function_calling: true
|
||||
- name: z-ai/glm-5.2:batch
|
||||
type: chat
|
||||
@@ -10142,16 +10180,16 @@
|
||||
input_price: 0.15
|
||||
output_price: 0.6
|
||||
cache_read_price: 0.015
|
||||
max_output_tokens: 943718
|
||||
max_output_tokens: 393216
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: ~deepseek/deepseek-pro-latest
|
||||
type: chat
|
||||
max_input_tokens: 1048576
|
||||
input_price: 0.66
|
||||
output_price: 1.98
|
||||
cache_read_price: 0.022
|
||||
max_output_tokens: 384000
|
||||
input_price: 0.5795
|
||||
output_price: 1.7384
|
||||
cache_read_price: 0.0184
|
||||
max_output_tokens: 393216
|
||||
supports_function_calling: true
|
||||
- name: ~deepseek/deepseek-v4-flash-latest
|
||||
type: chat
|
||||
@@ -10184,9 +10222,9 @@
|
||||
- name: ~moonshotai/kimi-latest
|
||||
type: chat
|
||||
max_input_tokens: 1048576
|
||||
input_price: 2.1
|
||||
output_price: 10.95
|
||||
cache_read_price: 0.23
|
||||
input_price: 2.0
|
||||
output_price: 11.2
|
||||
cache_read_price: 0.232
|
||||
max_output_tokens: 943718
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
|
||||
+30
@@ -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
|
||||
|
||||
+43
-8
@@ -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<Value> {
|
||||
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!({
|
||||
|
||||
+62
-2
@@ -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")];
|
||||
|
||||
+300
-7
@@ -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<ApiPatch>,
|
||||
pub responses: Option<ApiPatch>,
|
||||
pub embeddings: Option<ApiPatch>,
|
||||
pub rerank: Option<ApiPatch>,
|
||||
}
|
||||
|
||||
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 url: String,
|
||||
pub headers: IndexMap<String, String>,
|
||||
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<T>(&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);
|
||||
@@ -2012,4 +2046,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<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
@@ -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<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -263,3 +275,87 @@ pub fn extract_system_message(messages: &mut Vec<Message>) -> Option<String> {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+920
-57
File diff suppressed because it is too large.
Load diff
+163
-13
@@ -16,6 +16,7 @@ pub struct OpenAICompatibleConfig {
|
||||
pub api_key: Option<String>,
|
||||
pub auth: Option<String>,
|
||||
pub oauth: Option<Box<OAuthConfig>>,
|
||||
pub wire_api: Option<WireApi>,
|
||||
#[serde(default)]
|
||||
pub models: Vec<ModelData>,
|
||||
pub patch: Option<RequestPatch>,
|
||||
@@ -42,10 +43,13 @@ impl Client for OpenAICompatibleClient {
|
||||
client: &ReqwestClient,
|
||||
data: ChatCompletionsData,
|
||||
) -> 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);
|
||||
|
||||
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<RequestData> {
|
||||
) -> 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<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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
+32
-4
@@ -2576,13 +2576,16 @@ fn polyfill_cmd_name<T: AsRef<Path>>(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();
|
||||
|
||||
Reference in new issue
Block a user