Skip to content

feat(github_copilot): per-user GitHub OAuth connections for Copilot credentials - #45241

Merged
yassin-berriai merged 61 commits into
litellm_m365_copilot_obofrom
litellm_github_copilot_per_user_oauth
Oct 9, 2026
Merged

yassin-berriai merged 61 commits into
litellm_m365_copilot_obofrom
litellm_github_copilot_per_user_oauth

Conversation

@yassin-berriai

@yassin-berriai yassin-berriai commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Related to LIT-9306. Stacked on #45158 (litellm_m365_copilot_obo).

TLDR. A GitHub Copilot LLM credential can now use Auth Type "Per-user GitHub OAuth". Every request to a github_copilot/... model on that credential uses the calling LiteLLM user's own GitHub token, exchanged for a short-lived Copilot token cached per user. Internal Users connect their own GitHub account from Models + Endpoints > LLM Credentials > Your connections. Callers still send only their LiteLLM key or JWT. "Shared device login" stays the default and behaves exactly as before.

Add Model. "Per-user GitHub OAuth" is now credentialOnly in Add Model (commit 2aab235). Because github_copilot_auth_type is server-owned, the form never sends it inline. Instead it shows "Create credential", which opens the credential form with the provider locked and that auth type preselected, saves the credential with github_copilot_auth_type: per_user_oauth and attaches it through litellm_credential_name. Submitting the model without a credential is blocked. "Shared device login" stays inline. Before this change, an admin who picked per-user OAuth directly in Add Model got the 400 "server-owned ... parameter" error

Flow

  1. A proxy admin creates a GitHub Copilot credential with Auth Type "Per-user GitHub OAuth", either from LLM Credentials or from Add Model, where picking that auth type shows "Create credential" and opens the credential form with the provider locked and the auth type preselected. It saves credential_values = {"github_copilot_auth_type": "per_user_oauth"}, with no secret, and the model is attached to it through litellm_credential_name.
  2. An Internal User opens LLM Credentials. They see only "Your connections", never the shared credential list or its values. Admins see both. Internal Viewers get no tab.
  3. Connect: POST /credentials/{name}/user_connection/start runs the GitHub device flow with the existing allowlisted Copilot client (Iv1.b507a08c87ecfe98, scope read:user). Start returns an opaque flow_handle, which is encrypt_value_helper over {user_id, credential_name, device_code, interval, expires_at}, so the device_code never reaches the client in plaintext and no worker holds flow state. Each .../poll call sends {"flow_handle": ...}, gets a 400 when the handle is undecryptable, expired or bound to another user or credential, and polls GitHub once. On success it seat-checks via copilot_internal/v2/token (no seat returns no_copilot_seat and stores nothing), reads the login from GET /user, and stores encrypt_value_helper({"access_token", "github_login"}) in the new LiteLLM_UserProviderCredentials. Disconnect is DELETE /credentials/{name}/user_connection. With Redis attached it writes the not-connected tombstone and reads it back before deleting the row, since RedisCache swallows write errors, and returns 503 with the row kept when that write fails, so a retry is safe. A successful connect overwrites the Redis entry with the new connection, so a stale set-if-absent fill from an earlier read cannot leave a not-connected entry. It reads the write back and falls back to a verified delete, and returns 503 with the row kept if a stale entry survives both. GET /credentials/user_connections lists status and never returns token values. All four routes act only on user_api_key_dict.user_id. A missing user returns 401. An unknown or non-per-user credential returns 404.
  4. Requests: litellm_pre_call_utils checks whether the requested model group, or one of its fallbacks, has a deployment on a per-user credential. Discovery uses the same lookup as routing: get_model_list(model_name, team_id=<caller team>) for team public model names, plus get_deployment(model_id=...) for calls by deployment ID. When discovery hits a strategy router deployment (semantic, complexity, adaptive or quality auto-router), it loads all of the caller's per-user connections in one query, since the router picks the target later. Only then does it load that caller's tokens into SecretFields.user_provider_credentials, so other models never hit the DB. utils.wrapper/wrapper_async attach a GithubCopilotUserSession (validated host plus Copilot token) after load_credentials_from_list and before the cache lookup. Chat, Responses, Anthropic Messages and embeddings all read that session. When the mode is per-user, nothing touches the shared Authenticator or token file:
