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
@@ -2,31 +2,25 @@
2
2
 
3
3
  require "bigdecimal"
4
4
 
5
- require_relative "logging"
6
5
  require_relative "ledger"
7
6
  require_relative "pricing/estimator"
8
7
 
9
8
  module LlmCostTracker
10
- class Budget
9
+ module Budget
11
10
  BUDGET_TYPE_TO_PERIOD = { monthly: :month, daily: :day }.freeze
12
11
 
13
12
  class << self
14
- def enforce!(provider: nil, model: nil, request: nil)
13
+ def enforce!(provider: nil, model: nil, request: nil, estimate: nil, force: false)
15
14
  config = LlmCostTracker.configuration
16
- return unless config.budget_exceeded_behavior == :block_requests
15
+ return unless config.enabled
16
+ return unless force || config.budget_exceeded_behavior == :block_requests
17
17
 
18
- estimate = estimate_cost(provider: provider, model: model, request: request)
18
+ estimate ||= estimate_cost(provider: provider, model: model, request: request)
19
19
  raise_per_call_pre_send(estimate, config.per_call_budget) if config.per_call_budget && estimate.positive?
20
20
 
21
- budgets = { monthly: config.monthly_budget, daily: config.daily_budget }.compact
22
- return if budgets.empty?
23
-
24
- totals = totals_for(budgets.keys, time: Time.now.utc)
25
-
26
- budgets.each do |budget_type, budget|
27
- total = totals.fetch(budget_type) + estimate
28
- next unless total >= budget
29
-
21
+ check_windowed({ monthly: config.monthly_budget, daily: config.daily_budget }.compact,
22
+ time: Time.now.utc,
23
+ estimate: estimate) do |budget_type, total, budget|
30
24
  raise BudgetExceededError.new(**budget_payload(
31
25
  budget_type: budget_type, total: total, budget: budget, last_event: nil, stage: :pre_send
32
26
  ))
@@ -38,13 +32,9 @@ module LlmCostTracker
38
32
  return unless event.total_cost
39
33
 
40
34
  check_per_call_budget(event, config)
41
- budgets = { daily: config.daily_budget, monthly: config.monthly_budget }.compact
42
- totals = totals_for(budgets.keys, time: event.tracked_at)
43
-
44
- budgets.each do |budget_type, budget|
45
- total = totals.fetch(budget_type)
46
-
47
- handle_exceeded(budget_type: budget_type, total: total, budget: budget, last_event: event) if total >= budget
35
+ check_windowed({ daily: config.daily_budget, monthly: config.monthly_budget }.compact,
36
+ time: event.tracked_at) do |budget_type, total, budget|
37
+ handle_exceeded(budget_type: budget_type, total: total, budget: budget, last_event: event)
48
38
  end
49
39
  end
50
40
 
@@ -74,14 +64,22 @@ module LlmCostTracker
74
64
  handle_exceeded(budget_type: :per_call, total: total, budget: budget, last_event: event)
75
65
  end
76
66
 
67
+ def check_windowed(budgets, time:, estimate: BigDecimal("0"))
68
+ return if budgets.empty?
69
+
70
+ totals = totals_for(budgets.keys, time: time)
71
+ budgets.each do |budget_type, budget|
72
+ total = totals.fetch(budget_type) + estimate
73
+ yield(budget_type, total, budget) if total >= budget
74
+ end
75
+ end
76
+
77
77
  def totals_for(budget_types, time:)
78
78
  return {} if budget_types.empty?
79
79
 
80
- periods = budget_types.map { |type| BUDGET_TYPE_TO_PERIOD.fetch(type) }
81
- period_totals = LlmCostTracker::Ledger::Period::Totals.call(periods, time: time)
82
- BUDGET_TYPE_TO_PERIOD.each_with_object({}) do |(budget_type, period), totals|
83
- totals[budget_type] = period_totals[period] if period_totals.key?(period)
84
- end
80
+ period_for = budget_types.to_h { |type| [type, BUDGET_TYPE_TO_PERIOD.fetch(type)] }
81
+ period_totals = LlmCostTracker::Ledger::Period::Totals.call(period_for.values, time: time)
82
+ period_for.transform_values { |period| period_totals.fetch(period) }
85
83
  end
