llm_cost_tracker 0.8.0 → 0.10.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 (150) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +136 -0
  3. data/README.md +14 -6
  4. data/app/assets/llm_cost_tracker/application.css +65 -5
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +25 -33
  6. data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +21 -11
  8. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +4 -0
  9. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +11 -1
  12. data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
  13. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +5 -1
  15. data/app/models/llm_cost_tracker/call.rb +0 -3
  16. data/app/models/llm_cost_tracker/call_line_item.rb +1 -5
  17. data/app/models/llm_cost_tracker/call_rollup.rb +0 -3
  18. data/app/models/llm_cost_tracker/call_tag.rb +0 -4
  19. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +0 -4
  20. data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
  21. data/app/models/llm_cost_tracker/provider_invoice.rb +7 -3
  22. data/app/models/llm_cost_tracker/provider_invoice_import.rb +29 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +33 -4
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -4
  25. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
  26. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  27. data/app/views/llm_cost_tracker/calls/show.html.erb +26 -41
  28. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
  29. data/app/views/llm_cost_tracker/data_quality/index.html.erb +92 -53
  30. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  31. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  32. data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
  33. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  34. data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
  35. data/config/routes.rb +3 -2
  36. data/lib/llm_cost_tracker/billing/components.rb +45 -3
  37. data/lib/llm_cost_tracker/billing/components.yml +71 -0
  38. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  39. data/lib/llm_cost_tracker/billing/line_item.rb +16 -50
  40. data/lib/llm_cost_tracker/budget.rb +31 -7
  41. data/lib/llm_cost_tracker/capture/stream_collector.rb +113 -34
  42. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  43. data/lib/llm_cost_tracker/configuration.rb +72 -17
  44. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  45. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
  46. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +30 -4
  47. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  48. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  49. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  50. data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
  51. data/lib/llm_cost_tracker/doctor.rb +72 -14
  52. data/lib/llm_cost_tracker/engine.rb +8 -0
  53. data/lib/llm_cost_tracker/errors.rb +3 -2
  54. data/lib/llm_cost_tracker/event.rb +48 -1
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +43 -0
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -26
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +29 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +60 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +35 -25
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +35 -0
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +32 -0
  68. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +25 -0
  69. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +29 -0
  70. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
  71. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
  72. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  73. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  74. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  75. data/lib/llm_cost_tracker/ingestion/inbox.rb +3 -25
  76. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  77. data/lib/llm_cost_tracker/ingestion/worker.rb +28 -34
  78. data/lib/llm_cost_tracker/ingestion.rb +48 -11
  79. data/lib/llm_cost_tracker/integrations/anthropic.rb +31 -26
  80. data/lib/llm_cost_tracker/integrations/base.rb +35 -15
  81. data/lib/llm_cost_tracker/integrations/openai.rb +345 -84
  82. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +111 -14
  83. data/lib/llm_cost_tracker/integrations.rb +33 -14
  84. data/lib/llm_cost_tracker/ledger/period/totals.rb +25 -7
  85. data/lib/llm_cost_tracker/ledger/rollups.rb +22 -17
  86. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +41 -1
  87. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +16 -6
  88. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +28 -2
  89. data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -4
  90. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +57 -0
  91. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +52 -0
  92. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +56 -0
  93. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +28 -13
  94. data/lib/llm_cost_tracker/ledger/store.rb +34 -31
  95. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  96. data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -2
  97. data/lib/llm_cost_tracker/ledger.rb +2 -1
  98. data/lib/llm_cost_tracker/logging.rb +0 -4
  99. data/lib/llm_cost_tracker/masking.rb +39 -0
  100. data/lib/llm_cost_tracker/middleware/faraday.rb +120 -33
  101. data/lib/llm_cost_tracker/parsers/anthropic.rb +36 -28
  102. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  103. data/lib/llm_cost_tracker/parsers/base.rb +53 -43
  104. data/lib/llm_cost_tracker/parsers/gemini.rb +24 -22
  105. data/lib/llm_cost_tracker/parsers/openai.rb +20 -38
  106. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -39
  107. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +81 -13
  108. data/lib/llm_cost_tracker/parsers/openai_usage.rb +126 -59
  109. data/lib/llm_cost_tracker/parsers.rb +31 -4
  110. data/lib/llm_cost_tracker/prices.json +572 -493
  111. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  112. data/lib/llm_cost_tracker/pricing/effective_prices.rb +7 -40
  113. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  114. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -1
  115. data/lib/llm_cost_tracker/pricing/lookup.rb +73 -5
  116. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  117. data/lib/llm_cost_tracker/pricing/registry.rb +3 -8
  118. data/lib/llm_cost_tracker/pricing/service_charges.rb +14 -12
  119. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  120. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +62 -1
  121. data/lib/llm_cost_tracker/pricing/sync.rb +4 -10
  122. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  123. data/lib/llm_cost_tracker/pricing.rb +117 -44
  124. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  125. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  126. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  127. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  128. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  129. data/lib/llm_cost_tracker/railtie.rb +8 -0
  130. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  131. data/lib/llm_cost_tracker/reconciliation/diff.rb +409 -0
  132. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +44 -0
  133. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  134. data/lib/llm_cost_tracker/reconciliation/importer.rb +254 -0
  135. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +172 -0
  136. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  137. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  138. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  139. data/lib/llm_cost_tracker/report/data.rb +4 -1
  140. data/lib/llm_cost_tracker/report.rb +0 -4
  141. data/lib/llm_cost_tracker/retention.rb +31 -6
  142. data/lib/llm_cost_tracker/tags/context.rb +3 -4
  143. data/lib/llm_cost_tracker/tags/sanitizer.rb +73 -21
  144. data/lib/llm_cost_tracker/token_usage.rb +14 -2
  145. data/lib/llm_cost_tracker/tracker.rb +41 -55
  146. data/lib/llm_cost_tracker/version.rb +1 -1
  147. data/lib/llm_cost_tracker.rb +19 -14
  148. data/lib/tasks/llm_cost_tracker.rake +41 -4
  149. metadata +49 -3
  150. data/lib/llm_cost_tracker/usage_capture.rb +0 -58
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "active_support/core_ext/integer/time"
4
4
 
