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..fd42ad04 100644 --- a/Sources/AnyLanguageModel/Shared/JSONValue.swift +++ b/Sources/AnyLanguageModel/Shared/JSONValue.swift @@ -1,4 +1,211 @@ -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, +/// ] +/// ``` +@frozen 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) + } + } + + /// 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 } + 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..d85453e0 --- /dev/null +++ b/Tests/AnyLanguageModelTests/JSONValueTests.swift @@ -0,0 +1,198 @@ +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 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() { + 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