llm_cost_tracker 0.7.0 → 0.7.2

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 (174) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +21 -16
  4. data/app/assets/llm_cost_tracker/application.css +3 -0
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +22 -4
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +6 -11
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +2 -1
  8. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +5 -1
  9. data/app/controllers/llm_cost_tracker/models_controller.rb +0 -1
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +1 -8
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +2 -1
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +1 -2
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -1
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +10 -27
  15. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +58 -0
  16. data/app/models/llm_cost_tracker/ingestion/event.rb +13 -0
  17. data/app/models/llm_cost_tracker/ingestion/lease.rb +11 -0
  18. data/app/models/llm_cost_tracker/ledger/call.rb +45 -0
  19. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +66 -0
  20. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +71 -0
  21. data/app/models/llm_cost_tracker/ledger/period/total.rb +13 -0
  22. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +19 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +111 -94
  24. data/app/services/llm_cost_tracker/dashboard/date_range.rb +2 -2
  25. data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -18
  26. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +58 -67
  27. data/app/services/llm_cost_tracker/dashboard/pagination.rb +59 -0
  28. data/app/services/llm_cost_tracker/dashboard/params.rb +26 -0
  29. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +18 -20
  30. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -13
  31. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +28 -61
  32. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +8 -21
  33. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  34. data/app/services/llm_cost_tracker/dashboard/top_models.rb +12 -47
  35. data/app/views/llm_cost_tracker/calls/index.html.erb +12 -18
  36. data/app/views/llm_cost_tracker/calls/show.html.erb +30 -32
  37. data/app/views/llm_cost_tracker/dashboard/index.html.erb +17 -19
  38. data/app/views/llm_cost_tracker/data_quality/index.html.erb +108 -135
  39. data/app/views/llm_cost_tracker/models/index.html.erb +8 -9
  40. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +13 -2
  41. data/app/views/llm_cost_tracker/tags/show.html.erb +20 -20
  42. data/lib/llm_cost_tracker/budget.rb +8 -20
  43. data/lib/llm_cost_tracker/capture/stream.rb +9 -0
  44. data/lib/llm_cost_tracker/capture/stream_collector.rb +189 -0
  45. data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +41 -73
  46. data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
  47. data/lib/llm_cost_tracker/configuration.rb +33 -36
  48. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +61 -0
  49. data/lib/llm_cost_tracker/doctor/check.rb +7 -0
  50. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +22 -59
  51. data/lib/llm_cost_tracker/doctor/price_check.rb +60 -0
  52. data/lib/llm_cost_tracker/doctor.rb +63 -71
  53. data/lib/llm_cost_tracker/errors.rb +4 -15
  54. data/lib/llm_cost_tracker/event.rb +6 -6
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +42 -0
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +7 -7
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +3 -3
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +22 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +9 -14
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +0 -4
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +12 -1
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +2 -2
  64. data/lib/llm_cost_tracker/{storage/active_record_inbox_batch.rb → ingestion/batch.rb} +21 -20
  65. data/lib/llm_cost_tracker/ingestion/inbox.rb +105 -0
  66. data/lib/llm_cost_tracker/{storage/active_record_ingestor_lease.rb → ingestion/lease_claim.rb} +5 -7
  67. data/lib/llm_cost_tracker/{storage/active_record_ingestor.rb → ingestion/worker.rb} +38 -48
  68. data/lib/llm_cost_tracker/ingestion.rb +129 -0
  69. data/lib/llm_cost_tracker/integrations/anthropic.rb +66 -31
  70. data/lib/llm_cost_tracker/integrations/base.rb +73 -34
  71. data/lib/llm_cost_tracker/integrations/openai.rb +43 -37
  72. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +40 -30
  73. data/lib/llm_cost_tracker/integrations.rb +43 -0
  74. data/lib/llm_cost_tracker/ledger/period/totals.rb +66 -0
  75. data/lib/llm_cost_tracker/{storage/active_record_periods.rb → ledger/period.rb} +2 -2
  76. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +43 -0
  77. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +46 -0
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +87 -0
  79. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +51 -0
  80. data/lib/llm_cost_tracker/ledger/schema/calls.rb +101 -0
  81. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +32 -0
  82. data/lib/llm_cost_tracker/ledger/store.rb +60 -0
  83. data/lib/llm_cost_tracker/ledger/tags/query.rb +29 -0
  84. data/lib/llm_cost_tracker/ledger/tags/sql.rb +33 -0
  85. data/lib/llm_cost_tracker/ledger.rb +13 -0
  86. data/lib/llm_cost_tracker/logging.rb +3 -6
  87. data/lib/llm_cost_tracker/middleware/faraday.rb +88 -46
  88. data/lib/llm_cost_tracker/parsers/anthropic.rb +62 -29
  89. data/lib/llm_cost_tracker/parsers/base.rb +12 -21
  90. data/lib/llm_cost_tracker/parsers/gemini.rb +50 -25
  91. data/lib/llm_cost_tracker/parsers/openai.rb +27 -5
  92. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +14 -4
  93. data/lib/llm_cost_tracker/parsers/openai_usage.rb +58 -25
  94. data/lib/llm_cost_tracker/parsers/sse.rb +4 -7
  95. data/lib/llm_cost_tracker/parsers.rb +20 -0
  96. data/lib/llm_cost_tracker/prices.json +361 -36
  97. data/lib/llm_cost_tracker/pricing/components.rb +37 -0
  98. data/lib/llm_cost_tracker/pricing/effective_prices.rb +46 -50
  99. data/lib/llm_cost_tracker/pricing/explainer.rb +25 -30
  100. data/lib/llm_cost_tracker/pricing/lookup.rb +67 -46
  101. data/lib/llm_cost_tracker/pricing/registry.rb +156 -0
  102. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +107 -0
  103. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +53 -0
  104. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +63 -0
  105. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +31 -0
  106. data/lib/llm_cost_tracker/pricing/sync.rb +159 -0
  107. data/lib/llm_cost_tracker/pricing/unknown.rb +46 -0
  108. data/lib/llm_cost_tracker/pricing.rb +33 -32
  109. data/lib/llm_cost_tracker/railtie.rb +7 -8
  110. data/lib/llm_cost_tracker/report/data.rb +72 -0
  111. data/lib/llm_cost_tracker/report/formatter.rb +69 -0
  112. data/lib/llm_cost_tracker/report.rb +8 -8
  113. data/lib/llm_cost_tracker/retention.rb +27 -10
  114. data/lib/llm_cost_tracker/tags/context.rb +35 -0
  115. data/lib/llm_cost_tracker/tags/key.rb +18 -0
  116. data/lib/llm_cost_tracker/tags/sanitizer.rb +68 -0
  117. data/lib/llm_cost_tracker/token_usage.rb +67 -0
  118. data/lib/llm_cost_tracker/tracker.rb +39 -69
  119. data/lib/llm_cost_tracker/usage_capture.rb +37 -0
  120. data/lib/llm_cost_tracker/version.rb +1 -1
  121. data/lib/llm_cost_tracker.rb +56 -78
  122. data/lib/tasks/llm_cost_tracker.rake +18 -13
  123. metadata +54 -58
  124. data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +0 -81
  125. data/app/services/llm_cost_tracker/pagination.rb +0 -57
  126. data/lib/llm_cost_tracker/active_record_adapter.rb +0 -53
  127. data/lib/llm_cost_tracker/capture_verifier.rb +0 -64
  128. data/lib/llm_cost_tracker/cost.rb +0 -12
  129. data/lib/llm_cost_tracker/doctor/capture_check.rb +0 -39
  130. data/lib/llm_cost_tracker/event_metadata.rb +0 -52
  131. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +0 -29
  132. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +0 -29
  133. data/lib/llm_cost_tracker/inbox_event.rb +0 -9
  134. data/lib/llm_cost_tracker/ingestor_lease.rb +0 -9
  135. data/lib/llm_cost_tracker/integrations/object_reader.rb +0 -56
  136. data/lib/llm_cost_tracker/integrations/registry.rb +0 -71
  137. data/lib/llm_cost_tracker/llm_api_call.rb +0 -60
  138. data/lib/llm_cost_tracker/llm_api_call_metrics.rb +0 -63
  139. data/lib/llm_cost_tracker/parameter_hash.rb +0 -33
  140. data/lib/llm_cost_tracker/parsed_usage.rb +0 -72
  141. data/lib/llm_cost_tracker/parsers/registry.rb +0 -58
  142. data/lib/llm_cost_tracker/period_grouping.rb +0 -67
  143. data/lib/llm_cost_tracker/period_total.rb +0 -9
  144. data/lib/llm_cost_tracker/price_freshness.rb +0 -38
  145. data/lib/llm_cost_tracker/price_registry.rb +0 -144
  146. data/lib/llm_cost_tracker/price_sync/fetcher.rb +0 -104
  147. data/lib/llm_cost_tracker/price_sync/registry_diff.rb +0 -51
  148. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +0 -61
  149. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +0 -29
  150. data/lib/llm_cost_tracker/price_sync.rb +0 -144
  151. data/lib/llm_cost_tracker/report_data.rb +0 -94
  152. data/lib/llm_cost_tracker/report_formatter.rb +0 -67
  153. data/lib/llm_cost_tracker/request_url.rb +0 -20
  154. data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -167
  155. data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +0 -13
  156. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +0 -160
  157. data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +0 -84
  158. data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +0 -41
  159. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +0 -42
  160. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +0 -146
  161. data/lib/llm_cost_tracker/storage/active_record_store.rb +0 -145
  162. data/lib/llm_cost_tracker/storage/writer.rb +0 -35
  163. data/lib/llm_cost_tracker/stream_capture.rb +0 -7
  164. data/lib/llm_cost_tracker/stream_collector.rb +0 -199
  165. data/lib/llm_cost_tracker/tag_accessors.rb +0 -15
  166. data/lib/llm_cost_tracker/tag_context.rb +0 -52
  167. data/lib/llm_cost_tracker/tag_key.rb +0 -16
  168. data/lib/llm_cost_tracker/tag_query.rb +0 -43
  169. data/lib/llm_cost_tracker/tag_sanitizer.rb +0 -81
  170. data/lib/llm_cost_tracker/tag_sql.rb +0 -34
  171. data/lib/llm_cost_tracker/tags_column.rb +0 -105
  172. data/lib/llm_cost_tracker/unknown_pricing.rb +0 -54
  173. data/lib/llm_cost_tracker/usage_breakdown.rb +0 -30
  174. data/lib/llm_cost_tracker/value_helpers.rb +0 -40
