llm_cost_tracker 0.7.3 → 0.9.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/.ruby-version +1 -0
  3. data/CHANGELOG.md +173 -0
  4. data/README.md +60 -220
  5. data/app/assets/llm_cost_tracker/application.css +282 -45
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +25 -20
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +22 -19
  9. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +14 -2
  10. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +18 -21
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
  14. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
  15. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
  16. data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
  17. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
  18. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +24 -7
  19. data/app/models/llm_cost_tracker/call.rb +166 -0
  20. data/app/models/llm_cost_tracker/call_line_item.rb +18 -0
  21. data/app/models/llm_cost_tracker/call_rollup.rb +6 -0
  22. data/app/models/llm_cost_tracker/call_tag.rb +12 -0
  23. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +9 -0
  24. data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
  25. data/app/models/llm_cost_tracker/provider_invoice.rb +13 -0
  26. data/app/models/llm_cost_tracker/provider_invoice_import.rb +24 -0
  27. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +152 -32
  28. data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
  29. data/app/services/llm_cost_tracker/dashboard/filter.rb +8 -6
  30. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
  31. data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
  32. data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
  33. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
  34. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
  35. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
  36. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
  37. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  38. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
  39. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  40. data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
  41. data/app/views/llm_cost_tracker/calls/show.html.erb +73 -33
  42. data/app/views/llm_cost_tracker/dashboard/index.html.erb +16 -57
  43. data/app/views/llm_cost_tracker/data_quality/index.html.erb +183 -167
  44. data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
  45. data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
  46. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  47. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  48. data/app/views/llm_cost_tracker/shared/_filters.html.erb +66 -0
  49. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  50. data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
  51. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
  52. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
  53. data/app/views/llm_cost_tracker/tags/show.html.erb +64 -36
  54. data/config/routes.rb +3 -2
  55. data/lib/llm_cost_tracker/billing/components.rb +95 -0
  56. data/lib/llm_cost_tracker/billing/components.yml +188 -0
  57. data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
  58. data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
  59. data/lib/llm_cost_tracker/budget.rb +26 -36
  60. data/lib/llm_cost_tracker/capture/stream_collector.rb +125 -38
  61. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  62. data/lib/llm_cost_tracker/configuration.rb +86 -17
  63. data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
  64. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +56 -0
  65. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +48 -30
  66. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  67. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
  68. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
  69. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
  70. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
  71. data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
  72. data/lib/llm_cost_tracker/doctor/schema_check.rb +34 -0
  73. data/lib/llm_cost_tracker/doctor.rb +111 -44
  74. data/lib/llm_cost_tracker/engine.rb +9 -0
  75. data/lib/llm_cost_tracker/errors.rb +5 -19
  76. data/lib/llm_cost_tracker/event.rb +11 -3
  77. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  78. data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
  79. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -5
  80. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
  81. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  82. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  83. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +104 -0
  84. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
  85. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
  86. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
  87. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -0
  88. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  89. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  90. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +38 -0
  91. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_provider_response_id_generator.rb → upgrade_call_tags_key_value_index_generator.rb} +5 -4
  92. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_streaming_generator.rb → upgrade_image_tokens_generator.rb} +4 -4
  93. data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
  94. data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -24
  95. data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
  96. data/lib/llm_cost_tracker/ingestion/worker.rb +24 -7
  97. data/lib/llm_cost_tracker/ingestion.rb +66 -22
  98. data/lib/llm_cost_tracker/integrations/anthropic.rb +68 -42
  99. data/lib/llm_cost_tracker/integrations/base.rb +56 -32
  100. data/lib/llm_cost_tracker/integrations/openai.rb +342 -63
  101. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +110 -11
  102. data/lib/llm_cost_tracker/integrations.rb +21 -3
  103. data/lib/llm_cost_tracker/ledger/period/totals.rb +30 -11
  104. data/lib/llm_cost_tracker/ledger/period.rb +5 -5
  105. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +2 -2
  106. data/lib/llm_cost_tracker/ledger/rollups.rb +90 -25
  107. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
  108. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +79 -0
  109. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
  110. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +41 -0
  111. data/lib/llm_cost_tracker/ledger/schema/calls.rb +36 -23
  112. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
  113. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
  114. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
  115. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
  116. data/lib/llm_cost_tracker/ledger/store.rb +103 -20
  117. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  118. data/lib/llm_cost_tracker/ledger/tags/query.rb +6 -11
  119. data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -15
  120. data/lib/llm_cost_tracker/ledger.rb +5 -2
  121. data/lib/llm_cost_tracker/logging.rb +2 -5
  122. data/lib/llm_cost_tracker/masking.rb +39 -0
  123. data/lib/llm_cost_tracker/middleware/faraday.rb +95 -35
  124. data/lib/llm_cost_tracker/parsers/anthropic.rb +74 -14
  125. data/lib/llm_cost_tracker/parsers/base.rb +13 -4
  126. data/lib/llm_cost_tracker/parsers/gemini.rb +105 -15
  127. data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
  128. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +15 -3
  129. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +126 -0
  130. data/lib/llm_cost_tracker/parsers/openai_usage.rb +157 -59
  131. data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
  132. data/lib/llm_cost_tracker/parsers.rb +1 -1
  133. data/lib/llm_cost_tracker/prices.json +198 -22
  134. data/lib/llm_cost_tracker/pricing/effective_prices.rb +28 -21
  135. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
  136. data/lib/llm_cost_tracker/pricing/lookup.rb +73 -36
  137. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  138. data/lib/llm_cost_tracker/pricing/registry.rb +67 -45
  139. data/lib/llm_cost_tracker/pricing/service_charges.rb +210 -0
  140. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
  141. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
  142. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
  143. data/lib/llm_cost_tracker/pricing/sync.rb +59 -10
  144. data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
  145. data/lib/llm_cost_tracker/pricing.rb +220 -28
  146. data/lib/llm_cost_tracker/railtie.rb +6 -8
  147. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  148. data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
  149. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
  150. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  151. data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
  152. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -0
  153. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  154. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  155. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  156. data/lib/llm_cost_tracker/report/data.rb +19 -8
  157. data/lib/llm_cost_tracker/report.rb +0 -4
  158. data/lib/llm_cost_tracker/retention.rb +22 -9
  159. data/lib/llm_cost_tracker/tags/context.rb +2 -5
  160. data/lib/llm_cost_tracker/tags/key.rb +4 -0
  161. data/lib/llm_cost_tracker/tags/sanitizer.rb +71 -20
  162. data/lib/llm_cost_tracker/timing.rb +15 -0
  163. data/lib/llm_cost_tracker/token_usage.rb +64 -42
  164. data/lib/llm_cost_tracker/tracker.rb +97 -27
  165. data/lib/llm_cost_tracker/usage_capture.rb +29 -8
  166. data/lib/llm_cost_tracker/version.rb +1 -1
  167. data/lib/llm_cost_tracker.rb +45 -35
  168. data/lib/tasks/llm_cost_tracker.rake +45 -17
  169. metadata +71 -41
  170. data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
  171. data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
  172. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
  173. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
  174. data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
  175. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
  176. data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
  177. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
  178. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
  179. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
  180. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
  181. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
  182. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
  183. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
  184. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
  185. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
  186. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
  187. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
  188. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
  189. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
  190. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
  191. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
  192. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
  193. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
  194. data/lib/llm_cost_tracker/pricing/components.rb +0 -37
  195. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
