llm_cost_tracker 0.8.0 → 0.9.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/README.md +12 -5
  4. data/app/assets/llm_cost_tracker/application.css +65 -5
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +25 -33
  6. data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -7
  8. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +4 -0
  9. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +10 -0
  12. data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
  13. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +5 -1
  15. data/app/models/llm_cost_tracker/call.rb +0 -3
  16. data/app/models/llm_cost_tracker/call_line_item.rb +1 -5
  17. data/app/models/llm_cost_tracker/call_rollup.rb +0 -3
  18. data/app/models/llm_cost_tracker/call_tag.rb +0 -4
  19. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +0 -4
  20. data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
  21. data/app/models/llm_cost_tracker/provider_invoice.rb +7 -3
  22. data/app/models/llm_cost_tracker/provider_invoice_import.rb +24 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +33 -4
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -4
  25. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  26. data/app/views/llm_cost_tracker/calls/show.html.erb +25 -40
  27. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
  28. data/app/views/llm_cost_tracker/data_quality/index.html.erb +91 -52
  29. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  30. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  31. data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
  32. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  33. data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
  34. data/config/routes.rb +3 -2
  35. data/lib/llm_cost_tracker/billing/components.rb +45 -3
  36. data/lib/llm_cost_tracker/billing/components.yml +71 -0
  37. data/lib/llm_cost_tracker/billing/line_item.rb +1 -1
  38. data/lib/llm_cost_tracker/budget.rb +4 -2
  39. data/lib/llm_cost_tracker/capture/stream_collector.rb +93 -20
  40. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  41. data/lib/llm_cost_tracker/configuration.rb +53 -1
  42. data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
  43. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
  44. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +26 -0
  45. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  46. data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
  47. data/lib/llm_cost_tracker/doctor.rb +72 -3
  48. data/lib/llm_cost_tracker/engine.rb +9 -0
  49. data/lib/llm_cost_tracker/event.rb +1 -1
  50. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  51. data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
  52. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +13 -3
  53. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  54. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +38 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
  65. data/lib/llm_cost_tracker/ingestion/inbox.rb +0 -1
  66. data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
  67. data/lib/llm_cost_tracker/ingestion/worker.rb +10 -2
  68. data/lib/llm_cost_tracker/ingestion.rb +48 -10
  69. data/lib/llm_cost_tracker/integrations/anthropic.rb +24 -5
  70. data/lib/llm_cost_tracker/integrations/base.rb +22 -5
  71. data/lib/llm_cost_tracker/integrations/openai.rb +300 -66
  72. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +105 -6
  73. data/lib/llm_cost_tracker/integrations.rb +19 -1
  74. data/lib/llm_cost_tracker/ledger/period/totals.rb +21 -5
  75. data/lib/llm_cost_tracker/ledger/rollups.rb +24 -10
  76. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +30 -1
  77. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -3
  78. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +17 -2
  79. data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -0
  80. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
  81. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
  82. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
  83. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +2 -2
  84. data/lib/llm_cost_tracker/ledger/store.rb +14 -14
  85. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  86. data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -1
  87. data/lib/llm_cost_tracker/ledger.rb +2 -1
  88. data/lib/llm_cost_tracker/masking.rb +39 -0
  89. data/lib/llm_cost_tracker/middleware/faraday.rb +88 -29
  90. data/lib/llm_cost_tracker/parsers/anthropic.rb +22 -7
  91. data/lib/llm_cost_tracker/parsers/base.rb +5 -1
  92. data/lib/llm_cost_tracker/parsers/gemini.rb +4 -0
  93. data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
  94. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +5 -1
  95. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +49 -10
  96. data/lib/llm_cost_tracker/parsers/openai_usage.rb +124 -53
  97. data/lib/llm_cost_tracker/prices.json +110 -19
  98. data/lib/llm_cost_tracker/pricing/effective_prices.rb +5 -36
  99. data/lib/llm_cost_tracker/pricing/lookup.rb +36 -3
  100. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  101. data/lib/llm_cost_tracker/pricing/registry.rb +3 -1
  102. data/lib/llm_cost_tracker/pricing/service_charges.rb +9 -3
  103. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
  104. data/lib/llm_cost_tracker/pricing/sync.rb +3 -1
  105. data/lib/llm_cost_tracker/pricing.rb +47 -19
  106. data/lib/llm_cost_tracker/railtie.rb +6 -0
  107. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  108. data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
  109. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
  110. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  111. data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
  112. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -0
  113. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  114. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  115. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  116. data/lib/llm_cost_tracker/report/data.rb +4 -1
  117. data/lib/llm_cost_tracker/retention.rb +15 -2
  118. data/lib/llm_cost_tracker/tags/context.rb +3 -4
  119. data/lib/llm_cost_tracker/tags/sanitizer.rb +60 -4
  120. data/lib/llm_cost_tracker/token_usage.rb +10 -2
  121. data/lib/llm_cost_tracker/tracker.rb +45 -18
  122. data/lib/llm_cost_tracker/version.rb +1 -1
  123. data/lib/llm_cost_tracker.rb +9 -0
  124. data/lib/tasks/llm_cost_tracker.rake +25 -2
  125. metadata +36 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "metadata": {
3
- "updated_at": "2026-05-02",
3
+ "updated_at": "2026-05-08",
4
4
  "currency": "USD",
5
5
  "unit": "1M tokens",
6
6
  "source_urls": [
@@ -18,10 +18,13 @@
18
18
  "service_charges": {
19
19
  "anthropic": {
20
20
  "web_search_request": 10.0,
21
+ "web_fetch_request": 0.0,
21
22
  "code_execution_hour": 0.05
22
23
  },
23
24
  "openai": {
24
25
  "web_search_request": 10.0,
26
+ "web_search_preview_request_reasoning": 10.0,
27
+ "web_search_preview_request_non_reasoning": 25.0,
25
28
  "file_search_call": 2.5
26
29
  }
27
30
  },
@@ -140,13 +143,13 @@
140
143
  },
141
144
  "gemini/gemini-2.0-flash": {
142
145
  "input": 0.1,
143
- "cache_read_input": 0.025,
144
146
  "output": 0.4,
147
+ "audio_input": 0.7,
148
+ "cache_read_input": 0.025,
145
149
  "batch_input": 0.05,
146
150
  "batch_output": 0.2,
147
- "batch_cache_read_input": 0.025,
148
- "audio_input": 0.7,
149
- "batch_audio_input": 0.35
151
+ "batch_audio_input": 0.35,
152
+ "batch_cache_read_input": 0.025
150
153
  },
151
154
  "gemini/gemini-2.0-flash-lite": {
152
155
  "input": 0.075,
@@ -157,52 +160,52 @@
157
160
  "gemini/gemini-2.5-flash": {
158
161
  "input": 0.3,
159
162
  "output": 2.5,
163
+ "audio_input": 1.0,
160
164
  "cache_read_input": 0.03,
161
165
  "batch_input": 0.15,
162
166
  "batch_output": 1.25,
167
+ "batch_audio_input": 0.5,
163
168
  "batch_cache_read_input": 0.03,
164
169
  "flex_input": 0.15,
165
170
  "flex_output": 1.25,
171
+ "flex_audio_input": 0.5,
166
172
  "flex_cache_read_input": 0.03,
167
173
  "priority_input": 0.54,
168
174
  "priority_output": 4.5,
169
- "priority_cache_read_input": 0.054,
170
- "audio_input": 1.0,
171
- "batch_audio_input": 0.5,
172
- "flex_audio_input": 0.5,
173
- "priority_audio_input": 1.8
175
+ "priority_audio_input": 1.8,
176
+ "priority_cache_read_input": 0.054
174
177
  },
175
178
  "gemini/gemini-2.5-flash-lite": {
176
179
  "input": 0.1,
177
180
  "output": 0.4,
181
+ "audio_input": 0.3,
178
182
  "cache_read_input": 0.01,
179
183
  "batch_input": 0.05,
180
184
  "batch_output": 0.2,
185
+ "batch_audio_input": 0.15,
181
186
  "batch_cache_read_input": 0.01,
182
187
  "flex_input": 0.05,
183
188
  "flex_output": 0.2,
189
+ "flex_audio_input": 0.15,
184
190
  "flex_cache_read_input": 0.01,
185
191
  "priority_input": 0.18,
186
192
  "priority_output": 0.72,
187
- "priority_cache_read_input": 0.018,
188
- "audio_input": 0.3,
189
- "batch_audio_input": 0.15,
190
- "flex_audio_input": 0.15,
191
- "priority_audio_input": 0.54
193
+ "priority_audio_input": 0.54,
194
+ "priority_cache_read_input": 0.018
192
195
  },
193
196
  "gemini/gemini-2.5-pro": {
194
197
  "input": 1.25,
195
198
  "output": 10.0,
196
- "cache_read_input": 0.125,
197
- "batch_input": 0.625,
198
- "batch_output": 5.0,
199
- "batch_cache_read_input": 0.125,
200
199
  "_context_price_threshold_tokens": 200000,
201
200
  "above_context_input": 2.5,
202
201
  "above_context_output": 15.0,
202
+ "cache_read_input": 0.125,
203
203
  "above_context_cache_read_input": 0.25,
204
+ "batch_input": 0.625,
205
+ "batch_output": 5.0,
204
206
  "above_context_batch_input": 1.25,
205
207
  "above_context_batch_output": 7.5,
208
+ "batch_cache_read_input": 0.125,
206
209
  "above_context_batch_cache_read_input": 0.25,
207
210
  "flex_input": 0.625,
208
211
  "flex_output": 5.0,
@@ -394,6 +397,76 @@
394
397
  "output": 0.6,
395
398
  "audio_output": 20.0
396
399
  },
400
+ "openai/text-embedding-3-small": {
401
+ "input": 0.02,
402
+ "batch_input": 0.01
403
+ },
404
+ "openai/text-embedding-3-large": {
405
+ "input": 0.13,
406
+ "batch_input": 0.065
407
+ },
408
+ "openai/text-embedding-ada-002": {
409
+ "input": 0.10,
410
+ "batch_input": 0.05
411
+ },
412
+ "openai/gpt-4o-transcribe": {
413
+ "input": 2.5,
414
+ "audio_input": 6.0,
415
+ "output": 10.0
416
+ },
417
+ "openai/gpt-4o-mini-transcribe": {
418
+ "input": 1.25,
419
+ "audio_input": 3.0,
420
+ "output": 5.0
421
+ },
422
+ "openai/tts-1": {
423
+ "text_to_speech_character": 15.0
424
+ },
425
+ "openai/tts-1-hd": {
426
+ "text_to_speech_character": 30.0
427
+ },
428
+ "openai/gpt-image-1": {
429
+ "input": 5.0,
430
+ "cache_read_input": 1.25,
431
+ "image_input": 10.0,
432
+ "image_output": 40.0,
433
+ "batch_input": 2.5,
434
+ "batch_cache_read_input": 0.63,
435
+ "batch_image_input": 5.0,
436
+ "batch_image_output": 20.0
437
+ },
438
+ "openai/gpt-image-1-mini": {
439
+ "input": 2.0,
440
+ "cache_read_input": 0.2,
441
+ "image_input": 2.5,
442
+ "image_output": 8.0,
443
+ "batch_input": 1.0,
444
+ "batch_cache_read_input": 0.1,
445
+ "batch_image_input": 1.25,
446
+ "batch_image_output": 4.0
447
+ },
448
+ "openai/gpt-image-1.5": {
449
+ "input": 5.0,
450
+ "cache_read_input": 1.25,
451
+ "output": 10.0,
452
+ "image_input": 8.0,
453
+ "image_output": 32.0,
454
+ "batch_input": 2.5,
455
+ "batch_cache_read_input": 0.63,
456
+ "batch_output": 5.0,
457
+ "batch_image_input": 4.0,
458
+ "batch_image_output": 16.0
459
+ },
460
+ "openai/gpt-image-2": {
461
+ "input": 5.0,
462
+ "cache_read_input": 1.25,
463
+ "image_input": 8.0,
464
+ "image_output": 30.0,
465
+ "batch_input": 2.5,
466
+ "batch_cache_read_input": 0.625,
467
+ "batch_image_input": 4.0,
468
+ "batch_image_output": 15.0
469
+ },
397
470
  "openai/gpt-5": {
398
471
  "input": 1.25,
399
472
  "output": 10.0,
@@ -805,6 +878,24 @@
805
878
  "input": 1.5,
806
879
  "output": 6.0,
807
880
  "cache_read_input": 0.375
881
+ },
882
+ "gemini/gemini-3.1-flash-lite": {
883
+ "input": 0.25,
884
+ "output": 1.5,
885
+ "audio_input": 0.5,
886
+ "cache_read_input": 0.025,
887
+ "batch_input": 0.125,
888
+ "batch_output": 0.75,
889
+ "batch_audio_input": 0.25,
890
+ "batch_cache_read_input": 0.0125,
891
+ "flex_input": 0.125,
892
+ "flex_output": 0.75,
893
+ "flex_audio_input": 0.25,
894
+ "flex_cache_read_input": 0.0125,
895
+ "priority_input": 0.45,
896
+ "priority_output": 2.7,
897
+ "priority_audio_input": 0.9,
898
+ "priority_cache_read_input": 0.045
808
899
  }
809
900
  }
810
901
  }
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../billing/components"
4
+ require_relative "mode"
4
5
 