@@ -1,73 +1,69 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "components"
4
+
3
5
  module LlmCostTracker
4
6
  module Pricing
5
- EffectivePriceSet = Data.define(:input, :cache_read_input, :cache_write_input, :output) do
6
- def to_h
7
- {
8
- input: input,
9
- cache_read_input: cache_read_input,
10
- cache_write_input: cache_write_input,
11
- output: output
12
- }
13
- end
14
-
15
- def complete?
16
- missing_keys.empty?
17
- end
18
-
19
- def missing_keys
20
- to_h.filter_map { |key, value| key if value.nil? }
21
- end
22
- end
23
-
24
7
  module EffectivePrices
25
8
  class << self
26
9
  def call(usage:, prices:, pricing_mode:)
27
- EffectivePriceSet.new(
28
- input: price_for_usage(usage.input_tokens, prices, :input, pricing_mode),
29
- cache_read_input: price_for_cache_usage(
30
- usage.cache_read_input_tokens,
31
- prices,
32
- :cache_read_input,
33
- pricing_mode
34
- ),
35
- cache_write_input: price_for_cache_usage(
36
- usage.cache_write_input_tokens,
37
- prices,
38
- :cache_write_input,
39
- pricing_mode
40
- ),
41
- output: price_for_usage(usage.output_tokens, prices, :output, pricing_mode)
42
- )
10
+ quantities = usage.price_quantities
11
+ context_tier = context_tier?(usage: usage, prices: prices)
12
+
13
+ Pricing::COMPONENTS.to_h do |component|
14
+ price_key = component.price_key
15
+ tokens = quantities.fetch(price_key)
16
+ price = if tokens.positive?
17
+ price_for(
18
+ prices: prices,
19
+ key: price_key,
20
+ pricing_mode: pricing_mode,
21
+ context_tier: context_tier
22
+ )
23
+ else
24
+ 0.0
25
+ end
26
+ [price_key, price]
27
+ end
43
28
  end
