llm_cost_tracker 0.11.0 → 0.13.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 (196) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -0
  3. data/README.md +7 -4
  4. data/app/assets/llm_cost_tracker/application.css +8 -7
  5. data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -5
  6. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -0
  8. data/app/controllers/llm_cost_tracker/pricing_controller.rb +1 -1
  9. data/app/helpers/llm_cost_tracker/application_helper.rb +12 -19
  10. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  11. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +4 -4
  12. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  13. data/app/models/llm_cost_tracker/call.rb +28 -63
  14. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  15. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  16. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  17. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +3 -0
  18. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +76 -43
  19. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  20. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  21. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  22. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  23. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  24. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +30 -44
  25. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +4 -60
  26. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +1 -7
  27. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  28. data/app/views/layouts/llm_cost_tracker/application.html.erb +0 -6
  29. data/app/views/llm_cost_tracker/calls/index.html.erb +8 -8
  30. data/app/views/llm_cost_tracker/calls/show.html.erb +33 -25
  31. data/app/views/llm_cost_tracker/dashboard/index.html.erb +8 -8
  32. data/app/views/llm_cost_tracker/data_quality/index.html.erb +73 -117
  33. data/app/views/llm_cost_tracker/models/index.html.erb +5 -5
  34. data/app/views/llm_cost_tracker/pricing/index.html.erb +3 -3
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -1
  37. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  38. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -3
  39. data/app/views/llm_cost_tracker/tags/show.html.erb +10 -10
  40. data/config/routes.rb +2 -3
  41. data/lib/llm_cost_tracker/budget.rb +24 -26
  42. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  43. data/lib/llm_cost_tracker/capture/sse.rb +1 -0
  44. data/lib/llm_cost_tracker/capture/stream_collector.rb +32 -39
  45. data/lib/llm_cost_tracker/capture/stream_tracker.rb +17 -28
  46. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  47. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  48. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  49. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +41 -44
  50. data/lib/llm_cost_tracker/check.rb +5 -0
  51. data/lib/llm_cost_tracker/configuration.rb +13 -44
  52. data/lib/llm_cost_tracker/currency.rb +5 -0
  53. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  54. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  55. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  56. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  57. data/lib/llm_cost_tracker/doctor.rb +5 -69
  58. data/lib/llm_cost_tracker/engine.rb +4 -4
  59. data/lib/llm_cost_tracker/event.rb +12 -18
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +4 -3
  64. data/lib/llm_cost_tracker/ingestion/batch.rb +61 -12
  65. data/lib/llm_cost_tracker/ingestion/inbox.rb +7 -8
  66. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  67. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  68. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  69. data/lib/llm_cost_tracker/integrations/anthropic.rb +93 -106
  70. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  71. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  72. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  73. data/lib/llm_cost_tracker/integrations/openai.rb +70 -276
  74. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +87 -99
  75. data/lib/llm_cost_tracker/integrations.rb +32 -25
  76. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  77. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  79. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  80. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  81. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  82. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  83. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  84. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  85. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  86. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  87. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  88. data/lib/llm_cost_tracker/ledger/store.rb +24 -41
  89. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  90. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  91. data/lib/llm_cost_tracker/ledger.rb +8 -18
  92. data/lib/llm_cost_tracker/logging.rb +4 -21
  93. data/lib/llm_cost_tracker/middleware/faraday.rb +61 -50
  94. data/lib/llm_cost_tracker/parsers.rb +139 -26
  95. data/lib/llm_cost_tracker/prices.json +1821 -13
  96. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  97. data/lib/llm_cost_tracker/pricing/calculation.rb +279 -0
  98. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  99. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  100. data/lib/llm_cost_tracker/pricing/matcher.rb +98 -0
  101. data/lib/llm_cost_tracker/pricing/mode.rb +44 -56
  102. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  103. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  104. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  105. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  106. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  107. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  108. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  109. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  110. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  111. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  112. data/lib/llm_cost_tracker/pricing.rb +10 -278
  113. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  114. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  115. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  116. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  117. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  118. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  119. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  120. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  121. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  122. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  123. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  124. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  125. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +63 -39
  126. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  127. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  128. data/lib/llm_cost_tracker/providers.rb +35 -0
  129. data/lib/llm_cost_tracker/railtie.rb +0 -3
  130. data/lib/llm_cost_tracker/report/data.rb +3 -4
  131. data/lib/llm_cost_tracker/report/formatter.rb +1 -1
  132. data/lib/llm_cost_tracker/report.rb +1 -1
  133. data/lib/llm_cost_tracker/retention.rb +6 -19
  134. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  135. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  136. data/lib/llm_cost_tracker/timing.rb +2 -4
  137. data/lib/llm_cost_tracker/tracker.rb +23 -36
  138. data/lib/llm_cost_tracker/usage/catalog.rb +69 -0
  139. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  140. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +6 -52
  141. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  142. data/lib/llm_cost_tracker/usage/token_usage.rb +45 -0
  143. data/lib/llm_cost_tracker/version.rb +1 -1
  144. data/lib/llm_cost_tracker.rb +43 -52
  145. data/lib/tasks/llm_cost_tracker.rake +14 -73
  146. metadata +81 -55
  147. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -100
  148. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  149. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  150. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  151. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  152. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -174
  153. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  154. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  155. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  156. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  157. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  158. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  159. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  160. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  161. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  162. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  163. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  164. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -36
  165. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -27
  166. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  167. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  168. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  169. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  170. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  171. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  172. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  173. data/lib/llm_cost_tracker/masking.rb +0 -39
  174. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -176
  175. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  176. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  177. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -230
  178. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  179. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -45
  180. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  181. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  182. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  183. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  184. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +0 -15
  185. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  186. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -131
  187. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  188. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  189. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  190. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -249
  191. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -148
  192. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +0 -40
  193. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  194. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -118
  195. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  196. data/lib/llm_cost_tracker/token_usage.rb +0 -93
@@ -1,14 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../pricing"
4
- require_relative "../billing/cost_status"
5
- require_relative "../billing/line_item"
4
+ require_relative "../charges/line_item"
6
5
  require_relative "../ledger/rollups"
7
- require_relative "../token_usage"
6
+ require_relative "../usage/token_usage"
8
7
 
9
8
  module LlmCostTracker
10
9
  module Pricing
11
- class Backfill
10
+ module Backfill
12
11
  Result = Data.define(:examined, :recomputed, :still_unknown)
13
12
  RollupEvent = Data.define(:provider, :tracked_at, :pricing_snapshot, :total_cost)
14
13
 
@@ -24,14 +23,14 @@ module LlmCostTracker
24
23
  LlmCostTracker::Call.transaction do
25
24
  batch.each do |call|
26
25
  examined += 1
27
- outcome = recompute_for(call)
28
- next unless outcome
26
+ calculation = recompute_for(call)
27
+ next unless calculation
29
28
 
30
- persist!(call, outcome)
31
- rollup_events << rollup_event_for(call, outcome)
29
+ persist!(call, calculation)
30
+ rollup_events << rollup_event_for(call, calculation)
32
31
  recomputed += 1
33
32
  end
34
- Ledger::Rollups.increment_many!(rollup_events) if rollup_events.any?
33
+ Ledger::Rollups.increment!(rollup_events) if rollup_events.any?
35
34
  end
36
35
  end
37
36
 
@@ -45,93 +44,66 @@ module LlmCostTracker
45
44
  private
46
45
 
47
46
  def recompute_for(call)
