llm_cost_tracker 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/README.md +12 -5
  4. data/app/assets/llm_cost_tracker/application.css +65 -5
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +25 -33
  6. data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -7
  8. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +4 -0
  9. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +10 -0
  12. data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
  13. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +5 -1
  15. data/app/models/llm_cost_tracker/call.rb +0 -3
  16. data/app/models/llm_cost_tracker/call_line_item.rb +1 -5
  17. data/app/models/llm_cost_tracker/call_rollup.rb +0 -3
  18. data/app/models/llm_cost_tracker/call_tag.rb +0 -4
  19. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +0 -4
  20. data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
  21. data/app/models/llm_cost_tracker/provider_invoice.rb +7 -3
  22. data/app/models/llm_cost_tracker/provider_invoice_import.rb +24 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +33 -4
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -4
  25. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  26. data/app/views/llm_cost_tracker/calls/show.html.erb +25 -40
  27. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
  28. data/app/views/llm_cost_tracker/data_quality/index.html.erb +91 -52
  29. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  30. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  31. data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
  32. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  33. data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
  34. data/config/routes.rb +3 -2
  35. data/lib/llm_cost_tracker/billing/components.rb +45 -3
  36. data/lib/llm_cost_tracker/billing/components.yml +71 -0
  37. data/lib/llm_cost_tracker/billing/line_item.rb +1 -1
  38. data/lib/llm_cost_tracker/budget.rb +4 -2
  39. data/lib/llm_cost_tracker/capture/stream_collector.rb +93 -20
  40. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  41. data/lib/llm_cost_tracker/configuration.rb +53 -1
  42. data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
  43. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
  44. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +26 -0
  45. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  46. data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
  47. data/lib/llm_cost_tracker/doctor.rb +72 -3
  48. data/lib/llm_cost_tracker/engine.rb +9 -0
  49. data/lib/llm_cost_tracker/event.rb +1 -1
  50. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  51. data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
  52. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +13 -3
  53. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  54. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +38 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
  65. data/lib/llm_cost_tracker/ingestion/inbox.rb +0 -1
  66. data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
  67. data/lib/llm_cost_tracker/ingestion/worker.rb +10 -2
  68. data/lib/llm_cost_tracker/ingestion.rb +48 -10
  69. data/lib/llm_cost_tracker/integrations/anthropic.rb +24 -5
  70. data/lib/llm_cost_tracker/integrations/base.rb +22 -5
  71. data/lib/llm_cost_tracker/integrations/openai.rb +300 -66
  72. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +105 -6
  73. data/lib/llm_cost_tracker/integrations.rb +19 -1
  74. data/lib/llm_cost_tracker/ledger/period/totals.rb +21 -5
  75. data/lib/llm_cost_tracker/ledger/rollups.rb +24 -10
  76. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +30 -1
  77. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -3
  78. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +17 -2
  79. data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -0
  80. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
  81. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
  82. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
  83. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +2 -2
  84. data/lib/llm_cost_tracker/ledger/store.rb +14 -14
  85. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  86. data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -1
  87. data/lib/llm_cost_tracker/ledger.rb +2 -1
  88. data/lib/llm_cost_tracker/masking.rb +39 -0
  89. data/lib/llm_cost_tracker/middleware/faraday.rb +88 -29
  90. data/lib/llm_cost_tracker/parsers/anthropic.rb +22 -7
  91. data/lib/llm_cost_tracker/parsers/base.rb +5 -1
  92. data/lib/llm_cost_tracker/parsers/gemini.rb +4 -0
  93. data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
  94. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +5 -1
  95. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +49 -10
  96. data/lib/llm_cost_tracker/parsers/openai_usage.rb +124 -53
  97. data/lib/llm_cost_tracker/prices.json +110 -19
  98. data/lib/llm_cost_tracker/pricing/effective_prices.rb +5 -36
  99. data/lib/llm_cost_tracker/pricing/lookup.rb +36 -3
  100. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  101. data/lib/llm_cost_tracker/pricing/registry.rb +3 -1
  102. data/lib/llm_cost_tracker/pricing/service_charges.rb +9 -3
  103. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
  104. data/lib/llm_cost_tracker/pricing/sync.rb +3 -1
  105. data/lib/llm_cost_tracker/pricing.rb +47 -19
  106. data/lib/llm_cost_tracker/railtie.rb +6 -0
  107. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  108. data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
  109. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
  110. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  111. data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
  112. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -0
  113. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  114. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  115. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  116. data/lib/llm_cost_tracker/report/data.rb +4 -1
  117. data/lib/llm_cost_tracker/retention.rb +15 -2
  118. data/lib/llm_cost_tracker/tags/context.rb +3 -4
  119. data/lib/llm_cost_tracker/tags/sanitizer.rb +60 -4
  120. data/lib/llm_cost_tracker/token_usage.rb +10 -2
  121. data/lib/llm_cost_tracker/tracker.rb +45 -18
  122. data/lib/llm_cost_tracker/version.rb +1 -1
  123. data/lib/llm_cost_tracker.rb +9 -0
  124. data/lib/tasks/llm_cost_tracker.rake +25 -2
  125. metadata +36 -1
