386 lines
16 KiB
Swift
386 lines
16 KiB
Swift
//
|
|
// OpenAIProvider.swift
|
|
// oAI
|
|
//
|
|
// OpenAI API provider with SSE streaming and tool support
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// Copyright (C) 2026 Rune Olsen
|
|
//
|
|
// This file is part of oAI.
|
|
//
|
|
// oAI is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as
|
|
// published by the Free Software Foundation, either version 3 of the
|
|
// License, or (at your option) any later version.
|
|
//
|
|
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
|
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
|
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
|
// Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public
|
|
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
|
|
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
|
|
)
|
|
}
|
|
}
|