llm_cost_tracker 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +72 -1
  4. data/README.md +58 -221
  5. data/app/assets/llm_cost_tracker/application.css +218 -41
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +30 -17
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +19 -14
  9. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +10 -2
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +11 -24
  11. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
  13. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +20 -7
  15. data/app/models/llm_cost_tracker/call.rb +169 -0
  16. data/app/models/llm_cost_tracker/call_line_item.rb +22 -0
  17. data/app/models/llm_cost_tracker/call_rollup.rb +9 -0
  18. data/app/models/llm_cost_tracker/call_tag.rb +16 -0
  19. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +13 -0
  20. data/app/models/llm_cost_tracker/ingestion/lease.rb +1 -1
  21. data/app/models/llm_cost_tracker/provider_invoice.rb +9 -0
  22. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +125 -34
  23. data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -2
  25. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
  27. data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
  28. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
  29. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
  30. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
  31. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
  32. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  33. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
  34. data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
  35. data/app/views/llm_cost_tracker/calls/show.html.erb +62 -7
  36. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -50
  37. data/app/views/llm_cost_tracker/data_quality/index.html.erb +103 -126
  38. data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
  39. data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
  40. data/app/views/llm_cost_tracker/shared/_filters.html.erb +63 -0
  41. data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
  42. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
  43. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
  44. data/app/views/llm_cost_tracker/tags/show.html.erb +5 -37
  45. data/lib/llm_cost_tracker/billing/components.rb +53 -0
  46. data/lib/llm_cost_tracker/billing/components.yml +117 -0
  47. data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
  48. data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
  49. data/lib/llm_cost_tracker/budget.rb +23 -35
  50. data/lib/llm_cost_tracker/capture/stream_collector.rb +47 -33
  51. data/lib/llm_cost_tracker/configuration.rb +36 -19
  52. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +54 -0
  53. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +24 -32
  54. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
  55. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
  56. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
  57. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
  58. data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
  59. data/lib/llm_cost_tracker/doctor/schema_check.rb +31 -0
  60. data/lib/llm_cost_tracker/doctor.rb +43 -45
  61. data/lib/llm_cost_tracker/errors.rb +5 -19
  62. data/lib/llm_cost_tracker/event.rb +10 -2
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -2
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +157 -0
  66. data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
  67. data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -23
  68. data/lib/llm_cost_tracker/ingestion/worker.rb +14 -5
  69. data/lib/llm_cost_tracker/ingestion.rb +28 -22
  70. data/lib/llm_cost_tracker/integrations/anthropic.rb +45 -38
  71. data/lib/llm_cost_tracker/integrations/base.rb +36 -29
  72. data/lib/llm_cost_tracker/integrations/openai.rb +85 -40
  73. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +5 -5
  74. data/lib/llm_cost_tracker/integrations.rb +2 -2
  75. data/lib/llm_cost_tracker/ledger/period/totals.rb +12 -9
  76. data/lib/llm_cost_tracker/ledger/period.rb +5 -5
  77. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +4 -10
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +76 -25
  79. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +50 -0
  81. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
  82. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +26 -0
  83. data/lib/llm_cost_tracker/ledger/schema/calls.rb +34 -23
  84. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
  85. data/lib/llm_cost_tracker/ledger/store.rb +110 -18
  86. data/lib/llm_cost_tracker/ledger/tags/query.rb +5 -11
  87. data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -14
  88. data/lib/llm_cost_tracker/ledger.rb +4 -2
  89. data/lib/llm_cost_tracker/logging.rb +2 -5
  90. data/lib/llm_cost_tracker/middleware/faraday.rb +7 -6
  91. data/lib/llm_cost_tracker/parsers/anthropic.rb +52 -7
  92. data/lib/llm_cost_tracker/parsers/base.rb +8 -3
  93. data/lib/llm_cost_tracker/parsers/gemini.rb +101 -15
  94. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +10 -2
  95. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +87 -0
  96. data/lib/llm_cost_tracker/parsers/openai_usage.rb +48 -21
  97. data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
  98. data/lib/llm_cost_tracker/parsers.rb +1 -1
  99. data/lib/llm_cost_tracker/prices.json +105 -20
  100. data/lib/llm_cost_tracker/pricing/effective_prices.rb +57 -19
  101. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
  102. data/lib/llm_cost_tracker/pricing/lookup.rb +38 -34
  103. data/lib/llm_cost_tracker/pricing/registry.rb +65 -45
  104. data/lib/llm_cost_tracker/pricing/service_charges.rb +204 -0
  105. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
  106. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
  107. data/lib/llm_cost_tracker/pricing/sync.rb +57 -10
  108. data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
  109. data/lib/llm_cost_tracker/pricing.rb +190 -26
  110. data/lib/llm_cost_tracker/railtie.rb +0 -8
  111. data/lib/llm_cost_tracker/report/data.rb +16 -8
  112. data/lib/llm_cost_tracker/report.rb +0 -4
  113. data/lib/llm_cost_tracker/retention.rb +8 -8
  114. data/lib/llm_cost_tracker/tags/context.rb +2 -4
  115. data/lib/llm_cost_tracker/tags/key.rb +4 -0
  116. data/lib/llm_cost_tracker/tags/sanitizer.rb +12 -17
  117. data/lib/llm_cost_tracker/timing.rb +15 -0
  118. data/lib/llm_cost_tracker/token_usage.rb +56 -42
  119. data/lib/llm_cost_tracker/tracker.rb +67 -24
  120. data/lib/llm_cost_tracker/usage_capture.rb +29 -8
  121. data/lib/llm_cost_tracker/version.rb +1 -1
  122. data/lib/llm_cost_tracker.rb +36 -35
  123. data/lib/tasks/llm_cost_tracker.rake +22 -17
  124. metadata +36 -41
  125. data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
  126. data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
  127. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
  128. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
  129. data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
  130. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
  131. data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
  132. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
  133. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
  134. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
  135. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +0 -29
  136. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +0 -29
  137. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
  138. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
  139. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
  140. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
  141. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
  142. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
  143. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
  144. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
  145. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
  146. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
  147. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
  148. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
  149. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
  150. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
  151. data/lib/llm_cost_tracker/pricing/components.rb +0 -37
  152. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
