Repository navigation
feat(interactions): durable cross-pod settlement for background interaction billing - #41955
Conversation
…action billing Background interaction billing lived only in the creating replica's memory, so a DELETE routed to another replica, or a restart of the creating one, never billed the completed provider work and the budget reservation was refunded at the poll timeout. The create now registers the billing context in a settlement store before returning, the proxy installs a Prisma-backed store at boot (LiteLLM_BackgroundInteractionSettlement, schema-only migration), any replica claims the row once through a conditional update before billing or releasing, startup resumes every unclaimed row with its remaining timeout, and a give-up records an unsettled outcome instead of silently reconciling to zero. The SDK keeps an in-memory store and behaves as before.
…round_interaction_settlement
|
I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
|
Comments Outside DiffThese findings could not be posted inline.
|
…top carrying request headers
1 similar comment
|
bugbot run |
…n its claim only answers at the deadline
…xt for agent-only background creates An interaction created with an agent and no model reaches the poll with no model name, exactly as on main. The settlement context now stores that None instead of rejecting the create, which answered the client with a 500 after the provider had already accepted it.
… creating poll when a delete lands elsewhere The remote pre-delete path fetches with only the delete's credentials, so a fetch it cannot make says nothing about the interaction. It used to claim the settlement row and release the reservation anyway, which stopped the creating replica's poll and lost the bill when the delete then failed the same way. It now returns without claiming; the in-process path keeps releasing on an unfetchable state, since its context carries the create's own credentials.
…tch fails so the creating poll keeps the bill
…lects it The merge of main moved test_background_interaction_settlement.py under tests/unit/proxy/spend_tracking, but the proxy-db shards claim tests/unit/proxy files one by one in .circleci/scripts/unit_selection.sh, so no CI shard ran it and codecov/patch dropped. tests/test_litellm/proxy/spend_tracking is collected whole by the proxy-infra shard, which is where the test ran before the merge.
|
bugbot run |
…ent and meet its new lint gates
|
bugbot run |
AsyncHTTPHandler.get never raises for status and the Gemini GET transform only raised when the body was not JSON, so a 500 or 404 carrying Gemini's JSON error body parsed as an interaction with no status. A delete on a replica other than the creator then claimed the settlement as released and forwarded the delete instead of failing closed, and the bill was lost. The transform now raises GeminiError with the vendor's status, as the delete transform already does; the in-process poll already retries a fetch that raises
…ross replicas Twenty-six deterministic cells drive a one-worker creator and a two-worker settler against an owned scripted Gemini upstream: cross-replica deletes bill once, failed and cancelled interactions release, a later replica resumes unclaimed rows, custom deployment pricing bills at the deployment rate, a fetch the settler cannot make fails the delete closed, odd ids are refused, a missing settlement table keeps in-process billing, polling disabled registers nothing, the budget reservation is released by the settler, an upstream outage mid-burst fails closed and recovers, killed workers hand their polls to the respawned ones, and concurrent deletes on a slow upstream settle exactly once. The support upstream gains a scripted interaction store with per-id GET status and delay, and the process helper gains an owned upstream a test can stop and restart
…and pin the settlement budget below one estimate
…round_interaction_settlement # Conflicts: # tests/integration/_support/upstream.py
…er's resume The budget cell pinned a 400 that the proxy stopped answering when budget refusals moved to 422, so it now asserts the status and the budget_exceeded error type the sibling budget tests pin. The later-booting replica cell accepts a claimer that is any worker started after the creates, since uvicorn's supervisor can respawn the creator's worker under load and the respawned worker's boot resume claims the rows by design; the single spend row check is unchanged
A key whose budget is filled by its own reservation is refused on every route, the DELETE included, so the cell now asserts that 422 and sends the delete with a second key, which is what the reservation release on another replica needs in order to be observable at all
|
bugbot run |
There was a problem hiding this comment.
✅ 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 2cdc471. Configure here.
TLDR
Problem this solves:
How it solves it:
User Flow
Before
The create is served by one proxy replica and the delete by another, and the interaction's usage never shows up in the key's spend; a read of an id Gemini no longer knows answers 200 with every field null
POST https://litellm-domain/v1beta/interactionswith a virtual key and{"model":"gemini-3.8-flash","input":"Reply with the single word pong.","background":true}returns 200 with{"id":"v1_ChdJM2pB...","status":"in_progress","usage":null}GET https://litellm-domain/v1beta/interactions/v1_ChdJM2pB...a few seconds later (the load balancer sends it to a different replica) returns 200 with"status":"completed"and"usage":{"total_tokens":78,...}DELETE https://litellm-domain/v1beta/interactions/v1_ChdJM2pB...(also landing on the other replica) returns 200 with{"success":true,"id":"v1_ChdJM2pB..."}GET https://litellm-domain/spend/logs?request_id=v1_ChdJM2pB...with the master key returns[], right after the delete and still a minute laterGET https://litellm-domain/key/infowith the virtual key showsspendunchanged from before the createGET https://litellm-domain/v1beta/interactions/v1_ChdJM2pB...after the delete returns 200 with"id":null,"status":null,"usage":null, and so doesGET https://litellm-domain/v1beta/interactions/lpr-missing-11cf61ec01with a mistyped id, so the client cannot tell a deleted interaction from one still runningAfter
The same create and delete on different replicas bills the interaction once under the key that created it, and a read of a deleted or mistyped id answers Gemini's error
POST https://litellm-domain/v1beta/interactionswith a virtual key and{"model":"gemini-3.8-flash","input":"Reply with the single word pong.","background":true}returns 200 with{"id":"v1_ChduSGZB...","status":"in_progress","usage":null}GET https://litellm-domain/v1beta/interactions/v1_ChduSGZB...a few seconds later (the load balancer sends it to a different replica) returns 200 with"status":"completed"and"usage":{"total_tokens":67,...}DELETE https://litellm-domain/v1beta/interactions/v1_ChduSGZB...(also landing on the other replica) returns 200 with{"success":true,"id":"v1_ChduSGZB..."}GET https://litellm-domain/spend/logs?request_id=v1_ChduSGZB...with the master key returns exactly one row:"model":"gemini/gemini-3.8-flash","spend":0.00022725,"total_tokens":67GET https://litellm-domain/key/infowith the virtual key showsspendincreased by 0.00022725GET https://litellm-domain/v1beta/interactions/v1_ChduSGZB...after the delete returns 404 withlitellm.NotFoundError: GeminiExceptionand Gemini'sRequested entity was not found.message, andGET https://litellm-domain/v1beta/interactions/lpr-missing-27d61d508ewith a mistyped id returns 400 with Gemini'sInvalid interaction namemessageThe same holds when the replica that served the create restarts before the interaction completes: once it is back,
GET https://litellm-domain/spend/logs?request_id=<id>lists the row within one poll interval instead of never. ADELETEsent to a replica that cannot reach Gemini to read the interaction answers 500 instead of deleting it unbilled, and the creating replica's poll or the next delete bills itLinear ticket
Resolves LIT-4442
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*,make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more@greptileaito 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
Last updated 2026-10-03 at 2cdc471. The product tip is 6afd57f; 57f2ede and 2cdc471 touch only
tests/integration/, so the live legs and the /live-pr-risk run at 6afd57f and 57f2ede carry over to the tip, and the two cells those commits changed ran again at their commitEvery run below is the checked-in integration module
tests/integration/spend/test_background_interaction_settlement.pyplus one live cell against the real Gemini API, both legs on the same machine on 2026-10-02 (times UTC). Each leg owns a Postgres (port 54812, databaseaudit41955_baseoraudit41955_head), a Redis (32815), a scripted Gemini upstream that records every call and thex-goog-api-keyit carried (45569), and a 2-worker proxy booted from that leg's worktree (integration._support.proxy, base on 51315, head on 29294, Prisma db push at boot). Inside the module therigfixture boots, from the same worktree, X = a 1-worker creator whose own poll never ticks inside a test (initial interval 300 s), Y = a 2-worker settler with a fast poll (1 s ticks, 8 s timeout), and single tests boot Z, P, or C replicas or SIGKILL Y's workers. The four deployments (in-progress, completed-at-once, failing-create, custom-priced) point at the module's upstream; the live cell's deployment carries noapi_baseand the realGEMINI_API_KEY, so its calls go togenerativelanguage.googleapis.comand cost real money. Base is the merge base b28ce93; head is the PR's product tip 6afd57f, with the tests-only commits 57f2ede and 2cdc471 on top. Both legs ran under a loaded machine (headroom readings CRITICAL or TIGHT throughout), which matters for the one flaky cell explained under the matrixBefore (b28ce93)
Cross-replica delete on real Gemini (L1)
POST /v1beta/interactionson replica A with the virtual key and{"model":"integration-3c0025dd8da04be185d6af3727f2e64e","input":"Reply with the single word pong.","background":true}(that model alias is the scenario's deployment ofgemini/gemini-3.8-flash) returns 200 with"id":"v1_ChdJM2pBYXB...","status":"in_progress","usage":nullGET /v1beta/interactions/v1_ChdJM2pBYXB...on replica B returns 200 with"status":"completed"and"usage":{"total_tokens":78,...}DELETE /v1beta/interactions/v1_ChdJM2pBYXB...on replica B returns 200 with{"success":true,"id":"v1_ChdJM2pBYXBHbEZfYUYxTWtQcXVlS3dBMBIXSTNqQWFwR2xGX2FGMU1rUHF1ZUt3QTA"}SELECT ... FROM "LiteLLM_SpendLogs" WHERE request_id = '<id>'20 s later returns[]; the settlement table does not exist on this sideGET /key/infowith the virtual key shows"spend": 0.0Read of a deleted or mistyped id on real Gemini
GET /v1beta/interactions/<the id deleted above>returns 200 with"id":null,"status":null,"usage":nullafter Gemini answered 404Requested entity was not found.through the recorderGET /v1beta/interactions/lpr-missing-11cf61ec01returns 200 with the same null body after Gemini's 400Invalid interaction name;GET /interactions/<id>behaves the same on both readsDELETE /v1beta/interactions/<unknown id>returns 404 (control, unchanged on both sides)Integration matrix
python tests/integration/run.py accounting --seed 4106601 --order-seed 0 --results audit/base-b28ce93d2b-run1 tests/integration/spend/test_background_interaction_settlement.pyagainst the base proxyrelation "LiteLLM_BackgroundInteractionSettlement" does not exist,State did not converge: []on a cross-replica bill, a fail-closed delete answering 200), see the Base column of the matrixAfter (2cdc471)
Cross-replica delete on real Gemini (L1)
POST /v1beta/interactionson replica A with the virtual key and{"model":"integration-f1e5fe1483644b16b291a1bcf4b471f5","input":"Reply with the single word pong.","background":true}(that model alias is the scenario's deployment ofgemini/gemini-3.8-flash) returns 200 with"id":"v1_ChduSGZBYXI...","status":"in_progress","usage":nullGET /v1beta/interactions/v1_ChduSGZBYXI...on replica B returns 200 with"status":"completed"and"usage":{"total_tokens":67,...}DELETE /v1beta/interactions/v1_ChduSGZBYXI...on replica B returns 200 with{"success":true,"id":"v1_ChduSGZBYXI3aU9iR0ktc0FQcTZhMzZRURIXbkhmQWFyN2lPYkdJLXNBUHE2YTM2UVE"}SELECT ... FROM "LiteLLM_SpendLogs" WHERE request_id = '<id>'20 s later returns exactly one row:"spend":0.00022725,"prompt_tokens":8,"completion_tokens":59,"call_type":"acreate_interaction"; the settlement row readsclaimed_by: Mateos-MacBook-Pro.local:35570(replica B's worker),outcome: billed, claimed and settledGET /key/infowith the virtual key shows"spend": 0.00022725Read of a deleted or mistyped id on real Gemini
GET /v1beta/interactions/<the id deleted above>returns 404litellm.NotFoundError: GeminiExceptioncarrying Gemini'sRequested entity was not found.GET /v1beta/interactions/lpr-missing-27d61d508ereturns 400 carrying Gemini'sInvalid interaction name;GET /interactions/<id>behaves the same on both readsDELETE /v1beta/interactions/<unknown id>returns 404 (control, unchanged on both sides)Integration matrix
python tests/integration/run.py accounting --seed 4106601 --order-seed 0 --results audit/head-6afd57f9de-run1 tests/integration/spend/test_background_interaction_settlement.pyagainst the head proxy, then the same command a second time into-run2H5at 57f2ede (base 2026-10-02T20:06:03Z, 2 of 2 failed, 246.362 s; head 2026-10-02T20:07:14Z, 1 of 2 failed, 166.401 s, and 2026-10-02T20:11:22Z, 1 of 2 failed, 129.173 s);E5at 2cdc471 (base 2026-10-02T20:21:07Z, 1 of 1 failed, 106.410 s; head 2026-10-02T20:19:01Z, 0 of 1 failed, 141.600 s, and 2026-10-02T20:22:58Z, 0 of 1 failed, 202.452 s). The other 26 rows keep their 6afd57f results, since no product file changed after it/audit matrix
Cell ids follow the inventory: H = happy paths, S = sad paths, E = edge cases, U = unsettled, C = chaos, L = live. "Base expected" is what the merge base should do (red where the cell asserts a settlement row or a cross-replica bill, which main cannot produce). A row is VERIFIED when base matched that expectation and both head runs were green
test_creator_poll_bills_a_completed_background_interaction_oncetest_creator_poll_records_its_settlement_durablytest_delete_on_another_replica_bills_the_creators_interaction_once[/v1beta/interactions]test_delete_on_another_replica_bills_the_creators_interaction_once[/interactions]test_delete_of_a_failed_interaction_releases_without_a_spend_rowtest_delete_of_a_requires_action_interaction_bills_its_usagetest_a_replica_booting_later_resumes_and_bills_unclaimed_interactionstest_deletes_on_the_creating_proxy_bill_each_interaction_oncetest_custom_deployment_pricing_bills_at_the_deployment_rate_on_another_replicatest_cancel_then_delete_on_another_replica_releases_without_a_spend_rowtest_delete_fails_closed_when_the_settling_replica_cannot_fetchtest_delete_of_an_interaction_the_vendor_purged_sends_no_delete_and_keeps_the_rowtest_reading_an_interaction_never_bills_ittest_delete_of_an_odd_or_unknown_id_is_refused_and_the_proxy_keeps_servingtest_a_missing_settlement_table_leaves_in_process_billing_intacttest_a_failed_create_registers_nothingtest_polling_disabled_replica_registers_nothing_and_never_billstest_a_create_that_completes_at_once_is_billed_by_the_create_alone[synchronous]test_a_create_that_completes_at_once_is_billed_by_the_create_alone[background]test_identical_creates_settle_as_separate_interactionstest_settlement_on_another_replica_releases_the_creators_budget_reservationtest_a_poll_that_never_sees_a_terminal_status_records_unsettled_and_releasestest_an_upstream_outage_fails_deletes_closed_and_every_interaction_bills_once_after_recoverytest_killed_workers_leave_their_polls_to_the_respawned_workerstest_concurrent_deletes_on_a_slow_upstream_settle_exactly_onceEvery red in the Base column is the merge base missing the feature the cell checks:
relation "LiteLLM_BackgroundInteractionSettlement" does not existwhere a cell reads the table,State did not converge: []where it waits for a bill that only a settler on another replica or a resumed poll can write, and a 200 where head fails closed. Two cells needed a second lookH5(a replica booting later resumes and bills the rows nobody claimed) failed its first head run at 6afd57f and passed the second: the failing run's claimant was a worker pid outside the resumer's process group. The creator X runs one uvicorn worker, and under the loaded machine the supervisor's liveness ping timed out, loggedChild process died, and respawned it; the respawned worker ran the same boot resume the cell is about and claimed the rows first, which is the designed behavior for any worker that boots. 57f2ede makes the cell accept a claimant that is either one of the resumer's workers or a process that booted after the creates, and the cell then passed both head runs and kept its base redE5(a settlement on another replica releases the creator's budget reservation) was wrong twice in the test, never in the product. At 6afd57f it expected 400 where the proxy answers 422budget_exceededsince bf804f5 on main. At 57f2ede it sent the DELETE with the pinned key, and that DELETE is refused with the same 422 on both legs: the pre-call reservation is resized down to whatever budget is left, so the key's counted spend equals itsmax_budgetand the auth budget check refuses every later request with that key until something releases the reservation (the pinned-key caveat). 2cdc471 asserts that refusal and deletes with a second key; the settlement then bills the creator's key from the stored attribution and releases its reservation, so the third create with the pinned key answers 200 on head (both runs) and base never gets there (no cross-replica bill, so the spend row the cell waits for never appears)S2andS3are red on base at their tails rather than at the first assertion: the caller sees the same 404 on both legs, but base forwards the DELETE without a pre-delete GET and has no settlement row, and inS3the DELETE on Y never bills on base/live-pr-risk at 57f2ede, carried to 2cdc471 (a tests-only commit)
Verdict: two observable behavior changes, both chosen in this PR and recorded below, and no unintended ones. Across 22 scenarios run in the same order on a two-worker base proxy and a two-worker head proxy, every difference traces to those two changes, and a third leg on the merged tree of the head and
origin/mainanswered every scenario the way the head did (compare-head-merge: 22 rows, none differ). The real vendor saw the same eight calls with the same header set and body sizes from both legs (GET 400 x2, POST 200, GET 200 x3, DELETE 200, GET 404), so the head sends nothing extra outbound. A background create costs about 4.5 ms more at the median against a local scripted upstream, 6.0 ms on base against 10.5 ms on head over 12 interleaved creates each (p90 9 ms against 13 ms), which is the settlement row's insert before the create answersRig: base at the merge base b28ce93 on port 51315 and head at 57f2ede on port 29294 (product-identical to the tip 2cdc471, since the two commits after it touch only
tests/integration/), each a two-worker uvicorn proxy booted from its own worktree withPYTHONPATHset to that worktree, its own Postgres database and its own Redis namespace; a third two-worker leg on port 46870 from the merged tree of 57f2ede andorigin/mainat 2ccb7ed (commit-tree 0341813e41); a scripted upstream on port 45569 for the hostile inputs (unknown ids, a 404, a 500, a 500-then-200 outage, a 500 only for the pre-delete fetch) and a forwarding recorder in front ofgenerativelanguage.googleapis.comcarrying the real Gemini key for the vendor scenarios, which logs method, status, header set and body size of every outbound call; baselines for chat, streaming chat,/v1/messages, readiness and key info ran on every leg and matched. Thetests/llm_translation/interactionssuite ran against the head with the real key: 16 passed, 3 skipped (the suite's own skips), and 2 creates failed on the vendor's 503service_unavailableonPOST /v1beta/interactionsand passed when rerun 3 minutes laterBreaking
DELETEof a background interaction on a replica that did not create it fails when the pre-delete fetch fails, where main deleted it unbilled. Observed with the scripted upstream answering 500 to that fetch: base answered 200 and sent only theDELETEupstream, with no spend row on any replica; head answered 500internal_server_error, sent only theGET, and left the settlement row unclaimed (claimed_by null, outcome null) for the next settler. On the creator replica both legs answer 200 and sendGETthenDELETE(head's row endsreleased). Edge: the settler on the deleting replica rebuilds the billing context from the stored row and fetches the interaction before the vendor delete, and a failed fetch is raised to the caller instead of falling through to the delete. Chosen in 73a2dc9 and recorded as Decision 15, approved by mateo-berri in the LGTM review of 2026-09-24 at 2ba9b7b, which already carried itGET /v1beta/interactions/{id}andGET /interactions/{id}answer the vendor's non-2xx status where main answered 200 with every field null. Observed on both routes: an unknown id is 404litellm.NotFoundError: GeminiExceptionon head and 200 with null fields on base; a scripted upstream 500 is 500internal_server_erroron head and 200 null on base; the real vendor's 400 for a malformed id and its 404Requested entity was not found.for a deleted id come back as 400 and 404 on head and as 200 null on base. Edge: the Gemini interactions GET transform raisesGeminiErrorwith the vendor's status and body on a non-2xx response, which the route maps through the usual exception mapping. Introduced on purpose in 44c3026 and recorded as Decision 19; mateo-berri's standing approval predates that commitBackward incompatible
litellm.aget_interactionandget_interactionforgemini/andvertex_ai/raise the mapped exception where they returned anInteractionsAPIResponsewithstatus=None. Observed against the real vendors: Gemini raisedBadRequestError(vendor 400 for a malformed id) on head and returned the null object on base; Vertex raisedBadRequestErroron head and returned the null object on base, where the vendor's 400 wasUnsupported locationfor the rig's region rather than an unknown-id answer, so the observation is the raise against the return and not the 404 shape. Same commit and decision as Breaking 2litellm/interactions/background_cost_polling.py:maybe_schedule_background_interaction_cost_pollingis now a coroutine,maybe_settle_background_interactiontakesdelete_kwargs, the pre-delete fetch helper is renamed, and thebackground_interaction_settledkwarg no longer exists.grep -rnoverlitellm/,enterprise/andtests/finds no reader of the kwarg and no caller of these helpers outside the interactions handlers and their tests; an out-of-tree caller would fail at call time. The unit tests in CI and the 18 passing translation tests cover the in-tree callersLiteLLM_BackgroundInteractionSettlementvia migration20260915000000_add_background_interaction_settlement, dated before main's latest20261002220000. Verified on a fresh database that took the merge base's 197 migrations first:prisma migrate deployfrom the head applied the new one on top and the table exists, since Prisma applies every unapplied migration regardless of its date. A deployment withDISABLE_SCHEMA_UPDATEapplies it itself (caveat row)GETandDELETEroutes already need on main; the audit cells assert the environment credential on every settler call. The config-only-key case is a caveat row below, unchanged from mainRegression risk
api_baseis empty on the settled row (caveat row), but per-key and per-team callbacks, the privacy toggles, trace controls anddata_residencywere not exercised; a cell with a key-scoped callback and a check of the settled row's logging payload would decide eachInMemoryBackgroundSettlementStorekeeps oneNoneentry per claimed interaction for the process lifetime (record_outcomeis a no-op andclaimleaves the key), measured at 152 bytes per entry, 15.2 MB per 100k interactions; main dropped its_ACTIVE_POLLSentry when the task ended. The proxy installs the Prisma store and is unaffected; caveat row below and a follow-up ticketCancelledErrorduring the create's insert, a read replica lagging behind the create, the trace id contextvar set from the delete's context, polling disabled by config, SDK create and delete parity, and reading a stored context written by a later version are listed under Not verified with what each needsDependency graph
DISABLE_SCHEMA_UPDATEcaveatCancelledErrorduring the insertapi_basebackground_interaction_settledkwarg removedlitellm/,enterprise/,tests/Not verified
CancelledErrorwhile the create's insert is in flight: needs a client that disconnects during the create against a slowed databasedata_residencyon the rebuilt context: needs a key-scoped callback cellgetran against the real vendorsType
🐛 Bug Fix
Caveats (if any)
Low
DISABLE_SCHEMA_UPDATE=true, boot logs the schema mismatch with itsCREATE TABLE, the ERROR repeats on every background create, reads of the missing table count as no rows so the create's own poll still bills, and nothing else about the proxy changesbackground: truetake this path; foreground ones bill as beforeapi_baseapi_base, so a bill written by a delete on another replica or worker, or by a poll resumed after a restart, showsapi_base: ""in/spend/logs, while creator-settled rows show the deployment's base as before. Cost, tokens, model, and key attribution are unaffected. Ships as is by mateo's approval on this PR (the LGTM review), and the one-key fix is tracked in LIT-8573tests/test_litellm/interactions/test_background_cost_polling.pyintotests/unit(test: move tests/test_litellm root and small trees into tests/unit #43186); the PR's own diff is unchangedtests/unit/proxy/spend_tracking/, which the proxy-db shards claim file by file in.circleci/scripts/unit_selection.sh, so no CI shard ran it and codecov/patch dropped to 81.61%; 5ef919d moves it back undertests/test_litellm/proxy/spend_tracking/, which the proxy-infra shard collects wholetests/test_litellm/proxytotests/unit/proxy(ci: move tests/proxy_unit_tests to tests/unit/proxy and run the proxy-db shards from litellm-tests #42903) and with the Lens models main added next to the settlement model inschema.prisma(refactor(lens)!: rename internal engine code and API #44034); main's new lint gates also needed five small adaptations in the PR's code, made in the same committests/unit/proxy/spend_tracking/now, which the proxy-infra GitHub Actions shard collects whole since ci: move tests/proxy_unit_tests to tests/unit/proxy and run the proxy-db shards from litellm-tests #42903, and the threeschema.prismacopies carry the settlement model right before the Lens models# mutable-okmarkers that no longer suppressed anything are deleted (LIT013), the two stacked-forcomprehensions in_json_valuesand_pending_rowsare split into a named intermediate andchain.from_iterable(LIT014),_create_contextvalidates the logging object's untypedcall_type, metadata, andlitellm_paramsthrough pydantic TypeAdapters before reading them, andadelete's**kwargsis typedobject(fivereportUnknownArgumentTypeerrors that main's basedpyright budget no longer has room for). None changes what any input does: the validated values are the same str and mappings the old code iterated, and the context model already rejected a non-strcall_type(Decision 18).tests/integration/_support/upstream.py,tests/integration/_support/process.py, the threeschema.prismacopies, andproxy_server.pychanged on both sides); the PR's own diff is unchangedx-goog-api-keyheader; the merge keeps main's helper and the PR's observation. Unlike the two earlier merges, no proof is carried over this one: the audit matrix and the /live-pr-risk sections below ran at eee6988 against the new merge base b28ce93DELETEandGETof that interaction included; pre-existing on mainmax_budgetand the auth budget check (spend >= max_budget) answers 422budget_exceededto every later request with that key. The audit's budget cell shows it on both legs (the pinned key'sDELETEon another replica is 422 on base and head alike), so the cell deletes with a second key; a settlement by any key then bills the creator's key and releases its reservation, which is what this PR addsGETandDELETEof a background interaction, and so a settlement from another replica, need the Gemini credential in the environment (GEMINI_API_KEY); a deployment whose only key lives in the model config answers 500litellm.APIConnectionError: Google API key is requiredon both routes, on main and on this branch alikeconfig_cd.yamlwith the key only in the deployment), and the audit cells at this tip assert the environment credential on every settler call. Pre-existing on main; a fix is a credential lookup by the stored model, out of this PR's scopeosv-scanworkflow is red at the tip onGHSA-vfj7-8cjw-p6xm(npmbraces3.0.3, a dev dependency inui/litellm-dashboard/package-lock.json, no fixed version published). This PR does not touch that lockfile and every other branch scanned in the same hour fails on the same finding (for example https://github.com/BerriAI/litellm/actions/runs/37087700586), while main's last scan before the advisory passed (https://github.com/BerriAI/litellm/actions/runs/36975463472). It is not a required check and is left for the dependency bump that fixes it on maintracemalloc)InMemoryBackgroundSettlementStore.claimleaves aNoneentry andrecord_outcomeis a no-op, where main dropped its in-process poll entry when the task ended. The proxy installs the database store and is unaffected; a follow-up ticket covers dropping settled entries from the in-memory storeDecisions
user_api_*attribution metadata minus the auth object, and the deployment's custom pricing keys. Each value must be JSON; a non-JSON value is dropped rather than stringifiedclaimed_at IS NULL; there is no heartbeat or lease renewal, which is why a crash between claim and bill is the first Low caveat above rather than a re-poll, which would double bill whenever the crash came after the spend writestart_timeon the logging object (only test fixtures pass one; the proxy always passes a datetime) is read as UTC epoch seconds--num_workers 2each: that is the shape most customers deploy, and it is what showed the symptom also bites inside one instance, where the delete only bills on the worker that served the createrequester_custom_headerswas dropped from the carried keys as a dead key, since the enterprise logging path rebuilds it from the raw request at logging time and no settling replica has that requestunsettledrecord); settle in-process when this process holds the live poll (the same leftover row); stash the pre-delete usage for the local poll to bill once a claim succeeds (mutable cross-task state for an outage-only window, and no help when the delete lands on a non-creator replica)agentand nomodelreaches the poll with no model name, exactly as on main, so the stored context carries that None instead of rejecting the create: the first live rig pass at 93d56fb answered such a create with a 500 after the provider had already accepted it, while main answered 200 and billed it at 0.0 with an empty model. Fixed in 59c2ce4 with a regression test_create_contextvalidates the logging object'scall_type, metadata, andlitellm_paramsthrough pydantic TypeAdapters (strandMapping[str, object]) instead of reading the untyped attributes directly. A non-strcall_typealready failed the context model's own validation and a non-mapping already failed the.items()call, so the validated copies change no outcome; what they clear is the fivereportUnknownArgumentTypeerrors main's basedpyright budget no longer has room for. Alternatives considered: typingLogging.call_typeandLogging.litellm_paramsat their source (a change to the core logging class far outside this PR); building the context withmodel_validateon a dict (one validation, but the field names leave the type checker's sight);str()anddict()coercions (a coercion where a validation that raises on a wrong shape is the honest one)GeminiErrorwith the vendor's status and body on a non-2xx fetch (44c3026).AsyncHTTPHandler.getnever raises for status and the transform only raised when the body was not JSON, so a 404 for an unknown or deleted id and a 500 with Gemini's JSON error body both parsed as an interaction with every field null: the proxy GET route answered 200 with null fields, the poll loop read a null status and kept polling, and a delete on another replica read that null status as "fetched", claimed the settlement as released, and forwarded the delete, so the fail-closed branch of Decision 15 was unreachable and the bill was lost (the audit's S1 and S2 cells at 6ce60b9, settlement row settle-in-progress-8309a672-7bb1e9e5 claimed with outcome released). The delete transform already raised the same way. Alternatives considered: checking the status in the sharedget_interactionandasync_get_interactionhandlers (every provider's GET would change at once and_handle_erroronly mapshttpx.HTTPStatusError);raise_for_statusinsideAsyncHTTPHandler.get(every caller of the shared HTTP client would change); keeping the null-field response and teaching the three readers to treat a null status as an error (three readers to keep in step, and the GET route would still answer 200 for a vendor error)Final Attestation
Note
High Risk
Changes multi-replica spend tracking, budget reservations, and delete semantics, plus a new migration and exactly-once claim logic where store or claim failures can leave interactions unbilled or block deletes.
Overview
Background interaction billing no longer relies on an in-process flag on the replica that created the interaction. Creates register a pending settlement (billing attribution in JSON, no provider credentials) in a pluggable store; the proxy uses a new
LiteLLM_BackgroundInteractionSettlementtable with a conditionalclaimed_atupdate so poll, delete, and post-restart resume settle exactly once across workers and pods.Delete on any replica can load that row, rebuild the logging context, pre-fetch with the delete’s credentials, then bill or release budget reservations. If that fetch fails on a non-creator replica, the delete fails closed instead of deleting unbilled. Proxy startup installs the Prisma store and resumes unclaimed rows. The SDK default stays in-memory.
Gemini interaction GET now raises on non-2xx responses (instead of 200 with null fields). Scheduling cost polling is async and registers the durable row before returning.
Reviewed by Cursor Bugbot for commit 2cdc471. Bugbot is set up for automated code reviews on this repo. Configure here.