From 1cb716a8c468c3bb08f29c30b0a6d1f4c42cd492 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Fri, 25 Sep 2026 06:12:49 -0700 Subject: [PATCH 1/2] Make OpenAI and Open Responses error enums public OpenAILanguageModelError and OpenResponsesLanguageModelError were internal, so callers could see these errors only as any Error and couldn't match their cases. The Core ML, llama.cpp, and MLX error enums are already public. Make both enums and their error descriptions public, and document each case with the conditions that throw it. --- .../Models/OpenAILanguageModel.swift | 9 +- .../Models/OpenResponsesLanguageModel.swift | 13 +- .../ProviderErrorTests.swift | 127 ++++++++++++++++++ 3 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 Tests/AnyLanguageModelTests/ProviderErrorTests.swift diff --git a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift index a0556fd5..c9b44fbb 100644 --- a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift @@ -1994,10 +1994,15 @@ private func extractToolCallsFromOutput(_ output: [JSONValue]?) -> [OpenAIToolCa // MARK: - Errors -enum OpenAILanguageModelError: LocalizedError { +/// Errors that can occur when using ``OpenAILanguageModel``. +public enum OpenAILanguageModelError: LocalizedError { + /// The response contained no output to use. + /// + /// The Chat Completions API returned no choices, + /// or the Responses API returned no JSON for structured output. case noResponseGenerated - var errorDescription: String? { + public var errorDescription: String? { switch self { case .noResponseGenerated: return "No response was generated by the model" diff --git a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift index ad7cdacb..20caa438 100644 --- a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift @@ -1236,14 +1236,17 @@ private enum OpenResponsesStreamEvent: Decodable, Sendable { // MARK: - Errors -/// Errors produced by ``OpenResponsesLanguageModel``. -enum OpenResponsesLanguageModelError: LocalizedError, Sendable { - /// The API returned no parseable text or structured output. +/// Errors that can occur when using ``OpenResponsesLanguageModel``. +public enum OpenResponsesLanguageModelError: LocalizedError, Sendable { + /// The response contained no output to use. + /// + /// The API returned no JSON for structured output. case noResponseGenerated - /// The stream reported a failure event. + + /// The server sent a `response.failed` event while streaming. case streamFailed - var errorDescription: String? { + public var errorDescription: String? { switch self { case .noResponseGenerated: return "No response was generated by the model" case .streamFailed: return "The stream reported a failure event" diff --git a/Tests/AnyLanguageModelTests/ProviderErrorTests.swift b/Tests/AnyLanguageModelTests/ProviderErrorTests.swift new file mode 100644 index 00000000..cc17cc16 --- /dev/null +++ b/Tests/AnyLanguageModelTests/ProviderErrorTests.swift @@ -0,0 +1,127 @@ +import Foundation +import Testing + +// This file imports AnyLanguageModel without `@testable`, +// so it checks that callers outside the module can match these errors. +import AnyLanguageModel + +#if canImport(Darwin) && !canImport(AsyncHTTPClient) + @Suite("Provider errors", .serialized) + struct ProviderErrorTests { + @Generable + struct Answer { + let value: String + } + + @Test func openAIChatCompletionsWithoutChoices() async throws { + CannedURLProtocol.respond(with: #"{"id": "test", "choices": []}"#) + let model = OpenAILanguageModel( + apiKey: "test", + model: "test", + apiVariant: .chatCompletions, + session: CannedURLProtocol.makeSession() + ) + let session = LanguageModelSession(model: model) + + do { + _ = try await session.respond(to: "Hello") + Issue.record("Expected OpenAILanguageModelError.noResponseGenerated") + } catch OpenAILanguageModelError.noResponseGenerated { + // Expected. + } + } + + @Test func openAIResponsesWithoutStructuredOutput() async throws { + CannedURLProtocol.respond(with: #"{"id": "test", "output": []}"#) + let model = OpenAILanguageModel( + apiKey: "test", + model: "test", + apiVariant: .responses, + session: CannedURLProtocol.makeSession() + ) + let session = LanguageModelSession(model: model) + + await #expect(throws: OpenAILanguageModelError.self) { + _ = try await session.respond(to: "Hello", generating: Answer.self) + } + } + + @Test func openResponsesWithoutStructuredOutput() async throws { + CannedURLProtocol.respond(with: #"{"id": "test", "output": []}"#) + let session = LanguageModelSession(model: Self.openResponsesModel()) + + do { + _ = try await session.respond(to: "Hello", generating: Answer.self) + Issue.record("Expected OpenResponsesLanguageModelError.noResponseGenerated") + } catch let error as OpenResponsesLanguageModelError { + guard case .noResponseGenerated = error else { + Issue.record("Unexpected error: \(error)") + return + } + #expect(error.errorDescription != nil) + } + } + + @Test func openResponsesStreamFailure() async throws { + CannedURLProtocol.respond(with: "data: {\"type\": \"response.failed\"}\n\n") + let session = LanguageModelSession(model: Self.openResponsesModel()) + + do { + for try await _ in session.streamResponse(to: "Hello") {} + Issue.record("Expected OpenResponsesLanguageModelError.streamFailed") + } catch OpenResponsesLanguageModelError.streamFailed { + // Expected. + } + } + + private static func openResponsesModel() -> OpenResponsesLanguageModel { + OpenResponsesLanguageModel( + baseURL: URL(string: "https://example.com/v1")!, + apiKey: "test", + model: "test", + session: CannedURLProtocol.makeSession() + ) + } + } + + /// A `URLProtocol` that answers every request with one canned body. + private final class CannedURLProtocol: URLProtocol { + private static let lock = NSLock() + nonisolated(unsafe) private static var body = Data() + + /// Sets the body returned to every request. + static func respond(with body: String) { + lock.withLock { self.body = Data(body.utf8) } + } + + /// A session that routes every request to this protocol. + static func makeSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [CannedURLProtocol.self] + return URLSession(configuration: configuration) + } + + override class func canInit(with request: URLRequest) -> Bool { true } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let url = request.url else { + client?.urlProtocol(self, didFailWithError: URLError(.badURL)) + return + } + let isStream = request.value(forHTTPHeaderField: "Accept") == "text/event-stream" + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": isStream ? "text/event-stream" : "application/json"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Self.lock.withLock { Self.body }) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} + } +#endif From c96e43c240ff8872261974554697d8bb890d2635 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Fri, 25 Sep 2026 06:35:03 -0700 Subject: [PATCH 2/2] Report response.failed details from Responses streams A response.failed event carries the failed response with an error code and message, but OpenResponsesLanguageModelError.streamFailed dropped them. OpenAILanguageModel in the Responses variant ignored the event entirely, so a failed stream ended without saying why. Add code and message to streamFailed, include them in its error description, and add the same case to OpenAILanguageModelError, thrown when the Responses variant receives response.failed. --- .../Models/OpenAILanguageModel.swift | 18 +++++++ .../Models/OpenResponsesLanguageModel.swift | 19 ++++--- .../Shared/ResponseStreamFailure.swift | 38 ++++++++++++++ .../ProviderErrorTests.swift | 52 ++++++++++++++++++- 4 files changed, 119 insertions(+), 8 deletions(-) create mode 100644 Sources/AnyLanguageModel/Shared/ResponseStreamFailure.swift diff --git a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift index c9b44fbb..24a3bcc5 100644 --- a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift @@ -837,6 +837,11 @@ public struct OpenAILanguageModel: LanguageModel { } if let snapshot = state.snapshot() { continuation.yield(snapshot) } break responseEvents + case .failed(let failure): + throw OpenAILanguageModelError.streamFailed( + code: failure?.code, + message: failure?.message + ) case .ignored: break } @@ -1623,6 +1628,7 @@ private struct OpenAIToolFunction: Codable, Sendable { private enum OpenAIResponsesServerEvent: Decodable, Sendable { case outputTextDelta(String) case completed(Responses.Response?) + case failed(ResponseStreamFailure?) case ignored init(from decoder: any Decoder) throws { @@ -1633,6 +1639,8 @@ private enum OpenAIResponsesServerEvent: Decodable, Sendable { self = .outputTextDelta(try container.decode(String.self, forKey: .delta)) case "response.completed": self = .completed(try container.decodeIfPresent(Responses.Response.self, forKey: .response)) + case "response.failed": + self = .failed(ResponseStreamFailure(from: container, forKey: .response)) default: self = .ignored } @@ -2002,10 +2010,20 @@ public enum OpenAILanguageModelError: LocalizedError { /// or the Responses API returned no JSON for structured output. case noResponseGenerated + /// The server sent a `response.failed` event + /// while streaming from the Responses API. + /// + /// - Parameters: + /// - code: The error code from the failed response, if the server sent one. + /// - message: The error message from the failed response, if the server sent one. + case streamFailed(code: String?, message: String?) + public var errorDescription: String? { switch self { case .noResponseGenerated: return "No response was generated by the model" + case .streamFailed(let code, let message): + return streamFailureDescription(code: code, message: message) } } } diff --git a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift index 20caa438..82dfb46a 100644 --- a/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift @@ -533,8 +533,11 @@ public struct OpenResponsesLanguageModel: LanguageModel { } if let snapshot = state.snapshot() { continuation.yield(snapshot) } break responseEvents - case .failed: - throw OpenResponsesLanguageModelError.streamFailed + case .failed(let failure): + throw OpenResponsesLanguageModelError.streamFailed( + code: failure?.code, + message: failure?.message + ) case .ignored: break } @@ -1214,7 +1217,7 @@ private func resolveToolCalls( private enum OpenResponsesStreamEvent: Decodable, Sendable { case outputTextDelta(String) case completed(OpenResponsesAPI.Response?) - case failed + case failed(ResponseStreamFailure?) case ignored init(from decoder: Decoder) throws { @@ -1226,7 +1229,7 @@ private enum OpenResponsesStreamEvent: Decodable, Sendable { case "response.completed": self = .completed(try c.decodeIfPresent(OpenResponsesAPI.Response.self, forKey: .response)) case "response.failed": - self = .failed + self = .failed(ResponseStreamFailure(from: c, forKey: .response)) default: self = .ignored } @@ -1244,12 +1247,16 @@ public enum OpenResponsesLanguageModelError: LocalizedError, Sendable { case noResponseGenerated /// The server sent a `response.failed` event while streaming. - case streamFailed + /// + /// - Parameters: + /// - code: The error code from the failed response, if the server sent one. + /// - message: The error message from the failed response, if the server sent one. + case streamFailed(code: String?, message: String?) public var errorDescription: String? { switch self { case .noResponseGenerated: return "No response was generated by the model" - case .streamFailed: return "The stream reported a failure event" + case .streamFailed(let code, let message): return streamFailureDescription(code: code, message: message) } } } diff --git a/Sources/AnyLanguageModel/Shared/ResponseStreamFailure.swift b/Sources/AnyLanguageModel/Shared/ResponseStreamFailure.swift new file mode 100644 index 00000000..3da90146 --- /dev/null +++ b/Sources/AnyLanguageModel/Shared/ResponseStreamFailure.swift @@ -0,0 +1,38 @@ +import Foundation + +/// The error details of a `response.failed` streaming event. +/// +/// The OpenAI Responses API and Open Responses send the failed response +/// with an `error` object that has a `code` and a `message`. +struct ResponseStreamFailure: Sendable, Equatable { + var code: String? + var message: String? + + private enum ResponseKeys: String, CodingKey { case error } + private enum ErrorKeys: String, CodingKey { case code, message } + + /// Reads the error details from the response under `key`. + /// + /// Returns `nil` when the event has no response or the response has no error object. + /// A missing or malformed `code` or `message` becomes `nil` + /// so the failure is still reported. + init?(from container: KeyedDecodingContainer, forKey key: Key) { + guard let response = try? container.nestedContainer(keyedBy: ResponseKeys.self, forKey: key), + let error = try? response.nestedContainer(keyedBy: ErrorKeys.self, forKey: .error) + else { + return nil + } + self.code = try? error.decodeIfPresent(String.self, forKey: .code) + self.message = try? error.decodeIfPresent(String.self, forKey: .message) + } +} + +/// Describes a `response.failed` event for an error description. +func streamFailureDescription(code: String?, message: String?) -> String { + switch (code, message) { + case let (code?, message?): return "The response failed while streaming (\(code)): \(message)" + case let (code?, nil): return "The response failed while streaming (\(code))" + case let (nil, message?): return "The response failed while streaming: \(message)" + case (nil, nil): return "The response failed while streaming" + } +} diff --git a/Tests/AnyLanguageModelTests/ProviderErrorTests.swift b/Tests/AnyLanguageModelTests/ProviderErrorTests.swift index cc17cc16..6b023015 100644 --- a/Tests/AnyLanguageModelTests/ProviderErrorTests.swift +++ b/Tests/AnyLanguageModelTests/ProviderErrorTests.swift @@ -62,15 +62,63 @@ import AnyLanguageModel } } + /// A `response.failed` event as the OpenAI Responses API and Open Responses send it. + private static let failedEvent = """ + data: {"type": "response.failed", "sequence_number": 1, "response": {"id": "test", \ + "status": "failed", "error": {"code": "server_error", "message": "The model failed."}}} + + + """ + + @Test func openAIResponsesStreamFailure() async throws { + CannedURLProtocol.respond(with: Self.failedEvent) + let model = OpenAILanguageModel( + apiKey: "test", + model: "test", + apiVariant: .responses, + session: CannedURLProtocol.makeSession() + ) + let session = LanguageModelSession(model: model) + + do { + for try await _ in session.streamResponse(to: "Hello") {} + Issue.record("Expected OpenAILanguageModelError.streamFailed") + } catch OpenAILanguageModelError.streamFailed(let code, let message) { + #expect(code == "server_error") + #expect(message == "The model failed.") + } + } + @Test func openResponsesStreamFailure() async throws { + CannedURLProtocol.respond(with: Self.failedEvent) + let session = LanguageModelSession(model: Self.openResponsesModel()) + + do { + for try await _ in session.streamResponse(to: "Hello") {} + Issue.record("Expected OpenResponsesLanguageModelError.streamFailed") + } catch let error as OpenResponsesLanguageModelError { + guard case .streamFailed(let code, let message) = error else { + Issue.record("Unexpected error: \(error)") + return + } + #expect(code == "server_error") + #expect(message == "The model failed.") + #expect( + error.errorDescription == "The response failed while streaming (server_error): The model failed." + ) + } + } + + @Test func openResponsesStreamFailureWithoutDetails() async throws { CannedURLProtocol.respond(with: "data: {\"type\": \"response.failed\"}\n\n") let session = LanguageModelSession(model: Self.openResponsesModel()) do { for try await _ in session.streamResponse(to: "Hello") {} Issue.record("Expected OpenResponsesLanguageModelError.streamFailed") - } catch OpenResponsesLanguageModelError.streamFailed { - // Expected. + } catch OpenResponsesLanguageModelError.streamFailed(let code, let message) { + #expect(code == nil) + #expect(message == nil) } }