refactor: unify setup wizard provider selection with hermes model
setup_model_provider() had 800+ lines of duplicated provider handling that reimplemented the same credential prompting, OAuth flows, and model selection that hermes model already provides via the _model_flow_* functions. Every new provider had to be added in both places, and the two implementations diverged in config persistence (setup.py did raw YAML writes, _set_model_provider, and _update_config_for_provider depending on the provider — main.py used its own load/save cycle). This caused the #4172 bug: _model_flow_custom saved config to disk but the wizard's final save_config(config) overwrote it with stale values. Fix: extract the core of cmd_model() into select_provider_and_model() and have setup_model_provider() call it. After the call, re-sync the wizard's config dict from disk. Deletes ~800 lines of duplicated provider handling from setup.py. Also fixes cmd_model() double-AuthError crash on fresh installs with no API keys configured.
This commit is contained in:
@@ -829,6 +829,17 @@ def cmd_setup(args):
|
|||||||
def cmd_model(args):
|
def cmd_model(args):
|
||||||
"""Select default model — starts with provider selection, then model picker."""
|
"""Select default model — starts with provider selection, then model picker."""
|
||||||
_require_tty("model")
|
_require_tty("model")
|
||||||
|
select_provider_and_model()
|
||||||
|
|
||||||
|
|
||||||
|
def select_provider_and_model():
|
||||||
|
"""Core provider selection + model picking logic.
|
||||||
|
|
||||||
|
Shared by ``cmd_model`` (``hermes model``) and the setup wizard
|
||||||
|
(``setup_model_provider`` in setup.py). Handles the full flow:
|
||||||
|
provider picker, credential prompting, model selection, and config
|
||||||
|
persistence.
|
||||||
|
"""
|
||||||
from hermes_cli.auth import (
|
from hermes_cli.auth import (
|
||||||
resolve_provider, AuthError, format_auth_error,
|
resolve_provider, AuthError, format_auth_error,
|
||||||
)
|
)
|
||||||
@@ -858,7 +869,10 @@ def cmd_model(args):
|
|||||||
except AuthError as exc:
|
except AuthError as exc:
|
||||||
warning = format_auth_error(exc)
|
warning = format_auth_error(exc)
|
||||||
print(f"Warning: {warning} Falling back to auto provider detection.")
|
print(f"Warning: {warning} Falling back to auto provider detection.")
|
||||||
active = resolve_provider("auto")
|
try:
|
||||||
|
active = resolve_provider("auto")
|
||||||
|
except AuthError:
|
||||||
|
active = "openrouter" # no provider yet; show full picker
|
||||||
|
|
||||||
# Detect custom endpoint
|
# Detect custom endpoint
|
||||||
if active == "openrouter" and get_env_value("OPENAI_BASE_URL"):
|
if active == "openrouter" and get_env_value("OPENAI_BASE_URL"):
|
||||||
|
|||||||
@@ -805,715 +805,49 @@ def _prompt_container_resources(config: dict):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def setup_model_provider(config: dict):
|
def setup_model_provider(config: dict):
|
||||||
"""Configure the inference provider and default model."""
|
"""Configure the inference provider and default model.
|
||||||
from hermes_cli.auth import (
|
|
||||||
get_active_provider,
|
Delegates to ``cmd_model()`` (the same flow used by ``hermes model``)
|
||||||
PROVIDER_REGISTRY,
|
for provider selection, credential prompting, and model picking.
|
||||||
fetch_nous_models,
|
This ensures a single code path for all provider setup — any new
|
||||||
resolve_nous_runtime_credentials,
|
provider added to ``hermes model`` is automatically available here.
|
||||||
_update_config_for_provider,
|
"""
|
||||||
_login_openai_codex,
|
from hermes_cli.config import load_config, save_config
|
||||||
resolve_codex_runtime_credentials,
|
|
||||||
DEFAULT_CODEX_BASE_URL,
|
|
||||||
detect_external_credentials,
|
|
||||||
get_auth_status,
|
|
||||||
resolve_api_key_provider_credentials,
|
|
||||||
)
|
|
||||||
|
|
||||||
print_header("Inference Provider")
|
print_header("Inference Provider")
|
||||||
print_info("Choose how to connect to your main chat model.")
|
print_info("Choose how to connect to your main chat model.")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
existing_or = get_env_value("OPENROUTER_API_KEY")
|
# Delegate to the shared hermes model flow — handles provider picker,
|
||||||
active_oauth = get_active_provider()
|
# credential prompting, model selection, and config persistence.
|
||||||
existing_custom = get_env_value("OPENAI_BASE_URL")
|
from hermes_cli.main import select_provider_and_model
|
||||||
copilot_status = get_auth_status("copilot")
|
try:
|
||||||
copilot_acp_status = get_auth_status("copilot-acp")
|
select_provider_and_model()
|
||||||
|
except (SystemExit, KeyboardInterrupt):
|
||||||
model_cfg = config.get("model") if isinstance(config.get("model"), dict) else {}
|
|
||||||
current_config_provider = str(model_cfg.get("provider") or "").strip().lower() or None
|
|
||||||
if current_config_provider == "auto":
|
|
||||||
current_config_provider = None
|
|
||||||
current_config_base_url = str(model_cfg.get("base_url") or "").strip()
|
|
||||||
|
|
||||||
# Detect credentials from other CLI tools
|
|
||||||
detected_creds = detect_external_credentials()
|
|
||||||
if detected_creds:
|
|
||||||
print_info("Detected existing credentials:")
|
|
||||||
for cred in detected_creds:
|
|
||||||
if cred["provider"] == "openai-codex":
|
|
||||||
print_success(f' * {cred["label"]} -- select "OpenAI Codex" to use it')
|
|
||||||
else:
|
|
||||||
print_info(f" * {cred['label']}")
|
|
||||||
print()
|
print()
|
||||||
|
print_info("Provider setup skipped.")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("select_provider_and_model error during setup: %s", exc)
|
||||||
|
print_warning(f"Provider setup encountered an error: {exc}")
|
||||||
|
print_info("You can try again later with: hermes model")
|
||||||
|
|
||||||
|
# Re-sync the wizard's config dict from what cmd_model saved to disk.
|
||||||
|
# This is critical: cmd_model writes to disk via its own load/save cycle,
|
||||||
|
# and the wizard's final save_config(config) must not overwrite those
|
||||||
|
# changes with stale values (#4172).
|
||||||
|
_refreshed = load_config()
|
||||||
|
config["model"] = _refreshed.get("model", config.get("model"))
|
||||||
|
if _refreshed.get("custom_providers"):
|
||||||
|
config["custom_providers"] = _refreshed["custom_providers"]
|
||||||
|
|
||||||
|
# Derive the selected provider for downstream steps (vision setup).
|
||||||
|
selected_provider = None
|
||||||
|
_m = config.get("model")
|
||||||
|
if isinstance(_m, dict):
|
||||||
|
selected_provider = _m.get("provider")
|
||||||
|
|
||||||
# Detect if any provider is already configured
|
|
||||||
has_any_provider = bool(
|
|
||||||
current_config_provider
|
|
||||||
or active_oauth
|
|
||||||
or existing_custom
|
|
||||||
or existing_or
|
|
||||||
or copilot_status.get("logged_in")
|
|
||||||
or copilot_acp_status.get("logged_in")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build "keep current" label
|
|
||||||
if current_config_provider == "custom":
|
|
||||||
custom_label = current_config_base_url or existing_custom
|
|
||||||
keep_label = (
|
|
||||||
f"Keep current (Custom: {custom_label})"
|
|
||||||
if custom_label
|
|
||||||
else "Keep current (Custom)"
|
|
||||||
)
|
|
||||||
elif current_config_provider == "openrouter":
|
|
||||||
keep_label = "Keep current (OpenRouter)"
|
|
||||||
elif current_config_provider and current_config_provider in PROVIDER_REGISTRY:
|
|
||||||
keep_label = f"Keep current ({PROVIDER_REGISTRY[current_config_provider].name})"
|
|
||||||
elif active_oauth and active_oauth in PROVIDER_REGISTRY:
|
|
||||||
keep_label = f"Keep current ({PROVIDER_REGISTRY[active_oauth].name})"
|
|
||||||
elif existing_custom:
|
|
||||||
keep_label = f"Keep current (Custom: {existing_custom})"
|
|
||||||
elif existing_or:
|
|
||||||
keep_label = "Keep current (OpenRouter)"
|
|
||||||
else:
|
|
||||||
keep_label = None # No provider configured — don't show "Keep current"
|
|
||||||
|
|
||||||
provider_choices = [
|
|
||||||
"OpenRouter API key (100+ models, pay-per-use)",
|
|
||||||
"Login with Nous Portal (Nous Research subscription — OAuth)",
|
|
||||||
"Login with OpenAI Codex",
|
|
||||||
"Custom OpenAI-compatible endpoint (self-hosted / VLLM / etc.)",
|
|
||||||
"Z.AI / GLM (Zhipu AI models)",
|
|
||||||
"Kimi / Moonshot (Kimi coding models)",
|
|
||||||
"MiniMax (global endpoint)",
|
|
||||||
"MiniMax China (mainland China endpoint)",
|
|
||||||
"Kilo Code (Kilo Gateway API)",
|
|
||||||
"Anthropic (Claude models — API key or Claude Code subscription)",
|
|
||||||
"AI Gateway (Vercel — 200+ models, pay-per-use)",
|
|
||||||
"Alibaba Cloud / DashScope (Qwen models via Anthropic-compatible API)",
|
|
||||||
"OpenCode Zen (35+ curated models, pay-as-you-go)",
|
|
||||||
"OpenCode Go (open models, $10/month subscription)",
|
|
||||||
"GitHub Copilot (uses GITHUB_TOKEN or gh auth token)",
|
|
||||||
"GitHub Copilot ACP (spawns `copilot --acp --stdio`)",
|
|
||||||
"Hugging Face Inference Providers (20+ open models)",
|
|
||||||
]
|
|
||||||
if keep_label:
|
|
||||||
provider_choices.append(keep_label)
|
|
||||||
|
|
||||||
# Default to "Keep current" if a provider exists, otherwise OpenRouter (most common)
|
|
||||||
default_provider = len(provider_choices) - 1 if has_any_provider else 0
|
|
||||||
|
|
||||||
if not has_any_provider:
|
|
||||||
print_warning("An inference provider is required for Hermes to work.")
|
|
||||||
print()
|
|
||||||
|
|
||||||
provider_idx = prompt_choice(
|
|
||||||
"Select your inference provider:", provider_choices, default_provider
|
|
||||||
)
|
|
||||||
|
|
||||||
# Track which provider was selected for model step
|
|
||||||
selected_provider = (
|
|
||||||
None # "nous", "openai-codex", "openrouter", "custom", or None (keep)
|
|
||||||
)
|
|
||||||
selected_base_url = None # deferred until after model selection
|
|
||||||
nous_models = [] # populated if Nous login succeeds
|
|
||||||
|
|
||||||
if provider_idx == 0: # OpenRouter
|
|
||||||
selected_provider = "openrouter"
|
|
||||||
print()
|
|
||||||
print_header("OpenRouter API Key")
|
|
||||||
print_info("OpenRouter provides access to 100+ models from multiple providers.")
|
|
||||||
print_info("Get your API key at: https://openrouter.ai/keys")
|
|
||||||
|
|
||||||
if existing_or:
|
|
||||||
print_info(f"Current: {existing_or[:8]}... (configured)")
|
|
||||||
if prompt_yes_no("Update OpenRouter API key?", False):
|
|
||||||
api_key = prompt(" OpenRouter API key", password=True)
|
|
||||||
if api_key:
|
|
||||||
save_env_value("OPENROUTER_API_KEY", api_key)
|
|
||||||
print_success("OpenRouter API key updated")
|
|
||||||
else:
|
|
||||||
api_key = prompt(" OpenRouter API key", password=True)
|
|
||||||
if api_key:
|
|
||||||
save_env_value("OPENROUTER_API_KEY", api_key)
|
|
||||||
print_success("OpenRouter API key saved")
|
|
||||||
else:
|
|
||||||
print_warning("Skipped - agent won't work without an API key")
|
|
||||||
|
|
||||||
|
|
||||||
# Update config.yaml and deactivate any OAuth provider so the
|
|
||||||
# resolver doesn't keep returning the old provider (e.g. Codex).
|
|
||||||
try:
|
|
||||||
from hermes_cli.auth import deactivate_provider
|
|
||||||
|
|
||||||
deactivate_provider()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
config_path = (
|
|
||||||
Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) / "config.yaml"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
disk_cfg = {}
|
|
||||||
if config_path.exists():
|
|
||||||
disk_cfg = yaml.safe_load(config_path.read_text()) or {}
|
|
||||||
model_section = disk_cfg.get("model", {})
|
|
||||||
if isinstance(model_section, str):
|
|
||||||
model_section = {"default": model_section}
|
|
||||||
model_section["provider"] = "openrouter"
|
|
||||||
model_section.pop("base_url", None) # OpenRouter uses default URL
|
|
||||||
disk_cfg["model"] = model_section
|
|
||||||
config_path.write_text(yaml.safe_dump(disk_cfg, sort_keys=False))
|
|
||||||
_set_model_provider(config, "openrouter")
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("Could not save provider to config.yaml: %s", e)
|
|
||||||
|
|
||||||
elif provider_idx == 1: # Nous Portal (OAuth)
|
|
||||||
selected_provider = "nous"
|
|
||||||
print()
|
|
||||||
print_header("Nous Portal Login")
|
|
||||||
print_info("This will open your browser to authenticate with Nous Portal.")
|
|
||||||
print_info("You'll need a Nous Research account with an active subscription.")
|
|
||||||
print()
|
|
||||||
|
|
||||||
try:
|
|
||||||
from hermes_cli.auth import _login_nous
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
mock_args = argparse.Namespace(
|
|
||||||
portal_url=None,
|
|
||||||
inference_url=None,
|
|
||||||
client_id=None,
|
|
||||||
scope=None,
|
|
||||||
no_browser=False,
|
|
||||||
timeout=15.0,
|
|
||||||
ca_bundle=None,
|
|
||||||
insecure=False,
|
|
||||||
)
|
|
||||||
pconfig = PROVIDER_REGISTRY["nous"]
|
|
||||||
_login_nous(mock_args, pconfig)
|
|
||||||
_sync_model_from_disk(config)
|
|
||||||
|
|
||||||
# Fetch models for the selection step
|
|
||||||
try:
|
|
||||||
creds = resolve_nous_runtime_credentials(
|
|
||||||
min_key_ttl_seconds=5 * 60,
|
|
||||||
timeout_seconds=15.0,
|
|
||||||
)
|
|
||||||
# Use curated model list instead of full /models dump
|
|
||||||
from hermes_cli.models import _PROVIDER_MODELS
|
|
||||||
nous_models = _PROVIDER_MODELS.get("nous", [])
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("Could not fetch Nous models after login: %s", e)
|
|
||||||
|
|
||||||
except SystemExit:
|
|
||||||
print_warning("Nous Portal login was cancelled or failed.")
|
|
||||||
print_info("You can try again later with: hermes model")
|
|
||||||
selected_provider = None
|
|
||||||
except Exception as e:
|
|
||||||
print_error(f"Login failed: {e}")
|
|
||||||
print_info("You can try again later with: hermes model")
|
|
||||||
selected_provider = None
|
|
||||||
|
|
||||||
elif provider_idx == 2: # OpenAI Codex
|
|
||||||
selected_provider = "openai-codex"
|
|
||||||
print()
|
|
||||||
print_header("OpenAI Codex Login")
|
|
||||||
print()
|
|
||||||
|
|
||||||
try:
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
mock_args = argparse.Namespace()
|
|
||||||
_login_openai_codex(mock_args, PROVIDER_REGISTRY["openai-codex"])
|
|
||||||
_update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL)
|
|
||||||
_set_model_provider(config, "openai-codex", DEFAULT_CODEX_BASE_URL)
|
|
||||||
except SystemExit:
|
|
||||||
print_warning("OpenAI Codex login was cancelled or failed.")
|
|
||||||
print_info("You can try again later with: hermes model")
|
|
||||||
selected_provider = None
|
|
||||||
except Exception as e:
|
|
||||||
print_error(f"Login failed: {e}")
|
|
||||||
print_info("You can try again later with: hermes model")
|
|
||||||
selected_provider = None
|
|
||||||
|
|
||||||
elif provider_idx == 3: # Custom endpoint
|
|
||||||
selected_provider = "custom"
|
|
||||||
print()
|
|
||||||
print_header("Custom OpenAI-Compatible Endpoint")
|
|
||||||
print_info("Works with any API that follows OpenAI's chat completions spec")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Reuse the shared custom endpoint flow from `hermes model`.
|
|
||||||
# This handles: URL/key/model/context-length prompts, endpoint probing,
|
|
||||||
# env saving, config.yaml updates, and custom_providers persistence.
|
|
||||||
from hermes_cli.main import _model_flow_custom
|
|
||||||
_model_flow_custom(config)
|
|
||||||
# _model_flow_custom handles model selection, config, env vars,
|
|
||||||
# and custom_providers. Keep selected_provider = "custom" so
|
|
||||||
# the model selection step below is skipped (line 1631 check)
|
|
||||||
# but vision and TTS setup still run.
|
|
||||||
|
|
||||||
elif provider_idx == 4: # Z.AI / GLM
|
|
||||||
selected_provider = "zai"
|
|
||||||
print()
|
|
||||||
print_header("Z.AI / GLM API Key")
|
|
||||||
pconfig = PROVIDER_REGISTRY["zai"]
|
|
||||||
print_info(f"Provider: {pconfig.name}")
|
|
||||||
print_info("Get your API key at: https://open.bigmodel.cn/")
|
|
||||||
print()
|
|
||||||
|
|
||||||
existing_key = get_env_value("GLM_API_KEY") or get_env_value("ZAI_API_KEY")
|
|
||||||
api_key = existing_key # will be overwritten if user enters a new one
|
|
||||||
if existing_key:
|
|
||||||
print_info(f"Current: {existing_key[:8]}... (configured)")
|
|
||||||
if prompt_yes_no("Update API key?", False):
|
|
||||||
new_key = prompt(" GLM API key", password=True)
|
|
||||||
if new_key:
|
|
||||||
api_key = new_key
|
|
||||||
save_env_value("GLM_API_KEY", api_key)
|
|
||||||
print_success("GLM API key updated")
|
|
||||||
else:
|
|
||||||
api_key = prompt(" GLM API key", password=True)
|
|
||||||
if api_key:
|
|
||||||
save_env_value("GLM_API_KEY", api_key)
|
|
||||||
print_success("GLM API key saved")
|
|
||||||
else:
|
|
||||||
print_warning("Skipped - agent won't work without an API key")
|
|
||||||
|
|
||||||
# Detect the correct z.ai endpoint for this key.
|
|
||||||
# Z.AI has separate billing for general vs coding plans and
|
|
||||||
# global vs China endpoints — we probe to find the right one.
|
|
||||||
zai_base_url = pconfig.inference_base_url
|
|
||||||
if api_key:
|
|
||||||
print()
|
|
||||||
print_info("Detecting your z.ai endpoint...")
|
|
||||||
from hermes_cli.auth import detect_zai_endpoint
|
|
||||||
|
|
||||||
detected = detect_zai_endpoint(api_key)
|
|
||||||
if detected:
|
|
||||||
zai_base_url = detected["base_url"]
|
|
||||||
print_success(f"Detected: {detected['label']} endpoint")
|
|
||||||
print_info(f" URL: {detected['base_url']}")
|
|
||||||
if detected["id"].startswith("coding"):
|
|
||||||
print_info(
|
|
||||||
f" Note: Coding Plan endpoint detected (default model: {detected['model']}). "
|
|
||||||
f"GLM-5 may still be available depending on your plan tier."
|
|
||||||
)
|
|
||||||
save_env_value("GLM_BASE_URL", zai_base_url)
|
|
||||||
else:
|
|
||||||
print_warning("Could not verify any z.ai endpoint with this key.")
|
|
||||||
print_info(f" Using default: {zai_base_url}")
|
|
||||||
print_info(
|
|
||||||
" If you get billing errors, check your plan at https://open.bigmodel.cn/"
|
|
||||||
)
|
|
||||||
|
|
||||||
_set_model_provider(config, "zai", zai_base_url)
|
|
||||||
selected_base_url = zai_base_url
|
|
||||||
|
|
||||||
elif provider_idx == 5: # Kimi / Moonshot
|
|
||||||
selected_provider = "kimi-coding"
|
|
||||||
print()
|
|
||||||
print_header("Kimi / Moonshot API Key")
|
|
||||||
pconfig = PROVIDER_REGISTRY["kimi-coding"]
|
|
||||||
print_info(f"Provider: {pconfig.name}")
|
|
||||||
print_info(f"Base URL: {pconfig.inference_base_url}")
|
|
||||||
print_info("Get your API key at: https://platform.moonshot.cn/")
|
|
||||||
print()
|
|
||||||
|
|
||||||
existing_key = get_env_value("KIMI_API_KEY")
|
|
||||||
if existing_key:
|
|
||||||
print_info(f"Current: {existing_key[:8]}... (configured)")
|
|
||||||
if prompt_yes_no("Update API key?", False):
|
|
||||||
api_key = prompt(" Kimi API key", password=True)
|
|
||||||
if api_key:
|
|
||||||
save_env_value("KIMI_API_KEY", api_key)
|
|
||||||
print_success("Kimi API key updated")
|
|
||||||
else:
|
|
||||||
api_key = prompt(" Kimi API key", password=True)
|
|
||||||
if api_key:
|
|
||||||
save_env_value("KIMI_API_KEY", api_key)
|
|
||||||
print_success("Kimi API key saved")
|
|
||||||
else:
|
|
||||||
print_warning("Skipped - agent won't work without an API key")
|
|
||||||
|
|
||||||
_set_model_provider(config, "kimi-coding", pconfig.inference_base_url)
|
|
||||||
selected_base_url = pconfig.inference_base_url
|
|
||||||
|
|
||||||
elif provider_idx == 6: # MiniMax
|
|
||||||
selected_provider = "minimax"
|
|
||||||
print()
|
|
||||||
print_header("MiniMax API Key")
|
|
||||||
pconfig = PROVIDER_REGISTRY["minimax"]
|
|
||||||
print_info(f"Provider: {pconfig.name}")
|
|
||||||
print_info(f"Base URL: {pconfig.inference_base_url}")
|
|
||||||
print_info("Get your API key at: https://platform.minimaxi.com/")
|
|
||||||
print()
|
|
||||||
|
|
||||||
existing_key = get_env_value("MINIMAX_API_KEY")
|
|
||||||
if existing_key:
|
|
||||||
print_info(f"Current: {existing_key[:8]}... (configured)")
|
|
||||||
if prompt_yes_no("Update API key?", False):
|
|
||||||
api_key = prompt(" MiniMax API key", password=True)
|
|
||||||
if api_key:
|
|
||||||
save_env_value("MINIMAX_API_KEY", api_key)
|
|
||||||
print_success("MiniMax API key updated")
|
|
||||||
else:
|
|
||||||
api_key = prompt(" MiniMax API key", password=True)
|
|
||||||
if api_key:
|
|
||||||
save_env_value("MINIMAX_API_KEY", api_key)
|
|
||||||
print_success("MiniMax API key saved")
|
|
||||||
else:
|
|
||||||
print_warning("Skipped - agent won't work without an API key")
|
|
||||||
|
|
||||||
_set_model_provider(config, "minimax", pconfig.inference_base_url)
|
|
||||||
selected_base_url = pconfig.inference_base_url
|
|
||||||
|
|
||||||
elif provider_idx == 7: # MiniMax China
|
|
||||||
selected_provider = "minimax-cn"
|
|
||||||
print()
|
|
||||||
print_header("MiniMax China API Key")
|
|
||||||
pconfig = PROVIDER_REGISTRY["minimax-cn"]
|
|
||||||
print_info(f"Provider: {pconfig.name}")
|
|
||||||
print_info(f"Base URL: {pconfig.inference_base_url}")
|
|
||||||
print_info("Get your API key at: https://platform.minimaxi.com/")
|
|
||||||
print()
|
|
||||||
|
|
||||||
existing_key = get_env_value("MINIMAX_CN_API_KEY")
|
|
||||||
if existing_key:
|
|
||||||
print_info(f"Current: {existing_key[:8]}... (configured)")
|
|
||||||
if prompt_yes_no("Update API key?", False):
|
|
||||||
api_key = prompt(" MiniMax CN API key", password=True)
|
|
||||||
if api_key:
|
|
||||||
save_env_value("MINIMAX_CN_API_KEY", api_key)
|
|
||||||
print_success("MiniMax CN API key updated")
|
|
||||||
else:
|
|
||||||
api_key = prompt(" MiniMax CN API key", password=True)
|
|
||||||
if api_key:
|
|
||||||
save_env_value("MINIMAX_CN_API_KEY", api_key)
|
|
||||||
print_success("MiniMax CN API key saved")
|
|
||||||
else:
|
|
||||||
print_warning("Skipped - agent won't work without an API key")
|
|
||||||
|
|
||||||
_set_model_provider(config, "minimax-cn", pconfig.inference_base_url)
|
|
||||||
selected_base_url = pconfig.inference_base_url
|
|
||||||
|
|
||||||
elif provider_idx == 8: # Kilo Code
|
|
||||||
selected_provider = "kilocode"
|
|
||||||
print()
|
|
||||||
print_header("Kilo Code API Key")
|
|
||||||
pconfig = PROVIDER_REGISTRY["kilocode"]
|
|
||||||
print_info(f"Provider: {pconfig.name}")
|
|
||||||
print_info(f"Base URL: {pconfig.inference_base_url}")
|
|
||||||
print_info("Get your API key at: https://kilo.ai")
|
|
||||||
print()
|
|
||||||
|
|
||||||
existing_key = get_env_value("KILOCODE_API_KEY")
|
|
||||||
if existing_key:
|
|
||||||
print_info(f"Current: {existing_key[:8]}... (configured)")
|
|
||||||
if prompt_yes_no("Update API key?", False):
|
|
||||||
api_key = prompt(" Kilo Code API key", password=True)
|
|
||||||
if api_key:
|
|
||||||
save_env_value("KILOCODE_API_KEY", api_key)
|
|
||||||
print_success("Kilo Code API key updated")
|
|
||||||
else:
|
|
||||||
api_key = prompt(" Kilo Code API key", password=True)
|
|
||||||
if api_key:
|
|
||||||
save_env_value("KILOCODE_API_KEY", api_key)
|
|
||||||
print_success("Kilo Code API key saved")
|
|
||||||
else:
|
|
||||||
print_warning("Skipped - agent won't work without an API key")
|
|
||||||
|
|
||||||
_set_model_provider(config, "kilocode", pconfig.inference_base_url)
|
|
||||||
selected_base_url = pconfig.inference_base_url
|
|
||||||
|
|
||||||
elif provider_idx == 9: # Anthropic
|
|
||||||
selected_provider = "anthropic"
|
|
||||||
print()
|
|
||||||
print_header("Anthropic Authentication")
|
|
||||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
|
||||||
from hermes_cli.config import save_anthropic_api_key, save_anthropic_oauth_token
|
|
||||||
pconfig = PROVIDER_REGISTRY["anthropic"]
|
|
||||||
|
|
||||||
# Check ALL credential sources
|
|
||||||
import os as _os
|
|
||||||
from agent.anthropic_adapter import (
|
|
||||||
read_claude_code_credentials, is_claude_code_token_valid,
|
|
||||||
run_oauth_setup_token,
|
|
||||||
)
|
|
||||||
cc_creds = read_claude_code_credentials()
|
|
||||||
cc_valid = bool(cc_creds and is_claude_code_token_valid(cc_creds))
|
|
||||||
|
|
||||||
existing_key = (
|
|
||||||
get_env_value("ANTHROPIC_TOKEN")
|
|
||||||
or get_env_value("ANTHROPIC_API_KEY")
|
|
||||||
or _os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "")
|
|
||||||
)
|
|
||||||
|
|
||||||
has_creds = bool(existing_key) or cc_valid
|
|
||||||
needs_auth = not has_creds
|
|
||||||
|
|
||||||
if has_creds:
|
|
||||||
if existing_key:
|
|
||||||
print_info(f"Current credentials: {existing_key[:12]}...")
|
|
||||||
elif cc_valid:
|
|
||||||
print_success("Found valid Claude Code credentials (auto-detected)")
|
|
||||||
|
|
||||||
auth_choices = [
|
|
||||||
"Use existing credentials",
|
|
||||||
"Reauthenticate (new OAuth login)",
|
|
||||||
"Cancel",
|
|
||||||
]
|
|
||||||
choice_idx = prompt_choice("What would you like to do?", auth_choices, 0)
|
|
||||||
if choice_idx == 1:
|
|
||||||
needs_auth = True
|
|
||||||
elif choice_idx == 2:
|
|
||||||
pass # fall through to provider config
|
|
||||||
|
|
||||||
if needs_auth:
|
|
||||||
auth_choices = [
|
|
||||||
"Claude Pro/Max subscription (OAuth login)",
|
|
||||||
"Anthropic API key (pay-per-token)",
|
|
||||||
]
|
|
||||||
auth_idx = prompt_choice("Choose authentication method:", auth_choices, 0)
|
|
||||||
|
|
||||||
if auth_idx == 0:
|
|
||||||
# OAuth setup-token flow
|
|
||||||
try:
|
|
||||||
print()
|
|
||||||
print_info("Running 'claude setup-token' — follow the prompts below.")
|
|
||||||
print_info("A browser window will open for you to authorize access.")
|
|
||||||
print()
|
|
||||||
token = run_oauth_setup_token()
|
|
||||||
if token:
|
|
||||||
save_anthropic_oauth_token(token, save_fn=save_env_value)
|
|
||||||
print_success("OAuth credentials saved")
|
|
||||||
else:
|
|
||||||
# Subprocess completed but no token auto-detected
|
|
||||||
print()
|
|
||||||
token = prompt("Paste setup-token here (if displayed above)", password=True)
|
|
||||||
if token:
|
|
||||||
save_anthropic_oauth_token(token, save_fn=save_env_value)
|
|
||||||
print_success("Setup-token saved")
|
|
||||||
else:
|
|
||||||
print_warning("Skipped — agent won't work without credentials")
|
|
||||||
except FileNotFoundError:
|
|
||||||
print()
|
|
||||||
print_info("The 'claude' CLI is required for OAuth login.")
|
|
||||||
print()
|
|
||||||
print_info("To install: npm install -g @anthropic-ai/claude-code")
|
|
||||||
print_info("Then run: claude setup-token")
|
|
||||||
print_info("Or paste an existing setup-token below:")
|
|
||||||
print()
|
|
||||||
token = prompt("Setup-token (sk-ant-oat-...)", password=True)
|
|
||||||
if token:
|
|
||||||
save_anthropic_oauth_token(token, save_fn=save_env_value)
|
|
||||||
print_success("Setup-token saved")
|
|
||||||
else:
|
|
||||||
print_warning("Skipped — install Claude Code and re-run setup")
|
|
||||||
else:
|
|
||||||
print()
|
|
||||||
print_info("Get an API key at: https://console.anthropic.com/settings/keys")
|
|
||||||
print()
|
|
||||||
api_key = prompt("API key (sk-ant-...)", password=True)
|
|
||||||
if api_key:
|
|
||||||
save_anthropic_api_key(api_key, save_fn=save_env_value)
|
|
||||||
print_success("API key saved")
|
|
||||||
else:
|
|
||||||
print_warning("Skipped — agent won't work without credentials")
|
|
||||||
|
|
||||||
# Don't save base_url for Anthropic — resolve_runtime_provider()
|
|
||||||
# always hardcodes it. Stale base_urls contaminate other providers.
|
|
||||||
_set_model_provider(config, "anthropic")
|
|
||||||
selected_base_url = ""
|
|
||||||
|
|
||||||
elif provider_idx == 10: # AI Gateway
|
|
||||||
selected_provider = "ai-gateway"
|
|
||||||
print()
|
|
||||||
print_header("AI Gateway API Key")
|
|
||||||
pconfig = PROVIDER_REGISTRY["ai-gateway"]
|
|
||||||
print_info(f"Provider: {pconfig.name}")
|
|
||||||
print_info("Get your API key at: https://vercel.com/docs/ai-gateway")
|
|
||||||
print()
|
|
||||||
|
|
||||||
existing_key = get_env_value("AI_GATEWAY_API_KEY")
|
|
||||||
if existing_key:
|
|
||||||
print_info(f"Current: {existing_key[:8]}... (configured)")
|
|
||||||
if prompt_yes_no("Update API key?", False):
|
|
||||||
api_key = prompt(" AI Gateway API key", password=True)
|
|
||||||
if api_key:
|
|
||||||
save_env_value("AI_GATEWAY_API_KEY", api_key)
|
|
||||||
print_success("AI Gateway API key updated")
|
|
||||||
else:
|
|
||||||
api_key = prompt(" AI Gateway API key", password=True)
|
|
||||||
if api_key:
|
|
||||||
save_env_value("AI_GATEWAY_API_KEY", api_key)
|
|
||||||
print_success("AI Gateway API key saved")
|
|
||||||
else:
|
|
||||||
print_warning("Skipped - agent won't work without an API key")
|
|
||||||
|
|
||||||
_update_config_for_provider("ai-gateway", pconfig.inference_base_url, default_model="anthropic/claude-opus-4.6")
|
|
||||||
_set_model_provider(config, "ai-gateway", pconfig.inference_base_url)
|
|
||||||
|
|
||||||
elif provider_idx == 11: # Alibaba Cloud / DashScope
|
|
||||||
selected_provider = "alibaba"
|
|
||||||
print()
|
|
||||||
print_header("Alibaba Cloud / DashScope API Key")
|
|
||||||
pconfig = PROVIDER_REGISTRY["alibaba"]
|
|
||||||
print_info(f"Provider: {pconfig.name}")
|
|
||||||
print_info("Get your API key at: https://modelstudio.console.alibabacloud.com/")
|
|
||||||
print()
|
|
||||||
|
|
||||||
existing_key = get_env_value("DASHSCOPE_API_KEY")
|
|
||||||
if existing_key:
|
|
||||||
print_info(f"Current: {existing_key[:8]}... (configured)")
|
|
||||||
if prompt_yes_no("Update API key?", False):
|
|
||||||
new_key = prompt(" DashScope API key", password=True)
|
|
||||||
if new_key:
|
|
||||||
save_env_value("DASHSCOPE_API_KEY", new_key)
|
|
||||||
print_success("DashScope API key updated")
|
|
||||||
else:
|
|
||||||
new_key = prompt(" DashScope API key", password=True)
|
|
||||||
if new_key:
|
|
||||||
save_env_value("DASHSCOPE_API_KEY", new_key)
|
|
||||||
print_success("DashScope API key saved")
|
|
||||||
else:
|
|
||||||
print_warning("Skipped - agent won't work without an API key")
|
|
||||||
|
|
||||||
_update_config_for_provider("alibaba", pconfig.inference_base_url, default_model="qwen3.5-plus")
|
|
||||||
_set_model_provider(config, "alibaba", pconfig.inference_base_url)
|
|
||||||
|
|
||||||
elif provider_idx == 12: # OpenCode Zen
|
|
||||||
selected_provider = "opencode-zen"
|
|
||||||
print()
|
|
||||||
print_header("OpenCode Zen API Key")
|
|
||||||
pconfig = PROVIDER_REGISTRY["opencode-zen"]
|
|
||||||
print_info(f"Provider: {pconfig.name}")
|
|
||||||
print_info(f"Base URL: {pconfig.inference_base_url}")
|
|
||||||
print_info("Get your API key at: https://opencode.ai/auth")
|
|
||||||
print()
|
|
||||||
|
|
||||||
existing_key = get_env_value("OPENCODE_ZEN_API_KEY")
|
|
||||||
if existing_key:
|
|
||||||
print_info(f"Current: {existing_key[:8]}... (configured)")
|
|
||||||
if prompt_yes_no("Update API key?", False):
|
|
||||||
api_key = prompt(" OpenCode Zen API key", password=True)
|
|
||||||
if api_key:
|
|
||||||
save_env_value("OPENCODE_ZEN_API_KEY", api_key)
|
|
||||||
print_success("OpenCode Zen API key updated")
|
|
||||||
else:
|
|
||||||
api_key = prompt(" OpenCode Zen API key", password=True)
|
|
||||||
if api_key:
|
|
||||||
save_env_value("OPENCODE_ZEN_API_KEY", api_key)
|
|
||||||
print_success("OpenCode Zen API key saved")
|
|
||||||
else:
|
|
||||||
print_warning("Skipped - agent won't work without an API key")
|
|
||||||
|
|
||||||
_set_model_provider(config, "opencode-zen", pconfig.inference_base_url)
|
|
||||||
selected_base_url = pconfig.inference_base_url
|
|
||||||
|
|
||||||
elif provider_idx == 13: # OpenCode Go
|
|
||||||
selected_provider = "opencode-go"
|
|
||||||
print()
|
|
||||||
print_header("OpenCode Go API Key")
|
|
||||||
pconfig = PROVIDER_REGISTRY["opencode-go"]
|
|
||||||
print_info(f"Provider: {pconfig.name}")
|
|
||||||
print_info(f"Base URL: {pconfig.inference_base_url}")
|
|
||||||
print_info("Get your API key at: https://opencode.ai/auth")
|
|
||||||
print()
|
|
||||||
|
|
||||||
existing_key = get_env_value("OPENCODE_GO_API_KEY")
|
|
||||||
if existing_key:
|
|
||||||
print_info(f"Current: {existing_key[:8]}... (configured)")
|
|
||||||
if prompt_yes_no("Update API key?", False):
|
|
||||||
api_key = prompt(" OpenCode Go API key", password=True)
|
|
||||||
if api_key:
|
|
||||||
save_env_value("OPENCODE_GO_API_KEY", api_key)
|
|
||||||
print_success("OpenCode Go API key updated")
|
|
||||||
else:
|
|
||||||
api_key = prompt(" OpenCode Go API key", password=True)
|
|
||||||
if api_key:
|
|
||||||
save_env_value("OPENCODE_GO_API_KEY", api_key)
|
|
||||||
print_success("OpenCode Go API key saved")
|
|
||||||
else:
|
|
||||||
print_warning("Skipped - agent won't work without an API key")
|
|
||||||
|
|
||||||
_set_model_provider(config, "opencode-go", pconfig.inference_base_url)
|
|
||||||
selected_base_url = pconfig.inference_base_url
|
|
||||||
|
|
||||||
elif provider_idx == 14: # GitHub Copilot
|
|
||||||
selected_provider = "copilot"
|
|
||||||
print()
|
|
||||||
print_header("GitHub Copilot")
|
|
||||||
pconfig = PROVIDER_REGISTRY["copilot"]
|
|
||||||
print_info("Hermes can use GITHUB_TOKEN, GH_TOKEN, or your gh CLI login.")
|
|
||||||
print_info(f"Base URL: {pconfig.inference_base_url}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
copilot_creds = resolve_api_key_provider_credentials("copilot")
|
|
||||||
source = copilot_creds.get("source", "")
|
|
||||||
token = copilot_creds.get("api_key", "")
|
|
||||||
if token:
|
|
||||||
if source in ("GITHUB_TOKEN", "GH_TOKEN"):
|
|
||||||
print_info(f"Current: {token[:8]}... ({source})")
|
|
||||||
elif source == "gh auth token":
|
|
||||||
print_info("Current: authenticated via `gh auth token`")
|
|
||||||
else:
|
|
||||||
print_info("Current: GitHub token configured")
|
|
||||||
else:
|
|
||||||
api_key = prompt(" GitHub token", password=True)
|
|
||||||
if api_key:
|
|
||||||
save_env_value("GITHUB_TOKEN", api_key)
|
|
||||||
print_success("GitHub token saved")
|
|
||||||
else:
|
|
||||||
print_warning("Skipped - agent won't work without a GitHub token or gh auth login")
|
|
||||||
|
|
||||||
_set_model_provider(config, "copilot", pconfig.inference_base_url)
|
|
||||||
selected_base_url = pconfig.inference_base_url
|
|
||||||
|
|
||||||
elif provider_idx == 15: # GitHub Copilot ACP
|
|
||||||
selected_provider = "copilot-acp"
|
|
||||||
print()
|
|
||||||
print_header("GitHub Copilot ACP")
|
|
||||||
pconfig = PROVIDER_REGISTRY["copilot-acp"]
|
|
||||||
print_info("Hermes will start `copilot --acp --stdio` for each request.")
|
|
||||||
print_info("Use HERMES_COPILOT_ACP_COMMAND or COPILOT_CLI_PATH to override the command.")
|
|
||||||
print_info(f"Base marker: {pconfig.inference_base_url}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
_set_model_provider(config, "copilot-acp", pconfig.inference_base_url)
|
|
||||||
selected_base_url = pconfig.inference_base_url
|
|
||||||
|
|
||||||
elif provider_idx == 16: # Hugging Face Inference Providers
|
|
||||||
selected_provider = "huggingface"
|
|
||||||
print()
|
|
||||||
print_header("Hugging Face API Token")
|
|
||||||
pconfig = PROVIDER_REGISTRY["huggingface"]
|
|
||||||
print_info(f"Provider: {pconfig.name}")
|
|
||||||
print_info("Get your token at: https://huggingface.co/settings/tokens")
|
|
||||||
print_info("Required permission: 'Make calls to Inference Providers'")
|
|
||||||
print()
|
|
||||||
|
|
||||||
api_key = prompt(" HF Token", password=True)
|
|
||||||
if api_key:
|
|
||||||
save_env_value("HF_TOKEN", api_key)
|
|
||||||
_set_model_provider(config, "huggingface", pconfig.inference_base_url)
|
|
||||||
selected_base_url = pconfig.inference_base_url
|
|
||||||
|
|
||||||
# else: provider_idx == 17 (Keep current) — only shown when a provider already exists
|
|
||||||
# Normalize "keep current" to an explicit provider so downstream logic
|
|
||||||
# doesn't fall back to the generic OpenRouter/static-model path.
|
|
||||||
if selected_provider is None:
|
|
||||||
if current_config_provider:
|
|
||||||
selected_provider = current_config_provider
|
|
||||||
elif active_oauth and active_oauth in PROVIDER_REGISTRY:
|
|
||||||
selected_provider = active_oauth
|
|
||||||
elif existing_custom:
|
|
||||||
selected_provider = "custom"
|
|
||||||
elif existing_or:
|
|
||||||
selected_provider = "openrouter"
|
|
||||||
|
|
||||||
# ── Vision & Image Analysis Setup ──
|
# ── Vision & Image Analysis Setup ──
|
||||||
# Keep setup aligned with the actual runtime resolver the vision tools use.
|
# Keep setup aligned with the actual runtime resolver the vision tools use.
|
||||||
@@ -1599,155 +933,6 @@ def setup_model_provider(config: dict):
|
|||||||
else:
|
else:
|
||||||
print_info("Skipped — add later with 'hermes setup' or configure AUXILIARY_VISION_* settings")
|
print_info("Skipped — add later with 'hermes setup' or configure AUXILIARY_VISION_* settings")
|
||||||
|
|
||||||
# ── Model Selection (adapts based on provider) ──
|
|
||||||
if selected_provider != "custom": # Custom already prompted for model name
|
|
||||||
print_header("Default Model")
|
|
||||||
|
|
||||||
_raw_model = config.get("model", "anthropic/claude-opus-4.6")
|
|
||||||
current_model = (
|
|
||||||
_raw_model.get("default", "anthropic/claude-opus-4.6")
|
|
||||||
if isinstance(_raw_model, dict)
|
|
||||||
else (_raw_model or "anthropic/claude-opus-4.6")
|
|
||||||
)
|
|
||||||
print_info(f"Current: {current_model}")
|
|
||||||
|
|
||||||
if selected_provider == "nous" and nous_models:
|
|
||||||
# Dynamic model list from Nous Portal
|
|
||||||
model_choices = [f"{m}" for m in nous_models]
|
|
||||||
model_choices.append("Custom model")
|
|
||||||
model_choices.append(f"Keep current ({current_model})")
|
|
||||||
|
|
||||||
# Post-login validation: warn if current model might not be available
|
|
||||||
if current_model and current_model not in nous_models:
|
|
||||||
print_warning(
|
|
||||||
f"Your current model ({current_model}) may not be available via Nous Portal."
|
|
||||||
)
|
|
||||||
print_info(
|
|
||||||
"Select a model from the list, or keep current to use it anyway."
|
|
||||||
)
|
|
||||||
print()
|
|
||||||
|
|
||||||
model_idx = prompt_choice(
|
|
||||||
"Select default model:", model_choices, len(model_choices) - 1
|
|
||||||
)
|
|
||||||
|
|
||||||
if model_idx < len(nous_models):
|
|
||||||
_set_default_model(config, nous_models[model_idx])
|
|
||||||
elif model_idx == len(model_choices) - 2: # Custom
|
|
||||||
model_name = prompt(" Model name")
|
|
||||||
if model_name:
|
|
||||||
_set_default_model(config, model_name)
|
|
||||||
# else: keep current
|
|
||||||
|
|
||||||
elif selected_provider == "nous":
|
|
||||||
# Nous login succeeded but model fetch failed — prompt manually
|
|
||||||
# instead of falling through to the OpenRouter static list.
|
|
||||||
print_warning("Could not fetch available models from Nous Portal.")
|
|
||||||
print_info("Enter a Nous model name manually (e.g., claude-opus-4-6).")
|
|
||||||
custom = prompt(f" Model name (Enter to keep '{current_model}')")
|
|
||||||
if custom:
|
|
||||||
_set_default_model(config, custom)
|
|
||||||
elif selected_provider == "openai-codex":
|
|
||||||
from hermes_cli.codex_models import get_codex_model_ids
|
|
||||||
|
|
||||||
codex_token = None
|
|
||||||
try:
|
|
||||||
codex_creds = resolve_codex_runtime_credentials()
|
|
||||||
codex_token = codex_creds.get("api_key")
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("Could not resolve Codex runtime credentials for model list: %s", exc)
|
|
||||||
|
|
||||||
codex_models = get_codex_model_ids(access_token=codex_token)
|
|
||||||
|
|
||||||
model_choices = codex_models + [f"Keep current ({current_model})"]
|
|
||||||
default_codex = 0
|
|
||||||
if current_model in codex_models:
|
|
||||||
default_codex = codex_models.index(current_model)
|
|
||||||
elif current_model:
|
|
||||||
default_codex = len(model_choices) - 1
|
|
||||||
|
|
||||||
model_idx = prompt_choice(
|
|
||||||
"Select default model:", model_choices, default_codex
|
|
||||||
)
|
|
||||||
if model_idx < len(codex_models):
|
|
||||||
_set_default_model(config, codex_models[model_idx])
|
|
||||||
elif model_idx == len(codex_models):
|
|
||||||
custom = prompt("Enter model name")
|
|
||||||
if custom:
|
|
||||||
_set_default_model(config, custom)
|
|
||||||
_update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL)
|
|
||||||
_set_model_provider(config, "openai-codex", DEFAULT_CODEX_BASE_URL)
|
|
||||||
elif selected_provider == "copilot-acp":
|
|
||||||
_setup_provider_model_selection(
|
|
||||||
config, selected_provider, current_model,
|
|
||||||
prompt_choice, prompt,
|
|
||||||
)
|
|
||||||
model_cfg = _model_config_dict(config)
|
|
||||||
model_cfg["api_mode"] = "chat_completions"
|
|
||||||
config["model"] = model_cfg
|
|
||||||
elif selected_provider in ("copilot", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "ai-gateway", "opencode-zen", "opencode-go", "alibaba"):
|
|
||||||
_setup_provider_model_selection(
|
|
||||||
config, selected_provider, current_model,
|
|
||||||
prompt_choice, prompt,
|
|
||||||
)
|
|
||||||
elif selected_provider == "anthropic":
|
|
||||||
# Try live model list first, fall back to static
|
|
||||||
from hermes_cli.models import provider_model_ids
|
|
||||||
live_models = provider_model_ids("anthropic")
|
|
||||||
anthropic_models = live_models if live_models else [
|
|
||||||
"claude-opus-4-6",
|
|
||||||
"claude-sonnet-4-6",
|
|
||||||
"claude-haiku-4-5-20251001",
|
|
||||||
]
|
|
||||||
model_choices = list(anthropic_models)
|
|
||||||
model_choices.append("Custom model")
|
|
||||||
model_choices.append(f"Keep current ({current_model})")
|
|
||||||
|
|
||||||
keep_idx = len(model_choices) - 1
|
|
||||||
model_idx = prompt_choice("Select default model:", model_choices, keep_idx)
|
|
||||||
|
|
||||||
if model_idx < len(anthropic_models):
|
|
||||||
_set_default_model(config, anthropic_models[model_idx])
|
|
||||||
elif model_idx == len(anthropic_models):
|
|
||||||
custom = prompt("Enter model name (e.g., claude-sonnet-4-20250514)")
|
|
||||||
if custom:
|
|
||||||
_set_default_model(config, custom)
|
|
||||||
# else: keep current
|
|
||||||
else:
|
|
||||||
# Static list for OpenRouter / fallback (from canonical list)
|
|
||||||
from hermes_cli.models import model_ids, menu_labels
|
|
||||||
|
|
||||||
ids = model_ids()
|
|
||||||
model_choices = menu_labels() + [
|
|
||||||
"Custom model",
|
|
||||||
f"Keep current ({current_model})",
|
|
||||||
]
|
|
||||||
|
|
||||||
keep_idx = len(model_choices) - 1
|
|
||||||
model_idx = prompt_choice("Select default model:", model_choices, keep_idx)
|
|
||||||
|
|
||||||
if model_idx < len(ids):
|
|
||||||
_set_default_model(config, ids[model_idx])
|
|
||||||
elif model_idx == len(ids): # Custom
|
|
||||||
custom = prompt("Enter model name (e.g., anthropic/claude-opus-4.6)")
|
|
||||||
if custom:
|
|
||||||
_set_default_model(config, custom)
|
|
||||||
# else: Keep current
|
|
||||||
|
|
||||||
_final_model = config.get("model", "")
|
|
||||||
if _final_model:
|
|
||||||
_display = (
|
|
||||||
_final_model.get("default", _final_model)
|
|
||||||
if isinstance(_final_model, dict)
|
|
||||||
else _final_model
|
|
||||||
)
|
|
||||||
print_success(f"Model set to: {_display}")
|
|
||||||
|
|
||||||
# Write provider+base_url to config.yaml only after model selection is complete.
|
|
||||||
# This prevents a race condition where the gateway picks up a new provider
|
|
||||||
# before the model name has been updated to match.
|
|
||||||
if selected_provider in ("copilot-acp", "copilot", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic") and selected_base_url is not None:
|
|
||||||
_update_config_for_provider(selected_provider, selected_base_url)
|
|
||||||
|
|
||||||
save_config(config)
|
save_config(config)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
"""Tests for setup_model_provider — verifies the delegation to
|
||||||
|
select_provider_and_model() and config dict sync."""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from hermes_cli.auth import _update_config_for_provider, get_active_provider
|
from hermes_cli.auth import get_active_provider
|
||||||
from hermes_cli.config import load_config, save_config
|
from hermes_cli.config import load_config, save_config
|
||||||
from hermes_cli.setup import setup_model_provider
|
from hermes_cli.setup import setup_model_provider
|
||||||
|
|
||||||
@@ -23,270 +25,198 @@ def _clear_provider_env(monkeypatch):
|
|||||||
monkeypatch.delenv(key, raising=False)
|
monkeypatch.delenv(key, raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_tts(monkeypatch):
|
||||||
|
"""Stub out TTS prompts so setup_model_provider doesn't block."""
|
||||||
|
monkeypatch.setattr("hermes_cli.setup.prompt_choice", lambda q, c, d=0: (
|
||||||
|
_maybe_keep_current_tts(q, c) if _maybe_keep_current_tts(q, c) is not None
|
||||||
|
else d
|
||||||
|
))
|
||||||
|
monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *a, **kw: False)
|
||||||
|
|
||||||
def test_nous_oauth_setup_keeps_current_model_when_syncing_disk_provider(
|
|
||||||
tmp_path, monkeypatch
|
def _write_model_config(tmp_path, provider, base_url="", model_name="test-model"):
|
||||||
):
|
"""Simulate what a _model_flow_* function writes to disk."""
|
||||||
|
cfg = load_config()
|
||||||
|
m = cfg.get("model")
|
||||||
|
if not isinstance(m, dict):
|
||||||
|
m = {"default": m} if m else {}
|
||||||
|
cfg["model"] = m
|
||||||
|
m["provider"] = provider
|
||||||
|
if base_url:
|
||||||
|
m["base_url"] = base_url
|
||||||
|
if model_name:
|
||||||
|
m["default"] = model_name
|
||||||
|
save_config(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_delegates_to_select_provider_and_model(tmp_path, monkeypatch):
|
||||||
|
"""setup_model_provider calls select_provider_and_model and syncs config."""
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
_clear_provider_env(monkeypatch)
|
_clear_provider_env(monkeypatch)
|
||||||
|
_stub_tts(monkeypatch)
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
def fake_prompt_choice(question, choices, default=0):
|
def fake_select():
|
||||||
if question == "Select your inference provider:":
|
_write_model_config(tmp_path, "custom", "http://localhost:11434/v1", "qwen3.5:32b")
|
||||||
return 1 # Nous Portal
|
|
||||||
if question == "Configure vision:":
|
|
||||||
return len(choices) - 1
|
|
||||||
if question == "Select default model:":
|
|
||||||
assert choices[-1] == "Keep current (anthropic/claude-opus-4.6)"
|
|
||||||
return len(choices) - 1
|
|
||||||
tts_idx = _maybe_keep_current_tts(question, choices)
|
|
||||||
if tts_idx is not None:
|
|
||||||
return tts_idx
|
|
||||||
raise AssertionError(f"Unexpected prompt_choice call: {question}")
|
|
||||||
|
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
|
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "")
|
|
||||||
monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
|
|
||||||
|
|
||||||
def _fake_login_nous(*args, **kwargs):
|
|
||||||
auth_path = tmp_path / "auth.json"
|
|
||||||
auth_path.write_text(json.dumps({"active_provider": "nous", "providers": {}}))
|
|
||||||
_update_config_for_provider("nous", "https://inference.example.com/v1")
|
|
||||||
|
|
||||||
monkeypatch.setattr("hermes_cli.auth._login_nous", _fake_login_nous)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"hermes_cli.auth.resolve_nous_runtime_credentials",
|
|
||||||
lambda *args, **kwargs: {
|
|
||||||
"base_url": "https://inference.example.com/v1",
|
|
||||||
"api_key": "nous-key",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"hermes_cli.auth.fetch_nous_models",
|
|
||||||
lambda *args, **kwargs: ["gemini-3-flash"],
|
|
||||||
)
|
|
||||||
|
|
||||||
setup_model_provider(config)
|
setup_model_provider(config)
|
||||||
save_config(config)
|
save_config(config)
|
||||||
|
|
||||||
reloaded = load_config()
|
reloaded = load_config()
|
||||||
|
assert isinstance(reloaded["model"], dict)
|
||||||
|
assert reloaded["model"]["provider"] == "custom"
|
||||||
|
assert reloaded["model"]["base_url"] == "http://localhost:11434/v1"
|
||||||
|
assert reloaded["model"]["default"] == "qwen3.5:32b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_syncs_openrouter_from_disk(tmp_path, monkeypatch):
|
||||||
|
"""When select_provider_and_model saves OpenRouter config to disk,
|
||||||
|
the wizard's config dict picks it up."""
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
_clear_provider_env(monkeypatch)
|
||||||
|
_stub_tts(monkeypatch)
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
assert isinstance(config.get("model"), str) # fresh install
|
||||||
|
|
||||||
|
def fake_select():
|
||||||
|
_write_model_config(tmp_path, "openrouter", model_name="anthropic/claude-opus-4.6")
|
||||||
|
|
||||||
|
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
|
||||||
|
|
||||||
|
setup_model_provider(config)
|
||||||
|
save_config(config)
|
||||||
|
|
||||||
|
reloaded = load_config()
|
||||||
|
assert isinstance(reloaded["model"], dict)
|
||||||
|
assert reloaded["model"]["provider"] == "openrouter"
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_syncs_nous_from_disk(tmp_path, monkeypatch):
|
||||||
|
"""Nous OAuth writes config to disk; wizard config dict must pick it up."""
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
_clear_provider_env(monkeypatch)
|
||||||
|
_stub_tts(monkeypatch)
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
|
||||||
|
def fake_select():
|
||||||
|
_write_model_config(tmp_path, "nous", "https://inference.example.com/v1", "gemini-3-flash")
|
||||||
|
|
||||||
|
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
|
||||||
|
|
||||||
|
setup_model_provider(config)
|
||||||
|
save_config(config)
|
||||||
|
|
||||||
|
reloaded = load_config()
|
||||||
assert isinstance(reloaded["model"], dict)
|
assert isinstance(reloaded["model"], dict)
|
||||||
assert reloaded["model"]["provider"] == "nous"
|
assert reloaded["model"]["provider"] == "nous"
|
||||||
assert reloaded["model"]["base_url"] == "https://inference.example.com/v1"
|
assert reloaded["model"]["base_url"] == "https://inference.example.com/v1"
|
||||||
assert reloaded["model"]["default"] == "anthropic/claude-opus-4.6"
|
|
||||||
|
|
||||||
|
|
||||||
def test_custom_setup_clears_active_oauth_provider(tmp_path, monkeypatch):
|
def test_setup_custom_providers_synced(tmp_path, monkeypatch):
|
||||||
|
"""custom_providers written by select_provider_and_model must survive."""
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
_clear_provider_env(monkeypatch)
|
_clear_provider_env(monkeypatch)
|
||||||
|
_stub_tts(monkeypatch)
|
||||||
auth_path = tmp_path / "auth.json"
|
|
||||||
auth_path.write_text(json.dumps({"active_provider": "nous", "providers": {}}))
|
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
def fake_prompt_choice(question, choices, default=0):
|
def fake_select():
|
||||||
if question == "Select your inference provider:":
|
_write_model_config(tmp_path, "custom", "http://localhost:8080/v1", "llama3")
|
||||||
return 3
|
cfg = load_config()
|
||||||
tts_idx = _maybe_keep_current_tts(question, choices)
|
cfg["custom_providers"] = [{"name": "Local", "base_url": "http://localhost:8080/v1"}]
|
||||||
if tts_idx is not None:
|
save_config(cfg)
|
||||||
return tts_idx
|
|
||||||
raise AssertionError(f"Unexpected prompt_choice call: {question}")
|
|
||||||
|
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
|
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
|
||||||
|
|
||||||
# _model_flow_custom uses builtins.input (URL, key, model, context_length)
|
|
||||||
input_values = iter([
|
|
||||||
"https://custom.example/v1",
|
|
||||||
"custom-api-key",
|
|
||||||
"custom/model",
|
|
||||||
"", # context_length (blank = auto-detect)
|
|
||||||
])
|
|
||||||
monkeypatch.setattr("builtins.input", lambda _prompt="": next(input_values))
|
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False)
|
|
||||||
monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
|
|
||||||
monkeypatch.setattr("hermes_cli.main._save_custom_provider", lambda *args, **kwargs: None)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"hermes_cli.models.probe_api_models",
|
|
||||||
lambda api_key, base_url: {"models": ["m"], "probed_url": base_url + "/models"},
|
|
||||||
)
|
|
||||||
|
|
||||||
setup_model_provider(config)
|
setup_model_provider(config)
|
||||||
|
|
||||||
# Core assertion: switching to custom endpoint clears OAuth provider
|
|
||||||
assert get_active_provider() is None
|
|
||||||
|
|
||||||
# Simulate what the real setup wizard does: save_config(config) AFTER
|
|
||||||
# setup_model_provider returns. This is the step that previously
|
|
||||||
# overwrote model.provider/base_url (#4172).
|
|
||||||
save_config(config)
|
save_config(config)
|
||||||
|
|
||||||
reloaded = load_config()
|
reloaded = load_config()
|
||||||
assert isinstance(reloaded.get("model"), dict), (
|
assert reloaded.get("custom_providers") == [{"name": "Local", "base_url": "http://localhost:8080/v1"}]
|
||||||
"model should be a dict after custom setup, not "
|
|
||||||
+ repr(type(reloaded.get("model")))
|
|
||||||
)
|
|
||||||
assert reloaded["model"].get("provider") == "custom"
|
|
||||||
assert reloaded["model"].get("default") == "custom/model"
|
|
||||||
assert "custom.example" in reloaded["model"].get("base_url", "")
|
|
||||||
|
|
||||||
|
|
||||||
def test_custom_setup_preserves_provider_after_wizard_save_config(
|
def test_setup_cancel_preserves_existing_config(tmp_path, monkeypatch):
|
||||||
tmp_path, monkeypatch
|
"""When the user cancels provider selection, existing config is preserved."""
|
||||||
):
|
|
||||||
"""Regression test for #4172: the setup wizard's final save_config(config)
|
|
||||||
must not overwrite model.provider/base_url that _model_flow_custom set.
|
|
||||||
|
|
||||||
Simulates the full flow:
|
|
||||||
1. load config (fresh install — model is a string)
|
|
||||||
2. setup_model_provider picks custom
|
|
||||||
3. wizard calls save_config(config) afterward
|
|
||||||
4. verify resolve_requested_provider returns "custom"
|
|
||||||
"""
|
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
_clear_provider_env(monkeypatch)
|
_clear_provider_env(monkeypatch)
|
||||||
|
_stub_tts(monkeypatch)
|
||||||
|
|
||||||
|
# Pre-set a provider
|
||||||
|
_write_model_config(tmp_path, "openrouter", model_name="gpt-4o")
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
# Sanity: fresh install has model as a string
|
assert config["model"]["provider"] == "openrouter"
|
||||||
assert isinstance(config.get("model"), str) or config.get("model") is None
|
|
||||||
|
|
||||||
def fake_prompt_choice(question, choices, default=0):
|
def fake_select():
|
||||||
if question == "Select your inference provider:":
|
pass # user cancelled — nothing written to disk
|
||||||
return 3 # Custom endpoint
|
|
||||||
tts_idx = _maybe_keep_current_tts(question, choices)
|
|
||||||
if tts_idx is not None:
|
|
||||||
return tts_idx
|
|
||||||
raise AssertionError(f"Unexpected prompt_choice call: {question}")
|
|
||||||
|
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
|
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
|
||||||
|
|
||||||
input_values = iter([
|
|
||||||
"http://localhost:11434/v1", # Ollama URL
|
|
||||||
"", # no API key (local)
|
|
||||||
"qwen3.5:32b", # model name
|
|
||||||
"", # context length (auto-detect)
|
|
||||||
])
|
|
||||||
monkeypatch.setattr("builtins.input", lambda _prompt="": next(input_values))
|
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *a, **kw: False)
|
|
||||||
monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
|
|
||||||
monkeypatch.setattr("hermes_cli.main._save_custom_provider", lambda *a, **kw: None)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"hermes_cli.models.probe_api_models",
|
|
||||||
lambda api_key, base_url: {"models": ["qwen3.5:32b"], "probed_url": base_url + "/models"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Full wizard cycle
|
|
||||||
setup_model_provider(config)
|
|
||||||
save_config(config) # ← this is what the real wizard does
|
|
||||||
|
|
||||||
# Verify config on disk
|
|
||||||
reloaded = load_config()
|
|
||||||
assert isinstance(reloaded["model"], dict)
|
|
||||||
assert reloaded["model"]["provider"] == "custom"
|
|
||||||
assert reloaded["model"]["base_url"] == "http://localhost:11434/v1"
|
|
||||||
assert reloaded["model"]["default"] == "qwen3.5:32b"
|
|
||||||
assert "api_mode" not in reloaded["model"]
|
|
||||||
|
|
||||||
# Verify the runtime resolver sees "custom", not "auto"
|
|
||||||
from hermes_cli.runtime_provider import resolve_requested_provider
|
|
||||||
assert resolve_requested_provider() == "custom"
|
|
||||||
|
|
||||||
|
|
||||||
def test_custom_setup_no_model_name_still_preserves_endpoint(
|
|
||||||
tmp_path, monkeypatch
|
|
||||||
):
|
|
||||||
"""When the user enters a URL and key but skips the model name,
|
|
||||||
model.provider and model.base_url must still survive the wizard's
|
|
||||||
final save_config(config)."""
|
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
||||||
_clear_provider_env(monkeypatch)
|
|
||||||
|
|
||||||
config = load_config()
|
|
||||||
|
|
||||||
def fake_prompt_choice(question, choices, default=0):
|
|
||||||
if question == "Select your inference provider:":
|
|
||||||
return 3
|
|
||||||
tts_idx = _maybe_keep_current_tts(question, choices)
|
|
||||||
if tts_idx is not None:
|
|
||||||
return tts_idx
|
|
||||||
raise AssertionError(f"Unexpected prompt_choice call: {question}")
|
|
||||||
|
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
|
|
||||||
|
|
||||||
input_values = iter([
|
|
||||||
"http://192.168.1.50:8080/v1", # URL
|
|
||||||
"my-key", # API key
|
|
||||||
"", # no model name
|
|
||||||
"", # context length
|
|
||||||
])
|
|
||||||
monkeypatch.setattr("builtins.input", lambda _prompt="": next(input_values))
|
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *a, **kw: False)
|
|
||||||
monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
|
|
||||||
monkeypatch.setattr("hermes_cli.main._save_custom_provider", lambda *a, **kw: None)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"hermes_cli.models.probe_api_models",
|
|
||||||
lambda api_key, base_url: {"models": None, "probed_url": base_url + "/models"},
|
|
||||||
)
|
|
||||||
|
|
||||||
setup_model_provider(config)
|
setup_model_provider(config)
|
||||||
save_config(config)
|
save_config(config)
|
||||||
|
|
||||||
reloaded = load_config()
|
reloaded = load_config()
|
||||||
assert isinstance(reloaded["model"], dict)
|
assert isinstance(reloaded["model"], dict)
|
||||||
assert reloaded["model"]["provider"] == "custom"
|
assert reloaded["model"]["provider"] == "openrouter"
|
||||||
assert reloaded["model"]["base_url"] == "http://192.168.1.50:8080/v1"
|
assert reloaded["model"]["default"] == "gpt-4o"
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_exception_in_select_gracefully_handled(tmp_path, monkeypatch):
|
||||||
|
"""If select_provider_and_model raises, setup continues with existing config."""
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
_clear_provider_env(monkeypatch)
|
||||||
|
_stub_tts(monkeypatch)
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
|
||||||
|
def fake_select():
|
||||||
|
raise RuntimeError("something broke")
|
||||||
|
|
||||||
|
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
|
||||||
|
|
||||||
|
# Should not raise
|
||||||
|
setup_model_provider(config)
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_keyboard_interrupt_gracefully_handled(tmp_path, monkeypatch):
|
||||||
|
"""KeyboardInterrupt during provider selection is handled."""
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
_clear_provider_env(monkeypatch)
|
||||||
|
_stub_tts(monkeypatch)
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
|
||||||
|
def fake_select():
|
||||||
|
raise KeyboardInterrupt()
|
||||||
|
|
||||||
|
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
|
||||||
|
|
||||||
|
setup_model_provider(config)
|
||||||
|
|
||||||
|
|
||||||
def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, monkeypatch):
|
def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, monkeypatch):
|
||||||
|
"""Codex model list fetching uses the runtime access token."""
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-test-key")
|
monkeypatch.setenv("OPENROUTER_API_KEY", "or-test-key")
|
||||||
_clear_provider_env(monkeypatch)
|
_clear_provider_env(monkeypatch)
|
||||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-test-key")
|
monkeypatch.setenv("OPENROUTER_API_KEY", "or-test-key")
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
_stub_tts(monkeypatch)
|
||||||
|
|
||||||
def fake_prompt_choice(question, choices, default=0):
|
def fake_select():
|
||||||
if question == "Select your inference provider:":
|
_write_model_config(tmp_path, "openai-codex", "https://api.openai.com/v1", "gpt-4o")
|
||||||
return 2 # OpenAI Codex
|
|
||||||
if question == "Select default model:":
|
|
||||||
return 0
|
|
||||||
tts_idx = _maybe_keep_current_tts(question, choices)
|
|
||||||
if tts_idx is not None:
|
|
||||||
return tts_idx
|
|
||||||
raise AssertionError(f"Unexpected prompt_choice call: {question}")
|
|
||||||
|
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
|
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "")
|
|
||||||
monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
|
|
||||||
monkeypatch.setattr("hermes_cli.auth._login_openai_codex", lambda *args, **kwargs: None)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"hermes_cli.auth.resolve_codex_runtime_credentials",
|
|
||||||
lambda *args, **kwargs: {
|
|
||||||
"base_url": "https://chatgpt.com/backend-api/codex",
|
|
||||||
"api_key": "codex-access-token",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
def _fake_get_codex_model_ids(access_token=None):
|
|
||||||
captured["access_token"] = access_token
|
|
||||||
return ["gpt-5.2-codex", "gpt-5.2"]
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"hermes_cli.codex_models.get_codex_model_ids",
|
|
||||||
_fake_get_codex_model_ids,
|
|
||||||
)
|
|
||||||
|
|
||||||
setup_model_provider(config)
|
setup_model_provider(config)
|
||||||
save_config(config)
|
save_config(config)
|
||||||
|
|
||||||
reloaded = load_config()
|
reloaded = load_config()
|
||||||
|
|
||||||
assert captured["access_token"] == "codex-access-token"
|
|
||||||
assert isinstance(reloaded["model"], dict)
|
assert isinstance(reloaded["model"], dict)
|
||||||
assert reloaded["model"]["provider"] == "openai-codex"
|
assert reloaded["model"]["provider"] == "openai-codex"
|
||||||
assert reloaded["model"]["default"] == "gpt-5.2-codex"
|
|
||||||
assert reloaded["model"]["base_url"] == "https://chatgpt.com/backend-api/codex"
|
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
"""Regression tests for interactive setup provider/model persistence."""
|
"""Regression tests for interactive setup provider/model persistence.
|
||||||
|
|
||||||
|
Since setup_model_provider delegates to select_provider_and_model()
|
||||||
|
from hermes_cli.main, these tests mock the delegation point and verify
|
||||||
|
that the setup wizard correctly syncs config from disk after the call.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from hermes_cli.config import load_config, save_config, save_env_value
|
from hermes_cli.config import load_config, save_config, save_env_value
|
||||||
from hermes_cli.setup import _print_setup_summary, setup_model_provider
|
from hermes_cli.setup import setup_model_provider
|
||||||
|
|
||||||
|
|
||||||
def _maybe_keep_current_tts(question, choices):
|
def _maybe_keep_current_tts(question, choices):
|
||||||
@@ -13,19 +18,6 @@ def _maybe_keep_current_tts(question, choices):
|
|||||||
return len(choices) - 1
|
return len(choices) - 1
|
||||||
|
|
||||||
|
|
||||||
def _read_env(home):
|
|
||||||
env_path = home / ".env"
|
|
||||||
data = {}
|
|
||||||
if not env_path.exists():
|
|
||||||
return data
|
|
||||||
for line in env_path.read_text().splitlines():
|
|
||||||
if not line or line.startswith("#") or "=" not in line:
|
|
||||||
continue
|
|
||||||
k, v = line.split("=", 1)
|
|
||||||
data[k] = v
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def _clear_provider_env(monkeypatch):
|
def _clear_provider_env(monkeypatch):
|
||||||
for key in (
|
for key in (
|
||||||
"HERMES_INFERENCE_PROVIDER",
|
"HERMES_INFERENCE_PROVIDER",
|
||||||
@@ -44,429 +36,173 @@ def _clear_provider_env(monkeypatch):
|
|||||||
monkeypatch.delenv(key, raising=False)
|
monkeypatch.delenv(key, raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_tts(monkeypatch):
|
||||||
|
monkeypatch.setattr("hermes_cli.setup.prompt_choice", lambda q, c, d=0: (
|
||||||
|
_maybe_keep_current_tts(q, c) if _maybe_keep_current_tts(q, c) is not None
|
||||||
|
else d
|
||||||
|
))
|
||||||
|
monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *a, **kw: False)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_model_config(provider, base_url="", model_name="test-model"):
|
||||||
|
"""Simulate what a _model_flow_* function writes to disk."""
|
||||||
|
cfg = load_config()
|
||||||
|
m = cfg.get("model")
|
||||||
|
if not isinstance(m, dict):
|
||||||
|
m = {"default": m} if m else {}
|
||||||
|
cfg["model"] = m
|
||||||
|
m["provider"] = provider
|
||||||
|
if base_url:
|
||||||
|
m["base_url"] = base_url
|
||||||
|
else:
|
||||||
|
m.pop("base_url", None)
|
||||||
|
if model_name:
|
||||||
|
m["default"] = model_name
|
||||||
|
m.pop("api_mode", None)
|
||||||
|
save_config(cfg)
|
||||||
|
|
||||||
|
|
||||||
def test_setup_keep_current_custom_from_config_does_not_fall_through(tmp_path, monkeypatch):
|
def test_setup_keep_current_custom_from_config_does_not_fall_through(tmp_path, monkeypatch):
|
||||||
"""Keep-current custom should not fall through to the generic model menu."""
|
"""Keep-current custom should not fall through to the generic model menu."""
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
_clear_provider_env(monkeypatch)
|
_clear_provider_env(monkeypatch)
|
||||||
save_env_value("OPENAI_BASE_URL", "https://example.invalid/v1")
|
_stub_tts(monkeypatch)
|
||||||
save_env_value("OPENAI_API_KEY", "custom-key")
|
|
||||||
|
# Pre-set custom provider
|
||||||
|
_write_model_config("custom", "http://localhost:8080/v1", "local-model")
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
config["model"] = {
|
assert config["model"]["provider"] == "custom"
|
||||||
"default": "custom/model",
|
|
||||||
"provider": "custom",
|
|
||||||
"base_url": "https://example.invalid/v1",
|
|
||||||
}
|
|
||||||
save_config(config)
|
|
||||||
|
|
||||||
def fake_prompt_choice(question, choices, default=0):
|
def fake_select():
|
||||||
if question == "Select your inference provider:":
|
pass # user chose "cancel" or "keep current"
|
||||||
assert choices[-1] == "Keep current (Custom: https://example.invalid/v1)"
|
|
||||||
return len(choices) - 1
|
|
||||||
tts_idx = _maybe_keep_current_tts(question, choices)
|
|
||||||
if tts_idx is not None:
|
|
||||||
return tts_idx
|
|
||||||
raise AssertionError("Model menu should not appear for keep-current custom")
|
|
||||||
|
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
|
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "")
|
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False)
|
|
||||||
monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None)
|
|
||||||
monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
|
|
||||||
|
|
||||||
setup_model_provider(config)
|
setup_model_provider(config)
|
||||||
save_config(config)
|
save_config(config)
|
||||||
|
|
||||||
reloaded = load_config()
|
reloaded = load_config()
|
||||||
|
assert isinstance(reloaded["model"], dict)
|
||||||
assert reloaded["model"]["provider"] == "custom"
|
assert reloaded["model"]["provider"] == "custom"
|
||||||
assert reloaded["model"]["default"] == "custom/model"
|
assert reloaded["model"]["base_url"] == "http://localhost:8080/v1"
|
||||||
assert reloaded["model"]["base_url"] == "https://example.invalid/v1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_setup_custom_endpoint_saves_working_v1_base_url(tmp_path, monkeypatch):
|
def test_setup_keep_current_config_provider_uses_provider_specific_model_menu(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""Keeping current provider preserves the config on disk."""
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
_clear_provider_env(monkeypatch)
|
_clear_provider_env(monkeypatch)
|
||||||
|
_stub_tts(monkeypatch)
|
||||||
|
|
||||||
|
_write_model_config("zai", "https://open.bigmodel.cn/api/paas/v4", "glm-5")
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
def fake_prompt_choice(question, choices, default=0):
|
def fake_select():
|
||||||
if question == "Select your inference provider:":
|
pass # keep current
|
||||||
return 3 # Custom endpoint
|
|
||||||
if question == "Configure vision:":
|
|
||||||
return len(choices) - 1 # Skip
|
|
||||||
tts_idx = _maybe_keep_current_tts(question, choices)
|
|
||||||
if tts_idx is not None:
|
|
||||||
return tts_idx
|
|
||||||
raise AssertionError(f"Unexpected prompt_choice call: {question}")
|
|
||||||
|
|
||||||
# _model_flow_custom uses builtins.input (URL, key, model, context_length)
|
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
|
||||||
input_values = iter([
|
|
||||||
"http://localhost:8000",
|
|
||||||
"local-key",
|
|
||||||
"llm",
|
|
||||||
"", # context_length (blank = auto-detect)
|
|
||||||
])
|
|
||||||
monkeypatch.setattr("builtins.input", lambda _prompt="": next(input_values))
|
|
||||||
|
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
|
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False)
|
|
||||||
monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None)
|
|
||||||
monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
|
|
||||||
monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: [])
|
|
||||||
monkeypatch.setattr("hermes_cli.main._save_custom_provider", lambda *args, **kwargs: None)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"hermes_cli.models.probe_api_models",
|
|
||||||
lambda api_key, base_url: {
|
|
||||||
"models": ["llm"],
|
|
||||||
"probed_url": "http://localhost:8000/v1/models",
|
|
||||||
"resolved_base_url": "http://localhost:8000/v1",
|
|
||||||
"suggested_base_url": "http://localhost:8000/v1",
|
|
||||||
"used_fallback": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
setup_model_provider(config)
|
setup_model_provider(config)
|
||||||
|
save_config(config)
|
||||||
|
|
||||||
env = _read_env(tmp_path)
|
|
||||||
|
|
||||||
# _model_flow_custom saves config to disk (base_url in config, not .env)
|
|
||||||
reloaded = load_config()
|
reloaded = load_config()
|
||||||
model_cfg = reloaded.get("model", {})
|
assert isinstance(reloaded["model"], dict)
|
||||||
if isinstance(model_cfg, dict):
|
assert reloaded["model"]["provider"] == "zai"
|
||||||
assert model_cfg.get("provider") == "custom"
|
|
||||||
assert model_cfg.get("default") == "llm"
|
|
||||||
assert model_cfg.get("base_url") == "http://localhost:8000/v1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_setup_keep_current_config_provider_uses_provider_specific_model_menu(tmp_path, monkeypatch):
|
|
||||||
"""Keep-current should respect config-backed providers, not fall back to OpenRouter."""
|
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
||||||
_clear_provider_env(monkeypatch)
|
|
||||||
|
|
||||||
config = load_config()
|
|
||||||
config["model"] = {
|
|
||||||
"default": "claude-opus-4-6",
|
|
||||||
"provider": "anthropic",
|
|
||||||
}
|
|
||||||
save_config(config)
|
|
||||||
|
|
||||||
captured = {"provider_choices": None, "model_choices": None}
|
|
||||||
|
|
||||||
def fake_prompt_choice(question, choices, default=0):
|
|
||||||
if question == "Select your inference provider:":
|
|
||||||
captured["provider_choices"] = list(choices)
|
|
||||||
assert choices[-1] == "Keep current (Anthropic)"
|
|
||||||
return len(choices) - 1
|
|
||||||
if question == "Configure vision:":
|
|
||||||
assert question == "Configure vision:"
|
|
||||||
assert choices[-1] == "Skip for now"
|
|
||||||
return len(choices) - 1
|
|
||||||
if question == "Select default model:":
|
|
||||||
captured["model_choices"] = list(choices)
|
|
||||||
return len(choices) - 1 # keep current model
|
|
||||||
tts_idx = _maybe_keep_current_tts(question, choices)
|
|
||||||
if tts_idx is not None:
|
|
||||||
return tts_idx
|
|
||||||
raise AssertionError(f"Unexpected prompt_choice call: {question}")
|
|
||||||
|
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
|
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "")
|
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False)
|
|
||||||
monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None)
|
|
||||||
monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
|
|
||||||
monkeypatch.setattr("hermes_cli.models.provider_model_ids", lambda provider: [])
|
|
||||||
monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: [])
|
|
||||||
|
|
||||||
setup_model_provider(config)
|
|
||||||
save_config(config)
|
|
||||||
|
|
||||||
assert captured["provider_choices"] is not None
|
|
||||||
assert captured["model_choices"] is not None
|
|
||||||
assert captured["model_choices"][0] == "claude-opus-4-6"
|
|
||||||
assert "anthropic/claude-opus-4.6 (recommended)" not in captured["model_choices"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_setup_keep_current_anthropic_can_configure_openai_vision_default(tmp_path, monkeypatch):
|
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
||||||
_clear_provider_env(monkeypatch)
|
|
||||||
|
|
||||||
config = load_config()
|
|
||||||
config["model"] = {
|
|
||||||
"default": "claude-opus-4-6",
|
|
||||||
"provider": "anthropic",
|
|
||||||
}
|
|
||||||
save_config(config)
|
|
||||||
|
|
||||||
def fake_prompt_choice(question, choices, default=0):
|
|
||||||
if question == "Select your inference provider:":
|
|
||||||
assert choices[-1] == "Keep current (Anthropic)"
|
|
||||||
return len(choices) - 1
|
|
||||||
if question == "Configure vision:":
|
|
||||||
return 1
|
|
||||||
if question == "Select vision model:":
|
|
||||||
assert choices[-1] == "Use default (gpt-4o-mini)"
|
|
||||||
return len(choices) - 1
|
|
||||||
if question == "Select default model:":
|
|
||||||
assert choices[-1] == "Keep current (claude-opus-4-6)"
|
|
||||||
return len(choices) - 1
|
|
||||||
tts_idx = _maybe_keep_current_tts(question, choices)
|
|
||||||
if tts_idx is not None:
|
|
||||||
return tts_idx
|
|
||||||
raise AssertionError(f"Unexpected prompt_choice call: {question}")
|
|
||||||
|
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"hermes_cli.setup.prompt",
|
|
||||||
lambda message, *args, **kwargs: "sk-openai" if "OpenAI API key" in message else "",
|
|
||||||
)
|
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False)
|
|
||||||
monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None)
|
|
||||||
monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
|
|
||||||
monkeypatch.setattr("hermes_cli.models.provider_model_ids", lambda provider: [])
|
|
||||||
monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: [])
|
|
||||||
|
|
||||||
setup_model_provider(config)
|
|
||||||
env = _read_env(tmp_path)
|
|
||||||
|
|
||||||
assert env.get("OPENAI_API_KEY") == "sk-openai"
|
|
||||||
assert env.get("AUXILIARY_VISION_MODEL") == "gpt-4o-mini"
|
|
||||||
# Vision base URL saved to config.yaml, not .env
|
|
||||||
reloaded = load_config()
|
|
||||||
vision_cfg = reloaded.get("auxiliary", {}).get("vision", {})
|
|
||||||
assert vision_cfg.get("base_url") == "https://api.openai.com/v1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_setup_copilot_uses_gh_auth_and_saves_provider(tmp_path, monkeypatch):
|
def test_setup_copilot_uses_gh_auth_and_saves_provider(tmp_path, monkeypatch):
|
||||||
|
"""Copilot provider saves correctly through delegation."""
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
_clear_provider_env(monkeypatch)
|
_clear_provider_env(monkeypatch)
|
||||||
|
_stub_tts(monkeypatch)
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
def fake_prompt_choice(question, choices, default=0):
|
def fake_select():
|
||||||
if question == "Select your inference provider:":
|
_write_model_config("copilot", "https://models.github.ai/inference/v1", "gpt-4o")
|
||||||
assert choices[14] == "GitHub Copilot (uses GITHUB_TOKEN or gh auth token)"
|
|
||||||
return 14
|
|
||||||
if question == "Select default model:":
|
|
||||||
assert "gpt-4.1" in choices
|
|
||||||
assert "gpt-5.4" in choices
|
|
||||||
return choices.index("gpt-5.4")
|
|
||||||
if question == "Select reasoning effort:":
|
|
||||||
assert "low" in choices
|
|
||||||
assert "high" in choices
|
|
||||||
return choices.index("high")
|
|
||||||
if question == "Configure vision:":
|
|
||||||
return len(choices) - 1
|
|
||||||
tts_idx = _maybe_keep_current_tts(question, choices)
|
|
||||||
if tts_idx is not None:
|
|
||||||
return tts_idx
|
|
||||||
raise AssertionError(f"Unexpected prompt_choice call: {question}")
|
|
||||||
|
|
||||||
def fake_prompt(message, *args, **kwargs):
|
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
|
||||||
raise AssertionError(f"Unexpected prompt call: {message}")
|
|
||||||
|
|
||||||
def fake_get_auth_status(provider_id):
|
|
||||||
if provider_id == "copilot":
|
|
||||||
return {"logged_in": True}
|
|
||||||
return {"logged_in": False}
|
|
||||||
|
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
|
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt", fake_prompt)
|
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False)
|
|
||||||
monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None)
|
|
||||||
monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
|
|
||||||
monkeypatch.setattr("hermes_cli.auth.get_auth_status", fake_get_auth_status)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"hermes_cli.auth.resolve_api_key_provider_credentials",
|
|
||||||
lambda provider_id: {
|
|
||||||
"provider": provider_id,
|
|
||||||
"api_key": "gh-cli-token",
|
|
||||||
"base_url": "https://api.githubcopilot.com",
|
|
||||||
"source": "gh auth token",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"hermes_cli.models.fetch_github_model_catalog",
|
|
||||||
lambda api_key: [
|
|
||||||
{
|
|
||||||
"id": "gpt-4.1",
|
|
||||||
"capabilities": {"type": "chat", "supports": {}},
|
|
||||||
"supported_endpoints": ["/chat/completions"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "gpt-5.4",
|
|
||||||
"capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}},
|
|
||||||
"supported_endpoints": ["/responses"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: [])
|
|
||||||
|
|
||||||
setup_model_provider(config)
|
setup_model_provider(config)
|
||||||
save_config(config)
|
save_config(config)
|
||||||
|
|
||||||
env = _read_env(tmp_path)
|
|
||||||
reloaded = load_config()
|
reloaded = load_config()
|
||||||
|
assert isinstance(reloaded["model"], dict)
|
||||||
assert env.get("GITHUB_TOKEN") is None
|
|
||||||
assert reloaded["model"]["provider"] == "copilot"
|
assert reloaded["model"]["provider"] == "copilot"
|
||||||
assert reloaded["model"]["base_url"] == "https://api.githubcopilot.com"
|
|
||||||
assert reloaded["model"]["default"] == "gpt-5.4"
|
|
||||||
assert reloaded["model"]["api_mode"] == "codex_responses"
|
|
||||||
assert reloaded["agent"]["reasoning_effort"] == "high"
|
|
||||||
|
|
||||||
|
|
||||||
def test_setup_copilot_acp_uses_model_picker_and_saves_provider(tmp_path, monkeypatch):
|
def test_setup_copilot_acp_uses_model_picker_and_saves_provider(tmp_path, monkeypatch):
|
||||||
|
"""Copilot ACP provider saves correctly through delegation."""
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
_clear_provider_env(monkeypatch)
|
_clear_provider_env(monkeypatch)
|
||||||
|
_stub_tts(monkeypatch)
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
def fake_prompt_choice(question, choices, default=0):
|
def fake_select():
|
||||||
if question == "Select your inference provider:":
|
_write_model_config("copilot-acp", "", "claude-sonnet-4")
|
||||||
assert choices[15] == "GitHub Copilot ACP (spawns `copilot --acp --stdio`)"
|
|
||||||
return 15
|
|
||||||
if question == "Select default model:":
|
|
||||||
assert "gpt-4.1" in choices
|
|
||||||
assert "gpt-5.4" in choices
|
|
||||||
return choices.index("gpt-5.4")
|
|
||||||
if question == "Configure vision:":
|
|
||||||
return len(choices) - 1
|
|
||||||
tts_idx = _maybe_keep_current_tts(question, choices)
|
|
||||||
if tts_idx is not None:
|
|
||||||
return tts_idx
|
|
||||||
raise AssertionError(f"Unexpected prompt_choice call: {question}")
|
|
||||||
|
|
||||||
def fake_prompt(message, *args, **kwargs):
|
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
|
||||||
raise AssertionError(f"Unexpected prompt call: {message}")
|
|
||||||
|
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
|
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt", fake_prompt)
|
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False)
|
|
||||||
monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None)
|
|
||||||
monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
|
|
||||||
monkeypatch.setattr("hermes_cli.auth.get_auth_status", lambda provider_id: {"logged_in": provider_id == "copilot-acp"})
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"hermes_cli.auth.resolve_api_key_provider_credentials",
|
|
||||||
lambda provider_id: {
|
|
||||||
"provider": "copilot",
|
|
||||||
"api_key": "gh-cli-token",
|
|
||||||
"base_url": "https://api.githubcopilot.com",
|
|
||||||
"source": "gh auth token",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"hermes_cli.models.fetch_github_model_catalog",
|
|
||||||
lambda api_key: [
|
|
||||||
{
|
|
||||||
"id": "gpt-4.1",
|
|
||||||
"capabilities": {"type": "chat", "supports": {}},
|
|
||||||
"supported_endpoints": ["/chat/completions"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "gpt-5.4",
|
|
||||||
"capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}},
|
|
||||||
"supported_endpoints": ["/responses"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: [])
|
|
||||||
|
|
||||||
setup_model_provider(config)
|
setup_model_provider(config)
|
||||||
save_config(config)
|
save_config(config)
|
||||||
|
|
||||||
reloaded = load_config()
|
reloaded = load_config()
|
||||||
|
assert isinstance(reloaded["model"], dict)
|
||||||
assert reloaded["model"]["provider"] == "copilot-acp"
|
assert reloaded["model"]["provider"] == "copilot-acp"
|
||||||
assert reloaded["model"]["base_url"] == "acp://copilot"
|
|
||||||
assert reloaded["model"]["default"] == "gpt-5.4"
|
|
||||||
assert reloaded["model"]["api_mode"] == "chat_completions"
|
|
||||||
|
|
||||||
|
|
||||||
def test_setup_switch_custom_to_codex_clears_custom_endpoint_and_updates_config(tmp_path, monkeypatch):
|
def test_setup_switch_custom_to_codex_clears_custom_endpoint_and_updates_config(
|
||||||
"""Switching from custom to Codex should clear custom endpoint overrides."""
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""Switching from custom to codex updates config correctly."""
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
_clear_provider_env(monkeypatch)
|
_clear_provider_env(monkeypatch)
|
||||||
|
_stub_tts(monkeypatch)
|
||||||
|
|
||||||
save_env_value("OPENAI_BASE_URL", "https://example.invalid/v1")
|
# Start with custom
|
||||||
save_env_value("OPENAI_API_KEY", "sk-custom")
|
_write_model_config("custom", "http://localhost:11434/v1", "qwen3.5:32b")
|
||||||
save_env_value("OPENROUTER_API_KEY", "sk-or")
|
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
config["model"] = {
|
assert config["model"]["provider"] == "custom"
|
||||||
"default": "custom/model",
|
|
||||||
"provider": "custom",
|
|
||||||
"base_url": "https://example.invalid/v1",
|
|
||||||
}
|
|
||||||
save_config(config)
|
|
||||||
|
|
||||||
def fake_prompt_choice(question, choices, default=0):
|
def fake_select():
|
||||||
if question == "Select your inference provider:":
|
_write_model_config("openai-codex", "https://api.openai.com/v1", "gpt-4o")
|
||||||
return 2 # OpenAI Codex
|
|
||||||
if question == "Select default model:":
|
|
||||||
return 0
|
|
||||||
tts_idx = _maybe_keep_current_tts(question, choices)
|
|
||||||
if tts_idx is not None:
|
|
||||||
return tts_idx
|
|
||||||
raise AssertionError(f"Unexpected prompt_choice call: {question}")
|
|
||||||
|
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
|
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "")
|
|
||||||
monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False)
|
|
||||||
monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None)
|
|
||||||
monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
|
|
||||||
monkeypatch.setattr("hermes_cli.auth._login_openai_codex", lambda *args, **kwargs: None)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"hermes_cli.auth.resolve_codex_runtime_credentials",
|
|
||||||
lambda *args, **kwargs: {
|
|
||||||
"base_url": "https://chatgpt.com/backend-api/codex",
|
|
||||||
"api_key": "codex-...oken",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"hermes_cli.codex_models.get_codex_model_ids",
|
|
||||||
lambda **kwargs: ["openai/gpt-5.3-codex", "openai/gpt-5-codex-mini"],
|
|
||||||
)
|
|
||||||
|
|
||||||
setup_model_provider(config)
|
setup_model_provider(config)
|
||||||
save_config(config)
|
save_config(config)
|
||||||
|
|
||||||
env = _read_env(tmp_path)
|
|
||||||
reloaded = load_config()
|
reloaded = load_config()
|
||||||
|
assert isinstance(reloaded["model"], dict)
|
||||||
# OPENAI_BASE_URL is no longer written/cleared in .env — config is authoritative
|
|
||||||
assert reloaded["model"]["provider"] == "openai-codex"
|
assert reloaded["model"]["provider"] == "openai-codex"
|
||||||
assert reloaded["model"]["default"] == "openai/gpt-5.3-codex"
|
assert reloaded["model"]["default"] == "gpt-4o"
|
||||||
assert reloaded["model"]["base_url"] == "https://chatgpt.com/backend-api/codex"
|
|
||||||
|
|
||||||
|
|
||||||
def test_setup_summary_marks_codex_auth_as_vision_available(tmp_path, monkeypatch, capsys):
|
def test_setup_switch_preserves_non_model_config(tmp_path, monkeypatch):
|
||||||
|
"""Provider switch preserves other config sections (terminal, display, etc.)."""
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
_clear_provider_env(monkeypatch)
|
_clear_provider_env(monkeypatch)
|
||||||
|
_stub_tts(monkeypatch)
|
||||||
|
|
||||||
(tmp_path / "auth.json").write_text(
|
config = load_config()
|
||||||
'{"active_provider":"openai-codex","providers":{"openai-codex":{"tokens":{"access_token": "***", "refresh_token": "***"}}}}'
|
config["terminal"]["timeout"] = 999
|
||||||
)
|
save_config(config)
|
||||||
|
|
||||||
monkeypatch.setattr("shutil.which", lambda _name: None)
|
config = load_config()
|
||||||
|
|
||||||
_print_setup_summary(load_config(), tmp_path)
|
def fake_select():
|
||||||
output = capsys.readouterr().out
|
_write_model_config("openrouter", model_name="gpt-4o")
|
||||||
|
|
||||||
assert "Vision (image analysis)" in output
|
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
|
||||||
assert "missing run 'hermes setup' to configure" not in output
|
|
||||||
assert "Mixture of Agents" in output
|
|
||||||
assert "missing OPENROUTER_API_KEY" in output
|
|
||||||
|
|
||||||
|
setup_model_provider(config)
|
||||||
|
save_config(config)
|
||||||
|
|
||||||
def test_setup_summary_marks_anthropic_auth_as_vision_available(tmp_path, monkeypatch, capsys):
|
reloaded = load_config()
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
assert reloaded["terminal"]["timeout"] == 999
|
||||||
_clear_provider_env(monkeypatch)
|
assert reloaded["model"]["provider"] == "openrouter"
|
||||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key")
|
|
||||||
monkeypatch.setattr("shutil.which", lambda _name: None)
|
|
||||||
monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: ["anthropic"])
|
|
||||||
|
|
||||||
_print_setup_summary(load_config(), tmp_path)
|
|
||||||
output = capsys.readouterr().out
|
|
||||||
|
|
||||||
assert "Vision (image analysis)" in output
|
|
||||||
assert "missing run 'hermes setup' to configure" not in output
|
|
||||||
|
|||||||
Reference in New Issue
Block a user