44
29
 
45
30
  private
46
31
 
47
- def price_for_cache_usage(tokens, prices, key, pricing_mode)
48
- return 0.0 unless tokens.positive?
32
+ def price_for(prices:, key:, pricing_mode:, context_tier:)
33
+ mode = Pricing.normalize_mode(pricing_mode)
34
+ return contextual_price(prices: prices, key: key, context_tier: context_tier) unless mode
49
35
 
50
- price_for(prices, key, pricing_mode) || price_for(prices, :input, pricing_mode)
36
+ contextual_price(prices: prices, key: :"#{mode}_#{key}", context_tier: context_tier) ||
37
+ derived_mode_price(prices: prices, key: key, mode: mode, context_tier: context_tier)
51
38
  end
52
39
 
53
- def price_for_usage(tokens, prices, key, pricing_mode)
54
- tokens.positive? ? price_for(prices, key, pricing_mode) : 0.0
40
+ def contextual_price(prices:, key:, context_tier:)
41
+ return prices[key] unless context_tier
42
+
43
+ prices[:"above_context_#{key}"]
55
44
  end
56
45
 
57
- def price_for(prices, key, pricing_mode)
58
- mode = normalized_pricing_mode(pricing_mode)
59
- return prices[key] unless mode
46
+ def derived_mode_price(prices:, key:, mode:, context_tier:)
47
+ standard_price = contextual_price(prices: prices, key: key, context_tier: context_tier)
48
+ return nil unless standard_price
60
49
 
