Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions Sources/AnyLanguageModel/Models/OpenAILanguageModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
32 changes: 21 additions & 11 deletions Sources/AnyLanguageModel/Models/OpenResponsesLanguageModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand All @@ -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)
}
}
}
Expand Down
38 changes: 38 additions & 0 deletions Sources/AnyLanguageModel/Shared/ResponseStreamFailure.swift
Original file line number Diff line number Diff line change
@@ -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?<Key: CodingKey>(from container: KeyedDecodingContainer<Key>, 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"
}
}
175 changes: 175 additions & 0 deletions Tests/AnyLanguageModelTests/ProviderErrorTests.swift
Original file line number Diff line number Diff line change
@@ -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
Loading