llm_cost_tracker 0.9.0 → 0.11.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 (145) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +6 -2
  4. data/app/assets/llm_cost_tracker/application.css +782 -801
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +15 -3
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +39 -20
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +0 -3
  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/reconciliation_controller.rb +13 -19
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +16 -4
  13. data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
  14. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
  15. data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
  16. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
  17. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +104 -0
  18. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  19. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
  20. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  21. data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
  22. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  23. data/app/views/llm_cost_tracker/calls/show.html.erb +119 -120
  24. data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
  25. data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
  26. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  27. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  28. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  29. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +49 -58
  30. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  31. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  32. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  33. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  34. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  35. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +83 -102
  39. data/config/routes.rb +1 -0
  40. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  41. data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
  42. data/lib/llm_cost_tracker/budget.rb +29 -8
  43. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
  44. data/lib/llm_cost_tracker/capture/stream_collector.rb +34 -42
  45. data/lib/llm_cost_tracker/capture/stream_tracker.rb +2 -6
  46. data/lib/llm_cost_tracker/configuration.rb +30 -44
  47. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  48. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
  49. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  50. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  51. data/lib/llm_cost_tracker/doctor.rb +80 -25
  52. data/lib/llm_cost_tracker/engine.rb +1 -2
  53. data/lib/llm_cost_tracker/errors.rb +3 -2
  54. data/lib/llm_cost_tracker/event.rb +47 -0
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/{create_llm_cost_tracker_durable_ingestion.rb.erb → create_llm_cost_tracker_async_ingestion.rb.erb} +3 -3
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +27 -8
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +36 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +27 -0
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  67. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  68. data/lib/llm_cost_tracker/ingestion/inbox.rb +4 -25
  69. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  70. data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
  71. data/lib/llm_cost_tracker/ingestion.rb +8 -9
  72. data/lib/llm_cost_tracker/integrations/anthropic.rb +46 -68
  73. data/lib/llm_cost_tracker/integrations/base.rb +14 -11
  74. data/lib/llm_cost_tracker/integrations/openai.rb +104 -131
  75. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +27 -73
  76. data/lib/llm_cost_tracker/integrations.rb +14 -13
  77. data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
  79. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
  81. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
  82. data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
  83. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
  84. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
  85. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
  86. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
  87. data/lib/llm_cost_tracker/ledger/store.rb +21 -18
  88. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
  89. data/lib/llm_cost_tracker/ledger.rb +13 -0
  90. data/lib/llm_cost_tracker/logging.rb +0 -4
  91. data/lib/llm_cost_tracker/middleware/faraday.rb +46 -17
  92. data/lib/llm_cost_tracker/parsers/anthropic.rb +35 -59
  93. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  94. data/lib/llm_cost_tracker/parsers/base.rb +53 -47
  95. data/lib/llm_cost_tracker/parsers/gemini.rb +23 -27
  96. data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
  97. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -49
  98. data/lib/llm_cost_tracker/parsers/openai_usage.rb +19 -23
  99. data/lib/llm_cost_tracker/parsers.rb +29 -4
  100. data/lib/llm_cost_tracker/prices.json +567 -579
  101. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  102. data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
  103. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  104. data/lib/llm_cost_tracker/pricing/explainer.rb +5 -2
  105. data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
  106. data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
  107. data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
  108. data/lib/llm_cost_tracker/pricing/service_charges.rb +6 -10
  109. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  110. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
  111. data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
  112. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  113. data/lib/llm_cost_tracker/pricing.rb +71 -43
  114. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
  115. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  116. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  117. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  118. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  119. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  120. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
  121. data/lib/llm_cost_tracker/railtie.rb +3 -5
  122. data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
  123. data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
  124. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
  125. data/lib/llm_cost_tracker/reconciliation/importer.rb +3 -7
  126. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +10 -33
  127. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
  128. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
  129. data/lib/llm_cost_tracker/report/formatter.rb +32 -19
  130. data/lib/llm_cost_tracker/report.rb +0 -4
  131. data/lib/llm_cost_tracker/retention.rb +20 -8
  132. data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
  133. data/lib/llm_cost_tracker/token_usage.rb +4 -0
  134. data/lib/llm_cost_tracker/tracker.rb +33 -74
  135. data/lib/llm_cost_tracker/version.rb +1 -1
  136. data/lib/llm_cost_tracker.rb +11 -15
  137. data/lib/tasks/llm_cost_tracker.rake +16 -2
  138. metadata +31 -12
  139. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  140. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  141. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  142. data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
  143. data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
  144. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -126
  145. data/lib/llm_cost_tracker/usage_capture.rb +0 -58
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_support/core_ext/object/blank"
4
- require "bigdecimal"
5
4
  require "securerandom"
6
5
 
7
6
  require_relative "ingestion"
@@ -15,28 +14,30 @@ module LlmCostTracker
15
14
  EVENT_NAME = "llm_request.llm_cost_tracker"
16
15
 
17
16
  class << self
18
- def enforce_budget!
17
+ def enforce_budget!(provider: nil, model: nil, request: nil)
19
18
  return unless LlmCostTracker.configuration.enabled
20
19
 
21
- Budget.enforce!
20
+ Budget.enforce!(provider: provider, model: model, request: request)
22
21
  end
23
22
 
24
- def record(capture:, latency_ms: nil, pricing_mode: nil, metadata: {}, context_tags: nil)
23
+ def record(event:, latency_ms: nil, pricing_mode: nil, metadata: {}, context_tags: nil)
25
24
  return unless LlmCostTracker.configuration.enabled
26
25
 
27
- pricing_mode = Pricing.normalize_mode(pricing_mode) || capture.pricing_mode
26
+ pricing_mode = Pricing::Mode.normalize(pricing_mode) || event.pricing_mode
28
27
  cost_data, pricing_snapshot, priced_line_items = Pricing.calculate(
29
- provider: capture.provider,
30
- model: capture.model,
31
- tokens: capture.token_usage,
32
- line_items: capture.line_items,
28
+ provider: event.provider,
29
+ model: event.model,
30
+ tokens: event.token_usage,
31
+ line_items: event.line_items,
33
32
  pricing_mode: pricing_mode
34
33
  )
35
34
 
36
- Pricing::Unknown.handle!(capture.model) if cost_data.nil? && capture.token_usage.total_tokens.positive?
35
+ if cost_data.nil? && event.token_usage.total_tokens.positive? && priced_line_items.none?(&:priced?)
36
+ Pricing::Unknown.process(event.model)
37
+ end
37
38
 
38
39
  event = build_event(
39
- capture: capture,
40
+ event: event,
40
41
  pricing_mode: pricing_mode,
41
42
  cost_data: cost_data,
42
43
  pricing_snapshot: pricing_snapshot,
@@ -46,21 +47,21 @@ module LlmCostTracker
46
47
  context_tags: context_tags
47
48
  )
48
49
 
49
- save_event(event)
50
- yield :after_save if block_given?
50
+ if Ingestion.async?
51
+ Ingestion::Inbox.save(event)
52
+ Ingestion::Worker.ensure_started
53
+ else
54
+ Ledger::Store.insert(event, skip_existence_check: true)
55
+ end
56
+
57
+ yield if block_given?
51
58
  notify_subscribers(event)
52
59
  Budget.check!(event)
53
60
 
54
61
  event
55
62
  end
56
63
 
57
- def save_event(event)
58
- if LlmCostTracker.configuration.durable_ingestion
59
- Ingestion::Inbox.save(event)
60
- else
61
- Ingestion::Inline.save(event)
62
- end
63
- end
64
+ private
64
65
 
65
66
  def notify_subscribers(event)
66
67
  return unless ActiveSupport::Notifications.notifier.listening?(EVENT_NAME)
@@ -70,45 +71,25 @@ module LlmCostTracker
70
71
  Logging.warn("Subscriber raised on #{EVENT_NAME}: #{e.class}: #{e.message}")
71
72
  end
72
73
 
