From e04a42738ac0ba10eaa006fc473eb5e60457c0f9 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Fri, 25 Sep 2026 06:12:40 -0700 Subject: [PATCH 1/3] Make JSONValue an AnyLanguageModel type JSONValue was a typealias for the JSONSchema package's JSONValue, so every public API that takes or returns one tied AnyLanguageModel's major version to JSONSchema's. That includes extraBody on the OpenAI, Open Responses, and Anthropic options, MLX additionalContext, Ollama's custom options, and the GeneratedContent JSONValue initializer and property. Define JSONValue as AnyLanguageModel's own enum with the same cases, plain JSON coding, and literal conformances, so dictionary literals passed to these APIs keep compiling. It keeps the Encodable initializer and objectValue, which the package already uses, and drops the other JSONSchema conveniences. Values read from a JSONSchema are converted at the point of use. Code that imports both AnyLanguageModel and JSONSchema now has two JSONValue types and must qualify the name. --- .../Models/CoreMLLanguageModel.swift | 2 +- .../Models/LlamaLanguageModel.swift | 2 +- .../Models/SystemLanguageModel.swift | 4 +- .../Shared/JSONValue+JSONSchema.swift | 46 +++++ .../AnyLanguageModel/Shared/JSONValue.swift | 170 +++++++++++++++++- .../ConvertibleToGeneratedContentTests.swift | 1 - .../DynamicSchemaConversionTests.swift | 2 +- .../JSONValueTests.swift | 143 +++++++++++++++ .../OpenAILanguageModelTests.swift | 1 - 9 files changed, 361 insertions(+), 10 deletions(-) create mode 100644 Sources/AnyLanguageModel/Shared/JSONValue+JSONSchema.swift create mode 100644 Tests/AnyLanguageModelTests/JSONValueTests.swift diff --git a/Sources/AnyLanguageModel/Models/CoreMLLanguageModel.swift b/Sources/AnyLanguageModel/Models/CoreMLLanguageModel.swift index 05c3aebd..d5cca8c2 100644 --- a/Sources/AnyLanguageModel/Models/CoreMLLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/CoreMLLanguageModel.swift @@ -502,7 +502,7 @@ { header += ". Expected value: \(constString)" } else if let enumValues = jsonSchema.enum, !enumValues.isEmpty, - let data = try? encoder.encode(JSONValue.array(enumValues)), + let data = try? encoder.encode(enumValues), let enumString = String(data: data, encoding: .utf8) { header += ". Allowed values: \(enumString)" diff --git a/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift b/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift index 68c5ea1d..c1728c69 100644 --- a/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/LlamaLanguageModel.swift @@ -1602,7 +1602,7 @@ import Foundation { header += ". Expected value: \(constString)" } else if let enumValues = jsonSchema.enum, !enumValues.isEmpty, - let data = try? encoder.encode(JSONValue.array(enumValues)), + let data = try? encoder.encode(enumValues), let enumString = String(data: data, encoding: .utf8) { header += ". Allowed values: \(enumString)" diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index d4f10119..b6414e8c 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -477,7 +477,7 @@ case .integer(_, _, _, _, _, _, minimum: let minimum, maximum: let maximum, _, _, _): if let enumValues = jsonSchema.enum { - let enumsSchema = enumValues.compactMap { convertConstToSchema($0) } + let enumsSchema = enumValues.compactMap { convertConstToSchema(JSONValue($0)) } return .init(name: name ?? "", anyOf: enumsSchema) } @@ -495,7 +495,7 @@ case .number(_, _, _, _, _, _, minimum: let minimum, maximum: let maximum, _, _, _): if let enumValues = jsonSchema.enum { - let enumsSchema = enumValues.compactMap { convertConstToSchema($0) } + let enumsSchema = enumValues.compactMap { convertConstToSchema(JSONValue($0)) } return .init(name: name ?? "", anyOf: enumsSchema) } diff --git a/Sources/AnyLanguageModel/Shared/JSONValue+JSONSchema.swift b/Sources/AnyLanguageModel/Shared/JSONValue+JSONSchema.swift new file mode 100644 index 00000000..ae24bd22 --- /dev/null +++ b/Sources/AnyLanguageModel/Shared/JSONValue+JSONSchema.swift @@ -0,0 +1,46 @@ +import enum JSONSchema.JSONValue + +// This file imports only `JSONSchema.JSONValue`, +// so `JSONSchema` below names the module, not the `JSONSchema` type. + +extension AnyLanguageModel.JSONValue { + /// Creates a JSON value from the JSONSchema package's JSON value. + init(_ value: JSONSchema.JSONValue) { + switch value { + case .null: + self = .null + case .bool(let value): + self = .bool(value) + case .int(let value): + self = .int(value) + case .double(let value): + self = .double(value) + case .string(let value): + self = .string(value) + case .array(let values): + self = .array(values.map { AnyLanguageModel.JSONValue($0) }) + case .object(let object): + self = .object(object.mapValues { AnyLanguageModel.JSONValue($0) }) + } + } + + /// This value as the JSONSchema package's JSON value. + var jsonSchemaValue: JSONSchema.JSONValue { + switch self { + case .null: + return .null + case .bool(let value): + return .bool(value) + case .int(let value): + return .int(value) + case .double(let value): + return .double(value) + case .string(let value): + return .string(value) + case .array(let values): + return .array(values.map(\.jsonSchemaValue)) + case .object(let object): + return .object(object.mapValues(\.jsonSchemaValue)) + } + } +} diff --git a/Sources/AnyLanguageModel/Shared/JSONValue.swift b/Sources/AnyLanguageModel/Shared/JSONValue.swift index 5d3c3156..9db4dd23 100644 --- a/Sources/AnyLanguageModel/Shared/JSONValue.swift +++ b/Sources/AnyLanguageModel/Shared/JSONValue.swift @@ -1,4 +1,168 @@ -import enum JSONSchema.JSONValue +import Foundation -/// A type-safe representation of JSON values used by AnyLanguageModel APIs. -public typealias JSONValue = JSONSchema.JSONValue +/// A JSON value. +/// +/// Use `JSONValue` to pass arbitrary JSON to a provider, +/// such as extra request body fields or custom generation options. +/// It encodes and decodes as plain JSON. +/// +/// You can create a JSON value with a literal: +/// +/// ```swift +/// let value: JSONValue = [ +/// "name": "Ada", +/// "age": 36, +/// "active": true, +/// "tags": ["math", "engines"], +/// "manager": nil, +/// ] +/// ``` +public enum JSONValue: Sendable, Hashable { + /// A JSON null value. + case null + + /// A JSON boolean value. + case bool(Bool) + + /// A JSON number without a fractional part. + case int(Int) + + /// A JSON number with a fractional part. + case double(Double) + + /// A JSON string value. + case string(String) + + /// A JSON array. + case array([JSONValue]) + + /// A JSON object. + case object([String: JSONValue]) + + /// Creates a JSON value from an encodable value. + /// + /// This initializer encodes the value as JSON and decodes the result. + /// If the value is already a `JSONValue`, it's returned unchanged. + /// + /// - Parameter value: The value to convert. + /// - Throws: An error if the value can't be encoded as JSON. + public init(_ value: T) throws { + if let value = value as? JSONValue { + self = value + } else { + let data = try JSONEncoder().encode(value) + self = try JSONDecoder().decode(JSONValue.self, from: data) + } + } + + /// The dictionary of an object value, or `nil` for any other value. + public var objectValue: [String: JSONValue]? { + guard case .object(let value) = self else { return nil } + return value + } +} + +// MARK: - Codable + +extension JSONValue: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int.self) { + self = .int(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else if let value = try? container.decode([String: JSONValue].self) { + self = .object(value) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Expected a JSON value." + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: + try container.encodeNil() + case .bool(let value): + try container.encode(value) + case .int(let value): + try container.encode(value) + case .double(let value): + try container.encode(value) + case .string(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + } + } +} + +// MARK: - Literals + +extension JSONValue: ExpressibleByNilLiteral { + /// Creates a null JSON value. + public init(nilLiteral: ()) { + self = .null + } +} + +extension JSONValue: ExpressibleByBooleanLiteral { + /// Creates a boolean JSON value. + public init(booleanLiteral value: Bool) { + self = .bool(value) + } +} + +extension JSONValue: ExpressibleByIntegerLiteral { + /// Creates an integer JSON value. + public init(integerLiteral value: Int) { + self = .int(value) + } +} + +extension JSONValue: ExpressibleByFloatLiteral { + /// Creates a floating-point JSON value. + public init(floatLiteral value: Double) { + self = .double(value) + } +} + +extension JSONValue: ExpressibleByStringLiteral { + /// Creates a string JSON value. + public init(stringLiteral value: String) { + self = .string(value) + } +} + +extension JSONValue: ExpressibleByArrayLiteral { + /// Creates an array JSON value. + public init(arrayLiteral elements: JSONValue...) { + self = .array(elements) + } +} + +extension JSONValue: ExpressibleByDictionaryLiteral { + /// Creates an object JSON value. + /// + /// If a key appears more than once, the last value wins. + public init(dictionaryLiteral elements: (String, JSONValue)...) { + var object: [String: JSONValue] = [:] + for (key, value) in elements { + object[key] = value + } + self = .object(object) + } +} diff --git a/Tests/AnyLanguageModelTests/ConvertibleToGeneratedContentTests.swift b/Tests/AnyLanguageModelTests/ConvertibleToGeneratedContentTests.swift index f759b12f..6db95905 100644 --- a/Tests/AnyLanguageModelTests/ConvertibleToGeneratedContentTests.swift +++ b/Tests/AnyLanguageModelTests/ConvertibleToGeneratedContentTests.swift @@ -1,5 +1,4 @@ import Foundation -import JSONSchema import Testing @testable import AnyLanguageModel diff --git a/Tests/AnyLanguageModelTests/DynamicSchemaConversionTests.swift b/Tests/AnyLanguageModelTests/DynamicSchemaConversionTests.swift index 888bcf7b..c801be41 100644 --- a/Tests/AnyLanguageModelTests/DynamicSchemaConversionTests.swift +++ b/Tests/AnyLanguageModelTests/DynamicSchemaConversionTests.swift @@ -5,7 +5,7 @@ import Foundation #if canImport(FoundationModels) import FoundationModels - import JSONSchema + import enum JSONSchema.JSONSchema private let isFoundationModelsAvailable: Bool = { if #available(macOS 26.0, iOS 26.0, watchOS 27.0, tvOS 26.0, visionOS 26.0, *) { diff --git a/Tests/AnyLanguageModelTests/JSONValueTests.swift b/Tests/AnyLanguageModelTests/JSONValueTests.swift new file mode 100644 index 00000000..d741148c --- /dev/null +++ b/Tests/AnyLanguageModelTests/JSONValueTests.swift @@ -0,0 +1,143 @@ +import Foundation +import Testing + +import enum JSONSchema.JSONValue + +@testable import AnyLanguageModel + +@Suite("JSONValue") +struct JSONValueTests { + private let sample: AnyLanguageModel.JSONValue = .object([ + "null": .null, + "bool": .bool(true), + "int": .int(42), + "double": .double(2.5), + "string": .string("hello"), + "array": .array([.int(1), .string("two"), .null]), + "object": .object(["nested": .bool(false)]), + ]) + + // MARK: - Codable + + @Test func roundTripsThroughJSON() throws { + let data = try JSONEncoder().encode(sample) + let decoded = try JSONDecoder().decode(AnyLanguageModel.JSONValue.self, from: data) + #expect(decoded == sample) + } + + @Test func encodesAsPlainJSON() throws { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let value: AnyLanguageModel.JSONValue = ["a": [true, nil, 1, 1.5, "x"], "b": [:]] + let json = String(decoding: try encoder.encode(value), as: UTF8.self) + #expect(json == #"{"a":[true,null,1,1.5,"x"],"b":{}}"#) + } + + @Test func decodesPlainJSON() throws { + let json = #"{"a": [true, null, 1, 1.5, "x"], "b": {"c": []}}"# + let decoded = try JSONDecoder().decode(AnyLanguageModel.JSONValue.self, from: Data(json.utf8)) + #expect( + decoded + == .object([ + "a": .array([.bool(true), .null, .int(1), .double(1.5), .string("x")]), + "b": .object(["c": .array([])]), + ]) + ) + } + + @Test func decodesFragments() throws { + let decoder = JSONDecoder() + #expect(try decoder.decode(AnyLanguageModel.JSONValue.self, from: Data("null".utf8)) == .null) + #expect(try decoder.decode(AnyLanguageModel.JSONValue.self, from: Data("false".utf8)) == .bool(false)) + #expect(try decoder.decode(AnyLanguageModel.JSONValue.self, from: Data("-7".utf8)) == .int(-7)) + #expect(try decoder.decode(AnyLanguageModel.JSONValue.self, from: Data("0.25".utf8)) == .double(0.25)) + #expect(try decoder.decode(AnyLanguageModel.JSONValue.self, from: Data(#""hi""#.utf8)) == .string("hi")) + } + + @Test func matchesJSONSchemaWireFormat() throws { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let ours = try encoder.encode(sample) + let theirs = try encoder.encode(sample.jsonSchemaValue) + #expect(ours == theirs) + } + + // MARK: - Literals + + @Test func literals() { + let null: AnyLanguageModel.JSONValue = nil + let bool: AnyLanguageModel.JSONValue = true + let int: AnyLanguageModel.JSONValue = 42 + let double: AnyLanguageModel.JSONValue = 2.5 + let string: AnyLanguageModel.JSONValue = "hello" + let array: AnyLanguageModel.JSONValue = [1, "two", nil] + let object: AnyLanguageModel.JSONValue = ["nested": false] + + #expect(null == .null) + #expect(bool == .bool(true)) + #expect(int == .int(42)) + #expect(double == .double(2.5)) + #expect(string == .string("hello")) + #expect(array == .array([.int(1), .string("two"), .null])) + #expect(object == .object(["nested": .bool(false)])) + } + + @Test func dictionaryLiteralKeepsLastDuplicateKey() { + let value: AnyLanguageModel.JSONValue = ["key": 1, "key": 2] + #expect(value == .object(["key": .int(2)])) + } + + // MARK: - Initializers and accessors + + @Test func initFromEncodable() throws { + struct Payload: Encodable { + let name: String + let count: Int + let tags: [String] + } + let value = try AnyLanguageModel.JSONValue(Payload(name: "a", count: 3, tags: ["x"])) + #expect(value == ["name": "a", "count": 3, "tags": ["x"]]) + } + + @Test func initFromEncodableReturnsJSONValueUnchanged() throws { + #expect(try AnyLanguageModel.JSONValue(sample) == sample) + } + + @Test func objectValue() { + #expect(sample.objectValue?["int"] == .int(42)) + #expect(AnyLanguageModel.JSONValue.string("x").objectValue == nil) + } + + // MARK: - JSONSchema conversion + + @Test func convertsToJSONSchemaValue() { + let converted = sample.jsonSchemaValue + let expected: JSONSchema.JSONValue = .object([ + "null": .null, + "bool": .bool(true), + "int": .int(42), + "double": .double(2.5), + "string": .string("hello"), + "array": .array([.int(1), .string("two"), .null]), + "object": .object(["nested": .bool(false)]), + ]) + #expect(converted == expected) + } + + @Test func convertsFromJSONSchemaValue() { + let value: JSONSchema.JSONValue = .object([ + "null": .null, + "bool": .bool(true), + "int": .int(42), + "double": .double(2.5), + "string": .string("hello"), + "array": .array([.int(1), .string("two"), .null]), + "object": .object(["nested": .bool(false)]), + ]) + #expect(AnyLanguageModel.JSONValue(value) == sample) + } + + @Test func conversionRoundTrips() { + #expect(AnyLanguageModel.JSONValue(sample.jsonSchemaValue) == sample) + } +} diff --git a/Tests/AnyLanguageModelTests/OpenAILanguageModelTests.swift b/Tests/AnyLanguageModelTests/OpenAILanguageModelTests.swift index 4b4d2a5e..14111ebe 100644 --- a/Tests/AnyLanguageModelTests/OpenAILanguageModelTests.swift +++ b/Tests/AnyLanguageModelTests/OpenAILanguageModelTests.swift @@ -1,5 +1,4 @@ import Foundation -import JSONSchema import Testing @testable import AnyLanguageModel From c5ef118b269e23e6c9dfecf4b5a8fb19eb6687aa Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Fri, 25 Sep 2026 06:33:38 -0700 Subject: [PATCH 2/3] Add JSONValue accessors from JSONSchema The new JSONValue kept only objectValue from JSONSchema's accessors, so code that read values through isNull, boolValue, intValue, doubleValue, stringValue, or arrayValue stopped compiling. Add those accessors with JSONSchema's semantics: intValue returns nil for a double, and doubleValue converts an int. --- .../AnyLanguageModel/Shared/JSONValue.swift | 43 +++++++++++++++ .../JSONValueTests.swift | 55 +++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/Sources/AnyLanguageModel/Shared/JSONValue.swift b/Sources/AnyLanguageModel/Shared/JSONValue.swift index 9db4dd23..5c65c349 100644 --- a/Sources/AnyLanguageModel/Shared/JSONValue.swift +++ b/Sources/AnyLanguageModel/Shared/JSONValue.swift @@ -55,6 +55,49 @@ public enum JSONValue: Sendable, Hashable { } } + /// A Boolean value that indicates whether this is a null value. + public var isNull: Bool { + self == .null + } + + /// The value of a boolean value, or `nil` for any other value. + public var boolValue: Bool? { + guard case .bool(let value) = self else { return nil } + return value + } + + /// The value of an integer value, or `nil` for any other value. + /// + /// This property returns `nil` for a ``double(_:)`` value, + /// even one without a fractional part. + public var intValue: Int? { + guard case .int(let value) = self else { return nil } + return value + } + + /// The value of a number, or `nil` for any other value. + /// + /// This property converts an ``int(_:)`` value to `Double`. + public var doubleValue: Double? { + switch self { + case .double(let value): return value + case .int(let value): return Double(value) + default: return nil + } + } + + /// The value of a string value, or `nil` for any other value. + public var stringValue: String? { + guard case .string(let value) = self else { return nil } + return value + } + + /// The elements of an array value, or `nil` for any other value. + public var arrayValue: [JSONValue]? { + guard case .array(let value) = self else { return nil } + return value + } + /// The dictionary of an object value, or `nil` for any other value. public var objectValue: [String: JSONValue]? { guard case .object(let value) = self else { return nil } diff --git a/Tests/AnyLanguageModelTests/JSONValueTests.swift b/Tests/AnyLanguageModelTests/JSONValueTests.swift index d741148c..d85453e0 100644 --- a/Tests/AnyLanguageModelTests/JSONValueTests.swift +++ b/Tests/AnyLanguageModelTests/JSONValueTests.swift @@ -103,11 +103,66 @@ struct JSONValueTests { #expect(try AnyLanguageModel.JSONValue(sample) == sample) } + @Test func isNull() { + #expect(AnyLanguageModel.JSONValue.null.isNull) + #expect(!AnyLanguageModel.JSONValue.bool(false).isNull) + #expect(!AnyLanguageModel.JSONValue.string("").isNull) + } + + @Test func boolValue() { + #expect(AnyLanguageModel.JSONValue.bool(true).boolValue == true) + #expect(AnyLanguageModel.JSONValue.bool(false).boolValue == false) + #expect(AnyLanguageModel.JSONValue.int(1).boolValue == nil) + #expect(AnyLanguageModel.JSONValue.string("true").boolValue == nil) + } + + @Test func intValue() { + #expect(AnyLanguageModel.JSONValue.int(42).intValue == 42) + #expect(AnyLanguageModel.JSONValue.double(42.0).intValue == nil) + #expect(AnyLanguageModel.JSONValue.string("42").intValue == nil) + } + + @Test func doubleValue() { + #expect(AnyLanguageModel.JSONValue.double(2.5).doubleValue == 2.5) + #expect(AnyLanguageModel.JSONValue.int(42).doubleValue == 42.0) + #expect(AnyLanguageModel.JSONValue.string("2.5").doubleValue == nil) + #expect(AnyLanguageModel.JSONValue.null.doubleValue == nil) + } + + @Test func stringValue() { + #expect(AnyLanguageModel.JSONValue.string("hello").stringValue == "hello") + #expect(AnyLanguageModel.JSONValue.int(1).stringValue == nil) + #expect(AnyLanguageModel.JSONValue.null.stringValue == nil) + } + + @Test func arrayValue() { + #expect(sample.objectValue?["array"]?.arrayValue == [.int(1), .string("two"), .null]) + #expect(AnyLanguageModel.JSONValue.array([]).arrayValue == []) + #expect(sample.arrayValue == nil) + } + @Test func objectValue() { #expect(sample.objectValue?["int"] == .int(42)) + #expect(AnyLanguageModel.JSONValue.object([:]).objectValue == [:]) #expect(AnyLanguageModel.JSONValue.string("x").objectValue == nil) } + @Test func accessorsMatchJSONSchema() { + let values: [AnyLanguageModel.JSONValue] = [ + .null, .bool(true), .int(-3), .double(0.5), .string("s"), .array([.null]), .object(["k": .int(1)]), + ] + for value in values { + let theirs = value.jsonSchemaValue + #expect(value.isNull == theirs.isNull) + #expect(value.boolValue == theirs.boolValue) + #expect(value.intValue == theirs.intValue) + #expect(value.doubleValue == theirs.doubleValue) + #expect(value.stringValue == theirs.stringValue) + #expect(value.arrayValue?.map(\.jsonSchemaValue) == theirs.arrayValue) + #expect(value.objectValue?.mapValues(\.jsonSchemaValue) == theirs.objectValue) + } + } + // MARK: - JSONSchema conversion @Test func convertsToJSONSchemaValue() { From ee24ce3cc90a4e3dcbc8796c6fbaca0b4a0717cf Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Fri, 25 Sep 2026 06:52:32 -0700 Subject: [PATCH 3/3] Mark JSONValue as frozen JSONSchema's JSONValue is frozen, so clients built with library evolution can switch over its cases exhaustively. Keep that for the replacement, since JSON has a fixed set of seven kinds of value. --- Sources/AnyLanguageModel/Shared/JSONValue.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/AnyLanguageModel/Shared/JSONValue.swift b/Sources/AnyLanguageModel/Shared/JSONValue.swift index 5c65c349..fd42ad04 100644 --- a/Sources/AnyLanguageModel/Shared/JSONValue.swift +++ b/Sources/AnyLanguageModel/Shared/JSONValue.swift @@ -17,7 +17,7 @@ import Foundation /// "manager": nil, /// ] /// ``` -public enum JSONValue: Sendable, Hashable { +@frozen public enum JSONValue: Sendable, Hashable { /// A JSON null value. case null