fb2e8b0088a404d201c8fba8be7d8e98749569d2
527
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
fb2e8b0088 |
fix(custom-providers): route @custom:<slug> models to their own base_url (#7319)
Route @custom:<slug> models to their owning custom provider's base_url so a model picked from a second custom provider group resolves correctly instead of 400ing. Co-authored-by: b3nw <b3nw@users.noreply.github.com> |
||
|
|
b71c88f292 |
fix(workspace): preserve remote POSIX paths across Session & streaming callsites (#7131) (#7168)
Closes the remote POSIX workspace path preservation gap across Session and streaming. Profile-aware runtime path resolution. Co-authored-by: alfred-rootson <alfred.wojtasordzik@gmail.com> |
||
|
|
94fd2da830 |
Release: semantic fingerprint for the Codex catalog dependency (#7556) (#7558)
* fix(config): semantic fingerprint for the Codex catalog dependency (#7540) * fix(#7556): guard the recursive codex-cache strip against RecursionError Codex found a valid-but-deeply-nested models_cache.json crashes _codex_models_cache_fingerprint with RecursionError (the recursive strip ran outside the fallback try), 500ing /api/models. Move _strip_volatile_codex_cache_fields inside the existing encode try/except so a transform failure degrades to the stat-based fingerprint. +1 mutation-proven regression (forces the strip to raise; fails with RecursionError if the guard is reverted). * docs(changelog): stamp #7556 (semantic fingerprint for Codex catalog dependency) --------- Co-authored-by: webtecnica <webtecnica@gmail.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: n <a@n> |
||
|
|
b1286878a4 |
Release: models_discovered/discover_models live-catalog fix (#7409) (#7538)
* Apply reviewed change * fix(models): honor discover_models opt-out + preserve discovered catalog on live-probe failure Codex gate found two CORE edge cases in the #7404 fix: 1. discover_models:false must override models_discovered:true (explicit opt-out pins the configured allowlist; honor Agent string forms false/no/0). 2. a transient empty live-catalog probe dropped a discovered provider's models (cached empty ~24h); now falls back to configured discovered IDs merged with any static catalog (deduped, ordered first). Mirrors the Hermes Agent canonical semantics (model_switch_providers._discover_flag, model_switch._models_config_is_allowlist). Adds 3 mutation-proven regressions. * docs(changelog): stamp #7409 (models_discovered/discover_models live catalog) --------- Co-authored-by: evgenyponomarev <712802+evgenyponomarev@users.noreply.github.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: n <a@n> |
||
|
|
40a6a7696a |
feat: add GLM-5.3 Flash to Z.AI model list (#7323)
* feat: add GLM-5.3 Flash to Z.AI model list Add glm-5.3-flash next to glm-5.3 in _PROVIDER_MODELS["zai"] and mirror it in _FALLBACK_MODELS, following the existing glm-4.5 / glm-4.5-flash sibling pattern. Reasoning-effort gating needs no change: the >= 5.2 version gate already classifies the id at the full effort tier. Adds a regression suite asserting catalog presence, label, ordering, fallback entry, and end-to-end propagation through get_available_models(). * test: pin glm-5.3-flash sibling adjacency in zai provider catalog Per PR review feedback (#7323): the provider-catalog ordering test now asserts glm-5.3-flash immediately after glm-5.3 and glm-5.2 immediately after the flash sibling, matching the adjacency the fallback test already pins. The base<flash relative check is subsumed; flash<glm-5.1 is kept to pin 5.1 after the whole 5.3 generation. --------- Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com> |
||
|
|
cfdfe4db8f |
release: #7261 provider-native auxiliary model IDs (approved) (#7491)
* fix(settings): persist provider-native auxiliary model IDs Co-authored-by: lowlandsheperd <lowlandsheperd@users.noreply.github.com> * docs(changelog): stamp #7261 provider-native aux model IDs --------- Co-authored-by: n <a@n> Co-authored-by: lowlandsheperd <lowlandsheperd@users.noreply.github.com> |
||
|
|
2477d992f4 |
release: lock-disciplined STREAMS reads (#7339) (#7488)
* fix(streaming): lock-disciplined STREAMS reads via peek_stream() Writers pop STREAMS under STREAMS_LOCK, but four hot reads used bare STREAMS.get(): the streaming worker start, the gateway run start, and the two SSE attach paths in routes.py. Each is individually saved today by GIL-atomic dict.get plus a None-guard, but the check-then-act windows (get → register, get → subscribe) race teardown pops, and the bare pattern invites unprotected copies. peek_stream() centralizes the locked read; all four sites migrate with no behavioral change. * test: dual-patch config.STREAMS alongside routes.STREAMS in SSE seam tests peek_stream() reads the canonical api.config.STREAMS registry; tests that inject a fake registry by patching only the routes-level re-export left the helper reading the real (empty) registry and hung the SSE loop to the pytest-timeout ceiling. Mirror the test_issue6623 pattern: patch both bindings. * docs(changelog): stamp #7339 lock-disciplined STREAMS reads --------- Co-authored-by: Sebastian B <sb@wmc.sh> Co-authored-by: n <a@n> |
||
|
|
ad620c3211 | stage: merge #7356 (provider-hint slash-id) | ||
|
|
ec4bac9fc9 | fix: model picker search matches OpenRouter display names (#7228) | ||
|
|
1a31003e9e |
fix(config): gate custom provider hints on unique configured entries
Named custom providers are only routable when the slug resolves to a real, unique custom_providers[] entry. A stale session provider such as 'custom:missing' must not be minted into an @custom:missing:... route - resolve_model_provider() would take the named-provider lane and find no matching endpoint. _unique_custom_provider_entry() returns None for unknown slugs and raises AmbiguousCustomProviderError for collisions, matching the point-of-return guard used by resolve_model_provider. Addresses maintainer review on #7356. Adds two regression tests: unknown named custom slug stays bare, ambiguous normalized slug fails closed. |
||
|
|
da367cc692 |
fix(config): keep provider hint for slash ids when session provider differs
model_with_provider_context() passed any slash-bearing model ID through bare when the session provider was not OpenRouter/ACP/plugin and not listed under config.yaml -> providers:. That dropped the hint for portal providers (nous, nvidia, opencode-zen, opencode-go) and named custom providers (custom:<slug>), so resolve_model_provider() fell back to the profile default provider + base_url and sent a vendor-scoped id to the wrong endpoint (HTTP 404, e.g. upstage/solar-pro4:free to api.x.ai). Emit an explicit @provider:model hint for any known routable provider that differs from the configured default; keep the bare passthrough for unknown/ambiguous provider slugs so custom/proxy base_url routing stays in charge. Closes #7333. |
||
|
|
4528279cb2 |
fix: route overlapping custom_providers models by active endpoint (#7107)
* fix: route overlapping custom_providers models by active endpoint When two custom_providers[] entries list the same bare model id (e.g. dogapi and packyapi both advertising 'claude-sonnet-5'), resolve_model_provider scanned them in config write order and returned the first match, silently ignoring the active provider's base_url. A user whose model.base_url pointed at packyapi got routed to dogapi because dogapi appeared earlier in config. Now, when the active provider resolves to a named custom provider (custom:<slug>, including via model.base_url -> named-slug matching), that provider claims models it owns before the ordered scan runs. Falls through to the legacy first-match scan when the active provider is bare 'custom' with no base_url to disambiguate, so existing single-endpoint setups are unaffected. Adds 3 regression tests covering: shared-model active-endpoint wins, provider-unique model still routes by ownership, and bare-custom no-base_url preserves legacy order. * fix: extend active-endpoint routing to aux, handoff, and slug-collision paths Review follow-up: the overlapping-id fix must reach three sibling resolution paths that still routed on the wrong endpoint. 1. Auxiliary slot base_url (config.py set_auxiliary_model): a bare resolve_model_provider(model) ignored the SELECTED aux provider, so a custom:A slot was persisted with the active custom:B endpoint when both listed the model. Look up the selected provider's own custom entry via resolve_custom_provider_connection. (NOT model_with_provider_context here: the @custom:<slug>:model form resolves base_url to None, which would drop the base_url entirely.) 2. Handoff summary (routes.py): read s_obj.model_provider and pass model_with_provider_context(model, model_provider) so a session pinned to custom:A is not rerouted to the active custom:B. base_url is backfilled from the provider's own custom entry downstream. 3. Normalized-name slug collision (config.py resolve_model_provider): two distinct provider names can normalize to the same slug (e.g. "Foo Bar" and "foo-bar"). The active-slug guard now matches the EXACT entry by base_url instead of re-deriving it from the non-unique slug, and fails closed (falls through to the ordered scan) on genuine ambiguity. Adds 4 regression tests: normalized-slug base_url disambiguation, slug-collision fail-closed, aux-slot selected-provider base_url, and session-provider-context handoff routing. * fix: fail closed on custom-provider slug collisions and break aux deadlock resolve_model_provider() returns a provider slug (custom:<slug>) and the credential lookup (resolve_custom_provider_connection) later resolves the API key from the FIRST same-slug entry regardless of base_url. So when two distinct provider names normalize to the same slug (e.g. "Foo Bar" and "foo-bar") and both list the requested model, returning the slug silently pairs one entry's endpoint with another entry's credential. Now raise a clear AmbiguousCustomProviderError instead of guessing, on both the active-slug guard and the ordered-scan fall-through paths. Also: an explicit, unambiguous named custom provider now stays authoritative even when model.base_url is stale/absent and points at neither entry — a stale URL is no longer evidence to demote it to config order. And fix a self-deadlock in set_auxiliary_model(): it held the non-reentrant _cfg_lock while calling resolve_custom_provider_connection() -> get_config() -> reload_config_if_stale(), which re-acquires the same lock. The selected custom provider's base_url is now resolved inline from the config_data already loaded under the lock. Tests: collision cases now assert the ambiguity error; added a stale-URL explicit-provider regression and a watchdog-guarded aux no-deadlock regression (both fail before the fix). Co-authored-by: Cursor <cursoragent@cursor.com> * fix: fail closed on custom-provider slug collisions across all four paths The previous cut only guarded the symmetric bare-model case, and it built collision membership from entries that OWN the requested model. But credential resolution scans ALL same-slug entries and first-matches regardless of ownership, so several slug-only boundaries could still pair one entry's endpoint with another entry's credential — including the asymmetric case where only one colliding entry lists the model. Introduce one shared, pure/lock-safe helper (_custom_provider_slug_key + _unique_custom_provider_entry) that groups ALL named custom_providers entries by a single canonical slug normalization and raises AmbiguousCustomProviderError whenever a consumed slug maps to >=2 entries. Apply it at every slug-only boundary so endpoint and credential can never come from different entries: - bare resolution (resolve_model_provider): all-entry membership for both the active-slug guard and the ordered ownership scan (was owner-only). - provider-qualified @custom:<slug>:model return (the session/send/handoff model_with_provider_context shape), which previously bypassed the check. - credential lookup (resolve_custom_provider_connection), replacing its private first-match-by-slug scan and its duplicate _slugify. - auxiliary persistence (set_auxiliary_model), replacing its own inline first-match-by-slug; the ambiguity now propagates (fails the save) instead of being swallowed, while other best-effort errors stay non-fatal. Still resolves from the in-scope config_data so it remains lock-safe (no deadlock regression). Tests: add fail-closed regressions for the asymmetric A-first-non-owner/B-owner bare case (active-endpoint and bare-custom paths), the model_with_provider_context qualified handoff shape, the credential boundary called directly, and the auxiliary-save ambiguity (asserts nothing is persisted). All five fail before this change and pass after. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: one canonical custom-provider slug identity, point-of-return ambiguity, profile-scoped credentials Deep re-gate follow-up (5 findings): 1. Collision detection now derives from the single slug PRODUCER (_custom_provider_slug_from_name) instead of a looser private key, so producer-level collisions on punctuated/parenthesized names (e.g. 'Foo (Bar)' vs 'foo-bar', both -> custom:foo-bar) are detected. Previously the looser key normalized 'Foo (Bar)' to 'foo-(bar)' and missed the collision, reintroducing the endpoint-A/credential-B bug for those names. 2. The all-entry ambiguity check now runs ONLY at the point a custom:<slug> is about to be returned (via a _finalize() guard covering every custom-return in resolve_model_provider, including the final active-provider fallback), not up front. An unrelated collision on the active slug no longer blocks lanes that resolve to a different provider (@openrouter, a non-colliding @custom:other). 3. streaming.py now resolves endpoint AND credential inside ONE profile scope: the main path extends the scope over runtime-key + custom-provider resolution, and both self-heal retry paths bind the session profile. This stops a detached worker from pairing a named profile's endpoint with the default profile's key. 4. Handoff summary: AmbiguousCustomProviderError now returns HTTP 400 with the actionable rename message instead of degrading to a 200 local fallback the UI treats as success. 5. UI: aux + default model saves surface the server's message and abort (retain dirty state) instead of swallowing it into a generic failure / "settings saved"; the handoff catch shows the 400 rename message verbatim. AmbiguousCustomProviderError gains a .message attribute for verbatim forwarding. Tests: parenthesized-name collision fails closed on all direct paths; unrelated lanes are not blocked by an active collision; custom overrides resolve inside the passed profile scope; handoff returns 400 (not a persisted fallback); JS error-surfacing source guards. Verified the finding #1/#2 cases fail before and pass after (old key misses 'Foo (Bar)'/'foo-bar'; old up-front check over-blocks). Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com> |
||
|
|
747b5a6913 |
Batch release exp-v0.52.236 — dotted model labels (#6628) + MCP SDK pin (#6616) (#7103)
* fix(models): normalize dotted Bedrock/Vertex model IDs in labels
Split out of #6607 at reviewer request — it was unrelated to that PR's MEDIA
path handling and needed its own tests.
`us.anthropic.claude-opus-5` carries a cross-region routing prefix plus a vendor
namespace, and `mistral.mistral-large-2407-v1:0` adds a provisioned-revision
suffix. None of it belongs in a human label, so the turn footer rendered
"Us.anthropic.claude Opus 5".
Strip only the two shapes these hosts actually publish, against a CLOSED
provider allow-list:
<region>.<vendor>.<model> us.anthropic.claude-opus-5
<vendor>.<model> mistral.mistral-large-2407-v1:0
A generic "drop leading letters-only dot segments" loop was rejected because it
rewrites arbitrary uncatalogued IDs: `deepseek.v3` rendered as "V3" (vendor name
silently deleted) and `foo.bar.baz` as "BAZ". Dropping a vendor is additionally
gated on the remainder still naming the model, so `deepseek.v3` — where the
vendor IS the name — is left byte-intact.
Backend and frontend are paired: tests/test_dotted_model_label.py drives one
table through both `_get_label_for_model()` and `_stripDottedModelPrefix()` and
fails on divergence, including version dots (`gpt-4.1`, `qwen3.6-35b`),
URI-scheme IDs, and unknown vendors.
The Python half is inlined inside `_get_label_for_model` on purpose: the #3429
harnesses extract that function's source and eval it in isolation, so a
module-level helper NameErrors there. The #3429 JS driver is updated to pull in
the new helper for the same reason.
* fix(models): recognize `global` as a Bedrock routing prefix
Re-review catch, and a real gap: the catalog ships six
`global.anthropic.claude-*` IDs (api/config.py:1901-1909) and the first-party
routing notes use `global.anthropic.claude-…` as the canonical Bedrock shape,
but `global` was missing from the region allow-list. All six therefore kept the
noise this change exists to remove:
global.anthropic.claude-opus-4-7 -> "Global.anthropic.claude Opus 4 7"
Added to the region set in both implementations, which now read identically.
The root cause is two lists that must agree — the region allow-list and the
shipped catalog — so the new guard is catalog-driven rather than another
hardcoded region list: it scrapes every dotted `<head>.<vendor>.<model>` ID out
of api/config.py and asserts none of them keeps a routing/vendor namespace in
its label. A future routing prefix added to the catalog without updating the
region set fails there, instead of shipping mislabeled.
Also pins all six `global.*` IDs plus `us-gov` in the shared strip table (no
suite covered `us-gov` before either), and verified by mutation: dropping
`global` from the Python set fails 3 tests, dropping it from only the JS set
fails 2 — so backend/frontend parity drift is caught, not just a total absence.
Note: the review cited tests/test_provider_prefix_label_normalization.py and
tests/test_ui_model_label_parity.py as the suites to extend; neither exists in
this tree (nothing under tests/ referenced `us-gov` at all), so the cases live
in tests/test_dotted_model_label.py alongside the existing paired coverage.
* fix(models): add missing Bedrock vendors; make the catalog guard actually broad
Two self-review findings (hostile critic pass). Both are the same class as the
`global` gap the reviewer already caught, which means the guard I added for that
gap was too weak to prevent a recurrence.
1. Missing vendors. `luma`, `twelvelabs` and `ibm` are real Bedrock
foundation-model vendors and were absent from the allow-list, so genuine IDs
shipped with the namespace intact:
luma.ray-2 -> "Luma.ray 2"
us.twelvelabs.marengo-embed-2-7 -> "Us.twelvelabs.marengo Embed 2 7"
us.ibm.granite-3-8b -> "Us.ibm.granite 3 8B"
Added those plus `nvidia` and `snowflake` to both implementations.
2. The catalog guard inspected 6 of 75 dotted IDs. Its scrape regex only matched
three-segment `"id": "<region>.<vendor>.<model>"` literals with double quotes,
so the entire two-segment `<vendor>.<model>` shape -- the OTHER documented
shape this PR handles -- was invisible to it. A test that reads 8% of the
corpus while claiming to cover the catalog is worse than no test, because it
reads as proof.
Rewritten to scrape any quoted `id` value regardless of quote style or segment
count (75 dotted IDs now inspected), and to DERIVE the namespace heads from the
production `_regions`/`_vendors` literals instead of retyping them, so a set
that grows without a test update is still covered. Version dots (`qwen3.6-plus`,
`gpt-5.4`) are correctly skipped as non-namespaces.
Also added an explicit per-vendor round-trip test. It asserts no dotted NAMESPACE
survives rather than that the vendor word is absent, because a vendor legitimately
reappears inside some model names (`mistral.mistral-large-2407` -> "Mistral Large
2407") -- my first version of that assertion was wrong for exactly that reason.
Verified by mutation: removing the new vendors fails the round-trip test; deleting
the strip entirely fails 6 tests.
* test(models): drive real getModelLabel() in the parity oracle
The paired test compared _get_label_for_model() against itself, so JS
getModelLabel() never ran and nothing asserted the two sides agreed. It
stayed green when the JS dotted strip was reverted to a no-op AND when the
whole post-strip retry chain was deleted -- blind to both.
Replaced with two tests driven through the real getModelLabel() under Node,
reusing the driver already proven in test_issue3429_uri_scheme_model_label.
Only sinks are stubbed (_dynamicModelLabels empty as it is pre-fetch,
_fmtOllamaLabel identity); every decision function is the shipped source.
The oracle asserts the actual contract -- no routing/vendor namespace leaks
into either label -- rather than string equality of the two labels. Label
divergence is pre-existing and by design: at base
|
||
|
|
92c790ffe5 |
Batch release exp-v0.52.235 — cancelling-run reattach (#7096) + GPT-5.6 max reasoning (#7083) + test order-independence (#7101) (#7102)
* fix(tests): make mtime_invalidation and glm_5_3 tests order-independent (#7100) * fix: expose max reasoning for GPT-5.6 models * fix(recovery): do not reattach cancelling runs ACTIVE_RUNS tracks worker lifecycle, which is deliberately broader than "a turn a browser may attach to". cancel_stream() keeps the row as phase="cancelling" while the worker unwinds so a successor turn cannot start on top of it, but the client has already reached a terminal state for that stream: its run journal ends in a terminal event. The recovery lookups treated every same-session ACTIVE_RUNS row as attachable. An idle session holding a cancelling row therefore received a recovered server_turn_started on every /api/session/stream subscription: the client attached, replayed the terminal event, tore the renderer down, resubscribed, and the server replayed the same frame again. The result is an endless attach/replay loop that rebuilds the transcript repeatedly. Separate the two meanings instead of narrowing one call site: - api/config.py gains active_run_is_attachable() and active_run_cancel_is_stale() as the shared predicates. - active_stream_id_for_session() (browser recovery) returns attachable rows only; _session_has_active_turn() (busy check) keeps counting a fresh cancellation so a successor cannot overlap the unwinding worker. - _live_active_stream_id() applies the same rule to the hidden-tab status poller, on both the STREAMS and ACTIVE_RUNS paths. - routes._cancelled_run_is_stale() now delegates to the shared predicate rather than keeping a parallel copy of the staleness rule. - A cancelling row past a bounded unwind window with no live STREAMS channel is reclaimed from ACTIVE_RUNS and its stream owner released, so a wedged worker cannot suppress background wakeups forever. Age alone does not reclaim a row that still owns a live channel. Staleness anchors on cancelled_at, falling back to started_at, so a long-running turn cancelled moments ago is never treated as an orphan. tests/test_cancelling_run_not_attachable.py covers both directions of each rule. The six behavioral tests fail on the unpatched tree and pass with the fix; two targeted mutations (forcing the attachability predicate true, and disabling the staleness reaper) each turn the suite red. * docs(rfc): document cancellation attach/admission split Records the runtime contract introduced by the recovery fix in the WebUI run-state consistency RFC, so the distinction is discoverable instead of living only in code comments. - Adds ACTIVE_RUNS to the State Layers table as the worker-lifecycle registry, explicitly not the set of runs a browser may attach to. - Adds invariant 9: lifecycle-busy is not client-attachable. Cancellation splits the two meanings, recovery paths must exclude cancelling rows, and admission checks must keep counting them. - Documents the bounded cancellation-unwind window: reclamation needs both age and the absence of a live STREAMS channel, and staleness is anchored on the cancellation timestamp. - Extends the review checklist with the admission-vs-attachment question and the evidence required when changing a reclamation window. * docs(changelog): note #7096 cancelling-run reattach, #7083 GPT-5.6 max reasoning, #7101 test order-independence --------- Co-authored-by: webtecnica <webtecnica@gmail.com> Co-authored-by: Abdulrahman Elkenany <boudy.elkenany123@gmail.com> Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com> Co-authored-by: n <a@n> |
||
|
|
d13e922120 |
Release batch B: Docker single-container UID fix + GLM-5.3 catalog (experimental) (#7090)
* feat: add GLM-5.3 to Z.AI model list Add glm-5.3 as the newest zai entry in _PROVIDER_MODELS and a matching zai/glm-5.3 entry in _FALLBACK_MODELS, and bump the Z.AI onboarding default_model from glm-5.1 to glm-5.3 (Z.ai's current flagship; legacy GLM-5.2/5.1 requests are routed to GLM-5.3 per docs.z.ai). Reasoning gating needs no change: _zai_glm_classification() treats GLM >= 5.2 as effort-ladder capable, so glm-5.3 is already covered and pinned by tests/test_zai_reasoning_effort_gating.py. New regression coverage in tests/test_glm_5_3_catalog.py: catalog presence, newest-first ordering, fallback entry, onboarding default, full reasoning_effort ladder, and the get_available_models() payload. * test: restore _cfg_fingerprint in catalog test fixture Review follow-up (#7017): the isolation fixture snapshot restored cfg, _cfg_mtime, and _cfg_path but left _cfg_fingerprint pointing at the temporary config loaded by the payload test. api/config.py uses that fingerprint to distinguish in-memory overrides from changed files (config.py:371), so a stale value could make later same-process tests skip reloading a changed config. Snapshot and restore it like the rest. * fix: keep Z.AI onboarding default at glm-5.1 until direct API serves GLM-5.3 Review follow-up (#7017): GLM-5.3 is live on Z.ai's Coding Plan endpoint only; the direct api.z.ai endpoint the zai provider uses still lists the GLM-5.3 API as coming soon. Defaulting new direct-API users onto glm-5.3 would fail their first message, so the catalog addition stays (opt-in) and the default stays glm-5.1. Bump the default in a follow-up once the direct endpoint serves GLM-5.3. * fix(docker): probe the configured state dir before /workspace for UID/GID (#7027) UID/GID auto-detection probed /workspace before the configured HERMES_WEBUI_STATE_DIR. In a stock single-container image none of the priority-1 candidates exist, but /workspace does — owned by the image's build-time 1024:1024. Detection therefore returned the image's own owner, which carries no information about the host, while the one directory whose owner *is* the host UID by definition — the state-dir bind mount — was never probed. With a host-owned state mount and no explicit WANTED_UID the container remapped to 1024, failed its own state-dir writability check, and restart-looped. The log line made this expensive to debug: 1024 is also the fallback default, so "Auto-detected workspace UID: 1024" read as if detection had found nothing. - probe ${HERMES_WEBUI_STATE_DIR:-/app/data} first, for both UID and GID - keep /workspace as a lower-priority signal (unchanged for setups that actually bind-mount it), and keep the hermes-home probes from #668 - stop treating an explicitly supplied 1024 as "unset": the sentinel and a valid UID were the same number, so an operator who deliberately ran as 1024 got it overwritten by detection. The explicit/detected origin is persisted next to the value because `su` drops the environment when the script re-enters as the runtime user, so the second pass would otherwise see an explicit choice as a detected one. Tests: tests/test_7027_state_dir_uid_probe.py runs the real resolution block under bash with `stat` stubbed, covering the non-1024 state mount, the explicit 1024 override (including across the privilege drop), and the pre-existing /workspace + hermes-home fallback paths. A new state-dir-uid job in the Docker smoke workflow boots a real container on a host-owned state mount and gates on /health. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Release batch B: Docker UID fix + GLM-5.3 catalog (experimental) Two independently gate-passed contributor PRs, rebased fresh onto master and re-gated as a combined stage (Codex SAFE TO SHIP; full suite green except the 8 pre-existing approval-test failures that fail identically on clean origin/master — CI green on the same commit; tracked separately for a fix). - #7027 (@jorgejiro) probe configured state-dir before /workspace for Docker UID/GID; persist explicit marker so a supplied 1024 survives root->su re-entry (fixes the single-container restart loop) (#7034) - #7017 (@rh-id) add GLM-5.3 to the Z.AI model list (onboarding default stays glm-5.1) Co-authored-by: jorgejiro <jorgejiro@users.noreply.github.com> Co-authored-by: rh-id <rh-id@users.noreply.github.com> --------- Co-authored-by: Ruby Hartono <58564005+rh-id@users.noreply.github.com> Co-authored-by: Jorge <jorgejiro@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: n <a@n> Co-authored-by: jorgejiro <jorgejiro@users.noreply.github.com> Co-authored-by: rh-id <rh-id@users.noreply.github.com> |
||
|
|
0b69346970 |
Release: provider-filter + memory diagnostics (#6338 #6363) (#6994)
* fix(#6335): filter unconfigured providers from model selector When a known provider (e.g. openai-api) is listed in config.yaml's providers section with only metadata (name, display_name) and no route configuration or credentials, it should not appear in the chat model selector dropdown. Previously, any provider recognised as a 'known provider' (present in _PROVIDER_MODELS or _PROVIDER_DISPLAY) would be admitted to the picker simply by being listed in the config.yaml providers block — even after the user removed its API key. This caused a persistent ghost provider entry in the dropdown (#6335). The fix gates known-provider admission on credential evidence: the provider must already have been detected from a credential source (env vars, hermes auth, or credential pool) OR carry explicit route configuration (api/base_url/api_key/key_env) OR be the active provider with a configured models allowlist. * fix(#6338): normalize provider aliases in credential check The credential check compared _canonical (config key like 'x-ai', 'google') directly against detected_providers (alias-resolved values like 'xai', 'gemini'). Alias-named authenticated providers were therefore incorrectly treated as unconfigured and removed from the model selector. Fix: resolve _canonical through _resolve_provider_alias() before the membership test, so config keys like 'x-ai' match credential evidence reported as 'xai'. Also check the raw canonical for cases where the config key is itself the alias target. Refs: #6335, #6338 * fix(#6338): normalize detected_providers through alias resolver Normalize both _canonical and detected_providers entries through _resolve_provider_alias before the membership test, so that a WebUI canonical form in detected_providers (e.g. x-ai added by a prior loop iteration) also matches the alias-resolved form used in credential detection (e.g. xai from hermes_cli). This is belt-and-braces on the existing _resolve_provider_alias(_canonical) check: both sides now map into the same alias-resolved namespace, closing any edge case where detected_providers contains a WebUI-canonical form that happens to differ from the alias target. * test(ui): catalog-admission regression for credentialed providers (#6338) * test(ui): pin config path state in catalog-admission regression (#6338) * fix(system-health): expose runtime memory diagnostics without blocking owner locks (#6351) * fix(system-health): keep owner contention local to runtime status (#6351) * docs(changelog): stamp #6338 #6363 (certified batch) --------- Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> Co-authored-by: webtecnica <webtecnica@gmail.com> Co-authored-by: Rod Boev <rod.boev@gmail.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> |
||
|
|
64c2c12df7 |
feat(sidebar): add show_kanban_sessions toggle to hide kanban worker sessions (#6780)
* feat(sidebar): add show_kanban_sessions toggle to hide kanban worker sessions * fix(sidebar): kanban source-filter override + filtered-view cap (cron/webhook parity) Gate re-review (Codex) found the kanban-hide addition missed cron/webhook parity for the source-filter case: - An explicit kanban source_filter now overrides the default hide (threaded source_filter into _dedupe_cli_sidebar_sessions_for_api, normalized with strip().lower() to match the loader), so a deliberate kanban view shows those rows even with the toggle off. Scoped to the matching source only. - A dedicated KANBAN_PROJECT_CHIP_LIMIT bounds a filtered kanban-only view, mirroring CRON/WEBHOOK_PROJECT_CHIP_LIMIT. Deliberately NOT changing the general exclude_sources: the original PR lets kanban load on the source_filter=None path so the toggle-ON case works (the hide is applied by _hide_from_default_sidebar only when the toggle is off). Excluding kanban there would save the 20-row budget but break toggle-on unless a bounded kanban merge is added (cron/webhook don't do that either) — that query-budget optimization is deferred as a separate cross-cutting change. The pinned-survives-hide finding is also out of scope (cron/webhook hide pinned identically, confirmed by Codex). Adds bite-tested regressions. Co-authored-by: vduke <vduke@users.noreply.github.com> --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> |
||
|
|
064e95a38a |
fix(streaming): reject obsolete cancel finalizer after successor completes (#6636)
Prevents a delayed cancelled streaming worker from writing back stale output over a completed successor's result, and clears the Gateway writeback-owner entry on teardown/pre-start-cancel to avoid a slow SESSION_WRITEBACK_OWNERS leak. Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> |
||
|
|
c1fc3f2c79 |
Release exp-v0.52.178: honor configured appearance defaults for no-settings-file users (#6834) (#6849)
* fix(settings): _SETTINGS_DEFAULTS theme/skin never reach a user with no settings file
`_read_raw_settings_file()` returns `{}` for a MISSING settings.json. `{}` is a
dict, so `load_settings()` always took its `isinstance(stored, dict)` arms:
settings["theme"], settings["skin"] = _normalize_appearance(
stored.get("theme") if isinstance(stored, dict) else settings.get("theme"),
stored.get("skin") if isinstance(stored, dict) else settings.get("skin"),
)
`stored.get("theme")` is then None, `_normalize_appearance(None, None)` falls
through to its unknown-theme branch, and the result is ("dark", "default").
`_SETTINGS_DEFAULTS["theme"]` and `["skin"]` are therefore unreachable for the
one case they exist to serve: a user who has no settings file yet.
This is invisible on the shipped defaults, because dark/default is exactly what
the fallback produces — the two paths agree, so nothing ever caught it. It only
surfaces once the defaults are changed, at which point the dict silently does
nothing: a deployment (or fork, or a future change to the shipped values) sets an
appearance and every new user still gets dark.
The fix falls back to the defaults instead of to None, gated on the PAIR of
keys. Per-field (`or settings.get(...)`) looks equivalent and is not: with a
stored legacy theme and no stored skin, `slate` normalises to ("dark", "slate"),
but per-field fallback injects the default skin and returns ("dark", "default"),
silently dropping the legacy migration. That is the same absent-vs-explicit
distinction the boot script draws in #6808 — this is its server-side half. #6808
stops the browser fabricating a choice; without this, the server still answers
with the fallback rather than the configured default.
Behaviour on stock defaults is provably unchanged: `_normalize_appearance(None,
None)` and `_normalize_appearance("dark", "default")` both return
("dark", "default"). A stored preference is untouched in every case.
13 tests. Both directions are pinned, and both were checked by injection:
reverting api/config.py fails exactly the two "defaults are honoured" cases and
nothing else; substituting the per-field variant fails 8, including four of the
five legacy migrations. Ran the full settings/appearance surface — 61 test files,
873 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Release exp-v0.52.178: honor configured appearance defaults for users with no settings file (#6834)
---------
Co-authored-by: Hermes Agent <hermes@aip.de>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
|
||
|
|
e2b052a637 | fix(workspace): persist recovered session bindings | ||
|
|
a57d576805 |
fix: make prefixed Qwen detection positive-only to avoid shadowing hybrid IDs
Gate review found that the broadened token-prefix check caused
deepseek-r1-distill-qwen2.5-bakeneko-32b to enter the Qwen branch
(qwen2 token), return False, and prevent the DeepSeek detector from
running — a silent regression.
Fix: split into two phases:
1. Positive-only scan: any token matching qwen(\d+) with major >= 3
immediately returns True. No False path — Qwen 2.x in hybrid IDs
falls through to subsequent detectors.
2. Terminal guard (original behavior): standalone "qwen" token or
normalized.startswith("qwen") still applies the 3+ gate with
return False for 2.x, since those ARE pure Qwen models.
Add regression test for deepseek-r1-distill-qwen2.5-bakeneko-32b.
|
||
|
|
90250adfb7 |
fix: address review — remove redundant guard, add regression tests
- Drop the now-subsumed `normalized.startswith("qwen")` guard
(token-prefix matching already covers the bare case)
- Add test_qwen_prefixed_alias_reasoning_detection covering:
prefixed Qwen 3+ (al-/sn-), bare Qwen 3+, and Qwen 2.x exclusion
|
||
|
|
041e311f1e |
fix: Qwen reasoning heuristic fails for prefixed model IDs
The Qwen branch in _candidate_supports_reasoning() checks
"qwen" in token_set, but model IDs like "al-qwen3.8-max-preview"
(New-API alias prefixes) normalize to tokens ["al", "qwen3", "8", ...]
where "qwen" is not a standalone token. This causes the reasoning
effort toggle to disappear for any prefixed Qwen model.
Switch to token-prefix matching (token.startswith("qwen")), consistent
with the position-independent approach already used for DeepSeek
(tokens.index). This handles arbitrary vendor/alias prefixes while
still correctly excluding Qwen 2.x via the existing version gate.
|
||
|
|
0d06ae3467 | Merge remote-tracking branch 'origin/master' into stage/6040 | ||
|
|
f39cedafca | stage: merge #5797 | ||
|
|
65a3f795eb | stage: merge #6372 | ||
|
|
f30178e18a | fix(gateway): fall back safely without approval identities | ||
|
|
e9e9ed09a5 |
Release: #6148 alias-prefix model routing fix (exp-v0.52.141) (#6418)
* fix: resolve provider alias before matching _PROVIDER_MODELS (#6148) * fix(backend): resolve model prefix through _PROVIDER_ALIASES before matching - Fix IndentationError: dedent _canon_prefix assignment to 8 spaces - Rebased onto upstream/master - Add regression test: alias prefix (z-ai) resolves to canonical key (zai) and routes cross-provider through OpenRouter (#6131) * fix(#6131): canonicalize both sides via _canonicalise_provider_id (idempotent) + regression tests Codex found _resolve_provider_alias breaks Qwen (qwen->alibaba, not in _PROVIDER_MODELS) and cross-routes alias-of-active-provider (raw vs canon asymmetry). Use the idempotent _canonicalise_provider_id for both membership and equality. Adds 2 regression tests. * docs(changelog): stamp #6148 alias-prefix model routing fix --------- Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com> Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: nesquena-hermes <agent@webui> |
||
|
|
478f7749ab |
Release: #6107 per-model reasoning_efforts precedence (exp-v0.52.140) (#6417)
* fix: honor per-model reasoning effort metadata * docs(changelog): stamp #6107 per-model reasoning_efforts precedence --------- Co-authored-by: Charles Inglis <6466275+CharlesMcquade@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: nesquena-hermes <agent@webui> |
||
|
|
700bd976c1 |
fix(media): narrow MIME_MAP to only .ts/.tsx text/plain, add regression tests
- Add only .ts→text/plain and .tsx→text/plain to MIME_MAP (exclude .js, .jsx, .css and other unsafe mappings that would make workspace files executable as same-origin scripts) - Add test_ts_artifact_served_as_text_plain_with_attachment — asserts Content-Type: text/plain, Content-Disposition: attachment, and X-Content-Type-Options: nosniff for .ts files via /api/media - Add test_tsx_artifact_served_as_text_plain_with_attachment — same for .tsx - Add test_file_raw_js_not_served_as_inline_executable — proves that a workspace .js file falls through to application/octet-stream instead of text/javascript via /api/file/raw Closes #6372 |
||
|
|
c0d9d81512 |
Release: add preference to hide new-chat welcome panel (#6183, @vaidu-ai) (#6387)
* feat: add option to hide new-chat welcome panel * Release: add preference to hide new-chat welcome panel (#6183, @vaidu-ai) --------- Co-authored-by: vaidu-ai <im@vaidu.net> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> |
||
|
|
a725a0f71c |
Release p1-batch: session cache cap 300→100 (#6362) + virtualization comment (#6318) (#6375)
* perf(transcript): enable DOM virtualization by default (re-enable #4346 fix) (#6151) (#6155) * fix: revert DOM virtualization default to opt-in, fix gate RED (#6155) * fix(#6351): lower default session cache cap * Release p1-batch: session cache cap 300->100 (#6362) + virtualization comment (#6318) --------- Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com> Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> Co-authored-by: Rod Boev <rod.boev@gmail.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> |
||
|
|
4bdc22b7ce |
Release: gateway-default MoA send (#5869, @rodboev) (#6365)
* fix(#5853): allow gateway-default MoA sends * fix(#5853): freeze gateway auth to one locked snapshot * docs(changelog): stamp #5869 gateway-default MoA send --------- Co-authored-by: Rod Boev <rod.boev@gmail.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> |
||
|
|
5771a511e7 |
fix(#6099): make transparent stream activity timestamps optional (#6130)
Add an opt-in setting to hide Transparent Stream's per-event timestamp chips while keeping the response footer time visible, for users who found the per-event chips noisy. Thanks @rodboev. Closes #6099. |
||
|
|
d195b094bb |
Release: GLM per-version reasoning controls (#6219, @rh-id) (#6243)
* fix(reasoning): gate reasoning_effort ladder to GLM-5.2+ on native zai
Z.AI's API (docs.z.ai) defines reasoning_effort as GLM-5.2+ exclusive, but
hemes-webui advertised the full 6-level ladder for all 7 GLM models because
_candidate_supports_reasoning has an unconditional glm token match and
_filter_reasoning_efforts_for_provider had no ZAI branch. Six of seven catalog
models (glm-5.1, glm-5, glm-5-turbo, glm-4.7, glm-4.5, glm-4.5-flash) showed a
selector whose values the endpoint silently ignores, and GLM-4.7 (forced thinking
that cannot be disabled per Z.AI docs) showed a 'none' option with no effect.
Add a ZAI branch to _filter_reasoning_efforts_for_provider mirroring the existing
OpenAI/Gemini/Anthropic ceiling pattern: strip the whole ladder for pre-5.2 GLM
models and for the forced-thinking GLM-4.7 family; preserve the full ladder for
GLM-5.2+ (whose accepted values match VALID_REASONING_EFFORTS exactly). The gate
is scoped to the native zai provider only (aliases glm/z-ai/z.ai/zhipu all
resolve to zai); aggregator providers are untouched because they route through
their own routers, not Z.AI's native endpoint.
The glm family-detection heuristic in _candidate_supports_reasoning is unchanged
— GLM models DO support the thinking on/off toggle at the family level; this fix
is specifically about the reasoning_effort intensity ladder.
State layer: agent.reasoning_effort config + UI dropdown options derived from
resolve_model_reasoning_efforts. Invariant: UI options and coercion now agree
and match Z.AI's per-model docs (max offered only for GLM-5.2+, none never
offered for forced-thinking models). Out of scope: the thinking:{type:...}
request-field translation lives in the external agent/gateway layer.
* fix(reasoning): close ZAI coercion gap + harden test assertions
Address Greptile review on #6219 (round 1):
1. Vacuous test assertion (test_glm_5_2_preserves_none_sentinel): the 'or'
fallback made the assertion always-true, so a regression stripping 'none'
for GLM-5.2 would go undetected. Rewrote to inject 'none' via the raw source
(mocking _resolve_model_reasoning_efforts_impl) and assert it survives — the
test now genuinely exercises the preservation branch.
2. Coercion gap for non-max stored levels on pre-5.2 GLM: the existing
'if ceiling and raw not in ceiling' guard treats an empty ceiling as 'no
rule' (preserving the configured effort verbatim per #3505), so a stored
'high'/'medium'/'low' for glm-5.1/glm-4.5/glm-4.7 on native zai was forwarded
to Z.AI unchanged and silently ignored — contradicting the PR's stated
UI/coercion agreement invariant.
Root cause: the ZAI gate returns [] to mean 'known-empty' (no
reasoning_effort at all), but the coercion path treated all [] as
'ambiguous/unknown, preserve verbatim'. Fixed by extracting the ZAI decision
into _zai_glm_reasoning_efforts_supported (True/False/None sentinel) shared by
both the filter and coercion, then special-casing the known-False result in
coerce_reasoning_effort_for_model to return '' (send no field). The #3505
preserve-verbatim behavior for genuinely-unknown models on non-zai providers
is unchanged.
Added 10 regression tests (all fail before the coercion fix, pass after):
- All 6 levels (max..minimal) coerce to '' for each pre-5.2 GLM + glm-4.7
- All 4 aliases (glm/z-ai/z.ai/zhipu) resolve through the same coercion gate
- GLM-5.2 preserves all 6 levels verbatim
- Regression guard: unknown model on custom: provider STILL preserves verbatim
State layer: agent.reasoning_effort config + the value forwarded to Z.AI.
Invariant now fully holds: UI offers no options for pre-5.2 GLM AND coercion
sends no reasoning_effort field for any stored level on those models.
* fix(reasoning): preserve ZAI GLM 4.5-5.1 thinking toggle when effort ladder empty
Address nesquena-hermes round-2 review on #6219: returning [] for the effort
ladder on sub-5.2 GLM models hid the entire reasoning chip in the composer
(static/ui.js:4932 treats empty supported_efforts as 'no reasoning control at
all'), silently regressing the working thinking on/off toggle for GLM-4.5/4.6/
5.0/5.1 users. Per Z.AI's own docs, those models accept the thinking
{type:enabled|disabled} toggle even though they do not accept the
reasoning_effort intensity ladder.
Fix decouples thinking-toggle capability from the effort ladder:
Backend (api/config.py):
- Refactor _zai_glm_classification returns one of 'effort' (GLM-5.2+), 'thinking'
(GLM-4.5 up to but not including 5.2), 'forced' (GLM-4.7 family), or None
(non-zai / non-GLM). Single source of truth shared by all three consumers.
- _zai_glm_reasoning_efforts_supported now wraps classification for the coercion
contract (unchanged behavior).
- _zai_glm_thinking_toggle_supported returns True for 'effort' or 'thinking',
False for 'forced', None otherwise.
- get_reasoning_status gains a supports_thinking_toggle field = bool(supported)
OR (zai_thinking is True). Non-zai providers default to bool(supported_efforts)
so their chip-visibility behavior is unchanged.
Frontend (static/ui.js):
- New _currentReasoningToggleSupported state var (default undefined = treat as
true so legacy responses without the field do not newly hide the chip).
- _applyReasoningChip shows the chip when hasEffortLadder OR toggleSupported.
Empty efforts + toggle=True keeps the chip visible with just the None/On
control (the existing _applyReasoningOptions already shows 'none' when the
ladder is empty). Empty efforts + toggle=False (GLM-4.7 forced) hides it.
- Profile-transition and fetch-failure resets now pass
supports_thinking_toggle:false alongside the empty efforts so the chip hides
during the unknown-state window, matching the prior reset contract.
Tests (30 new):
- _zai_glm_classification parametrized across all three tiers + aliases + defer
- get_reasoning_status supports_thinking_toggle per tier (GLM-5.2 both, GLM-4.6
toggle-only, GLM-4.7 neither, non-zai defaults to effort capability)
- Frontend _applyReasoningChip behavior via node driver: empty efforts +
toggle=True stays visible, toggle=False hides, effort ladder alone is
sufficient, absent field keeps prior behavior
State layer: agent.reasoning_effort config + supports_thinking_toggle field in
/api/reasoning + composer chip visibility. Invariant: the chip is hidden ONLY
when the model supports neither the effort ladder nor the thinking toggle
(GLM-4.7 forced, or genuinely non-reasoning models); GLM-4.5-5.1 retain the
working On/None control they had before the round-1 effort gate.
* fix(reasoning): make ZAI thinking toggle two-way + force GLM-4.7 stored none
Address nesquena-hermes round-3 review on #6219 — two SILENT gaps in the
thinking-toggle path, plus a click-handler sibling I found while auditing.
Gap #1 — ONE-WAY toggle for GLM-4.5/4.6/5.0/5.1 (api/config.py:4247,
static/ui.js:4902, static/index.html:757):
The round-2 fix kept the chip visible for thinking-tier models but the only
rendered dropdown option was 'None' (the HTML had no Default option, and
set_reasoning_effort rejected empty effort with 400). So a GLM-4.6 user could
turn thinking OFF but never back ON — worse than the original bug.
Fix:
- set_reasoning_effort now accepts empty effort as 'clear the override' (removes
agent.reasoning_effort so the provider default takes effect). Invalid values
still raise ValueError.
- static/index.html gains a <div data-effort=''>Default</div> option.
- _applyReasoningOptions always shows both Default ('') and None alongside the
effort ladder, so a thinking-tier model (empty ladder + toggle=true) renders
an operable Default+None two-state control.
- Click handler (ui.js:5106) checks option presence (if(opt)) not truthiness
(if(effort)) — the old check silently ignored data-effort='' clicks, which
would have left the Default button dead even after the HTML/backend changes.
Gap #2 — GLM-4.7 not forced when 'none' stored (api/config.py:3984, :3857):
When GLM-4.7 had agent.reasoning_effort=none configured, coercion preserved
'none' via the early return at line 3985, so streaming built disabled reasoning
for a model that forces thinking on regardless. Separately, when the raw
capability source listed 'none', resolve_model_reasoning_efforts reattached it
to GLM-4.7's supported_efforts (['none']), leaking an 'off' option to the UI
for a forced-thinking model.
Fix:
- coerce_reasoning_effort_for_model checks _zai_glm_classification == 'forced'
BEFORE the generic 'none' early-return, coercing stored 'none' to '' (default
= thinking on) for forced models.
- resolve_model_reasoning_efforts returns [] early for forced-tier models,
skipping the 'none' reattachment entirely.
Tests (17 new):
- Gap #2: coerce('none', glm-4.7) -> '', regression guard that non-forced GLM
still accepts 'none'; resolve does not reattach 'none' for forced but DOES
for thinking-tier; end-to-end get_reasoning_status for forced+stored-none.
- Gap #1 backend: set('') clears the key (no raise), set('garbage') still
raises, all 7 valid levels still save.
- Gap #1 frontend: three new node-driver tests asserting the dropdown exposes
both Default and None for thinking-tier (two-state), Default+None+ladder for
effort-tier, and the off->on->off round trip keeps both visible throughout.
- Updated test_reasoning_show_hide.test_set_reasoning_effort_rejects_invalid to
reflect the new contract (empty accepted, garbage rejected).
Regression gate: gap#2 coerce tests fail without the forced-tier check (3
failures); gap#1 backend test fails with 'ValueError: effort is required'
without the empty-acceptance change. 219 passed, 1 pre-existing skip across
the full reasoning + chip + config-cache surface.
State layer: agent.reasoning_effort config + supports_thinking_toggle field +
composer dropdown options. Invariant: the thinking toggle is now genuinely
two-way for GLM-4.5-5.1 (Default=on, None=off, both always visible) and
GLM-4.7 forced-thinking never offers 'none' in any path (coerce, resolve,
status, or UI).
* CHANGELOG: GLM per-version reasoning controls (#6219)
---------
Co-authored-by: Ruby Hartono <58564005+rh-id@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
|
||
|
|
828b244031 |
Atomic config.yaml writes to survive mid-write crashes
config.yaml and profile config.yaml were persisted with a plain Path.write_text(), which truncates the target before writing. A crash (or exception) after the truncate but before the full payload was flushed left the live config truncated/corrupt, so the next agent/WebUI start failed to parse it (availability regression). Extract a shared api.paths._atomic_write_text() helper (tempfile in the same dir -> write -> flush + os.fsync -> os.replace; unlink tmp on error), mirroring the existing .env / cost-snapshot atomic pattern in api.providers, and apply it to _save_yaml_config_file (api.config) and the two profile model-config writers (api.profiles). On any mid-write failure os.replace never runs, so the original file stays byte-for-byte intact. Preserve the target's permissions: tempfile.mkstemp() hard-codes 0600 and os.replace carries the temp file's mode onto the target, so without an explicit chmod every save would silently tighten a group/other- readable config.yaml (the homelab install ships 0644, profiles 0664) down to owner-only. Copy the existing file's mode before the replace, falling back to the umask-adjusted 0666 for a new file. config.yaml holds no secrets, so that tightening would be a regression, not hardening (unlike .env, which stays 0600 in api.providers). settings.json is intentionally left untouched here. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d57fa7ba3b |
fix(stream): retry (not break) on queue.Empty so terminal frames survive a concurrent drain
Reviewer regression (reproduced): the broadcast drop-oldest loop did put_nowait -> on Full, get_nowait -> on Empty, break. But Empty here means a concurrent consumer (the SSE handler thread) DRAINED the queue between our Full and get_nowait — the queue now has space, which is exactly the condition to retry the put. `break` silently discarded `item`, and when `item` was a terminal frame (stream_end/error/cancel) the subscriber never received it, leaving the client attached indefinitely (spinner-forever). The "# cap 0 defensive guard; never reached" comment was the flawed assumption: any concurrent drain makes the Empty branch reachable at any cap. Fix: on queue.Empty, `continue` (retry the put into the now-empty queue) in both the broadcast path and the replay path (for consistency; the replay path runs under self._lock with a fresh queue so it's unreachable in practice, but the same race-safe rule applies). No infinite-loop risk: each iteration either succeeds a put (break) or succeeds a get (progress); the continue only fires on Empty, after which the next put has space. Test: test_terminal_frame_survives_concurrent_drain_during_drop_oldest deterministically simulates the interleaving with a custom queue whose first post-Full get_nowait raises Empty (as if a concurrent consumer drained it), then runs BOTH the pre-fix `break` logic (drops the terminal frame — proving the test bites) and the post-fix `continue` logic (delivers it). Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent). |
||
|
|
bbda885aa6 |
fix(stream): cap StreamChannel per-subscriber queues (drop-oldest on full)
Memory: each connected tab's subscriber queue was queue.Queue() with no maxsize. A slow/backpressured/backgrounded tab accumulated every coalesced token frame for the WHOLE turn (producer = agent token stream) — unbounded per-tab growth, OOM risk with many tabs x long agentic turns. The offline buffer was already capped (#4633), but once a subscriber attached the live-broadcast path flowed into an unbounded per-subscriber queue. Cap per-subscriber queues at _SUBSCRIBER_QUEUE_MAXSIZE (== _OFFLINE_BUFFER_MAXLEN) and drop the OLDEST frame on queue.Full in both the broadcast (put_nowait) and offline-buffer replay (subscribe_with_snapshot) paths, so a slow tab keeps the most recent tail (older frames stay recoverable via the run journal by last_event_id). Mirrors SessionChannel.emit's drop-on-full contract. The cap intentionally equals the offline buffer bound, not SessionChannel's smaller 16: StreamChannel carries the live token stream and has a #4633 reconnect-replay contract (a resubscribing tab must receive the full retained offline tail), so capping below the buffer would force every flaky-network reconnect through the run journal. Matching the buffer bound preserves that contract while bounding live-broadcast memory to the same worst-case #4633 already accepts. The SSE write deadline (SSE_WRITE_DEADLINE_SECONDS) breaks a stuck socket within ~20s, so the overflow window is short; this also bounds it by frame count. Adds a cumulative subscriber_dropped_events diagnostic counter (ops visibility). Test: tests/test_stream_subscriber_queue_cap.py — bounded queue never exceeds cap, drop-oldest keeps newest tail, draining vs non-draining subscribers, and the #4633 reconnect-replay contract still delivers the full offline tail. Existing offline-buffer/gap-recovery/multitab tests unchanged. Contract Routing - Task type: memory-bound fix (unbounded per-subscriber queue). - Touched areas: live chat SSE broadcast (StreamChannel). - Relevant public docs: AGENTS.md, CONTRIBUTING.md, docs/CONTRACTS.md, docs/rfcs/webui-run-state-consistency-contract.md (Live stream/SSE layer), docs/rfcs/session-sse-contract-v1.md (distinct: that RFC governs the PROPOSED per-session endpoint /api/sessions/{id}/events, NOT StreamChannel, which is the /api/chat/stream live token broadcast; no text in that RFC is changed). - State layer mutated: Live stream / SSE layer — specifically the per-subscriber queue inside StreamChannel (api/config.py). NOT the durable run journal, NOT the transcript, NOT replay. - Invariant preserved: the #4633 reconnect-replay contract — a resubscribing tab must receive the full retained offline tail. The cap is set EQUAL to _OFFLINE_BUFFER_MAXLEN so replay delivery is unchanged; only the previously- unbounded live-broadcast-to-slow-tab growth is bounded. Regression: test_reconnect_replay_still_delivers_full_offline_tail (and the pre-existing test_stream_offline_buffer_cap.py suite) pass unchanged. - Scope boundaries: does not touch replay idempotency (Invariant 5), the turn journal, compression, or sidebar metadata. Slow-tab frames dropped at the cap stay recoverable via the run journal by last_event_id. Release note: StreamChannel per-subscriber SSE queues are now bounded (drop-oldest), capping memory for slow/backgrounded tabs during long turns. Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent). No AI-specific tool use mattered beyond the repo's standard read/edit/test workflow. |
||
|
|
6ed474ca54 |
fix(models): route list-shaped custom provider models (#6127) (#6128)
Centralize custom-provider model-ID extraction into _configured_model_ids() + _configured_model_options(), used by both catalog construction and runtime routing so the picker and resolver can't drift. Fixes list-shaped custom_providers[].models allowlists silently staying on the default provider instead of routing to their own (#6121). Existing mapping / list-of-strings configs unaffected. Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> |
||
|
|
45cb5bd3e6 |
feat(#5979): persisted explicit-pick provenance for cold custom-proxy resolution
Nathan's call: adopt Codex's complete mechanism instead of the unconditional cold-preserve. The cold-catalog custom-proxy decision is now gated on whether the user DELIBERATELY selected the model this session, closing the residual where a stale #433 leftover would preserve+400 on a cold+no-disk send. Mechanism: - resolve_model_provider(model_id, *, explicitly_picked=False): in the custom-arm cold branch (no warm provenance, not config-declared), explicitly_picked=True preserves the vendor namespace verbatim (deliberate pick, proxy routes on it); otherwise the legacy redundant-prefix strip runs (stale cross-provider leftover, #433). Warm endpoint-advertised provenance still wins over the flag. - Session.model_explicitly_picked: new persisted field (default False, restored from metadata via **kwargs, listed in METADATA_FIELDS so it survives a restart — b3nw's exact cold scenario). - /api/chat/start stamps it: True on a fresh explicit_model_pick; cleared only when the resolved model actually CHANGED without a pick (real switch away); preserved across plain follow-up sends of the same model (the onchange marker is one-shot, so per-send explicit_model_pick alone would drop the intent). - streaming.py reads s.model_explicitly_picked and passes explicitly_picked= into resolve_model_provider (inside the profile scope). Independent end-to-end matrix (all pass): ben default cold, ben picked non-default cold+warm, ben stale cold (strips), #433 warm+cold, #3872 bedrock, #548 zai-org. 67 resolver+sprint40 tests pass incl. new explicit-pick + stale-companion + persistence round-trip. |
||
|
|
f42b4d14f5 |
harden #5979 follow-up round 2 (Codex): profile-fingerprint-gated warm no-op
Codex finding: the warm helper's fast no-op (if _models_cache_provenance is not None: return) ignored WHICH profile the resident provenance belongs to. The catalog globals are process-wide, so profile B's resident provenance could block profile A from loading A's own valid disk cache — A would then resolve against B's advertised ids (verified: profile A provenance resident + profile B bare-only disk cache -> A's openai/gpt-5.4 wrongly preserved). Fix: gate BOTH the fast no-op and the post-lock recheck on a current-profile fingerprint match (_provenance_is_current: prov[1] == _models_cache_source_fingerprint()). On mismatch the helper falls through, loads THIS profile's disk snapshot, and republishes provenance stamped with the current fingerprint. Tests: rewrote noop-when-warm to use the CURRENT runtime fingerprint (genuinely hits the fast path) and added ignores-foreign-profile-resident (foreign fp -> falls through -> loads this profile's disk snapshot). 66 resolver+sprint40 tests pass. NOTE: Codex also re-raised the cold+no-disk #433 case (a stale openai/gpt-5.4 on a bare-only relay preserves instead of strips when no catalog exists at all). Fable weighed this exact residual and judged cold-preserve strictly better than master (loud-fail-and-heal vs silent-recurring-truncation). The two advisors diverge; surfacing to Nathan rather than auto-adopting the heavier persisted-explicit-pick mechanism. |
||
|
|
e3152572aa |
harden #5979 follow-up per Codex gate: non-blocking disk-only warm + profile scope
Codex gate found two real issues in the send-path warm: 1. Blocking lock wait (CORE): get_available_models(prefer_cache=True) still acquires _available_models_cache_lock and can wait up to ~60s (unbounded in synchronous rebuild mode) on an in-flight rebuild — unacceptable on the send hot path. Rewrote warm_models_catalog_provenance_if_cold as a genuinely non-blocking, disk-only publish: try the cache lock NON-BLOCKING (return immediately if busy — a concurrent build will publish provenance itself), read ONLY the on-disk cache via _load_models_cache_from_disk (no network, no rebuild), publish snapshot+fingerprint + _sync_models_cache_provenance(). Verified: returns in ~3.5ms while the lock is held (was a 60s block). 2. Profile scope (CORE): the streaming worker is a separate thread that does NOT inherit the HTTP handler's request-profile TLS. Both the warm and the resolve read profile-keyed config (cache path + source fingerprint via get_active_profile_name), so a cold send from a NAMED profile could resolve against the DEFAULT profile's config and route to the wrong provider/base_url (get_available_models reloads config on path mismatch). Wrapped warm + resolve in profile_scope_for_detached_worker(_resolved_profile_name) so both see the owning session's profile (no-op for default/root). Tests: rewrote never-live-rebuilds (now asserts warm NEVER calls get_available_models — reads disk directly) and added a non-blocking regression (warm returns <2s while the cache lock is held). 65 resolver+sprint40 tests pass; b3nw end-to-end + non-blocking + disk-warm all verified. |
||
|
|
6745827eb5 |
fix(#5979 follow-up): preserve custom-proxy vendor namespace on cold-catalog sends
The earlier #5979 fix (PR #5980, exp-v0.52.53) resolved the WARM-catalog and config-declared cases, but b3nw confirmed truncation persists on exp-v0.52.55/56. Root cause (reproduced on master, confirmed by Codex + Fable): b3nw's config is provider: custom:llm-proxy, model.default: x-ai/grok-4.5, no models[] allowlist. His DEFAULT (x-ai/grok-4.5) preserves via config-declaration, but a model he SELECTS in the picker that isn't the default (x-ai/grok-composer-2.5-fast) truncates on every COLD send. The send path (api/streaming.py:7857) calls resolve_model_provider without warming the catalog, and the #1855 chat/start fast path deliberately skips the catalog when a session already has a persisted model+provider -- so 'cold at send' is the designed state after any restart / settings-save / cache-expiry, not a rare race. With provenance None and no config declaration, the #5980 tri-state fell through to the LEGACY family strip (if prefix in _PROVIDER_MODELS and _is_first_party_model: return bare), and grok-composer-2.5-fast had graduated into the x-ai first-party catalog -> stripped. Fix (two changes, both advisor-verified): 1. Flip the custom-arm cold default from family-strip to PRESERVE-verbatim (api/config.py). For an explicit custom / custom:<slug> proxy the strip now requires POSITIVE provenance (endpoint advertises only the bare id, #433). A wrong strip destroys routing info the user can't recover; a wrong preserve fails loudly and self-heals on warm. This also honours _endpoint_advertised_model_ids's own documented 'None -> preserve verbatim' contract and removes the data-driven flaw where a model graduating into the static catalog silently flips routing. 2. Network-free provenance warm on the send path (warm_models_catalog_provenance_if_cold, called in streaming.py before the resolve). prefer_cache=True republishes provenance from the durable disk cache (no live probe, no _cfg_lock held on the worker thread -> no lock-order deadlock), so #433's bare-only-strip stays exact whenever a disk cache exists. Residual (accepted, strictly better than master): a #433-shaped wrong-preserve now requires memory cold AND disk cache absent/invalid (first run, post-config-save window, schema/version bump). It fails loudly and heals on the next /api/models or session-visit rebuild. Under master b3nw's failure was the mirror image but silent, recurring every send, and unfixable short of declaring every model in config. Supersedes rodboev's #5996, which targeted the providers.<slug> arm and does not cover b3nw's actual custom:llm-proxy config (confirmed by Codex). Tests: rewrite the cold-fallback test (strip -> preserve), sharpen the foreign-profile test so trusted-strip vs ignored-preserve genuinely diverge, add b3nw's exact cold non-declared custom:slug case, and add three warm-helper tests (publishes-from-disk, noop-when-warm, never-live-rebuilds). 64 resolver+sprint40 tests pass; b3nw end-to-end verified; #433 positive-provenance strip verified. Thanks @b3nw for the CLI-vs-WebUI comparison that localized it. |
||
|
|
1443288dee |
harden #5979 round 3 (Codex): lock-free atomic (snapshot,fingerprint) provenance
The round-2 fix acquired _available_models_cache_lock in the resolver to read the snapshot+fingerprint consistently. Codex found that introduces a DEADLOCK: config-save takes _cfg_lock -> _available_models_cache_lock while catalog-refresh takes _available_models_cache_lock -> _cfg_lock (lock-order inversion), and it also makes every send wait behind a catalog rebuild. Fix: publish an immutable (snapshot, publisher_fingerprint) tuple atomically via _sync_models_cache_provenance() at every cache publish/invalidate site (9 sites), and have _endpoint_advertised_model_ids read that single global with ONE lock-free load. Reading one tuple can't tear (GIL), acquires no lock (no ordering edge, no deadlock), and never blocks behind a rebuild. A reader racing a publish sees the previous consistent pair, never a torn snapshot/fingerprint. Verified: two-thread probe — resolver completes in 0.0001s while another thread holds _available_models_cache_lock (was the deadlock). Real catalog build publishes the provenance tuple and closes the P0 end-to-end. Adds a deadlock-regression test. |
||
|
|
036c671f14 |
harden #5979 round 2 (Codex): atomic snapshot+fingerprint read + tri-state cold fallback
Two more real issues from the second Codex gate: 1. Atomic provenance read: _endpoint_advertised_model_ids read the global snapshot and its source fingerprint as two unlocked steps, while publishers assign the new snapshot BEFORE computing the fingerprint (streaming.py publish sites). In that window profile B's freshly-published catalog could validate against profile A's still-current fingerprint and strip against the wrong catalog. Fix: acquire _available_models_cache_lock (RLock) across the snapshot read + fingerprint check + memo build, so the pair is always consistent. The lock is held only for the two-assignment publish (the network rebuild runs before it), so the per-send acquisition never blocks on a probe. 2. Tri-state cold fallback (was: unconditional cold->preserve): an unconditional verbatim fallback newly broke the #433 relay cold path (openai/gpt-5.4 sent to a relay that only accepts gpt-5.4; master stripped it). Now: (1) config declares the full id verbatim (model.default / model.models / custom_providers[].models) -> preserve (network-free, survives a cold restart -> #5979 works cold via b3nw's model.default) (2) endpoint catalog advertised it -> full=preserve / bare-only=strip (3) genuinely unknown (cold + not declared) -> LEGACY family heuristic, so the edge is never worse than pre-fix: first-party redundant prefix still strips (#433 cold), intrinsic/unknown preserved (#3872/#548). The #5979 active-user path can't reach (3): a selected id is either config-declared or in the catalog the dropdown was built from. New _model_id_declared_in_config() provides the config-provenance signal. Tests updated: cold-fallback-to-legacy, config-declared-cold-preserve, foreign-profile-catalog-ignored (proves untrusted snapshot is not used). |
||
|
|
0bdf555779 |
harden #5979 per Codex gate: union extra_models bucket + profile-isolation fail-safe
Codex regression gate found two real issues on the first cut: 1. extra_models bucket: a provider's advertised ids can be split across the catalog's 'models' (visible) and 'extra_models' (picker overflow) buckets. Reading only 'models' could miss a bare id that overflowed, leaving the #433 stale prefix unstripped. _endpoint_advertised_model_ids now unions both buckets. 2. Profile isolation (profiles are islands): _available_models_cache is a process global, so a concurrently-active foreign profile could publish the snapshot a different profile then reads for provenance — potentially stripping an advertised full id against another profile's catalog. The accessor now rejects the snapshot unless its published source fingerprint (whose config_yaml axis is the profile-specific config path) matches the current runtime fingerprint; any mismatch fails safe to verbatim preserve. Adds tests: extra_models-counts-as-advertised (#433 via overflow bucket) and foreign-profile-catalog-preserves-verbatim (isolation fail-safe). Test helpers now stamp the source fingerprint exactly as the real publish sites do. |
||
|
|
4349ff4302 |
fix(config): custom/proxy providers keep the full vendor namespace (#5979)
Resolve the model id for custom/proxy providers by ENDPOINT PROVENANCE instead of a catalog-family guess. The old gate stripped a vendor prefix whenever the bare id was first-party of the prefix's home vendor, which could not distinguish a namespace the proxy actually routes on (x-ai/grok-4.5 -> keep) from a stale cross-provider leftover on a relay that only serves bare ids (openai/gpt-5.4 -> strip). A model graduating into the first-party catalog (agent commit adding grok-4.5) silently flipped a working custom-proxy id from preserved to stripped -> HTTP 400. New _endpoint_advertised_model_ids() reads the already-published catalog snapshot (never builds/probes/touches disk; O(1) per send via a snapshot-identity memo) and answers what the endpoint advertised: - full vendor/model advertised -> preserve (#5979, #3872, #548) - only the bare id advertised -> strip the redundant prefix (#433) - cold/unbuilt catalog -> preserve verbatim (fail-safe toward the id the user actively selected) The non-custom first-party-proxy strip arm is unchanged. Rewrites the two pinned #433 tests to model provenance (bare-only advertised) and adds #5979 coverage: advertised-full preserve (bare + named custom), cold-catalog preserve-verbatim, and both-advertised prefers-full. Thanks @b3nw for the report and precise root-cause analysis. |
||
|
|
20d258d552 |
Release exp-v0.52.40: ACP sessions in sidebar (#5939) + date-stamped Claude 3.0 reasoning fix (#5934) (#5951)
* fix(reasoning): stop date-stamped Claude 3.0 ids from reading reasoning-capable _candidate_supports_reasoning captured a trailing date stamp as the minor version so bare date-stamped Claude 3.0 ids like claude-3-opus-20240229 satisfied the major==3 and minor>=7 gate and wrongly surfaced reasoning-effort controls. Cap the minor group to 1-2 digits with a (?!\d) guard so the date stamp is not read as a minor version -- the same date-stamp defense _is_pre_adaptive_anthropic already uses. Claude 3.0/3.5 now correctly report no reasoning support; 3.7+ and 4.x (incl. date-stamped builds) are unaffected. * test(reasoning): cover date-stamped Claude 3.0 not reasoning-capable Locks the heuristic so claude-3-opus-20240229 (and sibling date-stamped 3.0 ids) report no reasoning support while 3.7+/4.x stay capable. * fix(session): classify ACP adapter sessions into the CLI sidebar family Gateway ACP adapter sessions (source='acp' in state.db — Zed, external device bridges like a Rabbit R1) normalised to session_source 'other', which fell through BOTH sidebar buckets: sidebar_source=webui skips the state.db projection entirely (and ACP rows have no WebUI sidecar), while sidebar_source=cli keeps only CLI-classified rows. Result: ACP conversations were invisible and could never be clicked/imported. Classify 'acp' as a CLI-family source, mirroring 'tui' (a local interactive agent client): normalize_agent_session_source maps it to session_source 'cli' with label 'ACP', is_cli_session_row recognises the raw source, is_cli_session_row_visible keeps ended/untitled ACP rows visible (they are always user-driven), and frontend _isCliSession accepts raw === 'acp'. Zero-message ACP connect/reconnect stubs stay hidden via the existing message-count guard. Claim policy is untouched: ACP stays claimable like CLI/TUI per the documented 'future local agent sources' allowance in _is_claimable_cli_source. Rollback: revert this commit (pre-change file copies also in ~/.hermes/backups/acp-sidebar-fix-20260711/), then restart WebUI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(session): require a user turn before surfacing ACP rows An ended ACP connection can record only assistant/tool/system messages (replayed or aborted turn), which the blanket TUI-style visibility exception would surface despite not being user-driven. Gate the ACP branch on _count_user_turns(row) > 0, per Greptile review on #5939. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Release exp-v0.52.40: ACP sessions in sidebar (#5939) + date-stamped Claude 3.0 reasoning-control fix (#5934) --------- Co-authored-by: 黄云龙 <76432572+nankingjing@users.noreply.github.com> Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> |
||
|
|
7f88b66ff9 | Merge salvage/5461-plugin-catalog | ||
|
|
7176b3a756 |
fix(config): route plugin providers before config_provider bare-passthrough
Gate found the plugin-routing branch ran AFTER the provider==config_provider early-return, so when an ACTIVE plugin provider was also the configured default, model_with_provider_context returned a bare model and dropped the '@plugin:' hint -> wrong backend. Moved the _is_plugin_model_provider() branch above the config_provider passthrough. Regression test (GMI + anthropic/claude-sonnet-4.6 as the active plugin+configured provider) added. Co-authored-by: alexfoxtm. |