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:
7
.changeset/fn-7636-hermes-picker-models.md
Normal file
7
.changeset/fn-7636-hermes-picker-models.md
Normal 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.
|
||||
@@ -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`
|
||||
|
||||
158
packages/dashboard/src/__tests__/hermes-model-cache.test.ts
Normal file
158
packages/dashboard/src/__tests__/hermes-model-cache.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
168
packages/dashboard/src/hermes-model-cache.ts
Normal file
168
packages/dashboard/src/hermes-model-cache.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user