Skip to content

Commit 75c2619

Browse files
committed
use tool calls for json_object compatability with chat completions
1 parent 4f8c678 commit 75c2619

8 files changed

Lines changed: 354 additions & 60 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ dev-notes/
1212
.turbo
1313
.env
1414
.claude/*local*.json
15+
.cursor

crates/coverage-report/src/requests_expected_differences.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,30 @@
441441
{ "pattern": "messages.length", "reason": "Parallel tool results grouped in one Tool message expand to separate function_call_output items in Responses API" }
442442
]
443443
},
444+
{
445+
"testCase": "textFormatJsonObjectParam",
446+
"source": "*",
447+
"target": "Anthropic",
448+
"fields": [
449+
{ "pattern": "params.tools", "reason": "Anthropic json_object compatibility uses a synthetic json tool shim" }
450+
]
451+
},
452+
{
453+
"testCase": "textFormatJsonObjectParam",
454+
"source": "*",
455+
"target": "Bedrock Anthropic",
456+
"fields": [
457+
{ "pattern": "params.tools", "reason": "Anthropic json_object compatibility uses a synthetic json tool shim" }
458+
]
459+
},
460+
{
461+
"testCase": "textFormatJsonObjectParam",
462+
"source": "*",
463+
"target": "Vertex Anthropic",
464+
"fields": [
465+
{ "pattern": "params.tools", "reason": "Anthropic json_object compatibility uses a synthetic json tool shim" }
466+
]
467+
},
444468
{
445469
"testCase": "simpleRequestTruncated",
446470
"source": "Google",

crates/lingua/src/providers/anthropic/adapter.rs

Lines changed: 199 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ use serde::Deserialize;
4040

4141
/// Default max_tokens for Anthropic requests (matches legacy proxy behavior).
4242
pub const DEFAULT_MAX_TOKENS: i64 = 4096;
43+
const JSON_OBJECT_SHIM_TOOL_NAME: &str = "json";
44+
const JSON_OBJECT_SHIM_TOOL_DESCRIPTION: &str = "Output the result in JSON format";
4345

4446
#[derive(Debug, Default, Deserialize)]
4547
struct AnthropicMetadataView {
@@ -75,6 +77,48 @@ fn is_enabled_thinking(value: &Value) -> bool {
7577
.ok()
7678
.is_some_and(|thinking| thinking.thinking_type == ThinkingType::Enabled)
7779
}
80+
81+
fn is_json_object_response_format(config: Option<&ResponseFormatConfig>) -> bool {
82+
config
83+
.and_then(|rf| rf.format_type)
84+
.is_some_and(|t| t == crate::universal::request::ResponseFormatType::JsonObject)
85+
}
86+
87+
fn maybe_unwrap_json_shim_tool_call(messages: &mut [Message]) {
88+
for message in messages {
89+
let Message::Assistant { content, .. } = message else {
90+
continue;
91+
};
92+
let should_unwrap = matches!(
93+
content,
94+
crate::universal::message::AssistantContent::Array(parts)
95+
if !parts.is_empty()
96+
&& parts.iter().all(|part| {
97+
matches!(
98+
part,
99+
crate::universal::message::AssistantContentPart::ToolCall { tool_name, .. }
100+
if tool_name == JSON_OBJECT_SHIM_TOOL_NAME
101+
)
102+
})
103+
);
104+
if !should_unwrap {
105+
continue;
106+
}
107+
let crate::universal::message::AssistantContent::Array(parts) = content else {
108+
continue;
109+
};
110+
let json_text = parts
111+
.iter()
112+
.find_map(|part| match part {
113+
crate::universal::message::AssistantContentPart::ToolCall { arguments, .. } => {
114+
Some(arguments.to_string())
115+
}
116+
_ => None,
117+
})
118+
.unwrap_or_else(|| "{}".to_string());
119+
*content = crate::universal::message::AssistantContent::String(json_text);
120+
}
121+
}
78122
/// Adapter for Anthropic Messages API.
79123
pub struct AnthropicAdapter;
80124

@@ -311,9 +355,23 @@ impl ProviderAdapter for AnthropicAdapter {
311355
}
312356
}
313357

358+
let use_json_object_shim =
359+
is_json_object_response_format(req.params.response_format.as_ref())
360+
&& anthropic_extras_view.tools.is_none()
361+
&& anthropic_extras_view.tool_choice.is_none();
362+
314363
// Convert tools to Anthropic format
315364
if let Some(raw_tools) = anthropic_extras_view.tools.as_ref() {
316365
obj.insert("tools".into(), raw_tools.clone());
366+
} else if use_json_object_shim {
367+
obj.insert(
368+
"tools".into(),
369+
serde_json::json!([{
370+
"name": JSON_OBJECT_SHIM_TOOL_NAME,
371+
"description": JSON_OBJECT_SHIM_TOOL_DESCRIPTION,
372+
"input_schema": { "type": "object" }
373+
}]),
374+
);
317375
} else if let Some(tools) = &req.params.tools {
318376
if !tools.is_empty() {
319377
let anthropic_tools: Vec<Tool> = tools
@@ -332,6 +390,11 @@ impl ProviderAdapter for AnthropicAdapter {
332390
let tool_choice_value =
333391
if let Some(raw_tool_choice) = anthropic_extras_view.tool_choice.as_ref() {
334392
Some(raw_tool_choice.clone())
393+
} else if use_json_object_shim {
394+
Some(serde_json::json!({
395+
"type": "tool",
396+
"name": JSON_OBJECT_SHIM_TOOL_NAME
397+
}))
335398
} else {
336399
req.params.tool_choice_for(ProviderFormat::Anthropic)
337400
};
@@ -357,11 +420,14 @@ impl ProviderAdapter for AnthropicAdapter {
357420
} else {
358421
None
359422
};
360-
let format = req
361-
.params
362-
.response_format
363-
.as_ref()
364-
.and_then(|rf| rf.try_into().ok());
423+
let format = if use_json_object_shim {
424+
None
425+
} else {
426+
req.params
427+
.response_format
428+
.as_ref()
429+
.and_then(|rf| rf.try_into().ok())
430+
};
365431

366432
let raw_output_config = anthropic_extras_view.output_config.as_ref();
367433
let raw_thinking = anthropic_extras_view.thinking.as_ref();
@@ -466,8 +532,10 @@ impl ProviderAdapter for AnthropicAdapter {
466532
})
467533
.collect::<Result<Vec<_>, _>>()?;
468534

469-
let messages = <Vec<Message> as TryFromLLM<Vec<ContentBlock>>>::try_from(content_blocks)
470-
.map_err(|e| TransformError::ToUniversalFailed(e.to_string()))?;
535+
let mut messages =
536+
<Vec<Message> as TryFromLLM<Vec<ContentBlock>>>::try_from(content_blocks)
537+
.map_err(|e| TransformError::ToUniversalFailed(e.to_string()))?;
538+
maybe_unwrap_json_shim_tool_call(&mut messages);
471539

472540
let finish_reason = match payload.get("stop_reason").and_then(Value::as_str) {
473541
Some(s) => Some(s.parse().map_err(|_| ConvertError::InvalidEnumValue {
@@ -1136,6 +1204,48 @@ fn parse_content_block_start_event(payload: &Value) -> ContentBlockStartEventVie
11361204
mod tests {
11371205
use super::*;
11381206
use crate::serde_json::json;
1207+
use serde::Deserialize;
1208+
1209+
#[derive(Debug, Deserialize)]
1210+
struct ShimInputSchemaView {
1211+
#[serde(rename = "type")]
1212+
schema_type: String,
1213+
}
1214+
1215+
#[derive(Debug, Deserialize)]
1216+
struct ShimToolView {
1217+
name: String,
1218+
description: Option<String>,
1219+
input_schema: ShimInputSchemaView,
1220+
}
1221+
1222+
#[derive(Debug, Deserialize)]
1223+
struct ShimToolChoiceView {
1224+
#[serde(rename = "type")]
1225+
choice_type: String,
1226+
name: Option<String>,
1227+
}
1228+
1229+
#[derive(Debug, Deserialize)]
1230+
struct ShimOutputConfigView {
1231+
#[serde(default)]
1232+
format: Option<Value>,
1233+
}
1234+
1235+
#[derive(Debug, Deserialize)]
1236+
struct ShimAnthropicRequestView {
1237+
#[serde(default)]
1238+
tools: Option<Vec<ShimToolView>>,
1239+
#[serde(default)]
1240+
tool_choice: Option<ShimToolChoiceView>,
1241+
#[serde(default)]
1242+
output_config: Option<ShimOutputConfigView>,
1243+
}
1244+
1245+
#[derive(Debug, Deserialize)]
1246+
struct JsonColorView {
1247+
color: String,
1248+
}
11391249

11401250
#[test]
11411251
fn test_anthropic_detect_request() {
@@ -1506,6 +1616,88 @@ mod tests {
15061616
assert!(anthropic_request.get("output_format").is_none());
15071617
}
15081618

1619+
#[test]
1620+
fn test_anthropic_json_object_uses_tool_shim() {
1621+
use crate::providers::openai::adapter::OpenAIAdapter;
1622+
1623+
let openai_adapter = OpenAIAdapter;
1624+
let anthropic_adapter = AnthropicAdapter;
1625+
1626+
let openai_payload = json!({
1627+
"model": "gpt-4o",
1628+
"messages": [{"role": "user", "content": "Return JSON"}],
1629+
"response_format": { "type": "json_object" }
1630+
});
1631+
1632+
let mut universal = openai_adapter.request_to_universal(openai_payload).unwrap();
1633+
universal.model = Some("claude-sonnet-4-5-20250929".to_string());
1634+
anthropic_adapter.apply_defaults(&mut universal);
1635+
1636+
let anthropic_request = anthropic_adapter
1637+
.request_from_universal(&universal)
1638+
.unwrap();
1639+
let request_view: ShimAnthropicRequestView = serde_json::from_value(anthropic_request)
1640+
.expect("shim request should deserialize into typed view");
1641+
let tools = request_view.tools.expect("tools should be present");
1642+
assert_eq!(tools.len(), 1);
1643+
assert_eq!(tools[0].name, JSON_OBJECT_SHIM_TOOL_NAME);
1644+
assert_eq!(
1645+
tools[0].description.as_deref(),
1646+
Some(JSON_OBJECT_SHIM_TOOL_DESCRIPTION)
1647+
);
1648+
assert_eq!(tools[0].input_schema.schema_type, "object");
1649+
1650+
let tool_choice = request_view
1651+
.tool_choice
1652+
.expect("tool_choice should be present");
1653+
assert_eq!(tool_choice.choice_type, "tool");
1654+
assert_eq!(
1655+
tool_choice.name.as_deref(),
1656+
Some(JSON_OBJECT_SHIM_TOOL_NAME)
1657+
);
1658+
1659+
assert!(
1660+
request_view
1661+
.output_config
1662+
.as_ref()
1663+
.and_then(|oc| oc.format.as_ref())
1664+
.is_none(),
1665+
"output_config.format should be omitted for json_object shim"
1666+
);
1667+
}
1668+
1669+
#[test]
1670+
fn test_anthropic_response_tool_shim_unwraps_to_assistant_content() {
1671+
let adapter = AnthropicAdapter;
1672+
let payload = json!({
1673+
"id": "msg_test",
1674+
"type": "message",
1675+
"role": "assistant",
1676+
"model": "claude-sonnet-4-5-20250929",
1677+
"stop_reason": "tool_use",
1678+
"content": [{
1679+
"type": "tool_use",
1680+
"id": "toolu_123",
1681+
"name": JSON_OBJECT_SHIM_TOOL_NAME,
1682+
"input": { "color": "blue" }
1683+
}]
1684+
});
1685+
1686+
let universal = adapter.response_to_universal(payload).unwrap();
1687+
assert_eq!(universal.messages.len(), 1);
1688+
match &universal.messages[0] {
1689+
Message::Assistant { content, .. } => match content {
1690+
crate::universal::message::AssistantContent::String(text) => {
1691+
let parsed: JsonColorView = serde_json::from_str(text)
1692+
.expect("shim output should be valid serialized JSON object");
1693+
assert_eq!(parsed.color, "blue");
1694+
}
1695+
_ => panic!("expected assistant string content after shim unwrap"),
1696+
},
1697+
_ => panic!("expected assistant message"),
1698+
}
1699+
}
1700+
15091701
#[test]
15101702
fn test_stream_to_universal_thinking_delta_semantic_chunk() {
15111703
let adapter = AnthropicAdapter;

crates/lingua/src/providers/anthropic/convert.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1285,7 +1285,7 @@ impl TryFrom<&ResponseFormatConfig> for JsonOutputFormat {
12851285
ResponseFormatType::JsonObject => Ok(JsonOutputFormat {
12861286
schema: serde_json::from_value(json!({
12871287
"type": "object",
1288-
"additionalProperties": false
1288+
"additionalProperties": true
12891289
}))
12901290
.expect("static JSON object is always a valid Map"),
12911291
json_output_format_type: JsonOutputFormatType::JsonSchema,
@@ -1414,6 +1414,41 @@ impl TryFromLLM<Vec<Tool>> for Vec<UniversalTool> {
14141414
mod tests {
14151415
use super::*;
14161416
use crate::universal::convert::TryFromLLM;
1417+
use serde::Deserialize;
1418+
1419+
#[derive(Debug, Deserialize)]
1420+
struct JsonObjectSchemaView {
1421+
#[serde(rename = "type")]
1422+
schema_type: String,
1423+
#[serde(default)]
1424+
properties: serde_json::Map<String, Value>,
1425+
#[serde(rename = "additionalProperties", default)]
1426+
additional_properties: Option<bool>,
1427+
}
1428+
1429+
#[derive(Debug, Deserialize)]
1430+
struct JsonOutputFormatView {
1431+
schema: JsonObjectSchemaView,
1432+
}
1433+
1434+
#[test]
1435+
fn test_json_object_response_format_maps_to_open_object_schema() {
1436+
let config = ResponseFormatConfig {
1437+
format_type: Some(ResponseFormatType::JsonObject),
1438+
json_schema: None,
1439+
};
1440+
1441+
let output = JsonOutputFormat::try_from(&config).expect("json_object should convert");
1442+
let output_view: JsonOutputFormatView = serde_json::from_value(
1443+
serde_json::to_value(&output)
1444+
.expect("JsonOutputFormat should serialize for assertions"),
1445+
)
1446+
.expect("serialized JsonOutputFormat should deserialize into test view");
1447+
1448+
assert_eq!(output_view.schema.schema_type, "object");
1449+
assert!(output_view.schema.properties.is_empty());
1450+
assert_eq!(output_view.schema.additional_properties, Some(true));
1451+
}
14171452

14181453
#[test]
14191454
fn test_file_to_anthropic_document_with_provider_options() {

0 commit comments

Comments
 (0)