Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 22 additions & 24 deletions apis/src/anthropic/to_openai/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ pub(crate) fn transform_response(body: &[u8], request_model: &str) -> Result<Tra

let (stop_reason, original_finish_reason) = map_finish_reason(obj);
let response = MessageResponse {
content: build_content_blocks(obj),
content: build_content_blocks(obj)?,
container: None,
id,
model,
Expand Down Expand Up @@ -150,20 +150,20 @@ fn error_type_for_status(status: StatusCode) -> &'static str {
// -----------------------------------------------------------------------------

/// Extract content blocks from the first choice.
fn build_content_blocks<'a>(obj: &'a Map<String, Value>) -> Vec<ContentBlock<'a>> {
fn build_content_blocks<'a>(obj: &'a Map<String, Value>) -> Result<Vec<ContentBlock<'a>>, String> {
let mut blocks = Vec::new();

let choice = obj.get("choices").and_then(Value::as_array).and_then(|c| c.first());

let Some(choice) = choice else {
return blocks;
return Ok(blocks);
};

let message = choice.get("message");
extract_text_block(message, &mut blocks);
extract_tool_call_blocks(message, &mut blocks);
extract_tool_call_blocks(message, &mut blocks)?;

blocks
Ok(blocks)
}

/// Extract a text content block from the message if present.
Expand All @@ -176,9 +176,9 @@ fn extract_text_block<'a>(message: Option<&'a Value>, blocks: &mut Vec<ContentBl
}

/// Extract tool call blocks from the message.
fn extract_tool_call_blocks<'a>(message: Option<&'a Value>, blocks: &mut Vec<ContentBlock<'a>>) {
fn extract_tool_call_blocks<'a>(message: Option<&'a Value>, blocks: &mut Vec<ContentBlock<'a>>) -> Result<(), String> {
let Some(Value::Array(tool_calls)) = message.and_then(|m| m.get("tool_calls")) else {
return;
return Ok(());
};

for tc in tool_calls {
Expand All @@ -192,11 +192,14 @@ fn extract_tool_call_blocks<'a>(message: Option<&'a Value>, blocks: &mut Vec<Con
.get("function")
.and_then(|f| f.get("arguments"))
.and_then(Value::as_str)
.unwrap_or("{}");
let input = serde_json::from_str::<Map<String, Value>>(args_str).unwrap_or_default();
.ok_or_else(|| "tool call arguments must be a JSON-encoded object string".to_owned())?;
let input = serde_json::from_str::<Map<String, Value>>(args_str)
.map_err(|error| format!("invalid tool call arguments: {error}"))?;

blocks.push(ContentBlock::tool_use(id, input, name));
}

Ok(())
}

// -----------------------------------------------------------------------------
Expand Down Expand Up @@ -589,21 +592,18 @@ mod tests {
}

#[test]
fn invalid_tool_call_arguments_fallback_to_empty_object() {
fn invalid_tool_call_arguments_fail_transformation() {
let body = br#"{"id":"chatcmpl-1","model":"gpt-4","choices":[{"message":{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"not{json"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5}}"#;
let tr = transform_response(body, "gpt-4").unwrap();
let parsed: Value = serde_json::from_slice(&tr.body).unwrap();
let error = transform_response(body, "gpt-4").err().unwrap();

assert_eq!(parsed["content"][0]["type"], "tool_use");
assert_eq!(
parsed["content"][0]["input"],
json!({}),
"invalid JSON arguments should fallback to empty object"
assert!(
error.contains("invalid tool call arguments"),
"malformed arguments should fail response transformation: {error}"
);
}

#[test]
fn non_object_tool_call_arguments_fallback_to_empty_object() {
fn non_object_tool_call_arguments_fail_transformation() {
for arguments in ["[]", "null", "\"text\""] {
let body = json!({
"id": "chatcmpl-1",
Expand All @@ -625,13 +625,11 @@ mod tests {
"usage": {"prompt_tokens": 10, "completion_tokens": 5}
});
let encoded = serde_json::to_vec(&body).unwrap();
let transformed = transform_response(&encoded, "gpt-4").unwrap();
let parsed: Value = serde_json::from_slice(&transformed.body).unwrap();
let error = transform_response(&encoded, "gpt-4").err().unwrap();

assert_eq!(
parsed["content"][0]["input"],
json!({}),
"{arguments} should not produce a non-object tool input"
assert!(
error.contains("invalid tool call arguments"),
"non-object arguments {arguments} should fail response transformation: {error}"
);
}
}
Expand Down
3 changes: 2 additions & 1 deletion tests/integration/fixtures/inference/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@ than editing the table.
<!-- BEGIN GENERATED INFERENCE COVERAGE -->
<!-- Generated by `cargo xtask sync-inference-readme`; do not edit. -->

The manifest declares **13 features** across **5 scopes**, linked to **11 scenarios**.
The manifest declares **14 features** across **5 scopes**, linked to **12 scenarios**.

| Scope | Feature | Status | Scenarios | Provider coverage |
| --- | --- | --- | --- | --- |
| `messages_to_chat_completions` | `messages.request.minimal` | `live_covered` | `messages/basic-nonstream`<br>`messages/basic-stream` | `openai`: `covered`<br>`vllm`: `live_covered` |
| `messages_to_chat_completions` | `messages.response.text` | `live_covered` | `messages/basic-nonstream`<br>`messages/basic-stream` | `openai`: `covered`<br>`vllm`: `live_covered` |
| `messages_to_chat_completions` | `messages.error.upstream` | `synthetic_only` | `messages/upstream-error` | `synthetic`: `synthetic_only` |
| `messages_to_chat_completions` | `messages.response.malformed_tool_arguments` | `synthetic_only` | `messages/malformed-tool-arguments` | `synthetic`: `synthetic_only` |
| `messages_native_passthrough` | `messages.native.request` | `live_covered` | `messages/native-basic-nonstream`<br>`messages/native-basic-stream`<br>`messages/native-tool-use` | `anthropic`: `live_covered` |
| `messages_native_passthrough` | `messages.native.response.text` | `live_covered` | `messages/native-basic-nonstream`<br>`messages/native-basic-stream` | `anthropic`: `live_covered` |
| `messages_native_passthrough` | `messages.native.tool_use` | `live_covered` | `messages/native-tool-use` | `anthropic`: `live_covered` |
Expand Down
9 changes: 9 additions & 0 deletions tests/integration/fixtures/inference/coverage.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ features:
providers:
synthetic:
status: synthetic_only
- id: messages.response.malformed_tool_arguments
scopes:
- messages_to_chat_completions
status: synthetic_only
scenarios:
- messages/malformed-tool-arguments
providers:
synthetic:
status: synthetic_only
- id: messages.native.request
scopes:
- messages_native_passthrough
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

51 changes: 51 additions & 0 deletions tests/integration/tests/suite/examples/anthropic_messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,57 @@ fn anthropic_to_openai_transforms_response_body() {
);
}

#[test]
fn anthropic_to_openai_preserves_response_with_malformed_tool_arguments() {
let response = serde_json::json!({
"id": "chatcmpl-malformed-tool",
"object": "chat.completion",
"model": "synthetic-malformed-tool-model",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "not{json"
}
}]
},
"finish_reason": "tool_calls"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
});
let backend = Backend::fixed(&response.to_string())
.header("content-type", "application/json")
.start_with_shutdown();
let proxy_port = free_port();
let config = load_example_config(
"anthropic/messages-to-openai.yaml",
proxy_port,
HashMap::from([("127.0.0.1:8000", backend.port())]),
);
let proxy = start_proxy(&config);
let request = serde_json::json!({
"model": "synthetic-malformed-tool-model",
"max_tokens": 64,
"messages": [{"role": "user", "content": "Use the weather tool."}]
});

let raw = http_send(proxy.addr(), &json_post("/v1/messages", &request.to_string()));
let client_body: serde_json::Value =
serde_json::from_str(&parse_body(&raw)).expect("preserved response should remain JSON");

assert_eq!(parse_status(&raw), 200, "upstream status should be preserved");
assert_eq!(
client_body, response,
"failed transformation must preserve the upstream response instead of emitting tool_use"
);
}

fn run_anthropic_to_openai_error(status: u16, response_body: &str, stream: bool) -> (u16, serde_json::Value) {
let backend = Backend::status(status, response_body)
.header("content-type", "application/json")
Expand Down
Loading
Loading