llm_cost_tracker 0.6.1 → 0.7.1

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 (180) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +13 -12
  4. data/app/assets/llm_cost_tracker/application.css +3 -0
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +22 -4
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +6 -11
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +2 -1
  8. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +5 -1
  9. data/app/controllers/llm_cost_tracker/models_controller.rb +0 -1
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +1 -8
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +2 -1
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +1 -2
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -1
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +10 -27
  15. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +58 -0
  16. data/app/models/llm_cost_tracker/ingestion/event.rb +13 -0
  17. data/app/models/llm_cost_tracker/ingestion/lease.rb +11 -0
  18. data/app/models/llm_cost_tracker/ledger/call.rb +45 -0
  19. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +66 -0
  20. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +71 -0
  21. data/app/models/llm_cost_tracker/ledger/period/total.rb +13 -0
  22. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +19 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +111 -94
  24. data/app/services/llm_cost_tracker/dashboard/date_range.rb +2 -2
  25. data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -18
  26. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +58 -67
  27. data/app/services/llm_cost_tracker/dashboard/pagination.rb +59 -0
  28. data/app/services/llm_cost_tracker/dashboard/params.rb +26 -0
  29. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +18 -20
  30. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -13
  31. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +28 -61
  32. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +8 -37
  33. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  34. data/app/services/llm_cost_tracker/dashboard/top_models.rb +12 -47
  35. data/app/views/llm_cost_tracker/calls/index.html.erb +12 -18
  36. data/app/views/llm_cost_tracker/calls/show.html.erb +30 -32
  37. data/app/views/llm_cost_tracker/dashboard/index.html.erb +17 -19
  38. data/app/views/llm_cost_tracker/data_quality/index.html.erb +108 -135
  39. data/app/views/llm_cost_tracker/models/index.html.erb +8 -9
  40. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +13 -2
  41. data/app/views/llm_cost_tracker/tags/show.html.erb +20 -20
  42. data/config/routes.rb +1 -1
  43. data/lib/llm_cost_tracker/assets.rb +0 -6
  44. data/lib/llm_cost_tracker/budget.rb +10 -24
  45. data/lib/llm_cost_tracker/capture/stream.rb +9 -0
  46. data/lib/llm_cost_tracker/capture/stream_collector.rb +182 -0
  47. data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +40 -72
  48. data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
  49. data/lib/llm_cost_tracker/configuration.rb +30 -45
  50. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +61 -0
  51. data/lib/llm_cost_tracker/doctor/check.rb +7 -0
  52. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +22 -61
  53. data/lib/llm_cost_tracker/doctor/price_check.rb +60 -0
  54. data/lib/llm_cost_tracker/doctor.rb +66 -79
  55. data/lib/llm_cost_tracker/engine.rb +0 -3
  56. data/lib/llm_cost_tracker/errors.rb +4 -15
  57. data/lib/llm_cost_tracker/event.rb +6 -6
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +42 -0
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +7 -7
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +5 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +22 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +15 -14
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +1 -21
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +12 -1
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +2 -2
  67. data/lib/llm_cost_tracker/{storage/active_record_inbox_batch.rb → ingestion/batch.rb} +21 -20
  68. data/lib/llm_cost_tracker/ingestion/inbox.rb +105 -0
  69. data/lib/llm_cost_tracker/{storage/active_record_ingestor_lease.rb → ingestion/lease_claim.rb} +5 -7
  70. data/lib/llm_cost_tracker/{storage/active_record_ingestor.rb → ingestion/worker.rb} +38 -48
  71. data/lib/llm_cost_tracker/ingestion.rb +129 -0
  72. data/lib/llm_cost_tracker/integrations/anthropic.rb +52 -34
  73. data/lib/llm_cost_tracker/integrations/base.rb +73 -34
  74. data/lib/llm_cost_tracker/integrations/openai.rb +45 -39
  75. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +40 -30
  76. data/lib/llm_cost_tracker/integrations.rb +43 -0
  77. data/lib/llm_cost_tracker/ledger/period/totals.rb +66 -0
  78. data/lib/llm_cost_tracker/{storage/active_record_periods.rb → ledger/period.rb} +2 -2
  79. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +43 -0
  80. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +46 -0
  81. data/lib/llm_cost_tracker/ledger/rollups.rb +87 -0
  82. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +51 -0
  83. data/lib/llm_cost_tracker/ledger/schema/calls.rb +101 -0
  84. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +32 -0
  85. data/lib/llm_cost_tracker/ledger/store.rb +60 -0
  86. data/lib/llm_cost_tracker/ledger/tags/query.rb +29 -0
  87. data/lib/llm_cost_tracker/ledger/tags/sql.rb +33 -0
  88. data/lib/llm_cost_tracker/ledger.rb +13 -0
  89. data/lib/llm_cost_tracker/logging.rb +3 -6
  90. data/lib/llm_cost_tracker/middleware/faraday.rb +35 -36
  91. data/lib/llm_cost_tracker/parsers/anthropic.rb +38 -27
  92. data/lib/llm_cost_tracker/parsers/base.rb +10 -19
  93. data/lib/llm_cost_tracker/parsers/gemini.rb +15 -16
  94. data/lib/llm_cost_tracker/parsers/openai_usage.rb +24 -19
  95. data/lib/llm_cost_tracker/parsers/sse.rb +4 -7
  96. data/lib/llm_cost_tracker/parsers.rb +20 -0
  97. data/lib/llm_cost_tracker/prices.json +52 -11
  98. data/lib/llm_cost_tracker/pricing/components.rb +37 -0
  99. data/lib/llm_cost_tracker/pricing/effective_prices.rb +40 -50
  100. data/lib/llm_cost_tracker/pricing/explainer.rb +12 -23
  101. data/lib/llm_cost_tracker/pricing/lookup.rb +24 -25
  102. data/lib/llm_cost_tracker/pricing/registry.rb +156 -0
  103. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +107 -0
  104. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +53 -0
  105. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +63 -0
  106. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +31 -0
  107. data/lib/llm_cost_tracker/pricing/sync.rb +143 -0
  108. data/lib/llm_cost_tracker/pricing/unknown.rb +46 -0
  109. data/lib/llm_cost_tracker/pricing.rb +33 -32
  110. data/lib/llm_cost_tracker/railtie.rb +7 -10
  111. data/lib/llm_cost_tracker/report/data.rb +72 -0
  112. data/lib/llm_cost_tracker/report/formatter.rb +69 -0
  113. data/lib/llm_cost_tracker/report.rb +8 -10
  114. data/lib/llm_cost_tracker/retention.rb +27 -10
  115. data/lib/llm_cost_tracker/tags/context.rb +35 -0
  116. data/lib/llm_cost_tracker/tags/key.rb +18 -0
  117. data/lib/llm_cost_tracker/tags/sanitizer.rb +68 -0
  118. data/lib/llm_cost_tracker/token_usage.rb +67 -0
  119. data/lib/llm_cost_tracker/tracker.rb +38 -70
  120. data/lib/llm_cost_tracker/usage_capture.rb +37 -0
  121. data/lib/llm_cost_tracker/version.rb +1 -1
  122. data/lib/llm_cost_tracker.rb +56 -90
  123. data/lib/tasks/llm_cost_tracker.rake +18 -13
  124. metadata +85 -99
  125. data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +0 -81
  126. data/app/services/llm_cost_tracker/pagination.rb +0 -57
  127. data/lib/llm_cost_tracker/active_record_adapter.rb +0 -49
  128. data/lib/llm_cost_tracker/capture_verifier.rb +0 -71
  129. data/lib/llm_cost_tracker/configuration/storage_backend.rb +0 -26
  130. data/lib/llm_cost_tracker/cost.rb +0 -12
  131. data/lib/llm_cost_tracker/doctor/capture_check.rb +0 -39
  132. data/lib/llm_cost_tracker/engine_compatibility.rb +0 -15
  133. data/lib/llm_cost_tracker/event_metadata.rb +0 -52
  134. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +0 -29
  135. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +0 -29
  136. data/lib/llm_cost_tracker/inbox_event.rb +0 -9
  137. data/lib/llm_cost_tracker/ingestor_lease.rb +0 -9
  138. data/lib/llm_cost_tracker/integrations/object_reader.rb +0 -56
  139. data/lib/llm_cost_tracker/integrations/registry.rb +0 -73
  140. data/lib/llm_cost_tracker/llm_api_call.rb +0 -60
  141. data/lib/llm_cost_tracker/llm_api_call_metrics.rb +0 -63
  142. data/lib/llm_cost_tracker/parameter_hash.rb +0 -33
  143. data/lib/llm_cost_tracker/parsed_usage.rb +0 -72
  144. data/lib/llm_cost_tracker/parsers/registry.rb +0 -58
  145. data/lib/llm_cost_tracker/period_grouping.rb +0 -69
  146. data/lib/llm_cost_tracker/period_total.rb +0 -9
  147. data/lib/llm_cost_tracker/price_freshness.rb +0 -38
  148. data/lib/llm_cost_tracker/price_registry.rb +0 -144
  149. data/lib/llm_cost_tracker/price_sync/fetcher.rb +0 -104
  150. data/lib/llm_cost_tracker/price_sync/registry_diff.rb +0 -51
  151. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +0 -61
  152. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +0 -29
  153. data/lib/llm_cost_tracker/price_sync.rb +0 -144
  154. data/lib/llm_cost_tracker/report_data.rb +0 -94
  155. data/lib/llm_cost_tracker/report_formatter.rb +0 -67
  156. data/lib/llm_cost_tracker/request_url.rb +0 -20
  157. data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -166
  158. data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +0 -13
  159. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +0 -165
  160. data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +0 -84
  161. data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +0 -41
  162. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +0 -42
  163. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +0 -146
  164. data/lib/llm_cost_tracker/storage/active_record_store.rb +0 -145
  165. data/lib/llm_cost_tracker/storage/custom_backend.rb +0 -32
  166. data/lib/llm_cost_tracker/storage/dispatcher.rb +0 -45
  167. data/lib/llm_cost_tracker/storage/log_backend.rb +0 -38
  168. data/lib/llm_cost_tracker/storage/registry.rb +0 -63
  169. data/lib/llm_cost_tracker/stream_capture.rb +0 -7
  170. data/lib/llm_cost_tracker/stream_collector.rb +0 -199
  171. data/lib/llm_cost_tracker/tag_accessors.rb +0 -15
  172. data/lib/llm_cost_tracker/tag_context.rb +0 -52
  173. data/lib/llm_cost_tracker/tag_key.rb +0 -16
  174. data/lib/llm_cost_tracker/tag_query.rb +0 -43
  175. data/lib/llm_cost_tracker/tag_sanitizer.rb +0 -81
  176. data/lib/llm_cost_tracker/tag_sql.rb +0 -34
  177. data/lib/llm_cost_tracker/tags_column.rb +0 -103
  178. data/lib/llm_cost_tracker/unknown_pricing.rb +0 -54
  179. data/lib/llm_cost_tracker/usage_breakdown.rb +0 -30
  180. data/lib/llm_cost_tracker/value_helpers.rb +0 -40
