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
@@ -2,29 +2,39 @@
2
2
 
3
3
  require "active_support/core_ext/object/blank"
4
4
  require "active_support/core_ext/object/deep_dup"
5
+ require "json"
5
6
 
6
7
  require_relative "stream"
8
+ require_relative "../pricing/mode"
9
+ require_relative "../timing"
7
10
 
8
11
  module LlmCostTracker
9
12
  module Capture
10
13
  class StreamCollector
11
14
  attr_reader :provider
12
15
 
13
- def initialize(provider:, model:, latency_ms: nil, provider_response_id: nil, pricing_mode: nil, metadata: {},
14
- context_tags: nil)
16
+ def initialize(provider:, model:, latency_ms: nil, provider_response_id: nil, provider_project_id: nil,
17
+ provider_api_key_id: nil, provider_workspace_id: nil, batch: nil, pricing_mode: nil,
18
+ metadata: {}, context_tags: nil, request: nil)
15
19
  @provider = provider.to_s
16
20
  @model = model
17
21
  @latency_ms = latency_ms
18
22
  @provider_response_id = provider_response_id
23
+ @provider_project_id = provider_project_id
24
+ @provider_api_key_id = provider_api_key_id
25
+ @provider_workspace_id = provider_workspace_id
26
+ @batch = batch
19
27
  @pricing_mode = pricing_mode
20
28
  @metadata = (metadata || {}).deep_dup
21
29
  @context_tags = (context_tags || LlmCostTracker::Tags::Context.tags).deep_dup
30
+ @request = request
22
31
  @events = []
23
32
  @captured_bytes = 0
24
33
  @overflowed = false
25
34
  @explicit_usage = nil
26
- @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
35
+ @started_at = LlmCostTracker::Timing.now_monotonic
27
36
  @finished = false
37
+ @recording = false
28
38
  @mutex = Mutex.new
29
39
  end
30
40
 
@@ -66,19 +76,35 @@ module LlmCostTracker
66
76
  @mutex.synchronize do
67
77
  ensure_open!
68
78
  @provider_response_id = extra.delete(:provider_response_id) || @provider_response_id
69
- @explicit_usage = TokenUsage.from_hash(extra.merge(
70
- input_tokens: input_tokens.to_i,
71
- output_tokens: output_tokens.to_i
72
- ))
79
+ @provider_project_id = extra.delete(:provider_project_id) || @provider_project_id
80
+ @provider_api_key_id = extra.delete(:provider_api_key_id) || @provider_api_key_id
81
+ @provider_workspace_id = extra.delete(:provider_workspace_id) || @provider_workspace_id
82
+ batch = extra.delete(:batch)
83
+ @batch = batch unless batch.nil?
84
+ @explicit_usage = TokenUsage.build(
85
+ **extra.slice(*TokenUsage.members),
86
+ input_tokens: input_tokens,
87
+ output_tokens: output_tokens
88
+ )
73
89
  end
74
90
  self
75
91
  end
76
92
 
77
93
  def finish!(errored: false)
78
- snapshot = @mutex.synchronize do
79
- return if @finished
94
+ snapshot = claim_recording_slot
95
+ return if snapshot.nil?
80
96
 
81
- @finished = true
97
+ record_snapshot(snapshot, errored: errored)
98
+ end
99
+
100
+ private
101
+
102
+ def claim_recording_slot
103
+ @mutex.synchronize do
104
+ return nil if @finished || @recording
105
+
106
+ @recording = true
107
+ pricing_mode = Pricing.normalize_mode(@pricing_mode)
82
108
  {
83
109
  events: @events.dup,
84
110
  overflowed: @overflowed,
@@ -86,27 +112,65 @@ module LlmCostTracker
86
112
  model: @model,
87
113
  latency_ms: @latency_ms,
88
114
  provider_response_id: @provider_response_id,
89
- pricing_mode: @pricing_mode,
115
+ capture_dimensions: capture_dimensions(pricing_mode),
116
+ pricing_mode: pricing_mode,
90
117
  metadata: @metadata.deep_dup,
91
- context_tags: @context_tags.deep_dup
118
+ context_tags: @context_tags.deep_dup,
119
+ request: @request
92
120
  }
