Repository navigation
feat(proxy): let proxy admins choose which team fields team admins may edit - #39996
Conversation
Greptile SummaryAdds configurable, server-enforced team-admin permissions for editing selected team fields and exposes those permissions to the dashboard.
Confidence Score: 5/5The PR appears safe to merge, with no newly established blocking issue. Cross-replica UI settings are now refreshed during periodic configuration reloads, resolving the earlier stale-permission concern. The prior unchanged-update finding remains outstanding for direct API callers because the backend still permits an empty filtered request to reach persistence; the new dashboard-side change only avoids that request for this client, so the non-blocking database-write and audit-noise concern remains. Important Files Changed
Reviews (8): Last reviewed commit: "fix(ui): send only a changed TPM limit f..." | Re-trigger Greptile |
| @@ -360,6 +380,7 @@ def _derived_ui_setting_value(key: str) -> object: | |||
| "disable_vector_stores_for_internal_users", | |||
There was a problem hiding this comment.
Settings Stay Stale Across Pods
In a multi-pod deployment, changing this setting updates only the pod handling the request. Other pods continue using their process-local general_settings, so new grants or revocations do not take effect there until restart once fields become configurable
Knowledge Base Used: Management APIs and resources
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
98a99d2 to
7743c3b
Compare
|
@greptile re review |
|
@greptileai re review |
|
bugbot run |
|
bugbot run |
25933b7 to
8c86c24
Compare
| return _GENERAL_SETTINGS.validate_python(cast(object, proxy_server.general_settings)) | ||
|
|
||
|
|
||
| def _caller_edit_access(role: TeamAccessRole | None, general_settings: Mapping[str, object]) -> TeamEditAccess: |
…t the tpm_limit tip
…y edit Team admins could never reach POST /team/update: the route gate answered 401 before the handler's team-admin branch ran. This moves /team/update into the self-managed routes, resolves proxy admin, org admin or team admin inside the handler, and filters team admins through a new proxy-wide UI setting, team_admin_editable_team_fields. The setting is an allow-list of team fields. Empty means team admins cannot edit team settings and get a 403 pointing at the proxy admin, and changing a field outside the list fails 403 naming that field. Only values that differ from what is stored count, since the dashboard resends the whole form. The registry of fields the setting accepts ships empty on purpose. Each field lands in its own follow-up PR with its value diff and dashboard wiring. The Admin UI gains a "Team admin editable fields" section under Settings > UI and a toast on the team page while editing is disabled. Refs LIT-5722 Claude-Session: https://claude.ai/code/session_01A6SkwJdfZUmkzfUkrEkqX8
The persisted UI settings were read once at startup, so a proxy admin flipping a runtime flag through PATCH /update/ui_settings only changed the pod that served the request. Every other pod kept serving the old value until it restarted. add_deployment, the reload the scheduler runs every 30s, now re-reads the row and applies the runtime flags before it takes the model reconcile lock, so a change made through one pod reaches the rest within one reload interval. The startup hook and the two settings endpoints share that helper instead of each repeating the flag copy. Claude-Session: https://claude.ai/code/session_018PUCupsaarVLJy4iDFx256
The five-condition chain pushed local/no-long-condition-chain one over its ceiling and failed the dashboard lint gate. Claude-Session: https://claude.ai/code/session_018PUCupsaarVLJy4iDFx256
… 3.10 assert_never landed in typing in 3.11, so importing it from typing broke the 3.10 import smoke job and the py310 typing gate. Take it from typing_extensions like the rest of the proxy does. Also drop /team/update from test_neighbouring_team_routes_stay_closed. The route is self-managed now, so the coarse gate admits the caller and update_team decides, which the sibling test in the same file already asserts. Claude-Session: https://claude.ai/code/session_018PUCupsaarVLJy4iDFx256
The dashboard gated the team settings form on a role it guessed from the is_* props, the members list and the org list. The org list is premium gated and empty while loading, so a team admin who is also an org admin was told team admins cannot edit, although /team/update accepts them as an org admin /team/info now returns caller_edit_access, resolved by the same helper /team/update uses, and TeamInfo keys the form and the toast off that field. The org list is only read for the organization dropdown now, and general_settings is read through one validated accessor in both handlers
max_budget is not a team-admin editable field yet, so the behavior suite now pins the 403 in both directions instead of the old lower-is-allowed rule
…tests already cover
|
@greptileai re review |
|
bugbot run |
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: 1 · PR risk: 0/10 |
Team admins are refused until a proxy admin enables a field, then limited to the enabled fields, and resending unchanged budget settings keeps the team's budget reset times
|
@greptileai re review |
|
bugbot run |
| def _only_changes(data: UpdateTeamRequest, changed: frozenset[str]) -> UpdateTeamRequest: | ||
| """The request without the values it resends unchanged, which would otherwise still trigger derived writes | ||
| such as a resent budget_duration pushing budget_reset_at back.""" | ||
| sent: Final = frozenset(data.model_fields_set) | ||
| via_metadata: Final = frozenset({"metadata"}) if changed - sent else frozenset() | ||
| kept: Final = frozenset({"team_id"}) | (changed & sent) | via_metadata |
There was a problem hiding this comment.
An unchanged team-admin request still writes to the database, refreshes caches, and creates avoidable audit noise
Knowledge Base Used: Proxy control plane and persistence
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!
There was a problem hiding this comment.
Fixed in 3ee8d43 on the client: the team admin form sends only changed values and keeps Save disabled until something changes
Save stays disabled until the value differs from the team's, so an unchanged form never reaches /team/update
|
@greptileai re review |
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 3ee8d43. Configure here.
|
Two caveats worth a look
|
…iAI#39996 and BerriAI#41310 Every one of these fails on main's own scheduled CircleCI run with the same assertion as on any PR, and each traces to a merged behavior change that never updated the test that pinned the old behavior - tests/integration/_support/client.py: BerriAI#41311 made /key/info serve deleted keys from the archive with status deleted, so the scenario teardown asserts the live row is gone and the readback reports deleted instead of a 404. This alone accounts for nine integration-management and one integration-providers failure - tests/integration/authorization/test_warmed_policy.py: BerriAI#39996 made team admins unable to edit any team field unless a proxy admin allow-lists it, and tpm_limit is the only field it accepts today. The demotion test now enables tpm_limit for the scenario and edits that instead of team_alias - tests/llm_responses_api_testing/test_base_responses_api_streaming_iterator.py: BerriAI#41337 reads usage off the terminal response and copies the event when it is missing, which a Mock(spec=ResponsesAPIResponse) cannot survive. The four mocks now carry a usage object - tests/test_openai_endpoints.py: BerriAI#41310 lengthened the access-denied message, and the test matched against the ExceptionInfo repr, which saferepr truncates in the middle. It now matches the exception text - tests/local_testing/test_text_completion.py: Together no longer serves Qwen2-1.5B serverless, the cheapest cost-map row. The test mocks the completions call and asserts the request litellm builds, so a vendor catalog rotation cannot fail it again test_router_fallbacks_with_cooldowns_and_dynamic_credentials is deliberately untouched: it passes and fails on main with identical code, and the failing path is a product question about whether dynamic-credential 429s cool down
TLDR
Problem this solves:
How it solves it:
team_admin_editable_team_fields: an allow-list of team fieldstpm_limitis the first field it accepts, the rest follow one PR eachcaller_edit_access, and the team page renders from itUser Flow
Before: a team admin who needs a higher TPM limit for their team cannot change it, and nothing the proxy admin configures helps
POST http://localhost:4000/user/newand{"user_role": "internal_user", "auto_create_key": true}and gets a key backPOST http://localhost:4000/team/new,"tpm_limit": 1000, and that user inmembers_with_roleswith"role": "admin"POST http://localhost:4000/team/updatewith{"team_id": "<team>", "tpm_limit": 5000}using their key and gets401 Authentication Error, Only proxy admin can be used to generate, delete, update info for new keys/users/teamsGET http://localhost:4000/team/info?team_id=<team>with their key returns the team but nothing about what they may changePATCH http://localhost:4000/update/ui_settingswith{"team_admin_editable_team_fields": ["tpm_limit"]}and gets 200, but the key is silently dropped{"team_id": "<team>", "team_alias": "renamed-by-team-admin"}and get the same 401POST http://localhost:4000/team/updatewith"tpm_limit": 20000for a team whose organization is capped at 10000 TPM, gets 200, and/team/infoshows 20000After: the proxy admin ticks TPM for team admins, the team admin changes it from the dashboard, and the organization's cap holds for everyone
POST http://localhost:4000/user/newand{"user_role": "internal_user", "auto_create_key": true}and gets a key backPOST http://localhost:4000/team/new,"tpm_limit": 1000, and that user inmembers_with_roleswith"role": "admin"POST http://localhost:4000/team/updatewith{"team_id": "<team>", "tpm_limit": 5000}using their key and gets403 Team admins on this proxy cannot edit team settings. Ask a proxy admin to enable fields under Settings > UI > Team admin editable fields.GET http://localhost:4000/team/info?team_id=<team>with their key carries"caller_edit_access": {"kind": "team_admin_disabled"}PATCH http://localhost:4000/update/ui_settingsand gets 200. Ticking alone stores nothing/team/infonow says{"kind": "team_admin", "editable_fields": ["tpm_limit"]}, and the TPM update returns 200 with"tpm_limit": 5000403 Team admins on this proxy do not have permission to update 'team_alias'. Ask a proxy admin to add it under Settings > UI > Team admin editable fields."tpm_limit": 20000for the team in the 10000 TPM organization and gets400 Team tpm_limit (20000) exceeds organization's tpm_limit (10000); 8000 still savesRelevant issues
Linear ticket
Refs LIT-5722
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*,make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more@greptileaito re-request a review after pushing changes)Screenshots / Proof of Fix
Both runs use
litellm/proxy/dev_config.yamlwithSTORE_MODEL_IN_DB=True, master keysk-1234, and two proxies on the same Postgres so the cross-pod case has a second replica to read from. Before ran on ports 4811 and 4812 from a checkout of the merge base, After on 4831 and 4832 from this branch.$TEAM_ADMIN_KEYand$ORG_ADMIN_KEYare the keys/user/newreturned in the team admin and org admin cases. The proxy admin also adds the team admin to the capped organization withPOST /organization/member_addbefore creating its team. The dashboard screenshots come from the bundle the proxy serves at/uifor Before and fromnpm run devinui/litellm-dashboardpointed at the After proxy; each user logged in with their email afterPOST /user/updategave them a passwordBefore (9357491)
The proxy-wide allow-list setting
curl -s http://localhost:4811/get/ui_settings -H 'Authorization: Bearer sk-1234' | jq -c '{value: .values.team_admin_editable_team_fields, supported: .field_schema.properties.team_admin_editable_team_fields.items.enum}'Output:
{"value":null,"supported":null}curl -s -X PATCH http://localhost:4811/update/ui_settings -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"team_admin_editable_team_fields": ["team_alias"]}' -w '\nHTTP %{http_code}\n'Output:
A team admin before any field is enabled
curl -s -X POST http://localhost:4811/user/new -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"user_id": "lit5722-team-admin-1789598553", "user_email": "lit5722-team-admin-1789598553@example.com", "user_role": "internal_user", "auto_create_key": true}' | jq -r .keyOutput:
sk-...Xd5Qcurl -s -X POST http://localhost:4811/team/new -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"team_alias": "lit5722-team-1789598553", "tpm_limit": 1000, "members_with_roles": [{"user_id": "lit5722-team-admin-1789598553", "role": "admin"}]}' | jq -r .team_idOutput:
993a5f94-659c-4f0a-ae13-782eb2286114curl -s 'http://localhost:4811/team/info?team_id=993a5f94-659c-4f0a-ae13-782eb2286114' -H "Authorization: Bearer $TEAM_ADMIN_KEY" | jq -c '.team_info | {tpm_limit, caller_edit_access}'Output:
{"tpm_limit":1000,"caller_edit_access":null}curl -s -X POST http://localhost:4811/team/update -H "Authorization: Bearer $TEAM_ADMIN_KEY" -H 'Content-Type: application/json' -d '{"team_id": "993a5f94-659c-4f0a-ae13-782eb2286114", "tpm_limit": 5000}' -w '\nHTTP %{http_code}\n' | jq -Rrc 'fromjson? // . | .data? // .error?.message? // . | if type == "object" then {team_alias, tpm_limit} else . end'Output:
The proxy admin lets team admins edit tpm_limit
curl -s -X PATCH http://localhost:4811/update/ui_settings -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"team_admin_editable_team_fields": ["tpm_limit"]}' -w '\nHTTP %{http_code}\n'Output:
curl -s http://localhost:4811/get/ui_settings -H 'Authorization: Bearer sk-1234' | jq -c '{value: .values.team_admin_editable_team_fields}'Output:
{"value":null}curl -s 'http://localhost:4811/team/info?team_id=993a5f94-659c-4f0a-ae13-782eb2286114' -H "Authorization: Bearer $TEAM_ADMIN_KEY" | jq -c '.team_info | {tpm_limit, caller_edit_access}'Output:
{"tpm_limit":1000,"caller_edit_access":null}The team admin changes the team's TPM limit
curl -s -X POST http://localhost:4811/team/update -H "Authorization: Bearer $TEAM_ADMIN_KEY" -H 'Content-Type: application/json' -d '{"team_id": "993a5f94-659c-4f0a-ae13-782eb2286114", "tpm_limit": 5000}' -w '\nHTTP %{http_code}\n' | jq -Rrc 'fromjson? // . | .data? // .error?.message? // . | if type == "object" then {team_alias, tpm_limit} else . end'Output:
curl -s -X POST http://localhost:4811/team/update -H "Authorization: Bearer $TEAM_ADMIN_KEY" -H 'Content-Type: application/json' -d '{"team_id": "993a5f94-659c-4f0a-ae13-782eb2286114", "team_alias": "renamed-by-team-admin"}' -w '\nHTTP %{http_code}\n' | jq -Rrc 'fromjson? // . | .data? // .error?.message? // . | if type == "object" then {team_alias, tpm_limit} else . end'Output:
curl -s -X POST http://localhost:4811/team/update -H "Authorization: Bearer $TEAM_ADMIN_KEY" -H 'Content-Type: application/json' -d '{"team_id": "993a5f94-659c-4f0a-ae13-782eb2286114", "team_alias": "lit5722-team-1789598553", "tpm_limit": 6000}' -w '\nHTTP %{http_code}\n' | jq -Rrc 'fromjson? // . | .data? // .error?.message? // . | if type == "object" then {team_alias, tpm_limit} else . end'Output:
The organization's TPM limit still caps a team admin
curl -s -X POST http://localhost:4811/organization/new -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"organization_alias": "lit5722-limited-org-1789598553", "tpm_limit": 10000}' | jq -r .organization_idOutput:
893f38c4-d887-4a1a-85e3-50058ec924decurl -s -X POST http://localhost:4811/team/new -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"team_alias": "lit5722-limited-team-1789598553", "organization_id": "893f38c4-d887-4a1a-85e3-50058ec924de", "tpm_limit": 1000, "members_with_roles": [{"user_id": "lit5722-team-admin-1789598553", "role": "admin"}]}' | jq -r .team_idOutput:
84fb1c88-257b-42d9-8932-fdaf38074a7ecurl -s -X POST http://localhost:4811/team/update -H "Authorization: Bearer $TEAM_ADMIN_KEY" -H 'Content-Type: application/json' -d '{"team_id": "84fb1c88-257b-42d9-8932-fdaf38074a7e", "tpm_limit": 20000}' -w '\nHTTP %{http_code}\n' | jq -Rrc 'fromjson? // . | .data? // .error?.message? // . | if type == "object" then {team_alias, tpm_limit} else . end'Output:
curl -s -X POST http://localhost:4811/team/update -H "Authorization: Bearer $TEAM_ADMIN_KEY" -H 'Content-Type: application/json' -d '{"team_id": "84fb1c88-257b-42d9-8932-fdaf38074a7e", "tpm_limit": 8000}' -w '\nHTTP %{http_code}\n' | jq -Rrc 'fromjson? // . | .data? // .error?.message? // . | if type == "object" then {team_alias, tpm_limit} else . end'Output:
A proxy admin raising the same team past its organization's TPM limit
curl -s -X POST http://localhost:4811/team/update -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"team_id": "84fb1c88-257b-42d9-8932-fdaf38074a7e", "tpm_limit": 20000}' -w '\nHTTP %{http_code}\n' | jq -Rrc 'fromjson? // . | .data? // .error?.message? // . | if type == "object" then {team_alias, tpm_limit} else . end'Output:
curl -s 'http://localhost:4811/team/info?team_id=84fb1c88-257b-42d9-8932-fdaf38074a7e' -H 'Authorization: Bearer sk-1234' | jq -c '.team_info | {tpm_limit}'Output:
{"tpm_limit":20000}A proxy admin editing the same team
curl -s -X POST http://localhost:4811/team/update -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"team_id": "993a5f94-659c-4f0a-ae13-782eb2286114", "team_alias": "renamed-by-proxy-admin"}' -w '\nHTTP %{http_code}\n' | jq -Rrc 'fromjson? // . | .data? // .error?.message? // . | if type == "object" then {team_alias, tpm_limit} else . end'Output:
An org admin who is also a team admin editing a team in their organization
curl -s -X POST http://localhost:4811/user/new -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"user_id": "lit5722-org-admin-1789598553", "user_email": "lit5722-org-admin-1789598553@example.com", "user_role": "internal_user", "auto_create_key": true}' | jq -r .keysk-...jPmQcurl -s -X POST http://localhost:4811/organization/new -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"organization_alias": "lit5722-org-1789598553"}' | jq -r .organization_id0eebe7d9-c47a-4190-b60d-729c2a88b470curl -s -X POST http://localhost:4811/organization/member_add -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"organization_id": "0eebe7d9-c47a-4190-b60d-729c2a88b470", "member": {"user_id": "lit5722-org-admin-1789598553", "role": "org_admin"}}' | jq -c '{updated_users: [.updated_users[]?.user_id]}'{"updated_users":["lit5722-org-admin-1789598553"]}curl -s -X POST http://localhost:4811/team/new -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"team_alias": "lit5722-org-team-1789598553", "organization_id": "0eebe7d9-c47a-4190-b60d-729c2a88b470", "members_with_roles": [{"user_id": "lit5722-org-admin-1789598553", "role": "admin"}]}' | jq -r .team_id23d6fac4-755a-4827-b3e9-66dde18c4feacurl -s 'http://localhost:4811/team/info?team_id=23d6fac4-755a-4827-b3e9-66dde18c4fea' -H "Authorization: Bearer $ORG_ADMIN_KEY" | jq -c '.team_info | {organization_id, caller_edit_access}'curl -s -X POST http://localhost:4811/team/update -H "Authorization: Bearer $ORG_ADMIN_KEY" -H 'Content-Type: application/json' -d '{"team_id": "23d6fac4-755a-4827-b3e9-66dde18c4fea", "team_alias": "renamed-by-org-admin"}' -w '\nHTTP %{http_code}\n' | jq -Rrc 'fromjson? // . | .data? // .error?.message? // . | if type == "object" then {team_alias, tpm_limit} else . end'A UI setting changed on one pod, read on another
curl -s http://localhost:4811/health/readiness; echo; curl -s http://localhost:4812/health/readinessOutput:
curl -s -X PATCH http://localhost:4811/update/ui_settings -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"allow_public_health_readiness_details": true}'Output:
curl -s http://localhost:4811/health/readiness | jq -c 'keys'Output:
start=$(date +%s); until curl -s http://localhost:4812/health/readiness | grep -q litellm_version; do sleep 2; [ $(( $(date +%s) - start )) -gt 90 ] && break; done; echo "$(( $(date +%s) - start ))s"; curl -s http://localhost:4812/health/readiness | jq -c 'if .litellm_version then keys else . end'Output:
Admin UI: the UI Settings tab as the proxy admin
Admin UI: Edit Settings as the team admin
Admin UI: Edit Settings as the org admin
After (3ee8d43)
The proxy-wide allow-list setting
curl -s http://localhost:4831/get/ui_settings -H 'Authorization: Bearer sk-1234' | jq -c '{value: .values.team_admin_editable_team_fields, supported: .field_schema.properties.team_admin_editable_team_fields.items.enum}'Output:
{"value":[],"supported":["tpm_limit"]}curl -s -X PATCH http://localhost:4831/update/ui_settings -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"team_admin_editable_team_fields": ["team_alias"]}' -w '\nHTTP %{http_code}\n'Output:
A team admin before any field is enabled
curl -s -X POST http://localhost:4831/user/new -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"user_id": "lit5722-team-admin-1789601272", "user_email": "lit5722-team-admin-1789601272@example.com", "user_role": "internal_user", "auto_create_key": true}' | jq -r .keyOutput:
sk-...i9qwcurl -s -X POST http://localhost:4831/team/new -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"team_alias": "lit5722-team-1789601272", "tpm_limit": 1000, "members_with_roles": [{"user_id": "lit5722-team-admin-1789601272", "role": "admin"}]}' | jq -r .team_idOutput:
4285c114-8ed1-4e51-99bc-5cca050804eecurl -s 'http://localhost:4831/team/info?team_id=4285c114-8ed1-4e51-99bc-5cca050804ee' -H "Authorization: Bearer $TEAM_ADMIN_KEY" | jq -c '.team_info | {tpm_limit, caller_edit_access}'Output:
curl -s -X POST http://localhost:4831/team/update -H "Authorization: Bearer $TEAM_ADMIN_KEY" -H 'Content-Type: application/json' -d '{"team_id": "4285c114-8ed1-4e51-99bc-5cca050804ee", "tpm_limit": 5000}' -w '\nHTTP %{http_code}\n' | jq -Rrc 'fromjson? // . | .data? // .error?.message? // . | if type == "object" then {team_alias, tpm_limit} else . end'Output:
The proxy admin lets team admins edit tpm_limit
curl -s -X PATCH http://localhost:4831/update/ui_settings -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"team_admin_editable_team_fields": ["tpm_limit"]}' -w '\nHTTP %{http_code}\n'Output:
curl -s http://localhost:4831/get/ui_settings -H 'Authorization: Bearer sk-1234' | jq -c '{value: .values.team_admin_editable_team_fields}'Output:
{"value":["tpm_limit"]}curl -s 'http://localhost:4831/team/info?team_id=4285c114-8ed1-4e51-99bc-5cca050804ee' -H "Authorization: Bearer $TEAM_ADMIN_KEY" | jq -c '.team_info | {tpm_limit, caller_edit_access}'Output:
The team admin changes the team's TPM limit
curl -s -X POST http://localhost:4831/team/update -H "Authorization: Bearer $TEAM_ADMIN_KEY" -H 'Content-Type: application/json' -d '{"team_id": "4285c114-8ed1-4e51-99bc-5cca050804ee", "tpm_limit": 5000}' -w '\nHTTP %{http_code}\n' | jq -Rrc 'fromjson? // . | .data? // .error?.message? // . | if type == "object" then {team_alias, tpm_limit} else . end'Output:
curl -s -X POST http://localhost:4831/team/update -H "Authorization: Bearer $TEAM_ADMIN_KEY" -H 'Content-Type: application/json' -d '{"team_id": "4285c114-8ed1-4e51-99bc-5cca050804ee", "team_alias": "renamed-by-team-admin"}' -w '\nHTTP %{http_code}\n' | jq -Rrc 'fromjson? // . | .data? // .error?.message? // . | if type == "object" then {team_alias, tpm_limit} else . end'Output:
curl -s -X POST http://localhost:4831/team/update -H "Authorization: Bearer $TEAM_ADMIN_KEY" -H 'Content-Type: application/json' -d '{"team_id": "4285c114-8ed1-4e51-99bc-5cca050804ee", "team_alias": "lit5722-team-1789601272", "tpm_limit": 6000}' -w '\nHTTP %{http_code}\n' | jq -Rrc 'fromjson? // . | .data? // .error?.message? // . | if type == "object" then {team_alias, tpm_limit} else . end'Output:
The organization's TPM limit still caps a team admin
curl -s -X POST http://localhost:4831/organization/new -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"organization_alias": "lit5722-limited-org-1789601272", "tpm_limit": 10000}' | jq -r .organization_idOutput:
37bc02ca-de10-4b4a-8b21-90a28d8e8d99curl -s -X POST http://localhost:4831/team/new -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"team_alias": "lit5722-limited-team-1789601272", "organization_id": "37bc02ca-de10-4b4a-8b21-90a28d8e8d99", "tpm_limit": 1000, "members_with_roles": [{"user_id": "lit5722-team-admin-1789601272", "role": "admin"}]}' | jq -r .team_idOutput:
6fd001a7-2987-4a75-ac3e-3022df83ecbfcurl -s -X POST http://localhost:4831/team/update -H "Authorization: Bearer $TEAM_ADMIN_KEY" -H 'Content-Type: application/json' -d '{"team_id": "6fd001a7-2987-4a75-ac3e-3022df83ecbf", "tpm_limit": 20000}' -w '\nHTTP %{http_code}\n' | jq -Rrc 'fromjson? // . | .data? // .error?.message? // . | if type == "object" then {team_alias, tpm_limit} else . end'Output:
curl -s -X POST http://localhost:4831/team/update -H "Authorization: Bearer $TEAM_ADMIN_KEY" -H 'Content-Type: application/json' -d '{"team_id": "6fd001a7-2987-4a75-ac3e-3022df83ecbf", "tpm_limit": 8000}' -w '\nHTTP %{http_code}\n' | jq -Rrc 'fromjson? // . | .data? // .error?.message? // . | if type == "object" then {team_alias, tpm_limit} else . end'Output:
A proxy admin raising the same team past its organization's TPM limit
curl -s -X POST http://localhost:4831/team/update -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"team_id": "6fd001a7-2987-4a75-ac3e-3022df83ecbf", "tpm_limit": 20000}' -w '\nHTTP %{http_code}\n' | jq -Rrc 'fromjson? // . | .data? // .error?.message? // . | if type == "object" then {team_alias, tpm_limit} else . end'Output:
curl -s 'http://localhost:4831/team/info?team_id=6fd001a7-2987-4a75-ac3e-3022df83ecbf' -H 'Authorization: Bearer sk-1234' | jq -c '.team_info | {tpm_limit}'Output:
{"tpm_limit":8000}A proxy admin editing the same team
curl -s -X POST http://localhost:4831/team/update -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"team_id": "4285c114-8ed1-4e51-99bc-5cca050804ee", "team_alias": "renamed-by-proxy-admin"}' -w '\nHTTP %{http_code}\n' | jq -Rrc 'fromjson? // . | .data? // .error?.message? // . | if type == "object" then {team_alias, tpm_limit} else . end'Output:
An org admin who is also a team admin editing a team in their organization
curl -s -X POST http://localhost:4831/user/new -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"user_id": "lit5722-org-admin-1789601272", "user_email": "lit5722-org-admin-1789601272@example.com", "user_role": "internal_user", "auto_create_key": true}' | jq -r .keysk-...GLlgcurl -s -X POST http://localhost:4831/organization/new -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"organization_alias": "lit5722-org-1789601272"}' | jq -r .organization_ide7db396b-c568-4b61-ac05-7e255b164349curl -s -X POST http://localhost:4831/organization/member_add -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"organization_id": "e7db396b-c568-4b61-ac05-7e255b164349", "member": {"user_id": "lit5722-org-admin-1789601272", "role": "org_admin"}}' | jq -c '{updated_users: [.updated_users[]?.user_id]}'{"updated_users":["lit5722-org-admin-1789601272"]}curl -s -X POST http://localhost:4831/team/new -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"team_alias": "lit5722-org-team-1789601272", "organization_id": "e7db396b-c568-4b61-ac05-7e255b164349", "members_with_roles": [{"user_id": "lit5722-org-admin-1789601272", "role": "admin"}]}' | jq -r .team_idcc34a267-b8d3-4a50-91fd-193a2584c58acurl -s 'http://localhost:4831/team/info?team_id=cc34a267-b8d3-4a50-91fd-193a2584c58a' -H "Authorization: Bearer $ORG_ADMIN_KEY" | jq -c '.team_info | {organization_id, caller_edit_access}'curl -s -X POST http://localhost:4831/team/update -H "Authorization: Bearer $ORG_ADMIN_KEY" -H 'Content-Type: application/json' -d '{"team_id": "cc34a267-b8d3-4a50-91fd-193a2584c58a", "team_alias": "renamed-by-org-admin"}' -w '\nHTTP %{http_code}\n' | jq -Rrc 'fromjson? // . | .data? // .error?.message? // . | if type == "object" then {team_alias, tpm_limit} else . end'A UI setting changed on one pod, read on another
curl -s http://localhost:4831/health/readiness; echo; curl -s http://localhost:4832/health/readinessOutput:
curl -s -X PATCH http://localhost:4831/update/ui_settings -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"allow_public_health_readiness_details": true}'Output:
curl -s http://localhost:4831/health/readiness | jq -c 'keys'Output:
start=$(date +%s); until curl -s http://localhost:4832/health/readiness | grep -q litellm_version; do sleep 2; [ $(( $(date +%s) - start )) -gt 90 ] && break; done; echo "$(( $(date +%s) - start ))s"; curl -s http://localhost:4832/health/readiness | jq -c 'if .litellm_version then keys else . end'Output:
A team admin resending the team's budget settings leaves its budget reset alone
curl -s -X POST http://localhost:4831/team/update -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d '{"team_id": "4285c114-8ed1-4e51-99bc-5cca050804ee", "budget_duration": "30s"}' -w '\nHTTP %{http_code}\n' | jq -Rrc 'fromjson? // . | .data? // .team_info? // .error?.message? // . | if type == "object" then {tpm_limit, budget_duration, budget_reset_at} else . end'Output:
sleep 31; curl -s -X POST http://localhost:4831/team/update -H "Authorization: Bearer $TEAM_ADMIN_KEY" -H 'Content-Type: application/json' -d '{"team_id": "4285c114-8ed1-4e51-99bc-5cca050804ee", "tpm_limit": 7000, "budget_duration": "30s"}' -w '\nHTTP %{http_code}\n' | jq -Rrc 'fromjson? // . | .data? // .team_info? // .error?.message? // . | if type == "object" then {tpm_limit, budget_duration, budget_reset_at} else . end'Output:
curl -s 'http://localhost:4831/team/info?team_id=4285c114-8ed1-4e51-99bc-5cca050804ee' -H 'Authorization: Bearer sk-1234' | jq -Rrc 'fromjson? // . | .data? // .team_info? // .error?.message? // . | if type == "object" then {tpm_limit, budget_duration, budget_reset_at} else . end'Output:
Admin UI: the UI Settings tab as the proxy admin
GET http://localhost:4000/get/ui_settingsstill returns"team_admin_editable_team_fields": []/get/ui_settingsnow returns["tpm_limit"]Admin UI: Edit Settings as the team admin
Admin UI: Edit Settings as the org admin
Type
🆕 New Feature
🐛 Bug Fix
Caveats (if any)
Severe
Medium
Low
"type": "internal_server_error", the wrapper the handler's other 403s already use/organization/member_adddoes not refresh the cached user row, so a user just promoted to org admin can be treated as a plain team admin until that cache entry expires; this predates the PR and every org admin check shares itcaller_edit_accessis additive on/team/infoand defaults to{"kind": "none"}, so older dashboards ignore it and the new dashboard treats a proxy that omits it as read-only for non-adminsproxy_config_reload_interval_secondssets that interval and defaults to 30 secondsSTORE_MODEL_IN_DBis on, which the settings endpoint already requiresFinal Attestation
Note
Medium Risk
Changes authorization for
/team/update(401→403 semantics), enforces org budget/TPM/RPM limits on update for all callers, and introduces field-level team-admin permissions that must stay aligned with the dashboard and multi-pod UI settings sync.Overview
Adds a proxy-wide UI setting
team_admin_editable_team_fieldsso proxy admins can choose which team settings team admins may change (initially onlytpm_limit)./team/updatemoves to self-managed routes; the handler resolves proxy/org/team admin roles, applies the allow-list for team admins (403 with clear messages), compares submitted vs stored values so unchanged form fields are ignored, and strips unchanged budget fields to avoid resetting budget timers./team/infonow returnscaller_edit_accessso the dashboard can show a limited team-admin settings form or block editing without guessing from roles. Admin UI gets a Team admin editable fields card; team settings editing uses the proxy verdict.UI settings that affect runtime (including the new allow-list) sync into
general_settingson startup and on every periodicadd_deploymentreload so replicas converge without restart./team/updatealso loads the org budget relation so org max budget / TPM / RPM caps apply on update (not only on create).Reviewed by Cursor Bugbot for commit 3ee8d43. Bugbot is set up for automated code reviews on this repo. Configure here.