Skip to content

feat: LLM usage and cost accounting, with an in-app pricing editor - #99

Open
notSumit25 wants to merge 1 commit into
mainfrom
feat/llm-usage-cost-accounting
Open

feat: LLM usage and cost accounting, with an in-app pricing editor#99
notSumit25 wants to merge 1 commit into
mainfrom
feat/llm-usage-cost-accounting

Conversation

@notSumit25

Copy link
Copy Markdown
Collaborator

Every model call now writes one llm_usage row (V119), surfaced at Settings -> AI Usage & Cost: spend by feature, user and model, a daily trend, and an editable per-model rate table.

Capture sits at the two provider funnels -- RefreshableChatModel and EmbeddingService -- rather than in feature code, so a new caller is accounted for without touching it. LlmUsageRecorder swallows its own failures by design: accounting must never break the call it measures. Rows are written through LlmUsageWriter in their own transaction, because self-invoking a @transactional method bypasses the Spring proxy and the row would roll back with a failed chat turn.

Rates live in system_config with no bundled defaults -- a stale price list produces confident wrong totals nobody checks. An unpriced model stores a NULL cost, never 0, and the UI reports the gap instead of silently understating spend.

Two pre-existing bugs surfaced while wiring this up:

  • ResponsesApiChatModel.buildMetadata discarded the provider's usage block entirely. Harmless while nothing read it, a silent zero the moment accounting summed it: real billed calls recorded 0 tokens and $0.00. It now reads both vendor dialects, and the streaming path requests usage (stream_options.include_usage) and emits the late usage event so a streamed turn is billable at all.

  • Attribution cannot ride a ThreadLocal. Chat returns a Flux and does its model work later on a CompletableFuture, so the servlet is gone by then; every row read feature=unknown while a single-threaded unit test of the filter passed. ChatService now re-establishes the scope where it already re-establishes the SQL actor.

Verified against the running stack, not inferred: real Azure OpenAI calls at three configured rates, costs matching to six decimals with no restart; a DEVELOPER 403'd on all endpoints with the rejected write leaving rates untouched; and chat still answering with the llm_usage table deleted outright.

Every model call now writes one `llm_usage` row (V119), surfaced at
Settings -> AI Usage & Cost: spend by feature, user and model, a daily
trend, and an editable per-model rate table.

Capture sits at the two provider funnels -- RefreshableChatModel and
EmbeddingService -- rather than in feature code, so a new caller is
accounted for without touching it. LlmUsageRecorder swallows its own
failures by design: accounting must never break the call it measures.
Rows are written through LlmUsageWriter in their own transaction,
because self-invoking a @transactional method bypasses the Spring proxy
and the row would roll back with a failed chat turn.

Rates live in system_config with no bundled defaults -- a stale price
list produces confident wrong totals nobody checks. An unpriced model
stores a NULL cost, never 0, and the UI reports the gap instead of
silently understating spend.

Two pre-existing bugs surfaced while wiring this up:

- ResponsesApiChatModel.buildMetadata discarded the provider's `usage`
  block entirely. Harmless while nothing read it, a silent zero the
  moment accounting summed it: real billed calls recorded 0 tokens and
  $0.00. It now reads both vendor dialects, and the streaming path
  requests usage (stream_options.include_usage) and emits the late
  usage event so a streamed turn is billable at all.

- Attribution cannot ride a ThreadLocal. Chat returns a Flux and does
  its model work later on a CompletableFuture, so the servlet is gone
  by then; every row read feature=unknown while a single-threaded unit
  test of the filter passed. ChatService now re-establishes the scope
  where it already re-establishes the SQL actor.

Verified against the running stack, not inferred: real Azure OpenAI
calls at three configured rates, costs matching to six decimals with no
restart; a DEVELOPER 403'd on all endpoints with the rejected write
leaving rates untouched; and chat still answering with the llm_usage
table deleted outright.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@notSumit25
notSumit25 requested a review from a team as a code owner September 2, 2026 13:26
@notSumit25

Copy link
Copy Markdown
Collaborator Author

Regression / test suite status

CI checks — 9/9 green

CodeQL · analyze (java-kotlin) · analyze (javascript-typescript) · backend (compile + package) · backend tests (advisory) · docker compose build · frontend (build) · frontend lint (advisory) · mcp (syntax)

⚠️ The green tick on backend tests (advisory) is misleading — read this

That job carries continue-on-error: true (ci.yml#L127), so it goes green whenever the step runs, regardless of the result. Its actual log on this PR says:

Tests run: 1528, Failures: 62, Errors: 7, Skipped: 23
[INFO] BUILD FAILURE

None of those failures come from this PR. Evidence, not inference — I reproduced CI's exact conditions locally (fresh pgvector/pgvector:pg18, CI's env vars, no live backend) and ran the 11 affected classes with and without this branch's changes:

Tests Failures Errors
origin/main @ 1b36bda (this branch's base) 112 35 5
This branch 112 35 5

Byte-identical. The failing classes are AgentOrchestratorTest, BrainEndpointsSmokeTest, BrainInitStageExecutorTest, BusinessRuleMemoryServiceTest, ChatServiceRoutingTest, IndexAdvisorServiceTest, Phase25TelemetryIntegrationTest, PostgresIntrospectionProviderTest, SchemaMetadataExecutorTest, SlackDailyDigestServiceTest, SlowQueryControllerIntegrationTest, TelemetryFlowIntegrationTest, TrainingServiceBusinessTermTest — Spring-context loads and Mockito strict-stubbing, all pre-existing.

I checked ChatServiceRoutingTest specifically, since this PR edits ChatService: it fails identically on the base commit.

This PR's own tests — 77/77 under CI conditions

LlmPricingServiceTest                     22 ✓
LlmUsageRecorderTest                      13 ✓
LlmUsageAttributionFilterTest              9 ✓
RefreshableChatModelUsageTest             11 ✓
ResponsesApiChatModelUsageTest            12 ✓
ApiSmokeTest                               3 ✓   (full Spring context boots with the 7 new beans)
ConnectionScopedAuthorizationSafetyTest    7 ✓   (accepts the new admin-only controller)

ApiSmokeTest is the load-bearing one: it proves the bean graph wires up, including the SystemConfigRepository injection added to LlmPricingService.

Local regression suite (npm run test:local-regression)

Build-and-boot only — health probes, frontend production build, ApiSmokeTest. No seed step; the one data-driven step (Brain retrieval smoke suite) is hardcoded to skip because that fixture lives outside this repo. Nothing here needed seeding: llm_usage starts empty and fills from real traffic, and pricing starts empty by design (bundling default prices is the specific thing LlmPricingService refuses to do).

Known coverage gap — worth a follow-up, not a blocker

The regression suite cannot catch the class of bug this PR fixes. It never makes a model call, so it would have stayed green while every row recorded 0 tokens and feature=unknown — both of which were real, and both found only by manual QA against the running stack.

Smallest useful addition (~30 lines, no LLM spend): seed 2–3 llm_usage rows plus one pricing key, call GET /admin/llm-usage/summary, assert the totals match. That would catch a broken aggregate or a JPQL constructor-expression typo. Deliberately not in this PR to keep the diff scoped.

Manual QA

Full hands-on pass against the running stack — 25 scenarios, each verified at the API, DB, and UI layers. Two real defects were found and fixed in-branch:

  1. Slash-containing model names (meta-llama/Llama-3-8b) were rejected with a bare 400 — Spring Security's default StrictHttpFirewall blocks %2F before any controller runs, platform-wide. Fixed with a pathless PUT /pricing that takes the name in the body; the firewall was left alone.
  2. A failed save rendered nothing at all — no error, no toast. Two causes: Spring's default 500 body has no message, and an uncaught mutateAsync rejection stopped the error banner from painting. Both fixed and re-verified by taking system_config away mid-save.

Also verified live: costs matching to six decimals at three different configured rates with no restart; a DEVELOPER 403'd on all endpoints with the rejected write leaving rates untouched; and chat still answering with the llm_usage table deleted outright (the accounting-must-never-break-the-call property).

Deploy note

V119__create_llm_usage.sql must be applied by hand on existing installs. This repo has no Flyway runtime — ddl-auto=update will create the table from the entity but not the four indexes, which matter because every rollup filters on created_at.

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.

1 participant