llm_cost_tracker 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +7 -4
  4. data/app/assets/llm_cost_tracker/application.css +8 -7
  5. data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -5
  6. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/pricing_controller.rb +1 -1
  8. data/app/helpers/llm_cost_tracker/application_helper.rb +6 -15
  9. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  10. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +4 -4
  11. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  12. data/app/models/llm_cost_tracker/call.rb +28 -63
  13. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  14. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  15. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  16. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
  17. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
  18. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  19. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  20. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  21. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  22. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  23. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +30 -44
  24. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +4 -60
  25. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +1 -7
  26. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  27. data/app/views/layouts/llm_cost_tracker/application.html.erb +0 -6
  28. data/app/views/llm_cost_tracker/calls/index.html.erb +8 -8
  29. data/app/views/llm_cost_tracker/calls/show.html.erb +31 -23
  30. data/app/views/llm_cost_tracker/dashboard/index.html.erb +8 -8
  31. data/app/views/llm_cost_tracker/data_quality/index.html.erb +62 -117
  32. data/app/views/llm_cost_tracker/models/index.html.erb +5 -5
  33. data/app/views/llm_cost_tracker/pricing/index.html.erb +2 -2
  34. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -1
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -3
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +10 -10
  39. data/config/routes.rb +2 -3
  40. data/lib/llm_cost_tracker/budget.rb +24 -26
  41. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  42. data/lib/llm_cost_tracker/capture/sse.rb +1 -0
  43. data/lib/llm_cost_tracker/capture/stream_collector.rb +28 -36
  44. data/lib/llm_cost_tracker/capture/stream_tracker.rb +17 -28
  45. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  46. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  47. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  48. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
  49. data/lib/llm_cost_tracker/check.rb +5 -0
  50. data/lib/llm_cost_tracker/configuration.rb +13 -44
  51. data/lib/llm_cost_tracker/currency.rb +5 -0
  52. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  53. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  54. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  55. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  56. data/lib/llm_cost_tracker/doctor.rb +5 -69
  57. data/lib/llm_cost_tracker/engine.rb +4 -4
  58. data/lib/llm_cost_tracker/event.rb +12 -20
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
  63. data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
  64. data/lib/llm_cost_tracker/ingestion/inbox.rb +7 -8
  65. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  66. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  67. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  68. data/lib/llm_cost_tracker/integrations/anthropic.rb +92 -106
  69. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  70. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  71. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  72. data/lib/llm_cost_tracker/integrations/openai.rb +70 -276
  73. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +87 -99
  74. data/lib/llm_cost_tracker/integrations.rb +32 -25
  75. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  76. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  77. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  78. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  79. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  81. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  82. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  83. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  84. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  85. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  86. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  87. data/lib/llm_cost_tracker/ledger/store.rb +18 -42
  88. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  89. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  90. data/lib/llm_cost_tracker/ledger.rb +8 -18
  91. data/lib/llm_cost_tracker/logging.rb +4 -21
  92. data/lib/llm_cost_tracker/middleware/faraday.rb +61 -50
  93. data/lib/llm_cost_tracker/parsers.rb +139 -26
  94. data/lib/llm_cost_tracker/prices.json +1707 -1
  95. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  96. data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
  97. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  98. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  99. data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
  100. data/lib/llm_cost_tracker/pricing/mode.rb +40 -52
  101. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  102. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  103. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  104. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  105. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  106. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  107. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  108. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  109. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  110. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  111. data/lib/llm_cost_tracker/pricing.rb +10 -278
  112. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  113. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  114. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  115. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  116. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  117. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  118. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  119. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  120. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  121. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  122. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  123. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  124. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +63 -39
  125. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  126. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  127. data/lib/llm_cost_tracker/providers.rb +35 -0
  128. data/lib/llm_cost_tracker/railtie.rb +0 -3
  129. data/lib/llm_cost_tracker/report/data.rb +3 -4
  130. data/lib/llm_cost_tracker/report/formatter.rb +1 -1
  131. data/lib/llm_cost_tracker/report.rb +1 -1
  132. data/lib/llm_cost_tracker/retention.rb +6 -19
  133. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  134. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  135. data/lib/llm_cost_tracker/timing.rb +2 -4
  136. data/lib/llm_cost_tracker/tracker.rb +24 -36
  137. data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
  138. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  139. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
  140. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  141. data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
  142. data/lib/llm_cost_tracker/version.rb +1 -1
  143. data/lib/llm_cost_tracker.rb +43 -52
  144. data/lib/tasks/llm_cost_tracker.rake +14 -73
  145. metadata +81 -55
  146. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -100
  147. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  148. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  149. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  150. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  151. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -174
  152. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  153. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  154. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  155. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  156. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  157. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  158. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  159. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  160. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  161. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  162. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  163. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -36
  164. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -27
  165. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  166. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  167. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  168. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  169. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  170. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  171. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  172. data/lib/llm_cost_tracker/masking.rb +0 -39
  173. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -176
  174. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  175. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  176. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -230
  177. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  178. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -45
  179. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  180. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  181. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  182. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  183. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +0 -15
  184. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  185. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -131
  186. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  187. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  188. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  189. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -249
  190. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -148
  191. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +0 -40
  192. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  193. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -118
  194. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  195. data/lib/llm_cost_tracker/token_usage.rb +0 -93
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 547223e1b3bb49e347aee3b92c0734baf563e83c62c64a97356f7b0c0d175e85
4
- data.tar.gz: 2d7601383de690b491b65a2aa5c33185dca6a6d7e9aefb53310eff7dc70102e6
3
+ metadata.gz: a3bb624cf9437e2ab972021128ab552b48b16c9b8d209429fb264062837e8547
4
+ data.tar.gz: 8785221213ed888a592b312e5a734193637653930ef9652ece73f650cb920eb5
5
5
  SHA512:
6
- metadata.gz: d88856b8451a27b706c28f153f8e4a68b7ec610a7a8052d6335481754c670785f2e87d970df913369f5c9d8de9a0716e11d7b60bc5f851ae2b617dcb386ecfd9
7
- data.tar.gz: d31b56366d4550f7c8ec4786e8029d566dd8742591ddd3ae61def4a0ee571df98338219a7b0d154f413ee345df3b84b28f645f217a64765e20ad98483e87496d
6
+ metadata.gz: c223c14dbfe3e2ebf61930175ae7607c2a4a05f502962963312c4ec929965242fccab115eda9d1426d6e331d7fb23ad811f73c9ba8a795cb3262c3d49a60eb45
7
+ data.tar.gz: 6b8e3ef019f41907909bb9f07eb58085dd6355a442699d52440de564c97fb6fb65979ee01271e4741bd9411ce7ef6a3b69102accd764fdf419d42db1bdb2f6e8
data/CHANGELOG.md CHANGED
@@ -4,6 +4,61 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.12.0] - 2026-06-04
8
+
9
+ ### Added
10
+
11
+ - `bin/rails llm_cost_tracker:rebuild_rollups` rebuilds the `llm_cost_tracker_call_rollups` cache from the calls ledger — populate it after turning on `config.cache_rollups` for an app with existing calls, or resync it if rollup totals ever drift from the calls.
12
+
13
+ ### Removed
14
+
15
+ - BREAKING: the experimental `Reconciliation` subsystem (provider invoice import + diff, the `/reconciliation` dashboard page, `bin/rails llm_cost_tracker:reconcile:*` rake tasks, `config.reconciliation_enabled`, `config.reconciliation_importers`, the `llm_cost_tracker:reconciliation` generator, and the `llm_cost_tracker_provider_invoices` / `_provider_invoice_imports` tables) is gone. It was never finished and never billing-accurate. `calls.provider_response_id` (captured on every call) already covers invoice cross-reference; if invoice-vs-ledger reconciliation ships again it lives in a separate gem. Existing installs can drop the two tables — see [docs/upgrading.md](docs/upgrading.md#v011--v012-unreleased).
16
+ - `config.instrument :gemnii` (or any other typo / unknown integration name) no longer raises at config time — it now logs `Logging.warn("Unknown integration: :gemnii. Known: ...")` once when integrations install, and `bin/rails llm_cost_tracker:doctor` shows the unknown name as a `:warn` row so the typo is visible without crashing boot.
17
+ - Pre-call budget enforcement for Azure-hosted OpenAI calls now keys on `"azure_openai"` (matching the recorded `Call.provider`), so `pricing_overrides` for Azure rates actually gate the call. Previously it always keyed on `"openai"` regardless of the SDK client's `base_url`.
18
+ - BREAKING: removed the `batch:` keyword argument from `LlmCostTracker.track`, `LlmCostTracker.track_stream`, and `stream.usage` (inside `track_stream` blocks). Signal a batch-tier call via `pricing_mode: :batch` (or any pricing_mode containing the `batch` token like `:batch_flex`) — that's the single source of truth now. Previously `batch:` and `pricing_mode:` could disagree, especially after request-side pricing_mode merge inside `Tracker.record` overwrote the parser's mode but left the stored `batch` flag stale, so `calls.batch` could read `true` while `calls.pricing_mode` read `flex` (or vice versa) for the same row.
19
+ - The `bin/rails llm_cost_tracker:prices:explain` rake task (and `LlmCostTracker::Pricing.explain`) is removed — the dashboard's Data Quality page surfaces unknown-pricing models and their effective rates instead.
20
+
21
+ ### Changed
22
+
23
+ - The RubyLLM SDK integration now requires `ruby_llm >= 1.15.0` (was `>= 1.14.1`).
24
+ - Engine no longer adds `tag` / `tag_value` to Rails `filter_parameters` — the Symbol filter was substring-matching unrelated host-app params (`tags`, `meta_tag`, etc.) into `[FILTERED]`. `Tags::Sanitizer` continues redacting secret-shaped tag values at storage.
25
+ - BREAKING: the serialized event `cost` (the `llm_request.llm_cost_tracker` notification payload and the async-ingestion inbox payload) is now `{ components: {...}, total:, currency: }` (was flat with a top-level `total_cost:`). Notification subscribers should read `cost[:total]`; `ingestion: :async` rolling deploys should drain the inbox first — see [docs/upgrading.md](docs/upgrading.md#v011--v012-unreleased).
26
+ - BREAKING: `pricing_mode` in the `llm_request.llm_cost_tracker` notification payload is now a String (e.g. `"batch"`, `"fast_data_residency"`), not a Symbol — subscribers matching it against a Symbol must compare to the String.
27
+ - BREAKING: `LlmCostTracker.track(tokens:)` now takes the same `_tokens`-suffixed keys as `stream.usage` and the stored columns — `input_tokens`, `output_tokens`, `cache_read_input_tokens`, `audio_input_tokens`, etc. (was the short `input`, `output`, `cache_read_input`, …). Update manual `track` calls. Pricing-file / `pricing_overrides` field names are unchanged — they stay `input`, `output`, … (per-component rates, a separate vocabulary).
28
+
29
+ ### Fixed
30
+
31
+ - RubyLLM streaming chats to Anthropic and Gemini (`chat.ask { |chunk| }`) are now recorded — previously the streamed response's raw body is the SSE text rather than the parsed hash the integration read, so an internal lookup raised and the call was silently dropped from the ledger. Blocking RubyLLM calls were unaffected.
32
+ - A malformed or very long `pricing_mode` (or a provider `service_tier` / `speed` with many underscore-separated tokens) no longer hangs cost calculation — the call lands `cost_status: unknown` instead of pinning a CPU.
33
+ - Gemini preview models dated with a four-digit year (e.g. `gemini-2.5-flash-preview-09-2025`) now fall back to the stable model's price instead of landing `cost_status: unknown`.
34
+ - A typo'd price-key prefix in `pricing_overrides` or a custom `prices_file` (e.g. `bath_input` for `batch_input`, or any unknown `<mode>_<component>`) now logs an `Unknown price keys` warning and is ignored, instead of being silently accepted so the override quietly never applied at the intended mode/tier.
35
+ - Anthropic responses with `service_tier: "priority"` now keep `:priority` as their pricing_mode instead of being silently billed at standard rates — committed-tier customers get `cost_status: unknown` (signaling to add `priority_input`/`priority_output` to `pricing_overrides`) instead of an over-counted USD figure that ignores their commitment discount.
36
+ - OpenAI's `scale` enterprise tier and `priority` tier are now recognized as pricing modes (no more `Logging.warn` about unknown tokens); calls land as `cost_status: unknown` when negotiated rates are absent so you can add them via `pricing_overrides`.
37
+ - Gemini responses echoing `usageMetadata.serviceTier: "unspecified"` (the default) now resolve to standard pricing instead of warning about an unknown token and landing as `cost_status: unknown`.
38
+ - Anthropic SDK batch results (`client.messages.batches.results_streaming(id).each`) land in the ledger with `pricing_mode: :batch` and the per-result `provider_response_id`, with a same-process best-effort dedup against already-ledgered `provider_response_id`s so re-iterating the stream doesn't duplicate rows (concurrent retrieves from multiple processes can still race; async-mode rows in the inbox aren't checked until they drain).
39
+ - OpenAI SDK batch processing auto-captures: `client.batches.retrieve(id)` on a completed batch downloads the output JSONL and emits one ledger event per response with `pricing_mode: :batch` and the per-response `provider_response_id`, with the same best-effort dedup as Anthropic batches.
40
+ - OpenRouter pricing is now scraped via `openrouter.ai/api/v1/models`, so RubyLLM-routed OpenRouter calls (e.g. `openrouter/openai/gpt-4o`) get a real `total_cost` from the next `prices_file` refresh instead of landing as `cost_status: unknown`. The scrape also captures `image` / `audio` per-token rates so OpenRouter calls with multimodal inputs bill against the correct bucket instead of folding image/audio tokens into the text-input rate.
41
+ - Misspelled `pricing_mode:` values now log a `Logging.warn` listing the unrecognized token (e.g. `:bach` for `:batch`) so the resulting `cost_status: unknown` call surfaces a typo instead of silently absorbing it; the warn fires once per unique token.
42
+ - Whisper-style transcriptions whose response carries `usage.type = "duration"` now emit a `transcription_minute` line item (quantity = `ceil(seconds / 60)`) across both the OpenAI Ruby SDK patch and the Faraday / RubyLLM HTTP path; the call previously recorded with zero tokens and no line item, so audio-minute usage was invisible.
43
+ - OpenAI Responses-API `image_generation_call` and `computer_call` output items now emit line items so per-call hosted-tool usage shows up on the dashboard alongside the existing `web_search_call` / `file_search_call` / `code_interpreter_call` coverage.
44
+ - `LlmCostTracker.track(..., enforce_budget: true)` now actually raises `BudgetExceededError` pre-call when the estimated cost (token cost plus priced service line items) overshoots the budget, even when `budget_exceeded_behavior: :notify` is configured — previously the kwarg silently no-op'd unless policy was already `:block_requests`.
45
+ - `Call#pricing_snapshot.rates` now includes per-charge rates for non-token service line items (web search, MCP calls, TTS character billing, etc.) — previously only token rates were captured, so audit/replay of service-charge pricing had no record of the rate that was actually applied.
46
+ - Tags with invalid keys (e.g. containing whitespace or characters outside `[\w.-]`) are now skipped at write with a `Logging.warn` instead of being silently written and then raising `InvalidFilterError` on dashboard read.
47
+ - A raising `default_tags` proc is now captured by `Logging.warn` and falls back to empty default tags, so a broken user callback doesn't take down every tracked call.
48
+ - `LlmCostTracker::Ingestion::Worker.shutdown!(drain: true)` always attempts the final inbox flush even if waking the worker thread raises, so pending inbox rows aren't left when the host process exits.
49
+ - Gemini preview-dated models (e.g. `gemini-2.5-flash-preview-04-17`) now resolve to the stable entry's pricing — previously the `preview-MM-DD` suffix didn't match the dated-snapshot regex so the call landed as `cost_status: unknown`.
50
+ - Gemini parser now reads `usageMetadata.serviceTier` from the response body in addition to the `x-gemini-service-tier` header, so tier-aware pricing applies when only the body carries the tier signal.
51
+ - Line-item and pricing-snapshot `currency` is now stored uppercase regardless of `prices_file` casing, so a `prices_file` with `currency: "eur"` shows up as `EUR` everywhere and service-line items don't get partitioned out of header totals on a case mismatch with cost-data currency.
52
+ - Async-ingestion inbox rows reaching `MAX_ATTEMPTS_BEFORE_QUARANTINE` now log a `Logging.warn` (with row ids) at the moment they quarantine, so production sees the event in `Rails.logger` instead of needing to run `bin/rails llm_cost_tracker:doctor` to discover it.
53
+ - Dashboard "Setup required" page now flags missing `llm_cost_tracker_ingestion_inbox_entries` and `llm_cost_tracker_ingestion_leases` tables when `ingestion: :async` is configured — previously the drift only surfaced as a worker boot crash.
54
+ - Gemini image-generation models (`gemini-2.5-flash-image`, `gemini-3-pro-image-preview`, `gemini-3.1-flash-image-preview`) and stable preview text models (`gemini-3.1-pro-preview`, `gemini-2.5-flash-lite-preview-09-2025`, etc.) are no longer dropped by the price scraper — they flow into the pricing snapshot on the next refresh cycle.
55
+ - Gemini parser splits `IMAGE`-modality tokens from `promptTokensDetails` / `candidatesTokensDetails` (mirroring the existing AUDIO handling), so image-output usage from Gemini calls routes to `image_output` rates instead of falling into the text-output bucket.
56
+ - RubyLLM SDK integration over-subtracted cache-read tokens from recorded `input_tokens` on chat completions, so the figure landed in the ledger short by the cache-read amount; the gem now passes RubyLLM's net `input_tokens` through unchanged.
57
+ - RubyLLM SDK integration captures `service_tier` from response bodies across Anthropic, OpenAI, and Gemini — previously the field was read from the wrong JSON path so batch and flex modes silently priced against standard rates.
58
+ - RubyLLM SDK integration records the provider's response id in `provider_response_id` (previously always nil), so each ledger row carries the upstream id you can cross-reference against provider invoices and logs.
59
+ - RubyLLM Anthropic chat completions split 1-hour and 5-minute cache writes into separate token buckets so 1h writes bill at the 2x extended rate instead of being lumped into the 5m bucket at 1.25x.
60
+ - Async-inbox `total_cost` now round-trips through the JSON payload without losing precision; previously the payload coerced `BigDecimal` to `Float` and dropped digits past ~15 significant figures, so high-volume aggregate billing under `ingestion: :async` came out systematically short. BREAKING for subscribers to the `llm_request.llm_cost_tracker` `ActiveSupport::Notifications` event: `payload[:cost]` numeric values are now decimal strings (was `Float`) — wrap with `BigDecimal(value)` before arithmetic.
61
+
7
62
  ## [0.11.0] - 2026-05-21
8
63
 
9
64
  ### Added
data/README.md CHANGED
@@ -32,16 +32,17 @@ gem "openai"
32
32
  bin/rails llm_cost_tracker:setup
33
33
  ```
34
34
 
35
- Runs the install generator, drops a price snapshot, migrates the database, and verifies via `llm_cost_tracker:doctor`.
35
+ Runs the install generator, drops a price snapshot, migrates the database, and verifies via `llm_cost_tracker:doctor`. The generated `config/initializers/llm_cost_tracker.rb` looks like:
36
36
 
37
37
  ```ruby
38
- # config/initializers/llm_cost_tracker.rb
39
38
  LlmCostTracker.configure do |config|
40
39
  config.default_tags = -> { { environment: Rails.env } }
41
40
  config.instrument :openai
42
41
  end
43
42
  ```
44
43
 
44
+ Edit it in place to add tags, switch on async ingestion, etc.
45
+
45
46
  Tag your calls to attribute spend:
46
47
 
47
48
  ```ruby
@@ -85,7 +86,9 @@ The engine ships without authentication on purpose.
85
86
  | Anything else | `LlmCostTracker.track` |
86
87
 
87
88
  Streams capture when the provider emits final usage. OpenAI Faraday streams
88
- need `stream_options: { include_usage: true }`.
89
+ get `stream_options: { include_usage: true }` auto-injected so the final
90
+ usage chunk lands in the ledger (opt out via
91
+ `config.auto_enable_stream_usage = false`).
89
92
 
90
93
  ## What it isn't
91
94
 
@@ -102,7 +105,7 @@ For batch jobs, internal gateways, or anything without an SDK/Faraday hook:
102
105
  LlmCostTracker.track(
103
106
  provider: :anthropic,
104
107
  model: "claude-sonnet-4-6",
105
- tokens: { input: 1500, output: 320 },
108
+ tokens: { input_tokens: 1500, output_tokens: 320 },
106
109
  tags: { feature: "summarizer", user_id: current_user.id }
107
110
  )
108
111
  ```
@@ -4,11 +4,12 @@
4
4
  --lct-surface-2: #f1f4f9;
5
5
  --lct-surface-hover: #eef2f7;
6
6
  --lct-panel: #ffffff;
7
+ --lct-panel-head-bg: #fbfcfe;
7
8
  --lct-border: #e3e8ef;
8
9
  --lct-border-strong: #cbd2dc;
9
10
  --lct-text: #0d1b2a;
10
- --lct-muted: #5a6573;
11
- --lct-subtle: #8b95a4;
11
+ --lct-muted: #475467;
12
+ --lct-subtle: #5a6573;
12
13
  --lct-accent: #5b54d8;
13
14
  --lct-accent-strong: #4540c4;
14
15
  --lct-accent-hover: #4540c4;
@@ -27,7 +28,6 @@
27
28
  --lct-danger-copy: #b91c1c;
28
29
  --lct-danger-strong: #991b1b;
29
30
  --lct-row-hover: #f8fafc;
30
- --lct-th-bg: #f1f4f9;
31
31
  --lct-toolbar-bg: rgba(255, 255, 255, 0.92);
32
32
  --lct-toolbar-border: #e3e8ef;
33
33
  --lct-chart-secondary: rgba(13, 27, 42, 0.42);
@@ -80,6 +80,7 @@
80
80
  --lct-surface-2: #1c2229;
81
81
  --lct-surface-hover: #232a35;
82
82
  --lct-panel: #161b22;
83
+ --lct-panel-head-bg: #1f262e;
83
84
  --lct-border: #2a313c;
84
85
  --lct-border-strong: #3d4654;
85
86
  --lct-text: #e6edf3;
@@ -103,7 +104,6 @@
103
104
  --lct-danger-copy: #f85149;
104
105
  --lct-danger-strong: #fecaca;
105
106
  --lct-row-hover: #1c2229;
106
- --lct-th-bg: #1c2229;
107
107
  --lct-toolbar-bg: rgba(22, 27, 34, 0.92);
108
108
  --lct-toolbar-border: #2a313c;
109
109
  --lct-shadow: 0 1px 2px rgba(0, 0, 0, 0.30);
@@ -123,6 +123,7 @@
123
123
  --lct-surface-2: #1c2229;
124
124
  --lct-surface-hover: #232a35;
125
125
  --lct-panel: #161b22;
126
+ --lct-panel-head-bg: #1f262e;
126
127
  --lct-border: #2a313c;
127
128
  --lct-border-strong: #3d4654;
128
129
  --lct-text: #e6edf3;
@@ -146,7 +147,6 @@
146
147
  --lct-danger-copy: #f85149;
147
148
  --lct-danger-strong: #fecaca;
148
149
  --lct-row-hover: #1c2229;
149
- --lct-th-bg: #1c2229;
150
150
  --lct-toolbar-bg: rgba(22, 27, 34, 0.92);
151
151
  --lct-toolbar-border: #2a313c;
152
152
  --lct-shadow: 0 1px 2px rgba(0, 0, 0, 0.30);
@@ -444,6 +444,8 @@
444
444
  gap: var(--sp-3);
445
445
  padding: 9px var(--sp-4);
446
446
  border-bottom: 1px solid var(--lct-border);
447
+ background: var(--lct-panel-head-bg);
448
+ border-radius: var(--radius-md) var(--radius-md) 0 0;
447
449
  }
448
450
  .lct-panel-title {
449
451
  font-size: var(--fs-md);
@@ -626,8 +628,7 @@
626
628
  color: var(--lct-muted);
627
629
  text-align: left;
628
630
  padding: 9px var(--sp-4);
629
- border-bottom: 1px solid var(--lct-border);
630
- background: var(--lct-th-bg);
631
+ border-bottom: 1px solid var(--lct-border-strong);
631
632
  white-space: nowrap;
632
633
  }
633
634
  .lct-tbl td {
@@ -26,7 +26,7 @@ module LlmCostTracker
26
26
  format.html do
27
27
  @page = Dashboard::Pagination.call(params)
28
28
  @calls_count, @calls_total_cost = scope.pick(Arel.sql("COUNT(*), COALESCE(SUM(total_cost), 0)"))
29
- @calls = ordered_scope.includes(:tag_records).limit(@page.limit).offset(@page.offset).to_a
29
+ @calls = ordered_scope.includes(:tag_records).limit(@page.per).offset(@page.offset).to_a
30
30
  end
31
31
  format.csv do
32
32
  response.headers["Cache-Control"] = "no-store"
@@ -83,7 +83,7 @@ module LlmCostTracker
83
83
 
84
84
  def csv_fields
85
85
  %i[tracked_at provider model] +
86
- TokenUsage.members +
86
+ Usage::TokenUsage.members +
87
87
  %i[
88
88
  total_cost cost_status pricing_snapshot latency_ms provider_response_id provider_project_id
89
89
  provider_api_key_id provider_workspace_id batch tags
@@ -93,15 +93,15 @@ module LlmCostTracker
93
93
  def csv_value(field, call)
94
94
  case field
95
95
  when :tracked_at
96
- call.tracked_at&.utc&.iso8601
96
+ call.tracked_at.utc.iso8601
97
97
  when :provider_api_key_id, :provider_workspace_id, :provider_project_id
98
- csv_safe(LlmCostTracker::Masking.mask_value(field, call[field]))
98
+ csv_safe(LlmCostTracker::Dashboard::Masking.mask_value(field, call[field]))
99
99
  when :provider, :model, :provider_response_id, :cost_status
100
100
  csv_safe(call[field])
101
101
  when :pricing_snapshot
102
102
  csv_safe(csv_json(call.pricing_snapshot))
103
103
  when :tags
104
- csv_safe(call.parsed_tags.to_json)
104
+ csv_safe(call.tag_pairs.to_json)
105
105
  else
106
106
  call[field]
107
107
  end
@@ -13,7 +13,7 @@ module LlmCostTracker
13
13
  )
14
14
 
15
15
  @stats = Dashboard::OverviewStats.call(scope: scope, previous_scope: previous_scope)
16
- @monthly_budget_status = Dashboard::OverviewStats.monthly_budget_status
16
+ @monthly_budget_status = Dashboard::MonthlyBudget.status
17
17
  @time_series = Dashboard::TimeSeries.call(scope: scope, from: @from_date, to: @to_date)
18
18
  @comparison_series = Dashboard::TimeSeries.call(scope: previous_scope, from: prev_from, to: prev_to)
19
19
  @spend_anomaly = Dashboard::SpendAnomaly.call(from: @from_date, to: @to_date, scope: scope)
@@ -4,7 +4,7 @@ module LlmCostTracker
4
4
  class PricingController < ApplicationController
5
5
  def index
6
6
  @overview = Dashboard::PricingOverview.call
7
- requested = params[:source].to_s.to_sym
7
+ requested = params[:source]&.to_sym
8
8
  @active_source = @overview.fetch(:sources).key?(requested) ? requested : @overview.fetch(:effective_source)
9
9
  @source_data = @overview.fetch(:sources).fetch(@active_source)
10
10
  @provider_filter = params[:provider].to_s.presence
@@ -7,7 +7,6 @@ module LlmCostTracker
7
7
  TAG_VALUE_SUMMARY_BYTES = 80
8
8
  TAG_TOOLTIP_BYTES = 512
9
9
 
10
- include DashboardFilterHelper
11
10
  include DashboardFilterOptionsHelper
12
11
  include DashboardQueryHelper
13
12
  include ChartHelper
@@ -22,7 +21,6 @@ module LlmCostTracker
22
21
  return :tags if path.start_with?(tags_path)
23
22
  return :data_quality if path.start_with?(data_quality_path)
24
23
  return :pricing if path.start_with?(pricing_path)
25
- return :reconciliation if LlmCostTracker.reconciliation_enabled? && path.start_with?(reconciliation_path)
26
24
 
27
25
  :overview
28
26
  end
@@ -45,26 +43,19 @@ module LlmCostTracker
45
43
  value.nil? ? "n/a" : money(value)
46
44
  end
47
45
 
48
- def optional_number(value)
49
- value.nil? ? "n/a" : number(value)
50
- end
51
-
52
- def number(value)
53
- number_with_delimiter(value)
54
- end
55
-
56
46
  def format_date(value)
57
- value.try(:strftime, "%Y-%m-%d %H:%M") || value.to_s
47
+ return "" if value.nil?
48
+
49
+ value.strftime("%Y-%m-%d %H:%M")
58
50
  end
59
51
 
60
52
  def pricing_status(call)
61
53
  return "Unknown" if call.total_cost.nil?
62
- return "Estimated" unless call.has_attribute?(:cost_status)
63
54
 
64
55
  {
65
- LlmCostTracker::Billing::CostStatus::COMPLETE => "Estimated",
66
- LlmCostTracker::Billing::CostStatus::FREE => "Free",
67
- LlmCostTracker::Billing::CostStatus::PARTIAL => "Partial"
56
+ LlmCostTracker::Charges::CostStatus::COMPLETE => "Estimated",
57
+ LlmCostTracker::Charges::CostStatus::FREE => "Free",
58
+ LlmCostTracker::Charges::CostStatus::PARTIAL => "Partial"
68
59
  }.fetch(call.cost_status, "Unknown")
69
60
  end
70
61
 
@@ -4,17 +4,7 @@ module LlmCostTracker
4
4
  module DashboardFilterOptionsHelper
5
5
  MAX_FILTER_OPTIONS = 100
6
6
 
7
- def provider_filter_options(filter_params: params)
8
- filter_options_for(:provider, filter_params: filter_params)
9
- end
10
-
11
- def model_filter_options(filter_params: params)
12
- filter_options_for(:model, filter_params: filter_params)
13
- end
14
-
15
- private
16
-
17
- def filter_options_for(column, filter_params:)
7
+ def filter_options_for(column, filter_params: params)
18
8
  source = LlmCostTracker::Dashboard::Params.to_hash(filter_params).symbolize_keys
19
9
  scope_params = source.merge(
20
10
  column => nil, format: nil, page: nil, per: nil, sort: nil
@@ -2,8 +2,8 @@
2
2
 
3
3
  module LlmCostTracker
4
4
  module SortableTableHelper
5
- def sortable_header(label, column, num: false)
6
- state = sortable_state(column, num: num)
5
+ def sortable_header(label, column, num: false, default: false)
6
+ state = sortable_state(column, num: num, default: default)
7
7
  classes = ["lct-sortable"]
8
8
  classes << "lct-num" if num
9
9
  classes << "lct-sorted" if state[:active]
@@ -16,8 +16,8 @@ module LlmCostTracker
16
16
 
17
17
  private
18
18
 
19
- def sortable_state(column, num:)
20
- current_sort = params[:sort].to_s
19
+ def sortable_state(column, num:, default: false)
20
+ current_sort = params[:sort].presence || (default ? column : nil)
21
21
  current_dir = Dashboard::Sort::DIRECTIONS.include?(params[:dir].to_s) ? params[:dir].to_s : nil
22
22
  natural_dir = num ? "desc" : "asc"
23
23
  active = current_sort == column
@@ -40,11 +40,9 @@ module LlmCostTracker
40
40
 
41
41
  def call_line_item_costs_by_component(call)
42
42
  call.line_items.each_with_object({}) do |line_item, accumulator|
43
- component = LlmCostTracker::Billing::Components::TOKEN_PRICED.find do |item|
44
- item.kind.to_s == line_item.kind.to_s &&
45
- item.direction.to_s == line_item.direction.to_s &&
46
- item.cache_state.to_s == line_item.cache_state.to_s
47
- end
43
+ component = LlmCostTracker::Usage::Catalog.token_priced_for(
44
+ kind: line_item.kind, direction: line_item.direction, cache_state: line_item.cache_state
45
+ )
48
46
  accumulator[component.key] = line_item.cost if component && line_item.cost
49
47
  end
50
48
  end
@@ -52,7 +50,7 @@ module LlmCostTracker
52
50
  private
53
51
 
54
52
  def token_usage_display_components(labels:)
55
- LlmCostTracker::Billing::Components::TOKEN_PRICED.map do |component|
53
+ LlmCostTracker::Usage::Catalog.token_priced.map do |component|
56
54
  token_key = component.token_key
57
55
  {
58
56
  token_key: token_key,
@@ -2,43 +2,26 @@
2
2
 
3
3
  require "securerandom"
4
4
 
5
- require "llm_cost_tracker/billing/cost_status"
6
- require "llm_cost_tracker/ledger/schema/adapter"
7
- require "llm_cost_tracker/ledger/tags/sql"
8
-
9
5
  module LlmCostTracker
10
6
  class Call < ActiveRecord::Base
11
7
  before_validation :assign_event_id
12
8
 
13
- PERIOD_FORMATS = {
14
- day: {
15
- postgres: "YYYY-MM-DD",
16
- mysql: "%Y-%m-%d"
17
- },
18
- month: {
19
- postgres: "YYYY-MM",
20
- mysql: "%Y-%m"
21
- }
22
- }.freeze
23
-
24
- private_constant :PERIOD_FORMATS
25
-
26
9
  scope :with_cost, -> { where.not(total_cost: nil) }
27
10
  scope :without_cost, -> { where(total_cost: nil) }
28
- scope :unknown_pricing, lambda {
29
- where(total_cost: nil).or(
30
- where(cost_status: [Billing::CostStatus::UNKNOWN, Billing::CostStatus::PARTIAL])
31
- )
32
- }
11
+ scope :unknown_pricing,
12
+ lambda {
13
+ where(Charges::CostStatus.unknown_pricing_sql)
14
+ }
33
15
  scope :with_latency, -> { where.not(latency_ms: nil) }
34
16
  scope :streaming, -> { where(stream: true) }
35
17
  scope :non_streaming, -> { where(stream: [false, nil]) }
36
18
  scope :by_usage_source, ->(source) { where(usage_source: source.to_s) }
37
19
  scope :with_provider_response_id, -> { where.not(provider_response_id: [nil, ""]) }
38
20
  scope :missing_provider_response_id, -> { where(provider_response_id: [nil, ""]) }
39
- scope :streaming_missing_usage, lambda {
40
- where(stream: true).where(usage_source: ["unknown", nil])
41
- }
21
+ scope :streaming_missing_usage,
22
+ lambda {
23
+ where(stream: true).where(usage_source: [Usage::Source::UNKNOWN, nil])
24
+ }
42
25
 
43
26
  has_many :line_items,
44
27
  class_name: "LlmCostTracker::CallLineItem",
@@ -58,6 +41,12 @@ module LlmCostTracker
58
41
  scope :between, ->(from, to) { where(tracked_at: from..to) }
59
42
 
60
43
  class << self
44
+ def already_recorded?(provider:, provider_response_id:)
45
+ return false if provider_response_id.to_s.empty?
46
+
47
+ where(provider: provider, provider_response_id: provider_response_id).exists?
48
+ end
49
+
61
50
  def by_tag(key, value) = by_tags(key => value)
62
51
 
63
52
  def by_tags(tags) = Ledger::Tags::Query.apply(tags)
@@ -71,20 +60,20 @@ module LlmCostTracker
71
60
  def cost_by_provider(limit: nil) = cost_by_column(:provider, limit: limit)
72
61
 
73
62
  def group_by_tag(key)
74
- Ledger::Tags::Sql.join_relation(self, key).group(Ledger::Tags::Sql.value_arel)
63
+ Ledger::Tags::Breakdown.join_relation(self, key).group(Ledger::Tags::Breakdown.value_arel)
75
64
  end
76
65
 
77
66
  def cost_by_tag(key, limit: nil)
78
- label = Ledger::Tags::Sql.label_sql(connection)
79
- raw_value = Ledger::Tags::Sql.raw_value_sql(connection)
80
- relation = Ledger::Tags::Sql.join_relation(self, key)
81
- .select("#{label} AS name", "COALESCE(SUM(total_cost), 0) AS total_cost")
82
- .group(Arel.sql(label))
83
- .order(
84
- Arel.sql("COALESCE(SUM(total_cost), 0) DESC"),
85
- Arel.sql("MAX(CASE WHEN #{raw_value} IS NULL THEN 1 ELSE 0 END) ASC"),
86
- Arel.sql("#{label} DESC")
87
- )
67
+ label = Ledger::Tags::Breakdown.label_sql(connection)
68
+ raw_value = Ledger::Tags::Breakdown.raw_value_sql(connection)
69
+ relation = Ledger::Tags::Breakdown.join_relation(self, key)
70
+ .select("#{label} AS name", "COALESCE(SUM(total_cost), 0) AS total_cost")
71
+ .group(Arel.sql(label))
72
+ .order(
73
+ Arel.sql("COALESCE(SUM(total_cost), 0) DESC"),
74
+ Arel.sql("MAX(CASE WHEN #{raw_value} IS NULL THEN 1 ELSE 0 END) ASC"),
75
+ Arel.sql("#{label} DESC")
76
+ )
88
77
  relation = relation.limit(limit) if limit
89
78
  relation
90
79
  end
@@ -108,8 +97,7 @@ module LlmCostTracker
108
97
  private
109
98
 
110
99
  def cost_by_column(column, limit:)
111
- quoted_column = "#{quoted_table_name}.#{connection.quote_column_name(column)}"
112
- relation = select("#{quoted_column} AS name, COALESCE(SUM(total_cost), 0) AS total_cost")
100
+ relation = select(arel_table[column].as("name"), "COALESCE(SUM(total_cost), 0) AS total_cost")
113
101
  .group(column)
114
102
  .order(Arel.sql("COALESCE(SUM(total_cost), 0) DESC"))
115
103
  relation = relation.limit(limit) if limit
@@ -117,30 +105,7 @@ module LlmCostTracker
117
105
  end
118
106
 
119
107
  def period_group_expression(period, column:)
120
- period = validated_period(period)
121
- column = period_column_expression(column)
122
- formats = PERIOD_FORMATS.fetch(period)
123
-
124
- if Ledger::Schema::Adapter.postgresql?(connection)
125
- postgres_period_expression(period, column, formats)
126
- elsif Ledger::Schema::Adapter.mysql?(connection)
127
- "DATE_FORMAT(#{column}, #{connection.quote(formats.fetch(:mysql))})"
128
- else
129
- Ledger::Schema::Adapter.ensure_supported!(connection)
130
- end
131
- end
132
-
133
- def postgres_period_expression(period, column, formats)
134
- "TO_CHAR(" \
135
- "DATE_TRUNC(#{connection.quote(period.name)}, #{column}), " \
136
- "#{connection.quote(formats.fetch(:postgres))}" \
137
- ")"
138
- end
139
-
140
- def validated_period(period)
141
- return period if PERIOD_FORMATS.key?(period)
142
-
143
- raise ArgumentError, "invalid period: #{period.inspect}"
108
+ Ledger::Schema::Adapter.period_bucket_sql(connection, period, period_column_expression(column))
144
109
  end
145
110
 
146
111
  def period_column_expression(column)
@@ -151,7 +116,7 @@ module LlmCostTracker
151
116
  end
152
117
  end
153
118
 
154
- def parsed_tags
119
+ def tag_pairs
155
120
  tag_records.to_h do |record|
156
121
  [record.key, record.value]
157
122
  end
@@ -12,7 +12,7 @@ module LlmCostTracker
12
12
  scope :by_direction, ->(direction) { where(direction: direction.to_s) }
13
13
  scope :by_modality, ->(modality) { where(modality: modality.to_s) }
14
14
  scope :cached, -> { where.not(cache_state: ["none", nil]) }
15
- scope :priced, -> { where(cost_status: %w[complete free]) }
16
- scope :unpriced, -> { where(cost_status: "unknown") }
15
+ scope :priced, -> { where(cost_status: [Charges::CostStatus::COMPLETE, Charges::CostStatus::FREE]) }
16
+ scope :unpriced, -> { where(cost_status: Charges::CostStatus::UNKNOWN) }
17
17
  end
18
18
  end
@@ -2,5 +2,43 @@
2
2
 
3
3
  module LlmCostTracker
4
4
  class CallRollup < ActiveRecord::Base
5
+ class << self
6
+ def increment_all(rows)
7
+ upsert_all(rows, on_duplicate: increment_on_duplicate, record_timestamps: true, unique_by: increment_unique_by)
8
+ end
9
+
10
+ def decrement(buckets)
11
+ now = Time.now.utc
12
+ buckets.each do |(period, period_start, currency, provider), amount|
13
+ where(period: period, period_start: period_start, currency: currency, provider: provider)
14
+ .update_all(["total_cost = GREATEST(0, total_cost - ?), updated_at = ?", amount, now])
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def increment_on_duplicate
21
+ return Arel.sql(mysql_increment_sql) if Ledger::Schema::Adapter.mysql?(connection)
22
+ return Arel.sql(postgres_increment_sql) if Ledger::Schema::Adapter.postgresql?(connection)
23
+
24
+ Ledger::Schema::Adapter.ensure_supported!(connection)
25
+ end
26
+
27
+ def postgres_increment_sql
28
+ total = connection.quote_column_name("total_cost")
29
+ updated = connection.quote_column_name("updated_at")
30
+ "#{total} = #{quoted_table_name}.#{total} + excluded.#{total}, #{updated} = excluded.#{updated}"
31
+ end
32
+
33
+ def mysql_increment_sql
34
+ "total_cost = total_cost + VALUES(total_cost), updated_at = VALUES(updated_at)"
35
+ end
36
+
37
+ def increment_unique_by
38
+ return unless connection.supports_insert_conflict_target?
39
+
40
+ %i[period period_start currency provider]
41
+ end
42
+ end
5
43
  end
6
44
  end
@@ -6,7 +6,5 @@ module LlmCostTracker
6
6
  class_name: "LlmCostTracker::Call",
7
7
  foreign_key: :llm_cost_tracker_call_id,
8
8
  inverse_of: :tag_records
9
-
10
- scope :with_key, ->(key) { where(key: key.to_s) }
11
9
  end
12
10
  end
@@ -4,6 +4,8 @@ module LlmCostTracker
4
4
  module Ingestion
5
5
  class InboxEntry < ActiveRecord::Base
6
6
  MAX_ATTEMPTS_BEFORE_QUARANTINE = 5
7
+
8
+ scope :pending, -> { where(attempts: ..(MAX_ATTEMPTS_BEFORE_QUARANTINE - 1)) }
7
9
  end
8
10
  end
9
11
  end