diff --git a/oAI/Models/ModelInfo.swift b/oAI/Models/ModelInfo.swift index e3387db..e46c2ad 100644 --- a/oAI/Models/ModelInfo.swift +++ b/oAI/Models/ModelInfo.swift @@ -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 { diff --git a/oAI/Providers/AIProvider.swift b/oAI/Providers/AIProvider.swift index 42f52ea..1749370 100644 --- a/oAI/Providers/AIProvider.swift +++ b/oAI/Providers/AIProvider.swift @@ -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 diff --git a/oAI/Providers/OpenRouterModels.swift b/oAI/Providers/OpenRouterModels.swift index 4fbb6b3..466ae5b 100644 --- a/oAI/Providers/OpenRouterModels.swift +++ b/oAI/Providers/OpenRouterModels.swift @@ -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 { diff --git a/oAI/Providers/OpenRouterProvider.swift b/oAI/Providers/OpenRouterProvider.swift index bcc874a..55d86bc 100644 --- a/oAI/Providers/OpenRouterProvider.swift +++ b/oAI/Providers/OpenRouterProvider.swift @@ -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 } diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index 44f852d..78b716e 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -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)")