fix(proxy): harden Responses and Anthropic protocol bridges

Fail closed on HTTP 2xx failure envelopes and pre-output SSE failures so semantic upstream errors can trigger failover instead of becoming empty successful replies.

Finalize incomplete and truncated streams explicitly, handle clean EOF and whole JSON responses, and keep tool-call stop reasons and terminal event ordering consistent.

Preserve structured tool results, URL images, documents, system roles, and signed thinking across both conversion directions. Drop incomplete historical tool calls safely and classify malformed completed arguments as non-retryable client requests.

Keep Codex-to-Anthropic prompt caching enabled by default while honoring the dedicated cache-injection switch.
This commit is contained in:
Jason
2026-07-12 12:19:21 +08:00
parent a078b4b207
commit 650905af2c
5 changed files with 1595 additions and 83 deletions
@@ -14,7 +14,7 @@ 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;
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};
@@ -65,7 +65,16 @@ fn reasoning_explicitly_disabled(effort: Option<&str>) -> bool {
/// 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" | "redacted_thinking") => {}
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()?;
@@ -79,10 +88,9 @@ pub(crate) fn decode_anthropic_thinking_block(encrypted_content: &str) -> Option
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()?;
match block.get("type").and_then(|value| value.as_str()) {
Some("thinking" | "redacted_thinking") => Some(block),
_ => None,
}
// 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(
@@ -183,6 +191,11 @@ pub(crate) fn build_responses_usage_from_anthropic(usage: Option<&Value>) -> Val
if cache_creation > 0 {
result["cache_creation_input_tokens"] = json!(cache_creation);
}
if let Some(cache_creation_details) = u.get("cache_creation") {
// Preserve Anthropic's TTL buckets as a compatibility extension. The usage
// parser consumes these to distinguish 5-minute and 1-hour write pricing.
result["cache_creation"] = cache_creation_details.clone();
}
result
}
@@ -190,6 +203,28 @@ pub(crate) fn build_responses_usage_from_anthropic(usage: Option<&Value>) -> Val
///
/// `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,
@@ -206,12 +241,28 @@ pub fn responses_request_to_anthropic(
result["model"] = json!(model);
}
// instructions system
// 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 !instructions.is_empty() {
result["system"] = json!(instructions);
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") {
@@ -457,7 +508,24 @@ fn convert_input_to_messages(
let mut messages: Vec<Value> = Vec::new();
for item in items {
match item.get("type").and_then(|t| t.as_str()) {
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")
@@ -471,8 +539,17 @@ fn convert_input_to_messages(
let input: Value = if args_str.trim().is_empty() {
json!({})
} else {
serde_json::from_str(args_str).unwrap_or(json!({}))
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,
@@ -532,14 +609,15 @@ fn convert_input_to_messages(
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);
push_tool_result_block(
&mut messages,
json!({
"type": "tool_result",
"tool_use_id": call_id,
"content": output
}),
);
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
@@ -571,6 +649,9 @@ fn convert_input_to_messages(
// 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 {
@@ -619,6 +700,11 @@ fn convert_input_to_messages(
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);
}
}
_ => {}
}
}
@@ -632,29 +718,70 @@ fn convert_input_to_messages(
Ok(messages)
}
fn tool_result_content_from_responses_item(item: &Value) -> Value {
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)) => json!(text),
Some(Value::String(text)) => ToolResultContent {
content: json!(text),
is_error: false,
},
Some(Value::Array(parts)) => {
let content: Vec<Value> = parts
.iter()
.filter_map(|part| match part.get("type").and_then(Value::as_str) {
Some("input_text" | "output_text") => part
.get("text")
.and_then(Value::as_str)
.map(|text| json!({ "type": "text", "text": text })),
Some("input_image") => image_block_from_input_image(part),
_ => None,
})
.collect();
if content.is_empty() {
json!(canonical_json_string(&Value::Array(parts.clone())))
} else {
Value::Array(content)
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) => json!(canonical_json_string(value)),
None => json!(canonical_json_string(item)),
Some(value) => ToolResultContent {
content: json!(canonical_json_string(value)),
is_error: false,
},
None => ToolResultContent {
content: json!(canonical_json_string(item)),
is_error: false,
},
}
}
@@ -968,6 +1095,46 @@ fn image_block_from_input_image(part: &Value) -> Option<Value> {
}
}
/// 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> {
@@ -1330,6 +1497,27 @@ mod tests {
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!({
@@ -1610,6 +1798,47 @@ mod tests {
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!({
@@ -2037,7 +2266,7 @@ mod tests {
let input = json!({
"id": "msg_1",
"content": [
{ "type": "thinking", "thinking": "Let me think" },
{ "type": "thinking", "thinking": "Let me think", "signature": "sig" },
{ "type": "text", "text": "answer" }
],
"stop_reason": "end_turn",
@@ -2049,6 +2278,22 @@ mod tests {
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!({
@@ -2073,7 +2318,11 @@ mod tests {
"output_tokens": 5,
"output_tokens_details": {"thinking_tokens": 3},
"cache_read_input_tokens": 60,
"cache_creation_input_tokens": 20
"cache_creation_input_tokens": 20,
"cache_creation": {
"ephemeral_5m_input_tokens": 5,
"ephemeral_1h_input_tokens": 15
}
}
});
let result = anthropic_response_to_responses(input).unwrap();
@@ -2093,6 +2342,10 @@ mod tests {
);
// cache_creation is passed through explicitly for downstream billing attribution (counted only once)
assert_eq!(result["usage"]["cache_creation_input_tokens"], 20);
assert_eq!(
result["usage"]["cache_creation"]["ephemeral_1h_input_tokens"],
15
);
}
#[test]
@@ -2274,6 +2527,40 @@ mod tests {
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]