Initial commit

This commit is contained in:
2026-02-11 22:22:55 +01:00
commit 42f54954c1
58 changed files with 10639 additions and 0 deletions

View File

@@ -0,0 +1,241 @@
//
// AIProvider.swift
// oAI
//
// Protocol for AI provider implementations
//
import Foundation
// MARK: - Provider Protocol
protocol AIProvider {
var name: String { get }
var capabilities: ProviderCapabilities { get }
func listModels() async throws -> [ModelInfo]
func getModel(_ id: String) async throws -> ModelInfo?
func chat(request: ChatRequest) async throws -> ChatResponse
func streamChat(request: ChatRequest) -> AsyncThrowingStream<StreamChunk, Error>
func getCredits() async throws -> Credits?
/// Chat completion with pre-encoded messages for the MCP tool call loop.
func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse
}
// MARK: - Provider Capabilities
struct ProviderCapabilities: Codable {
let supportsStreaming: Bool
let supportsVision: Bool
let supportsTools: Bool
let supportsOnlineSearch: Bool
let maxContextLength: Int?
static let `default` = ProviderCapabilities(
supportsStreaming: true,
supportsVision: false,
supportsTools: false,
supportsOnlineSearch: false,
maxContextLength: nil
)
}
// MARK: - Chat Request
struct ChatRequest {
let messages: [Message]
let model: String
let stream: Bool
let maxTokens: Int?
let temperature: Double?
let topP: Double?
let systemPrompt: String?
let tools: [Tool]?
let onlineMode: Bool
let imageGeneration: Bool
init(
messages: [Message],
model: String,
stream: Bool = true,
maxTokens: Int? = nil,
temperature: Double? = nil,
topP: Double? = nil,
systemPrompt: String? = nil,
tools: [Tool]? = nil,
onlineMode: Bool = false,
imageGeneration: Bool = false
) {
self.messages = messages
self.model = model
self.stream = stream
self.maxTokens = maxTokens
self.temperature = temperature
self.topP = topP
self.systemPrompt = systemPrompt
self.tools = tools
self.onlineMode = onlineMode
self.imageGeneration = imageGeneration
}
}
// MARK: - Chat Response
struct ToolCallInfo {
let id: String
let type: String
let functionName: String
let arguments: String
}
struct ChatResponse: Codable {
let id: String
let model: String
let content: String
let role: String
let finishReason: String?
let usage: Usage?
let created: Date
let toolCalls: [ToolCallInfo]?
let generatedImages: [Data]?
struct Usage: Codable {
let promptTokens: Int
let completionTokens: Int
let totalTokens: Int
enum CodingKeys: String, CodingKey {
case promptTokens = "prompt_tokens"
case completionTokens = "completion_tokens"
case totalTokens = "total_tokens"
}
}
// Custom Codable since ToolCallInfo/generatedImages are not from API directly
enum CodingKeys: String, CodingKey {
case id, model, content, role, finishReason, usage, created
}
init(id: String, model: String, content: String, role: String, finishReason: String?, usage: Usage?, created: Date, toolCalls: [ToolCallInfo]? = nil, generatedImages: [Data]? = nil) {
self.id = id
self.model = model
self.content = content
self.role = role
self.finishReason = finishReason
self.usage = usage
self.created = created
self.toolCalls = toolCalls
self.generatedImages = generatedImages
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
model = try container.decode(String.self, forKey: .model)
content = try container.decode(String.self, forKey: .content)
role = try container.decode(String.self, forKey: .role)
finishReason = try container.decodeIfPresent(String.self, forKey: .finishReason)
usage = try container.decodeIfPresent(Usage.self, forKey: .usage)
created = try container.decode(Date.self, forKey: .created)
toolCalls = nil
generatedImages = nil
}
}
// MARK: - Stream Chunk
struct StreamChunk {
let id: String
let model: String
let delta: Delta
let finishReason: String?
let usage: ChatResponse.Usage?
struct Delta {
let content: String?
let role: String?
let images: [Data]?
}
var deltaContent: String? {
delta.content
}
}
// MARK: - Tool Definition
struct Tool: Codable {
let type: String
let function: Function
struct Function: Codable {
let name: String
let description: String
let parameters: Parameters
struct Parameters: Codable {
let type: String
let properties: [String: Property]
let required: [String]?
struct Property: Codable {
let type: String
let description: String
let `enum`: [String]?
}
}
}
}
// MARK: - Credits
struct Credits: Codable {
let balance: Double
let currency: String
let usage: Double?
let limit: Double?
var balanceDisplay: String {
String(format: "$%.2f", balance)
}
var usageDisplay: String? {
guard let usage = usage else { return nil }
return String(format: "$%.2f", usage)
}
}
// MARK: - Provider Errors
enum ProviderError: LocalizedError {
case invalidAPIKey
case networkError(Error)
case invalidResponse
case rateLimitExceeded
case modelNotFound(String)
case insufficientCredits
case timeout
case unknown(String)
var errorDescription: String? {
switch self {
case .invalidAPIKey:
return "Invalid API key. Please check your settings."
case .networkError(let error):
return "Network error: \(error.localizedDescription)"
case .invalidResponse:
return "Received invalid response from API"
case .rateLimitExceeded:
return "Rate limit exceeded. Please try again later."
case .modelNotFound(let model):
return "Model '\(model)' not found"
case .insufficientCredits:
return "Insufficient credits"
case .timeout:
return "Request timed out"
case .unknown(let message):
return "Unknown error: \(message)"
}
}
}

View File

@@ -0,0 +1,534 @@
//
// AnthropicProvider.swift
// oAI
//
// Anthropic Messages API provider with SSE streaming and tool support
//
import Foundation
import os
class AnthropicProvider: AIProvider {
let name = "Anthropic"
let capabilities = ProviderCapabilities(
supportsStreaming: true,
supportsVision: true,
supportsTools: true,
supportsOnlineSearch: false,
maxContextLength: nil
)
enum AuthMode {
case apiKey(String)
case oauth
}
private let authMode: AuthMode
private let baseURL = "https://api.anthropic.com/v1"
private let apiVersion = "2023-06-01"
private let session: URLSession
/// Create with a standard API key
init(apiKey: String) {
self.authMode = .apiKey(apiKey)
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 60
config.timeoutIntervalForResource = 300
self.session = URLSession(configuration: config)
}
/// Create with OAuth (Pro/Max subscription)
init(oauth: Bool) {
self.authMode = .oauth
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 60
config.timeoutIntervalForResource = 300
self.session = URLSession(configuration: config)
}
/// Whether this provider is using OAuth authentication
var isOAuth: Bool {
if case .oauth = authMode { return true }
return false
}
// MARK: - Models (hardcoded Anthropic has no public models list endpoint)
private static let knownModels: [ModelInfo] = [
ModelInfo(
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
description: "Most capable and intelligent model",
contextLength: 200_000,
pricing: .init(prompt: 15.0, completion: 75.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-opus-4-5-20251101",
name: "Claude Opus 4.5",
description: "Previous generation Opus",
contextLength: 200_000,
pricing: .init(prompt: 15.0, completion: 75.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-sonnet-4-5-20250929",
name: "Claude Sonnet 4.5",
description: "Best balance of speed and capability",
contextLength: 200_000,
pricing: .init(prompt: 3.0, completion: 15.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-haiku-4-5-20251001",
name: "Claude Haiku 4.5",
description: "Fastest and most affordable",
contextLength: 200_000,
pricing: .init(prompt: 0.80, completion: 4.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-3-7-sonnet-20250219",
name: "Claude 3.7 Sonnet",
description: "Previous generation Sonnet",
contextLength: 200_000,
pricing: .init(prompt: 3.0, completion: 15.0),
capabilities: .init(vision: true, tools: true, online: true)
),
ModelInfo(
id: "claude-3-haiku-20240307",
name: "Claude 3 Haiku",
description: "Previous generation Haiku",
contextLength: 200_000,
pricing: .init(prompt: 0.25, completion: 1.25),
capabilities: .init(vision: true, tools: true, online: true)
),
]
func listModels() async throws -> [ModelInfo] {
return Self.knownModels
}
func getModel(_ id: String) async throws -> ModelInfo? {
return Self.knownModels.first { $0.id == id }
}
// MARK: - Chat Completion
func chat(request: ChatRequest) async throws -> ChatResponse {
Log.api.info("Anthropic chat request: model=\(request.model), messages=\(request.messages.count)")
var (urlRequest, _) = try buildURLRequest(from: request, stream: false)
try await applyAuth(to: &urlRequest)
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("Anthropic chat: invalid response (not HTTP)")
throw ProviderError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
if let errorObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let error = errorObj["error"] as? [String: Any],
let message = error["message"] as? String {
Log.api.error("Anthropic chat HTTP \(httpResponse.statusCode): \(message)")
throw ProviderError.unknown(message)
}
Log.api.error("Anthropic chat HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
return try parseResponse(data: data)
}
// MARK: - Chat with raw tool messages
func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse {
Log.api.info("Anthropic tool chat: model=\(model), messages=\(messages.count)")
let url = messagesURL
// Separate system message from conversation messages
var systemText: String? = nil
var conversationMessages: [[String: Any]] = []
for msg in messages {
let role = msg["role"] as? String ?? ""
if role == "system" {
systemText = msg["content"] as? String
} else if role == "tool" {
// Convert OpenAI tool result format to Anthropic tool_result format
let toolCallId = msg["tool_call_id"] as? String ?? ""
let content = msg["content"] as? String ?? ""
conversationMessages.append([
"role": "user",
"content": [
["type": "tool_result", "tool_use_id": toolCallId, "content": content]
]
])
} else if role == "assistant" {
// Check for tool_calls convert to Anthropic content blocks
if let toolCalls = msg["tool_calls"] as? [[String: Any]], !toolCalls.isEmpty {
var contentBlocks: [[String: Any]] = []
if let text = msg["content"] as? String, !text.isEmpty {
contentBlocks.append(["type": "text", "text": text])
}
for tc in toolCalls {
let fn = tc["function"] as? [String: Any] ?? [:]
let name = fn["name"] as? String ?? ""
let argsStr = fn["arguments"] as? String ?? "{}"
let argsObj = (try? JSONSerialization.jsonObject(with: Data(argsStr.utf8))) ?? [:]
contentBlocks.append([
"type": "tool_use",
"id": tc["id"] as? String ?? UUID().uuidString,
"name": name,
"input": argsObj
])
}
conversationMessages.append(["role": "assistant", "content": contentBlocks])
} else {
conversationMessages.append(["role": "assistant", "content": msg["content"] as? String ?? ""])
}
} else {
conversationMessages.append(["role": role, "content": msg["content"] as? String ?? ""])
}
}
var body: [String: Any] = [
"model": model,
"messages": conversationMessages,
"max_tokens": maxTokens ?? 4096,
"stream": false
]
if let systemText = systemText {
body["system"] = systemText
}
if let temperature = temperature {
body["temperature"] = temperature
}
if let tools = tools {
body["tools"] = tools.map { tool -> [String: Any] in
[
"name": tool.function.name,
"description": tool.function.description,
"input_schema": convertParametersToDict(tool.function.parameters)
]
}
body["tool_choice"] = ["type": "auto"]
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body)
try await applyAuth(to: &urlRequest)
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("Anthropic tool chat: invalid response (not HTTP)")
throw ProviderError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
if let errorObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let error = errorObj["error"] as? [String: Any],
let message = error["message"] as? String {
Log.api.error("Anthropic tool chat HTTP \(httpResponse.statusCode): \(message)")
throw ProviderError.unknown(message)
}
Log.api.error("Anthropic tool chat HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
return try parseResponse(data: data)
}
// MARK: - Streaming Chat
func streamChat(request: ChatRequest) -> AsyncThrowingStream<StreamChunk, Error> {
Log.api.info("Anthropic stream request: model=\(request.model), messages=\(request.messages.count)")
return AsyncThrowingStream { continuation in
Task {
do {
var (urlRequest, _) = try buildURLRequest(from: request, stream: true)
try await self.applyAuth(to: &urlRequest)
let (bytes, response) = try await session.bytes(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("Anthropic stream: invalid response (not HTTP)")
continuation.finish(throwing: ProviderError.invalidResponse)
return
}
guard httpResponse.statusCode == 200 else {
Log.api.error("Anthropic stream HTTP \(httpResponse.statusCode)")
continuation.finish(throwing: ProviderError.unknown("HTTP \(httpResponse.statusCode)"))
return
}
var currentId = ""
var currentModel = request.model
for try await line in bytes.lines {
// Anthropic SSE: "event: ..." and "data: {...}"
guard line.hasPrefix("data: ") else { continue }
let jsonString = String(line.dropFirst(6))
guard let jsonData = jsonString.data(using: .utf8),
let event = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
let eventType = event["type"] as? String else {
continue
}
switch eventType {
case "message_start":
if let message = event["message"] as? [String: Any] {
currentId = message["id"] as? String ?? ""
currentModel = message["model"] as? String ?? request.model
}
case "content_block_delta":
if let delta = event["delta"] as? [String: Any],
let deltaType = delta["type"] as? String,
deltaType == "text_delta",
let text = delta["text"] as? String {
continuation.yield(StreamChunk(
id: currentId,
model: currentModel,
delta: .init(content: text, role: nil, images: nil),
finishReason: nil,
usage: nil
))
}
case "message_delta":
let delta = event["delta"] as? [String: Any]
let stopReason = delta?["stop_reason"] as? String
var usage: ChatResponse.Usage? = nil
if let usageDict = event["usage"] as? [String: Any] {
let outputTokens = usageDict["output_tokens"] as? Int ?? 0
usage = ChatResponse.Usage(promptTokens: 0, completionTokens: outputTokens, totalTokens: outputTokens)
}
continuation.yield(StreamChunk(
id: currentId,
model: currentModel,
delta: .init(content: nil, role: nil, images: nil),
finishReason: stopReason,
usage: usage
))
case "message_stop":
continuation.finish()
return
default:
break
}
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
// MARK: - Credits
func getCredits() async throws -> Credits? {
// Anthropic doesn't have a public credits API
return nil
}
// MARK: - Auth Helpers
/// Apply auth headers based on mode (API key or OAuth Bearer)
private func applyAuth(to request: inout URLRequest) async throws {
switch authMode {
case .apiKey(let key):
request.addValue(key, forHTTPHeaderField: "x-api-key")
request.addValue(apiVersion, forHTTPHeaderField: "anthropic-version")
case .oauth:
let token = try await AnthropicOAuthService.shared.getValidAccessToken()
request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.addValue(apiVersion, forHTTPHeaderField: "anthropic-version")
request.addValue("oauth-2025-04-20,interleaved-thinking-2025-05-14", forHTTPHeaderField: "anthropic-beta")
}
}
/// Build the messages endpoint URL, appending ?beta=true for OAuth
private var messagesURL: URL {
switch authMode {
case .apiKey:
return URL(string: "\(baseURL)/messages")!
case .oauth:
return URL(string: "\(baseURL)/messages?beta=true")!
}
}
// MARK: - Request Building
private func buildURLRequest(from request: ChatRequest, stream: Bool) throws -> (URLRequest, Data) {
let url = messagesURL
// Separate system message
var systemText: String? = request.systemPrompt
var apiMessages: [[String: Any]] = []
for msg in request.messages {
if msg.role == .system {
systemText = msg.content
continue
}
let hasAttachments = msg.attachments?.contains(where: { $0.data != nil }) ?? false
if hasAttachments, let attachments = msg.attachments {
var contentBlocks: [[String: Any]] = []
contentBlocks.append(["type": "text", "text": msg.content])
for attachment in attachments {
guard let data = attachment.data else { continue }
switch attachment.type {
case .image, .pdf:
let base64 = data.base64EncodedString()
contentBlocks.append([
"type": "image",
"source": [
"type": "base64",
"media_type": attachment.mimeType,
"data": base64
]
])
case .text:
let filename = (attachment.path as NSString).lastPathComponent
let textContent = String(data: data, encoding: .utf8) ?? ""
contentBlocks.append(["type": "text", "text": "File: \(filename)\n\n\(textContent)"])
}
}
apiMessages.append(["role": msg.role.rawValue, "content": contentBlocks])
} else {
apiMessages.append(["role": msg.role.rawValue, "content": msg.content])
}
}
var body: [String: Any] = [
"model": request.model,
"messages": apiMessages,
"max_tokens": request.maxTokens ?? 4096,
"stream": stream
]
if let systemText = systemText {
body["system"] = systemText
}
if let temperature = request.temperature {
body["temperature"] = temperature
}
var toolsArray: [[String: Any]] = []
if let tools = request.tools {
toolsArray += tools.map { tool -> [String: Any] in
[
"name": tool.function.name,
"description": tool.function.description,
"input_schema": convertParametersToDict(tool.function.parameters)
]
}
}
if request.onlineMode {
toolsArray.append([
"type": "web_search_20250305",
"name": "web_search",
"max_uses": 5
])
}
if !toolsArray.isEmpty {
body["tools"] = toolsArray
if request.tools != nil {
body["tool_choice"] = ["type": "auto"]
}
}
let bodyData = try JSONSerialization.data(withJSONObject: body)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
if stream {
urlRequest.addValue("text/event-stream", forHTTPHeaderField: "Accept")
}
urlRequest.httpBody = bodyData
// Auth is applied async in the caller (chat/streamChat)
return (urlRequest, bodyData)
}
private func parseResponse(data: Data) throws -> ChatResponse {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw ProviderError.invalidResponse
}
let id = json["id"] as? String ?? ""
let model = json["model"] as? String ?? ""
let contentBlocks = json["content"] as? [[String: Any]] ?? []
var textContent = ""
var toolCalls: [ToolCallInfo] = []
for block in contentBlocks {
let blockType = block["type"] as? String ?? ""
switch blockType {
case "text":
textContent += block["text"] as? String ?? ""
case "tool_use":
let tcId = block["id"] as? String ?? UUID().uuidString
let tcName = block["name"] as? String ?? ""
let tcInput = block["input"] ?? [:]
let argsData = try JSONSerialization.data(withJSONObject: tcInput)
let argsStr = String(data: argsData, encoding: .utf8) ?? "{}"
toolCalls.append(ToolCallInfo(id: tcId, type: "function", functionName: tcName, arguments: argsStr))
default:
break
}
}
let usageDict = json["usage"] as? [String: Any]
let inputTokens = usageDict?["input_tokens"] as? Int ?? 0
let outputTokens = usageDict?["output_tokens"] as? Int ?? 0
return ChatResponse(
id: id,
model: model,
content: textContent,
role: "assistant",
finishReason: json["stop_reason"] as? String,
usage: ChatResponse.Usage(
promptTokens: inputTokens,
completionTokens: outputTokens,
totalTokens: inputTokens + outputTokens
),
created: Date(),
toolCalls: toolCalls.isEmpty ? nil : toolCalls
)
}
private func convertParametersToDict(_ params: Tool.Function.Parameters) -> [String: Any] {
var props: [String: Any] = [:]
for (key, prop) in params.properties {
var propDict: [String: Any] = [
"type": prop.type,
"description": prop.description
]
if let enumVals = prop.enum {
propDict["enum"] = enumVals
}
props[key] = propDict
}
var dict: [String: Any] = [
"type": params.type,
"properties": props
]
if let required = params.required {
dict["required"] = required
}
return dict
}
}

View File

@@ -0,0 +1,308 @@
//
// OllamaProvider.swift
// oAI
//
// Ollama local AI provider with JSON-lines streaming
//
import Foundation
import os
class OllamaProvider: AIProvider {
let name = "Ollama"
let capabilities = ProviderCapabilities(
supportsStreaming: true,
supportsVision: false,
supportsTools: false,
supportsOnlineSearch: false,
maxContextLength: nil
)
private let baseURL: String
private let session: URLSession
init(baseURL: String = "http://localhost:11434") {
self.baseURL = baseURL.hasSuffix("/") ? String(baseURL.dropLast()) : baseURL
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 120
config.timeoutIntervalForResource = 600
self.session = URLSession(configuration: config)
}
// MARK: - Models
func listModels() async throws -> [ModelInfo] {
Log.api.info("Fetching model list from Ollama at \(self.baseURL)")
let url = URL(string: "\(baseURL)/api/tags")!
var request = URLRequest(url: url)
request.timeoutInterval = 5
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
} catch {
Log.api.warning("Cannot connect to Ollama at \(self.baseURL). Is Ollama running?")
throw ProviderError.unknown("Cannot connect to Ollama at \(baseURL). Is Ollama running? Start it with: ollama serve")
}
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw ProviderError.unknown("Ollama returned an error. Is it running?")
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let modelsArray = json["models"] as? [[String: Any]] else {
return []
}
return modelsArray.compactMap { model -> ModelInfo? in
guard let name = model["name"] as? String else { return nil }
let sizeBytes = model["size"] as? Int64 ?? 0
let sizeGB = String(format: "%.1f GB", Double(sizeBytes) / 1_073_741_824)
return ModelInfo(
id: name,
name: name,
description: "Local model (\(sizeGB))",
contextLength: 0,
pricing: .init(prompt: 0, completion: 0),
capabilities: .init(vision: false, tools: false, online: false)
)
}
}
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("Ollama chat request: model=\(request.model), messages=\(request.messages.count)")
let url = URL(string: "\(baseURL)/api/chat")!
let body = buildRequestBody(from: request, stream: false)
let bodyData = try JSONSerialization.data(withJSONObject: body)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = bodyData
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("Ollama chat: invalid response (not HTTP)")
throw ProviderError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
if let errorObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let errorMsg = errorObj["error"] as? String {
Log.api.error("Ollama chat HTTP \(httpResponse.statusCode): \(errorMsg)")
throw ProviderError.unknown(errorMsg)
}
Log.api.error("Ollama chat HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("Ollama HTTP \(httpResponse.statusCode)")
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw ProviderError.invalidResponse
}
return parseOllamaResponse(json, model: request.model)
}
// MARK: - Chat with raw tool messages
func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse {
// Ollama doesn't support tool calls natively just send messages as plain chat
let url = URL(string: "\(baseURL)/api/chat")!
// Convert messages, stripping tool-specific fields
var ollamaMessages: [[String: Any]] = []
for msg in messages {
let role = msg["role"] as? String ?? "user"
let content = msg["content"] as? String ?? ""
if role == "tool" {
// Convert tool results to assistant context
let toolName = msg["name"] as? String ?? "tool"
ollamaMessages.append(["role": "user", "content": "[\(toolName) result]: \(content)"])
} else if role == "assistant" {
// Strip tool_calls, just keep content
if let tc = msg["tool_calls"] as? [[String: Any]], !tc.isEmpty {
let toolNames = tc.compactMap { ($0["function"] as? [String: Any])?["name"] as? String }
let text = (msg["content"] as? String) ?? ""
let combined = text.isEmpty ? "Calling: \(toolNames.joined(separator: ", "))" : text
ollamaMessages.append(["role": "assistant", "content": combined])
} else {
ollamaMessages.append(["role": "assistant", "content": content])
}
} else {
ollamaMessages.append(["role": role, "content": content])
}
}
var body: [String: Any] = [
"model": model,
"messages": ollamaMessages,
"stream": false
]
var options: [String: Any] = [:]
if let maxTokens = maxTokens { options["num_predict"] = maxTokens }
if let temperature = temperature { options["temperature"] = temperature }
if !options.isEmpty { body["options"] = options }
let bodyData = try JSONSerialization.data(withJSONObject: body)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = bodyData
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw ProviderError.unknown("Ollama error")
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw ProviderError.invalidResponse
}
return parseOllamaResponse(json, model: model)
}
// MARK: - Streaming Chat
func streamChat(request: ChatRequest) -> AsyncThrowingStream<StreamChunk, Error> {
Log.api.info("Ollama stream request: model=\(request.model), messages=\(request.messages.count)")
return AsyncThrowingStream { continuation in
Task {
do {
let url = URL(string: "\(baseURL)/api/chat")!
let body = buildRequestBody(from: request, stream: true)
let bodyData = try JSONSerialization.data(withJSONObject: body)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = bodyData
let (bytes, response) = try await session.bytes(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("Ollama stream: invalid response (not HTTP)")
continuation.finish(throwing: ProviderError.invalidResponse)
return
}
guard httpResponse.statusCode == 200 else {
Log.api.error("Ollama stream HTTP \(httpResponse.statusCode)")
continuation.finish(throwing: ProviderError.unknown("Ollama HTTP \(httpResponse.statusCode)"))
return
}
// Ollama streams JSON lines (one complete JSON object per line)
for try await line in bytes.lines {
guard !line.isEmpty,
let lineData = line.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] else {
continue
}
let done = json["done"] as? Bool ?? false
let message = json["message"] as? [String: Any]
let content = message?["content"] as? String
if done {
// Final chunk has usage stats
let promptTokens = json["prompt_eval_count"] as? Int ?? 0
let completionTokens = json["eval_count"] as? Int ?? 0
continuation.yield(StreamChunk(
id: "",
model: request.model,
delta: .init(content: content, role: nil, images: nil),
finishReason: "stop",
usage: ChatResponse.Usage(
promptTokens: promptTokens,
completionTokens: completionTokens,
totalTokens: promptTokens + completionTokens
)
))
continuation.finish()
return
} else if let content = content {
continuation.yield(StreamChunk(
id: "",
model: request.model,
delta: .init(content: content, role: nil, images: nil),
finishReason: nil,
usage: nil
))
}
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
// MARK: - Credits
func getCredits() async throws -> Credits? {
// Local models no credits needed
return nil
}
// MARK: - Helpers
private func buildRequestBody(from request: ChatRequest, stream: Bool) -> [String: Any] {
var messages: [[String: Any]] = []
// Add system prompt as a system message
if let systemPrompt = request.systemPrompt {
messages.append(["role": "system", "content": systemPrompt])
}
for msg in request.messages {
messages.append(["role": msg.role.rawValue, "content": msg.content])
}
var body: [String: Any] = [
"model": request.model,
"messages": messages,
"stream": stream
]
var options: [String: Any] = [:]
if let maxTokens = request.maxTokens { options["num_predict"] = maxTokens }
if let temperature = request.temperature { options["temperature"] = temperature }
if !options.isEmpty { body["options"] = options }
return body
}
private func parseOllamaResponse(_ json: [String: Any], model: String) -> ChatResponse {
let message = json["message"] as? [String: Any]
let content = message?["content"] as? String ?? ""
let promptTokens = json["prompt_eval_count"] as? Int ?? 0
let completionTokens = json["eval_count"] as? Int ?? 0
return ChatResponse(
id: UUID().uuidString,
model: model,
content: content,
role: "assistant",
finishReason: "stop",
usage: ChatResponse.Usage(
promptTokens: promptTokens,
completionTokens: completionTokens,
totalTokens: promptTokens + completionTokens
),
created: Date()
)
}
}

View File

@@ -0,0 +1,367 @@
//
// OpenAIProvider.swift
// oAI
//
// OpenAI API provider with SSE streaming and tool support
//
import Foundation
import os
class OpenAIProvider: AIProvider {
let name = "OpenAI"
let capabilities = ProviderCapabilities(
supportsStreaming: true,
supportsVision: true,
supportsTools: true,
supportsOnlineSearch: false,
maxContextLength: nil
)
private let apiKey: String
private let baseURL = "https://api.openai.com/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: - Models
/// Known models with pricing, used as fallback and for enrichment
private static let knownModels: [String: (name: String, desc: String?, ctx: Int, prompt: Double, completion: Double, vision: Bool)] = [
"gpt-4o": ("GPT-4o", "Most capable GPT-4 model", 128_000, 2.50, 10.0, true),
"gpt-4o-mini": ("GPT-4o Mini", "Affordable and fast", 128_000, 0.15, 0.60, true),
"gpt-4-turbo": ("GPT-4 Turbo", "GPT-4 Turbo with vision", 128_000, 10.0, 30.0, true),
"gpt-3.5-turbo": ("GPT-3.5 Turbo", "Fast and affordable", 16_385, 0.50, 1.50, false),
"o1": ("o1", "Advanced reasoning model", 200_000, 15.0, 60.0, true),
"o1-mini": ("o1 Mini", "Fast reasoning model", 128_000, 3.0, 12.0, false),
"o3-mini": ("o3 Mini", "Latest fast reasoning model", 200_000, 1.10, 4.40, false),
]
func listModels() async throws -> [ModelInfo] {
Log.api.info("Fetching model list from OpenAI")
let url = URL(string: "\(baseURL)/models")!
var request = URLRequest(url: url)
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
do {
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
Log.api.warning("OpenAI models endpoint failed, using fallback models")
return fallbackModels()
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let modelsArray = json["data"] as? [[String: Any]] else {
return fallbackModels()
}
// Filter to chat models
let chatModelIds = modelsArray
.compactMap { $0["id"] as? String }
.filter { id in
id.contains("gpt") || id.hasPrefix("o1") || id.hasPrefix("o3")
}
.sorted()
var models: [ModelInfo] = []
for id in chatModelIds {
if let known = Self.knownModels[id] {
models.append(ModelInfo(
id: id,
name: known.name,
description: known.desc,
contextLength: known.ctx,
pricing: .init(prompt: known.prompt, completion: known.completion),
capabilities: .init(vision: known.vision, tools: true, online: false)
))
} else {
models.append(ModelInfo(
id: id,
name: id,
description: nil,
contextLength: 128_000,
pricing: .init(prompt: 0, completion: 0),
capabilities: .init(vision: false, tools: true, online: false)
))
}
}
Log.api.info("OpenAI loaded \(models.count) models")
return models.isEmpty ? fallbackModels() : models
} catch {
Log.api.warning("OpenAI models fetch failed: \(error.localizedDescription), using fallback")
return fallbackModels()
}
}
private func fallbackModels() -> [ModelInfo] {
Self.knownModels.map { id, info in
ModelInfo(
id: id,
name: info.name,
description: info.desc,
contextLength: info.ctx,
pricing: .init(prompt: info.prompt, completion: info.completion),
capabilities: .init(vision: info.vision, tools: true, online: false)
)
}.sorted { $0.name < $1.name }
}
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("OpenAI chat request: model=\(request.model), messages=\(request.messages.count)")
let urlRequest = try buildURLRequest(from: request, stream: false)
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("OpenAI chat: invalid response (not HTTP)")
throw ProviderError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
if let errorObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let error = errorObj["error"] as? [String: Any],
let message = error["message"] as? String {
Log.api.error("OpenAI chat HTTP \(httpResponse.statusCode): \(message)")
throw ProviderError.unknown(message)
}
Log.api.error("OpenAI chat HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
// Reuse the OpenRouter response format OpenAI is identical
let apiResponse = try JSONDecoder().decode(OpenRouterChatResponse.self, from: data)
return convertToChatResponse(apiResponse)
}
// MARK: - Chat with raw tool messages
func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse {
Log.api.info("OpenAI tool chat: model=\(model), messages=\(messages.count)")
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 }
// o1/o3 models don't support temperature
if let temperature = temperature, !model.hasPrefix("o1"), !model.hasPrefix("o3") {
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.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("OpenAI tool chat: invalid response (not HTTP)")
throw ProviderError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
if let errorObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let error = errorObj["error"] as? [String: Any],
let message = error["message"] as? String {
Log.api.error("OpenAI tool chat HTTP \(httpResponse.statusCode): \(message)")
throw ProviderError.unknown(message)
}
Log.api.error("OpenAI tool chat HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
let apiResponse = try JSONDecoder().decode(OpenRouterChatResponse.self, from: data)
return convertToChatResponse(apiResponse)
}
// MARK: - Streaming Chat
func streamChat(request: ChatRequest) -> AsyncThrowingStream<StreamChunk, Error> {
Log.api.info("OpenAI stream request: model=\(request.model), messages=\(request.messages.count)")
return AsyncThrowingStream { continuation in
Task {
do {
let urlRequest = try buildURLRequest(from: request, stream: true)
let (bytes, response) = try await session.bytes(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("OpenAI stream: invalid response (not HTTP)")
continuation.finish(throwing: ProviderError.invalidResponse)
return
}
guard httpResponse.statusCode == 200 else {
Log.api.error("OpenAI stream HTTP \(httpResponse.statusCode)")
continuation.finish(throwing: ProviderError.unknown("HTTP \(httpResponse.statusCode)"))
return
}
var buffer = ""
for try await line in bytes.lines {
guard line.hasPrefix("data: ") else { continue }
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)
guard let choice = chunk.choices.first else { continue }
continuation.yield(StreamChunk(
id: chunk.id,
model: chunk.model,
delta: .init(content: choice.delta.content, role: choice.delta.role, images: nil),
finishReason: choice.finishReason,
usage: chunk.usage.map {
ChatResponse.Usage(promptTokens: $0.promptTokens, completionTokens: $0.completionTokens, totalTokens: $0.totalTokens)
}
))
buffer = ""
} catch {
continue // Partial JSON, keep buffering
}
}
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
// MARK: - Credits
func getCredits() async throws -> Credits? {
// OpenAI doesn't have a public credits API
return nil
}
// MARK: - Helpers
private func buildURLRequest(from request: ChatRequest, stream: Bool) throws -> URLRequest {
let url = URL(string: "\(baseURL)/chat/completions")!
var apiMessages: [[String: Any]] = []
// Add system prompt if present
if let systemPrompt = request.systemPrompt {
apiMessages.append(["role": "system", "content": systemPrompt])
}
for msg in request.messages {
let hasAttachments = msg.attachments?.contains(where: { $0.data != nil }) ?? false
if hasAttachments, let attachments = msg.attachments {
// Multi-part content (OpenAI vision format)
var contentArray: [[String: Any]] = [
["type": "text", "text": msg.content]
]
for attachment in attachments {
guard let data = attachment.data else { continue }
switch attachment.type {
case .image, .pdf:
let base64 = data.base64EncodedString()
let dataURL = "data:\(attachment.mimeType);base64,\(base64)"
contentArray.append([
"type": "image_url",
"image_url": ["url": dataURL]
])
case .text:
let filename = (attachment.path as NSString).lastPathComponent
let textContent = String(data: data, encoding: .utf8) ?? ""
contentArray.append(["type": "text", "text": "File: \(filename)\n\n\(textContent)"])
}
}
apiMessages.append(["role": msg.role.rawValue, "content": contentArray])
} else {
apiMessages.append(["role": msg.role.rawValue, "content": msg.content])
}
}
var body: [String: Any] = [
"model": request.model,
"messages": apiMessages,
"stream": stream
]
if let maxTokens = request.maxTokens { body["max_tokens"] = maxTokens }
// o1/o3 reasoning models don't support temperature
if let temperature = request.temperature, !request.model.hasPrefix("o1"), !request.model.hasPrefix("o3") {
body["temperature"] = temperature
}
if let tools = request.tools {
let toolsData = try JSONEncoder().encode(tools)
body["tools"] = try JSONSerialization.jsonObject(with: toolsData)
body["tool_choice"] = "auto"
}
if stream {
// Request usage in streaming mode
body["stream_options"] = ["include_usage": true]
}
let bodyData = try JSONSerialization.data(withJSONObject: body)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
if stream {
urlRequest.addValue("text/event-stream", forHTTPHeaderField: "Accept")
}
urlRequest.httpBody = bodyData
return urlRequest
}
private func convertToChatResponse(_ apiResponse: OpenRouterChatResponse) -> ChatResponse {
guard let choice = apiResponse.choices.first else {
return ChatResponse(id: apiResponse.id, model: apiResponse.model, content: "", role: "assistant", finishReason: nil, usage: nil, created: Date())
}
let toolCalls: [ToolCallInfo]? = choice.message.toolCalls?.map { tc in
ToolCallInfo(id: tc.id, type: tc.type, functionName: tc.function.name, arguments: tc.function.arguments)
}
return ChatResponse(
id: apiResponse.id,
model: apiResponse.model,
content: choice.message.content ?? "",
role: choice.message.role,
finishReason: choice.finishReason,
usage: apiResponse.usage.map {
ChatResponse.Usage(promptTokens: $0.promptTokens, completionTokens: $0.completionTokens, totalTokens: $0.totalTokens)
},
created: Date(timeIntervalSince1970: TimeInterval(apiResponse.created)),
toolCalls: toolCalls
)
}
}

View File

@@ -0,0 +1,313 @@
//
// OpenRouterModels.swift
// oAI
//
// OpenRouter API request and response models
//
import Foundation
// MARK: - API Request
struct OpenRouterChatRequest: Codable {
let model: String
let messages: [APIMessage]
var stream: Bool
let maxTokens: Int?
let temperature: Double?
let topP: Double?
let tools: [Tool]?
let toolChoice: String?
let modalities: [String]?
struct APIMessage: Codable {
let role: String
let content: MessageContent
enum MessageContent: Codable {
case string(String)
case array([ContentItem])
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let str = try? container.decode(String.self) {
self = .string(str)
} else if let arr = try? container.decode([ContentItem].self) {
self = .array(arr)
} else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid content")
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .string(let str):
try container.encode(str)
case .array(let arr):
try container.encode(arr)
}
}
}
enum ContentItem: Codable {
case text(String)
case image(ImageContent)
struct TextContent: Codable {
let type: String // "text"
let text: String
}
struct ImageContent: Codable {
let type: String // "image_url"
let imageUrl: ImageURL
struct ImageURL: Codable {
let url: String
}
enum CodingKeys: String, CodingKey {
case type
case imageUrl = "image_url"
}
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let textContent = try? container.decode(TextContent.self), textContent.type == "text" {
self = .text(textContent.text)
} else if let image = try? container.decode(ImageContent.self) {
self = .image(image)
} else if let str = try? container.decode(String.self) {
self = .text(str)
} else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid content item")
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .text(let text):
try container.encode(TextContent(type: "text", text: text))
case .image(let image):
try container.encode(image)
}
}
}
}
enum CodingKeys: String, CodingKey {
case model
case messages
case stream
case maxTokens = "max_tokens"
case temperature
case topP = "top_p"
case tools
case toolChoice = "tool_choice"
case modalities
}
}
// MARK: - API Response
struct OpenRouterChatResponse: Codable {
let id: String
let model: String
let choices: [Choice]
let usage: Usage?
let created: Int
struct Choice: Codable {
let index: Int
let message: MessageContent
let finishReason: String?
struct MessageContent: Codable {
let role: String
let content: String?
let toolCalls: [APIToolCall]?
let images: [ImageOutput]?
enum CodingKeys: String, CodingKey {
case role
case content
case toolCalls = "tool_calls"
case images
}
}
enum CodingKeys: String, CodingKey {
case index
case message
case finishReason = "finish_reason"
}
}
struct ImageOutput: Codable {
let imageUrl: ImageURL
struct ImageURL: Codable {
let url: String
}
enum CodingKeys: String, CodingKey {
case imageUrl = "image_url"
}
}
struct Usage: Codable {
let promptTokens: Int
let completionTokens: Int
let totalTokens: Int
enum CodingKeys: String, CodingKey {
case promptTokens = "prompt_tokens"
case completionTokens = "completion_tokens"
case totalTokens = "total_tokens"
}
}
}
// MARK: - Streaming Response
struct OpenRouterStreamChunk: Codable {
let id: String
let model: String
let choices: [StreamChoice]
let usage: OpenRouterChatResponse.Usage?
struct StreamChoice: Codable {
let index: Int
let delta: Delta
let finishReason: String?
struct Delta: Codable {
let role: String?
let content: String?
let images: [OpenRouterChatResponse.ImageOutput]?
}
enum CodingKeys: String, CodingKey {
case index
case delta
case finishReason = "finish_reason"
}
}
}
// MARK: - Models List
struct OpenRouterModelsResponse: Codable {
let data: [ModelData]
struct ModelData: Codable {
let id: String
let name: String
let description: String?
let contextLength: Int
let pricing: PricingData
let architecture: Architecture?
let supportedParameters: [String]?
let outputModalities: [String]?
struct PricingData: Codable {
let prompt: String
let completion: String
}
struct Architecture: Codable {
let modality: String?
let tokenizer: String?
let instructType: String?
enum CodingKeys: String, CodingKey {
case modality
case tokenizer
case instructType = "instruct_type"
}
}
enum CodingKeys: String, CodingKey {
case id
case name
case description
case contextLength = "context_length"
case pricing
case architecture
case supportedParameters = "supported_parameters"
case outputModalities = "output_modalities"
}
}
}
// MARK: - Credits Response
struct OpenRouterCreditsResponse: Codable {
let data: CreditsData
struct CreditsData: Codable {
let totalCredits: Double?
let totalUsage: Double?
enum CodingKeys: String, CodingKey {
case totalCredits = "total_credits"
case totalUsage = "total_usage"
}
}
}
// MARK: - Tool Call Models
struct APIToolCall: Codable {
let id: String
let type: String
let function: FunctionCall
struct FunctionCall: Codable {
let name: String
let arguments: String
}
}
/// Message shape for encoding assistant messages that contain tool calls
struct AssistantToolCallMessage: Encodable {
let role: String
let content: String?
let toolCalls: [APIToolCall]
enum CodingKeys: String, CodingKey {
case role
case content
case toolCalls = "tool_calls"
}
}
/// Message shape for encoding tool result messages back to the API
struct ToolResultMessage: Encodable {
let role: String // "tool"
let toolCallId: String
let name: String
let content: String
enum CodingKeys: String, CodingKey {
case role
case toolCallId = "tool_call_id"
case name
case content
}
}
// MARK: - Error Response
struct OpenRouterErrorResponse: Codable {
let error: ErrorDetail
struct ErrorDetail: Codable {
let message: String
let type: String?
let code: String?
}
}

View 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
}
}

View File

@@ -0,0 +1,102 @@
//
// ProviderRegistry.swift
// oAI
//
// Registry for managing multiple AI providers
//
import Foundation
import os
class ProviderRegistry {
static let shared = ProviderRegistry()
private var providers: [Settings.Provider: AIProvider] = [:]
private let settings = SettingsService.shared
private init() {}
// MARK: - Get Provider
func getProvider(for providerType: Settings.Provider) -> AIProvider? {
// Return cached provider if exists
if let provider = providers[providerType] {
return provider
}
// Create new provider based on type
let provider: AIProvider?
switch providerType {
case .openrouter:
guard let apiKey = settings.openrouterAPIKey, !apiKey.isEmpty else {
Log.api.warning("No API key configured for OpenRouter")
return nil
}
provider = OpenRouterProvider(apiKey: apiKey)
case .anthropic:
if AnthropicOAuthService.shared.isAuthenticated {
// OAuth (Pro/Max subscription) takes precedence
provider = AnthropicProvider(oauth: true)
} else if let apiKey = settings.anthropicAPIKey, !apiKey.isEmpty {
provider = AnthropicProvider(apiKey: apiKey)
} else {
Log.api.warning("No API key or OAuth configured for Anthropic")
return nil
}
case .openai:
guard let apiKey = settings.openaiAPIKey, !apiKey.isEmpty else {
Log.api.warning("No API key configured for OpenAI")
return nil
}
provider = OpenAIProvider(apiKey: apiKey)
case .ollama:
provider = OllamaProvider(baseURL: settings.ollamaEffectiveURL)
}
// Cache and return
if let provider = provider {
Log.api.info("Created \(providerType.rawValue) provider")
providers[providerType] = provider
}
return provider
}
// MARK: - Current Provider
func getCurrentProvider() -> AIProvider? {
let currentProviderType = settings.defaultProvider
return getProvider(for: currentProviderType)
}
// MARK: - Clear Cache
func clearCache() {
providers.removeAll()
}
// MARK: - Validate API Key
func hasValidAPIKey(for providerType: Settings.Provider) -> Bool {
switch providerType {
case .openrouter:
return settings.openrouterAPIKey != nil && !settings.openrouterAPIKey!.isEmpty
case .anthropic:
return AnthropicOAuthService.shared.isAuthenticated
|| (settings.anthropicAPIKey != nil && !settings.anthropicAPIKey!.isEmpty)
case .openai:
return settings.openaiAPIKey != nil && !settings.openaiAPIKey!.isEmpty
case .ollama:
return settings.ollamaConfigured
}
}
/// Providers that have credentials configured (API key or, for Ollama, a saved URL)
var configuredProviders: [Settings.Provider] {
Settings.Provider.allCases.filter { hasValidAPIKey(for: $0) }
}
}