feat: LLM usage and cost accounting, with an in-app pricing editor - #99
feat: LLM usage and cost accounting, with an in-app pricing editor#99notSumit25 wants to merge 1 commit into
Conversation
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>
Regression / test suite statusCI checks — 9/9 green
|
| 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:
- Slash-containing model names (
meta-llama/Llama-3-8b) were rejected with a bare 400 — Spring Security's defaultStrictHttpFirewallblocks%2Fbefore any controller runs, platform-wide. Fixed with a pathlessPUT /pricingthat takes the name in the body; the firewall was left alone. - A failed save rendered nothing at all — no error, no toast. Two causes: Spring's default 500 body has no
message, and an uncaughtmutateAsyncrejection stopped the error banner from painting. Both fixed and re-verified by takingsystem_configaway 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.
Every model call now writes one
llm_usagerow (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
usageblock 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.