llm_cost_tracker 0.8.0 → 0.9.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/README.md +12 -5
  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 +5 -7
  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 +10 -0
  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 +24 -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/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  26. data/app/views/llm_cost_tracker/calls/show.html.erb +25 -40
  27. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
  28. data/app/views/llm_cost_tracker/data_quality/index.html.erb +91 -52
  29. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  30. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  31. data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
  32. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  33. data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
  34. data/config/routes.rb +3 -2
  35. data/lib/llm_cost_tracker/billing/components.rb +45 -3
  36. data/lib/llm_cost_tracker/billing/components.yml +71 -0
  37. data/lib/llm_cost_tracker/billing/line_item.rb +1 -1
  38. data/lib/llm_cost_tracker/budget.rb +4 -2
  39. data/lib/llm_cost_tracker/capture/stream_collector.rb +93 -20
  40. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  41. data/lib/llm_cost_tracker/configuration.rb +53 -1
  42. data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
  43. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
  44. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +26 -0
  45. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  46. data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
  47. data/lib/llm_cost_tracker/doctor.rb +72 -3
  48. data/lib/llm_cost_tracker/engine.rb +9 -0
  49. data/lib/llm_cost_tracker/event.rb +1 -1
  50. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  51. data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
  52. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +13 -3
  53. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  54. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +38 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
  65. data/lib/llm_cost_tracker/ingestion/inbox.rb +0 -1
  66. data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
  67. data/lib/llm_cost_tracker/ingestion/worker.rb +10 -2
  68. data/lib/llm_cost_tracker/ingestion.rb +48 -10
  69. data/lib/llm_cost_tracker/integrations/anthropic.rb +24 -5
  70. data/lib/llm_cost_tracker/integrations/base.rb +22 -5
  71. data/lib/llm_cost_tracker/integrations/openai.rb +300 -66
  72. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +105 -6
  73. data/lib/llm_cost_tracker/integrations.rb +19 -1
  74. data/lib/llm_cost_tracker/ledger/period/totals.rb +21 -5
  75. data/lib/llm_cost_tracker/ledger/rollups.rb +24 -10
  76. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +30 -1
  77. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -3
  78. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +17 -2
  79. data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -0
  80. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
  81. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
  82. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
  83. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +2 -2
  84. data/lib/llm_cost_tracker/ledger/store.rb +14 -14
  85. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  86. data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -1
  87. data/lib/llm_cost_tracker/ledger.rb +2 -1
  88. data/lib/llm_cost_tracker/masking.rb +39 -0
  89. data/lib/llm_cost_tracker/middleware/faraday.rb +88 -29
  90. data/lib/llm_cost_tracker/parsers/anthropic.rb +22 -7
  91. data/lib/llm_cost_tracker/parsers/base.rb +5 -1
  92. data/lib/llm_cost_tracker/parsers/gemini.rb +4 -0
  93. data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
  94. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +5 -1
  95. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +49 -10
  96. data/lib/llm_cost_tracker/parsers/openai_usage.rb +124 -53
  97. data/lib/llm_cost_tracker/prices.json +110 -19
  98. data/lib/llm_cost_tracker/pricing/effective_prices.rb +5 -36
  99. data/lib/llm_cost_tracker/pricing/lookup.rb +36 -3
  100. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  101. data/lib/llm_cost_tracker/pricing/registry.rb +3 -1
  102. data/lib/llm_cost_tracker/pricing/service_charges.rb +9 -3
  103. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
  104. data/lib/llm_cost_tracker/pricing/sync.rb +3 -1
  105. data/lib/llm_cost_tracker/pricing.rb +47 -19
  106. data/lib/llm_cost_tracker/railtie.rb +6 -0
  107. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  108. data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
  109. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
  110. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  111. data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
  112. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -0
  113. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  114. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  115. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  116. data/lib/llm_cost_tracker/report/data.rb +4 -1
  117. data/lib/llm_cost_tracker/retention.rb +15 -2
  118. data/lib/llm_cost_tracker/tags/context.rb +3 -4
  119. data/lib/llm_cost_tracker/tags/sanitizer.rb +60 -4
  120. data/lib/llm_cost_tracker/token_usage.rb +10 -2
  121. data/lib/llm_cost_tracker/tracker.rb +45 -18
  122. data/lib/llm_cost_tracker/version.rb +1 -1
  123. data/lib/llm_cost_tracker.rb +9 -0
  124. data/lib/tasks/llm_cost_tracker.rake +25 -2
  125. metadata +36 -1
@@ -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
@@ -21,6 +21,17 @@ 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
+
24
35
  private
25
36
 
26
37
  def resolve_cutoff(older_than, now)
@@ -54,14 +65,16 @@ module LlmCostTracker
54
65
  next 0 if rows.empty?
55
66
 
56
67
  deleted = LlmCostTracker::Call.where(id: rows.map(&:first)).delete_all
57
- LlmCostTracker::Ledger::Rollups.decrement!(rows) if deleted.positive?
68
+ if deleted.positive? && LlmCostTracker.configuration.cache_rollups
69
+ LlmCostTracker::Ledger::Rollups.decrement!(rows)
70
+ end
58
71
  deleted
59
72
  end
60
73
  end
61
74
 
62
75
  def pluck_prunable(cutoff, batch_size)
63
76
  LlmCostTracker::Call.where(tracked_at: ...cutoff).order(:id).limit(batch_size).lock
64
- .pluck(:id, :tracked_at, :total_cost, :pricing_snapshot)
77
+ .pluck(:id, :tracked_at, :total_cost, :pricing_snapshot, :provider)
65
78
  end
66
79
  end
67
80
  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!
@@ -8,12 +8,26 @@ module LlmCostTracker
8
8
  module Sanitizer
9
9
  REDACTED_VALUE = "[REDACTED]"
10
10
 
