Skip to content

fix: warn on provider/model mismatch, surface auth errors (#266) - #283

Merged
nesquena-hermes merged 2 commits into
masterfrom
fix/provider-mismatch-warning
Apr 12, 2026
Merged

fix: warn on provider/model mismatch, surface auth errors (#266)#283
nesquena-hermes merged 2 commits into
masterfrom
fix/provider-mismatch-warning

Conversation

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

What this fixes

Closes #266 — WebUI silently fails with a 401 error when the user selects a model from a different provider than the one Hermes is configured for (e.g. selecting openai/gpt-4o while Hermes is configured for a local Ollama endpoint).

The root cause: hermes model configures a provider in config.yaml, but the WebUI model dropdown lists all available models. Selecting one from the wrong provider sends the request through the wrong endpoint, returning a 401 with no UI feedback.

Changes

api/streaming.py

  • Detects 401/auth errors explicitly: '401', AuthenticationError, 'unauthorized', 'invalid api key', 'no cookie auth credentials' (the Ollama-specific string from the issue)
  • Emits apperror with type='auth_mismatch' and a hint: "The selected model may not be supported by your configured provider. Run hermes model in your terminal to switch providers, then restart the WebUI."

static/ui.js

  • populateModelDropdown() now stores data.active_provider from /api/models as window._activeProvider (the field was already in the API response but the frontend never read it)
  • New _checkProviderMismatch(modelId) helper: compares the selected model's provider prefix against the active provider, returns a warning string if they differ. Skips the check for openrouter and custom to avoid false positives

static/boot.js

  • modelSelect.onchange calls _checkProviderMismatch() and surfaces the warning via showToast() when the user picks an incompatible model

static/messages.js

  • apperror handler now shows "Provider mismatch" instead of "Error" for type='auth_mismatch' events

static/i18n.js

  • provider_mismatch_warning (arrow function with model/provider interpolation) and provider_mismatch_label added to all 5 locales: en, es, de, zh-Hans, zh-Hant

tests/test_provider_mismatch.py

  • 21 new tests covering all 5 change areas

Test results

679 passed, 0 failed (658 baseline + 21 new)

nesquena and others added 2 commits April 12, 2026 04:12
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).
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>
@nesquena

Copy link
Copy Markdown
Owner

Review: fix/provider-mismatch-warning (#266)

Full end-to-end review: all 6 files read line-by-line, security audit, 21 new tests run, 2 bugs found and fixed.


Fixes pushed (commit 94c6f94)

BUG 1 — t() args passed as array instead of spread (ui.js:95)

// Before (broken):
window.t('provider_mismatch_warning', [modelId, ap])
// t(key, ...args) spreads — function received m=[modelId,ap], p=undefined
// Warning showed: '"openai/gpt-4o,anthropic" may not work...' (array stringified)

// After (fixed):
window.t('provider_mismatch_warning', modelId, ap)
// Function correctly receives m=modelId, p=ap

BUG 2 — hardcoded label instead of i18n key (messages.js:238)

// Before:
const label = isAuthMismatch ? 'Provider mismatch' : 'Error';
// ^ Always English, even when UI is set to Chinese/Spanish/German

// After:
const label = isAuthMismatch ? (typeof t==='function' ? t('provider_mismatch_label') : 'Provider mismatch') : 'Error';

The provider_mismatch_label key exists in all 5 locales but was never used.


Security audit: CLEAN

  • streaming.py: Auth error detection uses string matching against exception messages — no user input in the classification logic. The is_auth_error flag checks for '401', 'AuthenticationError', 'unauthorized', 'invalid api key', 'no cookie auth credentials' — all server-side error strings, not user-controlled data.
  • ui.js: _checkProviderMismatch() reads window._activeProvider from /api/models response (server-controlled). Warning uses t() i18n function or template literal — no innerHTML, no DOM injection. Toast uses textContent internally.
  • messages.js: apperror handler adds isAuthMismatch alongside isRateLimit. Label comes from i18n or hardcoded fallback. d.hint is rendered as italic markdown (*hint*) inside renderMd() which escapes via esc(). Safe.
  • i18n.js: Arrow functions for interpolated warning. Parameters are model ID and provider name from internal state. No XSS surface.
  • boot.js: showToast(warn, 4000) — toast helper uses textContent. Safe.

Code review

streaming.py (lines 463-487) — auth error classification: correct

  • is_auth_error checks are comprehensive: 401, AuthenticationError, unauthorized, invalid api key, no cookie auth credentials
  • Correctly placed AFTER is_rate_limit in the elif chain — rate limits won't be misclassified
  • Hint text is actionable: "Run hermes model in your terminal to switch providers"

ui.js (lines 75-99) — _checkProviderMismatch(): well-designed

  • Skips check for custom and openrouter — correct, these can route to any model
  • Normalizes aliases (claude -> anthropic, gpt -> openai, gemini -> google) — catches common cases
  • Returns null for bare model names (no /) — correct, can't determine provider
  • Returns null when _activeProvider is empty — correct fallback

boot.js (lines 212-216) — onchange handler: correct

  • Guards with typeof _checkProviderMismatch==='function' — won't crash if script load order changes
  • Shows toast with 4000ms timeout — visible but not persistent

i18n.js — all 5 locales covered: en, es, de, zh, zh-Hant all have both provider_mismatch_warning (function) and provider_mismatch_label (string). Good parity.

tests/test_provider_mismatch.py (266 lines, 21 tests) — comprehensive

  • 6 streaming.py tests: auth detection, 401 check, unauthorized check, hint text, rate limit priority
  • 5 ui.js tests: function exists, uses _activeProvider, skips openrouter/custom, stores provider on model load
  • 3 messages.js tests: auth_mismatch type handled, label present, isAuthMismatch variable
  • 4 i18n tests: warning key in all locales, label key in all locales, warning is function in en, Spanish parity
  • 2 boot.js tests: onchange calls check, uses showToast
  • 1 HTTP test: /api/models includes active_provider
  • All 21 pass

One note (not blocking)

The aliases normalization in _checkProviderMismatch covers claude, gpt, gemini but not deepseek or meta-llama. If someone has provider: deepseek in config and picks deepseek/deepseek-chat-v3, the prefix deepseek already matches the provider name, so no alias is needed. The current aliases are sufficient for the common mismatch cases (picking Claude models on an OpenAI endpoint, etc.).


One pre-existing flaky test (NOT from this PR)

test_security_redaction.py::test_api_sessions_list_redacts_titles fails in the full suite run but passes individually — test ordering/isolation issue. Fails on master too when run in full suite context. Not introduced by this PR.


Summary

Item Status
Security Clean — no XSS, no injection surfaces
Tests (21 new) All pass
t() args bug Fixed (commit 94c6f94)
Hardcoded label Fixed (commit 94c6f94)
i18n coverage All 5 locales covered
Auth error detection Correct — won't misclassify rate limits
Provider mismatch check Correct — skips custom/openrouter

Looks good. Two real bugs caught and fixed. Ready for merge whenever.

@nesquena-hermes

Copy link
Copy Markdown
Collaborator Author

Agent review — APPROVED ✅

Full independent review completed after the other agent's two bug fixes.

Commits reviewed: 9022176 (original fix) + 94c6f94 (bug fixes)

Security: CLEAN

  • streaming.py: auth detection uses server-side exception strings only — no user input in classification logic
  • ui.js: _checkProviderMismatch() reads from server response (window._activeProvider) and returns a plain string. No innerHTML, no DOM injection. Toast uses textContent
  • messages.js: d.hint is server-hardcoded and renders as italic markdown inside renderMd() which passes through esc(). Safe
  • i18n.js: Arrow functions interpolate internal model/provider values, not user input
  • Path traversal guard (relative_to): intact. Enum guard (Invalid category): intact. inlineMd safe HTML: intact

Tests: 679/679 pass (658 baseline + 21 new). Zero regressions.

QA harness: PASS (Phase 1 structural + Phase 2 browser API, 11/11)

Browser feature test (port 8789):

  • window._activeProvider populated from /api/models response ✅
  • _checkProviderMismatch('openai/gpt-4o') with active_provider=anthropic → returns warning string ✅
  • Warning contains model ID and provider name ✅
  • Same provider → returns null (no warning) ✅
  • openrouter active → returns null (skip check) ✅
  • custom active → returns null (skip check) ✅
  • gpt alias normalises to openai
  • t('provider_mismatch_warning', 'openai/gpt-4o', 'anthropic') → correctly interpolated English string ✅
  • t('provider_mismatch_label')"Provider mismatch"
  • Toast appears when model dropdown is changed to a mismatched provider ✅
  • Zero JS console errors ✅

Both agent-found bugs confirmed fixed:

  1. t() spread: t('provider_mismatch_warning', modelId, ap) — correct ✅
  2. i18n label: typeof t==='function'?t('provider_mismatch_label'):'Provider mismatch' — correct ✅

Ready to merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WebUI silently ignores provider/model selection when Hermes is configured for a different provider

2 participants