diff --git a/.gitignore b/.gitignore index 17dae2ef..d88d4822 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ dev-notes/ .turbo .env .claude/*local*.json +.cursor \ No newline at end of file diff --git a/crates/coverage-report/src/requests_expected_differences.json b/crates/coverage-report/src/requests_expected_differences.json index 83c1371f..1befe6ff 100644 --- a/crates/coverage-report/src/requests_expected_differences.json +++ b/crates/coverage-report/src/requests_expected_differences.json @@ -441,6 +441,30 @@ { "pattern": "messages.length", "reason": "Parallel tool results grouped in one Tool message expand to separate function_call_output items in Responses API" } ] }, + { + "testCase": "textFormatJsonObjectParam", + "source": "*", + "target": "Anthropic", + "fields": [ + { "pattern": "params.tools", "reason": "Anthropic json_object compatibility uses a synthetic json tool shim" } + ] + }, + { + "testCase": "textFormatJsonObjectParam", + "source": "*", + "target": "Bedrock Anthropic", + "fields": [ + { "pattern": "params.tools", "reason": "Anthropic json_object compatibility uses a synthetic json tool shim" } + ] + }, + { + "testCase": "textFormatJsonObjectParam", + "source": "*", + "target": "Vertex Anthropic", + "fields": [ + { "pattern": "params.tools", "reason": "Anthropic json_object compatibility uses a synthetic json tool shim" } + ] + }, { "testCase": "simpleRequestTruncated", "source": "Google", diff --git a/crates/lingua/src/providers/anthropic/adapter.rs b/crates/lingua/src/providers/anthropic/adapter.rs index 52b488ec..d2521bfe 100644 --- a/crates/lingua/src/providers/anthropic/adapter.rs +++ b/crates/lingua/src/providers/anthropic/adapter.rs @@ -40,6 +40,8 @@ use serde::Deserialize; /// Default max_tokens for Anthropic requests (matches legacy proxy behavior). pub const DEFAULT_MAX_TOKENS: i64 = 4096; +const JSON_OBJECT_SHIM_TOOL_NAME: &str = "json"; +const JSON_OBJECT_SHIM_TOOL_DESCRIPTION: &str = "Output the result in JSON format"; #[derive(Debug, Default, Deserialize)] struct AnthropicMetadataView { @@ -75,6 +77,48 @@ fn is_enabled_thinking(value: &Value) -> bool { .ok() .is_some_and(|thinking| thinking.thinking_type == ThinkingType::Enabled) } + +fn is_json_object_response_format(config: Option<&ResponseFormatConfig>) -> bool { + config + .and_then(|rf| rf.format_type) + .is_some_and(|t| t == crate::universal::request::ResponseFormatType::JsonObject) +} + +fn maybe_unwrap_json_shim_tool_call(messages: &mut [Message]) { + for message in messages { + let Message::Assistant { content, .. } = message else { + continue; + }; + let should_unwrap = matches!( + content, + crate::universal::message::AssistantContent::Array(parts) + if !parts.is_empty() + && parts.iter().all(|part| { + matches!( + part, + crate::universal::message::AssistantContentPart::ToolCall { tool_name, .. } + if tool_name == JSON_OBJECT_SHIM_TOOL_NAME + ) + }) + ); + if !should_unwrap { + continue; + } + let crate::universal::message::AssistantContent::Array(parts) = content else { + continue; + }; + let json_text = parts + .iter() + .find_map(|part| match part { + crate::universal::message::AssistantContentPart::ToolCall { arguments, .. } => { + Some(arguments.to_string()) + } + _ => None, + }) + .unwrap_or_else(|| "{}".to_string()); + *content = crate::universal::message::AssistantContent::String(json_text); + } +} /// Adapter for Anthropic Messages API. pub struct AnthropicAdapter; @@ -311,9 +355,23 @@ impl ProviderAdapter for AnthropicAdapter { } } + let use_json_object_shim = + is_json_object_response_format(req.params.response_format.as_ref()) + && anthropic_extras_view.tools.is_none() + && anthropic_extras_view.tool_choice.is_none(); + // Convert tools to Anthropic format if let Some(raw_tools) = anthropic_extras_view.tools.as_ref() { obj.insert("tools".into(), raw_tools.clone()); + } else if use_json_object_shim { + obj.insert( + "tools".into(), + serde_json::json!([{ + "name": JSON_OBJECT_SHIM_TOOL_NAME, + "description": JSON_OBJECT_SHIM_TOOL_DESCRIPTION, + "input_schema": { "type": "object" } + }]), + ); } else if let Some(tools) = &req.params.tools { if !tools.is_empty() { let anthropic_tools: Vec = tools @@ -332,6 +390,11 @@ impl ProviderAdapter for AnthropicAdapter { let tool_choice_value = if let Some(raw_tool_choice) = anthropic_extras_view.tool_choice.as_ref() { Some(raw_tool_choice.clone()) + } else if use_json_object_shim { + Some(serde_json::json!({ + "type": "tool", + "name": JSON_OBJECT_SHIM_TOOL_NAME + })) } else { req.params.tool_choice_for(ProviderFormat::Anthropic) }; @@ -357,11 +420,14 @@ impl ProviderAdapter for AnthropicAdapter { } else { None }; - let format = req - .params - .response_format - .as_ref() - .and_then(|rf| rf.try_into().ok()); + let format = if use_json_object_shim { + None + } else { + req.params + .response_format + .as_ref() + .and_then(|rf| rf.try_into().ok()) + }; let raw_output_config = anthropic_extras_view.output_config.as_ref(); let raw_thinking = anthropic_extras_view.thinking.as_ref(); @@ -466,8 +532,10 @@ impl ProviderAdapter for AnthropicAdapter { }) .collect::, _>>()?; - let messages = as TryFromLLM>>::try_from(content_blocks) - .map_err(|e| TransformError::ToUniversalFailed(e.to_string()))?; + let mut messages = + as TryFromLLM>>::try_from(content_blocks) + .map_err(|e| TransformError::ToUniversalFailed(e.to_string()))?; + maybe_unwrap_json_shim_tool_call(&mut messages); let finish_reason = match payload.get("stop_reason").and_then(Value::as_str) { Some(s) => Some(s.parse().map_err(|_| ConvertError::InvalidEnumValue { @@ -1136,6 +1204,48 @@ fn parse_content_block_start_event(payload: &Value) -> ContentBlockStartEventVie mod tests { use super::*; use crate::serde_json::json; + use serde::Deserialize; + + #[derive(Debug, Deserialize)] + struct ShimInputSchemaView { + #[serde(rename = "type")] + schema_type: String, + } + + #[derive(Debug, Deserialize)] + struct ShimToolView { + name: String, + description: Option, + input_schema: ShimInputSchemaView, + } + + #[derive(Debug, Deserialize)] + struct ShimToolChoiceView { + #[serde(rename = "type")] + choice_type: String, + name: Option, + } + + #[derive(Debug, Deserialize)] + struct ShimOutputConfigView { + #[serde(default)] + format: Option, + } + + #[derive(Debug, Deserialize)] + struct ShimAnthropicRequestView { + #[serde(default)] + tools: Option>, + #[serde(default)] + tool_choice: Option, + #[serde(default)] + output_config: Option, + } + + #[derive(Debug, Deserialize)] + struct JsonColorView { + color: String, + } #[test] fn test_anthropic_detect_request() { @@ -1506,6 +1616,88 @@ mod tests { assert!(anthropic_request.get("output_format").is_none()); } + #[test] + fn test_anthropic_json_object_uses_tool_shim() { + use crate::providers::openai::adapter::OpenAIAdapter; + + let openai_adapter = OpenAIAdapter; + let anthropic_adapter = AnthropicAdapter; + + let openai_payload = json!({ + "model": "gpt-4o", + "messages": [{"role": "user", "content": "Return JSON"}], + "response_format": { "type": "json_object" } + }); + + let mut universal = openai_adapter.request_to_universal(openai_payload).unwrap(); + universal.model = Some("claude-sonnet-4-5-20250929".to_string()); + anthropic_adapter.apply_defaults(&mut universal); + + let anthropic_request = anthropic_adapter + .request_from_universal(&universal) + .unwrap(); + let request_view: ShimAnthropicRequestView = serde_json::from_value(anthropic_request) + .expect("shim request should deserialize into typed view"); + let tools = request_view.tools.expect("tools should be present"); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].name, JSON_OBJECT_SHIM_TOOL_NAME); + assert_eq!( + tools[0].description.as_deref(), + Some(JSON_OBJECT_SHIM_TOOL_DESCRIPTION) + ); + assert_eq!(tools[0].input_schema.schema_type, "object"); + + let tool_choice = request_view + .tool_choice + .expect("tool_choice should be present"); + assert_eq!(tool_choice.choice_type, "tool"); + assert_eq!( + tool_choice.name.as_deref(), + Some(JSON_OBJECT_SHIM_TOOL_NAME) + ); + + assert!( + request_view + .output_config + .as_ref() + .and_then(|oc| oc.format.as_ref()) + .is_none(), + "output_config.format should be omitted for json_object shim" + ); + } + + #[test] + fn test_anthropic_response_tool_shim_unwraps_to_assistant_content() { + let adapter = AnthropicAdapter; + let payload = json!({ + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5-20250929", + "stop_reason": "tool_use", + "content": [{ + "type": "tool_use", + "id": "toolu_123", + "name": JSON_OBJECT_SHIM_TOOL_NAME, + "input": { "color": "blue" } + }] + }); + + let universal = adapter.response_to_universal(payload).unwrap(); + assert_eq!(universal.messages.len(), 1); + match &universal.messages[0] { + Message::Assistant { content, .. } => match content { + crate::universal::message::AssistantContent::String(text) => { + let parsed: JsonColorView = serde_json::from_str(text) + .expect("shim output should be valid serialized JSON object"); + assert_eq!(parsed.color, "blue"); + } + _ => panic!("expected assistant string content after shim unwrap"), + }, + _ => panic!("expected assistant message"), + } + } + #[test] fn test_stream_to_universal_thinking_delta_semantic_chunk() { let adapter = AnthropicAdapter; diff --git a/crates/lingua/src/providers/anthropic/convert.rs b/crates/lingua/src/providers/anthropic/convert.rs index 3773f694..44bfe19a 100644 --- a/crates/lingua/src/providers/anthropic/convert.rs +++ b/crates/lingua/src/providers/anthropic/convert.rs @@ -1282,14 +1282,9 @@ impl TryFrom<&ResponseFormatConfig> for JsonOutputFormat { fn try_from(config: &ResponseFormatConfig) -> Result { match config.format_type.ok_or(())? { ResponseFormatType::Text => Err(()), - ResponseFormatType::JsonObject => Ok(JsonOutputFormat { - schema: serde_json::from_value(json!({ - "type": "object", - "additionalProperties": false - })) - .expect("static JSON object is always a valid Map"), - json_output_format_type: JsonOutputFormatType::JsonSchema, - }), + // Anthropic json_object compatibility is handled in adapter.rs via synthetic json tool shim. + // Do not emit output_config.format for json_object here. + ResponseFormatType::JsonObject => Err(()), ResponseFormatType::JsonSchema => { let js = config.json_schema.as_ref().ok_or(())?; match &js.schema { @@ -1415,6 +1410,19 @@ mod tests { use super::*; use crate::universal::convert::TryFromLLM; + #[test] + fn test_json_object_response_format_is_not_converted_to_anthropic_format() { + let config = ResponseFormatConfig { + format_type: Some(ResponseFormatType::JsonObject), + json_schema: None, + }; + + assert!( + JsonOutputFormat::try_from(&config).is_err(), + "json_object should not map to Anthropic output_config.format; adapter shim handles it" + ); + } + #[test] fn test_file_to_anthropic_document_with_provider_options() { // Create a File content part marked as a document (via provider_options) diff --git a/payloads/scripts/transforms/__snapshots__/transforms.test.ts.snap b/payloads/scripts/transforms/__snapshots__/transforms.test.ts.snap index a7cc013c..a8e049ba 100644 --- a/payloads/scripts/transforms/__snapshots__/transforms.test.ts.snap +++ b/payloads/scripts/transforms/__snapshots__/transforms.test.ts.snap @@ -7297,15 +7297,19 @@ exports[`chat-completions → anthropic > textFormatJsonObjectParam > request 1` }, ], "model": "claude-sonnet-4-5-20250929", - "output_config": { - "format": { - "schema": { - "additionalProperties": false, + "tool_choice": { + "name": "json", + "type": "tool", + }, + "tools": [ + { + "description": "Output the result in JSON format", + "input_schema": { "type": "object", }, - "type": "json_schema", + "name": "json", }, - }, + ], } `; @@ -7313,26 +7317,26 @@ exports[`chat-completions → anthropic > textFormatJsonObjectParam > response 1 { "choices": [ { - "finish_reason": "stop", + "finish_reason": "tool_calls", "index": 0, "message": { "annotations": [], - "content": "{}", + "content": "{"object":{"status":"ok"}}", "role": "assistant", }, }, ], "created": 0, - "id": "chatcmpl-01HsvDHsmB5HM6huqARAVhcu", + "id": "chatcmpl-01RkujcwB67h4NPJMVn5Zktf", "model": "claude-sonnet-4-5-20250929", "object": "chat.completion", "usage": { - "completion_tokens": 4, - "prompt_tokens": 119, + "completion_tokens": 38, + "prompt_tokens": 642, "prompt_tokens_details": { "cached_tokens": 0, }, - "total_tokens": 123, + "total_tokens": 680, }, } `; @@ -10912,15 +10916,19 @@ exports[`google → anthropic > textFormatJsonObjectParam > request 1`] = ` }, ], "model": "claude-sonnet-4-5-20250929", - "output_config": { - "format": { - "schema": { - "additionalProperties": false, + "tool_choice": { + "name": "json", + "type": "tool", + }, + "tools": [ + { + "description": "Output the result in JSON format", + "input_schema": { "type": "object", }, - "type": "json_schema", + "name": "json", }, - }, + ], } `; @@ -10931,7 +10939,7 @@ exports[`google → anthropic > textFormatJsonObjectParam > response 1`] = ` "content": { "parts": [ { - "text": "{}", + "text": "{"parameter":{"status":"ok"}}", }, ], "role": "model", @@ -10943,9 +10951,9 @@ exports[`google → anthropic > textFormatJsonObjectParam > response 1`] = ` "modelVersion": "claude-sonnet-4-5-20250929", "usageMetadata": { "cachedContentTokenCount": 0, - "candidatesTokenCount": 5, - "promptTokenCount": 119, - "totalTokenCount": 124, + "candidatesTokenCount": 38, + "promptTokenCount": 642, + "totalTokenCount": 680, }, } `; @@ -15515,22 +15523,26 @@ exports[`responses → anthropic > textFormatJsonObjectParam > request 1`] = ` }, ], "model": "claude-sonnet-4-5-20250929", - "output_config": { - "format": { - "schema": { - "additionalProperties": false, + "tool_choice": { + "name": "json", + "type": "tool", + }, + "tools": [ + { + "description": "Output the result in JSON format", + "input_schema": { "type": "object", }, - "type": "json_schema", + "name": "json", }, - }, + ], } `; exports[`responses → anthropic > textFormatJsonObjectParam > response 1`] = ` { "created_at": 0, - "id": "resp_01JQpW2a8AWPrn2s89N7Kzeo", + "id": "resp_01YbmqM6Jb8QcM3BzFYPkGR8", "incomplete_details": null, "model": "claude-sonnet-4-5-20250929", "object": "response", @@ -15539,7 +15551,7 @@ exports[`responses → anthropic > textFormatJsonObjectParam > response 1`] = ` "content": [ { "annotations": [], - "text": "{}", + "text": "{"parameter":{"a":1}}", "type": "output_text", }, ], @@ -15549,21 +15561,21 @@ exports[`responses → anthropic > textFormatJsonObjectParam > response 1`] = ` "type": "message", }, ], - "output_text": "{}", + "output_text": "{"parameter":{"a":1}}", "parallel_tool_calls": false, "status": "completed", "tool_choice": "none", "tools": [], "usage": { - "input_tokens": 114, + "input_tokens": 637, "input_tokens_details": { "cached_tokens": 0, }, - "output_tokens": 4, + "output_tokens": 37, "output_tokens_details": { "reasoning_tokens": 0, }, - "total_tokens": 118, + "total_tokens": 674, }, } `; diff --git a/payloads/transforms/chat-completions_to_anthropic/textFormatJsonObjectParam.json b/payloads/transforms/chat-completions_to_anthropic/textFormatJsonObjectParam.json index 083ac058..22c8c318 100644 --- a/payloads/transforms/chat-completions_to_anthropic/textFormatJsonObjectParam.json +++ b/payloads/transforms/chat-completions_to_anthropic/textFormatJsonObjectParam.json @@ -1,25 +1,35 @@ { "model": "claude-sonnet-4-5-20250929", - "id": "msg_01HsvDHsmB5HM6huqARAVhcu", + "id": "msg_01RkujcwB67h4NPJMVn5Zktf", "type": "message", "role": "assistant", "content": [ { - "type": "text", - "text": "{}" + "type": "tool_use", + "id": "toolu_0199iz1APZqj8HsRGbNdtWZj", + "name": "json", + "input": { + "object": { + "status": "ok" + } + }, + "caller": { + "type": "direct" + } } ], - "stop_reason": "end_turn", + "stop_reason": "tool_use", "stop_sequence": null, + "stop_details": null, "usage": { - "input_tokens": 119, + "input_tokens": 642, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation": { "ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0 }, - "output_tokens": 4, + "output_tokens": 38, "service_tier": "standard", "inference_geo": "not_available" } diff --git a/payloads/transforms/google_to_anthropic/textFormatJsonObjectParam.json b/payloads/transforms/google_to_anthropic/textFormatJsonObjectParam.json index 9ea1e8f1..d3e6cb9e 100644 --- a/payloads/transforms/google_to_anthropic/textFormatJsonObjectParam.json +++ b/payloads/transforms/google_to_anthropic/textFormatJsonObjectParam.json @@ -1,25 +1,35 @@ { "model": "claude-sonnet-4-5-20250929", - "id": "msg_01QfFhygxpH88dnm9d9L4zxY", + "id": "msg_012Xx5wmB7LdVr9RPCXMxWro", "type": "message", "role": "assistant", "content": [ { - "type": "text", - "text": "{}" + "type": "tool_use", + "id": "toolu_01YLm7ifUY9TG9Y3qUVySdJP", + "name": "json", + "input": { + "parameter": { + "status": "ok" + } + }, + "caller": { + "type": "direct" + } } ], - "stop_reason": "end_turn", + "stop_reason": "tool_use", "stop_sequence": null, + "stop_details": null, "usage": { - "input_tokens": 119, + "input_tokens": 642, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation": { "ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0 }, - "output_tokens": 5, + "output_tokens": 38, "service_tier": "standard", "inference_geo": "not_available" } diff --git a/payloads/transforms/responses_to_anthropic/textFormatJsonObjectParam.json b/payloads/transforms/responses_to_anthropic/textFormatJsonObjectParam.json index 91fe8c03..2bd13af9 100644 --- a/payloads/transforms/responses_to_anthropic/textFormatJsonObjectParam.json +++ b/payloads/transforms/responses_to_anthropic/textFormatJsonObjectParam.json @@ -1,25 +1,35 @@ { "model": "claude-sonnet-4-5-20250929", - "id": "msg_01JQpW2a8AWPrn2s89N7Kzeo", + "id": "msg_01YbmqM6Jb8QcM3BzFYPkGR8", "type": "message", "role": "assistant", "content": [ { - "type": "text", - "text": "{}" + "type": "tool_use", + "id": "toolu_01J5CuvjvZCW5EEwyU3nyK6v", + "name": "json", + "input": { + "parameter": { + "a": 1 + } + }, + "caller": { + "type": "direct" + } } ], - "stop_reason": "end_turn", + "stop_reason": "tool_use", "stop_sequence": null, + "stop_details": null, "usage": { - "input_tokens": 114, + "input_tokens": 637, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation": { "ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0 }, - "output_tokens": 4, + "output_tokens": 37, "service_tier": "standard", "inference_geo": "not_available" }