73
- private
74
-
75
- def token_pricing_partial?(token_usage:, cost_data:)
76
- return false unless cost_data
77
-
78
- Billing::Components::TOKEN_PRICED.any? do |component|
79
- token_usage.public_send(component.token_key).positive? && cost_data[component.cost_key].nil?
80
- end
81
- end
82
-
83
- def build_event(capture:, pricing_mode:, cost_data:, pricing_snapshot:, line_items:,
74
+ def build_event(event:, pricing_mode:, cost_data:, pricing_snapshot:, line_items:,
84
75
  metadata:, latency_ms:, context_tags:)
85
76
  context_tags = (context_tags || LlmCostTracker::Tags::Context.tags).to_h
86
- cost = cost_with_service_lines(cost_data, line_items)
77
+ cost = Pricing.combine_with_service_lines(cost_data, line_items)
87
78
  cost_status = Billing::CostStatus.call(
88
- token_usage: capture.token_usage,
89
- usage_source: capture.usage_source,
79
+ token_usage: event.token_usage,
80
+ usage_source: event.usage_source,
90
81
  token_cost: cost_data,
91
- token_pricing_partial: token_pricing_partial?(token_usage: capture.token_usage, cost_data: cost_data),
82
+ token_pricing_partial: Pricing.token_pricing_partial?(event.token_usage, cost_data),
92
83
  service_line_items: line_items.reject(&:token?),
93
84
  total_cost: cost&.fetch(:total_cost, nil)
94
85
  )
95
86
 
96
- Event.new(
87
+ event.with(
97
88
  event_id: SecureRandom.uuid,
98
- provider: capture.provider,
99
- model: capture.model,
100
- token_usage: capture.token_usage,
101
89
  pricing_mode: pricing_mode,
102
90
  cost: cost,
103
- tags: LlmCostTracker::Tags::Sanitizer.call(context_tags.merge(metadata.to_h)).freeze,
91
+ tags: build_tags(context_tags: context_tags, metadata: metadata),
104
92
  latency_ms: finite_latency_ms(latency_ms),
105
- stream: capture.stream,
106
- usage_source: capture.usage_source,
107
- provider_response_id: capture.provider_response_id,
108
- provider_project_id: capture.provider_project_id,
109
- provider_api_key_id: capture.provider_api_key_id,
110
- provider_workspace_id: capture.provider_workspace_id,
111
- batch: capture.batch,
112
93
  tracked_at: Time.now.utc,
113
94
  cost_status: cost_status,
114
95
  pricing_snapshot: pricing_snapshot,
@@ -116,6 +97,11 @@ module LlmCostTracker
116
97
  )
117
98
  end
118
99
 
100
+ def build_tags(context_tags:, metadata:)
101
+ sanitized_metadata = LlmCostTracker::Tags::Sanitizer.call(metadata.to_h)
102
+ LlmCostTracker::Tags::Sanitizer.cap(context_tags.merge(sanitized_metadata)).freeze
103
+ end
104
+
119
105
  def finite_latency_ms(latency_ms)
120
106
  return nil if latency_ms.nil?
121
107
 
@@ -123,33 +109,6 @@ module LlmCostTracker
123
109
  rescue ArgumentError, TypeError, FloatDomainError
124
110
  nil
125
111
  end
126
-
127
- def cost_with_service_lines(cost_data, line_items)
128
- priced_services = line_items.reject(&:token?).select(&:priced?)
129
- return cost_data if priced_services.empty?
130
-
131
- base_currency = (cost_data && cost_data[:currency]) || Billing::LineItem::USD
132
- matching, mismatched = priced_services.partition { |line| line.currency.to_s == base_currency.to_s }
133
- warn_currency_mismatch(mismatched, base_currency) if mismatched.any?
134
-
135
- cost = cost_data ? cost_data.dup : {}
136
- cost[:currency] ||= base_currency.to_s
137
- return cost if matching.empty?
138
-
139
- service_total = matching.sum(BigDecimal("0"), &:cost_value)
140
- base_total = BigDecimal(cost.fetch(:total_cost, 0).to_s)
141
- cost[:total_cost] = (base_total + service_total).round(8)
142
- cost
143
- end
144
-
145
- def warn_currency_mismatch(lines, base_currency)
146
- currencies = lines.map { |line| line.currency.to_s }.uniq.sort
147
- Logging.warn(
148
- "Service line currency mismatch: header is #{base_currency}, dropping " \
149
- "#{lines.size} priced line(s) in #{currencies.join(', ')} from header total. " \
150
- "Per-line costs are still recorded; header total reflects #{base_currency} only."
151
- )
152
- end
153
112
  end
154
113
  end
155
114
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.9.0"
4
+ VERSION = "0.11.0"
5
5
  end
@@ -23,15 +23,6 @@ require_relative "llm_cost_tracker/billing/line_item"
23
23
  require_relative "llm_cost_tracker/billing/cost_status"
24
24
  require_relative "llm_cost_tracker/event"
25
25
  require_relative "llm_cost_tracker/pricing"
26
- require_relative "llm_cost_tracker/usage_capture"
27
- require_relative "llm_cost_tracker/pricing/sync"
28
- require_relative "llm_cost_tracker/parsers/base"
29
- require_relative "llm_cost_tracker/parsers/openai_usage"
30
- require_relative "llm_cost_tracker/parsers/openai"
31
- require_relative "llm_cost_tracker/parsers/openai_compatible"
32
- require_relative "llm_cost_tracker/parsers/anthropic"
33
- require_relative "llm_cost_tracker/parsers/gemini"
34
- require_relative "llm_cost_tracker/parsers/sse"
35
26
  require_relative "llm_cost_tracker/parsers"
36
27
  require_relative "llm_cost_tracker/middleware/faraday"
37
28
  require_relative "llm_cost_tracker/integrations"
@@ -40,14 +31,18 @@ require_relative "llm_cost_tracker/pricing/unknown"
40
31
  require_relative "llm_cost_tracker/ledger"
41
32
  require_relative "llm_cost_tracker/ingestion"
42
33
  require_relative "llm_cost_tracker/tracker"
43
- require_relative "llm_cost_tracker/retention"
44
- require_relative "llm_cost_tracker/report"
45
- require_relative "llm_cost_tracker/doctor"
46
- require_relative "llm_cost_tracker/doctor/capture_verifier"
47
34
 
48
35
  module LlmCostTracker
36
+ autoload :Engine, "llm_cost_tracker/engine"
49
37
  autoload :Reconciliation, "llm_cost_tracker/reconciliation"
50
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"
42
+
43
+ module Pricing
44
+ autoload :Sync, "llm_cost_tracker/pricing/sync"
45
+ end
51
46
 
52
47
  @configuration = Configuration.new
53
48
 
@@ -77,6 +72,7 @@ module LlmCostTracker
77
72
 
78
73
  def reset_configuration!
79
74
  Ingestion::Worker.shutdown!(drain: false)
75
+ Ingestion::Pool.reset!
80
76
  @configuration = Configuration.new
81
77
  Pricing::Lookup.reset!
82
78
  Pricing::Registry.reset!
@@ -84,7 +80,7 @@ module LlmCostTracker
84
80
  Pricing::Unknown.reset!
85
81
  Ingestion::Worker.reset!
86
82
  Tags::Context.clear!
87
- DashboardSetupState.reset! if defined?(DashboardSetupState)
83
+ Dashboard::SetupState.reset!
88
84
  end
89
85
 
90
86
  def with_tags(tags = nil, **kwargs, &)
@@ -98,7 +94,7 @@ module LlmCostTracker
98
94
  Tracker.enforce_budget! if enforce_budget
99
95
 
100
96
  Tracker.record(
101
- capture: UsageCapture.build(
97
+ event: Event.build(
102
98
  provider: provider,
103
99
  model: model,
104
100
  token_usage: TokenUsage.build_from_tokens(tokens),
@@ -4,7 +4,7 @@ require "fileutils"
4
4
  require "rails/generators"
5
5
 
6
6
  require_relative "../llm_cost_tracker/generators/llm_cost_tracker/install_generator"
7
- require_relative "../llm_cost_tracker/pricing/sync_change_printer"
7
+ require_relative "../llm_cost_tracker/pricing/sync/change_printer"
8
8
 
9
9
  # rubocop:disable Metrics/BlockLength
10
10
  namespace :llm_cost_tracker do
@@ -48,12 +48,26 @@ namespace :llm_cost_tracker do
48
48
  puts LlmCostTracker::Report.generate(days: days)
49
49
  end
50
50
 
51
+ desc "Recompute total_cost for calls with unknown pricing using the current price registry. " \
52
+ "Use BATCH_SIZE=N to tune."
53
+ task backfill_unknown_pricing: :environment do
54
+ require_relative "../llm_cost_tracker/pricing/backfill"
55
+ batch_size = (ENV["BATCH_SIZE"] || LlmCostTracker::Pricing::Backfill::DEFAULT_BATCH_SIZE).to_i
56
+ result = LlmCostTracker::Pricing::Backfill.call(batch_size: batch_size)
57
+ puts "llm_cost_tracker: examined #{result.examined} calls, recomputed #{result.recomputed}, " \
58
+ "still unknown #{result.still_unknown}"
59
+ end
60
+
51
61
  desc "Delete llm_cost_tracker_calls older than DAYS (default: 90). Use BATCH_SIZE=N to tune."
52
62
  task prune: :environment do
53
63
  days = (ENV["DAYS"] || 90).to_i
54
64
  batch_size = (ENV["BATCH_SIZE"] || LlmCostTracker::Retention::DEFAULT_BATCH_SIZE).to_i
55
65
  deleted = LlmCostTracker::Retention.prune(older_than: days, batch_size: batch_size)
56
66
  puts "llm_cost_tracker: pruned #{deleted} calls older than #{days} days"
67
+ inbox_pruned = LlmCostTracker::Retention.prune_inbox(older_than: days)
68
+ puts "llm_cost_tracker: pruned #{inbox_pruned} inbox entries older than #{days} days"
69
+ invoice_imports_pruned = LlmCostTracker::Retention.prune_invoice_imports(older_than: days)
70
+ puts "llm_cost_tracker: pruned #{invoice_imports_pruned} provider invoice imports older than #{days} days"
57
71
  end
58
72
 
59
73
  namespace :prices do
@@ -136,7 +150,7 @@ def reconcile_run(method)
136
150
  end
137
151
 
138
152
  def print_changes(changes)
139
- LlmCostTracker::Pricing::SyncChangePrinter.call(changes)
153
+ LlmCostTracker::Pricing::Sync::ChangePrinter.call(changes)
140
154
  end
141
155
 
142
156
  def price_refresh_output_path
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm_cost_tracker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergii Khomenko
@@ -239,6 +239,7 @@ files:
239
239
  - app/controllers/llm_cost_tracker/dashboard_controller.rb
240
240
  - app/controllers/llm_cost_tracker/data_quality_controller.rb
241
241
  - app/controllers/llm_cost_tracker/models_controller.rb
242
+ - app/controllers/llm_cost_tracker/pricing_controller.rb
242
243
  - app/controllers/llm_cost_tracker/reconciliation_controller.rb
243
244
  - app/controllers/llm_cost_tracker/tags_controller.rb
244
245
  - app/helpers/llm_cost_tracker/application_helper.rb
@@ -249,6 +250,7 @@ files:
249
250
  - app/helpers/llm_cost_tracker/inline_style_helper.rb
250
251
  - app/helpers/llm_cost_tracker/pagination_helper.rb
251
252
  - app/helpers/llm_cost_tracker/reconciliation_helper.rb
253
+ - app/helpers/llm_cost_tracker/sortable_table_helper.rb
252
254
  - app/helpers/llm_cost_tracker/token_usage_helper.rb
253
255
  - app/models/llm_cost_tracker/call.rb
254
256
  - app/models/llm_cost_tracker/call_line_item.rb
@@ -264,7 +266,10 @@ files:
264
266
  - app/services/llm_cost_tracker/dashboard/overview_stats.rb
265
267
  - app/services/llm_cost_tracker/dashboard/pagination.rb
266
268
  - app/services/llm_cost_tracker/dashboard/params.rb
269
+ - app/services/llm_cost_tracker/dashboard/pricing_overview.rb
267
270
  - app/services/llm_cost_tracker/dashboard/provider_breakdown.rb
271
+ - app/services/llm_cost_tracker/dashboard/setup_state.rb
272
+ - app/services/llm_cost_tracker/dashboard/sort.rb
268
273
  - app/services/llm_cost_tracker/dashboard/spend_anomaly.rb
269
274
  - app/services/llm_cost_tracker/dashboard/tag_breakdown.rb
270
275
  - app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb
@@ -279,12 +284,14 @@ files:
279
284
  - app/views/llm_cost_tracker/errors/invalid_filter.html.erb
280
285
  - app/views/llm_cost_tracker/errors/not_found.html.erb
281
286
  - app/views/llm_cost_tracker/models/index.html.erb
287
+ - app/views/llm_cost_tracker/pricing/index.html.erb
282
288
  - app/views/llm_cost_tracker/reconciliation/index.html.erb
283
- - app/views/llm_cost_tracker/shared/_active_filters.html.erb
284
289
  - app/views/llm_cost_tracker/shared/_bar.html.erb
285
- - app/views/llm_cost_tracker/shared/_filters.html.erb
290
+ - app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb
291
+ - app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb
292
+ - app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb
293
+ - app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb
286
294
  - app/views/llm_cost_tracker/shared/_metric_stack.html.erb
287
- - app/views/llm_cost_tracker/shared/_sort.html.erb
288
295
  - app/views/llm_cost_tracker/shared/_spend_chart.html.erb
289
296
  - app/views/llm_cost_tracker/shared/_tag_chips.html.erb
290
297
  - app/views/llm_cost_tracker/shared/setup_required.html.erb
@@ -298,11 +305,11 @@ files:
298
305
  - lib/llm_cost_tracker/billing/cost_status.rb
299
306
  - lib/llm_cost_tracker/billing/line_item.rb
300
307
  - lib/llm_cost_tracker/budget.rb
308
+ - lib/llm_cost_tracker/capture/sse.rb
301
309
  - lib/llm_cost_tracker/capture/stream.rb
302
310
  - lib/llm_cost_tracker/capture/stream_collector.rb
303
311
  - lib/llm_cost_tracker/capture/stream_tracker.rb
304
312
  - lib/llm_cost_tracker/configuration.rb
305
- - lib/llm_cost_tracker/dashboard_setup_state.rb
306
313
  - lib/llm_cost_tracker/doctor.rb
307
314
  - lib/llm_cost_tracker/doctor/capture_verifier.rb
308
315
  - lib/llm_cost_tracker/doctor/check.rb
@@ -318,27 +325,31 @@ files:
318
325
  - lib/llm_cost_tracker/engine.rb
319
326
  - lib/llm_cost_tracker/errors.rb
320
327
  - lib/llm_cost_tracker/event.rb
328
+ - lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb
321
329
  - lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb
322
- - lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb
323
330
  - lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb
324
331
  - lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb
325
332
  - lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb
333
+ - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb
326
334
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb
327
335
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb
328
- - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb
329
336
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb
330
337
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb
331
338
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb
332
339
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb
333
340
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb
341
+ - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb
342
+ - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb
334
343
  - lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb
335
344
  - lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb
336
345
  - lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb
346
+ - lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb
347
+ - lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb
337
348
  - lib/llm_cost_tracker/ingestion.rb
338
349
  - lib/llm_cost_tracker/ingestion/batch.rb
339
350
  - lib/llm_cost_tracker/ingestion/inbox.rb
340
- - lib/llm_cost_tracker/ingestion/inline.rb
341
351
  - lib/llm_cost_tracker/ingestion/lease_claim.rb
352
+ - lib/llm_cost_tracker/ingestion/pool.rb
342
353
  - lib/llm_cost_tracker/ingestion/worker.rb
343
354
  - lib/llm_cost_tracker/integrations.rb
344
355
  - lib/llm_cost_tracker/integrations/anthropic.rb
@@ -368,27 +379,35 @@ files:
368
379
  - lib/llm_cost_tracker/middleware/faraday.rb
369
380
  - lib/llm_cost_tracker/parsers.rb
370
381
  - lib/llm_cost_tracker/parsers/anthropic.rb
382
+ - lib/llm_cost_tracker/parsers/azure.rb
371
383
  - lib/llm_cost_tracker/parsers/base.rb
372
384
  - lib/llm_cost_tracker/parsers/gemini.rb
373
385
  - lib/llm_cost_tracker/parsers/openai.rb
374
386
  - lib/llm_cost_tracker/parsers/openai_compatible.rb
375
- - lib/llm_cost_tracker/parsers/openai_service_charges.rb
376
387
  - lib/llm_cost_tracker/parsers/openai_usage.rb
377
- - lib/llm_cost_tracker/parsers/sse.rb
378
388
  - lib/llm_cost_tracker/prices.json
379
389
  - lib/llm_cost_tracker/pricing.rb
390
+ - lib/llm_cost_tracker/pricing/backfill.rb
380
391
  - lib/llm_cost_tracker/pricing/effective_prices.rb
392
+ - lib/llm_cost_tracker/pricing/estimator.rb
381
393
  - lib/llm_cost_tracker/pricing/explainer.rb
382
394
  - lib/llm_cost_tracker/pricing/lookup.rb
383
395
  - lib/llm_cost_tracker/pricing/mode.rb
384
396
  - lib/llm_cost_tracker/pricing/registry.rb
385
397
  - lib/llm_cost_tracker/pricing/service_charges.rb
386
398
  - lib/llm_cost_tracker/pricing/sync.rb
399
+ - lib/llm_cost_tracker/pricing/sync/change_printer.rb
387
400
  - lib/llm_cost_tracker/pricing/sync/fetcher.rb
388
401
  - lib/llm_cost_tracker/pricing/sync/registry_diff.rb
389
402
  - lib/llm_cost_tracker/pricing/sync/registry_writer.rb
390
- - lib/llm_cost_tracker/pricing/sync_change_printer.rb
391
403
  - lib/llm_cost_tracker/pricing/unknown.rb
404
+ - lib/llm_cost_tracker/providers/anthropic/server_tools.rb
405
+ - lib/llm_cost_tracker/providers/anthropic/tier_classification.rb
406
+ - lib/llm_cost_tracker/providers/azure/hosts.rb
407
+ - lib/llm_cost_tracker/providers/gemini/model_families.rb
408
+ - lib/llm_cost_tracker/providers/openai/hosts.rb
409
+ - lib/llm_cost_tracker/providers/openai/model_families.rb
410
+ - lib/llm_cost_tracker/providers/openai/service_charges.rb
392
411
  - lib/llm_cost_tracker/railtie.rb
393
412
  - lib/llm_cost_tracker/reconcile_tasks.rb
394
413
  - lib/llm_cost_tracker/reconciliation.rb
@@ -397,6 +416,7 @@ files:
397
416
  - lib/llm_cost_tracker/reconciliation/import_result.rb
398
417
  - lib/llm_cost_tracker/reconciliation/importer.rb
399
418
  - lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb
419
+ - lib/llm_cost_tracker/reconciliation/sources/coercion.rb
400
420
  - lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb
401
421
  - lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb
402
422
  - lib/llm_cost_tracker/report.rb
@@ -409,7 +429,6 @@ files:
409
429
  - lib/llm_cost_tracker/timing.rb
410
430
  - lib/llm_cost_tracker/token_usage.rb
411
431
  - lib/llm_cost_tracker/tracker.rb
412
- - lib/llm_cost_tracker/usage_capture.rb
413
432
  - lib/llm_cost_tracker/version.rb
414
433
  - lib/tasks/llm_cost_tracker.rake
415
434
  homepage: https://github.com/sergey-homenko/llm_cost_tracker
@@ -1,16 +0,0 @@
1
- <% if chips.any? %>
2
- <div class="lct-chip-row" aria-label="Active filters">
3
- <% chips.each do |chip| %>
4
- <span class="lct-chip">
5
- <span class="lct-chip-label"><%= chip[:label] %></span>
6
- <span><%= chip[:value] %></span>
7
- <% if chip[:path] %>
8
- <%= link_to "×", chip[:path], class: "lct-chip-remove", aria: { label: "Remove #{chip[:label]} #{chip[:value]}" } %>
9
- <% end %>
10
- </span>
11
- <% end %>
12
- <% if local_assigns[:clear_path] %>
13
- <%= link_to "Clear all", clear_path, class: "lct-clear-link" %>
14
- <% end %>
15
- </div>
16
- <% end %>
@@ -1,66 +0,0 @@
1
- <%
2
- fields = local_assigns.fetch(:fields, %i[from to provider model stream])
3
- default_range = LlmCostTracker::Dashboard::DateRange.call(params: params)
4
- defaults = {
5
- from: default_range.from.iso8601,
6
- to: default_range.to.iso8601
7
- }.merge(local_assigns.fetch(:defaults, {}))
8
- reset_path = local_assigns.fetch(:reset_path, path)
9
- filter_scope = local_assigns.fetch(:filter_scope, params)
10
- %>
11
-
12
- <form class="lct-filters" action="<%= path %>" method="get">
13
- <% local_assigns.fetch(:hidden_fields, {}).each do |key, val| %>
14
- <%= hidden_field_tag(key, val) %>
15
- <% end %>
16
- <div class="lct-filter-row">
17
- <% if fields.include?(:from) %>
18
- <div class="lct-field">
19
- <label for="lct-filter-from">From</label>
20
- <input id="lct-filter-from" type="date" name="from" value="<%= params[:from] || defaults[:from] %>">
21
- </div>
22
- <% end %>
23
-
24
- <% if fields.include?(:to) %>
25
- <div class="lct-field">
26
- <label for="lct-filter-to">To</label>
27
- <input id="lct-filter-to" type="date" name="to" value="<%= params[:to] || defaults[:to] %>">
28
- </div>
29
- <% end %>
30
-
31
- <% if fields.include?(:provider) %>
32
- <div class="lct-field">
33
- <label for="lct-filter-provider">Provider</label>
34
- <%= select_tag :provider,
35
- options_for_select(provider_filter_options(filter_params: filter_scope), params[:provider]),
36
- include_blank: "All providers",
37
- id: "lct-filter-provider" %>
38
- </div>
39
- <% end %>
40
-
41
- <% if fields.include?(:model) %>
42
- <div class="lct-field">
43
- <label for="lct-filter-model">Model</label>
44
- <%= select_tag :model,
45
- options_for_select(model_filter_options(filter_params: filter_scope), params[:model]),
46
- include_blank: "All models",
47
- id: "lct-filter-model" %>
48
- </div>
49
- <% end %>
50
-
51
- <% if fields.include?(:stream) %>
52
- <div class="lct-field">
53
- <label for="lct-filter-stream">Stream</label>
54
- <%= select_tag :stream,
55
- options_for_select(LlmCostTracker::DashboardFilterHelper::STREAM_FILTER_OPTIONS, params[:stream]),
56
- include_blank: "All calls",
57
- id: "lct-filter-stream" %>
58
- </div>
59
- <% end %>
60
-
61
- <div class="lct-filter-actions">
62
- <button class="lct-button" type="submit">Apply</button>
63
- <%= link_to("Reset", reset_path, class: "lct-button lct-button-secondary") if any_filter_applied? %>
64
- </div>
65
- </div>
66
- </form>
@@ -1,13 +0,0 @@
1
- <%
2
- current = local_assigns.fetch(:current).to_s
3
- options = local_assigns.fetch(:options)
4
- %>
5
-
6
- <nav class="lct-sort" aria-label="Sort by">
7
- <% options.each do |label, value| %>
8
- <%= link_to label,
9
- path_for_sort.call(value),
10
- class: ["lct-sort-option", ("is-active" if current == value.to_s)].compact.join(" "),
11
- aria: ({ current: "true" } if current == value.to_s) %>
12
- <% end %>
13
- </nav>
@@ -1,109 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "ledger/schema/calls"
4
- require_relative "ledger/schema/call_line_items"
5
- require_relative "ledger/schema/call_tags"
6
- require_relative "ledger/schema/call_rollups"
7
-
8
- module LlmCostTracker
9
- module DashboardSetupState
10
- SetupRequired = Data.define(:message, :details)
11
- DOCS_HINT = "See docs/upgrading.md for the migration path."
12
- MUTEX = Mutex.new
13
-
14
- CORE_SCHEMA_CHECKS = [
15
- [
16
- LlmCostTracker::Ledger::Schema::Calls,
17
- "The llm_cost_tracker_calls table does not match the current LLM Cost Tracker schema."
18
- ],
19
- [
20
- LlmCostTracker::Ledger::Schema::CallLineItems,
21
- "The llm_cost_tracker_call_line_items table does not match the current LLM Cost Tracker schema."
22
- ],
23
- [
24
- LlmCostTracker::Ledger::Schema::CallTags,
25
- "The llm_cost_tracker_call_tags table does not match the current LLM Cost Tracker schema."
26
- ]
27
- ].freeze
28
-
29
- OPTIONAL_CALL_ROLLUPS_CHECK = [
30
- LlmCostTracker::Ledger::Schema::CallRollups,
31
- "The llm_cost_tracker_call_rollups table does not match the current LLM Cost Tracker schema."
32
- ].freeze
33
-
34
- private_constant :MUTEX, :CORE_SCHEMA_CHECKS, :OPTIONAL_CALL_ROLLUPS_CHECK, :DOCS_HINT
35
-
36
- class << self
37
- def current
38
- return @cached if defined?(@cached)
39
-
40
- MUTEX.synchronize do
41
- @cached = compute unless defined?(@cached)
42
- end
43
- @cached
44
- end
45
-
46
- def reset!
47
- MUTEX.synchronize do
48
- remove_instance_variable(:@cached) if defined?(@cached)
49
- end
50
- end
51
-
52
- private
53
-
54
- def compute
55
- LlmCostTracker::Logging.debug("DashboardSetupState recomputing")
56
- return calls_table_missing unless LlmCostTracker::Call.table_exists?
57
-
58
- core_drift = drift_in(schema_checks_for_current_config)
59
- return core_drift if core_drift
60
- return nil unless LlmCostTracker.reconciliation_enabled?
61
-
62
- reconciliation_drift
63
- end
64
-
65
- def schema_checks_for_current_config
66
- return CORE_SCHEMA_CHECKS unless LlmCostTracker.configuration.cache_rollups
67
-
68
- CORE_SCHEMA_CHECKS + [OPTIONAL_CALL_ROLLUPS_CHECK]
69
- end
70
-
71
- def drift_in(checks)
72
- checks.each do |schema, message|
73
- errors = schema.current_schema_errors
74
- next if errors.empty?
75
-
76
- return SetupRequired.new(message: message, details: errors + [DOCS_HINT])
77
- end
78
- nil
79
- end
80
-
81
- def reconciliation_drift
82
- LlmCostTracker.const_get(:Reconciliation) # autoload reconciliation + its ledger schemas
83
- connection = ActiveRecord::Base.connection
84
- LlmCostTracker::Reconciliation::SCHEMA_TABLES.each do |schema, table|
85
- unless connection.data_source_exists?(table)
86
- return SetupRequired.new(
87
- message: "The #{table} table is required when reconciliation is enabled.",
88
- details: ["run bin/rails generate llm_cost_tracker:reconciliation && bin/rails db:migrate", DOCS_HINT]
89
- )
90
- end
91
-
92
- errors = schema.current_schema_errors
93
- next if errors.empty?
94
-
95
- message = "The #{table} table does not match the current LLM Cost Tracker schema."
96
- return SetupRequired.new(message: message, details: errors + [DOCS_HINT])
97
- end
98
- nil
99
- end
100
-
101
- def calls_table_missing
102
- SetupRequired.new(
103
- message: "The llm_cost_tracker_calls table is not available yet.",
104
- details: nil
105
- )
106
- end
107
- end
108
- end
109
- end