{"repo":"NousResearch/hermes-agent","public_id":"6209","display_label":"Add api_mode selection for custom endpoints","summary":"These issues and PRs add the ability for users to explicitly select or auto-detect the API protocol mode (e.g., Anthropic Messages) when configuring custom endpoints. Includes UI prompting, persistence, and auto-detection for third-party Anthropic-compatible endpoints.","subsystem":"hermes_cli","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":25068},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-05-13T20:21:34+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.29066666666666663},"fix_edges":[{"issue_number":6209,"pr_number":25068,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":12137,"pr_number":25068,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":6209,"title":"[Feature]: Custom endpoint setup should let users choose API protocol (api_mode)","url":null,"state":"closed"},{"kind":"issue","number":13415,"title":"[Feature]: Allow explicit api_mode selection for manual custom endpoints","url":null,"state":"closed"},{"kind":"pr","number":6210,"title":"feat: add api_mode selection to custom endpoint setup","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":7410,"title":"fix: auto-detect anthropic_messages api_mode for third-party /anthropic endpoints","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#12846","number":12846}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":17391,"title":"feat(custom-endpoint): support Anthropic Messages protocol for custom endpoints","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#25068","number":25068},{"kind":"pr","value":"#25068","number":25068}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":25068,"title":"feat(custom): prompt and persist explicit api_mode for custom providers","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/main.py","scripts/release.py","tests/cli/test_cli_provider_resolution.py","tests/hermes_cli/test_model_provider_persistence.py"],"fix_hunks":[{"file":"hermes_cli/main.py","hunks":["@@ -3079,6 +3079,21 @@ def _model_flow_custom(config):\n             else:\n                 print(f\"  If /v1 should not be in the base URL, try: {suggested}\")\n \n+    # Prompt for API compatibility mode explicitly so codex-compatible custom\n+    # providers don't silently fall back to chat_completions.\n+    current_model_cfg = config.get(\"model\")\n+    current_api_mode = \"\"\n+    if isinstance(current_model_cfg, dict):\n+        current_api_mode = str(current_model_cfg.get(\"api_mode\") or \"\").strip()\n+    api_mode = _prompt_custom_api_mode_selection(\n+        effective_url,\n+        current_api_mode=current_api_mode,\n+    )\n+    if api_mode:\n+        print(f\"  API mode: {api_mode}\")\n+    else:\n+        print(\"  API mode: auto-detect\")\n+\n     # Select model — use probe results when available, fall back to manual input\n     model_name = \"\"\n     detected_models = probe.get(\"models\") or []","@@ -3142,7 +3157,10 @@ def _model_flow_custom(config):\n         model[\"base_url\"] = effective_url\n         if effective_key:\n             model[\"api_key\"] = effective_key\n-        model.pop(\"api_mode\", None)  # let runtime auto-detect from URL\n+        if api_mode:\n+            model[\"api_mode\"] = api_mode\n+        else:\n+            model.pop(\"api_mode\", None)\n         save_config(cfg)\n         deactivate_provider()\n ","@@ -3165,7 +3183,10 @@ def _model_flow_custom(config):\n         _caller_model[\"base_url\"] = effective_url\n         if effective_key:\n             _caller_model[\"api_key\"] = effective_key\n-        _caller_model.pop(\"api_mode\", None)\n+        if api_mode:\n+            _caller_model[\"api_mode\"] = api_mode\n+        else:\n+            _caller_model.pop(\"api_mode\", None)\n         config[\"model\"] = _caller_model\n         print(\"Endpoint saved. Use `/model` in chat or `hermes model` to set a model.\")\n ","@@ -3176,9 +3197,80 @@ def _model_flow_custom(config):\n         model_name or \"\",\n         context_length=context_length,\n         name=display_name,\n+        api_mode=api_mode,\n     )\n \n \n+def _prompt_custom_api_mode_selection(base_url: str, current_api_mode: str = \"\") -> Optional[str]:\n+    \"\"\"Prompt for a custom provider API mode.\n+\n+    Returns an explicit mode string, or None to keep auto-detect behavior.\n+    \"\"\"\n+    from hermes_cli.runtime_provider import _detect_api_mode_for_url\n+\n+    detected_mode = _detect_api_mode_for_url(base_url)\n+    normalized_current = str(current_api_mode or \"\").strip().lower()\n+    default_mode = normalized_current or detected_mode or \"\"\n+\n+    mode_options = [\n+        (\n+            \"\",\n+            \"Auto-detect\",\n+            \"Use Hermes URL heuristics; best for standard OpenAI-compatible endpoints.\",\n+        ),\n+        (\n+            \"chat_completions\",\n+            \"Chat Completions\",\n+            \"Use /chat/completions for standard OpenAI-compatible servers.\",\n+        ),\n+        (\n+            \"codex_responses\",\n+            \"Responses / Codex\",\n+            \"Use /responses for Codex-compatible tool-calling backends.\",\n+        ),\n+        (\n+            \"anthropic_messages\",\n+            \"Anthropic Messages\",\n+            \"Use /v1/messages for Anthropic-compatible endpoints.\",\n+        ),\n+    ]\n+\n+    print()\n+    print(\"Select API compatibility mode:\")\n+    for idx, (value, label, description) in enumerate(mode_options, 1):\n+        markers = []\n+        if value == detected_mode:\n+            markers.append(\"detected\")\n+        if value == default_mode:\n+            markers.append(\"current\")\n+        suffix = f\" [{' / '.join(markers)}]\" if markers else \"\"\n+        print(f\"  {idx}. {label}{suffix}\")\n+        print(f\"     {description}\")\n+\n+    try:\n+        raw = input(\n+            \"Choice [1-4, Enter to keep current/detected]: \"\n+        ).strip().lower()\n+    except (KeyboardInterrupt, EOFError):\n+        print(\"\\nCancelled.\")\n+        raise\n+\n+    if not raw:\n+        return default_mode or None\n+\n+    if raw in {\"1\", \"auto\", \"detect\", \"auto-detect\"}:\n+        return None\n+    if raw in {\"2\", \"chat\", \"chat_completions\", \"completions\"}:\n+        return \"chat_completions\"\n+    if raw in {\"3\", \"responses\", \"codex\", \"codex_responses\"}:\n+        return \"codex_responses\"\n+    if raw in {\"4\", \"anthropic\", \"anthropic_messages\", \"messages\"}:\n+        return \"anthropic_messages\"\n+\n+    print(f\"Invalid API mode choice: {raw}. Falling back to auto-detect.\")\n+    return None\n+\n+\n def _auto_provider_name(base_url: str) -> str:\n     \"\"\"Generate a display name from a custom endpoint URL.\n ","@@ -3214,12 +3306,12 @@ def _custom_provider_api_key_config_value(provider_info, resolved_api_key=\"\"):\n \n \n def _save_custom_provider(\n-    base_url, api_key=\"\", model=\"\", context_length=None, name=None\n+    base_url, api_key=\"\", model=\"\", context_length=None, name=None, api_mode=None\n ):\n     \"\"\"Save a custom endpoint to custom_providers in config.yaml.\n \n     Deduplicates by base_url — if the URL already exists, updates the\n-    model name and context_length but doesn't add a duplicate entry.\n+    model name, context_length, and api_mode but doesn't add a duplicate entry.\n     Uses *name* when provided, otherwise auto-generates from the URL.\n     \"\"\"\n     from hermes_cli.config import load_config, save_config","@@ -3245,6 +3337,13 @@ def _save_custom_provider(\n                 models_cfg[model] = {\"context_length\": context_length}\n                 entry[\"models\"] = models_cfg\n                 changed = True\n+            if api_mode:\n+                if entry.get(\"api_mode\") != api_mode:\n+                    entry[\"api_mode\"] = api_mode\n+                    changed = True\n+            elif \"api_mode\" in entry:\n+                entry.pop(\"api_mode\", None)\n+                changed = True\n             if changed:\n                 cfg[\"custom_providers\"] = providers\n                 save_config(cfg)","@@ -3259,6 +3358,8 @@ def _save_custom_provider(\n         entry[\"api_key\"] = api_key\n     if model:\n         entry[\"model\"] = model\n+    if api_mode:\n+        entry[\"api_mode\"] = api_mode\n     if model and context_length:\n         entry[\"models\"] = {model: {\"context_length\": context_length}}\n ","@@ -3712,7 +3813,7 @@ def _model_flow_named_custom(config, provider_info):\n                 save_config(cfg)\n     else:\n         # Save model name to the custom_providers entry for next time\n-        _save_custom_provider(base_url, config_api_key, model_name)\n+        _save_custom_provider(base_url, config_api_key, model_name, api_mode=api_mode)\n \n     print(f\"\\n✅ Model set to: {model_name}\")\n     print(f\"   Provider: {name} ({base_url})\")"]},{"file":"scripts/release.py","hunks":["@@ -53,6 +53,7 @@\n     \"[redacted:email]\": \"wuli666\",\n     \"[redacted:email]\": \"counterposition\",\n     \"[redacted:email]\": \"zhengyn0001\",\n+    \"[redacted:email]\": \"littlewwwhite\",\n     \"[redacted:email]\": \"db-aeon\",\n     \"[redacted:email]\": \"ahmedbadr3\",\n     \"[redacted:email]\": \"curiouscleo\","]},{"file":"tests/cli/test_cli_provider_resolution.py","hunks":["@@ -531,8 +531,8 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys):\n \n     # After the probe detects a single model (\"llm\"), the flow asks\n     # \"Use this model? [Y/n]:\" — confirm with Enter, then context length,\n-    # then display name.\n-    answers = iter([\"http://localhost:8000\", \"local-key\", \"\", \"\", \"\", \"\"])\n+    # then display name. The api_mode prompt also runs before model selection.\n+    answers = iter([\"http://localhost:8000\", \"local-key\", \"\", \"\", \"\", \"\", \"\"])\n     monkeypatch.setattr(\"builtins.input\", lambda _prompt=\"\": next(answers))\n     monkeypatch.setattr(\"getpass.getpass\", lambda _prompt=\"\": next(answers))\n ","@@ -546,6 +546,63 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys):\n     assert saved_env[\"MODEL\"] == \"llm\"\n \n \n+def test_model_flow_custom_persists_selected_api_mode(monkeypatch):\n+    saved_cfg = {\"model\": {\"default\": \"\", \"provider\": \"custom\", \"base_url\": \"\"}}\n+    captured_provider = {}\n+\n+    monkeypatch.setattr(\n+        \"hermes_cli.config.get_env_value\",\n+        lambda key: \"\" if key in {\"OPENAI_BASE_URL\", \"OPENAI_API_KEY\"} else \"\",\n+    )\n+    monkeypatch.setattr(\"hermes_cli.auth._save_model_choice\", lambda model: None)\n+    monkeypatch.setattr(\"hermes_cli.auth.deactivate_provider\", lambda: None)\n+    monkeypatch.setattr(\n+        \"hermes_cli.models.probe_api_models\",\n+        lambda api_key, base_url: {\n+            \"models\": [],\n+            \"probed_url\": f\"{base_url.rstrip('/')}/models\",\n+            \"resolved_base_url\": None,\n+            \"suggested_base_url\": None,\n+            \"used_fallback\": False,\n+        },\n+    )\n+    monkeypatch.setattr(\"hermes_cli.config.load_config\", lambda: saved_cfg)\n+    monkeypatch.setattr(\"hermes_cli.config.save_config\", lambda cfg: saved_cfg.update(cfg))\n+    monkeypatch.setattr(\n+        \"hermes_cli.main._save_custom_provider\",\n+        lambda base_url, api_key=\"\", model=\"\", context_length=None, name=None, api_mode=None: captured_provider.update(\n+            {\n+                \"base_url\": base_url,\n+                \"api_key\": api_key,\n+                \"model\": model,\n+                \"context_length\": context_length,\n+                \"name\": name,\n+                \"api_mode\": api_mode,\n+            }\n+        ),\n+    )\n+\n+    answers = iter(\n+        [\n+            \"https://codex.example.com/v1\",\n+            \"3\",\n+            \"chosen-model\",\n+            \"\",\n+            \"\",\n+        ]\n+    )\n+    monkeypatch.setattr(\"builtins.input\", lambda _prompt=\"\": next(answers))\n+    monkeypatch.setattr(\"getpass.getpass\", lambda _prompt=\"\": \"test-key\")\n+\n+    hermes_main._model_flow_custom({\"model\": {\"provider\": \"custom\"}})\n+\n+    assert saved_cfg[\"model\"][\"provider\"] == \"custom\"\n+    assert saved_cfg[\"model\"][\"base_url\"] == \"https://codex.example.com/v1\"\n+    assert saved_cfg[\"model\"][\"api_key\"] == \"test-key\"\n+    assert saved_cfg[\"model\"][\"api_mode\"] == \"codex_responses\"\n+    assert captured_provider[\"api_mode\"] == \"codex_responses\"\n+\n+\n def test_cmd_model_forwards_nous_login_tls_options(monkeypatch):\n     monkeypatch.setattr(hermes_main, \"_require_tty\", lambda *a: None)\n     monkeypatch.setattr("]},{"file":"tests/hermes_cli/test_model_provider_persistence.py","hunks":["@@ -177,6 +177,40 @@ def test_copilot_provider_saved_when_selected(self, config_home):\n         assert model.get(\"api_mode\") == \"codex_responses\"\n         assert config[\"agent\"][\"reasoning_effort\"] == \"high\"\n \n+    def test_named_custom_provider_preserves_explicit_api_mode(self, config_home):\n+        \"\"\"Named custom providers should re-activate with their saved api_mode.\"\"\"\n+        import yaml\n+\n+        from hermes_cli.main import _model_flow_named_custom\n+\n+        provider_info = {\n+            \"name\": \"Packy\",\n+            \"base_url\": \"https://packy.example.com/v1\",\n+            \"api_key\": \"sk-test\",\n+            \"model\": \"gpt-5.4\",\n+            \"api_mode\": \"codex_responses\",\n+        }\n+\n+        # Patch fetch_api_models so the named custom flow returns one model;\n+        # patch simple_term_menu to force the input() fallback; patch input to\n+        # auto-select the first model from the fallback prompt.\n+        from unittest.mock import MagicMock\n+        fake_menu_module = MagicMock()\n+        fake_menu_module.TerminalMenu.side_effect = OSError(\"no tty in test\")\n+        with patch(\"hermes_cli.auth._save_model_choice\"), \\\n+             patch(\"hermes_cli.auth.deactivate_provider\"), \\\n+             patch(\"hermes_cli.models.fetch_api_models\", return_value=[\"gpt-5.4\"]), \\\n+             patch.dict(\"sys.modules\", {\"simple_term_menu\": fake_menu_module}), \\\n+             patch(\"builtins.input\", return_value=\"1\"):\n+            _model_flow_named_custom({}, provider_info)\n+\n+        config = yaml.safe_load((config_home / \"config.yaml\").read_text()) or {}\n+        model = config.get(\"model\")\n+        assert isinstance(model, dict)\n+        assert model.get(\"provider\") == \"custom\"\n+        assert model.get(\"base_url\") == \"https://packy.example.com/v1\"\n+        assert model.get(\"api_mode\") == \"codex_responses\"\n+\n     def test_copilot_acp_provider_saved_when_selected(self, config_home):\n         \"\"\"_model_flow_copilot_acp should persist provider/base_url/model together.\"\"\"\n         from hermes_cli.main import _model_flow_copilot_acp\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}