Initial commit
This commit is contained in:
241
oAI/Providers/AIProvider.swift
Normal file
241
oAI/Providers/AIProvider.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
534
oAI/Providers/AnthropicProvider.swift
Normal file
534
oAI/Providers/AnthropicProvider.swift
Normal 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
|
||||
}
|
||||
}
|
||||
308
oAI/Providers/OllamaProvider.swift
Normal file
308
oAI/Providers/OllamaProvider.swift
Normal 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()
|
||||
)
|
||||
}
|
||||
}
|
||||
367
oAI/Providers/OpenAIProvider.swift
Normal file
367
oAI/Providers/OpenAIProvider.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
313
oAI/Providers/OpenRouterModels.swift
Normal file
313
oAI/Providers/OpenRouterModels.swift
Normal 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?
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
102
oAI/Providers/ProviderRegistry.swift
Normal file
102
oAI/Providers/ProviderRegistry.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user