[Rust Frontend] Keep literal "null" string for string-typed tool params (#46827)

Signed-off-by: Blas Rodriguez Irizar <rodrigblas@gmail.com>

Blas Rodriguez Irizar committed Jun 29, 2026 at 14:46 UTC 6185d73882c0cdfd9ee13cea16a9b50d2b5267be
2 files changed +48 -17
rust/src/parser/src/tool/deepseek_dsml/deepseek_v32.rs
+1 -1
@@ -154,7 +154,7 @@ mod tests {
154 "flag": true,
155 "payload": { "nested": true },
156 "items": [1, 2],
157 - "empty": null,
157 + "empty": "null",
158 })
159 );
160 }
rust/src/parser/src/tool/parameters.rs
+47 -16
@@ -166,7 +166,13 @@ impl JsonParamType {
166
167 // Typically, these types are already handled by checking the "type" field, but
168 // we can also infer them from their characteristic fields if "type" is missing.
169 - if schema.contains_key("enum") {
169 + if let Some(values) = schema.get("enum").and_then(Value::as_array) {
170 + // Enum values are treated as strings, except that a `null` member
171 + // makes the parameter nullable (mirrors Python's enum type
172 + // inference), so a literal "null" coerces to JSON null.
173 + if values.iter().any(Value::is_null) {
174 + return Some(Self::one_of(vec![Self::String, Self::Null]));
175 + }
176 return Some(Self::String);
177 }
178 if schema.contains_key("items") {
@@ -277,9 +283,12 @@ impl JsonParamType {
283
284 /// Convert one parameter input to a normalized JSON value.
285 fn convert_with_optional_schema(param_type: Option<&JsonParamType>, input: &ParamInput) -> Value {
280 - // For literal `null`, always convert to JSON null value.
286 + // Coerce the literal text `null` to JSON null, except for `string`-typed
287 + // params, where it must stay the string "null": a model emitting the literal
288 + // text "null" for a string field means the string, not a missing value.
289 if let ParamInput::Text(value) = input
290 && value.eq_ignore_ascii_case("null")
291 + && param_type != Some(&JsonParamType::String)
292 {
293 return Value::Null;
294 }
@@ -685,21 +694,43 @@ mod tests {
694 }
695
696 #[test]
688 - fn convert_params_preserves_null_for_known_param() {
689 - let schemas = ToolSchemas::from_tools(&[test_tool(
690 - "convert",
691 - json!({
692 - "type": "object",
693 - "properties": {
694 - "value": { "type": "string" }
695 - }
696 - }),
697 - )]);
697 + fn string_param_preserves_literal_null_text() {
698 + // A `string`-typed param whose value is the literal text "null"/"NULL"
699 + // must stay a string (the original case is preserved), rather than being
700 + // coerced to JSON null. Non-string types keep coercing "null" to null.
701 + let params = ToolSchema::from_schema(&json!({
702 + "type": "object",
703 + "properties": {
704 + "name": { "type": "string" },
705 + "count": { "type": "integer" },
706 + "anything": {}
707 + }
708 + }));
709 +
710 + assert_eq!(params.convert("name", text("null")), json!("null"));
711 + assert_eq!(params.convert("name", text("NULL")), json!("NULL"));
712 + // Non-string and schema-less params are unchanged: "null" -> null.
713 + assert_eq!(params.convert("count", text("null")), json!(null));
714 + assert_eq!(params.convert("anything", text("null")), json!(null));
715 + }
716
699 - let converted = schemas
700 - .convert_params_with_schema("convert", vec![("value".to_string(), "NULL".to_string())]);
717 + #[test]
718 + fn nullable_enum_param_coerces_literal_null() {
719 + // An enum that includes `null` admits a null value, so a literal "null"
720 + // must coerce to JSON null (matching Python's `extract_types_from_schema`,
721 + // which infers `null` from the enum values), while a non-null enum keeps
722 + // "null" as a string.
723 + let params = ToolSchema::from_schema(&json!({
724 + "type": "object",
725 + "properties": {
726 + "mode": { "enum": [null, "auto"] },
727 + "color": { "enum": ["red", "green"] }
728 + }
729 + }));
730
702 - assert_eq!(converted.get("value"), Some(&json!(null)));
731 + assert_eq!(params.convert("mode", text("null")), json!(null));
732 + assert_eq!(params.convert("mode", text("auto")), json!("auto"));
733 + assert_eq!(params.convert("color", text("null")), json!("null"));
734 }
735
736 #[test]
@@ -841,7 +872,7 @@ mod tests {
872 "user_id": 42,
873 "urgent": true,
874 "note": "Please leave at front desk.",
844 - "nil": null,
875 + "nil": "NULL",
876 "shipping": {
877 "city": "Singapore",
878 "zip": 18956