@@ -1,67 +1,81 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/core_ext/hash/keys"
3
+ require_relative "billing/components"
4
+ require_relative "logging"
4
5
 
5
6
  module LlmCostTracker
7
+ KNOWN_TOKEN_KEYS = (
8
+ Billing::Components::TOKEN_PRICED.map(&:key) + %i[total hidden_output]
9
+ ).freeze
10
+
6
11
  TokenUsage = Data.define(
7
12
  :input_tokens,
8
13
  :cache_read_input_tokens,
9
14
  :cache_write_input_tokens,
10
- :cache_write_1h_input_tokens,
15
+ :cache_write_extended_input_tokens,
16
+ :audio_input_tokens,
11
17
  :output_tokens,
18
+ :audio_output_tokens,
12
19
  :total_tokens,
13
20
  :hidden_output_tokens
14
21
  ) do
15
- def self.build(input_tokens:, output_tokens:, cache_read_input_tokens: 0,
16
- cache_write_input_tokens: 0, cache_write_1h_input_tokens: 0,
17
- total_tokens: nil, hidden_output_tokens: 0)
18
- input = input_tokens.to_i
19
- output = output_tokens.to_i
20
- cache_read = cache_read_input_tokens.to_i
21
- cache_write = cache_write_input_tokens.to_i
22
- cache_write_1h = cache_write_1h_input_tokens.to_i
23
- calculated_total = input + cache_read + cache_write + cache_write_1h + output
24
- total = total_tokens.nil? ? calculated_total : [total_tokens.to_i, calculated_total].max
22
+ def self.build_from_tokens(tokens)
23
+ return tokens if tokens.is_a?(self)
24
+ raise ArgumentError, "tokens must be a Hash, got #{tokens.class}" unless tokens.respond_to?(:to_h)
25
25
 
