-
Notifications
You must be signed in to change notification settings - Fork 30
fix(provider): gemma-4 tool calls 500 on nullable (array-typed) schema fields #310
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7e8b0ae
4c59aff
1d3df1e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -77,28 +77,66 @@ 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). | ||
| // 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(members: members, in: dict) | ||
| } | ||
|
Comment on lines
+89
to
+96
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 [INFO] 🧩 Complex nullable detection logic could be simplified 💡 Suggestion: Consider extracting the nullable detection into a simple helper function or inline the logic more directly 📊 Score: 2×3 = 6 · Category: over-abstraction
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed by the same restructure: one members extraction feeding both the nullable check and the collapse; no helper needed beyond that.
Comment on lines
+89
to
+96
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 [INFO] ⚡ compactMap creates new array for every type field without pre-allocation 💡 Suggestion: Pre-allocate array capacity or use lazy evaluation if members array is typically small 📊 Score: 2×3 = 6 · Category: Unbounded allocations
Comment on lines
+89
to
+96
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 [INFO] 🧩 Complex nullable detection logic could be simplified with early return 💡 Suggestion: Extract nullable detection to a separate function or use early return to reduce nesting and improve readability 📊 Score: 2×3 = 6 · Category: over-abstraction |
||
|
|
||
| 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 (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) | ||
| } | ||
|
|
||
| /// 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 | ||
|
Comment on lines
+113
to
+125
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 [INFO] 🧩 Type inference logic duplicated between collapsedType and inferredType functions 💡 Suggestion: Consider having collapsedType delegate directly to inferredType for the fallback case instead of duplicating the structural inference logic 📊 Score: 2×2 = 4 · Category: duplicate logic |
||
| /// 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? { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -102,4 +102,132 @@ 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). | ||
|
Comment on lines
+105
to
+108
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 [INFO] 🧩 Verbose multi-line comment could be more concise 💡 Suggestion: Condense the comment to focus on the key issue: 'Array-typed nullable fields crash gemma template's | upper filter' 📊 Score: 1×2 = 2 · Category: over-abstraction
Comment on lines
+105
to
+108
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 [INFO] 🧩 Verbose multi-line comment could be more concise 💡 Suggestion: Condense the comment to focus on the key issue: array-typed fields crash gemma template's upper filter 📊 Score: 1×2 = 2 · Category: over-abstraction |
||
|
|
||
| @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]) | ||
| 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 { | ||
| 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") | ||
| } | ||
| @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) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔵 [INFO] ⚡ Array type casting performed twice for same value
💡 Suggestion: Cache the compactMap result: let members = (t as? [Any])?.compactMap { $0 as? String } ?? []; then reuse members in both conditions
📊 Score: 2×3 = 6 · Category: Repeated work
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Valid — fixed: members is computed once at the call site and passed into collapsedType(members:in:), so the nullable check and the collapse share a single compactMap.