48
- token_usage = token_usage_from(call)
49
- billing_items = billing_line_items_from(call)
50
- cost_data, snapshot, priced = Pricing.calculate(
51
- provider: call.provider, model: call.model,
52
- tokens: token_usage, line_items: billing_items,
53
- pricing_mode: call.pricing_mode
47
+ calculation = Pricing::Calculation.for(
48
+ provider: call.provider,
49
+ model: call.model,
50
+ tokens: token_usage_from(call),
51
+ line_items: service_line_items_from(call),
52
+ pricing_mode: call.pricing_mode,
53
+ usage_source: call.usage_source
54
54
  )
55
- return nil unless cost_data
56
-
57
- full_cost = Pricing.combine_with_service_lines(cost_data, priced)
58
- total_cost = full_cost[:total_cost]
59
- return nil if total_cost.nil?
60
-
61
- {
62
- snapshot: snapshot,
63
- priced_line_items: priced,
64
- total_cost: total_cost,
65
- cost_status: Billing::CostStatus.call(
66
- token_usage: token_usage,
67
- usage_source: call.usage_source&.to_sym,
68
- token_cost: cost_data,
69
- token_pricing_partial: Pricing.token_pricing_partial?(token_usage, cost_data),
70
- service_line_items: priced.reject(&:token?),
71
- total_cost: total_cost
72
- )
73
- }
55
+ calculation if calculation.token_cost
74
56
  end
75
57
 
76
- def persist!(call, outcome)
58
+ def persist!(call, calculation)
77
59
  call.update!(
78
- total_cost: outcome[:total_cost],
79
- pricing_snapshot: outcome[:snapshot],
80
- cost_status: outcome[:cost_status]
60
+ total_cost: calculation.cost.total,
61
+ pricing_snapshot: calculation.snapshot,
62
+ cost_status: calculation.cost_status
81
63
  )
82
- call.line_items.to_a.zip(outcome[:priced_line_items]).each do |record, priced|
83
- next if priced.nil?
84
-
85
- record.update!(
86
- rate_amount: priced.rate_amount,
87
- rate_quantity: priced.rate_quantity,
88
- cost: priced.cost,
89
- currency: priced.currency,
90
- cost_status: priced.cost_status,
91
- price_key: priced.price_key,
92
- price_source: priced.price_source&.to_s,
93
- price_source_version: priced.price_source_version
94
- )
95
- end
64
+ token_priced = calculation.priced_line_items.select(&:token?).index_by { |item| dimension_key(item) }
65
+ service_priced = calculation.priced_line_items.reject(&:token?)
66
+ token_records, service_records = call.line_items.partition { |record| record.unit == "token" }
67
+
68
+ token_records.each { |record| apply_rate(record, token_priced[dimension_key(record)]) }
69
+ service_records.sort_by(&:position).zip(service_priced).each { |record, priced| apply_rate(record, priced) }
96
70
  end
97
71
 
98
- def rollup_event_for(call, outcome)
72
+ def apply_rate(record, priced)
73
+ return unless priced
74
+
75
+ record.update!(
76
+ rate_amount: priced.rate_amount,
77
+ rate_quantity: priced.rate_quantity,
78
+ cost: priced.cost,
79
+ currency: priced.currency,
80
+ cost_status: priced.cost_status,
81
+ price_key: priced.price_key,
82
+ price_source: priced.price_source,
83
+ price_source_version: priced.price_source_version
84
+ )
85
+ end
86
+
87
+ def dimension_key(item)
88
+ [item.kind, item.direction, item.modality, item.cache_state]
89
+ end
90
+
91
+ def rollup_event_for(call, calculation)
99
92
  RollupEvent.new(
100
93
  provider: call.provider,
101
94
  tracked_at: call.tracked_at,
102
- pricing_snapshot: outcome[:snapshot],
103
- total_cost: outcome[:total_cost]
95
+ pricing_snapshot: calculation.snapshot,
96
+ total_cost: calculation.cost.total
104
97
  )
105
98
  end
106
99
 
107
100
  def token_usage_from(call)
108
- TokenUsage.build(
109
- input_tokens: call.input_tokens,
110
- output_tokens: call.output_tokens,
111
- cache_read_input_tokens: call.cache_read_input_tokens,
112
- cache_write_input_tokens: call.cache_write_input_tokens,
113
- cache_write_extended_input_tokens: call.cache_write_extended_input_tokens,
114
- audio_input_tokens: call.audio_input_tokens,
115
- audio_output_tokens: call.audio_output_tokens,
116
- image_input_tokens: call.image_input_tokens,
117
- image_output_tokens: call.image_output_tokens,
118
- hidden_output_tokens: call.hidden_output_tokens,
119
- total_tokens: call.total_tokens
120
- )
101
+ Usage::TokenUsage.build(**call.attributes.transform_keys(&:to_sym).slice(*Usage::TokenUsage.members))
121
102
  end
122
103
 
123
- def billing_line_items_from(call)
124
- call.line_items.map do |record|
125
- Billing::LineItem.build(
126
- kind: record.kind, direction: record.direction, modality: record.modality,
127
- cache_state: record.cache_state, quantity: record.quantity, unit: record.unit,
128
- rate_amount: record.rate_amount, rate_quantity: record.rate_quantity,
129
- cost: record.cost, currency: record.currency, cost_status: record.cost_status,
130
- pricing_basis: record.pricing_basis, price_key: record.price_key,
131
- price_source: record.price_source, price_source_version: record.price_source_version,
132
- provider_field: record.provider_field, provider_item_id: record.provider_item_id,
133
- details: record.details
134
- )
104
+ def service_line_items_from(call)
105
+ call.line_items.reject { |record| record.unit == "token" }.sort_by(&:position).map do |record|
106
+ Charges::LineItem.build(record.attributes.transform_keys(&:to_sym).slice(*Charges::LineItem.members))
135
107
  end
136
108
  end
137
109
  end
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal/util"
4
+
5
+ require_relative "../usage/catalog"
6
+ require_relative "../charges/line_item"
7
+ require_relative "rate"
8
+
9
+ module LlmCostTracker
10
+ module Pricing
11
+ class Calculation
12
+ RATE_DENOMINATOR_TOKENS = Pricing::RATE_BASIS_QUANTITIES.fetch("per_million_tokens")
13
+ SNAPSHOT_SCHEMA_VERSION = 1
14
+ private_constant :RATE_DENOMINATOR_TOKENS, :SNAPSHOT_SCHEMA_VERSION
15
+
16
+ def self.for(provider:, model:, tokens:, pricing_mode:, line_items: [], usage_source: nil)
17
+ new(provider: provider,
18
+ model: model,
19
+ token_usage: Usage::TokenUsage.build_from_tokens(tokens),
20
+ line_items: line_items,
21
+ mode: Mode.normalize(pricing_mode),
22
+ usage_source: usage_source)
23
+ end
24
+
25
+ def initialize(provider:, model:, token_usage:, line_items:, mode:, usage_source: nil)
26
+ @provider = provider
27
+ @model = model
28
+ @token_usage = token_usage
29
+ @line_items = line_items
30
+ @mode = mode
31
+ @usage_source = usage_source
32
+ end
33
+
34
+ attr_reader :mode
35
+
36
+ def match
37
+ return @match if defined?(@match)
38
+
39
+ @match = Matcher.lookup(provider: @provider, model: @model)
40
+ end
41
+
42
+ def effective
43
+ return @effective if defined?(@effective)
44
+
45
+ @effective = match && EffectivePrices.call(
46
+ usage: @token_usage, quantities: quantities, prices: match.prices, pricing_mode: @mode
47
+ )
48
+ end
49
+
50
+ def token_cost
51
+ return @token_cost if defined?(@token_cost)
52
+
53
+ @token_cost = priceable? ? build_token_cost : nil
54
+ end
55
+
56
+ def priced_line_items
57
+ @priced_line_items ||= unpriced_line_items.map do |line_item|
58
+ line_item.token? ? price_token(line_item) : price_service(line_item)
59
+ end
60
+ end
61
+
62
+ def snapshot
63
+ return @snapshot if defined?(@snapshot)
64
+
65
+ @snapshot =
66
+ if priceable?
67
+ build_snapshot
68
+ elsif kept_service_lines.any?
69
+ build_service_snapshot
70
+ end
71
+ end
72
+
73
+ def cost
74
+ return @cost if defined?(@cost)
75
+
76
+ @cost = combine_service_lines
77
+ end
78
+
79
+ def cost_status
80
+ @cost_status ||= Charges::CostStatus.call(
81
+ token_usage: @token_usage,
82
+ usage_source: @usage_source,
83
+ token_cost: token_cost,
84
+ token_pricing_partial: token_pricing_partial?,
85
+ service_line_items: priced_line_items.reject(&:token?),
86
+ total_cost: cost&.total
87
+ )
88
+ end
89
+
90
+ private
91
+
92
+ def quantities
93
+ @quantities ||= @token_usage.priced_quantities
94
+ end
95
+
96
+ def unpriced_line_items
97
+ Charges::LineItem.from_token_usage(@token_usage) + @line_items.reject(&:token?)
98
+ end
99
+
100
+ def priceable?
101
+ !match.nil? && !all_billable_unpriced?
102
+ end
103
+
104
+ def all_billable_unpriced?
105
+ any_billable = false
106
+ quantities.each_pair do |key, quantity|
107
+ next unless quantity.positive?
108
+ return false if effective[key]
109
+
110
+ any_billable = true
111
+ end
112
+ any_billable
113
+ end
114
+
115
+ def priced_token_line_items
116
+ @priced_token_line_items ||= priced_line_items.select(&:token?)
117
+ end
118
+
119
+ def build_token_cost
120
+ by_dimension = priced_token_line_items.to_h { |line_item| [line_item.dimension, line_item] }
121
+ components = Usage::Catalog.token_priced.each_with_object({}) do |dimension, result|
122
+ cost = token_dimension_cost(dimension, by_dimension[dimension])
123
+ result[dimension.cost_key] = cost.round(8) unless cost.nil?
124
+ end
125
+ Charges::Cost.new(
126
+ components: components.freeze,
127
+ total: priced_token_line_items.sum(BigDecimal("0")) { |line_item| line_item.cost_value.round(8) },
128
+ currency: match.source.currency
129
+ )
130
+ end
131
+
132
+ def token_dimension_cost(dimension, line_item)
133
+ return BigDecimal("0") if quantities[dimension.key].zero?
134
+
135
+ line_item&.cost
136
+ end
137
+
138
+ def build_snapshot
139
+ {
140
+ "schema_version" => SNAPSHOT_SCHEMA_VERSION,
141
+ "source" => match.source.name,
142
+ "source_key" => match.key,
143
+ "source_version" => match.source.version,
144
+ "matched_by" => match.matched_by.to_s,
145
+ "currency" => match.source.currency,
146
+ "rates" => service_charge_rates.merge(token_charge_rates)
147
+ }
148
+ end
149
+
150
+ def build_service_snapshot
151
+ primary = kept_service_lines.first
152
+ {
153
+ "schema_version" => SNAPSHOT_SCHEMA_VERSION,
154
+ "source" => primary.price_source,
155
+ "source_version" => primary.price_source_version,
156
+ "matched_by" => "service_charges",
157
+ "currency" => cost.currency,
158
+ "rates" => service_charge_rates
159
+ }
160
+ end
161
+
162
+ def token_charge_rates
163
+ priced_token_line_items.each_with_object({}) do |line_item, rates|
164
+ next if line_item.price_key.nil? || line_item.rate_amount.nil?
165
+
166
+ rates[line_item.price_key] ||= rate_entry(line_item.rate_amount, line_item.rate_quantity)
167
+ end
168
+ end
169
+
170
+ def service_charge_rates
171
+ kept_service_lines.each_with_object({}) do |line_item, rates|
172
+ next if line_item.price_key.nil? || line_item.rate_amount.nil?
173
+
174
+ rates[line_item.price_key] ||= rate_entry(line_item.rate_amount, line_item.rate_quantity)
175
+ end
176
+ end
177
+
178
+ def rate_entry(amount, quantity)
179
+ { "amount" => amount.to_d.to_s("F"), "quantity" => Integer(quantity) }
180
+ end
181
+
182
+ def price_token(line_item)
183
+ dimension = line_item.dimension
184
+ return line_item unless dimension
185
+ return line_item.with(cost_status: Charges::CostStatus::UNKNOWN) unless priceable?
186
+
187
+ price = effective[dimension.key]
188
+ return line_item.with(cost_status: Charges::CostStatus::UNKNOWN) if price.nil?
189
+
190
+ line_item.with_rate(token_rate(dimension, price))
191
+ end
192
+
193
+ def token_rate(dimension, price)
194
+ Pricing::Rate.new(
195
+ amount: price.to_d,
196
+ quantity: RATE_DENOMINATOR_TOKENS.to_d,
197
+ currency: match.source.currency,
198
+ source: match.source.name,
199
+ source_key: dimension.key,
200
+ source_version: match.source.version
201
+ )
202
+ end
203
+
204
+ def price_service(line_item)
205
+ return line_item if line_item.priced? || !line_item.billable?
206
+
207
+ rate = model_rate(line_item) ||
208
+ ServiceRates.charge_rate(provider: @provider, dimension: line_item.kind, pricing_mode: @mode)
209
+ return line_item unless rate
210
+
211
+ line_item.with_rate(rate)
212
+ end
213
+
214
+ def model_rate(line_item)
215
+ return nil unless priceable?
216
+
217
+ amount = match.prices[line_item.kind]
218
+ return nil unless amount.is_a?(Numeric)
219
+
220
+ dimension = Usage::Catalog[line_item.kind]
221
+ Pricing::Rate.new(
222
+ amount: amount.to_d,
223
+ quantity: Pricing::RATE_BASIS_QUANTITIES.fetch(dimension.rate_basis).to_d,
224
+ currency: match.source.currency,
225
+ source: match.source.name,
226
+ source_key: "#{match.key}.#{line_item.kind}",
227
+ source_version: match.source.version
228
+ )
229
+ end
230
+
231
+ def kept_service_lines
232
+ return @kept_service_lines if defined?(@kept_service_lines)
233
+
234
+ @kept_service_lines = begin
235
+ priced_services = priced_line_items.reject(&:token?).select(&:priced?)
236
+ if priced_services.empty?
237
+ []
238
+ else
239
+ base_currency = base_currency_for(token_cost, priced_services)
240
+ matching, mismatched = priced_services.partition { |line| line.currency.to_s == base_currency.to_s }
241
+ warn_currency_mismatch(mismatched, base_currency) if mismatched.any?
242
+ matching
243
+ end
244
+ end
245
+ end
246
+
247
+ def combine_service_lines
248
+ cost = token_cost
249
+ return cost if kept_service_lines.empty?
250
+
251
+ service_total = kept_service_lines.sum(BigDecimal("0")) { |line| line.cost_value.round(8) }
252
+ Charges::Cost.new(
253
+ components: cost ? cost.components : {}.freeze,
254
+ total: (cost&.total || BigDecimal("0")) + service_total,
255
+ currency: (cost&.currency || kept_service_lines.first.currency).to_s
256
+ )
257
+ end
258
+
259
+ def base_currency_for(cost, priced_services)
260
+ cost&.currency || priced_services.first.currency || LlmCostTracker::DEFAULT_CURRENCY
261
+ end
262
+
263
+ def warn_currency_mismatch(lines, base_currency)
264
+ currencies = lines.map { |line| line.currency.to_s }.uniq.sort
265
+ Logging.warn(
266
+ "Service line currency mismatch: header is #{base_currency}, dropping " \
267
+ "#{lines.size} priced line(s) in #{currencies.join(', ')} from header total. " \
268
+ "Per-line costs are still recorded; header total reflects #{base_currency} only."
269
+ )
270
+ end
271
+
272
+ def token_pricing_partial?
273
+ return false unless token_cost
274
+
275
+ priced_token_line_items.any?(&:unpriced?)
276
+ end
277
+ end
278
+ end
279
+ end
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../billing/components"
3
+ require "bigdecimal/util"
4
+
4
5
  require_relative "mode"
6
+ require_relative "price_key"
5
7
 
6
8
  module LlmCostTracker
7
9
  module Pricing
@@ -9,7 +11,7 @@ module LlmCostTracker
9
11
  class << self
10
12
  def call(usage:, quantities:, prices:, pricing_mode:)
11
13
  context_tier = context_tier?(usage: usage, prices: prices)
12
- orderings = pricing_mode && Mode.parse(pricing_mode).permutations
14
+ orderings = pricing_mode && Mode.permutations_for(pricing_mode)
13
15
 
14
16
  quantities.to_h do |price_key, tokens|
15
17
  price = if tokens.positive?
@@ -20,7 +22,7 @@ module LlmCostTracker
20
22
  context_tier: context_tier
21
23
  )