5
+ require_relative "../billing/cost_status"
5
6
  require_relative "../ledger"
6
7
 
7
8
  module LlmCostTracker
@@ -57,7 +58,9 @@ module LlmCostTracker
57
58
  "COALESCE(SUM(total_cost), 0) AS total_cost, " \
58
59
  "COUNT(*) AS requests_count, " \
59
60
  "AVG(latency_ms) AS average_latency_ms, " \
60
- "COALESCE(SUM(CASE WHEN total_cost IS NULL THEN 1 ELSE 0 END), 0) AS unknown_pricing_count"
61
+ "COALESCE(SUM(CASE WHEN total_cost IS NULL " \
62
+ "OR cost_status IN ('#{Billing::CostStatus::UNKNOWN}', '#{Billing::CostStatus::PARTIAL}') " \
63
+ "THEN 1 ELSE 0 END), 0) AS unknown_pricing_count"
61
64
  )
62
65
  .take
63
66
  end
@@ -15,10 +15,6 @@ module LlmCostTracker
15
15
  )
16
16
 
17
17
  Formatter.new(report_data).to_s
18
- rescue LoadError => e
19
- "Unable to build LLM cost report: ActiveRecord storage is unavailable (#{e.message})"
20
- rescue StandardError => e
21
- "Unable to build LLM cost report: #{e.class}: #{e.message}"
22
18
  end
23
19
  end
24
20
  end
@@ -21,6 +21,25 @@ module LlmCostTracker
21
21
  deleted
22
22
  end
23
23
 
24
+ def prune_invoice_imports(older_than:, now: Time.now.utc)
25
+ cutoff = resolve_cutoff(older_than, now)
26
+ require_relative "ledger"
27
+ return 0 unless LlmCostTracker::ProviderInvoiceImport.table_exists?
28
+
29
+ LlmCostTracker::ProviderInvoiceImport
30
+ .where(state: %w[completed failed])
31
+ .where(finished_at: ...cutoff)
32
+ .delete_all
33
+ end
34
+
35
+ def prune_inbox(older_than:, now: Time.now.utc)
36
+ cutoff = resolve_cutoff(older_than, now)
37
+ require_relative "ingestion"
38
+ return 0 unless LlmCostTracker::Ingestion::InboxEntry.table_exists?
39
+
40
+ LlmCostTracker::Ingestion::InboxEntry.where(tracked_at: ...cutoff).delete_all
41
+ end
42
+
24
43
  private
25
44
 
26
45
  def resolve_cutoff(older_than, now)
@@ -50,18 +69,24 @@ module LlmCostTracker
50
69
 
51
70
  def prune_batch(cutoff, batch_size)
52
71
  LlmCostTracker::Call.transaction do
53
- rows = pluck_prunable(cutoff, batch_size)
72
+ cache_rollups = LlmCostTracker.configuration.cache_rollups
73
+ rows = pluck_prunable(cutoff, batch_size, with_rollup_columns: cache_rollups)
54
74
  next 0 if rows.empty?
55
75
 
56
- deleted = LlmCostTracker::Call.where(id: rows.map(&:first)).delete_all
57
- LlmCostTracker::Ledger::Rollups.decrement!(rows) if deleted.positive?
76
+ ids = cache_rollups ? rows.map(&:first) : rows
77
+ deleted = LlmCostTracker::Call.where(id: ids).delete_all
78
+ LlmCostTracker::Ledger::Rollups.decrement!(rows) if cache_rollups && deleted.positive?
58
79
  deleted
59
80
  end
60
81
  end
61
82
 
62
- def pluck_prunable(cutoff, batch_size)
63
- LlmCostTracker::Call.where(tracked_at: ...cutoff).order(:id).limit(batch_size).lock
64
- .pluck(:id, :tracked_at, :total_cost, :pricing_snapshot)
83
+ def pluck_prunable(cutoff, batch_size, with_rollup_columns:)
84
+ relation = LlmCostTracker::Call.where(tracked_at: ...cutoff).order(:id).limit(batch_size).lock
85
+ if with_rollup_columns
86
+ relation.pluck(:id, :tracked_at, :total_cost, :pricing_snapshot, :provider)
87
+ else
88
+ relation.pluck(:id)
89
+ end
65
90
  end
66
91
  end
67
92
  end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/core_ext/object/deep_dup"
4
3
  require "active_support/isolated_execution_state"
5
4
 
6
5
  module LlmCostTracker
@@ -11,7 +10,7 @@ module LlmCostTracker
11
10
  class << self
12
11
  def with(tags)
13
12
  stack = ActiveSupport::IsolatedExecutionState[KEY] || []
14
- ActiveSupport::IsolatedExecutionState[KEY] = stack + [(tags || {}).deep_dup.to_h]
13
+ ActiveSupport::IsolatedExecutionState[KEY] = stack + [Sanitizer.call((tags || {}).to_h)]
15
14
  yield
16
15
  ensure
17
16
  ActiveSupport::IsolatedExecutionState[KEY] = stack
@@ -19,9 +18,9 @@ module LlmCostTracker
19
18
 
20
19
  def tags
21
20
  default_tags = LlmCostTracker.configuration.default_tags
22
- default_tags = default_tags.call.deep_dup if default_tags.respond_to?(:call)
21
+ default_tags = default_tags.call if default_tags.respond_to?(:call)
23
22
 
24
- default_tags.to_h.merge(*Array(ActiveSupport::IsolatedExecutionState[KEY]))
23
+ Sanitizer.call(default_tags.to_h).merge(*Array(ActiveSupport::IsolatedExecutionState[KEY]))
25
24
  end
26
25
 
27
26
  def clear!
@@ -1,32 +1,99 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_support/core_ext/string/inflections"
4
- require "json"
5
4
 
6
5
  module LlmCostTracker
7
6
  module Tags
8
7
  module Sanitizer
9
8
  REDACTED_VALUE = "[REDACTED]"
10
9
 
10
+ SECRET_VALUE_PATTERNS = [
11
+ /\Ask-(?:ant-|admin-|proj-|svcacct-|live-|test-)?[A-Za-z0-9_-]{16,}\z/,
12
+ /\AAKIA[0-9A-Z]{16}\z/,
13
+ /\Agh[opsur]_[A-Za-z0-9]{16,}\z/,
14
+ /\Agithub_pat_[A-Za-z0-9_]{20,}\z/,
15
+ /\Aeyj[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\z/i,
16
+ /\Abearer\s+[A-Za-z0-9_.-]{20,}\z/i,
17
+ /\Axox[abprs]-[A-Za-z0-9-]{10,}\z/,
18
+ /\A(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{20,}\z/,
19
+ /\AAIza[0-9A-Za-z_-]{35}\z/
20
+ ].freeze
21
+ private_constant :SECRET_VALUE_PATTERNS
22
+
11
23
  class << self
12
24
  def call(tags, config: LlmCostTracker.configuration)
13
25
  tags = (tags || {}).to_h
14
- redacted = Array(config.redacted_tag_keys).map { |key| normalized_key(key) }
26
+ redacted = config.normalized_redacted_tag_keys
15
27
  limit = [config.max_tag_value_bytesize.to_i, 0].max
16
- tags.first([config.max_tag_count.to_i, 0].max).each_with_object({}) do |(key, value), sanitized|
28
+ max_count = [config.max_tag_count.to_i, 0].max
29
+ tags.to_a.last(max_count).each_with_object({}) do |(key, value), sanitized|
17
30
  sanitized[key] = sanitized_value(key, value, redacted, limit)
18
31
  end
19
32
  end
20
33
 
34
+ def cap(tags, config: LlmCostTracker.configuration)
35
+ tags = (tags || {}).to_h
36
+ max_count = [config.max_tag_count.to_i, 0].max
37
+ return tags if tags.size <= max_count
38
+
39
+ tags.to_a.last(max_count).to_h
40
+ end
41
+
42
+ def normalized_key(key)
43
+ key.to_s.underscore.gsub(/[^a-z0-9]+/, "_").delete_prefix("_").delete_suffix("_")
44
+ end
45
+
21
46
  private
22
47
 
23
48
  def sanitized_value(key, value, redacted, limit)
24
49
  return REDACTED_VALUE if redacted_key?(key, redacted)
25
50
 
26
- string = value_string(value)
27
- return value if string.bytesize <= limit
51
+ scrubbed = scrub_secrets(value)
52
+ return REDACTED_VALUE if scrubbed.equal?(REDACTED_SENTINEL)
53
+
54
+ scalar_truncate(scrubbed, limit)
55
+ end
56
+
57
+ REDACTED_SENTINEL = Object.new.freeze
58
+ private_constant :REDACTED_SENTINEL
59
+
60
+ def scalar_truncate(value, limit)
61
+ case value
62
+ when Hash
63
+ value.transform_values { |nested| scalar_truncate(nested, limit) }
64
+ when Array
65
+ value.map { |nested| scalar_truncate(nested, limit) }
66
+ else
67
+ return value if value == REDACTED_VALUE
68
+
69
+ string = value.to_s
70
+ return value if string.bytesize <= limit
71
+
72
+ string.byteslice(0, limit).encode("UTF-8", invalid: :replace, undef: :replace)
73
+ end
74
+ end
28
75
 
29
- string.byteslice(0, limit).encode("UTF-8", invalid: :replace, undef: :replace)
76
+ def scrub_secrets(value)
77
+ case value
78
+ when Hash
79
+ value.each_with_object({}) do |(key, nested), out|
80
+ scrubbed = scrub_secrets(nested)
81
+ out[key] = scrubbed.equal?(REDACTED_SENTINEL) ? REDACTED_VALUE : scrubbed
82
+ end
83
+ when Array
84
+ value.map do |nested|
85
+ scrubbed = scrub_secrets(nested)
86
+ scrubbed.equal?(REDACTED_SENTINEL) ? REDACTED_VALUE : scrubbed
87
+ end
88
+ else
89
+ secret_shaped?(value.to_s) ? REDACTED_SENTINEL : value
90
+ end
91
+ end
92
+
93
+ def secret_shaped?(string)
94
+ return false if string.bytesize < 16
95
+
96
+ SECRET_VALUE_PATTERNS.any? { |pattern| pattern.match?(string) }
30
97
  end
31
98
 
32
99
  def redacted_key?(key, redacted)
@@ -36,27 +103,12 @@ module LlmCostTracker
36
103
  redacted.any? { |candidate| redacted_key_component?(normalized, candidate) }
37
104
  end
38
105
 
39
- def normalized_key(key)
40
- key.to_s.underscore.gsub(/[^a-z0-9]+/, "_").delete_prefix("_").delete_suffix("_")
41
- end
42
-
43
106
  def redacted_key_component?(key, candidate)
44
107
  key == candidate ||
45
108
  key.start_with?("#{candidate}_") ||
46
109
  key.end_with?("_#{candidate}") ||
47
110
  key.include?("_#{candidate}_")
48
111
  end
49
-
50
- def value_string(value)
51
- case value
52
- when Hash, Array
53
- JSON.generate(value)
54
- else
55
- value.to_s
56
- end
57
- rescue JSON::GeneratorError, TypeError
58
- value.to_s
59
- end
60
112
  end
61
113
  end
62
114
  end
@@ -14,11 +14,17 @@ module LlmCostTracker
14
14
  :cache_write_input_tokens,
15
15
  :cache_write_extended_input_tokens,
16
16
  :audio_input_tokens,
17
+ :image_input_tokens,
17
18
  :output_tokens,
18
19
  :audio_output_tokens,
20
+ :image_output_tokens,
19
21
  :total_tokens,
20
22
  :hidden_output_tokens
21
23
  ) do
24
+ def priced_quantities
25
+ Billing::Components::TOKEN_PRICED.to_h { |component| [component.key, public_send(component.token_key)] }
26
+ end
27
+
22
28
  def self.build_from_tokens(tokens)
23
29
  return tokens if tokens.is_a?(self)
24
30
  raise ArgumentError, "tokens must be a Hash, got #{tokens.class}" unless tokens.respond_to?(:to_h)
@@ -53,6 +59,7 @@ module LlmCostTracker
53
59
  def self.build(input_tokens:, output_tokens:, cache_read_input_tokens: 0,
54
60
  cache_write_input_tokens: 0, cache_write_extended_input_tokens: 0,
55
61
  audio_input_tokens: 0, audio_output_tokens: 0,
62
+ image_input_tokens: 0, image_output_tokens: 0,
56
63
  total_tokens: nil, hidden_output_tokens: 0)
57
64
  input = non_negative_int(input_tokens)
58
65
  output = non_negative_int(output_tokens)
@@ -61,9 +68,12 @@ module LlmCostTracker
61
68
  cache_write_extended = non_negative_int(cache_write_extended_input_tokens)
62
69
  audio_input = non_negative_int(audio_input_tokens)
63
70
  audio_output = non_negative_int(audio_output_tokens)
71
+ image_input = non_negative_int(image_input_tokens)
72
+ image_output = non_negative_int(image_output_tokens)
64
73
  hidden_output = non_negative_int(hidden_output_tokens)
65
- calculated_total = input + cache_read + cache_write + cache_write_extended + audio_input + output + audio_output
66
- total = total_tokens.nil? ? calculated_total : [non_negative_int(total_tokens), calculated_total].max
74
+ calculated_total = input + cache_read + cache_write + cache_write_extended +
75
+ audio_input + image_input + output + audio_output + image_output
76
+ total = total_tokens ? [non_negative_int(total_tokens), calculated_total].max : calculated_total
67
77
 
68
78
  new(
69
79
  input_tokens: input,
@@ -71,8 +81,10 @@ module LlmCostTracker
71
81
  cache_write_input_tokens: cache_write,
72
82
  cache_write_extended_input_tokens: cache_write_extended,
73
83
  audio_input_tokens: audio_input,
84
+ image_input_tokens: image_input,
74
85
  output_tokens: output,
75
86
  audio_output_tokens: audio_output,
87
+ image_output_tokens: image_output,
76
88
  total_tokens: total,
77
89
  hidden_output_tokens: hidden_output
78
90
  )
@@ -1,11 +1,11 @@
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"
8
7
  require_relative "ledger"
8
+ require_relative "logging"
9
9
  require_relative "pricing"
10
10
  require_relative "billing/cost_status"
11
11
 
@@ -14,38 +14,48 @@ module LlmCostTracker
14
14
  EVENT_NAME = "llm_request.llm_cost_tracker"
15
15
 
16
16
  class << self
17
- def enforce_budget!
17
+ def enforce_budget!(provider: nil, model: nil, request: nil)
18
18
  return unless LlmCostTracker.configuration.enabled
19
19
 
20
- Budget.enforce!
20
+ Budget.enforce!(provider: provider, model: model, request: request)
21
21
  end
22
22
 
23
- 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)
24
24
  return unless LlmCostTracker.configuration.enabled
25
25
 
26
- pricing_mode = Pricing.normalize_mode(pricing_mode) || capture.pricing_mode
27
- cost_data, pricing_snapshot = Pricing.cost_and_snapshot_for(
28
- provider: capture.provider,
29
- model: capture.model,
30
- tokens: capture.token_usage,
26
+ pricing_mode = Pricing.normalize_mode(pricing_mode) || event.pricing_mode
27
+ cost_data, pricing_snapshot, priced_line_items = Pricing.calculate(
28
+ provider: event.provider,
29
+ model: event.model,
30
+ tokens: event.token_usage,
31
+ line_items: event.line_items,
31
32
  pricing_mode: pricing_mode
32
33
  )
33
34
 
34
- 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
35
38
 
36
39
  event = build_event(
37
- capture: capture,
40
+ event: event,
38
41
  pricing_mode: pricing_mode,
39
42
  cost_data: cost_data,
40
43
  pricing_snapshot: pricing_snapshot,
44
+ line_items: priced_line_items,
41
45
  metadata: metadata,
42
46
  latency_ms: latency_ms,
43
47
  context_tags: context_tags
44
48
  )
45
49
 
46
- ActiveSupport::Notifications.instrument(EVENT_NAME, event.to_h)
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
47
56
 
48
- Ingestion::Inbox.save(event)
57
+ yield if block_given?
58
+ notify_subscribers(event)
49
59
  Budget.check!(event)
50
60
 
51
61
  event
@@ -53,56 +63,44 @@ module LlmCostTracker
53
63
 
54
64
  private
55
65
 
56
- def token_pricing_partial?(token_usage:, cost_data:)
57
- return false unless cost_data
66
+ def notify_subscribers(event)
67
+ return unless ActiveSupport::Notifications.notifier.listening?(EVENT_NAME)
58
68
 
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
69
+ ActiveSupport::Notifications.instrument(EVENT_NAME, event.to_h)
70
+ rescue StandardError => e
71
+ Logging.warn("Subscriber raised on #{EVENT_NAME}: #{e.class}: #{e.message}")
62
72
  end
63
73
 
64
- # rubocop:disable Metrics/MethodLength
65
- def build_event(capture:, pricing_mode:, cost_data:, pricing_snapshot:, metadata:, latency_ms:, context_tags:)
74
+ def build_event(event:, pricing_mode:, cost_data:, pricing_snapshot:, line_items:,
75
+ metadata:, latency_ms:, context_tags:)
66
76
  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)