@@ -38,16 +38,32 @@ module LlmCostTracker
38
38
 
39
39
  def snapshot_select(period)
40
40
  start = Period.range_start(period, time)
41
+ components = [period_total_sql(period, start)]
42
+ components << pending_total_sql(start) if Ingestion.durable?
41
43
  "SELECT #{connection.quote(period.name)} AS period_key, " \
42
- "(#{rollup_total_sql(period)}) + (#{pending_total_sql(start)}) AS total_cost"
44
+ "(#{components.join(') + (')}) AS total_cost"
43
45
  end
44
46
 
45
- def rollup_total_sql(period)
47
+ def period_total_sql(period, start)
48
+ if LlmCostTracker.configuration.cache_rollups
49
+ "GREATEST(COALESCE(#{rollup_sum_sql(period)}, 0), COALESCE(#{calls_sum_sql(start)}, 0))"
50
+ else
51
+ "COALESCE(#{calls_sum_sql(start)}, 0)"
52
+ end
53
+ end
54
+
55
+ def rollup_sum_sql(period)
46
56
  table = connection.quote_table_name("llm_cost_tracker_call_rollups")
47
- "COALESCE((SELECT SUM(total_cost) FROM #{table} " \
57
+ "(SELECT SUM(total_cost) FROM #{table} " \
48
58
  "WHERE period = #{connection.quote(Period::PERIODS.fetch(period))} " \
49
- "AND period_start = #{connection.quote(Period.bucket(period, time))} " \
50
- "AND currency = #{connection.quote(Ledger::Rollups::DEFAULT_CURRENCY)}), 0)"
59
+ "AND period_start = #{connection.quote(Period.bucket(period, time))})"
60
+ end
61
+
62
+ def calls_sum_sql(start)
63
+ table = connection.quote_table_name("llm_cost_tracker_calls")
64
+ tracked_at = connection.quote_column_name("tracked_at")
65
+ "(SELECT SUM(total_cost) FROM #{table} " \
66
+ "WHERE #{tracked_at} BETWEEN #{connection.quote(start)} AND #{connection.quote(time)})"
51
67
  end
52
68
 
53
69
  def pending_total_sql(start)
@@ -35,22 +35,25 @@ module LlmCostTracker
35
35
 
36
36
  def period_rows(event)
37
37
  currency = currency_for(event)
38
+ provider = provider_for(event)
38
39
  Period::PERIODS.map do |period, name|
39
40
  {
40
41
  period: name,
41
42
  period_start: Period.bucket(period, event.tracked_at),
42
43
  currency: currency,
44
+ provider: provider,
43
45
  total_cost: event.total_cost
44
46
  }
45
47
  end
46
48
  end
47
49
 
48
50
  def period_rows_for_events(events)
49
- call_rollups(events).map do |(period, period_start, currency), total_cost|
51
+ call_rollups(events).map do |(period, period_start, currency, provider), total_cost|
50
52
  {
51
53
  period: period,
52
54
  period_start: period_start,
53
55
  currency: currency,
56
+ provider: provider,
54
57
  total_cost: total_cost
55
58
  }
56
59
  end
@@ -59,29 +62,33 @@ module LlmCostTracker
59
62
  def call_rollups(events)
60
63
  events.each_with_object(Hash.new { |totals, key| totals[key] = BigDecimal("0") }) do |event, totals|
61
64
  currency = currency_for(event)
65
+ provider = provider_for(event)
62
66
  Period::PERIODS.each do |period, name|
63
- totals[[name, Period.bucket(period, event.tracked_at), currency]] += BigDecimal(event.total_cost.to_s)
67
+ key = [name, Period.bucket(period, event.tracked_at), currency, provider]
68
+ totals[key] += BigDecimal(event.total_cost.to_s)
64
69
  end
65
70
  end
66
71
  end
67
72
 
68
73
  def period_decrement_totals(call_rows)
69
74
  call_rows.each_with_object(Hash.new { |totals, key| totals[key] = BigDecimal("0") }) do |row, totals|
70
- _id, tracked_at, total_cost, pricing_snapshot = row
75
+ _id, tracked_at, total_cost, pricing_snapshot, provider = row
71
76
  next unless total_cost
72
77
 
73
78
  currency = currency_from_snapshot(pricing_snapshot)
79
+ provider_key = provider.to_s
74
80
  Period::PERIODS.each_key do |period|
75
- totals[[period, Period.bucket(period, tracked_at), currency]] += total_cost
81
+ totals[[period, Period.bucket(period, tracked_at), currency, provider_key]] += total_cost
76
82
  end
77
83
  end
78
84
  end
79
85
 
80
86
  def apply_decrements(totals)
81
87
  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
88
+ buckets_by_period = totals.each_with_object({}) do |(key, amount), grouped|
89
+ period, period_start, currency, provider = key
90
+ grouped[[period, currency, provider]] ||= {}
91
+ grouped[[period, currency, provider]][period_start] = amount
85
92
  end
86
93
 
87
94
  conn = LlmCostTracker::CallRollup.connection
@@ -89,10 +96,11 @@ module LlmCostTracker
89
96
  period_col = conn.quote_column_name("period")
90
97
  start_col = conn.quote_column_name("period_start")
91
98
  currency_col = conn.quote_column_name("currency")
99
+ provider_col = conn.quote_column_name("provider")
92
100
  total_col = conn.quote_column_name("total_cost")
93
101
  updated_col = conn.quote_column_name("updated_at")
94
102
 
95
- buckets_by_period.each do |(period, currency), by_start|
103
+ buckets_by_period.each do |(period, currency, provider), by_start|
96
104
  case_clauses = by_start.map do |period_start, amount|
97
105
  "WHEN #{start_col} = #{conn.quote(period_start)} THEN #{conn.quote(amount)}"
98
106
  end.join(" ")
@@ -104,6 +112,7 @@ module LlmCostTracker
104
112
  "#{updated_col} = #{conn.quote(now)} " \
105
113
  "WHERE #{period_col} = #{conn.quote(Period::PERIODS.fetch(period))} " \
106
114
  "AND #{currency_col} = #{conn.quote(currency)} " \
115
+ "AND #{provider_col} = #{conn.quote(provider)} " \
107
116
  "AND #{start_col} IN (#{starts})"
108
117
  )
109
118
  end
@@ -115,7 +124,12 @@ module LlmCostTracker
115
124
  end
116
125
 
117
126
  def currency_from_snapshot(snapshot)
118
- (snapshot.is_a?(Hash) && (snapshot["currency"] || snapshot[:currency])) || DEFAULT_CURRENCY
127
+ value = (snapshot.is_a?(Hash) && (snapshot["currency"] || snapshot[:currency])) || DEFAULT_CURRENCY
128
+ value.to_s.upcase
129
+ end
130
+
131
+ def provider_for(event)
132
+ (event.respond_to?(:provider) ? event.provider : nil).to_s
119
133
  end
120
134
 
121
135
  def upsert_call_rollups(rows)
@@ -130,7 +144,7 @@ module LlmCostTracker
130
144
  def call_rollups_unique_by
131
145
  return unless LlmCostTracker::CallRollup.connection.supports_insert_conflict_target?
132
146
 
133
- %i[period period_start currency]
147
+ %i[period period_start currency provider]
134
148
  end
135
149
  end
136
150
  end
@@ -27,6 +27,11 @@ module LlmCostTracker
27
27
  provider_field
28
28
  provider_item_id
29
29
  details
30
+ created_at
31
+ ].freeze
32
+
33
+ REQUIRED_INDEX_COLUMNS = [
34
+ %w[llm_cost_tracker_call_id position]
30
35
  ].freeze
31
36
 
32
37
  class << self
@@ -41,7 +46,31 @@ module LlmCostTracker
41
46
  missing = REQUIRED_COLUMNS - columns.keys
42
47
  errors << "missing columns: #{missing.join(', ')}" if missing.any?
43
48
  errors.concat(Adapter.json_column_errors(columns["details"], connection, "details"))
44
- errors
49
+ errors.concat(missing_index_errors(connection, table_name))
50
+ errors << missing_fk_error(connection, table_name) if missing_fk?(connection, table_name)
51
+ errors.compact
52
+ end
53
+
54
+ def missing_index_errors(connection, table_name)
55
+ existing = connection.indexes(table_name).map { |index| Array(index.columns).map(&:to_s) }
56
+ REQUIRED_INDEX_COLUMNS.filter_map do |required|
57
+ next if existing.any? { |columns| columns == required }
58
+
59
+ "missing index on (#{required.join(', ')})"
60
+ end
61
+ end
62
+
63
+ def missing_fk?(connection, table_name)
64
+ connection.foreign_keys(table_name).none? do |fk|
65
+ fk.column.to_s == "llm_cost_tracker_call_id" &&
66
+ fk.to_table.to_s == "llm_cost_tracker_calls"
67
+ end
68
+ rescue NotImplementedError, NoMethodError
69
+ false
70
+ end
71
+
72
+ def missing_fk_error(_connection, _table_name)
73
+ "missing foreign key on llm_cost_tracker_call_id referencing llm_cost_tracker_calls"
45
74
  end
46
75
  end
47
76
  end
@@ -6,8 +6,8 @@ module LlmCostTracker
6
6
  module Ledger
7
7
  module Schema
8
8
  module CallRollups
9
- REQUIRED_COLUMNS = %w[period period_start currency total_cost].freeze
10
- UNIQUE_COLUMNS = %i[period period_start currency].freeze
9
+ REQUIRED_COLUMNS = %w[period period_start currency provider total_cost created_at updated_at].freeze
10
+ UNIQUE_COLUMNS = %i[period period_start currency provider].freeze
11
11
 
12
12
  class << self
13
13
  def current_schema_errors
@@ -20,7 +20,7 @@ module LlmCostTracker
20
20
  missing = REQUIRED_COLUMNS - LlmCostTracker::CallRollup.columns_hash.keys
21
21
  errors << "missing columns: #{missing.join(', ')}" if missing.any?
22
22
  unless unique_period_index?(connection, table_name)
23
- errors << "missing unique index: period, period_start, currency"
23
+ errors << "missing unique index: period, period_start, currency, provider"
24
24
  end
25
25
  errors
26
26
  end
@@ -6,6 +6,11 @@ module LlmCostTracker
6
6
  module CallTags
7
7
  REQUIRED_COLUMNS = %w[llm_cost_tracker_call_id key value].freeze
8
8
 
9
+ REQUIRED_INDEX_COLUMNS = [
10
+ %w[key value],
11
+ %w[llm_cost_tracker_call_id]
12
+ ].freeze
13
+
9
14
  class << self
10
15
  def current_schema_errors
11
16
  connection = LlmCostTracker::Call.connection
@@ -14,10 +19,20 @@ module LlmCostTracker
14
19
  return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
15
20
 
16
21
  columns = LlmCostTracker::CallTag.columns_hash
22
+ errors = []
17
23
  missing = REQUIRED_COLUMNS - columns.keys
18
- return [] if missing.empty?
24
+ errors << "missing columns: #{missing.join(', ')}" if missing.any?
25
+ errors.concat(missing_index_errors(connection, table_name))
26
+ errors
27
+ end
28
+
29
+ def missing_index_errors(connection, table_name)
30
+ existing = connection.indexes(table_name).map { |index| Array(index.columns).map(&:to_s) }
31
+ REQUIRED_INDEX_COLUMNS.filter_map do |required|
32
+ next if existing.any? { |columns| (required - columns).empty? }
19
33
 
20
- ["missing columns: #{missing.join(', ')}"]
34
+ "missing index on (#{required.join(', ')})"
35
+ end
21
36
  end
22
37
  end
23
38
  end
@@ -18,6 +18,8 @@ module LlmCostTracker
18
18
  cache_write_extended_input_tokens
19
19
  audio_input_tokens
20
20
  audio_output_tokens
21
+ image_input_tokens
22
+ image_output_tokens
21
23
  hidden_output_tokens
22
24
  total_cost
23
25
  latency_ms
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "adapter"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Schema
8
+ module IngestionInboxEntries
9
+ REQUIRED_COLUMNS = %w[
10
+ event_id
11
+ total_cost
12
+ tracked_at
13
+ payload
14
+ locked_at
15
+ locked_by
16
+ attempts
17
+ last_error
18
+ created_at
19
+ updated_at
20
+ ].freeze
21
+
22
+ UNIQUE_COLUMNS = %i[event_id].freeze
23
+
24
+ class << self
25
+ def current_schema_errors
26
+ connection = LlmCostTracker::Ingestion::InboxEntry.connection
27
+ Adapter.ensure_supported!(connection)
28
+ table_name = LlmCostTracker::Ingestion::InboxEntry.table_name
29
+ return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
30
+
31
+ errors = []
32
+ missing = REQUIRED_COLUMNS - LlmCostTracker::Ingestion::InboxEntry.columns_hash.keys
33
+ errors << "missing columns: #{missing.join(', ')}" if missing.any?
34
+ errors << "missing unique index: event_id" unless event_id_unique_index?(connection, table_name)
35
+ errors
36
+ end
37
+
38
+ private
39
+
40
+ def event_id_unique_index?(connection, table_name)
41
+ connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "adapter"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Schema
8
+ module IngestionLeases
9
+ REQUIRED_COLUMNS = %w[
10
+ name
11
+ locked_by
12
+ locked_until
13
+ created_at
14
+ updated_at
15
+ ].freeze
16
+
17
+ UNIQUE_COLUMNS = %i[name].freeze
18
+
19
+ class << self
20
+ def current_schema_errors
21
+ connection = LlmCostTracker::Ingestion::Lease.connection
22
+ Adapter.ensure_supported!(connection)
23
+ table_name = LlmCostTracker::Ingestion::Lease.table_name
24
+ return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
25
+
26
+ errors = []
27
+ missing = REQUIRED_COLUMNS - LlmCostTracker::Ingestion::Lease.columns_hash.keys
28
+ errors << "missing columns: #{missing.join(', ')}" if missing.any?
29
+ errors << "missing unique index: name" unless name_unique_index?(connection, table_name)
30
+ errors
31
+ end
32
+
33
+ private
34
+
35
+ def name_unique_index?(connection, table_name)
36
+ connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "adapter"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Schema
8
+ module ProviderInvoiceImports
9
+ REQUIRED_COLUMNS = %w[
10
+ source cursor window_start window_end state last_error
11
+ rows_imported started_at finished_at
12
+ ].freeze
13
+ SOURCE_STARTED_AT_INDEX = %i[source started_at].freeze
14
+
15
+ class << self
16
+ def current_schema_errors
17
+ connection = LlmCostTracker::Call.connection
18
+ Adapter.ensure_supported!(connection)
19
+ table_name = LlmCostTracker::ProviderInvoiceImport.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(index_errors(connection, table_name))
25
+ errors
26
+ end
27
+
28
+ private
29
+
30
+ def column_errors
31
+ missing = REQUIRED_COLUMNS - LlmCostTracker::ProviderInvoiceImport.columns_hash.keys
32
+ return [] if missing.empty?
33
+
34
+ ["missing columns: #{missing.join(', ')}"]
35
+ end
36
+
37
+ def index_errors(connection, table_name)
38
+ return [] if connection.index_exists?(table_name, SOURCE_STARTED_AT_INDEX)
39
+
40
+ ["missing index: source, started_at"]
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -10,7 +10,7 @@ module LlmCostTracker
10
10
  source period_start period_end external_id billed_amount currency metadata imported_at
