diff --git a/crates/tui/src/client/anthropic.rs b/crates/tui/src/client/anthropic.rs index 68e8b9460f..e8af845ad7 100644 --- a/crates/tui/src/client/anthropic.rs +++ b/crates/tui/src/client/anthropic.rs @@ -84,22 +84,56 @@ impl DeepSeekClient { if let Some(tools) = request.tools.as_ref() && !tools.is_empty() { + // Every Messages lane ships input_schema exactly as the tool + // declared it, with one recorded exception: the genuine Anthropic + // endpoint has returned HTTP 400 "input_schema does not support + // oneOf, allOf, or anyOf at the top level" since at least + // 2025-07 (f8e757322 quotes a live failure; CHANGELOG 0.9.0; + // anthropics/claude-code#4886/#30212), so the first-party lane — + // ApiProvider::Anthropic or a base URL on api.anthropic.com, the + // same endpoint predicate the thinking gates below use — narrows + // just those three root keywords, merging branch properties and + // noting the dropped constraint (a generic note when the branches + // carry no properties); the object type is restored whenever a + // stripped composition leaves a typeless root, a + // root that is not a JSON object at all is replaced by an empty + // object (the API defines input_schema as a JSON Schema object), + // and root enum/not, dependent keywords, and the declared root + // type stay verbatim, with the shared registry normalization + // (nullable-union collapse, bare-object properties injection, + // dangling-required pruning) still applied there. Provider + // identity stays in the gate beside the endpoint because + // ANTHROPIC_BASE_URL overrides commonly front the genuine + // validator through a proxy. The other lanes ship verbatim: + // DeepSeek's + // Anthropic-compatible line marks input_schema "Fully Supported" + // (api-docs.deepseek.com/guides/anthropic_api, fetched + // 2026-11-05), the Anthropic tool-use guide and its + // structured-outputs restrictions list name no root-composition + // ban, and MiniMax / Model Studio document none. MCP tool + // schemas carry no registry sanitization upstream of this + // adapter, so on the narrowing lane this pass is their only + // normalization; the native-registry sanitizer is unchanged. + let narrow_root_compositions = matches!(self.api_provider, ApiProvider::Anthropic) + || is_native_anthropic_base_url(&self.base_url); body["tools"] = json!( tools .iter() .map(|tool| { - // Sanitize the tool's input_schema the same way the - // OpenAI Responses adapter does: strip top-level - // oneOf/anyOf/allOf (which Anthropic rejects), merge - // alternative properties into the root, and surface - // the dropped constraint as a description note so the - // model still knows which parameters are expected. - let mut schema = tool.input_schema.clone(); - let constraint_note = schema_sanitize::sanitize_for_responses(&mut schema); - let description = match constraint_note { - Some(note) if tool.description.trim().is_empty() => note, - Some(note) => format!("{}\n\n{}", tool.description.trim(), note), - None => tool.description.clone(), + let (description, schema) = if narrow_root_compositions { + let mut schema = tool.input_schema.clone(); + let constraint_note = + schema_sanitize::sanitize_for_anthropic_messages(&mut schema); + let description = match constraint_note { + Some(note) if tool.description.trim().is_empty() => note, + Some(note) => { + format!("{}\n\n{}", tool.description.trim(), note) + } + None => tool.description.clone(), + }; + (description, schema) + } else { + (tool.description.clone(), tool.input_schema.clone()) }; let mut value = json!({ "name": tool.name, @@ -1232,6 +1266,330 @@ mod tests { DeepSeekClient::new(&config).expect("Model Studio Messages client constructs") } + fn openmodel_test_client() -> DeepSeekClient { + let _ = rustls::crypto::ring::default_provider().install_default(); + let config = crate::config::Config { + provider: Some("openmodel".to_string()), + providers: Some(crate::config::ProvidersConfig { + openmodel: crate::config::ProviderConfig { + api_key: Some("test-key".to_string()), + base_url: Some("https://api.openmodel.ai".to_string()), + ..Default::default() + }, + ..Default::default() + }), + ..Default::default() + }; + DeepSeekClient::new(&config).expect("Openmodel Messages client constructs") + } + + fn root_union_tool(name: &str, description: &str) -> Tool { + Tool { + tool_type: None, + name: name.to_string(), + description: description.to_string(), + input_schema: json!({ + "oneOf": [ + { + "type": "object", + "properties": {"patch": {"type": "string"}}, + "required": ["patch"] + }, + { + "type": "object", + "properties": { + "changes": {"type": "array", "items": {"type": "string"}} + }, + "required": ["changes"] + } + ] + }), + allowed_callers: None, + defer_loading: None, + input_examples: None, + strict: None, + cache_control: None, + } + } + + #[test] + fn non_anthropic_messages_lanes_ship_input_schema_verbatim_without_root_flattening() { + // The tool-use documents define input_schema simply as "A JSON Schema + // object defining the expected parameters for the tool" + // (platform.claude.com/docs/en/agents-and-tools/tool-use/ + // implement-tool-use, fetched 2026-11-05) with no root-composition + // restriction; the structured-outputs strict-restrictions list names + // none either, and DeepSeek's Anthropic-compatible line documents + // input_schema as fully supported. No record rejects root-level + // enum/not anywhere. The recorded Anthropic 400 (f8e757322; + // CHANGELOG 0.9.0; anthropics/claude-code#4886/#30212) is specific + // to the genuine Anthropic validator, so every other Messages lane + // ships the schema exactly as declared — one shared builder serves + // DeepSeek, MiniMax, Model Studio, and Openmodel, and this pins all + // four. + for client in [ + deepseek_test_client(crate::config::DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL), + minimax_test_client(), + modelstudio_test_client(crate::config::MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL), + openmodel_test_client(), + ] { + let mut request = request_with("claude-sonnet-4-6", None, None, None); + request.tools = Some(vec![root_union_tool("apply_patch", "Patch files")]); + + let body = client.build_anthropic_body(&request, true); + + let schema = &body["tools"][0]["input_schema"]; + assert!( + schema.get("oneOf").is_some(), + "a root union no record forbids must reach the wire verbatim: {schema}" + ); + assert_eq!( + schema["oneOf"][0]["required"], + json!(["patch"]), + "branches keep their own required groups: {schema}" + ); + assert_eq!( + body["tools"][0]["description"], "Patch files", + "no constraint note is synthesized for a schema that ships as declared: {body}" + ); + } + } + + #[test] + fn non_anthropic_messages_lane_ships_a_schema_the_registry_would_normalize() { + // "Verbatim" means exactly as declared, not merely un-flattened: a + // bare object is a shape the shared sanitize() normalization would + // mutate (bare-object properties injection), so byte identity here + // proves the verbatim branch runs no normalization at all. + let client = deepseek_test_client(crate::config::DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL); + let mut tool = root_union_tool("mcp_bare", "MCP tool"); + tool.input_schema = json!({"type": "object"}); + let mut request = request_with("claude-sonnet-4-6", None, None, None); + request.tools = Some(vec![tool]); + + let body = client.build_anthropic_body(&request, true); + + assert_eq!( + body["tools"][0]["input_schema"], + json!({"type": "object"}), + "the bare object must ship without the properties injection: {body}" + ); + } + + #[test] + fn anthropic_identity_narrows_even_on_a_proxy_base_url() { + // Provider identity stays in the gate because ANTHROPIC_BASE_URL-style + // overrides commonly front the genuine validator through a proxy: a + // native-identity client at a non-anthropic.com base URL must still + // narrow, which is the only behavior that distinguishes the identity + // disjunct from the endpoint clause. + let client = anthropic_test_client(Some("https://proxy.example.org")); + let mut request = request_with("claude-sonnet-4-6", None, None, None); + request.tools = Some(vec![root_union_tool("apply_patch", "Patch files")]); + + let body = client.build_anthropic_body(&request, true); + + let schema = &body["tools"][0]["input_schema"]; + assert!( + schema.get("oneOf").is_none(), + "the Anthropic identity alone must narrow on a proxy base URL: {schema}" + ); + assert_eq!( + body["tools"][0]["description"], + "Patch files\n\nExactly one of these parameter groups must be provided: `changes` | `patch`.", + "the dropped constraint is still surfaced through the proxy: {}", + body["tools"][0]["description"] + ); + } + + #[test] + fn anthropic_lane_narrows_the_recorded_root_composition_keywords() { + // The genuine Anthropic endpoint is recorded rejecting root-level + // oneOf/anyOf/allOf with HTTP 400 "input_schema does not support + // oneOf, allOf, or anyOf at the top level" (f8e757322, 2026-07-11; + // CHANGELOG 0.9.0; anthropics/claude-code#4886/#30212), so this lane + // strips exactly those keywords, merges branch properties into the + // root, and surfaces the dropped constraint as a description note. + let client = test_client(); + let mut request = request_with("claude-sonnet-4-6", None, None, None); + request.tools = Some(vec![ + root_union_tool("apply_patch", "Patch files"), + root_union_tool("apply_changes", ""), + ]); + + let body = client.build_anthropic_body(&request, true); + + let schema = &body["tools"][0]["input_schema"]; + assert!( + schema.get("oneOf").is_none(), + "the recorded rejection bars a root oneOf on this lane: {schema}" + ); + assert!( + schema["properties"].get("patch").is_some() + && schema["properties"].get("changes").is_some(), + "branch properties are merged into the root so the model keeps its vocabulary: {schema}" + ); + assert!( + body["tools"][0]["description"] + .as_str() + .unwrap() + .starts_with("Patch files\n\nExactly one of these parameter groups"), + "the dropped exactly-one constraint is surfaced as a note: {}", + body["tools"][0]["description"] + ); + assert_eq!( + body["tools"][1]["description"], + "Exactly one of these parameter groups must be provided: `changes` | `patch`.", + "an empty description is replaced by the note alone: {body}" + ); + } + + #[test] + fn anthropic_lane_keeps_unrecorded_keywords_verbatim() { + // The recorded 400 names only top-level oneOf/allOf/anyOf. The old + // Responses-style flatten also stripped root enum/not, dependent + // keywords, and non-object roots with no record behind them; this + // pins that the narrowed pass preserves all of them. + let client = test_client(); + let mut request = request_with("claude-sonnet-4-6", None, None, None); + request.tools = Some(vec![Tool { + tool_type: None, + name: "pick_mode".to_string(), + description: "Pick a mode".to_string(), + input_schema: json!({ + "type": "object", + "properties": {"mode": {"type": "string"}}, + "not": {"type": "integer"}, + "dependentRequired": {"mode": ["confirm"]}, + "dependentSchemas": { + "mode": { + "properties": {"confirm": {"type": "boolean"}}, + "required": ["confirm"] + } + } + }), + allowed_callers: None, + defer_loading: None, + input_examples: None, + strict: None, + cache_control: None, + }]); + let enum_root = Tool { + tool_type: None, + name: "style".to_string(), + description: "Style".to_string(), + input_schema: json!({ + "type": "string", + "enum": ["compact", "full"] + }), + allowed_callers: None, + defer_loading: None, + input_examples: None, + strict: None, + cache_control: None, + }; + request.tools.as_mut().unwrap().push(enum_root); + + let body = client.build_anthropic_body(&request, true); + + let schema = &body["tools"][0]["input_schema"]; + assert_eq!(schema["type"], "object", "root type stays as declared"); + assert_eq!( + schema["dependentRequired"], + json!({"mode": ["confirm"]}), + "dependent keywords are not in any record and stay verbatim: {schema}" + ); + assert_eq!( + schema["dependentSchemas"], + json!({ + "mode": { + "properties": {"confirm": {"type": "boolean"}}, + "required": ["confirm"] + } + }), + "a well-formed dependent schema ships byte-identically: {schema}" + ); + assert_eq!( + schema["not"], + json!({"type": "integer"}), + "root not is not in any record and stays verbatim: {schema}" + ); + assert_eq!( + body["tools"][0]["description"], "Pick a mode", + "no note is synthesized without a dropped root composition: {body}" + ); + let enum_schema = &body["tools"][1]["input_schema"]; + assert_eq!( + enum_schema, + &json!({ + "type": "string", + "enum": ["compact", "full"] + }), + "a non-object root with enum ships byte-identically: {enum_schema}" + ); + } + + fn custom_wire_test_client(name: &str, base_url: &str) -> DeepSeekClient { + let _ = rustls::crypto::ring::default_provider().install_default(); + let mut providers = crate::config::ProvidersConfig::default(); + providers.custom.insert( + name.to_string(), + crate::config::ProviderConfig { + api_key: Some("test-key".to_string()), + base_url: Some(base_url.to_string()), + wire: Some("anthropic".to_string()), + ..Default::default() + }, + ); + let config = crate::config::Config { + provider: Some(name.to_string()), + providers: Some(providers), + ..Default::default() + }; + DeepSeekClient::new(&config).expect("custom anthropic-wire client constructs") + } + + #[test] + fn custom_anthropic_wire_clients_narrow_by_first_party_endpoint() { + // The recorded 400 comes from the api.anthropic.com validator, not + // from a provider identity: a custom `wire = "anthropic"` provider + // pointed at a first-party base URL gets the same narrowing the + // thinking gates apply (is_native_anthropic_base_url), while one + // pointed at a third-party gateway ships the schema verbatim. + let native = custom_wire_test_client("byok-native", "https://api.anthropic.com"); + let mut request = request_with("claude-sonnet-4-6", None, None, None); + request.tools = Some(vec![root_union_tool("apply_patch", "Patch files")]); + + let body = native.build_anthropic_body(&request, true); + + let schema = &body["tools"][0]["input_schema"]; + assert!( + schema.get("oneOf").is_none(), + "a first-party base URL must narrow the recorded rejection: {schema}" + ); + assert_eq!( + body["tools"][0]["description"], + "Patch files\n\nExactly one of these parameter groups must be provided: `changes` | `patch`.", + "the dropped constraint is surfaced on the first-party lane: {body}" + ); + + let gateway = custom_wire_test_client("byok-gateway", "https://relay.example.com/v1"); + let mut request = request_with("claude-sonnet-4-6", None, None, None); + request.tools = Some(vec![root_union_tool("apply_patch", "Patch files")]); + + let body = gateway.build_anthropic_body(&request, true); + + let schema = &body["tools"][0]["input_schema"]; + assert!( + schema.get("oneOf").is_some(), + "a third-party gateway with no record must ship the union verbatim: {schema}" + ); + assert_eq!( + body["tools"][0]["description"], "Patch files", + "no note is synthesized on a verbatim lane: {body}" + ); + } + #[test] fn sampling_params_dropped_for_every_model_from_opus_4_7_on() { // The sampling restriction blankets every model released after diff --git a/crates/tui/src/tools/schema_sanitize.rs b/crates/tui/src/tools/schema_sanitize.rs index bdc0bda33b..10cd5c739a 100644 --- a/crates/tui/src/tools/schema_sanitize.rs +++ b/crates/tui/src/tools/schema_sanitize.rs @@ -9,7 +9,9 @@ //! The default sanitizer runs in-place on every schema returned by //! `ToolRegistry::tools_for_api()` before the registry hands them off. //! Provider-specific helpers below add stricter DeepSeek and OpenAI Responses -//! compatibility passes where their request shapes need it. +//! compatibility passes where their request shapes need it, and a narrower +//! Anthropic Messages pass that strips only the root composition keywords +//! that validator is recorded rejecting. use std::collections::HashSet; @@ -123,6 +125,95 @@ pub fn sanitize_for_responses(schema: &mut Value) -> Option { combine_constraint_notes(constraint_note, dependent_note) } +/// Narrow the root-composition keywords the Anthropic Messages validator is +/// recorded rejecting, and leave every other keyword untouched. +/// +/// The genuine Anthropic endpoint has returned HTTP 400 "input_schema does +/// not support oneOf, allOf, or anyOf at the top level" for tool schemas +/// carrying a root-level composition since at least 2025-07: commit +/// `f8e757322` quotes a live failure, CHANGELOG 0.9.0 records the same +/// rationale, and anthropics/claude-code#4886 / #30212 report the identical +/// server-side error. Unlike [`sanitize_for_responses`], this pass strips +/// only those three root keywords: root `enum`/`not`, +/// `dependentRequired`/`dependentSchemas`, and the declared root type ship +/// verbatim because no record rejects them. Root composition branch +/// properties are merged into the object and the dropped exactly-one / +/// at-least-one constraint is surfaced as a description note so the model +/// still knows which parameter groups apply. Where the stripped composition +/// leaves a root with no declared `type`, the object type is restored — the +/// previous release line always forced it, and the model-facing contract +/// otherwise loses its object shape even when the branches carried no +/// properties to merge (an `allOf` of `$ref`s, required-only or boolean +/// branches). When the merge contributes no properties at all, the dropped +/// composition is surfaced as a generic note so the model is not left +/// without any compensating guidance. +/// +/// The shared [`sanitize`] normalization still applies first, so a root +/// nullable union can collapse (rewriting the root `type`), a bare object +/// gains `properties`, and dangling `required` entries are pruned — only +/// the composition-keyword scope is narrower than [`sanitize_for_responses`]. +/// A root that is not a JSON object at all (malformed server or MCP output) +/// is replaced by an empty object exactly as in [`sanitize_for_responses`]: +/// the API defines `input_schema` as a JSON Schema object, and a declared +/// non-object schema such as a string-enum root still ships verbatim. +/// +/// Returns the same style of description note as [`sanitize_for_responses`]. +pub fn sanitize_for_anthropic_messages(schema: &mut Value) -> Option { + let constraint_note = schema + .as_object() + .and_then(root_composition_constraint_note); + + sanitize(schema); + + if !schema.is_object() { + *schema = Value::Object(Map::new()); + } + + let Some(obj) = schema.as_object_mut() else { + return constraint_note; + }; + + let stripped_composition = ["oneOf", "anyOf", "allOf"] + .iter() + .find(|key| obj.contains_key(**key)) + .copied(); + merge_root_composition_properties(obj); + obj.remove("oneOf"); + obj.remove("anyOf"); + obj.remove("allOf"); + let merged_properties = obj + .get("properties") + .and_then(Value::as_object) + .is_some_and(|properties| !properties.is_empty()); + if stripped_composition.is_some() && !obj.contains_key("type") { + obj.insert("type".into(), Value::String("object".to_string())); + } + prune_dangling_required(schema); + constraint_note.or_else(|| { + // A composition whose branches carry no properties (an allOf of + // $refs, required-only or boolean branches) merges nothing, so the + // argument contract is erased without any required-group note to + // compensate; surface a generic note instead of leaving the model + // without guidance about the dropped constraint. + stripped_composition + .filter(|_| !merged_properties) + .map(|key| match key { + "oneOf" => { + "Exactly one of the original argument alternatives must be provided (top-level `oneOf` is not supported by this API and was removed)." + .to_string() + } + "anyOf" => { + "At least one of the original argument alternatives must be provided (top-level `anyOf` is not supported by this API and was removed)." + .to_string() + } + _ => { + "All of the original argument alternatives must be satisfied (top-level `allOf` is not supported by this API and was removed)." + .to_string() + } + }) + }) +} + fn strict_schema_supported(schema: &Value) -> bool { let mut normalized = schema.clone(); sanitize(&mut normalized); @@ -1033,6 +1124,475 @@ mod tests { assert_eq!(note, None); } + #[test] + fn anthropic_messages_sanitize_strips_only_recorded_root_keywords() { + // The recorded 400 names only top-level oneOf/allOf/anyOf + // (f8e757322; CHANGELOG 0.9.0; anthropics/claude-code#4886/#30212). + // Root enum/not, dependent keywords, and the declared root type are + // not in any record, so the narrowed pass preserves all of them. + let mut schema = json!({ + "type": "object", + "properties": { + "mode": {"type": "string"} + }, + "not": {"type": "integer"}, + "dependentRequired": {"mode": ["confirm"]}, + "dependentSchemas": { + "mode": { + "properties": {"confirm": {"type": "boolean"}}, + "required": ["confirm"] + } + }, + "oneOf": [ + {"required": ["patch"]}, + {"required": ["changes"]} + ] + }); + + let note = sanitize_for_anthropic_messages(&mut schema); + + assert_eq!(schema["type"], "object"); + assert!(schema.get("oneOf").is_none()); + assert!(schema.get("anyOf").is_none()); + assert!(schema.get("allOf").is_none()); + assert_eq!(schema["not"], json!({"type": "integer"})); + assert_eq!(schema["dependentRequired"], json!({"mode": ["confirm"]})); + assert_eq!( + schema["dependentSchemas"], + json!({ + "mode": { + "properties": {"confirm": {"type": "boolean"}}, + "required": ["confirm"] + } + }), + "a well-formed dependent schema ships byte-identically: {schema}" + ); + assert_eq!( + note.as_deref(), + Some("Exactly one of these parameter groups must be provided: `changes` | `patch`.") + ); + } + + #[test] + fn anthropic_messages_sanitize_keeps_root_enum_and_non_object_roots() { + let mut enum_root = json!({ + "type": "string", + "enum": ["compact", "full"] + }); + + let note = sanitize_for_anthropic_messages(&mut enum_root); + + assert_eq!( + enum_root, + json!({ + "type": "string", + "enum": ["compact", "full"] + }) + ); + assert_eq!(note, None); + } + + #[test] + fn anthropic_messages_sanitize_merges_branch_properties_and_notes_groups() { + let mut schema = json!({ + "anyOf": [ + { + "type": "object", + "properties": { + "patch": {"type": "string"} + }, + "required": ["patch"] + }, + { + "type": "object", + "properties": { + "changes": {"type": "array", "items": {"type": "string"}} + }, + "required": ["changes"] + } + ] + }); + + let note = sanitize_for_anthropic_messages(&mut schema); + + assert!(schema.get("anyOf").is_none()); + assert!(schema["properties"].get("patch").is_some()); + assert!(schema["properties"].get("changes").is_some()); + assert_eq!( + schema["type"], "object", + "the merge leaves a populated object schema whose root declared no type, so the object type is restored: {schema}" + ); + assert_eq!( + note.as_deref(), + Some("At least one of these parameter groups must be provided: `changes` | `patch`.") + ); + } + + #[test] + fn anthropic_messages_sanitize_does_not_inject_type_without_a_root_composition() { + // Type restoration is scoped to stripped compositions: a declared + // typeless root with nothing to strip ships exactly as declared. + let mut schema = json!({"properties": {"x": {"type": "string"}}}); + + let note = sanitize_for_anthropic_messages(&mut schema); + + assert_eq!( + schema, + json!({"properties": {"x": {"type": "string"}}}), + "a typeless root without a composition must not gain a type: {schema}" + ); + assert_eq!(note, None); + } + + #[test] + fn anthropic_messages_sanitize_replaces_non_schema_roots_with_empty_objects() { + // The API defines input_schema as a JSON Schema object; a root that + // is an array or scalar (malformed server or MCP output) cannot be + // one, so the pass replaces it with an empty object exactly as the + // Responses pass does instead of shipping the raw value to the + // validator. A DECLARED non-object schema (string-enum root) is an + // object and keeps shipping verbatim. + let mut array_root = json!([{"type": "string"}]); + + let note = sanitize_for_anthropic_messages(&mut array_root); + + assert_eq!( + array_root, + json!({}), + "an array root is replaced, not shipped verbatim: {array_root}" + ); + assert_eq!(note, None); + + let mut scalar_root = json!("browse"); + + let note = sanitize_for_anthropic_messages(&mut scalar_root); + + assert_eq!( + scalar_root, + json!({}), + "a scalar root is replaced, not shipped verbatim: {scalar_root}" + ); + assert_eq!(note, None); + } + + #[test] + fn anthropic_messages_sanitize_pins_the_post_merge_required_prune() { + // A single-branch oneOf collapses inside sanitize(), which spreads + // the branch required groups to the root AFTER sanitize's own + // dangling-required prune has run; only the explicit post-merge + // prune removes the ghost entry. + let mut schema = json!({ + "oneOf": [{ + "type": "object", + "properties": {"a": {"type": "string"}}, + "required": ["a", "ghost"] + }] + }); + + let note = sanitize_for_anthropic_messages(&mut schema); + + assert_eq!( + schema["required"], + json!(["a"]), + "the explicit post-merge prune removes required entries spread after sanitize's prune: {schema}" + ); + assert_eq!(note, None); + } + + #[test] + fn anthropic_messages_sanitize_strips_root_all_of_and_notes_groups() { + // The recorded 400 names allOf alongside oneOf/anyOf; this pins the + // strip, the merge, and the All-prefix note for a root allOf. + let mut schema = json!({ + "allOf": [ + { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"] + }, + { + "type": "object", + "properties": {"url": {"type": "string"}}, + "required": ["url"] + } + ] + }); + + let note = sanitize_for_anthropic_messages(&mut schema); + + assert!(schema.get("allOf").is_none()); + assert_eq!(schema["type"], "object"); + assert!( + schema["properties"].get("path").is_some() && schema["properties"].get("url").is_some() + ); + assert_eq!( + note.as_deref(), + Some("All of these parameter groups must be provided: `path` | `url`.") + ); + } + + #[test] + fn anthropic_messages_sanitize_keeps_the_shared_registry_normalization() { + // The narrowed pass still runs the shared registry normalization + // (sanitize): the previous release line collapsed root nullable + // unions through it, and without that anchor a root + // `anyOf:[X, null]` would ship the recorded-rejection union shape + // verbatim. Bare-object properties injection and dangling-required + // pruning ride the same call. + let mut nullable = json!({ + "anyOf": [ + { + "type": "object", + "properties": {"q": {"type": "string"}} + }, + {"type": "null"} + ] + }); + let note = sanitize_for_anthropic_messages(&mut nullable); + assert_eq!( + nullable, + json!({ + "type": "object", + "properties": {"q": {"type": "string"}}, + "nullable": true + }), + "a root nullable union must collapse instead of shipping the recorded shape: {nullable}" + ); + assert_eq!(note, None); + + let mut bare = json!({"type": "object"}); + let note = sanitize_for_anthropic_messages(&mut bare); + assert_eq!( + bare, + json!({"type": "object", "properties": {}}), + "a bare object root keeps its properties injection: {bare}" + ); + assert_eq!(note, None); + + let mut dangling = json!({ + "type": "object", + "properties": {"a": {"type": "string"}}, + "required": ["a", "ghost"] + }); + let note = sanitize_for_anthropic_messages(&mut dangling); + assert_eq!( + dangling["required"], + json!(["a"]), + "required naming absent properties is pruned on this lane too: {dangling}" + ); + assert_eq!(note, None); + } + + #[test] + fn anthropic_messages_sanitize_restores_the_object_floor_for_property_less_compositions() { + // A composition whose branches carry no properties — an allOf of + // $refs (the classic OpenAPI-to-MCP shape), required-only or boolean + // branches — merges nothing, and without the type floor the wire + // would carry a constraint-free typeless schema with no note. The + // previous release line always forced type:"object"; keep that floor + // for every stripped composition and surface a generic note when the + // merge contributed nothing. + let mut refs = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "patch": {"type": "object", "properties": {"patch": {"type": "string"}}} + }, + "allOf": [ + {"$ref": "#/$defs/patch"}, + {"$ref": "#/$defs/missing"} + ] + }); + + let note = sanitize_for_anthropic_messages(&mut refs); + + assert_eq!( + refs["type"], "object", + "a stripped composition must not ship a typeless root even when no properties merged: {refs}" + ); + assert!( + refs.get("allOf").is_none(), + "the recorded keyword is still stripped: {refs}" + ); + assert!( + refs.get("properties").is_none(), + "no properties are invented when the branches reference $defs: {refs}" + ); + assert_eq!( + note.as_deref(), + Some( + "All of the original argument alternatives must be satisfied (top-level `allOf` is not supported by this API and was removed)." + ), + "the erased argument contract is surfaced as a note: {note:?}" + ); + + let mut booleans = json!({"oneOf": [true, false]}); + + let note = sanitize_for_anthropic_messages(&mut booleans); + + assert_eq!( + booleans, + json!({"type": "object"}), + "boolean branches merge nothing but keep the object floor: {booleans}" + ); + assert_eq!( + note.as_deref(), + Some( + "Exactly one of the original argument alternatives must be provided (top-level `oneOf` is not supported by this API and was removed)." + ), + "the note names the dropped exactly-one constraint: {note:?}" + ); + } + + #[test] + fn anthropic_messages_sanitize_preserves_nested_compositions() { + // The recorded 400 names only top-level composition keywords, so the + // strip is root-only exactly like the Responses pass; a nested oneOf + // inside a property must reach the wire untouched. Without this pin + // a mutation widening the strip tree-wide stays green. + let mut nested = json!({ + "oneOf": [ + {"type": "object", "properties": {"a": {"type": "string"}}}, + {"type": "object", "properties": {"b": {"type": "integer"}}} + ], + "properties": { + "payload": { + "oneOf": [ + {"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]}, + {"type": "object", "properties": {"y": {"type": "integer"}}, "required": ["y"]} + ] + } + } + }); + + let note = sanitize_for_anthropic_messages(&mut nested); + + assert!( + nested.get("oneOf").is_none(), + "the root oneOf is stripped: {nested}" + ); + assert_eq!( + nested["properties"]["payload"]["oneOf"][0]["required"], + json!(["x"]), + "a nested composition is outside the recorded root-only scope: {nested}" + ); + assert_eq!(note, None); + } + + #[test] + fn anthropic_messages_sanitize_keeps_root_properties_over_same_named_branch_properties() { + // merge_root_composition_properties resolves a same-named property + // first-wins: a property declared at the root keeps its own + // definition over a branch's redeclaration. Pin the precedence so a + // silent flip to last-wins cannot narrow the declared contract. + let mut precedence = json!({ + "type": "object", + "properties": {"mode": {"type": "string"}}, + "oneOf": [ + {"type": "object", "properties": {"mode": {"type": "integer"}}}, + {"type": "object", "properties": {"extra": {"type": "string"}}} + ] + }); + + let note = sanitize_for_anthropic_messages(&mut precedence); + + assert_eq!( + precedence["properties"]["mode"], + json!({"type": "string"}), + "the root-declared property wins over a same-named branch property: {precedence}" + ); + assert_eq!( + precedence["properties"]["extra"], + json!({"type": "string"}), + "branch properties without a root collision still merge: {precedence}" + ); + assert_eq!(note, None); + } + + #[test] + fn anthropic_messages_sanitize_notes_only_the_first_composition_keyword() { + // A root carrying two compositions strips both, but the note + // machinery surfaces only the first keyword's constraint (oneOf + // before anyOf before allOf, the recorded-keyword order); pin the + // precedence so a reordering cannot silently change model-facing + // copy. + let mut both = json!({ + "oneOf": [ + {"type": "object", "properties": {"a": {"type": "string"}}, "required": ["a"]}, + {"type": "object", "properties": {"b": {"type": "string"}}, "required": ["b"]} + ], + "anyOf": [ + {"type": "object", "properties": {"c": {"type": "string"}}, "required": ["c"]}, + {"type": "object", "properties": {"d": {"type": "string"}}, "required": ["d"]} + ] + }); + + let note = sanitize_for_anthropic_messages(&mut both); + + assert_eq!( + note.as_deref(), + Some("Exactly one of these parameter groups must be provided: `a` | `b`."), + "the oneOf note wins the fixed keyword precedence: {note:?}" + ); + assert!( + both.get("anyOf").is_none(), + "both compositions are stripped: {both}" + ); + } + + #[test] + fn anthropic_messages_pass_matches_the_previous_release_line_on_the_recorded_tools() { + // The PR's headline behavior claim: apply_patch and finance — the + // two model-visible built-ins whose root compositions produced the + // recorded Anthropic 400 — ship byte-identically to the previous + // release line, which ran sanitize_for_responses on every lane. The + // registry pre-pass (registry.rs) both lines share runs first. + use crate::tools::apply_patch::ApplyPatchTool; + use crate::tools::finance::FinanceTool; + use crate::tools::schema_canonicalize::canonicalize_schema; + use crate::tools::spec::ToolSpec as _; + + fn previous_line_pipeline(mut schema: Value) -> (Value, Option) { + sanitize(&mut schema); + canonicalize_schema(&mut schema); + let note = sanitize_for_responses(&mut schema); + (schema, note) + } + + fn current_line_pipeline(mut schema: Value) -> (Value, Option) { + sanitize(&mut schema); + canonicalize_schema(&mut schema); + let note = sanitize_for_anthropic_messages(&mut schema); + (schema, note) + } + + for (name, previous_input, current_input) in [ + ( + "apply_patch", + ApplyPatchTool.input_schema(), + ApplyPatchTool.input_schema(), + ), + ( + "finance", + FinanceTool::new().input_schema(), + FinanceTool::new().input_schema(), + ), + ] { + let (previous, previous_note) = previous_line_pipeline(previous_input); + let (current, current_note) = current_line_pipeline(current_input); + + assert_eq!( + previous, current, + "{name}: the narrowed pass must match the previous release line" + ); + assert_eq!( + previous.to_string(), + current.to_string(), + "{name}: key order must match too (prompt-cache stability)" + ); + assert_eq!(previous_note, current_note, "{name}: notes must match"); + } + } + #[test] fn xai_sanitize_flattens_apply_patch_root_one_of() { // The exact shape that produced the live 400: