Skip to main content

adk_gemini/
schema_adapter.rs

1//! Gemini-specific schema normalization adapter.
2//!
3//! The [`GeminiSchemaAdapter`] applies all destructive transforms required by
4//! Gemini's function-calling API. It composes shared utilities from
5//! [`adk_core::schema_utils`] with Gemini-specific keyword removal to produce
6//! schemas that Gemini accepts.
7//!
8//! # Transform Order
9//!
10//! Steps 3-10 exist because Gemini's **OpenAPI subset** cannot express those
11//! keywords, so they are skipped for [`GeminiSchemaDialect::JsonSchema`], whose
12//! field accepts them. Steps 1, 2, 11, 12 and 13 are about what the *endpoint*
13//! rejects regardless of dialect, so they always run.
14//!
15//! | # | Transform | OpenAPI subset | JSON Schema |
16//! | --- | --- | --- | --- |
17//! | 1 | Resolve `$ref` (inline from definitions/`$defs`, break cycles at depth 10) | ✓ | ✓ |
18//! | 2 | Strip `$schema` keyword | ✓ | ✓ |
19//! | 3 | Collapse `anyOf`/`oneOf` combiners (select first non-null sub-schema) | ✓ | — |
20//! | 4 | Merge `allOf` sub-schemas | ✓ | — |
21//! | 5 | Collapse type arrays (`["string", "null"]` → `"string"`) | ✓ | — |
22//! | 6 | Strip conditional keywords (`if`/`then`/`else`) | ✓ | — |
23//! | 7 | Convert `const` to single-element `enum` | ✓ | — |
24//! | 8 | Strip null values from `enum` arrays | ✓ | — |
25//! | 9 | Add implicit `"type": "object"` when `properties` exists | ✓ | — |
26//! | 10 | Remove unsupported keywords recursively | ✓ | — |
27//! | 11 | Strip unsupported `format` values | ✓ | ✓ |
28//! | 12 | Enforce nesting depth limit (5 levels) | ✓ | ✓ |
29//! | 13 | Remove `definitions`/`$defs` blocks | ✓ | ✓ |
30//!
31//! # Example
32//!
33//! ```rust
34//! use adk_gemini::schema_adapter::GeminiSchemaAdapter;
35//! use adk_core::SchemaAdapter;
36//! use serde_json::json;
37//!
38//! let adapter = GeminiSchemaAdapter::new();
39//! let schema = json!({
40//!     "$schema": "http://json-schema.org/draft-07/schema#",
41//!     "type": "object",
42//!     "properties": {
43//!         "name": { "type": "string", "format": "hostname" }
44//!     },
45//!     "additionalProperties": true
46//! });
47//!
48//! let normalized = adapter.normalize_schema(schema);
49//! assert!(normalized.get("$schema").is_none());
50//! assert!(normalized.get("additionalProperties").is_none());
51//! assert!(normalized["properties"]["name"].get("format").is_none());
52//! ```
53
54use adk_core::SchemaAdapter;
55use adk_core::schema_utils;
56use serde_json::{Map, Value};
57use std::borrow::Cow;
58
59/// Allowed `format` values for the Gemini API.
60const GEMINI_ALLOWED_FORMATS: &[&str] =
61    &["date-time", "date", "time", "email", "uri", "uuid", "int32", "int64", "float", "double"];
62
63/// Keywords that Gemini does not support and must be removed from all schema nodes
64/// (standard API surface — removes `additionalProperties` entirely).
65///
66/// Per the official Gemini API docs, the Schema proto for function declarations
67/// only supports: `type`, `description`, `enum`, `items` (single schema for arrays),
68/// `properties`, `required`, `nullable`, and `format` (limited values).
69/// Everything else must be stripped to avoid 400 errors from the proto parser.
70///
71/// Reference: https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/function-calling
72const UNSUPPORTED_KEYWORDS: &[&str] = &[
73    "$id",
74    "additionalProperties",
75    "contains",
76    "contentEncoding",
77    "contentMediaType",
78    "default",
79    "dependentRequired",
80    "dependentSchemas",
81    "deprecated",
82    "examples",
83    "exclusiveMaximum",
84    "exclusiveMinimum",
85    "maxItems",
86    "maxLength",
87    "maxProperties",
88    "maximum",
89    "minItems",
90    "minLength",
91    "minProperties",
92    "minimum",
93    "multipleOf",
94    "not",
95    "pattern",
96    "patternProperties",
97    "prefixItems",
98    "propertyNames",
99    "readOnly",
100    "title",
101    "unevaluatedProperties",
102    "uniqueItems",
103    "writeOnly",
104];
105
106/// Keywords that Gemini does not support on the Vertex AI surface.
107/// Unlike the standard surface, Vertex AI requires `additionalProperties: false`
108/// on object schemas rather than removing it.
109///
110/// Same comprehensive list as [`UNSUPPORTED_KEYWORDS`] but without
111/// `additionalProperties` (which is handled separately for Vertex AI).
112const UNSUPPORTED_KEYWORDS_VERTEX: &[&str] = &[
113    "$id",
114    "contains",
115    "contentEncoding",
116    "contentMediaType",
117    "default",
118    "dependentRequired",
119    "dependentSchemas",
120    "deprecated",
121    "examples",
122    "exclusiveMaximum",
123    "exclusiveMinimum",
124    "maxItems",
125    "maxLength",
126    "maxProperties",
127    "maximum",
128    "minItems",
129    "minLength",
130    "minProperties",
131    "minimum",
132    "multipleOf",
133    "not",
134    "pattern",
135    "patternProperties",
136    "prefixItems",
137    "propertyNames",
138    "readOnly",
139    "title",
140    "unevaluatedProperties",
141    "uniqueItems",
142    "writeOnly",
143];
144
145/// The schema dialect a [`GeminiSchemaAdapter`] produces, and the
146/// function-declaration field its output must be posted under.
147///
148/// Gemini accepts **two mutually exclusive** schema fields on a function
149/// declaration, and they are not two spellings of one thing:
150///
151/// | Dialect | Field | Expresses |
152/// | --- | --- | --- |
153/// | [`OpenApiSubset`](Self::OpenApiSubset) | `parameters` | An OpenAPI subset. No `allOf`, no `if`/`then`, no `additionalProperties`, no string/numeric bounds. |
154/// | [`VertexOpenApiSubset`](Self::VertexOpenApiSubset) | `parameters` | The same subset, except `additionalProperties: false` is set on objects rather than removed. |
155/// | [`JsonSchema`](Self::JsonSchema) | `parametersJsonSchema` | Standard JSON Schema: `allOf`, `anyOf`, `if`/`then`, `additionalProperties`, `minLength`, numeric bounds. |
156///
157/// Selecting the dialect and selecting the field is therefore **one decision**,
158/// which is why they live in one type. Posting a schema that kept its
159/// constraints under the legacy `parameters` field does not degrade — the Live
160/// socket closes with **WS 1007** (`Unknown name "additionalProperties"`), so
161/// the call dies before any audio flows.
162///
163/// # Choosing
164///
165/// [`JsonSchema`](Self::JsonSchema) is verified on Google AI Studio `v1beta`
166/// with `gemini-3.1-flash-live-preview`. Google's function-calling reference
167/// documents only `parameters` and does not mention `parametersJsonSchema` at
168/// all; support is stated in the structured-outputs announcement and confirmed
169/// here by probing the live endpoint. Re-probe before assuming it on another
170/// model, surface, or endpoint version — and note that acceptance is not
171/// enforcement: `setupComplete` proves the schema *parsed*, not that the model
172/// honours every keyword while generating.
173///
174/// The default remains [`OpenApiSubset`](Self::OpenApiSubset), so existing
175/// callers see no behaviour change.
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
177pub enum GeminiSchemaDialect {
178    /// OpenAPI subset on `parameters`. Removes `additionalProperties`.
179    #[default]
180    OpenApiSubset,
181    /// OpenAPI subset on `parameters`, Vertex AI flavour: sets
182    /// `additionalProperties: false` on object schemas instead of removing it.
183    VertexOpenApiSubset,
184    /// Standard JSON Schema on `parametersJsonSchema`.
185    JsonSchema,
186}
187
188impl GeminiSchemaDialect {
189    /// The function-declaration field a schema in this dialect belongs under.
190    pub fn parameters_field(self) -> &'static str {
191        match self {
192            Self::OpenApiSubset | Self::VertexOpenApiSubset => "parameters",
193            Self::JsonSchema => "parametersJsonSchema",
194        }
195    }
196
197    /// Whether this dialect is limited to Gemini's OpenAPI subset, and so needs
198    /// the combiners, conditionals and type arrays reduced away before sending.
199    ///
200    /// Asked as a *capability* rather than by comparing against a variant, so
201    /// that adding a dialect means classifying it here once instead of hunting
202    /// for scattered equality checks.
203    pub fn requires_openapi_reduction(self) -> bool {
204        match self {
205            Self::OpenApiSubset | Self::VertexOpenApiSubset => true,
206            Self::JsonSchema => false,
207        }
208    }
209}
210
211/// Schema adapter for the Gemini API surface.
212///
213/// Applies the transforms required by Gemini's function-calling API. How
214/// destructive those transforms are depends on the target
215/// [`GeminiSchemaDialect`]:
216///
217/// - **Standard** (`GeminiSchemaAdapter::new()`): OpenAPI subset; removes
218///   `additionalProperties` entirely.
219/// - **Vertex AI** (`GeminiSchemaAdapter::vertex_ai()`): OpenAPI subset; sets
220///   `additionalProperties: false` on object schemas instead of removing it.
221/// - **JSON Schema** (`GeminiSchemaAdapter::json_schema()`): keeps `allOf`,
222///   `anyOf`, `if`/`then`, `additionalProperties` and the string/numeric
223///   bounds, and its output must be posted under `parametersJsonSchema`.
224///
225/// Use [`parameters_field()`](adk_core::SchemaAdapter::parameters_field) rather
226/// than hardcoding a field name; it is derived from the dialect so the two
227/// cannot disagree.
228///
229/// # Example
230///
231/// ```rust
232/// use adk_gemini::schema_adapter::GeminiSchemaAdapter;
233/// use adk_core::SchemaAdapter;
234/// use serde_json::json;
235///
236/// let adapter = GeminiSchemaAdapter::new();
237/// let schema = json!({
238///     "anyOf": [
239///         {"type": "null"},
240///         {"type": "string", "minLength": 1}
241///     ]
242/// });
243///
244/// let normalized = adapter.normalize_schema(schema);
245/// assert_eq!(normalized["type"], "string");
246/// assert!(normalized.get("anyOf").is_none());
247/// ```
248#[derive(Debug)]
249pub struct GeminiSchemaAdapter {
250    /// The dialect this adapter reduces to, which also decides the wire field.
251    dialect: GeminiSchemaDialect,
252}
253
254impl GeminiSchemaAdapter {
255    /// Creates a new `GeminiSchemaAdapter` for the standard Gemini API surface.
256    ///
257    /// This variant removes `additionalProperties` from all schema nodes.
258    pub fn new() -> Self {
259        Self::with_dialect(GeminiSchemaDialect::OpenApiSubset)
260    }
261
262    /// Creates a new `GeminiSchemaAdapter` for the Vertex AI surface.
263    ///
264    /// This variant sets `additionalProperties: false` on object schemas
265    /// instead of removing the keyword entirely.
266    pub fn vertex_ai() -> Self {
267        Self::with_dialect(GeminiSchemaDialect::VertexOpenApiSubset)
268    }
269
270    /// Creates a new `GeminiSchemaAdapter` targeting standard JSON Schema.
271    ///
272    /// Keeps `allOf`, `anyOf`, `if`/`then`, `additionalProperties` and the
273    /// string/numeric bounds instead of stripping them, so a model is shown the
274    /// same contract the caller validates against. Its output belongs under
275    /// `parametersJsonSchema`; see [`GeminiSchemaDialect::JsonSchema`] for the
276    /// surfaces this is verified on.
277    pub fn json_schema() -> Self {
278        Self::with_dialect(GeminiSchemaDialect::JsonSchema)
279    }
280
281    /// Creates a new `GeminiSchemaAdapter` for an explicitly chosen dialect.
282    pub fn with_dialect(dialect: GeminiSchemaDialect) -> Self {
283        Self { dialect }
284    }
285
286    /// The dialect this adapter reduces to.
287    pub fn dialect(&self) -> GeminiSchemaDialect {
288        self.dialect
289    }
290}
291
292impl Default for GeminiSchemaAdapter {
293    fn default() -> Self {
294        Self::new()
295    }
296}
297
298impl SchemaAdapter for GeminiSchemaAdapter {
299    fn normalize_schema(&self, mut schema: Value) -> Value {
300        // Which steps run depends on the target dialect, along one line: a
301        // transform that exists because the *OpenAPI subset cannot express* a
302        // keyword is skipped for JSON Schema, which can; a transform that
303        // exists because the *endpoint rejects* something runs for every
304        // dialect. Steps 3-10 below are the first kind. Steps 1, 2, 11 and 12
305        // are the second, so they are unconditional.
306
307        // Step 1: Extract definitions and resolve $ref references.
308        // Always resolve refs — even with empty definitions — so that
309        // unresolvable $ref values are replaced with {"type": "object"}.
310        //
311        // Unconditional despite JSON Schema supporting `$ref`: Google's own
312        // guidance warns that large or deeply nested schemas may be rejected,
313        // and an inlined document is the shape both fields are known to accept.
314        let definitions = extract_definitions(&schema);
315        schema_utils::resolve_refs(&mut schema, &definitions, 0);
316
317        // Step 2: Strip $schema keyword — rejected on both fields.
318        schema_utils::strip_schema_keyword(&mut schema);
319
320        if self.dialect.requires_openapi_reduction() {
321            // Step 3: Collapse anyOf/oneOf combiners
322            schema_utils::collapse_combiners(&mut schema);
323
324            // Step 4: Merge allOf sub-schemas
325            schema_utils::merge_all_of(&mut schema);
326
327            // Step 5: Collapse type arrays
328            schema_utils::collapse_type_arrays(&mut schema);
329
330            // Step 6: Strip conditional keywords (if/then/else)
331            schema_utils::strip_conditional_keywords(&mut schema);
332
333            // Step 7: Convert const to single-element enum
334            schema_utils::convert_const_to_enum(&mut schema);
335
336            // Step 8: Strip null from enum arrays
337            schema_utils::strip_null_from_enum(&mut schema);
338
339            // Step 9: Add implicit object type.
340            //
341            // Skipped for JSON Schema not merely as unnecessary but as
342            // *harmful*: it injects `type: "object"` into every schema-shaped
343            // node it walks, including the `if` clause of a conditional, which
344            // changes what that clause matches.
345            schema_utils::add_implicit_object_type(&mut schema);
346        }
347
348        // Step 10: Remove unsupported keywords recursively.
349        //
350        // Matched exhaustively rather than defaulted: a dialect added later
351        // must state what it strips, instead of silently inheriting the
352        // subset's answer and quietly dropping constraints again.
353        match self.dialect {
354            // Nothing to remove. `additionalProperties`, `minLength`, `pattern`
355            // and the numeric bounds are all expressible here, and stripping
356            // them is exactly what leaves a model guessing at rules the caller
357            // still enforces.
358            GeminiSchemaDialect::JsonSchema => {}
359            GeminiSchemaDialect::VertexOpenApiSubset => {
360                remove_unsupported_keywords_vertex(&mut schema)
361            }
362            GeminiSchemaDialect::OpenApiSubset => remove_unsupported_keywords(&mut schema),
363        }
364
365        // Step 11: Strip unsupported format values
366        schema_utils::strip_unsupported_formats(&mut schema, GEMINI_ALLOWED_FORMATS);
367
368        // Step 12: Enforce nesting depth (max 5 levels)
369        schema_utils::enforce_nesting_depth(&mut schema, 5, 0);
370
371        // Step 13: Remove definitions/$defs blocks
372        if let Some(obj) = schema.as_object_mut() {
373            obj.remove("definitions");
374            obj.remove("$defs");
375        }
376
377        schema
378    }
379
380    /// Truncates tool names exceeding 64 bytes at a valid UTF-8 character boundary.
381    ///
382    /// Preserves the prefix of the name, truncating from the end.
383    fn normalize_tool_name<'a>(&self, name: &'a str) -> Cow<'a, str> {
384        if name.len() <= 64 {
385            Cow::Borrowed(name)
386        } else {
387            let mut end = 64;
388            while end > 0 && !name.is_char_boundary(end) {
389                end -= 1;
390            }
391            Cow::Owned(name[..end].to_string())
392        }
393    }
394
395    /// Returns the fallback schema for tools with no `parameters_schema`.
396    ///
397    /// Gemini requires `{"type": "object", "properties": {}}` as the minimum
398    /// valid function declaration parameters.
399    fn empty_schema(&self) -> Value {
400        serde_json::json!({"type": "object", "properties": {}})
401    }
402
403    /// The function-declaration field this adapter's output belongs under,
404    /// derived from the dialect so the reduction and the field cannot disagree.
405    fn parameters_field(&self) -> &'static str {
406        self.dialect.parameters_field()
407    }
408}
409
410/// Extracts and merges `definitions` and `$defs` from the top-level schema
411/// into a single map for reference resolution.
412fn extract_definitions(schema: &Value) -> Map<String, Value> {
413    let mut defs = Map::new();
414
415    if let Some(obj) = schema.as_object() {
416        // Collect from "definitions" (Draft 4-7)
417        if let Some(definitions) = obj.get("definitions").and_then(|v| v.as_object()) {
418            for (key, value) in definitions {
419                defs.insert(key.clone(), value.clone());
420            }
421        }
422
423        // Collect from "$defs" (Draft 2019-09+)
424        if let Some(dollar_defs) = obj.get("$defs").and_then(|v| v.as_object()) {
425            for (key, value) in dollar_defs {
426                defs.insert(key.clone(), value.clone());
427            }
428        }
429    }
430
431    defs
432}
433
434/// Recursively removes unsupported keywords from the schema and all nested sub-schemas.
435///
436/// Removes: `additionalProperties`, `exclusiveMinimum`, `exclusiveMaximum`,
437/// `items` (when type is not "array"), `not`, `propertyNames`, `patternProperties`,
438/// `unevaluatedProperties`.
439fn remove_unsupported_keywords(schema: &mut Value) {
440    let Some(obj) = schema.as_object_mut() else {
441        return;
442    };
443
444    // Remove standard unsupported keywords
445    for keyword in UNSUPPORTED_KEYWORDS {
446        obj.remove(*keyword);
447    }
448
449    // Handle `items`:
450    // 1. If type is NOT "array", remove items entirely (meaningless on non-array types).
451    // 2. If type IS "array" and items is a JSON array (tuple validation syntax),
452    //    convert to single schema using the first element. Gemini requires items
453    //    on array types but only supports a single schema, not tuple validation.
454    // 3. If type IS "array" and items is already an object, keep it (valid).
455    // 4. If type IS "array" and items is missing, add a default items schema.
456    let is_array_type = obj.get("type").and_then(|t| t.as_str()).is_some_and(|t| t == "array");
457    if !is_array_type {
458        obj.remove("items");
459    } else if obj.get("items").is_some_and(|v| v.is_array()) {
460        // Convert tuple items [schema1, schema2, ...] → first schema as single items
461        let first_schema = obj
462            .get("items")
463            .and_then(|v| v.as_array())
464            .and_then(|arr| arr.first())
465            .cloned()
466            .unwrap_or_else(|| serde_json::json!({"type": "string"}));
467        obj.insert("items".to_string(), first_schema);
468    } else if !obj.contains_key("items") {
469        // Gemini requires items on array types — add default if missing
470        obj.insert("items".to_string(), serde_json::json!({"type": "string"}));
471    }
472
473    // Recurse into properties
474    if let Some(props) = obj.get_mut("properties")
475        && let Some(props_obj) = props.as_object_mut()
476    {
477        for value in props_obj.values_mut() {
478            remove_unsupported_keywords(value);
479        }
480    }
481
482    // Recurse into items (now guaranteed to be a single schema object if present)
483    if let Some(items) = obj.get_mut("items")
484        && items.is_object()
485    {
486        remove_unsupported_keywords(items);
487    }
488
489    // Recurse into allOf, anyOf, oneOf (may still exist if not collapsed)
490    for keyword in &["allOf", "anyOf", "oneOf"] {
491        if let Some(arr_val) = obj.get_mut(*keyword)
492            && let Some(arr) = arr_val.as_array_mut()
493        {
494            for sub in arr.iter_mut() {
495                remove_unsupported_keywords(sub);
496            }
497        }
498    }
499}
500
501/// Recursively removes unsupported keywords for the Vertex AI surface.
502///
503/// Unlike the standard surface, Vertex AI requires `additionalProperties: false`
504/// on object schemas. This function:
505/// - Sets `additionalProperties` to `false` on object schemas (instead of removing it)
506/// - Removes all other unsupported keywords the same as the standard surface
507fn remove_unsupported_keywords_vertex(schema: &mut Value) {
508    let Some(obj) = schema.as_object_mut() else {
509        return;
510    };
511
512    // Remove Vertex-specific unsupported keywords (does NOT include additionalProperties)
513    for keyword in UNSUPPORTED_KEYWORDS_VERTEX {
514        obj.remove(*keyword);
515    }
516
517    // For object schemas, set additionalProperties to false
518    let is_object_type = obj.get("type").and_then(|t| t.as_str()).is_some_and(|t| t == "object");
519    if is_object_type {
520        obj.insert("additionalProperties".to_string(), Value::Bool(false));
521    } else {
522        // For non-object schemas, remove additionalProperties if present
523        obj.remove("additionalProperties");
524    }
525
526    // Handle `items`:
527    // 1. If type is NOT "array", remove items entirely (meaningless on non-array types).
528    // 2. If type IS "array" and items is a JSON array (tuple validation syntax),
529    //    convert to single schema using the first element. Gemini requires items
530    //    on array types but only supports a single schema, not tuple validation.
531    // 3. If type IS "array" and items is already an object, keep it (valid).
532    // 4. If type IS "array" and items is missing, add a default items schema.
533    let is_array_type = obj.get("type").and_then(|t| t.as_str()).is_some_and(|t| t == "array");
534    if !is_array_type {
535        obj.remove("items");
536    } else if obj.get("items").is_some_and(|v| v.is_array()) {
537        let first_schema = obj
538            .get("items")
539            .and_then(|v| v.as_array())
540            .and_then(|arr| arr.first())
541            .cloned()
542            .unwrap_or_else(|| serde_json::json!({"type": "string"}));
543        obj.insert("items".to_string(), first_schema);
544    } else if !obj.contains_key("items") {
545        obj.insert("items".to_string(), serde_json::json!({"type": "string"}));
546    }
547
548    // Recurse into properties
549    if let Some(props) = obj.get_mut("properties")
550        && let Some(props_obj) = props.as_object_mut()
551    {
552        for value in props_obj.values_mut() {
553            remove_unsupported_keywords_vertex(value);
554        }
555    }
556
557    // Recurse into items (now guaranteed to be a single schema object if present)
558    if let Some(items) = obj.get_mut("items")
559        && items.is_object()
560    {
561        remove_unsupported_keywords_vertex(items);
562    }
563
564    // Recurse into allOf, anyOf, oneOf (may still exist if not collapsed)
565    for keyword in &["allOf", "anyOf", "oneOf"] {
566        if let Some(arr_val) = obj.get_mut(*keyword)
567            && let Some(arr) = arr_val.as_array_mut()
568        {
569            for sub in arr.iter_mut() {
570                remove_unsupported_keywords_vertex(sub);
571            }
572        }
573    }
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579    use serde_json::json;
580
581    #[test]
582    fn test_strips_schema_keyword() {
583        let adapter = GeminiSchemaAdapter::new();
584        let schema = json!({
585            "$schema": "http://json-schema.org/draft-07/schema#",
586            "type": "object",
587            "properties": { "name": { "type": "string" } }
588        });
589        let result = adapter.normalize_schema(schema);
590        assert!(result.get("$schema").is_none());
591    }
592
593    #[test]
594    fn test_removes_additional_properties() {
595        let adapter = GeminiSchemaAdapter::new();
596        let schema = json!({
597            "type": "object",
598            "properties": { "name": { "type": "string" } },
599            "additionalProperties": true
600        });
601        let result = adapter.normalize_schema(schema);
602        assert!(result.get("additionalProperties").is_none());
603    }
604
605    #[test]
606    fn test_removes_exclusive_min_max() {
607        let adapter = GeminiSchemaAdapter::new();
608        let schema = json!({
609            "type": "number",
610            "exclusiveMinimum": 0,
611            "exclusiveMaximum": 100
612        });
613        let result = adapter.normalize_schema(schema);
614        assert!(result.get("exclusiveMinimum").is_none());
615        assert!(result.get("exclusiveMaximum").is_none());
616    }
617
618    #[test]
619    fn test_removes_items_when_not_array() {
620        let adapter = GeminiSchemaAdapter::new();
621        let schema = json!({
622            "type": "object",
623            "items": { "type": "string" }
624        });
625        let result = adapter.normalize_schema(schema);
626        assert!(result.get("items").is_none());
627    }
628
629    #[test]
630    fn test_preserves_items_when_array() {
631        let adapter = GeminiSchemaAdapter::new();
632        let schema = json!({
633            "type": "array",
634            "items": { "type": "string" }
635        });
636        let result = adapter.normalize_schema(schema);
637        assert!(result.get("items").is_some());
638        assert_eq!(result["items"]["type"], "string");
639    }
640
641    #[test]
642    fn test_converts_items_tuple_validation_to_single_schema() {
643        // Gemini's proto doesn't support tuple validation (items as JSON array).
644        // Convert to single schema using first element so arrays still have items.
645        let adapter = GeminiSchemaAdapter::new();
646        let schema = json!({
647            "type": "array",
648            "items": [
649                { "type": "number" },
650                { "type": "number" }
651            ]
652        });
653        let result = adapter.normalize_schema(schema);
654        // items should be converted to the first element schema, not removed
655        assert_eq!(result["items"], json!({"type": "number"}));
656        assert_eq!(result["type"], "array");
657    }
658
659    #[test]
660    fn test_vertex_ai_converts_items_tuple_validation() {
661        let adapter = GeminiSchemaAdapter::vertex_ai();
662        let schema = json!({
663            "type": "array",
664            "items": [
665                { "type": "integer" },
666                { "type": "boolean" }
667            ]
668        });
669        let result = adapter.normalize_schema(schema);
670        // Converts to first element schema
671        assert_eq!(result["items"], json!({"type": "integer"}));
672    }
673
674    #[test]
675    fn test_removes_not_keyword() {
676        let adapter = GeminiSchemaAdapter::new();
677        let schema = json!({
678            "type": "string",
679            "not": { "enum": ["bad"] }
680        });
681        let result = adapter.normalize_schema(schema);
682        assert!(result.get("not").is_none());
683    }
684
685    #[test]
686    fn test_removes_property_names() {
687        let adapter = GeminiSchemaAdapter::new();
688        let schema = json!({
689            "type": "object",
690            "propertyNames": { "pattern": "^[a-z]+$" }
691        });
692        let result = adapter.normalize_schema(schema);
693        assert!(result.get("propertyNames").is_none());
694    }
695
696    #[test]
697    fn test_removes_pattern_properties() {
698        let adapter = GeminiSchemaAdapter::new();
699        let schema = json!({
700            "type": "object",
701            "patternProperties": { "^S_": { "type": "string" } }
702        });
703        let result = adapter.normalize_schema(schema);
704        assert!(result.get("patternProperties").is_none());
705    }
706
707    #[test]
708    fn test_removes_unevaluated_properties() {
709        let adapter = GeminiSchemaAdapter::new();
710        let schema = json!({
711            "type": "object",
712            "unevaluatedProperties": false
713        });
714        let result = adapter.normalize_schema(schema);
715        assert!(result.get("unevaluatedProperties").is_none());
716    }
717
718    #[test]
719    fn test_collapses_any_of() {
720        let adapter = GeminiSchemaAdapter::new();
721        let schema = json!({
722            "anyOf": [
723                { "type": "null" },
724                { "type": "string", "description": "A non-empty string" }
725            ]
726        });
727        let result = adapter.normalize_schema(schema);
728        assert!(result.get("anyOf").is_none());
729        assert_eq!(result["type"], "string");
730        assert_eq!(result["description"], "A non-empty string");
731    }
732
733    #[test]
734    fn test_collapses_one_of() {
735        let adapter = GeminiSchemaAdapter::new();
736        let schema = json!({
737            "oneOf": [
738                { "type": "null" },
739                { "type": "integer", "minimum": 0 }
740            ]
741        });
742        let result = adapter.normalize_schema(schema);
743        assert!(result.get("oneOf").is_none());
744        assert_eq!(result["type"], "integer");
745    }
746
747    #[test]
748    fn test_merges_all_of() {
749        let adapter = GeminiSchemaAdapter::new();
750        let schema = json!({
751            "allOf": [
752                { "type": "object", "properties": { "a": { "type": "string" } } },
753                { "properties": { "b": { "type": "number" } }, "required": ["b"] }
754            ]
755        });
756        let result = adapter.normalize_schema(schema);
757        assert!(result.get("allOf").is_none());
758        assert_eq!(result["properties"]["a"]["type"], "string");
759        assert_eq!(result["properties"]["b"]["type"], "number");
760        assert_eq!(result["required"], json!(["b"]));
761    }
762
763    #[test]
764    fn test_collapses_type_arrays() {
765        let adapter = GeminiSchemaAdapter::new();
766        let schema = json!({
767            "type": ["string", "null"],
768            "minLength": 1
769        });
770        let result = adapter.normalize_schema(schema);
771        assert_eq!(result["type"], "string");
772    }
773
774    #[test]
775    fn test_strips_conditional_keywords() {
776        let adapter = GeminiSchemaAdapter::new();
777        let schema = json!({
778            "type": "object",
779            "if": { "properties": { "kind": { "const": "a" } } },
780            "then": { "required": ["extra"] },
781            "else": { "required": [] }
782        });
783        let result = adapter.normalize_schema(schema);
784        assert!(result.get("if").is_none());
785        assert!(result.get("then").is_none());
786        assert!(result.get("else").is_none());
787    }
788
789    #[test]
790    fn test_converts_const_to_enum() {
791        let adapter = GeminiSchemaAdapter::new();
792        let schema = json!({
793            "type": "string",
794            "const": "fixed"
795        });
796        let result = adapter.normalize_schema(schema);
797        assert!(result.get("const").is_none());
798        assert_eq!(result["enum"], json!(["fixed"]));
799    }
800
801    #[test]
802    fn test_strips_null_from_enum() {
803        let adapter = GeminiSchemaAdapter::new();
804        let schema = json!({
805            "type": "string",
806            "enum": ["a", null, "b"]
807        });
808        let result = adapter.normalize_schema(schema);
809        assert_eq!(result["enum"], json!(["a", "b"]));
810    }
811
812    #[test]
813    fn test_removes_empty_enum_after_null_strip() {
814        let adapter = GeminiSchemaAdapter::new();
815        let schema = json!({
816            "type": "string",
817            "enum": [null]
818        });
819        let result = adapter.normalize_schema(schema);
820        assert!(result.get("enum").is_none());
821    }
822
823    #[test]
824    fn test_adds_implicit_object_type() {
825        let adapter = GeminiSchemaAdapter::new();
826        let schema = json!({
827            "properties": { "name": { "type": "string" } }
828        });
829        let result = adapter.normalize_schema(schema);
830        assert_eq!(result["type"], "object");
831    }
832
833    #[test]
834    fn test_strips_unsupported_formats() {
835        let adapter = GeminiSchemaAdapter::new();
836        let schema = json!({
837            "type": "object",
838            "properties": {
839                "created": { "type": "string", "format": "date-time" },
840                "hostname": { "type": "string", "format": "hostname" },
841                "id": { "type": "string", "format": "uuid" }
842            }
843        });
844        let result = adapter.normalize_schema(schema);
845        assert_eq!(result["properties"]["created"]["format"], "date-time");
846        assert!(result["properties"]["hostname"].get("format").is_none());
847        assert_eq!(result["properties"]["id"]["format"], "uuid");
848    }
849
850    #[test]
851    fn test_preserves_all_allowed_formats() {
852        let adapter = GeminiSchemaAdapter::new();
853        for format in GEMINI_ALLOWED_FORMATS {
854            let schema = json!({ "type": "string", "format": format });
855            let result = adapter.normalize_schema(schema);
856            assert_eq!(result["format"], *format, "format '{format}' should be preserved");
857        }
858    }
859
860    #[test]
861    fn test_enforces_nesting_depth() {
862        let adapter = GeminiSchemaAdapter::new();
863        // Create a schema nested 7 levels deep
864        let schema = json!({
865            "type": "object",
866            "properties": {
867                "l1": {
868                    "type": "object",
869                    "properties": {
870                        "l2": {
871                            "type": "object",
872                            "properties": {
873                                "l3": {
874                                    "type": "object",
875                                    "properties": {
876                                        "l4": {
877                                            "type": "object",
878                                            "properties": {
879                                                "l5": {
880                                                    "type": "object",
881                                                    "properties": {
882                                                        "l6": { "type": "string" }
883                                                    }
884                                                }
885                                            }
886                                        }
887                                    }
888                                }
889                            }
890                        }
891                    }
892                }
893            }
894        });
895        let result = adapter.normalize_schema(schema);
896        // At depth 5, the schema should be truncated to {"type": "object"}
897        let l5 = &result["properties"]["l1"]["properties"]["l2"]["properties"]["l3"]["properties"]
898            ["l4"]["properties"]["l5"];
899        assert_eq!(l5, &json!({"type": "object"}));
900    }
901
902    #[test]
903    fn test_resolves_refs() {
904        let adapter = GeminiSchemaAdapter::new();
905        let schema = json!({
906            "type": "object",
907            "properties": {
908                "address": { "$ref": "#/definitions/Address" }
909            },
910            "definitions": {
911                "Address": {
912                    "type": "object",
913                    "properties": {
914                        "street": { "type": "string" }
915                    }
916                }
917            }
918        });
919        let result = adapter.normalize_schema(schema);
920        // $ref should be resolved
921        assert!(result["properties"]["address"].get("$ref").is_none());
922        assert_eq!(result["properties"]["address"]["type"], "object");
923        assert_eq!(result["properties"]["address"]["properties"]["street"]["type"], "string");
924        // definitions should be removed
925        assert!(result.get("definitions").is_none());
926    }
927
928    #[test]
929    fn test_resolves_dollar_defs() {
930        let adapter = GeminiSchemaAdapter::new();
931        let schema = json!({
932            "type": "object",
933            "properties": {
934                "item": { "$ref": "#/$defs/Item" }
935            },
936            "$defs": {
937                "Item": {
938                    "type": "object",
939                    "properties": {
940                        "name": { "type": "string" }
941                    }
942                }
943            }
944        });
945        let result = adapter.normalize_schema(schema);
946        assert!(result["properties"]["item"].get("$ref").is_none());
947        assert_eq!(result["properties"]["item"]["type"], "object");
948        assert!(result.get("$defs").is_none());
949    }
950
951    #[test]
952    fn test_unresolvable_ref_becomes_object() {
953        let adapter = GeminiSchemaAdapter::new();
954        let schema = json!({
955            "type": "object",
956            "properties": {
957                "unknown": { "$ref": "#/definitions/DoesNotExist" }
958            }
959        });
960        let result = adapter.normalize_schema(schema);
961        assert_eq!(result["properties"]["unknown"], json!({"type": "object"}));
962    }
963
964    #[test]
965    fn test_circular_ref_breaks() {
966        let adapter = GeminiSchemaAdapter::new();
967        let schema = json!({
968            "type": "object",
969            "properties": {
970                "self_ref": { "$ref": "#/definitions/Node" }
971            },
972            "definitions": {
973                "Node": {
974                    "type": "object",
975                    "properties": {
976                        "child": { "$ref": "#/definitions/Node" }
977                    }
978                }
979            }
980        });
981        let result = adapter.normalize_schema(schema);
982        // Should not panic and should terminate
983        assert_eq!(result["properties"]["self_ref"]["type"], "object");
984        assert!(result.get("definitions").is_none());
985    }
986
987    #[test]
988    fn test_removes_definitions_and_defs() {
989        let adapter = GeminiSchemaAdapter::new();
990        let schema = json!({
991            "type": "object",
992            "definitions": { "Foo": { "type": "string" } },
993            "$defs": { "Bar": { "type": "number" } }
994        });
995        let result = adapter.normalize_schema(schema);
996        assert!(result.get("definitions").is_none());
997        assert!(result.get("$defs").is_none());
998    }
999
1000    #[test]
1001    fn test_nested_unsupported_keywords_removed() {
1002        let adapter = GeminiSchemaAdapter::new();
1003        let schema = json!({
1004            "type": "object",
1005            "properties": {
1006                "inner": {
1007                    "type": "object",
1008                    "additionalProperties": false,
1009                    "exclusiveMinimum": 5,
1010                    "properties": {
1011                        "deep": {
1012                            "type": "number",
1013                            "exclusiveMaximum": 100
1014                        }
1015                    }
1016                }
1017            }
1018        });
1019        let result = adapter.normalize_schema(schema);
1020        let inner = &result["properties"]["inner"];
1021        assert!(inner.get("additionalProperties").is_none());
1022        assert!(inner.get("exclusiveMinimum").is_none());
1023        assert!(inner["properties"]["deep"].get("exclusiveMaximum").is_none());
1024    }
1025
1026    #[test]
1027    fn test_full_transform_pipeline() {
1028        let adapter = GeminiSchemaAdapter::new();
1029        let schema = json!({
1030            "$schema": "http://json-schema.org/draft-07/schema#",
1031            "definitions": {
1032                "Status": { "type": "string", "enum": ["active", null, "inactive"] }
1033            },
1034            "properties": {
1035                "name": { "type": ["string", "null"], "format": "hostname" },
1036                "status": { "$ref": "#/definitions/Status" },
1037                "config": {
1038                    "type": "object",
1039                    "additionalProperties": true,
1040                    "properties": {
1041                        "value": { "const": "fixed" }
1042                    }
1043                }
1044            },
1045            "if": { "properties": { "name": { "type": "string" } } },
1046            "then": { "required": ["status"] },
1047            "additionalProperties": false
1048        });
1049        let result = adapter.normalize_schema(schema);
1050
1051        // $schema removed
1052        assert!(result.get("$schema").is_none());
1053        // definitions removed
1054        assert!(result.get("definitions").is_none());
1055        // conditional keywords removed
1056        assert!(result.get("if").is_none());
1057        assert!(result.get("then").is_none());
1058        // additionalProperties removed
1059        assert!(result.get("additionalProperties").is_none());
1060        // type array collapsed
1061        assert_eq!(result["properties"]["name"]["type"], "string");
1062        // unsupported format stripped
1063        assert!(result["properties"]["name"].get("format").is_none());
1064        // $ref resolved and null stripped from enum
1065        assert_eq!(result["properties"]["status"]["enum"], json!(["active", "inactive"]));
1066        // const converted to enum
1067        assert_eq!(result["properties"]["config"]["properties"]["value"]["enum"], json!(["fixed"]));
1068        // nested additionalProperties removed
1069        assert!(result["properties"]["config"].get("additionalProperties").is_none());
1070        // implicit type added
1071        assert_eq!(result["type"], "object");
1072    }
1073
1074    #[test]
1075    fn test_idempotent() {
1076        let adapter = GeminiSchemaAdapter::new();
1077        let schema = json!({
1078            "$schema": "http://json-schema.org/draft-07/schema#",
1079            "type": "object",
1080            "properties": {
1081                "name": { "type": ["string", "null"], "format": "hostname" },
1082                "items": { "type": "array", "items": { "type": "string" } }
1083            },
1084            "additionalProperties": true,
1085            "if": { "const": true },
1086            "then": { "required": ["name"] }
1087        });
1088        let first = adapter.normalize_schema(schema);
1089        let second = adapter.normalize_schema(first.clone());
1090        assert_eq!(first, second);
1091    }
1092
1093    #[test]
1094    fn test_empty_schema() {
1095        let adapter = GeminiSchemaAdapter::new();
1096        let schema = json!({});
1097        let result = adapter.normalize_schema(schema);
1098        assert_eq!(result, json!({}));
1099    }
1100
1101    #[test]
1102    fn test_array_items_nested_cleanup() {
1103        let adapter = GeminiSchemaAdapter::new();
1104        let schema = json!({
1105            "type": "array",
1106            "items": {
1107                "type": "object",
1108                "additionalProperties": true,
1109                "properties": {
1110                    "id": { "type": "integer", "exclusiveMinimum": 0 }
1111                }
1112            }
1113        });
1114        let result = adapter.normalize_schema(schema);
1115        assert!(result["items"].get("additionalProperties").is_none());
1116        assert!(result["items"]["properties"]["id"].get("exclusiveMinimum").is_none());
1117    }
1118
1119    // --- Task 4.2: Vertex AI surface variant tests ---
1120
1121    #[test]
1122    fn test_vertex_ai_sets_additional_properties_false() {
1123        let adapter = GeminiSchemaAdapter::vertex_ai();
1124        let schema = json!({
1125            "type": "object",
1126            "properties": { "name": { "type": "string" } },
1127            "additionalProperties": true
1128        });
1129        let result = adapter.normalize_schema(schema);
1130        assert_eq!(result["additionalProperties"], json!(false));
1131    }
1132
1133    #[test]
1134    fn test_vertex_ai_sets_additional_properties_false_on_nested_objects() {
1135        let adapter = GeminiSchemaAdapter::vertex_ai();
1136        let schema = json!({
1137            "type": "object",
1138            "properties": {
1139                "inner": {
1140                    "type": "object",
1141                    "properties": {
1142                        "value": { "type": "string" }
1143                    }
1144                }
1145            }
1146        });
1147        let result = adapter.normalize_schema(schema);
1148        assert_eq!(result["additionalProperties"], json!(false));
1149        assert_eq!(result["properties"]["inner"]["additionalProperties"], json!(false));
1150    }
1151
1152    #[test]
1153    fn test_vertex_ai_does_not_set_additional_properties_on_non_object() {
1154        let adapter = GeminiSchemaAdapter::vertex_ai();
1155        let schema = json!({
1156            "type": "string",
1157            "additionalProperties": true
1158        });
1159        let result = adapter.normalize_schema(schema);
1160        // Non-object schemas should have additionalProperties removed
1161        assert!(result.get("additionalProperties").is_none());
1162    }
1163
1164    #[test]
1165    fn test_standard_mode_removes_additional_properties() {
1166        let adapter = GeminiSchemaAdapter::new();
1167        let schema = json!({
1168            "type": "object",
1169            "properties": { "name": { "type": "string" } },
1170            "additionalProperties": true
1171        });
1172        let result = adapter.normalize_schema(schema);
1173        assert!(result.get("additionalProperties").is_none());
1174    }
1175
1176    #[test]
1177    fn test_vertex_ai_still_removes_other_unsupported_keywords() {
1178        let adapter = GeminiSchemaAdapter::vertex_ai();
1179        let schema = json!({
1180            "type": "object",
1181            "properties": { "x": { "type": "number" } },
1182            "exclusiveMinimum": 0,
1183            "exclusiveMaximum": 100,
1184            "not": { "type": "null" },
1185            "propertyNames": { "pattern": "^[a-z]" },
1186            "patternProperties": { "^S_": { "type": "string" } },
1187            "unevaluatedProperties": false
1188        });
1189        let result = adapter.normalize_schema(schema);
1190        assert!(result.get("exclusiveMinimum").is_none());
1191        assert!(result.get("exclusiveMaximum").is_none());
1192        assert!(result.get("not").is_none());
1193        assert!(result.get("propertyNames").is_none());
1194        assert!(result.get("patternProperties").is_none());
1195        assert!(result.get("unevaluatedProperties").is_none());
1196        // But additionalProperties: false is set
1197        assert_eq!(result["additionalProperties"], json!(false));
1198    }
1199
1200    // --- Task 4.3: normalize_tool_name tests ---
1201
1202    #[test]
1203    fn test_normalize_tool_name_short_name_unchanged() {
1204        let adapter = GeminiSchemaAdapter::new();
1205        let name = "get_weather";
1206        let result = adapter.normalize_tool_name(name);
1207        assert_eq!(result, "get_weather");
1208        assert!(matches!(result, Cow::Borrowed(_)));
1209    }
1210
1211    #[test]
1212    fn test_normalize_tool_name_exactly_64_bytes() {
1213        let adapter = GeminiSchemaAdapter::new();
1214        let name = "a".repeat(64);
1215        let result = adapter.normalize_tool_name(&name);
1216        assert_eq!(result.len(), 64);
1217        assert!(matches!(result, Cow::Borrowed(_)));
1218    }
1219
1220    #[test]
1221    fn test_normalize_tool_name_truncates_at_64_bytes() {
1222        let adapter = GeminiSchemaAdapter::new();
1223        let name = "a".repeat(100);
1224        let result = adapter.normalize_tool_name(&name);
1225        assert_eq!(result.len(), 64);
1226        assert_eq!(result.as_ref(), "a".repeat(64));
1227    }
1228
1229    #[test]
1230    fn test_normalize_tool_name_multibyte_boundary() {
1231        let adapter = GeminiSchemaAdapter::new();
1232        // Each '日' is 3 bytes in UTF-8. 21 chars = 63 bytes.
1233        // Adding one more '日' would be 66 bytes, so truncation should stop at 63.
1234        let name = "日".repeat(22); // 66 bytes
1235        let result = adapter.normalize_tool_name(&name);
1236        assert!(result.len() <= 64);
1237        // Should be 63 bytes (21 chars × 3 bytes)
1238        assert_eq!(result.len(), 63);
1239        assert_eq!(result.as_ref(), "日".repeat(21));
1240        // Verify it's valid UTF-8
1241        assert!(std::str::from_utf8(result.as_bytes()).is_ok());
1242    }
1243
1244    #[test]
1245    fn test_normalize_tool_name_emoji_boundary() {
1246        let adapter = GeminiSchemaAdapter::new();
1247        // '🎯' is 4 bytes. 16 emojis = 64 bytes exactly.
1248        let name = "🎯".repeat(16);
1249        assert_eq!(name.len(), 64);
1250        let result = adapter.normalize_tool_name(&name);
1251        assert_eq!(result.len(), 64);
1252
1253        // 17 emojis = 68 bytes, should truncate to 16 emojis = 64 bytes
1254        let name = "🎯".repeat(17);
1255        let result = adapter.normalize_tool_name(&name);
1256        assert_eq!(result.len(), 64);
1257        assert_eq!(result.as_ref(), "🎯".repeat(16));
1258    }
1259
1260    // --- Task 4.4: empty_schema tests ---
1261
1262    #[test]
1263    fn test_empty_schema_returns_object_with_properties() {
1264        let adapter = GeminiSchemaAdapter::new();
1265        let result = adapter.empty_schema();
1266        assert_eq!(result, json!({"type": "object", "properties": {}}));
1267    }
1268
1269    #[test]
1270    fn test_empty_schema_vertex_ai_same_as_standard() {
1271        let adapter = GeminiSchemaAdapter::vertex_ai();
1272        let result = adapter.empty_schema();
1273        assert_eq!(result, json!({"type": "object", "properties": {}}));
1274    }
1275
1276    // --- Comprehensive unsupported keyword stripping tests ---
1277    // These validate that ALL keywords not in Gemini's Schema proto are removed.
1278
1279    #[test]
1280    fn test_removes_all_validation_keywords() {
1281        let adapter = GeminiSchemaAdapter::new();
1282        let schema = json!({
1283            "type": "object",
1284            "title": "MySchema",
1285            "$id": "https://example.com/schema",
1286            "default": {},
1287            "deprecated": true,
1288            "readOnly": true,
1289            "writeOnly": false,
1290            "examples": [{"name": "test"}],
1291            "minProperties": 1,
1292            "maxProperties": 10,
1293            "properties": {
1294                "name": {
1295                    "type": "string",
1296                    "title": "Name",
1297                    "default": "",
1298                    "minLength": 1,
1299                    "maxLength": 100,
1300                    "pattern": "^[a-z]+$"
1301                },
1302                "age": {
1303                    "type": "integer",
1304                    "minimum": 0,
1305                    "maximum": 150,
1306                    "multipleOf": 1
1307                },
1308                "tags": {
1309                    "type": "array",
1310                    "items": { "type": "string" },
1311                    "minItems": 1,
1312                    "maxItems": 10,
1313                    "uniqueItems": true,
1314                    "contains": { "type": "string" }
1315                }
1316            }
1317        });
1318        let result = adapter.normalize_schema(schema);
1319
1320        // Top-level annotation/validation keywords removed
1321        assert!(result.get("title").is_none());
1322        assert!(result.get("$id").is_none());
1323        assert!(result.get("default").is_none());
1324        assert!(result.get("deprecated").is_none());
1325        assert!(result.get("readOnly").is_none());
1326        assert!(result.get("writeOnly").is_none());
1327        assert!(result.get("examples").is_none());
1328        assert!(result.get("minProperties").is_none());
1329        assert!(result.get("maxProperties").is_none());
1330
1331        // String property: validation keywords removed, type/description preserved
1332        let name = &result["properties"]["name"];
1333        assert!(name.get("title").is_none());
1334        assert!(name.get("default").is_none());
1335        assert!(name.get("minLength").is_none());
1336        assert!(name.get("maxLength").is_none());
1337        assert!(name.get("pattern").is_none());
1338        assert_eq!(name["type"], "string");
1339
1340        // Integer property: numeric constraints removed
1341        let age = &result["properties"]["age"];
1342        assert!(age.get("minimum").is_none());
1343        assert!(age.get("maximum").is_none());
1344        assert!(age.get("multipleOf").is_none());
1345        assert_eq!(age["type"], "integer");
1346
1347        // Array property: array constraints removed, items preserved
1348        let tags = &result["properties"]["tags"];
1349        assert!(tags.get("minItems").is_none());
1350        assert!(tags.get("maxItems").is_none());
1351        assert!(tags.get("uniqueItems").is_none());
1352        assert!(tags.get("contains").is_none());
1353        assert_eq!(tags["type"], "array");
1354        assert_eq!(tags["items"]["type"], "string");
1355    }
1356
1357    #[test]
1358    fn test_removes_prefix_items() {
1359        let adapter = GeminiSchemaAdapter::new();
1360        let schema = json!({
1361            "type": "array",
1362            "prefixItems": [
1363                { "type": "string" },
1364                { "type": "integer" }
1365            ]
1366        });
1367        let result = adapter.normalize_schema(schema);
1368        assert!(result.get("prefixItems").is_none());
1369    }
1370
1371    #[test]
1372    fn test_removes_dependent_keywords() {
1373        let adapter = GeminiSchemaAdapter::new();
1374        let schema = json!({
1375            "type": "object",
1376            "properties": {
1377                "name": { "type": "string" },
1378                "credit_card": { "type": "string" }
1379            },
1380            "dependentRequired": {
1381                "credit_card": ["billing_address"]
1382            },
1383            "dependentSchemas": {
1384                "credit_card": {
1385                    "properties": {
1386                        "billing_address": { "type": "string" }
1387                    }
1388                }
1389            }
1390        });
1391        let result = adapter.normalize_schema(schema);
1392        assert!(result.get("dependentRequired").is_none());
1393        assert!(result.get("dependentSchemas").is_none());
1394    }
1395
1396    #[test]
1397    fn test_removes_content_keywords() {
1398        let adapter = GeminiSchemaAdapter::new();
1399        let schema = json!({
1400            "type": "string",
1401            "contentMediaType": "application/json",
1402            "contentEncoding": "base64"
1403        });
1404        let result = adapter.normalize_schema(schema);
1405        assert!(result.get("contentMediaType").is_none());
1406        assert!(result.get("contentEncoding").is_none());
1407    }
1408
1409    // --- Dialect selection -------------------------------------------------
1410
1411    /// The mapping that makes the reduction and the wire field one decision.
1412    #[test]
1413    fn parameters_field_follows_the_dialect() {
1414        assert_eq!(GeminiSchemaAdapter::new().parameters_field(), "parameters");
1415        assert_eq!(GeminiSchemaAdapter::vertex_ai().parameters_field(), "parameters");
1416        assert_eq!(GeminiSchemaAdapter::json_schema().parameters_field(), "parametersJsonSchema");
1417        // The default must stay the legacy field: it is what every existing
1418        // caller gets without asking.
1419        assert_eq!(GeminiSchemaDialect::default(), GeminiSchemaDialect::OpenApiSubset);
1420    }
1421
1422    /// A realistic acquisition schema: a conditional rule, a closed object, and
1423    /// a bounded string. Under the OpenAPI subset all three are stripped, which
1424    /// is the whole defect — the caller keeps enforcing them while the model is
1425    /// never shown them.
1426    fn schema_with_constraints_the_subset_cannot_carry() -> Value {
1427        json!({
1428            "type": "object",
1429            "additionalProperties": false,
1430            "properties": {
1431                "request_kind": {"type": "string", "enum": ["order", "information"]},
1432                "callback_number": {"type": "string", "minLength": 7},
1433                "party_size": {"type": "integer", "minimum": 1, "maximum": 40}
1434            },
1435            "required": ["request_kind"],
1436            "allOf": [{
1437                "if": {"properties": {"request_kind": {"const": "order"}}, "required": ["request_kind"]},
1438                "then": {"required": ["callback_number"]}
1439            }]
1440        })
1441    }
1442
1443    #[test]
1444    fn json_schema_dialect_keeps_what_the_subset_strips() {
1445        let result = GeminiSchemaAdapter::json_schema()
1446            .normalize_schema(schema_with_constraints_the_subset_cannot_carry());
1447
1448        assert_eq!(result["additionalProperties"], json!(false));
1449        assert!(result.get("allOf").is_some(), "conditional rule dropped: {result}");
1450        assert_eq!(result["properties"]["callback_number"]["minLength"], 7);
1451        assert_eq!(result["properties"]["party_size"]["minimum"], 1);
1452        assert_eq!(result["properties"]["party_size"]["maximum"], 40);
1453    }
1454
1455    /// `add_implicit_object_type` is skipped for JSON Schema because it is
1456    /// actively wrong there, not merely redundant: it stamps `type: "object"`
1457    /// onto every schema-shaped node it walks, including an `if` clause, which
1458    /// changes what that clause matches.
1459    #[test]
1460    fn json_schema_dialect_does_not_stamp_a_type_onto_conditionals() {
1461        let result = GeminiSchemaAdapter::json_schema()
1462            .normalize_schema(schema_with_constraints_the_subset_cannot_carry());
1463
1464        let if_clause = &result["allOf"][0]["if"];
1465        assert!(
1466            if_clause.get("type").is_none(),
1467            "an implicit object type was injected into the `if` clause: {if_clause}"
1468        );
1469    }
1470
1471    /// The non-breaking claim, held by a test rather than by assertion: the two
1472    /// pre-existing constructors reduce exactly as before.
1473    #[test]
1474    fn openapi_subset_dialects_still_reduce_as_before() {
1475        for adapter in [GeminiSchemaAdapter::new(), GeminiSchemaAdapter::vertex_ai()] {
1476            // Still the legacy field, so the reduction and the field stay
1477            // consistent for callers who never opt in.
1478            assert_eq!(adapter.parameters_field(), "parameters");
1479
1480            let result =
1481                adapter.normalize_schema(schema_with_constraints_the_subset_cannot_carry());
1482
1483            assert!(result.get("allOf").is_none(), "{result}");
1484            assert!(result["properties"]["callback_number"].get("minLength").is_none());
1485            assert!(result["properties"]["party_size"].get("minimum").is_none());
1486        }
1487    }
1488}