61
- prices[:"#{mode}_#{key}"] || prices[key]
62
- end
50
+ base_key = key == :output ? :output : :input
51
+ base_price = contextual_price(prices: prices, key: base_key, context_tier: context_tier)
52
+ mode_base_price = contextual_price(prices: prices, key: :"#{mode}_#{base_key}", context_tier: context_tier)
53
+ return nil unless base_price && mode_base_price
63
54
 
64
- def normalized_pricing_mode(value)
65
- return nil if value.nil?
55
+ standard_price * (mode_base_price.to_f / base_price)
56
+ end
66
57
 
67
- mode = value.to_s.strip
68
- return nil if mode.empty? || mode == "standard"
58
+ def context_tier?(usage:, prices:)
59
+ threshold = prices[:_context_price_threshold_tokens]
60
+ return false unless threshold
69
61
 
70
- mode
62
+ input_tokens = usage.input_tokens +
63
+ usage.cache_read_input_tokens +
64
+ usage.cache_write_input_tokens +
65
+ usage.cache_write_1h_input_tokens
66
+ input_tokens > threshold.to_i
71
67
  end
72
68
  end
73
69
  end
@@ -15,9 +15,13 @@ module LlmCostTracker
15
15
  :effective_prices,
16
16
  :missing_price_keys
17
17
  ) do
18
- def matched? = !prices.nil?
18
+ def matched?
19
+ !prices.nil?
20
+ end
19
21
 
20
- def complete? = matched? && missing_price_keys.empty?
22
+ def complete?
23
+ matched? && missing_price_keys.empty?
24
+ end
21
25
 
22
26
  def message
23
27
  return "No price entry matched #{provider}/#{model}" unless matched?
@@ -29,48 +33,39 @@ module LlmCostTracker
29
33
 
30
34
  module Explainer
31
35
  class << self
32
- def call(provider:, model:, input_tokens: 1, output_tokens: 1, cache_read_input_tokens: 0,
33
- cache_write_input_tokens: 0, pricing_mode: nil)
36
+ def call(provider:, model:, token_usage:, pricing_mode: nil)
34
37
  match = Lookup.call(provider: provider, model: model)
35
- usage = match && UsageBreakdown.build(
36
- input_tokens: input_tokens,
37
- output_tokens: output_tokens,
38
- cache_read_input_tokens: cache_read_input_tokens,
39
- cache_write_input_tokens: cache_write_input_tokens
40
- )
41
38
 
42
- explanation(provider, model, pricing_mode, match, usage)
39
+ explanation(
40
+ provider: provider,
41
+ model: model,
42
+ pricing_mode: pricing_mode,
43
+ match: match,
44
+ usage: token_usage
45
+ )
43
46
  end
44
47
 
45
48
  private
46
49
 
47
- def explanation(provider, model, pricing_mode, match, usage)
50
+ def explanation(provider:, model:, pricing_mode:, match:, usage:)
48
51
  prices = match&.prices
52
+ pricing_mode = Pricing.normalize_mode(pricing_mode)
49
53
  effective = if prices && usage
50
54
  EffectivePrices.call(usage: usage, prices: prices, pricing_mode: pricing_mode)
51
55
  end
52
56
 
53
57
  Explanation.new(
54
- provider.to_s,
55
- model.to_s,
56
- normalized_pricing_mode(pricing_mode),
57
- match&.source,
58
- match&.key,
59
- match&.matched_by,
60
- prices,
61
- effective ? effective.to_h : {},
62
- effective ? effective.missing_keys : []
58
+ provider: provider.to_s,
59
+ model: model.to_s,
60
+ pricing_mode: pricing_mode,
61
+ source: match&.source,
62
+ matched_key: match&.key,
63
+ matched_by: match&.matched_by,
64
+ prices: prices,
65
+ effective_prices: effective || {},
66
+ missing_price_keys: effective ? effective.filter_map { |key, value| key if value.nil? } : []
63
67
  )
64
68
  end
65
-
66
- def normalized_pricing_mode(value)
67
- return nil if value.nil?
68
-
69
- mode = value.to_s.strip
70
- return nil if mode.empty? || mode == "standard"
71
-
72
- mode
73
- end
74
69
  end
