Skip to content

fix: model picker snaps to wrong model with multi-slash IDs (#3360) - #3366

Closed
b3nw wants to merge 1 commit into
nesquena:masterfrom
b3nw:fix/3360-model-selection-multi-slash
Closed

b3nw wants to merge 1 commit into
nesquena:masterfrom
b3nw:fix/3360-model-selection-multi-slash

Conversation

@b3nw

@b3nw b3nw commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

fix: Model picker snaps to wrong model with multi-slash IDs

Closes #3360

Thinking Path

  • Hermes WebUI supports custom/proxy providers that expose models from multiple upstream vendors
  • When a proxy advertises models with multi-slash IDs (e.g. vendor_a/deepseek/deepseek-v4-pro, vendor_b/deepseek/deepseek-v4-pro), the dropdown contains entries that share the same base model name under different vendor hierarchies
  • The model picker uses normalization to fuzzy-match selected model IDs to dropdown options — and three separate normalization paths all use split('/').pop() (JS) or split('/')[-1] (Python), which discards everything except the last segment
  • This causes distinct multi-slash IDs to collide during matching, badge assignment, configured-entry dedup, and label rendering
  • This PR replaces all lossy split('/').pop() normalizations with first-segment-only stripping, preserving the vendor hierarchy that distinguishes these models
  • The result is that clicking a model always selects exactly that model, badges attach to the correct entry, configured entries don't duplicate, and the composer chip shows a meaningful label

What Changed

Fix 1 — _findModelInDropdown (static/ui.js)

Moved the exact string match (opts.includes(modelId)) before the provider-aware normalized match. Previously, when all models share the same provider ID (common with LLM proxy setups), the normalized match returned whichever colliding option appeared first in DOM order — even when the exact clicked value existed verbatim in the dropdown.

   const opts=options.map(o=>o.value);
+  // 0. Exact match — highest priority, no ambiguity possible (#3360).
+  if(opts.includes(modelId)) return modelId;
   // 1. Normalize: lowercase, strip namespace prefix, replace hyphens→dots.
   const norm=s=>...

Fix 2 — _normalizeConfiguredModelKey (static/ui.js) and _norm_model_id (api/config.py)

Replaced split('/').pop() / split('/')[-1] with first-segment-only stripping:

  • Frontend: s.replace(/^[^/]+\//, '') || s
  • Backend: s.split("/", 1)[1] (Python split with maxsplit=1 preserves the remainder)

This preserves vendor hierarchy so vendor_a/deepseek-v4-pro normalizes to deepseek.v4.pro while vendor_b/deepseek/deepseek-v4-pro normalizes to deepseek/deepseek.v4.pro — distinct keys that no longer collide.

Additionally, added colon-qualified prefix stripping to the frontend normalizer so that badge-key variants generated by the backend (e.g. custom:llm-proxy/opencode_go/model) merge correctly with bare values (opencode_go/model) during configured-section dedup.

Fix 3 — getModelLabel (static/ui.js) and _get_label_for_model (api/config.py)

Same split('/').pop() → first-segment-strip change in the display label functions. This ensures the composer-bar model chip shows opencode_go/deepseek-v4-pro instead of truncating to just deepseek-v4-pro.

Files changed

File Lines Description
static/ui.js +32/−8 All three frontend fixes
api/config.py +17/−12 Backend mirror: _norm_model_id + _get_label_for_model
tests/test_issue3360_multi_slash_model_collision.py +263 (new) 9 regression tests across 3 test classes
tests/test_norm_model_id_trailing_empty_guard.py +14/−8 Updated assertions for new pattern
CHANGELOG.md +3 Release note under [Unreleased]

Why It Matters

Any user running a custom LLM proxy (LiteLLM, OpenRouter, or a private gateway) that aggregates models from multiple vendors will hit this bug as soon as two models share the same base name. The symptoms — silent model swapping and misleading configured badges — are particularly dangerous because the user believes they are using one model when the API is actually being called with a different one.

Verification

Automated tests

pytest tests/test_issue3360_multi_slash_model_collision.py \
       tests/test_norm_model_id_trailing_empty_guard.py \
       tests/test_issue1188_fuzzy_match.py \
       -v --timeout=60
# 25 passed

New tests (test_issue3360):

  • TestFindModelExactMatchPriority — 3 tests: exact match beats normalized collision (same provider), exact match beats DOM order, single-slash still works
  • TestNormalizeConfiguredModelKeyMultiSlash — 5 tests: multi-slash preserves vendor segment, single-slash unchanged, bare model unchanged, @Provider prefix stripped, trailing slash fallback
  • TestBackendFrontendNormParity — 1 test: Python _norm_model_id produces identical output to JS _normalizeConfiguredModelKey for all input variants

Existing test updates:

  • test_norm_model_id_trailing_empty_guard — updated to assert the new replace(/^[^/]+\//) pattern instead of the old split('/').pop()
  • test_issue1188_fuzzy_match — all 11 tests pass unmodified (no regression in the existing fuzzy-match behavior)

Manual verification

Tested against a live deployment with an LLM proxy serving 173 models from multiple upstream vendors (including multiple models sharing the deepseek-v4-pro base name under different vendor prefixes):

  1. Clicking a multi-slash model selects exactly that model (no snapping)
  2. Configured badge attaches to the correct entry only
  3. Single configured entry shown (no duplicates from badge-key variants)
  4. Composer chip shows opencode_go/deepseek-v4-pro (not truncated to deepseek-v4-pro)
  5. Single-slash models (e.g. openai/gpt-5.5) behave identically to before

Risks / Follow-ups

  • Low risk: For single-slash IDs (provider/model), split('/').pop() and first-segment strip produce identical results — behavior is unchanged for the common case.
  • Edge case: IDs with trailing slashes (e.g. provider/) now fall back to the original string instead of empty string, which is strictly better.
  • Not addressed: The backend _build_configured_model_badges generates badge entries for model, provider/model, and @provider:model variants. This creates phantom configured entries when the provider-qualified variant doesn't exist in the dropdown. The frontend dedup now handles this correctly via the colon-prefix strip, but a cleaner long-term fix would be to avoid emitting badge keys that don't correspond to real model options.

AI Usage Disclosure

  • Provider: google antigravity
  • Model: Claude Opus 4.6
  • Tool: Antigravity IDE (agentic pair-programming)
  • Usage: Root cause analysis, implementation of all fixes, test authoring, and live hotpatch verification were done collaboratively. All changes were reviewed and tested by a human before commit.

@b3nw
b3nw force-pushed the fix/3360-model-selection-multi-slash branch 2 times, most recently from 8071917 to 1d83877 Compare June 2, 2026 01:08
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Reviewing this against origin/master and the existing model-picker test suite, I think the consistent shard-0 FAILURE across 3.11/3.12/3.13 (while shards 1 and 2 pass) is caused by Fix 1 — moving the exact match to step 0 of _findModelInDropdown.

The collision with #1228/#1313 provider-aware preference

On origin/master, static/ui.js:1059 orders the lookup so the provider-aware match runs before the bare exact match:

const preferred=String(preferredProviderId||explicitProvider||'').toLowerCase();
if(preferred){
  const providerMatch=options.find(o=>norm(o.value)===target && _getOptionProviderId(o).toLowerCase()===preferred);
  if(providerMatch) return providerMatch.value;
}
// 2. Exact match
if(opts.includes(modelId)) return modelId;

This PR hoists if(opts.includes(modelId)) return modelId; to step 0, above the preferred block. That re-breaks the case tests/test_issue1228_model_picker_duplicate_ids.py pins in TestFrontendPreferredProviderMatch.test_find_model_prefers_matching_provider_for_slash_collision (~line 250):

options: [
  { value: 'google/gemma-4-27b',               provider: 'custom:alpha' },
  { value: '@custom:beta:google/gemma-4-27b',  provider: 'custom:beta' },
]
_findModelInDropdown('google/gemma-4-27b', sel, 'custom:beta')  // expects '@custom:beta:google/gemma-4-27b'

With the reorder, opts.includes('google/gemma-4-27b') is true at step 0, so it returns the bare google/gemma-4-27b (the custom:alpha row) instead of the saved custom:beta sibling. That's a real behavior regression, not a flaky test: when a user has a model saved against a specific provider and a same-normalized bare duplicate exists under a different provider, rehydration now snaps to the wrong provider row.

Suggested fix — guard the step-0 short-circuit

Keep the exact match early (it's the right instinct for the #3360 same-provider proxy case), but don't let it override a cross-provider preference:

// 0. Exact match — but don't override a provider-aware preference (#1228/#1313).
if(opts.includes(modelId)){
  const exactOpt=options.find(o=>o.value===modelId);
  const exactProv=exactOpt?_getOptionProviderId(exactOpt).toLowerCase():'';
  const pref=String(preferredProviderId||'').toLowerCase();
  if(!pref || !exactProv || exactProv===pref) return modelId;
}

This still satisfies all three of your new TestFindModelExactMatchPriority cases — in each the exact option's own provider equals the preferred provider (llm-proxy/proxy/openai), so it short-circuits as intended — while letting the #1228 case fall through to the provider-aware match when the exact option belongs to a different provider.

Verify

Run the two suites together so the interaction is covered:

pytest tests/test_issue1228_model_picker_duplicate_ids.py \
       tests/test_issue3360_multi_slash_model_collision.py -v

The rest of the change (first-segment strip in _normalizeConfiguredModelKey, _norm_model_id, and the label helpers) looks consistent and the backend/frontend parity test is a nice touch. Just the _findModelInDropdown ordering needs the provider guard before this is green.

@b3nw
b3nw force-pushed the fix/3360-model-selection-multi-slash branch 2 times, most recently from 0541c83 to d8cf286 Compare June 2, 2026 01:37
…#3360)

When a custom/proxy provider serves models whose IDs share the same base
name across vendor prefixes (e.g. vendor_a/deepseek/deepseek-v4-pro vs
vendor_b/deepseek/deepseek-v4-pro), several normalization functions use
split('/').pop() (or split('/')[-1]) which discards all segments except
the last.  This causes three user-facing symptoms: (1) clicking one
model selects a different colliding model, (2) configured-model badges
attach to the wrong dropdown entry, and (3) the model-chip label in the
composer bar is truncated to just the base model name.

Root cause: all three callers take only the last slash-segment instead
of stripping only the first (provider) segment and preserving the
remaining vendor hierarchy.

Fix 1 — _findModelInDropdown (static/ui.js): Move the exact string match
before the provider-aware normalized match.  Previously, when all models
share the same provider ID (common with LLM proxy setups), the normalized
match returned whichever colliding option appeared first in DOM order,
even though an exact match existed.

Fix 2 — _normalizeConfiguredModelKey (static/ui.js) and _norm_model_id
(api/config.py): Replace split('/').pop() / split('/')[-1] with a first-
segment-only strip (regex on frontend, split('/',1) on backend), matching
the strategy already used by _findModelInDropdown's norm lambda.  This
prevents multi-slash IDs from colliding in badge assignment and the
configured-entry dedup set.  Additionally, strip colon-qualified provider
prefixes (e.g. custom:name/) before the slash strip so badge-key variants
like 'custom:llm-proxy/opencode_go/model' merge correctly with the bare
'opencode_go/model' in the configured section dedup.

Fix 3 — getModelLabel (static/ui.js) and _get_label_for_model
(api/config.py): Same split('/').pop() to first-segment-strip change so
the composer-bar model chip and backend label preserve vendor context
(e.g. shows 'opencode_go/deepseek-v4-pro' instead of 'deepseek-v4-pro').

Verification: 9 new regression tests (test_issue3360) covering exact-
match priority, multi-slash normalization, and backend/frontend parity.
Updated 1 existing test (test_norm_model_id_trailing_empty_guard) that
asserted the old split('/').pop() pattern.  All 25 related tests pass.

AI Usage: Gemini (gemini-2.5-pro), via Antigravity IDE, pair-programmed.
@b3nw
b3nw force-pushed the fix/3360-model-selection-multi-slash branch from d8cf286 to a454fec Compare June 2, 2026 01:46
@b3nw

b3nw commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

@nesquena-hermes lmk if any other feedback

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Shipped in v0.51.210 (Release GD) — cherry-picked onto fresh master and released as part of stage-batch1. Thank you @b3nw!

The multi-slash model-id collision fix is live: exact-match priority in _findModelInDropdown plus first-segment-only stripping in _normalizeConfiguredModelKey / _norm_model_id now prevent same-base-name collisions across proxy-provider model IDs in selection, badge assignment, and configured-entry dedup.

Verification before merge: full pytest suite green (7267 passed), and our Codex regression gate explicitly confirmed the normalization handles single-slash IDs, @provider:model qualified IDs, custom:proxy/model, Ollama model:tag, and cross-provider same-bare-name collisions (e.g. openai/gpt-4o vs openrouter/gpt-4o) without snapping to the wrong provider's row. Your 263-line node-driven + Python regression test is now part of the suite. Closing as merged.

@pamnard

pamnard commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Follow-up regression reported in #3429: getModelLabel() first-segment slash strip breaks URI-shaped model ids like gpt://${YANDEX_FOLDER_ID}/deepseek-v4-flash/latest (composer chip shows /${YANDEX_FOLDER_ID}/...).

pull Bot pushed a commit to soitun/hermes-webui that referenced this pull request Jun 2, 2026
…#3429 regression from nesquena#3366)

nesquena#3366 changed getModelLabel() to strip only the first /-segment (fixing nesquena#3360
multi-slash proxy IDs). That regressed URI-scheme IDs like Yandex
gpt://${FOLDER}/deepseek-v4-flash/latest — indexOf('/') lands inside the ://
and leaves /${FOLDER}/... path junk in the composer model chip. Detect a
scheme:// id, drop scheme+authority, and take the last meaningful path segment
(skipping ${...} env-var placeholders and bare version tails like latest).
Non-URI multi-slash IDs keep the nesquena#3360 first-segment-strip behavior unchanged.
Node-driven regression test covers the URI case + the nesquena#3360 non-regression.
eleboucher pushed a commit to eleboucher/homelab that referenced this pull request Jun 3, 2026
…➔ 0.51.230) (#798)

This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [ghcr.io/nesquena/hermes-webui](https://github.com/nesquena/hermes-webui) | patch | `0.51.210` → `0.51.230` |

---

### Release Notes

<details>
<summary>nesquena/hermes-webui (ghcr.io/nesquena/hermes-webui)</summary>

### [`v0.51.230`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051230--2026-06-03--Release-GX-stage-p14--extract-think-blocks-to-mreasoning--LLM-Wiki-last-writer)

[Compare Source](nesquena/hermes-webui@v0.51.229...v0.51.230)

##### Fixed

- Assistant message `<think>…</think>` blocks are now extracted into `m.reasoning` instead of being stored inline in `m.content` — **both client-side (streaming/inflight state) and server-side at save time**. Reasoning-only providers such as `MiniMax-M3` (OpenAI-compat) previously left the thinking trace inside the assistant content, bloating persisted session files by 30–50% and bypassing the `m.reasoning` field the thinking card reads on reload. A new `_splitThinkFromContent()` (in `static/messages.js`) and its server-side twin `_split_thinking_from_content()` (in `api/streaming.py`, applied to the final assistant message before `s.save()`) extract a single **leading** block (after lstrip) for all three known tag pairs, matching the live renderer's `_streamDisplay`/`_parseStreamState` semantics exactly: a closed `<think>…</think>` that appears mid-body (e.g. a literal tag inside a fenced code block) stays visible content and is never moved into reasoning, a partial/unclosed block is left intact, and any pre-existing `m.reasoning` (from a separate `on_reasoning` stream) is preserved/merged. So the persisted session file — not just the in-browser copy — is compacted on reload ([#&#8203;3455](nesquena/hermes-webui#3455) part 1, [@&#8203;gsurenull](https://github.com/gsurenull)).
- The LLM Wiki status panel's `Last writer` field is now populated (it always showed `Not available` since the panel shipped in [#&#8203;1257](nesquena/hermes-webui#1257)). The reader uses a 3-tier fallback — most-recent page frontmatter (`updated_by`/`writer`/`author`), the most recent `log.md` action verb, then a static `ai-agent` fallback — and reads only frontmatter + log headings, never page bodies, preserving the private-safe status contract ([#&#8203;3455](nesquena/hermes-webui#3455) part 2, [@&#8203;gsurenull](https://github.com/gsurenull); closes [#&#8203;1257](nesquena/hermes-webui#1257)).

### [`v0.51.229`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051229--2026-06-03--Release-GW-stage-p13--model-never-silently-snaps-a-versioned-name-to-a--tier-variant)

[Compare Source](nesquena/hermes-webui@v0.51.228...v0.51.229)

##### Fixed

- `/model <name>` no longer silently snaps a complete versioned model name to a longer `-tier` variant (and a different price tier). When the typed name ends in a version number (e.g. `mimo-v2.5`) and the catalog has only a longer suffixed variant (e.g. `xiaomi/mimo-v2.5-pro`), both the dropdown matcher (`_findModelInDropdown`) and the command fallback (`_bestModelMatch`) now reject the snap unless the extra text *continues the version* (`.` + digit), rather than upgrading the user to a `-pro`/`-flash` tier they did not type. When nothing matches cleanly, `/model` now shows a *"No model matching … — did you mean …?"* suggestion toast instead of silently switching. Legitimate fuzzy shorthand is preserved (`/model gpt-5` → `gpt-5.4-mini`, `/model claude` → `claude-opus-4.6`, `/model mimo-v2` → `mimo-v2.5-pro`), as is exact-match priority ([#&#8203;3368](nesquena/hermes-webui#3368), with [@&#8203;garyd9](https://github.com/garyd9); thanks [@&#8203;yutaotie](https://github.com/yutaotie) for confirmation).

### [`v0.51.228`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051228--2026-06-03--Release-GV-stage-p12--workspace-file-tree-drop--large-markdown-preview)

[Compare Source](nesquena/hermes-webui@v0.51.227...v0.51.228)

##### Fixed

- Dropping an OS file onto the **workspace file tree** now uploads it into the workspace only, instead of *also* attaching it to the chat composer. The tree's drag handlers now stop event propagation for OS `Files` drops so the document-level composer drop handler no longer fires for the same drop ([#&#8203;3411](nesquena/hermes-webui#3411), [@&#8203;pamnard](https://github.com/pamnard)).
- Moderately large Markdown documents in the **workspace preview** are no longer forced into plain-text too early. The rich-render ceiling is raised (64 KB / 1500 lines → 256 KB / 5000 lines, and the backend file-read limit 200 KB → 400 KB), and files above the limit gain a **"Render as markdown anyway"** button that force-renders the already-loaded content without a second fetch ([#&#8203;3378](nesquena/hermes-webui#3378), [@&#8203;starGazerK](https://github.com/starGazerK)).

### [`v0.51.227`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051227--2026-06-03--Release-GU-stage-p11--keep-the-active-New-Chat-visible-in-the-sidebar)

[Compare Source](nesquena/hermes-webui@v0.51.226...v0.51.227)

##### Fixed

- A freshly-created **New Chat** now stays visible and selected in the sidebar before its first message is sent. The sidebar intentionally filters inactive 0-message sessions, but that filter also hid the *currently active* blank chat until the user sent a turn — so starting a New Chat could make the selected row vanish from the list. The active ephemeral session is now injected into the sidebar render rows (only when the server-side list omits it), while inactive empty sessions stay filtered as before. Starting a New Chat from a CLI-filtered sidebar also switches the source filter back to WebUI so the active chat isn't immediately hidden ([#&#8203;3408](nesquena/hermes-webui#3408), [@&#8203;AJV20](https://github.com/AJV20)).

### [`v0.51.226`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051226--2026-06-03--Release-GT-stage-p9--mobile-composer-context-usage-ring--activity-feed-default-expand-setting)

[Compare Source](nesquena/hermes-webui@v0.51.225...v0.51.226)

##### Added

- **Settings → Appearance: "Expand activity feed by default"** — a new checkbox (default off) that expands new Activity disclosures by default as turns arrive. Manual per-turn collapse/expand still wins (an explicit user toggle is preserved), and live "Waiting on model" rows now explain what the agent is doing before and after tool calls ([#&#8203;3080](nesquena/hermes-webui#3080), [@&#8203;AJV20](https://github.com/AJV20)).

##### Changed

- The mobile composer's config button now shows a **context-usage ring** (an SVG progress ring with a centered percentage) in place of the static sliders icon, color-coded green (≤50%) / orange (≤85%) / red (>85%) and reset to 0% on a new session, so context-window pressure is visible at a glance on mobile ([#&#8203;3062](nesquena/hermes-webui#3062), [@&#8203;NottheGuy007](https://github.com/NottheGuy007)).

### [`v0.51.225`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051225--2026-06-03--Release-GS-stage-p7--remote-gateway-health-probe-resolves-gatewaystate)

[Compare Source](nesquena/hermes-webui@v0.51.224...v0.51.225)

##### Fixed

- The remote-gateway health probe now correctly reports `gateway_state`, so the Tasks/Cron banner lights up for Docker / remote-gateway deployments. The probe previously hit `/health` and `/status` (neither returns `gateway_state`) and never queried `/health/detailed` (which does), so `gateway_state == "running"` was never observed remotely. The probe now tries `/health/detailed` first, parses the JSON body of a 2xx response to extract `gateway_state`, and unifies the gateway base-URL env precedence to `GATEWAY_HEALTH_URL` > `HERMES_GATEWAY_HEALTH_URL` > `HERMES_API_URL` ([#&#8203;3355](nesquena/hermes-webui#3355), [@&#8203;rodboev](https://github.com/rodboev)).

### [`v0.51.224`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051224--2026-06-03--Release-GR-stage-p6--profile-toolskill-config-authoritative-on-the-streaming-worker)

[Compare Source](nesquena/hermes-webui@v0.51.223...v0.51.224)

##### Fixed

- Profile tool/skill restrictions are now respected for WebUI chats even when the per-session "Tool Restrictions" field is left blank. The streaming agent runs on a detached worker thread that does not inherit the per-request thread-local profile context, so the ambient `get_config()` resolved the process-global `default` profile and loaded its `platform_toolsets.cli` (all tools) instead of the session profile's configured list — inflating a tools-disabled profile's prompt from \~400 to \~15K input tokens. The worker now reads the session's own profile config explicitly via a new `get_config_for_profile_home()` helper (a race-free direct disk read with no shared-cache mutation), so toolsets, prefill context, and fallback chains all match the profile the session actually runs under ([#&#8203;3294](nesquena/hermes-webui#3294), [@&#8203;nesquena-hermes](https://github.com/nesquena-hermes)).

### [`v0.51.223`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051223--2026-06-02--Release-GQ-stage-p5--openai-api-first-class-picker-provider--MiniMax-M3)

[Compare Source](nesquena/hermes-webui@v0.51.222...v0.51.223)

##### Fixed

- GPT models now appear in the model picker when hermes-agent exposes its built-in OpenAI provider under the `openai-api` slug (the one activated by `OPENAI_API_KEY` / `OPENAI_BASE_URL`, distinct from `openai-codex`). `openai-api` is now a first-class picker provider in `_PROVIDER_DISPLAY` / `_PROVIDER_MODELS` rather than an alias of `openai` — an alias would have fixed the display but broken the send path, since the agent registry has `openai-api` and not `openai`. Env detection for `OPENAI_API_KEY` was also corrected to surface `openai-api` instead of a bare `openai` the agent registry can't resolve ([#&#8203;3443](nesquena/hermes-webui#3443), [@&#8203;rodboev](https://github.com/rodboev)).

##### Changed

- MiniMax default model catalog upgraded to M3 in the model picker ([#&#8203;3374](nesquena/hermes-webui#3374), [@&#8203;octo-patch](https://github.com/octo-patch)).

### [`v0.51.222`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051222--2026-06-02--Release-GP-stage-p4--backend-bugfix-batch-title-language-drift--orphaned-CLI-sidecar-prune--pin-quota-lineage)

[Compare Source](nesquena/hermes-webui@v0.51.221...v0.51.222)

##### Fixed

- Auto-generated session titles no longer persist in the wrong language. The title-language guard previously only rejected English titles for *German* conversation starts, so an English chat whose LLM-generated title came back in Chinese, Russian, or another script sailed through and was saved. `_title_language_mismatch` now also does a language-agnostic cross-script check: when the conversation start has a clear dominant writing script and the generated title introduces a substantial amount of a different script (CJK / Cyrillic / Arabic / etc.), the title is rejected and generation falls back to the deterministic topic title. The threshold tolerates a borrowed technical term (a CJK title with one English word still trips; an English title with a single foreign place-name does not), and the legacy German→English heuristic is preserved ([#&#8203;3293](nesquena/hermes-webui#3293)).
- WebUI sidebar now reconciles orphaned imported-CLI sessions. When a CLI/agent session is opened in the WebUI it gets a WebUI-owned sidecar so it can render and reopen; previously, if the user then deleted that session from the CLI / local Hermes storage, nothing pruned the sidecar and the stale row lingered in the sidebar indefinitely (there is no WebUI delete affordance for CLI rows). Orphaned sidecars whose backing session no longer exists are now pruned on reconciliation ([#&#8203;3238](nesquena/hermes-webui#3238)).
- Pin quota is now counted by visible session lineage rather than raw session rows, so continuation siblings in the same sidebar-visible lineage no longer each consume a separate pin slot. Previously a pinned session that had been compressed/continued into multiple rows could exhaust the pin limit with what the user sees as a single pinned conversation. The limit check now collapses each lineage to its visible root before counting against `pinned_sessions_limit` ([#&#8203;3288](nesquena/hermes-webui#3288), [@&#8203;andrewkangkr](https://github.com/andrewkangkr)).

### [`v0.51.221`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051221--2026-06-02--Release-GO-stage-p3e--block-all-workspace-symlink-escapes-security)

[Compare Source](nesquena/hermes-webui@v0.51.220...v0.51.221)

##### Security

- The workspace file API now blocks **all** symlink escapes from the selected workspace, not just symlinks pointing at system directories. Previously a symlink placed inside a workspace could resolve to an arbitrary external host path (e.g. `~/.ssh`, `~/.hermes/auth.json`) and be read through `/api/list` / `read_file_content` — and since that API is reachable by LLM agent tool calls, an imported or crafted workspace could expose credentials. `safe_resolve_ws` now requires the resolved path stay under the workspace root, `list_dir` hides escaping symlinks (they could never be opened anyway), and `read_file_content` rejects them. Symlinks that resolve back under the workspace still work normally. The directory-list, file-read, file-upload, and archive-extraction paths are additionally hardened against a symlink-swap **TOCTOU** race: each path is opened component-by-component from the workspace root with `O_NOFOLLOW` (an anchored `openat` walk on Linux/macOS, with a plain-open fallback on platforms without `dir_fd` support such as Windows, where creating symlinks needs admin anyway), so a symlink raced into any component after the containment check cannot redirect the read/list/write outside the workspace. Note: an intentional in-workspace symlink pointing to an external directory is no longer followed ([#&#8203;3398](nesquena/hermes-webui#3398), [@&#8203;Hinotoi-agent](https://github.com/Hinotoi-agent)).

### [`v0.51.220`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051220--2026-06-02--Release-GN-stage-p3c--fix-aux-title-generation-with-provider-model-ids)

[Compare Source](nesquena/hermes-webui@v0.51.219...v0.51.220)

##### Fixed

- Manual session-title regeneration and background auxiliary title generation no longer fail with `422` / `llm_error_aux` when `auxiliary.title_generation.model` in `config.yaml` is set using the WebUI model-picker's `@provider:model` format (e.g. `@gemini:gemini-3.1-flash-lite`). The `@provider:` prefix is now normalized away via the canonical helper before the id reaches the provider API ([#&#8203;3430](nesquena/hermes-webui#3430), [@&#8203;pamnard](https://github.com/pamnard)).

### [`v0.51.219`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051219--2026-06-02--Release-GM-stage-p3b--extend-URI-scheme-model-ID-fix-to-backend-normalization--matching)

[Compare Source](nesquena/hermes-webui@v0.51.218...v0.51.219)

##### Fixed

- Extended the [#&#8203;3429](nesquena/hermes-webui#3429) URI-scheme fix beyond the visible model chip (fixed in v0.51.218) to the model-identity normalization and matching paths: `api/config.py` `_norm_model_id` / `_get_label_for_model` and `static/ui.js` `_normalizeConfiguredModelKey` no longer strip the first `/`-segment of a `scheme://` id (e.g. `gpt://${FOLDER}/model/latest`), where the slashes are path separators rather than a provider prefix. This prevents the [#&#8203;3360](nesquena/hermes-webui#3360 identity collision/mislabel for URI-shaped model IDs in dropdown matching, badge assignment, and configured-entry dedup. Backend/front-end parity is covered by tests ([#&#8203;3436](nesquena/hermes-webui#3436), [@&#8203;b3nw](https://github.com/b3nw)).

### [`v0.51.218`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051218--2026-06-02--Release-GL-stage-p3a--fix-getModelLabel-mangling-URI-scheme-model-IDs)

[Compare Source](nesquena/hermes-webui@v0.51.217...v0.51.218)

##### Fixed

- The composer model chip no longer shows env-var path junk for model IDs that use a URI scheme (e.g. Yandex `gpt://${FOLDER}/deepseek-v4-flash/latest`). A regression from [#&#8203;3366](nesquena/hermes-webui#3366) (v0.51.210): `getModelLabel()` stripped the first `/`-segment, which for a `scheme://` id landed inside the `://` and left `/${FOLDER}/…`. The label now detects a URI scheme, drops scheme + authority, and takes the last meaningful path segment (skipping `${…}` placeholders and bare version tails like `latest`); non-URI multi-slash IDs keep their [#&#8203;3360](nesquena/hermes-webui#3360) behavior ([#&#8203;3429](nesquena/hermes-webui#3429)).

### [`v0.51.217`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051217--2026-06-02--Release-GK-stage-p2f--decode-and-complete-zh-Hant-locale-strings)

[Compare Source](nesquena/hermes-webui@v0.51.216...v0.51.217)

##### Changed

- Decoded the `zh-Hant` (Traditional Chinese) locale block from `\u`-escaped sequences to literal Chinese text and backfilled missing keys so `zh-Hant` now has full coverage of the English key set. Makes future locale review readable and prevents newer UI keys from falling back to English for Traditional Chinese users. Locale-only — no runtime behavior change ([#&#8203;3414](nesquena/hermes-webui#3414), [@&#8203;PeterDaveHello](https://github.com/PeterDaveHello)).

##### Fixed

- Added the missing `provider_mismatch_warning` string to the French (`fr`) locale. It was absent entirely; the gap was masked by a stale duplicate of the same key in the `zh-Hant` block that [#&#8203;3414](nesquena/hermes-webui#3414) removed, so all locales now carry the key.

### [`v0.51.216`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051216--2026-06-02--Release-GJ-stage-p2e--fix-consecutive-user-turn-rejection-on-strict-chat-templates)

[Compare Source](nesquena/hermes-webui@v0.51.215...v0.51.216)

##### Fixed

- WebUI session/delivery context (connected platforms, home channels, scheduled-task delivery hints) is now injected into the ephemeral **system prompt** instead of being appended as a prefill `user` message. The old prefill produced two consecutive `user` turns (session context + the actual message), which models with strict chat templates (Mistral, Gemma via llama.cpp) reject with a Jinja 500. The same context is preserved — just delivered in a role-alternation-safe place ([#&#8203;3324](nesquena/hermes-webui#3324), [@&#8203;aether-agent](https://github.com/aether-agent), closes [#&#8203;3276](nesquena/hermes-webui#3276)).

### [`v0.51.215`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051215--2026-06-02--Release-GI-stage-p2d--deduplicate-legacy-messages-in-append-only-merge)

[Compare Source](nesquena/hermes-webui@v0.51.214...v0.51.215)

##### Fixed

- `merge_session_messages_append_only` now deduplicates true duplicate legacy messages (same role, content, AND exact timestamp) that could accumulate in state, while preserving legitimately-repeated identical turns whose timestamps differ even slightly. This avoids both the stale-duplicate buildup and the data-loss class where collapsing same-second distinct turns would drop real messages ([#&#8203;3393](nesquena/hermes-webui#3393), [@&#8203;thanhtoantnt](https://github.com/thanhtoantnt), closes [#&#8203;3346](nesquena/hermes-webui#3346)).

### [`v0.51.214`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051214--2026-06-02--Release-GH-stage-p2c--preserve-loaded-transcript-width-on-same-session-external-refresh)

[Compare Source](nesquena/hermes-webui@v0.51.213...v0.51.214)

##### Fixed

- A same-session external refresh (e.g. a background poll triggering a force-reload of the conversation you're reading) no longer collapses a long transcript back to the default 30-message tail window and jumps the viewport to a different slice. The already-loaded transcript width and scroll position are now captured before the in-memory transcript is cleared and preserved across the authoritative reload ([#&#8203;3326](nesquena/hermes-webui#3326), [@&#8203;viraatdas](https://github.com/viraatdas), closes [#&#8203;3239](nesquena/hermes-webui#3239)).

### [`v0.51.213`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051213--2026-06-02--Release-GG-stage-p2b--keep-gateway-context-visible-in-chat-transcripts)

[Compare Source](nesquena/hermes-webui@v0.51.212...v0.51.213)

##### Fixed

- Gateway-backed chat now backfills model-context turns into the visible transcript before saving the latest reply, while keeping hidden `[context compaction]` markers out of the visible transcript. Previously a context-compacted gateway session could collapse the sidebar/header message count to a two-message conversation (and drop older visible turns) while the assistant was responding to hidden prior context. Older visible turns are preserved and compaction markers stay hidden from `saved.messages` ([#&#8203;3300](nesquena/hermes-webui#3300), [@&#8203;AJV20](https://github.com/AJV20)).

### [`v0.51.212`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051212--2026-06-02--Release-GF-stage-batch2--i18n-regenerate-title-strings--self-restart-argv--todos-cold-load)

[Compare Source](nesquena/hermes-webui@v0.51.211...v0.51.212)

##### Fixed

- Localized the five `session_title_regenerate*` session-menu strings (the "Regenerate title" action, its description, and the regenerating/regenerated/failed states) that shipped as English text in every non-English locale. Translated across it, ja, ru, es, de, zh, zh-Hant, pt, ko, fr, and tr, matching each locale's existing terminology; `zh`/`zh-Hant` keep the `\u`-escaped style of those blocks ([#&#8203;3396](nesquena/hermes-webui#3396), [@&#8203;vanshaj-pahwa](https://github.com/vanshaj-pahwa), closes [#&#8203;3364](nesquena/hermes-webui#3364)).
- Self-update re-exec now distinguishes source checkouts from frozen/packaged builds: a frozen binary (`sys.frozen`) re-execs with `sys.argv` as-is, while source checkouts keep the `[sys.executable] + sys.argv` CPython idiom. Previously the frozen path re-inserted the binary as `argv[1]`, turning re-exec into a no-op that left the WebUI stuck "offline" after every self-update ([#&#8203;3395](nesquena/hermes-webui#3395), [@&#8203;PatrickNoFilter](https://github.com/PatrickNoFilter)).
- The Todos panel now hydrates correctly on a cold session load (page refresh) even when the latest todo tool result is outside the truncated display window: `/api/session` derives a compact `todo_state` sidecar from the full settled transcript, and an explicit empty todo list is honored as the current state instead of falling through to an older non-empty write. A malformed historical tool message can never break session loading ([#&#8203;3373](nesquena/hermes-webui#3373), [@&#8203;v2psv](https://github.com/v2psv)).

### [`v0.51.211`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051211--2026-06-02--Release-GE-stage-batch1--reasoning-heuristics--model-shortest-match--Copilot-env-token-filter)

[Compare Source](nesquena/hermes-webui@v0.51.210...v0.51.211)

##### Fixed

- Generalized reasoning-effort capability checks in `_candidate_supports_reasoning` to target whole model families (GPT-5+, Claude 4/3.7, Qwen-3, Kimi, Minimax, Mimo, GLM, Step, and DeepSeek) instead of anchoring on hardcoded version numbers or vendor formats. This prevents the thinking-level configuration selector from being hidden on custom providers, new model releases, or when names carry suffixes like `-free` or `:free` (common on integrations such as Kilo Code or OpenCode Zen). The GPT heuristic is now version-anchored (5+) to avoid falsely enabling reasoning\_effort for gpt-4o/4.1/3.5 on aggregator providers ([#&#8203;3379](nesquena/hermes-webui#3379), [@&#8203;b3nw](https://github.com/b3nw), closes [#&#8203;3377](nesquena/hermes-webui#3377)).
- The `/model` slash command no longer selects a longer model variant when a shorter name is a prefix of it (e.g. `/model mimo-v2.5` selecting `mimo-v2.5-pro`). The fuzzy fallback now prefers an exact id/label match and otherwise the shortest matching option, applied to both the main and bare-name (`provider/...`) fallbacks ([#&#8203;3394](nesquena/hermes-webui#3394), [@&#8203;vanshaj-pahwa](https://github.com/vanshaj-pahwa), closes [#&#8203;3368](nesquena/hermes-webui#3368)).
- `GITHUB_TOKEN` and `GH_TOKEN` environment variables are now filtered from the Copilot credential pool alongside the seeded `gh`-CLI token, so a classic PAT (`ghp_*`) auto-detected from the environment no longer makes Copilot appear in the model picker when the Copilot API can't use it. User-specific `COPILOT_GITHUB_TOKEN` is still respected ([#&#8203;3382](nesquena/hermes-webui#3382), [@&#8203;happy5318](https://github.com/happy5318)).

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMDEuMSIsInVwZGF0ZWRJblZlciI6IjQzLjEwMS4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJyZW5vdmF0ZS9jb250YWluZXIiLCJ0eXBlL3BhdGNoIl19-->

Reviewed-on: https://git.erwanleboucher.dev/eleboucher/homelab/pulls/798
SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
…#3429 regression from nesquena#3366)

nesquena#3366 changed getModelLabel() to strip only the first /-segment (fixing nesquena#3360
multi-slash proxy IDs). That regressed URI-scheme IDs like Yandex
gpt://${FOLDER}/deepseek-v4-flash/latest — indexOf('/') lands inside the ://
and leaves /${FOLDER}/... path junk in the composer model chip. Detect a
scheme:// id, drop scheme+authority, and take the last meaningful path segment
(skipping ${...} env-var placeholders and bare version tails like latest).
Non-URI multi-slash IDs keep the nesquena#3360 first-segment-strip behavior unchanged.
Node-driven regression test covers the URI case + the nesquena#3360 non-regression.
@claw-io
claw-io deleted the fix/3360-model-selection-multi-slash branch July 6, 2026 02:32
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.

Bug: Model picker snaps to wrong model when multiple multi-slash IDs share the same base name

3 participants