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
@@ -25,7 +25,7 @@ module LlmCostTracker
25
25
  patch_target(
26
26
  "RubyLLM::Provider",
27
27
  with: ProviderPatch,
28
- methods: %i[slug complete embed transcribe]
28
+ methods: %i[slug complete embed transcribe paint moderate]
29
29
  )
30
30
  ]
31
31
  end
@@ -65,6 +65,69 @@ module LlmCostTracker
65
65
  )
66
66
  end
67
67
 
68
+ def record_image(provider, response, request:, latency_ms:)
69
+ usage = object_value(response, :usage)
70
+ usage = {} unless usage.is_a?(Hash)
71
+ raw_input = (usage[:input_tokens] || usage["input_tokens"]).to_i
72
+ raw_output = (usage[:output_tokens] || usage["output_tokens"]).to_i
73
+ image_input = image_token_detail(usage, :input)
74
+ image_output = image_token_detail(usage, :output)
75
+ text_input = [raw_input - image_input, 0].max
76
+ text_output = [raw_output - image_output, 0].max
77
+ record_passthrough(
78
+ provider: provider_slug(provider),
79
+ model: response_model_id(response) || model_id(request[:model]),
80
+ response: response,
81
+ latency_ms: latency_ms,
82
+ input_tokens: text_input,
83
+ image_input_tokens: image_input,
84
+ output_tokens: text_output,
85
+ image_output_tokens: image_output
86
+ )
87
+ end
88
+
89
+ def record_moderation(provider, response, request:, latency_ms:)
90
+ record_passthrough(
91
+ provider: provider_slug(provider),
92
+ model: response_model_id(response) || model_id(request[:model]),
93
+ response: response,
94
+ latency_ms: latency_ms,
95
+ input_tokens: 0,
96
+ output_tokens: 0
97
+ )
98
+ end
99
+
100
+ def image_token_detail(usage, direction)
101
+ container_key = direction == :input ? :input_tokens_details : :output_tokens_details
102
+ details = usage[container_key] || usage[container_key.to_s] || {}
103
+ return 0 unless details.is_a?(Hash)
104
+
105
+ (details[:image_tokens] || details["image_tokens"]).to_i
106
+ end
107
+
108
+ def record_passthrough(provider:, model:, response:, latency_ms:, input_tokens:, output_tokens:,
109
+ image_input_tokens: 0, image_output_tokens: 0)
110
+ return unless active?
111
+
112
+ record_safely do
113
+ LlmCostTracker::Tracker.record(
114
+ capture: UsageCapture.build(
115
+ provider: provider,
116
+ model: model,
117
+ token_usage: TokenUsage.build(
118
+ input_tokens: input_tokens,
119
+ output_tokens: output_tokens,
120
+ image_input_tokens: image_input_tokens,
121
+ image_output_tokens: image_output_tokens
122
+ ),
123
+ usage_source: :sdk_response,
124
+ provider_response_id: provider_response_id(response)
125
+ ),
126
+ latency_ms: latency_ms
127
+ )
128
+ end
129
+ end
130
+
68
131
  def record_usage(provider:, model:, response:, latency_ms:, stream:, output_tokens: nil)
69
132
  return unless active?
70
133
 
@@ -80,7 +143,7 @@ module LlmCostTracker
80
143
  capture: UsageCapture.build(
81
144
  provider: provider,
82
145
  model: model,
83
- pricing_mode: pricing_mode(response),
146
+ pricing_mode: pricing_mode(provider: provider, response: response),
84
147
  token_usage: TokenUsage.build(
85
148
  input_tokens: regular_input_tokens(input_tokens, cache_read),
86
149
  output_tokens: output_tokens.to_i,
@@ -89,7 +152,7 @@ module LlmCostTracker
89
152
  hidden_output_tokens: hidden_output
90
153
  ),
91
154
  stream: stream,
92
- usage_source: :ruby_llm,
155
+ usage_source: :sdk_response,
93
156
  provider_response_id: provider_response_id(response)
94
157
  ),
