mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-25 05:52:34 +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:
|
else:
|
||||||
print(f" If /v1 should not be in the base URL, try: {suggested}")
|
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
|
# Select model — use probe results when available, fall back to manual input
|
||||||
model_name = ""
|
model_name = ""
|
||||||
detected_models = probe.get("models") or []
|
detected_models = probe.get("models") or []
|
||||||
|
|
@ -3142,7 +3157,10 @@ def _model_flow_custom(config):
|
||||||
model["base_url"] = effective_url
|
model["base_url"] = effective_url
|
||||||
if effective_key:
|
if effective_key:
|
||||||
model["api_key"] = 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)
|
save_config(cfg)
|
||||||
deactivate_provider()
|
deactivate_provider()
|
||||||
|
|
||||||
|
|
@ -3165,7 +3183,10 @@ def _model_flow_custom(config):
|
||||||
_caller_model["base_url"] = effective_url
|
_caller_model["base_url"] = effective_url
|
||||||
if effective_key:
|
if effective_key:
|
||||||
_caller_model["api_key"] = 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
|
config["model"] = _caller_model
|
||||||
print("Endpoint saved. Use `/model` in chat or `hermes model` to set a 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 "",
|
model_name or "",
|
||||||
context_length=context_length,
|
context_length=context_length,
|
||||||
name=display_name,
|
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:
|
def _auto_provider_name(base_url: str) -> str:
|
||||||
"""Generate a display name from a custom endpoint URL.
|
"""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(
|
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.
|
"""Save a custom endpoint to custom_providers in config.yaml.
|
||||||
|
|
||||||
Deduplicates by base_url — if the URL already exists, updates the
|
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.
|
Uses *name* when provided, otherwise auto-generates from the URL.
|
||||||
"""
|
"""
|
||||||
from hermes_cli.config import load_config, save_config
|
from hermes_cli.config import load_config, save_config
|
||||||
|
|
@ -3245,6 +3337,13 @@ def _save_custom_provider(
|
||||||
models_cfg[model] = {"context_length": context_length}
|
models_cfg[model] = {"context_length": context_length}
|
||||||
entry["models"] = models_cfg
|
entry["models"] = models_cfg
|
||||||
changed = True
|
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:
|
if changed:
|
||||||
cfg["custom_providers"] = providers
|
cfg["custom_providers"] = providers
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
|
|
@ -3259,6 +3358,8 @@ def _save_custom_provider(
|
||||||
entry["api_key"] = api_key
|
entry["api_key"] = api_key
|
||||||
if model:
|
if model:
|
||||||
entry["model"] = model
|
entry["model"] = model
|
||||||
|
if api_mode:
|
||||||
|
entry["api_mode"] = api_mode
|
||||||
if model and context_length:
|
if model and context_length:
|
||||||
entry["models"] = {model: {"context_length": context_length}}
|
entry["models"] = {model: {"context_length": context_length}}
|
||||||
|
|
||||||
|
|
@ -3712,7 +3813,7 @@ def _model_flow_named_custom(config, provider_info):
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
else:
|
else:
|
||||||
# Save model name to the custom_providers entry for next time
|
# 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"\n✅ Model set to: {model_name}")
|
||||||
print(f" Provider: {name} ({base_url})")
|
print(f" Provider: {name} ({base_url})")
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ AUTHOR_MAP = {
|
||||||
"421774554@qq.com": "wuli666",
|
"421774554@qq.com": "wuli666",
|
||||||
"harish.kukreja@gmail.com": "counterposition",
|
"harish.kukreja@gmail.com": "counterposition",
|
||||||
"1046611633@qq.com": "zhengyn0001",
|
"1046611633@qq.com": "zhengyn0001",
|
||||||
|
"1095245867@qq.com": "littlewwwhite",
|
||||||
"db@project-aeon.com": "db-aeon",
|
"db@project-aeon.com": "db-aeon",
|
||||||
"ahmed@abadr.net": "ahmedbadr3",
|
"ahmed@abadr.net": "ahmedbadr3",
|
||||||
"cleo@edaphic.xyz": "curiouscleo",
|
"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
|
# After the probe detects a single model ("llm"), the flow asks
|
||||||
# "Use this model? [Y/n]:" — confirm with Enter, then context length,
|
# "Use this model? [Y/n]:" — confirm with Enter, then context length,
|
||||||
# then display name.
|
# then display name. The api_mode prompt also runs before model selection.
|
||||||
answers = iter(["http://localhost:8000", "local-key", "", "", "", ""])
|
answers = iter(["http://localhost:8000", "local-key", "", "", "", "", ""])
|
||||||
monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers))
|
monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers))
|
||||||
monkeypatch.setattr("getpass.getpass", 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"
|
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):
|
def test_cmd_model_forwards_nous_login_tls_options(monkeypatch):
|
||||||
monkeypatch.setattr(hermes_main, "_require_tty", lambda *a: None)
|
monkeypatch.setattr(hermes_main, "_require_tty", lambda *a: None)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,40 @@ class TestProviderPersistsAfterModelSave:
|
||||||
assert model.get("api_mode") == "codex_responses"
|
assert model.get("api_mode") == "codex_responses"
|
||||||
assert config["agent"]["reasoning_effort"] == "high"
|
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):
|
def test_copilot_acp_provider_saved_when_selected(self, config_home):
|
||||||
"""_model_flow_copilot_acp should persist provider/base_url/model together."""
|
"""_model_flow_copilot_acp should persist provider/base_url/model together."""
|
||||||
from hermes_cli.main import _model_flow_copilot_acp
|
from hermes_cli.main import _model_flow_copilot_acp
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue