The cherry-picked resolve_provider_full 0.5 step returned a generic
openai_chat ProviderDef for ANY registry ID, hijacking single-entry
alias rewrites like copilot -> github-copilot away from their overlay
transports (test_explicit_copilot_switch_uses_selected_model_api_mode
regression). Restrict the early return to names where MULTIPLE registry
providers collapse to one canonical (kimi-coding + kimi-coding-cn +
kimi + moonshot -> kimi-for-coding) — the only case where alias
resolution actually loses information.
Also maps Almurat123's contributor email.
Both providers share the same models.dev ID (kimi-for-coding) but
have different API keys (KIMI_API_KEY vs KIMI_CN_API_KEY) and base
URLs (moonshot.ai vs moonshot.cn). The /model picker was only
showing one because the dedup key was mdev_id alone.
Changes in list_authenticated_providers():
- Resolve canonical provider profile name and skip alias hermes_ids
(e.g. "kimi", "moonshot" → "kimi-coding") so only canonical
entries are processed.
- Deduplicate by slug (hermes_id) instead of mdev_id so distinct
profiles sharing a models.dev ID (kimi-coding vs kimi-coding-cn)
both appear.
- Prefer PROVIDER_REGISTRY name for the display label so the CN
variant shows "Kimi / Moonshot (China)" instead of the generic
models.dev name.
Adds test coverage for all three key scenarios:
- Only KIMI_CN_API_KEY set → only kimi-coding-cn appears
- Only KIMI_API_KEY set → only kimi-coding appears
- Both keys set → both providers appear, aliases not duplicated
Closes#10526
A single Kimi credential surfaced two rows in the `/model` picker — the
bare alias `kimi` (PROVIDER_TO_MODELS_DEV pass) and the canonical
`kimi-coding` (CANONICAL_PROVIDERS cross-check, section 2b) — both backed
by the same `kimi-for-coding` provider.
`kimi`, `moonshot` and the canonical `kimi-coding` all map to one
models.dev id (`kimi-for-coding`). The seen_mdev_ids guard collapses them
to the first key in section 1, but that key is the bare alias, so 2b
re-emits the canonical name as a second row.
Emit the row under the canonical Hermes slug instead: resolve the alias
via _PROVIDER_ALIASES (`kimi` -> `kimi-coding`) before appending, so 2b's
seen_slugs check collapses the pair. This matches the picker's other alias
rows (copilot, gemini) and the overlay slug-resolution contract, and keeps
the surviving row resolvable to the real provider. A defensive seen_slugs
guard prevents emitting a duplicate canonical row.
Distinct providers keep their own row: `kimi-coding-cn` has its own
KIMI_CN_API_KEY and is still emitted by section 2b.
Regression tests assert the single-key case yields one `kimi-coding` row
(fails on clean main, which shows both `kimi` and `kimi-coding`) and that
the China endpoint is preserved.
Fixes#49439
c253b0738 added clear_model_endpoint_credentials() to scrub an old endpoint's
inline secret (api_key, the legacy `api` alias, api_mode) when the web UI
switches the main model to a different provider. But _apply_main_model_assignment
gates the key-scrub path on model_cfg["api_key"] being truthy, so when the stale
secret lives only under the legacy `api` alias (no api_key), a provider switch
never clears it — the secret survives in config.yaml.
model.api is a live credential read path (_resolve_openrouter_runtime reads
`for k in ("api_key", "api")`), so the old endpoint's key contaminates a later
custom resolution — the exact harm clear_model_endpoint_credentials documents.
The sibling persistence sites (the gateway model-picker paths and the aux-slot
path) call the helper unconditionally on a non-custom switch and already scrub
`api`; only this caller had the api_key-only gate.
Widen the guard to fire on either field. The same-provider re-pick and
explicit-new-key paths are unchanged. Adds the api-alias case to the assignment
test (it fails without the fix).
Follow-up widening for salvaged PRs #67115, #67685, #67620:
- _PROVIDER_MODELS: add kimi-k3 atop kimi-coding / moonshot / opencode-go
curated lists (kimi-coding-cn covered by cherry-picked #67620)
- setup.py _DEFAULT_PROVIDER_MODELS: kimi-k3 for kimi-coding(-cn) + opencode-go
- model_metadata: align DEFAULT_CONTEXT_LENGTHS kimi-k3 entry to 1,048,576
(matches endpoint-scoped override, models.dev, and OpenRouter live metadata)
- anthropic_adapter: classify the bare Coding Plan slug 'k3' (and k3.x/k3-*)
as Kimi family so adaptive thinking applies on proxied endpoints
- moonshot_schema: is_moonshot_model matches bare 'k3' so tool-schema
sanitization runs on the chat-completions path
- contributor mappings for githubespresso407, datachainsystems, Punyko8
Tests: 582 passed across 11 targeted files; hermetic E2E verifies picker
order (kimi-k3 first), no dupes, and 1M context resolution.
Moonshot China (api.moonshot.cn) has rolled out kimi-k3 to all
CN-endpoint keys, plus the new kimi-k2.7-code / -highspeed variants.
The kimi-coding-cn curated picker whitelist was still on the k2.6/k2.5
era list, so users holding CN keys could not select any of the new
models from 'hermes model' or the gateway /model picker even though
the underlying provider + endpoint already serve them.
Verified against a live CN key:
GET https://api.moonshot.cn/v1/models
-> [kimi-k3, kimi-k2.7-code, kimi-k2.7-code-highspeed, kimi-k2.6,
kimi-k2.5, moonshot-v1-* ...]
No provider-code changes; pure whitelist addition mirroring the
existing kimi-coding (global) list which already tracks k2.7-code.
* fix(desktop): preserve interim assistant text wiped at message.complete
When the agent emits interim text (commentary alongside tool calls, or the
attempted final answer before a verify-on-stop nudge), all UI surfaces
streamed it live but then wiped it at message.complete — keeping only the
final response. The user saw text appear during inference, then disappear.
This is the complete fix across all three layers: agent core, gateway
transport, and all UI surfaces (desktop + Ink TUI).
The verify-on-stop and pre_verify paths flagged the assistant's attempted
final answer as _verification_stop_synthetic, suppressing it from both
state.db and the UI. The user only saw the terse post-verification reply.
Now the assistant response is real content: it's persisted to state.db and
emitted as an interim message via _emit_interim_assistant_message(force_display=True)
before the verification loop runs. Only the synthetic nudge messages keep
the synthetic flags. The turn finalizer drops nudges from live history and
compares content (not just role) to avoid duplicating a published candidate.
Message sequence repair collapses verification candidates in the
consecutive-assistant merge.
Wire agent.interim_assistant_callback both at construction (_agent_cbs())
and per-turn (defense-in-depth), emitting a new message.interim event with
{text, already_streamed}. Gated on display.interim_assistant_messages
(default true). Cleared in the finally block so a stale closure can't
fire on a later turn.
Add message.interim to the GatewayEventName union (apps/shared) and a
typed payload to the TUI's GatewayEvent discriminated union.
The TUI already had the segment-anchoring machinery (flushStreamingSegment +
finalTail) but had no handler for message.interim. Added recordInterimMessage
+ interimBoundaryIndex to seal segments mid-turn, and updated
recordMessageComplete to only dedupe segments after the interim boundary.
Replaced the fragile sealed-set approach with a proper interimBoundaryPending
state flag on ClientSessionState. finalizeInterimAssistantMessage finalizes
the streaming bubble in place (or creates a standalone one), rotates the
stream ID so next deltas create a new bubble, and sets the flag. When the
final text equals an already-sealed interim, they stay as distinct messages.
Extracted mergeFinalAssistantText() as a pure function in chat-messages.ts,
used by both completeAssistantMessage and finalizeInterimAssistantMessage.
Split the bidirectional dedup predicate: reasoning is a restatement only when
the final FULLY covers it. A short final ("Done.") no longer swallows a
longer reasoning block that merely starts with it.
Honor display.interim_assistant_messages (default true) across all layers:
the tui_gateway gates the callback, the desktop wires it to a nanostores
atom via use-hermes-config. Updated hermes_cli/config.py and
cli-config.yaml.example comments to document the Desktop behavior.
_split_segment_tokens now accepts posix=False and _find_ad_hoc_match tries
both posix modes so ad-hoc verification scripts with Windows backslash
paths are matched correctly. (response_previewed forwarding from #53553
is not included — our emit-interim + persist approach makes it unnecessary
since the attempted answer is now surfaced before the verification loop.)
- tsc: clean (desktop + TUI + shared)
- vitest desktop: 73/73 pass (7 interim-sealing + 5 mergeFinalAssistantText + 4 config atom)
- vitest TUI: 83/83 pass (4 new message.interim tests)
- python: 390 tests pass (340 tui_gateway + 33 verification/finalizer + 6 config gating + 3 evidence + 8 continuation budget)
Co-authored-by: Liam Zhang <yingliang-zhang@users.noreply.github.com>
Co-authored-by: Lucas D'Alessandro <lucasfdale@users.noreply.github.com>
Co-authored-by: Eric Manganaro <superposition@users.noreply.github.com>
Co-authored-by: sweetcornna <sweetcornna@users.noreply.github.com>
Co-authored-by: DECK6 <DECK6@users.noreply.github.com>
Co-authored-by: matantsevs <matantsevs@users.noreply.github.com>
Co-authored-by: gitcommit90 <gitcommit90@users.noreply.github.com>
* fix: prefix-match interim streamed content to avoid benign duplicate bubbles
_interim_content_was_streamed used exact equality (streamed == visible_content),
so a final response that was the streamed text plus a trailing delta — or a
partial stream before the verify nudge fired — failed the match and left
_response_was_previewed false. The turn then showed two bubbles (interim +
identical final) instead of settling the interim in place.
Relax to a prefix check (visible_content.startswith(streamed)) in both the
core match and the desktop's settle-in-place gate. The TUI already used
prefix matching via finalTail. The reverse direction (streamed longer than
final) is intentionally not matched — that could suppress a needed resend
in the gateway path where already_streamed=True calls on_segment_break().
* test(desktop): add partial-stream-then-nudge dedup edge case
Third edge case for the interim-sealing dedup: model streams part of its
answer via message.delta, verify nudge fires, interim seals the streamed
prefix, then the final response is the same text plus a trailing delta.
Asserts one bubble (not two) containing the full final text.
Acceptance protocol #2 — covers all three dedup edges:
1. interim == final (existing)
2. interim = strict prefix of final (existing)
3. partial-stream-then-nudge (this commit)
---------
Co-authored-by: Liam Zhang <yingliang-zhang@users.noreply.github.com>
Co-authored-by: Lucas D'Alessandro <lucasfdale@users.noreply.github.com>
Co-authored-by: Eric Manganaro <superposition@users.noreply.github.com>
Co-authored-by: sweetcornna <sweetcornna@users.noreply.github.com>
Co-authored-by: DECK6 <DECK6@users.noreply.github.com>
Co-authored-by: matantsevs <matantsevs@users.noreply.github.com>
Co-authored-by: gitcommit90 <gitcommit90@users.noreply.github.com>
activate_custom_endpoint copies the endpoint's base_url and api_key onto
cfg["model"]. delete_custom_endpoint pops the providers entry and saves —
it never touches that mirror.
So deleting the endpoint the agent is currently using leaves both behind:
DELETE /api/providers/custom-endpoints/acme -> 200
providers entry gone : True
model.api_key : sk-CUSTOM-ENDPOINT-SECRET
model.base_url : https://llm.acme.corp/v1
Two consequences, both silent:
* The agent keeps authenticating to the deleted host with the deleted key.
model.api_key outranks the environment at client construction, so this
also shadows whatever the operator configures next — the persistent-401
shape credential_lifecycle.py documents as #62269.
* A credential the operator just removed through the dashboard stays
sitting in config.yaml.
Scrub the main-slot mirror on delete, but only when it actually names the
deleted provider — an endpoint deleted while a different one is active must
leave that active assignment untouched. Both directions are pinned by tests.
_write_custom_endpoint builds a fresh entry dict from the request body and
assigns it over providers[endpoint_id], carrying nothing forward but api_key.
A providers.<name> block is not owned by that panel. It can carry keys the
dashboard has no field for, all of them load-bearing:
api_mode the protocol the endpoint speaks
key_env where the credential comes from
extra_headers per-provider HTTP headers (may carry credentials)
request_overrides extra body params
and a models map with more than the one model the panel names.
So an edit that only changes the default model destroys the rest:
BEFORE api_mode, base_url, extra_headers, key_env, model, models,
name, request_overrides
AFTER base_url, discover_models, model, models, name
FIELDS DESTROYED: ['api_mode', 'extra_headers', 'key_env',
'request_overrides']
The provider is left with no credential wiring (key_env gone, no api_key),
talking the wrong protocol, missing its proxy auth header — from a UI action
that said nothing about any of that. The models map also collapses to the one
named model, dropping the others and their context_length.
Merge onto the existing entry instead of replacing it, and merge the models
map rather than overwriting it. Managed fields still win, so the edit itself
still applies; a brand-new endpoint is unchanged. api_key keeps its previous
semantics — a supplied key overwrites, an omitted one leaves the stored key
in place (now via the merge rather than an explicit carry-forward branch).
POST /api/model/set accepts an api_key and threads it into
_apply_main_model_assignment. The custom-endpoint work then added a
provider-entry fallback right after it — but unconditionally:
if not base_url and provider_entry.get("base_url"):
base_url = provider_entry["base_url"] # explicit wins
model_cfg = _apply_main_model_assignment(..., base_url, api_key)
if provider_entry.get("api_key"):
model_cfg["api_key"] = provider_entry["api_key"] # explicit LOSES
The two lines disagree about precedence. base_url fills only a gap; api_key
overwrites whatever the caller sent.
So rotating a key through this endpoint returns 200 and silently keeps the
old one:
request api_key : sk-NEW-ROTATED-KEY
stored api_key : sk-STORED-OLD-KEY
That matters beyond the write itself: model.api_key outranks the environment
at client construction, so the stale key keeps authenticating and shadows
anything the operator configures next — the persistent-401 shape
credential_lifecycle.py documents as #62269.
A regression, not long-standing. Against 3d9789357^ the same request stores
sk-NEW-ROTATED-KEY.
Gate the fallback on `not api_key`, matching the base_url line directly above
it. Switching to a configured provider with no key in the request still adopts
the entry's key — pinned by its own test so the feature's intent doesn't
regress in the other direction.
Transform of salvaged PR #34250 per maintainer direction: arbitrary
config keys are a supported pattern (top-level scalars bridge into
os.environ for skills and external apps), so hard-refusing unknown
keys would break legitimate writes. Keep the contributor's schema
walker and did-you-mean suggestion engine, but write the value first
and print a post-write notice — no more bare success for
plausible-but-wrong paths like
gateway.discord.gateway_restart_notification, and no blocked writes.
--force suppresses the notice for scripted use.
The schema validation added in this PR (#34250) rejected underscore-
prefixed config keys like '_test.shim_marker', breaking the Docker
privilege-drop test suite (tests/docker/test_docker_exec_privilege_drop.py)
which writes such markers via 'hermes config set _test.<marker> 1' to
probe config.yaml file ownership after the UID-drop shim runs.
Ten Docker tests failed in build-amd64 CI for this reason:
test_shim_drops_root_to_hermes_uid (_test.shim_marker)
test_shim_short_circuits_for_non_root (_test.shim_short_circuit)
test_shim_opt_out_keeps_root (_test.opt_out)
test_shim_opt_out_strict_truthiness[*] (_test.falsy) x6
test_e2e_login_then_supervised_gateway (_test.e2e_marker)
Fix: treat a leading underscore on the TOP-LEVEL segment as an
internal/test marker that bypasses schema validation. Mirrors Python's
own '_private' convention. The escape is narrow:
- Only the first segment is checked, so a genuine typo in a sub-key
under a known top-level key (e.g. 'agent._max_turns') is still
flagged.
- Real typos ('agent.max_turn' -> 'agent.max_turns') still caught.
- The headline #34067 bug ('gateway.discord.gateway_restart_notification')
still caught.
Tests (5 new in TestValidateConfigKey):
- 4 parametrized cases for accepted underscore-prefixed keys
(_test.shim_marker, _internal, _test.nested.deep.marker, _x)
- test_underscore_only_first_segment_escapes: confirms agent._max_turns
(underscore in a SUB-key, not the top) is still rejected.
All 58 tests in test_set_config_value.py pass.
Refs: #34067#34250
Co-authored-by: Cursor <cursoragent@cursor.com>
Fixes#34067. 'hermes config set <unknown.key.path> <value>' silently
accepted arbitrary key paths, wrote them to config.yaml, and reported
success — but the runtime/gateway never read them.
The headline case from the issue: a user typing
hermes config set gateway.discord.gateway_restart_notification false
gets success, but the value lands at config.yaml:gateway.discord.* where
nothing reads it. The correct path is discord.gateway_restart_notification
(platform configs live at the top level of DEFAULT_CONFIG, not under
a 'platforms' namespace). The user reasonably believes the change took
effect, then loses time debugging behavior that hasn't changed.
Fix: schema-validate the dotted key path against DEFAULT_CONFIG before
writing. Walk DEFAULT_CONFIG along the user's segments and:
- Reject unknown top-level keys with a fuzzy-match suggestion
- Reject unknown sub-keys by suggesting the closest sibling
- Accept anything below open-dict shapes (mcp_servers.<name>.command,
providers.<openrouter>.api_key, etc.)
- Accept anything below schema-defined-extensible shapes (platform
configs like discord.*, telegram.* — PlatformConfig has dynamic
'extra' fields, so deep validation is unsafe)
- Special-case 'platforms.X' → suggest 'X' (the actual top-level layout)
Bypass with --force for forward-compatibility with keys a newer Hermes
version adds but the running version doesn't recognize yet:
hermes config set --force brand_new_future_key value
API-key style names (OPENROUTER_API_KEY, *_TOKEN, etc.) still route to
.env before schema validation runs, so this is non-breaking for that path.
Adds 21 regression tests across TestSchemaValidation + TestValidateConfigKey
covering: unknown top-level keys, unknown sub-keys (the headline bug),
platforms.* prefix suggestions, fuzzy-match top-level typos, sibling-
suggestion sub-key typos, --force bypass, and that known config keys
(simple, platform-extensible, open-dict) still work.
Also updates 2 pre-existing tests that used non-canonical paths
(platforms.telegram.* and 'verbose') which schema validation correctly
flags — switched to canonical paths (telegram.* and agent.gateway_timeout).
All 53 tests in test_set_config_value.py pass.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(desktop): inline TTS voice/model settings in the Capabilities tab
The Capabilities > Tools > Text-to-Speech panel only surfaced API keys per
provider — voice and model settings (e.g. tts.openai.voice) lived exclusively
in Settings > Voice, so users couldn't select or type a voice/model name where
they configure the backend.
- web_server: TTS provider rows now carry their tts_provider config key
(the section holding that backend's voice/model settings)
- desktop: new VoiceProviderFields renders the provider's config fields
inline in the toolset panel, deriving the key list from the curated
Settings > Voice section so the two surfaces can't drift; shared
ConfigField extracted to config-field.tsx
- voice/model name fields are now free-input comboboxes (Input + datalist)
instead of closed Selects — custom voice IDs (ElevenLabs cloned voices,
xAI custom voices, Edge's 400+ catalog) are typeable, known values remain
suggestions
- refreshed the stale OpenAI voice list (adds ash/ballad/cedar/coral/marin/
sage/verse) and added suggestion lists for edge/gemini/minimax/mistral/
kittentts/piper/neutts models and voices
- config.py: added missing tts.minimax and tts.kittentts default blocks and
deepinfra model/voice fields to the Voice section so those providers are
configurable from the GUI at all
* test(desktop): await effect-driven panel content in post-setup CTA tests
The auto-expand effect renders the provider's inner panel one re-render
after the row; with the QueryClientProvider wrapper the extra provider
tick made the synchronous getByRole race it (~10% local flake, failed on
CI). Await the panel content with findBy* instead.
Follow-up to the salvaged transport-failure auto-pause (#54387): the PR
branch predates the CLI completion gate merged in #67985, so that new
judge_goal consumer (hermes_cli/kanban.py) needed the 5-value unpack too
— otherwise it would fail open again via the swallowed ValueError.
Also migrates the two remaining 4-value mocks in
tests/cli/test_cli_goal_interrupt.py flagged on the earlier PR #27760.
When a goal_judge model has a broken API key (401), DNS failures, or
timeouts, the judge falls through to 'continue' (fail-open) but the
consecutive transport failures were not counted — only parse failures
were tracked. With 3 consecutive parse failures the loop auto-pauses,
but with transport failures it looped forever (the Xiaomi 401 bug).
Changes:
- Add DEFAULT_MAX_CONSECUTIVE_TRANSPORT_FAILURES = 5
- Add consecutive_transport_failures counter to GoalState
- judge_goal() now returns a 5-tuple (verdict, reason, parse_failed,
wait_directive, transport_failed) instead of 4-tuple
- Transport errors (API 401/5xx, timeouts) set transport_failed=True
- evaluate_after_turn() auto-pauses when consecutive transport failures
reach the threshold, with a clear message naming the failing model
- All 101 tests updated and passing
Follow-up to the cherry-picked CLI judge gate (#55854): the gate carried
the same 3-value unpack bug just fixed on the tool path in PR #67973 —
the ValueError would have been swallowed by the fail-open handler,
silently disabling the gate.
Also: test mock now returns the real 4-value judge contract (the old
3-value mock masked the bug), tests track complete_task invocations and
assert the rejection path never writes, and the unused _make_goal_task
helper is dropped.
The three-commit hardening series (Issue #38367, PR #55408) added a
pre-completion judge gate to `tools/kanban_tools.py:_handle_complete`
(the kanban_complete tool used by agent tool-calls). The structurally
identical `hermes_cli/kanban.py:_cmd_complete` (the `hermes kanban
complete` CLI subcommand) was left unguarded.
A goal_mode worker with terminal tool access — the overwhelming default
for coding agents — can bypass the judge entirely by running:
hermes kanban complete <task_id>
This transitions the task to `done` status with no judge verdict, making
the acceptance-criteria enforcement worthless on that path.
Fix: apply the same gate in _cmd_complete before calling kb.complete_task.
When a judge is reachable and returns anything other than "done", the
command prints an actionable rejection message and exits non-zero without
modifying the task. The fail-open policy (no judge configured → allowed)
is preserved to match the tool-call path.
* fix(fast): default /fast to session scope on CLI and gateway
Completes the session-first policy from #67946 for the /fast toggle
(the remaining half of #54084). A bare /fast fast|normal now applies to
the current session only; --global persists agent.service_tier to
config.yaml.
Gateway: new _session_service_tier_overrides dict (registered in
_CONVERSATION_SCOPED_STATE so /new clears it) resolved at both agent
turn sites via _resolve_session_service_tier(); the /fast handler and
its choice picker apply session overrides and evict the cached agent.
The TUI config.set fast path was already session-scoped.
CLI: /fast parses --global (parity with /reasoning); bare toggles
mutate self.service_tier only.
* fix(sessions): /new resets model, reasoning, and fast to config defaults
/new and /reset are full conversation boundaries: session-scoped
runtime overrides do not carry into the next session (#48055, #23131).
CLI new_session(): clears the one-turn model restore, re-derives
service_tier from config, and — when the session's model differs from
the config default — switches back via the shared switch_model()
pipeline (live agent swap included; best-effort so an unreachable
default never blocks /new).
TUI _reset_session_agent(): stops forwarding model_override /
create_reasoning_override / create_service_tier_override into the
rebuilt agent and pops the pins so later rebuilds can't resurrect
them. The gateway already cleared its per-session overrides via
_clear_conversation_scope on /new.
Cross-session contamination stays impossible: nothing here touches
process-global env or other sessions' pins.
Sections 1-2 of ``list_authenticated_providers`` emit rows directly
from ``PROVIDER_REGISTRY`` (auth-driven built-ins) before reaching the
per-section gate I added for section 3 (user-config providers). That
means flipping ``providers.openrouter.enabled: false`` hid OpenRouter
from a user-config block but the built-in OpenRouter row still showed
because its row came from section 1's auth-status path.
Add a single post-filter at the end of ``list_authenticated_providers``
that drops every row whose ``provider_id`` or ``slug`` matches a
disabled name in ``providers``. Same source of truth, applied once at
the end, covers all four sections in one pass.
Wrapped in ``try/except`` so a degraded config can't break the picker —
if anything fails reading the config, the filter no-ops and the picker
shows the un-filtered list (same as before this PR).
The first commit's gate sat inside ``_get_named_custom_provider`` —
which only handles user-defined custom blocks. Built-in provider names
(``openai`` / ``anthropic`` / ``openrouter`` / ``gemini`` / ...) have
their own resolution paths in ``resolve_runtime_provider`` (pool /
explicit / generic / ``resolve_provider``) and bypass that gate.
So a user who flipped ``providers.openrouter.enabled: false`` would
still see OpenRouter resolved when something explicitly requested it
(e.g. a fallback chain entry). That defeats the point of the flag.
This commit moves the gate one level up: right after
``requested_provider`` is computed, before any custom / built-in /
Azure short-circuit. It now raises a typed ``ValueError`` referencing
the YAML path, so callers can recognise it and advance to the next
fallback instead of silently using a disabled provider.
3 new tests cover:
* disabled custom provider raises
* disabled built-in provider raises
* enabled provider doesn't hit the gate
All 20 tests in the providers suite pass.
A ``providers.<name>`` block in ``config.yaml`` can now opt out of being
listed anywhere by setting ``enabled: false`` — without removing the
block, so re-enabling it stays a one-line edit. Missing or ``true`` keeps
the previous behaviour (enabled), so this is fully backwards-compatible.
The flag is honoured in four places:
* ``hermes_cli/model_switch.py`` — model-override validation (the
allow-list that the picker consults to accept a non-public model id)
and the picker's own endpoint iteration. A disabled provider no longer
appears as a row and its models can't be silently accepted via
override.
* ``hermes_cli/runtime_provider.py`` — the runtime resolver skips
disabled blocks, so an explicit ``--provider X`` against a disabled
entry fails fast instead of using stale base_url / api_key from the
ignored block.
* ``hermes_cli/doctor.py`` — the doctor's "configured providers" set
excludes disabled entries, so health checks don't flag missing API
keys for providers the user has turned off.
Motivation: when a user has 20+ providers wired up in ``config.yaml``
(many of them only used occasionally) the picker becomes noisy and the
runtime resolver may pick a suboptimal one on ambiguous --provider names.
There's currently no way to hide a provider short of deleting its block
— which loses the api_key + base_url + custom routing config the user
spent time wiring. ``enabled: false`` lets them keep the config but get
it out of the way.
The helper ``is_provider_enabled()`` in ``hermes_cli/config.py``
centralises the gate (and accepts YAML-stringified booleans like
``"false"`` for hand-edited configs). 17 unit tests cover the defaults
and edge cases.
A follow-up PR can wire ``hermes provider enable/disable <name>`` and a
dashboard toggle on top of this primitive — they reduce to mutating the
flag.
PRs #55848 and #55853 both added claude-sonnet-5 to the anthropic and
gmi curated lists; keep one entry each (newest-sonnet-first ordering
under claude-fable-5, matching the existing list convention).
Add claude-sonnet-5 to the static curated lists for Anthropic, OpenRouter,
Nous Portal, Copilot, GMI, OpenCode Zen, and AWS Bedrock so the model
appears in hermes model / /model picker discovery.
Fixes#55846
The 'Unknown top-level config key' warning (f5bacee27) assumed a
closed-world allowlist of valid roots, but top-level scalars in
config.yaml are deliberately bridged into os.environ (gateway/run.py,
hermes send) so skills and external apps Hermes drives can read
arbitrary env-style keys (DISCORD_HOME_CHANNEL, MY_APP_TOKEN, ...).
An allowlist can never enumerate those — two widening follow-ups
(7c2ece53c, 3c7217706) already proved the whack-a-mole. Drop the
generic warning entirely; keep the targeted provider-like-field
misplacement hint (base_url/api_key at root).
Parity with the gateway /reasoning handler and the new /model default:
a bare /reasoning <level> now applies to the current session only;
--global persists agent.reasoning_effort to config.yaml. --session is
still accepted as an explicit alias for the default. Display toggles
(show/hide/full/clamp) remain persistent as before — they are user
preferences, not conversation state.
Builds on YAMAGUCHI Seiji's #51158 (session-scope plumbing + /new reset,
cherry-picked as the previous commit) with the default flipped to match
the session-first policy. Fixes the CLI half of #54084.
Flip the resolve_persist_behavior() fallback from persist-to-config to
session-only. A plain /model <name> (typed or via any picker — CLI,
TUI/Desktop, gateway) now affects only the current session; --global
persists explicitly, and model.persist_switch_by_default: true restores
the old opt-out behavior for users who want switches to stick.
This is the root cause behind the recurring 'session switch applied
globally' bug class (#61458, #63083, #58290, #61190): every surface
funnels its no-flag default through this one function, so per-surface
patches kept missing paths. Fixing the default fixes all surfaces at
once: CLI typed + picker, TUI/Desktop config.set + slash, gateway typed
+ inline picker.
Builds on liuhao1024's #58371 (--provider session scoping, cherry-picked
as the previous commit) and supersedes the per-surface #61488.
When /model is called with --provider but without --global or --session,
the switch now defaults to session-only instead of persisting to
config.yaml. Provider switches are typically exploratory — the user is
trying a different backend for this conversation, not reconfiguring the
default. --global can still force persist when desired.
This addresses a regression from fad4b40d9 where /model switched to
persist-by-default, causing /model xxx --provider xxx to overwrite the
global config when the user only intended a temporary switch.
Fixes#58290
Address review on PR #36998: the inline ri.<service>..<ns>. stripper in
_get_status_bar_snapshot was a one-off heuristic that:
* lived in cli.py with no shared call site, so the switch-confirmation
banner ("✓ Model switched: ri.language-model-service..…") and the
[Note: model was just switched from … to …] system-prompt nudge still
printed the full opaque RID — exactly what the screenshot reported;
* split on '..' and re-split on '.', which would mis-handle any RID
whose namespace token isn't a single dotted segment.
Refactor:
* New module-level helper hermes_cli.model_switch.format_model_for_display
matches on a startswith() allow-list (_OPAQUE_MODEL_PREFIXES) and
returns the trailing slug. Falls through to the original string for
every non-Palantir id, so HF paths (meta-llama/Llama-3.3-70B-Instruct),
plain Claude/GPT names, .gguf paths, and aliased ids are untouched.
Allow-list is extensible — add a prefix tuple entry for future
proxies that wrap real names in a namespace (Bedrock ARNs are
already covered by the slash-split fallback and have a different shape).
* _get_status_bar_snapshot() now delegates to the shared helper after
the reverse-alias miss (so configured aliases still win over the
helper output).
* cli.py::_handle_model_command — both confirmation-print blocks
(~7720 and ~7975) now run result.new_model AND old_model through
the formatter before they hit _cprint() and the
_pending_model_switch_note text.
* gateway/run.py model-switch handler (~10915) — same treatment for
_pending_model_notes[_session_key] and the
t('gateway.model.switched', model=…) confirmation line returned to
the gateway client.
The formatter is DISPLAY-ONLY. The session_model_overrides map,
ModelSwitchResult.new_model, persistence to config.yaml, alias lookups,
and every wire call still carry the full opaque RID — Palantir's API
requires it.
Verification: unit reproducer covers (a) all four Palantir model RIDs
from this user's config stripped to the trailing slug, (b) plain
model names (claude-4-7-opus-20260101, gpt-5.4, HF paths, empty
string) passed through unchanged, (c) prefix-only edge preserved
(no infinite-loop / empty-output regression).
Refs: PR #36998 review feedback; screenshot showed model banner still
printing the long RID after the original status-bar-only fix landed.
Two related TUI quality-of-life fixes for users running multiple models
behind a single proxy/aggregator (e.g. Palantir Foundry, Bedrock,
self-hosted vLLM behind a single key).
1. _get_status_bar_snapshot() — friendlier model name in the status bar.
Long catalog IDs (Palantir RIDs like
``ri.language-model-service..language-model.anthropic-claude-4-7-opus``)
were truncated to ``ri.language-model-ser...`` by the existing 26-char
slash-split, leaving the user with no way to tell which model is active.
The status bar now:
* Reverse-looks up the model id in config.yaml ``model_aliases:`` /
``model.aliases:`` and shows the shortest configured alias when one
exists (so users who set up a friendly alias get it for free).
* Falls back to stripping Palantir's ``ri.<service>..<ns>.`` RID prefix
before length-truncation, so the truncated label carries the actual
model identity (``anthropic-claude-4-7-opus``) instead of the URN
scheme.
* Reverse-alias map is cached at module level (config is loaded once
per session; no need to re-resolve on every status-bar refresh).
2. list_authenticated_providers() section 3 — group ``providers:`` entries
by (api_url, key_env, api_mode), mirroring section 4's existing grouping
for ``custom_providers:`` lists.
Before: a Palantir Foundry config with two Anthropic-proxy entries
(``palantir-claude46`` + ``palantir-claude47``) produced two near-
duplicate picker rows labelled ``Palantir Claude 4.6 Opus`` and
``Palantir Claude 4.7 Opus`` — same endpoint, same PALANTIR_TOKEN,
same anthropic_messages wire protocol, differing only by model id.
After: those entries collapse into a single ``Palantir Claude`` row
with both models in the dropdown. Same-host entries with a different
``api_mode`` (e.g. an OpenAI-compat ``palantir-gpt54`` alongside the
Anthropic claude rows on the same host) keep distinct rows since
the wire protocol differs — same safety invariant section 4 already
enforced for ``custom_providers:``.
Group display name strips per-version trailing tokens (``Palantir
Claude 4.7 Opus`` → ``Palantir Claude``) only when the prefix has
≥2 words, so single-word names aren't over-trimmed.
The new code records (raw_display_name, api_url) into
_section3_emitted_pairs for every raw entry that joined the group, so
section 4's compatibility-merged ``custom_providers`` view (built by
``get_compatible_custom_providers()`` which calls
``providers_dict_to_custom_providers()`` to convert ``providers:``
into custom-provider shape) still dedupes against this grouped row.
Manual smoke test on a config with three Palantir entries
(claude-4.6, claude-4.7, gpt-5.4): before — 3 picker rows; after — 2
picker rows (1 row "Palantir Claude" with 2 models, 1 row
"Palantir GPT-5.4" with 1 model).
The new top-level mcp: config section surfaces exactly one field
(auto_reload_on_config_change) in the dashboard settings schema, which
tripped the no-single-field-categories invariant. Merge it into the
agent tab like onboarding/computer_use.
Follow-up on the salvaged #67449: auxiliary.mcp is the side-LLM task
provider block (provider/model/timeout for MCP aux calls) — a watcher
behavior toggle doesn't belong there. Move it to a new top-level mcp:
runtime section and read it from the same freshly-parsed config.yaml the
watcher already diffs (no second load_config() per tick, and flipping the
toggle + editing mcp_servers in one edit behaves correctly).
Also adds a regression test for the salvaged #55701 false-positive fix:
${VAR} templates in mcp_servers made the raw-yaml-vs-expanded-snapshot
comparison permanently unequal, so ANY save_config_value() rewrite (e.g.
/reasoning changing agent.reasoning_effort) fired a full MCP reconnect.
Credits: @OYLFLMH (#55701 env-expand fix), @TurgutKural (#67449 opt-out).
The automatic MCP reload added in #1474 watches config.yaml's mcp_servers
section every 5s and reloads on any change. Every reload rebuilds the agent
tool surface and INVALIDATES the provider prompt cache — the next message
re-sends the full input prefix, which is expensive on long-context /
high-reasoning models. When config.yaml is rewritten frequently (external
tooling, multiple Hermes instances, or a flapping MCP server that rewrites
config), this causes silent, repeated cache-breaking reloads.
Add `mcp.auto_reload_on_config_change` (default: true, backward compatible).
When set to false:
- The config change is still DETECTED (watcher keeps running).
- No automatic reload happens.
- The user is told the config changed, that new settings are NOT yet
applied, and how to apply them on their own terms with /reload-mcp —
including the explicit warning that /reload-mcp invalidates the prompt
cache.
Manual /reload-mcp is unaffected and still works for users who want to
apply changes deliberately.
Tests: extend TestMCPConfigWatch with test_optout_disables_auto_reload.
Co-authored-by: Turgut Kural <turgut.kural@gmail.com>
Follow-up to the salvaged #66083/#42792 commits:
- alibaba (Qwen Cloud coding-intl) gets qwen3.7-plus too — same platform
allowlist as alibaba-coding-plan (issue #44662 comment by @coder-movers)
- qwen3-max substring context entry (262144) so the newly-listed
qwen3-max-2026-01-23 snapshot doesn't fall to the generic 131072 qwen
fallback
The model list for alibaba coding plan is currently out of sync with
the actually supported models. See the official documentation[1].
Per the docs, alibaba coding plan does not support qwen3.7-max;
it supports qwen3.7-plus instead. Additionally, qwen3-max-2026-01-23
was missing from the model list.
Changes to the alibaba-coding-plan model list:
- Replace qwen3.7-max with qwen3.7-plus
- Add qwen3-max-2026-01-23
[1] https://www.alibabacloud.com/help/en/model-studio/coding-plan
Phase 2c found 3 tests that called _run_and_exit_oneshot without
mocking _cleanup_oneshot_runtime, causing real cleanup (terminal,
browser, MCP, auxiliary) to run in the pytest worker. Add the mock
to all three for test isolation.
Also remove redundant 'import logging' inside _exit_after_oneshot
(already imported at module level, line 729).
The initial salvage from #43698 only shut down MCP servers and cached
auxiliary clients. The interactive CLI's _run_cleanup() also closes
terminal environments, browser sessions, and interrupts async
delegations — all of which can hold native-extension-backed resources
(aiohttp connectors, websocket clients) that SIGABRT during
Py_FinalizeEx.
Add the missing three sites to _cleanup_oneshot_runtime(), matching the
order in cli.py:_run_cleanup(). Update tests to cover the expanded
cleanup chain.
Credit: @konsisumer (#67768) identified the full cleanup surface.
The dict-form guard from PR #67878 only covered the mapping shape
({model: {context_length: ...}}). The list-of-dicts shape
([{id: model, context_length: ...}]) is also a supported config form
(per _declared_model_ids) and was still being replaced with a flat
list of strings, destroying per-model metadata.
Sibling site for #67841.
When custom_providers[].models uses the mapping form to store
per-model metadata (e.g. context_length), _save_discovered_models_to_config
must not replace it with a flat list of strings. Add a guard that skips
entries whose models value is a dict, preserving the user's curated
metadata.
The regression was introduced by PR #65652, which added the auto-save
helper without considering the dict form.