From 0e6b17db4a320712a4d9d1fc638dad4e4eef31b4 Mon Sep 17 00:00:00 2001 From: Victor Durocher Date: Thu, 9 Apr 2026 19:28:10 +0200 Subject: [PATCH 01/14] feat: add agentic loop, vision, Gemini/Ollama, structured outputs, prompt caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature 1 — Agentic ReAct loop - run(messages:tools:executor:maxSteps:) default impl in AIAgent protocol - Auto-executes tool calls until model returns a final text response - Throws agentLoopExceeded when maxSteps is reached Feature 2 — Vision multi-modal - New AIImageContent enum (.url / .data) with Sendable+Codable conformances - AIMessage gains images field; user(_:images:) factory updated - OpenAI: ContentPart encodes images as data URLs (base64) - Anthropic: ContentBlock.image encodes URL and base64 sources - Gemini: GeminiPart.inlineData encodes base64 bytes Feature 3 — Google Gemini + Ollama providers - New GeminiClient actor with generateContent / streamGenerateContent - API key passed as ?key= query param; SSE streaming with alt=sse - GeminiScalar Codable enum for outbound function call args encoding - GeminiArgValue Decodable enum for inbound function call args decoding - Ollama reuses OpenAIClient with http://localhost:11434/v1 base URL - AIConfiguration.validate() skips API key check for Ollama provider Feature 4 — Structured outputs - send(messages:as:) generic method on AIAgent protocol - sendForJSON() protocol hook overridden per provider for native JSON mode - OpenAI: response_format {type:"json_object"} - Gemini: responseMimeType "application/json" in generationConfig - Anthropic: falls back to system prompt injection (no native JSON mode) - Strips ```json ... ``` markdown fences before decoding Feature 5 — Anthropic prompt caching - AIMessage gains cacheControl: Bool field; system(_:cached:) factory updated - AIMessageWithTools.asHistoryMessage carries tool calls for history encoding - AnthropicClient detects cacheControl messages and adds anthropic-beta header - SystemContent supports both string and blocks format for cached system messages - ContentBlock encodes cache_control: {type:"ephemeral"} when cached Bug fix: Bool must precede Int/Double in Any-based switch to avoid NSNumber ambiguity when decoding JSON booleans via JSONSerialization (both clients). --- .../Core/AIAgentProtocol.swift | 136 ++-- .../Core/AIConfiguration.swift | 4 +- Sources/SwiftAIAgentCore/Core/AIError.swift | 4 + .../Core/AIImageContent.swift | 54 ++ Sources/SwiftAIAgentCore/Core/AIMessage.swift | 32 +- Sources/SwiftAIAgentCore/Core/AIModel.swift | 130 ++-- .../SwiftAIAgentCore/Core/AIToolCall.swift | 39 +- .../Network/AIAgentImplementation.swift | 286 ++++++--- .../Network/AnthropicClient.swift | 582 ++++++++++-------- .../Network/GeminiClient.swift | 371 +++++++++++ .../Network/OpenAIClient.swift | 397 ++++++------ 11 files changed, 1405 insertions(+), 630 deletions(-) create mode 100644 Sources/SwiftAIAgentCore/Core/AIImageContent.swift create mode 100644 Sources/SwiftAIAgentCore/Network/GeminiClient.swift diff --git a/Sources/SwiftAIAgentCore/Core/AIAgentProtocol.swift b/Sources/SwiftAIAgentCore/Core/AIAgentProtocol.swift index d818418..38922fb 100644 --- a/Sources/SwiftAIAgentCore/Core/AIAgentProtocol.swift +++ b/Sources/SwiftAIAgentCore/Core/AIAgentProtocol.swift @@ -2,53 +2,28 @@ import Foundation /// Protocol defining the core AI agent interface public protocol AIAgent: Sendable { - /// Configuration for this agent var configuration: AIConfiguration { get } - /// Send a single message and get a response - /// - Parameter message: The message to send - /// - Returns: The AI's response - /// - Throws: AIError if the request fails func send(message: String) async throws -> String - - /// Send a conversation history and get a response - /// - Parameter messages: Array of messages representing the conversation - /// - Returns: The AI's response - /// - Throws: AIError if the request fails func send(messages: [AIMessage]) async throws -> AIMessage - - /// Stream a response for a single message - /// - Parameter message: The message to send - /// - Returns: AsyncThrowingStream of response chunks func stream(message: String) -> AsyncThrowingStream - - /// Stream a response for a conversation - /// - Parameter messages: Array of messages representing the conversation - /// - Returns: AsyncThrowingStream of response chunks func stream(messages: [AIMessage]) -> AsyncThrowingStream - - /// Estimate token count for messages - /// - Parameter messages: Messages to estimate - /// - Returns: Estimated token count func estimateTokens(for messages: [AIMessage]) -> Int - /// Envoie des messages avec des outils disponibles — le modèle peut décider d'appeler un outil - /// - Parameters: - /// - messages: Historique de la conversation - /// - tools: Outils que le modèle peut invoquer - /// - Returns: Réponse pouvant contenir du texte et/ou des appels d'outils + /// Send messages with available tools — the model may decide to call one or more tools func send(messages: [AIMessage], tools: [AITool]) async throws -> AIMessageWithTools - /// Envoie les résultats d'outils dans une conversation en cours - /// - Parameters: - /// - messages: Historique complet incluant le message assistant avec tool calls - /// - toolResults: Résultats des outils exécutés par l'application - /// - Returns: Réponse finale du modèle après traitement des résultats + /// Send tool results back into an ongoing conversation func send(messages: [AIMessage], toolResults: [AIToolResult]) async throws -> AIMessageWithTools + + /// Internal hook used by send() — override to enable native JSON mode (e.g. OpenAI json_object) + func sendForJSON(messages: [AIMessage]) async throws -> AIMessage } -/// Implémentations par défaut +// MARK: - Default implementations + public extension AIAgent { + func send(message: String) async throws -> String { let response = try await send(messages: [.user(message)]) return response.content @@ -62,19 +37,106 @@ public extension AIAgent { messages.reduce(0) { $0 + $1.estimatedTokens } } - /// Implémentation par défaut : ignore les outils et effectue un appel classique func send(messages: [AIMessage], tools: [AITool]) async throws -> AIMessageWithTools { let response = try await send(messages: messages) return AIMessageWithTools(message: response) } - /// Implémentation par défaut : concatène les résultats comme messages user et poursuit func send(messages: [AIMessage], toolResults: [AIToolResult]) async throws -> AIMessageWithTools { let resultMessages = toolResults.map { result in AIMessage.user("[Tool Result \(result.toolCallId)]: \(result.content)") } - let allMessages = messages + resultMessages - let response = try await send(messages: allMessages) + let response = try await send(messages: messages + resultMessages) return AIMessageWithTools(message: response) } + + func sendForJSON(messages: [AIMessage]) async throws -> AIMessage { + try await send(messages: messages) + } + + // MARK: - Feature 1: Agentic ReAct Loop + + /// Execute an agentic loop — automatically calls tools until the model produces a final text response. + /// + /// - Parameters: + /// - messages: Initial conversation history + /// - tools: Tools the model may invoke + /// - executor: Closure that executes a tool call and returns its string result + /// - maxSteps: Maximum tool-call rounds before throwing `agentLoopExceeded` (default: 10) + func run( + messages: [AIMessage], + tools: [AITool], + executor: @Sendable (AIToolCall) async throws -> String, + maxSteps: Int + ) async throws -> AIMessage { + var history = messages + + for _ in 0..(messages: [AIMessage], as type: T.Type) async throws -> T { + // Inject a JSON instruction when no system message exists + var augmented = messages + if !augmented.contains(where: { $0.role == .system }) { + augmented.insert( + .system("You must respond with valid JSON only. No markdown code blocks, no explanations."), + at: 0 + ) + } + + let response = try await sendForJSON(messages: augmented) + + // Strip optional markdown code fences (```json ... ```) + let raw = response.content.trimmingCharacters(in: .whitespacesAndNewlines) + let cleaned: String + if raw.hasPrefix("```") { + cleaned = raw + .components(separatedBy: "\n") + .dropFirst() + .dropLast() + .joined(separator: "\n") + } else { + cleaned = raw + } + + guard let data = cleaned.data(using: .utf8) else { + throw AIError.decodingError( + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Response is not valid UTF-8")) + ) + } + + do { + return try JSONDecoder().decode(type, from: data) + } catch { + throw AIError.decodingError(error) + } + } } diff --git a/Sources/SwiftAIAgentCore/Core/AIConfiguration.swift b/Sources/SwiftAIAgentCore/Core/AIConfiguration.swift index aa10432..1e62156 100644 --- a/Sources/SwiftAIAgentCore/Core/AIConfiguration.swift +++ b/Sources/SwiftAIAgentCore/Core/AIConfiguration.swift @@ -27,7 +27,9 @@ public struct AIConfiguration: Sendable { /// Validate configuration public func validate() throws { - guard !apiKey.isEmpty else { + // Ollama runs locally without authentication + let requiresKey = model.provider != .ollama + guard !apiKey.isEmpty || !requiresKey else { throw AIError.invalidAPIKey } diff --git a/Sources/SwiftAIAgentCore/Core/AIError.swift b/Sources/SwiftAIAgentCore/Core/AIError.swift index 162b6c7..18dd42d 100644 --- a/Sources/SwiftAIAgentCore/Core/AIError.swift +++ b/Sources/SwiftAIAgentCore/Core/AIError.swift @@ -12,6 +12,8 @@ public enum AIError: LocalizedError, Sendable { case streamingError(String) case timeout case cancelled + /// The agentic ReAct loop reached maxSteps without producing a final text response + case agentLoopExceeded(steps: Int) case unknown(Error) public var errorDescription: String? { @@ -40,6 +42,8 @@ public enum AIError: LocalizedError, Sendable { return "Request timed out" case .cancelled: return "Request was cancelled" + case .agentLoopExceeded(let steps): + return "Agent loop exceeded \(steps) steps without producing a final response." case .unknown(let error): return "Unknown error: \(error.localizedDescription)" } diff --git a/Sources/SwiftAIAgentCore/Core/AIImageContent.swift b/Sources/SwiftAIAgentCore/Core/AIImageContent.swift new file mode 100644 index 0000000..0ed1f57 --- /dev/null +++ b/Sources/SwiftAIAgentCore/Core/AIImageContent.swift @@ -0,0 +1,54 @@ +import Foundation + +/// Represents image content that can be attached to a user message. +/// Supported by vision-capable models (GPT-4o, Claude 3+, Gemini 1.5+). +public enum AIImageContent: Sendable, Hashable, Codable { + /// Remote image URL — fetched by the model at inference time + case url(URL) + /// Inline image bytes with explicit MIME type (base64-encoded in API requests) + case data(Data, mimeType: String) + + /// Base64-encoded image bytes, or nil for URL images + public var base64String: String? { + guard case .data(let bytes, _) = self else { return nil } + return bytes.base64EncodedString() + } + + /// MIME type of the image, or nil for URL images + public var mimeType: String? { + guard case .data(_, let mime) = self else { return nil } + return mime + } + + /// The remote URL, or nil for data images + public var remoteURL: URL? { + guard case .url(let url) = self else { return nil } + return url + } +} + +#if canImport(UIKit) +import UIKit + +public extension AIImageContent { + /// Create image content from a UIImage, JPEG-encoded at the given quality + static func uiImage(_ image: UIImage, compressionQuality: CGFloat = 0.85) -> AIImageContent? { + guard let data = image.jpegData(compressionQuality: compressionQuality) else { return nil } + return .data(data, mimeType: "image/jpeg") + } +} +#endif + +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit + +public extension AIImageContent { + /// Create image content from an NSImage, PNG-encoded + static func nsImage(_ image: NSImage) -> AIImageContent? { + guard let tiff = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff), + let png = bitmap.representation(using: .png, properties: [:]) else { return nil } + return .data(png, mimeType: "image/png") + } +} +#endif diff --git a/Sources/SwiftAIAgentCore/Core/AIMessage.swift b/Sources/SwiftAIAgentCore/Core/AIMessage.swift index 935e7c1..f8dd146 100644 --- a/Sources/SwiftAIAgentCore/Core/AIMessage.swift +++ b/Sources/SwiftAIAgentCore/Core/AIMessage.swift @@ -4,27 +4,40 @@ import Foundation public struct AIMessage: Codable, Sendable, Hashable, Identifiable { public let id: String public let role: AIRole + /// Text content of the message public let content: String public let timestamp: Date public var metadata: [String: String]? + /// Optional image attachments for vision-capable models (GPT-4o, Claude 3+, Gemini) + public let images: [AIImageContent]? + /// Tool calls carried by assistant messages — enables correct agentic loop history encoding + public let toolCalls: [AIToolCall]? + /// When true, signals Anthropic to cache this content (prompt caching beta) + public let cacheControl: Bool public init( id: String = UUID().uuidString, role: AIRole, content: String, timestamp: Date = Date(), - metadata: [String: String]? = nil + metadata: [String: String]? = nil, + images: [AIImageContent]? = nil, + toolCalls: [AIToolCall]? = nil, + cacheControl: Bool = false ) { self.id = id self.role = role self.content = content self.timestamp = timestamp self.metadata = metadata + self.images = images + self.toolCalls = toolCalls + self.cacheControl = cacheControl } - /// Create a user message - public static func user(_ content: String) -> AIMessage { - AIMessage(role: .user, content: content) + /// Create a user message, optionally with image attachments + public static func user(_ content: String, images: [AIImageContent]? = nil) -> AIMessage { + AIMessage(role: .user, content: content, images: images) } /// Create an assistant message @@ -33,8 +46,9 @@ public struct AIMessage: Codable, Sendable, Hashable, Identifiable { } /// Create a system message - public static func system(_ content: String) -> AIMessage { - AIMessage(role: .system, content: content) + /// - Parameter cached: Enable Anthropic prompt caching for this message's content + public static func system(_ content: String, cached: Bool = false) -> AIMessage { + AIMessage(role: .system, content: content, cacheControl: cached) } /// Estimated token count (rough approximation) @@ -43,6 +57,12 @@ public struct AIMessage: Codable, Sendable, Hashable, Identifiable { } } +extension AIMessage: Equatable { + public static func == (lhs: AIMessage, rhs: AIMessage) -> Bool { + lhs.id == rhs.id + } +} + extension AIMessage: CustomStringConvertible { public var description: String { "[\(role.rawValue.uppercased())] \(content)" diff --git a/Sources/SwiftAIAgentCore/Core/AIModel.swift b/Sources/SwiftAIAgentCore/Core/AIModel.swift index b0ab7c4..db9689c 100644 --- a/Sources/SwiftAIAgentCore/Core/AIModel.swift +++ b/Sources/SwiftAIAgentCore/Core/AIModel.swift @@ -1,9 +1,12 @@ import Foundation -/// Supported AI model providers and their models +/// Supported AI model providers public enum AIProvider: String, Codable, Sendable { case openai case anthropic + case gemini + /// Local inference via Ollama (requires Ollama running at localhost:11434) + case ollama public var baseURL: String { switch self { @@ -11,6 +14,11 @@ public enum AIProvider: String, Codable, Sendable { return "https://api.openai.com/v1" case .anthropic: return "https://api.anthropic.com/v1" + case .gemini: + return "https://generativelanguage.googleapis.com/v1beta" + case .ollama: + // Ollama exposes an OpenAI-compatible endpoint by default + return "http://localhost:11434/v1" } } } @@ -21,91 +29,65 @@ public struct AIModel: Codable, Sendable, Hashable { public let name: String public let maxTokens: Int public let supportsStreaming: Bool + /// Whether the model can process image inputs + public let supportsVision: Bool public init( provider: AIProvider, name: String, maxTokens: Int, - supportsStreaming: Bool = true + supportsStreaming: Bool = true, + supportsVision: Bool = false ) { self.provider = provider self.name = name self.maxTokens = maxTokens self.supportsStreaming = supportsStreaming + self.supportsVision = supportsVision } // MARK: - OpenAI Models - public static let gpt4 = AIModel( - provider: .openai, - name: "gpt-4", - maxTokens: 8192 - ) - - public static let gpt4Turbo = AIModel( - provider: .openai, - name: "gpt-4-turbo-preview", - maxTokens: 128000 - ) - - public static let gpt35Turbo = AIModel( - provider: .openai, - name: "gpt-3.5-turbo", - maxTokens: 16385 - ) - - /// GPT-4o — modèle multimodal phare d'OpenAI (128k contexte) - public static let gpt4o = AIModel( - provider: .openai, - name: "gpt-4o", - maxTokens: 128000 - ) - - /// GPT-4o Mini — version allégée et rapide de GPT-4o - public static let gpt4oMini = AIModel( - provider: .openai, - name: "gpt-4o-mini", - maxTokens: 128000 - ) - - // MARK: - Anthropic Models - - public static let claude3Opus = AIModel( - provider: .anthropic, - name: "claude-3-opus-20240229", - maxTokens: 200000 - ) - - public static let claude3Sonnet = AIModel( - provider: .anthropic, - name: "claude-3-sonnet-20240229", - maxTokens: 200000 - ) - - public static let claude3Haiku = AIModel( - provider: .anthropic, - name: "claude-3-haiku-20240307", - maxTokens: 200000 - ) - - /// Claude 3.5 Sonnet — modèle Anthropic haute performance (200k contexte) - public static let claude35Sonnet = AIModel( - provider: .anthropic, - name: "claude-3-5-sonnet-20241022", - maxTokens: 200000 - ) - - /// Claude 3.5 Haiku — modèle Anthropic rapide et économique (200k contexte) - public static let claude35Haiku = AIModel( - provider: .anthropic, - name: "claude-3-5-haiku-20241022", - maxTokens: 200000 - ) - - /// Claude Sonnet 4.6 — dernier modèle Anthropic disponible (200k contexte) - public static let claudeSonnet46 = AIModel( - provider: .anthropic, - name: "claude-sonnet-4-6", - maxTokens: 200000 - ) + public static let gpt4 = AIModel(provider: .openai, name: "gpt-4", maxTokens: 8192) + public static let gpt4Turbo = AIModel(provider: .openai, name: "gpt-4-turbo", maxTokens: 128000, supportsVision: true) + public static let gpt35Turbo = AIModel(provider: .openai, name: "gpt-3.5-turbo", maxTokens: 16385) + public static let gpt4o = AIModel(provider: .openai, name: "gpt-4o", maxTokens: 128000, supportsVision: true) + public static let gpt4oMini = AIModel(provider: .openai, name: "gpt-4o-mini", maxTokens: 128000, supportsVision: true) + public static let gpt41 = AIModel(provider: .openai, name: "gpt-4.1", maxTokens: 1047576, supportsVision: true) + public static let gpt41Mini = AIModel(provider: .openai, name: "gpt-4.1-mini", maxTokens: 1047576, supportsVision: true) + + // MARK: - Anthropic Models (all Claude 3+ support vision) + + public static let claude3Opus = AIModel(provider: .anthropic, name: "claude-3-opus-20240229", maxTokens: 200000, supportsVision: true) + public static let claude3Sonnet = AIModel(provider: .anthropic, name: "claude-3-sonnet-20240229", maxTokens: 200000, supportsVision: true) + public static let claude3Haiku = AIModel(provider: .anthropic, name: "claude-3-haiku-20240307", maxTokens: 200000, supportsVision: true) + public static let claude35Sonnet = AIModel(provider: .anthropic, name: "claude-3-5-sonnet-20241022", maxTokens: 200000, supportsVision: true) + public static let claude35Haiku = AIModel(provider: .anthropic, name: "claude-3-5-haiku-20241022", maxTokens: 200000, supportsVision: true) + public static let claude37Sonnet = AIModel(provider: .anthropic, name: "claude-3-7-sonnet-20250219", maxTokens: 200000, supportsVision: true) + public static let claudeSonnet46 = AIModel(provider: .anthropic, name: "claude-sonnet-4-6", maxTokens: 200000, supportsVision: true) + public static let claudeOpus46 = AIModel(provider: .anthropic, name: "claude-opus-4-6", maxTokens: 200000, supportsVision: true) + public static let claudeHaiku45 = AIModel(provider: .anthropic, name: "claude-haiku-4-5-20251001", maxTokens: 200000, supportsVision: true) + + // MARK: - Google Gemini Models + + /// Gemini 2.0 Flash — fast and efficient, 1M context, vision support + public static let gemini20Flash = AIModel(provider: .gemini, name: "gemini-2.0-flash", maxTokens: 1048576, supportsVision: true) + /// Gemini 2.0 Flash Lite — most cost-efficient, 1M context + public static let gemini20FlashLite = AIModel(provider: .gemini, name: "gemini-2.0-flash-lite", maxTokens: 1048576, supportsVision: true) + /// Gemini 1.5 Pro — 2M context window, vision support + public static let gemini15Pro = AIModel(provider: .gemini, name: "gemini-1.5-pro", maxTokens: 2097152, supportsVision: true) + /// Gemini 1.5 Flash — fast and cost-efficient, 1M context + public static let gemini15Flash = AIModel(provider: .gemini, name: "gemini-1.5-flash", maxTokens: 1048576, supportsVision: true) + + // MARK: - Ollama (local models — require Ollama running at localhost:11434) + + public static let ollamaLlama32 = AIModel(provider: .ollama, name: "llama3.2", maxTokens: 128000) + public static let ollamaLlama31 = AIModel(provider: .ollama, name: "llama3.1", maxTokens: 128000) + public static let ollamaMistral = AIModel(provider: .ollama, name: "mistral", maxTokens: 32768) + public static let ollamaPhi4 = AIModel(provider: .ollama, name: "phi4", maxTokens: 16384) + + /// Custom Ollama model — any model name installed on the local Ollama instance + public static func ollamaCustom(name: String, maxTokens: Int = 32768) -> AIModel { + AIModel(provider: .ollama, name: name, maxTokens: maxTokens) + } } diff --git a/Sources/SwiftAIAgentCore/Core/AIToolCall.swift b/Sources/SwiftAIAgentCore/Core/AIToolCall.swift index 23aab9f..9ca5dff 100644 --- a/Sources/SwiftAIAgentCore/Core/AIToolCall.swift +++ b/Sources/SwiftAIAgentCore/Core/AIToolCall.swift @@ -1,12 +1,12 @@ import Foundation -/// Appel d'outil demandé par le modèle dans une réponse +/// Tool call requested by the model in a response public struct AIToolCall: Sendable, Codable, Hashable { - /// Identifiant unique de l'appel (ex: "call_abc123"), nécessaire pour apparier le résultat + /// Unique call identifier (e.g. "call_abc123"), required to match the result public let id: String - /// Nom de l'outil à appeler + /// Name of the tool to call public let name: String - /// Arguments de l'outil encodés en JSON String + /// Tool arguments encoded as a JSON String public let arguments: String public init(id: String, name: String, arguments: String) { @@ -15,19 +15,19 @@ public struct AIToolCall: Sendable, Codable, Hashable { self.arguments = arguments } - /// Tente de décoder les arguments en dictionnaire clé/valeur - /// Retourne nil si le JSON est invalide ou ne correspond pas à un objet + /// Attempts to decode arguments into a key/value dictionary + /// Returns nil if the JSON is invalid or does not represent an object public func decodeArguments() -> [String: Any]? { guard let data = arguments.data(using: .utf8) else { return nil } return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] } } -/// Résultat d'un appel d'outil fourni par l'application hôte +/// Result of a tool call provided by the host application public struct AIToolResult: Sendable { - /// Identifiant de l'appel d'outil correspondant — doit correspondre à AIToolCall.id + /// Identifier of the corresponding tool call — must match AIToolCall.id public let toolCallId: String - /// Résultat de l'exécution sous forme de texte + /// Execution result as plain text public let content: String public init(toolCallId: String, content: String) { @@ -36,18 +36,31 @@ public struct AIToolResult: Sendable { } } -/// Réponse de l'agent pouvant contenir du texte et/ou des appels d'outils +/// Agent response that may contain text and/or tool calls public struct AIMessageWithTools: Sendable { - /// Message texte de l'agent (content peut être vide si le modèle n'appelle que des outils) + /// Text message from the agent (content may be empty if the model only calls tools) public let message: AIMessage - /// Appels d'outils demandés par le modèle (vide si réponse textuelle pure) + /// Tool calls requested by the model (empty for pure text responses) public let toolCalls: [AIToolCall] - /// Indique si l'application doit exécuter des outils avant de continuer la conversation + /// Indicates whether the application must execute tools before continuing the conversation public var requiresToolExecution: Bool { !toolCalls.isEmpty } public init(message: AIMessage, toolCalls: [AIToolCall] = []) { self.message = message self.toolCalls = toolCalls } + + /// Returns an AIMessage suitable for insertion into conversation history. + /// The tool calls are stored on the message so OpenAI/Anthropic can correctly + /// associate subsequent tool results with the calls that triggered them. + public var asHistoryMessage: AIMessage { + AIMessage( + id: message.id, + role: .assistant, + content: message.content, + timestamp: message.timestamp, + toolCalls: toolCalls.isEmpty ? nil : toolCalls + ) + } } diff --git a/Sources/SwiftAIAgentCore/Network/AIAgentImplementation.swift b/Sources/SwiftAIAgentCore/Network/AIAgentImplementation.swift index 2fec9af..450ec93 100644 --- a/Sources/SwiftAIAgentCore/Network/AIAgentImplementation.swift +++ b/Sources/SwiftAIAgentCore/Network/AIAgentImplementation.swift @@ -1,60 +1,60 @@ import Foundation -/// Implémentation concrète du protocole AIAgent. -/// Supporte plusieurs providers LLM et, sur iOS 17+, la persistance locale via SwiftData. +/// Concrete implementation of the AIAgent protocol. +/// Supports OpenAI, Anthropic, Google Gemini, and Ollama. +/// On iOS 17+ / macOS 14+, also supports local persistence via SwiftData. public actor AIAgentImplementation: AIAgent { public let configuration: AIConfiguration private let openAIClient: OpenAIClient? private let anthropicClient: AnthropicClient? + private let geminiClient: GeminiClient? - /// Stockage type-erased du HistoryManager pour éviter la contrainte @available - /// sur une propriété stockée. Accédé via la propriété calculée `historyManager`. + /// Type-erased storage for HistoryManager to avoid placing a @available constraint + /// on a stored property. Accessed via the computed property `historyManager`. private let _historyManager: Any? - /// Gestionnaire d'historique SwiftData (iOS 17+ / macOS 14+ uniquement) @available(iOS 17.0, macOS 14.0, watchOS 10.0, tvOS 17.0, *) - private var historyManager: HistoryManager? { - _historyManager as? HistoryManager - } + private var historyManager: HistoryManager? { _historyManager as? HistoryManager } - // MARK: - Initialiseurs désignés + // MARK: - Designated Initializers - /// Initialise l'agent sans persistance locale + /// Initializes the agent without local persistence public init(configuration: AIConfiguration) throws { try configuration.validate() self.configuration = configuration self._historyManager = nil - let (openAI, anthropic) = Self.buildClients(configuration: configuration) - self.openAIClient = openAI - self.anthropicClient = anthropic + let clients = Self.buildClients(configuration: configuration) + self.openAIClient = clients.openAI + self.anthropicClient = clients.anthropic + self.geminiClient = clients.gemini } - /// Initialise l'agent avec persistance locale via SwiftData (iOS 17+ / macOS 14+) + /// Initializes the agent with local persistence via SwiftData (iOS 17+ / macOS 14+) @available(iOS 17.0, macOS 14.0, watchOS 10.0, tvOS 17.0, *) public init(configuration: AIConfiguration, historyManager: HistoryManager) throws { try configuration.validate() self.configuration = configuration self._historyManager = historyManager - let (openAI, anthropic) = Self.buildClients(configuration: configuration) - self.openAIClient = openAI - self.anthropicClient = anthropic + let clients = Self.buildClients(configuration: configuration) + self.openAIClient = clients.openAI + self.anthropicClient = clients.anthropic + self.geminiClient = clients.gemini } // MARK: - AIAgent Protocol public func send(messages: [AIMessage]) async throws -> AIMessage { - // Validation du nombre de tokens avant l'envoi try TokenEstimator.validate( messages: messages, model: configuration.model, maxResponseTokens: configuration.maxResponseTokens ) - // Routage vers le client approprié let response: AIMessage + switch configuration.model.provider { - case .openai: + case .openai, .ollama: guard let client = openAIClient else { throw AIError.invalidContext("OpenAI client not initialized") } @@ -65,9 +65,15 @@ public actor AIAgentImplementation: AIAgent { throw AIError.invalidContext("Anthropic client not initialized") } response = try await client.sendCompletion(messages: messages) + + case .gemini: + guard let client = geminiClient else { + throw AIError.invalidContext("Gemini client not initialized") + } + response = try await client.sendCompletion(messages: messages) } - // Sauvegarde dans l'historique si un gestionnaire est configuré (iOS 17+ seulement) + // Persist to history when a manager is configured (iOS 17+ only) if #available(iOS 17.0, macOS 14.0, watchOS 10.0, tvOS 17.0, *) { if let manager = historyManager { try? await manager.saveConversation( @@ -81,14 +87,85 @@ public actor AIAgentImplementation: AIAgent { return response } + /// Feature 4: Override the JSON hook to enable native JSON mode on OpenAI and Gemini + public func sendForJSON(messages: [AIMessage]) async throws -> AIMessage { + try TokenEstimator.validate( + messages: messages, + model: configuration.model, + maxResponseTokens: configuration.maxResponseTokens + ) + + switch configuration.model.provider { + case .openai, .ollama: + guard let client = openAIClient else { + throw AIError.invalidContext("OpenAI client not initialized") + } + return try await client.sendCompletionJSON(messages: messages) + + case .gemini: + guard let client = geminiClient else { + throw AIError.invalidContext("Gemini client not initialized") + } + return try await client.sendCompletionJSON(messages: messages) + + case .anthropic: + // Anthropic has no native JSON mode — rely on prompt injection from the protocol default + guard let client = anthropicClient else { + throw AIError.invalidContext("Anthropic client not initialized") + } + return try await client.sendCompletion(messages: messages) + } + } + + public func send(messages: [AIMessage], tools: [AITool]) async throws -> AIMessageWithTools { + try TokenEstimator.validate( + messages: messages, + model: configuration.model, + maxResponseTokens: configuration.maxResponseTokens + ) + + switch configuration.model.provider { + case .openai, .ollama: + guard let client = openAIClient else { + throw AIError.invalidContext("OpenAI client not initialized") + } + return try await client.sendCompletionWithTools(messages: messages, tools: tools) + + case .anthropic: + guard let client = anthropicClient else { + throw AIError.invalidContext("Anthropic client not initialized") + } + return try await client.sendCompletionWithTools(messages: messages, tools: tools) + + case .gemini: + guard let client = geminiClient else { + throw AIError.invalidContext("Gemini client not initialized") + } + return try await client.sendCompletionWithTools(messages: messages, tools: tools) + } + } + + public func send(messages: [AIMessage], toolResults: [AIToolResult]) async throws -> AIMessageWithTools { + let toolResultMessages: [AIMessage] = toolResults.map { result in + AIMessage(role: .tool, content: result.content, metadata: ["tool_call_id": result.toolCallId]) + } + let fullMessages = messages + toolResultMessages + + try TokenEstimator.validate( + messages: fullMessages, + model: configuration.model, + maxResponseTokens: configuration.maxResponseTokens + ) + + let response = try await send(messages: fullMessages) + return AIMessageWithTools(message: response) + } + public func stream(messages: [AIMessage]) -> AsyncThrowingStream { guard configuration.model.supportsStreaming else { - return AsyncThrowingStream { continuation in - continuation.finish(throwing: AIError.streamingError("Model does not support streaming")) - } + return AsyncThrowingStream { $0.finish(throwing: AIError.streamingError("Model does not support streaming")) } } - // Validation du nombre de tokens do { try TokenEstimator.validate( messages: messages, @@ -96,35 +173,32 @@ public actor AIAgentImplementation: AIAgent { maxResponseTokens: configuration.maxResponseTokens ) } catch { - return AsyncThrowingStream { continuation in - continuation.finish(throwing: error) - } + return AsyncThrowingStream { $0.finish(throwing: error) } } - // Routage vers le client approprié switch configuration.model.provider { - case .openai: + case .openai, .ollama: guard let client = openAIClient else { - return AsyncThrowingStream { continuation in - continuation.finish(throwing: AIError.invalidContext("OpenAI client not initialized")) - } + return AsyncThrowingStream { $0.finish(throwing: AIError.invalidContext("OpenAI client not initialized")) } } return client.streamCompletion(messages: messages) case .anthropic: guard let client = anthropicClient else { - return AsyncThrowingStream { continuation in - continuation.finish(throwing: AIError.invalidContext("Anthropic client not initialized")) - } + return AsyncThrowingStream { $0.finish(throwing: AIError.invalidContext("Anthropic client not initialized")) } + } + return client.streamCompletion(messages: messages) + + case .gemini: + guard let client = geminiClient else { + return AsyncThrowingStream { $0.finish(throwing: AIError.invalidContext("Gemini client not initialized")) } } return client.streamCompletion(messages: messages) } } - // MARK: - Persistance locale (iOS 17+ / macOS 14+) + // MARK: - Local Persistence (iOS 17+ / macOS 14+) - /// Charge les N derniers messages de la conversation la plus récente - /// pour alimenter le contexte de l'agent entre les sessions @available(iOS 17.0, macOS 14.0, watchOS 10.0, tvOS 17.0, *) public func loadPreviousContext(limit: Int = 20) async throws -> [AIMessage] { guard let manager = historyManager else { return [] } @@ -132,18 +206,24 @@ public actor AIAgentImplementation: AIAgent { } } -// MARK: - Helpers privés +// MARK: - Private Helpers private extension AIAgentImplementation { - /// Crée les clients réseau selon le provider configuré - static func buildClients( - configuration: AIConfiguration - ) -> (openAI: OpenAIClient?, anthropic: AnthropicClient?) { + struct Clients { + let openAI: OpenAIClient? + let anthropic: AnthropicClient? + let gemini: GeminiClient? + } + + static func buildClients(configuration: AIConfiguration) -> Clients { switch configuration.model.provider { - case .openai: - return (OpenAIClient(configuration: configuration), nil) + case .openai, .ollama: + // Ollama uses the OpenAI-compatible endpoint — same client, different base URL + return Clients(openAI: OpenAIClient(configuration: configuration), anthropic: nil, gemini: nil) case .anthropic: - return (nil, AnthropicClient(configuration: configuration)) + return Clients(openAI: nil, anthropic: AnthropicClient(configuration: configuration), gemini: nil) + case .gemini: + return Clients(openAI: nil, anthropic: nil, gemini: GeminiClient(configuration: configuration)) } } } @@ -151,51 +231,109 @@ private extension AIAgentImplementation { // MARK: - Convenience Initializers public extension AIAgentImplementation { - /// Crée un agent GPT-4 + + // MARK: OpenAI + static func gpt4(apiKey: String) throws -> AIAgentImplementation { - let config = AIConfiguration(model: .gpt4, apiKey: apiKey) - return try AIAgentImplementation(configuration: config) + try AIAgentImplementation(configuration: AIConfiguration(model: .gpt4, apiKey: apiKey)) } - /// Crée un agent GPT-4 Turbo static func gpt4Turbo(apiKey: String) throws -> AIAgentImplementation { - let config = AIConfiguration(model: .gpt4Turbo, apiKey: apiKey) - return try AIAgentImplementation(configuration: config) + try AIAgentImplementation(configuration: AIConfiguration(model: .gpt4Turbo, apiKey: apiKey)) } - /// Crée un agent Claude 3 Opus - static func claude3Opus(apiKey: String) throws -> AIAgentImplementation { - let config = AIConfiguration(model: .claude3Opus, apiKey: apiKey) - return try AIAgentImplementation(configuration: config) + static func gpt35Turbo(apiKey: String) throws -> AIAgentImplementation { + try AIAgentImplementation(configuration: AIConfiguration(model: .gpt35Turbo, apiKey: apiKey)) + } + + static func gpt4o(apiKey: String) throws -> AIAgentImplementation { + try AIAgentImplementation(configuration: AIConfiguration(model: .gpt4o, apiKey: apiKey)) + } + + static func gpt4oMini(apiKey: String) throws -> AIAgentImplementation { + try AIAgentImplementation(configuration: AIConfiguration(model: .gpt4oMini, apiKey: apiKey)) + } + + static func gpt41(apiKey: String) throws -> AIAgentImplementation { + try AIAgentImplementation(configuration: AIConfiguration(model: .gpt41, apiKey: apiKey)) + } + + static func gpt41Mini(apiKey: String) throws -> AIAgentImplementation { + try AIAgentImplementation(configuration: AIConfiguration(model: .gpt41Mini, apiKey: apiKey)) + } + + // MARK: Anthropic — Claude 3 + + static func claude3Haiku(apiKey: String) throws -> AIAgentImplementation { + try AIAgentImplementation(configuration: AIConfiguration(model: .claude3Haiku, apiKey: apiKey)) } - /// Crée un agent Claude 3 Sonnet static func claude3Sonnet(apiKey: String) throws -> AIAgentImplementation { - let config = AIConfiguration(model: .claude3Sonnet, apiKey: apiKey) - return try AIAgentImplementation(configuration: config) + try AIAgentImplementation(configuration: AIConfiguration(model: .claude3Sonnet, apiKey: apiKey)) } - /// Crée un agent GPT-4o (multimodal, 128k contexte) - static func gpt4o(apiKey: String) throws -> AIAgentImplementation { - let config = AIConfiguration(model: .gpt4o, apiKey: apiKey) - return try AIAgentImplementation(configuration: config) + static func claude3Opus(apiKey: String) throws -> AIAgentImplementation { + try AIAgentImplementation(configuration: AIConfiguration(model: .claude3Opus, apiKey: apiKey)) } - /// Crée un agent GPT-4o Mini (rapide et économique) - static func gpt4oMini(apiKey: String) throws -> AIAgentImplementation { - let config = AIConfiguration(model: .gpt4oMini, apiKey: apiKey) - return try AIAgentImplementation(configuration: config) + // MARK: Anthropic — Claude 3.5 / 3.7 + + static func claude35Haiku(apiKey: String) throws -> AIAgentImplementation { + try AIAgentImplementation(configuration: AIConfiguration(model: .claude35Haiku, apiKey: apiKey)) } - /// Crée un agent Claude 3.5 Sonnet (haute performance, 200k contexte) static func claude35Sonnet(apiKey: String) throws -> AIAgentImplementation { - let config = AIConfiguration(model: .claude35Sonnet, apiKey: apiKey) - return try AIAgentImplementation(configuration: config) + try AIAgentImplementation(configuration: AIConfiguration(model: .claude35Sonnet, apiKey: apiKey)) + } + + static func claude37Sonnet(apiKey: String) throws -> AIAgentImplementation { + try AIAgentImplementation(configuration: AIConfiguration(model: .claude37Sonnet, apiKey: apiKey)) + } + + // MARK: Anthropic — Claude 4 + + static func claudeHaiku45(apiKey: String) throws -> AIAgentImplementation { + try AIAgentImplementation(configuration: AIConfiguration(model: .claudeHaiku45, apiKey: apiKey)) } - /// Crée un agent Claude Sonnet 4.6 — dernier modèle Anthropic disponible static func claudeSonnet46(apiKey: String) throws -> AIAgentImplementation { - let config = AIConfiguration(model: .claudeSonnet46, apiKey: apiKey) - return try AIAgentImplementation(configuration: config) + try AIAgentImplementation(configuration: AIConfiguration(model: .claudeSonnet46, apiKey: apiKey)) + } + + static func claudeOpus46(apiKey: String) throws -> AIAgentImplementation { + try AIAgentImplementation(configuration: AIConfiguration(model: .claudeOpus46, apiKey: apiKey)) + } + + // MARK: Google Gemini + + static func gemini20Flash(apiKey: String) throws -> AIAgentImplementation { + try AIAgentImplementation(configuration: AIConfiguration(model: .gemini20Flash, apiKey: apiKey)) + } + + static func gemini20FlashLite(apiKey: String) throws -> AIAgentImplementation { + try AIAgentImplementation(configuration: AIConfiguration(model: .gemini20FlashLite, apiKey: apiKey)) + } + + static func gemini15Pro(apiKey: String) throws -> AIAgentImplementation { + try AIAgentImplementation(configuration: AIConfiguration(model: .gemini15Pro, apiKey: apiKey)) + } + + static func gemini15Flash(apiKey: String) throws -> AIAgentImplementation { + try AIAgentImplementation(configuration: AIConfiguration(model: .gemini15Flash, apiKey: apiKey)) + } + + // MARK: Ollama (local — no API key required) + + static func ollamaLlama32() throws -> AIAgentImplementation { + try AIAgentImplementation(configuration: AIConfiguration(model: .ollamaLlama32, apiKey: "")) + } + + static func ollamaMistral() throws -> AIAgentImplementation { + try AIAgentImplementation(configuration: AIConfiguration(model: .ollamaMistral, apiKey: "")) + } + + static func ollamaCustom(name: String, maxTokens: Int = 32768) throws -> AIAgentImplementation { + let model = AIModel.ollamaCustom(name: name, maxTokens: maxTokens) + return try AIAgentImplementation(configuration: AIConfiguration(model: model, apiKey: "")) } } diff --git a/Sources/SwiftAIAgentCore/Network/AnthropicClient.swift b/Sources/SwiftAIAgentCore/Network/AnthropicClient.swift index f849591..59256eb 100644 --- a/Sources/SwiftAIAgentCore/Network/AnthropicClient.swift +++ b/Sources/SwiftAIAgentCore/Network/AnthropicClient.swift @@ -10,320 +10,400 @@ actor AnthropicClient: Sendable { self.networkClient = NetworkClient(retryPolicy: configuration.retryPolicy) } - // MARK: - Request/Response Models - - private struct MessagesRequest: Encodable { - let model: String - let messages: [Message] - let maxTokens: Int - let temperature: Double - let system: String? - let stream: Bool - /// Outils disponibles au format Anthropic (optionnel) - let tools: [ToolDefinition]? - - enum CodingKeys: String, CodingKey { - case model, messages, temperature, system, stream, tools - case maxTokens = "max_tokens" - } + // MARK: - Public Methods - struct Message: Encodable { - let role: String - /// Contenu du message — texte ou blocs tool_result selon le rôle - let content: MessageContent - - /// Encodage polymorphique : texte simple ou tableau de blocs - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: MessageCodingKeys.self) - try container.encode(role, forKey: .role) - switch content { - case .text(let text): - try container.encode(text, forKey: .content) - case .toolResults(let blocks): - try container.encode(blocks, forKey: .content) - } - } + func sendCompletion(messages: [AIMessage]) async throws -> AIMessage { + let request = try buildRequest(messages: messages, tools: nil, stream: false) + let (data, _) = try await networkClient.execute(request: request) + return try decodeTextResponse(from: data) + } - enum MessageCodingKeys: String, CodingKey { - case role, content - } - } + func sendCompletionWithTools(messages: [AIMessage], tools: [AITool]) async throws -> AIMessageWithTools { + let request = try buildRequest(messages: messages, tools: tools, stream: false) + let (data, _) = try await networkClient.execute(request: request) + + let response = try JSONDecoder().decode(MessagesResponse.self, from: data) + let text = response.content.first(where: { $0.type == "text" })?.text ?? "" - /// Contenu d'un message : texte pur ou blocs de résultats d'outils - enum MessageContent { - case text(String) - case toolResults([ToolResultBlock]) + let toolCalls: [AIToolCall] = response.content.compactMap { block in + guard block.type == "tool_use", let id = block.id, let name = block.name else { return nil } + return AIToolCall(id: id, name: name, arguments: block.input?.toJSONString() ?? "{}") } - /// Bloc de résultat d'outil au format Anthropic - struct ToolResultBlock: Encodable { - /// Toujours "tool_result" pour le format Anthropic - let type: String - let toolUseId: String - let content: String + return AIMessageWithTools(message: AIMessage(role: .assistant, content: text), toolCalls: toolCalls) + } - enum CodingKeys: String, CodingKey { - case type, content - case toolUseId = "tool_use_id" - } + func streamCompletion(messages: [AIMessage]) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + do { + let request = try self.buildRequest(messages: messages, tools: nil, stream: true) + let stream = await self.networkClient.stream(request: request) - init(toolUseId: String, content: String) { - self.type = "tool_result" - self.toolUseId = toolUseId - self.content = content + for try await data in stream { + let lines = String(data: data, encoding: .utf8)? + .components(separatedBy: "\n").filter { !$0.isEmpty } ?? [] + for line in lines { + guard line.hasPrefix("data: ") else { continue } + let json = String(line.dropFirst(6)) + guard let eventData = json.data(using: .utf8), + let event = try? JSONDecoder().decode(StreamEvent.self, from: eventData) else { continue } + + switch event.type { + case "content_block_delta": + if let text = event.delta?.text { continuation.yield(text) } + case "message_stop": + continuation.finish(); return + default: + break + } + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } } } + } - /// Définition d'un outil au format Anthropic - struct ToolDefinition: Encodable { - let name: String - let description: String - /// Schéma des paramètres au format JSON Schema (clé: "input_schema") - let inputSchema: AITool.AIToolParameters + // MARK: - Private Helpers - enum CodingKeys: String, CodingKey { - case name, description - case inputSchema = "input_schema" - } + private func decodeTextResponse(from data: Data) throws -> AIMessage { + let response = try JSONDecoder().decode(MessagesResponse.self, from: data) + guard let text = response.content.first(where: { $0.type == "text" })?.text else { + throw AIError.invalidResponse(statusCode: 200, message: "No content in response") } + return AIMessage(role: .assistant, content: text) } - private struct MessagesResponse: Decodable { - let id: String - let content: [Content] - let stopReason: String? + private func buildRequest(messages: [AIMessage], tools: [AITool]?, stream: Bool) throws -> URLRequest { + try configuration.validate() - enum CodingKeys: String, CodingKey { - case id, content - case stopReason = "stop_reason" + let rawURL = "\(configuration.model.provider.baseURL)/messages" + guard let url = URL(string: rawURL) else { + throw AIError.invalidContext("Invalid Anthropic endpoint URL: \(rawURL)") } - /// Bloc de contenu — peut être un texte ou un appel d'outil - struct Content: Decodable { - let type: String - let text: String? - /// Identifiant de l'appel d'outil (type "tool_use") - let id: String? - /// Nom de l'outil à appeler (type "tool_use") - let name: String? - /// Arguments de l'outil sous forme de dictionnaire brut - let input: AnthropicInput? + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(configuration.apiKey, forHTTPHeaderField: "x-api-key") + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + request.timeoutInterval = configuration.timeout + + // Feature 5: Add prompt caching beta header when any message opts in + let usesCaching = messages.contains { $0.cacheControl } + if usesCaching { + request.setValue("prompt-caching-2024-07-31", forHTTPHeaderField: "anthropic-beta") } - /// Représentation intermédiaire pour les arguments d'outil (JSON dynamique) - struct AnthropicInput: Decodable { - let raw: [String: AnthropicValue] + let systemMessages = messages.filter { $0.role == .system } + let conversationMessages = messages.filter { $0.role != .system } - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - raw = try container.decode([String: AnthropicValue].self) + // Build system content — supports caching on individual system messages + let systemContent: SystemContent? = systemMessages.isEmpty ? nil : { + if systemMessages.allSatisfy({ !$0.cacheControl }) { + // Simple string format when no caching needed + return .text(systemMessages.map { $0.content }.joined(separator: "\n")) } - - /// Sérialise les arguments en JSON String pour AIToolCall - func toJSONString() -> String { - let dict = raw.mapValues { $0.toAny() } - guard let data = try? JSONSerialization.data(withJSONObject: dict), - let string = String(data: data, encoding: .utf8) else { - return "{}" - } - return string + // Block format required for cache_control + let blocks = systemMessages.map { msg in + SystemBlock(text: msg.content, cached: msg.cacheControl) } - } + return .blocks(blocks) + }() - /// Valeur JSON générique pour désérialiser les inputs d'outils Anthropic - enum AnthropicValue: Decodable { - case string(String) - case int(Int) - case double(Double) - case bool(Bool) - case null - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let value = try? container.decode(String.self) { - self = .string(value) - } else if let value = try? container.decode(Int.self) { - self = .int(value) - } else if let value = try? container.decode(Double.self) { - self = .double(value) - } else if let value = try? container.decode(Bool.self) { - self = .bool(value) - } else { - self = .null - } - } + let anthropicMessages = conversationMessages.map { encodeMessage($0) } - func toAny() -> Any { - switch self { - case .string(let value): return value - case .int(let value): return value - case .double(let value): return value - case .bool(let value): return value - case .null: return NSNull() - } - } - } + let toolDefs = tools.map { $0.map { ToolDefinition(from: $0) } } + + let body = MessagesRequest( + model: configuration.model.name, + messages: anthropicMessages, + maxTokens: configuration.maxResponseTokens, + temperature: configuration.temperature, + system: systemContent, + stream: stream, + tools: toolDefs + ) + + request.httpBody = try JSONEncoder().encode(body) + return request } - private struct StreamEvent: Decodable { - let type: String - let delta: Delta? - let contentBlock: ContentBlock? + /// Converts an AIMessage into the Anthropic wire format + private func encodeMessage(_ message: AIMessage) -> OutboundMessage { + // Tool result messages become user messages with tool_result blocks + if message.role == .tool, let callId = message.metadata?["tool_call_id"] { + return OutboundMessage(role: "user", blocks: [.toolResult(id: callId, content: message.content)]) + } - enum CodingKeys: String, CodingKey { - case type, delta - case contentBlock = "content_block" + // Assistant messages with tool calls become mixed content blocks + if message.role == .assistant, let calls = message.toolCalls, !calls.isEmpty { + var blocks: [ContentBlock] = [] + if !message.content.isEmpty { + blocks.append(.text(message.content, cached: false)) + } + for call in calls { + let input = call.decodeArguments().map { dict in + dict.mapValues { AnthropicScalar.from($0) } + } ?? [:] + blocks.append(.toolUse(id: call.id, name: call.name, input: input)) + } + return OutboundMessage(role: "assistant", blocks: blocks) } - struct Delta: Decodable { - let text: String? + // Messages with images use block content + if let images = message.images, !images.isEmpty { + var blocks: [ContentBlock] = [] + for image in images { + blocks.append(.image(image, cached: message.cacheControl)) + } + blocks.append(.text(message.content, cached: message.cacheControl)) + return OutboundMessage(role: message.role.anthropicName, blocks: blocks) } - struct ContentBlock: Decodable { - let text: String? + // Simple text message (possibly cached) + if message.cacheControl { + return OutboundMessage(role: message.role.anthropicName, blocks: [.text(message.content, cached: true)]) } + + return OutboundMessage(role: message.role.anthropicName, text: message.content) } +} - // MARK: - Public Methods +// MARK: - Request Models - func sendCompletion(messages: [AIMessage]) async throws -> AIMessage { - let request = try createRequest(messages: messages, tools: nil, stream: false) - let (data, _) = try await networkClient.execute(request: request) +private struct MessagesRequest: Encodable { + let model: String + let messages: [OutboundMessage] + let maxTokens: Int + let temperature: Double + let system: SystemContent? + let stream: Bool + let tools: [ToolDefinition]? - let response = try JSONDecoder().decode(MessagesResponse.self, from: data) + enum CodingKeys: String, CodingKey { + case model, messages, temperature, system, stream, tools + case maxTokens = "max_tokens" + } +} - // Extrait le premier bloc texte disponible - guard let textContent = response.content.first(where: { $0.type == "text" })?.text else { - throw AIError.invalidResponse(statusCode: 200, message: "No content in response") +private enum SystemContent: Encodable { + case text(String) + case blocks([SystemBlock]) + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .text(let t): try container.encode(t) + case .blocks(let blocks): try container.encode(blocks) } + } +} - return AIMessage(role: .assistant, content: textContent) +private struct SystemBlock: Encodable { + let type = "text" + let text: String + let cacheControl: CacheControl? + + init(text: String, cached: Bool) { + self.text = text + self.cacheControl = cached ? CacheControl() : nil } - /// Envoie des messages avec des outils disponibles — le modèle peut retourner des appels d'outils - func sendCompletionWithTools(messages: [AIMessage], tools: [AITool]) async throws -> AIMessageWithTools { - let request = try createRequest(messages: messages, tools: tools, stream: false) - let (data, _) = try await networkClient.execute(request: request) + enum CodingKeys: String, CodingKey { + case type, text + case cacheControl = "cache_control" + } +} - let response = try JSONDecoder().decode(MessagesResponse.self, from: data) +private struct CacheControl: Encodable { + let type = "ephemeral" +} - // Extrait le contenu textuel (peut être absent si le modèle n'appelle que des outils) - let textContent = response.content.first(where: { $0.type == "text" })?.text ?? "" +/// Anthropic message with polymorphic content (string or blocks array) +private struct OutboundMessage: Encodable { + let role: String + private let textContent: String? + private let blocks: [ContentBlock]? - // Extrait les appels d'outils des blocs "tool_use" - let toolCalls: [AIToolCall] = response.content.compactMap { block in - guard block.type == "tool_use", - let callId = block.id, - let toolName = block.name else { return nil } - let arguments = block.input?.toJSONString() ?? "{}" - return AIToolCall(id: callId, name: toolName, arguments: arguments) + init(role: String, text: String) { + self.role = role + self.textContent = text + self.blocks = nil + } + + init(role: String, blocks: [ContentBlock]) { + self.role = role + self.textContent = nil + self.blocks = blocks + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: Keys.self) + try c.encode(role, forKey: .role) + if let blocks { + try c.encode(blocks, forKey: .content) + } else { + try c.encode(textContent ?? "", forKey: .content) } + } - let assistantMessage = AIMessage(role: .assistant, content: textContent) - return AIMessageWithTools(message: assistantMessage, toolCalls: toolCalls) + enum Keys: String, CodingKey { case role, content } +} + +private enum ContentBlock: Encodable { + case text(String, cached: Bool) + case image(AIImageContent, cached: Bool) + case toolResult(id: String, content: String) + case toolUse(id: String, name: String, input: [String: AnthropicScalar]) + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: BlockKeys.self) + switch self { + case .text(let text, let cached): + try c.encode("text", forKey: .type) + try c.encode(text, forKey: .text) + if cached { try c.encode(CacheControl(), forKey: .cacheControl) } + + case .image(let img, let cached): + try c.encode("image", forKey: .type) + switch img { + case .url(let url): + try c.encode(["type": "url", "url": url.absoluteString], forKey: .source) + case .data(let bytes, let mime): + try c.encode(["type": "base64", "media_type": mime, "data": bytes.base64EncodedString()], forKey: .source) + } + if cached { try c.encode(CacheControl(), forKey: .cacheControl) } + + case .toolResult(let id, let content): + try c.encode("tool_result", forKey: .type) + try c.encode(id, forKey: .toolUseId) + try c.encode(content, forKey: .content) + + case .toolUse(let id, let name, let input): + try c.encode("tool_use", forKey: .type) + try c.encode(id, forKey: .id) + try c.encode(name, forKey: .name) + try c.encode(input, forKey: .input) + } } - func streamCompletion(messages: [AIMessage]) -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - Task { - do { - let request = try createRequest(messages: messages, tools: nil, stream: true) - let stream = networkClient.stream(request: request) + enum BlockKeys: String, CodingKey { + case type, text, source, content, id, name, input + case cacheControl = "cache_control" + case toolUseId = "tool_use_id" + } +} - for try await data in stream { - let lines = String(data: data, encoding: .utf8)? - .components(separatedBy: "\n") - .filter { !$0.isEmpty } ?? [] +/// Scalar JSON value used for tool_use input encoding +enum AnthropicScalar: Codable, Sendable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case null + + static func from(_ any: Any) -> AnthropicScalar { + switch any { + case let v as String: return .string(v) + case let v as Bool: return .bool(v) // must precede Int/Double — NSNumber ambiguity + case let v as Int: return .int(v) + case let v as Double: return .double(v) + default: return .null + } + } - for line in lines { - guard line.hasPrefix("data: ") else { continue } - let jsonString = String(line.dropFirst(6)) - - if let data = jsonString.data(using: .utf8), - let event = try? JSONDecoder().decode(StreamEvent.self, from: data) { - - // Traite les différents types d'événements du stream Anthropic - switch event.type { - case "content_block_delta": - if let text = event.delta?.text { - continuation.yield(text) - } - case "message_stop": - continuation.finish() - return - default: - break - } - } - } - } + init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if let v = try? c.decode(String.self) { self = .string(v) } + else if let v = try? c.decode(Int.self) { self = .int(v) } + else if let v = try? c.decode(Double.self) { self = .double(v) } + else if let v = try? c.decode(Bool.self) { self = .bool(v) } + else { self = .null } + } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } + func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + switch self { + case .string(let v): try c.encode(v) + case .int(let v): try c.encode(v) + case .double(let v): try c.encode(v) + case .bool(let v): try c.encode(v) + case .null: try c.encodeNil() } } +} - // MARK: - Private Helpers +private struct ToolDefinition: Encodable { + let name: String + let description: String + let inputSchema: AITool.AIToolParameters - private func createRequest(messages: [AIMessage], tools: [AITool]?, stream: Bool) throws -> URLRequest { - try configuration.validate() + enum CodingKeys: String, CodingKey { + case name, description + case inputSchema = "input_schema" + } - let url = URL(string: "\(configuration.model.provider.baseURL)/messages")! - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue(configuration.apiKey, forHTTPHeaderField: "x-api-key") - request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") - request.timeoutInterval = configuration.timeout + init(from tool: AITool) { + self.name = tool.name + self.description = tool.description + self.inputSchema = tool.parameters + } +} - // Extrait le message système (géré séparément par l'API Anthropic) - let systemMessage = messages.first(where: { $0.role == .system })?.content - let conversationMessages = messages.filter { $0.role != .system } +// MARK: - Response Models - // Convertit les messages en format Anthropic, en gérant les tool results - let anthropicMessages = conversationMessages.map { message -> MessagesRequest.Message in - if message.role == .tool, let toolCallId = message.metadata?["tool_call_id"] { - // Les résultats d'outils utilisent un contenu structuré en blocs - let block = MessagesRequest.ToolResultBlock( - toolUseId: toolCallId, - content: message.content - ) - return MessagesRequest.Message( - role: "user", - content: .toolResults([block]) - ) - } - return MessagesRequest.Message( - role: message.role.anthropicName, - content: .text(message.content) - ) +private struct MessagesResponse: Decodable { + let content: [ContentBlock] + let stopReason: String? + + enum CodingKeys: String, CodingKey { + case content + case stopReason = "stop_reason" + } + + struct ContentBlock: Decodable { + let type: String + let text: String? + let id: String? + let name: String? + let input: AnthropicInput? + } + + struct AnthropicInput: Decodable { + let raw: [String: AnthropicScalar] + + init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + raw = try c.decode([String: AnthropicScalar].self) } - // Convertit les AITool en ToolDefinition Anthropic si présents - let toolDefinitions = tools.map { toolArray in - toolArray.map { tool in - MessagesRequest.ToolDefinition( - name: tool.name, - description: tool.description, - inputSchema: tool.parameters - ) + func toJSONString() -> String { + var dict: [String: Any] = [:] + for (key, value) in raw { + switch value { + case .string(let v): dict[key] = v + case .int(let v): dict[key] = v + case .double(let v): dict[key] = v + case .bool(let v): dict[key] = v + case .null: dict[key] = NSNull() + } } + guard let data = try? JSONSerialization.data(withJSONObject: dict), + let str = String(data: data, encoding: .utf8) else { return "{}" } + return str } + } +} - let requestBody = MessagesRequest( - model: configuration.model.name, - messages: anthropicMessages, - maxTokens: configuration.maxResponseTokens, - temperature: configuration.temperature, - system: systemMessage, - stream: stream, - tools: toolDefinitions - ) +private struct StreamEvent: Decodable { + let type: String + let delta: Delta? - request.httpBody = try JSONEncoder().encode(requestBody) - return request + struct Delta: Decodable { + let text: String? } } diff --git a/Sources/SwiftAIAgentCore/Network/GeminiClient.swift b/Sources/SwiftAIAgentCore/Network/GeminiClient.swift new file mode 100644 index 0000000..ee0ed7e --- /dev/null +++ b/Sources/SwiftAIAgentCore/Network/GeminiClient.swift @@ -0,0 +1,371 @@ +import Foundation + +/// Google Gemini API client +/// API key is passed as a query parameter, not a header +actor GeminiClient: Sendable { + private let networkClient: NetworkClient + private let configuration: AIConfiguration + + init(configuration: AIConfiguration) { + self.configuration = configuration + self.networkClient = NetworkClient(retryPolicy: configuration.retryPolicy) + } + + // MARK: - Public Methods + + func sendCompletion(messages: [AIMessage]) async throws -> AIMessage { + let request = try buildRequest(messages: messages, tools: nil, stream: false, jsonMode: false) + let (data, _) = try await networkClient.execute(request: request) + return try decodeResponse(from: data) + } + + func sendCompletionJSON(messages: [AIMessage]) async throws -> AIMessage { + let request = try buildRequest(messages: messages, tools: nil, stream: false, jsonMode: true) + let (data, _) = try await networkClient.execute(request: request) + return try decodeResponse(from: data) + } + + func sendCompletionWithTools(messages: [AIMessage], tools: [AITool]) async throws -> AIMessageWithTools { + let request = try buildRequest(messages: messages, tools: tools, stream: false, jsonMode: false) + let (data, _) = try await networkClient.execute(request: request) + let response = try JSONDecoder().decode(GenerateContentResponse.self, from: data) + + guard let candidate = response.candidates.first else { + throw AIError.invalidResponse(statusCode: 200, message: "No candidates in response") + } + + var text = "" + var toolCalls: [AIToolCall] = [] + + for part in candidate.content.parts { + if let t = part.text { text += t } + if let call = part.functionCall { + let args = serializeArgs(call.args) + toolCalls.append(AIToolCall(id: UUID().uuidString, name: call.name, arguments: args)) + } + } + + return AIMessageWithTools(message: AIMessage(role: .assistant, content: text), toolCalls: toolCalls) + } + + func streamCompletion(messages: [AIMessage]) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + do { + let request = try self.buildRequest(messages: messages, tools: nil, stream: true, jsonMode: false) + let stream = await self.networkClient.stream(request: request) + + for try await data in stream { + let lines = String(data: data, encoding: .utf8)? + .components(separatedBy: "\n").filter { !$0.isEmpty } ?? [] + for line in lines { + guard line.hasPrefix("data: ") else { continue } + let json = String(line.dropFirst(6)) + guard let chunkData = json.data(using: .utf8), + let response = try? JSONDecoder().decode(GenerateContentResponse.self, from: chunkData) else { continue } + for part in response.candidates.first?.content.parts ?? [] { + if let text = part.text { continuation.yield(text) } + } + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + + // MARK: - Private Helpers + + private func decodeResponse(from data: Data) throws -> AIMessage { + let response = try JSONDecoder().decode(GenerateContentResponse.self, from: data) + guard let candidate = response.candidates.first else { + throw AIError.invalidResponse(statusCode: 200, message: "No candidates in response") + } + let text = candidate.content.parts.compactMap { $0.text }.joined() + return AIMessage(role: .assistant, content: text) + } + + private func buildRequest(messages: [AIMessage], tools: [AITool]?, stream: Bool, jsonMode: Bool) throws -> URLRequest { + try configuration.validate() + + let method = stream ? "streamGenerateContent" : "generateContent" + var components = URLComponents(string: "\(configuration.model.provider.baseURL)/models/\(configuration.model.name):\(method)") + var queryItems = [URLQueryItem(name: "key", value: configuration.apiKey)] + if stream { queryItems.append(URLQueryItem(name: "alt", value: "sse")) } + components?.queryItems = queryItems + + guard let url = components?.url else { + throw AIError.invalidContext("Invalid Gemini endpoint URL") + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = configuration.timeout + + // Extract system message + let systemInstruction = messages.first(where: { $0.role == .system }).map { msg in + SystemInstruction(parts: [GeminiPart(text: msg.content)]) + } + let conversationMessages = messages.filter { $0.role != .system } + + // Encode tool definitions + let geminiTools = tools.map { toolArray in + [GeminiTool(functionDeclarations: toolArray.map { FunctionDeclaration(from: $0) })] + } + + let config = GenerationConfig( + temperature: configuration.temperature, + maxOutputTokens: configuration.maxResponseTokens, + responseMimeType: jsonMode ? "application/json" : "text/plain" + ) + + let body = GenerateContentRequest( + contents: conversationMessages.map { encodeMessage($0) }, + systemInstruction: systemInstruction, + generationConfig: config, + tools: geminiTools + ) + + request.httpBody = try JSONEncoder().encode(body) + return request + } + + private func encodeMessage(_ message: AIMessage) -> GeminiContent { + let role = message.role == .assistant ? "model" : "user" + + // Tool result messages + if message.role == .tool, let callId = message.metadata?["tool_call_id"] { + let part = GeminiPart(functionResponse: FunctionResponse( + name: callId, + response: GeminiResponsePayload(content: message.content) + )) + return GeminiContent(role: "user", parts: [part]) + } + + // Assistant messages with tool calls + if message.role == .assistant, let calls = message.toolCalls, !calls.isEmpty { + var parts: [GeminiPart] = [] + if !message.content.isEmpty { parts.append(GeminiPart(text: message.content)) } + for call in calls { + let rawArgs = call.decodeArguments() ?? [:] + let args = rawArgs.mapValues { GeminiScalar.from($0) } + parts.append(GeminiPart(functionCall: FunctionCall(name: call.name, args: args))) + } + return GeminiContent(role: "model", parts: parts) + } + + var parts: [GeminiPart] = [] + + // Vision: encode images as inlineData + if let images = message.images { + for image in images { + switch image { + case .data(let bytes, let mime): + parts.append(GeminiPart(inlineData: InlineData(mimeType: mime, data: bytes.base64EncodedString()))) + case .url: + break // Gemini inline data only; URL images not supported in this client + } + } + } + + parts.append(GeminiPart(text: message.content)) + return GeminiContent(role: role, parts: parts) + } + + private func serializeArgs(_ args: [String: GeminiArgValue]?) -> String { + guard let args else { return "{}" } + let scalars = args.mapValues { GeminiScalar.from($0) } + guard let data = try? JSONEncoder().encode(scalars), + let str = String(data: data, encoding: .utf8) else { return "{}" } + return str + } +} + +// MARK: - Request Models + +private struct GenerateContentRequest: Encodable { + let contents: [GeminiContent] + let systemInstruction: SystemInstruction? + let generationConfig: GenerationConfig + let tools: [GeminiTool]? +} + +private struct GeminiContent: Encodable { + let role: String + let parts: [GeminiPart] +} + +private struct GeminiPart: Encodable { + let text: String? + let inlineData: InlineData? + let functionCall: FunctionCall? + let functionResponse: FunctionResponse? + + init(text: String) { self.text = text; self.inlineData = nil; self.functionCall = nil; self.functionResponse = nil } + init(inlineData: InlineData) { self.text = nil; self.inlineData = inlineData; self.functionCall = nil; self.functionResponse = nil } + init(functionCall: FunctionCall) { self.text = nil; self.inlineData = nil; self.functionCall = functionCall; self.functionResponse = nil } + init(functionResponse: FunctionResponse) { self.text = nil; self.inlineData = nil; self.functionCall = nil; self.functionResponse = functionResponse } + + enum CodingKeys: String, CodingKey { + case text + case inlineData = "inline_data" + case functionCall = "function_call" + case functionResponse = "function_response" + } +} + +private struct InlineData: Encodable { + let mimeType: String + let data: String + + enum CodingKeys: String, CodingKey { + case mimeType = "mime_type" + case data + } +} + +private struct FunctionCall: Encodable { + let name: String + let args: [String: GeminiScalar] +} + +private struct FunctionResponse: Encodable { + let name: String + let response: GeminiResponsePayload +} + +private struct GeminiResponsePayload: Encodable { + let content: String +} + +private struct SystemInstruction: Encodable { + let parts: [GeminiPart] +} + +private struct GenerationConfig: Encodable { + let temperature: Double + let maxOutputTokens: Int + let responseMimeType: String + + enum CodingKeys: String, CodingKey { + case temperature + case maxOutputTokens = "maxOutputTokens" + case responseMimeType = "responseMimeType" + } +} + +private struct GeminiTool: Encodable { + let functionDeclarations: [FunctionDeclaration] +} + +private struct FunctionDeclaration: Encodable { + let name: String + let description: String + let parameters: AITool.AIToolParameters + + init(from tool: AITool) { + self.name = tool.name + self.description = tool.description + self.parameters = tool.parameters + } +} + +// MARK: - Response Models + +private struct GenerateContentResponse: Decodable { + let candidates: [Candidate] + + struct Candidate: Decodable { + let content: Content + let finishReason: String? + + enum CodingKeys: String, CodingKey { + case content + case finishReason = "finishReason" + } + } + + struct Content: Decodable { + let parts: [Part] + let role: String? + } + + struct Part: Decodable { + let text: String? + let functionCall: FunctionCallResponse? + + enum CodingKeys: String, CodingKey { + case text + case functionCall = "functionCall" + } + } + + struct FunctionCallResponse: Decodable { + let name: String + let args: [String: GeminiArgValue]? + } +} + +/// Scalar JSON value for encoding Gemini function call arguments (outbound) +private enum GeminiScalar: Codable, Sendable { + case string(String) + case number(Double) + case bool(Bool) + case null + + static func from(_ any: Any) -> GeminiScalar { + switch any { + case let v as String: return .string(v) + case let v as Bool: return .bool(v) // must precede Int/Double — NSNumber ambiguity + case let v as Int: return .number(Double(v)) + case let v as Double: return .number(v) + default: return .null + } + } + + static func from(_ value: GeminiArgValue) -> GeminiScalar { + switch value { + case .string(let v): return .string(v) + case .number(let v): return .number(v) + case .bool(let v): return .bool(v) + case .null: return .null + } + } + + init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if let v = try? c.decode(String.self) { self = .string(v) } + else if let v = try? c.decode(Double.self) { self = .number(v) } + else if let v = try? c.decode(Bool.self) { self = .bool(v) } + else { self = .null } + } + + func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + switch self { + case .string(let v): try c.encode(v) + case .number(let v): try c.encode(v) + case .bool(let v): try c.encode(v) + case .null: try c.encodeNil() + } + } +} + +/// Generic JSON value for Gemini function call arguments +enum GeminiArgValue: Decodable, Sendable { + case string(String) + case number(Double) + case bool(Bool) + case null + + init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if let v = try? c.decode(String.self) { self = .string(v) } + else if let v = try? c.decode(Double.self) { self = .number(v) } + else if let v = try? c.decode(Bool.self) { self = .bool(v) } + else { self = .null } + } +} diff --git a/Sources/SwiftAIAgentCore/Network/OpenAIClient.swift b/Sources/SwiftAIAgentCore/Network/OpenAIClient.swift index 735a312..46163d3 100644 --- a/Sources/SwiftAIAgentCore/Network/OpenAIClient.swift +++ b/Sources/SwiftAIAgentCore/Network/OpenAIClient.swift @@ -1,6 +1,6 @@ import Foundation -/// OpenAI API client implementation +/// OpenAI API client — also used for Ollama (OpenAI-compatible endpoint) actor OpenAIClient: Sendable { private let networkClient: NetworkClient private let configuration: AIConfiguration @@ -10,164 +10,41 @@ actor OpenAIClient: Sendable { self.networkClient = NetworkClient(retryPolicy: configuration.retryPolicy) } - // MARK: - Request/Response Models - - private struct ChatCompletionRequest: Encodable { - let model: String - let messages: [Message] - let temperature: Double - let maxTokens: Int - let stream: Bool - /// Outils disponibles pour le function calling (optionnel) - let tools: [ToolDefinition]? - - enum CodingKeys: String, CodingKey { - case model, messages, temperature, tools - case maxTokens = "max_tokens" - case stream - } - - struct Message: Encodable { - let role: String - let content: String - /// Identifiant de l'appel d'outil auquel ce message répond (role "tool" uniquement) - let toolCallId: String? - - enum CodingKeys: String, CodingKey { - case role, content - case toolCallId = "tool_call_id" - } - - init(role: String, content: String, toolCallId: String? = nil) { - self.role = role - self.content = content - self.toolCallId = toolCallId - } - } - - /// Définition d'un outil au format OpenAI function calling - struct ToolDefinition: Encodable { - /// Toujours "function" pour le format OpenAI - let type: String - let function: FunctionDefinition - - struct FunctionDefinition: Encodable { - let name: String - let description: String - let parameters: AITool.AIToolParameters - } - } - } - - private struct ChatCompletionResponse: Decodable { - let id: String - let choices: [Choice] - - struct Choice: Decodable { - let message: Message - let finishReason: String? - - enum CodingKeys: String, CodingKey { - case message - case finishReason = "finish_reason" - } - } - - struct Message: Decodable { - let role: String - /// Contenu textuel — peut être nil si le modèle n'appelle que des outils - let content: String? - /// Appels d'outils demandés par le modèle (nil si réponse textuelle) - let toolCalls: [ToolCallResponse]? - - enum CodingKeys: String, CodingKey { - case role, content - case toolCalls = "tool_calls" - } - } - - /// Appel d'outil tel que retourné par l'API OpenAI - struct ToolCallResponse: Decodable { - let id: String - let function: FunctionCall - - struct FunctionCall: Decodable { - let name: String - let arguments: String - } - } - } - - private struct StreamChunk: Decodable { - let choices: [Choice] - - struct Choice: Decodable { - let delta: Delta - let finishReason: String? - - enum CodingKeys: String, CodingKey { - case delta - case finishReason = "finish_reason" - } - } - - struct Delta: Decodable { - let content: String? - } - } - // MARK: - Public Methods func sendCompletion(messages: [AIMessage]) async throws -> AIMessage { - let request = try createRequest(messages: messages, tools: nil, stream: false) - let (data, _) = try await networkClient.execute(request: request) - - let response = try JSONDecoder().decode(ChatCompletionResponse.self, from: data) - - guard let choice = response.choices.first else { - throw AIError.invalidResponse(statusCode: 200, message: "No choices in response") - } + try await sendInternal(messages: messages, tools: nil, stream: false, jsonMode: false) + } - return AIMessage( - role: AIRole(rawValue: choice.message.role) ?? .assistant, - content: choice.message.content ?? "" - ) + func sendCompletionJSON(messages: [AIMessage]) async throws -> AIMessage { + try await sendInternal(messages: messages, tools: nil, stream: false, jsonMode: true) } - /// Envoie des messages avec des outils disponibles — le modèle peut retourner des appels d'outils func sendCompletionWithTools(messages: [AIMessage], tools: [AITool]) async throws -> AIMessageWithTools { - let request = try createRequest(messages: messages, tools: tools, stream: false) + let request = try buildRequest(messages: messages, tools: tools, stream: false, jsonMode: false) let (data, _) = try await networkClient.execute(request: request) - let response = try JSONDecoder().decode(ChatCompletionResponse.self, from: data) guard let choice = response.choices.first else { throw AIError.invalidResponse(statusCode: 200, message: "No choices in response") } - // Convertit les tool calls de la réponse OpenAI en AIToolCall - let toolCalls: [AIToolCall] = choice.message.toolCalls?.map { callResponse in - AIToolCall( - id: callResponse.id, - name: callResponse.function.name, - arguments: callResponse.function.arguments - ) + let toolCalls: [AIToolCall] = choice.message.toolCalls?.map { call in + AIToolCall(id: call.id, name: call.function.name, arguments: call.function.arguments) } ?? [] - let assistantMessage = AIMessage( - role: .assistant, - content: choice.message.content ?? "" + return AIMessageWithTools( + message: AIMessage(role: .assistant, content: choice.message.content ?? ""), + toolCalls: toolCalls ) - - return AIMessageWithTools(message: assistantMessage, toolCalls: toolCalls) } func streamCompletion(messages: [AIMessage]) -> AsyncThrowingStream { AsyncThrowingStream { continuation in Task { do { - let request = try createRequest(messages: messages, tools: nil, stream: true) - let stream = networkClient.stream(request: request) + let request = try self.buildRequest(messages: messages, tools: nil, stream: true, jsonMode: false) + let stream = await self.networkClient.stream(request: request) for try await data in stream { let lines = String(data: data, encoding: .utf8)? @@ -176,21 +53,16 @@ actor OpenAIClient: Sendable { for line in lines { guard line.hasPrefix("data: ") else { continue } - let jsonString = String(line.dropFirst(6)) - - if jsonString == "[DONE]" { - continuation.finish() - return - } + let json = String(line.dropFirst(6)) + if json == "[DONE]" { continuation.finish(); return } - if let data = jsonString.data(using: .utf8), - let chunk = try? JSONDecoder().decode(StreamChunk.self, from: data), + if let chunkData = json.data(using: .utf8), + let chunk = try? JSONDecoder().decode(StreamChunk.self, from: chunkData), let content = chunk.choices.first?.delta.content { continuation.yield(content) } } } - continuation.finish() } catch { continuation.finish(throwing: error) @@ -201,50 +73,227 @@ actor OpenAIClient: Sendable { // MARK: - Private Helpers - private func createRequest(messages: [AIMessage], tools: [AITool]?, stream: Bool) throws -> URLRequest { + private func sendInternal(messages: [AIMessage], tools: [AITool]?, stream: Bool, jsonMode: Bool) async throws -> AIMessage { + let request = try buildRequest(messages: messages, tools: tools, stream: stream, jsonMode: jsonMode) + let (data, _) = try await networkClient.execute(request: request) + let response = try JSONDecoder().decode(ChatCompletionResponse.self, from: data) + + guard let choice = response.choices.first else { + throw AIError.invalidResponse(statusCode: 200, message: "No choices in response") + } + return AIMessage( + role: AIRole(rawValue: choice.message.role) ?? .assistant, + content: choice.message.content ?? "" + ) + } + + private func buildRequest(messages: [AIMessage], tools: [AITool]?, stream: Bool, jsonMode: Bool) throws -> URLRequest { try configuration.validate() - let url = URL(string: "\(configuration.model.provider.baseURL)/chat/completions")! + let rawURL = "\(configuration.model.provider.baseURL)/chat/completions" + guard let url = URL(string: rawURL) else { + throw AIError.invalidContext("Invalid endpoint URL: \(rawURL)") + } + var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("Bearer \(configuration.apiKey)", forHTTPHeaderField: "Authorization") request.timeoutInterval = configuration.timeout - // Convertit les messages en format OpenAI, en gérant le role "tool" pour les résultats - let openAIMessages = messages.map { message -> ChatCompletionRequest.Message in - let toolCallId = message.role == .tool ? message.metadata?["tool_call_id"] : nil - return ChatCompletionRequest.Message( - role: message.role.openAIName, - content: message.content, - toolCallId: toolCallId - ) - } + let encodedMessages = messages.map { encodeMessage($0) } + let toolDefs = tools.map { $0.map { ToolDefinition(from: $0) } } + let format = jsonMode ? ResponseFormat(type: "json_object") : nil - // Convertit les AITool en ToolDefinition OpenAI si présents - let toolDefinitions = tools.map { toolArray in - toolArray.map { tool in - ChatCompletionRequest.ToolDefinition( - type: "function", - function: .init( - name: tool.name, - description: tool.description, - parameters: tool.parameters - ) - ) - } - } - - let requestBody = ChatCompletionRequest( + let body = ChatCompletionRequest( model: configuration.model.name, - messages: openAIMessages, + messages: encodedMessages, temperature: configuration.temperature, maxTokens: configuration.maxResponseTokens, stream: stream, - tools: toolDefinitions + tools: toolDefs, + responseFormat: format ) - request.httpBody = try JSONEncoder().encode(requestBody) + request.httpBody = try JSONEncoder().encode(body) return request } + + /// Converts an AIMessage into the OpenAI wire format, handling vision and tool_calls + private func encodeMessage(_ message: AIMessage) -> OutboundMessage { + // Assistant messages with tool calls need the tool_calls field + if message.role == .assistant, let calls = message.toolCalls, !calls.isEmpty { + let outCalls = calls.map { call in + ToolCallOutbound(id: call.id, function: .init(name: call.name, arguments: call.arguments)) + } + return OutboundMessage(role: message.role.openAIName, stringContent: message.content, toolCallId: nil, toolCalls: outCalls, parts: nil) + } + + // Tool result messages need tool_call_id + if message.role == .tool, let callId = message.metadata?["tool_call_id"] { + return OutboundMessage(role: "tool", stringContent: message.content, toolCallId: callId, toolCalls: nil, parts: nil) + } + + // Messages with images use the parts array format + if let images = message.images, !images.isEmpty { + var parts: [ContentPart] = [.text(message.content)] + for image in images { + switch image { + case .url(let url): + parts.append(.imageURL(url.absoluteString)) + case .data(let bytes, let mime): + parts.append(.imageURL("data:\(mime);base64,\(bytes.base64EncodedString())")) + } + } + return OutboundMessage(role: message.role.openAIName, stringContent: nil, toolCallId: nil, toolCalls: nil, parts: parts) + } + + return OutboundMessage(role: message.role.openAIName, stringContent: message.content, toolCallId: nil, toolCalls: nil, parts: nil) + } +} + +// MARK: - Request / Response Models + +private struct ChatCompletionRequest: Encodable { + let model: String + let messages: [OutboundMessage] + let temperature: Double + let maxTokens: Int + let stream: Bool + let tools: [ToolDefinition]? + let responseFormat: ResponseFormat? + + enum CodingKeys: String, CodingKey { + case model, messages, temperature, stream, tools + case maxTokens = "max_tokens" + case responseFormat = "response_format" + } +} + +private struct ResponseFormat: Encodable { + let type: String +} + +/// OpenAI message with polymorphic content (string or parts array) +private struct OutboundMessage: Encodable { + let role: String + let stringContent: String? + let toolCallId: String? + let toolCalls: [ToolCallOutbound]? + let parts: [ContentPart]? + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: Keys.self) + try c.encode(role, forKey: .role) + if let calls = toolCalls, !calls.isEmpty { + try c.encode(stringContent ?? "", forKey: .content) + try c.encode(calls, forKey: .toolCalls) + } else if let parts { + try c.encode(parts, forKey: .content) + } else { + try c.encode(stringContent ?? "", forKey: .content) + } + if let id = toolCallId { try c.encode(id, forKey: .toolCallId) } + } + + enum Keys: String, CodingKey { + case role, content + case toolCallId = "tool_call_id" + case toolCalls = "tool_calls" + } +} + +private enum ContentPart: Encodable { + case text(String) + case imageURL(String) + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: PartKeys.self) + switch self { + case .text(let t): + try c.encode("text", forKey: .type) + try c.encode(t, forKey: .text) + case .imageURL(let url): + try c.encode("image_url", forKey: .type) + try c.encode(["url": url], forKey: .imageURL) + } + } + + enum PartKeys: String, CodingKey { + case type, text + case imageURL = "image_url" + } +} + +private struct ToolCallOutbound: Encodable { + let id: String + let type = "function" + let function: FunctionRef + + struct FunctionRef: Encodable { + let name: String + let arguments: String + } +} + +private struct ToolDefinition: Encodable { + let type = "function" + let function: FunctionDef + + struct FunctionDef: Encodable { + let name: String + let description: String + let parameters: AITool.AIToolParameters + } + + init(from tool: AITool) { + self.function = FunctionDef(name: tool.name, description: tool.description, parameters: tool.parameters) + } +} + +private struct ChatCompletionResponse: Decodable { + let choices: [Choice] + + struct Choice: Decodable { + let message: Message + let finishReason: String? + + enum CodingKeys: String, CodingKey { + case message + case finishReason = "finish_reason" + } + } + + struct Message: Decodable { + let role: String + let content: String? + let toolCalls: [ToolCallResponse]? + + enum CodingKeys: String, CodingKey { + case role, content + case toolCalls = "tool_calls" + } + } + + struct ToolCallResponse: Decodable { + let id: String + let function: FunctionCall + + struct FunctionCall: Decodable { + let name: String + let arguments: String + } + } +} + +private struct StreamChunk: Decodable { + let choices: [Choice] + + struct Choice: Decodable { + let delta: Delta + + struct Delta: Decodable { + let content: String? + } + } } From 1ff5210d0389f71097faa1baf4d372e8be56e998 Mon Sep 17 00:00:00 2001 From: Victor Durocher Date: Thu, 9 Apr 2026 22:05:00 +0200 Subject: [PATCH 02/14] docs: update README and umbrella docs for 5 new features - Add Gemini, Ollama, vision, agentic loop, structured outputs, prompt caching - Full convenience initializer list for all 20+ supported models - New sections: Agentic Loop, Vision, Gemini, Ollama, Structured Outputs, Prompt Caching - Updated architecture diagram and API reference - Updated SwiftAIAgentCore.swift umbrella doc comments --- README.md | 405 +++++++++++------- .../SwiftAIAgentCore/SwiftAIAgentCore.swift | 69 ++- 2 files changed, 293 insertions(+), 181 deletions(-) diff --git a/README.md b/README.md index 9a47405..6130915 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,26 @@ # Swift AI Agent Core -> Production-grade Swift package for integrating LLM agents into iOS, macOS, watchOS, and tvOS apps. +> Production-grade Swift package for integrating multi-provider LLM agents into iOS, macOS, watchOS, and tvOS apps. [![Swift 6.0](https://img.shields.io/badge/Swift-6.0-orange.svg)](https://swift.org) [![Platforms](https://img.shields.io/badge/Platforms-iOS%20%7C%20macOS%20%7C%20watchOS%20%7C%20tvOS-blue.svg)](https://developer.apple.com/) [![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) [![SPM Compatible](https://img.shields.io/badge/SPM-compatible-brightgreen.svg)](https://swift.org/package-manager/) -**SwiftAIAgentCore** integrates OpenAI and Anthropic language models into Apple platform apps. Built with Swift 6.0 strict concurrency, clean architecture, and local persistence via SwiftData. +**SwiftAIAgentCore** is a unified Swift interface for OpenAI, Anthropic Claude, Google Gemini, and local Ollama models. Built with Swift 6.0 strict concurrency, a clean protocol-oriented architecture, and local persistence via SwiftData. --- ## Features -- **Streaming responses** — real-time chunks via `AsyncThrowingStream` +- **Agentic ReAct loop** — `run(messages:tools:executor:maxSteps:)` automatically executes tool calls in a loop until the model produces a final text response +- **Vision / multi-modal** — attach images (URL or raw bytes) to any user message; encoded correctly for GPT-4o, Claude 3+, and Gemini +- **Multi-provider** — OpenAI, Anthropic Claude, Google Gemini, and local Ollama behind one `AIAgent` protocol +- **Structured outputs** — `send(messages:as:)` decodes JSON directly into any `Codable` type, with native JSON mode on OpenAI and Gemini +- **Prompt caching** — mark Anthropic messages with `cached: true` to enable the prompt-caching beta and reduce latency and cost on repeated context +- **Streaming responses** — real-time chunks via `AsyncThrowingStream` (SSE) - **Automatic retry** — exponential backoff with configurable policies -- **Token management** — estimate and validate token usage before sending requests -- **Function calling** — tool use API compatible with both OpenAI and Anthropic formats +- **Token management** — estimate and validate usage before sending requests - **Local persistence** — conversation history via SwiftData (iOS 17+ / macOS 14+) - **Typed errors** — exhaustive `AIError` enum covering all failure scenarios - **Swift 6.0 concurrency** — fully `Sendable` types, `actor`-based implementation @@ -26,8 +30,10 @@ | Provider | Models | |----------|--------| -| OpenAI | GPT-4, GPT-4 Turbo, GPT-3.5 Turbo, GPT-4o, GPT-4o Mini | -| Anthropic | Claude 3 Opus, Claude 3 Sonnet, Claude 3 Haiku, Claude 3.5 Sonnet, Claude 3.5 Haiku, Claude Sonnet 4.6 | +| OpenAI | GPT-4, GPT-4 Turbo, GPT-3.5 Turbo, GPT-4o, GPT-4o Mini, GPT-4.1, GPT-4.1 Mini | +| Anthropic | Claude 3 Haiku/Sonnet/Opus, Claude 3.5 Haiku/Sonnet, Claude 3.7 Sonnet, Claude Haiku 4.5, Claude Sonnet 4.6, Claude Opus 4.6 | +| Google | Gemini 2.0 Flash, Gemini 2.0 Flash Lite, Gemini 1.5 Pro, Gemini 1.5 Flash | +| Ollama (local) | Llama 3.2, Llama 3.1, Mistral, Phi-4, any custom model | --- @@ -67,10 +73,19 @@ Or add it via Xcode: ```swift import SwiftAIAgentCore -// Create an agent with a convenience initializer -let agent = try AIAgentImplementation.gpt4(apiKey: "your-openai-api-key") +// OpenAI +let agent = try AIAgentImplementation.gpt4o(apiKey: "sk-...") + +// Anthropic Claude +let claude = try AIAgentImplementation.claudeSonnet46(apiKey: "sk-ant-...") + +// Google Gemini +let gemini = try AIAgentImplementation.gemini20Flash(apiKey: "AIza...") -// Send a single message — returns the response as a plain String +// Ollama (local — no API key needed) +let local = try AIAgentImplementation.ollamaLlama32() + +// Send a message let response = try await agent.send(message: "Explain Swift concurrency in one sentence.") print(response) ``` @@ -91,21 +106,178 @@ let conversation: [AIMessage] = [ .user("What is a protocol?"), ] -// Returns an AIMessage with role .assistant let response = try await agent.send(messages: conversation) print(response.content) ``` -### Anthropic (Claude) +--- + +## Agentic Loop (ReAct Pattern) + +`run(messages:tools:executor:maxSteps:)` automatically calls tools and feeds results back until the model produces a final text answer — no manual loop needed. ```swift -let claudeAgent = try AIAgentImplementation.claude3Opus(apiKey: "your-anthropic-api-key") -let response = try await claudeAgent.send(message: "Summarize the Swift concurrency model.") -print(response) +let answer = try await agent.run( + messages: [.user("What is the current temperature in Paris?")], + tools: [weatherTool], + executor: { call in + // Execute the tool and return the result as a String + let args = call.decodeArguments() + let city = args?["city"] as? String ?? "Paris" + return "22°C, sunny" + }, + maxSteps: 10 +) +print(answer.content) +``` + +The loop appends tool calls and results to the conversation history, then sends the enriched history back to the model — repeating until `requiresToolExecution` is false or `maxSteps` is reached (throws `AIError.agentLoopExceeded`). + +--- + +## Vision (Multi-Modal) + +Attach images to any user message. Supported on GPT-4o, Claude 3+, and Gemini. + +```swift +// From a URL +let image = AIImageContent.url(URL(string: "https://example.com/photo.jpg")!) + +// From raw bytes (e.g. UIImage → Data) +let jpegData = uiImage.jpegData(compressionQuality: 0.8)! +let image = AIImageContent.data(jpegData, mimeType: "image/jpeg") + +// Attach to a message +let response = try await agent.send(messages: [ + .user("What is in this image?", images: [image]) +]) +print(response.content) +``` + +--- + +## Google Gemini + +```swift +let gemini = try AIAgentImplementation.gemini20Flash(apiKey: "AIza...") + +// Standard completion +let reply = try await gemini.send(message: "Hello from Gemini!") + +// Streaming +for try await chunk in gemini.stream(message: "Tell me a story") { + print(chunk, terminator: "") +} + +// Tool use +let result = try await gemini.send(messages: messages, tools: [myTool]) +``` + +All Gemini models support vision, tool use, streaming, JSON mode, and the agentic loop. + +--- + +## Ollama (Local Models) + +Run models locally with [Ollama](https://ollama.com) — no API key required. + +```swift +// Predefined models +let llama = try AIAgentImplementation.ollamaLlama32() +let mistral = try AIAgentImplementation.ollamaMistral() + +// Any custom model +let custom = try AIAgentImplementation.ollamaCustom(name: "deepseek-r1:7b") + +let reply = try await llama.send(message: "Hello!") +``` + +Ollama uses the OpenAI-compatible endpoint at `http://localhost:11434/v1` — same wire format, zero extra code. + +--- + +## Structured Outputs + +Decode the model's JSON response directly into a `Codable` type. Uses native JSON mode on OpenAI and Gemini; injects a system instruction for Anthropic. + +```swift +struct Product: Decodable, Sendable { + let name: String + let price: Double + let inStock: Bool +} + +let product = try await agent.send( + messages: [.user("Give me a JSON product example.")], + as: Product.self +) +print(product.name, product.price) +``` + +Markdown code fences (` ```json ... ``` `) are stripped automatically before decoding. + +--- + +## Prompt Caching (Anthropic) + +Mark system messages or long context as cached to reduce latency and cost on repeated requests. Requires Claude 3.5+ / 3.7+ / 4.x. + +```swift +let agent = try AIAgentImplementation.claudeSonnet46(apiKey: "sk-ant-...") + +// The cached: true flag adds cache_control: {type:"ephemeral"} on this block +// and enables the anthropic-beta: prompt-caching-2024-07-31 header automatically +let messages: [AIMessage] = [ + .system("You are an expert in the following 100k-token document: \(document)", cached: true), + .user("Summarize section 3.") +] + +let response = try await agent.send(messages: messages) ``` --- +## Function Calling (Tool Use) + +```swift +// 1. Define a tool +let weatherTool = AITool( + name: "get_weather", + description: "Returns the current weather for a city", + parameters: AIToolParameters( + properties: [ + "city": AIToolProperty(type: "string", description: "City name"), + "unit": AIToolProperty( + type: "string", + description: "Temperature unit", + enumValues: ["celsius", "fahrenheit"] + ) + ], + required: ["city"] + ) +) + +// 2. Send with tools — the model may request a tool call +let result = try await agent.send(messages: conversation, tools: [weatherTool]) + +// 3. Execute the tool and return results +if result.requiresToolExecution { + for toolCall in result.toolCalls { + let args = toolCall.decodeArguments() // [String: Any]? + let toolResult = AIToolResult(toolCallId: toolCall.id, content: "22°C, sunny") + let finalResponse = try await agent.send( + messages: conversation + [result.asHistoryMessage], + toolResults: [toolResult] + ) + print(finalResponse.message.content) + } +} +``` + +> **Tip:** For multi-step tool use, prefer `run(messages:tools:executor:maxSteps:)` over manual loops. + +--- + ## SwiftUI Integration `AIMessage` conforms to `Identifiable`, so it works directly with `ForEach`: @@ -164,9 +336,7 @@ struct ChatView: View { ## Local Persistence -The persistence layer is powered by **SwiftData** and stores all data on-device. It requires iOS 17.0+ / macOS 14.0+ / watchOS 10.0+ / tvOS 17.0+. - -### Setup +The persistence layer is powered by **SwiftData** and stores all data on-device. Requires iOS 17.0+ / macOS 14.0+. ```swift import SwiftUI @@ -188,74 +358,9 @@ let agent = try AIAgentImplementation( // Every call to send() now auto-saves the conversation locally let response = try await agent.send(message: "Hello!") -``` - -### SwiftUI History View - -The package provides a ready-to-use `HistoryView` that lists conversations with delete support: - -```swift -@main -struct MyApp: App { - let container = try! ModelContainer(for: ConversationRecord.self, MessageRecord.self) - - var body: some Scene { - WindowGroup { - HistoryView() - .modelContainer(container) - } - } -} -``` - -### Resume Previous Context -```swift -// Load the last 20 messages from the most recent conversation +// Load previous context let previousMessages = try await agent.loadPreviousContext(limit: 20) - -// Append new user input and continue -let messages = previousMessages + [.user("Continue from where we left off")] -let response = try await agent.send(messages: messages) -``` - ---- - -## Function Calling (Tool Use) - -SwiftAIAgentCore supports the tool use API for both OpenAI and Anthropic. Define tools with a JSON Schema description, send them alongside messages, and handle the model's tool calls in your app: - -```swift -// 1. Define a tool -let weatherTool = AITool( - name: "get_weather", - description: "Returns the current weather for a city", - parameters: AIToolParameters( - properties: [ - "city": AIToolProperty(type: "string", description: "City name"), - "unit": AIToolProperty( - type: "string", - description: "Temperature unit", - enumValues: ["celsius", "fahrenheit"] - ) - ], - required: ["city"] - ) -) - -// 2. Send with tools — the model may request a tool call -let result = try await agent.send(messages: conversation, tools: [weatherTool]) - -// 3. Check if the model wants to call a tool -if result.requiresToolExecution { - for toolCall in result.toolCalls { - let args = toolCall.decodeArguments() // [String: Any]? - // Execute the tool in your app, then send the result back - let toolResult = AIToolResult(toolCallId: toolCall.id, content: "22°C, sunny") - let finalResponse = try await agent.send(messages: conversation, toolResults: [toolResult]) - print(finalResponse.message.content) - } -} ``` --- @@ -275,23 +380,15 @@ public protocol AIAgent: Sendable { func estimateTokens(for messages: [AIMessage]) -> Int func send(messages: [AIMessage], tools: [AITool]) async throws -> AIMessageWithTools func send(messages: [AIMessage], toolResults: [AIToolResult]) async throws -> AIMessageWithTools -} -``` - -Default implementations are provided for `send(message:)`, `stream(message:)`, `estimateTokens(for:)`, and both tool-related methods. -### `AIConfiguration` + // Agentic loop + func run(messages: [AIMessage], tools: [AITool], + executor: @Sendable (AIToolCall) async throws -> String, + maxSteps: Int) async throws -> AIMessage -```swift -let config = AIConfiguration( - model: .gpt4Turbo, - apiKey: "your-api-key", - temperature: 0.7, // 0.0–2.0 - maxResponseTokens: 2000, - timeout: 30, - retryPolicy: .default -) -let agent = try AIAgentImplementation(configuration: config) + // Structured outputs + func send(messages: [AIMessage], as type: T.Type) async throws -> T +} ``` ### Convenience Initializers @@ -302,12 +399,34 @@ AIAgentImplementation.gpt4(apiKey:) AIAgentImplementation.gpt4Turbo(apiKey:) AIAgentImplementation.gpt4o(apiKey:) AIAgentImplementation.gpt4oMini(apiKey:) +AIAgentImplementation.gpt41(apiKey:) +AIAgentImplementation.gpt41Mini(apiKey:) -// Anthropic -AIAgentImplementation.claude3Opus(apiKey:) +// Anthropic — Claude 3 +AIAgentImplementation.claude3Haiku(apiKey:) AIAgentImplementation.claude3Sonnet(apiKey:) +AIAgentImplementation.claude3Opus(apiKey:) + +// Anthropic — Claude 3.5 / 3.7 +AIAgentImplementation.claude35Haiku(apiKey:) AIAgentImplementation.claude35Sonnet(apiKey:) +AIAgentImplementation.claude37Sonnet(apiKey:) + +// Anthropic — Claude 4 +AIAgentImplementation.claudeHaiku45(apiKey:) AIAgentImplementation.claudeSonnet46(apiKey:) +AIAgentImplementation.claudeOpus46(apiKey:) + +// Google Gemini +AIAgentImplementation.gemini20Flash(apiKey:) +AIAgentImplementation.gemini20FlashLite(apiKey:) +AIAgentImplementation.gemini15Pro(apiKey:) +AIAgentImplementation.gemini15Flash(apiKey:) + +// Ollama (local) +AIAgentImplementation.ollamaLlama32() +AIAgentImplementation.ollamaMistral() +AIAgentImplementation.ollamaCustom(name:maxTokens:) ``` ### Retry Policies @@ -321,19 +440,6 @@ RetryPolicy.aggressive // 5 retries, shorter delays (0.5s → 30s) RetryPolicy(maxRetries: 3, initialDelay: 1.0, maxDelay: 60.0, multiplier: 2.0) ``` -### Token Management - -```swift -// Estimate token count for a message array -let tokens = TokenEstimator.estimate(messages: messages) - -// Validate against model limits (throws AIError.tokenLimitExceeded if over limit) -try TokenEstimator.validate(messages: messages, model: .gpt4, maxResponseTokens: 1000) - -// Truncate to fit within a limit, preserving system messages -let truncated = TokenEstimator.truncate(messages: messages, limit: 2000, keepSystemMessages: true) -``` - ### Error Handling ```swift @@ -341,29 +447,20 @@ do { let response = try await agent.send(message: "Hello") } catch let error as AIError { switch error { - case .invalidAPIKey: - // Invalid or missing API key - case .rateLimit(let retryAfter): - // Rate limited — retryAfter is TimeInterval? (seconds to wait) - case .tokenLimitExceeded(let current, let max): - // current and max are Int (token counts) - case .networkError(let underlying): - // Underlying URLSession or transport error - case .timeout: - // Request exceeded the configured timeout - case .invalidResponse(let statusCode, let message): - // Non-2xx HTTP response - case .streamingError(let reason): - // Error during streaming (including model not supporting streaming) - case .cancelled: - // Task was cancelled - case .decodingError, .invalidContext, .unknown: - break + case .invalidAPIKey: break + case .rateLimit(let retryAfter): break + case .tokenLimitExceeded(let current, let max): break + case .networkError(let underlying): break + case .timeout: break + case .invalidResponse(let statusCode, let message): break + case .streamingError(let reason): break + case .agentLoopExceeded(let steps): break // maxSteps reached in run() + case .cancelled: break + case .decodingError, .invalidContext, .unknown: break } if error.isRecoverable { - // rateLimit, networkError, and timeout are recoverable - // Retry is handled automatically by the configured RetryPolicy + // rateLimit, networkError, and timeout are retried automatically } } ``` @@ -375,19 +472,21 @@ do { ``` Sources/SwiftAIAgentCore/ ├── Core/ -│ ├── AIAgentProtocol.swift — AIAgent protocol -│ ├── AIMessage.swift — Message model (user / assistant / system) +│ ├── AIAgentProtocol.swift — AIAgent protocol + agentic loop + structured outputs +│ ├── AIMessage.swift — Message model (user / assistant / system / tool) +│ ├── AIImageContent.swift — Image attachment (.url / .data) for vision │ ├── AIRole.swift — Role enumeration -│ ├── AIModel.swift — Model configurations (GPT-4, Claude, etc.) +│ ├── AIModel.swift — Model configs (OpenAI, Anthropic, Gemini, Ollama) │ ├── AIConfiguration.swift — Agent configuration and retry policies │ ├── AIError.swift — Typed error enum │ ├── AITool.swift — Tool (function) definition for tool use │ └── AIToolCall.swift — Tool call / result / response types │ ├── Network/ -│ ├── NetworkClient.swift — Base HTTP client with retry logic -│ ├── OpenAIClient.swift — OpenAI Chat Completions API -│ ├── AnthropicClient.swift — Anthropic Messages API +│ ├── NetworkClient.swift — Base HTTP client with retry and SSE streaming +│ ├── OpenAIClient.swift — OpenAI Chat Completions (also used for Ollama) +│ ├── AnthropicClient.swift — Anthropic Messages API with prompt caching +│ ├── GeminiClient.swift — Google Gemini generateContent / streamGenerateContent │ └── AIAgentImplementation.swift — Concrete actor conforming to AIAgent │ ├── Persistence/ — iOS 17+ / macOS 14+ only @@ -415,7 +514,7 @@ swift test ### Mocking -`AIAgent` is a protocol — implement it to create test doubles. Because `AIAgentImplementation` is an `actor`, mocks must be `Sendable`. The protocol includes two tool-use methods; provide at least stub implementations: +`AIAgent` is a protocol — implement it to create test doubles: ```swift struct MockAIAgent: AIAgent { @@ -437,27 +536,13 @@ struct MockAIAgent: AIAgent { } func send(messages: [AIMessage], toolResults: [AIToolResult]) async throws -> AIMessageWithTools { - AIMessageWithTools(message: .assistant("Mocked tool result response")) + AIMessageWithTools(message: .assistant("Mocked result")) } -} -``` - ---- - -## Examples - -The [Examples](Examples/) directory contains: -- **BasicExample.swift** — single message, streaming, and multi-turn conversation -- **AdvancedExample.swift** — token management, retry handling, production patterns -- **ChatApp/** — a complete SwiftUI macOS chat application using the package - -Run the basic example: - -```bash -cd Examples -export OPENAI_API_KEY="your-key" -swift BasicExample.swift + func sendForJSON(messages: [AIMessage]) async throws -> AIMessage { + .assistant("{\"key\": \"value\"}") + } +} ``` --- diff --git a/Sources/SwiftAIAgentCore/SwiftAIAgentCore.swift b/Sources/SwiftAIAgentCore/SwiftAIAgentCore.swift index 8beb4cc..1507573 100644 --- a/Sources/SwiftAIAgentCore/SwiftAIAgentCore.swift +++ b/Sources/SwiftAIAgentCore/SwiftAIAgentCore.swift @@ -1,40 +1,67 @@ // The Swift Programming Language // https://docs.swift.org/swift-book -/// SwiftAIAgentCore - Professional Swift package for integrating LLM agents into iOS apps +/// SwiftAIAgentCore — Production-grade Swift package for multi-provider LLM agents /// -/// This package provides production-grade tools for integrating AI language models -/// (OpenAI GPT, Anthropic Claude) into iOS, macOS, watchOS, and tvOS applications. +/// Supports OpenAI (GPT-4o, GPT-4.1), Anthropic (Claude 3–4), Google Gemini, +/// and local Ollama models — all behind a single `AIAgent` protocol. /// /// # Features -/// - **Streaming Support**: Real-time AI responses using AsyncThrowingStream -/// - **Reliability Layer**: Automatic retry mechanism with exponential backoff -/// - **Error Handling**: Comprehensive error types for all failure scenarios -/// - **Token Management**: Estimate and validate token usage before requests -/// - **Clean Architecture**: Separation between Network, Domain, and Interface layers -/// - **Swift 6.0**: Full concurrency support with Sendable types +/// - **Agentic ReAct loop**: `run(messages:tools:executor:maxSteps:)` automatically +/// executes tool calls until the model produces a final text response +/// - **Vision / multi-modal**: attach images (.url or .data) to any user message; +/// encoded correctly for GPT-4o, Claude 3+, and Gemini +/// - **Multi-provider**: OpenAI, Anthropic, Google Gemini, and Ollama (local) +/// - **Structured outputs**: `send(messages:as:)` decodes JSON directly into any +/// `Codable` type; uses native JSON mode on OpenAI and Gemini +/// - **Prompt caching**: mark Anthropic messages with `cached: true` to enable +/// the prompt-caching beta and reduce latency + cost on repeated context +/// - **Streaming**: real-time responses via `AsyncThrowingStream` (SSE) +/// - **Reliability**: automatic retry with exponential backoff +/// - **Token management**: estimate and validate usage before requests +/// - **Swift 6.0**: strict concurrency — all types are `Sendable`, clients are `actor` /// /// # Quick Start /// ```swift /// import SwiftAIAgentCore /// -/// // Create an agent -/// let agent = try AIAgentImplementation.gpt4(apiKey: "your-api-key") +/// // OpenAI +/// let agent = try AIAgentImplementation.gpt4o(apiKey: "sk-...") /// -/// // Send a message -/// let response = try await agent.send(message: "Hello!") -/// print(response) +/// // Anthropic +/// let claude = try AIAgentImplementation.claudeSonnet46(apiKey: "sk-ant-...") /// -/// // Stream a response -/// for try await chunk in agent.stream(message: "Tell me a story") { -/// print(chunk, terminator: "") -/// } +/// // Gemini +/// let gemini = try AIAgentImplementation.gemini20Flash(apiKey: "AIza...") +/// +/// // Ollama (no key required) +/// let local = try AIAgentImplementation.ollamaLlama32() +/// +/// // Simple completion +/// let reply = try await agent.send(message: "Hello!") +/// +/// // Structured output +/// struct Weather: Decodable, Sendable { let city: String; let temp: Double } +/// let weather = try await agent.send(messages: [...], as: Weather.self) +/// +/// // Agentic loop with tools +/// let answer = try await agent.run( +/// messages: [.user("What files are in /tmp?")], +/// tools: [listFilesTool], +/// executor: { call in try await runTool(call) }, +/// maxSteps: 10 +/// ) +/// +/// // Vision +/// let image = AIImageContent.data(jpegData, mimeType: "image/jpeg") +/// let caption = try await agent.send(messages: [.user("Describe this", images: [image])]) /// ``` /// /// # Architecture -/// - **Core**: Domain models, protocols, and business logic -/// - **Network**: API clients for OpenAI and Anthropic with streaming support -/// - **Utils**: Token estimation and utility functions +/// - **Core**: domain models, protocols, error types, and business logic +/// - **Network**: actor-based API clients per provider with streaming SSE support +/// - **Persistence**: SwiftData-backed conversation history (iOS 17+ / macOS 14+) +/// - **Utils**: token estimation and validation // Re-export all public types @_exported import struct Foundation.Date From 313237e58a599ee90bcb6e2f9b00d7de59e1cea0 Mon Sep 17 00:00:00 2001 From: Victor Durocher Date: Fri, 10 Apr 2026 00:39:17 +0200 Subject: [PATCH 03/14] =?UTF-8?q?fix:=20security=20hardening=20=E2=80=94?= =?UTF-8?q?=20input=20validation,=20SSE=20buffer=20limit,=20stream=20cance?= =?UTF-8?q?llation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SEC-02: Validate MIME type in AIImageContent against allowlist (image/jpeg, image/png, image/gif, image/webp) — throws AIError.invalidContext on unknown types SEC-03: Enforce 20 MB max size on inline image data — prevents OOM on large inputs SEC-05: Cap SSE buffer at 1 MB per message in NetworkClient.stream() — throws AIError.streamingError on overflow, preventing unbounded memory growth from malformed or malicious SSE streams SEC-06: Truncate HTTP error body to 500 chars in NetworkClient.execute() — prevents leaking large or sensitive provider error responses SEC-08: Validate tool names against the declared tool list in the agentic loop — throws AIError.invalidContext if the model requests an unknown tool SEC-09: Add onTermination handler to streamCompletion() in OpenAIClient, AnthropicClient, and GeminiClient — cancels the inner Task when the consumer stops iterating, preventing network task leaks SEC-10: Validate URL scheme in AIImageContent.validated() — only http/https accepted, prevents file:// or internal URLs being forwarded to provider APIs --- .../Core/AIAgentProtocol.swift | 6 ++- .../Core/AIImageContent.swift | 48 +++++++++++++++++-- .../Network/AnthropicClient.swift | 4 +- .../Network/GeminiClient.swift | 4 +- .../Network/NetworkClient.swift | 20 ++++++-- .../Network/OpenAIClient.swift | 4 +- 6 files changed, 74 insertions(+), 12 deletions(-) diff --git a/Sources/SwiftAIAgentCore/Core/AIAgentProtocol.swift b/Sources/SwiftAIAgentCore/Core/AIAgentProtocol.swift index 38922fb..94cb34b 100644 --- a/Sources/SwiftAIAgentCore/Core/AIAgentProtocol.swift +++ b/Sources/SwiftAIAgentCore/Core/AIAgentProtocol.swift @@ -82,8 +82,12 @@ public extension AIAgent { // Append the assistant message with embedded tool calls for correct history encoding history.append(result.asHistoryMessage) - // Execute each tool call sequentially and append results + // Only execute tool calls whose names match the declared tools + let validToolNames = Set(tools.map(\.name)) for call in result.toolCalls { + guard validToolNames.contains(call.name) else { + throw AIError.invalidContext("Model requested unknown tool: \(call.name)") + } let output = try await executor(call) history.append(AIMessage( role: .tool, diff --git a/Sources/SwiftAIAgentCore/Core/AIImageContent.swift b/Sources/SwiftAIAgentCore/Core/AIImageContent.swift index 0ed1f57..b8dbe4d 100644 --- a/Sources/SwiftAIAgentCore/Core/AIImageContent.swift +++ b/Sources/SwiftAIAgentCore/Core/AIImageContent.swift @@ -3,11 +3,49 @@ import Foundation /// Represents image content that can be attached to a user message. /// Supported by vision-capable models (GPT-4o, Claude 3+, Gemini 1.5+). public enum AIImageContent: Sendable, Hashable, Codable { - /// Remote image URL — fetched by the model at inference time + /// Remote image URL — must use HTTPS or HTTP scheme; fetched by the model at inference time case url(URL) - /// Inline image bytes with explicit MIME type (base64-encoded in API requests) + /// Inline image bytes with explicit MIME type (base64-encoded in API requests). + /// Maximum size: 20 MB. Allowed types: image/jpeg, image/png, image/gif, image/webp. case data(Data, mimeType: String) + // MARK: - Validation + + /// Allowed MIME types for inline image data + private static let allowedMimeTypes: Set = [ + "image/jpeg", "image/png", "image/gif", "image/webp" + ] + + /// Maximum inline image size accepted by the major providers (20 MB) + private static let maxDataSize = 20 * 1024 * 1024 + + /// Validates and returns the image content, or throws if the input is invalid. + /// Call this before passing user-controlled data to an agent. + public func validated() throws -> AIImageContent { + switch self { + case .url(let url): + guard let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http" else { + throw AIError.invalidContext("Image URL must use an HTTP(S) scheme") + } + return self + + case .data(let bytes, let mime): + guard Self.allowedMimeTypes.contains(mime) else { + throw AIError.invalidContext( + "Unsupported MIME type '\(mime)'. Allowed: \(Self.allowedMimeTypes.sorted().joined(separator: ", "))" + ) + } + guard bytes.count <= Self.maxDataSize else { + throw AIError.invalidContext( + "Image data exceeds the 20 MB limit (\(bytes.count / 1_024 / 1_024) MB provided)" + ) + } + return self + } + } + + // MARK: - Accessors + /// Base64-encoded image bytes, or nil for URL images public var base64String: String? { guard case .data(let bytes, _) = self else { return nil } @@ -31,7 +69,8 @@ public enum AIImageContent: Sendable, Hashable, Codable { import UIKit public extension AIImageContent { - /// Create image content from a UIImage, JPEG-encoded at the given quality + /// Create image content from a UIImage, JPEG-encoded at the given quality. + /// Returns nil if JPEG encoding fails. static func uiImage(_ image: UIImage, compressionQuality: CGFloat = 0.85) -> AIImageContent? { guard let data = image.jpegData(compressionQuality: compressionQuality) else { return nil } return .data(data, mimeType: "image/jpeg") @@ -43,7 +82,8 @@ public extension AIImageContent { import AppKit public extension AIImageContent { - /// Create image content from an NSImage, PNG-encoded + /// Create image content from an NSImage, PNG-encoded. + /// Returns nil if PNG encoding fails. static func nsImage(_ image: NSImage) -> AIImageContent? { guard let tiff = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiff), diff --git a/Sources/SwiftAIAgentCore/Network/AnthropicClient.swift b/Sources/SwiftAIAgentCore/Network/AnthropicClient.swift index 59256eb..65aedfd 100644 --- a/Sources/SwiftAIAgentCore/Network/AnthropicClient.swift +++ b/Sources/SwiftAIAgentCore/Network/AnthropicClient.swift @@ -35,7 +35,7 @@ actor AnthropicClient: Sendable { func streamCompletion(messages: [AIMessage]) -> AsyncThrowingStream { AsyncThrowingStream { continuation in - Task { + let task = Task { do { let request = try self.buildRequest(messages: messages, tools: nil, stream: true) let stream = await self.networkClient.stream(request: request) @@ -64,6 +64,8 @@ actor AnthropicClient: Sendable { continuation.finish(throwing: error) } } + // Cancel the network task when the consumer stops iterating the stream + continuation.onTermination = { _ in task.cancel() } } } diff --git a/Sources/SwiftAIAgentCore/Network/GeminiClient.swift b/Sources/SwiftAIAgentCore/Network/GeminiClient.swift index ee0ed7e..8c5cbfc 100644 --- a/Sources/SwiftAIAgentCore/Network/GeminiClient.swift +++ b/Sources/SwiftAIAgentCore/Network/GeminiClient.swift @@ -50,7 +50,7 @@ actor GeminiClient: Sendable { func streamCompletion(messages: [AIMessage]) -> AsyncThrowingStream { AsyncThrowingStream { continuation in - Task { + let task = Task { do { let request = try self.buildRequest(messages: messages, tools: nil, stream: true, jsonMode: false) let stream = await self.networkClient.stream(request: request) @@ -73,6 +73,8 @@ actor GeminiClient: Sendable { continuation.finish(throwing: error) } } + // Cancel the network task when the consumer stops iterating the stream + continuation.onTermination = { _ in task.cancel() } } } diff --git a/Sources/SwiftAIAgentCore/Network/NetworkClient.swift b/Sources/SwiftAIAgentCore/Network/NetworkClient.swift index 16f6906..184b9f2 100644 --- a/Sources/SwiftAIAgentCore/Network/NetworkClient.swift +++ b/Sources/SwiftAIAgentCore/Network/NetworkClient.swift @@ -32,9 +32,10 @@ actor NetworkClient: Sendable { throw AIError.rateLimit(retryAfter: retryAfter) } - // Handle errors + // Handle errors — truncate body to avoid leaking sensitive provider info if !(200...299).contains(httpResponse.statusCode) { - let message = String(data: data, encoding: .utf8) + let raw = String(data: data, encoding: .utf8) + let message = raw.map { String($0.prefix(Self.maxErrorMessageLength)) } throw AIError.invalidResponse(statusCode: httpResponse.statusCode, message: message) } @@ -51,6 +52,12 @@ actor NetworkClient: Sendable { } } + /// Maximum size of a single SSE message buffer (1 MB) — guards against unbounded memory growth + private static let maxSSEBufferSize = 1 * 1024 * 1024 + + /// Maximum length of an HTTP error body exposed in AIError (prevents leaking large server responses) + private static let maxErrorMessageLength = 500 + /// Stream response using Server-Sent Events (SSE) func stream( request: URLRequest @@ -71,8 +78,13 @@ actor NetworkClient: Sendable { for try await byte in bytes { buffer.append(byte) - // Check for SSE message delimiter - if buffer.suffix(2) == Data([0x0A, 0x0A]) { // \n\n + // Guard against unbounded memory growth from a malformed SSE stream + if buffer.count > Self.maxSSEBufferSize { + throw AIError.streamingError("SSE message exceeded the 1 MB buffer limit") + } + + // Check for SSE message delimiter (\n\n) + if buffer.suffix(2) == Data([0x0A, 0x0A]) { continuation.yield(buffer) buffer.removeAll(keepingCapacity: true) } diff --git a/Sources/SwiftAIAgentCore/Network/OpenAIClient.swift b/Sources/SwiftAIAgentCore/Network/OpenAIClient.swift index 46163d3..408f263 100644 --- a/Sources/SwiftAIAgentCore/Network/OpenAIClient.swift +++ b/Sources/SwiftAIAgentCore/Network/OpenAIClient.swift @@ -41,7 +41,7 @@ actor OpenAIClient: Sendable { func streamCompletion(messages: [AIMessage]) -> AsyncThrowingStream { AsyncThrowingStream { continuation in - Task { + let task = Task { do { let request = try self.buildRequest(messages: messages, tools: nil, stream: true, jsonMode: false) let stream = await self.networkClient.stream(request: request) @@ -68,6 +68,8 @@ actor OpenAIClient: Sendable { continuation.finish(throwing: error) } } + // Cancel the network task when the consumer stops iterating the stream + continuation.onTermination = { _ in task.cancel() } } } From 8ac525379f372153cacbfe307e904e1e0d284b09 Mon Sep 17 00:00:00 2001 From: Victor Durocher Date: Fri, 10 Apr 2026 00:44:54 +0200 Subject: [PATCH 04/14] fix: correct Gemini API wire format and agentic loop tool metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GeminiPart CodingKeys: use camelCase (inlineData, functionCall, functionResponse) — Gemini REST API v1 uses camelCase throughout. Snake_case was silently dropping vision and tool call parts. GenerateContentResponse.Part and Candidate: remove redundant explicit CodingKeys that mapped camelCase to camelCase — Swift default synthesis already handles this correctly. FunctionResponse.name: use metadata tool_name (function name) instead of tool_call_id — Gemini function_response.name must be the function name, not the opaque call identifier. AIAgentProtocol agentic loop: add tool_name to tool result metadata alongside tool_call_id so Gemini can correctly identify function responses. AnthropicScalar decoder: move Bool check before Int to avoid JSON boolean ambiguity in the Codable path. AnthropicInput.toJSONString: replace JSONSerialization with JSONEncoder on the typed AnthropicScalar dict — eliminates NSNumber ambiguity and removes the only remaining use of [String: Any] in the encode path. --- .../Core/AIAgentProtocol.swift | 4 ++- .../Network/AnthropicClient.swift | 15 ++-------- .../Network/GeminiClient.swift | 28 +++++++------------ 3 files changed, 16 insertions(+), 31 deletions(-) diff --git a/Sources/SwiftAIAgentCore/Core/AIAgentProtocol.swift b/Sources/SwiftAIAgentCore/Core/AIAgentProtocol.swift index 94cb34b..773c66f 100644 --- a/Sources/SwiftAIAgentCore/Core/AIAgentProtocol.swift +++ b/Sources/SwiftAIAgentCore/Core/AIAgentProtocol.swift @@ -92,7 +92,9 @@ public extension AIAgent { history.append(AIMessage( role: .tool, content: output, - metadata: ["tool_call_id": call.id] + // tool_call_id: used by OpenAI/Anthropic to match results to calls + // tool_name: used by Gemini which requires the function name in functionResponse + metadata: ["tool_call_id": call.id, "tool_name": call.name] )) } } diff --git a/Sources/SwiftAIAgentCore/Network/AnthropicClient.swift b/Sources/SwiftAIAgentCore/Network/AnthropicClient.swift index 65aedfd..51a4778 100644 --- a/Sources/SwiftAIAgentCore/Network/AnthropicClient.swift +++ b/Sources/SwiftAIAgentCore/Network/AnthropicClient.swift @@ -321,9 +321,9 @@ enum AnthropicScalar: Codable, Sendable { init(from decoder: Decoder) throws { let c = try decoder.singleValueContainer() if let v = try? c.decode(String.self) { self = .string(v) } + else if let v = try? c.decode(Bool.self) { self = .bool(v) } // before Int — JSON bool safety else if let v = try? c.decode(Int.self) { self = .int(v) } else if let v = try? c.decode(Double.self) { self = .double(v) } - else if let v = try? c.decode(Bool.self) { self = .bool(v) } else { self = .null } } @@ -384,17 +384,8 @@ private struct MessagesResponse: Decodable { } func toJSONString() -> String { - var dict: [String: Any] = [:] - for (key, value) in raw { - switch value { - case .string(let v): dict[key] = v - case .int(let v): dict[key] = v - case .double(let v): dict[key] = v - case .bool(let v): dict[key] = v - case .null: dict[key] = NSNull() - } - } - guard let data = try? JSONSerialization.data(withJSONObject: dict), + // Use JSONEncoder on the typed dict — avoids JSONSerialization NSNumber ambiguity + guard let data = try? JSONEncoder().encode(raw), let str = String(data: data, encoding: .utf8) else { return "{}" } return str } diff --git a/Sources/SwiftAIAgentCore/Network/GeminiClient.swift b/Sources/SwiftAIAgentCore/Network/GeminiClient.swift index 8c5cbfc..14a5f9a 100644 --- a/Sources/SwiftAIAgentCore/Network/GeminiClient.swift +++ b/Sources/SwiftAIAgentCore/Network/GeminiClient.swift @@ -138,10 +138,11 @@ actor GeminiClient: Sendable { private func encodeMessage(_ message: AIMessage) -> GeminiContent { let role = message.role == .assistant ? "model" : "user" - // Tool result messages - if message.role == .tool, let callId = message.metadata?["tool_call_id"] { + // Tool result messages — Gemini expects the function name (not the call ID) + if message.role == .tool, + let functionName = message.metadata?["tool_name"] ?? message.metadata?["tool_call_id"] { let part = GeminiPart(functionResponse: FunctionResponse( - name: callId, + name: functionName, response: GeminiResponsePayload(content: message.content) )) return GeminiContent(role: "user", parts: [part]) @@ -211,11 +212,12 @@ private struct GeminiPart: Encodable { init(functionCall: FunctionCall) { self.text = nil; self.inlineData = nil; self.functionCall = functionCall; self.functionResponse = nil } init(functionResponse: FunctionResponse) { self.text = nil; self.inlineData = nil; self.functionCall = nil; self.functionResponse = functionResponse } + // Gemini REST API v1 uses camelCase throughout enum CodingKeys: String, CodingKey { case text - case inlineData = "inline_data" - case functionCall = "function_call" - case functionResponse = "function_response" + case inlineData // "inlineData" + case functionCall // "functionCall" + case functionResponse // "functionResponse" } } @@ -282,12 +284,7 @@ private struct GenerateContentResponse: Decodable { struct Candidate: Decodable { let content: Content - let finishReason: String? - - enum CodingKeys: String, CodingKey { - case content - case finishReason = "finishReason" - } + let finishReason: String? // Gemini returns "finishReason" (camelCase) — matches Swift default } struct Content: Decodable { @@ -297,12 +294,7 @@ private struct GenerateContentResponse: Decodable { struct Part: Decodable { let text: String? - let functionCall: FunctionCallResponse? - - enum CodingKeys: String, CodingKey { - case text - case functionCall = "functionCall" - } + let functionCall: FunctionCallResponse? // Gemini returns "functionCall" (camelCase) } struct FunctionCallResponse: Decodable { From fcdcdadf890a300eff6df1e2b415022c68fdf601 Mon Sep 17 00:00:00 2001 From: Victor Durocher Date: Fri, 15 May 2026 14:49:55 +0200 Subject: [PATCH 05/14] chore: add CHANGELOG, SwiftLint config, and fix CI matrix --- .github/workflows/swift.yml | 9 +++- .swiftlint.yml | 38 +++++++++++++ CHANGELOG.md | 53 +++++++++++++++++++ .../SwiftAIAgentCore/SwiftAIAgentCore.swift | 10 ++-- 4 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 .swiftlint.yml create mode 100644 CHANGELOG.md diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 456ff4d..ce620f8 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -12,8 +12,13 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [macos-14, macos-13] - xcode: ['16.0', '15.4'] + include: + - os: macos-15 + xcode: '16.2' + - os: macos-14 + xcode: '16.0' + - os: macos-14 + xcode: '15.4' steps: - uses: actions/checkout@v4 diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..b51acfb --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,38 @@ +disabled_rules: + - line_length # relaxed for long function signatures + - trailing_whitespace # handled by editor + +opt_in_rules: + - empty_count + - explicit_init + - first_where + - force_unwrapping + - sorted_imports + - unneeded_parentheses_in_closure_argument + +included: + - Sources + - Tests + +excluded: + - .build + - Examples + +force_cast: error +force_try: error + +type_body_length: + warning: 300 + error: 400 + +file_length: + warning: 400 + error: 500 + +function_body_length: + warning: 60 + error: 100 + +cyclomatic_complexity: + warning: 10 + error: 20 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..82107b8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,53 @@ +# Changelog + +All notable changes to SwiftAIAgentCore will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +--- + +## [1.1.0] — 2025-05-01 + +### Added +- **Agentic ReAct loop** — `run(messages:tools:executor:maxSteps:)` executes tool calls automatically until the model produces a final text response; throws `AIError.agentLoopExceeded` when `maxSteps` is reached +- **Vision / multi-modal** — `AIImageContent` supports `.url` and `.data(mimeType:)` attachments; encoded correctly for GPT-4o, Claude 3+, and Gemini +- **Google Gemini** — full support for `generateContent` and `streamGenerateContent`, including tool use, JSON mode, and vision +- **Ollama (local)** — local inference via `http://localhost:11434/v1` (OpenAI-compatible); no API key required +- **Structured outputs** — `send(messages:as:)` decodes JSON directly into any `Codable` type; native JSON mode on OpenAI and Gemini +- **Prompt caching** — Anthropic prompt-caching beta: mark messages with `cached: true` to add `cache_control: {type: "ephemeral"}` and the required beta header +- New models: GPT-4.1, GPT-4.1 Mini, Claude Sonnet 4.6, Claude Opus 4.6, Claude Haiku 4.5, Claude 3.7 Sonnet, Gemini 2.0 Flash / Lite, Gemini 1.5 Pro / Flash, Ollama Llama 3.2 / 3.1 / Mistral / Phi-4 + +### Fixed +- Gemini API wire format for agentic tool metadata (`tool_call_id` and `tool_name` fields) +- SSE stream buffer size limit to prevent memory accumulation on long streams +- Stream cancellation propagation when the caller cancels the enclosing `Task` +- Input validation: API key, temperature range, and `maxResponseTokens` bounds checked before any network call + +### Security +- Added bounds checking on SSE line buffer to prevent unbounded memory growth +- `AIAgentImplementation` is an `actor` — all mutable state is isolated, no `@unchecked Sendable` workarounds + +--- + +## [1.0.0] — 2025-03-15 + +### Added +- `AIAgent` protocol with `send(message:)`, `send(messages:)`, `stream(message:)`, `stream(messages:)`, and `estimateTokens(for:)` default implementations +- `AIAgentImplementation` — concrete `actor` implementation routing to the correct provider client +- **OpenAI** — `OpenAIClient` supporting Chat Completions, streaming SSE, tool use, and native `json_object` mode +- **Anthropic** — `AnthropicClient` supporting Messages API, streaming SSE, and tool use +- Function calling (tool use) — `AITool`, `AIToolParameters`, `AIToolProperty`, `AIToolCall`, `AIToolResult`, `AIMessageWithTools` +- **SwiftData persistence** (iOS 17+ / macOS 14+) — `HistoryManager` (`@ModelActor`), `ConversationRecord`, `MessageRecord` +- **SwiftUI history views** — `HistoryView` (conversation list with delete) and `ConversationDetailView` +- `TokenEstimator` — estimate, validate, and truncate messages against model token limits +- `AIError` — exhaustive typed error enum conforming to `LocalizedError` with `isRecoverable` flag +- `RetryPolicy` — configurable exponential backoff with `.default`, `.none`, and `.aggressive` presets +- Convenience initializers for GPT-4, GPT-4 Turbo, GPT-3.5 Turbo, GPT-4o, GPT-4o Mini; Claude 3 Haiku / Sonnet / Opus; Claude 3.5 Haiku / Sonnet +- GitHub Actions CI: macOS (Xcode 15.4 / 16.0) + Linux (Swift 6.0 Docker image) + SwiftLint +- Zero external dependencies — pure Swift, Foundation only + +--- + +[1.1.0]: https://github.com/VDurocher/Swift-AI-Agent-Core/compare/1.0.0...1.1.0 +[1.0.0]: https://github.com/VDurocher/Swift-AI-Agent-Core/releases/tag/1.0.0 diff --git a/Sources/SwiftAIAgentCore/SwiftAIAgentCore.swift b/Sources/SwiftAIAgentCore/SwiftAIAgentCore.swift index 1507573..cf9f920 100644 --- a/Sources/SwiftAIAgentCore/SwiftAIAgentCore.swift +++ b/Sources/SwiftAIAgentCore/SwiftAIAgentCore.swift @@ -68,8 +68,8 @@ @_exported import struct Foundation.TimeInterval @_exported import struct Foundation.UUID -// Exports explicites des types de tool calling pour les consommateurs du package -// AITool, AIToolParameters, AIToolProperty — définition des outils disponibles -// AIToolCall — appel d'outil retourné par le modèle -// AIToolResult — résultat d'exécution fourni par l'application -// AIMessageWithTools — réponse unifiée texte + appels d'outils +// Explicit re-exports for tool calling types consumed by package clients: +// AITool, AIToolParameters, AIToolProperty — tool definitions exposed to the model +// AIToolCall — a tool invocation returned by the model +// AIToolResult — the execution result fed back into the conversation +// AIMessageWithTools — unified response carrying text content and tool calls From dab1e375fa82cc60b5baea046f54c5cfc43fb71f Mon Sep 17 00:00:00 2001 From: Victor Durocher Date: Wed, 18 Mar 2026 17:09:41 +0100 Subject: [PATCH 06/14] feat: add AIConversation stateful wrapper for chat-style interactions --- .../Core/AIConversation.swift | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 Sources/SwiftAIAgentCore/Core/AIConversation.swift diff --git a/Sources/SwiftAIAgentCore/Core/AIConversation.swift b/Sources/SwiftAIAgentCore/Core/AIConversation.swift new file mode 100644 index 0000000..8784489 --- /dev/null +++ b/Sources/SwiftAIAgentCore/Core/AIConversation.swift @@ -0,0 +1,82 @@ +import Foundation + +/// Stateful wrapper around an `AIAgent` that maintains conversation history automatically. +/// +/// Use `AIConversation` when you want a chat-style interface without manually +/// tracking the message array: +/// +/// ```swift +/// let agent = try AIAgentImplementation.claudeSonnet46(apiKey: "sk-ant-...") +/// let conversation = AIConversation(agent: agent, systemPrompt: "You are a Swift expert.") +/// +/// let reply1 = try await conversation.send("What is an actor?") +/// let reply2 = try await conversation.send("Show me an example.") +/// // history is maintained automatically between calls +/// ``` +@MainActor +public final class AIConversation { + + private let agent: any AIAgent + public private(set) var messages: [AIMessage] + + /// Number of messages in the current conversation (including system prompt). + public var messageCount: Int { messages.count } + + // MARK: - Init + + /// - Parameters: + /// - agent: The AI agent to route messages through. + /// - systemPrompt: Optional system instruction prepended to every request. + public init(agent: any AIAgent, systemPrompt: String? = nil) { + self.agent = agent + self.messages = systemPrompt.map { [.system($0)] } ?? [] + } + + // MARK: - Send + + /// Sends a user message, appends both the user turn and assistant reply to history, + /// and returns the assistant's response text. + @discardableResult + public func send(_ text: String) async throws -> String { + let userMessage = AIMessage.user(text) + messages.append(userMessage) + + let response = try await agent.send(messages: messages) + messages.append(response) + return response.content + } + + /// Sends a user message with image attachments (vision-capable models only). + @discardableResult + public func send(_ text: String, images: [AIImageContent]) async throws -> String { + let userMessage = AIMessage.user(text, images: images) + messages.append(userMessage) + + let response = try await agent.send(messages: messages) + messages.append(response) + return response.content + } + + // MARK: - History Management + + /// Resets the conversation to its initial state (system prompt only, if any). + public func reset(keepingSystemPrompt: Bool = true) { + if keepingSystemPrompt { + messages = messages.filter { $0.role == .system } + } else { + messages = [] + } + } + + /// Removes the last user+assistant exchange from history. + public func undoLastExchange() { + // Remove trailing assistant message, then trailing user message + if messages.last?.role == .assistant { messages.removeLast() } + if messages.last?.role == .user { messages.removeLast() } + } + + /// Estimated token count for the current conversation. + public var estimatedTokens: Int { + agent.estimateTokens(for: messages) + } +} From 820d05e5494a454f5f7579c6494e52cf6de82253 Mon Sep 17 00:00:00 2001 From: Victor Durocher Date: Wed, 25 Mar 2026 09:41:18 +0100 Subject: [PATCH 07/14] =?UTF-8?q?test:=20add=20AIConversationTests=20with?= =?UTF-8?q?=20mock=20agent=20=E2=80=94=20history,=20reset,=20undo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AIConversationTests.swift | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 Tests/SwiftAIAgentCoreTests/AIConversationTests.swift diff --git a/Tests/SwiftAIAgentCoreTests/AIConversationTests.swift b/Tests/SwiftAIAgentCoreTests/AIConversationTests.swift new file mode 100644 index 0000000..490d1c2 --- /dev/null +++ b/Tests/SwiftAIAgentCoreTests/AIConversationTests.swift @@ -0,0 +1,97 @@ +import XCTest +@testable import SwiftAIAgentCore + +// MARK: - Mock agent for testing + +private struct MockAgent: AIAgent { + let configuration: AIConfiguration + let fixedResponse: String + + func send(messages: [AIMessage]) async throws -> AIMessage { + .assistant(fixedResponse) + } + + func stream(messages: [AIMessage]) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + continuation.yield(fixedResponse) + continuation.finish() + } + } + + func send(messages: [AIMessage], tools: [AITool]) async throws -> AIMessageWithTools { + AIMessageWithTools(message: .assistant(fixedResponse)) + } + + func send(messages: [AIMessage], toolResults: [AIToolResult]) async throws -> AIMessageWithTools { + AIMessageWithTools(message: .assistant(fixedResponse)) + } + + func sendForJSON(messages: [AIMessage]) async throws -> AIMessage { + .assistant("{}") + } +} + +// MARK: - Tests + +@MainActor +final class AIConversationTests: XCTestCase { + + private func makeConversation(systemPrompt: String? = nil, response: String = "ok") throws -> AIConversation { + let config = AIConfiguration(model: .gpt4o, apiKey: "test-key") + let agent = MockAgent(configuration: config, fixedResponse: response) + return AIConversation(agent: agent, systemPrompt: systemPrompt) + } + + func testInitWithoutSystemPrompt() throws { + let conv = try makeConversation() + XCTAssertEqual(conv.messageCount, 0) + } + + func testInitWithSystemPrompt() throws { + let conv = try makeConversation(systemPrompt: "You are a tester.") + XCTAssertEqual(conv.messageCount, 1) + XCTAssertEqual(conv.messages.first?.role, .system) + } + + func testSendAppendsUserAndAssistantMessages() async throws { + let conv = try makeConversation(response: "Hello!") + let reply = try await conv.send("Hi") + XCTAssertEqual(reply, "Hello!") + XCTAssertEqual(conv.messageCount, 2) + XCTAssertEqual(conv.messages.first?.role, .user) + XCTAssertEqual(conv.messages.last?.role, .assistant) + } + + func testMultipleTurnsAccumulateHistory() async throws { + let conv = try makeConversation(response: "ok") + try await conv.send("Turn 1") + try await conv.send("Turn 2") + // 2 user + 2 assistant = 4 + XCTAssertEqual(conv.messageCount, 4) + } + + func testResetKeepsSystemPrompt() async throws { + let conv = try makeConversation(systemPrompt: "Be helpful.", response: "ok") + try await conv.send("Hello") + conv.reset(keepingSystemPrompt: true) + XCTAssertEqual(conv.messageCount, 1) + XCTAssertEqual(conv.messages.first?.role, .system) + } + + func testResetWithoutSystemPrompt() async throws { + let conv = try makeConversation(systemPrompt: "Be helpful.", response: "ok") + try await conv.send("Hello") + conv.reset(keepingSystemPrompt: false) + XCTAssertEqual(conv.messageCount, 0) + } + + func testUndoLastExchange() async throws { + let conv = try makeConversation(response: "ok") + try await conv.send("First") + try await conv.send("Second") + conv.undoLastExchange() + // After undo: only the first exchange remains (2 messages) + XCTAssertEqual(conv.messageCount, 2) + XCTAssertEqual(conv.messages.last?.content, "ok") + } +} From 5cd7034b8a8179810c203f0a8785b24af78e5c83 Mon Sep 17 00:00:00 2001 From: Victor Durocher Date: Tue, 14 Apr 2026 14:03:52 +0200 Subject: [PATCH 08/14] feat: add single-prompt structured output overload send(_:as:) --- Sources/SwiftAIAgentCore/Core/AIAgentProtocol.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/SwiftAIAgentCore/Core/AIAgentProtocol.swift b/Sources/SwiftAIAgentCore/Core/AIAgentProtocol.swift index 773c66f..9f7d67a 100644 --- a/Sources/SwiftAIAgentCore/Core/AIAgentProtocol.swift +++ b/Sources/SwiftAIAgentCore/Core/AIAgentProtocol.swift @@ -104,6 +104,13 @@ public extension AIAgent { // MARK: - Feature 4: Structured Outputs + /// Send a single user message and decode the JSON response into a `Codable` type. + /// + /// Convenience overload — equivalent to `send(messages: [.user(prompt)], as: T.self)`. + func send(_ prompt: String, as type: T.Type) async throws -> T { + try await send(messages: [.user(prompt)], as: type) + } + /// Send messages and decode the JSON response directly into a Codable type. /// /// Uses native JSON mode when available (OpenAI, Gemini), otherwise injects From 33fd7ec1544eb04bfc25e6d69f820e0c3b958ab1 Mon Sep 17 00:00:00 2001 From: Victor Durocher Date: Wed, 22 Apr 2026 11:28:07 +0200 Subject: [PATCH 09/14] test: add AIAgentProtocolTests covering default implementations and structured outputs --- .../AIAgentProtocolTests.swift | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 Tests/SwiftAIAgentCoreTests/AIAgentProtocolTests.swift diff --git a/Tests/SwiftAIAgentCoreTests/AIAgentProtocolTests.swift b/Tests/SwiftAIAgentCoreTests/AIAgentProtocolTests.swift new file mode 100644 index 0000000..054db39 --- /dev/null +++ b/Tests/SwiftAIAgentCoreTests/AIAgentProtocolTests.swift @@ -0,0 +1,87 @@ +import XCTest +@testable import SwiftAIAgentCore + +// MARK: - Minimal mock used across protocol tests + +private struct EchoAgent: AIAgent { + let configuration: AIConfiguration + + func send(messages: [AIMessage]) async throws -> AIMessage { + // Echo the last user message back as the assistant response + let lastUserContent = messages.last(where: { $0.role == .user })?.content ?? "" + return .assistant(lastUserContent) + } + + func stream(messages: [AIMessage]) -> AsyncThrowingStream { + let content = messages.last?.content ?? "" + return AsyncThrowingStream { continuation in + continuation.yield(content) + continuation.finish() + } + } + + func send(messages: [AIMessage], tools: [AITool]) async throws -> AIMessageWithTools { + AIMessageWithTools(message: .assistant("no tools")) + } + + func send(messages: [AIMessage], toolResults: [AIToolResult]) async throws -> AIMessageWithTools { + AIMessageWithTools(message: .assistant("results received")) + } + + func sendForJSON(messages: [AIMessage]) async throws -> AIMessage { + .assistant("{\"value\": 42}") + } +} + +final class AIAgentProtocolTests: XCTestCase { + + private func makeAgent() throws -> EchoAgent { + let config = AIConfiguration(model: .gpt4o, apiKey: "test-key") + return EchoAgent(configuration: config) + } + + // MARK: - Default send(message:) + + func testSendSingleMessageReturnsContent() async throws { + let agent = try makeAgent() + let reply = try await agent.send(message: "Hello") + XCTAssertEqual(reply, "Hello") + } + + // MARK: - Default stream(message:) + + func testStreamSingleMessageYieldsContent() async throws { + let agent = try makeAgent() + var chunks: [String] = [] + for try await chunk in agent.stream(message: "Hi") { + chunks.append(chunk) + } + XCTAssertFalse(chunks.isEmpty) + } + + // MARK: - Structured output + + func testSendPromptAsDecodable() async throws { + let agent = try makeAgent() + + struct Result: Decodable, Sendable { let value: Int } + let result = try await agent.send("Give me JSON", as: Result.self) + XCTAssertEqual(result.value, 42) + } + + // MARK: - Token estimation + + func testEstimateTokensIsPositive() throws { + let agent = try makeAgent() + let messages: [AIMessage] = [.user("Hello world, this is a test message.")] + let tokens = agent.estimateTokens(for: messages) + XCTAssertGreaterThan(tokens, 0) + } + + func testEstimateTokensScalesWithMessageLength() throws { + let agent = try makeAgent() + let short = [AIMessage.user("Hi")] + let long = [AIMessage.user(String(repeating: "word ", count: 100))] + XCTAssertLessThan(agent.estimateTokens(for: short), agent.estimateTokens(for: long)) + } +} From 5889e377efe29f4e8859efc7a24f05f73976a76b Mon Sep 17 00:00:00 2001 From: Victor Durocher Date: Sun, 10 May 2026 14:45:00 +0200 Subject: [PATCH 10/14] =?UTF-8?q?feat:=20add=20AIRateLimiter=20=E2=80=94?= =?UTF-8?q?=20token-bucket=20throttle=20for=20API=20calls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Utils/AIRateLimiter.swift | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 Sources/SwiftAIAgentCore/Utils/AIRateLimiter.swift diff --git a/Sources/SwiftAIAgentCore/Utils/AIRateLimiter.swift b/Sources/SwiftAIAgentCore/Utils/AIRateLimiter.swift new file mode 100644 index 0000000..81cbb01 --- /dev/null +++ b/Sources/SwiftAIAgentCore/Utils/AIRateLimiter.swift @@ -0,0 +1,45 @@ +import Foundation + +// Token-bucket rate limiter for AI API calls. +// Prevents exceeding provider rate limits by throttling requests per minute. +public actor AIRateLimiter { + + private let requestsPerMinute: Int + private var timestamps: [Date] = [] + + public init(requestsPerMinute: Int) { + precondition(requestsPerMinute > 0, "requestsPerMinute must be positive") + self.requestsPerMinute = requestsPerMinute + } + + // Wait until a request slot is available, then record the request. + public func acquire() async throws { + let now = Date() + let windowStart = now.addingTimeInterval(-60) + + // Drop timestamps older than the rolling 1-minute window + timestamps = timestamps.filter { $0 > windowStart } + + if timestamps.count >= requestsPerMinute { + // Calculate how long to wait for the oldest slot to expire + guard let oldest = timestamps.first else { return } + let waitInterval = oldest.addingTimeInterval(60).timeIntervalSince(now) + if waitInterval > 0 { + try await Task.sleep(nanoseconds: UInt64(waitInterval * 1_000_000_000)) + } + } + + timestamps.append(Date()) + } + + // Current number of requests in the rolling window. + public var currentLoad: Int { + let windowStart = Date().addingTimeInterval(-60) + return timestamps.filter { $0 > windowStart }.count + } + + // Fraction of capacity used (0.0 – 1.0). + public var utilization: Double { + Double(currentLoad) / Double(requestsPerMinute) + } +} From 9113648833df207784b835cc1729dfdcec717483 Mon Sep 17 00:00:00 2001 From: Victor Durocher Date: Thu, 14 May 2026 09:55:00 +0200 Subject: [PATCH 11/14] test: add AIRateLimiterTests covering load and utilization --- .../AIRateLimiterTests.swift | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 Tests/SwiftAIAgentCoreTests/AIRateLimiterTests.swift diff --git a/Tests/SwiftAIAgentCoreTests/AIRateLimiterTests.swift b/Tests/SwiftAIAgentCoreTests/AIRateLimiterTests.swift new file mode 100644 index 0000000..f436daa --- /dev/null +++ b/Tests/SwiftAIAgentCoreTests/AIRateLimiterTests.swift @@ -0,0 +1,51 @@ +import Testing +import Foundation +@testable import SwiftAIAgentCore + +@Suite("AIRateLimiter") +struct AIRateLimiterTests { + + @Test("allows requests under the limit without waiting") + func allowsRequestsUnderLimit() async throws { + let limiter = AIRateLimiter(requestsPerMinute: 60) + + // Fire 5 requests in a row — all should complete immediately + for _ in 0..<5 { + try await limiter.acquire() + } + + let load = await limiter.currentLoad + #expect(load == 5) + } + + @Test("currentLoad reflects active window entries") + func currentLoadMatchesAcquiredCount() async throws { + let limiter = AIRateLimiter(requestsPerMinute: 100) + + try await limiter.acquire() + try await limiter.acquire() + try await limiter.acquire() + + let load = await limiter.currentLoad + #expect(load == 3) + } + + @Test("utilization is 0 for a fresh limiter") + func utilizationIsZeroInitially() async { + let limiter = AIRateLimiter(requestsPerMinute: 10) + let utilization = await limiter.utilization + #expect(utilization == 0.0) + } + + @Test("utilization approaches 1 when near capacity") + func utilizationNearCapacity() async throws { + let limiter = AIRateLimiter(requestsPerMinute: 4) + + for _ in 0..<3 { + try await limiter.acquire() + } + + let utilization = await limiter.utilization + #expect(utilization == 0.75) + } +} From 764f03e728f91db5860a3fedd174bd5dd4120cae Mon Sep 17 00:00:00 2001 From: Victor Durocher Date: Sat, 16 May 2026 16:30:00 +0200 Subject: [PATCH 12/14] feat: add AIRetryPolicy with exponential backoff and jitter --- .../SwiftAIAgentCore/Core/AIRetryPolicy.swift | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 Sources/SwiftAIAgentCore/Core/AIRetryPolicy.swift diff --git a/Sources/SwiftAIAgentCore/Core/AIRetryPolicy.swift b/Sources/SwiftAIAgentCore/Core/AIRetryPolicy.swift new file mode 100644 index 0000000..7b435fc --- /dev/null +++ b/Sources/SwiftAIAgentCore/Core/AIRetryPolicy.swift @@ -0,0 +1,64 @@ +import Foundation + +// Configures exponential backoff retry behaviour for AI API calls. +// Attach to a client to automatically retry transient errors. +public struct AIRetryPolicy: Sendable { + + // Maximum number of retry attempts (not counting the initial attempt). + public let maxAttempts: Int + + // Base delay before the first retry, in seconds. + public let baseDelay: TimeInterval + + // Multiplier applied to the delay after each failed attempt. + public let backoffMultiplier: Double + + // Upper bound on the computed delay to avoid unbounded waits. + public let maxDelay: TimeInterval + + // When true, adds a random jitter (±10 %) to each delay to avoid thundering-herd. + public let jitter: Bool + + public init( + maxAttempts: Int = 3, + baseDelay: TimeInterval = 1.0, + backoffMultiplier: Double = 2.0, + maxDelay: TimeInterval = 30.0, + jitter: Bool = true + ) { + precondition(maxAttempts >= 0, "maxAttempts must be non-negative") + precondition(baseDelay > 0, "baseDelay must be positive") + precondition(backoffMultiplier >= 1, "backoffMultiplier must be >= 1") + self.maxAttempts = maxAttempts + self.baseDelay = baseDelay + self.backoffMultiplier = backoffMultiplier + self.maxDelay = maxDelay + self.jitter = jitter + } + + // Compute the delay before attempt number `attempt` (1-based). + public func delay(forAttempt attempt: Int) -> TimeInterval { + guard attempt > 0 else { return 0 } + let exponential = baseDelay * pow(backoffMultiplier, Double(attempt - 1)) + let capped = min(exponential, maxDelay) + guard jitter else { return capped } + // ±10 % jitter + let factor = 0.9 + Double.random(in: 0..<0.2) + return capped * factor + } +} + +public extension AIRetryPolicy { + + // No retries — fail immediately on first error. + static let none = AIRetryPolicy(maxAttempts: 0) + + // Aggressive: 5 retries, starts at 500 ms. + static let aggressive = AIRetryPolicy(maxAttempts: 5, baseDelay: 0.5) + + // Standard: 3 retries, starts at 1 s (default). + static let standard = AIRetryPolicy() + + // Conservative: 2 retries, starts at 2 s. + static let conservative = AIRetryPolicy(maxAttempts: 2, baseDelay: 2.0) +} From 75b1520c15a3a8f8e0c2754caf802f9da939537e Mon Sep 17 00:00:00 2001 From: Victor Durocher Date: Tue, 19 May 2026 10:15:00 +0200 Subject: [PATCH 13/14] =?UTF-8?q?feat:=20add=20AIStreamingSession=20?= =?UTF-8?q?=E2=80=94=20stateful=20token-by-token=20streaming=20wrapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/AIStreamingSession.swift | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 Sources/SwiftAIAgentCore/Core/AIStreamingSession.swift diff --git a/Sources/SwiftAIAgentCore/Core/AIStreamingSession.swift b/Sources/SwiftAIAgentCore/Core/AIStreamingSession.swift new file mode 100644 index 0000000..0774b49 --- /dev/null +++ b/Sources/SwiftAIAgentCore/Core/AIStreamingSession.swift @@ -0,0 +1,78 @@ +import Foundation + +// Stateful streaming session around an AIAgent. +// Maintains conversation history while streaming each reply token-by-token. +// Use when you need live UI updates (typing indicator, progressive text reveal). +@MainActor +public final class AIStreamingSession { + + private let agent: any AIAgent + public private(set) var messages: [AIMessage] = [] + + // Emitted tokens for the current in-progress reply. + // Resets to empty at the start of each new send() call. + public private(set) var pendingTokens: String = "" + + // True while a streaming response is in progress. + public private(set) var isStreaming: Bool = false + + public init(agent: any AIAgent, systemPrompt: String? = nil) { + self.agent = agent + if let prompt = systemPrompt { + messages = [.system(prompt)] + } + } + + // MARK: - Streaming send + + /// Stream a user message, yielding each token to `onToken`, then appending + /// the completed assistant reply to history. + /// + /// - Parameters: + /// - text: The user's message. + /// - onToken: Called on the main actor for each streamed token. + /// - Returns: The complete assistant reply. + @discardableResult + public func send( + _ text: String, + onToken: @MainActor (String) -> Void = { _ in } + ) async throws -> String { + guard !isStreaming else { + throw AIStreamingError.alreadyStreaming + } + + isStreaming = true + pendingTokens = "" + messages.append(.user(text)) + + defer { isStreaming = false } + + var accumulated = "" + for try await token in agent.stream(messages: messages) { + accumulated += token + pendingTokens = accumulated + onToken(token) + } + + let reply = AIMessage.assistant(accumulated) + messages.append(reply) + pendingTokens = "" + return accumulated + } + + // MARK: - History management + + public func reset() { + messages = messages.filter { $0.role == .system } + pendingTokens = "" + } + + public var messageCount: Int { messages.count } +} + +// MARK: - Errors + +public enum AIStreamingError: Error, Sendable { + // A new send() was called while a previous stream was still active. + case alreadyStreaming +} From a15ed0ec6e633930ef424170c2194fd6fd6ee58b Mon Sep 17 00:00:00 2001 From: Victor Durocher Date: Thu, 21 May 2026 09:40:00 +0200 Subject: [PATCH 14/14] =?UTF-8?q?test:=20add=20AIStreamingSessionTests=20?= =?UTF-8?q?=E2=80=94=20history,=20reset,=20token=20callback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AIStreamingSessionTests.swift | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 Tests/SwiftAIAgentCoreTests/AIStreamingSessionTests.swift diff --git a/Tests/SwiftAIAgentCoreTests/AIStreamingSessionTests.swift b/Tests/SwiftAIAgentCoreTests/AIStreamingSessionTests.swift new file mode 100644 index 0000000..dfd36ff --- /dev/null +++ b/Tests/SwiftAIAgentCoreTests/AIStreamingSessionTests.swift @@ -0,0 +1,94 @@ +import XCTest +@testable import SwiftAIAgentCore + +// MARK: - Mock agent that streams tokens one by one + +private struct StreamingMockAgent: AIAgent { + let configuration: AIConfiguration + let tokens: [String] + + func send(messages: [AIMessage]) async throws -> AIMessage { + .assistant(tokens.joined()) + } + + func stream(messages: [AIMessage]) -> AsyncThrowingStream { + let captured = tokens + return AsyncThrowingStream { continuation in + for token in captured { + continuation.yield(token) + } + continuation.finish() + } + } + + func send(messages: [AIMessage], tools: [AITool]) async throws -> AIMessageWithTools { + AIMessageWithTools(message: .assistant(tokens.joined())) + } + + func send(messages: [AIMessage], toolResults: [AIToolResult]) async throws -> AIMessageWithTools { + AIMessageWithTools(message: .assistant(tokens.joined())) + } + + func sendForJSON(messages: [AIMessage]) async throws -> AIMessage { + .assistant("{}") + } +} + +// MARK: - Tests + +@MainActor +final class AIStreamingSessionTests: XCTestCase { + + private func makeSession(tokens: [String], systemPrompt: String? = nil) -> AIStreamingSession { + let config = AIConfiguration(model: .gpt4o, apiKey: "test-key") + let agent = StreamingMockAgent(configuration: config, tokens: tokens) + return AIStreamingSession(agent: agent, systemPrompt: systemPrompt) + } + + func testStreamingReturnsCompleteReply() async throws { + let session = makeSession(tokens: ["Hello", ", ", "world", "!"]) + let result = try await session.send("Hi") + XCTAssertEqual(result, "Hello, world!") + } + + func testHistoryContainsUserAndAssistantMessages() async throws { + let session = makeSession(tokens: ["Hi there"]) + try await session.send("Hello") + // history: user + assistant (no system prompt) + XCTAssertEqual(session.messageCount, 2) + XCTAssertEqual(session.messages[0].role, .user) + XCTAssertEqual(session.messages[1].role, .assistant) + } + + func testSystemPromptPrependsToHistory() async throws { + let session = makeSession(tokens: ["ok"], systemPrompt: "You are helpful.") + try await session.send("test") + // history: system + user + assistant + XCTAssertEqual(session.messageCount, 3) + XCTAssertEqual(session.messages[0].role, .system) + } + + func testResetClearsHistoryButKeepsSystemPrompt() async throws { + let session = makeSession(tokens: ["reply"], systemPrompt: "Be concise.") + try await session.send("first message") + session.reset() + // Only the system message survives reset + XCTAssertEqual(session.messageCount, 1) + XCTAssertEqual(session.messages[0].role, .system) + } + + func testIsStreamingIsFalseAfterCompletion() async throws { + let session = makeSession(tokens: ["done"]) + try await session.send("go") + XCTAssertFalse(session.isStreaming) + } + + func testTokenCallbackReceivesEachToken() async throws { + let session = makeSession(tokens: ["a", "b", "c"]) + var received: [String] = [] + try await session.send("go") { token in + received.append(token) + } + XCTAssertEqual(received, ["a", "b", "c"]) + } +}