SPB Git

spb/zyquo-cloud Public MIT

Native macOS AI chat client for 12 cloud providers — your keys, every cloud model, one beautiful chat.

Swift 97.4% Shell 1.7% Makefile 1%

phase2: models, provider clients (OpenAI-compat + Anthropic native), SSE streaming, persistence, catalog skeleton, 10 unit tests

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

Showing 18 changed files with +1,841 and −23

modified Makefile +1 −1
@@ -36,7 +36,7 @@ build:
36 36 swift build -c release
37 37
38 38 test:
39 swift test
39 + scripts/test.sh
40 40
41 41 run: dev
42 42 open "$(APP_DIR)"
added Sources/ZyquoCloud/Models/AIModel.swift +108 −0
@@ -0,0 +1,108 @@
1 +//
2 +// AIModel.swift
3 +// Zyquo Cloud
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 +
89 + static let openAIDefault = ParameterSupport(frequencyPenalty: true, presencePenalty: true)
90 +}
91 +
92 +/// Token usage reported by a provider for one exchange.
93 +struct TokenUsage: Codable, Hashable {
94 + var inputTokens: Int = 0
95 + var outputTokens: Int = 0
96 + var reasoningTokens: Int? = nil
97 +
98 + var totalTokens: Int { inputTokens + outputTokens }
99 +
100 + static func + (lhs: TokenUsage, rhs: TokenUsage) -> TokenUsage {
101 + TokenUsage(
102 + inputTokens: lhs.inputTokens + rhs.inputTokens,
103 + outputTokens: lhs.outputTokens + rhs.outputTokens,
104 + reasoningTokens: (lhs.reasoningTokens ?? 0) + (rhs.reasoningTokens ?? 0) == 0
105 + ? nil : (lhs.reasoningTokens ?? 0) + (rhs.reasoningTokens ?? 0)
106 + )
107 + }
108 +}
added Sources/ZyquoCloud/Models/Conversation.swift +126 −0
@@ -0,0 +1,126 @@
1 +//
2 +// Conversation.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import Foundation
10 +
11 +/// A chat thread. Persisted as one JSON file per conversation in
12 +/// `~/Library/Application Support/ZyquoCloud/Conversations/`.
13 +struct Conversation: Codable, Identifiable, Hashable {
14 + let id: UUID
15 + var title: String
16 + var messages: [Message]
17 + /// Current model selection (changeable mid-conversation and per message).
18 + var modelID: String
19 + var provider: ProviderID
20 + var systemPrompt: String?
21 + var parameters: ChatParameters
22 + var personaID: UUID?
23 + var isPinned: Bool = false
24 + var folder: String?
25 + var tags: [String] = []
26 + var createdAt: Date
27 + var updatedAt: Date
28 + /// True until the first exchange completes and a title is auto-generated.
29 + var hasAutoTitle: Bool = true
30 +
31 + init(
32 + id: UUID = UUID(),
33 + title: String = "New Chat",
34 + messages: [Message] = [],
35 + modelID: String,
36 + provider: ProviderID,
37 + systemPrompt: String? = nil,
38 + parameters: ChatParameters = ChatParameters(),
39 + personaID: UUID? = nil,
40 + createdAt: Date = Date(),
41 + updatedAt: Date = Date()
42 + ) {
43 + self.id = id
44 + self.title = title
45 + self.messages = messages
46 + self.modelID = modelID
47 + self.provider = provider
48 + self.systemPrompt = systemPrompt
49 + self.parameters = parameters
50 + self.personaID = personaID
51 + self.createdAt = createdAt
52 + self.updatedAt = updatedAt
53 + }
54 +
55 + /// Total usage across all assistant messages.
56 + var totalUsage: TokenUsage {
57 + messages.compactMap(\.usage).reduce(TokenUsage(), +)
58 + }
59 +
60 + /// Total estimated cost in USD.
61 + var totalCost: Double {
62 + messages.compactMap(\.estimatedCost).reduce(0, +)
63 + }
64 +}
65 +
66 +/// Per-conversation generation parameters. `nil` means "provider default" and
67 +/// the parameter is omitted from the request entirely.
68 +struct ChatParameters: Codable, Hashable {
69 + var temperature: Double?
70 + var topP: Double?
71 + var maxTokens: Int?
72 + var frequencyPenalty: Double?
73 + var presencePenalty: Double?
74 + /// "low" / "medium" / "high" for models supporting reasoning_effort.
75 + var reasoningEffort: String?
76 + /// Explicit thinking toggle for Anthropic/Qwen-style models.
77 + var thinkingEnabled: Bool?
78 +}
79 +
80 +/// A reusable configuration: system prompt + preferred model + parameters.
81 +struct Persona: Codable, Identifiable, Hashable {
82 + let id: UUID
83 + var name: String
84 + /// SF Symbol name for the persona glyph.
85 + var symbolName: String
86 + var systemPrompt: String
87 + var modelID: String?
88 + var provider: ProviderID?
89 + var parameters: ChatParameters
90 +
91 + init(
92 + id: UUID = UUID(),
93 + name: String,
94 + symbolName: String = "person.crop.circle",
95 + systemPrompt: String,
96 + modelID: String? = nil,
97 + provider: ProviderID? = nil,
98 + parameters: ChatParameters = ChatParameters()
99 + ) {
100 + self.id = id
101 + self.name = name
102 + self.symbolName = symbolName
103 + self.systemPrompt = systemPrompt
104 + self.modelID = modelID
105 + self.provider = provider
106 + self.parameters = parameters
107 + }
108 +}
109 +
110 +/// A prompt template from the built-in library or created by the user.
111 +/// `{{input}}` in the body is replaced with the user's text.
112 +struct PromptTemplate: Codable, Identifiable, Hashable {
113 + let id: UUID
114 + var title: String
115 + var category: String
116 + var body: String
117 + var isBuiltIn: Bool
118 +
119 + init(id: UUID = UUID(), title: String, category: String, body: String, isBuiltIn: Bool = false) {
120 + self.id = id
121 + self.title = title
122 + self.category = category
123 + self.body = body
124 + self.isBuiltIn = isBuiltIn
125 + }
126 +}
added Sources/ZyquoCloud/Models/Message.swift +105 −0
@@ -0,0 +1,105 @@
1 +//
2 +// Message.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import Foundation
10 +
11 +/// One turn in a conversation transcript.
12 +struct Message: Codable, Identifiable, Hashable {
13 + enum Role: String, Codable {
14 + case system
15 + case user
16 + case assistant
17 + }
18 +
19 + let id: UUID
20 + var role: Role
21 + var text: String
22 + /// Reasoning/thinking text streamed by reasoning models (collapsible in the UI).
23 + var reasoning: String?
24 + /// Image attachments (user messages, vision models).
25 + var attachments: [Attachment]
26 + /// Web-search citations (Perplexity).
27 + var citations: [Citation]
28 + /// Model that produced this message (assistant turns) or was targeted (user turns).
29 + var modelID: String?
30 + var provider: ProviderID?
31 + var usage: TokenUsage?
32 + /// Estimated USD cost computed from catalog pricing at receive time.
33 + var estimatedCost: Double?
34 + var createdAt: Date
35 + /// Set while a response is streaming; exactly one message can be streaming at a time.
36 + var isStreaming: Bool = false
37 + /// Human-readable error if generation failed mid-message.
38 + var errorText: String?
39 +
40 + init(
41 + id: UUID = UUID(),
42 + role: Role,
43 + text: String,
44 + reasoning: String? = nil,
45 + attachments: [Attachment] = [],
46 + citations: [Citation] = [],
47 + modelID: String? = nil,
48 + provider: ProviderID? = nil,
49 + usage: TokenUsage? = nil,
50 + estimatedCost: Double? = nil,
51 + createdAt: Date = Date()
52 + ) {
53 + self.id = id
54 + self.role = role
55 + self.text = text
56 + self.reasoning = reasoning
57 + self.attachments = attachments
58 + self.citations = citations
59 + self.modelID = modelID
60 + self.provider = provider
61 + self.usage = usage
62 + self.estimatedCost = estimatedCost
63 + self.createdAt = createdAt
64 + }
65 +}
66 +
67 +/// A file attached to a user message. Images go to vision models as base64;
68 +/// text files are injected into the prompt.
69 +struct Attachment: Codable, Identifiable, Hashable {
70 + enum Kind: String, Codable {
71 + case image
72 + case textFile
73 + }
74 +
75 + let id: UUID
76 + var kind: Kind
77 + var fileName: String
78 + /// image: raw image bytes; textFile: UTF-8 contents.
79 + var data: Data
80 + /// MIME type for images (image/png, image/jpeg, image/webp, image/gif).
81 + var mimeType: String
82 +
83 + init(id: UUID = UUID(), kind: Kind, fileName: String, data: Data, mimeType: String) {
84 + self.id = id
85 + self.kind = kind
86 + self.fileName = fileName
87 + self.data = data
88 + self.mimeType = mimeType
89 + }
90 +}
91 +
92 +/// A numbered web source backing an assistant answer (Perplexity sonar family).
93 +struct Citation: Codable, Identifiable, Hashable {
94 + let id: UUID
95 + var index: Int
96 + var url: URL
97 + var title: String?
98 +
99 + init(id: UUID = UUID(), index: Int, url: URL, title: String? = nil) {
100 + self.id = id
101 + self.index = index
102 + self.url = url
103 + self.title = title
104 + }
105 +}
added Sources/ZyquoCloud/Models/ProviderID.swift +93 −0
@@ -0,0 +1,93 @@
1 +//
2 +// ProviderID.swift
3 +// Zyquo Cloud
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/ZyquoCloud/Providers/AnthropicClient.swift +300 −0
@@ -0,0 +1,300 @@
1 +//
2 +// AnthropicClient.swift
3 +// Zyquo Cloud
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/ZyquoCloud/Providers/OpenAICompatibleClient.swift +383 −0
@@ -0,0 +1,383 @@
1 +//
2 +// OpenAICompatibleClient.swift
3 +// Zyquo Cloud
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 + var finishReason: String?
123 +
124 + enum CodingKeys: String, CodingKey {
125 + case delta, message
126 + case finishReason = "finish_reason"
127 + }
128 + }
129 +
130 + private struct WireDelta: Decodable {
131 + var content: String?
132 + var reasoningContent: String?
133 + var reasoning: String?
134 +
135 + enum CodingKeys: String, CodingKey {
136 + case content, reasoning
137 + case reasoningContent = "reasoning_content"
138 + }
139 + }
140 +
141 + private struct WireUsage: Decodable {
142 + var promptTokens: Int?
143 + var completionTokens: Int?
144 + var completionTokensDetails: Details?
145 +
146 + struct Details: Decodable {
147 + var reasoningTokens: Int?
148 + enum CodingKeys: String, CodingKey { case reasoningTokens = "reasoning_tokens" }
149 + }
150 +
151 + enum CodingKeys: String, CodingKey {
152 + case promptTokens = "prompt_tokens"
153 + case completionTokens = "completion_tokens"
154 + case completionTokensDetails = "completion_tokens_details"
155 + }
156 +
157 + var usage: TokenUsage {
158 + TokenUsage(
159 + inputTokens: promptTokens ?? 0,
160 + outputTokens: completionTokens ?? 0,
161 + reasoningTokens: completionTokensDetails?.reasoningTokens
162 + )
163 + }
164 + }
165 +
166 + private struct WireSearchResult: Decodable {
167 + var title: String?
168 + var url: String?
169 + }
170 +
171 + private struct WireModelList: Decodable {
172 + var data: [WireModelEntry]
173 + }
174 +
175 + private struct WireModelEntry: Decodable {
176 + var id: String
177 + }
178 +
179 + // MARK: - Request construction
180 +
181 + private var baseURL: URL? { baseURLOverride ?? providerID.defaultBaseURL }
182 +
183 + private func urlRequest(path: String, apiKey: String, method: String = "POST") throws -> URLRequest {
184 + guard let base = baseURL else {
185 + throw ProviderError.invalidResponse(providerID, detail: "no base URL configured")
186 + }
187 + // Preserve base path components ("…/v1", "…/compatible-mode/v1", "…/v1beta/openai").
188 + var request = URLRequest(url: base.appendingPathComponent(path))
189 + request.httpMethod = method
190 + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
191 + if method == "POST" {
192 + request.setValue("application/json", forHTTPHeaderField: "Content-Type")
193 + }
194 + return request
195 + }
196 +
197 + /// Providers whose final streamed chunk carries usage only when asked.
198 + private var wantsStreamOptions: Bool {
199 + switch providerID {
200 + case .openai, .xai, .gemini, .deepseek, .kimi, .together, .cerebras, .custom:
201 + return true
202 + // Qwen, DeepInfra, Perplexity include usage automatically; Mistral
203 + // rejects unknown params less gracefully — omit there.
204 + case .mistral, .qwen, .deepinfra, .perplexity:
205 + return false
206 + case .anthropic:
207 + return false // never routed here
208 + }
209 + }
210 +
211 + private func buildBody(_ request: ChatRequest) throws -> Data {
212 + var messages: [WireMessage] = []
213 + if let system = request.systemPrompt, !system.isEmpty {
214 + messages.append(WireMessage(role: "system", content: .text(system)))
215 + }
216 + for message in request.messages where message.role != .system {
217 + messages.append(wireMessage(from: message, vision: request.model.capabilities.vision))
218 + }
219 +
220 + let support = request.model.parameterSupport
221 + let params = request.parameters
222 + var wire = WireRequest(model: request.model.id, messages: messages)
223 + if request.stream {
224 + wire.stream = true
225 + if wantsStreamOptions {
226 + wire.streamOptions = StreamOptions(includeUsage: true)
227 + }
228 + }
229 + if support.temperature { wire.temperature = params.temperature }
230 + if support.topP { wire.topP = params.topP }
231 + if let max = params.maxTokens {
232 + if support.usesMaxCompletionTokens {
233 + wire.maxCompletionTokens = max
234 + } else {
235 + wire.maxTokens = max
236 + }
237 + }
238 + if support.frequencyPenalty { wire.frequencyPenalty = params.frequencyPenalty }
239 + if support.presencePenalty { wire.presencePenalty = params.presencePenalty }
240 + if support.reasoningEffort { wire.reasoningEffort = params.reasoningEffort }
241 + if support.thinkingToggle, providerID == .qwen {
242 + // DashScope: enable_thinking is only legal on streaming requests.
243 + if request.stream { wire.enableThinking = params.thinkingEnabled }
244 + }
245 + let encoder = JSONEncoder()
246 + return try encoder.encode(wire)
247 + }
248 +
249 + private func wireMessage(from message: Message, vision: Bool) -> WireMessage {
250 + let role = message.role == .assistant ? "assistant" : "user"
251 + var text = message.text
252 + // Text-file attachments are injected inline, fenced with the file name.
253 + for attachment in message.attachments where attachment.kind == .textFile {
254 + let contents = String(data: attachment.data, encoding: .utf8) ?? ""
255 + text += "\n\n```\(attachment.fileName)\n\(contents)\n```"
256 + }
257 + let images = message.attachments.filter { $0.kind == .image }
258 + guard vision, !images.isEmpty, message.role == .user else {
259 + return WireMessage(role: role, content: .text(text))
260 + }
261 + var parts: [WirePart] = [.text(text)]
262 + for image in images {
263 + let dataURI = "data:\(image.mimeType);base64,\(image.data.base64EncodedString())"
264 + parts.append(.imageURL(dataURI))
265 + }
266 + return WireMessage(role: role, content: .parts(parts))
267 + }
268 +
269 + // MARK: - ProviderClient
270 +
271 + func streamChat(_ request: ChatRequest, apiKey: String) -> AsyncThrowingStream<ChatEvent, Error> {
272 + AsyncThrowingStream { continuation in
273 + let task = Task {
274 + do {
275 + var urlReq = try urlRequest(path: "chat/completions", apiKey: apiKey)
276 + var streamRequest = request
277 + streamRequest.stream = true
278 + urlReq.httpBody = try buildBody(streamRequest)
279 +
280 + var citationsSent = false
281 + var finishReason: String?
282 + let decoder = JSONDecoder()
283 +
284 + for try await event in StreamingService.sseEvents(for: urlReq, provider: providerID) {
285 + if event.data == "[DONE]" { break }
286 + guard let data = event.data.data(using: .utf8),
287 + let chunk = try? decoder.decode(WireChunk.self, from: data) else {
288 + continue // tolerate unknown/malformed keep-alive chunks
289 + }
290 + if let choice = chunk.choices?.first {
291 + if let reasoning = choice.delta?.reasoningContent ?? choice.delta?.reasoning,
292 + !reasoning.isEmpty {
293 + continuation.yield(.reasoningDelta(reasoning))
294 + }
295 + if let text = choice.delta?.content, !text.isEmpty {
296 + continuation.yield(.textDelta(text))
297 + }
298 + if let reason = choice.finishReason {
299 + finishReason = reason
300 + }
301 + }
302 + if !citationsSent, let citations = Self.citations(from: chunk), !citations.isEmpty {
303 + citationsSent = true
304 + continuation.yield(.citations(citations))
305 + }
306 + if let usage = chunk.usage {
307 + continuation.yield(.usage(usage.usage))
308 + }
309 + }
310 + continuation.yield(.finished(reason: finishReason))
311 + continuation.finish()
312 + } catch {
313 + continuation.finish(throwing: error)
314 + }
315 + }
316 + continuation.onTermination = { _ in task.cancel() }
317 + }
318 + }
319 +
320 + func complete(_ request: ChatRequest, apiKey: String) async throws -> Message {
321 + var urlReq = try urlRequest(path: "chat/completions", apiKey: apiKey)
322 + var plainRequest = request
323 + plainRequest.stream = false
324 + urlReq.httpBody = try buildBody(plainRequest)
325 + let data = try await StreamingService.postJSON(urlReq, provider: providerID)
326 + let chunk = try decodeOrThrow(WireChunk.self, from: data)
327 + guard let choice = chunk.choices?.first, let content = choice.message ?? choice.delta else {
328 + throw ProviderError.invalidResponse(providerID, detail: "response contained no message")
329 + }
330 + var message = Message(
331 + role: .assistant,
332 + text: content.content ?? "",
333 + reasoning: content.reasoningContent ?? content.reasoning,
334 + modelID: request.model.id,
335 + provider: providerID
336 + )
337 + if let citations = Self.citations(from: chunk) {
338 + message.citations = citations
339 + }
340 + if let usage = chunk.usage?.usage {
341 + message.usage = usage
342 + message.estimatedCost = request.model.pricing?.cost(
343 + inputTokens: usage.inputTokens, outputTokens: usage.outputTokens
344 + )
345 + }
346 + return message
347 + }
348 +
349 + func listModelIDs(apiKey: String) async throws -> [String] {
350 + let urlReq = try urlRequest(path: "models", apiKey: apiKey, method: "GET")
351 + let data = try await StreamingService.getJSON(urlReq, provider: providerID)
352 + // Together returns a bare array; everyone else wraps in {"data": […]}.
353 + if let list = try? JSONDecoder().decode(WireModelList.self, from: data) {
354 + return list.data.map(\.id)
355 + }
356 + if let bare = try? JSONDecoder().decode([WireModelEntry].self, from: data) {
357 + return bare.map(\.id)
358 + }
359 + throw ProviderError.invalidResponse(providerID, detail: "unrecognized /models response shape")
360 + }
361 +
362 + // MARK: - Helpers
363 +
364 + private func decodeOrThrow<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
365 + do {
366 + return try JSONDecoder().decode(type, from: data)
367 + } catch {
368 + throw ProviderError.invalidResponse(providerID, detail: "decode failed: \(error.localizedDescription)")
369 + }
370 + }
371 +
372 + /// Perplexity: `citations` is an array of URL strings; `search_results`
373 + /// adds titles. Merge both into numbered citations.
374 + private static func citations(from chunk: WireChunk) -> [Citation]? {
375 + guard let urls = chunk.citations, !urls.isEmpty else { return nil }
376 + let titles = chunk.searchResults ?? []
377 + return urls.enumerated().compactMap { index, urlString in
378 + guard let url = URL(string: urlString) else { return nil }
379 + let title = index < titles.count ? titles[index].title : nil
380 + return Citation(index: index + 1, url: url, title: title)
381 + }
382 + }
383 +}
added Sources/ZyquoCloud/Providers/ProviderProtocol.swift +146 −0
@@ -0,0 +1,146 @@
1 +//
2 +// ProviderProtocol.swift
3 +// Zyquo Cloud
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/ZyquoCloud/Providers/ProviderRegistry.swift +34 −0
@@ -0,0 +1,34 @@
1 +//
2 +// ProviderRegistry.swift
3 +// Zyquo Cloud
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/ZyquoCloud/Services/ModelCatalog.swift +70 −0
@@ -0,0 +1,70 @@
1 +//
2 +// ModelCatalog.swift
3 +// Zyquo Cloud
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).
40 + func cheapestModel(for provider: ProviderID) -> AIModel? {
41 + models(for: provider)
42 + .filter { !$0.isLegacy }
43 + .min { ($0.pricing?.outputPerMTok ?? .infinity) < ($1.pricing?.outputPerMTok ?? .infinity) }
44 + }
45 +
46 + /// Default model offered for new conversations.
47 + var defaultModel: AIModel? {
48 + all.first { $0.isRecommended } ?? all.first
49 + }
50 +
51 + /// Merges a dynamic /models listing: known models are marked live; unknown
52 + /// IDs are surfaced so the user can add them.
53 + func applyLiveListing(_ ids: [String], for provider: ProviderID) {
54 + liveModelIDs[provider] = Set(ids)
55 + }
56 +
57 + /// IDs returned by the provider but absent from the built-in catalog.
58 + func unknownLiveIDs(for provider: ProviderID) -> [String] {
59 + guard let live = liveModelIDs[provider] else { return [] }
60 + let known = Set(models(for: provider).map(\.id))
61 + return live.subtracting(known).sorted()
62 + }
63 +
64 + private func rank(_ model: AIModel) -> Int {
65 + if favoriteIDs.contains(model.id) { return 0 }
66 + if model.isRecommended { return 1 }
67 + if model.isLegacy { return 3 }
68 + return 2
69 + }
70 +}
added Sources/ZyquoCloud/Services/ModelCatalogData.swift +41 −0
@@ -0,0 +1,41 @@
1 +//
2 +// ModelCatalogData.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Built-in model catalog, generated from docs/PROVIDERS.md (2026-07-30).
9 +// PLACEHOLDER seed — replaced by the full generated catalog before Phase 6.
10 +// When research and reality disagree in Phase 7, update docs/PROVIDERS.md and
11 +// this file together.
12 +//
13 +
14 +import Foundation
15 +
16 +enum ModelCatalogData {
17 + static let all: [AIModel] = [
18 + AIModel(
19 + id: "gpt-4o-mini",
20 + provider: .openai,
21 + displayName: "GPT-4o mini",
22 + contextWindow: 128_000,
23 + maxOutputTokens: 16_384,
24 + capabilities: ModelCapabilities(vision: true, tools: true, jsonMode: true),
25 + pricing: ModelPricing(inputPerMTok: 0.15, outputPerMTok: 0.60),
26 + parameterSupport: .openAIDefault,
27 + isRecommended: true
28 + ),
29 + AIModel(
30 + id: "claude-sonnet-5",
31 + provider: .anthropic,
32 + displayName: "Claude Sonnet 5",
33 + contextWindow: 1_000_000,
34 + maxOutputTokens: 128_000,
35 + capabilities: ModelCapabilities(vision: true, tools: true, reasoning: true, jsonMode: true),
36 + pricing: ModelPricing(inputPerMTok: 2, outputPerMTok: 10),
37 + parameterSupport: ParameterSupport(temperature: false, topP: false, thinkingToggle: true),
38 + isRecommended: true
39 + ),
40 + ]
41 +}
added Sources/ZyquoCloud/Services/PersistenceService.swift +74 −0
@@ -0,0 +1,74 @@
1 +//
2 +// PersistenceService.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// JSON persistence in ~/Library/Application Support/ZyquoCloud/:
9 +// Conversations/<uuid>.json one file per conversation
10 +// personas.json, templates.json, favorites.json, settings.json
11 +//
12 +
13 +import Foundation
14 +
15 +struct PersistenceService {
16 + static let shared = PersistenceService()
17 +
18 + let rootDirectory: URL
19 + var conversationsDirectory: URL { rootDirectory.appendingPathComponent("Conversations") }
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("ZyquoCloud")
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: conversationsDirectory, withIntermediateDirectories: true)
33 + }
34 +
35 + // MARK: - Conversations
36 +
37 + func loadConversations() -> [Conversation] {
38 + guard let files = try? FileManager.default.contentsOfDirectory(
39 + at: conversationsDirectory, includingPropertiesForKeys: nil
40 + ) else { return [] }
41 + return files
42 + .filter { $0.pathExtension == "json" }
43 + .compactMap { url in
44 + guard let data = try? Data(contentsOf: url) else { return nil }
45 + return try? decoder.decode(Conversation.self, from: data)
46 + }
47 + .sorted { $0.updatedAt > $1.updatedAt }
48 + }
49 +
50 + func save(_ conversation: Conversation) {
51 + let url = conversationsDirectory.appendingPathComponent("\(conversation.id.uuidString).json")
52 + guard let data = try? encoder.encode(conversation) else { return }
53 + try? data.write(to: url, options: .atomic)
54 + }
55 +
56 + func delete(_ conversation: Conversation) {
57 + let url = conversationsDirectory.appendingPathComponent("\(conversation.id.uuidString).json")
58 + try? FileManager.default.removeItem(at: url)
59 + }
60 +
61 + // MARK: - Generic documents (personas, templates, settings…)
62 +
63 + func load<T: Decodable>(_ type: T.Type, from fileName: String) -> T? {
64 + let url = rootDirectory.appendingPathComponent(fileName)
65 + guard let data = try? Data(contentsOf: url) else { return nil }
66 + return try? decoder.decode(type, from: data)
67 + }
68 +
69 + func save<T: Encodable>(_ value: T, to fileName: String) {
70 + let url = rootDirectory.appendingPathComponent(fileName)
71 + guard let data = try? encoder.encode(value) else { return }
72 + try? data.write(to: url, options: .atomic)
73 + }
74 +}
added Sources/ZyquoCloud/Services/StreamingService.swift +133 −0
@@ -0,0 +1,133 @@
1 +//
2 +// StreamingService.swift
3 +// Zyquo Cloud
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": "ZyquoCloud/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 + var parser = SSEParser()
80 + for try await line in bytes.lines {
81 + if Task.isCancelled { break }
82 + if let event = parser.consume(line: line) {
83 + continuation.yield(event)
84 + }
85 + }
86 + // Flush a trailing event if the stream ended without a blank line.
87 + if let event = parser.consume(line: "") {
88 + continuation.yield(event)
89 + }
90 + continuation.finish()
91 + } catch is CancellationError {
92 + continuation.finish(throwing: ProviderError.cancelled)
93 + } catch let error as ProviderError {
94 + continuation.finish(throwing: error)
95 + } catch {
96 + continuation.finish(throwing: ProviderError.networkError(underlying: error))
97 + }
98 + }
99 + continuation.onTermination = { _ in task.cancel() }
100 + }
101 + }
102 +
103 + /// Non-streaming JSON POST. Returns the decoded response body data.
104 + static func postJSON(
105 + _ request: URLRequest,
106 + provider: ProviderID
107 + ) async throws -> Data {
108 + do {
109 + let (data, response) = try await session.data(for: request)
110 + guard let http = response as? HTTPURLResponse else {
111 + throw ProviderError.invalidResponse(provider, detail: "not an HTTP response")
112 + }
113 + guard (200..<300).contains(http.statusCode) else {
114 + throw ProviderError.from(status: http.statusCode, body: data, provider: provider)
115 + }
116 + return data
117 + } catch let error as ProviderError {
118 + throw error
119 + } catch is CancellationError {
120 + throw ProviderError.cancelled
121 + } catch {
122 + throw ProviderError.networkError(underlying: error)
123 + }
124 + }
125 +
126 + /// GET returning decoded JSON data, with the same error mapping.
127 + static func getJSON(
128 + _ request: URLRequest,
129 + provider: ProviderID
130 + ) async throws -> Data {
131 + try await postJSON(request, provider: provider)
132 + }
133 +}
added Tests/ZyquoCloudTests/ModelTests.swift +87 −0
@@ -0,0 +1,87 @@
1 +//
2 +// ModelTests.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import Foundation
10 +import Testing
11 +@testable import ZyquoCloud
12 +
13 +@Suite struct ModelTests {
14 + @Test func conversationJSONRoundTrip() throws {
15 + // ISO8601 persistence has whole-second precision; use round dates.
16 + let date = Date(timeIntervalSince1970: 1_785_400_000)
17 + var message = Message(role: .user, text: "Hello", createdAt: date)
18 + message.attachments = [
19 + Attachment(kind: .image, fileName: "pic.png", data: Data([1, 2, 3]), mimeType: "image/png")
20 + ]
21 + var reply = Message(role: .assistant, text: "Hi!", reasoning: "thinking…", createdAt: date)
22 + reply.usage = TokenUsage(inputTokens: 10, outputTokens: 5)
23 + reply.citations = [Citation(index: 1, url: URL(string: "https://example.com")!, title: "Example")]
24 +
25 + let conversation = Conversation(
26 + title: "Test",
27 + messages: [message, reply],
28 + modelID: "gpt-4o-mini",
29 + provider: .openai,
30 + systemPrompt: "Be brief.",
31 + createdAt: date,
32 + updatedAt: date
33 + )
34 +
35 + let encoder = JSONEncoder()
36 + encoder.dateEncodingStrategy = .iso8601
37 + let decoder = JSONDecoder()
38 + decoder.dateDecodingStrategy = .iso8601
39 + let data = try encoder.encode(conversation)
40 + let decoded = try decoder.decode(Conversation.self, from: data)
41 +
42 + #expect(decoded == conversation)
43 + #expect(decoded.totalUsage.totalTokens == 15)
44 + }
45 +
46 + @Test func pricingCost() {
47 + let pricing = ModelPricing(inputPerMTok: 2, outputPerMTok: 10)
48 + #expect(abs(pricing.cost(inputTokens: 1_000_000, outputTokens: 500_000) - 7.0) < 0.0001)
49 + }
50 +
51 + @Test func contextBadge() {
52 + func model(_ ctx: Int) -> AIModel {
53 + AIModel(
54 + id: "m", provider: .openai, displayName: "M", contextWindow: ctx,
55 + maxOutputTokens: nil, capabilities: ModelCapabilities(),
56 + pricing: nil, parameterSupport: ParameterSupport()
57 + )
58 + }
59 + #expect(model(1_000_000).contextBadge == "1M ctx")
60 + #expect(model(128_000).contextBadge == "128K ctx")
61 + }
62 +
63 + @Test func providerErrorMapping() {
64 + let unauthorized = ProviderError.from(status: 401, body: Data(), provider: .mistral)
65 + guard case .invalidAPIKey(let provider) = unauthorized else {
66 + Issue.record("expected invalidAPIKey")
67 + return
68 + }
69 + #expect(provider == .mistral)
70 +
71 + let body = #"{"error":{"message":"model not found"}}"#.data(using: .utf8)!
72 + let notFound = ProviderError.from(status: 404, body: body, provider: .openai)
73 + guard case .badRequest(_, let message) = notFound else {
74 + Issue.record("expected badRequest")
75 + return
76 + }
77 + #expect(message == "model not found")
78 + }
79 +
80 + @Test func everyBuiltInProviderHasBaseURLAndFormat() {
81 + for provider in ProviderID.builtIn {
82 + #expect(provider.defaultBaseURL != nil, "\(provider) missing base URL")
83 + }
84 + #expect(ProviderID.anthropic.wireFormat == .anthropicMessages)
85 + #expect(ProviderID.builtIn.count == 12)
86 + }
87 +}
added Tests/ZyquoCloudTests/SSEParserTests.swift +85 −0
@@ -0,0 +1,85 @@
1 +//
2 +// SSEParserTests.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import Testing
10 +@testable import ZyquoCloud
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 +}
deleted Tests/ZyquoCloudTests/ZyquoCloudTests.swift +0 −15
@@ -1,15 +0,0 @@
1 //
2 // ZyquoCloudTests.swift
3 // Zyquo Cloud
4 //
5 // Author: Simon-Pierre Boucher
6 // Mail: contact@spboucher.ai
7 //
8
9 import XCTest
10
11 final class ZyquoCloudTests: XCTestCase {
12 func testPlaceholder() {
13 XCTAssertTrue(true)
14 }
15 }
modified docs/PLAN.md +21 −7
@@ -30,13 +30,27 @@ ad-hoc-signed `dist/Zyquo Cloud.app` with correct Info.plist (`com.zyquo.cloud`,
30 30 productivity category). Release/notarization targets stubbed to `scripts/notarize.sh` using the
31 31 verified zyquo-term identity + `MacLustr-Notarize` profile (implemented fully in Phase 8).
32 32
33 ## Phase 2 — Architecture (in progress)
34
35 - [ ] Models: `ProviderID`, `AIModel`, `ModelCapabilities`, `ModelPricing`, `ParameterSupport`, `Message`, `Conversation`, `Persona`, `ChatParameters`, `TokenUsage`
36 - [ ] Providers: `ProviderProtocol` (`ChatRequest``AsyncThrowingStream<ChatEvent>`), `OpenAICompatibleClient` (10+ providers incl. Gemini-compat), `AnthropicClient`
37 - [ ] Services: `StreamingService` (SSE parser), `ModelCatalog` (from PROVIDERS.md), `PersistenceService` (JSON in Application Support)
38 - [ ] ViewModels/Views skeleton folders
39 - [ ] Phase checkpoint: builds clean, unit tests for SSE parser + models
33 +## Phase 2 — Architecture ✅ (completed 2026-07-30)
34 +
35 +- [x] Models: `ProviderID`, `AIModel`, `ModelCapabilities`, `ModelPricing`, `ParameterSupport`, `Message`, `Conversation`, `Persona`, `PromptTemplate`, `ChatParameters`, `TokenUsage`
36 +- [x] Providers: `ProviderProtocol` (`ChatRequest``AsyncThrowingStream<ChatEvent>`), `OpenAICompatibleClient` (11 providers incl. Gemini-compat + custom), `AnthropicClient` (native Messages API), `ProviderRegistry`
37 +- [x] Services: `StreamingService` (SSE parser + URLSession streaming), `ModelCatalog` (+ seed `ModelCatalogData`, full data generated next), `PersistenceService` (JSON in Application Support)
38 +- [x] ViewModels/Views skeleton folders
39 +- [x] Phase checkpoint: builds clean (0 warnings), 10/10 unit tests pass (SSE parser incl. Anthropic
40 + named events / keep-alive comments / multi-line data; model JSON round-trip; error mapping)
41 +
42 +**Phase 2 checkpoint summary:** Full provider abstraction in place — all quirks (auth headers,
43 +stream_options, max_completion_tokens, enable_thinking, reasoning_content, citations, bare-array
44 +/models) isolated in the two clients. Toolchain note: no Xcode on this machine (CLT only), so tests
45 +use Swift Testing with `scripts/test.sh` wiring the CLT TestingMacros plugin + framework symlinks
46 +(`make test`).
47 +
48 +## Phase 3 — SecureKeyStore (in progress)
49 +
50 +- [ ] AES-256-GCM vault via CryptoKit at `~/Library/Application Support/ZyquoCloud/vault.zq`
51 +- [ ] HKDF master key: random salt + IOPlatformUUID (IOKit) + home path + obfuscated compiled-in pepper
52 +- [ ] Vault format `[salt][nonce][ciphertext+tag]`, plaintext JSON dict of provider→key
53 +- [ ] Round-trip encrypt/decrypt test (phase gate) + tamper test + no-Keychain sweep
40 54 ## Phase 2 — Architecture (pending)
41 55 ## Phase 3 — SecureKeyStore (pending)
42 56 ## Phase 4 — Design System (pending)
added scripts/test.sh +34 −0
@@ -0,0 +1,34 @@
1 +#!/bin/bash
2 +#
3 +# test.sh
4 +# Zyquo Cloud
5 +#
6 +# Author: Simon-Pierre Boucher
7 +# Mail: contact@spboucher.ai
8 +#
9 +# Runs the test suite. Works with Command Line Tools only (no Xcode): CLT's
10 +# Testing.framework isn't on the test bundle's runtime search path, so it is
11 +# symlinked into the build products dir before running.
12 +#
13 +set -euo pipefail
14 +cd "$(dirname "$0")/.."
15 +
16 +CLT="/Library/Developer/CommandLineTools"
17 +CLT_FRAMEWORKS="$CLT/Library/Developer/Frameworks"
18 +TESTING_PLUGINS="$CLT/usr/lib/swift/host/plugins/testing"
19 +
20 +swift build --build-tests -Xswiftc -plugin-path -Xswiftc "$TESTING_PLUGINS"
21 +
22 +for products in .build/out/Products/Debug .build/debug; do
23 + if [ -d "$products" ]; then
24 + mkdir -p "$products/PackageFrameworks"
25 + for fw in "$CLT_FRAMEWORKS"/*.framework; do
26 + name="$(basename "$fw")"
27 + ln -sfn "$fw" "$products/PackageFrameworks/$name"
28 + done
29 + ln -sfn "$CLT/Library/Developer/usr/lib/lib_TestingInterop.dylib" \
30 + "$products/PackageFrameworks/lib_TestingInterop.dylib"
31 + fi
32 +done
33 +
34 +swift test --skip-build "$@"
35