5
6
  module LlmCostTracker
6
7
  module Pricing
@@ -8,6 +9,7 @@ module LlmCostTracker
8
9
  class << self
9
10
  def call(usage:, prices:, pricing_mode:)
10
11
  context_tier = context_tier?(usage: usage, prices: prices)
12
+ orderings = pricing_mode && Mode.parse(pricing_mode).permutations
11
13
 
12
14
  Billing::Components::TOKEN_PRICED.to_h do |component|
13
15
  price_key = component.key
@@ -16,7 +18,7 @@ module LlmCostTracker
16
18
  price_for(
17
19
  prices: prices,
18
20
  key: price_key,
19
- pricing_mode: pricing_mode,
21
+ orderings: orderings,
20
22
  context_tier: context_tier
21
23
  )
22
24
  else
@@ -28,10 +30,9 @@ module LlmCostTracker
28
30
 
29
31
  private
30
32
 
31
- def price_for(prices:, key:, pricing_mode:, context_tier:)
32
- return contextual_price(prices: prices, key: key, context_tier: context_tier) unless pricing_mode
33
+ def price_for(prices:, key:, orderings:, context_tier:)
34
+ return contextual_price(prices: prices, key: key, context_tier: context_tier) unless orderings
33
35
 
34
- orderings = mode_orderings_for(pricing_mode)
35
36
  orderings.each do |mode|
36
37
  direct = contextual_price(prices: prices, key: :"#{mode}_#{key}", context_tier: context_tier)
37
38
  return direct if direct
@@ -41,38 +42,6 @@ module LlmCostTracker
41
42
  derived_mode_price(prices: prices, key: key, modes: orderings, context_tier: context_tier)
42
43
  end
43
44
 
44
- def mode_orderings_for(pricing_mode)
45
- mode_string = pricing_mode.to_s
46
- return [mode_string] unless mode_string.include?("_")
47
-
48
- tokens = tokenize_mode(mode_string)
49
- return [mode_string] if tokens.size <= 1
50
-
51
- [mode_string, *tokens.permutation.map { |permutation| permutation.join("_") }].uniq
52
- end
53
-
54
- def tokenize_mode(mode_string)
55
- remaining = mode_string.dup
56
- tokens = []
57
- loop do
58
- break if remaining.empty?
59
-
60
- compound = COMPOUND_MODE_TOKENS.find { |token| remaining == token || remaining.start_with?("#{token}_") }
61
- if compound
62
- tokens << compound
63
- remaining = remaining.delete_prefix(compound).delete_prefix("_")
64
- else
65
- first, _, rest = remaining.partition("_")
66
- tokens << first
67
- remaining = rest
68
- end
69
- end
70
- tokens
71
- end
72
-
73
- COMPOUND_MODE_TOKENS = %w[data_residency].freeze
74
- private_constant :COMPOUND_MODE_TOKENS
75
-
76
45
  def contextual_price(prices:, key:, context_tier:)
