Skip to content

fix(model-switch): handle list-of-dicts models in providers config (500 on /api/model/options) - #57888

Closed
yobo2u wants to merge 1 commit into
NousResearch:mainfrom
yobo2u:fix/model-options-list-of-dicts
Closed

fix(model-switch): handle list-of-dicts models in providers config (500 on /api/model/options)#57888
yobo2u wants to merge 1 commit into
NousResearch:mainfrom
yobo2u:fix/model-options-list-of-dicts

Conversation

@yobo2u

@yobo2u yobo2u commented Jul 3, 2026

Copy link
Copy Markdown

Bug: providers.<name>.models list-of-dicts format crashes /api/model/options with 500

Summary

When config.yaml has any providers.<name> entry whose models: is a list of dicts (e.g. [{id: foo, context_length: 128000}, ...] — a Hermes-supported format), and that provider falls through to Section 3 of list_authenticated_providers() without a live-probe, the Desktop GUI's model picker fails to open with:

Error invoking remote method 'hermes:api': Error: 500: {"detail":"Failed to list model options"}

Because /api/model/options is all-or-nothing, a single misbehaving provider row poisons the entire response — even providers the user actively uses can't be listed.

Environment

Reproduce

Minimal ~/.hermes/config.yaml:

model:
  default: some-model
  provider: some-provider

providers:
  # Any provider that:
  # 1. Falls through to Section 3 (not matched by Section 1/2 credentials), AND
  # 2. Has no api_key set (so should_probe stays False), AND
  # 3. Uses list-of-dicts models format
  github-copilot:
    name: Claude Code
    models:
      - context_length: 200000
        id: claude-opus-4.7
      - context_length: 200000
        id: claude-sonnet-4.7

Then either:

  • Open Hermes Desktop → click model picker → 500 error banner
  • Or via curl: curl http://127.0.0.1:<port>/api/model/options → HTTP 500

Root Cause

hermes_cli/model_switch.py, list_authenticated_providers() Section 3, lines 1966–1969:

elif isinstance(cfg_models, list):
    for m in cfg_models:
        if m and m not in models_list:
            models_list.append(m)         # ← appends the whole dict verbatim

When cfg_models is [{"id": "claude-opus-4.7", "context_length": 200000}, ...], the loop appends the dict to models_list. That poisoned list is returned as the row's "models" field and propagates to hermes_cli/inventory.py:188:

user_models.update(m.lower() for m in (row.get("models") or []))
#                  ^^^^^^^^^
# AttributeError: 'dict' object has no attribute 'lower'

Which bubbles out of build_models_payload()web_server.py:4232 → the 500 response.

Full stack trace (from ~/.hermes/logs/errors.log)

