Files
CC-Switch/src-tauri/src/proxy/providers/transform_codex_anthropic.rs
T
Jason 6eb217b242 revert(proxy): drop the 1-hour cache TTL option and TTL-bucketed write accounting
The forced 1-hour cache_control TTL (schema v14) was a mistake. Injected
breakpoints return to Anthropic's standard 5-minute TTL, caller-owned
markers are preserved verbatim instead of having their TTLs rewritten,
and the 5m/1h cache-write buckets are removed from usage parsing and
pricing (back to the single aggregate cache-creation rate). The cache
TTL selector is removed from the rectifier settings panel along with its
i18n keys in all four locales.

SCHEMA_VERSION returns to 13: the unreleased cache_creation_1h_tokens
column and the v13->v14 migration are removed. The feature never shipped
in a release and the introducing commit was never pushed, so no
databases were stamped v14 outside this machine (local DB verified at
user_version 13).
2026-07-13 17:55:33 +08:00

2719 lines
110 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! OpenAI Responses ↔ Anthropic Messages format conversion module (used when the Codex upstream is an Anthropic gateway)
//!
//! Scenario: The Codex CLI only speaks the OpenAI Responses protocol, while the
//! upstream AI gateway only offers the native Anthropic Messages protocol
//! (`/v1/messages`). This module converts the Responses request sent by Codex
//! into an Anthropic request, then converts the Anthropic response back into a
//! Responses response.
//!
//! The direction is exactly the mirror of `transform_responses.rs`:
//! - `transform_responses.rs`: Anthropic request → Responses request, Responses response → Anthropic response
//! - this module: Responses request → Anthropic request, Anthropic response → Responses response
use super::transform_codex_chat::{
build_codex_tool_context_from_request, response_tool_call_item_from_chat_name,
response_tool_call_item_id_from_chat_name, CodexToolContext,
};
use super::transform_responses::{sanitize_anthropic_tool_use_input, TOOL_RESULT_ERROR_MARKER};
use crate::proxy::error::ProxyError;
use crate::proxy::json_canonical::canonical_json_string;
use crate::proxy::sse::{strip_sse_field, take_sse_block};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use serde_json::{json, Value};
use std::collections::{BTreeMap, HashSet};
pub(crate) const ANTHROPIC_THINKING_ENCRYPTED_PREFIX: &str = "ccswitch-anthropic-thinking-v1:";
const TOOL_SEARCH_PROXY_NAME: &str = "tool_search";
/// Maps Codex's reasoning.effort to the token budget for Anthropic thinking.
///
/// Returning `None` indicates an unrecognized effort value—in that case extended
/// thinking should not be enabled (to avoid accidentally swallowing
/// temperature/top_p), keeping normal sampling.
pub(crate) fn effort_to_thinking_budget(effort: &str) -> Option<u64> {
match effort.trim().to_ascii_lowercase().as_str() {
"minimal" | "low" => Some(2048),
"medium" => Some(8192),
"high" => Some(16384),
"xhigh" | "max" => Some(24576),
_ => None,
}
}
fn codex_effort_to_anthropic(effort: &str) -> Option<&'static str> {
match effort.trim().to_ascii_lowercase().as_str() {
"minimal" | "low" => Some("low"),
"medium" => Some("medium"),
"high" => Some("high"),
"xhigh" | "max" => Some("max"),
_ => None,
}
}
fn reasoning_explicitly_disabled(effort: Option<&str>) -> bool {
matches!(
effort
.map(str::trim)
.map(str::to_ascii_lowercase)
.as_deref(),
Some("none" | "off" | "disabled")
)
}
/// Preserve an Anthropic signed thinking/redacted-thinking block inside the opaque
/// Responses `reasoning.encrypted_content` field so Codex replays it on the next
/// tool-result request. The prefix keeps unrelated providers' ciphertext isolated.
pub(crate) fn encode_anthropic_thinking_block(block: &Value) -> Option<String> {
match block.get("type").and_then(|value| value.as_str()) {
Some("thinking")
if block
.get("signature")
.and_then(Value::as_str)
.is_some_and(|value| !value.is_empty()) => {}
Some("redacted_thinking")
if block
.get("data")
.and_then(Value::as_str)
.is_some_and(|value| !value.is_empty()) => {}
_ => return None,
}
let bytes = serde_json::to_vec(block).ok()?;
Some(format!(
"{ANTHROPIC_THINKING_ENCRYPTED_PREFIX}{}",
URL_SAFE_NO_PAD.encode(bytes)
))
}
pub(crate) fn decode_anthropic_thinking_block(encrypted_content: &str) -> Option<Value> {
let encoded = encrypted_content.strip_prefix(ANTHROPIC_THINKING_ENCRYPTED_PREFIX)?;
let bytes = URL_SAFE_NO_PAD.decode(encoded).ok()?;
let block: Value = serde_json::from_slice(&bytes).ok()?;
// Reuse the encoder's validation so legacy/malformed bridge envelopes cannot
// replay an unsigned thinking block into an Anthropic tool turn.
encode_anthropic_thinking_block(&block).map(|_| block)
}
pub(crate) fn responses_reasoning_item_from_anthropic_block(
item_id: &str,
block: &Value,
) -> Option<Value> {
let encrypted_content = encode_anthropic_thinking_block(block)?;
let summary = block
.get("thinking")
.and_then(|value| value.as_str())
.filter(|text| !text.is_empty())
.map(|text| vec![json!({ "type": "summary_text", "text": text })])
.unwrap_or_default();
Some(json!({
"id": item_id,
"type": "reasoning",
"summary": summary,
"encrypted_content": encrypted_content
}))
}
/// Anthropic's stop_reason → Responses' (status, incomplete_details.reason)
pub(crate) fn map_anthropic_stop_reason_to_status(
stop_reason: Option<&str>,
) -> (&'static str, Option<&'static str>) {
match stop_reason {
Some("max_tokens") => ("incomplete", Some("max_output_tokens")),
// Safety refusal: report as incomplete to avoid Codex treating it as a normally-completed empty reply.
Some("refusal") => ("incomplete", Some("content_filter")),
Some("model_context_window_exceeded") => ("incomplete", Some("max_output_tokens")),
// pause_turn is unreachable on this path (Codex requests do not declare Anthropic server-side tools);
// if it does occur, log a warning and treat it as completed.
Some("pause_turn") => {
log::warn!("[Codex] Received unexpected Anthropic stop_reason=pause_turn, treating it as completed");
("completed", None)
}
_ => ("completed", None),
}
}
/// Builds Responses usage from Anthropic usage.
///
/// Anthropic's `input_tokens` is the cache-excluded fresh input. OpenAI Responses
/// reports total input and exposes cache reads/writes as subsets:
/// input_tokens = fresh + cache_read + cache_creation
/// input_tokens_details.cached_tokens = cache_read
/// input_tokens_details.cache_write_tokens = cache_creation
///
/// The internal billing parser subtracts both subsets before charging the normal
/// input rate, then prices cache reads and writes separately.
pub(crate) fn build_responses_usage_from_anthropic(usage: Option<&Value>) -> Value {
let u = match usage {
Some(v) if v.is_object() => v,
_ => {
return json!({
"input_tokens": 0,
"output_tokens": 0,
"total_tokens": 0,
"output_tokens_details": { "reasoning_tokens": 0 }
})
}
};
let fresh_input = u.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
let output = u.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
let reasoning = u
.pointer("/output_tokens_details/thinking_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let cache_read = u
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let cache_creation = u
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let input_tokens = fresh_input
.saturating_add(cache_read)
.saturating_add(cache_creation);
let total_tokens = input_tokens.saturating_add(output);
let mut result = json!({
"input_tokens": input_tokens,
"output_tokens": output,
"total_tokens": total_tokens,
"output_tokens_details": { "reasoning_tokens": reasoning }
});
if cache_read > 0 || cache_creation > 0 {
result["input_tokens_details"] = json!({
"cached_tokens": cache_read,
"cache_write_tokens": cache_creation
});
}
// Keep the legacy top-level alias for one compatibility window. New code reads
// the official nested cache_write_tokens field first.
if cache_creation > 0 {
result["cache_creation_input_tokens"] = json!(cache_creation);
}
result
}
/// OpenAI Responses request → Anthropic Messages request
///
/// `default_max_tokens`: injected when the Responses body has no
/// `max_output_tokens` (Anthropic's `max_tokens` is required; missing it yields a 400).
fn responses_system_text(item: &Value) -> Vec<String> {
match item.get("content") {
Some(Value::String(text)) if is_meaningful_text(text) => {
vec![text.trim().to_string()]
}
Some(Value::Array(parts)) => parts
.iter()
.filter_map(|part| {
matches!(
part.get("type").and_then(Value::as_str),
Some("input_text" | "output_text" | "text")
)
.then(|| part.get("text").and_then(Value::as_str))
.flatten()
.filter(|text| is_meaningful_text(text))
.map(|text| text.trim().to_string())
})
.collect(),
_ => Vec::new(),
}
}
pub fn responses_request_to_anthropic(
body: Value,
default_max_tokens: u64,
) -> Result<Value, ProxyError> {
let mut result = json!({});
let tool_context = build_codex_tool_context_from_request(&body);
let model = body
.get("model")
.and_then(|value| value.as_str())
.unwrap_or("");
// Pass model through (the upstream model has already been applied by the forwarder)
if let Some(model) = body.get("model").and_then(|m| m.as_str()) {
result["model"] = json!(model);
}
// instructions and historical system/developer messages → Anthropic system.
// Anthropic messages only accept user/assistant roles; degrading these items to
// user silently changes instruction precedence.
let mut system_parts = Vec::new();
if let Some(instructions) = body.get("instructions").and_then(|v| v.as_str()) {
if is_meaningful_text(instructions) {
system_parts.push(instructions.trim().to_string());
}
}
if let Some(items) = body.get("input").and_then(Value::as_array) {
for item in items {
if matches!(
item.get("role").and_then(Value::as_str),
Some("system" | "developer")
) {
system_parts.extend(responses_system_text(item));
}
}
}
if !system_parts.is_empty() {
result["system"] = json!(system_parts.join("\n\n"));
}
// input → messages
let mut messages = match body.get("input") {
Some(Value::Array(items)) => convert_input_to_messages(items, &tool_context)?,
Some(Value::String(text)) if is_meaningful_text(text) => vec![json!({
"role": "user",
"content": [{ "type": "text", "text": text }]
})],
_ => Vec::new(),
};
// Anthropic /v1/messages requires messages to be non-empty and the first to be user.
// Normalize the history (compacted/resumed sessions may start with
// assistant/function_call, or input may be entirely reasoning and thus empty after being dropped).
// Drop incomplete tool turns first (they would otherwise 400), then guarantee a leading user.
drop_incomplete_tool_turns(&mut messages);
drop_empty_messages(&mut messages);
ensure_leading_user_message(&mut messages);
if messages.is_empty() {
return Err(ProxyError::InvalidRequest(
"cannot convert Codex request: empty messages".to_string(),
));
}
trim_trailing_assistant_text(&mut messages);
drop_empty_messages(&mut messages);
if messages.is_empty() {
return Err(ProxyError::InvalidRequest(
"cannot convert Codex request: empty messages".to_string(),
));
}
let thinking_history_is_valid = trailing_turn_supports_thinking(&messages);
result["messages"] = json!(messages);
let reasoning_effort = body
.pointer("/reasoning/effort")
.and_then(|value| value.as_str());
let adaptive_model = crate::proxy::thinking_optimizer::uses_adaptive_thinking(model);
let adaptive_by_default = crate::proxy::thinking_optimizer::adaptive_thinking_is_default(model);
let cannot_disable_thinking =
crate::proxy::thinking_optimizer::thinking_cannot_be_disabled(model);
// max_output_tokens → max_tokens (required)
let max_tokens = body
.get("max_output_tokens")
.and_then(|v| v.as_u64())
.filter(|v| *v > 0)
.unwrap_or(default_max_tokens);
let mut thinking_enabled = false;
let mut thinking_budget = reasoning_effort
.and_then(effort_to_thinking_budget)
.unwrap_or(0);
let explicitly_disabled = reasoning_explicitly_disabled(reasoning_effort);
let adaptive_should_think = adaptive_model
&& (adaptive_by_default
|| reasoning_effort
.and_then(codex_effort_to_anthropic)
.is_some());
if !thinking_history_is_valid {
if cannot_disable_thinking {
return Err(ProxyError::InvalidRequest(
"Anthropic model requires thinking, but the tool history has no signed thinking block to replay"
.to_string(),
));
}
if adaptive_should_think {
result["thinking"] = json!({ "type": "disabled" });
}
} else if adaptive_should_think && (!explicitly_disabled || cannot_disable_thinking) {
thinking_enabled = true;
result["thinking"] = json!({ "type": "adaptive" });
if let Some(effort) = reasoning_effort.and_then(codex_effort_to_anthropic) {
result["output_config"] = json!({ "effort": effort });
} else if explicitly_disabled && cannot_disable_thinking {
// Fable/Mythos cannot turn thinking off. `low` is the closest safe
// representation of Codex's explicit `none` request.
result["output_config"] = json!({ "effort": "low" });
}
} else if explicitly_disabled {
result["thinking"] = json!({ "type": "disabled" });
} else if thinking_budget > 0 {
thinking_enabled = true;
// Anthropic requires max_tokens > budget_tokens and budget >= 1024. Reserve
// headroom for the visible answer: cap the thinking budget at half of max_tokens
// so a large derived budget (e.g. 24576 for xhigh) can't consume nearly all of a
// modest max_tokens and leave ~1 output token (an effectively empty completion).
// Do not raise the caller's max_tokens (it may exceed the model's output ceiling
// and 400). If the remaining budget is below Anthropic's 1024 floor, disable
// thinking and restore normal sampling.
let ceiling = max_tokens / 2;
thinking_budget = thinking_budget.min(ceiling);
if thinking_budget < 1024 {
thinking_enabled = false;
}
}
result["max_tokens"] = json!(max_tokens);
if thinking_enabled && !adaptive_model {
result["thinking"] = json!({
"type": "enabled",
"budget_tokens": thinking_budget
});
}
if !thinking_enabled {
if let Some(v) = body.get("temperature") {
result["temperature"] = v.clone();
}
if let Some(v) = body.get("top_p") {
result["top_p"] = v.clone();
}
}
if let Some(v) = body.get("stream") {
result["stream"] = v.clone();
}
// Reuse the Codex tool context so function, namespace, custom, tool_search, and
// dynamically loaded tools all receive stable flat names upstream.
let anth_tools: Vec<Value> = tool_context
.chat_tools()
.iter()
.filter_map(chat_tool_to_anthropic_tool)
.collect();
let has_tools = !anth_tools.is_empty();
if has_tools {
result["tools"] = json!(anth_tools);
}
// Only forward tool_choice when tools survived the filter. Anthropic 400s on a
// tool_choice with no tools ("tool_choice may only be specified while providing
// tools"), and that 400 is non-retryable — so a request whose only tools were
// unsupported hosted tools (for example web_search) must drop tool_choice too.
if has_tools {
if let Some(tc) = body.get("tool_choice") {
let mapped = map_tool_choice_to_anthropic(tc, &tool_context);
let forced = matches!(
mapped.get("type").and_then(|value| value.as_str()),
Some("any" | "tool")
);
if thinking_enabled && forced {
if cannot_disable_thinking {
return Err(ProxyError::InvalidRequest(
"Anthropic model requires adaptive thinking and cannot honor a forced tool_choice"
.to_string(),
));
}
// Anthropic rejects forced tools while thinking is enabled. Preserve
// the caller's explicit tool constraint and disable thinking for this
// request instead of silently weakening `required`/named selection.
result["thinking"] = json!({ "type": "disabled" });
result.as_object_mut().unwrap().remove("output_config");
if let Some(value) = body.get("temperature") {
result["temperature"] = value.clone();
}
if let Some(value) = body.get("top_p") {
result["top_p"] = value.clone();
}
}
result["tool_choice"] = mapped;
}
if body.get("parallel_tool_calls").and_then(Value::as_bool) == Some(false) {
if result.get("tool_choice").is_none() {
result["tool_choice"] = json!({ "type": "auto" });
}
if let Some(tool_choice) = result.get_mut("tool_choice").and_then(Value::as_object_mut)
{
tool_choice.insert("disable_parallel_tool_use".to_string(), json!(true));
}
}
}
Ok(result)
}
fn chat_tool_to_anthropic_tool(chat_tool: &Value) -> Option<Value> {
let function = chat_tool.get("function")?;
let name = function
.get("name")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())?;
let mut tool = json!({
"name": name,
"input_schema": function
.get("parameters")
.cloned()
.filter(|value| value.as_object().is_some_and(|object| !object.is_empty()))
.unwrap_or_else(|| json!({ "type": "object", "properties": {} }))
});
if let Some(description) = function.get("description").and_then(|value| value.as_str()) {
tool["description"] = json!(description);
}
if let Some(strict) = function.get("strict").and_then(|value| value.as_bool()) {
tool["strict"] = json!(strict);
}
Some(tool)
}
/// tool_choice: Responses → Anthropic (the reverse of `map_tool_choice_to_responses`)
fn map_tool_choice_to_anthropic(tool_choice: &Value, tool_context: &CodexToolContext) -> Value {
match tool_choice {
Value::String(s) => match s.as_str() {
"required" => json!({ "type": "any" }),
"auto" => json!({ "type": "auto" }),
"none" => json!({ "type": "none" }),
_ => json!({ "type": "auto" }),
},
Value::Object(obj) => match obj.get("type").and_then(|t| t.as_str()) {
Some("function") => {
let name = obj.get("name").and_then(|n| n.as_str()).unwrap_or("");
let namespace = obj.get("namespace").and_then(|value| value.as_str());
let upstream_name = tool_context.chat_name_for_response_function(name, namespace);
json!({ "type": "tool", "name": upstream_name })
}
Some("custom") => json!({
"type": "tool",
"name": obj.get("name").and_then(|value| value.as_str()).unwrap_or("")
}),
Some("tool_search") => {
json!({ "type": "tool", "name": TOOL_SEARCH_PROXY_NAME })
}
// Other object shapes (allowed_tools / hosted-tool selectors, etc.) are
// not recognized by Anthropic; downgrade to auto to avoid passing OpenAI's
// raw structure through and causing a 400.
_ => json!({ "type": "auto" }),
},
_ => json!({ "type": "auto" }),
}
}
/// Re-nests the flat Responses input[] back into Anthropic messages.
///
/// - input_text/output_text → text block of the corresponding role
/// - input_image → image block
/// - function_call → assistant's tool_use block (merged with the preceding assistant text into the same message)
/// - function_call_output → user's tool_result block (consecutive ones merged into the same user message)
/// - Anthropic-origin reasoning.encrypted_content → restored signed thinking block
fn convert_input_to_messages(
items: &[Value],
tool_context: &CodexToolContext,
) -> Result<Vec<Value>, ProxyError> {
let mut messages: Vec<Value> = Vec::new();
for item in items {
let item_type = item.get("type").and_then(|t| t.as_str());
if matches!(
item_type,
Some("function_call" | "custom_tool_call" | "tool_search_call")
) && item.get("status").and_then(Value::as_str) == Some("incomplete")
{
log::warn!(
"[Codex/Anthropic] Dropping incomplete historical tool call: type={}, call_id={}",
item_type.unwrap_or("unknown"),
item.get("call_id")
.and_then(Value::as_str)
.or_else(|| item.get("id").and_then(Value::as_str))
.unwrap_or("")
);
continue;
}
match item_type {
Some("function_call") => {
let call_id = item
.get("call_id")
.and_then(|v| v.as_str())
.or_else(|| item.get("id").and_then(|v| v.as_str()))
.unwrap_or("");
let name = item.get("name").and_then(|v| v.as_str()).unwrap_or("");
let namespace = item.get("namespace").and_then(|value| value.as_str());
let upstream_name = tool_context.chat_name_for_response_function(name, namespace);
let args_str = item.get("arguments").and_then(|v| v.as_str()).unwrap_or("");
let input: Value = if args_str.trim().is_empty() {
json!({})
} else {
serde_json::from_str(args_str).map_err(|error| {
ProxyError::InvalidRequest(format!(
"Invalid function_call arguments for '{name}': {error}"
))
})?
};
if !input.is_object() {
return Err(ProxyError::InvalidRequest(format!(
"Function call arguments for '{name}' must be a JSON object"
)));
}
let input = sanitize_anthropic_tool_use_input(name, input);
push_block(
&mut messages,
"assistant",
json!({
"type": "tool_use",
"id": call_id,
"name": upstream_name,
"input": input
}),
);
}
Some("custom_tool_call") => {
let call_id = item
.get("call_id")
.and_then(|value| value.as_str())
.or_else(|| item.get("id").and_then(|value| value.as_str()))
.unwrap_or("");
let name = item
.get("name")
.and_then(|value| value.as_str())
.unwrap_or("");
let input = item.get("input").cloned().unwrap_or_else(|| json!(""));
push_block(
&mut messages,
"assistant",
json!({
"type": "tool_use",
"id": call_id,
"name": name,
"input": { "input": input }
}),
);
}
Some("tool_search_call") => {
let call_id = item
.get("call_id")
.and_then(|value| value.as_str())
.or_else(|| item.get("id").and_then(|value| value.as_str()))
.unwrap_or("");
let input = item
.get("arguments")
.cloned()
.filter(Value::is_object)
.unwrap_or_else(|| json!({}));
push_block(
&mut messages,
"assistant",
json!({
"type": "tool_use",
"id": call_id,
"name": TOOL_SEARCH_PROXY_NAME,
"input": input
}),
);
}
Some("function_call_output" | "custom_tool_call_output" | "tool_search_output") => {
let call_id = item.get("call_id").and_then(|v| v.as_str()).unwrap_or("");
let output = tool_result_content_from_responses_item(item);
let mut block = json!({
"type": "tool_result",
"tool_use_id": call_id,
"content": output.content
});
if output.is_error {
block["is_error"] = json!(true);
}
push_tool_result_block(&mut messages, block);
}
Some("input_text") => {
if let Some(text) = item
.get("text")
.and_then(Value::as_str)
.filter(|text| is_meaningful_text(text))
{
push_block(
&mut messages,
"user",
json!({ "type": "text", "text": text }),
);
}
}
Some("input_image") => {
if let Some(block) = image_block_from_input_image(item) {
push_block(&mut messages, "user", block);
}
}
Some("reasoning") => {
if let Some(block) = item
.get("encrypted_content")
.and_then(|value| value.as_str())
.and_then(decode_anthropic_thinking_block)
{
push_assistant_thinking_block(&mut messages, block);
}
}
// message item or an item carrying a role
_ => {
let role = item.get("role").and_then(|r| r.as_str()).unwrap_or("user");
if matches!(role, "system" | "developer") {
continue;
}
let anth_role = if role == "assistant" {
"assistant"
} else {
"user"
};
match item.get("content") {
Some(Value::String(text)) if is_meaningful_text(text) => {
push_block(
&mut messages,
anth_role,
json!({ "type": "text", "text": text }),
);
}
Some(Value::Array(parts)) => {
for part in parts {
let part_type = part.get("type").and_then(|t| t.as_str()).unwrap_or("");
match part_type {
"input_text" | "output_text" => {
if let Some(text) = part
.get("text")
.and_then(|t| t.as_str())
.filter(|t| is_meaningful_text(t))
{
push_block(
&mut messages,
anth_role,
json!({ "type": "text", "text": text }),
);
}
}
"refusal" => {
if let Some(text) = part
.get("refusal")
.and_then(|t| t.as_str())
.filter(|t| is_meaningful_text(t))
{
push_block(
&mut messages,
anth_role,
json!({ "type": "text", "text": text }),
);
}
}
"input_image" => {
if let Some(block) = image_block_from_input_image(part) {
push_block(&mut messages, anth_role, block);
}
}
"input_file" => {
if let Some(block) = document_block_from_input_file(part) {
push_block(&mut messages, anth_role, block);
}
}
_ => {}
}
}
}
_ => {}
}
}
}
}
Ok(messages)
}
struct ToolResultContent {
content: Value,
is_error: bool,
}
fn tool_result_content_from_responses_item(item: &Value) -> ToolResultContent {
match item.get("output") {
Some(Value::String(text)) => ToolResultContent {
content: json!(text),
is_error: false,
},
Some(Value::Array(parts)) => {
let mut content = Vec::new();
let mut is_error = false;
for part in parts {
match part.get("type").and_then(Value::as_str) {
Some("input_text" | "output_text") => {
if let Some(text) = part.get("text").and_then(Value::as_str) {
if text == TOOL_RESULT_ERROR_MARKER {
is_error = true;
} else {
content.push(json!({"type":"text","text":text}));
}
}
}
Some("input_image") => {
if let Some(image) = image_block_from_input_image(part) {
content.push(image);
} else {
content.push(json!({
"type":"text",
"text":canonical_json_string(part)
}));
}
}
Some("input_file") => {
if let Some(document) = document_block_from_input_file(part) {
content.push(document);
} else {
content.push(json!({
"type":"text",
"text":canonical_json_string(part)
}));
}
}
_ => content.push(json!({
"type":"text",
"text":canonical_json_string(part)
})),
}
}
ToolResultContent {
content: Value::Array(content),
is_error,
}
}
Some(value) => ToolResultContent {
content: json!(canonical_json_string(value)),
is_error: false,
},
None => ToolResultContent {
content: json!(canonical_json_string(item)),
is_error: false,
},
}
}
/// Ensures the first message is a user: compacted/resumed sessions may start with
/// assistant or function_call, but Anthropic requires the first to be user, else 400.
/// An empty array is not handled (the caller decides whether to error).
fn ensure_leading_user_message(messages: &mut Vec<Value>) {
let leads_with_user = messages
.first()
.and_then(|m| m.get("role"))
.and_then(|r| r.as_str())
== Some("user");
if !messages.is_empty() && !leads_with_user {
messages.insert(
0,
json!({
"role": "user",
"content": [{ "type": "text", "text": "(continuing the conversation)" }]
}),
);
}
}
/// Removes compacted/resumed tool turns that no longer form a complete adjacent
/// assistant `tool_use` → user `tool_result` pair. Anthropic requires every tool
/// call in an assistant turn to be answered together in the immediately following
/// user turn. Dropping the whole incomplete assistant turn also avoids modifying a
/// subset of a signed thinking/tool-use response.
fn drop_incomplete_tool_turns(messages: &mut Vec<Value>) {
let original = std::mem::take(messages);
let mut sanitized = Vec::with_capacity(original.len());
let mut index = 0;
while index < original.len() {
let message = &original[index];
let is_assistant = message.get("role").and_then(Value::as_str) == Some("assistant");
let tool_use_ids = if is_assistant {
message_block_ids(message, "tool_use", "id")
} else {
Vec::new()
};
if !tool_use_ids.is_empty() {
let paired_user = original
.get(index + 1)
.filter(|next| next.get("role").and_then(Value::as_str) == Some("user"));
let tool_result_ids = paired_user
.map(|user| message_block_ids(user, "tool_result", "tool_use_id"))
.unwrap_or_default();
let unique_tool_uses: HashSet<&str> = tool_use_ids.iter().copied().collect();
let unique_tool_results: HashSet<&str> = tool_result_ids.iter().copied().collect();
let complete = tool_use_ids.iter().all(|id| !id.is_empty())
&& tool_result_ids.iter().all(|id| !id.is_empty())
&& unique_tool_uses.len() == tool_use_ids.len()
&& unique_tool_results.len() == tool_result_ids.len()
&& unique_tool_uses == unique_tool_results;
if complete {
sanitized.push(message.clone());
sanitized.push(paired_user.unwrap().clone());
} else if let Some(user) = paired_user {
let mut user = user.clone();
drop_tool_result_blocks(&mut user);
if message_has_content(&user) {
sanitized.push(user);
}
}
index += if paired_user.is_some() { 2 } else { 1 };
continue;
}
let mut message = message.clone();
if message.get("role").and_then(Value::as_str) == Some("user") {
// A user message not consumed as the adjacent half of a complete tool
// pair cannot legally retain any tool_result blocks.
drop_tool_result_blocks(&mut message);
}
if message_has_content(&message) {
sanitized.push(message);
}
index += 1;
}
*messages = sanitized;
}
fn message_block_ids<'a>(message: &'a Value, block_type: &str, id_field: &str) -> Vec<&'a str> {
message
.get("content")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter(|block| block.get("type").and_then(Value::as_str) == Some(block_type))
.map(|block| block.get(id_field).and_then(Value::as_str).unwrap_or(""))
.collect()
}
fn drop_tool_result_blocks(message: &mut Value) {
if let Some(content) = message.get_mut("content").and_then(Value::as_array_mut) {
content.retain(|block| block.get("type").and_then(Value::as_str) != Some("tool_result"));
}
}
fn message_has_content(message: &Value) -> bool {
message
.get("content")
.and_then(Value::as_array)
.map(|content| !content.is_empty())
.unwrap_or(true)
}
/// A fresh user prompt can start a new thinking turn. A tool-result continuation
/// may keep thinking enabled only when the preceding assistant turn includes the
/// signed thinking/redacted-thinking block that Anthropic requires callers to replay.
fn trailing_turn_supports_thinking(messages: &[Value]) -> bool {
let Some(last) = messages.last() else {
return false;
};
if last.get("role").and_then(Value::as_str) != Some("user") {
return false;
}
let mut tool_result_ids = Vec::new();
if let Some(blocks) = last.get("content").and_then(Value::as_array) {
for block in blocks {
if block.get("type").and_then(Value::as_str) != Some("tool_result") {
continue;
}
let Some(id) = block
.get("tool_use_id")
.and_then(Value::as_str)
.filter(|id| !id.is_empty())
else {
return false;
};
tool_result_ids.push(id);
}
}
if tool_result_ids.is_empty() {
return true;
}
// A tool-result turn answers the immediately preceding assistant tool-use turn.
// Looking any farther back can pick up an unrelated signed thinking block and
// incorrectly re-enable thinking for an unsigned tool call.
let Some(paired_assistant) = messages.get(messages.len().saturating_sub(2)) else {
return false;
};
if paired_assistant.get("role").and_then(Value::as_str) != Some("assistant") {
return false;
}
let Some(blocks) = paired_assistant.get("content").and_then(Value::as_array) else {
return false;
};
let has_signed_thinking = blocks.iter().any(|block| {
matches!(
block.get("type").and_then(Value::as_str),
Some("thinking" | "redacted_thinking")
)
});
if !has_signed_thinking {
return false;
}
let paired_tool_use_ids: HashSet<&str> = blocks
.iter()
.filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use"))
.filter_map(|block| block.get("id").and_then(Value::as_str))
.collect();
tool_result_ids
.iter()
.all(|id| paired_tool_use_ids.contains(id))
}
/// Removes whitespace-only assistant prefills and trims trailing whitespace from a
/// real prefill. Anthropic rejects an assistant prefill whose final text ends in
/// whitespace, and Codex may replay an empty assistant text beside a tool call.
fn trim_trailing_assistant_text(messages: &mut [Value]) {
let Some(last) = messages.last_mut() else {
return;
};
if last.get("role").and_then(Value::as_str) != Some("assistant") {
return;
}
let Some(blocks) = last.get_mut("content").and_then(Value::as_array_mut) else {
return;
};
let Some(block) = blocks.last_mut() else {
return;
};
if block.get("type").and_then(Value::as_str) != Some("text") {
return;
}
let Some(text) = block.get("text").and_then(Value::as_str) else {
return;
};
let trimmed = text.trim_end();
if trimmed.is_empty() {
blocks.pop();
} else if trimmed.len() != text.len() {
block["text"] = json!(trimmed);
}
}
/// Anthropic 400s on a text content block whose text is empty or whitespace-only.
/// Such blocks arise when a prior Responses turn recorded an empty
/// input_text/output_text (e.g. an empty assistant text emitted alongside a
/// tool_use); replaying it verbatim would fail the next follow-up request.
fn is_meaningful_text(text: &str) -> bool {
!text.trim().is_empty()
}
/// Removes messages whose content array ended up empty (e.g. a turn that carried
/// only empty text that was filtered out). Anthropic 400s on empty content.
fn drop_empty_messages(messages: &mut Vec<Value>) {
messages.retain(|msg| {
msg.get("content")
.and_then(|c| c.as_array())
.map(|arr| !arr.is_empty())
.unwrap_or(true)
});
}
/// Appends a content block to messages: merge if the last message has the same role, otherwise create a new message.
fn push_block(messages: &mut Vec<Value>, role: &str, block: Value) {
if let Some(last) = messages.last_mut() {
if last.get("role").and_then(|r| r.as_str()) == Some(role) {
if let Some(arr) = last.get_mut("content").and_then(|c| c.as_array_mut()) {
arr.push(block);
return;
}
}
}
messages.push(json!({
"role": role,
"content": [block]
}));
}
/// Appends a tool result to a user turn while preserving Anthropic's required
/// ordering: every tool_result block must precede any text or image blocks.
fn push_tool_result_block(messages: &mut Vec<Value>, block: Value) {
if let Some(last) = messages.last_mut() {
if last.get("role").and_then(Value::as_str) == Some("user") {
if let Some(content) = last.get_mut("content").and_then(Value::as_array_mut) {
let insert_at = content
.iter()
.position(|item| {
item.get("type").and_then(Value::as_str) != Some("tool_result")
})
.unwrap_or(content.len());
content.insert(insert_at, block);
return;
}
}
}
messages.push(json!({
"role": "user",
"content": [block]
}));
}
fn push_assistant_thinking_block(messages: &mut Vec<Value>, block: Value) {
if let Some(last) = messages.last_mut() {
if last.get("role").and_then(Value::as_str) == Some("assistant") {
if let Some(content) = last.get_mut("content").and_then(Value::as_array_mut) {
let index = content
.iter()
.take_while(|item| {
matches!(
item.get("type").and_then(Value::as_str),
Some("thinking" | "redacted_thinking")
)
})
.count();
content.insert(index, block);
return;
}
}
}
push_block(messages, "assistant", block);
}
/// Responses' input_image → Anthropic image block.
fn image_block_from_input_image(part: &Value) -> Option<Value> {
let url = part.get("image_url").and_then(|v| {
v.as_str()
.map(str::to_string)
.or_else(|| v.get("url").and_then(|u| u.as_str()).map(str::to_string))
})?;
if let Some(rest) = url.strip_prefix("data:") {
// data:<media_type>;base64,<data>
let (meta, data) = rest.split_once(',')?;
let media_type = meta.split(';').next().unwrap_or("image/png");
Some(json!({
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": data
}
}))
} else if url.starts_with("http://") || url.starts_with("https://") {
Some(json!({
"type": "image",
"source": { "type": "url", "url": url }
}))
} else {
None
}
}
/// Responses' input_file → Anthropic document block.
fn document_block_from_input_file(part: &Value) -> Option<Value> {
let filename = part
.get("filename")
.and_then(Value::as_str)
.filter(|value| !value.is_empty());
let mut block = if let Some(file_url) = part
.get("file_url")
.and_then(Value::as_str)
.filter(|url| url.starts_with("http://") || url.starts_with("https://"))
{
json!({
"type":"document",
"source":{"type":"url","url":file_url}
})
} else {
let file_data = part.get("file_data").and_then(Value::as_str)?;
let rest = file_data.strip_prefix("data:")?;
let (meta, data) = rest.split_once(',')?;
if data.is_empty() {
return None;
}
let media_type = meta.split(';').next().unwrap_or("application/pdf");
json!({
"type":"document",
"source":{
"type":"base64",
"media_type":media_type,
"data":data
}
})
};
if let Some(filename) = filename {
block["title"] = json!(filename);
}
Some(block)
}
/// Anthropic Messages response → OpenAI Responses response (non-streaming)
#[allow(dead_code)]
pub fn anthropic_response_to_responses(body: Value) -> Result<Value, ProxyError> {
anthropic_response_to_responses_with_context(body, &CodexToolContext::default())
}
pub(crate) fn anthropic_response_to_responses_with_context(
body: Value,
tool_context: &CodexToolContext,
) -> Result<Value, ProxyError> {
if body.get("type").and_then(Value::as_str) == Some("error") || body.get("error").is_some() {
let error = body.get("error").unwrap_or(&body);
let message = error
.get("message")
.and_then(Value::as_str)
.or_else(|| error.as_str())
.unwrap_or("Anthropic upstream returned an error envelope");
let error_type = error.get("type").and_then(Value::as_str).unwrap_or("error");
return Err(ProxyError::TransformError(format!(
"Anthropic upstream {error_type}: {message}"
)));
}
let id = body.get("id").and_then(|i| i.as_str()).unwrap_or("");
let response_id = if id.is_empty() {
"resp_ccswitch".to_string()
} else if id.starts_with("resp_") {
id.to_string()
} else {
format!("resp_{id}")
};
let model = body.get("model").and_then(|m| m.as_str()).unwrap_or("");
let mut output: Vec<Value> = Vec::new();
let mut text_parts: Vec<Value> = Vec::new();
let flush_text = |output: &mut Vec<Value>, text_parts: &mut Vec<Value>| {
if !text_parts.is_empty() {
let idx = output.len();
output.push(json!({
"id": format!("{response_id}_msg_{idx}"),
"type": "message",
"status": "completed",
"role": "assistant",
"content": std::mem::take(text_parts)
}));
}
};
if let Some(blocks) = body.get("content").and_then(|c| c.as_array()) {
for block in blocks {
match block.get("type").and_then(|t| t.as_str()).unwrap_or("") {
"text" => {
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
text_parts.push(json!({
"type": "output_text",
"text": text,
"annotations": []
}));
}
}
"tool_use" => {
flush_text(&mut output, &mut text_parts);
let call_id = block.get("id").and_then(|v| v.as_str()).unwrap_or("");
let name = block.get("name").and_then(|v| v.as_str()).unwrap_or("");
let input = block.get("input").cloned().unwrap_or(json!({}));
let input = sanitize_anthropic_tool_use_input(name, input);
let item_id =
response_tool_call_item_id_from_chat_name(call_id, name, tool_context);
output.push(response_tool_call_item_from_chat_name(
&item_id,
"completed",
call_id,
name,
&canonical_json_string(&input),
None,
tool_context,
));
}
"thinking" | "redacted_thinking" => {
flush_text(&mut output, &mut text_parts);
let idx = output.len();
if let Some(item) = responses_reasoning_item_from_anthropic_block(
&format!("rs_{response_id}_{idx}"),
block,
) {
output.push(item);
}
}
_ => {}
}
}
}
flush_text(&mut output, &mut text_parts);
let (status, incomplete_reason) =
map_anthropic_stop_reason_to_status(body.get("stop_reason").and_then(|s| s.as_str()));
let usage = build_responses_usage_from_anthropic(body.get("usage"));
let mut result = json!({
"id": response_id,
"object": "response",
"created_at": 0,
"status": status,
"model": model,
"output": output,
"usage": usage
});
if let Some(reason) = incomplete_reason {
result["incomplete_details"] = json!({ "reason": reason });
}
Ok(result)
}
/// Aggregates an Anthropic Messages **SSE stream** (with no Content-Type marker)
/// back into a single Anthropic non-streaming message JSON.
///
/// Used as a fallback: the upstream returned an SSE body for a `stream:false`
/// request but without the `text/event-stream` header (symmetric to the
/// `body_looks_like_sse` fallback on the chat / claude side, see #2234). The
/// aggregated message can be handed directly to [`anthropic_response_to_responses`].
///
/// It also tolerates the last event missing a trailing blank line (truncated
/// stream): after looping over complete event blocks, it processes the residual
/// buffer as the last event.
pub fn anthropic_sse_to_message_value(body: &str) -> Result<Value, ProxyError> {
let mut message: Option<Value> = None;
// Collect blocks by content index along with the partial_json accumulator for their tool_use.
let mut blocks: BTreeMap<u64, Value> = BTreeMap::new();
let mut json_accum: BTreeMap<u64, String> = BTreeMap::new();
let mut stop_reason: Option<String> = None;
let mut delta_output_tokens: Option<u64> = None;
let mut saw_message_stop = false;
let mut buffer = body.to_string();
let process_block = |block: &str,
message: &mut Option<Value>,
blocks: &mut BTreeMap<u64, Value>,
json_accum: &mut BTreeMap<u64, String>,
stop_reason: &mut Option<String>,
delta_output_tokens: &mut Option<u64>,
saw_message_stop: &mut bool|
-> Result<(), ProxyError> {
let mut data = String::new();
for line in block.lines() {
if let Some(chunk) = strip_sse_field(line, "data") {
if !data.is_empty() {
data.push('\n');
}
data.push_str(chunk);
}
}
if data.trim().is_empty() || data.trim() == "[DONE]" {
return Ok(());
}
let value: Value = match serde_json::from_str(data.trim()) {
Ok(v) => v,
Err(_) => return Ok(()), // Skip events that cannot be parsed (ping, etc.)
};
match value.get("type").and_then(|t| t.as_str()).unwrap_or("") {
"message_start" => {
if let Some(msg) = value.get("message") {
*message = Some(msg.clone());
}
}
"content_block_start" => {
if let Some(index) = value.get("index").and_then(|v| v.as_u64()) {
let block = value.get("content_block").cloned().unwrap_or(json!({}));
blocks.insert(index, block);
json_accum.entry(index).or_default();
}
}
"content_block_delta" => {
if let Some(index) = value.get("index").and_then(|v| v.as_u64()) {
let delta = value.get("delta").cloned().unwrap_or(json!({}));
match delta.get("type").and_then(|t| t.as_str()).unwrap_or("") {
"text_delta" => {
if let Some(text) = delta.get("text").and_then(|t| t.as_str()) {
append_str_field(
blocks.entry(index).or_insert(json!({})),
"text",
text,
);
}
}
"thinking_delta" => {
if let Some(text) = delta.get("thinking").and_then(|t| t.as_str()) {
append_str_field(
blocks.entry(index).or_insert(json!({})),
"thinking",
text,
);
}
}
"signature_delta" => {
if let Some(sig) = delta.get("signature").and_then(|t| t.as_str()) {
blocks.entry(index).or_insert(json!({}))["signature"] = json!(sig);
}
}
"input_json_delta" => {
if let Some(partial) =
delta.get("partial_json").and_then(|t| t.as_str())
{
json_accum.entry(index).or_default().push_str(partial);
}
}
_ => {}
}
}
}
"content_block_stop" => {
if let Some(index) = value.get("index").and_then(|v| v.as_u64()) {
if let Some(accum) = json_accum.get(&index) {
if !accum.trim().is_empty() {
let parsed: Value =
serde_json::from_str(accum).unwrap_or_else(|_| json!({}));
if let Some(block) = blocks.get_mut(&index) {
block["input"] = parsed;
}
}
}
}
}
"message_delta" => {
if let Some(reason) = value.pointer("/delta/stop_reason").and_then(|v| v.as_str()) {
*stop_reason = Some(reason.to_string());
}
if let Some(output) = value
.pointer("/usage/output_tokens")
.and_then(|v| v.as_u64())
{
*delta_output_tokens = Some(output);
}
}
"message_stop" => *saw_message_stop = true,
"error" => {
let msg = value
.pointer("/error/message")
.and_then(|v| v.as_str())
.unwrap_or("upstream anthropic SSE error");
return Err(ProxyError::TransformError(format!(
"anthropic SSE error event: {msg}"
)));
}
_ => {}
}
Ok(())
};
while let Some(block) = take_sse_block(&mut buffer) {
process_block(
&block,
&mut message,
&mut blocks,
&mut json_accum,
&mut stop_reason,
&mut delta_output_tokens,
&mut saw_message_stop,
)?;
}
// Tolerate the last event missing a trailing blank line (truncated stream).
if !buffer.trim().is_empty() {
process_block(
&buffer.clone(),
&mut message,
&mut blocks,
&mut json_accum,
&mut stop_reason,
&mut delta_output_tokens,
&mut saw_message_stop,
)?;
}
let mut message = message.ok_or_else(|| {
ProxyError::TransformError(
"anthropic SSE aggregation: missing message_start event".to_string(),
)
})?;
if !saw_message_stop && stop_reason.is_none() {
if blocks.is_empty() {
return Err(ProxyError::TransformError(
"anthropic SSE aggregation: stream ended before message_stop".to_string(),
));
}
// Preserve partial content but make the truncation visible to Codex instead
// of returning a normal completed response.
stop_reason = Some("max_tokens".to_string());
}
// Merge in the content blocks (ordered by index), stop_reason, and the cumulative output_tokens.
let content: Vec<Value> = blocks.into_values().collect();
message["content"] = json!(content);
if let Some(reason) = stop_reason {
message["stop_reason"] = json!(reason);
}
if let Some(output) = delta_output_tokens {
// message_delta's usage.output_tokens is a cumulative value, overriding the 0 from message_start.
if let Some(usage) = message.get_mut("usage").and_then(|u| u.as_object_mut()) {
usage.insert("output_tokens".to_string(), json!(output));
}
}
Ok(message)
}
/// Appends content to a string field of a JSON object (creating it if absent).
fn append_str_field(block: &mut Value, field: &str, text: &str) {
let existing = block
.get(field)
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
block[field] = json!(format!("{existing}{text}"));
}
#[cfg(test)]
mod tests {
use super::*;
// ==================== Request: Responses → Anthropic ====================
#[test]
fn test_request_simple_text() {
let input = json!({
"model": "claude-3-5-sonnet",
"max_output_tokens": 1024,
"input": [
{ "role": "user", "content": [{ "type": "input_text", "text": "Hello" }] }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert_eq!(result["model"], "claude-3-5-sonnet");
assert_eq!(result["max_tokens"], 1024);
assert_eq!(result["messages"][0]["role"], "user");
assert_eq!(result["messages"][0]["content"][0]["type"], "text");
assert_eq!(result["messages"][0]["content"][0]["text"], "Hello");
}
#[test]
fn test_request_missing_max_output_tokens_injects_default() {
let input = json!({
"model": "claude",
"input": [{ "role": "user", "content": "Hi" }]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert_eq!(result["max_tokens"], 4096);
}
#[test]
fn test_request_instructions_to_system() {
let input = json!({
"model": "claude",
"max_output_tokens": 100,
"instructions": "You are helpful.",
"input": [{ "role": "user", "content": "hi" }]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert_eq!(result["system"], "You are helpful.");
}
#[test]
fn test_request_system_and_developer_history_are_hoisted() {
let input = json!({
"model":"claude",
"instructions":"base",
"input":[
{"role":"system","content":"system history"},
{"role":"developer","content":[{"type":"input_text","text":"developer history"}]},
{"role":"user","content":"hi"}
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert_eq!(
result["system"],
"base\n\nsystem history\n\ndeveloper history"
);
assert_eq!(result["messages"].as_array().unwrap().len(), 1);
assert_eq!(result["messages"][0]["role"], "user");
}
#[test]
fn test_request_no_instructions_no_system() {
let input = json!({
"model": "claude",
"max_output_tokens": 100,
"input": [{ "role": "user", "content": "hi" }]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert!(result.get("system").is_none());
}
#[test]
fn test_request_tools_and_filtering() {
let input = json!({
"model": "claude",
"max_output_tokens": 100,
"input": [{ "role": "user", "content": "hi" }],
"tools": [
{ "type": "function", "name": "get_weather", "description": "d", "parameters": {"type": "object"} },
{ "type": "web_search" },
{ "type": "custom", "name": "apply_patch" }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let tools = result["tools"].as_array().unwrap();
assert_eq!(tools.len(), 2);
assert_eq!(tools[0]["name"], "get_weather");
assert_eq!(tools[0]["input_schema"]["type"], "object");
assert!(tools[0].get("parameters").is_none());
assert_eq!(tools[1]["name"], "apply_patch");
}
#[test]
fn test_request_tool_choice_mapping() {
// A function tool must be present, else tool_choice is (correctly) dropped.
let base = |tc: Value| {
json!({
"model": "c", "max_output_tokens": 100,
"input": [{ "role": "user", "content": "hi" }],
"tools": [{ "type": "function", "name": "x", "parameters": {"type": "object"} }],
"tool_choice": tc
})
};
assert_eq!(
responses_request_to_anthropic(base(json!("required")), 4096).unwrap()["tool_choice"],
json!({"type": "any"})
);
assert_eq!(
responses_request_to_anthropic(base(json!("auto")), 4096).unwrap()["tool_choice"],
json!({"type": "auto"})
);
assert_eq!(
responses_request_to_anthropic(base(json!({"type": "function", "name": "x"})), 4096)
.unwrap()["tool_choice"],
json!({"type": "tool", "name": "x"})
);
}
#[test]
fn test_request_function_call_renests_into_assistant_tool_use() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [
{ "role": "assistant", "content": [{ "type": "output_text", "text": "Let me check" }] },
{ "type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{\"city\":\"Tokyo\"}" },
{ "type": "function_call_output", "call_id": "call_1", "output": "sunny" }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let messages = result["messages"].as_array().unwrap();
// The assistant-first history is normalized: a synthetic user message is
// prepended, and the complete assistant tool turn remains intact.
assert_eq!(messages.len(), 3);
assert_eq!(messages[0]["role"], "user");
assert_eq!(messages[1]["role"], "assistant");
assert_eq!(messages[1]["content"][0]["type"], "text");
assert_eq!(messages[1]["content"][1]["type"], "tool_use");
assert_eq!(messages[1]["content"][1]["id"], "call_1");
assert_eq!(messages[1]["content"][1]["input"]["city"], "Tokyo");
}
#[test]
fn test_request_function_call_outputs_merge_into_one_user_message() {
// Consecutive function_call_output items (each paired with a preceding
// function_call) merge into a single user message of tool_result blocks.
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [
{ "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" },
{ "type": "function_call", "call_id": "c2", "name": "t", "arguments": "{}" },
{ "type": "function_call_output", "call_id": "c1", "output": "A" },
{ "type": "function_call_output", "call_id": "c2", "output": "B" }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let messages = result["messages"].as_array().unwrap();
// Leading assistant history is normalized with a synthetic leading user.
let last = messages.last().unwrap();
assert_eq!(last["role"], "user");
let content = last["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "tool_result");
assert_eq!(content[0]["tool_use_id"], "c1");
assert_eq!(content[0]["content"], "A");
assert_eq!(content[1]["tool_use_id"], "c2");
}
#[test]
fn test_request_unanswered_trailing_tool_use_is_dropped() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [
{ "role": "user", "content": "run it" },
{ "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let messages = result["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["role"], "user");
assert_eq!(messages[0]["content"][0]["text"], "run it");
}
#[test]
fn test_request_partial_parallel_tool_turn_is_dropped_as_a_unit() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [
{ "role": "user", "content": "run both" },
{ "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" },
{ "type": "function_call", "call_id": "c2", "name": "t", "arguments": "{}" },
{ "type": "function_call_output", "call_id": "c1", "output": "one" },
{ "role": "user", "content": [{ "type": "input_text", "text": "continue" }] }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let messages = result["messages"].as_array().unwrap();
assert!(messages.iter().all(|message| {
message["content"].as_array().unwrap().iter().all(|block| {
!matches!(
block.get("type").and_then(Value::as_str),
Some("tool_use" | "tool_result")
)
})
}));
assert_eq!(messages.last().unwrap()["content"][0]["text"], "continue");
}
#[test]
fn test_request_tool_results_precede_user_text() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [
{ "role": "user", "content": "run it" },
{ "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" },
{ "role": "user", "content": [{ "type": "input_text", "text": "then explain" }] },
{ "type": "function_call_output", "call_id": "c1", "output": "ok" }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let content = result["messages"].as_array().unwrap().last().unwrap()["content"]
.as_array()
.unwrap();
assert_eq!(content[0]["type"], "tool_result");
assert_eq!(content[0]["tool_use_id"], "c1");
assert_eq!(content[1]["type"], "text");
assert_eq!(content[1]["text"], "then explain");
}
#[test]
fn test_request_orphan_tool_result_dropped() {
// A function_call_output whose matching function_call was dropped (e.g. by
// compaction) becomes an orphan tool_result; it must be removed so Anthropic
// does not 400, while the rest of the turn is preserved.
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [
{ "type": "function_call_output", "call_id": "ghost", "output": "X" },
{ "role": "user", "content": [{ "type": "input_text", "text": "hello" }] }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let messages = result["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["role"], "user");
let content = messages[0]["content"].as_array().unwrap();
// Only the text survives; the orphan tool_result is gone.
assert_eq!(content.len(), 1);
assert_eq!(content[0]["type"], "text");
assert_eq!(content[0]["text"], "hello");
}
#[test]
fn test_request_empty_text_blocks_dropped() {
// An empty/whitespace-only assistant text emitted alongside a tool_use must be
// filtered out (Anthropic 400s on empty text blocks), keeping the tool_use, and
// a user turn made up solely of empty text must not leave an empty message.
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [
{ "role": "user", "content": [{ "type": "input_text", "text": "hi" }] },
{ "role": "assistant", "content": [{ "type": "output_text", "text": "" }] },
{ "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" },
{ "type": "function_call_output", "call_id": "c1", "output": "ok" },
{ "role": "user", "content": [{ "type": "input_text", "text": " " }] }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let messages = result["messages"].as_array().unwrap();
// Empty assistant text is gone; no message carries an empty text block.
for msg in messages {
for block in msg["content"].as_array().unwrap() {
if block["type"] == "text" {
assert!(!block["text"].as_str().unwrap().trim().is_empty());
}
}
assert!(!msg["content"].as_array().unwrap().is_empty());
}
// The whitespace-only trailing user turn collapsed into the tool_result user message.
let last = messages.last().unwrap();
assert_eq!(last["role"], "user");
assert_eq!(last["content"][0]["type"], "tool_result");
}
#[test]
fn test_request_all_orphan_tool_results_error() {
// If the entire input is orphan tool_results, dropping them empties the
// message list and conversion errors (nothing valid to send to Anthropic).
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [
{ "type": "function_call_output", "call_id": "c1", "output": "A" },
{ "type": "function_call_output", "call_id": "c2", "output": "B" }
]
});
assert!(responses_request_to_anthropic(input, 4096).is_err());
}
#[test]
fn test_request_paired_tool_result_kept() {
// A tool_result whose function_call is present survives the orphan guard.
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [
{ "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" },
{ "type": "function_call_output", "call_id": "c1", "output": "ok" }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let messages = result["messages"].as_array().unwrap();
let last = messages.last().unwrap();
assert_eq!(last["role"], "user");
assert_eq!(last["content"][0]["type"], "tool_result");
assert_eq!(last["content"][0]["tool_use_id"], "c1");
}
#[test]
fn test_request_empty_arguments_parses_to_empty_object() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [
{ "type": "function_call", "call_id": "c1", "name": "t", "arguments": "" },
{ "type": "function_call_output", "call_id": "c1", "output": "ok" }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
// function_call at the head → a synthetic user is prepended, tool_use is in the second assistant message.
assert_eq!(result["messages"][1]["content"][0]["input"], json!({}));
}
#[test]
fn test_request_invalid_or_non_object_arguments_error() {
for arguments in ["{broken", "[1,2]"] {
let input = json!({
"model":"c",
"input":[
{"type":"function_call","call_id":"c1","name":"t","arguments":arguments},
{"type":"function_call_output","call_id":"c1","output":"ok"}
]
});
assert!(matches!(
responses_request_to_anthropic(input, 4096),
Err(ProxyError::InvalidRequest(_))
));
}
}
#[test]
fn test_request_drops_incomplete_tool_call_and_orphaned_output() {
let input = json!({
"model":"c",
"input":[
{"role":"user","content":[{"type":"input_text","text":"run it"}]},
{
"type":"function_call",
"call_id":"c1",
"name":"exec",
"arguments":"{\"cmd\":",
"status":"incomplete"
},
{"type":"function_call_output","call_id":"c1","output":"never ran"}
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let serialized = result["messages"].to_string();
assert!(!serialized.contains("tool_use"));
assert!(!serialized.contains("tool_result"));
assert!(serialized.contains("run it"));
}
#[test]
fn test_request_image_data_url() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [{
"role": "user",
"content": [
{ "type": "input_text", "text": "what?" },
{ "type": "input_image", "image_url": "data:image/png;base64,abc123" }
]
}]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let content = result["messages"][0]["content"].as_array().unwrap();
assert_eq!(content[1]["type"], "image");
assert_eq!(content[1]["source"]["type"], "base64");
assert_eq!(content[1]["source"]["media_type"], "image/png");
assert_eq!(content[1]["source"]["data"], "abc123");
}
#[test]
fn test_request_top_level_content_items() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [
{ "type": "input_text", "text": "what?" },
{ "type": "input_image", "image_url": "data:image/png;base64,abc123" }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let messages = result["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["role"], "user");
let content = messages[0]["content"].as_array().unwrap();
assert_eq!(content[0]["type"], "text");
assert_eq!(content[0]["text"], "what?");
assert_eq!(content[1]["type"], "image");
assert_eq!(content[1]["source"]["type"], "base64");
assert_eq!(content[1]["source"]["data"], "abc123");
}
#[test]
fn test_request_image_http_url() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [{
"role": "user",
"content": [{ "type": "input_image", "image_url": "https://x/y.png" }]
}]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let block = &result["messages"][0]["content"][0];
assert_eq!(block["type"], "image");
assert_eq!(block["source"]["type"], "url");
assert_eq!(block["source"]["url"], "https://x/y.png");
}
#[test]
fn test_request_effort_to_thinking_and_drops_temperature() {
let input = json!({
"model": "c",
// Well above 2× the high-effort budget so the output-headroom cap
// (max_tokens/2) does not clamp it — this test covers effort mapping.
"max_output_tokens": 40000,
"temperature": 0.7,
"top_p": 0.9,
"reasoning": { "effort": "high" },
"input": [{ "role": "user", "content": "hi" }]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert_eq!(result["thinking"]["type"], "enabled");
assert_eq!(result["thinking"]["budget_tokens"], 16384);
assert!(result.get("temperature").is_none());
assert!(result.get("top_p").is_none());
}
#[test]
fn test_adaptive_thinking_respects_model_defaults() {
let request = |model: &str| {
json!({
"model": model,
"max_output_tokens": 4096,
"input": [{"role": "user", "content": "hi"}]
})
};
let sonnet = responses_request_to_anthropic(request("claude-sonnet-5"), 4096).unwrap();
assert_eq!(sonnet["thinking"]["type"], "adaptive");
let opus = responses_request_to_anthropic(request("claude-opus-4.8"), 4096).unwrap();
assert!(opus.get("thinking").is_none());
}
#[test]
fn test_request_unknown_effort_keeps_sampling_params() {
// An unrecognized effort should not enable thinking, nor swallow temperature/top_p.
let input = json!({
"model": "c",
"max_output_tokens": 20000,
"temperature": 0.7,
"top_p": 0.9,
"reasoning": { "effort": "turbo" },
"input": [{ "role": "user", "content": "hi" }]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert!(result.get("thinking").is_none());
assert_eq!(result["temperature"], 0.7);
assert_eq!(result["top_p"], 0.9);
}
#[test]
fn test_request_tool_history_disables_thinking() {
// When tool history is present (function_call_output), do not inject thinking
// even if effort is set, and fall back to passing temperature/top_p through,
// to avoid the Anthropic 400 caused by a missing thinking block.
let input = json!({
"model": "c",
"max_output_tokens": 20000,
"temperature": 0.5,
"reasoning": { "effort": "high" },
"input": [
{ "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" },
{ "type": "function_call_output", "call_id": "c1", "output": "ok" }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert!(result.get("thinking").is_none());
assert_eq!(result["temperature"], 0.5);
}
#[test]
fn test_older_signed_turn_does_not_enable_thinking_for_unsigned_tool_call() {
let encrypted = encode_anthropic_thinking_block(&json!({
"type": "thinking",
"thinking": "old reasoning",
"signature": "sig_old"
}))
.unwrap();
let input = json!({
"model": "claude-sonnet-5",
"max_output_tokens": 4096,
"reasoning": { "effort": "high" },
"input": [
{ "role": "user", "content": "first question" },
{ "type": "reasoning", "encrypted_content": encrypted },
{ "role": "assistant", "content": [{ "type": "output_text", "text": "first answer" }] },
{ "role": "user", "content": "call the tool" },
{ "type": "function_call", "call_id": "c2", "name": "Read", "arguments": "{}" },
{ "type": "function_call_output", "call_id": "c2", "output": "ok" }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert_eq!(result["thinking"]["type"], "disabled");
let messages = result["messages"].as_array().unwrap();
let paired_assistant = &messages[messages.len() - 2];
assert_eq!(paired_assistant["role"], "assistant");
assert_eq!(paired_assistant["content"][0]["type"], "tool_use");
}
#[test]
fn test_thinking_requires_all_parallel_tool_results_to_match_paired_turn() {
let paired = json!([
{"role": "assistant", "content": [
{"type": "thinking", "thinking": "reason", "signature": "sig"},
{"type": "tool_use", "id": "c1", "name": "a", "input": {}},
{"type": "tool_use", "id": "c2", "name": "b", "input": {}}
]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "c1", "content": "one"},
{"type": "tool_result", "tool_use_id": "c2", "content": "two"}
]}
]);
assert!(trailing_turn_supports_thinking(paired.as_array().unwrap()));
let mismatched = json!([
paired[0].clone(),
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "c1", "content": "one"},
{"type": "tool_result", "tool_use_id": "c3", "content": "three"}
]}
]);
assert!(!trailing_turn_supports_thinking(
mismatched.as_array().unwrap()
));
}
#[test]
fn test_request_completed_tool_round_reenables_thinking() {
// A *completed* tool round (assistant answered after the tool_result) followed
// by a fresh user question: the trailing turn is text-only, so thinking must be
// re-enabled — unlike a whole-history scan, which would stay off forever.
let input = json!({
"model": "c",
"max_output_tokens": 20000,
"reasoning": { "effort": "high" },
"input": [
{ "role": "user", "content": [{ "type": "input_text", "text": "hi" }] },
{ "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" },
{ "type": "function_call_output", "call_id": "c1", "output": "ok" },
{ "role": "assistant", "content": [{ "type": "output_text", "text": "done" }] },
{ "role": "user", "content": [{ "type": "input_text", "text": "next question" }] }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
// The last message is a text-only user turn → not mid tool-cycle → thinking on.
let messages = result["messages"].as_array().unwrap();
let last = messages.last().unwrap();
assert_eq!(last["role"], "user");
assert_eq!(last["content"][0]["type"], "text");
assert_eq!(result["thinking"]["type"], "enabled");
}
#[test]
fn test_request_custom_tool_survives_with_required_choice() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [{ "role": "user", "content": "hi" }],
"tools": [
{ "type": "web_search" },
{ "type": "custom", "name": "apply_patch" }
],
"tool_choice": "required"
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert_eq!(result["tools"].as_array().unwrap().len(), 1);
assert_eq!(result["tools"][0]["name"], "apply_patch");
assert_eq!(result["tool_choice"], json!({ "type": "any" }));
}
#[test]
fn test_request_forced_tool_choice_disables_thinking_instead_of_downgrading() {
let input = json!({
"model": "c",
"max_output_tokens": 20000,
"reasoning": { "effort": "high" },
"tools": [{ "type": "function", "name": "x", "parameters": {"type": "object"} }],
"tool_choice": "required",
"input": [
{ "role": "user", "content": [{ "type": "input_text", "text": "hi" }] }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert_eq!(result["thinking"]["type"], "disabled");
assert_eq!(result["tool_choice"], json!({ "type": "any" }));
}
#[test]
fn test_request_forced_tool_choice_kept_without_thinking() {
// With no effort (thinking off), a forced tool_choice is kept as-is.
let input = json!({
"model": "c",
"max_output_tokens": 100,
"tools": [{ "type": "function", "name": "x", "parameters": {"type": "object"} }],
"tool_choice": "required",
"input": [{ "role": "user", "content": "hi" }]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert_eq!(result["tool_choice"], json!({ "type": "any" }));
}
#[test]
fn test_request_small_max_tokens_disables_thinking() {
// The chosen effort budget is clamped below max_tokens; after clamping it is
// < 1024 → disable thinking, fall back to sampling, and do not raise the
// caller's max_tokens (to avoid exceeding the model's output ceiling and 400).
let input = json!({
"model": "c",
"max_output_tokens": 1000,
"temperature": 0.7,
"reasoning": { "effort": "high" },
"input": [{ "role": "user", "content": "hi" }]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert!(result.get("thinking").is_none());
assert_eq!(result["max_tokens"], 1000);
assert_eq!(result["temperature"], 0.7);
}
#[test]
fn test_request_thinking_budget_clamped_below_max_tokens() {
// The chosen effort budget exceeds half of max_tokens, so it is capped at
// max_tokens/2 (reserving the other half for the visible answer) while staying
// >= 1024, so thinking stays enabled.
let input = json!({
"model": "c",
"max_output_tokens": 5000,
"reasoning": { "effort": "high" }, // budget 16384
"input": [{ "role": "user", "content": "hi" }]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert_eq!(result["thinking"]["type"], "enabled");
assert_eq!(result["thinking"]["budget_tokens"], 2500);
assert_eq!(result["max_tokens"], 5000);
}
#[test]
fn test_request_default_max_tokens_leaves_output_headroom() {
// Regression: on the no-max_output_tokens fallback path, a large derived thinking
// budget must not consume nearly all of the default max_tokens. With default 8192
// and high effort (16384), the budget is capped at 8192/2 = 4096, leaving 4096 for
// the visible answer (previously it clamped to 8191, leaving ~1 output token).
let input = json!({
"model": "c",
"reasoning": { "effort": "high" },
"input": [{ "role": "user", "content": "hi" }]
});
let result = responses_request_to_anthropic(input, 8192).unwrap();
assert_eq!(result["thinking"]["type"], "enabled");
assert_eq!(result["thinking"]["budget_tokens"], 4096);
assert_eq!(result["max_tokens"], 8192);
assert!(
result["max_tokens"].as_u64().unwrap()
- result["thinking"]["budget_tokens"].as_u64().unwrap()
>= 4096,
"at least half of max_tokens must remain for the visible answer"
);
}
#[test]
fn test_request_unpaired_tool_turn_is_dropped_before_thinking_gate() {
// A resumed/compacted history ending in an unpaired assistant tool_use is
// removed, leaving the original user prompt as a fresh thinking turn.
let input = json!({
"model": "c",
"max_output_tokens": 40000,
"reasoning": { "effort": "high" },
"input": [
{ "role": "user", "content": [{ "type": "input_text", "text": "run it" }] },
{ "type": "function_call", "call_id": "c1", "name": "sh", "arguments": "{}" }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let messages = result["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["role"], "user");
assert_eq!(result["thinking"]["type"], "enabled");
}
#[test]
fn test_request_reasoning_item_dropped() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [
{ "type": "reasoning", "id": "rs_1", "encrypted_content": "xxx" },
{ "role": "user", "content": [{ "type": "input_text", "text": "hi" }] }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let messages = result["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["role"], "user");
}
#[test]
fn test_request_drops_openai_only_fields() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"store": false,
"include": ["reasoning.encrypted_content"],
"service_tier": "priority",
"parallel_tool_calls": true,
"input": [{ "role": "user", "content": "hi" }]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert!(result.get("store").is_none());
assert!(result.get("include").is_none());
assert!(result.get("service_tier").is_none());
assert!(result.get("parallel_tool_calls").is_none());
}
// ==================== Response: Anthropic → Responses ====================
#[test]
fn test_response_text_end_turn() {
let input = json!({
"id": "msg_1",
"type": "message",
"role": "assistant",
"model": "claude",
"content": [{ "type": "text", "text": "Hello!" }],
"stop_reason": "end_turn",
"usage": { "input_tokens": 10, "output_tokens": 5 }
});
let result = anthropic_response_to_responses(input).unwrap();
assert_eq!(result["id"], "resp_msg_1");
assert_eq!(result["status"], "completed");
assert_eq!(result["output"][0]["type"], "message");
assert_eq!(result["output"][0]["content"][0]["type"], "output_text");
assert_eq!(result["output"][0]["content"][0]["text"], "Hello!");
assert_eq!(result["usage"]["input_tokens"], 10);
assert_eq!(result["usage"]["output_tokens"], 5);
assert_eq!(result["usage"]["total_tokens"], 15);
}
#[test]
fn test_response_tool_use() {
let input = json!({
"id": "msg_1",
"content": [{
"type": "tool_use",
"id": "call_1",
"name": "get_weather",
"input": { "city": "Tokyo" }
}],
"stop_reason": "tool_use",
"usage": { "input_tokens": 10, "output_tokens": 15 }
});
let result = anthropic_response_to_responses(input).unwrap();
assert_eq!(result["status"], "completed");
assert_eq!(result["output"][0]["type"], "function_call");
assert_eq!(result["output"][0]["call_id"], "call_1");
assert_eq!(result["output"][0]["name"], "get_weather");
assert_eq!(result["output"][0]["arguments"], "{\"city\":\"Tokyo\"}");
}
#[test]
fn test_response_thinking_becomes_reasoning() {
let input = json!({
"id": "msg_1",
"content": [
{ "type": "thinking", "thinking": "Let me think", "signature": "sig" },
{ "type": "text", "text": "answer" }
],
"stop_reason": "end_turn",
"usage": { "input_tokens": 1, "output_tokens": 2 }
});
let result = anthropic_response_to_responses(input).unwrap();
assert_eq!(result["output"][0]["type"], "reasoning");
assert_eq!(result["output"][0]["summary"][0]["text"], "Let me think");
assert_eq!(result["output"][1]["type"], "message");
}
#[test]
fn test_unsigned_thinking_is_not_replayed_as_encrypted_reasoning() {
let input = json!({
"id":"msg_unsigned",
"content":[
{"type":"thinking","thinking":"unsigned"},
{"type":"text","text":"answer"}
],
"stop_reason":"end_turn"
});
let result = anthropic_response_to_responses(input).unwrap();
assert_eq!(result["output"].as_array().unwrap().len(), 1);
assert_eq!(result["output"][0]["type"], "message");
}
#[test]
fn test_response_max_tokens_incomplete() {
let input = json!({
"id": "msg_1",
"content": [{ "type": "text", "text": "partial" }],
"stop_reason": "max_tokens",
"usage": { "input_tokens": 1, "output_tokens": 2 }
});
let result = anthropic_response_to_responses(input).unwrap();
assert_eq!(result["status"], "incomplete");
assert_eq!(result["incomplete_details"]["reason"], "max_output_tokens");
}
#[test]
fn test_response_usage_cache_no_double_count() {
let input = json!({
"id": "msg_1",
"content": [{ "type": "text", "text": "x" }],
"stop_reason": "end_turn",
"usage": {
"input_tokens": 20,
"output_tokens": 5,
"output_tokens_details": {"thinking_tokens": 3},
"cache_read_input_tokens": 60,
"cache_creation_input_tokens": 20
}
});
let result = anthropic_response_to_responses(input).unwrap();
// Responses input_tokens is the inclusive total: fresh + read + write.
assert_eq!(result["usage"]["input_tokens"], 100);
assert_eq!(result["usage"]["output_tokens"], 5);
assert_eq!(
result["usage"]["output_tokens_details"]["reasoning_tokens"],
3
);
// total includes input total + output exactly once.
assert_eq!(result["usage"]["total_tokens"], 105);
assert_eq!(result["usage"]["input_tokens_details"]["cached_tokens"], 60);
assert_eq!(
result["usage"]["input_tokens_details"]["cache_write_tokens"],
20
);
// Aggregate cache creation is exposed for downstream billing attribution (counted only once).
assert_eq!(result["usage"]["cache_creation_input_tokens"], 20);
}
#[test]
fn test_response_read_tool_drops_empty_pages() {
let input = json!({
"id": "msg_1",
"content": [{
"type": "tool_use",
"id": "call_1",
"name": "Read",
"input": { "file_path": "/tmp/x", "pages": "" }
}],
"stop_reason": "tool_use"
});
let result = anthropic_response_to_responses(input).unwrap();
let args = result["output"][0]["arguments"].as_str().unwrap();
assert!(args.contains("/tmp/x"));
assert!(!args.contains("pages"));
}
#[test]
fn test_response_refusal_is_incomplete_content_filter() {
let input = json!({
"id": "msg_1",
"content": [{ "type": "text", "text": "" }],
"stop_reason": "refusal",
"usage": { "input_tokens": 1, "output_tokens": 0 }
});
let result = anthropic_response_to_responses(input).unwrap();
assert_eq!(result["status"], "incomplete");
assert_eq!(result["incomplete_details"]["reason"], "content_filter");
}
#[test]
fn test_signed_thinking_round_trips_through_responses_tool_loop() {
let converted = anthropic_response_to_responses(json!({
"id": "msg_signed",
"model": "claude-sonnet-5",
"stop_reason": "tool_use",
"content": [
{"type": "thinking", "thinking": "check", "signature": "sig_123"},
{"type": "tool_use", "id": "call_1", "name": "Read", "input": {"path": "/tmp/a"}}
]
}))
.unwrap();
let reasoning = converted["output"][0].clone();
assert!(reasoning["encrypted_content"]
.as_str()
.unwrap()
.starts_with(ANTHROPIC_THINKING_ENCRYPTED_PREFIX));
let replay = responses_request_to_anthropic(
json!({
"model": "claude-sonnet-5",
"max_output_tokens": 4096,
"reasoning": {"effort": "high"},
"input": [
reasoning,
converted["output"][1].clone(),
{"type": "function_call_output", "call_id": "call_1", "output": "ok"}
]
}),
4096,
)
.unwrap();
assert_eq!(replay["thinking"]["type"], "adaptive");
assert_eq!(replay["messages"][1]["content"][0]["type"], "thinking");
assert_eq!(replay["messages"][1]["content"][0]["signature"], "sig_123");
}
#[test]
fn test_redacted_thinking_is_preserved_without_visible_summary() {
let response = anthropic_response_to_responses(json!({
"id": "msg_redacted",
"content": [{"type": "redacted_thinking", "data": "opaque"}]
}))
.unwrap();
assert_eq!(response["output"][0]["summary"], json!([]));
assert!(response["output"][0]["encrypted_content"].is_string());
}
#[test]
fn test_namespace_tool_response_restores_namespace() {
let context = build_codex_tool_context_from_request(&json!({
"tools": [{
"type": "namespace",
"name": "mcp_files",
"tools": [{"type": "function", "name": "read", "parameters": {"type": "object"}}]
}]
}));
let response = anthropic_response_to_responses_with_context(
json!({
"id": "msg_ns",
"content": [{"type": "tool_use", "id": "call_1", "name": "mcp_files__read", "input": {}}]
}),
&context,
)
.unwrap();
assert_eq!(response["output"][0]["type"], "function_call");
assert_eq!(response["output"][0]["name"], "read");
assert_eq!(response["output"][0]["namespace"], "mcp_files");
}
#[test]
fn test_success_status_error_envelope_is_not_completed() {
let error = anthropic_response_to_responses(json!({
"type": "error",
"error": {"type": "overloaded_error", "message": "busy"}
}))
.unwrap_err();
assert!(error.to_string().contains("overloaded_error"));
}
#[test]
fn test_context_window_stop_reason_is_incomplete() {
let response = anthropic_response_to_responses(json!({
"id": "msg_ctx",
"stop_reason": "model_context_window_exceeded",
"content": [{"type": "text", "text": "partial"}]
}))
.unwrap();
assert_eq!(response["status"], "incomplete");
assert_eq!(
response["incomplete_details"]["reason"],
"max_output_tokens"
);
}
#[test]
fn test_request_parallel_tool_calls_false_disables_parallel_use() {
let response = responses_request_to_anthropic(
json!({
"model": "c",
"input": [{"role": "user", "content": "hi"}],
"tools": [{"type": "function", "name": "x", "parameters": {"type": "object"}}],
"parallel_tool_calls": false
}),
4096,
)
.unwrap();
assert_eq!(response["tool_choice"]["type"], "auto");
assert_eq!(response["tool_choice"]["disable_parallel_tool_use"], true);
}
#[test]
fn test_request_trims_trailing_assistant_whitespace() {
let response = responses_request_to_anthropic(
json!({
"model": "c",
"input": [
{"role": "user", "content": "continue"},
{"role": "assistant", "content": [{"type": "output_text", "text": "prefix \n"}]}
]
}),
4096,
)
.unwrap();
assert_eq!(response["messages"][1]["content"][0]["text"], "prefix");
}
#[test]
fn test_structured_tool_output_preserves_text_and_image_blocks() {
let response = responses_request_to_anthropic(
json!({
"model": "c",
"input": [
{"type": "function_call", "call_id": "c1", "name": "inspect", "arguments": "{}"},
{"type": "function_call_output", "call_id": "c1", "output": [
{"type": "output_text", "text": "result"},
{"type": "input_image", "image_url": "data:image/png;base64,abc"}
]}
]
}),
4096,
)
.unwrap();
let content = &response["messages"][2]["content"][0]["content"];
assert_eq!(content[0]["type"], "text");
assert_eq!(content[1]["type"], "image");
}
#[test]
fn test_structured_tool_output_restores_error_file_and_unknown_parts() {
let response = responses_request_to_anthropic(
json!({
"model":"c",
"input":[
{"type":"function_call","call_id":"c1","name":"inspect","arguments":"{}"},
{"type":"function_call_output","call_id":"c1","output":[
{"type":"input_text","text":TOOL_RESULT_ERROR_MARKER},
{"type":"input_text","text":"failed"},
{"type":"input_file","file_url":"https://example.com/log.pdf","filename":"log.pdf"},
{"type":"future_part","payload":{"x":1}}
]}
]
}),
4096,
)
.unwrap();
let tool_result = &response["messages"][2]["content"][0];
assert_eq!(tool_result["is_error"], true);
assert_eq!(
tool_result["content"][0],
json!({"type":"text","text":"failed"})
);
assert_eq!(tool_result["content"][1]["type"], "document");
assert_eq!(tool_result["content"][1]["source"]["type"], "url");
assert_eq!(tool_result["content"][2]["type"], "text");
assert!(tool_result["content"][2]["text"]
.as_str()
.unwrap()
.contains("future_part"));
}
// ==================== Request normalization: non-empty & first is user ====================
#[test]
fn test_request_empty_messages_errors() {
// input is entirely reasoning (dropped) → messages is empty → error explicitly rather than leaving it to the upstream 400.
let input = json!({
"model": "c",
"max_output_tokens": 100,
"instructions": "sys",
"input": [
{ "type": "reasoning", "id": "rs_1", "encrypted_content": "xxx" }
]
});
assert!(responses_request_to_anthropic(input, 4096).is_err());
}
#[test]
fn test_request_assistant_first_gets_leading_user() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [
{ "role": "assistant", "content": [{ "type": "output_text", "text": "hi" }] }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let messages = result["messages"].as_array().unwrap();
assert_eq!(messages[0]["role"], "user");
assert_eq!(messages[1]["role"], "assistant");
}
// ==================== tools / tool_choice edge cases ====================
#[test]
fn test_request_tool_without_description_or_params() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [{ "role": "user", "content": "hi" }],
"tools": [ { "type": "function", "name": "noop" } ]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let tool = &result["tools"][0];
// Do not emit an explicit null description; input_schema falls back to a valid object schema.
assert!(tool.get("description").is_none());
assert_eq!(tool["input_schema"]["type"], "object");
}
#[test]
fn test_request_unknown_object_tool_choice_degrades_to_auto() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [{ "role": "user", "content": "hi" }],
"tools": [{ "type": "function", "name": "x", "parameters": {"type": "object"} }],
"tool_choice": { "type": "allowed_tools", "tools": [] }
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert_eq!(result["tool_choice"], json!({ "type": "auto" }));
}
// ==================== SSE aggregation fallback ====================
#[test]
fn test_anthropic_sse_aggregation_text_and_usage() {
let sse = "event: message_start\n\
data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude\",\"content\":[],\"stop_reason\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":0}}}\n\n\
event: content_block_start\n\
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n\
event: content_block_delta\n\
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\n\
event: content_block_delta\n\
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" world\"}}\n\n\
event: content_block_stop\n\
data: {\"type\":\"content_block_stop\",\"index\":0}\n\n\
event: message_delta\n\
data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":7}}\n\n\
event: message_stop\n\
data: {\"type\":\"message_stop\"}\n\n";
let msg = anthropic_sse_to_message_value(sse).unwrap();
assert_eq!(msg["content"][0]["type"], "text");
assert_eq!(msg["content"][0]["text"], "Hello world");
assert_eq!(msg["stop_reason"], "end_turn");
assert_eq!(msg["usage"]["input_tokens"], 10);
assert_eq!(msg["usage"]["output_tokens"], 7);
// The aggregated result can be converted directly into Responses.
let resp = anthropic_response_to_responses(msg).unwrap();
assert_eq!(resp["status"], "completed");
assert_eq!(resp["output"][0]["content"][0]["text"], "Hello world");
}
#[test]
fn test_anthropic_sse_aggregation_tool_use_partial_json() {
let sse = "data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"c\",\"content\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\n\
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"get_weather\",\"input\":{}}}\n\n\
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\":\"}}\n\n\
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"Tokyo\\\"}\"}}\n\n\
data: {\"type\":\"content_block_stop\",\"index\":0}\n\n\
data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":3}}\n\n";
let msg = anthropic_sse_to_message_value(sse).unwrap();
assert_eq!(msg["content"][0]["type"], "tool_use");
assert_eq!(msg["content"][0]["name"], "get_weather");
assert_eq!(msg["content"][0]["input"]["city"], "Tokyo");
assert_eq!(msg["stop_reason"], "tool_use");
}
#[test]
fn test_anthropic_sse_aggregation_tool_use_input_only_in_start() {
// Parity guard with the live streaming emitter's
// `test_tool_use_input_only_in_start_event`: a gateway that carries the full tool
// `input` on content_block_start and emits NO input_json_delta must still resolve
// the same arguments (the empty-accum fallback keeps the start-carried input).
let sse = "data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"c\",\"content\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\n\
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"get_weather\",\"input\":{\"city\":\"Tokyo\"}}}\n\n\
data: {\"type\":\"content_block_stop\",\"index\":0}\n\n\
data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":3}}\n\n";
let msg = anthropic_sse_to_message_value(sse).unwrap();
assert_eq!(msg["content"][0]["type"], "tool_use");
assert_eq!(msg["content"][0]["name"], "get_weather");
// Identical to the deltas-only case above — neither path may drop start input.
assert_eq!(msg["content"][0]["input"]["city"], "Tokyo");
assert_eq!(msg["stop_reason"], "tool_use");
}
#[test]
fn test_anthropic_sse_aggregation_missing_message_start_errors() {
let sse = "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n";
assert!(anthropic_sse_to_message_value(sse).is_err());
}
#[test]
fn test_anthropic_sse_aggregation_error_event_errors() {
let sse = "data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"overloaded\"}}\n\n";
assert!(anthropic_sse_to_message_value(sse).is_err());
}
#[test]
fn test_anthropic_sse_aggregation_tolerates_missing_trailing_blank_line() {
// The last event missing a trailing blank line (truncated stream) should still be processed.
let sse = "data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"c\",\"content\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\n\
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"hi\"}}\n\n\
data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":2}}";
let msg = anthropic_sse_to_message_value(sse).unwrap();
assert_eq!(msg["stop_reason"], "end_turn");
assert_eq!(msg["usage"]["output_tokens"], 2);
}
#[test]
fn test_anthropic_sse_aggregation_truncated_output_is_incomplete() {
let sse = "data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"content\":[]}}\n\n\
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"partial\"}}\n\n";
let message = anthropic_sse_to_message_value(sse).unwrap();
let response = anthropic_response_to_responses(message).unwrap();
assert_eq!(response["status"], "incomplete");
assert_eq!(
response["incomplete_details"]["reason"],
"max_output_tokens"
);
}
#[test]
fn test_anthropic_sse_aggregation_truncated_without_output_errors() {
let sse =
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"content\":[]}}\n\n";
assert!(anthropic_sse_to_message_value(sse).is_err());
}
}