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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)")
|
||||
|
||||
Reference in New Issue
Block a user