NousResearch/hermes-agent

Anthropic protocol support for custom endpoints

Cluster 12137 · agent. Data as of

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

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue 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 items relate to adding support for the Anthropic Messages API protocol in custom/third-party provider endpoints, alongside prompting and persisting an explicit API mode setting for custom providers.

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": [redacted:secret-assignment]
      +                "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": "[redacted:secret-assignment]",
      +            "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

1 PR closed without merging

From the close comments: done elsewhere 1

  • #17391 feat(custom-endpoint): support Anthropic Messages protocol for custom endpoints · done elsewhere → #25068
Closed issues · 1
  • Closed#12137[Feature]: Support Anthropic compatible endpoint for Hermes model Custom endpoint (not only OpenAI)
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: 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
agent
Merged PRs
#25068
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
13 May 2026

For your agent

The same facts, typed.