Add OpenRouter dedicated images API support

Fetches /api/v1/images/models in parallel with /models and merges results
into the model picker. Image-only models (e.g. Sourceful, Seedream, Flux
via this endpoint) were previously invisible since they don't appear in the
standard /models endpoint.

Models from the images API get usesImagesAPI=true and route through a new
generateImageAPIResponse() path in ChatViewModel that POSTs to /api/v1/images
with {model, prompt} instead of the chat completions endpoint. The response's
b64_json data is decoded and displayed via the existing GeneratedImagesView.

Cost is taken directly from the usage.cost field in the images API response
(USD per image) via a new rawCostUSD field on ChatResponse.Usage, bypassing
the token-based calculateCost() path used for chat models.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 14:34:56 +02:00
parent 40c5f25517
commit f2949cea3b
5 changed files with 243 additions and 23 deletions
+1
View File
@@ -47,6 +47,7 @@ struct ModelInfo: Identifiable, Codable, Hashable {
let online: Bool // Web search
var imageGeneration: Bool = false // Image output
var thinking: Bool = false // Reasoning/thinking tokens
var usesImagesAPI: Bool = false // OpenRouter dedicated /images endpoint
}
struct Architecture: Codable, Hashable {
+15 -1
View File
@@ -132,15 +132,19 @@ struct ChatResponse: Codable {
let totalTokens: Int
let cacheCreationInputTokens: Int?
let cacheReadInputTokens: Int?
/// Direct USD cost returned by the images API (bypasses token-based calculation).
let rawCostUSD: Double?
init(promptTokens: Int, completionTokens: Int, totalTokens: Int, cacheCreationInputTokens: Int? = nil, cacheReadInputTokens: Int? = nil) {
init(promptTokens: Int, completionTokens: Int, totalTokens: Int, cacheCreationInputTokens: Int? = nil, cacheReadInputTokens: Int? = nil, rawCostUSD: Double? = nil) {
self.promptTokens = promptTokens
self.completionTokens = completionTokens
self.totalTokens = totalTokens
self.cacheCreationInputTokens = cacheCreationInputTokens
self.cacheReadInputTokens = cacheReadInputTokens
self.rawCostUSD = rawCostUSD
}
// rawCostUSD is set programmatically, never decoded from API responses
enum CodingKeys: String, CodingKey {
case promptTokens = "prompt_tokens"
case completionTokens = "completion_tokens"
@@ -148,6 +152,16 @@ struct ChatResponse: Codable {
case cacheCreationInputTokens = "cache_creation_input_tokens"
case cacheReadInputTokens = "cache_read_input_tokens"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
promptTokens = try c.decode(Int.self, forKey: .promptTokens)
completionTokens = try c.decode(Int.self, forKey: .completionTokens)
totalTokens = try c.decode(Int.self, forKey: .totalTokens)
cacheCreationInputTokens = try c.decodeIfPresent(Int.self, forKey: .cacheCreationInputTokens)
cacheReadInputTokens = try c.decodeIfPresent(Int.self, forKey: .cacheReadInputTokens)
rawCostUSD = nil
}
}
// Custom Codable since ToolCallInfo/generatedImages are not from API directly
+61
View File
@@ -420,6 +420,67 @@ struct ToolResultMessage: Encodable {
}
}
// 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 {
+109 -21
View File
@@ -53,30 +53,16 @@ class OpenRouterProvider: AIProvider {
func listModels() async throws -> [ModelInfo] {
Log.api.info("Fetching model list from OpenRouter")
let url = URL(string: "\(baseURL)/models")!
var request = URLRequest(url: url)
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, response) = try await session.data(for: request)
// Fetch chat models and image models in parallel
async let chatData = fetchRaw(path: "/models")
async let imageData = fetchRaw(path: "/images/models")
let (chatRaw, imageRaw) = try await (chatData, imageData)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("OpenRouter models: invalid response (not HTTP)")
throw ProviderError.invalidResponse
}
let modelsResponse = try JSONDecoder().decode(OpenRouterModelsResponse.self, from: chatRaw)
Log.api.info("OpenRouter loaded \(modelsResponse.data.count) chat models")
guard httpResponse.statusCode == 200 else {
if let errorResponse = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) {
Log.api.error("OpenRouter models HTTP \(httpResponse.statusCode): \(errorResponse.error.message)")
throw ProviderError.unknown(errorResponse.error.message)
}
Log.api.error("OpenRouter models HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
let modelsResponse = try JSONDecoder().decode(OpenRouterModelsResponse.self, from: data)
Log.api.info("OpenRouter loaded \(modelsResponse.data.count) models")
return modelsResponse.data.map { modelData in
var models = modelsResponse.data.map { modelData in
let promptPrice = Double(modelData.pricing.prompt) ?? 0.0
let completionPrice = Double(modelData.pricing.completion) ?? 0.0
@@ -129,6 +115,108 @@ class OpenRouterProvider: AIProvider {
)
return info
}
// Merge dedicated image models (these don't appear in /models)
if let imageModelsResponse = try? JSONDecoder().decode(OpenRouterImageModelsResponse.self, from: imageRaw) {
Log.api.info("OpenRouter loaded \(imageModelsResponse.data.count) image models")
let existingIds = Set(models.map { $0.id })
let imageModels = imageModelsResponse.data.compactMap { m -> ModelInfo? in
guard !existingIds.contains(m.id) else { return nil }
let acceptsImageInput = m.architecture?.inputModalities?.contains("image") ?? false
var info = ModelInfo(
id: m.id,
name: m.name,
description: m.description,
contextLength: 0,
pricing: ModelInfo.Pricing(prompt: 0, completion: 0),
capabilities: ModelInfo.ModelCapabilities(
vision: acceptsImageInput,
tools: false,
online: false,
imageGeneration: true,
thinking: false,
usesImagesAPI: true
),
topProvider: m.id.components(separatedBy: "/").first
)
info.categories = ModelCategory.infer(name: m.name, id: m.id, description: m.description)
return info
}
models.append(contentsOf: imageModels)
}
return models
}
private func fetchRaw(path: String) async throws -> Data {
let url = URL(string: "\(baseURL)\(path)")!
var request = URLRequest(url: url)
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else { throw ProviderError.invalidResponse }
guard httpResponse.statusCode == 200 else {
if let err = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) {
throw ProviderError.unknown(err.error.message)
}
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
return data
}
// MARK: - Images API
func generateImage(model: String, prompt: String) async throws -> ChatResponse {
Log.api.info("OpenRouter images API: model=\(model)")
let url = URL(string: "\(baseURL)/images")!
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.addValue("https://github.com/yourusername/oAI", forHTTPHeaderField: "HTTP-Referer")
urlRequest.addValue("oAI-Swift", forHTTPHeaderField: "X-Title")
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: ["model": model, "prompt": prompt])
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else { throw ProviderError.invalidResponse }
if httpResponse.statusCode != 200 {
if let err = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) {
Log.api.error("OpenRouter images HTTP \(httpResponse.statusCode): \(err.error.message)")
throw ProviderError.unknown(err.error.message)
}
Log.api.error("OpenRouter images HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
if let rawStr = String(data: data, encoding: .utf8) {
Log.api.debug("Images API raw response (first 200 chars): \(rawStr.prefix(200))")
}
let imageResponse = try JSONDecoder().decode(OpenRouterImageGenerationResponse.self, from: data)
let images: [Data] = imageResponse.data.compactMap { item in
Data(base64Encoded: item.b64Json)
}
let usage: ChatResponse.Usage? = imageResponse.usage.map { u in
ChatResponse.Usage(
promptTokens: u.promptTokens,
completionTokens: u.completionTokens,
totalTokens: u.totalTokens,
rawCostUSD: u.cost
)
}
return ChatResponse(
id: UUID().uuidString,
model: model,
content: "",
role: "assistant",
finishReason: "stop",
usage: usage,
created: Date(),
generatedImages: images.isEmpty ? nil : images
)
}
func getModel(_ id: String) async throws -> ModelInfo? {
+56
View File
@@ -801,6 +801,13 @@ Don't narrate future actions ("Let me...") - just use the tools.
let bashActive = settings.bashEnabled
let personalDataActive = settings.calendarEnabled || settings.remindersEnabled || settings.contactsEnabled || settings.locationMapsEnabled
let researchAgentsActive = settings.agentsEnabled
// Dedicated images API path (OpenRouter /images endpoint separate from chat completions)
if selectedModel?.capabilities.usesImagesAPI == true,
let orProvider = provider as? OpenRouterProvider {
generateImageAPIResponse(orProvider: orProvider, modelId: modelId, prompt: prompt)
return
}
let modelSupportTools = selectedModel?.capabilities.tools ?? false
if modelSupportTools && (anytypeActive || bashActive || personalDataActive || researchAgentsActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
generateAIResponseWithTools(provider: provider, modelId: modelId)
@@ -1263,6 +1270,55 @@ Don't narrate future actions ("Let me...") - just use the tools.
// MARK: - AI Response with Tool Calls
// MARK: - Images API Generation
private func generateImageAPIResponse(orProvider: OpenRouterProvider, modelId: String, prompt: String) {
isGenerating = true
streamingTask?.cancel()
streamingTask = Task {
let startTime = Date()
let assistantMessage = Message(
role: .assistant,
content: ThinkingVerbs.random(),
tokens: nil,
cost: nil,
timestamp: Date(),
attachments: nil,
modelId: modelId,
isStreaming: true
)
let messageId = assistantMessage.id
messages.append(assistantMessage)
do {
let response = try await orProvider.generateImage(model: modelId, prompt: prompt)
let responseTime = Date().timeIntervalSince(startTime)
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = response.content
messages[index].isStreaming = false
messages[index].generatedImages = response.generatedImages
messages[index].responseTime = responseTime
if let usage = response.usage {
messages[index].tokens = usage.completionTokens
let cost = usage.rawCostUSD
messages[index].cost = cost
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
}
}
_ = detectGoodbyePhrase(in: "")
} catch {
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = "❌ Image generation failed: \(error.localizedDescription)"
messages[index].isStreaming = false
}
Log.api.error("Images API error: \(error)")
}
isGenerating = false
}
}
private func generateAIResponseWithTools(provider: AIProvider, modelId: String) {
let mcp = MCPService.shared
Log.ui.info("generateAIResponseWithTools: model=\(modelId)")