Skip to content

Commit f196da1

Browse files
committed
fix: restore Hermes Web model discovery fallbacks
1 parent b6125eb commit f196da1

2 files changed

Lines changed: 140 additions & 5 deletions

File tree

extension/app.js

Lines changed: 118 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
estimateTokens,
66
groupSessionsForMenu,
77
messageDisplayText,
8+
isModelRuntimeSelectable,
9+
normalizeHermesModels,
810
normalizeHermesSessions,
911
normalizeHermesSkills,
1012
normalizeToolActivity,
@@ -21,7 +23,19 @@ import {
2123
normalizeColorMode,
2224
resolveColorMode,
2325
} from './lib/appearance-themes.mjs';
24-
import { discoverModelsFromRegistry } from './lib/model-discovery.mjs';
26+
import {
27+
MODEL_CATALOG_CACHE_STORAGE_KEY,
28+
dashboardModelDiscoveryBaseUrl,
29+
discoverModelsFromDashboard,
30+
discoverModelsFromRegistry,
31+
discoverModelsFromSessions,
32+
mergeModelsWithRegistry,
33+
modelCatalogCacheKey,
34+
modelCatalogRefreshDecision,
35+
normalizeCachedModelCatalog,
36+
selectModelCatalogFallback,
37+
shouldTrySessionModelFallback,
38+
} from './lib/model-discovery.mjs';
2539
import { extractMediaTags, resolveImageSource, resolvedGeneratedImageSources, stripGeneratedImageEchoes } from './lib/image-render.mjs';
2640
import { modelLockRequestOutcome, readHermesSse, runSteerFailureState } from './lib/fulltab-runtime.mjs';
2741
import { createDiffusionCanvas } from './lib/diffusion-canvas.mjs';
@@ -867,6 +881,9 @@ function renderModelPicker(query = '') {
867881
const button = document.createElement('button');
868882
button.type = 'button';
869883
button.className = `model-choice ${model.id === current.id ? 'selected' : ''}`.trim();
884+
const selectable = isModelRuntimeSelectable(model);
885+
button.disabled = !selectable;
886+
if (!selectable) button.title = 'Observed from session history; not advertised as a requestable Hermes model.';
870887
const copy = document.createElement('span');
871888
const provider = document.createElement('small');
872889
const label = document.createElement('strong');
@@ -876,18 +893,114 @@ function renderModelPicker(query = '') {
876893
selected.textContent = model.id === current.id ? '✓' : '';
877894
copy.append(provider, label);
878895
button.append(copy, selected);
879-
button.addEventListener('click', () => selectModel(model));
896+
if (selectable) button.addEventListener('click', () => selectModel(model));
880897
els.modelList.append(button);
881898
}
882899
}
883900
if (!els.modelList.childElementCount) els.modelList.textContent = 'No models found.';
884901
renderModelRuntimeOptions();
885902
}
886903

