Commit graph

2114 commits

Author SHA1 Message Date
Almurat
52e16c1138 fix: preserve kimi-coding-cn provider identity 2026-07-20 10:17:57 -07:00
Almurat
2ffdf08376 fix: show both kimi-coding and kimi-coding-cn in /model picker
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
2026-07-20 10:17:57 -07:00
AIalliAI
b99e1e3bf6 fix(model): collapse kimi alias/canonical to one /model picker row
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
2026-07-20 10:17:57 -07:00
Teknium
c0dff40e3a test: use shutil.copy2 instead of os.link for cross-device tmp fixtures
TestPtyWebSocket's two python-resolution tests and the sibling fixture in
test_tui_resume_flow.py hard-linked sys.executable into pytest's tmp_path.
On machines where /tmp is a different filesystem than the venv (tmpfs vs
disk home) os.link raises OSError EXDEV and the tests fail before reaching
any assertion. copy2 preserves the executable bit and works across devices.
2026-07-20 09:21:08 -07:00
ly-wang19
28028cce55 fix(web): clear stale api-alias credential on provider switch in main-model assignment
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).
2026-07-20 09:21:08 -07:00
Teknium
25eafd7d71 fix(models): complete kimi-k3 rollout across Kimi-direct catalog surfaces
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.
2026-07-20 08:47:55 -07:00
Frowtek
b520f507cc fix(dashboard): clear the model mirror when its custom endpoint is deleted
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.
2026-07-20 08:37:14 -07:00
Frowtek
6bedec4734 fix(dashboard): don't wipe hand-written provider fields on custom-endpoint edit
_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).
2026-07-20 08:37:14 -07:00
Frowtek
38a274b297 fix(dashboard): let an explicit api_key win over the provider entry's stored one
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.
2026-07-20 06:48:59 -07:00
Teknium
ed3a0b3948 fix(config): warn-after-write for unrecognized keys instead of refusing
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.
2026-07-20 05:38:59 -07:00
Bartok9
3b2e445890 test(config): use schema-known key in config-set confirm-flow test
Since #34067 validation, config set refuses unknown top-level keys, so
test_config_set_requires_confirmation_then_writes must target a valid
path. Switch console.test -> telegram.test (PlatformConfig open-dict).
2026-07-20 05:38:59 -07:00
Bartok9
5bb00f9e3a fix(config): validate gateway.platforms.* + approvals.* per maintainer review
Address hermes-sweeper review on #34250:
- Accept top-level platforms.<name>.* and gateway.platforms.<name>.*
  (current docs + gateway/config.py resolve platforms under these paths;
  PlatformConfig.extra keeps them open below the platform-name segment).
- Remove approvals from open-dict whitelist so approvals.<typo> is
  schema-validated and refused instead of silently written.
- Tests for canonical platform paths and unknown approvals key rejection.
2026-07-20 05:38:59 -07:00
Bartok9
477274f1d9 fix(config): allow underscore-prefixed internal/test keys past schema validation
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>
2026-07-20 05:38:59 -07:00
Bartok9
fd19e8bb48 test(config): update placeholder usage assertion for --force flag
CI caught test_config_set_usage_marks_placeholders failing on this PR's
new usage line ('Usage: hermes config set [--force] <key> <value>' vs
the previous 'Usage: hermes config set <key> <value>').

The usage change is intentional — it documents the --force escape hatch
this PR adds for bypassing schema validation. Update the assertion to
require the placeholder markers and --force keyword without pinning the
exact wording, so future doc tweaks (e.g. wrapping or color codes) don't
re-break this test.

Confirmed locally: 4/4 placeholder tests pass.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 05:38:59 -07:00
Bartok9
70679ada61 fix(config): validate config-key schema, refuse unknown keys (#34067)
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>
2026-07-20 05:38:59 -07:00
Teknium
a1813c1ef4
feat(desktop): inline TTS voice/model settings in the Capabilities tab (#68017)
* 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.
2026-07-20 05:38:38 -07:00
Teknium
369afc60be fix: migrate CLI kanban gate + remaining mocks to 5-value judge contract
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.
2026-07-20 05:38:25 -07:00
João Vitor Cunha
d401fd7251 fix(goals): update judge consumers for transport result 2026-07-20 05:38:25 -07:00
João Vitor Cunha
48fc1d780b fix(goals): auto-pause goal loop on consecutive transport failures
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
2026-07-20 05:38:25 -07:00
Teknium
34a304abb3 fix: unpack judge_goal 4-tuple in salvaged CLI gate; harden tests
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.
2026-07-20 03:36:44 -07:00
srojk34
aa32154e4b fix(kanban): apply goal_mode judge gate to CLI complete command
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.
2026-07-20 03:36:44 -07:00
nnnet
305ecac8b2 feat(providers): extend `enabled: false` gate to built-in resolution
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.
2026-07-20 03:06:02 -07:00
nnnet
7de06f700e feat(providers): add `enabled: false` flag to hide a provider
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.
2026-07-20 03:06:02 -07:00
Craig French
b239ee2123 feat(model-switch): excluded_providers config to hide providers from /model picker 2026-07-20 03:06:02 -07:00
Deepak Jain
1b56d0d1a2 test(cli): isolate model picker Ollama probes
Fixes #30604
2026-07-20 03:06:02 -07:00
liuhao1024
07f39cf9a6 fix(models): add Claude Sonnet 5 to curated model lists
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
2026-07-20 02:25:44 -07:00
Ariel Bravy
e2561466c7 feat(models): add Claude Sonnet 5 support 2026-07-20 02:25:44 -07:00
Teknium
9bda6438d4
fix(config): remove unknown-top-level-key warning — top-level keys bridge to env (#67924)
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).
2026-07-20 02:25:33 -07:00
Craig French
1c3a48965b fix(model-switch): keep same-endpoint custom providers with different names as separate picker rows 2026-07-20 02:22:37 -07:00
Alex López
7ed18dae90 test(model): isolate custom-provider grouping tests from live discovery 2026-07-20 02:22:26 -07:00
YAMAGUCHI Seiji
8590c2d0d9 feat(cli): make reasoning effort session-scoped 2026-07-20 02:22:22 -07:00
Teknium
8b6fde3a35 fix(model): default /model switches to session scope everywhere
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.
2026-07-20 02:22:22 -07:00
liuhao1024
0d6d73525d fix(model): default --provider switches to session-only persistence
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
2026-07-20 02:22:22 -07:00
Teknium
463c2ae255 fix: section-3 grouping follow-ups for salvaged PR #36998
- extra_headers participates in the section-3 group identity (mirrors
  section 4 — header-routed tenants behind one proxy URL stay distinct)
- model declarations go through _declared_model_ids() so
  models: [{id: ...}] rows keep working
- gateway model-switch handler moved to gateway/slash_commands.py since
  the PR branched — re-applied the display-form edits there (both the
  legacy picker closure and the current typed path)
- regression tests: same-endpoint fold, api_mode separation,
  header-routed separation, list-of-dict models, RID display stripping
2026-07-20 00:41:21 -07:00
kshitijk4poor
244dabbd9c test(cli): mock _cleanup_oneshot_runtime in all _run_and_exit tests
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).
2026-07-20 12:59:28 +05:30
kshitijk4poor
97fc8a4a3c refactor(cli): apply /simplify-code findings to oneshot teardown
- Add idempotency guard (_oneshot_cleanup_done) to _cleanup_oneshot_runtime,
  matching cli.py:_run_cleanup's pattern
- Trim _exit_after_oneshot docstring from 22 to 8 lines (per-resource
  ownership enumeration already documented in _run_agent)
- Add comment clarifying cleanup ordering mirrors gateway/run.py, not
  cli.py (oneshot has no _active_agent_ref)
- Clarify session_db.close() comment: agent.close() calls end_session()
  but leaves the connection open

Findings skipped (follow-up scope):
- Extract shared 5-step cleanup helper from cli.py:_run_cleanup (widens
  scope into critical file)
- Extract _hard_exit helper (3 copies across cli.py)
- Extract shutdown_agent_resources helper (touches run_agent.py + cli.py
  + gateway/run.py)
- Test boilerplate dedup (test-only, non-blocking)
2026-07-20 12:59:28 +05:30
kshitijk4poor
2de60a3a7e fix(cli): expand oneshot cleanup to cover all process-global resources
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.
2026-07-20 12:59:28 +05:30
harjoth
54eea80bf7 test(cli): cover termux oneshot usage file 2026-07-20 12:59:28 +05:30
harjoth
fbfe89871b fix(cli): guarantee hard exit after cleanup interruption 2026-07-20 12:59:28 +05:30
harjoth
b82ffdaa4d test(cli): preserve oneshot usage file through hard exit 2026-07-20 12:59:28 +05:30
harjoth
bfa7a794cb fix(cli): avoid one-shot SIGABRT during teardown 2026-07-20 12:59:28 +05:30
kshitij
2ae195673e fix: widen metadata-preserve guard to list-of-dicts models form
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.
2026-07-20 12:13:37 +05:30
kyssta-exe 25470058+kyssta-exe@users.noreply.github.com
311bacb572 fix(model-switch): preserve per-model metadata dict in _save_discovered_models_to_config (#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.
2026-07-20 12:13:37 +05:30
Floze
cd0219da86 fix(gateway): stop slow restart redelivery loops 2026-07-20 12:11:07 +05:30
kshitijk4poor
3c72177061 fix(config): widen doctor allowlist to all gateway-bridged top-level keys
Salvage of PR #67447 — the original PR fixed 3 of 7 missing keys.
gateway/config.py reads 4 more top-level keys (stt_echo_transcripts,
reset_triggers, always_log_local, filter_silence_narration) that
produced the same false 'Unknown top-level config key' warning.
Add all 4 and extend the regression test to cover them.
2026-07-20 11:53:10 +05:30
HexLab98
54157da9ee test(config): cover doctor allowlist for Hermes-written root keys
Regression for known_plugin_toolsets / group_sessions_per_user /
thread_sessions_per_user so validate_config_structure no longer
false-positives on keys Hermes owns.
2026-07-20 11:53:10 +05:30
Austin Pickett
3d97893571
feat(desktop): custom endpoint settings (supersedes #42745) (#67759)
* feat(desktop): add custom endpoint settings (supersedes #42745)

Salvages PR #42745 (elashera:custom-endpoints-desktop), which could no
longer merge cleanly against main. Re-integrated the work onto current
main and reconciled the conflicts:

- Settings nav: wired the new 'Custom Endpoints' provider sub-view into
  main's data-driven navGroups/OverlayNav layout (PR predated that
  refactor) and added it to PROVIDER_VIEWS.
- providers-settings: kept BOTH main's LocalEndpointRow affordance and
  the PR's fuller CRUD panel; unified ProvidersSettingsProps to carry
  onClose + onConfigSaved + onMainModelChanged.
- web_server: kept main's _normalize_main_model_assignment + api_key
  propagation AND the PR's provider base_url lookup in
  _apply_model_assignment_sync.
- model_switch: dropped the PR's bare direct-custom-config picker block;
  main already implements it (source='model-config', with live model
  discovery). Updated the salvaged test to assert main's behavior.
- Merged additive import/type blocks in hermes.ts and types/hermes.ts.

Backend endpoints, i18n labels (en/ja/zh/zh-hant), and the
custom-endpoints-settings.tsx panel carried over. 28 custom-endpoint
tests pass.

Co-authored-by: elashera <emilio.jesus.lasheras.romero@nttdata.com>

* chore(contributors): map elashera's commit email

Salvage of #42745 (superseded by #67759) preserves @elashera's
authorship, whose corporate commit email had no contributor mapping.
Adds contributors/emails/ mapping so check-attribution passes.
Verified: GitHub user 'elashera' id=135239963 matches their own
noreply commit email (135239963+elashera@users.noreply.github.com).

---------

Co-authored-by: elashera <emilio.jesus.lasheras.romero@nttdata.com>
2026-07-19 19:59:46 -04:00
ajzrva-sys
54459e76ed
fix: speed up CLI /model picker by skipping non-current custom provider probing (#65652)
* fix: speed up CLI /model picker by skipping non-current custom provider probing

The CLI /model picker calls build_models_payload() with default
probe_custom_providers=True, which live-fetches /v1/models from every
saved custom endpoint on every open. The GUI/desktop picker already
passes probe_custom_providers=False for snappiness.

Match the GUI behavior: skip probing non-current custom providers, but
still probe the current one so its model list stays accurate. Users can
force a full re-fetch with /model --refresh.

Fixes #65650
Related: #63583

* fix(cli): forward force_refresh to model picker probe flags

When /model --refresh is used, the CLI model picker must probe all
custom providers to refresh their model lists — not skip them.
Normal bare /model still skips non-current probes for speed.

Mirrors the existing desktop/TUI behavior. Add regression test for
both normal and refresh flag forwarding.

Fixes #65650

* fix: auto-save discovered models to config for discover-once caching

After a successful /v1/models probe, persist the discovered model list
back to config.yaml under the matching custom_providers entry. This
makes discover_models: false meaningful out of the box — users get a
populated cache after the first probe instead of a stale 1-model list.

- Add _save_discovered_models_to_config() helper
- Call after successful fetch_api_models in section 4 probe path
- Skip config write when model list hasn't changed
- Idempotent — no-op on empty api_url or model_ids

Tests: 4 new tests covering auto-save, empty-probe skip, unchanged
skip, and no-op-on-empty-args. All 4 pass.

Refs: #65652, #65650

---------

Co-authored-by: ajzrva-sys <302567740+ajzrva-sys@users.noreply.github.com>
2026-07-19 19:33:49 -04:00
teknium1
786df3ca6c fix(cron): resolve provider with the job's effective model; default dashboard cron creates to the backend's own profile
Two follow-ups to the per-job model pin surface (#67472 / #49948 review):

- cron/scheduler.py: pass target_model=<effective job model> to
  resolve_runtime_provider() on the primary path, so providers with
  model-specific api_mode routing derive the mode from the model the job
  actually runs (per-job pin > env > config default) instead of the stale
  persisted default. The auth-fallback path already did this for its
  fb_model.

- hermes_cli/web_server.py: POST /api/cron/jobs (and its sync worker) no
  longer hardcodes profile="default" when the request carries no profile
  param. A pool backend scoped to a named profile now resolves its own
  profile via get_active_profile_name(), so pre-profileScoped desktop
  clients can't write a named profile's job into ~/.hermes. Unscoped /
  custom HERMES_HOME keeps the legacy default fallback.

Tests: target_model capture test on run_job; two profile-default tests on
the create endpoint.
2026-07-19 09:57:21 -07:00
Teknium
2ae0d67f63
feat(desktop): five Capabilities-tab UX fixes from live testing — hints, vision link, web split, key deep-links (#67482)
* fix(desktop): stop contradicting the Ready pill with the one-time-install hint

When a provider's server-computed status is 'ready' (post_setup install
verifiably satisfied, e.g. cua-driver on PATH), the PostSetupRunner row
still said 'This backend needs a one-time install (…)'. Swap the copy for
a muted installed-confirmation one-liner and keep the Run setup button for
repair re-runs. Gated purely on the provider status prop so it composes
with the server-driven resting state work in the sibling lane.

* feat(tools): surface the web search/extract capability split in the Capabilities UI

The runtime has dispatched web_search and web_extract to independently
configurable backends for a long time (web.search_backend /
web.extract_backend overrides with web.backend as the shared fallback),
but the Capabilities tab still presented one monolithic 'Web Search &
Extract' choice that only wrote web.backend.

Backend:
- GET /api/tools/toolsets/web/config now returns active_search_backend /
  active_extract_backend resolved via the REAL runtime getters
  (tools.web_tools._get_search_backend/_get_extract_backend), plus each
  provider row's web_backend key and supported capabilities (from the
  registry's supports_search/supports_extract flags).
- PUT /api/tools/toolsets/web/provider accepts an optional capability
  ('search'|'extract') that writes web.<capability>_backend without
  touching web.backend; validates the provider actually supports the
  requested capability (ddgs/brave-free are search-only). Omitted →
  unchanged legacy apply_provider_selection path.
- New tools_config.web_provider_capabilities() helper reads the plugin
  registry's capability flags.

Frontend: 'Search: <backend>' / 'Extract: <backend>' pills above the web
provider matrix, per-row 'Search backend'/'Extract backend' assignment
pills, and 'Use for Search'/'Use for Extract' actions gated on each
backend's declared capabilities.

Tests: endpoint tests assert the runtime getters resolve to the written
backend (searxng for search, firecrawl for extract) after the endpoint
write; vitest covers badges, capability-gated buttons, and non-web
toolsets staying untouched.

* feat(desktop): deep-link Capabilities key rows to Settings → API Keys

Set env-var rows in the toolset config panel now offer 'Manage in API
Keys' in the row actions menu — an internal route change to
/settings?tab=keys&key=<ENV_KEY>. KeysSettings consumes the ?key= param
via the shared useDeepLinkHighlight hook (same mechanism as the command
palette's ?field= config deep links and ?session= archived-session
links): scrolls the credential card into view, flashes it, and expands
it. Applies generically to every env-var row, and only when the key is
set (unset keys are managed inline via Set). i18n in en/zh/zh-hant/ja.

* feat(desktop): point the vision Capabilities detail at Settings → Models

The vision toolset has no TOOL_CATEGORIES provider matrix — its
provider/model resolution runs through the auxiliary model config
(agent/auxiliary_client.py), so the Capabilities detail pane looked
empty with no hint of where the model choice lives.

Add a short explainer + an internal deep link
(/settings?tab=config:model&aux=vision) rendered only for
toolset.name === 'vision'. ModelSettings consumes the ?aux= param via
the shared useDeepLinkHighlight hook and scrolls/flashes the matching
auxiliary task row (rows now carry aux-task-<key> anchor ids). No
external URLs. i18n in en/zh/zh-hant/ja.

* test(desktop): use type-alias imports for the react-router mock (lint)

* chore: drop accidentally committed node_modules symlinks

* chore: drop remaining committed node_modules symlinks (apps/desktop, apps/shared)
2026-07-19 05:45:01 -07:00