| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952 |
- //===----------------------------------------------------------------------===//
- //
- // This source file is part of the Foundation Models open source project.
- //
- // Copyright © 2024-2027 Apple Inc. and the Foundation Models project authors.
- //
- // Licensed under the Apache License v2.0
- //
- // See LICENSE.txt for license information
- //
- //===----------------------------------------------------------------------===//
- public import Foundation
- #if canImport(FoundationNetworking)
- public import FoundationNetworking
- #endif
- public import FoundationModels
- #if canImport(CoreImage)
- private import CoreImage
- private import UniformTypeIdentifiers
- #endif
- /// A `LanguageModel` that talks to any OpenAI-compatible
- /// `/chat/completions` endpoint, streaming results back through the
- /// Foundation Models framework.
- ///
- /// Use this model to drive a `LanguageModelSession` against any remote
- /// service that implements the OpenAI Chat Completions API.
- ///
- /// ```swift
- /// let model = ChatCompletionsLanguageModel(
- /// name: "your-model-name",
- /// url: URL(string: "https://api.example.com")!,
- /// additionalHeaders: ["Authorization": "Bearer \(apiKey)"]
- /// )
- ///
- /// let session = LanguageModelSession(model: model)
- /// let response = try await session.respond(to: "Hello!")
- /// ```
- public struct ChatCompletionsLanguageModel: Sendable, LanguageModel {
- /// The name of the underlying model, sent in the `model` field of each
- /// chat completion request.
- public var name: String
- /// The base URL of the chat completions endpoint. The path
- /// `/v1/chat/completions` is appended automatically when the supplied
- /// URL does not already include a `v1` segment.
- public var url: URL
- /// Headers added to every outgoing request, merged on top of the
- /// defaults. Use this to provide authorization tokens or other
- /// vendor-specific headers.
- public var additionalHeaders: [String: String]
- public var supportsGuidedGeneration: Bool
- // Overridden in tests to inject a URLSession with mock protocol handlers.
- var urlSession: URLSession?
- /// Creates a chat completions language model.
- ///
- /// - Parameters:
- /// - name: The model identifier sent in the `model` field of each
- /// request.
- /// - url: The base URL of the chat completions endpoint.
- /// - additionalHeaders: Headers to merge on top of the defaults
- /// (for example, an `Authorization` header).
- /// - supportsGuidedGeneration: Whether the endpoint supports the
- /// `response_format` field for structured output. Defaults to `true`.
- /// - urlSessionConfiguration: An optional `URLSessionConfiguration` used
- /// to build the `URLSession` that drives requests. Use this to tune
- /// timeouts, proxies, or other transport settings. When `nil`, an
- /// ephemeral configuration is used.
- public init(
- name: String,
- url: URL,
- additionalHeaders: [String: String] = [:],
- supportsGuidedGeneration: Bool = true,
- urlSessionConfiguration: URLSessionConfiguration? = nil
- ) {
- self.name = name
- self.url = url
- self.additionalHeaders = additionalHeaders
- self.supportsGuidedGeneration = supportsGuidedGeneration
- self.urlSession = urlSessionConfiguration.map { URLSession(configuration: $0) }
- }
- // Implementation of LanguageModel Protocol
- public var capabilities: LanguageModelCapabilities {
- if supportsGuidedGeneration {
- LanguageModelCapabilities([.vision, .toolCalling, .reasoning, .guidedGeneration])
- } else {
- LanguageModelCapabilities([.vision, .toolCalling, .reasoning])
- }
- }
- public var executorConfiguration: Executor.Configuration {
- Executor.Configuration(
- modelName: name,
- url: url,
- additionalHeaders: additionalHeaders,
- urlSession: urlSession
- )
- }
- /// An error returned by the chat completions endpoint in the body of a
- /// failed request or a streaming error event.
- ///
- /// Servers may populate any subset of the optional fields.
- public struct APIError: LocalizedError {
- /// A human-readable explanation of the error returned by the server.
- public var message: String
- /// The error category reported by the server (for example,
- /// `"invalid_request_error"`).
- public var type: String?
- /// The name of the request parameter associated with the error,
- /// when applicable.
- public var param: String?
- /// A short machine-readable error code provided by the server.
- public var code: String?
- /// Creates a new API error.
- ///
- /// - Parameters:
- /// - message: A human-readable explanation of the error.
- /// - type: The error category reported by the server.
- /// - param: The request parameter associated with the error.
- /// - code: A short machine-readable error code.
- public init(
- message: String,
- type: String? = nil,
- param: String? = nil,
- code: String? = nil
- ) {
- self.message = message
- self.type = type
- self.param = param
- self.code = code
- }
- }
- /// An error raised by ``ChatCompletionsLanguageModel`` when a request
- /// cannot be issued or its response cannot be parsed.
- public enum RequestError: LocalizedError {
- /// The request could not be constructed because of invalid input.
- /// The associated value contains a human-readable description.
- case invalidRequest(_ description: String)
- /// A streaming chunk could not be decoded as a chat completion event.
- case invalidStreamData
- /// The endpoint returned a non-200 HTTP status code. The associated
- /// values contain the status code and the raw response body.
- case httpError(statusCode: Int, data: Data)
- public var errorDescription: String? {
- switch self {
- case .invalidRequest(let description):
- "Invalid request: \(description)"
- case .invalidStreamData:
- "Invalid streaming data received"
- case .httpError(let statusCode, let data):
- """
- HTTP error with status code \(statusCode):
- \(String(data: data, encoding: .utf8) ?? data.description)
- """
- }
- }
- }
- /// The wire format of an error envelope returned by the chat completions
- /// endpoint, used internally to decode error responses before raising
- /// them as ``APIError``.
- struct ErrorResponse: Codable, Sendable {
- var error: APIError
- struct APIError: Codable, Sendable {
- var message: String
- var type: String?
- var param: String?
- var code: String?
- }
- }
- public struct Executor: LanguageModelExecutor {
- public typealias Model = ChatCompletionsLanguageModel
- private let configuration: Configuration
- public init(configuration: Configuration) {
- self.configuration = configuration
- }
- public struct Configuration: Hashable, Sendable {
- fileprivate let modelName: String
- fileprivate let url: URL
- fileprivate let additionalHeaders: [String: String]
- fileprivate let urlSession: URLSession?
- public static func == (lhs: Configuration, rhs: Configuration) -> Bool {
- lhs.modelName == rhs.modelName
- && lhs.url == rhs.url
- && lhs.additionalHeaders == rhs.additionalHeaders
- }
- public func hash(into hasher: inout Hasher) {
- hasher.combine(modelName)
- hasher.combine(url)
- hasher.combine(additionalHeaders)
- }
- }
- public func respond(
- to request: LanguageModelExecutorGenerationRequest,
- model: ChatCompletionsLanguageModel,
- streamingInto channel: LanguageModelExecutorGenerationChannel
- ) async throws {
- // Caller-supplied headers override the defaults on conflict.
- let headers = [
- "Content-Type": "application/json",
- "Accept": "text/event-stream",
- "User-Agent": Bundle.main.bundleIdentifier ?? "com.apple.FoundationModels"
- ].merging(
- configuration.additionalHeaders,
- uniquingKeysWith: { _, custom in custom }
- )
- // Tests inject a URLSession; production uses a fresh ephemeral one.
- let client = ChatCompletionsClient(
- baseURL: configuration.url,
- headers: headers,
- session: configuration.urlSession ?? URLSession(configuration: .ephemeral)
- )
- // Translate the framework's request into the OpenAI-compatible wire format.
- let chatRequest = ChatCompletionsClient.ChatCompletionRequest(
- model: configuration.modelName,
- messages: try convertedTranscript(request.transcript),
- temperature: request.generationOptions.temperature,
- topP: try request.generationOptions.samplingMode.map(topP),
- maxCompletionTokens: request.generationOptions.maximumResponseTokens,
- tools: request.enabledToolDefinitions.map { tool in
- ChatCompletionsClient.Tool(
- function: ChatCompletionsClient.Tool.Function(
- name: tool.name,
- description: tool.description,
- parameters: tool.parameters
- )
- )
- },
- toolChoice: ChatCompletionsClient.ChatCompletionRequest.ToolChoice(
- mode: {
- switch request.generationOptions.toolCallingMode?.kind {
- case .allowed, .none: .auto
- case .required: .required
- case .disallowed: .none
- @unknown default: .auto
- }
- }()
- ),
- responseFormat: request.schema.map { schema in
- ChatCompletionsClient.ResponseFormat(
- jsonSchema: ChatCompletionsClient.ResponseFormat.JSONSchemaWrapper(
- name: schema.name,
- schema: schema
- )
- )
- }
- )
- // Stream the response back into the framework via `channel`.
- try await Self.processChunks(
- client.streamChatCompletions(request: chatRequest),
- into: channel
- )
- }
- private static func processChunks<ChunkSequence: AsyncSequence>(
- _ chunks: ChunkSequence,
- into channel: LanguageModelExecutorGenerationChannel
- ) async throws where ChunkSequence.Element == ChatCompletionsClient.ChatCompletionChunk {
- // Per-index `id`/`name` for tool calls. The first delta for a given
- // index supplies them; later deltas at the same index typically carry
- // only argument fragments and are routed using these latched values.
- // Argument accumulation is the framework's job — we just forward each
- // delta via `.appendArguments`.
- var toolCallRouting: [Int: (id: String, name: String)] = [:]
- // Stable entryIDs per event type for the duration of this stream.
- // Without these, interleaved reasoning/response/toolCalls chunks would
- // split into multiple transcript entries — the framework only coalesces
- // consecutive events of the same type into the trailing entry.
- let responseEntryID = UUID().uuidString
- let reasoningEntryID = UUID().uuidString
- let toolCallsEntryID = UUID().uuidString
- for try await chunk in chunks {
- if let delta = chunk.choices.first?.delta {
- if let reasoning = delta.reasoningContent {
- await channel.send(
- .reasoning(
- entryID: reasoningEntryID,
- action: .appendText(reasoning, tokenCount: 1)
- )
- )
- }
- if let toolCallDeltas = delta.toolCalls {
- for toolCallDelta in toolCallDeltas {
- let existing = toolCallRouting[toolCallDelta.index] ?? (id: "", name: "")
- let routing = (
- id: existing.id + (toolCallDelta.id ?? ""),
- name: existing.name + (toolCallDelta.function?.name ?? "")
- )
- toolCallRouting[toolCallDelta.index] = routing
- guard !routing.id.isEmpty, !routing.name.isEmpty else { continue }
- await channel.send(
- .toolCalls(
- entryID: toolCallsEntryID,
- action: .toolCall(
- id: routing.id,
- name: routing.name,
- action: .appendArguments(
- toolCallDelta.function?.arguments ?? "",
- tokenCount: 1
- )
- )
- )
- )
- }
- } else if let text = delta.content {
- await channel.send(
- .response(
- entryID: responseEntryID,
- action: .appendText(text, tokenCount: 1)
- )
- )
- }
- }
- // Send usage AFTER content so the authoritative cumulative total
- // overwrites any tokens credited by `appendText` for this chunk.
- if let usage = chunk.usage {
- await channel.send(
- .response(
- entryID: responseEntryID,
- action: .updateUsage(
- input: .init(
- totalTokenCount: usage.promptTokens,
- cachedTokenCount: usage.promptTokensDetails?.cachedTokens ?? 0
- ),
- output: .init(
- totalTokenCount: usage.completionTokens,
- reasoningTokenCount: usage.completionTokensDetails?.reasoningTokens ?? 0
- )
- )
- )
- )
- }
- }
- }
- private func topP(_ sampling: GenerationOptions.SamplingMode) throws -> Double {
- switch sampling.kind {
- case .greedy:
- return 0
- case .randomTopK:
- throw ChatCompletionsLanguageModel.RequestError.invalidRequest(
- "Top K sampling is not supported"
- )
- case .randomProbabilityThreshold(let threshold, let seed):
- guard seed == nil else {
- throw ChatCompletionsLanguageModel.RequestError.invalidRequest(
- "Setting a random seed is not supported"
- )
- }
- return threshold
- @unknown default:
- throw ChatCompletionsLanguageModel.RequestError.invalidRequest(
- "Unknown sampling mode \(sampling.kind) is not supported"
- )
- }
- }
- private func convertedTranscript(
- _ entries: some Collection<Transcript.Entry>
- ) throws -> [ChatCompletionsClient.ChatMessage] {
- // Converts a single transcript segment into chat-completion message content.
- func convertedSegment(
- _ segment: Transcript.Segment,
- in entry: Transcript.Entry
- ) throws -> [ChatCompletionsClient.MessageContent] {
- switch segment {
- case .text(let text):
- return [
- ChatCompletionsClient.MessageContent(
- text: text.content
- )
- ]
- // Structured content is serialized to JSON text on the wire.
- case .structure(let structure):
- return [
- ChatCompletionsClient.MessageContent(
- text: structure.content.jsonString
- )
- ]
- case .attachment(let attachment):
- switch attachment.content {
- case .image(let image):
- #if canImport(CoreImage)
- // Images are inlined as base64 data URLs (JPEG).
- let base64String = image.cgImage.jpegData().base64EncodedString()
- let dataURL = URL(string: "data:image/jpeg;base64,\(base64String)")!
- let imageURL = ChatCompletionsClient.MessageContent.ImageURL(url: dataURL)
- return [ChatCompletionsClient.MessageContent(imageURL: imageURL)]
- #else
- guard let url = image.url else {
- throw LanguageModelError.unsupportedTranscriptContent(
- LanguageModelError.UnsupportedTranscriptContent(
- unsupportedContent: [entry],
- debugDescription: "Image attachment without a URL is not supported by \(Self.self) on this platform."
- )
- )
- }
- let dataURL: URL
- if url.scheme == "data" {
- dataURL = url
- } else {
- let data = try Data(contentsOf: url)
- let base64String = data.base64EncodedString()
- dataURL = URL(string: "data:image/jpeg;base64,\(base64String)")!
- }
- let imageURL = ChatCompletionsClient.MessageContent.ImageURL(url: dataURL)
- return [ChatCompletionsClient.MessageContent(imageURL: imageURL)]
- #endif
- @unknown default:
- throw LanguageModelError.unsupportedTranscriptContent(
- LanguageModelError.UnsupportedTranscriptContent(
- unsupportedContent: [entry],
- debugDescription: "Attachment type not supported by \(Self.self)."
- )
- )
- }
- case .custom:
- throw LanguageModelError.unsupportedTranscriptContent(
- LanguageModelError.UnsupportedTranscriptContent(
- unsupportedContent: [entry],
- debugDescription: "Custom segments are not supported by \(Self.self)"
- )
- )
- @unknown default:
- throw LanguageModelError.unsupportedTranscriptContent(
- LanguageModelError.UnsupportedTranscriptContent(
- unsupportedContent: [entry],
- debugDescription: "Unknown segment type not supported by \(Self.self)"
- )
- )
- }
- }
- var messages: [ChatCompletionsClient.ChatMessage] = []
- // Reasoning entries are buffered and attached to the next assistant
- // message (response or toolCalls) via `reasoning_content`. If a turn
- // has only reasoning with no following assistant entry, it's emitted
- // as a standalone assistant message.
- var pendingReasoning: String? = nil
- func consumePendingReasoning() -> String? {
- defer { pendingReasoning = nil }
- return pendingReasoning
- }
- // Translate each transcript entry into one chat-completion message.
- for entry in entries {
- switch entry {
- case .instructions(let instructions):
- // Instructions become system-role messages.
- messages.append(
- ChatCompletionsClient.ChatMessage(
- role: .system,
- content: try instructions.segments.flatMap { try convertedSegment($0, in: entry) }
- )
- )
- case .prompt(let prompt):
- // User prompts; flush any orphaned reasoning as a message first.
- if let reasoning = consumePendingReasoning() {
- messages.append(
- ChatCompletionsClient.ChatMessage(
- role: .assistant,
- reasoningContent: reasoning
- )
- )
- }
- messages.append(
- ChatCompletionsClient.ChatMessage(
- role: .user,
- content: try prompt.segments.flatMap { try convertedSegment($0, in: entry) }
- )
- )
- case .toolCalls(let toolCalls):
- // Tool calls ride along on an assistant message, with any buffered reasoning attached.
- messages.append(
- ChatCompletionsClient.ChatMessage(
- role: .assistant,
- toolCalls: toolCalls.map { call in
- ChatCompletionsClient.ToolCall(
- id: call.id,
- function: ChatCompletionsClient.ToolCall.FunctionCall(
- name: call.toolName,
- arguments: call.arguments.jsonString
- )
- )
- },
- reasoningContent: consumePendingReasoning()
- )
- )
- case .toolOutput(let toolOutput):
- // Tool outputs become tool-role messages keyed by the originating call ID.
- messages.append(
- ChatCompletionsClient.ChatMessage(
- role: .tool,
- content: try toolOutput.segments.flatMap { try convertedSegment($0, in: entry) },
- toolCallID: toolOutput.id
- )
- )
- case .response(let response):
- // Assistant responses; attach any buffered reasoning to this message.
- messages.append(
- ChatCompletionsClient.ChatMessage(
- role: .assistant,
- content: try response.segments.flatMap { try convertedSegment($0, in: entry) },
- reasoningContent: consumePendingReasoning()
- )
- )
- case .reasoning(let reasoning):
- // Buffer reasoning text; it will attach to the next assistant entry.
- let text = reasoning.segments.compactMap { segment -> String? in
- if case .text(let textSegment) = segment { return textSegment.content }
- return nil
- }.joined()
- pendingReasoning = (pendingReasoning ?? "") + text
- @unknown default:
- continue
- }
- }
- // Trailing reasoning with no following assistant entry — emit it solo.
- if let reasoning = consumePendingReasoning() {
- messages.append(
- ChatCompletionsClient.ChatMessage(
- role: .assistant,
- reasoningContent: reasoning
- )
- )
- }
- return messages
- }
- }
- }
- private struct ChatCompletionsClient {
- let baseURL: URL
- let headers: [String: String]
- let session: URLSession
- func streamChatCompletions(
- request: ChatCompletionRequest
- ) -> AsyncThrowingStream<ChatCompletionChunk, Swift.Error> {
- AsyncThrowingStream { continuation in
- let task = Task {
- do {
- let urlRequest = try buildURLRequest(for: request)
- #if canImport(Darwin)
- let (stream, response) = try await session.bytes(for: urlRequest)
- let httpResponse = response as! HTTPURLResponse
- guard httpResponse.statusCode == 200 else {
- throw ChatCompletionsLanguageModel.RequestError.httpError(
- statusCode: httpResponse.statusCode,
- data: try await stream.reduce(Data(), { $0 + [$1] })
- )
- }
- for try await line in stream.lines {
- if let chunk = try parseStreamLine(line) {
- continuation.yield(chunk)
- }
- }
- continuation.finish()
- #else
- let (data, response) = try await session.data(for: urlRequest)
- let httpResponse = response as! HTTPURLResponse
- guard httpResponse.statusCode == 200 else {
- throw ChatCompletionsLanguageModel.RequestError.httpError(
- statusCode: httpResponse.statusCode,
- data: data
- )
- }
- let body = String(data: data, encoding: .utf8) ?? ""
- for line in body.split(separator: "\n", omittingEmptySubsequences: false) {
- if let chunk = try parseStreamLine(String(line)) {
- continuation.yield(chunk)
- }
- }
- continuation.finish()
- #endif
- } catch {
- continuation.finish(throwing: error)
- }
- }
- continuation.onTermination = { _ in task.cancel() }
- }
- }
- private func buildURLRequest(for request: ChatCompletionRequest) throws -> URLRequest {
- let isVersioned = baseURL.pathComponents.contains("v1")
- let endpoint = isVersioned ? "/chat/completions" : "/v1/chat/completions"
- let url = baseURL.appendingPathComponent(endpoint)
- var urlRequest = URLRequest(url: url)
- urlRequest.httpMethod = "POST"
- for (header, value) in headers {
- urlRequest.setValue(value, forHTTPHeaderField: header)
- }
- let encoder = JSONEncoder()
- urlRequest.httpBody = try encoder.encode(request)
- return urlRequest
- }
- func parseStreamLine(_ line: String) throws -> ChatCompletionChunk? {
- let trimmedLine = line.trimmingCharacters(in: .whitespaces)
- // Skip empty lines and comments
- guard !trimmedLine.isEmpty, !trimmedLine.hasPrefix(":") else {
- return nil
- }
- if trimmedLine.hasPrefix("data: ") {
- let jsonString = String(trimmedLine.dropFirst(6)) // Remove "data: "
- if jsonString.trimmingCharacters(in: .whitespaces) == "[DONE]" {
- return nil
- }
- guard let jsonData = jsonString.data(using: .utf8) else {
- throw ChatCompletionsLanguageModel.RequestError.invalidStreamData
- }
- let decoder = JSONDecoder()
- do {
- return try decoder.decode(ChatCompletionChunk.self, from: jsonData)
- } catch {
- if let response = try? decoder.decode(
- ChatCompletionsLanguageModel.ErrorResponse.self,
- from: jsonData
- ) {
- throw ChatCompletionsLanguageModel.APIError(
- message: response.error.message,
- type: response.error.type,
- param: response.error.param,
- code: response.error.code
- )
- }
- throw error
- }
- }
- return nil
- }
- struct ChatCompletionRequest: Encodable {
- enum ToolChoiceMode: String, Encodable {
- case auto
- case required
- case none
- }
- struct ToolChoice: Encodable {
- let mode: ToolChoiceMode
- func encode(to encoder: any Encoder) throws {
- var container = encoder.singleValueContainer()
- try container.encode(mode)
- }
- }
- var model: String
- var messages: [ChatMessage]
- var temperature: Double?
- var topP: Double?
- var maxCompletionTokens: Int?
- var tools: [Tool]?
- var toolChoice: ChatCompletionRequest.ToolChoice?
- var responseFormat: ResponseFormat?
- var stream = true
- var streamOptions = StreamOptions(includeUsage: true)
- struct StreamOptions: Encodable {
- var includeUsage: Bool
- private enum CodingKeys: String, CodingKey {
- case includeUsage = "include_usage"
- }
- }
- private enum CodingKeys: String, CodingKey {
- case model
- case messages
- case temperature
- case topP = "top_p"
- case maxCompletionTokens = "max_completion_tokens"
- case tools
- case responseFormat = "response_format"
- case stream
- case streamOptions = "stream_options"
- case toolChoice = "tool_choice"
- }
- }
- struct ChatMessage: Encodable {
- var role: Role
- var content: [MessageContent]
- var toolCalls: [ToolCall]?
- var toolCallID: String?
- var reasoningContent: String?
- private enum CodingKeys: String, CodingKey {
- case role
- case content
- case toolCalls = "tool_calls"
- case toolCallID = "tool_call_id"
- case reasoningContent = "reasoning_content"
- }
- enum Role: String, Encodable {
- case system
- case user
- case assistant
- case tool
- }
- init(
- role: Role,
- content: [MessageContent] = [],
- toolCalls: [ToolCall]? = nil,
- toolCallID: String? = nil,
- reasoningContent: String? = nil
- ) {
- self.role = role
- self.content = content
- self.toolCalls = toolCalls
- self.toolCallID = toolCallID
- self.reasoningContent = reasoningContent
- }
- func encode(to encoder: Encoder) throws {
- var container = encoder.container(keyedBy: CodingKeys.self)
- try container.encode(role, forKey: .role)
- let hasToolCalls = toolCalls?.isEmpty == false
- let compactText = content.count == 1 ? content.first?.text : nil
- if let compactText {
- try container.encode(compactText, forKey: .content)
- } else if !hasToolCalls && !content.isEmpty {
- try container.encode(content, forKey: .content)
- }
- try container.encodeIfPresent(toolCalls, forKey: .toolCalls)
- try container.encodeIfPresent(toolCallID, forKey: .toolCallID)
- try container.encodeIfPresent(reasoningContent, forKey: .reasoningContent)
- }
- }
- struct Tool: Encodable {
- var type: String = "function"
- var function: Function
- struct Function: Encodable {
- let name: String
- let description: String
- let parameters: GenerationSchema
- }
- }
- struct ToolCall: Codable {
- var id: String
- var type = "function"
- var function: FunctionCall
- struct FunctionCall: Codable {
- var name: String
- var arguments: String
- }
- }
- struct ResponseFormat: Encodable {
- var type = "json_schema"
- var jsonSchema: JSONSchemaWrapper
- private enum CodingKeys: String, CodingKey {
- case type
- case jsonSchema = "json_schema"
- }
- struct JSONSchemaWrapper: Encodable {
- var name: String
- var description: String?
- var schema: GenerationSchema
- var strict = true
- }
- }
- struct ChatCompletionChunk: Decodable {
- let id: String
- let model: String
- let choices: [Choice]
- let usage: Usage?
- struct Choice: Decodable {
- let delta: Delta
- struct Delta: Decodable {
- var role: String?
- var content: String?
- var reasoningContent: String?
- var toolCalls: [ToolCallDelta]?
- enum CodingKeys: String, CodingKey {
- case role
- case content
- case reasoningContent = "reasoning_content"
- case toolCalls = "tool_calls"
- }
- }
- }
- struct ToolCallDelta: Decodable {
- let index: Int
- let id: String?
- let type: String?
- let function: FunctionCallDelta?
- struct FunctionCallDelta: Decodable {
- let name: String?
- let arguments: String?
- }
- }
- fileprivate struct Usage: Decodable {
- let promptTokens: Int
- let completionTokens: Int
- let promptTokensDetails: PromptTokensDetails?
- let completionTokensDetails: CompletionTokensDetails?
- fileprivate struct PromptTokensDetails: Decodable {
- let cachedTokens: Int?
- private enum CodingKeys: String, CodingKey {
- case cachedTokens = "cached_tokens"
- }
- }
- fileprivate struct CompletionTokensDetails: Decodable {
- let reasoningTokens: Int?
- private enum CodingKeys: String, CodingKey {
- case reasoningTokens = "reasoning_tokens"
- }
- }
- private enum CodingKeys: String, CodingKey {
- case promptTokens = "prompt_tokens"
- case completionTokens = "completion_tokens"
- case promptTokensDetails = "prompt_tokens_details"
- case completionTokensDetails = "completion_tokens_details"
- }
- }
- }
- struct MessageContent: Codable {
- var type: ContentType
- var text: String?
- var imageURL: ImageURL?
- enum CodingKeys: String, CodingKey {
- case type
- case text
- case imageURL = "image_url"
- }
- enum ContentType: String, Codable {
- case text
- case imageURL = "image_url"
- }
- struct ImageURL: Codable {
- var url: URL
- var detail: String? = "auto"
- }
- init(text: String) {
- self.type = .text
- self.text = text
- self.imageURL = nil
- }
- init(imageURL: ImageURL) {
- self.type = .imageURL
- self.text = nil
- self.imageURL = imageURL
- }
- }
- }
- #if canImport(CoreImage)
- private extension CGImage {
- func jpegData() -> Data {
- let imageData = NSMutableData()
- let destination = CGImageDestinationCreateWithData(
- /* data */ imageData,
- /* format */ UTType.jpeg.identifier as CFString,
- /* count */ 1,
- /* options */ nil
- )!
- CGImageDestinationAddImage(destination, self, nil)
- CGImageDestinationFinalize(destination)
- return Data(referencing: imageData)
- }
- }
- #endif
|