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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
33 changes: 32 additions & 1 deletion Sources/OpenRouterKit/Errors/OpenRouterError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Sources/OpenRouterKit/Models/Chat/ChatResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
107 changes: 107 additions & 0 deletions Tests/OpenRouterKitTests/ChatResponseProviderTests.swift
Original file line number Diff line number Diff line change
@@ -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)")
}
}
53 changes: 53 additions & 0 deletions Tests/OpenRouterKitTests/OpenRouterErrorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading