Skip to content
Merged
Show file tree
Hide file tree
Changes from 58 commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
47160b0
feat(github_copilot): per-user GitHub OAuth connections for Copilot c…
yassin-berriai Oct 8, 2026
c005bb4
Merge remote-tracking branch 'origin/litellm_m365_copilot_obo' into l…
devin-ai-integration[bot] Oct 8, 2026
2aab235
fix(ui): make per-user GitHub Copilot OAuth credential-only in Add Model
devin-ai-integration[bot] Oct 8, 2026
546256f
Merge remote-tracking branch 'origin/litellm_m365_copilot_obo' into l…
devin-ai-integration[bot] Oct 8, 2026
a696458
fix(proxy): render LiteLLM_UserProviderCredentials prisma spans
devin-ai-integration[bot] Oct 8, 2026
df80029
Merge remote-tracking branch 'origin/litellm_m365_copilot_obo' into l…
devin-ai-integration[bot] Oct 8, 2026
fe3c3eb
Merge remote-tracking branch 'origin/litellm_m365_copilot_obo' into l…
devin-ai-integration[bot] Oct 8, 2026
642311b
Merge branch 'litellm_m365_copilot_obo' into litellm_github_copilot_p…
devin-ai-integration[bot] Oct 8, 2026
7703843
Merge remote-tracking branch 'origin/litellm_m365_copilot_obo' into l…
devin-ai-integration[bot] Oct 8, 2026
43f0b34
style(ui): prettier-format add_model tests
devin-ai-integration[bot] Oct 8, 2026
e9e98ab
Merge remote-tracking branch 'origin/litellm_m365_copilot_obo' into l…
devin-ai-integration[bot] Oct 8, 2026
47dde07
Merge remote-tracking branch 'origin/litellm_github_copilot_per_user_…
devin-ai-integration[bot] Oct 8, 2026
2da0ae9
fix(lint): annotate connection endpoints and narrow per-user credenti…
devin-ai-integration[bot] Oct 8, 2026
1562a3a
fix(lint): satisfy type-discipline gate for per-user copilot code
devin-ai-integration[bot] Oct 8, 2026
c4f3a5f
Merge remote-tracking branch 'origin/litellm_m365_copilot_obo' into l…
devin-ai-integration[bot] Oct 8, 2026
34f84e9
Merge remote-tracking branch 'origin/litellm_m365_copilot_obo' into HEAD
devin-ai-integration[bot] Oct 8, 2026
c136394
fix(types): clear basedpyright and strict-lint gate regressions
yassin-berriai Oct 8, 2026
a6c88cf
fix(types): keep runtime guards on untyped responses input
yassin-berriai Oct 8, 2026
126cee9
style: ruff format per-user Copilot files
devin-ai-integration[bot] Oct 8, 2026
26487a2
fix(lint): keep type-discipline suppressions on flagged lines
devin-ai-integration[bot] Oct 8, 2026
05c1c9c
fix(github_copilot): resolve team and deployment-id per-user routes, …
devin-ai-integration[bot] Oct 8, 2026
214c9ff
test(github_copilot): scope response-cache mutation via monkeypatch, …
devin-ai-integration[bot] Oct 8, 2026
bf01ef0
fix(github_copilot): fall back to the database on Redis errors and sk…
devin-ai-integration[bot] Oct 8, 2026
f1d4033
merge: litellm_m365_copilot_obo into litellm_github_copilot_per_user_…
devin-ai-integration[bot] Oct 8, 2026
8a291fc
fix(lint): suppress mutable return annotation after base merge
devin-ai-integration[bot] Oct 8, 2026
9fd2de9
style: shorten rebind-ok reason so the dispatch line stays formatted
devin-ai-integration[bot] Oct 8, 2026
eda1cc2
fix(github_copilot): harden per-user token cache, overrides, fallback…
devin-ai-integration[bot] Oct 8, 2026
ebe1ce9
Merge branch 'litellm_m365_copilot_obo' into litellm_github_copilot_p…
devin-ai-integration[bot] Oct 8, 2026
fdfff3e
fix(lint): suppress MutableMapping param on session auth pinning
devin-ai-integration[bot] Oct 8, 2026
58e99c6
test(github_copilot): narrow pytest.raises to HTTPException for servi…
devin-ai-integration[bot] Oct 8, 2026
2ea22a1
Merge branch 'litellm_m365_copilot_obo' into litellm_github_copilot_p…
devin-ai-integration[bot] Oct 8, 2026
8f732ff
fix(github_copilot): fail closed on revoke errors, strip caller auth …
devin-ai-integration[bot] Oct 8, 2026
458dd7b
fix(github_copilot): verify revoke tombstone, bound fallback discover…
devin-ai-integration[bot] Oct 8, 2026
3182399
fix(github_copilot): make fallback discovery a router superset and gu…
devin-ai-integration[bot] Oct 9, 2026
c12bfab
style(router): drop explanatory comment on per-user credential guard
devin-ai-integration[bot] Oct 9, 2026
ab92ca5
fix(github_copilot): gate fallback discovery on per-user credentials …
devin-ai-integration[bot] Oct 9, 2026
f9af01c
fix(github_copilot): count every fallback mapping toward the discover…
devin-ai-integration[bot] Oct 9, 2026
4e0a12c
fix(github_copilot): suppress banned typing cast imports with per-lin…
devin-ai-integration[bot] Oct 9, 2026
8276efe
Merge remote-tracking branch 'origin/litellm_m365_copilot_obo' into l…
devin-ai-integration[bot] Oct 9, 2026
7e4cdb8
Merge remote-tracking branch 'origin/litellm_m365_copilot_obo' into l…
devin-ai-integration[bot] Oct 9, 2026
c578aeb
test(github_copilot): cover per-user 401 cooldown skip through the ro…
devin-ai-integration[bot] Oct 9, 2026
e0ca00a
Merge remote-tracking branch 'origin/litellm_m365_copilot_obo' into l…
devin-ai-integration[bot] Oct 9, 2026
5ab7f4a
test(github_copilot): cover per-user 401 cooldown skip through router…
devin-ai-integration[bot] Oct 9, 2026
11bcb2d
Merge remote-tracking branch 'origin/litellm_m365_copilot_obo' into l…
devin-ai-integration[bot] Oct 9, 2026
66e5da3
fix(github_copilot): cover auto-router discovery, per-user fallback c…
devin-ai-integration[bot] Oct 9, 2026
46648ad
fix(github_copilot): cover every strategy router kind and verify the …
devin-ai-integration[bot] Oct 9, 2026
f3f411a
Merge remote-tracking branch 'origin/litellm_m365_copilot_obo' into l…
devin-ai-integration[bot] Oct 9, 2026
4cd0cea
fix merge conflict marker remnant in credential endpoint tests
devin-ai-integration[bot] Oct 9, 2026
061256a
Merge remote-tracking branch 'origin/litellm_m365_copilot_obo' into l…
devin-ai-integration[bot] Oct 9, 2026
0408828
fix(github_copilot): do not purge user connections on label-only cred…
devin-ai-integration[bot] Oct 9, 2026
e4e1cf2
fix(github_copilot): read user provider credentials from the writer e…
devin-ai-integration[bot] Oct 9, 2026
55097b0
test(github_copilot): move per-user auth type form test to the integr…
devin-ai-integration[bot] Oct 9, 2026
62f3a20
Merge remote-tracking branch 'origin/litellm_m365_copilot_obo' into l…
devin-ai-integration[bot] Oct 9, 2026
55a9346
test(github_copilot): annotate new credential regression test locals …
devin-ai-integration[bot] Oct 9, 2026
503fa8b
fix(github_copilot): type per-user OAuth JSON and DB boundaries
devin-ai-integration[bot] Oct 9, 2026
a1330a6
fix(github_copilot): validate per-user OAuth boundaries and restore t…
devin-ai-integration[bot] Oct 9, 2026
7ab1853
style(github_copilot): complete cut-off suppression reasons on new casts
devin-ai-integration[bot] Oct 9, 2026
c217402
refactor(github_copilot): annotate new boundary-typing locals as Final
devin-ai-integration[bot] Oct 9, 2026
4db3ad5
Merge remote-tracking branch 'origin/litellm_m365_copilot_obo' into l…
devin-ai-integration[bot] Oct 9, 2026
4049f95
Merge remote-tracking branch 'origin/litellm_m365_copilot_obo' into l…
devin-ai-integration[bot] Oct 9, 2026
7fc259b
chore: merge M365 updates into per-user Copilot
devin-ai-integration[bot] Oct 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE IF NOT EXISTS "LiteLLM_UserProviderCredentials" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"credential_name" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"credential_b64" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "LiteLLM_UserProviderCredentials_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "LiteLLM_UserProviderCredentials_user_id_credential_name_key" ON "LiteLLM_UserProviderCredentials"("user_id", "credential_name");

