ErrorDetail.code was typed String? but OpenRouter returns it as a JSON number, so decoding the whole error body silently failed and every non-200 response fell back to "Unknown error: HTTP <code>" instead of showing OpenRouter's actual message (e.g. why image generation was rejected). Dropped the unused code field, and made the streaming path attempt to decode the error body too instead of skipping it outright.
494 lines
15 KiB
Swift
494 lines
15 KiB
Swift
//
|
|
// OpenRouterModels.swift
|
|
// Confab
|
|
//
|
|
// OpenRouter API request and response models
|
|
//
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
// Copyright (C) 2026 Rune Olsen
|
|
//
|
|
// This file is part of Confab.
|
|
//
|
|
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
|
|
// You may use, study, modify, and share it for any noncommercial
|
|
// purpose. Commercial use — including selling Confab or any part of
|
|
// it, standalone or bundled into another product or service —
|
|
// requires a separate commercial license from the copyright holder.
|
|
//
|
|
// See the LICENSE file or
|
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
|
// the full license text. For commercial licensing, contact Rune
|
|
// Olsen via <https://confab.no>.
|
|
|
|
|
|
import Foundation
|
|
|
|
// MARK: - API Request
|
|
|
|
struct ReasoningAPIConfig: Codable {
|
|
let effort: String
|
|
let exclude: Bool?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case effort
|
|
case exclude
|
|
}
|
|
}
|
|
|
|
struct OpenRouterChatRequest: Codable {
|
|
let model: String
|
|
let messages: [APIMessage]
|
|
var stream: Bool
|
|
let maxTokens: Int?
|
|
let temperature: Double?
|
|
let topP: Double?
|
|
let tools: [Tool]?
|
|
let toolChoice: String?
|
|
let modalities: [String]?
|
|
let reasoning: ReasoningAPIConfig?
|
|
let cacheControl: CacheControl?
|
|
|
|
struct CacheControl: Codable {
|
|
let type: String
|
|
}
|
|
|
|
struct APIMessage: Codable {
|
|
let role: String
|
|
let content: MessageContent
|
|
|
|
enum MessageContent: Codable {
|
|
case string(String)
|
|
case array([ContentItem])
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.singleValueContainer()
|
|
if let str = try? container.decode(String.self) {
|
|
self = .string(str)
|
|
} else if let arr = try? container.decode([ContentItem].self) {
|
|
self = .array(arr)
|
|
} else {
|
|
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid content")
|
|
}
|
|
}
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
var container = encoder.singleValueContainer()
|
|
switch self {
|
|
case .string(let str):
|
|
try container.encode(str)
|
|
case .array(let arr):
|
|
try container.encode(arr)
|
|
}
|
|
}
|
|
}
|
|
|
|
enum ContentItem: Codable {
|
|
case text(String)
|
|
case image(ImageContent)
|
|
|
|
struct TextContent: Codable {
|
|
let type: String // "text"
|
|
let text: String
|
|
}
|
|
|
|
struct ImageContent: Codable {
|
|
let type: String // "image_url"
|
|
let imageUrl: ImageURL
|
|
|
|
struct ImageURL: Codable {
|
|
let url: String
|
|
}
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case type
|
|
case imageUrl = "image_url"
|
|
}
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.singleValueContainer()
|
|
if let textContent = try? container.decode(TextContent.self), textContent.type == "text" {
|
|
self = .text(textContent.text)
|
|
} else if let image = try? container.decode(ImageContent.self) {
|
|
self = .image(image)
|
|
} else if let str = try? container.decode(String.self) {
|
|
self = .text(str)
|
|
} else {
|
|
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid content item")
|
|
}
|
|
}
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
var container = encoder.singleValueContainer()
|
|
switch self {
|
|
case .text(let text):
|
|
try container.encode(TextContent(type: "text", text: text))
|
|
case .image(let image):
|
|
try container.encode(image)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case model
|
|
case messages
|
|
case stream
|
|
case maxTokens = "max_tokens"
|
|
case temperature
|
|
case topP = "top_p"
|
|
case tools
|
|
case toolChoice = "tool_choice"
|
|
case modalities
|
|
case reasoning
|
|
case cacheControl = "cache_control"
|
|
}
|
|
}
|
|
|
|
// MARK: - API Response
|
|
|
|
struct OpenRouterChatResponse: Codable {
|
|
let id: String
|
|
let model: String
|
|
let choices: [Choice]
|
|
let usage: Usage?
|
|
let created: Int
|
|
|
|
struct Choice: Codable {
|
|
let index: Int
|
|
let message: MessageContent
|
|
let finishReason: String?
|
|
|
|
struct MessageContent: Codable {
|
|
let role: String
|
|
let content: String?
|
|
let toolCalls: [APIToolCall]?
|
|
let images: [ImageOutput]?
|
|
// Images extracted from content[] blocks (e.g. GPT-5 Image response format)
|
|
let contentBlockImages: [ImageOutput]
|
|
|
|
private struct ContentBlock: Codable {
|
|
let type: String
|
|
let text: String?
|
|
let imageUrl: ImageOutput.ImageURL?
|
|
enum CodingKeys: String, CodingKey {
|
|
case type, text
|
|
case imageUrl = "image_url"
|
|
}
|
|
}
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case role
|
|
case content
|
|
case toolCalls = "tool_calls"
|
|
case images
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
role = try c.decode(String.self, forKey: .role)
|
|
toolCalls = try c.decodeIfPresent([APIToolCall].self, forKey: .toolCalls)
|
|
images = try c.decodeIfPresent([ImageOutput].self, forKey: .images)
|
|
// content can be a plain String OR an array of content blocks
|
|
if let text = try? c.decodeIfPresent(String.self, forKey: .content) {
|
|
content = text
|
|
contentBlockImages = []
|
|
} else if let blocks = try? c.decodeIfPresent([ContentBlock].self, forKey: .content) {
|
|
content = blocks.compactMap { $0.text }.joined().nonEmptyOrNil
|
|
contentBlockImages = blocks.compactMap { block in
|
|
guard block.type == "image_url", let url = block.imageUrl else { return nil }
|
|
return ImageOutput(imageUrl: url)
|
|
}
|
|
} else {
|
|
content = nil
|
|
contentBlockImages = []
|
|
}
|
|
}
|
|
}
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case index
|
|
case message
|
|
case finishReason = "finish_reason"
|
|
}
|
|
}
|
|
|
|
struct ImageOutput: Codable {
|
|
let imageUrl: ImageURL
|
|
|
|
struct ImageURL: Codable {
|
|
let url: String
|
|
}
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case imageUrl = "image_url"
|
|
}
|
|
}
|
|
|
|
struct Usage: Codable {
|
|
let promptTokens: Int
|
|
let completionTokens: Int
|
|
let totalTokens: Int
|
|
let promptTokensDetails: PromptTokensDetails?
|
|
|
|
struct PromptTokensDetails: Codable {
|
|
let cachedTokens: Int?
|
|
let cacheWriteTokens: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case cachedTokens = "cached_tokens"
|
|
case cacheWriteTokens = "cache_write_tokens"
|
|
}
|
|
}
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case promptTokens = "prompt_tokens"
|
|
case completionTokens = "completion_tokens"
|
|
case totalTokens = "total_tokens"
|
|
case promptTokensDetails = "prompt_tokens_details"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Streaming Response
|
|
|
|
struct OpenRouterStreamChunk: Codable {
|
|
let id: String
|
|
let model: String
|
|
let choices: [StreamChoice]
|
|
let usage: OpenRouterChatResponse.Usage?
|
|
|
|
struct StreamChoice: Codable {
|
|
let index: Int
|
|
let delta: Delta
|
|
let finishReason: String?
|
|
|
|
struct Delta: Codable {
|
|
let role: String?
|
|
let content: String?
|
|
let reasoning: String?
|
|
// images[] from top-level delta field (custom OpenRouter format)
|
|
let images: [OpenRouterChatResponse.ImageOutput]?
|
|
// images extracted from content[] array (standard OpenAI content-block format)
|
|
let contentBlockImages: [OpenRouterChatResponse.ImageOutput]
|
|
|
|
private struct ContentBlock: Codable {
|
|
let type: String
|
|
let text: String?
|
|
let imageUrl: OpenRouterChatResponse.ImageOutput.ImageURL?
|
|
enum CodingKeys: String, CodingKey {
|
|
case type, text
|
|
case imageUrl = "image_url"
|
|
}
|
|
}
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case role, content, images, reasoning
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
role = try c.decodeIfPresent(String.self, forKey: .role)
|
|
images = try c.decodeIfPresent([OpenRouterChatResponse.ImageOutput].self, forKey: .images)
|
|
reasoning = try c.decodeIfPresent(String.self, forKey: .reasoning)
|
|
// content can be a plain String OR an array of content blocks
|
|
if let text = try? c.decodeIfPresent(String.self, forKey: .content) {
|
|
content = text
|
|
contentBlockImages = []
|
|
} else if let blocks = try? c.decodeIfPresent([ContentBlock].self, forKey: .content) {
|
|
content = blocks.compactMap { $0.text }.joined().nonEmptyOrNil
|
|
contentBlockImages = blocks.compactMap { block in
|
|
guard block.type == "image_url", let url = block.imageUrl else { return nil }
|
|
return OpenRouterChatResponse.ImageOutput(imageUrl: url)
|
|
}
|
|
} else {
|
|
content = nil
|
|
contentBlockImages = []
|
|
}
|
|
}
|
|
}
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case index
|
|
case delta
|
|
case finishReason = "finish_reason"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Models List
|
|
|
|
struct OpenRouterModelsResponse: Codable {
|
|
let data: [ModelData]
|
|
|
|
struct ModelData: Codable {
|
|
let id: String
|
|
let name: String
|
|
let description: String?
|
|
let contextLength: Int
|
|
let pricing: PricingData
|
|
let architecture: Architecture?
|
|
let supportedParameters: [String]?
|
|
let outputModalities: [String]?
|
|
let created: Int?
|
|
|
|
struct PricingData: Codable {
|
|
let prompt: String
|
|
let completion: String
|
|
}
|
|
|
|
struct Architecture: Codable {
|
|
let modality: String?
|
|
let tokenizer: String?
|
|
let instructType: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case modality
|
|
case tokenizer
|
|
case instructType = "instruct_type"
|
|
}
|
|
}
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id
|
|
case name
|
|
case description
|
|
case contextLength = "context_length"
|
|
case pricing
|
|
case architecture
|
|
case supportedParameters = "supported_parameters"
|
|
case outputModalities = "output_modalities"
|
|
case created
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Credits Response
|
|
|
|
struct OpenRouterCreditsResponse: Codable {
|
|
let data: CreditsData
|
|
|
|
struct CreditsData: Codable {
|
|
let totalCredits: Double?
|
|
let totalUsage: Double?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case totalCredits = "total_credits"
|
|
case totalUsage = "total_usage"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Tool Call Models
|
|
|
|
struct APIToolCall: Codable {
|
|
let id: String
|
|
let type: String
|
|
let function: FunctionCall
|
|
|
|
struct FunctionCall: Codable {
|
|
let name: String
|
|
let arguments: String
|
|
}
|
|
}
|
|
|
|
/// Message shape for encoding assistant messages that contain tool calls
|
|
struct AssistantToolCallMessage: Encodable {
|
|
let role: String
|
|
let content: String?
|
|
let toolCalls: [APIToolCall]
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case role
|
|
case content
|
|
case toolCalls = "tool_calls"
|
|
}
|
|
}
|
|
|
|
/// Message shape for encoding tool result messages back to the API
|
|
struct ToolResultMessage: Encodable {
|
|
let role: String // "tool"
|
|
let toolCallId: String
|
|
let name: String
|
|
let content: String
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case role
|
|
case toolCallId = "tool_call_id"
|
|
case name
|
|
case content
|
|
}
|
|
}
|
|
|
|
// MARK: - Images API — Model Discovery
|
|
|
|
struct OpenRouterImageModelsResponse: Codable {
|
|
let data: [ImageModelData]
|
|
|
|
struct ImageModelData: Codable {
|
|
let id: String
|
|
let name: String
|
|
let description: String?
|
|
let architecture: Architecture?
|
|
let supportsStreaming: Bool?
|
|
|
|
struct Architecture: Codable {
|
|
let inputModalities: [String]?
|
|
let outputModalities: [String]?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case inputModalities = "input_modalities"
|
|
case outputModalities = "output_modalities"
|
|
}
|
|
}
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, name, description, architecture
|
|
case supportsStreaming = "supports_streaming"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Images API — Generation Response
|
|
|
|
struct OpenRouterImageGenerationResponse: Codable {
|
|
let created: Int?
|
|
let data: [ImageData]
|
|
let usage: Usage?
|
|
|
|
struct ImageData: Codable {
|
|
let b64Json: String
|
|
let mediaType: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case b64Json = "b64_json"
|
|
case mediaType = "media_type"
|
|
}
|
|
}
|
|
|
|
struct Usage: Codable {
|
|
let promptTokens: Int
|
|
let completionTokens: Int
|
|
let totalTokens: Int
|
|
let cost: Double?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case promptTokens = "prompt_tokens"
|
|
case completionTokens = "completion_tokens"
|
|
case totalTokens = "total_tokens"
|
|
case cost
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Error Response
|
|
|
|
struct OpenRouterErrorResponse: Codable {
|
|
let error: ErrorDetail
|
|
|
|
struct ErrorDetail: Codable {
|
|
let message: String
|
|
let type: String?
|
|
}
|
|
}
|