From acd4f34e65ae23289358fef3c428c8e02941ff33 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Wed, 10 Jun 2026 18:55:04 +0700 Subject: [PATCH] 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. --- hermes_cli/runtime_provider.py | 32 +++++++++++++++++++++++++++++--- tools/cronjob_tools.py | 22 +++++++++++++++------- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index b8165978538..c53a930e9e4 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -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:" — 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: diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 3b1c46ec3d7..2ec49760715 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -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:" matching a custom_providers entry. LLMs frequently + # Bare "custom" is usually an incomplete spec — the canonical form is + # "custom:" matching a custom_providers entry, and LLMs frequently # supply the bare type because the schema does not advertise the - # ":" 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. + # ":" 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: