Teams - Support default key expiry + UI - support enforcing access for members of specific SSO Group (#12023)

* fix(team_endpoints.py): support setting default key expiry

allows admin to set key expiry on all team member keys

makes it easier to setup default team for experimentation

* feat(key_management_endpoints.py): allows admin to set duration for keys created by team members

* feat(team_endpoints.py): support team_member_key_duration on `/team/update`

allows setting max time team member keys are valid for

* fix(team_info.tsx): ui component to update team member key duration

* fix(team_info.tsx): support updating team member key duration, if set

* feat(teams.tsx): add team member key duration param ui component

allow admin to set this on UI

* feat(ui_sso.py): support restricting ui access by sso group

allows controlling who can/can't access the UI

* feat(ssomodals.tsx): add initial commit adding sso group access to admin ui

* feat(proxy_server.py): support reading + writing ui_access_mode from db

allows admin to configure allowed sso groups from UI

* feat(ui_sso.py): support enforcing all teams on sso jwt handler

if ui access mode set via ui, support reading the value and enforcing it

* feat(ui/): ui component for controlling sso access group

allow admin to only allow users within specific sso group to log into UI

* fix(uiaccesscontrolform.tsx): fix field names

* feat(ui_sso.py): return received sso response in the clientside error message - enables easier debugging

* test: add unit tests

* fix: minor fixes
This commit is contained in:
Krish Dholakia 2025-06-24 21:58:07 -07:00 • committed by GitHub
parent 15dabf6573
commit ac15ca3014
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 864 additions and 85 deletions

View file

@ -0,0 +1,30 @@
from typing import Optional
from litellm.proxy._types import GenerateKeyRequest, LiteLLM_TeamTable
def add_team_member_key_duration(
team_table: Optional[LiteLLM_TeamTable],
data: GenerateKeyRequest,
) -> GenerateKeyRequest:
if team_table is None:
return data
if data.user_id is None: # only apply for team member keys, not service accounts
return data
if (
team_table.metadata is not None
and team_table.metadata.get("team_member_key_duration") is not None
):
data.duration = team_table.metadata["team_member_key_duration"]
return data
def apply_enterprise_key_management_params(
data: GenerateKeyRequest,
team_table: Optional[LiteLLM_TeamTable],
) -> GenerateKeyRequest:
data = add_team_member_key_duration(team_table, data)
return data

View file

@ -1,4 +1,4 @@
model_list:
- model_name: gemini-2.5-pro
litellm_params:
model: gemini/gemini-2.5-pro
model: gemini/gemini-2.5-pro

View file

@ -1115,6 +1115,7 @@ class NewTeamRequest(TeamBase):
team_member_budget: Optional[float] = (
None # allow user to set a budget for all team members
)
team_member_key_duration: Optional[str] = None # e.g. "1d", "1w", "1m"
model_config = ConfigDict(protected_namespaces=())
@ -1157,6 +1158,7 @@ class UpdateTeamRequest(LiteLLMPydanticObjectBase):
guardrails: Optional[List[str]] = None
object_permission: Optional[LiteLLM_ObjectPermissionBase] = None
team_member_budget: Optional[float] = None
team_member_key_duration: Optional[str] = None
class ResetTeamBudgetRequest(LiteLLMPydanticObjectBase):
@ -2792,6 +2794,7 @@ LiteLLM_ManagementEndpoint_MetadataFields = [
LiteLLM_ManagementEndpoint_MetadataFields_Premium = [
"guardrails",
"tags",
"team_member_key_duration",
]

View file

@ -164,7 +164,9 @@ class JWTHandler:
self.litellm_jwtauth.team_ids_jwt_field is not None
and token.get(self.litellm_jwtauth.team_ids_jwt_field) is not None
):
return token[self.litellm_jwtauth.team_ids_jwt_field]
return []
def get_end_user_id(

357
litellm/proxy/litellm.log Normal file
View file

@ -0,0 +1,357 @@
18:10:09 - LiteLLM Router:INFO: router.py:660 - Routing strategy: simple-shuffle
18:10:11 - LiteLLM Proxy:INFO: utils.py:1317 - All necessary views exist!
18:10:11 - LiteLLM Router:WARNING: router.py:4862 - Error upserting deployment: vertex_project, and vertex_location must be set in litellm_params for pass-through endpoints., ignoring and continuing with other deployments.
18:10:11 - LiteLLM Router:WARNING: router.py:4862 - Error upserting deployment: vertex_project, and vertex_location must be set in litellm_params for pass-through endpoints., ignoring and continuing with other deployments.
18:10:23 - LiteLLM Proxy:INFO: ui_sso.py:129 - Redirecting to SSO login for http://localhost:4000/sso/callback
18:10:27 - LiteLLM Proxy:INFO: ui_sso.py:495 - Starting SSO callback
18:10:27 - LiteLLM Proxy:INFO: ui_sso.py:550 - Redirecting to http://localhost:4000/sso/callback
18:10:28 - LiteLLM Proxy:INFO: ui_sso.py:581 - SSO callback result: id='krrishd' email='krrishdholakia@gmail.com' first_name=None last_name=None display_name='a3f1c107-04dc-4c93-ae60-7f32eb4b05ce' picture=None provider=None team_ids=[]
18:10:28 - LiteLLM Proxy:INFO: ui_sso.py:671 - user_defined_values for creating ui key: {'models': [], 'user_id': 'krrishd', 'user_email': 'krrishdholakia@gmail.com', 'max_budget': None, 'user_role': 'proxy_admin', 'budget_duration': None}
18:10:28 - LiteLLM Proxy:INFO: utils.py:1856 - Data Inserted into Keys Table
18:10:28 - LiteLLM Proxy:INFO: ui_sso.py:761 - user_id: krrishd; jwt_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoia3JyaXNoZCIsImtleSI6InNrLTVvOXVVc0ZaaTVBRFBiWERoanhCZlEiLCJ1c2VyX2VtYWlsIjoia3JyaXNoZGhvbGFraWFAZ21haWwuY29tIiwidXNlcl9yb2xlIjoicHJveHlfYWRtaW4iLCJsb2dpbl9tZXRob2QiOiJzc28iLCJwcmVtaXVtX3VzZXIiOnRydWUsImF1dGhfaGVhZGVyX25hbWUiOiJBdXRob3JpemF0aW9uIiwiZGlzYWJsZWRfbm9uX2FkbWluX3BlcnNvbmFsX2tleV9jcmVhdGlvbiI6ZmFsc2UsInNlcnZlcl9yb290X3BhdGgiOiIvIn0.OiZdFjZ2wiMhFbMCwu2cZYXh7oV5BB8Vta-Ysk5JBQU
18:10:28 - LiteLLM Proxy:INFO: ui_sso.py:764 - Redirecting to http://localhost:4000/ui/?login=success
18:10:30 - LiteLLM Proxy:ERROR: key_management_endpoints.py:2275 - Error in list_keys: Server disconnected without sending a response.
Traceback (most recent call last):
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 101, in map_httpcore_exceptions
yield
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 394, in handle_async_request
resp = await self._pool.handle_async_request(req)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection_pool.py", line 216, in handle_async_request
raise exc from None
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection_pool.py", line 196, in handle_async_request
response = await connection.handle_async_request(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection.py", line 101, in handle_async_request
return await self._connection.handle_async_request(request)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/http11.py", line 143, in handle_async_request
raise exc
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/http11.py", line 113, in handle_async_request
) = await self._receive_response_headers(**kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/http11.py", line 186, in _receive_response_headers
event = await self._receive_event(timeout=timeout)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/http11.py", line 238, in _receive_event
raise RemoteProtocolError(msg)
httpcore.RemoteProtocolError: Server disconnected without sending a response.
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "/Users/krrishdholakia/Documents/litellm/litellm/proxy/management_endpoints/key_management_endpoints.py", line 2255, in list_keys
response = await _list_key_helper(
^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/krrishdholakia/Documents/litellm/litellm/proxy/management_endpoints/key_management_endpoints.py", line 2434, in _list_key_helper
total_count = await prisma_client.db.litellm_verificationtoken.count(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/actions.py", line 10157, in count
resp = await self._client._execute(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/_base_client.py", line 543, in _execute
return await self._engine.query(builder.build(), tx_id=self._tx_id)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/engine/_query.py", line 402, in query
return await self.request(
^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/engine/_http.py", line 217, in request
response = await self.session.request(method, url, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/_async_http.py", line 26, in request
return Response(await self.session.request(method, url, **kwargs))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1540, in request
return await self.send(request, auth=auth, follow_redirects=follow_redirects)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1629, in send
response = await self._send_handling_auth(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1657, in _send_handling_auth
response = await self._send_handling_redirects(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1694, in _send_handling_redirects
response = await self._send_single_request(request)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1730, in _send_single_request
response = await transport.handle_async_request(request)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 393, in handle_async_request
with map_httpcore_exceptions():
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/contextlib.py", line 155, in __exit__
self.gen.throw(typ, value, traceback)
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 118, in map_httpcore_exceptions
raise mapped_exc(message) from exc
httpx.RemoteProtocolError: Server disconnected without sending a response.
18:10:30 - LiteLLM Proxy:ERROR: proxy_server.py:2730 - litellm.proxy_server.py::add_deployment() - Error getting new models from DB - All connection attempts failed
Traceback (most recent call last):
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 101, in map_httpcore_exceptions
yield
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 394, in handle_async_request
resp = await self._pool.handle_async_request(req)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection_pool.py", line 216, in handle_async_request
raise exc from None
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection_pool.py", line 196, in handle_async_request
response = await connection.handle_async_request(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection.py", line 99, in handle_async_request
raise exc
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection.py", line 76, in handle_async_request
stream = await self._connect(request)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection.py", line 122, in _connect
stream = await self._network_backend.connect_tcp(**kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_backends/auto.py", line 30, in connect_tcp
return await self._backend.connect_tcp(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_backends/anyio.py", line 112, in connect_tcp
with map_exceptions(exc_map):
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/contextlib.py", line 155, in __exit__
self.gen.throw(typ, value, traceback)
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_exceptions.py", line 14, in map_exceptions
raise to_exc(exc) from exc
httpcore.ConnectError: All connection attempts failed
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "/Users/krrishdholakia/Documents/litellm/litellm/proxy/proxy_server.py", line 2728, in _get_models_from_db
new_models = await prisma_client.db.litellm_proxymodeltable.find_many()
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/actions.py", line 2540, in find_many
resp = await self._client._execute(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/_base_client.py", line 543, in _execute
return await self._engine.query(builder.build(), tx_id=self._tx_id)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/engine/_query.py", line 402, in query
return await self.request(
^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/engine/_http.py", line 217, in request
response = await self.session.request(method, url, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/_async_http.py", line 26, in request
return Response(await self.session.request(method, url, **kwargs))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1540, in request
return await self.send(request, auth=auth, follow_redirects=follow_redirects)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1629, in send
response = await self._send_handling_auth(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1657, in _send_handling_auth
response = await self._send_handling_redirects(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1694, in _send_handling_redirects
response = await self._send_single_request(request)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1730, in _send_single_request
response = await transport.handle_async_request(request)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 393, in handle_async_request
with map_httpcore_exceptions():
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/contextlib.py", line 155, in __exit__
self.gen.throw(typ, value, traceback)
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 118, in map_httpcore_exceptions
raise mapped_exc(message) from exc
httpx.ConnectError: All connection attempts failed
18:10:30 - LiteLLM Proxy:ERROR: utils.py:1404 - LiteLLM Prisma Client Exception get_generic_data: All connection attempts failed
18:10:30 - LiteLLM Proxy:ERROR: utils.py:1404 - LiteLLM Prisma Client Exception get_generic_data: All connection attempts failed
18:10:30 - LiteLLM Proxy:ERROR: proxy_server.py:2778 - litellm.proxy.proxy_server.py::ProxyConfig:add_deployment - All connection attempts failed
Traceback (most recent call last):
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 101, in map_httpcore_exceptions
yield
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 394, in handle_async_request
resp = await self._pool.handle_async_request(req)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection_pool.py", line 216, in handle_async_request
raise exc from None
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection_pool.py", line 196, in handle_async_request
response = await connection.handle_async_request(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection.py", line 99, in handle_async_request
raise exc
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection.py", line 76, in handle_async_request
stream = await self._connect(request)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection.py", line 122, in _connect
stream = await self._network_backend.connect_tcp(**kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_backends/auto.py", line 30, in connect_tcp
return await self._backend.connect_tcp(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_backends/anyio.py", line 112, in connect_tcp
with map_exceptions(exc_map):
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/contextlib.py", line 155, in __exit__
self.gen.throw(typ, value, traceback)
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_exceptions.py", line 14, in map_exceptions
raise to_exc(exc) from exc
httpcore.ConnectError: All connection attempts failed
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "/Users/krrishdholakia/Documents/litellm/litellm/proxy/proxy_server.py", line 2760, in add_deployment
await self._update_llm_router(
File "/Users/krrishdholakia/Documents/litellm/litellm/proxy/proxy_server.py", line 2418, in _update_llm_router
config_data = await proxy_config.get_config()
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/krrishdholakia/Documents/litellm/litellm/proxy/proxy_server.py", line 1584, in get_config
config = await self._update_config_from_db(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/krrishdholakia/Documents/litellm/litellm/proxy/proxy_server.py", line 2706, in _update_config_from_db
responses = await asyncio.gather(*_tasks)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/krrishdholakia/Documents/litellm/litellm/proxy/db/log_db_metrics.py", line 99, in wrapper
raise e
File "/Users/krrishdholakia/Documents/litellm/litellm/proxy/db/log_db_metrics.py", line 42, in wrapper
result = await func(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/backoff/_async.py", line 151, in retry
ret = await target(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/krrishdholakia/Documents/litellm/litellm/proxy/utils.py", line 1418, in get_generic_data
raise e
File "/Users/krrishdholakia/Documents/litellm/litellm/proxy/utils.py", line 1392, in get_generic_data
response = await self.db.litellm_config.find_first( # type: ignore
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/actions.py", line 11822, in find_first
resp = await self._client._execute(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/_base_client.py", line 543, in _execute
return await self._engine.query(builder.build(), tx_id=self._tx_id)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/engine/_query.py", line 402, in query
return await self.request(
^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/engine/_http.py", line 217, in request
response = await self.session.request(method, url, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/_async_http.py", line 26, in request
return Response(await self.session.request(method, url, **kwargs))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1540, in request
return await self.send(request, auth=auth, follow_redirects=follow_redirects)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1629, in send
response = await self._send_handling_auth(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1657, in _send_handling_auth
response = await self._send_handling_redirects(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1694, in _send_handling_redirects
response = await self._send_single_request(request)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1730, in _send_single_request
response = await transport.handle_async_request(request)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 393, in handle_async_request
with map_httpcore_exceptions():
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/contextlib.py", line 155, in __exit__
self.gen.throw(typ, value, traceback)
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 118, in map_httpcore_exceptions
raise mapped_exc(message) from exc
httpx.ConnectError: All connection attempts failed
18:10:30 - LiteLLM Proxy:ERROR: utils.py:1404 - LiteLLM Prisma Client Exception get_generic_data: All connection attempts failed
18:10:30 - LiteLLM Proxy:ERROR: utils.py:1404 - LiteLLM Prisma Client Exception get_generic_data: All connection attempts failed
18:10:30 - LiteLLM Proxy:INFO: proxy_server.py:490 - Shutting down LiteLLM Proxy Server
18:11:47 - LiteLLM Router:INFO: router.py:660 - Routing strategy: simple-shuffle
18:11:49 - LiteLLM Proxy:INFO: utils.py:1317 - All necessary views exist!
18:11:50 - LiteLLM Router:WARNING: router.py:4862 - Error upserting deployment: vertex_project, and vertex_location must be set in litellm_params for pass-through endpoints., ignoring and continuing with other deployments.
18:11:50 - LiteLLM Router:WARNING: router.py:4862 - Error upserting deployment: vertex_project, and vertex_location must be set in litellm_params for pass-through endpoints., ignoring and continuing with other deployments.
18:12:00 - LiteLLM Proxy:ERROR: proxy_server.py:2925 - litellm.proxy_server.py::get_credentials() - Error getting credentials from DB - Server disconnected without sending a response.
Traceback (most recent call last):
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 101, in map_httpcore_exceptions
yield
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 394, in handle_async_request
resp = await self._pool.handle_async_request(req)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection_pool.py", line 216, in handle_async_request
raise exc from None
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection_pool.py", line 196, in handle_async_request
response = await connection.handle_async_request(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/connection.py", line 101, in handle_async_request
return await self._connection.handle_async_request(request)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/http11.py", line 143, in handle_async_request
raise exc
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/http11.py", line 113, in handle_async_request
) = await self._receive_response_headers(**kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/http11.py", line 186, in _receive_response_headers
event = await self._receive_event(timeout=timeout)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpcore/_async/http11.py", line 238, in _receive_event
raise RemoteProtocolError(msg)
httpcore.RemoteProtocolError: Server disconnected without sending a response.
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "/Users/krrishdholakia/Documents/litellm/litellm/proxy/proxy_server.py", line 2916, in get_credentials
credentials = await prisma_client.db.litellm_credentialstable.find_many()
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/actions.py", line 1502, in find_many
resp = await self._client._execute(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/_base_client.py", line 543, in _execute
return await self._engine.query(builder.build(), tx_id=self._tx_id)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/engine/_query.py", line 402, in query
return await self.request(
^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/engine/_http.py", line 217, in request
response = await self.session.request(method, url, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/prisma/_async_http.py", line 26, in request
return Response(await self.session.request(method, url, **kwargs))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1540, in request
return await self.send(request, auth=auth, follow_redirects=follow_redirects)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1629, in send
response = await self._send_handling_auth(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1657, in _send_handling_auth
response = await self._send_handling_redirects(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1694, in _send_handling_redirects
response = await self._send_single_request(request)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_client.py", line 1730, in _send_single_request
response = await transport.handle_async_request(request)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 393, in handle_async_request
with map_httpcore_exceptions():
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/contextlib.py", line 155, in __exit__
self.gen.throw(typ, value, traceback)
File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/httpx/_transports/default.py", line 118, in map_httpcore_exceptions
raise mapped_exc(message) from exc
httpx.RemoteProtocolError: Server disconnected without sending a response.
18:12:01 - LiteLLM Proxy:INFO: proxy_server.py:490 - Shutting down LiteLLM Proxy Server
18:12:14 - LiteLLM Router:INFO: router.py:660 - Routing strategy: simple-shuffle
18:12:16 - LiteLLM Proxy:INFO: utils.py:1317 - All necessary views exist!
18:12:16 - LiteLLM Router:WARNING: router.py:4862 - Error upserting deployment: vertex_project, and vertex_location must be set in litellm_params for pass-through endpoints., ignoring and continuing with other deployments.
18:12:16 - LiteLLM Router:WARNING: router.py:4862 - Error upserting deployment: vertex_project, and vertex_location must be set in litellm_params for pass-through endpoints., ignoring and continuing with other deployments.
18:12:21 - LiteLLM Proxy:INFO: ui_sso.py:129 - Redirecting to SSO login for http://localhost:4000/sso/callback
18:12:26 - LiteLLM Proxy:INFO: ui_sso.py:495 - Starting SSO callback
18:12:26 - LiteLLM Proxy:INFO: ui_sso.py:550 - Redirecting to http://localhost:4000/sso/callback
18:12:26 - LiteLLM Proxy:INFO: ui_sso.py:581 - SSO callback result: id='krrishd' email='krrishdholakia@gmail.com' first_name=None last_name=None display_name='a3f1c107-04dc-4c93-ae60-7f32eb4b05ce' picture=None provider=None team_ids=[]
18:12:27 - LiteLLM Proxy:INFO: ui_sso.py:672 - user_defined_values for creating ui key: {'models': [], 'user_id': 'krrishd', 'user_email': 'krrishdholakia@gmail.com', 'max_budget': None, 'user_role': 'proxy_admin', 'budget_duration': None}
18:12:27 - LiteLLM Proxy:INFO: utils.py:1856 - Data Inserted into Keys Table
18:12:27 - LiteLLM Proxy:INFO: ui_sso.py:762 - user_id: krrishd; jwt_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoia3JyaXNoZCIsImtleSI6InNrLUQzMEFpdW9lckU3YlMyakFXWVFLd1EiLCJ1c2VyX2VtYWlsIjoia3JyaXNoZGhvbGFraWFAZ21haWwuY29tIiwidXNlcl9yb2xlIjoicHJveHlfYWRtaW4iLCJsb2dpbl9tZXRob2QiOiJzc28iLCJwcmVtaXVtX3VzZXIiOnRydWUsImF1dGhfaGVhZGVyX25hbWUiOiJBdXRob3JpemF0aW9uIiwiZGlzYWJsZWRfbm9uX2FkbWluX3BlcnNvbmFsX2tleV9jcmVhdGlvbiI6ZmFsc2UsInNlcnZlcl9yb290X3BhdGgiOiIvIn0.EzYP86hw12J4WHLe6ZZz4YgVNGPnxM_PHqLjINH2_-U
18:12:27 - LiteLLM Proxy:INFO: ui_sso.py:765 - Redirecting to http://localhost:4000/ui/?login=success
18:12:31 - LiteLLM Proxy:INFO: proxy_server.py:490 - Shutting down LiteLLM Proxy Server
18:15:07 - LiteLLM Router:INFO: router.py:660 - Routing strategy: simple-shuffle
18:15:09 - LiteLLM Proxy:INFO: utils.py:1317 - All necessary views exist!
18:15:09 - LiteLLM Router:WARNING: router.py:4862 - Error upserting deployment: vertex_project, and vertex_location must be set in litellm_params for pass-through endpoints., ignoring and continuing with other deployments.
18:15:09 - LiteLLM Router:WARNING: router.py:4862 - Error upserting deployment: vertex_project, and vertex_location must be set in litellm_params for pass-through endpoints., ignoring and continuing with other deployments.
18:15:17 - LiteLLM Proxy:INFO: utils.py:1916 - Data Inserted into Config Table
18:15:28 - LiteLLM Proxy:INFO: ui_sso.py:129 - Redirecting to SSO login for http://localhost:4000/sso/callback
18:15:32 - LiteLLM Proxy:INFO: ui_sso.py:495 - Starting SSO callback
18:15:32 - LiteLLM Proxy:INFO: ui_sso.py:550 - Redirecting to http://localhost:4000/sso/callback
18:15:32 - LiteLLM Proxy:INFO: ui_sso.py:581 - SSO callback result: id='krrishd' email='krrishdholakia@gmail.com' first_name=None last_name=None display_name='a3f1c107-04dc-4c93-ae60-7f32eb4b05ce' picture=None provider=None team_ids=[]
18:15:37 - LiteLLM Proxy:INFO: proxy_server.py:490 - Shutting down LiteLLM Proxy Server

View file

@ -512,6 +512,20 @@ async def generate_key_fn( # noqa: PLR0915
},
)
# APPLY ENTERPRISE KEY MANAGEMENT PARAMS
try:
from litellm_enterprise.proxy.management_endpoints.key_management_endpoints import (
apply_enterprise_key_management_params,
)
data = apply_enterprise_key_management_params(data, team_table)
except Exception as e:
verbose_proxy_logger.info(
"litellm.proxy.proxy_server.generate_key_fn(): Enterprise key management params not applied - {}".format(
str(e)
)
)
# TODO: @ishaan-jaff: Migrate all budget tracking to use LiteLLM_BudgetTable
_budget_id = data.budget_id
if prisma_client is not None and data.soft_budget is not None:
@ -536,7 +550,7 @@ async def generate_key_fn( # noqa: PLR0915
# ADD METADATA FIELDS
# Set Management Endpoint Metadata Fields
for field in LiteLLM_ManagementEndpoint_MetadataFields_Premium:
if getattr(data, field) is not None:
if getattr(data, field, None) is not None:
_set_object_metadata_field(
object_data=data,
field_name=field,
@ -589,9 +603,9 @@ async def generate_key_fn( # noqa: PLR0915
request_type="key", **data_json, table_name="key"
)
response[
"soft_budget"
] = data.soft_budget # include the user-input soft budget in the response
response["soft_budget"] = (
data.soft_budget
) # include the user-input soft budget in the response
response = GenerateKeyResponse(**response)
@ -667,9 +681,9 @@ async def _set_object_permission(
data=data_json["object_permission"],
)
)
data_json[
"object_permission_id"
] = created_object_permission.object_permission_id
data_json["object_permission_id"] = (
created_object_permission.object_permission_id
)
# delete the object_permission from the data_json
data_json.pop("object_permission")
@ -1652,10 +1666,10 @@ async def delete_verification_tokens(
try:
if prisma_client:
tokens = [_hash_token_if_needed(token=key) for key in tokens]
_keys_being_deleted: List[
LiteLLM_VerificationToken
] = await prisma_client.db.litellm_verificationtoken.find_many(
where={"token": {"in": tokens}}
_keys_being_deleted: List[LiteLLM_VerificationToken] = (
await prisma_client.db.litellm_verificationtoken.find_many(
where={"token": {"in": tokens}}
)
)
if len(_keys_being_deleted) == 0:
@ -1763,9 +1777,9 @@ async def _rotate_master_key(
from litellm.proxy.proxy_server import proxy_config
try:
models: Optional[
List
] = await prisma_client.db.litellm_proxymodeltable.find_many()
models: Optional[List] = (
await prisma_client.db.litellm_proxymodeltable.find_many()
)
except Exception:
models = None
# 2. process model table
@ -2057,11 +2071,11 @@ async def validate_key_list_check(
param="user_id",
code=status.HTTP_403_FORBIDDEN,
)
complete_user_info_db_obj: Optional[
BaseModel
] = await prisma_client.db.litellm_usertable.find_unique(
where={"user_id": user_api_key_dict.user_id},
include={"organization_memberships": True},
complete_user_info_db_obj: Optional[BaseModel] = (
await prisma_client.db.litellm_usertable.find_unique(
where={"user_id": user_api_key_dict.user_id},
include={"organization_memberships": True},
)
)
if complete_user_info_db_obj is None:
@ -2147,10 +2161,10 @@ async def get_admin_team_ids(
if complete_user_info is None:
return []
# Get all teams that user is an admin of
teams: Optional[
List[BaseModel]
] = await prisma_client.db.litellm_teamtable.find_many(
where={"team_id": {"in": complete_user_info.teams}}
teams: Optional[List[BaseModel]] = (
await prisma_client.db.litellm_teamtable.find_many(
where={"team_id": {"in": complete_user_info.teams}}
)
)
if teams is None:
return []
@ -2403,12 +2417,14 @@ async def _list_key_helper(
where=where, # type: ignore
skip=skip, # type: ignore
take=size, # type: ignore
order=order_by
if order_by
else [
{"created_at": "desc"},
{"token": "desc"}, # fallback sort
],
order=(
order_by
if order_by
else [
{"created_at": "desc"},
{"token": "desc"}, # fallback sort
]
),
include={"object_permission": True},
)

View file

@ -1,9 +1,14 @@
from typing import Dict, Union
from litellm.proxy._types import LitellmUserRoles
def check_is_admin_only_access(ui_access_mode: str) -> bool:
def check_is_admin_only_access(ui_access_mode: Union[str, Dict]) -> bool:
"""Checks ui access mode is admin_only"""
return ui_access_mode == "admin_only"
if isinstance(ui_access_mode, str):
return ui_access_mode == "admin_only"
else:
return False
def has_admin_ui_access(user_role: str) -> bool:

View file

@ -262,6 +262,7 @@ async def new_team( # noqa: PLR0915
- guardrails: Optional[List[str]] - Guardrails for the team. [Docs](https://docs.litellm.ai/docs/proxy/guardrails)
- object_permission: Optional[LiteLLM_ObjectPermissionBase] - team-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"]}. IF null or {} then no object permission.
- team_member_budget: Optional[float] - The maximum budget allocated to an individual team member.
- team_member_key_duration: Optional[str] - The duration for a team member's key. e.g. "1d", "1w", "1mo"
Returns:
- team_id: (str) Unique team id - used for tracking spend across multiple keys for same team id.
@ -688,6 +689,7 @@ async def update_team(
- guardrails: Optional[List[str]] - Guardrails for the team. [Docs](https://docs.litellm.ai/docs/proxy/guardrails)
- object_permission: Optional[LiteLLM_ObjectPermissionBase] - team-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"]}. IF null or {} then no object permission.
- team_member_budget: Optional[float] - The maximum budget allocated to an individual team member.
- team_member_key_duration: Optional[str] - The duration for a team member's key. e.g. "1d", "1w", "1mo"
Example - update team TPM Limit
```

View file

@ -145,7 +145,11 @@ async def google_login(request: Request): # noqa: PLR0915
return HTMLResponse(content=html_form, status_code=200)
def generic_response_convertor(response, jwt_handler: JWTHandler):
def generic_response_convertor(
response,
jwt_handler: JWTHandler,
sso_jwt_handler: Optional[JWTHandler] = None,
):
generic_user_id_attribute_name = os.getenv(
"GENERIC_USER_ID_ATTRIBUTE", "preferred_username"
)
@ -171,6 +175,13 @@ def generic_response_convertor(response, jwt_handler: JWTHandler):
f" generic_user_id_attribute_name: {generic_user_id_attribute_name}\n generic_user_email_attribute_name: {generic_user_email_attribute_name}"
)
all_teams = []
if sso_jwt_handler is not None:
team_ids = sso_jwt_handler.get_team_ids_from_jwt(cast(dict, response))
all_teams.extend(team_ids)
team_ids = jwt_handler.get_team_ids_from_jwt(cast(dict, response))
all_teams.extend(team_ids)
return CustomOpenID(
id=response.get(generic_user_id_attribute_name),
display_name=response.get(generic_user_display_name_attribute_name),
@ -178,20 +189,24 @@ def generic_response_convertor(response, jwt_handler: JWTHandler):
first_name=response.get(generic_user_first_name_attribute_name),
last_name=response.get(generic_user_last_name_attribute_name),
provider=response.get(generic_provider_attribute_name),
team_ids=jwt_handler.get_team_ids_from_jwt(cast(dict, response)),
team_ids=all_teams,
)
async def get_generic_sso_response(
request: Request,
jwt_handler: JWTHandler,
sso_jwt_handler: Optional[
JWTHandler
], # sso specific jwt handler - used for restricted sso group access control
generic_client_id: str,
redirect_url: str,
) -> Union[OpenID, dict]:
) -> Tuple[Union[OpenID, dict], Optional[dict]]: # return received response
# make generic sso provider
from fastapi_sso.sso.base import DiscoveryDocument
from fastapi_sso.sso.generic import create_provider
received_response: Optional[dict] = None
generic_client_secret = os.getenv("GENERIC_CLIENT_SECRET", None)
generic_scope = os.getenv("GENERIC_SCOPE", "openid email profile").split(" ")
generic_authorization_endpoint = os.getenv("GENERIC_AUTHORIZATION_ENDPOINT", None)
@ -242,9 +257,12 @@ async def get_generic_sso_response(
)
def response_convertor(response, client):
nonlocal received_response # return for user debugging
received_response = response
return generic_response_convertor(
response=response,
jwt_handler=jwt_handler,
sso_jwt_handler=sso_jwt_handler,
)
SSOProvider = create_provider(
@ -284,7 +302,7 @@ async def get_generic_sso_response(
)
raise e
verbose_proxy_logger.debug("generic result: %s", result)
return result or {}
return result or {}, received_response
async def create_team_member_add_task(team_id, user_info):
@ -480,6 +498,8 @@ async def check_and_update_if_proxy_admin_id(
async def auth_callback(request: Request): # noqa: PLR0915
"""Verify login"""
verbose_proxy_logger.info("Starting SSO callback")
from litellm.proxy._types import LiteLLM_JWTAuth
from litellm.proxy.auth.handle_jwt import JWTHandler
from litellm.proxy.management_endpoints.key_management_endpoints import (
generate_key_helper_fn,
)
@ -490,7 +510,6 @@ async def auth_callback(request: Request): # noqa: PLR0915
premium_user,
prisma_client,
proxy_logging_obj,
ui_access_mode,
user_api_key_cache,
user_custom_sso,
)
@ -502,9 +521,25 @@ async def auth_callback(request: Request): # noqa: PLR0915
status_code=500, detail=CommonProxyErrors.db_not_connected_error.value
)
sso_jwt_handler: Optional[JWTHandler] = None
ui_access_mode = general_settings.get("ui_access_mode", None)
if ui_access_mode is not None and isinstance(ui_access_mode, dict):
sso_jwt_handler = JWTHandler()
sso_jwt_handler.update_environment(
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
litellm_jwtauth=LiteLLM_JWTAuth(
team_ids_jwt_field=general_settings.get("ui_access_mode", {}).get(
"sso_group_jwt_field", None
),
),
leeway=0,
)
microsoft_client_id = os.getenv("MICROSOFT_CLIENT_ID", None)
google_client_id = os.getenv("GOOGLE_CLIENT_ID", None)
generic_client_id = os.getenv("GENERIC_CLIENT_ID", None)
received_response: Optional[dict] = None
# get url from request
if master_key is None:
raise ProxyException(
@ -532,11 +567,12 @@ async def auth_callback(request: Request): # noqa: PLR0915
redirect_url=redirect_url,
)
elif generic_client_id is not None:
result = await get_generic_sso_response(
result, received_response = await get_generic_sso_response(
request=request,
jwt_handler=jwt_handler,
generic_client_id=generic_client_id,
redirect_url=redirect_url,
sso_jwt_handler=sso_jwt_handler,
)
if result is None:
@ -547,6 +583,7 @@ async def auth_callback(request: Request): # noqa: PLR0915
# User is Authe'd in - generate key for the UI to access Proxy
verbose_proxy_logger.info(f"SSO callback result: {result}")
user_email: Optional[str] = getattr(result, "email", None)
user_id: Optional[str] = getattr(result, "id", None) if result is not None else None
@ -612,6 +649,13 @@ async def auth_callback(request: Request): # noqa: PLR0915
budget_duration=internal_user_budget_duration,
)
# (IF SET) Verify user is in restricted SSO group
SSOAuthenticationHandler.verify_user_in_restricted_sso_group(
general_settings=general_settings,
result=result,
received_response=received_response,
)
user_info = await get_user_info_from_db(
result=result,
prisma_client=prisma_client,
@ -1055,6 +1099,44 @@ class SSOAuthenticationHandler:
sso_teams = getattr(result, "team_ids", [])
await add_missing_team_member(user_info=user_info, sso_teams=sso_teams)
@staticmethod
def verify_user_in_restricted_sso_group(
general_settings: Dict,
result: Optional[Union[CustomOpenID, OpenID, dict]],
received_response: Optional[dict],
) -> Literal[True]:
"""
when ui_access_mode.type == "restricted_sso_group":
- result.team_ids should contain the restricted_sso_group
- if not, raise a ProxyException
- if so, return True
- if result.team_ids is None, return False
- if result.team_ids is an empty list, return False
- if result.team_ids is a list, return True if the restricted_sso_group is in the list, otherwise return False
"""
ui_access_mode = cast(
Optional[Union[Dict, str]], general_settings.get("ui_access_mode")
)
if ui_access_mode is None:
return True
if isinstance(ui_access_mode, str):
return True
team_ids = getattr(result, "team_ids", [])
if ui_access_mode.get("type") == "restricted_sso_group":
restricted_sso_group = ui_access_mode.get("restricted_sso_group")
if restricted_sso_group not in team_ids:
raise ProxyException(
message=f"User is not in the restricted SSO group: {restricted_sso_group}. User groups: {team_ids}. Received SSO response: {received_response}",
type=ProxyErrorTypes.auth_error,
param="restricted_sso_group",
code=status.HTTP_403_FORBIDDEN,
)
return True
@staticmethod
async def create_litellm_team_from_sso_group(
litellm_team_id: str,
@ -1551,7 +1633,29 @@ async def debug_sso_callback(request: Request):
from fastapi.responses import HTMLResponse
from litellm.proxy.proxy_server import jwt_handler
from litellm.proxy._types import LiteLLM_JWTAuth
from litellm.proxy.auth.handle_jwt import JWTHandler
from litellm.proxy.proxy_server import (
general_settings,
jwt_handler,
prisma_client,
user_api_key_cache,
)
sso_jwt_handler: Optional[JWTHandler] = None
ui_access_mode = general_settings.get("ui_access_mode", None)
if ui_access_mode is not None and isinstance(ui_access_mode, dict):
sso_jwt_handler = JWTHandler()
sso_jwt_handler.update_environment(
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
litellm_jwtauth=LiteLLM_JWTAuth(
team_ids_jwt_field=general_settings.get("ui_access_mode", {}).get(
"sso_group_jwt_field", None
),
),
leeway=0,
)
microsoft_client_id = os.getenv("MICROSOFT_CLIENT_ID", None)
google_client_id = os.getenv("GOOGLE_CLIENT_ID", None)
@ -1580,11 +1684,12 @@ async def debug_sso_callback(request: Request):
)
elif generic_client_id is not None:
result = await get_generic_sso_response(
result, _ = await get_generic_sso_response(
request=request,
jwt_handler=jwt_handler,
generic_client_id=generic_client_id,
redirect_url=redirect_url,
sso_jwt_handler=sso_jwt_handler,
)
# If result is None, return a basic error message

View file

@ -905,7 +905,7 @@ health_check_results: Dict[str, Union[int, List[Dict[str, Any]]]] = {}
queue: List = []
litellm_proxy_budget_name = "litellm-proxy-budget"
litellm_proxy_admin_name = LITELLM_PROXY_ADMIN_NAME
ui_access_mode: Literal["admin", "all"] = "all"
ui_access_mode: Union[Literal["admin", "all"], Dict] = "all"
proxy_budget_rescheduler_min_time = PROXY_BUDGET_RESCHEDULER_MIN_TIME
proxy_budget_rescheduler_max_time = PROXY_BUDGET_RESCHEDULER_MAX_TIME
proxy_batch_write_at = PROXY_BATCH_WRITE_AT
@ -1435,11 +1435,13 @@ class ProxyConfig:
- Do not write restricted params like 'api_key' to the database
- if api_key is passed, save that to the local environment or connected secret manage (maybe expose `litellm.save_secret()`)
"""
if prisma_client is not None and (
general_settings.get("store_model_in_db", False) is True
or store_model_in_db
):
# if using - db for config - models are in ModelTable
new_config.pop("model_list", None)
await prisma_client.insert_data(data=new_config, table_name="config")
else:
@ -2625,6 +2627,10 @@ class ProxyConfig:
pass_through_endpoints=general_settings["pass_through_endpoints"]
)
## UI ACCESS MODE ##
if "ui_access_mode" in _general_settings:
general_settings["ui_access_mode"] = _general_settings["ui_access_mode"]
def _update_config_fields(
self,
current_config: dict,

View file

@ -7,7 +7,10 @@ import litellm
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import *
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.types.proxy.management_endpoints.ui_sso import DefaultTeamSSOParams, SSOConfig
from litellm.types.proxy.management_endpoints.ui_sso import (
DefaultTeamSSOParams,
SSOConfig,
)
router = APIRouter()
@ -18,26 +21,29 @@ class IPAddress(BaseModel):
class SettingsResponse(BaseModel):
"""Base response model for settings with values and schema information"""
values: Dict[str, Any]
"""The current configuration values"""
field_schema: Dict[str, Any]
"""Schema information including descriptions and property types for UI display"""
class SSOSettingsResponse(SettingsResponse):
"""Response model for SSO settings"""
pass
class InternalUserSettingsResponse(SettingsResponse):
"""Response model for internal user settings"""
pass
class DefaultTeamSettingsResponse(SettingsResponse):
"""Response model for default team settings"""
pass
@ -166,7 +172,10 @@ async def _get_settings_with_schema(
# Add descriptions to the response
result = {
"values": settings_dict,
"field_schema": {"description": schema.get("description", ""), "properties": {}},
"field_schema": {
"description": schema.get("description", ""),
"properties": {},
},
}
# Add property descriptions
@ -322,20 +331,21 @@ async def get_sso_settings():
Returns a structured object with values and descriptions for UI display.
"""
import os
from litellm.proxy.proxy_server import proxy_config
# Load existing config to get both environment variables and general settings
config = await proxy_config.get_config()
general_settings = config.get("general_settings", {}) or {}
environment_variables = config.get("environment_variables", {}) or {}
# Get user_email from general_settings
proxy_admin_email = general_settings.get("proxy_admin_email", None)
# Helper function to get env var value (first from config, then from environment)
def get_env_value(env_var_name: str):
return environment_variables.get(env_var_name) or os.getenv(env_var_name)
# Get current environment variables for SSO
sso_config = SSOConfig(
google_client_id=get_env_value("GOOGLE_CLIENT_ID"),
@ -351,27 +361,31 @@ async def get_sso_settings():
proxy_base_url=get_env_value("PROXY_BASE_URL"),
user_email=proxy_admin_email, # Get from config instead of environment
)
# Get the schema for UI display
from pydantic import TypeAdapter
schema = TypeAdapter(SSOConfig).json_schema(by_alias=True)
# Convert to dict for response
sso_dict = sso_config.model_dump()
# Add descriptions to the response
result = {
"values": sso_dict,
"field_schema": {"description": schema.get("description", ""), "properties": {}},
"field_schema": {
"description": schema.get("description", ""),
"properties": {},
},
}
# Add property descriptions
for field_name, field_info in schema["properties"].items():
result["field_schema"]["properties"][field_name] = {
"description": field_info.get("description", ""),
"type": field_info.get("type", "string"),
}
return result
@ -384,51 +398,56 @@ async def update_sso_settings(sso_config: SSOConfig):
"""
Update SSO configuration by saving to both environment variables and config file.
"""
from litellm.proxy.proxy_server import proxy_config
import os
from litellm.proxy.proxy_server import proxy_config
# Update environment variables
env_var_mapping = {
'google_client_id': 'GOOGLE_CLIENT_ID',
'google_client_secret': 'GOOGLE_CLIENT_SECRET',
'microsoft_client_id': 'MICROSOFT_CLIENT_ID',
'microsoft_client_secret': 'MICROSOFT_CLIENT_SECRET',
'microsoft_tenant': 'MICROSOFT_TENANT',
'generic_client_id': 'GENERIC_CLIENT_ID',
'generic_client_secret': 'GENERIC_CLIENT_SECRET',
'generic_authorization_endpoint': 'GENERIC_AUTHORIZATION_ENDPOINT',
'generic_token_endpoint': 'GENERIC_TOKEN_ENDPOINT',
'generic_userinfo_endpoint': 'GENERIC_USERINFO_ENDPOINT',
'proxy_base_url': 'PROXY_BASE_URL',
"google_client_id": "GOOGLE_CLIENT_ID",
"google_client_secret": "GOOGLE_CLIENT_SECRET",
"microsoft_client_id": "MICROSOFT_CLIENT_ID",
"microsoft_client_secret": "MICROSOFT_CLIENT_SECRET",
"microsoft_tenant": "MICROSOFT_TENANT",
"generic_client_id": "GENERIC_CLIENT_ID",
"generic_client_secret": "GENERIC_CLIENT_SECRET",
"generic_authorization_endpoint": "GENERIC_AUTHORIZATION_ENDPOINT",
"generic_token_endpoint": "GENERIC_TOKEN_ENDPOINT",
"generic_userinfo_endpoint": "GENERIC_USERINFO_ENDPOINT",
"proxy_base_url": "PROXY_BASE_URL",
}
# Load existing config
config = await proxy_config.get_config()
# Update config with new environment variables
if "environment_variables" not in config:
config["environment_variables"] = {}
# Update general_settings for user_email (admin email)
if "general_settings" not in config:
config["general_settings"] = {}
# Update environment variables in config and in memory
sso_data = sso_config.model_dump(exclude_none=True)
for field_name, value in sso_data.items():
if field_name == 'user_email' and value is not None:
if field_name == "user_email" and value is not None:
# Store user_email in general_settings instead of environment variables
config["general_settings"]["proxy_admin_email"] = value
elif field_name == "ui_access_mode" and value is not None:
config["general_settings"]["ui_access_mode"] = value
elif field_name in env_var_mapping and value is not None:
env_var_name = env_var_mapping[field_name]
# Update in config
config["environment_variables"][env_var_name] = value
# Update in runtime environment
os.environ[env_var_name] = value
# Save the updated config
await proxy_config.save_config(new_config=config)
return {
"message": "SSO settings updated successfully",
"status": "success",

View file

@ -1,4 +1,4 @@
from typing import List, Literal, Optional, TypedDict
from typing import List, Literal, Optional, TypedDict, Union
from pydantic import Field
@ -31,6 +31,14 @@ class MicrosoftServicePrincipalTeam(TypedDict, total=False):
principalId: Optional[str]
class AccessControl_UI_AccessMode(LiteLLMPydanticObjectBase):
"""Model for Controlling UI Access Mode via SSO Groups"""
type: Literal["restricted_sso_group"]
restricted_sso_group: str
sso_group_jwt_field: str
class SSOConfig(LiteLLMPydanticObjectBase):
"""
Configuration for SSO environment variables and settings
@ -45,7 +53,7 @@ class SSOConfig(LiteLLMPydanticObjectBase):
default=None,
description="Google OAuth Client Secret for SSO authentication",
)
# Microsoft SSO
microsoft_client_id: Optional[str] = Field(
default=None,
@ -59,7 +67,7 @@ class SSOConfig(LiteLLMPydanticObjectBase):
default=None,
description="Microsoft Azure Tenant ID for SSO authentication",
)
# Generic/Okta SSO
generic_client_id: Optional[str] = Field(
default=None,
@ -81,7 +89,7 @@ class SSOConfig(LiteLLMPydanticObjectBase):
default=None,
description="User info endpoint URL for generic OAuth provider",
)
# Common settings
proxy_base_url: Optional[str] = Field(
default=None,
@ -92,6 +100,12 @@ class SSOConfig(LiteLLMPydanticObjectBase):
description="Email of the proxy admin user",
)
# Access Mode
ui_access_mode: Optional[Union[AccessControl_UI_AccessMode, str]] = Field(
default=None,
description="Access mode for the UI",
)
class DefaultTeamSSOParams(LiteLLMPydanticObjectBase):
"""

View file

@ -696,11 +696,12 @@ async def test_get_generic_sso_response_with_additional_headers():
"fastapi_sso.sso.generic.create_provider", return_value=mock_sso_class
) as mock_create_provider:
# Act
result = await get_generic_sso_response(
result, received_response = await get_generic_sso_response(
request=mock_request,
jwt_handler=mock_jwt_handler,
generic_client_id=generic_client_id,
redirect_url=redirect_url,
sso_jwt_handler=None,
)
# Assert
@ -756,11 +757,12 @@ async def test_get_generic_sso_response_with_empty_headers():
"fastapi_sso.sso.generic.create_provider", return_value=mock_sso_class
) as mock_create_provider:
# Act
result = await get_generic_sso_response(
result, received_response = await get_generic_sso_response(
request=mock_request,
jwt_handler=mock_jwt_handler,
generic_client_id=generic_client_id,
redirect_url=redirect_url,
sso_jwt_handler=None,
)
# Assert

View file

@ -132,7 +132,7 @@ const SSOModals: React.FC<SSOModalsProps> = ({
}
}
// Set form values with existing data
// Set form values with existing data (excluding UI access control fields)
const formValues = {
sso_provider: selectedProvider,
proxy_base_url: ssoData.values.proxy_base_url,

View file

@ -0,0 +1,148 @@
import React, { useEffect, useState } from "react";
import { Form, Button as Button2, Select, message } from "antd";
import { Text, TextInput } from "@tremor/react";
import { getSSOSettings, updateSSOSettings } from "./networking";
interface UIAccessControlFormProps {
accessToken: string | null;
onSuccess: () => void;
}
// Separate UI Access Control Form Component
const UIAccessControlForm: React.FC<UIAccessControlFormProps> = ({ accessToken, onSuccess }) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
// Load existing UI access control settings
useEffect(() => {
const loadUIAccessSettings = async () => {
if (accessToken) {
try {
const ssoData = await getSSOSettings(accessToken);
if (ssoData && ssoData.values) {
// Handle nested ui_access_mode structure
const uiAccessMode = ssoData.values.ui_access_mode;
let formValues = {};
if (uiAccessMode && typeof uiAccessMode === 'object') {
formValues = {
ui_access_mode_type: uiAccessMode.type,
restricted_sso_group: uiAccessMode.restricted_sso_group,
sso_group_jwt_field: uiAccessMode.sso_group_jwt_field,
};
} else if (typeof uiAccessMode === 'string') {
// Handle legacy flat structure
formValues = {
ui_access_mode_type: uiAccessMode,
restricted_sso_group: ssoData.values.restricted_sso_group,
sso_group_jwt_field: ssoData.values.team_ids_jwt_field || ssoData.values.sso_group_jwt_field,
};
}
form.setFieldsValue(formValues);
}
} catch (error) {
console.error("Failed to load UI access settings:", error);
}
}
};
loadUIAccessSettings();
}, [accessToken, form]);
const handleUIAccessSubmit = async (formValues: Record<string, any>) => {
if (!accessToken) {
message.error("No access token available");
return;
}
setLoading(true);
try {
// Transform form data to match API expected structure
const apiPayload = {
ui_access_mode: {
type: formValues.ui_access_mode_type,
restricted_sso_group: formValues.restricted_sso_group,
sso_group_jwt_field: formValues.sso_group_jwt_field,
}
};
await updateSSOSettings(accessToken, apiPayload);
onSuccess();
} catch (error) {
console.error("Failed to save UI access settings:", error);
message.error("Failed to save UI access settings");
} finally {
setLoading(false);
}
};
return (
<div style={{ padding: '16px' }}>
<div style={{ marginBottom: '16px' }}>
<Text style={{ fontSize: '14px', color: '#6b7280' }}>
Configure who can access the UI interface and how group information is extracted from JWT tokens.
</Text>
</div>
<Form
form={form}
onFinish={handleUIAccessSubmit}
layout="vertical"
>
<Form.Item
label="UI Access Mode"
name="ui_access_mode_type"
tooltip="Controls who can access the UI interface"
>
<Select placeholder="Select access mode">
<Select.Option value="all_authenticated_users">All Authenticated Users</Select.Option>
<Select.Option value="restricted_sso_group">Restricted SSO Group</Select.Option>
</Select>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.ui_access_mode_type !== currentValues.ui_access_mode_type}
>
{({ getFieldValue }) => {
const uiAccessModeType = getFieldValue('ui_access_mode_type');
return uiAccessModeType === 'restricted_sso_group' ? (
<Form.Item
label="Restricted SSO Group"
name="restricted_sso_group"
rules={[{ required: true, message: "Please enter the restricted SSO group" }]}
>
<TextInput placeholder="ui-access-group" />
</Form.Item>
) : null;
}}
</Form.Item>
<Form.Item
label="SSO Group JWT Field"
name="sso_group_jwt_field"
tooltip="JWT field name that contains team/group information. Use dot notation to access nested fields."
>
<TextInput placeholder="groups" />
</Form.Item>
<div style={{ textAlign: "right", marginTop: "16px" }}>
<Button2
type="primary"
htmlType="submit"
loading={loading}
style={{
backgroundColor: '#6366f1',
borderColor: '#6366f1'
}}
>
Update UI Access Control
</Button2>
</div>
</Form>
</div>
);
};
export default UIAccessControlForm;

View file

@ -44,6 +44,7 @@ import { InvitationLink } from "./onboarding_link";
import SSOModals from "./SSOModals";
import { ssoProviderConfigs } from './SSOModals';
import SCIMConfig from "./SCIM";
import UIAccessControlForm from "./UIAccessControlForm";
interface AdminPanelProps {
searchParams: any;
@ -97,6 +98,7 @@ const AdminPanel: React.FC<AdminPanelProps> = ({
const [isAllowedIPModalVisible, setIsAllowedIPModalVisible] = useState(false);
const [isAddIPModalVisible, setIsAddIPModalVisible] = useState(false);
const [isDeleteIPModalVisible, setIsDeleteIPModalVisible] = useState(false);
const [isUIAccessControlModalVisible, setIsUIAccessControlModalVisible] = useState(false);
const [allowedIPs, setAllowedIPs] = useState<string[]>([]);
const [ipToDelete, setIPToDelete] = useState<string | null>(null);
const [ssoConfigured, setSsoConfigured] = useState<boolean>(false);
@ -532,6 +534,14 @@ const AdminPanel: React.FC<AdminPanelProps> = ({
}
};
const handleUIAccessControlOk = () => {
setIsUIAccessControlModalVisible(false);
};
const handleUIAccessControlCancel = () => {
setIsUIAccessControlModalVisible(false);
};
console.log(`admins: ${admins?.length}`);
return (
<div className="w-full m-2 mt-2 p-8">
@ -563,6 +573,14 @@ const AdminPanel: React.FC<AdminPanelProps> = ({
Allowed IPs
</Button>
</div>
<div>
<Button
style={{ width: '150px' }}
onClick={() => premiumUser === true ? setIsUIAccessControlModalVisible(true) : message.error("Only premium users can configure UI access control")}
>
UI Access Control
</Button>
</div>
</div>
</Card>
@ -654,6 +672,24 @@ const AdminPanel: React.FC<AdminPanelProps> = ({
>
<p>Are you sure you want to delete the IP address: {ipToDelete}?</p>
</Modal>
{/* UI Access Control Modal */}
<Modal
title="UI Access Control Settings"
visible={isUIAccessControlModalVisible}
width={600}
footer={null}
onOk={handleUIAccessControlOk}
onCancel={handleUIAccessControlCancel}
>
<UIAccessControlForm
accessToken={accessToken}
onSuccess={() => {
handleUIAccessControlOk();
message.success("UI Access Control settings updated successfully");
}}
/>
</Modal>
</div>
<Callout title="Login without SSO" color="teal">
If you need to login without sso, you can access{" "}

View file

@ -260,6 +260,10 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
updateData.team_member_budget = Number(values.team_member_budget);
}
if (values.team_member_key_duration !== undefined) {
updateData.team_member_key_duration = values.team_member_key_duration;
}
// Handle object_permission updates
if (values.vector_stores !== undefined || values.mcp_servers !== undefined) {
updateData.object_permission = {
@ -453,6 +457,15 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<NumericalInput step={0.01} precision={2} style={{ width: "100%" }} />
</Form.Item>
<Form.Item label="Team Member Key Duration" name="team_member_key_duration" tooltip="Set a limit to the duration of a team member's key.">
<Select placeholder="n/a">
<Select.Option value="1d">1 day</Select.Option>
<Select.Option value="1w">1 week</Select.Option>
<Select.Option value="1mo">1 month</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Reset Budget" name="budget_duration">
<Select placeholder="n/a">
<Select.Option value="24h">daily</Select.Option>
@ -561,9 +574,19 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<div>RPM: {info.rpm_limit || 'Unlimited'}</div>
</div>
<div>
<Text className="font-medium">Budget</Text>
<div>Max: {info.max_budget !== null ? `$${info.max_budget}` : 'No Limit'}</div>
<div>Reset: {info.budget_duration || 'Never'}</div>
<Text className="font-medium">Team Budget</Text>
<div>Max Budget: {info.max_budget !== null ? `$${info.max_budget}` : 'No Limit'}</div>
<div>Budget Reset: {info.budget_duration || 'Never'}</div>
</div>
<div>
<Text className="font-medium">
Team Member Settings{' '}
<Tooltip title="These are limits on individual team members">
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</Tooltip>
</Text>
<div>Max Budget: {info.team_member_budget_table?.max_budget || 'No Limit'}</div>
<div>Key Duration: {info.metadata?.team_member_key_duration || 'No Limit'}</div>
</div>
<div>
<Text className="font-medium">Organization ID</Text>

View file

@ -1060,6 +1060,17 @@ const Teams: React.FC<TeamProps> = ({
>
<NumericalInput step={0.01} precision={2} width={200} />
</Form.Item>
<Form.Item
label="Team Member Key Duration"
name="team_member_key_duration"
tooltip="Set a limit to the duration of a team member's key."
>
<Select2 defaultValue={null} placeholder="n/a">
<Select2.Option value="1d">1 day</Select2.Option>
<Select2.Option value="1w">1 week</Select2.Option>
<Select2.Option value="1mo">1 month</Select2.Option>
</Select2>
</Form.Item>
<Form.Item label="Metadata" name="metadata" help="Additional team metadata. Enter metadata as JSON object.">
<Input.TextArea rows={4} />
</Form.Item>