llm_cost_tracker 0.7.3 → 0.8.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 (152) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +66 -1
  4. data/README.md +58 -225
  5. data/app/assets/llm_cost_tracker/application.css +218 -41
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +30 -17
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +19 -14
  9. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +10 -2
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +11 -24
  11. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
  13. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +20 -7
  15. data/app/models/llm_cost_tracker/call.rb +169 -0
  16. data/app/models/llm_cost_tracker/call_line_item.rb +22 -0
  17. data/app/models/llm_cost_tracker/call_rollup.rb +9 -0
  18. data/app/models/llm_cost_tracker/call_tag.rb +16 -0
  19. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +13 -0
  20. data/app/models/llm_cost_tracker/ingestion/lease.rb +1 -1
  21. data/app/models/llm_cost_tracker/provider_invoice.rb +9 -0
  22. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +121 -30
  23. data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -2
  25. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
  27. data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
  28. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
  29. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
  30. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
  31. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
  32. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  33. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
  34. data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
  35. data/app/views/llm_cost_tracker/calls/show.html.erb +62 -7
  36. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -50
  37. data/app/views/llm_cost_tracker/data_quality/index.html.erb +103 -126
  38. data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
  39. data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
  40. data/app/views/llm_cost_tracker/shared/_filters.html.erb +63 -0
  41. data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
  42. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
  43. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
  44. data/app/views/llm_cost_tracker/tags/show.html.erb +5 -37
  45. data/lib/llm_cost_tracker/billing/components.rb +53 -0
  46. data/lib/llm_cost_tracker/billing/components.yml +117 -0
  47. data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
  48. data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
  49. data/lib/llm_cost_tracker/budget.rb +23 -35
  50. data/lib/llm_cost_tracker/capture/stream_collector.rb +47 -33
  51. data/lib/llm_cost_tracker/configuration.rb +36 -19
  52. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +54 -0
  53. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +24 -32
  54. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
  55. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
  56. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
  57. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
  58. data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
  59. data/lib/llm_cost_tracker/doctor/schema_check.rb +31 -0
  60. data/lib/llm_cost_tracker/doctor.rb +43 -45
  61. data/lib/llm_cost_tracker/errors.rb +5 -19
  62. data/lib/llm_cost_tracker/event.rb +10 -2
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -2
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +157 -0
  66. data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
  67. data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -23
  68. data/lib/llm_cost_tracker/ingestion/worker.rb +14 -5
  69. data/lib/llm_cost_tracker/ingestion.rb +28 -22
  70. data/lib/llm_cost_tracker/integrations/anthropic.rb +45 -38
  71. data/lib/llm_cost_tracker/integrations/base.rb +36 -29
  72. data/lib/llm_cost_tracker/integrations/openai.rb +85 -40
  73. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +5 -5
  74. data/lib/llm_cost_tracker/integrations.rb +2 -2
  75. data/lib/llm_cost_tracker/ledger/period/totals.rb +12 -9
  76. data/lib/llm_cost_tracker/ledger/period.rb +5 -5
  77. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +2 -2
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +76 -25
  79. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +50 -0
  81. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
  82. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +26 -0
  83. data/lib/llm_cost_tracker/ledger/schema/calls.rb +34 -23
  84. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
  85. data/lib/llm_cost_tracker/ledger/store.rb +96 -13
  86. data/lib/llm_cost_tracker/ledger/tags/query.rb +4 -10
  87. data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -15
  88. data/lib/llm_cost_tracker/ledger.rb +4 -2
  89. data/lib/llm_cost_tracker/logging.rb +2 -5
  90. data/lib/llm_cost_tracker/middleware/faraday.rb +7 -6
  91. data/lib/llm_cost_tracker/parsers/anthropic.rb +52 -7
  92. data/lib/llm_cost_tracker/parsers/base.rb +8 -3
  93. data/lib/llm_cost_tracker/parsers/gemini.rb +101 -15
  94. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +10 -2
  95. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +87 -0
  96. data/lib/llm_cost_tracker/parsers/openai_usage.rb +48 -21
  97. data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
  98. data/lib/llm_cost_tracker/parsers.rb +1 -1
  99. data/lib/llm_cost_tracker/prices.json +105 -20
  100. data/lib/llm_cost_tracker/pricing/effective_prices.rb +57 -19
  101. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
  102. data/lib/llm_cost_tracker/pricing/lookup.rb +38 -34
  103. data/lib/llm_cost_tracker/pricing/registry.rb +65 -45
  104. data/lib/llm_cost_tracker/pricing/service_charges.rb +204 -0
  105. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
  106. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
  107. data/lib/llm_cost_tracker/pricing/sync.rb +57 -10
  108. data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
  109. data/lib/llm_cost_tracker/pricing.rb +190 -26
  110. data/lib/llm_cost_tracker/railtie.rb +0 -8
  111. data/lib/llm_cost_tracker/report/data.rb +16 -8
  112. data/lib/llm_cost_tracker/report.rb +0 -4
  113. data/lib/llm_cost_tracker/retention.rb +8 -8
  114. data/lib/llm_cost_tracker/tags/context.rb +2 -4
  115. data/lib/llm_cost_tracker/tags/key.rb +4 -0
  116. data/lib/llm_cost_tracker/tags/sanitizer.rb +12 -17
  117. data/lib/llm_cost_tracker/timing.rb +15 -0
  118. data/lib/llm_cost_tracker/token_usage.rb +56 -42
  119. data/lib/llm_cost_tracker/tracker.rb +67 -24
  120. data/lib/llm_cost_tracker/usage_capture.rb +29 -8
  121. data/lib/llm_cost_tracker/version.rb +1 -1
  122. data/lib/llm_cost_tracker.rb +36 -35
  123. data/lib/tasks/llm_cost_tracker.rake +22 -17
  124. metadata +36 -41
  125. data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
  126. data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
  127. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
  128. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
  129. data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
  130. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
  131. data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
  132. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
  133. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
  134. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
  135. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +0 -29
  136. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +0 -29
  137. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
  138. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
  139. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
  140. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
  141. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
  142. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
  143. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
  144. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
  145. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
  146. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
  147. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
  148. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
  149. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
  150. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
  151. data/lib/llm_cost_tracker/pricing/components.rb +0 -37
  152. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
