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
+1798
-98
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:
|
# 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__
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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!({
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in new issue
Block a user