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
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../period"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Period
8
+ class Totals
9
+ def self.call(periods, time:)
10
+ new(periods, time: time).totals
11
+ end
12
+
13
+ def initialize(periods, time:)
14
+ @periods = Period.valid_keys(periods)
15
+ @time = time
16
+ end
17
+
18
+ def totals
19
+ return {} if periods.empty?
20
+
21
+ snapshot_totals
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :periods, :time
27
+
28
+ def snapshot_totals
29
+ values = periods.to_h { |period| [period, 0.0] }
30
+ 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
33
+ end
34
+ values
35
+ end
36
+
37
+ def snapshot_select(period)
38
+ 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
+ end
42
+
43
+ def rollup_total_sql(period)
44
+ table = connection.quote_table_name("llm_cost_tracker_period_totals")
45
+ "COALESCE((SELECT total_cost FROM #{table} " \
46
+ "WHERE period = #{connection.quote(Period::PERIODS.fetch(period))} " \
47
+ "AND period_start = #{connection.quote(Period.bucket(period, time))} LIMIT 1), 0)"
48
+ end
49
+
50
+ def pending_total_sql(start)
51
+ table = connection.quote_table_name(Ingestion::Event.table_name)
52
+ total_cost = connection.quote_column_name("total_cost")
53
+ tracked_at = connection.quote_column_name("tracked_at")
54
+ attempts = connection.quote_column_name("attempts")
55
+ "COALESCE((SELECT SUM(#{total_cost}) FROM #{table} " \
56
+ "WHERE #{attempts} < #{Ingestion::Event::MAX_ATTEMPTS} " \
57
+ "AND #{tracked_at} BETWEEN #{connection.quote(start)} AND #{connection.quote(time)}), 0)"
58
+ end
59
+
60
+ def connection
61
+ LlmCostTracker::Ledger::Call.connection
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- module Storage
5
- module ActiveRecordPeriods
4
+ module Ledger
5
+ module Period
6
6
  PERIODS = {
7
7
  monthly: "month",
8
8
  daily: "day"
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ require_relative "../period"
6
+
7
+ module LlmCostTracker
8
+ module Ledger
9
+ class Rollups
10
+ class Batch
11
+ def self.rows(events)
12
+ new(events).rows
13
+ end
14
+
15
+ def initialize(events)
16
+ @events = events
17
+ end
18
+
19
+ def rows
20
+ totals.map do |(period, period_start), total_cost|
21
+ {
22
+ period: period,
23
+ period_start: period_start,
24
+ total_cost: total_cost
25
+ }
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :events
32
+
33
+ def totals
34
+ events.each_with_object(Hash.new { |hash, key| hash[key] = BigDecimal("0") }) do |event, rows|
35
+ Period::PERIODS.each do |period, name|
36
+ rows[[name, Period.bucket(period, event.tracked_at)]] += BigDecimal(event.total_cost.to_s)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../schema/adapter"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ class Rollups
8
+ class UpsertSql
9
+ def self.call(model)
10
+ new(model).call
11
+ end
12
+
13
+ def initialize(model)
14
+ @model = model
15
+ end
16
+
17
+ def call
18
+ return Arel.sql(mysql_sql) if Ledger::Schema::Adapter.mysql?(connection)
19
+ return Arel.sql(postgres_sql) if Ledger::Schema::Adapter.postgresql?(connection)
20
+
21
+ Ledger::Schema::Adapter.ensure_supported!(connection)
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :model
27
+
28
+ def postgres_sql
29
+ total_cost = connection.quote_column_name("total_cost")
30
+ updated_at = connection.quote_column_name("updated_at")
31
+
32
+ "#{total_cost} = #{model.quoted_table_name}.#{total_cost} + excluded.#{total_cost}, " \
33
+ "#{updated_at} = excluded.#{updated_at}"
34
+ end
35
+
36
+ def mysql_sql
37
+ "total_cost = total_cost + VALUES(total_cost), updated_at = VALUES(updated_at)"
38
+ end
39
+
40
+ def connection
41
+ model.connection
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ require_relative "period"
6
+ require_relative "rollups/batch"
7
+ require_relative "rollups/upsert_sql"
8
+
9
+ module LlmCostTracker
10
+ module Ledger
11
+ class Rollups
12
+ class << self
13
+ def increment!(event)
14
+ return unless event.total_cost
15
+
16
+ Period::Total.upsert_all(
17
+ period_rows(event),
18
+ on_duplicate: Ledger::Rollups::UpsertSql.call(Period::Total),
19
+ record_timestamps: true,
20
+ unique_by: unique_by(Period::Total, %i[period period_start])
21
+ )
22
+ end
23
+
24
+ def increment_many!(events)
25
+ events = Array(events).select(&:total_cost)
26
+ return if events.empty?
27
+
28
+ Period::Total.upsert_all(
29
+ Ledger::Rollups::Batch.rows(events),
30
+ on_duplicate: Ledger::Rollups::UpsertSql.call(Period::Total),
31
+ record_timestamps: true,
32
+ unique_by: unique_by(Period::Total, %i[period period_start])
33
+ )
34
+ end
35
+
36
+ def decrement!(call_rows)
37
+ totals = period_decrement_totals(call_rows)
38
+ return if totals.empty?
39
+
40
+ apply_decrements(totals)
41
+ end
42
+
43
+ private
44
+
45
+ def period_rows(event)
46
+ Period::PERIODS.map do |period, name|
47
+ {
48
+ period: name,
49
+ period_start: Period.bucket(period, event.tracked_at),
50
+ total_cost: event.total_cost
51
+ }
52
+ end
53
+ end
54
+
55
+ def period_decrement_totals(call_rows)
56
+ call_rows.each_with_object(Hash.new { |totals, key| totals[key] = BigDecimal("0") }) do |row, totals|
57
+ _id, tracked_at, total_cost = row
58
+ next unless total_cost
59
+
60
+ Period::PERIODS.each_key do |period|
61
+ totals[[period, Period.bucket(period, tracked_at)]] += BigDecimal(total_cost.to_s)
62
+ end
63
+ end
64
+ end
65
+
66
+ def apply_decrements(totals)
67
+ now = Time.now.utc
68
+
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)
76
+ end
77
+ end
78
+
79
+ def unique_by(model, column)
80
+ return unless model.connection.supports_insert_conflict_target?
81
+
82
+ column
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../errors"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Schema
8
+ module Adapter
9
+ MYSQL_ADAPTERS = %w[
10
+ ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter
11
+ ActiveRecord::ConnectionAdapters::Mysql2Adapter
12
+ ActiveRecord::ConnectionAdapters::TrilogyAdapter
13
+ ].freeze
14
+ POSTGRESQL_ADAPTERS = %w[
15
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
16
+ ].freeze
17
+ MYSQL_PATTERN = /mysql|trilogy|mariadb/i
18
+ POSTGRESQL_PATTERN = /postgres/i
19
+
20
+ class << self
21
+ def mysql?(value)
22
+ adapter_instance?(value, MYSQL_ADAPTERS) || adapter_name(value).match?(MYSQL_PATTERN)
23
+ end
24
+
25
+ def postgresql?(value)
26
+ adapter_instance?(value, POSTGRESQL_ADAPTERS) || adapter_name(value).match?(POSTGRESQL_PATTERN)
27
+ end
28
+
29
+ def ensure_supported!(value)
30
+ return if mysql?(value) || postgresql?(value)
31
+
32
+ raise Error, "Unsupported database adapter: #{adapter_name(value)}. Use PostgreSQL or MySQL."
33
+ end
34
+
35
+ private
36
+
37
+ def adapter_instance?(value, class_names)
38
+ class_names.any? do |class_name|
39
+ adapter_class = class_name.safe_constantize
40
+ adapter_class && value.is_a?(adapter_class)
41
+ end
42
+ end
43
+
44
+ def adapter_name(value)
45
+ value.try(:adapter_name).presence || value.to_s
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "llm_cost_tracker/ledger/schema/adapter"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Schema
8
+ module Calls
9
+ CURRENT_SCHEMA_COLUMNS = %w[
10
+ event_id
11
+ provider
12
+ model
13
+ input_tokens
14
+ output_tokens
15
+ total_tokens
16
+ cache_read_input_tokens
17
+ cache_write_input_tokens
18
+ cache_write_1h_input_tokens
19
+ hidden_output_tokens
20
+ input_cost
21
+ output_cost
22
+ total_cost
23
+ cache_read_input_cost
24
+ cache_write_input_cost
25
+ cache_write_1h_input_cost
26
+ latency_ms
27
+ stream
28
+ usage_source
29
+ provider_response_id
30
+ pricing_mode
31
+ tags
32
+ tracked_at
33
+ ].freeze
34
+
35
+ class << self
36
+ def current_schema?
37
+ current_schema_errors.empty?
38
+ end
39
+
40
+ def current_schema_errors
41
+ schema_capabilities.fetch(:current_schema_errors)
42
+ end
43
+
44
+ def missing_current_schema_columns
45
+ schema_capabilities.fetch(:missing_current_schema_columns)
46
+ end
47
+
48
+ private
49
+
50
+ def schema_capabilities
51
+ columns = Ledger::Call.columns_hash
52
+ adapter_name = Ledger::Call.connection.adapter_name
53
+ cache = @schema_capabilities
54
+
55
+ return cache.fetch(:values) if cache && cache.fetch(:columns).equal?(columns) &&
56
+ cache.fetch(:adapter_name) == adapter_name
57
+
58
+ values = build_schema_capabilities(columns, adapter_name)
59
+ @schema_capabilities = { columns: columns, adapter_name: adapter_name, values: values }
60
+ values
61
+ end
62
+
63
+ def build_schema_capabilities(columns, adapter_name)
64
+ Ledger::Schema::Adapter.ensure_supported!(adapter_name)
65
+
66
+ {
67
+ missing_current_schema_columns: missing_columns_for(columns),
68
+ current_schema_errors: schema_errors_for(columns, adapter_name)
69
+ }
70
+ end
71
+
72
+ def schema_errors_for(columns, adapter_name)
73
+ errors = []
74
+ missing = missing_columns_for(columns)
75
+ errors << "missing columns: #{missing.join(', ')}" if missing.any?
76
+
77
+ tag_column = columns["tags"]
78
+ if tag_column
79
+ postgresql = Ledger::Schema::Adapter.postgresql?(adapter_name)
80
+ expected_type = postgresql ? "jsonb" : "json"
81
+ valid_type =
82
+ if postgresql
83
+ tag_column.type == :jsonb || tag_column.sql_type.to_s.downcase == "jsonb"
84
+ else
85
+ tag_column.type == :json
86
+ end
87
+
88
+ errors << "tags column must use #{expected_type}" unless valid_type
89
+ end
90
+
91
+ errors
92
+ end
93
+
94
+ def missing_columns_for(columns)
95
+ CURRENT_SCHEMA_COLUMNS - columns.keys
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Ledger
5
+ module Schema
6
+ module PeriodTotals
7
+ REQUIRED_COLUMNS = %w[period period_start total_cost].freeze
8
+ UNIQUE_COLUMNS = %i[period period_start].freeze
9
+
10
+ class << self
11
+ def current_schema_errors
12
+ connection = Ledger::Call.connection
13
+ table_name = Ledger::Period::Total.table_name
14
+ return ["llm_cost_tracker_period_totals table is missing"] unless connection.data_source_exists?(table_name)
15
+
16
+ errors = []
17
+ missing = REQUIRED_COLUMNS - Ledger::Period::Total.columns_hash.keys
18
+ errors << "missing columns: #{missing.join(', ')}" if missing.any?
19
+ errors << "missing unique index: period, period_start" unless unique_period_index?(connection, table_name)
20
+ errors
21
+ end
22
+
23
+ private
24
+
25
+ def unique_period_index?(connection, table_name)
26
+ connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../pricing"
4
+ require_relative "rollups"
5
+
6
+ module LlmCostTracker
7
+ module Ledger
8
+ class Store
9
+ class << self
10
+ def insert_many(events)
11
+ events = Array(events)
12
+ return [] if events.empty?
13
+
14
+ model = LlmCostTracker::Ledger::Call
15
+ insertable = new_events(model, events)
16
+
17
+ if insertable.any?
18
+ rows = insertable.map { |event| attributes_for(event) }
19
+ model.insert_all!(rows, record_timestamps: true, returning: false)
20
+ Ledger::Rollups.increment_many!(insertable)
21
+ end
22
+ events
23
+ end
24
+
25
+ private
26
+
27
+ def attributes_for(event)
28
+ tags = (event.tags || {}).transform_keys(&:to_s).transform_values { |value| stringify_tag_value(value) }
29
+ usage = event.token_usage.stored_attributes
30
+
31
+ attributes = {
32
+ event_id: event.event_id,
33
+ provider: event.provider,
34
+ model: event.model,
35
+ tags: tags,
36
+ tracked_at: event.tracked_at,
37
+ pricing_mode: event.pricing_mode,
38
+ latency_ms: event.latency_ms,
39
+ stream: event.stream,
40
+ usage_source: event.usage_source,
41
+ provider_response_id: event.provider_response_id
42
+ }
43
+
44
+ attributes.merge(usage).merge(Pricing.stored_cost_attributes(event.cost || {}))
45
+ end
46
+
47
+ def new_events(model, events)
48
+ existing_ids = model.where(event_id: events.map(&:event_id)).pluck(:event_id).to_set
49
+ events.reject { |event| existing_ids.include?(event.event_id) }
50
+ end
51
+
52
+ def stringify_tag_value(value)
53
+ return value.transform_values { |nested| stringify_tag_value(nested) } if value.is_a?(Hash)
54
+
55
+ value.to_s
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "../schema/adapter"
6
+
7
+ module LlmCostTracker
8
+ module Ledger
9
+ module Tags
10
+ module Query
11
+ class << self
12
+ def apply(model, tags)
13
+ normalized_tags = (tags || {}).to_h.transform_keys(&:to_s).transform_values(&:to_s)
14
+ return model.all if normalized_tags.empty?
15
+
16
+ connection = model.connection
17
+ json = normalized_tags.to_json
18
+
19
+ if Schema::Adapter.postgresql?(connection)
20
+ model.where("tags @> ?::jsonb", json)
21
+ else
22
+ model.where("JSON_CONTAINS(tags, ?)", json)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../schema/adapter"
4
+ require_relative "../../tags/key"
5
+
6
+ module LlmCostTracker
7
+ module Ledger
8
+ module Tags
9
+ module Sql
10
+ class << self
11
+ def value_expression(model, key, table_name:)
12
+ key = LlmCostTracker::Tags::Key.validate!(key)
13
+ column = "#{table_name}.#{model.connection.quote_column_name('tags')}"
14
+
15
+ if Ledger::Schema::Adapter.postgresql?(model.connection)
16
+ "#{column}->>#{model.connection.quote(key)}"
17
+ elsif Ledger::Schema::Adapter.mysql?(model.connection)
18
+ "JSON_UNQUOTE(JSON_EXTRACT(#{column}, #{model.connection.quote(json_path(key))}))"
19
+ else
20
+ Ledger::Schema::Adapter.ensure_supported!(model.connection)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def json_path(key)
27
+ "$.\"#{key}\""
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ledger/schema/adapter"
4
+ require_relative "ledger/schema/calls"
5
+ require_relative "ledger/schema/period_totals"
6
+ require_relative "ledger/tags/query"
7
+ require_relative "ledger/tags/sql"
8
+ require_relative "ledger/period"
9
+ require_relative "ledger/rollups/batch"
10
+ require_relative "ledger/rollups/upsert_sql"
11
+ require_relative "ledger/rollups"
12
+ require_relative "ledger/store"
13
+ require_relative "ledger/period/totals"
@@ -19,9 +19,10 @@ module LlmCostTracker
19
19
 
20
20
  def log(level, message)
21
21
  message = prefixed(message)
22
+ logger = Rails.logger
22
23
 
23
- if rails_logger
24
- rails_logger.public_send(level, message)
24
+ if logger
25
+ logger.try(level, message)
25
26
  else
26
27
  Kernel.warn(message)
27
28
  end
@@ -35,10 +36,6 @@ module LlmCostTracker
35
36
 
36
37
  "#{PREFIX} #{message}"
37
38
  end
38
-
39
- def rails_logger
40
- Rails.logger if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
41
- end
42
39
  end
43
40
  end
44
41
  end