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

1""" 

2Endpoints to control callbacks per team 

3 

4Use this when each team should control its own callbacks 

5""" 

6 

7import asyncio 

8import copy 

9import json 

10import traceback 

11from datetime import datetime, timezone 

12from typing import Annotated, Final 

13 

14from fastapi import APIRouter, Depends, Header, HTTPException, Request, status 

15 

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 

53 

54router: Final = APIRouter() 

55 

56 

57_CALLBACK_VARS_REDACTED: Final = "***REDACTED***" 

58 

59 

60def _callback_config_error(message: str) -> HTTPException: 

61 return HTTPException(status_code=400, detail={"error": message}) # mutable-ok: FastAPI detail contract 

62 

63 

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) 

68 

69 

70def _redact_callback_secrets(metadata: object) -> object: 

71 """Strip secret values out of a team-metadata snapshot before audit logging. 

72 

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 

94 

95 

96def _mask_sensitive_callback_vars(callbacks: TeamCallbackMetadata) -> None: 

97 """Mask credential-bearing callback vars in place, keeping the rest readable. 

98 

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. 

104 

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. 

109 

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 

121 

122 

123def _resolve_team_callbacks(team_metadata: object) -> TeamCallbackMetadata: 

124 """Report the callbacks that are actually in effect for a team. 

125 

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. 

136 

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() 

143 

144 decrypted: Final = decrypt_callback_vars(team_metadata) 

145 logging_entries: Final = decrypted.get("logging") 

146 

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 ) 

161 

162 _mask_sensitive_callback_vars(resolved) 

163 return resolved 

164 

165 

166def _log_audit_task_exception(task: "asyncio.Task[None]") -> None: 

167 """Surface a fire-and-forget audit-log task failure. 

168 

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) 

179 

180 

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. 

190 

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. 

196 

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 

205 

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 

208 

209 redacted_before: Final = _redact_callback_secrets(before_metadata) 

210 redacted_after: Final = _redact_callback_secrets(after_metadata) 

211 

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) 

228 

229 

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 ) 

236 

237 

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. 

240 

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 ) 

252 

253 

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 

272 

273 Use this if if you want different teams to have different success/failure callbacks 

274 

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 

295 

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"} 

305  

306 }' 

307 ``` 

308 

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 

310 

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 ) 

319 

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 ) 

325 

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) 

330 

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 ) 

339 

340 _validate_team_callback(data) 

341 

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 = [] 

347 

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 ) 

372 

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 ) 

385 

386 before_metadata: Final = copy.deepcopy(team_metadata) 

387 team_callback_settings.append(data.model_dump()) 

388 

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 

392 

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 ) 

401 

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.") 

404 

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 ) 

411 

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 ) 

419 

420 return { 

421 "status": "success", 

422 "data": new_team_row, 

423 } 

424 

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 ) 

437 

438 

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 

460 

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. 

463 

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. 

466 

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") 

471 

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 ``` 

477 

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. 

481 

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 ) 

491 

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) 

494 

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) 

500 

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 ) 

508 

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 () 

512 

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}.") 

518 

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) 

522 

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 ) 

530 

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") 

533 

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 ) 

541 

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 ) 

549 

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) 

553 

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 ) 

563 

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 

582 

583 

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 

601 

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 

604 

605 Parameters: 

606 - team_id (str, required): The unique identifier for the team 

607 

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 ``` 

613 

614 

615 """ 

616 try: 

617 from litellm.proxy.proxy_server import ( 

618 prisma_client, 

619 proxy_logging_obj, 

620 user_api_key_cache, 

621 ) 

622 

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"}) 

625 

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 ) 

633 

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 ) 

641 

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) 

647 

648 # Reset callbacks 

649 team_callback_settings_obj.success_callback = [] 

650 team_callback_settings_obj.failure_callback = [] 

651 

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) 

659 

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 ) 

669 

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 ) 

675 

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 ) 

683 

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 ) 

694 

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 } 

704 

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 ) 

721 

722 

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 

736 

737 Parameters: 

738 - team_id (str, required): The unique identifier for the team 

739 

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 ``` 

745 

746 This will return the callback settings for the team with id dbe2f686-a686-4896-864a-4c3924458709 

747 

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***` 

753 

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 

766 

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"}) 

769 

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) 

774 

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 ) 

782 

783 team_callback_settings_obj: Final = _resolve_team_callbacks(_existing_team.metadata) 

784 

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 } 

794 

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 )