SPB Git

spb/zyquo-atlas Public License

The AI-native macOS web browser — every surface, intelligent.

Swift 75.2% JavaScript 22% Shell 2% Makefile 0.9%

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>
simon-pierre boucher committed 11 days ago (Jul 30, 2026) parent 64557b1

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