From 67fb81290133e9bbec7a65ca7e44dbd416c411b6 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Fri, 2 Oct 2026 05:40:42 -0700 Subject: [PATCH 01/11] Add missing Foundation Models 26 feedback, null schema, and system model APIs --- .../DynamicGenerationSchema.swift | 30 +++++++ .../AnyLanguageModel/GenerationSchema.swift | 13 ++- .../LanguageModelSession.swift | 46 +++++++++++ .../Models/SystemLanguageModel.swift | 81 ++++++++++++++++++- .../Shared/ResponseSchemaInlining.swift | 2 +- .../Shared/StructuredGeneration.swift | 4 + .../DynamicGenerationSchemaTests.swift | 16 ++++ .../LanguageModelFeedbackTests.swift | 80 ++++++++++++++++++ .../SystemLanguageModelTests.swift | 32 ++++++++ 9 files changed, 300 insertions(+), 4 deletions(-) diff --git a/Sources/AnyLanguageModel/DynamicGenerationSchema.swift b/Sources/AnyLanguageModel/DynamicGenerationSchema.swift index 3245a25e..03729cce 100644 --- a/Sources/AnyLanguageModel/DynamicGenerationSchema.swift +++ b/Sources/AnyLanguageModel/DynamicGenerationSchema.swift @@ -22,6 +22,7 @@ public struct DynamicGenerationSchema: Sendable { case number case integer case decimal + case null } internal let body: Body @@ -115,6 +116,35 @@ public struct DynamicGenerationSchema: Sendable { } } + /// A schema that represents a null value. + /// + /// Use a null schema to express a value that can't be absent but can be empty. + /// For example, combine it with another schema in an any-of schema: + /// + /// ```swift + /// let person = DynamicGenerationSchema( + /// name: "Person", + /// properties: [ + /// DynamicGenerationSchema.Property( + /// name: "fullName", + /// schema: DynamicGenerationSchema(type: String.self) + /// ) + /// ] + /// ) + /// let nullablePerson = DynamicGenerationSchema( + /// name: "NullablePerson", + /// anyOf: [person, .null] + /// ) + /// let schema = try GenerationSchema(root: nullablePerson, dependencies: []) + /// ``` + public static var null: DynamicGenerationSchema { + DynamicGenerationSchema(body: .scalar(.null)) + } + + private init(body: Body) { + self.body = body + } + /// Creates an refrence schema. /// /// - Parameters: diff --git a/Sources/AnyLanguageModel/GenerationSchema.swift b/Sources/AnyLanguageModel/GenerationSchema.swift index 729d04ef..c2ace152 100644 --- a/Sources/AnyLanguageModel/GenerationSchema.swift +++ b/Sources/AnyLanguageModel/GenerationSchema.swift @@ -15,6 +15,7 @@ public struct GenerationSchema: Equatable, Codable, CustomDebugStringConvertible case string(StringNode) case number(NumberNode) case boolean + case null case anyOf([Node]) case ref(String) @@ -22,7 +23,7 @@ public struct GenerationSchema: Equatable, Codable, CustomDebugStringConvertible static func == (lhs: GenerationSchema.Node, rhs: GenerationSchema.Node) -> Bool { switch (lhs, rhs) { - case (.boolean, .boolean): + case (.boolean, .boolean), (.null, .null): return true case (.ref(let lhsName), .ref(let rhsName)): return lhsName == rhsName @@ -131,6 +132,9 @@ public struct GenerationSchema: Equatable, Codable, CustomDebugStringConvertible case .boolean: try container.encode("boolean", forKey: .type) + case .null: + try container.encode("null", forKey: .type) + case .anyOf(let nodes): try container.encode(nodes, forKey: .anyOf) @@ -209,6 +213,9 @@ public struct GenerationSchema: Equatable, Codable, CustomDebugStringConvertible case "boolean": self = .boolean + case "null": + self = .null + default: throw DecodingError.dataCorruptedError( forKey: .type, @@ -281,6 +288,8 @@ public struct GenerationSchema: Equatable, Codable, CustomDebugStringConvertible return num.integerOnly ? "integer" : "number" case .boolean: return "boolean" + case .null: + return "null" case .anyOf(let nodes): return "anyOf(\(nodes.count) choices)" case .ref(let name): @@ -521,6 +530,8 @@ public struct GenerationSchema: Equatable, Codable, CustomDebugStringConvertible switch scalar { case .bool: return .boolean + case .null: + return .null case .string: return .string(StringNode(description: dynamicProp?.description, pattern: nil, enumChoices: nil)) case .number: diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index f027b894..8093182b 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -1024,6 +1024,52 @@ extension LanguageModelSession { desiredOutput: desiredOutput ) } + + /// Logs feedback about the most recent response, with the text you wanted instead. + /// + /// - Parameters: + /// - sentiment: Whether the response was positive, negative, or neutral. + /// - issues: The problems with the response. + /// - desiredResponseText: The text that the model should have generated. + /// - Returns: The feedback attachment data. + @discardableResult + public func logFeedbackAttachment( + sentiment: LanguageModelFeedback.Sentiment?, + issues: [LanguageModelFeedback.Issue] = [], + desiredResponseText: String? + ) -> Data { + let entry = desiredResponseText.map { content in + Transcript.Entry.response( + Transcript.Response(assetIDs: [], segments: [.text(.init(content: content))]) + ) + } + return logFeedbackAttachment(sentiment: sentiment, issues: issues, desiredOutput: entry) + } + + /// Logs feedback about the most recent response, with the content you wanted instead. + /// + /// - Parameters: + /// - sentiment: Whether the response was positive, negative, or neutral. + /// - issues: The problems with the response. + /// - desiredResponseContent: The content that the model should have generated. + /// - Returns: The feedback attachment data. + @discardableResult + public func logFeedbackAttachment( + sentiment: LanguageModelFeedback.Sentiment?, + issues: [LanguageModelFeedback.Issue] = [], + desiredResponseContent: (any ConvertibleToGeneratedContent)? + ) -> Data { + let entry = desiredResponseContent.map { content in + let segment = Transcript.StructuredSegment( + source: String(describing: type(of: content)), + content: content.generatedContent + ) + return Transcript.Entry.response( + Transcript.Response(assetIDs: [], segments: [.structure(segment)]) + ) + } + return logFeedbackAttachment(sentiment: sentiment, issues: issues, desiredOutput: entry) + } } // MARK: - diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index d4f10119..42a16bf3 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -63,6 +63,70 @@ } #endif + /// The languages that the model supports. + public var supportedLanguages: Set { + systemModel.supportedLanguages + } + + /// Returns a Boolean value that indicates whether the model supports a locale. + /// + /// - Parameter locale: The locale to check. Defaults to the current locale. + /// - Returns: `true` if the model supports the locale's language. + public func supportsLocale(_ locale: Locale = Locale.current) -> Bool { + systemModel.supportsLocale(locale) + } + + #if compiler(>=6.3) && !os(tvOS) && !os(watchOS) + /// Returns the number of tokens in a prompt. + /// + /// - Parameter prompt: The prompt to count. + /// - Returns: The number of tokens. + @available(macOS 26.4, iOS 26.4, visionOS 26.4, *) + public func tokenCount(for prompt: some PromptRepresentable) async throws -> Int { + try await systemModel.tokenCount(for: prompt.promptRepresentation.toFoundationModels()) + } + + /// Returns the number of tokens in instructions. + /// + /// - Parameter instructions: The instructions to count. + /// - Returns: The number of tokens. + @available(macOS 26.4, iOS 26.4, visionOS 26.4, *) + public func tokenCount(for instructions: Instructions) async throws -> Int { + try await systemModel.tokenCount(for: instructions.toFoundationModels()) + } + + /// Returns the number of tokens that the definitions of tools use. + /// + /// - Parameter tools: The tools to count. + /// - Returns: The number of tokens. + @available(macOS 26.4, iOS 26.4, visionOS 26.4, *) + public func tokenCount(for tools: [any Tool]) async throws -> Int { + try await systemModel.tokenCount(for: tools.toFoundationModels()) + } + + /// Returns the number of tokens in a generation schema. + /// + /// - Parameter schema: The schema to count. + /// - Returns: The number of tokens. + @available(macOS 26.4, iOS 26.4, visionOS 26.4, *) + public func tokenCount(for schema: GenerationSchema) async throws -> Int { + try await systemModel.tokenCount(for: FoundationModels.GenerationSchema(schema)) + } + + /// Returns the number of tokens in transcript entries. + /// + /// - Parameter transcriptEntries: The entries to count. + /// - Returns: The number of tokens. + @available(macOS 26.4, iOS 26.4, visionOS 26.4, *) + public func tokenCount( + for transcriptEntries: some Collection + ) async throws -> Int { + let transcript = Transcript(entries: Array(transcriptEntries)) + .toFoundationModels(instructions: nil, toolDefinitions: []) + return try await systemModel.tokenCount(for: Array(transcript)) + } + #endif + /// Whether the model accepts image input. public var supportsImageInput: Bool { #if compiler(>=6.4) && !os(tvOS) && !os(watchOS) @@ -229,7 +293,9 @@ let fmSentiment = sentiment?.toFoundationModels() let fmIssues = issues.map { $0.toFoundationModels() } - let fmDesiredOutput: FoundationModels.Transcript.Entry? = nil + let fmDesiredOutput = desiredOutput.flatMap { entry in + Transcript(entries: [entry]).toFoundationModels(instructions: nil, toolDefinitions: []).first + } return fmSession.logFeedbackAttachment( sentiment: fmSentiment, @@ -528,7 +594,15 @@ let typeName = name.hasPrefix(prefix) ? String(name.dropFirst(prefix.count)) : name return .init(referenceTo: typeName) - case .allOf, .oneOf, .not, .null, .empty, .any: + case .null: + #if compiler(>=6.3) + if #available(macOS 26.4, iOS 26.4, watchOS 27.0, visionOS 26.4, *) { + return .null + } + #endif + return .init(type: String.self) + + case .allOf, .oneOf, .not, .empty, .any: return .init(type: String.self) } } @@ -1053,6 +1127,9 @@ case .boolean: return GeneratedContent(true) + case .null: + return GeneratedContent(kind: .null) + case .anyOf(let nodes): if let first = nodes.first { return placeholderGeneratedContent(from: first, defs: defs) diff --git a/Sources/AnyLanguageModel/Shared/ResponseSchemaInlining.swift b/Sources/AnyLanguageModel/Shared/ResponseSchemaInlining.swift index 452eb9cd..424b99c1 100644 --- a/Sources/AnyLanguageModel/Shared/ResponseSchemaInlining.swift +++ b/Sources/AnyLanguageModel/Shared/ResponseSchemaInlining.swift @@ -93,7 +93,7 @@ extension GenerationSchema { ) } ) - case .string, .number, .boolean: + case .string, .number, .boolean, .null: return node } } diff --git a/Sources/AnyLanguageModel/Shared/StructuredGeneration.swift b/Sources/AnyLanguageModel/Shared/StructuredGeneration.swift index 370fadd8..5bb0880d 100644 --- a/Sources/AnyLanguageModel/Shared/StructuredGeneration.swift +++ b/Sources/AnyLanguageModel/Shared/StructuredGeneration.swift @@ -387,6 +387,8 @@ struct ConstrainedJSONGenerator { return try await generateNumber(numberNode) case .boolean: return try await generateChoice(["true", "false"]) + case .null: + return try await emit("null") case .ref(let typeName): guard let referenced = schema.defs[typeName] else { throw ConstrainedGenerationError.missingReference(typeName) @@ -611,6 +613,8 @@ struct ConstrainedJSONGenerator { return tokensStarting(with: "[") case .boolean: return tokensPrefixing(anyOf: ["true", "false"]) + case .null: + return tokensPrefixing(anyOf: ["null"]) case .number(let numberNode): let numeric = numberNode.integerOnly diff --git a/Tests/AnyLanguageModelTests/DynamicGenerationSchemaTests.swift b/Tests/AnyLanguageModelTests/DynamicGenerationSchemaTests.swift index 5a4956da..f65be8c3 100644 --- a/Tests/AnyLanguageModelTests/DynamicGenerationSchemaTests.swift +++ b/Tests/AnyLanguageModelTests/DynamicGenerationSchemaTests.swift @@ -92,6 +92,22 @@ struct DynamicGenerationSchemaTests { } } + @Test func nullSchemaEncodesAsNullType() throws { + let person = DynamicGenerationSchema( + name: "Person", + properties: [.init(name: "fullName", schema: .init(type: String.self))] + ) + let nullablePerson = DynamicGenerationSchema(name: "NullablePerson", anyOf: [person, .null]) + + let schema = try GenerationSchema(root: nullablePerson, dependencies: []) + let data = try JSONEncoder().encode(schema) + let json = try #require(String(data: data, encoding: .utf8)) + #expect(json.contains(#"{"type":"null"}"#)) + + let decoded = try JSONDecoder().decode(GenerationSchema.self, from: data) + #expect(decoded == schema) + } + @Test func duplicateDependencyNamesThrow() { let dep1 = DynamicGenerationSchema(name: "Shared", properties: []) let dep2 = DynamicGenerationSchema(name: "Shared", properties: []) diff --git a/Tests/AnyLanguageModelTests/LanguageModelFeedbackTests.swift b/Tests/AnyLanguageModelTests/LanguageModelFeedbackTests.swift index c4a142fe..2fc14f30 100644 --- a/Tests/AnyLanguageModelTests/LanguageModelFeedbackTests.swift +++ b/Tests/AnyLanguageModelTests/LanguageModelFeedbackTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import AnyLanguageModel @@ -42,4 +43,83 @@ struct LanguageModelFeedbackTests { #expect(feedback.issues.first?.category == .incorrect) #expect(feedback.issues.first?.explanation == nil) } + + @Test func desiredResponseTextIsLoggedAsTextResponse() throws { + let session = LanguageModelSession(model: FeedbackRecordingModel()) + let data = session.logFeedbackAttachment( + sentiment: .negative, + issues: [.init(category: .incorrect)], + desiredResponseText: "Paris" + ) + + let entry = try JSONDecoder().decode(Transcript.Entry.self, from: data) + guard case .response(let response) = entry, + case .text(let text)? = response.segments.first + else { + Issue.record("Expected a response entry with a text segment, got \(entry)") + return + } + #expect(response.segments.count == 1) + #expect(text.content == "Paris") + } + + @Test func desiredResponseContentIsLoggedAsStructuredResponse() throws { + let session = LanguageModelSession(model: FeedbackRecordingModel()) + let content = GeneratedContent(properties: ["city": "Paris"]) + let data = session.logFeedbackAttachment( + sentiment: .negative, + desiredResponseContent: content + ) + + let entry = try JSONDecoder().decode(Transcript.Entry.self, from: data) + guard case .response(let response) = entry, + case .structure(let structure)? = response.segments.first + else { + Issue.record("Expected a response entry with a structured segment, got \(entry)") + return + } + #expect(structure.source == "GeneratedContent") + #expect(structure.content == content) + } + + @Test func nilDesiredResponseLogsNoOutput() { + let session = LanguageModelSession(model: FeedbackRecordingModel()) + #expect(session.logFeedbackAttachment(sentiment: .positive, desiredResponseText: nil).isEmpty) + #expect(session.logFeedbackAttachment(sentiment: .positive, desiredResponseContent: nil).isEmpty) + } +} + +/// A model that returns the desired output it receives, encoded as JSON. +private struct FeedbackRecordingModel: LanguageModel { + typealias UnavailableReason = Never + + func respond( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) async throws -> LanguageModelSession.Response where Content: Generable { + fatalError("Not used") + } + + func streamResponse( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) -> sending LanguageModelSession.ResponseStream where Content: Generable { + fatalError("Not used") + } + + func logFeedbackAttachment( + within session: LanguageModelSession, + sentiment: LanguageModelFeedback.Sentiment?, + issues: [LanguageModelFeedback.Issue], + desiredOutput: Transcript.Entry? + ) -> Data { + guard let desiredOutput else { return Data() } + return (try? JSONEncoder().encode(desiredOutput)) ?? Data() + } } diff --git a/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift b/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift index a41d7ffd..cca118dc 100644 --- a/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift +++ b/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import AnyLanguageModel @@ -96,6 +97,37 @@ import Testing .enabled(if: isSystemLanguageModelAvailable) ) struct SystemLanguageModelTests { + @available(macOS 26.0, iOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func supportedLanguagesIncludeEnglish() { + let model = SystemLanguageModel() + #expect(model.supportedLanguages.contains { $0.languageCode == .english }) + #expect(model.supportsLocale(Locale(identifier: "en_US"))) + } + + #if compiler(>=6.3) + @available(macOS 26.0, iOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func tokenCountsArePositive() async throws { + guard #available(macOS 26.4, iOS 26.4, visionOS 26.4, *) else { return } + let model = SystemLanguageModel() + + let short = try await model.tokenCount(for: "Hello") + let long = try await model.tokenCount(for: Prompt("How's the weather in Cupertino today?")) + #expect(short > 0) + #expect(long > short) + + #expect(try await model.tokenCount(for: Instructions("Answer in one sentence.")) > 0) + + #expect(try await model.tokenCount(for: [WeatherTool()]) > 0) + #expect(try await model.tokenCount(for: Person.generationSchema) > 0) + + let entries: [Transcript.Entry] = [ + .prompt(Transcript.Prompt(segments: [.text(.init(content: "Hello"))])), + .response(Transcript.Response(assetIDs: [], segments: [.text(.init(content: "Hi there!"))])), + ] + #expect(try await model.tokenCount(for: entries) > 0) + } + #endif + @available(macOS 26.0, iOS 26.0, tvOS 26.0, visionOS 26.0, *) @Test func basicResponse() async throws { let model: SystemLanguageModel = SystemLanguageModel() From 9eee71c91db3d55f7d6e3f80bc4aecca6dd383ba Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Fri, 2 Oct 2026 05:54:30 -0700 Subject: [PATCH 02/11] Add representNilExplicitlyInGeneratedContent and leave out nil optional properties by default --- .../DynamicGenerationSchema.swift | 19 +++ Sources/AnyLanguageModel/Generable.swift | 11 ++ .../AnyLanguageModel/GenerationSchema.swift | 101 +++++++++++++- .../LanguageModelSession.swift | 43 ++++-- Sources/AnyLanguageModel/Transcript.swift | 2 +- .../GenerableMacro.swift | 125 +++++++++-------- .../ExplicitNilTests.swift | 130 ++++++++++++++++++ 7 files changed, 362 insertions(+), 69 deletions(-) create mode 100644 Tests/AnyLanguageModelTests/ExplicitNilTests.swift diff --git a/Sources/AnyLanguageModel/DynamicGenerationSchema.swift b/Sources/AnyLanguageModel/DynamicGenerationSchema.swift index 03729cce..52032138 100644 --- a/Sources/AnyLanguageModel/DynamicGenerationSchema.swift +++ b/Sources/AnyLanguageModel/DynamicGenerationSchema.swift @@ -26,6 +26,7 @@ public struct DynamicGenerationSchema: Sendable { } internal let body: Body + internal private(set) var representsNilExplicitly = false internal var name: String? { switch body { case .object(let name, _, _), .anyOf(let name, _, _), .stringEnum(let name, _, _): @@ -49,6 +50,24 @@ public struct DynamicGenerationSchema: Sendable { self.body = .object(name: name, description: description, properties: properties) } + /// Creates an object schema. + /// + /// - Parameters: + /// - name: A name this dynamic schema can be referenced by. + /// - description: A natural language description of this schema. + /// - explicitNil: Whether generated content has a `null` value + /// for each optional property that it would otherwise leave out. + /// - properties: The properties associated with this schema. + public init( + name: String, + description: String? = nil, + representNilExplicitlyInGeneratedContent explicitNil: Bool, + properties: [DynamicGenerationSchema.Property] + ) { + self.body = .object(name: name, description: description, properties: properties) + self.representsNilExplicitly = explicitNil + } + /// Creates an any-of schema. /// /// - Parameters: diff --git a/Sources/AnyLanguageModel/Generable.swift b/Sources/AnyLanguageModel/Generable.swift index 19437ed2..86e39e77 100644 --- a/Sources/AnyLanguageModel/Generable.swift +++ b/Sources/AnyLanguageModel/Generable.swift @@ -55,6 +55,17 @@ public typealias GeneratedContentConversionError = GeneratedContentError public macro Generable(description: String? = nil) = #externalMacro(module: "AnyLanguageModelMacros", type: "GenerableMacro") +/// Conforms a type to ``Generable`` protocol, +/// choosing how generated content represents `nil` optional properties. +/// +/// By default, generated content leaves out an optional property whose value is `nil`. +/// Pass `true` for `representNilExplicitlyInGeneratedContent` +/// to give such properties a `null` value instead. +@attached(extension, conformances: Generable, names: named(init(_:)), named(generatedContent)) +@attached(member, names: arbitrary) +public macro Generable(description: String? = nil, representNilExplicitlyInGeneratedContent: Bool) = + #externalMacro(module: "AnyLanguageModelMacros", type: "GenerableMacro") + /// Allows for influencing the allowed values of properties of a generable type. @attached(peer) public macro Guide(description: String) = diff --git a/Sources/AnyLanguageModel/GenerationSchema.swift b/Sources/AnyLanguageModel/GenerationSchema.swift index c2ace152..6f3f13d5 100644 --- a/Sources/AnyLanguageModel/GenerationSchema.swift +++ b/Sources/AnyLanguageModel/GenerationSchema.swift @@ -230,6 +230,13 @@ public struct GenerationSchema: Equatable, Codable, CustomDebugStringConvertible var description: String? var properties: [String: Node] var required: Set + /// Whether generated content has a `null` value for each optional property + /// that it would otherwise leave out. + var representsNilExplicitly = false + + private enum CodingKeys: String, CodingKey { + case description, properties, required + } } struct ArrayNode: Sendable, Codable { @@ -307,6 +314,32 @@ public struct GenerationSchema: Equatable, Codable, CustomDebugStringConvertible type: any Generable.Type, description: String? = nil, properties: [GenerationSchema.Property] + ) { + self.init(type: type, description: description, explicitNil: false, properties: properties) + } + + /// Creates a schema by providing an array of properties. + /// + /// - Parameters: + /// - type: The type this schema represents. + /// - description: A natural language description of this schema. + /// - explicitNil: Whether generated content has a `null` value + /// for each optional property that it would otherwise leave out. + /// - properties: An array of properties. + public init( + type: any Generable.Type, + description: String? = nil, + representNilExplicitlyInGeneratedContent explicitNil: Bool, + properties: [GenerationSchema.Property] + ) { + self.init(type: type, description: description, explicitNil: explicitNil, properties: properties) + } + + private init( + type: any Generable.Type, + description: String?, + explicitNil: Bool, + properties: [GenerationSchema.Property] ) { let typeName = String(reflecting: type) var props: [String: Node] = [:] @@ -326,7 +359,12 @@ public struct GenerationSchema: Equatable, Codable, CustomDebugStringConvertible } } - let objectNode = ObjectNode(description: description, properties: props, required: required) + let objectNode = ObjectNode( + description: description, + properties: props, + required: required, + representsNilExplicitly: explicitNil + ) allDefs[typeName] = .object(objectNode) self.root = .ref(typeName) @@ -490,7 +528,14 @@ public struct GenerationSchema: Equatable, Codable, CustomDebugStringConvertible required.insert(prop.name) } } - let node = Node.object(ObjectNode(description: desc, properties: props, required: required)) + let node = Node.object( + ObjectNode( + description: desc, + properties: props, + required: required, + representsNilExplicitly: dynamic.representsNilExplicitly + ) + ) if let name = name { defs[name] = node return .ref(name) @@ -894,3 +939,55 @@ extension GenerationSchema { return "Respond with valid JSON matching this schema:\n\(schemaJSON)" } } + +// MARK: - Explicit nil + +extension GenerationSchema { + /// Returns generated content with a `null` value for each optional property + /// that the content leaves out, + /// in objects whose schema represents `nil` explicitly. + /// + /// Content for a schema without such objects is returned unchanged. + func representingNilExplicitly(in content: GeneratedContent) -> GeneratedContent { + let representsNilExplicitly = ([root] + Array(defs.values)).contains { node in + if case .object(let object) = node { return object.representsNilExplicitly } + return false + } + guard representsNilExplicitly else { return content } + return representingNilExplicitly(in: content, node: root, depth: 0) + } + + private func representingNilExplicitly( + in content: GeneratedContent, + node: Node, + depth: Int + ) -> GeneratedContent { + guard depth < 64 else { return content } + switch node { + case .ref(let name): + guard let resolved = defs[name] else { return content } + return representingNilExplicitly(in: content, node: resolved, depth: depth + 1) + case .object(let object): + guard case .structure(var properties, var orderedKeys) = content.kind else { return content } + for (key, value) in properties { + if let child = object.properties[key] { + properties[key] = representingNilExplicitly(in: value, node: child, depth: depth + 1) + } + } + if object.representsNilExplicitly { + for key in object.properties.keys.sorted() + where properties[key] == nil && !object.required.contains(key) { + properties[key] = GeneratedContent(kind: .null) + orderedKeys.append(key) + } + } + return GeneratedContent(kind: .structure(properties: properties, orderedKeys: orderedKeys), id: content.id) + case .array(let array): + guard case .array(let elements) = content.kind else { return content } + let items = elements.map { representingNilExplicitly(in: $0, node: array.items, depth: depth + 1) } + return GeneratedContent(kind: .array(items), id: content.id) + case .string, .number, .boolean, .null, .anyOf: + return content + } + } +} diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 8093182b..5c33a610 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -205,6 +205,24 @@ public final class LanguageModelSession: @unchecked Sendable { } } + /// Returns the response with a `null` value for each omitted optional property + /// when the response format's schema represents `nil` explicitly. + nonisolated private static func representingNilExplicitly( + in response: Response, + responseFormat: Transcript.ResponseFormat? + ) -> Response where Content: Generable { + guard let responseFormat else { return response } + let rawContent = responseFormat.schema.representingNilExplicitly(in: response.rawContent) + guard rawContent != response.rawContent else { return response } + return Response( + content: response.content, + rawContent: rawContent, + transcriptEntries: response.transcriptEntries, + usage: response.usage, + providerMetadata: response.providerMetadata + ) + } + nonisolated private func wrapStream( _ upstream: sending ResponseStream, promptEntry: Transcript.Entry @@ -240,11 +258,15 @@ public final class LanguageModelSession: @unchecked Sendable { throw ResponseStreamError.noSnapshots } // Extract text content from the generated content + var rawContent = lastSnapshot.rawContent + if case .prompt(let prompt) = promptEntry, let responseFormat = prompt.responseFormat { + rawContent = responseFormat.schema.representingNilExplicitly(in: rawContent) + } let textContent: String - if case .string(let str) = lastSnapshot.rawContent.kind { + if case .string(let str) = rawContent.kind { textContent = str } else { - textContent = lastSnapshot.rawContent.jsonString + textContent = rawContent.jsonString } let responseEntry = Transcript.Entry.response( @@ -506,7 +528,7 @@ public final class LanguageModelSession: @unchecked Sendable { state.withLock { $0.transcript.append(promptEntry) } } - let response = try await generate() + let response = Self.representingNilExplicitly(in: try await generate(), responseFormat: responseFormat) recordUsage(response.usage) @@ -886,12 +908,15 @@ extension LanguageModelSession { // Extract text content for the Prompt parameter let textPrompt = Prompt(prompt) - let response = try await model.respond( - within: self, - to: textPrompt, - generating: type, - includeSchemaInPrompt: includeSchemaInPrompt, - options: options + let response = Self.representingNilExplicitly( + in: try await model.respond( + within: self, + to: textPrompt, + generating: type, + includeSchemaInPrompt: includeSchemaInPrompt, + options: options + ), + responseFormat: type == String.self ? nil : .init(type: type) ) recordUsage(response.usage) diff --git a/Sources/AnyLanguageModel/Transcript.swift b/Sources/AnyLanguageModel/Transcript.swift index edd6c939..65d61b97 100644 --- a/Sources/AnyLanguageModel/Transcript.swift +++ b/Sources/AnyLanguageModel/Transcript.swift @@ -299,7 +299,7 @@ public struct Transcript: Sendable, Equatable, Codable { /// Specifies a response format that the model must conform its output to. public struct ResponseFormat: Sendable, Codable { - private let schema: GenerationSchema + let schema: GenerationSchema /// A name associated with the response format. public var name: String { diff --git a/Sources/AnyLanguageModelMacros/GenerableMacro.swift b/Sources/AnyLanguageModelMacros/GenerableMacro.swift index bfc8afa2..a4a537cd 100644 --- a/Sources/AnyLanguageModelMacros/GenerableMacro.swift +++ b/Sources/AnyLanguageModelMacros/GenerableMacro.swift @@ -15,21 +15,24 @@ public struct GenerableMacro: MemberMacro, ExtensionMacro { let structName = structDecl.name.text let description = extractDescription(from: node) + let explicitNil = extractRepresentNilExplicitly(from: node) let properties = extractGuidedProperties(from: structDecl) return [ generateRawContentProperty(), - generateMemberwiseInit(properties: properties), + generateMemberwiseInit(properties: properties, explicitNil: explicitNil), generateInitFromGeneratedContent(structName: structName, properties: properties), generateGeneratedContentProperty( structName: structName, description: description, - properties: properties + properties: properties, + explicitNil: explicitNil ), generateGenerationSchemaProperty( structName: structName, description: description, - properties: properties + properties: properties, + explicitNil: explicitNil ), generatePartiallyGeneratedStruct(structName: structName, properties: properties), generateAsPartiallyGeneratedMethod(structName: structName), @@ -87,6 +90,50 @@ public struct GenerableMacro: MemberMacro, ExtensionMacro { // MARK: - Helpers + /// Returns whether the attribute passes `representNilExplicitlyInGeneratedContent: true`. + private static func extractRepresentNilExplicitly(from node: AttributeSyntax) -> Bool { + guard let arguments = node.arguments?.as(LabeledExprListSyntax.self), + let argument = arguments.first(where: { $0.label?.text == "representNilExplicitlyInGeneratedContent" }), + let literal = argument.expression.as(BooleanLiteralExprSyntax.self) + else { + return false + } + return literal.literal.tokenKind == .keyword(.true) + } + + /// Returns a statement that adds an optional property to `properties`. + /// + /// When `explicitNil` is `true`, a `nil` value becomes `null`; + /// otherwise, the property is left out. + private static func optionalPropertyConversion( + propertyName propName: String, + baseType: String, + explicitNil: Bool + ) -> String { + let conversion: String + if baseType == "String" { + conversion = "GeneratedContent(value)" + } else if baseType.hasPrefix("[") && baseType.hasSuffix("]") && !isDictionaryType(baseType) { + conversion = "GeneratedContent(elements: value)" + } else { + conversion = "value.generatedContent" + } + if explicitNil { + return """ + if let value = \(propName) { + properties["\(propName)"] = \(conversion) + } else { + properties["\(propName)"] = GeneratedContent(kind: .null) + } + """ + } + return """ + if let value = \(propName) { + properties["\(propName)"] = \(conversion) + } + """ + } + private static func extractDescription(from node: AttributeSyntax) -> String? { guard let arguments = node.arguments?.as(LabeledExprListSyntax.self), let firstArg = arguments.first, @@ -543,7 +590,7 @@ public struct GenerableMacro: MemberMacro, ExtensionMacro { ) } - private static func generateMemberwiseInit(properties: [PropertyInfo]) -> DeclSyntax { + private static func generateMemberwiseInit(properties: [PropertyInfo], explicitNil: Bool) -> DeclSyntax { if properties.isEmpty { return DeclSyntax( stringLiteral: """ @@ -567,30 +614,11 @@ public struct GenerableMacro: MemberMacro, ExtensionMacro { let propType = prop.type if propType.hasSuffix("?") { - let baseType = String(propType.dropLast()) - if baseType == "String" { - return - "properties[\"\(propName)\"] = \(propName).map { GeneratedContent($0) } ?? GeneratedContent(kind: .null)" - } else if baseType == "Int" || baseType == "Double" || baseType == "Float" - || baseType == "Bool" || baseType == "Decimal" - { - return - "properties[\"\(propName)\"] = \(propName).map { $0.generatedContent } ?? GeneratedContent(kind: .null)" - } else if isDictionaryType(baseType) { - return - "properties[\"\(propName)\"] = \(propName).map { $0.generatedContent } ?? GeneratedContent(kind: .null)" - } else if baseType.hasPrefix("[") && baseType.hasSuffix("]") { - return - "properties[\"\(propName)\"] = \(propName).map { GeneratedContent(elements: $0) } ?? GeneratedContent(kind: .null)" - } else { - return """ - if let value = \(propName) { - properties["\(propName)"] = value.generatedContent - } else { - properties["\(propName)"] = GeneratedContent(kind: .null) - } - """ - } + return optionalPropertyConversion( + propertyName: propName, + baseType: String(propType.dropLast()), + explicitNil: explicitNil + ) } else if isDictionaryType(propType) { return "properties[\"\(propName)\"] = \(propName).generatedContent" } else if propType.hasPrefix("[") && propType.hasSuffix("]") { @@ -620,7 +648,7 @@ public struct GenerableMacro: MemberMacro, ExtensionMacro { self._rawGeneratedContent = GeneratedContent( kind: .structure( properties: properties, - orderedKeys: [\(orderedKeys)] + orderedKeys: [\(orderedKeys)].filter { properties[$0] != nil } ) ) } @@ -816,37 +844,19 @@ public struct GenerableMacro: MemberMacro, ExtensionMacro { private static func generateGeneratedContentProperty( structName: String, description: String?, - properties: [PropertyInfo] + properties: [PropertyInfo], + explicitNil: Bool ) -> DeclSyntax { let propertyConversions = properties.map { prop in let propName = prop.name let propType = prop.type if propType.hasSuffix("?") { - let baseType = String(propType.dropLast()) - if baseType == "String" { - return - "properties[\"\(propName)\"] = \(propName).map { GeneratedContent($0) } ?? GeneratedContent(kind: .null)" - } else if baseType == "Int" || baseType == "Double" || baseType == "Float" - || baseType == "Bool" || baseType == "Decimal" - { - return - "properties[\"\(propName)\"] = \(propName).map { $0.generatedContent } ?? GeneratedContent(kind: .null)" - } else if isDictionaryType(baseType) { - return - "properties[\"\(propName)\"] = \(propName).map { $0.generatedContent } ?? GeneratedContent(kind: .null)" - } else if baseType.hasPrefix("[") && baseType.hasSuffix("]") { - return - "properties[\"\(propName)\"] = \(propName).map { GeneratedContent(elements: $0) } ?? GeneratedContent(kind: .null)" - } else { - return """ - if let value = \(propName) { - properties["\(propName)"] = value.generatedContent - } else { - properties["\(propName)"] = GeneratedContent(kind: .null) - } - """ - } + return optionalPropertyConversion( + propertyName: propName, + baseType: String(propType.dropLast()), + explicitNil: explicitNil + ) } else if isDictionaryType(propType) { return "properties[\"\(propName)\"] = \(propName).generatedContent" } else if propType.hasPrefix("[") && propType.hasSuffix("]") { @@ -890,7 +900,7 @@ public struct GenerableMacro: MemberMacro, ExtensionMacro { return GeneratedContent( kind: .structure( properties: properties, - orderedKeys: [\(orderedKeys)] + orderedKeys: [\(orderedKeys)].filter { properties[$0] != nil } ) ) } @@ -902,7 +912,8 @@ public struct GenerableMacro: MemberMacro, ExtensionMacro { private static func generateGenerationSchemaProperty( structName: String, description: String?, - properties: [PropertyInfo] + properties: [PropertyInfo], + explicitNil: Bool ) -> DeclSyntax { let propertySchemas = properties.map { prop in let escapedDescription = escapeDescriptionString(prop.guide.description) @@ -923,7 +934,7 @@ public struct GenerableMacro: MemberMacro, ExtensionMacro { nonisolated public static var generationSchema: GenerationSchema { return GenerationSchema( type: Self.self, - description: \(description.map { "\"\($0)\"" } ?? "\"Generated \(structName)\""), + description: \(description.map { "\"\($0)\"" } ?? "\"Generated \(structName)\""),\(explicitNil ? "\n representNilExplicitlyInGeneratedContent: true," : "") properties: [\(properties.isEmpty ? "" : "\n \(propertySchemas)\n ")] ) } diff --git a/Tests/AnyLanguageModelTests/ExplicitNilTests.swift b/Tests/AnyLanguageModelTests/ExplicitNilTests.swift new file mode 100644 index 00000000..aad3f3d8 --- /dev/null +++ b/Tests/AnyLanguageModelTests/ExplicitNilTests.swift @@ -0,0 +1,130 @@ +import Foundation +import Testing + +@testable import AnyLanguageModel + +@Generable(description: "A contact") +private struct ImplicitNilContact { + var name: String + var nickname: String? + var tags: [String]? +} + +@Generable(description: "A contact", representNilExplicitlyInGeneratedContent: true) +private struct ExplicitNilContact { + var name: String + var nickname: String? + var tags: [String]? +} + +@Suite("Explicit nil") +struct ExplicitNilTests { + @Test func nilOptionalPropertiesAreLeftOutByDefault() throws { + let contact = ImplicitNilContact(name: "Alice", nickname: nil, tags: nil) + guard case .structure(let properties, let orderedKeys) = contact.generatedContent.kind else { + Issue.record("Expected structured content") + return + } + #expect(properties.keys.sorted() == ["name"]) + #expect(orderedKeys == ["name"]) + #expect(contact.generatedContent == (try ImplicitNilContact(contact.generatedContent)).generatedContent) + } + + @Test func nilOptionalPropertiesAreNullWhenExplicit() throws { + let contact = ExplicitNilContact(name: "Alice", nickname: nil, tags: nil) + guard case .structure(let properties, let orderedKeys) = contact.generatedContent.kind else { + Issue.record("Expected structured content") + return + } + #expect(properties["nickname"]?.kind == .null) + #expect(properties["tags"]?.kind == .null) + #expect(orderedKeys == ["name", "nickname", "tags"]) + } + + @Test func setOptionalPropertiesAreIncludedEitherWay() { + let implicit = ImplicitNilContact(name: "Alice", nickname: "Al", tags: ["friend"]) + let explicit = ExplicitNilContact(name: "Alice", nickname: "Al", tags: ["friend"]) + #expect(implicit.generatedContent == explicit.generatedContent) + } + + @Test func bothFormsDecode() throws { + let omitted = try GeneratedContent(json: #"{"name": "Alice"}"#) + let null = try GeneratedContent(json: #"{"name": "Alice", "nickname": null, "tags": null}"#) + for content in [omitted, null] { + let implicit = try ImplicitNilContact(content) + let explicit = try ExplicitNilContact(content) + #expect(implicit.nickname == nil && implicit.tags == nil) + #expect(explicit.nickname == nil && explicit.tags == nil) + } + } + + @Test func flagDoesNotChangeEncodedSchema() throws { + let properties = [ + GenerationSchema.Property(name: "name", type: String.self), + GenerationSchema.Property(name: "nickname", type: String?.self), + ] + let implicit = GenerationSchema(type: ImplicitNilContact.self, properties: properties) + let explicit = GenerationSchema( + type: ImplicitNilContact.self, + representNilExplicitlyInGeneratedContent: true, + properties: properties + ) + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + #expect(try encoder.encode(implicit) == encoder.encode(explicit)) + } + + @Test func schemaFillsInNullForOmittedOptionalProperties() throws { + let content = try GeneratedContent(json: #"{"name": "Alice"}"#) + + let explicit = ExplicitNilContact.generationSchema.representingNilExplicitly(in: content) + guard case .structure(let properties, _) = explicit.kind else { + Issue.record("Expected structured content") + return + } + #expect(properties["nickname"]?.kind == .null) + #expect(properties["tags"]?.kind == .null) + #expect(properties["name"] == GeneratedContent("Alice")) + + #expect(ImplicitNilContact.generationSchema.representingNilExplicitly(in: content) == content) + } + + @Test func dynamicSchemaFillsInNullForOmittedOptionalProperties() throws { + let contact = DynamicGenerationSchema( + name: "Contact", + representNilExplicitlyInGeneratedContent: true, + properties: [ + .init(name: "name", schema: .init(type: String.self)), + .init(name: "nickname", schema: .init(type: String.self), isOptional: true), + ] + ) + let schema = try GenerationSchema(root: contact, dependencies: []) + let content = schema.representingNilExplicitly(in: try GeneratedContent(json: #"{"name": "Alice"}"#)) + guard case .structure(let properties, _) = content.kind else { + Issue.record("Expected structured content") + return + } + #expect(properties["nickname"]?.kind == .null) + } + + @Test func sessionRecordsNullForOmittedOptionalProperties() async throws { + let model = MockLanguageModel { _, _ in #"{"name": "Alice"}"# } + + let explicitSession = LanguageModelSession(model: model) + let explicit = try await explicitSession.respond(to: "Who?", generating: ExplicitNilContact.self) + #expect(explicit.rawContent.jsonString.contains("nickname")) + #expect(lastResponseText(in: explicitSession)?.contains(#""nickname":null"#) == true) + + let implicitSession = LanguageModelSession(model: model) + let implicit = try await implicitSession.respond(to: "Who?", generating: ImplicitNilContact.self) + #expect(!implicit.rawContent.jsonString.contains("nickname")) + #expect(lastResponseText(in: implicitSession)?.contains("nickname") == false) + } + + private func lastResponseText(in session: LanguageModelSession) -> String? { + guard case .response(let response)? = session.transcript.last, + case .text(let text)? = response.segments.first + else { return nil } + return text.content + } +} From b45ea3ac447b308294bab0bd5fa73166a6d58dd1 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Fri, 2 Oct 2026 08:58:55 -0700 Subject: [PATCH 03/11] Fill in explicit nulls through unions and in streamed responses --- .../DynamicGenerationSchema.swift | 2 + .../AnyLanguageModel/GenerationSchema.swift | 29 +++++++++++- .../LanguageModelSession.swift | 16 ++++++- .../ExplicitNilTests.swift | 47 +++++++++++++++++++ 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/Sources/AnyLanguageModel/DynamicGenerationSchema.swift b/Sources/AnyLanguageModel/DynamicGenerationSchema.swift index 52032138..2533fdc2 100644 --- a/Sources/AnyLanguageModel/DynamicGenerationSchema.swift +++ b/Sources/AnyLanguageModel/DynamicGenerationSchema.swift @@ -57,6 +57,8 @@ public struct DynamicGenerationSchema: Sendable { /// - description: A natural language description of this schema. /// - explicitNil: Whether generated content has a `null` value /// for each optional property that it would otherwise leave out. + /// Like Foundation Models, + /// the encoded form of a schema built from this one doesn't include this setting. /// - properties: The properties associated with this schema. public init( name: String, diff --git a/Sources/AnyLanguageModel/GenerationSchema.swift b/Sources/AnyLanguageModel/GenerationSchema.swift index 6f3f13d5..c9f46114 100644 --- a/Sources/AnyLanguageModel/GenerationSchema.swift +++ b/Sources/AnyLanguageModel/GenerationSchema.swift @@ -44,6 +44,7 @@ public struct GenerationSchema: Equatable, Codable, CustomDebugStringConvertible case (.object(let lhsObject), .object(let rhsObject)): return lhsObject.description == rhsObject.description && lhsObject.required == rhsObject.required + && lhsObject.representsNilExplicitly == rhsObject.representsNilExplicitly && lhsObject.properties.keys == rhsObject.properties.keys && lhsObject.properties.allSatisfy { key, lhsNode in guard let rhsNode = rhsObject.properties[key] else { return false } @@ -325,6 +326,8 @@ public struct GenerationSchema: Equatable, Codable, CustomDebugStringConvertible /// - description: A natural language description of this schema. /// - explicitNil: Whether generated content has a `null` value /// for each optional property that it would otherwise leave out. + /// Like Foundation Models, + /// the schema's encoded form doesn't include this setting. /// - properties: An array of properties. public init( type: any Generable.Type, @@ -986,8 +989,32 @@ extension GenerationSchema { guard case .array(let elements) = content.kind else { return content } let items = elements.map { representingNilExplicitly(in: $0, node: array.items, depth: depth + 1) } return GeneratedContent(kind: .array(items), id: content.id) - case .string, .number, .boolean, .null, .anyOf: + case .anyOf(let variants): + guard let variant = variant(matching: content, among: variants, depth: depth) else { return content } + return representingNilExplicitly(in: content, node: variant, depth: depth + 1) + case .string, .number, .boolean, .null: return content } } + + /// Returns the first variant whose shape matches the content: + /// an object with every property in a structure, or an array for an array. + private func variant(matching content: GeneratedContent, among variants: [Node], depth: Int) -> Node? { + variants.first { variant in + switch (resolving(variant, depth: depth), content.kind) { + case (.object(let object)?, .structure(let properties, _)): + return properties.keys.allSatisfy { object.properties[$0] != nil } + case (.array?, .array): + return true + default: + return false + } + } + } + + private func resolving(_ node: Node, depth: Int) -> Node? { + guard depth < 64 else { return nil } + guard case .ref(let name) = node else { return node } + return defs[name].flatMap { resolving($0, depth: depth + 1) } + } } diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 5c33a610..7749646f 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -257,11 +257,25 @@ public final class LanguageModelSession: @unchecked Sendable { guard let lastSnapshot else { throw ResponseStreamError.noSnapshots } - // Extract text content from the generated content + // Fill in `null` for omitted optional properties when the schema asks for it, + // and yield the result so that consumers and `collect()` see it. var rawContent = lastSnapshot.rawContent if case .prompt(let prompt) = promptEntry, let responseFormat = prompt.responseFormat { rawContent = responseFormat.schema.representingNilExplicitly(in: rawContent) } + if rawContent != lastSnapshot.rawContent { + continuation.yield( + ResponseStream.Snapshot( + content: lastSnapshot.content, + rawContent: rawContent, + transcriptEntries: lastSnapshot.transcriptEntries, + usage: lastSnapshot.usage, + providerMetadata: lastSnapshot.providerMetadata + ) + ) + } + + // Extract text content from the generated content let textContent: String if case .string(let str) = rawContent.kind { textContent = str diff --git a/Tests/AnyLanguageModelTests/ExplicitNilTests.swift b/Tests/AnyLanguageModelTests/ExplicitNilTests.swift index aad3f3d8..43e30f9f 100644 --- a/Tests/AnyLanguageModelTests/ExplicitNilTests.swift +++ b/Tests/AnyLanguageModelTests/ExplicitNilTests.swift @@ -107,6 +107,53 @@ struct ExplicitNilTests { #expect(properties["nickname"]?.kind == .null) } + @Test func nullableUnionFillsInNullForTheObjectVariant() throws { + let contact = DynamicGenerationSchema( + name: "Contact", + representNilExplicitlyInGeneratedContent: true, + properties: [ + .init(name: "name", schema: .init(type: String.self)), + .init(name: "nickname", schema: .init(type: String.self), isOptional: true), + ] + ) + let nullableContact = DynamicGenerationSchema(name: "NullableContact", anyOf: [contact, .null]) + let schema = try GenerationSchema(root: nullableContact, dependencies: []) + + let content = schema.representingNilExplicitly(in: try GeneratedContent(json: #"{"name": "Alice"}"#)) + guard case .structure(let properties, _) = content.kind else { + Issue.record("Expected structured content") + return + } + #expect(properties["nickname"]?.kind == .null) + + let null = GeneratedContent(kind: .null) + #expect(schema.representingNilExplicitly(in: null) == null) + } + + @Test func flagIsPartOfSchemaEquality() { + let properties = [GenerationSchema.Property(name: "nickname", type: String?.self)] + let implicit = GenerationSchema(type: ImplicitNilContact.self, properties: properties) + let explicit = GenerationSchema( + type: ImplicitNilContact.self, + representNilExplicitlyInGeneratedContent: true, + properties: properties + ) + #expect(implicit != explicit) + } + + @Test func streamedResponseCollectsNullForOmittedOptionalProperties() async throws { + let model = MockLanguageModel { _, _ in #"{"name": "Alice"}"# } + let session = LanguageModelSession(model: model) + + let response = try await session.streamResponse(to: "Who?", generating: ExplicitNilContact.self).collect() + guard case .structure(let properties, _) = response.rawContent.kind else { + Issue.record("Expected structured content") + return + } + #expect(properties["nickname"]?.kind == .null) + #expect(lastResponseText(in: session)?.contains(#""nickname":null"#) == true) + } + @Test func sessionRecordsNullForOmittedOptionalProperties() async throws { let model = MockLanguageModel { _, _ in #"{"name": "Alice"}"# } From f3845e66ee7ac9e85c9166ec0bd6945951eab0eb Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Fri, 2 Oct 2026 09:35:17 -0700 Subject: [PATCH 04/11] Match union variants by required properties and skip token counts on tvOS --- .../AnyLanguageModel/GenerationSchema.swift | 5 +++- .../ExplicitNilTests.swift | 27 +++++++++++++++++++ .../SystemLanguageModelTests.swift | 2 +- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/Sources/AnyLanguageModel/GenerationSchema.swift b/Sources/AnyLanguageModel/GenerationSchema.swift index c9f46114..d7e50c00 100644 --- a/Sources/AnyLanguageModel/GenerationSchema.swift +++ b/Sources/AnyLanguageModel/GenerationSchema.swift @@ -998,12 +998,15 @@ extension GenerationSchema { } /// Returns the first variant whose shape matches the content: - /// an object with every property in a structure, or an array for an array. + /// an object that declares every property in a structure + /// and whose required properties the structure has, + /// or an array for an array. private func variant(matching content: GeneratedContent, among variants: [Node], depth: Int) -> Node? { variants.first { variant in switch (resolving(variant, depth: depth), content.kind) { case (.object(let object)?, .structure(let properties, _)): return properties.keys.allSatisfy { object.properties[$0] != nil } + && object.required.allSatisfy { properties[$0] != nil } case (.array?, .array): return true default: diff --git a/Tests/AnyLanguageModelTests/ExplicitNilTests.swift b/Tests/AnyLanguageModelTests/ExplicitNilTests.swift index 43e30f9f..5bc2af48 100644 --- a/Tests/AnyLanguageModelTests/ExplicitNilTests.swift +++ b/Tests/AnyLanguageModelTests/ExplicitNilTests.swift @@ -130,6 +130,33 @@ struct ExplicitNilTests { #expect(schema.representingNilExplicitly(in: null) == null) } + @Test func unionSkipsObjectVariantsWithMissingRequiredProperties() throws { + let person = DynamicGenerationSchema( + name: "Person", + properties: [ + .init(name: "name", schema: .init(type: String.self)), + .init(name: "age", schema: .init(type: Int.self)), + ] + ) + let contact = DynamicGenerationSchema( + name: "Contact", + representNilExplicitlyInGeneratedContent: true, + properties: [ + .init(name: "name", schema: .init(type: String.self)), + .init(name: "nickname", schema: .init(type: String.self), isOptional: true), + ] + ) + let either = DynamicGenerationSchema(name: "Either", anyOf: [person, contact]) + let schema = try GenerationSchema(root: either, dependencies: []) + + let content = schema.representingNilExplicitly(in: try GeneratedContent(json: #"{"name": "Alice"}"#)) + guard case .structure(let properties, _) = content.kind else { + Issue.record("Expected structured content") + return + } + #expect(properties["nickname"]?.kind == .null) + } + @Test func flagIsPartOfSchemaEquality() { let properties = [GenerationSchema.Property(name: "nickname", type: String?.self)] let implicit = GenerationSchema(type: ImplicitNilContact.self, properties: properties) diff --git a/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift b/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift index cca118dc..51168808 100644 --- a/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift +++ b/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift @@ -104,7 +104,7 @@ import Testing #expect(model.supportsLocale(Locale(identifier: "en_US"))) } - #if compiler(>=6.3) + #if compiler(>=6.3) && !os(tvOS) @available(macOS 26.0, iOS 26.0, tvOS 26.0, visionOS 26.0, *) @Test func tokenCountsArePositive() async throws { guard #available(macOS 26.4, iOS 26.4, visionOS 26.4, *) else { return } From 53fe9cf87049340447faa04a0a7baae7a46b90e4 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Fri, 2 Oct 2026 10:29:50 -0700 Subject: [PATCH 05/11] Return explicit nulls in the content of schema-based responses --- .../LanguageModelSession.swift | 6 +++-- .../ExplicitNilTests.swift | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 7749646f..2d48c48c 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -215,7 +215,8 @@ public final class LanguageModelSession: @unchecked Sendable { let rawContent = responseFormat.schema.representingNilExplicitly(in: response.rawContent) guard rawContent != response.rawContent else { return response } return Response( - content: response.content, + // Schema-based responses return the generated content itself. + content: (rawContent as? Content) ?? response.content, rawContent: rawContent, transcriptEntries: response.transcriptEntries, usage: response.usage, @@ -266,7 +267,8 @@ public final class LanguageModelSession: @unchecked Sendable { if rawContent != lastSnapshot.rawContent { continuation.yield( ResponseStream.Snapshot( - content: lastSnapshot.content, + // Schema-based streams yield the generated content itself. + content: (rawContent as? Content.PartiallyGenerated) ?? lastSnapshot.content, rawContent: rawContent, transcriptEntries: lastSnapshot.transcriptEntries, usage: lastSnapshot.usage, diff --git a/Tests/AnyLanguageModelTests/ExplicitNilTests.swift b/Tests/AnyLanguageModelTests/ExplicitNilTests.swift index 5bc2af48..a6269c41 100644 --- a/Tests/AnyLanguageModelTests/ExplicitNilTests.swift +++ b/Tests/AnyLanguageModelTests/ExplicitNilTests.swift @@ -181,6 +181,31 @@ struct ExplicitNilTests { #expect(lastResponseText(in: session)?.contains(#""nickname":null"#) == true) } + @Test func schemaResponsesReturnNullInContent() async throws { + let contact = DynamicGenerationSchema( + name: "Contact", + representNilExplicitlyInGeneratedContent: true, + properties: [ + .init(name: "name", schema: .init(type: String.self)), + .init(name: "nickname", schema: .init(type: String.self), isOptional: true), + ] + ) + let schema = try GenerationSchema(root: contact, dependencies: []) + let model = MockLanguageModel { _, _ in #"{"name": "Alice"}"# } + + let response = try await LanguageModelSession(model: model).respond(to: "Who?", schema: schema) + #expect(response.content == response.rawContent) + guard case .structure(let properties, _) = response.content.kind else { + Issue.record("Expected structured content") + return + } + #expect(properties["nickname"]?.kind == .null) + + let streamed = try await LanguageModelSession(model: model).streamResponse(to: "Who?", schema: schema) + .collect() + #expect(streamed.content == streamed.rawContent) + } + @Test func sessionRecordsNullForOmittedOptionalProperties() async throws { let model = MockLanguageModel { _, _ in #"{"name": "Alice"}"# } From 0ee0544576638ca94b28170fbc4cbbea260ee544 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Fri, 2 Oct 2026 10:48:27 -0700 Subject: [PATCH 06/11] Forward desired feedback output for Private Cloud Compute --- .../Models/PrivateCloudComputeLanguageModel.swift | 2 +- .../Models/SystemLanguageModel.swift | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/PrivateCloudComputeLanguageModel.swift b/Sources/AnyLanguageModel/Models/PrivateCloudComputeLanguageModel.swift index 7f8f1aa9..659a0150 100644 --- a/Sources/AnyLanguageModel/Models/PrivateCloudComputeLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/PrivateCloudComputeLanguageModel.swift @@ -134,7 +134,7 @@ return fmSession.logFeedbackAttachment( sentiment: sentiment?.toFoundationModels(), issues: issues.map { $0.toFoundationModels() }, - desiredOutput: nil + desiredOutput: desiredOutput?.toFoundationModels() ) } } diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index 42a16bf3..ffbeba01 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -293,9 +293,7 @@ let fmSentiment = sentiment?.toFoundationModels() let fmIssues = issues.map { $0.toFoundationModels() } - let fmDesiredOutput = desiredOutput.flatMap { entry in - Transcript(entries: [entry]).toFoundationModels(instructions: nil, toolDefinitions: []).first - } + let fmDesiredOutput = desiredOutput?.toFoundationModels() return fmSession.logFeedbackAttachment( sentiment: fmSentiment, @@ -637,6 +635,14 @@ } } + @available(macOS 26.0, iOS 26.0, watchOS 27.0, tvOS 26.0, visionOS 26.0, *) + extension Transcript.Entry { + /// Converts the entry to a Foundation Models transcript entry. + func toFoundationModels() -> FoundationModels.Transcript.Entry? { + Transcript(entries: [self]).toFoundationModels(instructions: nil, toolDefinitions: []).first + } + } + @available(macOS 26.0, iOS 26.0, watchOS 27.0, tvOS 26.0, visionOS 26.0, *) extension Transcript { func toFoundationModels( From 87ffd706e45d929768e4c78e1ab7e81a248d58bb Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Fri, 2 Oct 2026 11:53:21 -0700 Subject: [PATCH 07/11] Match nested unions when filling in explicit nulls --- .../AnyLanguageModel/GenerationSchema.swift | 6 ++++- .../ExplicitNilTests.swift | 27 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/Sources/AnyLanguageModel/GenerationSchema.swift b/Sources/AnyLanguageModel/GenerationSchema.swift index d7e50c00..f563d44c 100644 --- a/Sources/AnyLanguageModel/GenerationSchema.swift +++ b/Sources/AnyLanguageModel/GenerationSchema.swift @@ -1000,7 +1000,8 @@ extension GenerationSchema { /// Returns the first variant whose shape matches the content: /// an object that declares every property in a structure /// and whose required properties the structure has, - /// or an array for an array. + /// an array for an array, + /// or a nested union with a matching variant. private func variant(matching content: GeneratedContent, among variants: [Node], depth: Int) -> Node? { variants.first { variant in switch (resolving(variant, depth: depth), content.kind) { @@ -1009,6 +1010,9 @@ extension GenerationSchema { && object.required.allSatisfy { properties[$0] != nil } case (.array?, .array): return true + case (.anyOf(let nested)?, _): + guard depth < 64 else { return false } + return self.variant(matching: content, among: nested, depth: depth + 1) != nil default: return false } diff --git a/Tests/AnyLanguageModelTests/ExplicitNilTests.swift b/Tests/AnyLanguageModelTests/ExplicitNilTests.swift index a6269c41..80042541 100644 --- a/Tests/AnyLanguageModelTests/ExplicitNilTests.swift +++ b/Tests/AnyLanguageModelTests/ExplicitNilTests.swift @@ -157,6 +157,33 @@ struct ExplicitNilTests { #expect(properties["nickname"]?.kind == .null) } + @Test func nestedUnionFillsInNullForTheObjectVariant() throws { + let contact = DynamicGenerationSchema( + name: "Contact", + representNilExplicitlyInGeneratedContent: true, + properties: [ + .init(name: "name", schema: .init(type: String.self)), + .init(name: "nickname", schema: .init(type: String.self), isOptional: true), + ] + ) + let nullableContact = DynamicGenerationSchema(name: "NullableContact", anyOf: [contact, .null]) + let outer = DynamicGenerationSchema( + name: "Outer", + anyOf: [ + DynamicGenerationSchema(type: String.self), + DynamicGenerationSchema(referenceTo: "NullableContact"), + ] + ) + let schema = try GenerationSchema(root: outer, dependencies: [nullableContact]) + + let content = schema.representingNilExplicitly(in: try GeneratedContent(json: #"{"name": "Alice"}"#)) + guard case .structure(let properties, _) = content.kind else { + Issue.record("Expected structured content") + return + } + #expect(properties["nickname"]?.kind == .null) + } + @Test func flagIsPartOfSchemaEquality() { let properties = [GenerationSchema.Property(name: "nickname", type: String?.self)] let implicit = GenerationSchema(type: ImplicitNilContact.self, properties: properties) From a8e7a3926ddc2b99720c82e000ba935631db065a Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Fri, 2 Oct 2026 13:01:27 -0700 Subject: [PATCH 08/11] Require a literal explicit-nil flag and drop null variants before OS 26.4 --- .../Models/SystemLanguageModel.swift | 27 ++++++++++++++++++- .../GenerableMacro.swift | 16 ++++++++--- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index ffbeba01..72b7cf19 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -579,7 +579,21 @@ return .init(type: Bool.self) case .anyOf(let schemas): - return .init(name: name ?? "", anyOf: schemas.map { convertToDynamicSchema($0) }) + // 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 = schemas + if !supportsNullSchema { + let nonNull = schemas.filter { schema in + if case .null = schema { return false } + return 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(_, _, _, _, _, _, items: let items, minItems: let minItems, maxItems: let maxItems, _): let itemsSchema = @@ -605,6 +619,17 @@ } } + /// 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 { + #if compiler(>=6.3) + if #available(macOS 26.4, iOS 26.4, watchOS 27.0, visionOS 26.4, *) { + return true + } + #endif + return false + } + @available(macOS 26.0, iOS 26.0, watchOS 27.0, tvOS 26.0, visionOS 26.0, *) func convertToProperty( key: String, diff --git a/Sources/AnyLanguageModelMacros/GenerableMacro.swift b/Sources/AnyLanguageModelMacros/GenerableMacro.swift index a4a537cd..1e810ad8 100644 --- a/Sources/AnyLanguageModelMacros/GenerableMacro.swift +++ b/Sources/AnyLanguageModelMacros/GenerableMacro.swift @@ -15,7 +15,7 @@ public struct GenerableMacro: MemberMacro, ExtensionMacro { let structName = structDecl.name.text let description = extractDescription(from: node) - let explicitNil = extractRepresentNilExplicitly(from: node) + let explicitNil = try extractRepresentNilExplicitly(from: node) let properties = extractGuidedProperties(from: structDecl) return [ @@ -91,13 +91,18 @@ public struct GenerableMacro: MemberMacro, ExtensionMacro { // MARK: - Helpers /// Returns whether the attribute passes `representNilExplicitlyInGeneratedContent: true`. - private static func extractRepresentNilExplicitly(from node: AttributeSyntax) -> Bool { + /// + /// The macro reads the value when it expands the type, + /// so the argument must be a Boolean literal. + private static func extractRepresentNilExplicitly(from node: AttributeSyntax) throws -> Bool { guard let arguments = node.arguments?.as(LabeledExprListSyntax.self), - let argument = arguments.first(where: { $0.label?.text == "representNilExplicitlyInGeneratedContent" }), - let literal = argument.expression.as(BooleanLiteralExprSyntax.self) + let argument = arguments.first(where: { $0.label?.text == "representNilExplicitlyInGeneratedContent" }) else { return false } + guard let literal = argument.expression.as(BooleanLiteralExprSyntax.self) else { + throw GenerableMacroError.nonLiteralRepresentNilExplicitly + } return literal.literal.tokenKind == .keyword(.true) } @@ -1458,6 +1463,7 @@ public enum GenerableMacroError: Error, CustomStringConvertible { case notApplicableToType case invalidSyntax case missingRequiredParameter + case nonLiteralRepresentNilExplicitly public var description: String { switch self { @@ -1467,6 +1473,8 @@ public enum GenerableMacroError: Error, CustomStringConvertible { return "Invalid macro syntax" case .missingRequiredParameter: return "Missing required parameter" + case .nonLiteralRepresentNilExplicitly: + return "representNilExplicitlyInGeneratedContent must be a Boolean literal (true or false)" } } } From 33ba04cfb5f59cd9509210bb3c70eb4b362fa0cf Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Fri, 2 Oct 2026 18:13:43 -0700 Subject: [PATCH 09/11] Build the final streamed partial content from normalized content --- .../LanguageModelSession.swift | 3 +- .../ExplicitNilTests.swift | 57 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index 2d48c48c..1fd1b6f1 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -267,8 +267,7 @@ public final class LanguageModelSession: @unchecked Sendable { if rawContent != lastSnapshot.rawContent { continuation.yield( ResponseStream.Snapshot( - // Schema-based streams yield the generated content itself. - content: (rawContent as? Content.PartiallyGenerated) ?? lastSnapshot.content, + content: (try? Content.PartiallyGenerated(rawContent)) ?? lastSnapshot.content, rawContent: rawContent, transcriptEntries: lastSnapshot.transcriptEntries, usage: lastSnapshot.usage, diff --git a/Tests/AnyLanguageModelTests/ExplicitNilTests.swift b/Tests/AnyLanguageModelTests/ExplicitNilTests.swift index 80042541..8649f351 100644 --- a/Tests/AnyLanguageModelTests/ExplicitNilTests.swift +++ b/Tests/AnyLanguageModelTests/ExplicitNilTests.swift @@ -233,6 +233,23 @@ struct ExplicitNilTests { #expect(streamed.content == streamed.rawContent) } + @Test func finalStreamedSnapshotHasNullInPartialContent() async throws { + // Providers build partial content from the raw JSON, without normalizing it. + let session = LanguageModelSession(model: RawPartialStreamingModel(json: #"{"name": "Alice"}"#)) + + var last: LanguageModelSession.ResponseStream.Snapshot? + for try await snapshot in session.streamResponse(to: "Who?", generating: ExplicitNilContact.self) { + last = snapshot + } + let snapshot = try #require(last) + guard case .structure(let properties, _) = snapshot.content.generatedContent.kind else { + Issue.record("Expected structured content") + return + } + #expect(properties["nickname"]?.kind == .null) + #expect(snapshot.content.generatedContent == snapshot.rawContent) + } + @Test func sessionRecordsNullForOmittedOptionalProperties() async throws { let model = MockLanguageModel { _, _ in #"{"name": "Alice"}"# } @@ -254,3 +271,43 @@ struct ExplicitNilTests { return text.content } } + +/// A model that streams one snapshot whose partial content comes straight from the raw JSON. +private struct RawPartialStreamingModel: LanguageModel { + typealias UnavailableReason = Never + + let json: String + + func respond( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) async throws -> LanguageModelSession.Response where Content: Generable { + fatalError("Not used") + } + + func streamResponse( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) -> sending LanguageModelSession.ResponseStream where Content: Generable { + let json = json + let stream = AsyncThrowingStream.Snapshot, any Error> { + continuation in + do { + let rawContent = try GeneratedContent(json: json) + continuation.yield( + .init(content: try Content.PartiallyGenerated(rawContent), rawContent: rawContent) + ) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + return LanguageModelSession.ResponseStream(stream: stream) + } +} From 38567746778ec3e7e34dc5ea111f9b272fab368a Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Sat, 3 Oct 2026 04:27:46 -0700 Subject: [PATCH 10/11] Fill in explicit nulls in declaration order and skip null schemas on tvOS --- Sources/AnyLanguageModel/GenerationSchema.swift | 11 ++++++++--- .../Models/SystemLanguageModel.swift | 4 ++-- Tests/AnyLanguageModelTests/ExplicitNilTests.swift | 13 +++++++++++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/Sources/AnyLanguageModel/GenerationSchema.swift b/Sources/AnyLanguageModel/GenerationSchema.swift index f563d44c..201eda85 100644 --- a/Sources/AnyLanguageModel/GenerationSchema.swift +++ b/Sources/AnyLanguageModel/GenerationSchema.swift @@ -234,6 +234,8 @@ public struct GenerationSchema: Equatable, Codable, CustomDebugStringConvertible /// Whether generated content has a `null` value for each optional property /// that it would otherwise leave out. var representsNilExplicitly = false + /// The property names in declaration order, when known. + var propertyOrder: [String] = [] private enum CodingKeys: String, CodingKey { case description, properties, required @@ -366,7 +368,8 @@ public struct GenerationSchema: Equatable, Codable, CustomDebugStringConvertible description: description, properties: props, required: required, - representsNilExplicitly: explicitNil + representsNilExplicitly: explicitNil, + propertyOrder: properties.map(\.name) ) allDefs[typeName] = .object(objectNode) @@ -536,7 +539,8 @@ public struct GenerationSchema: Equatable, Codable, CustomDebugStringConvertible description: desc, properties: props, required: required, - representsNilExplicitly: dynamic.representsNilExplicitly + representsNilExplicitly: dynamic.representsNilExplicitly, + propertyOrder: properties.map(\.name) ) ) if let name = name { @@ -978,7 +982,8 @@ extension GenerationSchema { } } if object.representsNilExplicitly { - for key in object.properties.keys.sorted() + let declaredKeys = object.propertyOrder.isEmpty ? object.properties.keys.sorted() : object.propertyOrder + for key in declaredKeys where properties[key] == nil && !object.required.contains(key) { properties[key] = GeneratedContent(kind: .null) orderedKeys.append(key) diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index 72b7cf19..8b4c6b28 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -607,7 +607,7 @@ return .init(referenceTo: typeName) case .null: - #if compiler(>=6.3) + #if compiler(>=6.3) && !os(tvOS) if #available(macOS 26.4, iOS 26.4, watchOS 27.0, visionOS 26.4, *) { return .null } @@ -622,7 +622,7 @@ /// 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 { - #if compiler(>=6.3) + #if compiler(>=6.3) && !os(tvOS) if #available(macOS 26.4, iOS 26.4, watchOS 27.0, visionOS 26.4, *) { return true } diff --git a/Tests/AnyLanguageModelTests/ExplicitNilTests.swift b/Tests/AnyLanguageModelTests/ExplicitNilTests.swift index 8649f351..3e45dcba 100644 --- a/Tests/AnyLanguageModelTests/ExplicitNilTests.swift +++ b/Tests/AnyLanguageModelTests/ExplicitNilTests.swift @@ -17,6 +17,13 @@ private struct ExplicitNilContact { var tags: [String]? } +@Generable(description: "A place", representNilExplicitlyInGeneratedContent: true) +private struct ExplicitNilPlace { + var name: String + var zone: String? + var alias: String? +} + @Suite("Explicit nil") struct ExplicitNilTests { @Test func nilOptionalPropertiesAreLeftOutByDefault() throws { @@ -184,6 +191,12 @@ struct ExplicitNilTests { #expect(properties["nickname"]?.kind == .null) } + @Test func filledInPropertiesFollowDeclarationOrder() throws { + let content = try GeneratedContent(json: #"{"name": "Home"}"#) + let filled = ExplicitNilPlace.generationSchema.representingNilExplicitly(in: content) + #expect(filled == ExplicitNilPlace(name: "Home", zone: nil, alias: nil).generatedContent) + } + @Test func flagIsPartOfSchemaEquality() { let properties = [GenerationSchema.Property(name: "nickname", type: String?.self)] let implicit = GenerationSchema(type: ImplicitNilContact.self, properties: properties) From 2c63fa65b0ad82f3fdd7edf6f80c97fbc78fce9d Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Sat, 3 Oct 2026 04:42:20 -0700 Subject: [PATCH 11/11] Attach feedback to the conversation and keep declared order for explicit nulls --- .../AnyLanguageModel/GenerationSchema.swift | 8 +++++ .../PrivateCloudComputeLanguageModel.swift | 8 ++++- .../Models/SystemLanguageModel.swift | 8 ++++- .../ExplicitNilTests.swift | 32 +++++++++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/Sources/AnyLanguageModel/GenerationSchema.swift b/Sources/AnyLanguageModel/GenerationSchema.swift index 201eda85..9df4978c 100644 --- a/Sources/AnyLanguageModel/GenerationSchema.swift +++ b/Sources/AnyLanguageModel/GenerationSchema.swift @@ -45,6 +45,7 @@ public struct GenerationSchema: Equatable, Codable, CustomDebugStringConvertible return lhsObject.description == rhsObject.description && lhsObject.required == rhsObject.required && lhsObject.representsNilExplicitly == rhsObject.representsNilExplicitly + && (!lhsObject.representsNilExplicitly || lhsObject.propertyOrder == rhsObject.propertyOrder) && lhsObject.properties.keys == rhsObject.properties.keys && lhsObject.properties.allSatisfy { key, lhsNode in guard let rhsNode = rhsObject.properties[key] else { return false } @@ -988,6 +989,13 @@ extension GenerationSchema { properties[key] = GeneratedContent(kind: .null) orderedKeys.append(key) } + // Put declared properties in declaration order, followed by any others. + if !object.propertyOrder.isEmpty { + let declared = Set(object.propertyOrder) + orderedKeys = + object.propertyOrder.filter { properties[$0] != nil } + + orderedKeys.filter { !declared.contains($0) } + } } return GeneratedContent(kind: .structure(properties: properties, orderedKeys: orderedKeys), id: content.id) case .array(let array): diff --git a/Sources/AnyLanguageModel/Models/PrivateCloudComputeLanguageModel.swift b/Sources/AnyLanguageModel/Models/PrivateCloudComputeLanguageModel.swift index 659a0150..09804437 100644 --- a/Sources/AnyLanguageModel/Models/PrivateCloudComputeLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/PrivateCloudComputeLanguageModel.swift @@ -126,10 +126,16 @@ issues: [LanguageModelFeedback.Issue], desiredOutput: Transcript.Entry? ) -> Data { + // Attach the feedback to the session's conversation, including its latest response. let fmSession = FoundationModels.LanguageModelSession( model: pccModel, tools: session.tools.toFoundationModels(), - instructions: session.instructions?.toFoundationModels() + transcript: session.transcript.toFoundationModels( + instructions: session.instructions, + toolDefinitions: session.tools + .filter(\.includesSchemaInInstructions) + .map { Transcript.ToolDefinition(tool: $0) } + ) ) return fmSession.logFeedbackAttachment( sentiment: sentiment?.toFoundationModels(), diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index 8b4c6b28..645fd4b9 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -285,10 +285,16 @@ issues: [LanguageModelFeedback.Issue], desiredOutput: Transcript.Entry? ) -> Data { + // Attach the feedback to the session's conversation, including its latest response. let fmSession = FoundationModels.LanguageModelSession( model: systemModel, tools: session.tools.toFoundationModels(), - instructions: session.instructions?.toFoundationModels() + transcript: session.transcript.toFoundationModels( + instructions: session.instructions, + toolDefinitions: session.tools + .filter(\.includesSchemaInInstructions) + .map { Transcript.ToolDefinition(tool: $0) } + ) ) let fmSentiment = sentiment?.toFoundationModels() diff --git a/Tests/AnyLanguageModelTests/ExplicitNilTests.swift b/Tests/AnyLanguageModelTests/ExplicitNilTests.swift index 3e45dcba..752c4f0d 100644 --- a/Tests/AnyLanguageModelTests/ExplicitNilTests.swift +++ b/Tests/AnyLanguageModelTests/ExplicitNilTests.swift @@ -24,6 +24,12 @@ private struct ExplicitNilPlace { var alias: String? } +@Generable(description: "A greeting", representNilExplicitlyInGeneratedContent: true) +private struct ExplicitNilGreeting { + var salutation: String? + var name: String +} + @Suite("Explicit nil") struct ExplicitNilTests { @Test func nilOptionalPropertiesAreLeftOutByDefault() throws { @@ -197,6 +203,32 @@ struct ExplicitNilTests { #expect(filled == ExplicitNilPlace(name: "Home", zone: nil, alias: nil).generatedContent) } + @Test func filledInPropertiesKeepDeclarationOrderAroundPresentProperties() throws { + let content = try GeneratedContent(json: #"{"name": "Alice"}"#) + let filled = ExplicitNilGreeting.generationSchema.representingNilExplicitly(in: content) + #expect(filled == ExplicitNilGreeting(salutation: nil, name: "Alice").generatedContent) + } + + @Test func propertyOrderIsPartOfExplicitNilSchemaEquality() { + let name = GenerationSchema.Property(name: "name", type: String.self) + let nickname = GenerationSchema.Property(name: "nickname", type: String?.self) + let first = GenerationSchema( + type: ImplicitNilContact.self, + representNilExplicitlyInGeneratedContent: true, + properties: [name, nickname] + ) + let second = GenerationSchema( + type: ImplicitNilContact.self, + representNilExplicitlyInGeneratedContent: true, + properties: [nickname, name] + ) + #expect(first != second) + #expect( + GenerationSchema(type: ImplicitNilContact.self, properties: [name, nickname]) + == GenerationSchema(type: ImplicitNilContact.self, properties: [nickname, name]) + ) + } + @Test func flagIsPartOfSchemaEquality() { let properties = [GenerationSchema.Property(name: "nickname", type: String?.self)] let implicit = GenerationSchema(type: ImplicitNilContact.self, properties: properties)