mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 16:26:16 +08:00
6eb217b242
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).
2719 lines
110 KiB
Rust
2719 lines
110 KiB
Rust
//! 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());
|
||
}
|
||
}
|