diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index f8254bfa8f..734a1cdeb5 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -6085,11 +6085,7 @@ _VISION_AUTO_PROVIDER_ORDER = ( def _main_model_supports_vision(provider: str, model: Optional[str]) -> bool: - """Return True when ``provider``/``model`` is known to accept image input. - - Lets vision auto-detect skip a text-only main provider instead of surfacing - a cryptic provider-side error. Unknown capability → True (attempt the call). - """ + """True when ``provider``/``model`` is known to accept image input; unknown capability → True (attempt the call).""" try: from agent.image_routing import _lookup_supports_vision from hermes_cli.config import load_config_readonly @@ -6099,49 +6095,46 @@ def _main_model_supports_vision(provider: str, model: Optional[str]) -> bool: supports = _lookup_supports_vision(provider, model, load_config_readonly()) except Exception: # pragma: no cover - defensive return True - if supports is None: - # No capability data — attempt the call rather than silently skipping. - return True - return bool(supports) + return True if supports is None else bool(supports) def _normalize_vision_provider(provider: Optional[str]) -> str: return _normalize_aux_provider(provider) +def _deepinfra_strict_vision_backend(model: Optional[str]) -> Tuple[Optional[Any], Optional[str]]: + """DeepInfra vision: default model is discovered live via default_vision_model() so no hardcoded id can rot.""" + vision_model = model or _resolve_provider_vision_default("deepinfra") + if not vision_model: + logger.debug( + "Vision auto-detect: deepinfra catalog unreachable or " + "returned no vision-tagged models — skipping" + ) + return None, None + return resolve_provider_client("deepinfra", vision_model, is_vision=True) + + +# Strict (explicitly requested) vision backends by normalized provider name. +# nous MUST go through resolve_provider_client so anthropic/* vision picks wrap +# onto /v1/messages (a bare _try_nous client 404s). openai-codex has no safe +# default model (shifting allow-list); callers set auxiliary..model. +_STRICT_VISION_BACKENDS: Dict[str, Callable[[Optional[str]], Tuple[Optional[Any], Optional[str]]]] = { + "copilot": lambda model: resolve_provider_client("copilot", model, is_vision=True), + "openrouter": lambda model: _try_openrouter(model=model), + "nous": lambda model: resolve_provider_client("nous", model, is_vision=True), + "openai-codex": lambda model: resolve_provider_client("openai-codex", model, is_vision=True), + "anthropic": lambda model: _try_anthropic(), + "deepinfra": _deepinfra_strict_vision_backend, + "custom": lambda model: _try_custom_endpoint(), +} + + def _resolve_strict_vision_backend( provider: str, model: Optional[str] = None, ) -> Tuple[Optional[Any], Optional[str]]: - provider = _normalize_vision_provider(provider) - if provider == "copilot": - return resolve_provider_client("copilot", model, is_vision=True) - if provider == "openrouter": - return _try_openrouter(model=model) - if provider == "nous": - # Must go through resolve_provider_client so anthropic/* vision picks - # wrap onto /v1/messages; a bare _try_nous client 404s. - return resolve_provider_client("nous", model, is_vision=True) - if provider == "openai-codex": - # No safe default Codex model (shifting allow-list); callers must - # specify via auxiliary..model. - return resolve_provider_client("openai-codex", model, is_vision=True) - if provider == "anthropic": - return _try_anthropic() - if provider == "deepinfra": - # Default vision model is discovered live via the profile's - # default_vision_model() hook so no hardcoded id can rot. - vision_model = model or _resolve_provider_vision_default("deepinfra") - if not vision_model: - logger.debug( - "Vision auto-detect: deepinfra catalog unreachable or " - "returned no vision-tagged models — skipping" - ) - return None, None - return resolve_provider_client("deepinfra", vision_model, is_vision=True) - if provider == "custom": - return _try_custom_endpoint() - return None, None + backend = _STRICT_VISION_BACKENDS.get(_normalize_vision_provider(provider)) + return backend(model) if backend is not None else (None, None) def _strict_vision_backend_available(provider: str) -> bool: @@ -6157,12 +6150,11 @@ def get_available_vision_backends() -> List[str]: main_provider = _read_main_provider() if main_provider and main_provider not in {"auto", ""}: if main_provider in _VISION_AUTO_PROVIDER_ORDER: - if _strict_vision_backend_available(main_provider): - available.append(main_provider) + main_ok = _strict_vision_backend_available(main_provider) else: - client, _ = resolve_provider_client(main_provider, _read_main_model()) - if client is not None: - available.append(main_provider) + main_ok = resolve_provider_client(main_provider, _read_main_model())[0] is not None + if main_ok: + available.append(main_provider) # 2. OpenRouter, 3. Nous — skip if already covered by main provider. for p in _VISION_AUTO_PROVIDER_ORDER: if p not in available and _strict_vision_backend_available(p): @@ -6170,6 +6162,126 @@ def get_available_vision_backends() -> List[str]: return available +def _finalize_vision_client( + resolved_provider: str, sync_client: Any, default_model: Optional[str], + resolved_model: Optional[str], async_mode: bool, +) -> Tuple[Optional[str], Optional[Any], Optional[str]]: + """Apply the explicit model override (and async wrapping) to a resolved vision client.""" + if sync_client is None: + return resolved_provider, None, None + final_model = resolved_model or default_model + if async_mode: + async_client, async_model = _to_async_client(sync_client, final_model, is_vision=True) + return resolved_provider, async_client, async_model + return resolved_provider, sync_client, final_model + + +def _vision_main_provider_client( + main_provider: str, + main_model: str, + runtime: Dict[str, Any], + resolved_model: Optional[str], + resolved_api_mode: Optional[str], +) -> Tuple[Optional[Any], Optional[str]]: + """Auto-detect step 1: try the main provider; (None, None) falls through to the aggregator chain.""" + # A provider vision default (static override or catalog discovery) is a + # *known* multimodal model; the pinned chat model usually isn't, so only + # fall back to it when no provider default exists. + provider_vision_default = _resolve_provider_vision_default(main_provider) + vision_model = provider_vision_default or main_model + if main_provider == "nous": + # Nous picks its vision model from Portal tier-aware slots inside + # _try_nous(vision=True); passing the chat model would override that and + # 404. Only explicit auxiliary.vision.model may override. + sync_client, default_model = _resolve_strict_vision_backend(main_provider, resolved_model or provider_vision_default) + if sync_client is None: + return None, None + logger.info("Vision auto-detect: using main provider %s (%s)", main_provider, default_model or resolved_model or main_model) + return sync_client, default_model + if main_provider in _PROVIDERS_WITHOUT_VISION: + # Endpoint rejects image input entirely (e.g. Kimi Coding Plan). + logger.debug( + "Vision auto-detect: skipping main provider %s (no " + "vision support) — falling through to aggregator chain", + main_provider, + ) + return None, None + if not _main_model_supports_vision(main_provider, vision_model): + # Known text-only model. Log only the provider name (CodeQL + # clear-text-logging false positives on multi-value logs). + logger.debug( + "Vision auto-detect: skipping main provider %s " + "(reports no vision capability) — falling through to " + "aggregator chain", + main_provider, + ) + return None, None + # Custom endpoints carry no built-in base_url/api_key: recover the live main + # endpoint from set_runtime_main() or, for non-gateway callers with no live + # runtime recorded, the configured custom endpoint. + rpc_base_url = rpc_api_key = None + rpc_api_mode = resolved_api_mode + if main_provider == "custom" or main_provider.startswith("custom:"): + if runtime.get("base_url"): + custom_base, custom_key, custom_mode = runtime.get("base_url"), runtime.get("api_key") or None, runtime.get("api_mode") + else: + custom_base, custom_key, custom_mode = _resolve_custom_runtime() + if custom_base: + rpc_base_url, rpc_api_key = custom_base, custom_key + rpc_api_mode = resolved_api_mode or custom_mode or None + rpc_client, rpc_model = resolve_provider_client( + main_provider, vision_model, + api_mode=rpc_api_mode, + explicit_base_url=rpc_base_url, + explicit_api_key=rpc_api_key, + main_runtime=runtime, + is_vision=True) + if rpc_client is None: + return None, None + logger.info("Vision auto-detect: using main provider %s (%s)", main_provider, rpc_model or vision_model) + return rpc_client, rpc_model or vision_model + + +def _vision_auto_route( + runtime: Dict[str, Any], + resolved_model: Optional[str], + resolved_api_mode: Optional[str], + async_mode: bool, +) -> Tuple[Optional[str], Optional[Any], Optional[str]]: + """Auto-detect order: 1. main provider + model, 2. OpenRouter, 3. Nous Portal, 4. DeepInfra, 5. stop.""" + main_provider = str(runtime.get("provider") or _read_main_provider()) + main_model = str(runtime.get("model") or _read_main_model()) + if main_provider.strip().lower() == "moa": + # MoA main_model is a preset NAME, not a wire model — unwrap to the + # preset's aggregator slot. The moa:// facade endpoint belongs to the + # virtual provider, not the aggregator's real provider. + _agg_provider, _agg_model = _resolve_moa_aggregator(main_model) + if _agg_provider and _agg_model: + main_provider, main_model = _agg_provider, _agg_model + runtime = dict(runtime, base_url="", api_key="", api_mode="") + if main_provider and main_provider not in {"auto", "", "moa"}: + client, default_model = _vision_main_provider_client(main_provider, main_model, runtime, resolved_model, resolved_api_mode) + if client is not None: + return _finalize_vision_client(main_provider, client, default_model, resolved_model, async_mode) + # Aggregators use their dedicated vision model, not the user's main model. + for candidate in _VISION_AUTO_PROVIDER_ORDER: + if candidate == main_provider: + continue # already tried above + sync_client, default_model = _resolve_strict_vision_backend(candidate) + if sync_client is not None: + return _finalize_vision_client(candidate, sync_client, default_model, resolved_model, async_mode) + logger.debug("Auxiliary vision client: none available") + return None, None, None + + +# ZAI vision must use the OpenAI-compatible endpoint: the Anthropic wire +# rejects max_tokens on multimodal calls (error 1210). +_ZAI_OPENAI_VISION_URLS = ( + "https://open.bigmodel.cn/api/paas/v4", + "https://api.z.ai/api/paas/v4", +) + + def resolve_vision_provider_client( provider: Optional[str] = None, model: Optional[str] = None, @@ -6190,19 +6302,8 @@ def resolve_vision_provider_client( ) requested = _normalize_vision_provider(requested) - def _finalize(resolved_provider: str, sync_client: Any, default_model: Optional[str]): - if sync_client is None: - return resolved_provider, None, None - final_model = resolved_model or default_model - if async_mode: - async_client, async_model = _to_async_client(sync_client, final_model, is_vision=True) - return resolved_provider, async_client, async_model - return resolved_provider, sync_client, final_model - if resolved_base_url: - provider_for_base_override = ( - requested if requested and requested not in {"", "auto"} else "custom" - ) + provider_for_base_override = requested if requested and requested not in {"", "auto"} else "custom" client, final_model = resolve_provider_client( provider_for_base_override, model=resolved_model, @@ -6215,120 +6316,14 @@ def resolve_vision_provider_client( return provider_for_base_override, client, (final_model if client is not None else None) if requested == "auto": - # Auto-detect order: 1. main provider + model (per-provider vision - # overrides / live DeepInfra discovery; Nous uses its own strict backend), - # 2. OpenRouter, 3. Nous Portal, 4. DeepInfra, 5. stop. - main_provider = str(runtime.get("provider") or _read_main_provider()) - main_model = str(runtime.get("model") or _read_main_model()) - if main_provider.strip().lower() == "moa": - # MoA main_model is a preset NAME, not a wire model — unwrap to the - # preset's aggregator slot so capability probes target a real pair. - _agg_provider, _agg_model = _resolve_moa_aggregator(main_model) - if _agg_provider and _agg_model: - main_provider, main_model = _agg_provider, _agg_model - # The moa:// facade endpoint belongs to the virtual provider, not - # the aggregator's real provider. - runtime = dict(runtime) - runtime["base_url"] = "" - runtime["api_key"] = "" - runtime["api_mode"] = "" - if main_provider and main_provider not in {"auto", "", "moa"}: - # A provider vision default (static override or catalog discovery) - # is a *known* multimodal model; the pinned chat model usually isn't, - # so only fall back to it when no provider default exists. - provider_vision_default = _resolve_provider_vision_default(main_provider) - vision_model = provider_vision_default or main_model - if main_provider == "nous": - # Nous picks its vision model from Portal tier-aware slots inside - # _try_nous(vision=True); passing the chat model would override - # that and 404. Only explicit auxiliary.vision.model may override. - sync_client, default_model = _resolve_strict_vision_backend( - main_provider, resolved_model or provider_vision_default - ) - if sync_client is not None: - logger.info( - "Vision auto-detect: using main provider %s (%s)", - main_provider, default_model or resolved_model or main_model, - ) - return _finalize(main_provider, sync_client, default_model) - elif main_provider in _PROVIDERS_WITHOUT_VISION: - # Provider endpoint rejects image input entirely (e.g. Kimi - # Coding Plan); fall through to aggregators instead of 404ing. - logger.debug( - "Vision auto-detect: skipping main provider %s (no " - "vision support) — falling through to aggregator chain", - main_provider, - ) - elif not _main_model_supports_vision(main_provider, vision_model): - # Known text-only model; sending an image yields a cryptic - # provider error. Log only the provider name (CodeQL - # clear-text-logging false positives on multi-value logs). - logger.debug( - "Vision auto-detect: skipping main provider %s " - "(reports no vision capability) — falling through to " - "aggregator chain", - main_provider, - ) - else: - # Custom endpoints carry no built-in base_url/api_key, so recover - # the live main endpoint from set_runtime_main() (or the - # configured custom endpoint) to build a working client. - rpc_base_url = None - rpc_api_key = None - rpc_api_mode = resolved_api_mode - if main_provider == "custom" or main_provider.startswith("custom:"): - if runtime.get("base_url"): - custom_base, custom_key, custom_mode = ( - runtime.get("base_url"), runtime.get("api_key") or None, runtime.get("api_mode"), - ) - else: - # Non-gateway caller: no live runtime recorded. - custom_base, custom_key, custom_mode = _resolve_custom_runtime() - if custom_base: - rpc_base_url = custom_base - rpc_api_key = custom_key - rpc_api_mode = resolved_api_mode or custom_mode or None - rpc_client, rpc_model = resolve_provider_client( - main_provider, vision_model, - api_mode=rpc_api_mode, - explicit_base_url=rpc_base_url, - explicit_api_key=rpc_api_key, - main_runtime=runtime, - is_vision=True) - if rpc_client is not None: - logger.info( - "Vision auto-detect: using main provider %s (%s)", - main_provider, rpc_model or vision_model, - ) - return _finalize( - main_provider, rpc_client, rpc_model or vision_model) - - # Fall back through aggregators (their dedicated vision model, not the - # user's main model). - for candidate in _VISION_AUTO_PROVIDER_ORDER: - if candidate == main_provider: - continue # already tried above - sync_client, default_model = _resolve_strict_vision_backend(candidate) - if sync_client is not None: - return _finalize(candidate, sync_client, default_model) - - logger.debug("Auxiliary vision client: none available") - return None, None, None + return _vision_auto_route(runtime, resolved_model, resolved_api_mode, async_mode) if requested in _VISION_AUTO_PROVIDER_ORDER: - sync_client, default_model = _resolve_strict_vision_backend( - requested, resolved_model - ) - return _finalize(requested, sync_client, default_model) + sync_client, default_model = _resolve_strict_vision_backend(requested, resolved_model) + return _finalize_vision_client(requested, sync_client, default_model, resolved_model, async_mode) - # ZAI vision must use the OpenAI-compatible endpoint: the Anthropic wire - # rejects max_tokens on multimodal calls (error 1210). - if requested == "zai" and not resolved_base_url: - zai_openai_urls = [ - "https://open.bigmodel.cn/api/paas/v4", - "https://api.z.ai/api/paas/v4", - ] - for _zai_url in zai_openai_urls: + if requested == "zai": + for _zai_url in _ZAI_OPENAI_VISION_URLS: client, final_model = _get_cached_client( requested, resolved_model, async_mode, base_url=_zai_url, @@ -6338,16 +6333,13 @@ def resolve_vision_provider_client( is_vision=True, ) if client is not None: - return _finalize(requested, client, final_model) + return _finalize_vision_client(requested, client, final_model, resolved_model, async_mode) # Fallback: try without explicit base_url (old behavior) - client, final_model = _get_cached_client(requested, resolved_model, async_mode, - api_mode=resolved_api_mode, - main_runtime=runtime, - is_vision=True) - if client is None: - return requested, None, None - return requested, client, final_model + client, final_model = _get_cached_client( + requested, resolved_model, async_mode, api_mode=resolved_api_mode, main_runtime=runtime, is_vision=True, + ) + return requested, client, (final_model if client is not None else None) def get_auxiliary_extra_body() -> dict: @@ -6364,15 +6356,16 @@ def auxiliary_max_tokens_param(value: int, *, model: Optional[str] = None) -> di custom_base = _current_custom_base_url() or_key = _scoped_key_env("OPENROUTER_API_KEY") _custom_host = base_url_hostname(custom_base) or "" - if (not or_key - and _read_nous_auth() is None - and ( - _custom_host == "api.openai.com" - or _custom_host == "api.githubcopilot.com" - or _custom_host.endswith(".githubcopilot.com") - )): - return {"max_completion_tokens": value} - if model_forces_max_completion_tokens(model): + direct_openai_family = ( + not or_key + and _read_nous_auth() is None + and ( + _custom_host == "api.openai.com" + or _custom_host == "api.githubcopilot.com" + or _custom_host.endswith(".githubcopilot.com") + ) + ) + if direct_openai_family or model_forces_max_completion_tokens(model): return {"max_completion_tokens": value} return {"max_tokens": value} @@ -6436,16 +6429,9 @@ def _client_cache_key( model: Optional[str] = None, ) -> tuple: runtime = _normalize_main_runtime(main_runtime) - runtime_key = tuple( - _runtime_cache_discriminator(field, runtime.get(field, "")) - for field in _MAIN_RUNTIME_FIELDS - ) if provider == "auto" else () - # `auto` resolves through task-specific policy, so the task joins the key. - task_key = ( - (task or "", _task_prefers_fast_model(task)) - if provider == "auto" - else "" - ) + # `auto` resolves through the main runtime and task-specific policy, so both join the key. + runtime_key = tuple(_runtime_cache_discriminator(f, runtime.get(f, "")) for f in _MAIN_RUNTIME_FIELDS) if provider == "auto" else () + task_key = (task or "", _task_prefers_fast_model(task)) if provider == "auto" else "" pool_hint = _pool_cache_hint(provider, main_runtime=main_runtime) # Model MUST be in the key: concurrent calls to the same endpoint with # different models would otherwise share an entry, and the second builder's @@ -6490,23 +6476,14 @@ def _refresh_nous_auxiliary_client( ) -> Tuple[Optional[Any], Optional[str]]: """Refresh Nous runtime creds, rebuild the client, and replace the cache entry. - ``model`` is the resolved model actually sent on the wire (e.g. the provider - default ``"Hermes-4-405B"``); it is stored as the entry's usable model and - returned to the caller. ``lookup_model`` is the model as it was passed to - ``_get_cached_client`` when the (now stale) client was acquired -- ``None`` - on the default Nous config, where ``call_llm`` looks up with - ``resolved_model=None``. The cache KEY MUST be built from ``lookup_model`` so - the fresh client overwrites the exact entry the stale client is served from. - Keying on the resolved ``model`` instead stored under a different key (model - element ``"Hermes-4-405B"`` vs the lookup's ``""``), leaving the expired - client immortal so every auxiliary call 401s forever (#56889). - - ``lookup_task`` is the task the stale client was acquired under. For - ``provider == "auto"`` the task participates in the cache key (task-specific - fallback policy), so it MUST be carried into the key here for the same - reason as ``lookup_model``; otherwise an auto-provider client refreshed on a - 401 lands under the ``task=""`` key while the stale entry survives under the - task-scoped key (#58894). + ``model`` is the resolved wire model (e.g. the provider default) stored as the + entry's usable model and returned. The cache KEY MUST be built from + ``lookup_model``/``lookup_task`` — the model and task as passed to + ``_get_cached_client`` when the stale client was acquired (``None`` model on + the default Nous config; task joins the key for ``provider == "auto"``) — so + the fresh client overwrites the exact entry the stale one is served from. + Keying on the resolved model or an empty task would leave the expired client + immortal and every auxiliary call 401ing forever. """ runtime = _resolve_nous_runtime_api(force_refresh=True) if runtime is None: @@ -6641,11 +6618,7 @@ def shutdown_cached_clients() -> None: while an owner loop drains, and holding the lock would convoy every caller. """ with _client_cache_lock: - clients = [ - (entry[0], entry[2]) - for entry in _client_cache.values() - if entry[0] is not None - ] + clients = [(entry[0], entry[2]) for entry in _client_cache.values() if entry[0] is not None] _client_cache.clear() try: import asyncio as _aio @@ -6656,9 +6629,7 @@ def shutdown_cached_clients() -> None: for client, owner_loop in clients: # A live foreign loop owns its transport — neuter only and let it finish # teardown. Closed loops and the current loop are safe to drain here. - close_async = owner_loop is not None and ( - owner_loop.is_closed() or owner_loop is running_loop - ) + close_async = owner_loop is not None and (owner_loop.is_closed() or owner_loop is running_loop) _close_cached_client(client, close_async=close_async) @@ -6667,32 +6638,28 @@ def cleanup_stale_async_clients() -> None: Call after each agent turn; defense-in-depth behind ``neuter_async_httpx_del``. """ - stale_clients = [] with _client_cache_lock: - stale_keys = [] - for key, entry in _client_cache.items(): - client, _default, cached_loop = entry - if cached_loop is not None and cached_loop.is_closed(): - stale_keys.append(key) - stale_clients.append(client) - for key in stale_keys: + stale = [ + (key, entry[0]) + for key, entry in _client_cache.items() + if entry[2] is not None and entry[2].is_closed() + ] + for key, _client in stale: del _client_cache[key] - for client in stale_clients: + for _key, client in stale: _close_cached_client(client, close_async=True) def _is_openrouter_client(client: Any) -> bool: - for obj in (client, getattr(client, "_client", None), getattr(client, "client", None)): - if obj and base_url_host_matches(str(getattr(obj, "base_url", "") or ""), "openrouter.ai"): - return True - return False + return any( + obj and base_url_host_matches(str(getattr(obj, "base_url", "") or ""), "openrouter.ai") + for obj in (client, getattr(client, "_client", None), getattr(client, "client", None)) + ) def _cached_client_accepts_slash_models(client: Any, cached_default: Optional[str]) -> bool: """Best-effort check for cached clients that accept ``vendor/model`` IDs.""" - if _is_openrouter_client(client): - return True - return bool(cached_default and "/" in cached_default) + return _is_openrouter_client(client) or bool(cached_default and "/" in cached_default) def _compat_model(client: Any, model: Optional[str], cached_default: Optional[str]) -> Optional[str]: @@ -6738,26 +6705,17 @@ def _get_cached_client( with _client_cache_lock: if cache_key in _client_cache: cached_client, cached_default, cached_loop = _client_cache[cache_key] - if async_mode: - # Cached client must be bound to the CURRENT, OPEN loop. - loop_ok = ( - cached_loop is not None - and cached_loop is current_loop - and not cached_loop.is_closed() - ) - if loop_ok: - effective = _compat_model(cached_client, model, cached_default) - return cached_client, effective - # Stale — evict. Only a closed owner loop may be awaited here; - # a live foreign loop stays force-neutered. - owner_loop_closed = ( - cached_loop is not None and cached_loop.is_closed() - ) - _close_cached_client(cached_client, close_async=owner_loop_closed) - del _client_cache[cache_key] - else: - effective = _compat_model(cached_client, model, cached_default) - return cached_client, effective + loop_ok = not async_mode or ( + cached_loop is not None + and cached_loop is current_loop + and not cached_loop.is_closed() + ) + if loop_ok: + return cached_client, _compat_model(cached_client, model, cached_default) + # Stale async entry — evict. Only a closed owner loop may be awaited + # here; a live foreign loop stays force-neutered. + _close_cached_client(cached_client, close_async=cached_loop is not None and cached_loop.is_closed()) + del _client_cache[cache_key] # Build outside the lock. For pool-backed providers derive the key from the # pool entry: resolve_api_key_provider_credentials prefers env vars, which # would bypass pool rotation and retry an exhausted key. @@ -6765,9 +6723,7 @@ def _get_cached_client( if not effective_api_key: _pe = _peek_pool_entry(_normalize_aux_provider(provider)) if _pe is not None: - _pk = _pool_runtime_api_key(_pe) - if _pk: - effective_api_key = _pk + effective_api_key = _pool_runtime_api_key(_pe) or api_key client, default_model = resolve_provider_client( provider, model, @@ -6780,15 +6736,13 @@ def _get_cached_client( task=task, ) if client is not None: - bound_loop = current_loop with _client_cache_lock: if cache_key not in _client_cache: # FIFO safety-belt eviction. Do NOT close evicted clients: # another caller may be mid-request on one; refcount/GC handles it. while len(_client_cache) >= _CLIENT_CACHE_MAX_SIZE: - evict_key = next(iter(_client_cache)) - del _client_cache[evict_key] - _client_cache[cache_key] = (client, default_model, bound_loop) + del _client_cache[next(iter(_client_cache))] + _client_cache[cache_key] = (client, default_model, current_loop) else: built_client = client client, default_model, _ = _client_cache[cache_key] @@ -6806,6 +6760,67 @@ _AUX_DIRECT_API_BASE_URLS: Dict[str, str] = { } +def _read_task_route_config( + task: Optional[str], +) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[str], Optional[str]]: + """Read auxiliary..{provider, model, base_url, api_key|key_env, api_mode}; all None without a task.""" + if not task: + return None, None, None, None, None + task_config = _get_auxiliary_task_config(task) + cfg_provider = str(task_config.get("provider", "")).strip() or None + cfg_model = str(task_config.get("model", "")).strip() or None + cfg_base_url = str(task_config.get("base_url", "")).strip() or None + cfg_api_key = str(task_config.get("api_key", "")).strip() or None + if not cfg_api_key: + # Resolve key_env → env var when api_key is not set directly. + cfg_key_env = str(task_config.get("key_env") or task_config.get("api_key_env") or "").strip() + if cfg_key_env: + cfg_api_key = _scoped_key_env(cfg_key_env) or None + cfg_api_mode = str(task_config.get("api_mode", "")).strip() or None + return cfg_provider, cfg_model, cfg_base_url, cfg_api_key, cfg_api_mode + + +def _unwrap_moa_provider(prov: str, mdl: Optional[str]) -> Tuple[str, Optional[str]]: + """Resolve an *explicit* ``provider: moa`` to its preset's aggregator slot. + + _resolve_auto() only unwraps the implicit case; "moa" isn't in + PROVIDER_REGISTRY and would dead-end. + """ + if prov.strip().lower() != "moa": + return prov, mdl + agg_provider, agg_model = _resolve_moa_aggregator(mdl) + if agg_provider and agg_model: + return agg_provider, agg_model + return prov, mdl + + +def _expand_direct_api_alias(prov: Optional[str], existing_base: Optional[str]) -> Tuple[Optional[str], Optional[str]]: + """``provider: openai`` → custom + api.openai.com/v1; a user base_url is kept but the provider still becomes custom.""" + if not prov: + return prov, existing_base + target_base = _AUX_DIRECT_API_BASE_URLS.get(prov.strip().lower()) + if target_base is None: + return prov, existing_base + return "custom", existing_base or target_base + + +def _preserve_provider_with_base_url(prov: Optional[str]) -> bool: + """True when a first-class provider keeps its identity alongside an explicit base_url.""" + normalized = str(prov or "").strip().lower() + if normalized in {"", "auto", "custom"} or normalized.startswith("custom:"): + return False + try: + from hermes_cli.providers import get_provider + + return get_provider(normalized) is not None + except Exception: + # Keep provider-backed routes safe when the catalog can't load. + return normalized in { + "anthropic", "copilot", "copilot-acp", "minimax-oauth", + "nous", "openai-codex", "qwen-oauth", "xai-oauth", + } + + def _resolve_task_provider_model( task: str = None, provider: str = None, @@ -6820,26 +6835,7 @@ def _resolve_task_provider_model( provider identity so its auth/transport shaping still applies. api_mode is "chat_completions", "codex_responses", or None (auto-detect). """ - cfg_provider = None - cfg_model = None - cfg_base_url = None - cfg_api_key = None - cfg_api_mode = None - - if task: - task_config = _get_auxiliary_task_config(task) - cfg_provider = str(task_config.get("provider", "")).strip() or None - cfg_model = str(task_config.get("model", "")).strip() or None - cfg_base_url = str(task_config.get("base_url", "")).strip() or None - cfg_api_key = str(task_config.get("api_key", "")).strip() or None - # Resolve key_env → env var when api_key is not set directly - if not cfg_api_key: - cfg_key_env = str( - task_config.get("key_env") or task_config.get("api_key_env") or "" - ).strip() - if cfg_key_env: - cfg_api_key = _scoped_key_env(cfg_key_env) or None - cfg_api_mode = str(task_config.get("api_mode", "")).strip() or None + cfg_provider, cfg_model, cfg_base_url, cfg_api_key, resolved_api_mode = _read_task_route_config(task) # 'auto' is a sentinel ("inherit / auto-detect"), not a model id — leaking it # to the wire yields a 200 with an error-text body that consumers accept as @@ -6849,25 +6845,12 @@ def _resolve_task_provider_model( model = None if cfg_model and cfg_model.lower() == "auto": cfg_model = None - resolved_model = model or cfg_model - resolved_api_mode = cfg_api_mode - - # An *explicit* `provider: moa` (arg or config) bypasses _resolve_auto(), - # which only unwraps the implicit case; "moa" isn't in PROVIDER_REGISTRY and - # would dead-end. Resolve to the preset's aggregator slot instead. - def _unwrap_moa_provider(prov: str, mdl: Optional[str]) -> Tuple[str, Optional[str]]: - if prov.strip().lower() != "moa": - return prov, mdl - agg_provider, agg_model = _resolve_moa_aggregator(mdl) - if agg_provider and agg_model: - return agg_provider, agg_model - return prov, mdl + # Any moa:// facade endpoint belongs to the facade, not the aggregator's + # real provider — drop it (mirrors _resolve_auto()). if provider and str(provider).strip().lower() == "moa": provider, resolved_model = _unwrap_moa_provider(provider, resolved_model) - # Any moa:// facade endpoint belongs to the facade, not the aggregator's - # real provider — drop it (mirrors _resolve_auto()). if provider and provider.lower() != "moa": base_url = None api_key = None @@ -6878,38 +6861,6 @@ def _resolve_task_provider_model( cfg_base_url = None cfg_api_key = None - # Direct API-key aliases (``provider: openai`` → custom + api.openai.com/v1). - # A user-supplied base_url is kept, but the provider still becomes ``custom`` - # so resolution avoids the PROVIDER_REGISTRY-only path. - def _expand_direct_api_alias(prov: Optional[str], existing_base: Optional[str]) -> Tuple[Optional[str], Optional[str]]: - if not prov: - return prov, existing_base - target_base = _AUX_DIRECT_API_BASE_URLS.get(prov.strip().lower()) - if target_base is None: - return prov, existing_base - return "custom", existing_base or target_base - - def _preserve_provider_with_base_url(prov: Optional[str]) -> bool: - normalized = str(prov or "").strip().lower() - if normalized in {"", "auto", "custom"} or normalized.startswith("custom:"): - return False - try: - from hermes_cli.providers import get_provider - - return get_provider(normalized) is not None - except Exception: - # Keep provider-backed routes safe when the catalog can't load. - return normalized in { - "anthropic", - "copilot", - "copilot-acp", - "minimax-oauth", - "nous", - "openai-codex", - "qwen-oauth", - "xai-oauth", - } - if provider: provider, base_url = _expand_direct_api_alias(provider, base_url) if cfg_provider: @@ -6923,25 +6874,20 @@ def _resolve_task_provider_model( if not api_key: api_key = cfg_api_key - if base_url and _preserve_provider_with_base_url(provider): - return provider, resolved_model, base_url, api_key, resolved_api_mode if base_url: - return "custom", resolved_model, base_url, api_key, resolved_api_mode + kept = provider if _preserve_provider_with_base_url(provider) else "custom" + return kept, resolved_model, base_url, api_key, resolved_api_mode if provider: return provider, resolved_model, base_url, api_key, resolved_api_mode - if task: - if cfg_base_url and cfg_api_key: - return "custom", resolved_model, cfg_base_url, cfg_api_key, resolved_api_mode - if cfg_base_url and cfg_provider and cfg_provider != "auto": - # base_url without api_key: keep the provider so it can resolve - # credentials from env vars instead of locking into "custom". - return cfg_provider, resolved_model, cfg_base_url, None, resolved_api_mode - if cfg_provider and cfg_provider != "auto": - return cfg_provider, resolved_model, cfg_base_url, cfg_api_key, resolved_api_mode - - return "auto", resolved_model, None, None, resolved_api_mode - + if cfg_base_url and cfg_api_key: + return "custom", resolved_model, cfg_base_url, cfg_api_key, resolved_api_mode + if cfg_base_url and cfg_provider and cfg_provider != "auto": + # base_url without api_key: keep the provider so it can resolve + # credentials from env vars instead of locking into "custom". + return cfg_provider, resolved_model, cfg_base_url, None, resolved_api_mode + if cfg_provider and cfg_provider != "auto": + return cfg_provider, resolved_model, cfg_base_url, cfg_api_key, resolved_api_mode return "auto", resolved_model, None, None, resolved_api_mode @@ -6971,17 +6917,13 @@ def _get_auxiliary_task_config(task: str) -> Dict[str, Any]: if not isinstance(task_config, dict): task_config = {} - # Layer plugin defaults under user config so register_auxiliary_task(defaults=…) - # works without config.yaml entries. try: from hermes_cli.plugins import get_plugin_auxiliary_tasks for _entry in get_plugin_auxiliary_tasks(): if _entry.get("key") == task: _defaults = _entry.get("defaults") or {} if isinstance(_defaults, dict): - merged = dict(_defaults) - merged.update(task_config) - return merged + return {**_defaults, **task_config} break except Exception: # Plugin discovery failure must not break aux task config reads.