Skip to content

fix(team-callbacks): report API-registered callbacks from GET /team/{team_id}/callback - #35512

Merged
yucheng-berri merged 3 commits into
litellm_internal_stagingfrom
litellm_lit5093_team_callback_get
Aug 2, 2026
Merged

fix(team-callbacks): report API-registered callbacks from GET /team/{team_id}/callback#35512
yucheng-berri merged 3 commits into
litellm_internal_stagingfrom
litellm_lit5093_team_callback_get

Conversation

@yucheng-berri

@yucheng-berri yucheng-berri commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • GET /team/{team_id}/callback always returned an empty list
  • Callbacks registered via the API or Admin UI were invisible to the API
  • Programmatic callback management had no data to work with

How it solves it:

  • Read the metadata slot the POST actually writes
  • Apply the same precedence request-time resolution uses
  • Mask credential values instead of returning ciphertext

Relevant issues

Linear ticket

Resolves LIT-5093

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Root cause

POST /team/{team_id}/callback writes its entries to metadata["logging"], while GET /team/{team_id}/callback read metadata["callback_settings"]. Those are two different slots in the team row, so the GET reported nothing for any team configured through the API or the dashboard.

The divergence came from c620d76fe4 (2025-09-17), which migrated the writer in add_team_callbacks to the newer logging key and left the reader on the old one. The Admin UI kept working because it reads metadata.logging from /team/info rather than calling this endpoint.

Approach

_resolve_team_callbacks mirrors the precedence that _get_dynamic_logging_metadata applies at request time: a logging slot that is present wins outright, and callback_settings is consulted only as the deprecated fallback. The two are resolved as mutually exclusive rather than merged, because merging would report a callback_settings destination as active for a team whose requests never send to it. An empty logging list therefore reports no callbacks, which is what such a team's requests actually do.

callback_vars are decrypted for the response and credential keys are masked with the marker the audit-log redaction in this file already uses. The masking predicate is is_sensitive_callback_key, the same predicate that decides what gets encrypted on write, so read masking and write encryption cannot disagree. Non-secret configuration such as project names, bucket names, and hosts stays readable, which is what makes the response useful.

Behavior changes

  • callback_vars credential values now come back as ***REDACTED***. Previously they were returned as stored: ciphertext for rows written after callback encryption landed, and plaintext for rows predating it. No first-party consumer reads secrets back from this endpoint; the repo, ui/litellm-dashboard, litellm/proxy/client/, the e2e suites, and the docs were swept and only a POST-side helper exists.
  • A team row whose metadata is not a JSON object previously produced a 500 from an AttributeError and now returns the documented empty shape.
  • A callback var that cannot be decrypted is masked rather than returned. The shared decrypt helper passes such a value through untouched, which happens to existing rows after a salt-key rotation, and under a key that is not classified as sensitive it would otherwise reach the caller as opaque ciphertext indistinguishable from a real value.
  • Entries under logging that fail AddTeamCallback validation are skipped rather than failing the read, matching how request-time resolution treats them. Such entries do not fire callbacks either, so the response stays consistent with behavior.

Known limitation, tracked separately

The masked value is a marker, not an absence, and the write paths have no rule for it. A caller that reads this endpoint and posts the payload back, for example a script that clones one team's logging config onto another, stores the literal ***REDACTED*** as the credential. It is encrypted at rest like any real secret, so the row looks normal and that team's logging silently stops working. Reproduced on a live proxy: the source team keeps lsv2-REAL-WORKING-KEY while the destination team ends up holding ***REDACTED***.

This is not a regression for any consumer that exists. Nothing in the repo reads this endpoint; the sweep covered ui/litellm-dashboard/src, litellm/proxy/client/, the e2e suites, cookbooks and load tests, and the only caller anywhere is a POST-only e2e helper that reads status with extra="ignore". The same clone flow did work before this PR, but only for pre-encryption rows and only because the endpoint handed back the credential in the clear, which is the exposure this PR closes; for teams configured through the API or the Admin UI the endpoint returned nothing at all, so there was no working flow to preserve.

Tracked as LIT-5110 rather than fixed here, since the likely fix is to stop returning the credential keys at all rather than to add marker-restore logic to four separate write paths.

Scope

Limited to the GET. Two adjacent problems in the same area were found while reproducing this one and are tracked separately rather than bundled here:

  • LIT-5101: POST /team/{team_id}/disable_logging clears only callback_settings, so it is a no-op for teams configured through the API. Confirmed on the live rig: the endpoint returns success while the callback keeps delivering.
  • LIT-5102: /team/info, /team/list, /v2/team/list, and /key/info return team callback callback_vars unmasked, on a wider access gate than this endpoint.

Screenshots / Proof of Fix

Live proxy with real Postgres and a real Bedrock model (us.anthropic.claude-haiku-4-5-20251001-v1:0). No mocks.

Before, at base b1fd20f4cd. Register a callback, then read it back:

$ curl -sS -X POST "$P/team/$TEAM/callback" -H "$K" -H "$J" \
  -d '{"callback_name":"langsmith","callback_type":"success","callback_vars":{"langsmith_api_key":"lsv2-secret-abc","langsmith_project":"lit5093-proj"}}'
HTTP 200   {"status":"success", ...}

$ curl -sS -X GET "$P/team/$TEAM/callback" -H "$K"
{"status":"success","data":{"team_id":"96278faf-892e-4eb2-a307-243ff007b797","success_callbacks":[],"failure_callbacks":[],"callback_vars":{}}}
HTTP 200

The data was written correctly the whole time; /team/info on the same row shows where it went:

{
  "logging": [
    {
      "callback_name": "langsmith",
      "callback_type": "success",
      "callback_vars": {
        "langsmith_api_key": "litellm_enc::-8GKg2WuRqi13MVFtFarpxi7XCnvBJaVpwQF1xW4_KpvmnxA1b-qUcN5qHt2eYW_3ZXcuF60iA==",
        "langsmith_project": "lit5093-proj"
      }
    }
  ]
}

After, at f02f4c1813, same team row written by the unfixed build, same curl, no re-registration:

$ curl -sS -X GET "$P/team/$TEAM/callback" -H "$K"
{
    "status": "success",
    "data": {
        "team_id": "96278faf-892e-4eb2-a307-243ff007b797",
        "success_callbacks": ["langsmith"],
        "failure_callbacks": ["langfuse"],
        "callback_vars": {
            "langsmith_api_key": "***REDACTED***",
            "langsmith_project": "lit5093-proj",
            "langfuse_public_key": "***REDACTED***",
            "langfuse_secret_key": "***REDACTED***"
        }
    }
}

Edge cases on the same live rig:

legacy callback_settings only   success=['gcs_bucket'] failure=['langfuse'] vars={'gcs_bucket_name': 'legacy-bucket', 'langfuse_secret_key': '***REDACTED***'}
both shapes present             success=['langsmith']  failure=['langsmith'] vars={'langsmith_project': 'tenant-proj'}
empty logging slot              success=[]             failure=[]            vars={}
malformed entry skipped         success=['langsmith']  failure=[]            vars={'langsmith_project': 'ok-proj'}
no callbacks configured         success=[]             failure=[]            vars={}
non-admin caller                HTTP 401, access guard intact

The response is only correct if it agrees with what a request would really do, so every team row on the rig was resolved through both this endpoint's resolver and the real _get_dynamic_logging_metadata:

lit5093-both             runtime=(['langsmith'], ['langsmith'])   GET=(['langsmith'], ['langsmith'])   -> MATCH
lit5093-empty            runtime=([], [])                        GET=([], [])                         -> MATCH
lit5093-emptylogging     runtime=([], [])                        GET=([], [])                         -> MATCH
lit5093-legacy           runtime=(['gcs_bucket'], ['langfuse'])  GET=(['gcs_bucket'], ['langfuse'])   -> MATCH
lit5093-legacy2          runtime=(['gcs_bucket'], ['langfuse'])  GET=(['gcs_bucket'], ['langfuse'])   -> MATCH
lit5093-malformed        runtime=(['langsmith'], [])             GET=(['langsmith'], [])              -> MATCH
lit5093-repro            runtime=(['langsmith'], ['langfuse'])   GET=(['langsmith'], ['langfuse'])    -> MATCH
lit5093-runtime          runtime=(['langsmith'], [])             GET=(['langsmith'], [])              -> MATCH

mismatches: 0

Mutation matrix over the six new tests, each mutation applied to the fixed source and asserted to be a real edit before the suite ran:

pre-fix reader (callback_settings only)   killed, 5 tests
decryption removed                        killed, 1 test
masking removed                           killed, 2 tests
union instead of precedence               killed, 1 test
invalid-entry skip removed                killed, 1 test

End-to-end evidence

Captured after the fix, on the live rig, with the step order deliberately inverted from the verify run: a fresh team, read before any write, and the failure callback registered before the success one.

step 1  GET before any callback        {"success_callbacks":[],"failure_callbacks":[],"callback_vars":{}}   HTTP 200
step 2  POST langfuse / failure                                                                            HTTP 200
step 3  GET   success=[]  failure=['langfuse']  vars={langfuse_host: https://cloud.langfuse.com,
                                                     langfuse_public_key: ***REDACTED***,
                                                     langfuse_secret_key: ***REDACTED***}
step 4  POST langsmith / success_and_failure                                                               HTTP 200
step 5  GET   success=['langsmith']  failure=['langfuse', 'langsmith']
              vars={langfuse_host: https://cloud.langfuse.com, langfuse_public_key: ***REDACTED***,
                    langfuse_secret_key: ***REDACTED***, langsmith_api_key: ***REDACTED***,
                    langsmith_project: e2e-proj}

A success_and_failure registration lands in both lists, the non-secret langfuse_host stays readable, and every credential is masked.

The response is only worth anything if it names the callbacks that really run, so the last check ties the report to an actual delivery. A team was given a langsmith callback pointed at a local HTTP collector, then a real Bedrock request was sent on a key scoped to that team:

what the endpoint REPORTS:
   success_callbacks = ['langsmith']
   langsmith_project = correlate-proj
   langsmith_api_key = ***REDACTED***

real Bedrock call on a key scoped to that team:
   model: bedrock-claude
   content: verified
   usage: 17 tokens

what ACTUALLY fired: collector deliveries 2 -> 3 (delta 1)
   {"path": "/api/v1/runs/batch", "bytes": 11764}

The ticket noted the callback was visible in the Admin UI while the API returned nothing. Both now agree; the dashboard for the team above shows the same two integrations the API reports, Langfuse as Failure Only and LangSmith as Success and Failure:

Team logging settings in the Admin UI

No UI code is touched by this PR; the screenshot is there to show the dashboard and the API agree after the fix.

Type

🐛 Bug Fix

Changes

litellm/proxy/management_endpoints/team_callback_endpoints.py gains _resolve_team_callbacks and _mask_sensitive_callback_vars, and get_team_callbacks reads through the resolver. Six regression tests are added to the mapped test file, including a POST-then-GET round trip that feeds the GET exactly the payload the POST persisted so the two paths cannot drift apart again. schema.d.ts carries the regenerated docstring delta. A second commit masks callback vars that fail to decrypt, raised by Greptile against the first commit and covered by its own regression test.

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

…team_id}/callback

POST /team/{team_id}/callback writes metadata["logging"] while the GET read
metadata["callback_settings"], so every team configured through the API or the
Admin UI got back an empty list. c620d76 migrated the writer to the new key
and left this reader on the old one.

Resolve the read the same way request-time resolution does in
_get_dynamic_logging_metadata: a logging slot that is present wins outright and
callback_settings stays as the deprecated fallback, so the endpoint reports what
a request would really do rather than the union of both shapes. An empty logging
list therefore reports no callbacks, matching a request that fires none.

Decrypt callback_vars for the response and mask the credential keys. Ciphertext
would be unusable to the caller, and a value encrypted under a key that is no
longer classified as sensitive would otherwise come back as a raw blob.

Resolves LIT-5093
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

devin-ai-integration[bot]

This comment was marked as resolved.

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit f02f4c1. Configure here.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Open in Devin Review

Comment on lines +89 to +136
if not callbacks.callback_vars:
return
for key in tuple(callbacks.callback_vars):
if is_sensitive_callback_key(key):
callbacks.callback_vars[key] = _CALLBACK_VARS_REDACTED


def _resolve_team_callbacks(team_metadata: object) -> TeamCallbackMetadata:
"""Report the callbacks that are actually in effect for a team.

A team's callback config can live in either of two metadata slots.
``metadata["logging"]`` holds the ``AddTeamCallback`` entries written by
``POST /team/{team_id}/callback`` and by the Admin UI, while
``metadata["callback_settings"]`` holds the older ``TeamCallbackMetadata``
shape. Request-time resolution in ``_get_dynamic_logging_metadata`` treats
the two as mutually exclusive: a populated ``logging`` slot wins outright
and ``callback_settings`` is consulted only as the deprecated fallback.
This reader applies the same precedence so it reports what a request would
really do. Merging the two instead would report a ``callback_settings``
entry as active for a team whose requests never fire it.

Credential ``callback_vars`` are stored encrypted, so they are decrypted
before being masked by key; a value encrypted under a key that is no longer
classified as sensitive would otherwise come back as raw ciphertext.
"""
if not isinstance(team_metadata, dict):
return TeamCallbackMetadata()

