Skip to content

fix(batches): account a managed batch's cost exactly once - #37050

Merged
mateo-berri merged 8 commits into
litellm_internal_stagingfrom
litellm_batch_cost_accounted_once
Aug 15, 2026
Merged

fix(batches): account a managed batch's cost exactly once#37050
mateo-berri merged 8 commits into
litellm_internal_stagingfrom
litellm_batch_cost_accounted_once

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Retrieving a batch and the cost poller both compute cost
  • Whoever sees completion first locks the other out
  • A failed callback loses that batch's cost permanently
  • Repeated retrieves bill the same batch again
  • Poller-accounted batches carry no key, so key spend never moves

How it solves it:

  • Only whoever recorded the cost marks it accounted
  • The poller owns accounting whenever it is running, confirmed at startup
  • Batches created via /v1/batches remember their creating key and tags

User Flow

Before: a team running batch jobs through the gateway sees a completed batch billed once per status check that observes completion, or not at all when that check fails to read the output, and the key that submitted the batch shows no spend when the gateway's own poller is the one that bills it

  1. They upload a JSONL file with POST https://litellm-domain/v1/files (purpose: batch, target_model_names: <model>) and get back a gateway file id
  2. They send POST https://litellm-domain/v1/batches with that input_file_id and get back a batch id with status: validating
  3. They poll GET https://litellm-domain/v1/batches/{batch_id} until it returns status: completed, then keep polling a few more times (dashboards, retries, several workers)
  4. They open https://litellm-domain/ui/?page=logs (or GET https://litellm-domain/spend/logs?api_key=) and every completed-status poll billed the batch's full cost again, so three polls move their spend on GET https://litellm-domain/key/info by three times the real cost
  5. When the first completed-status poll fails to read the batch output, no spend entry ever appears for that batch and no later poll adds one
  6. When nobody polls after completion and the gateway bills the batch on its own, GET https://litellm-domain/key/info for the submitting key still shows no spend for it; only the user total moved

After: the same batch is billed exactly once, at its real cost, to the key that submitted it, however many times its status is checked

  1. They upload a JSONL file with POST https://litellm-domain/v1/files (purpose: batch, target_model_names: <model>) and get back a gateway file id
  2. They send POST https://litellm-domain/v1/batches with that input_file_id and get back a batch id with status: validating
  3. They poll GET https://litellm-domain/v1/batches/{batch_id} until it returns status: completed, then keep polling a few more times (dashboards, retries, several workers)
  4. https://litellm-domain/ui/?page=logs (or GET https://litellm-domain/spend/logs?api_key=) shows nothing for those polls; within about a minute a single aretrieve_batch entry appears for the batch with its real cost and token counts, and GET https://litellm-domain/key/info shows the key's spend up by exactly that amount
  5. Further polls add nothing, and a poll that fails to read the output no longer leaves the batch unbilled, since the gateway bills it on its next pass
  6. A batch nobody polls after completion lands the same way: one entry under the submitting key, key spend up by the batch cost

Relevant issues

Supersedes #36877 by @marty-sullivan, whose org-owned fork rejects maintainer pushes. This branch carries that PR's commits unchanged, plus a ruff format fix for the CI lint job, a merge of litellm_internal_staging (which already retires completed batches that have no output file, via #35360), a fix so batches created via /v1/batches persist their creating key and tags for the poller to bill, and a startup probe so the poller is confirmed before any request is served

Found alongside #36876, the spend log primary key fix, which is a prerequisite for a batch cost row to land at all. The two are independent changes in different subsystems and can merge in either order

Linear ticket

Resolves LIT-5622

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

Two proxies, each on a fresh Postgres database, proxy_batch_polling_interval: 60, one Bedrock deployment bedrock-batch-haiku -> bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0 (real Bedrock batch inference job, about ten minutes each). Same script on both: mint a key, upload a 100-line JSONL, create the batch, then poll GET /v1/batches/{id} from three concurrent clients every five seconds until three rounds past completed, wait one poller interval plus flush, and read spend back over HTTP