95
158
  latency_ms: latency_ms
@@ -98,7 +161,7 @@ module LlmCostTracker
98
161
  end
99
162
 
100
163
  def regular_input_tokens(input_tokens, cache_read)
101
- [input_tokens.to_i - cache_read.to_i, 0].max
164
+ [input_tokens.to_i - cache_read, 0].max
102
165
  end
103
166
 
104
167
  def provider_slug(provider)
@@ -122,10 +185,16 @@ module LlmCostTracker
122
185
  object_value(response, :id, :provider_response_id) || object_dig(response, :raw, :id)
123
186
  end
124
187
 
125
- def pricing_mode(response)
126
- object_value(response, :pricing_mode, :service_tier) ||
127
- object_dig(response, :raw, :pricing_mode) ||
128
- object_dig(response, :raw, :service_tier)
188
+ ANTHROPIC_STANDARD_EQUIVALENT_SERVICE_TIERS = %w[standard standard_only priority].freeze
189
+ private_constant :ANTHROPIC_STANDARD_EQUIVALENT_SERVICE_TIERS
190
+
191
+ def pricing_mode(provider:, response:)
192
+ raw = object_value(response, :pricing_mode, :service_tier) ||
193
+ object_dig(response, :raw, :pricing_mode) ||
194
+ object_dig(response, :raw, :service_tier)
195
+ return nil if provider == "anthropic" && ANTHROPIC_STANDARD_EQUIVALENT_SERVICE_TIERS.include?(raw.to_s)
196
+
197
+ raw
129
198
  end
130
199
  end
131
200
 
@@ -133,8 +202,8 @@ module LlmCostTracker
133
202
  def complete(*args, **kwargs, &)
134
203
  integration = LlmCostTracker::Integrations::RubyLlm
135
204
  request = integration.request_params(args, kwargs)
136
- started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
137
205
  integration.enforce_budget!
206
+ started_at = LlmCostTracker::Timing.now_monotonic
138
207
  response = super
