@@ -40,6 +40,8 @@ use serde::Deserialize;
4040
4141/// Default max_tokens for Anthropic requests (matches legacy proxy behavior).
4242pub 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 ) ]
4547struct 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.
79123pub 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
11361204mod 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 ;
0 commit comments