fix(custom-providers): route @custom:<slug> models to their own base_url (#7319)

Route @custom:<slug> models to their owning custom provider's base_url so a model picked from a second custom provider group resolves correctly instead of 400ing.

Co-authored-by: b3nw <b3nw@users.noreply.github.com>
This commit is contained in:
b3nw authored and GitHub committed 2026-09-15 20:05:40 -07:00
1 parent 27b7064c4c
commit fb2e8b0088
9 files changed
+7863 -297

No files matched your search

+1507 -64
View File
File diff suppressed because it is too large. Load diff
+281 -129
View File
@@ -2970,6 +2970,128 @@ from api.request_diagnostics import RequestDiagnostics
from api.system_health import build_system_health_payload
# ── Non-streaming custom-provider connection authority ───────────────────────
#
# Several WebUI consumers build their own AIAgent outside the streaming path
# (POST /api/chat, manual compression, update summary, git commit message,
# handoff summary). They resolve a model/provider, ask the Hermes runtime
# provider for a connection, then had to apply the named ``custom:<slug>``
# record's authority by hand.
#
# ``apply_custom_provider_connection_authority`` returns only the THREE
# connection fields, and three fields are not a complete constructor contract:
# AIAgent also takes ``api_mode`` (wire protocol), ``credential_pool``
# (credential source) and ``acp_command``/``acp_args`` (subprocess transport).
# A consumer that replaced the endpoint and credential while leaving those to
# default — or, worse, to the ambient runtime — built a mixed-authority agent:
# an exact row's ``api_mode: anthropic_messages`` or its own pool never reached
# the constructor at all. These helpers carry the WHOLE bundle instead, exactly
# as ``api/streaming.py`` does for the streaming path.
# The constructor-routing fields that travel with provider/base_url/api_key as
# one authority. Mirrors ``api.streaming._RUNTIME_BUNDLE_FIELDS`` and
# ``api.config.CUSTOM_CONNECTION_SIDE_FIELDS``.
_AGENT_BUNDLE_SIDE_FIELDS = ("api_mode", "acp_command", "acp_args", "credential_pool")
def _resolve_agent_connection_bundle(
resolved_provider,
resolved_api_key,
resolved_base_url,
runtime_provider=None,
*,
lookup_provider=None,
):
"""Return the COMPLETE constructor-routing bundle for a non-streaming send.
Keys: ``provider``, ``base_url``, ``api_key`` plus every field in
:data:`_AGENT_BUNDLE_SIDE_FIELDS`. Pass the whole dict to the constructor
via :func:`_agent_bundle_kwargs` — the endpoint/credential and the
transport/protocol/pool fields are ONE authority.
``runtime_provider`` is the dict ``resolve_runtime_provider`` returned. It
matters: the merge seeds the side fields from it and then decides, by
endpoint provenance, whether they are same-authority (keep) or the ambient
provider's (clear). Omitting it silently drops that signal.
``lookup_provider`` preserves the pre-canonicalization ``custom:<slug>``
identity, since the merge rewrites a resolved bundle's provider to the
generic ``custom``.
Raises :class:`api.config.CustomProviderRouteError` when the merge returns a
TERMINAL route verdict — a named ``custom:<slug>`` that resolved no complete
``(api_key, base_url)`` pair. This is the single chokepoint for every
non-streaming and auxiliary consumer precisely because an incomplete bundle
is NOT a refusal at the constructor: AIAgent's ``_init_openai_client()``
only honours an explicit pair when BOTH fields are truthy and otherwise
calls ``_routed_client_kwargs()``, which re-resolves a provider and can
reach the ambient endpoint or the init-time fallback chain. Returning the
bundle with a hole in it would therefore route the send somewhere the user
never asked for; raising here keeps the refusal terminal for all five call
sites (POST /api/chat, manual compression, update summary, git commit
message, handoff summary) without each having to remember to check.
The exception subclasses ``ValueError``, so the existing ``except
ValueError`` / broad-``except`` handlers at those call sites already turn it
into a controlled 400 or a deterministic non-LLM fallback.
"""
return api_config.raise_for_custom_provider_route(
api_config.merge_custom_provider_runtime_bundle(
resolved_provider,
resolved_api_key,
resolved_base_url,
runtime_provider,
lookup_provider=lookup_provider or resolved_provider,
)
)
def _agent_bundle_kwargs(agent_cls, bundle):
"""Return the bundle's side-field kwargs supported by ``agent_cls``.
``api_mode``/``acp_command``/``acp_args``/``credential_pool`` were added to
AIAgent over several releases, so gate each on the constructor signature the
way the streaming path does rather than raising TypeError against an older
hermes-agent build. Values come from the BUNDLE, never from the runtime
provider dict: a custom-provider override clears these, and reading them off
the runtime would re-introduce the authority the merge just replaced.
"""
import inspect as _inspect
try:
params = set(_inspect.signature(agent_cls.__init__).parameters)
except (TypeError, ValueError):
return {}
return {
field: bundle[field]
for field in _AGENT_BUNDLE_SIDE_FIELDS
if field in params
}
def _auxiliary_main_runtime(bundle, model):
"""Return the ``main_runtime`` an auxiliary client must receive for a bundle.
When the auxiliary client answers, AIAgent is never built, so this dict is
the ONLY place the resolved authority reaches the wire. It therefore carries
the same whole bundle :func:`_agent_bundle_kwargs` hands the constructor —
endpoint and credential plus every field in
:data:`_AGENT_BUNDLE_SIDE_FIELDS`. Sending only provider/model/base_url/
api_key silently downgraded an exact row's ``api_mode``
(``anthropic_messages`` fell back to chat completions) and dropped the
credential pool/ACP transport that belong to the same record.
"""
runtime = {
"provider": bundle["provider"],
"model": model,
"base_url": bundle["base_url"],
"api_key": bundle["api_key"],
}
for field in _AGENT_BUNDLE_SIDE_FIELDS:
runtime[field] = bundle[field]
return runtime
def _kanban_unknown_endpoint(handler, parsed, method: str) -> bool:
"""Return a Kanban-specific 404 for stale clients/obsolete endpoint shapes."""
return bad(
@@ -14907,6 +15029,96 @@ def _resolve_new_session_workspace(body, visible_prev_session_id, profile=None):
)
return str(workspace)
def _llm_update_summary(system_prompt: str, user_prompt: str, active_profile: str | None = None) -> str:
from api import profiles as profiles_api
profile = active_profile or profiles_api.get_active_profile_name() or "default"
with profiles_api.profile_env_for_background_worker(
profile,
"update summary",
logger_override=logger,
):
from api.config import (
get_effective_default_model,
resolve_model_provider,
)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
_main_model, _main_provider, _main_base_url = resolve_model_provider(get_effective_default_model())
_main_api_key = None
_rt = None
try:
from api.oauth import resolve_runtime_provider_with_anthropic_env_lock
from hermes_cli.runtime_provider import resolve_runtime_provider
_rt = resolve_runtime_provider_with_anthropic_env_lock(
resolve_runtime_provider,
requested=_main_provider,
)
_main_api_key = _rt.get("api_key")
if not _main_provider:
_main_provider = _rt.get("provider")
if not _main_base_url:
_main_base_url = _rt.get("base_url")
except Exception as _e:
logger.debug("update summary runtime provider resolution failed: %s", _e)
# Atomic custom-provider authority (see the /api/chat note): the record
# that supplies the endpoint must also supply the credential — and the
# wire protocol, credential pool and ACP transport that go with it.
_bundle = _resolve_agent_connection_bundle(
_main_provider, _main_api_key, _main_base_url, _rt
)
_main_provider = _bundle["provider"]
_main_api_key = _bundle["api_key"]
_main_base_url = _bundle["base_url"]
main_runtime = _auxiliary_main_runtime(_bundle, _main_model)
ensure_agent_runtime_current()
try:
from agent.auxiliary_client import get_text_auxiliary_client
aux_client, aux_model = get_text_auxiliary_client(
"compression",
main_runtime=main_runtime,
)
if aux_client is not None and aux_model:
response = aux_client.chat.completions.create(
model=aux_model,
messages=messages,
)
return str(response.choices[0].message.content or "").strip()
except Exception as _e:
logger.debug("update summary auxiliary model failed; falling back to main model: %s", _e)
AIAgent = require_ai_agent_class()
agent = AIAgent(
model=_main_model,
provider=_main_provider,
base_url=_main_base_url,
api_key=_main_api_key,
platform="webui",
quiet_mode=True,
enabled_toolsets=[],
session_id=f"updates-summary-{uuid.uuid4().hex[:8]}",
**_agent_bundle_kwargs(AIAgent, _bundle),
)
result = agent.run_conversation(
user_message=user_prompt,
system_message=system_prompt,
conversation_history=[],
task_id=f"updates-summary-{uuid.uuid4().hex[:8]}",
)
return str(result.get("final_response") or "").strip()
def handle_post(handler, parsed) -> bool:
"""Handle all POST routes. Returns True if handled, False for 404."""
diag = RequestDiagnostics.maybe_start("POST", parsed.path, logger=logger, print_fn=getattr(handler, '_safe_webui_print', None))
@@ -17369,99 +17581,6 @@ def handle_post(handler, parsed) -> bool:
updates = body.get("updates") if isinstance(body, dict) else {}
target = body.get("target") if isinstance(body, dict) else None
def _llm_update_summary(system_prompt: str, user_prompt: str) -> str:
from api import profiles as profiles_api
active_profile = profiles_api.get_active_profile_name() or "default"
with profiles_api.profile_env_for_background_worker(
active_profile,
"update summary",
logger_override=logger,
):
from api.config import (
get_effective_default_model,
resolve_model_provider,
resolve_custom_provider_connection,
)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
_main_model, _main_provider, _main_base_url = resolve_model_provider(get_effective_default_model())
_main_api_key = None
try:
from api.oauth import resolve_runtime_provider_with_anthropic_env_lock
from hermes_cli.runtime_provider import resolve_runtime_provider
_rt = resolve_runtime_provider_with_anthropic_env_lock(
resolve_runtime_provider,
requested=_main_provider,
)
_main_api_key = _rt.get("api_key")
if not _main_provider:
_main_provider = _rt.get("provider")
if not _main_base_url:
_main_base_url = _rt.get("base_url")
except Exception as _e:
logger.debug("update summary runtime provider resolution failed: %s", _e)
if isinstance(_main_provider, str) and _main_provider.startswith("custom:"):
_cp_key, _cp_base = resolve_custom_provider_connection(_main_provider)
if not _main_api_key and _cp_key:
_main_api_key = _cp_key
if not _main_base_url and _cp_base:
_main_base_url = _cp_base
main_runtime = {
"provider": _main_provider,
"model": _main_model,
"base_url": _main_base_url,
"api_key": _main_api_key,
}
ensure_agent_runtime_current()
try:
from agent.auxiliary_client import get_text_auxiliary_client
# Update summaries are a short text-compression/summarization task.
# Reuse the documented auxiliary.compression slot instead of
# inventing a WebUI-only auxiliary task name that users cannot
# discover in the Hermes Agent setup/config UI.
aux_client, aux_model = get_text_auxiliary_client(
"compression",
main_runtime=main_runtime,
)
if aux_client is not None and aux_model:
response = aux_client.chat.completions.create(
model=aux_model,
messages=messages,
)
return str(response.choices[0].message.content or "").strip()
except Exception as _e:
logger.debug("update summary auxiliary model failed; falling back to main model: %s", _e)
AIAgent = require_ai_agent_class()
agent = AIAgent(
model=_main_model,
provider=_main_provider,
base_url=_main_base_url,
api_key=_main_api_key,
platform="webui",
quiet_mode=True,
enabled_toolsets=[],
session_id=f"updates-summary-{uuid.uuid4().hex[:8]}",
)
result = agent.run_conversation(
user_message=user_prompt,
system_message=system_prompt,
conversation_history=[],
task_id=f"updates-summary-{uuid.uuid4().hex[:8]}",
)
return str(result.get("final_response") or "").strip()
return j(handler, summarize_update_payload(updates, llm_callback=_llm_update_summary, target=target))
# ── CLI session import (POST) ──
@@ -24525,16 +24644,14 @@ def _handle_chat_sync(handler, body):
AIAgent = require_ai_agent_class()
with CHAT_LOCK:
from api.config import (
resolve_model_provider,
resolve_custom_provider_connection,
)
from api.config import resolve_model_provider
_model, _provider, _base_url = resolve_model_provider(
model_with_provider_context(s.model, getattr(s, "model_provider", None))
)
# Resolve API key via Hermes runtime provider (matches gateway behaviour)
_api_key = None
_rt = None
try:
from api.oauth import resolve_runtime_provider_with_anthropic_env_lock
from hermes_cli.runtime_provider import resolve_runtime_provider
@@ -24554,12 +24671,35 @@ def _handle_chat_sync(handler, body):
f"[webui] WARNING: resolve_runtime_provider failed: {_e}",
flush=True,
)
if isinstance(_provider, str) and _provider.startswith("custom:"):
_cp_key, _cp_base = resolve_custom_provider_connection(_provider)
if not _api_key and _cp_key:
_api_key = _cp_key
if not _base_url and _cp_base:
_base_url = _cp_base
# Apply the named custom provider's OWN record atomically, as one
# COMPLETE bundle. The fill-only form this replaced kept a truthy
# runtime value, so a slug present in BOTH custom_providers[] and
# providers: sent the list row's URL with the keyed row's API key;
# the connection-only view that followed still truncated the record's
# api_mode / credential_pool / ACP transport before the constructor.
try:
_bundle = _resolve_agent_connection_bundle(
_provider, _api_key, _base_url, _rt
)
except api_config.CustomProviderRouteError as _route_err:
# The named route resolved no usable connection. Constructing
# AIAgent with the incomplete pair would send this turn through
# ``_routed_client_kwargs()`` to whatever provider init resolves
# next, so answer with the actionable cause instead. 400, not
# 500: it is a user-fixable provider misconfiguration, exactly
# like the ambiguous-slug collision.
logger.warning(
"Chat blocked by unroutable custom provider: %s", _route_err.message
)
return j(handler, {
"error": _route_err.message,
"type": "custom_provider_unroutable",
"reason": _route_err.reason,
"hint": _route_err.hint,
}, status=400)
_provider = _bundle["provider"]
_api_key = _bundle["api_key"]
_base_url = _bundle["base_url"]
agent = AIAgent(
model=_model,
provider=_provider,
@@ -24571,6 +24711,7 @@ def _handle_chat_sync(handler, body):
quiet_mode=True,
enabled_toolsets=_resolve_cli_toolsets(),
session_id=s.session_id,
**_agent_bundle_kwargs(AIAgent, _bundle),
)
from api.streaming import (
_WEBUI_PROGRESS_PROMPT,
@@ -25138,7 +25279,6 @@ def _llm_git_commit_message(system_prompt: str, user_prompt: str, session=None)
from api.config import (
get_effective_default_model,
model_with_provider_context,
resolve_custom_provider_connection,
resolve_model_provider,
)
@@ -25151,6 +25291,7 @@ def _llm_git_commit_message(system_prompt: str, user_prompt: str, session=None)
)
_main_model, _main_provider, _main_base_url = resolve_model_provider(model_for_resolution)
_main_api_key = None
_rt = None
try:
from api.oauth import resolve_runtime_provider_with_anthropic_env_lock
from hermes_cli.runtime_provider import resolve_runtime_provider
@@ -25166,23 +25307,21 @@ def _llm_git_commit_message(system_prompt: str, user_prompt: str, session=None)
_main_base_url = _rt.get("base_url")
except Exception as _e:
logger.debug("git commit message runtime provider resolution failed: %s", _e)
if isinstance(_main_provider, str) and _main_provider.startswith("custom:"):
_cp_key, _cp_base = resolve_custom_provider_connection(_main_provider)
if not _main_api_key and _cp_key:
_main_api_key = _cp_key
if not _main_base_url and _cp_base:
_main_base_url = _cp_base
# Atomic custom-provider authority (see the /api/chat note): the record
# that supplies the endpoint must also supply the credential — and the
# wire protocol, credential pool and ACP transport that go with it.
_bundle = _resolve_agent_connection_bundle(
_main_provider, _main_api_key, _main_base_url, _rt
)
_main_provider = _bundle["provider"]
_main_api_key = _bundle["api_key"]
_main_base_url = _bundle["base_url"]
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
main_runtime = {
"provider": _main_provider,
"model": _main_model,
"base_url": _main_base_url,
"api_key": _main_api_key,
}
main_runtime = _auxiliary_main_runtime(_bundle, _main_model)
ensure_agent_runtime_current()
try:
from agent.auxiliary_client import get_text_auxiliary_client
@@ -25211,6 +25350,7 @@ def _llm_git_commit_message(system_prompt: str, user_prompt: str, session=None)
quiet_mode=True,
enabled_toolsets=[],
session_id=f"git-commit-message-{uuid.uuid4().hex[:8]}",
**_agent_bundle_kwargs(AIAgent, _bundle),
)
result = agent.run_conversation(
user_message=user_prompt,
@@ -27230,6 +27370,7 @@ def _handle_session_compress(handler, body):
)
resolved_api_key = None
_rt = None
try:
_rt = resolve_runtime_provider_with_anthropic_env_lock(
_runtime_provider.resolve_runtime_provider,
@@ -27243,12 +27384,16 @@ def _handle_session_compress(handler, body):
except Exception as _e:
logger.warning("resolve_runtime_provider failed for compression: %s", _e)
if isinstance(resolved_provider, str) and resolved_provider.startswith("custom:"):
_cp_key, _cp_base = _cfg.resolve_custom_provider_connection(resolved_provider)
if not resolved_api_key and _cp_key:
resolved_api_key = _cp_key
if not resolved_base_url and _cp_base:
resolved_base_url = _cp_base
# Atomic custom-provider authority: the deterministic list-row URL must
# not be paired with a keyed/runtime API key (see the /api/chat note),
# and the record's own api_mode / credential_pool / ACP transport must
# reach the constructor with it rather than being truncated away.
_bundle = _resolve_agent_connection_bundle(
resolved_provider, resolved_api_key, resolved_base_url, _rt
)
resolved_provider = _bundle["provider"]
resolved_api_key = _bundle["api_key"]
resolved_base_url = _bundle["base_url"]
if not resolved_api_key:
return bad(handler, "No provider configured -- cannot compress.")
@@ -27277,6 +27422,7 @@ def _handle_session_compress(handler, body):
quiet_mode=True,
enabled_toolsets=_resolve_cli_toolsets(),
session_id=sid,
**_agent_bundle_kwargs(AIAgent, _bundle),
)
compressed = agent.context_compressor.compress(
original_messages,
@@ -27905,7 +28051,7 @@ def _handle_handoff_summary(handler, body):
# of the resolve_model_provider fix). model_with_provider_context
# encodes it as @custom:A:model so the resolver honors the session's
# endpoint; base_url is backfilled from that provider's own custom
# entry by the resolve_custom_provider_connection block below.
# entry by the custom-provider authority block below.
session_model_provider = getattr(s_obj, "model_provider", None)
except Exception:
pass
@@ -27916,6 +28062,7 @@ def _handle_handoff_summary(handler, body):
resolved_model, resolved_provider, resolved_base_url = _cfg.resolve_model_provider(model_for_resolution)
resolved_api_key = None
_rt = None
try:
_rt = resolve_runtime_provider_with_anthropic_env_lock(
_runtime_provider.resolve_runtime_provider,
@@ -27929,12 +28076,16 @@ def _handle_handoff_summary(handler, body):
except Exception as _e:
logger.warning("resolve_runtime_provider failed for handoff summary: %s", _e)
if isinstance(resolved_provider, str) and resolved_provider.startswith("custom:"):
_cp_key, _cp_base = _cfg.resolve_custom_provider_connection(resolved_provider)
if not resolved_api_key and _cp_key:
resolved_api_key = _cp_key
if not resolved_base_url and _cp_base:
resolved_base_url = _cp_base
# Atomic custom-provider authority (see the /api/chat note): the session's
# own custom_providers[] row owns BOTH the endpoint and the credential,
# plus the api_mode / credential_pool / ACP transport that travel with
# them as one constructor bundle.
_bundle = _resolve_agent_connection_bundle(
resolved_provider, resolved_api_key, resolved_base_url, _rt
)
resolved_provider = _bundle["provider"]
resolved_api_key = _bundle["api_key"]
resolved_base_url = _bundle["base_url"]
if not resolved_api_key:
summary_text = _fallback_handoff_summary(msgs)
@@ -27965,6 +28116,7 @@ def _handle_handoff_summary(handler, body):
quiet_mode=True,
enabled_toolsets=[],
session_id=sid,
**_agent_bundle_kwargs(AIAgent, _bundle),
)
summary_system_prompt = (
+409 -85
View File
@@ -45,6 +45,13 @@ from api.config import (
SESSION_AGENT_LOCKS, SESSION_AGENT_LOCKS_LOCK,
resolve_model_provider,
resolve_custom_provider_connection,
apply_custom_provider_connection_authority,
merge_custom_provider_runtime_bundle,
CustomProviderRouteError,
CUSTOM_ROUTE_NO_CREDENTIAL,
CUSTOM_ROUTE_NO_ENDPOINT,
custom_provider_route_error,
raise_for_custom_provider_route,
model_with_provider_context,
warm_models_catalog_provenance_if_cold,
load_settings,
@@ -271,7 +278,6 @@ def _cancel_event_payload(
# (api/profiles.py:715).
_ENV_LOCK = threading.Lock()
_KEYLESS_CUSTOM_API_KEY = "dummy-key"
_STREAM_WRITEBACK_DIAG_DEFAULT_THRESHOLD_MS = 250.0
_STREAMING_CRON_PROFILE_HOME: contextvars.ContextVar[str | None] = contextvars.ContextVar(
@@ -570,50 +576,121 @@ def _apply_profile_home_context_to_streaming_model(
return model, provider_context, False
def _resolve_custom_provider_runtime_overrides(
def _resolve_custom_provider_connection_authority(
resolved_provider: str | None,
resolved_api_key: str | None,
resolved_base_url: str | None,
profile_name: str | None = None,
) -> tuple[str | None, str | None, str | None]:
"""Return provider/key/base_url overrides for ``custom:*`` endpoints.
custom_provider_lookup: str | None = None,
) -> tuple[str | None, str | None, str | None, bool]:
"""Return ``(provider, api_key, base_url, custom_owned)`` for ``custom:*``.
Hermes Agent treats named custom providers as routing hints around an
OpenAI-compatible base URL. Local OpenAI-compatible servers often run
without authentication, so a missing key should not fail before the first
request; pass a harmless placeholder to the SDK and let the endpoint accept
it or return its own auth error.
Thin profile-scoped wrapper around
:func:`api.config.apply_custom_provider_connection_authority`, which holds the
atomic-replacement rules (exact ``custom_providers[]`` row owns BOTH the
endpoint and the credential; keyed/``model:`` fallbacks only fill gaps, as one
same-record bundle).
``profile_name`` binds the SESSION's own profile while
``resolve_custom_provider_connection`` reads the endpoint AND the credential.
That helper resolves both from ONE ``get_config()``/env snapshot, so binding
the profile here keeps them from splitting across profiles: on a detached
worker thread (which doesn't inherit the request-profile TLS/env) an unbound
call would read the DEFAULT profile and pair a named profile's endpoint with
the default profile's API key (finding #3). No-op for the default/root
profile; when ``profile_name`` is None the ambient scope (if any) is used.
``profile_name`` binds the SESSION's own profile while the connection is
resolved. That lookup reads the endpoint AND the credential from ONE
``get_config()``/env snapshot, so binding the profile here keeps them from
splitting across profiles: on a detached worker thread (which doesn't inherit
the request-profile TLS/env) an unbound call would read the DEFAULT profile
and pair a named profile's endpoint with the default profile's API key
(finding #3). No-op for the default/root profile; when ``profile_name`` is
None the ambient scope (if any) is used.
``custom_provider_lookup`` carries the session's pre-canonicalization
identity, so a provider already rewritten to plain ``"custom"`` by an earlier
resolve still selects its own named record on a retry.
``custom_owned`` is True when a config-owned custom record supplied the
connection; see :func:`_resolve_runtime_connection_bundle` for why callers
must then clear the runtime-owned side fields.
"""
if not (isinstance(resolved_provider, str) and resolved_provider.startswith("custom:")):
return resolved_provider, resolved_api_key, resolved_base_url
lookup_provider = custom_provider_lookup or resolved_provider
if not (isinstance(lookup_provider, str) and lookup_provider.startswith("custom:")):
return resolved_provider, resolved_api_key, resolved_base_url, False
from api import profiles as _profiles_api
with _profiles_api.profile_scope_for_detached_worker(
profile_name, "custom provider connection", logger_override=logger
):
_cp_key, _cp_base = resolve_custom_provider_connection(resolved_provider)
if not resolved_api_key and _cp_key:
resolved_api_key = _cp_key
if not resolved_base_url and _cp_base:
resolved_base_url = _cp_base
if resolved_base_url:
# Route through the generic custom OpenAI-compatible client once the
# named provider has supplied the concrete endpoint. Keeping the
# provider as custom:<slug> would make Agent init synthesize invalid
# env-var hints like CUSTOM:SOMETHING-8000_API_KEY on keyless setups.
resolved_provider = "custom"
if not resolved_api_key:
resolved_api_key = _KEYLESS_CUSTOM_API_KEY
return resolved_provider, resolved_api_key, resolved_base_url
return apply_custom_provider_connection_authority(
resolved_provider,
resolved_api_key,
resolved_base_url,
lookup_provider=lookup_provider,
# Pass THIS module's binding so tests (and any future shim) that patch
# ``streaming.resolve_custom_provider_connection`` still take effect.
connection_resolver=resolve_custom_provider_connection,
)
def _resolve_custom_provider_runtime_overrides(
resolved_provider: str | None,
resolved_api_key: str | None,
resolved_base_url: str | None,
profile_name: str | None = None,
custom_provider_lookup: str | None = None,
) -> tuple[str | None, str | None, str | None]:
"""Return provider/key/base_url overrides for ``custom:*`` endpoints.
Legacy three-value view of
:func:`_resolve_custom_provider_connection_authority`. Prefer
:func:`_resolve_runtime_connection_bundle` in agent-construction paths: the
three fields alone are NOT a complete constructor bundle, so a caller that
keeps the ambient runtime's ``credential_pool`` / ``api_mode`` / ACP
command+args beside them still sends a mixed-authority agent to the custom
endpoint.
"""
return _resolve_custom_provider_connection_authority(
resolved_provider,
resolved_api_key,
resolved_base_url,
profile_name=profile_name,
custom_provider_lookup=custom_provider_lookup,
)[:3]
# The constructor-routing fields AIAgent takes from the resolved runtime
# provider. They travel together: a bundle that replaces the endpoint/credential
# but leaves these behind builds an agent whose transport (ACP subprocess),
# wire protocol (api_mode) or credential source (pool) still points at the
# previous authority.
_RUNTIME_BUNDLE_FIELDS = ('api_mode', 'acp_command', 'acp_args', 'credential_pool')
def _resolve_runtime_connection_bundle(
resolved_provider: str | None,
resolved_api_key: str | None,
resolved_base_url: str | None,
runtime_provider: dict | None,
profile_name: str | None = None,
custom_provider_lookup: str | None = None,
) -> dict:
"""Return the COMPLETE constructor-routing bundle for one send attempt.
Keys: ``provider``, ``base_url``, ``api_key`` plus every field in
:data:`_RUNTIME_BUNDLE_FIELDS`. Callers must apply the whole dict — that is
the point: the endpoint/credential and the transport/protocol/pool fields
are one authority, and the agent-cache signature must be derived from the
same dict so a bundle change always mints a new agent.
"""
lookup_provider = custom_provider_lookup or resolved_provider
from api import profiles as _profiles_api
with _profiles_api.profile_scope_for_detached_worker(
profile_name, "custom provider connection", logger_override=logger
):
return merge_custom_provider_runtime_bundle(
resolved_provider,
resolved_api_key,
resolved_base_url,
runtime_provider,
lookup_provider=lookup_provider,
connection_resolver=resolve_custom_provider_connection,
)
def _same_base_url_endpoint(url_a: str, url_b: str) -> bool:
@@ -645,6 +722,7 @@ def _runtime_preferred_base_url(
runtime_provider: dict | None,
resolved_provider: str | None,
configured_base_url: str | None,
session_requested_provider: str | None = None,
) -> str | None:
"""Prefer the runtime-normalized base_url, but never override an explicit
configured endpoint that points somewhere genuinely different.
@@ -670,7 +748,8 @@ def _runtime_preferred_base_url(
return runtime_base_url
provider_id = str(
resolved_provider
session_requested_provider
or resolved_provider
or (runtime_provider or {}).get("provider")
or ""
).strip().lower()
@@ -1429,6 +1508,41 @@ def _result_reports_compression_snapshot_stale(result) -> bool:
)
def _custom_provider_route_classification(error) -> dict:
"""WebUI apperror classification for a terminal custom-provider route.
``error`` is either a :class:`CustomProviderRouteError` or the verdict dict
:func:`custom_provider_route_error` read off a bundle — the retry paths hold
the verdict without ever raising, so both shapes classify identically.
"""
if isinstance(error, dict):
reason = error.get('reason')
hint = error.get('hint') or ''
message = error.get('message') or ''
else:
reason = getattr(error, 'reason', None)
hint = getattr(error, 'hint', '') or ''
message = getattr(error, 'message', None) or str(error)
if reason == CUSTOM_ROUTE_NO_CREDENTIAL:
label = 'Provider credential unavailable'
elif reason == CUSTOM_ROUTE_NO_ENDPOINT:
label = 'Provider endpoint unavailable'
else:
label = 'Provider not configured'
return {
'label': label,
'type': 'provider_unroutable',
# The STRUCTURED verdict (``custom_provider_endpoint_unresolved`` /
# ``custom_provider_no_credential``) travels beside the prose so the
# emitted apperror can carry it too. ``type`` alone says only "this route
# is unroutable"; which of the two settings to fix lives here, and the
# non-streaming /api/chat refusal already answers it as ``reason``.
'reason': reason,
'hint': hint,
'message': message,
}
def _classify_provider_error(
err_str: str,
exc=None,
@@ -1451,6 +1565,12 @@ def _classify_provider_error(
err_str = str(_probe_text or err_str or '')
_err_lower = err_str.lower()
_exc_name = type(exc).__name__ if exc is not None else ''
if isinstance(exc, CustomProviderRouteError):
# An unroutable named ``custom:<slug>`` route. Classified BEFORE any text
# matching so it can never be read as a 401 — the auth branch would run
# credential self-heal, which re-resolves a provider and is exactly the
# re-routing this verdict exists to prevent.
return _custom_provider_route_classification(exc)
_result_is_compression_snapshot_stale = (
_result_reports_compression_snapshot_stale(result)
)
@@ -8745,6 +8865,54 @@ def _agent_cache_api_key_sig(resolved_api_key, credential_pool) -> str:
return _hashlib.sha256((resolved_api_key or '').encode()).hexdigest()[:16]
def _compute_agent_cache_signature(
resolved_model: str | None,
resolved_api_key: str | None,
resolved_base_url: str | None,
resolved_provider: str | None,
runtime_bundle: dict | None,
max_iterations_cfg=None,
max_tokens_cfg=None,
fallback_resolved=None,
toolsets=None,
reasoning_config=None,
main_request_overrides=None,
_main_request_overrides=None,
prefill_context=None,
profile_home: str | None = None,
safe_profile_runtime_env: dict | None = None,
) -> str:
"""Return the SHA256 signature identifying an agent's constructor routing state."""
import hashlib as _hashlib
import json as _json
_bundle = runtime_bundle if isinstance(runtime_bundle, dict) else {}
_credential_pool = _bundle.get('credential_pool')
_env = safe_profile_runtime_env if isinstance(safe_profile_runtime_env, dict) else {}
_main_request_overrides = main_request_overrides if main_request_overrides is not None else _main_request_overrides
_sig_blob = _json.dumps([
resolved_model or '',
_agent_cache_api_key_sig(resolved_api_key, _credential_pool),
resolved_base_url or '',
resolved_provider or '',
_bundle.get('api_mode') or '',
_bundle.get('acp_command') or '',
_bundle.get('acp_args') or [],
bool(_credential_pool),
max_iterations_cfg or '',
max_tokens_cfg or '',
fallback_resolved or {},
sorted(toolsets) if toolsets else [],
reasoning_config or {},
_main_request_overrides or {},
_public_prefill_context_status(prefill_context),
profile_home or '',
_env.get('TERMINAL_ENV', '') or '',
_env.get('TERMINAL_SSH_HOST', '') or '',
_env.get('TERMINAL_SSH_USER', '') or '',
], sort_keys=True)
return _hashlib.sha256(_sig_blob.encode()).hexdigest()[:16]
def _lifecycle_commit_session_memory(session_id: str, *, agent=None, wait: bool = False) -> bool:
from api.session_lifecycle import commit_session_memory
@@ -10348,6 +10516,9 @@ def _run_agent_streaming(
# Resolve API key via Hermes runtime provider (matches gateway behaviour).
# Pass the resolved provider so non-default providers get their own credentials.
resolved_api_key = None
# Default to an empty runtime dict so the constructor-routing
# bundle below stays buildable when resolution raises.
_rt = {}
try:
from api.oauth import resolve_runtime_provider_with_anthropic_env_lock
from hermes_cli.runtime_provider import resolve_runtime_provider
@@ -10371,11 +10542,38 @@ def _run_agent_streaming(
# Preserve the pre-canonicalization identity so image routing can
# still select the exact custom_providers entry after the rewrite
# to "custom" below.
#
# Resolve ONE complete constructor-routing bundle here and let
# every downstream field (including the agent-cache signature)
# read from it. Resolving only provider/key/base_url left the
# runtime-owned credential_pool / api_mode / ACP command+args
# beside a replaced custom endpoint, so the agent was built with
# a mixed authority: the list row's URL and key, but the ambient
# provider's transport, wire protocol and credential source.
_session_requested_provider = resolved_provider
resolved_provider, resolved_api_key, resolved_base_url = _resolve_custom_provider_runtime_overrides(
resolved_provider, resolved_api_key, resolved_base_url,
_runtime_bundle = _resolve_runtime_connection_bundle(
resolved_provider, resolved_api_key, resolved_base_url, _rt,
profile_name=_resolved_profile_name,
custom_provider_lookup=_session_requested_provider,
)
# Stop HERE on a terminal route verdict — before the agent
# kwargs, before _AIAgent(), before the agent-cache write. A
# named custom:<slug> that resolved no complete
# (api_key, base_url) pair is not "fail closed" at the
# constructor: AIAgent honours an explicit pair only when BOTH
# are truthy and otherwise calls _routed_client_kwargs(), which
# re-resolves a provider and can land on the ambient endpoint or
# the init-time fallback chain. Raising sends this turn to the
# outer handler, which classifies it via
# _custom_provider_route_classification() and emits a controlled
# provider / missing-credential error — and, because we never
# reach SESSION_AGENT_CACHE, the cache is never poisoned with an
# agent built on an unroutable bundle that later turns would
# silently reuse.
raise_for_custom_provider_route(_runtime_bundle)
resolved_provider = _runtime_bundle['provider']
resolved_api_key = _runtime_bundle['api_key']
resolved_base_url = _runtime_bundle['base_url']
# Read per-profile config at call time (not module-level snapshot).
# The streaming worker is a detached thread that does NOT inherit the
@@ -10582,15 +10780,18 @@ def _run_agent_streaming(
_agent_kwargs['max_tokens'] = _max_tokens_cfg
if 'request_overrides' in _agent_params and _main_request_overrides:
_agent_kwargs['request_overrides'] = _main_request_overrides
# Params added in newer hermes-agent — skip if not supported
# Params added in newer hermes-agent — skip if not supported.
# Read from the resolved bundle, NOT from _rt: a custom-provider
# override clears these, and taking them straight off the runtime
# provider would re-introduce the authority it replaced.
if 'api_mode' in _agent_params:
_agent_kwargs['api_mode'] = _rt.get('api_mode')
_agent_kwargs['api_mode'] = _runtime_bundle['api_mode']
if 'acp_command' in _agent_params:
_agent_kwargs['acp_command'] = _rt.get('command')
_agent_kwargs['acp_command'] = _runtime_bundle['acp_command']
if 'acp_args' in _agent_params:
_agent_kwargs['acp_args'] = _rt.get('args')
_agent_kwargs['acp_args'] = _runtime_bundle['acp_args']
if 'credential_pool' in _agent_params:
_agent_kwargs['credential_pool'] = _rt.get('credential_pool')
_agent_kwargs['credential_pool'] = _runtime_bundle['credential_pool']
# Pin Honcho memory sessions to the stable WebUI session ID.
# Without this, 'per-session' Honcho strategy creates a new Honcho
# session on every streaming request because HonchoSessionManager is
@@ -10605,41 +10806,26 @@ def _run_agent_streaming(
agent = _AIAgent(**_agent_kwargs)
logger.debug('[webui] Created ephemeral agent for session %s', session_id)
else:
import hashlib as _hashlib
import json as _json
from api.config import SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK
_credential_pool = _rt.get('credential_pool')
_sig_blob = _json.dumps([
resolved_model or '',
_agent_cache_api_key_sig(resolved_api_key, _credential_pool),
resolved_base_url or '',
resolved_provider or '',
_rt.get('api_mode') or '',
_rt.get('command') or '',
_rt.get('args') or [],
bool(_credential_pool),
_max_iterations_cfg or '',
_max_tokens_cfg or '',
_fallback_resolved or {},
sorted(_toolsets) if _toolsets else [],
_reasoning_config or {},
_main_request_overrides or {},
_public_prefill_context_status(_prefill_context),
# #1897: profile_home is part of the agent's identity because
# AIAgent caches `_cached_system_prompt` from `load_soul_md()`
# at construction time, sourced from HERMES_HOME. Same-session
# profile switches keep `session_id` stable, so without this
# field the cached agent silently retains the previous
# profile's SOUL.md (and any other profile-scoped context).
_profile_home or '',
# Terminal backend identity: sessions switching between
# remote (SSH) and local backends must not reuse a cached
# agent that carries stale terminal env vars (#5937).
_safe_profile_runtime_env.get('TERMINAL_ENV', '') or '',
_safe_profile_runtime_env.get('TERMINAL_SSH_HOST', '') or '',
_safe_profile_runtime_env.get('TERMINAL_SSH_USER', '') or '',
], sort_keys=True)
_agent_sig = _hashlib.sha256(_sig_blob.encode()).hexdigest()[:16]
# Signature fields come from the SAME bundle the constructor
# received, so a cleared/overridden field always mints a new
# agent instead of reusing one built on the prior authority.
_agent_sig = _compute_agent_cache_signature(
resolved_model,
resolved_api_key,
resolved_base_url,
resolved_provider,
_runtime_bundle,
max_iterations_cfg=_max_iterations_cfg,
max_tokens_cfg=_max_tokens_cfg,
fallback_resolved=_fallback_resolved,
toolsets=_toolsets,
reasoning_config=_reasoning_config,
main_request_overrides=_main_request_overrides,
prefill_context=_prefill_context,
profile_home=_profile_home,
safe_profile_runtime_env=_safe_profile_runtime_env,
)
agent = None
_identity_mismatch_entry = None
@@ -11497,7 +11683,8 @@ def _run_agent_streaming(
if not resolved_provider:
resolved_provider = _heal_rt.get('provider')
resolved_base_url = _runtime_preferred_base_url(
_heal_rt, resolved_provider, configured_base_url
_heal_rt, resolved_provider, configured_base_url,
session_requested_provider=_session_requested_provider,
)
# Preserve the session's original pre-canonicalization
# provider identity (captured at first resolve) so a
@@ -11506,21 +11693,64 @@ def _run_agent_streaming(
# (e.g. the provider was first discovered on this heal).
if not _session_requested_provider:
_session_requested_provider = resolved_provider
resolved_provider, resolved_api_key, resolved_base_url = _resolve_custom_provider_runtime_overrides(
resolved_provider, resolved_api_key, resolved_base_url,
# Rebuild the COMPLETE bundle, not just the three
# connection fields: the self-heal re-resolve can
# return a different runtime authority, and a
# custom-provider override must clear its
# credential_pool / api_mode / ACP fields here too.
_runtime_bundle = _resolve_runtime_connection_bundle(
resolved_provider, resolved_api_key, resolved_base_url, _heal_rt,
profile_name=_resolved_profile_name,
custom_provider_lookup=_session_requested_provider,
)
# The re-resolve can turn a previously routable named
# route terminal (the record's key_cmd stopped
# minting, the pool drained, the row was edited
# mid-turn). Abandon the retry BEFORE _AIAgent and
# before the SESSION_AGENT_CACHE write below: healing
# a 401 by handing the constructor an incomplete pair
# is exactly the re-routing this verdict exists to
# stop, and caching that agent would carry it into
# every later turn. Raising reaches the outer handler,
# which emits the controlled provider /
# missing-credential error instead of the 401.
raise_for_custom_provider_route(_runtime_bundle)
resolved_provider = _runtime_bundle['provider']
resolved_api_key = _runtime_bundle['api_key']
resolved_base_url = _runtime_bundle['base_url']
# Rebuild agent kwargs and create a fresh agent
_agent_kwargs['api_key'] = resolved_api_key
_agent_kwargs['base_url'] = resolved_base_url
_agent_kwargs['model'] = resolved_model
_agent_kwargs['provider'] = resolved_provider
_replace_session_db_in_kwargs(_agent_kwargs, _state_db_path)
if 'api_mode' in _agent_params:
_agent_kwargs['api_mode'] = _runtime_bundle['api_mode']
if 'acp_command' in _agent_params:
_agent_kwargs['acp_command'] = _runtime_bundle['acp_command']
if 'acp_args' in _agent_params:
_agent_kwargs['acp_args'] = _runtime_bundle['acp_args']
if 'credential_pool' in _agent_params:
_agent_kwargs['credential_pool'] = _heal_rt.get('credential_pool')
_agent_kwargs['credential_pool'] = _runtime_bundle['credential_pool']
agent = _AIAgent(**_agent_kwargs)
with STREAMS_LOCK:
AGENT_INSTANCES[stream_id] = agent
_agent_sig = _compute_agent_cache_signature(
resolved_model,
resolved_api_key,
resolved_base_url,
resolved_provider,
_runtime_bundle,
max_iterations_cfg=_max_iterations_cfg,
max_tokens_cfg=_max_tokens_cfg,
fallback_resolved=_fallback_resolved,
toolsets=_toolsets,
reasoning_config=_reasoning_config,
main_request_overrides=_main_request_overrides,
prefill_context=_prefill_context,
profile_home=_profile_home,
safe_profile_runtime_env=_safe_profile_runtime_env,
)
from api.config import SESSION_AGENT_CACHE as _SAC, SESSION_AGENT_CACHE_LOCK as _SAC_L
with _SAC_L:
_SAC[session_id] = (agent, _agent_sig)
@@ -12747,9 +12977,24 @@ def _run_agent_streaming(
_exc_is_compression_snapshot_stale = (
_classification['type'] == 'compression_snapshot_stale'
)
_exc_is_provider_unroutable = _classification['type'] == 'provider_unroutable'
# The structured terminal reason for an unroutable named custom route,
# stamped onto the emitted payload below. Kept separate from _exc_type
# because every unroutable route shares one type and differs only here.
_exc_route_reason = None
# The user hint still points to Settings / `hermes model` from _classify_provider_error().
if _exc_is_quota:
if _exc_is_provider_unroutable:
# Checked FIRST so the terminal route verdict can never be flattened
# into the generic 'Error' tail of this chain: its hint names the
# exact provider setting to fix, which is the only actionable thing
# the user gets on an unroutable custom:<slug>.
_exc_label, _exc_type, _exc_hint = (
_classification['label'], _classification['type'], _classification['hint'],
)
_exc_route_reason = _classification.get('reason')
elif _exc_is_quota:
_exc_label, _exc_type, _exc_hint = (
_classification['label'], _classification['type'], _classification['hint'],
)
@@ -12763,6 +13008,15 @@ def _run_agent_streaming(
)
elif _exc_is_auth:
_heal_stale_classification = None
# Set when the self-heal re-resolve produces a TERMINAL route
# verdict. Unlike the two in-flight sites, this branch runs INSIDE
# the outer exception handler, so raising here would escape the
# generator with no error event at all. Record the verdict instead
# and let the abandoned heal fall through to the emission below,
# which prefers this classification over the generic
# "Authentication error" — the 401 is a symptom, the unroutable
# named provider is the cause the user can actually fix.
_heal_route_classification = None
if not _self_healed:
# ── Credential self-heal on 401 (#1401) ──
# Bind the session's profile so the self-heal re-resolve AND the
@@ -12786,7 +13040,8 @@ def _run_agent_streaming(
if not resolved_provider:
resolved_provider = _heal_rt.get('provider')
resolved_base_url = _runtime_preferred_base_url(
_heal_rt, resolved_provider, configured_base_url
_heal_rt, resolved_provider, configured_base_url,
session_requested_provider=_session_requested_provider,
)
# Preserve the session's original pre-canonicalization provider
# identity (captured at first resolve) so a named custom:slug
@@ -12794,10 +13049,41 @@ def _run_agent_streaming(
# Only initialize when empty.
if not _session_requested_provider:
_session_requested_provider = resolved_provider
resolved_provider, resolved_api_key, resolved_base_url = _resolve_custom_provider_runtime_overrides(
resolved_provider, resolved_api_key, resolved_base_url,
# Rebuild the COMPLETE bundle (see the returned-error retry
# above): replacing only provider/key/base_url would leave
# the pre-heal credential_pool / api_mode / ACP fields on a
# custom endpoint that owns none of them.
_runtime_bundle = _resolve_runtime_connection_bundle(
resolved_provider, resolved_api_key, resolved_base_url, _heal_rt,
profile_name=_resolved_profile_name,
custom_provider_lookup=_session_requested_provider,
)
_heal_route_verdict = custom_provider_route_error(_runtime_bundle)
if _heal_route_verdict is not None:
# The refreshed credential did not produce a routable
# named connection. Abandon the heal BEFORE _AIAgent and
# before the SESSION_AGENT_CACHE write below — building
# here would hand the constructor an incomplete pair and
# let _routed_client_kwargs() pick a provider of its own,
# and caching that agent would keep doing so on every
# later turn in this session.
_heal_route_classification = (
_custom_provider_route_classification(_heal_route_verdict)
)
logger.warning(
'[webui] self-heal (except path): abandoning retry — %s',
_heal_route_classification['message'],
)
# Clearing _heal_rt closes the retry block below. We must
# NOT raise from inside the outer exception handler: that
# would escape the generator and the client would get no
# error event at all.
_heal_rt = None
if _heal_rt is not None:
resolved_provider = _runtime_bundle['provider']
resolved_api_key = _runtime_bundle['api_key']
resolved_base_url = _runtime_bundle['base_url']
# Build a fresh agent with the new credentials
_heal_kwargs = dict(_agent_kwargs) if '_agent_kwargs' in dir() else {}
_heal_kwargs['api_key'] = resolved_api_key
@@ -12805,11 +13091,33 @@ def _run_agent_streaming(
_heal_kwargs['model'] = resolved_model
_heal_kwargs['provider'] = resolved_provider
_replace_session_db_in_kwargs(_heal_kwargs, _state_db_path)
if 'api_mode' in _agent_params:
_heal_kwargs['api_mode'] = _runtime_bundle['api_mode']
if 'acp_command' in _agent_params:
_heal_kwargs['acp_command'] = _runtime_bundle['acp_command']
if 'acp_args' in _agent_params:
_heal_kwargs['acp_args'] = _runtime_bundle['acp_args']
if 'credential_pool' in _agent_params:
_heal_kwargs['credential_pool'] = _heal_rt.get('credential_pool')
_heal_kwargs['credential_pool'] = _runtime_bundle['credential_pool']
_heal_agent = _AIAgent(**_heal_kwargs)
with STREAMS_LOCK:
AGENT_INSTANCES[stream_id] = _heal_agent
_agent_sig = _compute_agent_cache_signature(
resolved_model,
resolved_api_key,
resolved_base_url,
resolved_provider,
_runtime_bundle,
max_iterations_cfg=_max_iterations_cfg,
max_tokens_cfg=_max_tokens_cfg,
fallback_resolved=_fallback_resolved,
toolsets=_toolsets,
reasoning_config=_reasoning_config,
main_request_overrides=_main_request_overrides,
prefill_context=_prefill_context,
profile_home=_profile_home,
safe_profile_runtime_env=_safe_profile_runtime_env,
)
from api.config import SESSION_AGENT_CACHE as _SAC2, SESSION_AGENT_CACHE_LOCK as _SAC2_L
with _SAC2_L:
_SAC2[session_id] = (_heal_agent, _agent_sig)
@@ -12925,7 +13233,19 @@ def _run_agent_streaming(
except Exception as _retry_exc2:
logger.warning('[webui] self-heal (except path): retry failed: %s', _retry_exc2)
# Fall through to emit the original error
if _heal_stale_classification is not None:
if _heal_route_classification is not None:
# The heal was abandoned because the named route is unroutable.
# Report THAT, not the 401 that triggered the heal: telling the
# user to check credentials when the provider resolves no
# endpoint (or declares a credential source that yields nothing)
# sends them to the wrong setting.
_exc_label = _heal_route_classification['label']
_exc_type = _heal_route_classification['type']
_exc_hint = _heal_route_classification['hint']
_exc_route_reason = _heal_route_classification.get('reason')
if _heal_route_classification['message']:
err_str = _heal_route_classification['message']
elif _heal_stale_classification is not None:
_exc_label = _heal_stale_classification['label']
_exc_type = _heal_stale_classification['type']
_exc_hint = _heal_stale_classification['hint']
@@ -12963,6 +13283,10 @@ def _run_agent_streaming(
'The conversation changed while context compression was being prepared.'
)
_error_payload = _provider_error_payload(err_str, _exc_type, _exc_hint)
if _exc_route_reason:
# Clients (and regressions) branch on the exact verdict rather than
# on the human-readable message, which is free to be reworded.
_error_payload['reason'] = _exc_route_reason
if s is not None:
if _checkpoint_stop is not None:
_checkpoint_stop.set()
+163 -3
View File
@@ -13,6 +13,153 @@ REPO = Path(__file__).resolve().parent.parent
STREAMING_PY = (REPO / "api" / "streaming.py").read_text(encoding="utf-8")
def _compute_agent_cache_signature_source() -> str:
"""Return the source of the `_compute_agent_cache_signature()` helper.
The cache-signature blob used to be inlined in the streaming send path; it
now lives in this helper so the initial send and both self-heal retry paths
derive the signature from the same final runtime bundle.
"""
start = STREAMING_PY.index("def _compute_agent_cache_signature(")
end = STREAMING_PY.index("\ndef ", start)
return STREAMING_PY[start:end]
def _signature_blob() -> str:
"""Return the `_json.dumps([...])` field list the signature hashes."""
helper = _compute_agent_cache_signature_source()
blob_start = helper.index("_sig_blob = _json.dumps")
blob_end = helper.index("], sort_keys=True)", blob_start)
return helper[blob_start:blob_end]
def _production_signature_calls() -> list[tuple[int, str]]:
"""Return `(offset, source)` for every production signature call site.
Paren-balanced so the whole multi-line keyword-argument list is captured,
and every live call site is returned so a retry path cannot silently drop a
field that the initial send still passes. Commented-out occurrences are
skipped -- a disabled retry call must read as missing, not as present.
"""
marker = "_agent_sig = _compute_agent_cache_signature("
calls: list[tuple[int, str]] = []
pos = STREAMING_PY.find(marker)
while pos != -1:
line_start = STREAMING_PY.rfind("\n", 0, pos) + 1
if STREAMING_PY[line_start:pos].strip():
# Commented-out (or otherwise non-statement) occurrence: it no
# longer runs, so it must not count as a live call site.
pos = STREAMING_PY.find(marker, pos + 1)
continue
depth = 0
for idx in range(pos + len(marker) - 1, len(STREAMING_PY)):
char = STREAMING_PY[idx]
if char == "(":
depth += 1
elif char == ")":
depth -= 1
if depth == 0:
calls.append((pos, STREAMING_PY[pos:idx + 1]))
break
else:
raise AssertionError("unterminated _compute_agent_cache_signature( call")
pos = STREAMING_PY.find(marker, pos + 1)
return calls
# Lifecycle anchors for the streaming send path. Pinning one signature call to
# each region keeps the oracle honest: a retry call that silently disappears
# fails the region check instead of hiding behind the calls that remain.
INITIAL_SEND_MARKER = "# ── Agent cache: reuse across messages in the same session ──"
RETURNED_ERROR_SELF_HEAL_MARKER = (
"logger.info('[webui] self-heal: retrying stream after credential refresh')"
)
RAISED_EXCEPTION_SELF_HEAL_MARKER = (
"logger.info('[webui] self-heal (except path): "
"retrying stream after credential refresh')"
)
# Each region ends at the cache write that stores the signature it just
# computed. Bounding the region at the write -- rather than at the next
# lifecycle anchor or at EOF -- is what proves the signature is recomputed
# *before* it is cached: a call moved below its own write lands outside the
# region and fails, instead of passing while the write consumes a stale
# `_agent_sig`.
INITIAL_SEND_CACHE_WRITE_MARKER = (
"SESSION_AGENT_CACHE[session_id] = (agent, _agent_sig)"
)
RETURNED_ERROR_SELF_HEAL_CACHE_WRITE_MARKER = (
"_SAC[session_id] = (agent, _agent_sig)"
)
RAISED_EXCEPTION_SELF_HEAL_CACHE_WRITE_MARKER = (
"_SAC2[session_id] = (_heal_agent, _agent_sig)"
)
def _marker_offset(marker: str) -> int:
"""Return the single offset of a lifecycle marker in streaming.py."""
offset = STREAMING_PY.index(marker)
assert STREAMING_PY.find(marker, offset + 1) == -1, (
"lifecycle marker is no longer unique in streaming.py:\n" + marker
)
return offset
def _signature_calls_by_region(
calls: list[tuple[int, str]],
) -> dict[str, tuple[int, str]]:
"""Map each lifecycle region to the single signature call inside it.
The regions are the initial send, the returned-error self-heal retry and
the raised-exception self-heal retry; each runs from its lifecycle anchor
to the cache write that consumes `_agent_sig`. Requiring exactly one call
inside those bounds means neither a dropped retry call (masked by its
surviving siblings) nor a call recomputed after its own cache write (which
would leave the write storing a stale signature) can read as correct.
"""
initial = _marker_offset(INITIAL_SEND_MARKER)
initial_cache_write = _marker_offset(INITIAL_SEND_CACHE_WRITE_MARKER)
returned_error = _marker_offset(RETURNED_ERROR_SELF_HEAL_MARKER)
returned_error_cache_write = _marker_offset(
RETURNED_ERROR_SELF_HEAL_CACHE_WRITE_MARKER
)
raised_exception = _marker_offset(RAISED_EXCEPTION_SELF_HEAL_MARKER)
raised_exception_cache_write = _marker_offset(
RAISED_EXCEPTION_SELF_HEAL_CACHE_WRITE_MARKER
)
assert (
initial
< initial_cache_write
< returned_error
< returned_error_cache_write
< raised_exception
< raised_exception_cache_write
), (
"streaming.py lifecycle regions are out of order; these anchors no "
"longer describe the send path"
)
bounds = {
"initial send": (initial, initial_cache_write),
"returned-error self-heal": (returned_error, returned_error_cache_write),
"raised-exception self-heal": (
raised_exception,
raised_exception_cache_write,
),
}
by_region: dict[str, tuple[int, str]] = {}
for region, (start, end) in bounds.items():
found = [(offset, call) for offset, call in calls if start < offset < end]
assert len(found) == 1, (
"expected exactly one _compute_agent_cache_signature() call in the "
f"{region} region of streaming.py -- between its lifecycle anchor "
f"and the cache write that stores `_agent_sig` -- found "
f"{len(found)}"
)
by_region[region] = found[0]
return by_region
def test_streaming_agent_reads_agent_max_turns_from_config():
assert "_agent_cfg_for_iterations" in STREAMING_PY
assert "_agent_cfg_for_iterations.get('max_turns')" in STREAMING_PY
@@ -25,6 +172,19 @@ def test_streaming_agent_passes_max_iterations_to_aiagent():
def test_streaming_agent_cache_signature_includes_max_iterations():
sig_start = STREAMING_PY.index("_sig_blob = _json.dumps")
sig_block = STREAMING_PY[sig_start:STREAMING_PY.index("], sort_keys=True)", sig_start)]
assert "_max_iterations_cfg or ''" in sig_block
assert "max_iterations_cfg or ''" in _signature_blob(), (
"_compute_agent_cache_signature() must hash max_iterations_cfg, or a "
"max_turns change reuses the cached agent built on the old budget."
)
calls = _production_signature_calls()
assert len(calls) == 3, (
"expected exactly three _compute_agent_cache_signature() call sites "
"(initial send plus both self-heal retries), found "
f"{len(calls)}"
)
for region, (_offset, call) in _signature_calls_by_region(calls).items():
assert "max_iterations_cfg=_max_iterations_cfg" in call, (
f"the {region} signature call must pass the resolved "
"max_iterations budget:\n" + call
)
+19
View File
@@ -620,6 +620,25 @@ def test_handoff_summary_codex_output_cap_matches_provider_compatibility(
import api.models as models
import api.routes as routes
if provider.startswith("custom:"):
# A named ``custom:<slug>`` route is identity-owned: the endpoint comes
# from the record that names the slug, and a route no record owns fails
# closed (#1806). ``resolve_model_provider`` only ever returns this
# provider/base_url pair BECAUSE such a row exists, so the fixture must
# carry it — otherwise the handler is handed a connection with no
# authority behind it and falls back before reaching the Codex path.
monkeypatch.setitem(
cfg.cfg,
"custom_providers",
[
{
"name": provider.split(":", 1)[1],
"base_url": base_url,
"api_key": "handoff-summary-key",
}
],
)
if expects_normalized_base_url:
real_urlsplit = routes.urlsplit
File diff suppressed because it is too large. Load diff
@@ -13,10 +13,151 @@ REPO = Path(__file__).resolve().parent.parent
STREAMING_PY = (REPO / "api" / "streaming.py").read_text(encoding="utf-8")
def _compute_agent_cache_signature_source() -> str:
"""Return the source of the `_compute_agent_cache_signature()` helper.
The signature blob used to be inlined in the streaming send path; it now
lives in this helper so the initial send and both self-heal retry paths
derive the signature from the same final runtime bundle.
"""
start = STREAMING_PY.index("def _compute_agent_cache_signature(")
end = STREAMING_PY.index("\ndef ", start)
return STREAMING_PY[start:end]
def _signature_block() -> str:
sig_start = STREAMING_PY.index("_sig_blob = _json.dumps")
sig_end = STREAMING_PY.index("], sort_keys=True)", sig_start)
return STREAMING_PY[sig_start:sig_end]
"""Return the `_json.dumps([...])` field list the signature hashes."""
helper = _compute_agent_cache_signature_source()
sig_start = helper.index("_sig_blob = _json.dumps")
sig_end = helper.index("], sort_keys=True)", sig_start)
return helper[sig_start:sig_end]
def _production_signature_calls() -> list[tuple[int, str]]:
"""Return `(offset, source)` for every production signature call site.
Paren-balanced so the whole multi-line keyword-argument list is captured,
and every live call site is returned so a retry path cannot silently drop a
field that the initial send still passes. Commented-out occurrences are
skipped -- a disabled retry call must read as missing, not as present.
"""
marker = "_agent_sig = _compute_agent_cache_signature("
calls: list[tuple[int, str]] = []
pos = STREAMING_PY.find(marker)
while pos != -1:
line_start = STREAMING_PY.rfind("\n", 0, pos) + 1
if STREAMING_PY[line_start:pos].strip():
# Commented-out (or otherwise non-statement) occurrence: it no
# longer runs, so it must not count as a live call site.
pos = STREAMING_PY.find(marker, pos + 1)
continue
depth = 0
for idx in range(pos + len(marker) - 1, len(STREAMING_PY)):
char = STREAMING_PY[idx]
if char == "(":
depth += 1
elif char == ")":
depth -= 1
if depth == 0:
calls.append((pos, STREAMING_PY[pos:idx + 1]))
break
else:
raise AssertionError("unterminated _compute_agent_cache_signature( call")
pos = STREAMING_PY.find(marker, pos + 1)
return calls
# Lifecycle anchors for the streaming send path. Pinning one signature call to
# each region keeps the oracle honest: a retry call that silently disappears
# fails the region check instead of hiding behind the calls that remain.
INITIAL_SEND_MARKER = "# ── Agent cache: reuse across messages in the same session ──"
RETURNED_ERROR_SELF_HEAL_MARKER = (
"logger.info('[webui] self-heal: retrying stream after credential refresh')"
)
RAISED_EXCEPTION_SELF_HEAL_MARKER = (
"logger.info('[webui] self-heal (except path): "
"retrying stream after credential refresh')"
)
# Each region ends at the cache write that stores the signature it just
# computed. Bounding the region at the write -- rather than at the next
# lifecycle anchor or at EOF -- is what proves the signature is recomputed
# *before* it is cached: a call moved below its own write lands outside the
# region and fails, instead of passing while the write consumes a stale
# `_agent_sig`.
INITIAL_SEND_CACHE_WRITE_MARKER = (
"SESSION_AGENT_CACHE[session_id] = (agent, _agent_sig)"
)
RETURNED_ERROR_SELF_HEAL_CACHE_WRITE_MARKER = (
"_SAC[session_id] = (agent, _agent_sig)"
)
RAISED_EXCEPTION_SELF_HEAL_CACHE_WRITE_MARKER = (
"_SAC2[session_id] = (_heal_agent, _agent_sig)"
)
def _marker_offset(marker: str) -> int:
"""Return the single offset of a lifecycle marker in streaming.py."""
offset = STREAMING_PY.index(marker)
assert STREAMING_PY.find(marker, offset + 1) == -1, (
"lifecycle marker is no longer unique in streaming.py:\n" + marker
)
return offset
def _signature_calls_by_region(
calls: list[tuple[int, str]],
) -> dict[str, tuple[int, str]]:
"""Map each lifecycle region to the single signature call inside it.
The regions are the initial send, the returned-error self-heal retry and
the raised-exception self-heal retry; each runs from its lifecycle anchor
to the cache write that consumes `_agent_sig`. Requiring exactly one call
inside those bounds means neither a dropped retry call (masked by its
surviving siblings) nor a call recomputed after its own cache write (which
would leave the write storing a stale signature) can read as correct.
"""
initial = _marker_offset(INITIAL_SEND_MARKER)
initial_cache_write = _marker_offset(INITIAL_SEND_CACHE_WRITE_MARKER)
returned_error = _marker_offset(RETURNED_ERROR_SELF_HEAL_MARKER)
returned_error_cache_write = _marker_offset(
RETURNED_ERROR_SELF_HEAL_CACHE_WRITE_MARKER
)
raised_exception = _marker_offset(RAISED_EXCEPTION_SELF_HEAL_MARKER)
raised_exception_cache_write = _marker_offset(
RAISED_EXCEPTION_SELF_HEAL_CACHE_WRITE_MARKER
)
assert (
initial
< initial_cache_write
< returned_error
< returned_error_cache_write
< raised_exception
< raised_exception_cache_write
), (
"streaming.py lifecycle regions are out of order; these anchors no "
"longer describe the send path"
)
bounds = {
"initial send": (initial, initial_cache_write),
"returned-error self-heal": (returned_error, returned_error_cache_write),
"raised-exception self-heal": (
raised_exception,
raised_exception_cache_write,
),
}
by_region: dict[str, tuple[int, str]] = {}
for region, (start, end) in bounds.items():
found = [(offset, call) for offset, call in calls if start < offset < end]
assert len(found) == 1, (
"expected exactly one _compute_agent_cache_signature() call in the "
f"{region} region of streaming.py -- between its lifecycle anchor "
f"and the cache write that stores `_agent_sig` -- found "
f"{len(found)}"
)
by_region[region] = found[0]
return by_region
def test_same_session_profile_switch_rebuilds_agent_under_new_soul_home(tmp_path, monkeypatch):
@@ -225,22 +366,51 @@ def test_same_session_profile_switch_rebuilds_agent_under_new_soul_home(tmp_path
def test_cache_signature_includes_profile_home():
block = _signature_block()
assert "_profile_home" in block, (
"SESSION_AGENT_CACHE signature is missing `_profile_home`. Without this, "
assert "profile_home" in block, (
"SESSION_AGENT_CACHE signature is missing `profile_home`. Without this, "
"same-session profile switches reuse the cached agent built under the "
"previous profile's HERMES_HOME, leaking the old SOUL.md into new turns."
)
calls = _production_signature_calls()
assert len(calls) == 3, (
"expected exactly three _compute_agent_cache_signature() call sites "
"(initial send plus both self-heal retries), found "
f"{len(calls)}"
)
for region, (_offset, call) in _signature_calls_by_region(calls).items():
assert "profile_home=_profile_home" in call, (
f"the {region} signature call site must pass the resolved profile home, "
"or a retry re-caches the agent under a signature that ignores the active profile:\n" + call
)
def test_profile_home_resolved_before_cache_signature():
profile_home_assignment = STREAMING_PY.index("_profile_home = str(_profile_home_path)")
sig_start = STREAMING_PY.index("_sig_blob = _json.dumps")
assert profile_home_assignment < sig_start
calls = _production_signature_calls()
assert len(calls) == 3, (
"expected exactly three _compute_agent_cache_signature() call sites "
"(initial send plus both self-heal retries), found "
f"{len(calls)}"
)
for region, (offset, call) in _signature_calls_by_region(calls).items():
assert profile_home_assignment < offset, (
"`_profile_home` must be resolved before the cache signature is "
f"computed, otherwise the {region} signature hashes a stale/unbound "
"home."
)
assert "profile_home=_profile_home" in call, call
assert "max_iterations_cfg=_max_iterations_cfg" in call, call
assert "max_tokens_cfg=_max_tokens_cfg" in call, call
def test_signature_uses_profile_home_with_fallback():
block = _signature_block()
assert "_profile_home or ''" in block, (
"Signature should use `_profile_home or ''` so empty-home deployments get "
assert "profile_home or ''" in block, (
"Signature should use `profile_home or ''` so empty-home deployments get "
"a stable cache key rather than unnecessary cache churn."
)
for _offset, call in _production_signature_calls():
assert "profile_home=_profile_home" in call, call
+9 -4
View File
@@ -123,12 +123,17 @@ class TestRuntimeRouteInjection(unittest.TestCase):
Since issue #772 these are passed defensively via inspect-guarded kwargs
so the WebUI degrades gracefully against older hermes-agent builds.
They are read from the resolved connection bundle rather than straight
off the runtime provider dict: a named custom provider owns none of them,
so the bundle clears them and forwarding ``_rt`` directly would send the
ambient provider's transport/protocol/pool to a custom endpoint.
"""
for snippet in (
"_agent_kwargs['api_mode'] = _rt.get('api_mode')",
"_agent_kwargs['acp_command'] = _rt.get('command')",
"_agent_kwargs['acp_args'] = _rt.get('args')",
"_agent_kwargs['credential_pool'] = _rt.get('credential_pool')",
"_agent_kwargs['api_mode'] = _runtime_bundle['api_mode']",
"_agent_kwargs['acp_command'] = _runtime_bundle['acp_command']",
"_agent_kwargs['acp_args'] = _runtime_bundle['acp_args']",
"_agent_kwargs['credential_pool'] = _runtime_bundle['credential_pool']",
):
self.assertIn(
snippet,
+175 -3
View File
@@ -16,6 +16,156 @@ def _src() -> str:
return STREAMING.read_text(encoding="utf-8")
def _compute_agent_cache_signature_source() -> str:
"""Return the source of the `_compute_agent_cache_signature()` helper.
The signature blob used to be inlined in the streaming send path; it now
lives in this helper so the initial send and both self-heal retry paths
derive the signature from the same final runtime bundle.
"""
src = _src()
start = src.index("def _compute_agent_cache_signature(")
end = src.index("\ndef ", start)
return src[start:end]
def _signature_blob() -> str:
"""Return the `_json.dumps([...])` field list the signature hashes."""
helper = _compute_agent_cache_signature_source()
blob_start = helper.index("_sig_blob = _json.dumps")
blob_end = helper.index("], sort_keys=True)", blob_start)
return helper[blob_start:blob_end]
def _production_signature_calls() -> list[tuple[int, str]]:
"""Return `(offset, source)` for every production signature call site.
Paren-balanced so the whole multi-line keyword-argument list is captured,
and every live call site is returned so a retry path cannot silently drop a
field that the initial send still passes. Commented-out occurrences are
skipped -- a disabled retry call must read as missing, not as present.
"""
src = _src()
marker = "_agent_sig = _compute_agent_cache_signature("
calls: list[tuple[int, str]] = []
pos = src.find(marker)
while pos != -1:
line_start = src.rfind("\n", 0, pos) + 1
if src[line_start:pos].strip():
# Commented-out (or otherwise non-statement) occurrence: it no
# longer runs, so it must not count as a live call site.
pos = src.find(marker, pos + 1)
continue
depth = 0
for idx in range(pos + len(marker) - 1, len(src)):
char = src[idx]
if char == "(":
depth += 1
elif char == ")":
depth -= 1
if depth == 0:
calls.append((pos, src[pos:idx + 1]))
break
else:
raise AssertionError("unterminated _compute_agent_cache_signature( call")
pos = src.find(marker, pos + 1)
return calls
# Lifecycle anchors for the streaming send path. Pinning one signature call to
# each region keeps the oracle honest: a retry call that silently disappears
# fails the region check instead of hiding behind the calls that remain.
INITIAL_SEND_MARKER = "# ── Agent cache: reuse across messages in the same session ──"
RETURNED_ERROR_SELF_HEAL_MARKER = (
"logger.info('[webui] self-heal: retrying stream after credential refresh')"
)
RAISED_EXCEPTION_SELF_HEAL_MARKER = (
"logger.info('[webui] self-heal (except path): "
"retrying stream after credential refresh')"
)
# Each region ends at the cache write that stores the signature it just
# computed. Bounding the region at the write -- rather than at the next
# lifecycle anchor or at EOF -- is what proves the signature is recomputed
# *before* it is cached: a call moved below its own write lands outside the
# region and fails, instead of passing while the write consumes a stale
# `_agent_sig`.
INITIAL_SEND_CACHE_WRITE_MARKER = (
"SESSION_AGENT_CACHE[session_id] = (agent, _agent_sig)"
)
RETURNED_ERROR_SELF_HEAL_CACHE_WRITE_MARKER = (
"_SAC[session_id] = (agent, _agent_sig)"
)
RAISED_EXCEPTION_SELF_HEAL_CACHE_WRITE_MARKER = (
"_SAC2[session_id] = (_heal_agent, _agent_sig)"
)
def _marker_offset(marker: str) -> int:
"""Return the single offset of a lifecycle marker in streaming.py."""
src = _src()
offset = src.index(marker)
assert src.find(marker, offset + 1) == -1, (
"lifecycle marker is no longer unique in streaming.py:\n" + marker
)
return offset
def _signature_calls_by_region(
calls: list[tuple[int, str]],
) -> dict[str, tuple[int, str]]:
"""Map each lifecycle region to the single signature call inside it.
The regions are the initial send, the returned-error self-heal retry and
the raised-exception self-heal retry; each runs from its lifecycle anchor
to the cache write that consumes `_agent_sig`. Requiring exactly one call
inside those bounds means neither a dropped retry call (masked by its
surviving siblings) nor a call recomputed after its own cache write (which
would leave the write storing a stale signature) can read as correct.
"""
initial = _marker_offset(INITIAL_SEND_MARKER)
initial_cache_write = _marker_offset(INITIAL_SEND_CACHE_WRITE_MARKER)
returned_error = _marker_offset(RETURNED_ERROR_SELF_HEAL_MARKER)
returned_error_cache_write = _marker_offset(
RETURNED_ERROR_SELF_HEAL_CACHE_WRITE_MARKER
)
raised_exception = _marker_offset(RAISED_EXCEPTION_SELF_HEAL_MARKER)
raised_exception_cache_write = _marker_offset(
RAISED_EXCEPTION_SELF_HEAL_CACHE_WRITE_MARKER
)
assert (
initial
< initial_cache_write
< returned_error
< returned_error_cache_write
< raised_exception
< raised_exception_cache_write
), (
"streaming.py lifecycle regions are out of order; these anchors no "
"longer describe the send path"
)
bounds = {
"initial send": (initial, initial_cache_write),
"returned-error self-heal": (returned_error, returned_error_cache_write),
"raised-exception self-heal": (
raised_exception,
raised_exception_cache_write,
),
}
by_region: dict[str, tuple[int, str]] = {}
for region, (start, end) in bounds.items():
found = [(offset, call) for offset, call in calls if start < offset < end]
assert len(found) == 1, (
"expected exactly one _compute_agent_cache_signature() call in the "
f"{region} region of streaming.py -- between its lifecycle anchor "
f"and the cache write that stores `_agent_sig` -- found "
f"{len(found)}"
)
by_region[region] = found[0]
return by_region
def test_streaming_passes_configured_max_tokens_to_agent():
src = _src()
assert "_raw_max_tokens = _cfg.get('max_tokens')" in src
@@ -24,9 +174,31 @@ def test_streaming_passes_configured_max_tokens_to_agent():
def test_streaming_agent_cache_signature_includes_max_tokens_and_fallback():
src = _src()
assert "_max_tokens_cfg or ''" in src
assert "_fallback_resolved or {}" in src
blob = _signature_blob()
assert "max_tokens_cfg or ''" in blob, (
"_compute_agent_cache_signature() must hash max_tokens_cfg, or a "
"max_tokens change reuses the agent built on the old output ceiling."
)
assert "fallback_resolved or {}" in blob, (
"_compute_agent_cache_signature() must hash the resolved fallback "
"chain so a fallback edit mints a new agent."
)
calls = _production_signature_calls()
assert len(calls) == 3, (
"expected exactly three _compute_agent_cache_signature() call sites "
"(initial send plus both self-heal retries), found "
f"{len(calls)}"
)
for region, (_offset, call) in _signature_calls_by_region(calls).items():
assert "max_tokens_cfg=_max_tokens_cfg" in call, (
f"the {region} signature call must pass the resolved "
"max_tokens:\n" + call
)
assert "fallback_resolved=_fallback_resolved" in call, (
f"the {region} signature call must pass the resolved fallback "
"chain:\n" + call
)
def test_openrouter_more_credits_error_is_classified_as_quota():