Skip to content

fix(batch): track cost for managed batches with no attributable key/u… - #35468

Merged
mateo-berri merged 2 commits into
BerriAI:litellm_internal_stagingfrom
elinacse:bugfix/managed-batch-cost-not-logged
Aug 6, 2026
Merged

fix(batch): track cost for managed batches with no attributable key/u…#35468
mateo-berri merged 2 commits into
BerriAI:litellm_internal_stagingfrom
elinacse:bugfix/managed-batch-cost-not-logged

Conversation

@elinacse

@elinacse elinacse commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Managed batches from a key with no user and no team complete but never bill
  • The cost event is dropped silently, with no error or warning
  • That batch spend stays invisible to spend logs and dashboards

How it solves it:

  • Completed-batch cost events are now tracked even with no attributable owner
  • Reuses the carve-out that already covers unauthenticated pass-through traffic
  • A batch that does have a team now attributes its spend to that team

User Flow

Before: the batch completes, but its spend never shows up anywhere on the gateway

  1. POST https://litellm-domain/key/generate with the admin key and {"models": ["gpt-5.4-nano-batch"]} returns a fresh virtual key attached to no user and no team
  2. POST https://litellm-domain/v1/files with that key (multipart: a JSONL of chat requests, purpose=batch, target_model_names=gpt-5.4-nano-batch) returns 200 with a long scrambled file id and status "uploaded"
  3. POST https://litellm-domain/v1/batches with {"input_file_id": "", "endpoint": "/v1/chat/completions", "completion_window": "24h"} returns 200 with a batch id and status "validating"
  4. Walk away without polling the batch; it finishes on the provider side
  5. Open https://litellm-domain/ui/?page=logs (or GET https://litellm-domain/spend/logs): the $0.00 upload and create entries are there, but an entry with the batch's real tokens and cost never appears, no matter how long you wait

After: the same batch shows up as billed spend shortly after it completes

  1. POST https://litellm-domain/key/generate with the admin key and {"models": ["gpt-5.4-nano-batch"]} returns a fresh virtual key attached to no user and no team
  2. POST https://litellm-domain/v1/files with that key (multipart: a JSONL of chat requests, purpose=batch, target_model_names=gpt-5.4-nano-batch) returns 200 with a long scrambled file id and status "uploaded"
  3. POST https://litellm-domain/v1/batches with {"input_file_id": "", "endpoint": "/v1/chat/completions", "completion_window": "24h"} returns 200 with a batch id and status "validating"
  4. Walk away without polling the batch; it finishes on the provider side
  5. Open https://litellm-domain/ui/?page=logs a couple of minutes after the batch finishes: a batch retrieval entry appears with the run's real usage, for a two-request batch 26 input and 8 output tokens billed at $0.0000076

Relevant issues

Fixes #35358

Linear ticket

Resolves LIT-5183

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)

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

Live proxies against real OpenAI batch spend, one per build, each on its own fresh postgres database. Before runs at staging head 0acca3e, after runs at this PR's head e0c4c7c. Config pins gpt-5.4-nano-batch (openai/gpt-5.4-nano, mode batch) and proxy_batch_polling_interval: 30

Two repro details matter. Polling GET /v1/batches/{id} to completion masks the bug on both builds, because the completion-detecting GET writes the cost itself attributed to the calling key, so after batch creation no further HTTP calls touch the batch and only the background CheckBatchCost job can observe completion. And the issue title's master-key case already bills at staging head, since a master-key batch stores created_by = default_user_id, which passes the pre-fix guard; the surviving gap is a key with no user and no team, where the managed-object row has created_by NULL, team_id NULL

Same steps on both proxies (before on 41783, after on 45291):

$ curl -s http://localhost:41783/key/generate -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
    -d '{"models": ["gpt-5.4-nano-batch"], "key_alias": "qa-c14-userless-before"}'
{... "user_id": null, "team_id": null ...}

$ curl -s http://localhost:41783/v1/files -H "Authorization: Bearer <that key>" \
    -F purpose=batch -F target_model_names=gpt-5.4-nano-batch -F file=@batch_input.jsonl
{"id":"bGl0ZWxsbV9wcm94eTph...","status":"uploaded",...}

$ curl -s http://localhost:41783/v1/batches -H "Authorization: Bearer <that key>" -H "Content-Type: application/json" \
    -d '{"input_file_id": "<unified file id>", "endpoint": "/v1/chat/completions", "completion_window": "24h"}'
{"id":"bGl0ZWxsbV9wcm94eTtt...","status":"validating",...}

No further requests are made. Both proxy logs then show the poller picking the completed batch up, with the same all-None attribution reaching the track-cost guard:

before  00:32:44 check_batch_cost.py:352 - Batch ID: batch_6a743830...17bf is complete, tracking cost and usage
before  00:32:44 proxy_track_cost_callback.py:232 - user_api_key None, user_id None, team_id None, end_user_id None
after   00:39:30 check_batch_cost.py:352 - Batch ID: batch_6a743830...6efa is complete, tracking cost and usage
after   00:39:30 proxy_track_cost_callback.py:238 - user_api_key None, user_id None, team_id None, end_user_id None

Spend logs afterwards, queried several poller cycles later. The before database has no billed row for that batch, the after database has exactly one:

$ psql -h 127.0.0.1 -U mateo -d qa_c14_before -c 'SELECT call_type, spend, prompt_tokens, completion_tokens, request_tags
    FROM "LiteLLM_SpendLogs" WHERE call_type = '"'"'aretrieve_batch'"'"' AND spend > 0;'
(0 rows for the user-less batch; batch_processed still flipped to true, nothing logged, no error)