per-user credential, no connection   -> 401 "Connect GitHub Copilot for credential '<name>' in the LiteLLM UI (LLM Credentials)"
exchange 401/403/404                 -> CallerCredentialAuthenticationError (401 reconnect) + evict cached Copilot token
exchange 429                         -> CallerCredentialRateLimitError (429)
Caller* errors                       -> exempt from deployment cooldown (one user cannot cool down a shared deployment)
per-user deployment failures        -> exempt from cooldown on router and fallback paths, keyed on the deployment's own config
endpoints.api not https://*.githubcopilot.com -> https://api.githubcopilot.com

Security

  • The response cache skips per-user requests. _is_response_cache_excluded also returns true for is_github_copilot_per_user_request(kwargs), because cache keys ignore caller identity. Shared device-login models keep caching.
  • github_copilot_auth_type joins server_owned_wif_litellm_params as github_copilot_oauth_litellm_params. The existing non-admin gates therefore stop team admins from creating, patching or attaching a per-user credential or setting the mode on a model.
  • github_copilot_auth_type, user_provider_credentials and github_copilot_user_session are added to _BANNED_REQUEST_BODY_PARAMS.
  • A caller api_base override keeps litellm_credential_name/github_copilot_auth_type for per-user deployments in clientside_credential_handler, so it cannot flip the call into shared mode. The session's validated host still wins.
  • Deleting a credential or switching it out of per-user mode deletes its user connections. A label-only update keeps them, since the update route cannot rename. Every read and write on the connection store uses the writer engine, so a read replica can't hide a fresh connection
  • If a per-user credential applies to the requested model group or any of its transitive fallbacks, a request body that contains litellm_credential_name, even empty or null, gets a 400, so the caller cannot switch the call to a shared credential.
  • Failures from a per-user session (exchange or inference 401/403/429) never cool down the shared deployment. Shared-mode failures still cool down as before.
  • SecretFields.user_provider_credentials is a RedactedDict, the same as raw_headers, so debug request logs never print GitHub tokens. Streaming cache writes also see the per-user marker, so per-user streamed completions are never cached.
  • Device-flow and credential cache keys use a JSON-encoded (user_id, credential_name) pair, and the connection routes take {credential_name:path}, so credential names containing / or : work. Disconnect also clears any pending device flow.
  • When Redis is attached to the auth cache, the per-user token store reads and writes Redis only, skipping the worker-local layer, so connect and disconnect invalidate on every worker. Fills use set-if-absent and disconnect writes a not-connected tombstone, so a request that read the row before disconnect cannot restore the old token. A Redis error counts as a cache miss and falls back to the database. Without Redis the store is not cached at all, so each per-user request reads its row from the database
  • The litellm_credential_name body check runs as soon as discovery finds a per-user credential, before the missing user_id return, so service-account keys cannot switch a per-user deployment to shared mode. Discovery walks every fallback list the router can use, transitively and with the router's own get_fallback_model_group matcher: router fallbacks, request-level fallbacks, context_window_fallbacks and content_policy_fallbacks, key and team router_settings fallbacks, plus the names model_alias_map and model_group_alias rewrite to. Exact-match keys use one dict lookup, with duplicate source keys unioned, and the matcher only sees wildcard keys, so discovery is a superset of what the router can pick. Discovery only runs when some credential in litellm.credential_list uses per-user OAuth, so shared-only proxies keep the base behavior exactly. With per-user OAuth configured, request bodies whose fallback lists carry more than _FALLBACK_DISCOVERY_LIMIT (256) work units get a 400, where every mapping key costs at least one unit even with an empty or scalar value, and each wildcard entry expands only once. As a second guard that doesn't depend on discovery, Router._update_kwargs_with_deployment raises BadRequestError when a caller-supplied litellm_credential_name differs from a per-user deployment's own credential
  • pin_session_authorization drops any caller Authorization header and sets the session bearer at the last header merge in Chat, Responses, Anthropic Messages, embeddings and _complete_custom_openai, so extra_headers cannot replace the stored login. When the session is attached, the wrapper also drops caller Authorization keys from extra_headers, headers and the extra_headers inside optional_params and litellm_params, so the HTTP handler cannot add them back
  • The four connection routes are method-scoped in RouteChecks (_INTERNAL_USER_CREDENTIAL_CONNECTION_ROUTES): Internal Users get only GET /credentials/user_connections, POST .../user_connection/start, POST .../user_connection/poll and DELETE .../user_connection. Any other method on those paths falls through to the admin-only denial, so a credential whose name collides with a connection path can never reach the generic credential CRUD handlers through this allowlist.
  • Per-user tokens load only for the identity the API key or JWT authenticated. add_litellm_data_to_request captures authenticated_user_id before user_header_mappings can overwrite user_api_key_dict.user_id, so a caller-supplied identity header cannot select another user's connection.

