From 6f2d1c88b76fd85bda3460128fc21819a211ad1e Mon Sep 17 00:00:00 2001 From: littlewwwhite <1095245867@qq.com> Date: Wed, 13 May 2026 08:46:01 -0700 Subject: [PATCH] feat(custom): prompt and persist explicit api_mode for custom providers Adds an explicit API compatibility mode prompt to the `hermes model -> custom` flow so Codex-compatible third-party endpoints (and any other non-default backend whose URL doesn't match the existing heuristics in `_detect_api_mode_for_url`) can be selected explicitly instead of silently falling back to chat_completions. Choices: Auto-detect / chat_completions / codex_responses / anthropic_messages. Persists `api_mode` to: - `model.api_mode` (active session config) - the matching `custom_providers[*]` entry (so re-activating the named provider next time replays the same transport) Salvaged from PR #6125 onto current main: kept the new prompt and the `_save_custom_provider(api_mode=...)` plumbing; the named-custom flow already extracts and applies `api_mode` from the saved entry on current main so those changes are preserved as-is. Test fixtures updated for the new prompt and the existing display-name prompt. Co-authored-by: littlewwwhite <1095245867@qq.com> --- hermes_cli/main.py | 111 +++++++++++++++++- scripts/release.py | 1 + tests/cli/test_cli_provider_resolution.py | 61 +++++++++- .../test_model_provider_persistence.py | 34 ++++++ 4 files changed, 200 insertions(+), 7 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e8aa0d761c4..c93fa485c98 100644 --- a/hermes_cli/main.py +++ b/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})") diff --git a/scripts/release.py b/scripts/release.py index ddc5be1317a..e4cfaa0dd9c 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -53,6 +53,7 @@ AUTHOR_MAP = { "421774554@qq.com": "wuli666", "harish.kukreja@gmail.com": "counterposition", "1046611633@qq.com": "zhengyn0001", + "1095245867@qq.com": "littlewwwhite", "db@project-aeon.com": "db-aeon", "ahmed@abadr.net": "ahmedbadr3", "cleo@edaphic.xyz": "curiouscleo", diff --git a/tests/cli/test_cli_provider_resolution.py b/tests/cli/test_cli_provider_resolution.py index 0c9aab82add..e8eb7325157 100644 --- a/tests/cli/test_cli_provider_resolution.py +++ b/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( diff --git a/tests/hermes_cli/test_model_provider_persistence.py b/tests/hermes_cli/test_model_provider_persistence.py index 20f81d62d8f..0b350ba9adb 100644 --- a/tests/hermes_cli/test_model_provider_persistence.py +++ b/tests/hermes_cli/test_model_provider_persistence.py @@ -177,6 +177,40 @@ class TestProviderPersistsAfterModelSave: 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