Files
oai-swift/oAI/Providers/OpenRouterProvider.swift
2026-02-11 22:22:55 +01:00

434 lines
18 KiB
Swift

//
// OpenRouterProvider.swift
// oAI
//
// OpenRouter AI provider implementation with SSE streaming
//
import Foundation
import os
class OpenRouterProvider: AIProvider {
let name = "OpenRouter"
let capabilities = ProviderCapabilities(
supportsStreaming: true,
supportsVision: true,
supportsTools: true,
supportsOnlineSearch: true,
maxContextLength: nil
)
private let apiKey: String
private let baseURL = "https://openrouter.ai/api/v1"
private let session: URLSession
init(apiKey: String) {
self.apiKey = apiKey
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 60
config.timeoutIntervalForResource = 300
self.session = URLSession(configuration: config)
}
// MARK: - List Models
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)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("OpenRouter models: invalid response (not HTTP)")
throw ProviderError.invalidResponse
}
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
let promptPrice = Double(modelData.pricing.prompt) ?? 0.0
let completionPrice = Double(modelData.pricing.completion) ?? 0.0
return ModelInfo(
id: modelData.id,
name: modelData.name,
description: modelData.description,
contextLength: modelData.contextLength,
pricing: ModelInfo.Pricing(
prompt: promptPrice * 1_000_000, // Convert to per 1M tokens
completion: completionPrice * 1_000_000
),
capabilities: ModelInfo.ModelCapabilities(
vision: {
let mod = modelData.architecture?.modality ?? ""
return mod == "multimodal" || mod.hasPrefix("text+image")
}(),
tools: modelData.supportedParameters?.contains("tools") ?? false,
online: {
// OpenRouter supports :online suffix for all text models
let mod = modelData.architecture?.modality ?? ""
if let arrow = mod.range(of: "->") {
return !mod[arrow.upperBound...].contains("image")
}
return true
}(),
imageGeneration: {
if let mod = modelData.architecture?.modality,
let arrow = mod.range(of: "->") {
let output = mod[arrow.upperBound...]
return output.contains("image")
}
return false
}()
),
architecture: modelData.architecture.map { arch in
ModelInfo.Architecture(
tokenizer: arch.tokenizer,
instructType: arch.instructType,
modality: arch.modality
)
},
topProvider: modelData.id.components(separatedBy: "/").first
)
}
}
func getModel(_ id: String) async throws -> ModelInfo? {
let models = try await listModels()
return models.first { $0.id == id }
}
// MARK: - Chat Completion
func chat(request: ChatRequest) async throws -> ChatResponse {
Log.api.info("OpenRouter chat request: model=\(request.model), messages=\(request.messages.count)")
let apiRequest = try buildAPIRequest(from: request)
let url = URL(string: "\(baseURL)/chat/completions")!
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 JSONEncoder().encode(apiRequest)
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw ProviderError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
if let errorResponse = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) {
Log.api.error("OpenRouter chat HTTP \(httpResponse.statusCode): \(errorResponse.error.message)")
throw ProviderError.unknown(errorResponse.error.message)
}
Log.api.error("OpenRouter chat HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
let apiResponse = try JSONDecoder().decode(OpenRouterChatResponse.self, from: data)
return try convertToChatResponse(apiResponse)
}
// MARK: - Chat with raw tool messages
/// Chat completion that accepts pre-encoded messages (for the tool call loop where
/// message shapes vary: user, assistant+tool_calls, tool results).
func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse {
let url = URL(string: "\(baseURL)/chat/completions")!
var body: [String: Any] = [
"model": model,
"messages": messages,
"stream": false
]
if let tools = tools {
let toolsData = try JSONEncoder().encode(tools)
body["tools"] = try JSONSerialization.jsonObject(with: toolsData)
body["tool_choice"] = "auto"
}
if let maxTokens = maxTokens { body["max_tokens"] = maxTokens }
if let temperature = temperature { body["temperature"] = temperature }
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: body)
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw ProviderError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
if let errorResponse = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) {
Log.api.error("OpenRouter tool chat HTTP \(httpResponse.statusCode): \(errorResponse.error.message)")
throw ProviderError.unknown(errorResponse.error.message)
}
Log.api.error("OpenRouter tool chat HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
let apiResponse = try JSONDecoder().decode(OpenRouterChatResponse.self, from: data)
return try convertToChatResponse(apiResponse)
}
// MARK: - Streaming Chat
func streamChat(request: ChatRequest) -> AsyncThrowingStream<StreamChunk, Error> {
Log.api.info("OpenRouter stream request: model=\(request.model), messages=\(request.messages.count)")
return AsyncThrowingStream { continuation in
Task {
do {
var apiRequest = try buildAPIRequest(from: request)
apiRequest.stream = true
let url = URL(string: "\(baseURL)/chat/completions")!
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.addValue("text/event-stream", forHTTPHeaderField: "Accept")
urlRequest.addValue("https://github.com/yourusername/oAI", forHTTPHeaderField: "HTTP-Referer")
urlRequest.addValue("oAI-Swift", forHTTPHeaderField: "X-Title")
urlRequest.httpBody = try JSONEncoder().encode(apiRequest)
let (bytes, response) = try await session.bytes(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("OpenRouter stream: invalid response (not HTTP)")
continuation.finish(throwing: ProviderError.invalidResponse)
return
}
guard httpResponse.statusCode == 200 else {
Log.api.error("OpenRouter stream HTTP \(httpResponse.statusCode)")
continuation.finish(throwing: ProviderError.unknown("HTTP \(httpResponse.statusCode)"))
return
}
var buffer = ""
for try await line in bytes.lines {
if line.hasPrefix("data: ") {
let jsonString = String(line.dropFirst(6))
if jsonString == "[DONE]" {
continuation.finish()
return
}
buffer += jsonString
if let jsonData = buffer.data(using: .utf8) {
do {
let chunk = try JSONDecoder().decode(OpenRouterStreamChunk.self, from: jsonData)
let streamChunk = try convertToStreamChunk(chunk)
continuation.yield(streamChunk)
buffer = ""
} catch {
// Partial JSON, keep buffering
continue
}
}
}
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
// MARK: - Credits
func getCredits() async throws -> Credits? {
Log.api.info("Fetching OpenRouter credits")
let url = URL(string: "\(baseURL)/credits")!
var request = URLRequest(url: url)
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
return nil
}
let creditsResponse = try JSONDecoder().decode(OpenRouterCreditsResponse.self, from: data)
let totalCredits = creditsResponse.data.totalCredits ?? 0
let totalUsage = creditsResponse.data.totalUsage ?? 0
let remaining = totalCredits - totalUsage
return Credits(
balance: remaining,
currency: "USD",
usage: totalUsage,
limit: totalCredits
)
}
// MARK: - Helper Methods
private func buildAPIRequest(from request: ChatRequest) throws -> OpenRouterChatRequest {
let apiMessages = request.messages.map { message -> OpenRouterChatRequest.APIMessage in
let hasAttachments = message.attachments?.contains(where: { $0.data != nil }) ?? false
let content: OpenRouterChatRequest.APIMessage.MessageContent
if hasAttachments {
// Use array format for messages with attachments
var contentArray: [OpenRouterChatRequest.APIMessage.ContentItem] = []
// Add main text content
contentArray.append(.text(message.content))
// Add attachments
if let attachments = message.attachments {
for attachment in attachments {
guard let data = attachment.data else { continue }
switch attachment.type {
case .image, .pdf:
// Send as base64 data URL with correct MIME type
let base64String = data.base64EncodedString()
let dataURL = "data:\(attachment.mimeType);base64,\(base64String)"
let imageContent = OpenRouterChatRequest.APIMessage.ContentItem.ImageContent(
type: "image_url",
imageUrl: .init(url: dataURL)
)
contentArray.append(.image(imageContent))
case .text:
// Inline text file content
let filename = (attachment.path as NSString).lastPathComponent
let textContent = String(data: data, encoding: .utf8) ?? ""
contentArray.append(.text("File: \(filename)\n\n\(textContent)"))
}
}
}
content = .array(contentArray)
} else {
// Use simple string format for text-only messages
content = .string(message.content)
}
return OpenRouterChatRequest.APIMessage(
role: message.role.rawValue,
content: content
)
}
// Append :online suffix for web search when online mode is enabled
let effectiveModel: String
if request.onlineMode && !request.imageGeneration && !request.model.hasSuffix(":online") {
effectiveModel = request.model + ":online"
} else {
effectiveModel = request.model
}
return OpenRouterChatRequest(
model: effectiveModel,
messages: apiMessages,
stream: request.stream,
maxTokens: request.maxTokens,
temperature: request.temperature,
topP: request.topP,
tools: request.tools,
toolChoice: request.tools != nil ? "auto" : nil,
modalities: request.imageGeneration ? ["text", "image"] : nil
)
}
private func convertToChatResponse(_ apiResponse: OpenRouterChatResponse) throws -> ChatResponse {
guard let choice = apiResponse.choices.first else {
throw ProviderError.invalidResponse
}
let toolCalls: [ToolCallInfo]? = choice.message.toolCalls?.map { tc in
ToolCallInfo(id: tc.id, type: tc.type, functionName: tc.function.name, arguments: tc.function.arguments)
}
let images = choice.message.images.flatMap { decodeImageOutputs($0) }
return ChatResponse(
id: apiResponse.id,
model: apiResponse.model,
content: choice.message.content ?? "",
role: choice.message.role,
finishReason: choice.finishReason,
usage: apiResponse.usage.map { usage in
ChatResponse.Usage(
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
totalTokens: usage.totalTokens
)
},
created: Date(timeIntervalSince1970: TimeInterval(apiResponse.created)),
toolCalls: toolCalls,
generatedImages: images
)
}
private func convertToStreamChunk(_ apiChunk: OpenRouterStreamChunk) throws -> StreamChunk {
guard let choice = apiChunk.choices.first else {
throw ProviderError.invalidResponse
}
let images = choice.delta.images.flatMap { decodeImageOutputs($0) }
return StreamChunk(
id: apiChunk.id,
model: apiChunk.model,
delta: StreamChunk.Delta(
content: choice.delta.content,
role: choice.delta.role,
images: images
),
finishReason: choice.finishReason,
usage: apiChunk.usage.map { usage in
ChatResponse.Usage(
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
totalTokens: usage.totalTokens
)
}
)
}
/// Decode base64 data URL images from API response
private func decodeImageOutputs(_ outputs: [OpenRouterChatResponse.ImageOutput]) -> [Data]? {
let decoded = outputs.compactMap { output -> Data? in
let url = output.imageUrl.url
// Strip "data:image/...;base64," prefix
guard let commaIndex = url.firstIndex(of: ",") else { return nil }
let base64String = String(url[url.index(after: commaIndex)...])
return Data(base64Encoded: base64String)
}
return decoded.isEmpty ? nil : decoded
}
}