@@ -0,0 +1,55 @@
1
+ require "llm_cost_tracker/ledger/schema/adapter"
2
+
3
+ class CreateLlmCostTrackerReconciliation < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ create_table :llm_cost_tracker_provider_invoices, if_not_exists: true do |t|
6
+ t.string :source, null: false
7
+ t.date :period_start, null: false
8
+ t.date :period_end, null: false
9
+ t.string :external_id, null: false
10
+ t.decimal :billed_amount, precision: 20, scale: 8
11
+ t.string :currency, null: false, default: "USD"
12
+ if postgresql?
13
+ t.jsonb :metadata, null: false, default: {}
14
+ elsif mysql?
15
+ t.json :metadata, null: false
16
+ else
17
+ raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
18
+ end
19
+ t.datetime :imported_at, null: false
20
+
21
+ t.timestamps
22
+ end
23
+
24
+ create_table :llm_cost_tracker_provider_invoice_imports, if_not_exists: true do |t|
25
+ t.string :source, null: false
26
+ t.string :cursor
27
+ t.date :window_start
28
+ t.date :window_end
29
+ t.string :state, null: false
30
+ t.text :last_error
31
+ t.integer :rows_imported, null: false, default: 0
32
+ t.datetime :started_at, null: false
33
+ t.datetime :finished_at
34
+
35
+ t.timestamps
36
+ end
37
+
38
+ add_index :llm_cost_tracker_provider_invoices, :external_id, unique: true,
39
+ if_not_exists: true
40
+ add_index :llm_cost_tracker_provider_invoices, %i[source currency period_start],
41
+ if_not_exists: true
42
+ add_index :llm_cost_tracker_provider_invoice_imports, %i[source started_at],
43
+ if_not_exists: true
44
+ end
45
+
46
+ private
47
+
48
+ def postgresql?
49
+ LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
50
+ end
51
+
52
+ def mysql?
53
+ LlmCostTracker::Ledger::Schema::Adapter.mysql?(connection)
54
+ end
55
+ end
@@ -4,7 +4,10 @@ LlmCostTracker.configure do |config|
4
4
  # Set to false to temporarily disable tracking without removing middleware.
5
5
  config.enabled = true
6
6
 
7
- # Tags are merged into every event. Use a callable for request/job-time context.
7
+ # LLM Cost Tracker logs warnings through Rails.logger when available.
8
+ config.log_level = :info
9
+
10
+ # Tags merged into every event. Use a callable for request/job-time context.
8
11
  config.default_tags = -> { { environment: Rails.env } }
9
12
 
10
13
  # Tag guardrails keep accidental high-cardinality or sensitive values out of the ledger.
@@ -18,40 +21,40 @@ LlmCostTracker.configure do |config|
18
21
  # config.instrument :anthropic
19
22
  # config.instrument :ruby_llm
20
23
 
21
- # Budget behavior: :notify calls on_budget_exceeded, :raise raises after recording,
22
- # :block_requests preflights monthly/daily budgets before supported requests.
23
- config.budget_exceeded_behavior = :notify
24
-
25
- # Unknown pricing records token usage with nil cost by default. Use :raise if
26
- # every model must have known pricing before it can be used.
27
- config.unknown_pricing_behavior = :warn
28
-
29
- # LLM Cost Tracker logs warnings through Rails.logger when available.
30
- config.log_level = :info
24
+ # Pricing local file refreshed via bin/rails llm_cost_tracker:prices:refresh
25
+ # plus inline overrides. Prices are USD per 1M tokens.
31
26
  <% if options[:prices] -%>
32
-
33
- # Local JSON/YAML pricing file generated by --prices. Keep it in source control
34
- # and refresh it with bin/rails llm_cost_tracker:prices:refresh.
35
27
  config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.yml")
28
+ <% else -%>
29
+ # config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.yml")
36
30
  <% end -%>
31
+ # config.pricing_overrides = {
32
+ # "my-custom-model" => { input: 1.00, output: 2.00 }
33
+ # }
34
+ # :warn (default) records token usage with nil cost when a model has no rate.
35
+ # Use :raise to require known pricing for every model.
36
+ config.unknown_pricing_behavior = :warn
37
37
 
38
- # Cumulative monthly/daily budgets and a single-call ceiling, in USD.
38
+ # Budget guardrails — cumulative monthly/daily and per-call ceilings in USD,
39
+ # plus behavior on crossing (:notify default fires on_budget_exceeded; :raise
40
+ # raises after recording; :block_requests preflights supported requests) and
41
+ # an optional callback. Cap evaluation reads from llm_cost_tracker_calls live;
42
+ # flip cache_rollups to true at high volume so reads hit the rollups table
43
+ # instead — generate the table with `bin/rails generate llm_cost_tracker:call_rollups`.
39
44
  # config.monthly_budget = 100.00
40
45
  # config.daily_budget = 10.00
41
46
  # config.per_call_budget = 1.00
42
-
43
- # Called when :notify is selected and a monthly, daily, or per-call budget is exceeded.
47
+ config.budget_exceeded_behavior = :notify
44
48
  # config.on_budget_exceeded = ->(data) {
45
- # Rails.logger.warn(
46
- # "LLM #{data[:budget_type]} budget exceeded: $#{data[:total]} / $#{data[:budget]}"
47
- # )
49
+ # Rails.logger.warn("LLM #{data[:budget_type]} budget exceeded: $#{data[:total]} / $#{data[:budget]}")
48
50
  # }
51
+ # config.cache_rollups = true
49
52
 
50
- # Local pricing table and small Ruby-side overrides. Prices are USD per 1M tokens.
51
- # config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.yml")
52
- # config.pricing_overrides = {
53
- # "my-custom-model" => { input: 1.00, output: 2.00 }
54
- # }
53
+ # Ingestion path false (default) writes events synchronously from the request
54
+ # thread. Flip to true for a write-ahead inbox + background worker that batches
55
+ # inserts and survives caller transaction rollbacks. Requires the optional
56
+ # inbox/leases tables created by `bin/rails generate llm_cost_tracker:durable_ingestion`.
57
+ # config.durable_ingestion = true
55
58
 
56
59
  # Register OpenAI-compatible gateway hosts and choose extra tag breakdowns
57
60
  # for bin/rails llm_cost_tracker:report.
@@ -0,0 +1,20 @@
1
+ class UpgradeLlmCostTrackerCallRollupsProvider < ActiveRecord::Migration<%= migration_version %>
2
+ TABLE = :llm_cost_tracker_call_rollups
3
+ OLD_INDEX = %i[period period_start currency].freeze
4
+ NEW_INDEX = %i[period period_start currency provider].freeze
5
+
6
+ def up
7
+ unless column_exists?(TABLE, :provider)
8
+ execute "DELETE FROM #{TABLE}"
9
+ add_column TABLE, :provider, :string, null: false, default: ""
10
+ end
11
+ remove_index TABLE, column: OLD_INDEX, unique: true if index_exists?(TABLE, OLD_INDEX, unique: true)
12
+ add_index TABLE, NEW_INDEX, unique: true unless index_exists?(TABLE, NEW_INDEX, unique: true)
13
+ end
14
+
15
+ def down
16
+ remove_index TABLE, column: NEW_INDEX, unique: true if index_exists?(TABLE, NEW_INDEX, unique: true)
17
+ add_index TABLE, OLD_INDEX, unique: true unless index_exists?(TABLE, OLD_INDEX, unique: true)
18
+ remove_column TABLE, :provider if column_exists?(TABLE, :provider)
19
+ end
20
+ end
@@ -0,0 +1,32 @@
1
+ require "llm_cost_tracker/ledger/schema/adapter"
2
+
3
+ class UpgradeLlmCostTrackerCallTagsKeyValueIndex < ActiveRecord::Migration<%= migration_version %>
4
+ TABLE = :llm_cost_tracker_call_tags
5
+ INDEX_COLUMNS = %i[key value].freeze
6
+
7
+ def up
8
+ return if index_exists?(TABLE, INDEX_COLUMNS)
9
+
10
+ if postgresql?
11
+ add_index TABLE, INDEX_COLUMNS
12
+ elsif mysql?
13
+ add_index TABLE, INDEX_COLUMNS, length: { value: 191 }
14
+ else
15
+ raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
16
+ end
17
+ end
18
+
19
+ def down
20
+ remove_index TABLE, column: INDEX_COLUMNS if index_exists?(TABLE, INDEX_COLUMNS)
21
+ end
22
+
23
+ private
24
+
25
+ def postgresql?
26
+ LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
27
+ end
28
+
29
+ def mysql?
30
+ LlmCostTracker::Ledger::Schema::Adapter.mysql?(connection)
31
+ end
32
+ end
@@ -0,0 +1,18 @@
1
+ class UpgradeLlmCostTrackerImageTokens < ActiveRecord::Migration<%= migration_version %>
2
+ TABLE = :llm_cost_tracker_calls
3
+ COLUMNS = %i[image_input_tokens image_output_tokens].freeze
4
+
5
+ def up
6
+ COLUMNS.each do |column|
7
+ next if column_exists?(TABLE, column)
8
+
9
+ add_column TABLE, column, :integer, null: false, default: 0
10
+ end
11
+ end
12
+
13
+ def down
14
+ COLUMNS.each do |column|
15
+ remove_column TABLE, column if column_exists?(TABLE, column)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module LlmCostTracker
7
+ module Generators
8
+ class UpgradeCallRollupsProviderGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Adds the v0.9 provider column and unique index to llm_cost_tracker_call_rollups."
14
+
15
+ def create_migration_file
16
+ migration_template(
17
+ "upgrade_call_rollups_provider.rb.erb",
18
+ "db/migrate/upgrade_llm_cost_tracker_call_rollups_provider.rb"
19
+ )
20
+ end
21
+
22
+ def warn_about_rollups_truncation
23
+ say(<<~MSG, :yellow)
24
+ The migration clears existing llm_cost_tracker_call_rollups rows before adding the
25
+ provider column. Budget reads fall back to live aggregation from
26
+ llm_cost_tracker_calls until new events repopulate the rollups under their provider
27
+ keys. See docs/upgrading.md for details.
28
+ MSG
29
+ end
30
+
31
+ private
32
+
33
+ def migration_version
34
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -5,17 +5,18 @@ require "rails/generators/active_record"
5
5
 
6
6
  module LlmCostTracker
7
7
  module Generators
8
- class AddProviderResponseIdGenerator < Rails::Generators::Base
8
+ class UpgradeCallTagsKeyValueIndexGenerator < Rails::Generators::Base
9
9
  include ActiveRecord::Generators::Migration
10
10
 
11
11
  source_root File.expand_path("templates", __dir__)
12
12
 
13
- desc "Creates a migration to add llm_api_calls.provider_response_id"
13
+ desc "Adds a (key, value) composite index on llm_cost_tracker_call_tags " \
14
+ "so high-cardinality tag filters use an index lookup instead of a key-only scan."
14
15
 
15
16
  def create_migration_file
16
17
  migration_template(
17
- "add_provider_response_id_to_llm_api_calls.rb.erb",
18
- "db/migrate/add_provider_response_id_to_llm_api_calls.rb"
18
+ "upgrade_call_tags_key_value_index.rb.erb",
19
+ "db/migrate/upgrade_llm_cost_tracker_call_tags_key_value_index.rb"
19
20
  )
20
21
  end
21
22
 
@@ -5,17 +5,17 @@ require "rails/generators/active_record"
5
5
 
6
6
  module LlmCostTracker
7
7
  module Generators
8
- class AddStreamingGenerator < Rails::Generators::Base
8
+ class UpgradeImageTokensGenerator < Rails::Generators::Base
9
9
  include ActiveRecord::Generators::Migration
10
10
 
11
11
  source_root File.expand_path("templates", __dir__)
12
12
 
13
- desc "Creates a migration to add llm_api_calls.stream and llm_api_calls.usage_source"
13
+ desc "Adds image_input_tokens and image_output_tokens columns to llm_cost_tracker_calls."
14
14
 
15
15
  def create_migration_file
16
16
  migration_template(
17
- "add_streaming_to_llm_api_calls.rb.erb",
18
- "db/migrate/add_streaming_to_llm_api_calls.rb"
17
+ "upgrade_image_tokens.rb.erb",
18
+ "db/migrate/upgrade_llm_cost_tracker_image_tokens.rb"
19
19
  )
20
20
  end
21
21
 
@@ -27,7 +27,7 @@ module LlmCostTracker
27
27
  end
28
28
 
29
29
  def pending?
30
- Ingestion::Event.where("attempts < ?", Ingestion::Event::MAX_ATTEMPTS).exists?
30
+ Ingestion::InboxEntry.where("attempts < ?", Ingestion::InboxEntry::MAX_ATTEMPTS_BEFORE_QUARANTINE).exists?
31
31
  end
32
32
 
33
33
  def claimable?
@@ -37,7 +37,7 @@ module LlmCostTracker
37
37
  def mark_failed(rows, error)
38
38
  message = "#{error.class}: #{error.message}".byteslice(0, 1_000)
39
39
  now = Time.now.utc
40
- Ingestion::Event
40
+ Ingestion::InboxEntry
41
41
  .where(id: rows.map(&:id), locked_by: identity)
42
42
  .update_all(last_error: message, locked_at: now, locked_by: nil, updated_at: now)
43
43
  rescue StandardError
@@ -51,16 +51,15 @@ module LlmCostTracker
51
51
  def claim
52
52
  now = Time.now.utc
53
53
  cutoff = now - LOCK_TIMEOUT_SECONDS
54
- Ingestion::Event.transaction do
54
+ Ingestion::InboxEntry.transaction do
55
55
  rows = claimable_scope(cutoff).order(:id).limit(BATCH_SIZE).lock.to_a
56
- ids = rows.map(&:id)
57
- next [] if ids.empty?
56
+ next [] if rows.empty?
58
57
 
59
- updates = Ingestion::Event.sanitize_sql_array(
58
+ updates = Ingestion::InboxEntry.sanitize_sql_array(
60
59
  ["locked_at = ?, locked_by = ?, attempts = attempts + 1, updated_at = ?", now, identity, now]
61
60
  )
62
- Ingestion::Event.where(id: ids).update_all(updates)
63
- Ingestion::Event.where(id: ids, locked_by: identity).order(:id).to_a
61
+ Ingestion::InboxEntry.where(id: rows.map(&:id)).update_all(updates)
62
+ rows
64
63
  end
65
64
  end
66
65
 
@@ -77,15 +76,15 @@ module LlmCostTracker
77
76
  end
78
77
 
79
78
  def persist(rows, events)
80
- LlmCostTracker::Ledger::Call.transaction do
79
+ LlmCostTracker::Call.transaction do
81
80
  Ledger::Store.insert_many(events)
82
- Ingestion::Event.where(id: rows.map(&:id), locked_by: identity).delete_all
81
+ Ingestion::InboxEntry.where(id: rows.map(&:id), locked_by: identity).delete_all
83
82
  end
84
83
  end
85
84
 
86
85
  def claimable_scope(cutoff)
87
- Ingestion::Event
88
- .where("attempts < ?", Ingestion::Event::MAX_ATTEMPTS)
86
+ Ingestion::InboxEntry
87
+ .where("attempts < ?", Ingestion::InboxEntry::MAX_ATTEMPTS_BEFORE_QUARANTINE)
89
88
  .where("locked_at IS NULL OR locked_at < ?", cutoff)
90
89
  end
91
90
  end
@@ -5,11 +5,12 @@ require "time"
5
5
 
6
6
  require_relative "../event"
7
7
  require_relative "../pricing"
8
+ require_relative "../billing/line_item"
8
9
 
9
10
  module LlmCostTracker
10
11
  module Ingestion
11
12
  class Inbox
12
- PAYLOAD_SCHEMA_VERSION = 1
13
+ PAYLOAD_SCHEMA_VERSION = 2
13
14
 
14
15
  class << self
15
16
  def save(event)
@@ -19,32 +20,47 @@ module LlmCostTracker
19
20
  end
20
21
 
21
22
  def event_from_row(row)
22
- payload = JSON.parse(row.payload)
23
- schema_version = payload.fetch("schema_version", 0)
24
- unless [0, PAYLOAD_SCHEMA_VERSION].include?(schema_version)
23
+ payload = JSON.parse(row.payload, symbolize_names: true)
24
+ schema_version = payload[:schema_version]
25
+ unless schema_version == PAYLOAD_SCHEMA_VERSION
25
26
  raise LlmCostTracker::Error, "unsupported ledger inbox payload schema version #{schema_version.inspect}"
26
27
  end
27
28
 
28
- cost = payload["cost"] && Pricing.stored_cost_attributes(payload["cost"])
29
- token_usage = payload["token_usage"] || payload
29
+ LlmCostTracker::Event.new(**event_attributes_from(payload))
30
+ end
31
+
32
+ private
30
33
 
31
- LlmCostTracker::Event.new(
32
- event_id: payload.fetch("event_id"),
33
- provider: payload.fetch("provider"),
34
- model: payload.fetch("model"),
35
- token_usage: TokenUsage.from_hash(token_usage),
36
- pricing_mode: payload["pricing_mode"],
34
+ def event_attributes_from(payload)
35
+ cost = payload[:cost] && Pricing.stored_cost_attributes(payload[:cost])
36
+ token_usage = TokenUsage.build(**payload.fetch(:token_usage).slice(*TokenUsage.members))
37
+
38
+ {
39
+ event_id: payload.fetch(:event_id),
40
+ provider: payload.fetch(:provider),
41
+ model: payload.fetch(:model),
42
+ token_usage: token_usage,
43
+ pricing_mode: Pricing.normalize_mode(payload[:pricing_mode]),
37
44
  cost: cost,
38
- tags: payload.fetch("tags"),
39
- latency_ms: payload["latency_ms"],
40
- stream: payload.fetch("stream"),
41
- usage_source: payload["usage_source"],
42
- provider_response_id: payload["provider_response_id"],
43
- tracked_at: Time.iso8601(payload.fetch("tracked_at"))
44
- )
45
+ tags: payload.fetch(:tags),
46
+ latency_ms: payload[:latency_ms],
47
+ stream: payload.fetch(:stream),
48
+ usage_source: payload[:usage_source]&.to_sym,
49
+ provider_response_id: payload[:provider_response_id],
50
+ provider_project_id: payload[:provider_project_id],
51
+ provider_api_key_id: payload[:provider_api_key_id],
52
+ provider_workspace_id: payload[:provider_workspace_id],
53
+ batch: payload.fetch(:batch),
54
+ tracked_at: Time.iso8601(payload.fetch(:tracked_at)),
55
+ cost_status: payload.fetch(:cost_status),
56
+ pricing_snapshot: payload[:pricing_snapshot],
57
+ line_items: line_items_from(payload)
58
+ }
45
59
  end
46
60
 
47
- private
61
+ def line_items_from(payload)
62
+ (payload[:line_items] || []).map { |attributes| Billing::LineItem.build(attributes) }
63
+ end
48
64
 
49
65
  def row_for(event)
50
66
  now = Time.now.utc
@@ -70,7 +86,7 @@ module LlmCostTracker
70
86
  end
71
87
 
72
88
  def insert_row(row)
73
- connection = LlmCostTracker::Ledger::Call.connection
89
+ connection = LlmCostTracker::Call.connection
74
90
  if connection.transaction_open?
75
91
  insert_with_separate_connection(row)
76
92
  else
@@ -82,7 +98,7 @@ module LlmCostTracker
82
98
  end
83
99
 
84
100
  def insert_with_separate_connection(row)
85
- pool = LlmCostTracker::Ledger::Call.connection_pool
101
+ pool = LlmCostTracker::Call.connection_pool
86
102
  connection = pool.checkout
87
103
  begin
88
104
  connection.transaction(requires_new: true) { execute_insert(connection, row) }
@@ -95,8 +111,7 @@ module LlmCostTracker
95
111
  columns = row.keys
96
112
  quoted_columns = columns.map { |column| connection.quote_column_name(column) }.join(", ")
97
113
  quoted_values = columns.map { |column| connection.quote(row.fetch(column)) }.join(", ")
98
- table = connection.quote_table_name(Event.table_name)
99
-
114
+ table = connection.quote_table_name(InboxEntry.table_name)
100
115
  connection.execute("INSERT INTO #{table} (#{quoted_columns}) VALUES (#{quoted_values})")
101
116
  end
102
117
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ledger/store"
4
+
5
+ module LlmCostTracker
6
+ module Ingestion
7
+ module Inline
8
+ class << self
9
+ def save(event)
10
+ persist(event)
11
+ event
12
+ end
13
+
14
+ private
15
+
16
+ def persist(event)
17
+ Ledger::Store.insert_many([event])
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -18,6 +18,8 @@ module LlmCostTracker
18
18
  FLUSH_TIMEOUT_SECONDS = 10
19
19
  class << self
20
20
  def ensure_started
21
+ return unless Ingestion.durable?
22
+
21
23
  thread = mutex.synchronize do
22
24
  reset_after_fork!
23
25
  unless @thread&.alive?
@@ -25,18 +27,20 @@ module LlmCostTracker
25
27
  @generation = @generation.to_i + 1
26
28
  generation = @generation
27
29
  @thread = Thread.new { run(generation) }
28
- @thread.name = "llm_cost_tracker_ingestor" if @thread.respond_to?(:name=)
29
- @thread.report_on_exception = false if @thread.respond_to?(:report_on_exception=)
30
+ @thread.name = "llm_cost_tracker_ingestor"
31
+ @thread.report_on_exception = false
30
32
  end
31
33
  @thread
32
34
  end
33
35
  wake_thread(thread)
34
36
  end
35
37
 
36
- def flush!(timeout: FLUSH_TIMEOUT_SECONDS, require_lease: false)
38
+ def flush!(timeout: nil, require_lease: false)
39
+ return true unless Ingestion.durable?
40
+
37
41
  Ingestion.ensure_current_schema!
38
42
 
39
- deadline = Time.now.utc + timeout
43
+ deadline = Time.now.utc + flush_timeout_seconds(timeout)
40
44
  loop do
41
45
  return true unless Ingestion::Batch.new(identity: identity).pending?
42
46
  return false if Time.now.utc >= deadline
@@ -51,20 +55,25 @@ module LlmCostTracker
51
55
  end
52
56
  end
53
57
 
54
- def shutdown!(timeout: FLUSH_TIMEOUT_SECONDS, drain: true)
58
+ def shutdown!(timeout: nil, drain: true)
59
+ return true unless Ingestion.durable?
60
+
61
+ timeout ||= FLUSH_TIMEOUT_SECONDS
55
62
  thread = mutex.synchronize do
56
63
  @stop_requested = true
57
64
  @generation = @generation.to_i + 1
58
65
  @thread
59
66
  end
60
67
  wake_thread(thread)
61
- thread&.join([timeout, 1].min)
68
+ thread&.join(timeout)
62
69
  drain ? flush!(timeout: timeout, require_lease: true) : true
63
70
  rescue StandardError => e
64
71
  handle_error(e)
65
72
  false
66
73
  ensure
67
- mutex.synchronize { @thread = nil if @thread.equal?(thread) }
74
+ mutex.synchronize do
75
+ @thread = nil if @thread.equal?(thread) && !thread&.alive?
76
+ end
68
77
  end
69
78
 
70
79
  def reset!
@@ -80,7 +89,15 @@ module LlmCostTracker
80
89
  wake_thread(thread)
81
90
  end
82
91
 
92
+ def flush_timeout_seconds(timeout)
93
+ numeric = Float(timeout, exception: false)
94
+ return FLUSH_TIMEOUT_SECONDS unless numeric&.finite? && numeric.positive?
95
+
96
+ numeric
97
+ end
98
+
83
99
  def ingest_once(require_lease: true)
100
+ Ingestion.ensure_current_schema!
84
101
  batch = Ingestion::Batch.new(identity: identity)
85
102
  return 0 unless batch.claimable?
86
103
  return 0 if require_lease && !Ingestion::LeaseClaim.new(identity: identity, seconds: LEASE_SECONDS).acquire