22
24
  else
23
- 0.0
25
+ BigDecimal("0")
24
26
  end
25
27
  [price_key, price]
26
28
  end
@@ -29,45 +31,42 @@ module LlmCostTracker
29
31
  private
30
32
 
31
33
  def price_for(prices:, key:, orderings:, context_tier:)
32
- return contextual_price(prices: prices, key: key, context_tier: context_tier) unless orderings
34
+ return prices[PriceKey.build(key, above_context: context_tier)] unless orderings
33
35
 
34
36
  orderings.each do |mode|
35
- direct = contextual_price(prices: prices, key: :"#{mode}_#{key}", context_tier: context_tier)
37
+ direct = prices[PriceKey.build(key, mode: mode, above_context: context_tier)]
36
38
  return direct if direct
37
39
  end
38
- return nil if %i[input output].include?(key)
40
+ return nil if %w[input output].include?(key)
39
41
 
40
42
  derived_mode_price(prices: prices, key: key, modes: orderings, context_tier: context_tier)
41
43
  end
42
44
 
43
- def contextual_price(prices:, key:, context_tier:)
44
- return prices[key] unless context_tier
45
-
46
- prices[:"above_context_#{key}"]
47
- end
48
-
49
45
  def derived_mode_price(prices:, key:, modes:, context_tier:)
