Files
hermes-agent/tests/hermes_cli/test_cli_provider_resolution.py
ethernet 890bbbda1f Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	apps/desktop/e2e/archived-hidden-session-recoverable.spec.ts
#	apps/desktop/e2e/bot-chat-message-agent-friendly-name.spec.ts
#	apps/desktop/e2e/bot-mailbox-unreadable-ticket.spec.ts
#	apps/desktop/e2e/bot-mode-roster-localized.spec.ts
#	apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts
#	apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts
#	apps/desktop/e2e/bot-roster-group-row-organisation.spec.ts
#	apps/desktop/e2e/bot-roster-ignores-infra-dirs.spec.ts
#	apps/desktop/e2e/bot-roster-timestamp-meta.spec.ts
#	apps/desktop/e2e/bot-roster-user-sections.spec.ts
#	apps/desktop/e2e/bot-routines-pane-narrow.spec.ts
#	apps/desktop/e2e/bot-row-open-recent-session.spec.ts
#	apps/desktop/e2e/bot-tile-ignores-ambient-composer-model.spec.ts
#	apps/desktop/e2e/group-composer-auto-grow.spec.ts
#	apps/desktop/e2e/group-create-gate-remote-roster.spec.ts
#	apps/desktop/e2e/group-prompt-renamed-primary-handle.spec.ts
#	apps/desktop/e2e/hosted-room-backend-continuity.spec.ts
#	apps/desktop/e2e/hosted-room-legacy-store-migration.spec.ts
#	apps/desktop/e2e/settings-scope-chips-bot-title.spec.ts
#	apps/desktop/e2e/worktree-branch-status.spec.ts
#	apps/desktop/electron/backend-probes.test.ts
#	apps/desktop/electron/connection-apply.test.ts
#	apps/desktop/electron/desktop-electron-pin.test.ts
#	apps/desktop/electron/desktop-uninstall.test.ts
#	apps/desktop/electron/gateway-file-download-transport.test.ts
#	apps/desktop/electron/gateway-stop-before-update.test.ts
#	apps/desktop/electron/github-api-auth.test.ts
#	apps/desktop/electron/registry-primary-profile-scope.test.ts
#	apps/desktop/electron/update-api-check.test.ts
#	apps/desktop/electron/update-handoff-marker.test.ts
#	apps/desktop/electron/venv-blocker-scan.test.ts
#	apps/desktop/scripts/after-extract.test.mjs
#	apps/desktop/scripts/local-pack-publish.test.mjs
#	apps/desktop/scripts/tasks-scroll.test.mjs
#	apps/desktop/src/app/settings/model-settings.test.tsx
#	apps/desktop/src/app/updates-overlay.blockers.test.tsx
#	apps/desktop/src/components/desktop-install-overlay.test.tsx
#	apps/desktop/src/lib/update-copy.test.ts
#	scripts/ci/check_os_marker_fakes.py
#	tests-js/desktop-mac-usage-descriptions.test.ts
#	tests-js/node-engine-alignment.test.ts
#	tests/agent/lsp/test_install_and_lint_fixes.py
#	tests/agent/test_command_token_source.py
#	tests/agent/test_compression_boundary_hook.py
#	tests/agent/test_create_openai_client_ssl_verify.py
#	tests/agent/test_custom_provider_ca_probes.py
#	tests/agent/test_endpoint_blackhole.py
#	tests/agent/test_estimator_parity.py
#	tests/agent/test_in_place_compaction.py
#	tests/agent/test_moa_loop_mode.py
#	tests/agent/test_model_metadata.py
#	tests/agent/test_skill_session_platform_gate.py
#	tests/agent/test_skill_utils.py
#	tests/agent/test_ssl_ca_guard.py
#	tests/computer_use/test_doctor.py
#	tests/cron/test_codex_execution_paths.py
#	tests/cron/test_cron_bot_chat_delivery.py
#	tests/cron/test_cron_script.py
#	tests/cron/test_media_delivery_parity.py
#	tests/cron/test_misfire_catchup.py
#	tests/cron/test_parallel_pool.py
#	tests/cron/test_recurring_eagain_redispatch.py
#	tests/gateway/test_choice_picker.py
#	tests/gateway/test_control_socket_windows_live.py
#	tests/gateway/test_dingtalk.py
#	tests/gateway/test_feishu.py
#	tests/gateway/test_feishu_onboard.py
#	tests/gateway/test_gateway_shutdown.py
#	tests/gateway/test_matrix.py
#	tests/gateway/test_model_command_custom_providers.py
#	tests/gateway/test_reasoning_command.py
#	tests/gateway/test_runtime_footer.py
#	tests/gateway/test_session.py
#	tests/gateway/test_session_hygiene.py
#	tests/gateway/test_status.py
#	tests/gateway/test_teams.py
#	tests/gateway/test_turn_lease.py
#	tests/gateway/test_whatsapp_connect.py
#	tests/hermes_cli/test_approvals_command.py
#	tests/hermes_cli/test_auth_store_lock_concurrent.py
#	tests/hermes_cli/test_backup.py
#	tests/hermes_cli/test_banner_git_state.py
#	tests/hermes_cli/test_certifi_repair.py
#	tests/hermes_cli/test_cmd_update.py
#	tests/hermes_cli/test_compat_manifest_targets.py
#	tests/hermes_cli/test_computer_use_cli.py
#	tests/hermes_cli/test_cpr_local_leak.py
#	tests/hermes_cli/test_dashboard_auth_gate.py
#	tests/hermes_cli/test_dashboard_procs_kill_grace.py
#	tests/hermes_cli/test_desktop_lifecycle_windows_live.py
#	tests/hermes_cli/test_doctor.py
#	tests/hermes_cli/test_doctor_command_install.py
#	tests/hermes_cli/test_fleet_config_migration_windows_live.py
#	tests/hermes_cli/test_gateway.py
#	tests/hermes_cli/test_gateway_platform_gating.py
#	tests/hermes_cli/test_gateway_restart_loop.py
#	tests/hermes_cli/test_gateway_task_probe.py
#	tests/hermes_cli/test_gateway_wsl.py
#	tests/hermes_cli/test_gui_command.py
#	tests/hermes_cli/test_install_cua_driver.py
#	tests/hermes_cli/test_kanban_db.py
#	tests/hermes_cli/test_lazy_command_exports.py
#	tests/hermes_cli/test_lazy_refresh_venv_repair.py
#	tests/hermes_cli/test_linux_desktop_entry.py
#	tests/hermes_cli/test_local_runtime.py
#	tests/hermes_cli/test_local_runtime_updates.py
#	tests/hermes_cli/test_managed_uv.py
#	tests/hermes_cli/test_mcp_reload_confirm_gate.py
#	tests/hermes_cli/test_nous_subscription.py
#	tests/hermes_cli/test_npm_engine.py
#	tests/hermes_cli/test_personality_none.py
#	tests/hermes_cli/test_pet_toggle.py
#	tests/hermes_cli/test_plan_reconciliation_windows_live.py
#	tests/hermes_cli/test_plugin_event_bus.py
#	tests/hermes_cli/test_plugin_manifest_v2.py
#	tests/hermes_cli/test_plugin_packs.py
#	tests/hermes_cli/test_plugins_cmd.py
#	tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py
#	tests/hermes_cli/test_process_identity.py
#	tests/hermes_cli/test_profiles.py
#	tests/hermes_cli/test_profiles_sidebar_cache.py
#	tests/hermes_cli/test_pty_bridge.py
#	tests/hermes_cli/test_resolve_turn_limit.py
#	tests/hermes_cli/test_serve_runtime_inventory.py
#	tests/hermes_cli/test_session_vacuum_config.py
#	tests/hermes_cli/test_set_config_value.py
#	tests/hermes_cli/test_signal_handler_kanban_worker.py
#	tests/hermes_cli/test_slash_confirm_windows.py
#	tests/hermes_cli/test_stale_pid_guard.py
#	tests/hermes_cli/test_startup_fast_guards.py
#	tests/hermes_cli/test_status.py
#	tests/hermes_cli/test_telegram_managed_bot.py
#	tests/hermes_cli/test_tools_config.py
#	tests/hermes_cli/test_update_apply_shallow_count.py
#	tests/hermes_cli/test_update_autostash.py
#	tests/hermes_cli/test_update_concurrent_quarantine.py
#	tests/hermes_cli/test_update_fetch_failure_classifier.py
#	tests/hermes_cli/test_update_fleet_probe_resume_token.py
#	tests/hermes_cli/test_update_handoff_backend_reap.py
#	tests/hermes_cli/test_update_handoff_desktop_rebuild.py
#	tests/hermes_cli/test_update_head_moved_gate.py
#	tests/hermes_cli/test_update_host_obligation.py
#	tests/hermes_cli/test_update_import_guard.py
#	tests/hermes_cli/test_update_interrupted_recovery.py
#	tests/hermes_cli/test_update_inventory.py
#	tests/hermes_cli/test_update_launchd_unloaded_gateway.py
#	tests/hermes_cli/test_update_missing_configured_deps.py
#	tests/hermes_cli/test_update_modified_notice.py
#	tests/hermes_cli/test_update_multiplex_migration_hook.py
#	tests/hermes_cli/test_update_no_gateway_restart.py
#	tests/hermes_cli/test_update_orphan_backend_reap.py
#	tests/hermes_cli/test_update_parked_branch_guard.py
#	tests/hermes_cli/test_update_post_pull_syntax_guard.py
#	tests/hermes_cli/test_update_receipt.py
#	tests/hermes_cli/test_update_self_lock.py
#	tests/hermes_cli/test_update_shim_fail_closed.py
#	tests/hermes_cli/test_update_shim_self_lock.py
#	tests/hermes_cli/test_update_sqlite_remediation.py
#	tests/hermes_cli/test_update_stale_dashboard.py
#	tests/hermes_cli/test_update_stale_virtualenv.py
#	tests/hermes_cli/test_update_venv_health.py
#	tests/hermes_cli/test_update_venv_ownership_preflight.py
#	tests/hermes_cli/test_update_wedged_gateway.py
#	tests/hermes_cli/test_update_yes_flag.py
#	tests/hermes_cli/test_update_zip_two_phase.py
#	tests/hermes_cli/test_urllib_security.py
#	tests/hermes_cli/test_ux_messages_auth_config.py
#	tests/hermes_cli/test_ux_messages_startup.py
#	tests/hermes_cli/test_venv_holder_classifier.py
#	tests/hermes_cli/test_verify_console_scripts.py
#	tests/hermes_cli/test_verify_core_dependencies.py
#	tests/hermes_cli/test_web_server.py
#	tests/hermes_cli/test_web_server_console_ws.py
#	tests/hermes_cli/test_web_server_ws_ping.py
#	tests/hermes_cli/test_web_ui_build.py
#	tests/hermes_state/test_fts_rebuild_admission.py
#	tests/hermes_state/test_hermes_state.py
#	tests/plugins/memory/test_memory_lazy_install.py
#	tests/plugins/test_google_meet_plugin.py
#	tests/plugins/test_langfuse_plugin.py
#	tests/plugins/test_security_guidance_plugin.py
#	tests/plugins/test_transform_llm_output_hook.py
#	tests/scripts/desktop_update/test_desktop_update_windows_gateway_flag.py
#	tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py
#	tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py
#	tests/scripts/install/test_install_clone_throttle_fallback.py
#	tests/scripts/install/test_install_lockfile_churn.py
#	tests/scripts/install/test_install_no_initial_commit.py
#	tests/scripts/install/test_install_sh_browser_install.py
#	tests/scripts/install/test_install_sh_node_prerelease.py
#	tests/scripts/install/test_install_sh_symlink_stomp.py
#	tests/scripts/install/test_install_sh_uv_lock_config.py
#	tests/scripts/install/test_install_unmerged_index.py
#	tests/scripts/test_contributor_map.py
#	tests/scripts/test_run_tests_parallel.py
#	tests/skills/test_competitor_news_monitor_skill.py
#	tests/skills/test_document_to_action_items_skill.py
#	tests/skills/test_google_workspace_setup.py
#	tests/skills/test_google_workspace_setup_deps.py
#	tests/skills/test_grounded_citations_skill.py
#	tests/skills/test_ip_as_logo_skill.py
#	tests/skills/test_live_dashboard_skill.py
#	tests/skills/test_mcp_oauth_remote_gateway_skill.py
#	tests/skills/test_office_document_skills.py
#	tests/skills/test_openclaw_migration.py
#	tests/skills/test_product_price_monitor_skill.py
#	tests/skills/test_scrollcraft_skill.py
#	tests/skills/test_setup_wizard_generator_skill.py
#	tests/skills/test_weekly_review_planning_skill.py
#	tests/test_engines_satisfiable.py
#	tests/test_fast_safe_load.py
#	tests/test_hermes_bootstrap.py
#	tests/test_hermes_constants.py
#	tests/test_hermes_logging.py
#	tests/test_managed_runtime_resolution.py
#	tests/test_model_tools_async_bridge.py
#	tests/test_packaging_build_guard.py
#	tests/test_packaging_metadata.py
#	tests/test_yaml_indent_consistency.py
#	tests/tools/test_approval_timeout_overflow.py
#	tests/tools/test_base_environment.py
#	tests/tools/test_bot_mode_dm.py
#	tests/tools/test_browser_chromium_check.py
#	tests/tools/test_browser_hardening.py
#	tests/tools/test_browser_homebrew_paths.py
#	tests/tools/test_browser_npx_warmup.py
#	tests/tools/test_browser_orphan_reaper.py
#	tests/tools/test_browser_real_profile.py
#	tests/tools/test_browser_use_cli.py
#	tests/tools/test_clipboard.py
#	tests/tools/test_code_execution.py
#	tests/tools/test_code_execution_modes.py
#	tests/tools/test_code_execution_windows_env.py
#	tests/tools/test_computer_use.py
#	tests/tools/test_delegate_liveness_timeout.py
#	tests/tools/test_execute_code_approval_cluster.py
#	tests/tools/test_execution_flag_detection.py
#	tests/tools/test_fal_common.py
#	tests/tools/test_file_operations.py
#	tests/tools/test_file_tools.py
#	tests/tools/test_file_tools_cwd_resolution.py
#	tests/tools/test_file_tools_live.py
#	tests/tools/test_lazy_deps.py
#	tests/tools/test_lazy_deps_durable_target.py
#	tests/tools/test_lazy_deps_managed.py
#	tests/tools/test_local_env_blocklist.py
#	tests/tools/test_local_tempdir.py
#	tests/tools/test_macos_protected_search.py
#	tests/tools/test_mcp_npx_cached_bin.py
#	tests/tools/test_oneshot_completion_linger.py
#	tests/tools/test_process_registry.py
#	tests/tools/test_read_file_schema_gating.py
#	tests/tools/test_skill_improvements.py
#	tests/tools/test_skills_sync.py
#	tests/tools/test_termux_api_detection.py
#	tests/tools/test_tirith_security.py
#	tests/tools/test_transcription_tools.py
#	tests/tools/test_tts_streaming.py
#	tests/tools/test_wake_word.py
#	tests/tui_gateway/test_compute_host_borrowed_lease.py
#	tests/tui_gateway/test_compute_host_turn_protocol.py
#	tests/tui_gateway/test_isolated_orphan_activity.py
#	tests/tui_gateway/test_protocol.py
#	tests/tui_gateway/test_slash_worker_profile_home.py
#	tests/tui_gateway/test_subprocess_encoding.py
#	tests/tui_gateway/test_tui_gateway_server.py
#	ui-tui/src/__tests__/terminalParity.test.ts
#	ui-tui/src/__tests__/termuxComposerLayout.test.ts
#	ui-tui/src/__tests__/textInputFastEcho.test.ts
2026-09-23 07:02:44 -04:00

884 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import importlib
import sys
import types
from contextlib import nullcontext
from types import SimpleNamespace
import pytest
from hermes_cli.auth import AuthError
from hermes_cli import main as hermes_main
import hermes_cli.main_provider_setup as hermes_cli_main_provider_setup
from hermes_cli import model_switch
# ---------------------------------------------------------------------------
# Module isolation: _import_cli() wipes tools.* / cli / run_agent from
# sys.modules so it can re-import cli fresh. Without cleanup the wiped
# modules leak into subsequent tests, breaking
# mock patches that target "tools.file_tools._get_file_ops" etc.
# ---------------------------------------------------------------------------
def _reset_modules(prefixes: tuple[str, ...]):
for name in list(sys.modules):
if any(name == p or name.startswith(p + ".") for p in prefixes):
sys.modules.pop(name, None)
@pytest.fixture(autouse=True)
def _restore_cli_and_tool_modules():
"""Save and restore tools/cli/run_agent modules around every test."""
prefixes = ("tools", "cli", "run_agent")
original_modules = {
name: module
for name, module in sys.modules.items()
if any(name == p or name.startswith(p + ".") for p in prefixes)
}
try:
yield
finally:
_reset_modules(prefixes)
sys.modules.update(original_modules)
def _install_prompt_toolkit_stubs():
class _Dummy:
def __init__(self, *args, **kwargs):
pass
class _Condition:
def __init__(self, func):
self.func = func
def __bool__(self):
return bool(self.func())
class _ANSI(str):
pass
root = types.ModuleType("prompt_toolkit")
history = types.ModuleType("prompt_toolkit.history")
styles = types.ModuleType("prompt_toolkit.styles")
patch_stdout = types.ModuleType("prompt_toolkit.patch_stdout")
application = types.ModuleType("prompt_toolkit.application")
layout = types.ModuleType("prompt_toolkit.layout")
processors = types.ModuleType("prompt_toolkit.layout.processors")
filters = types.ModuleType("prompt_toolkit.filters")
dimension = types.ModuleType("prompt_toolkit.layout.dimension")
menus = types.ModuleType("prompt_toolkit.layout.menus")
widgets = types.ModuleType("prompt_toolkit.widgets")
key_binding = types.ModuleType("prompt_toolkit.key_binding")
completion = types.ModuleType("prompt_toolkit.completion")
formatted_text = types.ModuleType("prompt_toolkit.formatted_text")
history.FileHistory = _Dummy
styles.Style = _Dummy
patch_stdout.patch_stdout = lambda *args, **kwargs: nullcontext()
application.Application = _Dummy
layout.Layout = _Dummy
layout.HSplit = _Dummy
layout.Window = _Dummy
layout.FormattedTextControl = _Dummy
layout.ConditionalContainer = _Dummy
processors.Processor = _Dummy
processors.Transformation = _Dummy
processors.PasswordProcessor = _Dummy
processors.ConditionalProcessor = _Dummy
filters.Condition = _Condition
dimension.Dimension = _Dummy
menus.CompletionsMenu = _Dummy
widgets.TextArea = _Dummy
key_binding.KeyBindings = _Dummy
completion.Completer = _Dummy
completion.Completion = _Dummy
formatted_text.ANSI = _ANSI
root.print_formatted_text = lambda *args, **kwargs: None
sys.modules.setdefault("prompt_toolkit", root)
sys.modules.setdefault("prompt_toolkit.history", history)
sys.modules.setdefault("prompt_toolkit.styles", styles)
sys.modules.setdefault("prompt_toolkit.patch_stdout", patch_stdout)
sys.modules.setdefault("prompt_toolkit.application", application)
sys.modules.setdefault("prompt_toolkit.layout", layout)
sys.modules.setdefault("prompt_toolkit.layout.processors", processors)
sys.modules.setdefault("prompt_toolkit.filters", filters)
sys.modules.setdefault("prompt_toolkit.layout.dimension", dimension)
sys.modules.setdefault("prompt_toolkit.layout.menus", menus)
sys.modules.setdefault("prompt_toolkit.widgets", widgets)
sys.modules.setdefault("prompt_toolkit.key_binding", key_binding)
sys.modules.setdefault("prompt_toolkit.completion", completion)
sys.modules.setdefault("prompt_toolkit.formatted_text", formatted_text)
def _import_cli():
for name in list(sys.modules):
if name == "cli" or name == "run_agent" or name == "tools" or name.startswith("tools."):
sys.modules.pop(name, None)
if "firecrawl" not in sys.modules:
sys.modules["firecrawl"] = types.SimpleNamespace(Firecrawl=object)
try:
importlib.import_module("prompt_toolkit")
except ModuleNotFoundError:
_install_prompt_toolkit_stubs()
return importlib.import_module("cli")
def test_provider_flag_uses_named_custom_default_model(monkeypatch):
"""`--provider <custom>` without `-m` uses that entry's default_model (#86978)."""
cli = _import_cli()
monkeypatch.setitem(
cli.CLI_CONFIG,
"model",
{"default": "tencent/hy3:free", "provider": "nous"},
)
config = {
"model": {"default": "tencent/hy3:free", "provider": "nous"},
"providers": {
"gmk-lan": {
"name": "GMK Local",
"base_url": "http://gmk.lan:9931/v1",
"api_key": "not-needed",
"default_model": "/models/gemma.gguf",
}
},
}
monkeypatch.setattr("hermes_cli.config.load_config", lambda: config)
monkeypatch.setattr("hermes_cli.runtime_provider.load_config", lambda: config)
shell = cli.HermesCLI(provider="gmk-lan", compact=True, max_turns=1)
assert shell.model == "/models/gemma.gguf"
assert shell.requested_provider == "gmk-lan"
def test_explicit_model_wins_over_provider_default_model(monkeypatch):
"""`-m` still wins when `--provider` also names a custom default_model."""
cli = _import_cli()
monkeypatch.setitem(
cli.CLI_CONFIG,
"model",
{"default": "tencent/hy3:free", "provider": "nous"},
)
config = {
"model": {"default": "tencent/hy3:free", "provider": "nous"},
"providers": {
"gmk-lan": {
"name": "GMK Local",
"base_url": "http://gmk.lan:9931/v1",
"api_key": "not-needed",
"default_model": "/models/gemma.gguf",
}
},
}
monkeypatch.setattr("hermes_cli.config.load_config", lambda: config)
monkeypatch.setattr("hermes_cli.runtime_provider.load_config", lambda: config)
shell = cli.HermesCLI(
provider="gmk-lan",
model="explicit-id",
compact=True,
max_turns=1,
)
assert shell.model == "explicit-id"
@pytest.mark.parametrize(
("explicit_base_url", "expected_base_url"),
[
(None, "http://alias.example:8000/v1"),
("http://override.example:9000/v1", "http://override.example:9000/v1"),
],
)
def test_startup_alias_base_url_reaches_runtime_resolution(
monkeypatch,
explicit_base_url,
expected_base_url,
):
"""Startup aliases keep their endpoint unless --base-url overrides it (#103933)."""
cli = _import_cli()
monkeypatch.setitem(
cli.CLI_CONFIG,
"model",
{
"default": "fallback-model",
"provider": "openrouter",
"base_url": "https://openrouter.ai/api/v1",
},
)
monkeypatch.setattr(
model_switch,
"DIRECT_ALIASES",
{
"myalias": model_switch.DirectAlias(
"my-model-id",
"custom",
"http://alias.example:8000/v1",
api_key="not-needed",
),
},
)
shell = cli.HermesCLI(
model="myalias",
base_url=explicit_base_url,
compact=True,
max_turns=1,
)
assert shell._ensure_runtime_credentials() is True
assert shell.model == "my-model-id"
assert shell.provider == "custom"
assert shell.base_url == expected_base_url
def test_provider_flag_logs_when_custom_default_model_cannot_resolve(monkeypatch, caplog):
"""A named --provider that fails to resolve must not fail silently."""
cli = _import_cli()
monkeypatch.setitem(
cli.CLI_CONFIG,
"model",
{"default": "tencent/hy3:free", "provider": "nous"},
)
def _boom(_name):
raise RuntimeError("catalog unavailable")
monkeypatch.setattr(
"hermes_cli.runtime_provider._get_named_custom_provider",
_boom,
)
with caplog.at_level("WARNING"):
shell = cli.HermesCLI(provider="gmk-lan", compact=True, max_turns=1)
assert shell.model == "tencent/hy3:free"
assert any(
"gmk-lan" in rec.getMessage() and "catalog unavailable" in rec.getMessage()
for rec in caplog.records
)
def test_runtime_resolution_failure_is_not_sticky(monkeypatch):
cli = _import_cli()
calls = {"count": 0}
def _runtime_resolve(**kwargs):
calls["count"] += 1
if calls["count"] == 1:
raise RuntimeError("temporary auth failure")
return {
"provider": "openrouter",
"api_mode": "chat_completions",
"base_url": "https://openrouter.ai/api/v1",
"api_key": "test-key",
"source": "env/config",
}
class _DummyAgent:
def __init__(self, *args, **kwargs):
self.kwargs = kwargs
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve)
monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc))
monkeypatch.setattr("run_agent.AIAgent", _DummyAgent)
shell = cli.HermesCLI(model="gpt-5", compact=True, max_turns=1)
assert shell._init_agent() is False
assert shell._init_agent() is True
assert calls["count"] == 2
assert shell.agent is not None
def test_ensure_runtime_credentials_passes_cli_model_as_target_model(monkeypatch):
"""`hermes -m mimo-v2.5 --provider opencode-go` must resolve credentials for the model the
CLI will send: the Zen/Go rungs key off the effective model, and without target_model a
`*-free` config default decides the api_mode/base_url for an explicit paid model (#112600)."""
cli = _import_cli()
seen = {}
def _runtime_resolve(**kwargs):
seen.update(kwargs)
return {
"provider": "opencode-go",
"api_mode": "chat_completions",
"base_url": "https://opencode.ai/zen/go/v1",
"api_key": "test-key",
"source": "env",
}
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve)
shell = cli.HermesCLI(model="mimo-v2.5", provider="opencode-go", compact=True, max_turns=1)
assert shell._ensure_runtime_credentials() is True
assert seen["requested"] == "opencode-go"
assert seen["target_model"] == "mimo-v2.5"
def test_fallback_runtime_resolves_the_fallback_entry_model(monkeypatch, tmp_path):
"""The auth-fallback rung must resolve credentials for the ENTRY's model, exactly like the
primary path does for `-m`: a `*-free` config default must not decide the api_mode/base_url
a Go-only fallback entry is built with (#112600)."""
from hermes_cli.auth import AuthError
from hermes_cli.cli_agent_setup_mixin import CLIAgentSetupMixin
home = tmp_path / "hermes"
home.mkdir()
(home / "config.yaml").write_text(
"model:\n default: mimo-v2.5-free\n provider: opencode\n base_url: https://opencode.ai/zen/v1\n")
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("OPENCODE_GO_API_KEY", "sk-test-go")
monkeypatch.setattr("cli._cprint", lambda *a, **k: None, raising=False)
shell = CLIAgentSetupMixin.__new__(CLIAgentSetupMixin)
shell._fallback_model = [{"provider": "opencode-go", "model": "mimo-v2.5"}]
runtime = shell._resolve_fallback_runtime(AuthError("no key", provider="opencode-zen", code="missing_api_key"))
assert runtime is not None
assert shell.model == "mimo-v2.5"
assert runtime["base_url"] == "https://opencode.ai/zen/go/v1"
def _quota_auth_error():
from hermes_cli.auth import CODEX_RATE_LIMITED_CODE, AuthError
return AuthError(
"Codex provider quota exhausted (429); retry after 1839s. Credentials are still valid.",
provider="openai-codex",
code=CODEX_RATE_LIMITED_CODE,
relogin_required=False,
)
@pytest.mark.parametrize(("exc_factory", "expected", "absent"), [
(_quota_auth_error, "quota exhausted", "auth failed"),
(lambda: __import__("hermes_cli.auth", fromlist=["AuthError"]).AuthError(
"no key", provider="openai-codex", code="missing_api_key"), "Primary auth failed", "quota exhausted"),
])
def test_fallback_runtime_labels_quota_outage_and_bad_credentials_distinctly(monkeypatch, tmp_path, exc_factory, expected, absent):
"""A 429 at credential resolution is quota, not bad credentials (#117482); a real
credential failure keeps the auth-failed wording."""
from hermes_cli.cli_agent_setup_mixin import CLIAgentSetupMixin
home = tmp_path / "hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
printed = []
monkeypatch.setattr("cli._cprint", printed.append, raising=False)
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda **kw: {"provider": "custom", "base_url": "http://x/v1", "api_key": "k"},
)
monkeypatch.setattr("hermes_cli.fallback_config.resolve_entry_api_key", lambda entry: "k")
shell = CLIAgentSetupMixin.__new__(CLIAgentSetupMixin)
shell._fallback_model = [{"provider": "custom", "model": "local-model"}]
runtime = shell._resolve_fallback_runtime(exc_factory())
assert runtime is not None
assert printed
assert expected in printed[-1]
assert absent not in printed[-1]
def test_ensure_runtime_credentials_records_quota_vs_bad_key(monkeypatch, tmp_path):
"""Kanban workers need this flag: a quota wall at startup is not a worker failure (#117482)."""
from hermes_cli.auth import AuthError
from hermes_cli.cli_agent_setup_mixin import CLIAgentSetupMixin
home = tmp_path / "hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr("cli._cprint", lambda *a, **k: None, raising=False)
def _raise_quota(**kw):
raise _quota_auth_error()
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _raise_quota)
quota_shell = CLIAgentSetupMixin.__new__(CLIAgentSetupMixin)
quota_shell.model = "gpt-x"
quota_shell.requested_provider = "openai-codex"
quota_shell._explicit_api_key = None
quota_shell._explicit_base_url = None
quota_shell._fallback_model = []
quota_shell.tool_progress_mode = "off"
assert quota_shell._ensure_runtime_credentials() is False
assert quota_shell._credentials_rate_limited is True
def _raise_missing(**kw):
raise AuthError("no key", provider="openai-codex", code="missing_api_key")
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _raise_missing)
bad_shell = CLIAgentSetupMixin.__new__(CLIAgentSetupMixin)
bad_shell.model = "gpt-x"
bad_shell.requested_provider = "openai-codex"
bad_shell._explicit_api_key = None
bad_shell._explicit_base_url = None
bad_shell._fallback_model = []
bad_shell.tool_progress_mode = "off"
assert bad_shell._ensure_runtime_credentials() is False
assert bad_shell._credentials_rate_limited is False
def test_model_flow_nous_does_not_restore_stale_custom_api_key(tmp_path, monkeypatch):
import hermes_yaml as yaml
config_home = tmp_path / "hermes"
config_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(config_home))
config_path = config_home / "config.yaml"
config_path.write_text(
yaml.safe_dump(
{
"model": {
"provider": "custom",
"default": "glm-5.2",
"base_url": "https://api.neuralwatt.com/v1",
"api_key": "${NEURALWATT_API_KEY}",
"api_mode": "chat_completions",
}
},
sort_keys=False,
)
)
stale_config = yaml.safe_load(config_path.read_text()) or {}
selected_model = "deepseek/deepseek-v4-flash"
monkeypatch.setattr(
"hermes_cli.auth.get_provider_auth_state",
lambda provider: {
"access_token": "nous-token",
"portal_base_url": "https://portal.example.com",
},
)
monkeypatch.setattr(
"hermes_cli.auth.resolve_nous_runtime_credentials",
lambda *args, **kwargs: {
"base_url": "https://inference-api.nousresearch.com/v1",
"api_key": "nous-key",
},
)
monkeypatch.setattr(
"hermes_cli.models.get_curated_nous_model_ids",
lambda: [selected_model],
)
monkeypatch.setattr("hermes_cli.models_pricing.get_pricing_for_provider", lambda provider: {})
monkeypatch.setattr("hermes_cli.models.check_nous_free_tier", lambda **kwargs: False)
monkeypatch.setattr(
"hermes_cli.models.union_with_portal_paid_recommendations",
lambda model_ids, pricing, portal_url: (model_ids, pricing),
)
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda *args, **kwargs: selected_model,
)
monkeypatch.setattr(
"hermes_cli.nous_subscription.prompt_enable_tool_gateway",
lambda config: None,
)
hermes_main._model_flow_nous(stale_config, current_model="glm-5.2")
config = yaml.safe_load(config_path.read_text()) or {}
model = config.get("model")
assert model["provider"] == "nous"
assert model["default"] == selected_model
assert model["base_url"] == "https://inference-api.nousresearch.com/v1"
assert "api_key" not in model
assert "api_mode" not in model
def _seed_stale_custom_model(tmp_path, monkeypatch):
import hermes_yaml as yaml
config_home = tmp_path / "hermes"
config_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(config_home))
config_path = config_home / "config.yaml"
config_path.write_text(
yaml.safe_dump(
{
"model": {
"provider": "custom",
"default": "glm-5.2",
"base_url": "https://api.neuralwatt.com/v1",
"api_key": "${NEURALWATT_API_KEY}",
"api": "legacy-stale-key",
"api_mode": "anthropic_messages",
}
},
sort_keys=False,
)
)
(config_home / ".env").write_text("")
return config_path
def test_codex_provider_uses_config_model(monkeypatch):
"""Model comes from config.yaml, not LLM_MODEL env var.
Config.yaml is the single source of truth to avoid multi-agent conflicts."""
cli = _import_cli()
# LLM_MODEL env var should be IGNORED (even if set)
monkeypatch.setenv("LLM_MODEL", "should-be-ignored")
monkeypatch.delenv("OPENAI_MODEL", raising=False)
# Set model via config
monkeypatch.setitem(cli.CLI_CONFIG, "model", {
"default": "gpt-5.2-codex",
"provider": "openai-codex",
"base_url": "https://chatgpt.com/backend-api/codex",
})
def _runtime_resolve(**kwargs):
return {
"provider": "openai-codex",
"api_mode": "codex_responses",
"base_url": "https://chatgpt.com/backend-api/codex",
"api_key": "fake-codex-token",
"source": "env/config",
}
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve)
monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc))
# Prevent live API call from overriding the config model
monkeypatch.setattr(
"hermes_cli.codex_models.get_codex_model_ids",
lambda access_token=None: ["gpt-5.2-codex"],
)
shell = cli.HermesCLI(compact=True, max_turns=1)
assert shell._ensure_runtime_credentials() is True
assert shell.provider == "openai-codex"
# Model from config (may be normalized by codex provider logic)
assert "codex" in shell.model.lower()
# LLM_MODEL env var is NOT used
assert shell.model != "should-be-ignored"
@pytest.mark.parametrize(
("reasoning_flag", "expected_effort"),
[(None, "high"), ("low", "low")],
ids=["fallback_model_override_applies", "explicit_cli_flag_outranks"],
)
def test_startup_fallback_re_resolves_reasoning_for_the_fallback_model(monkeypatch, reasoning_flag, expected_effort):
"""Startup auth fallback swaps the model, so the CLI-level reasoning_config must follow it
(per-model override for the fallback model, not the launch model's effort); an explicit
``--reasoning`` is the user's intent for this run and survives the swap."""
cli = _import_cli()
monkeypatch.setattr(cli, "_cprint", lambda *a, **k: None)
monkeypatch.setitem(cli.CLI_CONFIG, "model", {"default": "primary-model", "provider": "openai-codex"})
monkeypatch.setitem(cli.CLI_CONFIG, "fallback_providers", [{"provider": "zai", "model": "glm-5.3-flash"}])
monkeypatch.setitem(cli.CLI_CONFIG, "agent", {
**cli.CLI_CONFIG.get("agent", {}), "reasoning_effort": "medium",
"reasoning_overrides": {"glm-5.3-flash": "high"}})
def _runtime_resolve(requested=None, **kwargs):
if requested == "openai-codex":
raise AuthError("quota exhausted", provider="openai-codex", code="auth_failed")
return {"provider": "zai", "api_mode": "chat_completions",
"base_url": "https://api.z.ai/api/coding/paas/v4", "api_key": "sk-zai", "source": "env"}
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve)
shell = cli.HermesCLI(compact=True, max_turns=1, reasoning=reasoning_flag)
assert shell.reasoning_config["effort"] == ("medium" if reasoning_flag is None else reasoning_flag)
assert shell._ensure_runtime_credentials() is True
assert (shell.model, shell.provider) == ("glm-5.3-flash", "zai")
assert shell.reasoning_config["effort"] == expected_effort
def test_custom_entry_model_swap_re_resolves_reasoning(monkeypatch):
"""`hermes chat --model <custom-provider-name>`: the runtime's explicit `model` replaces the
slug, so the CLI-level reasoning_config must follow to that model's per-model override."""
cli = _import_cli()
monkeypatch.setattr(cli, "_cprint", lambda *a, **k: None)
monkeypatch.setitem(cli.CLI_CONFIG, "agent", {
**cli.CLI_CONFIG.get("agent", {}), "reasoning_effort": "medium",
"reasoning_overrides": {"real-model": "high"}})
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda **kw: {"provider": "custom", "name": "my-lan", "model": "real-model", "api_mode": "chat_completions",
"base_url": "http://10.0.0.7:11434/v1", "api_key": "sk-lan", "source": "custom"})
shell = cli.HermesCLI(model="my-lan", compact=True, max_turns=1)
assert shell.reasoning_config["effort"] == "medium"
assert shell._ensure_runtime_credentials() is True
assert shell.model == "real-model"
assert shell.reasoning_config["effort"] == "high"
def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch):
monkeypatch.setattr(
"hermes_cli.config.get_env_value",
lambda key: "" if key in {"OPENAI_BASE_URL", "OPENAI_API_KEY"} else "",
)
saved_env = {}
monkeypatch.setattr("hermes_cli.config.save_env_value", lambda key, value: saved_env.__setitem__(key, value))
monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: saved_env.__setitem__("MODEL", model))
monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda: None)
monkeypatch.setattr("hermes_cli.main_provider_setup._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,
},
)
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"model": {"default": "", "provider": "custom", "base_url": ""}},
)
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None)
# After the probe detects a single model ("llm"), the flow asks
# "Use this model? [Y/n]:" — confirm with Enter, then context length,
# then display name. The api_mode prompt also runs before model selection.
answers = iter(["http://localhost:8000", "local-key", "", "", "", "", ""])
monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers))
monkeypatch.setattr("hermes_cli.secret_prompt.masked_secret_prompt", lambda _prompt="": next(answers))
caller_cfg = {}
hermes_main._model_flow_custom(caller_cfg)
assert caller_cfg["model"]["base_url"] == "http://localhost:8000/v1"
# OPENAI_BASE_URL is no longer saved to .env — config.yaml is authoritative
assert "OPENAI_BASE_URL" not in saved_env
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,
},
)
saved_env = {}
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.config.save_env_value",
lambda key, value: saved_env.__setitem__(key, value),
)
monkeypatch.setattr(
"hermes_cli.main_provider_setup._save_custom_provider",
lambda base_url, api_key="", model="", context_length=None, name=None, api_mode=None, key_env="": captured_provider.update(
{
"base_url": base_url,
"api_key": api_key,
"model": model,
"context_length": context_length,
"name": name,
"api_mode": api_mode,
"key_env": key_env,
}
),
)
answers = iter(
[
"https://codex.example.com/v1",
"3",
"chosen-model",
"",
"",
]
)
monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers))
monkeypatch.setattr("hermes_cli.secret_prompt.masked_secret_prompt", 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_mode"] == "codex_responses"
assert captured_provider["api_mode"] == "codex_responses"
# The key itself goes to .env; config.yaml only references it (#69449).
key_env = captured_provider["key_env"]
assert saved_cfg["model"]["api_key"] == f"${{{key_env}}}"
assert saved_env[key_env] == "test-key"
def test_cmd_model_forwards_nous_login_tls_options(monkeypatch):
monkeypatch.setattr(hermes_main, "_require_tty", lambda *a: None)
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"model": {"default": "gpt-5", "provider": "nous"}},
)
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None)
monkeypatch.setattr("hermes_cli.config.get_env_value", lambda key: "")
monkeypatch.setattr("hermes_cli.config.save_env_value", lambda key, value: None)
monkeypatch.setattr("hermes_cli.auth.resolve_provider", lambda requested, **kwargs: "nous")
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider_id: None)
monkeypatch.setattr(hermes_main, "_prompt_provider_choice", lambda choices, **kwargs: 0)
monkeypatch.setattr(hermes_cli_main_provider_setup, "_prompt_provider_choice", lambda choices, **kwargs: 0)
captured = {}
def _fake_login(login_args, provider_config):
captured["portal_url"] = login_args.portal_url
captured["inference_url"] = login_args.inference_url
captured["client_id"] = login_args.client_id
captured["scope"] = login_args.scope
captured["no_browser"] = login_args.no_browser
captured["timeout"] = login_args.timeout
captured["ca_bundle"] = login_args.ca_bundle
captured["insecure"] = login_args.insecure
monkeypatch.setattr("hermes_cli.auth._login_nous", _fake_login)
hermes_main.cmd_model(
SimpleNamespace(
portal_url="https://portal.nousresearch.com",
inference_url="https://inference.nousresearch.com/v1",
client_id="hermes-local",
scope="openid profile",
no_browser=True,
timeout=7.5,
ca_bundle="/tmp/local-ca.pem",
insecure=True,
)
)
assert captured == {
"portal_url": "https://portal.nousresearch.com",
"inference_url": "https://inference.nousresearch.com/v1",
"client_id": "hermes-local",
"scope": "openid profile",
"no_browser": True,
"timeout": 7.5,
"ca_bundle": "/tmp/local-ca.pem",
"insecure": True,
}
# ---------------------------------------------------------------------------
# _auto_provider_name — unit tests
# ---------------------------------------------------------------------------
def test_save_custom_provider_uses_provided_name(monkeypatch, tmp_path):
"""When a display name is passed, it should appear in the saved entry."""
import hermes_yaml as yaml
from hermes_cli.main_provider_setup import _save_custom_provider
cfg_path = tmp_path / "config.yaml"
cfg_path.write_text(yaml.safe_dump({}))
monkeypatch.setattr(
"hermes_cli.config.load_config", lambda: yaml.safe_load(cfg_path.read_text()) or {},
)
saved = {}
def _save(cfg):
saved.update(cfg)
monkeypatch.setattr("hermes_cli.config.save_config", _save)
_save_custom_provider("http://localhost:11434/v1", name="Ollama")
entries = saved.get("custom_providers", [])
assert len(entries) == 1
assert entries[0]["name"] == "Ollama"
def test_save_custom_provider_references_the_key_instead_of_inlining_it(monkeypatch, tmp_path):
"""With key_env set the entry must not carry the secret (#69449)."""
import hermes_yaml as yaml
from hermes_cli.main_provider_setup import _save_custom_provider
cfg_path = tmp_path / "config.yaml"
cfg_path.write_text(yaml.safe_dump({}))
monkeypatch.setattr(
"hermes_cli.config.load_config", lambda: yaml.safe_load(cfg_path.read_text()) or {},
)
saved = {}
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: saved.update(cfg))
_save_custom_provider(
"http://localhost:11434/v1",
api_key="sk-secret",
name="Ollama",
key_env="HERMES_CUSTOM_LOCALHOST_11434_API_KEY",
)
entry = saved["custom_providers"][0]
assert entry["key_env"] == "HERMES_CUSTOM_LOCALHOST_11434_API_KEY"
assert "api_key" not in entry
assert "sk-secret" not in yaml.safe_dump(saved)
def test_custom_endpoint_key_env_is_a_valid_posix_name_for_ip_endpoints():
"""Every IP-based local endpoint slugs to a digit-leading name.
``save_env_value`` rejects names that don't match
``[A-Za-z_][A-Za-z0-9_]*``, so deriving ``127_0_0_1_8080_API_KEY`` would
raise on exactly the local-proxy setups this is meant to protect. The
fixed prefix makes the result valid by construction.
"""
from hermes_cli.config import _ENV_VAR_NAME_RE, custom_endpoint_key_env
for identity in ("127.0.0.1_8080", "0.0.0.0", "10.0.0.7:11434", "", "-–-"):
assert _ENV_VAR_NAME_RE.match(custom_endpoint_key_env(identity)), identity