From 115bc16b144f9c48219a5b2300a37266b80b8b80 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Fri, 24 Apr 2026 01:59:21 +0200 Subject: [PATCH] feat: Nous Portal + Tool Gateway support for Hermes v0.10.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes v0.10.0 (v2026.4.16) introduces the Tool Gateway — paid Nous Portal subscribers route web search, image generation, TTS, and browser automation through their subscription without separate API keys. - ModelCatalogService merges HERMES_OVERLAYS on top of the models.dev cache, surfacing 6 overlay-only providers (Nous Portal, OpenAI Codex, Qwen OAuth, Google Gemini CLI, GitHub Copilot ACP, Arcee) that were previously invisible in Scarf's picker. Subscription-gated providers sort first. - NousSubscriptionService reads ~/.hermes/auth.json -> providers.nous to detect subscription state. Read-only; Hermes owns the write path. - ModelPickerSheet renders a "Subscription" pill, auth-type-aware instructions, and free-form model-ID entry for overlay providers (no models.dev catalog for them). - AuxiliaryTab gains a per-task "Nous Portal" toggle that flips auxiliary..provider between "nous" and "auto". Hermes derives gateway routing from provider selection; there's no separate use_gateway key in the source. - HermesConfig + HermesFileService parse platform_toolsets. - HealthViewModel adds a synthetic "Tool Gateway" section showing subscription state, platform_toolsets, and which aux tasks are routed through Nous. - Gateway -> Messaging Gateway rename (sidebar, dashboard card, menu bar, log-source filter, Settings/Agent/Gateway section header) to disambiguate from the new Tool Gateway. - CLAUDE.md bumped to Hermes v0.10.0 (v2026.4.16) with a keep-overlayOnlyProviders-in-sync reminder. - 13 new tests covering overlay merge, subscription detection, and platform_toolsets parsing; full suite (106 tests, 19 suites) green on top of v2.3 projects branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 12 +- scarf/scarf/Core/Models/HermesConfig.swift | 10 + .../Core/Services/HermesFileService.swift | 11 + .../Core/Services/ModelCatalogService.swift | 179 ++++++++++++++-- .../Services/NousSubscriptionService.swift | 85 ++++++++ .../Dashboard/Views/DashboardView.swift | 2 +- .../Features/Gateway/Views/GatewayView.swift | 2 +- .../Health/ViewModels/HealthViewModel.swift | 80 +++++++ .../Logs/ViewModels/LogsViewModel.swift | 4 +- .../Views/Components/ModelPickerSheet.swift | 180 +++++++++++++++- .../Settings/Views/Tabs/AgentTab.swift | 2 +- .../Settings/Views/Tabs/AuxiliaryTab.swift | 32 +++ scarf/scarf/Navigation/AppCoordinator.swift | 2 +- scarf/scarf/scarfApp.swift | 2 +- scarf/scarfTests/ToolGatewayTests.swift | 199 ++++++++++++++++++ 15 files changed, 769 insertions(+), 33 deletions(-) create mode 100644 scarf/scarf/Core/Services/NousSubscriptionService.swift create mode 100644 scarf/scarfTests/ToolGatewayTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index 918bb99..990bf4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,17 @@ Public documentation lives in the GitHub wiki at https://github.com/awizemann/sc ## Hermes Version -Targets Hermes v0.9.0 (v2026.4.13). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse. +Targets Hermes v0.10.0 (v2026.4.16). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse. + +v0.10.0 introduced the **Tool Gateway** — paid Nous Portal subscribers route web search, image generation, TTS, and browser automation through their subscription without separate API keys. In Scarf: + +- **Provider picker** ([ModelCatalogService.swift](scarf/scarf/Core/Services/ModelCatalogService.swift)) merges Hermes's `HERMES_OVERLAYS` so Nous Portal and other overlay-only providers (OpenAI Codex, Qwen OAuth, Google Gemini CLI, GitHub Copilot ACP, Arcee) appear alongside the models.dev catalog. Subscription-gated providers sort first and render a "Subscription" pill. +- **Subscription detection** ([NousSubscriptionService.swift](scarf/scarf/Core/Services/NousSubscriptionService.swift)) reads `~/.hermes/auth.json` → `providers.nous`. Read-only; Hermes owns the write path. +- **Per-task routing** (Auxiliary tab) toggles `auxiliary..provider` between `nous` and `auto`. Hermes derives gateway routing from provider selection — there is no separate `use_gateway` key. +- **Health surface** ([HealthViewModel.swift](scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift)) adds a synthetic "Tool Gateway" section showing subscription state + `platform_toolsets` mappings + which aux tasks are routed through Nous. +- **Scarf's existing `Gateway` feature is renamed to "Messaging Gateway"** everywhere user-facing to disambiguate from the new Tool Gateway. The `SidebarSection.gateway` enum case and `gateway_state.json` / `gateway.log` paths are unchanged (not user-facing strings). + +**Keep `ModelCatalogService.overlayOnlyProviders` in sync** with `HERMES_OVERLAYS` in `~/.hermes/hermes-agent/hermes_cli/providers.py`. When Hermes adds a new overlay-only provider, mirror the entry (display name, base URL, auth type, subscription-gated flag, doc URL) or the picker won't reach it. ## Project Templates diff --git a/scarf/scarf/Core/Models/HermesConfig.swift b/scarf/scarf/Core/Models/HermesConfig.swift index 2a0436a..3e705be 100644 --- a/scarf/scarf/Core/Models/HermesConfig.swift +++ b/scarf/scarf/Core/Models/HermesConfig.swift @@ -339,6 +339,15 @@ struct HermesConfig: Sendable { var prefillMessagesFile: String var skillsExternalDirs: [String] + /// Per-platform toolset allowlists as written by `hermes setup tools` / + /// `hermes tools`. Keyed by platform (`cli`, `slack`, `discord`, …) to a + /// list of enabled toolset identifiers (`browser`, `messaging`, + /// `nous-tools`, …). Hermes v0.10.0 introduced the Tool Gateway; enabling + /// the `nous-tools` toolset here is how subscribers opt-in per-platform. + /// Scarf reads this for display; edits go through `hermes setup tools` + /// rather than direct YAML writes to preserve Hermes-side validation. + var platformToolsets: [String: [String]] + // Grouped blocks var display: DisplaySettings var terminal: TerminalSettings @@ -397,6 +406,7 @@ struct HermesConfig: Sendable { cronWrapResponse: true, prefillMessagesFile: "", skillsExternalDirs: [], + platformToolsets: [:], display: .empty, terminal: .empty, browser: .empty, diff --git a/scarf/scarf/Core/Services/HermesFileService.swift b/scarf/scarf/Core/Services/HermesFileService.swift index cf5e980..193ab9b 100644 --- a/scarf/scarf/Core/Services/HermesFileService.swift +++ b/scarf/scarf/Core/Services/HermesFileService.swift @@ -211,6 +211,16 @@ struct HermesFileService: Sendable { replyPrefix: str("whatsapp.reply_prefix") ) + // `platform_toolsets.` is a dict of lists in config.yaml — + // parseNestedYAML flattens nested lists into dotted-path keys. Pull + // every key under the prefix and strip it. + var platformToolsets: [String: [String]] = [:] + for (key, items) in lists where key.hasPrefix("platform_toolsets.") { + let platform = String(key.dropFirst("platform_toolsets.".count)) + guard !platform.isEmpty else { continue } + platformToolsets[platform] = items + } + // Home Assistant lives under `platforms.homeassistant.extra.*`. let homeAssistant = HomeAssistantSettings( watchDomains: lists["platforms.homeassistant.extra.watch_domains"] ?? [], @@ -258,6 +268,7 @@ struct HermesFileService: Sendable { cronWrapResponse: bool("cron.wrap_response", default: true), prefillMessagesFile: str("prefill_messages_file"), skillsExternalDirs: lists["skills.external_dirs"] ?? [], + platformToolsets: platformToolsets, display: display, terminal: terminal, browser: browser, diff --git a/scarf/scarf/Core/Services/ModelCatalogService.swift b/scarf/scarf/Core/Services/ModelCatalogService.swift index 190aff5..356906f 100644 --- a/scarf/scarf/Core/Services/ModelCatalogService.swift +++ b/scarf/scarf/Core/Services/ModelCatalogService.swift @@ -42,6 +42,15 @@ struct HermesProviderInfo: Sendable, Identifiable, Hashable { let envVars: [String] // e.g. ["ANTHROPIC_API_KEY"] let docURL: String? let modelCount: Int + /// True when this provider is surfaced only by the Hermes overlay list — + /// i.e. it has no entry in `models_dev_cache.json` and therefore no model + /// list from models.dev. The picker renders a different right-column + /// affordance in this case (subscription CTA or free-form model entry). + let isOverlay: Bool + /// True for providers whose tool access is gated on an active subscription + /// rather than a BYO API key. Nous Portal is the only such provider as of + /// hermes-agent v0.10.0. + let subscriptionGated: Bool } /// Reads the models.dev catalog that hermes caches at @@ -67,20 +76,53 @@ struct ModelCatalogService: Sendable { self.transport = LocalTransport() } - /// All providers, sorted by display name. + /// All providers, sorted with subscription-gated providers first (Nous + /// Portal), then alphabetical by display name. + /// + /// Merges two data sources: + /// 1. `~/.hermes/models_dev_cache.json` — the models.dev mirror. + /// 2. ``Self/overlayOnlyProviders`` — Hermes-injected providers that + /// aren't in the models.dev catalog (e.g. Nous Portal, OpenAI Codex). + /// Without this merge, those providers are invisible in Scarf's picker + /// even though `hermes model` on the CLI can reach them. func loadProviders() -> [HermesProviderInfo] { - guard let catalog = loadCatalog() else { return [] } - return catalog - .map { (id, p) in - HermesProviderInfo( - providerID: id, - providerName: p.name ?? id, - envVars: p.env ?? [], - docURL: p.doc, - modelCount: p.models?.count ?? 0 - ) + let catalog = loadCatalog() ?? [:] + var byID: [String: HermesProviderInfo] = [:] + for (id, p) in catalog { + byID[id] = HermesProviderInfo( + providerID: id, + providerName: p.name ?? id, + envVars: p.env ?? [], + docURL: p.doc, + modelCount: p.models?.count ?? 0, + isOverlay: false, + subscriptionGated: false + ) + } + for (id, overlay) in Self.overlayOnlyProviders where byID[id] == nil { + byID[id] = HermesProviderInfo( + providerID: id, + providerName: overlay.displayName, + envVars: [], + docURL: overlay.docURL, + modelCount: 0, + isOverlay: true, + subscriptionGated: overlay.subscriptionGated + ) + } + return byID.values.sorted { lhs, rhs in + if lhs.subscriptionGated != rhs.subscriptionGated { + return lhs.subscriptionGated } - .sorted { $0.providerName.localizedCaseInsensitiveCompare($1.providerName) == .orderedAscending } + return lhs.providerName.localizedCaseInsensitiveCompare(rhs.providerName) == .orderedAscending + } + } + + /// Overlay metadata for a provider that isn't in the models.dev catalog — + /// Scarf needs to surface these so the picker matches `hermes model` on + /// the CLI. + func overlayMetadata(for providerID: String) -> HermesProviderOverlay? { + Self.overlayOnlyProviders[providerID] } /// Models for one provider, sorted by release date (newest first), then name. @@ -123,7 +165,9 @@ struct ModelCatalogService: Sendable { providerName: p.name ?? providerID, envVars: p.env ?? [], docURL: p.doc, - modelCount: p.models?.count ?? 0 + modelCount: p.models?.count ?? 0, + isOverlay: false, + subscriptionGated: false ) } } @@ -137,13 +181,45 @@ struct ModelCatalogService: Sendable { providerName: p.name ?? prefix, envVars: p.env ?? [], docURL: p.doc, - modelCount: p.models?.count ?? 0 + modelCount: p.models?.count ?? 0, + isOverlay: false, + subscriptionGated: false ) } } return nil } + /// Look up a provider by ID, falling back to overlays when the cache has + /// no entry. Use this when resolving a stored `model.provider` to display + /// metadata — `nous` and other overlay-only IDs never appear in the + /// cache, so a plain catalog lookup returns nil for them. + func providerByID(_ providerID: String) -> HermesProviderInfo? { + if let catalog = loadCatalog(), let p = catalog[providerID] { + return HermesProviderInfo( + providerID: providerID, + providerName: p.name ?? providerID, + envVars: p.env ?? [], + docURL: p.doc, + modelCount: p.models?.count ?? 0, + isOverlay: false, + subscriptionGated: false + ) + } + if let overlay = Self.overlayOnlyProviders[providerID] { + return HermesProviderInfo( + providerID: providerID, + providerName: overlay.displayName, + envVars: [], + docURL: overlay.docURL, + modelCount: 0, + isOverlay: true, + subscriptionGated: overlay.subscriptionGated + ) + } + return nil + } + /// Look up a specific model by provider + ID. Returns nil if not in the /// catalog (e.g., free-typed custom model). func model(providerID: String, modelID: String) -> HermesModelInfo? { @@ -207,4 +283,79 @@ struct ModelCatalogService: Sendable { let context: Int? let output: Int? } + + // MARK: - Hermes overlay providers + + /// The six providers Hermes surfaces via `hermes model` that have no + /// entry in `models_dev_cache.json` (models.dev doesn't mirror them). + /// Mirrors the overlay-only subset of `HERMES_OVERLAYS` in + /// `hermes-agent/hermes_cli/providers.py`. The other ~19 overlay entries + /// already ship in the cache and only add augmentation (base-URL + /// override, extra env vars) that Scarf doesn't currently display. + /// + /// Keep this in sync with the Python side on Hermes version bumps. + static let overlayOnlyProviders: [String: HermesProviderOverlay] = [ + "nous": HermesProviderOverlay( + displayName: "Nous Portal", + baseURL: "https://inference-api.nousresearch.com/v1", + authType: .oauthDeviceCode, + subscriptionGated: true, + docURL: "https://hermes-agent.nousresearch.com/docs/user-guide/setup/nous-portal" + ), + "openai-codex": HermesProviderOverlay( + displayName: "OpenAI Codex", + baseURL: "https://chatgpt.com/backend-api/codex", + authType: .oauthExternal, + subscriptionGated: false, + docURL: nil + ), + "qwen-oauth": HermesProviderOverlay( + displayName: "Qwen (OAuth)", + baseURL: "https://portal.qwen.ai/v1", + authType: .oauthExternal, + subscriptionGated: false, + docURL: nil + ), + "google-gemini-cli": HermesProviderOverlay( + displayName: "Google Gemini CLI", + baseURL: "cloudcode-pa://google", + authType: .oauthExternal, + subscriptionGated: false, + docURL: nil + ), + "copilot-acp": HermesProviderOverlay( + displayName: "GitHub Copilot ACP", + baseURL: "acp://copilot", + authType: .externalProcess, + subscriptionGated: false, + docURL: nil + ), + "arcee": HermesProviderOverlay( + displayName: "Arcee", + baseURL: "https://api.arcee.ai/api/v1", + authType: .apiKey, + subscriptionGated: false, + docURL: nil + ), + ] +} + +/// Scarf-side mirror of `HermesOverlay` from hermes-agent's +/// `hermes_cli/providers.py`. Describes a provider that isn't in the +/// models.dev catalog. +struct HermesProviderOverlay: Sendable { + let displayName: String + let baseURL: String? + let authType: AuthType + /// True for providers whose tool access is subscription-gated rather than + /// BYO-API-key. Nous Portal is the only `true` entry today. + let subscriptionGated: Bool + let docURL: String? + + enum AuthType: String, Sendable { + case apiKey + case oauthDeviceCode + case oauthExternal + case externalProcess + } } diff --git a/scarf/scarf/Core/Services/NousSubscriptionService.swift b/scarf/scarf/Core/Services/NousSubscriptionService.swift new file mode 100644 index 0000000..b08ef77 --- /dev/null +++ b/scarf/scarf/Core/Services/NousSubscriptionService.swift @@ -0,0 +1,85 @@ +import Foundation +import os + +/// Snapshot of the user's Nous Portal subscription state, derived from the +/// `providers.nous` entry in `~/.hermes/auth.json`. Read-only — Scarf never +/// writes the subscription record; `hermes model` + `hermes auth` own that +/// path. +struct NousSubscriptionState: Sendable, Hashable { + /// True when `providers.nous` exists and has a usable access token. + /// Mirrors the `nous_auth_present` field on + /// `NousSubscriptionFeatures` in `hermes_cli/nous_subscription.py`. + let present: Bool + /// True when the user's **active provider** is `nous`, i.e. they've not + /// just authed but selected it as the primary model provider. The Tool + /// Gateway only routes tools when this is true — auth alone isn't enough. + let providerIsNous: Bool + /// Last update time for the auth record, if known. Useful in the Health + /// view to tell the user when their subscription state was last refreshed. + let updatedAt: Date? + + static let absent = NousSubscriptionState(present: false, providerIsNous: false, updatedAt: nil) + + /// Overall subscription active for Tool Gateway routing. Both halves have + /// to line up: auth record present *and* `nous` is the active provider. + /// Mirrors `NousSubscriptionFeatures.subscribed` on the Python side. + var subscribed: Bool { present && providerIsNous } +} + +/// Reads `auth.json` to detect Nous Portal subscription state. Delegates file +/// I/O to the active `ServerTransport`, so remote installations work the same +/// as local ones. +/// +/// The auth-record shape is defined by hermes-agent and is load-bearing. This +/// service parses a small, stable subset and tolerates anything new Hermes +/// adds — we only rely on `providers.nous` being a dict with `access_token` +/// and `active_provider` being either `"nous"` or not. +struct NousSubscriptionService: Sendable { + private let logger = Logger(subsystem: "com.scarf", category: "NousSubscriptionService") + let authJSONPath: String + let transport: any ServerTransport + + nonisolated init(context: ServerContext = .local) { + self.authJSONPath = context.paths.authJSON + self.transport = context.makeTransport() + } + + /// Escape hatch for tests — point at a fixture `auth.json` without + /// constructing a full `ServerContext`. Uses `LocalTransport` so the + /// fixture must live on the local filesystem. + init(path: String) { + self.authJSONPath = path + self.transport = LocalTransport() + } + + /// Load the current subscription state. Returns ``NousSubscriptionState/absent`` + /// on any read or parse failure — callers treat "absent" and "can't + /// read" the same in UI (show a "not subscribed" CTA). + nonisolated func loadState() -> NousSubscriptionState { + guard let data = try? transport.readFile(authJSONPath) else { + return .absent + } + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + logger.warning("auth.json is not a JSON object; assuming no Nous subscription") + return .absent + } + let providers = root["providers"] as? [String: Any] ?? [:] + let nous = providers["nous"] as? [String: Any] + let token = nous?["access_token"] as? String + let present = (token?.isEmpty == false) + + let activeProvider = root["active_provider"] as? String + let providerIsNous = (activeProvider == "nous") + + let updatedAt: Date? = { + guard let raw = root["updated_at"] as? String else { return nil } + return ISO8601DateFormatter().date(from: raw) + }() + + return NousSubscriptionState( + present: present, + providerIsNous: providerIsNous, + updatedAt: updatedAt + ) + } +} diff --git a/scarf/scarf/Features/Dashboard/Views/DashboardView.swift b/scarf/scarf/Features/Dashboard/Views/DashboardView.swift index 9aacca7..5689ab5 100644 --- a/scarf/scarf/Features/Dashboard/Views/DashboardView.swift +++ b/scarf/scarf/Features/Dashboard/Views/DashboardView.swift @@ -95,7 +95,7 @@ struct DashboardView: View { color: .purple ) StatusCard( - title: "Gateway", + title: "Messaging Gateway", value: viewModel.gatewayState?.statusText ?? "unknown", icon: "network", color: viewModel.gatewayState?.isRunning == true ? .green : .secondary diff --git a/scarf/scarf/Features/Gateway/Views/GatewayView.swift b/scarf/scarf/Features/Gateway/Views/GatewayView.swift index 1e908ba..821e227 100644 --- a/scarf/scarf/Features/Gateway/Views/GatewayView.swift +++ b/scarf/scarf/Features/Gateway/Views/GatewayView.swift @@ -19,7 +19,7 @@ struct GatewayView: View { .padding() .frame(maxWidth: .infinity, alignment: .topLeading) } - .navigationTitle("Gateway") + .navigationTitle("Messaging Gateway") .onAppear { viewModel.load() } .onChange(of: fileWatcher.lastChangeDate) { viewModel.load() } } diff --git a/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift b/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift index 384da0b..f1ef7c4 100644 --- a/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift +++ b/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift @@ -24,10 +24,12 @@ struct HealthSection: Identifiable { final class HealthViewModel { let context: ServerContext private let fileService: HermesFileService + private let subscriptionService: NousSubscriptionService init(context: ServerContext = .local) { self.context = context self.fileService = HermesFileService(context: context) + self.subscriptionService = NousSubscriptionService(context: context) } @@ -52,6 +54,7 @@ final class HealthViewModel { isLoading = true let ctx = context let svc = fileService + let subSvc = subscriptionService // Health runs four sync transport-mediated commands plus a process // probe — that's 4-5 ssh round-trips on remote, easily 1-2s. Detach // the whole load. @@ -60,6 +63,8 @@ final class HealthViewModel { let versionOutput = ctx.runHermes(["version"]).output let statusOutput = ctx.runHermes(["status"]).output let doctorOutput = ctx.runHermes(["doctor"]).output + let subscription = subSvc.loadState() + let config = svc.loadConfig() let lines = versionOutput.components(separatedBy: "\n") let version = lines.first ?? "" @@ -68,6 +73,7 @@ final class HealthViewModel { let updateInfo = updateLine?.trimmingCharacters(in: .whitespaces) ?? "" let statusSections = Self.parseOutputStatic(statusOutput) + + [Self.toolGatewaySection(subscription: subscription, config: config)] let doctorSections = Self.parseOutputStatic(doctorOutput) await MainActor.run { [weak self] in @@ -85,6 +91,80 @@ final class HealthViewModel { } } + /// Synthesize a Tool Gateway health section from the subscription state + + /// `platform_toolsets` table. Runs alongside the other status sections so + /// the user sees at a glance whether their Nous Portal subscription is + /// wired up. + /// + /// This is distinct from the "Messaging Gateway" (inbound Slack/Discord/… + /// requests) — the two are unrelated systems that unfortunately share the + /// "gateway" name in Hermes's CLI output. + /// + /// `nonisolated` so `load()` can call it from `Task.detached` alongside + /// `parseOutputStatic` without hopping back to MainActor. + nonisolated private static func toolGatewaySection(subscription: NousSubscriptionState, config: HermesConfig) -> HealthSection { + var checks: [HealthCheck] = [] + + let subscriptionCheck: HealthCheck = { + if subscription.subscribed { + return HealthCheck( + label: "Nous Portal subscription active", + status: .ok, + detail: "Tool requests route through the Nous Portal gateway." + ) + } + if subscription.present { + return HealthCheck( + label: "Signed in, but Nous isn't the active provider", + status: .warning, + detail: "Open Settings → General and pick Nous Portal to route tools through the gateway." + ) + } + return HealthCheck( + label: "Not subscribed", + status: .warning, + detail: "Run `hermes auth` and pick Nous Portal to enable subscription-gated tools." + ) + }() + checks.append(subscriptionCheck) + + if !config.platformToolsets.isEmpty { + let platforms = config.platformToolsets.keys.sorted() + for platform in platforms { + let toolsets = config.platformToolsets[platform] ?? [] + checks.append(HealthCheck( + label: "\(platform): \(toolsets.count) toolset\(toolsets.count == 1 ? "" : "s")", + status: .ok, + detail: toolsets.joined(separator: ", ") + )) + } + } + + let auxOnNous = [ + ("vision", config.auxiliary.vision.provider), + ("web_extract", config.auxiliary.webExtract.provider), + ("compression", config.auxiliary.compression.provider), + ("session_search", config.auxiliary.sessionSearch.provider), + ("skills_hub", config.auxiliary.skillsHub.provider), + ("approval", config.auxiliary.approval.provider), + ("mcp", config.auxiliary.mcp.provider), + ("flush_memories", config.auxiliary.flushMemories.provider), + ].filter { $0.1 == "nous" }.map(\.0) + if !auxOnNous.isEmpty { + checks.append(HealthCheck( + label: "Auxiliary tasks routed through Nous", + status: subscription.subscribed ? .ok : .warning, + detail: auxOnNous.joined(separator: ", ") + )) + } + + return HealthSection( + title: "Tool Gateway", + icon: "arrow.triangle.branch", + checks: checks + ) + } + func refreshProcessStatus() { let svc = fileService Task.detached { [weak self] in diff --git a/scarf/scarf/Features/Logs/ViewModels/LogsViewModel.swift b/scarf/scarf/Features/Logs/ViewModels/LogsViewModel.swift index 9af34d2..b4c89cf 100644 --- a/scarf/scarf/Features/Logs/ViewModels/LogsViewModel.swift +++ b/scarf/scarf/Features/Logs/ViewModels/LogsViewModel.swift @@ -28,7 +28,7 @@ final class LogsViewModel { switch self { case .agent: return "Agent" case .errors: return "Errors" - case .gateway: return "Gateway" + case .gateway: return "Messaging Gateway" } } } @@ -54,7 +54,7 @@ final class LogsViewModel { var displayName: LocalizedStringResource { switch self { case .all: return "All" - case .gateway: return "Gateway" + case .gateway: return "Messaging Gateway" case .agent: return "Agent" case .tools: return "Tools" case .cli: return "CLI" diff --git a/scarf/scarf/Features/Settings/Views/Components/ModelPickerSheet.swift b/scarf/scarf/Features/Settings/Views/Components/ModelPickerSheet.swift index 999af77..0bdd93c 100644 --- a/scarf/scarf/Features/Settings/Views/Components/ModelPickerSheet.swift +++ b/scarf/scarf/Features/Settings/Views/Components/ModelPickerSheet.swift @@ -3,6 +3,12 @@ import SwiftUI /// Two-column model browser sheet. Left column lists providers, right column /// lists models for the selected provider. Supports filtering and a "Custom…" /// option for free-form model IDs not in the catalog. +/// +/// Overlay-only providers (Nous Portal, OpenAI Codex, Qwen OAuth, …) have no +/// models.dev catalog entry, so their right column renders an overlay detail +/// view: subscription state for Nous, plus a free-form model-ID field for +/// users who know what they want. This is how the picker keeps parity with +/// `hermes model` on the CLI, which can reach these providers natively. struct ModelPickerSheet: View { let initialProvider: String let initialModel: String @@ -21,8 +27,17 @@ struct ModelPickerSheet: View { @State private var customModelID: String = "" @State private var customProviderID: String = "" + // Overlay-provider model entry — distinct from `customMode` because the + // provider is pinned; only the model ID is user-editable. + @State private var overlayModelID: String = "" + + // Subscription state for the Nous Portal row / detail view. Loaded on + // appear; stays in-memory for the life of the sheet. + @State private var subscription: NousSubscriptionState = .absent + @Environment(\.serverContext) private var serverContext private var catalog: ModelCatalogService { ModelCatalogService(context: serverContext) } + private var subscriptionService: NousSubscriptionService { NousSubscriptionService(context: serverContext) } var body: some View { VStack(spacing: 0) { @@ -44,6 +59,8 @@ struct ModelPickerSheet: View { providers = catalog.loadProviders() selectedProviderID = initialProvider.isEmpty ? (providers.first?.providerID ?? "") : initialProvider selectedModelID = initialModel + overlayModelID = initialModel + subscription = subscriptionService.loadState() loadModelsForSelection() } } @@ -80,20 +97,39 @@ struct ModelPickerSheet: View { } )) { ForEach(filteredProviders) { provider in - HStack { - Text(provider.providerName) - Spacer() - Text("\(provider.modelCount)") - .font(.caption2.monospaced()) - .foregroundStyle(.secondary) - } - .tag(provider.providerID) + providerRow(provider) + .tag(provider.providerID) } } .listStyle(.inset) } + @ViewBuilder + private func providerRow(_ provider: HermesProviderInfo) -> some View { + HStack(spacing: 6) { + Text(provider.providerName) + if provider.subscriptionGated { + capsuleTag("Subscription", tint: .accentColor) + } + Spacer() + if !provider.isOverlay { + Text("\(provider.modelCount)") + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + } + } + } + + @ViewBuilder private var modelColumn: some View { + if let selected = providers.first(where: { $0.providerID == selectedProviderID }), selected.isOverlay { + overlayProviderDetail(selected) + } else { + cachedModelList + } + } + + private var cachedModelList: some View { List(selection: $selectedModelID) { ForEach(filteredModels) { model in VStack(alignment: .leading, spacing: 2) { @@ -138,6 +174,104 @@ struct ModelPickerSheet: View { } } + /// Right-column detail for overlay-only providers (Nous Portal, OpenAI + /// Codex, Qwen OAuth, …). models.dev has no catalog for them, so the user + /// either trusts Hermes's default (subscription providers) or types a + /// model ID they know is valid for the provider's API. + @ViewBuilder + private func overlayProviderDetail(_ provider: HermesProviderInfo) -> some View { + let overlay = catalog.overlayMetadata(for: provider.providerID) + ScrollView { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(provider.providerName).font(.title3.bold()) + if provider.subscriptionGated { + capsuleTag("Subscription", tint: .accentColor) + } + } + if provider.subscriptionGated { + subscriptionSummary(provider: provider, overlay: overlay) + } else { + Text(overlayInstruction(for: overlay?.authType)) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Divider() + + VStack(alignment: .leading, spacing: 4) { + Text("Model ID").font(.caption).foregroundStyle(.secondary) + TextField(modelIDPlaceholder(for: provider), text: $overlayModelID) + .textFieldStyle(.roundedBorder) + .font(.system(.caption, design: .monospaced)) + if provider.subscriptionGated { + Text("Leave blank to use Hermes's default Nous model.") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + + if let docURL = overlay?.docURL, let url = URL(string: docURL) { + Link(destination: url) { + Label("Setup documentation", systemImage: "book") + .font(.caption) + } + } + + Spacer(minLength: 0) + } + .padding() + } + } + + @ViewBuilder + private func subscriptionSummary(provider: HermesProviderInfo, overlay: HermesProviderOverlay?) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Paid Nous Portal subscribers route web search, image generation, TTS, and browser automation through their subscription — no separate API keys needed.") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 6) { + Image(systemName: subscription.subscribed ? "checkmark.circle.fill" : "exclamationmark.circle") + .foregroundStyle(subscription.subscribed ? Color.green : Color.secondary) + if subscription.subscribed { + Text("Subscription active — active provider is Nous.") + } else if subscription.present { + Text("Signed in to Nous, but another provider is active.") + .foregroundStyle(.secondary) + } else { + Text("Not signed in. Run `hermes auth` and select Nous Portal.") + .foregroundStyle(.secondary) + } + } + .font(.callout) + } + } + + private func overlayInstruction(for authType: HermesProviderOverlay.AuthType?) -> String { + switch authType { + case .oauthExternal: + return "Sign in through the provider's OAuth flow — run `hermes auth` from a terminal, then pick the provider to complete sign-in. Back here, set the model ID you want to use." + case .externalProcess: + return "Uses an external process (e.g. a local agent bridge). Run `hermes auth` from a terminal to complete the link, then set the model ID you want to use." + case .oauthDeviceCode: + return "Sign in via device-code flow — run `hermes auth` from a terminal and follow the printed URL." + default: + return "This provider isn't in the models.dev catalog. Enter the model ID you want to use — Hermes will pass it through to the provider verbatim." + } + } + + private func modelIDPlaceholder(for provider: HermesProviderInfo) -> String { + switch provider.providerID { + case "nous": return "e.g. hermes-3" + case "openai-codex": return "e.g. gpt-5-codex" + case "qwen-oauth": return "e.g. qwen3-coder-plus" + default: return "e.g. model-name" + } + } + private var customEntry: some View { VStack(alignment: .leading, spacing: 12) { Text("Use a model not in the catalog. Hermes accepts any string the provider recognizes, including provider-prefixed forms like \"openrouter/anthropic/claude-opus-4.6\".") @@ -201,14 +335,35 @@ struct ModelPickerSheet: View { } } + private var isSelectedProviderOverlay: Bool { + providers.first(where: { $0.providerID == selectedProviderID })?.isOverlay ?? false + } + + private var isSelectedProviderSubscriptionGated: Bool { + providers.first(where: { $0.providerID == selectedProviderID })?.subscriptionGated ?? false + } + private var canSubmit: Bool { if customMode { return !customModelID.trimmingCharacters(in: .whitespaces).isEmpty } + if isSelectedProviderOverlay { + // Subscription-gated providers can submit with an empty model ID + // (Hermes picks its default). Other overlays require a model ID. + if isSelectedProviderSubscriptionGated { return true } + return !overlayModelID.trimmingCharacters(in: .whitespaces).isEmpty + } return !selectedModelID.isEmpty } private var selectedPreview: String? { + if isSelectedProviderOverlay { + let trimmed = overlayModelID.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + return selectedProviderID.isEmpty ? nil : "\(selectedProviderID) / (default)" + } + return "\(selectedProviderID) / \(trimmed)" + } guard !selectedModelID.isEmpty, !selectedProviderID.isEmpty else { return nil } return "\(selectedProviderID) / \(selectedModelID)" } @@ -249,18 +404,21 @@ struct ModelPickerSheet: View { let model = customModelID.trimmingCharacters(in: .whitespaces) let provider = resolvedCustomProvider() onSelect(model, provider) + } else if isSelectedProviderOverlay { + let model = overlayModelID.trimmingCharacters(in: .whitespaces) + onSelect(model, selectedProviderID) } else { onSelect(selectedModelID, selectedProviderID) } } - private func capsuleTag(_ text: String) -> some View { + private func capsuleTag(_ text: String, tint: Color = .secondary) -> some View { Text(text) .font(.caption2) - .foregroundStyle(.secondary) + .foregroundStyle(tint == .secondary ? AnyShapeStyle(.secondary) : AnyShapeStyle(tint)) .padding(.horizontal, 5) .padding(.vertical, 1) - .background(.quaternary) + .background(tint == .secondary ? AnyShapeStyle(.quaternary) : AnyShapeStyle(tint.opacity(0.15))) .clipShape(Capsule()) } } diff --git a/scarf/scarf/Features/Settings/Views/Tabs/AgentTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/AgentTab.swift index b6f8457..2c2bbd4 100644 --- a/scarf/scarf/Features/Settings/Views/Tabs/AgentTab.swift +++ b/scarf/scarf/Features/Settings/Views/Tabs/AgentTab.swift @@ -16,7 +16,7 @@ struct AgentTab: View { StepperRow(label: "Approval Timeout (s)", value: viewModel.config.approvalTimeout, range: 5...600, step: 5) { viewModel.setApprovalTimeout($0) } } - SettingsSection(title: "Gateway", icon: "antenna.radiowaves.left.and.right") { + SettingsSection(title: "Messaging Gateway", icon: "antenna.radiowaves.left.and.right") { ToggleRow(label: "Fast Mode", isOn: viewModel.config.serviceTier == "fast") { on in viewModel.setServiceTier(on ? "fast" : "normal") } diff --git a/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift index df3c7ee..5aaff53 100644 --- a/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift +++ b/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift @@ -2,9 +2,18 @@ import SwiftUI /// Auxiliary tab — the 8 sub-model tasks hermes delegates to cheaper models. /// Each follows the same provider/model/base_url/api_key/timeout pattern. +/// +/// Adds a per-task **Route through Nous Portal** toggle for Hermes v0.10.0+ +/// subscribers. The toggle flips `auxiliary..provider` between `nous` +/// (subscription-routed) and `auto` (inherit main provider) — Hermes derives +/// the gateway routing from that single field; there is no separate +/// `use_gateway` key to write. struct AuxiliaryTab: View { @Bindable var viewModel: SettingsViewModel + @Environment(\.serverContext) private var serverContext + @State private var subscription: NousSubscriptionState = .absent + // Keyed by the config path name — matches `auxiliary..*` in config.yaml. private let tasks: [(key: String, title: LocalizedStringKey, icon: String)] = [ ("vision", "Vision", "eye"), @@ -28,11 +37,16 @@ struct AuxiliaryTab: View { auxRows(for: task.key) } } + Color.clear.frame(height: 0) + .onAppear { + subscription = NousSubscriptionService(context: serverContext).loadState() + } } @ViewBuilder private func auxRows(for key: String) -> some View { let model = auxModel(for: key) + nousGatewayToggle(for: key, currentProvider: model.provider) EditableTextField(label: "Provider", value: model.provider) { viewModel.setAuxiliary(key, field: "provider", value: $0) } EditableTextField(label: "Model", value: model.model) { viewModel.setAuxiliary(key, field: "model", value: $0) } EditableTextField(label: "Base URL", value: model.baseURL) { viewModel.setAuxiliary(key, field: "base_url", value: $0) } @@ -40,6 +54,24 @@ struct AuxiliaryTab: View { StepperRow(label: "Timeout (s)", value: model.timeout, range: 5...3600, step: 5) { viewModel.setAuxiliaryTimeout(key, value: $0) } } + @ViewBuilder + private func nousGatewayToggle(for key: String, currentProvider: String) -> some View { + let isOn = (currentProvider == "nous") + ToggleRow(label: "Nous Portal", isOn: isOn) { wantsOn in + // "nous" enables subscription routing; "auto" reverts to the + // inherit-main-provider default. We never touch model/base/key + // fields here — Hermes reuses them if the user switches back. + viewModel.setAuxiliary(key, field: "provider", value: wantsOn ? "nous" : "auto") + } + if !subscription.present && !isOn { + Text("Requires an active Nous Portal subscription. Run `hermes auth` to sign in.") + .font(.caption2) + .foregroundStyle(.tertiary) + .padding(.horizontal, 12) + .padding(.bottom, 4) + } + } + private func auxModel(for key: String) -> AuxiliaryModel { switch key { case "vision": return viewModel.config.auxiliary.vision diff --git a/scarf/scarf/Navigation/AppCoordinator.swift b/scarf/scarf/Navigation/AppCoordinator.swift index d553b56..4fc55a5 100644 --- a/scarf/scarf/Navigation/AppCoordinator.swift +++ b/scarf/scarf/Navigation/AppCoordinator.swift @@ -50,7 +50,7 @@ enum SidebarSection: String, CaseIterable, Identifiable { case .profiles: return "Profiles" case .tools: return "Tools" case .mcpServers: return "MCP Servers" - case .gateway: return "Gateway" + case .gateway: return "Messaging Gateway" case .cron: return "Cron" case .health: return "Health" case .logs: return "Logs" diff --git a/scarf/scarf/scarfApp.swift b/scarf/scarf/scarfApp.swift index 6a35920..46fd232 100644 --- a/scarf/scarf/scarfApp.swift +++ b/scarf/scarf/scarfApp.swift @@ -378,7 +378,7 @@ struct MenuBarMenu: View { systemImage: status.hermesRunning ? "circle.fill" : "circle" ) Label( - status.gatewayRunning ? "Gateway Running" : "Gateway Stopped", + status.gatewayRunning ? "Messaging Gateway Running" : "Messaging Gateway Stopped", systemImage: status.gatewayRunning ? "circle.fill" : "circle" ) Button("Start Hermes") { status.startHermes() } diff --git a/scarf/scarfTests/ToolGatewayTests.swift b/scarf/scarfTests/ToolGatewayTests.swift new file mode 100644 index 0000000..5772c8a --- /dev/null +++ b/scarf/scarfTests/ToolGatewayTests.swift @@ -0,0 +1,199 @@ +import Testing +import Foundation +@testable import scarf + +/// Invariants around Hermes v0.10.0 Tool Gateway integration: +/// overlay-provider merge, Nous Portal subscription detection, and +/// `platform_toolsets` YAML parsing. +@Suite struct ToolGatewayTests { + + // MARK: - Fixtures + + /// Minimal models.dev cache with exactly two providers so the overlay + /// merge is easy to reason about — none of them are overlays. + private func writeCacheFixture() throws -> String { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("scarf-catalog-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let path = dir.appendingPathComponent("models_dev_cache.json").path + let json = """ + { + "anthropic": { + "name": "Anthropic", + "models": { + "claude-sonnet-4-5-20250929": { "name": "Claude Sonnet 4.5" } + } + }, + "openai": { + "name": "OpenAI", + "models": { + "gpt-4o": { "name": "GPT-4o" } + } + } + } + """ + try json.write(toFile: path, atomically: true, encoding: .utf8) + return path + } + + private func writeAuthFixture(_ body: String) throws -> String { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("scarf-auth-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let path = dir.appendingPathComponent("auth.json").path + try body.write(toFile: path, atomically: true, encoding: .utf8) + return path + } + + // MARK: - ModelCatalogService overlay merge + + @Test func overlayOnlyProvidersAppearInPicker() throws { + let path = try writeCacheFixture() + let service = ModelCatalogService(path: path) + let providers = service.loadProviders() + + let ids = providers.map(\.providerID) + #expect(ids.contains("nous"), "Nous Portal must appear after overlay merge") + #expect(ids.contains("openai-codex"), "OpenAI Codex overlay must appear") + #expect(ids.contains("qwen-oauth"), "Qwen OAuth overlay must appear") + // Cached providers still present. + #expect(ids.contains("anthropic")) + #expect(ids.contains("openai")) + } + + @Test func nousPortalSortsFirst() throws { + let path = try writeCacheFixture() + let service = ModelCatalogService(path: path) + let providers = service.loadProviders() + #expect(providers.first?.providerID == "nous", + "Subscription-gated providers must sort before the alphabetical block") + } + + @Test func overlayProvidersCarryMetadata() throws { + let path = try writeCacheFixture() + let service = ModelCatalogService(path: path) + let providers = service.loadProviders() + + let nous = providers.first { $0.providerID == "nous" } + #expect(nous?.isOverlay == true) + #expect(nous?.subscriptionGated == true) + #expect(nous?.providerName == "Nous Portal") + #expect(nous?.modelCount == 0, "Overlay-only providers have no models in the cache") + + let codex = providers.first { $0.providerID == "openai-codex" } + #expect(codex?.isOverlay == true) + #expect(codex?.subscriptionGated == false, + "Only Nous is subscription-gated today") + } + + @Test func cachedProvidersAreNotMarkedOverlay() throws { + let path = try writeCacheFixture() + let service = ModelCatalogService(path: path) + let providers = service.loadProviders() + + let anthropic = providers.first { $0.providerID == "anthropic" } + #expect(anthropic?.isOverlay == false) + #expect(anthropic?.subscriptionGated == false) + } + + @Test func providerByIDReturnsOverlayWhenCacheMisses() throws { + let path = try writeCacheFixture() + let service = ModelCatalogService(path: path) + + let nous = service.providerByID("nous") + #expect(nous?.providerName == "Nous Portal") + #expect(nous?.isOverlay == true) + + let missing = service.providerByID("definitely-not-a-provider") + #expect(missing == nil) + } + + // MARK: - NousSubscriptionService + + @Test func subscriptionAbsentWhenAuthFileMissing() throws { + let path = "/tmp/this-file-should-not-exist-\(UUID().uuidString).json" + let service = NousSubscriptionService(path: path) + let state = service.loadState() + #expect(state == .absent) + } + + @Test func subscriptionAbsentWhenProvidersEmpty() throws { + let path = try writeAuthFixture(""" + { "version": 1, "providers": {}, "active_provider": null } + """) + let state = NousSubscriptionService(path: path).loadState() + #expect(state.present == false) + #expect(state.subscribed == false) + } + + @Test func subscriptionPresentButInactiveWhenOtherProviderActive() throws { + let path = try writeAuthFixture(""" + { + "version": 1, + "providers": { "nous": { "access_token": "tok-12345" } }, + "active_provider": "anthropic" + } + """) + let state = NousSubscriptionService(path: path).loadState() + #expect(state.present == true) + #expect(state.providerIsNous == false) + #expect(state.subscribed == false, + "Auth alone isn't enough — the Tool Gateway only routes when Nous is the active provider") + } + + @Test func subscriptionActiveWhenAuthAndActiveProviderLineUp() throws { + let path = try writeAuthFixture(""" + { + "version": 1, + "providers": { "nous": { "access_token": "tok-12345" } }, + "active_provider": "nous" + } + """) + let state = NousSubscriptionService(path: path).loadState() + #expect(state.present == true) + #expect(state.providerIsNous == true) + #expect(state.subscribed == true) + } + + @Test func subscriptionAbsentWhenTokenEmpty() throws { + let path = try writeAuthFixture(""" + { + "version": 1, + "providers": { "nous": { "access_token": "" } }, + "active_provider": "nous" + } + """) + let state = NousSubscriptionService(path: path).loadState() + #expect(state.present == false, + "Empty token is as good as no token — don't claim subscription") + } + + @Test func subscriptionAbsentOnMalformedJSON() throws { + let path = try writeAuthFixture("{ this is not valid json") + let state = NousSubscriptionService(path: path).loadState() + #expect(state == .absent) + } + + // MARK: - platform_toolsets YAML parse + + @Test func platformToolsetsParsed() throws { + let yaml = """ + model: + default: claude-sonnet-4.5 + provider: anthropic + platform_toolsets: + cli: + - browser + - messaging + slack: + - messaging + """ + let parsed = HermesFileService.parseNestedYAML(yaml) + #expect(parsed.lists["platform_toolsets.cli"] == ["browser", "messaging"]) + #expect(parsed.lists["platform_toolsets.slack"] == ["messaging"]) + } + + @Test func platformToolsetsEmptyWhenMissing() throws { + // HermesConfig.empty should have no platform toolsets. + let config = HermesConfig.empty + #expect(config.platformToolsets.isEmpty) + } +}