Before, at 8732155 (staging tip). Setup and polling:

$ curl -s http://127.0.0.1:37266/key/generate -H 'Authorization: Bearer $MASTER_KEY' -H 'Content-Type: application/json' -d '{"models": ["bedrock-batch-haiku"], "key_alias": "qa36877-before-run5", "user_id": "qa36877-user"}' | jq '{key, models, user_id}'
{ "key": "sk-e0Nq2VDc4...", "models": ["bedrock-batch-haiku"], "user_id": "qa36877-user" }

$ curl -s http://127.0.0.1:37266/v1/files -H 'Authorization: Bearer $KEY' -F purpose=batch -F file=@batch_input.jsonl -F target_model_names=bedrock-batch-haiku | jq '{id, purpose, status}'
{ "id": "bGl0ZWxsbV9wcm94eTphcHBsaWNhdGlvbi9v...", "purpose": "batch", "status": "uploaded" }

$ curl -s http://127.0.0.1:37266/v1/batches -H 'Authorization: Bearer $KEY' -H 'Content-Type: application/json' -d '{"input_file_id": "bGl0ZWxsbV9wcm94eTphcHBsaWNhdGlvbi9v...", "endpoint": "/v1/chat/completions", "completion_window": "24h"}' | jq '{id, status}'
{ "id": "bGl0ZWxsbV9wcm94eTttb2RlbF9pZDplNWJi...", "status": "validating" }

$ for round in $(seq 1 200); do (3 concurrent) curl -s http://127.0.0.1:37266/v1/batches/$BATCH_ID -H 'Authorization: Bearer $KEY' | jq -c '{status, output_file_id, request_counts}'; sleep 5; done
...
[12:32:08 round 100]
  {"status":"completed","output_file_id":"bGl0ZWxsbV9wcm94eTphcHBsaWNhdGlvbi9qc29u...","request_counts":{"completed":0,"failed":0,"total":0}}
  {"status":"completed","output_file_id":"bGl0ZWxsbV9wcm94eTphcHBsaWNhdGlvbi9qc29u...","request_counts":{"completed":0,"failed":0,"total":0}}
  {"status":"completed","output_file_id":"bGl0ZWxsbV9wcm94eTphcHBsaWNhdGlvbi9qc29u...","request_counts":{"completed":0,"failed":0,"total":0}}
batch completed at 12:32:18; waiting 150s for the cost poller interval (60s + up to 30s jitter) and spend log flush

Each of the three concurrent polls that saw completed computed the batch's cost itself, and the poller never got to it (five "Querying model ID" polls before completion, none after):

$ grep -c "batch output aggregate" litellm.log
3
$ grep "batch output aggregate" litellm.log | cut -c1-120
12:32:08 - LiteLLM:DEBUG: batch_utils.py:162 - batch output aggregate: cost=0.0 usage=Usage(completion_tokens=500, prompt_tokens=1600, total_tokens=2100 ...
12:32:08 - LiteLLM:DEBUG: batch_utils.py:162 - batch output aggregate: cost=0.0 usage=Usage(completion_tokens=500, prompt_tokens=1600, total_tokens=2100 ...
12:32:08 - LiteLLM:DEBUG: batch_utils.py:162 - batch output aggregate: cost=0.0 usage=Usage(completion_tokens=500, prompt_tokens=1600, total_tokens=2100 ...

What the user sees: the key was charged three times, each time for $0, because the retrieve path prices Bedrock output against a model name that is not a Bedrock cost-map key (a pre-existing miss the poller does not share). The batch's real cost is simply gone:

$ curl -s 'http://127.0.0.1:37266/key/info?key=$KEY' -H 'Authorization: Bearer $MASTER_KEY' | jq '{spend: .info.spend, key_alias: .info.key_alias}'
{ "spend": 0.0, "key_alias": "qa36877-before-run5" }

$ curl -s 'http://127.0.0.1:37266/spend/logs' -H 'Authorization: Bearer $MASTER_KEY' | jq -c '[.[] | select(.call_type == "aretrieve_batch")] | {aretrieve_batch_rows: length, rows: map({spend, total_tokens, model, api_key: .api_key[0:10], user, startTime})}'
{"aretrieve_batch_rows":1,"rows":[{"spend":0.0,"total_tokens":2100,"model":"bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0","api_key":"76b4a975f3","user":"qa36877-user","startTime":"2026-08-15T19:32:07.976000Z"}]}

After, at bb1c336 (tip: d9e377f plus the merge of litellm_internal_staging, which resolved the overlap with #37047 and #37048). Same script, same batch shape:

$ curl -s http://127.0.0.1:29124/key/generate -H 'Authorization: Bearer $MASTER_KEY' -H 'Content-Type: application/json' -d '{"models": ["bedrock-batch-haiku"], "key_alias": "qa36877-after2-run9", "user_id": "qa36877-user"}' | jq '{key, models, user_id}'
{ "key": "sk-ORr3gyUvE...", "models": ["bedrock-batch-haiku"], "user_id": "qa36877-user" }

$ curl -s http://127.0.0.1:29124/v1/files -H 'Authorization: Bearer $KEY' -F purpose=batch -F file=@batch_input.jsonl -F target_model_names=bedrock-batch-haiku | jq '{id, purpose, status}'
{ "id": "bGl0ZWxsbV9wcm94eTphcHBsaWNhdGlvbi9v...", "purpose": "batch", "status": "uploaded" }

$ curl -s http://127.0.0.1:29124/v1/batches -H 'Authorization: Bearer $KEY' -H 'Content-Type: application/json' -d '{"input_file_id": "bGl0ZWxsbV9wcm94eTphcHBsaWNhdGlvbi9v...", "endpoint": "/v1/chat/completions", "completion_window": "24h"}' | jq '{id, status}'
{ "id": "bGl0ZWxsbV9wcm94eTttb2RlbF9pZDozYzE...", "status": "validating" }

$ for round in $(seq 1 200); do (3 concurrent) curl -s http://127.0.0.1:29124/v1/batches/$BATCH_ID -H 'Authorization: Bearer $KEY' | jq -c '{status, output_file_id, request_counts}'; sleep 5; done
...
[16:14:15 round 97]
  {"status":"completed","output_file_id":"bGl0ZWxsbV9wcm94eTphcHBsaWNhdGlvbi9qc29u...","request_counts":{"completed":0,"failed":0,"total":0}}
  {"status":"completed","output_file_id":"bGl0ZWxsbV9wcm94eTphcHBsaWNhdGlvbi9qc29u...","request_counts":{"completed":0,"failed":0,"total":0}}
  {"status":"completed","output_file_id":"bGl0ZWxsbV9wcm94eTphcHBsaWNhdGlvbi9qc29u...","request_counts":{"completed":0,"failed":0,"total":0}}
[16:14:20 round 98]
  {"status":"completed","output_file_id":"bGl0ZWxsbV9wcm94eTphcHBsaWNhdGlvbi9qc29u...","request_counts":{"completed":0,"failed":0,"total":0}}
  {"status":"completed","output_file_id":"bGl0ZWxsbV9wcm94eTphcHBsaWNhdGlvbi9qc29u...","request_counts":{"completed":0,"failed":0,"total":0}}
  {"status":"completed","output_file_id":"bGl0ZWxsbV9wcm94eTphcHBsaWNhdGlvbi9qc29u...","request_counts":{"completed":0,"failed":0,"total":0}}
batch completed at 16:14:21; waiting 150s for the cost poller interval (60s + up to 30s jitter) and spend log flush

The three completed-status polls recorded nothing; the poller computed the cost once, on its first pass after completion, and wrote one spend entry:

$ grep -c "batch output aggregate" litellm.log
1
$ grep "Querying model ID\|batch output aggregate\|Writing spend log to db" litellm.log | tail -3 | cut -c1-150
16:15:04 - LiteLLM Proxy:INFO: check_batch_cost.py:774 - Querying model ID: 3c19ebe5b9c0dd89d13be1425c1fc4d53b1c103056b83e927218b019629f7cb6 for cost and usage...
16:15:04 - LiteLLM:DEBUG: batch_utils.py:163 - batch output aggregate: cost=0.002255 usage=Usage(completion_tokens=500, prompt_tokens=1600, total_tokens=2100 ...
16:15:04 - LiteLLM Proxy:DEBUG: db_spend_update_writer.py:784 - Writing spend log to db - request_id: bGl0ZWxsbV9wcm94eTttb2RlbF9pZDozYzE..._batch_cost, spend: 0.002255

What the user sees: one aretrieve_batch entry at the batch's real cost, attributed to the key that submitted it, and the key's spend up by exactly that amount:

$ curl -s 'http://127.0.0.1:29124/key/info?key=$KEY' -H 'Authorization: Bearer $MASTER_KEY' | jq '{spend: .info.spend, key_alias: .info.key_alias}'
{ "spend": 0.002255, "key_alias": "qa36877-after2-run9" }

$ curl -s 'http://127.0.0.1:29124/spend/logs?api_key=$KEY' -H 'Authorization: Bearer $MASTER_KEY' | jq -c 'group_by(.call_type) | map({call_type: .[0].call_type, rows: length, spend: (map(.spend) | add)})'
[{"call_type":"acreate_batch","rows":1,"spend":0.0},{"call_type":"acreate_file","rows":1,"spend":0.0},{"call_type":"aretrieve_batch","rows":1,"spend":0.002255}]

$ curl -s 'http://127.0.0.1:29124/spend/logs' -H 'Authorization: Bearer $MASTER_KEY' | jq -c '[.[] | select(.call_type == "aretrieve_batch")] | {aretrieve_batch_rows: length, rows: map({spend, total_tokens, model, api_key: .api_key[0:10], user, startTime})}'
{"aretrieve_batch_rows":1,"rows":[{"spend":0.002255,"total_tokens":2100,"model":"us.anthropic.claude-haiku-4-5-20251001-v1:0","api_key":"2c65919424","user":"qa36877-user","startTime":"2026-08-15T23:15:04.567000Z"}]}

With batch polling switched off, behavior is unchanged: the retrieve remains the only accountant and still sets the flag, so a proxy running without the poller does not lose batch cost

Type

🐛 Bug Fix

Changes

  • New batch_cost_poller_is_active: polling on, job scheduled, column confirmed
  • Poller confirms batch_processed support at startup, before the first request, and again after each filtered query
  • Retrieve sets batch_ignore_default_logging when the poller owns accounting
  • update_batch_in_database leaves batch_processed for the poller in that case
  • Ownership decided once per retrieve and carried into the DB write
  • Poller off or column unconfirmed: retrieve accounts and marks as before
  • The /v1/batches create hook persists the creating key and request tags, so the poller bills the key, not only the user
  • Unmanaged batches untouched
  • Merge of litellm_internal_staging (fix(batches): mark terminal batch with no output file as processed in CheckBatchCost #35360 retires completed batches lacking output)

Caveats (if any)

  • Cost lands up to one polling interval after completion, not at retrieve time
  • Batches whose deployment no longer resolves stay unaccounted, as they already were
  • Two replicas both running the poller can still each bill the same batch; pre-existing, untouched here
  • Bedrock output-cost reads need env AWS_S3_BUCKET_NAME and credentials
  • Retrieve-path Bedrock cost is $0 (model name unmapped), pre-existing
  • Bedrock reports request_counts all zero on completed batches
  • A key with no user_id gets 403 retrieving its own batch

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

marty-sullivan and others added 5 commits August 14, 2026 01:36
Two components computed a managed batch's cost and each assumed it was the only
one. Retrieving a batch computed it through the @client decorator's success
callback, and CheckBatchCost computed it on its own schedule. Whichever observed
completion first decided the outcome, so cost was either counted once per
retrieve or not at all.

The lockout is the worse half. Retrieving a batch that had reached completion set
batch_processed=True, which is what takes a batch out of CheckBatchCost's queue,
since it selects batch_processed=False. That write claimed the cost had been
accounted for on behalf of a callback that had not run yet and was not awaited.
When the callback then failed the cost was gone permanently, with the poller
already retired and no retry left. Observed on a live proxy: two completed
batches whose callbacks raised inside the logging worker, one on a provider
output path that did not resolve and one on a batch whose output file id was
still None, both left marked processed with no spend row and no way to recover
them. Nothing logged at error level for the batches themselves.

The over-count is the other half. Nothing suppressed recomputation, so each
retrieve of an already-completed batch recorded that batch's full cost again. A
caller polling its own batch to see whether it had finished inflated spend by
however many times it looked.

The flag now means what its name says, and only the component that actually
recorded the cost sets it. When the poller is running it owns accounting, so
retrieving a managed batch records no cost and leaves the flag alone; the poller
computes once and sets it. When the poller cannot be relied on, either because
polling is disabled by config or because the enterprise job never registered,
the retrieve path is the only accountant and behaves exactly as before. Batches
with no managed object row are untouched either way, since neither the flag nor
the poller queue applies to them.
…ches done

The handoff asked whether the poller was running, when what matters is whether it
will actually account for the batch. Those differ on a schema without the
batch_processed column: the poller cannot filter on it, so it falls back to a
query that excludes complete and completed rows, and it cannot set it either. A
caller retrieving a provider-completed batch before the poller saw it therefore
suppressed inline accounting, then marked the row complete, and the fallback query
could never find it again. Nobody accounted for that batch, so its cost escaped
the caller's budget entirely.

The poller now publishes batch_processed_support_confirmed, set only once a
filtered query has actually succeeded, and the handoff requires it. Defaulting to
unconfirmed keeps accounting on the retrieve path in exactly the cases the poller
would drop the batch, including the window before the poller's first cycle. All
four combinations account exactly once: unconfirmed leaves the retrieve
accounting and setting the marker, whether or not the column exists, and
confirmed is only reachable when the column is present, where the poller accounts
and sets it.

A scheduler that hands back something other than a bound method leaves no poller
to interrogate, which reads as unconfirmed rather than as working.
The ownership question was asked twice for one retrieve: once before the provider
call to decide whether to suppress inline accounting, and again afterwards to
decide whether to mark the batch accounted. Between those two points the poller
can complete its first successful filtered query and become usable, so the two
answers disagree. The retrieve then accounts for the batch inline, having decided
the poller was unusable, while the later check sees a usable poller and leaves the
marker unset, so the poller accounts for the same batch again and its spend is
counted twice.

The retrieve now decides once and passes that decision to
update_batch_in_database, which prefers it over re-deriving one. Callers that
record no cost of their own leave it unset and keep deriving it as before, so the
cancel path is unchanged.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@greptile-apps

greptile-apps Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR transfers managed-batch accounting ownership to the active cost poller and preserves creator attribution so completed batches are charged once to the submitting key

  • Confirms database support before enabling poller ownership
  • Suppresses retrieve-path accounting while the confirmed poller is active
  • Persists creating-key and request-tag attribution for managed batches
  • Adds regression coverage for startup, accounting handoff, and attribution

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 Confirms batch-processing column support and records poller-accounted batch spend with creator attribution
enterprise/litellm_enterprise/proxy/hooks/managed_files.py Persists creating-key and request-tag attribution only during managed batch creation
litellm/proxy/batches_endpoints/endpoints.py Hands managed-batch accounting to the confirmed poller while retaining inline accounting when unavailable
litellm/proxy/openai_files_endpoints/common_utils.py Coordinates the accounting ownership decision with managed-object database updates
litellm/proxy/proxy_server.py Confirms poller database support during scheduled-job startup

Reviews (3): Last reviewed commit: "Merge remote-tracking branch 'origin/lit..." | Re-trigger Greptile

Comment thread tests/test_litellm/proxy/batches_endpoints/test_endpoints.py Outdated

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: First-cycle double-billing race
    • Added a per-job find_unique re-read at the top of the poller loop so a batch that a concurrent retrieve request already inline-accounted and marked batch_processed=True is skipped instead of billed again from the stale find_many snapshot.

Create PR

Or push these changes by commenting:

@cursor push 27b0835ddd
Preview (27b0835ddd)
diff --git a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py
--- a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py
+++ b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py
@@ -736,6 +736,24 @@
         else:
             jobs = await self._fallback_find_jobs()
         for job in jobs:
+            # A retrieve request that sampled batch_cost_poller_is_active() as False
+            # before this cycle flipped batch_processed_support_confirmed to True can
+            # still finish and inline-account, marking batch_processed itself, while
+            # we walk this snapshot. Re-read per job so the poller does not bill a
+            # batch the retrieve already covered.
+            if self._has_batch_processed_column:
+                try:
+                    fresh: Final = await self.prisma_client.db.litellm_managedobjecttable.find_unique(
+                        where={"id": job.id}
+                    )
+                except Exception as recheck_err:
+                    verbose_proxy_logger.warning(
+                        f"CheckBatchCost: could not re-verify batch_processed for "
+                        f"job {job.id}, skipping to avoid duplicate billing: {recheck_err}"
+                    )
+                    continue
+                if fresh is None or getattr(fresh, "batch_processed", False) is True:
+                    continue
             routing = self._resolve_job_routing(job, prom_logger)
             if routing is None:
                 if self._has_unified_id_without_model(job):

diff --git a/tests/proxy_unit_tests/test_check_batch_cost.py b/tests/proxy_unit_tests/test_check_batch_cost.py
--- a/tests/proxy_unit_tests/test_check_batch_cost.py
+++ b/tests/proxy_unit_tests/test_check_batch_cost.py
@@ -62,6 +62,7 @@
         client = MagicMock()
         client.db = MagicMock()
         client.db.litellm_managedobjecttable = MagicMock()
+        client.db.litellm_managedobjecttable.find_unique = AsyncMock()
         client.db.litellm_usertable = MagicMock()
         return client
 
@@ -427,6 +428,51 @@
         assert update_data["status"] == "complete"
 
     @pytest.mark.asyncio
+    async def test_poller_reverifies_batch_processed_per_job_to_avoid_first_cycle_double_bill(
+        self, check_batch_cost_instance, mock_prisma_client, mock_llm_router
+    ):
+        """
+        Regression for the first-cycle double-billing race: a retrieve request that
+        sampled batch_cost_poller_is_active() as False just before this cycle flipped
+        batch_processed_support_confirmed to True can still finish, inline-account, and
+        mark batch_processed=True while the poller walks the find_many snapshot. The
+        poller must re-read batch_processed per job and skip anything that flipped, so
+        the same completed batch is never billed twice.
+        """
+        mock_prisma_client.db.litellm_managedobjecttable.update_many = AsyncMock(
+            return_value=0
+        )
+        mock_prisma_client.db.litellm_managedobjecttable.update = AsyncMock()
+
+        mock_job = MagicMock()
+        mock_job.id = "job-raced-by-retrieve"
+        mock_job.unified_object_id = "dW5pZmllZF9iYXRjaF9pZA=="
+        mock_job.created_by = "user-1"
+        mock_job.batch_processed = False
+
+        assert check_batch_cost_instance._has_batch_processed_column is True
+        mock_prisma_client.db.litellm_managedobjecttable.find_many = AsyncMock(
+            return_value=[mock_job]
+        )
+        # The retrieve endpoint already inline-accounted and flipped this row before
+        # the poller reached it in the loop.
+        fresh_row = MagicMock()
+        fresh_row.batch_processed = True
+        mock_prisma_client.db.litellm_managedobjecttable.find_unique = AsyncMock(
+            return_value=fresh_row
+        )
+
+        mock_llm_router.aretrieve_batch = AsyncMock()
+
+        await check_batch_cost_instance.check_batch_cost()
+
+        mock_prisma_client.db.litellm_managedobjecttable.find_unique.assert_awaited_once_with(
+            where={"id": "job-raced-by-retrieve"}
+        )
+        mock_llm_router.aretrieve_batch.assert_not_awaited()
+        mock_prisma_client.db.litellm_managedobjecttable.update.assert_not_awaited()
+
+    @pytest.mark.asyncio
     async def test_completed_batch_with_no_attributable_owner_still_writes_spend_log(
         self, check_batch_cost_instance, mock_prisma_client, mock_llm_router
     ):
@@ -1378,6 +1424,7 @@
         prisma.db.litellm_managedobjecttable.find_many = AsyncMock(
             return_value=[self._job()]
         )
+        prisma.db.litellm_managedobjecttable.find_unique = AsyncMock()
         prisma.db.litellm_usertable = MagicMock()
         prisma.db.litellm_usertable.find_unique = AsyncMock(return_value=None)
 
@@ -1608,6 +1655,7 @@
         prisma.db.litellm_managedobjecttable.find_many = AsyncMock(
             return_value=[self._job()]
         )
+        prisma.db.litellm_managedobjecttable.find_unique = AsyncMock()
         prisma.db.litellm_usertable = MagicMock()
         prisma.db.litellm_usertable.find_unique = AsyncMock(return_value=None)
 
@@ -2063,6 +2111,7 @@
         prisma.db.litellm_managedobjecttable.update_many = AsyncMock(return_value=0)
         prisma.db.litellm_managedobjecttable.update = AsyncMock()
         prisma.db.litellm_managedobjecttable.find_many = AsyncMock(return_value=jobs)
+        prisma.db.litellm_managedobjecttable.find_unique = AsyncMock()
         prisma.db.litellm_usertable.find_unique = AsyncMock(return_value=None)
         return prisma
 

diff --git a/tests/test_litellm/enterprise/proxy/test_managed_files_access_check.py b/tests/test_litellm/enterprise/proxy/test_managed_files_access_check.py
--- a/tests/test_litellm/enterprise/proxy/test_managed_files_access_check.py
+++ b/tests/test_litellm/enterprise/proxy/test_managed_files_access_check.py
@@ -191,6 +191,7 @@
     mock_prisma.db.litellm_managedobjecttable.find_many = AsyncMock(
         return_value=[mock_job]
     )
+    mock_prisma.db.litellm_managedobjecttable.find_unique = AsyncMock()
     mock_prisma.db.litellm_managedobjecttable.update = AsyncMock()
 
     # Mock proxy_logging_obj — should NOT be called for file content

You can send follow-ups to the cloud agent here.

@codecov

codecov Bot commented Aug 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…eated via /v1/batches

The retrieve path now defers a managed batch's accounting to CheckBatchCost, which
bills the key, team, and tags stored on the managed object row. The /v1/batches
create hook never persisted api_key or request_tags there (only the passthrough
creates did), so the poller attributed the cost to the user alone and the creating
key's spend stayed at zero.
… retrieve accounts inline before the first poll cycle

Probe the column before the scheduler registers CheckBatchCost, closing the window where a retrieve that decided the poller was inactive billed a batch the first poll cycle then billed again. Also drop narration docstrings and section banners from the new tests.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@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 d9e377f. Configure here.

@mateo-berri
mateo-berri enabled auto-merge August 15, 2026 20:26
@codspeed-hq

codspeed-hq Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_batch_cost_accounted_once (bb1c336) with litellm_internal_staging (abddd64)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (bb1c336) during the generation of this report, so abddd64 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

…itellm_batch_cost_accounted_once

# Conflicts:
#	tests/test_litellm/proxy/openai_files_endpoint/test_files_common_utils.py
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@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 bb1c336. Configure here.

@mateo-berri
mateo-berri merged commit a41c45a into litellm_internal_staging Aug 15, 2026
72 checks passed
@mateo-berri
mateo-berri deleted the litellm_batch_cost_accounted_once branch August 15, 2026 23:11
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.

3 participants