{"repo":"NousResearch/hermes-agent","public_id":"14065","display_label":"Custom provider config corrections across CLI and runtime","summary":"These items address bugs in how custom provider configurations are preserved and applied—covering migration from v11 to v12, CLI argument handling, API key dropping, and transport field support.","subsystem":"hermes_cli","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":17901,"merged_at":"2026-04-30T10:29:49+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-30T10:29:49+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":14065,"pr_number":17901,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-30T10:29:49+00:00"}],"members":[{"kind":"issue","number":14065,"title":"Named custom providers under providers: drop inline api_key during runtime resolution","url":null,"state":"closed"},{"kind":"pr","number":14960,"title":"fix(cli): preserve api_mode/key_env/model for providers-dict named resolution","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#17901","number":17901}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":17901,"title":"fix(runtime_provider): honour transport field on v12+ providers dict","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/runtime_provider.py","scripts/release.py","tests/hermes_cli/test_user_providers_model_switch.py"],"fix_hunks":[{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -391,7 +391,14 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An\n                         \"api_key\": [redacted:secret-assignment]\n                         \"model\": entry.get(\"default_model\", \"\"),\n                     }\n-                    api_mode = _parse_api_mode(entry.get(\"api_mode\"))\n+                    # The v11→v12 migration writes the API mode under the new\n+                    # ``transport`` field, but hand-edited configs may still\n+                    # use the legacy ``api_mode`` spelling.  Accept both —\n+                    # the runtime normaliser ``_normalize_custom_provider_entry``\n+                    # already does, so without this lift every migrated config\n+                    # silently downgrades codex_responses / anthropic_messages\n+                    # providers to chat_completions in the resolved runtime.\n+                    api_mode = _parse_api_mode(entry.get(\"api_mode\") or entry.get(\"transport\"))\n                     if api_mode:\n                         result[\"api_mode\"] = api_mode\n                     return result","@@ -409,7 +416,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An\n                             \"api_key\": [redacted:secret-assignment]\n                             \"model\": entry.get(\"default_model\", \"\"),\n                         }\n-                        api_mode = _parse_api_mode(entry.get(\"api_mode\"))\n+                        api_mode = _parse_api_mode(entry.get(\"api_mode\") or entry.get(\"transport\"))\n                         if api_mode:\n                             result[\"api_mode\"] = api_mode\n                         return result"]},{"file":"scripts/release.py","hunks":["@@ -117,6 +117,7 @@\n     \"[redacted:email]\": \"Abnertheforeman\",\n     \"[redacted:email]\": \"amanning3390\",\n     \"[redacted:email]\": \"tochukwuada\",\n+    \"[redacted:email]\": \"zicochaos\",\n     \"[redacted:email]\": \"hharry11\",\n     \"[redacted:email]\": \"kshitijk4poor\",\n     \"1294707+Tosko4@users.noreply.github.com\": \"Tosko4\","]},{"file":"tests/hermes_cli/test_user_providers_model_switch.py","hunks":["@@ -748,6 +748,94 @@ def test_switch_model_resolves_user_provider_credentials(monkeypatch, tmp_path):\n         is_global=False,\n         user_providers=config[\"providers\"],\n     )\n-    \n+\n     assert result.success is True\n     assert result.error_message == \"\"\n+\n+\n+# =============================================================================\n+# Regression: providers: dict ``transport`` field must be honored\n+# =============================================================================\n+\n+\n+def test_get_named_custom_provider_reads_transport_field(monkeypatch):\n+    \"\"\"v12+ ``providers:`` dict stores api mode under ``transport:`` (not the\n+    legacy ``api_mode:``).  ``_get_named_custom_provider`` must accept both\n+    field names.\n+\n+    Bug: this function read only ``entry.get(\"api_mode\")`` for v12+ entries.\n+    After ``migrate_config()`` writes ``transport`` on every entry, the\n+    lookup returns None and ``_resolve_named_custom_runtime`` falls back\n+    through ``_detect_api_mode_for_url(base_url) or \"chat_completions\"``\n+    — silently downgrading every codex_responses / anthropic_messages\n+    provider to chat_completions.\n+    \"\"\"\n+    config = {\n+        \"_config_version\": 12,\n+        \"providers\": {\n+            \"my-codex-provider\": {\n+                \"name\": \"my-codex-provider\",\n+                \"api\": \"http://127.0.0.1:4000/v1\",\n+                \"api_key\": \"[redacted:secret-assignment]\",\n+                \"default_model\": \"gpt-5\",\n+                \"transport\": \"codex_responses\",\n+            },\n+        },\n+    }\n+\n+    monkeypatch.setattr(rp, \"load_config\", lambda: config)\n+\n+    result = rp._get_named_custom_provider(\"my-codex-provider\")\n+    assert result is not None\n+    assert result[\"api_mode\"] == \"codex_responses\"\n+    assert result[\"base_url\"] == \"http://127.0.0.1:4000/v1\"\n+    assert result[\"model\"] == \"gpt-5\"\n+\n+\n+def test_get_named_custom_provider_legacy_api_mode_field_still_works(monkeypatch):\n+    \"\"\"Hand-edited configs that used ``api_mode:`` (legacy spelling) inside\n+    the v12+ providers: dict shape must keep working — the migration writer\n+    produces ``transport:`` but human-edited configs may carry the older\n+    spelling forward.\"\"\"\n+    config = {\n+        \"_config_version\": 12,\n+        \"providers\": {\n+            \"anthropic-proxy\": {\n+                \"name\": \"anthropic-proxy\",\n+                \"api\": \"http://127.0.0.1:8082\",\n+                \"api_key\": \"[redacted:secret-assignment]\",\n+                \"default_model\": \"claude-opus-4-7\",\n+                \"api_mode\": \"anthropic_messages\",  # legacy spelling\n+            },\n+        },\n+    }\n+\n+    monkeypatch.setattr(rp, \"load_config\", lambda: config)\n+\n+    result = rp._get_named_custom_provider(\"anthropic-proxy\")\n+    assert result is not None\n+    assert result[\"api_mode\"] == \"anthropic_messages\"\n+\n+\n+def test_get_named_custom_provider_transport_resolves_via_display_name(monkeypatch):\n+    \"\"\"When the requested name matches the entry's ``name:`` field rather\n+    than its dict key, the same transport-vs-api_mode logic must apply\n+    (second branch in ``_get_named_custom_provider``).\"\"\"\n+    config = {\n+        \"_config_version\": 12,\n+        \"providers\": {\n+            \"slug-different-from-name\": {\n+                \"name\": \"Codex Provider\",  # display name\n+                \"api\": \"http://127.0.0.1:4000/v1\",\n+                \"api_key\": \"[redacted:secret-assignment]\",\n+                \"default_model\": \"gpt-5\",\n+                \"transport\": \"codex_responses\",\n+            },\n+        },\n+    }\n+\n+    monkeypatch.setattr(rp, \"load_config\", lambda: config)\n+\n+    result = rp._get_named_custom_provider(\"Codex Provider\")\n+    assert result is not None\n+    assert result[\"api_mode\"] == \"codex_responses\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}