From 3b36ec1aca08fd6d83543d82b6710361917b4c25 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 10 Feb 2026 05:11:23 -0800 Subject: [PATCH 1/6] Add support for Open Responses --- README.md | 55 +- .../Models/OpenResponsesLanguageModel.swift | 947 ++++++++++++++++++ .../CustomGenerationOptionsTests.swift | 120 +++ .../OpenResponsesLanguageModelTests.swift | 229 +++++ 4 files changed, 1343 insertions(+), 8 deletions(-) create mode 100644 Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift create mode 100644 Tests/AnyLanguageModelTests/OpenResponsesLanguageModelTests.swift diff --git a/README.md b/README.md index b8429ff4..efb90976 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ session.toolExecutionDelegate = ToolExecutionObserver() - [x] Google [Gemini API](https://ai.google.dev/api/generate-content) - [x] OpenAI [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) - [x] OpenAI [Responses API](https://platform.openai.com/docs/api-reference/responses) +- [x] [Open Responses](https://www.openresponses.org) (multi-provider Responses API–compatible endpoints) ## Requirements @@ -509,6 +510,41 @@ options[custom: OpenAILanguageModel.self] = .init( ) ``` +### Open Responses + +Connects to any API that conforms to the [Open Responses](https://www.openresponses.org) specification (e.g. OpenAI, OpenRouter, or other compatible providers). Base URL is required—use your provider’s endpoint: + +```swift +// Example: OpenAI +let model = OpenResponsesLanguageModel( + baseURL: URL(string: "https://api.openai.com/v1/")!, + apiKey: ProcessInfo.processInfo.environment["OPEN_RESPONSES_API_KEY"]!, + model: "gpt-4o-mini" +) + +// Example: OpenRouter (https://openrouter.ai/api/v1/) +let model = OpenResponsesLanguageModel( + baseURL: URL(string: "https://openrouter.ai/api/v1/")!, + apiKey: ProcessInfo.processInfo.environment["OPEN_RESPONSES_API_KEY"]!, + model: "openai/gpt-4o-mini" +) + +let session = LanguageModelSession(model: model) +let response = try await session.respond(to: "Say hello") +``` + +Custom options support Open Responses–specific fields such as `tool_choice` (including `allowed_tools`) and `extraBody`: + +```swift +var options = GenerationOptions(temperature: 0.8) +options[custom: OpenResponsesLanguageModel.self] = .init( + toolChoice: .auto, + allowedTools: ["getWeather"], + reasoningEffort: .high, + extraBody: ["custom_param": .string("value")] +) +``` + ### Anthropic Uses the [Messages API](https://docs.claude.com/en/api/messages) with Claude models: @@ -691,14 +727,15 @@ swift test Tests for different language model backends have varying requirements: -| Backend | Traits | Environment Variables | -| --------- | -------- | --------------------- | -| CoreML | `CoreML` | `HF_TOKEN` | -| MLX | `MLX` | `HF_TOKEN` | -| Llama | `Llama` | `LLAMA_MODEL_PATH` | -| Anthropic | — | `ANTHROPIC_API_KEY` | -| OpenAI | — | `OPENAI_API_KEY` | -| Ollama | — | — | +| Backend | Traits | Environment Variables | +| -------------- | -------- | --------------------------------------------------- | +| CoreML | `CoreML` | `HF_TOKEN` | +| MLX | `MLX` | `HF_TOKEN` | +| Llama | `Llama` | `LLAMA_MODEL_PATH` | +| Anthropic | — | `ANTHROPIC_API_KEY` | +| OpenAI | — | `OPENAI_API_KEY` | +| Open Responses | — | `OPEN_RESPONSES_API_KEY`, `OPEN_RESPONSES_BASE_URL` | +| Ollama | — | — | Example setup for running multiple tests at once: @@ -707,6 +744,8 @@ export HF_TOKEN=your_huggingface_token export LLAMA_MODEL_PATH=/path/to/model.gguf export ANTHROPIC_API_KEY=your_anthropic_key export OPENAI_API_KEY=your_openai_key +export OPEN_RESPONSES_API_KEY=your_open_responses_key +export OPEN_RESPONSES_BASE_URL=https://api.openai.com/v1/ swift test --traits CoreML,Llama ``` diff --git a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift new file mode 100644 index 00000000..de3ca6ff --- /dev/null +++ b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift @@ -0,0 +1,947 @@ +import Foundation +import JSONSchema + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// A language model that connects to APIs conforming to the +/// [Open Responses](https://www.openresponses.org) specification. +/// +/// Open Responses defines a shared schema for multi-provider, interoperable LLM +/// interfaces based on the OpenAI Responses API. Use this model with any +/// provider that implements the Open Responses spec (e.g. OpenAI, OpenRouter, +/// or other compatible endpoints). +/// +/// ```swift +/// let model = OpenResponsesLanguageModel( +/// baseURL: URL(string: "https://openrouter.ai/api/v1/")!, +/// apiKey: "your-api-key", +/// model: "openai/gpt-4o-mini" +/// ) +/// ``` +public struct OpenResponsesLanguageModel: LanguageModel { + /// The reason the model is unavailable. + /// This model is always available. + public typealias UnavailableReason = Never + + /// Custom generation options for Open Responses–compatible APIs. + /// + /// Includes Open Responses–specific fields such as ``toolChoice`` (including + /// ``ToolChoice/allowedTools(tools:mode:)``), ``allowedTools``, and + /// reasoning/text options. Use ``extraBody`` for parameters not yet modeled. + public struct CustomGenerationOptions: AnyLanguageModel.CustomGenerationOptions, Codable, Sendable { + /// Controls whether and how the model may invoke tools. + public var toolChoice: ToolChoice? + + /// Limits which tools the model is permitted to invoke for this request. + /// When set, the model may only call tools in this list. + public var allowedTools: [String]? + + /// Top-p (nucleus) sampling. Range 0.0–1.0. + public var topP: Double? + + /// Presence penalty. Range typically -2.0 to 2.0. + public var presencePenalty: Double? + + /// Frequency penalty. Range typically -2.0 to 2.0. + public var frequencyPenalty: Double? + + /// Whether the model may call multiple tools in parallel. + public var parallelToolCalls: Bool? + + /// Maximum number of tool calls in one response. + public var maxToolCalls: Int? + + /// Reasoning effort for reasoning-capable models. + public var reasoningEffort: ReasoningEffort? + + /// Reasoning configuration (effort and summary). + public var reasoning: ReasoningConfiguration? + + /// Response verbosity. + public var verbosity: Verbosity? + + /// Maximum output tokens. + public var maxOutputTokens: Int? + + /// Whether to store the response for later retrieval. + public var store: Bool? + + /// Key-value metadata attached to the request. + public var metadata: [String: String]? + + /// Safety / abuse detection identifier. + public var safetyIdentifier: String? + + /// Truncation behavior when input exceeds context window. + public var truncation: Truncation? + + /// Additional parameters merged into the request body (applied last). + public var extraBody: [String: JSONValue]? + + /// Tool choice: none, auto, required, a specific function, or allowed_tools with mode. + public enum ToolChoice: Hashable, Codable, Sendable { + case none + case auto + case required + case function(name: String) + case allowedTools(tools: [String], mode: AllowedToolsMode = .auto) + + public enum AllowedToolsMode: String, Hashable, Codable, Sendable { + case none + case auto + case required + } + } + + public enum ReasoningEffort: String, Hashable, Codable, Sendable { + case none + case low + case medium + case high + case xhigh + } + + public struct ReasoningConfiguration: Hashable, Codable, Sendable { + public var effort: ReasoningEffort? + public var summary: String? + + public init(effort: ReasoningEffort? = nil, summary: String? = nil) { + self.effort = effort + self.summary = summary + } + } + + public enum Verbosity: String, Hashable, Codable, Sendable { + case low + case medium + case high + } + + public enum Truncation: String, Hashable, Codable, Sendable { + case auto + case disabled + } + + enum CodingKeys: String, CodingKey { + case toolChoice = "tool_choice" + case allowedTools = "allowed_tools" + case topP = "top_p" + case presencePenalty = "presence_penalty" + case frequencyPenalty = "frequency_penalty" + case parallelToolCalls = "parallel_tool_calls" + case maxToolCalls = "max_tool_calls" + case reasoningEffort = "reasoning_effort" + case reasoning + case verbosity + case maxOutputTokens = "max_output_tokens" + case store + case metadata + case safetyIdentifier = "safety_identifier" + case truncation + case extraBody = "extra_body" + } + + public init( + toolChoice: ToolChoice? = nil, + allowedTools: [String]? = nil, + topP: Double? = nil, + presencePenalty: Double? = nil, + frequencyPenalty: Double? = nil, + parallelToolCalls: Bool? = nil, + maxToolCalls: Int? = nil, + reasoningEffort: ReasoningEffort? = nil, + reasoning: ReasoningConfiguration? = nil, + verbosity: Verbosity? = nil, + maxOutputTokens: Int? = nil, + store: Bool? = nil, + metadata: [String: String]? = nil, + safetyIdentifier: String? = nil, + truncation: Truncation? = nil, + extraBody: [String: JSONValue]? = nil + ) { + self.toolChoice = toolChoice + self.allowedTools = allowedTools + self.topP = topP + self.presencePenalty = presencePenalty + self.frequencyPenalty = frequencyPenalty + self.parallelToolCalls = parallelToolCalls + self.maxToolCalls = maxToolCalls + self.reasoningEffort = reasoningEffort + self.reasoning = reasoning + self.verbosity = verbosity + self.maxOutputTokens = maxOutputTokens + self.store = store + self.metadata = metadata + self.safetyIdentifier = safetyIdentifier + self.truncation = truncation + self.extraBody = extraBody + } + } + + /// Base URL for the API endpoint. + public let baseURL: URL + + /// Closure that provides the API key for authentication. + private let tokenProvider: @Sendable () -> String + + /// Model identifier to use for generation. + public let model: String + + private let urlSession: URLSession + + /// Creates an Open Responses language model. + /// + /// - Parameters: + /// - baseURL: Base URL for the API (e.g. `https://api.openai.com/v1/` or `https://openrouter.ai/api/v1/`). Must end with `/`. + /// - apiKey: API key or closure that returns it. + /// - model: Model identifier (e.g. `gpt-4o-mini` or provider-specific id). + /// - session: URL session for network requests. + public init( + baseURL: URL, + apiKey tokenProvider: @escaping @autoclosure @Sendable () -> String, + model: String, + session: URLSession = URLSession(configuration: .default) + ) { + var baseURL = baseURL + if !baseURL.path.hasSuffix("/") { + baseURL = baseURL.appendingPathComponent("") + } + self.baseURL = baseURL + self.tokenProvider = tokenProvider + self.model = model + self.urlSession = session + } + + public func respond( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) async throws -> LanguageModelSession.Response where Content: Generable { + let tools: [OpenResponsesTool]? = + session.tools.isEmpty ? nil : session.tools.map { convertToolToOpenResponsesFormat($0) } + return try await respondWithOpenResponses( + messages: session.transcript.toOpenResponsesMessages(), + tools: tools, + generating: type, + options: options, + session: session + ) + } + + public func streamResponse( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) -> sending LanguageModelSession.ResponseStream where Content: Generable { + let tools: [OpenResponsesTool]? = + session.tools.isEmpty ? nil : session.tools.map { convertToolToOpenResponsesFormat($0) } + let url = baseURL.appendingPathComponent("responses") + let stream: AsyncThrowingStream.Snapshot, any Error> = .init { + continuation in + do { + let params = try OpenResponsesAPI.createRequestBody( + model: model, + messages: session.transcript.toOpenResponsesMessages(), + tools: tools, + 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 + var raw: GeneratedContent + let content: Content.PartiallyGenerated? + if type == String.self { + raw = GeneratedContent(accumulatedText) + content = (accumulatedText as! Content).asPartiallyGenerated() + } else { + raw = + (try? GeneratedContent(json: accumulatedText)) + ?? GeneratedContent(accumulatedText) + content = (try? type.init(raw))?.asPartiallyGenerated() + } + if let content { + continuation.yield(.init(content: content, rawContent: raw)) + } + case .completed: + continuation.finish() + return + case .failed: + continuation.finish(throwing: OpenResponsesLanguageModelError.streamFailed) + return + case .ignored: + break + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } catch { + continuation.finish(throwing: error) + } + } + return LanguageModelSession.ResponseStream(stream: stream) + } + + private func respondWithOpenResponses( + messages: [OpenResponsesMessage], + tools: [OpenResponsesTool]?, + generating type: Content.Type, + options: GenerationOptions, + session: LanguageModelSession + ) async throws -> LanguageModelSession.Response where Content: Generable { + var entries: [Transcript.Entry] = [] + var text = "" + var lastOutput: [JSONValue]? + var messages = messages + let url = baseURL.appendingPathComponent("responses") + + while true { + let params = try OpenResponsesAPI.createRequestBody( + model: model, + messages: messages, + tools: tools, + generating: type, + options: options, + stream: false + ) + let body = try JSONEncoder().encode(params) + let resp: OpenResponsesAPI.Response = try await urlSession.fetch( + .post, + url: url, + headers: ["Authorization": "Bearer \(tokenProvider())"], + body: body + ) + + let toolCalls = extractToolCallsFromOutput(resp.output) + lastOutput = resp.output + if !toolCalls.isEmpty { + if let output = resp.output { + for item in output { + messages.append(OpenResponsesMessage(role: .raw(rawContent: item), content: .text(""))) + } + } + let resolution = try await resolveToolCalls(toolCalls, session: session) + switch resolution { + case .stop(let calls): + if !calls.isEmpty { + entries.append(.toolCalls(Transcript.ToolCalls(calls))) + } + let empty = try emptyResponseContent(for: type) + return LanguageModelSession.Response( + content: empty.content, + rawContent: empty.rawContent, + transcriptEntries: ArraySlice(entries) + ) + case .invocations(let invocations): + if !invocations.isEmpty { + entries.append(.toolCalls(Transcript.ToolCalls(invocations.map { $0.call }))) + for inv in invocations { + entries.append(.toolOutput(inv.output)) + messages.append( + OpenResponsesMessage( + role: .tool(id: inv.call.id), + content: .text(openResponsesConvertSegmentsToToolContentString(inv.output.segments)) + ) + ) + } + continue + } + } + } + + text = resp.outputText ?? extractTextFromOutput(resp.output) ?? "" + break + } + + if type == String.self { + return LanguageModelSession.Response( + content: text as! Content, + rawContent: GeneratedContent(text), + transcriptEntries: ArraySlice(entries) + ) + } + if let jsonString = extractJSONFromOutput(lastOutput) { + let generatedContent = try GeneratedContent(json: jsonString) + let content = try type.init(generatedContent) + return LanguageModelSession.Response( + content: content, + rawContent: generatedContent, + transcriptEntries: ArraySlice(entries) + ) + } + throw OpenResponsesLanguageModelError.noResponseGenerated + } + + private func emptyResponseContent(for type: Content.Type) throws -> ( + content: Content, rawContent: GeneratedContent + ) { + if type == String.self { + return ("" as! Content, GeneratedContent("")) + } + let raw = GeneratedContent(properties: [:]) + return (try type.init(raw), raw) + } +} + +// MARK: - API Request / Response + +private enum OpenResponsesAPI { + static func createRequestBody( + model: String, + messages: [OpenResponsesMessage], + tools: [OpenResponsesTool]?, + generating type: Content.Type, + options: GenerationOptions, + stream: Bool + ) throws -> JSONValue { + var body: [String: JSONValue] = [ + "model": .string(model), + "stream": .bool(stream), + ] + var input: [JSONValue] = [] + for msg in messages { + switch msg.role { + case .user: + let contentBlocks: [JSONValue] + switch msg.content { + case .text(let t): + contentBlocks = [.object(["type": .string("input_text"), "text": .string(t)])] + case .blocks(let blocks): + contentBlocks = blocks.map { b in + switch b { + case .text(let t): return .object(["type": .string("input_text"), "text": .string(t)]) + case .imageURL(let url): + return .object(["type": .string("input_image"), "image_url": .string(url)]) + } + } + } + input.append( + .object([ + "type": .string("message"), + "role": .string("user"), + "content": .array(contentBlocks), + ]) + ) + case .tool(let id): + var contentBlocks: [JSONValue] + switch msg.content { + case .text(let t): + contentBlocks = [.object(["type": .string("input_text"), "text": .string(t)])] + case .blocks(let blocks): + contentBlocks = blocks.map { b in + switch b { + case .text(let t): return .object(["type": .string("input_text"), "text": .string(t)]) + case .imageURL(let url): + return .object(["type": .string("input_image"), "image_url": .string(url)]) + } + } + } + let outputString: String + if contentBlocks.count > 1 { + let data = try JSONEncoder().encode(JSONValue.array(contentBlocks)) + outputString = String(data: data, encoding: .utf8) ?? "[]" + } else if let first = contentBlocks.first { + let data = try JSONEncoder().encode(first) + outputString = String(data: data, encoding: .utf8) ?? "{}" + } else { + outputString = "{}" + } + input.append( + .object([ + "type": .string("function_call_output"), + "call_id": .string(id), + "output": .string(outputString), + ]) + ) + case .raw(rawContent: let raw): + input.append(raw) + case .system: + switch msg.content { + case .text(let t): + body["instructions"] = .string(t) + case .blocks(let blocks): + let t = blocks.compactMap { if case .text(let s) = $0 { return s } else { return nil } }.joined( + separator: "\n" + ) + if !t.isEmpty { body["instructions"] = .string(t) } + } + case .assistant: + break + } + } + body["input"] = .array(input) + + if let tools { + body["tools"] = .array(tools.map { $0.jsonValue }) + } + + if type != String.self { + let schemaValue = try type.generationSchema.toJSONValueForOpenResponsesStrictMode() + body["text"] = .object([ + "format": .object([ + "type": .string("json_schema"), + "name": .string("response_schema"), + "strict": .bool(true), + "schema": schemaValue, + ]) + ]) + } + + if let temp = options.temperature { body["temperature"] = .double(temp) } + if let max = options.maximumResponseTokens { body["max_output_tokens"] = .int(max) } + + if let custom = options[custom: OpenResponsesLanguageModel.self] { + if let v = custom.toolChoice { + body["tool_choice"] = openResponsesToolChoiceJSON(v) + } + if let v = custom.allowedTools { body["allowed_tools"] = .array(v.map { .string($0) }) } + if let v = custom.topP { body["top_p"] = .double(v) } + if let v = custom.presencePenalty { body["presence_penalty"] = .double(v) } + if let v = custom.frequencyPenalty { body["frequency_penalty"] = .double(v) } + if let v = custom.parallelToolCalls { body["parallel_tool_calls"] = .bool(v) } + if let v = custom.maxToolCalls { body["max_tool_calls"] = .int(v) } + if let v = custom.reasoningEffort { body["reasoning"] = .object(["effort": .string(v.rawValue)]) } + if let r = custom.reasoning { + var obj: [String: JSONValue] = [:] + if let e = r.effort { obj["effort"] = .string(e.rawValue) } + if let s = r.summary { obj["summary"] = .string(s) } + if !obj.isEmpty { body["reasoning"] = .object(obj) } + } + if let v = custom.verbosity { body["verbosity"] = .string(v.rawValue) } + if let v = custom.maxOutputTokens { body["max_output_tokens"] = .int(v) } + if let v = custom.store { body["store"] = .bool(v) } + if let m = custom.metadata, !m.isEmpty { + body["metadata"] = .object( + Dictionary(uniqueKeysWithValues: m.map { ($0.key, JSONValue.string($0.value)) }) + ) + } + if let v = custom.safetyIdentifier { body["safety_identifier"] = .string(v) } + if let v = custom.truncation { body["truncation"] = .string(v.rawValue) } + if let extra = custom.extraBody { + for (k, v) in extra { body[k] = v } + } + } + return .object(body) + } + + struct Response: Decodable, Sendable { + let id: String + let output: [JSONValue]? + let outputText: String? + let error: OpenResponsesError? + + private enum CodingKeys: String, CodingKey { + case id + case output + case outputText = "output_text" + case error + } + } + + struct OpenResponsesError: Decodable, Sendable { + let message: String? + let type: String? + let code: String? + } +} + +private func openResponsesToolChoiceJSON(_ choice: OpenResponsesLanguageModel.CustomGenerationOptions.ToolChoice) + -> JSONValue +{ + switch choice { + case .none: return .string("none") + case .auto: return .string("auto") + case .required: return .string("required") + case .function(name: let name): + return .object(["type": .string("function"), "name": .string(name)]) + case .allowedTools(tools: let tools, mode: let mode): + return .object([ + "type": .string("allowed_tools"), + "tools": .array(tools.map { .object(["type": .string("function"), "name": .string($0)]) }), + "mode": .string(mode.rawValue), + ]) + } +} + +// MARK: - Transcript → Open Responses + +private struct OpenResponsesMessage: Sendable { + enum Role: Sendable { + case system + case user + case assistant + case tool(id: String) + case raw(rawContent: JSONValue) + } + enum Content: Sendable { + case text(String) + case blocks([OpenResponsesBlock]) + } + let role: Role + let content: Content +} + +private enum OpenResponsesBlock: Sendable { + case text(String) + case imageURL(String) +} + +extension Transcript { + fileprivate func toOpenResponsesMessages() -> [OpenResponsesMessage] { + var list: [OpenResponsesMessage] = [] + for item in self { + switch item { + case .instructions(let inst): + list.append( + OpenResponsesMessage( + role: .system, + content: .blocks(openResponsesConvertSegmentsToBlocks(inst.segments)) + ) + ) + case .prompt(let prompt): + list.append( + OpenResponsesMessage( + role: .user, + content: .blocks(openResponsesConvertSegmentsToBlocks(prompt.segments)) + ) + ) + case .response(let response): + list.append( + OpenResponsesMessage( + role: .assistant, + content: .blocks(openResponsesConvertSegmentsToBlocks(response.segments)) + ) + ) + case .toolCalls(let toolCalls): + let rawCalls: [JSONValue] = toolCalls.map { call in + let argsStr = + (try? JSONEncoder().encode(call.arguments)).flatMap { String(data: $0, encoding: .utf8) } + ?? "{}" + return .object([ + "id": .string(call.id), + "type": .string("function_call"), + "call_id": .string(call.id), + "name": .string(call.toolName), + "arguments": .string(argsStr), + ]) + } + list.append( + OpenResponsesMessage( + role: .raw( + rawContent: .object([ + "type": .string("message"), + "role": .string("assistant"), + "content": .array(rawCalls), + ]) + ), + content: .text("") + ) + ) + case .toolOutput(let out): + list.append( + OpenResponsesMessage( + role: .tool(id: out.id), + content: .text(openResponsesConvertSegmentsToToolContentString(out.segments)) + ) + ) + } + } + return list + } +} + +private func openResponsesConvertSegmentsToBlocks(_ segments: [Transcript.Segment]) -> [OpenResponsesBlock] { + segments.map { seg in + switch seg { + case .text(let t): return .text(t.content) + case .structure(let s): + switch s.content.kind { + case .string(let t): return .text(t) + default: return .text(s.content.jsonString) + } + case .image(let img): + switch img.source { + case .url(let u): return .imageURL(u.absoluteString) + case .data(let data, let mime): + return .imageURL("data:\(mime);base64,\(data.base64EncodedString())") + } + } + } +} + +private func openResponsesConvertSegmentsToToolContentString(_ segments: [Transcript.Segment]) -> String { + segments.compactMap { seg in + switch seg { + case .text(let t): return t.content + case .structure(let s): + switch s.content.kind { + case .string(let t): return t + default: return s.content.jsonString + } + case .image: return nil + } + }.joined(separator: "\n") +} + +// MARK: - Tools + +private struct OpenResponsesTool: Sendable { + let type: String = "function" + let name: String + let description: String + let parameters: JSONValue? + + var jsonValue: JSONValue { + var obj: [String: JSONValue] = [ + "type": .string(type), + "name": .string(name), + "description": .string(description), + ] + if let p = parameters { obj["parameters"] = p } + return .object(obj) + } +} + +private func convertToolToOpenResponsesFormat(_ tool: any Tool) -> OpenResponsesTool { + let parameters: JSONValue? + if let resolved = tool.parameters.withResolvedRoot() { + parameters = try? JSONValue(resolved) + } else { + parameters = try? JSONValue(tool.parameters) + } + return OpenResponsesTool( + name: tool.name, + description: tool.description, + parameters: parameters + ) +} + +// MARK: - Tool call extraction and resolution + +private struct OpenResponsesToolCall: Sendable { + let id: String + let name: String + let arguments: String? +} + +private func extractToolCallsFromOutput(_ output: [JSONValue]?) -> [OpenResponsesToolCall] { + guard let output else { return [] } + var result: [OpenResponsesToolCall] = [] + for item in output { + guard case .object(let obj) = item, + let typeStr = obj["type"].flatMap({ if case .string(let s) = $0 { return s } else { return nil } }) + else { continue } + if typeStr == "function_call" { + let id = + (obj["call_id"] ?? obj["id"]).flatMap { if case .string(let s) = $0 { return s } else { return nil } } + ?? UUID().uuidString + let name = obj["name"].flatMap { if case .string(let s) = $0 { return s } else { return nil } } ?? "" + let args: String? + if let a = obj["arguments"] { + if case .string(let s) = a { + args = s + } else if case .object(let o) = a { + args = (try? JSONEncoder().encode(JSONValue.object(o))).flatMap { + String(data: $0, encoding: .utf8) + } + } else { + args = nil + } + } else { + args = nil + } + result.append(OpenResponsesToolCall(id: id, name: name, arguments: args)) + } + } + return result +} + +private func extractTextFromOutput(_ output: [JSONValue]?) -> String? { + guard let output else { return nil } + var parts: [String] = [] + for item in output { + guard case .object(let obj) = item, + obj["type"].flatMap({ if case .string(let t) = $0 { return t } else { return nil } }) == "message", + case .array(let content)? = obj["content"] + else { continue } + for block in content { + guard case .object(let b) = block, + b["type"].flatMap({ if case .string(let t) = $0 { return t } else { return nil } }) == "output_text", + case .string(let text)? = b["text"] + else { continue } + parts.append(text) + } + } + return parts.isEmpty ? nil : parts.joined() +} + +private func extractJSONFromOutput(_ output: [JSONValue]?) -> String? { + guard let output else { return nil } + for item in output { + guard case .object(let obj) = item, + obj["type"].flatMap({ if case .string(let t) = $0 { return t } else { return nil } }) == "message", + case .array(let content)? = obj["content"] + else { continue } + for block in content { + guard case .object(let b) = block, + b["type"].flatMap({ if case .string(let t) = $0 { return t } else { return nil } }) == "output_text", + case .string(let s)? = b["text"] + else { continue } + return s + } + } + return nil +} + +private struct OpenResponsesToolInvocationResult: Sendable { + let call: Transcript.ToolCall + let output: Transcript.ToolOutput +} + +private enum OpenResponsesToolResolutionOutcome: Sendable { + case stop(calls: [Transcript.ToolCall]) + case invocations([OpenResponsesToolInvocationResult]) +} + +private func resolveToolCalls( + _ toolCalls: [OpenResponsesToolCall], + session: LanguageModelSession +) async throws -> OpenResponsesToolResolutionOutcome { + if toolCalls.isEmpty { return .invocations([]) } + var byName: [String: any Tool] = [:] + for t in session.tools { if byName[t.name] == nil { byName[t.name] = t } } + var transcriptCalls: [Transcript.ToolCall] = [] + for c in toolCalls { + let args = (c.arguments.flatMap { try? GeneratedContent(json: $0) } ?? GeneratedContent(properties: [:])) + transcriptCalls.append(Transcript.ToolCall(id: c.id, toolName: c.name, arguments: args)) + } + if let d = session.toolExecutionDelegate { + await d.didGenerateToolCalls(transcriptCalls, in: session) + } + guard !transcriptCalls.isEmpty else { return .invocations([]) } + var decisions: [ToolExecutionDecision] = [] + if let d = session.toolExecutionDelegate { + for call in transcriptCalls { + let dec = await d.toolCallDecision(for: call, in: session) + if case .stop = dec { return .stop(calls: transcriptCalls) } + decisions.append(dec) + } + } else { + decisions = Array(repeating: .execute, count: transcriptCalls.count) + } + var results: [OpenResponsesToolInvocationResult] = [] + for (i, call) in transcriptCalls.enumerated() { + switch decisions[i] { + case .stop: + return .stop(calls: transcriptCalls) + case .provideOutput(let segs): + let out = Transcript.ToolOutput(id: call.id, toolName: call.toolName, segments: segs) + if let d = session.toolExecutionDelegate { await d.didExecuteToolCall(call, output: out, in: session) } + results.append(OpenResponsesToolInvocationResult(call: call, output: out)) + case .execute: + guard let tool = byName[call.toolName] else { + let out = Transcript.ToolOutput( + id: call.id, + toolName: call.toolName, + segments: [.text(.init(content: "Tool not found: \(call.toolName)"))] + ) + if let d = session.toolExecutionDelegate { await d.didExecuteToolCall(call, output: out, in: session) } + results.append(OpenResponsesToolInvocationResult(call: call, output: out)) + continue + } + do { + let segs = try await tool.makeOutputSegments(from: call.arguments) + let out = Transcript.ToolOutput(id: call.id, toolName: tool.name, segments: segs) + if let d = session.toolExecutionDelegate { await d.didExecuteToolCall(call, output: out, in: session) } + results.append(OpenResponsesToolInvocationResult(call: call, output: out)) + } catch { + if let d = session.toolExecutionDelegate { await d.didFailToolCall(call, error: error, in: session) } + throw LanguageModelSession.ToolCallError(tool: tool, underlyingError: error) + } + } + } + return .invocations(results) +} + +// MARK: - Streaming events + +private enum OpenResponsesStreamEvent: Decodable, Sendable { + case outputTextDelta(String) + case completed + case failed + case ignored + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let type = try c.decodeIfPresent(String.self, forKey: .type) + switch type { + case "response.output_text.delta": + self = .outputTextDelta(try c.decode(String.self, forKey: .delta)) + case "response.completed": + self = .completed + case "response.failed": + self = .failed + default: + self = .ignored + } + } + private enum CodingKeys: String, CodingKey { case type, delta } +} + +// MARK: - Errors + +enum OpenResponsesLanguageModelError: LocalizedError, Sendable { + case noResponseGenerated + case streamFailed + + var errorDescription: String? { + switch self { + case .noResponseGenerated: return "No response was generated by the model" + case .streamFailed: return "The stream reported a failure event" + } + } +} + +// MARK: - Schema for structured output + +private extension GenerationSchema { + func toJSONValueForOpenResponsesStrictMode() throws -> JSONValue { + let resolved = withResolvedRoot() ?? self + let encoder = JSONEncoder() + encoder.userInfo[GenerationSchema.omitAdditionalPropertiesKey] = false + let data = try encoder.encode(resolved) + let jsonSchema = try JSONDecoder().decode(JSONSchema.self, from: data) + var value = try JSONValue(jsonSchema) + if case .object(var obj) = value { + obj["additionalProperties"] = .bool(false) + if case .object(let props)? = obj["properties"], !props.isEmpty { + obj["required"] = .array(Array(props.keys).sorted().map { .string($0) }) + } + value = .object(obj) + } + return value + } +} diff --git a/Tests/AnyLanguageModelTests/CustomGenerationOptionsTests.swift b/Tests/AnyLanguageModelTests/CustomGenerationOptionsTests.swift index 3486fe87..a6dd283f 100644 --- a/Tests/AnyLanguageModelTests/CustomGenerationOptionsTests.swift +++ b/Tests/AnyLanguageModelTests/CustomGenerationOptionsTests.swift @@ -533,6 +533,126 @@ struct OpenAICustomOptionsTests { } } +@Suite("OpenResponses CustomGenerationOptions") +struct OpenResponsesCustomOptionsTests { + @Test func initialization() { + let options = OpenResponsesLanguageModel.CustomGenerationOptions( + toolChoice: .function(name: "getWeather"), + allowedTools: ["getWeather", "sendEmail"], + topP: 0.9, + presencePenalty: 0.3, + frequencyPenalty: 0.5, + parallelToolCalls: false, + maxToolCalls: 10, + reasoningEffort: .high, + reasoning: .init(effort: .medium, summary: "concise"), + verbosity: .medium, + maxOutputTokens: 100, + store: true, + metadata: ["key": "value"], + safetyIdentifier: "user-123", + truncation: .auto, + extraBody: ["custom_param": .string("value")] + ) + #expect(options.toolChoice != nil) + if case .function(name: let n) = options.toolChoice! { + #expect(n == "getWeather") + } + #expect(options.allowedTools?.count == 2) + #expect(options.topP == 0.9) + #expect(options.presencePenalty == 0.3) + #expect(options.frequencyPenalty == 0.5) + #expect(options.parallelToolCalls == false) + #expect(options.maxToolCalls == 10) + #expect(options.reasoningEffort == .high) + #expect(options.reasoning?.effort == .medium) + #expect(options.reasoning?.summary == "concise") + #expect(options.verbosity == .medium) + #expect(options.maxOutputTokens == 100) + #expect(options.store == true) + #expect(options.metadata?["key"] == "value") + #expect(options.safetyIdentifier == "user-123") + #expect(options.truncation == .auto) + #expect(options.extraBody?["custom_param"] == .string("value")) + } + + @Test func equality() { + let options1 = OpenResponsesLanguageModel.CustomGenerationOptions( + toolChoice: .auto, + topP: 0.9 + ) + let options2 = OpenResponsesLanguageModel.CustomGenerationOptions( + toolChoice: .auto, + topP: 0.9 + ) + #expect(options1 == options2) + } + + @Test func codable() throws { + let options = OpenResponsesLanguageModel.CustomGenerationOptions( + toolChoice: .required, + topP: 0.9, + truncation: .auto, + extraBody: ["k": .string("v")] + ) + let data = try JSONEncoder().encode(options) + let decoded = try JSONDecoder().decode( + OpenResponsesLanguageModel.CustomGenerationOptions.self, + from: data + ) + #expect(decoded == options) + } + + @Test func codableUsesSnakeCase() throws { + typealias ToolChoice = OpenResponsesLanguageModel.CustomGenerationOptions.ToolChoice + let options = OpenResponsesLanguageModel.CustomGenerationOptions( + toolChoice: ToolChoice.none, + topP: 0.9, + parallelToolCalls: true + ) + let data = try JSONEncoder().encode(options) + let json = String(data: data, encoding: .utf8)! + #expect(json.contains("\"top_p\"")) + #expect(json.contains("\"parallel_tool_calls\"")) + #expect(json.contains("\"tool_choice\"")) + } + + @Test func toolChoiceVariants() { + typealias ToolChoice = OpenResponsesLanguageModel.CustomGenerationOptions.ToolChoice + let none = OpenResponsesLanguageModel.CustomGenerationOptions(toolChoice: ToolChoice.none) + let auto = OpenResponsesLanguageModel.CustomGenerationOptions(toolChoice: .auto) + let required = OpenResponsesLanguageModel.CustomGenerationOptions(toolChoice: .required) + let fn = OpenResponsesLanguageModel.CustomGenerationOptions(toolChoice: .function(name: "x")) + let allowed = OpenResponsesLanguageModel.CustomGenerationOptions( + toolChoice: .allowedTools(tools: ["a"], mode: .auto) + ) + #expect(none.toolChoice != auto.toolChoice) + #expect(auto.toolChoice != required.toolChoice) + #expect(required.toolChoice != fn.toolChoice) + #expect(fn.toolChoice != allowed.toolChoice) + } + + @Test func integrationWithGenerationOptions() { + var options = GenerationOptions(temperature: 0.8) + options[custom: OpenResponsesLanguageModel.self] = .init( + toolChoice: .auto, + allowedTools: ["getWeather"], + topP: 0.9 + ) + let retrieved = options[custom: OpenResponsesLanguageModel.self] + #expect(retrieved?.toolChoice == .auto) + #expect(retrieved?.topP == 0.9) + #expect(retrieved?.allowedTools?.count == 1) + } + + @Test func nilProperties() { + let options = OpenResponsesLanguageModel.CustomGenerationOptions() + #expect(options.toolChoice == nil) + #expect(options.allowedTools == nil) + #expect(options.extraBody == nil) + } +} + @Suite("Ollama CustomGenerationOptions") struct OllamaCustomOptionsTests { @Test func typealiasIsDictionary() { diff --git a/Tests/AnyLanguageModelTests/OpenResponsesLanguageModelTests.swift b/Tests/AnyLanguageModelTests/OpenResponsesLanguageModelTests.swift new file mode 100644 index 00000000..325178ab --- /dev/null +++ b/Tests/AnyLanguageModelTests/OpenResponsesLanguageModelTests.swift @@ -0,0 +1,229 @@ +import Foundation +import Testing + +@testable import AnyLanguageModel + +private let openResponsesAPIKey: String? = ProcessInfo.processInfo.environment["OPEN_RESPONSES_API_KEY"] +private let openResponsesBaseURL: URL? = ProcessInfo.processInfo.environment["OPEN_RESPONSES_BASE_URL"].flatMap { + URL(string: $0) +} + +@Suite("OpenResponsesLanguageModel") +struct OpenResponsesLanguageModelTests { + @Test func customHost() throws { + let customURL = URL(string: "https://example.com")! + let model = OpenResponsesLanguageModel(baseURL: customURL, apiKey: "test", model: "test-model") + #expect(model.baseURL.absoluteString.hasSuffix("/")) + } + + @Test func modelParameter() throws { + let baseURL = URL(string: "https://api.example.com/v1/")! + let model = OpenResponsesLanguageModel(baseURL: baseURL, apiKey: "test-key", model: "my-model") + #expect(model.model == "my-model") + } + + @Suite( + "OpenResponsesLanguageModel API", + .enabled(if: openResponsesAPIKey?.isEmpty == false && openResponsesBaseURL != nil) + ) + struct APITests { + private let apiKey = openResponsesAPIKey! + private var baseURL: URL { openResponsesBaseURL! } + + private var model: OpenResponsesLanguageModel { + OpenResponsesLanguageModel( + baseURL: baseURL, + apiKey: apiKey, + model: "gpt-4o-mini" + ) + } + + @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 withCustomGenerationOptions() async throws { + let session = LanguageModelSession(model: model) + var options = GenerationOptions( + temperature: 0.7, + maximumResponseTokens: 50 + ) + options[custom: OpenResponsesLanguageModel.self] = .init( + extraBody: ["user": .string("test-user-id")] + ) + let response = try await session.respond( + to: "Say hello", + options: options + ) + #expect(!response.content.isEmpty) + } + + @Test func multimodalWithImageURL() async throws { + let session = LanguageModelSession(model: model) + let response = try await session.respond( + to: "Describe this image", + image: .init(url: testImageURL) + ) + #expect(!response.content.isEmpty) + } + + @Test func multimodalWithImageData() async throws { + let session = LanguageModelSession(model: model) + let response = try await session.respond( + to: "Describe this image", + image: .init(data: testImageData, mimeType: "image/png") + ) + #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.contains("color")) + } + + @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.toolName == "getWeather") + foundToolOutput = true + } + #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: OpenResponsesLanguageModel { + OpenResponsesLanguageModel( + baseURL: openResponsesBaseURL!, + apiKey: openResponsesAPIKey!, + model: "gpt-4o-mini" + ) + } + + @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.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) + } + } + } +} From 8cf29aad30dedd1c7653da299c8ddf6f65de0337 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 10 Feb 2026 05:55:24 -0800 Subject: [PATCH 2/6] Expand documentation coverage --- .../Models/OpenResponsesLanguageModel.swift | 94 ++++++++++++++++--- 1 file changed, 80 insertions(+), 14 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift index de3ca6ff..e3a08fc9 100644 --- a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift @@ -31,96 +31,138 @@ public struct OpenResponsesLanguageModel: LanguageModel { /// ``ToolChoice/allowedTools(tools:mode:)``), ``allowedTools``, and /// reasoning/text options. Use ``extraBody`` for parameters not yet modeled. public struct CustomGenerationOptions: AnyLanguageModel.CustomGenerationOptions, Codable, Sendable { - /// Controls whether and how the model may invoke tools. + /// Controls which tool the model should use, if any. public var toolChoice: ToolChoice? - /// Limits which tools the model is permitted to invoke for this request. + /// The list of tools that are permitted for this request. /// When set, the model may only call tools in this list. public var allowedTools: [String]? - /// Top-p (nucleus) sampling. Range 0.0–1.0. + /// Nucleus sampling parameter, between 0 and 1. + /// The model considers only the tokens with the top cumulative probability. public var topP: Double? - /// Presence penalty. Range typically -2.0 to 2.0. + /// Penalizes new tokens based on whether they appear in the text so far. public var presencePenalty: Double? - /// Frequency penalty. Range typically -2.0 to 2.0. + /// Penalizes new tokens based on their frequency in the text so far. public var frequencyPenalty: Double? /// Whether the model may call multiple tools in parallel. public var parallelToolCalls: Bool? - /// Maximum number of tool calls in one response. + /// The maximum number of tool calls the model may make while generating the response. public var maxToolCalls: Int? /// Reasoning effort for reasoning-capable models. public var reasoningEffort: ReasoningEffort? - /// Reasoning configuration (effort and summary). + /// Configuration options for reasoning behavior. public var reasoning: ReasoningConfiguration? - /// Response verbosity. + /// Controls the level of detail in generated text output. public var verbosity: Verbosity? - /// Maximum output tokens. + /// The maximum number of tokens the model may generate for this response. public var maxOutputTokens: Int? - /// Whether to store the response for later retrieval. + /// Whether to store the response so it can be retrieved later. public var store: Bool? - /// Key-value metadata attached to the request. + /// Set of key-value pairs attached to the request. + /// Keys are strings with a maximum length of 64 characters; + /// values are strings with a maximum length of 512 characters. public var metadata: [String: String]? - /// Safety / abuse detection identifier. + /// A stable identifier used for safety monitoring and abuse detection. public var safetyIdentifier: String? - /// Truncation behavior when input exceeds context window. + /// Controls how the service truncates the input when it exceeds the model context window. public var truncation: Truncation? /// Additional parameters merged into the request body (applied last). public var extraBody: [String: JSONValue]? - /// Tool choice: none, auto, required, a specific function, or allowed_tools with mode. + /// Controls which tool the model should use, if any. + /// See [tool_choice](https://www.openresponses.org/reference#tool_choice) in the Open Responses reference. public enum ToolChoice: Hashable, Codable, Sendable { + /// Restrict the model from calling any tools. case none + /// Let the model choose the tools from among the provided set. case auto + /// Require the model to call a tool. case required + /// Require the model to call the named function. case function(name: String) + /// Restrict tool calls to the given tools with the specified mode. case allowedTools(tools: [String], mode: AllowedToolsMode = .auto) + /// How to select a tool from the allowed set. + /// See [AllowedToolChoice](https://www.openresponses.org/reference#allowedtoolchoice) in the Open Responses reference. public enum AllowedToolsMode: String, Hashable, Codable, Sendable { + /// Restrict the model from calling any tools. case none + /// Let the model choose the tools from among the provided set. case auto + /// Require the model to call a tool. case required } } + /// Reasoning effort level for models that support extended reasoning. + /// See [ReasoningEffortEnum](https://www.openresponses.org/reference#reasoningeffortenum) in the Open Responses reference. public enum ReasoningEffort: String, Hashable, Codable, Sendable { + /// Restrict the model from performing any reasoning before emitting a final answer. case none + /// Use a lower reasoning effort for faster responses. case low + /// Use a balanced reasoning effort. case medium + /// Use a higher reasoning effort to improve answer quality. case high + /// Use the maximum reasoning effort available. case xhigh } + /// Configuration options for reasoning behavior. + /// See [ReasoningParam](https://www.openresponses.org/reference#reasoningparam) in the Open Responses reference. public struct ReasoningConfiguration: Hashable, Codable, Sendable { + /// The level of reasoning effort the model should apply. + /// Higher effort may increase latency and cost. public var effort: ReasoningEffort? + /// Controls whether the response includes a reasoning summary + /// (e.g. `concise`, `detailed`, or `auto`). public var summary: String? + /// Creates a reasoning configuration. + /// + /// - Parameters: + /// - effort: The level of reasoning effort the model should apply. + /// - summary: Optional reasoning summary preference for the model. public init(effort: ReasoningEffort? = nil, summary: String? = nil) { self.effort = effort self.summary = summary } } + /// Controls the level of detail in generated text output. + /// See [VerbosityEnum](https://www.openresponses.org/reference#verbosityenum) in the Open Responses reference. public enum Verbosity: String, Hashable, Codable, Sendable { + /// Instruct the model to emit less verbose final responses. case low + /// Use the model's default verbosity setting. case medium + /// Instruct the model to emit more verbose final responses. case high } + /// Controls how the service truncates the input when it exceeds the model context window. + /// See [TruncationEnum](https://www.openresponses.org/reference#truncationenum) in the Open Responses reference. public enum Truncation: String, Hashable, Codable, Sendable { + /// Let the service decide how to truncate. case auto + /// Disable service truncation. + /// Context over the model's context limit will result in a 400 error. case disabled } @@ -143,6 +185,25 @@ public struct OpenResponsesLanguageModel: LanguageModel { case extraBody = "extra_body" } + /// Creates custom generation options with the given Open Responses–specific parameters. + /// + /// - Parameters: + /// - toolChoice: Controls which tool the model should use, if any. + /// - allowedTools: The list of tools that are permitted for this request. + /// - topP: Nucleus sampling parameter, between 0 and 1. + /// - presencePenalty: Penalizes new tokens based on whether they appear in the text so far. + /// - frequencyPenalty: Penalizes new tokens based on their frequency in the text so far. + /// - parallelToolCalls: Whether the model may call multiple tools in parallel. + /// - maxToolCalls: The maximum number of tool calls the model may make while generating the response. + /// - reasoningEffort: Reasoning effort for reasoning-capable models. + /// - reasoning: Configuration options for reasoning behavior. + /// - verbosity: Controls the level of detail in generated text output. + /// - maxOutputTokens: The maximum number of tokens the model may generate for this response. + /// - store: Whether to store the response so it can be retrieved later. + /// - metadata: Key-value pairs (keys max 64 chars, values max 512 chars). + /// - safetyIdentifier: A stable identifier used for safety monitoring and abuse detection. + /// - truncation: Controls how the service truncates input when it exceeds the context window. + /// - extraBody: Additional parameters merged into the request body. public init( toolChoice: ToolChoice? = nil, allowedTools: [String]? = nil, @@ -305,6 +366,7 @@ public struct OpenResponsesLanguageModel: LanguageModel { return LanguageModelSession.ResponseStream(stream: stream) } + /// Sends a non-streaming request to the Open Responses API and returns the parsed response. private func respondWithOpenResponses( messages: [OpenResponsesMessage], tools: [OpenResponsesTool]?, @@ -395,6 +457,7 @@ public struct OpenResponsesLanguageModel: LanguageModel { throw OpenResponsesLanguageModelError.noResponseGenerated } + /// Produces empty content and raw content for the given type (used when tool execution stops the response). private func emptyResponseContent(for type: Content.Type) throws -> ( content: Content, rawContent: GeneratedContent ) { @@ -913,8 +976,11 @@ private enum OpenResponsesStreamEvent: Decodable, Sendable { // MARK: - Errors +/// Errors produced by ``OpenResponsesLanguageModel``. enum OpenResponsesLanguageModelError: LocalizedError, Sendable { + /// The API returned no parseable text or structured output. case noResponseGenerated + /// The stream reported a failure event. case streamFailed var errorDescription: String? { From 239eb1048d04b3215c98e215e17cbf8ae738ec0a Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 10 Feb 2026 05:56:42 -0800 Subject: [PATCH 3/6] Incorporate feedback from review --- .../Models/OpenResponsesLanguageModel.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift index e3a08fc9..c944f2bd 100644 --- a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift @@ -586,11 +586,12 @@ private enum OpenResponsesAPI { if let v = custom.frequencyPenalty { body["frequency_penalty"] = .double(v) } if let v = custom.parallelToolCalls { body["parallel_tool_calls"] = .bool(v) } if let v = custom.maxToolCalls { body["max_tool_calls"] = .int(v) } - if let v = custom.reasoningEffort { body["reasoning"] = .object(["effort": .string(v.rawValue)]) } - if let r = custom.reasoning { + do { + let effort = custom.reasoning?.effort ?? custom.reasoningEffort + let summary = custom.reasoning?.summary var obj: [String: JSONValue] = [:] - if let e = r.effort { obj["effort"] = .string(e.rawValue) } - if let s = r.summary { obj["summary"] = .string(s) } + if let e = effort { obj["effort"] = .string(e.rawValue) } + if let s = summary { obj["summary"] = .string(s) } if !obj.isEmpty { body["reasoning"] = .object(obj) } } if let v = custom.verbosity { body["verbosity"] = .string(v.rawValue) } From dedeb6041caca39c31dd449676800993152b3d82 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 10 Feb 2026 06:27:46 -0800 Subject: [PATCH 4/6] Incorporate feedback from review --- .../Models/OpenResponsesLanguageModel.swift | 156 +++++++++++++++--- 1 file changed, 132 insertions(+), 24 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift index c944f2bd..60824f28 100644 --- a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift @@ -97,6 +97,78 @@ public struct OpenResponsesLanguageModel: LanguageModel { /// Restrict tool calls to the given tools with the specified mode. case allowedTools(tools: [String], mode: AllowedToolsMode = .auto) + private enum CodingKeys: String, CodingKey { + case type + case name + case tools + case mode + } + + private enum ToolType: String { + case function + case allowedTools = "allowed_tools" + } + + public init(from decoder: Decoder) throws { + if let singleValueContainer = try? decoder.singleValueContainer(), + let stringValue = try? singleValueContainer.decode(String.self) + { + switch stringValue { + case "none": self = .none + case "auto": self = .auto + case "required": self = .required + default: + throw DecodingError.dataCorruptedError( + in: singleValueContainer, + debugDescription: "Invalid tool_choice string value: \(stringValue)" + ) + } + return + } + let container = try decoder.container(keyedBy: CodingKeys.self) + let typeString = try container.decode(String.self, forKey: .type) + switch ToolType(rawValue: typeString) { + case .function?: + let name = try container.decode(String.self, forKey: .name) + self = .function(name: name) + case .allowedTools?: + let tools = try container.decode([String].self, forKey: .tools) + let mode = try container.decodeIfPresent(AllowedToolsMode.self, forKey: .mode) ?? .auto + self = .allowedTools(tools: tools, mode: mode) + case nil: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unsupported tool_choice type: \(typeString)" + ) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .none: + var container = encoder.singleValueContainer() + try container.encode("none") + case .auto: + var container = encoder.singleValueContainer() + try container.encode("auto") + case .required: + var container = encoder.singleValueContainer() + try container.encode("required") + case .function(let name): + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(ToolType.function.rawValue, forKey: .type) + try container.encode(name, forKey: .name) + case .allowedTools(let tools, let mode): + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(ToolType.allowedTools.rawValue, forKey: .type) + try container.encode(tools, forKey: .tools) + if mode != .auto { + try container.encode(mode, forKey: .mode) + } + } + } + /// How to select a tool from the allowed set. /// See [AllowedToolChoice](https://www.openresponses.org/reference#allowedtoolchoice) in the Open Responses reference. public enum AllowedToolsMode: String, Hashable, Codable, Sendable { @@ -811,34 +883,70 @@ private struct OpenResponsesToolCall: Sendable { let arguments: String? } +private func parseOpenResponsesToolCall(from obj: [String: JSONValue]) -> OpenResponsesToolCall? { + let idOpt = (obj["call_id"] ?? obj["id"]).flatMap { + if case .string(let s) = $0 { return s } else { return nil } + } + let nameOpt = obj["name"].flatMap { + if case .string(let s) = $0 { return s } else { return nil } + } + guard let id = idOpt, !id.isEmpty, + let name = nameOpt, !name.isEmpty + else { return nil } + let args: String? + if let a = obj["arguments"] { + switch a { + case .string(let s): args = s + case .object(let o): + args = (try? JSONEncoder().encode(JSONValue.object(o))).flatMap { + String(data: $0, encoding: .utf8) + } + default: args = nil + } + } else { + args = nil + } + return OpenResponsesToolCall(id: id, name: name, arguments: args) +} + +private func collectOpenResponsesToolCalls(from value: JSONValue, into result: inout [OpenResponsesToolCall]) { + switch value { + case .object(let obj): + let typeStr: String? = obj["type"].flatMap { + if case .string(let s) = $0 { return s } else { return nil } + } + if let typeStr { + if typeStr == "function_call" || typeStr == "tool_call" || typeStr == "tool_use" { + if let call = parseOpenResponsesToolCall(from: obj) { + result.append(call) + } + } + if typeStr == "message", + let content = obj["content"] + { + switch content { + case .array(let arr): + for item in arr { collectOpenResponsesToolCalls(from: item, into: &result) } + default: + collectOpenResponsesToolCalls(from: content, into: &result) + } + } + } + for (_, v) in obj { + collectOpenResponsesToolCalls(from: v, into: &result) + } + case .array(let arr): + for item in arr { collectOpenResponsesToolCalls(from: item, into: &result) } + default: + break + } +} + private func extractToolCallsFromOutput(_ output: [JSONValue]?) -> [OpenResponsesToolCall] { guard let output else { return [] } var result: [OpenResponsesToolCall] = [] for item in output { - guard case .object(let obj) = item, - let typeStr = obj["type"].flatMap({ if case .string(let s) = $0 { return s } else { return nil } }) - else { continue } - if typeStr == "function_call" { - let id = - (obj["call_id"] ?? obj["id"]).flatMap { if case .string(let s) = $0 { return s } else { return nil } } - ?? UUID().uuidString - let name = obj["name"].flatMap { if case .string(let s) = $0 { return s } else { return nil } } ?? "" - let args: String? - if let a = obj["arguments"] { - if case .string(let s) = a { - args = s - } else if case .object(let o) = a { - args = (try? JSONEncoder().encode(JSONValue.object(o))).flatMap { - String(data: $0, encoding: .utf8) - } - } else { - args = nil - } - } else { - args = nil - } - result.append(OpenResponsesToolCall(id: id, name: name, arguments: args)) - } + collectOpenResponsesToolCalls(from: item, into: &result) } return result } From 5784f011a5508e7cf46bbb6b53882628cd9fbc08 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 10 Feb 2026 09:12:19 -0800 Subject: [PATCH 5/6] Incorporate feedback from review --- .../Models/OpenResponsesLanguageModel.swift | 52 +++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift index 60824f28..c4ba51ef 100644 --- a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift @@ -109,6 +109,46 @@ public struct OpenResponsesLanguageModel: LanguageModel { case allowedTools = "allowed_tools" } + private static func decodeToolDescriptorArray( + container: KeyedDecodingContainer, + key: CodingKeys + ) throws -> [String] { + var arr = try container.nestedUnkeyedContainer(forKey: key) + var names: [String] = [] + while !arr.isAtEnd { + do { + let nested = try arr.nestedContainer(keyedBy: ToolDescriptorCodingKeys.self) + let typeStr = try nested.decode(String.self, forKey: .type) + guard typeStr == "function" else { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "Unsupported tool descriptor type: \(typeStr)" + ) + } + names.append(try nested.decode(String.self, forKey: .name)) + } catch { + let name = try arr.decode(String.self) + names.append(name) + } + } + return names + } + + private enum ToolDescriptorCodingKeys: String, CodingKey { + case type + case name + } + + private struct ToolDescriptorEncodable: Encodable { + let name: String + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: ToolDescriptorCodingKeys.self) + try c.encode(ToolType.function.rawValue, forKey: .type) + try c.encode(name, forKey: .name) + } + } + public init(from decoder: Decoder) throws { if let singleValueContainer = try? decoder.singleValueContainer(), let stringValue = try? singleValueContainer.decode(String.self) @@ -132,7 +172,7 @@ public struct OpenResponsesLanguageModel: LanguageModel { let name = try container.decode(String.self, forKey: .name) self = .function(name: name) case .allowedTools?: - let tools = try container.decode([String].self, forKey: .tools) + let tools = try Self.decodeToolDescriptorArray(container: container, key: .tools) let mode = try container.decodeIfPresent(AllowedToolsMode.self, forKey: .mode) ?? .auto self = .allowedTools(tools: tools, mode: mode) case nil: @@ -162,7 +202,10 @@ public struct OpenResponsesLanguageModel: LanguageModel { case .allowedTools(let tools, let mode): var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(ToolType.allowedTools.rawValue, forKey: .type) - try container.encode(tools, forKey: .tools) + try container.encode( + tools.map { ToolDescriptorEncodable(name: $0) }, + forKey: .tools + ) if mode != .auto { try container.encode(mode, forKey: .mode) } @@ -932,7 +975,10 @@ private func collectOpenResponsesToolCalls(from value: JSONValue, into result: i } } } - for (_, v) in obj { + for (key, v) in obj { + if key == "content", let typeStr, typeStr == "message" { + continue + } collectOpenResponsesToolCalls(from: v, into: &result) } case .array(let arr): From be7da34de7b6dc193f106e9b676a0164e346cb46 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 10 Feb 2026 09:45:42 -0800 Subject: [PATCH 6/6] Add missing round-trip test coverage --- .../CustomGenerationOptionsTests.swift | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/Tests/AnyLanguageModelTests/CustomGenerationOptionsTests.swift b/Tests/AnyLanguageModelTests/CustomGenerationOptionsTests.swift index a6dd283f..fa810499 100644 --- a/Tests/AnyLanguageModelTests/CustomGenerationOptionsTests.swift +++ b/Tests/AnyLanguageModelTests/CustomGenerationOptionsTests.swift @@ -632,6 +632,40 @@ struct OpenResponsesCustomOptionsTests { #expect(fn.toolChoice != allowed.toolChoice) } + @Test func toolChoiceAllowedToolsCodable() throws { + typealias ToolChoice = OpenResponsesLanguageModel.CustomGenerationOptions.ToolChoice + + // Round-trip: spec format (tools as {type,name} objects) with explicit mode + let withMode: ToolChoice = .allowedTools(tools: ["get_weather", "send_email"], mode: .required) + let encodedWithMode = try JSONEncoder().encode(withMode) + let decodedWithMode = try JSONDecoder().decode(ToolChoice.self, from: encodedWithMode) + #expect(decodedWithMode == withMode) + + // Decode from spec-compliant JSON (tools array of objects) + let specJSON = """ + {"type":"allowed_tools","tools":[{"type":"function","name":"a"},{"type":"function","name":"b"}],"mode":"none"} + """ + let fromSpec = try JSONDecoder().decode(ToolChoice.self, from: Data(specJSON.utf8)) + #expect(fromSpec == .allowedTools(tools: ["a", "b"], mode: .none)) + + // Decode from string array (backwards compatibility) + let stringsJSON = """ + {"type":"allowed_tools","tools":["x","y"]} + """ + let fromStrings = try JSONDecoder().decode(ToolChoice.self, from: Data(stringsJSON.utf8)) + #expect(fromStrings == .allowedTools(tools: ["x", "y"], mode: .auto)) + + // Encode with mode .auto: "mode" must be omitted + let autoMode: ToolChoice = .allowedTools(tools: ["only"], mode: .auto) + let encodedAuto = try JSONEncoder().encode(autoMode) + let jsonAuto = String(data: encodedAuto, encoding: .utf8)! + #expect(!jsonAuto.contains("\"mode\"")) + + // Round-trip allowedTools with mode .auto + let decodedAuto = try JSONDecoder().decode(ToolChoice.self, from: encodedAuto) + #expect(decodedAuto == autoMode) + } + @Test func integrationWithGenerationOptions() { var options = GenerationOptions(temperature: 0.8) options[custom: OpenResponsesLanguageModel.self] = .init(