mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14:37 +00:00
feat: Nous Portal + Tool Gateway support for Hermes v0.10.0
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.<task>.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) <noreply@anthropic.com>
This commit is contained in:
@@ -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.<task>.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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -211,6 +211,16 @@ struct HermesFileService: Sendable {
|
||||
replyPrefix: str("whatsapp.reply_prefix")
|
||||
)
|
||||
|
||||
// `platform_toolsets.<platform>` 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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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.<task>.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.<task>.*` 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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user