mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
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>
This commit is contained in:
parent
1979ef5802
commit
6f2d1c88b7
4 changed files with 200 additions and 7 deletions
|
|
@ -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})")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue