Merge branch 'simp/r3-03-aux-C' into simp/r3-03-aux

This commit is contained in:
Teknium
2026-09-02 19:05:01 -07:00
+290 -348
View File
@@ -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.<task>.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.<task>.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.<task>.{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.