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
@@ -8,7 +8,6 @@ require "rubygems"
8
8
  require_relative "registry"
9
9
  require_relative "sync/fetcher"
10
10
  require_relative "sync/registry_diff"
11
- require_relative "sync/registry_loader"
12
11
  require_relative "sync/registry_writer"
13
12
 
14
13
  module LlmCostTracker
@@ -39,7 +38,7 @@ module LlmCostTracker
39
38
 
40
39
  def refresh(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, preview: false, fetcher: Fetcher.new,
41
40
  today: Date.today)
42
- current = RegistryLoader.new.call(path: path, seed_path: Registry::DEFAULT_PRICES_PATH)
41
+ current = load_registry(path)
43
42
  response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
44
43
 
45
44
  if response.not_modified
@@ -55,7 +54,10 @@ module LlmCostTracker
55
54
  end
56
55
 
57
56
  remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
58
- RegistryWriter.new.call(path: path, registry: remote) unless preview
57
+ unless preview
58
+ RegistryWriter.new.call(path: path, registry: remote)
59
+ invalidate_pricing_caches!
60
+ end
59
61
  refresh_result(
60
62
  path: path,
61
63
  url: url,
@@ -67,8 +69,14 @@ module LlmCostTracker
67
69
  )
68
70
  end
69
71
 
72
+ def invalidate_pricing_caches!
73
+ Pricing::Lookup.reset!
74
+ Pricing::Registry.reset!
75
+ Pricing::ServiceCharges.reset!
76
+ end
77
+
70
78
  def check(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, fetcher: Fetcher.new, today: Date.today)
71
- current = RegistryLoader.new.call(path: path, seed_path: Registry::DEFAULT_PRICES_PATH)
79
+ current = load_registry(path)
72
80
  response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
73
81
 
74
82
  if response.not_modified
@@ -82,7 +90,7 @@ module LlmCostTracker
82
90
  end
83
91
 
84
92
  remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
85
- changes = RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {}))
93
+ changes = registry_changes(current, remote)
86
94
 
87
95
  CheckResult.new(
88
96
  path: path,
@@ -118,10 +126,15 @@ module LlmCostTracker
118
126
  raise Error, "remote pricing snapshot requires llm_cost_tracker >= #{min_gem_version}"
119
127
  end
120
128
 
121
- models = registry.fetch("models", {})
122
- Registry.normalize_price_table(models)
129
+ raw_models = registry.fetch("models", {})
130
+ models = Registry.normalize_price_table(raw_models).each_with_object({}) do |(model, prices), normalized|
131
+ model_metadata = (raw_models[model] || {}).slice(*Registry::METADATA_KEYS)
132
+ normalized[model] = model_metadata.merge(prices.to_h { |key, value| [key.name, value] })
133
+ end
134
+ service_charges = registry["service_charges"]
135
+ ServiceCharges.rates_from_registry(registry, context: "remote pricing snapshot") if service_charges
123
136
 
124
- registry.merge(
137
+ normalized = {
125
138
  "metadata" => metadata.merge(
126
139
  "schema_version" => schema_version,
127
140
  "updated_at" => metadata["updated_at"] || today.iso8601,
@@ -129,11 +142,19 @@ module LlmCostTracker
129
142
  "source_version" => response.source_version
130
143
  ),
131
144
  "models" => models
132
- )
145
+ }
146
+ normalized["service_charges"] = service_charges if service_charges.present?
147
+ normalized
133
148
  rescue ArgumentError, TypeError => e
134
149
  raise Error, "Unable to load remote pricing snapshot: #{e.message}"
135
150
  end
136
151
 
152
+ def load_registry(path)
153
+ YAML.safe_load_file(path, aliases: false) || {}
154
+ rescue Errno::ENOENT, Psych::Exception, ArgumentError, TypeError => e
155
+ raise Error, "Unable to load pricing registry #{path.inspect}: #{e.message}"
156
+ end
157
+
137
158
  def parse_registry(body)
138
159
  registry = JSON.parse(body.to_s)
139
160
  raise Error, "remote pricing snapshot must be a JSON object" unless registry.is_a?(Hash)
@@ -148,11 +169,37 @@ module LlmCostTracker
148
169
  path: path,
149
170
  source_url: url,
150
171
  source_version: response.source_version,
151
- changes: RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {})),
172
+ changes: registry_changes(current, remote),
152
173
  written: written,
153
174
  not_modified: not_modified
154
175
  )