decrypted = decrypt_callback_vars(team_metadata)
logging_entries = decrypted.get("logging")

if logging_entries is not None:
resolved = TeamCallbackMetadata()
for entry in logging_entries if isinstance(logging_entries, list) else ():
if not isinstance(entry, dict):
continue
callback = _get_validated_callback_metadata(item=entry, source="team-level read")
if callback is None:
continue
resolved = convert_key_logging_metadata_to_callback(data=callback, team_callback_settings_obj=resolved)
else:
callback_settings = decrypted.get("callback_settings")
resolved = (
TeamCallbackMetadata(**callback_settings) if isinstance(callback_settings, dict) else TeamCallbackMetadata()
)

_mask_sensitive_callback_vars(resolved)
return resolved

@devin-ai-integration devin-ai-integration Bot Aug 1, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 New callback resolution code builds results by repeated mutation instead of the required immutable style

The newly added resolution helper repeatedly overwrites its accumulator and mutates the credential mapping in place (resolved = convert_key_logging_metadata_to_callback(...) at litellm/proxy/management_endpoints/team_callback_endpoints.py:128), which the repository's coding guidelines explicitly disallow for new code.
Impact: The new code does not follow the project's mandated no-mutation/no-reassignment style, so it needs a rewrite before merge.

Rule source and affected code

CLAUDE.md requires for new/updated code: "No mutation; don't reassign variables, global or local. Instead of mutable lists and dicts, prefer tuples, frozen dataclasses..." and "build values in one shot with comprehensions or generators". _resolve_team_callbacks reassigns resolved inside the loop (litellm/proxy/management_endpoints/team_callback_endpoints.py:121-128), and _mask_sensitive_callback_vars (litellm/proxy/management_endpoints/team_callback_endpoints.py:89-93) mutates callbacks.callback_vars entries in place rather than constructing a new mapping.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@greptile-apps

greptile-apps Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR updates the team callback read endpoint to resolve API-registered callback metadata using request-time precedence and safely mask credentials.

  • Adds callback resolution across current and deprecated metadata shapes.
  • Masks sensitive and undecryptable callback values.
  • Adds regression coverage for round trips, precedence, malformed entries, legacy metadata, and decryption failures.
  • Regenerates the dashboard API schema documentation.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the previously reported undecryptable-value path now masks ciphertext that survives decryption, and the regression test exercises that behavior after a key change.

Important Files Changed

Filename Overview
litellm/proxy/management_endpoints/team_callback_endpoints.py Resolves callbacks from the active metadata slot and masks sensitive or undecryptable values; the previously reported ciphertext-return issue is fixed.
tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py Adds focused regression tests covering POST-to-GET behavior, metadata precedence, decryption failures, malformed entries, and legacy rows.
ui/litellm-dashboard/src/lib/http/schema.d.ts Regenerates the endpoint documentation to describe callback resolution and credential masking.

Reviews (2): Last reviewed commit: "fix(team-callbacks): mask callback vars ..." | Re-trigger Greptile

greptile-apps[bot]

This comment was marked as resolved.

decrypt_callback_vars passes a value through untouched when it cannot be
decrypted, which happens to existing rows after a salt-key rotation. Under a
key that is not classified as sensitive that blob reached the caller as opaque
ciphertext it could not use or tell apart from a real value, so mask anything
still carrying the encrypted prefix.

Raised by Greptile on the first commit.
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai please review the current head 582587d

The one concern from the previous review is addressed there: a value that fails to decrypt (existing rows after a salt-key rotation) is now masked rather than returned as ciphertext, since under a non-sensitive key it reached the caller as an opaque blob indistinguishable from a real value. Covered by test_get_team_callbacks_masks_values_that_fail_to_decrypt, which fails when the guard is removed.

@codecov

codecov Bot commented Aug 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.85714% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...xy/management_endpoints/team_callback_endpoints.py 92.85% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_lit5093_team_callback_get (582587d) with litellm_internal_staging (b1fd20f)

Open in CodSpeed

@yucheng-berri
yucheng-berri merged commit 7c3b578 into litellm_internal_staging Aug 2, 2026
84 checks passed
@yucheng-berri
yucheng-berri deleted the litellm_lit5093_team_callback_get branch August 2, 2026 00:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants