Initial commit
This commit is contained in:
433
oAI/Providers/OpenRouterProvider.swift
Normal file
433
oAI/Providers/OpenRouterProvider.swift
Normal file
@@ -0,0 +1,433 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user