phase2: models, provider clients (OpenAI-compat + Anthropic native), SSE streaming, persistence, catalog skeleton, 10 unit tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 18 changed files with +1,841 and −23
modified
Makefile
+1 −1
@@ -36,7 +36,7 @@ build: | ||
| 36 | 36 | swift build -c release |
| 37 | 37 | |
| 38 | 38 | test: |
| 39 | − swift test | |
| 39 | + scripts/test.sh | |
| 40 | 40 | |
| 41 | 41 | run: dev |
| 42 | 42 | open "$(APP_DIR)" |
added
Sources/ZyquoCloud/Models/AIModel.swift
+108 −0
@@ -0,0 +1,108 @@ | ||
| 1 | +// | |
| 2 | +// AIModel.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// A chat-capable model offered by a provider. Instances come exclusively from | |
| 12 | +/// `ModelCatalog` (built-in data generated from docs/PROVIDERS.md, dynamic | |
| 13 | +/// `/models` refreshes, and user-defined custom models) — never hardcode these | |
| 14 | +/// in views or clients. | |
| 15 | +struct AIModel: Codable, Identifiable, Hashable { | |
| 16 | + /// Exact model ID as sent in API requests (e.g. "gpt-5.6-terra"). | |
| 17 | + let id: String | |
| 18 | + let provider: ProviderID | |
| 19 | + /// Human-friendly name shown in the UI (e.g. "GPT-5.6 Terra"). | |
| 20 | + let displayName: String | |
| 21 | + /// Context window in tokens. | |
| 22 | + let contextWindow: Int | |
| 23 | + /// Maximum output tokens, when documented. | |
| 24 | + let maxOutputTokens: Int? | |
| 25 | + let capabilities: ModelCapabilities | |
| 26 | + let pricing: ModelPricing? | |
| 27 | + let parameterSupport: ParameterSupport | |
| 28 | + /// Deprecated or superseded models stay selectable but are ranked last and badged. | |
| 29 | + var isLegacy: Bool = false | |
| 30 | + /// Featured/flagship models surface at the top of pickers. | |
| 31 | + var isRecommended: Bool = false | |
| 32 | + /// Base URL override for user-defined custom models; nil for built-ins. | |
| 33 | + var customBaseURL: URL? = nil | |
| 34 | + | |
| 35 | + /// Short badge text for the model chip (e.g. "1M ctx"). | |
| 36 | + var contextBadge: String { | |
| 37 | + switch contextWindow { | |
| 38 | + case 1_000_000...: return "\(contextWindow / 1_000_000)M ctx" | |
| 39 | + case 1_000...: return "\(contextWindow / 1_000)K ctx" | |
| 40 | + default: return "\(contextWindow) ctx" | |
| 41 | + } | |
| 42 | + } | |
| 43 | +} | |
| 44 | + | |
| 45 | +/// What a model can do. Drives UI affordances (attach button, thinking section…) | |
| 46 | +/// and request construction. | |
| 47 | +struct ModelCapabilities: Codable, Hashable { | |
| 48 | + /// Accepts image input. | |
| 49 | + var vision: Bool = false | |
| 50 | + /// Supports function calling / tools. | |
| 51 | + var tools: Bool = false | |
| 52 | + /// Produces reasoning/thinking output (shown in the collapsible section). | |
| 53 | + var reasoning: Bool = false | |
| 54 | + /// Supports SSE streaming (true for every catalog model; custom endpoints may vary). | |
| 55 | + var streaming: Bool = true | |
| 56 | + /// Supports JSON mode / structured output. | |
| 57 | + var jsonMode: Bool = false | |
| 58 | + /// Returns web-search citations (Perplexity sonar family). | |
| 59 | + var citations: Bool = false | |
| 60 | +} | |
| 61 | + | |
| 62 | +/// USD per 1M tokens. Cached/tiered pricing is intentionally simplified to the | |
| 63 | +/// base rate — cost figures in the UI are labeled as estimates. | |
| 64 | +struct ModelPricing: Codable, Hashable { | |
| 65 | + var inputPerMTok: Double | |
| 66 | + var outputPerMTok: Double | |
| 67 | + | |
| 68 | + /// Estimated cost in USD for a usage record. | |
| 69 | + func cost(inputTokens: Int, outputTokens: Int) -> Double { | |
| 70 | + (Double(inputTokens) * inputPerMTok + Double(outputTokens) * outputPerMTok) / 1_000_000 | |
| 71 | + } | |
| 72 | +} | |
| 73 | + | |
| 74 | +/// Which sampling/control parameters a model accepts. Providers reject requests | |
| 75 | +/// carrying unsupported parameters, so requests only include what's supported — | |
| 76 | +/// and Settings only shows sliders that apply. | |
| 77 | +struct ParameterSupport: Codable, Hashable { | |
| 78 | + var temperature: Bool = true | |
| 79 | + var topP: Bool = true | |
| 80 | + var frequencyPenalty: Bool = false | |
| 81 | + var presencePenalty: Bool = false | |
| 82 | + /// Send "max_completion_tokens" instead of "max_tokens" (OpenAI reasoning models, Cerebras). | |
| 83 | + var usesMaxCompletionTokens: Bool = false | |
| 84 | + /// Accepts `reasoning_effort` (OpenAI, xAI, Mistral, DeepSeek, Kimi K-series, Cerebras…). | |
| 85 | + var reasoningEffort: Bool = false | |
| 86 | + /// Anthropic `thinking` / Qwen `enable_thinking` style explicit thinking toggle. | |
| 87 | + var thinkingToggle: Bool = false | |
| 88 | + | |
| 89 | + static let openAIDefault = ParameterSupport(frequencyPenalty: true, presencePenalty: true) | |
| 90 | +} | |
| 91 | + | |
| 92 | +/// Token usage reported by a provider for one exchange. | |
| 93 | +struct TokenUsage: Codable, Hashable { | |
| 94 | + var inputTokens: Int = 0 | |
| 95 | + var outputTokens: Int = 0 | |
| 96 | + var reasoningTokens: Int? = nil | |
| 97 | + | |
| 98 | + var totalTokens: Int { inputTokens + outputTokens } | |
| 99 | + | |
| 100 | + static func + (lhs: TokenUsage, rhs: TokenUsage) -> TokenUsage { | |
| 101 | + TokenUsage( | |
| 102 | + inputTokens: lhs.inputTokens + rhs.inputTokens, | |
| 103 | + outputTokens: lhs.outputTokens + rhs.outputTokens, | |
| 104 | + reasoningTokens: (lhs.reasoningTokens ?? 0) + (rhs.reasoningTokens ?? 0) == 0 | |
| 105 | + ? nil : (lhs.reasoningTokens ?? 0) + (rhs.reasoningTokens ?? 0) | |
| 106 | + ) | |
| 107 | + } | |
| 108 | +} | |
added
Sources/ZyquoCloud/Models/Conversation.swift
+126 −0
@@ -0,0 +1,126 @@ | ||
| 1 | +// | |
| 2 | +// Conversation.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// A chat thread. Persisted as one JSON file per conversation in | |
| 12 | +/// `~/Library/Application Support/ZyquoCloud/Conversations/`. | |
| 13 | +struct Conversation: Codable, Identifiable, Hashable { | |
| 14 | + let id: UUID | |
| 15 | + var title: String | |
| 16 | + var messages: [Message] | |
| 17 | + /// Current model selection (changeable mid-conversation and per message). | |
| 18 | + var modelID: String | |
| 19 | + var provider: ProviderID | |
| 20 | + var systemPrompt: String? | |
| 21 | + var parameters: ChatParameters | |
| 22 | + var personaID: UUID? | |
| 23 | + var isPinned: Bool = false | |
| 24 | + var folder: String? | |
| 25 | + var tags: [String] = [] | |
| 26 | + var createdAt: Date | |
| 27 | + var updatedAt: Date | |
| 28 | + /// True until the first exchange completes and a title is auto-generated. | |
| 29 | + var hasAutoTitle: Bool = true | |
| 30 | + | |
| 31 | + init( | |
| 32 | + id: UUID = UUID(), | |
| 33 | + title: String = "New Chat", | |
| 34 | + messages: [Message] = [], | |
| 35 | + modelID: String, | |
| 36 | + provider: ProviderID, | |
| 37 | + systemPrompt: String? = nil, | |
| 38 | + parameters: ChatParameters = ChatParameters(), | |
| 39 | + personaID: UUID? = nil, | |
| 40 | + createdAt: Date = Date(), | |
| 41 | + updatedAt: Date = Date() | |
| 42 | + ) { | |
| 43 | + self.id = id | |
| 44 | + self.title = title | |
| 45 | + self.messages = messages | |
| 46 | + self.modelID = modelID | |
| 47 | + self.provider = provider | |
| 48 | + self.systemPrompt = systemPrompt | |
| 49 | + self.parameters = parameters | |
| 50 | + self.personaID = personaID | |
| 51 | + self.createdAt = createdAt | |
| 52 | + self.updatedAt = updatedAt | |
| 53 | + } | |
| 54 | + | |
| 55 | + /// Total usage across all assistant messages. | |
| 56 | + var totalUsage: TokenUsage { | |
| 57 | + messages.compactMap(\.usage).reduce(TokenUsage(), +) | |
| 58 | + } | |
| 59 | + | |
| 60 | + /// Total estimated cost in USD. | |
| 61 | + var totalCost: Double { | |
| 62 | + messages.compactMap(\.estimatedCost).reduce(0, +) | |
| 63 | + } | |
| 64 | +} | |
| 65 | + | |
| 66 | +/// Per-conversation generation parameters. `nil` means "provider default" and | |
| 67 | +/// the parameter is omitted from the request entirely. | |
| 68 | +struct ChatParameters: Codable, Hashable { | |
| 69 | + var temperature: Double? | |
| 70 | + var topP: Double? | |
| 71 | + var maxTokens: Int? | |
| 72 | + var frequencyPenalty: Double? | |
| 73 | + var presencePenalty: Double? | |
| 74 | + /// "low" / "medium" / "high" for models supporting reasoning_effort. | |
| 75 | + var reasoningEffort: String? | |
| 76 | + /// Explicit thinking toggle for Anthropic/Qwen-style models. | |
| 77 | + var thinkingEnabled: Bool? | |
| 78 | +} | |
| 79 | + | |
| 80 | +/// A reusable configuration: system prompt + preferred model + parameters. | |
| 81 | +struct Persona: Codable, Identifiable, Hashable { | |
| 82 | + let id: UUID | |
| 83 | + var name: String | |
| 84 | + /// SF Symbol name for the persona glyph. | |
| 85 | + var symbolName: String | |
| 86 | + var systemPrompt: String | |
| 87 | + var modelID: String? | |
| 88 | + var provider: ProviderID? | |
| 89 | + var parameters: ChatParameters | |
| 90 | + | |
| 91 | + init( | |
| 92 | + id: UUID = UUID(), | |
| 93 | + name: String, | |
| 94 | + symbolName: String = "person.crop.circle", | |
| 95 | + systemPrompt: String, | |
| 96 | + modelID: String? = nil, | |
| 97 | + provider: ProviderID? = nil, | |
| 98 | + parameters: ChatParameters = ChatParameters() | |
| 99 | + ) { | |
| 100 | + self.id = id | |
| 101 | + self.name = name | |
| 102 | + self.symbolName = symbolName | |
| 103 | + self.systemPrompt = systemPrompt | |
| 104 | + self.modelID = modelID | |
| 105 | + self.provider = provider | |
| 106 | + self.parameters = parameters | |
| 107 | + } | |
| 108 | +} | |
| 109 | + | |
| 110 | +/// A prompt template from the built-in library or created by the user. | |
| 111 | +/// `{{input}}` in the body is replaced with the user's text. | |
| 112 | +struct PromptTemplate: Codable, Identifiable, Hashable { | |
| 113 | + let id: UUID | |
| 114 | + var title: String | |
| 115 | + var category: String | |
| 116 | + var body: String | |
| 117 | + var isBuiltIn: Bool | |
| 118 | + | |
| 119 | + init(id: UUID = UUID(), title: String, category: String, body: String, isBuiltIn: Bool = false) { | |
| 120 | + self.id = id | |
| 121 | + self.title = title | |
| 122 | + self.category = category | |
| 123 | + self.body = body | |
| 124 | + self.isBuiltIn = isBuiltIn | |
| 125 | + } | |
| 126 | +} | |
added
Sources/ZyquoCloud/Models/Message.swift
+105 −0
@@ -0,0 +1,105 @@ | ||
| 1 | +// | |
| 2 | +// Message.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// One turn in a conversation transcript. | |
| 12 | +struct Message: Codable, Identifiable, Hashable { | |
| 13 | + enum Role: String, Codable { | |
| 14 | + case system | |
| 15 | + case user | |
| 16 | + case assistant | |
| 17 | + } | |
| 18 | + | |
| 19 | + let id: UUID | |
| 20 | + var role: Role | |
| 21 | + var text: String | |
| 22 | + /// Reasoning/thinking text streamed by reasoning models (collapsible in the UI). | |
| 23 | + var reasoning: String? | |
| 24 | + /// Image attachments (user messages, vision models). | |
| 25 | + var attachments: [Attachment] | |
| 26 | + /// Web-search citations (Perplexity). | |
| 27 | + var citations: [Citation] | |
| 28 | + /// Model that produced this message (assistant turns) or was targeted (user turns). | |
| 29 | + var modelID: String? | |
| 30 | + var provider: ProviderID? | |
| 31 | + var usage: TokenUsage? | |
| 32 | + /// Estimated USD cost computed from catalog pricing at receive time. | |
| 33 | + var estimatedCost: Double? | |
| 34 | + var createdAt: Date | |
| 35 | + /// Set while a response is streaming; exactly one message can be streaming at a time. | |
| 36 | + var isStreaming: Bool = false | |
| 37 | + /// Human-readable error if generation failed mid-message. | |
| 38 | + var errorText: String? | |
| 39 | + | |
| 40 | + init( | |
| 41 | + id: UUID = UUID(), | |
| 42 | + role: Role, | |
| 43 | + text: String, | |
| 44 | + reasoning: String? = nil, | |
| 45 | + attachments: [Attachment] = [], | |
| 46 | + citations: [Citation] = [], | |
| 47 | + modelID: String? = nil, | |
| 48 | + provider: ProviderID? = nil, | |
| 49 | + usage: TokenUsage? = nil, | |
| 50 | + estimatedCost: Double? = nil, | |
| 51 | + createdAt: Date = Date() | |
| 52 | + ) { | |
| 53 | + self.id = id | |
| 54 | + self.role = role | |
| 55 | + self.text = text | |
| 56 | + self.reasoning = reasoning | |
| 57 | + self.attachments = attachments | |
| 58 | + self.citations = citations | |
| 59 | + self.modelID = modelID | |
| 60 | + self.provider = provider | |
| 61 | + self.usage = usage | |
| 62 | + self.estimatedCost = estimatedCost | |
| 63 | + self.createdAt = createdAt | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 67 | +/// A file attached to a user message. Images go to vision models as base64; | |
| 68 | +/// text files are injected into the prompt. | |
| 69 | +struct Attachment: Codable, Identifiable, Hashable { | |
| 70 | + enum Kind: String, Codable { | |
| 71 | + case image | |
| 72 | + case textFile | |
| 73 | + } | |
| 74 | + | |
| 75 | + let id: UUID | |
| 76 | + var kind: Kind | |
| 77 | + var fileName: String | |
| 78 | + /// image: raw image bytes; textFile: UTF-8 contents. | |
| 79 | + var data: Data | |
| 80 | + /// MIME type for images (image/png, image/jpeg, image/webp, image/gif). | |
| 81 | + var mimeType: String | |
| 82 | + | |
| 83 | + init(id: UUID = UUID(), kind: Kind, fileName: String, data: Data, mimeType: String) { | |
| 84 | + self.id = id | |
| 85 | + self.kind = kind | |
| 86 | + self.fileName = fileName | |
| 87 | + self.data = data | |
| 88 | + self.mimeType = mimeType | |
| 89 | + } | |
| 90 | +} | |
| 91 | + | |
| 92 | +/// A numbered web source backing an assistant answer (Perplexity sonar family). | |
| 93 | +struct Citation: Codable, Identifiable, Hashable { | |
| 94 | + let id: UUID | |
| 95 | + var index: Int | |
| 96 | + var url: URL | |
| 97 | + var title: String? | |
| 98 | + | |
| 99 | + init(id: UUID = UUID(), index: Int, url: URL, title: String? = nil) { | |
| 100 | + self.id = id | |
| 101 | + self.index = index | |
| 102 | + self.url = url | |
| 103 | + self.title = title | |
| 104 | + } | |
| 105 | +} | |
added
Sources/ZyquoCloud/Models/ProviderID.swift
+93 −0
@@ -0,0 +1,93 @@ | ||
| 1 | +// | |
| 2 | +// ProviderID.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// The 12 built-in cloud AI providers, plus user-defined custom endpoints. | |
| 12 | +enum ProviderID: String, Codable, CaseIterable, Identifiable, Hashable { | |
| 13 | + case openai | |
| 14 | + case anthropic | |
| 15 | + case xai | |
| 16 | + case mistral | |
| 17 | + case gemini | |
| 18 | + case qwen | |
| 19 | + case deepseek | |
| 20 | + case kimi | |
| 21 | + case perplexity | |
| 22 | + case together | |
| 23 | + case deepinfra | |
| 24 | + case cerebras | |
| 25 | + case custom | |
| 26 | + | |
| 27 | + var id: String { rawValue } | |
| 28 | + | |
| 29 | + /// User-facing display name. | |
| 30 | + var displayName: String { | |
| 31 | + switch self { | |
| 32 | + case .openai: return "OpenAI" | |
| 33 | + case .anthropic: return "Anthropic" | |
| 34 | + case .xai: return "xAI" | |
| 35 | + case .mistral: return "Mistral" | |
| 36 | + case .gemini: return "Google Gemini" | |
| 37 | + case .qwen: return "Alibaba Qwen" | |
| 38 | + case .deepseek: return "DeepSeek" | |
| 39 | + case .kimi: return "Kimi" | |
| 40 | + case .perplexity: return "Perplexity" | |
| 41 | + case .together: return "Together AI" | |
| 42 | + case .deepinfra: return "DeepInfra" | |
| 43 | + case .cerebras: return "Cerebras" | |
| 44 | + case .custom: return "Custom" | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + /// Wire protocol used by this provider's chat endpoint. | |
| 49 | + var wireFormat: WireFormat { | |
| 50 | + switch self { | |
| 51 | + case .anthropic: return .anthropicMessages | |
| 52 | + default: return .openAIChatCompletions | |
| 53 | + } | |
| 54 | + } | |
| 55 | + | |
| 56 | + /// Base URL of the provider's API (chat + models live under this root). | |
| 57 | + /// `custom` has no fixed base URL — it comes from the user's endpoint config. | |
| 58 | + var defaultBaseURL: URL? { | |
| 59 | + switch self { | |
| 60 | + case .openai: return URL(string: "https://api.openai.com/v1") | |
| 61 | + case .anthropic: return URL(string: "https://api.anthropic.com/v1") | |
| 62 | + case .xai: return URL(string: "https://api.x.ai/v1") | |
| 63 | + case .mistral: return URL(string: "https://api.mistral.ai/v1") | |
| 64 | + case .gemini: return URL(string: "https://generativelanguage.googleapis.com/v1beta/openai") | |
| 65 | + case .qwen: return URL(string: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1") | |
| 66 | + case .deepseek: return URL(string: "https://api.deepseek.com") | |
| 67 | + case .kimi: return URL(string: "https://api.moonshot.ai/v1") | |
| 68 | + case .perplexity: return URL(string: "https://api.perplexity.ai") | |
| 69 | + case .together: return URL(string: "https://api.together.xyz/v1") | |
| 70 | + case .deepinfra: return URL(string: "https://api.deepinfra.com/v1/openai") | |
| 71 | + case .cerebras: return URL(string: "https://api.cerebras.ai/v1") | |
| 72 | + case .custom: return nil | |
| 73 | + } | |
| 74 | + } | |
| 75 | + | |
| 76 | + /// Whether the provider exposes a `/models` listing endpoint usable for dynamic refresh. | |
| 77 | + var supportsModelListing: Bool { | |
| 78 | + self != .perplexity | |
| 79 | + } | |
| 80 | + | |
| 81 | + /// Providers shown in Settings (custom endpoints are managed separately). | |
| 82 | + static var builtIn: [ProviderID] { | |
| 83 | + allCases.filter { $0 != .custom } | |
| 84 | + } | |
| 85 | +} | |
| 86 | + | |
| 87 | +/// The request/response schema a provider speaks. | |
| 88 | +enum WireFormat: String, Codable { | |
| 89 | + /// OpenAI `/chat/completions` schema (used by 11 of the 12 built-in providers). | |
| 90 | + case openAIChatCompletions | |
| 91 | + /// Anthropic `/v1/messages` schema. | |
| 92 | + case anthropicMessages | |
| 93 | +} | |
added
Sources/ZyquoCloud/Providers/AnthropicClient.swift
+300 −0
@@ -0,0 +1,300 @@ | ||
| 1 | +// | |
| 2 | +// AnthropicClient.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Native Anthropic Messages API client (/v1/messages) — NOT OpenAI-compatible. | |
| 9 | +// Auth: x-api-key + anthropic-version headers. System prompt is a top-level | |
| 10 | +// param, content is block-structured, max_tokens is mandatory, streaming uses | |
| 11 | +// named SSE events. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import Foundation | |
| 15 | + | |
| 16 | +struct AnthropicClient: ProviderClient { | |
| 17 | + let providerID: ProviderID = .anthropic | |
| 18 | + | |
| 19 | + private static let apiVersion = "2023-06-01" | |
| 20 | + private static let defaultMaxTokens = 8192 | |
| 21 | + | |
| 22 | + // MARK: - Wire types (requests) | |
| 23 | + | |
| 24 | + private struct WireRequest: Encodable { | |
| 25 | + var model: String | |
| 26 | + var maxTokens: Int | |
| 27 | + var messages: [WireMessage] | |
| 28 | + var system: String? | |
| 29 | + var stream: Bool? | |
| 30 | + var temperature: Double? | |
| 31 | + var topP: Double? | |
| 32 | + var thinking: Thinking? | |
| 33 | + | |
| 34 | + enum CodingKeys: String, CodingKey { | |
| 35 | + case model, messages, system, stream, temperature, thinking | |
| 36 | + case maxTokens = "max_tokens" | |
| 37 | + case topP = "top_p" | |
| 38 | + } | |
| 39 | + } | |
| 40 | + | |
| 41 | + private struct Thinking: Encodable { | |
| 42 | + var type: String | |
| 43 | + var budgetTokens: Int? | |
| 44 | + enum CodingKeys: String, CodingKey { | |
| 45 | + case type | |
| 46 | + case budgetTokens = "budget_tokens" | |
| 47 | + } | |
| 48 | + } | |
| 49 | + | |
| 50 | + private struct WireMessage: Encodable { | |
| 51 | + var role: String | |
| 52 | + var content: [WireBlock] | |
| 53 | + } | |
| 54 | + | |
| 55 | + private enum WireBlock: Encodable { | |
| 56 | + case text(String) | |
| 57 | + case image(mediaType: String, base64: String) | |
| 58 | + | |
| 59 | + func encode(to encoder: Encoder) throws { | |
| 60 | + var container = encoder.container(keyedBy: Key.self) | |
| 61 | + switch self { | |
| 62 | + case .text(let s): | |
| 63 | + try container.encode("text", forKey: .type) | |
| 64 | + try container.encode(s, forKey: .text) | |
| 65 | + case .image(let mediaType, let base64): | |
| 66 | + try container.encode("image", forKey: .type) | |
| 67 | + var source = container.nestedContainer(keyedBy: Key.self, forKey: .source) | |
| 68 | + try source.encode("base64", forKey: .type) | |
| 69 | + try source.encode(mediaType, forKey: .mediaType) | |
| 70 | + try source.encode(base64, forKey: .data) | |
| 71 | + } | |
| 72 | + } | |
| 73 | + | |
| 74 | + enum Key: String, CodingKey { | |
| 75 | + case type, text, source, data | |
| 76 | + case mediaType = "media_type" | |
| 77 | + } | |
| 78 | + } | |
| 79 | + | |
| 80 | + // MARK: - Wire types (responses) | |
| 81 | + | |
| 82 | + private struct StreamEvent: Decodable { | |
| 83 | + var type: String? | |
| 84 | + var delta: Delta? | |
| 85 | + var usage: WireUsage? | |
| 86 | + var message: MessageStart? | |
| 87 | + var error: WireError? | |
| 88 | + | |
| 89 | + struct Delta: Decodable { | |
| 90 | + var type: String? | |
| 91 | + var text: String? | |
| 92 | + var thinking: String? | |
| 93 | + var stopReason: String? | |
| 94 | + enum CodingKeys: String, CodingKey { | |
| 95 | + case type, text, thinking | |
| 96 | + case stopReason = "stop_reason" | |
| 97 | + } | |
| 98 | + } | |
| 99 | + | |
| 100 | + struct MessageStart: Decodable { | |
| 101 | + var usage: WireUsage? | |
| 102 | + } | |
| 103 | + | |
| 104 | + struct WireError: Decodable { | |
| 105 | + var message: String? | |
| 106 | + } | |
| 107 | + } | |
| 108 | + | |
| 109 | + private struct WireUsage: Decodable { | |
| 110 | + var inputTokens: Int? | |
| 111 | + var outputTokens: Int? | |
| 112 | + enum CodingKeys: String, CodingKey { | |
| 113 | + case inputTokens = "input_tokens" | |
| 114 | + case outputTokens = "output_tokens" | |
| 115 | + } | |
| 116 | + } | |
| 117 | + | |
| 118 | + private struct WireResponse: Decodable { | |
| 119 | + var content: [Block]? | |
| 120 | + var usage: WireUsage? | |
| 121 | + var stopReason: String? | |
| 122 | + | |
| 123 | + struct Block: Decodable { | |
| 124 | + var type: String? | |
| 125 | + var text: String? | |
| 126 | + var thinking: String? | |
| 127 | + } | |
| 128 | + | |
| 129 | + enum CodingKeys: String, CodingKey { | |
| 130 | + case content, usage | |
| 131 | + case stopReason = "stop_reason" | |
| 132 | + } | |
| 133 | + } | |
| 134 | + | |
| 135 | + private struct WireModelList: Decodable { | |
| 136 | + var data: [Entry] | |
| 137 | + struct Entry: Decodable { var id: String } | |
| 138 | + } | |
| 139 | + | |
| 140 | + // MARK: - Request construction | |
| 141 | + | |
| 142 | + private func urlRequest(path: String, apiKey: String, method: String = "POST") throws -> URLRequest { | |
| 143 | + guard let base = providerID.defaultBaseURL else { | |
| 144 | + throw ProviderError.invalidResponse(providerID, detail: "no base URL") | |
| 145 | + } | |
| 146 | + var request = URLRequest(url: base.appendingPathComponent(path)) | |
| 147 | + request.httpMethod = method | |
| 148 | + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") | |
| 149 | + request.setValue(Self.apiVersion, forHTTPHeaderField: "anthropic-version") | |
| 150 | + if method == "POST" { | |
| 151 | + request.setValue("application/json", forHTTPHeaderField: "Content-Type") | |
| 152 | + } | |
| 153 | + return request | |
| 154 | + } | |
| 155 | + | |
| 156 | + private func buildBody(_ request: ChatRequest) throws -> Data { | |
| 157 | + var messages: [WireMessage] = [] | |
| 158 | + for message in request.messages where message.role != .system { | |
| 159 | + messages.append(wireMessage(from: message, vision: request.model.capabilities.vision)) | |
| 160 | + } | |
| 161 | + let params = request.parameters | |
| 162 | + var wire = WireRequest( | |
| 163 | + model: request.model.id, | |
| 164 | + maxTokens: params.maxTokens ?? Self.defaultMaxTokens, | |
| 165 | + messages: messages | |
| 166 | + ) | |
| 167 | + if let system = request.systemPrompt, !system.isEmpty { | |
| 168 | + wire.system = system | |
| 169 | + } | |
| 170 | + if request.stream { wire.stream = true } | |
| 171 | + // Claude 4.7+ removed temperature/top_p; ParameterSupport encodes that per model. | |
| 172 | + let support = request.model.parameterSupport | |
| 173 | + if support.temperature { wire.temperature = params.temperature } | |
| 174 | + if support.topP { wire.topP = params.topP } | |
| 175 | + if support.thinkingToggle, let enabled = params.thinkingEnabled { | |
| 176 | + wire.thinking = enabled | |
| 177 | + ? Thinking(type: "enabled", budgetTokens: 8000) | |
| 178 | + : Thinking(type: "disabled") | |
| 179 | + } | |
| 180 | + return try JSONEncoder().encode(wire) | |
| 181 | + } | |
| 182 | + | |
| 183 | + private func wireMessage(from message: Message, vision: Bool) -> WireMessage { | |
| 184 | + let role = message.role == .assistant ? "assistant" : "user" | |
| 185 | + var text = message.text | |
| 186 | + for attachment in message.attachments where attachment.kind == .textFile { | |
| 187 | + let contents = String(data: attachment.data, encoding: .utf8) ?? "" | |
| 188 | + text += "\n\n```\(attachment.fileName)\n\(contents)\n```" | |
| 189 | + } | |
| 190 | + var blocks: [WireBlock] = [] | |
| 191 | + if vision, message.role == .user { | |
| 192 | + for image in message.attachments where image.kind == .image { | |
| 193 | + blocks.append(.image(mediaType: image.mimeType, base64: image.data.base64EncodedString())) | |
| 194 | + } | |
| 195 | + } | |
| 196 | + blocks.append(.text(text.isEmpty ? " " : text)) | |
| 197 | + return WireMessage(role: role, content: blocks) | |
| 198 | + } | |
| 199 | + | |
| 200 | + // MARK: - ProviderClient | |
| 201 | + | |
| 202 | + func streamChat(_ request: ChatRequest, apiKey: String) -> AsyncThrowingStream<ChatEvent, Error> { | |
| 203 | + AsyncThrowingStream { continuation in | |
| 204 | + let task = Task { | |
| 205 | + do { | |
| 206 | + var urlReq = try urlRequest(path: "messages", apiKey: apiKey) | |
| 207 | + var streamRequest = request | |
| 208 | + streamRequest.stream = true | |
| 209 | + urlReq.httpBody = try buildBody(streamRequest) | |
| 210 | + | |
| 211 | + var usage = TokenUsage() | |
| 212 | + var stopReason: String? | |
| 213 | + let decoder = JSONDecoder() | |
| 214 | + | |
| 215 | + for try await sse in StreamingService.sseEvents(for: urlReq, provider: providerID) { | |
| 216 | + guard let data = sse.data.data(using: .utf8), | |
| 217 | + let event = try? decoder.decode(StreamEvent.self, from: data) else { | |
| 218 | + continue | |
| 219 | + } | |
| 220 | + let type = sse.event ?? event.type ?? "" | |
| 221 | + switch type { | |
| 222 | + case "message_start": | |
| 223 | + if let u = event.message?.usage { | |
| 224 | + usage.inputTokens = u.inputTokens ?? 0 | |
| 225 | + } | |
| 226 | + case "content_block_delta": | |
| 227 | + if let text = event.delta?.text, !text.isEmpty { | |
| 228 | + continuation.yield(.textDelta(text)) | |
| 229 | + } | |
| 230 | + if let thinking = event.delta?.thinking, !thinking.isEmpty { | |
| 231 | + continuation.yield(.reasoningDelta(thinking)) | |
| 232 | + } | |
| 233 | + case "message_delta": | |
| 234 | + if let u = event.usage { | |
| 235 | + usage.outputTokens = u.outputTokens ?? usage.outputTokens | |
| 236 | + } | |
| 237 | + if let reason = event.delta?.stopReason { | |
| 238 | + stopReason = reason | |
| 239 | + } | |
| 240 | + case "error": | |
| 241 | + throw ProviderError.serverError( | |
| 242 | + providerID, status: 200, message: event.error?.message | |
| 243 | + ) | |
| 244 | + case "message_stop": | |
| 245 | + break | |
| 246 | + default: | |
| 247 | + break // ping, content_block_start/stop, unknown future events | |
| 248 | + } | |
| 249 | + } | |
| 250 | + continuation.yield(.usage(usage)) | |
| 251 | + continuation.yield(.finished(reason: stopReason)) | |
| 252 | + continuation.finish() | |
| 253 | + } catch { | |
| 254 | + continuation.finish(throwing: error) | |
| 255 | + } | |
| 256 | + } | |
| 257 | + continuation.onTermination = { _ in task.cancel() } | |
| 258 | + } | |
| 259 | + } | |
| 260 | + | |
| 261 | + func complete(_ request: ChatRequest, apiKey: String) async throws -> Message { | |
| 262 | + var urlReq = try urlRequest(path: "messages", apiKey: apiKey) | |
| 263 | + var plainRequest = request | |
| 264 | + plainRequest.stream = false | |
| 265 | + urlReq.httpBody = try buildBody(plainRequest) | |
| 266 | + let data = try await StreamingService.postJSON(urlReq, provider: providerID) | |
| 267 | + guard let response = try? JSONDecoder().decode(WireResponse.self, from: data) else { | |
| 268 | + throw ProviderError.invalidResponse(providerID, detail: "undecodable messages response") | |
| 269 | + } | |
| 270 | + let text = (response.content ?? []).compactMap { $0.type == "text" ? $0.text : nil }.joined() | |
| 271 | + let thinking = (response.content ?? []).compactMap { $0.type == "thinking" ? $0.thinking : nil }.joined() | |
| 272 | + var message = Message( | |
| 273 | + role: .assistant, | |
| 274 | + text: text, | |
| 275 | + reasoning: thinking.isEmpty ? nil : thinking, | |
| 276 | + modelID: request.model.id, | |
| 277 | + provider: providerID | |
| 278 | + ) | |
| 279 | + if let u = response.usage { | |
| 280 | + let usage = TokenUsage(inputTokens: u.inputTokens ?? 0, outputTokens: u.outputTokens ?? 0) | |
| 281 | + message.usage = usage | |
| 282 | + message.estimatedCost = request.model.pricing?.cost( | |
| 283 | + inputTokens: usage.inputTokens, outputTokens: usage.outputTokens | |
| 284 | + ) | |
| 285 | + } | |
| 286 | + return message | |
| 287 | + } | |
| 288 | + | |
| 289 | + func listModelIDs(apiKey: String) async throws -> [String] { | |
| 290 | + var urlReq = try urlRequest(path: "models", apiKey: apiKey, method: "GET") | |
| 291 | + urlReq.url = urlReq.url.flatMap { | |
| 292 | + URL(string: $0.absoluteString + "?limit=100") | |
| 293 | + } | |
| 294 | + let data = try await StreamingService.getJSON(urlReq, provider: providerID) | |
| 295 | + guard let list = try? JSONDecoder().decode(WireModelList.self, from: data) else { | |
| 296 | + throw ProviderError.invalidResponse(providerID, detail: "unrecognized /models response shape") | |
| 297 | + } | |
| 298 | + return list.data.map(\.id) | |
| 299 | + } | |
| 300 | +} | |
added
Sources/ZyquoCloud/Providers/OpenAICompatibleClient.swift
+383 −0
@@ -0,0 +1,383 @@ | ||
| 1 | +// | |
| 2 | +// OpenAICompatibleClient.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// One client for every provider speaking the OpenAI /chat/completions schema: | |
| 9 | +// OpenAI, xAI, Mistral, Gemini (compat endpoint), Qwen/DashScope, DeepSeek, | |
| 10 | +// Kimi, Perplexity, Together, DeepInfra, Cerebras, and custom endpoints. | |
| 11 | +// All provider quirks live HERE — nothing leaks into ViewModels or Views. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import Foundation | |
| 15 | + | |
| 16 | +struct OpenAICompatibleClient: ProviderClient { | |
| 17 | + let providerID: ProviderID | |
| 18 | + /// Custom endpoints override the provider's default base URL. | |
| 19 | + var baseURLOverride: URL? | |
| 20 | + | |
| 21 | + init(provider: ProviderID, baseURLOverride: URL? = nil) { | |
| 22 | + self.providerID = provider | |
| 23 | + self.baseURLOverride = baseURLOverride | |
| 24 | + } | |
| 25 | + | |
| 26 | + // MARK: - Wire types (requests) | |
| 27 | + | |
| 28 | + private struct WireRequest: Encodable { | |
| 29 | + var model: String | |
| 30 | + var messages: [WireMessage] | |
| 31 | + var stream: Bool? | |
| 32 | + var streamOptions: StreamOptions? | |
| 33 | + var temperature: Double? | |
| 34 | + var topP: Double? | |
| 35 | + var maxTokens: Int? | |
| 36 | + var maxCompletionTokens: Int? | |
| 37 | + var frequencyPenalty: Double? | |
| 38 | + var presencePenalty: Double? | |
| 39 | + var reasoningEffort: String? | |
| 40 | + var enableThinking: Bool? | |
| 41 | + | |
| 42 | + enum CodingKeys: String, CodingKey { | |
| 43 | + case model, messages, stream, temperature | |
| 44 | + case streamOptions = "stream_options" | |
| 45 | + case topP = "top_p" | |
| 46 | + case maxTokens = "max_tokens" | |
| 47 | + case maxCompletionTokens = "max_completion_tokens" | |
| 48 | + case frequencyPenalty = "frequency_penalty" | |
| 49 | + case presencePenalty = "presence_penalty" | |
| 50 | + case reasoningEffort = "reasoning_effort" | |
| 51 | + case enableThinking = "enable_thinking" | |
| 52 | + } | |
| 53 | + } | |
| 54 | + | |
| 55 | + private struct StreamOptions: Encodable { | |
| 56 | + var includeUsage: Bool | |
| 57 | + enum CodingKeys: String, CodingKey { case includeUsage = "include_usage" } | |
| 58 | + } | |
| 59 | + | |
| 60 | + private struct WireMessage: Encodable { | |
| 61 | + var role: String | |
| 62 | + var content: WireContent | |
| 63 | + } | |
| 64 | + | |
| 65 | + /// Message content: plain string, or an array of text/image parts for vision. | |
| 66 | + private enum WireContent: Encodable { | |
| 67 | + case text(String) | |
| 68 | + case parts([WirePart]) | |
| 69 | + | |
| 70 | + func encode(to encoder: Encoder) throws { | |
| 71 | + var container = encoder.singleValueContainer() | |
| 72 | + switch self { | |
| 73 | + case .text(let s): try container.encode(s) | |
| 74 | + case .parts(let p): try container.encode(p) | |
| 75 | + } | |
| 76 | + } | |
| 77 | + } | |
| 78 | + | |
| 79 | + private enum WirePart: Encodable { | |
| 80 | + case text(String) | |
| 81 | + case imageURL(String) | |
| 82 | + | |
| 83 | + func encode(to encoder: Encoder) throws { | |
| 84 | + var container = encoder.container(keyedBy: DynamicKey.self) | |
| 85 | + switch self { | |
| 86 | + case .text(let s): | |
| 87 | + try container.encode("text", forKey: DynamicKey("type")) | |
| 88 | + try container.encode(s, forKey: DynamicKey("text")) | |
| 89 | + case .imageURL(let url): | |
| 90 | + try container.encode("image_url", forKey: DynamicKey("type")) | |
| 91 | + var nested = container.nestedContainer(keyedBy: DynamicKey.self, forKey: DynamicKey("image_url")) | |
| 92 | + try nested.encode(url, forKey: DynamicKey("url")) | |
| 93 | + } | |
| 94 | + } | |
| 95 | + } | |
| 96 | + | |
| 97 | + private struct DynamicKey: CodingKey { | |
| 98 | + var stringValue: String | |
| 99 | + var intValue: Int? { nil } | |
| 100 | + init(_ s: String) { stringValue = s } | |
| 101 | + init?(stringValue: String) { self.stringValue = stringValue } | |
| 102 | + init?(intValue: Int) { nil } | |
| 103 | + } | |
| 104 | + | |
| 105 | + // MARK: - Wire types (responses) | |
| 106 | + | |
| 107 | + private struct WireChunk: Decodable { | |
| 108 | + var choices: [WireChoice]? | |
| 109 | + var usage: WireUsage? | |
| 110 | + var citations: [String]? | |
| 111 | + var searchResults: [WireSearchResult]? | |
| 112 | + | |
| 113 | + enum CodingKeys: String, CodingKey { | |
| 114 | + case choices, usage, citations | |
| 115 | + case searchResults = "search_results" | |
| 116 | + } | |
| 117 | + } | |
| 118 | + | |
| 119 | + private struct WireChoice: Decodable { | |
| 120 | + var delta: WireDelta? | |
| 121 | + var message: WireDelta? | |
| 122 | + var finishReason: String? | |
| 123 | + | |
| 124 | + enum CodingKeys: String, CodingKey { | |
| 125 | + case delta, message | |
| 126 | + case finishReason = "finish_reason" | |
| 127 | + } | |
| 128 | + } | |
| 129 | + | |
| 130 | + private struct WireDelta: Decodable { | |
| 131 | + var content: String? | |
| 132 | + var reasoningContent: String? | |
| 133 | + var reasoning: String? | |
| 134 | + | |
| 135 | + enum CodingKeys: String, CodingKey { | |
| 136 | + case content, reasoning | |
| 137 | + case reasoningContent = "reasoning_content" | |
| 138 | + } | |
| 139 | + } | |
| 140 | + | |
| 141 | + private struct WireUsage: Decodable { | |
| 142 | + var promptTokens: Int? | |
| 143 | + var completionTokens: Int? | |
| 144 | + var completionTokensDetails: Details? | |
| 145 | + | |
| 146 | + struct Details: Decodable { | |
| 147 | + var reasoningTokens: Int? | |
| 148 | + enum CodingKeys: String, CodingKey { case reasoningTokens = "reasoning_tokens" } | |
| 149 | + } | |
| 150 | + | |
| 151 | + enum CodingKeys: String, CodingKey { | |
| 152 | + case promptTokens = "prompt_tokens" | |
| 153 | + case completionTokens = "completion_tokens" | |
| 154 | + case completionTokensDetails = "completion_tokens_details" | |
| 155 | + } | |
| 156 | + | |
| 157 | + var usage: TokenUsage { | |
| 158 | + TokenUsage( | |
| 159 | + inputTokens: promptTokens ?? 0, | |
| 160 | + outputTokens: completionTokens ?? 0, | |
| 161 | + reasoningTokens: completionTokensDetails?.reasoningTokens | |
| 162 | + ) | |
| 163 | + } | |
| 164 | + } | |
| 165 | + | |
| 166 | + private struct WireSearchResult: Decodable { | |
| 167 | + var title: String? | |
| 168 | + var url: String? | |
| 169 | + } | |
| 170 | + | |
| 171 | + private struct WireModelList: Decodable { | |
| 172 | + var data: [WireModelEntry] | |
| 173 | + } | |
| 174 | + | |
| 175 | + private struct WireModelEntry: Decodable { | |
| 176 | + var id: String | |
| 177 | + } | |
| 178 | + | |
| 179 | + // MARK: - Request construction | |
| 180 | + | |
| 181 | + private var baseURL: URL? { baseURLOverride ?? providerID.defaultBaseURL } | |
| 182 | + | |
| 183 | + private func urlRequest(path: String, apiKey: String, method: String = "POST") throws -> URLRequest { | |
| 184 | + guard let base = baseURL else { | |
| 185 | + throw ProviderError.invalidResponse(providerID, detail: "no base URL configured") | |
| 186 | + } | |
| 187 | + // Preserve base path components ("…/v1", "…/compatible-mode/v1", "…/v1beta/openai"). | |
| 188 | + var request = URLRequest(url: base.appendingPathComponent(path)) | |
| 189 | + request.httpMethod = method | |
| 190 | + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") | |
| 191 | + if method == "POST" { | |
| 192 | + request.setValue("application/json", forHTTPHeaderField: "Content-Type") | |
| 193 | + } | |
| 194 | + return request | |
| 195 | + } | |
| 196 | + | |
| 197 | + /// Providers whose final streamed chunk carries usage only when asked. | |
| 198 | + private var wantsStreamOptions: Bool { | |
| 199 | + switch providerID { | |
| 200 | + case .openai, .xai, .gemini, .deepseek, .kimi, .together, .cerebras, .custom: | |
| 201 | + return true | |
| 202 | + // Qwen, DeepInfra, Perplexity include usage automatically; Mistral | |
| 203 | + // rejects unknown params less gracefully — omit there. | |
| 204 | + case .mistral, .qwen, .deepinfra, .perplexity: | |
| 205 | + return false | |
| 206 | + case .anthropic: | |
| 207 | + return false // never routed here | |
| 208 | + } | |
| 209 | + } | |
| 210 | + | |
| 211 | + private func buildBody(_ request: ChatRequest) throws -> Data { | |
| 212 | + var messages: [WireMessage] = [] | |
| 213 | + if let system = request.systemPrompt, !system.isEmpty { | |
| 214 | + messages.append(WireMessage(role: "system", content: .text(system))) | |
| 215 | + } | |
| 216 | + for message in request.messages where message.role != .system { | |
| 217 | + messages.append(wireMessage(from: message, vision: request.model.capabilities.vision)) | |
| 218 | + } | |
| 219 | + | |
| 220 | + let support = request.model.parameterSupport | |
| 221 | + let params = request.parameters | |
| 222 | + var wire = WireRequest(model: request.model.id, messages: messages) | |
| 223 | + if request.stream { | |
| 224 | + wire.stream = true | |
| 225 | + if wantsStreamOptions { | |
| 226 | + wire.streamOptions = StreamOptions(includeUsage: true) | |
| 227 | + } | |
| 228 | + } | |
| 229 | + if support.temperature { wire.temperature = params.temperature } | |
| 230 | + if support.topP { wire.topP = params.topP } | |
| 231 | + if let max = params.maxTokens { | |
| 232 | + if support.usesMaxCompletionTokens { | |
| 233 | + wire.maxCompletionTokens = max | |
| 234 | + } else { | |
| 235 | + wire.maxTokens = max | |
| 236 | + } | |
| 237 | + } | |
| 238 | + if support.frequencyPenalty { wire.frequencyPenalty = params.frequencyPenalty } | |
| 239 | + if support.presencePenalty { wire.presencePenalty = params.presencePenalty } | |
| 240 | + if support.reasoningEffort { wire.reasoningEffort = params.reasoningEffort } | |
| 241 | + if support.thinkingToggle, providerID == .qwen { | |
| 242 | + // DashScope: enable_thinking is only legal on streaming requests. | |
| 243 | + if request.stream { wire.enableThinking = params.thinkingEnabled } | |
| 244 | + } | |
| 245 | + let encoder = JSONEncoder() | |
| 246 | + return try encoder.encode(wire) | |
| 247 | + } | |
| 248 | + | |
| 249 | + private func wireMessage(from message: Message, vision: Bool) -> WireMessage { | |
| 250 | + let role = message.role == .assistant ? "assistant" : "user" | |
| 251 | + var text = message.text | |
| 252 | + // Text-file attachments are injected inline, fenced with the file name. | |
| 253 | + for attachment in message.attachments where attachment.kind == .textFile { | |
| 254 | + let contents = String(data: attachment.data, encoding: .utf8) ?? "" | |
| 255 | + text += "\n\n```\(attachment.fileName)\n\(contents)\n```" | |
| 256 | + } | |
| 257 | + let images = message.attachments.filter { $0.kind == .image } | |
| 258 | + guard vision, !images.isEmpty, message.role == .user else { | |
| 259 | + return WireMessage(role: role, content: .text(text)) | |
| 260 | + } | |
| 261 | + var parts: [WirePart] = [.text(text)] | |
| 262 | + for image in images { | |
| 263 | + let dataURI = "data:\(image.mimeType);base64,\(image.data.base64EncodedString())" | |
| 264 | + parts.append(.imageURL(dataURI)) | |
| 265 | + } | |
| 266 | + return WireMessage(role: role, content: .parts(parts)) | |
| 267 | + } | |
| 268 | + | |
| 269 | + // MARK: - ProviderClient | |
| 270 | + | |
| 271 | + func streamChat(_ request: ChatRequest, apiKey: String) -> AsyncThrowingStream<ChatEvent, Error> { | |
| 272 | + AsyncThrowingStream { continuation in | |
| 273 | + let task = Task { | |
| 274 | + do { | |
| 275 | + var urlReq = try urlRequest(path: "chat/completions", apiKey: apiKey) | |
| 276 | + var streamRequest = request | |
| 277 | + streamRequest.stream = true | |
| 278 | + urlReq.httpBody = try buildBody(streamRequest) | |
| 279 | + | |
| 280 | + var citationsSent = false | |
| 281 | + var finishReason: String? | |
| 282 | + let decoder = JSONDecoder() | |
| 283 | + | |
| 284 | + for try await event in StreamingService.sseEvents(for: urlReq, provider: providerID) { | |
| 285 | + if event.data == "[DONE]" { break } | |
| 286 | + guard let data = event.data.data(using: .utf8), | |
| 287 | + let chunk = try? decoder.decode(WireChunk.self, from: data) else { | |
| 288 | + continue // tolerate unknown/malformed keep-alive chunks | |
| 289 | + } | |
| 290 | + if let choice = chunk.choices?.first { | |
| 291 | + if let reasoning = choice.delta?.reasoningContent ?? choice.delta?.reasoning, | |
| 292 | + !reasoning.isEmpty { | |
| 293 | + continuation.yield(.reasoningDelta(reasoning)) | |
| 294 | + } | |
| 295 | + if let text = choice.delta?.content, !text.isEmpty { | |
| 296 | + continuation.yield(.textDelta(text)) | |
| 297 | + } | |
| 298 | + if let reason = choice.finishReason { | |
| 299 | + finishReason = reason | |
| 300 | + } | |
| 301 | + } | |
| 302 | + if !citationsSent, let citations = Self.citations(from: chunk), !citations.isEmpty { | |
| 303 | + citationsSent = true | |
| 304 | + continuation.yield(.citations(citations)) | |
| 305 | + } | |
| 306 | + if let usage = chunk.usage { | |
| 307 | + continuation.yield(.usage(usage.usage)) | |
| 308 | + } | |
| 309 | + } | |
| 310 | + continuation.yield(.finished(reason: finishReason)) | |
| 311 | + continuation.finish() | |
| 312 | + } catch { | |
| 313 | + continuation.finish(throwing: error) | |
| 314 | + } | |
| 315 | + } | |
| 316 | + continuation.onTermination = { _ in task.cancel() } | |
| 317 | + } | |
| 318 | + } | |
| 319 | + | |
| 320 | + func complete(_ request: ChatRequest, apiKey: String) async throws -> Message { | |
| 321 | + var urlReq = try urlRequest(path: "chat/completions", apiKey: apiKey) | |
| 322 | + var plainRequest = request | |
| 323 | + plainRequest.stream = false | |
| 324 | + urlReq.httpBody = try buildBody(plainRequest) | |
| 325 | + let data = try await StreamingService.postJSON(urlReq, provider: providerID) | |
| 326 | + let chunk = try decodeOrThrow(WireChunk.self, from: data) | |
| 327 | + guard let choice = chunk.choices?.first, let content = choice.message ?? choice.delta else { | |
| 328 | + throw ProviderError.invalidResponse(providerID, detail: "response contained no message") | |
| 329 | + } | |
| 330 | + var message = Message( | |
| 331 | + role: .assistant, | |
| 332 | + text: content.content ?? "", | |
| 333 | + reasoning: content.reasoningContent ?? content.reasoning, | |
| 334 | + modelID: request.model.id, | |
| 335 | + provider: providerID | |
| 336 | + ) | |
| 337 | + if let citations = Self.citations(from: chunk) { | |
| 338 | + message.citations = citations | |
| 339 | + } | |
| 340 | + if let usage = chunk.usage?.usage { | |
| 341 | + message.usage = usage | |
| 342 | + message.estimatedCost = request.model.pricing?.cost( | |
| 343 | + inputTokens: usage.inputTokens, outputTokens: usage.outputTokens | |
| 344 | + ) | |
| 345 | + } | |
| 346 | + return message | |
| 347 | + } | |
| 348 | + | |
| 349 | + func listModelIDs(apiKey: String) async throws -> [String] { | |
| 350 | + let urlReq = try urlRequest(path: "models", apiKey: apiKey, method: "GET") | |
| 351 | + let data = try await StreamingService.getJSON(urlReq, provider: providerID) | |
| 352 | + // Together returns a bare array; everyone else wraps in {"data": […]}. | |
| 353 | + if let list = try? JSONDecoder().decode(WireModelList.self, from: data) { | |
| 354 | + return list.data.map(\.id) | |
| 355 | + } | |
| 356 | + if let bare = try? JSONDecoder().decode([WireModelEntry].self, from: data) { | |
| 357 | + return bare.map(\.id) | |
| 358 | + } | |
| 359 | + throw ProviderError.invalidResponse(providerID, detail: "unrecognized /models response shape") | |
| 360 | + } | |
| 361 | + | |
| 362 | + // MARK: - Helpers | |
| 363 | + | |
| 364 | + private func decodeOrThrow<T: Decodable>(_ type: T.Type, from data: Data) throws -> T { | |
| 365 | + do { | |
| 366 | + return try JSONDecoder().decode(type, from: data) | |
| 367 | + } catch { | |
| 368 | + throw ProviderError.invalidResponse(providerID, detail: "decode failed: \(error.localizedDescription)") | |
| 369 | + } | |
| 370 | + } | |
| 371 | + | |
| 372 | + /// Perplexity: `citations` is an array of URL strings; `search_results` | |
| 373 | + /// adds titles. Merge both into numbered citations. | |
| 374 | + private static func citations(from chunk: WireChunk) -> [Citation]? { | |
| 375 | + guard let urls = chunk.citations, !urls.isEmpty else { return nil } | |
| 376 | + let titles = chunk.searchResults ?? [] | |
| 377 | + return urls.enumerated().compactMap { index, urlString in | |
| 378 | + guard let url = URL(string: urlString) else { return nil } | |
| 379 | + let title = index < titles.count ? titles[index].title : nil | |
| 380 | + return Citation(index: index + 1, url: url, title: title) | |
| 381 | + } | |
| 382 | + } | |
| 383 | +} | |
added
Sources/ZyquoCloud/Providers/ProviderProtocol.swift
+146 −0
@@ -0,0 +1,146 @@ | ||
| 1 | +// | |
| 2 | +// ProviderProtocol.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// A provider-agnostic chat request. Clients translate this into their wire format; | |
| 12 | +/// provider behavior differences never leak above this layer. | |
| 13 | +struct ChatRequest { | |
| 14 | + var model: AIModel | |
| 15 | + var systemPrompt: String? | |
| 16 | + var messages: [Message] | |
| 17 | + var parameters: ChatParameters | |
| 18 | + var stream: Bool = true | |
| 19 | +} | |
| 20 | + | |
| 21 | +/// Incremental events surfaced while a response streams. | |
| 22 | +enum ChatEvent { | |
| 23 | + case reasoningDelta(String) | |
| 24 | + case textDelta(String) | |
| 25 | + case citations([Citation]) | |
| 26 | + case usage(TokenUsage) | |
| 27 | + case finished(reason: String?) | |
| 28 | +} | |
| 29 | + | |
| 30 | +/// One cloud AI provider client. | |
| 31 | +protocol ProviderClient { | |
| 32 | + var providerID: ProviderID { get } | |
| 33 | + | |
| 34 | + /// Streams a chat completion. The stream finishes after `.finished` or throws a `ProviderError`. | |
| 35 | + func streamChat(_ request: ChatRequest, apiKey: String) -> AsyncThrowingStream<ChatEvent, Error> | |
| 36 | + | |
| 37 | + /// Non-streaming completion (used for title generation and the verify harness). | |
| 38 | + func complete(_ request: ChatRequest, apiKey: String) async throws -> Message | |
| 39 | + | |
| 40 | + /// Model IDs currently served by the provider, for dynamic catalog refresh. | |
| 41 | + func listModelIDs(apiKey: String) async throws -> [String] | |
| 42 | +} | |
| 43 | + | |
| 44 | +extension ProviderClient { | |
| 45 | + /// Key validation: performs the cheapest authenticated call available and | |
| 46 | + /// returns the round-trip latency. `fallbackModel` is used for providers | |
| 47 | + /// without a /models endpoint (Perplexity) — pass the provider's cheapest | |
| 48 | + /// catalog model. | |
| 49 | + func testKey(_ apiKey: String, fallbackModel: AIModel?) async throws -> TimeInterval { | |
| 50 | + let start = Date() | |
| 51 | + if providerID.supportsModelListing { | |
| 52 | + _ = try await listModelIDs(apiKey: apiKey) | |
| 53 | + } else { | |
| 54 | + guard let model = fallbackModel else { | |
| 55 | + throw ProviderError.noModelAvailable(providerID) | |
| 56 | + } | |
| 57 | + var request = ChatRequest( | |
| 58 | + model: model, | |
| 59 | + systemPrompt: nil, | |
| 60 | + messages: [Message(role: .user, text: "Reply with exactly: OK")], | |
| 61 | + parameters: ChatParameters(maxTokens: 16), | |
| 62 | + stream: false | |
| 63 | + ) | |
| 64 | + request.parameters.temperature = nil | |
| 65 | + _ = try await complete(request, apiKey: apiKey) | |
| 66 | + } | |
| 67 | + return Date().timeIntervalSince(start) | |
| 68 | + } | |
| 69 | +} | |
| 70 | + | |
| 71 | +/// Errors mapped to clear, human-readable messages ("Invalid API key for Mistral", | |
| 72 | +/// "Rate limited — retrying in 20s"). | |
| 73 | +enum ProviderError: LocalizedError { | |
| 74 | + case invalidAPIKey(ProviderID) | |
| 75 | + case rateLimited(ProviderID, retryAfter: TimeInterval?) | |
| 76 | + case serverError(ProviderID, status: Int, message: String?) | |
| 77 | + case badRequest(ProviderID, message: String?) | |
| 78 | + case networkError(underlying: Error) | |
| 79 | + case invalidResponse(ProviderID, detail: String) | |
| 80 | + case missingAPIKey(ProviderID) | |
| 81 | + case noModelAvailable(ProviderID) | |
| 82 | + case cancelled | |
| 83 | + | |
| 84 | + var errorDescription: String? { | |
| 85 | + switch self { | |
| 86 | + case .invalidAPIKey(let p): | |
| 87 | + return "Invalid API key for \(p.displayName)." | |
| 88 | + case .rateLimited(let p, let retryAfter): | |
| 89 | + if let s = retryAfter { | |
| 90 | + return "\(p.displayName) rate limited — retry in \(Int(s.rounded()))s." | |
| 91 | + } | |
| 92 | + return "\(p.displayName) rate limited — please retry shortly." | |
| 93 | + case .serverError(let p, let status, let message): | |
| 94 | + return "\(p.displayName) server error (\(status))\(message.map { ": \($0)" } ?? "")." | |
| 95 | + case .badRequest(let p, let message): | |
| 96 | + return "\(p.displayName) rejected the request\(message.map { ": \($0)" } ?? "")." | |
| 97 | + case .networkError(let underlying): | |
| 98 | + return "Network error: \(underlying.localizedDescription)" | |
| 99 | + case .invalidResponse(let p, let detail): | |
| 100 | + return "Unexpected response from \(p.displayName): \(detail)" | |
| 101 | + case .missingAPIKey(let p): | |
| 102 | + return "No API key configured for \(p.displayName). Add one in Settings → Providers & Keys." | |
| 103 | + case .noModelAvailable(let p): | |
| 104 | + return "No model available for \(p.displayName)." | |
| 105 | + case .cancelled: | |
| 106 | + return "Generation stopped." | |
| 107 | + } | |
| 108 | + } | |
| 109 | + | |
| 110 | + /// Maps an HTTP status + provider error body to a typed error. | |
| 111 | + static func from(status: Int, body: Data, provider: ProviderID) -> ProviderError { | |
| 112 | + let message = Self.extractMessage(from: body) | |
| 113 | + switch status { | |
| 114 | + case 401, 403: | |
| 115 | + return .invalidAPIKey(provider) | |
| 116 | + case 429: | |
| 117 | + return .rateLimited(provider, retryAfter: nil) | |
| 118 | + case 400, 404, 422: | |
| 119 | + return .badRequest(provider, message: message) | |
| 120 | + default: | |
| 121 | + return .serverError(provider, status: status, message: message) | |
| 122 | + } | |
| 123 | + } | |
| 124 | + | |
| 125 | + /// Providers wrap errors differently ({"error":{"message":…}}, {"message":…}, | |
| 126 | + /// {"error":"…"}, Gemini arrays…). Try the common shapes. | |
| 127 | + private static func extractMessage(from body: Data) -> String? { | |
| 128 | + guard let obj = try? JSONSerialization.jsonObject(with: body) else { | |
| 129 | + return String(data: body.prefix(300), encoding: .utf8) | |
| 130 | + } | |
| 131 | + if let dict = obj as? [String: Any] { | |
| 132 | + if let err = dict["error"] as? [String: Any], let msg = err["message"] as? String { | |
| 133 | + return msg | |
| 134 | + } | |
| 135 | + if let msg = dict["error"] as? String { return msg } | |
| 136 | + if let msg = dict["message"] as? String { return msg } | |
| 137 | + if let msg = dict["detail"] as? String { return msg } | |
| 138 | + } | |
| 139 | + if let arr = obj as? [[String: Any]], | |
| 140 | + let err = arr.first?["error"] as? [String: Any], | |
| 141 | + let msg = err["message"] as? String { | |
| 142 | + return msg | |
| 143 | + } | |
| 144 | + return String(data: body.prefix(300), encoding: .utf8) | |
| 145 | + } | |
| 146 | +} | |
added
Sources/ZyquoCloud/Providers/ProviderRegistry.swift
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +// | |
| 2 | +// ProviderRegistry.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// Resolves the right client for a provider or custom model. The only place | |
| 12 | +/// that knows which wire format each provider speaks. | |
| 13 | +enum ProviderRegistry { | |
| 14 | + static func client(for model: AIModel) -> ProviderClient { | |
| 15 | + switch model.provider.wireFormat { | |
| 16 | + case .anthropicMessages: | |
| 17 | + return AnthropicClient() | |
| 18 | + case .openAIChatCompletions: | |
| 19 | + return OpenAICompatibleClient( | |
| 20 | + provider: model.provider, | |
| 21 | + baseURLOverride: model.customBaseURL | |
| 22 | + ) | |
| 23 | + } | |
| 24 | + } | |
| 25 | + | |
| 26 | + static func client(for provider: ProviderID) -> ProviderClient { | |
| 27 | + switch provider.wireFormat { | |
| 28 | + case .anthropicMessages: | |
| 29 | + return AnthropicClient() | |
| 30 | + case .openAIChatCompletions: | |
| 31 | + return OpenAICompatibleClient(provider: provider) | |
| 32 | + } | |
| 33 | + } | |
| 34 | +} | |
added
Sources/ZyquoCloud/Services/ModelCatalog.swift
+70 −0
@@ -0,0 +1,70 @@ | ||
| 1 | +// | |
| 2 | +// ModelCatalog.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Single source of truth for model data. Built-in entries are generated from | |
| 9 | +// docs/PROVIDERS.md (see ModelCatalogData.swift); dynamic /models refreshes and | |
| 10 | +// user-defined custom models layer on top. Views and clients never hardcode | |
| 11 | +// model IDs. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import Foundation | |
| 15 | + | |
| 16 | +@MainActor | |
| 17 | +final class ModelCatalog: ObservableObject { | |
| 18 | + /// Built-in catalog (generated from docs/PROVIDERS.md — keep in sync). | |
| 19 | + @Published private(set) var builtIn: [AIModel] = ModelCatalogData.all | |
| 20 | + /// User-defined custom models (custom ID + base URL). | |
| 21 | + @Published var customModels: [AIModel] = [] | |
| 22 | + /// Model IDs confirmed live by the last dynamic refresh, per provider. | |
| 23 | + @Published private(set) var liveModelIDs: [ProviderID: Set<String>] = [:] | |
| 24 | + /// Favorite model IDs, pinned at the top of pickers. | |
| 25 | + @Published var favoriteIDs: Set<String> = [] | |
| 26 | + | |
| 27 | + var all: [AIModel] { builtIn + customModels } | |
| 28 | + | |
| 29 | + func models(for provider: ProviderID) -> [AIModel] { | |
| 30 | + all.filter { $0.provider == provider } | |
| 31 | + .sorted { rank($0) < rank($1) } | |
| 32 | + } | |
| 33 | + | |
| 34 | + func model(id: String, provider: ProviderID) -> AIModel? { | |
| 35 | + all.first { $0.id == id && $0.provider == provider } | |
| 36 | + } | |
| 37 | + | |
| 38 | + /// Cheapest non-legacy chat model for a provider (used for key tests and | |
| 39 | + /// auto-title generation). | |
| 40 | + func cheapestModel(for provider: ProviderID) -> AIModel? { | |
| 41 | + models(for: provider) | |
| 42 | + .filter { !$0.isLegacy } | |
| 43 | + .min { ($0.pricing?.outputPerMTok ?? .infinity) < ($1.pricing?.outputPerMTok ?? .infinity) } | |
| 44 | + } | |
| 45 | + | |
| 46 | + /// Default model offered for new conversations. | |
| 47 | + var defaultModel: AIModel? { | |
| 48 | + all.first { $0.isRecommended } ?? all.first | |
| 49 | + } | |
| 50 | + | |
| 51 | + /// Merges a dynamic /models listing: known models are marked live; unknown | |
| 52 | + /// IDs are surfaced so the user can add them. | |
| 53 | + func applyLiveListing(_ ids: [String], for provider: ProviderID) { | |
| 54 | + liveModelIDs[provider] = Set(ids) | |
| 55 | + } | |
| 56 | + | |
| 57 | + /// IDs returned by the provider but absent from the built-in catalog. | |
| 58 | + func unknownLiveIDs(for provider: ProviderID) -> [String] { | |
| 59 | + guard let live = liveModelIDs[provider] else { return [] } | |
| 60 | + let known = Set(models(for: provider).map(\.id)) | |
| 61 | + return live.subtracting(known).sorted() | |
| 62 | + } | |
| 63 | + | |
| 64 | + private func rank(_ model: AIModel) -> Int { | |
| 65 | + if favoriteIDs.contains(model.id) { return 0 } | |
| 66 | + if model.isRecommended { return 1 } | |
| 67 | + if model.isLegacy { return 3 } | |
| 68 | + return 2 | |
| 69 | + } | |
| 70 | +} | |
added
Sources/ZyquoCloud/Services/ModelCatalogData.swift
+41 −0
@@ -0,0 +1,41 @@ | ||
| 1 | +// | |
| 2 | +// ModelCatalogData.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Built-in model catalog, generated from docs/PROVIDERS.md (2026-07-30). | |
| 9 | +// PLACEHOLDER seed — replaced by the full generated catalog before Phase 6. | |
| 10 | +// When research and reality disagree in Phase 7, update docs/PROVIDERS.md and | |
| 11 | +// this file together. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import Foundation | |
| 15 | + | |
| 16 | +enum ModelCatalogData { | |
| 17 | + static let all: [AIModel] = [ | |
| 18 | + AIModel( | |
| 19 | + id: "gpt-4o-mini", | |
| 20 | + provider: .openai, | |
| 21 | + displayName: "GPT-4o mini", | |
| 22 | + contextWindow: 128_000, | |
| 23 | + maxOutputTokens: 16_384, | |
| 24 | + capabilities: ModelCapabilities(vision: true, tools: true, jsonMode: true), | |
| 25 | + pricing: ModelPricing(inputPerMTok: 0.15, outputPerMTok: 0.60), | |
| 26 | + parameterSupport: .openAIDefault, | |
| 27 | + isRecommended: true | |
| 28 | + ), | |
| 29 | + AIModel( | |
| 30 | + id: "claude-sonnet-5", | |
| 31 | + provider: .anthropic, | |
| 32 | + displayName: "Claude Sonnet 5", | |
| 33 | + contextWindow: 1_000_000, | |
| 34 | + maxOutputTokens: 128_000, | |
| 35 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 36 | + pricing: ModelPricing(inputPerMTok: 2, outputPerMTok: 10), | |
| 37 | + parameterSupport: ParameterSupport(temperature: false, topP: false, thinkingToggle: true), | |
| 38 | + isRecommended: true | |
| 39 | + ), | |
| 40 | + ] | |
| 41 | +} | |
added
Sources/ZyquoCloud/Services/PersistenceService.swift
+74 −0
@@ -0,0 +1,74 @@ | ||
| 1 | +// | |
| 2 | +// PersistenceService.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// JSON persistence in ~/Library/Application Support/ZyquoCloud/: | |
| 9 | +// Conversations/<uuid>.json one file per conversation | |
| 10 | +// personas.json, templates.json, favorites.json, settings.json | |
| 11 | +// | |
| 12 | + | |
| 13 | +import Foundation | |
| 14 | + | |
| 15 | +struct PersistenceService { | |
| 16 | + static let shared = PersistenceService() | |
| 17 | + | |
| 18 | + let rootDirectory: URL | |
| 19 | + var conversationsDirectory: URL { rootDirectory.appendingPathComponent("Conversations") } | |
| 20 | + | |
| 21 | + private let encoder: JSONEncoder | |
| 22 | + private let decoder: JSONDecoder | |
| 23 | + | |
| 24 | + init(rootDirectory: URL? = nil) { | |
| 25 | + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] | |
| 26 | + self.rootDirectory = rootDirectory ?? base.appendingPathComponent("ZyquoCloud") | |
| 27 | + encoder = JSONEncoder() | |
| 28 | + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] | |
| 29 | + encoder.dateEncodingStrategy = .iso8601 | |
| 30 | + decoder = JSONDecoder() | |
| 31 | + decoder.dateDecodingStrategy = .iso8601 | |
| 32 | + try? FileManager.default.createDirectory(at: conversationsDirectory, withIntermediateDirectories: true) | |
| 33 | + } | |
| 34 | + | |
| 35 | + // MARK: - Conversations | |
| 36 | + | |
| 37 | + func loadConversations() -> [Conversation] { | |
| 38 | + guard let files = try? FileManager.default.contentsOfDirectory( | |
| 39 | + at: conversationsDirectory, includingPropertiesForKeys: nil | |
| 40 | + ) else { return [] } | |
| 41 | + return files | |
| 42 | + .filter { $0.pathExtension == "json" } | |
| 43 | + .compactMap { url in | |
| 44 | + guard let data = try? Data(contentsOf: url) else { return nil } | |
| 45 | + return try? decoder.decode(Conversation.self, from: data) | |
| 46 | + } | |
| 47 | + .sorted { $0.updatedAt > $1.updatedAt } | |
| 48 | + } | |
| 49 | + | |
| 50 | + func save(_ conversation: Conversation) { | |
| 51 | + let url = conversationsDirectory.appendingPathComponent("\(conversation.id.uuidString).json") | |
| 52 | + guard let data = try? encoder.encode(conversation) else { return } | |
| 53 | + try? data.write(to: url, options: .atomic) | |
| 54 | + } | |
| 55 | + | |
| 56 | + func delete(_ conversation: Conversation) { | |
| 57 | + let url = conversationsDirectory.appendingPathComponent("\(conversation.id.uuidString).json") | |
| 58 | + try? FileManager.default.removeItem(at: url) | |
| 59 | + } | |
| 60 | + | |
| 61 | + // MARK: - Generic documents (personas, templates, settings…) | |
| 62 | + | |
| 63 | + func load<T: Decodable>(_ type: T.Type, from fileName: String) -> T? { | |
| 64 | + let url = rootDirectory.appendingPathComponent(fileName) | |
| 65 | + guard let data = try? Data(contentsOf: url) else { return nil } | |
| 66 | + return try? decoder.decode(type, from: data) | |
| 67 | + } | |
| 68 | + | |
| 69 | + func save<T: Encodable>(_ value: T, to fileName: String) { | |
| 70 | + let url = rootDirectory.appendingPathComponent(fileName) | |
| 71 | + guard let data = try? encoder.encode(value) else { return } | |
| 72 | + try? data.write(to: url, options: .atomic) | |
| 73 | + } | |
| 74 | +} | |
added
Sources/ZyquoCloud/Services/StreamingService.swift
+133 −0
@@ -0,0 +1,133 @@ | ||
| 1 | +// | |
| 2 | +// StreamingService.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// One Server-Sent Event as parsed off the wire. | |
| 12 | +struct SSEEvent { | |
| 13 | + /// The `event:` field, if the stream names its events (Anthropic does). | |
| 14 | + var event: String? | |
| 15 | + /// Joined `data:` lines. | |
| 16 | + var data: String | |
| 17 | +} | |
| 18 | + | |
| 19 | +/// Incremental SSE parser. Feed it raw lines (or byte chunks split on newlines) | |
| 20 | +/// and it yields complete events at blank-line boundaries, ignoring `:` comment | |
| 21 | +/// lines (DeepSeek sends `: keep-alive`) and unknown fields. | |
| 22 | +struct SSEParser { | |
| 23 | + private var currentEvent: String? | |
| 24 | + private var currentData: [String] = [] | |
| 25 | + | |
| 26 | + /// Consumes one line (without its trailing newline). Returns a completed | |
| 27 | + /// event when the line is the blank separator, else nil. | |
| 28 | + mutating func consume(line: String) -> SSEEvent? { | |
| 29 | + if line.isEmpty { | |
| 30 | + guard !currentData.isEmpty || currentEvent != nil else { return nil } | |
| 31 | + let event = SSEEvent(event: currentEvent, data: currentData.joined(separator: "\n")) | |
| 32 | + currentEvent = nil | |
| 33 | + currentData = [] | |
| 34 | + return event.data.isEmpty && event.event == nil ? nil : event | |
| 35 | + } | |
| 36 | + if line.hasPrefix(":") { return nil } // comment / keep-alive | |
| 37 | + if line.hasPrefix("event:") { | |
| 38 | + currentEvent = String(line.dropFirst(6)).trimmingCharacters(in: .whitespaces) | |
| 39 | + } else if line.hasPrefix("data:") { | |
| 40 | + var value = String(line.dropFirst(5)) | |
| 41 | + if value.hasPrefix(" ") { value.removeFirst() } | |
| 42 | + currentData.append(value) | |
| 43 | + } | |
| 44 | + // id:/retry:/unknown fields are ignored. | |
| 45 | + return nil | |
| 46 | + } | |
| 47 | +} | |
| 48 | + | |
| 49 | +/// Shared networking for all provider clients: request construction helpers and | |
| 50 | +/// an SSE line stream over URLSession. | |
| 51 | +enum StreamingService { | |
| 52 | + /// URLSession tuned for long-lived streaming responses. | |
| 53 | + static let session: URLSession = { | |
| 54 | + let config = URLSessionConfiguration.default | |
| 55 | + config.timeoutIntervalForRequest = 120 | |
| 56 | + config.timeoutIntervalForResource = 900 | |
| 57 | + config.httpAdditionalHeaders = ["User-Agent": "ZyquoCloud/1.0 (macOS)"] | |
| 58 | + return URLSession(configuration: config) | |
| 59 | + }() | |
| 60 | + | |
| 61 | + /// POSTs `body` as JSON and returns the SSE events of the response. | |
| 62 | + /// Throws `ProviderError` on non-2xx status (reading the full error body). | |
| 63 | + static func sseEvents( | |
| 64 | + for request: URLRequest, | |
| 65 | + provider: ProviderID | |
| 66 | + ) -> AsyncThrowingStream<SSEEvent, Error> { | |
| 67 | + AsyncThrowingStream { continuation in | |
| 68 | + let task = Task { | |
| 69 | + do { | |
| 70 | + let (bytes, response) = try await session.bytes(for: request) | |
| 71 | + guard let http = response as? HTTPURLResponse else { | |
| 72 | + throw ProviderError.invalidResponse(provider, detail: "not an HTTP response") | |
| 73 | + } | |
| 74 | + guard (200..<300).contains(http.statusCode) else { | |
| 75 | + var body = Data() | |
| 76 | + for try await byte in bytes { body.append(byte) } | |
| 77 | + throw ProviderError.from(status: http.statusCode, body: body, provider: provider) | |
| 78 | + } | |
| 79 | + var parser = SSEParser() | |
| 80 | + for try await line in bytes.lines { | |
| 81 | + if Task.isCancelled { break } | |
| 82 | + if let event = parser.consume(line: line) { | |
| 83 | + continuation.yield(event) | |
| 84 | + } | |
| 85 | + } | |
| 86 | + // Flush a trailing event if the stream ended without a blank line. | |
| 87 | + if let event = parser.consume(line: "") { | |
| 88 | + continuation.yield(event) | |
| 89 | + } | |
| 90 | + continuation.finish() | |
| 91 | + } catch is CancellationError { | |
| 92 | + continuation.finish(throwing: ProviderError.cancelled) | |
| 93 | + } catch let error as ProviderError { | |
| 94 | + continuation.finish(throwing: error) | |
| 95 | + } catch { | |
| 96 | + continuation.finish(throwing: ProviderError.networkError(underlying: error)) | |
| 97 | + } | |
| 98 | + } | |
| 99 | + continuation.onTermination = { _ in task.cancel() } | |
| 100 | + } | |
| 101 | + } | |
| 102 | + | |
| 103 | + /// Non-streaming JSON POST. Returns the decoded response body data. | |
| 104 | + static func postJSON( | |
| 105 | + _ request: URLRequest, | |
| 106 | + provider: ProviderID | |
| 107 | + ) async throws -> Data { | |
| 108 | + do { | |
| 109 | + let (data, response) = try await session.data(for: request) | |
| 110 | + guard let http = response as? HTTPURLResponse else { | |
| 111 | + throw ProviderError.invalidResponse(provider, detail: "not an HTTP response") | |
| 112 | + } | |
| 113 | + guard (200..<300).contains(http.statusCode) else { | |
| 114 | + throw ProviderError.from(status: http.statusCode, body: data, provider: provider) | |
| 115 | + } | |
| 116 | + return data | |
| 117 | + } catch let error as ProviderError { | |
| 118 | + throw error | |
| 119 | + } catch is CancellationError { | |
| 120 | + throw ProviderError.cancelled | |
| 121 | + } catch { | |
| 122 | + throw ProviderError.networkError(underlying: error) | |
| 123 | + } | |
| 124 | + } | |
| 125 | + | |
| 126 | + /// GET returning decoded JSON data, with the same error mapping. | |
| 127 | + static func getJSON( | |
| 128 | + _ request: URLRequest, | |
| 129 | + provider: ProviderID | |
| 130 | + ) async throws -> Data { | |
| 131 | + try await postJSON(request, provider: provider) | |
| 132 | + } | |
| 133 | +} | |
added
Tests/ZyquoCloudTests/ModelTests.swift
+87 −0
@@ -0,0 +1,87 @@ | ||
| 1 | +// | |
| 2 | +// ModelTests.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import Testing | |
| 11 | +@testable import ZyquoCloud | |
| 12 | + | |
| 13 | +@Suite struct ModelTests { | |
| 14 | + @Test func conversationJSONRoundTrip() throws { | |
| 15 | + // ISO8601 persistence has whole-second precision; use round dates. | |
| 16 | + let date = Date(timeIntervalSince1970: 1_785_400_000) | |
| 17 | + var message = Message(role: .user, text: "Hello", createdAt: date) | |
| 18 | + message.attachments = [ | |
| 19 | + Attachment(kind: .image, fileName: "pic.png", data: Data([1, 2, 3]), mimeType: "image/png") | |
| 20 | + ] | |
| 21 | + var reply = Message(role: .assistant, text: "Hi!", reasoning: "thinking…", createdAt: date) | |
| 22 | + reply.usage = TokenUsage(inputTokens: 10, outputTokens: 5) | |
| 23 | + reply.citations = [Citation(index: 1, url: URL(string: "https://example.com")!, title: "Example")] | |
| 24 | + | |
| 25 | + let conversation = Conversation( | |
| 26 | + title: "Test", | |
| 27 | + messages: [message, reply], | |
| 28 | + modelID: "gpt-4o-mini", | |
| 29 | + provider: .openai, | |
| 30 | + systemPrompt: "Be brief.", | |
| 31 | + createdAt: date, | |
| 32 | + updatedAt: date | |
| 33 | + ) | |
| 34 | + | |
| 35 | + let encoder = JSONEncoder() | |
| 36 | + encoder.dateEncodingStrategy = .iso8601 | |
| 37 | + let decoder = JSONDecoder() | |
| 38 | + decoder.dateDecodingStrategy = .iso8601 | |
| 39 | + let data = try encoder.encode(conversation) | |
| 40 | + let decoded = try decoder.decode(Conversation.self, from: data) | |
| 41 | + | |
| 42 | + #expect(decoded == conversation) | |
| 43 | + #expect(decoded.totalUsage.totalTokens == 15) | |
| 44 | + } | |
| 45 | + | |
| 46 | + @Test func pricingCost() { | |
| 47 | + let pricing = ModelPricing(inputPerMTok: 2, outputPerMTok: 10) | |
| 48 | + #expect(abs(pricing.cost(inputTokens: 1_000_000, outputTokens: 500_000) - 7.0) < 0.0001) | |
| 49 | + } | |
| 50 | + | |
| 51 | + @Test func contextBadge() { | |
| 52 | + func model(_ ctx: Int) -> AIModel { | |
| 53 | + AIModel( | |
| 54 | + id: "m", provider: .openai, displayName: "M", contextWindow: ctx, | |
| 55 | + maxOutputTokens: nil, capabilities: ModelCapabilities(), | |
| 56 | + pricing: nil, parameterSupport: ParameterSupport() | |
| 57 | + ) | |
| 58 | + } | |
| 59 | + #expect(model(1_000_000).contextBadge == "1M ctx") | |
| 60 | + #expect(model(128_000).contextBadge == "128K ctx") | |
| 61 | + } | |
| 62 | + | |
| 63 | + @Test func providerErrorMapping() { | |
| 64 | + let unauthorized = ProviderError.from(status: 401, body: Data(), provider: .mistral) | |
| 65 | + guard case .invalidAPIKey(let provider) = unauthorized else { | |
| 66 | + Issue.record("expected invalidAPIKey") | |
| 67 | + return | |
| 68 | + } | |
| 69 | + #expect(provider == .mistral) | |
| 70 | + | |
| 71 | + let body = #"{"error":{"message":"model not found"}}"#.data(using: .utf8)! | |
| 72 | + let notFound = ProviderError.from(status: 404, body: body, provider: .openai) | |
| 73 | + guard case .badRequest(_, let message) = notFound else { | |
| 74 | + Issue.record("expected badRequest") | |
| 75 | + return | |
| 76 | + } | |
| 77 | + #expect(message == "model not found") | |
| 78 | + } | |
| 79 | + | |
| 80 | + @Test func everyBuiltInProviderHasBaseURLAndFormat() { | |
| 81 | + for provider in ProviderID.builtIn { | |
| 82 | + #expect(provider.defaultBaseURL != nil, "\(provider) missing base URL") | |
| 83 | + } | |
| 84 | + #expect(ProviderID.anthropic.wireFormat == .anthropicMessages) | |
| 85 | + #expect(ProviderID.builtIn.count == 12) | |
| 86 | + } | |
| 87 | +} | |
added
Tests/ZyquoCloudTests/SSEParserTests.swift
+85 −0
@@ -0,0 +1,85 @@ | ||
| 1 | +// | |
| 2 | +// SSEParserTests.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Testing | |
| 10 | +@testable import ZyquoCloud | |
| 11 | + | |
| 12 | +@Suite struct SSEParserTests { | |
| 13 | + private func parse(_ lines: [String]) -> [SSEEvent] { | |
| 14 | + var parser = SSEParser() | |
| 15 | + var events: [SSEEvent] = [] | |
| 16 | + for line in lines { | |
| 17 | + if let event = parser.consume(line: line) { events.append(event) } | |
| 18 | + } | |
| 19 | + if let last = parser.consume(line: "") { events.append(last) } | |
| 20 | + return events | |
| 21 | + } | |
| 22 | + | |
| 23 | + @Test func openAIStyleDataEvents() { | |
| 24 | + let events = parse([ | |
| 25 | + #"data: {"choices":[{"delta":{"content":"Hel"}}]}"#, | |
| 26 | + "", | |
| 27 | + #"data: {"choices":[{"delta":{"content":"lo"}}]}"#, | |
| 28 | + "", | |
| 29 | + "data: [DONE]", | |
| 30 | + "", | |
| 31 | + ]) | |
| 32 | + #expect(events.count == 3) | |
| 33 | + #expect(events[0].data.contains("Hel")) | |
| 34 | + #expect(events[2].data == "[DONE]") | |
| 35 | + #expect(events[0].event == nil) | |
| 36 | + } | |
| 37 | + | |
| 38 | + @Test func anthropicNamedEvents() { | |
| 39 | + let events = parse([ | |
| 40 | + "event: message_start", | |
| 41 | + #"data: {"type":"message_start","message":{"usage":{"input_tokens":9}}}"#, | |
| 42 | + "", | |
| 43 | + "event: content_block_delta", | |
| 44 | + #"data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"OK"}}"#, | |
| 45 | + "", | |
| 46 | + "event: message_stop", | |
| 47 | + #"data: {"type":"message_stop"}"#, | |
| 48 | + "", | |
| 49 | + ]) | |
| 50 | + #expect(events.count == 3) | |
| 51 | + #expect(events[0].event == "message_start") | |
| 52 | + #expect(events[1].event == "content_block_delta") | |
| 53 | + #expect(events[2].event == "message_stop") | |
| 54 | + } | |
| 55 | + | |
| 56 | + @Test func commentAndKeepAliveLinesIgnored() { | |
| 57 | + let events = parse([ | |
| 58 | + ": keep-alive", | |
| 59 | + "", | |
| 60 | + #"data: {"x":1}"#, | |
| 61 | + "", | |
| 62 | + ": another comment", | |
| 63 | + "", | |
| 64 | + ]) | |
| 65 | + #expect(events.count == 1) | |
| 66 | + #expect(events[0].data == #"{"x":1}"#) | |
| 67 | + } | |
| 68 | + | |
| 69 | + @Test func multiLineDataJoined() { | |
| 70 | + let events = parse([ | |
| 71 | + "data: line1", | |
| 72 | + "data: line2", | |
| 73 | + "", | |
| 74 | + ]) | |
| 75 | + #expect(events.count == 1) | |
| 76 | + #expect(events[0].data == "line1\nline2") | |
| 77 | + } | |
| 78 | + | |
| 79 | + @Test func trailingEventWithoutBlankLineFlushed() { | |
| 80 | + var parser = SSEParser() | |
| 81 | + #expect(parser.consume(line: "data: tail") == nil) | |
| 82 | + let flushed = parser.consume(line: "") | |
| 83 | + #expect(flushed?.data == "tail") | |
| 84 | + } | |
| 85 | +} | |
deleted
Tests/ZyquoCloudTests/ZyquoCloudTests.swift
+0 −15
@@ -1,15 +0,0 @@ | ||
| 1 | −// | |
| 2 | −// ZyquoCloudTests.swift | |
| 3 | −// Zyquo Cloud | |
| 4 | −// | |
| 5 | −// Author: Simon-Pierre Boucher | |
| 6 | −// Mail: contact@spboucher.ai | |
| 7 | −// | |
| 8 | − | |
| 9 | −import XCTest | |
| 10 | − | |
| 11 | −final class ZyquoCloudTests: XCTestCase { | |
| 12 | − func testPlaceholder() { | |
| 13 | − XCTAssertTrue(true) | |
| 14 | − } | |
| 15 | −} | |
modified
docs/PLAN.md
+21 −7
@@ -30,13 +30,27 @@ ad-hoc-signed `dist/Zyquo Cloud.app` with correct Info.plist (`com.zyquo.cloud`, | ||
| 30 | 30 | productivity category). Release/notarization targets stubbed to `scripts/notarize.sh` using the |
| 31 | 31 | verified zyquo-term identity + `MacLustr-Notarize` profile (implemented fully in Phase 8). |
| 32 | 32 | |
| 33 | −## Phase 2 — Architecture (in progress) | |
| 34 | − | |
| 35 | −- [ ] Models: `ProviderID`, `AIModel`, `ModelCapabilities`, `ModelPricing`, `ParameterSupport`, `Message`, `Conversation`, `Persona`, `ChatParameters`, `TokenUsage` | |
| 36 | −- [ ] Providers: `ProviderProtocol` (`ChatRequest` → `AsyncThrowingStream<ChatEvent>`), `OpenAICompatibleClient` (10+ providers incl. Gemini-compat), `AnthropicClient` | |
| 37 | −- [ ] Services: `StreamingService` (SSE parser), `ModelCatalog` (from PROVIDERS.md), `PersistenceService` (JSON in Application Support) | |
| 38 | −- [ ] ViewModels/Views skeleton folders | |
| 39 | −- [ ] Phase checkpoint: builds clean, unit tests for SSE parser + models | |
| 33 | +## Phase 2 — Architecture ✅ (completed 2026-07-30) | |
| 34 | + | |
| 35 | +- [x] Models: `ProviderID`, `AIModel`, `ModelCapabilities`, `ModelPricing`, `ParameterSupport`, `Message`, `Conversation`, `Persona`, `PromptTemplate`, `ChatParameters`, `TokenUsage` | |
| 36 | +- [x] Providers: `ProviderProtocol` (`ChatRequest` → `AsyncThrowingStream<ChatEvent>`), `OpenAICompatibleClient` (11 providers incl. Gemini-compat + custom), `AnthropicClient` (native Messages API), `ProviderRegistry` | |
| 37 | +- [x] Services: `StreamingService` (SSE parser + URLSession streaming), `ModelCatalog` (+ seed `ModelCatalogData`, full data generated next), `PersistenceService` (JSON in Application Support) | |
| 38 | +- [x] ViewModels/Views skeleton folders | |
| 39 | +- [x] Phase checkpoint: builds clean (0 warnings), 10/10 unit tests pass (SSE parser incl. Anthropic | |
| 40 | + named events / keep-alive comments / multi-line data; model JSON round-trip; error mapping) | |
| 41 | + | |
| 42 | +**Phase 2 checkpoint summary:** Full provider abstraction in place — all quirks (auth headers, | |
| 43 | +stream_options, max_completion_tokens, enable_thinking, reasoning_content, citations, bare-array | |
| 44 | +/models) isolated in the two clients. Toolchain note: no Xcode on this machine (CLT only), so tests | |
| 45 | +use Swift Testing with `scripts/test.sh` wiring the CLT TestingMacros plugin + framework symlinks | |
| 46 | +(`make test`). | |
| 47 | + | |
| 48 | +## Phase 3 — SecureKeyStore (in progress) | |
| 49 | + | |
| 50 | +- [ ] AES-256-GCM vault via CryptoKit at `~/Library/Application Support/ZyquoCloud/vault.zq` | |
| 51 | +- [ ] HKDF master key: random salt + IOPlatformUUID (IOKit) + home path + obfuscated compiled-in pepper | |
| 52 | +- [ ] Vault format `[salt][nonce][ciphertext+tag]`, plaintext JSON dict of provider→key | |
| 53 | +- [ ] Round-trip encrypt/decrypt test (phase gate) + tamper test + no-Keychain sweep | |
| 40 | 54 | ## Phase 2 — Architecture (pending) |
| 41 | 55 | ## Phase 3 — SecureKeyStore (pending) |
| 42 | 56 | ## Phase 4 — Design System (pending) |
added
scripts/test.sh
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +#!/bin/bash | |
| 2 | +# | |
| 3 | +# test.sh | |
| 4 | +# Zyquo Cloud | |
| 5 | +# | |
| 6 | +# Author: Simon-Pierre Boucher | |
| 7 | +# Mail: contact@spboucher.ai | |
| 8 | +# | |
| 9 | +# Runs the test suite. Works with Command Line Tools only (no Xcode): CLT's | |
| 10 | +# Testing.framework isn't on the test bundle's runtime search path, so it is | |
| 11 | +# symlinked into the build products dir before running. | |
| 12 | +# | |
| 13 | +set -euo pipefail | |
| 14 | +cd "$(dirname "$0")/.." | |
| 15 | + | |
| 16 | +CLT="/Library/Developer/CommandLineTools" | |
| 17 | +CLT_FRAMEWORKS="$CLT/Library/Developer/Frameworks" | |
| 18 | +TESTING_PLUGINS="$CLT/usr/lib/swift/host/plugins/testing" | |
| 19 | + | |
| 20 | +swift build --build-tests -Xswiftc -plugin-path -Xswiftc "$TESTING_PLUGINS" | |
| 21 | + | |
| 22 | +for products in .build/out/Products/Debug .build/debug; do | |
| 23 | + if [ -d "$products" ]; then | |
| 24 | + mkdir -p "$products/PackageFrameworks" | |
| 25 | + for fw in "$CLT_FRAMEWORKS"/*.framework; do | |
| 26 | + name="$(basename "$fw")" | |
| 27 | + ln -sfn "$fw" "$products/PackageFrameworks/$name" | |
| 28 | + done | |
| 29 | + ln -sfn "$CLT/Library/Developer/usr/lib/lib_TestingInterop.dylib" \ | |
| 30 | + "$products/PackageFrameworks/lib_TestingInterop.dylib" | |
| 31 | + fi | |
| 32 | +done | |
| 33 | + | |
| 34 | +swift test --skip-build "$@" | |
| 35 | ||