diff --git a/.changeset/fn-7636-hermes-picker-models.md b/.changeset/fn-7636-hermes-picker-models.md new file mode 100644 index 0000000000..615c092964 --- /dev/null +++ b/.changeset/fn-7636-hermes-picker-models.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Hermes-configured models now appear in Fusion's model picker when the Hermes runtime is available. +category: feature +dev: /api/models additively merges `hermes profile list` results under the `hermes` provider via a short-TTL, single-flight cache (no per-request CLI spawn); rows are deduped by provider/id and never displace existing entries. Deferred item 1 of FN-7630. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index a86961953a..c613f43de4 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -963,6 +963,8 @@ Fusion resolves task models through workflow-backed lane values first, then glob Z.ai's built-in provider uses the existing `zai` auth entry / `ZAI_API_KEY` environment variable and includes `zai/glm-5.2` as a selectable model in the same dropdowns and workflow lane controls as the other built-in GLM models. If a pi extension also registers the `zai` provider, Fusion preserves the extension's models and re-adds any missing built-in Z.ai models so built-in GLM choices remain available. +When the Hermes Runtime plugin (`fusion-plugin-hermes-runtime`) is installed and the local `hermes` CLI has configured profiles (`hermes profile list`), those profiles are surfaced additively in `/api/models` under the `hermes` provider — one row per profile, id/name derived from the profile name and its configured model. This surfacing is read-only (Fusion does not create or edit Hermes profiles) and is fetched through a short-TTL, single-flight cache so the model picker never spawns the `hermes` CLI on every request; a missing/failed `hermes` binary simply yields zero Hermes rows without affecting other providers. + ### Planning model 1. Per-task `planningModelProvider` + `planningModelId` diff --git a/packages/dashboard/src/__tests__/hermes-model-cache.test.ts b/packages/dashboard/src/__tests__/hermes-model-cache.test.ts new file mode 100644 index 0000000000..8e3434525f --- /dev/null +++ b/packages/dashboard/src/__tests__/hermes-model-cache.test.ts @@ -0,0 +1,158 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { HermesProfileSummary } from "../runtime-provider-probes.js"; + +vi.mock("../runtime-provider-probes.js", () => ({ + listHermesProviderProfiles: vi.fn(), +})); + +import { listHermesProviderProfiles } from "../runtime-provider-probes.js"; +import { + __resetHermesPickerModelsCacheForTests, + getHermesPickerModels, + hermesProfilesToModels, +} from "../hermes-model-cache.js"; + +const mockedList = vi.mocked(listHermesProviderProfiles); + +afterEach(() => { + vi.clearAllMocks(); + __resetHermesPickerModelsCacheForTests(); +}); + +describe("hermesProfilesToModels", () => { + it("maps a profile with a model into a labeled row using profile name as stable id", () => { + const profiles: HermesProfileSummary[] = [ + { name: "default", model: "MiniMax-M3", gateway: "stopped", isDefault: true }, + ]; + const models = hermesProfilesToModels(profiles); + expect(models).toEqual([ + { provider: "hermes", id: "default", name: "default (MiniMax-M3)", reasoning: false, contextWindow: 0 }, + ]); + }); + + it("maps a profile without a model to a name-only label", () => { + const profiles: HermesProfileSummary[] = [{ name: "no-model-profile", isDefault: false }]; + const models = hermesProfilesToModels(profiles); + expect(models).toEqual([ + { provider: "hermes", id: "no-model-profile", name: "no-model-profile", reasoning: false, contextWindow: 0 }, + ]); + }); + + it("maps multiple profiles preserving order", () => { + const profiles: HermesProfileSummary[] = [ + { name: "default", model: "MiniMax-M3", isDefault: true }, + { name: "work", model: "claude-sonnet-4-5", isDefault: false }, + ]; + const models = hermesProfilesToModels(profiles); + expect(models.map((m) => m.id)).toEqual(["default", "work"]); + }); + + it("de-duplicates profiles that map to the same stable id, keeping the first occurrence", () => { + const profiles: HermesProfileSummary[] = [ + { name: "default", model: "MiniMax-M3", isDefault: true }, + { name: "default", model: "claude-sonnet-4-5", isDefault: false }, + ]; + const models = hermesProfilesToModels(profiles); + expect(models).toHaveLength(1); + expect(models[0]?.name).toBe("default (MiniMax-M3)"); + }); + + it("returns an empty array for an empty profile list", () => { + expect(hermesProfilesToModels([])).toEqual([]); + }); +}); + +describe("getHermesPickerModels caching", () => { + it("fetches once and returns mapped models", async () => { + mockedList.mockResolvedValue([{ name: "default", model: "MiniMax-M3", isDefault: true }]); + + const models = await getHermesPickerModels({ binaryPath: "hermes-test-1" }); + + expect(models).toEqual([ + { provider: "hermes", id: "default", name: "default (MiniMax-M3)", reasoning: false, contextWindow: 0 }, + ]); + expect(mockedList).toHaveBeenCalledTimes(1); + }); + + it("serves subsequent requests within the TTL window from cache with no additional spawn", async () => { + mockedList.mockResolvedValue([{ name: "default", isDefault: true }]); + let clock = 1000; + const now = () => clock; + + await getHermesPickerModels({ binaryPath: "hermes-test-2", ttlMs: 60_000, now }); + clock += 30_000; // still inside the 60s TTL + await getHermesPickerModels({ binaryPath: "hermes-test-2", ttlMs: 60_000, now }); + + expect(mockedList).toHaveBeenCalledTimes(1); + }); + + it("refreshes after the TTL window expires", async () => { + mockedList.mockResolvedValue([{ name: "default", isDefault: true }]); + let clock = 1000; + const now = () => clock; + + await getHermesPickerModels({ binaryPath: "hermes-test-3", ttlMs: 1_000, now }); + clock += 1_001; // past the 1s TTL + await getHermesPickerModels({ binaryPath: "hermes-test-3", ttlMs: 1_000, now }); + + expect(mockedList).toHaveBeenCalledTimes(2); + }); + + it("single-flights concurrent requests for the same binaryPath", async () => { + let resolveFetch: (v: HermesProfileSummary[]) => void = () => {}; + mockedList.mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + const p1 = getHermesPickerModels({ binaryPath: "hermes-test-4" }); + const p2 = getHermesPickerModels({ binaryPath: "hermes-test-4" }); + + resolveFetch([{ name: "default", isDefault: true }]); + const [r1, r2] = await Promise.all([p1, p2]); + + expect(r1).toEqual(r2); + expect(mockedList).toHaveBeenCalledTimes(1); + }); + + it("degrades to an empty array (never throws) when the CLI fetch fails, and caches the empty result", async () => { + mockedList.mockRejectedValue(new Error("hermes profile list failed: binary not found")); + let clock = 1000; + const now = () => clock; + + const first = await getHermesPickerModels({ binaryPath: "hermes-test-5", ttlMs: 60_000, now }); + expect(first).toEqual([]); + + clock += 10; // still inside TTL + const second = await getHermesPickerModels({ binaryPath: "hermes-test-5", ttlMs: 60_000, now }); + expect(second).toEqual([]); + + // The failure result is cached too — only one spawn attempt within the TTL window. + expect(mockedList).toHaveBeenCalledTimes(1); + }); + + it("resolves binaryPath from HERMES_BIN env when not explicitly provided", async () => { + const prevEnv = process.env.HERMES_BIN; + process.env.HERMES_BIN = "/custom/path/to/hermes"; + mockedList.mockResolvedValue([]); + + try { + await getHermesPickerModels(); + expect(mockedList).toHaveBeenCalledWith({ binaryPath: "/custom/path/to/hermes" }); + } finally { + if (prevEnv === undefined) delete process.env.HERMES_BIN; + else process.env.HERMES_BIN = prevEnv; + } + }); + + it("caches distinct binaryPaths independently", async () => { + mockedList.mockResolvedValue([{ name: "default", isDefault: true }]); + + await getHermesPickerModels({ binaryPath: "hermes-test-6a" }); + await getHermesPickerModels({ binaryPath: "hermes-test-6b" }); + + expect(mockedList).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/dashboard/src/__tests__/register-model-routes-hermes.test.ts b/packages/dashboard/src/__tests__/register-model-routes-hermes.test.ts new file mode 100644 index 0000000000..8ca5885c4d --- /dev/null +++ b/packages/dashboard/src/__tests__/register-model-routes-hermes.test.ts @@ -0,0 +1,186 @@ +/* +FNXC:ModelCatalog 2026-07-07-09:10: +FN-7636 regression coverage: Hermes-configured models (`hermes profile list`, +mocked at the `../runtime-provider-probes.js` boundary) must appear +additively under provider "hermes" in `/api/models`, deduped by provider/id +with existing rows always winning collisions, never displacing/filtering out +unrelated rows, and degrading to zero Hermes rows (HTTP 200, existing rows +intact) when the underlying façade throws. Also covers the caching +contract (single spawn per request cycle) and the configuredProviders +allow-list interaction ("hermes" only added when rows were contributed). +*/ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Router } from "express"; + +vi.mock("../runtime-provider-probes.js", () => ({ + listHermesProviderProfiles: vi.fn(), +})); + +import { listHermesProviderProfiles } from "../runtime-provider-probes.js"; +import { registerModelRoutes } from "../routes/register-model-routes.js"; +import { __resetHermesPickerModelsCacheForTests } from "../hermes-model-cache.js"; + +const mockedList = vi.mocked(listHermesProviderProfiles); + +afterEach(() => { + vi.clearAllMocks(); + __resetHermesPickerModelsCacheForTests(); +}); + +function setup(availableModels: Array<{ provider: string; id: string; name: string; reasoning: boolean; contextWindow: number }>) { + const getHandlers = new Map void }) => Promise>(); + const router = { + get: vi.fn((path: string, handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise) => { + getHandlers.set(path, handler); + }), + } as unknown as Router; + + const store = { + getGlobalSettingsStore: () => ({ + getSettings: vi.fn().mockResolvedValue({ useDroidCli: true }), + }), + getSettingsFast: vi.fn().mockResolvedValue({}), + }; + + const runtimeLogger = { + child: vi.fn(() => ({ warn: vi.fn() })), + }; + + const modelRegistry = { + refresh: vi.fn(), + getAvailable: vi.fn(() => availableModels), + }; + + registerModelRoutes({ + router, + store: store as never, + runtimeLogger: runtimeLogger as never, + options: { modelRegistry } as never, + } as never); + + return { handler: getHandlers.get("/models")! }; +} + +async function callModels(handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise) { + const json = vi.fn(); + await handler({}, { json }); + return json.mock.calls[0][0] as { models: Array<{ provider: string; id: string; name: string }> }; +} + +// Use a droid-cli row (with useDroidCli:true in settings) as the "existing +// row that must survive" baseline: unlike "openai", its presence in the +// final response does not depend on the test runner's ambient auth-storage +// files (~/.fusion/agent/auth.json etc.), which vary across environments. +const OPENAI_MODEL = { provider: "droid-cli", id: "droid/model", name: "Droid", reasoning: false, contextWindow: 0 }; + +describe("register-model-routes: Hermes additive surfacing", () => { + it("adds zero Hermes rows and leaves existing rows unchanged when no profiles are configured", async () => { + mockedList.mockResolvedValue([]); + const { handler } = setup([OPENAI_MODEL]); + + const response = await callModels(handler); + + expect(response.models).toEqual([OPENAI_MODEL]); + expect(response.models.some((m) => m.provider === "hermes")).toBe(false); + }); + + it("surfaces a single profile with a model, mapped to a hermes/ row, alongside existing rows", async () => { + mockedList.mockResolvedValue([{ name: "default", model: "MiniMax-M3", isDefault: true }]); + const { handler } = setup([OPENAI_MODEL]); + + const response = await callModels(handler); + + expect(response.models).toContainEqual(OPENAI_MODEL); + expect(response.models).toContainEqual({ + provider: "hermes", + id: "default", + name: "default (MiniMax-M3)", + reasoning: false, + contextWindow: 0, + }); + }); + + it("surfaces a profile without a model using the profile name only", async () => { + mockedList.mockResolvedValue([{ name: "bare-profile", isDefault: false }]); + const { handler } = setup([]); + + const response = await callModels(handler); + + expect(response.models).toContainEqual({ + provider: "hermes", + id: "bare-profile", + name: "bare-profile", + reasoning: false, + contextWindow: 0, + }); + }); + + it("surfaces multiple profiles as multiple rows without dropping existing rows", async () => { + mockedList.mockResolvedValue([ + { name: "default", model: "MiniMax-M3", isDefault: true }, + { name: "work", model: "claude-sonnet-4-5", isDefault: false }, + ]); + const { handler } = setup([OPENAI_MODEL]); + + const response = await callModels(handler); + + expect(response.models.some((m) => m.provider === "droid-cli")).toBe(true); + expect(response.models.filter((m) => m.provider === "hermes")).toHaveLength(2); + }); + + it("keeps the existing row when a Hermes-derived id collides with an already-present row (existing row wins)", async () => { + const existingHermesRow = { provider: "hermes", id: "default", name: "Pre-existing Hermes Row", reasoning: true, contextWindow: 999 }; + mockedList.mockResolvedValue([{ name: "default", model: "MiniMax-M3", isDefault: true }]); + const { handler } = setup([OPENAI_MODEL, existingHermesRow]); + + const response = await callModels(handler); + + const hermesRows = response.models.filter((m) => m.provider === "hermes" && m.id === "default"); + expect(hermesRows).toHaveLength(1); + expect(hermesRows[0]).toEqual(existingHermesRow); + }); + + it("degrades to zero Hermes rows and returns HTTP 200 with existing rows intact when the façade throws", async () => { + mockedList.mockRejectedValue(new Error("hermes profile list failed: binary not found")); + const { handler } = setup([OPENAI_MODEL]); + + const response = await callModels(handler); + + expect(response.models).toEqual([OPENAI_MODEL]); + expect(response.models.some((m) => m.provider === "hermes")).toBe(false); + }); + + it("does not include hermes in configuredProviders (and thus contributes no rows) when zero profiles exist", async () => { + mockedList.mockResolvedValue([]); + const { handler } = setup([{ provider: "hermes", id: "unconfigured-registry-row", name: "Should be filtered", reasoning: false, contextWindow: 0 }]); + + const response = await callModels(handler); + + // A hermes-provider row surfaced solely via modelRegistry.getAvailable() + // (not via the Hermes profile façade) is still subject to the + // configuredProviders allow-list: with zero Hermes profiles configured, + // "hermes" is never added to the allow-list, so this row is filtered. + expect(response.models.some((m) => m.provider === "hermes")).toBe(false); + }); + + it("calls the Hermes façade at most once per /api/models request (single-flight cache boundary honored)", async () => { + mockedList.mockResolvedValue([{ name: "default", isDefault: true }]); + const { handler } = setup([OPENAI_MODEL]); + + await callModels(handler); + + expect(mockedList).toHaveBeenCalledTimes(1); + }); + + it("serves a second request within the cache TTL without spawning again", async () => { + mockedList.mockResolvedValue([{ name: "default", isDefault: true }]); + const { handler } = setup([OPENAI_MODEL]); + + await callModels(handler); + await callModels(handler); + + // Both requests hit register-model-routes' default (unconfigured ttl -> + // module default ~60s) cache window, so only the first should spawn. + expect(mockedList).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/dashboard/src/hermes-model-cache.ts b/packages/dashboard/src/hermes-model-cache.ts new file mode 100644 index 0000000000..2af63d3715 --- /dev/null +++ b/packages/dashboard/src/hermes-model-cache.ts @@ -0,0 +1,168 @@ +/** + * Hermes profile → model-picker mapping, behind a short-TTL, single-flight + * cache so `/api/models` never spawns the `hermes` CLI per request. + * + * FNXC:ModelCatalog 2026-07-07-09:00: + * FN-7636 (deferred item 1 of FN-7630/GitHub #1931): Hermes-configured models + * (`hermes profile list`) must appear additively in Fusion's `/api/models` + * picker under the stable `"hermes"` provider id (== HERMES_RUNTIME_ID in the + * plugin's index.ts), so selections route to the Hermes runtime. The source + * of truth, `listHermesProviderProfiles` (façade over `listHermesProfiles`), + * spawns the real `hermes` binary as a subprocess — expensive and slow + * relative to an HTTP handler, and `/api/models` can be polled frequently by + * dashboard clients. This module owns two contracts: + * 1. A deterministic, stable profile→model-id mapping (id = profile name) + * so picker selections remain stable across requests/restarts. + * 2. A per-binaryPath TTL cache (default 60s) with single-flight + * de-duplication of concurrent in-flight fetches, so parallel + * `/api/models` requests spawn the CLI at most once per TTL window. + * A missing/failed `hermes` binary (ENOENT, non-zero exit, timeout) must + * degrade to an empty model list — never throw — so `/api/models` always + * returns HTTP 200 with existing rows intact. The empty result is cached + * briefly too, so a persistently-missing binary does not turn into a + * spawn-per-request storm. + */ + +import { listHermesProviderProfiles, type HermesProfileSummary } from "./runtime-provider-probes.js"; + +/** Stable model-picker row shape emitted for a Hermes profile. */ +export interface HermesPickerModel { + provider: "hermes"; + id: string; + name: string; + reasoning: boolean; + contextWindow: number; +} + +/** The picker provider id used for all Hermes-derived model rows. */ +export const HERMES_PICKER_PROVIDER_ID = "hermes" as const; + +/** Default cache TTL for Hermes profile listings, in milliseconds. */ +const DEFAULT_TTL_MS = 60_000; + +/** + * Map `hermes profile list` output into the stable `/api/models` row shape. + * + * The profile `name` is used as the stable model id (it is the CLI's own + * unique identifier). The display `name` label incorporates the configured + * `model` when present so users can tell profiles apart by underlying model, + * e.g. `"default (MiniMax-M3)"`. `reasoning`/`contextWindow` are unknown at + * this layer (Hermes does not report them via `profile list`), so they + * default to `false`/`0` — enrichment is a documented follow-up, not part of + * this task's scope. + * + * Profiles that map to the same id (duplicate profile names — should not + * normally happen, but the CLI's output is free text) are de-duplicated, + * keeping the first occurrence. + */ +export function hermesProfilesToModels(profiles: readonly HermesProfileSummary[]): HermesPickerModel[] { + const seen = new Set(); + const models: HermesPickerModel[] = []; + + for (const profile of profiles) { + const id = profile.name.trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + + const name = profile.model ? `${id} (${profile.model})` : id; + + models.push({ + provider: HERMES_PICKER_PROVIDER_ID, + id, + name, + reasoning: false, + contextWindow: 0, + }); + } + + return models; +} + +interface CacheEntry { + /** Timestamp (ms) at which this entry was populated. */ + fetchedAt: number; + /** The resolved (possibly empty, on failure) model list. */ + models: HermesPickerModel[]; +} + +/** Per-binaryPath cache of the most recently resolved Hermes picker models. */ +const cache = new Map(); + +/** Per-binaryPath in-flight fetch promise, for single-flight de-duplication. */ +const inFlight = new Map>(); + +/** + * Reset all cached/in-flight state. Test-only escape hatch — production code + * should never need this since entries expire naturally via TTL. + */ +export function __resetHermesPickerModelsCacheForTests(): void { + cache.clear(); + inFlight.clear(); +} + +export interface GetHermesPickerModelsOptions { + /** Override the hermes binary path. Defaults to `HERMES_BIN` env, then `"hermes"`. */ + binaryPath?: string; + /** Cache TTL in milliseconds. Defaults to 60s. */ + ttlMs?: number; + /** Injectable clock (ms epoch) for deterministic tests. Defaults to `Date.now`. */ + now?: () => number; +} + +/** + * Resolve the Hermes binary path: explicit override, then `HERMES_BIN` env, + * then the bare `"hermes"` command (resolved via PATH by the CLI spawn layer). + */ +function resolveBinaryPath(explicit?: string): string { + return explicit ?? process.env.HERMES_BIN ?? "hermes"; +} + +/** + * Fetch Hermes-configured models for the model picker, behind a short-TTL, + * single-flight cache keyed by binary path. + * + * Never throws: a `listHermesProviderProfiles` failure (missing binary, + * non-zero exit, timeout) resolves to `[]`, which is itself cached briefly + * (same TTL) so a persistently-broken/missing binary does not spawn the CLI + * on every call. + */ +export async function getHermesPickerModels( + opts?: GetHermesPickerModelsOptions, +): Promise { + const binaryPath = resolveBinaryPath(opts?.binaryPath); + const ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS; + const now = opts?.now ?? Date.now; + const nowMs = now(); + + const cached = cache.get(binaryPath); + if (cached && nowMs - cached.fetchedAt < ttlMs) { + return cached.models; + } + + const existingInFlight = inFlight.get(binaryPath); + if (existingInFlight) { + return existingInFlight; + } + + const fetchPromise = (async (): Promise => { + try { + const profiles = await listHermesProviderProfiles({ binaryPath }); + return hermesProfilesToModels(profiles); + } catch { + // Degrade to zero Hermes rows on any spawn/parse failure (ENOENT, + // non-zero exit, timeout) — never let a Hermes error propagate into + // /api/models. See FNXC:ModelCatalog comment at the top of this file. + return []; + } + })(); + + inFlight.set(binaryPath, fetchPromise); + + try { + const models = await fetchPromise; + cache.set(binaryPath, { fetchedAt: now(), models }); + return models; + } finally { + inFlight.delete(binaryPath); + } +} diff --git a/packages/dashboard/src/routes/register-model-routes.ts b/packages/dashboard/src/routes/register-model-routes.ts index a7bf2ea747..651fd70200 100644 --- a/packages/dashboard/src/routes/register-model-routes.ts +++ b/packages/dashboard/src/routes/register-model-routes.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { customProviderRegistryKey, mergeSupplementalAnthropicModels, resolvePlanningSettingsModel } from "@fusion/core"; import type { CustomProvider } from "@fusion/core"; import { ApiError } from "../api-error.js"; +import { getHermesPickerModels, HERMES_PICKER_PROVIDER_ID } from "../hermes-model-cache.js"; import type { AuthStorageLike } from "../routes.js"; import type { ApiRouteRegistrar } from "./types.js"; @@ -255,6 +256,33 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => { models = models.filter((m) => m.provider !== "cursor-cli"); } + /* + FNXC:ModelCatalog 2026-07-07-09:05: + FN-7636 (deferred item 1 of FN-7630/GitHub #1931): additively surface + Hermes-configured models (`hermes profile list`) under the stable + "hermes" provider id so picker selections route to the Hermes runtime + (HERMES_RUNTIME_ID). Fetched through getHermesPickerModels, which is + backed by a short-TTL, single-flight cache — this call NEVER spawns the + `hermes` CLI per request, and NEVER throws (a missing/failed binary + degrades to []). Hermes rows are merged respecting the existing + seenModelKeys provider/id dedup so an existing row always wins over a + colliding Hermes row — this is purely additive and must never displace, + overwrite, or filter out an existing row. + */ + const hermesModels = await getHermesPickerModels(); + // Track "configured" by profile presence, not by how many rows survived + // the seenModelKeys dedup: even when every Hermes-derived id collides + // with an already-present row (existing row wins, see FN-7636 Surface + // Enumeration), the user still has Hermes profiles configured, so the + // "hermes" provider must remain selectable below. + const hermesRowsAdded = hermesModels.length > 0; + for (const hermesModel of hermesModels) { + const key = `${hermesModel.provider}/${hermesModel.id}`; + if (seenModelKeys.has(key)) continue; + seenModelKeys.add(key); + models.push(hermesModel); + } + // Filter to only providers the user has explicitly configured in Fusion. // getAvailable() checks supplemental credential stores (Codex CLI, // Claude Code, env vars) which surface providers the user may not @@ -277,6 +305,11 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => { if (useClaudeCli) configuredProviders.add("pi-claude-cli"); if (useDroidCli) configuredProviders.add("droid-cli"); if (useLlamaCpp) configuredProviders.add("llama-server"); + // FNXC:ModelCatalog 2026-07-07-09:05 (FN-7636): only allow-list "hermes" + // through the final filter when Hermes rows were actually contributed + // above, mirroring the useClaudeCli/useDroidCli toggle pattern (Hermes + // has no separate settings toggle — profile presence IS the signal). + if (hermesRowsAdded) configuredProviders.add(HERMES_PICKER_PROVIDER_ID); // Custom providers are configured in Fusion's global settings rather than // the auth.json/models.json stores, so add their registry keys explicitly. for (const provider of customProviders) {