mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +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
|
## 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
|
## Project Templates
|
||||||
|
|
||||||
|
|||||||
@@ -339,6 +339,15 @@ struct HermesConfig: Sendable {
|
|||||||
var prefillMessagesFile: String
|
var prefillMessagesFile: String
|
||||||
var skillsExternalDirs: [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
|
// Grouped blocks
|
||||||
var display: DisplaySettings
|
var display: DisplaySettings
|
||||||
var terminal: TerminalSettings
|
var terminal: TerminalSettings
|
||||||
@@ -397,6 +406,7 @@ struct HermesConfig: Sendable {
|
|||||||
cronWrapResponse: true,
|
cronWrapResponse: true,
|
||||||
prefillMessagesFile: "",
|
prefillMessagesFile: "",
|
||||||
skillsExternalDirs: [],
|
skillsExternalDirs: [],
|
||||||
|
platformToolsets: [:],
|
||||||
display: .empty,
|
display: .empty,
|
||||||
terminal: .empty,
|
terminal: .empty,
|
||||||
browser: .empty,
|
browser: .empty,
|
||||||
|
|||||||
@@ -211,6 +211,16 @@ struct HermesFileService: Sendable {
|
|||||||
replyPrefix: str("whatsapp.reply_prefix")
|
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.*`.
|
// Home Assistant lives under `platforms.homeassistant.extra.*`.
|
||||||
let homeAssistant = HomeAssistantSettings(
|
let homeAssistant = HomeAssistantSettings(
|
||||||
watchDomains: lists["platforms.homeassistant.extra.watch_domains"] ?? [],
|
watchDomains: lists["platforms.homeassistant.extra.watch_domains"] ?? [],
|
||||||
@@ -258,6 +268,7 @@ struct HermesFileService: Sendable {
|
|||||||
cronWrapResponse: bool("cron.wrap_response", default: true),
|
cronWrapResponse: bool("cron.wrap_response", default: true),
|
||||||
prefillMessagesFile: str("prefill_messages_file"),
|
prefillMessagesFile: str("prefill_messages_file"),
|
||||||
skillsExternalDirs: lists["skills.external_dirs"] ?? [],
|
skillsExternalDirs: lists["skills.external_dirs"] ?? [],
|
||||||
|
platformToolsets: platformToolsets,
|
||||||
display: display,
|
display: display,
|
||||||
terminal: terminal,
|
terminal: terminal,
|
||||||
browser: browser,
|
browser: browser,
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ struct HermesProviderInfo: Sendable, Identifiable, Hashable {
|
|||||||
let envVars: [String] // e.g. ["ANTHROPIC_API_KEY"]
|
let envVars: [String] // e.g. ["ANTHROPIC_API_KEY"]
|
||||||
let docURL: String?
|
let docURL: String?
|
||||||
let modelCount: Int
|
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
|
/// Reads the models.dev catalog that hermes caches at
|
||||||
@@ -67,20 +76,53 @@ struct ModelCatalogService: Sendable {
|
|||||||
self.transport = LocalTransport()
|
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] {
|
func loadProviders() -> [HermesProviderInfo] {
|
||||||
guard let catalog = loadCatalog() else { return [] }
|
let catalog = loadCatalog() ?? [:]
|
||||||
return catalog
|
var byID: [String: HermesProviderInfo] = [:]
|
||||||
.map { (id, p) in
|
for (id, p) in catalog {
|
||||||
HermesProviderInfo(
|
byID[id] = HermesProviderInfo(
|
||||||
providerID: id,
|
providerID: id,
|
||||||
providerName: p.name ?? id,
|
providerName: p.name ?? id,
|
||||||
envVars: p.env ?? [],
|
envVars: p.env ?? [],
|
||||||
docURL: p.doc,
|
docURL: p.doc,
|
||||||
modelCount: p.models?.count ?? 0
|
modelCount: p.models?.count ?? 0,
|
||||||
|
isOverlay: false,
|
||||||
|
subscriptionGated: false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.sorted { $0.providerName.localizedCaseInsensitiveCompare($1.providerName) == .orderedAscending }
|
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
|
||||||
|
}
|
||||||
|
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.
|
/// Models for one provider, sorted by release date (newest first), then name.
|
||||||
@@ -123,7 +165,9 @@ struct ModelCatalogService: Sendable {
|
|||||||
providerName: p.name ?? providerID,
|
providerName: p.name ?? providerID,
|
||||||
envVars: p.env ?? [],
|
envVars: p.env ?? [],
|
||||||
docURL: p.doc,
|
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,
|
providerName: p.name ?? prefix,
|
||||||
envVars: p.env ?? [],
|
envVars: p.env ?? [],
|
||||||
docURL: p.doc,
|
docURL: p.doc,
|
||||||
modelCount: p.models?.count ?? 0
|
modelCount: p.models?.count ?? 0,
|
||||||
|
isOverlay: false,
|
||||||
|
subscriptionGated: false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
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
|
/// Look up a specific model by provider + ID. Returns nil if not in the
|
||||||
/// catalog (e.g., free-typed custom model).
|
/// catalog (e.g., free-typed custom model).
|
||||||
func model(providerID: String, modelID: String) -> HermesModelInfo? {
|
func model(providerID: String, modelID: String) -> HermesModelInfo? {
|
||||||
@@ -207,4 +283,79 @@ struct ModelCatalogService: Sendable {
|
|||||||
let context: Int?
|
let context: Int?
|
||||||
let output: 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
|
color: .purple
|
||||||
)
|
)
|
||||||
StatusCard(
|
StatusCard(
|
||||||
title: "Gateway",
|
title: "Messaging Gateway",
|
||||||
value: viewModel.gatewayState?.statusText ?? "unknown",
|
value: viewModel.gatewayState?.statusText ?? "unknown",
|
||||||
icon: "network",
|
icon: "network",
|
||||||
color: viewModel.gatewayState?.isRunning == true ? .green : .secondary
|
color: viewModel.gatewayState?.isRunning == true ? .green : .secondary
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ struct GatewayView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
}
|
}
|
||||||
.navigationTitle("Gateway")
|
.navigationTitle("Messaging Gateway")
|
||||||
.onAppear { viewModel.load() }
|
.onAppear { viewModel.load() }
|
||||||
.onChange(of: fileWatcher.lastChangeDate) { viewModel.load() }
|
.onChange(of: fileWatcher.lastChangeDate) { viewModel.load() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ struct HealthSection: Identifiable {
|
|||||||
final class HealthViewModel {
|
final class HealthViewModel {
|
||||||
let context: ServerContext
|
let context: ServerContext
|
||||||
private let fileService: HermesFileService
|
private let fileService: HermesFileService
|
||||||
|
private let subscriptionService: NousSubscriptionService
|
||||||
|
|
||||||
init(context: ServerContext = .local) {
|
init(context: ServerContext = .local) {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.fileService = HermesFileService(context: context)
|
self.fileService = HermesFileService(context: context)
|
||||||
|
self.subscriptionService = NousSubscriptionService(context: context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ final class HealthViewModel {
|
|||||||
isLoading = true
|
isLoading = true
|
||||||
let ctx = context
|
let ctx = context
|
||||||
let svc = fileService
|
let svc = fileService
|
||||||
|
let subSvc = subscriptionService
|
||||||
// Health runs four sync transport-mediated commands plus a process
|
// Health runs four sync transport-mediated commands plus a process
|
||||||
// probe — that's 4-5 ssh round-trips on remote, easily 1-2s. Detach
|
// probe — that's 4-5 ssh round-trips on remote, easily 1-2s. Detach
|
||||||
// the whole load.
|
// the whole load.
|
||||||
@@ -60,6 +63,8 @@ final class HealthViewModel {
|
|||||||
let versionOutput = ctx.runHermes(["version"]).output
|
let versionOutput = ctx.runHermes(["version"]).output
|
||||||
let statusOutput = ctx.runHermes(["status"]).output
|
let statusOutput = ctx.runHermes(["status"]).output
|
||||||
let doctorOutput = ctx.runHermes(["doctor"]).output
|
let doctorOutput = ctx.runHermes(["doctor"]).output
|
||||||
|
let subscription = subSvc.loadState()
|
||||||
|
let config = svc.loadConfig()
|
||||||
|
|
||||||
let lines = versionOutput.components(separatedBy: "\n")
|
let lines = versionOutput.components(separatedBy: "\n")
|
||||||
let version = lines.first ?? ""
|
let version = lines.first ?? ""
|
||||||
@@ -68,6 +73,7 @@ final class HealthViewModel {
|
|||||||
let updateInfo = updateLine?.trimmingCharacters(in: .whitespaces) ?? ""
|
let updateInfo = updateLine?.trimmingCharacters(in: .whitespaces) ?? ""
|
||||||
|
|
||||||
let statusSections = Self.parseOutputStatic(statusOutput)
|
let statusSections = Self.parseOutputStatic(statusOutput)
|
||||||
|
+ [Self.toolGatewaySection(subscription: subscription, config: config)]
|
||||||
let doctorSections = Self.parseOutputStatic(doctorOutput)
|
let doctorSections = Self.parseOutputStatic(doctorOutput)
|
||||||
|
|
||||||
await MainActor.run { [weak self] in
|
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() {
|
func refreshProcessStatus() {
|
||||||
let svc = fileService
|
let svc = fileService
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ final class LogsViewModel {
|
|||||||
switch self {
|
switch self {
|
||||||
case .agent: return "Agent"
|
case .agent: return "Agent"
|
||||||
case .errors: return "Errors"
|
case .errors: return "Errors"
|
||||||
case .gateway: return "Gateway"
|
case .gateway: return "Messaging Gateway"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,7 +54,7 @@ final class LogsViewModel {
|
|||||||
var displayName: LocalizedStringResource {
|
var displayName: LocalizedStringResource {
|
||||||
switch self {
|
switch self {
|
||||||
case .all: return "All"
|
case .all: return "All"
|
||||||
case .gateway: return "Gateway"
|
case .gateway: return "Messaging Gateway"
|
||||||
case .agent: return "Agent"
|
case .agent: return "Agent"
|
||||||
case .tools: return "Tools"
|
case .tools: return "Tools"
|
||||||
case .cli: return "CLI"
|
case .cli: return "CLI"
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import SwiftUI
|
|||||||
/// Two-column model browser sheet. Left column lists providers, right column
|
/// Two-column model browser sheet. Left column lists providers, right column
|
||||||
/// lists models for the selected provider. Supports filtering and a "Custom…"
|
/// lists models for the selected provider. Supports filtering and a "Custom…"
|
||||||
/// option for free-form model IDs not in the catalog.
|
/// 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 {
|
struct ModelPickerSheet: View {
|
||||||
let initialProvider: String
|
let initialProvider: String
|
||||||
let initialModel: String
|
let initialModel: String
|
||||||
@@ -21,8 +27,17 @@ struct ModelPickerSheet: View {
|
|||||||
@State private var customModelID: String = ""
|
@State private var customModelID: String = ""
|
||||||
@State private var customProviderID: 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
|
@Environment(\.serverContext) private var serverContext
|
||||||
private var catalog: ModelCatalogService { ModelCatalogService(context: serverContext) }
|
private var catalog: ModelCatalogService { ModelCatalogService(context: serverContext) }
|
||||||
|
private var subscriptionService: NousSubscriptionService { NousSubscriptionService(context: serverContext) }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -44,6 +59,8 @@ struct ModelPickerSheet: View {
|
|||||||
providers = catalog.loadProviders()
|
providers = catalog.loadProviders()
|
||||||
selectedProviderID = initialProvider.isEmpty ? (providers.first?.providerID ?? "") : initialProvider
|
selectedProviderID = initialProvider.isEmpty ? (providers.first?.providerID ?? "") : initialProvider
|
||||||
selectedModelID = initialModel
|
selectedModelID = initialModel
|
||||||
|
overlayModelID = initialModel
|
||||||
|
subscription = subscriptionService.loadState()
|
||||||
loadModelsForSelection()
|
loadModelsForSelection()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,20 +97,39 @@ struct ModelPickerSheet: View {
|
|||||||
}
|
}
|
||||||
)) {
|
)) {
|
||||||
ForEach(filteredProviders) { provider in
|
ForEach(filteredProviders) { provider in
|
||||||
HStack {
|
providerRow(provider)
|
||||||
Text(provider.providerName)
|
|
||||||
Spacer()
|
|
||||||
Text("\(provider.modelCount)")
|
|
||||||
.font(.caption2.monospaced())
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
.tag(provider.providerID)
|
.tag(provider.providerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.inset)
|
.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 {
|
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) {
|
List(selection: $selectedModelID) {
|
||||||
ForEach(filteredModels) { model in
|
ForEach(filteredModels) { model in
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
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 {
|
private var customEntry: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
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\".")
|
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 {
|
private var canSubmit: Bool {
|
||||||
if customMode {
|
if customMode {
|
||||||
return !customModelID.trimmingCharacters(in: .whitespaces).isEmpty
|
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
|
return !selectedModelID.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
private var selectedPreview: String? {
|
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 }
|
guard !selectedModelID.isEmpty, !selectedProviderID.isEmpty else { return nil }
|
||||||
return "\(selectedProviderID) / \(selectedModelID)"
|
return "\(selectedProviderID) / \(selectedModelID)"
|
||||||
}
|
}
|
||||||
@@ -249,18 +404,21 @@ struct ModelPickerSheet: View {
|
|||||||
let model = customModelID.trimmingCharacters(in: .whitespaces)
|
let model = customModelID.trimmingCharacters(in: .whitespaces)
|
||||||
let provider = resolvedCustomProvider()
|
let provider = resolvedCustomProvider()
|
||||||
onSelect(model, provider)
|
onSelect(model, provider)
|
||||||
|
} else if isSelectedProviderOverlay {
|
||||||
|
let model = overlayModelID.trimmingCharacters(in: .whitespaces)
|
||||||
|
onSelect(model, selectedProviderID)
|
||||||
} else {
|
} else {
|
||||||
onSelect(selectedModelID, selectedProviderID)
|
onSelect(selectedModelID, selectedProviderID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func capsuleTag(_ text: String) -> some View {
|
private func capsuleTag(_ text: String, tint: Color = .secondary) -> some View {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(tint == .secondary ? AnyShapeStyle(.secondary) : AnyShapeStyle(tint))
|
||||||
.padding(.horizontal, 5)
|
.padding(.horizontal, 5)
|
||||||
.padding(.vertical, 1)
|
.padding(.vertical, 1)
|
||||||
.background(.quaternary)
|
.background(tint == .secondary ? AnyShapeStyle(.quaternary) : AnyShapeStyle(tint.opacity(0.15)))
|
||||||
.clipShape(Capsule())
|
.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) }
|
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
|
ToggleRow(label: "Fast Mode", isOn: viewModel.config.serviceTier == "fast") { on in
|
||||||
viewModel.setServiceTier(on ? "fast" : "normal")
|
viewModel.setServiceTier(on ? "fast" : "normal")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,18 @@ import SwiftUI
|
|||||||
|
|
||||||
/// Auxiliary tab — the 8 sub-model tasks hermes delegates to cheaper models.
|
/// Auxiliary tab — the 8 sub-model tasks hermes delegates to cheaper models.
|
||||||
/// Each follows the same provider/model/base_url/api_key/timeout pattern.
|
/// 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 {
|
struct AuxiliaryTab: View {
|
||||||
@Bindable var viewModel: SettingsViewModel
|
@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.
|
// Keyed by the config path name — matches `auxiliary.<task>.*` in config.yaml.
|
||||||
private let tasks: [(key: String, title: LocalizedStringKey, icon: String)] = [
|
private let tasks: [(key: String, title: LocalizedStringKey, icon: String)] = [
|
||||||
("vision", "Vision", "eye"),
|
("vision", "Vision", "eye"),
|
||||||
@@ -28,11 +37,16 @@ struct AuxiliaryTab: View {
|
|||||||
auxRows(for: task.key)
|
auxRows(for: task.key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Color.clear.frame(height: 0)
|
||||||
|
.onAppear {
|
||||||
|
subscription = NousSubscriptionService(context: serverContext).loadState()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func auxRows(for key: String) -> some View {
|
private func auxRows(for key: String) -> some View {
|
||||||
let model = auxModel(for: key)
|
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: "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: "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) }
|
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) }
|
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 {
|
private func auxModel(for key: String) -> AuxiliaryModel {
|
||||||
switch key {
|
switch key {
|
||||||
case "vision": return viewModel.config.auxiliary.vision
|
case "vision": return viewModel.config.auxiliary.vision
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
|||||||
case .profiles: return "Profiles"
|
case .profiles: return "Profiles"
|
||||||
case .tools: return "Tools"
|
case .tools: return "Tools"
|
||||||
case .mcpServers: return "MCP Servers"
|
case .mcpServers: return "MCP Servers"
|
||||||
case .gateway: return "Gateway"
|
case .gateway: return "Messaging Gateway"
|
||||||
case .cron: return "Cron"
|
case .cron: return "Cron"
|
||||||
case .health: return "Health"
|
case .health: return "Health"
|
||||||
case .logs: return "Logs"
|
case .logs: return "Logs"
|
||||||
|
|||||||
@@ -378,7 +378,7 @@ struct MenuBarMenu: View {
|
|||||||
systemImage: status.hermesRunning ? "circle.fill" : "circle"
|
systemImage: status.hermesRunning ? "circle.fill" : "circle"
|
||||||
)
|
)
|
||||||
Label(
|
Label(
|
||||||
status.gatewayRunning ? "Gateway Running" : "Gateway Stopped",
|
status.gatewayRunning ? "Messaging Gateway Running" : "Messaging Gateway Stopped",
|
||||||
systemImage: status.gatewayRunning ? "circle.fill" : "circle"
|
systemImage: status.gatewayRunning ? "circle.fill" : "circle"
|
||||||
)
|
)
|
||||||
Button("Start Hermes") { status.startHermes() }
|
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