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
@@ -6,6 +6,7 @@ require "time"
6
6
  require_relative "version"
7
7
  require_relative "token_usage"
8
8
  require_relative "billing/components"
9
+ require_relative "pricing/mode"
9
10
  require_relative "pricing/registry"
10
11
  require_relative "pricing/lookup"
11
12
  require_relative "pricing/effective_prices"
@@ -13,7 +14,7 @@ require_relative "pricing/explainer"
13
14
  require_relative "pricing/service_charges"
14
15
 
15
16
  module LlmCostTracker
16
- module Pricing
17
+ module Pricing # rubocop:disable Metrics/ModuleLength
17
18
  extend ServiceCharges
18
19
 
19
20
  STANDARD_MODE_VALUES = %i[auto default standard standard_only].freeze
@@ -24,7 +25,7 @@ module LlmCostTracker
24
25
  def normalize_mode(value)
25
26
  return nil if value.nil?
26
27
 
27
- mode = value.is_a?(Symbol) ? value.downcase : normalize_string_mode(value)
28
+ mode = normalize_string_mode(value.to_s)
28
29
  return nil unless mode
29
30
 
30
31
  STANDARD_MODE_VALUES.include?(mode) ? nil : mode
@@ -42,29 +43,26 @@ module LlmCostTracker
42
43
  cost_from(calculation)
43
44
  end
44
45
 
45
- def cost_and_snapshot_for(provider:, model:, tokens:, pricing_mode: nil)
46
+ def calculate(provider:, model:, tokens:, line_items:, pricing_mode: nil)
46
47
  calculation = calculation_for(
47
48
  provider: provider,
48
49
  model: model,
49
50
  tokens: tokens,
50
51
  pricing_mode: pricing_mode
51
52
  )
52
- return [nil, nil] unless calculation
53
-
54
- [cost_from(calculation), snapshot_from(calculation)]
53
+ cost_data = calculation && cost_from(calculation)
54
+ snapshot = calculation && snapshot_from(calculation)
55
+ priced = apply_calculation_to_line_items(line_items, calculation,
56
+ provider: provider, pricing_mode: pricing_mode)
57
+ [cost_data, snapshot, priced]
55
58
  end
56
59
 
57
60
  def price_line_items(provider:, model:, line_items:, pricing_mode: nil)
58
61
  token_usage = TokenUsage.build_from_tokens(token_attributes_from(line_items))
59
62
  calculation = calculation_for(provider: provider, model: model, tokens: token_usage, pricing_mode: pricing_mode)
60
63
  snapshot = calculation && snapshot_from(calculation)
61
-
62
- priced = line_items.map do |line_item|
63
- next price_token_line_item(line_item, calculation) if line_item.unit == :token
64
-
65
- price_service_charge_line_item(line_item, provider: provider, pricing_mode: pricing_mode)
66
- end
67
-
64
+ priced = apply_calculation_to_line_items(line_items, calculation,
65
+ provider: provider, pricing_mode: pricing_mode)
68
66
  [priced, snapshot]
69
67
  end
70
68
 
@@ -91,7 +89,7 @@ module LlmCostTracker
91
89
 
92
90
  def stored_cost_attributes(attributes)
93
91
  value = attributes.to_h[:total_cost]
94
- value.nil? ? {} : { total_cost: value }
92
+ value ? { total_cost: value } : {}
95
93
  end
96
94
 
97
95
  private
@@ -109,7 +107,7 @@ module LlmCostTracker
109
107
  cost = costs[component.key]
110
108
  result[component.cost_key] = cost.round(8) unless cost.nil?
111
109
  end
112
- values[:total_cost] = costs.values.compact.sum.round(8)
110
+ values[:total_cost] = costs.values.compact.sum(BigDecimal("0")).round(8)
113
111
  values
114
112
  end
115
113
 
@@ -163,6 +161,17 @@ module LlmCostTracker
163
161
  end
164
162
  end
165
163
 
164
+ def apply_calculation_to_line_items(line_items, calculation, provider:, pricing_mode:)
165
+ line_items.map do |line_item|
166
+ next price_token_line_item(line_item, calculation) if line_item.unit == :token
167
+
168
+ price_service_charge_line_item(line_item,
169
+ provider: provider,
170
+ calculation: calculation,
171
+ pricing_mode: pricing_mode)
172
+ end
173
+ end
174
+
166
175
  def token_attributes_from(line_items)
167
176
  line_items.each_with_object({}) do |line_item, totals|
168
177
  next unless line_item.unit == :token
@@ -195,16 +204,35 @@ module LlmCostTracker
195
204
  )
196
205
  end
197
206
 
198
- def price_service_charge_line_item(line_item, provider:, pricing_mode:)
207
+ def price_service_charge_line_item(line_item, provider:, calculation:, pricing_mode:)
199
208
  return line_item if line_item.priced?
200
209
  return line_item unless line_item.billable?
201
210
 
202
- rate = charge_rate(provider: provider, component: line_item.kind, pricing_mode: pricing_mode)
211
+ rate = model_rate_for(line_item, calculation) ||
212
+ charge_rate(provider: provider, component: line_item.kind, pricing_mode: pricing_mode)
203
213
  return line_item unless rate
204
214
 
205
215
  line_item.apply_rate(rate)
206
216
  end
207
217
 
218
+ def model_rate_for(line_item, calculation)
219
+ return nil unless calculation
220
+
221
+ match = calculation[:match]
222
+ amount = match.prices[line_item.kind] || match.prices[line_item.kind.to_s]
223
+ return nil unless amount.is_a?(Numeric)
224
+
225
+ component = Billing::Components::BY_KEY[line_item.kind]
226
+ {
227
+ amount: BigDecimal(amount.to_s),
228
+ quantity: BigDecimal(Billing::RATE_BASIS_QUANTITIES.fetch(component.rate_basis).to_s),
229
+ currency: "USD",
230
+ source: match.source,
231
+ source_key: "#{match.key}.#{line_item.kind}",
232
+ source_version: source_version_for(match.source)
233
+ }
234
+ end
235
+
208
236
  def component_for_line_item(line_item)
209
237
  Billing::Components::REGISTRY.find do |component|
210
238
  component.kind == line_item.kind &&
@@ -230,10 +258,10 @@ module LlmCostTracker
230
258
  end
231
259
 
232
260
  def token_cost(tokens, per_million_price)
233
- return 0.0 if tokens.zero?
261
+ return BigDecimal("0") if tokens.zero?
234
262
  return nil if per_million_price.nil?
235
263
 
236
- (tokens * per_million_price) / RATE_DENOMINATOR_TOKENS
264
+ (BigDecimal(tokens.to_s) * BigDecimal(per_million_price.to_s)) / RATE_DENOMINATOR_TOKENS
237
265
  end
238
266
  end
239
267
  end
@@ -11,6 +11,12 @@ module LlmCostTracker
11
11
  generators do
12
12
  require_relative "generators/llm_cost_tracker/install_generator"
13
13
  require_relative "generators/llm_cost_tracker/prices_generator"
14
+ require_relative "generators/llm_cost_tracker/call_rollups_generator"
15
+ require_relative "generators/llm_cost_tracker/durable_ingestion_generator"
16
+ require_relative "generators/llm_cost_tracker/reconciliation_generator"
17
+ require_relative "generators/llm_cost_tracker/upgrade_call_rollups_provider_generator"
18
+ require_relative "generators/llm_cost_tracker/upgrade_image_tokens_generator"
19
+ require_relative "generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator"
14
20
  end
15
21
 
