diff --git a/README.md b/README.md index 712f689..1559803 100644 --- a/README.md +++ b/README.md @@ -370,6 +370,8 @@ provider: ProviderPreferences(only: ["azure"], allowFallbacks: false) Advanced `sort` with `partition: .none` sorts endpoints globally across model fallbacks (see [OpenRouter provider routing](https://openrouter.ai/docs/guides/routing/provider-selection)). Model slug shortcuts `:nitro` (throughput) and `:floor` (price) are equivalent to setting `sort` without using `ProviderPreferences`. +To see which provider actually served a chat completion, read `response.provider` (for example `"Groq"`). It is `nil` when OpenRouter omits it. On failures, `OpenRouterError.metadata?["provider_name"]` carries the provider when OpenRouter reports one. + ## Multi-Model Routing Specify fallback models so the request automatically tries alternatives: @@ -467,6 +469,8 @@ do { } ``` +`error.metadata` holds OpenRouter's `error.metadata` as strings. String values pass through unchanged, and structured values such as `raw` or `reasons` arrive as compact JSON text. + ## Migration Notes ### Custom Protocol Conformers diff --git a/Sources/OpenRouterKit/Errors/OpenRouterError.swift b/Sources/OpenRouterKit/Errors/OpenRouterError.swift index f66713e..1a27f32 100644 --- a/Sources/OpenRouterKit/Errors/OpenRouterError.swift +++ b/Sources/OpenRouterKit/Errors/OpenRouterError.swift @@ -13,6 +13,34 @@ package struct ErrorResponse: Decodable { package let code: Int package let message: String package let metadata: [String: String]? + + enum CodingKeys: String, CodingKey { + case code + case message + case metadata + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.code = try container.decode(Int.self, forKey: .code) + self.message = try container.decode(String.self, forKey: .message) + // Metadata values are arbitrary JSON (`raw`, `reasons`), so non-strings are kept as compact JSON text. + let values = try container.decodeIfPresent([String: JSONValue].self, forKey: .metadata) + self.metadata = values?.compactMapValues(Self.metadataString) + } + + private static func metadataString(_ value: JSONValue) -> String? { + switch value { + case .string(let string): + return string + case .null: + return nil + default: + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return (try? encoder.encode(value)).flatMap { String(bytes: $0, encoding: .utf8) } + } + } } package let error: ErrorDetail } @@ -28,7 +56,10 @@ public struct OpenRouterError: Error { /// Human-readable error message public let message: String - /// Optional metadata associated with the error + /// Optional metadata associated with the error, such as `provider_name`. + /// + /// String values are passed through as-is. Other JSON values (for example + /// `raw` or `reasons`) are rendered as compact JSON text; `null` values are omitted. public let metadata: [String: String]? /// Raw HTTP response body returned by the server when the structured error diff --git a/Sources/OpenRouterKit/Models/Chat/ChatResponse.swift b/Sources/OpenRouterKit/Models/Chat/ChatResponse.swift index 8722b95..2cce635 100644 --- a/Sources/OpenRouterKit/Models/Chat/ChatResponse.swift +++ b/Sources/OpenRouterKit/Models/Chat/ChatResponse.swift @@ -20,6 +20,9 @@ public struct ChatResponse: Codable, Sendable { /// Model identifier used for the completion. public var model: String + /// Name of the upstream provider that served the request, for example `Groq` or `Cerebras`. + public var provider: String? + /// Token usage statistics. public var usage: Usage? diff --git a/Tests/OpenRouterKitTests/ChatResponseProviderTests.swift b/Tests/OpenRouterKitTests/ChatResponseProviderTests.swift new file mode 100644 index 0000000..5fbcadd --- /dev/null +++ b/Tests/OpenRouterKitTests/ChatResponseProviderTests.swift @@ -0,0 +1,107 @@ +// +// ChatResponseProviderTests.swift +// OpenRouterKit +// +// Tests for decoding the upstream provider on chat responses +// + +import Testing +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +@testable import OpenRouterKit + +@Suite("Chat Response Provider Decoding Tests") +struct ChatResponseProviderTests { + + @Test("Decodes top-level provider") + func decodesProvider() throws { + let jsonString = """ + { + "id": "gen-provider123", + "provider": "Groq", + "model": "openai/gpt-oss-120b", + "object": "chat.completion", + "created": 1758900000, + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hello!" + }, + "finish_reason": "stop" + } + ] + } + """ + + let jsonData = try #require(jsonString.data(using: .utf8)) + let response = try JSONDecoder().decode(ChatResponse.self, from: jsonData) + + #expect(response.provider == "Groq") + #expect(response.model == "openai/gpt-oss-120b") + } + + @Test("Decodes response without provider") + func decodesWithoutProvider() throws { + let jsonString = """ + { + "id": "gen-provider456", + "model": "test-model", + "choices": [ + { + "message": { + "role": "assistant", + "content": "No provider here." + }, + "finish_reason": "stop" + } + ] + } + """ + + let jsonData = try #require(jsonString.data(using: .utf8)) + let response = try JSONDecoder().decode(ChatResponse.self, from: jsonData) + + #expect(response.provider == nil) + } +} + +@Suite("Chat Response Provider Integration Tests", + .enabled(if: ProcessInfo.processInfo.environment["OPENROUTER_API_KEY"]?.isEmpty == false)) +struct ChatResponseProviderIntegrationTests { + let client: OpenRouterClient + + init() throws { + let apiKey = try #require(ProcessInfo.processInfo.environment["OPENROUTER_API_KEY"]) + + #if canImport(FoundationNetworking) + let session = URLSession(configuration: .default) + #else + let session = URLSession.shared + #endif + + client = OpenRouterClient( + apiKey: apiKey, + siteURL: "https://github.com", + siteName: "Swift OpenRouterKit Tests", + session: session + ) + } + + @Test("Non-streaming chat completion reports the upstream provider") + func chatCompletionIncludesProvider() async throws { + let request = OpenRouterRequest( + messages: [Message(role: .user, content: .string("Reply with one word: ok"))], + model: "google/gemini-3-flash-preview", + maxTokens: 5 + ) + + let response = try await client.chat.send(request: request) + let provider = try #require(response.provider, "OpenRouter should return the serving provider") + + #expect(!provider.isEmpty) + print("Served by provider: \(provider)") + } +} diff --git a/Tests/OpenRouterKitTests/OpenRouterErrorTests.swift b/Tests/OpenRouterKitTests/OpenRouterErrorTests.swift index c1a5df3..febfa35 100644 --- a/Tests/OpenRouterKitTests/OpenRouterErrorTests.swift +++ b/Tests/OpenRouterKitTests/OpenRouterErrorTests.swift @@ -90,6 +90,59 @@ struct OpenRouterErrorTests { } } + @Test func providerErrorWithObjectRawMetadataDecodes() throws { + let json = """ + { + "error": { + "code": 403, + "message": "Provider refused the request", + "metadata": { + "provider_name": "Anthropic", + "provider_code": "permission_error", + "error_type": "refusal", + "raw": {"type": "error", "error": {"type": "permission_error", "retryable": false}}, + "attempt": 2, + "cached": null + } + } + } + """ + let errorResponse = try JSONDecoder().decode(ErrorResponse.self, from: Data(json.utf8)) + let error = OpenRouterError(httpStatusCode: 403, errorResponse: errorResponse, rawBody: json) + + #expect(error.message == "Provider refused the request") + #expect(error.rawResponseBody == nil) + #expect(error.metadata?["provider_name"] == "Anthropic") + #expect(error.metadata?["provider_code"] == "permission_error") + #expect(error.metadata?["raw"] == #"{"error":{"retryable":false,"type":"permission_error"},"type":"error"}"#) + #expect(error.metadata?["attempt"] == "2") + #expect(error.metadata?.keys.contains("cached") == false) + } + + @Test func moderationErrorWithArrayMetadataDecodes() throws { + let json = """ + { + "error": { + "code": 403, + "message": "Input was flagged", + "metadata": { + "reasons": ["harassment", "violence"], + "flagged_input": "some text", + "provider_name": "OpenAI", + "model_slug": "openai/gpt-4o" + } + } + } + """ + let errorResponse = try JSONDecoder().decode(ErrorResponse.self, from: Data(json.utf8)) + let error = OpenRouterError(httpStatusCode: 403, errorResponse: errorResponse) + + #expect(error.message == "Input was flagged") + #expect(error.metadata?["provider_name"] == "OpenAI") + #expect(error.metadata?["reasons"] == #"["harassment","violence"]"#) + #expect(error.metadata?["flagged_input"] == "some text") + } + @Test func statusCodeMappingRateLimited() throws { let errorResponse = try makeErrorResponse(message: "Too many requests", code: 429) let error = OpenRouterError(httpStatusCode: 429, errorResponse: errorResponse)