Skip to content

fix(delegate): keep delegation.base_url authoritative against credential-pool rotation - #61275

Closed
arcticloud wants to merge 1 commit into
NousResearch:mainfrom
arcticloud:fix/delegation-base-url-routing
Closed

arcticloud wants to merge 1 commit into
NousResearch:mainfrom
arcticloud:fix/delegation-base-url-routing

Conversation

@arcticloud

Copy link
Copy Markdown

Closes #61195.

Problem

With delegation.base_url: https://api.anthropic.com and a primary
model.provider: openrouter, the subagent's provider/base_url/api_mode
resolve correctly to the Anthropic values through
_resolve_delegation_credentials() → _build_child_agent() → init_agent(), as
the 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 credential
lease in _run_single_child():

child_pool = getattr(child, "_credential_pool", None)
if child_pool is not None:
    leased_cred_id = child_pool.acquire_lease()
    if leased_cred_id is not None:
        leased_entry = child_pool.current()
        if leased_entry is not None and hasattr(child, "_swap_credential"):
            child._swap_credential(leased_entry)   # ← applies entry.base_url

_swap_credential() applies the pool entry's base_url as well as its
api_key
, unconditionally. So a pool entry that carries the parent provider's
endpoint silently retargets the correctly-resolved child back to
openrouter.ai while child.provider still says anthropic. I reproduced
this end-to-end (real AIAgent build + the verbatim lease block): a child
resolved to api.anthropic.com whose _credential_pool contained an
openrouter entry ends up with provider=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 delegate
startup 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)

  1. _swap_credential refuses cross-provider entries
    (_credential_entry_compatible). A pool entry whose provider doesn't match
    the agent's is never applied — so base_url/api_key can't be retargeted to
    a foreign host. This subsumes the ad-hoc guards in
    recover_with_credential_pool/restore_primary and, crucially, covers any
    future 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).

  2. An explicit delegation.base_url is 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.

  3. A direct api.anthropic.com delegation endpoint resolves the user's own
    Anthropic credential
    (via resolve_anthropic_token(), the same resolver
    init_agent uses for native Anthropic) instead of inheriting the parent's
    foreign-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_key still wins.

Tests

New tests/tools/test_delegation_base_url_routing.py (9 cases) drives the real
_build_child_agent + the verbatim _run_single_child lease block:

  • cross-provider pool entry is refused (the reported shape) — child stays on
    api.anthropic.com, foreign key not applied;
  • same-provider entry carrying a foreign base_url → key rotates, pinned
    endpoint kept;
  • same-provider rotation still works (children must still rotate on rate limits);
  • unscoped/legacy entries keep swapping;
  • pin is released after a provider switch;
  • non-delegation swaps still follow the entry's base_url (per-key proxy case);
  • credential resolution prefers the native Anthropic key / falls back / honors
    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_credential to refuse a same-provider entry whose own
base_url differs (the per-key proxy case in test 9) — that's a legitimate
existing 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).

…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.
@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint tool/delegate Subagent delegation P2 Medium — degraded but workaround exists labels Jul 9, 2026
@teknium1

Copy link
Copy Markdown
Collaborator

Thanks for the thorough investigation and regression coverage.

Automated hermes-sweeper review found that current main already prevents the reported routing failure:

  • tools/delegate_tool.py:3061-3063 resolves direct https://api.anthropic.com delegation as provider="anthropic" using the Anthropic transport.
  • tools/delegate_tool.py:2980-2988 reuses a parent pool only for the same provider; otherwise it loads the resolved child provider's pool. An OpenRouter parent pool therefore cannot be leased into this Anthropic child.
  • This provider-scoped child-pool behavior and its regression coverage landed in f2c11ff30cd5601a4017cae64cbbeac0a481f5c9 (fix(delegate): share credential pools with subagents + per-task leasing).

The PR's injected foreign-pool test state bypasses that production resolver, so the reported behavior is already implemented on main.

@teknium1 teknium1 closed this Jul 10, 2026
@teknium1 teknium1 added the sweeper:implemented-on-main Sweeper: behavior already present on current main label Jul 10, 2026
@arcticloud

Copy link
Copy Markdown
Author

You're right, and thanks for the precise trace — apologies for the noise. Confirmed against current main: my reproduction patched out _resolve_child_credential_pool, which is exactly the layer that prevents this — an OpenRouter parent pool is never leased into an Anthropic child (provider mismatch → load_pool for the child's own provider, seeded from ANTHROPIC_API_KEY), and f2c11ff already covers different-provider pool selection. So the guard here defended a state the pool-selection layer already makes unreachable. Agree with the close. Appreciate the review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists sweeper:implemented-on-main Sweeper: behavior already present on current main tool/delegate Subagent delegation type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: delegation.base_url correctly resolved through init_agent() but subagent's actual API call still routes to OpenRouter → 401

3 participants