Files
luma/Sources/LumaCore/Agentic/OpenAIProvider.swift
Sunwoo Moon 03b5fb8791 Add a second OpenAI-compatible mission provider
Fold the local and URL providers into one configurable
OpenAICompatibleProvider and register both, so a remote
endpoint can be set alongside the local one. Accept optional
API keys for compatible providers in the mission sheet, and
collapse duplicate /v1 segments when building request URLs.
2026-07-06 17:31:43 +02:00

494 lines
16 KiB
Swift

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
public struct OpenAIProvider: LLMProvider {
public static let providerID = "openai"
public static let defaultBaseURL = URL(string: "https://api.openai.com")!
public let descriptor: LLMProviderDescriptor
private let session: URLSession
public init(session: URLSession = .shared, baseURL: URL = OpenAIProvider.defaultBaseURL) {
self.session = session
self.descriptor = LLMProviderDescriptor(
id: Self.providerID,
displayName: "OpenAI",
capabilities: LLMProviderCapabilities(
supported: [.streaming, .thinking, .toolUse, .apiKey]
),
defaultModelID: nil,
summarizationModelID: nil,
defaultBaseURL: baseURL
)
}
public func suggestedModels(apiKey: String?, baseURL: URL?) async throws -> [LLMModelInfo] {
try await fetchOpenAICompatibleModels(
session: session,
baseURL: baseURL ?? descriptor.defaultBaseURL,
apiKey: apiKey
)
}
public func streamTurn(
_ request: LLMTurnRequest,
apiKey: String?,
baseURL: URL?
) -> AsyncThrowingStream<LLMTurnEvent, Error> {
runOpenAICompatibleStream(
request: request,
apiKey: apiKey,
baseURL: baseURL ?? descriptor.defaultBaseURL,
session: session,
requiresAPIKey: descriptor.capabilities.supports(.apiKey)
)
}
}
public func describeModelFetchError(_ error: Error, baseURL: URL?) -> String {
if let urlError = error as? URLError {
switch urlError.code {
case .cannotConnectToHost, .cannotFindHost, .timedOut, .networkConnectionLost, .dnsLookupFailed, .notConnectedToInternet:
if let baseURL {
return "Could not reach \(baseURL.absoluteString)"
}
return "Could not reach server"
default:
break
}
}
return "Failed to load models: \(error.localizedDescription)"
}
func fetchOpenAICompatibleModels(
session: URLSession,
baseURL: URL,
apiKey: String?
) async throws -> [LLMModelInfo] {
let url = openAICompatibleURL(baseURL: baseURL, path: "/v1/models")
var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "accept")
if let apiKey, !apiKey.isEmpty {
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "authorization")
}
let (data, response) = try await session.data(for: request)
if let http = response as? HTTPURLResponse, http.statusCode != 200 {
let body = String(data: data, encoding: .utf8) ?? ""
throw LLMProviderError.requestFailed(status: http.statusCode, message: body)
}
guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let entries = object["data"] as? [[String: Any]]
else {
throw LLMProviderError.requestFailed(status: 0, message: "Unexpected response from \(url.absoluteString)")
}
return entries.compactMap { entry -> LLMModelInfo? in
guard let id = entry["id"] as? String else { return nil }
return LLMModelInfo(
id: id,
displayName: id,
contextWindow: 128_000,
maxOutput: 16_384
)
}
.sorted { $0.id < $1.id }
}
func runOpenAICompatibleStream(
request: LLMTurnRequest,
apiKey: String?,
baseURL: URL,
session: URLSession,
requiresAPIKey: Bool
) -> AsyncThrowingStream<LLMTurnEvent, Error> {
AsyncThrowingStream<LLMTurnEvent, Error> { continuation in
let work = Task<Void, Never> {
do {
try await driveOpenAIStream(
request: request,
apiKey: apiKey,
baseURL: baseURL,
session: session,
requiresAPIKey: requiresAPIKey,
continuation: continuation
)
continuation.finish()
} catch is CancellationError {
continuation.finish(throwing: LLMProviderError.cancelled)
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in work.cancel() }
}
}
private func driveOpenAIStream(
request: LLMTurnRequest,
apiKey: String?,
baseURL: URL,
session: URLSession,
requiresAPIKey: Bool,
continuation: AsyncThrowingStream<LLMTurnEvent, Error>.Continuation
) async throws {
if requiresAPIKey {
guard let apiKey, !apiKey.isEmpty else {
throw LLMProviderError.missingAPIKey
}
}
let url = openAICompatibleURL(baseURL: baseURL, path: "/v1/chat/completions")
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
if let apiKey, !apiKey.isEmpty {
urlRequest.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
}
urlRequest.setValue("application/json", forHTTPHeaderField: "content-type")
urlRequest.setValue("text/event-stream", forHTTPHeaderField: "accept")
let body = buildOpenAIRequestBody(request)
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
let sse = try await openServerSentEventStream(session: session, request: urlRequest)
let http = sse.http
if http.statusCode != 200 {
var errorBody = ""
for try await line in sse.lines {
errorBody.append(line)
errorBody.append("\n")
if errorBody.count > 8192 { break }
}
throw LLMProviderError.requestFailed(status: http.statusCode, message: errorBody)
}
var accumulator = OpenAIResponseAccumulator()
for try await line in sse.lines {
try Task.checkCancellation()
if line.isEmpty || line.hasPrefix(":") { continue }
guard line.hasPrefix("data:") else { continue }
let payload = String(line.dropFirst("data:".count)).trimmingCharacters(in: CharacterSet.whitespaces)
if payload == "[DONE]" {
break
}
let data = Data(payload.utf8)
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { continue }
try handleOpenAIChunk(object, accumulator: &accumulator, continuation: continuation)
}
let blocks = accumulator.assembledBlocks()
if !blocks.isEmpty {
continuation.yield(.finalMessage(role: .assistant, blocks: blocks))
}
continuation.yield(.messageStop(accumulator.stopReason ?? .endTurn))
}
private func openAICompatibleURL(baseURL: URL, path: String) -> URL {
var url = baseURL
let normalizedPath = path.hasPrefix("/") ? String(path.dropFirst()) : path
let components = url.path.split(separator: "/").map(String.init)
if components.contains("v1"), normalizedPath.hasPrefix("v1/") {
url.append(path: String(normalizedPath.dropFirst(3)))
} else {
url.append(path: normalizedPath)
}
return url
}
private func buildOpenAIRequestBody(_ request: LLMTurnRequest) -> [String: Any] {
let messages = openAIMessages(systemBlocks: request.systemBlocks, conversation: request.messages)
var body: [String: Any] = [
"model": request.modelID,
"messages": messages,
"stream": true,
"stream_options": ["include_usage": true],
]
if request.maxOutputTokens > 0 {
body["max_completion_tokens"] = request.maxOutputTokens
}
if let temperature = request.temperature {
body["temperature"] = temperature
}
if !request.tools.isEmpty {
body["tools"] = openAITools(request.tools)
}
if request.thinkingBudget >= 1024 {
body["reasoning_effort"] = "medium"
}
_ = messages
return body
}
private func openAIMessages(systemBlocks: [LLMContentBlock], conversation: [LLMMessage]) -> [[String: Any]] {
var out: [[String: Any]] = []
let systemText = systemBlocks.compactMap { block -> String? in
if case .text(let t) = block.content { return t }
return nil
}.joined(separator: "\n\n")
if !systemText.isEmpty {
out.append(["role": "system", "content": systemText])
}
for message in conversation {
switch message.role {
case .user:
out.append(contentsOf: openAIUserMessages(blocks: message.blocks))
case .assistant:
out.append(openAIAssistantMessage(blocks: message.blocks))
}
}
return out
}
private func openAIUserMessages(blocks: [LLMContentBlock]) -> [[String: Any]] {
var out: [[String: Any]] = []
var pendingText = ""
for block in blocks {
switch block.content {
case .text(let t):
if !pendingText.isEmpty { pendingText.append("\n\n") }
pendingText.append(t)
case .toolResult(let id, let content, _, let attachments):
if !pendingText.isEmpty {
out.append(["role": "user", "content": pendingText])
pendingText = ""
}
out.append([
"role": "tool",
"tool_call_id": id,
"content": content,
])
if !attachments.isEmpty {
var parts: [[String: Any]] = [[
"type": "text",
"text": "Attachments for the preceding tool result:",
]]
for attachment in attachments where attachment.kind == .image {
parts.append([
"type": "image_url",
"image_url": [
"url": "data:\(attachment.mediaType);base64,\(attachment.base64)",
],
])
}
out.append(["role": "user", "content": parts])
}
default:
break
}
}
if !pendingText.isEmpty {
out.append(["role": "user", "content": pendingText])
}
return out
}
private func openAIAssistantMessage(blocks: [LLMContentBlock]) -> [String: Any] {
var content = ""
var toolCalls: [[String: Any]] = []
for block in blocks {
switch block.content {
case .text(let t):
if !content.isEmpty { content.append("\n") }
content.append(t)
case .toolUse(let id, let name, let inputJSON):
toolCalls.append([
"id": id,
"type": "function",
"function": [
"name": name,
"arguments": inputJSON,
],
])
default:
break
}
}
var msg: [String: Any] = ["role": "assistant"]
if !content.isEmpty { msg["content"] = content }
if !toolCalls.isEmpty { msg["tool_calls"] = toolCalls }
return msg
}
private func openAITools(_ tools: [LLMToolSpec]) -> [[String: Any]] {
tools.map { tool in
let schema = (try? JSONSerialization.jsonObject(with: Data(tool.inputSchemaJSON.utf8))) ?? ["type": "object", "properties": [:]]
return [
"type": "function",
"function": [
"name": tool.name,
"description": tool.description,
"parameters": schema,
],
]
}
}
private func handleOpenAIChunk(
_ chunk: [String: Any],
accumulator: inout OpenAIResponseAccumulator,
continuation: AsyncThrowingStream<LLMTurnEvent, Error>.Continuation
) throws {
if let usage = chunk["usage"] as? [String: Any] {
let u = decodeOpenAIUsage(usage)
accumulator.usage = u
continuation.yield(.usage(u))
}
guard let choices = chunk["choices"] as? [[String: Any]],
let choice = choices.first
else { return }
if let delta = choice["delta"] as? [String: Any] {
if let text = delta["content"] as? String, !text.isEmpty {
accumulator.appendText(text)
continuation.yield(.textDelta(text))
}
if let reasoning = delta["reasoning_content"] as? String, !reasoning.isEmpty {
accumulator.appendReasoning(reasoning)
continuation.yield(.thinkingDelta(reasoning))
}
if let toolCalls = delta["tool_calls"] as? [[String: Any]] {
for callDelta in toolCalls {
applyToolCallDelta(callDelta, accumulator: &accumulator, continuation: continuation)
}
}
}
if let finish = choice["finish_reason"] as? String {
accumulator.stopReason = mapOpenAIFinishReason(finish)
}
}
private func applyToolCallDelta(
_ callDelta: [String: Any],
accumulator: inout OpenAIResponseAccumulator,
continuation: AsyncThrowingStream<LLMTurnEvent, Error>.Continuation
) {
guard let index = callDelta["index"] as? Int else { return }
let id = callDelta["id"] as? String
let function = callDelta["function"] as? [String: Any]
let name = function?["name"] as? String
let argsDelta = function?["arguments"] as? String
let entry = accumulator.toolCallEntry(index: index)
let wasNew = entry.id.isEmpty && id != nil
if let id { accumulator.setToolCallID(index: index, id: id) }
if let name { accumulator.setToolCallName(index: index, name: name) }
if let argsDelta {
accumulator.appendToolCallArgs(index: index, partial: argsDelta)
if let resolvedID = accumulator.toolCallEntry(index: index).id.nilIfEmpty {
continuation.yield(.toolUseInputDelta(id: resolvedID, partialJSON: argsDelta))
}
}
if wasNew, let resolvedID = accumulator.toolCallEntry(index: index).id.nilIfEmpty,
let resolvedName = accumulator.toolCallEntry(index: index).name.nilIfEmpty
{
continuation.yield(.toolUseStarted(id: resolvedID, name: resolvedName))
}
}
private func mapOpenAIFinishReason(_ reason: String) -> LLMStopReason {
switch reason {
case "stop": return .endTurn
case "length": return .maxTokens
case "tool_calls", "function_call": return .toolUse
case "content_filter": return .refusal
default: return .endTurn
}
}
private func decodeOpenAIUsage(_ obj: [String: Any]) -> LLMUsage {
var u = LLMUsage.zero
if let v = obj["prompt_tokens"] as? Int { u.inputTokens = v }
if let v = obj["completion_tokens"] as? Int { u.outputTokens = v }
if let details = obj["prompt_tokens_details"] as? [String: Any],
let cached = details["cached_tokens"] as? Int
{
u.cacheReadTokens = cached
}
return u
}
private struct OpenAIToolCallEntry {
var id: String = ""
var name: String = ""
var argsJSON: String = ""
}
private struct OpenAIResponseAccumulator {
var text: String = ""
var reasoning: String = ""
var toolCallsByIndex: [Int: OpenAIToolCallEntry] = [:]
var toolCallOrder: [Int] = []
var usage: LLMUsage = .zero
var stopReason: LLMStopReason?
mutating func appendText(_ s: String) { text.append(s) }
mutating func appendReasoning(_ s: String) { reasoning.append(s) }
mutating func toolCallEntry(index: Int) -> OpenAIToolCallEntry {
if let existing = toolCallsByIndex[index] { return existing }
let entry = OpenAIToolCallEntry()
toolCallsByIndex[index] = entry
toolCallOrder.append(index)
return entry
}
mutating func setToolCallID(index: Int, id: String) {
var entry = toolCallEntry(index: index)
entry.id = id
toolCallsByIndex[index] = entry
}
mutating func setToolCallName(index: Int, name: String) {
var entry = toolCallEntry(index: index)
entry.name = name
toolCallsByIndex[index] = entry
}
mutating func appendToolCallArgs(index: Int, partial: String) {
var entry = toolCallEntry(index: index)
entry.argsJSON.append(partial)
toolCallsByIndex[index] = entry
}
func assembledBlocks() -> [LLMContentBlock] {
var blocks: [LLMContentBlock] = []
if !reasoning.isEmpty {
blocks.append(LLMContentBlock(content: .thinking(text: reasoning, signature: "")))
}
if !text.isEmpty {
blocks.append(LLMContentBlock(content: .text(text)))
}
for index in toolCallOrder {
guard let entry = toolCallsByIndex[index],
!entry.id.isEmpty,
!entry.name.isEmpty
else { continue }
let inputJSON = entry.argsJSON.isEmpty ? "{}" : entry.argsJSON
blocks.append(LLMContentBlock(content: .toolUse(id: entry.id, name: entry.name, inputJSON: inputJSON)))
}
return blocks
}
}
private extension String {
var nilIfEmpty: String? { isEmpty ? nil : self }
}