77
46
  return prices[key] unless context_tier
78
47
 
@@ -7,6 +7,9 @@ module LlmCostTracker
7
7
  MUTEX = Mutex.new
8
8
  CACHE_MISS = Object.new.freeze
9
9
  NO_MATCH = Object.new.freeze
10
+ LOOKUP_CACHE_LIMIT = 2_048
11
+ PRICE_FILE_RECHECK_INTERVAL = 1.0
12
+ private_constant :PRICE_FILE_RECHECK_INTERVAL
10
13
 
11
14
  class << self
12
15
  def call(provider:, model:)
@@ -14,6 +17,8 @@ module LlmCostTracker
14
17
  model_name = model.to_s
15
18
  return nil if model_name.empty?
16
19
 
20
+ invalidate_cache_if_prices_file_changed!
21
+
17
22
  cache_key = [provider_name, model_name]
18
23
  cached = cached_lookup(cache_key)
19
24
  return cached unless cached.equal?(CACHE_MISS)
@@ -25,14 +30,41 @@ module LlmCostTracker
25
30
 
26
31
  def reset!
27
32
  MUTEX.synchronize do
28
- @prices_cache = nil
29
- @lookup_cache = nil
30
- @sorted_price_keys_cache = nil
33
+ reset_prices_caches!(signature: nil)
34
+ @prices_file_last_check_at = nil
31
35
  end
