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
@@ -2,16 +2,37 @@
2
2
 
3
3
  module LlmCostTracker
4
4
  module Pricing
5
- class Mode
5
+ module Mode
6
+ STANDARD_MODE_VALUES = %w[auto default standard standard_only unspecified].freeze
6
7
  COMPOUND_MODIFIERS = %w[data_residency].freeze
8
+ KNOWN_MODIFIERS = %w[batch flex priority scale fast on_demand data_residency].freeze
9
+ MAX_PERMUTED_MODIFIERS = 6
7
10
 
8
- attr_reader :modifiers
11
+ def self.normalize(value)
12
+ return nil if value.nil?
9
13
 
10
- def self.parse(value)
11
- return value if value.is_a?(self)
12
- return new([]) if value.nil?
14
+ mode = normalize_string(value.to_s)
15
+ return nil unless mode
16
+ return nil if STANDARD_MODE_VALUES.include?(mode)
13
17
 
14
- new(tokenize(value.to_s))
18
+ warn_unknown_tokens(mode)
19
+ mode
20
+ end
21
+
22
+ def self.merge(provider_mode, request_mode)
23
+ return normalize(request_mode) if provider_mode.to_s.strip.empty?
24
+
25
+ provider_tokens = tokenize(provider_mode) - STANDARD_MODE_VALUES
26
+ request_host_tokens = tokenize(request_mode || "") & COMPOUND_MODIFIERS
27
+ combined = provider_tokens | request_host_tokens
28
+ return nil if combined.empty?
29
+
30
+ normalize(combined.join("_"))
31
+ end
32
+
33
+ def self.compose(tokens)
34
+ tokens = Array(tokens).compact.uniq
35
+ tokens.empty? ? nil : tokens.join("_")
15
36
  end
16
37
 
17
38
  def self.tokenize(value)
@@ -24,53 +45,50 @@ module LlmCostTracker
24
45
  remaining == token || remaining.start_with?("#{token}_")
25
46
  end
26
47
  if compound
27
- tokens << compound.to_sym
48
+ tokens << compound
28
49
  remaining = remaining.delete_prefix(compound).delete_prefix("_")
29
50
  else
30
51
  first, _, rest = remaining.partition("_")
31
- tokens << first.to_sym unless first.empty?
52
+ tokens << first unless first.empty?
32
53
  remaining = rest
33
54
  end
34
55
  end
35
56
  tokens
36
57
  end
37
58
 
38
- def initialize(modifiers)
39
- @modifiers = Array(modifiers).map(&:to_sym).uniq.sort
40
- freeze
41
- end
59
+ def self.permutations_for(value)
60
+ modifiers = tokenize(value).uniq.sort
61
+ return [""] if modifiers.empty?
62
+ return [modifiers.first] if modifiers.size == 1
63
+ return [modifiers.join("_")] if modifiers.size > MAX_PERMUTED_MODIFIERS
42
64
 
43
- def empty?
44
- modifiers.empty?
45
- end
46
-
47
- def include?(modifier)
48
- modifiers.include?(modifier.to_sym)
65
+ modifiers.permutation.map { |permutation| permutation.join("_") }.uniq
49
66
  end
50
67
 
51
- def canonical
52
- modifiers.join("_")
53
- end
54
- alias to_s canonical
68
+ def self.normalize_string(value)
69
+ normalized = value.strip
70
+ return nil if normalized.empty?
55
71
 
56
- def to_sym
57
- empty? ? nil : canonical.to_sym
72
+ normalized.downcase.tr("-", "_")
58
73
  end
74
+ private_class_method :normalize_string
59
75
 
60
- def permutations
61
- return [canonical] if modifiers.size <= 1
76
+ def self.warn_unknown_tokens(mode)
77
+ unknown = tokenize(mode) - KNOWN_MODIFIERS - STANDARD_MODE_VALUES
78
+ return if unknown.empty?
62
79
 
63
- modifiers.permutation.map { |permutation| permutation.join("_") }.uniq
64
- end
65
-
66
- def ==(other)
67
- other.is_a?(self.class) && modifiers == other.modifiers
68
- end
69
- alias eql? ==
80
+ @warned_tokens ||= Set.new
81
+ fresh = unknown.uniq.reject { |token| @warned_tokens.include?(token) }
82
+ return if fresh.empty?
70
83
 
71
- def hash
72
- modifiers.hash
84
+ @warned_tokens.merge(fresh)
85
+ Logging.warn(
86
+ "Unrecognized pricing_mode token(s) #{fresh.inspect} in #{mode.inspect}; " \
87
+ "the call will land with cost_status: unknown. " \
88
+ "Known pricing_mode tokens: #{KNOWN_MODIFIERS.inspect}"
89
+ )
73
90
  end
91
+ private_class_method :warn_unknown_tokens
74
92
  end
75
93
  end
76
94
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../usage/catalog"
4
+ require_relative "mode"
5
+
6
+ module LlmCostTracker
7
+ module Pricing
8
+ module PriceKey
9
+ ABOVE_CONTEXT_PREFIX = "above_context_"
10
+
11
+ class << self
12
+ def build(dimension_key, mode: nil, above_context: false)
13
+ key = mode ? "#{mode}_#{dimension_key}" : dimension_key.to_s
14
+ above_context ? "#{ABOVE_CONTEXT_PREFIX}#{key}" : key
15
+ end
16
+
17
+ def price_key_for(key)
18
+ key = key.to_s
19
+ dimension_key = strip_mode_prefix(key.delete_prefix(ABOVE_CONTEXT_PREFIX))
20
+ dimension = Usage::Catalog[dimension_key]
21
+ return nil unless dimension
22
+ return key if key == dimension_key
23
+
24
+ dimension.token_key ? key : nil
25
+ end
26
+
27
+ def parse_dimension_key(key)
28
+ name = key.to_s
29
+ exact = Usage::Catalog.all.find { |dimension| dimension.key == name }
30
+ return [exact, nil] if exact
31
+
32
+ Usage::Catalog.all.sort_by { |dimension| -dimension.key.length }.each do |dimension|
33
+ suffix = "_#{dimension.key}"
34
+ next unless name.end_with?(suffix)
35
+
36
+ tier = name.delete_suffix(suffix)
37
+ return [dimension, tier] unless tier.empty?
38
+ end
39
+ nil
40
+ end
41
+
42
+ private
43
+
44
+ def strip_mode_prefix(key)
45
+ loop do
46
+ modifier = Mode::KNOWN_MODIFIERS.find { |m| key.start_with?("#{m}_") }
47
+ break unless modifier
48
+
49
+ key = key.delete_prefix("#{modifier}_")
50
+ end
51
+ key
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Pricing
5
+ RATE_BASIS_QUANTITIES = {
6
+ "per_million_tokens" => 1_000_000,
7
+ "per_million_characters" => 1_000_000,
8
+ "per_request" => 1,
9
+ "per_1k_requests" => 1_000,
10
+ "per_session" => 1,
11
+ "per_hour" => 1,
12
+ "per_minute" => 1,
13
+ "per_image" => 1
14
+ }.freeze
15
+
16
+ Rate = Data.define(:amount, :quantity, :currency, :source, :source_key, :source_version)
17
+ end
18
+ end
@@ -1,162 +1,211 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/core_ext/object/blank"
4
+ require "bigdecimal/util"
3
5
  require "yaml"
4
6
 
5
- require_relative "../billing/components"
7
+ require_relative "../usage/catalog"
8
+ require_relative "../pricing/rate"
6
9
  require_relative "../logging"
10
+ require_relative "mode"
11
+ require_relative "price_key"
12
+ require_relative "source"
7
13
 
8
14
  module LlmCostTracker
9
15
  module Pricing
10
16
  module Registry
11
17
  DEFAULT_PRICES_PATH = File.expand_path("../prices.json", __dir__)
12
- EMPTY_PRICES = {}.freeze
13
- CONTEXT_THRESHOLD_KEY = :_context_price_threshold_tokens
14
- PRICE_KEYS = Billing::Components::TOKEN_PRICED.map { |component| component.key.name }.freeze
15
- METADATA_KEYS = [
16
- "_source", "_source_version", "_fetched_at", "_updated", "_notes", "_validator_override",
17
- CONTEXT_THRESHOLD_KEY.name
18
- ].freeze
19
- MUTEX = Mutex.new
18
+ CONTEXT_THRESHOLD_KEY = "_context_price_threshold_tokens"
19
+ PRICE_KEYS = Usage::Catalog.token_priced.map(&:key).freeze
20
+ METADATA_KEYS = ["_source", CONTEXT_THRESHOLD_KEY].freeze
20
21
 