@@ -1,19 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_support/core_ext/object/blank"
4
+ require "bigdecimal"
4
5
  require "securerandom"
5
6
 
6
7
  require_relative "ingestion"
7
8
  require_relative "ledger"
8
9
  require_relative "pricing"
10
+ require_relative "billing/cost_status"
9
11
 
10
12
  module LlmCostTracker
11
13
  class Tracker
12
14
  EVENT_NAME = "llm_request.llm_cost_tracker"
13
15
 
14
- USAGE_SOURCES = %i[response stream_final sdk_response ruby_llm manual unknown].freeze
15
- TRACKING_METADATA_KEYS = (TokenUsage.members.map(&:to_s) + %w[pricing_mode provider_response_id]).freeze
16
-
17
16
  class << self
18
17
  def enforce_budget!
19
18
  return unless LlmCostTracker.configuration.enabled
@@ -25,19 +24,20 @@ module LlmCostTracker
25
24
  return unless LlmCostTracker.configuration.enabled
26
25
 
27
26
  pricing_mode = Pricing.normalize_mode(pricing_mode) || capture.pricing_mode
28
- cost_data = Pricing.cost_for(
27
+ cost_data, pricing_snapshot = Pricing.cost_and_snapshot_for(
29
28
  provider: capture.provider,
30
29
  model: capture.model,
31
- token_usage: capture.token_usage,
30
+ tokens: capture.token_usage,
32
31
  pricing_mode: pricing_mode
33
32
  )
34
33
 
35
- Pricing::Unknown.handle!(capture.model) unless cost_data
34
+ Pricing::Unknown.handle!(capture.model) if cost_data.nil? && capture.token_usage.total_tokens.positive?
36
35
 
37
36
  event = build_event(
38
37
  capture: capture,
39
38
  pricing_mode: pricing_mode,
40
39
  cost_data: cost_data,
40
+ pricing_snapshot: pricing_snapshot,
41
41
  metadata: metadata,
42
42
  latency_ms: latency_ms,
43
43
  context_tags: context_tags
@@ -53,15 +53,32 @@ module LlmCostTracker
53
53
 
54
54
  private
55
55
 
56
- def build_event(capture:, pricing_mode:, cost_data:, metadata:, latency_ms:, context_tags:)
57
- usage_source = if capture.usage_source.nil?
58
- nil
59
- else
60
- symbol = capture.usage_source.to_sym
61
- USAGE_SOURCES.include?(symbol) ? symbol.to_s : nil
62
- end
63
- tags = metadata.to_h.reject { |key, _value| TRACKING_METADATA_KEYS.include?(key.to_s) }
64
- context_tags = context_tags.nil? ? LlmCostTracker::Tags::Context.tags : context_tags.to_h
56
+ def token_pricing_partial?(token_usage:, cost_data:)
57
+ return false unless cost_data
58
+
59
+ Billing::Components::TOKEN_PRICED.any? do |component|
60
+ token_usage.public_send(component.token_key).positive? && cost_data[component.cost_key].nil?
61
+ end
62
+ end
63
+
64
+ # rubocop:disable Metrics/MethodLength
65
+ def build_event(capture:, pricing_mode:, cost_data:, pricing_snapshot:, metadata:, latency_ms:, context_tags:)
66
+ context_tags = (context_tags || LlmCostTracker::Tags::Context.tags).to_h
67
+ line_items, = Pricing.price_line_items(
68
+ provider: capture.provider,
69
+ model: capture.model,
70
+ line_items: capture.line_items,
71
+ pricing_mode: pricing_mode
72
+ )
73
+ cost = cost_with_service_lines(cost_data, line_items)
74
+ cost_status = Billing::CostStatus.call(
75
+ token_usage: capture.token_usage,
76
+ usage_source: capture.usage_source,
77
+ token_cost: cost_data,
78
+ token_pricing_partial: token_pricing_partial?(token_usage: capture.token_usage, cost_data: cost_data),
79
+ service_line_items: line_items.reject(&:token?),
80
+ total_cost: cost&.fetch(:total_cost, nil)
81
+ )
65
82
 
66
83
  Event.new(
67
84
  event_id: SecureRandom.uuid,
@@ -69,17 +86,43 @@ module LlmCostTracker
69
86
  model: capture.model,
70
87
  token_usage: capture.token_usage,
71
88
  pricing_mode: pricing_mode,
72
- cost: cost_data,
73
- tags: LlmCostTracker::Tags::Sanitizer.call(
74
- context_tags.merge(tags)
75
- ).freeze,
76
- latency_ms: latency_ms.nil? ? nil : [latency_ms.to_i, 0].max,
77
- stream: capture.stream ? true : false,
78
- usage_source: usage_source,
79
- provider_response_id: capture.provider_response_id.to_s.presence,
80
- tracked_at: Time.now.utc
89
+ cost: cost,
90
+ tags: LlmCostTracker::Tags::Sanitizer.call(context_tags.merge(metadata.to_h)).freeze,
91
+ latency_ms: finite_latency_ms(latency_ms),
92
+ stream: capture.stream,
93
+ usage_source: capture.usage_source,
94
+ provider_response_id: capture.provider_response_id,
95
+ provider_project_id: capture.provider_project_id,
96
+ provider_api_key_id: capture.provider_api_key_id,
97
+ provider_workspace_id: capture.provider_workspace_id,
98
+ batch: capture.batch,
99
+ tracked_at: Time.now.utc,
100
+ cost_status: cost_status,
101
+ pricing_snapshot: pricing_snapshot,
102
+ line_items: line_items
81
103
  )
82
104
  end
105
+ # rubocop:enable Metrics/MethodLength
106
+
107
+ def finite_latency_ms(latency_ms)
108
+ return nil if latency_ms.nil?
109
+
110
+ Integer(latency_ms).clamp(0, (1 << 31) - 1)
111
+ rescue ArgumentError, TypeError, FloatDomainError
112
+ nil
113
+ end
114
+
115
+ def cost_with_service_lines(cost_data, line_items)
116
+ service_lines = line_items.reject(&:token?)
117
+ return cost_data if service_lines.empty?
118
+ return cost_data if service_lines.none?(&:priced?)
119
+
120
+ service_total = service_lines.sum(BigDecimal("0"), &:cost_value)
121
+ cost = cost_data ? cost_data.dup : {}
122
+ base_total = BigDecimal(cost.fetch(:total_cost, 0).to_s)
123
+ cost[:total_cost] = (base_total + service_total).round(8).to_f
124
+ cost
125
+ end
83
126
  end
84
127
  end
85
128
  end
@@ -3,6 +3,7 @@
3
3
  require "active_support/core_ext/object/blank"
4
4
 
5
5
  require_relative "pricing"
6
+ require_relative "billing/line_item"
6
7
 
7
8
  module LlmCostTracker
8
9
  UsageCapture = Data.define(
@@ -12,26 +13,46 @@ module LlmCostTracker
12
13
  :stream,
13
14
  :usage_source,
14
15
  :provider_response_id,
15
- :pricing_mode
16
+ :provider_project_id,
17
+ :provider_api_key_id,
18
+ :provider_workspace_id,
19
+ :batch,
20
+ :pricing_mode,
21
+ :line_items
16
22
  )
17
23
 
18
24
  class UsageCapture
19
25
  UNKNOWN_MODEL = "unknown"
20
26
 
27
+ def self.batch_from_pricing_mode?(pricing_mode)
28
+ pricing_mode.to_s.split("_").include?("batch")
29
+ end
30
+
21
31
  def self.build(**attributes)
32
+ pricing_mode = Pricing.normalize_mode(attributes[:pricing_mode])
33
+ batch = attributes[:batch]
34
+ batch = batch_from_pricing_mode?(pricing_mode) if batch.nil?
35
+
36
+ token_usage = attributes.fetch(:token_usage)
37
+ service_line_items = Array(attributes[:service_line_items]).map do |item|
38
+ item.is_a?(Billing::LineItem) ? item : Billing::LineItem.build(item)
39
+ end
40
+ line_items = attributes[:line_items] || (Billing::LineItem.from_token_usage(token_usage) + service_line_items)
41
+
22
42
  new(
23
43
  provider: attributes.fetch(:provider).to_s,
24
44
  model: attributes.fetch(:model).to_s.strip.presence || UNKNOWN_MODEL,
25
- token_usage: attributes.fetch(:token_usage),
45
+ token_usage: token_usage,
26
46
  stream: attributes[:stream] || false,
27
47
  usage_source: attributes[:usage_source],
28
- provider_response_id: attributes[:provider_response_id],
29
- pricing_mode: Pricing.normalize_mode(attributes[:pricing_mode])
48
+ provider_response_id: attributes[:provider_response_id].to_s.strip.presence,
49
+ provider_project_id: attributes[:provider_project_id].to_s.strip.presence,
50
+ provider_api_key_id: attributes[:provider_api_key_id].to_s.strip.presence,
51
+ provider_workspace_id: attributes[:provider_workspace_id].to_s.strip.presence,
52
+ batch: batch,
53
+ pricing_mode: pricing_mode,
54
+ line_items: line_items
30
55
  )
31
56
  end
32
-
33
- def to_h
34
- super.compact
35
- end
36
57
  end
37
58
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.7.3"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -17,6 +17,9 @@ require_relative "llm_cost_tracker/tags/key"
17
17
  require_relative "llm_cost_tracker/tags/context"
18
18
  require_relative "llm_cost_tracker/tags/sanitizer"
19
19
  require_relative "llm_cost_tracker/token_usage"
20
+ require_relative "llm_cost_tracker/billing/components"
21
+ require_relative "llm_cost_tracker/billing/line_item"
22
+ require_relative "llm_cost_tracker/billing/cost_status"
20
23
  require_relative "llm_cost_tracker/event"
21
24
  require_relative "llm_cost_tracker/pricing"
22
25
  require_relative "llm_cost_tracker/usage_capture"
@@ -47,14 +50,19 @@ module LlmCostTracker
47
50
  class << self
48
51
  attr_reader :configuration
49
52
 
53
+ def table_name_prefix
54
+ "llm_cost_tracker_"
55
+ end
56
+
50
57
  def configure
51
58
  config = configuration
52
59
  raise Error, "LlmCostTracker is already configured" if config.finalized?
53
60
 
54
61
  yield(config)
55
- config.openai_compatible_providers = config.openai_compatible_providers.dup
56
62
  config.finalize!
57
63
  Pricing::Lookup.reset!
64
+ Pricing::Registry.reset!
65
+ Pricing::ServiceCharges.reset!
58
66
  Integrations.install!
59
67
  config
60
68
  end
@@ -63,67 +71,60 @@ module LlmCostTracker
63
71
  Ingestion::Worker.shutdown!(drain: false)
64
72
  @configuration = Configuration.new
65
73
  Pricing::Lookup.reset!
74
+ Pricing::Registry.reset!
75
+ Pricing::ServiceCharges.reset!
66
76
  Pricing::Unknown.reset!
67
77
  Ingestion::Worker.reset!
68
78
  Tags::Context.clear!
69
79
  end
70
80
 
71
- def flush!(timeout: nil)
72
- if timeout
73
- Ingestion::Worker.flush!(timeout: timeout)
74
- else
75
- Ingestion::Worker.flush!
76
- end
77
- end
78
-
79
- def shutdown!(timeout: nil, drain: true)
80
- if timeout
81
- Ingestion::Worker.shutdown!(timeout: timeout, drain: drain)
82
- else
83
- Ingestion::Worker.shutdown!(drain: drain)
84
- end
85
- end
86
-
87
- def enforce_budget!
88
- Tracker.enforce_budget!
89
- end
90
-
91
81
  def with_tags(tags = nil, **kwargs, &)
92
- merged = (tags || {}).to_h.merge(kwargs)
93
- Tags::Context.with(merged, &)
82
+ Tags::Context.with((tags || {}).merge(kwargs), &)
94
83
  end
95
84
 
96
- def track(provider:, input_tokens:, output_tokens:, model: nil, latency_ms: nil, stream: false,
97
- usage_source: :manual, enforce_budget: false, provider_response_id: nil, pricing_mode: nil, **metadata)
98
- enforce_budget! if enforce_budget
99
- token_usage = TokenUsage.from_hash(metadata.merge(input_tokens: input_tokens, output_tokens: output_tokens))
85
+ def track(provider:, tokens:, model: nil, tags: {}, latency_ms: nil, stream: false,
86
+ usage_source: :manual, enforce_budget: false,
87
+ provider_response_id: nil, provider_project_id: nil, provider_api_key_id: nil,
88
+ provider_workspace_id: nil, batch: nil, pricing_mode: nil, service_line_items: [])
89
+ Tracker.enforce_budget! if enforce_budget
100
90
 
101
91
  Tracker.record(
102
92
  capture: UsageCapture.build(
103
93
  provider: provider,
104
94
  model: model,
105
- token_usage: token_usage,
95
+ token_usage: TokenUsage.build_from_tokens(tokens),
106
96
  stream: stream,
107
97
  usage_source: usage_source,
108
- provider_response_id: provider_response_id
98
+ provider_response_id: provider_response_id,
99
+ provider_project_id: provider_project_id,
100
+ provider_api_key_id: provider_api_key_id,
101
+ provider_workspace_id: provider_workspace_id,
102
+ batch: batch,
103
+ pricing_mode: pricing_mode,
104
+ service_line_items: service_line_items
109
105
  ),
110
106
  latency_ms: latency_ms,
111
107
  pricing_mode: pricing_mode,
112
- metadata: metadata
108
+ metadata: tags
113
109
  )
114
110
  end
115
111
 
116
- def track_stream(provider:, model: nil, latency_ms: nil, enforce_budget: false, provider_response_id: nil,
117
- pricing_mode: nil, **metadata)
112
+ def track_stream(provider:, model: nil, tags: {}, latency_ms: nil, enforce_budget: false,
113
+ provider_response_id: nil, provider_project_id: nil, provider_api_key_id: nil,
114
+ provider_workspace_id: nil, batch: nil, pricing_mode: nil)
118
115
  require_relative "llm_cost_tracker/capture/stream_collector"
119
- enforce_budget! if enforce_budget
116
+ Tracker.enforce_budget! if enforce_budget
120
117
  collector = Capture::StreamCollector.new(
121
118
  provider: provider.to_s,
122
119
  model: model,
123
120
  latency_ms: latency_ms,
124
121
  provider_response_id: provider_response_id,
122
+ provider_project_id: provider_project_id,
123
+ provider_api_key_id: provider_api_key_id,
124
+ provider_workspace_id: provider_workspace_id,
125
+ batch: batch,
125
126
  pricing_mode: pricing_mode,
126
- metadata: metadata
127
+ metadata: tags
127
128
  )
128
129
  yield collector
129
130
  collector.finish!
@@ -140,4 +141,4 @@ Faraday::Middleware.register_middleware(
140
141
  llm_cost_tracker: LlmCostTracker::Middleware::Faraday
141
142
  )
142
143
 
143
- at_exit { LlmCostTracker.shutdown!(drain: false) }
144
+ at_exit { LlmCostTracker::Ingestion::Worker.shutdown!(drain: false) }
@@ -1,9 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "rails/generators"
5
+
6
+ require_relative "../llm_cost_tracker/generators/llm_cost_tracker/install_generator"
7
+ require_relative "../llm_cost_tracker/pricing/sync_change_printer"
4
8
 
5
9
  # rubocop:disable Metrics/BlockLength
6
10
  namespace :llm_cost_tracker do
11
+ desc "Install LLM Cost Tracker with dashboard and prices, migrate, and run doctor"
12
+ task :setup do
13
+ Rails::Generators.invoke("llm_cost_tracker:install", %w[--dashboard --prices])
14
+ Rake::Task["db:migrate"].invoke
15
+ Rake::Task["llm_cost_tracker:doctor"].invoke
16
+ end
17
+
7
18
  desc "Check LLM Cost Tracker setup"
8
19
  task :doctor do
9
20
  Rake::Task["environment"].invoke if Rake::Task.task_defined?("environment")
@@ -30,7 +41,7 @@ namespace :llm_cost_tracker do
30
41
  puts LlmCostTracker::Report.generate(days: days)
31
42
  end
32
43
 
33
- desc "Delete llm_api_calls older than DAYS (default: 90). Use BATCH_SIZE=N to tune."
44
+ desc "Delete llm_cost_tracker_calls older than DAYS (default: 90). Use BATCH_SIZE=N to tune."
34
45
  task prune: :environment do
35
46
  days = (ENV["DAYS"] || 90).to_i
36
47
  batch_size = (ENV["BATCH_SIZE"] || LlmCostTracker::Retention::DEFAULT_BATCH_SIZE).to_i
@@ -102,15 +113,7 @@ end
102
113
  # rubocop:enable Metrics/BlockLength
103
114
 
104
115
  def print_changes(changes)
105
- puts " changed models: #{changes.size}"
106
- return if changes.empty?
107
-
108
- changes.each do |model, fields|
109
- puts " - #{model}"
110
- fields.each do |field, values|
111
- puts " #{field}: #{values['from'].inspect} -> #{values['to'].inspect}"
112
- end
113
- end
116
+ LlmCostTracker::Pricing::SyncChangePrinter.call(changes)
114
117
  end
115
118
 
116
119
  def price_refresh_output_path
@@ -128,13 +131,15 @@ def price_explanation_from_env
128
131
  provider: provider,
129
132
  model: model,
130
133
  pricing_mode: ENV.fetch("PRICING_MODE", nil),
131
- token_usage: LlmCostTracker::TokenUsage.build(
132
- input_tokens: ENV.fetch("INPUT_TOKENS", 1).to_i,
133
- output_tokens: ENV.fetch("OUTPUT_TOKENS", 1).to_i,
134
- cache_read_input_tokens: ENV.fetch("CACHE_READ_INPUT_TOKENS", 0).to_i,
135
- cache_write_input_tokens: ENV.fetch("CACHE_WRITE_INPUT_TOKENS", 0).to_i,
136
- cache_write_1h_input_tokens: ENV.fetch("CACHE_WRITE_1H_INPUT_TOKENS", 0).to_i
137
- )
134
+ tokens: {
135
+ input: ENV.fetch("INPUT_TOKENS", 1).to_i,
136
+ output: ENV.fetch("OUTPUT_TOKENS", 1).to_i,
137
+ cache_read_input: ENV.fetch("CACHE_READ_INPUT_TOKENS", 0).to_i,
138
+ cache_write_input: ENV.fetch("CACHE_WRITE_INPUT_TOKENS", 0).to_i,
139
+ cache_write_extended_input: ENV.fetch("CACHE_WRITE_EXTENDED_INPUT_TOKENS", 0).to_i,
140
+ audio_input: ENV.fetch("AUDIO_INPUT_TOKENS", 0).to_i,
141
+ audio_output: ENV.fetch("AUDIO_OUTPUT_TOKENS", 0).to_i
142
+ }
138
143
  )
139
144
  end
140
145
 
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm_cost_tracker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.3
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergii Khomenko
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-05-01 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activerecord
@@ -216,11 +215,9 @@ dependencies:
216
215
  - - "~>"
217
216
  - !ruby/object:Gem::Version
218
217
  version: '3.0'
219
- description: Tracks token usage, latency, and estimated costs for RubyLLM, OpenAI,
220
- Anthropic, Google Gemini, OpenRouter, DeepSeek, and OpenAI-compatible APIs. Works
221
- through Rails SDK integrations, Faraday middleware, or explicit track/track_stream
222
- helpers, with ActiveRecord storage, tag-based attribution, price sync tasks, and
223
- budget guardrails.
218
+ description: 'Logs every call your Rails app makes to OpenAI, Anthropic, Gemini, RubyLLM,
219
+ or an OpenAI-compatible API: tokens, cost, latency, tags. Calls go straight to the
220
+ provider no proxy. Includes price sync, budget guardrails, and a mountable dashboard.'
224
221
  email:
225
222
  - sergey@mm.st
226
223
  executables: []
@@ -228,6 +225,7 @@ extensions: []
228
225
  extra_rdoc_files: []
229
226
  files:
230
227
  - ".rspec"
228
+ - ".ruby-version"
231
229
  - CHANGELOG.md
232
230
  - CODE_OF_CONDUCT.md
233
231
  - LICENSE.txt
@@ -249,13 +247,13 @@ files:
249
247
  - app/helpers/llm_cost_tracker/dashboard_query_helper.rb
250
248
  - app/helpers/llm_cost_tracker/pagination_helper.rb
251
249
  - app/helpers/llm_cost_tracker/token_usage_helper.rb
252
- - app/models/llm_cost_tracker/ingestion/event.rb
250
+ - app/models/llm_cost_tracker/call.rb
251
+ - app/models/llm_cost_tracker/call_line_item.rb
252
+ - app/models/llm_cost_tracker/call_rollup.rb
253
+ - app/models/llm_cost_tracker/call_tag.rb
254
+ - app/models/llm_cost_tracker/ingestion/inbox_entry.rb
253
255
  - app/models/llm_cost_tracker/ingestion/lease.rb
254
- - app/models/llm_cost_tracker/ledger/call.rb
255
- - app/models/llm_cost_tracker/ledger/call_metrics.rb
256
- - app/models/llm_cost_tracker/ledger/period/grouping.rb
257
- - app/models/llm_cost_tracker/ledger/period/total.rb
258
- - app/models/llm_cost_tracker/ledger/tags/accessors.rb
256
+ - app/models/llm_cost_tracker/provider_invoice.rb
259
257
  - app/services/llm_cost_tracker/dashboard/data_quality.rb
260
258
  - app/services/llm_cost_tracker/dashboard/date_range.rb
261
259
  - app/services/llm_cost_tracker/dashboard/filter.rb
@@ -279,7 +277,9 @@ files:
279
277
  - app/views/llm_cost_tracker/models/index.html.erb
280
278
  - app/views/llm_cost_tracker/shared/_active_filters.html.erb
281
279
  - app/views/llm_cost_tracker/shared/_bar.html.erb
280
+ - app/views/llm_cost_tracker/shared/_filters.html.erb
282
281
  - app/views/llm_cost_tracker/shared/_metric_stack.html.erb
282
+ - app/views/llm_cost_tracker/shared/_sort.html.erb
283
283
  - app/views/llm_cost_tracker/shared/_spend_chart.html.erb
284
284
  - app/views/llm_cost_tracker/shared/_tag_chips.html.erb
285
285
  - app/views/llm_cost_tracker/shared/setup_required.html.erb
@@ -288,40 +288,33 @@ files:
288
288
  - config/routes.rb
289
289
  - lib/llm_cost_tracker.rb
290
290
  - lib/llm_cost_tracker/assets.rb
291
+ - lib/llm_cost_tracker/billing/components.rb
292
+ - lib/llm_cost_tracker/billing/components.yml
293
+ - lib/llm_cost_tracker/billing/cost_status.rb
294
+ - lib/llm_cost_tracker/billing/line_item.rb
291
295
  - lib/llm_cost_tracker/budget.rb
292
296
  - lib/llm_cost_tracker/capture/stream.rb
293
297
  - lib/llm_cost_tracker/capture/stream_collector.rb
294
298
  - lib/llm_cost_tracker/capture/stream_tracker.rb
295
299
  - lib/llm_cost_tracker/configuration.rb
296
- - lib/llm_cost_tracker/configuration/instrumentation.rb
297
300
  - lib/llm_cost_tracker/doctor.rb
298
301
  - lib/llm_cost_tracker/doctor/capture_verifier.rb
299
302
  - lib/llm_cost_tracker/doctor/check.rb
303
+ - lib/llm_cost_tracker/doctor/cost_drift_check.rb
300
304
  - lib/llm_cost_tracker/doctor/ingestion_check.rb
305
+ - lib/llm_cost_tracker/doctor/legacy_audit_check.rb
306
+ - lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb
301
307
  - lib/llm_cost_tracker/doctor/price_check.rb
308
+ - lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb
309
+ - lib/llm_cost_tracker/doctor/probe.rb
310
+ - lib/llm_cost_tracker/doctor/schema_check.rb
302
311
  - lib/llm_cost_tracker/engine.rb
303
312
  - lib/llm_cost_tracker/errors.rb
304
313
  - lib/llm_cost_tracker/event.rb
305
- - lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb
306
- - lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb
307
- - lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb
308
- - lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb
309
- - lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb
310
- - lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb
311
314
  - lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb
312
315
  - lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb
313
- - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb
314
- - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb
315
- - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb
316
- - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb
317
- - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb
318
- - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb
319
- - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb
316
+ - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb
320
317
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb
321
- - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb
322
- - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb
323
- - lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb
324
- - lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb
325
318
  - lib/llm_cost_tracker/ingestion.rb
326
319
  - lib/llm_cost_tracker/ingestion/batch.rb
327
320
  - lib/llm_cost_tracker/ingestion/inbox.rb
@@ -336,11 +329,13 @@ files:
336
329
  - lib/llm_cost_tracker/ledger/period.rb
337
330
  - lib/llm_cost_tracker/ledger/period/totals.rb
338
331
  - lib/llm_cost_tracker/ledger/rollups.rb
339
- - lib/llm_cost_tracker/ledger/rollups/batch.rb
340
332
  - lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb
341
333
  - lib/llm_cost_tracker/ledger/schema/adapter.rb
334
+ - lib/llm_cost_tracker/ledger/schema/call_line_items.rb
335
+ - lib/llm_cost_tracker/ledger/schema/call_rollups.rb
336
+ - lib/llm_cost_tracker/ledger/schema/call_tags.rb
342
337
  - lib/llm_cost_tracker/ledger/schema/calls.rb
343
- - lib/llm_cost_tracker/ledger/schema/period_totals.rb
338
+ - lib/llm_cost_tracker/ledger/schema/provider_invoices.rb
344
339
  - lib/llm_cost_tracker/ledger/store.rb
345
340
  - lib/llm_cost_tracker/ledger/tags/query.rb
346
341
  - lib/llm_cost_tracker/ledger/tags/sql.rb
@@ -352,20 +347,21 @@ files:
352
347
  - lib/llm_cost_tracker/parsers/gemini.rb
353
348
  - lib/llm_cost_tracker/parsers/openai.rb
354
349
  - lib/llm_cost_tracker/parsers/openai_compatible.rb
350
+ - lib/llm_cost_tracker/parsers/openai_service_charges.rb
355
351
  - lib/llm_cost_tracker/parsers/openai_usage.rb
356
352
  - lib/llm_cost_tracker/parsers/sse.rb
357
353
  - lib/llm_cost_tracker/prices.json
358
354
  - lib/llm_cost_tracker/pricing.rb
359
- - lib/llm_cost_tracker/pricing/components.rb
360
355
  - lib/llm_cost_tracker/pricing/effective_prices.rb
361
356
  - lib/llm_cost_tracker/pricing/explainer.rb
362
357
  - lib/llm_cost_tracker/pricing/lookup.rb
363
358
  - lib/llm_cost_tracker/pricing/registry.rb
359
+ - lib/llm_cost_tracker/pricing/service_charges.rb
364
360
  - lib/llm_cost_tracker/pricing/sync.rb
365
361
  - lib/llm_cost_tracker/pricing/sync/fetcher.rb
366
362
  - lib/llm_cost_tracker/pricing/sync/registry_diff.rb
367
- - lib/llm_cost_tracker/pricing/sync/registry_loader.rb
368
363
  - lib/llm_cost_tracker/pricing/sync/registry_writer.rb
364
+ - lib/llm_cost_tracker/pricing/sync_change_printer.rb
369
365
  - lib/llm_cost_tracker/pricing/unknown.rb
370
366
  - lib/llm_cost_tracker/railtie.rb
371
367
  - lib/llm_cost_tracker/report.rb
@@ -375,6 +371,7 @@ files:
375
371
  - lib/llm_cost_tracker/tags/context.rb
376
372
  - lib/llm_cost_tracker/tags/key.rb
377
373
  - lib/llm_cost_tracker/tags/sanitizer.rb
374
+ - lib/llm_cost_tracker/timing.rb
378
375
  - lib/llm_cost_tracker/token_usage.rb
379
376
  - lib/llm_cost_tracker/tracker.rb
380
377
  - lib/llm_cost_tracker/usage_capture.rb
@@ -389,7 +386,6 @@ metadata:
389
386
  source_code_uri: https://github.com/sergey-homenko/llm_cost_tracker
390
387
  documentation_uri: https://github.com/sergey-homenko/llm_cost_tracker#readme
391
388
  rubygems_mfa_required: 'true'
392
- post_install_message:
393
389
  rdoc_options: []
394
390
  require_paths:
395
391
  - lib
@@ -397,15 +393,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
397
393
  requirements:
398
394
  - - ">="
399
395
  - !ruby/object:Gem::Version
400
- version: 3.3.0
396
+ version: 3.4.0
401
397
  required_rubygems_version: !ruby/object:Gem::Requirement
402
398
  requirements:
403
399
  - - ">="
404
400
  - !ruby/object:Gem::Version
405
401
  version: '0'
406
402
  requirements: []
407
- rubygems_version: 3.5.22
408
- signing_key:
403
+ rubygems_version: 3.6.9
409
404
  specification_version: 4
410
- summary: Rails-native LLM usage and cost tracking with ActiveRecord storage
405
+ summary: LLM API cost tracking for Rails applications
411
406
  test_files: []
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_record"
4
-
5
- module LlmCostTracker
6
- module Ingestion
7
- class Event < ActiveRecord::Base
8
- MAX_ATTEMPTS = 5
9
-
10
- self.table_name = "llm_cost_tracker_inbox_events"
11
- end
12
- end
13
- end
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_record"
4
-
5
- module LlmCostTracker
6
- module Ledger
7
- class Call < ActiveRecord::Base
8
- extend Period::Grouping
9
- extend Ledger::CallMetrics
10
- include Ledger::Tags::Accessors
11
-
12
- self.table_name = "llm_api_calls"
13
-
14
- scope :with_cost, -> { where.not(total_cost: nil) }
15
- scope :without_cost, -> { where(total_cost: nil) }
16
- scope :unknown_pricing, -> { without_cost }
17
- scope :with_latency, -> { where.not(latency_ms: nil) }
18
- scope :streaming, -> { where(stream: true) }
19
- scope :non_streaming, -> { where(stream: [false, nil]) }
20
- scope :by_usage_source, ->(source) { where(usage_source: source.to_s) }
21
- scope :with_provider_response_id, -> { where.not(provider_response_id: [nil, ""]) }
22
- scope :missing_provider_response_id, -> { where(provider_response_id: [nil, ""]) }
23
- scope :streaming_missing_usage, lambda {
24
- where(stream: true).where(usage_source: ["unknown", nil])
25
- }
26
-
27
- scope :with_json_tags, lambda {
28
- where.not(tags: {})
29
- }
30
-
31
- scope :today, -> { where(tracked_at: Time.now.utc.beginning_of_day..) }
32
- scope :this_week, -> { where(tracked_at: Time.now.utc.beginning_of_week..) }
33
- scope :this_month, -> { where(tracked_at: Time.now.utc.beginning_of_month..) }
34
- scope :between, ->(from, to) { where(tracked_at: from..to) }
35
-
36
- def self.by_tag(key, value)
37
- by_tags(key => value)
38
- end
39
-
40
- def self.by_tags(tags)
41
- Ledger::Tags::Query.apply(tags)
42
- end
43
- end
44
- end
45
- end