phase2: architecture + server skeleton — ported provider layer, NIO server, /health + /v1/models
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 31 changed files with +4,558 and −7
modified
Package.swift
+1 −0
@@ -26,6 +26,7 @@ let package = Package( | ||
| 26 | 26 | .product(name: "NIOCore", package: "swift-nio"), |
| 27 | 27 | .product(name: "NIOPosix", package: "swift-nio"), |
| 28 | 28 | .product(name: "NIOHTTP1", package: "swift-nio"), |
| 29 | + .product(name: "NIOFoundationCompat", package: "swift-nio"), | |
| 29 | 30 | .product(name: "NIOExtras", package: "swift-nio-extras"), |
| 30 | 31 | .product(name: "Markdown", package: "swift-markdown") |
| 31 | 32 | ], |
modified
Sources/ZyquoRouter/App/Main.swift
+23 −0
@@ -18,6 +18,29 @@ import Foundation | ||
| 18 | 18 | @main |
| 19 | 19 | enum Main { |
| 20 | 20 | static func main() { |
| 21 | + let arguments = CommandLine.arguments | |
| 22 | + if let flagIndex = arguments.firstIndex(of: "--serve") { | |
| 23 | + // Headless mode for scripted verification (`ZyquoRouter --serve [port]`). | |
| 24 | + let port = arguments.indices.contains(flagIndex + 1) ? Int(arguments[flagIndex + 1]) ?? 8787 : 8787 | |
| 25 | + Task.detached { | |
| 26 | + let routes = Routes(router: RequestRouter()) | |
| 27 | + let server = HTTPServer(host: "127.0.0.1", port: port) { request in | |
| 28 | + await routes.handle(request) | |
| 29 | + } | |
| 30 | + do { | |
| 31 | + try await server.run { | |
| 32 | + print("Zyquo Router serving on http://127.0.0.1:\(port)/v1") | |
| 33 | + } | |
| 34 | + exit(0) | |
| 35 | + } catch { | |
| 36 | + FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8)) | |
| 37 | + exit(1) | |
| 38 | + } | |
| 39 | + } | |
| 40 | + // Park the main thread servicing the main queue so MainActor work | |
| 41 | + // can run (a blocking semaphore here would deadlock). | |
| 42 | + dispatchMain() | |
| 43 | + } | |
| 21 | 44 | ZyquoRouterApp.main() |
| 22 | 45 | } |
| 23 | 46 | } |
modified
Sources/ZyquoRouter/App/ZyquoRouterApp.swift
+63 −6
@@ -10,10 +10,12 @@ import SwiftUI | ||
| 10 | 10 | |
| 11 | 11 | struct ZyquoRouterApp: App { |
| 12 | 12 | @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate |
| 13 | + @StateObject private var server = ServerController() | |
| 13 | 14 | |
| 14 | 15 | var body: some Scene { |
| 15 | 16 | WindowGroup("Zyquo Router") { |
| 16 | − PlaceholderView() | |
| 17 | + Phase2ControlView() | |
| 18 | + .environmentObject(server) | |
| 17 | 19 | } |
| 18 | 20 | .defaultSize(width: 1280, height: 820) |
| 19 | 21 | } |
@@ -28,15 +30,70 @@ final class AppDelegate: NSObject, NSApplicationDelegate { | ||
| 28 | 30 | } |
| 29 | 31 | } |
| 30 | 32 | |
| 31 | −/// Phase 1 placeholder — replaced by the real dashboard in Phase 6. | |
| 32 | −private struct PlaceholderView: View { | |
| 33 | +/// Minimal Phase 2 control surface — port field, Start/Stop, status, endpoint. | |
| 34 | +/// Replaced by the real dashboard (ZyquoTheme) in Phases 4–6. | |
| 35 | +private struct Phase2ControlView: View { | |
| 36 | + @EnvironmentObject private var server: ServerController | |
| 37 | + | |
| 33 | 38 | var body: some View { |
| 34 | − VStack(spacing: 8) { | |
| 39 | + VStack(spacing: 16) { | |
| 35 | 40 | Text("Zyquo Router") |
| 36 | 41 | .font(.largeTitle.weight(.semibold)) |
| 37 | − Text("Gateway under construction — Phase 1") | |
| 38 | − .foregroundStyle(.secondary) | |
| 42 | + | |
| 43 | + HStack(spacing: 12) { | |
| 44 | + statusDot | |
| 45 | + Text(statusText) | |
| 46 | + .font(.body.monospaced()) | |
| 47 | + } | |
| 48 | + | |
| 49 | + HStack(spacing: 12) { | |
| 50 | + TextField("Port", value: $server.port, format: .number.grouping(.never)) | |
| 51 | + .textFieldStyle(.roundedBorder) | |
| 52 | + .frame(width: 90) | |
| 53 | + .disabled(server.isRunning) | |
| 54 | + | |
| 55 | + Button(server.isRunning ? "Stop" : "Start") { | |
| 56 | + server.toggle() | |
| 57 | + } | |
| 58 | + .keyboardShortcut("r", modifiers: .command) | |
| 59 | + } | |
| 60 | + | |
| 61 | + if server.isRunning { | |
| 62 | + HStack(spacing: 8) { | |
| 63 | + Text(server.endpointURL) | |
| 64 | + .font(.body.monospaced()) | |
| 65 | + .textSelection(.enabled) | |
| 66 | + Button("Copy") { | |
| 67 | + NSPasteboard.general.clearContents() | |
| 68 | + NSPasteboard.general.setString(server.endpointURL, forType: .string) | |
| 69 | + } | |
| 70 | + } | |
| 71 | + } | |
| 39 | 72 | } |
| 40 | 73 | .frame(minWidth: 1020, minHeight: 660) |
| 41 | 74 | } |
| 75 | + | |
| 76 | + private var statusDot: some View { | |
| 77 | + Circle() | |
| 78 | + .fill(dotColor) | |
| 79 | + .frame(width: 10, height: 10) | |
| 80 | + } | |
| 81 | + | |
| 82 | + private var dotColor: Color { | |
| 83 | + switch server.state { | |
| 84 | + case .running: return .green | |
| 85 | + case .starting: return .orange | |
| 86 | + case .failed: return .red | |
| 87 | + case .stopped: return .secondary.opacity(0.5) | |
| 88 | + } | |
| 89 | + } | |
| 90 | + | |
| 91 | + private var statusText: String { | |
| 92 | + switch server.state { | |
| 93 | + case .stopped: return "Stopped" | |
| 94 | + case .starting: return "Starting…" | |
| 95 | + case .running(let port): return "Running on :\(port)" | |
| 96 | + case .failed(let message): return message | |
| 97 | + } | |
| 98 | + } | |
| 42 | 99 | } |
added
Sources/ZyquoRouter/Models/AIModel.swift
+111 −0
@@ -0,0 +1,111 @@ | ||
| 1 | +// | |
| 2 | +// AIModel.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// A chat-capable model offered by a provider. Instances come exclusively from | |
| 12 | +/// `ModelCatalog` (built-in data generated from docs/PROVIDERS.md, dynamic | |
| 13 | +/// `/models` refreshes, and user-defined custom models) — never hardcode these | |
| 14 | +/// in views or clients. | |
| 15 | +struct AIModel: Codable, Identifiable, Hashable { | |
| 16 | + /// Exact model ID as sent in API requests (e.g. "gpt-5.6-terra"). | |
| 17 | + let id: String | |
| 18 | + let provider: ProviderID | |
| 19 | + /// Human-friendly name shown in the UI (e.g. "GPT-5.6 Terra"). | |
| 20 | + let displayName: String | |
| 21 | + /// Context window in tokens. | |
| 22 | + let contextWindow: Int | |
| 23 | + /// Maximum output tokens, when documented. | |
| 24 | + let maxOutputTokens: Int? | |
| 25 | + let capabilities: ModelCapabilities | |
| 26 | + let pricing: ModelPricing? | |
| 27 | + let parameterSupport: ParameterSupport | |
| 28 | + /// Deprecated or superseded models stay selectable but are ranked last and badged. | |
| 29 | + var isLegacy: Bool = false | |
| 30 | + /// Featured/flagship models surface at the top of pickers. | |
| 31 | + var isRecommended: Bool = false | |
| 32 | + /// Base URL override for user-defined custom models; nil for built-ins. | |
| 33 | + var customBaseURL: URL? = nil | |
| 34 | + | |
| 35 | + /// Short badge text for the model chip (e.g. "1M ctx"). | |
| 36 | + var contextBadge: String { | |
| 37 | + switch contextWindow { | |
| 38 | + case 1_000_000...: return "\(contextWindow / 1_000_000)M ctx" | |
| 39 | + case 1_000...: return "\(contextWindow / 1_000)K ctx" | |
| 40 | + default: return "\(contextWindow) ctx" | |
| 41 | + } | |
| 42 | + } | |
| 43 | +} | |
| 44 | + | |
| 45 | +/// What a model can do. Drives UI affordances (attach button, thinking section…) | |
| 46 | +/// and request construction. | |
| 47 | +struct ModelCapabilities: Codable, Hashable { | |
| 48 | + /// Accepts image input. | |
| 49 | + var vision: Bool = false | |
| 50 | + /// Supports function calling / tools. | |
| 51 | + var tools: Bool = false | |
| 52 | + /// Produces reasoning/thinking output (shown in the collapsible section). | |
| 53 | + var reasoning: Bool = false | |
| 54 | + /// Supports SSE streaming (true for every catalog model; custom endpoints may vary). | |
| 55 | + var streaming: Bool = true | |
| 56 | + /// Supports JSON mode / structured output. | |
| 57 | + var jsonMode: Bool = false | |
| 58 | + /// Returns web-search citations (Perplexity sonar family). | |
| 59 | + var citations: Bool = false | |
| 60 | +} | |
| 61 | + | |
| 62 | +/// USD per 1M tokens. Cached/tiered pricing is intentionally simplified to the | |
| 63 | +/// base rate — cost figures in the UI are labeled as estimates. | |
| 64 | +struct ModelPricing: Codable, Hashable { | |
| 65 | + var inputPerMTok: Double | |
| 66 | + var outputPerMTok: Double | |
| 67 | + | |
| 68 | + /// Estimated cost in USD for a usage record. | |
| 69 | + func cost(inputTokens: Int, outputTokens: Int) -> Double { | |
| 70 | + (Double(inputTokens) * inputPerMTok + Double(outputTokens) * outputPerMTok) / 1_000_000 | |
| 71 | + } | |
| 72 | +} | |
| 73 | + | |
| 74 | +/// Which sampling/control parameters a model accepts. Providers reject requests | |
| 75 | +/// carrying unsupported parameters, so requests only include what's supported — | |
| 76 | +/// and Settings only shows sliders that apply. | |
| 77 | +struct ParameterSupport: Codable, Hashable { | |
| 78 | + var temperature: Bool = true | |
| 79 | + var topP: Bool = true | |
| 80 | + var frequencyPenalty: Bool = false | |
| 81 | + var presencePenalty: Bool = false | |
| 82 | + /// Send "max_completion_tokens" instead of "max_tokens" (OpenAI reasoning models, Cerebras). | |
| 83 | + var usesMaxCompletionTokens: Bool = false | |
| 84 | + /// Accepts `reasoning_effort` (OpenAI, xAI, Mistral, DeepSeek, Kimi K-series, Cerebras…). | |
| 85 | + var reasoningEffort: Bool = false | |
| 86 | + /// Anthropic `thinking` / Qwen `enable_thinking` style explicit thinking toggle. | |
| 87 | + var thinkingToggle: Bool = false | |
| 88 | + /// Model rejects non-streaming calls (Qwen qwq/qvq, DashScope-hosted models | |
| 89 | + /// on Together…) — `complete` aggregates a stream instead. | |
| 90 | + var requiresStreaming: Bool = false | |
| 91 | + | |
| 92 | + static let openAIDefault = ParameterSupport(frequencyPenalty: true, presencePenalty: true) | |
| 93 | +} | |
| 94 | + | |
| 95 | +/// Token usage reported by a provider for one exchange. | |
| 96 | +struct TokenUsage: Codable, Hashable { | |
| 97 | + var inputTokens: Int = 0 | |
| 98 | + var outputTokens: Int = 0 | |
| 99 | + var reasoningTokens: Int? = nil | |
| 100 | + | |
| 101 | + var totalTokens: Int { inputTokens + outputTokens } | |
| 102 | + | |
| 103 | + static func + (lhs: TokenUsage, rhs: TokenUsage) -> TokenUsage { | |
| 104 | + TokenUsage( | |
| 105 | + inputTokens: lhs.inputTokens + rhs.inputTokens, | |
| 106 | + outputTokens: lhs.outputTokens + rhs.outputTokens, | |
| 107 | + reasoningTokens: (lhs.reasoningTokens ?? 0) + (rhs.reasoningTokens ?? 0) == 0 | |
| 108 | + ? nil : (lhs.reasoningTokens ?? 0) + (rhs.reasoningTokens ?? 0) | |
| 109 | + ) | |
| 110 | + } | |
| 111 | +} | |
added
Sources/ZyquoRouter/Models/APIKeyRecord.swift
+67 −0
@@ -0,0 +1,67 @@ | ||
| 1 | +// | |
| 2 | +// APIKeyRecord.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// A local router API key (`zyquo-sk-…`). The plaintext token is shown once at | |
| 9 | +// creation and never stored — only its SHA-256 hash persists. | |
| 10 | +// | |
| 11 | + | |
| 12 | +import CryptoKit | |
| 13 | +import Foundation | |
| 14 | + | |
| 15 | +struct APIKeyRecord: Codable, Identifiable, Hashable, Sendable { | |
| 16 | + let id: UUID | |
| 17 | + var name: String | |
| 18 | + /// Hex SHA-256 of the full token. | |
| 19 | + var tokenHash: String | |
| 20 | + /// First 12 characters of the token, for display ("zyquo-sk-a1b…"). | |
| 21 | + var tokenPrefix: String | |
| 22 | + var enabled: Bool | |
| 23 | + var createdAt: Date | |
| 24 | + /// Requests per minute; nil = unlimited. | |
| 25 | + var rateLimitPerMinute: Int? | |
| 26 | + /// Namespaced model IDs this key may use; nil = all models. | |
| 27 | + var allowedModels: Set<String>? | |
| 28 | + | |
| 29 | + init( | |
| 30 | + id: UUID = UUID(), | |
| 31 | + name: String, | |
| 32 | + tokenHash: String, | |
| 33 | + tokenPrefix: String, | |
| 34 | + enabled: Bool = true, | |
| 35 | + createdAt: Date = Date(), | |
| 36 | + rateLimitPerMinute: Int? = nil, | |
| 37 | + allowedModels: Set<String>? = nil | |
| 38 | + ) { | |
| 39 | + self.id = id | |
| 40 | + self.name = name | |
| 41 | + self.tokenHash = tokenHash | |
| 42 | + self.tokenPrefix = tokenPrefix | |
| 43 | + self.enabled = enabled | |
| 44 | + self.createdAt = createdAt | |
| 45 | + self.rateLimitPerMinute = rateLimitPerMinute | |
| 46 | + self.allowedModels = allowedModels | |
| 47 | + } | |
| 48 | + | |
| 49 | + /// Generates a fresh token and its record. The token is returned exactly once. | |
| 50 | + static func generate(name: String) -> (record: APIKeyRecord, token: String) { | |
| 51 | + let random = Data((0..<24).map { _ in UInt8.random(in: 0...255) }) | |
| 52 | + let token = "zyquo-sk-" + random.base64EncodedString() | |
| 53 | + .replacingOccurrences(of: "+", with: "a") | |
| 54 | + .replacingOccurrences(of: "/", with: "b") | |
| 55 | + .replacingOccurrences(of: "=", with: "") | |
| 56 | + let record = APIKeyRecord( | |
| 57 | + name: name, | |
| 58 | + tokenHash: Self.hash(token), | |
| 59 | + tokenPrefix: String(token.prefix(12)) | |
| 60 | + ) | |
| 61 | + return (record, token) | |
| 62 | + } | |
| 63 | + | |
| 64 | + static func hash(_ token: String) -> String { | |
| 65 | + SHA256.hash(data: Data(token.utf8)).map { String(format: "%02x", $0) }.joined() | |
| 66 | + } | |
| 67 | +} | |
added
Sources/ZyquoRouter/Models/Message.swift
+120 −0
@@ -0,0 +1,120 @@ | ||
| 1 | +// | |
| 2 | +// Message.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// One turn in a conversation transcript. | |
| 12 | +struct Message: Codable, Identifiable, Hashable { | |
| 13 | + enum Role: String, Codable { | |
| 14 | + case system | |
| 15 | + case user | |
| 16 | + case assistant | |
| 17 | + } | |
| 18 | + | |
| 19 | + let id: UUID | |
| 20 | + var role: Role | |
| 21 | + var text: String | |
| 22 | + /// Reasoning/thinking text streamed by reasoning models (collapsible in the UI). | |
| 23 | + var reasoning: String? | |
| 24 | + /// Image attachments (user messages, vision models). | |
| 25 | + var attachments: [Attachment] | |
| 26 | + /// Web-search citations (Perplexity). | |
| 27 | + var citations: [Citation] | |
| 28 | + /// Model that produced this message (assistant turns) or was targeted (user turns). | |
| 29 | + var modelID: String? | |
| 30 | + var provider: ProviderID? | |
| 31 | + var usage: TokenUsage? | |
| 32 | + /// Estimated USD cost computed from catalog pricing at receive time. | |
| 33 | + var estimatedCost: Double? | |
| 34 | + var createdAt: Date | |
| 35 | + /// Set while a response is streaming; exactly one message can be streaming at a time. | |
| 36 | + var isStreaming: Bool = false | |
| 37 | + /// Human-readable error if generation failed mid-message. | |
| 38 | + var errorText: String? | |
| 39 | + | |
| 40 | + init( | |
| 41 | + id: UUID = UUID(), | |
| 42 | + role: Role, | |
| 43 | + text: String, | |
| 44 | + reasoning: String? = nil, | |
| 45 | + attachments: [Attachment] = [], | |
| 46 | + citations: [Citation] = [], | |
| 47 | + modelID: String? = nil, | |
| 48 | + provider: ProviderID? = nil, | |
| 49 | + usage: TokenUsage? = nil, | |
| 50 | + estimatedCost: Double? = nil, | |
| 51 | + createdAt: Date = Date() | |
| 52 | + ) { | |
| 53 | + self.id = id | |
| 54 | + self.role = role | |
| 55 | + self.text = text | |
| 56 | + self.reasoning = reasoning | |
| 57 | + self.attachments = attachments | |
| 58 | + self.citations = citations | |
| 59 | + self.modelID = modelID | |
| 60 | + self.provider = provider | |
| 61 | + self.usage = usage | |
| 62 | + self.estimatedCost = estimatedCost | |
| 63 | + self.createdAt = createdAt | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 67 | +/// A file attached to a user message. Images go to vision models as base64; | |
| 68 | +/// text files are injected into the prompt. | |
| 69 | +struct Attachment: Codable, Identifiable, Hashable { | |
| 70 | + enum Kind: String, Codable { | |
| 71 | + case image | |
| 72 | + case textFile | |
| 73 | + } | |
| 74 | + | |
| 75 | + let id: UUID | |
| 76 | + var kind: Kind | |
| 77 | + var fileName: String | |
| 78 | + /// image: raw image bytes; textFile: UTF-8 contents. | |
| 79 | + var data: Data | |
| 80 | + /// MIME type for images (image/png, image/jpeg, image/webp, image/gif). | |
| 81 | + var mimeType: String | |
| 82 | + | |
| 83 | + init(id: UUID = UUID(), kind: Kind, fileName: String, data: Data, mimeType: String) { | |
| 84 | + self.id = id | |
| 85 | + self.kind = kind | |
| 86 | + self.fileName = fileName | |
| 87 | + self.data = data | |
| 88 | + self.mimeType = mimeType | |
| 89 | + } | |
| 90 | +} | |
| 91 | + | |
| 92 | +/// A numbered web source backing an assistant answer (Perplexity sonar family). | |
| 93 | +struct Citation: Codable, Identifiable, Hashable { | |
| 94 | + let id: UUID | |
| 95 | + var index: Int | |
| 96 | + var url: URL | |
| 97 | + var title: String? | |
| 98 | + | |
| 99 | + init(id: UUID = UUID(), index: Int, url: URL, title: String? = nil) { | |
| 100 | + self.id = id | |
| 101 | + self.index = index | |
| 102 | + self.url = url | |
| 103 | + self.title = title | |
| 104 | + } | |
| 105 | +} | |
| 106 | + | |
| 107 | +/// Sampling/reasoning knobs shared by both provider clients. | |
| 108 | +/// Ported from Zyquo Cloud (Models/Conversation.swift) — kept identical so the | |
| 109 | +/// provider layer stays in sync with Cloud. | |
| 110 | +struct ChatParameters: Codable, Hashable { | |
| 111 | + var temperature: Double? | |
| 112 | + var topP: Double? | |
| 113 | + var maxTokens: Int? | |
| 114 | + var frequencyPenalty: Double? | |
| 115 | + var presencePenalty: Double? | |
| 116 | + /// "low" / "medium" / "high" for models supporting reasoning_effort. | |
| 117 | + var reasoningEffort: String? | |
| 118 | + /// Explicit thinking toggle for Anthropic/Qwen-style models. | |
| 119 | + var thinkingEnabled: Bool? | |
| 120 | +} | |
added
Sources/ZyquoRouter/Models/ProviderID.swift
+93 −0
@@ -0,0 +1,93 @@ | ||
| 1 | +// | |
| 2 | +// ProviderID.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// The 12 built-in cloud AI providers, plus user-defined custom endpoints. | |
| 12 | +enum ProviderID: String, Codable, CaseIterable, Identifiable, Hashable { | |
| 13 | + case openai | |
| 14 | + case anthropic | |
| 15 | + case xai | |
| 16 | + case mistral | |
| 17 | + case gemini | |
| 18 | + case qwen | |
| 19 | + case deepseek | |
| 20 | + case kimi | |
| 21 | + case perplexity | |
| 22 | + case together | |
| 23 | + case deepinfra | |
| 24 | + case cerebras | |
| 25 | + case custom | |
| 26 | + | |
| 27 | + var id: String { rawValue } | |
| 28 | + | |
| 29 | + /// User-facing display name. | |
| 30 | + var displayName: String { | |
| 31 | + switch self { | |
| 32 | + case .openai: return "OpenAI" | |
| 33 | + case .anthropic: return "Anthropic" | |
| 34 | + case .xai: return "xAI" | |
| 35 | + case .mistral: return "Mistral" | |
| 36 | + case .gemini: return "Google Gemini" | |
| 37 | + case .qwen: return "Alibaba Qwen" | |
| 38 | + case .deepseek: return "DeepSeek" | |
| 39 | + case .kimi: return "Kimi" | |
| 40 | + case .perplexity: return "Perplexity" | |
| 41 | + case .together: return "Together AI" | |
| 42 | + case .deepinfra: return "DeepInfra" | |
| 43 | + case .cerebras: return "Cerebras" | |
| 44 | + case .custom: return "Custom" | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + /// Wire protocol used by this provider's chat endpoint. | |
| 49 | + var wireFormat: WireFormat { | |
| 50 | + switch self { | |
| 51 | + case .anthropic: return .anthropicMessages | |
| 52 | + default: return .openAIChatCompletions | |
| 53 | + } | |
| 54 | + } | |
| 55 | + | |
| 56 | + /// Base URL of the provider's API (chat + models live under this root). | |
| 57 | + /// `custom` has no fixed base URL — it comes from the user's endpoint config. | |
| 58 | + var defaultBaseURL: URL? { | |
| 59 | + switch self { | |
| 60 | + case .openai: return URL(string: "https://api.openai.com/v1") | |
| 61 | + case .anthropic: return URL(string: "https://api.anthropic.com/v1") | |
| 62 | + case .xai: return URL(string: "https://api.x.ai/v1") | |
| 63 | + case .mistral: return URL(string: "https://api.mistral.ai/v1") | |
| 64 | + case .gemini: return URL(string: "https://generativelanguage.googleapis.com/v1beta/openai") | |
| 65 | + case .qwen: return URL(string: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1") | |
| 66 | + case .deepseek: return URL(string: "https://api.deepseek.com") | |
| 67 | + case .kimi: return URL(string: "https://api.moonshot.ai/v1") | |
| 68 | + case .perplexity: return URL(string: "https://api.perplexity.ai") | |
| 69 | + case .together: return URL(string: "https://api.together.xyz/v1") | |
| 70 | + case .deepinfra: return URL(string: "https://api.deepinfra.com/v1/openai") | |
| 71 | + case .cerebras: return URL(string: "https://api.cerebras.ai/v1") | |
| 72 | + case .custom: return nil | |
| 73 | + } | |
| 74 | + } | |
| 75 | + | |
| 76 | + /// Whether the provider exposes a `/models` listing endpoint usable for dynamic refresh. | |
| 77 | + var supportsModelListing: Bool { | |
| 78 | + self != .perplexity | |
| 79 | + } | |
| 80 | + | |
| 81 | + /// Providers shown in Settings (custom endpoints are managed separately). | |
| 82 | + static var builtIn: [ProviderID] { | |
| 83 | + allCases.filter { $0 != .custom } | |
| 84 | + } | |
| 85 | +} | |
| 86 | + | |
| 87 | +/// The request/response schema a provider speaks. | |
| 88 | +enum WireFormat: String, Codable { | |
| 89 | + /// OpenAI `/chat/completions` schema (used by 11 of the 12 built-in providers). | |
| 90 | + case openAIChatCompletions | |
| 91 | + /// Anthropic `/v1/messages` schema. | |
| 92 | + case anthropicMessages | |
| 93 | +} | |
added
Sources/ZyquoRouter/Providers/AnthropicClient.swift
+300 −0
@@ -0,0 +1,300 @@ | ||
| 1 | +// | |
| 2 | +// AnthropicClient.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Native Anthropic Messages API client (/v1/messages) — NOT OpenAI-compatible. | |
| 9 | +// Auth: x-api-key + anthropic-version headers. System prompt is a top-level | |
| 10 | +// param, content is block-structured, max_tokens is mandatory, streaming uses | |
| 11 | +// named SSE events. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import Foundation | |
| 15 | + | |
| 16 | +struct AnthropicClient: ProviderClient { | |
| 17 | + let providerID: ProviderID = .anthropic | |
| 18 | + | |
| 19 | + private static let apiVersion = "2023-06-01" | |
| 20 | + private static let defaultMaxTokens = 8192 | |
| 21 | + | |
| 22 | + // MARK: - Wire types (requests) | |
| 23 | + | |
| 24 | + private struct WireRequest: Encodable { | |
| 25 | + var model: String | |
| 26 | + var maxTokens: Int | |
| 27 | + var messages: [WireMessage] | |
| 28 | + var system: String? | |
| 29 | + var stream: Bool? | |
| 30 | + var temperature: Double? | |
| 31 | + var topP: Double? | |
| 32 | + var thinking: Thinking? | |
| 33 | + | |
| 34 | + enum CodingKeys: String, CodingKey { | |
| 35 | + case model, messages, system, stream, temperature, thinking | |
| 36 | + case maxTokens = "max_tokens" | |
| 37 | + case topP = "top_p" | |
| 38 | + } | |
| 39 | + } | |
| 40 | + | |
| 41 | + private struct Thinking: Encodable { | |
| 42 | + var type: String | |
| 43 | + var budgetTokens: Int? | |
| 44 | + enum CodingKeys: String, CodingKey { | |
| 45 | + case type | |
| 46 | + case budgetTokens = "budget_tokens" | |
| 47 | + } | |
| 48 | + } | |
| 49 | + | |
| 50 | + private struct WireMessage: Encodable { | |
| 51 | + var role: String | |
| 52 | + var content: [WireBlock] | |
| 53 | + } | |
| 54 | + | |
| 55 | + private enum WireBlock: Encodable { | |
| 56 | + case text(String) | |
| 57 | + case image(mediaType: String, base64: String) | |
| 58 | + | |
| 59 | + func encode(to encoder: Encoder) throws { | |
| 60 | + var container = encoder.container(keyedBy: Key.self) | |
| 61 | + switch self { | |
| 62 | + case .text(let s): | |
| 63 | + try container.encode("text", forKey: .type) | |
| 64 | + try container.encode(s, forKey: .text) | |
| 65 | + case .image(let mediaType, let base64): | |
| 66 | + try container.encode("image", forKey: .type) | |
| 67 | + var source = container.nestedContainer(keyedBy: Key.self, forKey: .source) | |
| 68 | + try source.encode("base64", forKey: .type) | |
| 69 | + try source.encode(mediaType, forKey: .mediaType) | |
| 70 | + try source.encode(base64, forKey: .data) | |
| 71 | + } | |
| 72 | + } | |
| 73 | + | |
| 74 | + enum Key: String, CodingKey { | |
| 75 | + case type, text, source, data | |
| 76 | + case mediaType = "media_type" | |
| 77 | + } | |
| 78 | + } | |
| 79 | + | |
| 80 | + // MARK: - Wire types (responses) | |
| 81 | + | |
| 82 | + private struct StreamEvent: Decodable { | |
| 83 | + var type: String? | |
| 84 | + var delta: Delta? | |
| 85 | + var usage: WireUsage? | |
| 86 | + var message: MessageStart? | |
| 87 | + var error: WireError? | |
| 88 | + | |
| 89 | + struct Delta: Decodable { | |
| 90 | + var type: String? | |
| 91 | + var text: String? | |
| 92 | + var thinking: String? | |
| 93 | + var stopReason: String? | |
| 94 | + enum CodingKeys: String, CodingKey { | |
| 95 | + case type, text, thinking | |
| 96 | + case stopReason = "stop_reason" | |
| 97 | + } | |
| 98 | + } | |
| 99 | + | |
| 100 | + struct MessageStart: Decodable { | |
| 101 | + var usage: WireUsage? | |
| 102 | + } | |
| 103 | + | |
| 104 | + struct WireError: Decodable { | |
| 105 | + var message: String? | |
| 106 | + } | |
| 107 | + } | |
| 108 | + | |
| 109 | + private struct WireUsage: Decodable { | |
| 110 | + var inputTokens: Int? | |
| 111 | + var outputTokens: Int? | |
| 112 | + enum CodingKeys: String, CodingKey { | |
| 113 | + case inputTokens = "input_tokens" | |
| 114 | + case outputTokens = "output_tokens" | |
| 115 | + } | |
| 116 | + } | |
| 117 | + | |
| 118 | + private struct WireResponse: Decodable { | |
| 119 | + var content: [Block]? | |
| 120 | + var usage: WireUsage? | |
| 121 | + var stopReason: String? | |
| 122 | + | |
| 123 | + struct Block: Decodable { | |
| 124 | + var type: String? | |
| 125 | + var text: String? | |
| 126 | + var thinking: String? | |
| 127 | + } | |
| 128 | + | |
| 129 | + enum CodingKeys: String, CodingKey { | |
| 130 | + case content, usage | |
| 131 | + case stopReason = "stop_reason" | |
| 132 | + } | |
| 133 | + } | |
| 134 | + | |
| 135 | + private struct WireModelList: Decodable { | |
| 136 | + var data: [Entry] | |
| 137 | + struct Entry: Decodable { var id: String } | |
| 138 | + } | |
| 139 | + | |
| 140 | + // MARK: - Request construction | |
| 141 | + | |
| 142 | + private func urlRequest(path: String, apiKey: String, method: String = "POST") throws -> URLRequest { | |
| 143 | + guard let base = providerID.defaultBaseURL else { | |
| 144 | + throw ProviderError.invalidResponse(providerID, detail: "no base URL") | |
| 145 | + } | |
| 146 | + var request = URLRequest(url: base.appendingPathComponent(path)) | |
| 147 | + request.httpMethod = method | |
| 148 | + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") | |
| 149 | + request.setValue(Self.apiVersion, forHTTPHeaderField: "anthropic-version") | |
| 150 | + if method == "POST" { | |
| 151 | + request.setValue("application/json", forHTTPHeaderField: "Content-Type") | |
| 152 | + } | |
| 153 | + return request | |
| 154 | + } | |
| 155 | + | |
| 156 | + private func buildBody(_ request: ChatRequest) throws -> Data { | |
| 157 | + var messages: [WireMessage] = [] | |
| 158 | + for message in request.messages where message.role != .system { | |
| 159 | + messages.append(wireMessage(from: message, vision: request.model.capabilities.vision)) | |
| 160 | + } | |
| 161 | + let params = request.parameters | |
| 162 | + var wire = WireRequest( | |
| 163 | + model: request.model.id, | |
| 164 | + maxTokens: params.maxTokens ?? Self.defaultMaxTokens, | |
| 165 | + messages: messages | |
| 166 | + ) | |
| 167 | + if let system = request.systemPrompt, !system.isEmpty { | |
| 168 | + wire.system = system | |
| 169 | + } | |
| 170 | + if request.stream { wire.stream = true } | |
| 171 | + // Claude 4.7+ removed temperature/top_p; ParameterSupport encodes that per model. | |
| 172 | + let support = request.model.parameterSupport | |
| 173 | + if support.temperature { wire.temperature = params.temperature } | |
| 174 | + if support.topP { wire.topP = params.topP } | |
| 175 | + if support.thinkingToggle, let enabled = params.thinkingEnabled { | |
| 176 | + wire.thinking = enabled | |
| 177 | + ? Thinking(type: "enabled", budgetTokens: 8000) | |
| 178 | + : Thinking(type: "disabled") | |
| 179 | + } | |
| 180 | + return try JSONEncoder().encode(wire) | |
| 181 | + } | |
| 182 | + | |
| 183 | + private func wireMessage(from message: Message, vision: Bool) -> WireMessage { | |
| 184 | + let role = message.role == .assistant ? "assistant" : "user" | |
| 185 | + var text = message.text | |
| 186 | + for attachment in message.attachments where attachment.kind == .textFile { | |
| 187 | + let contents = String(data: attachment.data, encoding: .utf8) ?? "" | |
| 188 | + text += "\n\n```\(attachment.fileName)\n\(contents)\n```" | |
| 189 | + } | |
| 190 | + var blocks: [WireBlock] = [] | |
| 191 | + if vision, message.role == .user { | |
| 192 | + for image in message.attachments where image.kind == .image { | |
| 193 | + blocks.append(.image(mediaType: image.mimeType, base64: image.data.base64EncodedString())) | |
| 194 | + } | |
| 195 | + } | |
| 196 | + blocks.append(.text(text.isEmpty ? " " : text)) | |
| 197 | + return WireMessage(role: role, content: blocks) | |
| 198 | + } | |
| 199 | + | |
| 200 | + // MARK: - ProviderClient | |
| 201 | + | |
| 202 | + func streamChat(_ request: ChatRequest, apiKey: String) -> AsyncThrowingStream<ChatEvent, Error> { | |
| 203 | + AsyncThrowingStream { continuation in | |
| 204 | + let task = Task { | |
| 205 | + do { | |
| 206 | + var urlReq = try urlRequest(path: "messages", apiKey: apiKey) | |
| 207 | + var streamRequest = request | |
| 208 | + streamRequest.stream = true | |
| 209 | + urlReq.httpBody = try buildBody(streamRequest) | |
| 210 | + | |
| 211 | + var usage = TokenUsage() | |
| 212 | + var stopReason: String? | |
| 213 | + let decoder = JSONDecoder() | |
| 214 | + | |
| 215 | + for try await sse in StreamingService.sseEvents(for: urlReq, provider: providerID) { | |
| 216 | + guard let data = sse.data.data(using: .utf8), | |
| 217 | + let event = try? decoder.decode(StreamEvent.self, from: data) else { | |
| 218 | + continue | |
| 219 | + } | |
| 220 | + let type = sse.event ?? event.type ?? "" | |
| 221 | + switch type { | |
| 222 | + case "message_start": | |
| 223 | + if let u = event.message?.usage { | |
| 224 | + usage.inputTokens = u.inputTokens ?? 0 | |
| 225 | + } | |
| 226 | + case "content_block_delta": | |
| 227 | + if let text = event.delta?.text, !text.isEmpty { | |
| 228 | + continuation.yield(.textDelta(text)) | |
| 229 | + } | |
| 230 | + if let thinking = event.delta?.thinking, !thinking.isEmpty { | |
| 231 | + continuation.yield(.reasoningDelta(thinking)) | |
| 232 | + } | |
| 233 | + case "message_delta": | |
| 234 | + if let u = event.usage { | |
| 235 | + usage.outputTokens = u.outputTokens ?? usage.outputTokens | |
| 236 | + } | |
| 237 | + if let reason = event.delta?.stopReason { | |
| 238 | + stopReason = reason | |
| 239 | + } | |
| 240 | + case "error": | |
| 241 | + throw ProviderError.serverError( | |
| 242 | + providerID, status: 200, message: event.error?.message | |
| 243 | + ) | |
| 244 | + case "message_stop": | |
| 245 | + break | |
| 246 | + default: | |
| 247 | + break // ping, content_block_start/stop, unknown future events | |
| 248 | + } | |
| 249 | + } | |
| 250 | + continuation.yield(.usage(usage)) | |
| 251 | + continuation.yield(.finished(reason: stopReason)) | |
| 252 | + continuation.finish() | |
| 253 | + } catch { | |
| 254 | + continuation.finish(throwing: error) | |
| 255 | + } | |
| 256 | + } | |
| 257 | + continuation.onTermination = { _ in task.cancel() } | |
| 258 | + } | |
| 259 | + } | |
| 260 | + | |
| 261 | + func complete(_ request: ChatRequest, apiKey: String) async throws -> Message { | |
| 262 | + var urlReq = try urlRequest(path: "messages", apiKey: apiKey) | |
| 263 | + var plainRequest = request | |
| 264 | + plainRequest.stream = false | |
| 265 | + urlReq.httpBody = try buildBody(plainRequest) | |
| 266 | + let data = try await StreamingService.postJSON(urlReq, provider: providerID) | |
| 267 | + guard let response = try? JSONDecoder().decode(WireResponse.self, from: data) else { | |
| 268 | + throw ProviderError.invalidResponse(providerID, detail: "undecodable messages response") | |
| 269 | + } | |
| 270 | + let text = (response.content ?? []).compactMap { $0.type == "text" ? $0.text : nil }.joined() | |
| 271 | + let thinking = (response.content ?? []).compactMap { $0.type == "thinking" ? $0.thinking : nil }.joined() | |
| 272 | + var message = Message( | |
| 273 | + role: .assistant, | |
| 274 | + text: text, | |
| 275 | + reasoning: thinking.isEmpty ? nil : thinking, | |
| 276 | + modelID: request.model.id, | |
| 277 | + provider: providerID | |
| 278 | + ) | |
| 279 | + if let u = response.usage { | |
| 280 | + let usage = TokenUsage(inputTokens: u.inputTokens ?? 0, outputTokens: u.outputTokens ?? 0) | |
| 281 | + message.usage = usage | |
| 282 | + message.estimatedCost = request.model.pricing?.cost( | |
| 283 | + inputTokens: usage.inputTokens, outputTokens: usage.outputTokens | |
| 284 | + ) | |
| 285 | + } | |
| 286 | + return message | |
| 287 | + } | |
| 288 | + | |
| 289 | + func listModelIDs(apiKey: String) async throws -> [String] { | |
| 290 | + var urlReq = try urlRequest(path: "models", apiKey: apiKey, method: "GET") | |
| 291 | + urlReq.url = urlReq.url.flatMap { | |
| 292 | + URL(string: $0.absoluteString + "?limit=100") | |
| 293 | + } | |
| 294 | + let data = try await StreamingService.getJSON(urlReq, provider: providerID) | |
| 295 | + guard let list = try? JSONDecoder().decode(WireModelList.self, from: data) else { | |
| 296 | + throw ProviderError.invalidResponse(providerID, detail: "unrecognized /models response shape") | |
| 297 | + } | |
| 298 | + return list.data.map(\.id) | |
| 299 | + } | |
| 300 | +} | |
added
Sources/ZyquoRouter/Providers/OpenAICompatibleClient.swift
+477 −0
@@ -0,0 +1,477 @@ | ||
| 1 | +// | |
| 2 | +// OpenAICompatibleClient.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// One client for every provider speaking the OpenAI /chat/completions schema: | |
| 9 | +// OpenAI, xAI, Mistral, Gemini (compat endpoint), Qwen/DashScope, DeepSeek, | |
| 10 | +// Kimi, Perplexity, Together, DeepInfra, Cerebras, and custom endpoints. | |
| 11 | +// All provider quirks live HERE — nothing leaks into ViewModels or Views. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import Foundation | |
| 15 | + | |
| 16 | +struct OpenAICompatibleClient: ProviderClient { | |
| 17 | + let providerID: ProviderID | |
| 18 | + /// Custom endpoints override the provider's default base URL. | |
| 19 | + var baseURLOverride: URL? | |
| 20 | + | |
| 21 | + init(provider: ProviderID, baseURLOverride: URL? = nil) { | |
| 22 | + self.providerID = provider | |
| 23 | + self.baseURLOverride = baseURLOverride | |
| 24 | + } | |
| 25 | + | |
| 26 | + // MARK: - Wire types (requests) | |
| 27 | + | |
| 28 | + private struct WireRequest: Encodable { | |
| 29 | + var model: String | |
| 30 | + var messages: [WireMessage] | |
| 31 | + var stream: Bool? | |
| 32 | + var streamOptions: StreamOptions? | |
| 33 | + var temperature: Double? | |
| 34 | + var topP: Double? | |
| 35 | + var maxTokens: Int? | |
| 36 | + var maxCompletionTokens: Int? | |
| 37 | + var frequencyPenalty: Double? | |
| 38 | + var presencePenalty: Double? | |
| 39 | + var reasoningEffort: String? | |
| 40 | + var enableThinking: Bool? | |
| 41 | + | |
| 42 | + enum CodingKeys: String, CodingKey { | |
| 43 | + case model, messages, stream, temperature | |
| 44 | + case streamOptions = "stream_options" | |
| 45 | + case topP = "top_p" | |
| 46 | + case maxTokens = "max_tokens" | |
| 47 | + case maxCompletionTokens = "max_completion_tokens" | |
| 48 | + case frequencyPenalty = "frequency_penalty" | |
| 49 | + case presencePenalty = "presence_penalty" | |
| 50 | + case reasoningEffort = "reasoning_effort" | |
| 51 | + case enableThinking = "enable_thinking" | |
| 52 | + } | |
| 53 | + } | |
| 54 | + | |
| 55 | + private struct StreamOptions: Encodable { | |
| 56 | + var includeUsage: Bool | |
| 57 | + enum CodingKeys: String, CodingKey { case includeUsage = "include_usage" } | |
| 58 | + } | |
| 59 | + | |
| 60 | + private struct WireMessage: Encodable { | |
| 61 | + var role: String | |
| 62 | + var content: WireContent | |
| 63 | + } | |
| 64 | + | |
| 65 | + /// Message content: plain string, or an array of text/image parts for vision. | |
| 66 | + private enum WireContent: Encodable { | |
| 67 | + case text(String) | |
| 68 | + case parts([WirePart]) | |
| 69 | + | |
| 70 | + func encode(to encoder: Encoder) throws { | |
| 71 | + var container = encoder.singleValueContainer() | |
| 72 | + switch self { | |
| 73 | + case .text(let s): try container.encode(s) | |
| 74 | + case .parts(let p): try container.encode(p) | |
| 75 | + } | |
| 76 | + } | |
| 77 | + } | |
| 78 | + | |
| 79 | + private enum WirePart: Encodable { | |
| 80 | + case text(String) | |
| 81 | + case imageURL(String) | |
| 82 | + | |
| 83 | + func encode(to encoder: Encoder) throws { | |
| 84 | + var container = encoder.container(keyedBy: DynamicKey.self) | |
| 85 | + switch self { | |
| 86 | + case .text(let s): | |
| 87 | + try container.encode("text", forKey: DynamicKey("type")) | |
| 88 | + try container.encode(s, forKey: DynamicKey("text")) | |
| 89 | + case .imageURL(let url): | |
| 90 | + try container.encode("image_url", forKey: DynamicKey("type")) | |
| 91 | + var nested = container.nestedContainer(keyedBy: DynamicKey.self, forKey: DynamicKey("image_url")) | |
| 92 | + try nested.encode(url, forKey: DynamicKey("url")) | |
| 93 | + } | |
| 94 | + } | |
| 95 | + } | |
| 96 | + | |
| 97 | + private struct DynamicKey: CodingKey { | |
| 98 | + var stringValue: String | |
| 99 | + var intValue: Int? { nil } | |
| 100 | + init(_ s: String) { stringValue = s } | |
| 101 | + init?(stringValue: String) { self.stringValue = stringValue } | |
| 102 | + init?(intValue: Int) { nil } | |
| 103 | + } | |
| 104 | + | |
| 105 | + // MARK: - Wire types (responses) | |
| 106 | + | |
| 107 | + private struct WireChunk: Decodable { | |
| 108 | + var choices: [WireChoice]? | |
| 109 | + var usage: WireUsage? | |
| 110 | + var citations: [String]? | |
| 111 | + var searchResults: [WireSearchResult]? | |
| 112 | + | |
| 113 | + enum CodingKeys: String, CodingKey { | |
| 114 | + case choices, usage, citations | |
| 115 | + case searchResults = "search_results" | |
| 116 | + } | |
| 117 | + } | |
| 118 | + | |
| 119 | + private struct WireChoice: Decodable { | |
| 120 | + var delta: WireDelta? | |
| 121 | + var message: WireDelta? | |
| 122 | + /// Together streams some models completions-style: the token text | |
| 123 | + /// lives in `choices[].text` instead of `delta.content`. | |
| 124 | + var text: String? | |
| 125 | + var finishReason: String? | |
| 126 | + | |
| 127 | + enum CodingKeys: String, CodingKey { | |
| 128 | + case delta, message, text | |
| 129 | + case finishReason = "finish_reason" | |
| 130 | + } | |
| 131 | + } | |
| 132 | + | |
| 133 | + private struct WireDelta: Decodable { | |
| 134 | + var content: String? | |
| 135 | + var reasoningContent: String? | |
| 136 | + var reasoning: String? | |
| 137 | + | |
| 138 | + enum CodingKeys: String, CodingKey { | |
| 139 | + case content, reasoning | |
| 140 | + case reasoningContent = "reasoning_content" | |
| 141 | + } | |
| 142 | + | |
| 143 | + init(from decoder: Decoder) throws { | |
| 144 | + let container = try decoder.container(keyedBy: CodingKeys.self) | |
| 145 | + reasoning = try? container.decodeIfPresent(String.self, forKey: .reasoning) | |
| 146 | + reasoningContent = try? container.decodeIfPresent(String.self, forKey: .reasoningContent) | |
| 147 | + // `content` is normally a string, but Mistral's reasoning models | |
| 148 | + // return an array of chunks ({type: "thinking"|"text", …}). | |
| 149 | + if let text = try? container.decodeIfPresent(String.self, forKey: .content) { | |
| 150 | + content = text | |
| 151 | + } else if let chunks = try? container.decodeIfPresent([ContentChunk].self, forKey: .content) { | |
| 152 | + var textParts: [String] = [] | |
| 153 | + var thinkingParts: [String] = [] | |
| 154 | + for chunk in chunks { | |
| 155 | + if chunk.type == "thinking" { | |
| 156 | + thinkingParts.append(chunk.flattenedText) | |
| 157 | + } else { | |
| 158 | + textParts.append(chunk.flattenedText) | |
| 159 | + } | |
| 160 | + } | |
| 161 | + content = textParts.joined() | |
| 162 | + let thinking = thinkingParts.joined() | |
| 163 | + if !thinking.isEmpty, reasoningContent == nil { | |
| 164 | + reasoningContent = thinking | |
| 165 | + } | |
| 166 | + } | |
| 167 | + } | |
| 168 | + | |
| 169 | + /// Mistral ThinkChunk/TextChunk: {"type":"text","text":…} or | |
| 170 | + /// {"type":"thinking","thinking":[{"type":"text","text":…}]}. | |
| 171 | + struct ContentChunk: Decodable { | |
| 172 | + var type: String? | |
| 173 | + var text: String? | |
| 174 | + var thinking: [ContentChunkPart]? | |
| 175 | + | |
| 176 | + var flattenedText: String { | |
| 177 | + if let text { return text } | |
| 178 | + return (thinking ?? []).compactMap(\.text).joined() | |
| 179 | + } | |
| 180 | + } | |
| 181 | + | |
| 182 | + struct ContentChunkPart: Decodable { | |
| 183 | + var text: String? | |
| 184 | + } | |
| 185 | + } | |
| 186 | + | |
| 187 | + private struct WireUsage: Decodable { | |
| 188 | + var promptTokens: Int? | |
| 189 | + var completionTokens: Int? | |
| 190 | + var completionTokensDetails: Details? | |
| 191 | + | |
| 192 | + struct Details: Decodable { | |
| 193 | + var reasoningTokens: Int? | |
| 194 | + enum CodingKeys: String, CodingKey { case reasoningTokens = "reasoning_tokens" } | |
| 195 | + } | |
| 196 | + | |
| 197 | + enum CodingKeys: String, CodingKey { | |
| 198 | + case promptTokens = "prompt_tokens" | |
| 199 | + case completionTokens = "completion_tokens" | |
| 200 | + case completionTokensDetails = "completion_tokens_details" | |
| 201 | + } | |
| 202 | + | |
| 203 | + var usage: TokenUsage { | |
| 204 | + TokenUsage( | |
| 205 | + inputTokens: promptTokens ?? 0, | |
| 206 | + outputTokens: completionTokens ?? 0, | |
| 207 | + reasoningTokens: completionTokensDetails?.reasoningTokens | |
| 208 | + ) | |
| 209 | + } | |
| 210 | + } | |
| 211 | + | |
| 212 | + private struct WireSearchResult: Decodable { | |
| 213 | + var title: String? | |
| 214 | + var url: String? | |
| 215 | + } | |
| 216 | + | |
| 217 | + private struct WireModelList: Decodable { | |
| 218 | + var data: [WireModelEntry] | |
| 219 | + } | |
| 220 | + | |
| 221 | + private struct WireModelEntry: Decodable { | |
| 222 | + var id: String | |
| 223 | + } | |
| 224 | + | |
| 225 | + // MARK: - Request construction | |
| 226 | + | |
| 227 | + private var baseURL: URL? { baseURLOverride ?? providerID.defaultBaseURL } | |
| 228 | + | |
| 229 | + private func urlRequest(path: String, apiKey: String, method: String = "POST") throws -> URLRequest { | |
| 230 | + guard let base = baseURL else { | |
| 231 | + throw ProviderError.invalidResponse(providerID, detail: "no base URL configured") | |
| 232 | + } | |
| 233 | + // Preserve base path components ("…/v1", "…/compatible-mode/v1", "…/v1beta/openai"). | |
| 234 | + var request = URLRequest(url: base.appendingPathComponent(path)) | |
| 235 | + request.httpMethod = method | |
| 236 | + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") | |
| 237 | + if method == "POST" { | |
| 238 | + request.setValue("application/json", forHTTPHeaderField: "Content-Type") | |
| 239 | + } | |
| 240 | + return request | |
| 241 | + } | |
| 242 | + | |
| 243 | + /// Providers whose final streamed chunk carries usage only when asked. | |
| 244 | + private var wantsStreamOptions: Bool { | |
| 245 | + switch providerID { | |
| 246 | + case .openai, .xai, .gemini, .deepseek, .kimi, .together, .cerebras, .custom: | |
| 247 | + return true | |
| 248 | + // Qwen, DeepInfra, Perplexity include usage automatically; Mistral | |
| 249 | + // rejects unknown params less gracefully — omit there. | |
| 250 | + case .mistral, .qwen, .deepinfra, .perplexity: | |
| 251 | + return false | |
| 252 | + case .anthropic: | |
| 253 | + return false // never routed here | |
| 254 | + } | |
| 255 | + } | |
| 256 | + | |
| 257 | + private func buildBody(_ request: ChatRequest) throws -> Data { | |
| 258 | + var messages: [WireMessage] = [] | |
| 259 | + if let system = request.systemPrompt, !system.isEmpty { | |
| 260 | + messages.append(WireMessage(role: "system", content: .text(system))) | |
| 261 | + } | |
| 262 | + for message in request.messages where message.role != .system { | |
| 263 | + messages.append(wireMessage(from: message, vision: request.model.capabilities.vision)) | |
| 264 | + } | |
| 265 | + | |
| 266 | + let support = request.model.parameterSupport | |
| 267 | + let params = request.parameters | |
| 268 | + var wire = WireRequest(model: request.model.id, messages: messages) | |
| 269 | + if request.stream { | |
| 270 | + wire.stream = true | |
| 271 | + if wantsStreamOptions { | |
| 272 | + wire.streamOptions = StreamOptions(includeUsage: true) | |
| 273 | + } | |
| 274 | + } | |
| 275 | + if support.temperature { wire.temperature = params.temperature } | |
| 276 | + if support.topP { wire.topP = params.topP } | |
| 277 | + if let max = params.maxTokens { | |
| 278 | + if support.usesMaxCompletionTokens { | |
| 279 | + wire.maxCompletionTokens = max | |
| 280 | + } else { | |
| 281 | + wire.maxTokens = max | |
| 282 | + } | |
| 283 | + } | |
| 284 | + if support.frequencyPenalty { wire.frequencyPenalty = params.frequencyPenalty } | |
| 285 | + if support.presencePenalty { wire.presencePenalty = params.presencePenalty } | |
| 286 | + if support.reasoningEffort { | |
| 287 | + // Mistral only accepts "high"/"none": map medium→high, low→none. | |
| 288 | + if providerID == .mistral, let effort = params.reasoningEffort { | |
| 289 | + wire.reasoningEffort = effort == "low" ? "none" : "high" | |
| 290 | + } else { | |
| 291 | + wire.reasoningEffort = params.reasoningEffort | |
| 292 | + } | |
| 293 | + } | |
| 294 | + if support.thinkingToggle, providerID == .qwen { | |
| 295 | + // DashScope: enable_thinking is only legal on streaming requests. | |
| 296 | + if request.stream { wire.enableThinking = params.thinkingEnabled } | |
| 297 | + } | |
| 298 | + let encoder = JSONEncoder() | |
| 299 | + return try encoder.encode(wire) | |
| 300 | + } | |
| 301 | + | |
| 302 | + private func wireMessage(from message: Message, vision: Bool) -> WireMessage { | |
| 303 | + let role = message.role == .assistant ? "assistant" : "user" | |
| 304 | + var text = message.text | |
| 305 | + // Text-file attachments are injected inline, fenced with the file name. | |
| 306 | + for attachment in message.attachments where attachment.kind == .textFile { | |
| 307 | + let contents = String(data: attachment.data, encoding: .utf8) ?? "" | |
| 308 | + text += "\n\n```\(attachment.fileName)\n\(contents)\n```" | |
| 309 | + } | |
| 310 | + let images = message.attachments.filter { $0.kind == .image } | |
| 311 | + guard vision, !images.isEmpty, message.role == .user else { | |
| 312 | + return WireMessage(role: role, content: .text(text)) | |
| 313 | + } | |
| 314 | + var parts: [WirePart] = [.text(text)] | |
| 315 | + for image in images { | |
| 316 | + let dataURI = "data:\(image.mimeType);base64,\(image.data.base64EncodedString())" | |
| 317 | + parts.append(.imageURL(dataURI)) | |
| 318 | + } | |
| 319 | + return WireMessage(role: role, content: .parts(parts)) | |
| 320 | + } | |
| 321 | + | |
| 322 | + // MARK: - ProviderClient | |
| 323 | + | |
| 324 | + func streamChat(_ request: ChatRequest, apiKey: String) -> AsyncThrowingStream<ChatEvent, Error> { | |
| 325 | + AsyncThrowingStream { continuation in | |
| 326 | + let task = Task { | |
| 327 | + do { | |
| 328 | + var urlReq = try urlRequest(path: "chat/completions", apiKey: apiKey) | |
| 329 | + var streamRequest = request | |
| 330 | + streamRequest.stream = true | |
| 331 | + urlReq.httpBody = try buildBody(streamRequest) | |
| 332 | + | |
| 333 | + var citationsSent = false | |
| 334 | + var finishReason: String? | |
| 335 | + let decoder = JSONDecoder() | |
| 336 | + | |
| 337 | + for try await event in StreamingService.sseEvents(for: urlReq, provider: providerID) { | |
| 338 | + if event.data == "[DONE]" { break } | |
| 339 | + guard let data = event.data.data(using: .utf8), | |
| 340 | + let chunk = try? decoder.decode(WireChunk.self, from: data) else { | |
| 341 | + continue // tolerate unknown/malformed keep-alive chunks | |
| 342 | + } | |
| 343 | + if let choice = chunk.choices?.first { | |
| 344 | + if let reasoning = choice.delta?.reasoningContent ?? choice.delta?.reasoning, | |
| 345 | + !reasoning.isEmpty { | |
| 346 | + continuation.yield(.reasoningDelta(reasoning)) | |
| 347 | + } | |
| 348 | + let deltaText = choice.delta?.content ?? choice.text | |
| 349 | + if let deltaText, !deltaText.isEmpty { | |
| 350 | + continuation.yield(.textDelta(deltaText)) | |
| 351 | + } | |
| 352 | + if let reason = choice.finishReason { | |
| 353 | + finishReason = reason | |
| 354 | + } | |
| 355 | + } | |
| 356 | + if !citationsSent, let citations = Self.citations(from: chunk), !citations.isEmpty { | |
| 357 | + citationsSent = true | |
| 358 | + continuation.yield(.citations(citations)) | |
| 359 | + } | |
| 360 | + if let usage = chunk.usage { | |
| 361 | + continuation.yield(.usage(usage.usage)) | |
| 362 | + } | |
| 363 | + } | |
| 364 | + continuation.yield(.finished(reason: finishReason)) | |
| 365 | + continuation.finish() | |
| 366 | + } catch { | |
| 367 | + continuation.finish(throwing: error) | |
| 368 | + } | |
| 369 | + } | |
| 370 | + continuation.onTermination = { _ in task.cancel() } | |
| 371 | + } | |
| 372 | + } | |
| 373 | + | |
| 374 | + func complete(_ request: ChatRequest, apiKey: String) async throws -> Message { | |
| 375 | + // Some models reject non-streaming calls — aggregate a stream instead. | |
| 376 | + if request.model.parameterSupport.requiresStreaming { | |
| 377 | + return try await completeViaStream(request, apiKey: apiKey) | |
| 378 | + } | |
| 379 | + var urlReq = try urlRequest(path: "chat/completions", apiKey: apiKey) | |
| 380 | + var plainRequest = request | |
| 381 | + plainRequest.stream = false | |
| 382 | + urlReq.httpBody = try buildBody(plainRequest) | |
| 383 | + let data = try await StreamingService.postJSON(urlReq, provider: providerID) | |
| 384 | + let chunk = try decodeOrThrow(WireChunk.self, from: data) | |
| 385 | + guard let choice = chunk.choices?.first, let content = choice.message ?? choice.delta else { | |
| 386 | + throw ProviderError.invalidResponse(providerID, detail: "response contained no message") | |
| 387 | + } | |
| 388 | + var message = Message( | |
| 389 | + role: .assistant, | |
| 390 | + text: content.content ?? choice.text ?? "", | |
| 391 | + reasoning: content.reasoningContent ?? content.reasoning, | |
| 392 | + modelID: request.model.id, | |
| 393 | + provider: providerID | |
| 394 | + ) | |
| 395 | + if let citations = Self.citations(from: chunk) { | |
| 396 | + message.citations = citations | |
| 397 | + } | |
| 398 | + if let usage = chunk.usage?.usage { | |
| 399 | + message.usage = usage | |
| 400 | + message.estimatedCost = request.model.pricing?.cost( | |
| 401 | + inputTokens: usage.inputTokens, outputTokens: usage.outputTokens | |
| 402 | + ) | |
| 403 | + } | |
| 404 | + return message | |
| 405 | + } | |
| 406 | + | |
| 407 | + func listModelIDs(apiKey: String) async throws -> [String] { | |
| 408 | + let urlReq = try urlRequest(path: "models", apiKey: apiKey, method: "GET") | |
| 409 | + let data = try await StreamingService.getJSON(urlReq, provider: providerID) | |
| 410 | + // Together returns a bare array; everyone else wraps in {"data": […]}. | |
| 411 | + // Gemini's compat endpoint prefixes IDs with "models/" — normalize. | |
| 412 | + let ids: [String] | |
| 413 | + if let list = try? JSONDecoder().decode(WireModelList.self, from: data) { | |
| 414 | + ids = list.data.map(\.id) | |
| 415 | + } else if let bare = try? JSONDecoder().decode([WireModelEntry].self, from: data) { | |
| 416 | + ids = bare.map(\.id) | |
| 417 | + } else { | |
| 418 | + throw ProviderError.invalidResponse(providerID, detail: "unrecognized /models response shape") | |
| 419 | + } | |
| 420 | + return ids.map { $0.hasPrefix("models/") ? String($0.dropFirst(7)) : $0 } | |
| 421 | + } | |
| 422 | + | |
| 423 | + /// Non-streaming result assembled from the streaming endpoint, for models | |
| 424 | + /// that only support `stream: true`. | |
| 425 | + private func completeViaStream(_ request: ChatRequest, apiKey: String) async throws -> Message { | |
| 426 | + var text = "" | |
| 427 | + var reasoning = "" | |
| 428 | + var citations: [Citation] = [] | |
| 429 | + var usage: TokenUsage? | |
| 430 | + for try await event in streamChat(request, apiKey: apiKey) { | |
| 431 | + switch event { | |
| 432 | + case .textDelta(let delta): text += delta | |
| 433 | + case .reasoningDelta(let delta): reasoning += delta | |
| 434 | + case .citations(let c): citations = c | |
| 435 | + case .usage(let u): usage = u | |
| 436 | + case .finished: break | |
| 437 | + } | |
| 438 | + } | |
| 439 | + var message = Message( | |
| 440 | + role: .assistant, | |
| 441 | + text: text, | |
| 442 | + reasoning: reasoning.isEmpty ? nil : reasoning, | |
| 443 | + citations: citations, | |
| 444 | + modelID: request.model.id, | |
| 445 | + provider: providerID | |
| 446 | + ) | |
| 447 | + if let usage { | |
| 448 | + message.usage = usage | |
| 449 | + message.estimatedCost = request.model.pricing?.cost( | |
| 450 | + inputTokens: usage.inputTokens, outputTokens: usage.outputTokens | |
| 451 | + ) | |
| 452 | + } | |
| 453 | + return message | |
| 454 | + } | |
| 455 | + | |
| 456 | + // MARK: - Helpers | |
| 457 | + | |
| 458 | + private func decodeOrThrow<T: Decodable>(_ type: T.Type, from data: Data) throws -> T { | |
| 459 | + do { | |
| 460 | + return try JSONDecoder().decode(type, from: data) | |
| 461 | + } catch { | |
| 462 | + throw ProviderError.invalidResponse(providerID, detail: "decode failed: \(error.localizedDescription)") | |
| 463 | + } | |
| 464 | + } | |
| 465 | + | |
| 466 | + /// Perplexity: `citations` is an array of URL strings; `search_results` | |
| 467 | + /// adds titles. Merge both into numbered citations. | |
| 468 | + private static func citations(from chunk: WireChunk) -> [Citation]? { | |
| 469 | + guard let urls = chunk.citations, !urls.isEmpty else { return nil } | |
| 470 | + let titles = chunk.searchResults ?? [] | |
| 471 | + return urls.enumerated().compactMap { index, urlString in | |
| 472 | + guard let url = URL(string: urlString) else { return nil } | |
| 473 | + let title = index < titles.count ? titles[index].title : nil | |
| 474 | + return Citation(index: index + 1, url: url, title: title) | |
| 475 | + } | |
| 476 | + } | |
| 477 | +} | |
added
Sources/ZyquoRouter/Providers/ProviderProtocol.swift
+146 −0
@@ -0,0 +1,146 @@ | ||
| 1 | +// | |
| 2 | +// ProviderProtocol.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// A provider-agnostic chat request. Clients translate this into their wire format; | |
| 12 | +/// provider behavior differences never leak above this layer. | |
| 13 | +struct ChatRequest { | |
| 14 | + var model: AIModel | |
| 15 | + var systemPrompt: String? | |
| 16 | + var messages: [Message] | |
| 17 | + var parameters: ChatParameters | |
| 18 | + var stream: Bool = true | |
| 19 | +} | |
| 20 | + | |
| 21 | +/// Incremental events surfaced while a response streams. | |
| 22 | +enum ChatEvent { | |
| 23 | + case reasoningDelta(String) | |
| 24 | + case textDelta(String) | |
| 25 | + case citations([Citation]) | |
| 26 | + case usage(TokenUsage) | |
| 27 | + case finished(reason: String?) | |
| 28 | +} | |
| 29 | + | |
| 30 | +/// One cloud AI provider client. | |
| 31 | +protocol ProviderClient { | |
| 32 | + var providerID: ProviderID { get } | |
| 33 | + | |
| 34 | + /// Streams a chat completion. The stream finishes after `.finished` or throws a `ProviderError`. | |
| 35 | + func streamChat(_ request: ChatRequest, apiKey: String) -> AsyncThrowingStream<ChatEvent, Error> | |
| 36 | + | |
| 37 | + /// Non-streaming completion (used for title generation and the verify harness). | |
| 38 | + func complete(_ request: ChatRequest, apiKey: String) async throws -> Message | |
| 39 | + | |
| 40 | + /// Model IDs currently served by the provider, for dynamic catalog refresh. | |
| 41 | + func listModelIDs(apiKey: String) async throws -> [String] | |
| 42 | +} | |
| 43 | + | |
| 44 | +extension ProviderClient { | |
| 45 | + /// Key validation: performs the cheapest authenticated call available and | |
| 46 | + /// returns the round-trip latency. `fallbackModel` is used for providers | |
| 47 | + /// without a /models endpoint (Perplexity) — pass the provider's cheapest | |
| 48 | + /// catalog model. | |
| 49 | + func testKey(_ apiKey: String, fallbackModel: AIModel?) async throws -> TimeInterval { | |
| 50 | + let start = Date() | |
| 51 | + if providerID.supportsModelListing { | |
| 52 | + _ = try await listModelIDs(apiKey: apiKey) | |
| 53 | + } else { | |
| 54 | + guard let model = fallbackModel else { | |
| 55 | + throw ProviderError.noModelAvailable(providerID) | |
| 56 | + } | |
| 57 | + var request = ChatRequest( | |
| 58 | + model: model, | |
| 59 | + systemPrompt: nil, | |
| 60 | + messages: [Message(role: .user, text: "Reply with exactly: OK")], | |
| 61 | + parameters: ChatParameters(maxTokens: 16), | |
| 62 | + stream: false | |
| 63 | + ) | |
| 64 | + request.parameters.temperature = nil | |
| 65 | + _ = try await complete(request, apiKey: apiKey) | |
| 66 | + } | |
| 67 | + return Date().timeIntervalSince(start) | |
| 68 | + } | |
| 69 | +} | |
| 70 | + | |
| 71 | +/// Errors mapped to clear, human-readable messages ("Invalid API key for Mistral", | |
| 72 | +/// "Rate limited — retrying in 20s"). | |
| 73 | +enum ProviderError: LocalizedError { | |
| 74 | + case invalidAPIKey(ProviderID) | |
| 75 | + case rateLimited(ProviderID, retryAfter: TimeInterval?) | |
| 76 | + case serverError(ProviderID, status: Int, message: String?) | |
| 77 | + case badRequest(ProviderID, message: String?) | |
| 78 | + case networkError(underlying: Error) | |
| 79 | + case invalidResponse(ProviderID, detail: String) | |
| 80 | + case missingAPIKey(ProviderID) | |
| 81 | + case noModelAvailable(ProviderID) | |
| 82 | + case cancelled | |
| 83 | + | |
| 84 | + var errorDescription: String? { | |
| 85 | + switch self { | |
| 86 | + case .invalidAPIKey(let p): | |
| 87 | + return "Invalid API key for \(p.displayName)." | |
| 88 | + case .rateLimited(let p, let retryAfter): | |
| 89 | + if let s = retryAfter { | |
| 90 | + return "\(p.displayName) rate limited — retry in \(Int(s.rounded()))s." | |
| 91 | + } | |
| 92 | + return "\(p.displayName) rate limited — please retry shortly." | |
| 93 | + case .serverError(let p, let status, let message): | |
| 94 | + return "\(p.displayName) server error (\(status))\(message.map { ": \($0)" } ?? "")." | |
| 95 | + case .badRequest(let p, let message): | |
| 96 | + return "\(p.displayName) rejected the request\(message.map { ": \($0)" } ?? "")." | |
| 97 | + case .networkError(let underlying): | |
| 98 | + return "Network error: \(underlying.localizedDescription)" | |
| 99 | + case .invalidResponse(let p, let detail): | |
| 100 | + return "Unexpected response from \(p.displayName): \(detail)" | |
| 101 | + case .missingAPIKey(let p): | |
| 102 | + return "No API key configured for \(p.displayName). Add one in Settings → Providers & Keys." | |
| 103 | + case .noModelAvailable(let p): | |
| 104 | + return "No model available for \(p.displayName)." | |
| 105 | + case .cancelled: | |
| 106 | + return "Generation stopped." | |
| 107 | + } | |
| 108 | + } | |
| 109 | + | |
| 110 | + /// Maps an HTTP status + provider error body to a typed error. | |
| 111 | + static func from(status: Int, body: Data, provider: ProviderID) -> ProviderError { | |
| 112 | + let message = Self.extractMessage(from: body) | |
| 113 | + switch status { | |
| 114 | + case 401, 403: | |
| 115 | + return .invalidAPIKey(provider) | |
| 116 | + case 429: | |
| 117 | + return .rateLimited(provider, retryAfter: nil) | |
| 118 | + case 400, 404, 422: | |
| 119 | + return .badRequest(provider, message: message) | |
| 120 | + default: | |
| 121 | + return .serverError(provider, status: status, message: message) | |
| 122 | + } | |
| 123 | + } | |
| 124 | + | |
| 125 | + /// Providers wrap errors differently ({"error":{"message":…}}, {"message":…}, | |
| 126 | + /// {"error":"…"}, Gemini arrays…). Try the common shapes. | |
| 127 | + private static func extractMessage(from body: Data) -> String? { | |
| 128 | + guard let obj = try? JSONSerialization.jsonObject(with: body) else { | |
| 129 | + return String(data: body.prefix(300), encoding: .utf8) | |
| 130 | + } | |
| 131 | + if let dict = obj as? [String: Any] { | |
| 132 | + if let err = dict["error"] as? [String: Any], let msg = err["message"] as? String { | |
| 133 | + return msg | |
| 134 | + } | |
| 135 | + if let msg = dict["error"] as? String { return msg } | |
| 136 | + if let msg = dict["message"] as? String { return msg } | |
| 137 | + if let msg = dict["detail"] as? String { return msg } | |
| 138 | + } | |
| 139 | + if let arr = obj as? [[String: Any]], | |
| 140 | + let err = arr.first?["error"] as? [String: Any], | |
| 141 | + let msg = err["message"] as? String { | |
| 142 | + return msg | |
| 143 | + } | |
| 144 | + return String(data: body.prefix(300), encoding: .utf8) | |
| 145 | + } | |
| 146 | +} | |
added
Sources/ZyquoRouter/Providers/ProviderRegistry.swift
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +// | |
| 2 | +// ProviderRegistry.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// Resolves the right client for a provider or custom model. The only place | |
| 12 | +/// that knows which wire format each provider speaks. | |
| 13 | +enum ProviderRegistry { | |
| 14 | + static func client(for model: AIModel) -> ProviderClient { | |
| 15 | + switch model.provider.wireFormat { | |
| 16 | + case .anthropicMessages: | |
| 17 | + return AnthropicClient() | |
| 18 | + case .openAIChatCompletions: | |
| 19 | + return OpenAICompatibleClient( | |
| 20 | + provider: model.provider, | |
| 21 | + baseURLOverride: model.customBaseURL | |
| 22 | + ) | |
| 23 | + } | |
| 24 | + } | |
| 25 | + | |
| 26 | + static func client(for provider: ProviderID) -> ProviderClient { | |
| 27 | + switch provider.wireFormat { | |
| 28 | + case .anthropicMessages: | |
| 29 | + return AnthropicClient() | |
| 30 | + case .openAIChatCompletions: | |
| 31 | + return OpenAICompatibleClient(provider: provider) | |
| 32 | + } | |
| 33 | + } | |
| 34 | +} | |
added
Sources/ZyquoRouter/Router/RequestRouter.swift
+99 −0
@@ -0,0 +1,99 @@ | ||
| 1 | +// | |
| 2 | +// RequestRouter.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Resolves a requested model string — namespaced `provider/model-id`, an | |
| 9 | +// unambiguous bare upstream ID, or a user alias — to a catalog model. | |
| 10 | +// Aliases and fallback chains are user-configured (Phase 6 UI); disabled | |
| 11 | +// models 404 through the API. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import Foundation | |
| 15 | + | |
| 16 | +struct RequestRouter: Sendable { | |
| 17 | + /// Snapshot of the catalog (never hop to the main actor per request). | |
| 18 | + let catalog: [AIModel] | |
| 19 | + /// User aliases, e.g. "fast" → "cerebras/llama-3.3-70b". | |
| 20 | + var aliases: [String: String] = [:] | |
| 21 | + /// Namespaced IDs the user disabled (they 404 through the API). | |
| 22 | + var disabledIDs: Set<String> = [] | |
| 23 | + /// User fallback chains: namespaced ID → ordered list of namespaced IDs to try next. | |
| 24 | + var fallbackChains: [String: [String]] = [:] | |
| 25 | + | |
| 26 | + struct Resolution { | |
| 27 | + let model: AIModel | |
| 28 | + /// `provider/model-id` — always echoed back in responses. | |
| 29 | + let namespacedID: String | |
| 30 | + } | |
| 31 | + | |
| 32 | + enum RoutingError: Error { | |
| 33 | + case unknownModel(String) | |
| 34 | + case ambiguousModel(String, candidates: [String]) | |
| 35 | + case disabledModel(String) | |
| 36 | + } | |
| 37 | + | |
| 38 | + init( | |
| 39 | + catalog: [AIModel] = ModelCatalogData.all, | |
| 40 | + aliases: [String: String] = [:], | |
| 41 | + disabledIDs: Set<String> = [], | |
| 42 | + fallbackChains: [String: [String]] = [:] | |
| 43 | + ) { | |
| 44 | + self.catalog = catalog | |
| 45 | + self.aliases = aliases | |
| 46 | + self.disabledIDs = disabledIDs | |
| 47 | + self.fallbackChains = fallbackChains | |
| 48 | + } | |
| 49 | + | |
| 50 | + static func namespacedID(for model: AIModel) -> String { | |
| 51 | + "\(model.provider.rawValue)/\(model.id)" | |
| 52 | + } | |
| 53 | + | |
| 54 | + /// Every model the router serves, in catalog order, with disabled ones flagged. | |
| 55 | + var exposedModels: [(namespacedID: String, model: AIModel, disabled: Bool)] { | |
| 56 | + catalog.map { model in | |
| 57 | + let id = Self.namespacedID(for: model) | |
| 58 | + return (id, model, disabledIDs.contains(id)) | |
| 59 | + } | |
| 60 | + } | |
| 61 | + | |
| 62 | + /// Resolves a requested model string. Order: alias → namespaced ID → bare ID. | |
| 63 | + func resolve(_ requested: String) throws -> Resolution { | |
| 64 | + let name = aliases[requested] ?? requested | |
| 65 | + | |
| 66 | + // Namespaced: the segment before the first "/" names a provider. | |
| 67 | + // (Model IDs themselves may contain "/" — e.g. meta-llama/… on | |
| 68 | + // Together/DeepInfra — which is exactly why the namespace is required | |
| 69 | + // to be a known provider prefix.) | |
| 70 | + if let slash = name.firstIndex(of: "/"), | |
| 71 | + let provider = ProviderID(rawValue: String(name[name.startIndex..<slash])) { | |
| 72 | + let bareID = String(name[name.index(after: slash)...]) | |
| 73 | + if let model = catalog.first(where: { $0.provider == provider && $0.id == bareID }) { | |
| 74 | + return try admit(model) | |
| 75 | + } | |
| 76 | + throw RoutingError.unknownModel(requested) | |
| 77 | + } | |
| 78 | + | |
| 79 | + // Bare upstream ID, accepted when unambiguous across providers. | |
| 80 | + let matches = catalog.filter { $0.id == name } | |
| 81 | + switch matches.count { | |
| 82 | + case 0: | |
| 83 | + throw RoutingError.unknownModel(requested) | |
| 84 | + case 1: | |
| 85 | + return try admit(matches[0]) | |
| 86 | + default: | |
| 87 | + throw RoutingError.ambiguousModel( | |
| 88 | + requested, | |
| 89 | + candidates: matches.map(Self.namespacedID(for:)) | |
| 90 | + ) | |
| 91 | + } | |
| 92 | + } | |
| 93 | + | |
| 94 | + private func admit(_ model: AIModel) throws -> Resolution { | |
| 95 | + let id = Self.namespacedID(for: model) | |
| 96 | + guard !disabledIDs.contains(id) else { throw RoutingError.disabledModel(id) } | |
| 97 | + return Resolution(model: model, namespacedID: id) | |
| 98 | + } | |
| 99 | +} | |
added
Sources/ZyquoRouter/Router/RetryPolicy.swift
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +// | |
| 2 | +// RetryPolicy.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Exponential backoff with jitter for transient upstream failures (429/5xx, | |
| 9 | +// timeouts). Never retries once the first streamed byte has been forwarded | |
| 10 | +// to the client — enforced by the caller in Phase 3's UpstreamCall path. | |
| 11 | +// | |
| 12 | + | |
| 13 | +import Foundation | |
| 14 | + | |
| 15 | +struct RetryPolicy: Sendable { | |
| 16 | + var maxAttempts = 3 | |
| 17 | + var baseDelay: TimeInterval = 0.5 | |
| 18 | + var maxDelay: TimeInterval = 8.0 | |
| 19 | + | |
| 20 | + /// Whether a failed attempt with this upstream status is worth retrying. | |
| 21 | + func shouldRetry(status: Int, attempt: Int) -> Bool { | |
| 22 | + guard attempt < maxAttempts else { return false } | |
| 23 | + return status == 429 || (500...599).contains(status) | |
| 24 | + } | |
| 25 | + | |
| 26 | + /// Full-jitter exponential backoff; honors an upstream Retry-After when given. | |
| 27 | + func delay(attempt: Int, retryAfter: TimeInterval? = nil) -> TimeInterval { | |
| 28 | + if let retryAfter, retryAfter > 0 { | |
| 29 | + return min(retryAfter, maxDelay) | |
| 30 | + } | |
| 31 | + let exponential = min(baseDelay * pow(2, Double(attempt - 1)), maxDelay) | |
| 32 | + return Double.random(in: 0...exponential) | |
| 33 | + } | |
| 34 | +} | |
added
Sources/ZyquoRouter/Router/UsageMeter.swift
+77 −0
@@ -0,0 +1,77 @@ | ||
| 1 | +// | |
| 2 | +// UsageMeter.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Tokens + estimated cost per request, aggregated per model/provider/key. | |
| 9 | +// Phase 2 records in memory; persistence and dashboard aggregation land with | |
| 10 | +// the observability work. | |
| 11 | +// | |
| 12 | + | |
| 13 | +import Foundation | |
| 14 | + | |
| 15 | +/// One completed (or failed) routed request. | |
| 16 | +struct UsageRecord: Codable, Identifiable, Sendable { | |
| 17 | + let id: UUID | |
| 18 | + var date: Date | |
| 19 | + var namespacedModelID: String | |
| 20 | + var provider: ProviderID | |
| 21 | + var localKeyName: String? | |
| 22 | + var usage: TokenUsage | |
| 23 | + var usageEstimated: Bool | |
| 24 | + var estimatedCost: Double? | |
| 25 | + var latency: TimeInterval | |
| 26 | + var streamed: Bool | |
| 27 | + var status: Int | |
| 28 | + | |
| 29 | + init( | |
| 30 | + id: UUID = UUID(), | |
| 31 | + date: Date = Date(), | |
| 32 | + namespacedModelID: String, | |
| 33 | + provider: ProviderID, | |
| 34 | + localKeyName: String? = nil, | |
| 35 | + usage: TokenUsage = TokenUsage(), | |
| 36 | + usageEstimated: Bool = false, | |
| 37 | + estimatedCost: Double? = nil, | |
| 38 | + latency: TimeInterval = 0, | |
| 39 | + streamed: Bool = false, | |
| 40 | + status: Int = 200 | |
| 41 | + ) { | |
| 42 | + self.id = id | |
| 43 | + self.date = date | |
| 44 | + self.namespacedModelID = namespacedModelID | |
| 45 | + self.provider = provider | |
| 46 | + self.localKeyName = localKeyName | |
| 47 | + self.usage = usage | |
| 48 | + self.usageEstimated = usageEstimated | |
| 49 | + self.estimatedCost = estimatedCost | |
| 50 | + self.latency = latency | |
| 51 | + self.streamed = streamed | |
| 52 | + self.status = status | |
| 53 | + } | |
| 54 | +} | |
| 55 | + | |
| 56 | +actor UsageMeter { | |
| 57 | + private(set) var records: [UsageRecord] = [] | |
| 58 | + | |
| 59 | + func record(_ entry: UsageRecord) { | |
| 60 | + records.append(entry) | |
| 61 | + } | |
| 62 | + | |
| 63 | + /// Totals since a cutoff (today's tiles on the dashboard). | |
| 64 | + func totals(since cutoff: Date) -> (requests: Int, usage: TokenUsage, cost: Double, errors: Int) { | |
| 65 | + var usage = TokenUsage() | |
| 66 | + var cost = 0.0 | |
| 67 | + var requests = 0 | |
| 68 | + var errors = 0 | |
| 69 | + for record in records where record.date >= cutoff { | |
| 70 | + requests += 1 | |
| 71 | + usage = usage + record.usage | |
| 72 | + cost += record.estimatedCost ?? 0 | |
| 73 | + if record.status >= 400 { errors += 1 } | |
| 74 | + } | |
| 75 | + return (requests, usage, cost, errors) | |
| 76 | + } | |
| 77 | +} | |
added
Sources/ZyquoRouter/Server/AuthMiddleware.swift
+42 −0
@@ -0,0 +1,42 @@ | ||
| 1 | +// | |
| 2 | +// AuthMiddleware.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Local API-key gate (Bearer `zyquo-sk-…`). With no keys configured the | |
| 9 | +// router is open (localhost-only posture); LAN exposure forces at least one | |
| 10 | +// key at start time. Per-key rate limits and model allow-lists apply in the | |
| 11 | +// chat route. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import Foundation | |
| 15 | + | |
| 16 | +struct AuthMiddleware: Sendable { | |
| 17 | + /// Keys in force; empty ⇒ no auth required (localhost default). | |
| 18 | + var keys: [APIKeyRecord] = [] | |
| 19 | + | |
| 20 | + enum Decision: Sendable { | |
| 21 | + case allowed(APIKeyRecord?) | |
| 22 | + case unauthorized(message: String) | |
| 23 | + } | |
| 24 | + | |
| 25 | + func authorize(_ request: RouteRequest) -> Decision { | |
| 26 | + guard !keys.isEmpty else { return .allowed(nil) } | |
| 27 | + | |
| 28 | + guard let auth = request.headers.first(name: "Authorization"), | |
| 29 | + auth.lowercased().hasPrefix("bearer ") else { | |
| 30 | + return .unauthorized(message: "Missing bearer token. Pass a local router key: Authorization: Bearer zyquo-sk-…") | |
| 31 | + } | |
| 32 | + let token = String(auth.dropFirst("bearer ".count)).trimmingCharacters(in: .whitespaces) | |
| 33 | + let hash = APIKeyRecord.hash(token) | |
| 34 | + guard let match = keys.first(where: { $0.tokenHash == hash }) else { | |
| 35 | + return .unauthorized(message: "Invalid API key.") | |
| 36 | + } | |
| 37 | + guard match.enabled else { | |
| 38 | + return .unauthorized(message: "This API key has been revoked.") | |
| 39 | + } | |
| 40 | + return .allowed(match) | |
| 41 | + } | |
| 42 | +} | |
added
Sources/ZyquoRouter/Server/CORS.swift
+31 −0
@@ -0,0 +1,31 @@ | ||
| 1 | +// | |
| 2 | +// CORS.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Permissive-by-default CORS so browser-based tooling can call the router | |
| 9 | +// on localhost. Configurable origin in Settings (Phase 6). | |
| 10 | +// | |
| 11 | + | |
| 12 | +import Foundation | |
| 13 | +import NIOHTTP1 | |
| 14 | + | |
| 15 | +struct CORS { | |
| 16 | + var allowedOrigin: String = "*" | |
| 17 | + | |
| 18 | + var headers: [(String, String)] { | |
| 19 | + [ | |
| 20 | + ("Access-Control-Allow-Origin", allowedOrigin), | |
| 21 | + ("Access-Control-Allow-Methods", "GET, POST, OPTIONS"), | |
| 22 | + ("Access-Control-Allow-Headers", "Authorization, Content-Type, OpenAI-Beta, X-Requested-With"), | |
| 23 | + ("Access-Control-Max-Age", "600"), | |
| 24 | + ] | |
| 25 | + } | |
| 26 | + | |
| 27 | + /// Response to a preflight `OPTIONS` request. | |
| 28 | + func preflightResponse() -> RouteResult { | |
| 29 | + .complete(status: .noContent, headers: headers, body: Data()) | |
| 30 | + } | |
| 31 | +} | |
added
Sources/ZyquoRouter/Server/HTTPServer.swift
+192 −0
@@ -0,0 +1,192 @@ | ||
| 1 | +// | |
| 2 | +// HTTPServer.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Embedded HTTP/1.1 server on SwiftNIO's structured-concurrency APIs | |
| 9 | +// (NIOAsyncChannel). One task per connection, one request at a time per | |
| 10 | +// connection (keep-alive), spec-exact SSE via SSEWriter. Cancelling the | |
| 11 | +// server task stops the accept loop and cancels in-flight connections, | |
| 12 | +// which propagates into upstream provider calls. | |
| 13 | +// | |
| 14 | + | |
| 15 | +import Foundation | |
| 16 | +import NIOCore | |
| 17 | +import NIOFoundationCompat | |
| 18 | +import NIOHTTP1 | |
| 19 | +import NIOPosix | |
| 20 | + | |
| 21 | +/// What the server does with one parsed request. | |
| 22 | +/// `.complete` answers with a full buffered body; `.stream` hands an SSEWriter | |
| 23 | +/// to the route (Phase 3 streaming completions). | |
| 24 | +enum RouteResult { | |
| 25 | + case complete(status: HTTPResponseStatus, headers: [(String, String)], body: Data) | |
| 26 | + case stream(status: HTTPResponseStatus, headers: [(String, String)], body: (SSEWriter) async throws -> Void) | |
| 27 | +} | |
| 28 | + | |
| 29 | +/// One parsed inbound HTTP request, as handed to the route layer. | |
| 30 | +struct RouteRequest { | |
| 31 | + let method: HTTPMethod | |
| 32 | + let uri: String | |
| 33 | + let headers: HTTPHeaders | |
| 34 | + let body: Data | |
| 35 | + | |
| 36 | + /// URI path without the query string. | |
| 37 | + var path: String { | |
| 38 | + uri.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false) | |
| 39 | + .first.map(String.init) ?? uri | |
| 40 | + } | |
| 41 | +} | |
| 42 | + | |
| 43 | +enum ServerError: LocalizedError { | |
| 44 | + case portInUse(host: String, port: Int) | |
| 45 | + case bindFailed(String) | |
| 46 | + | |
| 47 | + var errorDescription: String? { | |
| 48 | + switch self { | |
| 49 | + case .portInUse(_, let port): | |
| 50 | + return "Port \(port) is already in use — try \(port + 1), or stop the other process." | |
| 51 | + case .bindFailed(let detail): | |
| 52 | + return "Could not start the server: \(detail)" | |
| 53 | + } | |
| 54 | + } | |
| 55 | +} | |
| 56 | + | |
| 57 | +/// The embedded server. Create one per Start; it is single-use. | |
| 58 | +final class HTTPServer: Sendable { | |
| 59 | + /// Maximum buffered request body (base64 images make chat bodies large). | |
| 60 | + static let maxBodyBytes = 32 * 1024 * 1024 | |
| 61 | + | |
| 62 | + let host: String | |
| 63 | + let port: Int | |
| 64 | + private let handler: @Sendable (RouteRequest) async -> RouteResult | |
| 65 | + | |
| 66 | + init(host: String, port: Int, handler: @escaping @Sendable (RouteRequest) async -> RouteResult) { | |
| 67 | + self.host = host | |
| 68 | + self.port = port | |
| 69 | + self.handler = handler | |
| 70 | + } | |
| 71 | + | |
| 72 | + /// Binds and serves until the surrounding task is cancelled. | |
| 73 | + /// `onRunning` fires once the socket is bound and accepting. | |
| 74 | + func run(onRunning: @escaping @Sendable () -> Void) async throws { | |
| 75 | + let serverChannel: NIOAsyncChannel<NIOAsyncChannel<HTTPServerRequestPart, HTTPServerResponsePart>, Never> | |
| 76 | + do { | |
| 77 | + serverChannel = try await ServerBootstrap(group: MultiThreadedEventLoopGroup.singleton) | |
| 78 | + .serverChannelOption(ChannelOptions.backlog, value: 64) | |
| 79 | + .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) | |
| 80 | + .childChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) | |
| 81 | + .bind(host: host, port: port) { channel in | |
| 82 | + channel.eventLoop.makeCompletedFuture { | |
| 83 | + try channel.pipeline.syncOperations.configureHTTPServerPipeline(withErrorHandling: true) | |
| 84 | + return try NIOAsyncChannel(wrappingChannelSynchronously: channel) | |
| 85 | + } | |
| 86 | + } | |
| 87 | + } catch let error as IOError where error.errnoCode == EADDRINUSE { | |
| 88 | + throw ServerError.portInUse(host: host, port: port) | |
| 89 | + } catch let error as NIOCore.ChannelError { | |
| 90 | + throw ServerError.bindFailed(String(describing: error)) | |
| 91 | + } | |
| 92 | + | |
| 93 | + onRunning() | |
| 94 | + | |
| 95 | + // Cancellation of the surrounding task ends the accept iteration; the | |
| 96 | + // task group then cancels and awaits every in-flight connection, so | |
| 97 | + // shutdown is clean end-to-end. (withThrowingTaskGroup rather than a | |
| 98 | + // discarding group: the package targets macOS 13.) | |
| 99 | + try await serverChannel.executeThenClose { inbound, _ in | |
| 100 | + try await withThrowingTaskGroup(of: Void.self) { group in | |
| 101 | + for try await connection in inbound { | |
| 102 | + group.addTask { [handler] in | |
| 103 | + await Self.serve(connection: connection, handler: handler) | |
| 104 | + } | |
| 105 | + } | |
| 106 | + } | |
| 107 | + } | |
| 108 | + } | |
| 109 | + | |
| 110 | + // MARK: - Per-connection loop | |
| 111 | + | |
| 112 | + private static func serve( | |
| 113 | + connection: NIOAsyncChannel<HTTPServerRequestPart, HTTPServerResponsePart>, | |
| 114 | + handler: @Sendable (RouteRequest) async -> RouteResult | |
| 115 | + ) async { | |
| 116 | + try? await connection.executeThenClose { inbound, outbound in | |
| 117 | + var iterator = inbound.makeAsyncIterator() | |
| 118 | + while let part = try await iterator.next() { | |
| 119 | + guard case .head(let head) = part else { continue } | |
| 120 | + | |
| 121 | + var bodyBuffer = ByteBuffer() | |
| 122 | + var tooLarge = false | |
| 123 | + readLoop: while let next = try await iterator.next() { | |
| 124 | + switch next { | |
| 125 | + case .body(var chunk): | |
| 126 | + if bodyBuffer.readableBytes + chunk.readableBytes > maxBodyBytes { | |
| 127 | + tooLarge = true | |
| 128 | + } else { | |
| 129 | + bodyBuffer.writeBuffer(&chunk) | |
| 130 | + } | |
| 131 | + case .end: | |
| 132 | + break readLoop | |
| 133 | + case .head: | |
| 134 | + return // protocol violation; drop the connection | |
| 135 | + } | |
| 136 | + } | |
| 137 | + | |
| 138 | + let keepAlive = head.isKeepAlive | |
| 139 | + if tooLarge { | |
| 140 | + try await Self.write( | |
| 141 | + result: OpenAIError.response( | |
| 142 | + status: .payloadTooLarge, | |
| 143 | + message: "Request body exceeds the \(maxBodyBytes / (1024 * 1024)) MB limit.", | |
| 144 | + type: "invalid_request_error" | |
| 145 | + ), | |
| 146 | + version: head.version, keepAlive: false, outbound: outbound | |
| 147 | + ) | |
| 148 | + return | |
| 149 | + } | |
| 150 | + | |
| 151 | + let request = RouteRequest( | |
| 152 | + method: head.method, | |
| 153 | + uri: head.uri, | |
| 154 | + headers: head.headers, | |
| 155 | + body: bodyBuffer.readData(length: bodyBuffer.readableBytes) ?? Data() | |
| 156 | + ) | |
| 157 | + let result = await handler(request) | |
| 158 | + try await Self.write(result: result, version: head.version, keepAlive: keepAlive, outbound: outbound) | |
| 159 | + if !keepAlive { return } | |
| 160 | + } | |
| 161 | + } | |
| 162 | + } | |
| 163 | + | |
| 164 | + private static func write( | |
| 165 | + result: RouteResult, | |
| 166 | + version: HTTPVersion, | |
| 167 | + keepAlive: Bool, | |
| 168 | + outbound: NIOAsyncChannelOutboundWriter<HTTPServerResponsePart> | |
| 169 | + ) async throws { | |
| 170 | + switch result { | |
| 171 | + case .complete(let status, let extraHeaders, let body): | |
| 172 | + var headers = HTTPHeaders(extraHeaders) | |
| 173 | + headers.replaceOrAdd(name: "Content-Length", value: String(body.count)) | |
| 174 | + headers.replaceOrAdd(name: "Connection", value: keepAlive ? "keep-alive" : "close") | |
| 175 | + try await outbound.write(.head(HTTPResponseHead(version: version, status: status, headers: headers))) | |
| 176 | + if !body.isEmpty { | |
| 177 | + try await outbound.write(.body(.byteBuffer(ByteBuffer(bytes: body)))) | |
| 178 | + } | |
| 179 | + try await outbound.write(.end(nil)) | |
| 180 | + | |
| 181 | + case .stream(let status, let extraHeaders, let body): | |
| 182 | + var headers = HTTPHeaders(extraHeaders) | |
| 183 | + headers.replaceOrAdd(name: "Content-Type", value: "text/event-stream") | |
| 184 | + headers.replaceOrAdd(name: "Cache-Control", value: "no-cache") | |
| 185 | + headers.replaceOrAdd(name: "Connection", value: "close") | |
| 186 | + try await outbound.write(.head(HTTPResponseHead(version: version, status: status, headers: headers))) | |
| 187 | + let writer = SSEWriter(outbound: outbound) | |
| 188 | + try await body(writer) | |
| 189 | + try await outbound.write(.end(nil)) | |
| 190 | + } | |
| 191 | + } | |
| 192 | +} | |
added
Sources/ZyquoRouter/Server/Routes.swift
+188 −0
@@ -0,0 +1,188 @@ | ||
| 1 | +// | |
| 2 | +// Routes.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Maps inbound requests to endpoints: /health, /v1/models, /v1/models/{id}; | |
| 9 | +// /v1/chat/completions arrives in Phase 3. All errors are OpenAI-shaped. | |
| 10 | +// | |
| 11 | + | |
| 12 | +import Foundation | |
| 13 | +import NIOHTTP1 | |
| 14 | + | |
| 15 | +struct Routes: Sendable { | |
| 16 | + let router: RequestRouter | |
| 17 | + let auth: AuthMiddleware | |
| 18 | + let cors: CORS | |
| 19 | + let version: String | |
| 20 | + let startedAt: Date | |
| 21 | + | |
| 22 | + private static let encoder: JSONEncoder = { | |
| 23 | + let encoder = JSONEncoder() | |
| 24 | + encoder.outputFormatting = [.withoutEscapingSlashes] | |
| 25 | + return encoder | |
| 26 | + }() | |
| 27 | + | |
| 28 | + init( | |
| 29 | + router: RequestRouter, | |
| 30 | + auth: AuthMiddleware = AuthMiddleware(), | |
| 31 | + cors: CORS = CORS(), | |
| 32 | + version: String = "1.0.0", | |
| 33 | + startedAt: Date = Date() | |
| 34 | + ) { | |
| 35 | + self.router = router | |
| 36 | + self.auth = auth | |
| 37 | + self.cors = cors | |
| 38 | + self.version = version | |
| 39 | + self.startedAt = startedAt | |
| 40 | + } | |
| 41 | + | |
| 42 | + func handle(_ request: RouteRequest) async -> RouteResult { | |
| 43 | + if request.method == .OPTIONS { | |
| 44 | + return cors.preflightResponse() | |
| 45 | + } | |
| 46 | + | |
| 47 | + let path = request.path | |
| 48 | + | |
| 49 | + // /health is deliberately unauthenticated (readiness probes). | |
| 50 | + if request.method == .GET, path == "/health" { | |
| 51 | + return withCORS(health()) | |
| 52 | + } | |
| 53 | + | |
| 54 | + switch auth.authorize(request) { | |
| 55 | + case .unauthorized(let message): | |
| 56 | + return withCORS(OpenAIError.response( | |
| 57 | + status: .unauthorized, | |
| 58 | + message: message, | |
| 59 | + type: "authentication_error", | |
| 60 | + code: "invalid_api_key" | |
| 61 | + )) | |
| 62 | + case .allowed: | |
| 63 | + break | |
| 64 | + } | |
| 65 | + | |
| 66 | + switch (request.method, path) { | |
| 67 | + case (.GET, "/v1/models"): | |
| 68 | + return withCORS(modelList()) | |
| 69 | + case (.GET, _) where path.hasPrefix("/v1/models/"): | |
| 70 | + let id = String(path.dropFirst("/v1/models/".count)) | |
| 71 | + .removingPercentEncoding ?? String(path.dropFirst("/v1/models/".count)) | |
| 72 | + return withCORS(model(id: id)) | |
| 73 | + default: | |
| 74 | + return withCORS(OpenAIError.response( | |
| 75 | + status: .notFound, | |
| 76 | + message: "Unknown request URL: \(request.method) \(path). The router serves /v1/chat/completions, /v1/models, and /health.", | |
| 77 | + type: "invalid_request_error" | |
| 78 | + )) | |
| 79 | + } | |
| 80 | + } | |
| 81 | + | |
| 82 | + // MARK: - Endpoints | |
| 83 | + | |
| 84 | + private func health() -> RouteResult { | |
| 85 | + struct Health: Codable { | |
| 86 | + var status: String | |
| 87 | + var version: String | |
| 88 | + var uptime: Int | |
| 89 | + var models: Int | |
| 90 | + } | |
| 91 | + let payload = Health( | |
| 92 | + status: "ok", | |
| 93 | + version: version, | |
| 94 | + uptime: Int(Date().timeIntervalSince(startedAt)), | |
| 95 | + models: router.exposedModels.filter { !$0.disabled }.count | |
| 96 | + ) | |
| 97 | + return json(payload) | |
| 98 | + } | |
| 99 | + | |
| 100 | + private func modelList() -> RouteResult { | |
| 101 | + let created = Int(startedAt.timeIntervalSince1970) | |
| 102 | + var entries: [OpenAIModelEntry] = [] | |
| 103 | + var aliasTargets: [String: String] = [:] | |
| 104 | + for (alias, target) in router.aliases { | |
| 105 | + aliasTargets[target] = alias | |
| 106 | + } | |
| 107 | + for (namespacedID, model, disabled) in router.exposedModels where !disabled { | |
| 108 | + entries.append(OpenAIModelEntry( | |
| 109 | + id: namespacedID, | |
| 110 | + created: created, | |
| 111 | + ownedBy: model.provider.rawValue, | |
| 112 | + xZyquo: XZyquoModelInfo( | |
| 113 | + displayName: model.displayName, | |
| 114 | + contextWindow: model.contextWindow, | |
| 115 | + maxOutputTokens: model.maxOutputTokens, | |
| 116 | + vision: model.capabilities.vision, | |
| 117 | + tools: model.capabilities.tools, | |
| 118 | + reasoning: model.capabilities.reasoning, | |
| 119 | + inputPerMTok: model.pricing?.inputPerMTok, | |
| 120 | + outputPerMTok: model.pricing?.outputPerMTok, | |
| 121 | + alias: aliasTargets[namespacedID] | |
| 122 | + ) | |
| 123 | + )) | |
| 124 | + } | |
| 125 | + return json(OpenAIModelList(data: entries)) | |
| 126 | + } | |
| 127 | + | |
| 128 | + private func model(id: String) -> RouteResult { | |
| 129 | + do { | |
| 130 | + let resolution = try router.resolve(id) | |
| 131 | + let entry = OpenAIModelEntry( | |
| 132 | + id: resolution.namespacedID, | |
| 133 | + created: Int(startedAt.timeIntervalSince1970), | |
| 134 | + ownedBy: resolution.model.provider.rawValue, | |
| 135 | + xZyquo: XZyquoModelInfo( | |
| 136 | + displayName: resolution.model.displayName, | |
| 137 | + contextWindow: resolution.model.contextWindow, | |
| 138 | + maxOutputTokens: resolution.model.maxOutputTokens, | |
| 139 | + vision: resolution.model.capabilities.vision, | |
| 140 | + tools: resolution.model.capabilities.tools, | |
| 141 | + reasoning: resolution.model.capabilities.reasoning, | |
| 142 | + inputPerMTok: resolution.model.pricing?.inputPerMTok, | |
| 143 | + outputPerMTok: resolution.model.pricing?.outputPerMTok, | |
| 144 | + alias: nil | |
| 145 | + ) | |
| 146 | + ) | |
| 147 | + return json(entry) | |
| 148 | + } catch let error as RequestRouter.RoutingError { | |
| 149 | + return routingErrorResponse(error) | |
| 150 | + } catch { | |
| 151 | + return OpenAIError.response(status: .internalServerError, message: "Internal error.", type: "server_error") | |
| 152 | + } | |
| 153 | + } | |
| 154 | + | |
| 155 | + /// Shared mapping used by every route that resolves a model. | |
| 156 | + func routingErrorResponse(_ error: RequestRouter.RoutingError) -> RouteResult { | |
| 157 | + switch error { | |
| 158 | + case .unknownModel(let name), .disabledModel(let name): | |
| 159 | + return OpenAIError.modelNotFound(name) | |
| 160 | + case .ambiguousModel(let name, let candidates): | |
| 161 | + return OpenAIError.response( | |
| 162 | + status: .notFound, | |
| 163 | + message: "The model `\(name)` is ambiguous — use a namespaced ID: \(candidates.joined(separator: ", ")).", | |
| 164 | + type: "invalid_request_error", | |
| 165 | + param: "model", | |
| 166 | + code: "model_not_found" | |
| 167 | + ) | |
| 168 | + } | |
| 169 | + } | |
| 170 | + | |
| 171 | + // MARK: - Helpers | |
| 172 | + | |
| 173 | + private func json<T: Encodable>(_ payload: T, status: HTTPResponseStatus = .ok) -> RouteResult { | |
| 174 | + guard let body = try? Self.encoder.encode(payload) else { | |
| 175 | + return OpenAIError.response(status: .internalServerError, message: "Encoding failure.", type: "server_error") | |
| 176 | + } | |
| 177 | + return .complete(status: status, headers: [("Content-Type", "application/json")], body: body) | |
| 178 | + } | |
| 179 | + | |
| 180 | + private func withCORS(_ result: RouteResult) -> RouteResult { | |
| 181 | + switch result { | |
| 182 | + case .complete(let status, let headers, let body): | |
| 183 | + return .complete(status: status, headers: headers + cors.headers, body: body) | |
| 184 | + case .stream(let status, let headers, let body): | |
| 185 | + return .stream(status: status, headers: headers + cors.headers, body: body) | |
| 186 | + } | |
| 187 | + } | |
| 188 | +} | |
added
Sources/ZyquoRouter/Server/SSEWriter.swift
+50 −0
@@ -0,0 +1,50 @@ | ||
| 1 | +// | |
| 2 | +// SSEWriter.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Spec-exact Server-Sent Events emission for /v1/chat/completions streaming: | |
| 9 | +// each event is `data: <json>\n\n`, the stream ends with `data: [DONE]\n\n`. | |
| 10 | +// Every write flushes; a write against a disconnected client throws, which | |
| 11 | +// cancels the calling route task and, with it, the upstream provider call. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import Foundation | |
| 15 | +import NIOCore | |
| 16 | +import NIOHTTP1 | |
| 17 | + | |
| 18 | +struct SSEWriter { | |
| 19 | + let outbound: NIOAsyncChannelOutboundWriter<HTTPServerResponsePart> | |
| 20 | + | |
| 21 | + private static let encoder: JSONEncoder = { | |
| 22 | + let encoder = JSONEncoder() | |
| 23 | + encoder.outputFormatting = [.withoutEscapingSlashes] | |
| 24 | + return encoder | |
| 25 | + }() | |
| 26 | + | |
| 27 | + /// Emits one `data:` event carrying an encodable payload (a chunk object). | |
| 28 | + func send<T: Encodable>(_ payload: T) async throws { | |
| 29 | + try await send(raw: Self.encoder.encode(payload)) | |
| 30 | + } | |
| 31 | + | |
| 32 | + /// Emits one `data:` event carrying pre-serialized JSON (pass-through path). | |
| 33 | + func send(raw json: Data) async throws { | |
| 34 | + var buffer = ByteBuffer() | |
| 35 | + buffer.writeString("data: ") | |
| 36 | + buffer.writeBytes(json) | |
| 37 | + buffer.writeString("\n\n") | |
| 38 | + try await outbound.write(.body(.byteBuffer(buffer))) | |
| 39 | + } | |
| 40 | + | |
| 41 | + /// Emits an SSE comment line (keep-alive heartbeat). | |
| 42 | + func sendHeartbeat() async throws { | |
| 43 | + try await outbound.write(.body(.byteBuffer(ByteBuffer(string: ": keep-alive\n\n")))) | |
| 44 | + } | |
| 45 | + | |
| 46 | + /// Terminates the stream per the OpenAI contract. | |
| 47 | + func sendDone() async throws { | |
| 48 | + try await outbound.write(.body(.byteBuffer(ByteBuffer(string: "data: [DONE]\n\n")))) | |
| 49 | + } | |
| 50 | +} | |
added
Sources/ZyquoRouter/Services/ModelCatalog.swift
+72 −0
@@ -0,0 +1,72 @@ | ||
| 1 | +// | |
| 2 | +// ModelCatalog.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Single source of truth for model data. Built-in entries are generated from | |
| 9 | +// docs/PROVIDERS.md (see ModelCatalogData.swift); dynamic /models refreshes and | |
| 10 | +// user-defined custom models layer on top. Views and clients never hardcode | |
| 11 | +// model IDs. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import Foundation | |
| 15 | + | |
| 16 | +@MainActor | |
| 17 | +final class ModelCatalog: ObservableObject { | |
| 18 | + /// Built-in catalog (generated from docs/PROVIDERS.md — keep in sync). | |
| 19 | + @Published private(set) var builtIn: [AIModel] = ModelCatalogData.all | |
| 20 | + /// User-defined custom models (custom ID + base URL). | |
| 21 | + @Published var customModels: [AIModel] = [] | |
| 22 | + /// Model IDs confirmed live by the last dynamic refresh, per provider. | |
| 23 | + @Published private(set) var liveModelIDs: [ProviderID: Set<String>] = [:] | |
| 24 | + /// Favorite model IDs, pinned at the top of pickers. | |
| 25 | + @Published var favoriteIDs: Set<String> = [] | |
| 26 | + | |
| 27 | + var all: [AIModel] { builtIn + customModels } | |
| 28 | + | |
| 29 | + func models(for provider: ProviderID) -> [AIModel] { | |
| 30 | + all.filter { $0.provider == provider } | |
| 31 | + .sorted { rank($0) < rank($1) } | |
| 32 | + } | |
| 33 | + | |
| 34 | + func model(id: String, provider: ProviderID) -> AIModel? { | |
| 35 | + all.first { $0.id == id && $0.provider == provider } | |
| 36 | + } | |
| 37 | + | |
| 38 | + /// Cheapest non-legacy chat model for a provider (used for key tests and | |
| 39 | + /// auto-title generation). Non-reasoning models are preferred — reasoning | |
| 40 | + /// models burn their token budget thinking, useless for tiny utility calls. | |
| 41 | + func cheapestModel(for provider: ProviderID) -> AIModel? { | |
| 42 | + let candidates = models(for: provider).filter { !$0.isLegacy } | |
| 43 | + let plain = candidates.filter { !$0.capabilities.reasoning } | |
| 44 | + return (plain.isEmpty ? candidates : plain) | |
| 45 | + .min { ($0.pricing?.outputPerMTok ?? .infinity) < ($1.pricing?.outputPerMTok ?? .infinity) } | |
| 46 | + } | |
| 47 | + | |
| 48 | + /// Default model offered for new conversations. | |
| 49 | + var defaultModel: AIModel? { | |
| 50 | + all.first { $0.isRecommended } ?? all.first | |
| 51 | + } | |
| 52 | + | |
| 53 | + /// Merges a dynamic /models listing: known models are marked live; unknown | |
| 54 | + /// IDs are surfaced so the user can add them. | |
| 55 | + func applyLiveListing(_ ids: [String], for provider: ProviderID) { | |
| 56 | + liveModelIDs[provider] = Set(ids) | |
| 57 | + } | |
| 58 | + | |
| 59 | + /// IDs returned by the provider but absent from the built-in catalog. | |
| 60 | + func unknownLiveIDs(for provider: ProviderID) -> [String] { | |
| 61 | + guard let live = liveModelIDs[provider] else { return [] } | |
| 62 | + let known = Set(models(for: provider).map(\.id)) | |
| 63 | + return live.subtracting(known).sorted() | |
| 64 | + } | |
| 65 | + | |
| 66 | + private func rank(_ model: AIModel) -> Int { | |
| 67 | + if favoriteIDs.contains(model.id) { return 0 } | |
| 68 | + if model.isRecommended { return 1 } | |
| 69 | + if model.isLegacy { return 3 } | |
| 70 | + return 2 | |
| 71 | + } | |
| 72 | +} | |
added
Sources/ZyquoRouter/Services/ModelCatalogData.swift
+1364 −0
@@ -0,0 +1,1364 @@ | ||
| 1 | +// | |
| 2 | +// ModelCatalogData.swift | |
| 3 | +// Zyquo Router | |
| 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/ZyquoRouter/Services/PersistenceService.swift
+48 −0
@@ -0,0 +1,48 @@ | ||
| 1 | +// | |
| 2 | +// PersistenceService.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// JSON persistence in ~/Library/Application Support/ZyquoRouter/: | |
| 9 | +// aliases.json, chains.json, local-keys.json, usage.json, settings.json | |
| 10 | +// Ported from Zyquo Cloud; conversation-specific storage dropped (the Router | |
| 11 | +// keeps generic named-document load/save only). | |
| 12 | +// | |
| 13 | + | |
| 14 | +import Foundation | |
| 15 | + | |
| 16 | +struct PersistenceService { | |
| 17 | + static let shared = PersistenceService() | |
| 18 | + | |
| 19 | + let rootDirectory: URL | |
| 20 | + | |
| 21 | + private let encoder: JSONEncoder | |
| 22 | + private let decoder: JSONDecoder | |
| 23 | + | |
| 24 | + init(rootDirectory: URL? = nil) { | |
| 25 | + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] | |
| 26 | + self.rootDirectory = rootDirectory ?? base.appendingPathComponent("ZyquoRouter") | |
| 27 | + encoder = JSONEncoder() | |
| 28 | + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] | |
| 29 | + encoder.dateEncodingStrategy = .iso8601 | |
| 30 | + decoder = JSONDecoder() | |
| 31 | + decoder.dateDecodingStrategy = .iso8601 | |
| 32 | + try? FileManager.default.createDirectory(at: self.rootDirectory, withIntermediateDirectories: true) | |
| 33 | + } | |
| 34 | + | |
| 35 | + // MARK: - Generic documents (aliases, chains, keys, settings…) | |
| 36 | + | |
| 37 | + func load<T: Decodable>(_ type: T.Type, from fileName: String) -> T? { | |
| 38 | + let url = rootDirectory.appendingPathComponent(fileName) | |
| 39 | + guard let data = try? Data(contentsOf: url) else { return nil } | |
| 40 | + return try? decoder.decode(type, from: data) | |
| 41 | + } | |
| 42 | + | |
| 43 | + func save<T: Encodable>(_ value: T, to fileName: String) { | |
| 44 | + let url = rootDirectory.appendingPathComponent(fileName) | |
| 45 | + guard let data = try? encoder.encode(value) else { return } | |
| 46 | + try? data.write(to: url, options: .atomic) | |
| 47 | + } | |
| 48 | +} | |
added
Sources/ZyquoRouter/Services/SecureKeyStore.swift
+174 −0
@@ -0,0 +1,174 @@ | ||
| 1 | +// | |
| 2 | +// SecureKeyStore.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Custom API-key vault — deliberately NOT the macOS Keychain. | |
| 9 | +// | |
| 10 | +// Design: | |
| 11 | +// • Vault file ~/Library/Application Support/ZyquoRouter/vault.zq | |
| 12 | +// layout: [salt 32B][AES-GCM nonce 12B][ciphertext+tag] | |
| 13 | +// plaintext: JSON dictionary {"openai": "sk-…", "anthropic": "sk-ant-…", …} | |
| 14 | +// • Master key HKDF-SHA256(ikm: machineEntropy ‖ pepper, salt: vault salt, | |
| 15 | +// info: "ZyquoRouter.vault.v1") → AES-256-GCM key. | |
| 16 | +// machineEntropy = IOPlatformUUID (IOKit) ‖ user home path — binds the | |
| 17 | +// vault to this machine and account. | |
| 18 | +// • Pepper: compiled-in, assembled at runtime from obfuscated fragments — | |
| 19 | +// never a plain string literal in the binary. | |
| 20 | +// | |
| 21 | +// Keys are decrypted only on demand, never logged, never written to disk in | |
| 22 | +// plaintext, and redacted to their last 4 characters everywhere in the UI. | |
| 23 | +// | |
| 24 | + | |
| 25 | +import CryptoKit | |
| 26 | +import Foundation | |
| 27 | +import IOKit | |
| 28 | +import Security | |
| 29 | + | |
| 30 | +struct SecureKeyStore { | |
| 31 | + enum VaultError: LocalizedError { | |
| 32 | + case corrupted | |
| 33 | + case machineIdentityUnavailable | |
| 34 | + | |
| 35 | + var errorDescription: String? { | |
| 36 | + switch self { | |
| 37 | + case .corrupted: | |
| 38 | + return "The key vault is damaged or belongs to another machine." | |
| 39 | + case .machineIdentityUnavailable: | |
| 40 | + return "Could not read this Mac's hardware identity." | |
| 41 | + } | |
| 42 | + } | |
| 43 | + } | |
| 44 | + | |
| 45 | + private static let saltLength = 32 | |
| 46 | + private static let keyLength = 32 | |
| 47 | + | |
| 48 | + let vaultURL: URL | |
| 49 | + /// Overridable for tests; defaults to real machine identity. | |
| 50 | + private let machineEntropy: () throws -> Data | |
| 51 | + | |
| 52 | + init( | |
| 53 | + vaultURL: URL? = nil, | |
| 54 | + machineEntropy: (() throws -> Data)? = nil | |
| 55 | + ) { | |
| 56 | + let root = PersistenceService.shared.rootDirectory | |
| 57 | + self.vaultURL = vaultURL ?? root.appendingPathComponent("vault.zq") | |
| 58 | + self.machineEntropy = machineEntropy ?? Self.defaultMachineEntropy | |
| 59 | + } | |
| 60 | + | |
| 61 | + // MARK: - Public API | |
| 62 | + | |
| 63 | + /// All stored keys (provider rawValue → API key). Empty if no vault exists. | |
| 64 | + func loadKeys() throws -> [String: String] { | |
| 65 | + guard let blob = try? Data(contentsOf: vaultURL) else { return [:] } | |
| 66 | + guard blob.count > Self.saltLength + 12 + 16 else { throw VaultError.corrupted } | |
| 67 | + let salt = blob.prefix(Self.saltLength) | |
| 68 | + let rest = blob.dropFirst(Self.saltLength) | |
| 69 | + let key = try masterKey(salt: salt) | |
| 70 | + do { | |
| 71 | + let box = try AES.GCM.SealedBox(combined: rest) | |
| 72 | + let plaintext = try AES.GCM.open(box, using: key) | |
| 73 | + return try JSONDecoder().decode([String: String].self, from: plaintext) | |
| 74 | + } catch { | |
| 75 | + throw VaultError.corrupted | |
| 76 | + } | |
| 77 | + } | |
| 78 | + | |
| 79 | + /// Encrypts and atomically writes the full key dictionary. | |
| 80 | + func saveKeys(_ keys: [String: String]) throws { | |
| 81 | + let salt = try existingSalt() ?? Self.randomBytes(Self.saltLength) | |
| 82 | + let key = try masterKey(salt: salt) | |
| 83 | + let plaintext = try JSONEncoder().encode(keys) | |
| 84 | + let box = try AES.GCM.seal(plaintext, using: key) | |
| 85 | + guard let combined = box.combined else { throw VaultError.corrupted } | |
| 86 | + var blob = Data(salt) | |
| 87 | + blob.append(combined) | |
| 88 | + try FileManager.default.createDirectory( | |
| 89 | + at: vaultURL.deletingLastPathComponent(), withIntermediateDirectories: true | |
| 90 | + ) | |
| 91 | + try blob.write(to: vaultURL, options: [.atomic, .completeFileProtection]) | |
| 92 | + } | |
| 93 | + | |
| 94 | + func key(for provider: ProviderID) throws -> String? { | |
| 95 | + try loadKeys()[provider.rawValue] | |
| 96 | + } | |
| 97 | + | |
| 98 | + func setKey(_ apiKey: String, for provider: ProviderID) throws { | |
| 99 | + var keys = try loadKeys() | |
| 100 | + keys[provider.rawValue] = apiKey | |
| 101 | + try saveKeys(keys) | |
| 102 | + } | |
| 103 | + | |
| 104 | + func deleteKey(for provider: ProviderID) throws { | |
| 105 | + var keys = try loadKeys() | |
| 106 | + keys.removeValue(forKey: provider.rawValue) | |
| 107 | + try saveKeys(keys) | |
| 108 | + } | |
| 109 | + | |
| 110 | + /// "••••…abcd" display form. Never show more. | |
| 111 | + static func redacted(_ apiKey: String) -> String { | |
| 112 | + let suffix = apiKey.suffix(4) | |
| 113 | + return "••••\(suffix)" | |
| 114 | + } | |
| 115 | + | |
| 116 | + // MARK: - Key derivation | |
| 117 | + | |
| 118 | + private func masterKey(salt: Data) throws -> SymmetricKey { | |
| 119 | + var ikm = try machineEntropy() | |
| 120 | + ikm.append(Self.pepper()) | |
| 121 | + return HKDF<SHA256>.deriveKey( | |
| 122 | + inputKeyMaterial: SymmetricKey(data: ikm), | |
| 123 | + salt: salt, | |
| 124 | + info: Data("ZyquoRouter.vault.v1".utf8), | |
| 125 | + outputByteCount: Self.keyLength | |
| 126 | + ) | |
| 127 | + } | |
| 128 | + | |
| 129 | + /// Hardware UUID + home path. Binds the vault to machine + account. | |
| 130 | + private static func defaultMachineEntropy() throws -> Data { | |
| 131 | + guard let uuid = platformUUID() else { throw VaultError.machineIdentityUnavailable } | |
| 132 | + var entropy = Data(uuid.utf8) | |
| 133 | + entropy.append(Data(NSHomeDirectory().utf8)) | |
| 134 | + return entropy | |
| 135 | + } | |
| 136 | + | |
| 137 | + /// IOPlatformUUID from the IOPlatformExpertDevice registry entry. | |
| 138 | + private static func platformUUID() -> String? { | |
| 139 | + let service = IOServiceGetMatchingService( | |
| 140 | + kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice") | |
| 141 | + ) | |
| 142 | + guard service != IO_OBJECT_NULL else { return nil } | |
| 143 | + defer { IOObjectRelease(service) } | |
| 144 | + guard let property = IORegistryEntryCreateCFProperty( | |
| 145 | + service, kIOPlatformUUIDKey as CFString, kCFAllocatorDefault, 0 | |
| 146 | + ) else { return nil } | |
| 147 | + return property.takeRetainedValue() as? String | |
| 148 | + } | |
| 149 | + | |
| 150 | + /// App pepper, assembled at runtime — the constants below are the pepper | |
| 151 | + /// bytes XOR 0x5A so the value never appears verbatim in the binary. | |
| 152 | + private static func pepper() -> Data { | |
| 153 | + let obfuscated: [UInt8] = [ | |
| 154 | + 0x10, 0x23, 0x2B, 0x2F, 0x35, 0x79, 0x36, 0x35, 0x2F, 0x3E, | |
| 155 | + 0x77, 0x28, 0x3B, 0x33, 0x34, 0x78, 0x39, 0x36, 0x35, 0x2F, | |
| 156 | + 0x3E, 0x69, 0x6E, 0x68, 0x6C, 0x0E, 0x3B, 0x28, 0x3F, 0x08, | |
| 157 | + ] | |
| 158 | + return Data(obfuscated.map { $0 ^ 0x5A }) | |
| 159 | + } | |
| 160 | + | |
| 161 | + private static func randomBytes(_ count: Int) throws -> Data { | |
| 162 | + var bytes = [UInt8](repeating: 0, count: count) | |
| 163 | + let status = SecRandomCopyBytes(kSecRandomDefault, count, &bytes) | |
| 164 | + guard status == errSecSuccess else { throw VaultError.corrupted } | |
| 165 | + return Data(bytes) | |
| 166 | + } | |
| 167 | + | |
| 168 | + /// Salt of the existing vault, so re-saving keeps the same derivation. | |
| 169 | + private func existingSalt() throws -> Data? { | |
| 170 | + guard let blob = try? Data(contentsOf: vaultURL), | |
| 171 | + blob.count >= Self.saltLength else { return nil } | |
| 172 | + return blob.prefix(Self.saltLength) | |
| 173 | + } | |
| 174 | +} | |
added
Sources/ZyquoRouter/Services/StreamingService.swift
+165 −0
@@ -0,0 +1,165 @@ | ||
| 1 | +// | |
| 2 | +// StreamingService.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | + | |
| 11 | +/// One Server-Sent Event as parsed off the wire. | |
| 12 | +struct SSEEvent { | |
| 13 | + /// The `event:` field, if the stream names its events (Anthropic does). | |
| 14 | + var event: String? | |
| 15 | + /// Joined `data:` lines. | |
| 16 | + var data: String | |
| 17 | +} | |
| 18 | + | |
| 19 | +/// Incremental SSE parser. Feed it raw lines (or byte chunks split on newlines) | |
| 20 | +/// and it yields complete events at blank-line boundaries, ignoring `:` comment | |
| 21 | +/// lines (DeepSeek sends `: keep-alive`) and unknown fields. | |
| 22 | +struct SSEParser { | |
| 23 | + private var currentEvent: String? | |
| 24 | + private var currentData: [String] = [] | |
| 25 | + | |
| 26 | + /// Consumes one line (without its trailing newline). Returns a completed | |
| 27 | + /// event when the line is the blank separator, else nil. | |
| 28 | + mutating func consume(line: String) -> SSEEvent? { | |
| 29 | + if line.isEmpty { | |
| 30 | + guard !currentData.isEmpty || currentEvent != nil else { return nil } | |
| 31 | + let event = SSEEvent(event: currentEvent, data: currentData.joined(separator: "\n")) | |
| 32 | + currentEvent = nil | |
| 33 | + currentData = [] | |
| 34 | + return event.data.isEmpty && event.event == nil ? nil : event | |
| 35 | + } | |
| 36 | + if line.hasPrefix(":") { return nil } // comment / keep-alive | |
| 37 | + if line.hasPrefix("event:") { | |
| 38 | + currentEvent = String(line.dropFirst(6)).trimmingCharacters(in: .whitespaces) | |
| 39 | + } else if line.hasPrefix("data:") { | |
| 40 | + var value = String(line.dropFirst(5)) | |
| 41 | + if value.hasPrefix(" ") { value.removeFirst() } | |
| 42 | + currentData.append(value) | |
| 43 | + } | |
| 44 | + // id:/retry:/unknown fields are ignored. | |
| 45 | + return nil | |
| 46 | + } | |
| 47 | +} | |
| 48 | + | |
| 49 | +/// Shared networking for all provider clients: request construction helpers and | |
| 50 | +/// an SSE line stream over URLSession. | |
| 51 | +enum StreamingService { | |
| 52 | + /// URLSession tuned for long-lived streaming responses. | |
| 53 | + static let session: URLSession = { | |
| 54 | + let config = URLSessionConfiguration.default | |
| 55 | + config.timeoutIntervalForRequest = 120 | |
| 56 | + config.timeoutIntervalForResource = 900 | |
| 57 | + config.httpAdditionalHeaders = ["User-Agent": "ZyquoRouter/1.0 (macOS)"] | |
| 58 | + return URLSession(configuration: config) | |
| 59 | + }() | |
| 60 | + | |
| 61 | + /// POSTs `body` as JSON and returns the SSE events of the response. | |
| 62 | + /// Throws `ProviderError` on non-2xx status (reading the full error body). | |
| 63 | + static func sseEvents( | |
| 64 | + for request: URLRequest, | |
| 65 | + provider: ProviderID | |
| 66 | + ) -> AsyncThrowingStream<SSEEvent, Error> { | |
| 67 | + AsyncThrowingStream { continuation in | |
| 68 | + let task = Task { | |
| 69 | + do { | |
| 70 | + let (bytes, response) = try await session.bytes(for: request) | |
| 71 | + guard let http = response as? HTTPURLResponse else { | |
| 72 | + throw ProviderError.invalidResponse(provider, detail: "not an HTTP response") | |
| 73 | + } | |
| 74 | + guard (200..<300).contains(http.statusCode) else { | |
| 75 | + var body = Data() | |
| 76 | + for try await byte in bytes { body.append(byte) } | |
| 77 | + throw ProviderError.from(status: http.statusCode, body: body, provider: provider) | |
| 78 | + } | |
| 79 | + // NOTE: AsyncBytes.lines skips empty lines, which are the | |
| 80 | + // SSE event separators — split manually to preserve them. | |
| 81 | + var parser = SSEParser() | |
| 82 | + var lineBuffer = Data() | |
| 83 | + for try await byte in bytes { | |
| 84 | + if Task.isCancelled { break } | |
| 85 | + if byte == 0x0A { // \n | |
| 86 | + if lineBuffer.last == 0x0D { lineBuffer.removeLast() } // \r\n | |
| 87 | + let line = String(decoding: lineBuffer, as: UTF8.self) | |
| 88 | + lineBuffer.removeAll(keepingCapacity: true) | |
| 89 | + if let event = parser.consume(line: line) { | |
| 90 | + continuation.yield(event) | |
| 91 | + } | |
| 92 | + } else { | |
| 93 | + lineBuffer.append(byte) | |
| 94 | + } | |
| 95 | + } | |
| 96 | + // Flush a trailing line + event if the stream ended | |
| 97 | + // without a final newline / blank separator. | |
| 98 | + if !lineBuffer.isEmpty { | |
| 99 | + let line = String(decoding: lineBuffer, as: UTF8.self) | |
| 100 | + if let event = parser.consume(line: line) { | |
| 101 | + continuation.yield(event) | |
| 102 | + } | |
| 103 | + } | |
| 104 | + if let event = parser.consume(line: "") { | |
| 105 | + continuation.yield(event) | |
| 106 | + } | |
| 107 | + continuation.finish() | |
| 108 | + } catch is CancellationError { | |
| 109 | + continuation.finish(throwing: ProviderError.cancelled) | |
| 110 | + } catch let error as ProviderError { | |
| 111 | + continuation.finish(throwing: error) | |
| 112 | + } catch { | |
| 113 | + continuation.finish(throwing: ProviderError.networkError(underlying: error)) | |
| 114 | + } | |
| 115 | + } | |
| 116 | + continuation.onTermination = { _ in task.cancel() } | |
| 117 | + } | |
| 118 | + } | |
| 119 | + | |
| 120 | + /// Non-streaming JSON POST with exponential backoff on 429/5xx (3 attempts). | |
| 121 | + /// Returns the response body data. | |
| 122 | + static func postJSON( | |
| 123 | + _ request: URLRequest, | |
| 124 | + provider: ProviderID | |
| 125 | + ) async throws -> Data { | |
| 126 | + let maxAttempts = 3 | |
| 127 | + var lastError: ProviderError = .invalidResponse(provider, detail: "no attempts made") | |
| 128 | + for attempt in 1...maxAttempts { | |
| 129 | + do { | |
| 130 | + let (data, response) = try await session.data(for: request) | |
| 131 | + guard let http = response as? HTTPURLResponse else { | |
| 132 | + throw ProviderError.invalidResponse(provider, detail: "not an HTTP response") | |
| 133 | + } | |
| 134 | + guard (200..<300).contains(http.statusCode) else { | |
| 135 | + let error = ProviderError.from(status: http.statusCode, body: data, provider: provider) | |
| 136 | + if attempt < maxAttempts, http.statusCode == 429 || http.statusCode >= 500 { | |
| 137 | + lastError = error | |
| 138 | + let retryAfter = (response as? HTTPURLResponse)? | |
| 139 | + .value(forHTTPHeaderField: "Retry-After").flatMap(Double.init) | |
| 140 | + let delay = retryAfter ?? pow(2, Double(attempt)) * 2 // 4s, 8s | |
| 141 | + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) | |
| 142 | + continue | |
| 143 | + } | |
| 144 | + throw error | |
| 145 | + } | |
| 146 | + return data | |
| 147 | + } catch let error as ProviderError { | |
| 148 | + throw error | |
| 149 | + } catch is CancellationError { | |
| 150 | + throw ProviderError.cancelled | |
| 151 | + } catch { | |
| 152 | + throw ProviderError.networkError(underlying: error) | |
| 153 | + } | |
| 154 | + } | |
| 155 | + throw lastError | |
| 156 | + } | |
| 157 | + | |
| 158 | + /// GET returning decoded JSON data, with the same error mapping. | |
| 159 | + static func getJSON( | |
| 160 | + _ request: URLRequest, | |
| 161 | + provider: ProviderID | |
| 162 | + ) async throws -> Data { | |
| 163 | + try await postJSON(request, provider: provider) | |
| 164 | + } | |
| 165 | +} | |
added
Sources/ZyquoRouter/Translate/OpenAINormalizer.swift
+105 −0
@@ -0,0 +1,105 @@ | ||
| 1 | +// | |
| 2 | +// OpenAINormalizer.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Canonical OpenAI wire format served by the router. Phase 2 carries the | |
| 9 | +// error body and the /v1/models list shapes; Phase 3 adds the full | |
| 10 | +// chat/completions request/response/chunk types and normalization. | |
| 11 | +// | |
| 12 | + | |
| 13 | +import Foundation | |
| 14 | +import NIOHTTP1 | |
| 15 | + | |
| 16 | +// MARK: - Error body (spec: {"error": {"message", "type", "param", "code"}}) | |
| 17 | + | |
| 18 | +struct OpenAIError: Codable { | |
| 19 | + struct Body: Codable { | |
| 20 | + var message: String | |
| 21 | + var type: String | |
| 22 | + var param: String? | |
| 23 | + var code: String? | |
| 24 | + } | |
| 25 | + | |
| 26 | + var error: Body | |
| 27 | + | |
| 28 | + private static let encoder: JSONEncoder = { | |
| 29 | + let encoder = JSONEncoder() | |
| 30 | + encoder.outputFormatting = [.withoutEscapingSlashes, .sortedKeys] | |
| 31 | + return encoder | |
| 32 | + }() | |
| 33 | + | |
| 34 | + /// A complete error response in OpenAI wire shape. | |
| 35 | + static func response( | |
| 36 | + status: HTTPResponseStatus, | |
| 37 | + message: String, | |
| 38 | + type: String, | |
| 39 | + param: String? = nil, | |
| 40 | + code: String? = nil, | |
| 41 | + extraHeaders: [(String, String)] = [] | |
| 42 | + ) -> RouteResult { | |
| 43 | + let payload = OpenAIError(error: Body(message: message, type: type, param: param, code: code)) | |
| 44 | + let body = (try? encoder.encode(payload)) ?? Data(#"{"error":{"message":"internal error"}}"#.utf8) | |
| 45 | + return .complete( | |
| 46 | + status: status, | |
| 47 | + headers: [("Content-Type", "application/json")] + extraHeaders, | |
| 48 | + body: body | |
| 49 | + ) | |
| 50 | + } | |
| 51 | + | |
| 52 | + /// The exact 404 OpenAI returns for unknown models. | |
| 53 | + static func modelNotFound(_ model: String) -> RouteResult { | |
| 54 | + response( | |
| 55 | + status: .notFound, | |
| 56 | + message: "The model `\(model)` does not exist or you do not have access to it.", | |
| 57 | + type: "invalid_request_error", | |
| 58 | + param: "model", | |
| 59 | + code: "model_not_found" | |
| 60 | + ) | |
| 61 | + } | |
| 62 | +} | |
| 63 | + | |
| 64 | +// MARK: - /v1/models list shapes | |
| 65 | + | |
| 66 | +struct OpenAIModelEntry: Codable { | |
| 67 | + var id: String | |
| 68 | + var object = "model" | |
| 69 | + var created: Int | |
| 70 | + var ownedBy: String | |
| 71 | + /// Router extension: catalog metadata (context, pricing, capabilities). | |
| 72 | + var xZyquo: XZyquoModelInfo? | |
| 73 | + | |
| 74 | + enum CodingKeys: String, CodingKey { | |
| 75 | + case id, object, created | |
| 76 | + case ownedBy = "owned_by" | |
| 77 | + case xZyquo = "x_zyquo" | |
| 78 | + } | |
| 79 | +} | |
| 80 | + | |
| 81 | +struct XZyquoModelInfo: Codable { | |
| 82 | + var displayName: String | |
| 83 | + var contextWindow: Int | |
| 84 | + var maxOutputTokens: Int? | |
| 85 | + var vision: Bool | |
| 86 | + var tools: Bool | |
| 87 | + var reasoning: Bool | |
| 88 | + var inputPerMTok: Double? | |
| 89 | + var outputPerMTok: Double? | |
| 90 | + var alias: String? | |
| 91 | + | |
| 92 | + enum CodingKeys: String, CodingKey { | |
| 93 | + case displayName = "display_name" | |
| 94 | + case contextWindow = "context_window" | |
| 95 | + case maxOutputTokens = "max_output_tokens" | |
| 96 | + case vision, tools, reasoning, alias | |
| 97 | + case inputPerMTok = "input_per_mtok" | |
| 98 | + case outputPerMTok = "output_per_mtok" | |
| 99 | + } | |
| 100 | +} | |
| 101 | + | |
| 102 | +struct OpenAIModelList: Codable { | |
| 103 | + var object = "list" | |
| 104 | + var data: [OpenAIModelEntry] | |
| 105 | +} | |
added
Sources/ZyquoRouter/ViewModels/ServerController.swift
+81 −0
@@ -0,0 +1,81 @@ | ||
| 1 | +// | |
| 2 | +// ServerController.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Owns the embedded server's lifecycle for the app: Start/Stop, state for | |
| 9 | +// the status pill, and the served endpoint URL. The server itself runs in a | |
| 10 | +// detached task; stopping cancels it, which drains the accept loop and | |
| 11 | +// cancels in-flight requests (and their upstream calls). | |
| 12 | +// | |
| 13 | + | |
| 14 | +import Foundation | |
| 15 | +import SwiftUI | |
| 16 | + | |
| 17 | +@MainActor | |
| 18 | +final class ServerController: ObservableObject { | |
| 19 | + enum State: Equatable { | |
| 20 | + case stopped | |
| 21 | + case starting | |
| 22 | + case running(port: Int) | |
| 23 | + case failed(String) | |
| 24 | + } | |
| 25 | + | |
| 26 | + @Published private(set) var state: State = .stopped | |
| 27 | + @AppStorage("serverPort") var port = 8787 | |
| 28 | + @AppStorage("bindLAN") var bindLAN = false | |
| 29 | + | |
| 30 | + private var serverTask: Task<Void, Never>? | |
| 31 | + | |
| 32 | + var endpointURL: String { | |
| 33 | + "http://localhost:\(port)/v1" | |
| 34 | + } | |
| 35 | + | |
| 36 | + var isRunning: Bool { | |
| 37 | + if case .running = state { return true } | |
| 38 | + return false | |
| 39 | + } | |
| 40 | + | |
| 41 | + func start() { | |
| 42 | + guard serverTask == nil else { return } | |
| 43 | + state = .starting | |
| 44 | + | |
| 45 | + let host = bindLAN ? "0.0.0.0" : "127.0.0.1" | |
| 46 | + let port = port | |
| 47 | + let routes = Routes(router: RequestRouter()) | |
| 48 | + let server = HTTPServer(host: host, port: port) { request in | |
| 49 | + await routes.handle(request) | |
| 50 | + } | |
| 51 | + | |
| 52 | + // The Task inherits MainActor isolation, so state updates after `run` | |
| 53 | + // returns are direct; only the nonisolated onRunning callback hops. | |
| 54 | + serverTask = Task { | |
| 55 | + do { | |
| 56 | + try await server.run { | |
| 57 | + Task { @MainActor [weak self] in | |
| 58 | + guard let self else { return } | |
| 59 | + self.state = .running(port: port) | |
| 60 | + } | |
| 61 | + } | |
| 62 | + self.serverTask = nil | |
| 63 | + self.state = .stopped | |
| 64 | + } catch is CancellationError { | |
| 65 | + self.serverTask = nil | |
| 66 | + self.state = .stopped | |
| 67 | + } catch { | |
| 68 | + self.serverTask = nil | |
| 69 | + self.state = .failed(error.localizedDescription) | |
| 70 | + } | |
| 71 | + } | |
| 72 | + } | |
| 73 | + | |
| 74 | + func stop() { | |
| 75 | + serverTask?.cancel() | |
| 76 | + } | |
| 77 | + | |
| 78 | + func toggle() { | |
| 79 | + isRunning || state == .starting ? stop() : start() | |
| 80 | + } | |
| 81 | +} | |
added
Tests/ZyquoRouterTests/HTTPServerTests.swift
+87 −0
@@ -0,0 +1,87 @@ | ||
| 1 | +// | |
| 2 | +// HTTPServerTests.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Phase 2 gate: bind, serve /health and /v1/models, detect port-in-use, | |
| 9 | +// shut down gracefully on cancellation (socket must be reusable right after). | |
| 10 | +// | |
| 11 | + | |
| 12 | +import XCTest | |
| 13 | +@testable import ZyquoRouter | |
| 14 | + | |
| 15 | +final class HTTPServerTests: XCTestCase { | |
| 16 | + private static let port = 18787 | |
| 17 | + | |
| 18 | + private func makeServer(port: Int) -> HTTPServer { | |
| 19 | + let routes = Routes(router: RequestRouter()) | |
| 20 | + return HTTPServer(host: "127.0.0.1", port: port) { request in | |
| 21 | + await routes.handle(request) | |
| 22 | + } | |
| 23 | + } | |
| 24 | + | |
| 25 | + /// Starts a server task and waits until the socket is accepting. | |
| 26 | + private func startServer(port: Int) async -> Task<Void, Error> { | |
| 27 | + let started = expectation(description: "server started on \(port)") | |
| 28 | + let task = Task { [server = makeServer(port: port)] in | |
| 29 | + try await server.run { started.fulfill() } | |
| 30 | + } | |
| 31 | + await fulfillment(of: [started], timeout: 5) | |
| 32 | + return task | |
| 33 | + } | |
| 34 | + | |
| 35 | + private func get(_ path: String, port: Int) async throws -> (Int, Data) { | |
| 36 | + let (data, response) = try await URLSession.shared.data( | |
| 37 | + from: URL(string: "http://127.0.0.1:\(port)\(path)")! | |
| 38 | + ) | |
| 39 | + return ((response as! HTTPURLResponse).statusCode, data) | |
| 40 | + } | |
| 41 | + | |
| 42 | + func testServeHealthAndModelsThenGracefulStop() async throws { | |
| 43 | + let task = await startServer(port: Self.port) | |
| 44 | + | |
| 45 | + let (healthStatus, healthBody) = try await get("/health", port: Self.port) | |
| 46 | + XCTAssertEqual(healthStatus, 200) | |
| 47 | + let health = try JSONSerialization.jsonObject(with: healthBody) as! [String: Any] | |
| 48 | + XCTAssertEqual(health["status"] as? String, "ok") | |
| 49 | + | |
| 50 | + let (modelsStatus, modelsBody) = try await get("/v1/models", port: Self.port) | |
| 51 | + XCTAssertEqual(modelsStatus, 200) | |
| 52 | + let list = try JSONSerialization.jsonObject(with: modelsBody) as! [String: Any] | |
| 53 | + XCTAssertEqual(list["object"] as? String, "list") | |
| 54 | + let data = list["data"] as! [[String: Any]] | |
| 55 | + XCTAssertEqual(data.count, ModelCatalogData.all.count) | |
| 56 | + XCTAssertTrue((data[0]["id"] as! String).contains("/"), "model IDs must be namespaced") | |
| 57 | + | |
| 58 | + // Graceful stop: cancel, wait for run() to return, then the port must | |
| 59 | + // be immediately bindable again. | |
| 60 | + task.cancel() | |
| 61 | + _ = try? await task.value | |
| 62 | + | |
| 63 | + let restarted = await startServer(port: Self.port) | |
| 64 | + let (again, _) = try await get("/health", port: Self.port) | |
| 65 | + XCTAssertEqual(again, 200) | |
| 66 | + restarted.cancel() | |
| 67 | + _ = try? await restarted.value | |
| 68 | + } | |
| 69 | + | |
| 70 | + func testPortInUseError() async throws { | |
| 71 | + let task = await startServer(port: Self.port + 1) | |
| 72 | + | |
| 73 | + let second = makeServer(port: Self.port + 1) | |
| 74 | + do { | |
| 75 | + try await second.run {} | |
| 76 | + XCTFail("second bind should fail") | |
| 77 | + } catch let error as ServerError { | |
| 78 | + guard case .portInUse(_, let port) = error else { | |
| 79 | + return XCTFail("expected portInUse, got \(error)") | |
| 80 | + } | |
| 81 | + XCTAssertEqual(port, Self.port + 1) | |
| 82 | + } | |
| 83 | + | |
| 84 | + task.cancel() | |
| 85 | + _ = try? await task.value | |
| 86 | + } | |
| 87 | +} | |
added
Tests/ZyquoRouterTests/RequestRouterTests.swift
+97 −0
@@ -0,0 +1,97 @@ | ||
| 1 | +// | |
| 2 | +// RequestRouterTests.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Model-resolution rules: namespaced IDs, unambiguous bare IDs, aliases, | |
| 9 | +// disabled models, ambiguity across providers. | |
| 10 | +// | |
| 11 | + | |
| 12 | +import XCTest | |
| 13 | +@testable import ZyquoRouter | |
| 14 | + | |
| 15 | +final class RequestRouterTests: XCTestCase { | |
| 16 | + private func model(_ id: String, _ provider: ProviderID) -> AIModel { | |
| 17 | + AIModel( | |
| 18 | + id: id, | |
| 19 | + provider: provider, | |
| 20 | + displayName: id, | |
| 21 | + contextWindow: 128_000, | |
| 22 | + maxOutputTokens: nil, | |
| 23 | + capabilities: ModelCapabilities(), | |
| 24 | + pricing: nil, | |
| 25 | + parameterSupport: ParameterSupport() | |
| 26 | + ) | |
| 27 | + } | |
| 28 | + | |
| 29 | + private var router: RequestRouter { | |
| 30 | + RequestRouter( | |
| 31 | + catalog: [ | |
| 32 | + model("alpha-1", .openai), | |
| 33 | + model("shared-model", .together), | |
| 34 | + model("shared-model", .deepinfra), | |
| 35 | + model("meta-llama/Llama-4", .deepinfra), | |
| 36 | + ], | |
| 37 | + aliases: ["fast": "openai/alpha-1"], | |
| 38 | + disabledIDs: ["together/shared-model"] | |
| 39 | + ) | |
| 40 | + } | |
| 41 | + | |
| 42 | + func testNamespacedResolution() throws { | |
| 43 | + let resolution = try router.resolve("openai/alpha-1") | |
| 44 | + XCTAssertEqual(resolution.namespacedID, "openai/alpha-1") | |
| 45 | + XCTAssertEqual(resolution.model.provider, .openai) | |
| 46 | + } | |
| 47 | + | |
| 48 | + func testBareIDUnambiguous() throws { | |
| 49 | + XCTAssertEqual(try router.resolve("alpha-1").namespacedID, "openai/alpha-1") | |
| 50 | + } | |
| 51 | + | |
| 52 | + func testModelIDContainingSlash() throws { | |
| 53 | + XCTAssertEqual( | |
| 54 | + try router.resolve("deepinfra/meta-llama/Llama-4").namespacedID, | |
| 55 | + "deepinfra/meta-llama/Llama-4" | |
| 56 | + ) | |
| 57 | + // Bare form works too: "meta-llama" is not a provider prefix. | |
| 58 | + XCTAssertEqual( | |
| 59 | + try router.resolve("meta-llama/Llama-4").namespacedID, | |
| 60 | + "deepinfra/meta-llama/Llama-4" | |
| 61 | + ) | |
| 62 | + } | |
| 63 | + | |
| 64 | + func testAlias() throws { | |
| 65 | + XCTAssertEqual(try router.resolve("fast").namespacedID, "openai/alpha-1") | |
| 66 | + } | |
| 67 | + | |
| 68 | + func testAmbiguousBareID() { | |
| 69 | + XCTAssertThrowsError(try router.resolve("shared-model")) { error in | |
| 70 | + guard case RequestRouter.RoutingError.ambiguousModel(_, let candidates) = error else { | |
| 71 | + return XCTFail("expected ambiguity, got \(error)") | |
| 72 | + } | |
| 73 | + XCTAssertEqual(Set(candidates), ["together/shared-model", "deepinfra/shared-model"]) | |
| 74 | + } | |
| 75 | + } | |
| 76 | + | |
| 77 | + func testDisabledModel() { | |
| 78 | + XCTAssertThrowsError(try router.resolve("together/shared-model")) { error in | |
| 79 | + guard case RequestRouter.RoutingError.disabledModel = error else { | |
| 80 | + return XCTFail("expected disabled, got \(error)") | |
| 81 | + } | |
| 82 | + } | |
| 83 | + } | |
| 84 | + | |
| 85 | + func testUnknownModel() { | |
| 86 | + XCTAssertThrowsError(try router.resolve("nope")) { error in | |
| 87 | + guard case RequestRouter.RoutingError.unknownModel = error else { | |
| 88 | + return XCTFail("expected unknown, got \(error)") | |
| 89 | + } | |
| 90 | + } | |
| 91 | + } | |
| 92 | + | |
| 93 | + func testFullCatalogHasNoAmbiguousNamespacedIDs() { | |
| 94 | + let ids = ModelCatalogData.all.map { RequestRouter.namespacedID(for: $0) } | |
| 95 | + XCTAssertEqual(ids.count, Set(ids).count, "namespaced IDs must be unique") | |
| 96 | + } | |
| 97 | +} | |
added
Tests/ZyquoRouterTests/SSEParserTests.swift
+85 −0
@@ -0,0 +1,85 @@ | ||
| 1 | +// | |
| 2 | +// SSEParserTests.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Testing | |
| 10 | +@testable import ZyquoRouter | |
| 11 | + | |
| 12 | +@Suite struct SSEParserTests { | |
| 13 | + private func parse(_ lines: [String]) -> [SSEEvent] { | |
| 14 | + var parser = SSEParser() | |
| 15 | + var events: [SSEEvent] = [] | |
| 16 | + for line in lines { | |
| 17 | + if let event = parser.consume(line: line) { events.append(event) } | |
| 18 | + } | |
| 19 | + if let last = parser.consume(line: "") { events.append(last) } | |
| 20 | + return events | |
| 21 | + } | |
| 22 | + | |
| 23 | + @Test func openAIStyleDataEvents() { | |
| 24 | + let events = parse([ | |
| 25 | + #"data: {"choices":[{"delta":{"content":"Hel"}}]}"#, | |
| 26 | + "", | |
| 27 | + #"data: {"choices":[{"delta":{"content":"lo"}}]}"#, | |
| 28 | + "", | |
| 29 | + "data: [DONE]", | |
| 30 | + "", | |
| 31 | + ]) | |
| 32 | + #expect(events.count == 3) | |
| 33 | + #expect(events[0].data.contains("Hel")) | |
| 34 | + #expect(events[2].data == "[DONE]") | |
| 35 | + #expect(events[0].event == nil) | |
| 36 | + } | |
| 37 | + | |
| 38 | + @Test func anthropicNamedEvents() { | |
| 39 | + let events = parse([ | |
| 40 | + "event: message_start", | |
| 41 | + #"data: {"type":"message_start","message":{"usage":{"input_tokens":9}}}"#, | |
| 42 | + "", | |
| 43 | + "event: content_block_delta", | |
| 44 | + #"data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"OK"}}"#, | |
| 45 | + "", | |
| 46 | + "event: message_stop", | |
| 47 | + #"data: {"type":"message_stop"}"#, | |
| 48 | + "", | |
| 49 | + ]) | |
| 50 | + #expect(events.count == 3) | |
| 51 | + #expect(events[0].event == "message_start") | |
| 52 | + #expect(events[1].event == "content_block_delta") | |
| 53 | + #expect(events[2].event == "message_stop") | |
| 54 | + } | |
| 55 | + | |
| 56 | + @Test func commentAndKeepAliveLinesIgnored() { | |
| 57 | + let events = parse([ | |
| 58 | + ": keep-alive", | |
| 59 | + "", | |
| 60 | + #"data: {"x":1}"#, | |
| 61 | + "", | |
| 62 | + ": another comment", | |
| 63 | + "", | |
| 64 | + ]) | |
| 65 | + #expect(events.count == 1) | |
| 66 | + #expect(events[0].data == #"{"x":1}"#) | |
| 67 | + } | |
| 68 | + | |
| 69 | + @Test func multiLineDataJoined() { | |
| 70 | + let events = parse([ | |
| 71 | + "data: line1", | |
| 72 | + "data: line2", | |
| 73 | + "", | |
| 74 | + ]) | |
| 75 | + #expect(events.count == 1) | |
| 76 | + #expect(events[0].data == "line1\nline2") | |
| 77 | + } | |
| 78 | + | |
| 79 | + @Test func trailingEventWithoutBlankLineFlushed() { | |
| 80 | + var parser = SSEParser() | |
| 81 | + #expect(parser.consume(line: "data: tail") == nil) | |
| 82 | + let flushed = parser.consume(line: "") | |
| 83 | + #expect(flushed?.data == "tail") | |
| 84 | + } | |
| 85 | +} | |
added
Tests/ZyquoRouterTests/SecureKeyStoreTests.swift
+106 −0
@@ -0,0 +1,106 @@ | ||
| 1 | +// | |
| 2 | +// SecureKeyStoreTests.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import Testing | |
| 11 | +@testable import ZyquoRouter | |
| 12 | + | |
| 13 | +@Suite struct SecureKeyStoreTests { | |
| 14 | + private func temporaryVaultURL() -> URL { | |
| 15 | + FileManager.default.temporaryDirectory | |
| 16 | + .appendingPathComponent("zyquo-tests-\(UUID().uuidString)") | |
| 17 | + .appendingPathComponent("vault.zq") | |
| 18 | + } | |
| 19 | + | |
| 20 | + @Test func encryptDecryptRoundTrip() throws { | |
| 21 | + let url = temporaryVaultURL() | |
| 22 | + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } | |
| 23 | + let store = SecureKeyStore(vaultURL: url) | |
| 24 | + | |
| 25 | + let keys = [ | |
| 26 | + "openai": "sk-proj-test-1234", | |
| 27 | + "anthropic": "sk-ant-test-5678", | |
| 28 | + "mistral": "plainkey", | |
| 29 | + ] | |
| 30 | + try store.saveKeys(keys) | |
| 31 | + let loaded = try store.loadKeys() | |
| 32 | + #expect(loaded == keys) | |
| 33 | + | |
| 34 | + // Vault file must not contain any key material in plaintext. | |
| 35 | + let raw = try Data(contentsOf: url) | |
| 36 | + let rawString = String(decoding: raw, as: UTF8.self) | |
| 37 | + #expect(!rawString.contains("sk-proj-test-1234")) | |
| 38 | + #expect(!rawString.contains("sk-ant-test-5678")) | |
| 39 | + #expect(!rawString.contains("openai")) | |
| 40 | + } | |
| 41 | + | |
| 42 | + @Test func perProviderSetGetDelete() throws { | |
| 43 | + let url = temporaryVaultURL() | |
| 44 | + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } | |
| 45 | + let store = SecureKeyStore(vaultURL: url) | |
| 46 | + | |
| 47 | + try store.setKey("xai-abc", for: .xai) | |
| 48 | + try store.setKey("pplx-def", for: .perplexity) | |
| 49 | + #expect(try store.key(for: .xai) == "xai-abc") | |
| 50 | + #expect(try store.key(for: .perplexity) == "pplx-def") | |
| 51 | + | |
| 52 | + try store.deleteKey(for: .xai) | |
| 53 | + #expect(try store.key(for: .xai) == nil) | |
| 54 | + #expect(try store.key(for: .perplexity) == "pplx-def") | |
| 55 | + } | |
| 56 | + | |
| 57 | + @Test func emptyVaultLoadsEmpty() throws { | |
| 58 | + let store = SecureKeyStore(vaultURL: temporaryVaultURL()) | |
| 59 | + #expect(try store.loadKeys().isEmpty) | |
| 60 | + } | |
| 61 | + | |
| 62 | + @Test func tamperedVaultThrowsCorrupted() throws { | |
| 63 | + let url = temporaryVaultURL() | |
| 64 | + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } | |
| 65 | + let store = SecureKeyStore(vaultURL: url) | |
| 66 | + try store.saveKeys(["openai": "sk-test"]) | |
| 67 | + | |
| 68 | + var blob = try Data(contentsOf: url) | |
| 69 | + blob[blob.count - 1] ^= 0xFF // flip a tag byte | |
| 70 | + try blob.write(to: url) | |
| 71 | + | |
| 72 | + #expect(throws: SecureKeyStore.VaultError.self) { | |
| 73 | + _ = try store.loadKeys() | |
| 74 | + } | |
| 75 | + } | |
| 76 | + | |
| 77 | + @Test func vaultIsMachineBound() throws { | |
| 78 | + let url = temporaryVaultURL() | |
| 79 | + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } | |
| 80 | + | |
| 81 | + let machineA = SecureKeyStore(vaultURL: url, machineEntropy: { Data("machine-A".utf8) }) | |
| 82 | + try machineA.saveKeys(["openai": "sk-test"]) | |
| 83 | + | |
| 84 | + let machineB = SecureKeyStore(vaultURL: url, machineEntropy: { Data("machine-B".utf8) }) | |
| 85 | + #expect(throws: SecureKeyStore.VaultError.self) { | |
| 86 | + _ = try machineB.loadKeys() | |
| 87 | + } | |
| 88 | + } | |
| 89 | + | |
| 90 | + @Test func saltIsStableAcrossSaves() throws { | |
| 91 | + let url = temporaryVaultURL() | |
| 92 | + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } | |
| 93 | + let store = SecureKeyStore(vaultURL: url) | |
| 94 | + | |
| 95 | + try store.saveKeys(["a": "1"]) | |
| 96 | + let salt1 = try Data(contentsOf: url).prefix(32) | |
| 97 | + try store.saveKeys(["a": "1", "b": "2"]) | |
| 98 | + let salt2 = try Data(contentsOf: url).prefix(32) | |
| 99 | + #expect(salt1 == salt2) | |
| 100 | + #expect(try store.loadKeys() == ["a": "1", "b": "2"]) | |
| 101 | + } | |
| 102 | + | |
| 103 | + @Test func redactionShowsOnlyLastFour() { | |
| 104 | + #expect(SecureKeyStore.redacted("sk-proj-abcdefgh1234") == "••••1234") | |
| 105 | + } | |
| 106 | +} | |
modified
docs/PLAN.md
+26 −1
@@ -51,7 +51,32 @@ from terminal, activates, and quits cleanly. Test target scaffolded and green. | ||
| 51 | 51 | Family conventions ported from Zyquo Cloud's Makefile/write-info-plist.sh, incl. |
| 52 | 52 | the Developer ID identity + notary profile constants for Phase 8. |
| 53 | 53 | |
| 54 | −## Phase 2 — Architecture + server skeleton — pending | |
| 54 | +## Phase 2 — Architecture + server skeleton | |
| 55 | + | |
| 56 | +- [x] 2.1 Port from Zyquo Cloud (headers → `Zyquo Router`, type names kept): `ProviderID`, `AIModel`, `Message`+`ChatParameters`, `ProviderProtocol`, `ProviderRegistry`, `OpenAICompatibleClient`, `AnthropicClient`, `StreamingService`, `SecureKeyStore`, `ModelCatalog`, `ModelCatalogData` (170 models, byte-identical), `PersistenceService`, SSE + vault tests | |
| 57 | +- [x] 2.2 Vault decision recorded: own vault at `~/Library/Application Support/ZyquoRouter/vault.zq`, HKDF info `"ZyquoRouter.vault.v1"`; User-Agent `ZyquoRouter/1.0` | |
| 58 | +- [x] 2.3 `Server/`: `HTTPServer` (NIOAsyncChannel bootstrap, bind host/port, EADDRINUSE detection, graceful shutdown), `Routes` (`GET /health`, `GET /v1/models`), `SSEWriter` skeleton, `CORS`, `AuthMiddleware` skeleton | |
| 59 | +- [x] 2.4 `Router/`: `RequestRouter` (namespaced `provider/model` + unambiguous bare ID resolution; alias/fallback hooks), `RetryPolicy` + `UsageMeter` stubs | |
| 60 | +- [x] 2.5 App owns server lifecycle (start/stop; placeholder UI button + port field) | |
| 61 | +- [x] 2.6 Phase gate: server starts on chosen port; `/health` ok; `/v1/models` returns full namespaced catalog (spec shape); port-in-use → clean error; graceful stop drains | |
| 62 | +- [x] 2.7 `swift build` zero warnings; headers sweep | |
| 63 | + | |
| 64 | +**Phase gate: PASSED (2026-07-30).** | |
| 65 | + | |
| 66 | +**Phase 2 summary:** Provider layer ported from Zyquo Cloud (12 providers, 170-model | |
| 67 | +catalog byte-identical, SecureKeyStore with own vault `ZyquoRouter/vault.zq` + HKDF info | |
| 68 | +`ZyquoRouter.vault.v1`, StreamingService with `ZyquoRouter/1.0` UA); Cloud's SSE + vault | |
| 69 | +tests ported and green. New: `HTTPServer` on NIOAsyncChannel structured concurrency | |
| 70 | +(keep-alive, 32 MB body cap, EADDRINUSE → "Port N in use — try N+1"), `Routes` | |
| 71 | +(/health, /v1/models, /v1/models/{id} incl. IDs containing "/"), `SSEWriter`, | |
| 72 | +`CORS` (preflight 204), `AuthMiddleware` (hashed `zyquo-sk-…` gate, open when no keys), | |
| 73 | +`RequestRouter` (namespaced/bare/alias/disabled resolution), `RetryPolicy`, `UsageMeter`, | |
| 74 | +`APIKeyRecord`, `ServerController` + minimal Start/Stop UI, and a headless | |
| 75 | +`--serve [port]` CLI mode for scripted verification. Gate verified live with curl | |
| 76 | +(170 models, spec shapes, 404s, CORS) and by integration tests (graceful stop rebinds | |
| 77 | +immediately; port-in-use typed error). 23 tests green; zero warnings; headers swept. | |
| 78 | + | |
| 79 | + | |
| 55 | 80 | ## Phase 3 — Standardized API (the core) — pending |
| 56 | 81 | ## Phase 4 — Design system & UI spec — pending |
| 57 | 82 | ## Phase 5 — App icon — pending |
| 58 | 83 | |