From 305b11b3d1d10437c792363a5d64727e68bf8fad Mon Sep 17 00:00:00 2001 From: Aditya Dhar <74172325+ProJedi1234@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:42:43 -0400 Subject: [PATCH 1/2] feat(chat): decode the upstream provider on ChatResponse OpenRouter puts a top-level `provider` string (Groq, Cerebras, ...) on chat completion responses, naming the upstream that served the call. `StreamingDelta` already decoded it, but `ChatResponse` did not, so non-streaming callers had no way to record which provider they got. The field is optional. OpenRouter's OpenAPI schema for ChatResult does not list it, so responses that omit it still decode. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Models/Chat/ChatResponse.swift | 3 + .../ChatResponseProviderTests.swift | 107 ++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 Tests/OpenRouterKitTests/ChatResponseProviderTests.swift 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)") + } +} From 4c042b2757da60232d7fb0242cdf7da0d23fb463 Mon Sep 17 00:00:00 2001 From: Aditya Dhar <74172325+ProJedi1234@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:42:43 -0400 Subject: [PATCH 2/2] fix(errors): decode error metadata that holds non-string values OpenRouter types `error.metadata` as `Record`. Moderation errors carry `reasons: string[]`, guardrail blocks carry `patterns: string[]`, and provider errors carry the upstream's verbatim payload in `raw`, which is often an object. `ErrorDetail` decoded metadata as `[String: String]`, so any one of those values made the whole `ErrorResponse` decode throw. HTTPClient swallows that with `try?`, so the caller got "Unknown error occurred", `metadata == nil`, and no way to read `provider_name` short of parsing `rawResponseBody`. Metadata now decodes as arbitrary JSON and is flattened into the existing `[String: String]` shape. Strings pass through unchanged, other values become compact sorted-key JSON text, and nulls are dropped. The public type of `OpenRouterError.metadata` is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 4 ++ .../Errors/OpenRouterError.swift | 33 +++++++++++- .../OpenRouterErrorTests.swift | 53 +++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) 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/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)