phase2: port provider layer from Zyquo Cloud + normalized tool-calling interface; ZyquoTheme violet tokens
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 | |