FN-7636: surface Hermes runtime models in /api/models picker

Adds a short-TTL, single-flight Hermes model cache and merges Hermes profile-list models additively into the dashboard's /api/models endpoint under the hermes provider, without displacing existing entries.

- Add packages/dashboard/src/hermes-model-cache.ts: single-flight, short-TTL cache wrapping `hermes profile list` CLI output (no per-request spawn)
- Merge Hermes-provided models into register-model-routes.ts's /api/models response, deduped by provider/id, additive-only
- Add unit tests for the Hermes model cache and the /api/models route's Hermes merge behavior
- Document the new behavior in docs/settings-reference.md
- Add changeset (@runfusion/fusion: minor) describing the feature for release notes

Files changed:
 .changeset/fn-7636-hermes-picker-models.md         |   7 +
 docs/settings-reference.md                         |   2 +
 packages/dashboard/src/__tests__/hermes-model-cache.test.ts       | 158 +++++++++++++++++
 packages/dashboard/src/__tests__/register-model-routes-hermes.test.ts | 186 +++++++++++++++++++++
 packages/dashboard/src/hermes-model-cache.ts       | 168 +++++++++++++++++++
 packages/dashboard/src/routes/register-model-routes.ts  |  33 ++++
 6 files changed, 554 insertions(+)

Fusion-Task-Id: FN-7636

Fusion-Task-Lineage: c5ab3bba-37e4-416b-9325-667a8ef321dc

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-07 08:50:59 -07:00
parent 51e389199a
commit 5b243f1223
6 changed files with 554 additions and 0 deletions

View File

@@ -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.

View File

@@ -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`

View File

@@ -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<HermesProfileSummary[]>((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);
});
});

View File

@@ -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<string, (req: unknown, res: { json: (body: unknown) => void }) => Promise<void>>();
const router = {
get: vi.fn((path: string, handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise<void>) => {
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<void>) {
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/<name> 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);
});
});

View File

@@ -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<string>();
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<string, CacheEntry>();
/** Per-binaryPath in-flight fetch promise, for single-flight de-duplication. */
const inFlight = new Map<string, Promise<HermesPickerModel[]>>();
/**
* 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<HermesPickerModel[]> {
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<HermesPickerModel[]> => {
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);
}
}

View File

@@ -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) {