11
+ SECRET_VALUE_PATTERNS = [
12
+ /\Ask-(?:ant-|admin-|proj-|svcacct-|live-|test-)?[A-Za-z0-9_-]{16,}\z/,
13
+ /\AAKIA[0-9A-Z]{16}\z/,
14
+ /\Agh[opsur]_[A-Za-z0-9]{16,}\z/,
15
+ /\Agithub_pat_[A-Za-z0-9_]{20,}\z/,
16
+ /\Aeyj[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\z/i,
17
+ /\Abearer\s+[A-Za-z0-9_.-]{20,}\z/i,
18
+ /\Axox[abprs]-[A-Za-z0-9-]{10,}\z/,
19
+ /\A(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{20,}\z/,
20
+ /\AAIza[0-9A-Za-z_-]{35}\z/
21
+ ].freeze
22
+ private_constant :SECRET_VALUE_PATTERNS
23
+
11
24
  class << self
12
25
  def call(tags, config: LlmCostTracker.configuration)
13
26
  tags = (tags || {}).to_h
14
27
  redacted = Array(config.redacted_tag_keys).map { |key| normalized_key(key) }
15
28
  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|
29
+ max_count = [config.max_tag_count.to_i, 0].max
30
+ tags.to_a.last(max_count).each_with_object({}) do |(key, value), sanitized|
17
31
  sanitized[key] = sanitized_value(key, value, redacted, limit)
18
32
  end
19
33
  end
@@ -23,10 +37,52 @@ module LlmCostTracker
23
37
  def sanitized_value(key, value, redacted, limit)
24
38
  return REDACTED_VALUE if redacted_key?(key, redacted)
25
39
 
26
- string = value_string(value)
27
- return value if string.bytesize <= limit
40
+ scrubbed = scrub_secrets(value)
41
+ return REDACTED_VALUE if scrubbed.equal?(REDACTED_SENTINEL)
42
+
43
+ scalar_truncate(scrubbed, limit)
44
+ end
45
+
46
+ REDACTED_SENTINEL = Object.new.freeze
47
+ private_constant :REDACTED_SENTINEL
48
+
49
+ def scalar_truncate(value, limit)
50
+ case value
51
+ when Hash
52
+ value.transform_values { |nested| scalar_truncate(nested, limit) }
53
+ when Array
54
+ value.map { |nested| scalar_truncate(nested, limit) }
55
+ else
56
+ return value if value == REDACTED_VALUE
57
+
58
+ string = value.to_s
59
+ return value if string.bytesize <= limit
60
+
61
+ string.byteslice(0, limit).encode("UTF-8", invalid: :replace, undef: :replace)
62
+ end
63
+ end
64
+
65
+ def scrub_secrets(value)
66
+ case value
67
+ when Hash
68
+ value.each_with_object({}) do |(key, nested), out|
69
+ scrubbed = scrub_secrets(nested)
70
+ out[key] = scrubbed.equal?(REDACTED_SENTINEL) ? REDACTED_VALUE : scrubbed
71
+ end
72
+ when Array
73
+ value.map do |nested|
74
+ scrubbed = scrub_secrets(nested)
75
+ scrubbed.equal?(REDACTED_SENTINEL) ? REDACTED_VALUE : scrubbed
76
+ end
77
+ else
78
+ secret_shaped?(value.to_s) ? REDACTED_SENTINEL : value
79
+ end
80
+ end
81
+
82
+ def secret_shaped?(string)
83
+ return false if string.bytesize < 16
28
84
 
29
- string.byteslice(0, limit).encode("UTF-8", invalid: :replace, undef: :replace)
85
+ SECRET_VALUE_PATTERNS.any? { |pattern| pattern.match?(string) }
30
86
  end
31
87
 
32
88
  def redacted_key?(key, redacted)
@@ -14,8 +14,10 @@ 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
@@ -53,6 +55,7 @@ module LlmCostTracker
53
55
  def self.build(input_tokens:, output_tokens:, cache_read_input_tokens: 0,
54
56
  cache_write_input_tokens: 0, cache_write_extended_input_tokens: 0,
55
57
  audio_input_tokens: 0, audio_output_tokens: 0,
58
+ image_input_tokens: 0, image_output_tokens: 0,
56
59
  total_tokens: nil, hidden_output_tokens: 0)
57
60
  input = non_negative_int(input_tokens)
58
61
  output = non_negative_int(output_tokens)
@@ -61,9 +64,12 @@ module LlmCostTracker
61
64
  cache_write_extended = non_negative_int(cache_write_extended_input_tokens)
62
65
  audio_input = non_negative_int(audio_input_tokens)
63
66
  audio_output = non_negative_int(audio_output_tokens)
67
+ image_input = non_negative_int(image_input_tokens)
68
+ image_output = non_negative_int(image_output_tokens)
64
69
  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
70
+ calculated_total = input + cache_read + cache_write + cache_write_extended +
71
+ audio_input + image_input + output + audio_output + image_output
72
+ total = total_tokens ? [non_negative_int(total_tokens), calculated_total].max : calculated_total
67
73
 
68
74
  new(
69
75
  input_tokens: input,
@@ -71,8 +77,10 @@ module LlmCostTracker
71
77
  cache_write_input_tokens: cache_write,
72
78
  cache_write_extended_input_tokens: cache_write_extended,
73
79
  audio_input_tokens: audio_input,
80
+ image_input_tokens: image_input,
74
81
  output_tokens: output,
75
82
  audio_output_tokens: audio_output,
83
+ image_output_tokens: image_output,
76
84
  total_tokens: total,
77
85
  hidden_output_tokens: hidden_output
78
86
  )
@@ -6,6 +6,7 @@ require "securerandom"
6
6
 
7
7
  require_relative "ingestion"
8
8
  require_relative "ledger"
9
+ require_relative "logging"
9
10
  require_relative "pricing"
10
11
  require_relative "billing/cost_status"
11
12
 
@@ -24,10 +25,11 @@ module LlmCostTracker
24
25
  return unless LlmCostTracker.configuration.enabled
25
26
 
26
27
  pricing_mode = Pricing.normalize_mode(pricing_mode) || capture.pricing_mode
27
- cost_data, pricing_snapshot = Pricing.cost_and_snapshot_for(
28
+ cost_data, pricing_snapshot, priced_line_items = Pricing.calculate(
28
29
  provider: capture.provider,
29
30
  model: capture.model,
30
31
  tokens: capture.token_usage,
32
+ line_items: capture.line_items,
31
33
  pricing_mode: pricing_mode
32
34
  )
33
35
 
@@ -38,19 +40,36 @@ module LlmCostTracker
38
40
  pricing_mode: pricing_mode,
39
41
  cost_data: cost_data,
40
42
  pricing_snapshot: pricing_snapshot,
43
+ line_items: priced_line_items,
41
44
  metadata: metadata,
42
45
  latency_ms: latency_ms,
43
46
  context_tags: context_tags
44
47
  )
45
48
 
46
- ActiveSupport::Notifications.instrument(EVENT_NAME, event.to_h)
47
-
48
- Ingestion::Inbox.save(event)
49
+ save_event(event)
50
+ yield :after_save if block_given?
51
+ notify_subscribers(event)
49
52
  Budget.check!(event)
50
53
 
51
54
  event
52
55
  end
53
56
 
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
+
65
+ def notify_subscribers(event)
66
+ return unless ActiveSupport::Notifications.notifier.listening?(EVENT_NAME)
67
+
68
+ ActiveSupport::Notifications.instrument(EVENT_NAME, event.to_h)
69
+ rescue StandardError => e
70
+ Logging.warn("Subscriber raised on #{EVENT_NAME}: #{e.class}: #{e.message}")
71
+ end
72
+
54
73
  private
55
74
 
56
75
  def token_pricing_partial?(token_usage:, cost_data:)
@@ -61,15 +80,9 @@ module LlmCostTracker
61
80
  end
62
81
  end
63
82
 
64
- # rubocop:disable Metrics/MethodLength
65
- def build_event(capture:, pricing_mode:, cost_data:, pricing_snapshot:, metadata:, latency_ms:, context_tags:)
83
+ def build_event(capture:, pricing_mode:, cost_data:, pricing_snapshot:, line_items:,
84
+ metadata:, latency_ms:, context_tags:)
66
85
  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
86
  cost = cost_with_service_lines(cost_data, line_items)
74
87
  cost_status = Billing::CostStatus.call(
75
88
  token_usage: capture.token_usage,
@@ -102,7 +115,6 @@ module LlmCostTracker
102
115
  line_items: line_items
103
116
  )
104
117
  end
105
- # rubocop:enable Metrics/MethodLength
106
118
 
107
119
  def finite_latency_ms(latency_ms)
108
120
  return nil if latency_ms.nil?
@@ -113,16 +125,31 @@ module LlmCostTracker
113
125
  end
114
126
 
115
127
  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?)
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?
119
134
 
120
- service_total = service_lines.sum(BigDecimal("0"), &:cost_value)
121
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)
122
140
  base_total = BigDecimal(cost.fetch(:total_cost, 0).to_s)
