Skip to content

fix(batches): attribute managed batch spend when the creating key has no user_id - #35850

Closed
devin-ai-integration[bot] wants to merge 2 commits into
litellm_internal_stagingfrom
devin_ai_fix_managed_batch_spend_row_35358
Closed

fix(batches): attribute managed batch spend when the creating key has no user_id#35850
devin-ai-integration[bot] wants to merge 2 commits into
litellm_internal_stagingfrom
devin_ai_fix_managed_batch_spend_row_35358

Conversation

@devin-ai-integration

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Completed managed batch writes no spend row when the key has no user_id
  • batch_processed still flips to true, so the cost is lost forever
  • Team budgets never see managed batch spend

How it solves it:

  • Fall back to LITELLM_PROXY_ADMIN_NAME when created_by is NULL
  • Forward the job's team_id as user_api_key_team_id
  • Regression tests assert the emitted event is actually billable

Relevant issues

Fixes #35358

Linear ticket

Resolves LIT-5183

Pre-Submission checklist

  • 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

Screenshots / Proof of Fix

Caveat on realism: this session's network policy only allowlists github, linear and pypi, so api.openai.com is unreachable from the box. Everything in the run below is real (proxy, postgres, managed files and batches, the CheckBatchCost poller, the spend logging pipeline) except the provider, which is a local OpenAI-compatible /v1/files + /v1/batches stub that returns completed plus an output file with usage of 1000 prompt / 500 completion tokens on the first retrieve. That is enough to exercise the reported path end to end; the cost the proxy computes is real gpt-4o-mini batch pricing. Worth re-running against live OpenAI before merge on a box that can reach it.

Setup, identical for both runs:

uv run --no-sync litellm --config config.yaml --detailed_debug --port 4000   # PROXY_BATCH_POLLING_INTERVAL=5
curl -X POST localhost:4000/key/generate -H "Authorization: Bearer sk-1234" \
  -H "Content-Type: application/json" -d '{"models":["my-gpt"]}'            # note: no user_id
FILE=$(curl -sS -X POST localhost:4000/v1/files -H "Authorization: Bearer $KEY" \
  -F purpose=batch -F target_model_names=my-gpt -F file=@batch.jsonl | jq -r .id)
curl -sS -X POST localhost:4000/v1/batches -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d "{\"input_file_id\":\"$FILE\",\"endpoint\":\"/v1/chat/completions\",\"completion_window\":\"24h\"}"

Before, at 956d517: the poller reconciles the batch and marks it processed, and no billed row is ever written

psql> SELECT unified_object_id, status, batch_processed, created_by FROM "LiteLLM_ManagedObjectTable";
                                    unified_object_id                                    |  status  | batch_processed | created_by
-----------------------------------------------------------------------------------------+----------+-----------------+------------
 bGl0ZWxsbV9wcm94eTttb2RlbF9pZDpteS1ncHRfdW5pZmllZC0xO2xsbV9iYXRjaF9pZDpiYXRjaF9tb2NrXzE | complete | t               |

psql> SELECT call_type, model, prompt_tokens, completion_tokens, spend FROM "LiteLLM_SpendLogs" ORDER BY "startTime";
   call_type   |       model        | prompt_tokens | completion_tokens | spend
---------------+--------------------+---------------+-------------------+-------
 acreate_file  | openai/gpt-4o-mini |             0 |                 0 |     0
 acreate_batch | openai/gpt-4o-mini |             0 |                 0 |     0

The proxy log shows the silent drop; the cost was computed, there was just nothing to attribute it to

23:39:12 - LiteLLM Proxy:INFO: check_batch_cost.py:356 - Batch ID: batch_mock_1 is complete, tracking cost and usage
23:39:12 - LiteLLM Proxy:DEBUG: proxy_track_cost_callback.py:232 - user_api_key None, user_id None, team_id None, end_user_id None

After, at this PR's tree (local 0117345cb3, pushed as bb85f3c), same key with no user_id

psql> SELECT call_type, model, prompt_tokens, completion_tokens, spend, "user" FROM "LiteLLM_SpendLogs" ORDER BY "startTime";
    call_type    |       model        | prompt_tokens | completion_tokens |  spend   |      user
-----------------+--------------------+---------------+-------------------+----------+-----------------
 acreate_file    | openai/gpt-4o-mini |             0 |                 0 |        0 |
 acreate_batch   | openai/gpt-4o-mini |             0 |                 0 |        0 |
 aretrieve_batch | gpt-4o-mini        |          1000 |               500 | 0.000225 | default_user_id

0.000225 is correct gpt-4o-mini batch pricing for 1000 in / 500 out. A key that does carry a user_id produced the same row before and after, attributed to that user, so the existing behaviour is unchanged

Type

🐛 Bug Fix

Changes

CheckBatchCost._track_completed_batch_cost builds its own LiteLLMLogging object for the completed batch and put only user_api_key_user_id = job.created_by in its metadata. managed_files stores created_by = user_api_key_dict.user_id, so a batch submitted with a virtual key that has no user_id gets created_by = NULL, and the emitted success event then carries no key hash, user, team or end user. _PROXY_track_cost_callback runs _should_track_cost_callback(None, None, None, None), gets False, and returns without writing anything and without logging a warning, while the poller goes on to set batch_processed = true; the batch is never retried and the spend is gone.

The poller already guards the aretrieve_batch router call with job.created_by or "default-user-id"; the spend-emitting path just never got the same treatment. It now falls back to LITELLM_PROXY_ADMIN_NAME (the same default_user_id the rest of the proxy attributes unowned spend to) and additionally forwards job.team_id, which the managed object row already stores, so team budgets and the team spend views pick batch spend up.

Two regression tests were added to tests/proxy_unit_tests/test_check_batch_cost.py. Every existing test in that file mocks logging_obj.async_success_handler, so nothing there could catch this; the new ones let the real Logging object run, capture the event with a CustomLogger registered on the async success callbacks, and assert the captured kwargs would pass the exact _should_track_cost_callback check _PROXY_track_cost_callback applies. Both fail on main and pass with the fix

Not addressed here: LiteLLM_ManagedObjectTable never stores the hash of the key that created the batch, so batch spend can still never be attributed to user_api_key and per-key max_budget cannot see it. That needs a schema column and belongs in its own PR

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

… no user_id

CheckBatchCost put only user_api_key_user_id=job.created_by on the synthetic logging object it builds for a completed batch. Batches created by a virtual key without a user_id land in LiteLLM_ManagedObjectTable with created_by=NULL, so _should_track_cost_callback saw no key, user, team or end user and _PROXY_track_cost_callback dropped the row without logging anything, while the poller still flipped batch_processed to true; the batch was billed nowhere and never retried.

Fall back to LITELLM_PROXY_ADMIN_NAME the same way the aretrieve_batch call already does, and forward the job's team_id so team budgets see batch spend.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@greptile-apps

greptile-apps Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR repairs managed-batch spend attribution when the creating key has no user ID and propagates the batch’s stored team into the logging pipeline.

  • Falls back to the proxy administrator sentinel for otherwise unowned batch spend.
  • Adds team attribution so team spend counters and budgets receive completed-batch costs.
  • Adds regression coverage using the real asynchronous logging callback path.

Confidence Score: 4/5

The PR appears safe to merge, with only a non-blocking test-implementation issue around mutable callback capture state.

The production change routes completed-batch cost through the existing user and team attribution fields and covers the null-creator path; the remaining feedback is limited to repository-required test style.

Files Needing Attention: tests/proxy_unit_tests/test_check_batch_cost.py

Important Files Changed

Filename Overview
enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py Correctly supplies fallback user and team metadata to the existing spend callback pipeline; no blocking defect was identified.
tests/proxy_unit_tests/test_check_batch_cost.py Adds meaningful end-to-end callback assertions, but the capture helper violates the repository’s immutability guidance.

Reviews (1): Last reviewed commit: "fix(batches): attribute managed batch sp..." | Re-trigger Greptile

Comment on lines +1467 to +1471
from unittest.mock import patch

import litellm
from litellm.integrations.custom_logger import CustomLogger
from litellm.types.utils import Usage

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.

P2 Mutable callback capture state

The new callback capture uses a mutable list and captured.append(kwargs), contrary to the repository guidance requiring immutable local values and making the captured callback state less constrained.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, switched to resolving an asyncio.Future; a second success event now fails loudly instead of piling up.

@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…le list

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@mateo-berri

Copy link
Copy Markdown
Contributor

Closing: #35468 merged and fixes the same dropped spend row via the unattributed carve-out, including the team_id forwarding this PR also made

@mateo-berri mateo-berri closed this Aug 6, 2026
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.

[Bug]: CheckBatchCost reconciles a managed batch but never writes a spend row — silently, no error

2 participants