-- CreateIndex
CREATE INDEX IF NOT EXISTS "LiteLLM_UserProviderCredentials_credential_name_idx" ON "LiteLLM_UserProviderCredentials"("credential_name");
14 changes: 14 additions & 0 deletions litellm-proxy-extras/litellm_proxy_extras/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,20 @@ model LiteLLM_MCPUserCredentials {
@@unique([user_id, server_id])
}

// Per-user provider connections (e.g. GitHub Copilot OAuth) keyed by credential name
model LiteLLM_UserProviderCredentials {
id String @id @default(uuid())
user_id String
credential_name String
provider String
credential_b64 String
created_at DateTime @default(now()) @map("created_at")
updated_at DateTime @default(now()) @updatedAt @map("updated_at")

@@unique([user_id, credential_name])
@@index([credential_name])
}

// Per-user environment variable values for MCP servers.
// values_b64 is an encrypted JSON object: {VAR_NAME: "value", ...}.
model LiteLLM_MCPUserEnvVars {
Expand Down
2 changes: 2 additions & 0 deletions litellm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1396,6 +1396,8 @@ def _build_models_by_provider() -> dict:
from .llms.custom_httpx.async_client_cleanup import close_litellm_async_clients
from .exceptions import (
AuthenticationError,
CallerCredentialAuthenticationError as CallerCredentialAuthenticationError,
CallerCredentialRateLimitError as CallerCredentialRateLimitError,
InvalidRequestError,
BadRequestError,
ImageFetchError,
Expand Down
12 changes: 9 additions & 3 deletions litellm/caching/caching_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,15 @@ def _drop_logging_obj_from_kwargs(request_kwargs: dict[str, object]) -> dict[str


def _is_response_cache_excluded(model: str | None, kwargs: Mapping[str, object]) -> bool:
from litellm.llms.github_copilot.per_user_auth import is_github_copilot_per_user_request

custom_llm_provider: Final = kwargs.get("custom_llm_provider")
model_provider: Final = model.split("/", maxsplit=1)[0] if model is not None else None
return (
isinstance(custom_llm_provider, str) and custom_llm_provider in RESPONSE_CACHE_EXCLUDED_PROVIDERS
) or model_provider in RESPONSE_CACHE_EXCLUDED_PROVIDERS
(isinstance(custom_llm_provider, str) and custom_llm_provider in RESPONSE_CACHE_EXCLUDED_PROVIDERS)
or model_provider in RESPONSE_CACHE_EXCLUDED_PROVIDERS
or is_github_copilot_per_user_request(kwargs)
)


def _is_chat_completion_cached_dict(cached_result: dict) -> bool:
Expand Down Expand Up @@ -317,6 +321,7 @@ async def async_get_cache(
None
"""
if _is_response_cache_excluded(model=model, kwargs=kwargs):
self.request_kwargs = _drop_logging_obj_from_kwargs(kwargs)
return None

# Check if caching should be performed BEFORE doing expensive operations
Expand Down Expand Up @@ -444,6 +449,7 @@ def sync_get_cache(

# Check if caching should be performed BEFORE doing expensive kwargs copy
if _is_response_cache_excluded(model=model, kwargs=kwargs):
self.request_kwargs = _drop_logging_obj_from_kwargs(kwargs)
return CachingHandlerResponse(cached_result=None)
if litellm.cache is not None and self._is_call_type_supported_by_cache(original_function=original_function):
args = args or ()
Expand Down Expand Up @@ -867,11 +873,11 @@ async def _retrieve_from_cache(
new_kwargs.pop("metadata", None)
model_value: Final = new_kwargs.get("model")
model: Final = model_value if isinstance(model_value, str) else None
self.request_kwargs = _drop_logging_obj_from_kwargs(new_kwargs)
if _is_response_cache_excluded(model=model, kwargs=new_kwargs):
return None
if new_kwargs.get("stream") is True and "cache_key" not in new_kwargs:
new_kwargs["cache_key"] = litellm.cache.get_cache_key(**new_kwargs)
self.request_kwargs = _drop_logging_obj_from_kwargs(new_kwargs)
cached_result: object | None = None
if call_type == CallTypes.aembedding.value:
new_kwargs["input"] = self.handle_kwargs_input_list_or_str(new_kwargs)
Expand Down
7 changes: 7 additions & 0 deletions litellm/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@
# Replies depend on the caller's delegated identity, which response-cache keys do not include.
RESPONSE_CACHE_EXCLUDED_PROVIDERS: Final = frozenset({"microsoft_365_copilot"})
MICROSOFT_365_COPILOT_DEFAULT_TIME_ZONE: Final = "UTC"
GITHUB_COPILOT_PER_USER_AUTH_TYPE: Final = "per_user_oauth"
GITHUB_COPILOT_AUTH_TYPE_KEY: Final = "github_copilot_auth_type"
GITHUB_COPILOT_USER_TOKEN_SAFETY_MARGIN_SECONDS: Final = 60
GITHUB_COPILOT_USER_CREDENTIAL_CACHE_TTL_SECONDS: Final = 60
GITHUB_COPILOT_DEVICE_FLOW_CACHE_PREFIX: Final = "github_copilot_device_flow"
USER_PROVIDER_CREDENTIAL_CACHE_PREFIX: Final = "user_provider_credential"
USER_PROVIDER_CREDENTIAL_NOT_CONNECTED: Final = "__not_connected__"
SERVER_STREAMING_CLASSIFICATION_KEY: Final = "litellm_server_streaming_classification"


Expand Down
12 changes: 12 additions & 0 deletions litellm/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,12 @@ def __repr__(self):
return _message


class CallerCredentialAuthenticationError(AuthenticationError):
"""401 raised when the CALLING user's own stored provider credential is
missing or rejected (e.g. a per-user OAuth connection). Scoped to one
caller's credential, so routers must not cool down the shared deployment."""


# raise when invalid models passed, example gpt-8
class NotFoundError(openai.NotFoundError):
def __init__(
Expand Down Expand Up @@ -531,6 +537,12 @@ def __repr__(self):
return _message


class CallerCredentialRateLimitError(RateLimitError):
"""429 raised when a per-user credential exchange is rate limited by the
provider (e.g. GitHub's token endpoint). Scoped to one caller's
credential, so routers must not cool down the shared deployment."""


# sub class of rate limit error - meant to give more granularity for error handling context window exceeded errors
class ContextWindowExceededError(BadRequestError):
def __init__(
Expand Down
1 change: 1 addition & 0 deletions litellm/integrations/otel/model/spans.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ class PostgresOperation:
"LiteLLM_BackgroundInteractionSettlement",
"LiteLLM_ErrorLogs",
"LiteLLM_UserNotifications",
"LiteLLM_UserProviderCredentials",
"LiteLLM_TeamMembership",
"LiteLLM_OrganizationMembership",
"LiteLLM_InvitationLink",
Expand Down
4 changes: 3 additions & 1 deletion litellm/litellm_core_utils/credential_accessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ def find_credential(credential_name: str) -> CredentialItem | None:
)

@staticmethod
def get_credential_values(credential_name: str) -> dict:
def get_credential_values(
credential_name: str,
) -> dict[str, object]: # mutable-ok: callers merge the returned credential map
"""Safe accessor for credentials."""

credential: Final = CredentialAccessor.find_credential(credential_name)
Expand Down
2 changes: 2 additions & 0 deletions litellm/litellm_core_utils/get_litellm_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@
"itpm",
"otpm",
"use_xai_oauth",
"github_copilot_auth_type",
"github_copilot_user_session",
"fireworks_forward_user_id",
PROVIDER_AFFINITY_HEADER_KWARG_KEY,
}
Expand Down
35 changes: 29 additions & 6 deletions litellm/litellm_core_utils/get_llm_provider_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -795,13 +795,36 @@ def _get_openai_compatible_provider_info(
api_base = api_base or get_secret("GALADRIEL_API_BASE") or "https://api.galadriel.com/v1"
dynamic_api_key = api_key or get_secret_str("GALADRIEL_API_KEY")
elif custom_llm_provider == "github_copilot":
(
api_base,
dynamic_api_key,
custom_llm_provider,
) = litellm.GithubCopilotConfig().get_openai_compatible_provider_info(
model, api_base, api_key, custom_llm_provider
from litellm.llms.github_copilot.common_utils import DEFAULT_GITHUB_COPILOT_API_BASE
from litellm.llms.github_copilot.per_user_auth import (
github_copilot_per_user_credential_name,
github_copilot_user_session_from,
)

user_session: Final = github_copilot_user_session_from(
cast("dict[str, object]", litellm_params) # cast-ok: litellm_params arrives as an untyped request dict
)
if user_session is not None:
api_base = user_session.api_base # rebind-ok: resolves provider args in place
dynamic_api_key = user_session.token # rebind-ok: resolves provider args in place
elif (
github_copilot_per_user_credential_name(
cast("dict[str, object]", litellm_params) # cast-ok: untyped params dict
)
is not None
):
# per-user deployments resolve no token at provider-info time; the
# caller's session supplies base + key per request
api_base = api_base or DEFAULT_GITHUB_COPILOT_API_BASE # rebind-ok: resolves provider args in place
dynamic_api_key = None # rebind-ok: resolves provider args in place
else:
(
api_base, # rebind-ok: resolves provider args in place
dynamic_api_key, # rebind-ok: resolves provider args in place
custom_llm_provider, # rebind-ok: resolves provider args in place
) = litellm.GithubCopilotConfig().get_openai_compatible_provider_info(
model, api_base, api_key, custom_llm_provider
)
elif custom_llm_provider == "chatgpt":
(
api_base,
Expand Down
29 changes: 22 additions & 7 deletions litellm/llms/anthropic/pass_through/messages/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import asyncio
import contextvars
from collections.abc import AsyncIterator, Coroutine, Iterator
from collections.abc import AsyncIterator, Coroutine, Iterator, Sequence
from functools import partial
from typing import Any, Final, cast

Expand All @@ -23,6 +23,7 @@
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
from litellm.types.llms.anthropic import AllAnthropicToolsValues
from litellm.types.llms.anthropic_messages.anthropic_request import AnthropicMetadata
from litellm.types.llms.anthropic_messages.anthropic_response import (
AnthropicMessagesResponse,
Expand Down Expand Up @@ -467,6 +468,7 @@ def anthropic_messages_handler(
custom_llm_provider=custom_llm_provider,
api_base=litellm_params.api_base,
api_key=litellm_params.api_key,
litellm_params=litellm_params,
)

# Store agentic loop params in logging object for agentic hooks
Expand Down Expand Up @@ -513,7 +515,9 @@ def anthropic_messages_handler(
metadata=metadata,
stop_sequences=stop_sequences,
stream=stream,
system=system,
system=cast( # cast-ok: anthropic accepts list system blocks; handler signature is narrower
"str | None", system
),
temperature=temperature,
thinking=thinking,
tool_choice=tool_choice,
Expand Down Expand Up @@ -557,11 +561,15 @@ def anthropic_messages_handler(
metadata=metadata,
stop_sequences=stop_sequences,
stream=stream,
system=system,
system=cast( # cast-ok: anthropic accepts list system blocks; handler signature is narrower
"str | None", system
),
temperature=temperature,
thinking=thinking,
tool_choice=tool_choice,
tools=tools,
tools=cast( # cast-ok: dict tools already fit the handler's union
"list[AllAnthropicToolsValues | dict[str, object]] | None", tools
),
top_k=top_k,
top_p=top_p,
_is_async=is_async,
Expand All @@ -583,11 +591,13 @@ def anthropic_messages_handler(
metadata=metadata,
stop_sequences=stop_sequences,
stream=stream,
system=system,
system=cast( # cast-ok: anthropic accepts list system blocks; handler signature is narrower
"str | None", system
),
temperature=temperature,
thinking=thinking,
tool_choice=tool_choice,
tools=tools,
tools=cast("list[dict[str, object]] | None", tools), # cast-ok: dict tools fit the adapter's declared union
top_k=top_k,
top_p=top_p,
_is_async=is_async,
Expand Down Expand Up @@ -619,7 +629,12 @@ def anthropic_messages_handler(
)
return base_llm_http_handler.anthropic_messages_handler(
model=model,
messages=strip_provider_specific_fields_from_anthropic_messages(messages),
messages=cast( # cast-ok: anthropic messages are dicts at runtime
"list[dict[str, object]]",
strip_provider_specific_fields_from_anthropic_messages(
cast(Sequence[object], messages) # cast-ok: anthropic payloads arrive as untyped dicts
),
),
anthropic_messages_provider_config=anthropic_messages_provider_config,
anthropic_messages_optional_request_params=dict(anthropic_messages_optional_request_params),
_is_async=is_async,
Expand Down
32 changes: 17 additions & 15 deletions litellm/llms/github_copilot/authenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,22 @@ class _AccessTokenPoll(TypedDict):
_ACCESS_TOKEN_POLL: Final = TypeAdapter(_AccessTokenPoll)


def github_api_headers(
access_token: str | None = None,
) -> dict[str, str]: # mutable-ok: returned straight into httpx handlers whose headers params require dict
headers: Final = {
"accept": "application/json",
"editor-version": "vscode/1.85.1",
"editor-plugin-version": "copilot/1.155.0",
"user-agent": "GithubCopilot/1.155.0",
"accept-encoding": "gzip,deflate,br",
"content-type": "application/json",
}
if access_token:
headers["authorization"] = f"token {access_token}"
return headers


class Authenticator:
def __init__(self) -> None:
"""Initialize the GitHub Copilot authenticator with configurable token paths."""
Expand Down Expand Up @@ -230,21 +246,7 @@ def _get_github_headers(self, access_token: str | None = None) -> dict[str, str]
Returns:
Dict[str, str]: Headers for GitHub API requests.
"""
headers: Final = {
"accept": "application/json",
"editor-version": "vscode/1.85.1",
"editor-plugin-version": "copilot/1.155.0",
"user-agent": "GithubCopilot/1.155.0",
"accept-encoding": "gzip,deflate,br",
}

if access_token:
headers["authorization"] = f"token {access_token}"

if "content-type" not in headers:
headers["content-type"] = "application/json"

return headers
return github_api_headers(access_token)

def _get_device_code(self) -> _DeviceCode:
"""
Expand Down
Loading
Loading