86
84
 
87
85
  def handle_exceeded(budget_type:, total:, budget:, last_event: nil)
@@ -112,8 +110,7 @@ module LlmCostTracker
112
110
 
113
111
  def notify_exceeded?(config, budget_type:, total:, budget:, last_event:)
114
112
  return false unless config.on_budget_exceeded
115
- return true unless last_event&.total_cost
116
- return true if budget_type == :per_call
113
+ return true if !last_event&.total_cost || budget_type == :per_call
117
114
 
118
115
  total - last_event.total_cost < budget
119
116
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/deep_dup"
4
+ require "active_support/core_ext/object/try"
5
+
6
+ module LlmCostTracker
7
+ module Capture
8
+ module SdkPayload
9
+ module_function
10
+
11
+ def normalize(value)
12
+ case value
13
+ when Hash
14
+ value.each_with_object({}) { |(key, nested), out| out[key.to_s] = normalize(nested) }
15
+ when Array
16
+ value.map { |nested| normalize(nested) }
17
+ when Symbol
18
+ value.to_s
19
+ when NilClass
20
+ nil
21
+ else
22
+ converted = container_for(value)
23
+ converted ? normalize(converted) : value.deep_dup
24
+ end
25
+ end
26
+
27
+ def container_for(value)
28
+ value.try(:deep_to_h) || value.try(:to_h)
29
+ rescue StandardError
30
+ nil
31
+ end
32
+ end
33
+ end
34
+ end
@@ -4,9 +4,10 @@ require "active_support/core_ext/object/blank"
4
4
  require "json"
5
5
 
6
6
  module LlmCostTracker
7
- module Parsers
7
+ module Capture
8
8
  module SSE
9
9
  DONE_MARKER = "[DONE]"
10
+ LIMIT_BYTES = 1_048_576
10
11
 
11
12
  class << self
12
13
  def parse(body)
@@ -4,8 +4,7 @@ require "active_support/core_ext/object/blank"
4
4
  require "active_support/core_ext/object/deep_dup"
5
5
  require "json"
6
6
 
7
- require_relative "stream"
8
- require_relative "../pricing/mode"
7
+ require_relative "sse"
9
8
  require_relative "../timing"
10
9
 
11
10
  module LlmCostTracker
@@ -13,9 +12,17 @@ module LlmCostTracker
13
12
  class StreamCollector
14
13
  attr_reader :provider
15
14
 
16
- def initialize(provider:, model:, latency_ms: nil, provider_response_id: nil, provider_project_id: nil,
17
- provider_api_key_id: nil, provider_workspace_id: nil, batch: nil, pricing_mode: nil,
18
- metadata: {}, context_tags: nil, request: nil)
15
+ def initialize(provider:,
16
+ model:,
17
+ latency_ms: nil,
18
+ provider_response_id: nil,
19
+ provider_project_id: nil,
20
+ provider_api_key_id: nil,
21
+ provider_workspace_id: nil,
22
+ pricing_mode: nil,
23
+ metadata: {},
24
+ context_tags: nil,
25
+ request: nil)
19
26
  @provider = provider.to_s
20
27
  @model = model
21
28
  @latency_ms = latency_ms
@@ -23,7 +30,6 @@ module LlmCostTracker
23
30
  @provider_project_id = provider_project_id
24
31
  @provider_api_key_id = provider_api_key_id
25
32
  @provider_workspace_id = provider_workspace_id
26
- @batch = batch
27
33
  @pricing_mode = pricing_mode
28
34
  @metadata = (metadata || {}).deep_dup
29
35
  @context_tags = (context_tags || LlmCostTracker::Tags::Context.tags).deep_dup
@@ -38,18 +44,6 @@ module LlmCostTracker
38
44
  @mutex = Mutex.new
39
45
  end
40
46
 
