diff --git a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift index c8d2d71..e86a606 100644 --- a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift @@ -82,10 +82,6 @@ public struct OpenAILanguageModel: LanguageModel { includeSchemaInPrompt: Bool, options: GenerationOptions ) async throws -> LanguageModelSession.Response where Content: Generable { - // For now, only String is supported - guard type == String.self else { - fatalError("OpenAILanguageModel only supports generating String content") - } var messages: [OpenAIMessage] = [] if let systemSegments = extractInstructionSegments(from: session) { @@ -112,6 +108,7 @@ public struct OpenAILanguageModel: LanguageModel { return try await respondWithChatCompletions( messages: messages, tools: openAITools, + generating: type, options: options, session: session ) @@ -119,6 +116,7 @@ public struct OpenAILanguageModel: LanguageModel { return try await respondWithResponses( messages: messages, tools: openAITools, + generating: type, options: options, session: session ) @@ -128,13 +126,15 @@ public struct OpenAILanguageModel: LanguageModel { private func respondWithChatCompletions( messages: [OpenAIMessage], tools: [OpenAITool]?, + generating type: Content.Type, options: GenerationOptions, session: LanguageModelSession ) async throws -> LanguageModelSession.Response where Content: Generable { - let params = ChatCompletions.createRequestBody( + let params = try ChatCompletions.createRequestBody( model: model, messages: messages, tools: tools, + generating: type, options: options, stream: false ) @@ -153,10 +153,27 @@ public struct OpenAILanguageModel: LanguageModel { var entries: [Transcript.Entry] = [] guard let choice = resp.choices.first else { - return LanguageModelSession.Response( - content: "" as! Content, - rawContent: GeneratedContent(""), - transcriptEntries: ArraySlice(entries) + if type == String.self { + return LanguageModelSession.Response( + content: "" as! Content, + rawContent: GeneratedContent(""), + transcriptEntries: ArraySlice(entries) + ) + } else { + throw OpenAILanguageModelError.noResponseGenerated + } + } + + // Handle refusal + if let refusalMessage = choice.message.refusal { + let refusalEntry = Transcript.Entry.response( + Transcript.Response(assetIDs: [], segments: [.text(.init(content: refusalMessage))]) + ) + throw LanguageModelSession.GenerationError.refusal( + LanguageModelSession.GenerationError.Refusal(transcriptEntries: [refusalEntry]), + LanguageModelSession.GenerationError.Context( + debugDescription: "OpenAI model refused to generate response: \(refusalMessage)" + ) ) } @@ -170,24 +187,39 @@ public struct OpenAILanguageModel: LanguageModel { } } - let text = choice.message.content ?? "" - return LanguageModelSession.Response( - content: text as! Content, - rawContent: GeneratedContent(text), - transcriptEntries: ArraySlice(entries) - ) + // Handle structured output or text output + if type == String.self { + let text = choice.message.content ?? "" + return LanguageModelSession.Response( + content: text as! Content, + rawContent: GeneratedContent(text), + transcriptEntries: ArraySlice(entries) + ) + } else { + // Parse structured JSON response + let text = choice.message.content ?? "" + let generatedContent = try GeneratedContent(json: text) + let content = try type.init(generatedContent) + return LanguageModelSession.Response( + content: content, + rawContent: generatedContent, + transcriptEntries: ArraySlice(entries) + ) + } } private func respondWithResponses( messages: [OpenAIMessage], tools: [OpenAITool]?, + generating type: Content.Type, options: GenerationOptions, session: LanguageModelSession ) async throws -> LanguageModelSession.Response where Content: Generable { - let params = Responses.createRequestBody( + let params = try Responses.createRequestBody( model: model, messages: messages, tools: tools, + generating: type, options: options, stream: false ) @@ -216,12 +248,28 @@ public struct OpenAILanguageModel: LanguageModel { } } - let text = resp.outputText ?? extractTextFromOutput(resp.output) ?? "" - return LanguageModelSession.Response( - content: text as! Content, - rawContent: GeneratedContent(text), - transcriptEntries: ArraySlice(entries) - ) + // Handle structured output or text output + if type == String.self { + let text = resp.outputText ?? extractTextFromOutput(resp.output) ?? "" + return LanguageModelSession.Response( + content: text as! Content, + rawContent: GeneratedContent(text), + transcriptEntries: ArraySlice(entries) + ) + } else { + // Parse structured JSON response from output blocks + if let jsonString = extractJSONFromOutput(resp.output) { + let generatedContent = try GeneratedContent(json: jsonString) + let content = try type.init(generatedContent) + return LanguageModelSession.Response( + content: content, + rawContent: generatedContent, + transcriptEntries: ArraySlice(entries) + ) + } else { + throw OpenAILanguageModelError.noResponseGenerated + } + } } public func streamResponse( @@ -231,10 +279,6 @@ public struct OpenAILanguageModel: LanguageModel { includeSchemaInPrompt: Bool, options: GenerationOptions ) -> sending LanguageModelSession.ResponseStream where Content: Generable { - // For now, only String is supported - guard type == String.self else { - fatalError("OpenAILanguageModel only supports generating String content") - } var messages: [OpenAIMessage] = [] if let systemSegments = extractInstructionSegments(from: session) { @@ -258,120 +302,177 @@ public struct OpenAILanguageModel: LanguageModel { switch apiVariant { case .responses: - let params = Responses.createRequestBody( - model: model, - messages: messages, - tools: openAITools, - options: options, - stream: true - ) - let url = baseURL.appendingPathComponent("responses") let stream: AsyncThrowingStream.Snapshot, any Error> = .init { continuation in - let task = Task { @Sendable in - do { - let body = try JSONEncoder().encode(params) - - let events: AsyncThrowingStream = - urlSession.fetchEventStream( - .post, - url: url, - headers: [ - "Authorization": "Bearer \(tokenProvider())" - ], - body: body - ) - - var accumulatedText = "" - - for try await event in events { - switch event { - case .outputTextDelta(let delta): - accumulatedText += delta - - // Yield snapshot with partially generated content - let raw = GeneratedContent(accumulatedText) - let content: Content.PartiallyGenerated = (accumulatedText as! Content) - .asPartiallyGenerated() - continuation.yield(.init(content: content, rawContent: raw)) - - case .toolCallCreated(_): - // Minimal streaming implementation ignores tool call events - break - case .toolCallDelta(_): - // Minimal streaming implementation ignores tool call deltas - break - case .completed(_): - continuation.finish() - case .ignored: - break + do { + let params = try Responses.createRequestBody( + model: model, + messages: messages, + tools: openAITools, + generating: type, + options: options, + stream: true + ) + let task = Task { @Sendable in + do { + let body = try JSONEncoder().encode(params) + + let events: AsyncThrowingStream = + urlSession.fetchEventStream( + .post, + url: url, + headers: [ + "Authorization": "Bearer \(tokenProvider())" + ], + body: body + ) + + var accumulatedText = "" + + for try await event in events { + switch event { + case .outputTextDelta(let delta): + accumulatedText += delta + + // Yield snapshot with partially generated content + var raw: GeneratedContent + let content: Content.PartiallyGenerated? + + if type == String.self { + raw = GeneratedContent(accumulatedText) + content = (accumulatedText as! Content).asPartiallyGenerated() + } else { + // Try to parse as JSON, falling back to string if incomplete + raw = + (try? GeneratedContent(json: accumulatedText)) + ?? GeneratedContent(accumulatedText) + if let parsed = try? type.init(raw) { + content = parsed.asPartiallyGenerated() + } else { + // Fallback: try to create from empty properties, or skip this chunk + if let emptyParsed = try? type.init(GeneratedContent(properties: [:])) { + content = emptyParsed.asPartiallyGenerated() + } else { + // Last resort: skip this chunk + raw = GeneratedContent(accumulatedText) + content = nil + } + } + } + + if let content { + continuation.yield(.init(content: content, rawContent: raw)) + } + + case .toolCallCreated(_): + // Minimal streaming implementation ignores tool call events + break + case .toolCallDelta(_): + // Minimal streaming implementation ignores tool call deltas + break + case .completed(_): + continuation.finish() + case .ignored: + break + } } - } - continuation.finish() - } catch { - continuation.finish(throwing: error) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } } + continuation.onTermination = { _ in task.cancel() } + } catch { + continuation.finish(throwing: error) } - continuation.onTermination = { _ in task.cancel() } } return LanguageModelSession.ResponseStream(stream: stream) case .chatCompletions: - let params = ChatCompletions.createRequestBody( - model: model, - messages: messages, - tools: openAITools, - options: options, - stream: true - ) - let url = baseURL.appendingPathComponent("chat/completions") let stream: AsyncThrowingStream.Snapshot, any Error> = .init { continuation in - let task = Task { @Sendable in - do { - let body = try JSONEncoder().encode(params) - - let events: AsyncThrowingStream = - urlSession.fetchEventStream( - .post, - url: url, - headers: [ - "Authorization": "Bearer \(tokenProvider())" - ], - body: body - ) - - var accumulatedText = "" - - for try await chunk in events { - if let choice = chunk.choices.first { - if let piece = choice.delta.content, !piece.isEmpty { - accumulatedText += piece - - let raw = GeneratedContent(accumulatedText) - let content: Content.PartiallyGenerated = (accumulatedText as! Content) - .asPartiallyGenerated() - continuation.yield(.init(content: content, rawContent: raw)) - } - - if choice.finishReason != nil { - continuation.finish() + do { + let params = try ChatCompletions.createRequestBody( + model: model, + messages: messages, + tools: openAITools, + generating: type, + options: options, + stream: true + ) + + let task = Task { @Sendable in + do { + let body = try JSONEncoder().encode(params) + + let events: AsyncThrowingStream = + urlSession.fetchEventStream( + .post, + url: url, + headers: [ + "Authorization": "Bearer \(tokenProvider())" + ], + body: body + ) + + var accumulatedText = "" + + for try await chunk in events { + if let choice = chunk.choices.first { + if let piece = choice.delta.content, !piece.isEmpty { + accumulatedText += piece + + var raw: GeneratedContent + let content: Content.PartiallyGenerated? + + if type == String.self { + raw = GeneratedContent(accumulatedText) + content = (accumulatedText as! Content).asPartiallyGenerated() + } else { + // Try to parse as JSON, falling back to string if incomplete + raw = + (try? GeneratedContent(json: accumulatedText)) + ?? GeneratedContent(accumulatedText) + if let parsed = try? type.init(raw) { + content = parsed.asPartiallyGenerated() + } else { + // Fallback: try to create from empty properties, or use string fallback + if let emptyParsed = try? type.init(GeneratedContent(properties: [:])) { + content = emptyParsed.asPartiallyGenerated() + } else { + // Last resort: skip this chunk + raw = GeneratedContent(accumulatedText) + content = nil + } + } + } + + if let content { + continuation.yield(.init(content: content, rawContent: raw)) + } + } + + if choice.finishReason != nil { + continuation.finish() + } } } - } - continuation.finish() - } catch { - continuation.finish(throwing: error) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } } + continuation.onTermination = { _ in task.cancel() } + } catch { + continuation.finish(throwing: error) } - continuation.onTermination = { _ in task.cancel() } } return LanguageModelSession.ResponseStream(stream: stream) @@ -382,13 +483,14 @@ public struct OpenAILanguageModel: LanguageModel { // MARK: - API Variants private enum ChatCompletions { - static func createRequestBody( + static func createRequestBody( model: String, messages: [OpenAIMessage], tools: [OpenAITool]?, + generating type: Content.Type, options: GenerationOptions, stream: Bool - ) -> JSONValue { + ) throws -> JSONValue { var body: [String: JSONValue] = [ "model": .string(model), "messages": .array(messages.map { $0.jsonValue(for: .chatCompletions) }), @@ -399,6 +501,20 @@ private enum ChatCompletions { body["tools"] = .array(tools.map { $0.jsonValue(for: .chatCompletions) }) } + // Add response_format for structured output (if not String) + if type != String.self { + let jsonSchemaValue = try type.generationSchema.toJSONValueForOpenAIStrictMode() + + body["response_format"] = .object([ + "type": .string("json_schema"), + "json_schema": .object([ + "name": .string("response_schema"), + "strict": .bool(true), + "schema": jsonSchemaValue, + ]), + ]) + } + if let temperature = options.temperature { body["temperature"] = .double(temperature) } @@ -426,11 +542,13 @@ private enum ChatCompletions { struct Message: Decodable, Sendable { let role: String let content: String? + let refusal: String? let toolCalls: [OpenAIToolCall]? private enum CodingKeys: String, CodingKey { case role case content + case refusal case toolCalls = "tool_calls" } } @@ -438,13 +556,14 @@ private enum ChatCompletions { } private enum Responses { - static func createRequestBody( + static func createRequestBody( model: String, messages: [OpenAIMessage], tools: [OpenAITool]?, + generating type: Content.Type, options: GenerationOptions, stream: Bool - ) -> JSONValue { + ) throws -> JSONValue { // Build input blocks from the user message content let systemMessage = messages.first { $0.role == .system } let userMessage = messages.first { $0.role == .user } @@ -510,6 +629,21 @@ private enum Responses { body["tools"] = .array(tools.map { $0.jsonValue(for: .responses) }) } + // Add text.format for structured output (if not String) + // Responses API uses text.format instead of response_format + if type != String.self { + let jsonSchemaValue = try type.generationSchema.toJSONValueForOpenAIStrictMode() + + body["text"] = .object([ + "format": .object([ + "type": .string("json_schema"), + "name": .string("response_schema"), + "strict": .bool(true), + "schema": jsonSchemaValue, + ]) + ]) + } + if let temperature = options.temperature { body["temperature"] = .double(temperature) } @@ -910,6 +1044,31 @@ private func extractTextFromOutput(_ output: [JSONValue]?) -> String? { return textParts.isEmpty ? nil : textParts.joined() } +private func extractJSONFromOutput(_ output: [JSONValue]?) -> String? { + guard let output else { return nil } + + for block in output { + if case let .object(obj) = block, + case let .string(type)? = obj["type"], + type == "message", + case let .array(contentBlocks)? = obj["content"] + { + for contentBlock in contentBlocks { + if case let .object(contentObj) = contentBlock, + case let .string(contentType)? = contentObj["type"], + contentType == "output_text", + case let .string(jsonString)? = contentObj["text"] + { + // For structured output, the text field contains a JSON string + return jsonString + } + } + } + } + + return nil +} + private func extractToolCallsFromOutput(_ output: [JSONValue]?) -> [OpenAIToolCall] { guard let output else { return [] } @@ -998,3 +1157,55 @@ private func extractToolCallsFromOutput(_ output: [JSONValue]?) -> [OpenAIToolCa return toolCalls } + +// MARK: - Errors + +enum OpenAILanguageModelError: LocalizedError { + case noResponseGenerated + + var errorDescription: String? { + switch self { + case .noResponseGenerated: + return "No response was generated by the model" + } + } +} + +// MARK: - + +private extension GenerationSchema { + /// Converts this schema to a JSONValue with OpenAI strict mode requirements applied. + /// + /// OpenAI's strict mode requires: + /// 1. `additionalProperties: false` at root and all nested objects + /// 2. All properties (including optional ones) must be in the `required` array + /// + /// - Returns: A JSONValue representation of the schema with strict mode constraints applied + /// - Throws: An error if the schema cannot be encoded + func toJSONValueForOpenAIStrictMode() throws -> JSONValue { + let resolvedSchema = self.withResolvedRoot() ?? self + + let encoder = JSONEncoder() + encoder.userInfo[GenerationSchema.omitAdditionalPropertiesKey] = false + let schemaData = try encoder.encode(resolvedSchema) + let jsonSchema = try JSONDecoder().decode(JSONSchema.self, from: schemaData) + var jsonSchemaValue = try JSONValue(jsonSchema) + + // Apply OpenAI strict mode requirements + if case .object(var schemaObj) = jsonSchemaValue { + schemaObj["additionalProperties"] = .bool(false) + + // Ensure all properties are in the required array + if case .object(let properties)? = schemaObj["properties"], + !properties.isEmpty + { + let allPropertyNames = Array(properties.keys).sorted() + schemaObj["required"] = .array(allPropertyNames.map { .string($0) }) + } + + jsonSchemaValue = .object(schemaObj) + } + + return jsonSchemaValue + } +} diff --git a/Tests/AnyLanguageModelTests/OpenAILanguageModelTests.swift b/Tests/AnyLanguageModelTests/OpenAILanguageModelTests.swift index 11028ea..5fdf280 100644 --- a/Tests/AnyLanguageModelTests/OpenAILanguageModelTests.swift +++ b/Tests/AnyLanguageModelTests/OpenAILanguageModelTests.swift @@ -260,5 +260,304 @@ struct OpenAILanguageModelTests { } #expect(foundToolOutput) } + + @Suite("Structured Output") + struct StructuredOutputTests { + @Generable + struct Person { + @Guide(description: "The person's full name") + var name: String + + @Guide(description: "The person's age in years") + var age: Int + + @Guide(description: "The person's email address") + var email: String? + } + + @Generable + struct Book { + @Guide(description: "The book's title") + var title: String + + @Guide(description: "The book's author") + var author: String + + @Guide(description: "The publication year") + var year: Int + } + + private var model: OpenAILanguageModel { + OpenAILanguageModel(apiKey: openaiAPIKey!, model: "gpt-4o-mini", apiVariant: .chatCompletions) + } + + @Test func basicStructuredOutput() async throws { + let session = LanguageModelSession(model: model) + let response = try await session.respond( + to: "Generate a person named John Doe, age 30, email john@example.com", + generating: Person.self + ) + + // Verify structured output was generated successfully + #expect(!response.content.name.isEmpty) + #expect(response.content.name.contains("John") || response.content.name.contains("Doe")) + #expect(response.content.age > 0) + #expect(response.content.age <= 100) + #expect(response.content.email != nil) + } + + @Test func structuredOutputWithOptionalField() async throws { + let session = LanguageModelSession(model: model) + let response = try await session.respond( + to: "Generate a person named Jane Smith, age 25, with no email", + generating: Person.self + ) + + #expect(!response.content.name.isEmpty) + #expect(response.content.name.contains("Jane") || response.content.name.contains("Smith")) + #expect(response.content.age > 0) + #expect(response.content.age <= 100) + #expect(response.content.email == nil || response.content.email?.isEmpty == true) + } + + @Test func structuredOutputWithNestedTypes() async throws { + let session = LanguageModelSession(model: model) + let response = try await session.respond( + to: "Generate a book titled 'The Swift Programming Language' by 'Apple Inc.' published in 2024", + generating: Book.self + ) + + #expect(!response.content.title.isEmpty) + #expect(!response.content.author.isEmpty) + #expect(response.content.year >= 2020) + } + + @Test func streamingStructuredOutput() async throws { + let session = LanguageModelSession(model: model) + let stream = session.streamResponse( + to: "Generate a person named Alice, age 28, email alice@example.com", + generating: Person.self + ) + + var snapshots: [LanguageModelSession.ResponseStream.Snapshot] = [] + for try await snapshot in stream { + snapshots.append(snapshot) + } + + #expect(!snapshots.isEmpty) + let finalSnapshot = snapshots.last! + #expect((finalSnapshot.content.name?.isEmpty ?? true) == false) + #expect((finalSnapshot.content.age ?? 0) > 0) + } + } + } + + @Suite("OpenAILanguageModel Responses API", .enabled(if: openaiAPIKey?.isEmpty == false)) + struct ResponsesAPITests { + private let apiKey = openaiAPIKey! + + private var model: OpenAILanguageModel { + OpenAILanguageModel(apiKey: apiKey, model: "gpt-4o-mini", apiVariant: .responses) + } + + @Test func basicResponse() async throws { + let session = LanguageModelSession(model: model) + + let response = try await session.respond(to: "Say hello") + #expect(!response.content.isEmpty) + } + + @Test func withInstructions() async throws { + let session = LanguageModelSession( + model: model, + instructions: "You are a helpful assistant. Be concise." + ) + + let response = try await session.respond(to: "What is 2+2?") + #expect(!response.content.isEmpty) + } + + @Test func streaming() async throws { + let session = LanguageModelSession(model: model) + + let stream = session.streamResponse(to: "Count to 5") + var chunks: [String] = [] + + for try await response in stream { + chunks.append(response.content) + } + + #expect(!chunks.isEmpty) + } + + @Test func streamingString() async throws { + let session = LanguageModelSession(model: model) + + let stream = session.streamResponse(to: "Say 'Hello' slowly") + + var snapshots: [LanguageModelSession.ResponseStream.Snapshot] = [] + for try await snapshot in stream { + snapshots.append(snapshot) + } + + #expect(!snapshots.isEmpty) + #expect(!snapshots.last!.rawContent.jsonString.isEmpty) + } + + @Test func withGenerationOptions() async throws { + let session = LanguageModelSession(model: model) + + let options = GenerationOptions( + temperature: 0.7, + maximumResponseTokens: 50 + ) + + let response = try await session.respond( + to: "Tell me a fact", + options: options + ) + #expect(!response.content.isEmpty) + } + + @Test func multimodalWithImageURL() async throws { + let transcript = Transcript(entries: [ + .prompt( + Transcript.Prompt(segments: [ + .text(.init(content: "Describe this image")), + .image(.init(url: testImageURL)), + ]) + ) + ]) + let session = LanguageModelSession(model: model, transcript: transcript) + let response = try await session.respond(to: "") + #expect(!response.content.isEmpty) + } + + @Test func multimodalWithImageData() async throws { + let transcript = Transcript(entries: [ + .prompt( + Transcript.Prompt(segments: [ + .text(.init(content: "Describe this image")), + .image(.init(data: testImageData, mimeType: "image/png")), + ]) + ) + ]) + let session = LanguageModelSession(model: model, transcript: transcript) + let response = try await session.respond(to: "") + #expect(!response.content.isEmpty) + } + + @Test func conversationContext() async throws { + let session = LanguageModelSession(model: model) + + let firstResponse = try await session.respond(to: "My favorite color is blue") + #expect(!firstResponse.content.isEmpty) + + let secondResponse = try await session.respond(to: "What did I just tell you?") + #expect(!secondResponse.content.isEmpty) + } + + @Test func withTools() async throws { + let weatherTool = WeatherTool() + let session = LanguageModelSession(model: model, tools: [weatherTool]) + + let response = try await session.respond(to: "How's the weather in San Francisco?") + + var foundToolOutput = false + for case let .toolOutput(toolOutput) in response.transcriptEntries { + #expect(toolOutput.id == "getWeather") + foundToolOutput = true + } + #expect(foundToolOutput) + } + + @Suite("Structured Output") + struct ResponsesStructuredOutputTests { + @Generable + struct Person { + @Guide(description: "The person's full name") + var name: String + + @Guide(description: "The person's age in years") + var age: Int + + @Guide(description: "The person's email address") + var email: String? + } + + @Generable + struct Book { + @Guide(description: "The book's title") + var title: String + + @Guide(description: "The book's author") + var author: String + + @Guide(description: "The publication year") + var year: Int + } + + private var model: OpenAILanguageModel { + OpenAILanguageModel(apiKey: openaiAPIKey!, model: "gpt-4o-mini", apiVariant: .responses) + } + + @Test func basicStructuredOutput() async throws { + let session = LanguageModelSession(model: model) + let response = try await session.respond( + to: "Generate a person named John Doe, age 30, email john@example.com", + generating: Person.self + ) + + #expect(!response.content.name.isEmpty) + #expect(response.content.name.contains("John") || response.content.name.contains("Doe")) + #expect(response.content.age > 0) + #expect(response.content.age <= 100) + #expect(response.content.email != nil) + } + + @Test func structuredOutputWithOptionalField() async throws { + let session = LanguageModelSession(model: model) + let response = try await session.respond( + to: "Generate a person named Jane Smith, age 25, with no email", + generating: Person.self + ) + + #expect(!response.content.name.isEmpty) + #expect(response.content.name.contains("Jane") || response.content.name.contains("Smith")) + #expect(response.content.age > 0) + #expect(response.content.age <= 100) + #expect(response.content.email == nil || response.content.email?.isEmpty == true) + } + + @Test func structuredOutputWithNestedTypes() async throws { + let session = LanguageModelSession(model: model) + let response = try await session.respond( + to: "Generate a book titled 'The Swift Programming Language' by 'Apple Inc.' published in 2024", + generating: Book.self + ) + + #expect(!response.content.title.isEmpty) + #expect(!response.content.author.isEmpty) + #expect(response.content.year >= 2020) + } + + @Test func streamingStructuredOutput() async throws { + let session = LanguageModelSession(model: model) + let stream = session.streamResponse( + to: "Generate a person named Alice, age 28, email alice@example.com", + generating: Person.self + ) + + var snapshots: [LanguageModelSession.ResponseStream.Snapshot] = [] + for try await snapshot in stream { + snapshots.append(snapshot) + } + + #expect(!snapshots.isEmpty) + let finalSnapshot = snapshots.last! + #expect((finalSnapshot.content.name?.isEmpty ?? true) == false) + #expect((finalSnapshot.content.age ?? 0) > 0) + } + } } }