16
22
  rake_tasks do
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "reconciliation"
6
+
7
+ module LlmCostTracker
8
+ module ReconcileTasks
9
+ SOURCE_PARSERS = {
10
+ "openai" => Reconciliation::Sources::OpenaiUsage,
11
+ "anthropic" => Reconciliation::Sources::AnthropicUsage
12
+ }.freeze
13
+ GENERIC_SOURCES = %w[csv].freeze
14
+
15
+ module_function
16
+
17
+ def run_import(env: ENV, output: $stdout, error_output: $stderr)
18
+ result = import_from_env(env: env)
19
+ output.puts "llm_cost_tracker: imported #{result.total_imported} rows " \
20
+ "(inserted=#{result.inserted}, updated=#{result.updated}, skipped=#{result.skipped})"
21
+ result.errors.each { |error| error_output.puts " error: #{error}" }
22
+ raise "llm_cost_tracker: reconcile import had errors" unless result.success?
23
+
24
+ result
25
+ end
26
+
27
+ def run_diff(env: ENV, output: $stdout)
28
+ diff = diff_from_env(env: env)
29
+ print_diff(diff, output: output)
30
+ diff
31
+ end
32
+
33
+ def import_from_env(env: ENV)
34
+ source = required_env(env, "SOURCE")
35
+ input_path = required_env(env, "INPUT")
36
+ raise ArgumentError, "INPUT file not found: #{input_path}" unless File.exist?(input_path)
37
+
38
+ payload = JSON.parse(File.read(input_path))
39
+ rows = parse_rows(source: source, payload: payload)
40
+ Reconciliation.import(source: source.to_sym, rows: rows, provider: env["PROVIDER"])
41
+ end
42
+
43
+ def diff_from_env(env: ENV)
44
+ source = required_env(env, "SOURCE")
45
+ period_start = Date.parse(required_env(env, "PERIOD_START"))
46
+ period_end = Date.parse(required_env(env, "PERIOD_END"))
47
+ Reconciliation.diff(source: source.to_sym, period_start: period_start, period_end: period_end,
48
+ provider: env["PROVIDER"],
49
+ drilldown_limit: parse_drilldown_limit(env["DRILLDOWN_LIMIT"]))
50
+ end
51
+
52
+ def parse_drilldown_limit(value)
53
+ return Reconciliation::Diff::DEFAULT_DRILLDOWN_LIMIT if value.nil? || value.to_s.empty?
54
+ return nil if value.to_s.downcase == "all"
55
+
56
+ Integer(value)
57
+ end
58
+
59
+ def print_diff(diff, output: $stdout)
60
+ output.puts "llm_cost_tracker: reconciliation diff for #{diff.source} " \
61
+ "#{diff.period_start}..#{diff.period_end}"
62
+ output.puts " provider_total: #{diff.provider_total.to_s('F')} #{diff.currency}"
63
+ output.puts " local_total: #{diff.local_total.to_s('F')} #{diff.currency} " \
64
+ "(from #{diff.local_total_source})"
65
+ output.puts " delta: #{diff.delta_amount.to_s('F')} (#{diff.delta_percent || 'n/a'}%)"
66
+ print_unmatched_provider_rows(diff, output)
67
+ print_unmatched_local_calls(diff, output)
68
+ print_non_cost_rows(diff, output)
69
+ end
70
+
71
+ def parse_rows(source:, payload:)
72
+ parser = SOURCE_PARSERS[source.to_s]
73
+ return parser.parse(payload) if parser
74
+ return Array(payload["rows"]) if GENERIC_SOURCES.include?(source.to_s)
75
+
76
+ known = (SOURCE_PARSERS.keys + GENERIC_SOURCES).join(", ")
77
+ raise ArgumentError, "unknown SOURCE #{source.inspect}; known sources: #{known}"
78
+ end
79
+
80
+ def required_env(env, key)
81
+ value = env[key].to_s.strip
82
+ raise ArgumentError, "missing #{key}" if value.empty?
83
+
84
+ value
85
+ end
86
+
87
+ def print_unmatched_provider_rows(diff, output)
88
+ return if diff.unmatched_provider_rows.empty?
89
+
90
+ output.puts " unmatched provider rows#{truncation_suffix(diff.unmatched_provider_rows.size,
91
+ diff.unmatched_provider_rows_total)}:"
92
+ diff.unmatched_provider_rows.each do |row|
93
+ output.puts " #{row[:external_id]} (#{row[:match_basis]}): " \
94
+ "#{format_amount(row[:billed_amount])} #{format_attribution(row[:attribution])}"
95
+ end
96
+ end
97
+
98
+ def print_unmatched_local_calls(diff, output)
99
+ return if diff.unmatched_local_calls.empty?
100
+
101
+ output.puts " unmatched local calls#{truncation_suffix(diff.unmatched_local_calls.size,
102
+ diff.unmatched_local_calls_total)}:"
103
+ diff.unmatched_local_calls.each do |row|
104
+ output.puts " #{row[:count]} calls / #{row[:total_cost].to_s('F')} " \
105
+ "#{format_attribution(row[:attribution])}"
106
+ end
107
+ end
108
+
109
+ def print_non_cost_rows(diff, output)
110
+ return if diff.non_cost_rows.empty?
111
+
112
+ output.puts " non-cost evidence#{truncation_suffix(diff.non_cost_rows.size,
113
+ diff.non_cost_rows_total)}:"
114
+ diff.non_cost_rows.each do |row|
115
+ output.puts " [#{row[:row_type]}/#{row[:meter]}] #{format_amount(row[:billed_amount])} " \
116
+ "#{format_attribution(row[:attribution])}"
117
+ end
118
+ end
119
+
120
+ def truncation_suffix(shown, total)
121
+ return "" if shown >= total
122
+
123
+ " (showing #{shown} of #{total} — pass DRILLDOWN_LIMIT=all to see every row)"
124
+ end
125
+
126
+ def format_amount(value)
127
+ value.nil? ? "n/a" : value.to_s("F")
128
+ end
129
+
130
+ def format_attribution(attribution)
131
+ LlmCostTracker::Masking.format_attribution(attribution, separator: ",")
132
+ end
133
+ end
134
+ end