11
11
  ].freeze
12
12
  UNIQUE_INDEX_COLUMNS = %i[external_id].freeze
13
- SOURCE_PERIOD_INDEX_COLUMNS = %i[source period_start].freeze
13
+ SOURCE_PERIOD_INDEX_COLUMNS = %i[source currency period_start].freeze
14
14
 
15
15
  class << self
16
16
  def current_schema_errors
@@ -46,7 +46,7 @@ module LlmCostTracker
46
46
  errors << "missing unique index: external_id"
47
47
  end
48
48
  unless connection.index_exists?(table_name, SOURCE_PERIOD_INDEX_COLUMNS)
49
- errors << "missing index: source, period_start"
49
+ errors << "missing index: source, currency, period_start"
50
50
  end
51
51
  errors
52
52
  end
@@ -5,6 +5,7 @@ require "json"
5
5
  require_relative "../pricing"
6
6
  require_relative "../billing/line_item"
7
7
  require_relative "rollups"
8
+ require_relative "tags/encoding"
8
9
 
9
10
  module LlmCostTracker
10
11
  module Ledger
@@ -23,8 +24,8 @@ module LlmCostTracker
23
24
  call_ids = call_ids_for(insertable)
24
25
  insert_line_items(insertable, call_ids)
25
26
  insert_call_tags(insertable, call_ids)
26
- Ledger::Rollups.increment_many!(insertable)
27
27
  end
28
+ increment_rollups_safely(insertable) if LlmCostTracker.configuration.cache_rollups
28
29
  end
29
30
  events
30
31
  end
@@ -119,14 +120,21 @@ module LlmCostTracker
119
120
  end
120
121
 
121
122
  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
123
+ Tags::Encoding.encode(value)
126
124
  end
127
125
 
128
126
  def stored_details(details)
129
- (details || {}).transform_keys(&:to_s).transform_values { |value| stored_tag_value(value) }
127
+ (details || {}).transform_keys(&:to_s).transform_values { |value| Tags::Encoding.normalize_value(value) }
128
+ end
129
+
130
+ def increment_rollups_safely(events)
131
+ Ledger::Rollups.increment_many!(events)
132
+ rescue StandardError => e
133
+ raise if LlmCostTracker::Call.connection.open_transactions.positive?
134
+
135
+ LlmCostTracker::Logging.warn(
136
+ "Rollup increment failed for #{events.size} events after ledger commit: #{e.class}: #{e.message}"
137
+ )
130
138
  end