77
+ cost = Pricing.combine_with_service_lines(cost_data, line_items)
74
78
  cost_status = Billing::CostStatus.call(
75
- token_usage: capture.token_usage,
76
- usage_source: capture.usage_source,
79
+ token_usage: event.token_usage,
80
+ usage_source: event.usage_source,
77
81
  token_cost: cost_data,
78
- 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),
79
83
  service_line_items: line_items.reject(&:token?),
80
84
  total_cost: cost&.fetch(:total_cost, nil)
81
85
  )
82
86
 
83
- Event.new(
87
+ event.with(
84
88
  event_id: SecureRandom.uuid,
85
- provider: capture.provider,
86
- model: capture.model,
87
- token_usage: capture.token_usage,
88
89
  pricing_mode: pricing_mode,
89
90
  cost: cost,
90
- tags: LlmCostTracker::Tags::Sanitizer.call(context_tags.merge(metadata.to_h)).freeze,
91
+ tags: build_tags(context_tags: context_tags, metadata: metadata),
91
92
  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
93
  tracked_at: Time.now.utc,
100
94
  cost_status: cost_status,
101
95
  pricing_snapshot: pricing_snapshot,
102
96
  line_items: line_items
103
97
  )
104
98
  end
105
- # rubocop:enable Metrics/MethodLength
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
106
104
 
107
105
  def finite_latency_ms(latency_ms)
108
106
  return nil if latency_ms.nil?
@@ -111,18 +109,6 @@ module LlmCostTracker
111
109
  rescue ArgumentError, TypeError, FloatDomainError
112
110
  nil
113
111
  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
126
112
  end
127
113
  end
128
114
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.8.0"
4
+ VERSION = "0.10.0"
5
5
  end
@@ -16,21 +16,13 @@ require_relative "llm_cost_tracker/logging"
16
16
  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
+ require_relative "llm_cost_tracker/masking"
19
20
  require_relative "llm_cost_tracker/token_usage"
20
21
  require_relative "llm_cost_tracker/billing/components"
21
22
  require_relative "llm_cost_tracker/billing/line_item"
22
23
  require_relative "llm_cost_tracker/billing/cost_status"
23
24
  require_relative "llm_cost_tracker/event"
24
25
  require_relative "llm_cost_tracker/pricing"
25
- require_relative "llm_cost_tracker/usage_capture"
26
- require_relative "llm_cost_tracker/pricing/sync"
27
- require_relative "llm_cost_tracker/parsers/base"
28
- require_relative "llm_cost_tracker/parsers/openai_usage"
29
- require_relative "llm_cost_tracker/parsers/openai"
30
- require_relative "llm_cost_tracker/parsers/openai_compatible"
31
- require_relative "llm_cost_tracker/parsers/anthropic"
32
- require_relative "llm_cost_tracker/parsers/gemini"
33
- require_relative "llm_cost_tracker/parsers/sse"
34
26
  require_relative "llm_cost_tracker/parsers"
35
27
  require_relative "llm_cost_tracker/middleware/faraday"
36
28
  require_relative "llm_cost_tracker/integrations"
@@ -39,12 +31,19 @@ require_relative "llm_cost_tracker/pricing/unknown"
39
31
  require_relative "llm_cost_tracker/ledger"
40
32
  require_relative "llm_cost_tracker/ingestion"
41
33
  require_relative "llm_cost_tracker/tracker"
42
- require_relative "llm_cost_tracker/retention"
43
- require_relative "llm_cost_tracker/report"
44
- require_relative "llm_cost_tracker/doctor"
45
- require_relative "llm_cost_tracker/doctor/capture_verifier"
46
34
 
47
35
  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"
42
+
43
+ module Pricing
44
+ autoload :Sync, "llm_cost_tracker/pricing/sync"
45
+ end
46
+
48
47
  @configuration = Configuration.new
49
48
 
50
49
  class << self
@@ -54,6 +53,10 @@ module LlmCostTracker
54
53
  "llm_cost_tracker_"
55
54
  end
56
55
 
56
+ def reconciliation_enabled?
57
+ configuration.reconciliation_enabled
58
+ end
59
+
57
60
  def configure
58
61
  config = configuration
59
62
  raise Error, "LlmCostTracker is already configured" if config.finalized?
@@ -69,6 +72,7 @@ module LlmCostTracker
69
72
 
70
73
  def reset_configuration!
71
74
  Ingestion::Worker.shutdown!(drain: false)
75
+ Ingestion::Pool.reset!
72
76
  @configuration = Configuration.new
73
77
  Pricing::Lookup.reset!
74
78
  Pricing::Registry.reset!
@@ -76,6 +80,7 @@ module LlmCostTracker
76
80
  Pricing::Unknown.reset!
77
81
  Ingestion::Worker.reset!
78
82
  Tags::Context.clear!
83
+ Dashboard::SetupState.reset!
79
84
  end
80
85
 
81
86
  def with_tags(tags = nil, **kwargs, &)
@@ -89,7 +94,7 @@ module LlmCostTracker
89
94
  Tracker.enforce_budget! if enforce_budget
90
95
 
91
96
  Tracker.record(
92
- capture: UsageCapture.build(
97
+ event: Event.build(
93
98
  provider: provider,
94
99
  model: model,
95
100
  token_usage: TokenUsage.build_from_tokens(tokens),
@@ -4,14 +4,21 @@ 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
11
11
  desc "Install LLM Cost Tracker with dashboard and prices, migrate, and run doctor"
12
12
  task :setup do
13
- Rails::Generators.invoke("llm_cost_tracker:install", %w[--dashboard --prices])
14
- Rake::Task["db:migrate"].invoke
13
+ Rails::Generators.invoke("llm_cost_tracker:install", %w[--dashboard --prices --skip])
14
+ begin
15
+ Rake::Task["db:migrate"].invoke
16
+ rescue ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished => e
17
+ abort(
18
+ "llm_cost_tracker: database is not reachable (#{e.class}). " \
19
+ "Start your database, run 'rails db:create db:migrate', then re-run 'rails llm_cost_tracker:setup'."
20
+ )
21
+ end
15
22
  Rake::Task["llm_cost_tracker:doctor"].invoke
16
23
  end
17
24
 
@@ -41,12 +48,26 @@ namespace :llm_cost_tracker do
41
48
  puts LlmCostTracker::Report.generate(days: days)
42
49
  end
43
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
+
44
61
  desc "Delete llm_cost_tracker_calls older than DAYS (default: 90). Use BATCH_SIZE=N to tune."
45
62
  task prune: :environment do
46
63
  days = (ENV["DAYS"] || 90).to_i
47
64
  batch_size = (ENV["BATCH_SIZE"] || LlmCostTracker::Retention::DEFAULT_BATCH_SIZE).to_i
48
65
  deleted = LlmCostTracker::Retention.prune(older_than: days, batch_size: batch_size)
49
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"
50
71
  end
51
72
 
52
73
  namespace :prices do
@@ -109,11 +130,27 @@ namespace :llm_cost_tracker do
109
130
  abort("llm_cost_tracker: price is incomplete or unknown") unless explanation.complete?
110
131
  end
111
132
  end
133
+
134
+ namespace :reconcile do
135
+ desc "Import provider invoice rows from a JSON INPUT file. " \
136
+ "Use SOURCE=openai INPUT=path/to/file.json. Pass PROVIDER=openai for unmapped sources (csv, ...)."
137
+ task(:import) { reconcile_run(:run_import) }
138
+
139
+ desc "Print a reconciliation diff. " \
140
+ "Use SOURCE=openai PERIOD_START=YYYY-MM-DD PERIOD_END=YYYY-MM-DD. PROVIDER=openai for unmapped sources."
141
+ task(:diff) { reconcile_run(:run_diff) }
142
+ end
112
143
  end
113
144
  # rubocop:enable Metrics/BlockLength
114
145
 
146
+ def reconcile_run(method)
147
+ Rake::Task["environment"].invoke if Rake::Task.task_defined?("environment")
148
+ require_relative "../llm_cost_tracker"
149
+ LlmCostTracker::ReconcileTasks.public_send(method, env: ENV)
150
+ end
151
+
115
152
  def print_changes(changes)
116
- LlmCostTracker::Pricing::SyncChangePrinter.call(changes)
153
+ LlmCostTracker::Pricing::Sync::ChangePrinter.call(changes)
117
154
  end
118
155
 
119
156
  def price_refresh_output_path