llm_cost_tracker 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +11 -5
  4. data/app/assets/llm_cost_tracker/application.css +784 -802
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +14 -2
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +28 -21
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -4
  8. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  9. data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +19 -16
  12. data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  14. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
  15. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  16. data/app/models/llm_cost_tracker/call.rb +28 -63
  17. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  18. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  19. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  20. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
  22. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  23. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  24. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  25. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  27. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +81 -0
  28. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +6 -68
  29. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  30. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +20 -12
  31. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  32. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  33. data/app/views/layouts/llm_cost_tracker/application.html.erb +74 -17
  34. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  35. data/app/views/llm_cost_tracker/calls/show.html.erb +132 -125
  36. data/app/views/llm_cost_tracker/dashboard/index.html.erb +120 -159
  37. data/app/views/llm_cost_tracker/data_quality/index.html.erb +140 -194
  38. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  39. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  40. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  41. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  42. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  43. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  44. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  45. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  46. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  47. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  48. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  49. data/app/views/llm_cost_tracker/tags/show.html.erb +85 -104
  50. data/config/routes.rb +3 -3
  51. data/lib/llm_cost_tracker/budget.rb +25 -28
  52. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  53. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +2 -1
  54. data/lib/llm_cost_tracker/capture/stream_collector.rb +30 -52
  55. data/lib/llm_cost_tracker/capture/stream_tracker.rb +18 -33
  56. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  57. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  58. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  59. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
  60. data/lib/llm_cost_tracker/check.rb +5 -0
  61. data/lib/llm_cost_tracker/configuration.rb +13 -61
  62. data/lib/llm_cost_tracker/currency.rb +5 -0
  63. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  64. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  65. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  66. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  67. data/lib/llm_cost_tracker/doctor.rb +66 -64
  68. data/lib/llm_cost_tracker/engine.rb +4 -4
  69. data/lib/llm_cost_tracker/event.rb +12 -20
  70. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  71. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  72. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  73. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
  74. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +4 -0
  75. data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
  76. data/lib/llm_cost_tracker/ingestion/inbox.rb +8 -9
  77. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  78. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  79. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  80. data/lib/llm_cost_tracker/integrations/anthropic.rb +94 -116
  81. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  82. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  83. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  84. data/lib/llm_cost_tracker/integrations/openai.rb +72 -332
  85. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +89 -145
  86. data/lib/llm_cost_tracker/integrations.rb +32 -25
  87. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  88. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  89. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  90. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  91. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  92. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  93. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  94. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  95. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  96. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  97. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  98. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  99. data/lib/llm_cost_tracker/ledger/store.rb +18 -42
  100. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  101. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  102. data/lib/llm_cost_tracker/ledger.rb +14 -11
  103. data/lib/llm_cost_tracker/logging.rb +4 -21
  104. data/lib/llm_cost_tracker/middleware/faraday.rb +63 -51
  105. data/lib/llm_cost_tracker/parsers.rb +140 -29
  106. data/lib/llm_cost_tracker/prices.json +1707 -1
  107. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  108. data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
  109. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  110. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  111. data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
  112. data/lib/llm_cost_tracker/pricing/mode.rb +53 -35
  113. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  114. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  115. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  116. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  117. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  118. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  119. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  120. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  121. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  122. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  123. data/lib/llm_cost_tracker/pricing.rb +10 -295
  124. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  125. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  126. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  127. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  128. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  129. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  130. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  131. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  132. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  133. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  134. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  135. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  136. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +181 -0
  137. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  138. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  139. data/lib/llm_cost_tracker/providers.rb +35 -0
  140. data/lib/llm_cost_tracker/railtie.rb +0 -7
  141. data/lib/llm_cost_tracker/report/data.rb +3 -4
  142. data/lib/llm_cost_tracker/report/formatter.rb +33 -20
  143. data/lib/llm_cost_tracker/report.rb +1 -1
  144. data/lib/llm_cost_tracker/retention.rb +6 -19
  145. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  146. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  147. data/lib/llm_cost_tracker/timing.rb +2 -4
  148. data/lib/llm_cost_tracker/tracker.rb +24 -36
  149. data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
  150. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  151. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
  152. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  153. data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
  154. data/lib/llm_cost_tracker/version.rb +1 -1
  155. data/lib/llm_cost_tracker.rb +43 -52
  156. data/lib/tasks/llm_cost_tracker.rake +14 -73
  157. metadata +92 -58
  158. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -106
  159. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  160. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  161. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  162. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  163. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -183
  164. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  165. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  166. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  167. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  168. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  169. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  170. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  171. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  172. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  173. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  174. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  175. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  176. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  177. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  178. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -32
  179. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -25
  180. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  181. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  182. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  183. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  184. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  185. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  186. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  187. data/lib/llm_cost_tracker/masking.rb +0 -39
  188. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -193
  189. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  190. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  191. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -232
  192. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  193. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -51
  194. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -155
  195. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  196. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  197. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  198. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  199. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  200. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -134
  201. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  202. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  203. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  204. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -254
  205. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -172
  206. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  207. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -142
  208. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  209. data/lib/llm_cost_tracker/token_usage.rb +0 -93
@@ -6,13 +6,15 @@ module LlmCostTracker
6
6
  module RegistryDiff
7
7
  class << self
8
8
  def call(current_models, updated_models)
9
- current_models = normalize_models(current_models)
10
- updated_models = normalize_models(updated_models)
9
+ current_models = Registry.normalize_price_entries(current_models, context: "current price table")
10
+ updated_models = Registry.normalize_price_entries(updated_models, context: "updated price table")
11
11
 
12
12
  (current_models.keys | updated_models.keys).sort.each_with_object({}) do |model, changes|
13
13
  fields = price_field_changes(current_models[model], updated_models[model])
14
14
  changes[model] = fields if fields.any?
15
15
  end
16
+ rescue ArgumentError, TypeError => e
17
+ raise Error, e.message
16
18
  end
17
19
 
18
20
  private
@@ -29,14 +31,6 @@ module LlmCostTracker
29
31
  changes[field] = { "from" => from, "to" => to }
30
32
  end
31
33
  end
32
-
33
- def normalize_models(models)
34
- Registry.normalize_price_table(models).transform_values do |price|
35
- price.to_h { |key, value| [key.name, value] }
36
- end
37
- rescue ArgumentError, TypeError => e
38
- raise Error, e.message
39
- end
40
34
  end
41
35
  end
42
36
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bigdecimal"
3
4
  require "fileutils"
4
5
  require "json"
5
6
  require "yaml"
@@ -12,9 +13,8 @@ module LlmCostTracker
12
13
  MANUAL_SOURCE = "manual"
13
14
 
14
15
  def call(path:, registry:)
16
+ payload = render(path: path, registry: registry)
15
17
  FileUtils.mkdir_p(File.dirname(path))
16
- merged = canonicalize(merge_with_existing(path: path, registry: registry))
17
- payload = yaml_file?(path) ? YAML.dump(merged) : "#{JSON.pretty_generate(merged)}\n"
18
18
  temp_path = "#{path}.tmp-#{Process.pid}-#{Thread.current.object_id}"
19
19
  File.write(temp_path, payload)
20
20
  File.rename(temp_path, path)
@@ -22,6 +22,11 @@ module LlmCostTracker
22
22
  FileUtils.rm_f(temp_path) if temp_path && File.exist?(temp_path)
23
23
  end
24
24
 
25
+ def render(path:, registry:)
26
+ merged = canonicalize(merge_with_existing(path: path, registry: registry))
27
+ yaml_file?(path) ? YAML.dump(merged) : "#{JSON.pretty_generate(merged)}\n"
28
+ end
29
+
25
30
  private
26
31
 
27
32
  def canonicalize(value)
@@ -30,6 +35,8 @@ module LlmCostTracker
30
35
  value.sort_by { |key, _| key.to_s }.to_h { |key, nested| [key, canonicalize(nested)] }
31
36
  when Array
32
37
  value.map { |element| canonicalize(element) }
38
+ when BigDecimal
39
+ value.to_f
33
40
  else
34
41
  value
35
42
  end
@@ -79,7 +86,7 @@ module LlmCostTracker
79
86
  else
80
87
  JSON.parse(contents)
81
88
  end
82
- rescue StandardError
89
+ rescue Errno::ENOENT, Psych::Exception, JSON::ParserError
83
90
  nil
84
91
  end
85
92
 
@@ -36,7 +36,10 @@ module LlmCostTracker
36
36
  env["URL"].to_s.strip.presence || DEFAULT_REMOTE_URL
37
37
  end
38
38
 
39
- def refresh(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, preview: false, fetcher: Fetcher.new,
39
+ def refresh(path: DEFAULT_OUTPUT_PATH,
40
+ url: DEFAULT_REMOTE_URL,
41
+ preview: false,
42
+ fetcher: Fetcher.new,
40
43
  today: Date.today)
41
44
  current = load_registry(path)
42
45
  response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
@@ -56,7 +59,7 @@ module LlmCostTracker
56
59
  remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
57
60
  unless preview
58
61
  RegistryWriter.new.call(path: path, registry: remote)
59
- invalidate_pricing_caches!
62
+ Pricing::Registry.reset!
60
63
  end
61
64
  refresh_result(
62
65
  path: path,
@@ -69,12 +72,6 @@ module LlmCostTracker
69
72
  )
70
73
  end
71
74
 
72
- def invalidate_pricing_caches!
73
- Pricing::Lookup.reset!
74
- Pricing::Registry.reset!
75
- Pricing::ServiceCharges.reset!
76
- end
77
-
78
75
  def check(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, fetcher: Fetcher.new, today: Date.today)
79
76
  current = load_registry(path)
80
77
  response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
@@ -119,12 +116,13 @@ module LlmCostTracker
119
116
  end
120
117
 
121
118
  raw_models = registry.fetch("models", {})
122
- models = Registry.normalize_price_table(raw_models).each_with_object({}) do |(model, prices), normalized|
119
+ models = Registry.normalize_price_entries(raw_models, context: "remote pricing snapshot")
120
+ .each_with_object({}) do |(model, prices), normalized|
123
121
  model_metadata = (raw_models[model] || {}).slice(*Registry::METADATA_KEYS)
124
- normalized[model] = model_metadata.merge(prices.to_h { |key, value| [key.name, value] })
122
+ normalized[model] = model_metadata.merge(prices)
125
123
  end
126
124
  service_charges = registry["service_charges"]
127
- ServiceCharges.rates_from_registry(registry, context: "remote pricing snapshot") if service_charges
125
+ Registry.rates_from_registry(registry, context: "remote pricing snapshot") if service_charges
128
126
 
129
127
  normalized = {
130
128
  "metadata" => metadata.merge(
@@ -4,7 +4,7 @@ require_relative "../logging"
4
4
 
5
5
  module LlmCostTracker
6
6
  module Pricing
7
- class Unknown
7
+ module Unknown
8
8
  MUTEX = Mutex.new
9
9
  WARN_CACHE_LIMIT = 1024
10
10
 
@@ -22,10 +22,6 @@ module LlmCostTracker
22
22
  end
23
23
  end
24
24
 
25
- def reset!
26
- MUTEX.synchronize { @warned_models = Set.new }
27
- end
28
-
29
25
  private
30
26
 
31
27
  def warn_missing(model)
@@ -5,308 +5,23 @@ require "bigdecimal"
5
5
  require "time"
6
6
 
7
7
  require_relative "version"
8
- require_relative "logging"
9
- require_relative "token_usage"
10
- require_relative "billing/components"
11
- require_relative "billing/line_item"
12
- require_relative "pricing/mode"
8
+ require_relative "usage/token_usage"
9
+ require_relative "charges/cost"
10
+ require_relative "charges/cost_status"
11
+ require_relative "pricing/price_key"
13
12
  require_relative "pricing/registry"
14
- require_relative "pricing/lookup"
13
+ require_relative "pricing/source"
14
+ require_relative "pricing/matcher"
15
+ require_relative "pricing/service_rates"
15
16
  require_relative "pricing/effective_prices"
16
- require_relative "pricing/explainer"
17
- require_relative "pricing/service_charges"
18
17
  require_relative "pricing/estimator"
18
+ require_relative "pricing/calculation"
19
19
 
20
20
  module LlmCostTracker
21
- module Pricing # rubocop:disable Metrics/ModuleLength
22
- extend ServiceCharges
23
-
24
- STANDARD_MODE_VALUES = %i[auto default standard standard_only].freeze
25
- RATE_DENOMINATOR_TOKENS = 1_000_000
26
- private_constant :RATE_DENOMINATOR_TOKENS
27
-
21
+ module Pricing
28
22
  class << self
29
- def normalize_mode(value)
30
- return nil if value.nil?
31
-
32
- mode = normalize_string_mode(value.to_s)
33
- return nil unless mode
34
-
35
- STANDARD_MODE_VALUES.include?(mode) ? nil : mode
36
- end
37
-
38
23
  def cost_for(provider:, model:, tokens:, pricing_mode: nil)
39
- calculation = calculation_for(
40
- provider: provider,
41
- model: model,
42
- tokens: tokens,
43
- pricing_mode: pricing_mode
44
- )
45
- return nil unless calculation
46
-
47
- cost_from(calculation)
48
- end
49
-
50
- def calculate(provider:, model:, tokens:, line_items:, pricing_mode: nil)
51
- calculation = calculation_for(
52
- provider: provider,
53
- model: model,
54
- tokens: tokens,
55
- pricing_mode: pricing_mode
56
- )
57
- cost_data = calculation && cost_from(calculation)
58
- snapshot = calculation && snapshot_from(calculation)
59
- priced = apply_calculation_to_line_items(line_items, calculation,
60
- provider: provider, pricing_mode: pricing_mode)
61
- [cost_data, snapshot, priced]
62
- end
63
-
64
- def price_line_items(provider:, model:, line_items:, pricing_mode: nil)
65
- token_usage = TokenUsage.build_from_tokens(token_attributes_from(line_items))
66
- calculation = calculation_for(provider: provider, model: model, tokens: token_usage, pricing_mode: pricing_mode)
67
- snapshot = calculation && snapshot_from(calculation)
68
- priced = apply_calculation_to_line_items(line_items, calculation,
69
- provider: provider, pricing_mode: pricing_mode)
70
- [priced, snapshot]
71
- end
72
-
73
- def snapshot_for(provider:, model:, tokens:, pricing_mode: nil)
74
- calculation = calculation_for(
75
- provider: provider,
76
- model: model,
77
- tokens: tokens,
78
- pricing_mode: pricing_mode
79
- )
80
- return nil unless calculation
81
-
82
- snapshot_from(calculation)
83
- end
84
-
85
- def explain(provider:, model:, tokens:, pricing_mode: nil)
86
- Explainer.call(
87
- provider: provider,
88
- model: model,
89
- tokens: tokens,
90
- pricing_mode: pricing_mode
91
- )
92
- end
93
-
94
- def stored_cost_attributes(attributes)
95
- value = attributes.to_h[:total_cost]
96
- value ? { total_cost: value } : {}
97
- end
98
-
99
- def combine_with_service_lines(cost_data, line_items)
100
- priced_services = line_items.reject(&:token?).select(&:priced?)
101
- return cost_data if priced_services.empty?
102
-
103
- base_currency = base_currency_for(cost_data, priced_services)
104
- matching, mismatched = priced_services.partition { |line| line.currency.to_s == base_currency.to_s }
105
- warn_currency_mismatch(mismatched, base_currency) if mismatched.any?
106
-
107
- cost = cost_data ? cost_data.dup : {}
108
- cost[:currency] ||= base_currency.to_s
109
- return cost if matching.empty?
110
-
111
- service_total = matching.sum(BigDecimal("0"), &:cost_value)
112
- base_total = BigDecimal(cost.fetch(:total_cost, 0).to_s)
113
- cost[:total_cost] = (base_total + service_total).round(8)
114
- cost
115
- end
116
-
117
- def token_pricing_partial?(token_usage, cost_data)
118
- return false unless cost_data
119
-
120
- token_usage.priced_quantities.any? do |key, quantity|
121
- next false unless quantity.positive?
122
-
123
- cost_data[Billing::Components::BY_KEY.fetch(key).cost_key].nil?
124
- end
125
- end
126
-
127
- private
128
-
129
- def base_currency_for(cost_data, priced_services)
130
- (cost_data && cost_data[:currency]) || priced_services.first.currency || Billing::LineItem::USD
131
- end
132
-
133
- def warn_currency_mismatch(lines, base_currency)
134
- currencies = lines.map { |line| line.currency.to_s }.uniq.sort
135
- Logging.warn(
136
- "Service line currency mismatch: header is #{base_currency}, dropping " \
137
- "#{lines.size} priced line(s) in #{currencies.join(', ')} from header total. " \
138
- "Per-line costs are still recorded; header total reflects #{base_currency} only."
139
- )
140
- end
141
-
142
- def normalize_string_mode(value)
143
- normalized = value.strip
144
- return nil if normalized.empty?
145
-
146
- normalized.downcase.tr("-", "_").to_sym
147
- end
148
-
149
- def cost_from(calculation)
150
- costs = calculation[:costs]
151
- values = Billing::Components::TOKEN_PRICED.each_with_object({}) do |component, result|
152
- cost = costs[component.key]
153
- result[component.cost_key] = cost.round(8) unless cost.nil?
154
- end
155
- values[:total_cost] = costs.values.compact.sum(BigDecimal("0")).round(8)
156
- values[:currency] = calculation[:match].currency
157
- values
158
- end
159
-
160
- def snapshot_from(calculation)
161
- match = calculation[:match]
162
- effective = calculation[:effective]
163
- rates = calculation[:quantities].each_with_object({}) do |(key, quantity), values|
164
- price = effective[key]
165
- next if quantity.zero? || price.nil?
166
-
167
- values[key] = { amount: price, quantity: RATE_DENOMINATOR_TOKENS }
168
- end
169
-
170
- {
171
- schema_version: 1,
172
- source: match.source,
173
- source_key: match.key,
174
- source_version: source_version_for(match.source),
175
- matched_by: match.matched_by,
176
- currency: match.currency,
177
- rates: rates
178
- }
179
- end
180
-
181
- def calculation_for(provider:, model:, tokens:, pricing_mode:)
182
- match = Lookup.call(provider: provider, model: model)
183
- return nil unless match
184
-
185
- token_usage = TokenUsage.build_from_tokens(tokens)
186
- quantities = token_usage.priced_quantities
187
- mode = normalize_mode(pricing_mode)
188
- effective = EffectivePrices.call(usage: token_usage, quantities: quantities, prices: match.prices,
189
- pricing_mode: mode)
190
- return nil unless any_billable_priced?(quantities, effective)
191
-
192
- { match: match, effective: effective, token_usage: token_usage, quantities: quantities,
193
- costs: costs_for(quantities, effective) }
194
- end
195
-
196
- def any_billable_priced?(quantities, effective)
197
- any_billable = false
198
- quantities.each_pair do |key, quantity|
199
- next unless quantity.positive?
200
- return true if effective[key]
201
-
202
- any_billable = true
203
- end
204
- !any_billable
205
- end
206
-
207
- def costs_for(quantities, effective)
208
- quantities.to_h { |key, tokens| [key, token_cost(tokens, effective[key])] }
209
- end
210
-
211
- def apply_calculation_to_line_items(line_items, calculation, provider:, pricing_mode:)
212
- line_items.map do |line_item|
213
- next price_token_line_item(line_item, calculation) if line_item.unit == :token
214
-
215
- price_service_charge_line_item(line_item,
216
- provider: provider,
217
- calculation: calculation,
218
- pricing_mode: pricing_mode)
219
- end
220
- end
221
-
222
- def token_attributes_from(line_items)
223
- line_items.each_with_object({}) do |line_item, totals|
224
- next unless line_item.unit == :token
225
-
226
- component = component_for_line_item(line_item)
227
- next unless component
228
-
229
- totals[component.key] = (totals[component.key] || 0) + line_item.quantity.to_i
230
- end
231
- end
232
-
233
- def price_token_line_item(line_item, calculation)
234
- component = component_for_line_item(line_item)
235
- return line_item unless component
236
- return line_item.with(cost_status: Billing::CostStatus::UNKNOWN) unless calculation
237
-
238
- effective_price = calculation[:effective][component.key]
239
- return line_item.with(cost_status: Billing::CostStatus::UNKNOWN) if effective_price.nil?
240
-
241
- cost = (line_item.quantity * BigDecimal(effective_price.to_s)) / RATE_DENOMINATOR_TOKENS
242
- match = calculation[:match]
243
- line_item.with(
244
- rate_amount: BigDecimal(effective_price.to_s),
245
- rate_quantity: BigDecimal(RATE_DENOMINATOR_TOKENS),
246
- cost: cost,
247
- currency: match.currency,
248
- cost_status: cost.zero? ? Billing::CostStatus::FREE : Billing::CostStatus::COMPLETE,
249
- price_key: component.key,
250
- price_source: match.source,
251
- price_source_version: source_version_for(match.source)
252
- )
253
- end
254
-
255
- def price_service_charge_line_item(line_item, provider:, calculation:, pricing_mode:)
256
- return line_item if line_item.priced?
257
- return line_item unless line_item.billable?
258
-
259
- rate = model_rate_for(line_item, calculation) ||
260
- charge_rate(provider: provider, component: line_item.kind, pricing_mode: pricing_mode)
261
- return line_item unless rate
262
-
263
- line_item.with_rate(rate)
264
- end
265
-
266
- def model_rate_for(line_item, calculation)
267
- return nil unless calculation
268
-
269
- match = calculation[:match]
270
- amount = match.prices[line_item.kind] || match.prices[line_item.kind.to_s]
271
- return nil unless amount.is_a?(Numeric)
272
-
273
- component = Billing::Components::BY_KEY[line_item.kind]
274
- {
275
- amount: BigDecimal(amount.to_s),
276
- quantity: BigDecimal(Billing::RATE_BASIS_QUANTITIES.fetch(component.rate_basis).to_s),
277
- currency: match.currency,
278
- source: match.source,
279
- source_key: "#{match.key}.#{line_item.kind}",
280
- source_version: source_version_for(match.source)
281
- }
282
- end
283
-
284
- def component_for_line_item(line_item)
285
- Billing::Components::REGISTRY.find do |component|
286
- component.kind == line_item.kind &&
287
- component.direction == line_item.direction &&
288
- component.modality == line_item.modality &&
289
- component.cache_state == line_item.cache_state &&
290
- component.unit == line_item.unit
291
- end
292
- end
293
-
294
- def source_version_for(source)
295
- case source
296
- when :bundled
297
- LlmCostTracker::VERSION
298
- when :prices_file
299
- Lookup.prices_file_mtime_iso
300
- when :pricing_overrides
301
- "configuration"
302
- end
303
- end
304
-
305
- def token_cost(tokens, per_million_price)
306
- return BigDecimal("0") if tokens.zero?
307
- return nil if per_million_price.nil?
308
-
309
- (BigDecimal(tokens.to_s) * BigDecimal(per_million_price.to_s)) / RATE_DENOMINATOR_TOKENS
24
+ Calculation.for(provider: provider, model: model, tokens: tokens, pricing_mode: pricing_mode).token_cost
310
25
  end
311
26
  end
312
27
  end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/hash/keys"
4
+
5
+ module LlmCostTracker
6
+ module Providers
7
+ module Anthropic
8
+ class Parser < LlmCostTracker::Parsers::Base
9
+ HOSTS = %w[api.anthropic.com].freeze
10
+
11
+ class << self
12
+ def match?(url)
13
+ match_uri?(url, hosts: HOSTS, path_includes: "/v1/messages")
14
+ end
15
+
16
+ def provider_names
17
+ %w[anthropic]
18
+ end
19
+ end
20
+
21
+ def parse(request_body:, response_status:, response_body:, **)
22
+ return nil unless response_status == 200
23
+
24
+ response = safe_json_parse(response_body)
25
+ usage = response["usage"]&.deep_symbolize_keys
26
+ return nil unless usage
27
+
28
+ request = symbolize_request(request_body)
29
+
30
+ ResponseParser.event_from_usage(
31
+ usage: usage,
32
+ model: response["model"] || request[:model],
33
+ provider_response_id: response["id"],
34
+ usage_source: Usage::Source::RESPONSE,
35
+ request: request
36
+ )
37
+ end
38
+
39
+ def parse_stream(response_status:, request_body: nil, events: [], **)
40
+ return nil unless response_status == 200
41
+
42
+ request = symbolize_request(request_body)
43
+ model = find_event_value(events) { |data| data.dig("message", "model") } || request[:model]
44
+ usage = stream_usage(events)&.deep_symbolize_keys
45
+ response_id = find_event_value(events) { |data| data.dig("message", "id") || data["id"] }
46
+
47
+ if usage
48
+ ResponseParser.event_from_usage(
49
+ usage: usage,
50
+ model: model,
51
+ provider_response_id: response_id,
52
+ usage_source: Usage::Source::STREAM_FINAL,
53
+ request: request,
54
+ stream: true
55
+ )
56
+ else
57
+ build_unknown_stream_usage(
58
+ provider: "anthropic",
59
+ model: model,
60
+ provider_response_id: response_id,
61
+ pricing_mode: UsageExtractor.pricing_mode(request: request, usage: usage)
62
+ )
63
+ end
64
+ end
65
+
66
+ def provider_for(_request_url)
67
+ "anthropic"
68
+ end
69
+
70
+ private
71
+
72
+ def symbolize_request(request_body)
73
+ safe_json_parse(request_body).deep_symbolize_keys
74
+ end
75
+
76
+ def stream_usage(events)
77
+ latest_delta = find_event_value(events, reverse: true) do |data|
78
+ data["usage"] if data["type"] == "message_delta" && data["usage"].is_a?(Hash)
79
+ end
80
+ return nil unless latest_delta
81
+
82
+ start_usage = find_event_value(events, reverse: true) do |data|
83
+ data.dig("message", "usage") if data["type"] == "message_start"
84
+ end
85
+
86
+ (start_usage || {}).merge(latest_delta) do |_key, start_val, delta_val|
87
+ delta_val || start_val
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "usage_extractor"
4
+
5
+ module LlmCostTracker
6
+ module Providers
7
+ module Anthropic
8
+ module ResponseParser
9
+ def self.event_from_usage(usage:,
10
+ model:,
11
+ provider_response_id:,
12
+ usage_source:,
13
+ request: nil,
14
+ pricing_mode: nil,
15
+ stream: false)
16
+ Event.build(
17
+ provider: "anthropic",
18
+ provider_response_id: provider_response_id,
19
+ pricing_mode: pricing_mode || UsageExtractor.pricing_mode(request: request, usage: usage),
20
+ model: model,
21
+ token_usage: UsageExtractor.token_usage(usage),
22
+ stream: stream,
23
+ usage_source: usage_source,
24
+ service_line_items: UsageExtractor.service_line_items(usage)
25
+ )
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Providers
5
+ module Anthropic
6
+ module UsageExtractor
7
+ SERVER_TOOL_LINE_ITEMS = {
8
+ "web_search_request" => :web_search_requests,
9
+ "web_fetch_request" => :web_fetch_requests
10
+ }.freeze
11
+ DATA_RESIDENCY_GEOS = %w[us].freeze
12
+ private_constant :SERVER_TOOL_LINE_ITEMS, :DATA_RESIDENCY_GEOS
13
+
14
+ def self.token_usage(usage)
15
+ input = usage[:input_tokens].to_i
16
+ output = usage[:output_tokens].to_i
17
+ cache_read = usage[:cache_read_input_tokens].to_i
18
+ cache_write, cache_write_extended = cache_writes(usage)
19
+
20
+ Usage::TokenUsage.build(
21
+ input_tokens: input,
22
+ output_tokens: output,
23
+ cache_read_input_tokens: cache_read,
24
+ cache_write_input_tokens: cache_write,
25
+ cache_write_extended_input_tokens: cache_write_extended
26
+ )
27
+ end
28
+
29
+ def self.pricing_mode(request:, usage:)
30
+ speed = request&.dig(:speed)
31
+ service_tier = usage&.dig(:service_tier) || request&.dig(:service_tier)
32
+ geo = (usage&.dig(:inference_geo) || request&.dig(:inference_geo)).to_s.downcase
33
+
34
+ modes = [Pricing::Mode.normalize(speed), Pricing::Mode.normalize(service_tier)]
35
+ modes << "data_residency" if DATA_RESIDENCY_GEOS.include?(geo)
36
+ Pricing::Mode.compose(modes)
37
+ end
38
+
39
+ def self.service_line_items(usage)
40
+ server_tool_use = usage[:server_tool_use]
41
+ return [] unless server_tool_use.is_a?(Hash)
42
+
43
+ SERVER_TOOL_LINE_ITEMS.filter_map do |dimension_key, count_key|
44
+ quantity = server_tool_use[count_key].to_i
45
+ next if quantity.zero?
46
+
47
+ Charges::LineItem.build(
48
+ dimension_key: dimension_key,
49
+ quantity: quantity,
50
+ cost_status: Charges::CostStatus::UNKNOWN,
51
+ pricing_basis: "provider_usage",
52
+ provider_field: "usage.server_tool_use.#{count_key}"
53
+ )
54
+ end
55
+ end
56
+
57
+ def self.cache_writes(usage)
58
+ cache_creation = usage[:cache_creation]
59
+ if cache_creation.is_a?(Hash)
60
+ [cache_creation[:ephemeral_5m_input_tokens].to_i, cache_creation[:ephemeral_1h_input_tokens].to_i]
61
+ else
62
+ warn_unexpected_cache_creation(cache_creation, usage)
63
+ [usage[:cache_creation_input_tokens].to_i, 0]
64
+ end
65
+ end
66
+
67
+ def self.warn_unexpected_cache_creation(cache_creation, usage)
68
+ return if cache_creation.nil?
69
+ return if usage.key?(:cache_creation_input_tokens)
70
+
71
+ Logging.warn("Anthropic usage.cache_creation has unexpected shape: #{cache_creation.class}")
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -5,10 +5,7 @@ module LlmCostTracker
5
5
  module Azure
6
6
  module Hosts
7
7
  OPENAI_HOST_PATTERN = /\A[a-z0-9][a-z0-9-]*\.(?:openai\.azure\.com|services\.ai\.azure\.com)\z/i
8
-
9
- module_function
10
-
11
- def openai?(host)
8
+ def self.openai?(host)
12
9
  host.to_s.match?(OPENAI_HOST_PATTERN)
13
10
  end
14
11
  end