123
- cost[:total_cost] = (base_total + service_total).round(8).to_f
141
+ cost[:total_cost] = (base_total + service_total).round(8)
124
142
  cost
125
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
126
153
  end
127
154
  end
128
155
  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.9.0"
5
5
  end
@@ -16,6 +16,7 @@ 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"
@@ -45,6 +46,9 @@ require_relative "llm_cost_tracker/doctor"
45
46
  require_relative "llm_cost_tracker/doctor/capture_verifier"
46
47
 
47
48
  module LlmCostTracker
49
+ autoload :Reconciliation, "llm_cost_tracker/reconciliation"
50
+ autoload :ReconcileTasks, "llm_cost_tracker/reconcile_tasks"
51
+
48
52
  @configuration = Configuration.new
49
53
 
50
54
  class << self
@@ -54,6 +58,10 @@ module LlmCostTracker
54
58
  "llm_cost_tracker_"
55
59
  end
56
60
 
61
+ def reconciliation_enabled?
62
+ configuration.reconciliation_enabled
63
+ end
64
+
57
65
  def configure
58
66
  config = configuration
59
67
  raise Error, "LlmCostTracker is already configured" if config.finalized?
@@ -76,6 +84,7 @@ module LlmCostTracker
76
84
  Pricing::Unknown.reset!
77
85
  Ingestion::Worker.reset!
78
86
  Tags::Context.clear!
87
+ DashboardSetupState.reset! if defined?(DashboardSetupState)
79
88
  end
80
89
 
81
90
  def with_tags(tags = nil, **kwargs, &)
@@ -10,8 +10,15 @@ require_relative "../llm_cost_tracker/pricing/sync_change_printer"
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
 
