llm_cost_tracker 0.10.0 → 0.12.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 (209) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +11 -5
  4. data/app/assets/llm_cost_tracker/application.css +784 -802
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +14 -2
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +28 -21
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -4
  8. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  9. data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +19 -16
  12. data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  14. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
  15. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  16. data/app/models/llm_cost_tracker/call.rb +28 -63
  17. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  18. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  19. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  20. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
  22. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  23. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  24. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  25. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  27. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +81 -0
  28. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +6 -68
  29. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  30. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +20 -12
  31. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  32. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  33. data/app/views/layouts/llm_cost_tracker/application.html.erb +74 -17
  34. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  35. data/app/views/llm_cost_tracker/calls/show.html.erb +132 -125
  36. data/app/views/llm_cost_tracker/dashboard/index.html.erb +120 -159
  37. data/app/views/llm_cost_tracker/data_quality/index.html.erb +140 -194
  38. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  39. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  40. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  41. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  42. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  43. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  44. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  45. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  46. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  47. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  48. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  49. data/app/views/llm_cost_tracker/tags/show.html.erb +85 -104
  50. data/config/routes.rb +3 -3
  51. data/lib/llm_cost_tracker/budget.rb +25 -28
  52. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  53. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +2 -1
  54. data/lib/llm_cost_tracker/capture/stream_collector.rb +30 -52
  55. data/lib/llm_cost_tracker/capture/stream_tracker.rb +18 -33
  56. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  57. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  58. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  59. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
  60. data/lib/llm_cost_tracker/check.rb +5 -0
  61. data/lib/llm_cost_tracker/configuration.rb +13 -61
  62. data/lib/llm_cost_tracker/currency.rb +5 -0
  63. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  64. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  65. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  66. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  67. data/lib/llm_cost_tracker/doctor.rb +66 -64
  68. data/lib/llm_cost_tracker/engine.rb +4 -4
  69. data/lib/llm_cost_tracker/event.rb +12 -20
  70. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  71. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  72. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  73. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
  74. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +4 -0
  75. data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
  76. data/lib/llm_cost_tracker/ingestion/inbox.rb +8 -9
  77. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  78. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  79. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  80. data/lib/llm_cost_tracker/integrations/anthropic.rb +94 -116
  81. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  82. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  83. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  84. data/lib/llm_cost_tracker/integrations/openai.rb +72 -332
  85. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +89 -145
  86. data/lib/llm_cost_tracker/integrations.rb +32 -25
  87. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  88. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  89. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  90. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  91. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  92. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  93. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  94. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  95. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  96. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  97. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  98. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  99. data/lib/llm_cost_tracker/ledger/store.rb +18 -42
  100. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  101. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  102. data/lib/llm_cost_tracker/ledger.rb +14 -11
  103. data/lib/llm_cost_tracker/logging.rb +4 -21
  104. data/lib/llm_cost_tracker/middleware/faraday.rb +63 -51
  105. data/lib/llm_cost_tracker/parsers.rb +140 -29
  106. data/lib/llm_cost_tracker/prices.json +1707 -1
  107. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  108. data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
  109. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  110. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  111. data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
  112. data/lib/llm_cost_tracker/pricing/mode.rb +53 -35
  113. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  114. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  115. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  116. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  117. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  118. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  119. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  120. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  121. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  122. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  123. data/lib/llm_cost_tracker/pricing.rb +10 -295
  124. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  125. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  126. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  127. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  128. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  129. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  130. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  131. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  132. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  133. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  134. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  135. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  136. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +181 -0
  137. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  138. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  139. data/lib/llm_cost_tracker/providers.rb +35 -0
  140. data/lib/llm_cost_tracker/railtie.rb +0 -7
  141. data/lib/llm_cost_tracker/report/data.rb +3 -4
  142. data/lib/llm_cost_tracker/report/formatter.rb +33 -20
  143. data/lib/llm_cost_tracker/report.rb +1 -1
  144. data/lib/llm_cost_tracker/retention.rb +6 -19
  145. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  146. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  147. data/lib/llm_cost_tracker/timing.rb +2 -4
  148. data/lib/llm_cost_tracker/tracker.rb +24 -36
  149. data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
  150. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  151. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
  152. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  153. data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
  154. data/lib/llm_cost_tracker/version.rb +1 -1
  155. data/lib/llm_cost_tracker.rb +43 -52
  156. data/lib/tasks/llm_cost_tracker.rake +14 -73
  157. metadata +92 -58
  158. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -106
  159. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  160. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  161. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  162. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  163. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -183
  164. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  165. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  166. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  167. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  168. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  169. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  170. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  171. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  172. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  173. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  174. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  175. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  176. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  177. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  178. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -32
  179. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -25
  180. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  181. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  182. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  183. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  184. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  185. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  186. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  187. data/lib/llm_cost_tracker/masking.rb +0 -39
  188. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -193
  189. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  190. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  191. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -232
  192. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  193. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -51
  194. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -155
  195. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  196. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  197. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  198. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  199. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  200. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -134
  201. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  202. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  203. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  204. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -254
  205. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -172
  206. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  207. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -142
  208. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  209. 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,260 @@
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
+ private_constant :RATE_DENOMINATOR_TOKENS
14
+
15
+ def self.for(provider:, model:, tokens:, pricing_mode:, line_items: [], usage_source: nil)
16
+ new(provider: provider,
17
+ model: model,
18
+ token_usage: Usage::TokenUsage.build_from_tokens(tokens),
19
+ line_items: line_items,
20
+ mode: Mode.normalize(pricing_mode),
21
+ usage_source: usage_source)
22
+ end
23
+
24
+ def initialize(provider:, model:, token_usage:, line_items:, mode:, usage_source: nil)
25
+ @provider = provider
26
+ @model = model
27
+ @token_usage = token_usage
28
+ @line_items = line_items
29
+ @mode = mode
30
+ @usage_source = usage_source
31
+ end
32
+
33
+ attr_reader :mode
34
+
35
+ def match
36
+ return @match if defined?(@match)
37
+
38
+ @match = Matcher.lookup(provider: @provider, model: @model)
39
+ end
40
+
41
+ def effective
42
+ return @effective if defined?(@effective)
43
+
44
+ @effective = match && EffectivePrices.call(
45
+ usage: @token_usage, quantities: quantities, prices: match.prices, pricing_mode: @mode
46
+ )
47
+ end
48
+
49
+ def token_cost
50
+ return @token_cost if defined?(@token_cost)
51
+
52
+ @token_cost = priceable? ? build_token_cost : nil
53
+ end
54
+
55
+ def priced_line_items
56
+ @priced_line_items ||= unpriced_line_items.map do |line_item|
57
+ line_item.token? ? price_token(line_item) : price_service(line_item)
58
+ end
59
+ end
60
+
61
+ def snapshot
62
+ return @snapshot if defined?(@snapshot)
63
+
64
+ @snapshot = priceable? ? build_snapshot : nil
65
+ end
66
+
67
+ def cost
68
+ return @cost if defined?(@cost)
69
+
70
+ @cost = combine_service_lines
71
+ end
72
+
73
+ def cost_status
74
+ @cost_status ||= Charges::CostStatus.call(
75
+ token_usage: @token_usage,
76
+ usage_source: @usage_source,
77
+ token_cost: token_cost,
78
+ token_pricing_partial: token_pricing_partial?,
79
+ service_line_items: priced_line_items.reject(&:token?),
80
+ total_cost: cost&.total
81
+ )
82
+ end
83
+
84
+ private
85
+
86
+ def quantities
87
+ @quantities ||= @token_usage.priced_quantities
88
+ end
89
+
90
+ def unpriced_line_items
91
+ Charges::LineItem.from_token_usage(@token_usage) + @line_items.reject(&:token?)
92
+ end
93
+
94
+ def priceable?
95
+ !match.nil? && !all_billable_unpriced?
96
+ end
97
+
98
+ def all_billable_unpriced?
99
+ any_billable = false
100
+ quantities.each_pair do |key, quantity|
101
+ next unless quantity.positive?
102
+ return false if effective[key]
103
+
104
+ any_billable = true
105
+ end
106
+ any_billable
107
+ end
108
+
109
+ def priced_token_line_items
110
+ @priced_token_line_items ||= priced_line_items.select(&:token?)
111
+ end
112
+
113
+ def build_token_cost
114
+ by_dimension = priced_token_line_items.to_h { |line_item| [line_item.dimension, line_item] }
115
+ components = Usage::Catalog.token_priced.each_with_object({}) do |dimension, result|
116
+ cost = token_dimension_cost(dimension, by_dimension[dimension])
117
+ result[dimension.cost_key] = cost.round(8) unless cost.nil?
118
+ end
119
+ Charges::Cost.new(
120
+ components: components.freeze,
121
+ total: priced_token_line_items.sum(BigDecimal("0")) { |line_item| line_item.cost_value.round(8) },
122
+ currency: match.source.currency
123
+ )
124
+ end
125
+
126
+ def token_dimension_cost(dimension, line_item)
127
+ return BigDecimal("0") if quantities[dimension.key].zero?
128
+
129
+ line_item&.cost
130
+ end
131
+
132
+ def build_snapshot
133
+ {
134
+ "schema_version" => 1,
135
+ "source" => match.source.name,
136
+ "source_key" => match.key,
137
+ "source_version" => match.source.version,
138
+ "matched_by" => match.matched_by.to_s,
139
+ "currency" => match.source.currency,
140
+ "rates" => service_charge_rates.merge(token_charge_rates)
141
+ }
142
+ end
143
+
144
+ def token_charge_rates
145
+ priced_token_line_items.each_with_object({}) do |line_item, rates|
146
+ next if line_item.price_key.nil? || line_item.rate_amount.nil?
147
+
148
+ rates[line_item.price_key] ||= rate_entry(line_item.rate_amount, line_item.rate_quantity)
149
+ end
150
+ end
151
+
152
+ def service_charge_rates
153
+ priced_line_items.each_with_object({}) do |line_item, rates|
154
+ next if line_item.token? || line_item.price_key.nil? || line_item.rate_amount.nil?
155
+
156
+ rates[line_item.price_key] ||= rate_entry(line_item.rate_amount, line_item.rate_quantity)
157
+ end
158
+ end
159
+
160
+ def rate_entry(amount, quantity)
161
+ { "amount" => amount.to_d.to_s("F"), "quantity" => Integer(quantity) }
162
+ end
163
+
164
+ def price_token(line_item)
165
+ dimension = dimension_for(line_item)
166
+ return line_item unless dimension
167
+ return line_item.with(cost_status: Charges::CostStatus::UNKNOWN) unless priceable?
168
+
169
+ price = effective[dimension.key]
170
+ return line_item.with(cost_status: Charges::CostStatus::UNKNOWN) if price.nil?
171
+
172
+ line_item.with_rate(token_rate(dimension, price))
173
+ end
174
+
175
+ def token_rate(dimension, price)
176
+ Pricing::Rate.new(
177
+ amount: price.to_d,
178
+ quantity: RATE_DENOMINATOR_TOKENS.to_d,
179
+ currency: match.source.currency,
180
+ source: match.source.name,
181
+ source_key: dimension.key,
182
+ source_version: match.source.version
183
+ )
184
+ end
185
+
186
+ def price_service(line_item)
187
+ return line_item if line_item.priced? || !line_item.billable?
188
+
189
+ rate = model_rate(line_item) ||
190
+ ServiceRates.charge_rate(provider: @provider, dimension: line_item.kind, pricing_mode: @mode)
191
+ return line_item unless rate
192
+
193
+ line_item.with_rate(rate)
194
+ end
195
+
196
+ def model_rate(line_item)
197
+ return nil unless priceable?
198
+
199
+ amount = match.prices[line_item.kind]
200
+ return nil unless amount.is_a?(Numeric)
201
+
202
+ dimension = Usage::Catalog[line_item.kind]
203
+ Pricing::Rate.new(
204
+ amount: amount.to_d,
205
+ quantity: Pricing::RATE_BASIS_QUANTITIES.fetch(dimension.rate_basis).to_d,
206
+ currency: match.source.currency,
207
+ source: match.source.name,
208
+ source_key: "#{match.key}.#{line_item.kind}",
209
+ source_version: match.source.version
210
+ )
211
+ end
212
+
213
+ def dimension_for(line_item)
214
+ Usage::Catalog.all.find do |dimension|
215
+ dimension.kind == line_item.kind &&
216
+ dimension.direction == line_item.direction &&
217
+ dimension.modality == line_item.modality &&
218
+ dimension.cache_state == line_item.cache_state &&
219
+ dimension.unit == line_item.unit
220
+ end
221
+ end
222
+
223
+ def combine_service_lines
224
+ cost = token_cost
225
+ priced_services = priced_line_items.reject(&:token?).select(&:priced?)
226
+ return cost if priced_services.empty?
227
+
228
+ base_currency = base_currency_for(cost, priced_services)
229
+ matching, mismatched = priced_services.partition { |line| line.currency.to_s == base_currency.to_s }
230
+ warn_currency_mismatch(mismatched, base_currency) if mismatched.any?
231
+
232
+ service_total = matching.sum(BigDecimal("0")) { |line| line.cost_value.round(8) }
233
+ Charges::Cost.new(
234
+ components: cost ? cost.components : {}.freeze,
235
+ total: (cost&.total || BigDecimal("0")) + service_total,
236
+ currency: (cost&.currency || base_currency).to_s
237
+ )
238
+ end
239
+
240
+ def base_currency_for(cost, priced_services)
241
+ cost&.currency || priced_services.first.currency || LlmCostTracker::DEFAULT_CURRENCY
242
+ end
243
+
244
+ def warn_currency_mismatch(lines, base_currency)
245
+ currencies = lines.map { |line| line.currency.to_s }.uniq.sort
246
+ Logging.warn(
247
+ "Service line currency mismatch: header is #{base_currency}, dropping " \
248
+ "#{lines.size} priced line(s) in #{currencies.join(', ')} from header total. " \
249
+ "Per-line costs are still recorded; header total reflects #{base_currency} only."
250
+ )
251
+ end
252
+
253
+ def token_pricing_partial?
254
+ return false unless token_cost
255
+
256
+ priced_token_line_items.any?(&:unpriced?)
257
+ end
258
+ end
259
+ end
260
+ 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,84 @@
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
+ class << self
13
+ def lookup(provider:, model:)
14
+ provider_name = provider.to_s.presence
15
+ model_name = model.to_s
16
+ return nil if model_name.empty?
17
+
18
+ lookup_match(provider_name: provider_name, model_name: model_name)
19
+ end
20
+
21
+ private
22
+
23
+ def lookup_match(provider_name:, model_name:)
24
+ provider_model = provider_name ? "#{provider_name}/#{model_name}" : model_name
25
+ normalized = normalize_model_name(model_name)
26
+
27
+ Registry.sources.each do |source|
28
+ match = match_in_source(source, provider_model, model_name, normalized)
29
+ return match if match
30
+ end
31
+ nil
32
+ end
33
+
34
+ def match_in_source(source, provider_model, model_name, normalized)
35
+ table = source.prices
36
+ return nil if table.empty?
37
+
38
+ [[provider_model, :provider_model], [model_name, :model], [normalized, :normalized_model]].each do |key, by|
39
+ return build_match(source, key, by) if table.key?(key)
40
+ end
41
+
42
+ scan = native_keys(table)
43
+ if (key = unique_in(scan) { |native| normalize_model_name(native) == normalized })
44
+ return build_match(source, key, :unique_providerless_model)
45
+ end
46
+
47
+ dated = scan.find do |native|
48
+ snapshot_variant?(provider_model, native) || snapshot_variant?(normalized, native)
49
+ end
50
+ return build_match(source, dated, :dated_snapshot) if dated
51
+
52
+ unique_dated = unique_in(scan) { |native| snapshot_variant?(normalized, normalize_model_name(native)) }
53
+ return build_match(source, unique_dated, :unique_providerless_dated_snapshot) if unique_dated
54
+
55
+ nil
56
+ end
57
+
58
+ def unique_in(keys, &)
59
+ matches = keys.select(&)
60
+ matches.first if matches.one?
61
+ end
62
+
63
+ def normalize_model_name(model)
64
+ model.to_s.split("/").last
65
+ end
66
+
67
+ def native_keys(table)
68
+ Registry.sorted_price_keys(table).reject { |key| key.count("/") > 1 }
69
+ end
70
+
71
+ def build_match(source, key, matched_by)
72
+ Match.new(source: source, key: key, prices: source.prices[key], matched_by: matched_by)
73
+ end
74
+
75
+ def snapshot_variant?(model, key)
76
+ suffix = model.delete_prefix("#{key}-")
77
+ return false if suffix == model
78
+
79
+ suffix.match?(/\A(?:\d{4}-\d{2}-\d{2}|\d{8}|(?:preview|exp)-\d{2}-(?:\d{2}|\d{4}))\z/)
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end