llm_cost_tracker 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +7 -4
  4. data/app/assets/llm_cost_tracker/application.css +8 -7
  5. data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -5
  6. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/pricing_controller.rb +1 -1
  8. data/app/helpers/llm_cost_tracker/application_helper.rb +6 -15
  9. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  10. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +4 -4
  11. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  12. data/app/models/llm_cost_tracker/call.rb +28 -63
  13. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  14. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  15. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  16. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
  17. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
  18. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  19. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  20. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  21. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  22. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  23. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +30 -44
  24. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +4 -60
  25. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +1 -7
  26. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  27. data/app/views/layouts/llm_cost_tracker/application.html.erb +0 -6
  28. data/app/views/llm_cost_tracker/calls/index.html.erb +8 -8
  29. data/app/views/llm_cost_tracker/calls/show.html.erb +31 -23
  30. data/app/views/llm_cost_tracker/dashboard/index.html.erb +8 -8
  31. data/app/views/llm_cost_tracker/data_quality/index.html.erb +62 -117
  32. data/app/views/llm_cost_tracker/models/index.html.erb +5 -5
  33. data/app/views/llm_cost_tracker/pricing/index.html.erb +2 -2
  34. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -1
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -3
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +10 -10
  39. data/config/routes.rb +2 -3
  40. data/lib/llm_cost_tracker/budget.rb +24 -26
  41. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  42. data/lib/llm_cost_tracker/capture/sse.rb +1 -0
  43. data/lib/llm_cost_tracker/capture/stream_collector.rb +28 -36
  44. data/lib/llm_cost_tracker/capture/stream_tracker.rb +17 -28
  45. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  46. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  47. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  48. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
  49. data/lib/llm_cost_tracker/check.rb +5 -0
  50. data/lib/llm_cost_tracker/configuration.rb +13 -44
  51. data/lib/llm_cost_tracker/currency.rb +5 -0
  52. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  53. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  54. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  55. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  56. data/lib/llm_cost_tracker/doctor.rb +5 -69
  57. data/lib/llm_cost_tracker/engine.rb +4 -4
  58. data/lib/llm_cost_tracker/event.rb +12 -20
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
  63. data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
  64. data/lib/llm_cost_tracker/ingestion/inbox.rb +7 -8
  65. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  66. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  67. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  68. data/lib/llm_cost_tracker/integrations/anthropic.rb +92 -106
  69. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  70. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  71. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  72. data/lib/llm_cost_tracker/integrations/openai.rb +70 -276
  73. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +87 -99
  74. data/lib/llm_cost_tracker/integrations.rb +32 -25
  75. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  76. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  77. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  78. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  79. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  81. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  82. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  83. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  84. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  85. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  86. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  87. data/lib/llm_cost_tracker/ledger/store.rb +18 -42
  88. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  89. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  90. data/lib/llm_cost_tracker/ledger.rb +8 -18
  91. data/lib/llm_cost_tracker/logging.rb +4 -21
  92. data/lib/llm_cost_tracker/middleware/faraday.rb +61 -50
  93. data/lib/llm_cost_tracker/parsers.rb +139 -26
  94. data/lib/llm_cost_tracker/prices.json +1707 -1
  95. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  96. data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
  97. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  98. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  99. data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
  100. data/lib/llm_cost_tracker/pricing/mode.rb +40 -52
  101. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  102. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  103. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  104. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  105. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  106. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  107. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  108. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  109. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  110. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  111. data/lib/llm_cost_tracker/pricing.rb +10 -278
  112. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  113. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  114. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  115. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  116. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  117. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  118. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  119. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  120. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  121. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  122. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  123. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  124. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +63 -39
  125. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  126. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  127. data/lib/llm_cost_tracker/providers.rb +35 -0
  128. data/lib/llm_cost_tracker/railtie.rb +0 -3
  129. data/lib/llm_cost_tracker/report/data.rb +3 -4
  130. data/lib/llm_cost_tracker/report/formatter.rb +1 -1
  131. data/lib/llm_cost_tracker/report.rb +1 -1
  132. data/lib/llm_cost_tracker/retention.rb +6 -19
  133. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  134. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  135. data/lib/llm_cost_tracker/timing.rb +2 -4
  136. data/lib/llm_cost_tracker/tracker.rb +24 -36
  137. data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
  138. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  139. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
  140. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  141. data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
  142. data/lib/llm_cost_tracker/version.rb +1 -1
  143. data/lib/llm_cost_tracker.rb +43 -52
  144. data/lib/tasks/llm_cost_tracker.rake +14 -73
  145. metadata +81 -55
  146. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -100
  147. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  148. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  149. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  150. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  151. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -174
  152. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  153. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  154. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  155. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  156. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  157. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  158. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  159. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  160. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  161. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  162. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  163. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -36
  164. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -27
  165. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  166. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  167. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  168. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  169. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  170. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  171. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  172. data/lib/llm_cost_tracker/masking.rb +0 -39
  173. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -176
  174. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  175. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  176. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -230
  177. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  178. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -45
  179. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  180. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  181. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  182. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  183. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +0 -15
  184. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  185. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -131
  186. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  187. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  188. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  189. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -249
  190. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -148
  191. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +0 -40
  192. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  193. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -118
  194. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  195. data/lib/llm_cost_tracker/token_usage.rb +0 -93
@@ -1,74 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../token_usage"
4
- require_relative "effective_prices"
5
-
6
- module LlmCostTracker
7
- module Pricing
8
- Explanation = Data.define(
9
- :provider,
10
- :model,
11
- :pricing_mode,
12
- :source,
13
- :matched_key,
14
- :matched_by,
15
- :prices,
16
- :effective_prices,
17
- :missing_price_keys
18
- ) do
19
- def matched?
20
- !prices.nil?
21
- end
22
-
23
- def complete?
24
- matched? && missing_price_keys.empty?
25
- end
26
-
27
- def message
28
- return "No price entry matched #{provider}/#{model}" unless matched?
29
- return "Matched #{matched_key} from #{source} via #{matched_by}" if complete?
30
-
31
- "Matched #{matched_key} from #{source} via #{matched_by}, but missing #{missing_price_keys.join(', ')}"
32
- end
33
- end
34
-
35
- module Explainer
36
- class << self
37
- def call(provider:, model:, tokens:, pricing_mode: nil)
38
- match = Lookup.call(provider: provider, model: model)
39
-
40
- explanation(
41
- provider: provider,
42
- model: model,
43
- pricing_mode: pricing_mode,
44
- match: match,
45
- usage: TokenUsage.build_from_tokens(tokens)
46
- )
47
- end
48
-
49
- private
50
-
51
- def explanation(provider:, model:, pricing_mode:, match:, usage:)
52
- prices = match&.prices
53
- pricing_mode = Pricing::Mode.normalize(pricing_mode)
54
- effective = if prices
55
- EffectivePrices.call(usage: usage, quantities: usage.priced_quantities,
56
- prices: prices, pricing_mode: pricing_mode)
57
- end
58
-
59
- Explanation.new(
60
- provider: provider.to_s,
61
- model: model.to_s,
62
- pricing_mode: pricing_mode,
63
- source: match&.source,
64
- matched_key: match&.key,
65
- matched_by: match&.matched_by,
66
- prices: prices,
67
- effective_prices: effective || {},
68
- missing_price_keys: effective ? effective.filter_map { |key, value| key if value.nil? } : []
69
- )
70
- end
71
- end
72
- end
73
- end
74
- end
@@ -1,236 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LlmCostTracker
4
- module Pricing
5
- module Lookup
6
- Match = Data.define(:source, :key, :prices, :matched_by, :currency)
7
- DEFAULT_CURRENCY = "USD"
8
- MUTEX = Mutex.new
9
- CACHE_MISS = Object.new.freeze
10
- NO_MATCH = Object.new.freeze
11
- LOOKUP_CACHE_LIMIT = 2_048
12
- PRICE_FILE_RECHECK_INTERVAL = 1.0
13
- private_constant :PRICE_FILE_RECHECK_INTERVAL
14
-
15
- class << self
16
- def call(provider:, model:)
17
- provider_name = provider.to_s.presence
18
- model_name = model.to_s
19
- return nil if model_name.empty?
20
-
21
- invalidate_cache_if_prices_file_changed!
22
-
23
- cache_key = [provider_name, model_name]
24
- cached = cached_lookup(cache_key)
25
- return cached unless cached.equal?(CACHE_MISS)
26
-
27
- match = lookup_match(provider_name: provider_name, model_name: model_name)
28
- cache_lookup(cache_key, match)
29
- match
30
- end
31
-
32
- def reset!
33
- MUTEX.synchronize do
34
- reset_prices_caches!(signature: nil)
35
- @prices_file_last_check_at = nil
36
- end
37
- end
38
-
39
- def prices_file_mtime_iso
40
- invalidate_cache_if_prices_file_changed!
41
- signature = @prices_file_signature
42
- return nil unless signature
43
-
44
- cached = @prices_file_iso_cache
45
- return cached[:value] if cached && cached[:mtime] == signature
46
-
47
- MUTEX.synchronize do
48
- cached = @prices_file_iso_cache
49
- return cached[:value] if cached && cached[:mtime] == signature
50
-
51
- iso = signature.utc.iso8601
52
- @prices_file_iso_cache = { mtime: signature, value: iso }.freeze
53
- iso
54
- end
55
- end
56
-
57
- private
58
-
59
- def invalidate_cache_if_prices_file_changed!
60
- path = LlmCostTracker.configuration.prices_file
61
-
62
- unless path
63
- return if @prices_file_signature.nil?
64
-
65
- MUTEX.synchronize { reset_prices_caches!(signature: nil) unless @prices_file_signature.nil? }
66
- return
67
- end
68
-
69
- now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
70
- last_check = @prices_file_last_check_at
71
- return if last_check && (now - last_check) < PRICE_FILE_RECHECK_INTERVAL
72
-
73
- signature = File.exist?(path) ? File.mtime(path) : nil
74
- MUTEX.synchronize do
75
- @prices_file_last_check_at = now
76
- reset_prices_caches!(signature: signature) if @prices_file_signature != signature
77
- end
78
- end
79
-
80
- def reset_prices_caches!(signature:)
81
- @prices_cache = nil
82
- @lookup_cache = nil
83
- @sorted_price_keys_cache = nil
84
- @prices_file_iso_cache = nil
85
- @prices_file_signature = signature
86
- end
87
-
88
- def lookup_match(provider_name:, model_name:)
89
- provider_model = provider_name ? "#{provider_name}/#{model_name}" : model_name
90
- normalized_model = normalize_model_name(model_name)
91
- current = current_price_tables
92
-
93
- ordered_table_lookups(current).each do |source, table|
94
- match = explain_table(
95
- table: table,
96
- source: source,
97
- provider_model: provider_model,
98
- model_name: model_name,
99
- normalized_model: normalized_model
100
- )
101
- return match if match
102
- end
103
- nil
104
- end
105
-
106
- def ordered_table_lookups(current)
107
- [
108
- [:pricing_overrides, current.fetch(:pricing_overrides)],
109
- [:prices_file, current.fetch(:file_prices)],
110
- [:bundled, Registry.builtin_prices]
111
- ]
112
- end
113
-
114
- def current_price_tables
115
- cached = @prices_cache
116
- return cached if cached
117
-
118
- MUTEX.synchronize do
119
- cached = @prices_cache
120
- return cached if cached
121
-
122
- config = LlmCostTracker.configuration
123
- file_prices = Registry.file_prices(config.prices_file)
124
- value = { pricing_overrides: config.pricing_overrides, file_prices: file_prices }.freeze
125
- @prices_cache = value
126
- value
127
- end
128
- end
129
-
130
- def cached_lookup(cache_key)
131
- cached = @lookup_cache
132
- return CACHE_MISS unless cached&.key?(cache_key)
133
-
134
- match = cached.fetch(cache_key)
135
- match.equal?(NO_MATCH) ? nil : match
136
- end
137
-
138
- def cache_lookup(cache_key, match)
139
- MUTEX.synchronize do
140
- values = (@lookup_cache || {}).dup
141
- values.shift while values.size >= LOOKUP_CACHE_LIMIT
142
- values[cache_key] = match || NO_MATCH
143
- @lookup_cache = values.freeze
144
- end
145
- end
146
-
147
- def explain_table(table:, source:, provider_model:, model_name:, normalized_model:)
148
- return nil if table.empty?
149
-
150
- direct_match(table: table, source: source, key: provider_model, matched_by: :provider_model) ||
151
- direct_match(table: table, source: source, key: model_name, matched_by: :model) ||
152
- direct_match(table: table, source: source, key: normalized_model, matched_by: :normalized_model) ||
153
- unique_providerless_lookup(model: normalized_model, table: table, source: source) ||
154
- fuzzy_match(model: provider_model, normalized_model: normalized_model, table: table, source: source) ||
155
- unique_providerless_fuzzy_match(model: normalized_model, table: table, source: source)
156
- end
157
-
158
- def normalize_model_name(model)
159
- model.to_s.split("/").last
160
- end
161
-
162
- def unique_providerless_lookup(model:, table:, source:)
163
- matches = sorted_price_keys(table).select { |key| normalize_model_name(key) == model }
164
- return unless matches.one?
165
-
166
- match(table: table, source: source, key: matches.first, matched_by: :unique_providerless_model)
167
- end
168
-
169
- def fuzzy_match(model:, normalized_model:, table:, source:)
170
- sorted_price_keys(table).each do |key|
171
- if snapshot_variant?(model, key) || snapshot_variant?(normalized_model, key)
172
- return match(table: table, source: source, key: key, matched_by: :dated_snapshot)
173
- end
174
- end
175
-
176
- nil
177
- end
178
-
179
- def unique_providerless_fuzzy_match(model:, table:, source:)
180
- matches = sorted_price_keys(table).select { |key| snapshot_variant?(model, normalize_model_name(key)) }
181
- return unless matches.one?
182
-
183
- match(table: table, source: source, key: matches.first, matched_by: :unique_providerless_dated_snapshot)
184
- end
185
-
186
- def direct_match(table:, source:, key:, matched_by:)
187
- match(table: table, source: source, key: key, matched_by: matched_by) if table.key?(key)
188
- end
189
-
190
- def match(table:, source:, key:, matched_by:)
191
- Match.new(
192
- source: source,
193
- key: key,
194
- prices: table[key],
195
- matched_by: matched_by,
196
- currency: source_currency(source)
197
- )
198
- end
199
-
200
- def source_currency(source)
201
- case source
202
- when :bundled then Registry.metadata["currency"] || DEFAULT_CURRENCY
203
- when :prices_file
204
- Registry.file_metadata(LlmCostTracker.configuration.prices_file)["currency"] || DEFAULT_CURRENCY
205
- else DEFAULT_CURRENCY
206
- end
207
- end
208
-
209
- def snapshot_variant?(model, key)
210
- suffix = model.delete_prefix("#{key}-")
211
- return false if suffix == model
212
-
213
- suffix.match?(/\A(?:\d{4}-\d{2}-\d{2}|\d{8})\z/)
214
- end
215
-
216
- def sorted_price_keys(table)
217
- cached = @sorted_price_keys_cache
218
- existing = cached && cached[table]
219
- return existing if existing
220
-
221
- MUTEX.synchronize do
222
- cached = @sorted_price_keys_cache
223
- existing = cached && cached[table]
224
- return existing if existing
225
-
226
- keys = table.keys.sort_by { |key| -key.length }
227
- next_cache = cached ? cached.dup : {}.compare_by_identity
228
- next_cache[table] = keys
229
- @sorted_price_keys_cache = next_cache.freeze
230
- keys
231
- end
232
- end
233
- end
234
- end
235
- end
236
- end
@@ -1,206 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_support/core_ext/object/blank"
4
- require "bigdecimal"
5
- require "time"
6
- require "yaml"
7
-
8
- require_relative "../billing/components"
9
- require_relative "registry"
10
-
11
- module LlmCostTracker
12
- module Pricing
13
- module ServiceCharges
14
- extend self
15
-
16
- DEFAULT_CURRENCY = "USD"
17
- EMPTY_RATES = {}.freeze
18
- MUTEX = Mutex.new
19
-
20
- def reset!
21
- MUTEX.synchronize do
22
- @builtin_rates = nil
23
- @file_rates_cache = nil
24
- end
25
- end
26
-
27
- def builtin_rates
28
- cached = @builtin_rates
29
- return cached if cached
30
-
31
- MUTEX.synchronize do
32
- @builtin_rates ||= begin
33
- registry = YAML.safe_load_file(Registry::DEFAULT_PRICES_PATH, aliases: false) || {}
34
- rates_from_registry(registry).freeze
35
- end
36
- end
37
- end
38
-
39
- def file_rates(path)
40
- return EMPTY_RATES unless path
41
-
42
- cache_key = [path, File.mtime(path)]
43
- cached = @file_rates_cache
44
- return cached[:value] if cached && cached[:key] == cache_key
45
-
46
- MUTEX.synchronize do
47
- cached = @file_rates_cache
48
- return cached[:value] if cached && cached[:key] == cache_key
49
-
50
- registry = YAML.safe_load_file(path, aliases: false) || {}
51
- value = rates_from_registry(registry, context: path).freeze
52
- @file_rates_cache = { key: cache_key, value: value }.freeze
53
- value
54
- end
55
- rescue Errno::ENOENT, Psych::Exception, ArgumentError, TypeError => e
56
- raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
57
- end
58
-
59
- def rates_from_registry(registry, context: "price registry")
60
- data = registry.fetch("service_charges", EMPTY_RATES)
61
- raise ArgumentError, "#{context} service_charges must be a hash" unless data.is_a?(Hash)
62
-
63
- currency = registry.dig("metadata", "currency") || DEFAULT_CURRENCY
64
- data.each_with_object({}) do |(provider, entries), rates|
65
- section_context = "#{context} service_charges.#{provider}"
66
- rates[provider] = rates_from_section(entries, currency: currency, context: section_context)
67
- end
68
- end
69
-
70
- def charge_rate(provider:, component:, pricing_mode:)
71
- pricing_mode = Pricing::Mode.normalize(pricing_mode)
72
- match = charge_rate_match(provider: provider, component: component, pricing_mode: pricing_mode)
73
- return nil unless match
74
-
75
- rate = match.fetch(:rate)
76
- {
77
- amount: rate.fetch(:amount),
78
- quantity: rate.fetch(:quantity),
79
- currency: rate.fetch(:currency),
80
- source: match.fetch(:source),
81
- source_key: match.fetch(:key),
82
- source_version: rate_source_version_for(match.fetch(:source))
83
- }
84
- end
85
-
86
- private
87
-
88
- def rates_from_section(entries, currency:, context:)
89
- raise ArgumentError, "#{context} must be a hash" unless entries.is_a?(Hash)
90
-
91
- entries.each_with_object({}) do |(key, amount), rates|
92
- key = key.name if key.is_a?(Symbol)
93
- component, tier = component_and_tier_for(key, context: context)
94
- amount = amount_for(key, amount, context: context)
95
-
96
- rate = {
97
- amount: amount,
98
- quantity: rate_quantity(component),
99
- currency: currency,
100
- source_key: key
101
- }
102
- component_rates = rates[component.key] ||= { tiers: {} }
103
- (tier ? component_rates[:tiers] : component_rates)[tier || :default] = rate
104
- end
105
- end
106
-
107
- def component_and_tier_for(key, context:)
108
- Billing::Components::REGISTRY.each do |component|
109
- next if component.token_key
110
-
111
- return [component, nil] if key == component.key.name
112
-
113
- suffix = "_#{component.key.name}"
114
- next unless key.end_with?(suffix)
115
-
116
- tier = key.delete_suffix(suffix)
117
- return [component, :"#{tier}"] unless tier.empty?
118
- end
119
-
120
- raise ArgumentError, "service charge price key #{key.inspect} in #{context} uses unknown billing component"
121
- end
122
-
123
- def amount_for(key, amount, context:)
124
- value = BigDecimal(amount.to_s)
125
- if value.infinite? || value.nan?
126
- raise ArgumentError,
127
- "service charge price amount for #{key.inspect} in #{context} must be finite"
128
- end
129
- if value.negative?
130
- raise ArgumentError,
131
- "service charge price amount for #{key.inspect} in #{context} must be non-negative"
132
- end
133
-
134
- value
135
- end
136
-
137
- def rate_quantity(component)
138
- BigDecimal(Billing::RATE_BASIS_QUANTITIES.fetch(component.rate_basis, 1).to_s)
139
- end
140
-
141
- def charge_rate_match(provider:, component:, pricing_mode:)
142
- provider_name = provider.is_a?(Symbol) ? provider.name : provider.presence
143
- return nil unless provider_name
144
-
145
- component_key = charge_component_key(component)
146
-
147
- table = ServiceCharges.file_rates(LlmCostTracker.configuration.prices_file)
148
- provider_table = table.fetch(provider_name, EMPTY_RATES)
149
- rate = rate_for(provider_table, component_key: component_key, pricing_mode: pricing_mode)
150
- if rate
151
- return {
152
- source: :prices_file,
153
- key: "service_charges.#{provider_name}.#{rate.fetch(:source_key)}",
154
- rate: rate
155
- }
156
- end
157
-
158
- table = ServiceCharges.builtin_rates
159
- provider_table = table.fetch(provider_name, EMPTY_RATES)
160
- rate = rate_for(provider_table, component_key: component_key, pricing_mode: pricing_mode)
161
- return unless rate
162
-
163
- {
164
- source: :bundled,
165
- key: "service_charges.#{provider_name}.#{rate.fetch(:source_key)}",
166
- rate: rate
167
- }
168
- end
169
-
170
- def rate_for(provider_table, component_key:, pricing_mode:)
171
- component_rates = provider_table.fetch(component_key, EMPTY_RATES)
172
- tier_rates = component_rates.fetch(:tiers, EMPTY_RATES)
173
- if pricing_mode
174
- rate = tier_rates[pricing_mode]
175
- return rate if rate
176
-
177
- name = pricing_mode.name
178
- tier_rates.each do |candidate, candidate_rate|
179
- return candidate_rate if tier_includes?(name, candidate.name)
180
- end
181
- end
182
- component_rates[:default]
183
- end
184
-
185
- def tier_includes?(tier_name, candidate_name)
186
- tier_name == candidate_name ||
187
- tier_name.start_with?("#{candidate_name}_") ||
188
- tier_name.end_with?("_#{candidate_name}") ||
189
- tier_name.include?("_#{candidate_name}_")
190
- end
191
-
192
- def charge_component_key(component)
193
- billing_component = Billing::Components::BY_KEY[component]
194
- return billing_component.key if billing_component && billing_component.token_key.nil?
195
-
196
- raise Error, "Unknown billing component: #{component.inspect}"
197
- end
198
-
199
- def rate_source_version_for(source)
200
- return LlmCostTracker::VERSION if source == :bundled
201
-
202
- Lookup.prices_file_mtime_iso
203
- end
204
- end
205
- end
206
- end
@@ -1,15 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LlmCostTracker
4
- module Providers
5
- module Anthropic
6
- module ServerTools
7
- LINE_ITEMS = {
8
- web_search_request: :web_search_requests,
9
- web_fetch_request: :web_fetch_requests,
10
- code_execution_request: :code_execution_requests
11
- }.freeze
12
- end
13
- end
14
- end
15
- end
@@ -1,22 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LlmCostTracker
4
- module Providers
5
- module Anthropic
6
- module TierClassification
7
- DATA_RESIDENCY_GEOS = %w[us].freeze
8
- STANDARD_EQUIVALENT_SERVICE_TIERS = %w[standard standard_only priority].freeze
9
-
10
- module_function
11
-
12
- def data_residency_geo?(geo)
13
- DATA_RESIDENCY_GEOS.include?(geo.to_s.downcase)
14
- end
15
-
16
- def standard_equivalent_tier?(service_tier)
17
- STANDARD_EQUIVALENT_SERVICE_TIERS.include?(service_tier.to_s)
18
- end
19
- end
20
- end
21
- end
22
- end
@@ -1,131 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
-
5
- require_relative "reconciliation"
6
-
7
- module LlmCostTracker
8
- module ReconcileTasks
9
- SOURCE_PARSERS = {
10
- "openai" => Reconciliation::Sources::OpenaiUsage,
11
- "anthropic" => Reconciliation::Sources::AnthropicUsage
12
- }.freeze
13
- GENERIC_SOURCES = %w[csv].freeze
14
-
15
- module_function
16
-
17
- def run_import(env: ENV, output: $stdout, error_output: $stderr)
18
- result = import_from_env(env: env)
19
- output.puts "llm_cost_tracker: imported #{result.total_imported} rows " \
20
- "(inserted=#{result.inserted}, updated=#{result.updated}, skipped=#{result.skipped})"
21
- result.errors.each { |error| error_output.puts " error: #{error}" }
22
- raise "llm_cost_tracker: reconcile import had errors" unless result.success?
23
-
24
- result
25
- end
26
-
27
- def run_diff(env: ENV, output: $stdout)
28
- diff = diff_from_env(env: env)
29
- print_diff(diff, output: output)
30
- diff
31
- end
32
-
33
- def import_from_env(env: ENV)
34
- source = required_env(env, "SOURCE")
35
- input_path = required_env(env, "INPUT")
36
- raise ArgumentError, "INPUT file not found: #{input_path}" unless File.exist?(input_path)
37
-
38
- payload = JSON.parse(File.read(input_path))
39
- rows = parse_rows(source: source, payload: payload)
40
- Reconciliation.import(source: source.to_sym, rows: rows, provider: env["PROVIDER"])
41
- end
42
-
43
- def diff_from_env(env: ENV)
44
- source = required_env(env, "SOURCE")
45
- period_start = Date.parse(required_env(env, "PERIOD_START"))
46
- period_end = Date.parse(required_env(env, "PERIOD_END"))
47
- Reconciliation.diff(source: source.to_sym, period_start: period_start, period_end: period_end,
48
- provider: env["PROVIDER"],
49
- drilldown_limit: parse_drilldown_limit(env["DRILLDOWN_LIMIT"]))
50
- end
51
-
52
- def parse_drilldown_limit(value)
53
- return Reconciliation::Diff::DEFAULT_DRILLDOWN_LIMIT if value.nil? || value.to_s.empty?
54
- return nil if value.to_s.downcase == "all"
55
-
56
- Integer(value)
57
- end
58
-
59
- def print_diff(diff, output: $stdout)
60
- output.puts "llm_cost_tracker: reconciliation diff for #{diff.source} " \
61
- "#{diff.period_start}..#{diff.period_end}"
62
- output.puts " provider_total: #{diff.provider_total.to_s('F')} #{diff.currency}"
63
- output.puts " local_total: #{diff.local_total.to_s('F')} #{diff.currency} " \
64
- "(from #{diff.local_total_source})"
65
- output.puts " delta: #{diff.delta_amount.to_s('F')} (#{diff.delta_percent || 'n/a'}%)"
66
- print_unmatched_provider_rows(diff, output)
67
- print_unmatched_local_calls(diff, output)
68
- print_non_cost_rows(diff, output)
69
- end
70
-
71
- def parse_rows(source:, payload:)
72
- parser = SOURCE_PARSERS[source.to_s]
73
- return parser.parse(payload) if parser
74
- return Array(payload["rows"]) if GENERIC_SOURCES.include?(source.to_s)
75
-
76
- known = (SOURCE_PARSERS.keys + GENERIC_SOURCES).join(", ")
77
- raise ArgumentError, "unknown SOURCE #{source.inspect}; known sources: #{known}"
78
- end
79
-
80
- def required_env(env, key)
81
- value = env[key].to_s.strip
82
- raise ArgumentError, "missing #{key}" if value.empty?
83
-
84
- value
85
- end
86
-
87
- def print_unmatched_provider_rows(diff, output)
88
- print_section(output, "unmatched provider rows",
89
- diff.unmatched_provider_rows, diff.unmatched_provider_rows_total) do |row|
90
- "#{row[:external_id]} (#{row[:match_basis]}): " \
91
- "#{format_amount(row[:billed_amount])} #{format_attribution(row[:attribution])}"
92
- end
93
- end
94
-
95
- def print_unmatched_local_calls(diff, output)
96
- print_section(output, "unmatched local calls",
97
- diff.unmatched_local_calls, diff.unmatched_local_calls_total) do |row|
98
- "#{row[:count]} calls / #{row[:total_cost].to_s('F')} #{format_attribution(row[:attribution])}"
99
- end
100
- end
101
-
102
- def print_non_cost_rows(diff, output)
103
- print_section(output, "non-cost evidence",
104
- diff.non_cost_rows, diff.non_cost_rows_total) do |row|
105
- "[#{row[:row_type]}/#{row[:meter]}] " \
106
- "#{format_amount(row[:billed_amount])} #{format_attribution(row[:attribution])}"
107
- end
108
- end
109
-
110
- def print_section(output, label, rows, total)
111
- return if rows.empty?
112
-
113
- output.puts " #{label}#{truncation_suffix(rows.size, total)}:"
114
- rows.each { |row| output.puts " #{yield(row)}" }
115
- end
116
-
117
- def truncation_suffix(shown, total)
118
- return "" if shown >= total
119
-
120
- " (showing #{shown} of #{total} — pass DRILLDOWN_LIMIT=all to see every row)"
121
- end
122
-
123
- def format_amount(value)
124
- value.nil? ? "n/a" : value.to_s("F")
125
- end
126
-
127
- def format_attribution(attribution)
128
- LlmCostTracker::Masking.format_attribution(attribution, separator: ",")
129
- end
130
- end
131
- end