26
- new(
27
- input_tokens: input,
28
- cache_read_input_tokens: cache_read,
29
- cache_write_input_tokens: cache_write,
30
- cache_write_1h_input_tokens: cache_write_1h,
31
- output_tokens: output,
32
- total_tokens: total,
33
- hidden_output_tokens: hidden_output_tokens.to_i
34
- )
35
- end
26
+ values = tokens.to_h.transform_keys { |key| key.to_s.to_sym }
27
+ warn_on_unknown_keys(values)
28
+ token_attributes = Billing::Components::TOKEN_PRICED.to_h do |component|
29
+ [component.token_key, values.fetch(component.key, 0)]
30
+ end
36
31
 
37
- def self.from_hash(attributes)
38
- attributes = attributes.to_h.symbolize_keys
39
- values = TokenUsage::COMPONENT_TOKEN_KEYS.to_h { |key| [key, attributes[key]] }
40
32
  build(
41
- **values,
42
- total_tokens: attributes[:total_tokens]
33
+ **token_attributes,
34
+ total_tokens: values[:total],
35
+ hidden_output_tokens: values.fetch(:hidden_output, 0)
43
36
  )
44
37
  end
45
38
 
46
- def price_quantities
47
- {
48
- input: input_tokens,
49
- cache_read_input: cache_read_input_tokens,
50
- cache_write_input: cache_write_input_tokens,
51
- cache_write_1h_input: cache_write_1h_input_tokens,
52
- output: output_tokens
53
- }
39
+ def self.warn_on_unknown_keys(values)
40
+ return if values.empty?
41
+ return if values.keys.intersect?(KNOWN_TOKEN_KEYS)
42
+
43
+ Logging.warn(
44
+ "tokens hash contains no recognized keys (#{values.keys.inspect}); " \
45
+ "expected one of #{KNOWN_TOKEN_KEYS.inspect}. Did you pass a raw provider response?"
46
+ )
54
47
  end
55
48
 
56
- def stored_attributes
57
- to_h.slice(*self.class::STORED_KEYS)
49
+ def self.non_negative_int(value)
50
+ [value.to_i, 0].max
58
51
  end
59
52
 
60
- def to_h
61
- super.compact
53
+ def self.build(input_tokens:, output_tokens:, cache_read_input_tokens: 0,
54
+ cache_write_input_tokens: 0, cache_write_extended_input_tokens: 0,
55
+ audio_input_tokens: 0, audio_output_tokens: 0,
56
+ total_tokens: nil, hidden_output_tokens: 0)
57
+ input = non_negative_int(input_tokens)
58
+ output = non_negative_int(output_tokens)
59
+ cache_read = non_negative_int(cache_read_input_tokens)
60
+ cache_write = non_negative_int(cache_write_input_tokens)
61
+ cache_write_extended = non_negative_int(cache_write_extended_input_tokens)
62
+ audio_input = non_negative_int(audio_input_tokens)
63
+ audio_output = non_negative_int(audio_output_tokens)
64
+ 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
67
+
68
+ new(
69
+ input_tokens: input,
70
+ cache_read_input_tokens: cache_read,
71
+ cache_write_input_tokens: cache_write,
72
+ cache_write_extended_input_tokens: cache_write_extended,
73
+ audio_input_tokens: audio_input,
74
+ output_tokens: output,
75
+ audio_output_tokens: audio_output,
76
+ total_tokens: total,
77
+ hidden_output_tokens: hidden_output
78
+ )
62
79
  end
63
80
  end
64
-
65
- TokenUsage::STORED_KEYS = TokenUsage.members.freeze
66
- TokenUsage::COMPONENT_TOKEN_KEYS = (TokenUsage.members - %i[total_tokens]).freeze
67
81
  end
@@ -1,19 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_support/core_ext/object/blank"
4
+ require "bigdecimal"
4
5
  require "securerandom"
5
6
 
6
7
  require_relative "ingestion"
7
8
  require_relative "ledger"
8
9
  require_relative "pricing"
10
+ require_relative "billing/cost_status"
9
11
 
10
12
  module LlmCostTracker
11
13
  class Tracker
12
14
  EVENT_NAME = "llm_request.llm_cost_tracker"
13
15
 
14
- USAGE_SOURCES = %i[response stream_final sdk_response ruby_llm manual unknown].freeze
15
- TRACKING_METADATA_KEYS = (TokenUsage.members.map(&:to_s) + %w[pricing_mode provider_response_id]).freeze
16
-
17
16
  class << self
18
17
  def enforce_budget!
19
18
  return unless LlmCostTracker.configuration.enabled
@@ -25,19 +24,20 @@ module LlmCostTracker
25
24
  return unless LlmCostTracker.configuration.enabled
26
25
 
27
26
  pricing_mode = Pricing.normalize_mode(pricing_mode) || capture.pricing_mode
28
- cost_data = Pricing.cost_for(
27
+ cost_data, pricing_snapshot = Pricing.cost_and_snapshot_for(
29
28
  provider: capture.provider,
30
29
  model: capture.model,
31
- token_usage: capture.token_usage,
30
+ tokens: capture.token_usage,
32
31
  pricing_mode: pricing_mode
33
32
  )
34
33
 
35
- Pricing::Unknown.handle!(capture.model) unless cost_data
34
+ Pricing::Unknown.handle!(capture.model) if cost_data.nil? && capture.token_usage.total_tokens.positive?
36
35
 
37
36
  event = build_event(
38
37
  capture: capture,
39
38
  pricing_mode: pricing_mode,
40
39
  cost_data: cost_data,
40
+ pricing_snapshot: pricing_snapshot,
41
41
  metadata: metadata,
42
42
  latency_ms: latency_ms,
43
43
  context_tags: context_tags
@@ -53,15 +53,32 @@ module LlmCostTracker
53
53
 
54
54
  private
55
55
 
56
- def build_event(capture:, pricing_mode:, cost_data:, metadata:, latency_ms:, context_tags:)
57
- usage_source = if capture.usage_source.nil?
58
- nil
59
- else
60
- symbol = capture.usage_source.to_sym
61
- USAGE_SOURCES.include?(symbol) ? symbol.to_s : nil
62
- end
63
- tags = metadata.to_h.reject { |key, _value| TRACKING_METADATA_KEYS.include?(key.to_s) }
64
- context_tags = context_tags.nil? ? LlmCostTracker::Tags::Context.tags : context_tags.to_h
56
+ def token_pricing_partial?(token_usage:, cost_data:)
57
+ return false unless cost_data
58
+
59
+ Billing::Components::TOKEN_PRICED.any? do |component|
60
+ token_usage.public_send(component.token_key).positive? && cost_data[component.cost_key].nil?
61
+ end
62
+ end
63
+
64
+ # rubocop:disable Metrics/MethodLength
65
+ def build_event(capture:, pricing_mode:, cost_data:, pricing_snapshot:, metadata:, latency_ms:, context_tags:)
66
+ context_tags = (context_tags || LlmCostTracker::Tags::Context.tags).to_h
67
+ line_items, = Pricing.price_line_items(
68
+ provider: capture.provider,
69
+ model: capture.model,
70
+ line_items: capture.line_items,
71
+ pricing_mode: pricing_mode
72
+ )
73
+ cost = cost_with_service_lines(cost_data, line_items)
74
+ cost_status = Billing::CostStatus.call(
75
+ token_usage: capture.token_usage,
76
+ usage_source: capture.usage_source,
77
+ token_cost: cost_data,
78
+ token_pricing_partial: token_pricing_partial?(token_usage: capture.token_usage, cost_data: cost_data),
79
+ service_line_items: line_items.reject(&:token?),
80
+ total_cost: cost&.fetch(:total_cost, nil)
81
+ )
65
82
 
66
83
  Event.new(
67
84
  event_id: SecureRandom.uuid,
@@ -69,17 +86,43 @@ module LlmCostTracker
69
86
  model: capture.model,
70
87
  token_usage: capture.token_usage,
71
88
  pricing_mode: pricing_mode,
72
- cost: cost_data,
73
- tags: LlmCostTracker::Tags::Sanitizer.call(
74
- context_tags.merge(tags)
75
- ).freeze,
76
- latency_ms: latency_ms.nil? ? nil : [latency_ms.to_i, 0].max,
77
- stream: capture.stream ? true : false,
78
- usage_source: usage_source,
79
- provider_response_id: capture.provider_response_id.to_s.presence,
80
- tracked_at: Time.now.utc
89
+ cost: cost,
90
+ tags: LlmCostTracker::Tags::Sanitizer.call(context_tags.merge(metadata.to_h)).freeze,
91
+ latency_ms: finite_latency_ms(latency_ms),
92
+ stream: capture.stream,
93
+ usage_source: capture.usage_source,
94
+ provider_response_id: capture.provider_response_id,
95
+ provider_project_id: capture.provider_project_id,
96
+ provider_api_key_id: capture.provider_api_key_id,
97
+ provider_workspace_id: capture.provider_workspace_id,
98
+ batch: capture.batch,
99
+ tracked_at: Time.now.utc,
100
+ cost_status: cost_status,
101
+ pricing_snapshot: pricing_snapshot,
102
+ line_items: line_items
81
103
  )
82
104
  end
105
+ # rubocop:enable Metrics/MethodLength
106
+
107
+ def finite_latency_ms(latency_ms)
108
+ return nil if latency_ms.nil?
109
+
110
+ Integer(latency_ms).clamp(0, (1 << 31) - 1)
111
+ rescue ArgumentError, TypeError, FloatDomainError
112
+ nil
113
+ end
114
+
115
+ def cost_with_service_lines(cost_data, line_items)
116
+ service_lines = line_items.reject(&:token?)
117
+ return cost_data if service_lines.empty?
118
+ return cost_data if service_lines.none?(&:priced?)
119
+
120
+ service_total = service_lines.sum(BigDecimal("0"), &:cost_value)
121
+ cost = cost_data ? cost_data.dup : {}
122
+ base_total = BigDecimal(cost.fetch(:total_cost, 0).to_s)
123
+ cost[:total_cost] = (base_total + service_total).round(8).to_f
124
+ cost
125
+ end
83
126
  end
84
127
  end
85
128
  end
@@ -3,6 +3,7 @@
3
3
  require "active_support/core_ext/object/blank"
4
4
 
5
5
  require_relative "pricing"
6
+ require_relative "billing/line_item"
6
7
 
7
8
  module LlmCostTracker
8
9
  UsageCapture = Data.define(
@@ -12,26 +13,46 @@ module LlmCostTracker
12
13
  :stream,
13
14
  :usage_source,
14
15
  :provider_response_id,
15
- :pricing_mode
16
+ :provider_project_id,
17
+ :provider_api_key_id,
18
+ :provider_workspace_id,
19
+ :batch,
20
+ :pricing_mode,
21
+ :line_items
16
22
  )
17
23
 
18
24
  class UsageCapture
19
25
  UNKNOWN_MODEL = "unknown"
20
26
 
27
+ def self.batch_from_pricing_mode?(pricing_mode)
28
+ pricing_mode.to_s.split("_").include?("batch")
29
+ end
30
+
21
31
  def self.build(**attributes)
32
+ pricing_mode = Pricing.normalize_mode(attributes[:pricing_mode])
33
+ batch = attributes[:batch]
34
+ batch = batch_from_pricing_mode?(pricing_mode) if batch.nil?
35
+
36
+ token_usage = attributes.fetch(:token_usage)
37
+ service_line_items = Array(attributes[:service_line_items]).map do |item|
38
+ item.is_a?(Billing::LineItem) ? item : Billing::LineItem.build(item)
39
+ end
40
+ line_items = attributes[:line_items] || (Billing::LineItem.from_token_usage(token_usage) + service_line_items)
41
+
22
42
  new(
23
43
  provider: attributes.fetch(:provider).to_s,
24
44
  model: attributes.fetch(:model).to_s.strip.presence || UNKNOWN_MODEL,
25
- token_usage: attributes.fetch(:token_usage),
45
+ token_usage: token_usage,
26
46
  stream: attributes[:stream] || false,
27
47
  usage_source: attributes[:usage_source],
28
- provider_response_id: attributes[:provider_response_id],
29
- pricing_mode: Pricing.normalize_mode(attributes[:pricing_mode])
48
+ provider_response_id: attributes[:provider_response_id].to_s.strip.presence,
49
+ provider_project_id: attributes[:provider_project_id].to_s.strip.presence,
50
+ provider_api_key_id: attributes[:provider_api_key_id].to_s.strip.presence,
51
+ provider_workspace_id: attributes[:provider_workspace_id].to_s.strip.presence,
52
+ batch: batch,
53
+ pricing_mode: pricing_mode,
54
+ line_items: line_items
30
55
  )
31
56
  end
32
-
33
- def to_h
34
- super.compact
35
- end
36
57
  end
37
58
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.7.2"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -17,6 +17,9 @@ require_relative "llm_cost_tracker/tags/key"
17
17
  require_relative "llm_cost_tracker/tags/context"
18
18
  require_relative "llm_cost_tracker/tags/sanitizer"
19
19
  require_relative "llm_cost_tracker/token_usage"
20
+ require_relative "llm_cost_tracker/billing/components"
21
+ require_relative "llm_cost_tracker/billing/line_item"
22
+ require_relative "llm_cost_tracker/billing/cost_status"
20
23
  require_relative "llm_cost_tracker/event"
21
24
  require_relative "llm_cost_tracker/pricing"
22
25
  require_relative "llm_cost_tracker/usage_capture"
@@ -47,14 +50,19 @@ module LlmCostTracker
47
50
  class << self
48
51
  attr_reader :configuration
49
52
 
53
+ def table_name_prefix
54
+ "llm_cost_tracker_"
55
+ end
56
+
50
57
  def configure
51
58
  config = configuration
52
59
  raise Error, "LlmCostTracker is already configured" if config.finalized?
53
60
 
54
61
  yield(config)
55
- config.openai_compatible_providers = config.openai_compatible_providers.dup
56
62
  config.finalize!
57
63
  Pricing::Lookup.reset!
64
+ Pricing::Registry.reset!
65
+ Pricing::ServiceCharges.reset!
58
66
  Integrations.install!
59
67
  config
60
68
  end
@@ -63,67 +71,60 @@ module LlmCostTracker
63
71
  Ingestion::Worker.shutdown!(drain: false)
64
72
  @configuration = Configuration.new
65
73
  Pricing::Lookup.reset!
74
+ Pricing::Registry.reset!
75
+ Pricing::ServiceCharges.reset!
66
76
  Pricing::Unknown.reset!
67
77
  Ingestion::Worker.reset!
68
78
  Tags::Context.clear!
69
79
  end
70
80
 
71
- def flush!(timeout: nil)
72
- if timeout
73
- Ingestion::Worker.flush!(timeout: timeout)
74
- else
75
- Ingestion::Worker.flush!
76
- end
77
- end
78
-
79
- def shutdown!(timeout: nil, drain: true)
80
- if timeout
81
- Ingestion::Worker.shutdown!(timeout: timeout, drain: drain)
82
- else
83
- Ingestion::Worker.shutdown!(drain: drain)
84
- end
85
- end
86
-
87
- def enforce_budget!
88
- Tracker.enforce_budget!
89
- end
90
-
91
81
  def with_tags(tags = nil, **kwargs, &)
92
- merged = (tags || {}).to_h.merge(kwargs)
93
- Tags::Context.with(merged, &)
82
+ Tags::Context.with((tags || {}).merge(kwargs), &)
94
83
  end
95
84
 
96
- def track(provider:, input_tokens:, output_tokens:, model: nil, latency_ms: nil, stream: false,
97
- usage_source: :manual, enforce_budget: false, provider_response_id: nil, pricing_mode: nil, **metadata)
98
- enforce_budget! if enforce_budget
99
- token_usage = TokenUsage.from_hash(metadata.merge(input_tokens: input_tokens, output_tokens: output_tokens))
85
+ def track(provider:, tokens:, model: nil, tags: {}, latency_ms: nil, stream: false,
86
+ usage_source: :manual, enforce_budget: false,
87
+ provider_response_id: nil, provider_project_id: nil, provider_api_key_id: nil,
88
+ provider_workspace_id: nil, batch: nil, pricing_mode: nil, service_line_items: [])
89
+ Tracker.enforce_budget! if enforce_budget
100
90
 
101
91
  Tracker.record(
102
92
  capture: UsageCapture.build(
103
93
  provider: provider,
104
94
  model: model,
105
- token_usage: token_usage,
95
+ token_usage: TokenUsage.build_from_tokens(tokens),
106
96
  stream: stream,
107
97
  usage_source: usage_source,
108
- provider_response_id: provider_response_id
98
+ provider_response_id: provider_response_id,
99
+ provider_project_id: provider_project_id,
100
+ provider_api_key_id: provider_api_key_id,
101
+ provider_workspace_id: provider_workspace_id,
102
+ batch: batch,
103
+ pricing_mode: pricing_mode,
104
+ service_line_items: service_line_items
109
105
  ),
110
106
  latency_ms: latency_ms,
111
107
  pricing_mode: pricing_mode,
112
- metadata: metadata
108
+ metadata: tags
113
109
  )
114
110
  end
115
111
 
116
- def track_stream(provider:, model: nil, latency_ms: nil, enforce_budget: false, provider_response_id: nil,
117
- pricing_mode: nil, **metadata)
112
+ def track_stream(provider:, model: nil, tags: {}, latency_ms: nil, enforce_budget: false,
113
+ provider_response_id: nil, provider_project_id: nil, provider_api_key_id: nil,
114
+ provider_workspace_id: nil, batch: nil, pricing_mode: nil)
118
115
  require_relative "llm_cost_tracker/capture/stream_collector"
119
- enforce_budget! if enforce_budget
116
+ Tracker.enforce_budget! if enforce_budget
120
117
  collector = Capture::StreamCollector.new(
121
118
  provider: provider.to_s,
122
119
  model: model,
123
120
  latency_ms: latency_ms,
124
121
  provider_response_id: provider_response_id,
122
+ provider_project_id: provider_project_id,
123
+ provider_api_key_id: provider_api_key_id,
124
+ provider_workspace_id: provider_workspace_id,
125
+ batch: batch,
125
126
  pricing_mode: pricing_mode,
126
- metadata: metadata
127
+ metadata: tags
127
128
  )
128
129
  yield collector
129
130
  collector.finish!
@@ -140,4 +141,4 @@ Faraday::Middleware.register_middleware(
140
141
  llm_cost_tracker: LlmCostTracker::Middleware::Faraday
141
142
  )
142
143
 
143
- at_exit { LlmCostTracker.shutdown!(drain: false) }
144
+ at_exit { LlmCostTracker::Ingestion::Worker.shutdown!(drain: false) }
@@ -1,9 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "rails/generators"
5
+
6
+ require_relative "../llm_cost_tracker/generators/llm_cost_tracker/install_generator"
7
+ require_relative "../llm_cost_tracker/pricing/sync_change_printer"
4
8
 
5
9
  # rubocop:disable Metrics/BlockLength
6
10
  namespace :llm_cost_tracker do
11
+ desc "Install LLM Cost Tracker with dashboard and prices, migrate, and run doctor"
12
+ task :setup do
13
+ Rails::Generators.invoke("llm_cost_tracker:install", %w[--dashboard --prices])
14
+ Rake::Task["db:migrate"].invoke
15
+ Rake::Task["llm_cost_tracker:doctor"].invoke
16
+ end
17
+
7
18
  desc "Check LLM Cost Tracker setup"
8
19
  task :doctor do
9
20
  Rake::Task["environment"].invoke if Rake::Task.task_defined?("environment")
@@ -30,7 +41,7 @@ namespace :llm_cost_tracker do
30
41
  puts LlmCostTracker::Report.generate(days: days)
31
42
  end
32
43
 
33
- desc "Delete llm_api_calls older than DAYS (default: 90). Use BATCH_SIZE=N to tune."
44
+ desc "Delete llm_cost_tracker_calls older than DAYS (default: 90). Use BATCH_SIZE=N to tune."
34
45
  task prune: :environment do
35
46
  days = (ENV["DAYS"] || 90).to_i
36
47
  batch_size = (ENV["BATCH_SIZE"] || LlmCostTracker::Retention::DEFAULT_BATCH_SIZE).to_i
@@ -102,15 +113,7 @@ end
102
113
  # rubocop:enable Metrics/BlockLength
103
114
 
104
115
  def print_changes(changes)
105
- puts " changed models: #{changes.size}"
106
- return if changes.empty?
107
-
108
- changes.each do |model, fields|
109
- puts " - #{model}"
110
- fields.each do |field, values|
111
- puts " #{field}: #{values['from'].inspect} -> #{values['to'].inspect}"
112
- end
113
- end
116
+ LlmCostTracker::Pricing::SyncChangePrinter.call(changes)
114
117
  end
115
118
 
116
119
  def price_refresh_output_path
@@ -128,13 +131,15 @@ def price_explanation_from_env
128
131
  provider: provider,
129
132
  model: model,
130
133
  pricing_mode: ENV.fetch("PRICING_MODE", nil),
131
- token_usage: LlmCostTracker::TokenUsage.build(
132
- input_tokens: ENV.fetch("INPUT_TOKENS", 1).to_i,
133
- output_tokens: ENV.fetch("OUTPUT_TOKENS", 1).to_i,
134
- cache_read_input_tokens: ENV.fetch("CACHE_READ_INPUT_TOKENS", 0).to_i,
135
- cache_write_input_tokens: ENV.fetch("CACHE_WRITE_INPUT_TOKENS", 0).to_i,
136
- cache_write_1h_input_tokens: ENV.fetch("CACHE_WRITE_1H_INPUT_TOKENS", 0).to_i
137
- )
134
+ tokens: {
135
+ input: ENV.fetch("INPUT_TOKENS", 1).to_i,
136
+ output: ENV.fetch("OUTPUT_TOKENS", 1).to_i,
137
+ cache_read_input: ENV.fetch("CACHE_READ_INPUT_TOKENS", 0).to_i,
138
+ cache_write_input: ENV.fetch("CACHE_WRITE_INPUT_TOKENS", 0).to_i,
139
+ cache_write_extended_input: ENV.fetch("CACHE_WRITE_EXTENDED_INPUT_TOKENS", 0).to_i,
140
+ audio_input: ENV.fetch("AUDIO_INPUT_TOKENS", 0).to_i,
141
+ audio_output: ENV.fetch("AUDIO_OUTPUT_TOKENS", 0).to_i
142
+ }
138
143
  )
139
144
  end
140
145