Files changed (excluding tests)

Backend: litellm/llms/github_copilot/per_user_auth.py (new: session, sync/async exchange with per-key single-flight, host validation, device flow, redaction), litellm/proxy/credential_endpoints/user_provider_credentials.py (new: encrypted storage plus DualCache), litellm/proxy/credential_endpoints/endpoints.py (four routes, cascade), litellm/proxy/litellm_pre_call_utils.py, litellm/utils.py, litellm/caching/caching_handler.py, litellm/llms/github_copilot/{chat,responses,messages,embedding}/transformation.py, litellm/litellm_core_utils/get_llm_provider_logic.py, litellm/main.py, litellm/responses/main.py, litellm/router.py (pass litellm_params to get_llm_provider at deployment add, so a per-user deployment does not trigger the shared device login), litellm/router_utils/{clientside_credential_handler,cooldown_handlers}.py, litellm/exceptions.py, litellm/types/utils.py, litellm/proxy/_types.py, litellm/proxy/auth/auth_utils.py, the three schema.prisma copies plus migration 20261007221529_user_provider_credentials.

UI: provider_auth_types.ts (two GitHub Copilot auth types, fixedValues), UserConnectionsPanel.tsx, GithubCopilotConnectModal.tsx, github_copilot_connect_flow.ts (reducer: pending, slow_down, expired, denied, no_copilot_seat, connected), useUserConnections.ts, networking.tsx, page.tsx and LlmCredentialsPanel.tsx (role gating), and schema.d.ts (regenerated by npm run gen:api, only the four routes).

Before / after

Before: GitHub Copilot had only one device login in the proxy's token file, shared by every caller and billed to one seat. Internal Users could not open LLM Credentials.
After: an admin can choose per-user mode per credential. Each Internal User connects their own GitHub account and requests run on their own Copilot seat. Unconnected users get a 401 pointing them to the UI, never the shared login.

Test evidence

  • Backend focused suites: 2002 passed (github_copilot, credential_endpoints, pre_call_utils, auth_utils, model_management_endpoints, caching, cooldown_handlers, types/test_router). tests/unit/test_router/: 1258 passed. The 6 failures there reproduce on the base (semantic_router not installed) or are suite-order flakes that pass standalone.
  • After the bot-review fixes: 702 passed on the touched backend suites (per_user_auth, credential endpoints, cooldown handlers, pre_call_utils), plus the new UserConnectionsPanel Vitest suite.
  • Dashboard Vitest on touched suites: all green (role gating for admin, internal user and viewer, the connect state machine, disconnect, and auth selector fields unmounting).
  • make check: green (ruff, format, basedpyright vs base, LIT/TQ gates, dashboard lint budgets, API type sync).
  • Mutation checks, each failing tests and then restored: removing the no-fallback guard, sharing the session cache key across users, removing the request-body bans, removing the response-cache exclusion, dropping github_copilot_oauth_litellm_params from the server-owned set, reverting the deployment-add per-user branch, removing the body litellm_credential_name guard, and removing the per-user cooldown exemption.
  • Live proof on a local proxy with Postgres and the dashboard at head 47160b0720, with a real GitHub Copilot account approving the device codes. Admin created the per-user credential and attached a model (this first returned 500; the fix is in this PR). User A clicked Connect and the modal showed "Connected as @yassin-berriai". A's chat completion on the per-user model returned 200 (14 prompt + 4 completion tokens), and so did Responses (200, 18 tokens), Anthropic Messages (200, 14 in + 4 out) and embeddings (200, 1536 dims, 1 token). User B sent the same request and got 401 Connect GitHub Copilot for credential 'copilot-per-user' in the LiteLLM UI (LLM Credentials) with no fallback, even with response caching on. The shared device-login model returned 200 (18 tokens). After A clicked Disconnect, A's request returned 401 with the connect message. The viewer got no tab, and GET /credentials/user_connections returned 401 for them. A scan of 27 logs and curl transcripts found no gho_/ghu_/ghp_/tid=/sk-.