@@ -1,20 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "uri"
4
-
5
- module LlmCostTracker
6
- module RequestUrl
7
- class << self
8
- def label(value)
9
- uri = URI.parse(value.to_s)
10
- uri.query = nil
11
- uri.fragment = nil
12
- uri.user = nil if uri.respond_to?(:user=)
13
- uri.password = nil if uri.respond_to?(:password=)
14
- uri.to_s
15
- rescue URI::InvalidURIError
16
- value.to_s.split("?", 2).first
17
- end
18
- end
19
- end
20
- end
@@ -1,166 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "securerandom"
4
-
5
- require_relative "registry"
6
- require_relative "active_record_inbox"
7
- require_relative "active_record_ingestor"
8
- require_relative "active_record_store"
9
-
10
- module LlmCostTracker
11
- module Storage
12
- class ActiveRecordBackend
13
- VERIFY_TAG = "llm_cost_tracker_verify"
14
-
15
- class << self
16
- def save(event)
17
- require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
18
-
19
- if ActiveRecordInbox.enabled?
20
- ActiveRecordInbox.save(event)
21
- else
22
- ActiveRecordStore.save(event)
23
- end
24
- event
25
- rescue LoadError => e
26
- raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
27
- end
28
-
29
- def verify
30
- require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
31
-
32
- unless LlmCostTracker::LlmApiCall.table_exists?
33
- return [
34
- VerificationResult.new(
35
- :error,
36
- "active_record",
37
- "llm_api_calls table is missing; run install generator and migrate"
38
- )
39
- ]
40
- end
41
-
42
- [active_record_capture_check]
43
- rescue LoadError => e
44
- [VerificationResult.new(:error, "active_record", "unavailable: #{e.message}")]
45
- rescue StandardError => e
46
- [VerificationResult.new(:error, "active_record", "#{e.class}: #{e.message}")]
47
- end
48
-
49
- def prune(cutoff:, batch_size:)
50
- require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
51
-
52
- ActiveRecordStore.prune(cutoff: cutoff, batch_size: batch_size)
53
- end
54
-
55
- private
56
-
57
- def active_record_capture_check
58
- return active_record_inbox_capture_check if ActiveRecordInbox.enabled?
59
-
60
- provider, model = sample_priced_identity
61
- response_id = "lct_verify_#{SecureRandom.hex(8)}"
62
- notifications = []
63
- persisted = false
64
- subscription = subscribe_to_verification(response_id, notifications)
65
-
66
- LlmCostTracker::LlmApiCall.transaction do
67
- LlmCostTracker.track(
68
- provider: provider,
69
- model: model,
70
- input_tokens: 1,
71
- output_tokens: 1,
72
- provider_response_id: response_id,
73
- feature: VERIFY_TAG
74
- )
75
- persisted = LlmCostTracker::LlmApiCall.where(provider_response_id: response_id).exists?
76
- raise ActiveRecord::Rollback
77
- end
78
-
79
- return active_record_capture_success if persisted && notifications.any?
80
-
81
- VerificationResult.new(:error, "active_record capture", capture_failure_message(persisted, notifications))
82
- rescue LlmCostTracker::BudgetExceededError => e
83
- VerificationResult.new(:error, "active_record capture", "blocked by budget guardrail: #{e.message}")
84
- rescue LlmCostTracker::Error => e
85
- VerificationResult.new(:error, "active_record capture", e.message)
86
- rescue StandardError => e
87
- VerificationResult.new(:error, "active_record capture", "#{e.class}: #{e.message}")
88
- ensure
89
- ActiveSupport::Notifications.unsubscribe(subscription) if subscription
90
- end
91
-
92
- def active_record_inbox_capture_check
93
- provider, model = sample_priced_identity
94
- response_id = "lct_verify_#{SecureRandom.hex(8)}"
95
- notifications = []
96
- subscription = subscribe_to_verification(response_id, notifications)
97
-
98
- event = LlmCostTracker.track(
99
- provider: provider,
100
- model: model,
101
- input_tokens: 1,
102
- output_tokens: 1,
103
- provider_response_id: response_id,
104
- feature: VERIFY_TAG
105
- )
106
- LlmCostTracker.flush!
107
- persisted = LlmCostTracker::LlmApiCall.where(provider_response_id: response_id).exists?
108
-
109
- if persisted && notifications.any?
110
- return active_record_capture_success("manual event emitted and persisted through durable inbox")
111
- end
112
-
113
- VerificationResult.new(:error, "active_record capture", capture_failure_message(persisted, notifications))
114
- rescue LlmCostTracker::BudgetExceededError => e
115
- VerificationResult.new(:error, "active_record capture", "blocked by budget guardrail: #{e.message}")
116
- rescue LlmCostTracker::Error => e
117
- VerificationResult.new(:error, "active_record capture", e.message)
118
- rescue StandardError => e
119
- VerificationResult.new(:error, "active_record capture", "#{e.class}: #{e.message}")
120
- ensure
121
- cleanup_verification_call(response_id) if response_id
122
- LlmCostTracker::InboxEvent.where(event_id: event.event_id).delete_all if event
123
- ActiveSupport::Notifications.unsubscribe(subscription) if subscription
124
- end
125
-
126
- def subscribe_to_verification(response_id, notifications)
127
- ActiveSupport::Notifications.subscribe(LlmCostTracker::Tracker::EVENT_NAME) do |*, payload|
128
- notifications << payload if payload[:provider_response_id] == response_id
129
- end
130
- end
131
-
132
- def active_record_capture_success(message = "manual event emitted and persisted inside rollback")
133
- VerificationResult.new(
134
- :ok,
135
- "active_record capture",
136
- message
137
- )
138
- end
139
-
140
- def capture_failure_message(persisted, notifications)
141
- missing = []
142
- missing << "notification" if notifications.empty?
143
- missing << "persisted row" unless persisted
144
- "missing #{missing.join(' and ')} for synthetic manual event"
145
- end
146
-
147
- def cleanup_verification_call(response_id)
148
- relation = LlmCostTracker::LlmApiCall.where(provider_response_id: response_id)
149
- rows = relation.pluck(:id, :tracked_at, :total_cost)
150
- return if rows.empty?
151
-
152
- relation.delete_all
153
- ActiveRecordRollups.decrement!(rows)
154
- end
155
-
156
- def sample_priced_identity
157
- key = LlmCostTracker::PriceRegistry.builtin_prices.find do |model_id, prices|
158
- model_id.include?("/") && prices[:input] && prices[:output]
159
- end&.first
160
- provider, model = key.to_s.split("/", 2)
161
- [provider || "openai", model || "gpt-4o-mini"]
162
- end
163
- end
164
- end
165
- end
166
- end
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LlmCostTracker
4
- module Storage
5
- module ActiveRecordConnectionCleanup
6
- def self.release!
7
- ActiveRecord::Base.connection_handler.clear_active_connections!
8
- rescue StandardError
9
- nil
10
- end
11
- end
12
- end
13
- end
@@ -1,165 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "time"
5
-
6
- require_relative "../active_record_adapter"
7
- require_relative "../cost"
8
- require_relative "../event"
9
- require_relative "../inbox_event"
10
- require_relative "active_record_periods"
11
-
12
- module LlmCostTracker
13
- module Storage
14
- class ActiveRecordInbox
15
- TABLE_NAME = "llm_cost_tracker_inbox_events"
16
- LEASE_TABLE_NAME = "llm_cost_tracker_ingestor_leases"
17
- MAX_ATTEMPTS = 5
18
-
19
- class << self
20
- def reset!
21
- remove_instance_variable(:@enabled) if instance_variable_defined?(:@enabled)
22
- end
23
-
24
- def enabled?
25
- return @enabled unless @enabled.nil?
26
-
27
- model = LlmCostTracker::LlmApiCall
28
- @enabled = model.columns_hash.key?("event_id") &&
29
- model.connection.data_source_exists?(TABLE_NAME) &&
30
- model.connection.data_source_exists?(LEASE_TABLE_NAME)
31
- rescue StandardError
32
- @enabled = false
33
- end
34
-
35
- def save(event)
36
- insert_row(row_for(event))
37
- ActiveRecordIngestor.ensure_started
38
- event
39
- end
40
-
41
- def pending_period_totals(periods, time:)
42
- return periods.to_h { |period| [period, 0.0] } unless enabled?
43
-
44
- periods.to_h do |period|
45
- [period, pending_period_total(period, time)]
46
- end
47
- end
48
-
49
- def event_from_row(row)
50
- payload = JSON.parse(row.payload)
51
- cost = payload["cost"] && LlmCostTracker::Cost.new(**symbolize_keys(payload["cost"]))
52
-
53
- LlmCostTracker::Event.new(
54
- event_id: payload.fetch("event_id"),
55
- provider: payload.fetch("provider"),
56
- model: payload.fetch("model"),
57
- input_tokens: payload.fetch("input_tokens"),
58
- output_tokens: payload.fetch("output_tokens"),
59
- total_tokens: payload.fetch("total_tokens"),
60
- cache_read_input_tokens: payload.fetch("cache_read_input_tokens"),
61
- cache_write_input_tokens: payload.fetch("cache_write_input_tokens"),
62
- hidden_output_tokens: payload.fetch("hidden_output_tokens"),
63
- pricing_mode: payload["pricing_mode"],
64
- cost: cost,
65
- tags: payload.fetch("tags"),
66
- latency_ms: payload["latency_ms"],
67
- stream: payload.fetch("stream"),
68
- usage_source: payload["usage_source"],
69
- provider_response_id: payload["provider_response_id"],
70
- tracked_at: Time.iso8601(payload.fetch("tracked_at"))
71
- )
72
- end
73
-
74
- private
75
-
76
- def row_for(event)
77
- now = Time.now.utc
78
- {
79
- event_id: event.event_id,
80
- total_cost: event.cost&.total_cost,
81
- tracked_at: event.tracked_at,
82
- payload: JSON.generate(payload_for(event)),
83
- attempts: 0,
84
- created_at: now,
85
- updated_at: now
86
- }
87
- end
88
-
89
- def payload_for(event)
90
- {
91
- event_id: event.event_id,
92
- provider: event.provider,
93
- model: event.model,
94
- input_tokens: event.input_tokens,
95
- output_tokens: event.output_tokens,
96
- total_tokens: event.total_tokens,
97
- cache_read_input_tokens: event.cache_read_input_tokens,
98
- cache_write_input_tokens: event.cache_write_input_tokens,
99
- hidden_output_tokens: event.hidden_output_tokens,
100
- pricing_mode: event.pricing_mode,
101
- cost: event.cost&.to_h,
102
- tags: event.tags || {},
103
- latency_ms: event.latency_ms,
104
- stream: event.stream,
105
- usage_source: event.usage_source,
106
- provider_response_id: event.provider_response_id,
107
- tracked_at: event.tracked_at.iso8601(6)
108
- }
109
- end
110
-
111
- def insert_row(row)
112
- connection = LlmCostTracker::LlmApiCall.connection
113
- if connection.transaction_open? && !sqlite_database?(connection)
114
- insert_with_separate_connection(row)
115
- else
116
- execute_insert(connection, row)
117
- end
118
- rescue ActiveRecord::ConnectionTimeoutError => e
119
- raise LlmCostTracker::Error,
120
- "ActiveRecord inbox could not checkout a separate database connection: #{e.message}"
121
- end
122
-
123
- def insert_with_separate_connection(row)
124
- pool = LlmCostTracker::LlmApiCall.connection_pool
125
- connection = pool.checkout
126
- begin
127
- connection.transaction(requires_new: true) { execute_insert(connection, row) }
128
- ensure
129
- pool.checkin(connection)
130
- end
131
- end
132
-
133
- def execute_insert(connection, row)
134
- columns = row.keys
135
- quoted_columns = columns.map { |column| connection.quote_column_name(column) }.join(", ")
136
- quoted_values = columns.map { |column| connection.quote(row.fetch(column)) }.join(", ")
137
- table = connection.quote_table_name(TABLE_NAME)
138
-
139
- connection.execute("INSERT INTO #{table} (#{quoted_columns}) VALUES (#{quoted_values})")
140
- end
141
-
142
- def pending_period_total(period, time)
143
- LlmCostTracker::InboxEvent
144
- .where("attempts < ?", MAX_ATTEMPTS)
145
- .where(tracked_at: period_range(period, time))
146
- .sum(:total_cost)
147
- .to_f
148
- end
149
-
150
- def period_range(period, time)
151
- utc_time = time.to_time.utc
152
- ActiveRecordPeriods.range_start(period, utc_time)..utc_time
153
- end
154
-
155
- def symbolize_keys(hash)
156
- hash.transform_keys(&:to_sym)
157
- end
158
-
159
- def sqlite_database?(connection)
160
- ActiveRecordAdapter.sqlite?(connection)
161
- end
162
- end
163
- end
164
- end
165
- end
@@ -1,84 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "active_record_inbox"
4
- require_relative "active_record_periods"
5
- require_relative "active_record_rollups"
6
-
7
- module LlmCostTracker
8
- module Storage
9
- class ActiveRecordPeriodTotals
10
- def self.call(periods, time:)
11
- new(periods, time: time).totals
12
- end
13
-
14
- def initialize(periods, time:)
15
- @periods = ActiveRecordPeriods.valid_keys(periods)
16
- @time = time
17
- end
18
-
19
- def totals
20
- return {} if periods.empty?
21
- return ActiveRecordRollups.period_totals(periods, time: time) unless ActiveRecordInbox.enabled?
22
-
23
- snapshot_totals
24
- end
25
-
26
- private
27
-
28
- attr_reader :periods, :time
29
-
30
- def snapshot_totals
31
- values = periods.to_h { |period| [period, 0.0] }
32
- connection.select_all(snapshot_sql).each do |row|
33
- values[row.fetch("period_key").to_sym] = row.fetch("total_cost").to_f
34
- end
35
- values
36
- end
37
-
38
- def snapshot_sql
39
- periods.map { |period| snapshot_select(period) }.join(" UNION ALL ")
40
- end
41
-
42
- def snapshot_select(period)
43
- start = range_start_for(period)
44
- "SELECT #{connection.quote(period.to_s)} AS period_key, " \
45
- "(#{stored_total_sql(period, start)}) + (#{pending_total_sql(start)}) AS total_cost"
46
- end
47
-
48
- def stored_total_sql(period, start)
49
- period_totals_table? ? rollup_total_sql(period) : ledger_total_sql(start)
50
- end
51
-
52
- def rollup_total_sql(period)
53
- table = connection.quote_table_name("llm_cost_tracker_period_totals")
54
- "COALESCE((SELECT total_cost FROM #{table} " \
55
- "WHERE period = #{connection.quote(ActiveRecordPeriods::PERIODS.fetch(period))} " \
56
- "AND period_start = #{connection.quote(ActiveRecordPeriods.bucket(period, time))} LIMIT 1), 0)"
57
- end
58
-
59
- def ledger_total_sql(start)
60
- table = LlmCostTracker::LlmApiCall.quoted_table_name
61
- total_cost = connection.quote_column_name("total_cost")
62
- tracked_at = connection.quote_column_name("tracked_at")
63
- "COALESCE((SELECT SUM(#{total_cost}) FROM #{table} " \
64
- "WHERE #{tracked_at} BETWEEN #{connection.quote(start)} AND #{connection.quote(time)}), 0)"
65
- end
66
-
67
- def pending_total_sql(start)
68
- table = connection.quote_table_name(ActiveRecordInbox::TABLE_NAME)
69
- total_cost = connection.quote_column_name("total_cost")
70
- tracked_at = connection.quote_column_name("tracked_at")
71
- attempts = connection.quote_column_name("attempts")
72
- "COALESCE((SELECT SUM(#{total_cost}) FROM #{table} " \
73
- "WHERE #{attempts} < #{ActiveRecordInbox::MAX_ATTEMPTS} " \
74
- "AND #{tracked_at} BETWEEN #{connection.quote(start)} AND #{connection.quote(time)}), 0)"
75
- end
76
-
77
- def period_totals_table? = connection.data_source_exists?("llm_cost_tracker_period_totals")
78
-
79
- def range_start_for(period) = ActiveRecordPeriods.range_start(period, time)
80
-
81
- def connection = LlmCostTracker::LlmApiCall.connection
82
- end
83
- end
84
- end
@@ -1,41 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bigdecimal"
4
-
5
- require_relative "active_record_periods"
6
-
7
- module LlmCostTracker
8
- module Storage
9
- class ActiveRecordRollupBatch
10
- def self.rows(events)
11
- new(events).rows
12
- end
13
-
14
- def initialize(events)
15
- @events = events
16
- end
17
-
18
- def rows
19
- totals.map do |(period, period_start), total_cost|
20
- {
21
- period: period,
22
- period_start: period_start,
23
- total_cost: total_cost
24
- }
25
- end
26
- end
27
-
28
- private
29
-
30
- attr_reader :events
31
-
32
- def totals
33
- events.each_with_object(Hash.new { |hash, key| hash[key] = BigDecimal("0") }) do |event, rows|
34
- ActiveRecordPeriods::PERIODS.each do |period, name|
35
- rows[[name, ActiveRecordPeriods.bucket(period, event.tracked_at)]] += BigDecimal(event.cost.total_cost.to_s)
36
- end
37
- end
38
- end
39
- end
40
- end
41
- end
@@ -1,42 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../active_record_adapter"
4
-
5
- module LlmCostTracker
6
- module Storage
7
- class ActiveRecordRollupUpsertSql
8
- def self.call(model)
9
- new(model).call
10
- end
11
-
12
- def initialize(model)
13
- @model = model
14
- end
15
-
16
- def call
17
- return Arel.sql(mysql_sql) if ActiveRecordAdapter.mysql?(connection)
18
- return Arel.sql(postgres_sql) if ActiveRecordAdapter.postgresql?(connection)
19
-
20
- Arel.sql("total_cost = total_cost + excluded.total_cost, updated_at = excluded.updated_at")
21
- end
22
-
23
- private
24
-
25
- attr_reader :model
26
-
27
- def postgres_sql
28
- total_cost = connection.quote_column_name("total_cost")
29
- updated_at = connection.quote_column_name("updated_at")
30
-
31
- "#{total_cost} = #{model.quoted_table_name}.#{total_cost} + excluded.#{total_cost}, " \
32
- "#{updated_at} = excluded.#{updated_at}"
33
- end
34
-
35
- def mysql_sql
36
- "total_cost = total_cost + VALUES(total_cost), updated_at = VALUES(updated_at)"
37
- end
38
-
39
- def connection = model.connection
40
- end
41
- end
42
- end
@@ -1,146 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bigdecimal"
4
-
5
- require_relative "active_record_periods"
6
- require_relative "active_record_rollup_batch"
7
- require_relative "active_record_rollup_upsert_sql"
8
-
9
- module LlmCostTracker
10
- module Storage
11
- class ActiveRecordRollups
12
- class << self
13
- def reset!
14
- remove_instance_variable(:@period_totals_enabled) if instance_variable_defined?(:@period_totals_enabled)
15
- end
16
-
17
- def increment!(event)
18
- return unless event.cost&.total_cost
19
- return unless period_totals_enabled?
20
-
21
- model = period_total_model
22
- model.upsert_all(
23
- period_rows(event),
24
- on_duplicate: ActiveRecordRollupUpsertSql.call(model),
25
- record_timestamps: true,
26
- unique_by: unique_by(model, %i[period period_start])
27
- )
28
- end
29
-
30
- def increment_many!(events)
31
- events = Array(events).select { |event| event.cost&.total_cost }
32
- return if events.empty?
33
- return unless period_totals_enabled?
34
-
35
- model = period_total_model
36
- model.upsert_all(
37
- ActiveRecordRollupBatch.rows(events),
38
- on_duplicate: ActiveRecordRollupUpsertSql.call(model),
39
- record_timestamps: true,
40
- unique_by: unique_by(model, %i[period period_start])
41
- )
42
- end
43
-
44
- def decrement!(call_rows)
45
- return unless period_totals_enabled?
46
-
47
- totals = period_decrement_totals(call_rows)
48
- return if totals.empty?
49
-
50
- apply_decrements(totals)
51
- end
52
-
53
- def period_totals(periods, time: Time.now.utc)
54
- periods = ActiveRecordPeriods.valid_keys(periods)
55
- return {} if periods.empty?
56
-
57
- if period_totals_enabled?
58
- rollup_period_totals(periods, time)
59
- else
60
- periods.to_h { |period| [period, fallback_period_total(period, time)] }
61
- end
62
- end
63
-
64
- private
65
-
66
- def period_rows(event)
67
- ActiveRecordPeriods::PERIODS.map do |period, name|
68
- {
69
- period: name,
70
- period_start: ActiveRecordPeriods.bucket(period, event.tracked_at),
71
- total_cost: event.cost.total_cost
72
- }
73
- end
74
- end
75
-
76
- def period_decrement_totals(call_rows)
77
- call_rows.each_with_object(Hash.new { |totals, key| totals[key] = BigDecimal("0") }) do |row, totals|
78
- _id, tracked_at, total_cost = row
79
- next unless total_cost
80
-
81
- ActiveRecordPeriods::PERIODS.each_key do |period|
82
- totals[[period, ActiveRecordPeriods.bucket(period, tracked_at)]] += BigDecimal(total_cost.to_s)
83
- end
84
- end
85
- end
86
-
87
- def apply_decrements(totals)
88
- model = period_total_model
89
- now = Time.now.utc
90
-
91
- totals.each do |(period, period_start), amount|
92
- row = model.lock.find_by(period: ActiveRecordPeriods::PERIODS.fetch(period), period_start: period_start)
93
- next unless row
94
-
95
- row.update_columns(total_cost: decremented_total(row.total_cost, amount), updated_at: now)
96
- end
97
- end
98
-
99
- def decremented_total(current, amount) = [BigDecimal(current.to_s) - amount, BigDecimal("0")].max
100
-
101
- def rollup_period_totals(periods, time)
102
- buckets = periods.to_h { |period| [period, ActiveRecordPeriods.bucket(period, time)] }
103
- index = buckets.to_h { |period, bucket| [[ActiveRecordPeriods::PERIODS.fetch(period), bucket], period] }
104
- totals = periods.to_h { |period| [period, 0.0] }
105
-
106
- period_total_model
107
- .where(period: periods.map { |period| ActiveRecordPeriods::PERIODS.fetch(period) },
108
- period_start: buckets.values)
109
- .pluck(:period, :period_start, :total_cost)
110
- .each do |name, start, total|
111
- period = index[[name, start.to_date]]
112
- totals[period] = total.to_f if period
113
- end
114
-
115
- totals
116
- end
117
-
118
- def fallback_period_total(period, time)
119
- LlmCostTracker::LlmApiCall
120
- .where(tracked_at: ActiveRecordPeriods.range_start(period, time)..time)
121
- .sum(:total_cost)
122
- .to_f
123
- end
124
-
125
- def period_totals_enabled?
126
- return @period_totals_enabled unless @period_totals_enabled.nil?
127
-
128
- @period_totals_enabled =
129
- LlmCostTracker::LlmApiCall.connection.data_source_exists?("llm_cost_tracker_period_totals")
130
- end
131
-
132
- def period_total_model
133
- require_relative "../period_total" unless defined?(LlmCostTracker::PeriodTotal)
134
-
135
- LlmCostTracker::PeriodTotal
136
- end
137
-
138
- def unique_by(model, column)
139
- return unless model.connection.supports_insert_conflict_target?
140
-
141
- column
142
- end
143
- end
144
- end
145
- end
146
- end