41
- def model
42
- @mutex.synchronize { @model }
43
- end
44
-
45
- def metadata
46
- @mutex.synchronize { @metadata.deep_dup }
47
- end
48
-
49
- def provider_response_id
50
- @mutex.synchronize { @provider_response_id }
51
- end
52
-
53
47
  def model=(value)
54
48
  @mutex.synchronize do
55
49
  ensure_open!
@@ -72,16 +66,20 @@ module LlmCostTracker
72
66
  end
73
67
 
74
68
  def usage(input_tokens:, output_tokens:, **extra)
69
+ if extra.key?(:batch)
70
+ raise ArgumentError,
71
+ "`batch:` is no longer accepted by stream.usage; " \
72
+ "pass `pricing_mode: :batch` to track_stream"
73
+ end
74
+
75
75
  @mutex.synchronize do
76
76
  ensure_open!
77
77
  @provider_response_id = extra.delete(:provider_response_id) || @provider_response_id
78
78
  @provider_project_id = extra.delete(:provider_project_id) || @provider_project_id
79
79
  @provider_api_key_id = extra.delete(:provider_api_key_id) || @provider_api_key_id
80
80
  @provider_workspace_id = extra.delete(:provider_workspace_id) || @provider_workspace_id
81
- batch = extra.delete(:batch)
82
- @batch = batch unless batch.nil?
83
- @explicit_usage = TokenUsage.build(
84
- **extra.slice(*TokenUsage.members),
81
+ @explicit_usage = Usage::TokenUsage.build(
82
+ **extra.slice(*Usage::TokenUsage.members),
85
83
  input_tokens: input_tokens,
86
84
  output_tokens: output_tokens
87
85
  )
@@ -102,7 +100,7 @@ module LlmCostTracker
102
100
  return nil if @finished || @recording
103
101
 
104
102
  @recording = true
105
- pricing_mode = Pricing.normalize_mode(@pricing_mode)
103
+ pricing_mode = Pricing::Mode.normalize(@pricing_mode)
106
104
  {
107
105
  events: @events.dup,
108
106
  overflowed: @overflowed,
@@ -110,7 +108,7 @@ module LlmCostTracker
110
108
  model: @model,
111
109
  latency_ms: @latency_ms,
112
110
  provider_response_id: @provider_response_id,
113
- capture_dimensions: capture_dimensions(pricing_mode),
111
+ capture_dimensions: capture_dimensions,
114
112
  pricing_mode: pricing_mode,
115
113
  metadata: @metadata.deep_dup,
116
114
  context_tags: @context_tags.deep_dup,
@@ -129,7 +127,7 @@ module LlmCostTracker
129
127
  Tracker.record(
130
128
  event: event,
131
129
  latency_ms: snapshot[:latency_ms] || LlmCostTracker::Timing.elapsed_ms(@started_at),
132
- pricing_mode: merge_pricing_modes(event.pricing_mode, snapshot[:pricing_mode]),
130
+ pricing_mode: Pricing::Mode.merge(event.pricing_mode, snapshot[:pricing_mode]),
133
131
  metadata: (errored ? { stream_errored: true } : {}).merge(snapshot[:metadata]),
134
132
  context_tags: snapshot[:context_tags]
135
133
  ) { save_succeeded = true }
@@ -141,27 +139,11 @@ module LlmCostTracker
141
139
  end
142
140
  end
143
141
 
144
- HOST_DERIVED_MODE_TOKENS = %i[data_residency].freeze
145
- private_constant :HOST_DERIVED_MODE_TOKENS
146
-
147
- def merge_pricing_modes(provider_mode, request_mode)
148
- return Pricing.normalize_mode(request_mode) if provider_mode.to_s.strip.empty?
149
-
150
- provider_tokens = Pricing::Mode.tokenize(provider_mode) - Pricing::STANDARD_MODE_VALUES
151
- request_host_tokens = Pricing::Mode.tokenize(request_mode || "") & HOST_DERIVED_MODE_TOKENS
152
- combined = provider_tokens | request_host_tokens
153
- return nil if combined.empty?
154
-
155
- Pricing.normalize_mode(combined.join("_"))
156
- end
157
-
158
- def capture_dimensions(pricing_mode)
159
- batch = @batch.nil? ? Event.batch_from_pricing_mode?(pricing_mode).presence : @batch
142
+ def capture_dimensions
160
143
  {
161
144
  provider_project_id: @provider_project_id.to_s.strip.presence,
162
145
  provider_api_key_id: @provider_api_key_id.to_s.strip.presence,
163
- provider_workspace_id: @provider_workspace_id.to_s.strip.presence,
164
- batch: batch
146
+ provider_workspace_id: @provider_workspace_id.to_s.strip.presence
165
147
  }.compact
166
148
  end
167
149
 
@@ -197,12 +179,8 @@ module LlmCostTracker
197
179
  end
198
180
 
199
181
  def present_model(value)
200
- return nil if value.nil?
201
-
202
182
  string = value.to_s.presence
203
- return nil if string.nil? || string == Event::UNKNOWN_MODEL
204
-
205
- string
183
+ string unless string == Event::UNKNOWN_MODEL
206
184
  end
207
185
 
208
186
  def build_from_explicit_usage(snapshot)
@@ -211,7 +189,7 @@ module LlmCostTracker
211
189
  model: snapshot[:model] || Event::UNKNOWN_MODEL,
212
190
  token_usage: snapshot[:explicit_usage],
213
191
  stream: true,
214
- usage_source: :manual,
192
+ usage_source: Usage::Source::MANUAL,
215
193
  pricing_mode: snapshot[:pricing_mode],
216
194
  **snapshot.fetch(:capture_dimensions)
217
195
  )
@@ -221,9 +199,9 @@ module LlmCostTracker
221
199
  Event.build(
222
200
  provider: @provider,
223
201
  model: snapshot[:model] || Event::UNKNOWN_MODEL,
224
- token_usage: TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
202
+ token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
225
203
  stream: true,
226
- usage_source: :unknown,
204
+ usage_source: Usage::Source::UNKNOWN,
227
205
  pricing_mode: snapshot[:pricing_mode],
228
206
  **snapshot.fetch(:capture_dimensions)
229
207
  )
@@ -238,7 +216,7 @@ module LlmCostTracker
238
216
  def capture_event(data, type:)
239
217
  event = { event: type, data: strip_heavy_payload(data) }
240
218
  size = approximate_bytesize(event)
241
- if @captured_bytes + size <= Capture::Stream::LIMIT_BYTES
219
+ if @captured_bytes + size <= Capture::SSE::LIMIT_BYTES
242
220
  @events << event
243
221
  @captured_bytes += size
244
222
  else
@@ -3,7 +3,7 @@
3
3
  require "active_support/core_ext/object/deep_dup"
4
4
  require "active_support/core_ext/object/try"
5
5
 
6
- require_relative "../logging"
6
+ require_relative "sdk_payload"
7
7
 
8
8
  module LlmCostTracker
9
9
  module Capture
@@ -26,13 +26,24 @@ module LlmCostTracker
26
26
  if @stream.instance_variable_defined?(:@iterator)
27
27
  iterator = @stream.instance_variable_get(:@iterator)
28
28
  if iterator.respond_to?(:each)
29
- @stream.instance_variable_set(:@iterator, Enumerator.new do |yielder|
30
- each_from(iterator) { |event| yielder << event }
31
- end)
29
+ @stream.instance_variable_set(:@iterator,
30
+ Enumerator.new do |yielder|
31
+ each_from(iterator) { |event| yielder << event }
32
+ end)
32
33
  iterator_wrapped = true
33
34
  end
34
35
  end
35
- wrap_each if !iterator_wrapped && @stream.respond_to?(:each)
36
+ each_wrapped = false
37
+ if !iterator_wrapped && @stream.respond_to?(:each)
38
+ wrap_each
39
+ each_wrapped = true
40
+ end
41
+ unless iterator_wrapped || each_wrapped
42
+ Logging.warn(
43
+ "stream integration found no wrappable iterator on #{@stream.class} " \
44
+ "(missing both `@iterator` ivar and `#each`); usage will not be captured"
45
+ )
46
+ end
36
47
 
37
48
  register_orphan_finalizer
38
49
  @stream
@@ -74,40 +85,14 @@ module LlmCostTracker
74
85
  end
75
86
 
76
87
  def capture(event)
77
- raw_payload = event.try(:deep_to_h) || event.try(:to_h)
78
- raw_payload ||= %i[type id model usage response message].each_with_object({}) do |key, attributes|
79
- value = event.try(key)
80
- attributes[key] = value unless value.nil?
81
- end
82
- payload = normalize(raw_payload)
88
+ raw_payload = event.try(:deep_to_h) || event.try(:to_h) || {}
89
+ payload = SdkPayload.normalize(raw_payload)
83
90
  type = event.try(:type) || payload["type"]
84
91
  @collector.event(payload, type: type&.to_s)
85
92
  rescue StandardError => e
86
93
  warn_capture_failure(e)
87
94
  end
88
95
 
89
- def normalize(value)
90
- case value
91
- when Hash
92
- value.each_with_object({}) do |(key, nested), normalized|
93
- normalized[key.to_s] = normalize(nested)
94
- end
95
- when Array
96
- value.map { |nested| normalize(nested) }
97
- when Symbol
98
- value.to_s
99
- when NilClass
100
- nil
101
- else
102
- converted = begin
103
- value.try(:deep_to_h) || value.try(:to_h)
104
- rescue StandardError
105
- nil
106
- end
107
- converted ? normalize(converted) : value.deep_dup
108
- end
109
- end
110
-
111
96
  def warn_capture_failure(error)
112
97
  should_warn = @mutex.synchronize do
113
98
  next false if @capture_failed
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "check"
4
+ require_relative "ingestion"
5
+
6
+ module LlmCostTracker
7
+ class CaptureVerifier
8
+ class << self
9
+ def call
10
+ new.checks
11
+ end
12
+
13
+ def report(checks = call)
14
+ (["LLM Cost Tracker capture verification"] + checks.map do |check|
15
+ "[#{check.status}] #{check.name}: #{check.message}"
16
+ end).join("\n")
17
+ end
18
+
19
+ def healthy?(checks = call)
20
+ checks.none? { |check| check.status == :error }
21
+ end
22
+ end
23
+
24
+ def checks
25
+ [
26
+ enabled_check,
27
+ *integration_checks,
28
+ *storage_checks
29
+ ].compact
30
+ end
31
+
32
+ private
33
+
34
+ def enabled_check
35
+ return Check.new(:ok, "tracking", "enabled") if LlmCostTracker.configuration.enabled
36
+
37
+ Check.new(:error, "tracking", "disabled; set config.enabled = true before verifying capture")
38
+ end
39
+
40
+ def integration_checks
41
+ enabled = LlmCostTracker.configuration.instrumented_integrations
42
+ if enabled.empty?
43
+ return [
44
+ Check.new(:ok, "sdk integrations", "none enabled; Faraday middleware and manual capture remain available")
45
+ ]
46
+ end
47
+
48
+ LlmCostTracker::Integrations.checks.map do |check|
49
+ check.with(name: "sdk integration #{check.name}")
50
+ end
51
+ end
52
+
53
+ def storage_checks
54
+ LlmCostTracker::Ingestion.verify
55
+ rescue LlmCostTracker::Error => e
56
+ [Check.new(:error, "storage", e.message)]
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module LlmCostTracker
6
+ module Charges
7
+ Cost = Data.define(:components, :total, :currency) do
8
+ def self.from_h(attributes)
9
+ components = attributes.key?(:components) ? attributes[:components] : attributes.except(:total_cost, :currency)
10
+ total = attributes.fetch(:total) { attributes[:total_cost] }
11
+ new(
12
+ components: components.transform_values { |value| BigDecimal(value.to_s) }.freeze,
13
+ total: total && BigDecimal(total.to_s),
14
+ currency: attributes[:currency]
15
+ )
16
+ end
17
+
18
+ def to_h
19
+ {
20
+ components: components.transform_values { |value| value.to_s("F") },
21
+ total: total&.to_s("F"),
22
+ currency: currency
23
+ }
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,19 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "components"
3
+ require_relative "../usage/source"
4
4
 
5
5
  module LlmCostTracker
6
- module Billing
6
+ module Charges
7
7
  module CostStatus
8
8
  COMPLETE = "complete"
9
9
  FREE = "free"
10
10
  PARTIAL = "partial"
11
11
  UNKNOWN = "unknown"
12
+ INCOMPLETE = [UNKNOWN, PARTIAL].freeze
13
+
14
+ def self.unknown_pricing_sql(total_cost: "total_cost", cost_status: "cost_status")
15
+ statuses = INCOMPLETE.map { |status| ActiveRecord::Base.connection.quote(status) }.join(", ")
16
+ "#{total_cost} IS NULL OR #{cost_status} IN (#{statuses})"
17
+ end
12
18
 
13
19
  # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
14
- def self.call(token_usage:, usage_source:, token_cost:, service_line_items:, total_cost:,
20
+ def self.call(token_usage:,
21
+ usage_source:,
22
+ token_cost:,
23
+ service_line_items:,
24
+ total_cost:,
15
25
  token_pricing_partial: false)
16
- return UNKNOWN if usage_source == :unknown
26
+ return UNKNOWN if usage_source == Usage::Source::UNKNOWN
17
27
 
18
28
  token_billable = token_usage.priced_quantities.any? { |_key, quantity| quantity.positive? }
19
29
  service_billable = false
@@ -2,11 +2,12 @@
2
2
 
3
3
  require "bigdecimal"
4
4
 
5
- require_relative "components"
5
+ require_relative "../currency"
6
+ require_relative "../usage/catalog"
6
7
  require_relative "cost_status"
7
8
 
8
9
  module LlmCostTracker
9
- module Billing
10
+ module Charges
10
11
  LineItem = Data.define(
11
12
  :kind,
12
13
  :direction,
@@ -29,26 +30,24 @@ module LlmCostTracker
29
30
  )
30
31
 
31
32
  class LineItem
32
- USD = "USD"
33
-
34
33
  def self.build(attributes)
35
34
  attributes = attributes.to_h
36
- component = component_for(attributes)
35
+ dimension = dimension_for(attributes)
37
36
  new(
38
- kind: symbol_or_nil(attributes[:kind]) || component&.kind,
39
- direction: symbol_or_nil(attributes[:direction]) || component&.direction,
40
- modality: symbol_or_nil(attributes[:modality]) || component&.modality,
41
- cache_state: symbol_or_nil(attributes[:cache_state]) || component&.cache_state,
42
- quantity: decimal_or_zero(attributes[:quantity]),
43
- unit: symbol_or_nil(attributes[:unit]) || component&.unit,
37
+ kind: attributes[:kind]&.to_s || dimension&.kind,
38
+ direction: attributes[:direction]&.to_s || dimension&.direction,
39
+ modality: attributes[:modality]&.to_s || dimension&.modality,
40
+ cache_state: attributes[:cache_state]&.to_s || dimension&.cache_state || "none",
41
+ quantity: decimal_or_nil(attributes[:quantity]) || BigDecimal("0"),
42
+ unit: attributes[:unit]&.to_s || dimension&.unit,
44
43
  rate_amount: decimal_or_nil(attributes[:rate_amount]),
45
44
  rate_quantity: decimal_or_nil(attributes[:rate_quantity]) || BigDecimal("1"),
46
45
  cost: decimal_or_nil(attributes[:cost]),
47
- currency: attributes[:currency] || USD,
46
+ currency: canonical_currency(attributes[:currency]),
48
47
  cost_status: cost_status_for(attributes),
49
- pricing_basis: symbol_or_nil(attributes[:pricing_basis]),
50
- price_key: attributes[:price_key],
51
- price_source: symbol_or_nil(attributes[:price_source]),
48
+ pricing_basis: attributes[:pricing_basis]&.to_s,
49
+ price_key: attributes[:price_key]&.to_s,
50
+ price_source: attributes[:price_source]&.to_s,
52
51
  price_source_version: attributes[:price_source_version],
53
52
  provider_field: attributes[:provider_field],
54
53
  provider_item_id: attributes[:provider_item_id],
@@ -62,14 +61,14 @@ module LlmCostTracker
62
61
  token_usage.priced_quantities.filter_map do |key, quantity|
63
62
  next unless quantity.positive?
64
63
 
65
- component = Components::BY_KEY.fetch(key)
64
+ dimension = Usage::Catalog.fetch(key)
66
65
  build(
67
- kind: component.kind,
68
- direction: component.direction,
69
- modality: component.modality,
70
- cache_state: component.cache_state,
66
+ kind: dimension.kind,
67
+ direction: dimension.direction,
68
+ modality: dimension.modality,
69
+ cache_state: dimension.cache_state,
71
70
  quantity: quantity,
72
- unit: component.unit
71
+ unit: dimension.unit
73
72
  )
74
73
  end
75
74
  end
@@ -84,17 +83,11 @@ module LlmCostTracker
84
83
  cost.zero? ? CostStatus::FREE : CostStatus::COMPLETE
85
84
  end
86
85
 
87
- def self.component_for(attributes)
88
- component_key = attributes[:component_key] || attributes[:price_key]
89
- return nil unless component_key
90
-
91
- Components::BY_KEY[component_key.to_sym]
92
- end
93
-
94
- def self.symbol_or_nil(value)
95
- return nil if value.nil?
86
+ def self.dimension_for(attributes)
87
+ dimension_key = attributes[:dimension_key] || attributes[:price_key]
88
+ return nil unless dimension_key
96
89
 
97
- value.to_s.to_sym
90
+ Usage::Catalog[dimension_key.to_s]
98
91
  end
99
92
 
100
93
  def self.decimal_or_nil(value)
@@ -103,11 +96,11 @@ module LlmCostTracker
103
96
  BigDecimal(value.to_s)
104
97
  end
105
98
 
106
- def self.decimal_or_zero(value)
107
- decimal_or_nil(value) || BigDecimal("0")
99
+ def self.canonical_currency(value)
100
+ (value || LlmCostTracker::DEFAULT_CURRENCY).to_s.upcase
108
101
  end
109
102
 
110
- private_class_method :cost_status_for, :component_for, :symbol_or_nil, :decimal_or_nil, :decimal_or_zero
103
+ private_class_method :cost_status_for, :dimension_for, :decimal_or_nil, :canonical_currency
111
104
 
112
105
  def billable?
113
106
  quantity.positive?
@@ -122,7 +115,12 @@ module LlmCostTracker
122
115
  end
123
116
 
124
117
  def token?
125
- unit == :token
118
+ unit == "token"
119
+ end
120
+
121
+ def dimension
122
+ Usage::Catalog[price_key] ||
123
+ Usage::Catalog.token_priced_for(kind: kind, direction: direction, cache_state: cache_state)
126
124
  end
127
125
 
128
126
  def cost_value
@@ -130,18 +128,16 @@ module LlmCostTracker
130
128
  end
131
129
 
132
130
  def with_rate(rate)
133
- rate_amount = rate.fetch(:amount)
134
- rate_quantity = rate.fetch(:quantity)
135
- applied_cost = (quantity / rate_quantity) * rate_amount
131
+ applied_cost = (quantity / rate.quantity) * rate.amount
136
132
  with(
137
- rate_amount: rate_amount,
138
- rate_quantity: rate_quantity,
133
+ rate_amount: rate.amount,
134
+ rate_quantity: rate.quantity,
139
135
  cost: applied_cost,
140
- currency: rate.fetch(:currency),
136
+ currency: rate.currency.upcase,
141
137
  cost_status: applied_cost.zero? ? CostStatus::FREE : CostStatus::COMPLETE,
142
- price_key: rate.fetch(:source_key),
143
- price_source: rate.fetch(:source),
144
- price_source_version: rate.fetch(:source_version)
138
+ price_key: rate.source_key,
139
+ price_source: rate.source,
140
+ price_source_version: rate.source_version
145
141
  )
146
142
  end
147
143
 
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ Check = Data.define(:status, :name, :message)
5
+ end