Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/management_endpoints/team_callback_endpoints.py: 64%
229 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1"""
2Endpoints to control callbacks per team
4Use this when each team should control its own callbacks
5"""
7import asyncio
8import copy
9import json
10import traceback
11from datetime import datetime, timezone
12from typing import Annotated, Final
14from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
16from litellm._logging import verbose_proxy_logger
17from litellm._uuid import uuid
18from litellm.proxy._types import (
19 AddTeamCallback,
20 LiteLLM_AuditLogs,
21 LiteLLM_TeamTable,
22 LitellmTableNames,
23 LitellmUserRoles,
24 ProxyErrorTypes,
25 ProxyException,
26 TeamCallbackDeleteResponse,
27 TeamCallbackDeleteResponseData,
28 TeamCallbackMetadata,
29 UserAPIKeyAuth,
30)
31from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
32from litellm.proxy.common_utils.callback_config_validation import (
33 callback_config_error,
34 conflicting_span_scope_error,
35 cross_entry_family_error,
36)
37from litellm.proxy.common_utils.callback_utils import (
38 _CALLBACK_VAR_ENCRYPTED_PREFIX,
39 decrypt_callback_vars,
40 encrypt_callback_vars,
41 is_sensitive_callback_key,
42)
43from litellm.proxy.litellm_pre_call_utils import (
44 _get_validated_callback_metadata,
45 convert_key_logging_metadata_to_callback,
46)
47from litellm.proxy.management_endpoints.team_endpoints import (
48 _refresh_cached_team,
49 _verify_team_access,
50)
51from litellm.proxy.management_helpers.utils import management_endpoint_wrapper
52from litellm.repositories.team_repository import TeamRepository
54router: Final = APIRouter()
57_CALLBACK_VARS_REDACTED: Final = "***REDACTED***"
60def _callback_config_error(message: str) -> HTTPException:
61 return HTTPException(status_code=400, detail={"error": message}) # mutable-ok: FastAPI detail contract
64def _validate_team_callback(data: "AddTeamCallback") -> None:
65 error: Final = callback_config_error(data.callback_name, data.callback_vars)
66 if error is not None: 66 ↛ 67line 66 didn't jump to line 67 because the condition on line 66 was never true
67 raise _callback_config_error(error)
70def _redact_callback_secrets(metadata: object) -> object:
71 """Strip secret values out of a team-metadata snapshot before audit logging.
73 Both ``team_metadata["logging"]`` (list of ``AddTeamCallback`` dicts) and
74 ``team_metadata["callback_settings"]["callback_vars"]`` carry provider
75 credentials such as ``langfuse_secret_key``, ``langsmith_api_key``, and
76 ``gcs_path_service_account``. Persisting them verbatim into
77 ``LiteLLM_AuditLogs`` would let anyone with read access to the audit
78 table harvest team callback credentials, so we replace each value with
79 a fixed marker. The keys themselves are kept so the audit reader can
80 still see *which* fields changed.
81 """
82 if not isinstance(metadata, dict):
83 return metadata
84 redacted: Final = copy.deepcopy(metadata)
85 logging_entries: Final = redacted.get("logging")
86 if isinstance(logging_entries, list):
87 for entry in logging_entries:
88 if isinstance(entry, dict) and isinstance(entry.get("callback_vars"), dict):
89 entry["callback_vars"] = {k: _CALLBACK_VARS_REDACTED for k in entry["callback_vars"]}
90 callback_settings: Final = redacted.get("callback_settings")
91 if isinstance(callback_settings, dict) and isinstance(callback_settings.get("callback_vars"), dict):
92 callback_settings["callback_vars"] = {k: _CALLBACK_VARS_REDACTED for k in callback_settings["callback_vars"]}
93 return redacted
96def _mask_sensitive_callback_vars(callbacks: TeamCallbackMetadata) -> None:
97 """Mask credential-bearing callback vars in place, keeping the rest readable.
99 ``callback_vars`` mixes credentials (``langsmith_api_key``,
100 ``langfuse_secret_key``, ``gcs_path_service_account``) with plain
101 configuration (project names, bucket names, hosts). The configuration is
102 what makes a read of this endpoint useful, so only the sensitive keys are
103 replaced, using the same marker as the audit-log redaction above.
105 A value that still carries the encrypted prefix here failed to decrypt, so
106 it is masked too. Handing back a ciphertext blob under a key that is not
107 classified as sensitive would give the caller something it cannot use and
108 cannot tell apart from a real value.
110 Masking in place rather than rebuilding the mapping keeps this under the
111 LIT002 mutable-collection-construction budget. It is safe because the only
112 caller passes an object it just built from a decrypted deep copy of the
113 row, so nothing here is reachable from the team's stored metadata.
114 """
115 if not callbacks.callback_vars: 115 ↛ 117line 115 didn't jump to line 117 because the condition on line 115 was always true
116 return
117 for key in tuple(callbacks.callback_vars):
118 value = callbacks.callback_vars[key]
119 if is_sensitive_callback_key(key) or str(value).startswith(_CALLBACK_VAR_ENCRYPTED_PREFIX):
120 callbacks.callback_vars[key] = _CALLBACK_VARS_REDACTED
123def _resolve_team_callbacks(team_metadata: object) -> TeamCallbackMetadata:
124 """Report the callbacks that are actually in effect for a team.
126 A team's callback config can live in either of two metadata slots.
127 ``metadata["logging"]`` holds the ``AddTeamCallback`` entries written by
128 ``POST /team/{team_id}/callback`` and by the Admin UI, while
129 ``metadata["callback_settings"]`` holds the older ``TeamCallbackMetadata``
130 shape. Request-time resolution in ``_get_dynamic_logging_metadata`` treats
131 the two as mutually exclusive: a populated ``logging`` slot wins outright
132 and ``callback_settings`` is consulted only as the deprecated fallback.
133 This reader applies the same precedence so it reports what a request would
134 really do. Merging the two instead would report a ``callback_settings``
135 entry as active for a team whose requests never fire it.
137 Credential ``callback_vars`` are stored encrypted, so they are decrypted
138 before being masked by key; a value encrypted under a key that is no longer
139 classified as sensitive would otherwise come back as raw ciphertext.
140 """
141 if not isinstance(team_metadata, dict): 141 ↛ 142line 141 didn't jump to line 142 because the condition on line 141 was never true
142 return TeamCallbackMetadata()
144 decrypted: Final = decrypt_callback_vars(team_metadata)
145 logging_entries: Final = decrypted.get("logging")
147 if logging_entries is not None:
148 resolved = TeamCallbackMetadata()
149 for entry in logging_entries if isinstance(logging_entries, list) else ():
150 if not isinstance(entry, dict): 150 ↛ 151line 150 didn't jump to line 151 because the condition on line 150 was never true
151 continue
152 callback = _get_validated_callback_metadata(item=entry, source="team-level read")
153 if callback is None: 153 ↛ 154line 153 didn't jump to line 154 because the condition on line 153 was never true
154 continue
155 resolved = convert_key_logging_metadata_to_callback(data=callback, team_callback_settings_obj=resolved)
156 else:
157 callback_settings: Final = decrypted.get("callback_settings")
158 resolved = (
159 TeamCallbackMetadata(**callback_settings) if isinstance(callback_settings, dict) else TeamCallbackMetadata()
160 )
162 _mask_sensitive_callback_vars(resolved)
163 return resolved
166def _log_audit_task_exception(task: "asyncio.Task[None]") -> None:
167 """Surface a fire-and-forget audit-log task failure.
169 ``asyncio.create_task`` swallows exceptions silently — if the audit
170 write fails (transient DB error etc.) we'd otherwise lose the row
171 without any signal. Log at warning level so the operator sees there's
172 a gap in the audit trail.
173 """
174 if task.cancelled():
175 return
176 exc: Final = task.exception()
177 if exc is not None:
178 verbose_proxy_logger.warning("Failed to write team-callback audit log: %s", exc)
181async def _emit_team_callback_audit_log(
182 *,
183 team_id: str,
184 before_metadata: object,
185 after_metadata: object,
186 user_api_key_dict: UserAPIKeyAuth,
187 litellm_changed_by: str | None,
188) -> None:
189 """Emit an audit-log row for a team-callback mutation.
191 Mirrors the ``store_audit_logs``-gated pattern used in
192 ``team_endpoints.py``: the call is async-fire-and-forget and is a no-op
193 when audit logging is not enabled on the proxy. Captured under
194 ``LitellmTableNames.TEAM_TABLE_NAME`` so the row co-locates with other
195 team mutations in the audit table.
197 Callback secrets are redacted before serialization so the audit table
198 cannot itself become a credential-harvest sink.
199 """
200 from litellm.proxy.management_helpers.audit_logs import (
201 create_audit_log_for_update,
202 is_audit_logging_enabled,
203 )
204 from litellm.proxy.proxy_server import litellm_proxy_admin_name
206 if not is_audit_logging_enabled(): 206 ↛ 209line 206 didn't jump to line 209 because the condition on line 206 was always true
207 return
209 redacted_before: Final = _redact_callback_secrets(before_metadata)
210 redacted_after: Final = _redact_callback_secrets(after_metadata)
212 task: Final = asyncio.create_task(
213 create_audit_log_for_update(
214 request_data=LiteLLM_AuditLogs(
215 id=str(uuid.uuid4()),
216 updated_at=datetime.now(timezone.utc),
217 changed_by=litellm_changed_by or user_api_key_dict.user_id or litellm_proxy_admin_name,
218 changed_by_api_key=user_api_key_dict.api_key,
219 table_name=LitellmTableNames.TEAM_TABLE_NAME,
220 object_id=team_id,
221 action="updated",
222 updated_values=json.dumps({"metadata": redacted_after}, default=str),
223 before_value=json.dumps({"metadata": redacted_before}, default=str),
224 )
225 )
226 )
227 task.add_done_callback(_log_audit_task_exception)
230def _callback_error(status_code: int, message: str) -> HTTPException:
231 """Build the ``{"error": ...}`` failure body the team callback endpoints return."""
232 return HTTPException(
233 status_code=status_code,
234 detail={"error": message}, # mutable-ok: the error response body is a JSON object
235 )
238def _unknown_team_error(team_id: str, user_api_key_dict: UserAPIKeyAuth, status_code: int) -> HTTPException:
239 """Report an unknown team without telling an unauthorized caller that it is unknown.
241 These routes are reachable by any authenticated caller so that a team admin can
242 get as far as _verify_team_access. A distinct "does not exist" would therefore let
243 any valid key probe which team ids exist, so a caller who could not have managed
244 the team either way gets the same 403 body _verify_team_access raises.
245 """
246 if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN: 246 ↛ 248line 246 didn't jump to line 248 because the condition on line 246 was always true
247 return _callback_error(status_code, f"Team id = {team_id} does not exist.")
248 return HTTPException(
249 status_code=status.HTTP_403_FORBIDDEN,
250 detail="You do not have access to this team",
251 )
254@router.post(
255 "/team/{team_id:path}/callback",
256 tags=["team management"],
257 dependencies=[Depends(user_api_key_auth)],
258)
259@management_endpoint_wrapper
260async def add_team_callbacks(
261 data: AddTeamCallback,
262 http_request: Request,
263 team_id: str,
264 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
265 litellm_changed_by: str | None = Header(
266 None,
267 description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability",
268 ),
269):
270 """
271 Add a success/failure callback to a team
273 Use this if if you want different teams to have different success/failure callbacks
275 Parameters:
276 - callback_name (str, required): The name of the callback to add, e.g. "langfuse", "langsmith", "gcs", "newrelic". The value is validated against the callbacks that support team-scoped credentials
277 - callback_type (Literal["success", "failure", "success_and_failure"], required): The type of callback to add. One of:
278 - "success": Callback for successful LLM calls
279 - "failure": Callback for failed LLM calls
280 - "success_and_failure": Callback for both successful and failed LLM calls
281 - callback_vars (StandardCallbackDynamicParams, required): A dictionary of variables to pass to the callback
282 - langfuse_public_key: The public key for the Langfuse callback
283 - langfuse_secret_key: The secret key for the Langfuse callback
284 - langfuse_secret: The secret for the Langfuse callback
285 - langfuse_host: The host for the Langfuse callback
286 - langfuse_environment: The tracing environment for the Langfuse callback (lowercase; falls back to LANGFUSE_TRACING_ENVIRONMENT)
287 - langfuse_span_scope: For langfuse_otel, "full" (default) sends the whole request trace, "llm_only" sends only the model-call spans
288 - gcs_bucket_name: The name of the GCS bucket
289 - gcs_path_service_account: The path to the GCS service account
290 - langsmith_api_key: The API key for the Langsmith callback
291 - langsmith_project: The project for the Langsmith callback
292 - langsmith_base_url: The base URL for the Langsmith callback
293 - newrelic_api_key: The ingest license key for the team's New Relic account; routes both LLM/agent traces and cost metrics to that account. Requires the proxy to run with LITELLM_OTEL_V2=true, otherwise this callback is rejected with a 400
294 - newrelic_region: The New Relic region for the team's account ("us" or "eu"), riding the team's own key
296 Example curl:
297 ```
298 curl -X POST 'http:/localhost:4000/team/dbe2f686-a686-4896-864a-4c3924458709/callback' \
299 -H 'Content-Type: application/json' \
300 -H 'Authorization: Bearer sk-1234' \
301 -d '{
302 "callback_name": "langfuse",
303 "callback_type": "success",
304 "callback_vars": {"langfuse_public_key": "pk-lf-xxxx1", "langfuse_secret_key": "sk-xxxxx"}
306 }'
307 ```
309 This means for the team where team_id = dbe2f686-a686-4896-864a-4c3924458709, all LLM calls will be logged to langfuse using the public key pk-lf-xxxx1 and the secret key sk-xxxxx
311 """
312 try:
313 from litellm.proxy._types import CommonProxyErrors
314 from litellm.proxy.proxy_server import (
315 prisma_client,
316 proxy_logging_obj,
317 user_api_key_cache,
318 )
320 if prisma_client is None: 320 ↛ 321line 320 didn't jump to line 321 because the condition on line 320 was never true
321 raise HTTPException(
322 status_code=500,
323 detail={"error": CommonProxyErrors.db_not_connected_error.value},
324 )
326 # Check if team_id exists already
327 _existing_team = await prisma_client.get_data(team_id=team_id, table_name="team", query_type="find_unique")
328 if _existing_team is None:
329 raise _unknown_team_error(team_id, user_api_key_dict, status.HTTP_400_BAD_REQUEST)
331 # IDOR guard: only proxy admins / org admins / team admins of THIS
332 # team may write callback credentials. Without this, any
333 # authenticated key holder could overwrite another team's logging
334 # config (and read back the credentials they wrote).
335 await _verify_team_access(
336 team_obj=LiteLLM_TeamTable(**_existing_team.model_dump()),
337 user_api_key_dict=user_api_key_dict,
338 )
340 _validate_team_callback(data)
342 # store team callback settings in metadata
343 team_metadata = _existing_team.metadata
344 team_callback_settings: list[dict] = team_metadata.get("logging") # will be dict of type AddTeamCallback
345 if team_callback_settings is None or not isinstance(team_callback_settings, list):
346 team_callback_settings = []
348 # Decrypted, because the checks compare the incoming values against
349 # the stored ones and the credentials are encrypted at rest.
350 decrypted_logging: Final = decrypt_callback_vars(team_metadata).get("logging")
351 stored_entries: Final = decrypted_logging if isinstance(decrypted_logging, list) else ()
352 stored_entry_vars: Final = [ # mutable-ok: read-only input to the checks, never stored
353 entry.get("callback_vars") or {} for entry in stored_entries
354 ]
355 scope_error: Final = conflicting_span_scope_error(data.callback_vars, stored_entry_vars)
356 if scope_error is not None: 356 ↛ 357line 356 didn't jump to line 357 because the condition on line 356 was never true
357 raise _callback_config_error(scope_error)
358 # One entry has to own a credential family end to end. The entries are
359 # flattened into one dict before a request reads them, so an entry
360 # naming only a destination would pair with a key written on another
361 # entry and carry it to that destination -- a key a team admin can read
362 # back nowhere. Repeating a value the owning entry already stores is
363 # fine, which is how one integration covers both events. Proxy admins
364 # are exempt: they already hold every credential the proxy has.
365 if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: 365 ↛ 366line 365 didn't jump to line 366 because the condition on line 365 was never true
366 family_error: Final = cross_entry_family_error(data.callback_vars, stored_entry_vars)
367 if family_error is not None:
368 raise HTTPException(
369 status_code=status.HTTP_400_BAD_REQUEST,
370 detail=family_error,
371 )
373 ## check if it already exists, for the same callback event
374 for callback in team_callback_settings:
375 if (
376 callback.get("callback_name") == data.callback_name
377 and callback.get("callback_type") == data.callback_type
378 ):
379 raise ProxyException(
380 message=f"callback_name = {data.callback_name} already exists in team_callback_settings, for team_id = {team_id} and event = {data.callback_type}",
381 code=status.HTTP_400_BAD_REQUEST,
382 type=ProxyErrorTypes.bad_request_error,
383 param="callback_name",
384 )
386 before_metadata: Final = copy.deepcopy(team_metadata)
387 team_callback_settings.append(data.model_dump())
389 team_metadata["logging"] = team_callback_settings
390 team_metadata = encrypt_callback_vars(team_metadata)
391 team_metadata_json: Final = json.dumps(team_metadata) # update team_metadata
393 new_team_row: Final = await TeamRepository(prisma_client).table.update(
394 where={"team_id": team_id},
395 data={"metadata": team_metadata_json},
396 # `object_permission` is included so `_refresh_cached_team` doesn't
397 # write a cached team with the relation nulled out — see
398 # team_model_add for the full rationale.
399 include={"object_permission": True}, # mutable-ok: prisma include takes a dict literal
400 )
402 if new_team_row is None: 402 ↛ 403line 402 didn't jump to line 403 because the condition on line 402 was never true
403 raise _callback_error(400, f"Team id = {team_id} does not exist. Please use a different team id.")
405 # Without this a newly registered callback stays dormant for existing keys.
406 await _refresh_cached_team(
407 team_row=new_team_row,
408 user_api_key_cache=user_api_key_cache,
409 proxy_logging_obj=proxy_logging_obj,
410 )
412 await _emit_team_callback_audit_log(
413 team_id=team_id,
414 before_metadata=before_metadata,
415 after_metadata=team_metadata,
416 user_api_key_dict=user_api_key_dict,
417 litellm_changed_by=litellm_changed_by,
418 )
420 return {
421 "status": "success",
422 "data": new_team_row,
423 }
425 except HTTPException as e:
426 raise e
427 except ProxyException as e:
428 raise e
429 except Exception as e:
430 verbose_proxy_logger.exception("litellm.proxy.proxy_server.add_team_callbacks(): Exception occured - %s", e)
431 raise ProxyException(
432 message="Internal Server Error, " + str(e),
433 type=ProxyErrorTypes.internal_server_error.value,
434 param=getattr(e, "param", "None"),
435 code=status.HTTP_500_INTERNAL_SERVER_ERROR,
436 )
439@router.delete(
440 "/team/{team_id:path}/callback/{callback_name}",
441 tags=["team management"], # mutable-ok: FastAPI's route decorator takes a list of tags
442 dependencies=[Depends(user_api_key_auth)], # mutable-ok: FastAPI's route decorator takes a list of dependencies
443 response_model=TeamCallbackDeleteResponse,
444)
445@management_endpoint_wrapper
446async def delete_team_callback(
447 http_request: Request,
448 team_id: str,
449 callback_name: str,
450 user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
451 litellm_changed_by: Annotated[
452 str | None,
453 Header(
454 description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability"
455 ),
456 ] = None,
457):
458 """
459 Remove a single callback from a team
461 The team's other callbacks stay registered and keep firing. Use this instead of
462 POST /team/{team_id}/disable_logging, which clears every callback on the team at once.
464 Every entry registered under this callback_name is removed, across callback types, so a
465 callback registered for both "success" and "failure" is deregistered by one call.
467 Parameters:
468 - team_id (str, required): The unique identifier for the team
469 - callback_name (str, required): The name of the callback to remove, matched exactly as it was
470 registered with POST /team/{team_id}/callback (e.g. "langfuse", "langsmith", "gcs")
472 Example curl:
473 ```
474 curl -X DELETE 'http://localhost:4000/team/dbe2f686-a686-4896-864a-4c3924458709/callback/langsmith' \
475 -H 'Authorization: Bearer sk-1234'
476 ```
478 Covers callbacks registered through POST /team/{team_id}/callback and the Admin UI. Teams still
479 on the deprecated callback_settings metadata shape hold no such entries, so this returns 404 for
480 them; POST /team/{team_id}/disable_logging remains the way to clear those.
482 Returns 404 if the team does not exist, or if callback_name is not registered for the team.
483 """
484 try:
485 from litellm.proxy._types import CommonProxyErrors
486 from litellm.proxy.proxy_server import (
487 prisma_client,
488 proxy_logging_obj,
489 user_api_key_cache,
490 )
492 if prisma_client is None: 492 ↛ 493line 492 didn't jump to line 493 because the condition on line 492 was never true
493 raise _callback_error(500, CommonProxyErrors.db_not_connected_error.value)
495 _existing_team: Final = await prisma_client.get_data(
496 team_id=team_id, table_name="team", query_type="find_unique"
497 )
498 if _existing_team is None:
499 raise _unknown_team_error(team_id, user_api_key_dict, status.HTTP_404_NOT_FOUND)
501 # IDOR guard: only proxy admins / org admins / team admins of THIS team may
502 # deregister its callbacks, otherwise any authenticated key holder could
503 # silence another team's observability integration.
504 await _verify_team_access(
505 team_obj=LiteLLM_TeamTable(**_existing_team.model_dump()),
506 user_api_key_dict=user_api_key_dict,
507 )
509 team_metadata: Final = _existing_team.metadata
510 registered_callbacks: Final = team_metadata.get("logging")
511 entries: Final = registered_callbacks if isinstance(registered_callbacks, list) else ()
513 remaining_callbacks: Final = [ # mutable-ok: metadata["logging"] is isinstance-checked for list downstream
514 entry for entry in entries if not (isinstance(entry, dict) and entry.get("callback_name") == callback_name)
515 ]
516 if len(remaining_callbacks) == len(entries): 516 ↛ 517line 516 didn't jump to line 517 because the condition on line 516 was never true
517 raise _callback_error(404, f"callback_name = {callback_name} is not registered for team_id = {team_id}.")
519 updated_metadata: Final = {**team_metadata, "logging": remaining_callbacks} # mutable-ok: persisted as JSON
520 encrypted_metadata: Final[object] = encrypt_callback_vars(updated_metadata)
521 team_metadata_json: Final = json.dumps(encrypted_metadata)
523 updated_team: Final = await TeamRepository(prisma_client).table.update(
524 where={"team_id": team_id}, # mutable-ok: prisma where takes a dict literal
525 data={"metadata": team_metadata_json}, # mutable-ok: prisma data takes a dict literal
526 # `object_permission` is included so `_refresh_cached_team` doesn't write a
527 # cached team with the relation nulled out, see team_model_add for the rationale.
528 include={"object_permission": True}, # mutable-ok: prisma include takes a dict literal
529 )
531 if updated_team is None: 531 ↛ 532line 531 didn't jump to line 532 because the condition on line 531 was never true
532 raise _callback_error(404, f"Team id = {team_id} does not exist. Error removing team callback")
534 # Request-time callback resolution reads the cached team, so without this
535 # the removed callback keeps firing for live keys until the cache expires.
536 await _refresh_cached_team(
537 team_row=updated_team,
538 user_api_key_cache=user_api_key_cache,
539 proxy_logging_obj=proxy_logging_obj,
540 )
542 await _emit_team_callback_audit_log(
543 team_id=team_id,
544 before_metadata=team_metadata,
545 after_metadata=encrypted_metadata,
546 user_api_key_dict=user_api_key_dict,
547 litellm_changed_by=litellm_changed_by,
548 )
550 # Report what survives with the same resolution the GET endpoint uses, so a
551 # caller can confirm in one round trip that its other callbacks are intact.
552 surviving: Final = _resolve_team_callbacks(encrypted_metadata)
554 response: Final = TeamCallbackDeleteResponse(
555 status="success",
556 message=f"Callback {callback_name} removed for team {team_id}",
557 data=TeamCallbackDeleteResponseData(
558 team_id=team_id,
559 success_callbacks=tuple(surviving.success_callback or ()),
560 failure_callbacks=tuple(surviving.failure_callback or ()),
561 ),
562 )
564 except HTTPException:
565 # Legitimate 4xx (403 from the access guard, 404 for an unknown team or
566 # an unregistered callback). Re-raise without the error-level log noise
567 # the catch-all below would produce.
568 raise
569 except ProxyException:
570 raise
571 except Exception as e:
572 verbose_proxy_logger.error("litellm.proxy.proxy_server.delete_team_callback(): Exception occurred - %s", e)
573 verbose_proxy_logger.debug(traceback.format_exc())
574 raise ProxyException(
575 message="Internal Server Error, " + str(e),
576 type=ProxyErrorTypes.internal_server_error.value,
577 param=getattr(e, "param", "None"),
578 code=status.HTTP_500_INTERNAL_SERVER_ERROR,
579 )
580 else:
581 return response
584@router.post(
585 "/team/{team_id}/disable_logging",
586 tags=["team management"],
587 dependencies=[Depends(user_api_key_auth)],
588)
589@management_endpoint_wrapper
590async def disable_team_logging(
591 http_request: Request,
592 team_id: str,
593 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
594 litellm_changed_by: str | None = Header(
595 None,
596 description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability",
597 ),
598):
599 """
600 Disable all logging callbacks for a team
602 Callbacks registered through POST /team/{team_id}/callback and the Admin UI are cleared, so
603 re-enabling logging means registering them again with their callback_vars
605 Parameters:
606 - team_id (str, required): The unique identifier for the team
608 Example curl:
609 ```
610 curl -X POST 'http://localhost:4000/team/dbe2f686-a686-4896-864a-4c3924458709/disable_logging' \
611 -H 'Authorization: Bearer sk-1234'
612 ```
615 """
616 try:
617 from litellm.proxy.proxy_server import (
618 prisma_client,
619 proxy_logging_obj,
620 user_api_key_cache,
621 )
623 if prisma_client is None: 623 ↛ 624line 623 didn't jump to line 624 because the condition on line 623 was never true
624 raise HTTPException(status_code=500, detail={"error": "No db connected"})
626 # Check if team exists
627 _existing_team = await prisma_client.get_data(team_id=team_id, table_name="team", query_type="find_unique")
628 if _existing_team is None:
629 raise HTTPException(
630 status_code=404,
631 detail={"error": f"Team id = {team_id} does not exist."},
632 )
634 # IDOR guard: only proxy admins / org admins / team admins of THIS
635 # team may disable its logging — otherwise any authenticated key
636 # holder can silence audit logging for any team.
637 await _verify_team_access(
638 team_obj=LiteLLM_TeamTable(**_existing_team.model_dump()),
639 user_api_key_dict=user_api_key_dict,
640 )
642 # Update team metadata to disable logging
643 team_metadata = _existing_team.metadata
644 before_metadata: Final = copy.deepcopy(team_metadata)
645 team_callback_settings: Final = team_metadata.get("callback_settings", {})
646 team_callback_settings_obj: Final = TeamCallbackMetadata(**team_callback_settings)
648 # Reset callbacks
649 team_callback_settings_obj.success_callback = []
650 team_callback_settings_obj.failure_callback = []
652 # Update metadata
653 team_metadata["callback_settings"] = team_callback_settings_obj.model_dump()
654 # _get_dynamic_logging_metadata stops at metadata["logging"], where the API
655 # and Admin UI register callbacks, without ever reading callback_settings.
656 team_metadata["logging"] = [] # mutable-ok: the disabled state is persisted as an empty JSON array
657 encrypted_metadata: Final[object] = encrypt_callback_vars(team_metadata)
658 team_metadata_json: Final = json.dumps(encrypted_metadata)
660 # Update team in database
661 updated_team: Final = await TeamRepository(prisma_client).table.update(
662 where={"team_id": team_id},
663 data={"metadata": team_metadata_json},
664 # `object_permission` is included so `_refresh_cached_team` doesn't
665 # write a cached team with the relation nulled out — see
666 # team_model_add for the full rationale.
667 include={"object_permission": True}, # mutable-ok: prisma include takes a dict literal
668 )
670 if updated_team is None: 670 ↛ 671line 670 didn't jump to line 671 because the condition on line 670 was never true
671 raise HTTPException(
672 status_code=404,
673 detail={"error": f"Team id = {team_id} does not exist. Error updating team logging"},
674 )
676 # Request-time callback resolution reads the cached team, so without this
677 # the DB says logging is off while live keys keep sending until it expires.
678 await _refresh_cached_team(
679 team_row=updated_team,
680 user_api_key_cache=user_api_key_cache,
681 proxy_logging_obj=proxy_logging_obj,
682 )
684 # Disabling a team's logging callbacks is itself a logging-control
685 # action — emit an audit-log row so the action remains traceable
686 # even though the team's own observability is now off.
687 await _emit_team_callback_audit_log(
688 team_id=team_id,
689 before_metadata=before_metadata,
690 after_metadata=encrypted_metadata,
691 user_api_key_dict=user_api_key_dict,
692 litellm_changed_by=litellm_changed_by,
693 )
695 return {
696 "status": "success",
697 "message": f"Logging disabled for team {team_id}",
698 "data": {
699 "team_id": updated_team.team_id,
700 "success_callbacks": [],
701 "failure_callbacks": [],
702 },
703 }
705 except HTTPException:
706 # Legitimate 4xx (e.g. 403 from the access guard, 404 for an
707 # unknown team). Re-raise without the error-level log noise that
708 # the catch-all branch below would produce.
709 raise
710 except ProxyException:
711 raise
712 except Exception as e:
713 verbose_proxy_logger.error("litellm.proxy.proxy_server.disable_team_logging(): Exception occurred - %s", e)
714 verbose_proxy_logger.debug(traceback.format_exc())
715 raise ProxyException(
716 message="Internal Server Error, " + str(e),
717 type=ProxyErrorTypes.internal_server_error.value,
718 param=getattr(e, "param", "None"),
719 code=status.HTTP_500_INTERNAL_SERVER_ERROR,
720 )
723@router.get(
724 "/team/{team_id:path}/callback",
725 tags=["team management"],
726 dependencies=[Depends(user_api_key_auth)],
727)
728@management_endpoint_wrapper
729async def get_team_callbacks(
730 http_request: Request,
731 team_id: str,
732 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
733):
734 """
735 Get the success/failure callbacks and variables for a team
737 Parameters:
738 - team_id (str, required): The unique identifier for the team
740 Example curl:
741 ```
742 curl -X GET 'http://localhost:4000/team/dbe2f686-a686-4896-864a-4c3924458709/callback' \
743 -H 'Authorization: Bearer sk-1234'
744 ```
746 This will return the callback settings for the team with id dbe2f686-a686-4896-864a-4c3924458709
748 Covers callbacks registered through POST /team/{team_id}/callback and the Admin UI as well as
749 teams still on the deprecated callback_settings shape, resolved from the team's stored metadata
750 with the same precedence used at request time. A key-level logging config overrides the team's
751 at request time and is not reflected here. Credential-bearing callback_vars are returned masked
752 as `***REDACTED***`
754 Returns {
755 "status": "success",
756 "data": {
757 "team_id": team_id,
758 "success_callbacks": team_callback_settings_obj.success_callback,
759 "failure_callbacks": team_callback_settings_obj.failure_callback,
760 "callback_vars": team_callback_settings_obj.callback_vars,
761 },
762 }
763 """
764 try:
765 from litellm.proxy.proxy_server import prisma_client
767 if prisma_client is None: 767 ↛ 768line 767 didn't jump to line 768 because the condition on line 767 was never true
768 raise HTTPException(status_code=500, detail={"error": "No db connected"})
770 # Check if team_id exists
771 _existing_team = await prisma_client.get_data(team_id=team_id, table_name="team", query_type="find_unique")
772 if _existing_team is None:
773 raise _unknown_team_error(team_id, user_api_key_dict, status.HTTP_404_NOT_FOUND)
775 # IDOR guard: callback metadata holds third-party API credentials
776 # (Langfuse / Langsmith / GCS). Only proxy admins / org admins /
777 # team admins of THIS team may read them.
778 await _verify_team_access(
779 team_obj=LiteLLM_TeamTable(**_existing_team.model_dump()),
780 user_api_key_dict=user_api_key_dict,
781 )
783 team_callback_settings_obj: Final = _resolve_team_callbacks(_existing_team.metadata)
785 return {
786 "status": "success",
787 "data": {
788 "team_id": team_id,
789 "success_callbacks": team_callback_settings_obj.success_callback,
790 "failure_callbacks": team_callback_settings_obj.failure_callback,
791 "callback_vars": team_callback_settings_obj.callback_vars,
792 },
793 }
795 except HTTPException:
796 # Legitimate 4xx (e.g. 403 from the access guard) — re-raise
797 # without the error-level log noise that the catch-all below
798 # would produce.
799 raise
800 except ProxyException:
801 raise
802 except Exception as e:
803 verbose_proxy_logger.error("litellm.proxy.proxy_server.get_team_callbacks(): Exception occurred - %s", e)
804 verbose_proxy_logger.debug(traceback.format_exc())
805 if isinstance(e, HTTPException):
806 raise ProxyException(
807 message=getattr(e, "detail", f"Internal Server Error({e})"),
808 type=ProxyErrorTypes.internal_server_error.value,
809 param=getattr(e, "param", "None"),
810 code=getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR),
811 )
812 elif isinstance(e, ProxyException):
813 raise e
814 raise ProxyException(
815 message="Internal Server Error, " + str(e),
816 type=ProxyErrorTypes.internal_server_error.value,
817 param=getattr(e, "param", "None"),
818 code=status.HTTP_500_INTERNAL_SERVER_ERROR,
819 )