From cb3d0bbf69fa1ee3f5c7c41bf51be90b210a1c67 Mon Sep 17 00:00:00 2001 From: James Cahall Date: Tue, 25 Aug 2026 11:15:15 -0700 Subject: [PATCH] Accept stringified primitives in Generable conversions Some chat templates stringify every tool-call argument value, so models emit {"count": "5"} where the schema declares an integer. The strict kind checks in the primitive Generable initializers then throw typeMismatch, which makes any non-String tool argument unusable on those backends. Accept string content in the Bool, Int, Float, Double, and Decimal initializers when it parses unambiguously as the target type. Native kinds decode exactly as before, and unparseable strings still throw typeMismatch. Integer parsing accepts only whole-number values. --- Sources/AnyLanguageModel/Generable.swift | 80 ++++++++++++++++--- .../PrimitiveStringCoercionTests.swift | 56 +++++++++++++ 2 files changed, 126 insertions(+), 10 deletions(-) create mode 100644 Tests/AnyLanguageModelTests/PrimitiveStringCoercionTests.swift diff --git a/Sources/AnyLanguageModel/Generable.swift b/Sources/AnyLanguageModel/Generable.swift index b7ca7fc0..9ec3ff4c 100644 --- a/Sources/AnyLanguageModel/Generable.swift +++ b/Sources/AnyLanguageModel/Generable.swift @@ -113,10 +113,17 @@ extension Bool: Generable { /// Creates an instance with the content. public init(_ content: GeneratedContent) throws { - guard case .bool(let value) = content.kind else { + switch content.kind { + case .bool(let value): + self = value + case .string(let text): + guard let value = Bool(coercing: text) else { + throw GeneratedContentConversionError.typeMismatch + } + self = value + default: throw GeneratedContentConversionError.typeMismatch } - self = value } /// An instance that represents the generated content. @@ -163,10 +170,17 @@ extension Int: Generable { /// Creates an instance with the content. public init(_ content: GeneratedContent) throws { - guard case .number(let value) = content.kind else { + switch content.kind { + case .number(let value): + self = Int(value) + case .string(let text): + guard let value = Double(coercing: text), value == value.rounded() else { + throw GeneratedContentConversionError.typeMismatch + } + self = Int(value) + default: throw GeneratedContentConversionError.typeMismatch } - self = Int(value) } /// An instance that represents the generated content. @@ -188,10 +202,17 @@ extension Float: Generable { /// Creates an instance with the content. public init(_ content: GeneratedContent) throws { - guard case .number(let value) = content.kind else { + switch content.kind { + case .number(let value): + self = Float(value) + case .string(let text): + guard let value = Double(coercing: text) else { + throw GeneratedContentConversionError.typeMismatch + } + self = Float(value) + default: throw GeneratedContentConversionError.typeMismatch } - self = Float(value) } /// An instance that represents the generated content. @@ -213,10 +234,17 @@ extension Double: Generable { /// Creates an instance with the content. public init(_ content: GeneratedContent) throws { - guard case .number(let value) = content.kind else { + switch content.kind { + case .number(let value): + self = value + case .string(let text): + guard let value = Double(coercing: text) else { + throw GeneratedContentConversionError.typeMismatch + } + self = value + default: throw GeneratedContentConversionError.typeMismatch } - self = value } /// An instance that represents the generated content. @@ -238,10 +266,17 @@ extension Decimal: Generable { /// Creates an instance with the content. public init(_ content: GeneratedContent) throws { - guard case .number(let value) = content.kind else { + switch content.kind { + case .number(let value): + self = Decimal(value) + case .string(let text): + guard let value = Double(coercing: text) else { + throw GeneratedContentConversionError.typeMismatch + } + self = Decimal(value) + default: throw GeneratedContentConversionError.typeMismatch } - self = Decimal(value) } /// An instance that represents the generated content. @@ -251,6 +286,31 @@ extension Decimal: Generable { } } +// MARK: String Coercion + +/// Language models sometimes emit primitive values as JSON strings, most commonly in +/// tool-call arguments where a chat template stringifies every argument value. The +/// primitive initializers above accept such strings when they parse unambiguously as +/// the target type, and throw ``GeneratedContentConversionError/typeMismatch`` otherwise. +extension Bool { + fileprivate init?(coercing text: String) { + switch text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "true": + self = true + case "false": + self = false + default: + return nil + } + } +} + +extension Double { + fileprivate init?(coercing text: String) { + self.init(text.trimmingCharacters(in: .whitespacesAndNewlines)) + } +} + // MARK: Array extension Array: Generable where Element: Generable { diff --git a/Tests/AnyLanguageModelTests/PrimitiveStringCoercionTests.swift b/Tests/AnyLanguageModelTests/PrimitiveStringCoercionTests.swift new file mode 100644 index 00000000..7452e224 --- /dev/null +++ b/Tests/AnyLanguageModelTests/PrimitiveStringCoercionTests.swift @@ -0,0 +1,56 @@ +import Foundation +import Testing + +@testable import AnyLanguageModel + +@Generable +private struct StringifiedToolArguments { + var count: Int? + var enabled: Bool? + var ratio: Double? +} + +@Suite("Primitive string coercion") +struct PrimitiveStringCoercionTests { + @Test func decodesStringifiedNumbersAndBools() throws { + #expect(try Int(GeneratedContent(kind: .string("5"))) == 5) + #expect(try Int(GeneratedContent(kind: .string(" -12 "))) == -12) + #expect(try Int(GeneratedContent(kind: .string("5.0"))) == 5) + #expect(try Bool(GeneratedContent(kind: .string("true"))) == true) + #expect(try Bool(GeneratedContent(kind: .string("False"))) == false) + #expect(try Double(GeneratedContent(kind: .string("3.25"))) == 3.25) + #expect(try Float(GeneratedContent(kind: .string("0.5"))) == 0.5) + #expect(try Decimal(GeneratedContent(kind: .string("2.5"))) == Decimal(2.5)) + } + + @Test func rejectsUnparseableStrings() { + #expect(throws: GeneratedContentConversionError.self) { + try Int(GeneratedContent(kind: .string("five"))) + } + #expect(throws: GeneratedContentConversionError.self) { + try Int(GeneratedContent(kind: .string("5.5"))) + } + #expect(throws: GeneratedContentConversionError.self) { + try Bool(GeneratedContent(kind: .string("yes"))) + } + #expect(throws: GeneratedContentConversionError.self) { + try Double(GeneratedContent(kind: .string(""))) + } + } + + @Test func nativeKindsStillDecode() throws { + #expect(try Int(GeneratedContent(kind: .number(7))) == 7) + #expect(try Bool(GeneratedContent(kind: .bool(true))) == true) + #expect(try Double(GeneratedContent(kind: .number(1.5))) == 1.5) + #expect(try String(GeneratedContent(kind: .string("text"))) == "text") + } + + @Test func decodesGenerableArgumentsWithStringifiedValues() throws { + let json = #"{"count": "3", "enabled": "true", "ratio": "0.75"}"# + let arguments = try StringifiedToolArguments(GeneratedContent(json: json)) + + #expect(arguments.count == 3) + #expect(arguments.enabled == true) + #expect(arguments.ratio == 0.75) + } +}