SPB Git

spb/zyquo-agent Public MIT

The autonomous agent that actually operates your Mac — plans, runs real commands, verifies its own work.

Swift 94.7% Shell 4.1% Python 0.7% Makefile 0.5%

phase2: port provider layer from Zyquo Cloud + normalized tool-calling interface; ZyquoTheme violet tokens

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed 11 days ago (Jul 30, 2026) parent d2abd17

Showing 18 changed files with +4,152 and −3

added Sources/ZyquoAgent/DesignSystem/ZyquoTheme.swift +166 −0
@@ -0,0 +1,166 @@
1 +//
2 +// ZyquoTheme.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// The design system. Every color, font, spacing, radius, and shadow in the app
9 +// comes from these tokens — views contain zero raw hex values or magic numbers.
10 +// Same token system as Zyquo Cloud/Local; the Agent identity is a confident
11 +// violet/plum. The light theme is the flagship (Phase 4 spec); dark derives
12 +// from the same semantic tokens with a deep plum-charcoal command-center base.
13 +//
14 +
15 +import SwiftUI
16 +
17 +// MARK: - Colors
18 +
19 +/// Semantic color tokens. Resolved per appearance via dynamic NSColor so the
20 +/// system handles light/dark switching natively.
21 +enum ZyquoColor {
22 + /// Main canvas — cool off-white with a faint violet undertone / deep plum-charcoal.
23 + static let background = dynamic(light: 0xFBFAFD, dark: 0x161420)
24 + /// Cards, bubbles, input bar, panels.
25 + static let surface = dynamic(light: 0xFFFFFF, dark: 0x1E1B2A)
26 + /// Hover states, code/terminal block background.
27 + static let surfaceSecondary = dynamic(light: 0xF4F2F8, dark: 0x262233)
28 + /// Primary actions, selection, links, run button (confident violet).
29 + static let accent = dynamic(light: 0x7A5AF0, dark: 0x9B82F6)
30 + /// Selected task rows, user bubble tint.
31 + static let accentSubtle = dynamic(light: 0xEFEBFD, dark: 0x2E2749)
32 + static let textPrimary = dynamic(light: 0x1B1A20, dark: 0xEAE8F0)
33 + static let textSecondary = dynamic(light: 0x6E6B78, dark: 0x9C98AA)
34 + static let textTertiary = dynamic(light: 0xA09DAC, dark: 0x6B6878)
35 + /// Hairline separators (draw at 0.5pt).
36 + static let border = dynamic(light: 0xE7E4EE, dark: 0x2E2A3A)
37 + /// Tool succeeded / approval needed / destructive & errors.
38 + static let success = dynamic(light: 0x2FA36B, dark: 0x43BD83)
39 + static let warning = dynamic(light: 0xD9822B, dark: 0xE59A4D)
40 + static let danger = dynamic(light: 0xD64545, dark: 0xE36363)
41 +
42 + /// Builds a dynamic color that resolves per appearance.
43 + private static func dynamic(light: UInt32, dark: UInt32) -> Color {
44 + Color(nsColor: NSColor(name: nil) { appearance in
45 + let hex = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light
46 + return NSColor(hex: hex)
47 + })
48 + }
49 +}
50 +
51 +extension NSColor {
52 + /// 0xRRGGBB → NSColor (sRGB).
53 + convenience init(hex: UInt32) {
54 + self.init(
55 + srgbRed: CGFloat((hex >> 16) & 0xFF) / 255,
56 + green: CGFloat((hex >> 8) & 0xFF) / 255,
57 + blue: CGFloat(hex & 0xFF) / 255,
58 + alpha: 1
59 + )
60 + }
61 +}
62 +
63 +// MARK: - Typography
64 +
65 +/// Type scale (SF Pro system font; SF Mono for code/terminal). Body scales with
66 +/// the user's font size preference (12–18pt).
67 +enum ZyquoFont {
68 + /// 20pt semibold — window/section titles.
69 + static let title = Font.system(size: 20, weight: .semibold)
70 + /// Message body at a given user-selected size (default 13.5).
71 + static func body(size: Double = 13.5) -> Font {
72 + .system(size: size, weight: .regular)
73 + }
74 + static func bodyEmphasis(size: Double = 13.5) -> Font {
75 + .system(size: size, weight: .medium)
76 + }
77 + /// 11pt — timestamps, step counters, captions, status pills.
78 + static let caption = Font.system(size: 11, weight: .regular)
79 + /// Code blocks, commands, and terminal output (SF Mono), scaled slightly below body.
80 + static func code(size: Double = 12.5) -> Font {
81 + .system(size: size, weight: .regular, design: .monospaced)
82 + }
83 + /// Mandatory generous line height for message text (spec: 1.45).
84 + static let bodyLineSpacingFactor: Double = 0.45
85 +}
86 +
87 +// MARK: - Spacing, radii, shadows, metrics
88 +
89 +/// Spacing scale: 4 / 8 / 12 / 16 / 20 / 24 / 32.
90 +enum ZyquoSpacing {
91 + static let xxs: CGFloat = 4
92 + static let xs: CGFloat = 8
93 + static let sm: CGFloat = 12
94 + static let md: CGFloat = 16
95 + static let lg: CGFloat = 20
96 + static let xl: CGFloat = 24
97 + static let xxl: CGFloat = 32
98 +}
99 +
100 +/// Corner radii: 6 (small controls), 10 (cards/bubbles), 14 (floating panels).
101 +enum ZyquoRadius {
102 + static let small: CGFloat = 6
103 + static let medium: CGFloat = 10
104 + static let large: CGFloat = 14
105 +}
106 +
107 +/// Shadows: extremely soft, used sparingly (floating panels, popovers, input bar).
108 +enum ZyquoShadow {
109 + static let soft = ShadowStyle(color: .black.opacity(0.06), radius: 12, y: 2)
110 +
111 + struct ShadowStyle {
112 + let color: Color
113 + let radius: CGFloat
114 + var x: CGFloat = 0
115 + var y: CGFloat = 0
116 + }
117 +}
118 +
119 +/// Fixed layout metrics from the Phase 4 spec (command-center window).
120 +enum ZyquoMetrics {
121 + static let sidebarWidth: CGFloat = 260
122 + static let headerHeight: CGFloat = 52
123 + static let maxMessageColumnWidth: CGFloat = 760
124 + static let planPanelWidth: CGFloat = 300
125 + static let contentInset: CGFloat = 16
126 + static let hairline: CGFloat = 0.5
127 + static let windowMinWidth: CGFloat = 1040
128 + static let windowMinHeight: CGFloat = 680
129 + static let windowDefaultWidth: CGFloat = 1320
130 + static let windowDefaultHeight: CGFloat = 860
131 + static let quickTaskWidth: CGFloat = 640
132 + static let settingsWidth: CGFloat = 760
133 + static let settingsHeight: CGFloat = 560
134 + static let verticalTurnRhythm: CGFloat = 16
135 +}
136 +
137 +/// Motion tokens: hover 80ms ease, appear 150ms ease-out fade+rise, pressed 0.97.
138 +enum ZyquoMotion {
139 + static let hover = Animation.easeInOut(duration: 0.08)
140 + static let appear = Animation.easeOut(duration: 0.15)
141 + static let pressedScale: CGFloat = 0.97
142 + static let picker = Animation.snappy
143 +}
144 +
145 +// MARK: - View helpers
146 +
147 +extension View {
148 + /// Standard soft shadow for floating panels/popovers only.
149 + func zyquoSoftShadow() -> some View {
150 + shadow(
151 + color: ZyquoShadow.soft.color,
152 + radius: ZyquoShadow.soft.radius,
153 + x: ZyquoShadow.soft.x,
154 + y: ZyquoShadow.soft.y
155 + )
156 + }
157 +}
158 +
159 +/// 0.5pt hairline separator in the border token color.
160 +struct ZyquoHairline: View {
161 + var body: some View {
162 + Rectangle()
163 + .fill(ZyquoColor.border)
164 + .frame(height: ZyquoMetrics.hairline)
165 + }
166 +}
added Sources/ZyquoAgent/Models/AIModel.swift +114 −0
@@ -0,0 +1,114 @@
1 +//
2 +// AIModel.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Ported verbatim from Zyquo Cloud. Zyquo Agent adds the `agentCapable`
9 +// marking (see AgentModelSupport.swift) on top of the identical catalog.
10 +//
11 +
12 +import Foundation
13 +
14 +/// A chat-capable model offered by a provider. Instances come exclusively from
15 +/// `ModelCatalog` (built-in data generated from docs/PROVIDERS.md, dynamic
16 +/// `/models` refreshes, and user-defined custom models) — never hardcode these
17 +/// in views or clients.
18 +struct AIModel: Codable, Identifiable, Hashable {
19 + /// Exact model ID as sent in API requests (e.g. "gpt-5.6-terra").
20 + let id: String
21 + let provider: ProviderID
22 + /// Human-friendly name shown in the UI (e.g. "GPT-5.6 Terra").
23 + let displayName: String
24 + /// Context window in tokens.
25 + let contextWindow: Int
26 + /// Maximum output tokens, when documented.
27 + let maxOutputTokens: Int?
28 + let capabilities: ModelCapabilities
29 + let pricing: ModelPricing?
30 + let parameterSupport: ParameterSupport
31 + /// Deprecated or superseded models stay selectable but are ranked last and badged.
32 + var isLegacy: Bool = false
33 + /// Featured/flagship models surface at the top of pickers.
34 + var isRecommended: Bool = false
35 + /// Base URL override for user-defined custom models; nil for built-ins.
36 + var customBaseURL: URL? = nil
37 +
38 + /// Short badge text for the model chip (e.g. "1M ctx").
39 + var contextBadge: String {
40 + switch contextWindow {
41 + case 1_000_000...: return "\(contextWindow / 1_000_000)M ctx"
42 + case 1_000...: return "\(contextWindow / 1_000)K ctx"
43 + default: return "\(contextWindow) ctx"
44 + }
45 + }
46 +}
47 +
48 +/// What a model can do. Drives UI affordances (attach button, thinking section…)
49 +/// and request construction.
50 +struct ModelCapabilities: Codable, Hashable {
51 + /// Accepts image input.
52 + var vision: Bool = false
53 + /// Supports function calling / tools.
54 + var tools: Bool = false
55 + /// Produces reasoning/thinking output (shown in the collapsible section).
56 + var reasoning: Bool = false
57 + /// Supports SSE streaming (true for every catalog model; custom endpoints may vary).
58 + var streaming: Bool = true
59 + /// Supports JSON mode / structured output.
60 + var jsonMode: Bool = false
61 + /// Returns web-search citations (Perplexity sonar family).
62 + var citations: Bool = false
63 +}
64 +
65 +/// USD per 1M tokens. Cached/tiered pricing is intentionally simplified to the
66 +/// base rate — cost figures in the UI are labeled as estimates.
67 +struct ModelPricing: Codable, Hashable {
68 + var inputPerMTok: Double
69 + var outputPerMTok: Double
70 +
71 + /// Estimated cost in USD for a usage record.
72 + func cost(inputTokens: Int, outputTokens: Int) -> Double {
73 + (Double(inputTokens) * inputPerMTok + Double(outputTokens) * outputPerMTok) / 1_000_000
74 + }
75 +}
76 +
77 +/// Which sampling/control parameters a model accepts. Providers reject requests
78 +/// carrying unsupported parameters, so requests only include what's supported —
79 +/// and Settings only shows sliders that apply.
80 +struct ParameterSupport: Codable, Hashable {
81 + var temperature: Bool = true
82 + var topP: Bool = true
83 + var frequencyPenalty: Bool = false
84 + var presencePenalty: Bool = false
85 + /// Send "max_completion_tokens" instead of "max_tokens" (OpenAI reasoning models, Cerebras).
86 + var usesMaxCompletionTokens: Bool = false
87 + /// Accepts `reasoning_effort` (OpenAI, xAI, Mistral, DeepSeek, Kimi K-series, Cerebras…).
88 + var reasoningEffort: Bool = false
89 + /// Anthropic `thinking` / Qwen `enable_thinking` style explicit thinking toggle.
90 + var thinkingToggle: Bool = false
91 + /// Model rejects non-streaming calls (Qwen qwq/qvq, DashScope-hosted models
92 + /// on Together…) — `complete` aggregates a stream instead.
93 + var requiresStreaming: Bool = false
94 +
95 + static let openAIDefault = ParameterSupport(frequencyPenalty: true, presencePenalty: true)
96 +}
97 +
98 +/// Token usage reported by a provider for one exchange.
99 +struct TokenUsage: Codable, Hashable {
100 + var inputTokens: Int = 0
101 + var outputTokens: Int = 0
102 + var reasoningTokens: Int? = nil
103 +
104 + var totalTokens: Int { inputTokens + outputTokens }
105 +
106 + static func + (lhs: TokenUsage, rhs: TokenUsage) -> TokenUsage {
107 + TokenUsage(
108 + inputTokens: lhs.inputTokens + rhs.inputTokens,
109 + outputTokens: lhs.outputTokens + rhs.outputTokens,
110 + reasoningTokens: (lhs.reasoningTokens ?? 0) + (rhs.reasoningTokens ?? 0) == 0
111 + ? nil : (lhs.reasoningTokens ?? 0) + (rhs.reasoningTokens ?? 0)
112 + )
113 + }
114 +}
added Sources/ZyquoAgent/Models/AgentModelSupport.swift +164 −0
@@ -0,0 +1,164 @@
1 +//
2 +// AgentModelSupport.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// The agent-capable subset of the shared Zyquo Cloud catalog, exactly as
9 +// documented in docs/PROVIDER-REUSE.md §3: models with live-verified native
10 +// function calling, frontier-tier multi-step reasoning, and ≥ ~128K context.
11 +// Everything else stays in the picker (same list as Cloud) but is
12 +// de-emphasized; models with `capabilities.tools == false` (Perplexity sonar,
13 +// Gemma, QVQ, DeepSeek R1 distills…) are hard-excluded — the agent loop
14 +// refuses to start with them.
15 +//
16 +
17 +import Foundation
18 +
19 +enum AgentModelSupport {
20 + /// Curated "provider|modelID" keys of the agent-capable subset
21 + /// (the 🤖 marks in docs/PROVIDER-REUSE.md §2).
22 + static let capableKeys: Set<String> = [
23 + // OpenAI
24 + "openai|gpt-5.6-sol",
25 + "openai|gpt-5.6-terra",
26 + "openai|gpt-5.6-luna",
27 + "openai|gpt-5.5",
28 + "openai|gpt-5.4",
29 + "openai|gpt-5.4-mini",
30 + "openai|gpt-5.2",
31 + "openai|gpt-5.1",
32 + "openai|gpt-5",
33 + "openai|gpt-5-mini",
34 + "openai|o3",
35 + "openai|o4-mini",
36 + // Anthropic
37 + "anthropic|claude-opus-5",
38 + "anthropic|claude-sonnet-5",
39 + "anthropic|claude-fable-5",
40 + "anthropic|claude-opus-4-8",
41 + "anthropic|claude-opus-4-7",
42 + "anthropic|claude-opus-4-6",
43 + "anthropic|claude-sonnet-4-6",
44 + "anthropic|claude-haiku-4-5-20251001",
45 + // xAI
46 + "xai|grok-4.5",
47 + "xai|grok-4.3",
48 + "xai|grok-4.20",
49 + "xai|grok-4.20-non-reasoning",
50 + "xai|grok-code-fast-1",
51 + // Mistral
52 + "mistral|mistral-medium-latest",
53 + "mistral|mistral-large-latest",
54 + "mistral|mistral-small-latest",
55 + // Google Gemini (compat endpoint)
56 + "gemini|gemini-3.6-flash",
57 + "gemini|gemini-3.5-flash",
58 + "gemini|gemini-3.5-flash-lite",
59 + "gemini|gemini-3.1-pro-preview",
60 + "gemini|gemini-2.5-pro",
61 + "gemini|gemini-2.5-flash",
62 + "gemini|gemini-pro-latest",
63 + "gemini|gemini-flash-latest",
64 + // Alibaba Qwen / DashScope
65 + "qwen|qwen3.7-max",
66 + "qwen|qwen3.7-plus",
67 + "qwen|qwen3.7-flash",
68 + "qwen|qwen3.6-plus",
69 + "qwen|qwen3.5-plus",
70 + "qwen|qwen3-coder-plus",
71 + "qwen|qwen3-coder-flash",
72 + "qwen|qwen3-coder-next",
73 + "qwen|qwen3-coder-480b-a35b-instruct",
74 + "qwen|qwen3.5-397b-a17b",
75 + "qwen|deepseek-v4-pro",
76 + "qwen|glm-5.2",
77 + // DeepSeek
78 + "deepseek|deepseek-v4-flash",
79 + "deepseek|deepseek-v4-pro",
80 + // Kimi / Moonshot
81 + "kimi|kimi-k3",
82 + "kimi|kimi-k2.7-code",
83 + "kimi|kimi-k2.7-code-highspeed",
84 + "kimi|kimi-k2.6",
85 + "kimi|kimi-k2.5",
86 + // Together AI
87 + "together|moonshotai/Kimi-K3",
88 + "together|moonshotai/Kimi-K2.7-Code",
89 + "together|deepseek-ai/DeepSeek-V4-Pro",
90 + "together|zai-org/GLM-5.2",
91 + "together|Qwen/Qwen3.7-Max",
92 + "together|openai/gpt-oss-120b",
93 + "together|nvidia/nemotron-3-ultra-550b-a55b",
94 + "together|MiniMaxAI/MiniMax-M3",
95 + // DeepInfra
96 + "deepinfra|anthropic/claude-fable-5",
97 + "deepinfra|anthropic/claude-opus-5",
98 + "deepinfra|anthropic/claude-sonnet-5",
99 + "deepinfra|anthropic/claude-opus-4-8",
100 + "deepinfra|anthropic/claude-haiku-4-5",
101 + "deepinfra|google/gemini-3.1-pro",
102 + "deepinfra|google/gemini-3.5-flash",
103 + "deepinfra|deepseek-ai/DeepSeek-V4-Pro",
104 + "deepinfra|deepseek-ai/DeepSeek-V4-Flash",
105 + "deepinfra|moonshotai/Kimi-K2.7-Code",
106 + "deepinfra|zai-org/GLM-5.2",
107 + "deepinfra|Qwen/Qwen3.7-Max",
108 + "deepinfra|Qwen/Qwen3-Coder-480B-A35B-Instruct-Turbo",
109 + "deepinfra|openai/gpt-oss-120b",
110 + "deepinfra|MiniMaxAI/MiniMax-M3",
111 + // Cerebras
112 + "cerebras|gpt-oss-120b",
113 + "cerebras|gemma-4-31b",
114 + ]
115 +
116 + /// Overall default agent model (docs/PROVIDER-REUSE.md §3):
117 + /// Claude Sonnet 5 — 1M context, adaptive thinking, best-in-class tool use.
118 + static let defaultModelID = "claude-sonnet-5"
119 + static let defaultModelProvider = ProviderID.anthropic
120 +
121 + /// Suggested per-provider agent defaults (bolded 🤖 entries in §2).
122 + static let providerDefaults: [ProviderID: String] = [
123 + .openai: "gpt-5.6-terra",
124 + .anthropic: "claude-sonnet-5",
125 + .xai: "grok-4.5",
126 + .mistral: "mistral-medium-latest",
127 + .gemini: "gemini-3.6-flash",
128 + .qwen: "qwen3.7-max",
129 + .deepseek: "deepseek-v4-pro",
130 + .kimi: "kimi-k3",
131 + .together: "deepseek-ai/DeepSeek-V4-Pro",
132 + .deepinfra: "deepseek-ai/DeepSeek-V4-Pro",
133 + .cerebras: "gpt-oss-120b",
134 + ]
135 +
136 + /// Recommended top tier, surfaced first in the model chip (§3).
137 + static let recommendedKeys: [String] = [
138 + "anthropic|claude-sonnet-5",
139 + "anthropic|claude-opus-5",
140 + "openai|gpt-5.6-terra",
141 + "openai|gpt-5.6-sol",
142 + "gemini|gemini-3.6-flash",
143 + "xai|grok-4.5",
144 + "xai|grok-code-fast-1",
145 + "deepseek|deepseek-v4-pro",
146 + "kimi|kimi-k3",
147 + "kimi|kimi-k2.7-code",
148 + "qwen|qwen3.7-max",
149 + "mistral|mistral-medium-latest",
150 + "cerebras|gpt-oss-120b",
151 + ]
152 +}
153 +
154 +extension AIModel {
155 + /// Whether this model is suitable for deep agentic, multi-step tool use.
156 + /// Built-ins follow the curated subset; user-defined custom models qualify
157 + /// whenever they declare tool support. `capabilities.tools == false` is
158 + /// always disqualifying (hard exclusion).
159 + var agentCapable: Bool {
160 + guard capabilities.tools, !isLegacy else { return false }
161 + if provider == .custom { return true }
162 + return AgentModelSupport.capableKeys.contains("\(provider.rawValue)|\(id)")
163 + }
164 +}
added Sources/ZyquoAgent/Models/ChatParameters.swift +57 −0
@@ -0,0 +1,57 @@
1 +//
2 +// ChatParameters.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Shared value types extracted from Zyquo Cloud's Conversation.swift
9 +// (docs/PROVIDER-REUSE.md §6). Cloud's Conversation itself is superseded by
10 +// Zyquo Agent's Task model (Phase 2 Models/), which owns these parameters.
11 +//
12 +
13 +import Foundation
14 +
15 +/// Per-task generation parameters. `nil` means "provider default" and the
16 +/// parameter is omitted from the request entirely.
17 +struct ChatParameters: Codable, Hashable {
18 + var temperature: Double?
19 + var topP: Double?
20 + var maxTokens: Int?
21 + var frequencyPenalty: Double?
22 + var presencePenalty: Double?
23 + /// "low" / "medium" / "high" for models supporting reasoning_effort.
24 + var reasoningEffort: String?
25 + /// Explicit thinking toggle for Anthropic/Qwen-style models.
26 + var thinkingEnabled: Bool?
27 +}
28 +
29 +/// A reusable configuration: system prompt + preferred model + parameters.
30 +struct Persona: Codable, Identifiable, Hashable {
31 + let id: UUID
32 + var name: String
33 + /// SF Symbol name for the persona glyph.
34 + var symbolName: String
35 + var systemPrompt: String
36 + var modelID: String?
37 + var provider: ProviderID?
38 + var parameters: ChatParameters
39 +
40 + init(
41 + id: UUID = UUID(),
42 + name: String,
43 + symbolName: String = "person.crop.circle",
44 + systemPrompt: String,
45 + modelID: String? = nil,
46 + provider: ProviderID? = nil,
47 + parameters: ChatParameters = ChatParameters()
48 + ) {
49 + self.id = id
50 + self.name = name
51 + self.symbolName = symbolName
52 + self.systemPrompt = systemPrompt
53 + self.modelID = modelID
54 + self.provider = provider
55 + self.parameters = parameters
56 + }
57 +}
added Sources/ZyquoAgent/Models/JSONValue.swift +82 −0
@@ -0,0 +1,82 @@
1 +//
2 +// JSONValue.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// A Codable representation of arbitrary JSON, used to embed tool parameter
9 +// JSON Schemas and tool_use inputs verbatim inside Encodable wire requests
10 +// (JSONEncoder cannot encode `Any`).
11 +//
12 +
13 +import Foundation
14 +
15 +/// Arbitrary JSON, encodable/decodable losslessly.
16 +enum JSONValue: Codable, Hashable {
17 + case null
18 + case bool(Bool)
19 + case number(Double)
20 + case string(String)
21 + case array([JSONValue])
22 + case object([String: JSONValue])
23 +
24 + init(from decoder: Decoder) throws {
25 + let container = try decoder.singleValueContainer()
26 + if container.decodeNil() {
27 + self = .null
28 + } else if let b = try? container.decode(Bool.self) {
29 + self = .bool(b)
30 + } else if let n = try? container.decode(Double.self) {
31 + self = .number(n)
32 + } else if let s = try? container.decode(String.self) {
33 + self = .string(s)
34 + } else if let a = try? container.decode([JSONValue].self) {
35 + self = .array(a)
36 + } else if let o = try? container.decode([String: JSONValue].self) {
37 + self = .object(o)
38 + } else {
39 + throw DecodingError.dataCorruptedError(in: container, debugDescription: "unrecognized JSON value")
40 + }
41 + }
42 +
43 + func encode(to encoder: Encoder) throws {
44 + var container = encoder.singleValueContainer()
45 + switch self {
46 + case .null: try container.encodeNil()
47 + case .bool(let b): try container.encode(b)
48 + case .number(let n):
49 + // Preserve integer-looking numbers without a trailing ".0".
50 + if n.truncatingRemainder(dividingBy: 1) == 0,
51 + n >= Double(Int64.min), n <= Double(Int64.max) {
52 + try container.encode(Int64(n))
53 + } else {
54 + try container.encode(n)
55 + }
56 + case .string(let s): try container.encode(s)
57 + case .array(let a): try container.encode(a)
58 + case .object(let o): try container.encode(o)
59 + }
60 + }
61 +
62 + /// Parses a JSON string (e.g. a ToolSpec's parameter schema). Returns nil
63 + /// on malformed input.
64 + static func parse(_ jsonString: String) -> JSONValue? {
65 + guard let data = jsonString.data(using: .utf8) else { return nil }
66 + return try? JSONDecoder().decode(JSONValue.self, from: data)
67 + }
68 +
69 + /// The canonical empty-object schema, used when a tool declares no parameters
70 + /// or a schema string fails to parse (providers reject absent schemas).
71 + static let emptyObject = JSONValue.object([:])
72 +
73 + /// Compact JSON serialization (used to turn tool_use inputs back into the
74 + /// normalized ToolCall.argumentsJSON string).
75 + var jsonString: String {
76 + guard let data = try? JSONEncoder().encode(self),
77 + let string = String(data: data, encoding: .utf8) else {
78 + return "{}"
79 + }
80 + return string
81 + }
82 +}
added Sources/ZyquoAgent/Models/Message.swift +133 −0
@@ -0,0 +1,133 @@
1 +//
2 +// Message.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Ported from Zyquo Cloud, extended with the normalized tool-calling fields
9 +// (docs/PROVIDER-REUSE.md §5.3): `toolCalls` on assistant turns and
10 +// `toolResults` on the turn that feeds results back. Clients render these
11 +// per wire format (OpenAI `tool_calls` + role:"tool" messages; Anthropic
12 +// `tool_use` assistant blocks + `tool_result` user blocks) — Message is the
13 +// provider-neutral turn representation the agent loop works with.
14 +//
15 +
16 +import Foundation
17 +
18 +/// One turn in a conversation transcript.
19 +struct Message: Codable, Identifiable, Hashable {
20 + enum Role: String, Codable {
21 + case system
22 + case user
23 + case assistant
24 + }
25 +
26 + let id: UUID
27 + var role: Role
28 + var text: String
29 + /// Reasoning/thinking text streamed by reasoning models (collapsible in the UI).
30 + var reasoning: String?
31 + /// Image attachments (user messages, vision models).
32 + var attachments: [Attachment]
33 + /// Web-search citations (Perplexity).
34 + var citations: [Citation]
35 + /// Tool calls emitted by the model on this assistant turn. Threaded back to
36 + /// the provider verbatim so multi-step tool history round-trips correctly.
37 + var toolCalls: [ToolCall]?
38 + /// Tool results being returned to the model. Clients render these as
39 + /// role:"tool" messages (OpenAI schema) or tool_result user blocks (Anthropic).
40 + var toolResults: [ToolResult]?
41 + /// Model that produced this message (assistant turns) or was targeted (user turns).
42 + var modelID: String?
43 + var provider: ProviderID?
44 + var usage: TokenUsage?
45 + /// Estimated USD cost computed from catalog pricing at receive time.
46 + var estimatedCost: Double?
47 + var createdAt: Date
48 + /// Set while a response is streaming; exactly one message can be streaming at a time.
49 + var isStreaming: Bool = false
50 + /// Human-readable error if generation failed mid-message.
51 + var errorText: String?
52 +
53 + init(
54 + id: UUID = UUID(),
55 + role: Role,
56 + text: String,
57 + reasoning: String? = nil,
58 + attachments: [Attachment] = [],
59 + citations: [Citation] = [],
60 + toolCalls: [ToolCall]? = nil,
61 + toolResults: [ToolResult]? = nil,
62 + modelID: String? = nil,
63 + provider: ProviderID? = nil,
64 + usage: TokenUsage? = nil,
65 + estimatedCost: Double? = nil,
66 + createdAt: Date = Date()
67 + ) {
68 + self.id = id
69 + self.role = role
70 + self.text = text
71 + self.reasoning = reasoning
72 + self.attachments = attachments
73 + self.citations = citations
74 + self.toolCalls = toolCalls
75 + self.toolResults = toolResults
76 + self.modelID = modelID
77 + self.provider = provider
78 + self.usage = usage
79 + self.estimatedCost = estimatedCost
80 + self.createdAt = createdAt
81 + }
82 +
83 + /// An assistant turn carrying the model's tool calls (text may be empty).
84 + static func assistantToolCalls(_ calls: [ToolCall], text: String = "", reasoning: String? = nil) -> Message {
85 + Message(role: .assistant, text: text, reasoning: reasoning, toolCalls: calls)
86 + }
87 +
88 + /// The turn that returns tool results to the model. Multiple results in one
89 + /// turn cover parallel tool calls.
90 + static func toolResultsMessage(_ results: [ToolResult]) -> Message {
91 + Message(role: .user, text: "", toolResults: results)
92 + }
93 +}
94 +
95 +/// A file attached to a user message. Images go to vision models as base64;
96 +/// text files are injected into the prompt.
97 +struct Attachment: Codable, Identifiable, Hashable {
98 + enum Kind: String, Codable {
99 + case image
100 + case textFile
101 + }
102 +
103 + let id: UUID
104 + var kind: Kind
105 + var fileName: String
106 + /// image: raw image bytes; textFile: UTF-8 contents.
107 + var data: Data
108 + /// MIME type for images (image/png, image/jpeg, image/webp, image/gif).
109 + var mimeType: String
110 +
111 + init(id: UUID = UUID(), kind: Kind, fileName: String, data: Data, mimeType: String) {
112 + self.id = id
113 + self.kind = kind
114 + self.fileName = fileName
115 + self.data = data
116 + self.mimeType = mimeType
117 + }
118 +}
119 +
120 +/// A numbered web source backing an assistant answer (Perplexity sonar family).
121 +struct Citation: Codable, Identifiable, Hashable {
122 + let id: UUID
123 + var index: Int
124 + var url: URL
125 + var title: String?
126 +
127 + init(id: UUID = UUID(), index: Int, url: URL, title: String? = nil) {
128 + self.id = id
129 + self.index = index
130 + self.url = url
131 + self.title = title
132 + }
133 +}
added Sources/ZyquoAgent/Models/ProviderID.swift +96 −0
@@ -0,0 +1,96 @@
1 +//
2 +// ProviderID.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Ported verbatim from Zyquo Cloud — same providers, same base URLs, same
9 +// wire formats. See docs/PROVIDER-REUSE.md.
10 +//
11 +
12 +import Foundation
13 +
14 +/// The 12 built-in cloud AI providers, plus user-defined custom endpoints.
15 +enum ProviderID: String, Codable, CaseIterable, Identifiable, Hashable {
16 + case openai
17 + case anthropic
18 + case xai
19 + case mistral
20 + case gemini
21 + case qwen
22 + case deepseek
23 + case kimi
24 + case perplexity
25 + case together
26 + case deepinfra
27 + case cerebras
28 + case custom
29 +
30 + var id: String { rawValue }
31 +
32 + /// User-facing display name.
33 + var displayName: String {
34 + switch self {
35 + case .openai: return "OpenAI"
36 + case .anthropic: return "Anthropic"
37 + case .xai: return "xAI"
38 + case .mistral: return "Mistral"
39 + case .gemini: return "Google Gemini"
40 + case .qwen: return "Alibaba Qwen"
41 + case .deepseek: return "DeepSeek"
42 + case .kimi: return "Kimi"
43 + case .perplexity: return "Perplexity"
44 + case .together: return "Together AI"
45 + case .deepinfra: return "DeepInfra"
46 + case .cerebras: return "Cerebras"
47 + case .custom: return "Custom"
48 + }
49 + }
50 +
51 + /// Wire protocol used by this provider's chat endpoint.
52 + var wireFormat: WireFormat {
53 + switch self {
54 + case .anthropic: return .anthropicMessages
55 + default: return .openAIChatCompletions
56 + }
57 + }
58 +
59 + /// Base URL of the provider's API (chat + models live under this root).
60 + /// `custom` has no fixed base URL — it comes from the user's endpoint config.
61 + var defaultBaseURL: URL? {
62 + switch self {
63 + case .openai: return URL(string: "https://api.openai.com/v1")
64 + case .anthropic: return URL(string: "https://api.anthropic.com/v1")
65 + case .xai: return URL(string: "https://api.x.ai/v1")
66 + case .mistral: return URL(string: "https://api.mistral.ai/v1")
67 + case .gemini: return URL(string: "https://generativelanguage.googleapis.com/v1beta/openai")
68 + case .qwen: return URL(string: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1")
69 + case .deepseek: return URL(string: "https://api.deepseek.com")
70 + case .kimi: return URL(string: "https://api.moonshot.ai/v1")
71 + case .perplexity: return URL(string: "https://api.perplexity.ai")
72 + case .together: return URL(string: "https://api.together.xyz/v1")
73 + case .deepinfra: return URL(string: "https://api.deepinfra.com/v1/openai")
74 + case .cerebras: return URL(string: "https://api.cerebras.ai/v1")
75 + case .custom: return nil
76 + }
77 + }
78 +
79 + /// Whether the provider exposes a `/models` listing endpoint usable for dynamic refresh.
80 + var supportsModelListing: Bool {
81 + self != .perplexity
82 + }
83 +
84 + /// Providers shown in Settings (custom endpoints are managed separately).
85 + static var builtIn: [ProviderID] {
86 + allCases.filter { $0 != .custom }
87 + }
88 +}
89 +
90 +/// The request/response schema a provider speaks.
91 +enum WireFormat: String, Codable {
92 + /// OpenAI `/chat/completions` schema (used by 11 of the 12 built-in providers).
93 + case openAIChatCompletions
94 + /// Anthropic `/v1/messages` schema.
95 + case anthropicMessages
96 +}
added Sources/ZyquoAgent/Models/ToolTypes.swift +118 −0
@@ -0,0 +1,118 @@
1 +//
2 +// ToolTypes.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// The normalized tool-calling vocabulary shared by the agent loop, the tool
9 +// registry, and every provider client (docs/PROVIDER-REUSE.md §5.3). Clients
10 +// translate these to/from each wire dialect (OpenAI `tools`/`tool_calls`,
11 +// Anthropic `tools`/`tool_use`/`tool_result`) — nothing provider-specific
12 +// leaks above this layer.
13 +//
14 +
15 +import Foundation
16 +
17 +/// A tool offered to the model, as handed to the client by the ToolRegistry.
18 +struct ToolSpec: Codable, Hashable {
19 + var name: String
20 + var description: String
21 + /// Canonical JSON Schema (an `object` schema) for the tool's parameters,
22 + /// serialized as a JSON string. Clients embed it as raw JSON in requests.
23 + var parametersJSONSchema: String
24 +
25 + init(name: String, description: String, parametersJSONSchema: String) {
26 + self.name = name
27 + self.description = description
28 + self.parametersJSONSchema = parametersJSONSchema
29 + }
30 +}
31 +
32 +/// One tool invocation emitted by the model.
33 +struct ToolCall: Codable, Identifiable, Hashable {
34 + /// Provider call ID (`toolu_…` / `call_…`); synthesized when a provider omits it.
35 + var id: String
36 + var name: String
37 + /// Raw accumulated JSON string of the arguments; parsed and validated
38 + /// against the ToolSpec schema by the agent loop before execution.
39 + var argumentsJSON: String
40 +
41 + init(id: String, name: String, argumentsJSON: String) {
42 + self.id = id
43 + self.name = name
44 + self.argumentsJSON = argumentsJSON
45 + }
46 +
47 + /// Arguments parsed to a dictionary; nil when the model produced
48 + /// malformed JSON (the loop re-prompts on that).
49 + var argumentsDictionary: [String: Any]? {
50 + guard let data = argumentsJSON.data(using: .utf8),
51 + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
52 + return nil
53 + }
54 + return obj
55 + }
56 +}
57 +
58 +/// The outcome of executing one tool call, threaded back on the next request.
59 +struct ToolResult: Codable, Hashable {
60 + var toolCallID: String
61 + /// Stringified output (stdout/stderr summary, file text, error message…).
62 + var content: String
63 + var isError: Bool
64 +
65 + init(toolCallID: String, content: String, isError: Bool = false) {
66 + self.toolCallID = toolCallID
67 + self.content = content
68 + self.isError = isError
69 + }
70 +}
71 +
72 +/// How the model is allowed to use the offered tools.
73 +enum ToolChoice: Hashable {
74 + /// Model decides freely (the default; omitted from the wire when possible).
75 + case auto
76 + /// Tools are declared but must not be called.
77 + case none
78 + /// The model must call some tool (OpenAI "required" / Anthropic "any").
79 + case required
80 + /// The model must call this specific tool.
81 + case named(String)
82 +}
83 +
84 +/// Normalized stop condition, derived from the provider's raw finish/stop
85 +/// reason. The agent loop branches only on this — never on raw strings.
86 +enum StopReason: Hashable {
87 + /// Natural end of the assistant turn ("stop", "end_turn", "eos", …).
88 + case endTurn
89 + /// The model is waiting for tool results ("tool_calls" / "tool_use").
90 + case toolUse
91 + /// Output truncated by the token limit ("length" / "max_tokens").
92 + case maxTokens
93 + /// The model refused ("refusal" / "content_filter").
94 + case refusal
95 + /// Anything else (provider-specific values like DeepSeek's
96 + /// "insufficient_system_resource"), with the raw string preserved.
97 + case other(String?)
98 +
99 + /// Maps every observed finish_reason/stop_reason vocabulary (OpenAI schema
100 + /// and Anthropic Messages) to the normalized enum. `hasToolCalls` covers
101 + /// providers that stream tool calls but report a plain "stop".
102 + static func normalize(_ raw: String?, hasToolCalls: Bool = false) -> StopReason {
103 + switch raw {
104 + case "tool_calls", "tool_use", "function_call":
105 + return .toolUse
106 + case "stop", "end_turn", "eos", "stop_sequence", "pause_turn":
107 + return hasToolCalls ? .toolUse : .endTurn
108 + case "length", "max_tokens", "model_context_window_exceeded":
109 + return .maxTokens
110 + case "refusal", "content_filter":
111 + return .refusal
112 + case nil:
113 + return hasToolCalls ? .toolUse : .endTurn
114 + default:
115 + return hasToolCalls ? .toolUse : .other(raw)
116 + }
117 + }
118 +}
added Sources/ZyquoAgent/Providers/AnthropicClient.swift +461 −0
@@ -0,0 +1,461 @@
1 +//
2 +// AnthropicClient.swift
3 +// Zyquo Agent
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 +// Ported from Zyquo Cloud; Zyquo Agent adds native tool calling
14 +// (docs/PROVIDER-REUSE.md §5.1): `tools` [{name, description, input_schema}]
15 +// + `tool_choice` on requests; streaming `content_block_start` (tool_use id +
16 +// name) → `input_json_delta` (partial_json fragments, accumulated per block
17 +// index) → `content_block_stop`; stop_reason "tool_use" vs "end_turn"; and
18 +// threading of assistant tool_use blocks + user tool_result blocks from
19 +// history.
20 +//
21 +
22 +import Foundation
23 +
24 +struct AnthropicClient: ProviderClient {
25 + let providerID: ProviderID = .anthropic
26 +
27 + private static let apiVersion = "2023-06-01"
28 + private static let defaultMaxTokens = 8192
29 +
30 + // MARK: - Wire types (requests)
31 +
32 + private struct WireRequest: Encodable {
33 + var model: String
34 + var maxTokens: Int
35 + var messages: [WireMessage]
36 + var system: String?
37 + var stream: Bool?
38 + var temperature: Double?
39 + var topP: Double?
40 + var thinking: Thinking?
41 + var tools: [WireToolDef]?
42 + var toolChoice: WireToolChoice?
43 +
44 + enum CodingKeys: String, CodingKey {
45 + case model, messages, system, stream, temperature, thinking, tools
46 + case maxTokens = "max_tokens"
47 + case topP = "top_p"
48 + case toolChoice = "tool_choice"
49 + }
50 + }
51 +
52 + private struct Thinking: Encodable {
53 + var type: String
54 + var budgetTokens: Int?
55 + enum CodingKeys: String, CodingKey {
56 + case type
57 + case budgetTokens = "budget_tokens"
58 + }
59 + }
60 +
61 + /// `tools: [{name, description, input_schema: <JSON Schema>}]`.
62 + private struct WireToolDef: Encodable {
63 + var name: String
64 + var description: String
65 + var inputSchema: JSONValue
66 +
67 + enum CodingKeys: String, CodingKey {
68 + case name, description
69 + case inputSchema = "input_schema"
70 + }
71 + }
72 +
73 + /// `tool_choice: {"type":"auto"|"any"|"none"|"tool","name":…}`.
74 + private struct WireToolChoice: Encodable {
75 + var type: String
76 + var name: String?
77 + }
78 +
79 + private struct WireMessage: Encodable {
80 + var role: String
81 + var content: [WireBlock]
82 + }
83 +
84 + private enum WireBlock: Encodable {
85 + case text(String)
86 + case image(mediaType: String, base64: String)
87 + /// Assistant tool call echoed back into history.
88 + case toolUse(id: String, name: String, input: JSONValue)
89 + /// Tool result returned inside a **user** turn.
90 + case toolResult(toolUseID: String, content: String, isError: Bool)
91 +
92 + func encode(to encoder: Encoder) throws {
93 + var container = encoder.container(keyedBy: Key.self)
94 + switch self {
95 + case .text(let s):
96 + try container.encode("text", forKey: .type)
97 + try container.encode(s, forKey: .text)
98 + case .image(let mediaType, let base64):
99 + try container.encode("image", forKey: .type)
100 + var source = container.nestedContainer(keyedBy: Key.self, forKey: .source)
101 + try source.encode("base64", forKey: .type)
102 + try source.encode(mediaType, forKey: .mediaType)
103 + try source.encode(base64, forKey: .data)
104 + case .toolUse(let id, let name, let input):
105 + try container.encode("tool_use", forKey: .type)
106 + try container.encode(id, forKey: .id)
107 + try container.encode(name, forKey: .name)
108 + try container.encode(input, forKey: .input)
109 + case .toolResult(let toolUseID, let content, let isError):
110 + try container.encode("tool_result", forKey: .type)
111 + try container.encode(toolUseID, forKey: .toolUseID)
112 + try container.encode(content, forKey: .content)
113 + if isError {
114 + try container.encode(true, forKey: .isError)
115 + }
116 + }
117 + }
118 +
119 + enum Key: String, CodingKey {
120 + case type, text, source, data, id, name, input, content
121 + case mediaType = "media_type"
122 + case toolUseID = "tool_use_id"
123 + case isError = "is_error"
124 + }
125 + }
126 +
127 + // MARK: - Wire types (responses)
128 +
129 + private struct StreamEvent: Decodable {
130 + var type: String?
131 + /// Content block index (content_block_start/delta/stop events).
132 + var index: Int?
133 + var delta: Delta?
134 + var usage: WireUsage?
135 + var message: MessageStart?
136 + var contentBlock: ContentBlock?
137 + var error: WireError?
138 +
139 + enum CodingKeys: String, CodingKey {
140 + case type, index, delta, usage, message, error
141 + case contentBlock = "content_block"
142 + }
143 +
144 + struct Delta: Decodable {
145 + var type: String?
146 + var text: String?
147 + var thinking: String?
148 + /// input_json_delta fragments for a streaming tool_use block.
149 + var partialJSON: String?
150 + var stopReason: String?
151 + enum CodingKeys: String, CodingKey {
152 + case type, text, thinking
153 + case partialJSON = "partial_json"
154 + case stopReason = "stop_reason"
155 + }
156 + }
157 +
158 + /// content_block_start payload: a tool_use block announces id + name.
159 + struct ContentBlock: Decodable {
160 + var type: String?
161 + var id: String?
162 + var name: String?
163 + }
164 +
165 + struct MessageStart: Decodable {
166 + var usage: WireUsage?
167 + }
168 +
169 + struct WireError: Decodable {
170 + var message: String?
171 + }
172 + }
173 +
174 + private struct WireUsage: Decodable {
175 + var inputTokens: Int?
176 + var outputTokens: Int?
177 + enum CodingKeys: String, CodingKey {
178 + case inputTokens = "input_tokens"
179 + case outputTokens = "output_tokens"
180 + }
181 + }
182 +
183 + private struct WireResponse: Decodable {
184 + var content: [Block]?
185 + var usage: WireUsage?
186 + var stopReason: String?
187 +
188 + struct Block: Decodable {
189 + var type: String?
190 + var text: String?
191 + var thinking: String?
192 + /// tool_use block fields.
193 + var id: String?
194 + var name: String?
195 + var input: JSONValue?
196 + }
197 +
198 + enum CodingKeys: String, CodingKey {
199 + case content, usage
200 + case stopReason = "stop_reason"
201 + }
202 + }
203 +
204 + private struct WireModelList: Decodable {
205 + var data: [Entry]
206 + struct Entry: Decodable { var id: String }
207 + }
208 +
209 + // MARK: - Request construction
210 +
211 + private func urlRequest(path: String, apiKey: String, method: String = "POST") throws -> URLRequest {
212 + guard let base = providerID.defaultBaseURL else {
213 + throw ProviderError.invalidResponse(providerID, detail: "no base URL")
214 + }
215 + var request = URLRequest(url: base.appendingPathComponent(path))
216 + request.httpMethod = method
217 + request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
218 + request.setValue(Self.apiVersion, forHTTPHeaderField: "anthropic-version")
219 + if method == "POST" {
220 + request.setValue("application/json", forHTTPHeaderField: "Content-Type")
221 + }
222 + return request
223 + }
224 +
225 + private func buildBody(_ request: ChatRequest) throws -> Data {
226 + var messages: [WireMessage] = []
227 + for message in request.messages where message.role != .system {
228 + messages.append(wireMessage(from: message, vision: request.model.capabilities.vision))
229 + }
230 + let params = request.parameters
231 + var wire = WireRequest(
232 + model: request.model.id,
233 + maxTokens: params.maxTokens ?? Self.defaultMaxTokens,
234 + messages: messages
235 + )
236 + if let system = request.systemPrompt, !system.isEmpty {
237 + wire.system = system
238 + }
239 + if request.stream { wire.stream = true }
240 + // Claude 4.7+ removed temperature/top_p; ParameterSupport encodes that per model.
241 + let support = request.model.parameterSupport
242 + if support.temperature { wire.temperature = params.temperature }
243 + if support.topP { wire.topP = params.topP }
244 + if support.thinkingToggle, let enabled = params.thinkingEnabled {
245 + wire.thinking = enabled
246 + ? Thinking(type: "enabled", budgetTokens: 8000)
247 + : Thinking(type: "disabled")
248 + }
249 + // Native tool calling.
250 + if !request.tools.isEmpty, request.model.capabilities.tools {
251 + wire.tools = request.tools.map { spec in
252 + WireToolDef(
253 + name: spec.name,
254 + description: spec.description,
255 + inputSchema: JSONValue.parse(spec.parametersJSONSchema) ?? .emptyObject
256 + )
257 + }
258 + switch request.toolChoice {
259 + case .auto:
260 + break // Anthropic default — omit
261 + case .none:
262 + wire.toolChoice = WireToolChoice(type: "none")
263 + case .required:
264 + wire.toolChoice = WireToolChoice(type: "any")
265 + case .named(let name):
266 + wire.toolChoice = WireToolChoice(type: "tool", name: name)
267 + }
268 + }
269 + return try JSONEncoder().encode(wire)
270 + }
271 +
272 + private func wireMessage(from message: Message, vision: Bool) -> WireMessage {
273 + // Tool results travel as tool_result blocks inside a USER turn — not a
274 + // special role. Parallel calls put multiple results in one turn.
275 + if let results = message.toolResults, !results.isEmpty {
276 + let blocks = results.map { result in
277 + WireBlock.toolResult(
278 + toolUseID: result.toolCallID,
279 + content: result.content,
280 + isError: result.isError
281 + )
282 + }
283 + return WireMessage(role: "user", content: blocks)
284 + }
285 + let role = message.role == .assistant ? "assistant" : "user"
286 + var text = message.text
287 + for attachment in message.attachments where attachment.kind == .textFile {
288 + let contents = String(data: attachment.data, encoding: .utf8) ?? ""
289 + text += "\n\n```\(attachment.fileName)\n\(contents)\n```"
290 + }
291 + var blocks: [WireBlock] = []
292 + if vision, message.role == .user {
293 + for image in message.attachments where image.kind == .image {
294 + blocks.append(.image(mediaType: image.mimeType, base64: image.data.base64EncodedString()))
295 + }
296 + }
297 + // Assistant turns that called tools: text block (when non-empty)
298 + // followed by the tool_use blocks, echoed back verbatim.
299 + if let calls = message.toolCalls, !calls.isEmpty, message.role == .assistant {
300 + if !text.isEmpty {
301 + blocks.append(.text(text))
302 + }
303 + for call in calls {
304 + blocks.append(.toolUse(
305 + id: call.id,
306 + name: call.name,
307 + input: JSONValue.parse(call.argumentsJSON) ?? .emptyObject
308 + ))
309 + }
310 + return WireMessage(role: role, content: blocks)
311 + }
312 + blocks.append(.text(text.isEmpty ? " " : text))
313 + return WireMessage(role: role, content: blocks)
314 + }
315 +
316 + // MARK: - ProviderClient
317 +
318 + func streamChat(_ request: ChatRequest, apiKey: String) -> AsyncThrowingStream<ChatEvent, Error> {
319 + AsyncThrowingStream { continuation in
320 + let task = Task {
321 + do {
322 + var urlReq = try urlRequest(path: "messages", apiKey: apiKey)
323 + var streamRequest = request
324 + streamRequest.stream = true
325 + urlReq.httpBody = try buildBody(streamRequest)
326 +
327 + var usage = TokenUsage()
328 + var stopReason: String?
329 + // Streaming tool_use blocks, keyed by content block index.
330 + struct PartialToolUse {
331 + var id: String
332 + var name: String
333 + var inputJSON = ""
334 + }
335 + var partials: [Int: PartialToolUse] = [:]
336 + let decoder = JSONDecoder()
337 +
338 + for try await sse in StreamingService.sseEvents(for: urlReq, provider: providerID) {
339 + guard let data = sse.data.data(using: .utf8),
340 + let event = try? decoder.decode(StreamEvent.self, from: data) else {
341 + continue
342 + }
343 + let type = sse.event ?? event.type ?? ""
344 + switch type {
345 + case "message_start":
346 + if let u = event.message?.usage {
347 + usage.inputTokens = u.inputTokens ?? 0
348 + }
349 + case "content_block_start":
350 + if let block = event.contentBlock, block.type == "tool_use" {
351 + let index = event.index ?? (partials.keys.max().map { $0 + 1 } ?? 0)
352 + let id = block.id ?? "toolu_\(index)_\(UUID().uuidString.prefix(8))"
353 + let name = block.name ?? ""
354 + partials[index] = PartialToolUse(id: id, name: name)
355 + continuation.yield(.toolCallStarted(index: index, id: id, name: name))
356 + }
357 + case "content_block_delta":
358 + if let text = event.delta?.text, !text.isEmpty {
359 + continuation.yield(.textDelta(text))
360 + }
361 + if let thinking = event.delta?.thinking, !thinking.isEmpty {
362 + continuation.yield(.reasoningDelta(thinking))
363 + }
364 + if let fragment = event.delta?.partialJSON, !fragment.isEmpty,
365 + let index = event.index, partials[index] != nil {
366 + partials[index]?.inputJSON += fragment
367 + continuation.yield(.toolCallArgumentsDelta(index: index, delta: fragment))
368 + }
369 + case "content_block_stop":
370 + break // block complete; finalized set is emitted at stream end
371 + case "message_delta":
372 + if let u = event.usage {
373 + usage.outputTokens = u.outputTokens ?? usage.outputTokens
374 + }
375 + if let reason = event.delta?.stopReason {
376 + stopReason = reason
377 + }
378 + case "error":
379 + throw ProviderError.serverError(
380 + providerID, status: 200, message: event.error?.message
381 + )
382 + case "message_stop":
383 + break
384 + default:
385 + break // ping, unknown future events
386 + }
387 + }
388 + let calls = partials.sorted { $0.key < $1.key }.map { _, partial in
389 + ToolCall(
390 + id: partial.id,
391 + name: partial.name,
392 + argumentsJSON: partial.inputJSON.isEmpty ? "{}" : partial.inputJSON
393 + )
394 + }
395 + if !calls.isEmpty {
396 + continuation.yield(.toolCalls(calls))
397 + }
398 + continuation.yield(.usage(usage))
399 + continuation.yield(.finished(
400 + reason: stopReason,
401 + stop: StopReason.normalize(stopReason, hasToolCalls: !calls.isEmpty)
402 + ))
403 + continuation.finish()
404 + } catch {
405 + continuation.finish(throwing: error)
406 + }
407 + }
408 + continuation.onTermination = { _ in task.cancel() }
409 + }
410 + }
411 +
412 + func complete(_ request: ChatRequest, apiKey: String) async throws -> Message {
413 + var urlReq = try urlRequest(path: "messages", apiKey: apiKey)
414 + var plainRequest = request
415 + plainRequest.stream = false
416 + urlReq.httpBody = try buildBody(plainRequest)
417 + let data = try await StreamingService.postJSON(urlReq, provider: providerID)
418 + guard let response = try? JSONDecoder().decode(WireResponse.self, from: data) else {
419 + throw ProviderError.invalidResponse(providerID, detail: "undecodable messages response")
420 + }
421 + let blocks = response.content ?? []
422 + let text = blocks.compactMap { $0.type == "text" ? $0.text : nil }.joined()
423 + let thinking = blocks.compactMap { $0.type == "thinking" ? $0.thinking : nil }.joined()
424 + let calls: [ToolCall] = blocks.enumerated().compactMap { index, block in
425 + guard block.type == "tool_use" else { return nil }
426 + return ToolCall(
427 + id: block.id ?? "toolu_\(index)_\(UUID().uuidString.prefix(8))",
428 + name: block.name ?? "",
429 + argumentsJSON: (block.input ?? .emptyObject).jsonString
430 + )
431 + }
432 + var message = Message(
433 + role: .assistant,
434 + text: text,
435 + reasoning: thinking.isEmpty ? nil : thinking,
436 + toolCalls: calls.isEmpty ? nil : calls,
437 + modelID: request.model.id,
438 + provider: providerID
439 + )
440 + if let u = response.usage {
441 + let usage = TokenUsage(inputTokens: u.inputTokens ?? 0, outputTokens: u.outputTokens ?? 0)
442 + message.usage = usage
443 + message.estimatedCost = request.model.pricing?.cost(
444 + inputTokens: usage.inputTokens, outputTokens: usage.outputTokens
445 + )
446 + }
447 + return message
448 + }
449 +
450 + func listModelIDs(apiKey: String) async throws -> [String] {
451 + var urlReq = try urlRequest(path: "models", apiKey: apiKey, method: "GET")
452 + urlReq.url = urlReq.url.flatMap {
453 + URL(string: $0.absoluteString + "?limit=100")
454 + }
455 + let data = try await StreamingService.getJSON(urlReq, provider: providerID)
456 + guard let list = try? JSONDecoder().decode(WireModelList.self, from: data) else {
457 + throw ProviderError.invalidResponse(providerID, detail: "unrecognized /models response shape")
458 + }
459 + return list.data.map(\.id)
460 + }
461 +}
added Sources/ZyquoAgent/Providers/OpenAICompatibleClient.swift +676 −0
@@ -0,0 +1,676 @@
1 +//
2 +// OpenAICompatibleClient.swift
3 +// Zyquo Agent
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 +// Ported from Zyquo Cloud; Zyquo Agent adds native tool calling
14 +// (docs/PROVIDER-REUSE.md §5.2): `tools` [{type:"function",function:{…}}] +
15 +// `tool_choice` on requests, index-keyed accumulation of streaming
16 +// `delta.tool_calls` fragments, finish_reason "tool_calls" vs "stop", and
17 +// threading of assistant `tool_calls` + role:"tool" messages from history.
18 +//
19 +
20 +import Foundation
21 +
22 +struct OpenAICompatibleClient: ProviderClient {
23 + let providerID: ProviderID
24 + /// Custom endpoints override the provider's default base URL.
25 + var baseURLOverride: URL?
26 +
27 + init(provider: ProviderID, baseURLOverride: URL? = nil) {
28 + self.providerID = provider
29 + self.baseURLOverride = baseURLOverride
30 + }
31 +
32 + // MARK: - Wire types (requests)
33 +
34 + private struct WireRequest: Encodable {
35 + var model: String
36 + var messages: [WireMessage]
37 + var stream: Bool?
38 + var streamOptions: StreamOptions?
39 + var temperature: Double?
40 + var topP: Double?
41 + var maxTokens: Int?
42 + var maxCompletionTokens: Int?
43 + var frequencyPenalty: Double?
44 + var presencePenalty: Double?
45 + var reasoningEffort: String?
46 + var enableThinking: Bool?
47 + var tools: [WireTool]?
48 + var toolChoice: WireToolChoice?
49 +
50 + enum CodingKeys: String, CodingKey {
51 + case model, messages, stream, temperature, tools
52 + case streamOptions = "stream_options"
53 + case topP = "top_p"
54 + case maxTokens = "max_tokens"
55 + case maxCompletionTokens = "max_completion_tokens"
56 + case frequencyPenalty = "frequency_penalty"
57 + case presencePenalty = "presence_penalty"
58 + case reasoningEffort = "reasoning_effort"
59 + case enableThinking = "enable_thinking"
60 + case toolChoice = "tool_choice"
61 + }
62 + }
63 +
64 + private struct StreamOptions: Encodable {
65 + var includeUsage: Bool
66 + enum CodingKeys: String, CodingKey { case includeUsage = "include_usage" }
67 + }
68 +
69 + /// `tools: [{"type":"function","function":{name, description, parameters}}]`.
70 + private struct WireTool: Encodable {
71 + var type = "function"
72 + var function: Function
73 +
74 + struct Function: Encodable {
75 + var name: String
76 + var description: String
77 + var parameters: JSONValue
78 + }
79 + }
80 +
81 + /// `tool_choice`: "none"/"auto"/"required" or {"type":"function","function":{"name":…}}.
82 + private enum WireToolChoice: Encodable {
83 + case mode(String)
84 + case function(String)
85 +
86 + func encode(to encoder: Encoder) throws {
87 + switch self {
88 + case .mode(let mode):
89 + var container = encoder.singleValueContainer()
90 + try container.encode(mode)
91 + case .function(let name):
92 + var container = encoder.container(keyedBy: DynamicKey.self)
93 + try container.encode("function", forKey: DynamicKey("type"))
94 + var nested = container.nestedContainer(keyedBy: DynamicKey.self, forKey: DynamicKey("function"))
95 + try nested.encode(name, forKey: DynamicKey("name"))
96 + }
97 + }
98 + }
99 +
100 + private struct WireMessage: Encodable {
101 + var role: String
102 + var content: WireContent?
103 + /// Assistant turns that called tools carry them back verbatim.
104 + var toolCalls: [WireToolCallOut]?
105 + /// role:"tool" messages reference the call they answer.
106 + var toolCallID: String?
107 +
108 + enum CodingKeys: String, CodingKey {
109 + case role, content
110 + case toolCalls = "tool_calls"
111 + case toolCallID = "tool_call_id"
112 + }
113 + }
114 +
115 + /// An assistant tool call echoed back into history.
116 + private struct WireToolCallOut: Encodable {
117 + var id: String
118 + var type = "function"
119 + var function: Function
120 +
121 + struct Function: Encodable {
122 + var name: String
123 + var arguments: String
124 + }
125 + }
126 +
127 + /// Message content: plain string, or an array of text/image parts for vision.
128 + private enum WireContent: Encodable {
129 + case text(String)
130 + case parts([WirePart])
131 +
132 + func encode(to encoder: Encoder) throws {
133 + var container = encoder.singleValueContainer()
134 + switch self {
135 + case .text(let s): try container.encode(s)
136 + case .parts(let p): try container.encode(p)
137 + }
138 + }
139 + }
140 +
141 + private enum WirePart: Encodable {
142 + case text(String)
143 + case imageURL(String)
144 +
145 + func encode(to encoder: Encoder) throws {
146 + var container = encoder.container(keyedBy: DynamicKey.self)
147 + switch self {
148 + case .text(let s):
149 + try container.encode("text", forKey: DynamicKey("type"))
150 + try container.encode(s, forKey: DynamicKey("text"))
151 + case .imageURL(let url):
152 + try container.encode("image_url", forKey: DynamicKey("type"))
153 + var nested = container.nestedContainer(keyedBy: DynamicKey.self, forKey: DynamicKey("image_url"))
154 + try nested.encode(url, forKey: DynamicKey("url"))
155 + }
156 + }
157 + }
158 +
159 + private struct DynamicKey: CodingKey {
160 + var stringValue: String
161 + var intValue: Int? { nil }
162 + init(_ s: String) { stringValue = s }
163 + init?(stringValue: String) { self.stringValue = stringValue }
164 + init?(intValue: Int) { nil }
165 + }
166 +
167 + // MARK: - Wire types (responses)
168 +
169 + private struct WireChunk: Decodable {
170 + var choices: [WireChoice]?
171 + var usage: WireUsage?
172 + var citations: [String]?
173 + var searchResults: [WireSearchResult]?
174 +
175 + enum CodingKeys: String, CodingKey {
176 + case choices, usage, citations
177 + case searchResults = "search_results"
178 + }
179 + }
180 +
181 + private struct WireChoice: Decodable {
182 + var delta: WireDelta?
183 + var message: WireDelta?
184 + /// Together streams some models completions-style: the token text
185 + /// lives in `choices[].text` instead of `delta.content`.
186 + var text: String?
187 + var finishReason: String?
188 +
189 + enum CodingKeys: String, CodingKey {
190 + case delta, message, text
191 + case finishReason = "finish_reason"
192 + }
193 + }
194 +
195 + private struct WireDelta: Decodable {
196 + var content: String?
197 + var reasoningContent: String?
198 + var reasoning: String?
199 + /// Streaming: index-keyed fragments. Non-streaming: complete calls.
200 + var toolCalls: [WireToolCallDelta]?
201 +
202 + enum CodingKeys: String, CodingKey {
203 + case content, reasoning
204 + case reasoningContent = "reasoning_content"
205 + case toolCalls = "tool_calls"
206 + }
207 +
208 + init(from decoder: Decoder) throws {
209 + let container = try decoder.container(keyedBy: CodingKeys.self)
210 + reasoning = try? container.decodeIfPresent(String.self, forKey: .reasoning)
211 + reasoningContent = try? container.decodeIfPresent(String.self, forKey: .reasoningContent)
212 + toolCalls = try? container.decodeIfPresent([WireToolCallDelta].self, forKey: .toolCalls)
213 + // `content` is normally a string, but Mistral's reasoning models
214 + // return an array of chunks ({type: "thinking"|"text", …}).
215 + if let text = try? container.decodeIfPresent(String.self, forKey: .content) {
216 + content = text
217 + } else if let chunks = try? container.decodeIfPresent([ContentChunk].self, forKey: .content) {
218 + var textParts: [String] = []
219 + var thinkingParts: [String] = []
220 + for chunk in chunks {
221 + if chunk.type == "thinking" {
222 + thinkingParts.append(chunk.flattenedText)
223 + } else {
224 + textParts.append(chunk.flattenedText)
225 + }
226 + }
227 + content = textParts.joined()
228 + let thinking = thinkingParts.joined()
229 + if !thinking.isEmpty, reasoningContent == nil {
230 + reasoningContent = thinking
231 + }
232 + }
233 + }
234 +
235 + /// Mistral ThinkChunk/TextChunk: {"type":"text","text":…} or
236 + /// {"type":"thinking","thinking":[{"type":"text","text":…}]}.
237 + struct ContentChunk: Decodable {
238 + var type: String?
239 + var text: String?
240 + var thinking: [ContentChunkPart]?
241 +
242 + var flattenedText: String {
243 + if let text { return text }
244 + return (thinking ?? []).compactMap(\.text).joined()
245 + }
246 + }
247 +
248 + struct ContentChunkPart: Decodable {
249 + var text: String?
250 + }
251 + }
252 +
253 + /// One `delta.tool_calls[]` fragment (streaming) or `message.tool_calls[]`
254 + /// entry (non-streaming). The first fragment for an index carries id +
255 + /// function.name; subsequent ones carry function.arguments chunks.
256 + private struct WireToolCallDelta: Decodable {
257 + var index: Int?
258 + var id: String?
259 + var function: FunctionFragment?
260 +
261 + struct FunctionFragment: Decodable {
262 + var name: String?
263 + var arguments: String?
264 + }
265 + }
266 +
267 + private struct WireUsage: Decodable {
268 + var promptTokens: Int?
269 + var completionTokens: Int?
270 + var completionTokensDetails: Details?
271 +
272 + struct Details: Decodable {
273 + var reasoningTokens: Int?
274 + enum CodingKeys: String, CodingKey { case reasoningTokens = "reasoning_tokens" }
275 + }
276 +
277 + enum CodingKeys: String, CodingKey {
278 + case promptTokens = "prompt_tokens"
279 + case completionTokens = "completion_tokens"
280 + case completionTokensDetails = "completion_tokens_details"
281 + }
282 +
283 + var usage: TokenUsage {
284 + TokenUsage(
285 + inputTokens: promptTokens ?? 0,
286 + outputTokens: completionTokens ?? 0,
287 + reasoningTokens: completionTokensDetails?.reasoningTokens
288 + )
289 + }
290 + }
291 +
292 + private struct WireSearchResult: Decodable {
293 + var title: String?
294 + var url: String?
295 + }
296 +
297 + private struct WireModelList: Decodable {
298 + var data: [WireModelEntry]
299 + }
300 +
301 + private struct WireModelEntry: Decodable {
302 + var id: String
303 + }
304 +
305 + // MARK: - Request construction
306 +
307 + private var baseURL: URL? { baseURLOverride ?? providerID.defaultBaseURL }
308 +
309 + private func urlRequest(path: String, apiKey: String, method: String = "POST") throws -> URLRequest {
310 + guard let base = baseURL else {
311 + throw ProviderError.invalidResponse(providerID, detail: "no base URL configured")
312 + }
313 + // Preserve base path components ("…/v1", "…/compatible-mode/v1", "…/v1beta/openai").
314 + var request = URLRequest(url: base.appendingPathComponent(path))
315 + request.httpMethod = method
316 + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
317 + if method == "POST" {
318 + request.setValue("application/json", forHTTPHeaderField: "Content-Type")
319 + }
320 + return request
321 + }
322 +
323 + /// Providers whose final streamed chunk carries usage only when asked.
324 + private var wantsStreamOptions: Bool {
325 + switch providerID {
326 + case .openai, .xai, .gemini, .deepseek, .kimi, .together, .cerebras, .custom:
327 + return true
328 + // Qwen, DeepInfra, Perplexity include usage automatically; Mistral
329 + // rejects unknown params less gracefully — omit there.
330 + case .mistral, .qwen, .deepinfra, .perplexity:
331 + return false
332 + case .anthropic:
333 + return false // never routed here
334 + }
335 + }
336 +
337 + private func buildBody(_ request: ChatRequest) throws -> Data {
338 + var messages: [WireMessage] = []
339 + if let system = request.systemPrompt, !system.isEmpty {
340 + messages.append(WireMessage(role: "system", content: .text(system)))
341 + }
342 + for message in request.messages where message.role != .system {
343 + messages.append(contentsOf: wireMessages(from: message, vision: request.model.capabilities.vision))
344 + }
345 +
346 + let support = request.model.parameterSupport
347 + let params = request.parameters
348 + var wire = WireRequest(model: request.model.id, messages: messages)
349 + if request.stream {
350 + wire.stream = true
351 + if wantsStreamOptions {
352 + wire.streamOptions = StreamOptions(includeUsage: true)
353 + }
354 + }
355 + if support.temperature { wire.temperature = params.temperature }
356 + if support.topP { wire.topP = params.topP }
357 + if let max = params.maxTokens {
358 + if support.usesMaxCompletionTokens {
359 + wire.maxCompletionTokens = max
360 + } else {
361 + wire.maxTokens = max
362 + }
363 + }
364 + if support.frequencyPenalty { wire.frequencyPenalty = params.frequencyPenalty }
365 + if support.presencePenalty { wire.presencePenalty = params.presencePenalty }
366 + if support.reasoningEffort {
367 + // Mistral only accepts "high"/"none": map medium→high, low→none.
368 + if providerID == .mistral, let effort = params.reasoningEffort {
369 + wire.reasoningEffort = effort == "low" ? "none" : "high"
370 + } else {
371 + wire.reasoningEffort = params.reasoningEffort
372 + }
373 + }
374 + if support.thinkingToggle, providerID == .qwen {
375 + // DashScope: enable_thinking is only legal on streaming requests.
376 + if request.stream { wire.enableThinking = params.thinkingEnabled }
377 + }
378 + // Native tool calling (only for models that support it — providers
379 + // reject `tools` on non-tool models).
380 + if !request.tools.isEmpty, request.model.capabilities.tools {
381 + wire.tools = request.tools.map { spec in
382 + WireTool(function: WireTool.Function(
383 + name: spec.name,
384 + description: spec.description,
385 + parameters: JSONValue.parse(spec.parametersJSONSchema) ?? .emptyObject
386 + ))
387 + }
388 + switch request.toolChoice {
389 + case .auto:
390 + break // provider default — omit for maximum compatibility
391 + case .none:
392 + wire.toolChoice = .mode("none")
393 + case .required:
394 + wire.toolChoice = .mode("required")
395 + case .named(let name):
396 + wire.toolChoice = .function(name)
397 + }
398 + }
399 + let encoder = JSONEncoder()
400 + return try encoder.encode(wire)
401 + }
402 +
403 + /// One transcript Message → one or more wire messages. Tool results expand
404 + /// to one role:"tool" message per result; assistant turns carry their
405 + /// `tool_calls` back verbatim so multi-step tool history round-trips.
406 + private func wireMessages(from message: Message, vision: Bool) -> [WireMessage] {
407 + if let results = message.toolResults, !results.isEmpty {
408 + return results.map { result in
409 + WireMessage(role: "tool", content: .text(result.content), toolCallID: result.toolCallID)
410 + }
411 + }
412 + let role = message.role == .assistant ? "assistant" : "user"
413 + var text = message.text
414 + // Text-file attachments are injected inline, fenced with the file name.
415 + for attachment in message.attachments where attachment.kind == .textFile {
416 + let contents = String(data: attachment.data, encoding: .utf8) ?? ""
417 + text += "\n\n```\(attachment.fileName)\n\(contents)\n```"
418 + }
419 + let toolCallsOut: [WireToolCallOut]? = message.toolCalls.flatMap { calls in
420 + calls.isEmpty ? nil : calls.map { call in
421 + WireToolCallOut(
422 + id: call.id,
423 + function: WireToolCallOut.Function(name: call.name, arguments: call.argumentsJSON)
424 + )
425 + }
426 + }
427 + let images = message.attachments.filter { $0.kind == .image }
428 + guard vision, !images.isEmpty, message.role == .user else {
429 + // Assistant tool-call turns may have empty text — omit content then.
430 + let content: WireContent? = (text.isEmpty && toolCallsOut != nil) ? nil : .text(text)
431 + return [WireMessage(role: role, content: content, toolCalls: toolCallsOut)]
432 + }
433 + var parts: [WirePart] = [.text(text)]
434 + for image in images {
435 + let dataURI = "data:\(image.mimeType);base64,\(image.data.base64EncodedString())"
436 + parts.append(.imageURL(dataURI))
437 + }
438 + return [WireMessage(role: role, content: .parts(parts), toolCalls: toolCallsOut)]
439 + }
440 +
441 + // MARK: - Streaming tool-call accumulation
442 +
443 + /// Accumulates index-keyed `delta.tool_calls` fragments into complete calls.
444 + private struct ToolCallAccumulator {
445 + private struct Partial {
446 + var id: String?
447 + var name: String?
448 + var arguments = ""
449 + var announced = false
450 + }
451 +
452 + private var partials: [Int: Partial] = [:]
453 +
454 + var isEmpty: Bool { partials.isEmpty }
455 +
456 + /// Consumes one fragment; returns events to yield (started/arguments deltas).
457 + mutating func consume(_ fragments: [WireToolCallDelta]) -> [ChatEvent] {
458 + var events: [ChatEvent] = []
459 + for fragment in fragments {
460 + let index = fragment.index ?? partials.keys.max() ?? 0
461 + var partial = partials[index] ?? Partial()
462 + if let id = fragment.id, !id.isEmpty { partial.id = id }
463 + if let name = fragment.function?.name, !name.isEmpty {
464 + partial.name = (partial.name ?? "") + name
465 + }
466 + if !partial.announced, let name = partial.name {
467 + partial.announced = true
468 + if partial.id == nil {
469 + // Providers occasionally omit ids — synthesize a stable one.
470 + partial.id = "call_\(index)_\(UUID().uuidString.prefix(8))"
471 + }
472 + events.append(.toolCallStarted(index: index, id: partial.id ?? "", name: name))
473 + }
474 + if let args = fragment.function?.arguments, !args.isEmpty {
475 + partial.arguments += args
476 + events.append(.toolCallArgumentsDelta(index: index, delta: args))
477 + }
478 + partials[index] = partial
479 + }
480 + return events
481 + }
482 +
483 + /// The finalized calls in index order.
484 + func finalized() -> [ToolCall] {
485 + partials.sorted { $0.key < $1.key }.map { index, partial in
486 + ToolCall(
487 + id: partial.id ?? "call_\(index)_\(UUID().uuidString.prefix(8))",
488 + name: partial.name ?? "",
489 + argumentsJSON: partial.arguments.isEmpty ? "{}" : partial.arguments
490 + )
491 + }
492 + }
493 + }
494 +
495 + // MARK: - ProviderClient
496 +
497 + func streamChat(_ request: ChatRequest, apiKey: String) -> AsyncThrowingStream<ChatEvent, Error> {
498 + AsyncThrowingStream { continuation in
499 + let task = Task {
500 + do {
501 + var urlReq = try urlRequest(path: "chat/completions", apiKey: apiKey)
502 + var streamRequest = request
503 + streamRequest.stream = true
504 + urlReq.httpBody = try buildBody(streamRequest)
505 +
506 + var citationsSent = false
507 + var finishReason: String?
508 + var accumulator = ToolCallAccumulator()
509 + let decoder = JSONDecoder()
510 +
511 + for try await event in StreamingService.sseEvents(for: urlReq, provider: providerID) {
512 + if event.data == "[DONE]" { break }
513 + guard let data = event.data.data(using: .utf8),
514 + let chunk = try? decoder.decode(WireChunk.self, from: data) else {
515 + continue // tolerate unknown/malformed keep-alive chunks
516 + }
517 + if let choice = chunk.choices?.first {
518 + if let reasoning = choice.delta?.reasoningContent ?? choice.delta?.reasoning,
519 + !reasoning.isEmpty {
520 + continuation.yield(.reasoningDelta(reasoning))
521 + }
522 + let deltaText = choice.delta?.content ?? choice.text
523 + if let deltaText, !deltaText.isEmpty {
524 + continuation.yield(.textDelta(deltaText))
525 + }
526 + if let fragments = choice.delta?.toolCalls, !fragments.isEmpty {
527 + for toolEvent in accumulator.consume(fragments) {
528 + continuation.yield(toolEvent)
529 + }
530 + }
531 + if let reason = choice.finishReason {
532 + finishReason = reason
533 + }
534 + }
535 + if !citationsSent, let citations = Self.citations(from: chunk), !citations.isEmpty {
536 + citationsSent = true
537 + continuation.yield(.citations(citations))
538 + }
539 + if let usage = chunk.usage {
540 + continuation.yield(.usage(usage.usage))
541 + }
542 + }
543 + let calls = accumulator.finalized()
544 + if !calls.isEmpty {
545 + continuation.yield(.toolCalls(calls))
546 + }
547 + continuation.yield(.finished(
548 + reason: finishReason,
549 + stop: StopReason.normalize(finishReason, hasToolCalls: !calls.isEmpty)
550 + ))
551 + continuation.finish()
552 + } catch {
553 + continuation.finish(throwing: error)
554 + }
555 + }
556 + continuation.onTermination = { _ in task.cancel() }
557 + }
558 + }
559 +
560 + func complete(_ request: ChatRequest, apiKey: String) async throws -> Message {
561 + // Some models reject non-streaming calls — aggregate a stream instead.
562 + if request.model.parameterSupport.requiresStreaming {
563 + return try await completeViaStream(request, apiKey: apiKey)
564 + }
565 + var urlReq = try urlRequest(path: "chat/completions", apiKey: apiKey)
566 + var plainRequest = request
567 + plainRequest.stream = false
568 + urlReq.httpBody = try buildBody(plainRequest)
569 + let data = try await StreamingService.postJSON(urlReq, provider: providerID)
570 + let chunk = try decodeOrThrow(WireChunk.self, from: data)
571 + guard let choice = chunk.choices?.first, let content = choice.message ?? choice.delta else {
572 + throw ProviderError.invalidResponse(providerID, detail: "response contained no message")
573 + }
574 + var message = Message(
575 + role: .assistant,
576 + text: content.content ?? choice.text ?? "",
577 + reasoning: content.reasoningContent ?? content.reasoning,
578 + modelID: request.model.id,
579 + provider: providerID
580 + )
581 + if let wireCalls = content.toolCalls, !wireCalls.isEmpty {
582 + message.toolCalls = wireCalls.enumerated().map { offset, call in
583 + ToolCall(
584 + id: call.id ?? "call_\(call.index ?? offset)_\(UUID().uuidString.prefix(8))",
585 + name: call.function?.name ?? "",
586 + argumentsJSON: call.function?.arguments ?? "{}"
587 + )
588 + }
589 + }
590 + if let citations = Self.citations(from: chunk) {
591 + message.citations = citations
592 + }
593 + if let usage = chunk.usage?.usage {
594 + message.usage = usage
595 + message.estimatedCost = request.model.pricing?.cost(
596 + inputTokens: usage.inputTokens, outputTokens: usage.outputTokens
597 + )
598 + }
599 + return message
600 + }
601 +
602 + func listModelIDs(apiKey: String) async throws -> [String] {
603 + let urlReq = try urlRequest(path: "models", apiKey: apiKey, method: "GET")
604 + let data = try await StreamingService.getJSON(urlReq, provider: providerID)
605 + // Together returns a bare array; everyone else wraps in {"data": […]}.
606 + // Gemini's compat endpoint prefixes IDs with "models/" — normalize.
607 + let ids: [String]
608 + if let list = try? JSONDecoder().decode(WireModelList.self, from: data) {
609 + ids = list.data.map(\.id)
610 + } else if let bare = try? JSONDecoder().decode([WireModelEntry].self, from: data) {
611 + ids = bare.map(\.id)
612 + } else {
613 + throw ProviderError.invalidResponse(providerID, detail: "unrecognized /models response shape")
614 + }
615 + return ids.map { $0.hasPrefix("models/") ? String($0.dropFirst(7)) : $0 }
616 + }
617 +
618 + /// Non-streaming result assembled from the streaming endpoint, for models
619 + /// that only support `stream: true`.
620 + private func completeViaStream(_ request: ChatRequest, apiKey: String) async throws -> Message {
621 + var text = ""
622 + var reasoning = ""
623 + var citations: [Citation] = []
624 + var toolCalls: [ToolCall] = []
625 + var usage: TokenUsage?
626 + for try await event in streamChat(request, apiKey: apiKey) {
627 + switch event {
628 + case .textDelta(let delta): text += delta
629 + case .reasoningDelta(let delta): reasoning += delta
630 + case .citations(let c): citations = c
631 + case .toolCalls(let calls): toolCalls = calls
632 + case .toolCallStarted, .toolCallArgumentsDelta: break // covered by .toolCalls
633 + case .usage(let u): usage = u
634 + case .finished: break
635 + }
636 + }
637 + var message = Message(
638 + role: .assistant,
639 + text: text,
640 + reasoning: reasoning.isEmpty ? nil : reasoning,
641 + citations: citations,
642 + toolCalls: toolCalls.isEmpty ? nil : toolCalls,
643 + modelID: request.model.id,
644 + provider: providerID
645 + )
646 + if let usage {
647 + message.usage = usage
648 + message.estimatedCost = request.model.pricing?.cost(
649 + inputTokens: usage.inputTokens, outputTokens: usage.outputTokens
650 + )
651 + }
652 + return message
653 + }
654 +
655 + // MARK: - Helpers
656 +
657 + private func decodeOrThrow<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
658 + do {
659 + return try JSONDecoder().decode(type, from: data)
660 + } catch {
661 + throw ProviderError.invalidResponse(providerID, detail: "decode failed: \(error.localizedDescription)")
662 + }
663 + }
664 +
665 + /// Perplexity: `citations` is an array of URL strings; `search_results`
666 + /// adds titles. Merge both into numbered citations.
667 + private static func citations(from chunk: WireChunk) -> [Citation]? {
668 + guard let urls = chunk.citations, !urls.isEmpty else { return nil }
669 + let titles = chunk.searchResults ?? []
670 + return urls.enumerated().compactMap { index, urlString in
671 + guard let url = URL(string: urlString) else { return nil }
672 + let title = index < titles.count ? titles[index].title : nil
673 + return Citation(index: index + 1, url: url, title: title)
674 + }
675 + }
676 +}
added Sources/ZyquoAgent/Providers/ProviderProtocol.swift +165 −0
@@ -0,0 +1,165 @@
1 +//
2 +// ProviderProtocol.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Ported from Zyquo Cloud and extended with the normalized tool-calling
9 +// interface (docs/PROVIDER-REUSE.md §5.3): ChatRequest carries ToolSpecs and
10 +// a ToolChoice; ChatEvent adds live tool-call streaming cases and a
11 +// normalized StopReason on `.finished`. Per-provider wire translation stays
12 +// inside the clients — the agent loop sees only this surface.
13 +//
14 +
15 +import Foundation
16 +
17 +/// A provider-agnostic chat request. Clients translate this into their wire format;
18 +/// provider behavior differences never leak above this layer.
19 +struct ChatRequest {
20 + var model: AIModel
21 + var systemPrompt: String?
22 + var messages: [Message]
23 + var parameters: ChatParameters
24 + var stream: Bool = true
25 + /// Tools offered to the model (empty = plain chat, wire-identical to Cloud).
26 + var tools: [ToolSpec] = []
27 + /// How the model may use the offered tools.
28 + var toolChoice: ToolChoice = .auto
29 +}
30 +
31 +/// Incremental events surfaced while a response streams.
32 +enum ChatEvent {
33 + case reasoningDelta(String)
34 + case textDelta(String)
35 + case citations([Citation])
36 + /// A tool-use block/fragment opened: show the live chip in the UI.
37 + case toolCallStarted(index: Int, id: String, name: String)
38 + /// Streamed tool-call argument JSON fragments for the call at `index`.
39 + case toolCallArgumentsDelta(index: Int, delta: String)
40 + /// The finalized, accumulated set of tool calls for this turn (emitted
41 + /// once, before `.finished`, whenever the model called tools).
42 + case toolCalls([ToolCall])
43 + case usage(TokenUsage)
44 + /// `reason` is the provider's raw finish/stop string; `stop` is the
45 + /// normalized value the agent loop branches on.
46 + case finished(reason: String?, stop: StopReason)
47 +}
48 +
49 +/// One cloud AI provider client.
50 +protocol ProviderClient {
51 + var providerID: ProviderID { get }
52 +
53 + /// Streams a chat completion. The stream finishes after `.finished` or throws a `ProviderError`.
54 + func streamChat(_ request: ChatRequest, apiKey: String) -> AsyncThrowingStream<ChatEvent, Error>
55 +
56 + /// Non-streaming completion (used for title generation and the verify harness).
57 + func complete(_ request: ChatRequest, apiKey: String) async throws -> Message
58 +
59 + /// Model IDs currently served by the provider, for dynamic catalog refresh.
60 + func listModelIDs(apiKey: String) async throws -> [String]
61 +}
62 +
63 +extension ProviderClient {
64 + /// Key validation: performs the cheapest authenticated call available and
65 + /// returns the round-trip latency. `fallbackModel` is used for providers
66 + /// without a /models endpoint (Perplexity) — pass the provider's cheapest
67 + /// catalog model.
68 + func testKey(_ apiKey: String, fallbackModel: AIModel?) async throws -> TimeInterval {
69 + let start = Date()
70 + if providerID.supportsModelListing {
71 + _ = try await listModelIDs(apiKey: apiKey)
72 + } else {
73 + guard let model = fallbackModel else {
74 + throw ProviderError.noModelAvailable(providerID)
75 + }
76 + var request = ChatRequest(
77 + model: model,
78 + systemPrompt: nil,
79 + messages: [Message(role: .user, text: "Reply with exactly: OK")],
80 + parameters: ChatParameters(maxTokens: 16),
81 + stream: false
82 + )
83 + request.parameters.temperature = nil
84 + _ = try await complete(request, apiKey: apiKey)
85 + }
86 + return Date().timeIntervalSince(start)
87 + }
88 +}
89 +
90 +/// Errors mapped to clear, human-readable messages ("Invalid API key for Mistral",
91 +/// "Rate limited — retrying in 20s").
92 +enum ProviderError: LocalizedError {
93 + case invalidAPIKey(ProviderID)
94 + case rateLimited(ProviderID, retryAfter: TimeInterval?)
95 + case serverError(ProviderID, status: Int, message: String?)
96 + case badRequest(ProviderID, message: String?)
97 + case networkError(underlying: Error)
98 + case invalidResponse(ProviderID, detail: String)
99 + case missingAPIKey(ProviderID)
100 + case noModelAvailable(ProviderID)
101 + case cancelled
102 +
103 + var errorDescription: String? {
104 + switch self {
105 + case .invalidAPIKey(let p):
106 + return "Invalid API key for \(p.displayName)."
107 + case .rateLimited(let p, let retryAfter):
108 + if let s = retryAfter {
109 + return "\(p.displayName) rate limited — retry in \(Int(s.rounded()))s."
110 + }
111 + return "\(p.displayName) rate limited — please retry shortly."
112 + case .serverError(let p, let status, let message):
113 + return "\(p.displayName) server error (\(status))\(message.map { ": \($0)" } ?? "")."
114 + case .badRequest(let p, let message):
115 + return "\(p.displayName) rejected the request\(message.map { ": \($0)" } ?? "")."
116 + case .networkError(let underlying):
117 + return "Network error: \(underlying.localizedDescription)"
118 + case .invalidResponse(let p, let detail):
119 + return "Unexpected response from \(p.displayName): \(detail)"
120 + case .missingAPIKey(let p):
121 + return "No API key configured for \(p.displayName). Add one in Settings → Providers & Keys."
122 + case .noModelAvailable(let p):
123 + return "No model available for \(p.displayName)."
124 + case .cancelled:
125 + return "Generation stopped."
126 + }
127 + }
128 +
129 + /// Maps an HTTP status + provider error body to a typed error.
130 + static func from(status: Int, body: Data, provider: ProviderID) -> ProviderError {
131 + let message = Self.extractMessage(from: body)
132 + switch status {
133 + case 401, 403:
134 + return .invalidAPIKey(provider)
135 + case 429:
136 + return .rateLimited(provider, retryAfter: nil)
137 + case 400, 404, 422:
138 + return .badRequest(provider, message: message)
139 + default:
140 + return .serverError(provider, status: status, message: message)
141 + }
142 + }
143 +
144 + /// Providers wrap errors differently ({"error":{"message":…}}, {"message":…},
145 + /// {"error":"…"}, Gemini arrays…). Try the common shapes.
146 + private static func extractMessage(from body: Data) -> String? {
147 + guard let obj = try? JSONSerialization.jsonObject(with: body) else {
148 + return String(data: body.prefix(300), encoding: .utf8)
149 + }
150 + if let dict = obj as? [String: Any] {
151 + if let err = dict["error"] as? [String: Any], let msg = err["message"] as? String {
152 + return msg
153 + }
154 + if let msg = dict["error"] as? String { return msg }
155 + if let msg = dict["message"] as? String { return msg }
156 + if let msg = dict["detail"] as? String { return msg }
157 + }
158 + if let arr = obj as? [[String: Any]],
159 + let err = arr.first?["error"] as? [String: Any],
160 + let msg = err["message"] as? String {
161 + return msg
162 + }
163 + return String(data: body.prefix(300), encoding: .utf8)
164 + }
165 +}
added Sources/ZyquoAgent/Providers/ProviderRegistry.swift +36 −0
@@ -0,0 +1,36 @@
1 +//
2 +// ProviderRegistry.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Ported verbatim from Zyquo Cloud.
9 +//
10 +
11 +import Foundation
12 +
13 +/// Resolves the right client for a provider or custom model. The only place
14 +/// that knows which wire format each provider speaks.
15 +enum ProviderRegistry {
16 + static func client(for model: AIModel) -> ProviderClient {
17 + switch model.provider.wireFormat {
18 + case .anthropicMessages:
19 + return AnthropicClient()
20 + case .openAIChatCompletions:
21 + return OpenAICompatibleClient(
22 + provider: model.provider,
23 + baseURLOverride: model.customBaseURL
24 + )
25 + }
26 + }
27 +
28 + static func client(for provider: ProviderID) -> ProviderClient {
29 + switch provider.wireFormat {
30 + case .anthropicMessages:
31 + return AnthropicClient()
32 + case .openAIChatCompletions:
33 + return OpenAICompatibleClient(provider: provider)
34 + }
35 + }
36 +}
added Sources/ZyquoAgent/Services/ModelCatalog.swift +113 −0
@@ -0,0 +1,113 @@
1 +//
2 +// ModelCatalog.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Single source of truth for model data. Built-in entries are the exact
9 +// Zyquo Cloud catalog (ModelCatalogData.swift); dynamic /models refreshes and
10 +// user-defined custom models layer on top. Views and clients never hardcode
11 +// model IDs. Zyquo Agent adds the agent-capable filter and the default agent
12 +// model (docs/PROVIDER-REUSE.md §3).
13 +//
14 +
15 +import Combine
16 +import Foundation
17 +
18 +@MainActor
19 +final class ModelCatalog: ObservableObject {
20 + /// Built-in catalog (identical to Zyquo Cloud's — keep in sync).
21 + @Published private(set) var builtIn: [AIModel] = ModelCatalogData.all
22 + /// User-defined custom models (custom ID + base URL).
23 + @Published var customModels: [AIModel] = []
24 + /// Model IDs confirmed live by the last dynamic refresh, per provider.
25 + @Published private(set) var liveModelIDs: [ProviderID: Set<String>] = [:]
26 + /// Favorite model IDs, pinned at the top of pickers.
27 + @Published var favoriteIDs: Set<String> = []
28 +
29 + var all: [AIModel] { builtIn + customModels }
30 +
31 + func models(for provider: ProviderID) -> [AIModel] {
32 + all.filter { $0.provider == provider }
33 + .sorted { rank($0) < rank($1) }
34 + }
35 +
36 + func model(id: String, provider: ProviderID) -> AIModel? {
37 + all.first { $0.id == id && $0.provider == provider }
38 + }
39 +
40 + /// Cheapest non-legacy chat model for a provider (used for key tests and
41 + /// auto-title generation). Non-reasoning models are preferred — reasoning
42 + /// models burn their token budget thinking, useless for tiny utility calls.
43 + func cheapestModel(for provider: ProviderID) -> AIModel? {
44 + let candidates = models(for: provider).filter { !$0.isLegacy }
45 + let plain = candidates.filter { !$0.capabilities.reasoning }
46 + return (plain.isEmpty ? candidates : plain)
47 + .min { ($0.pricing?.outputPerMTok ?? .infinity) < ($1.pricing?.outputPerMTok ?? .infinity) }
48 + }
49 +
50 + /// Default model offered for new conversations.
51 + var defaultModel: AIModel? {
52 + all.first { $0.isRecommended } ?? all.first
53 + }
54 +
55 + // MARK: - Agent-capable subset (Zyquo Agent addition)
56 +
57 + /// Models suitable for deep agentic, multi-step tool use — the curated
58 + /// subset of the shared catalog (docs/PROVIDER-REUSE.md §3).
59 + var agentCapableModels: [AIModel] {
60 + all.filter(\.agentCapable)
61 + }
62 +
63 + func agentCapableModels(for provider: ProviderID) -> [AIModel] {
64 + models(for: provider).filter(\.agentCapable)
65 + }
66 +
67 + /// Default agent model: Claude Sonnet 5 (falls back to the first
68 + /// agent-capable model if the catalog ever changes).
69 + var defaultAgentModel: AIModel? {
70 + model(id: AgentModelSupport.defaultModelID, provider: AgentModelSupport.defaultModelProvider)
71 + ?? agentCapableModels.first
72 + }
73 +
74 + /// Suggested agent default for one provider (bolded 🤖 entries in the doc).
75 + func defaultAgentModel(for provider: ProviderID) -> AIModel? {
76 + if let id = AgentModelSupport.providerDefaults[provider],
77 + let model = model(id: id, provider: provider), model.agentCapable {
78 + return model
79 + }
80 + return agentCapableModels(for: provider).first
81 + }
82 +
83 + /// The recommended top tier, surfaced first in the model chip.
84 + var recommendedAgentModels: [AIModel] {
85 + AgentModelSupport.recommendedKeys.compactMap { key in
86 + let parts = key.split(separator: "|", maxSplits: 1)
87 + guard parts.count == 2, let provider = ProviderID(rawValue: String(parts[0])) else { return nil }
88 + return model(id: String(parts[1]), provider: provider)
89 + }
90 + }
91 +
92 + // MARK: - Dynamic listings
93 +
94 + /// Merges a dynamic /models listing: known models are marked live; unknown
95 + /// IDs are surfaced so the user can add them.
96 + func applyLiveListing(_ ids: [String], for provider: ProviderID) {
97 + liveModelIDs[provider] = Set(ids)
98 + }
99 +
100 + /// IDs returned by the provider but absent from the built-in catalog.
101 + func unknownLiveIDs(for provider: ProviderID) -> [String] {
102 + guard let live = liveModelIDs[provider] else { return [] }
103 + let known = Set(models(for: provider).map(\.id))
104 + return live.subtracting(known).sorted()
105 + }
106 +
107 + private func rank(_ model: AIModel) -> Int {
108 + if favoriteIDs.contains(model.id) { return 0 }
109 + if model.isRecommended { return 1 }
110 + if model.isLegacy { return 3 }
111 + return 2
112 + }
113 +}
added Sources/ZyquoAgent/Services/ModelCatalogData.swift +1364 −0
@@ -0,0 +1,1364 @@
1 +//
2 +// ModelCatalogData.swift
3 +// Zyquo Agent
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/ZyquoAgent/Services/PersistenceService.swift +54 −0
@@ -0,0 +1,54 @@
1 +//
2 +// PersistenceService.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// JSON persistence in ~/Library/Application Support/ZyquoAgent/:
9 +// vault.zq encrypted API-key vault (SecureKeyStore)
10 +// Workspaces/ per-task working directories (WorkspaceManager)
11 +// settings.json, personas.json, … generic documents
12 +//
13 +// Ported from Zyquo Cloud (root folder renamed ZyquoCloud → ZyquoAgent;
14 +// Cloud's per-conversation persistence is superseded by Agent's Task
15 +// persistence, which arrives with the Agent layer).
16 +//
17 +
18 +import Foundation
19 +
20 +struct PersistenceService {
21 + static let shared = PersistenceService()
22 +
23 + let rootDirectory: URL
24 + /// Per-task working directories root (Phase 2 Workspace layer).
25 + var workspacesDirectory: URL { rootDirectory.appendingPathComponent("Workspaces") }
26 +
27 + private let encoder: JSONEncoder
28 + private let decoder: JSONDecoder
29 +
30 + init(rootDirectory: URL? = nil) {
31 + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
32 + self.rootDirectory = rootDirectory ?? base.appendingPathComponent("ZyquoAgent")
33 + encoder = JSONEncoder()
34 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
35 + encoder.dateEncodingStrategy = .iso8601
36 + decoder = JSONDecoder()
37 + decoder.dateDecodingStrategy = .iso8601
38 + try? FileManager.default.createDirectory(at: workspacesDirectory, withIntermediateDirectories: true)
39 + }
40 +
41 + // MARK: - Generic documents (personas, templates, settings…)
42 +
43 + func load<T: Decodable>(_ type: T.Type, from fileName: String) -> T? {
44 + let url = rootDirectory.appendingPathComponent(fileName)
45 + guard let data = try? Data(contentsOf: url) else { return nil }
46 + return try? decoder.decode(type, from: data)
47 + }
48 +
49 + func save<T: Encodable>(_ value: T, to fileName: String) {
50 + let url = rootDirectory.appendingPathComponent(fileName)
51 + guard let data = try? encoder.encode(value) else { return }
52 + try? data.write(to: url, options: .atomic)
53 + }
54 +}
added Sources/ZyquoAgent/Services/SecureKeyStore.swift +178 −0
@@ -0,0 +1,178 @@
1 +//
2 +// SecureKeyStore.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Custom API-key vault — deliberately NOT the macOS Keychain. Same design and
9 +// blob format as Zyquo Cloud's vault; only the HKDF info string and pepper
10 +// differ, so the two apps keep separate, mutually-undecryptable vaults with an
11 +// identical user experience (docs/PROVIDER-REUSE.md §4).
12 +//
13 +// Design:
14 +// • Vault file ~/Library/Application Support/ZyquoAgent/vault.zq
15 +// layout: [salt 32B][AES-GCM nonce 12B][ciphertext+tag]
16 +// plaintext: JSON dictionary {"openai": "sk-…", "anthropic": "sk-ant-…", …}
17 +// • Master key HKDF-SHA256(ikm: machineEntropy ‖ pepper, salt: vault salt,
18 +// info: "ZyquoAgent.vault.v1") → AES-256-GCM key.
19 +// machineEntropy = IOPlatformUUID (IOKit) ‖ user home path — binds the
20 +// vault to this machine and account.
21 +// • Pepper: compiled-in, assembled at runtime from obfuscated fragments —
22 +// never a plain string literal in the binary.
23 +//
24 +// Keys are decrypted only on demand, never logged, never written to disk in
25 +// plaintext, and redacted to their last 4 characters everywhere in the UI.
26 +//
27 +
28 +import CryptoKit
29 +import Foundation
30 +import IOKit
31 +import Security
32 +
33 +struct SecureKeyStore {
34 + enum VaultError: LocalizedError {
35 + case corrupted
36 + case machineIdentityUnavailable
37 +
38 + var errorDescription: String? {
39 + switch self {
40 + case .corrupted:
41 + return "The key vault is damaged or belongs to another machine."
42 + case .machineIdentityUnavailable:
43 + return "Could not read this Mac's hardware identity."
44 + }
45 + }
46 + }
47 +
48 + private static let saltLength = 32
49 + private static let keyLength = 32
50 +
51 + let vaultURL: URL
52 + /// Overridable for tests; defaults to real machine identity.
53 + private let machineEntropy: () throws -> Data
54 +
55 + init(
56 + vaultURL: URL? = nil,
57 + machineEntropy: (() throws -> Data)? = nil
58 + ) {
59 + let root = PersistenceService.shared.rootDirectory
60 + self.vaultURL = vaultURL ?? root.appendingPathComponent("vault.zq")
61 + self.machineEntropy = machineEntropy ?? Self.defaultMachineEntropy
62 + }
63 +
64 + // MARK: - Public API
65 +
66 + /// All stored keys (provider rawValue → API key). Empty if no vault exists.
67 + func loadKeys() throws -> [String: String] {
68 + guard let blob = try? Data(contentsOf: vaultURL) else { return [:] }
69 + guard blob.count > Self.saltLength + 12 + 16 else { throw VaultError.corrupted }
70 + let salt = blob.prefix(Self.saltLength)
71 + let rest = blob.dropFirst(Self.saltLength)
72 + let key = try masterKey(salt: salt)
73 + do {
74 + let box = try AES.GCM.SealedBox(combined: rest)
75 + let plaintext = try AES.GCM.open(box, using: key)
76 + return try JSONDecoder().decode([String: String].self, from: plaintext)
77 + } catch {
78 + throw VaultError.corrupted
79 + }
80 + }
81 +
82 + /// Encrypts and atomically writes the full key dictionary.
83 + func saveKeys(_ keys: [String: String]) throws {
84 + let salt = try existingSalt() ?? Self.randomBytes(Self.saltLength)
85 + let key = try masterKey(salt: salt)
86 + let plaintext = try JSONEncoder().encode(keys)
87 + let box = try AES.GCM.seal(plaintext, using: key)
88 + guard let combined = box.combined else { throw VaultError.corrupted }
89 + var blob = Data(salt)
90 + blob.append(combined)
91 + try FileManager.default.createDirectory(
92 + at: vaultURL.deletingLastPathComponent(), withIntermediateDirectories: true
93 + )
94 + try blob.write(to: vaultURL, options: [.atomic, .completeFileProtection])
95 + }
96 +
97 + func key(for provider: ProviderID) throws -> String? {
98 + try loadKeys()[provider.rawValue]
99 + }
100 +
101 + func setKey(_ apiKey: String, for provider: ProviderID) throws {
102 + var keys = try loadKeys()
103 + keys[provider.rawValue] = apiKey
104 + try saveKeys(keys)
105 + }
106 +
107 + func deleteKey(for provider: ProviderID) throws {
108 + var keys = try loadKeys()
109 + keys.removeValue(forKey: provider.rawValue)
110 + try saveKeys(keys)
111 + }
112 +
113 + /// "••••…abcd" display form. Never show more.
114 + static func redacted(_ apiKey: String) -> String {
115 + let suffix = apiKey.suffix(4)
116 + return "••••\(suffix)"
117 + }
118 +
119 + // MARK: - Key derivation
120 +
121 + private func masterKey(salt: Data) throws -> SymmetricKey {
122 + var ikm = try machineEntropy()
123 + ikm.append(Self.pepper())
124 + return HKDF<SHA256>.deriveKey(
125 + inputKeyMaterial: SymmetricKey(data: ikm),
126 + salt: salt,
127 + info: Data("ZyquoAgent.vault.v1".utf8),
128 + outputByteCount: Self.keyLength
129 + )
130 + }
131 +
132 + /// Hardware UUID + home path. Binds the vault to machine + account.
133 + private static func defaultMachineEntropy() throws -> Data {
134 + guard let uuid = platformUUID() else { throw VaultError.machineIdentityUnavailable }
135 + var entropy = Data(uuid.utf8)
136 + entropy.append(Data(NSHomeDirectory().utf8))
137 + return entropy
138 + }
139 +
140 + /// IOPlatformUUID from the IOPlatformExpertDevice registry entry.
141 + private static func platformUUID() -> String? {
142 + let service = IOServiceGetMatchingService(
143 + kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice")
144 + )
145 + guard service != IO_OBJECT_NULL else { return nil }
146 + defer { IOObjectRelease(service) }
147 + guard let property = IORegistryEntryCreateCFProperty(
148 + service, kIOPlatformUUIDKey as CFString, kCFAllocatorDefault, 0
149 + ) else { return nil }
150 + return property.takeRetainedValue() as? String
151 + }
152 +
153 + /// App pepper, assembled at runtime — the constants below are the pepper
154 + /// bytes XOR 0x5A so the value never appears verbatim in the binary.
155 + /// (Distinct from Zyquo Cloud's pepper by design.)
156 + private static func pepper() -> Data {
157 + let obfuscated: [UInt8] = [
158 + 0x00, 0x23, 0x2B, 0x2F, 0x35, 0x7A, 0x1B, 0x3D, 0x3F, 0x34,
159 + 0x2E, 0x7A, 0x2C, 0x3B, 0x2F, 0x36, 0x2E, 0x7A, 0x2A, 0x3F,
160 + 0x2A, 0x2A, 0x3F, 0x28, 0x7A, 0x2C, 0x6B, 0x7A, 0x09, 0x18,
161 + ]
162 + return Data(obfuscated.map { $0 ^ 0x5A })
163 + }
164 +
165 + private static func randomBytes(_ count: Int) throws -> Data {
166 + var bytes = [UInt8](repeating: 0, count: count)
167 + let status = SecRandomCopyBytes(kSecRandomDefault, count, &bytes)
168 + guard status == errSecSuccess else { throw VaultError.corrupted }
169 + return Data(bytes)
170 + }
171 +
172 + /// Salt of the existing vault, so re-saving keeps the same derivation.
173 + private func existingSalt() throws -> Data? {
174 + guard let blob = try? Data(contentsOf: vaultURL),
175 + blob.count >= Self.saltLength else { return nil }
176 + return blob.prefix(Self.saltLength)
177 + }
178 +}
added Sources/ZyquoAgent/Services/StreamingService.swift +167 −0
@@ -0,0 +1,167 @@
1 +//
2 +// StreamingService.swift
3 +// Zyquo Agent
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Ported verbatim from Zyquo Cloud (only the User-Agent string changed).
9 +//
10 +
11 +import Foundation
12 +
13 +/// One Server-Sent Event as parsed off the wire.
14 +struct SSEEvent {
15 + /// The `event:` field, if the stream names its events (Anthropic does).
16 + var event: String?
17 + /// Joined `data:` lines.
18 + var data: String
19 +}
20 +
21 +/// Incremental SSE parser. Feed it raw lines (or byte chunks split on newlines)
22 +/// and it yields complete events at blank-line boundaries, ignoring `:` comment
23 +/// lines (DeepSeek sends `: keep-alive`) and unknown fields.
24 +struct SSEParser {
25 + private var currentEvent: String?
26 + private var currentData: [String] = []
27 +
28 + /// Consumes one line (without its trailing newline). Returns a completed
29 + /// event when the line is the blank separator, else nil.
30 + mutating func consume(line: String) -> SSEEvent? {
31 + if line.isEmpty {
32 + guard !currentData.isEmpty || currentEvent != nil else { return nil }
33 + let event = SSEEvent(event: currentEvent, data: currentData.joined(separator: "\n"))
34 + currentEvent = nil
35 + currentData = []
36 + return event.data.isEmpty && event.event == nil ? nil : event
37 + }
38 + if line.hasPrefix(":") { return nil } // comment / keep-alive
39 + if line.hasPrefix("event:") {
40 + currentEvent = String(line.dropFirst(6)).trimmingCharacters(in: .whitespaces)
41 + } else if line.hasPrefix("data:") {
42 + var value = String(line.dropFirst(5))
43 + if value.hasPrefix(" ") { value.removeFirst() }
44 + currentData.append(value)
45 + }
46 + // id:/retry:/unknown fields are ignored.
47 + return nil
48 + }
49 +}
50 +
51 +/// Shared networking for all provider clients: request construction helpers and
52 +/// an SSE line stream over URLSession.
53 +enum StreamingService {
54 + /// URLSession tuned for long-lived streaming responses.
55 + static let session: URLSession = {
56 + let config = URLSessionConfiguration.default
57 + config.timeoutIntervalForRequest = 120
58 + config.timeoutIntervalForResource = 900
59 + config.httpAdditionalHeaders = ["User-Agent": "ZyquoAgent/1.0 (macOS)"]
60 + return URLSession(configuration: config)
61 + }()
62 +
63 + /// POSTs `body` as JSON and returns the SSE events of the response.
64 + /// Throws `ProviderError` on non-2xx status (reading the full error body).
65 + static func sseEvents(
66 + for request: URLRequest,
67 + provider: ProviderID
68 + ) -> AsyncThrowingStream<SSEEvent, Error> {
69 + AsyncThrowingStream { continuation in
70 + let task = Task {
71 + do {
72 + let (bytes, response) = try await session.bytes(for: request)
73 + guard let http = response as? HTTPURLResponse else {
74 + throw ProviderError.invalidResponse(provider, detail: "not an HTTP response")
75 + }
76 + guard (200..<300).contains(http.statusCode) else {
77 + var body = Data()
78 + for try await byte in bytes { body.append(byte) }
79 + throw ProviderError.from(status: http.statusCode, body: body, provider: provider)
80 + }
81 + // NOTE: AsyncBytes.lines skips empty lines, which are the
82 + // SSE event separators — split manually to preserve them.
83 + var parser = SSEParser()
84 + var lineBuffer = Data()
85 + for try await byte in bytes {
86 + if Task.isCancelled { break }
87 + if byte == 0x0A { // \n
88 + if lineBuffer.last == 0x0D { lineBuffer.removeLast() } // \r\n
89 + let line = String(decoding: lineBuffer, as: UTF8.self)
90 + lineBuffer.removeAll(keepingCapacity: true)
91 + if let event = parser.consume(line: line) {
92 + continuation.yield(event)
93 + }
94 + } else {
95 + lineBuffer.append(byte)
96 + }
97 + }
98 + // Flush a trailing line + event if the stream ended
99 + // without a final newline / blank separator.
100 + if !lineBuffer.isEmpty {
101 + let line = String(decoding: lineBuffer, as: UTF8.self)
102 + if let event = parser.consume(line: line) {
103 + continuation.yield(event)
104 + }
105 + }
106 + if let event = parser.consume(line: "") {
107 + continuation.yield(event)
108 + }
109 + continuation.finish()
110 + } catch is CancellationError {
111 + continuation.finish(throwing: ProviderError.cancelled)
112 + } catch let error as ProviderError {
113 + continuation.finish(throwing: error)
114 + } catch {
115 + continuation.finish(throwing: ProviderError.networkError(underlying: error))
116 + }
117 + }
118 + continuation.onTermination = { _ in task.cancel() }
119 + }
120 + }
121 +
122 + /// Non-streaming JSON POST with exponential backoff on 429/5xx (3 attempts).
123 + /// Returns the response body data.
124 + static func postJSON(
125 + _ request: URLRequest,
126 + provider: ProviderID
127 + ) async throws -> Data {
128 + let maxAttempts = 3
129 + var lastError: ProviderError = .invalidResponse(provider, detail: "no attempts made")
130 + for attempt in 1...maxAttempts {
131 + do {
132 + let (data, response) = try await session.data(for: request)
133 + guard let http = response as? HTTPURLResponse else {
134 + throw ProviderError.invalidResponse(provider, detail: "not an HTTP response")
135 + }
136 + guard (200..<300).contains(http.statusCode) else {
137 + let error = ProviderError.from(status: http.statusCode, body: data, provider: provider)
138 + if attempt < maxAttempts, http.statusCode == 429 || http.statusCode >= 500 {
139 + lastError = error
140 + let retryAfter = (response as? HTTPURLResponse)?
141 + .value(forHTTPHeaderField: "Retry-After").flatMap(Double.init)
142 + let delay = retryAfter ?? pow(2, Double(attempt)) * 2 // 4s, 8s
143 + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
144 + continue
145 + }
146 + throw error
147 + }
148 + return data
149 + } catch let error as ProviderError {
150 + throw error
151 + } catch is CancellationError {
152 + throw ProviderError.cancelled
153 + } catch {
154 + throw ProviderError.networkError(underlying: error)
155 + }
156 + }
157 + throw lastError
158 + }
159 +
160 + /// GET returning decoded JSON data, with the same error mapping.
161 + static func getJSON(
162 + _ request: URLRequest,
163 + provider: ProviderID
164 + ) async throws -> Data {
165 + try await postJSON(request, provider: provider)
166 + }
167 +}
modified docs/PLAN.md +8 −3
@@ -7,11 +7,16 @@
7 7 - [x] 0.B Study `~/Desktop/zyquo-cloud``docs/PROVIDER-REUSE.md` (12 providers / 2 clients: AnthropicClient + OpenAICompatibleClient incl. Gemini compat endpoint; 170-model catalog, ~70 agent-capable, default `claude-sonnet-5`; AES-256-GCM SecureKeyStore vault; 3 tool-calling wire dialects documented)
8 8
9 9 ## Phase 1 — Project Setup (SPM, no Xcode IDE)
10 - [ ] Package.swift, executable target `ZyquoAgent`
11 - [ ] Makefile (dev bundle, ad-hoc sign), Info.plist (com.zyquo.agent, usage descriptions)
12 - [ ] @main SwiftUI App, terminal-launch activation
10 +- [x] Package.swift, executable target `ZyquoAgent` (swift-markdown dep, test target)
11 +- [x] Makefile (dev bundle, ad-hoc sign; SDKROOT pinned to MacOSX26.sdk — SDK 27 SwiftUI macros need Xcode), scripts/write-info-plist.sh (com.zyquo.agent, NSAppleEventsUsageDescription, developer-tools)
12 +- [x] @main via synchronous Main enum (CLI modes: --run/--verify/--load-vault stubs), SwiftUI app shell + AppDelegate activation
13 +
14 +**Phase 1 checkpoint (2026-07-30):** `swift build -c release` clean (0 warnings), `make dev` assembles + ad-hoc signs `dist/Zyquo Agent.app`, app window shell launches. CLI scaffold (`AgentCLI`) ready to host the Phase 3 POC and Phase 7 harness.
13 15
14 16 ## Phase 2 — Architecture skeleton (folders per CLAUDE.md)
17 +- [ ] Providers/ + Models/ + Services/ ported from Zyquo Cloud (per docs/PROVIDER-REUSE.md) with normalized tool-calling interface
18 +- [ ] DesignSystem/ ZyquoTheme with Agent violet palette
19 +- [ ] Agent/, Tools/, Execution/, Workspace/ folder scaffolds (protocols + type stubs)
15 20
16 21 ## Phase 3 — Agent Engine (loop, memory, tools+safety) → CLI POC gate
17 22
18 23