From f7a6d6a6a1bc57a1ffb085281957606df4b46cda Mon Sep 17 00:00:00 2001 From: xxxigm Date: Wed, 10 Jun 2026 18:55:04 +0700 Subject: [PATCH] =?UTF-8?q?test(cron):=20cover=20provider=20"custom"=20?= =?UTF-8?q?=E2=86=92=20providers.custom=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add execution-time coverage that bare `provider="custom"` resolves a literal providers.custom endpoint (and still falls through when none exists), plus creation-time coverage that `_resolve_model_override` keeps a resolvable "custom" and only pins the main provider when it is unresolvable. --- .../test_runtime_provider_resolution.py | 70 +++++++++++++++++++ tests/tools/test_cronjob_tools.py | 51 ++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index 2f89be93368..3e788fe3d53 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -712,6 +712,76 @@ def test_named_custom_provider_uses_saved_credentials(monkeypatch): assert resolved["source"] == "custom_provider:Local" +def test_bare_custom_resolves_providers_dict_entry_named_custom(monkeypatch): + """A request for bare ``provider="custom"`` must resolve a literal + ``providers.custom`` entry (e.g. a cliproxy endpoint) instead of falling + through to the global default. Regression for cron jobs stored with + ``provider: "custom"`` failing with ``auth_unavailable: providers=codex``. + """ + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "providers": { + "custom": { + "api": "https://cliproxy.example.com/v1", + "api_key": "cliproxy-key", + "default_model": "gpt-5.4", + "name": "CLIProxy", + } + } + }, + ) + # Reaching resolve_provider for bare custom with a matching entry means the + # named-custom path was bypassed — that is the bug we are fixing. + monkeypatch.setattr( + rp, + "resolve_provider", + lambda *a, **k: (_ for _ in ()).throw( + AssertionError( + "resolve_provider must not be called; providers.custom should match" + ) + ), + ) + + resolved = rp.resolve_runtime_provider(requested="custom") + + assert resolved["provider"] == "custom" + assert resolved["base_url"] == "https://cliproxy.example.com/v1" + assert resolved["api_key"] == "cliproxy-key" + assert resolved["requested_provider"] == "custom" + + +def test_bare_custom_without_named_entry_still_falls_through(monkeypatch): + """No literal providers.custom entry → bare custom keeps the legacy + model.base_url trust-path behavior, unchanged by the fix.""" + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter") + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: { + "provider": "openrouter", + "base_url": "http://127.0.0.1:8082/v1", + "default": "my-local-model", + }, + ) + monkeypatch.setattr( + rp, + "load_config", + lambda: {"providers": {"some-other-proxy": {"api": "https://x.example/v1"}}}, + ) + monkeypatch.delenv("CUSTOM_BASE_URL", raising=False) + monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False) + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + + resolved = rp.resolve_runtime_provider(requested="custom") + + assert resolved["provider"] == "custom" + assert resolved["base_url"] == "http://127.0.0.1:8082/v1" + + def test_named_custom_provider_uses_providers_dict_when_list_missing(monkeypatch): """After v11→v12 migration deletes custom_providers, resolution should still find entries in the providers dict via get_compatible_custom_providers.""" diff --git a/tests/tools/test_cronjob_tools.py b/tests/tools/test_cronjob_tools.py index fc03ab1d330..1ca877064a7 100644 --- a/tests/tools/test_cronjob_tools.py +++ b/tests/tools/test_cronjob_tools.py @@ -452,3 +452,54 @@ class TestUnifiedCronjobTool: assert updated["success"] is True stored = get_job(created["job_id"]) assert stored["deliver"] == "telegram" + + +# ========================================================================= +# Per-job model/provider override resolution +# ========================================================================= + +from tools.cronjob_tools import _resolve_model_override # noqa: E402 + + +class TestResolveModelOverride: + """`_resolve_model_override` must not silently hijack a job that meant to + use a configured custom endpoint (e.g. ``providers.custom`` → cliproxy). + Regression for cron jobs with ``provider: "custom"`` falling back to codex. + """ + + def test_keeps_bare_custom_when_a_named_entry_exists(self, monkeypatch): + import hermes_cli.runtime_provider as rp_mod + + monkeypatch.setattr(rp_mod, "has_named_custom_provider", lambda name: True) + provider, model = _resolve_model_override( + {"provider": "custom", "model": "gpt-5.4"} + ) + assert provider == "custom" + assert model == "gpt-5.4" + + def test_pins_main_provider_when_bare_custom_unresolvable(self, monkeypatch): + import hermes_cli.config as cfg_mod + import hermes_cli.runtime_provider as rp_mod + + monkeypatch.setattr(rp_mod, "has_named_custom_provider", lambda name: False) + monkeypatch.setattr( + cfg_mod, "load_config", lambda: {"model": {"provider": "openai-codex"}} + ) + provider, model = _resolve_model_override( + {"provider": "custom", "model": "gpt-5.4"} + ) + # No matching custom entry → fall back to pinning the main provider. + assert provider == "openai-codex" + assert model == "gpt-5.4" + + def test_keeps_explicit_custom_name_unchanged(self, monkeypatch): + import hermes_cli.runtime_provider as rp_mod + + # Even if the resolver claims no entry, the canonical "custom:" + # form is never stripped or pinned. + monkeypatch.setattr(rp_mod, "has_named_custom_provider", lambda name: False) + provider, model = _resolve_model_override( + {"provider": "custom:cliproxy", "model": "gpt-5.4"} + ) + assert provider == "custom:cliproxy" + assert model == "gpt-5.4"