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
|
let online: Bool // Web search
|
||||||
var imageGeneration: Bool = false // Image output
|
var imageGeneration: Bool = false // Image output
|
||||||
var thinking: Bool = false // Reasoning/thinking tokens
|
var thinking: Bool = false // Reasoning/thinking tokens
|
||||||
|
var usesImagesAPI: Bool = false // OpenRouter dedicated /images endpoint
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Architecture: Codable, Hashable {
|
struct Architecture: Codable, Hashable {
|
||||||
|
|||||||
@@ -132,15 +132,19 @@ struct ChatResponse: Codable {
|
|||||||
let totalTokens: Int
|
let totalTokens: Int
|
||||||
let cacheCreationInputTokens: Int?
|
let cacheCreationInputTokens: Int?
|
||||||
let cacheReadInputTokens: 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.promptTokens = promptTokens
|
||||||
self.completionTokens = completionTokens
|
self.completionTokens = completionTokens
|
||||||
self.totalTokens = totalTokens
|
self.totalTokens = totalTokens
|
||||||
self.cacheCreationInputTokens = cacheCreationInputTokens
|
self.cacheCreationInputTokens = cacheCreationInputTokens
|
||||||
self.cacheReadInputTokens = cacheReadInputTokens
|
self.cacheReadInputTokens = cacheReadInputTokens
|
||||||
|
self.rawCostUSD = rawCostUSD
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rawCostUSD is set programmatically, never decoded from API responses
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case promptTokens = "prompt_tokens"
|
case promptTokens = "prompt_tokens"
|
||||||
case completionTokens = "completion_tokens"
|
case completionTokens = "completion_tokens"
|
||||||
@@ -148,6 +152,16 @@ struct ChatResponse: Codable {
|
|||||||
case cacheCreationInputTokens = "cache_creation_input_tokens"
|
case cacheCreationInputTokens = "cache_creation_input_tokens"
|
||||||
case cacheReadInputTokens = "cache_read_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
|
// 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
|
// MARK: - Error Response
|
||||||
|
|
||||||
struct OpenRouterErrorResponse: Codable {
|
struct OpenRouterErrorResponse: Codable {
|
||||||
|
|||||||
@@ -53,30 +53,16 @@ class OpenRouterProvider: AIProvider {
|
|||||||
|
|
||||||
func listModels() async throws -> [ModelInfo] {
|
func listModels() async throws -> [ModelInfo] {
|
||||||
Log.api.info("Fetching model list from OpenRouter")
|
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 {
|
let modelsResponse = try JSONDecoder().decode(OpenRouterModelsResponse.self, from: chatRaw)
|
||||||
Log.api.error("OpenRouter models: invalid response (not HTTP)")
|
Log.api.info("OpenRouter loaded \(modelsResponse.data.count) chat models")
|
||||||
throw ProviderError.invalidResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
guard httpResponse.statusCode == 200 else {
|
var models = modelsResponse.data.map { modelData in
|
||||||
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
|
|
||||||
let promptPrice = Double(modelData.pricing.prompt) ?? 0.0
|
let promptPrice = Double(modelData.pricing.prompt) ?? 0.0
|
||||||
let completionPrice = Double(modelData.pricing.completion) ?? 0.0
|
let completionPrice = Double(modelData.pricing.completion) ?? 0.0
|
||||||
|
|
||||||
@@ -129,6 +115,108 @@ class OpenRouterProvider: AIProvider {
|
|||||||
)
|
)
|
||||||
return info
|
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? {
|
func getModel(_ id: String) async throws -> ModelInfo? {
|
||||||
|
|||||||
@@ -801,6 +801,13 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
let bashActive = settings.bashEnabled
|
let bashActive = settings.bashEnabled
|
||||||
let personalDataActive = settings.calendarEnabled || settings.remindersEnabled || settings.contactsEnabled || settings.locationMapsEnabled
|
let personalDataActive = settings.calendarEnabled || settings.remindersEnabled || settings.contactsEnabled || settings.locationMapsEnabled
|
||||||
let researchAgentsActive = settings.agentsEnabled
|
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
|
let modelSupportTools = selectedModel?.capabilities.tools ?? false
|
||||||
if modelSupportTools && (anytypeActive || bashActive || personalDataActive || researchAgentsActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
|
if modelSupportTools && (anytypeActive || bashActive || personalDataActive || researchAgentsActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
|
||||||
generateAIResponseWithTools(provider: provider, modelId: modelId)
|
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: - 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) {
|
private func generateAIResponseWithTools(provider: AIProvider, modelId: String) {
|
||||||
let mcp = MCPService.shared
|
let mcp = MCPService.shared
|
||||||
Log.ui.info("generateAIResponseWithTools: model=\(modelId)")
|
Log.ui.info("generateAIResponseWithTools: model=\(modelId)")
|
||||||
|
|||||||
Reference in New Issue
Block a user