@@ -109,9 +116,25 @@ namespace :llm_cost_tracker do
109
116
  abort("llm_cost_tracker: price is incomplete or unknown") unless explanation.complete?
110
117
  end
111
118
  end
119
+
120
+ namespace :reconcile do
121
+ desc "Import provider invoice rows from a JSON INPUT file. " \
122
+ "Use SOURCE=openai INPUT=path/to/file.json. Pass PROVIDER=openai for unmapped sources (csv, ...)."
123
+ task(:import) { reconcile_run(:run_import) }
124
+
125
+ desc "Print a reconciliation diff. " \
126
+ "Use SOURCE=openai PERIOD_START=YYYY-MM-DD PERIOD_END=YYYY-MM-DD. PROVIDER=openai for unmapped sources."
127
+ task(:diff) { reconcile_run(:run_diff) }
128
+ end
112
129
  end
113
130
  # rubocop:enable Metrics/BlockLength
114
131
 
132
+ def reconcile_run(method)
133
+ Rake::Task["environment"].invoke if Rake::Task.task_defined?("environment")
134
+ require_relative "../llm_cost_tracker"
135
+ LlmCostTracker::ReconcileTasks.public_send(method, env: ENV)
136
+ end
137
+
115
138
  def print_changes(changes)
116
139
  LlmCostTracker::Pricing::SyncChangePrinter.call(changes)
117
140
  end
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.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergii Khomenko
@@ -239,13 +239,16 @@ 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/reconciliation_controller.rb
242
243
  - app/controllers/llm_cost_tracker/tags_controller.rb
243
244
  - app/helpers/llm_cost_tracker/application_helper.rb
244
245
  - app/helpers/llm_cost_tracker/chart_helper.rb
245
246
  - app/helpers/llm_cost_tracker/dashboard_filter_helper.rb
246
247
  - app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb
247
248
  - app/helpers/llm_cost_tracker/dashboard_query_helper.rb
249
+ - app/helpers/llm_cost_tracker/inline_style_helper.rb
248
250
  - app/helpers/llm_cost_tracker/pagination_helper.rb
251
+ - app/helpers/llm_cost_tracker/reconciliation_helper.rb
249
252
  - app/helpers/llm_cost_tracker/token_usage_helper.rb
250
253
  - app/models/llm_cost_tracker/call.rb
251
254
  - app/models/llm_cost_tracker/call_line_item.rb
@@ -254,6 +257,7 @@ files:
254
257
  - app/models/llm_cost_tracker/ingestion/inbox_entry.rb
255
258
  - app/models/llm_cost_tracker/ingestion/lease.rb
256
259
  - app/models/llm_cost_tracker/provider_invoice.rb
260
+ - app/models/llm_cost_tracker/provider_invoice_import.rb
257
261
  - app/services/llm_cost_tracker/dashboard/data_quality.rb
258
262
  - app/services/llm_cost_tracker/dashboard/date_range.rb
259
263
  - app/services/llm_cost_tracker/dashboard/filter.rb
@@ -275,6 +279,7 @@ files:
275
279
  - app/views/llm_cost_tracker/errors/invalid_filter.html.erb
276
280
  - app/views/llm_cost_tracker/errors/not_found.html.erb
277
281
  - app/views/llm_cost_tracker/models/index.html.erb
282
+ - app/views/llm_cost_tracker/reconciliation/index.html.erb
278
283
  - app/views/llm_cost_tracker/shared/_active_filters.html.erb
279
284
  - app/views/llm_cost_tracker/shared/_bar.html.erb
280
285
  - app/views/llm_cost_tracker/shared/_filters.html.erb
@@ -297,11 +302,13 @@ files:
297
302
  - lib/llm_cost_tracker/capture/stream_collector.rb
298
303
  - lib/llm_cost_tracker/capture/stream_tracker.rb
299
304
  - lib/llm_cost_tracker/configuration.rb
305
+ - lib/llm_cost_tracker/dashboard_setup_state.rb
300
306
  - lib/llm_cost_tracker/doctor.rb
301
307
  - lib/llm_cost_tracker/doctor/capture_verifier.rb
302
308
  - lib/llm_cost_tracker/doctor/check.rb
303
309
  - lib/llm_cost_tracker/doctor/cost_drift_check.rb
304
310
  - lib/llm_cost_tracker/doctor/ingestion_check.rb
311
+ - lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb
305
312
  - lib/llm_cost_tracker/doctor/legacy_audit_check.rb
306
313
  - lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb
307
314
  - lib/llm_cost_tracker/doctor/price_check.rb
@@ -311,13 +318,26 @@ files:
311
318
  - lib/llm_cost_tracker/engine.rb
312
319
  - lib/llm_cost_tracker/errors.rb
313
320
  - lib/llm_cost_tracker/event.rb
321
+ - 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
314
323
  - lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb
315
324
  - lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb
325
+ - lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb
326
+ - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb
316
327
  - 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
+ - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb
317
330
  - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb
331
+ - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb
332
+ - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb
333
+ - lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb
334
+ - lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb
335
+ - lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb
336
+ - lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb
318
337
  - lib/llm_cost_tracker/ingestion.rb
319
338
  - lib/llm_cost_tracker/ingestion/batch.rb
320
339
  - lib/llm_cost_tracker/ingestion/inbox.rb
340
+ - lib/llm_cost_tracker/ingestion/inline.rb
321
341
  - lib/llm_cost_tracker/ingestion/lease_claim.rb
322
342
  - lib/llm_cost_tracker/ingestion/worker.rb
323
343
  - lib/llm_cost_tracker/integrations.rb
@@ -335,11 +355,16 @@ files:
335
355
  - lib/llm_cost_tracker/ledger/schema/call_rollups.rb
336
356
  - lib/llm_cost_tracker/ledger/schema/call_tags.rb
337
357
  - lib/llm_cost_tracker/ledger/schema/calls.rb
358
+ - lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb
359
+ - lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb
360
+ - lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb
338
361
  - lib/llm_cost_tracker/ledger/schema/provider_invoices.rb
339
362
  - lib/llm_cost_tracker/ledger/store.rb
363
+ - lib/llm_cost_tracker/ledger/tags/encoding.rb
340
364
  - lib/llm_cost_tracker/ledger/tags/query.rb
341
365
  - lib/llm_cost_tracker/ledger/tags/sql.rb
342
366
  - lib/llm_cost_tracker/logging.rb
367
+ - lib/llm_cost_tracker/masking.rb
343
368
  - lib/llm_cost_tracker/middleware/faraday.rb
344
369
  - lib/llm_cost_tracker/parsers.rb
345
370
  - lib/llm_cost_tracker/parsers/anthropic.rb
@@ -355,6 +380,7 @@ files:
355
380
  - lib/llm_cost_tracker/pricing/effective_prices.rb
356
381
  - lib/llm_cost_tracker/pricing/explainer.rb
357
382
  - lib/llm_cost_tracker/pricing/lookup.rb
383
+ - lib/llm_cost_tracker/pricing/mode.rb
358
384
  - lib/llm_cost_tracker/pricing/registry.rb
359
385
  - lib/llm_cost_tracker/pricing/service_charges.rb
360
386
  - lib/llm_cost_tracker/pricing/sync.rb
@@ -364,6 +390,15 @@ files:
364
390
  - lib/llm_cost_tracker/pricing/sync_change_printer.rb
365
391
  - lib/llm_cost_tracker/pricing/unknown.rb
366
392
  - lib/llm_cost_tracker/railtie.rb
393
+ - lib/llm_cost_tracker/reconcile_tasks.rb
394
+ - lib/llm_cost_tracker/reconciliation.rb
395
+ - lib/llm_cost_tracker/reconciliation/diff.rb
396
+ - lib/llm_cost_tracker/reconciliation/diff_result.rb
397
+ - lib/llm_cost_tracker/reconciliation/import_result.rb
398
+ - lib/llm_cost_tracker/reconciliation/importer.rb
399
+ - lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb
400
+ - lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb
401
+ - lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb
367
402
  - lib/llm_cost_tracker/report.rb
368
403
  - lib/llm_cost_tracker/report/data.rb
369
404
  - lib/llm_cost_tracker/report/formatter.rb