fix(fallback): named custom providers keep their configured identity after automatic fallback (#98739)

resolve_runtime_provider returns the bare billing class 'custom' for every
named providers:/custom_providers: entry; the configured id only survives in
requested_provider. All three fallback resolvers (gateway, TUI/desktop, cron)
persisted runtime['provider'] as the agent identity, so an automatic fallback
labeled the session 'custom' in the UI and billing rows, while a manual
/model switch to the same provider showed the configured name.

New shared helper hermes_cli.fallback_config.effective_runtime_provider()
upgrades the bare class back to the entry's configured identity (ad-hoc
provider: custom entries stay unchanged), applied at all three sites —
same class as the delegate_tool fix.
This commit is contained in:
Teknium
2026-08-30 11:22:32 -07:00
parent 2aad4035a5
commit f72e79a111
5 changed files with 73 additions and 4 deletions
+4 -1
View File
@@ -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)
+4 -1
View File
@@ -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
+32
View File
@@ -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]] = []
+29 -1
View File
@@ -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) == ""
+4 -1
View File
@@ -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)