SPB Git

spb/zyquo-atlas

Public License

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

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 (Jul 30, 2026) parent 64557b1 Browse files

16 changed files +3,407 −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
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added Sources/ZyquoAtlas/Services/SecureKeyStore.swift +174 −0
Large diff (174 lines) is not loaded by default to keep the page fast.Load diff
added Sources/ZyquoAtlas/Services/StreamingService.swift +165 −0
Large diff (165 lines) is not loaded by default to keep the page fast.Load diff
added Sources/ZyquoAtlas/ViewModels/KeyVaultStore.swift +93 −0
Large diff (93 lines) is not loaded by default to keep the page fast.Load diff
added Tests/ZyquoAtlasTests/SSEParserTests.swift +85 −0
Large diff (85 lines) is not loaded by default to keep the page fast.Load diff
added Tests/ZyquoAtlasTests/SecureKeyStoreTests.swift +106 −0
Large diff (107 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme