# 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
884 lines
32 KiB
Python
884 lines
32 KiB
Python
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
|
||
|