155
176
  end
177
+
178
+ def registry_changes(current, remote)
179
+ model_changes = RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {}))
180
+ charge_changes = service_charges_diff(
181
+ current.fetch("service_charges", {}),
182
+ remote.fetch("service_charges", {})
183
+ )
184
+ return model_changes if charge_changes.empty?
185
+
186
+ model_changes.merge("service_charges" => charge_changes)
187
+ end
188
+
189
+ def service_charges_diff(current, remote)
190
+ (current.keys | remote.keys).sort.each_with_object({}) do |provider, changes|
191
+ current_rates = (current[provider] || {}).transform_keys(&:to_s)
192
+ remote_rates = (remote[provider] || {}).transform_keys(&:to_s)
193
+ (current_rates.keys | remote_rates.keys).sort.each_with_object(changes) do |component, _|
194
+ from = current_rates[component]
195
+ to = remote_rates[component]
196
+ next if from == to
197
+
198
+ changes[provider] ||= {}
199
+ changes[provider][component] = { "from" => from, "to" => to }
200
+ end
201
+ end
202
+ end
156
203
  end
157
204
  end
158
205
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Pricing
5
+ module SyncChangePrinter
6
+ class << self
7
+ def call(changes, output: $stdout)
8
+ service_changes = changes["service_charges"]
9
+ model_changes = changes.except("service_charges")
10
+
11
+ output.puts " changed models: #{model_changes.size}"
12
+ model_changes.each do |model, fields|
13
+ output.puts " - #{model}"
14
+ fields.each do |field, values|
15
+ output.puts " #{field}: #{values['from'].inspect} -> #{values['to'].inspect}"
16
+ end
17
+ end
18
+
19
+ return if service_changes.nil? || service_changes.empty?
20
+
21
+ output.puts " changed service charges: #{service_changes.values.sum(&:size)}"
22
+ service_changes.each do |provider, components|
23
+ components.each do |component, values|
24
+ output.puts " - #{provider}.#{component}: " \
25
+ "#{values['from'].inspect} -> #{values['to'].inspect}"
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -1,75 +1,239 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/core_ext/hash/keys"
4
3
  require "active_support/core_ext/object/blank"
4
+ require "time"
5
5
 
6
- require_relative "pricing/components"
6
+ require_relative "version"
7
+ require_relative "token_usage"
8
+ require_relative "billing/components"
7
9
  require_relative "pricing/registry"
8
10
  require_relative "pricing/lookup"
9
11
  require_relative "pricing/effective_prices"
10
12
  require_relative "pricing/explainer"
13
+ require_relative "pricing/service_charges"
11
14
 
12
15
  module LlmCostTracker
13
16
  module Pricing
14
- STANDARD_MODE_VALUES = %w[auto default standard standard_only].freeze
15
- private_constant :STANDARD_MODE_VALUES
17
+ extend ServiceCharges
18
+
19
+ STANDARD_MODE_VALUES = %i[auto default standard standard_only].freeze
20
+ RATE_DENOMINATOR_TOKENS = 1_000_000
21
+ private_constant :STANDARD_MODE_VALUES, :RATE_DENOMINATOR_TOKENS
16
22
 
17
23
  class << self
18
24
  def normalize_mode(value)
19
- mode = value.to_s.strip.presence
25
+ return nil if value.nil?
26
+
27
+ mode = value.is_a?(Symbol) ? value.downcase : normalize_string_mode(value)
20
28
  return nil unless mode
21
29
 
22
- mode = mode.tr("-", "_")
23
30
  STANDARD_MODE_VALUES.include?(mode) ? nil : mode
24
31
  end
25
32
 
26
- def cost_for(provider:, model:, token_usage:, pricing_mode: nil)
27
- prices = lookup(provider: provider, model: model)
28
- return nil unless prices
33
+ def cost_for(provider:, model:, tokens:, pricing_mode: nil)
34
+ calculation = calculation_for(
35
+ provider: provider,
36
+ model: model,
37
+ tokens: tokens,
38
+ pricing_mode: pricing_mode
39
+ )
40
+ return nil unless calculation
41
+
42
+ cost_from(calculation)
43
+ end
44
+
45
+ def cost_and_snapshot_for(provider:, model:, tokens:, pricing_mode: nil)
46
+ calculation = calculation_for(
47
+ provider: provider,
48
+ model: model,
49
+ tokens: tokens,
50
+ pricing_mode: pricing_mode
51
+ )
52
+ return [nil, nil] unless calculation
29
53
 