32
36
  end
33
37
 
34
38
  private
35
39
 
40
+ def invalidate_cache_if_prices_file_changed!
41
+ path = LlmCostTracker.configuration.prices_file
42
+
43
+ unless path
44
+ return if @prices_file_signature.nil?
45
+
46
+ MUTEX.synchronize { reset_prices_caches!(signature: nil) unless @prices_file_signature.nil? }
47
+ return
48
+ end
49
+
50
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
51
+ last_check = @prices_file_last_check_at
52
+ return if last_check && (now - last_check) < PRICE_FILE_RECHECK_INTERVAL
53
+
54
+ signature = File.exist?(path) ? File.mtime(path) : nil
55
+ MUTEX.synchronize do
56
+ @prices_file_last_check_at = now
57
+ reset_prices_caches!(signature: signature) if @prices_file_signature != signature
58
+ end
59
+ end
60
+
61
+ def reset_prices_caches!(signature:)
62
+ @prices_cache = nil
63
+ @lookup_cache = nil
64
+ @sorted_price_keys_cache = nil
65
+ @prices_file_signature = signature
66
+ end
67
+
36
68
  def lookup_match(provider_name:, model_name:)
37
69
  provider_model = provider_name ? "#{provider_name}/#{model_name}" : model_name
38
70
  normalized_model = normalize_model_name(model_name)
@@ -86,6 +118,7 @@ module LlmCostTracker
86
118
  def cache_lookup(cache_key, match)
87
119
  MUTEX.synchronize do
88
120
  values = (@lookup_cache || {}).dup
121
+ values.shift while values.size >= LOOKUP_CACHE_LIMIT
89
122
  values[cache_key] = match || NO_MATCH
90
123
  @lookup_cache = values.freeze
91
124
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Pricing
5
+ class Mode
6
+ COMPOUND_MODIFIERS = %w[data_residency].freeze
7
+
8
+ attr_reader :modifiers
9
+
10
+ def self.parse(value)
11
+ return value if value.is_a?(self)
12
+ return new([]) if value.nil?
13
+
14
+ new(tokenize(value.to_s))
15
+ end
16
+
17
+ def self.tokenize(value)
18
+ remaining = value.to_s.downcase.tr("-", "_")
19
+ tokens = []
20
+ loop do
21
+ break if remaining.empty?
22
+
23
+ compound = COMPOUND_MODIFIERS.find do |token|
24
+ remaining == token || remaining.start_with?("#{token}_")
25
+ end
26
+ if compound
27
+ tokens << compound.to_sym
28
+ remaining = remaining.delete_prefix(compound).delete_prefix("_")
29
+ else
30
+ first, _, rest = remaining.partition("_")
31
+ tokens << first.to_sym unless first.empty?
32
+ remaining = rest
33
+ end
34
+ end
35
+ tokens
36
+ end
37
+
38
+ def initialize(modifiers)
39
+ @modifiers = Array(modifiers).map(&:to_sym).uniq.sort
40
+ freeze
41
+ end
42
+
43
+ def empty?
44
+ modifiers.empty?
45
+ end
46
+
47
+ def include?(modifier)
48
+ modifiers.include?(modifier.to_sym)
49
+ end
50
+
51
+ def canonical
52
+ modifiers.join("_")
53
+ end
54
+ alias to_s canonical
55
+
56
+ def to_sym
57
+ empty? ? nil : canonical.to_sym
58
+ end
59
+
60
+ def permutations
61
+ return [canonical] if modifiers.size <= 1
62
+
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? ==
70
+
71
+ def hash
72
+ modifiers.hash
73
+ end
74
+ end
75
+ end
76
+ end
@@ -115,6 +115,7 @@ module LlmCostTracker
115
115
 
116
116
  def non_negative_float(key, value)
117
117
  rate = Float(value)
118
+ raise ArgumentError, "price for #{key.inspect} must be finite (got #{rate})" unless rate.finite?
118
119
  raise ArgumentError, "price for #{key.inspect} must be non-negative (got #{rate})" if rate.negative?
119
120
 
120
121
  rate
@@ -145,8 +146,9 @@ module LlmCostTracker
145
146
 
146
147
  def price_key_for(key)
147
148
  name = key.is_a?(Symbol) ? key.name : key
148
- Billing::Components::TOKEN_PRICED.each do |candidate|
149
+ Billing::Components::REGISTRY.each do |candidate|
149
150
  return candidate.key if candidate.key.name == name
151
+ next unless candidate.token_key
150
152
 
151
153
  suffix = "_#{candidate.key.name}"
152
154
  next unless name.end_with?(suffix)
@@ -121,14 +121,20 @@ module LlmCostTracker
121
121
 
122
122
  def amount_for(key, amount, context:)
123
123
  value = BigDecimal(amount.to_s)
124
- message = "service charge price amount for #{key.inspect} in #{context} must be non-negative"
125
- raise ArgumentError, message if value.negative?
124
+ if value.infinite? || value.nan?
125
+ raise ArgumentError,
126
+ "service charge price amount for #{key.inspect} in #{context} must be finite"
127
+ end
128
+ if value.negative?
129
+ raise ArgumentError,
130
+ "service charge price amount for #{key.inspect} in #{context} must be non-negative"
131
+ end
126
132
 
127
133
  value
128
134
  end
129
135
 
130
136
  def rate_quantity(component)
131
- component.unit == :request ? BigDecimal("1000") : BigDecimal("1")
137
+ BigDecimal(Billing::RATE_BASIS_QUANTITIES.fetch(component.rate_basis, 1).to_s)
132
138
  end
133
139
 
134
140
  def charge_rate_match(provider:, component:, pricing_mode:)
@@ -9,10 +9,12 @@ module LlmCostTracker
9
9
  module Sync
10
10
  class RegistryWriter
11
11
  YAML_EXTENSIONS = %w[.yml .yaml].freeze
12
+ MANUAL_SOURCE = "manual"
12
13
 
13
14
  def call(path:, registry:)
14
15
  FileUtils.mkdir_p(File.dirname(path))
15
- payload = yaml_file?(path) ? YAML.dump(registry) : "#{JSON.pretty_generate(registry)}\n"
16
+ merged = merge_with_existing(path: path, registry: registry)
17
+ payload = yaml_file?(path) ? YAML.dump(merged) : "#{JSON.pretty_generate(merged)}\n"
16
18
  temp_path = "#{path}.tmp-#{Process.pid}-#{Thread.current.object_id}"
17
19
  File.write(temp_path, payload)
18
20
  File.rename(temp_path, path)
@@ -22,6 +24,53 @@ module LlmCostTracker
22
24
 
23
25
  private
24
26
 
27
+ def merge_with_existing(path:, registry:)
28
+ existing = read_existing(path)
29
+ return registry unless existing.is_a?(Hash)
30
+
31
+ merged = registry.dup
32
+ merged["models"] = merged_models(registry, existing) if existing["models"].is_a?(Hash)
33
+ if existing["service_charges"].is_a?(Hash)
34
+ merged["service_charges"] = merged_service_charges(registry, existing)
35
+ end
36
+ merged
37
+ end
38
+
39
+ def merged_models(registry, existing)
40
+ merged = registry.fetch("models", {}).dup
41
+ existing.fetch("models", {}).each do |model, attrs|
42
+ next unless attrs.is_a?(Hash) && attrs["_source"].to_s == MANUAL_SOURCE
43
+ next if merged.key?(model)
44
+
45
+ merged[model] = attrs
46
+ end
47
+ merged
48
+ end
49
+
50
+ def merged_service_charges(registry, existing)
51
+ remote = registry.fetch("service_charges", {})
52
+ existing.fetch("service_charges", {}).each_with_object(remote.dup) do |(provider, charges), merged|
53
+ next unless charges.is_a?(Hash)
54
+
55
+ merged[provider] = charges.merge(merged.fetch(provider, {}))
56
+ end
57
+ end
58
+
59
+ def read_existing(path)
60
+ return nil unless File.exist?(path)
61
+
62
+ contents = File.read(path)
63
+ return nil if contents.strip.empty?
64
+
65
+ if yaml_file?(path)
66
+ YAML.safe_load(contents, permitted_classes: [Symbol, Date, Time])
67
+ else
68
+ JSON.parse(contents)
69
+ end
70
+ rescue StandardError
71
+ nil
72
+ end
73
+
25
74
  def yaml_file?(path)
26
75
  YAML_EXTENSIONS.include?(File.extname(path).downcase)
27
76
  end
@@ -150,8 +150,10 @@ module LlmCostTracker
150
150
  end
151
151
 
152
152
  def load_registry(path)
153
+ return {} unless File.exist?(path)
154
+
153
155
  YAML.safe_load_file(path, aliases: false) || {}
154
- rescue Errno::ENOENT, Psych::Exception, ArgumentError, TypeError => e
156
+ rescue Psych::Exception, ArgumentError, TypeError => e
155
157
  raise Error, "Unable to load pricing registry #{path.inspect}: #{e.message}"
156
158
  end
157
159