Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
6792d99
fix(proxy): enforce key-level model allowlist for custom auth
milan-berri Mar 20, 2026
b907b59
Merge branch 'main' into fix/custom-auth-key-model-allowlist
milan-berri Mar 20, 2026
5c8ab20
fix(proxy): gate custom-auth key model checks behind opt-in
milan-berri Apr 1, 2026
770d050
test(proxy): isolate custom auth default check from shared settings s…
milan-berri Apr 1, 2026
b722d69
test(proxy): strengthen custom auth post-check assertions
milan-berri Apr 2, 2026
ca19d7b
fix(agentcore): parse A2A JSON-RPC responses in AgentCore provider
michelligabriele Apr 2, 2026
cdab991
fix(prompt-templates): ensure_alternating_roles handles tool-call chains
michelligabriele Apr 2, 2026
a2ad2c3
feat(auth): add JWT claim routing overrides for OAuth2 validation
milan-berri Apr 2, 2026
ae6cc70
docs(auth): document JWT-to-OAuth2 routing overrides
milan-berri Apr 2, 2026
46c2348
Merge pull request #24989 from milan-berri/feat/jwt-routing-overrides
yuneng-berri Apr 2, 2026
6058de4
Merge branch 'litellm_internal_dev_04_02_2026' into fix/custom-auth-k…
yuneng-berri Apr 2, 2026
5b231a0
Merge pull request #24175 from milan-berri/fix/custom-auth-key-model-…
yuneng-berri Apr 2, 2026
f109dff
Merge pull request #24995 from michelligabriele/fix/agentcore-a2a-res…
yuneng-berri Apr 2, 2026
8852d8e
Merge pull request #24996 from michelligabriele/fix/ensure-alternatin…
yuneng-berri Apr 2, 2026
1d26232
Merge remote-tracking branch 'origin' into litellm_internal_dev_04_02…
yuneng-berri Apr 2, 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
21 changes: 21 additions & 0 deletions docs/my-website/docs/proxy/oauth2.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,24 @@ curl --location 'http://0.0.0.0:4000/chat/completions' \

