diff --git a/Sources/AnyLanguageModel/GenerationSchema.swift b/Sources/AnyLanguageModel/GenerationSchema.swift index 694dac0c..a8065d96 100644 --- a/Sources/AnyLanguageModel/GenerationSchema.swift +++ b/Sources/AnyLanguageModel/GenerationSchema.swift @@ -408,14 +408,15 @@ public struct GenerationSchema: Sendable, Codable, CustomDebugStringConvertible private static func convertDynamic( _ dynamic: DynamicGenerationSchema, nameMap: [String: DynamicGenerationSchema], - defs: inout [String: Node] + defs: inout [String: Node], + dynamicProp: DynamicGenerationSchema.Property? = nil ) throws -> Node { switch dynamic.body { case .object(let name, let desc, let properties): var props: [String: Node] = [:] var required: Set = [] for prop in properties { - props[prop.name] = try convertDynamic(prop.schema, nameMap: nameMap, defs: &defs) + props[prop.name] = try convertDynamic(prop.schema, nameMap: nameMap, defs: &defs, dynamicProp: prop) if !prop.isOptional { required.insert(prop.name) } @@ -452,20 +453,28 @@ public struct GenerationSchema: Sendable, Codable, CustomDebugStringConvertible case .array(let item, let min, let max): let itemNode = try convertDynamic(item, nameMap: nameMap, defs: &defs) - return .array(ArrayNode(description: nil, items: itemNode, minItems: min, maxItems: max)) + return .array( + ArrayNode(description: dynamicProp?.description, items: itemNode, minItems: min, maxItems: max) + ) case .scalar(let scalar): switch scalar { case .bool: return .boolean case .string: - return .string(StringNode(description: nil, pattern: nil, enumChoices: nil)) + return .string(StringNode(description: dynamicProp?.description, pattern: nil, enumChoices: nil)) case .number: - return .number(NumberNode(description: nil, minimum: nil, maximum: nil, integerOnly: false)) + return .number( + NumberNode(description: dynamicProp?.description, minimum: nil, maximum: nil, integerOnly: false) + ) case .integer: - return .number(NumberNode(description: nil, minimum: nil, maximum: nil, integerOnly: true)) + return .number( + NumberNode(description: dynamicProp?.description, minimum: nil, maximum: nil, integerOnly: true) + ) case .decimal: - return .number(NumberNode(description: nil, minimum: nil, maximum: nil, integerOnly: false)) + return .number( + NumberNode(description: dynamicProp?.description, minimum: nil, maximum: nil, integerOnly: false) + ) } case .reference(let name): diff --git a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift index c8d2d714..77b64187 100644 --- a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift @@ -131,46 +131,62 @@ public struct OpenAILanguageModel: LanguageModel { options: GenerationOptions, session: LanguageModelSession ) async throws -> LanguageModelSession.Response where Content: Generable { - let params = ChatCompletions.createRequestBody( - model: model, - messages: messages, - tools: tools, - options: options, - stream: false - ) - - let url = baseURL.appendingPathComponent("chat/completions") - let body = try JSONEncoder().encode(params) - let resp: ChatCompletions.Response = try await urlSession.fetch( - .post, - url: url, - headers: [ - "Authorization": "Bearer \(tokenProvider())" - ], - body: body - ) var entries: [Transcript.Entry] = [] + var text = "" + var messages = messages - guard let choice = resp.choices.first else { - return LanguageModelSession.Response( - content: "" as! Content, - rawContent: GeneratedContent(""), - transcriptEntries: ArraySlice(entries) + // Loop until no more tool calls + while true { + let params = ChatCompletions.createRequestBody( + model: model, + messages: messages, + tools: tools, + options: options, + stream: false + ) + + let url = baseURL.appendingPathComponent("chat/completions") + let body = try JSONEncoder().encode(params) + let resp: ChatCompletions.Response = try await urlSession.fetch( + .post, + url: url, + headers: [ + "Authorization": "Bearer \(tokenProvider())" + ], + body: body ) - } - if let toolCalls = choice.message.toolCalls, !toolCalls.isEmpty { - let invocations = try await resolveToolCalls(toolCalls, session: session) - if !invocations.isEmpty { - entries.append(.toolCalls(Transcript.ToolCalls(invocations.map { $0.call }))) - for invocation in invocations { - entries.append(.toolOutput(invocation.output)) + guard let choice = resp.choices.first else { + return LanguageModelSession.Response( + content: "" as! Content, + rawContent: GeneratedContent(""), + transcriptEntries: ArraySlice(entries) + ) + } + + let toolCallMessage = choice.message + if let toolCalls = toolCallMessage.toolCalls, !toolCalls.isEmpty { + if let value = try? JSONValue(toolCallMessage) { + messages.append(OpenAIMessage(role: .raw(rawContent: value), content: .text(""))) + } + let invocations = try await resolveToolCalls(toolCalls, session: session) + if !invocations.isEmpty { + entries.append(.toolCalls(Transcript.ToolCalls(invocations.map { $0.call }))) + for invocation in invocations { + let output = invocation.output + entries.append(.toolOutput(output)) + let toolSegments: [Transcript.Segment] = output.segments + let blocks = convertSegmentsToOpenAIBlocks(toolSegments) + messages.append(OpenAIMessage(role: .tool(id: invocation.call.id), content: .blocks(blocks))) + } + continue } } - } - let text = choice.message.content ?? "" + text = choice.message.content ?? "" + break + } return LanguageModelSession.Response( content: text as! Content, rawContent: GeneratedContent(text), @@ -184,39 +200,59 @@ public struct OpenAILanguageModel: LanguageModel { options: GenerationOptions, session: LanguageModelSession ) async throws -> LanguageModelSession.Response where Content: Generable { - let params = Responses.createRequestBody( - model: model, - messages: messages, - tools: tools, - options: options, - stream: false - ) + var entries: [Transcript.Entry] = [] + var text = "" + var messages = messages let url = baseURL.appendingPathComponent("responses") - let body = try JSONEncoder().encode(params) - let resp: Responses.Response = try await urlSession.fetch( - .post, - url: url, - headers: [ - "Authorization": "Bearer \(tokenProvider())" - ], - body: body - ) - var entries: [Transcript.Entry] = [] + // Loop until no more tool calls + while true { + let params = Responses.createRequestBody( + model: model, + messages: messages, + tools: tools, + options: options, + stream: false + ) - let toolCalls = extractToolCallsFromOutput(resp.output) - if !toolCalls.isEmpty { - let invocations = try await resolveToolCalls(toolCalls, session: session) - if !invocations.isEmpty { - entries.append(.toolCalls(Transcript.ToolCalls(invocations.map { $0.call }))) - for invocation in invocations { - entries.append(.toolOutput(invocation.output)) + let encoder = JSONEncoder() + let body = try encoder.encode(params) + let resp: Responses.Response = try await urlSession.fetch( + .post, + url: url, + headers: [ + "Authorization": "Bearer \(tokenProvider())" + ], + body: body + ) + + let toolCalls = extractToolCallsFromOutput(resp.output) + if !toolCalls.isEmpty { + if let output = resp.output { + for msg in output { + messages.append(OpenAIMessage(role: .raw(rawContent: msg), content: .text(""))) + } + } + let invocations = try await resolveToolCalls(toolCalls, session: session) + if !invocations.isEmpty { + entries.append(.toolCalls(Transcript.ToolCalls(invocations.map { $0.call }))) + + for invocation in invocations { + let output = invocation.output + entries.append(.toolOutput(output)) + let toolSegments: [Transcript.Segment] = output.segments + let blocks = convertSegmentsToOpenAIBlocks(toolSegments) + messages.append(OpenAIMessage(role: .tool(id: invocation.call.id), content: .blocks(blocks))) + } + continue } } - } - let text = resp.outputText ?? extractTextFromOutput(resp.output) ?? "" + text = resp.outputText ?? extractTextFromOutput(resp.output) ?? "" + + break + } return LanguageModelSession.Response( content: text as! Content, rawContent: GeneratedContent(text), @@ -413,7 +449,7 @@ private enum ChatCompletions { let id: String let choices: [Choice] - struct Choice: Decodable, Sendable { + struct Choice: Codable, Sendable { let message: Message let finishReason: String? @@ -423,7 +459,7 @@ private enum ChatCompletions { } } - struct Message: Decodable, Sendable { + struct Message: Codable, Sendable { let role: String let content: String? let toolCalls: [OpenAIToolCall]? @@ -446,65 +482,118 @@ private enum Responses { stream: Bool ) -> JSONValue { // Build input blocks from the user message content - let systemMessage = messages.first { $0.role == .system } - let userMessage = messages.first { $0.role == .user } var body: [String: JSONValue] = [ "model": .string(model), "stream": .bool(stream), ] - if let userMessage { - // Wrap user content into a single top-level message as required by Responses API - let contentBlocks: [JSONValue] - switch userMessage.content { - case .text(let text): - contentBlocks = [ - .object(["type": .string("input_text"), "text": .string(text)]) - ] - case .blocks(let blocks): - contentBlocks = blocks.map { block in - switch block { - case .text(let text): - return .object(["type": .string("input_text"), "text": .string(text)]) - case .imageURL(let url): - return .object([ - "type": .string("input_image"), - "image_url": .object(["url": .string(url)]), - ]) + var outputs: [JSONValue] = [] + for msg in messages { + switch msg.role { + case .user: + + let userMessage = msg + // Wrap user content into a single top-level message as required by Responses API + let contentBlocks: [JSONValue] + switch userMessage.content { + case .text(let text): + contentBlocks = [ + .object(["type": .string("input_text"), "text": .string(text)]) + ] + case .blocks(let blocks): + contentBlocks = blocks.map { block in + switch block { + case .text(let text): + return .object(["type": .string("input_text"), "text": .string(text)]) + case .imageURL(let url): + return .object([ + "type": .string("input_image"), + "image_url": .object(["url": .string(url)]), + ]) + } } } - } - - body["input"] = .array([ - .object([ + let object = JSONValue.object([ "type": .string("message"), "role": .string("user"), "content": .array(contentBlocks), ]) - ]) - } else { - body["input"] = .array([ - .object([ - "type": .string("message"), - "role": .string("user"), - "content": .array([]), - ]) - ]) - } - - if let systemMessage { - switch systemMessage.content { - case .text(let text): - body["instructions"] = .string(text) - case .blocks(let blocks): - // Concatenate text blocks for instructions; ignore images - let text = blocks.compactMap { if case .text(let t) = $0 { return t } else { return nil } }.joined( - separator: "\n" + outputs.append(object) + + case .tool(let id): + let toolMessage = msg + // Wrap user content into a single top-level message as required by Responses API + var contentBlocks: [JSONValue] + switch toolMessage.content { + case .text(let text): + contentBlocks = [ + .object(["type": .string("input_text"), "text": .string(text)]) + ] + case .blocks(let blocks): + contentBlocks = blocks.map { block in + switch block { + case .text(let text): + return .object(["type": .string("input_text"), "text": .string(text)]) + case .imageURL(let url): + return .object([ + "type": .string("input_image"), + "image_url": .object(["url": .string(url)]), + ]) + } + } + } + let outputString: String + if contentBlocks.count > 1 { + let encoder = JSONEncoder() + if let data = try? encoder.encode(JSONValue.array(contentBlocks)), + let str = String(data: data, encoding: .utf8) + { + outputString = str + } else { + outputString = "[]" + } + } else if let block = contentBlocks.first { + let encoder = JSONEncoder() + if let data = try? encoder.encode(block), + let str = String(data: data, encoding: .utf8) + { + outputString = str + } else { + outputString = "{}" + } + } else { + outputString = "{}" + } + outputs.append( + .object([ + "type": .string("function_call_output"), + "call_id": .string(id), + "output": .string(outputString), + ]) ) - if !text.isEmpty { body["instructions"] = .string(text) } + + case .raw(rawContent: let rawContent): + outputs.append(rawContent) + + case .system: + let systemMessage = msg + switch systemMessage.content { + case .text(let text): + body["instructions"] = .string(text) + case .blocks(let blocks): + // Concatenate text blocks for instructions; ignore images + let text = blocks.compactMap { if case .text(let t) = $0 { return t } else { return nil } }.joined( + separator: "\n" + ) + if !text.isEmpty { body["instructions"] = .string(text) } + } + + case .assistant: + break } } + body["input"] = .array(outputs) if let tools { body["tools"] = .array(tools.map { $0.jsonValue(for: .responses) }) @@ -523,6 +612,7 @@ private enum Responses { struct Response: Decodable, Sendable { let id: String let output: [JSONValue]? + let error: [JSONValue]? let outputText: String? let finishReason: String? @@ -531,6 +621,7 @@ private enum Responses { case output case outputText = "output_text" case finishReason = "finish_reason" + case error = "error" } } } @@ -538,7 +629,19 @@ private enum Responses { // MARK: - Supporting Types private struct OpenAIMessage: Hashable, Codable, Sendable { - enum Role: String, Hashable, Codable, Sendable { case system, user, assistant, tool } + enum Role: Hashable, Codable, Sendable { + case system, user, assistant, raw(rawContent: JSONValue), tool(id: String) + + var description: String { + switch self { + case .system: return "system" + case .user: return "user" + case .assistant: return "assistant" + case .tool(id: _): return "tool" + case .raw(rawContent: _): return "raw" + } + } + } enum Content: Hashable, Codable, Sendable { case text(String) @@ -548,40 +651,55 @@ private struct OpenAIMessage: Hashable, Codable, Sendable { let role: Role let content: Content - func jsonValue(for apiVariant: OpenAILanguageModel.APIVariant) -> JSONValue { + func contentAsJsonValue(for apiVariant: OpenAILanguageModel.APIVariant) -> JSONValue { switch content { case .text(let text): switch apiVariant { case .chatCompletions: - // Chat Completions uses simple string content - return .object([ - "role": .string(role.rawValue), - "content": .string(text), - ]) + return .string(text) case .responses: - // Responses API uses array of content blocks - return .object([ - "role": .string(role.rawValue), - "content": .array([.object(["type": .string("text"), "text": .string(text)])]), - ]) + return .array([.object(["type": .string("text"), "text": .string(text)])]) } case .blocks(let blocks): switch apiVariant { case .chatCompletions: - // Chat Completions accepts array of content parts + return .array(blocks.map { $0.jsonValueForChatCompletions }) + case .responses: + return .array(blocks.map { $0.jsonValueForResponses }) + } + } + } + + func jsonValue(for apiVariant: OpenAILanguageModel.APIVariant) -> JSONValue { + + switch role { + case .raw(rawContent: let rawContent): + return rawContent + + case .tool(id: let id): + switch apiVariant { + case .chatCompletions: return .object([ - "role": .string(role.rawValue), - "content": .array(blocks.map { $0.jsonValueForChatCompletions }), + "role": .string(role.description), + "tool_call_id": .string(id), + "content": contentAsJsonValue(for: apiVariant), ]) case .responses: - // Responses expects message content blocks return .object([ - "role": .string(role.rawValue), - "content": .array(blocks.map { $0.jsonValueForResponses }), + "type": .string("function_call_output"), + "call_id": .string(id), + "content": contentAsJsonValue(for: apiVariant), ]) } + + case .system, .user, .assistant: + return .object([ + "role": .string(role.description), + "content": contentAsJsonValue(for: apiVariant), + ]) } } + } private enum Block: Hashable, Codable, Sendable { @@ -622,7 +740,12 @@ private func convertSegmentsToOpenAIBlocks(_ segments: [Transcript.Segment]) -> case .text(let text): blocks.append(.text(text.content)) case .structure(let structured): - blocks.append(.text(structured.content.jsonString)) + switch structured.content.kind { + case .string(let text): + blocks.append(.text(text)) + default: + blocks.append(.text(structured.content.jsonString)) + } case .image(let image): switch image.source { case .url(let url): @@ -737,13 +860,13 @@ private struct OpenAISchema: Hashable, Codable, Sendable { } } -private struct OpenAIToolCall: Decodable, Sendable { +private struct OpenAIToolCall: Codable, Sendable { let id: String? let type: String? let function: OpenAIToolFunction? } -private struct OpenAIToolFunction: Decodable, Sendable { +private struct OpenAIToolFunction: Codable, Sendable { let name: String let arguments: String? } @@ -844,7 +967,7 @@ private func resolveToolCalls( do { let segments = try await tool.makeOutputSegments(from: args) let output = Transcript.ToolOutput( - id: tool.name, + id: callID, toolName: tool.name, segments: segments ) @@ -920,7 +1043,11 @@ private func extractToolCallsFromOutput(_ output: [JSONValue]?) -> [OpenAIToolCa { // Handle direct function_call at top level if type == "function_call" { - guard let id = obj["id"].flatMap({ if case .string(let s) = $0 { return s } else { return nil } }), + // Responses API uses "call_id", Chat Completions uses "id" + guard + let id = (obj["call_id"] ?? obj["id"]).flatMap({ + if case .string(let s) = $0 { return s } else { return nil } + }), let name = obj["name"].flatMap({ if case .string(let s) = $0 { return s } else { return nil } }) else { continue } diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index 930fd274..88efd692 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -3,6 +3,8 @@ import Foundation import PartialJSONDecoder + import JSONSchema + /// A language model that uses Apple Intelligence. /// /// Use this model to generate text using on-device language models provided by Apple. @@ -116,20 +118,25 @@ instructions: session.instructions?.toFoundationModels() ) - // Bridge FoundationModels' stream into our ResponseStream snapshots - let fmStream: FoundationModels.LanguageModelSession.ResponseStream = - fmSession.streamResponse(to: fmPrompt, options: fmOptions) - let fmBox = UnsafeSendableBox(value: fmStream) - let stream = AsyncThrowingStream.Snapshot, any Error> { @Sendable continuation in let task = Task { + // Bridge FoundationModels' stream into our ResponseStream snapshots + let fmStream: FoundationModels.LanguageModelSession.ResponseStream = + fmSession.streamResponse(to: fmPrompt, options: fmOptions) + var accumulatedText = "" do { // Iterate FM stream of String snapshots var lastLength = 0 - for try await snapshot in fmBox.value { - let chunkText: String = snapshot.content + for try await snapshot in fmStream { + var chunkText: String = snapshot.content + + // We something get "null" from FoundationModels as a first temp result when streaming + // Some nil is probably converted to our String type when no data is available + if chunkText == "null" && accumulatedText == "" { + chunkText = "" + } if chunkText.count >= lastLength, chunkText.hasPrefix(accumulatedText) { // Cumulative; compute delta via previous length @@ -150,7 +157,6 @@ accumulatedText += chunkText lastLength = accumulatedText.count } - // Build raw content from plain text let raw: GeneratedContent = GeneratedContent(accumulatedText) @@ -280,14 +286,193 @@ @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) extension Array where Element == (any Tool) { fileprivate func toFoundationModels() -> [any FoundationModels.Tool] { - return [] + map { AnyToolWrapper($0) } + } + } + + /// A type-erased wrapper that bridges any `Tool` to `FoundationModels.Tool`. + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + private struct AnyToolWrapper: FoundationModels.Tool { + typealias Arguments = FoundationModels.GeneratedContent + typealias Output = String + + let name: String + let description: String + let parameters: FoundationModels.GenerationSchema + let includesSchemaInInstructions: Bool + + private let wrappedTool: any Tool + + init(_ tool: any Tool) { + self.wrappedTool = tool + self.name = tool.name + self.description = tool.description + self.parameters = FoundationModels.GenerationSchema(tool.parameters) + self.includesSchemaInInstructions = tool.includesSchemaInInstructions + } + + func call(arguments: FoundationModels.GeneratedContent) async throws -> Output { + let output = try await wrappedTool.callFunction(arguments: arguments) + return output.promptRepresentation.description + } + } + + @available(macOS 26.0, *) + extension FoundationModels.GenerationSchema { + internal init(_ content: AnyLanguageModel.GenerationSchema) { + let resolvedSchema = content.withResolvedRoot() ?? content + + let rawParameters = try? JSONValue(resolvedSchema) + var schema: FoundationModels.GenerationSchema? = nil + if rawParameters?.objectValue is [String: JSONValue] { + if let data = try? JSONEncoder().encode(rawParameters) { + if let jsonSchema = try? JSONDecoder().decode(JSONSchema.self, from: data) { + let dynamicSchema = convertToDynamicSchema(jsonSchema) + schema = try? FoundationModels.GenerationSchema(root: dynamicSchema, dependencies: []) + } + } + } + if let schema = schema { + self = schema + } else { + self = FoundationModels.GenerationSchema( + type: String.self, + properties: [] + ) + + } + } + } + + @available(macOS 26.0, *) + extension FoundationModels.GeneratedContent { + internal init(_ content: AnyLanguageModel.GeneratedContent) throws { + try self.init(json: content.jsonString) + } + } + + @available(macOS 26.0, *) + extension AnyLanguageModel.GeneratedContent { + internal init(_ content: FoundationModels.GeneratedContent) throws { + try self.init(json: content.jsonString) + } + } + + @available(macOS 26.0, *) + extension Tool { + fileprivate func callFunction(arguments: FoundationModels.GeneratedContent) async throws + -> any PromptRepresentable + { + let content = try GeneratedContent(arguments) + return try await call(arguments: Self.Arguments(content)) + } + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func convertToDynamicSchema(_ jsonSchema: JSONSchema) -> FoundationModels.DynamicGenerationSchema { + switch jsonSchema { + case .object(_, _, _, _, _, _, properties: let properties, required: let required, _): + let schemaProperties = properties.compactMap { key, value in + convertToProperty(key: key, schema: value, required: required) + } + return .init(name: "", description: jsonSchema.description, properties: schemaProperties) + + case .string(_, _, _, _, _, _, _, _, pattern: let pattern, _): + var guides: [FoundationModels.GenerationGuide] = [] + if let values = jsonSchema.enum?.compactMap(\.stringValue), !values.isEmpty { + guides.append(.anyOf(values)) + } + if let value = jsonSchema.const?.stringValue { + guides.append(.constant(value)) + } + if let pattern, let regex = try? Regex(pattern) { + guides.append(.pattern(regex)) + } + return .init(type: String.self, guides: guides) + + case .integer(_, _, _, _, _, _, minimum: let minimum, maximum: let maximum, _, _, _): + if let enumValues = jsonSchema.enum { + let enumsSchema = enumValues.compactMap { convertConstToSchema($0) } + return .init(name: "", anyOf: enumsSchema) + } + + var guides: [FoundationModels.GenerationGuide] = [] + if let min = minimum { + guides.append(.minimum(min)) + } + if let max = maximum { + guides.append(.maximum(max)) + } + if let value = jsonSchema.const?.intValue { + guides.append(.range(value ... value)) + } + return .init(type: Int.self, guides: guides) + + case .number(_, _, _, _, _, _, minimum: let minimum, maximum: let maximum, _, _, _): + if let enumValues = jsonSchema.enum { + let enumsSchema = enumValues.compactMap { convertConstToSchema($0) } + return .init(name: "", anyOf: enumsSchema) + } + + var guides: [FoundationModels.GenerationGuide] = [] + if let min = minimum { + guides.append(.minimum(min)) + } + if let max = maximum { + guides.append(.maximum(max)) + } + if let value = jsonSchema.const?.doubleValue { + guides.append(.range(value ... value)) + } + return .init(type: Double.self, guides: guides) + + case .boolean: + return .init(type: Bool.self) + + case .anyOf(let schemas): + return .init(name: "", anyOf: schemas.map { convertToDynamicSchema($0) }) + + case .array(_, _, _, _, _, _, items: let items, minItems: let minItems, maxItems: let maxItems, _): + let itemsSchema = + items.map { convertToDynamicSchema($0) } + ?? FoundationModels.DynamicGenerationSchema(type: String.self) + return .init(arrayOf: itemsSchema, minimumElements: minItems, maximumElements: maxItems) + + case .reference(let name): + return .init(referenceTo: name) + + case .allOf, .oneOf, .not, .null, .empty, .any: + return .init(type: String.self) } } - // MARK: - Errors + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func convertToProperty( + key: String, + schema: JSONSchema, + required: [String] + ) -> FoundationModels.DynamicGenerationSchema.Property { + .init( + name: key, + description: schema.description, + schema: convertToDynamicSchema(schema), + isOptional: !required.contains(key) + ) + } + /// Converts a JSON constant value to a DynamicGenerationSchema. + /// Only handles scalar types (int, double, string); returns nil for null, object, bool, and array. @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) - private enum SystemLanguageModelError: Error { - case streamingFailed + func convertConstToSchema(_ value: JSONValue) -> FoundationModels.DynamicGenerationSchema? { + switch value { + case .int(let intValue): + .init(type: Int.self, guides: [.range(intValue ... intValue)]) + case .double(let doubleValue): + .init(type: Double.self, guides: [.range(doubleValue ... doubleValue)]) + case .string(let stringValue): + .init(type: String.self, guides: [.constant(stringValue)]) + case .null, .object, .bool, .array: + nil + } } #endif diff --git a/Tests/AnyLanguageModelTests/DynamicSchemaConversionTests.swift b/Tests/AnyLanguageModelTests/DynamicSchemaConversionTests.swift new file mode 100644 index 00000000..b5d95f3c --- /dev/null +++ b/Tests/AnyLanguageModelTests/DynamicSchemaConversionTests.swift @@ -0,0 +1,408 @@ +import Testing + +@testable import AnyLanguageModel + +#if canImport(FoundationModels) + import FoundationModels + import JSONSchema + + private let isFoundationModelsAvailable: Bool = { + if #available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) { + return true + } + return false + }() + + @Suite("Dynamic Schema Conversion", .enabled(if: isFoundationModelsAvailable)) + struct DynamicSchemaConversionTests { + + // MARK: - Primitive Types + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertStringSchema() throws { + let schema: JSONSchema = .string(description: "A name") + let dynamic = convertToDynamicSchema(schema) + + _ = try FoundationModels.GenerationSchema(root: dynamic, dependencies: []) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertStringSchemaWithEnum() throws { + let schema: JSONSchema = .string(enum: ["red", "green", "blue"]) + let dynamic = convertToDynamicSchema(schema) + + _ = try FoundationModels.GenerationSchema(root: dynamic, dependencies: []) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertStringSchemaWithConst() throws { + let schema: JSONSchema = .string(const: "fixed_value") + let dynamic = convertToDynamicSchema(schema) + + _ = try FoundationModels.GenerationSchema(root: dynamic, dependencies: []) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertStringSchemaWithPattern() throws { + let schema: JSONSchema = .string(pattern: "^[A-Z]{2}$") + let dynamic = convertToDynamicSchema(schema) + + _ = try FoundationModels.GenerationSchema(root: dynamic, dependencies: []) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertIntegerSchema() throws { + let schema: JSONSchema = .integer(description: "An age") + let dynamic = convertToDynamicSchema(schema) + + _ = try FoundationModels.GenerationSchema(root: dynamic, dependencies: []) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertIntegerSchemaWithRange() throws { + let schema: JSONSchema = .integer(minimum: 0, maximum: 100) + let dynamic = convertToDynamicSchema(schema) + + _ = try FoundationModels.GenerationSchema(root: dynamic, dependencies: []) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertIntegerSchemaWithEnum() throws { + let schema: JSONSchema = .integer(enum: [1, 2, 3, 5, 8]) + let dynamic = convertToDynamicSchema(schema) + + _ = try FoundationModels.GenerationSchema(root: dynamic, dependencies: []) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertNumberSchema() throws { + let schema: JSONSchema = .number(description: "A temperature") + let dynamic = convertToDynamicSchema(schema) + + _ = try FoundationModels.GenerationSchema(root: dynamic, dependencies: []) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertNumberSchemaWithRange() throws { + let schema: JSONSchema = .number(minimum: -273.15, maximum: 1000.0) + let dynamic = convertToDynamicSchema(schema) + + _ = try FoundationModels.GenerationSchema(root: dynamic, dependencies: []) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertBooleanSchema() throws { + let schema: JSONSchema = .boolean(description: "Is active") + let dynamic = convertToDynamicSchema(schema) + + _ = try FoundationModels.GenerationSchema(root: dynamic, dependencies: []) + } + + // MARK: - Array Types + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertArraySchema() throws { + let schema: JSONSchema = .array(items: .string()) + let dynamic = convertToDynamicSchema(schema) + + _ = try FoundationModels.GenerationSchema(root: dynamic, dependencies: []) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertArraySchemaWithConstraints() throws { + let schema: JSONSchema = .array(items: .integer(), minItems: 1, maxItems: 10) + let dynamic = convertToDynamicSchema(schema) + + _ = try FoundationModels.GenerationSchema(root: dynamic, dependencies: []) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertArraySchemaWithoutItems() throws { + let schema: JSONSchema = .array() + let dynamic = convertToDynamicSchema(schema) + + // Should default to String items + _ = try FoundationModels.GenerationSchema(root: dynamic, dependencies: []) + } + + // MARK: - Object Types + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertObjectSchema() throws { + let schema: JSONSchema = .object( + description: "A person", + properties: [ + "name": .string(description: "The person's name"), + "age": .integer(description: "The person's age"), + ], + required: ["name"] + ) + let dynamic = convertToDynamicSchema(schema) + + _ = try FoundationModels.GenerationSchema(root: dynamic, dependencies: []) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertNestedObjectSchema() throws { + let addressSchema: JSONSchema = .object( + properties: [ + "street": .string(), + "city": .string(), + "region": .string(), + "postalCode": .string(), + ], + required: ["street", "city", "region"] + ) + + let schema: JSONSchema = .object( + properties: [ + "name": .string(), + "address": addressSchema, + ], + required: ["name", "address"] + ) + let dynamic = convertToDynamicSchema(schema) + + _ = try FoundationModels.GenerationSchema(root: dynamic, dependencies: []) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertObjectWithArrayProperty() throws { + let schema: JSONSchema = .object( + properties: [ + "tags": .array(items: .string(), minItems: 1), + "scores": .array(items: .integer()), + ], + required: ["tags"] + ) + let dynamic = convertToDynamicSchema(schema) + + _ = try FoundationModels.GenerationSchema(root: dynamic, dependencies: []) + } + + // MARK: - Composite Types + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertAnyOfSchema() throws { + let schema: JSONSchema = .anyOf([ + .string(), + .integer(), + ]) + let dynamic = convertToDynamicSchema(schema) + + _ = try FoundationModels.GenerationSchema(root: dynamic, dependencies: []) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertReferenceSchema() { + let schema: JSONSchema = .reference("SomeType") + // Reference schemas need the referenced type in dependencies + // This test just verifies the conversion doesn't crash + _ = convertToDynamicSchema(schema) + } + + // MARK: - Fallback Types + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertNullSchemaFallsBackToString() throws { + let schema: JSONSchema = .null + let dynamic = convertToDynamicSchema(schema) + + _ = try FoundationModels.GenerationSchema(root: dynamic, dependencies: []) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertEmptySchemaFallsBackToString() throws { + let schema: JSONSchema = .empty + let dynamic = convertToDynamicSchema(schema) + + _ = try FoundationModels.GenerationSchema(root: dynamic, dependencies: []) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertAnySchemaFallsBackToString() throws { + let schema: JSONSchema = .any + let dynamic = convertToDynamicSchema(schema) + + _ = try FoundationModels.GenerationSchema(root: dynamic, dependencies: []) + } + + // MARK: - Property Conversion + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertRequiredProperty() throws { + let schema: JSONSchema = .string(description: "Required field") + let property = convertToProperty(key: "name", schema: schema, required: ["name"]) + + // Build a schema with this property to verify it's valid + let objectSchema = FoundationModels.DynamicGenerationSchema( + name: "Test", + description: nil, + properties: [property] + ) + _ = try FoundationModels.GenerationSchema(root: objectSchema, dependencies: []) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertOptionalProperty() throws { + let schema: JSONSchema = .string(description: "Optional field") + let property = convertToProperty(key: "nickname", schema: schema, required: ["name"]) + + let objectSchema = FoundationModels.DynamicGenerationSchema( + name: "Test", + description: nil, + properties: [property] + ) + _ = try FoundationModels.GenerationSchema(root: objectSchema, dependencies: []) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertPropertyWithDescription() throws { + let schema: JSONSchema = .string(description: "A detailed description") + let property = convertToProperty(key: "field", schema: schema, required: []) + + let objectSchema = FoundationModels.DynamicGenerationSchema( + name: "Test", + description: nil, + properties: [property] + ) + _ = try FoundationModels.GenerationSchema(root: objectSchema, dependencies: []) + } + + // MARK: - Constant Value Conversion + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertIntConstant() { + let value: JSONValue = .int(42) + let schema = convertConstToSchema(value) + + #expect(schema != nil) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertDoubleConstant() { + let value: JSONValue = .double(3.14) + let schema = convertConstToSchema(value) + + #expect(schema != nil) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertStringConstant() { + let value: JSONValue = .string("constant") + let schema = convertConstToSchema(value) + + #expect(schema != nil) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertNullConstantReturnsNil() { + let value: JSONValue = .null + let schema = convertConstToSchema(value) + + #expect(schema == nil) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertBoolConstantReturnsNil() { + let value: JSONValue = .bool(true) + let schema = convertConstToSchema(value) + + #expect(schema == nil) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertArrayConstantReturnsNil() { + let value: JSONValue = .array([.int(1), .int(2)]) + let schema = convertConstToSchema(value) + + #expect(schema == nil) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertObjectConstantReturnsNil() { + let value: JSONValue = .object(["key": .string("value")]) + let schema = convertConstToSchema(value) + + #expect(schema == nil) + } + + // MARK: - Integration with AnyLanguageModel.GenerationSchema + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertFromAnyLanguageModelGenerationSchema() { + // Create a schema using AnyLanguageModel types + let schema = AnyLanguageModel.GenerationSchema( + type: String.self, + properties: [ + AnyLanguageModel.GenerationSchema.Property( + name: "text", + description: "Some text", + type: String.self + ) + ] + ) + + // Convert through the FoundationModels.GenerationSchema initializer + _ = FoundationModels.GenerationSchema(schema) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertSchemaWithIntegerProperty() { + let schema = AnyLanguageModel.GenerationSchema( + type: Int.self, + properties: [ + AnyLanguageModel.GenerationSchema.Property( + name: "count", + description: "A count value", + type: Int.self + ) + ] + ) + + _ = FoundationModels.GenerationSchema(schema) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertSchemaWithBooleanProperty() { + let schema = AnyLanguageModel.GenerationSchema( + type: Bool.self, + properties: [ + AnyLanguageModel.GenerationSchema.Property( + name: "isEnabled", + description: "To enable or not to enable", + type: Bool.self + ) + ] + ) + + _ = FoundationModels.GenerationSchema(schema) + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test func convertSchemaWithMultiplePropertyTypes() { + let schema = AnyLanguageModel.GenerationSchema( + type: String.self, + properties: [ + AnyLanguageModel.GenerationSchema.Property( + name: "name", + description: "A name", + type: String.self + ), + AnyLanguageModel.GenerationSchema.Property( + name: "age", + description: "An age", + type: Int.self + ), + AnyLanguageModel.GenerationSchema.Property( + name: "active", + description: "Is active", + type: Bool.self + ), + ] + ) + + _ = FoundationModels.GenerationSchema(schema) + } + } +#endif diff --git a/Tests/AnyLanguageModelTests/OpenAILanguageModelTests.swift b/Tests/AnyLanguageModelTests/OpenAILanguageModelTests.swift index 11028eab..74c29b0a 100644 --- a/Tests/AnyLanguageModelTests/OpenAILanguageModelTests.swift +++ b/Tests/AnyLanguageModelTests/OpenAILanguageModelTests.swift @@ -135,7 +135,7 @@ struct OpenAILanguageModelTests { var foundToolOutput = false for case let .toolOutput(toolOutput) in response.transcriptEntries { - #expect(toolOutput.id == "getWeather") + #expect(toolOutput.toolName == "getWeather") foundToolOutput = true } #expect(foundToolOutput) @@ -255,7 +255,7 @@ struct OpenAILanguageModelTests { var foundToolOutput = false for case let .toolOutput(toolOutput) in response.transcriptEntries { - #expect(toolOutput.id == "getWeather") + #expect(toolOutput.toolName == "getWeather") foundToolOutput = true } #expect(foundToolOutput) diff --git a/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift b/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift index 9d642f5c..2958cd13 100644 --- a/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift +++ b/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift @@ -85,5 +85,26 @@ import AnyLanguageModel #expect(!snapshots.isEmpty) #expect(!snapshots.last!.rawContent.jsonString.isEmpty) } + + @available(macOS 26.0, *) + @Test func withTools() async throws { + let weatherTool = WeatherTool() + let session = LanguageModelSession(model: SystemLanguageModel.default, tools: [weatherTool]) + + let response = try await session.respond(to: "How's the weather in San Francisco?") + + #if false // Disabled for now because transcript entries are not converted from FoundationModels for now + var foundToolOutput = false + for case let .toolOutput(toolOutput) in response.transcriptEntries { + #expect(toolOutput.id == "getWeather") + foundToolOutput = true + } + #expect(foundToolOutput) + #endif + + let content = response.content + #expect(content.contains("San Francisco")) + #expect(content.contains("72°F")) + } } #endif