75
70
  end
76
71
  end
@@ -1,120 +1,141 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "monitor"
4
-
5
3
  module LlmCostTracker
6
4
  module Pricing
7
5
  module Lookup
8
6
  Match = Data.define(:source, :key, :prices, :matched_by)
9
- MUTEX = Monitor.new
7
+ MUTEX = Mutex.new
10
8
  CACHE_MISS = Object.new.freeze
11
9
  NO_MATCH = Object.new.freeze
12
10
  MAX_LOOKUP_CACHE_ENTRIES = 512
13
11
 
14
12
  class << self
15
13
  def call(provider:, model:)
16
- provider_name = provider.to_s
14
+ provider_name = provider.to_s.presence
17
15
  model_name = model.to_s
18
- generation = LlmCostTracker.configuration_generation
19
- cache_key = [generation, provider_name, model_name]
16
+ cache_key = [provider_name, model_name]
20
17
  cached = cached_lookup(cache_key)
21
18
  return cached unless cached.equal?(CACHE_MISS)
22
19
 
23
- provider_model = provider_name.empty? ? model_name : "#{provider_name}/#{model_name}"
20
+ provider_model = provider_name ? "#{provider_name}/#{model_name}" : model_name
24
21
  normalized_model = normalize_model_name(model_name)
25
- current = current_price_tables(generation)
22
+ current = current_price_tables
26
23
 
27
24
  match =
28
- explain_table(current.fetch(:pricing_overrides), :pricing_overrides, provider_model, model_name,
29
- normalized_model) ||
30
- explain_table(current.fetch(:file_prices), :prices_file, provider_model, model_name, normalized_model) ||
31
- explain_table(Pricing::PRICES, :bundled, provider_model, model_name, normalized_model)
25
+ explain_table(
26
+ table: current.fetch(:pricing_overrides),
27
+ source: :pricing_overrides,
28
+ provider_model: provider_model,
29
+ model_name: model_name,
30
+ normalized_model: normalized_model
31
+ ) ||
32
+ explain_table(
33
+ table: current.fetch(:file_prices),
34
+ source: :prices_file,
35
+ provider_model: provider_model,
36
+ model_name: model_name,
37
+ normalized_model: normalized_model
38
+ ) ||
39
+ explain_table(
40
+ table: Registry.builtin_prices,
41
+ source: :bundled,
42
+ provider_model: provider_model,
43
+ model_name: model_name,
44
+ normalized_model: normalized_model
45
+ )
32
46
  cache_lookup(cache_key, match)
33
47
  match
34
48
  end
35
49
 
50
+ def reset!
51
+ MUTEX.synchronize do
52
+ @prices_cache = nil
53
+ @lookup_cache = nil
54
+ @sorted_price_keys_cache = nil
55
+ end
56
+ end
57
+
36
58
  private
37
59
 
38
- def current_price_tables(generation)
60
+ def current_price_tables
39
61
  cached = @prices_cache
40
- return cached[:value] if cached && cached[:generation] == generation
62
+ return cached if cached
41
63
 
42
64
  MUTEX.synchronize do
43
65
  cached = @prices_cache
44
- return cached[:value] if cached && cached[:generation] == generation
66
+ return cached if cached
45
67
 
46
68
  config = LlmCostTracker.configuration
47
- file_prices = PriceRegistry.file_prices(config.prices_file)
48
- overrides = PriceRegistry.normalize_price_table(config.pricing_overrides)
69
+ file_prices = Registry.file_prices(config.prices_file)
70
+ overrides = Registry.normalize_price_table(config.pricing_overrides)
49
71
  value = { pricing_overrides: overrides, file_prices: file_prices }.freeze
50
- @prices_cache = { generation: generation, value: value }.freeze
72
+ @prices_cache = value
51
73
  value
52
74
  end
53
75
  end
54
76
 
55
77
  def cached_lookup(cache_key)
56
78
  cached = @lookup_cache
57
- return CACHE_MISS unless cached && cached[:generation] == cache_key.first
58
- return CACHE_MISS unless cached[:values].key?(cache_key)
79
+ return CACHE_MISS unless cached&.key?(cache_key)
59
80
 
60
- match = cached[:values].fetch(cache_key)
81
+ match = cached.fetch(cache_key)
61
82
  match.equal?(NO_MATCH) ? nil : match
62
83
  end
63
84
 
64
85
  def cache_lookup(cache_key, match)
65
86
  MUTEX.synchronize do
66
- cached = @lookup_cache
67
- values = if cached && cached[:generation] == cache_key.first
68
- cached[:values].dup
69
- else
70
- {}
71
- end
87
+ values = (@lookup_cache || {}).dup
72
88
  values.clear if values.size >= MAX_LOOKUP_CACHE_ENTRIES
73
89
  values[cache_key] = match || NO_MATCH
74
- @lookup_cache = { generation: cache_key.first, values: values.freeze }.freeze
90
+ @lookup_cache = values.freeze
75
91
  end
76
92
  end
77
93
 
78
- def explain_table(table, source, provider_model, model_name, normalized_model)
94
+ def explain_table(table:, source:, provider_model:, model_name:, normalized_model:)
79
95
  return nil if table.empty?
80
96
 
81
- direct_match(table, source, provider_model, :provider_model) ||
82
- direct_match(table, source, model_name, :model) ||
83
- direct_match(table, source, normalized_model, :normalized_model) ||
84
- unique_providerless_lookup(normalized_model, table, source) ||
85
- fuzzy_match(provider_model, normalized_model, table, source) ||
86
- unique_providerless_fuzzy_match(normalized_model, table, source)
97
+ direct_match(table: table, source: source, key: provider_model, matched_by: :provider_model) ||
98
+ direct_match(table: table, source: source, key: model_name, matched_by: :model) ||
99
+ direct_match(table: table, source: source, key: normalized_model, matched_by: :normalized_model) ||
100
+ unique_providerless_lookup(model: normalized_model, table: table, source: source) ||
101
+ fuzzy_match(model: provider_model, normalized_model: normalized_model, table: table, source: source) ||
102
+ unique_providerless_fuzzy_match(model: normalized_model, table: table, source: source)
87
103
  end
88
104
 
89
105
  def normalize_model_name(model)
90
106
  model.to_s.split("/").last
91
107
  end
92
108
 
93
- def unique_providerless_lookup(model, table, source)
109
+ def unique_providerless_lookup(model:, table:, source:)
94
110
  matches = sorted_price_keys(table).select { |key| normalize_model_name(key) == model }
95
- match(table, source, matches.first, :unique_providerless_model) if matches.one?
111
+ return unless matches.one?
112
+
113
+ match(table: table, source: source, key: matches.first, matched_by: :unique_providerless_model)
96
114
  end
97
115
 
98
- def fuzzy_match(model, normalized_model, table, source)
116
+ def fuzzy_match(model:, normalized_model:, table:, source:)
99
117
  sorted_price_keys(table).each do |key|
100
- return match(table, source, key, :dated_snapshot) if snapshot_variant?(model, key) ||
101
- snapshot_variant?(normalized_model, key)
118
+ if snapshot_variant?(model, key) || snapshot_variant?(normalized_model, key)
119
+ return match(table: table, source: source, key: key, matched_by: :dated_snapshot)
120
+ end
102
121
  end
103
122
 
104
123
  nil
105
124
  end
106
125
 
107
- def unique_providerless_fuzzy_match(model, table, source)
126
+ def unique_providerless_fuzzy_match(model:, table:, source:)
108
127
  matches = sorted_price_keys(table).select { |key| snapshot_variant?(model, normalize_model_name(key)) }
109
- match(table, source, matches.first, :unique_providerless_dated_snapshot) if matches.one?
128
+ return unless matches.one?
129
+
130
+ match(table: table, source: source, key: matches.first, matched_by: :unique_providerless_dated_snapshot)
110
131
  end
111
132
 
112
- def direct_match(table, source, key, matched_by)
113
- match(table, source, key, matched_by) if table.key?(key)
133
+ def direct_match(table:, source:, key:, matched_by:)
134
+ match(table: table, source: source, key: key, matched_by: matched_by) if table.key?(key)
114
135
  end
115
136
 
116
- def match(table, source, key, matched_by)
117
- Match.new(source.to_s, key, table[key], matched_by.to_s)
137
+ def match(table:, source:, key:, matched_by:)
138
+ Match.new(source: source.to_s, key: key, prices: table[key], matched_by: matched_by.to_s)
118
139
  end
119
140
 
120
141
  def snapshot_variant?(model, key)
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+
6
+ require_relative "components"
7
+ require_relative "../logging"
8
+
9
+ module LlmCostTracker
10
+ module Pricing
11
+ module Registry
12
+ DEFAULT_PRICES_PATH = File.expand_path("../prices.json", __dir__)
13
+ EMPTY_PRICES = {}.freeze
14
+ PRICE_KEYS = Pricing::COMPONENTS.map { |component| component.price_key.to_s }.freeze
15
+ METADATA_KEYS = %w[
16
+ _source _source_version _fetched_at _updated _notes _validator_override
17
+ _context_price_threshold_tokens
18
+ ].freeze
19
+ MAX_FILE_BYTES = 2_097_152
20
+ MUTEX = Mutex.new
21
+
22
+ class << self
23
+ def builtin_prices
24
+ cached = @builtin_prices
25
+ return cached if cached
26
+
27
+ value = normalize_price_table(raw_registry.fetch("models", {})).freeze
28
+ MUTEX.synchronize { @builtin_prices ||= value }
29
+ end
30
+
31
+ def metadata
32
+ cached = @metadata
33
+ return cached if cached
34
+
35
+ value = raw_registry.fetch("metadata", {}).freeze
36
+ MUTEX.synchronize { @metadata ||= value }
37
+ end
38
+
39
+ def file_metadata(path)
40
+ return {} unless path
41
+
42
+ registry = load_price_file(path.to_s)
43
+ raise ArgumentError, "prices_file must be a hash" unless registry.is_a?(Hash)
44
+
45
+ metadata = registry.fetch("metadata", {})
46
+ raise ArgumentError, "prices_file metadata must be a hash" unless metadata.is_a?(Hash)
47
+
48
+ metadata
49
+ rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
50
+ raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
51
+ end
52
+
53
+ def normalize_price_table(table)
54
+ normalize_price_entries(table, context: "price table")
55
+ end
56
+
57
+ def file_prices(path)
58
+ return EMPTY_PRICES unless path
59
+
60
+ path = path.to_s
61
+ cache_key = [path, File.mtime(path).to_f]
62
+ cached = @file_prices_cache
63
+ return cached[:value] if cached && cached[:key] == cache_key
64
+
65
+ MUTEX.synchronize do
66
+ cached = @file_prices_cache
67
+ return cached[:value] if cached && cached[:key] == cache_key
68
+
69
+ value = normalize_price_entries(price_file_models(load_price_file(path)), context: path).freeze
70
+ @file_prices_cache = { key: cache_key, value: value }.freeze
71
+ value
72
+ end
73
+ rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
74
+ raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
75
+ end
76
+
77
+ private
78
+
79
+ def raw_registry
80
+ cached = @raw_registry
81
+ return cached if cached
82
+
83
+ MUTEX.synchronize { @raw_registry ||= JSON.parse(File.read(DEFAULT_PRICES_PATH)).freeze }
84
+ end
85
+
86
+ def normalize_price_entry(price)
87
+ price.each_with_object({}) do |(key, value), normalized|
88
+ key = key.to_s
89
+ if price_key?(key)
90
+ normalized[key.to_sym] = Float(value)
91
+ elsif key == "_context_price_threshold_tokens"
92
+ normalized[key.to_sym] = Integer(value)
93
+ end
94
+ end
95
+ end
96
+
97
+ def normalize_price_entries(table, context:)
98
+ table = {} if table.nil?
99
+ raise ArgumentError, "#{context} must be a hash of models" unless table.is_a?(Hash)
100
+
101
+ table.each_with_object({}) do |(model, price), normalized|
102
+ price = validate_price_entry(price, model: model, context: context)
103
+ warn_unknown_keys(model, price, context)
104
+ normalized[model.to_s] = normalize_price_entry(price)
105
+ end
106
+ end
107
+
108
+ def warn_unknown_keys(model, price, path)
109
+ unknown_keys = price.keys.map(&:to_s).reject do |key|
110
+ price_key?(key) || METADATA_KEYS.include?(key)
111
+ end
112
+ return if unknown_keys.empty?
113
+
114
+ Logging.warn(
115
+ "Unknown price keys #{unknown_keys.inspect} for #{model.inspect} in #{path}; " \
116
+ "ignored. Known keys: #{(PRICE_KEYS + METADATA_KEYS).inspect}; mode-specific keys use mode_input"
117
+ )
118
+ end
119
+
120
+ def price_key?(key)
121
+ return true if PRICE_KEYS.include?(key)
122
+
123
+ PRICE_KEYS.any? do |base_key|
124
+ key.end_with?("_#{base_key}") && key.delete_suffix("_#{base_key}") != ""
125
+ end
126
+ end
127
+
128
+ def load_price_file(path)
129
+ raise ArgumentError, "prices_file exceeds #{MAX_FILE_BYTES} bytes" if File.size(path) > MAX_FILE_BYTES
130
+
131
+ contents = File.read(path)
132
+ return YAML.safe_load(contents, aliases: false) || {} if yaml_file?(path)
133
+
134
+ JSON.parse(contents)
135
+ end
136
+
137
+ def yaml_file?(path)
138
+ %w[.yaml .yml].include?(File.extname(path).downcase)
139
+ end
140
+
141
+ def price_file_models(registry)
142
+ raise ArgumentError, "prices_file must be a hash" unless registry.is_a?(Hash)
143
+
144
+ registry.fetch("models", registry)
145
+ end
146
+
147
+ def validate_price_entry(price, model:, context:)
148
+ return {} if price.nil?
149
+ return price if price.is_a?(Hash)
150
+
151
+ raise ArgumentError, "price entry for #{model.inspect} in #{context} must be a hash"
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/blank"
4
+ require "digest"
5
+ require "net/http"
6
+ require "openssl"
7
+ require "time"
8
+ require "uri"
9
+
10
+ module LlmCostTracker
11
+ module Pricing
12
+ module Sync
13
+ class Fetcher
14
+ Response = Data.define(:body, :etag, :last_modified, :not_modified, :fetched_at) do
15
+ def source_version
16
+ etag || last_modified || Digest::SHA256.hexdigest(body.to_s)
17
+ end
18
+ end
19
+
20
+ USER_AGENT = "llm_cost_tracker price refresh"
21
+ MAX_REDIRECTS = 5
22
+ MAX_BODY_BYTES = 2_097_152
23
+ OPEN_TIMEOUT = 5
24
+ READ_TIMEOUT = 10
25
+ WRITE_TIMEOUT = 10
26
+
27
+ def get(url, etag: nil, redirects: 0)
28
+ raise Error, "Too many redirects while fetching #{url}" if redirects > MAX_REDIRECTS
29
+
30
+ uri = URI.parse(url)
31
+ raise Error, "Pricing snapshot URL must use https" unless uri.scheme == "https"
32
+
33
+ request = Net::HTTP::Get.new(uri)
34
+ request["User-Agent"] = USER_AGENT
35
+ request["If-None-Match"] = etag if etag
36
+
37
+ response, body = fetch_response(uri, request)
38
+
39
+ case response
40
+ when Net::HTTPSuccess
41
+ build_response(response, body: body || limited_body(response), not_modified: false)
42
+ when Net::HTTPNotModified
43
+ build_response(response, body: nil, not_modified: true)
44
+ when Net::HTTPRedirection
45
+ location = response["location"]
46
+ raise Error, "Redirect without location while fetching #{url}" if location.blank?
47
+
48
+ get(URI.join(url, location).to_s, etag: etag, redirects: redirects + 1)
49
+ else
50
+ raise Error, "Unable to fetch #{url}: HTTP #{response.code}"
51
+ end
52
+ rescue OpenSSL::SSL::SSLError, SocketError, SystemCallError, Timeout::Error => e
53
+ raise Error, "Unable to fetch #{url}: #{e.class}: #{e.message}"
54
+ end
55
+
56
+ private
57
+
58
+ def fetch_response(uri, request)
59
+ body = nil
60
+ response = Net::HTTP.start(
61
+ uri.host,
62
+ uri.port,
63
+ use_ssl: uri.scheme == "https",
64
+ open_timeout: OPEN_TIMEOUT,
65
+ read_timeout: READ_TIMEOUT,
66
+ write_timeout: WRITE_TIMEOUT
67
+ ) do |http|
68
+ http.request(request) do |streamed_response|
69
+ body = limited_body(streamed_response) if streamed_response.is_a?(Net::HTTPSuccess)
70
+ end
71
+ end
72
+
73
+ [response, body]
74
+ end
75
+
76
+ def limited_body(response)
77
+ body = +""
78
+ if response.respond_to?(:read_body)
79
+ response.read_body do |chunk|
80
+ chunk = chunk.to_s
81
+ if body.bytesize + chunk.bytesize > MAX_BODY_BYTES
82
+ raise Error, "Pricing snapshot response exceeds #{MAX_BODY_BYTES} bytes"
83
+ end
84
+
85
+ body << chunk
86
+ end
87
+ else
88
+ body = response.body.to_s
89
+ end
90
+ raise Error, "Pricing snapshot response exceeds #{MAX_BODY_BYTES} bytes" if body.bytesize > MAX_BODY_BYTES
91
+
92
+ body
93
+ end
94
+
95
+ def build_response(response, not_modified:, body: response.body)
96
+ Response.new(
97
+ body: body,
98
+ etag: response["etag"],
99
+ last_modified: response["last-modified"],
100
+ not_modified: not_modified,
101
+ fetched_at: Time.now.utc.iso8601
102
+ )
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end