llm_cost_tracker 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +72 -1
  4. data/README.md +58 -221
  5. data/app/assets/llm_cost_tracker/application.css +218 -41
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +30 -17
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +19 -14
  9. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +10 -2
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +11 -24
  11. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
  13. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +20 -7
  15. data/app/models/llm_cost_tracker/call.rb +169 -0
  16. data/app/models/llm_cost_tracker/call_line_item.rb +22 -0
  17. data/app/models/llm_cost_tracker/call_rollup.rb +9 -0
  18. data/app/models/llm_cost_tracker/call_tag.rb +16 -0
  19. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +13 -0
  20. data/app/models/llm_cost_tracker/ingestion/lease.rb +1 -1
  21. data/app/models/llm_cost_tracker/provider_invoice.rb +9 -0
  22. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +125 -34
  23. data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -2
  25. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
  27. data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
  28. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
  29. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
  30. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
  31. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
  32. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  33. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
  34. data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
  35. data/app/views/llm_cost_tracker/calls/show.html.erb +62 -7
  36. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -50
  37. data/app/views/llm_cost_tracker/data_quality/index.html.erb +103 -126
  38. data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
  39. data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
  40. data/app/views/llm_cost_tracker/shared/_filters.html.erb +63 -0
  41. data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
  42. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
  43. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
  44. data/app/views/llm_cost_tracker/tags/show.html.erb +5 -37
  45. data/lib/llm_cost_tracker/billing/components.rb +53 -0
  46. data/lib/llm_cost_tracker/billing/components.yml +117 -0
  47. data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
  48. data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
  49. data/lib/llm_cost_tracker/budget.rb +23 -35
  50. data/lib/llm_cost_tracker/capture/stream_collector.rb +47 -33
  51. data/lib/llm_cost_tracker/configuration.rb +36 -19
  52. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +54 -0
  53. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +24 -32
  54. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
  55. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
  56. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
  57. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
  58. data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
  59. data/lib/llm_cost_tracker/doctor/schema_check.rb +31 -0
  60. data/lib/llm_cost_tracker/doctor.rb +43 -45
  61. data/lib/llm_cost_tracker/errors.rb +5 -19
  62. data/lib/llm_cost_tracker/event.rb +10 -2
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -2
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +157 -0
  66. data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
  67. data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -23
  68. data/lib/llm_cost_tracker/ingestion/worker.rb +14 -5
  69. data/lib/llm_cost_tracker/ingestion.rb +28 -22
  70. data/lib/llm_cost_tracker/integrations/anthropic.rb +45 -38
  71. data/lib/llm_cost_tracker/integrations/base.rb +36 -29
  72. data/lib/llm_cost_tracker/integrations/openai.rb +85 -40
  73. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +5 -5
  74. data/lib/llm_cost_tracker/integrations.rb +2 -2
  75. data/lib/llm_cost_tracker/ledger/period/totals.rb +12 -9
  76. data/lib/llm_cost_tracker/ledger/period.rb +5 -5
  77. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +4 -10
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +76 -25
  79. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +50 -0
  81. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
  82. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +26 -0
  83. data/lib/llm_cost_tracker/ledger/schema/calls.rb +34 -23
  84. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
  85. data/lib/llm_cost_tracker/ledger/store.rb +110 -18
  86. data/lib/llm_cost_tracker/ledger/tags/query.rb +5 -11
  87. data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -14
  88. data/lib/llm_cost_tracker/ledger.rb +4 -2
  89. data/lib/llm_cost_tracker/logging.rb +2 -5
  90. data/lib/llm_cost_tracker/middleware/faraday.rb +7 -6
  91. data/lib/llm_cost_tracker/parsers/anthropic.rb +52 -7
  92. data/lib/llm_cost_tracker/parsers/base.rb +8 -3
  93. data/lib/llm_cost_tracker/parsers/gemini.rb +101 -15
  94. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +10 -2
  95. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +87 -0
  96. data/lib/llm_cost_tracker/parsers/openai_usage.rb +48 -21
  97. data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
  98. data/lib/llm_cost_tracker/parsers.rb +1 -1
  99. data/lib/llm_cost_tracker/prices.json +105 -20
  100. data/lib/llm_cost_tracker/pricing/effective_prices.rb +57 -19
  101. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
  102. data/lib/llm_cost_tracker/pricing/lookup.rb +38 -34
  103. data/lib/llm_cost_tracker/pricing/registry.rb +65 -45
  104. data/lib/llm_cost_tracker/pricing/service_charges.rb +204 -0
  105. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
  106. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
  107. data/lib/llm_cost_tracker/pricing/sync.rb +57 -10
  108. data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
  109. data/lib/llm_cost_tracker/pricing.rb +190 -26
  110. data/lib/llm_cost_tracker/railtie.rb +0 -8
  111. data/lib/llm_cost_tracker/report/data.rb +16 -8
  112. data/lib/llm_cost_tracker/report.rb +0 -4
  113. data/lib/llm_cost_tracker/retention.rb +8 -8
  114. data/lib/llm_cost_tracker/tags/context.rb +2 -4
  115. data/lib/llm_cost_tracker/tags/key.rb +4 -0
  116. data/lib/llm_cost_tracker/tags/sanitizer.rb +12 -17
  117. data/lib/llm_cost_tracker/timing.rb +15 -0
  118. data/lib/llm_cost_tracker/token_usage.rb +56 -42
  119. data/lib/llm_cost_tracker/tracker.rb +67 -24
  120. data/lib/llm_cost_tracker/usage_capture.rb +29 -8
  121. data/lib/llm_cost_tracker/version.rb +1 -1
  122. data/lib/llm_cost_tracker.rb +36 -35
  123. data/lib/tasks/llm_cost_tracker.rake +22 -17
  124. metadata +36 -41
  125. data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
  126. data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
  127. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
  128. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
  129. data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
  130. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
  131. data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
  132. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
  133. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
  134. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
  135. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +0 -29
  136. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +0 -29
  137. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
  138. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
  139. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
  140. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
  141. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
  142. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
  143. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
  144. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
  145. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
  146. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
  147. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
  148. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
  149. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
  150. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
  151. data/lib/llm_cost_tracker/pricing/components.rb +0 -37
  152. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
@@ -3,34 +3,25 @@
3
3
  require "bigdecimal"
4
4
 
5
5
  require_relative "period"
6
- require_relative "rollups/batch"
7
6
  require_relative "rollups/upsert_sql"
8
7
 
9
8
  module LlmCostTracker
10
9
  module Ledger
11
10
  class Rollups
11
+ DEFAULT_CURRENCY = "USD"
12
+
12
13
  class << self
13
14
  def increment!(event)
14
15
  return unless event.total_cost
15
16
 
16
- Period::Total.upsert_all(
17
- period_rows(event),
18
- on_duplicate: Ledger::Rollups::UpsertSql.call(Period::Total),
19
- record_timestamps: true,
20
- unique_by: unique_by(Period::Total, %i[period period_start])
21
- )
17
+ upsert_call_rollups(period_rows(event))
22
18
  end
23
19
 
24
20
  def increment_many!(events)
25
21
  events = Array(events).select(&:total_cost)
26
22
  return if events.empty?
27
23
 
28
- Period::Total.upsert_all(
29
- Ledger::Rollups::Batch.rows(events),
30
- on_duplicate: Ledger::Rollups::UpsertSql.call(Period::Total),
31
- record_timestamps: true,
32
- unique_by: unique_by(Period::Total, %i[period period_start])
33
- )
24
+ upsert_call_rollups(period_rows_for_events(events))
34
25
  end
35
26
 
36
27
  def decrement!(call_rows)
@@ -43,43 +34,103 @@ module LlmCostTracker
43
34
  private
44
35
 
45
36
  def period_rows(event)
37
+ currency = currency_for(event)
46
38
  Period::PERIODS.map do |period, name|
47
39
  {
48
40
  period: name,
49
41
  period_start: Period.bucket(period, event.tracked_at),
42
+ currency: currency,
50
43
  total_cost: event.total_cost
51
44
  }
52
45
  end
53
46
  end
54
47
 
48
+ def period_rows_for_events(events)
49
+ call_rollups(events).map do |(period, period_start, currency), total_cost|
50
+ {
51
+ period: period,
52
+ period_start: period_start,
53
+ currency: currency,
54
+ total_cost: total_cost
55
+ }
56
+ end
57
+ end
58
+
59
+ def call_rollups(events)
60
+ events.each_with_object(Hash.new { |totals, key| totals[key] = BigDecimal("0") }) do |event, totals|
61
+ currency = currency_for(event)
62
+ Period::PERIODS.each do |period, name|
63
+ totals[[name, Period.bucket(period, event.tracked_at), currency]] += BigDecimal(event.total_cost.to_s)
64
+ end
65
+ end
66
+ end
67
+
55
68
  def period_decrement_totals(call_rows)
56
69
  call_rows.each_with_object(Hash.new { |totals, key| totals[key] = BigDecimal("0") }) do |row, totals|
57
- _id, tracked_at, total_cost = row
70
+ _id, tracked_at, total_cost, pricing_snapshot = row
58
71
  next unless total_cost
59
72
 
73
+ currency = currency_from_snapshot(pricing_snapshot)
60
74
  Period::PERIODS.each_key do |period|
61
- totals[[period, Period.bucket(period, tracked_at)]] += BigDecimal(total_cost.to_s)
75
+ totals[[period, Period.bucket(period, tracked_at), currency]] += total_cost
62
76
  end
63
77
  end
64
78
  end
65
79
 
66
80
  def apply_decrements(totals)
67
81
  now = Time.now.utc
82
+ buckets_by_period = totals.each_with_object({}) do |((period, period_start, currency), amount), grouped|
83
+ grouped[[period, currency]] ||= {}
84
+ grouped[[period, currency]][period_start] = amount
85
+ end
68
86
 
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)
87
+ conn = LlmCostTracker::CallRollup.connection
88
+ table = LlmCostTracker::CallRollup.quoted_table_name
89
+ period_col = conn.quote_column_name("period")
90
+ start_col = conn.quote_column_name("period_start")
91
+ currency_col = conn.quote_column_name("currency")
92
+ total_col = conn.quote_column_name("total_cost")
93
+ updated_col = conn.quote_column_name("updated_at")
94
+
95
+ buckets_by_period.each do |(period, currency), by_start|
96
+ case_clauses = by_start.map do |period_start, amount|
97
+ "WHEN #{start_col} = #{conn.quote(period_start)} THEN #{conn.quote(amount)}"
98
+ end.join(" ")
99
+ starts = by_start.keys.map { |period_start| conn.quote(period_start) }.join(", ")
100
+
101
+ conn.execute(
102
+ "UPDATE #{table} " \
103
+ "SET #{total_col} = GREATEST(0, #{total_col} - CASE #{case_clauses} ELSE 0 END), " \
104
+ "#{updated_col} = #{conn.quote(now)} " \
105
+ "WHERE #{period_col} = #{conn.quote(Period::PERIODS.fetch(period))} " \
106
+ "AND #{currency_col} = #{conn.quote(currency)} " \
107
+ "AND #{start_col} IN (#{starts})"
108
+ )
76
109
  end
77
110
  end
78
111
 
79
- def unique_by(model, column)
80
- return unless model.connection.supports_insert_conflict_target?
112
+ def currency_for(event)
113
+ snapshot = event.respond_to?(:pricing_snapshot) ? event.pricing_snapshot : nil
114
+ currency_from_snapshot(snapshot)
115
+ end
116
+
117
+ def currency_from_snapshot(snapshot)
118
+ (snapshot.is_a?(Hash) && (snapshot["currency"] || snapshot[:currency])) || DEFAULT_CURRENCY
119
+ end
120
+
121
+ def upsert_call_rollups(rows)
122
+ LlmCostTracker::CallRollup.upsert_all(
123
+ rows,
124
+ on_duplicate: Ledger::Rollups::UpsertSql.call,
125
+ record_timestamps: true,
126
+ unique_by: call_rollups_unique_by
127
+ )
128
+ end
129
+
130
+ def call_rollups_unique_by
131
+ return unless LlmCostTracker::CallRollup.connection.supports_insert_conflict_target?
81
132
 
