Repository navigation
feat(microsoft_365_copilot): add Microsoft 365 Copilot chat provider with OAuth token exchange - #45158
Conversation
|
I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".
|
|
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Merging this PR will not alter performance
|
|
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 3 · PR risk: 0/10 |
5c7e921 to
256da10
Compare
256da10 to
747e40c
Compare
747e40c to
0b609df
Compare
0b609df to
4aefe35
Compare
4aefe35 to
0670cad
Compare
0670cad to
9e16a72
Compare
9e16a72 to
097984a
Compare
097984a to
0e9f2a8
Compare
…with OAuth token exchange
0e9f2a8 to
6da0259
Compare
# Conflicts: # ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx # ui/litellm-dashboard/src/components/add_model/provider_specific_fields.tsx # ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx
|
@greptileai Please review the latest merge from main and confirm the M365, OAuth, health check, router, and dashboard changes |
|
@greptileai Please review this CI fix mapping Microsoft 365 Copilot unit tests into the H-to-Z shard |
|
@greptileai Please review the latest pushed changes for PR #45158 and refresh the confidence summary against the current head |
|
@greptileai Please review commit 2a93a1c and post the updated confidence summary and any findings for PR #45158 |

TLDR
Problem this solves:
How it solves it:
microsoft_365_copilotchat providerlitellm/litellm_core_utils/oauth_token_exchange.py, with two grant profiles:rfc8693(standard token exchange) andjwt_bearer_obo(JWT bearer on-behalf-of). The provider exchanges the caller's JWT for a delegated Graph token through itcredentialOnly: the form shows "Create credential", which opens the credential form with the provider locked and the auth type preselected, saves an LLM credential, and attaches it vialitellm_credential_name. Submitting the model with no credential attached is blockedmax_tokens/max_completion_tokensfor OpenAI-client compatibility (the UI "Test connection" health check injectsmax_tokens) and never sends them to Graph.microsoft_365_copilot/chatis in the cost map at $0 (Copilot is license-billed) so the Add Model model dropdown lists it/health/test_connectionwithout a model id borrows the first deployment with the same model. A request that brings its ownapi_keynow gets none of that deployment's stored auth settings (token_exchange_*,client_id/client_secret,litellm_credential_name, Azure AD, AWS role and so on). Before, a static-token test inherited a saved OAuth deployment's exchange config and failed asking for a caller JWT. This applies to every provider; re-testing a saved model, which sends its id and no key, is unchanged"pongpong"for "reply with pong"), so a reply made of the exact same text twice is collapsed to one copy before it is returned and countedsystemturn after the user's question. That turn used to become the Graph prompt, so Copilot answered the environment block with a greeting. The prompt is now the lastusermessage/health/test_connectionnow passes the caller's request headers to the probe as server-builtsecret_fields, the same way normal requests do, so a caller signed in with an IdP JWT can test an OAuth token exchange model. A caller without a JWT still gets the 401User Flow
Before: an admin whose users sign in to LiteLLM with their IdP JWT cannot add Microsoft 365 Copilot as a model
model: microsoft_365_copilot/chatin config.yaml; the proxy logsLLM Provider NOT providedwhile loading it"model": "m365-copilot"; it returns 400There are no healthy deployments for this modelAfter: the same admin adds the model from the UI, and the user's own Copilot license answers the request
chatfrom the model dropdown. Auth Type defaults to "OAuth token exchange (on-behalf-of)" with the note that the endpoint must be Entra for this provider. Clicking "Create credential" opens the credential form with that auth type preselected, showing Token Endpoint URL, Exchange Grant (jwt_bearer_obodefault), Client ID, Client Secret, Scope (https://graph.microsoft.com/.defaultdefault) and Audience. Saving it attaches the credential to the model, and "Test connection" passes. Switching Auth Type to "Static delegated access token" shows only the token field inlinechoices[0].message.content, also with"stream": truemicrosoft_365_copilot with OAuth token exchange requires the caller's IdP-issued access token in the Authorization headerHow it works
sequenceDiagram participant C as Caller (IdP JWT) participant L as LiteLLM proxy participant T as Configured token endpoint (Entra for this provider) participant G as Graph beta Copilot C->>L: POST /v1/chat/completions (Bearer JWT) L->>L: enable_jwt_auth validates the JWT L->>T: token_exchange_profile form (jwt_bearer_obo or rfc8693), client_secret_post T-->>L: delegated Graph token (cached until expires_in minus 60s) L->>G: POST /copilot/conversations, then /conversations/{id}/chat (delegated token) G-->>L: messages[] (user turn, then Copilot reply) L-->>C: OpenAI chat.completion (last message), or fake-streamed chunksExchange forms built by
build_token_exchange_form(config, subject_token):Deployment params:
token_exchange_endpoint,token_exchange_profile(defaultjwt_bearer_obo),token_exchange_scope(defaulthttps://graph.microsoft.com/.default),token_exchange_audience,client_id,client_secret. With endpoint, client ID and secret all set the provider exchanges the caller's JWT and fails closed (401) without one. With none of them set,api_keyis used as a pre-acquired delegated Graph token. A partial set, an unknown profile, a non-HTTPS endpoint, or an emptyjwt_bearer_oboscope is rejected (400) instead of falling back. The fourtoken_exchange_*params are added to the proxy's banned request-body params, so a caller cannot point the server's client secret at its own endpoint. The Graph host is fixed andapi_baseis not honoured.Only a proxy admin can set the four
token_exchange_*params: they are part ofserver_owned_wif_litellm_params(oauth_token_exchange_litellm_params), so the existing non-admin gates on model writes (including config inherited throughlitellm_credential_name), credential writes, request bodies, health display, and clientapi_baseoverrides apply. A team admin therefore cannot point a deployment at an endpoint that would receive callers' JWTs.client_idandclient_secretare also in_FEDERATION_SURFACE_FIELDS, so a team admin patching only those on a deployment that carries exchange config gets 403 too, while the same patch on an ordinary deployment is still allowed.With token exchange configured, a 401 or 403 belongs to the caller (missing or rejected JWT, or a Graph rejection of that user's token), so
Router.deployment_callback_on_failureand the async fallback handler_trigger_cooldown_for_failed_deploymentreturn early throughis_caller_scoped_auth_failurewithout counting a failure or cooling the shared deployment down. This covers exchange config set inline or throughlitellm_credential_name; 429, 5xx and deployments using a staticapi_keykeep the normal cooldownReplies depend on the caller's delegated identity, so
microsoft_365_copilotis inRESPONSE_CACHE_EXCLUDED_PROVIDERSandcaching_handlernever reads or writes the response cache for it. Graph returns no token counts, so usage is estimated withtoken_counterover the request messages and the reply, which keeps TPM limits effective.The token cache key is a SHA-256 over every config field plus the subject token. Token endpoint 429 and 5xx keep their status, other 4xx map to 401, and Graph statuses (for example the 403 for a user without a Copilot license) pass through. The JWT and client secret are redacted from errors.
_collapse_doubled_replykeeps the first half of a reply only when its two halves are identical; a reply that legitimately repeats itself exactly is shortened too, which was accepted as the tradeoff for working around the Graph bugThe last
usermessage is the Graph prompt and every other message, in order, goes to Graph asadditionalContext.microsoft_365_copilot/chatsetssupports_mid_conversation_system, so the Anthropic adapter keeps a system turn sent after the prompt (Claude Desktop sends its environment block that way) assystemcontext instead of turning it into the prompt. At least oneusermessage with text content is required, and images are rejected.locationHint.timeZonedefaults to UTC and can be set withtime_zone.Files changed
litellm/litellm_core_utils/oauth_token_exchange.pyOAuthTokenExchangeConfig,build_token_exchange_form, sync and asyncexchange_token, token cache, status mapping, redactionlitellm/llms/microsoft_365_copilot/chat/handler.pylitellm/llms/microsoft_365_copilot/chat/transformation.pyMicrosoft365CopilotChatConfig, OpenAI messages to Graph chat body, Graph response toModelResponse, Graph error parsinglitellm/llms/microsoft_365_copilot/common_utils.pysecret_fields, provider error typelitellm/llms/microsoft_365_copilot/__init__.py,chat/__init__.pylitellm/main.pycustom_llm_provider == "microsoft_365_copilot"; passtoken_exchange_*kwargs throughlitellm/litellm_core_utils/get_litellm_params.py,litellm/types/litellm_params.py,litellm/types/router.pytoken_exchange_*paramslitellm/types/utils.py(oauth_token_exchange_litellm_params)token_exchange_*joinserver_owned_wif_litellm_params, so only proxy admins can set themlitellm/caching/caching_handler.py,litellm/constants.py_is_response_cache_excludedgates response-cache reads and writes forRESPONSE_CACHE_EXCLUDED_PROVIDERSlitellm/proxy/...gate messages,litellm/router_utils/clientside_credential_handler.pylitellm/utils.py,litellm/types/utils.pyProviderConfigManagerentry,LlmProviders.MICROSOFT_365_COPILOTlitellm/constants.pylitellm/proxy/public_endpoints/provider_create_fields.jsonprovider_endpoints_support.jsonui/litellm-dashboard/src/components/add_model/provider_auth_types.tsui/litellm-dashboard/src/components/add_model/provider_specific_fields.tsxui/litellm-dashboard/src/components/model_add/CredentialModal.tsxui/litellm-dashboard/src/components/provider_info_helpers.tsxlitellm/proxy/common_utils/credential_hydration.pyclient_idandclient_secretjoin_FEDERATION_SURFACE_FIELDS, so writes to them go through the proxy-admin gatelitellm/router_utils/cooldown_handlers.py,litellm/router.py,litellm/router_utils/fallback_event_handlers.pyis_caller_scoped_auth_failure: OAuth exchange 401/403 skip failure counting and cooldownlitellm/proxy/health_endpoints/_health_endpoints.py_request_inherits_config_credentials: a truthy requestapi_keymeans the connection test uses only its own credentials. The probe gets server-builtsecret_fieldsfrom the caller's headers, overriding any body valueLinear ticket
Resolves LIT-9306
Pre-Submission checklist
uv run pytest tests/unit/llms/microsoft_365_copilot tests/unit/litellm_core_utils/test_oauth_token_exchange.py tests/unit/proxy/auth/test_auth_utils.py tests/unit/proxy/management_endpoints/test_model_management_endpoints.py -vScreenshots / Proof of Fix
Tested on litellm 1.106.0 with Postgres,
enable_jwt_auth: true(Entra JWKS,user_id_jwt_field: oid), and a real Entra tenant. The middle-tier app registration exposesaccess_as_userand holds delegated Graph permissions for Copilot Chat. The caller JWT belongs to a user with a Microsoft 365 Copilot license. Before used maina9b9700790on port 4000; after used this branch on port 4001. Tokens are redacted.Body for every leg:
{"model":"<model>","messages":[{"role":"user","content":"<prompt>"}]}Before (a9b9700)
curl -sS localhost:4000/v1/chat/completions -H "Authorization: Bearer $USER_JWT" -d @body_control.json: HTTP 200,"content":"pong"m365-copilot: HTTP 400There are no healthy deployments for this model; proxy logLLM Provider NOT provided. ... You passed model=microsoft_365_copilot/chatAfter, generic token exchange config above
"content":"LiteLLM Copilot live QA passed.LiteLLM Copilot live QA passed.""stream": true: HTTP 200, same content, thenfinish_reason: "stop"anddata: [DONE]After, earlier revision of this PR with the same OBO flow (Entra-named params)
"content":"pongpong"; stream HTTP 200 with[DONE]grep -cover the proxy log for the caller JWT and the client secret: 0 and 0you don't have a valid licensecomes back as 403The doubled text comes from Graph, not LiteLLM. A direct
POST https://graph.microsoft.com/beta/copilot/conversations/{id}/chatwith the same delegated token returns the reply twice in its last message, and the provider passes it through unchanged.Trailing system turn from Claude Desktop (d3b99bf)
Request:
POST /v1/messagesto aclaude-m365-copilotalias withmessages: [{"role":"user","content":"whats 1+1"}, {"role":"system","content":"# Environment\n..."}], asystemlist and a tool, as Claude Desktop sends it"Got it. What can I help you with today?Got it. What can I help you with today?"{"content":[{"type":"text","text":"1 + 1 = **2**."}],"stop_reason":"end_turn","usage":{"input_tokens":52,"output_tokens":9}}text_deltawith1 + 1 = **2**, thenmessage_stopTest connection with its own api_key (a5dfffe)
A saved
microsoft_365_copilot/chatdeployment withtoken_exchange_endpoint,client_idandclient_secret, then the Add Model static-token test:"status":"error",microsoft_365_copilot with OAuth token exchange requires the caller's IdP-issued access token, and the result echoes the other deployment'stoken_exchange_endpoint"status":"error"from Graph itself,Protocol 'Bearer' failed to validate because The token could not be read., so the probe used the request's own token and nothing from the saved deploymentTests
tests/unit/litellm_core_utils/test_oauth_token_exchange.py: exactrfc8693andjwt_bearer_oboforms, audience only onrfc8693, empty OBO scope and non-HTTPS endpoint rejected, cache key separation for every config field and the subject token, TTL margin, 400 to 401, 429 and 5xx preserved, secret and subject token redaction, sync and asynctests/unit/llms/microsoft_365_copilot: mode selection, partial config rejected without any HTTP call, Graph gets the exchanged token never the caller JWT, message mapping, stream chunks, error mapping, and alitellm.completiontest that mocks only HTTP and asserts the exchange POST reaches the configured endpointtests/unit/proxy/auth/test_auth_utils.py, model management, credential endpoint tests:token_exchange_endpointin a request body is rejected; a team admin setting it directly or by attaching a credential that holds it gets 403; a proxy admin is allowed; a team admin rpm-only edit still workstoken_counterover the messages and reply, non-stream and streamtests/unit/proxy/health_endpoints/test_health_endpoints.py: a requestapi_keydrops the matched deployment's exchange config, client secret, credential name,api_baseandvertex_credentials, also withallow_client_side_credentials; a blankapi_keystill inherits; the endpoint test checks the probe gets the request key and no exchange config. Removing the guard fails all three, and"api_key" in request_paramsfails the blank-key testclient_idorclient_secreton an exchange deployment gets 403 and nothing is written; the same patch on an ordinary deployment succeedstests/unit/router_utils/test_cooldown_handlers.py: 401 and 403 on inline and named-credential exchange deployments leave the failure count at 0 and nothing in cooldown; anapi_keydeployment still cools down on 401, and 429 and 500 on an exchange deployment still cool down; the fallback handler skips 401 and 403 on an exchange deployment and still cools down anapi_keydeploymentMutation checks, each failed its targeted tests and was restored: drop
requested_token_usefrom the OBO form; map 429 to 401; dropoauth_token_exchange_litellm_paramsfrom the server-owned set (7 failures); emptyRESPONSE_CACHE_EXCLUDED_PROVIDERS; report zero usage; break selector switching; skip stale credential cleanup; dropclient_id/client_secretfrom the write gate; makeis_caller_scoped_auth_failurealways false, skip its credential lookup, or skip its status check.Scope: chat completions only (sync, async, fake stream).
/v1/messagesgoes through the Anthropic adapter./v1/responsesis not wired for this provider. Graph'schatOverStreamendpoint is not used.Taxonomy audit
token_exchange_*are proxy-admin-only server-owned params (request body, model and credential writes); Graph host fixed;api_baseignoredexpires_inminus 60s and skipped when not positiveapi_keyresponse = _complete_microsoft_365_copilot(...)inmain.pywithoutFinalmatches every sibling dispatch branch_experimental/outbundle is not rebuilt here; bundle refreshes ship separatelyType
New Feature
Link to Devin session: https://app.devin.ai/sessions/453c40b054f240f1bf0e4512da9a9aae
Open in Devin Desktop: https://app.devin.ai/desktop/session/453c40b054f240f1bf0e4512da9a9aae?variant=devin
Requested by: @yassin-berriai