Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 124 additions & 35 deletions Sources/AnyLanguageModel/Models/SystemLanguageModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -605,38 +605,18 @@
internal init(_ content: AnyLanguageModel.GenerationSchema) {
let resolvedSchema = content.withResolvedRoot() ?? content

// Convert the GenerationSchema into a DynamicGenerationSchema, preserving $defs
let rawParameters = try? JSONValue(resolvedSchema)

if case .object(var rootObject) = rawParameters {
// Extract dependencies from $defs and remove from the root payload
let defs = rootObject.removeValue(forKey: "$defs")?.objectValue ?? [:]

// Convert root schema
if let rootData = try? JSONEncoder().encode(JSONValue.object(rootObject)),
let rootJSONSchema = try? JSONDecoder().decode(JSONSchema.self, from: rootData)
{
let rootDynamicSchema = convertToDynamicSchema(rootJSONSchema)

// Convert each dependency schema
let dependencies: [FoundationModels.DynamicGenerationSchema] = defs.compactMap { name, value in
guard
let defData = try? JSONEncoder().encode(value),
let defJSONSchema = try? JSONDecoder().decode(JSONSchema.self, from: defData)
else {
return nil
}
return convertToDynamicSchema(defJSONSchema, name: name)
}

if let schema = try? FoundationModels.GenerationSchema(
root: rootDynamicSchema,
dependencies: dependencies
) {
self = schema
return
}
}
// Convert straight from the schema's nodes, which keep each object's properties in
// declaration order: the order the model generates them in.
let rootDynamicSchema = convertToDynamicSchema(resolvedSchema.root)
let dependencies = resolvedSchema.defs.sorted { $0.key < $1.key }.map { name, node in
convertToDynamicSchema(node, name: name)
}
if let schema = try? FoundationModels.GenerationSchema(
root: rootDynamicSchema,
dependencies: dependencies
) {
self = schema
return
}

// Fallback to a minimal string schema if conversion fails
Expand Down Expand Up @@ -787,6 +767,112 @@
}
}

@available(macOS 26.0, iOS 26.0, watchOS 27.0, tvOS 26.0, visionOS 26.0, *)
func convertToDynamicSchema(
_ node: AnyLanguageModel.GenerationSchema.Node,
name: String? = nil
) -> FoundationModels.DynamicGenerationSchema {
switch node {
case .object(let object):
// Declaration order, then any properties the order doesn't name, sorted.
let declared = object.propertyOrder.filter { object.properties[$0] != nil }
let rest = object.properties.keys.filter { !declared.contains($0) }.sorted()
let properties = (declared + rest).compactMap { key -> FoundationModels.DynamicGenerationSchema.Property? in
guard let property = object.properties[key] else { return nil }
return .init(
name: key,
description: property.description,
schema: convertToDynamicSchema(property),
isOptional: !object.required.contains(key)
)
}
return .init(name: name ?? "", description: object.description, properties: properties)

case .string(let string):
if let values = string.enumChoices, !values.isEmpty, let name, !string.isConstant {
return .init(name: name, description: string.description, anyOf: values)
}
var guides: [FoundationModels.GenerationGuide<String>] = []
if let values = string.enumChoices, !values.isEmpty {
guides.append(string.isConstant && values.count == 1 ? .constant(values[0]) : .anyOf(values))
}
if let pattern = string.pattern, let regex = try? Regex(pattern) {
guides.append(.pattern(regex))
}
return .init(type: String.self, guides: guides)

case .number(let number) where number.integerOnly:
// The whole numbers within the bounds; a bound no `Int` can hold sets no guide.
var guides: [FoundationModels.GenerationGuide<Int>] = []
if let minimum = number.minimum.flatMap({ Int(exactly: $0.rounded(.up)) }) {
guides.append(.minimum(minimum))
}
if let maximum = number.maximum.flatMap({ Int(exactly: $0.rounded(.down)) }) {
guides.append(.maximum(maximum))
}
return .init(type: Int.self, guides: guides)

case .number(let number):
var guides: [FoundationModels.GenerationGuide<Double>] = []
if let minimum = number.minimum { guides.append(.minimum(minimum)) }
if let maximum = number.maximum { guides.append(.maximum(maximum)) }
return .init(type: Double.self, guides: guides)

case .boolean:
return .init(type: Bool.self)

case .anyOf(let nodes):
// Before OS 26.4, Foundation Models has no null schema.
// Leave out null variants there,
// so that the model generates another variant instead of an unconstrained string.
var choices = nodes
if !supportsNullSchema {
let nonNull = nodes.filter { if case .null = $0 { false } else { true } }
if !nonNull.isEmpty { choices = nonNull }
}
if choices.count == 1 {
return convertToDynamicSchema(choices[0], name: name)
}
return .init(name: name ?? "", anyOf: choices.map { convertToDynamicSchema($0) })

case .array(let array):
return .init(
arrayOf: convertToDynamicSchema(array.items),
minimumElements: array.minItems,
maximumElements: array.maxItems
)

case .ref(let typeName):
// A named definition that is only a reference, such as a one-choice `anyOf`, keeps its
// name, or the references to it are undefined.
if let name {
return .init(name: name, anyOf: [.init(referenceTo: typeName)])
}
return .init(referenceTo: typeName)

case .null:
#if compiler(>=6.3) && !os(tvOS)
if #available(macOS 26.4, iOS 26.4, watchOS 27.0, visionOS 26.4, *) {
return .null
}
#endif
return .init(type: String.self)
}
}