30
- costs = calculate_costs(token_usage, prices, pricing_mode: pricing_mode)
31
- return nil unless costs
54
+ [cost_from(calculation), snapshot_from(calculation)]
55
+ end
32
56
 
33
- values = COMPONENTS.to_h do |component|
34
- [component.cost_key, costs.fetch(component.price_key).round(8)]
57
+ def price_line_items(provider:, model:, line_items:, pricing_mode: nil)
58
+ token_usage = TokenUsage.build_from_tokens(token_attributes_from(line_items))
59
+ calculation = calculation_for(provider: provider, model: model, tokens: token_usage, pricing_mode: pricing_mode)
60
+ 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)
35
66
  end
36
67
 
37
- values.merge(total_cost: costs.values.sum.round(8))
68
+ [priced, snapshot]
38
69
  end
39
70
 
40
- def lookup(provider:, model:)
41
- Lookup.call(provider: provider, model: model)&.prices
71
+ def snapshot_for(provider:, model:, tokens:, pricing_mode: nil)
72
+ calculation = calculation_for(
73
+ provider: provider,
74
+ model: model,
75
+ tokens: tokens,
76
+ pricing_mode: pricing_mode
77
+ )
78
+ return nil unless calculation
79
+
80
+ snapshot_from(calculation)
42
81
  end
43
82
 
44
- def explain(provider:, model:, token_usage:, pricing_mode: nil)
83
+ def explain(provider:, model:, tokens:, pricing_mode: nil)
45
84
  Explainer.call(
46
85
  provider: provider,
47
86
  model: model,
48
- token_usage: token_usage,
87
+ tokens: tokens,
49
88
  pricing_mode: pricing_mode
50
89
  )
51
90
  end
52
91
 
53
92
  def stored_cost_attributes(attributes)
54
- attributes.to_h.symbolize_keys.slice(*COST_KEYS).compact
93
+ value = attributes.to_h[:total_cost]
94
+ value.nil? ? {} : { total_cost: value }
55
95
  end
56
96
 
57
97
  private
58
98
 
