-
-
Notifications
You must be signed in to change notification settings - Fork 6.8k
feat: team override model #24330
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: team override model #24330
Changes from 6 commits
82fecaa
237d835
0199f21
55c3d5c
b62fdbd
9a11474
d8a47eb
b479aa0
a2ecbd9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -113,6 +113,151 @@ curl --location 'http://0.0.0.0:4000/chat/completions' \ | |
|
|
||
| ### [API Reference](https://litellm-api.up.railway.app/#/team%20management/new_team_team_new_post) | ||
|
|
||
| ## **Per-Member Model Overrides (Team-Scoped Defaults)** | ||
|
|
||
| :::info | ||
|
|
||
| Requires `TEAM_MODEL_OVERRIDES=true` environment variable or `litellm.team_model_overrides_enabled = True`. | ||
|
|
||
| ::: | ||
|
|
||
| By default, every team member can access all models in `team.models`. With per-member model overrides, you can: | ||
|
|
||
| - Set **`default_models`** on a team — the models every member gets by default | ||
| - Set **`models`** on individual team members — additional models only they can access | ||
|
|
||
| A member's **effective models** = `default_models` ∪ `member.models`. If neither is set, falls back to `team.models` (full backward compatibility). | ||
|
|
||
| ### Enable the Feature | ||
|
|
||
| Add to your `config.yaml`: | ||
|
|
||
| ```yaml | ||
| environment_variables: | ||
| TEAM_MODEL_OVERRIDES: "true" | ||
| ``` | ||
|
|
||
| ### 1. Create a Team with Default Models | ||
|
|
||
| ```shell | ||
| curl -L 'http://localhost:4000/team/new' \ | ||
| -H 'Authorization: Bearer <your-master-key>' \ | ||
| -H 'Content-Type: application/json' \ | ||
| -d '{ | ||
| "team_alias": "engineering", | ||
| "models": ["gpt-4", "gpt-4o-mini", "gpt-4o"], | ||
| "default_models": ["gpt-4o-mini"] | ||
| }' | ||
| ``` | ||
|
|
||
|
Comment on lines
+144
to
+152
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The docs state:
Two behaviours are not documented:
|
||
| - `models` — the full pool of models the team is allowed to use | ||
| - `default_models` — the subset every member gets by default (must be a subset of `models`) | ||
|
|
||
| ### 2. Add Members with Per-User Overrides | ||
|
|
||
| ```shell | ||
| # Alice gets the default (gpt-4o-mini only) | ||
| curl -L 'http://localhost:4000/team/member_add' \ | ||
| -H 'Authorization: Bearer <your-master-key>' \ | ||
| -H 'Content-Type: application/json' \ | ||
| -d '{ | ||
| "team_id": "<team-id>", | ||
| "member": {"role": "user", "user_id": "alice"} | ||
| }' | ||
|
|
||
| # Bob gets gpt-4o in addition to the default | ||
| curl -L 'http://localhost:4000/team/member_add' \ | ||
| -H 'Authorization: Bearer <your-master-key>' \ | ||
| -H 'Content-Type: application/json' \ | ||
| -d '{ | ||
| "team_id": "<team-id>", | ||
| "member": {"role": "user", "user_id": "bob", "models": ["gpt-4o"]} | ||
| }' | ||
| ``` | ||
|
|
||
| | Member | Override | Effective Models | | ||
| |--------|----------|-----------------| | ||
| | Alice | none | `["gpt-4o-mini"]` | | ||
| | Bob | `["gpt-4o"]` | `["gpt-4o-mini", "gpt-4o"]` | | ||
|
|
||
| ### 3. Generate Keys and Test | ||
|
|
||
| ```shell | ||
| # Generate key for Bob | ||
| curl -L 'http://localhost:4000/key/generate' \ | ||
| -H 'Authorization: Bearer <your-master-key>' \ | ||
| -H 'Content-Type: application/json' \ | ||
| -d '{"team_id": "<team-id>", "user_id": "bob"}' | ||
| ``` | ||
|
|
||
| <Tabs> | ||
| <TabItem label="Allowed (Bob → gpt-4o)" value="allowed"> | ||
|
|
||
| ```shell | ||
| curl -L 'http://localhost:4000/chat/completions' \ | ||
| -H 'Authorization: Bearer <bob-key>' \ | ||
| -H 'Content-Type: application/json' \ | ||
| -d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hello"}]}' | ||
| ``` | ||
|
|
||
| Returns `200 OK` — `gpt-4o` is in Bob's effective set. | ||
|
|
||
| </TabItem> | ||
| <TabItem label="Denied (Bob → gpt-4)" value="denied"> | ||
|
|
||
| ```shell | ||
| curl -L 'http://localhost:4000/chat/completions' \ | ||
| -H 'Authorization: Bearer <bob-key>' \ | ||
| -H 'Content-Type: application/json' \ | ||
| -d '{"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}]}' | ||
| ``` | ||
|
|
||
| Returns `401 Unauthorized` — `gpt-4` is in the team pool but not in Bob's effective set. | ||
|
|
||
| </TabItem> | ||
| </Tabs> | ||
|
|
||
| ### 4. Update Member Overrides | ||
|
|
||
| ```shell | ||
| # Add gpt-4 to Bob's overrides | ||
| curl -L 'http://localhost:4000/team/member_update' \ | ||
| -H 'Authorization: Bearer <your-master-key>' \ | ||
| -H 'Content-Type: application/json' \ | ||
| -d '{ | ||
| "team_id": "<team-id>", | ||
| "user_id": "bob", | ||
| "models": ["gpt-4o", "gpt-4"] | ||
| }' | ||
|
|
||
| # Remove all overrides (Bob falls back to default_models only) | ||
| curl -L 'http://localhost:4000/team/member_update' \ | ||
| -H 'Authorization: Bearer <your-master-key>' \ | ||
| -H 'Content-Type: application/json' \ | ||
| -d '{ | ||
| "team_id": "<team-id>", | ||
| "user_id": "bob", | ||
| "models": [] | ||
| }' | ||
| ``` | ||
|
|
||
| ### Validation Rules | ||
|
|
||
| | Rule | Error | | ||
| |------|-------| | ||
| | `default_models` must be a subset of `team.models` | `400` on `/team/new` and `/team/update` | | ||
| | Member `models` must be a subset of `team.models` | `400` on `/team/member_add` and `/team/member_update` | | ||
| | Key `models` must be a subset of effective models | `403` on `/key/generate` | | ||
| | Narrowing `team.models` auto-prunes stale `default_models` | Automatic on `/team/update` | | ||
|
|
||
| ### Backward Compatibility | ||
|
|
||
| When the feature flag is off **or** when neither `default_models` nor member `models` is configured: | ||
|
|
||
| - `get_effective_team_models()` returns `team.models` unchanged | ||
| - All existing teams and keys work exactly as before | ||
| - Zero extra database queries on the auth hot path | ||
|
|
||
|
|
||
| ## **View Available Fallback Models** | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| -- AlterTable: Add default_models to LiteLLM_TeamTable | ||
| ALTER TABLE "LiteLLM_TeamTable" ADD COLUMN IF NOT EXISTS "default_models" TEXT[] DEFAULT ARRAY[]::TEXT[]; | ||
|
|
||
| -- AlterTable: Add models to LiteLLM_TeamMembership | ||
| ALTER TABLE "LiteLLM_TeamMembership" ADD COLUMN IF NOT EXISTS "models" TEXT[] DEFAULT ARRAY[]::TEXT[]; |
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -9,6 +9,7 @@ | |||||||||||
| 3. If end_user ('user' passed to /chat/completions, /embeddings endpoint) is in budget | ||||||||||||
| """ | ||||||||||||
| import asyncio | ||||||||||||
| import os | ||||||||||||
| import re | ||||||||||||
| import time | ||||||||||||
| from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union, cast | ||||||||||||
|
|
@@ -409,20 +410,16 @@ async def common_checks( # noqa: PLR0915 | |||||||||||
| # 2. If team can call model | ||||||||||||
| if _model and team_object: | ||||||||||||
| with tracer.trace("litellm.proxy.auth.common_checks.can_team_access_model"): | ||||||||||||
| if not await can_team_access_model( | ||||||||||||
| # can_team_access_model returns Literal[True] or raises ProxyException | ||||||||||||
| await can_team_access_model( | ||||||||||||
| model=_model, | ||||||||||||
| team_object=team_object, | ||||||||||||
| llm_router=llm_router, | ||||||||||||
| team_model_aliases=valid_token.team_model_aliases | ||||||||||||
| if valid_token | ||||||||||||
| else None, | ||||||||||||
| ): | ||||||||||||
| raise ProxyException( | ||||||||||||
| message=f"Team not allowed to access model. Team={team_object.team_id}, Model={_model}. Allowed team models = {team_object.models}", | ||||||||||||
| type=ProxyErrorTypes.team_model_access_denied, | ||||||||||||
| param="model", | ||||||||||||
| code=status.HTTP_401_UNAUTHORIZED, | ||||||||||||
| ) | ||||||||||||
| valid_token=valid_token, | ||||||||||||
| ) | ||||||||||||
|
|
||||||||||||
| # Require trace id for agent keys when agent has require_trace_id_on_calls_by_agent | ||||||||||||
| if valid_token is not None and valid_token.agent_id: | ||||||||||||
|
|
@@ -2690,29 +2687,105 @@ def can_org_access_model( | |||||||||||
| ) | ||||||||||||
|
|
||||||||||||
|
|
||||||||||||
| def compute_effective_models( | ||||||||||||
| team_defaults: List[str], | ||||||||||||
| member_models: List[str], | ||||||||||||
| team_pool: List[str], | ||||||||||||
| ) -> List[str]: | ||||||||||||
| """ | ||||||||||||
| Core computation shared by the auth hot-path and key-generation. | ||||||||||||
|
|
||||||||||||
| effective = union(team_defaults, member_models), capped by team_pool. | ||||||||||||
| - If neither defaults nor overrides are set, falls back to team_pool (backward compat). | ||||||||||||
| - If cap empties the list (all overrides/defaults are stale), falls back to team_pool | ||||||||||||
| rather than returning [] (which would mean "allow all"). This is a deliberate security | ||||||||||||
| trade-off: a member with entirely stale overrides gets team-pool access (the team's | ||||||||||||
| restriction is still enforced) instead of unrestricted access. Admins should clean up | ||||||||||||
| stale overrides via /team/member_update when narrowing team.models. | ||||||||||||
| - team_pool=[] means "allow all" — cap is skipped. | ||||||||||||
| """ | ||||||||||||
| # dict.fromkeys preserves insertion order while deduplicating | ||||||||||||
| effective = list(dict.fromkeys(team_defaults + member_models)) | ||||||||||||
|
|
||||||||||||
| if not effective: | ||||||||||||
| return team_pool | ||||||||||||
|
|
||||||||||||
| if team_pool: | ||||||||||||
| effective = [m for m in effective if m in set(team_pool)] | ||||||||||||
| if not effective: | ||||||||||||
| return team_pool | ||||||||||||
|
|
||||||||||||
|
Comment on lines
+2710
to
+2717
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a member has per-user model overrides set (e.g. Concretely:
The comment justifies this as "NOT [] which = allow-all", but If this fallback is intentional, add a clear doc comment explaining the privilege trade-off and consider whether |
||||||||||||
| return effective | ||||||||||||
|
|
||||||||||||
|
|
||||||||||||
| def get_effective_team_models( | ||||||||||||
| team_object: Optional[LiteLLM_TeamTable], | ||||||||||||
| valid_token: Optional[UserAPIKeyAuth] = None, | ||||||||||||
| ) -> List[str]: | ||||||||||||
| """ | ||||||||||||
| Returns the effective list of models for a team member. | ||||||||||||
| The union of: | ||||||||||||
| - team_object.default_models (OR valid_token.team_default_models if available) | ||||||||||||
| - team_membership.models (OR valid_token.team_member_models if available) | ||||||||||||
|
|
||||||||||||
| Capped by team_object.models. Falls back to team_object.models when empty. | ||||||||||||
| """ | ||||||||||||
| if not ( | ||||||||||||
| litellm.team_model_overrides_enabled | ||||||||||||
| or os.getenv("TEAM_MODEL_OVERRIDES", "").lower() == "true" | ||||||||||||
| ): | ||||||||||||
|
Comment on lines
+2733
to
+2736
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This is the hot auth path — even a cheap
Suggested change
If dynamic env-var toggling at runtime is intentionally supported, document it explicitly so the cost is clearly understood. |
||||||||||||
| return team_object.models if team_object else [] | ||||||||||||
|
|
||||||||||||
| # Get from team defaults — prefer team_object (authoritative, fresh from DB/cache) | ||||||||||||
| # over valid_token (snapshot from key creation time, may be stale). | ||||||||||||
| # Use `is not None` instead of truthiness so that an explicit empty list [] | ||||||||||||
| # (meaning "no defaults") is not confused with "field missing". | ||||||||||||
| team_defaults: List[str] = [] | ||||||||||||
| if team_object and team_object.default_models is not None: | ||||||||||||
| team_defaults = team_object.default_models | ||||||||||||
| elif valid_token and valid_token.team_default_models is not None: | ||||||||||||
| team_defaults = valid_token.team_default_models | ||||||||||||
|
|
||||||||||||
| # Get from member specific overrides | ||||||||||||
| member_models: List[str] = [] | ||||||||||||
| if valid_token and valid_token.team_member_models is not None: | ||||||||||||
| member_models = valid_token.team_member_models | ||||||||||||
|
|
||||||||||||
| team_pool = team_object.models if team_object else [] | ||||||||||||
|
|
||||||||||||
| return compute_effective_models(team_defaults, member_models, team_pool) | ||||||||||||
|
Comment on lines
+2721
to
+2762
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This means service keys are silently restricted to Contrast with the comment at the key-generation call-site:
The same logic needs to be applied in # In get_effective_team_models, after the feature-flag check:
# Service keys (no user_id) have no membership row, so skip per-member logic
# and use team_pool directly for backward compatibility.
if valid_token is not None and valid_token.user_id is None:
return team_object.models if team_object else []This is a backward-incompatible regression (per repo rules) that activates the moment an admin enables the feature flag and sets Rule Used: What: avoid backwards-incompatible changes without... (source) |
||||||||||||
|
|
||||||||||||
|
|
||||||||||||
| async def can_team_access_model( | ||||||||||||
| model: Union[str, List[str]], | ||||||||||||
| team_object: Optional[LiteLLM_TeamTable], | ||||||||||||
| llm_router: Optional[Router], | ||||||||||||
| team_model_aliases: Optional[Dict[str, str]] = None, | ||||||||||||
| valid_token: Optional[UserAPIKeyAuth] = None, | ||||||||||||
| ) -> Literal[True]: | ||||||||||||
| """ | ||||||||||||
| Returns True if the team can access a specific model. | ||||||||||||
|
|
||||||||||||
| 1. First checks native team-level model permissions (current implementation) | ||||||||||||
| 2. If not allowed natively, falls back to access_group_ids on the team | ||||||||||||
| """ | ||||||||||||
| effective_models = get_effective_team_models(team_object, valid_token) | ||||||||||||
| try: | ||||||||||||
| return _can_object_call_model( | ||||||||||||
| model=model, | ||||||||||||
| llm_router=llm_router, | ||||||||||||
| models=team_object.models if team_object else [], | ||||||||||||
| models=effective_models, | ||||||||||||
| team_model_aliases=team_model_aliases, | ||||||||||||
| team_id=team_object.team_id if team_object else None, | ||||||||||||
| object_type="team", | ||||||||||||
| ) | ||||||||||||
| except ProxyException: | ||||||||||||
| # Fallback: check team's access_group_ids | ||||||||||||
| # Fallback: check team's access_group_ids. | ||||||||||||
| # Note: access groups are a team-level concept and are NOT restricted by | ||||||||||||
| # per-member model overrides. If a team has access_group_ids configured, | ||||||||||||
| # any member can access models from those groups regardless of their | ||||||||||||
| # effective_models set. This is by design — access groups grant team-wide | ||||||||||||
| # access, while default_models/member.models control the team's own model list. | ||||||||||||
| team_access_group_ids = ( | ||||||||||||
| (team_object.access_group_ids or []) if team_object else [] | ||||||||||||
| ) | ||||||||||||
|
|
||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
access_group_idssilently bypasses per-member model restrictionsThe docs describe the feature as providing fine-grained per-member model access control. However,
can_team_access_modelinauth_checks.pyfalls back to the team'saccess_group_idsafter the per-member effective-models check fails — without any restriction from the user'seffective_modelsset. The code comment acknowledges this:This means:
["gpt-4o-mini"]via per-member overridesaccess_group_ids = ["premium-models"]which includes["gpt-4", "claude-3"]gpt-4andclaude-3through the access-group bypass even though they're not in their effective model setThe docs should explicitly call this out so admins understand that
access_group_idsis an additive team-wide grant that overrides per-member restrictions. Without this note, an admin who configures per-member restrictions for compliance or cost-control may unknowingly leave a bypass path open.