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:
+4
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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]] = []
|
||||
|
||||
@@ -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) == ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user