ERROR hermes_cli.web_server: GET /api/model/options failed
Traceback (most recent call last):
  File "hermes_cli/web_server.py", line 4219, in get_model_options
    return build_models_payload(
  File "hermes_cli/inventory.py", line 188, in build_models_payload
    user_models.update(m.lower() for m in (row.get("models") or []))
  File "hermes_cli/inventory.py", line 188, in <genexpr>
    user_models.update(m.lower() for m in (row.get("models") or []))
                       ^^^^^^^
AttributeError: 'dict' object has no attribute 'lower'

Why This Only Bites Sometimes

Section 3 has an escape hatch that masks the bug for providers with credentials:

should_probe = bool(api_url) and discover and (
    bool(api_key) or not has_explicit_models
)
if should_probe:
    live_models = fetch_api_models(api_key, api_url)
    if live_models:
        models_list = live_models      # ← overwrites the poisoned list with strings

So a volcengine-agent-plan with an api_key hits /v1/models, gets a clean string list, and the dict-poisoning is invisible. But a credential-less provider with an explicit models: list (e.g. a providers.github-copilot entry configured for Claude Code but lacking GH_TOKEN/COPILOT_GITHUB_TOKEN) skips the probe and ships the poisoned row downstream — 500-ing the whole endpoint.

Related: Section 4 (custom_providers) handles the same shape correctly:

# model_switch.py, section 4 — CORRECT
elif isinstance(cfg_models, list):
    for m in cfg_models:
        if m and m not in groups[group_key]["models"]:
            groups[group_key]["models"].append(m)     # <-- but cfg_models here is already normalized to dict-of-strings

And _normalize_custom_provider_entry() in hermes_cli/config.py:4582-4589 already knows list-of-dicts is a valid input… but silently drops them:

elif isinstance(models, list) and models:
    normalized["models"] = {
        str(m): {} for m in models if isinstance(m, str) and m.strip()
        #                             ^^^^^^^^^^^^^^^^^^^ dicts fail this and get dropped
    }

Fix

Apply the same dict-flattening pattern already used elsewhere in the codebase (e.g. hermes_cli/config.py similar sites):

--- a/hermes_cli/model_switch.py
+++ b/hermes_cli/model_switch.py
@@ -1963,10 +1963,11 @@
             cfg_models = ep_cfg.get("models", [])
             if isinstance(cfg_models, dict):
                 for m in cfg_models:
                     if m and m not in models_list:
                         models_list.append(m)
             elif isinstance(cfg_models, list):
                 for m in cfg_models:
-                    if m and m not in models_list:
-                        models_list.append(m)
+                    mid = (m.get("id") if isinstance(m, dict) else m) if m else ""
+                    if mid and mid not in models_list:
+                        models_list.append(mid)

Verification

Before fix:

$ curl http://127.0.0.1:<port>/api/model/options
{"detail":"Failed to list model options"}

After fix:

$ curl http://127.0.0.1:<port>/api/model/options | jq '.providers | length'
42

All configured providers (volcengine-agent-plan: 12 models, openai-codex: 4 models, github-copilot/Claude Code: 4 models) now appear in the GUI picker.

Suggested Additional Improvements (out of scope for this PR)

  1. _normalize_custom_provider_entry() in hermes_cli/config.py:4579-4589 should also accept list-of-dicts (extract id field) instead of silently dropping them.
  2. /api/model/options should degrade gracefully — one malformed provider shouldn't 500 the whole endpoint. Consider per-row try/except so bad rows are skipped with a warning rather than killing the entire response.
  3. Config validation (hermes config check / doctor) should warn when it sees list-of-dicts models with no id field, or when a providers.<name> shape doesn't match any documented schema.

…section

list_authenticated_providers() Section 3 handled 'providers.<name>.models'
as either a dict-keyed-by-id (Hermes writer format) or a list, but the
list branch appended items verbatim. When a user configures models as a
list of dicts like:

  providers:
    my-provider:
      models:
        - id: model-a
          context_length: 128000
        - id: model-b

the whole dict objects were appended to the row's 'models' list. When
that row later reached hermes_cli/inventory.py:188's

  user_models.update(m.lower() for m in (row.get("models") or []))

Python raised AttributeError: 'dict' object has no attribute 'lower',
bubbling out of build_models_payload() and turning /api/model/options
into a 500 that displays in the Desktop GUI as:

  Failed to list model options

This only triggered when the offending provider *also* skipped the
Section 3 live /v1/models probe (should_probe=False), which happens
when the row has no api_key AND has explicit models. Providers with a
key masked the bug because fetch_api_models() overwrote the poisoned
list with a clean string list.

Fix mirrors the pattern used in the same function's Section 4 and in
_normalize_custom_provider_entry(): extract m['id'] when m is a dict,
otherwise use m verbatim. Restores the full picker payload — verified
locally against a config with volcengine-agent-plan + github-copilot
(list-of-dicts, no COPILOT/GH token) + openai-codex, which previously
500'd and now returns all 42 provider rows correctly.
@alt-glitch alt-glitch added type/bug Something isn't working comp/cli CLI entry point, hermes_cli/, setup wizard comp/desktop Electron desktop app (apps/desktop/*) area/config Config system, migrations, profiles P3 Low — cosmetic, nice to have labels Jul 3, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related: this fixes the same /api/model/options 500 on list-of-dict providers.<name>.models config as #33372 (earliest, factors an _add_model_id() helper applied to both dict+list branches and a 2nd call site, with tests) and #55770 (inline list-branch fix). This PR patches the Section 3 list branch. The three overlap on the same code site in hermes_cli/model_switch.py; #33372 looks the most comprehensive. Maintainer to pick the canonical fix.

@teknium1

Copy link
Copy Markdown
Contributor

Thanks for the focused reproduction and patch. This is an automated hermes-sweeper review; the behavioral fix is already present on current main.

  • hermes_cli/model_switch.py:55-98 now normalizes declared model collections, including [{"id": ...}] entries, to string IDs.
  • Section 3 consumes that helper at hermes_cli/model_switch.py:2046, so it no longer appends dict values into picker rows.
  • The broader implementation landed in 2b5d4ae916a805829e0c789a6dffe05f48e08a07 (fix(model): merge configured models into picker rows (#63055)).

The supplied triage comment correctly identified overlapping fixes; this PR's target hunk has since been superseded.

@teknium1 teknium1 closed this Jul 15, 2026
@teknium1 teknium1 added the sweeper:implemented-on-main Sweeper: behavior already present on current main label Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles comp/cli CLI entry point, hermes_cli/, setup wizard comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have sweeper:implemented-on-main Sweeper: behavior already present on current main type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants