llm_cost_tracker 0.7.0 → 0.7.1

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 (172) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +11 -9
  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 +182 -0
  45. data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +40 -72
  46. data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
  47. data/lib/llm_cost_tracker/configuration.rb +28 -35
  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 +52 -34
  70. data/lib/llm_cost_tracker/integrations/base.rb +73 -34
  71. data/lib/llm_cost_tracker/integrations/openai.rb +45 -39
  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 +35 -36
  88. data/lib/llm_cost_tracker/parsers/anthropic.rb +38 -27
  89. data/lib/llm_cost_tracker/parsers/base.rb +10 -19
  90. data/lib/llm_cost_tracker/parsers/gemini.rb +15 -16
  91. data/lib/llm_cost_tracker/parsers/openai_usage.rb +24 -19
  92. data/lib/llm_cost_tracker/parsers/sse.rb +4 -7
  93. data/lib/llm_cost_tracker/parsers.rb +20 -0
  94. data/lib/llm_cost_tracker/prices.json +52 -11
  95. data/lib/llm_cost_tracker/pricing/components.rb +37 -0
  96. data/lib/llm_cost_tracker/pricing/effective_prices.rb +40 -50
  97. data/lib/llm_cost_tracker/pricing/explainer.rb +12 -23
  98. data/lib/llm_cost_tracker/pricing/lookup.rb +24 -25
  99. data/lib/llm_cost_tracker/pricing/registry.rb +156 -0
  100. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +107 -0
  101. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +53 -0
  102. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +63 -0
  103. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +31 -0
  104. data/lib/llm_cost_tracker/pricing/sync.rb +143 -0
  105. data/lib/llm_cost_tracker/pricing/unknown.rb +46 -0
  106. data/lib/llm_cost_tracker/pricing.rb +33 -32
  107. data/lib/llm_cost_tracker/railtie.rb +7 -8
  108. data/lib/llm_cost_tracker/report/data.rb +72 -0
  109. data/lib/llm_cost_tracker/report/formatter.rb +69 -0
  110. data/lib/llm_cost_tracker/report.rb +8 -8
  111. data/lib/llm_cost_tracker/retention.rb +27 -10
  112. data/lib/llm_cost_tracker/tags/context.rb +35 -0
  113. data/lib/llm_cost_tracker/tags/key.rb +18 -0
  114. data/lib/llm_cost_tracker/tags/sanitizer.rb +68 -0
  115. data/lib/llm_cost_tracker/token_usage.rb +67 -0
  116. data/lib/llm_cost_tracker/tracker.rb +38 -70
  117. data/lib/llm_cost_tracker/usage_capture.rb +37 -0
  118. data/lib/llm_cost_tracker/version.rb +1 -1
  119. data/lib/llm_cost_tracker.rb +56 -78
  120. data/lib/tasks/llm_cost_tracker.rake +18 -13
  121. metadata +54 -58
  122. data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +0 -81
  123. data/app/services/llm_cost_tracker/pagination.rb +0 -57
  124. data/lib/llm_cost_tracker/active_record_adapter.rb +0 -53
  125. data/lib/llm_cost_tracker/capture_verifier.rb +0 -64
  126. data/lib/llm_cost_tracker/cost.rb +0 -12
  127. data/lib/llm_cost_tracker/doctor/capture_check.rb +0 -39
  128. data/lib/llm_cost_tracker/event_metadata.rb +0 -52
  129. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +0 -29
  130. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +0 -29
  131. data/lib/llm_cost_tracker/inbox_event.rb +0 -9
  132. data/lib/llm_cost_tracker/ingestor_lease.rb +0 -9
  133. data/lib/llm_cost_tracker/integrations/object_reader.rb +0 -56
  134. data/lib/llm_cost_tracker/integrations/registry.rb +0 -71
  135. data/lib/llm_cost_tracker/llm_api_call.rb +0 -60
  136. data/lib/llm_cost_tracker/llm_api_call_metrics.rb +0 -63
  137. data/lib/llm_cost_tracker/parameter_hash.rb +0 -33
  138. data/lib/llm_cost_tracker/parsed_usage.rb +0 -72
  139. data/lib/llm_cost_tracker/parsers/registry.rb +0 -58
  140. data/lib/llm_cost_tracker/period_grouping.rb +0 -67
  141. data/lib/llm_cost_tracker/period_total.rb +0 -9
  142. data/lib/llm_cost_tracker/price_freshness.rb +0 -38
  143. data/lib/llm_cost_tracker/price_registry.rb +0 -144
  144. data/lib/llm_cost_tracker/price_sync/fetcher.rb +0 -104
  145. data/lib/llm_cost_tracker/price_sync/registry_diff.rb +0 -51
  146. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +0 -61
  147. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +0 -29
  148. data/lib/llm_cost_tracker/price_sync.rb +0 -144
  149. data/lib/llm_cost_tracker/report_data.rb +0 -94
  150. data/lib/llm_cost_tracker/report_formatter.rb +0 -67
  151. data/lib/llm_cost_tracker/request_url.rb +0 -20
  152. data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -167
  153. data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +0 -13
  154. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +0 -160
  155. data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +0 -84
  156. data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +0 -41
  157. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +0 -42
  158. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +0 -146
  159. data/lib/llm_cost_tracker/storage/active_record_store.rb +0 -145
  160. data/lib/llm_cost_tracker/storage/writer.rb +0 -35
  161. data/lib/llm_cost_tracker/stream_capture.rb +0 -7
  162. data/lib/llm_cost_tracker/stream_collector.rb +0 -199
  163. data/lib/llm_cost_tracker/tag_accessors.rb +0 -15
  164. data/lib/llm_cost_tracker/tag_context.rb +0 -52
  165. data/lib/llm_cost_tracker/tag_key.rb +0 -16
  166. data/lib/llm_cost_tracker/tag_query.rb +0 -43
  167. data/lib/llm_cost_tracker/tag_sanitizer.rb +0 -81
  168. data/lib/llm_cost_tracker/tag_sql.rb +0 -34
  169. data/lib/llm_cost_tracker/tags_column.rb +0 -105
  170. data/lib/llm_cost_tracker/unknown_pricing.rb +0 -54
  171. data/lib/llm_cost_tracker/usage_breakdown.rb +0 -30
  172. data/lib/llm_cost_tracker/value_helpers.rb +0 -40