Start the LiteLLM Proxy with [`--detailed_debug` mode and you should see more verbose logs](cli.md#detailed_debug)

## Using OAuth2 + JWT Together

If both `enable_oauth2_auth` and `enable_jwt_auth` are enabled, LiteLLM can split auth paths:
- JWT validation for user tokens
- OAuth2 introspection for machine tokens

For JWT-shaped machine tokens, configure `litellm_jwtauth.routing_overrides`:

```yaml title="config.yaml"
general_settings:
enable_jwt_auth: true
enable_oauth2_auth: true
litellm_jwtauth:
routing_overrides:
- iss: "machine-issuer.example.com"
client_id: "MID_LITELLM"
path: "oauth2"
```

For full `routing_overrides` behavior and list-based selectors, see [`/proxy/token_auth`](./token_auth.md#route-jwt-shaped-machine-tokens-to-oauth2).

41 changes: 41 additions & 0 deletions docs/my-website/docs/proxy/token_auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,47 @@ litellm_jwtauth:
user_roles_jwt_field: "resource_access.your-client.roles"
```

## Route JWT-Shaped Machine Tokens to OAuth2

Use this when both are enabled:
- `enable_jwt_auth: true` for standard JWT validation
- `enable_oauth2_auth: true` for OAuth2 introspection

If some machine tokens are also JWT-shaped, configure `routing_overrides` to route matching tokens to OAuth2.

```yaml title="config.yaml"
general_settings:
enable_jwt_auth: true
enable_oauth2_auth: true
litellm_jwtauth:
user_id_jwt_field: "sub"
routing_overrides:
- iss: "machine-issuer.example.com"
client_id: "MID_LITELLM"
path: "oauth2"
```

### Matching behavior

- A rule matches when all configured selectors match token claims
- Supported selectors: `iss` (required), `client_id` (optional), `aud` (optional)
- Selector values support both string and list forms
- If no rule matches, LiteLLM continues with standard JWT validation

### List-based override example

```yaml title="config.yaml"
general_settings:
enable_jwt_auth: true
enable_oauth2_auth: true
litellm_jwtauth:
routing_overrides:
- iss: ["machine-issuer.example.com", "backup-issuer.example.com"]
client_id: ["MID_LITELLM", "MID_BACKUP"]
aud: ["api://litellm", "api://fallback"]
path: "oauth2"
```

## [BETA] Control Access with OIDC Roles

Allow JWT tokens with supported roles to access the proxy.
Expand Down
29 changes: 20 additions & 9 deletions litellm/litellm_core_utils/prompt_templates/common_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,24 +337,35 @@ def _insert_assistant_continue_message(
"""
Add assistant continuation messages between consecutive user messages.

Only checks directly adjacent messages to preserve backward compatibility.
Skips tool messages and assistant messages with tool calls in the
alternation check, matching strict templates like llama.cpp.
"""
if not ensure_alternating_roles or len(messages) <= 1:
return messages

continue_message = assistant_continue_message or DEFAULT_ASSISTANT_CONTINUE_MESSAGE

# Find indexes where assistant_continue should be inserted (before that index)
insert_before_indexes: set = set()

for i in range(len(messages)):
curr = messages[i]
if _counts_for_alternation(curr) and curr["role"] == "user":
# Look backwards for the previous counted message
j = i - 1
while j >= 0:
if _counts_for_alternation(messages[j]):
if messages[j]["role"] == "user":
insert_before_indexes.add(i)
break
j -= 1

# Build the result with assistant_continue inserted at the right positions
modified_messages: List[AllMessageValues] = []
for i, message in enumerate(messages):
if (
i < len(messages) - 1
and message.get("role") == "user"
and messages[i + 1].get("role") == "user"
):
modified_messages.append(message)
if i in insert_before_indexes:
modified_messages.append(continue_message)
else:
modified_messages.append(message)
modified_messages.append(message)

return modified_messages
Comment on lines 337 to 370

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.

P1 Backward-incompatible behavior change without a feature flag

The previous implementation only inserted assistant_continue between two directly adjacent user messages. The new implementation uses _counts_for_alternation to skip tool messages and assistant(tool_calls) messages, meaning a pattern like:

[user, assistant(tool_calls), tool, user]

now gets assistant_continue inserted before the second user message — behavior that did not exist before. Three existing tests (test_ensure_alternating_roles_does_not_split_tool_call_chain, test_ensure_alternating_roles_assistant_tool_call_then_assistant, test_ensure_alternating_roles_trailing_tool_call_assistant) were updated to match the new behavior, confirming this is intentional.

However, per the project rule on backwards-incompatible changes, existing users who have ensure_alternating_roles=True and agentic/tool-calling message patterns will silently receive extra injected assistant_continue messages, potentially breaking their existing model calls without any migration path.

The safer approach would be to introduce this under a flag (e.g. ensure_alternating_roles="strict" or a separate skip_tool_messages_in_alternation param) so existing users are not affected.

Rule Used: What: avoid backwards-incompatible changes without... (source)


Expand Down
14 changes: 14 additions & 0 deletions litellm/llms/bedrock/chat/agentcore/transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
from litellm.llms.a2a.common_utils import extract_text_from_a2a_response
from litellm.llms.bedrock.common_utils import BedrockError
from litellm.types.llms.bedrock_agentcore import (
AgentCoreMessage,
Expand Down Expand Up @@ -343,6 +344,7 @@ def _parse_json_response(self, response_json: dict) -> AgentCoreParsedResponse:
Parse direct JSON response (non-streaming).

Supports multiple agent response schemas:
0. {"jsonrpc": "2.0", "result": {"message": {"parts": [...]}}} - A2A JSON-RPC
1. {"result": {"role": "assistant", "content": [{"text": "..."}]}} - standard AgentCore
2. {"response": [{"text": "..."}]} - Strands agent format
3. {"result": "plain text"} or {"response": "plain text"} - simple string
Expand All @@ -361,6 +363,18 @@ def _parse_json_response(self, response_json: dict) -> AgentCoreParsedResponse:
final_message=None,
)

# Strategy 0: A2A JSON-RPC format
# {"jsonrpc": "2.0", "result": {"message": {"parts": [{"kind": "text", "text": "..."}]}}}
if "jsonrpc" in response_json:
content = extract_text_from_a2a_response(response_json)
if content:
return AgentCoreParsedResponse(
content=content,
usage=None,
final_message=None,
)
# Fall through to other strategies if A2A extraction returned empty

# Strategy 1: {"result": {"content": [{"text": "..."}]}} - standard AgentCore format
if "result" in response_json and isinstance(response_json["result"], dict):
result = response_json["result"]
Expand Down
22 changes: 22 additions & 0 deletions litellm/proxy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -4098,6 +4098,24 @@ class ScopeMapping(OIDCPermissions):
}


class JWTRoutingOverride(BaseModel):
"""
Override default auth routing for JWT-shaped bearer tokens.

A rule matches when all provided selectors match token claims.
If matched, request is routed to the configured auth path.
"""

iss: Union[str, List[str]]
client_id: Optional[Union[str, List[str]]] = None
aud: Optional[Union[str, List[str]]] = None
path: Literal["oauth2"] = "oauth2"

model_config = {
"extra": "forbid",
}


class LiteLLM_JWTAuth(LiteLLMPydanticObjectBase):
"""
A class to define the roles and permissions for a LiteLLM Proxy w/ JWT Auth.
Expand Down Expand Up @@ -4198,6 +4216,10 @@ class LiteLLM_JWTAuth(LiteLLMPydanticObjectBase):
default=300,
description="TTL (seconds) for caching JWT-to-virtual-key mapping lookups.",
)
routing_overrides: Optional[List[JWTRoutingOverride]] = Field(
default=None,
description="Optional claim-based routing overrides for JWT-shaped tokens. Matching rules route requests to oauth2 before default JWT flow.",
)
#########################################################

def __init__(self, **kwargs: Any) -> None:
Expand Down
64 changes: 43 additions & 21 deletions litellm/proxy/auth/handle_jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from fastapi import HTTPException
import jwt
from jwt.api_jwk import PyJWK

from litellm._logging import verbose_proxy_logger
from litellm.caching.caching import DualCache
Expand Down Expand Up @@ -71,6 +73,21 @@ class JWTHandler:

prisma_client: Optional[PrismaClient]
user_api_key_cache: DualCache
# Supported algos: https://pyjwt.readthedocs.io/en/stable/algorithms.html
# "Warning: Make sure not to mix symmetric and asymmetric algorithms that interpret
# the key in different ways (e.g. HS* and RS*)."
SUPPORTED_JWT_ALGORITHMS = [
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES512",
"EdDSA",
]

def __init__(
self,
Expand All @@ -97,6 +114,30 @@ def is_jwt(token: Optional[str]) -> bool:
parts = token.split(".")
return len(parts) == 3

@staticmethod
def get_unverified_claims(token: str) -> Optional[dict]:
"""
Decode JWT claims without signature verification.
Used for routing decisions before selecting validation path.
"""
if not JWTHandler.is_jwt(token):
return None

try:
claims = jwt.decode(
token,
options={"verify_signature": False, "verify_aud": False},
algorithms=JWTHandler.SUPPORTED_JWT_ALGORITHMS,
)
if isinstance(claims, dict):
return claims
return None
except Exception as e:
verbose_proxy_logger.debug(
"Failed to decode unverified JWT claims for routing: %s", e
)
return None

def _rbac_role_from_role_mapping(self, token: dict) -> Optional[RBAC_ROLES]:
"""
Returns the RBAC role the token 'belongs' to based on role mappings.
Expand Down Expand Up @@ -664,30 +705,11 @@ async def get_oidc_userinfo(self, token: str) -> dict:
raise Exception(f"Failed to fetch OIDC UserInfo: {str(e)}")

async def auth_jwt(self, token: str) -> dict:
# Supported algos: https://pyjwt.readthedocs.io/en/stable/algorithms.html
# "Warning: Make sure not to mix symmetric and asymmetric algorithms that interpret
# the key in different ways (e.g. HS* and RS*)."
algorithms = [
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES512",
"EdDSA",
]

audience = os.getenv("JWT_AUDIENCE")
decode_options = None
if audience is None:
decode_options = {"verify_aud": False}

import jwt
from jwt.api_jwk import PyJWK

header = jwt.get_unverified_header(token)

verbose_proxy_logger.debug("header: %s", header)
Expand Down Expand Up @@ -721,7 +743,7 @@ async def auth_jwt(self, token: str) -> dict:
payload = jwt.decode(
token,
public_key_obj, # type: ignore
algorithms=algorithms,
algorithms=self.SUPPORTED_JWT_ALGORITHMS,
options=decode_options, # type: ignore[arg-type]
audience=audience,
leeway=self.leeway, # allow testing of expired tokens
Expand Down Expand Up @@ -749,7 +771,7 @@ async def auth_jwt(self, token: str) -> dict:
payload = jwt.decode(
token,
key,
algorithms=algorithms,
algorithms=self.SUPPORTED_JWT_ALGORITHMS,
audience=audience,
options=decode_options,
)
Expand Down
Loading
Loading