93
121
  end
122
+ end
123
+
124
+ def record_snapshot(snapshot, errored:)
125
+ save_succeeded = false
126
+ begin
127
+ capture = build_usage_capture(snapshot)
128
+ provider_response_id = capture.provider_response_id || snapshot[:provider_response_id]
129
+ capture = capture.with(provider_response_id: provider_response_id)
130
+
131
+ Tracker.record(
132
+ capture: capture,
133
+ latency_ms: snapshot[:latency_ms] || LlmCostTracker::Timing.elapsed_ms(@started_at),
134
+ pricing_mode: pricing_mode_for(capture: capture, snapshot: snapshot),
135
+ metadata: (errored ? { stream_errored: true } : {}).merge(snapshot[:metadata]),
136
+ context_tags: snapshot[:context_tags]
137
+ ) { |stage| save_succeeded = true if stage == :after_save }
138
+ ensure
139
+ @mutex.synchronize do
140
+ @finished = save_succeeded
141
+ @recording = false
142
+ end
143
+ end
144
+ end
94
145
 
95
- capture = build_usage_capture(snapshot)
96
- provider_response_id = capture.provider_response_id || snapshot[:provider_response_id]
97
- capture = capture.with(provider_response_id: provider_response_id)
146
+ HOST_DERIVED_MODE_TOKENS = %i[data_residency].freeze
147
+ STANDARD_LIKE_MODE_TOKENS = %i[standard standard_only auto default].freeze
148
+ private_constant :HOST_DERIVED_MODE_TOKENS, :STANDARD_LIKE_MODE_TOKENS
98
149
 
99
- Tracker.record(
100
- capture: capture,
101
- latency_ms: snapshot[:latency_ms] ||
102
- ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at) * 1000).round,
103
- pricing_mode: snapshot[:pricing_mode],
104
- metadata: (errored ? { stream_errored: true } : {}).merge(snapshot[:metadata]),
105
- context_tags: snapshot[:context_tags]
106
- )
150
+ def pricing_mode_for(capture:, snapshot:)
151
+ merge_pricing_modes(capture.pricing_mode, snapshot[:pricing_mode])
107
152
  end
108
153
 
109
- private
154
+ def merge_pricing_modes(provider_mode, request_mode)
155
+ return Pricing.normalize_mode(request_mode) if provider_mode.to_s.strip.empty?
156
+
157
+ provider_tokens = Pricing::Mode.tokenize(provider_mode) - STANDARD_LIKE_MODE_TOKENS
158
+ request_host_tokens = Pricing::Mode.tokenize(request_mode || "") & HOST_DERIVED_MODE_TOKENS
159
+ combined = provider_tokens | request_host_tokens
160
+ return nil if combined.empty?
161
+
162
+ Pricing.normalize_mode(combined.join("_"))
163
+ end
164
+
165
+ def capture_dimensions(pricing_mode)
166
+ batch = @batch.nil? ? UsageCapture.batch_from_pricing_mode?(pricing_mode).presence : @batch
167
+ {
168
+ provider_project_id: @provider_project_id.to_s.strip.presence,
169
+ provider_api_key_id: @provider_api_key_id.to_s.strip.presence,
170
+ provider_workspace_id: @provider_workspace_id.to_s.strip.presence,
171
+ batch: batch
172
+ }.compact
173
+ end
110
174
 
111
175
  def ensure_open!
112
176
  return unless @finished
@@ -120,16 +184,25 @@ module LlmCostTracker
120
184
 
121
185
  capture = Parsers.find_for_provider(@provider)&.parse_stream(
122
186
  response_status: 200,
123
- events: snapshot[:events]
187
+ events: snapshot[:events],
188
+ request_body: request_body_for(snapshot[:request])
124
189
  )
125
190
  if capture
126
191
  model = present_model(capture.model) || present_model(snapshot[:model]) || UsageCapture::UNKNOWN_MODEL
127
- return capture.with(provider: @provider, model: model)
192
+ return capture.with(provider: @provider, model: model, **snapshot.fetch(:capture_dimensions))
128
193
  end
129
194
 
130
195
  build_unknown_usage(snapshot)
131
196
  end
132
197
 
198
+ def request_body_for(request)
199
+ return nil unless request
200
+
201
+ JSON.generate(request)
202
+ rescue StandardError
203
+ nil
204
+ end
205
+
133
206
  def present_model(value)
134
207
  return nil if value.nil?
135
208
 
@@ -145,7 +218,9 @@ module LlmCostTracker
145
218
  model: snapshot[:model] || UsageCapture::UNKNOWN_MODEL,
146
219
  token_usage: snapshot[:explicit_usage],
147
220
  stream: true,
148
- usage_source: :manual
221
+ usage_source: :manual,
222
+ pricing_mode: snapshot[:pricing_mode],
223
+ **snapshot.fetch(:capture_dimensions)
149
224
  )
150
225
  end
151
226
 
@@ -155,33 +230,45 @@ module LlmCostTracker
155
230
  model: snapshot[:model] || UsageCapture::UNKNOWN_MODEL,
156
231
  token_usage: TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
157
232
  stream: true,
158
- usage_source: :unknown
233
+ usage_source: :unknown,
234
+ pricing_mode: snapshot[:pricing_mode],
235
+ **snapshot.fetch(:capture_dimensions)
159
236
  )
160
237
  end
161
238
 
239
+ IGNORED_PAYLOAD_KEYS = %w[b64_json partial_image_b64].freeze
240
+ private_constant :IGNORED_PAYLOAD_KEYS
241
+
242
+ HEAVY_STRING_BYTES = 8 * 1024
243
+ private_constant :HEAVY_STRING_BYTES
244
+
162
245
  def capture_event(data, type:)
163
- size = type.to_s.bytesize + estimated_bytes(data) + 32
246
+ event = { event: type, data: strip_heavy_payload(data) }
247
+ size = JSON.generate(event).bytesize
164
248
  if @captured_bytes + size <= Capture::Stream::LIMIT_BYTES
165
- @events << { event: type, data: data.deep_dup }
249
+ @events << event.deep_dup
166
250
  @captured_bytes += size
167
251
  else
168
252
  @overflowed = true
169
- @events.clear
170
253
  end
254
+ rescue JSON::JSONError, TypeError, SystemStackError
255
+ @overflowed = true
171
256
  end
172
257
 
173
- def estimated_bytes(value)
258
+ def strip_heavy_payload(value)
174
259
  case value
175
260
  when Hash
176
- value.sum { |key, nested| estimated_bytes(key) + estimated_bytes(nested) + 4 }
261
+ value.each_with_object({}) do |(key, nested), out|
262
+ next if IGNORED_PAYLOAD_KEYS.include?(key.to_s)
263
+
264
+ out[key] = strip_heavy_payload(nested)
265
+ end
177
266
  when Array
178
- value.sum { |nested| estimated_bytes(nested) + 2 }
267
+ value.map { |nested| strip_heavy_payload(nested) }
179
268
  when String
180
- value.bytesize + 2
181
- when Numeric, true, false, nil
182
- value.to_s.bytesize
269
+ value.bytesize > HEAVY_STRING_BYTES ? "" : value
183
270
  else
184
- value.to_s.bytesize + 2
271
+ value
185
272
  end
186
273
  end
187
274
  end
@@ -12,8 +12,9 @@ module LlmCostTracker
12
12
  @stream = stream
13
13
  @collector = collector
14
14
  @active = active
15
- @finish = finish || proc { |errored:| @collector.finish!(errored: errored) }
16
- @finished = false
15
+ @finish = finish || proc { |errored| collector.finish!(errored: errored) }
16
+ @finished_ref = [false]
17
+ @attempted_ref = [false]
17
18
  @capture_failed = false
18
19
  @mutex = Mutex.new
19
20
  end
@@ -33,6 +34,7 @@ module LlmCostTracker
33
34
  end
34
35
  wrap_each if !iterator_wrapped && @stream.respond_to?(:each)
35
36
 
37
+ register_orphan_finalizer
36
38
  @stream
37
39
  rescue StandardError => e
38
40
  Logging.warn("stream integration failed to install wrapper: #{e.class}: #{e.message}")
@@ -120,14 +122,47 @@ module LlmCostTracker
120
122
 
121
123
  def finish!(errored:)
122
124
  should_finish = @mutex.synchronize do
123
- next false if @finished
125
+ @attempted_ref[0] = true
126
+ next false if @finished_ref[0]
124
127
 
125
- @finished = true
128
+ @finished_ref[0] = true
126
129
  true
127
130
  end
128
131
  return unless should_finish && @active.call
129
132
 
130
- @finish.call(errored: errored)
133
+ begin
134
+ @finish.call(errored)
135
+ rescue StandardError
136
+ @mutex.synchronize { @finished_ref[0] = false }
137
+ raise
138
+ end
139
+ end
140
+
141
+ def register_orphan_finalizer
142
+ finished_ref = @finished_ref
143
+ attempted_ref = @attempted_ref
144
+ finish_proc = @finish
145
+ active_proc = @active
146
+ mutex = @mutex
147
+ finalizer = lambda do |_object_id|
148
+ should_finish = mutex.synchronize do
149
+ next false if finished_ref[0] || attempted_ref[0]
150
+
151
+ finished_ref[0] = true
152
+ attempted_ref[0] = true
153
+ true
154
+ end
155
+ next unless should_finish && active_proc.call
156
+
157
+ begin
158
+ finish_proc.call(false)
159
+ rescue StandardError
160
+ nil
161
+ end
162
+ end
163
+ ObjectSpace.define_finalizer(@stream, finalizer)
164
+ rescue TypeError, ArgumentError
165
+ nil
131
166
  end
132
167
  end
133
168
  end
@@ -1,13 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "errors"
4
+ require_relative "pricing/registry"
4
5
  require_relative "tags/key"
5
- require_relative "configuration/instrumentation"
6
6
 
7
7
  module LlmCostTracker
8
8
  class Configuration
9
- include ConfigurationInstrumentation
10
-
11
9
  OPENAI_COMPATIBLE_PROVIDERS = {
12
10
  "openrouter.ai" => "openrouter",
13
11
  "api.deepseek.com" => "deepseek",
@@ -16,8 +14,8 @@ module LlmCostTracker
16
14
 
17
15
  BUDGET_EXCEEDED_BEHAVIORS = %i[notify raise block_requests].freeze
18
16
  UNKNOWN_PRICING_BEHAVIORS = %i[ignore warn raise].freeze
19
- SHARED_SCALAR_ATTRIBUTES = %i[enabled on_budget_exceeded monthly_budget daily_budget per_call_budget log_level
20
- prices_file max_tag_count max_tag_value_bytesize].freeze
17
+ SHARED_SCALAR_ATTRIBUTES = %i[enabled default_tags on_budget_exceeded monthly_budget daily_budget per_call_budget
18
+ log_level prices_file max_tag_count max_tag_value_bytesize].freeze
21
19
  SHARED_ENUM_ATTRIBUTES = {
22
20
  budget_exceeded_behavior: [BUDGET_EXCEEDED_BEHAVIORS, :notify],
23
21
  unknown_pricing_behavior: [UNKNOWN_PRICING_BEHAVIORS, :warn]
@@ -27,13 +25,17 @@ module LlmCostTracker
27
25
  attr_reader(
28
26
  *SHARED_SCALAR_ATTRIBUTES,
29
27
  :budget_exceeded_behavior,
30
- :default_tags,
31
- :pricing_overrides,
28
+ :durable_ingestion,
32
29
  :instrumented_integrations,
30
+ :pricing_overrides,
33
31
  :report_tag_breakdowns,
34
32
  :redacted_tag_keys,
35
33
  :unknown_pricing_behavior,
36
- :openai_compatible_providers
34
+ :openai_compatible_providers,
35
+ :reconciliation_importers,
36
+ :reconciliation_enabled,
37
+ :auto_enable_stream_usage,
38
+ :cache_rollups
37
39
  )
38
40
 
39
41
  def initialize
@@ -49,19 +51,61 @@ module LlmCostTracker
49
51
  @prices_file = nil
50
52
  @max_tag_count = 50
51
53
  @max_tag_value_bytesize = 1024
52
- @pricing_overrides = {}
53
- @instrumented_integrations = []
54
+ self.pricing_overrides = {}
55
+ @instrumented_integrations = Set.new
54
56
  @report_tag_breakdowns = []
55
57
  @redacted_tag_keys = DEFAULT_REDACTED_TAG_KEYS.dup
56
58
  self.openai_compatible_providers = OPENAI_COMPATIBLE_PROVIDERS
59
+ @reconciliation_importers = {}
60
+ @reconciliation_enabled = false
61
+ @auto_enable_stream_usage = true
62
+ @durable_ingestion = false
63
+ @cache_rollups = false
57
64
  @finalized = false
58
65
  end
59
66
 
60
- def default_tags=(value)
67
+ def durable_ingestion=(value)
68
+ ensure_shared_configuration_mutable!
69
+ @durable_ingestion = value
70
+ end
71
+
72
+ def cache_rollups=(value)
73
+ ensure_shared_configuration_mutable!
74
+ @cache_rollups = value
75
+ end
76
+
77
+ def reconciliation_enabled=(value)
61
78
  ensure_shared_configuration_mutable!
62
- @default_tags = value
79
+ @reconciliation_enabled = value
63
80
  end
64
81
 
82
+ def auto_enable_stream_usage=(value)
83
+ ensure_shared_configuration_mutable!
84
+ @auto_enable_stream_usage = value
85
+ end
86
+
87
+ def reconciliation_importers=(importers)
88
+ ensure_shared_configuration_mutable!
89
+ raise Error, RECONCILIATION_DISABLED_MESSAGE unless @reconciliation_enabled
90
+
91
+ @reconciliation_importers = (importers || {}).to_h do |source, importer|
92
+ raise Error, "reconciliation_importers[#{source}] must respond to call" unless importer.respond_to?(:call)
93
+
94
+ [source.to_sym, importer]
95
+ end
96
+ end
97
+
98
+ def register_reconciliation_importer(source, &block)
99
+ ensure_shared_configuration_mutable!
100
+ raise Error, RECONCILIATION_DISABLED_MESSAGE unless @reconciliation_enabled
101
+ raise Error, "register_reconciliation_importer requires a block" unless block
102
+
103
+ @reconciliation_importers[source.to_sym] = block
104
+ end
105
+
106
+ RECONCILIATION_DISABLED_MESSAGE = "reconciliation is disabled; set config.reconciliation_enabled = true first"
107
+ private_constant :RECONCILIATION_DISABLED_MESSAGE
108
+
65
109
  def openai_compatible_providers=(providers)
66
110
  ensure_shared_configuration_mutable!
67
111
  @openai_compatible_providers = normalize_openai_compatible_providers(providers)
@@ -69,7 +113,9 @@ module LlmCostTracker
69
113
 
70
114
  def pricing_overrides=(value)
71
115
  ensure_shared_configuration_mutable!
72
- @pricing_overrides = value
116
+ @pricing_overrides = Pricing::Registry.normalize_price_table(value || {})
117
+ rescue ArgumentError => e
118
+ raise Error, "invalid pricing_overrides: #{e.message}"
73
119
  end
74
120
 
75
121
  def report_tag_breakdowns=(value)
@@ -82,6 +128,15 @@ module LlmCostTracker
82
128
  @redacted_tag_keys = Array(value).map(&:to_s)
83
129
  end
84
130
 
131
+ def instrument(*names)
132
+ ensure_shared_configuration_mutable!
133
+ @instrumented_integrations.merge(normalize_instrumentation_names(names))
134
+ end
135
+
136
+ def instrumented?(name)
137
+ @instrumented_integrations.include?(name)
138
+ end
139
+
85
140
  SHARED_SCALAR_ATTRIBUTES.each do |name|
86
141
  define_method("#{name}=") do |value|
87
142
  ensure_shared_configuration_mutable!
@@ -99,10 +154,12 @@ module LlmCostTracker
99
154
  def finalize!
100
155
  @default_tags = deep_freeze(@default_tags || {})
101
156
  @pricing_overrides = deep_freeze(@pricing_overrides || {})
102
- @instrumented_integrations = deep_freeze(@instrumented_integrations || [])
157
+ @instrumented_integrations = deep_freeze(@instrumented_integrations || Set.new)
103
158
  @report_tag_breakdowns = deep_freeze(Array(@report_tag_breakdowns))
104
159
  @redacted_tag_keys = deep_freeze(Array(@redacted_tag_keys))
105
- @openai_compatible_providers = deep_freeze(@openai_compatible_providers || {})
160
+ @openai_compatible_providers = deep_freeze(
161
+ normalize_openai_compatible_providers(@openai_compatible_providers)
162
+ )
106
163
  @finalized = true
107
164
  self
108
165
  end
@@ -115,7 +172,6 @@ module LlmCostTracker
115
172
 
116
173
  def normalize_enum(name, value, allowed, default:)
117
174
  value = default if value.nil?
118
- value = value.to_sym
119
175
  return value if allowed.include?(value)
120
176
 
121
177
  raise Error, "Unknown #{name}: #{value.inspect}. Use one of: #{allowed.join(', ')}"
@@ -127,6 +183,19 @@ module LlmCostTracker
127
183
  end
128
184
  end
129
185
 
186
+ def normalize_instrumentation_names(names)
187
+ names = names.flatten
188
+ integrations = Integrations.names
189
+ return integrations if names == [:all]
190
+
191
+ names.each do |name|
192
+ next if integrations.include?(name)
193
+
194
+ raise Error, "Unknown integration: #{name.inspect}. Use one of: #{integrations.join(', ')}"
195
+ end
196
+ names
197
+ end
198
+
130
199
  def ensure_shared_configuration_mutable!
131
200
  return unless finalized?
132
201
 
@@ -141,7 +210,7 @@ module LlmCostTracker
141
210
  deep_freeze(nested_value)
142
211
  end
143
212
  value.frozen? ? value : value.freeze
144
- when Array
213
+ when Array, Set
145
214
  value.each { |nested_value| deep_freeze(nested_value) }
146
215
  value.frozen? ? value : value.freeze
147
216
  when String
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ledger/schema/calls"
4
+ require_relative "ledger/schema/call_line_items"
5
+ require_relative "ledger/schema/call_tags"
6
+ require_relative "ledger/schema/call_rollups"
7
+
8
+ module LlmCostTracker
9
+ module DashboardSetupState
10
+ SetupRequired = Data.define(:message, :details)
11
+ DOCS_HINT = "See docs/upgrading.md for the migration path."
12
+ MUTEX = Mutex.new
13
+
14
+ CORE_SCHEMA_CHECKS = [
15
+ [
16
+ LlmCostTracker::Ledger::Schema::Calls,
17
+ "The llm_cost_tracker_calls table does not match the current LLM Cost Tracker schema."
18
+ ],
19
+ [
20
+ LlmCostTracker::Ledger::Schema::CallLineItems,
21
+ "The llm_cost_tracker_call_line_items table does not match the current LLM Cost Tracker schema."
22
+ ],
23
+ [
24
+ LlmCostTracker::Ledger::Schema::CallTags,
25
+ "The llm_cost_tracker_call_tags table does not match the current LLM Cost Tracker schema."
26
+ ]
27
+ ].freeze
28
+
29
+ OPTIONAL_CALL_ROLLUPS_CHECK = [
30
+ LlmCostTracker::Ledger::Schema::CallRollups,
31
+ "The llm_cost_tracker_call_rollups table does not match the current LLM Cost Tracker schema."
32
+ ].freeze
33
+
34
+ private_constant :MUTEX, :CORE_SCHEMA_CHECKS, :OPTIONAL_CALL_ROLLUPS_CHECK, :DOCS_HINT
35
+
36
+ class << self
37
+ def current
38
+ return @cached if defined?(@cached)
39
+
40
+ MUTEX.synchronize do
41
+ @cached = compute unless defined?(@cached)
42
+ end
43
+ @cached
44
+ end
45
+
46
+ def reset!
47
+ MUTEX.synchronize do
48
+ remove_instance_variable(:@cached) if defined?(@cached)
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def compute
55
+ LlmCostTracker::Logging.debug("DashboardSetupState recomputing")
56
+ return calls_table_missing unless LlmCostTracker::Call.table_exists?
57
+
58
+ core_drift = drift_in(schema_checks_for_current_config)
59
+ return core_drift if core_drift
60
+ return nil unless LlmCostTracker.reconciliation_enabled?
61
+
62
+ reconciliation_drift
63
+ end
64
+
65
+ def schema_checks_for_current_config
66
+ return CORE_SCHEMA_CHECKS unless LlmCostTracker.configuration.cache_rollups
67
+
68
+ CORE_SCHEMA_CHECKS + [OPTIONAL_CALL_ROLLUPS_CHECK]
69
+ end
70
+
71
+ def drift_in(checks)
72
+ checks.each do |schema, message|
73
+ errors = schema.current_schema_errors
74
+ next if errors.empty?
75
+
76
+ return SetupRequired.new(message: message, details: errors + [DOCS_HINT])
77
+ end
78
+ nil
79
+ end
80
+
81
+ def reconciliation_drift
82
+ LlmCostTracker.const_get(:Reconciliation) # autoload reconciliation + its ledger schemas
83
+ connection = ActiveRecord::Base.connection
84
+ LlmCostTracker::Reconciliation::SCHEMA_TABLES.each do |schema, table|
85
+ unless connection.data_source_exists?(table)
86
+ return SetupRequired.new(
87
+ message: "The #{table} table is required when reconciliation is enabled.",
88
+ details: ["run bin/rails generate llm_cost_tracker:reconciliation && bin/rails db:migrate", DOCS_HINT]
89
+ )
90
+ end
91
+
92
+ errors = schema.current_schema_errors
93
+ next if errors.empty?
94
+
95
+ message = "The #{table} table does not match the current LLM Cost Tracker schema."
96
+ return SetupRequired.new(message: message, details: errors + [DOCS_HINT])
97
+ end
98
+ nil
99
+ end
100
+
101
+ def calls_table_missing
102
+ SetupRequired.new(
103
+ message: "The llm_cost_tracker_calls table is not available yet.",
104
+ details: nil
105
+ )
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ require_relative "check"
6
+ require_relative "probe"
7
+ require_relative "../ledger/rollups"
8
+
9
+ module LlmCostTracker
10
+ class Doctor
11
+ class CostDriftCheck
12
+ SAMPLE_SIZE = 200
13
+ EPSILON = BigDecimal("0.00000001")
14
+
15
+ def call
16
+ return unless Probe.table_exists?("llm_cost_tracker_calls")
17
+ return unless Probe.table_exists?("llm_cost_tracker_call_line_items")
18
+
19
+ sampled = LlmCostTracker::Call
20
+ .where.not(total_cost: nil)
21
+ .where(cost_status: %w[complete free partial])
22
+ .order(id: :desc)
23
+ .limit(SAMPLE_SIZE)
24
+ .pluck(:id, :total_cost, :cost_status)
25
+ return Check.new(:ok, "cost drift", "no priced calls to inspect") if sampled.empty?
26
+
27
+ line_item_totals = LlmCostTracker::CallLineItem
28
+ .where(llm_cost_tracker_call_id: sampled.map(&:first))
29
+ .where(currency: Ledger::Rollups::DEFAULT_CURRENCY)
30
+ .group(:llm_cost_tracker_call_id)
31
+ .sum(:cost)
32
+
33
+ drifted = sampled.filter_map do |id, total_cost, cost_status|
34
+ line_total = line_item_totals[id] || BigDecimal("0")
35
+ header = BigDecimal(total_cost.to_s)
36
+ next if cost_status == "partial" && header >= line_total
37
+ next if (header - line_total).abs <= EPSILON
38
+
39
+ "##{id}: header=#{header.to_s('F')} line_items=#{line_total.to_s('F')}"
40
+ end
41
+
42
+ if drifted.empty?
43
+ return Check.new(:ok, "cost drift",
44
+ "header total_cost matches line items in #{sampled.size} sampled calls")
45
+ end
46
+
47
+ Check.new(
48
+ :warn,
49
+ "cost drift",
50
+ "header total_cost diverges from line items in #{drifted.size}/#{sampled.size} sampled calls: " \
51
+ "#{drifted.first(5).join('; ')}#{'; ...' if drifted.size > 5}"
52
+ )
53
+ end
54
+ end
55
+ end
56
+ end