diff --git a/cron/scheduler.py b/cron/scheduler.py index 7fd0fbfaac..3d745e1360 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -1550,7 +1550,7 @@ def _resolve_job_runtime(job: dict, job_id: str, jc: _CronJobConfig) -> tuple[di if not fb_provider or not fb_model: continue try: - from hermes_cli.fallback_config import resolve_entry_api_key + from hermes_cli.fallback_config import effective_runtime_provider, resolve_entry_api_key fb_kwargs = {"requested": fb_provider, "target_model": fb_model} if entry.get("base_url"): @@ -1559,6 +1559,9 @@ def _resolve_job_runtime(job: dict, job_id: str, jc: _CronJobConfig) -> tuple[di if fb_api_key: fb_kwargs["explicit_api_key"] = fb_api_key runtime = resolve_runtime_provider(**fb_kwargs) + # Named custom entries resolve to the bare "custom" billing class; keep the configured + # identity so job sessions record the provider name (#98739). + runtime["provider"] = effective_runtime_provider(entry, runtime) logger.info( "Job '%s': fallback resolved to %s model %s", job_id, runtime.get("provider"), fb_model) diff --git a/gateway/run.py b/gateway/run.py index e14e6958aa..e91aade0b4 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2382,10 +2382,13 @@ def _try_resolve_fallback_provider() -> dict | None: return None for entry in fb_list: try: - from hermes_cli.fallback_config import resolve_entry_api_key + from hermes_cli.fallback_config import effective_runtime_provider, resolve_entry_api_key runtime = resolve_runtime_provider( requested=entry.get("provider"), explicit_base_url=entry.get("base_url"), explicit_api_key=resolve_entry_api_key(entry)) + # Named custom entries resolve to the bare "custom" billing class; persist the configured + # identity so UI/billing rows match the manual-switch path (#98739). + runtime["provider"] = effective_runtime_provider(entry, runtime) # Log the config `provider`, not the runtime category (Ollama would log "openrouter"). logger.info( # Log the literal `provider` key from config, not the resolved runtime category — an diff --git a/hermes_cli/fallback_config.py b/hermes_cli/fallback_config.py index a3e90b569c..f440398c19 100644 --- a/hermes_cli/fallback_config.py +++ b/hermes_cli/fallback_config.py @@ -28,6 +28,38 @@ def resolve_entry_api_key(entry: dict[str, Any] | None) -> str | None: return None +def effective_runtime_provider( + entry: dict[str, Any] | None, runtime: dict[str, Any] | None +) -> str: + """Provider identity to persist/display for a resolved fallback entry. + + ``resolve_runtime_provider`` returns the bare billing class ``"custom"`` + for every named ``providers:`` / ``custom_providers:`` entry; the entry's + configured id only survives in ``requested_provider``. Fallback resolvers + that persist ``runtime["provider"]`` as the agent identity therefore label + sessions/billing rows ``custom`` instead of the configured provider name — + while the manual ``/model`` switch path correctly persists the named id + (#98739). Same class as the delegation fix in ``tools/delegate_tool.py``. + + Returns the entry's requested identity when the resolved provider is the + bare ``custom`` class; a genuinely ad-hoc endpoint (requested provider IS + ``custom``) keeps the bare class unchanged. + """ + runtime = runtime or {} + resolved = str(runtime.get("provider") or "").strip() + if resolved.lower() != "custom": + return resolved + requested = str( + runtime.get("requested_provider") + or (entry or {}).get("provider") + or "" + ).strip() + if requested and requested.lower() != "custom": + return requested + return resolved + + + def _iter_fallback_entries(raw: Any) -> list[dict[str, Any]]: candidates = [raw] if isinstance(raw, dict) else raw if isinstance(raw, list) else [] entries: list[dict[str, Any]] = [] diff --git a/tests/hermes_cli/test_fallback_config.py b/tests/hermes_cli/test_fallback_config.py index 3d6da5604b..84c7bb1c81 100644 --- a/tests/hermes_cli/test_fallback_config.py +++ b/tests/hermes_cli/test_fallback_config.py @@ -1,7 +1,7 @@ """Tests for hermes_cli/fallback_config.py — fallback entry API-key resolution.""" from agent.secret_scope import reset_secret_scope, set_secret_scope -from hermes_cli.fallback_config import resolve_entry_api_key +from hermes_cli.fallback_config import effective_runtime_provider, resolve_entry_api_key class TestResolveEntryApiKey: @@ -37,3 +37,31 @@ class TestResolveEntryApiKey: # secret scope installed, resolution still reads os.environ. monkeypatch.setenv("FB_KEY", "env-key") assert resolve_entry_api_key({"key_env": "FB_KEY"}) == "env-key" + + +class TestEffectiveRuntimeProvider: + """Named custom fallback entries must keep their configured identity (#98739).""" + + def test_named_custom_entry_keeps_configured_id(self): + entry = {"provider": "my-custom-provider", "model": "some-model"} + runtime = {"provider": "custom", "requested_provider": "my-custom-provider"} + assert effective_runtime_provider(entry, runtime) == "my-custom-provider" + + def test_requested_provider_missing_falls_back_to_entry(self): + entry = {"provider": "my-custom-provider", "model": "some-model"} + runtime = {"provider": "custom"} + assert effective_runtime_provider(entry, runtime) == "my-custom-provider" + + def test_builtin_provider_untouched(self): + entry = {"provider": "openrouter", "model": "glm"} + runtime = {"provider": "openrouter", "requested_provider": "openrouter"} + assert effective_runtime_provider(entry, runtime) == "openrouter" + + def test_genuinely_bare_custom_stays_custom(self): + # Ad-hoc endpoint: user literally configured provider: custom. + entry = {"provider": "custom", "model": "some-model"} + runtime = {"provider": "custom", "requested_provider": "custom"} + assert effective_runtime_provider(entry, runtime) == "custom" + + def test_none_inputs_are_safe(self): + assert effective_runtime_provider(None, None) == "" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 40de67816c..cc60857233 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2207,12 +2207,15 @@ def _resolve_runtime_with_fallback(resolve_kwargs: dict | None = None) -> _Runti if not fb_provider or not fb_model: continue try: - from hermes_cli.fallback_config import resolve_entry_api_key + from hermes_cli.fallback_config import effective_runtime_provider, resolve_entry_api_key fb_kwargs: dict = {"requested": fb_provider, "target_model": fb_model, **({"explicit_base_url": entry["base_url"]} if entry.get("base_url") else {})} if fb_api_key := resolve_entry_api_key(entry): fb_kwargs["explicit_api_key"] = fb_api_key runtime = resolve_runtime_provider(**fb_kwargs) + # Named custom entries resolve to the bare "custom" billing class; keep the configured + # identity so the session/UI shows the provider name, matching the manual-switch path (#98739). + runtime["provider"] = effective_runtime_provider(entry, runtime) logging.getLogger(__name__).warning( "Primary auth failed (%s), falling back to %s model %s", primary_exc, fb_provider, fb_model) return _RuntimeFallbackResolution(runtime, fb_model, True)