Repository navigation
feat(a2a): reach Microsoft Foundry agents with Entra auth and versioned card discovery - #41511
Conversation
…ed card discovery Foundry serves its agent card only at agentCard/v1.0, accepts only an Entra ID bearer, and defaults to a non-blocking send, so the A2A relay and the chat completions route could not use it. The relay gains an agent_card_path litellm_param plus agentCard/v1.0 as a third discovery probe, mints a bearer from flat Entra fields on the agent (tenant_id, client_id, client_secret, azure_ad_token, azure_username, azure_password, azure_scope) for https://ai.azure.com/.default, and sends it on the card fetch, message/send, message/stream, tasks/* and the chat bridge. Chat completions look the registered agent up by its provider-stripped name so its api_key and headers reach the request, tag every message with its kind, ask for a blocking send, fall back to a blocking send when the registered card says streaming: false, and fail the call on a JSON-RPC error inside a stream instead of yielding an empty one. Entra fields stay out of the chat bridge's logged parameters. Resolves LIT-5122
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
|
|
Fixed the base-class patching and the narrative docstrings. Rebutted the 404 status change and the provider placement findings with evidence in their threads |
…ect the card client in tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
Pushed 75eec87: discovery errors keep the upstream status, agent_tests mock accepts relative_card_path, added an all-404 regression test. Placement finding rebutted above |
… keep one Authorization line
|
bugbot run |
…reaches the next The chat route handed the registry's stored headers dict straight to validate_environment, which wrote the caller's bearer into it, so the next caller of the same agent with no key of their own sent the previous caller's token. The registry lookup now copies the stored headers and validate_environment returns a new dict instead of mutating its input. A regression test drives two completions through one registered agent and asserts the second carries no Authorization and the stored agent is unchanged.
|
bugbot run |
…pi_key or authorization header
|
bugbot run |
basedpyright cannot narrow the probe error through isinstance(error, AgentCardResolutionError) while that class is imported inside try/except ImportError, which left three reportAttributeAccessIssue errors over the budget main now carries. A runtime-checkable Protocol with the same status_code contract carries the narrowing instead, so the check no longer depends on the possibly unbound SDK name and the import goes.
|
bugbot run |
The OIDC branch of the agent token mint handed a missing tenant_id or client_id to the shared helper, which fills them from the host's AZURE_TENANT_ID and AZURE_CLIENT_ID, so an agent carrying only an oidc/ token could be authenticated with the host's identity. The branch now needs both ids on the agent and otherwise fails with the credential help, which names the requirement
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit e5744c5. Configure here.
TLDR
Problem this solves:
agentCard/v1.0, never the well-known pathsapi_keyorheaderskind, which Foundry rejectsmessage/stream, so streaming chat completions came back emptyazure_ai/agents/<name>provider only speaks the Assistants APIHow it solves it:
agent_card_pathlitellm_param, plusagentCard/v1.0as a third discovery probehttps://ai.azure.com/.defaultmessage/send,message/stream,tasks/*and chat completionsapi_keyandheadersare sent, with the Entra bearer winning over a static one stored next to the Entra fields"kind": "message"capabilitiesblock does not saystreaming: trueturns a streaming chat completion into one blocking chunkUser Flow
Before: a platform admin registers a Microsoft Foundry agent, and every call to it fails inside the gateway before a request ever reaches the agent
a2a_agentsin config.yaml with the Foundry A2A endpoint as the cardurland the service principal'stenant_id,client_idandclient_secretinlitellm_params, then restarts the proxymessage/sendbody-32603 Internal error: Failed to fetch agent card from https://<account>.services.ai.azure.com/api/projects/<project>/agents/<agent>/endpoint/protocols/a2a/.well-known/agent.json (HTTP 401)A2A-Version: 1.0header and aSendMessagebody and get the same HTTP 500"model": "a2a/foundry-agent"and get HTTP 401Access denied due to invalid subscription key or wrong API endpoint"stream": trueand get the same HTTP 401azure_ai/agents/<agent>model and get HTTP 400Invalid 'assistant_id': '<agent>'. Expected an ID that begins with 'asst'After: the same agent answers through the gateway on the A2A route and on chat completions
a2a_agentswith the same cardurl, the same Entra fields, andagent_card_path: agentCard/v1.0inlitellm_params, then restarts the proxymessage/sendbody and"configuration": {"blocking": true}A2A-Version: 1.0header and aSendMessagebody and get HTTP 200 with a completed task; aGetTaskwith that task id returns the same task"model": "a2a/foundry-agent"and get HTTP 200 with the agent's answer inchoices[0].message.content"stream": trueand get an SSE stream: one chunk carrying the whole answer, one with"finish_reason": "stop", then[DONE]agent_card_pathout still works: discovery reachesagentCard/v1.0after the two well-known probes 404Relevant issues
Affected release
Linear ticket
Resolves LIT-5122
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*,make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more@greptileaito re-request a review after pushing changes)Delays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Screenshots / Proof of Fix
Every block below was run at the commit its heading names: a proxy booted on this config from a worktree at that commit, against the same live Microsoft Foundry agent (a prompt agent that answers arithmetic and appends
FOUNDRY-A2A-OK). The fourAZURE_FOUNDRY_A2A_QA_*variables hold the Foundry project's service principal.foundry-agentnames the card path;foundry-agent-discoveredleaves discovery to the probes;foundry-agent-static-headersadds a storedheadersentry,foundry-agent-with-api-keya wrong staticapi_keyandfoundry-agent-lowercase-autha wrongauthorizationheader, each next to the same Entra fields; those three are only used by the two sections at the end, and the Before side at the merge base ran with the first three agents, since the last two were added with 1c40e60. The registered card is Foundry's ownagentCard/v1.0, withcapabilities.streaming: falseEach side is one proxy process booted with
--num_workers 2and no database, the same topology on every side, started from its worktree withPYTHONPATHset to it; the run prints the proxy's working directory and where that environment resolveslitellm, and both sit inside the worktree. The curls below show the proxy on port 4000 with master keysk-1234; the run used a random port and its own keyBefore (16bbff6)
Registered agents
0.3 message/send through the relay
agent_card_path:1.0 SendMessage through the relay
A2A-Version: 1.0header and the 1.0 body:Discovery without agent_card_path
agent_card_path:Non-blocking message/send then tasks/get
1.0 multi-turn on the contextId
Chat completions
agent_card_path:Streaming chat completions
"stream": true:The card the gateway serves
After (e5744c5)
Registered agents
0.3 message/send through the relay
agent_card_path:1.0 SendMessage through the relay
A2A-Version: 1.0header and the 1.0 body:Discovery without agent_card_path
agent_card_path:Non-blocking message/send then tasks/get
1.0 multi-turn on the contextId
Chat completions
agent_card_path:Streaming chat completions
"stream": true:The card the gateway serves
Stored agent headers stay as configured
This leg covers decision 23 (commit 4669041), so its before side is the PR's tip before that commit, c2f77fd, where the agent already answers, and not the merge base, where an Entra-authenticated Foundry agent cannot be reached through
a2a/at all. Same config, same topology, same requests on both sides. The listing masks the value of any key that looks sensitive, so a leaked bearer shows as the maskedAuthorizationentry. At the merge base the same three steps give six HTTP 401s and unchanged stored headers, so the write only ever existed on the PR's earlier tipBefore (c2f77fd)
litellm_params.headersoffoundry-agent-static-headersin the listing), four reads in a row against the two-worker proxy:After (e5744c5)
litellm_params.headersoffoundry-agent-static-headersin the listing), four reads in a row against the two-worker proxy:An api_key or authorization header stored next to the Entra fields
This section covers decision 24 (commit 1c40e60), so its before side is the PR's tip before that commit, 4669041, where an Entra agent already answers on both routes, and not the merge base, where no Entra agent can be reached at all. Two more agents carry a wrong static credential next to the same Entra fields:
foundry-agent-with-api-keystoresapi_key: not-a-foundry-tokenandfoundry-agent-lowercase-authstoresheaders: {authorization: Bearer not-a-foundry-token}. At 4669041 the relay already let the minted bearer win (decisions 7 and 20) while the chat route sent the stored value, so/a2a/<agent>answered anda2a/<agent>chat completions failed with Foundry's 401:Received 401 from a service requestfor the stored key, andError parsing client JWTfor the stored header, since both authorization lines went out. Same config and topology on both sides, four calls per step so both workers answerBefore (4669041)
message/sendcalls through/a2a/foundry-agent-with-api-key, the agent with a storedapi_key:"model": "a2a/foundry-agent-with-api-key":message/sendcalls through/a2a/foundry-agent-lowercase-auth, the agent with a storedauthorizationheader:"model": "a2a/foundry-agent-lowercase-auth":After (e5744c5)
message/sendcalls through/a2a/foundry-agent-with-api-key, the agent with a storedapi_key:"model": "a2a/foundry-agent-with-api-key":message/sendcalls through/a2a/foundry-agent-lowercase-auth, the agent with a storedauthorizationheader:"model": "a2a/foundry-agent-lowercase-auth":Observed during the run, beyond the flow above:
A2AAgentCardDiscoveryErrornow: PR causesmessage/sendanswers in the 1.0 task shape: PR leavestasks/cancelwith -32004: PR leavesclient_secret; tenant and client ids shown: PR leavesType
🆕 New Feature
Decisions
Ordered by how much they shape the change. Each names the alternative it beat
agent_card_pathlitellm_param andagentCard/v1.0as a third probe after the two well-known paths. Foundry configs should setagent_card_path: agentCard/v1.0to skip the two 404 probes. Alternative: probe only, which costs two failed round trips per request on Foundry; or trust the registered card and skip discovery, which changes how every agent behaves todayA2AAgentCardDiscoveryError, which names every probed path and its error. Alternative: keep reporting only the last probe's error, which hid the 401 Foundry returns on the well-known pathlitellm_params(tenant_id+client_id+client_secret, orazure_ad_token, orclient_id+azure_username+azure_password, plus an optionalazure_scope), the same names theazure_aiprovider uses. Alternative: a nestedazure_entrablock nobody else reads, or reusingapi_keyfor a pasted bearer, which cannot mint or refreshclient_secret,azure_ad_token,azure_password) is set on the agent, and never reads the process-wideAZURE_*variables. Alternative: the legacyget_azure_ad_tokenfallback chain, which would authenticate every agent on a box that hasAZURE_CLIENT_SECRETexported and could send one agent's identity to another agent. Managed identity andDefaultAzureCredentialare not wired for agents in this PRhttps://ai.azure.com/.default, the Foundry Agent Consumer scope, overridable withazure_scope. Alternative: the legacyhttps://cognitiveservices.azure.com/.default, which Foundry rejects with 401azure_ad_token. Alternative: static token first, where a stale pasted token would beat a renewable principaldatabricks_oauthis checked before Entra, and the minted bearer overrides anAuthorizationset instatic_headers. Alternative: static headers win, where a stale header would shadow a fresh tokencustom_llm_provider) are left out of the relay's Entra mint; their Entra fields belong to the model provider they bridge to. Alternative: mint for every agent, which would send a Foundry bearer to a chat model provideroptional_paramson the chat route and out of the streaming logging object'slitellm_params, which now carries only the pricing keys. Alternative: rely on the downstream secret redaction, which runs after raw kwargs reach callbacksconfiguration.blocking: trueonmessage/send, so Foundry answers in the same response. Alternative: polltasks/getuntil the task completes, which needs a timeout policy and more round trips.acceptedOutputModesis left unset because Foundry ignores itcompletion()stripsa2a/before dispatch, and treats the empty header dictcompletion()hands it as "no caller headers". Alternative: re-add the prefix inside_complete_a2a, which hides the contract, or have the proxy'sa2a_routinginjectapi_keyandheaders, which duplicates the lookupazure_aiprovider does; the credential caches the token in memory, so only the first call reaches Entra. Alternative: thread an async mint through_complete_a2a, a wider change to the sync and async dispatchaget_agent_cardnow sends the agent's extra headers on the card fetch, because Foundry wants the bearer on the card too. Alternative: keep the card fetch unauthenticated, which 401s on Foundryagent_card_pathis joined onto the base as a relative path by the SDK (it becomes{base}/https://...); documented rather than special-cased, since no known agent needs itazure_aiprovider's cached credential provider"kind": "message", the 0.3 discriminator Foundry requires. Alternative: add it only when the card names Foundry, but the a2a-sdk sends it on every message too, so the relay already didcapabilitiesblock with no truthystreamingruns as a blockingmessage/sendand is replayed as one chunk, with usage estimated the same way the non-streaming route does; a card with nocapabilitiesblock at all keepsmessage/stream, so config.yaml agents registered before cards carried capabilities behave as before. The block is read the way the A2A spec and the a2a-sdk client read it, absent meaning false, becausePOST /v1/agentsstores the upstream card throughmerge_agent_card, which keeps only truthy capabilities and turns Foundry'sstreaming: falseinto an empty block: the first cut required a literalfalseand only ever fired for hand-written config.yaml agents. Alternatives: treat a missing block as no streaming too, the spec's default, which changes every existing agent; keepstreaming: falseon the stored card, which changes the card the proxy serves for every agent registered through the API; retry withmessage/sendafter a-32004error, which costs a failed round trip on every call; alwaysmessage/send, which drops streaming for agents that support itThis operation is not supportedturned into an empty streamMappingProxyTypereturns in the Entra helpers and the_card_http_kwargshelper come from the repo's mutability lint, not from behaviorauthorization, throughextra_headersor thex-a2a-<agent>-authorizationconvention, is dropped whatever its casing when the relay mints a bearer, so the backend gets oneAuthorizationline. Alternative: override on the exact name only, whereauthorizationandAuthorizationboth go out and the backend picks oneA2AAgentCardDiscoveryErrorcarries the first non-404 probe status, else 404, so the relay's HTTP status tells an auth failure (Foundry's 401 on the well-known paths) from a missing card. Alternatives: the last probe's status, which the a2a-sdk'sAgentCardResolutionErrorreported at the base and which hid the 401 behind the final 404; always 404, which e243237 did and made every discovery failure look like a missing agentllms/a2a,resolve_a2a_hop_auth_header, with the mint injected so its tests need no monkeypatch; the endpoint keeps only thedatabricks_oauthcheck it already had. Alternatives: branch on the Azure helpers inside the endpoint, which the first cut did and the repo's provider-code rule flags; move the Databricks check underllms/too, which pulls pre-existing proxy code into the PRheadersbefore use and the header build returns a new dict instead of writing into its input, so the bearer one caller supplies (an explicitapi_keyor the Entra token minted for that request) is written to that request alone. Alternative: keep handing the stored dict through, which wrote each caller's bearer onto the shared in-memory agent, where the next caller with no key of their own sent it (Bugbot's finding on c2f77fd, pinned by the two-caller test intests/test_litellm/test_a2a_registry_lookup.py)api_keyis ignored and a storedauthorizationheader is dropped in any casing when the agent authenticates with Entra, which is what decisions 7, 8 and 20 already did on the relay; a bridge agent (custom_llm_providerset) keeps its stored values (commit 1c40e60). Alternatives: let the storedapi_keywin, which 4669041 did and which sent Foundry the static key the Entra fields were configured to replace (the legacy provider's behavior the ticket asks not to inherit); anapi_keypassed on the request itself still wins, the lookup's existing contract, so a caller who supplies a bearer gets the bearer they suppliedruntime_checkableProtocol (_HasStatusCode, an object with astatus_codeattribute) instead ofisinstance(error, AgentCardResolutionError)(commit d4b5f05). The SDK class is imported insidetry/except ImportErrorso basedpyright reports it possibly unbound and never narrowserrorthrough it, which put threereportAttributeAccessIssuehits on the new hunk and pushed the rule over the budget main carries; the resolver raises onlyAgentCardResolutionError, so the Protocol matches the same errors. Alternatives: keep the class check and suppress the three hits (a suppression per access on a hunk that is otherwise clean);getattr(error, "status_code", None), which types the value asAnyand moves the cost toreportAny; import the class unconditionally, which makes the module fail to import without the a2a-sdk installed, where today only the resolver call failsazure_ad_tokenis anoidc/federated token is exchanged only with thetenant_idandclient_idstored on that agent, and without both the call fails with the credential help, which now names the requirement (commit e5744c5, from bugbot's review of d4b5f05). The shared OIDC helper fills a missing id from the host'sAZURE_TENANT_IDandAZURE_CLIENT_ID, so the previous tip could exchange the host's federated token for the host's identity at that agent's URL, the process-wide fallback decision 7 rules out. Alternatives: pass the ids through and document the fallback (the legacy provider's behavior the ticket asks not to inherit, and the docs already promise the host variables are never read for an agent); treat anoidc/token without ids as a static bearer (sends the literaloidc/...string to Foundry, a 401 with no hint)Caveats (if any)
Low
capabilitiesblock still getmessage/streamon streaming chat completions, the tail of decision 17This operation is not supported. Through the proxy the merge base never got that far (HTTP 401, no bearer is sent), and with a bearer supplied on the SDK path it answered an empty stream, so the only path this changes was already unusable and now names its causecapabilities.streaming: false, and agents registered throughPOST /v1/agentsalways carry the block; left as is because the fix (a missing block meaning no streaming) turns every existing config.yaml agent that streams today into a fake streamlitellm.completion(model="a2a/...", api_base=..., api_key=..., stream=True)) has no registry and so no card, and sendsmessage/streamThis operation is not supportederror instead of an empty stream;stream=Falseworks; left as is for the same reason, a retry as a blocking send on that error would add a hidden second request to every agent's failure pathDefaultAzureCredentialare not wired for agentsagent_card_pathcosts two 404 probes per call, on top of the per-request discovery the relay already didagent_card_path: agentCard/v1.0skips them; a card cache is a follow-upmessage/sendthrough the relay answers in the 1.0 task shape (result.task), pre-existingsupportedInterfacesurl to the proxy, pre-existingstream: true, the shape the client asked for, so spend rows do not say the backend call was blockingagent_referenceroute is unchanged and tracked separatelyagent_card_path, the Entra fields, and the blocking fallback: docs(a2a): register Microsoft Foundry agents over A2A with Entra credentials litellm-docs#1508Invalid 'assistant_id'from the legacyazure_ai/agents/<agent>model, is not in the posted legs: through the proxy the QA service principal gets HTTP 401 from the legacy threads API before the assistant id is checked, and the 400 was reproduced once on the SDK path with a user identity; the PR does not touch that pathbuild_and_testjobs (litellm_utils_testing,pass_through_unit_testing,logging_testing,ocr_testing), each red on main's own pipeline 89764 and fixed on main since (fix(proxy): forward every method on the typesafe pass-through route #41723, test(logging): add azure_spillover to the GCS pub/sub spend-log golden #41729 and the OCR changes in fix(ocr): keep a downloaded document inlined when callbacks intercept the request (#41550 follow-up) #41719 and refactor(rust): isolate legacy callback contract #41752; main's 89780 is 44 of 44 green), so they turn green on the merge commit; merging main into the branch first would only reset the bot reviews and the per-commit QALive PR risk
Base 16bbff6 and the tip ran through the same legs in the same order, the tip at 4669041, again at 1c40e60, again at d4b5f05 and again at e5744c5 (18 leg headers on each re-run, every status line the same as at 4669041), each as one proxy with 2 uvicorn workers and no database, started from its own worktree with
PYTHONPATHset to it, on the same config (three Foundry agents: one withagent_card_pathand acapabilitiesblock, one withoutagent_card_path, one withoutcapabilities), driven through the same legs in the same order. The SDK legs (litellm.completionwith and withoutstream, andaget_agent_card) ran on the base tree and on all four tips too, each printing thelitellmit imported. The first run at e5744c5 hit a DNS outage on the QA machine mid-chain (the proxy answerednodename nor servname providedfor the Foundry host and the cost map fetch failed the same way), so that run was discarded and the whole chain, base legs excluded, ran again from a fresh proxy once names resolved; every posted leg at this tip is from that second runMerge ref: main has moved since the merge base and touched
litellm/main.pyandlitellm/llms/azure_ai/common_utils.py, so the legs ran again on local merges of each tip into main, all clean: 4669041 into 356b8d4 (the risk legs, the Proof of Fix legs and the SDK legs), 1c40e60 into 57d41bd (the same legs plus the mixed-credential section; that merge is now on the branch as c29d1c2) d4b5f05 into 03ba563 and e5744c5 into d42f448 (the same legs, never pushed). Every status line matched the tip's on every merged tree, the stored headers stayed as configured, and the PR's mapped unit test files pass on each, 165 tests on the lastBreaking: none observed. Every relay route (
message/sendin the 0.3 and 1.0 shapes,message/stream,tasks/get, the served card,/v1/agents), chat completions with and withoutstream, and discovery with and withoutagent_card_pathanswer at the tip as the User Flow says, and the base fails them the way the Before section showsBackward incompatible:
aget_agent_card(also exported aslitellm.a2a_protocol.aget_agent_cardand used bylitellm/a2a_protocol/client.py) raisesA2AAgentCardDiscoveryError, a litellmA2AError, with the first non-404 probe status, where the base let the a2a-sdk'sAgentCardResolutionErrorfor the last probe escape. Observed with the same call on both trees: basea2a.client.errors.AgentCardResolutionError status_code=401naming only/.well-known/agent.json, tiplitellm.a2a_protocol.exceptions.A2AAgentCardDiscoveryError status_code=401naming all three probesmessage/sendnow carries"kind": "message"andconfiguration.blocking: true(decisions 10 and 16); the a2a-sdk sends the samekindon the relay, and a blocking send is what an agent that answers inline already didcapabilitiesblock has no truthystreamingturns a streaming chat completion into one blocking chunk (decision 17); a JSON-RPC error inside a stream raises instead of yielding an empty chunk (decision 18); a minted bearer replaces a forwarded clientauthorizationin any casing (decision 20)Regression risk, reachable but not driven live at the tip:
POST /v1/agents, the DB-backed path wheremerge_agent_cardstores the upstream card, were not driven live; the shape it produces is what decision 17 was written for and is pinned intests/test_litellm/proxy/agent_endpoints/test_a2a_endpoints.pydatabricks_oauth, and the relay's static-header forwarding for agents without Entra fields, are unit-tested in the same file, not driven liveagent_referenceroute is untouched by the diff and was not drivenDependency graph, every dependent of a changed symbol and how it was covered:
aget_agent_card,LiteLLMA2ACardResolver,A2AAgentCardDiscoveryError: the relay's per-request discovery (live, the relay legs), the SDK client ina2a_protocol/client.py(live, the card fetch above),POST /v1/agentsregistration (unit tests). No dashboard reference: the twoagent_card_pathhits inui/litellm-dashboard/src/components/public_model_hub.tsxare comments about the a2a-sdk defaultsagent_card_pathlitellm_param (AGENT_CARD_PATH_PARAM): read bya2a_protocol/main.pyand the completion bridge handler; a new key with no prior meaningA2AConfig.resolve_agent_config_from_registry,transform_request,A2AModelResponseIterator.chunk_parser:litellm.main._complete_a2afor everya2a/chat completion (live, the chat legs with and withoutstream, on all three agents) andlitellm_completion_bridge/handler.py(unit tests intest_completion_bridge_streaming.py)A2AConfig.validate_environmentandresolve_agent_config_from_registryafter 4669041, which got its own graph walk: the header build now returns a new dict and the registry lookup copies the storedheaders. Each has one A2A caller (llm_http_handler.pyfor the header build,litellm.main._complete_a2afor the lookup) and both use the return value, so nothing relied on the old in-place write; every chat leg drives both, and the stored-headers section in Proof of Fix shows the effecta2a_hop_uses_entra,_agent_authenticates_with_entra,_registry_api_keyand_registry_headersafter 1c40e60, which got its own graph walk: the first is read byresolve_a2a_hop_auth_headeralone, whose one caller is the relay's_resolve_backend_auth_header(live, every relay leg); the other three are read byresolve_agent_config_from_registryalone, whose one caller islitellm.main._complete_a2a(live, every chat leg and the mixed-credential section of Proof of Fix). The stored dict is copied, not written, which the stored-headers section shows. No string dispatch, subclass, dashboard or config-key reference reaches any of the four, and the precedence of anapi_keypassed on the request is unchanged (the parametrized test intests/test_litellm/test_a2a_registry_lookup.pypins all three agent shapes)_HasStatusCodeafter d4b5f05, which got its own graph walk: a module-private Protocol read by_discovery_status_codealone, whose one caller is_get_agent_card_from_first_reachable_path, so the only dependent path is discovery's status choice when every probe fails, driven live by the relay legs withoutagent_card_path(two 404 probes before the third path answers) and by the SDK card fetch that gets a 401 on both trees;tests/test_litellm/a2a_protocol/test_card_resolver.pypins the first non-404 status and the all-404 case. No other module referencedAgentCardResolutionError, so dropping its import changes no other pathget_azure_ai_agent_entra_tokenafter e5744c5, the last commit, which got its own graph walk: two callers,resolve_azure_ai_agent_auth_header(the relay's defaultresolve_entra_header, live on every Entra relay leg) andA2AConfigon the chat route (live on every Entra chat leg), and both reach the changed lines only for an agent whoseazure_ad_tokenstarts withoidc/, which no QA agent does, so every posted leg still takes the service principal branch and its status lines are unchanged at this tip; the OIDC branch is pinned by the two new tests intests/test_litellm/llms/azure_ai/test_azure_ai_entra_auth.py(host ids set and never used; agent ids and the Foundry scope reach the exchange).AZURE_ENTRA_CREDENTIAL_HELPhas no other reader_forwarding_headersand_resolve_backend_auth_headerina2a_endpoints.py, plusresolve_a2a_hop_auth_header(added in c2f77fd tolitellm/llms/a2a/common_utils.py, with its own graph walk: one caller, the relay's header build; its module now importsllms/azure_ai/common_utilsat load, whichproxy/utils.py, the chat transformation, the guardrail handler, the streaming iterator and the AgentCore transformation already reached through the chat transformation): every relay request (live, the relay legs); the casing override is pinned intest_a2a_endpoints.pyand the bridge-agent exclusion intests/test_litellm/llms/a2a/test_common_utils.pyget_azure_ai_agent_entra_token,has_azure_entra_params,AZURE_ENTRA_LITELLM_PARAM_KEYSinazure_ai/common_utils.py: additions only, read by the chat transformation and, throughresolve_a2a_hop_auth_header, the relay; theazure_aiprovider's own Entra path is unchangedGET /v1/agentsshowsclient_secretasREDACTED_BY_LITELMon both trees and never carries the raw secret (checked by value on four reads per side;tenant_idandclient_idare shown, they are identifiers). The agent writers restore the stored value when that marker comes back (_restore_redacted_litellm_paramsinagent_registry.py); the masking and the restore both pre-date the PR, which does not touch that file, and work on key names, so they cover the new fields. Traced, not driven: the writers need a databasetests/agent_tests/test_a2a_agent.py(updated in this PR) ran green in the agent_testing job at the tip;tests/llm_translation/test_a2a.pyskips itself when no local agent listens (4 skipped at the tip, the same as on main); the local-only suites undertests/agent_tests/local_only_agent_tests/need a LangGraph server and an AgentCore runtime and were not run. The workflow's reds at the tip are classified belowNot verified: the DB-registered agent path,
databricks_oauthagents, the Responsesagent_referenceroute, managed identity /DefaultAzureCredential(not wired, decision 4), and the local-only a2a suites that need a LangGraph server and an AgentCore runtimeCircleCI at 1c40e60 (pipeline 89752, workflow 88a135df) and at 4669041 (pipeline 89742): the
integrationworkflow is green andbuild_and_testis red on the same eight jobs, on the same tests. None is caused by this PR: each failing test was fixed on main after the merge base 16bbff6, and every fix is an ancestor of 57d41bd, the main merged into the branch as c29d1c2llm_translation_testing,local_testing_part1,local_testing_part2: 22 Together AI tests failing withUnable to access non-serverless model openai/gpt-oss-20b, fixed on main by test(together_ai): move request-shape checks to the mapped file, drop the live ones #41360logging_testing:test_generic_api_callback_sumologic_uses_ndjson, fixed on main by test(logging): pick this test's own records out of the shared log batch #41487litellm_router_testing(test_router_fallbacks_with_cooldowns_and_dynamic_credentials),llm_responses_api_testing(the fourtest_base_responses_api_streaming_iteratortests) andbuild_and_test(test_chat_completion, a gpt-4 403 from the CI key): fixed on main by test: fix seven tests left stale by #41311, #41337, #39996, #41310, #41289 and #41315 #41527e2e_ui_testing:tests/modelsPage/autoRouterTemplateSelect.spec.ts:49:7failed andkeyBudgetWindow.spec.ts:29:3was flaky, 151 specs passed; the failing spec was dropped on main by test(e2e): drop the auto-router select "opens below" spec #41568 and no UI file is in the PRCircleCI at d4b5f05 (pipeline 89767, workflow dbf1742b): the
integrationworkflow is green (7 of 7 jobs) andbuild_and_testis red on four of 44 jobs, none caused by this PR: each of them is red on main's own pipeline 89764 (03ba563) and reproduces on a pure checkout of mainlitellm_utils_testing(test_models_by_provider, expects atypesafeprovider key) andpass_through_unit_testing(test_pass_through_routes_support_all_methods, expects/typesafe/{endpoint:path}to take every HTTP method): both fail the same way on main's own pipeline 89764 and on a local run of the newest main 03ba563 merged with the tipocr_testing(test_basic_ocr_with_url[True]and[False],invalid OCR document data URI): fails the same way on main's pipeline 89764logging_testing(test_async_gcs_pub_sub_v1,Extra key in actual: metadata.azure_spillover): reproduces with the identical message on a pure checkout of main 02a20fe and on 03ba563 merged with the tip; the key comes from main's 7b855bd, which updated the spend-log golden but not the GCS Pub/Sub one, and the PR touches nothing under loggingagent_testing, the job that runs this PR's A2A suite, is green at d4b5f05. None of the red jobs is in main's rulesetrequired_status_checks, and the 28 required GitHub Actions checks,osv-scanincluded, all pass at d4b5f05CircleCI at e5744c5 (pipeline 89774, workflow cd7223c1, the tip): the
integrationworkflow is green (7 of 7 jobs) andbuild_and_testis red on the same four of 44 jobs as at d4b5f05, none caused by this PR: each is red on main's own pipeline 89764 (03ba563) and green on main's pipeline 89780 (44 of 44, 06:09Z), because main fixed every one of them after the last main merged into this branch (57d41bd, on the branch as c29d1c2)litellm_utils_testing(test_models_by_provider) andpass_through_unit_testing(test_pass_through_routes_support_all_methods): fixed on main by fix(proxy): forward every method on the typesafe pass-through route #41723 (acc375a, 01:04Z), which skipsevaluation-mode cost-map entries in the first test and forwards every HTTP method on the typesafe route for the second; both pass on a local merge of the tip into main d42f448logging_testing(test_async_gcs_pub_sub_v1,Extra key in actual: metadata.azure_spillover): fixed on main by test(logging): add azure_spillover to the GCS pub/sub spend-log golden #41729 (fc662a7, 01:56Z), which adds the key totests/logging_callback_tests/gcs_pub_sub_body/spend_logs_payload.json; locally the test stops at the enterprise gate withoutLITELLM_LICENSE, so main's 89780 is its evidenceocr_testing(test_basic_ocr_with_url[True]and[False],invalid OCR document data URI): two OCR changes landed on main between those two pipelines, fix(ocr): keep a downloaded document inlined when callbacks intercept the request (#41550 follow-up) #41719 (7e03084, 01:16Z) and refactor(rust): isolate legacy callback contract #41752 (b1f9da7, 04:46Z), both in the Rust OCR crate the job builds withinstall_rust, and the job is green on 89780; the test is an opt-in live Vertex AI leg locally (skipped), so 89780 is its evidence tooagent_testing, the job that runs this PR's A2A suite, is green at the tip. None of the red jobs is in main's rulesetrequired_status_checks, and the 28 required GitHub Actions checks,osv-scanincluded, all pass at the tip. Merging main into the branch only to turn CircleCI green was weighed and skipped: it resets the bot reviews and the per-commit QA without changing what ships, and the four tests already pass on the local merge of the tip into mainFinal Attestation
Note
High Risk
Touches proxy authentication, credential handling, and core A2A/chat request shaping; behavior changes include discovery errors, blocking sends, streaming fallback, and what gets logged.
Overview
Adds Microsoft Foundry–compatible A2A support: agent cards can be fetched via optional
agent_card_pathor a new/agentCard/v1.0probe (after the two well-known paths), withA2AAgentCardDiscoveryErrorreporting every failed attempt and a sensible HTTP status.Entra ID for A2A hops is minted from each agent’s own
litellm_params(default scopehttps://ai.azure.com/.default), forwarded on relay routes (message/*,tasks/*) and chat completions, without using hostAZURE_*env vars or leaking secrets into streaming logs. Completion-bridge agents (custom_llm_providerset) skip hop-level Entra minting; mintedAuthorizationwins over forwardedauthorizationheaders.Chat completions over
a2a/now resolve registry config by stripped agent name, apply registeredapi_key/headers/Entra, sendkind: message, use blockingmessage/sendfor non-streaming, and fake-stream when the card’s capabilities omit truthystreaming. JSON-RPC errors in A2A streams surface asA2AErrorinstead of empty output. Header/registry handling avoids mutating shared agent header dicts across callers.Reviewed by Cursor Bugbot for commit e5744c5. Bugbot is set up for automated code reviews on this repo. Configure here.