82
- column
133
+ %i[period period_start currency]
83
134
  end
84
135
  end
85
136
  end
@@ -32,6 +32,24 @@ module LlmCostTracker
32
32
  raise Error, "Unsupported database adapter: #{adapter_name(value)}. Use PostgreSQL or MySQL."
33
33
  end
34
34
 
35
+ def json_column_errors(column, adapter_value, column_name)
36
+ return [] unless column
37
+
38
+ expected_type = postgresql?(adapter_value) ? "jsonb" : "json"
39
+ return [] if json_column_type?(column, adapter_value)
40
+
41
+ ["#{column_name} column must use #{expected_type} (got #{column.sql_type})"]
42
+ end
43
+
44
+ def json_column_type?(column, adapter_value)
45
+ sql_type = column.sql_type.to_s.downcase
46
+ if postgresql?(adapter_value)
47
+ column.type == :jsonb || sql_type == "jsonb"
48
+ else
49
+ column.type == :json || sql_type == "json" || sql_type == "longtext"
50
+ end
51
+ end
52
+
35
53
  private
36
54
 
37
55
  def adapter_instance?(value, class_names)
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "adapter"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Schema
8
+ module CallLineItems
9
+ REQUIRED_COLUMNS = %w[
10
+ llm_cost_tracker_call_id
11
+ position
12
+ kind
13
+ direction
14
+ modality
15
+ cache_state
16
+ quantity
17
+ unit
18
+ rate_amount
19
+ rate_quantity
20
+ cost
21
+ currency
22
+ cost_status
23
+ pricing_basis
24
+ price_key
25
+ price_source
26
+ price_source_version
27
+ provider_field
28
+ provider_item_id
29
+ details
30
+ ].freeze
31
+
32
+ class << self
33
+ def current_schema_errors
34
+ connection = LlmCostTracker::Call.connection
35
+ Adapter.ensure_supported!(connection)
36
+ table_name = LlmCostTracker::CallLineItem.table_name
37
+ return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
38
+
39
+ columns = LlmCostTracker::CallLineItem.columns_hash
40
+ errors = []
41
+ missing = REQUIRED_COLUMNS - columns.keys
42
+ errors << "missing columns: #{missing.join(', ')}" if missing.any?
43
+ errors.concat(Adapter.json_column_errors(columns["details"], connection, "details"))
44
+ errors
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "adapter"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Schema
8
+ module CallRollups
9
+ REQUIRED_COLUMNS = %w[period period_start currency total_cost].freeze
10
+ UNIQUE_COLUMNS = %i[period period_start currency].freeze
11
+
12
+ class << self
13
+ def current_schema_errors
14
+ connection = LlmCostTracker::CallRollup.connection
15
+ Adapter.ensure_supported!(connection)
16
+ table_name = LlmCostTracker::CallRollup.table_name
17
+ return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
18
+
19
+ errors = []
20
+ missing = REQUIRED_COLUMNS - LlmCostTracker::CallRollup.columns_hash.keys
21
+ errors << "missing columns: #{missing.join(', ')}" if missing.any?
22
+ unless unique_period_index?(connection, table_name)
23
+ errors << "missing unique index: period, period_start, currency"
24
+ end
25
+ errors
26
+ end
27
+
28
+ private
29
+
30
+ def unique_period_index?(connection, table_name)
31
+ connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Ledger
5
+ module Schema
6
+ module CallTags
7
+ REQUIRED_COLUMNS = %w[llm_cost_tracker_call_id key value].freeze
8
+
9
+ class << self
10
+ def current_schema_errors
11
+ connection = LlmCostTracker::Call.connection
12
+ Ledger::Schema::Adapter.ensure_supported!(connection)
13
+ table_name = LlmCostTracker::CallTag.table_name
14
+ return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
15
+
16
+ columns = LlmCostTracker::CallTag.columns_hash
17
+ missing = REQUIRED_COLUMNS - columns.keys
18
+ return [] if missing.empty?
19
+
20
+ ["missing columns: #{missing.join(', ')}"]
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -15,23 +15,35 @@ module LlmCostTracker
15
15
  total_tokens
16
16
  cache_read_input_tokens
17
17
  cache_write_input_tokens
18
- cache_write_1h_input_tokens
18
+ cache_write_extended_input_tokens
19
+ audio_input_tokens
20
+ audio_output_tokens
19
21
  hidden_output_tokens
20
- input_cost
21
- output_cost
22
22
  total_cost
23
- cache_read_input_cost
24
- cache_write_input_cost
25
- cache_write_1h_input_cost
26
23
  latency_ms
27
24
  stream
28
25
  usage_source
29
26
  provider_response_id
27
+ provider_project_id
28
+ provider_api_key_id
29
+ provider_workspace_id
30
+ batch
30
31
  pricing_mode
31
- tags
32
+ cost_status
33
+ pricing_snapshot
32
34
  tracked_at
33
35
  ].freeze
34
36
 
37
+ REQUIRED_INDEXES = [
38
+ { columns: :event_id, unique: true },
39
+ { columns: :tracked_at },
40
+ { columns: %i[provider tracked_at] },
41
+ { columns: %i[model tracked_at] },
42
+ { columns: :cost_status },
43
+ { columns: :provider_response_id }
44
+ ].freeze
45
+ private_constant :REQUIRED_INDEXES
46
+
35
47
  class << self
36
48
  def current_schema?
37
49
  current_schema_errors.empty?
@@ -48,8 +60,8 @@ module LlmCostTracker
48
60
  private
49
61
 
50
62
  def schema_capabilities
51
- columns = Ledger::Call.columns_hash
52
- adapter_name = Ledger::Call.connection.adapter_name
63
+ columns = LlmCostTracker::Call.columns_hash
64
+ adapter_name = LlmCostTracker::Call.connection.adapter_name
53
65
  cache = @schema_capabilities
54
66
 
55
67
  return cache.fetch(:values) if cache && cache.fetch(:columns).equal?(columns) &&
@@ -73,22 +85,21 @@ module LlmCostTracker
73
85
  errors = []
74
86
  missing = missing_columns_for(columns)
75
87
  errors << "missing columns: #{missing.join(', ')}" if missing.any?
88
+ errors.concat(Adapter.json_column_errors(columns["pricing_snapshot"], adapter_name, "pricing_snapshot"))
89
+ errors.concat(missing_index_errors)
90
+ errors
91
+ end
76
92
 
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
93
+ def missing_index_errors
94
+ connection = LlmCostTracker::Call.connection
95
+ REQUIRED_INDEXES.filter_map do |spec|
96
+ next if connection.index_exists?(LlmCostTracker::Call.table_name, spec[:columns], **spec.except(:columns))
90
97
 
91
- errors
98
+ prefix = spec[:unique] ? "unique " : ""
99
+ "missing #{prefix}index: #{Array(spec[:columns]).join(', ')}"
100
+ end
101
+ rescue StandardError
102
+ []
92
103
  end
93
104
 
94
105
  def missing_columns_for(columns)
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "adapter"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Schema
8
+ module ProviderInvoices
9
+ REQUIRED_COLUMNS = %w[
10
+ source period_start period_end external_id billed_amount currency metadata imported_at
11
+ ].freeze
12
+ UNIQUE_INDEX_COLUMNS = %i[external_id].freeze
13
+ SOURCE_PERIOD_INDEX_COLUMNS = %i[source period_start].freeze
14
+
15
+ class << self
16
+ def current_schema_errors
17
+ connection = LlmCostTracker::Call.connection
18
+ Adapter.ensure_supported!(connection)
19
+ table_name = LlmCostTracker::ProviderInvoice.table_name
20
+ return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
21
+
22
+ errors = []
23
+ errors.concat(column_errors)
24
+ errors.concat(metadata_type_errors(connection))
25
+ errors.concat(index_errors(connection, table_name))
26
+ errors
27
+ end
28
+
29
+ private
30
+
31
+ def column_errors
32
+ missing = REQUIRED_COLUMNS - LlmCostTracker::ProviderInvoice.columns_hash.keys
33
+ return [] if missing.empty?
34
+
35
+ ["missing columns: #{missing.join(', ')}"]
36
+ end
37
+
38
+ def metadata_type_errors(connection)
39
+ metadata = LlmCostTracker::ProviderInvoice.columns_hash["metadata"]
40
+ Adapter.json_column_errors(metadata, connection, "metadata")
41
+ end
42
+
43
+ def index_errors(connection, table_name)
44
+ errors = []
45
+ unless connection.index_exists?(table_name, UNIQUE_INDEX_COLUMNS, unique: true)
46
+ errors << "missing unique index: external_id"
47
+ end
48
+ unless connection.index_exists?(table_name, SOURCE_PERIOD_INDEX_COLUMNS)
49
+ errors << "missing index: source, period_start"
50
+ end
51
+ errors
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  require_relative "../pricing"
6
+ require_relative "../billing/line_item"
4
7
  require_relative "rollups"
5
8
 
6
9
  module LlmCostTracker
@@ -11,13 +14,17 @@ module LlmCostTracker
11
14
  events = Array(events)
12
15
  return [] if events.empty?
13
16
 
14
- model = LlmCostTracker::Ledger::Call
15
- insertable = new_events(model, events)
17
+ insertable = insertable_events(events)
16
18
 
17
19
  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)
20
+ LlmCostTracker::Call.transaction do
21
+ rows = insertable.map { |event| attributes_for(event) }
22
+ LlmCostTracker::Call.insert_all!(rows, record_timestamps: true, returning: false)
23
+ call_ids = call_ids_for(insertable)
24
+ insert_line_items(insertable, call_ids)
25
+ insert_call_tags(insertable, call_ids)
26
+ Ledger::Rollups.increment_many!(insertable)
27
+ end
21
28
  end
22
29
  events
23
30
  end
@@ -25,32 +32,117 @@ module LlmCostTracker
25
32
  private
26
33
 
27
34
  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
35
  attributes = {
32
36
  event_id: event.event_id,
33
37
  provider: event.provider,
34
38
  model: event.model,
35
- tags: tags,
36
39
  tracked_at: event.tracked_at,
37
- pricing_mode: event.pricing_mode,
40
+ pricing_mode: event.pricing_mode&.name,
38
41
  latency_ms: event.latency_ms,
39
42
  stream: event.stream,
40
- usage_source: event.usage_source,
41
- provider_response_id: event.provider_response_id
43
+ usage_source: event.usage_source&.name,
44
+ provider_response_id: event.provider_response_id,
45
+ provider_project_id: event.provider_project_id,
46
+ provider_api_key_id: event.provider_api_key_id,
47
+ provider_workspace_id: event.provider_workspace_id,
48
+ batch: event.batch,
49
+ cost_status: event.cost_status,
50
+ pricing_snapshot: event.pricing_snapshot
42
51
  }
43
52
 
44
- attributes.merge(usage).merge(Pricing.stored_cost_attributes(event.cost || {}))
53
+ attributes
54
+ .merge(event.token_usage.to_h)
55
+ .merge(Pricing.stored_cost_attributes(event.cost || {}))
56
+ end
57
+
58
+ def call_ids_for(events)
59
+ LlmCostTracker::Call
60
+ .where(event_id: events.map(&:event_id))
61
+ .pluck(:event_id, :id)
62
+ .to_h
63
+ end
64
+
65
+ def insert_line_items(events, call_ids)
66
+ rows = events.flat_map do |event|
67
+ (event.line_items || []).each_with_index.map do |line_item, position|
68
+ line_item_attributes(
69
+ call_id: call_ids.fetch(event.event_id),
70
+ line_item: line_item,
71
+ position: position
72
+ )
73
+ end
74
+ end
75
+ return if rows.empty?
76
+
77
+ LlmCostTracker::CallLineItem.insert_all!(rows, record_timestamps: false, returning: false)
45
78
  end
