language_models: Fix Gemini tool parameter nullability and multi-type schema (#49292)

Transform JSON schemas for Google AI tools to use `nullable: true`
instead of `type: ["type", "null"]`, which is not supported by the
Gemini API.

Additionally, convert multi-type arrays (e.g., `type: ["string",
"number"]`)
to `anyOf` constraints, as Gemini expects a single string for the `type`
field.

This handles recursive transformation of properties, items, definitions,
and logical operators, safely merging conflicting `anyOf` and `allOf`
constraints.

Closes https://github.com/zed-industries/zed/issues/44875
Closes https://github.com/zed-industries/zed/issues/32429

Release Notes:

- Fixed a bug where using Gemini with certain tools (especially via MCP)
resulted in "Invalid JSON payload received" errors due to incompatible
JSON schema formats.

## Testing

Added unit tests in `crates/language_model/src/tool_schema.rs` covering
nullability, multi-types, and `oneOf` conversions.

### Manual Testing with MCP Test Server
The [MCP Test Server](https://github.com/dastrobu/mcp-test-server) was
used to verify these edge cases with Gemini 3 Flash.

#### Setup
1. Install the test server: `cargo install --git
https://github.com/dastrobu/mcp-test-server`
2. Add to Zed `settings.json`:
   ```json
   "context_servers": [
     {
       "command": "mcp-test-server"
     }
   ]
   ```


Use the following pattern in a chat window:

```
call the add_tool function to create a new tool: weather with input schema: 

  {
    "type": "object",
    "properties": {
      "city": { "type": ["string", "null"] }
    }
  }
```
Afterwards:
...

```
call it
```

Without fix: 

<img width="671" height="448" alt="image"
src="https://github.com/user-attachments/assets/e8b6e7bd-d431-4a7e-8c9a-fff73d82127d"
/>

With fix:

<img width="729" height="334" alt="image"
src="https://github.com/user-attachments/assets/eb758765-4c34-4915-a8cf-dd4bfb993619"
/>

#### Cases verified manually:



**1. Nullability in properties**
- **Input:**
  ```json
  {
    "type": "object",
    "properties": {
      "city": { "type": ["string", "null"] }
    }
  }
  ```
- **Converted:**
  ```json
  {
    "type": "object",
    "properties": {
      "city": { "type": "string", "nullable": true }
    }
  }
  ```

**2. Multi-type properties**
- **Input:**
  ```json
  {
    "type": "object",
    "properties": {
      "city": { "type": ["string", "number"] }
    }
  }
  ```
- **Converted:**
  ```json
  {
    "type": "object",
    "properties": {
      "city": {
        "anyOf": [
          { "type": "string" },
          { "type": "number" }
        ]
      }
    }
  }
  ```

**3. Explicit `anyOf` with nullability**
- **Input:**
  ```json
  {
    "type": "object",
    "properties": {
      "city": {
        "anyOf": [
          { "type": "string" },
          { "type": "null" }
        ]
      }
    }
  }
  ```
- **Converted:**
  ```json
  {
    "type": "object",
    "properties": {
      "city": {
        "anyOf": [
          { "type": "string" },
          { "nullable": true }
        ]
      }
    }
  }
  ```

**4. Conflicting `anyOf` sources (Multi-type + existing `anyOf`)**
- **Input:**
  ```json
  {
    "type": "object",
    "properties": {
      "city": {
        "type": ["string", "number"],
        "anyOf": [
          { "minLength": 5 }
        ]
      }
    }
  }
  ```
- **Converted:**
  ```json
  {
    "type": "object",
    "properties": {
      "city": {
        "allOf": [
          { "anyOf": [{ "minLength": 5 }] },
          { "anyOf": [{ "type": "string" }, { "type": "number" }] }
        ]
      }
    }
  }
  ```

Co-authored-by: Richard Feldman <richard@zed.dev>
This commit is contained in:
Daniel StrobuschandRichard Feldman authored and GitHub committed 2026-05-19 15:56:34 +00:00
1 parent 8b98750d1d
commit deb6f199b0
3 files changed
+351 -8

No files matched your search

Generated
+1
View File
@@ -9651,6 +9651,7 @@ dependencies = [
"futures 0.3.32",
"gpui_shared_string",
"http_client",
"log",
"partial-json-fixer",
"schemars 1.0.4",
"serde",
+1
View File
@@ -19,6 +19,7 @@ cloud_llm_client.workspace = true
futures.workspace = true
gpui_shared_string.workspace = true
http_client.workspace = true
log.workspace = true
partial-json-fixer.workspace = true
schemars.workspace = true
serde.workspace = true
+349 -8
View File
@@ -4,7 +4,7 @@ use schemars::{
generate::SchemaSettings,
transform::{Transform, transform_subschemas},
};
use serde_json::Value;
use serde_json::{Map, Value, json};
/// Indicates the format used to define the input schema for a language model tool.
#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
@@ -64,6 +64,8 @@ pub fn adapt_schema_to_format(
json: &mut Value,
format: LanguageModelToolSchemaFormat,
) -> Result<()> {
log::trace!("Adapting schema to format {:?}: {}", format, json);
if let Value::Object(obj) = json {
obj.remove("$schema");
obj.remove("title");
@@ -73,7 +75,10 @@ pub fn adapt_schema_to_format(
match format {
LanguageModelToolSchemaFormat::JsonSchema => preprocess_json_schema(json),
LanguageModelToolSchemaFormat::JsonSchemaSubset => adapt_to_json_schema_subset(json),
}
}?;
log::trace!("Adapted schema: {}", json);
Ok(())
}
fn preprocess_json_schema(json: &mut Value) -> Result<()> {
@@ -118,6 +123,9 @@ fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> {
}
}
convert_null_in_types_to_nullable(obj);
convert_types_to_any_of_defs(obj);
// Ensure that the type field is not an array. This can happen with MCP tool
// schemas that use multiple types (e.g. `["string", "number"]` or `["string", "null"]`).
if let Some(type_value) = obj.get_mut("type")
@@ -126,7 +134,6 @@ fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> {
{
*type_value = first_type;
}
if matches!(obj.get("description"), Some(Value::String(_)))
&& !obj.contains_key("type")
&& !(obj.contains_key("anyOf")
@@ -141,7 +148,7 @@ fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> {
{
let subschemas_clone = subschemas.clone();
obj.remove("oneOf");
obj.insert("anyOf".to_string(), subschemas_clone);
push_any_of_constraint(obj, subschemas_clone);
}
for (_, value) in obj.iter_mut() {
@@ -157,11 +164,278 @@ fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> {
Ok(())
}
fn convert_null_in_types_to_nullable(obj: &mut Map<String, Value>) {
let mut nullable_found_in_type = false;
if let Some(type_entry) = obj.get_mut("type") {
if let Some(types) = type_entry.as_array_mut() {
let mut had_null_type = false;
types.retain(|t| {
if t.as_str() == Some("null") {
had_null_type = true;
false
} else {
true
}
});
if had_null_type {
nullable_found_in_type = true;
if types.len() == 1 {
*type_entry = types.remove(0);
} else if types.is_empty() {
obj.remove("type");
}
}
} else if let Some(type_str) = type_entry.as_str() {
if type_str == "null" {
nullable_found_in_type = true;
obj.remove("type");
}
}
}
if nullable_found_in_type {
obj.insert("nullable".to_string(), Value::Bool(true));
}
}
fn convert_types_to_any_of_defs(obj: &mut Map<String, Value>) {
if let Some(type_entry) = obj.get_mut("type") {
if let Some(types) = type_entry.as_array_mut() {
if types.len() > 1 {
let remaining_types = std::mem::take(types);
let mut any_of_schemas = Vec::new();
for t in remaining_types {
any_of_schemas.push(json!({"type": t}));
}
obj.remove("type");
push_any_of_constraint(obj, Value::Array(any_of_schemas));
}
}
}
}
fn push_any_of_constraint(obj: &mut Map<String, Value>, any_of_schemas: Value) {
if let Some(existing_any_of) = obj.remove("anyOf") {
let mut all_of = obj
.remove("allOf")
.and_then(|v| v.as_array().cloned())
.unwrap_or_default();
if all_of.is_empty() {
all_of.push(json!({"anyOf": existing_any_of}));
}
all_of.push(json!({"anyOf": any_of_schemas}));
obj.insert("allOf".to_string(), Value::Array(all_of));
} else if let Some(all_of) = obj.get_mut("allOf").and_then(|v| v.as_array_mut()) {
all_of.push(json!({"anyOf": any_of_schemas}));
} else {
obj.insert("anyOf".to_string(), any_of_schemas);
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_convert_null_in_types_to_nullable() {
// ["string", "null"] -> "string", nullable: true
let mut obj = json!({"type": ["string", "null"]})
.as_object_mut()
.unwrap()
.to_owned();
convert_null_in_types_to_nullable(&mut obj);
assert_eq!(
obj,
json!({"type": "string", "nullable": true})
.as_object()
.unwrap()
.to_owned()
);
// "null" -> nullable: true
let mut obj = json!({"type": "null"}).as_object_mut().unwrap().to_owned();
convert_null_in_types_to_nullable(&mut obj);
assert_eq!(
obj,
json!({"nullable": true}).as_object().unwrap().to_owned()
);
// ["string", "number", "null"] -> ["string", "number"], nullable: true (anyOf handled elsewhere)
let mut obj = json!({"type": ["string", "number", "null"]})
.as_object_mut()
.unwrap()
.to_owned();
convert_null_in_types_to_nullable(&mut obj);
assert_eq!(
obj,
json!({"type": ["string", "number"], "nullable": true})
.as_object()
.unwrap()
.to_owned()
);
// "string" (no change, not nullable)
let mut obj = json!({"type": "string"})
.as_object_mut()
.unwrap()
.to_owned();
convert_null_in_types_to_nullable(&mut obj);
assert_eq!(
obj,
json!({"type": "string"}).as_object().unwrap().to_owned()
);
// ["string", "number"] (no change, not nullable)
let mut obj = json!({"type": ["string", "number"]})
.as_object_mut()
.unwrap()
.to_owned();
convert_null_in_types_to_nullable(&mut obj);
assert_eq!(
obj,
json!({"type": ["string", "number"]})
.as_object()
.unwrap()
.to_owned()
);
// object with other properties, ["boolean", "null"]
let mut obj = json!({
"description": "A test field",
"type": ["boolean", "null"]
})
.as_object_mut()
.unwrap()
.to_owned();
convert_null_in_types_to_nullable(&mut obj);
assert_eq!(
obj,
json!({
"description": "A test field",
"type": "boolean",
"nullable": true
})
.as_object()
.unwrap()
.to_owned()
);
}
#[test]
fn test_convert_types_to_any_of_defs() {
// ["string", "number"] -> anyOf with string and number
let mut obj = json!({"type": ["string", "number"]})
.as_object_mut()
.unwrap()
.to_owned();
convert_types_to_any_of_defs(&mut obj);
assert_eq!(
obj,
json!({
"anyOf": [
{"type": "string"},
{"type": "number"}
]
})
.as_object()
.unwrap()
.to_owned()
);
// "string" (no change)
let mut obj = json!({"type": "string"})
.as_object_mut()
.unwrap()
.to_owned();
convert_types_to_any_of_defs(&mut obj);
assert_eq!(
obj,
json!({"type": "string"}).as_object().unwrap().to_owned()
);
// object with other properties, ["string", "number"]
let mut obj = json!({
"description": "A test field",
"type": ["string", "number"]
})
.as_object_mut()
.unwrap()
.to_owned();
convert_types_to_any_of_defs(&mut obj);
assert_eq!(
obj,
json!({
"description": "A test field",
"anyOf": [
{"type": "string"},
{"type": "number"}
]
})
.as_object()
.unwrap()
.to_owned()
);
// anyOf already present (no change)
let mut obj = json!({
"anyOf": [
{"type": "string"},
{"type": "number"}
]
})
.as_object_mut()
.unwrap()
.to_owned();
convert_types_to_any_of_defs(&mut obj);
assert_eq!(
obj,
json!({
"anyOf": [
{"type": "string"},
{"type": "number"}
]
})
.as_object()
.unwrap()
.to_owned()
);
// both type array and anyOf present
let mut obj = json!({
"type": ["string", "number"],
"anyOf": [
{"format": "email"}
]
})
.as_object_mut()
.unwrap()
.to_owned();
convert_types_to_any_of_defs(&mut obj);
assert_eq!(
obj,
json!({
"allOf": [
{
"anyOf": [
{"format": "email"}
]
},
{
"anyOf": [
{"type": "string"},
{"type": "number"}
]
}
]
})
.as_object()
.unwrap()
.to_owned()
);
}
#[test]
fn test_transform_adds_type_when_missing() {
let mut json = json!({
@@ -259,6 +533,69 @@ mod tests {
);
}
#[test]
fn test_transform_null_in_any_of() {
let mut json = json!({
"anyOf": [
{ "type": "string" },
{ "type": "null" }
]
});
adapt_to_json_schema_subset(&mut json).unwrap();
assert_eq!(
json,
json!({
"anyOf": [
{ "type": "string" },
{ "nullable": true }
]
})
);
}
#[test]
fn test_transform_conflicting_any_of_sources() {
let mut json = json!({
"type": ["string", "number"],
"anyOf": [
{ "minLength": 5 }
],
"oneOf": [
{ "pattern": "^a" },
{ "pattern": "^b" }
]
});
adapt_to_json_schema_subset(&mut json).unwrap();
assert_eq!(
json,
json!({
"allOf": [
{
"anyOf": [
{ "minLength": 5 },
]
},
{
"anyOf": [
{"type": "string"},
{"type": "number"}
]
},
{
"anyOf": [
{ "pattern": "^a" },
{ "pattern": "^b" }
]
}
]
})
);
}
#[test]
fn test_transform_one_of_to_any_of() {
let mut json = json!({
@@ -308,8 +645,8 @@ mod tests {
"nested": {
"anyOf": [
{ "type": "string" },
{ "type": "null" }
]
{ "nullable": true }
],
}
}
})
@@ -340,12 +677,16 @@ mod tests {
"type": "object",
"properties": {
"projectSlugOrId": {
"type": "string",
"anyOf": [
{"type": "string"},
{"type": "number"}
],
"description": "Project slug or numeric ID"
},
"optionalName": {
"type": "string",
"description": "An optional name"
"description": "An optional name",
"nullable": true
}
}
})