50
- standard_price = contextual_price(prices: prices, key: key, context_tier: context_tier)
51
- base_price = contextual_price(prices: prices, key: :input, context_tier: context_tier)
46
+ standard_price = prices[PriceKey.build(key, above_context: context_tier)]
47
+ base_price = prices[PriceKey.build("input", above_context: context_tier)]
52
48
  return nil unless standard_price && base_price
53
49
  return nil if base_price.zero?
54
50
 
55
51
  modes.each do |mode|
56
- mode_base_price = contextual_price(prices: prices, key: :"#{mode}_input", context_tier: context_tier)
57
- return standard_price * (mode_base_price / base_price) if mode_base_price
52
+ mode_base_price = prices[PriceKey.build("input", mode: mode, above_context: context_tier)]
53
+ next unless mode_base_price
54
+
55
+ return standard_price.to_d * mode_base_price.to_d / base_price.to_d
58
56
  end
59
57
  nil
60
58
  end
61
59
 
62
60
  def context_tier?(usage:, prices:)
63
- threshold = prices[:_context_price_threshold_tokens]
61
+ threshold = prices[Registry::CONTEXT_THRESHOLD_KEY]
64
62
  return false unless threshold
65
63
 
66
64
  input_tokens = usage.input_tokens +
67
65
  usage.cache_read_input_tokens +
68
66
  usage.cache_write_input_tokens +
69
67
  usage.cache_write_extended_input_tokens +
70
- usage.audio_input_tokens
68
+ usage.audio_input_tokens +
69
+ usage.image_input_tokens
71
70
  input_tokens > threshold
72
71
  end
73
72
  end
@@ -15,9 +15,9 @@ module LlmCostTracker
15
15
  cost_data = Pricing.cost_for(
16
16
  provider: provider,
17
17
  model: model,
18
- tokens: { input: estimated_tokens }
18
+ tokens: { input_tokens: estimated_tokens }
19
19
  )
20
- cost_data && BigDecimal(cost_data[:total_cost].to_s)
20
+ cost_data&.total
21
21
  end
22
22
 
23
23
  def self.char_count(value)
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/blank"
4
+
5
+ require_relative "registry"
6
+
7
+ module LlmCostTracker
8
+ module Pricing
9
+ module Matcher
10
+ Match = Data.define(:source, :key, :prices, :matched_by)
11
+
12
+ CACHE_LIMIT = 2048
13
+ private_constant :CACHE_LIMIT
14
+
15
+ class << self
16
+ def lookup(provider:, model:)
17
+ provider_name = provider.to_s.presence
18
+ model_name = model.to_s
19
+ return nil if model_name.empty?
20
+
21
+ sources = Registry.sources
22
+ reset_cache(sources) unless @cache_sources.equal?(sources)
23
+ key = [provider_name, model_name].freeze
24
+ return @cache[key] if @cache.key?(key)
25
+
26
+ @cache.clear if @cache.size >= CACHE_LIMIT
27
+ @cache[key] = lookup_match(sources, provider_name, model_name)
28
+ end
29
+
30
+ private
31
+
32
+ def reset_cache(sources)
33
+ @cache_sources = sources
34
+ @cache = {}
35
+ end
36
+
37
+ def lookup_match(sources, provider_name, model_name)
38
+ provider_model = provider_name ? "#{provider_name}/#{model_name}" : model_name
39
+ normalized = normalize_model_name(model_name)
40
+
41
+ sources.each do |source|
42
+ match = match_in_source(source, provider_model, model_name, normalized)
43
+ return match if match
44
+ end
45
+ nil
46
+ end
47
+
48
+ def match_in_source(source, provider_model, model_name, normalized)
49
+ table = source.prices
50
+ return nil if table.empty?
51
+
52
+ [[provider_model, :provider_model], [model_name, :model], [normalized, :normalized_model]].each do |key, by|
53
+ return build_match(source, key, by) if table.key?(key)
54
+ end
55
+
56
+ scan = native_keys(table)
57
+ if (key = unique_in(scan) { |native| normalize_model_name(native) == normalized })
58
+ return build_match(source, key, :unique_providerless_model)
59
+ end
60
+
61
+ dated = scan.find do |native|
62
+ snapshot_variant?(provider_model, native) || snapshot_variant?(normalized, native)
63
+ end
64
+ return build_match(source, dated, :dated_snapshot) if dated
65
+
66
+ unique_dated = unique_in(scan) { |native| snapshot_variant?(normalized, normalize_model_name(native)) }
67
+ return build_match(source, unique_dated, :unique_providerless_dated_snapshot) if unique_dated
68
+
69
+ nil
70
+ end
71
+
72
+ def unique_in(keys, &)
73
+ matches = keys.select(&)
74
+ matches.first if matches.one?
75
+ end
76
+
77
+ def normalize_model_name(model)
78
+ model.to_s.split("/").last
79
+ end
80
+
81
+ def native_keys(table)
82
+ Registry.sorted_price_keys(table).reject { |key| key.count("/") > 1 }
83
+ end
84
+
85
+ def build_match(source, key, matched_by)
86
+ Match.new(source: source, key: key, prices: source.prices[key], matched_by: matched_by)
87
+ end
88
+
89
+ def snapshot_variant?(model, key)
90
+ suffix = model.delete_prefix("#{key}-")
91
+ return false if suffix == model
92
+
93
+ suffix.match?(/\A(?:\d{4}-\d{2}-\d{2}|\d{8}|(?:preview|exp)-\d{2}-(?:\d{2}|\d{4}))\z/)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end