User A connected

After Disconnect

Admin sees credentials and Your connections
Internal user sees only Your connections
Connect modal with device code
Viewer has no LLM Credentials tab

Taxonomy audit

Audited the full diff against the litellm PR-review caveat taxonomy. Notable points: tokens never reach logs, reprs or errors (GithubCopilotUserSession.__repr__, redacted exceptions); authz uses only the authenticated user id; the new routes are declared before the greedy /credentials/{credential_name:path}; per-user requests opt out of cache identity collisions; caller credential failures don't cool down deployments; there's no DB access for unrelated models; sync and async paths are at parity; shared mode is covered by regression tests. Known limitation, not addressed here: the batch retrieve and cancel router paths call get_llm_provider(model=...) without deployment params, so a per-user deployment there would still reach the shared authenticator. Batches are not one of the four supported surfaces.

Link to Devin session: https://app.devin.ai/sessions/d08b073f89e542b984aee767e79470a6
Open in Devin Desktop: https://app.devin.ai/desktop/session/d08b073f89e542b984aee767e79470a6?variant=devin

@devin-ai-integration

Copy link
Copy Markdown
Contributor

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@CLAassistant

CLAassistant commented Oct 8, 2026 •

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
0 out of 2 committers have signed the CLA.

❌ yassin-berriai
❌ devin-ai-integration[bot]
You have signed the CLA already but the status is still pending? Let us recheck it.

@greptile-apps

greptile-apps Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[Critical impact] The PR appears safe to merge with no new actionable findings

Summary

Adds per-user GitHub OAuth connections for Copilot credentials while retaining shared device login as the default

  • Admins can attach a per-user GitHub Copilot credential to a model.
  • Internal Users can connect or disconnect GitHub accounts for Copilot credentials.
  • Copilot requests use the calling user's own connected account.
  • Supporting type and request-shape edits leave unrelated behavior unchanged.

Reviews (21) · Last reviewed commit: "chore: merge M365 updates into per-user ..." · Reviewed by Greptile

Comment thread litellm/proxy/credential_endpoints/user_provider_credentials.py Outdated
Comment thread litellm/proxy/credential_endpoints/endpoints.py
Comment thread litellm/proxy/litellm_pre_call_utils.py Outdated
Comment thread litellm/llms/github_copilot/per_user_auth.py Outdated
Comment thread litellm/proxy/credential_endpoints/endpoints.py Outdated
Comment thread litellm/proxy/litellm_pre_call_utils.py Outdated
Comment on lines +2660 to +2664
if (
isinstance(values, Mapping)
and values.get(GITHUB_COPILOT_AUTH_TYPE_KEY) == GITHUB_COPILOT_PER_USER_AUTH_TYPE
):
names.append(credential_name_obj)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Provider logic crosses shared code

_per_user_credential_names_for_groups implements GitHub Copilot-specific mode checks in the proxy. The repository requires provider-specific logic to stay under llms/. Move the mode decision there and keep the proxy discovery generic before merging. The same pattern appears in the connection endpoints and response-cache check