139
208
  integration.record_completion(
140
209
  self,
@@ -149,8 +218,8 @@ module LlmCostTracker
149
218
  def embed(*args, **kwargs)
150
219
  integration = LlmCostTracker::Integrations::RubyLlm
151
220
  request = integration.request_params(args, kwargs)
152
- started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
153
221
  integration.enforce_budget!
222
+ started_at = LlmCostTracker::Timing.now_monotonic
154
223
  response = super
155
224
  integration.record_embedding(
156
225
  self,
@@ -164,8 +233,8 @@ module LlmCostTracker
164
233
  def transcribe(*args, **kwargs)
165
234
  integration = LlmCostTracker::Integrations::RubyLlm
166
235
  request = integration.request_params(args, kwargs)
167
- started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
168
236
  integration.enforce_budget!
237
+ started_at = LlmCostTracker::Timing.now_monotonic
169
238
  response = super
170
239
  integration.record_transcription(
171
240
  self,
@@ -175,6 +244,36 @@ module LlmCostTracker
175
244
  )
176
245
  response
177
246
  end
247
+
248
+ def paint(*args, **kwargs)
249
+ integration = LlmCostTracker::Integrations::RubyLlm
250
+ request = integration.request_params(args, kwargs)
251
+ integration.enforce_budget!
252
+ started_at = LlmCostTracker::Timing.now_monotonic
253
+ response = super
254
+ integration.record_image(
255
+ self,
256
+ response,
257
+ request: request,
258
+ latency_ms: integration.elapsed_ms(started_at)
259
+ )
260
+ response
261
+ end
262
+
263
+ def moderate(*args, **kwargs)
264
+ integration = LlmCostTracker::Integrations::RubyLlm
265
+ request = integration.request_params(args, kwargs)
266
+ integration.enforce_budget!
267
+ started_at = LlmCostTracker::Timing.now_monotonic
268
+ response = super
269
+ integration.record_moderation(
270
+ self,
271
+ response,
272
+ request: request,
273
+ latency_ms: integration.elapsed_ms(started_at)
274
+ )
275
+ response
276
+ end
178
277
  end
179
278
  end
180
279
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "errors"
4
+ require_relative "logging"
4
5
  require_relative "integrations/openai"
5
6
  require_relative "integrations/anthropic"
6
7
  require_relative "integrations/ruby_llm"
@@ -13,10 +14,14 @@ module LlmCostTracker
13
14
  ruby_llm: RubyLlm
14
15
  }.freeze
15
16
 
17
+ DOUBLE_INSTRUMENTATION_OVERLAPS = %i[openai anthropic].freeze
18
+
16
19
  module_function
17
20
 
18
21
  def install!(names = LlmCostTracker.configuration.instrumented_integrations)
19
- normalize(names).each { |name| fetch(name).install }
22
+ normalized = normalize(names)
23
+ warn_double_instrumentation(normalized)
24
+ normalized.each { |name| fetch(name).install }
20
25
  end
21
26
 
22
27
  def checks(names = LlmCostTracker.configuration.instrumented_integrations)
@@ -26,11 +31,24 @@ module LlmCostTracker
26
31
  end
27
32
 
28
33
  def normalize(names)
29
- Array(names).flatten.map(&:to_sym).uniq
34
+ Array(names).flatten.uniq
35
+ end
36
+
37
+ def warn_double_instrumentation(names)
38
+ return unless names.include?(:ruby_llm)
39
+
40
+ overlapping = names & DOUBLE_INSTRUMENTATION_OVERLAPS
41
+ return if overlapping.empty?
42
+
43
+ Logging.warn(
44
+ ":ruby_llm is enabled together with #{overlapping.map(&:inspect).join(', ')}. " \
45
+ "RubyLLM uses HTTP underneath, so calls routed to those providers may be recorded twice " \
46
+ "(once via the SDK patch, once via the Faraday parser). Pick one path per provider."
47
+ )
30
48
  end
31
49
 
32
50
  def fetch(name)
33
- AVAILABLE.fetch(name.to_sym) do
51
+ AVAILABLE.fetch(name) do
34
52
  message = "Unknown integration: #{name.inspect}. Use one of: #{names.join(', ')}"
35
53
  raise LlmCostTracker::Error, message
36
54
  end
@@ -27,38 +27,57 @@ module LlmCostTracker
27
27
 
28
28
  def snapshot_totals
29
29
  values = periods.to_h { |period| [period, 0.0] }
30
+ period_by_name = periods.to_h { |period| [period.name, period] }
30
31
  sql = periods.map { |period| snapshot_select(period) }.join(" UNION ALL ")
31
- LlmCostTracker::Ledger::Call.find_by_sql(sql).each do |row|
32
- values[row.period_key.to_sym] = row.total_cost.to_f
32
+ LlmCostTracker::Call.find_by_sql(sql).each do |row|
33
+ period = period_by_name.fetch(row.period_key)
34
+ values[period] = row.total_cost.to_f
33
35
  end
34
36
  values
35
37
  end
36
38
 
37
39
  def snapshot_select(period)
38
40
  start = Period.range_start(period, time)
39
- "SELECT #{connection.quote(period.to_s)} AS period_key, " \
40
- "(#{rollup_total_sql(period)}) + (#{pending_total_sql(start)}) AS total_cost"
41
+ components = [period_total_sql(period, start)]
42
+ components << pending_total_sql(start) if Ingestion.durable?
43
+ "SELECT #{connection.quote(period.name)} AS period_key, " \
44
+ "(#{components.join(') + (')}) AS total_cost"
41
45
  end
42
46
 
43
- def rollup_total_sql(period)
44
- table = connection.quote_table_name("llm_cost_tracker_period_totals")
45
- "COALESCE((SELECT total_cost FROM #{table} " \
47
+ def period_total_sql(period, start)
48
+ if LlmCostTracker.configuration.cache_rollups
49
+ "GREATEST(COALESCE(#{rollup_sum_sql(period)}, 0), COALESCE(#{calls_sum_sql(start)}, 0))"
50
+ else
51
+ "COALESCE(#{calls_sum_sql(start)}, 0)"
52
+ end
53
+ end
54
+
55
+ def rollup_sum_sql(period)
56
+ table = connection.quote_table_name("llm_cost_tracker_call_rollups")
57
+ "(SELECT SUM(total_cost) FROM #{table} " \
46
58
  "WHERE period = #{connection.quote(Period::PERIODS.fetch(period))} " \
47
- "AND period_start = #{connection.quote(Period.bucket(period, time))} LIMIT 1), 0)"
59
+ "AND period_start = #{connection.quote(Period.bucket(period, time))})"
60
+ end
61
+
62
+ def calls_sum_sql(start)
63
+ table = connection.quote_table_name("llm_cost_tracker_calls")
64
+ tracked_at = connection.quote_column_name("tracked_at")
65
+ "(SELECT SUM(total_cost) FROM #{table} " \
66
+ "WHERE #{tracked_at} BETWEEN #{connection.quote(start)} AND #{connection.quote(time)})"
48
67
  end
49
68
 
50
69
  def pending_total_sql(start)
51
- table = connection.quote_table_name(Ingestion::Event.table_name)
70
+ table = connection.quote_table_name(Ingestion::InboxEntry.table_name)
52
71
  total_cost = connection.quote_column_name("total_cost")
53
72
  tracked_at = connection.quote_column_name("tracked_at")
54
73
  attempts = connection.quote_column_name("attempts")
55
74
  "COALESCE((SELECT SUM(#{total_cost}) FROM #{table} " \
56
- "WHERE #{attempts} < #{Ingestion::Event::MAX_ATTEMPTS} " \
75
+ "WHERE #{attempts} < #{Ingestion::InboxEntry::MAX_ATTEMPTS_BEFORE_QUARANTINE} " \
57
76
  "AND #{tracked_at} BETWEEN #{connection.quote(start)} AND #{connection.quote(time)}), 0)"
58
77
  end
59
78
 
60
79
  def connection
61
- LlmCostTracker::Ledger::Call.connection
80
+ LlmCostTracker::Call.connection
62
81
  end
63
82
  end
64
83
  end
@@ -4,22 +4,22 @@ module LlmCostTracker
4
4
  module Ledger
5
5
  module Period
6
6
  PERIODS = {
7
- monthly: "month",
8
- daily: "day"
7
+ month: "month",
8
+ day: "day"
9
9
  }.freeze
10
10
 
11
11
  module_function
12
12
 
13
13
  def valid_keys(periods)
14
- periods.map(&:to_sym).select { |period| PERIODS.key?(period) }
14
+ periods.select { |period| PERIODS.key?(period) }
15
15
  end
16
16
 
17
17
  def range_start(period, time)
18
18
  utc_time = time.to_time.utc
19
19
 
20
20
  case period
21
- when :monthly then utc_time.beginning_of_month
22
- when :daily then utc_time.beginning_of_day
21
+ when :month then utc_time.beginning_of_month
22
+ when :day then utc_time.beginning_of_day
23
23
  end
24
24
  end
25
25
 
@@ -23,7 +23,7 @@ module LlmCostTracker
23
23
  total_cost = connection.quote_column_name("total_cost")
24
24
  updated_at = connection.quote_column_name("updated_at")
25
25
 
26
- "#{total_cost} = #{Period::Total.quoted_table_name}.#{total_cost} + excluded.#{total_cost}, " \
26
+ "#{total_cost} = #{LlmCostTracker::CallRollup.quoted_table_name}.#{total_cost} + excluded.#{total_cost}, " \
27
27
  "#{updated_at} = excluded.#{updated_at}"
28
28
  end
29
29
 
@@ -32,7 +32,7 @@ module LlmCostTracker
32
32
  end
33
33
 
34
34
  def connection
35
- Period::Total.connection
35
+ LlmCostTracker::CallRollup.connection
36
36
  end
37
37
  end
38
38
  end
@@ -3,34 +3,25 @@
3
3
  require "bigdecimal"
4
4
 
5
5
  require_relative "period"
6
- require_relative "rollups/batch"
7
6
  require_relative "rollups/upsert_sql"
8
7
 
9
8
  module LlmCostTracker
10
9
  module Ledger
11
10
  class Rollups
11
+ DEFAULT_CURRENCY = "USD"
12
+
12
13
  class << self
13
14
  def increment!(event)
14
15
  return unless event.total_cost
15
16
 
16
- Period::Total.upsert_all(
17
- period_rows(event),
18
- on_duplicate: Ledger::Rollups::UpsertSql.call,
19
- record_timestamps: true,
20
- unique_by: period_totals_unique_by
21
- )
17
+ upsert_call_rollups(period_rows(event))
22
18
  end
23
19
 
24
20
  def increment_many!(events)
25
21
  events = Array(events).select(&:total_cost)
26
22
  return if events.empty?
27
23
 
28
- Period::Total.upsert_all(
29
- Ledger::Rollups::Batch.rows(events),
30
- on_duplicate: Ledger::Rollups::UpsertSql.call,
31
- record_timestamps: true,
32
- unique_by: period_totals_unique_by
33
- )
24
+ upsert_call_rollups(period_rows_for_events(events))
34
25
  end
35
26
 
36
27
  def decrement!(call_rows)
@@ -43,43 +34,117 @@ module LlmCostTracker
43
34
  private
44
35
 
45
36
  def period_rows(event)
37
+ currency = currency_for(event)
38
+ provider = provider_for(event)
46
39
  Period::PERIODS.map do |period, name|
47
40
  {
48
41
  period: name,
49
42
  period_start: Period.bucket(period, event.tracked_at),
43
+ currency: currency,
44
+ provider: provider,
50
45
  total_cost: event.total_cost
51
46
  }
52
47
  end
53
48
  end
54
49
 
50
+ def period_rows_for_events(events)
51
+ call_rollups(events).map do |(period, period_start, currency, provider), total_cost|
52
+ {
53
+ period: period,
54
+ period_start: period_start,
55
+ currency: currency,
56
+ provider: provider,
57
+ total_cost: total_cost
58
+ }
59
+ end
60
+ end
61
+
62
+ def call_rollups(events)
63
+ events.each_with_object(Hash.new { |totals, key| totals[key] = BigDecimal("0") }) do |event, totals|
64
+ currency = currency_for(event)
65
+ provider = provider_for(event)
66
+ Period::PERIODS.each do |period, name|
67
+ key = [name, Period.bucket(period, event.tracked_at), currency, provider]
68
+ totals[key] += BigDecimal(event.total_cost.to_s)
69
+ end
70
+ end
71
+ end
72
+
55
73
  def period_decrement_totals(call_rows)
56
74
  call_rows.each_with_object(Hash.new { |totals, key| totals[key] = BigDecimal("0") }) do |row, totals|
57
- _id, tracked_at, total_cost = row
75
+ _id, tracked_at, total_cost, pricing_snapshot, provider = row
58
76
  next unless total_cost
59
77
 
78
+ currency = currency_from_snapshot(pricing_snapshot)
79
+ provider_key = provider.to_s
60
80
  Period::PERIODS.each_key do |period|
61
- totals[[period, Period.bucket(period, tracked_at)]] += BigDecimal(total_cost.to_s)
81
+ totals[[period, Period.bucket(period, tracked_at), currency, provider_key]] += total_cost
62
82
  end
63
83
  end
64
84
  end
65
85
 
66
86
  def apply_decrements(totals)
67
87
  now = Time.now.utc
88
+ buckets_by_period = totals.each_with_object({}) do |(key, amount), grouped|
89
+ period, period_start, currency, provider = key
90
+ grouped[[period, currency, provider]] ||= {}
91
+ grouped[[period, currency, provider]][period_start] = amount
92
+ end
68
93
 
69
- totals.each do |(period, period_start), amount|
70
- row = Period::Total.lock.find_by(period: Period::PERIODS.fetch(period),
71
- period_start: period_start)
72
- next unless row
73
-
74
- row.update_columns(total_cost: [BigDecimal(row.total_cost.to_s) - amount, BigDecimal("0")].max,
75
- updated_at: now)
94
+ conn = LlmCostTracker::CallRollup.connection
95
+ table = LlmCostTracker::CallRollup.quoted_table_name
96
+ period_col = conn.quote_column_name("period")
97
+ start_col = conn.quote_column_name("period_start")
98
+ currency_col = conn.quote_column_name("currency")
99
+ provider_col = conn.quote_column_name("provider")
100
+ total_col = conn.quote_column_name("total_cost")
101
+ updated_col = conn.quote_column_name("updated_at")
102
+
103
+ buckets_by_period.each do |(period, currency, provider), by_start|
104
+ case_clauses = by_start.map do |period_start, amount|
105
+ "WHEN #{start_col} = #{conn.quote(period_start)} THEN #{conn.quote(amount)}"
106
+ end.join(" ")
107
+ starts = by_start.keys.map { |period_start| conn.quote(period_start) }.join(", ")
108
+
109
+ conn.execute(
110
+ "UPDATE #{table} " \
111
+ "SET #{total_col} = GREATEST(0, #{total_col} - CASE #{case_clauses} ELSE 0 END), " \
112
+ "#{updated_col} = #{conn.quote(now)} " \
113
+ "WHERE #{period_col} = #{conn.quote(Period::PERIODS.fetch(period))} " \
114
+ "AND #{currency_col} = #{conn.quote(currency)} " \
115
+ "AND #{provider_col} = #{conn.quote(provider)} " \
116
+ "AND #{start_col} IN (#{starts})"
117
+ )
76
118
  end
77
119
  end
78
120
 
79
- def period_totals_unique_by
80
- return unless Period::Total.connection.supports_insert_conflict_target?
121
+ def currency_for(event)
122
+ snapshot = event.respond_to?(:pricing_snapshot) ? event.pricing_snapshot : nil
123
+ currency_from_snapshot(snapshot)
124
+ end
125
+
126
+ def currency_from_snapshot(snapshot)
127
+ value = (snapshot.is_a?(Hash) && (snapshot["currency"] || snapshot[:currency])) || DEFAULT_CURRENCY
128
+ value.to_s.upcase
129
+ end
130
+
131
+ def provider_for(event)
132
+ (event.respond_to?(:provider) ? event.provider : nil).to_s
133
+ end
134
+
135
+ def upsert_call_rollups(rows)
136
+ LlmCostTracker::CallRollup.upsert_all(
137
+ rows,
138
+ on_duplicate: Ledger::Rollups::UpsertSql.call,
139
+ record_timestamps: true,
140
+ unique_by: call_rollups_unique_by
141
+ )
142
+ end
143
+
144
+ def call_rollups_unique_by
145
+ return unless LlmCostTracker::CallRollup.connection.supports_insert_conflict_target?
81
146
 
82
- %i[period period_start]
147
+ %i[period period_start currency provider]
83
148
  end
84
149
  end
85
150
  end
@@ -32,6 +32,24 @@ module LlmCostTracker
32
32
  raise Error, "Unsupported database adapter: #{adapter_name(value)}. Use PostgreSQL or MySQL."
33
33
  end
34
34
 
35
+ def json_column_errors(column, adapter_value, column_name)
36
+ return [] unless column
37
+
38
+ expected_type = postgresql?(adapter_value) ? "jsonb" : "json"
39
+ return [] if json_column_type?(column, adapter_value)
40
+
41
+ ["#{column_name} column must use #{expected_type} (got #{column.sql_type})"]
42
+ end
43
+
44
+ def json_column_type?(column, adapter_value)
45
+ sql_type = column.sql_type.to_s.downcase
46
+ if postgresql?(adapter_value)
47
+ column.type == :jsonb || sql_type == "jsonb"
48
+ else
49
+ column.type == :json || sql_type == "json" || sql_type == "longtext"
50
+ end
51
+ end
52
+
35
53
  private
36
54
 
37
55
  def adapter_instance?(value, class_names)
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "adapter"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Schema
8
+ module CallLineItems
9
+ REQUIRED_COLUMNS = %w[
10
+ llm_cost_tracker_call_id
11
+ position
12
+ kind
13
+ direction
14
+ modality
15
+ cache_state
16
+ quantity
17
+ unit
18
+ rate_amount
19
+ rate_quantity
20
+ cost
21
+ currency
22
+ cost_status
23
+ pricing_basis
24
+ price_key
25
+ price_source
26
+ price_source_version
27
+ provider_field
28
+ provider_item_id
29
+ details
30
+ created_at
31
+ ].freeze
32
+
33
+ REQUIRED_INDEX_COLUMNS = [
34
+ %w[llm_cost_tracker_call_id position]
35
+ ].freeze
36
+
37
+ class << self
38
+ def current_schema_errors
39
+ connection = LlmCostTracker::Call.connection
40
+ Adapter.ensure_supported!(connection)
41
+ table_name = LlmCostTracker::CallLineItem.table_name
42
+ return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
43
+
44
+ columns = LlmCostTracker::CallLineItem.columns_hash
45
+ errors = []
46
+ missing = REQUIRED_COLUMNS - columns.keys
47
+ errors << "missing columns: #{missing.join(', ')}" if missing.any?
48
+ errors.concat(Adapter.json_column_errors(columns["details"], connection, "details"))
49
+ errors.concat(missing_index_errors(connection, table_name))
50
+ errors << missing_fk_error(connection, table_name) if missing_fk?(connection, table_name)
51
+ errors.compact
52
+ end
53
+
54
+ def missing_index_errors(connection, table_name)
55
+ existing = connection.indexes(table_name).map { |index| Array(index.columns).map(&:to_s) }
56
+ REQUIRED_INDEX_COLUMNS.filter_map do |required|
57
+ next if existing.any? { |columns| columns == required }
58
+
59
+ "missing index on (#{required.join(', ')})"
60
+ end
61
+ end
62
+
63
+ def missing_fk?(connection, table_name)
64
+ connection.foreign_keys(table_name).none? do |fk|
65
+ fk.column.to_s == "llm_cost_tracker_call_id" &&
66
+ fk.to_table.to_s == "llm_cost_tracker_calls"
67
+ end
68
+ rescue NotImplementedError, NoMethodError
69
+ false
70
+ end
71
+
72
+ def missing_fk_error(_connection, _table_name)
73
+ "missing foreign key on llm_cost_tracker_call_id referencing llm_cost_tracker_calls"
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "adapter"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Schema
8
+ module CallRollups
9
+ REQUIRED_COLUMNS = %w[period period_start currency provider total_cost created_at updated_at].freeze
10
+ UNIQUE_COLUMNS = %i[period period_start currency provider].freeze
11
+
12
+ class << self
13
+ def current_schema_errors
14
+ connection = LlmCostTracker::CallRollup.connection
15
+ Adapter.ensure_supported!(connection)
16
+ table_name = LlmCostTracker::CallRollup.table_name
17
+ return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
18
+
19
+ errors = []
20
+ missing = REQUIRED_COLUMNS - LlmCostTracker::CallRollup.columns_hash.keys
21
+ errors << "missing columns: #{missing.join(', ')}" if missing.any?
22
+ unless unique_period_index?(connection, table_name)
23
+ errors << "missing unique index: period, period_start, currency, provider"
24
+ end
25
+ errors
26
+ end
27
+
28
+ private
29
+
30
+ def unique_period_index?(connection, table_name)
31
+ connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end