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
+110 -22
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,8 +115,110 @@ 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? {
let models = try await listModels()
return models.first { $0.id == id }