llm_cost_tracker 0.7.0 → 0.7.2

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 (174) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +21 -16
  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 -21
  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/lib/llm_cost_tracker/budget.rb +8 -20
  43. data/lib/llm_cost_tracker/capture/stream.rb +9 -0
  44. data/lib/llm_cost_tracker/capture/stream_collector.rb +189 -0
  45. data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +41 -73
  46. data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
  47. data/lib/llm_cost_tracker/configuration.rb +33 -36
  48. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +61 -0
  49. data/lib/llm_cost_tracker/doctor/check.rb +7 -0
  50. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +22 -59
  51. data/lib/llm_cost_tracker/doctor/price_check.rb +60 -0
  52. data/lib/llm_cost_tracker/doctor.rb +63 -71
  53. data/lib/llm_cost_tracker/errors.rb +4 -15
  54. data/lib/llm_cost_tracker/event.rb +6 -6
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +42 -0
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +7 -7
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +3 -3
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +22 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +9 -14
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +0 -4
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +12 -1
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +2 -2
  64. data/lib/llm_cost_tracker/{storage/active_record_inbox_batch.rb → ingestion/batch.rb} +21 -20
  65. data/lib/llm_cost_tracker/ingestion/inbox.rb +105 -0
  66. data/lib/llm_cost_tracker/{storage/active_record_ingestor_lease.rb → ingestion/lease_claim.rb} +5 -7
  67. data/lib/llm_cost_tracker/{storage/active_record_ingestor.rb → ingestion/worker.rb} +38 -48
  68. data/lib/llm_cost_tracker/ingestion.rb +129 -0
  69. data/lib/llm_cost_tracker/integrations/anthropic.rb +66 -31
  70. data/lib/llm_cost_tracker/integrations/base.rb +73 -34
  71. data/lib/llm_cost_tracker/integrations/openai.rb +43 -37
  72. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +40 -30
  73. data/lib/llm_cost_tracker/integrations.rb +43 -0
  74. data/lib/llm_cost_tracker/ledger/period/totals.rb +66 -0
  75. data/lib/llm_cost_tracker/{storage/active_record_periods.rb → ledger/period.rb} +2 -2
  76. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +43 -0
  77. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +46 -0
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +87 -0
  79. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +51 -0
  80. data/lib/llm_cost_tracker/ledger/schema/calls.rb +101 -0
  81. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +32 -0
  82. data/lib/llm_cost_tracker/ledger/store.rb +60 -0
  83. data/lib/llm_cost_tracker/ledger/tags/query.rb +29 -0
  84. data/lib/llm_cost_tracker/ledger/tags/sql.rb +33 -0
  85. data/lib/llm_cost_tracker/ledger.rb +13 -0
  86. data/lib/llm_cost_tracker/logging.rb +3 -6
  87. data/lib/llm_cost_tracker/middleware/faraday.rb +88 -46
  88. data/lib/llm_cost_tracker/parsers/anthropic.rb +62 -29
  89. data/lib/llm_cost_tracker/parsers/base.rb +12 -21
  90. data/lib/llm_cost_tracker/parsers/gemini.rb +50 -25
  91. data/lib/llm_cost_tracker/parsers/openai.rb +27 -5
  92. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +14 -4
  93. data/lib/llm_cost_tracker/parsers/openai_usage.rb +58 -25
  94. data/lib/llm_cost_tracker/parsers/sse.rb +4 -7
  95. data/lib/llm_cost_tracker/parsers.rb +20 -0
  96. data/lib/llm_cost_tracker/prices.json +361 -36
  97. data/lib/llm_cost_tracker/pricing/components.rb +37 -0
  98. data/lib/llm_cost_tracker/pricing/effective_prices.rb +46 -50
  99. data/lib/llm_cost_tracker/pricing/explainer.rb +25 -30
  100. data/lib/llm_cost_tracker/pricing/lookup.rb +67 -46
  101. data/lib/llm_cost_tracker/pricing/registry.rb +156 -0
  102. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +107 -0
  103. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +53 -0
  104. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +63 -0
  105. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +31 -0
  106. data/lib/llm_cost_tracker/pricing/sync.rb +159 -0
  107. data/lib/llm_cost_tracker/pricing/unknown.rb +46 -0
  108. data/lib/llm_cost_tracker/pricing.rb +33 -32
  109. data/lib/llm_cost_tracker/railtie.rb +7 -8
  110. data/lib/llm_cost_tracker/report/data.rb +72 -0
  111. data/lib/llm_cost_tracker/report/formatter.rb +69 -0
  112. data/lib/llm_cost_tracker/report.rb +8 -8
  113. data/lib/llm_cost_tracker/retention.rb +27 -10
  114. data/lib/llm_cost_tracker/tags/context.rb +35 -0
  115. data/lib/llm_cost_tracker/tags/key.rb +18 -0
  116. data/lib/llm_cost_tracker/tags/sanitizer.rb +68 -0
  117. data/lib/llm_cost_tracker/token_usage.rb +67 -0
  118. data/lib/llm_cost_tracker/tracker.rb +39 -69
  119. data/lib/llm_cost_tracker/usage_capture.rb +37 -0
  120. data/lib/llm_cost_tracker/version.rb +1 -1
  121. data/lib/llm_cost_tracker.rb +56 -78
  122. data/lib/tasks/llm_cost_tracker.rake +18 -13
  123. metadata +54 -58
  124. data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +0 -81
  125. data/app/services/llm_cost_tracker/pagination.rb +0 -57
  126. data/lib/llm_cost_tracker/active_record_adapter.rb +0 -53
  127. data/lib/llm_cost_tracker/capture_verifier.rb +0 -64
  128. data/lib/llm_cost_tracker/cost.rb +0 -12
  129. data/lib/llm_cost_tracker/doctor/capture_check.rb +0 -39
  130. data/lib/llm_cost_tracker/event_metadata.rb +0 -52
  131. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +0 -29
  132. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +0 -29
  133. data/lib/llm_cost_tracker/inbox_event.rb +0 -9
  134. data/lib/llm_cost_tracker/ingestor_lease.rb +0 -9
  135. data/lib/llm_cost_tracker/integrations/object_reader.rb +0 -56
  136. data/lib/llm_cost_tracker/integrations/registry.rb +0 -71
  137. data/lib/llm_cost_tracker/llm_api_call.rb +0 -60
  138. data/lib/llm_cost_tracker/llm_api_call_metrics.rb +0 -63
  139. data/lib/llm_cost_tracker/parameter_hash.rb +0 -33
  140. data/lib/llm_cost_tracker/parsed_usage.rb +0 -72
  141. data/lib/llm_cost_tracker/parsers/registry.rb +0 -58
  142. data/lib/llm_cost_tracker/period_grouping.rb +0 -67
  143. data/lib/llm_cost_tracker/period_total.rb +0 -9
  144. data/lib/llm_cost_tracker/price_freshness.rb +0 -38
  145. data/lib/llm_cost_tracker/price_registry.rb +0 -144
  146. data/lib/llm_cost_tracker/price_sync/fetcher.rb +0 -104
  147. data/lib/llm_cost_tracker/price_sync/registry_diff.rb +0 -51
  148. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +0 -61
  149. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +0 -29
  150. data/lib/llm_cost_tracker/price_sync.rb +0 -144
  151. data/lib/llm_cost_tracker/report_data.rb +0 -94
  152. data/lib/llm_cost_tracker/report_formatter.rb +0 -67
  153. data/lib/llm_cost_tracker/request_url.rb +0 -20
  154. data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -167
  155. data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +0 -13
  156. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +0 -160
  157. data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +0 -84
  158. data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +0 -41
  159. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +0 -42
  160. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +0 -146
  161. data/lib/llm_cost_tracker/storage/active_record_store.rb +0 -145
  162. data/lib/llm_cost_tracker/storage/writer.rb +0 -35
  163. data/lib/llm_cost_tracker/stream_capture.rb +0 -7
  164. data/lib/llm_cost_tracker/stream_collector.rb +0 -199
  165. data/lib/llm_cost_tracker/tag_accessors.rb +0 -15
  166. data/lib/llm_cost_tracker/tag_context.rb +0 -52
  167. data/lib/llm_cost_tracker/tag_key.rb +0 -16
  168. data/lib/llm_cost_tracker/tag_query.rb +0 -43
  169. data/lib/llm_cost_tracker/tag_sanitizer.rb +0 -81
  170. data/lib/llm_cost_tracker/tag_sql.rb +0 -34
  171. data/lib/llm_cost_tracker/tags_column.rb +0 -105
  172. data/lib/llm_cost_tracker/unknown_pricing.rb +0 -54
  173. data/lib/llm_cost_tracker/usage_breakdown.rb +0 -30
  174. 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,167 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "securerandom"
4
-
5
- require_relative "active_record_inbox"
6
- require_relative "active_record_ingestor"
7
- require_relative "active_record_store"
8
-
9
- module LlmCostTracker
10
- module Storage
11
- VerificationResult = Data.define(:status, :name, :message)
12
-
13
- class ActiveRecordBackend
14
- VERIFY_TAG = "llm_cost_tracker_verify"
15
-
16
- class << self
17
- def save(event)
18
- require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
19
-
20
- if ActiveRecordInbox.enabled?
21
- ActiveRecordInbox.save(event)
22
- else
23
- ActiveRecordStore.save(event)
24
- end
25
- event
26
- rescue LoadError => e
27
- raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
28
- end
29
-
30
- def verify
31
- require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
32
-
33
- unless LlmCostTracker::LlmApiCall.table_exists?
34
- return [
35
- VerificationResult.new(
36
- :error,
37
- "active_record",
38
- "llm_api_calls table is missing; run install generator and migrate"
39
- )
40
- ]
41
- end
42
-
43
- [active_record_capture_check]
44
- rescue LoadError => e
45
- [VerificationResult.new(:error, "active_record", "unavailable: #{e.message}")]
46
- rescue StandardError => e
47
- [VerificationResult.new(:error, "active_record", "#{e.class}: #{e.message}")]
48
- end
49
-
50
- def prune(cutoff:, batch_size:)
51
- require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
52
-
53
- ActiveRecordStore.prune(cutoff: cutoff, batch_size: batch_size)
54
- end
55
-
56
- private
57
-
58
- def active_record_capture_check
59
- return active_record_inbox_capture_check if ActiveRecordInbox.enabled?
60
-
61
- provider, model = sample_priced_identity
62
- response_id = "lct_verify_#{SecureRandom.hex(8)}"
63
- notifications = []
64
- persisted = false
65
- subscription = subscribe_to_verification(response_id, notifications)
66
-
67
- LlmCostTracker::LlmApiCall.transaction do
68
- LlmCostTracker.track(
69
- provider: provider,
70
- model: model,
71
- input_tokens: 1,
72
- output_tokens: 1,
73
- provider_response_id: response_id,
74
- feature: VERIFY_TAG
75
- )
76
- persisted = LlmCostTracker::LlmApiCall.where(provider_response_id: response_id).exists?
77
- raise ActiveRecord::Rollback
78
- end
79
-
80
- return active_record_capture_success if persisted && notifications.any?
81
-
82
- VerificationResult.new(:error, "active_record capture", capture_failure_message(persisted, notifications))
83
- rescue LlmCostTracker::BudgetExceededError => e
84
- VerificationResult.new(:error, "active_record capture", "blocked by budget guardrail: #{e.message}")
85
- rescue LlmCostTracker::Error => e
86
- VerificationResult.new(:error, "active_record capture", e.message)
87
- rescue StandardError => e
88
- VerificationResult.new(:error, "active_record capture", "#{e.class}: #{e.message}")
89
- ensure
90
- ActiveSupport::Notifications.unsubscribe(subscription) if subscription
91
- end
92
-
93
- def active_record_inbox_capture_check
94
- provider, model = sample_priced_identity
95
- response_id = "lct_verify_#{SecureRandom.hex(8)}"
96
- notifications = []
97
- subscription = subscribe_to_verification(response_id, notifications)
98
-
99
- event = LlmCostTracker.track(
100
- provider: provider,
101
- model: model,
102
- input_tokens: 1,
103
- output_tokens: 1,
104
- provider_response_id: response_id,
105
- feature: VERIFY_TAG
106
- )
107
- LlmCostTracker.flush!
108
- persisted = LlmCostTracker::LlmApiCall.where(provider_response_id: response_id).exists?
109
-
110
- if persisted && notifications.any?
111
- return active_record_capture_success("manual event emitted and persisted through durable inbox")
112
- end
113
-
114
- VerificationResult.new(:error, "active_record capture", capture_failure_message(persisted, notifications))
115
- rescue LlmCostTracker::BudgetExceededError => e
116
- VerificationResult.new(:error, "active_record capture", "blocked by budget guardrail: #{e.message}")
117
- rescue LlmCostTracker::Error => e
118
- VerificationResult.new(:error, "active_record capture", e.message)
119
- rescue StandardError => e
120
- VerificationResult.new(:error, "active_record capture", "#{e.class}: #{e.message}")
121
- ensure
122
- cleanup_verification_call(response_id) if response_id
123
- LlmCostTracker::InboxEvent.where(event_id: event.event_id).delete_all if event
124
- ActiveSupport::Notifications.unsubscribe(subscription) if subscription
125
- end
126
-
127
- def subscribe_to_verification(response_id, notifications)
128
- ActiveSupport::Notifications.subscribe(LlmCostTracker::Tracker::EVENT_NAME) do |*, payload|
129
- notifications << payload if payload[:provider_response_id] == response_id
130
- end
131
- end
132
-
133
- def active_record_capture_success(message = "manual event emitted and persisted inside rollback")
134
- VerificationResult.new(
135
- :ok,
136
- "active_record capture",
137
- message
138
- )
139
- end
140
-
141
- def capture_failure_message(persisted, notifications)
142
- missing = []
143
- missing << "notification" if notifications.empty?
144
- missing << "persisted row" unless persisted
145
- "missing #{missing.join(' and ')} for synthetic manual event"
146
- end
147
-
148
- def cleanup_verification_call(response_id)
149
- relation = LlmCostTracker::LlmApiCall.where(provider_response_id: response_id)
150
- rows = relation.pluck(:id, :tracked_at, :total_cost)
151
- return if rows.empty?
152
-
153
- relation.delete_all
154
- ActiveRecordRollups.decrement!(rows)
155
- end
156
-
157
- def sample_priced_identity
158
- key = LlmCostTracker::PriceRegistry.builtin_prices.find do |model_id, prices|
159
- model_id.include?("/") && prices[:input] && prices[:output]
160
- end&.first
161
- provider, model = key.to_s.split("/", 2)
162
- [provider || "openai", model || "gpt-4o-mini"]
163
- end
164
- end
165
- end
166
- end
167
- 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,160 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "time"
5
-
6
- require_relative "../cost"
7
- require_relative "../event"
8
- require_relative "../inbox_event"
9
- require_relative "active_record_periods"
10
-
11
- module LlmCostTracker
12
- module Storage
13
- class ActiveRecordInbox
14
- TABLE_NAME = "llm_cost_tracker_inbox_events"
15
- LEASE_TABLE_NAME = "llm_cost_tracker_ingestor_leases"
16
- MAX_ATTEMPTS = 5
17
-
18
- class << self
19
- def reset!
20
- remove_instance_variable(:@enabled) if instance_variable_defined?(:@enabled)
21
- end
22
-
23
- def enabled?
24
- return @enabled unless @enabled.nil?
25
-
26
- model = LlmCostTracker::LlmApiCall
27
- @enabled = model.columns_hash.key?("event_id") &&
28
- model.connection.data_source_exists?(TABLE_NAME) &&
29
- model.connection.data_source_exists?(LEASE_TABLE_NAME)
30
- rescue StandardError
31
- @enabled = false
32
- end
33
-
34
- def save(event)
35
- insert_row(row_for(event))
36
- ActiveRecordIngestor.ensure_started
37
- event
38
- end
39
-
40
- def pending_period_totals(periods, time:)
41
- return periods.to_h { |period| [period, 0.0] } unless enabled?
42
-
43
- periods.to_h do |period|
44
- [period, pending_period_total(period, time)]
45
- end
46
- end
47
-
48
- def event_from_row(row)
49
- payload = JSON.parse(row.payload)
50
- cost = payload["cost"] && LlmCostTracker::Cost.new(**symbolize_keys(payload["cost"]))
51
-
52
- LlmCostTracker::Event.new(
53
- event_id: payload.fetch("event_id"),
54
- provider: payload.fetch("provider"),
55
- model: payload.fetch("model"),
56
- input_tokens: payload.fetch("input_tokens"),
57
- output_tokens: payload.fetch("output_tokens"),
58
- total_tokens: payload.fetch("total_tokens"),
59
- cache_read_input_tokens: payload.fetch("cache_read_input_tokens"),
60
- cache_write_input_tokens: payload.fetch("cache_write_input_tokens"),
61
- hidden_output_tokens: payload.fetch("hidden_output_tokens"),
62
- pricing_mode: payload["pricing_mode"],
63
- cost: cost,
64
- tags: payload.fetch("tags"),
65
- latency_ms: payload["latency_ms"],
66
- stream: payload.fetch("stream"),
67
- usage_source: payload["usage_source"],
68
- provider_response_id: payload["provider_response_id"],
69
- tracked_at: Time.iso8601(payload.fetch("tracked_at"))
70
- )
71
- end
72
-
73
- private
74
-
75
- def row_for(event)
76
- now = Time.now.utc
77
- {
78
- event_id: event.event_id,
79
- total_cost: event.cost&.total_cost,
80
- tracked_at: event.tracked_at,
81
- payload: JSON.generate(payload_for(event)),
82
- attempts: 0,
83
- created_at: now,
84
- updated_at: now
85
- }
86
- end
87
-
88
- def payload_for(event)
89
- {
90
- event_id: event.event_id,
91
- provider: event.provider,
92
- model: event.model,
93
- input_tokens: event.input_tokens,
94
- output_tokens: event.output_tokens,
95
- total_tokens: event.total_tokens,
96
- cache_read_input_tokens: event.cache_read_input_tokens,
97
- cache_write_input_tokens: event.cache_write_input_tokens,
98
- hidden_output_tokens: event.hidden_output_tokens,
99
- pricing_mode: event.pricing_mode,
100
- cost: event.cost&.to_h,
101
- tags: event.tags || {},
102
- latency_ms: event.latency_ms,
103
- stream: event.stream,
104
- usage_source: event.usage_source,
105
- provider_response_id: event.provider_response_id,
106
- tracked_at: event.tracked_at.iso8601(6)
107
- }
108
- end
109
-
110
- def insert_row(row)
111
- connection = LlmCostTracker::LlmApiCall.connection
112
- if connection.transaction_open?
113
- insert_with_separate_connection(row)
114
- else
115
- execute_insert(connection, row)
116
- end
117
- rescue ActiveRecord::ConnectionTimeoutError => e
118
- raise LlmCostTracker::Error,
119
- "ActiveRecord inbox could not checkout a separate database connection: #{e.message}"
120
- end
121
-
122
- def insert_with_separate_connection(row)
123
- pool = LlmCostTracker::LlmApiCall.connection_pool
124
- connection = pool.checkout
125
- begin
126
- connection.transaction(requires_new: true) { execute_insert(connection, row) }
127
- ensure
128
- pool.checkin(connection)
129
- end
130
- end
131
-
132
- def execute_insert(connection, row)
133
- columns = row.keys
134
- quoted_columns = columns.map { |column| connection.quote_column_name(column) }.join(", ")
135
- quoted_values = columns.map { |column| connection.quote(row.fetch(column)) }.join(", ")
136
- table = connection.quote_table_name(TABLE_NAME)
137
-
138
- connection.execute("INSERT INTO #{table} (#{quoted_columns}) VALUES (#{quoted_values})")
139
- end
140
-
141
- def pending_period_total(period, time)
142
- LlmCostTracker::InboxEvent
143
- .where("attempts < ?", MAX_ATTEMPTS)
144
- .where(tracked_at: period_range(period, time))
145
- .sum(:total_cost)
146
- .to_f
147
- end
148
-
149
- def period_range(period, time)
150
- utc_time = time.to_time.utc
151
- ActiveRecordPeriods.range_start(period, utc_time)..utc_time
152
- end
153
-
154
- def symbolize_keys(hash)
155
- hash.transform_keys(&:to_sym)
156
- end
157
- end
158
- end
159
- end
160
- 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
- ActiveRecordAdapter.ensure_supported!(connection)
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