SPB Git

spb/zyquo-router Public MIT

One local endpoint, every AI provider — a private OpenAI-compatible LLM gateway for your Mac (170 models, 12 providers).

Swift 95.7% Python 2.3% Shell 1.2% Makefile 0.9%

phase2: architecture + server skeleton — ported provider layer, NIO server, /health + /v1/models

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 11 days ago (Jul 31, 2026) parent e8c2c79

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