904+
async function readCachedModelCatalog() {
905+
try {
906+
const stored = await chrome.storage.local.get([MODEL_CATALOG_CACHE_STORAGE_KEY]);
907+
const key = modelCatalogCacheKey({
908+
gatewayMode: settings.gatewayMode,
909+
gatewayUrl: settings.gatewayUrl,
910+
profile: settings.activeProfile,
911+
});
912+
return normalizeCachedModelCatalog(stored?.[MODEL_CATALOG_CACHE_STORAGE_KEY]?.[key]?.models);
913+
} catch {
914+
return [];
915+
}
916+
}
917+
918+
async function writeCachedModelCatalog(models = []) {
919+
const canonicalModels = normalizeCachedModelCatalog(models);
920+
if (!canonicalModels.length) return;
921+
try {
922+
const stored = await chrome.storage.local.get([MODEL_CATALOG_CACHE_STORAGE_KEY]);
923+
const cache = stored?.[MODEL_CATALOG_CACHE_STORAGE_KEY] && typeof stored[MODEL_CATALOG_CACHE_STORAGE_KEY] === 'object'
924+
? stored[MODEL_CATALOG_CACHE_STORAGE_KEY]
925+
: {};
926+
const key = modelCatalogCacheKey({
927+
gatewayMode: settings.gatewayMode,
928+
gatewayUrl: settings.gatewayUrl,
929+
profile: settings.activeProfile,
930+
});
931+
cache[key] = { savedAt: Date.now(), models: canonicalModels };
932+
await chrome.storage.local.set({ [MODEL_CATALOG_CACHE_STORAGE_KEY]: cache });
933+
} catch {
934+
// Catalog caching is resilience-only; storage failures must not block sync.
935+
}
936+
}
937+
887938
async function loadModels({ refresh = false } = {}) {
888-
const result = await discoverModelsFromRegistry({ apiFetch: client.fetch, readJsonResponse: client.readJson, refresh });
889-
if (!result.ok) throw new Error(result.error || 'Model discovery failed.');
890-
availableModels = result.models;
939+
const previousSelectedModel = settings.model;
940+
let registryModels = [];
941+
let registrySource = '';
942+
943+
const registryResult = await discoverModelsFromRegistry({ apiFetch: client.fetch, readJsonResponse: client.readJson, refresh });
944+
if (registryResult.ok && registryResult.models.length) {
945+
registryModels = normalizeHermesModels(registryResult.models, settings.model);
946+
registrySource = 'registry';
947+
} else {
948+
const dashboardResult = await discoverModelsFromDashboard({
949+
baseUrl: dashboardModelDiscoveryBaseUrl({
950+
gatewayMode: settings.gatewayMode,
951+
gatewayUrl: settings.gatewayUrl,
952+
}),
953+
refresh,
954+
profile: settings.activeProfile,
955+
});
956+
if (dashboardResult.ok && dashboardResult.models.length) {
957+
registryModels = normalizeHermesModels(dashboardResult.models, settings.model);
958+
registrySource = 'dashboard';
959+
} else {
960+
const cachedCatalogModels = await readCachedModelCatalog();
961+
const cachedFallback = selectModelCatalogFallback({ cachedModels: cachedCatalogModels });
962+
if (cachedFallback.models.length) {
963+
registryModels = normalizeHermesModels(cachedFallback.models, settings.model);
964+
registrySource = cachedFallback.source;
965+
} else {
966+
const response = await client.fetch('/v1/models', { method: 'GET' });
967+
const payload = await client.readJson(response);
968+
if (!response.ok) throw new Error(payload?.error?.message || payload?.error || `Model list failed (${response.status})`);
969+
registryModels = normalizeHermesModels(payload, settings.model);
970+
registrySource = 'v1';
971+
}
972+
}
973+
}
974+
975+
if (registryModels.length && ['registry', 'dashboard'].includes(registrySource)) {
976+
await writeCachedModelCatalog(registryModels);
977+
}
978+
979+
if (shouldTrySessionModelFallback({
980+
registryModels,
981+
registrySource,
982+
defaultModelId: 'hermes-agent',
983+
})) {
984+
const sessionResult = await discoverModelsFromSessions({ apiFetch: client.fetch, readJsonResponse: client.readJson });
985+
if (sessionResult.ok && sessionResult.models.length) {
986+
registryModels = normalizeHermesModels(
987+
mergeModelsWithRegistry({ registryModels, sessionModels: sessionResult.models }),
988+
settings.model,
989+
);
990+
registrySource = 'sessions';
991+
}
992+
}
993+
994+
const refreshDecision = modelCatalogRefreshDecision({
995+
previousSelectedModel,
996+
discoveredModels: registryModels,
997+
refresh,
998+
});
999+
if (refreshDecision.keepPreviousSelection) {
1000+
registryModels = normalizeHermesModels(registryModels, refreshDecision.selectedModel);
1001+
}
1002+
1003+
availableModels = registryModels;
8911004
const current = effectiveModel();
8921005
els.modelLabel.textContent = current.label;
8931006
renderModelPicker(els.modelSearch.value);

tests/full-tab.test.mjs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,28 @@ test('full-tab model locks distinguish accepted, legacy, mismatch, and hard fail
304304
});
305305
});
306306

307+
test('Hermes Web model discovery mirrors the side-panel fallback chain', () => {
308+
const js = read('extension/app.js');
309+
const loadModels = js.match(/async function loadModels\(\{ refresh = false \} = \{\}\)\s*\{([\s\S]*?)\n\}/)?.[1] || '';
310+
const renderModelPicker = js.match(/function renderModelPicker\(query = ''\)\s*\{([\s\S]*?)\n\}/)?.[1] || '';
311+
312+
const registry = loadModels.indexOf('discoverModelsFromRegistry');
313+
const dashboard = loadModels.indexOf('discoverModelsFromDashboard');
314+
const cache = loadModels.indexOf('readCachedModelCatalog');
315+
const openAiCompat = loadModels.indexOf("client.fetch('/v1/models'");
316+
const sessions = loadModels.indexOf('discoverModelsFromSessions');
317+
318+
assert.ok(registry >= 0, 'Web should try the canonical API registry first');
319+
assert.ok(dashboard > registry, 'Web should fall back to the dashboard registry after an API registry miss');
320+
assert.ok(cache > dashboard, 'Web should retain the side-panel canonical cache fallback');
321+
assert.ok(openAiCompat > cache, 'Web should fall back to the OpenAI-compatible model list after the cache');
322+
assert.ok(sessions > openAiCompat, 'Web should expand sparse legacy inventories from session history');
323+
assert.match(loadModels, /writeCachedModelCatalog/);
324+
assert.match(loadModels, /modelCatalogRefreshDecision/);
325+
assert.match(renderModelPicker, /isModelRuntimeSelectable/);
326+
assert.match(renderModelPicker, /button\.disabled\s*=\s*!selectable/);
327+
});
328+
307329
test('full-tab run steer failures distinguish stale runs from missing gateway support', () => {
308330
assert.equal(typeof fulltabRuntime.runSteerFailureState, 'function');
309331
assert.deepEqual(fulltabRuntime.runSteerFailureState({

0 commit comments

Comments
 (0)