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
@@ -1,47 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_support/core_ext/object/blank"
4
+ require "active_support/notifications"
4
5
  require "securerandom"
5
6
 
6
7
  require_relative "ingestion"
7
8
  require_relative "ledger"
8
- require_relative "logging"
9
9
  require_relative "pricing"
10
- require_relative "billing/cost_status"
11
10
 
12
11
  module LlmCostTracker
13
- class Tracker
12
+ module Tracker
14
13
  EVENT_NAME = "llm_request.llm_cost_tracker"
15
14
 
16
15
  class << self
17
- def enforce_budget!(provider: nil, model: nil, request: nil)
16
+ def record(event:, latency_ms: nil, pricing_mode: nil, metadata: {}, context_tags: nil, enforce_budget: false)
18
17
  return unless LlmCostTracker.configuration.enabled
19
18
 
20
- Budget.enforce!(provider: provider, model: model, request: request)
21
- end
22
-
23
- def record(event:, latency_ms: nil, pricing_mode: nil, metadata: {}, context_tags: nil)
24
- return unless LlmCostTracker.configuration.enabled
25
-
26
- pricing_mode = Pricing.normalize_mode(pricing_mode) || event.pricing_mode
27
- cost_data, pricing_snapshot, priced_line_items = Pricing.calculate(
19
+ pricing_mode ||= event.pricing_mode
20
+ calculation = Pricing::Calculation.for(
28
21
  provider: event.provider,
29
22
  model: event.model,
30
23
  tokens: event.token_usage,
31
24
  line_items: event.line_items,
32
- pricing_mode: pricing_mode
25
+ pricing_mode: pricing_mode,
26
+ usage_source: event.usage_source
33
27
  )
34
28
 
35
- if cost_data.nil? && event.token_usage.total_tokens.positive? && priced_line_items.none?(&:priced?)
29
+ if enforce_budget
30
+ Budget.enforce!(provider: event.provider,
31
+ model: event.model,
32
+ estimate: calculation.cost&.total,
33
+ force: true)
34
+ end
35
+
36
+ if calculation.token_cost.nil? && event.token_usage.total_tokens.positive? &&
37
+ calculation.priced_line_items.none?(&:priced?)
36
38
  Pricing::Unknown.process(event.model)
37
39
  end
38
40
 
39
41
  event = build_event(
40
42
  event: event,
41
- pricing_mode: pricing_mode,
42
- cost_data: cost_data,
43
- pricing_snapshot: pricing_snapshot,
44
- line_items: priced_line_items,
43
+ calculation: calculation,
45
44
  metadata: metadata,
46
45
  latency_ms: latency_ms,
47
46
  context_tags: context_tags
@@ -51,7 +50,7 @@ module LlmCostTracker
51
50
  Ingestion::Inbox.save(event)
52
51
  Ingestion::Worker.ensure_started
53
52
  else
54
- Ledger::Store.insert(event, skip_existence_check: true)
53
+ Ledger::Store.insert(event)
55
54
  end
56
55
 
57
56
  yield if block_given?
@@ -71,29 +70,18 @@ module LlmCostTracker
71
70
  Logging.warn("Subscriber raised on #{EVENT_NAME}: #{e.class}: #{e.message}")
72
71
  end
73
72
 
74
- def build_event(event:, pricing_mode:, cost_data:, pricing_snapshot:, line_items:,
75
- metadata:, latency_ms:, context_tags:)
73
+ def build_event(event:, calculation:, metadata:, latency_ms:, context_tags:)
76
74
  context_tags = (context_tags || LlmCostTracker::Tags::Context.tags).to_h
77
- cost = Pricing.combine_with_service_lines(cost_data, line_items)
78
- cost_status = Billing::CostStatus.call(
79
- token_usage: event.token_usage,
80
- usage_source: event.usage_source,
81
- token_cost: cost_data,
82
- token_pricing_partial: Pricing.token_pricing_partial?(event.token_usage, cost_data),
83
- service_line_items: line_items.reject(&:token?),
84
- total_cost: cost&.fetch(:total_cost, nil)
85
- )
86
-
87
75
  event.with(
88
76
  event_id: SecureRandom.uuid,
89
- pricing_mode: pricing_mode,
90
- cost: cost,
77
+ pricing_mode: calculation.mode,
78
+ cost: calculation.cost,
91
79
  tags: build_tags(context_tags: context_tags, metadata: metadata),
92
80
  latency_ms: finite_latency_ms(latency_ms),
93
81
  tracked_at: Time.now.utc,
94
- cost_status: cost_status,
95
- pricing_snapshot: pricing_snapshot,
96
- line_items: line_items
82
+ cost_status: calculation.cost_status,
83
+ pricing_snapshot: calculation.snapshot,
84
+ line_items: calculation.priced_line_items
97
85
  )
98
86
  end
99
87
 
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/module/delegation"
4
+ require "psych"
5
+
6
+ require_relative "dimension"
7
+
8
+ module LlmCostTracker
9
+ module Usage
10
+ module Catalog
11
+ DEFINITIONS_PATH = File.expand_path("dimensions.yml", __dir__)
12
+
13
+ DEFAULT_RATE_BASIS_BY_UNIT = {
14
+ "token" => "per_million_tokens",
15
+ "character" => "per_million_characters",
16
+ "request" => "per_request",
17
+ "session" => "per_session",
18
+ "hour" => "per_hour",
19
+ "minute" => "per_minute",
20
+ "image" => "per_image"
21
+ }.freeze
22
+
23
+ class << self
24
+ delegate :[], :fetch, to: :index
25
+
26
+ def all
27
+ @all ||= load_definitions.freeze
28
+ end
29
+
30
+ def token_priced
31
+ @token_priced ||= all.select(&:token_key).freeze
32
+ end
33
+
34
+ def token_priced_for(kind:, direction:, cache_state:)
35
+ token_priced.find do |dimension|
36
+ dimension.kind == kind && dimension.direction == direction && dimension.cache_state == cache_state
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def index
43
+ @index ||= all.to_h { |dimension| [dimension.key, dimension] }.freeze
44
+ end
45
+
46
+ def load_definitions
47
+ Psych.safe_load_file(DEFINITIONS_PATH, permitted_classes: [], symbolize_names: true)
48
+ .map { |attributes| build(attributes) }
49
+ end
50
+
51
+ def build(attributes)
52
+ rate_basis = attributes[:rate_basis] || DEFAULT_RATE_BASIS_BY_UNIT.fetch(attributes.fetch(:unit))
53
+ Dimension.new(**attributes, rate_basis: rate_basis)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Usage
5
+ Dimension = Data.define(
6
+ :key, :kind, :direction, :modality, :cache_state, :unit, :rate_basis
7
+ ) do
8
+ def token?
9
+ unit == "token"
10
+ end
11
+
12
+ def token_key
13
+ :"#{key}_tokens" if token?
14
+ end
15
+
16
+ def cost_key
17
+ :"#{key}_cost" if token?
18
+ end
19
+ end
20
+ end
21
+ end
@@ -4,9 +4,6 @@
4
4
  modality: text
5
5
  cache_state: none
6
6
  unit: token
7
- category: token
8
- token_key: input_tokens
9
- cost_key: input_cost
10
7
 
11
8
  - key: cache_read_input
12
9
  kind: text_token
@@ -14,9 +11,6 @@
14
11
  modality: text
15
12
  cache_state: read
16
13
  unit: token
17
- category: token
18
- token_key: cache_read_input_tokens
19
- cost_key: cache_read_input_cost
20
14
 
21
15
  - key: cache_write_input
22
16
  kind: text_token
@@ -24,9 +18,6 @@
24
18
  modality: text
25
19
  cache_state: write_default
26
20
  unit: token
27
- category: token
28
- token_key: cache_write_input_tokens
29
- cost_key: cache_write_input_cost
30
21
 
31
22
  - key: cache_write_extended_input
32
23
  kind: text_token
@@ -34,9 +25,6 @@
34
25
  modality: text
35
26
  cache_state: write_extended
36
27
  unit: token
37
- category: token
38
- token_key: cache_write_extended_input_tokens
39
- cost_key: cache_write_extended_input_cost
40
28
 
41
29
  - key: output
42
30
  kind: text_token
@@ -44,9 +32,6 @@
44
32
  modality: text
45
33
  cache_state: none
46
34
  unit: token
47
- category: token
48
- token_key: output_tokens
49
- cost_key: output_cost
50
35
 
51
36
  - key: audio_input
52
37
  kind: audio_token
@@ -54,9 +39,6 @@
54
39
  modality: audio
55
40
  cache_state: none
56
41
  unit: token
57
- category: token
58
- token_key: audio_input_tokens
59
- cost_key: audio_input_cost
60
42
 
61
43
  - key: audio_output
62
44
  kind: audio_token
@@ -64,9 +46,6 @@
64
46
  modality: audio
65
47
  cache_state: none
66
48
  unit: token
67
- category: token
68
- token_key: audio_output_tokens
69
- cost_key: audio_output_cost
70
49
 
71
50
  - key: image_input
72
51
  kind: image_token
@@ -74,9 +53,6 @@
74
53
  modality: image
75
54
  cache_state: none
76
55
  unit: token
77
- category: token
78
- token_key: image_input_tokens
79
- cost_key: image_input_cost
80
56
 
81
57
  - key: image_output
82
58
  kind: image_token
@@ -84,9 +60,6 @@
84
60
  modality: image
85
61
  cache_state: none
86
62
  unit: token
87
- category: token
88
- token_key: image_output_tokens
89
- cost_key: image_output_cost
90
63
 
91
64
  - key: web_search_request
92
65
  kind: web_search_request
@@ -94,7 +67,6 @@
94
67
  modality: text
95
68
  cache_state: none
96
69
  unit: request
97
- category: tool
98
70
  rate_basis: per_1k_requests
99
71
 
100
72
  - key: web_search_preview_request_reasoning
@@ -103,7 +75,6 @@
103
75
  modality: text
104
76
  cache_state: none
105
77
  unit: request
106
- category: tool
107
78
  rate_basis: per_1k_requests
108
79
 
109
80
  - key: web_search_preview_request_non_reasoning
@@ -112,7 +83,6 @@
112
83
  modality: text
113
84
  cache_state: none
114
85
  unit: request
115
- category: tool
116
86
  rate_basis: per_1k_requests
117
87
 
118
88
  - key: web_fetch_request
@@ -121,7 +91,6 @@
121
91
  modality: text
122
92
  cache_state: none
123
93
  unit: request
124
- category: tool
125
94
  rate_basis: per_1k_requests
126
95
 
127
96
  - key: file_search_call
@@ -130,7 +99,6 @@
130
99
  modality: text
131
100
  cache_state: none
132
101
  unit: request
133
- category: tool
134
102
  rate_basis: per_1k_requests
135
103
 
136
104
  - key: container_session
@@ -139,25 +107,14 @@
139
107
  modality: none
140
108
  cache_state: none
141
109
  unit: session
142
- category: runtime
143
110
  rate_basis: per_session
144
111
 
145
- - key: code_execution_request
146
- kind: code_execution_request
147
- direction: neither
148
- modality: none
149
- cache_state: none
150
- unit: request
151
- category: runtime
152
- rate_basis: per_1k_requests
153
-
154
112
  - key: code_execution_hour
155
113
  kind: code_execution_hour
156
114
  direction: neither
157
115
  modality: none
158
116
  cache_state: none
159
117
  unit: hour
160
- category: runtime
161
118
  rate_basis: per_hour
162
119
 
163
120
  - key: grounding_request
@@ -166,7 +123,6 @@
166
123
  modality: text
167
124
  cache_state: none
168
125
  unit: request
169
- category: tool
170
126
  rate_basis: per_1k_requests
171
127
 
172
128
  - key: text_to_speech_character
@@ -175,14 +131,36 @@
175
131
  modality: audio
176
132
  cache_state: none
177
133
  unit: character
178
- category: tool
179
134
  rate_basis: per_million_characters
180
135
 
136
+ - key: transcription_minute
137
+ kind: transcription_minute
138
+ direction: input
139
+ modality: audio
140
+ cache_state: none
141
+ unit: minute
142
+ rate_basis: per_minute
143
+
144
+ - key: image_generation_call
145
+ kind: image_generation_call
146
+ direction: output
147
+ modality: image
148
+ cache_state: none
149
+ unit: image
150
+ rate_basis: per_image
151
+
152
+ - key: computer_call
153
+ kind: computer_call
154
+ direction: neither
155
+ modality: text
156
+ cache_state: none
157
+ unit: request
158
+ rate_basis: per_request
159
+
181
160
  - key: mcp_call
182
161
  kind: mcp_call
183
162
  direction: neither
184
163
  modality: text
185
164
  cache_state: none
186
165
  unit: request
187
- category: tool
188
166
  rate_basis: per_request
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Usage
5
+ module Source
6
+ MANUAL = "manual"
7
+ UNKNOWN = "unknown"
8
+ RESPONSE = "response"
9
+ STREAM_FINAL = "stream_final"
10
+ SDK_RESPONSE = "sdk_response"
11
+ SDK_BATCH_RESULT = "sdk_batch_result"
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "catalog"
4
+
5
+ module LlmCostTracker
6
+ module Usage
7
+ KNOWN_TOKEN_KEYS = (
8
+ Catalog.token_priced.map { |dimension| dimension.token_key.to_s } + %w[total_tokens hidden_output_tokens]
9
+ ).freeze
10
+
11
+ TokenUsage = Data.define(
12
+ :input_tokens,
13
+ :cache_read_input_tokens,
14
+ :cache_write_input_tokens,
15
+ :cache_write_extended_input_tokens,
16
+ :audio_input_tokens,
17
+ :image_input_tokens,
18
+ :output_tokens,
19
+ :audio_output_tokens,
20
+ :image_output_tokens,
21
+ :total_tokens,
22
+ :hidden_output_tokens
23
+ ) do
24
+ def priced_quantities
25
+ Catalog.token_priced.to_h { |dimension| [dimension.key, public_send(dimension.token_key)] }
26
+ end
27
+
28
+ def self.build_from_tokens(tokens)
29
+ return tokens if tokens.is_a?(self)
30
+ raise ArgumentError, "tokens must be a Hash, got #{tokens.class}" unless tokens.respond_to?(:to_h)
31
+
32
+ values = tokens.to_h.transform_keys(&:to_s)
33
+ warn_on_unknown_keys(values)
34
+ token_attributes = Catalog.token_priced.to_h do |dimension|
35
+ [dimension.token_key, values.fetch(dimension.token_key.to_s, 0)]
36
+ end
37
+
38
+ build(
39
+ **token_attributes,
40
+ total_tokens: values["total_tokens"],
41
+ hidden_output_tokens: values.fetch("hidden_output_tokens", 0)
42
+ )
43
+ end
44
+
45
+ def self.warn_on_unknown_keys(values)
46
+ return if values.empty?
47
+ return if values.keys.intersect?(KNOWN_TOKEN_KEYS)
48
+
49
+ Logging.warn(
50
+ "tokens hash contains no recognized keys (#{values.keys.inspect}); " \
51
+ "expected one of #{KNOWN_TOKEN_KEYS.inspect}. Did you pass a raw provider response?"
52
+ )
53
+ end
54
+
55
+ def self.non_negative_int(value)
56
+ [value.to_i, 0].max
57
+ end
58
+
59
+ def self.build(input_tokens:,
60
+ output_tokens:,
61
+ cache_read_input_tokens: 0,
62
+ cache_write_input_tokens: 0,
63
+ cache_write_extended_input_tokens: 0,
64
+ audio_input_tokens: 0,
65
+ audio_output_tokens: 0,
66
+ image_input_tokens: 0,
67
+ image_output_tokens: 0,
68
+ total_tokens: nil,
69
+ hidden_output_tokens: 0)
70
+ input = non_negative_int(input_tokens)
71
+ output = non_negative_int(output_tokens)
72
+ cache_read = non_negative_int(cache_read_input_tokens)
73
+ cache_write = non_negative_int(cache_write_input_tokens)
74
+ cache_write_extended = non_negative_int(cache_write_extended_input_tokens)
75
+ audio_input = non_negative_int(audio_input_tokens)
76
+ audio_output = non_negative_int(audio_output_tokens)
77
+ image_input = non_negative_int(image_input_tokens)
78
+ image_output = non_negative_int(image_output_tokens)
79
+ hidden_output = non_negative_int(hidden_output_tokens)
80
+ calculated_total = input + cache_read + cache_write + cache_write_extended +
81
+ audio_input + image_input + output + audio_output + image_output
82
+ total = total_tokens ? [non_negative_int(total_tokens), calculated_total].max : calculated_total
83
+
84
+ new(
85
+ input_tokens: input,
86
+ cache_read_input_tokens: cache_read,
87
+ cache_write_input_tokens: cache_write,
88
+ cache_write_extended_input_tokens: cache_write_extended,
89
+ audio_input_tokens: audio_input,
90
+ image_input_tokens: image_input,
91
+ output_tokens: output,
92
+ audio_output_tokens: audio_output,
93
+ image_output_tokens: image_output,
94
+ total_tokens: total,
95
+ hidden_output_tokens: hidden_output
96
+ )
97
+ end
98
+ end
99
+ end
100
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.10.0"
4
+ VERSION = "0.12.0"
5
5
  end
@@ -1,13 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rails"
4
- require "active_support"
5
4
  require "active_support/core_ext/object/blank"
6
- require "active_support/core_ext/object/deep_dup"
7
5
  require "active_support/core_ext/object/try"
8
- require "active_support/core_ext/hash/indifferent_access"
9
- require "active_support/core_ext/string/inflections"
10
- require "active_support/notifications"
11
6
 
12
7
  require_relative "llm_cost_tracker/version"
13
8
  require_relative "llm_cost_tracker/configuration"
@@ -16,32 +11,34 @@ require_relative "llm_cost_tracker/logging"
16
11
  require_relative "llm_cost_tracker/tags/key"
17
12
  require_relative "llm_cost_tracker/tags/context"
18
13
  require_relative "llm_cost_tracker/tags/sanitizer"
19
- require_relative "llm_cost_tracker/masking"
20
- require_relative "llm_cost_tracker/token_usage"
21
- require_relative "llm_cost_tracker/billing/components"
22
- require_relative "llm_cost_tracker/billing/line_item"
23
- require_relative "llm_cost_tracker/billing/cost_status"
14
+ require_relative "llm_cost_tracker/currency"
15
+ require_relative "llm_cost_tracker/usage/catalog"
16
+ require_relative "llm_cost_tracker/usage/token_usage"
17
+ require_relative "llm_cost_tracker/usage/source"
18
+ require_relative "llm_cost_tracker/pricing/rate"
19
+ require_relative "llm_cost_tracker/charges/cost"
20
+ require_relative "llm_cost_tracker/charges/line_item"
21
+ require_relative "llm_cost_tracker/charges/cost_status"
24
22
  require_relative "llm_cost_tracker/event"
25
23
  require_relative "llm_cost_tracker/pricing"
26
24
  require_relative "llm_cost_tracker/parsers"
27
25
  require_relative "llm_cost_tracker/middleware/faraday"
28
26
  require_relative "llm_cost_tracker/integrations"
29
27
  require_relative "llm_cost_tracker/budget"
30
- require_relative "llm_cost_tracker/pricing/unknown"
31
28
  require_relative "llm_cost_tracker/ledger"
32
29
  require_relative "llm_cost_tracker/ingestion"
33
30
  require_relative "llm_cost_tracker/tracker"
34
31
 
35
32
  module LlmCostTracker
36
- autoload :Engine, "llm_cost_tracker/engine"
37
- autoload :Reconciliation, "llm_cost_tracker/reconciliation"
38
- autoload :ReconcileTasks, "llm_cost_tracker/reconcile_tasks"
39
- autoload :Doctor, "llm_cost_tracker/doctor"
40
- autoload :Report, "llm_cost_tracker/report"
41
- autoload :Retention, "llm_cost_tracker/retention"
33
+ autoload :Engine, "llm_cost_tracker/engine"
34
+ autoload :Doctor, "llm_cost_tracker/doctor"
35
+ autoload :CaptureVerifier, "llm_cost_tracker/capture_verifier"
36
+ autoload :Report, "llm_cost_tracker/report"
37
+ autoload :Retention, "llm_cost_tracker/retention"
42
38
 
43
39
  module Pricing
44
40
  autoload :Sync, "llm_cost_tracker/pricing/sync"
41
+ autoload :Unknown, "llm_cost_tracker/pricing/unknown"
45
42
  end
46
43
 
47
44
  @configuration = Configuration.new
@@ -53,72 +50,67 @@ module LlmCostTracker
53
50
  "llm_cost_tracker_"
54
51
  end
55
52
 
56
- def reconciliation_enabled?
57
- configuration.reconciliation_enabled
58
- end
59
-
60
53
  def configure
61
54
  config = configuration
62
55
  raise Error, "LlmCostTracker is already configured" if config.finalized?
63
56
 
64
57
  yield(config)
65
58
  config.finalize!
66
- Pricing::Lookup.reset!
67
59
  Pricing::Registry.reset!
68
- Pricing::ServiceCharges.reset!
69
60
  Integrations.install!
70
61
  config
71
62
  end
72
63
 
73
- def reset_configuration!
74
- Ingestion::Worker.shutdown!(drain: false)
75
- Ingestion::Pool.reset!
76
- @configuration = Configuration.new
77
- Pricing::Lookup.reset!
78
- Pricing::Registry.reset!
79
- Pricing::ServiceCharges.reset!
80
- Pricing::Unknown.reset!
81
- Ingestion::Worker.reset!
82
- Tags::Context.clear!
83
- Dashboard::SetupState.reset!
84
- end
85
-
86
64
  def with_tags(tags = nil, **kwargs, &)
87
65
  Tags::Context.with((tags || {}).merge(kwargs), &)
88
66
  end
89
67
 
90
- def track(provider:, tokens:, model: nil, tags: {}, latency_ms: nil, stream: false,
91
- usage_source: :manual, enforce_budget: false,
92
- provider_response_id: nil, provider_project_id: nil, provider_api_key_id: nil,
93
- provider_workspace_id: nil, batch: nil, pricing_mode: nil, service_line_items: [])
94
- Tracker.enforce_budget! if enforce_budget
95
-
68
+ def track(provider:,
69
+ tokens:,
70
+ model: nil,
71
+ tags: {},
72
+ latency_ms: nil,
73
+ stream: false,
74
+ usage_source: Usage::Source::MANUAL,
75
+ enforce_budget: false,
76
+ provider_response_id: nil,
77
+ provider_project_id: nil,
78
+ provider_api_key_id: nil,
79
+ provider_workspace_id: nil,
80
+ pricing_mode: nil,
81
+ service_line_items: [])
96
82
  Tracker.record(
97
83
  event: Event.build(
98
84
  provider: provider,
99
85
  model: model,
100
- token_usage: TokenUsage.build_from_tokens(tokens),
86
+ token_usage: Usage::TokenUsage.build_from_tokens(tokens),
101
87
  stream: stream,
102
88
  usage_source: usage_source,
103
89
  provider_response_id: provider_response_id,
104
90
  provider_project_id: provider_project_id,
105
91
  provider_api_key_id: provider_api_key_id,
106
92
  provider_workspace_id: provider_workspace_id,
107
- batch: batch,
108
93
  pricing_mode: pricing_mode,
109
94
  service_line_items: service_line_items
110
95
  ),
111
96
  latency_ms: latency_ms,
112
- pricing_mode: pricing_mode,
113
- metadata: tags
97
+ metadata: tags,
98
+ enforce_budget: enforce_budget
114
99
  )
115
100
  end
116
101
 
117
- def track_stream(provider:, model: nil, tags: {}, latency_ms: nil, enforce_budget: false,
118
- provider_response_id: nil, provider_project_id: nil, provider_api_key_id: nil,
119
- provider_workspace_id: nil, batch: nil, pricing_mode: nil)
102
+ def track_stream(provider:,
103
+ model: nil,
104
+ tags: {},
105
+ latency_ms: nil,
106
+ enforce_budget: false,
107
+ provider_response_id: nil,
108
+ provider_project_id: nil,
109
+ provider_api_key_id: nil,
110
+ provider_workspace_id: nil,
111
+ pricing_mode: nil)
120
112
  require_relative "llm_cost_tracker/capture/stream_collector"
121
- Tracker.enforce_budget! if enforce_budget
113
+ Budget.enforce!(provider: provider, model: model, force: true) if enforce_budget
122
114
  collector = Capture::StreamCollector.new(
123
115
  provider: provider.to_s,
124
116
  model: model,
@@ -127,7 +119,6 @@ module LlmCostTracker
127
119
  provider_project_id: provider_project_id,
128
120
  provider_api_key_id: provider_api_key_id,
129
121
  provider_workspace_id: provider_workspace_id,
130
- batch: batch,
131
122
  pricing_mode: pricing_mode,
132
123
  metadata: tags
133
124
  )
@@ -146,4 +137,4 @@ Faraday::Middleware.register_middleware(
146
137
  llm_cost_tracker: LlmCostTracker::Middleware::Faraday
147
138
  )
148
139
 
149
- at_exit { LlmCostTracker::Ingestion::Worker.shutdown!(drain: false) }
140
+ at_exit { LlmCostTracker::Ingestion::Worker.shutdown!(drain: false) if LlmCostTracker::Ingestion.async? }