59
- def calculate_costs(usage, prices, pricing_mode:)
60
- effective = EffectivePrices.call(usage: usage, prices: prices, pricing_mode: pricing_mode)
61
- return nil if effective.value?(nil)
99
+ def normalize_string_mode(value)
100
+ normalized = value.strip
101
+ return nil if normalized.empty?
102
+
103
+ normalized.downcase.tr("-", "_").to_sym
104
+ end
105
+
106
+ def cost_from(calculation)
107
+ costs = calculation[:costs]
108
+ values = Billing::Components::TOKEN_PRICED.each_with_object({}) do |component, result|
109
+ cost = costs[component.key]
110
+ result[component.cost_key] = cost.round(8) unless cost.nil?
111
+ end
112
+ values[:total_cost] = costs.values.compact.sum.round(8)
113
+ values
114
+ end
115
+
116
+ def snapshot_from(calculation)
117
+ match = calculation[:match]
118
+ effective = calculation[:effective]
119
+ token_usage = calculation[:token_usage]
120
+ rates = Billing::Components::TOKEN_PRICED.each_with_object({}) do |component, values|
121
+ quantity = token_usage.public_send(component.token_key)
122
+ price = effective[component.key]
123
+ next if quantity.zero? || price.nil?
124
+
125
+ values[component.key] = {
126
+ amount: price,
127
+ quantity: RATE_DENOMINATOR_TOKENS
128
+ }
129
+ end
130
+
131
+ {
132
+ schema_version: 1,
133
+ source: match.source,
134
+ source_key: match.key,
135
+ source_version: source_version_for(match.source),
136
+ matched_by: match.matched_by,
137
+ currency: "USD",
138
+ rates: rates
139
+ }
140
+ end
141
+
142
+ def calculation_for(provider:, model:, tokens:, pricing_mode:)
143
+ match = Lookup.call(provider: provider, model: model)
144
+ return nil unless match
145
+
146
+ token_usage = TokenUsage.build_from_tokens(tokens)
147
+ mode = normalize_mode(pricing_mode)
148
+ effective = EffectivePrices.call(usage: token_usage, prices: match.prices, pricing_mode: mode)
149
+ return nil unless any_billable_priced?(token_usage, effective)
150
+
151
+ { match: match, effective: effective, token_usage: token_usage, costs: costs_for(token_usage, effective) }
152
+ end
153
+
154
+ def any_billable_priced?(token_usage, effective)
155
+ billable = Billing::Components::TOKEN_PRICED.select { |c| token_usage.public_send(c.token_key).positive? }
156
+ billable.empty? || billable.any? { |c| effective[c.key] }
157
+ end
158
+
159
+ def costs_for(usage, effective)
160
+ Billing::Components::TOKEN_PRICED.to_h do |component|
161
+ tokens = usage.public_send(component.token_key)
162
+ [component.key, token_cost(tokens, effective[component.key])]
163
+ end
164
+ end
165
+
166
+ def token_attributes_from(line_items)
167
+ line_items.each_with_object({}) do |line_item, totals|
168
+ next unless line_item.unit == :token
169
+
170
+ component = component_for_line_item(line_item)
171
+ next unless component
172
+
173
+ totals[component.key] = (totals[component.key] || 0) + line_item.quantity.to_i
174
+ end
175
+ end
176
+
177
+ def price_token_line_item(line_item, calculation)
178
+ component = component_for_line_item(line_item)
179
+ return line_item unless component
180
+ return line_item.with(cost_status: Billing::CostStatus::UNKNOWN) unless calculation
181
+
182
+ effective_price = calculation[:effective][component.key]
183
+ return line_item.with(cost_status: Billing::CostStatus::UNKNOWN) if effective_price.nil?
184
+
185
+ cost = (line_item.quantity * BigDecimal(effective_price.to_s)) / RATE_DENOMINATOR_TOKENS
186
+ match = calculation[:match]
187
+ line_item.with(
188
+ rate_amount: BigDecimal(effective_price.to_s),
189
+ rate_quantity: BigDecimal(RATE_DENOMINATOR_TOKENS),
190
+ cost: cost,
191
+ cost_status: cost.zero? ? Billing::CostStatus::FREE : Billing::CostStatus::COMPLETE,
192
+ price_key: component.key,
193
+ price_source: match.source,
194
+ price_source_version: source_version_for(match.source)
195
+ )
196
+ end
197
+
198
+ def price_service_charge_line_item(line_item, provider:, pricing_mode:)
199
+ return line_item if line_item.priced?
200
+ return line_item unless line_item.billable?
201
+
202
+ rate = charge_rate(provider: provider, component: line_item.kind, pricing_mode: pricing_mode)
203
+ return line_item unless rate
204
+
205
+ line_item.apply_rate(rate)
206
+ end
207
+
208
+ def component_for_line_item(line_item)
209
+ Billing::Components::REGISTRY.find do |component|
210
+ component.kind == line_item.kind &&
211
+ component.direction == line_item.direction &&
212
+ component.modality == line_item.modality &&
213
+ component.cache_state == line_item.cache_state &&
214
+ component.unit == line_item.unit
215
+ end
216
+ end
62
217
 
63
- usage.price_quantities.to_h do |key, tokens|
64
- [key, token_cost(tokens, effective.fetch(key))]
218
+ def source_version_for(source)
219
+ case source
220
+ when :bundled
221
+ LlmCostTracker::VERSION
222
+ when :prices_file
223
+ path = LlmCostTracker.configuration.prices_file
224
+ path ? File.mtime(path).utc.iso8601 : nil
225
+ when :pricing_overrides
226
+ "configuration"
65
227
  end
228
+ rescue Errno::ENOENT
229
+ nil
66
230
  end
67
231
 
68
232
  def token_cost(tokens, per_million_price)
69
- return 0.0 if tokens.to_i.zero?
233
+ return 0.0 if tokens.zero?
70
234
  return nil if per_million_price.nil?
71
235
 
72
- (tokens.to_f / 1_000_000) * per_million_price
236
+ (tokens * per_million_price) / RATE_DENOMINATOR_TOKENS
73
237
  end
74
238
  end
75
239
  end
@@ -9,16 +9,8 @@ module LlmCostTracker
9
9
  end
10
10
 
11
11
  generators do
12
- require_relative "generators/llm_cost_tracker/add_ingestion_generator"
13
- require_relative "generators/llm_cost_tracker/add_period_totals_generator"
14
- require_relative "generators/llm_cost_tracker/add_latency_ms_generator"
15
- require_relative "generators/llm_cost_tracker/add_provider_response_id_generator"
16
- require_relative "generators/llm_cost_tracker/add_streaming_generator"
17
- require_relative "generators/llm_cost_tracker/add_token_usage_generator"
18
12
  require_relative "generators/llm_cost_tracker/install_generator"
19
13
  require_relative "generators/llm_cost_tracker/prices_generator"
20
- require_relative "generators/llm_cost_tracker/upgrade_cost_precision_generator"
21
- require_relative "generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator"
22
14
  end
23
15
 
24
16
  rake_tasks do
@@ -32,17 +32,18 @@ module LlmCostTracker
32
32
  breakdown_limit = nil unless breakdown_limit.positive?
33
33
  end
34
34
  from = now - days.days
35
- scope = Ledger::Call.where(tracked_at: from..now)
35
+ scope = LlmCostTracker::Call.where(tracked_at: from..now)
36
36
  tag_breakdowns ||= LlmCostTracker.configuration.report_tag_breakdowns || []
37
+ aggregate = totals(scope)
37
38
 
38
39
  new(
39
40
  days: days,
40
41
  from_time: from,
41
42
  to_time: now,
42
- total_cost: scope.sum(:total_cost).to_f,
43
- requests_count: scope.count,
44
- average_latency_ms: average_latency_ms(scope),
45
- unknown_pricing_count: scope.where(total_cost: nil).count,
43
+ total_cost: aggregate.total_cost.to_f,
44
+ requests_count: aggregate.requests_count.to_i,
45
+ average_latency_ms: aggregate.average_latency_ms&.to_f,
46
+ unknown_pricing_count: aggregate.unknown_pricing_count.to_i,
46
47
  cost_by_provider: scope.cost_by_provider(limit: breakdown_limit).to_a,
47
48
  cost_by_model: scope.cost_by_model(limit: breakdown_limit).to_a,
48
49
  cost_by_tags: cost_by_tags(scope, tag_breakdowns, limit: breakdown_limit),
@@ -50,8 +51,15 @@ module LlmCostTracker
50
51
  )
51
52
  end
52
53
 
53
- def self.average_latency_ms(scope)
54
- scope.average(:latency_ms)&.to_f
54
+ def self.totals(scope)
55
+ scope
56
+ .select(
57
+ "COALESCE(SUM(total_cost), 0) AS total_cost, " \
58
+ "COUNT(*) AS requests_count, " \
59
+ "AVG(latency_ms) AS average_latency_ms, " \
60
+ "COALESCE(SUM(CASE WHEN total_cost IS NULL THEN 1 ELSE 0 END), 0) AS unknown_pricing_count"
61
+ )
62
+ .take
55
63
  end
56
64
 
57
65
  def self.cost_by_tags(scope, keys, limit:)
@@ -66,7 +74,7 @@ module LlmCostTracker
66
74
  .to_a
67
75
  end
68
76
 
69
- private_class_method :average_latency_ms, :cost_by_tags, :top_calls
77
+ private_class_method :cost_by_tags, :top_calls, :totals
70
78
  end
71
79
  end
72
80
  end
@@ -20,10 +20,6 @@ module LlmCostTracker
20
20
  rescue StandardError => e
21
21
  "Unable to build LLM cost report: #{e.class}: #{e.message}"
22
22
  end
