fix: warn on provider/model mismatch, surface auth errors (#266)
* fix: warn on provider/model mismatch, surface auth errors (#266) Fixes #266 — WebUI silently ignores provider/model selection mismatch. The problem: selecting an OpenRouter (or Anthropic/OpenAI) model while Hermes is configured for a different provider (e.g. local Ollama) sends the request to the wrong endpoint, which returns a 401 Unauthorized error with no UI indication of why. Three-layer fix: 1. api/streaming.py — detect 401/auth errors explicitly Added is_auth_error detection covering '401', 'AuthenticationError', 'authentication', 'unauthorized', 'invalid api key', and the specific Ollama error string 'no cookie auth credentials'. Auth errors emit apperror with type='auth_mismatch' and a hint pointing to 'hermes model'. 2. static/ui.js — expose active_provider and warn on selection - populateModelDropdown() stores data.active_provider from /api/models as window._activeProvider (the field was already in the response but the frontend never used it) - New _checkProviderMismatch(modelId) helper: compares the selected model's slash-prefix (e.g. 'openai/' from 'openai/gpt-4o') against the active provider. Skips the check for 'openrouter' and 'custom' to avoid false positives on configs that legitimately route any model. 3. static/boot.js — warn on model dropdown change modelSelect.onchange calls _checkProviderMismatch() and shows a toast when the selected model looks incompatible with the configured provider. 4. static/messages.js — distinct UI label for auth errors apperror handler now distinguishes type='auth_mismatch' and shows 'Provider mismatch' as the error label instead of 'Error'. 5. static/i18n.js — provider_mismatch_warning and provider_mismatch_label keys added to all 5 locales (en, es, de, zh-Hans, zh-Hant). Tests: 21 new tests in tests/test_provider_mismatch.py covering all five change areas. 679/679 total pass (658 baseline + 21 new). * fix: t() call args spread + use i18n label for auth mismatch 1. ui.js: _checkProviderMismatch passed [modelId, ap] as a single array arg to t(). Since t(key, ...args) spreads, the function received the array as m and undefined as p. Fixed to pass as separate args: t('provider_mismatch_warning', modelId, ap). 2. messages.js: 'Provider mismatch' label was hardcoded instead of using t('provider_mismatch_label'). Now uses the i18n key with fallback for when t() isn't available. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -209,6 +209,11 @@ $('modelSelect').onchange=async()=>{
|
||||
localStorage.setItem('hermes-webui-model', selectedModel);
|
||||
await api('/api/session/update',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,workspace:S.session.workspace,model:selectedModel})});
|
||||
S.session.model=selectedModel;syncTopbar();
|
||||
// Warn if selected model belongs to a different provider than what Hermes is configured for
|
||||
if(typeof _checkProviderMismatch==='function'){
|
||||
const warn=_checkProviderMismatch(selectedModel);
|
||||
if(warn&&typeof showToast==='function') showToast(warn,4000);
|
||||
}
|
||||
};
|
||||
$('msg').addEventListener('input',()=>{
|
||||
autoResize();
|
||||
|
||||
@@ -48,6 +48,8 @@ const LOCALES = {
|
||||
n_messages: (n) => `${n} messages`,
|
||||
model_unavailable: ' (unavailable)',
|
||||
model_unavailable_title: 'This model is no longer in your current provider list',
|
||||
provider_mismatch_warning: (m,p)=>`"${m}" may not work with your configured provider (${p}). Send anyway, or run \`hermes model\` in your terminal to switch.`,
|
||||
provider_mismatch_label: 'Provider mismatch',
|
||||
// commands.js
|
||||
cmd_help: 'List available commands',
|
||||
cmd_clear: 'Clear conversation messages',
|
||||
@@ -237,6 +239,8 @@ const LOCALES = {
|
||||
n_messages: (n) => `${n} mensajes`,
|
||||
model_unavailable: ' (no disponible)',
|
||||
model_unavailable_title: 'Este modelo ya no está en tu lista actual de proveedores',
|
||||
provider_mismatch_warning: (m,p)=>`"${m}" puede no funcionar con tu proveedor configurado (${p}). Envía de todas formas, o ejecuta \`hermes model\` en la terminal para cambiar.`,
|
||||
provider_mismatch_label: 'Proveedor incompatible',
|
||||
// commands.js
|
||||
cmd_help: 'Listar los comandos disponibles',
|
||||
cmd_clear: 'Borrar los mensajes de la conversación',
|
||||
@@ -426,6 +430,8 @@ const LOCALES = {
|
||||
n_messages: (n) => `${n} Nachrichten`,
|
||||
model_unavailable: ' (nicht verfügbar)',
|
||||
model_unavailable_title: 'Dieses Modell ist nicht mehr in Ihrer aktuellen Provider-Liste',
|
||||
provider_mismatch_warning: (m,p)=>`"${m}" funktioniert möglicherweise nicht mit Ihrem konfigurierten Provider (${p}). Trotzdem senden, oder \`hermes model\` im Terminal ausführen.`,
|
||||
provider_mismatch_label: 'Provider-Konflikt',
|
||||
// commands.js
|
||||
cmd_help: 'Verfügbare Befehle auflisten',
|
||||
cmd_clear: 'Konversationsverlauf löschen',
|
||||
@@ -615,6 +621,8 @@ const LOCALES = {
|
||||
n_messages: (n) => `${n} \u6761\u6d88\u606f`,
|
||||
model_unavailable: '\uff08\u4e0d\u53ef\u7528\uff09',
|
||||
model_unavailable_title: '\u8fd9\u4e2a\u6a21\u578b\u5df2\u7ecf\u4e0d\u5728\u5f53\u524d provider \u5217\u8868\u4e2d',
|
||||
provider_mismatch_warning: (m,p)=>`\"${m}\" \u53ef\u80fd\u65e0\u6cd5\u5728\u5f53\u524d\u914d\u7f6e\u7684\u63d0\u4f9b\u5546 (${p}) \u4e0b\u5de5\u4f5c\u3002\u76f4\u63a5\u53d1\u9001\uff0c\u6216\u5728\u7ec8\u7aef\u8fd0\u884c \`hermes model\` \u5207\u6362\u3002`,
|
||||
provider_mismatch_label: '\u63d0\u4f9b\u5546\u4e0d\u5339\u914d',
|
||||
// commands.js
|
||||
cmd_help: '\u67e5\u770b\u53ef\u7528\u547d\u4ee4',
|
||||
cmd_clear: '\u6e05\u7a7a\u5f53\u524d\u5bf9\u8bdd\u6d88\u606f',
|
||||
@@ -802,6 +810,8 @@ const LOCALES = {
|
||||
n_messages: (n) => `${n} \u689d\u8a0a\u606f`,
|
||||
model_unavailable: '\uff08\u4e0d\u53ef\u7528\uff09',
|
||||
model_unavailable_title: '\u6b64\u6a21\u578b\u5df2\u7d93\u4e0d\u5728\u7576\u524d provider \u5217\u8868\u4e2d',
|
||||
provider_mismatch_warning: (m,p)=>`\"${m}\" \u53ef\u80fd\u7121\u6cd5\u5728\u7576\u524d\u914d\u7f6e\u7684\u63d0\u4f9b\u8005 (${p}) \u4e0b\u904b\u4f5c\u3002\u5c1a\u9001\uff0c\u6216\u5728\u7d42\u7aef\u57f7\u884c \`hermes model\` \u5207\u63db\u3002`,
|
||||
provider_mismatch_label: '\u63d0\u4f9b\u8005\u4e0d\u76f8\u7b26',
|
||||
// commands.js
|
||||
cmd_help: '\u67e5\u770b\u53ef\u7528\u547d\u4ee4',
|
||||
cmd_clear: '\u6e05\u7a7a\u7576\u524d\u5c0d\u8a71\u8a0a\u606f',
|
||||
|
||||
@@ -234,7 +234,8 @@ async function send(){
|
||||
try{
|
||||
const d=JSON.parse(e.data);
|
||||
const isRateLimit=d.type==='rate_limit';
|
||||
const label=isRateLimit?'Rate limit reached':'Error';
|
||||
const isAuthMismatch=d.type==='auth_mismatch';
|
||||
const label=isRateLimit?'Rate limit reached':isAuthMismatch?(typeof t==='function'?t('provider_mismatch_label'):'Provider mismatch'):'Error';
|
||||
const hint=d.hint?`\n\n*${d.hint}*`:'';
|
||||
S.messages.push({role:'assistant',content:`**${label}:** ${d.message}${hint}`});
|
||||
}catch(_){
|
||||
|
||||
28
static/ui.js
28
static/ui.js
@@ -45,6 +45,8 @@ async function populateModelDropdown(){
|
||||
try{
|
||||
const data=await fetch(new URL('/api/models',location.origin).href,{credentials:'include'}).then(r=>r.json());
|
||||
if(!data.groups||!data.groups.length) return; // keep HTML defaults
|
||||
// Store active provider globally so the send path can warn on mismatch
|
||||
window._activeProvider=data.active_provider||null;
|
||||
// Clear existing options
|
||||
sel.innerHTML='';
|
||||
_dynamicModelLabels={};
|
||||
@@ -70,6 +72,32 @@ async function populateModelDropdown(){
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given model ID belongs to a different provider than the one
|
||||
* currently configured in Hermes. Returns a warning string if mismatched,
|
||||
* or null if the selection looks compatible.
|
||||
*
|
||||
* Provider detection is intentionally loose — we compare the model's slash
|
||||
* prefix (e.g. "openai/" from "openai/gpt-4o") against the active provider
|
||||
* name. Custom/local endpoints report active_provider='custom' or the
|
||||
* base_url hostname and we skip the check to avoid false positives.
|
||||
*/
|
||||
function _checkProviderMismatch(modelId){
|
||||
const ap=(window._activeProvider||'').toLowerCase();
|
||||
if(!ap||ap==='custom'||ap==='openrouter') return null; // can't reliably check
|
||||
const slash=modelId.indexOf('/');
|
||||
if(slash<0) return null; // bare model name, no provider prefix
|
||||
const modelProvider=modelId.substring(0,slash).toLowerCase();
|
||||
// Normalise common aliases
|
||||
const aliases={'claude':'anthropic','gpt':'openai','gemini':'google'};
|
||||
const norm=p=>aliases[p]||p;
|
||||
if(norm(modelProvider)!==norm(ap)){
|
||||
return (window.t?window.t('provider_mismatch_warning',modelId,ap):
|
||||
`"${modelId}" may not work with your configured provider (${ap}). Send anyway or run \`hermes model\` to switch.`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Scroll pinning ──────────────────────────────────────────────────────────
|
||||
// When streaming, auto-scroll only if the user hasn't manually scrolled up.
|
||||
// Once the user scrolls back to within 80px of the bottom, re-pin.
|
||||
|
||||
Reference in New Issue
Block a user