@@ -1,73 +1,63 @@
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, prices)
12
+
13
+ Pricing::COMPONENTS.to_h do |component|
14
+ price_key = component.price_key
15
+ tokens = quantities.fetch(price_key)
16
+ price = tokens.positive? ? price_for(prices, price_key, pricing_mode, context_tier) : 0.0
17
+ [price_key, price]
18
+ end
43
19
  end
44
20
 
45
21
  private
46
22
 
47
- def price_for_cache_usage(tokens, prices, key, pricing_mode)
48
- return 0.0 unless tokens.positive?
23
+ def price_for(prices, key, pricing_mode, context_tier)
24
+ mode = Pricing.normalize_mode(pricing_mode)
25
+ return contextual_price(prices, key, context_tier) unless mode
49
26
 
50
- price_for(prices, key, pricing_mode) || price_for(prices, :input, pricing_mode)
27
+ contextual_price(prices, :"#{mode}_#{key}", context_tier) ||
28
+ derived_batch_price(prices, key, mode, context_tier)
51
29
  end
52
30
 
53
- def price_for_usage(tokens, prices, key, pricing_mode)
54
- tokens.positive? ? price_for(prices, key, pricing_mode) : 0.0
31
+ def contextual_price(prices, key, context_tier)
32
+ return prices[key] unless context_tier
33
+
34
+ prices[:"above_context_#{key}"]
55
35
  end
56
36
 
57
- def price_for(prices, key, pricing_mode)
58
- mode = normalized_pricing_mode(pricing_mode)
59
- return prices[key] unless mode
37
+ def derived_batch_price(prices, key, mode, context_tier)
38
+ return nil unless mode == "batch"
60
39
 
61
- prices[:"#{mode}_#{key}"] || prices[key]
62
- end
40
+ standard_price = contextual_price(prices, key, context_tier)
41
+ return nil unless standard_price
42
+
43
+ base_key = key == :output ? :output : :input
44
+ batch_key = key == :output ? :batch_output : :batch_input
45
+ base_price = contextual_price(prices, base_key, context_tier)
46
+ batch_price = contextual_price(prices, batch_key, context_tier)
47
+ return nil unless base_price && batch_price
63
48
 
64
- def normalized_pricing_mode(value)
65
- return nil if value.nil?
49
+ standard_price * (batch_price.to_f / base_price)
50
+ end
66
51
 
67
- mode = value.to_s.strip
68
- return nil if mode.empty? || mode == "standard"
52
+ def context_tier?(usage, prices)
53
+ threshold = prices[:_context_price_threshold_tokens]
54
+ return false unless threshold
69
55
 
70
- mode
56
+ input_tokens = usage.input_tokens +
57
+ usage.cache_read_input_tokens +
58
+ usage.cache_write_input_tokens +
59
+ usage.cache_write_1h_input_tokens
60
+ input_tokens > threshold.to_i
71
61
  end
72
62
  end
73
63
  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,23 +33,17 @@ 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(provider, model, pricing_mode, match, token_usage)
43
40
  end
44
41
 
45
42
  private
46
43
 
47
44
  def explanation(provider, model, pricing_mode, match, usage)
48
45
  prices = match&.prices
46
+ pricing_mode = Pricing.normalize_mode(pricing_mode)
49
47
  effective = if prices && usage
50
48
  EffectivePrices.call(usage: usage, prices: prices, pricing_mode: pricing_mode)
51
49
  end
@@ -53,24 +51,15 @@ module LlmCostTracker
53
51
  Explanation.new(
54
52
  provider.to_s,
55
53
  model.to_s,
56
- normalized_pricing_mode(pricing_mode),
54
+ pricing_mode,
57
55
  match&.source,
58
56
  match&.key,
59
57
  match&.matched_by,
60
58
  prices,
61
- effective ? effective.to_h : {},
62
- effective ? effective.missing_keys : []
59
+ effective || {},
60
+ effective ? effective.filter_map { |key, value| key if value.nil? } : []
63
61
  )
64
62
  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
63
  end
75
64
  end
76
65
  end
@@ -1,77 +1,76 @@
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
25
  explain_table(current.fetch(:pricing_overrides), :pricing_overrides, provider_model, model_name,
29
26
  normalized_model) ||
30
27
  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)
28
+ explain_table(Registry.builtin_prices, :bundled, provider_model, model_name, normalized_model)
32
29
  cache_lookup(cache_key, match)
33
30
  match
34
31
  end
35
32
 
33
+ def reset!
34
+ MUTEX.synchronize do
35
+ @prices_cache = nil
36
+ @lookup_cache = nil
37
+ @sorted_price_keys_cache = nil
38
+ end
39
+ end
40
+
36
41
  private
37
42
 
38
- def current_price_tables(generation)
43
+ def current_price_tables
39
44
  cached = @prices_cache
40
- return cached[:value] if cached && cached[:generation] == generation
45
+ return cached if cached
41
46
 
42
47
  MUTEX.synchronize do
43
48
  cached = @prices_cache
44
- return cached[:value] if cached && cached[:generation] == generation
49
+ return cached if cached
45
50
 
46
51
  config = LlmCostTracker.configuration
47
- file_prices = PriceRegistry.file_prices(config.prices_file)
48
- overrides = PriceRegistry.normalize_price_table(config.pricing_overrides)
52
+ file_prices = Registry.file_prices(config.prices_file)
53
+ overrides = Registry.normalize_price_table(config.pricing_overrides)
49
54
  value = { pricing_overrides: overrides, file_prices: file_prices }.freeze
50
- @prices_cache = { generation: generation, value: value }.freeze
55
+ @prices_cache = value
51
56
  value
52
57
  end
53
58
  end
54
59
 
55
60
  def cached_lookup(cache_key)
56
61
  cached = @lookup_cache
57
- return CACHE_MISS unless cached && cached[:generation] == cache_key.first
58
- return CACHE_MISS unless cached[:values].key?(cache_key)
62
+ return CACHE_MISS unless cached&.key?(cache_key)
59
63
 
60
- match = cached[:values].fetch(cache_key)
64
+ match = cached.fetch(cache_key)
61
65
  match.equal?(NO_MATCH) ? nil : match
62
66
  end
63
67
 
64
68
  def cache_lookup(cache_key, match)
65
69
  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
70
+ values = (@lookup_cache || {}).dup
72
71
  values.clear if values.size >= MAX_LOOKUP_CACHE_ENTRIES
73
72
  values[cache_key] = match || NO_MATCH
74
- @lookup_cache = { generation: cache_key.first, values: values.freeze }.freeze
73
+ @lookup_cache = values.freeze
75
74
  end
76
75
  end
77
76
 
@@ -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
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Pricing
5
+ module Sync
6
+ module RegistryDiff
7
+ class << self
8
+ def call(current_models, updated_models)
9
+ current_models = normalize_models(current_models)
10
+ updated_models = normalize_models(updated_models)
11
+
12
+ (current_models.keys | updated_models.keys).sort.each_with_object({}) do |model, changes|
13
+ fields = price_field_changes(current_models[model], updated_models[model])
14
+ changes[model] = fields if fields.any?
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def price_field_changes(current_entry, updated_entry)
21
+ current_price = comparable_price(current_entry)
22
+ updated_price = comparable_price(updated_entry)
23
+
24
+ (current_price.keys | updated_price.keys).sort.each_with_object({}) do |field, changes|
25
+ from = current_price[field]
26
+ to = updated_price[field]
27
+ next if from == to
28
+
29
+ changes[field] = { "from" => from, "to" => to }
30
+ end
31
+ end
32
+
33
+ def comparable_price(entry)
34
+ normalize_hash(entry).slice(*Registry::PRICE_KEYS)
35
+ end
36
+
37
+ def normalize_models(models)
38
+ normalize_hash(models).transform_values { |entry| normalize_hash(entry) }
39
+ end
40
+
41
+ def normalize_hash(hash)
42
+ return {} if hash.nil?
43
+ raise Error, "pricing entries must be hashes" unless hash.is_a?(Hash)
44
+
45
+ hash.each_with_object({}) do |(key, value), normalized|
46
+ normalized[key.to_s] = value
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+
6
+ require_relative "../registry"
7
+
8
+ module LlmCostTracker
9
+ module Pricing
10
+ module Sync
11
+ class RegistryLoader
12
+ YAML_EXTENSIONS = %w[.yml .yaml].freeze
13
+
14
+ def call(path:, seed_path:)
15
+ source_path = File.exist?(path.to_s) ? path.to_s : seed_path.to_s
16
+ normalize_registry(load_registry_file(source_path))
17
+ rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
18
+ raise Error, "Unable to load pricing registry #{source_path.inspect}: #{e.message}"
19
+ end
20
+
21
+ private
22
+
23
+ def load_registry_file(path)
24
+ if File.size(path) > Registry::MAX_FILE_BYTES
25
+ raise ArgumentError, "pricing registry exceeds #{Registry::MAX_FILE_BYTES} bytes"
26
+ end
27
+
28
+ contents = File.read(path)
29
+ registry = yaml_file?(path) ? (YAML.safe_load(contents, aliases: false) || {}) : JSON.parse(contents)
30
+ raise ArgumentError, "pricing registry must be a hash" unless registry.is_a?(Hash)
31
+
32
+ registry
33
+ end
34
+
35
+ def normalize_registry(registry)
36
+ {
37
+ "metadata" => normalize_hash(registry.fetch("metadata", {}), label: "pricing metadata"),
38
+ "models" => normalize_models(registry.fetch("models", {}))
39
+ }
40
+ end
41
+
42
+ def normalize_models(models)
43
+ normalize_hash(models, label: "pricing models").each_with_object({}) do |(model, entry), normalized|
44
+ normalized[model.to_s] = normalize_hash(entry, label: "pricing model entry")
45
+ end
46
+ end
47
+
48
+ def normalize_hash(hash, label:)
49
+ return {} if hash.nil?
50
+ raise ArgumentError, "#{label} must be a hash" unless hash.is_a?(Hash)
51
+
52
+ hash.each_with_object({}) do |(key, value), normalized|
53
+ normalized[key.to_s] = value
54
+ end
55
+ end
56
+
57
+ def yaml_file?(path)
58
+ YAML_EXTENSIONS.include?(File.extname(path).downcase)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end