From 7e8b0ae1cea6db9c78882c53b83ff017becfaf5e Mon Sep 17 00:00:00 2001 From: Gajesh Naik <26431906+Gajesh2007@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:14:10 -0700 Subject: [PATCH 1/3] =?UTF-8?q?fix(provider):=20collapse=20array-typed=20t?= =?UTF-8?q?ool=20schema=20types=20=E2=80=94=20second=20DAR-130=20class?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consumer-reported and reproduced on prod: gemma-4 tool calls 500 with 'Runtime error: upper filter requires string' whenever a tool parameter uses the JSON-Schema nullable array form — "type": ["string","null"] — which Pydantic emits for every Optional[...] field. The DAR-130 normalizer only filled MISSING types (dict["type"] == nil); a present-but-non-string type sailed through to the template's {{ value['type'] | upper }} and crashed. The coordinator retries 3x and the consumer ultimately sees a 408/500. Fix: collapse any non-string type to one renderable string — first concrete (non-"null") member of an array type, the lone "null" when that is all the array declares, else structural inference (object/array/union-member/string, extracted into inferredType and shared with the missing-type branch). The key is never deleted: a node whose only content is its type would not be refilled and would crash anyway. 5 new tests: nullable collapse, null-first ordering, null-only, nested object/items, malformed numeric type. Full suite 708 green. --- .../Inference/ToolSchemaNormalization.swift | 55 ++++++++++--- .../ToolSchemaNormalizationTests.swift | 81 +++++++++++++++++++ 2 files changed, 124 insertions(+), 12 deletions(-) diff --git a/provider-swift/Sources/ProviderCore/Inference/ToolSchemaNormalization.swift b/provider-swift/Sources/ProviderCore/Inference/ToolSchemaNormalization.swift index b15c1416..7cf15c69 100644 --- a/provider-swift/Sources/ProviderCore/Inference/ToolSchemaNormalization.swift +++ b/provider-swift/Sources/ProviderCore/Inference/ToolSchemaNormalization.swift @@ -77,28 +77,59 @@ enum ToolSchemaNormalization { } } + // A type that is PRESENT but not a string crashes `| upper` just like a + // missing one. The common real-world shape is the JSON-Schema array form + // for nullable fields — `"type": ["string","null"]` — which Pydantic + // emits for every Optional[...] tool parameter. Collapse it to a single + // representative string (never delete the key: a node whose only content + // is its type would not be refilled below and would crash anyway). + if let t = dict["type"], !(t is String) { + dict["type"] = collapsedType(t, in: dict) + } + let looksLikeSchemaNode = dict["properties"] != nil || dict["items"] != nil || dict["additionalProperties"] != nil || dict["enum"] != nil || dict["description"] != nil || dict["anyOf"] != nil || dict["oneOf"] != nil || dict["allOf"] != nil if dict["type"] == nil, looksLikeSchemaNode { - if dict["properties"] != nil || dict["additionalProperties"] != nil { - dict["type"] = "object" - } else if dict["items"] != nil { - dict["type"] = "array" - } else if let unionType = unionMemberType(dict) { - // anyOf/oneOf/allOf without a parent type: borrow the first concrete - // member type (skipping "null") rather than mislabelling a union as a - // string. The template still gets a usable type and can't crash. - dict["type"] = unionType - } else { - dict["type"] = "string" - } + dict["type"] = inferredType(for: dict) } return dict } + /// Collapse a non-string `type` value to one renderable string: the first + /// concrete (non-"null") member of an array type, the lone "null" when that + /// is all the array declares, else fall back to structural inference. + private static func collapsedType(_ value: Any, in dict: [String: Any]) -> String { + if let members = (value as? [Any])?.compactMap({ $0 as? String }) { + if let concrete = members.first(where: { $0 != "null" }) { + return concrete + } + if let nullOnly = members.first { + return nullOnly + } + } + return inferredType(for: dict) + } + + /// Structural default for a schema node's `type`: object when it has + /// properties, array when it has items, a union member's type when it is an + /// anyOf/oneOf/allOf (skipping "null" — mislabelling a union as a string + /// would be wrong), otherwise string. + private static func inferredType(for dict: [String: Any]) -> String { + if dict["properties"] != nil || dict["additionalProperties"] != nil { + return "object" + } + if dict["items"] != nil { + return "array" + } + if let unionType = unionMemberType(dict) { + return unionType + } + return "string" + } + /// Derive a representative `type` for a union node from the first member that /// declares a concrete, non-"null" type. Returns nil when none is found. private static func unionMemberType(_ dict: [String: Any]) -> String? { diff --git a/provider-swift/Tests/ProviderCoreTests/ToolSchemaNormalizationTests.swift b/provider-swift/Tests/ProviderCoreTests/ToolSchemaNormalizationTests.swift index 2707b1e0..3b6e2c3e 100644 --- a/provider-swift/Tests/ProviderCoreTests/ToolSchemaNormalizationTests.swift +++ b/provider-swift/Tests/ProviderCoreTests/ToolSchemaNormalizationTests.swift @@ -102,4 +102,85 @@ extension ToolSchemaNormalizationTests { body.append(Data(count: ToolSchemaNormalization.maxNormalizationBytes)) #expect(ToolSchemaNormalization.ensureParameterTypes(in: body) == body) } + // MARK: - Array-typed (nullable) `type` values — the second DAR-130 class. + // `"type": ["string","null"]` is what Pydantic emits for Optional[...] tool + // parameters; the gemma template's `| upper` crashed on the list ("upper + // filter requires string", reproduced on prod 2026-06-10). + + @Test func collapsesNullableArrayTypeToConcreteMember() throws { + let body = #""" + {"tools":[{"type":"function","function":{"name":"get_weather", + "parameters":{"type":"object","properties":{ + "city":{"type":["string","null"],"description":"city"}}, + "required":["city"]}}}]} + """#.data(using: .utf8)! + + let out = ToolSchemaNormalization.ensureParameterTypes(in: body) + let function = try #require((parse(out)["tools"] as? [[String: Any]])?[0]["function"] as? [String: Any]) + let props = try #require((function["parameters"] as? [String: Any])?["properties"] as? [String: Any]) + #expect((props["city"] as? [String: Any])?["type"] as? String == "string") + } + + @Test func collapsesArrayTypeSkippingLeadingNull() throws { + let body = #""" + {"tools":[{"type":"function","function":{"name":"f", + "parameters":{"type":"object","properties":{ + "n":{"type":["null","integer"]}}}}}]} + """#.data(using: .utf8)! + + let out = ToolSchemaNormalization.ensureParameterTypes(in: body) + let function = try #require((parse(out)["tools"] as? [[String: Any]])?[0]["function"] as? [String: Any]) + let props = try #require((function["parameters"] as? [String: Any])?["properties"] as? [String: Any]) + #expect((props["n"] as? [String: Any])?["type"] as? String == "integer") + } + + @Test func collapsesNullOnlyArrayTypeToNullString() throws { + // ["null"] has no concrete member — keep the honest "null", which still + // renders (it is a string for `| upper`). + let body = #""" + {"tools":[{"type":"function","function":{"name":"f", + "parameters":{"type":"object","properties":{ + "x":{"type":["null"]}}}}}]} + """#.data(using: .utf8)! + + let out = ToolSchemaNormalization.ensureParameterTypes(in: body) + let function = try #require((parse(out)["tools"] as? [[String: Any]])?[0]["function"] as? [String: Any]) + let props = try #require((function["parameters"] as? [String: Any])?["properties"] as? [String: Any]) + #expect((props["x"] as? [String: Any])?["type"] as? String == "null") + } + + @Test func collapsesArrayTypeInNestedObjectAndItems() throws { + let body = #""" + {"tools":[{"type":"function","function":{"name":"set_alarm", + "parameters":{"type":"object","properties":{ + "opts":{"type":"object","properties":{"snooze":{"type":["integer","null"]}}}, + "tags":{"type":"array","items":{"type":["string","null"]}}}}}}]} + """#.data(using: .utf8)! + + let out = ToolSchemaNormalization.ensureParameterTypes(in: body) + let function = try #require((parse(out)["tools"] as? [[String: Any]])?[0]["function"] as? [String: Any]) + let props = try #require((function["parameters"] as? [String: Any])?["properties"] as? [String: Any]) + let snooze = try #require(((props["opts"] as? [String: Any])?["properties"] as? [String: Any])?["snooze"] as? [String: Any]) + #expect(snooze["type"] as? String == "integer") + let items = try #require((props["tags"] as? [String: Any])?["items"] as? [String: Any]) + #expect(items["type"] as? String == "string") + } + + @Test func malformedNonStringTypeFallsBackToStructuralInference() throws { + // A numeric `type` is invalid JSON Schema; repair it from structure + // (properties present → object) instead of leaving the list/number for + // the template to choke on. + let body = #""" + {"tools":[{"type":"function","function":{"name":"f", + "parameters":{"type":"object","properties":{ + "cfg":{"type":42,"properties":{"k":{"type":"string"}}}, + "v":{"type":7,"description":"v"}}}}}]} + """#.data(using: .utf8)! + + let out = ToolSchemaNormalization.ensureParameterTypes(in: body) + let function = try #require((parse(out)["tools"] as? [[String: Any]])?[0]["function"] as? [String: Any]) + let props = try #require((function["parameters"] as? [String: Any])?["properties"] as? [String: Any]) + #expect((props["cfg"] as? [String: Any])?["type"] as? String == "object") + #expect((props["v"] as? [String: Any])?["type"] as? String == "string") + } } From 4c59aff8717de400004a896a91181c8e0411349b Mon Sep 17 00:00:00 2001 From: Gajesh Naik <26431906+Gajesh2007@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:22:37 -0700 Subject: [PATCH 2/3] fix(provider): preserve nullability via the template-native nullable key (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer finding: the gemma template natively renders the standard `nullable` key, so collapsing ["string","null"] -> "string" can be LOSSLESS — set nullable:true when a null member is collapsed away (never clobbering an explicit value). Also adds three ordering/coverage tests from review: union member with array type drives parent inference, top-level parameters node collapse, additionalProperties schema collapse. Suite 11 -> 14 green. --- .../Inference/ToolSchemaNormalization.swift | 8 +++ .../ToolSchemaNormalizationTests.swift | 49 ++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/provider-swift/Sources/ProviderCore/Inference/ToolSchemaNormalization.swift b/provider-swift/Sources/ProviderCore/Inference/ToolSchemaNormalization.swift index 7cf15c69..1997342b 100644 --- a/provider-swift/Sources/ProviderCore/Inference/ToolSchemaNormalization.swift +++ b/provider-swift/Sources/ProviderCore/Inference/ToolSchemaNormalization.swift @@ -83,7 +83,15 @@ enum ToolSchemaNormalization { // emits for every Optional[...] tool parameter. Collapse it to a single // representative string (never delete the key: a node whose only content // is its type would not be refilled below and would crash anyway). + // Nullability is preserved losslessly: the gemma template natively + // renders the standard `nullable` key, so collapsing away a "null" + // member sets it (without clobbering an explicit value). if let t = dict["type"], !(t is String) { + let members = (t as? [Any])?.compactMap { $0 as? String } ?? [] + if members.contains("null"), members.contains(where: { $0 != "null" }), + dict["nullable"] == nil { + dict["nullable"] = true + } dict["type"] = collapsedType(t, in: dict) } diff --git a/provider-swift/Tests/ProviderCoreTests/ToolSchemaNormalizationTests.swift b/provider-swift/Tests/ProviderCoreTests/ToolSchemaNormalizationTests.swift index 3b6e2c3e..1ea375ad 100644 --- a/provider-swift/Tests/ProviderCoreTests/ToolSchemaNormalizationTests.swift +++ b/provider-swift/Tests/ProviderCoreTests/ToolSchemaNormalizationTests.swift @@ -118,7 +118,10 @@ extension ToolSchemaNormalizationTests { let out = ToolSchemaNormalization.ensureParameterTypes(in: body) let function = try #require((parse(out)["tools"] as? [[String: Any]])?[0]["function"] as? [String: Any]) let props = try #require((function["parameters"] as? [String: Any])?["properties"] as? [String: Any]) - #expect((props["city"] as? [String: Any])?["type"] as? String == "string") + let city = try #require(props["city"] as? [String: Any]) + #expect(city["type"] as? String == "string") + // Nullability preserved losslessly via the template-supported key. + #expect(city["nullable"] as? Bool == true) } @Test func collapsesArrayTypeSkippingLeadingNull() throws { @@ -183,4 +186,48 @@ extension ToolSchemaNormalizationTests { #expect((props["cfg"] as? [String: Any])?["type"] as? String == "object") #expect((props["v"] as? [String: Any])?["type"] as? String == "string") } + @Test func unionMemberWithArrayTypeStillDrivesParentInference() throws { + // Ordering is load-bearing: members collapse BEFORE the parent's union + // inference, so a first member declaring ["string","null"] must yield a + // "string" parent type (not fall through to the default). + let body = #""" + {"tools":[{"type":"function","function":{"name":"f", + "parameters":{"type":"object","properties":{ + "u":{"anyOf":[{"type":["string","null"]},{"type":"integer"}],"description":"u"}}}}}]} + """#.data(using: .utf8)! + + let out = ToolSchemaNormalization.ensureParameterTypes(in: body) + let function = try #require((parse(out)["tools"] as? [[String: Any]])?[0]["function"] as? [String: Any]) + let props = try #require((function["parameters"] as? [String: Any])?["properties"] as? [String: Any]) + #expect((props["u"] as? [String: Any])?["type"] as? String == "string") + } + + @Test func collapsesArrayTypeOnTopLevelParametersNode() throws { + // The template also renders params['type'] | upper at the top level. + let body = #""" + {"tools":[{"type":"function","function":{"name":"f", + "parameters":{"type":["object","null"],"properties":{"q":{"type":"string"}}}}}]} + """#.data(using: .utf8)! + + let out = ToolSchemaNormalization.ensureParameterTypes(in: body) + let function = try #require((parse(out)["tools"] as? [[String: Any]])?[0]["function"] as? [String: Any]) + let params = try #require(function["parameters"] as? [String: Any]) + #expect(params["type"] as? String == "object") + #expect(params["nullable"] as? Bool == true) + } + + @Test func collapsesArrayTypeInsideAdditionalPropertiesSchema() throws { + let body = #""" + {"tools":[{"type":"function","function":{"name":"f", + "parameters":{"type":"object","properties":{ + "kv":{"type":"object","additionalProperties":{"type":["number","null"]}}}}}}]} + """#.data(using: .utf8)! + + let out = ToolSchemaNormalization.ensureParameterTypes(in: body) + let function = try #require((parse(out)["tools"] as? [[String: Any]])?[0]["function"] as? [String: Any]) + let props = try #require((function["parameters"] as? [String: Any])?["properties"] as? [String: Any]) + let addl = try #require((props["kv"] as? [String: Any])?["additionalProperties"] as? [String: Any]) + #expect(addl["type"] as? String == "number") + #expect(addl["nullable"] as? Bool == true) + } } From 1d3df1e33a6b228e7620fb28d4a0142df6c35fa2 Mon Sep 17 00:00:00 2001 From: Gajesh Naik <26431906+Gajesh2007@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:35:46 -0700 Subject: [PATCH 3/3] refactor(provider): compute array-type members once (review nit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit collapsedType now takes the pre-extracted string members instead of re-casting the raw value — the nullable check and the collapse share one compactMap. --- .../Inference/ToolSchemaNormalization.swift | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/provider-swift/Sources/ProviderCore/Inference/ToolSchemaNormalization.swift b/provider-swift/Sources/ProviderCore/Inference/ToolSchemaNormalization.swift index 1997342b..284e56d5 100644 --- a/provider-swift/Sources/ProviderCore/Inference/ToolSchemaNormalization.swift +++ b/provider-swift/Sources/ProviderCore/Inference/ToolSchemaNormalization.swift @@ -92,7 +92,7 @@ enum ToolSchemaNormalization { dict["nullable"] == nil { dict["nullable"] = true } - dict["type"] = collapsedType(t, in: dict) + dict["type"] = collapsedType(members: members, in: dict) } let looksLikeSchemaNode = @@ -106,17 +106,16 @@ enum ToolSchemaNormalization { return dict } - /// Collapse a non-string `type` value to one renderable string: the first - /// concrete (non-"null") member of an array type, the lone "null" when that - /// is all the array declares, else fall back to structural inference. - private static func collapsedType(_ value: Any, in dict: [String: Any]) -> String { - if let members = (value as? [Any])?.compactMap({ $0 as? String }) { - if let concrete = members.first(where: { $0 != "null" }) { - return concrete - } - if let nullOnly = members.first { - return nullOnly - } + /// Collapse a non-string `type` value (pre-extracted string members of the + /// array form) to one renderable string: the first concrete (non-"null") + /// member, the lone "null" when that is all the array declares, else fall + /// back to structural inference. + private static func collapsedType(members: [String], in dict: [String: Any]) -> String { + if let concrete = members.first(where: { $0 != "null" }) { + return concrete + } + if let nullOnly = members.first { + return nullOnly } return inferredType(for: dict) }