extension AnyLanguageModel.GenerationSchema.Node {
/// What the node says it is, where it says.
fileprivate var description: String? {
switch self {
case .object(let object): object.description
case .array(let array): array.description
case .string(let string): string.description
case .number(let number): number.description
case .boolean, .null, .anyOf, .ref: nil
}
}
}

/// Whether Foundation Models supports `DynamicGenerationSchema.null` at run time.
@available(macOS 26.0, iOS 26.0, watchOS 27.0, tvOS 26.0, visionOS 26.0, *)
private var supportsNullSchema: Bool {
Expand Down Expand Up @@ -1295,7 +1381,7 @@
}

/// Builds a minimal generated content tree from a schema node.
private func placeholderGeneratedContent(
func placeholderGeneratedContent(
from node: GenerationSchema.Node,
defs: [String: GenerationSchema.Node]
) -> GeneratedContent {
Expand Down Expand Up @@ -1328,10 +1414,13 @@
return GeneratedContent("placeholder")

case .number(let num):
// The smallest value the minimum allows, as far as the type can hold it.
let minimum = num.minimum.flatMap { $0.isFinite ? $0 : nil }
if num.integerOnly {
return GeneratedContent(Int(num.minimum ?? 0))
guard let minimum else { return GeneratedContent(0) }
return GeneratedContent(Int(exactly: minimum.rounded(.up)) ?? (minimum > 0 ? .max : 0))
} else {
return GeneratedContent(num.minimum ?? 0)
return GeneratedContent(minimum ?? 0)
}

case .boolean:
Expand Down
42 changes: 42 additions & 0 deletions Tests/AnyLanguageModelTests/DynamicSchemaConversionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,48 @@ import Foundation
)
}

/// A definition that is only a reference, such as a one-choice `anyOf`, keeps its name when
/// converted from a `GenerationSchema`'s nodes, so the references to it are defined.
@available(macOS 26.0, iOS 26.0, watchOS 27.0, tvOS 26.0, visionOS 26.0, *)
@Test func convertNamedOneChoiceReferenceFromGenerationSchema() throws {
let schema = try AnyLanguageModel.GenerationSchema(
root: .init(
name: "Root",
properties: [.init(name: "action", schema: .init(referenceTo: "Action"))]
),
dependencies: [
.init(name: "Action", anyOf: [.init(referenceTo: "ReplyAction")]),
.init(name: "ReplyAction", properties: [.init(name: "value", schema: .init(type: String.self))]),
]
)
_ = try FoundationModels.GenerationSchema(
root: convertToDynamicSchema(schema.root),
dependencies: schema.defs.map { name, node in convertToDynamicSchema(node, name: name) }
)
}

/// An integer's bounds become the whole numbers within them, and a bound no `Int` holds
/// sets no guide, rather than trapping.
@available(macOS 26.0, iOS 26.0, watchOS 27.0, tvOS 26.0, visionOS 26.0, *)
@Test func convertIntegerBoundsThatIntCannotHold() throws {
let schema = try JSONDecoder().decode(
AnyLanguageModel.GenerationSchema.self,
from: Data(#"{"type": "integer", "minimum": 1.5, "maximum": 1e300}"#.utf8)
)
_ = try FoundationModels.GenerationSchema(root: convertToDynamicSchema(schema.root), dependencies: [])
}

/// A stand-in integer is the smallest whole number the minimum allows, and the nearest an
/// `Int` can hold when it holds none, rather than trapping.
@available(macOS 26.0, iOS 26.0, watchOS 27.0, tvOS 26.0, visionOS 26.0, *)
@Test(arguments: [(1.5, 2.0), (1e300, Double(Int.max)), (-1e300, 0.0), (-Double.infinity, 0.0)])
func standInIntegerFromItsMinimum(minimum: Double, expected: Double) {
let node = AnyLanguageModel.GenerationSchema.Node.number(
.init(description: nil, minimum: minimum, maximum: nil, integerOnly: true)
)
#expect(placeholderGeneratedContent(from: node, defs: [:]).kind == .number(expected))
}

// MARK: - Fallback Types

@available(macOS 26.0, iOS 26.0, watchOS 27.0, tvOS 26.0, visionOS 26.0, *)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Foundation
import Testing

@testable import AnyLanguageModel

#if canImport(FoundationModels)
import FoundationModels

@Suite("Schema property order for Foundation Models")
struct SchemaPropertyOrderFoundationModelsTests {
/// Foundation Models generates properties in the order its schema's `x-order` names.
@available(macOS 26.0, iOS 26.0, watchOS 27.0, tvOS 26.0, visionOS 26.0, *)
@Test func foundationModelsSchemaKeepsDeclarationOrder() throws {
let schema = FoundationModels.GenerationSchema(OrderedItem.generationSchema)
let json = String(decoding: try JSONEncoder().encode(schema), as: UTF8.self)
#expect(json.contains(#""x-order":["kind","words","items"]"#))
#expect(json.contains(#""x-order":["kind","words"]"#))
}
}
#endif
19 changes: 19 additions & 0 deletions Tests/AnyLanguageModelTests/SchemaPropertyOrderTypes.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Foundation
import Testing

@testable import AnyLanguageModel

/// Properties declared out of alphabetical order, as a model must generate them: what an item
/// is first, then what it holds.
@Generable
struct OrderedLeaf {
var kind: String
var words: String
}

@Generable
struct OrderedItem {
var kind: String
var words: String
var items: [OrderedLeaf]
}
Loading