$ psql -h 127.0.0.1 -U mateo -d qa_c14_after -c '...same query...'
    call_type    |  spend  | prompt_tokens | completion_tokens |                        request_tags
-----------------+---------+---------------+-------------------+------------------------------------------------------------
 aretrieve_batch | 7.6e-06 |            26 |                 8 | ["User-Agent: LiteLLM Proxy", "User-Agent: LiteLLM Proxy/CheckBatchCost"]

The billed amount is exact for gpt-5.4-nano batch pricing: 26 prompt tokens at 1e-07 plus 8 completion tokens at 6.25e-07 is 7.6e-06 USD. A master-key control batch billed the same 7.6e-06 on both builds, and no batch double-billed anywhere, including one that was both poller-reconciled and then retrieved over HTTP

Leg Commit Master-key batch User-less key batch
Before 0acca3e billed 7.6e-06 no spend row
After e0c4c7c billed 7.6e-06 billed 7.6e-06

Type

🐛 Bug Fix

Changes

_should_track_cost_callback skips any cost event that carries no key, user, team, or end-user, with a carve-out for unauthenticated pass-through routes. CheckBatchCost attributes its synthetic logging object from LiteLLM_ManagedObjectTable, which stores only created_by and team_id and never the raw key hash, so a batch created by a key with no user and no team reaches that guard all-None and its cost event was silently dropped after the batch was already marked processed

The carve-out set is renamed from _PASS_THROUGH_CALL_TYPES to _UNATTRIBUTED_TRACKABLE_CALL_TYPES and now includes aretrieve_batch: the batch already incurred real provider cost, so it must be tracked regardless of attribution, the same reasoning as pass-through. The synthetic logging object also now carries user_api_key_team_id from the job, so a team-owned batch gets real team attribution instead of falling through to the track-anyway path

Regression tests: test_completed_batch_with_no_attributable_owner_still_writes_spend_log runs the real logging pipeline through _ProxyDBLogger (the layer the bug hid behind) and asserts the spend write happens and the job is still marked processed, and the two parametrized guard tests in test_proxy_track_cost_callback.py pin aretrieve_batch as tracked without an owner while other unattributed call types stay skipped

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

@greptile-apps

greptile-apps Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR restores spend persistence for completed managed batches that have no attributable key, user, team, or end user.

  • Allows synthetic aretrieve_batch reconciliation events through the unattributed cost-tracking guard.
  • Propagates the managed batch’s stored team ID into spend metadata.
  • Adds callback-level and reconciliation-path regression coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py Propagates the batch’s creation-time team ID into the synthetic completed-batch logging event.
litellm/proxy/hooks/proxy_track_cost_callback.py Extends unattributed spend tracking to completed managed-batch retrieval events while preserving the global spend-update disable switch.
tests/proxy_unit_tests/test_check_batch_cost.py Adds an end-to-end mocked regression test proving an unattributed completed batch reaches the spend database writer with its calculated cost.
tests/test_litellm/proxy/hooks/test_proxy_track_cost_callback.py Extends callback guard and database-write tests to cover unattributed aretrieve_batch events.

Reviews (2): Last reviewed commit: "Merge branch 'litellm_internal_staging' ..." | Re-trigger Greptile

@codecov

codecov Bot commented Aug 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 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 elinacse:bugfix/managed-batch-cost-not-logged (e0c4c7c) with litellm_internal_staging (0acca3e)

Open in CodSpeed

…ser/team

LiteLLM_ManagedObjectTable only stores created_by (user_id) and team_id,
never the raw API key hash. A batch created with the master key or a
team-less key has both null, so CheckBatchCost's synthetic logging_obj
for the completed batch carried no attributable key/user/team/end-user.
_should_track_cost_callback silently skipped the DB write in that case
(by design, to avoid tracking truly anonymous requests), with no error
or warning: batch_processed still became true, but no LiteLLM_SpendLogs
row was ever written despite real, already-incurred provider cost.

Extend the same allowance already made for unauthenticated pass-through
requests to aretrieve_batch's cost event, and pass job.team_id through
so a batch's team gets real attribution when one exists.
@elinacse
elinacse force-pushed the bugfix/managed-batch-cost-not-logged branch from 62478ea to 833670f Compare August 2, 2026 06:50
# user_api_key_team_id (from .team_id) -- both are None for batches created with
# the master key or a team-less key, since the table never stores the raw key
# hash. The batch already incurred real provider cost, so track it regardless.
CallTypes.aretrieve_batch.value,

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.

Low: Unauthenticated batch retrieval can inflate recorded spend

This allowlist entry also covers the public retrieve-batch route. With proxy authentication disabled, an attacker who repeatedly retrieves a completed batch causes async_success_handler to recompute and persist the entire batch cost on every GET, even though that cost was not incurred again; these writes also update the global proxy-budget row when a maximum budget is configured. Use a distinct internal call type or another non-client-controllable marker for the synthetic CheckBatchCost event rather than allowing all unattributed aretrieve_batch calls.

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

Auth-disabled proxies already let anyone incur unlimited real spend, so inflating recorded spend adds no marginal risk. The aggregate re-increment predates this PR

@veria-ai

veria-ai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

PR overview

This PR updates proxy cost tracking so managed batch costs are recorded even when they cannot be attributed to a specific key or user. It adds batch retrieval handling to the proxy cost callback.

One security issue remains open: when proxy authentication is disabled, repeated retrieval of a completed batch can repeatedly record its full cost and inflate spend and global budget accounting. Exploitation depends on the public batch retrieval route being exposed without authentication, but it could disrupt accounting and budget enforcement in that configuration.

Open issues (1)

Fixed/addressed: 0 · PR risk: 4/10

@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri mateo-berri 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.

LGTM. Thanks for the contribution!

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