fix(delegate): keep delegation.base_url authoritative against credential-pool rotation - #61275
arcticloud wants to merge 1 commit into
Conversation
…ial-pool rotation With `delegation.base_url: https://api.anthropic.com` and a primary `model.provider: openrouter`, the subagent's provider/base_url/api_mode resolve correctly through _resolve_delegation_credentials() -> _build_child_agent() -> init_agent(), but the child's first request still hit https://openrouter.ai/api/v1 -> 401, with the mismatched pair `provider=anthropic base_url=https://openrouter.ai/api/v1`. The divergence happens after init_agent(), in the startup credential lease in _run_single_child(): _swap_credential() applies the pool entry's base_url as well as its api_key, unconditionally. A pool entry carrying the parent provider's endpoint silently retargets the correctly-resolved child back to openrouter.ai while child.provider still says anthropic. This is the same class as the recurring regressions the issue lists (NousResearch#10653/NousResearch#16816/NousResearch#26482/NousResearch#34318). Each earlier fix guarded ONE caller of the rotation path; the delegate startup lease is another caller that was never guarded. Fix it at the single choke point every rotation flows through: 1. _swap_credential refuses cross-provider entries (_credential_entry_compatible) so base_url/api_key can't be retargeted to a foreign host. Subsumes the ad-hoc guards in recover_with_credential_pool/restore_primary and covers future call sites by construction. Unscoped entries still swap; custom:<name> endpoints use the same key comparison as NousResearch#56885. 2. An explicit delegation.base_url is pinned (_delegation_endpoint_pin): same-provider rotation swaps the key but keeps the configured endpoint. Provider-scoped, so a fallback-chain provider switch is unaffected. 3. A direct api.anthropic.com delegation endpoint resolves the user's own Anthropic credential instead of inheriting the parent's foreign-provider key (a guaranteed 401 there even once routing is correct). Falls back to parent inheritance when none exists; explicit delegation.api_key still wins. Adds tests/tools/test_delegation_base_url_routing.py (9 cases) driving the real _build_child_agent + the verbatim _run_single_child lease block. Closes NousResearch#61195.
|
Thanks for the thorough investigation and regression coverage. Automated hermes-sweeper review found that current
The PR's injected foreign-pool test state bypasses that production resolver, so the reported behavior is already implemented on main. |
|
You're right, and thanks for the precise trace — apologies for the noise. Confirmed against current main: my reproduction patched out |
Closes #61195.
Problem
With
delegation.base_url: https://api.anthropic.comand a primarymodel.provider: openrouter, the subagent'sprovider/base_url/api_moderesolve correctly to the Anthropic values through
_resolve_delegation_credentials() → _build_child_agent() → init_agent(), asthe issue reports. But the child's first outbound request still went to
https://openrouter.ai/api/v1, producing the mismatched pair from the log —provider=anthropic base_url=https://openrouter.ai/api/v1— and a 401.Root cause
The divergence happens after
init_agent(), in the startup credentiallease in
_run_single_child():_swap_credential()applies the pool entry'sbase_urlas well as itsapi_key, unconditionally. So a pool entry that carries the parent provider's
endpoint silently retargets the correctly-resolved child back to
openrouter.aiwhilechild.providerstill saysanthropic. I reproducedthis end-to-end (real
AIAgentbuild + the verbatim lease block): a childresolved to
api.anthropic.comwhose_credential_poolcontained anopenrouterentry ends up withprovider=anthropic base_url=openrouter.ai/api/v1— the exact failure in the report.
This is the same class as the recurring regressions the issue lists
(#10653/#16816/#26482/#34318). Each earlier fix guarded one caller of the
rotation path (
recover_with_credential_pool,restore_primary). The delegatestartup lease is simply another caller that was never guarded — hence the
recurrence. Rather than add a fourth caller-side guard, this fixes it at the
single choke point every rotation flows through.
Fix (structural)
_swap_credentialrefuses cross-provider entries(
_credential_entry_compatible). A pool entry whose provider doesn't matchthe agent's is never applied — so
base_url/api_key can't be retargeted toa foreign host. This subsumes the ad-hoc guards in
recover_with_credential_pool/restore_primaryand, crucially, covers anyfuture call site (like this lease) by construction. Entries with no provider
string stay "unscoped" and are still applied (legacy pools);
custom:<name>endpoints use the same key comparison as the existing guards (Credential pool desync persists on v0.18.0 — regression of #25727 (closed as implemented-on-main) #56885).
An explicit
delegation.base_urlis pinned (_delegation_endpoint_pin).When the user configures a literal endpoint, same-provider rotation may swap
the key but must not change the endpoint. The pin is provider-scoped, so
a later fallback-chain provider switch is unaffected.
A direct
api.anthropic.comdelegation endpoint resolves the user's ownAnthropic credential (via
resolve_anthropic_token(), the same resolverinit_agentuses for native Anthropic) instead of inheriting the parent'sforeign-provider key — which was a guaranteed 401 on that endpoint even once
the routing is correct. Falls back to parent inheritance when no Anthropic
credential exists; an explicit
delegation.api_keystill wins.Tests
New
tests/tools/test_delegation_base_url_routing.py(9 cases) drives the real_build_child_agent+ the verbatim_run_single_childlease block:api.anthropic.com, foreign key not applied;base_url→ key rotates, pinnedendpoint kept;
explicit
delegation.api_key.Existing delegation + credential-pool + swap suites pass unchanged (245 tests
across
test_delegate,test_async_delegation,test_credential_pool_*,test_fallback_credential_isolation,test_primary_runtime_restore,test_restore_primary_pool_reselect,test_anthropic_third_party_oauth_guard).Note / possible follow-up
I did not change
_swap_credentialto refuse a same-provider entry whose ownbase_urldiffers (the per-key proxy case in test 9) — that's a legitimateexisting behavior for primary agents, so the endpoint authority is expressed via
the delegation pin rather than a blanket base_url lock. Happy to adjust the
shape if you'd prefer the pin implemented differently (e.g. storing the pinned
endpoint on the pool entry instead of the agent).