fix(cron): resolve per-job provider "custom" to providers.custom instead of codex

A cron job stored with `provider: "custom"` and a matching `providers.custom`
entry in config failed at execution with `auth_unavailable: providers=codex`.
Two layers conspired:

- `_get_named_custom_provider` returned None for bare "custom" *before*
  scanning config, so a literal `providers.custom` entry was never matched and
  resolution fell through to the global default (codex). Now it scans config
  for an entry literally named "custom"; with none it still returns None,
  preserving the legacy model.base_url trust path.
- `_resolve_model_override` blindly stripped bare "custom" at job creation and
  pinned `model.provider` (e.g. codex). It now keeps "custom" when a configured
  custom endpoint resolves, pinning the main provider only when it doesn't.
This commit is contained in:
xxxigm 2026-06-10 18:55:04 +07:00 committed by Teknium
parent 1e7316ced2
commit acd4f34e65
2 changed files with 44 additions and 10 deletions

View file

@ -491,15 +491,27 @@ def _lift_max_output_tokens(entry: Dict[str, Any], result: Dict[str, Any]) -> No
def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, Any]]:
requested_norm = _normalize_custom_provider_name(requested_provider or "")
if not requested_norm or requested_norm == "custom":
if not requested_norm:
return None
# Bare "custom" is normally an incomplete spec — the canonical form is
# "custom:<name>" — and is otherwise owned by the model.base_url "bare
# custom" trust path. BUT a user may literally name a ``providers:`` (or
# legacy ``custom_providers:``) entry "custom" (e.g. ``providers.custom``
# pointing at cliproxy). We used to return None here *before* scanning
# config, so such an entry was never matched and resolution fell through to
# the global default (Codex) — the cause of cron jobs with
# ``provider: "custom"`` failing with ``auth_unavailable: providers=codex``.
# Fall through to the config scan instead; if no entry is literally named
# "custom" it still returns None at the end, preserving the trust path.
# Raw names should only map to custom providers when they are not already
# valid built-in providers or aliases. Explicit menu keys like
# ``custom:local`` always target the saved custom provider.
# ``custom:local`` always target the saved custom provider. Bare "custom"
# is exempt from the shadow check — it is not a built-in to defer to.
if requested_norm == "auto":
return None
if not requested_norm.startswith("custom:"):
if requested_norm != "custom" and not requested_norm.startswith("custom:"):
try:
canonical = auth_mod.resolve_provider(requested_norm)
except AuthError:
@ -634,6 +646,20 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
return None
def has_named_custom_provider(requested_provider: str) -> bool:
"""Return True when config defines a custom provider matching the request.
Thin public wrapper around :func:`_get_named_custom_provider` so other
modules (e.g. the cronjob tool) can decide whether a provider name will
actually resolve to a configured ``providers:`` / ``custom_providers:``
entry without reaching into a private helper or duplicating the scan.
"""
try:
return _get_named_custom_provider(requested_provider) is not None
except Exception:
return False
def _custom_provider_request_overrides(custom_provider: Dict[str, Any]) -> Dict[str, Any]:
extra_body = custom_provider.get("extra_body")
if not isinstance(extra_body, dict) or not extra_body:

View file

@ -326,15 +326,23 @@ def _resolve_model_override(model_obj: Optional[Dict[str, Any]]) -> tuple:
return (None, None)
model_name = (model_obj.get("model") or "").strip() or None
provider_name = (model_obj.get("provider") or "").strip() or None
# Bare "custom" is an incomplete spec — the canonical form is
# "custom:<name>" matching a custom_providers entry. LLMs frequently
# Bare "custom" is usually an incomplete spec — the canonical form is
# "custom:<name>" matching a custom_providers entry, and LLMs frequently
# supply the bare type because the schema does not advertise the
# ":<name>" suffix, which used to bypass the pinning path below and
# leave the job stored with an unresolvable "custom" provider. Treat
# the bare value as "no provider supplied" so the current main
# provider gets pinned instead.
# ":<name>" suffix. It is only a problem when it can't resolve at runtime:
# a user may literally name a ``providers.custom`` (or custom_providers
# "custom") entry, in which case the job should keep ``provider="custom"``
# and run against that endpoint. Only when no such entry exists do we treat
# the bare value as "no provider supplied" and pin the current main
# provider below — otherwise pinning to ``model.provider`` (e.g. codex)
# silently hijacks a job that meant to use the configured custom endpoint.
if provider_name == "custom":
provider_name = None
try:
from hermes_cli.runtime_provider import has_named_custom_provider
if not has_named_custom_provider("custom"):
provider_name = None
except Exception:
provider_name = None
if model_name and not provider_name:
# Pin to the current main provider so the job is stable
try: