fix: discover actual Hermes provider models and route selections (2.5.2)

This commit is contained in:
Michael committed 2026-09-05 09:06:36 +02:00
1 parent 5201742f45
commit c9b44433ac
8 files changed
+89 -22

No files matched your search

+1 -1
View File
@@ -15,7 +15,7 @@ export function ModelSelect({ label, value, models, defaultLabel, onChange }: {
<select id={id} className="select" value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">{defaultLabel}</option>
{value && !models.some((m) => m.id === value) && <option value={value}>{value} (configuré)</option>}
{models.map((m) => <option key={m.id} value={m.id}>{m.id}{m.provider || m.owned_by ? ` · ${m.provider || m.owned_by}` : ''}</option>)}
{models.map((m) => <option key={m.id} value={m.id} disabled={m.available === false}>{m.name || m.id}{m.provider || m.owned_by ? ` · ${m.provider || m.owned_by}` : ''}{m.available === false ? ' (indisponible)' : ''}</option>)}
</select>
<button type="button" className="btn small" aria-expanded={manual} onClick={() => setManual(!manual)}>
{manual ? 'Masquer la saisie manuelle' : 'Saisir un autre identifiant'}
+3 -2
View File
@@ -81,6 +81,7 @@ export function SettingsDrawer({ onClose }: Props) {
const hermesModels = useHermes((s) => s.models);
const modelsLoading = useHermes((s) => s.modelsLoading);
const modelsError = useHermes((s) => s.modelsError);
const modelsNotice = useHermes((s) => s.modelsNotice);
const refreshHermesModels = useHermes((s) => s.refreshModels);
const hermesWebhook = useHermes((s) => s.webhook);
const hermesConnect = useHermes((s) => s.connect);
@@ -224,12 +225,12 @@ export function SettingsDrawer({ onClose }: Props) {
</div>
</div>
<div className="field">
<button className="btn small" disabled={modelsLoading} onClick={() => void refreshHermesModels()}>
<button className="btn small" disabled={modelsLoading} onClick={() => void refreshHermesModels(true)}>
{modelsLoading ? <Loader2 size={13} className="spin" /> : <RotateCcw size={13} />}
{modelsLoading ? 'Chargement des modèles…' : 'Actualiser les modèles'}
</button>
<span className="hint" role="status">{modelsLoading ? 'Interrogation du serveur Hermes…' : modelsError || (hermesModels.length ? `${hermesModels.length} modèle(s) disponible(s).` : 'Aucun modèle annoncé par le serveur.')}</span>
<span className="hint">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.</span>
<span className={modelsNotice ? 'test-result fail' : 'hint'}>{modelsNotice || 'Modèles des fournisseurs configurés dans Hermes. Les modèles sans accès sont indiqués comme indisponibles.'}</span>
</div>
<div className="grid-2">
<div className="field">
+35 -4
View File
@@ -31,6 +31,13 @@ const isRec = (v: unknown): v is Rec => !!v && typeof v === 'object' && !Array.i
export type ResolvedTransport = Exclude<HermesTransport, 'auto'>;
/** 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<unknown>(`/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<string, HermesModel>();
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<HermesSkill[]> {
const payload = await this.request<unknown>('/v1/skills');
return extractArray<HermesSkill>(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';
+2
View File
@@ -35,6 +35,8 @@ export interface HermesHealth {
export interface HermesModel {
id: string;
name?: string;
available?: boolean;
owned_by?: string;
provider?: string;
[key: string]: unknown;
+8 -6
View File
@@ -35,7 +35,8 @@ interface HermesStore {
models: HermesModel[];
modelsLoading: boolean;
modelsError: string | null;
refreshModels: () => Promise<void>;
modelsNotice: string | null;
refreshModels: (refresh?: boolean) => Promise<void>;
skills: HermesSkill[];
toolsets: HermesToolset[];
sessions: HermesSession[];
@@ -93,6 +94,7 @@ export const useHermes = create<HermesStore>((set, get) => ({
models: [],
modelsLoading: false,
modelsError: null,
modelsNotice: null,
skills: [],
toolsets: [],
sessions: [],
@@ -108,7 +110,7 @@ export const useHermes = create<HermesStore>((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<HermesStore>((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 {