diff --git a/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift b/Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift index a0556fd5..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 } @@ -1994,13 +2002,28 @@ 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? { + /// 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 ad7cdacb..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 } @@ -1236,17 +1239,24 @@ 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. - case streamFailed - var errorDescription: String? { + /// The server sent a `response.failed` event while streaming. + /// + /// - 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 new file mode 100644 index 00000000..6b023015 --- /dev/null +++ b/Tests/AnyLanguageModelTests/ProviderErrorTests.swift @@ -0,0 +1,175 @@ +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) + } + } + + /// 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(let code, let message) { + #expect(code == nil) + #expect(message == nil) + } + } + + 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