diff --git a/provider-swift/Sources/ProviderCore/Inference/ToolSchemaNormalization.swift b/provider-swift/Sources/ProviderCore/Inference/ToolSchemaNormalization.swift index b15c1416..284e56d5 100644 --- a/provider-swift/Sources/ProviderCore/Inference/ToolSchemaNormalization.swift +++ b/provider-swift/Sources/ProviderCore/Inference/ToolSchemaNormalization.swift @@ -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) + } + 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 + /// 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..1ea375ad 100644 --- a/provider-swift/Tests/ProviderCoreTests/ToolSchemaNormalizationTests.swift +++ b/provider-swift/Tests/ProviderCoreTests/ToolSchemaNormalizationTests.swift @@ -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). + + @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) + } }