23
-
24
- def data(days: Data::DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
25
- Data.build(days: days, now: now, tag_breakdowns: tag_breakdowns)
26
- end
27
23
  end
28
24
  end
29
25
  end
@@ -49,20 +49,20 @@ module LlmCostTracker
49
49
  end
50
50
 
51
51
  def prune_batch(cutoff, batch_size)
52
- LlmCostTracker::Ledger::Call.transaction do
53
- rows = LlmCostTracker::Ledger::Call
54
- .where(tracked_at: ...cutoff)
55
- .order(:id)
56
- .limit(batch_size)
57
- .lock
58
- .pluck(:id, :tracked_at, :total_cost)
52
+ LlmCostTracker::Call.transaction do
53
+ rows = pluck_prunable(cutoff, batch_size)
59
54
  next 0 if rows.empty?
60
55
 
61
- deleted = LlmCostTracker::Ledger::Call.where(id: rows.map(&:first)).delete_all
56
+ deleted = LlmCostTracker::Call.where(id: rows.map(&:first)).delete_all
62
57
  LlmCostTracker::Ledger::Rollups.decrement!(rows) if deleted.positive?
63
58
  deleted
64
59
  end
65
60
  end
61
+
62
+ def pluck_prunable(cutoff, batch_size)
63
+ LlmCostTracker::Call.where(tracked_at: ...cutoff).order(:id).limit(batch_size).lock
64
+ .pluck(:id, :tracked_at, :total_cost, :pricing_snapshot)
65
+ end
66
66
  end
67
67
  end
68
68
  end
@@ -19,11 +19,9 @@ module LlmCostTracker
19
19
 
20
20
  def tags
21
21
  default_tags = LlmCostTracker.configuration.default_tags
22
- default_tags = default_tags.call if default_tags.respond_to?(:call)
22
+ default_tags = default_tags.call.deep_dup if default_tags.respond_to?(:call)
23
23
 
24
- (default_tags || {}).deep_dup.to_h.merge(
25
- (ActiveSupport::IsolatedExecutionState[KEY] || []).reduce({}) { |merged, tags| merged.merge(tags) }
26
- )
24
+ default_tags.to_h.merge(*Array(ActiveSupport::IsolatedExecutionState[KEY]))
27
25
  end
28
26
 
29
27
  def clear!
@@ -4,10 +4,14 @@ module LlmCostTracker
4
4
  module Tags
5
5
  module Key
6
6
  PATTERN = /\A[\w.-]+\z/
7
+ MAX_BYTESIZE = 64
7
8
 
8
9
  class << self
9
10
  def validate!(key, error_class: ArgumentError)
10
11
  key = key.to_s
12
+ if key.bytesize > MAX_BYTESIZE
13
+ raise error_class, "tag key exceeds #{MAX_BYTESIZE} bytes: #{key[0, 16].inspect}..."
14
+ end
11
15
  return key if key.match?(PATTERN)
12
16
 
13
17
  raise error_class, "invalid tag key: #{key.inspect}"
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/core_ext/string/inflections"
3
4
  require "json"
4
5
 
5
6
  module LlmCostTracker
@@ -10,39 +11,33 @@ module LlmCostTracker
10
11
  class << self
11
12
  def call(tags, config: LlmCostTracker.configuration)
12
13
  tags = (tags || {}).to_h
14
+ redacted = Array(config.redacted_tag_keys).map { |key| normalized_key(key) }
15
+ limit = [config.max_tag_value_bytesize.to_i, 0].max
13
16
  tags.first([config.max_tag_count.to_i, 0].max).each_with_object({}) do |(key, value), sanitized|
14
- sanitized[key] = sanitized_value(key, value, config)
17
+ sanitized[key] = sanitized_value(key, value, redacted, limit)
15
18
  end
16
19
  end
17
20
 
18
21
  private
19
22
 
20
- def sanitized_value(key, value, config)
21
- return REDACTED_VALUE if redacted_key?(key, config)
23
+ def sanitized_value(key, value, redacted, limit)
24
+ return REDACTED_VALUE if redacted_key?(key, redacted)
22
25
 
23
26
  string = value_string(value)
24
- limit = [config.max_tag_value_bytesize.to_i, 0].max
25
27
  return value if string.bytesize <= limit
26
28
 
27
- string.byteslice(0, limit).to_s.encode("UTF-8", invalid: :replace, undef: :replace)
29
+ string.byteslice(0, limit).encode("UTF-8", invalid: :replace, undef: :replace)
28
30
  end
29
31
 
30
- def redacted_key?(key, config)
32
+ def redacted_key?(key, redacted)
33
+ return false if redacted.empty?
34
+
31
35
  normalized = normalized_key(key)
32
- Array(config.redacted_tag_keys).map { |redacted_key| normalized_key(redacted_key) }.any? do |candidate|
33
- redacted_key_component?(normalized, candidate)
34
- end
36
+ redacted.any? { |candidate| redacted_key_component?(normalized, candidate) }
35
37
  end
36
38
 
37
39
  def normalized_key(key)
38
- key.to_s
39
- .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
40
- .gsub(/([a-z\d])([A-Z])/, '\1_\2')
41
- .downcase
42
- .gsub(/[^a-z0-9]+/, "_")
43
- .gsub(/_+/, "_")
44
- .delete_prefix("_")
45
- .delete_suffix("_")
40
+ key.to_s.underscore.gsub(/[^a-z0-9]+/, "_").delete_prefix("_").delete_suffix("_")
46
41
  end
47
42
 
48
43
  def redacted_key_component?(key, candidate)
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Timing
5
+ module_function
6
+
7
+ def now_monotonic
8
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
9
+ end
10
+
11
+ def elapsed_ms(started_at)
12
+ ((now_monotonic - started_at) * 1000).round
13
+ end
14
+ end
15
+ end