131
139
 
132
140
  def insertable_events(events)
@@ -138,14 +146,6 @@ module LlmCostTracker
138
146
  !existing_ids.include?(event_id) && seen_ids.add?(event_id)
139
147
  end
140
148
  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
146
-
147
- value.to_s
148
- end
149
149
  end
150
150
  end
151
151
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Tags
8
+ module Encoding
9
+ module_function
10
+
11
+ def encode(value)
12
+ case value
13
+ when Hash then JSON.generate(normalize_hash(value))
14
+ when Array then JSON.generate(normalize_array(value))
15
+ else value.to_s
16
+ end
17
+ end
18
+
19
+ def normalize_hash(hash)
20
+ hash.transform_keys(&:to_s).sort.to_h.transform_values { |v| normalize_value(v) }
21
+ end
22
+
23
+ def normalize_array(array)
24
+ array.map { |v| normalize_value(v) }
25
+ end
26
+
27
+ def normalize_value(value)
28
+ case value
29
+ when Hash then normalize_hash(value)
30
+ when Array then normalize_array(value)
31
+ else value.to_s
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../schema/adapter"
4
+ require_relative "encoding"
4
5
 
5
6
  module LlmCostTracker
6
7
  module Ledger
@@ -8,7 +9,7 @@ module LlmCostTracker
8
9
  module Query
9
10
  class << self
10
11
  def apply(tags)
11
- normalized_tags = (tags || {}).to_h.transform_keys(&:to_s).transform_values(&:to_s)
12
+ normalized_tags = (tags || {}).to_h.transform_keys(&:to_s).transform_values { |v| Encoding.encode(v) }
12
13
  return LlmCostTracker::Call.all if normalized_tags.empty?
13
14
 
14
15
  normalized_tags.inject(LlmCostTracker::Call.all) do |relation, (key, value)|
@@ -5,7 +5,8 @@ require_relative "ledger/schema/calls"
5
5
  require_relative "ledger/schema/call_rollups"
6
6
  require_relative "ledger/schema/call_line_items"
7
7
  require_relative "ledger/schema/call_tags"
8
- require_relative "ledger/schema/provider_invoices"
8
+ require_relative "ledger/schema/ingestion_inbox_entries"
9
+ require_relative "ledger/schema/ingestion_leases"
9
10
  require_relative "ledger/tags/query"
10
11
  require_relative "ledger/tags/sql"
11
12
  require_relative "ledger/period"
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Masking
5
+ SENSITIVE_KEYS = %i[
6
+ provider_api_key_id provider_workspace_id provider_organization_id provider_project_id
7
+ ].to_set.freeze
8
+ MASK_TAIL_LENGTH = 4
9
+
10
+ module_function
11
+
12
+ def mask_value(key, value)
13
+ string = value.to_s
14
+ return string unless SENSITIVE_KEYS.include?(key.to_sym)
15
+ return string if string.length <= MASK_TAIL_LENGTH
16
+
17
+ "***#{string[-MASK_TAIL_LENGTH, MASK_TAIL_LENGTH]}"
18
+ end
19
+
20
+ def format_attribution(attribution, separator: ", ")
21
+ return "" if attribution.nil? || attribution.empty?
22
+
23
+ attribution.map { |key, value| "#{key}=#{mask_value(key, value)}" }.join(separator)
24
+ end
25
+
26
+ def mask_hash(hash)
27
+ return hash unless hash.is_a?(Hash)
28
+
29
+ hash.each_with_object({}) do |(key, value), masked|
30
+ masked[key] = case value
31
+ when Hash then mask_hash(value)
32
+ when Array then value.map { |entry| entry.is_a?(Hash) ? mask_hash(entry) : entry }
33
+ else
34
+ mask_value(key, value)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end