Rule Used: What: Avoid writing provider-specific code outside of the llms/ directory. Why: This practice ensures better maintainability and reduces complexity over time. Good: ```python # Handle provider-specific logic within llms/vertex_ai/transformation.py ... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mode decision already lives under llms/. github_copilot_auth_mode and is_github_copilot_per_user_request are in litellm/llms/github_copilot/per_user_auth.py, along with every GitHub call (device flow, seat check, exchange). The proxy only calls those helpers to decide which credential names need the caller's token, and the connection endpoints are by nature an API for that provider. Turning discovery into a provider-registry abstraction with one implementation would add indirection without removing any provider knowledge from the proxy, so I'm keeping the direct call

Written by Devin

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That’s fair—the exchange/device-flow behavior and the authoritative github_copilot_auth_mode decision do live in llms/github_copilot. My concern is narrower: this proxy helper still independently imports Copilot constants and reads credential.credential_values to reproduce that decision, rather than calling the provider helper. That leaves two mode-resolution implementations to keep in sync and is the provider-specific logic the repo rule is intended to prevent.

A small provider-facing predicate/credential-name resolver, or a generic registry hook that invokes it, would preserve the current discovery behavior without adding meaningful indirection. If you intentionally accept this one provider-specific discovery exception, I’ll treat the comment as addressed.

Comment thread litellm/proxy/credential_endpoints/user_provider_credentials.py Outdated
Comment thread tests/unit/llms/github_copilot/test_per_user_auth.py Outdated
Comment thread tests/unit/llms/github_copilot/test_per_user_auth.py
Comment thread litellm/llms/github_copilot/per_user_auth.py
@devin-ai-integration
devin-ai-integration Bot force-pushed the litellm_github_copilot_per_user_oauth branch from a4b5b2d to c27c316 Compare October 8, 2026 02:19
@codecov

codecov Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Comment thread litellm/llms/github_copilot/per_user_auth.py Outdated
Comment thread litellm/router_utils/cooldown_handlers.py
@veria-ai

veria-ai Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 9 · PR risk: 0/10

Comment thread litellm/proxy/litellm_pre_call_utils.py Outdated
Comment thread litellm/utils.py
@devin-ai-integration
devin-ai-integration Bot force-pushed the litellm_github_copilot_per_user_oauth branch from c27c316 to 8a39c9e Compare October 8, 2026 03:08
@devin-ai-integration
devin-ai-integration Bot force-pushed the litellm_github_copilot_per_user_oauth branch from 8a39c9e to db3ba26 Compare October 8, 2026 03:14
@devin-ai-integration
devin-ai-integration Bot force-pushed the litellm_github_copilot_per_user_oauth branch from db3ba26 to d7136c4 Compare October 8, 2026 03:27
Comment thread litellm/proxy/_types.py Outdated
@devin-ai-integration
devin-ai-integration Bot force-pushed the litellm_github_copilot_per_user_oauth branch from d7136c4 to f7101c2 Compare October 8, 2026 03:52
@devin-ai-integration
devin-ai-integration Bot force-pushed the litellm_github_copilot_per_user_oauth branch from f7101c2 to a72a51c Compare October 8, 2026 04:05
Comment thread litellm/proxy/litellm_pre_call_utils.py Outdated
@devin-ai-integration
devin-ai-integration Bot force-pushed the litellm_github_copilot_per_user_oauth branch from a72a51c to e26d863 Compare October 8, 2026 04:16
@devin-ai-integration
devin-ai-integration Bot force-pushed the litellm_github_copilot_per_user_oauth branch from e26d863 to 47160b0 Compare October 8, 2026 04:22
Comment thread tests/unit/proxy/credential_endpoints/test_endpoints.py Outdated
@devin-ai-integration

Copy link
Copy Markdown
Contributor

@greptileai please re-review at 55a9346. It adds the missing Final annotations to the new tests, on top of the base health endpoint merge

@devin-ai-integration

Copy link
Copy Markdown
Contributor

@greptileai please re-review at 7ab1853. It types the per-user OAuth JSON, DB and request boundaries so the basedpyright gate passes, with no behavior change

Comment thread litellm/proxy/litellm_pre_call_utils.py Outdated
@devin-ai-integration

Copy link
Copy Markdown
Contributor

@greptileai please re-review at c217402. It adds Final to the new boundary-typing locals and applies ruff format to three files

@devin-ai-integration

Copy link
Copy Markdown
Contributor

@greptileai please re-review at 7fc259b. It merges the base after main landed, keeping both sides of two test conflicts

@yassin-berriai

Copy link
Copy Markdown
Contributor Author
Screenshot 2026-10-09 at 2 43 35 PM called copilot model from claude cowork with OIDC

This branch is waiting to be deployed

1 waiting deployment
e2e-changed — 7fc259b3 Waiting Oct 9, 2026 by devin-ai-integration[bot] via oauth #4197
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants