# NousResearch/hermes-agent · Add api_mode selection for custom endpoints

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 6 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 1 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #25068. Verify it addresses your case.
- **Recommended:** read PR #25068 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.29`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #6209: \[Feature\]: Custom endpoint setup should let users choose API protocol (api_mode)
- #13415: \[Feature\]: Allow explicit api_mode selection for manual custom endpoints

## Prior & competing PRs

- **#6210: feat: add api_mode selection to custom endpoint setup**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
- **#25068: feat(custom): prompt and persist explicit api_mode for custom providers**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/main.py`, `scripts/release.py`, `tests/cli/test_cli_provider_resolution.py`, `tests/hermes_cli/test_model_provider_persistence.py`
  - fix hunks:
  - `hermes_cli/main.py`

    ```diff
    @@ -3079,6 +3079,21 @@ def _model_flow_custom(config):
                 else:
                     print(f"  If /v1 should not be in the base URL, try: {suggested}")
     
    +    # Prompt for API compatibility mode explicitly so codex-compatible custom
    +    # providers don't silently fall back to chat_completions.
    +    current_model_cfg = config.get("model")
    +    current_api_mode = ""
    +    if isinstance(current_model_cfg, dict):
    +        current_api_mode = str(current_model_cfg.get("api_mode") or "").strip()
    +    api_mode = _prompt_custom_api_mode_selection(
    +        effective_url,
    +        current_api_mode=current_api_mode,
    +    )
    +    if api_mode:
    +        print(f"  API mode: {api_mode}")
    +    else:
    +        print("  API mode: auto-detect")
    +
         # Select model — use probe results when available, fall back to manual input
         model_name = ""
         detected_models = probe.get("models") or []
    ```

    ```diff
    @@ -3142,7 +3157,10 @@ def _model_flow_custom(config):
             model["base_url"] = effective_url
             if effective_key:
                 model["api_key"] = effective_key
    -        model.pop("api_mode", None)  # let runtime auto-detect from URL
    +        if api_mode:
    +            model["api_mode"] = api_mode
    +        else:
    +            model.pop("api_mode", None)
             save_config(cfg)
             deactivate_provider()
     
    ```

    ```diff
    @@ -3165,7 +3183,10 @@ def _model_flow_custom(config):
             _caller_model["base_url"] = effective_url
             if effective_key:
                 _caller_model["api_key"] = effective_key
    -        _caller_model.pop("api_mode", None)
    +        if api_mode:
    +            _caller_model["api_mode"] = api_mode
    +        else:
    +            _caller_model.pop("api_mode", None)
             config["model"] = _caller_model
             print("Endpoint saved. Use `/model` in chat or `hermes model` to set a model.")
     
    ```

    ```diff
    @@ -3176,9 +3197,80 @@ def _model_flow_custom(config):
             model_name or "",
             context_length=context_length,
             name=display_name,
    +        api_mode=api_mode,
         )
     
     
    +def _prompt_custom_api_mode_selection(base_url: str, current_api_mode: str = "") -> Optional[str]:
    +    """Prompt for a custom provider API mode.
    +
    +    Returns an explicit mode string, or None to keep auto-detect behavior.
    +    """
    +    from hermes_cli.runtime_provider import _detect_api_mode_for_url
    +
    +    detected_mode = _detect_api_mode_for_url(base_url)
    +    normalized_current = str(current_api_mode or "").strip().lower()
    +    default_mode = normalized_current or detected_mode or ""
    +
    +    mode_options = [
    +        (
    +            "",
    +            "Auto-detect",
    +            "Use Hermes URL heuristics; best for standard OpenAI-compatible endpoints.",
    +        ),
    +        (
    +            "chat_completions",
    +            "Chat Completions",
    +            "Use /chat/completions for standard OpenAI-compatible servers.",
    +        ),
    +        (
    +            "codex_responses",
    +            "Responses / Codex",
    +            "Use /responses for Codex-compatible tool-calling backends.",
    +        ),
    +        (
    +            "anthropic_messages",
    +            "Anthropic Messages",
    +            "Use /v1/messages for Anthropic-compatible endpoints.",
    +        ),
    +    ]
    +
    +    print()
    +    print("Select API compatibility mode:")
    +    for idx, (value, label, description) in enumerate(mode_options, 1):
    +        markers = []
    +        if value == detected_mode:
    +            markers.append("detected")
    +        if value == default_mode:
    +            markers.append("current")
    +        suffix = f" [{' / '.join(markers)}]" if markers else ""
    +        print(f"  {idx}. {label}{suffix}")
    +        print(f"     {description}")
    +
    +    try:
    +        raw = input(
    +            "Choice [1-4, Enter to keep current/detected]: "
    +        ).strip().lower()
    +    except (KeyboardInterrupt, EOFError):
    +        print("\nCancelled.")
    +        raise
    +
    +    if not raw:
    +        return default_mode or None
    +
    +    if raw in {"1", "auto", "detect", "auto-detect"}:
    +        return None
    +    if raw in {"2", "chat", "chat_completions", "completions"}:
    +        return "chat_completions"
    +    if raw in {"3", "responses", "codex", "codex_responses"}:
    +        return "codex_responses"
    +    if raw in {"4", "anthropic", "anthropic_messages", "messages"}:
    +        return "anthropic_messages"
    +
    +    print(f"Invalid API mode choice: {raw}. Falling back to auto-detect.")
    +    return None
    +
    +
     def _auto_provider_name(base_url: str) -> str:
         """Generate a display name from a custom endpoint URL.
     
    ```

    ```diff
    @@ -3214,12 +3306,12 @@ def _custom_provider_api_key_config_value(provider_info, resolved_api_key=""):
     
     
     def _save_custom_provider(
    -    base_url, api_key="", model="", context_length=None, name=None
    +    base_url, api_key="", model="", context_length=None, name=None, api_mode=None
     ):
         """Save a custom endpoint to custom_providers in config.yaml.
     
         Deduplicates by base_url — if the URL already exists, updates the
    -    model name and context_length but doesn't add a duplicate entry.
    +    model name, context_length, and api_mode but doesn't add a duplicate entry.
         Uses *name* when provided, otherwise auto-generates from the URL.
         """
         from hermes_cli.config import load_config, save_config
    ```

    ```diff
    @@ -3245,6 +3337,13 @@ def _save_custom_provider(
                     models_cfg[model] = {"context_length": context_length}
                     entry["models"] = models_cfg
                     changed = True
    +            if api_mode:
    +                if entry.get("api_mode") != api_mode:
    +                    entry["api_mode"] = api_mode
    +                    changed = True
    +            elif "api_mode" in entry:
    +                entry.pop("api_mode", None)
    +                changed = True
                 if changed:
                     cfg["custom_providers"] = providers
                     save_config(cfg)
    ```

    ```diff
    @@ -3259,6 +3358,8 @@ def _save_custom_provider(
             entry["api_key"] = api_key
         if model:
             entry["model"] = model
    +    if api_mode:
    +        entry["api_mode"] = api_mode
         if model and context_length:
             entry["models"] = {model: {"context_length": context_length}}
     
    ```

    ```diff
    @@ -3712,7 +3813,7 @@ def _model_flow_named_custom(config, provider_info):
                     save_config(cfg)
         else:
             # Save model name to the custom_providers entry for next time
    -        _save_custom_provider(base_url, config_api_key, model_name)
    +        _save_custom_provider(base_url, config_api_key, model_name, api_mode=api_mode)
     
         print(f"\n✅ Model set to: {model_name}")
         print(f"   Provider: {name} ({base_url})")
    ```
  - `scripts/release.py`

    ```diff
    @@ -53,6 +53,7 @@
         "[redacted:email]": "wuli666",
         "[redacted:email]": "counterposition",
         "[redacted:email]": "zhengyn0001",
    +    "[redacted:email]": "littlewwwhite",
         "[redacted:email]": "db-aeon",
         "[redacted:email]": "ahmedbadr3",
         "[redacted:email]": "curiouscleo",
    ```
  - `tests/cli/test_cli_provider_resolution.py`

    ```diff
    @@ -531,8 +531,8 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys):
     
         # After the probe detects a single model ("llm"), the flow asks
         # "Use this model? [Y/n]:" — confirm with Enter, then context length,
    -    # then display name.
    -    answers = iter(["http://localhost:8000", "local-key", "", "", "", ""])
    +    # then display name. The api_mode prompt also runs before model selection.
    +    answers = iter(["http://localhost:8000", "local-key", "", "", "", "", ""])
         monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers))
         monkeypatch.setattr("getpass.getpass", lambda _prompt="": next(answers))
     
    ```

    ```diff
    @@ -546,6 +546,63 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys):
         assert saved_env["MODEL"] == "llm"
     
     
    +def test_model_flow_custom_persists_selected_api_mode(monkeypatch):
    +    saved_cfg = {"model": {"default": "", "provider": "custom", "base_url": ""}}
    +    captured_provider = {}
    +
    +    monkeypatch.setattr(
    +        "hermes_cli.config.get_env_value",
    +        lambda key: "" if key in {"OPENAI_BASE_URL", "OPENAI_API_KEY"} else "",
    +    )
    +    monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: None)
    +    monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda: None)
    +    monkeypatch.setattr(
    +        "hermes_cli.models.probe_api_models",
    +        lambda api_key, base_url: {
    +            "models": [],
    +            "probed_url": f"{base_url.rstrip('/')}/models",
    +            "resolved_base_url": None,
    +            "suggested_base_url": None,
    +            "used_fallback": False,
    +        },
    +    )
    +    monkeypatch.setattr("hermes_cli.config.load_config", lambda: saved_cfg)
    +    monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: saved_cfg.update(cfg))
    +    monkeypatch.setattr(
    +        "hermes_cli.main._save_custom_provider",
    +        lambda base_url, api_key="", model="", context_length=None, name=None, api_mode=None: captured_provider.update(
    +            {
    +                "base_url": base_url,
    +                "api_key": api_key,
    +                "model": model,
    +                "context_length": context_length,
    +                "name": name,
    +                "api_mode": api_mode,
    +            }
    +        ),
    +    )
    +
    +    answers = iter(
    +        [
    +            "https://codex.example.com/v1",
    +            "3",
    +            "chosen-model",
    +            "",
    +            "",
    +        ]
    +    )
    +    monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers))
    +    monkeypatch.setattr("getpass.getpass", lambda _prompt="": "test-key")
    +
    +    hermes_main._model_flow_custom({"model": {"provider": "custom"}})
    +
    +    assert saved_cfg["model"]["provider"] == "custom"
    +    assert saved_cfg["model"]["base_url"] == "https://codex.example.com/v1"
    +    assert saved_cfg["model"]["api_key"] == "test-key"
    +    assert saved_cfg["model"]["api_mode"] == "codex_responses"
    +    assert captured_provider["api_mode"] == "codex_responses"
    +
    +
     def test_cmd_model_forwards_nous_login_tls_options(monkeypatch):
         monkeypatch.setattr(hermes_main, "_require_tty", lambda *a: None)
         monkeypatch.setattr(
    ```
  - `tests/hermes_cli/test_model_provider_persistence.py`

    ```diff
    @@ -177,6 +177,40 @@ def test_copilot_provider_saved_when_selected(self, config_home):
             assert model.get("api_mode") == "codex_responses"
             assert config["agent"]["reasoning_effort"] == "high"
     
    +    def test_named_custom_provider_preserves_explicit_api_mode(self, config_home):
    +        """Named custom providers should re-activate with their saved api_mode."""
    +        import yaml
    +
    +        from hermes_cli.main import _model_flow_named_custom
    +
    +        provider_info = {
    +            "name": "Packy",
    +            "base_url": "https://packy.example.com/v1",
    +            "api_key": "sk-test",
    +            "model": "gpt-5.4",
    +            "api_mode": "codex_responses",
    +        }
    +
    +        # Patch fetch_api_models so the named custom flow returns one model;
    +        # patch simple_term_menu to force the input() fallback; patch input to
    +        # auto-select the first model from the fallback prompt.
    +        from unittest.mock import MagicMock
    +        fake_menu_module = MagicMock()
    +        fake_menu_module.TerminalMenu.side_effect = OSError("no tty in test")
    +        with patch("hermes_cli.auth._save_model_choice"), \
    +             patch("hermes_cli.auth.deactivate_provider"), \
    +             patch("hermes_cli.models.fetch_api_models", return_value=["gpt-5.4"]), \
    +             patch.dict("sys.modules", {"simple_term_menu": fake_menu_module}), \
    +             patch("builtins.input", return_value="1"):
    +            _model_flow_named_custom({}, provider_info)
    +
    +        config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
    +        model = config.get("model")
    +        assert isinstance(model, dict)
    +        assert model.get("provider") == "custom"
    +        assert model.get("base_url") == "https://packy.example.com/v1"
    +        assert model.get("api_mode") == "codex_responses"
    +
         def test_copilot_acp_provider_saved_when_selected(self, config_home):
             """_model_flow_copilot_acp should persist provider/base_url/model together."""
             from hermes_cli.main import _model_flow_copilot_acp
    ```
- **#7410: fix: auto-detect anthropic_messages api_mode for third-party /anthropic endpoints**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#12846`
- **#17391: feat(custom-endpoint): support Anthropic Messages protocol for custom endpoints**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#25068`, `#25068`

## Fix status

- Merged to main in PR #25068 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-13`.
- Subsystem: `hermes_cli`