21
22
  class << self
22
23
  def reset!
23
- MUTEX.synchronize do
24
- @builtin_prices = nil
25
- @metadata = nil
26
- @raw_registry = nil
27
- @file_prices_cache = nil
28
- end
24
+ @builtin_prices = nil
25
+ @metadata = nil
26
+ @raw_registry = nil
27
+ @raw_file_registries = nil
28
+ @file_prices = nil
29
+ @builtin_rates = nil
30
+ @file_rates = nil
31
+ @sources = nil
32
+ @sorted_price_keys_cache = nil
33
+ @prices_file_mtime_iso = nil
29
34
  end
30
35
 
31
36
  def builtin_prices
32
- cached = @builtin_prices
33
- return cached if cached
34
-
35
- MUTEX.synchronize do
36
- @builtin_prices ||= begin
37
- registry = @raw_registry ||= load_raw_registry
38
- normalize_price_table(registry.fetch("models", {})).freeze
39
- end
40
- end
37
+ @builtin_prices ||= normalize_price_entries(
38
+ raw_registry.fetch("models", {}), context: "bundled prices"
39
+ ).freeze
41
40
  end
42
41
 
43
42
  def metadata
44
- cached = @metadata
45
- return cached if cached
46
-
47
- MUTEX.synchronize do
48
- @metadata ||= begin
49
- registry = @raw_registry ||= load_raw_registry
50
- registry.fetch("metadata", {}).freeze
51
- end
52
- end
43
+ @metadata ||= raw_registry.fetch("metadata", {}).freeze
53
44
  end
54
45
 
55
46
  def file_metadata(path)
56
47
  return {} unless path
57
48
 
58
- registry = YAML.safe_load_file(path, aliases: false) || {}
49
+ meta = raw_file_registry(path).fetch("metadata", {})
50
+ return meta if meta.is_a?(Hash)
59
51
 
60
- metadata = registry.fetch("metadata", {})
61
- raise ArgumentError, "prices_file metadata must be a hash" unless metadata.is_a?(Hash)
52
+ raise Error, "Unable to load prices_file #{path.inspect}: prices_file metadata must be a hash"
53
+ end
62
54
 
63
- metadata
64
- rescue Errno::ENOENT, Psych::Exception, ArgumentError, TypeError => e
65
- raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
55
+ def file_prices(path)
56
+ return {} unless path
57
+
58
+ prices, @file_prices = memoize_in(@file_prices, path) { load_file_prices(path) }
59
+ prices
60
+ end
61
+
62
+ def normalize_price_entries(table, context:)
63
+ table = {} if table.nil?
64
+ raise ArgumentError, "#{context} must be a hash of models" unless table.is_a?(Hash)
65
+
66
+ table.each_with_object({}) do |(model, price), normalized|
67
+ price = validate_price_entry(price, model: model, context: context)
68
+ normalized[model.to_s] = normalize_price_entry(model, price, context)
69
+ end
66
70
  end
67
71
 
68
- def normalize_price_table(table)
69
- normalize_price_entries(table, context: "price table")
72
+ def raw_registry
73
+ @raw_registry ||= YAML.safe_load_file(DEFAULT_PRICES_PATH, aliases: false).freeze
70
74
  end
71
75
 
72
- def file_prices(path)
73
- return EMPTY_PRICES unless path
76
+ def raw_file_registry(path)
77
+ registry, @raw_file_registries = memoize_in(@raw_file_registries, path) { load_raw_file_registry(path) }
78
+ registry
79
+ end
80
+
81
+ def builtin_rates
82
+ @builtin_rates ||= rates_from_registry(raw_registry, context: DEFAULT_PRICES_PATH).freeze
83
+ end
84
+
85
+ def file_rates(path)
86
+ return {} unless path
74
87
 
75
- cache_key = [path, File.mtime(path)]
76
- cached = @file_prices_cache
77
- return cached[:value] if cached && cached[:key] == cache_key
88
+ rates, @file_rates = memoize_in(@file_rates, path) { load_file_rates(path) }
89
+ rates
90
+ end
78
91
 
79
- MUTEX.synchronize do
80
- cached = @file_prices_cache
81
- return cached[:value] if cached && cached[:key] == cache_key
92
+ def rates_from_registry(registry, context:)
93
+ data = registry.fetch("service_charges", {})
94
+ raise ArgumentError, "#{context} service_charges must be a hash" unless data.is_a?(Hash)
82
95
 
83
- registry = YAML.safe_load_file(path, aliases: false) || {}
84
- value = normalize_price_entries(registry.fetch("models", registry), context: path).freeze
85
- @file_prices_cache = { key: cache_key, value: value }.freeze
86
- value
96
+ currency = upcased_currency(registry.dig("metadata", "currency"))
97
+ data.each_with_object({}) do |(provider, entries), rates|
98
+ section_context = "#{context} service_charges.#{provider}"
99
+ rates[provider] = rates_from_section(entries, currency: currency, context: section_context)
87
100
  end
88
- rescue Errno::ENOENT, Psych::Exception, ArgumentError, TypeError => e
89
- raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
90
101
  end
91
102
 
92
- private
103
+ def prices_file_mtime_iso
104
+ path = LlmCostTracker.configuration.prices_file
105
+ return nil unless path && File.exist?(path)
93
106
 
94
- def load_raw_registry
95
- YAML.safe_load_file(DEFAULT_PRICES_PATH, aliases: false).freeze
107
+ @prices_file_mtime_iso ||= File.mtime(path).utc.iso8601
96
108
  end
97
109
 
98
- def normalize_price_entry(price)
99
- price.each_with_object({}) do |(key, value), normalized|
100
- key = registry_key_for(key)
101
- if key == CONTEXT_THRESHOLD_KEY
102
- normalized[key] = Integer(value)
103
- elsif key
104
- normalized[key] = non_negative_float(key, value)
105
- end
110
+ def sources
111
+ @sources ||= begin
112
+ config = LlmCostTracker.configuration
113
+ [
114
+ Source.new(
115
+ name: "pricing_overrides",
116
+ prices: config.pricing_overrides,
117
+ rates: {},
118
+ currency: upcased_currency(nil),
119
+ version: "configuration"
120
+ ),
121
+ Source.new(
122
+ name: "prices_file",
123
+ prices: file_prices(config.prices_file),
124
+ rates: file_rates(config.prices_file),
125
+ currency: upcased_currency(file_metadata(config.prices_file)["currency"]),
126
+ version: prices_file_mtime_iso
127
+ ),
128
+ Source.new(
129
+ name: "bundled",
130
+ prices: builtin_prices,
131
+ rates: builtin_rates,
132
+ currency: upcased_currency(metadata["currency"]),
133
+ version: LlmCostTracker::VERSION
134
+ )
135
+ ].freeze
106
136
  end
107
137
  end
108
138
 
109
- def non_negative_float(key, value)
110
- rate = Float(value)
111
- raise ArgumentError, "price for #{key.inspect} must be finite (got #{rate})" unless rate.finite?
112
- raise ArgumentError, "price for #{key.inspect} must be non-negative (got #{rate})" if rate.negative?
139
+ def sorted_price_keys(table)
140
+ keys, @sorted_price_keys_cache =
141
+ memoize_in(@sorted_price_keys_cache, table, identity: true) { table.keys.sort_by { |key| -key.length } }
142
+ keys
143
+ end
144
+
145
+ private
113
146
 
114
- rate
147
+ def memoize_in(cache, key, identity: false)
148
+ existing = cache && cache[key]
149
+ return [existing, cache] if existing
150
+
151
+ value = yield
152
+ next_cache = cache&.dup || (identity ? {}.compare_by_identity : {})
153
+ next_cache[key] = value
154
+ [value, next_cache.freeze]
115
155
  end
116
156
 
117
- def normalize_price_entries(table, context:)
118
- table = {} if table.nil?
119
- raise ArgumentError, "#{context} must be a hash of models" unless table.is_a?(Hash)
157
+ def loading(path)
158
+ yield
159
+ rescue Errno::ENOENT, Psych::Exception, ArgumentError, TypeError => e
160
+ raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
161
+ end
120
162
 