46
79
 
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) }
80
+ def line_item_attributes(call_id:, line_item:, position:)
81
+ {
82
+ llm_cost_tracker_call_id: call_id,
83
+ position: position,
84
+ kind: line_item.kind&.to_s,
85
+ direction: line_item.direction&.to_s,
86
+ modality: line_item.modality&.to_s,
87
+ cache_state: line_item.cache_state&.to_s || "none",
88
+ quantity: line_item.quantity,
89
+ unit: line_item.unit&.to_s,
90
+ rate_amount: line_item.rate_amount,
91
+ rate_quantity: line_item.rate_quantity,
92
+ cost: line_item.cost,
93
+ currency: line_item.currency,
94
+ cost_status: line_item.cost_status,
95
+ pricing_basis: line_item.pricing_basis&.to_s,
96
+ price_key: line_item.price_key,
97
+ price_source: line_item.price_source&.to_s,
98
+ price_source_version: line_item.price_source_version,
99
+ provider_field: line_item.provider_field,
100
+ provider_item_id: line_item.provider_item_id,
101
+ details: stored_details(line_item.details),
102
+ created_at: Time.now.utc
103
+ }
50
104
  end
51
105
 
52
- def stringify_tag_value(value)
53
- return value.transform_values { |nested| stringify_tag_value(nested) } if value.is_a?(Hash)
106
+ def insert_call_tags(events, call_ids)
107
+ rows = events.flat_map do |event|
108
+ (event.tags || {}).map do |key, value|
109
+ {
110
+ llm_cost_tracker_call_id: call_ids.fetch(event.event_id),
111
+ key: key.to_s,
112
+ value: tag_row_value(value)
113
+ }
114
+ end
115
+ end
116
+ return if rows.empty?
117
+
118
+ LlmCostTracker::CallTag.insert_all!(rows, record_timestamps: false, returning: false)
119
+ end
120
+
121
+ def tag_row_value(value)
122
+ case value
123
+ when Hash, Array then JSON.generate(stored_tag_value(value))
124
+ else value.to_s
125
+ end
126
+ end
127
+
128
+ def stored_details(details)
129
+ (details || {}).transform_keys(&:to_s).transform_values { |value| stored_tag_value(value) }
130
+ end
131
+
132
+ def insertable_events(events)
133
+ existing_ids = LlmCostTracker::Call.where(event_id: events.map(&:event_id)).pluck(:event_id).to_set
134
+ seen_ids = Set.new
135
+
136
+ events.select do |event|
137
+ event_id = event.event_id
138
+ !existing_ids.include?(event_id) && seen_ids.add?(event_id)
139
+ end
140
+ end
141
+
142
+ def stored_tag_value(value)
143
+ if value.is_a?(Hash)
144
+ return value.transform_keys(&:to_s).transform_values { |nested| stored_tag_value(nested) }
145
+ end
54
146
 
55
147
  value.to_s
56
148
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
-
5
3
  require_relative "../schema/adapter"
6
4
 
7
5
  module LlmCostTracker
@@ -9,17 +7,13 @@ module LlmCostTracker
9
7
  module Tags
10
8
  module Query
11
9
  class << self
12
- def apply(model, tags)
10
+ def apply(tags)
13
11
  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
12
+ return LlmCostTracker::Call.all if normalized_tags.empty?
18
13
 
19
- if Schema::Adapter.postgresql?(connection)
20
- model.where("tags @> ?::jsonb", json)
21
- else
22
- model.where("JSON_CONTAINS(tags, ?)", json)
14
+ normalized_tags.inject(LlmCostTracker::Call.all) do |relation, (key, value)|
15
+ relation.where(id: LlmCostTracker::CallTag.where(key: key,
16
+ value: value).select(:llm_cost_tracker_call_id))
23
17
  end
24
18
  end
25
19
  end