Add api_mode selection for custom endpoints

NousResearch/hermes-agent · cluster 6209 · hermes cli

Data as of

Nothing open on GitHub. Fix merged in #25068. Check it covers your case.

The short answer

0open issues
0open PRs
6items grouped
Known?
Yes. 6 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #25068. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #25068 feat(custom): prompt and persist explicit api_mode for custom providers

    merged to main

    files: hermes_cli/main.py, scripts/release.py, tests/cli/test_cli_provider_resolution.py, tests/hermes_cli/test_model_provider_persistence.py

    Show the diff
    • hermes_cli/main.py
      @@ -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 []
      @@ -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()
       
      @@ -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.")
       
      @@ -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.
       
      @@ -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
      @@ -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)
      @@ -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}}
       
      @@ -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
      @@ -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
      @@ -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))
       
      @@ -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
      @@ -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

3 PRs closed without merging

From the close comments: done elsewhere 2 · superseded by another PR 1

  • #6210 feat: add api_mode selection to custom endpoint setup · done elsewhere
  • #7410 fix: auto-detect anthropic_messages api_mode for third-party /anthropic endpoints · superseded by another PR → #12846
  • #17391 feat(custom-endpoint): support Anthropic Messages protocol for custom endpoints · done elsewhere → #25068
Closed issues · 2
  • Closed#6209[Feature]: Custom endpoint setup should let users choose API protocol (api_mode)
  • Closed#13415[Feature]: Allow explicit api_mode selection for manual custom endpoints
How this cluster was grouped
  • Link evidence (partly structural links): 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 item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
hermes cli
Merged PRs
#25068
Closed issues
2
Attempts
3 PRs closed without merging
Fix link checked
13 May 2026

For your agent

The same facts, typed.