121
- table.each_with_object({}) do |(model, price), normalized|
122
- price = validate_price_entry(price, model: model, context: context)
123
- warn_unknown_keys(model, price, context)
124
- normalized[model.to_s] = normalize_price_entry(price)
163
+ def load_raw_file_registry(path)
164
+ loading(path) { (YAML.safe_load_file(path, aliases: false) || {}).freeze }
165
+ end
166
+
167
+ def load_file_prices(path)
168
+ loading(path) do
169
+ doc = raw_file_registry(path)
170
+ normalize_price_entries(doc.fetch("models", doc), context: path).freeze
125
171
  end
126
172
  end
127
173
 
128
- def warn_unknown_keys(model, price, path)
129
- unknown_keys = price.keys.reject do |key|
130
- registry_key_for(key) || METADATA_KEYS.include?(key)
174
+ def normalize_price_entry(model, price, context)
175
+ unknown = []
176
+ normalized = price.each_with_object({}) do |(key, value), acc|
177
+ registry_key = registry_key_for(key)
178
+ if registry_key == CONTEXT_THRESHOLD_KEY
179
+ acc[registry_key] = Integer(value)
180
+ elsif registry_key
181
+ acc[registry_key] = non_negative_decimal(value, label: "price for #{registry_key.inspect}")
182
+ elsif !METADATA_KEYS.include?(key)
183
+ unknown << key
184
+ end
131
185
  end
132
- return if unknown_keys.empty?
186
+ warn_unknown_keys(model, unknown, context) unless unknown.empty?
187
+ normalized
188
+ end
189
+
190
+ def non_negative_decimal(value, label:)
191
+ decimal = BigDecimal(value.to_s)
192
+ raise ArgumentError, "#{label} must be finite (got #{value})" unless decimal.finite?
193
+ raise ArgumentError, "#{label} must be non-negative (got #{value})" if decimal.negative?
133
194
 
195
+ decimal
196
+ end
197
+
198
+ def warn_unknown_keys(model, unknown_keys, path)
134
199
  Logging.warn(
135
200
  "Unknown price keys #{unknown_keys.inspect} for #{model.inspect} in #{path}; " \
136
201
  "ignored. Known keys: #{(PRICE_KEYS + METADATA_KEYS).inspect}; mode-specific keys use mode_input"
137
202
  )
138
203
  end
139
204
 
140
- def price_key_for(key)
141
- name = key.is_a?(Symbol) ? key.name : key
142
- Billing::Components::REGISTRY.each do |candidate|
143
- return candidate.key if candidate.key.name == name
144
- next unless candidate.token_key
145
-
146
- suffix = "_#{candidate.key.name}"
147
- next unless name.end_with?(suffix)
148
-
149
- prefix = name.delete_suffix(suffix)
150
- return :"#{prefix}_#{candidate.key.name}" unless prefix.empty?
151
- end
152
-
153
- nil
154
- end
155
-
156
205
  def registry_key_for(key)
157
- return CONTEXT_THRESHOLD_KEY if key == CONTEXT_THRESHOLD_KEY || key == CONTEXT_THRESHOLD_KEY.name
206
+ return CONTEXT_THRESHOLD_KEY if key.to_s == CONTEXT_THRESHOLD_KEY
158
207
 
159
- price_key_for(key)
208
+ PriceKey.price_key_for(key)
160
209
  end
161
210
 
162
211
  def validate_price_entry(price, model:, context:)
@@ -165,6 +214,46 @@ module LlmCostTracker
165
214
 
166
215
  raise ArgumentError, "price entry for #{model.inspect} in #{context} must be a hash"
167
216
  end
217
+
218
+ def load_file_rates(path)
219
+ loading(path) { rates_from_registry(raw_file_registry(path), context: path).freeze }
220
+ end
221
+
222
+ def rates_from_section(entries, currency:, context:)
223
+ raise ArgumentError, "#{context} must be a hash" unless entries.is_a?(Hash)
224
+
225
+ entries.each_with_object({}) do |(key, amount), rates|
226
+ key = key.to_s
227
+ dimension, tier = dimension_and_tier_for(key, context: context)
228
+ amount = non_negative_decimal(amount, label: "service charge price amount for #{key.inspect} in #{context}")
229
+
230
+ rate = {
231
+ amount: amount,
232
+ quantity: rate_quantity(dimension),
233
+ currency: currency,
234
+ source_key: key
235
+ }
236
+ dimension_rates = rates[dimension.key] ||= { tiers: {} }
237
+ (tier ? dimension_rates[:tiers] : dimension_rates)[tier || :default] = rate
238
+ end
239
+ end
240
+
241
+ def dimension_and_tier_for(key, context:)
242
+ dimension, tier = PriceKey.parse_dimension_key(key)
243
+ unless dimension && dimension.token_key.nil?
244
+ raise ArgumentError, "service charge price key #{key.inspect} in #{context} uses unknown billing dimension"
245
+ end
246
+
247
+ [dimension, tier]
248
+ end
249
+
250
+ def rate_quantity(dimension)
251
+ Pricing::RATE_BASIS_QUANTITIES.fetch(dimension.rate_basis).to_d
252
+ end
253
+
254
+ def upcased_currency(value)
255
+ (value || LlmCostTracker::DEFAULT_CURRENCY).upcase
256
+ end
168
257
  end
169
258
  end
170
259
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/blank"
4
+
5
+ require_relative "../usage/catalog"
6
+ require_relative "registry"
7
+ require_relative "rate"
8
+ require_relative "mode"
9
+
10
+ module LlmCostTracker
11
+ module Pricing
12
+ module ServiceRates
13
+ class << self
14
+ def charge_rate(provider:, dimension:, pricing_mode:)
15
+ pricing_mode = Mode.normalize(pricing_mode)
16
+ provider_name = provider.to_s.presence
17
+ return nil unless provider_name
18
+
19
+ dimension_key = charge_dimension_key(dimension)
20
+ Registry.sources.each do |source|
21
+ provider_rates = source.rates.fetch(provider_name, {})
22
+ rate = rate_for(provider_rates, dimension_key: dimension_key, pricing_mode: pricing_mode)
23
+ next unless rate
24
+
25
+ return Pricing::Rate.new(
26
+ amount: rate.fetch(:amount),
27
+ quantity: rate.fetch(:quantity),
28
+ currency: rate.fetch(:currency),
29
+ source: source.name,
30
+ source_key: "service_charges.#{provider_name}.#{rate.fetch(:source_key)}",
31
+ source_version: source.version
32
+ )
33
+ end
34
+ nil
35
+ end
36
+
37
+ private
38
+
39
+ def rate_for(provider_table, dimension_key:, pricing_mode:)
40
+ dimension_rates = provider_table.fetch(dimension_key, {})
41
+ tier_rates = dimension_rates.fetch(:tiers, {})
42
+ if pricing_mode
43
+ rate = tier_rates[pricing_mode]
44
+ return rate if rate
45
+
46
+ tier_rates.each do |candidate, candidate_rate|
47
+ return candidate_rate if tier_includes?(pricing_mode, candidate)
48
+ end
49
+ end
50
+ dimension_rates[:default]
51
+ end
52
+
53
+ def tier_includes?(tier_name, candidate_name)
54
+ tier_name == candidate_name ||
55
+ tier_name.start_with?("#{candidate_name}_") ||
56
+ tier_name.end_with?("_#{candidate_name}") ||
57
+ tier_name.include?("_#{candidate_name}_")
58
+ end
59
+
60
+ def charge_dimension_key(dimension)
61
+ billing_dimension = Usage::Catalog[dimension]
62
+ return billing_dimension.key if billing_dimension && billing_dimension.token_key.nil?
63
+
64
+ raise Error, "Unknown billing dimension: #{dimension.inspect}"
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Pricing
5
+ Source = Data.define(:name, :prices, :rates, :currency, :version)
6
+ end
7
+ end
@@ -13,7 +13,7 @@ module LlmCostTracker
13
13
  module Pricing
14
14
  module Sync
15
15
  class Fetcher
16
- Response = Data.define(:body, :etag, :last_modified, :not_modified, :fetched_at) do
16
+ Response = Data.define(:body, :etag, :last_modified, :not_modified) do
17
17
  def source_version
18
18
  etag || last_modified || Digest::SHA256.hexdigest(body.to_s)
19
19
  end
@@ -106,8 +106,7 @@ module LlmCostTracker
106
106
  body: body,
107
107
  etag: response["etag"],
108
108
  last_modified: response["last-modified"],
109
- not_modified: not_modified,
110
- fetched_at: Time.now.utc.iso8601
109
+ not_modified: not_modified
111
110
  )
112
111
  end
113
112
  end