phase3.0: port Zyquo Cloud provider layer verbatim — 2 clients (12 providers), 170-model catalog, AES-256-GCM vault, SSE streaming; 19 tests green
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 16 changed files with +3,407 and −0
added
Sources/ZyquoAtlas/Models/AIModel.swift
+111 −0
@@ -0,0 +1,111 @@ | ||
| 1 | +// | |
| 2 | +// AIModel.swift | |
| 3 | +// Zyquo Atlas | |
| 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 | + /// Model rejects non-streaming calls (Qwen qwq/qvq, DashScope-hosted models | |
| 89 | + /// on Together…) — `complete` aggregates a stream instead. | |
| 90 | + var requiresStreaming: Bool = false | |
| 91 | + | |
| 92 | + static let openAIDefault = ParameterSupport(frequencyPenalty: true, presencePenalty: true) | |
| 93 | +} | |
| 94 | + | |
| 95 | +/// Token usage reported by a provider for one exchange. | |
| 96 | +struct TokenUsage: Codable, Hashable { | |
| 97 | + var inputTokens: Int = 0 | |
| 98 | + var outputTokens: Int = 0 | |
| 99 | + var reasoningTokens: Int? = nil | |
| 100 | + | |
| 101 | + var totalTokens: Int { inputTokens + outputTokens } | |
| 102 | + | |
| 103 | + static func + (lhs: TokenUsage, rhs: TokenUsage) -> TokenUsage { | |
| 104 | + TokenUsage( | |
| 105 | + inputTokens: lhs.inputTokens + rhs.inputTokens, | |
| 106 | + outputTokens: lhs.outputTokens + rhs.outputTokens, | |
| 107 | + reasoningTokens: (lhs.reasoningTokens ?? 0) + (rhs.reasoningTokens ?? 0) == 0 | |
| 108 | + ? nil : (lhs.reasoningTokens ?? 0) + (rhs.reasoningTokens ?? 0) | |
| 109 | + ) | |
| 110 | + } | |
| 111 | +} | |
added
Sources/ZyquoAtlas/Models/ChatParameters.swift
+42 −0
@@ -0,0 +1,42 @@ | ||
| 1 | +// | |
| 2 | +// ChatParameters.swift | |
| 3 | +// Zyquo Atlas | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Provider-agnostic generation parameters (extracted from Zyquo Cloud's | |
| 9 | +// Conversation.swift so the provider layer ports without the conversation | |
| 10 | +// types). `nil` means "provider default" and the parameter is omitted from | |
| 11 | +// the request entirely. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import Foundation | |
| 15 | + | |
| 16 | +struct ChatParameters: Codable, Hashable { | |
| 17 | + var temperature: Double? | |
| 18 | + var topP: Double? | |
| 19 | + var maxTokens: Int? | |
| 20 | + var frequencyPenalty: Double? | |
| 21 | + var presencePenalty: Double? | |
| 22 | + /// "low" / "medium" / "high" for models supporting reasoning_effort. | |
| 23 | + var reasoningEffort: String? | |
| 24 | + /// Explicit thinking toggle for Anthropic/Qwen-style models. | |
| 25 | + var thinkingEnabled: Bool? | |
| 26 | + | |
| 27 | + init(temperature: Double? = nil, | |
| 28 | + topP: Double? = nil, | |
| 29 | + maxTokens: Int? = nil, | |
| 30 | + frequencyPenalty: Double? = nil, | |
| 31 | + presencePenalty: Double? = nil, | |
| 32 | + reasoningEffort: String? = nil, | |
| 33 | + thinkingEnabled: Bool? = nil) { | |
| 34 | + self.temperature = temperature | |
| 35 | + self.topP = topP | |
| 36 | + self.maxTokens = maxTokens | |
| 37 | + self.frequencyPenalty = frequencyPenalty | |
| 38 | + self.presencePenalty = presencePenalty | |
| 39 | + self.reasoningEffort = reasoningEffort | |
| 40 | + self.thinkingEnabled = thinkingEnabled | |
| 41 | + } | |
| 42 | +} | |
added
Sources/ZyquoAtlas/Models/Message.swift
+105 −0
@@ -0,0 +1,105 @@ | ||
| 1 | +// | |
| 2 | +// Message.swift | |
| 3 | +// Zyquo Atlas | |
| 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/ZyquoAtlas/Models/ProviderID.swift
+93 −0
@@ -0,0 +1,93 @@ | ||
| 1 | +// | |
| 2 | +// ProviderID.swift | |
| 3 | +// Zyquo Atlas | |
| 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/ZyquoAtlas/Providers/AnthropicClient.swift
+300 −0
@@ -0,0 +1,300 @@ | ||
| 1 | +// | |
| 2 | +// AnthropicClient.swift | |
| 3 | +// Zyquo Atlas | |
| 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/ZyquoAtlas/Providers/OpenAICompatibleClient.swift
+477 −0
@@ -0,0 +1,477 @@ | ||
| 1 | +// | |
| 2 | +// OpenAICompatibleClient.swift | |
| 3 | +// Zyquo Atlas | |
| 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 | + /// Together streams some models completions-style: the token text | |
| 123 | + /// lives in `choices[].text` instead of `delta.content`. | |
| 124 | + var text: String? | |
| 125 | + var finishReason: String? | |
| 126 | + | |
| 127 | + enum CodingKeys: String, CodingKey { | |
| 128 | + case delta, message, text | |
| 129 | + case finishReason = "finish_reason" | |
| 130 | + } | |
| 131 | + } | |
| 132 | + | |
| 133 | + private struct WireDelta: Decodable { | |
| 134 | + var content: String? | |
| 135 | + var reasoningContent: String? | |
| 136 | + var reasoning: String? | |
| 137 | + | |
| 138 | + enum CodingKeys: String, CodingKey { | |
| 139 | + case content, reasoning | |
| 140 | + case reasoningContent = "reasoning_content" | |
| 141 | + } | |
| 142 | + | |
| 143 | + init(from decoder: Decoder) throws { | |
| 144 | + let container = try decoder.container(keyedBy: CodingKeys.self) | |
| 145 | + reasoning = try? container.decodeIfPresent(String.self, forKey: .reasoning) | |
| 146 | + reasoningContent = try? container.decodeIfPresent(String.self, forKey: .reasoningContent) | |
| 147 | + // `content` is normally a string, but Mistral's reasoning models | |
| 148 | + // return an array of chunks ({type: "thinking"|"text", …}). | |
| 149 | + if let text = try? container.decodeIfPresent(String.self, forKey: .content) { | |
| 150 | + content = text | |
| 151 | + } else if let chunks = try? container.decodeIfPresent([ContentChunk].self, forKey: .content) { | |
| 152 | + var textParts: [String] = [] | |
| 153 | + var thinkingParts: [String] = [] | |
| 154 | + for chunk in chunks { | |
| 155 | + if chunk.type == "thinking" { | |
| 156 | + thinkingParts.append(chunk.flattenedText) | |
| 157 | + } else { | |
| 158 | + textParts.append(chunk.flattenedText) | |
| 159 | + } | |
| 160 | + } | |
| 161 | + content = textParts.joined() | |
| 162 | + let thinking = thinkingParts.joined() | |
| 163 | + if !thinking.isEmpty, reasoningContent == nil { | |
| 164 | + reasoningContent = thinking | |
| 165 | + } | |
| 166 | + } | |
| 167 | + } | |
| 168 | + | |
| 169 | + /// Mistral ThinkChunk/TextChunk: {"type":"text","text":…} or | |
| 170 | + /// {"type":"thinking","thinking":[{"type":"text","text":…}]}. | |
| 171 | + struct ContentChunk: Decodable { | |
| 172 | + var type: String? | |
| 173 | + var text: String? | |
| 174 | + var thinking: [ContentChunkPart]? | |
| 175 | + | |
| 176 | + var flattenedText: String { | |
| 177 | + if let text { return text } | |
| 178 | + return (thinking ?? []).compactMap(\.text).joined() | |
| 179 | + } | |
| 180 | + } | |
| 181 | + | |
| 182 | + struct ContentChunkPart: Decodable { | |
| 183 | + var text: String? | |
| 184 | + } | |
| 185 | + } | |
| 186 | + | |
| 187 | + private struct WireUsage: Decodable { | |
| 188 | + var promptTokens: Int? | |
| 189 | + var completionTokens: Int? | |
| 190 | + var completionTokensDetails: Details? | |
| 191 | + | |
| 192 | + struct Details: Decodable { | |
| 193 | + var reasoningTokens: Int? | |
| 194 | + enum CodingKeys: String, CodingKey { case reasoningTokens = "reasoning_tokens" } | |
| 195 | + } | |
| 196 | + | |
| 197 | + enum CodingKeys: String, CodingKey { | |
| 198 | + case promptTokens = "prompt_tokens" | |
| 199 | + case completionTokens = "completion_tokens" | |
| 200 | + case completionTokensDetails = "completion_tokens_details" | |
| 201 | + } | |
| 202 | + | |
| 203 | + var usage: TokenUsage { | |
| 204 | + TokenUsage( | |
| 205 | + inputTokens: promptTokens ?? 0, | |
| 206 | + outputTokens: completionTokens ?? 0, | |
| 207 | + reasoningTokens: completionTokensDetails?.reasoningTokens | |
| 208 | + ) | |
| 209 | + } | |
| 210 | + } | |
| 211 | + | |
| 212 | + private struct WireSearchResult: Decodable { | |
| 213 | + var title: String? | |
| 214 | + var url: String? | |
| 215 | + } | |
| 216 | + | |
| 217 | + private struct WireModelList: Decodable { | |
| 218 | + var data: [WireModelEntry] | |
| 219 | + } | |
| 220 | + | |
| 221 | + private struct WireModelEntry: Decodable { | |
| 222 | + var id: String | |
| 223 | + } | |
| 224 | + | |
| 225 | + // MARK: - Request construction | |
| 226 | + | |
| 227 | + private var baseURL: URL? { baseURLOverride ?? providerID.defaultBaseURL } | |
| 228 | + | |
| 229 | + private func urlRequest(path: String, apiKey: String, method: String = "POST") throws -> URLRequest { | |
| 230 | + guard let base = baseURL else { | |
| 231 | + throw ProviderError.invalidResponse(providerID, detail: "no base URL configured") | |
| 232 | + } | |
| 233 | + // Preserve base path components ("…/v1", "…/compatible-mode/v1", "…/v1beta/openai"). | |
| 234 | + var request = URLRequest(url: base.appendingPathComponent(path)) | |
| 235 | + request.httpMethod = method | |
| 236 | + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") | |
| 237 | + if method == "POST" { | |
| 238 | + request.setValue("application/json", forHTTPHeaderField: "Content-Type") | |
| 239 | + } | |
| 240 | + return request | |
| 241 | + } | |
| 242 | + | |
| 243 | + /// Providers whose final streamed chunk carries usage only when asked. | |
| 244 | + private var wantsStreamOptions: Bool { | |
| 245 | + switch providerID { | |
| 246 | + case .openai, .xai, .gemini, .deepseek, .kimi, .together, .cerebras, .custom: | |
| 247 | + return true | |
| 248 | + // Qwen, DeepInfra, Perplexity include usage automatically; Mistral | |
| 249 | + // rejects unknown params less gracefully — omit there. | |
| 250 | + case .mistral, .qwen, .deepinfra, .perplexity: | |
| 251 | + return false | |
| 252 | + case .anthropic: | |
| 253 | + return false // never routed here | |
| 254 | + } | |
| 255 | + } | |
| 256 | + | |
| 257 | + private func buildBody(_ request: ChatRequest) throws -> Data { | |
| 258 | + var messages: [WireMessage] = [] | |
| 259 | + if let system = request.systemPrompt, !system.isEmpty { | |
| 260 | + messages.append(WireMessage(role: "system", content: .text(system))) | |
| 261 | + } | |
| 262 | + for message in request.messages where message.role != .system { | |
| 263 | + messages.append(wireMessage(from: message, vision: request.model.capabilities.vision)) | |
| 264 | + } | |
| 265 | + | |
| 266 | + let support = request.model.parameterSupport | |
| 267 | + let params = request.parameters | |
| 268 | + var wire = WireRequest(model: request.model.id, messages: messages) | |
| 269 | + if request.stream { | |
| 270 | + wire.stream = true | |
| 271 | + if wantsStreamOptions { | |
| 272 | + wire.streamOptions = StreamOptions(includeUsage: true) | |
| 273 | + } | |
| 274 | + } | |
| 275 | + if support.temperature { wire.temperature = params.temperature } | |
| 276 | + if support.topP { wire.topP = params.topP } | |
| 277 | + if let max = params.maxTokens { | |
| 278 | + if support.usesMaxCompletionTokens { | |
| 279 | + wire.maxCompletionTokens = max | |
| 280 | + } else { | |
| 281 | + wire.maxTokens = max | |
| 282 | + } | |
| 283 | + } | |
| 284 | + if support.frequencyPenalty { wire.frequencyPenalty = params.frequencyPenalty } | |
| 285 | + if support.presencePenalty { wire.presencePenalty = params.presencePenalty } | |
| 286 | + if support.reasoningEffort { | |
| 287 | + // Mistral only accepts "high"/"none": map medium→high, low→none. | |
| 288 | + if providerID == .mistral, let effort = params.reasoningEffort { | |
| 289 | + wire.reasoningEffort = effort == "low" ? "none" : "high" | |
| 290 | + } else { | |
| 291 | + wire.reasoningEffort = params.reasoningEffort | |
| 292 | + } | |
| 293 | + } | |
| 294 | + if support.thinkingToggle, providerID == .qwen { | |
| 295 | + // DashScope: enable_thinking is only legal on streaming requests. | |
| 296 | + if request.stream { wire.enableThinking = params.thinkingEnabled } | |
| 297 | + } | |
| 298 | + let encoder = JSONEncoder() | |
| 299 | + return try encoder.encode(wire) | |
| 300 | + } | |
| 301 | + | |
| 302 | + private func wireMessage(from message: Message, vision: Bool) -> WireMessage { | |
| 303 | + let role = message.role == .assistant ? "assistant" : "user" | |
| 304 | + var text = message.text | |
| 305 | + // Text-file attachments are injected inline, fenced with the file name. | |
| 306 | + for attachment in message.attachments where attachment.kind == .textFile { | |
| 307 | + let contents = String(data: attachment.data, encoding: .utf8) ?? "" | |
| 308 | + text += "\n\n```\(attachment.fileName)\n\(contents)\n```" | |
| 309 | + } | |
| 310 | + let images = message.attachments.filter { $0.kind == .image } | |
| 311 | + guard vision, !images.isEmpty, message.role == .user else { | |
| 312 | + return WireMessage(role: role, content: .text(text)) | |
| 313 | + } | |
| 314 | + var parts: [WirePart] = [.text(text)] | |
| 315 | + for image in images { | |
| 316 | + let dataURI = "data:\(image.mimeType);base64,\(image.data.base64EncodedString())" | |
| 317 | + parts.append(.imageURL(dataURI)) | |
| 318 | + } | |
| 319 | + return WireMessage(role: role, content: .parts(parts)) | |
| 320 | + } | |
| 321 | + | |
| 322 | + // MARK: - ProviderClient | |
| 323 | + | |
| 324 | + func streamChat(_ request: ChatRequest, apiKey: String) -> AsyncThrowingStream<ChatEvent, Error> { | |
| 325 | + AsyncThrowingStream { continuation in | |
| 326 | + let task = Task { | |
| 327 | + do { | |
| 328 | + var urlReq = try urlRequest(path: "chat/completions", apiKey: apiKey) | |
| 329 | + var streamRequest = request | |
| 330 | + streamRequest.stream = true | |
| 331 | + urlReq.httpBody = try buildBody(streamRequest) | |
| 332 | + | |
| 333 | + var citationsSent = false | |
| 334 | + var finishReason: String? | |
| 335 | + let decoder = JSONDecoder() | |
| 336 | + | |
| 337 | + for try await event in StreamingService.sseEvents(for: urlReq, provider: providerID) { | |
| 338 | + if event.data == "[DONE]" { break } | |
| 339 | + guard let data = event.data.data(using: .utf8), | |
| 340 | + let chunk = try? decoder.decode(WireChunk.self, from: data) else { | |
| 341 | + continue // tolerate unknown/malformed keep-alive chunks | |
| 342 | + } | |
| 343 | + if let choice = chunk.choices?.first { | |
| 344 | + if let reasoning = choice.delta?.reasoningContent ?? choice.delta?.reasoning, | |
| 345 | + !reasoning.isEmpty { | |
| 346 | + continuation.yield(.reasoningDelta(reasoning)) | |
| 347 | + } | |
| 348 | + let deltaText = choice.delta?.content ?? choice.text | |
| 349 | + if let deltaText, !deltaText.isEmpty { | |
| 350 | + continuation.yield(.textDelta(deltaText)) | |
| 351 | + } | |
| 352 | + if let reason = choice.finishReason { | |
| 353 | + finishReason = reason | |
| 354 | + } | |
| 355 | + } | |
| 356 | + if !citationsSent, let citations = Self.citations(from: chunk), !citations.isEmpty { | |
| 357 | + citationsSent = true | |
| 358 | + continuation.yield(.citations(citations)) | |
| 359 | + } | |
| 360 | + if let usage = chunk.usage { | |
| 361 | + continuation.yield(.usage(usage.usage)) | |
| 362 | + } | |
| 363 | + } | |
| 364 | + continuation.yield(.finished(reason: finishReason)) | |
| 365 | + continuation.finish() | |
| 366 | + } catch { | |
| 367 | + continuation.finish(throwing: error) | |
| 368 | + } | |
| 369 | + } | |
| 370 | + continuation.onTermination = { _ in task.cancel() } | |
| 371 | + } | |
| 372 | + } | |
| 373 | + | |
| 374 | + func complete(_ request: ChatRequest, apiKey: String) async throws -> Message { | |
| 375 | + // Some models reject non-streaming calls — aggregate a stream instead. | |
| 376 | + if request.model.parameterSupport.requiresStreaming { | |
| 377 | + return try await completeViaStream(request, apiKey: apiKey) | |
| 378 | + } | |
| 379 | + var urlReq = try urlRequest(path: "chat/completions", apiKey: apiKey) | |
| 380 | + var plainRequest = request | |
| 381 | + plainRequest.stream = false | |
| 382 | + urlReq.httpBody = try buildBody(plainRequest) | |
| 383 | + let data = try await StreamingService.postJSON(urlReq, provider: providerID) | |
| 384 | + let chunk = try decodeOrThrow(WireChunk.self, from: data) | |
| 385 | + guard let choice = chunk.choices?.first, let content = choice.message ?? choice.delta else { | |
| 386 | + throw ProviderError.invalidResponse(providerID, detail: "response contained no message") | |
| 387 | + } | |
| 388 | + var message = Message( | |
| 389 | + role: .assistant, | |
| 390 | + text: content.content ?? choice.text ?? "", | |
| 391 | + reasoning: content.reasoningContent ?? content.reasoning, | |
| 392 | + modelID: request.model.id, | |
| 393 | + provider: providerID | |
| 394 | + ) | |
| 395 | + if let citations = Self.citations(from: chunk) { | |
| 396 | + message.citations = citations | |
| 397 | + } | |
| 398 | + if let usage = chunk.usage?.usage { | |
| 399 | + message.usage = usage | |
| 400 | + message.estimatedCost = request.model.pricing?.cost( | |
| 401 | + inputTokens: usage.inputTokens, outputTokens: usage.outputTokens | |
| 402 | + ) | |
| 403 | + } | |
| 404 | + return message | |
| 405 | + } | |
| 406 | + | |
| 407 | + func listModelIDs(apiKey: String) async throws -> [String] { | |
| 408 | + let urlReq = try urlRequest(path: "models", apiKey: apiKey, method: "GET") | |
| 409 | + let data = try await StreamingService.getJSON(urlReq, provider: providerID) | |
| 410 | + // Together returns a bare array; everyone else wraps in {"data": […]}. | |
| 411 | + // Gemini's compat endpoint prefixes IDs with "models/" — normalize. | |
| 412 | + let ids: [String] | |
| 413 | + if let list = try? JSONDecoder().decode(WireModelList.self, from: data) { | |
| 414 | + ids = list.data.map(\.id) | |
| 415 | + } else if let bare = try? JSONDecoder().decode([WireModelEntry].self, from: data) { | |
| 416 | + ids = bare.map(\.id) | |
| 417 | + } else { | |
| 418 | + throw ProviderError.invalidResponse(providerID, detail: "unrecognized /models response shape") | |
| 419 | + } | |
| 420 | + return ids.map { $0.hasPrefix("models/") ? String($0.dropFirst(7)) : $0 } | |
| 421 | + } | |
| 422 | + | |
| 423 | + /// Non-streaming result assembled from the streaming endpoint, for models | |
| 424 | + /// that only support `stream: true`. | |
| 425 | + private func completeViaStream(_ request: ChatRequest, apiKey: String) async throws -> Message { | |
| 426 | + var text = "" | |
| 427 | + var reasoning = "" | |
| 428 | + var citations: [Citation] = [] | |
| 429 | + var usage: TokenUsage? | |
| 430 | + for try await event in streamChat(request, apiKey: apiKey) { | |
| 431 | + switch event { | |
| 432 | + case .textDelta(let delta): text += delta | |
| 433 | + case .reasoningDelta(let delta): reasoning += delta | |
| 434 | + case .citations(let c): citations = c | |
| 435 | + case .usage(let u): usage = u | |
| 436 | + case .finished: break | |
| 437 | + } | |
| 438 | + } | |
| 439 | + var message = Message( | |
| 440 | + role: .assistant, | |
| 441 | + text: text, | |
| 442 | + reasoning: reasoning.isEmpty ? nil : reasoning, | |
| 443 | + citations: citations, | |
| 444 | + modelID: request.model.id, | |
| 445 | + provider: providerID | |
| 446 | + ) | |
| 447 | + if let usage { | |
| 448 | + message.usage = usage | |
| 449 | + message.estimatedCost = request.model.pricing?.cost( | |
| 450 | + inputTokens: usage.inputTokens, outputTokens: usage.outputTokens | |
| 451 | + ) | |
| 452 | + } | |
| 453 | + return message | |
| 454 | + } | |
| 455 | + | |
| 456 | + // MARK: - Helpers | |
| 457 | + | |
| 458 | + private func decodeOrThrow<T: Decodable>(_ type: T.Type, from data: Data) throws -> T { | |
| 459 | + do { | |
| 460 | + return try JSONDecoder().decode(type, from: data) | |
| 461 | + } catch { | |
| 462 | + throw ProviderError.invalidResponse(providerID, detail: "decode failed: \(error.localizedDescription)") | |
| 463 | + } | |
| 464 | + } | |
| 465 | + | |
| 466 | + /// Perplexity: `citations` is an array of URL strings; `search_results` | |
| 467 | + /// adds titles. Merge both into numbered citations. | |
| 468 | + private static func citations(from chunk: WireChunk) -> [Citation]? { | |
| 469 | + guard let urls = chunk.citations, !urls.isEmpty else { return nil } | |
| 470 | + let titles = chunk.searchResults ?? [] | |
| 471 | + return urls.enumerated().compactMap { index, urlString in | |
| 472 | + guard let url = URL(string: urlString) else { return nil } | |
| 473 | + let title = index < titles.count ? titles[index].title : nil | |
| 474 | + return Citation(index: index + 1, url: url, title: title) | |
| 475 | + } | |
| 476 | + } | |
| 477 | +} | |
added
Sources/ZyquoAtlas/Providers/ProviderProtocol.swift
+146 −0
@@ -0,0 +1,146 @@ | ||
| 1 | +// | |
| 2 | +// ProviderProtocol.swift | |
| 3 | +// Zyquo Atlas | |
| 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/ZyquoAtlas/Providers/ProviderRegistry.swift
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +// | |
| 2 | +// ProviderRegistry.swift | |
| 3 | +// Zyquo Atlas | |
| 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/ZyquoAtlas/Services/ModelCatalog.swift
+72 −0
@@ -0,0 +1,72 @@ | ||
| 1 | +// | |
| 2 | +// ModelCatalog.swift | |
| 3 | +// Zyquo Atlas | |
| 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). Non-reasoning models are preferred — reasoning | |
| 40 | + /// models burn their token budget thinking, useless for tiny utility calls. | |
| 41 | + func cheapestModel(for provider: ProviderID) -> AIModel? { | |
| 42 | + let candidates = models(for: provider).filter { !$0.isLegacy } | |
| 43 | + let plain = candidates.filter { !$0.capabilities.reasoning } | |
| 44 | + return (plain.isEmpty ? candidates : plain) | |
| 45 | + .min { ($0.pricing?.outputPerMTok ?? .infinity) < ($1.pricing?.outputPerMTok ?? .infinity) } | |
| 46 | + } | |
| 47 | + | |
| 48 | + /// Default model offered for new conversations. | |
| 49 | + var defaultModel: AIModel? { | |
| 50 | + all.first { $0.isRecommended } ?? all.first | |
| 51 | + } | |
| 52 | + | |
| 53 | + /// Merges a dynamic /models listing: known models are marked live; unknown | |
| 54 | + /// IDs are surfaced so the user can add them. | |
| 55 | + func applyLiveListing(_ ids: [String], for provider: ProviderID) { | |
| 56 | + liveModelIDs[provider] = Set(ids) | |
| 57 | + } | |
| 58 | + | |
| 59 | + /// IDs returned by the provider but absent from the built-in catalog. | |
| 60 | + func unknownLiveIDs(for provider: ProviderID) -> [String] { | |
| 61 | + guard let live = liveModelIDs[provider] else { return [] } | |
| 62 | + let known = Set(models(for: provider).map(\.id)) | |
| 63 | + return live.subtracting(known).sorted() | |
| 64 | + } | |
| 65 | + | |
| 66 | + private func rank(_ model: AIModel) -> Int { | |
| 67 | + if favoriteIDs.contains(model.id) { return 0 } | |
| 68 | + if model.isRecommended { return 1 } | |
| 69 | + if model.isLegacy { return 3 } | |
| 70 | + return 2 | |
| 71 | + } | |
| 72 | +} | |
added
Sources/ZyquoAtlas/Services/ModelCatalogData.swift
+1364 −0
@@ -0,0 +1,1364 @@ | ||
| 1 | +// | |
| 2 | +// ModelCatalogData.swift | |
| 3 | +// Zyquo Atlas | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Built-in model catalog, generated from docs/PROVIDERS.md and the per-provider | |
| 9 | +// research files in docs/research/ on 2026-07-30. This file and docs/PROVIDERS.md | |
| 10 | +// are a single source of truth and MUST stay in sync: when Phase 7 verification | |
| 11 | +// (or any later research pass) changes a model, update both together. | |
| 12 | +// | |
| 13 | +// Scope: chat-completions-capable chat models only. Embeddings, audio/TTS/ASR, | |
| 14 | +// realtime, image/video generation, moderation, OCR, robotics, deep-research | |
| 15 | +// agents, and Responses-API-only models are excluded by design. | |
| 16 | +// | |
| 17 | + | |
| 18 | +import Foundation | |
| 19 | + | |
| 20 | +enum ModelCatalogData { | |
| 21 | + | |
| 22 | + // MARK: - Shared parameter-support presets | |
| 23 | + | |
| 24 | + /// OpenAI reasoning models (o-series, gpt-5.x reasoning variants): reject | |
| 25 | + /// temperature/top_p/penalties, require max_completion_tokens, accept reasoning_effort. | |
| 26 | + private static let openAIReasoning = ParameterSupport( | |
| 27 | + temperature: false, topP: false, | |
| 28 | + usesMaxCompletionTokens: true, reasoningEffort: true | |
| 29 | + ) | |
| 30 | + | |
| 31 | + // MARK: - OpenAI | |
| 32 | + | |
| 33 | + static let openai: [AIModel] = [ | |
| 34 | + // Flagship GPT-5.6 trio | |
| 35 | + AIModel( | |
| 36 | + id: "gpt-5.6-sol", provider: .openai, displayName: "GPT-5.6 Sol", | |
| 37 | + contextWindow: 1_050_000, maxOutputTokens: 128_000, | |
| 38 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 39 | + pricing: ModelPricing(inputPerMTok: 5.00, outputPerMTok: 30.00), | |
| 40 | + parameterSupport: openAIReasoning, | |
| 41 | + isRecommended: true | |
| 42 | + ), | |
| 43 | + AIModel( | |
| 44 | + id: "gpt-5.6-terra", provider: .openai, displayName: "GPT-5.6 Terra", | |
| 45 | + contextWindow: 1_050_000, maxOutputTokens: 128_000, | |
| 46 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 47 | + pricing: ModelPricing(inputPerMTok: 2.50, outputPerMTok: 15.00), | |
| 48 | + parameterSupport: openAIReasoning, | |
| 49 | + isRecommended: true | |
| 50 | + ), | |
| 51 | + AIModel( | |
| 52 | + id: "gpt-5.6-luna", provider: .openai, displayName: "GPT-5.6 Luna", | |
| 53 | + contextWindow: 1_050_000, maxOutputTokens: 128_000, | |
| 54 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 55 | + pricing: ModelPricing(inputPerMTok: 1.00, outputPerMTok: 6.00), | |
| 56 | + parameterSupport: openAIReasoning | |
| 57 | + ), | |
| 58 | + AIModel( | |
| 59 | + id: "chat-latest", provider: .openai, displayName: "ChatGPT Latest", | |
| 60 | + contextWindow: 128_000, maxOutputTokens: nil, | |
| 61 | + capabilities: ModelCapabilities(vision: true, tools: true, jsonMode: true), | |
| 62 | + pricing: ModelPricing(inputPerMTok: 5.00, outputPerMTok: 30.00), | |
| 63 | + parameterSupport: ParameterSupport(frequencyPenalty: true, presencePenalty: true, usesMaxCompletionTokens: true) | |
| 64 | + ), | |
| 65 | + // Current / recent GPT-5.x | |
| 66 | + AIModel( | |
| 67 | + id: "gpt-5.5", provider: .openai, displayName: "GPT-5.5", | |
| 68 | + contextWindow: 400_000, maxOutputTokens: nil, | |
| 69 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 70 | + pricing: ModelPricing(inputPerMTok: 5.00, outputPerMTok: 30.00), | |
| 71 | + parameterSupport: openAIReasoning | |
| 72 | + ), | |
| 73 | + AIModel( | |
| 74 | + id: "gpt-5.4", provider: .openai, displayName: "GPT-5.4", | |
| 75 | + contextWindow: 400_000, maxOutputTokens: 128_000, | |
| 76 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 77 | + pricing: ModelPricing(inputPerMTok: 2.50, outputPerMTok: 15.00), | |
| 78 | + parameterSupport: openAIReasoning | |
| 79 | + ), | |
| 80 | + AIModel( | |
| 81 | + id: "gpt-5.4-mini", provider: .openai, displayName: "GPT-5.4 mini", | |
| 82 | + contextWindow: 400_000, maxOutputTokens: nil, | |
| 83 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 84 | + pricing: ModelPricing(inputPerMTok: 0.75, outputPerMTok: 4.50), | |
| 85 | + parameterSupport: openAIReasoning | |
| 86 | + ), | |
| 87 | + AIModel( | |
| 88 | + id: "gpt-5.4-nano", provider: .openai, displayName: "GPT-5.4 nano", | |
| 89 | + contextWindow: 400_000, maxOutputTokens: nil, | |
| 90 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 91 | + pricing: ModelPricing(inputPerMTok: 0.20, outputPerMTok: 1.25), | |
| 92 | + parameterSupport: openAIReasoning | |
| 93 | + ), | |
| 94 | + AIModel( | |
| 95 | + id: "gpt-5.3-chat-latest", provider: .openai, displayName: "GPT-5.3 Chat Latest", | |
| 96 | + contextWindow: 128_000, maxOutputTokens: nil, | |
| 97 | + capabilities: ModelCapabilities(vision: true, tools: true, jsonMode: true), | |
| 98 | + pricing: nil, | |
| 99 | + parameterSupport: ParameterSupport(frequencyPenalty: true, presencePenalty: true, usesMaxCompletionTokens: true) | |
| 100 | + ), | |
| 101 | + AIModel( | |
| 102 | + id: "gpt-5.2", provider: .openai, displayName: "GPT-5.2", | |
| 103 | + contextWindow: 400_000, maxOutputTokens: 128_000, | |
| 104 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 105 | + pricing: ModelPricing(inputPerMTok: 1.75, outputPerMTok: 14.00), | |
| 106 | + parameterSupport: openAIReasoning | |
| 107 | + ), | |
| 108 | + AIModel( | |
| 109 | + id: "gpt-5.2-chat-latest", provider: .openai, displayName: "GPT-5.2 Chat Latest", | |
| 110 | + contextWindow: 128_000, maxOutputTokens: 16_000, | |
| 111 | + capabilities: ModelCapabilities(vision: true, tools: true, jsonMode: true), | |
| 112 | + pricing: ModelPricing(inputPerMTok: 1.75, outputPerMTok: 14.00), | |
| 113 | + parameterSupport: ParameterSupport(frequencyPenalty: true, presencePenalty: true, usesMaxCompletionTokens: true) | |
| 114 | + ), | |
| 115 | + AIModel( | |
| 116 | + id: "gpt-5.1", provider: .openai, displayName: "GPT-5.1", | |
| 117 | + contextWindow: 400_000, maxOutputTokens: 128_000, | |
| 118 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 119 | + pricing: ModelPricing(inputPerMTok: 1.25, outputPerMTok: 10.00), | |
| 120 | + parameterSupport: openAIReasoning | |
| 121 | + ), | |
| 122 | + AIModel( | |
| 123 | + id: "gpt-5", provider: .openai, displayName: "GPT-5", | |
| 124 | + contextWindow: 400_000, maxOutputTokens: 128_000, | |
| 125 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 126 | + pricing: ModelPricing(inputPerMTok: 1.25, outputPerMTok: 10.00), | |
| 127 | + parameterSupport: openAIReasoning | |
| 128 | + ), | |
| 129 | + AIModel( | |
| 130 | + id: "gpt-5-mini", provider: .openai, displayName: "GPT-5 mini", | |
| 131 | + contextWindow: 400_000, maxOutputTokens: 128_000, | |
| 132 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 133 | + pricing: ModelPricing(inputPerMTok: 0.25, outputPerMTok: 2.00), | |
| 134 | + parameterSupport: openAIReasoning | |
| 135 | + ), | |
| 136 | + AIModel( | |
| 137 | + id: "gpt-5-nano", provider: .openai, displayName: "GPT-5 nano", | |
| 138 | + contextWindow: 400_000, maxOutputTokens: 128_000, | |
| 139 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 140 | + pricing: ModelPricing(inputPerMTok: 0.05, outputPerMTok: 0.40), | |
| 141 | + parameterSupport: openAIReasoning | |
| 142 | + ), | |
| 143 | + // o-series reasoning | |
| 144 | + AIModel( | |
| 145 | + id: "o3", provider: .openai, displayName: "OpenAI o3", | |
| 146 | + contextWindow: 200_000, maxOutputTokens: 100_000, | |
| 147 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 148 | + pricing: ModelPricing(inputPerMTok: 2.00, outputPerMTok: 8.00), | |
| 149 | + parameterSupport: openAIReasoning | |
| 150 | + ), | |
| 151 | + AIModel( | |
| 152 | + id: "o4-mini", provider: .openai, displayName: "OpenAI o4-mini", | |
| 153 | + contextWindow: 200_000, maxOutputTokens: 100_000, | |
| 154 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 155 | + pricing: ModelPricing(inputPerMTok: 1.10, outputPerMTok: 4.40), | |
| 156 | + parameterSupport: openAIReasoning | |
| 157 | + ), | |
| 158 | + AIModel( | |
| 159 | + id: "o3-mini", provider: .openai, displayName: "OpenAI o3-mini", | |
| 160 | + contextWindow: 200_000, maxOutputTokens: 100_000, | |
| 161 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 162 | + pricing: ModelPricing(inputPerMTok: 1.10, outputPerMTok: 4.40), | |
| 163 | + parameterSupport: openAIReasoning, | |
| 164 | + isLegacy: true | |
| 165 | + ), | |
| 166 | + AIModel( | |
| 167 | + id: "o1", provider: .openai, displayName: "OpenAI o1", | |
| 168 | + contextWindow: 200_000, maxOutputTokens: 100_000, | |
| 169 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 170 | + pricing: ModelPricing(inputPerMTok: 15.00, outputPerMTok: 60.00), | |
| 171 | + parameterSupport: openAIReasoning, | |
| 172 | + isLegacy: true | |
| 173 | + ), | |
| 174 | + // Legacy GPT-4.x / 3.5 | |
| 175 | + AIModel( | |
| 176 | + id: "gpt-4.1", provider: .openai, displayName: "GPT-4.1", | |
| 177 | + contextWindow: 1_047_576, maxOutputTokens: 32_768, | |
| 178 | + capabilities: ModelCapabilities(vision: true, tools: true, jsonMode: true), | |
| 179 | + pricing: ModelPricing(inputPerMTok: 2.00, outputPerMTok: 8.00), | |
| 180 | + parameterSupport: .openAIDefault, | |
| 181 | + isLegacy: true | |
| 182 | + ), | |
| 183 | + AIModel( | |
| 184 | + id: "gpt-4.1-mini", provider: .openai, displayName: "GPT-4.1 mini", | |
| 185 | + contextWindow: 1_047_576, maxOutputTokens: 32_768, | |
| 186 | + capabilities: ModelCapabilities(vision: true, tools: true, jsonMode: true), | |
| 187 | + pricing: ModelPricing(inputPerMTok: 0.40, outputPerMTok: 1.60), | |
| 188 | + parameterSupport: .openAIDefault, | |
| 189 | + isLegacy: true | |
| 190 | + ), | |
| 191 | + AIModel( | |
| 192 | + id: "gpt-4.1-nano", provider: .openai, displayName: "GPT-4.1 nano", | |
| 193 | + contextWindow: 1_047_576, maxOutputTokens: 32_768, | |
| 194 | + capabilities: ModelCapabilities(vision: true, tools: true, jsonMode: true), | |
| 195 | + pricing: ModelPricing(inputPerMTok: 0.10, outputPerMTok: 0.40), | |
| 196 | + parameterSupport: .openAIDefault, | |
| 197 | + isLegacy: true | |
| 198 | + ), | |
| 199 | + AIModel( | |
| 200 | + id: "gpt-4o", provider: .openai, displayName: "GPT-4o", | |
| 201 | + contextWindow: 128_000, maxOutputTokens: 16_384, | |
| 202 | + capabilities: ModelCapabilities(vision: true, tools: true, jsonMode: true), | |
| 203 | + pricing: ModelPricing(inputPerMTok: 2.50, outputPerMTok: 10.00), | |
| 204 | + parameterSupport: .openAIDefault, | |
| 205 | + isLegacy: true | |
| 206 | + ), | |
| 207 | + AIModel( | |
| 208 | + id: "gpt-4o-mini", provider: .openai, displayName: "GPT-4o mini", | |
| 209 | + contextWindow: 128_000, maxOutputTokens: 16_384, | |
| 210 | + capabilities: ModelCapabilities(vision: true, tools: true, jsonMode: true), | |
| 211 | + pricing: ModelPricing(inputPerMTok: 0.15, outputPerMTok: 0.60), | |
| 212 | + parameterSupport: .openAIDefault, | |
| 213 | + isLegacy: true | |
| 214 | + ), | |
| 215 | + AIModel( | |
| 216 | + id: "gpt-4-turbo", provider: .openai, displayName: "GPT-4 Turbo", | |
| 217 | + contextWindow: 128_000, maxOutputTokens: 4_096, | |
| 218 | + capabilities: ModelCapabilities(vision: true, tools: true, jsonMode: true), | |
| 219 | + pricing: ModelPricing(inputPerMTok: 10.00, outputPerMTok: 30.00), | |
| 220 | + parameterSupport: .openAIDefault, | |
| 221 | + isLegacy: true | |
| 222 | + ), | |
| 223 | + AIModel( | |
| 224 | + id: "gpt-4", provider: .openai, displayName: "GPT-4", | |
| 225 | + contextWindow: 8_192, maxOutputTokens: 8_192, | |
| 226 | + capabilities: ModelCapabilities(tools: true), | |
| 227 | + pricing: ModelPricing(inputPerMTok: 30.00, outputPerMTok: 60.00), | |
| 228 | + parameterSupport: .openAIDefault, | |
| 229 | + isLegacy: true | |
| 230 | + ), | |
| 231 | + AIModel( | |
| 232 | + id: "gpt-3.5-turbo", provider: .openai, displayName: "GPT-3.5 Turbo", | |
| 233 | + contextWindow: 16_385, maxOutputTokens: 4_096, | |
| 234 | + capabilities: ModelCapabilities(tools: true, jsonMode: true), | |
| 235 | + pricing: ModelPricing(inputPerMTok: 0.50, outputPerMTok: 1.50), | |
| 236 | + parameterSupport: .openAIDefault, | |
| 237 | + isLegacy: true | |
| 238 | + ), | |
| 239 | + ] | |
| 240 | + | |
| 241 | + // MARK: - Anthropic | |
| 242 | + | |
| 243 | + static let anthropic: [AIModel] = [ | |
| 244 | + AIModel( | |
| 245 | + id: "claude-opus-5", provider: .anthropic, displayName: "Claude Opus 5", | |
| 246 | + contextWindow: 1_000_000, maxOutputTokens: 128_000, | |
| 247 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 248 | + pricing: ModelPricing(inputPerMTok: 5.00, outputPerMTok: 25.00), | |
| 249 | + parameterSupport: ParameterSupport(temperature: false, topP: false, thinkingToggle: true), | |
| 250 | + isRecommended: true | |
| 251 | + ), | |
| 252 | + AIModel( | |
| 253 | + id: "claude-sonnet-5", provider: .anthropic, displayName: "Claude Sonnet 5", | |
| 254 | + contextWindow: 1_000_000, maxOutputTokens: 128_000, | |
| 255 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 256 | + pricing: ModelPricing(inputPerMTok: 3.00, outputPerMTok: 15.00), | |
| 257 | + parameterSupport: ParameterSupport(temperature: false, topP: false, thinkingToggle: true), | |
| 258 | + isRecommended: true | |
| 259 | + ), | |
| 260 | + AIModel( | |
| 261 | + id: "claude-fable-5", provider: .anthropic, displayName: "Claude Fable 5", | |
| 262 | + contextWindow: 1_000_000, maxOutputTokens: 128_000, | |
| 263 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 264 | + pricing: ModelPricing(inputPerMTok: 10.00, outputPerMTok: 50.00), | |
| 265 | + // Thinking is always on and cannot be disabled — no toggle. | |
| 266 | + parameterSupport: ParameterSupport(temperature: false, topP: false, thinkingToggle: false) | |
| 267 | + ), | |
| 268 | + AIModel( | |
| 269 | + id: "claude-opus-4-8", provider: .anthropic, displayName: "Claude Opus 4.8", | |
| 270 | + contextWindow: 1_000_000, maxOutputTokens: 128_000, | |
| 271 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 272 | + pricing: ModelPricing(inputPerMTok: 5.00, outputPerMTok: 25.00), | |
| 273 | + parameterSupport: ParameterSupport(temperature: false, topP: false, thinkingToggle: true) | |
| 274 | + ), | |
| 275 | + AIModel( | |
| 276 | + id: "claude-opus-4-7", provider: .anthropic, displayName: "Claude Opus 4.7", | |
| 277 | + contextWindow: 1_000_000, maxOutputTokens: 128_000, | |
| 278 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 279 | + pricing: ModelPricing(inputPerMTok: 5.00, outputPerMTok: 25.00), | |
| 280 | + parameterSupport: ParameterSupport(temperature: false, topP: false, thinkingToggle: true) | |
| 281 | + ), | |
| 282 | + AIModel( | |
| 283 | + id: "claude-opus-4-6", provider: .anthropic, displayName: "Claude Opus 4.6", | |
| 284 | + contextWindow: 1_000_000, maxOutputTokens: 128_000, | |
| 285 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 286 | + pricing: ModelPricing(inputPerMTok: 5.00, outputPerMTok: 25.00), | |
| 287 | + parameterSupport: ParameterSupport(temperature: true, topP: true, thinkingToggle: true) | |
| 288 | + ), | |
| 289 | + AIModel( | |
| 290 | + id: "claude-sonnet-4-6", provider: .anthropic, displayName: "Claude Sonnet 4.6", | |
| 291 | + contextWindow: 1_000_000, maxOutputTokens: 128_000, | |
| 292 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 293 | + pricing: ModelPricing(inputPerMTok: 3.00, outputPerMTok: 15.00), | |
| 294 | + parameterSupport: ParameterSupport(temperature: true, topP: true, thinkingToggle: true) | |
| 295 | + ), | |
| 296 | + AIModel( | |
| 297 | + id: "claude-haiku-4-5-20251001", provider: .anthropic, displayName: "Claude Haiku 4.5", | |
| 298 | + contextWindow: 200_000, maxOutputTokens: 64_000, | |
| 299 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 300 | + pricing: ModelPricing(inputPerMTok: 1.00, outputPerMTok: 5.00), | |
| 301 | + parameterSupport: ParameterSupport(temperature: true, topP: true, thinkingToggle: true) | |
| 302 | + ), | |
| 303 | + AIModel( | |
| 304 | + id: "claude-opus-4-5-20251101", provider: .anthropic, displayName: "Claude Opus 4.5", | |
| 305 | + contextWindow: 200_000, maxOutputTokens: 64_000, | |
| 306 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 307 | + pricing: ModelPricing(inputPerMTok: 5.00, outputPerMTok: 25.00), | |
| 308 | + parameterSupport: ParameterSupport(temperature: true, topP: true, thinkingToggle: true), | |
| 309 | + isLegacy: true | |
| 310 | + ), | |
| 311 | + AIModel( | |
| 312 | + id: "claude-sonnet-4-5-20250929", provider: .anthropic, displayName: "Claude Sonnet 4.5", | |
| 313 | + contextWindow: 1_000_000, maxOutputTokens: 64_000, | |
| 314 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 315 | + pricing: ModelPricing(inputPerMTok: 3.00, outputPerMTok: 15.00), | |
| 316 | + parameterSupport: ParameterSupport(temperature: true, topP: true, thinkingToggle: true), | |
| 317 | + isLegacy: true | |
| 318 | + ), | |
| 319 | + AIModel( | |
| 320 | + id: "claude-opus-4-1-20250805", provider: .anthropic, displayName: "Claude Opus 4.1", | |
| 321 | + contextWindow: 200_000, maxOutputTokens: 32_000, | |
| 322 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 323 | + pricing: ModelPricing(inputPerMTok: 15.00, outputPerMTok: 75.00), | |
| 324 | + parameterSupport: ParameterSupport(temperature: true, topP: true, thinkingToggle: true), | |
| 325 | + isLegacy: true | |
| 326 | + ), | |
| 327 | + ] | |
| 328 | + | |
| 329 | + // MARK: - xAI (Grok) | |
| 330 | + | |
| 331 | + static let xai: [AIModel] = [ | |
| 332 | + AIModel( | |
| 333 | + id: "grok-4.5", provider: .xai, displayName: "Grok 4.5", | |
| 334 | + contextWindow: 500_000, maxOutputTokens: nil, | |
| 335 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 336 | + pricing: ModelPricing(inputPerMTok: 2.00, outputPerMTok: 6.00), | |
| 337 | + parameterSupport: ParameterSupport(reasoningEffort: true), | |
| 338 | + isRecommended: true | |
| 339 | + ), | |
| 340 | + AIModel( | |
| 341 | + id: "grok-4.3", provider: .xai, displayName: "Grok 4.3", | |
| 342 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 343 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 344 | + pricing: ModelPricing(inputPerMTok: 1.25, outputPerMTok: 2.50), | |
| 345 | + parameterSupport: ParameterSupport(reasoningEffort: true) | |
| 346 | + ), | |
| 347 | + AIModel( | |
| 348 | + id: "grok-4.20", provider: .xai, displayName: "Grok 4.20 Reasoning", | |
| 349 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 350 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 351 | + pricing: ModelPricing(inputPerMTok: 1.25, outputPerMTok: 2.50), | |
| 352 | + parameterSupport: ParameterSupport() | |
| 353 | + ), | |
| 354 | + AIModel( | |
| 355 | + id: "grok-4.20-non-reasoning", provider: .xai, displayName: "Grok 4.20 Non-Reasoning", | |
| 356 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 357 | + capabilities: ModelCapabilities(vision: true, tools: true, jsonMode: true), | |
| 358 | + pricing: ModelPricing(inputPerMTok: 1.25, outputPerMTok: 2.50), | |
| 359 | + parameterSupport: ParameterSupport() | |
| 360 | + ), | |
| 361 | + AIModel( | |
| 362 | + id: "grok-code-fast-1", provider: .xai, displayName: "Grok Code Fast 1", | |
| 363 | + contextWindow: 256_000, maxOutputTokens: nil, | |
| 364 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 365 | + pricing: ModelPricing(inputPerMTok: 1.00, outputPerMTok: 2.00), | |
| 366 | + parameterSupport: ParameterSupport(), | |
| 367 | + isRecommended: true | |
| 368 | + ), | |
| 369 | + ] | |
| 370 | + | |
| 371 | + // MARK: - Mistral | |
| 372 | + | |
| 373 | + static let mistral: [AIModel] = [ | |
| 374 | + AIModel( | |
| 375 | + id: "mistral-medium-latest", provider: .mistral, displayName: "Mistral Medium 3.5", | |
| 376 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 377 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 378 | + pricing: ModelPricing(inputPerMTok: 1.50, outputPerMTok: 7.50), | |
| 379 | + parameterSupport: ParameterSupport(frequencyPenalty: true, presencePenalty: true, reasoningEffort: true), | |
| 380 | + isRecommended: true | |
| 381 | + ), | |
| 382 | + AIModel( | |
| 383 | + id: "mistral-large-latest", provider: .mistral, displayName: "Mistral Large 3", | |
| 384 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 385 | + capabilities: ModelCapabilities(vision: true, tools: true, jsonMode: true), | |
| 386 | + pricing: ModelPricing(inputPerMTok: 0.50, outputPerMTok: 1.50), | |
| 387 | + parameterSupport: .openAIDefault, | |
| 388 | + isRecommended: true | |
| 389 | + ), | |
| 390 | + AIModel( | |
| 391 | + id: "mistral-small-latest", provider: .mistral, displayName: "Mistral Small 4", | |
| 392 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 393 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 394 | + pricing: ModelPricing(inputPerMTok: 0.15, outputPerMTok: 0.60), | |
| 395 | + parameterSupport: ParameterSupport(frequencyPenalty: true, presencePenalty: true, reasoningEffort: true), | |
| 396 | + isRecommended: true | |
| 397 | + ), | |
| 398 | + AIModel( | |
| 399 | + id: "codestral-latest", provider: .mistral, displayName: "Codestral", | |
| 400 | + contextWindow: 256_000, maxOutputTokens: nil, | |
| 401 | + capabilities: ModelCapabilities(tools: true, jsonMode: true), | |
| 402 | + pricing: ModelPricing(inputPerMTok: 0.30, outputPerMTok: 0.90), | |
| 403 | + parameterSupport: .openAIDefault | |
| 404 | + ), | |
| 405 | + AIModel( | |
| 406 | + id: "ministral-14b-latest", provider: .mistral, displayName: "Ministral 3 14B", | |
| 407 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 408 | + capabilities: ModelCapabilities(vision: true, tools: true, jsonMode: true), | |
| 409 | + pricing: ModelPricing(inputPerMTok: 0.20, outputPerMTok: 0.20), | |
| 410 | + parameterSupport: .openAIDefault | |
| 411 | + ), | |
| 412 | + AIModel( | |
| 413 | + id: "ministral-8b-latest", provider: .mistral, displayName: "Ministral 3 8B", | |
| 414 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 415 | + capabilities: ModelCapabilities(vision: true, tools: true, jsonMode: true), | |
| 416 | + pricing: ModelPricing(inputPerMTok: 0.15, outputPerMTok: 0.15), | |
| 417 | + parameterSupport: .openAIDefault | |
| 418 | + ), | |
| 419 | + AIModel( | |
| 420 | + id: "ministral-3b-latest", provider: .mistral, displayName: "Ministral 3 3B", | |
| 421 | + contextWindow: 131_072, maxOutputTokens: nil, | |
| 422 | + capabilities: ModelCapabilities(vision: true, tools: true, jsonMode: true), | |
| 423 | + pricing: ModelPricing(inputPerMTok: 0.10, outputPerMTok: 0.10), | |
| 424 | + parameterSupport: .openAIDefault | |
| 425 | + ), | |
| 426 | + // Legacy / deprecated (still served; hidden by default) | |
| 427 | + AIModel( | |
| 428 | + id: "magistral-medium-latest", provider: .mistral, displayName: "Magistral Medium", | |
| 429 | + contextWindow: 131_072, maxOutputTokens: nil, | |
| 430 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 431 | + pricing: ModelPricing(inputPerMTok: 2.00, outputPerMTok: 5.00), | |
| 432 | + parameterSupport: .openAIDefault, | |
| 433 | + isLegacy: true | |
| 434 | + ), | |
| 435 | + AIModel( | |
| 436 | + id: "devstral-latest", provider: .mistral, displayName: "Devstral 2", | |
| 437 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 438 | + capabilities: ModelCapabilities(tools: true, jsonMode: true), | |
| 439 | + pricing: ModelPricing(inputPerMTok: 0.40, outputPerMTok: 2.00), | |
| 440 | + parameterSupport: .openAIDefault, | |
| 441 | + isLegacy: true | |
| 442 | + ), | |
| 443 | + AIModel( | |
| 444 | + id: "open-mistral-nemo", provider: .mistral, displayName: "Mistral Nemo", | |
| 445 | + contextWindow: 131_072, maxOutputTokens: nil, | |
| 446 | + capabilities: ModelCapabilities(tools: true, jsonMode: true), | |
| 447 | + pricing: ModelPricing(inputPerMTok: 0.15, outputPerMTok: 0.15), | |
| 448 | + parameterSupport: .openAIDefault, | |
| 449 | + isLegacy: true | |
| 450 | + ), | |
| 451 | + ] | |
| 452 | + | |
| 453 | + // MARK: - Google Gemini | |
| 454 | + | |
| 455 | + static let gemini: [AIModel] = [ | |
| 456 | + AIModel( | |
| 457 | + id: "gemini-3.6-flash", provider: .gemini, displayName: "Gemini 3.6 Flash", | |
| 458 | + contextWindow: 1_048_576, maxOutputTokens: 65_536, | |
| 459 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 460 | + pricing: ModelPricing(inputPerMTok: 1.50, outputPerMTok: 7.50), | |
| 461 | + parameterSupport: ParameterSupport(reasoningEffort: true), | |
| 462 | + isRecommended: true | |
| 463 | + ), | |
| 464 | + AIModel( | |
| 465 | + id: "gemini-3.5-flash", provider: .gemini, displayName: "Gemini 3.5 Flash", | |
| 466 | + contextWindow: 1_048_576, maxOutputTokens: 65_536, | |
| 467 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 468 | + pricing: ModelPricing(inputPerMTok: 1.50, outputPerMTok: 9.00), | |
| 469 | + parameterSupport: ParameterSupport(reasoningEffort: true) | |
| 470 | + ), | |
| 471 | + AIModel( | |
| 472 | + id: "gemini-3.5-flash-lite", provider: .gemini, displayName: "Gemini 3.5 Flash-Lite", | |
| 473 | + contextWindow: 1_048_576, maxOutputTokens: 65_536, | |
| 474 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 475 | + pricing: ModelPricing(inputPerMTok: 0.30, outputPerMTok: 2.50), | |
| 476 | + parameterSupport: ParameterSupport(reasoningEffort: true), | |
| 477 | + isRecommended: true | |
| 478 | + ), | |
| 479 | + AIModel( | |
| 480 | + id: "gemini-3.1-pro-preview", provider: .gemini, displayName: "Gemini 3.1 Pro (Preview)", | |
| 481 | + contextWindow: 1_048_576, maxOutputTokens: 65_536, | |
| 482 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 483 | + pricing: ModelPricing(inputPerMTok: 2.00, outputPerMTok: 12.00), | |
| 484 | + parameterSupport: ParameterSupport(reasoningEffort: true), | |
| 485 | + isRecommended: true | |
| 486 | + ), | |
| 487 | + AIModel( | |
| 488 | + id: "gemini-3.1-flash-lite", provider: .gemini, displayName: "Gemini 3.1 Flash-Lite", | |
| 489 | + contextWindow: 1_048_576, maxOutputTokens: 65_536, | |
| 490 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 491 | + pricing: ModelPricing(inputPerMTok: 0.25, outputPerMTok: 1.50), | |
| 492 | + parameterSupport: ParameterSupport(reasoningEffort: true) | |
| 493 | + ), | |
| 494 | + AIModel( | |
| 495 | + id: "gemini-2.5-pro", provider: .gemini, displayName: "Gemini 2.5 Pro", | |
| 496 | + contextWindow: 1_048_576, maxOutputTokens: 65_536, | |
| 497 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 498 | + pricing: ModelPricing(inputPerMTok: 1.25, outputPerMTok: 10.00), | |
| 499 | + parameterSupport: ParameterSupport(reasoningEffort: true) | |
| 500 | + ), | |
| 501 | + AIModel( | |
| 502 | + id: "gemini-2.5-flash", provider: .gemini, displayName: "Gemini 2.5 Flash", | |
| 503 | + contextWindow: 1_048_576, maxOutputTokens: 65_536, | |
| 504 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 505 | + pricing: ModelPricing(inputPerMTok: 0.30, outputPerMTok: 2.50), | |
| 506 | + parameterSupport: ParameterSupport(reasoningEffort: true) | |
| 507 | + ), | |
| 508 | + AIModel( | |
| 509 | + id: "gemini-2.5-flash-lite", provider: .gemini, displayName: "Gemini 2.5 Flash-Lite", | |
| 510 | + contextWindow: 1_048_576, maxOutputTokens: 65_536, | |
| 511 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 512 | + pricing: ModelPricing(inputPerMTok: 0.10, outputPerMTok: 0.40), | |
| 513 | + parameterSupport: ParameterSupport(reasoningEffort: true) | |
| 514 | + ), | |
| 515 | + // Rolling aliases (auto-track the latest release; pricing varies with target) | |
| 516 | + AIModel( | |
| 517 | + id: "gemini-pro-latest", provider: .gemini, displayName: "Gemini Pro (Latest)", | |
| 518 | + contextWindow: 1_048_576, maxOutputTokens: 65_536, | |
| 519 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 520 | + pricing: nil, | |
| 521 | + parameterSupport: ParameterSupport(reasoningEffort: true) | |
| 522 | + ), | |
| 523 | + AIModel( | |
| 524 | + id: "gemini-flash-latest", provider: .gemini, displayName: "Gemini Flash (Latest)", | |
| 525 | + contextWindow: 1_048_576, maxOutputTokens: 65_536, | |
| 526 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 527 | + pricing: nil, | |
| 528 | + parameterSupport: ParameterSupport(reasoningEffort: true) | |
| 529 | + ), | |
| 530 | + AIModel( | |
| 531 | + id: "gemini-flash-lite-latest", provider: .gemini, displayName: "Gemini Flash-Lite (Latest)", | |
| 532 | + contextWindow: 1_048_576, maxOutputTokens: 65_536, | |
| 533 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 534 | + pricing: nil, | |
| 535 | + parameterSupport: ParameterSupport(reasoningEffort: true) | |
| 536 | + ), | |
| 537 | + // Preview / secondary | |
| 538 | + AIModel( | |
| 539 | + id: "gemini-3-flash-preview", provider: .gemini, displayName: "Gemini 3 Flash (Preview)", | |
| 540 | + contextWindow: 1_048_576, maxOutputTokens: 65_536, | |
| 541 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 542 | + pricing: ModelPricing(inputPerMTok: 0.50, outputPerMTok: 3.00), | |
| 543 | + parameterSupport: ParameterSupport(reasoningEffort: true) | |
| 544 | + ), | |
| 545 | + AIModel( | |
| 546 | + id: "gemma-4-26b-a4b-it", provider: .gemini, displayName: "Gemma 4 26B", | |
| 547 | + contextWindow: 262_144, maxOutputTokens: 32_768, | |
| 548 | + capabilities: ModelCapabilities(jsonMode: true), | |
| 549 | + pricing: nil, | |
| 550 | + parameterSupport: ParameterSupport() | |
| 551 | + ), | |
| 552 | + AIModel( | |
| 553 | + id: "gemma-4-31b-it", provider: .gemini, displayName: "Gemma 4 31B", | |
| 554 | + contextWindow: 262_144, maxOutputTokens: 32_768, | |
| 555 | + capabilities: ModelCapabilities(jsonMode: true), | |
| 556 | + pricing: nil, | |
| 557 | + parameterSupport: ParameterSupport() | |
| 558 | + ), | |
| 559 | + ] | |
| 560 | + | |
| 561 | + // MARK: - Alibaba Qwen (DashScope) | |
| 562 | + | |
| 563 | + static let qwen: [AIModel] = [ | |
| 564 | + // Flagship commercial | |
| 565 | + AIModel( | |
| 566 | + id: "qwen3.7-max", provider: .qwen, displayName: "Qwen3.7 Max", | |
| 567 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 568 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 569 | + pricing: ModelPricing(inputPerMTok: 2.50, outputPerMTok: 7.50), | |
| 570 | + parameterSupport: ParameterSupport(thinkingToggle: true), | |
| 571 | + isRecommended: true | |
| 572 | + ), | |
| 573 | + AIModel( | |
| 574 | + id: "qwen3.7-plus", provider: .qwen, displayName: "Qwen3.7 Plus", | |
| 575 | + contextWindow: 1_000_000, maxOutputTokens: 65_536, | |
| 576 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 577 | + pricing: ModelPricing(inputPerMTok: 0.32, outputPerMTok: 1.28), | |
| 578 | + parameterSupport: ParameterSupport(thinkingToggle: true), | |
| 579 | + isRecommended: true | |
| 580 | + ), | |
| 581 | + AIModel( | |
| 582 | + id: "qwen3.7-flash", provider: .qwen, displayName: "Qwen3.7 Flash", | |
| 583 | + contextWindow: 1_000_000, maxOutputTokens: 65_536, | |
| 584 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 585 | + pricing: ModelPricing(inputPerMTok: 0.03, outputPerMTok: 0.13), | |
| 586 | + parameterSupport: ParameterSupport(thinkingToggle: true), | |
| 587 | + isRecommended: true | |
| 588 | + ), | |
| 589 | + AIModel( | |
| 590 | + id: "qwen3.6-plus", provider: .qwen, displayName: "Qwen3.6 Plus", | |
| 591 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 592 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 593 | + pricing: nil, | |
| 594 | + parameterSupport: ParameterSupport(thinkingToggle: true) | |
| 595 | + ), | |
| 596 | + AIModel( | |
| 597 | + id: "qwen3.6-flash", provider: .qwen, displayName: "Qwen3.6 Flash", | |
| 598 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 599 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 600 | + pricing: nil, | |
| 601 | + parameterSupport: ParameterSupport(thinkingToggle: true) | |
| 602 | + ), | |
| 603 | + AIModel( | |
| 604 | + id: "qwen3.5-plus", provider: .qwen, displayName: "Qwen3.5 Plus", | |
| 605 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 606 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 607 | + pricing: nil, | |
| 608 | + parameterSupport: ParameterSupport(thinkingToggle: true) | |
| 609 | + ), | |
| 610 | + AIModel( | |
| 611 | + id: "qwen3.5-flash", provider: .qwen, displayName: "Qwen3.5 Flash", | |
| 612 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 613 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 614 | + pricing: nil, | |
| 615 | + parameterSupport: ParameterSupport(thinkingToggle: true) | |
| 616 | + ), | |
| 617 | + // Stable aliases (previous-gen commercial) | |
| 618 | + AIModel( | |
| 619 | + id: "qwen-max", provider: .qwen, displayName: "Qwen Max", | |
| 620 | + contextWindow: 128_000, maxOutputTokens: nil, | |
| 621 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 622 | + pricing: nil, | |
| 623 | + parameterSupport: ParameterSupport(thinkingToggle: true) | |
| 624 | + ), | |
| 625 | + AIModel( | |
| 626 | + id: "qwen-plus", provider: .qwen, displayName: "Qwen Plus", | |
| 627 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 628 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 629 | + pricing: nil, | |
| 630 | + parameterSupport: ParameterSupport(thinkingToggle: true) | |
| 631 | + ), | |
| 632 | + AIModel( | |
| 633 | + id: "qwen-turbo", provider: .qwen, displayName: "Qwen Turbo", | |
| 634 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 635 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 636 | + pricing: nil, | |
| 637 | + parameterSupport: ParameterSupport(thinkingToggle: true), | |
| 638 | + isLegacy: true | |
| 639 | + ), | |
| 640 | + AIModel( | |
| 641 | + id: "qwen-flash", provider: .qwen, displayName: "Qwen Flash", | |
| 642 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 643 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 644 | + pricing: nil, | |
| 645 | + parameterSupport: ParameterSupport(thinkingToggle: true) | |
| 646 | + ), | |
| 647 | + // Coder family | |
| 648 | + AIModel( | |
| 649 | + id: "qwen3-coder-plus", provider: .qwen, displayName: "Qwen3 Coder Plus", | |
| 650 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 651 | + capabilities: ModelCapabilities(tools: true, jsonMode: true), | |
| 652 | + pricing: nil, | |
| 653 | + parameterSupport: ParameterSupport() | |
| 654 | + ), | |
| 655 | + AIModel( | |
| 656 | + id: "qwen3-coder-flash", provider: .qwen, displayName: "Qwen3 Coder Flash", | |
| 657 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 658 | + capabilities: ModelCapabilities(tools: true, jsonMode: true), | |
| 659 | + pricing: nil, | |
| 660 | + parameterSupport: ParameterSupport() | |
| 661 | + ), | |
| 662 | + AIModel( | |
| 663 | + id: "qwen3-coder-next", provider: .qwen, displayName: "Qwen3 Coder Next", | |
| 664 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 665 | + capabilities: ModelCapabilities(tools: true, jsonMode: true), | |
| 666 | + pricing: nil, | |
| 667 | + parameterSupport: ParameterSupport() | |
| 668 | + ), | |
| 669 | + AIModel( | |
| 670 | + id: "qwen3-coder-480b-a35b-instruct", provider: .qwen, displayName: "Qwen3 Coder 480B A35B", | |
| 671 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 672 | + capabilities: ModelCapabilities(tools: true, jsonMode: true), | |
| 673 | + pricing: nil, | |
| 674 | + parameterSupport: ParameterSupport() | |
| 675 | + ), | |
| 676 | + // Vision-language | |
| 677 | + AIModel( | |
| 678 | + id: "qwen3-vl-plus", provider: .qwen, displayName: "Qwen3 VL Plus", | |
| 679 | + contextWindow: 1_000_000, maxOutputTokens: 65_536, | |
| 680 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 681 | + pricing: nil, | |
| 682 | + parameterSupport: ParameterSupport(thinkingToggle: true) | |
| 683 | + ), | |
| 684 | + AIModel( | |
| 685 | + id: "qwen3-vl-flash", provider: .qwen, displayName: "Qwen3 VL Flash", | |
| 686 | + contextWindow: 1_000_000, maxOutputTokens: 65_536, | |
| 687 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 688 | + pricing: nil, | |
| 689 | + parameterSupport: ParameterSupport(thinkingToggle: true) | |
| 690 | + ), | |
| 691 | + AIModel( | |
| 692 | + id: "qwen3-vl-235b-a22b-instruct", provider: .qwen, displayName: "Qwen3 VL 235B Instruct", | |
| 693 | + contextWindow: 131_072, maxOutputTokens: nil, | |
| 694 | + capabilities: ModelCapabilities(vision: true, tools: true, jsonMode: true), | |
| 695 | + pricing: nil, | |
| 696 | + parameterSupport: ParameterSupport() | |
| 697 | + ), | |
| 698 | + AIModel( | |
| 699 | + id: "qwen3-vl-235b-a22b-thinking", provider: .qwen, displayName: "Qwen3 VL 235B Thinking", | |
| 700 | + contextWindow: 131_072, maxOutputTokens: nil, | |
| 701 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 702 | + pricing: nil, | |
| 703 | + parameterSupport: ParameterSupport() | |
| 704 | + ), | |
| 705 | + AIModel( | |
| 706 | + id: "qvq-max", provider: .qwen, displayName: "QVQ Max", | |
| 707 | + contextWindow: 131_072, maxOutputTokens: nil, | |
| 708 | + capabilities: ModelCapabilities(vision: true, reasoning: true, jsonMode: true), | |
| 709 | + pricing: nil, | |
| 710 | + parameterSupport: ParameterSupport(requiresStreaming: true) | |
| 711 | + ), | |
| 712 | + // Reasoning-only | |
| 713 | + AIModel( | |
| 714 | + id: "qwq-plus", provider: .qwen, displayName: "QwQ Plus", | |
| 715 | + contextWindow: 131_072, maxOutputTokens: nil, | |
| 716 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 717 | + pricing: nil, | |
| 718 | + parameterSupport: ParameterSupport(requiresStreaming: true) | |
| 719 | + ), | |
| 720 | + // Open-weights Qwen hosted on DashScope | |
| 721 | + AIModel( | |
| 722 | + id: "qwen3.5-397b-a17b", provider: .qwen, displayName: "Qwen3.5 397B A17B", | |
| 723 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 724 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 725 | + pricing: nil, | |
| 726 | + parameterSupport: ParameterSupport(thinkingToggle: true) | |
| 727 | + ), | |
| 728 | + AIModel( | |
| 729 | + id: "qwen3.5-122b-a10b", provider: .qwen, displayName: "Qwen3.5 122B A10B", | |
| 730 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 731 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 732 | + pricing: nil, | |
| 733 | + parameterSupport: ParameterSupport(thinkingToggle: true) | |
| 734 | + ), | |
| 735 | + AIModel( | |
| 736 | + id: "qwen3.5-35b-a3b", provider: .qwen, displayName: "Qwen3.5 35B A3B", | |
| 737 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 738 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 739 | + pricing: nil, | |
| 740 | + parameterSupport: ParameterSupport(thinkingToggle: true) | |
| 741 | + ), | |
| 742 | + AIModel( | |
| 743 | + id: "qwen3-235b-a22b-instruct-2507", provider: .qwen, displayName: "Qwen3 235B Instruct 2507", | |
| 744 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 745 | + capabilities: ModelCapabilities(tools: true, jsonMode: true), | |
| 746 | + pricing: nil, | |
| 747 | + parameterSupport: ParameterSupport() | |
| 748 | + ), | |
| 749 | + AIModel( | |
| 750 | + id: "qwen3-235b-a22b-thinking-2507", provider: .qwen, displayName: "Qwen3 235B Thinking 2507", | |
| 751 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 752 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 753 | + pricing: nil, | |
| 754 | + parameterSupport: ParameterSupport() | |
| 755 | + ), | |
| 756 | + AIModel( | |
| 757 | + id: "qwen3-next-80b-a3b-instruct", provider: .qwen, displayName: "Qwen3 Next 80B Instruct", | |
| 758 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 759 | + capabilities: ModelCapabilities(tools: true, jsonMode: true), | |
| 760 | + pricing: nil, | |
| 761 | + parameterSupport: ParameterSupport() | |
| 762 | + ), | |
| 763 | + AIModel( | |
| 764 | + id: "qwen3-next-80b-a3b-thinking", provider: .qwen, displayName: "Qwen3 Next 80B Thinking", | |
| 765 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 766 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 767 | + pricing: nil, | |
| 768 | + parameterSupport: ParameterSupport() | |
| 769 | + ), | |
| 770 | + // Third-party models hosted on DashScope | |
| 771 | + AIModel( | |
| 772 | + id: "deepseek-v4-pro", provider: .qwen, displayName: "DeepSeek V4 Pro (DashScope)", | |
| 773 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 774 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 775 | + pricing: nil, | |
| 776 | + parameterSupport: ParameterSupport(thinkingToggle: true) | |
| 777 | + ), | |
| 778 | + AIModel( | |
| 779 | + id: "deepseek-v4-flash", provider: .qwen, displayName: "DeepSeek V4 Flash (DashScope)", | |
| 780 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 781 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 782 | + pricing: nil, | |
| 783 | + parameterSupport: ParameterSupport(thinkingToggle: true) | |
| 784 | + ), | |
| 785 | + AIModel( | |
| 786 | + id: "glm-5.2", provider: .qwen, displayName: "GLM 5.2 (DashScope)", | |
| 787 | + contextWindow: 198_000, maxOutputTokens: nil, | |
| 788 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 789 | + pricing: nil, | |
| 790 | + parameterSupport: ParameterSupport(thinkingToggle: true) | |
| 791 | + ), | |
| 792 | + AIModel( | |
| 793 | + id: "kimi-k2.7-code", provider: .qwen, displayName: "Kimi K2.7 Code (DashScope)", | |
| 794 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 795 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 796 | + pricing: nil, | |
| 797 | + parameterSupport: ParameterSupport(thinkingToggle: true) | |
| 798 | + ), | |
| 799 | + ] | |
| 800 | + | |
| 801 | + // MARK: - DeepSeek | |
| 802 | + | |
| 803 | + static let deepseek: [AIModel] = [ | |
| 804 | + AIModel( | |
| 805 | + id: "deepseek-v4-flash", provider: .deepseek, displayName: "DeepSeek V4 Flash", | |
| 806 | + contextWindow: 1_000_000, maxOutputTokens: 384_000, | |
| 807 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 808 | + pricing: ModelPricing(inputPerMTok: 0.14, outputPerMTok: 0.28), | |
| 809 | + parameterSupport: ParameterSupport(reasoningEffort: true, thinkingToggle: true), | |
| 810 | + isRecommended: true | |
| 811 | + ), | |
| 812 | + AIModel( | |
| 813 | + id: "deepseek-v4-pro", provider: .deepseek, displayName: "DeepSeek V4 Pro", | |
| 814 | + contextWindow: 1_000_000, maxOutputTokens: 384_000, | |
| 815 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 816 | + pricing: ModelPricing(inputPerMTok: 0.435, outputPerMTok: 0.87), | |
| 817 | + parameterSupport: ParameterSupport(reasoningEffort: true, thinkingToggle: true), | |
| 818 | + isRecommended: true | |
| 819 | + ), | |
| 820 | + ] | |
| 821 | + | |
| 822 | + // MARK: - Kimi (Moonshot AI) | |
| 823 | + | |
| 824 | + static let kimi: [AIModel] = [ | |
| 825 | + AIModel( | |
| 826 | + id: "kimi-k3", provider: .kimi, displayName: "Kimi K3", | |
| 827 | + contextWindow: 1_048_576, maxOutputTokens: 131_072, | |
| 828 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 829 | + pricing: ModelPricing(inputPerMTok: 3.00, outputPerMTok: 15.00), | |
| 830 | + // Thinking always on with preserved thinking; depth via reasoning_effort. | |
| 831 | + parameterSupport: ParameterSupport(temperature: false, topP: false, usesMaxCompletionTokens: true, reasoningEffort: true), | |
| 832 | + isRecommended: true | |
| 833 | + ), | |
| 834 | + AIModel( | |
| 835 | + id: "kimi-k2.7-code", provider: .kimi, displayName: "Kimi K2.7 Code", | |
| 836 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 837 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 838 | + pricing: ModelPricing(inputPerMTok: 0.95, outputPerMTok: 4.00), | |
| 839 | + parameterSupport: ParameterSupport(temperature: false, topP: false, usesMaxCompletionTokens: true), | |
| 840 | + isRecommended: true | |
| 841 | + ), | |
| 842 | + AIModel( | |
| 843 | + id: "kimi-k2.7-code-highspeed", provider: .kimi, displayName: "Kimi K2.7 Code Highspeed", | |
| 844 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 845 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 846 | + pricing: ModelPricing(inputPerMTok: 1.90, outputPerMTok: 8.00), | |
| 847 | + parameterSupport: ParameterSupport(temperature: false, topP: false, usesMaxCompletionTokens: true) | |
| 848 | + ), | |
| 849 | + AIModel( | |
| 850 | + id: "kimi-k2.6", provider: .kimi, displayName: "Kimi K2.6", | |
| 851 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 852 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 853 | + pricing: ModelPricing(inputPerMTok: 0.95, outputPerMTok: 4.00), | |
| 854 | + parameterSupport: ParameterSupport(temperature: false, topP: false, usesMaxCompletionTokens: true, thinkingToggle: true) | |
| 855 | + ), | |
| 856 | + AIModel( | |
| 857 | + id: "kimi-k2.5", provider: .kimi, displayName: "Kimi K2.5", | |
| 858 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 859 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 860 | + pricing: ModelPricing(inputPerMTok: 0.60, outputPerMTok: 3.00), | |
| 861 | + parameterSupport: ParameterSupport(temperature: false, topP: false, usesMaxCompletionTokens: true, thinkingToggle: true) | |
| 862 | + ), | |
| 863 | + // Legacy moonshot-v1 "classic" series (temperature capped at 1.0) | |
| 864 | + AIModel( | |
| 865 | + id: "moonshot-v1-8k", provider: .kimi, displayName: "Moonshot v1 8K", | |
| 866 | + contextWindow: 8_192, maxOutputTokens: nil, | |
| 867 | + capabilities: ModelCapabilities(tools: true, jsonMode: true), | |
| 868 | + pricing: ModelPricing(inputPerMTok: 0.20, outputPerMTok: 2.00), | |
| 869 | + parameterSupport: .openAIDefault, | |
| 870 | + isLegacy: true | |
| 871 | + ), | |
| 872 | + AIModel( | |
| 873 | + id: "moonshot-v1-32k", provider: .kimi, displayName: "Moonshot v1 32K", | |
| 874 | + contextWindow: 32_768, maxOutputTokens: nil, | |
| 875 | + capabilities: ModelCapabilities(tools: true, jsonMode: true), | |
| 876 | + pricing: ModelPricing(inputPerMTok: 1.00, outputPerMTok: 3.00), | |
| 877 | + parameterSupport: .openAIDefault, | |
| 878 | + isLegacy: true | |
| 879 | + ), | |
| 880 | + AIModel( | |
| 881 | + id: "moonshot-v1-128k", provider: .kimi, displayName: "Moonshot v1 128K", | |
| 882 | + contextWindow: 131_072, maxOutputTokens: nil, | |
| 883 | + capabilities: ModelCapabilities(tools: true, jsonMode: true), | |
| 884 | + pricing: ModelPricing(inputPerMTok: 2.00, outputPerMTok: 5.00), | |
| 885 | + parameterSupport: .openAIDefault, | |
| 886 | + isLegacy: true | |
| 887 | + ), | |
| 888 | + AIModel( | |
| 889 | + id: "moonshot-v1-auto", provider: .kimi, displayName: "Moonshot v1 Auto", | |
| 890 | + contextWindow: 131_072, maxOutputTokens: nil, | |
| 891 | + capabilities: ModelCapabilities(tools: true, jsonMode: true), | |
| 892 | + pricing: nil, | |
| 893 | + parameterSupport: .openAIDefault, | |
| 894 | + isLegacy: true | |
| 895 | + ), | |
| 896 | + AIModel( | |
| 897 | + id: "moonshot-v1-8k-vision-preview", provider: .kimi, displayName: "Moonshot v1 8K Vision", | |
| 898 | + contextWindow: 8_192, maxOutputTokens: nil, | |
| 899 | + capabilities: ModelCapabilities(vision: true, tools: true, jsonMode: true), | |
| 900 | + pricing: ModelPricing(inputPerMTok: 0.20, outputPerMTok: 2.00), | |
| 901 | + parameterSupport: .openAIDefault, | |
| 902 | + isLegacy: true | |
| 903 | + ), | |
| 904 | + AIModel( | |
| 905 | + id: "moonshot-v1-32k-vision-preview", provider: .kimi, displayName: "Moonshot v1 32K Vision", | |
| 906 | + contextWindow: 32_768, maxOutputTokens: nil, | |
| 907 | + capabilities: ModelCapabilities(vision: true, tools: true, jsonMode: true), | |
| 908 | + pricing: ModelPricing(inputPerMTok: 1.00, outputPerMTok: 3.00), | |
| 909 | + parameterSupport: .openAIDefault, | |
| 910 | + isLegacy: true | |
| 911 | + ), | |
| 912 | + AIModel( | |
| 913 | + id: "moonshot-v1-128k-vision-preview", provider: .kimi, displayName: "Moonshot v1 128K Vision", | |
| 914 | + contextWindow: 131_072, maxOutputTokens: nil, | |
| 915 | + capabilities: ModelCapabilities(vision: true, tools: true, jsonMode: true), | |
| 916 | + pricing: ModelPricing(inputPerMTok: 2.00, outputPerMTok: 5.00), | |
| 917 | + parameterSupport: .openAIDefault, | |
| 918 | + isLegacy: true | |
| 919 | + ), | |
| 920 | + ] | |
| 921 | + | |
| 922 | + // MARK: - Perplexity | |
| 923 | + | |
| 924 | + static let perplexity: [AIModel] = [ | |
| 925 | + AIModel( | |
| 926 | + id: "sonar", provider: .perplexity, displayName: "Sonar", | |
| 927 | + contextWindow: 128_000, maxOutputTokens: 128_000, | |
| 928 | + capabilities: ModelCapabilities(jsonMode: true, citations: true), | |
| 929 | + pricing: ModelPricing(inputPerMTok: 1.00, outputPerMTok: 1.00), | |
| 930 | + parameterSupport: ParameterSupport(), | |
| 931 | + isRecommended: true | |
| 932 | + ), | |
| 933 | + AIModel( | |
| 934 | + id: "sonar-pro", provider: .perplexity, displayName: "Sonar Pro", | |
| 935 | + contextWindow: 200_000, maxOutputTokens: 8_000, | |
| 936 | + capabilities: ModelCapabilities(jsonMode: true, citations: true), | |
| 937 | + pricing: ModelPricing(inputPerMTok: 3.00, outputPerMTok: 15.00), | |
| 938 | + parameterSupport: ParameterSupport(), | |
| 939 | + isRecommended: true | |
| 940 | + ), | |
| 941 | + AIModel( | |
| 942 | + id: "sonar-reasoning-pro", provider: .perplexity, displayName: "Sonar Reasoning Pro", | |
| 943 | + contextWindow: 128_000, maxOutputTokens: nil, | |
| 944 | + capabilities: ModelCapabilities(reasoning: true, jsonMode: true, citations: true), | |
| 945 | + pricing: ModelPricing(inputPerMTok: 2.00, outputPerMTok: 8.00), | |
| 946 | + parameterSupport: ParameterSupport(reasoningEffort: true) | |
| 947 | + ), | |
| 948 | + AIModel( | |
| 949 | + id: "sonar-deep-research", provider: .perplexity, displayName: "Sonar Deep Research", | |
| 950 | + contextWindow: 128_000, maxOutputTokens: nil, | |
| 951 | + capabilities: ModelCapabilities(reasoning: true, jsonMode: true, citations: true), | |
| 952 | + pricing: ModelPricing(inputPerMTok: 2.00, outputPerMTok: 8.00), | |
| 953 | + parameterSupport: ParameterSupport(reasoningEffort: true) | |
| 954 | + ), | |
| 955 | + ] | |
| 956 | + | |
| 957 | + // MARK: - Together AI | |
| 958 | + | |
| 959 | + static let together: [AIModel] = [ | |
| 960 | + AIModel( | |
| 961 | + id: "moonshotai/Kimi-K3", provider: .together, displayName: "Kimi K3", | |
| 962 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 963 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 964 | + pricing: ModelPricing(inputPerMTok: 3.00, outputPerMTok: 15.00), | |
| 965 | + parameterSupport: .openAIDefault, | |
| 966 | + isRecommended: true | |
| 967 | + ), | |
| 968 | + AIModel( | |
| 969 | + id: "moonshotai/Kimi-K2.7-Code", provider: .together, displayName: "Kimi K2.7 Code", | |
| 970 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 971 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 972 | + pricing: ModelPricing(inputPerMTok: 0.95, outputPerMTok: 4.00), | |
| 973 | + parameterSupport: .openAIDefault | |
| 974 | + ), | |
| 975 | + AIModel( | |
| 976 | + id: "moonshotai/Kimi-K2.6", provider: .together, displayName: "Kimi K2.6", | |
| 977 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 978 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 979 | + pricing: ModelPricing(inputPerMTok: 1.20, outputPerMTok: 4.50), | |
| 980 | + parameterSupport: .openAIDefault | |
| 981 | + ), | |
| 982 | + AIModel( | |
| 983 | + id: "deepseek-ai/DeepSeek-V4-Pro", provider: .together, displayName: "DeepSeek V4 Pro", | |
| 984 | + contextWindow: 512_000, maxOutputTokens: nil, | |
| 985 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 986 | + pricing: ModelPricing(inputPerMTok: 1.74, outputPerMTok: 3.48), | |
| 987 | + parameterSupport: .openAIDefault, | |
| 988 | + isRecommended: true | |
| 989 | + ), | |
| 990 | + AIModel( | |
| 991 | + id: "zai-org/GLM-5.2", provider: .together, displayName: "GLM 5.2", | |
| 992 | + contextWindow: 512_000, maxOutputTokens: nil, | |
| 993 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 994 | + pricing: ModelPricing(inputPerMTok: 1.40, outputPerMTok: 4.40), | |
| 995 | + parameterSupport: .openAIDefault | |
| 996 | + ), | |
| 997 | + AIModel( | |
| 998 | + id: "Qwen/Qwen3.7-Max", provider: .together, displayName: "Qwen3.7 Max", | |
| 999 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 1000 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 1001 | + pricing: ModelPricing(inputPerMTok: 1.25, outputPerMTok: 3.75), | |
| 1002 | + parameterSupport: ParameterSupport(frequencyPenalty: true, presencePenalty: true, requiresStreaming: true) | |
| 1003 | + ), | |
| 1004 | + AIModel( | |
| 1005 | + id: "Qwen/Qwen3.7-Plus", provider: .together, displayName: "Qwen3.7 Plus", | |
| 1006 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 1007 | + capabilities: ModelCapabilities(tools: true, jsonMode: true), | |
| 1008 | + pricing: ModelPricing(inputPerMTok: 0.32, outputPerMTok: 1.28), | |
| 1009 | + parameterSupport: ParameterSupport(frequencyPenalty: true, presencePenalty: true, requiresStreaming: true) | |
| 1010 | + ), | |
| 1011 | + AIModel( | |
| 1012 | + id: "Qwen/Qwen3.6-Plus", provider: .together, displayName: "Qwen3.6 Plus", | |
| 1013 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 1014 | + capabilities: ModelCapabilities(tools: true, jsonMode: true), | |
| 1015 | + pricing: ModelPricing(inputPerMTok: 0.50, outputPerMTok: 3.00), | |
| 1016 | + parameterSupport: ParameterSupport(frequencyPenalty: true, presencePenalty: true, requiresStreaming: true) | |
| 1017 | + ), | |
| 1018 | + AIModel( | |
| 1019 | + id: "Qwen/Qwen3.5-9B", provider: .together, displayName: "Qwen3.5 9B", | |
| 1020 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 1021 | + capabilities: ModelCapabilities(tools: true, jsonMode: true), | |
| 1022 | + pricing: ModelPricing(inputPerMTok: 0.17, outputPerMTok: 0.25), | |
| 1023 | + parameterSupport: ParameterSupport(frequencyPenalty: true, presencePenalty: true, requiresStreaming: true) | |
| 1024 | + ), | |
| 1025 | + AIModel( | |
| 1026 | + id: "meta-llama/Llama-3.3-70B-Instruct-Turbo", provider: .together, displayName: "Llama 3.3 70B Turbo", | |
| 1027 | + contextWindow: 131_072, maxOutputTokens: nil, | |
| 1028 | + capabilities: ModelCapabilities(tools: true, jsonMode: true), | |
| 1029 | + pricing: ModelPricing(inputPerMTok: 1.04, outputPerMTok: 1.04), | |
| 1030 | + parameterSupport: .openAIDefault | |
| 1031 | + ), | |
| 1032 | + AIModel( | |
| 1033 | + id: "openai/gpt-oss-120b", provider: .together, displayName: "GPT-OSS 120B", | |
| 1034 | + contextWindow: 131_072, maxOutputTokens: nil, | |
| 1035 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 1036 | + pricing: ModelPricing(inputPerMTok: 0.15, outputPerMTok: 0.60), | |
| 1037 | + parameterSupport: ParameterSupport(frequencyPenalty: true, presencePenalty: true, reasoningEffort: true), | |
| 1038 | + isRecommended: true | |
| 1039 | + ), | |
| 1040 | + AIModel( | |
| 1041 | + id: "openai/gpt-oss-20b", provider: .together, displayName: "GPT-OSS 20B", | |
| 1042 | + contextWindow: 131_072, maxOutputTokens: nil, | |
| 1043 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 1044 | + pricing: ModelPricing(inputPerMTok: 0.05, outputPerMTok: 0.20), | |
| 1045 | + parameterSupport: ParameterSupport(frequencyPenalty: true, presencePenalty: true, reasoningEffort: true) | |
| 1046 | + ), | |
| 1047 | + AIModel( | |
| 1048 | + id: "nvidia/nemotron-3-ultra-550b-a55b", provider: .together, displayName: "Nemotron 3 Ultra 550B", | |
| 1049 | + contextWindow: 512_288, maxOutputTokens: nil, | |
| 1050 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 1051 | + pricing: ModelPricing(inputPerMTok: 0.60, outputPerMTok: 3.60), | |
| 1052 | + parameterSupport: .openAIDefault | |
| 1053 | + ), | |
| 1054 | + AIModel( | |
| 1055 | + id: "MiniMaxAI/MiniMax-M3", provider: .together, displayName: "MiniMax M3", | |
| 1056 | + contextWindow: 524_288, maxOutputTokens: nil, | |
| 1057 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 1058 | + pricing: ModelPricing(inputPerMTok: 0.30, outputPerMTok: 1.20), | |
| 1059 | + parameterSupport: .openAIDefault | |
| 1060 | + ), | |
| 1061 | + AIModel( | |
| 1062 | + // vision disabled 2026-07-30: Together's endpoint accepts image | |
| 1063 | + // input but streams an empty answer (verified live) — text only. | |
| 1064 | + id: "google/gemma-4-31B-it", provider: .together, displayName: "Gemma 4 31B", | |
| 1065 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 1066 | + capabilities: ModelCapabilities(vision: false, tools: true, jsonMode: true), | |
| 1067 | + pricing: ModelPricing(inputPerMTok: 0.39, outputPerMTok: 0.97), | |
| 1068 | + parameterSupport: ParameterSupport(frequencyPenalty: true, presencePenalty: true, requiresStreaming: true) | |
| 1069 | + ), | |
| 1070 | + AIModel( | |
| 1071 | + id: "thinkingmachines/Inkling", provider: .together, displayName: "Inkling", | |
| 1072 | + contextWindow: 524_288, maxOutputTokens: nil, | |
| 1073 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 1074 | + pricing: ModelPricing(inputPerMTok: 1.00, outputPerMTok: 4.05), | |
| 1075 | + parameterSupport: .openAIDefault | |
| 1076 | + ), | |
| 1077 | + ] | |
| 1078 | + | |
| 1079 | + // MARK: - DeepInfra | |
| 1080 | + | |
| 1081 | + static let deepinfra: [AIModel] = [ | |
| 1082 | + // Proxied frontier models (Claude / Gemini under DeepInfra billing) | |
| 1083 | + AIModel( | |
| 1084 | + id: "anthropic/claude-fable-5", provider: .deepinfra, displayName: "Claude Fable 5", | |
| 1085 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 1086 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 1087 | + pricing: ModelPricing(inputPerMTok: 10.00, outputPerMTok: 50.00), | |
| 1088 | + parameterSupport: ParameterSupport() | |
| 1089 | + ), | |
| 1090 | + AIModel( | |
| 1091 | + id: "anthropic/claude-opus-5", provider: .deepinfra, displayName: "Claude Opus 5", | |
| 1092 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 1093 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 1094 | + pricing: ModelPricing(inputPerMTok: 5.00, outputPerMTok: 25.00), | |
| 1095 | + parameterSupport: ParameterSupport() | |
| 1096 | + ), | |
| 1097 | + AIModel( | |
| 1098 | + id: "anthropic/claude-sonnet-5", provider: .deepinfra, displayName: "Claude Sonnet 5", | |
| 1099 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 1100 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 1101 | + pricing: ModelPricing(inputPerMTok: 2.00, outputPerMTok: 10.00), | |
| 1102 | + parameterSupport: ParameterSupport() | |
| 1103 | + ), | |
| 1104 | + AIModel( | |
| 1105 | + id: "anthropic/claude-opus-4-8", provider: .deepinfra, displayName: "Claude Opus 4.8", | |
| 1106 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 1107 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 1108 | + pricing: ModelPricing(inputPerMTok: 5.00, outputPerMTok: 25.00), | |
| 1109 | + parameterSupport: ParameterSupport() | |
| 1110 | + ), | |
| 1111 | + AIModel( | |
| 1112 | + id: "anthropic/claude-haiku-4-5", provider: .deepinfra, displayName: "Claude Haiku 4.5", | |
| 1113 | + contextWindow: 200_000, maxOutputTokens: nil, | |
| 1114 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 1115 | + pricing: ModelPricing(inputPerMTok: 1.00, outputPerMTok: 5.00), | |
| 1116 | + parameterSupport: ParameterSupport() | |
| 1117 | + ), | |
| 1118 | + AIModel( | |
| 1119 | + id: "google/gemini-3.1-pro", provider: .deepinfra, displayName: "Gemini 3.1 Pro", | |
| 1120 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 1121 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 1122 | + pricing: ModelPricing(inputPerMTok: 2.00, outputPerMTok: 12.00), | |
| 1123 | + parameterSupport: ParameterSupport() | |
| 1124 | + ), | |
| 1125 | + AIModel( | |
| 1126 | + id: "google/gemini-3.5-flash", provider: .deepinfra, displayName: "Gemini 3.5 Flash", | |
| 1127 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 1128 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 1129 | + pricing: ModelPricing(inputPerMTok: 1.50, outputPerMTok: 9.00), | |
| 1130 | + parameterSupport: ParameterSupport() | |
| 1131 | + ), | |
| 1132 | + AIModel( | |
| 1133 | + id: "google/gemini-3.1-flash-lite", provider: .deepinfra, displayName: "Gemini 3.1 Flash-Lite", | |
| 1134 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 1135 | + capabilities: ModelCapabilities(vision: true, tools: true, jsonMode: true), | |
| 1136 | + pricing: ModelPricing(inputPerMTok: 0.25, outputPerMTok: 1.50), | |
| 1137 | + parameterSupport: ParameterSupport() | |
| 1138 | + ), | |
| 1139 | + AIModel( | |
| 1140 | + id: "google/gemini-2.5-pro", provider: .deepinfra, displayName: "Gemini 2.5 Pro", | |
| 1141 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 1142 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 1143 | + pricing: ModelPricing(inputPerMTok: 1.25, outputPerMTok: 10.00), | |
| 1144 | + parameterSupport: ParameterSupport() | |
| 1145 | + ), | |
| 1146 | + AIModel( | |
| 1147 | + id: "google/gemini-2.5-flash", provider: .deepinfra, displayName: "Gemini 2.5 Flash", | |
| 1148 | + contextWindow: 1_000_000, maxOutputTokens: nil, | |
| 1149 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 1150 | + pricing: ModelPricing(inputPerMTok: 0.30, outputPerMTok: 2.50), | |
| 1151 | + parameterSupport: ParameterSupport() | |
| 1152 | + ), | |
| 1153 | + // Open-weight chat models | |
| 1154 | + AIModel( | |
| 1155 | + id: "deepseek-ai/DeepSeek-V4-Pro", provider: .deepinfra, displayName: "DeepSeek V4 Pro", | |
| 1156 | + contextWindow: 1_048_576, maxOutputTokens: nil, | |
| 1157 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 1158 | + pricing: ModelPricing(inputPerMTok: 1.30, outputPerMTok: 2.60), | |
| 1159 | + parameterSupport: .openAIDefault, | |
| 1160 | + isRecommended: true | |
| 1161 | + ), | |
| 1162 | + AIModel( | |
| 1163 | + id: "deepseek-ai/DeepSeek-V4-Flash", provider: .deepinfra, displayName: "DeepSeek V4 Flash", | |
| 1164 | + contextWindow: 1_048_576, maxOutputTokens: nil, | |
| 1165 | + capabilities: ModelCapabilities(tools: true, jsonMode: true), | |
| 1166 | + pricing: ModelPricing(inputPerMTok: 0.09, outputPerMTok: 0.18), | |
| 1167 | + parameterSupport: .openAIDefault, | |
| 1168 | + isRecommended: true | |
| 1169 | + ), | |
| 1170 | + AIModel( | |
| 1171 | + id: "deepseek-ai/DeepSeek-V3.1", provider: .deepinfra, displayName: "DeepSeek V3.1", | |
| 1172 | + contextWindow: 163_840, maxOutputTokens: nil, | |
| 1173 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 1174 | + pricing: ModelPricing(inputPerMTok: 0.25, outputPerMTok: 0.95), | |
| 1175 | + parameterSupport: .openAIDefault | |
| 1176 | + ), | |
| 1177 | + AIModel( | |
| 1178 | + id: "deepseek-ai/DeepSeek-R1-0528", provider: .deepinfra, displayName: "DeepSeek R1 0528", | |
| 1179 | + contextWindow: 163_840, maxOutputTokens: nil, | |
| 1180 | + capabilities: ModelCapabilities(reasoning: true), | |
| 1181 | + pricing: ModelPricing(inputPerMTok: 0.50, outputPerMTok: 2.15), | |
| 1182 | + parameterSupport: .openAIDefault | |
| 1183 | + ), | |
| 1184 | + AIModel( | |
| 1185 | + id: "moonshotai/Kimi-K2.7-Code", provider: .deepinfra, displayName: "Kimi K2.7 Code", | |
| 1186 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 1187 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 1188 | + pricing: ModelPricing(inputPerMTok: 0.74, outputPerMTok: 3.50), | |
| 1189 | + parameterSupport: .openAIDefault | |
| 1190 | + ), | |
| 1191 | + AIModel( | |
| 1192 | + id: "moonshotai/Kimi-K2.6", provider: .deepinfra, displayName: "Kimi K2.6", | |
| 1193 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 1194 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 1195 | + pricing: ModelPricing(inputPerMTok: 0.75, outputPerMTok: 3.50), | |
| 1196 | + parameterSupport: .openAIDefault | |
| 1197 | + ), | |
| 1198 | + AIModel( | |
| 1199 | + id: "moonshotai/Kimi-K2.5", provider: .deepinfra, displayName: "Kimi K2.5", | |
| 1200 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 1201 | + capabilities: ModelCapabilities(tools: true, jsonMode: true), | |
| 1202 | + pricing: ModelPricing(inputPerMTok: 0.45, outputPerMTok: 2.25), | |
| 1203 | + parameterSupport: ParameterSupport(frequencyPenalty: true, presencePenalty: true, requiresStreaming: true) | |
| 1204 | + ), | |
| 1205 | + AIModel( | |
| 1206 | + id: "zai-org/GLM-5.2", provider: .deepinfra, displayName: "GLM 5.2", | |
| 1207 | + contextWindow: 1_048_576, maxOutputTokens: nil, | |
| 1208 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 1209 | + pricing: ModelPricing(inputPerMTok: 0.75, outputPerMTok: 2.40), | |
| 1210 | + parameterSupport: .openAIDefault, | |
| 1211 | + isRecommended: true | |
| 1212 | + ), | |
| 1213 | + AIModel( | |
| 1214 | + id: "zai-org/GLM-4.7", provider: .deepinfra, displayName: "GLM 4.7", | |
| 1215 | + contextWindow: 202_752, maxOutputTokens: nil, | |
| 1216 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 1217 | + pricing: ModelPricing(inputPerMTok: 0.40, outputPerMTok: 1.75), | |
| 1218 | + parameterSupport: .openAIDefault | |
| 1219 | + ), | |
| 1220 | + AIModel( | |
| 1221 | + id: "Qwen/Qwen3.7-Max", provider: .deepinfra, displayName: "Qwen3.7 Max", | |
| 1222 | + contextWindow: 256_000, maxOutputTokens: nil, | |
| 1223 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 1224 | + pricing: ModelPricing(inputPerMTok: 2.50, outputPerMTok: 7.50), | |
| 1225 | + parameterSupport: .openAIDefault | |
| 1226 | + ), | |
| 1227 | + AIModel( | |
| 1228 | + id: "Qwen/Qwen3.5-397B-A17B", provider: .deepinfra, displayName: "Qwen3.5 397B A17B", | |
| 1229 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 1230 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 1231 | + pricing: ModelPricing(inputPerMTok: 0.45, outputPerMTok: 3.00), | |
| 1232 | + parameterSupport: .openAIDefault | |
| 1233 | + ), | |
| 1234 | + AIModel( | |
| 1235 | + id: "Qwen/Qwen3-235B-A22B-Instruct-2507", provider: .deepinfra, displayName: "Qwen3 235B Instruct 2507", | |
| 1236 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 1237 | + capabilities: ModelCapabilities(tools: true, jsonMode: true), | |
| 1238 | + pricing: ModelPricing(inputPerMTok: 0.09, outputPerMTok: 0.55), | |
| 1239 | + parameterSupport: .openAIDefault | |
| 1240 | + ), | |
| 1241 | + AIModel( | |
| 1242 | + id: "Qwen/Qwen3-235B-A22B-Thinking-2507", provider: .deepinfra, displayName: "Qwen3 235B Thinking 2507", | |
| 1243 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 1244 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 1245 | + pricing: ModelPricing(inputPerMTok: 0.23, outputPerMTok: 2.30), | |
| 1246 | + parameterSupport: .openAIDefault | |
| 1247 | + ), | |
| 1248 | + AIModel( | |
| 1249 | + id: "Qwen/Qwen3-Coder-480B-A35B-Instruct-Turbo", provider: .deepinfra, displayName: "Qwen3 Coder 480B Turbo", | |
| 1250 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 1251 | + capabilities: ModelCapabilities(tools: true, jsonMode: true), | |
| 1252 | + pricing: ModelPricing(inputPerMTok: 0.30, outputPerMTok: 1.00), | |
| 1253 | + parameterSupport: .openAIDefault | |
| 1254 | + ), | |
| 1255 | + AIModel( | |
| 1256 | + id: "Qwen/Qwen3-VL-235B-A22B-Instruct", provider: .deepinfra, displayName: "Qwen3 VL 235B", | |
| 1257 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 1258 | + capabilities: ModelCapabilities(vision: true, tools: true, jsonMode: true), | |
| 1259 | + pricing: ModelPricing(inputPerMTok: 0.20, outputPerMTok: 0.88), | |
| 1260 | + parameterSupport: .openAIDefault | |
| 1261 | + ), | |
| 1262 | + AIModel( | |
| 1263 | + id: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", provider: .deepinfra, displayName: "Llama 4 Maverick", | |
| 1264 | + contextWindow: 1_048_576, maxOutputTokens: nil, | |
| 1265 | + capabilities: ModelCapabilities(vision: true, tools: true, jsonMode: true), | |
| 1266 | + pricing: ModelPricing(inputPerMTok: 0.20, outputPerMTok: 0.80), | |
| 1267 | + parameterSupport: .openAIDefault | |
| 1268 | + ), | |
| 1269 | + AIModel( | |
| 1270 | + id: "meta-llama/Llama-4-Scout-17B-16E-Instruct", provider: .deepinfra, displayName: "Llama 4 Scout", | |
| 1271 | + contextWindow: 327_680, maxOutputTokens: nil, | |
| 1272 | + capabilities: ModelCapabilities(vision: true, tools: true, jsonMode: true), | |
| 1273 | + pricing: ModelPricing(inputPerMTok: 0.10, outputPerMTok: 0.30), | |
| 1274 | + parameterSupport: .openAIDefault | |
| 1275 | + ), | |
| 1276 | + AIModel( | |
| 1277 | + id: "meta-llama/Llama-3.3-70B-Instruct-Turbo", provider: .deepinfra, displayName: "Llama 3.3 70B Turbo", | |
| 1278 | + contextWindow: 131_072, maxOutputTokens: nil, | |
| 1279 | + capabilities: ModelCapabilities(tools: true, jsonMode: true), | |
| 1280 | + pricing: ModelPricing(inputPerMTok: 0.10, outputPerMTok: 0.32), | |
| 1281 | + parameterSupport: .openAIDefault | |
| 1282 | + ), | |
| 1283 | + AIModel( | |
| 1284 | + id: "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo", provider: .deepinfra, displayName: "Llama 3.1 8B Turbo", | |
| 1285 | + contextWindow: 131_072, maxOutputTokens: nil, | |
| 1286 | + capabilities: ModelCapabilities(tools: true, jsonMode: true), | |
| 1287 | + pricing: ModelPricing(inputPerMTok: 0.02, outputPerMTok: 0.04), | |
| 1288 | + parameterSupport: .openAIDefault | |
| 1289 | + ), | |
| 1290 | + AIModel( | |
| 1291 | + id: "openai/gpt-oss-120b", provider: .deepinfra, displayName: "GPT-OSS 120B", | |
| 1292 | + contextWindow: 131_072, maxOutputTokens: nil, | |
| 1293 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 1294 | + pricing: ModelPricing(inputPerMTok: 0.037, outputPerMTok: 0.17), | |
| 1295 | + parameterSupport: ParameterSupport(frequencyPenalty: true, presencePenalty: true, reasoningEffort: true), | |
| 1296 | + isRecommended: true | |
| 1297 | + ), | |
| 1298 | + AIModel( | |
| 1299 | + id: "openai/gpt-oss-20b", provider: .deepinfra, displayName: "GPT-OSS 20B", | |
| 1300 | + contextWindow: 131_072, maxOutputTokens: nil, | |
| 1301 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 1302 | + pricing: ModelPricing(inputPerMTok: 0.03, outputPerMTok: 0.14), | |
| 1303 | + parameterSupport: ParameterSupport(frequencyPenalty: true, presencePenalty: true, reasoningEffort: true) | |
| 1304 | + ), | |
| 1305 | + AIModel( | |
| 1306 | + id: "MiniMaxAI/MiniMax-M3", provider: .deepinfra, displayName: "MiniMax M3", | |
| 1307 | + contextWindow: 524_288, maxOutputTokens: nil, | |
| 1308 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 1309 | + pricing: ModelPricing(inputPerMTok: 0.30, outputPerMTok: 1.20), | |
| 1310 | + parameterSupport: .openAIDefault | |
| 1311 | + ), | |
| 1312 | + AIModel( | |
| 1313 | + id: "nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B", provider: .deepinfra, displayName: "Nemotron 3 Ultra 550B", | |
| 1314 | + contextWindow: 262_144, maxOutputTokens: nil, | |
| 1315 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 1316 | + pricing: ModelPricing(inputPerMTok: 0.50, outputPerMTok: 2.20), | |
| 1317 | + parameterSupport: .openAIDefault | |
| 1318 | + ), | |
| 1319 | + AIModel( | |
| 1320 | + id: "mistralai/Mistral-Small-3.2-24B-Instruct-2506", provider: .deepinfra, displayName: "Mistral Small 3.2 24B", | |
| 1321 | + contextWindow: 128_000, maxOutputTokens: nil, | |
| 1322 | + capabilities: ModelCapabilities(vision: true, tools: true, jsonMode: true), | |
| 1323 | + pricing: ModelPricing(inputPerMTok: 0.075, outputPerMTok: 0.20), | |
| 1324 | + parameterSupport: .openAIDefault | |
| 1325 | + ), | |
| 1326 | + // google/gemma-4-31B-it removed 2026-07-30: endpoint hangs (60s+, zero | |
| 1327 | + // bytes) on chat completions — see docs/PROVIDERS.md Phase 7 amendments. | |
| 1328 | + ] | |
| 1329 | + | |
| 1330 | + // MARK: - Cerebras | |
| 1331 | + | |
| 1332 | + static let cerebras: [AIModel] = [ | |
| 1333 | + AIModel( | |
| 1334 | + id: "gpt-oss-120b", provider: .cerebras, displayName: "GPT-OSS 120B", | |
| 1335 | + contextWindow: 131_072, maxOutputTokens: 40_000, | |
| 1336 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 1337 | + pricing: ModelPricing(inputPerMTok: 0.35, outputPerMTok: 0.75), | |
| 1338 | + parameterSupport: ParameterSupport(frequencyPenalty: true, presencePenalty: true, usesMaxCompletionTokens: true, reasoningEffort: true), | |
| 1339 | + isRecommended: true | |
| 1340 | + ), | |
| 1341 | + AIModel( | |
| 1342 | + id: "gemma-4-31b", provider: .cerebras, displayName: "Gemma 4 31B", | |
| 1343 | + contextWindow: 131_072, maxOutputTokens: 40_000, | |
| 1344 | + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true), | |
| 1345 | + pricing: ModelPricing(inputPerMTok: 0.99, outputPerMTok: 1.49), | |
| 1346 | + parameterSupport: ParameterSupport(frequencyPenalty: true, presencePenalty: true, usesMaxCompletionTokens: true, reasoningEffort: true) | |
| 1347 | + ), | |
| 1348 | + AIModel( | |
| 1349 | + id: "zai-glm-4.7", provider: .cerebras, displayName: "GLM 4.7", | |
| 1350 | + contextWindow: 131_072, maxOutputTokens: 40_000, | |
| 1351 | + capabilities: ModelCapabilities(tools: true, reasoning: true, jsonMode: true), | |
| 1352 | + pricing: ModelPricing(inputPerMTok: 2.25, outputPerMTok: 2.75), | |
| 1353 | + parameterSupport: ParameterSupport(frequencyPenalty: true, presencePenalty: true, usesMaxCompletionTokens: true, reasoningEffort: true), | |
| 1354 | + // Scheduled for discontinuation on 2026-08-17. | |
| 1355 | + isLegacy: true | |
| 1356 | + ), | |
| 1357 | + ] | |
| 1358 | + | |
| 1359 | + // MARK: - All providers | |
| 1360 | + | |
| 1361 | + static let all: [AIModel] = | |
| 1362 | + openai + anthropic + xai + mistral + gemini + qwen + | |
| 1363 | + deepseek + kimi + perplexity + together + deepinfra + cerebras | |
| 1364 | +} | |
added
Sources/ZyquoAtlas/Services/PersistenceService.swift
+40 −0
@@ -0,0 +1,40 @@ | ||
| 1 | +// | |
| 2 | +// PersistenceService.swift | |
| 3 | +// Zyquo Atlas | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Owns Zyquo Atlas's on-disk root and the browsing-data subfolders. The | |
| 9 | +// encrypted key vault (SecureKeyStore) and, in later phases, bookmarks / | |
| 10 | +// history / sessions / profiles live under this root. Deliberately minimal in | |
| 11 | +// Phase 3 — it exists so SecureKeyStore has a stable rootDirectory | |
| 12 | +// (`~/Library/Application Support/ZyquoAtlas/`), separate from Zyquo Cloud's. | |
| 13 | +// | |
| 14 | + | |
| 15 | +import Foundation | |
| 16 | + | |
| 17 | +final class PersistenceService { | |
| 18 | + static let shared = PersistenceService() | |
| 19 | + | |
| 20 | + /// `~/Library/Application Support/ZyquoAtlas/`, created on first access. | |
| 21 | + let rootDirectory: URL | |
| 22 | + /// `…/ZyquoAtlas/Profiles/` — per-profile browsing data (Phase 4/6). | |
| 23 | + let profilesDirectory: URL | |
| 24 | + | |
| 25 | + private init() { | |
| 26 | + let appSupport = FileManager.default.urls( | |
| 27 | + for: .applicationSupportDirectory, in: .userDomainMask | |
| 28 | + ).first ?? URL(fileURLWithPath: NSHomeDirectory()) | |
| 29 | + .appendingPathComponent("Library/Application Support") | |
| 30 | + | |
| 31 | + rootDirectory = appSupport.appendingPathComponent("ZyquoAtlas", isDirectory: true) | |
| 32 | + profilesDirectory = rootDirectory.appendingPathComponent("Profiles", isDirectory: true) | |
| 33 | + | |
| 34 | + for dir in [rootDirectory, profilesDirectory] { | |
| 35 | + try? FileManager.default.createDirectory( | |
| 36 | + at: dir, withIntermediateDirectories: true | |
| 37 | + ) | |
| 38 | + } | |
| 39 | + } | |
| 40 | +} | |
added
Sources/ZyquoAtlas/Services/SecureKeyStore.swift
+174 −0
@@ -0,0 +1,174 @@ | ||
| 1 | +// | |
| 2 | +// SecureKeyStore.swift | |
| 3 | +// Zyquo Atlas | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Custom API-key vault — deliberately NOT the macOS Keychain. | |
| 9 | +// | |
| 10 | +// Design: | |
| 11 | +// • Vault file ~/Library/Application Support/ZyquoAtlas/vault.zq | |
| 12 | +// layout: [salt 32B][AES-GCM nonce 12B][ciphertext+tag] | |
| 13 | +// plaintext: JSON dictionary {"openai": "sk-…", "anthropic": "sk-ant-…", …} | |
| 14 | +// • Master key HKDF-SHA256(ikm: machineEntropy ‖ pepper, salt: vault salt, | |
| 15 | +// info: "ZyquoAtlas.vault.v1") → AES-256-GCM key. | |
| 16 | +// machineEntropy = IOPlatformUUID (IOKit) ‖ user home path — binds the | |
| 17 | +// vault to this machine and account. | |
| 18 | +// • Pepper: compiled-in, assembled at runtime from obfuscated fragments — | |
| 19 | +// never a plain string literal in the binary. | |
| 20 | +// | |
| 21 | +// Keys are decrypted only on demand, never logged, never written to disk in | |
| 22 | +// plaintext, and redacted to their last 4 characters everywhere in the UI. | |
| 23 | +// | |
| 24 | + | |
| 25 | +import CryptoKit | |
| 26 | +import Foundation | |
| 27 | +import IOKit | |
| 28 | +import Security | |
| 29 | + | |
| 30 | +struct SecureKeyStore { | |
| 31 | + enum VaultError: LocalizedError { | |
| 32 | + case corrupted | |
| 33 | + case machineIdentityUnavailable | |
| 34 | + | |
| 35 | + var errorDescription: String? { | |
| 36 | + switch self { | |
| 37 | + case .corrupted: | |
| 38 | + return "The key vault is damaged or belongs to another machine." | |
| 39 | + case .machineIdentityUnavailable: | |
| 40 | + return "Could not read this Mac's hardware identity." | |
| 41 | + } | |
| 42 | + } | |
| 43 | + } | |
| 44 | + | |
| 45 | + private static let saltLength = 32 | |
| 46 | + private static let keyLength = 32 | |
| 47 | + | |
| 48 | + let vaultURL: URL | |
| 49 | + /// Overridable for tests; defaults to real machine identity. | |
| 50 | + private let machineEntropy: () throws -> Data | |
| 51 | + | |
| 52 | + init( | |
| 53 | + vaultURL: URL? = nil, | |
| 54 | + machineEntropy: (() throws -> Data)? = nil | |
| 55 | + ) { | |
| 56 | + let root = PersistenceService.shared.rootDirectory | |
| 57 | + self.vaultURL = vaultURL ?? root.appendingPathComponent("vault.zq") | |
| 58 | + self.machineEntropy = machineEntropy ?? Self.defaultMachineEntropy | |
| 59 | + } | |
| 60 | + | |
| 61 | + // MARK: - Public API | |
| 62 | + | |
| 63 | + /// All stored keys (provider rawValue → API key). Empty if no vault exists. | |
| 64 | + func loadKeys() throws -> [String: String] { | |
| 65 | + guard let blob = try? Data(contentsOf: vaultURL) else { return [:] } | |
| 66 | + guard blob.count > Self.saltLength + 12 + 16 else { throw VaultError.corrupted } | |
| 67 | + let salt = blob.prefix(Self.saltLength) | |
| 68 | + let rest = blob.dropFirst(Self.saltLength) | |
| 69 | + let key = try masterKey(salt: salt) | |
| 70 | + do { | |
| 71 | + let box = try AES.GCM.SealedBox(combined: rest) | |
| 72 | + let plaintext = try AES.GCM.open(box, using: key) | |
| 73 | + return try JSONDecoder().decode([String: String].self, from: plaintext) | |
| 74 | + } catch { | |
| 75 | + throw VaultError.corrupted | |
| 76 | + } | |
| 77 | + } | |
| 78 | + | |
| 79 | + /// Encrypts and atomically writes the full key dictionary. | |
| 80 | + func saveKeys(_ keys: [String: String]) throws { | |
| 81 | + let salt = try existingSalt() ?? Self.randomBytes(Self.saltLength) | |
| 82 | + let key = try masterKey(salt: salt) | |
| 83 | + let plaintext = try JSONEncoder().encode(keys) | |
| 84 | + let box = try AES.GCM.seal(plaintext, using: key) | |
| 85 | + guard let combined = box.combined else { throw VaultError.corrupted } | |
| 86 | + var blob = Data(salt) | |
| 87 | + blob.append(combined) | |
| 88 | + try FileManager.default.createDirectory( | |
| 89 | + at: vaultURL.deletingLastPathComponent(), withIntermediateDirectories: true | |
| 90 | + ) | |
| 91 | + try blob.write(to: vaultURL, options: [.atomic, .completeFileProtection]) | |
| 92 | + } | |
| 93 | + | |
| 94 | + func key(for provider: ProviderID) throws -> String? { | |
| 95 | + try loadKeys()[provider.rawValue] | |
| 96 | + } | |
| 97 | + | |
| 98 | + func setKey(_ apiKey: String, for provider: ProviderID) throws { | |
| 99 | + var keys = try loadKeys() | |
| 100 | + keys[provider.rawValue] = apiKey | |
| 101 | + try saveKeys(keys) | |
| 102 | + } | |
| 103 | + | |
| 104 | + func deleteKey(for provider: ProviderID) throws { | |
| 105 | + var keys = try loadKeys() | |
| 106 | + keys.removeValue(forKey: provider.rawValue) | |
| 107 | + try saveKeys(keys) | |
| 108 | + } | |
| 109 | + | |
| 110 | + /// "••••…abcd" display form. Never show more. | |
| 111 | + static func redacted(_ apiKey: String) -> String { | |
| 112 | + let suffix = apiKey.suffix(4) | |
| 113 | + return "••••\(suffix)" | |
| 114 | + } | |
| 115 | + | |
| 116 | + // MARK: - Key derivation | |
| 117 | + | |
| 118 | + private func masterKey(salt: Data) throws -> SymmetricKey { | |
| 119 | + var ikm = try machineEntropy() | |
| 120 | + ikm.append(Self.pepper()) | |
| 121 | + return HKDF<SHA256>.deriveKey( | |
| 122 | + inputKeyMaterial: SymmetricKey(data: ikm), | |
| 123 | + salt: salt, | |
| 124 | + info: Data("ZyquoAtlas.vault.v1".utf8), | |
| 125 | + outputByteCount: Self.keyLength | |
| 126 | + ) | |
| 127 | + } | |
| 128 | + | |
| 129 | + /// Hardware UUID + home path. Binds the vault to machine + account. | |
| 130 | + private static func defaultMachineEntropy() throws -> Data { | |
| 131 | + guard let uuid = platformUUID() else { throw VaultError.machineIdentityUnavailable } | |
| 132 | + var entropy = Data(uuid.utf8) | |
| 133 | + entropy.append(Data(NSHomeDirectory().utf8)) | |
| 134 | + return entropy | |
| 135 | + } | |
| 136 | + | |
| 137 | + /// IOPlatformUUID from the IOPlatformExpertDevice registry entry. | |
| 138 | + private static func platformUUID() -> String? { | |
| 139 | + let service = IOServiceGetMatchingService( | |
| 140 | + kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice") | |
| 141 | + ) | |
| 142 | + guard service != IO_OBJECT_NULL else { return nil } | |
| 143 | + defer { IOObjectRelease(service) } | |
| 144 | + guard let property = IORegistryEntryCreateCFProperty( | |
| 145 | + service, kIOPlatformUUIDKey as CFString, kCFAllocatorDefault, 0 | |
| 146 | + ) else { return nil } | |
| 147 | + return property.takeRetainedValue() as? String | |
| 148 | + } | |
| 149 | + | |
| 150 | + /// App pepper, assembled at runtime — the constants below are the pepper | |
| 151 | + /// bytes XOR 0x5A so the value never appears verbatim in the binary. | |
| 152 | + private static func pepper() -> Data { | |
| 153 | + let obfuscated: [UInt8] = [ | |
| 154 | + 0x10, 0x23, 0x2B, 0x2F, 0x35, 0x79, 0x36, 0x35, 0x2F, 0x3E, | |
| 155 | + 0x77, 0x28, 0x3B, 0x33, 0x34, 0x78, 0x39, 0x36, 0x35, 0x2F, | |
| 156 | + 0x3E, 0x69, 0x6E, 0x68, 0x6C, 0x0E, 0x3B, 0x28, 0x3F, 0x08, | |
| 157 | + ] | |
| 158 | + return Data(obfuscated.map { $0 ^ 0x5A }) | |
| 159 | + } | |
| 160 | + | |
| 161 | + private static func randomBytes(_ count: Int) throws -> Data { | |
| 162 | + var bytes = [UInt8](repeating: 0, count: count) | |
| 163 | + let status = SecRandomCopyBytes(kSecRandomDefault, count, &bytes) | |
| 164 | + guard status == errSecSuccess else { throw VaultError.corrupted } | |
| 165 | + return Data(bytes) | |
| 166 | + } | |
| 167 | + | |
| 168 | + /// Salt of the existing vault, so re-saving keeps the same derivation. | |
| 169 | + private func existingSalt() throws -> Data? { | |
| 170 | + guard let blob = try? Data(contentsOf: vaultURL), | |
| 171 | + blob.count >= Self.saltLength else { return nil } | |
| 172 | + return blob.prefix(Self.saltLength) | |
| 173 | + } | |
| 174 | +} | |
added
Sources/ZyquoAtlas/Services/StreamingService.swift
+165 −0
@@ -0,0 +1,165 @@ | ||
| 1 | +// | |
| 2 | +// StreamingService.swift | |
| 3 | +// Zyquo Atlas | |
| 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": "ZyquoAtlas/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 | + // NOTE: AsyncBytes.lines skips empty lines, which are the | |
| 80 | + // SSE event separators — split manually to preserve them. | |
| 81 | + var parser = SSEParser() | |
| 82 | + var lineBuffer = Data() | |
| 83 | + for try await byte in bytes { | |
| 84 | + if Task.isCancelled { break } | |
| 85 | + if byte == 0x0A { // \n | |
| 86 | + if lineBuffer.last == 0x0D { lineBuffer.removeLast() } // \r\n | |
| 87 | + let line = String(decoding: lineBuffer, as: UTF8.self) | |
| 88 | + lineBuffer.removeAll(keepingCapacity: true) | |
| 89 | + if let event = parser.consume(line: line) { | |
| 90 | + continuation.yield(event) | |
| 91 | + } | |
| 92 | + } else { | |
| 93 | + lineBuffer.append(byte) | |
| 94 | + } | |
| 95 | + } | |
| 96 | + // Flush a trailing line + event if the stream ended | |
| 97 | + // without a final newline / blank separator. | |
| 98 | + if !lineBuffer.isEmpty { | |
| 99 | + let line = String(decoding: lineBuffer, as: UTF8.self) | |
| 100 | + if let event = parser.consume(line: line) { | |
| 101 | + continuation.yield(event) | |
| 102 | + } | |
| 103 | + } | |
| 104 | + if let event = parser.consume(line: "") { | |
| 105 | + continuation.yield(event) | |
| 106 | + } | |
| 107 | + continuation.finish() | |
| 108 | + } catch is CancellationError { | |
| 109 | + continuation.finish(throwing: ProviderError.cancelled) | |
| 110 | + } catch let error as ProviderError { | |
| 111 | + continuation.finish(throwing: error) | |
| 112 | + } catch { | |
| 113 | + continuation.finish(throwing: ProviderError.networkError(underlying: error)) | |
| 114 | + } | |
| 115 | + } | |
| 116 | + continuation.onTermination = { _ in task.cancel() } | |
| 117 | + } | |
| 118 | + } | |
| 119 | + | |
| 120 | + /// Non-streaming JSON POST with exponential backoff on 429/5xx (3 attempts). | |
| 121 | + /// Returns the response body data. | |
| 122 | + static func postJSON( | |
| 123 | + _ request: URLRequest, | |
| 124 | + provider: ProviderID | |
| 125 | + ) async throws -> Data { | |
| 126 | + let maxAttempts = 3 | |
| 127 | + var lastError: ProviderError = .invalidResponse(provider, detail: "no attempts made") | |
| 128 | + for attempt in 1...maxAttempts { | |
| 129 | + do { | |
| 130 | + let (data, response) = try await session.data(for: request) | |
| 131 | + guard let http = response as? HTTPURLResponse else { | |
| 132 | + throw ProviderError.invalidResponse(provider, detail: "not an HTTP response") | |
| 133 | + } | |
| 134 | + guard (200..<300).contains(http.statusCode) else { | |
| 135 | + let error = ProviderError.from(status: http.statusCode, body: data, provider: provider) | |
| 136 | + if attempt < maxAttempts, http.statusCode == 429 || http.statusCode >= 500 { | |
| 137 | + lastError = error | |
| 138 | + let retryAfter = (response as? HTTPURLResponse)? | |
| 139 | + .value(forHTTPHeaderField: "Retry-After").flatMap(Double.init) | |
| 140 | + let delay = retryAfter ?? pow(2, Double(attempt)) * 2 // 4s, 8s | |
| 141 | + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) | |
| 142 | + continue | |
| 143 | + } | |
| 144 | + throw error | |
| 145 | + } | |
| 146 | + return data | |
| 147 | + } catch let error as ProviderError { | |
| 148 | + throw error | |
| 149 | + } catch is CancellationError { | |
| 150 | + throw ProviderError.cancelled | |
| 151 | + } catch { | |
| 152 | + throw ProviderError.networkError(underlying: error) | |
| 153 | + } | |
| 154 | + } | |
| 155 | + throw lastError | |
| 156 | + } | |
| 157 | + | |
| 158 | + /// GET returning decoded JSON data, with the same error mapping. | |
| 159 | + static func getJSON( | |
| 160 | + _ request: URLRequest, | |
| 161 | + provider: ProviderID | |
| 162 | + ) async throws -> Data { | |
| 163 | + try await postJSON(request, provider: provider) | |
| 164 | + } | |
| 165 | +} | |
added
Sources/ZyquoAtlas/ViewModels/KeyVaultStore.swift
+93 −0
@@ -0,0 +1,93 @@ | ||
| 1 | +// | |
| 2 | +// KeyVaultStore.swift | |
| 3 | +// Zyquo Atlas | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Observable wrapper around SecureKeyStore for the Settings UI: per-provider | |
| 9 | +// key presence, redacted display, and "Test" with latency. Decrypted keys are | |
| 10 | +// fetched on demand and never retained beyond the call. | |
| 11 | +// | |
| 12 | + | |
| 13 | +import Foundation | |
| 14 | + | |
| 15 | +@MainActor | |
| 16 | +final class KeyVaultStore: ObservableObject { | |
| 17 | + enum KeyStatus: Equatable { | |
| 18 | + case unset | |
| 19 | + case saved // present, not yet verified this session | |
| 20 | + case testing | |
| 21 | + case verified(latency: TimeInterval) | |
| 22 | + case failed(message: String) | |
| 23 | + } | |
| 24 | + | |
| 25 | + @Published private(set) var statuses: [ProviderID: KeyStatus] = [:] | |
| 26 | + /// Redacted display strings (••••1234) for providers with saved keys. | |
| 27 | + @Published private(set) var redactedKeys: [ProviderID: String] = [:] | |
| 28 | + | |
| 29 | + private let store: SecureKeyStore | |
| 30 | + | |
| 31 | + init(store: SecureKeyStore = SecureKeyStore()) { | |
| 32 | + self.store = store | |
| 33 | + refresh() | |
| 34 | + } | |
| 35 | + | |
| 36 | + func refresh() { | |
| 37 | + let keys = (try? store.loadKeys()) ?? [:] | |
| 38 | + for provider in ProviderID.builtIn { | |
| 39 | + if let key = keys[provider.rawValue], !key.isEmpty { | |
| 40 | + redactedKeys[provider] = SecureKeyStore.redacted(key) | |
| 41 | + if case .verified = statuses[provider] ?? .unset {} else { | |
| 42 | + statuses[provider] = .saved | |
| 43 | + } | |
| 44 | + } else { | |
| 45 | + redactedKeys[provider] = nil | |
| 46 | + statuses[provider] = .unset | |
| 47 | + } | |
| 48 | + } | |
| 49 | + } | |
| 50 | + | |
| 51 | + func hasKey(for provider: ProviderID) -> Bool { | |
| 52 | + redactedKeys[provider] != nil | |
| 53 | + } | |
| 54 | + | |
| 55 | + /// Decrypts and returns the key — call sites use it immediately and drop it. | |
| 56 | + func apiKey(for provider: ProviderID) throws -> String { | |
| 57 | + guard let key = try store.key(for: provider), !key.isEmpty else { | |
| 58 | + throw ProviderError.missingAPIKey(provider) | |
| 59 | + } | |
| 60 | + return key | |
| 61 | + } | |
| 62 | + | |
| 63 | + func setKey(_ key: String, for provider: ProviderID) { | |
| 64 | + let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 65 | + guard !trimmed.isEmpty else { return } | |
| 66 | + try? store.setKey(trimmed, for: provider) | |
| 67 | + statuses[provider] = .saved | |
| 68 | + refresh() | |
| 69 | + } | |
| 70 | + | |
| 71 | + func deleteKey(for provider: ProviderID) { | |
| 72 | + try? store.deleteKey(for: provider) | |
| 73 | + statuses[provider] = .unset | |
| 74 | + refresh() | |
| 75 | + } | |
| 76 | + | |
| 77 | + /// Runs the cheapest authenticated call and records latency or failure. | |
| 78 | + func testKey(for provider: ProviderID, catalog: ModelCatalog) async { | |
| 79 | + guard let key = try? apiKey(for: provider) else { | |
| 80 | + statuses[provider] = .failed(message: "No key saved") | |
| 81 | + return | |
| 82 | + } | |
| 83 | + statuses[provider] = .testing | |
| 84 | + let client = ProviderRegistry.client(for: provider) | |
| 85 | + let fallback = catalog.cheapestModel(for: provider) | |
| 86 | + do { | |
| 87 | + let latency = try await client.testKey(key, fallbackModel: fallback) | |
| 88 | + statuses[provider] = .verified(latency: latency) | |
| 89 | + } catch { | |
| 90 | + statuses[provider] = .failed(message: error.localizedDescription) | |
| 91 | + } | |
| 92 | + } | |
| 93 | +} | |
added
Tests/ZyquoAtlasTests/SSEParserTests.swift
+85 −0
@@ -0,0 +1,85 @@ | ||
| 1 | +// | |
| 2 | +// SSEParserTests.swift | |
| 3 | +// Zyquo Atlas | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Testing | |
| 10 | +@testable import ZyquoAtlas | |
| 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 | +} | |
added
Tests/ZyquoAtlasTests/SecureKeyStoreTests.swift
+106 −0
@@ -0,0 +1,106 @@ | ||
| 1 | +// | |
| 2 | +// SecureKeyStoreTests.swift | |
| 3 | +// Zyquo Atlas | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import Testing | |
| 11 | +@testable import ZyquoAtlas | |
| 12 | + | |
| 13 | +@Suite struct SecureKeyStoreTests { | |
| 14 | + private func temporaryVaultURL() -> URL { | |
| 15 | + FileManager.default.temporaryDirectory | |
| 16 | + .appendingPathComponent("zyquo-tests-\(UUID().uuidString)") | |
| 17 | + .appendingPathComponent("vault.zq") | |
| 18 | + } | |
| 19 | + | |
| 20 | + @Test func encryptDecryptRoundTrip() throws { | |
| 21 | + let url = temporaryVaultURL() | |
| 22 | + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } | |
| 23 | + let store = SecureKeyStore(vaultURL: url) | |
| 24 | + | |
| 25 | + let keys = [ | |
| 26 | + "openai": "sk-proj-test-1234", | |
| 27 | + "anthropic": "sk-ant-test-5678", | |
| 28 | + "mistral": "plainkey", | |
| 29 | + ] | |
| 30 | + try store.saveKeys(keys) | |
| 31 | + let loaded = try store.loadKeys() | |
| 32 | + #expect(loaded == keys) | |
| 33 | + | |
| 34 | + // Vault file must not contain any key material in plaintext. | |
| 35 | + let raw = try Data(contentsOf: url) | |
| 36 | + let rawString = String(decoding: raw, as: UTF8.self) | |
| 37 | + #expect(!rawString.contains("sk-proj-test-1234")) | |
| 38 | + #expect(!rawString.contains("sk-ant-test-5678")) | |
| 39 | + #expect(!rawString.contains("openai")) | |
| 40 | + } | |
| 41 | + | |
| 42 | + @Test func perProviderSetGetDelete() throws { | |
| 43 | + let url = temporaryVaultURL() | |
| 44 | + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } | |
| 45 | + let store = SecureKeyStore(vaultURL: url) | |
| 46 | + | |
| 47 | + try store.setKey("xai-abc", for: .xai) | |
| 48 | + try store.setKey("pplx-def", for: .perplexity) | |
| 49 | + #expect(try store.key(for: .xai) == "xai-abc") | |
| 50 | + #expect(try store.key(for: .perplexity) == "pplx-def") | |
| 51 | + | |
| 52 | + try store.deleteKey(for: .xai) | |
| 53 | + #expect(try store.key(for: .xai) == nil) | |
| 54 | + #expect(try store.key(for: .perplexity) == "pplx-def") | |
| 55 | + } | |
| 56 | + | |
| 57 | + @Test func emptyVaultLoadsEmpty() throws { | |
| 58 | + let store = SecureKeyStore(vaultURL: temporaryVaultURL()) | |
| 59 | + #expect(try store.loadKeys().isEmpty) | |
| 60 | + } | |
| 61 | + | |
| 62 | + @Test func tamperedVaultThrowsCorrupted() throws { | |
| 63 | + let url = temporaryVaultURL() | |
| 64 | + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } | |
| 65 | + let store = SecureKeyStore(vaultURL: url) | |
| 66 | + try store.saveKeys(["openai": "sk-test"]) | |
| 67 | + | |
| 68 | + var blob = try Data(contentsOf: url) | |
| 69 | + blob[blob.count - 1] ^= 0xFF // flip a tag byte | |
| 70 | + try blob.write(to: url) | |
| 71 | + | |
| 72 | + #expect(throws: SecureKeyStore.VaultError.self) { | |
| 73 | + _ = try store.loadKeys() | |
| 74 | + } | |
| 75 | + } | |
| 76 | + | |
| 77 | + @Test func vaultIsMachineBound() throws { | |
| 78 | + let url = temporaryVaultURL() | |
| 79 | + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } | |
| 80 | + | |
| 81 | + let machineA = SecureKeyStore(vaultURL: url, machineEntropy: { Data("machine-A".utf8) }) | |
| 82 | + try machineA.saveKeys(["openai": "sk-test"]) | |
| 83 | + | |
| 84 | + let machineB = SecureKeyStore(vaultURL: url, machineEntropy: { Data("machine-B".utf8) }) | |
| 85 | + #expect(throws: SecureKeyStore.VaultError.self) { | |
| 86 | + _ = try machineB.loadKeys() | |
| 87 | + } | |
| 88 | + } | |
| 89 | + | |
| 90 | + @Test func saltIsStableAcrossSaves() throws { | |
| 91 | + let url = temporaryVaultURL() | |
| 92 | + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } | |
| 93 | + let store = SecureKeyStore(vaultURL: url) | |
| 94 | + | |
| 95 | + try store.saveKeys(["a": "1"]) | |
| 96 | + let salt1 = try Data(contentsOf: url).prefix(32) | |
| 97 | + try store.saveKeys(["a": "1", "b": "2"]) | |
| 98 | + let salt2 = try Data(contentsOf: url).prefix(32) | |
| 99 | + #expect(salt1 == salt2) | |
| 100 | + #expect(try store.loadKeys() == ["a": "1", "b": "2"]) | |
| 101 | + } | |
| 102 | + | |
| 103 | + @Test func redactionShowsOnlyLastFour() { | |
| 104 | + #expect(SecureKeyStore.redacted("sk-proj-abcdefgh1234") == "••••1234") | |
| 105 | + } | |
| 106 | +} | |
| 107 | ||