From c9b44433acadc79c659473c4ed9cfce827e947a6 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sat, 5 Sep 2026 09:06:36 +0200 Subject: [PATCH] fix: discover actual Hermes provider models and route selections (2.5.2) --- package-lock.json | 4 +-- package.json | 4 +-- src/components/settings/ModelSelect.tsx | 2 +- src/components/settings/SettingsDrawer.tsx | 5 +-- src/services/hermes/client.ts | 39 +++++++++++++++++--- src/services/hermes/types.ts | 2 ++ src/state/hermes.ts | 14 ++++---- tests/hermesModels.test.ts | 41 +++++++++++++++++++--- 8 files changed, 89 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0a09e71..88a13d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "eveflow", - "version": "2.5.1", + "version": "2.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "eveflow", - "version": "2.5.1", + "version": "2.5.2", "license": "MIT", "dependencies": { "@fontsource/orbitron": "^5.3.0", diff --git a/package.json b/package.json index 71e4d2c..a26b662 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "eveflow", - "version": "2.5.1", - "releaseVersion": "2.5.1", + "version": "2.5.2", + "releaseVersion": "2.5.2", "description": "JARVIS-style desktop HUD for Hermes Agent: voice, streaming runs, scheduled jobs, skills and telemetry", "main": "dist-electron/main.js", "private": true, diff --git a/src/components/settings/ModelSelect.tsx b/src/components/settings/ModelSelect.tsx index ce29fae..cc87adf 100644 --- a/src/components/settings/ModelSelect.tsx +++ b/src/components/settings/ModelSelect.tsx @@ -15,7 +15,7 @@ export function ModelSelect({ label, value, models, defaultLabel, onChange }: { {modelsLoading ? 'Interrogation du serveur Hermes…' : modelsError || (hermesModels.length ? `${hermesModels.length} modèle(s) disponible(s).` : 'Aucun modèle annoncé par le serveur.')} - Cette liste contient les modèles exposés par votre serveur Hermes. Si seul « hermes-agent » apparaît, les autres modèles doivent être configurés et exposés côté serveur. + {modelsNotice || 'Modèles des fournisseurs configurés dans Hermes. Les modèles sans accès sont indiqués comme indisponibles.'}
diff --git a/src/services/hermes/client.ts b/src/services/hermes/client.ts index 8170d81..6e2b690 100644 --- a/src/services/hermes/client.ts +++ b/src/services/hermes/client.ts @@ -31,6 +31,13 @@ const isRec = (v: unknown): v is Rec => !!v && typeof v === 'object' && !Array.i export type ResolvedTransport = Exclude; +/** Stored picker IDs include the provider so identical model names remain distinct. */ +export function modelSelection(value: string): { model?: string; provider?: string } { + const id = value.trim(); + const separator = id.indexOf('::'); + return separator > 0 ? { provider: id.slice(0, separator), model: id.slice(separator + 2) } : id ? { model: id } : {}; +} + /** * A web page (login portal, dashboard, reverse-proxy error) instead of JSON means the URL does not * point at the Hermes API. Returns a human explanation, or null when the body is not HTML. @@ -217,6 +224,29 @@ export class HermesClient { return [...models.values()]; } + async modelCatalog(refresh = false): Promise<{ models: HermesModel[]; notice: string | null }> { + let payload: unknown; + try { + payload = await this.request(`/api/model/options${refresh ? '?refresh=true' : ''}`, { timeoutMs: 30_000 }); + } catch (err) { + if (!(err instanceof HttpError) || ![404, 405].includes(err.status)) throw err; + return { models: await this.models(), notice: 'Ce serveur ne propose pas le catalogue IA (/api/model/options). Mettez Hermes à jour pour choisir le fournisseur et son modèle. La liste ci-dessous contient uniquement les alias de connexion.' }; + } + if (!isRec(payload) || !Array.isArray(payload.providers)) throw new Error('Catalogue IA Hermes invalide : liste des fournisseurs absente.'); + const models = new Map(); + for (const row of payload.providers) { + if (!isRec(row) || typeof row.slug !== 'string' || !row.slug.trim() || !Array.isArray(row.models)) continue; + const unavailable = Array.isArray(row.unavailable_models) ? row.unavailable_models : []; + for (const model of row.models) { + if (typeof model !== 'string' || !model.trim()) continue; + const id = `${row.slug}::${model}`; + models.set(id, { id, name: model, provider: typeof row.name === 'string' ? row.name : row.slug, + available: row.authenticated !== false && !unavailable.includes(model) }); + } + } + return { models: [...models.values()], notice: null }; + } + async skills(): Promise { const payload = await this.request('/v1/skills'); return extractArray(payload, ['skills', 'data', 'items']); @@ -304,11 +334,12 @@ export class HermesClient { // ── Runs ────────────────────────────────────────────────────────────────── - async startRun(body: { input: string; session_id?: string; instructions?: string; model?: string }): Promise<{ run_id: string; status: string }> { + async startRun(body: { input: string; session_id?: string; instructions?: string; model?: string; provider?: string }): Promise<{ run_id: string; status: string }> { const payload: Rec = { input: body.input }; if (body.session_id) payload.session_id = body.session_id; if (body.instructions) payload.instructions = body.instructions; if (body.model) payload.model = body.model; + if (body.provider) payload.provider = body.provider; return this.request<{ run_id: string; status: string }>('/v1/runs', { method: 'POST', body: payload, timeoutMs: 30_000 }); } @@ -402,7 +433,7 @@ export class HermesClient { input: options.text, session_id: plainSession(options.sessionId) || undefined, instructions: this.config.instructions || undefined, - model: this.config.model || undefined + ...modelSelection(this.config.model) }); const runId = run.run_id; if (isAborted()) { @@ -466,7 +497,7 @@ export class HermesClient { const realId = sessionId.slice(3); const body: Rec = { input: options.text }; if (this.config.instructions) body.instructions = this.config.instructions; - if (this.config.model) body.model = this.config.model; + Object.assign(body, modelSelection(this.config.model)); let streamed = ''; let finalText = ''; @@ -503,7 +534,7 @@ export class HermesClient { let fullText = ''; let toolsAllowed = useTools; for (let iteration = 0; iteration < 6 && !aborted(); iteration++) { - const payload: Rec = { model: this.config.model || 'hermes-agent', messages, stream: true }; + const payload: Rec = { model: 'hermes-agent', ...modelSelection(this.config.model), messages, stream: true }; if (toolsAllowed) { payload.tools = options.localToolDefinitions; payload.tool_choice = 'auto'; diff --git a/src/services/hermes/types.ts b/src/services/hermes/types.ts index e1935da..f4f648c 100644 --- a/src/services/hermes/types.ts +++ b/src/services/hermes/types.ts @@ -35,6 +35,8 @@ export interface HermesHealth { export interface HermesModel { id: string; + name?: string; + available?: boolean; owned_by?: string; provider?: string; [key: string]: unknown; diff --git a/src/state/hermes.ts b/src/state/hermes.ts index 45d7912..8f9f14a 100644 --- a/src/state/hermes.ts +++ b/src/state/hermes.ts @@ -35,7 +35,8 @@ interface HermesStore { models: HermesModel[]; modelsLoading: boolean; modelsError: string | null; - refreshModels: () => Promise; + modelsNotice: string | null; + refreshModels: (refresh?: boolean) => Promise; skills: HermesSkill[]; toolsets: HermesToolset[]; sessions: HermesSession[]; @@ -93,6 +94,7 @@ export const useHermes = create((set, get) => ({ models: [], modelsLoading: false, modelsError: null, + modelsNotice: null, skills: [], toolsets: [], sessions: [], @@ -108,7 +110,7 @@ export const useHermes = create((set, get) => ({ client: (modelOverride) => { const config = useSettings.getState().settings.hermes; // Without an explicit model, use the alias advertised by /v1/models (Hermes rejects unknown names). - const model = (modelOverride ?? '').trim() || config.model.trim() || get().models[0]?.id || ''; + const model = (modelOverride ?? '').trim() || config.model.trim(); return new HermesClient({ ...config, model }); }, @@ -166,17 +168,17 @@ export const useHermes = create((set, get) => ({ return connectInflight; }, - refreshModels: async () => { + refreshModels: async (refresh = false) => { const request = ++modelsRequest; const config = useSettings.getState().settings.hermes; const current = () => { const now = useSettings.getState().settings.hermes; return request === modelsRequest && now.url === config.url && now.apiKey === config.apiKey && now.sessionKey === config.sessionKey; }; - set({ models: [], modelsLoading: true, modelsError: null }); + set({ models: [], modelsLoading: true, modelsError: null, modelsNotice: null }); try { - const models = await new HermesClient(config).models(); - if (current()) set({ models }); + const { models, notice } = await new HermesClient(config).modelCatalog(refresh); + if (current()) set({ models, modelsNotice: notice }); } catch (err) { if (current()) set({ modelsError: (err as Error).message }); } finally { diff --git a/tests/hermesModels.test.ts b/tests/hermesModels.test.ts index c93d78a..c3b6f44 100644 --- a/tests/hermesModels.test.ts +++ b/tests/hermesModels.test.ts @@ -1,13 +1,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { HermesClient } from '../src/services/hermes/client'; -import { httpFetch } from '../src/lib/transport'; +import { httpFetch, httpStream } from '../src/lib/transport'; import { DEFAULT_SETTINGS, useSettings } from '../src/state/settings'; import { useHermes } from '../src/state/hermes'; import { createElement, act } from 'react'; import { createRoot } from 'react-dom/client'; import { ModelSelect } from '../src/components/settings/ModelSelect'; -vi.mock('../src/lib/transport', async (original) => ({ ...await original(), httpFetch: vi.fn() })); +vi.mock('../src/lib/transport', async (original) => ({ ...await original(), httpFetch: vi.fn(), httpStream: vi.fn() })); const config = { ...DEFAULT_SETTINGS.hermes, url: 'https://example.test/v1/', apiKey: ' test-key ' }; function respond(payload: unknown, status = 200) { vi.mocked(httpFetch).mockResolvedValue({ ok: status === 200, status, statusText: '', headers: {}, text: JSON.stringify(payload) }); @@ -15,6 +15,37 @@ function respond(payload: unknown, status = 200) { afterEach(() => { vi.restoreAllMocks(); useSettings.setState({ settings: DEFAULT_SETTINGS }); }); describe('Hermes models', () => { + it('reads the real provider inventory, keeps providers distinct and marks inaccessible models', async () => { + respond({ providers: [ + { slug: 'first', name: 'First', authenticated: true, models: ['same', 'locked'], unavailable_models: ['locked'] }, + { slug: 'second', name: 'Second', authenticated: false, models: ['same'] } + ] }); + const result = await new HermesClient(config).modelCatalog(true); + expect(httpFetch).toHaveBeenCalledWith(expect.objectContaining({ url: 'https://example.test/api/model/options?refresh=true' })); + expect(result.models.map(m => [m.id, m.available])).toEqual([['first::same', true], ['first::locked', false], ['second::same', false]]); + expect(result.notice).toBeNull(); + }); + it('explains old servers instead of presenting the connection alias as an LLM catalog', async () => { + vi.mocked(httpFetch).mockResolvedValueOnce({ ok: false, status: 404, statusText: '', headers: {}, text: '' }) + .mockResolvedValueOnce({ ok: true, status: 200, statusText: '', headers: {}, text: JSON.stringify({ data: [{ id: 'hermes-agent' }] }) }); + const result = await new HermesClient(config).modelCatalog(); + expect(result.models).toEqual([{ id: 'hermes-agent' }]); + expect(result.notice).toContain('Mettez Hermes à jour'); + }); + it.each(['runs', 'sessions', 'completions'] as const)('sends provider and actual model separately over %s', async (transport) => { + const requests: unknown[] = []; + vi.mocked(httpFetch).mockImplementation(async req => { + requests.push(JSON.parse(req.body as string)); + throw new Error('stop'); + }); + vi.mocked(httpStream).mockImplementation(async req => { + requests.push(JSON.parse(req.body as string)); + throw new Error('stop'); + }); + const send = new HermesClient({ ...config, model: 'custom:local::real-model' }).send({ text: 'Bonjour', sessionId: 'hs:test', history: [], onEvent: vi.fn() }, transport); + await expect(send.result).rejects.toThrow('stop'); + expect(requests[0]).toMatchObject({ model: 'real-model', provider: 'custom:local' }); + }); it('uses authenticated discovery and preserves provider labels, order and unique valid IDs', async () => { respond({ data: [{ id: 'b', provider: 'provider-b' }, { id: 'a' }, { id: 'b' }, {}, ' c '] }); expect(await new HermesClient(config).models()).toEqual([{ id: 'b', provider: 'provider-b' }, { id: 'a' }, { id: 'c' }]); @@ -38,12 +69,12 @@ describe('Hermes models', () => { }); it('discards results from a previous server or an older refresh', async () => { useSettings.setState({ settings: { ...DEFAULT_SETTINGS, hermes: config } }); - let finish!: (value: { id: string }[]) => void; - vi.spyOn(HermesClient.prototype, 'models').mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })).mockResolvedValue([{ id: 'new' }]); + let finish!: (value: { models: { id: string }[]; notice: null }) => void; + vi.spyOn(HermesClient.prototype, 'modelCatalog').mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })).mockResolvedValue({ models: [{ id: 'new' }], notice: null }); const old = useHermes.getState().refreshModels(); useSettings.setState({ settings: { ...DEFAULT_SETTINGS, hermes: { ...config, url: 'https://new.test' } } }); await useHermes.getState().refreshModels(); - finish([{ id: 'old' }]); + finish({ models: [{ id: 'old' }], notice: null }); await old; expect(useHermes.getState().models).toEqual([{ id: 'new' }]); });