llm_cost_tracker 0.14.0 → 0.14.2

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 (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +128 -0
  3. data/README.md +16 -5
  4. data/app/assets/llm_cost_tracker/application.css +16 -0
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +16 -3
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +1 -3
  7. data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
  8. data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
  9. data/app/controllers/llm_cost_tracker/tags_controller.rb +9 -7
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +5 -26
  11. data/app/helpers/llm_cost_tracker/chart_helper.rb +6 -10
  12. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -0
  13. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +8 -28
  14. data/app/models/llm_cost_tracker/call.rb +10 -14
  15. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +4 -15
  16. data/app/services/llm_cost_tracker/dashboard/filter.rb +29 -30
  17. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  18. data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
  19. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
  20. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +1 -1
  21. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +2 -3
  22. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -0
  23. data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
  24. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -2
  25. data/app/views/llm_cost_tracker/calls/show.html.erb +8 -22
  26. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  27. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
  28. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
  29. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
  30. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
  31. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -4
  32. data/app/views/llm_cost_tracker/tags/show.html.erb +8 -5
  33. data/config/routes.rb +6 -1
  34. data/lib/llm_cost_tracker/budget/per_tag.rb +21 -11
  35. data/lib/llm_cost_tracker/budget.rb +44 -55
  36. data/lib/llm_cost_tracker/capture/event_window.rb +100 -0
  37. data/lib/llm_cost_tracker/capture/sdk_payload.rb +5 -1
  38. data/lib/llm_cost_tracker/capture/sse.rb +108 -32
  39. data/lib/llm_cost_tracker/capture/stream_collector.rb +41 -81
  40. data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
  41. data/lib/llm_cost_tracker/capture/stream_tracker.rb +14 -58
  42. data/lib/llm_cost_tracker/capture_verifier.rb +1 -7
  43. data/lib/llm_cost_tracker/charges/line_item.rb +5 -1
  44. data/lib/llm_cost_tracker/configuration/budgets.rb +1 -1
  45. data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
  46. data/lib/llm_cost_tracker/configuration.rb +5 -9
  47. data/lib/llm_cost_tracker/doctor/price_check.rb +14 -3
  48. data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
  49. data/lib/llm_cost_tracker/doctor.rb +19 -2
  50. data/lib/llm_cost_tracker/engine.rb +0 -1
  51. data/lib/llm_cost_tracker/errors.rb +13 -0
  52. data/lib/llm_cost_tracker/event.rb +8 -0
  53. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
  54. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +1 -1
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -12
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -1
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -1
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +1 -1
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -1
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
  68. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
  69. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
  70. data/lib/llm_cost_tracker/ingestion/batch.rb +37 -8
  71. data/lib/llm_cost_tracker/ingestion/inbox.rb +2 -8
  72. data/lib/llm_cost_tracker/ingestion.rb +3 -1
  73. data/lib/llm_cost_tracker/integrations/anthropic.rb +70 -11
  74. data/lib/llm_cost_tracker/integrations/base.rb +56 -19
  75. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +21 -13
  76. data/lib/llm_cost_tracker/integrations/openai/patches.rb +8 -0
  77. data/lib/llm_cost_tracker/integrations/openai.rb +42 -59
  78. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +275 -75
  79. data/lib/llm_cost_tracker/integrations.rb +1 -20
  80. data/lib/llm_cost_tracker/ledger/isolation.rb +23 -0
  81. data/lib/llm_cost_tracker/ledger/period/totals.rb +16 -11
  82. data/lib/llm_cost_tracker/ledger/rollups.rb +25 -17
  83. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +14 -3
  84. data/lib/llm_cost_tracker/ledger/schema/base.rb +5 -4
  85. data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
  86. data/lib/llm_cost_tracker/ledger/store.rb +14 -13
  87. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +1 -5
  88. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
  89. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
  90. data/lib/llm_cost_tracker/ledger.rb +1 -0
  91. data/lib/llm_cost_tracker/logging.rb +3 -1
  92. data/lib/llm_cost_tracker/middleware/faraday.rb +67 -61
  93. data/lib/llm_cost_tracker/parsers.rb +12 -41
  94. data/lib/llm_cost_tracker/prices.json +3071 -249
  95. data/lib/llm_cost_tracker/pricing/backfill.rb +46 -13
  96. data/lib/llm_cost_tracker/pricing/calculation.rb +118 -75
  97. data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
  98. data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
  99. data/lib/llm_cost_tracker/pricing/matcher.rb +36 -22
  100. data/lib/llm_cost_tracker/pricing/mode.rb +2 -13
  101. data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
  102. data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
  103. data/lib/llm_cost_tracker/pricing/registry.rb +17 -17
  104. data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
  105. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
  106. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
  107. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
  108. data/lib/llm_cost_tracker/pricing/sync.rb +38 -70
  109. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +33 -3
  110. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +11 -5
  111. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +81 -12
  112. data/lib/llm_cost_tracker/providers/azure/parser.rb +22 -7
  113. data/lib/llm_cost_tracker/providers/gemini/parser.rb +175 -47
  114. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +13 -22
  115. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -1
  116. data/lib/llm_cost_tracker/providers/openai/parser.rb +11 -13
  117. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +101 -27
  118. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +57 -40
  119. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +24 -6
  120. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +7 -1
  121. data/lib/llm_cost_tracker/redaction.rb +32 -0
  122. data/lib/llm_cost_tracker/report/data.rb +1 -1
  123. data/lib/llm_cost_tracker/retention.rb +1 -3
  124. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -36
  125. data/lib/llm_cost_tracker/tracker.rb +17 -10
  126. data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
  127. data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
  128. data/lib/llm_cost_tracker/usage/dimensions.yml +61 -0
  129. data/lib/llm_cost_tracker/version.rb +1 -1
  130. data/lib/llm_cost_tracker.rb +4 -2
  131. data/lib/tasks/llm_cost_tracker.rake +26 -21
  132. data/llm_cost_tracker.gemspec +63 -0
  133. metadata +23 -10
@@ -27,39 +27,19 @@ module LlmCostTracker
27
27
  line_items_from_output(output_items, request: request, model: model)
28
28
  end
29
29
 
30
- CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD = "choices.message.annotations.url_citation"
31
- CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD = "request.model"
32
-
33
30
  def chat_completions_web_search_items(response, model: nil)
34
- return [] unless response["choices"]
35
-
36
- provider_field = chat_completions_search_provider_field(response["choices"], model)
37
- return [] unless provider_field
31
+ return [] unless response["choices"] && chat_completions_search_model?(model)
38
32
 
39
33
  [{ "type" => "web_search_call", "id" => response["id"], "action" => { "type" => "search" },
40
- "provider_field" => provider_field }]
41
- end
42
-
43
- def chat_completions_search_provider_field(choices, model)
44
- return CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD if chat_completions_used_web_search?(choices)
45
- return CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD if chat_completions_search_model?(model)
46
-
47
- nil
48
- end
49
-
50
- def chat_completions_used_web_search?(choices)
51
- Array(choices).any? do |choice|
52
- Array(choice.dig("message", "annotations")).any? do |annotation|
53
- annotation.is_a?(Hash) && annotation["type"].to_s == "url_citation"
54
- end
55
- end
34
+ "provider_field" => "request.model" }]
56
35
  end
57
36
 
58
37
  def billable?(item)
59
38
  return false unless item.is_a?(Hash)
60
39
 
61
- dimension = output_dimension(item["type"])
40
+ dimension = item_dimension(item)
62
41
  return false unless dimension
42
+ return item["status"] == "completed" if dimension == "image_generation_call"
63
43
  return true unless dimension == "web_search_request"
64
44
 
65
45
  action_type = item.dig("action", "type")
@@ -69,11 +49,11 @@ module LlmCostTracker
69
49
  def store_output_item(output_items, item)
70
50
  return unless item.is_a?(Hash)
71
51
 
72
- dimension = output_dimension(item["type"])
52
+ dimension = item_dimension(item)
73
53
  return unless dimension
74
54
 
75
- key = if dimension == "container_session" && item["container_id"]
76
- "#{dimension}:#{item['container_id']}"
55
+ key = if dimension == "container_session" && container_id(item)
56
+ "#{dimension}:#{container_id(item)}"
77
57
  else
78
58
  item["id"] || "#{item['type']}:#{output_items.length}"
79
59
  end
@@ -87,7 +67,7 @@ module LlmCostTracker
87
67
  return nil unless dimension_key
88
68
 
89
69
  provider_item_id = if dimension_key == "container_session"
90
- item["container_id"] || item["id"]
70
+ container_id(item) || item["id"]
91
71
  else
92
72
  item["id"]
93
73
  end
@@ -103,13 +83,28 @@ module LlmCostTracker
103
83
  end
104
84
 
105
85
  def dimension_key_for(item, request:, model:)
106
- dimension = output_dimension(item["type"])
86
+ dimension = item_dimension(item)
107
87
  return dimension unless dimension == "web_search_request"
108
88
  return dimension unless web_search_preview_used?(request) || chat_completions_search_model?(model)
109
89
 
110
90
  reasoning_model?(model) ? "web_search_preview_request_reasoning" : "web_search_preview_request_non_reasoning"
111
91
  end
112
92
 
93
+ def item_dimension(item)
94
+ return "container_session" if item["type"] == "shell_call" && shell_container_id(item)
95
+
96
+ output_dimension(item["type"])
97
+ end
98
+
99
+ def container_id(item)
100
+ item["container_id"] || shell_container_id(item)
101
+ end
102
+
103
+ def shell_container_id(item)
104
+ environment = item["environment"]
105
+ environment["container_id"] if environment.is_a?(Hash) && environment["type"] == "container_reference"
106
+ end
107
+
113
108
  def output_dimension(type)
114
109
  key = RESPONSE_OUTPUT_RENAMES[type] || type
115
110
  dimension = Usage::Catalog[key]
@@ -144,19 +139,10 @@ module LlmCostTracker
144
139
  {
145
140
  status: item["status"],
146
141
  action_type: item.dig("action", "type"),
147
- container_id: item["container_id"]
142
+ container_id: container_id(item)
148
143
  }.compact
149
144
  end
150
145
 
151
- def openai_stream_service_line_items(events, request: nil, model: nil)
152
- output_items = []
153
- each_event_data(events) do |data|
154
- output_items.concat(Array(data.dig("response", "output")))
155
- output_items << data["item"] if data["item"]
156
- end
157
- line_items_from_output(output_items, request: request, model: model)
158
- end
159
-
160
146
  def transcription_line_items(usage)
161
147
  return [] unless usage
162
148
 
@@ -168,13 +154,44 @@ module LlmCostTracker
168
154
 
169
155
  [Charges::LineItem.build(
170
156
  dimension_key: "transcription_minute",
171
- quantity: (seconds / 60.0).ceil,
157
+ quantity: BigDecimal(seconds.to_s) / 60,
172
158
  cost_status: Charges::CostStatus::UNKNOWN,
173
159
  pricing_basis: "provider_usage",
174
160
  provider_field: "usage.seconds",
175
161
  details: { seconds: seconds }
176
162
  )]
177
163
  end
164
+
165
+ def speech_line_items(request)
166
+ input = request["input"]
167
+ return [] unless input.is_a?(String) && ModelFamilies.character_billed_tts?(request["model"])
168
+
169
+ [Charges::LineItem.build(
170
+ dimension_key: "text_to_speech_character",
171
+ quantity: input.length,
172
+ cost_status: Charges::CostStatus::UNKNOWN,
173
+ pricing_basis: "provider_usage",
174
+ provider_field: "request.input"
175
+ )]
176
+ end
177
+
178
+ # OpenRouter bills BYOK inference to the user's own provider key and puts only its fee in usage.cost.
179
+ def billed_line_items(usage)
180
+ amounts = [usage[:cost]]
181
+ amounts << usage.dig(:cost_details, :upstream_inference_cost) if usage[:is_byok]
182
+ return [] unless amounts.all?(Numeric)
183
+
184
+ amount = amounts.sum { |value| BigDecimal(value.to_s) }
185
+ [Charges::LineItem.build(
186
+ dimension_key: "billed_request",
187
+ quantity: 1,
188
+ rate_amount: amount,
189
+ cost: amount,
190
+ pricing_basis: "provider_usage",
191
+ price_source: "provider_response",
192
+ provider_field: "usage.cost"
193
+ )]
194
+ end
178
195
  end
179
196
  end
180
197
  end
@@ -8,14 +8,21 @@ module LlmCostTracker
8
8
  module UsageExtractor
9
9
  INPUT_DETAIL_KEYS = %i[input_tokens_details input_token_details prompt_tokens_details].freeze
10
10
  OUTPUT_DETAIL_KEYS = %i[output_tokens_details output_token_details completion_tokens_details].freeze
11
+ CACHED_MODALITY_DIMENSIONS = {
12
+ audio_tokens: "audio_cache_read_input", image_tokens: "image_cache_read_input"
13
+ }.freeze
14
+
11
15
  def self.token_usage(usage, model: nil)
12
16
  input_tokens = (usage[:input_tokens] || usage[:prompt_tokens]).to_i
13
17
  output_tokens = (usage[:output_tokens] || usage[:completion_tokens]).to_i
18
+ reasoning = hidden_output_tokens(usage)
19
+ output_tokens += reasoning if usage[:total_tokens].to_i == input_tokens + output_tokens + reasoning
14
20
  cache_read = cache_read_input_tokens(usage)
15
21
  cache_write = cache_write_input_tokens(usage)
16
- audio_input = audio_input_tokens(usage)
22
+ uncached_input = [input_tokens - cache_read - cache_write, 0].max
23
+ image_input = uncached_input_tokens(usage, :image_tokens).clamp(0, uncached_input)
24
+ audio_input = uncached_input_tokens(usage, :audio_tokens).clamp(0, uncached_input - image_input)
17
25
  audio_output = audio_output_tokens(usage)
18
- image_input = image_input_tokens(usage)
19
26
  image_output, regular_output = split_output(
20
27
  output_tokens: output_tokens,
21
28
  image_output_details: image_output_tokens(usage),
@@ -25,7 +32,7 @@ module LlmCostTracker
25
32
  )
26
33
 
27
34
  Usage::TokenUsage.build(
28
- input_tokens: [input_tokens - cache_read - cache_write - audio_input - image_input, 0].max,
35
+ input_tokens: uncached_input - audio_input - image_input,
29
36
  output_tokens: regular_output,
30
37
  total_tokens: usage[:total_tokens],
31
38
  cache_read_input_tokens: cache_read,
@@ -34,10 +41,17 @@ module LlmCostTracker
34
41
  audio_output_tokens: audio_output,
35
42
  image_input_tokens: image_input,
36
43
  image_output_tokens: image_output,
37
- hidden_output_tokens: hidden_output_tokens(usage)
44
+ hidden_output_tokens: reasoning
38
45
  )
39
46
  end
40
47
 
48
+ def self.cache_read_line_items(usage)
49
+ CACHED_MODALITY_DIMENSIONS.filter_map do |detail_key, dimension_key|
50
+ quantity = detail(usage, INPUT_DETAIL_KEYS, :cached_tokens_details, detail_key)
51
+ Charges::LineItem.build(dimension_key: dimension_key, quantity: quantity) if quantity.positive?
52
+ end
53
+ end
54
+
41
55
  def self.split_output(output_tokens:,
42
56
  image_output_details:,
43
57
  text_output_details:,
@@ -60,9 +74,13 @@ module LlmCostTracker
60
74
  def self.image_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :image_tokens)
61
75
  def self.text_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :text_tokens)
62
76
 
63
- def self.detail(usage, containers, key)
77
+ def self.uncached_input_tokens(usage, key)
78
+ detail(usage, INPUT_DETAIL_KEYS, key) - detail(usage, INPUT_DETAIL_KEYS, :cached_tokens_details, key)
79
+ end
80
+
81
+ def self.detail(usage, containers, *path)
64
82
  containers.each do |container|
65
- value = usage.dig(container, key)
83
+ value = usage.dig(container, *path)
66
84
  return value.to_i if value
67
85
  end
68
86
  0
@@ -10,7 +10,9 @@ module LlmCostTracker
10
10
 
11
11
  class << self
12
12
  def match?(url)
13
- match_uri?(url, path_suffixes: TRACKED_PATH_SUFFIXES) { |uri| provider_for_uri(uri) }
13
+ uri_matches?(url) do |uri|
14
+ TRACKED_PATH_SUFFIXES.any? { |suffix| uri.path.to_s.end_with?(suffix) } && !provider_for_uri(uri).nil?
15
+ end
14
16
  end
15
17
 
16
18
  def provider_names
@@ -30,6 +32,10 @@ module LlmCostTracker
30
32
  def provider_for(request_url)
31
33
  self.class.provider_for_uri(parsed_uri(request_url)) || "openai_compatible"
32
34
  end
35
+
36
+ def auto_enable_stream_usage?(request_url, request_parsed)
37
+ super && Configuration::Capture::OPENAI_COMPATIBLE_PROVIDERS.key?(parsed_uri(request_url).host.to_s.downcase)
38
+ end
33
39
  end
34
40
  end
35
41
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Redaction
5
+ REDACTED = "[REDACTED]"
6
+
7
+ TOKEN = Regexp.union(
8
+ /sk-(?:ant-|admin-|proj-|svcacct-|live-|test-)?[A-Za-z0-9_-]{16,}/,
9
+ /AKIA[0-9A-Z]{16}/,
10
+ /gh[opsur]_[A-Za-z0-9]{16,}/,
11
+ /github_pat_[A-Za-z0-9_]{20,}/,
12
+ /eyj[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/i,
13
+ /bearer\s+[A-Za-z0-9_.-]{20,}/i,
14
+ /xox[abprs]-[A-Za-z0-9-]{10,}/,
15
+ /(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{20,}/,
16
+ /AIza[0-9A-Za-z_-]{35}/
17
+ )
18
+ EMBEDDED_TOKEN = /(?<![A-Za-z0-9_-])(?:#{TOKEN})(?![A-Za-z0-9_-])/
19
+ PARAM = /(?<![A-Za-z0-9])((?:api)?key|token|secret|password|sig(?:nature)?)=[^&\s"']+/i
20
+ HEADER = /((?:authorization|api-key)["']?\s*(?:=>|:)\s*["']?)(?:[A-Za-z]+\s+)?[^\s"',;]+/i
21
+ USERINFO = %r{://[^/\s@?#]+@}
22
+ private_constant :TOKEN, :EMBEDDED_TOKEN, :PARAM, :HEADER, :USERINFO
23
+
24
+ def self.text(value)
25
+ value.to_s.scrub
26
+ .gsub(USERINFO, "://#{REDACTED}@")
27
+ .gsub(HEADER, "\\1#{REDACTED}")
28
+ .gsub(PARAM, "\\1=#{REDACTED}")
29
+ .gsub(EMBEDDED_TOKEN, REDACTED)
30
+ end
31
+ end
32
+ end
@@ -34,7 +34,7 @@ module LlmCostTracker
34
34
  end
35
35
  from = now - days.days
36
36
  scope = LlmCostTracker::Call.where(tracked_at: from..now)
37
- tag_breakdowns ||= LlmCostTracker.configuration.tags.report_breakdown_keys || []
37
+ tag_breakdowns ||= LlmCostTracker.configuration.tags.report_breakdown_keys
38
38
  aggregate = totals(scope)
39
39
 
40
40
  new(
@@ -3,8 +3,6 @@
3
3
  module LlmCostTracker
4
4
  module Retention
5
5
  DEFAULT_BATCH_SIZE = 5_000
6
- ROLLUP_COLUMNS = %i[tracked_at total_cost pricing_snapshot provider].freeze
7
- private_constant :ROLLUP_COLUMNS
8
6
 
9
7
  class << self
10
8
  def prune(older_than:, batch_size: DEFAULT_BATCH_SIZE, now: Time.now.utc)
@@ -84,7 +82,7 @@ module LlmCostTracker
84
82
  def prunable_rows(cutoff, batch_size)
85
83
  relation = LlmCostTracker::Call.where(tracked_at: ...cutoff).order(:id).limit(batch_size).lock
86
84
  columns = [:id]
87
- columns += ROLLUP_COLUMNS if LlmCostTracker::Ledger::Rollups.cache_active?
85
+ columns += LlmCostTracker::Ledger::Rollups::SOURCE_COLUMNS if LlmCostTracker::Ledger::Rollups.cache_active?
88
86
  relation.select(*columns).to_a
89
87
  end
90
88
  end
@@ -2,23 +2,13 @@
2
2
 
3
3
  require "active_support/core_ext/string/inflections"
4
4
 
5
+ require_relative "../ledger/storable"
6
+ require_relative "../redaction"
7
+
5
8
  module LlmCostTracker
6
9
  module Tags
7
10
  module Sanitizer
8
- REDACTED_VALUE = "[REDACTED]"
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
11
+ REDACTED_VALUE = Redaction::REDACTED
22
12
 
23
13
  class << self
24
14
  def call(tags, config: LlmCostTracker.configuration)
@@ -58,15 +48,9 @@ module LlmCostTracker
58
48
  def sanitized_value(key, value, redacted, limit)
59
49
  return REDACTED_VALUE if redacted_key?(key, redacted)
60
50
 
61
- scrubbed = scrub_secrets(value)
62
- return REDACTED_VALUE if scrubbed.equal?(REDACTED_SENTINEL)
63
-
64
- scalar_truncate(scrubbed, limit)
51
+ scalar_truncate(scrub_secrets(Ledger::Storable.clean(value)), limit)
65
52
  end
66
53
 
67
- REDACTED_SENTINEL = Object.new.freeze
68
- private_constant :REDACTED_SENTINEL
69
-
70
54
  def scalar_truncate(value, limit)
71
55
  case value
72
56
  when Hash
@@ -86,26 +70,16 @@ module LlmCostTracker
86
70
  def scrub_secrets(value)
87
71
  case value
88
72
  when Hash
89
- value.each_with_object({}) do |(key, nested), out|
90
- scrubbed = scrub_secrets(nested)
91
- out[key] = scrubbed.equal?(REDACTED_SENTINEL) ? REDACTED_VALUE : scrubbed
92
- end
73
+ value.transform_values { |nested| scrub_secrets(nested) }
93
74
  when Array
94
- value.map do |nested|
95
- scrubbed = scrub_secrets(nested)
96
- scrubbed.equal?(REDACTED_SENTINEL) ? REDACTED_VALUE : scrubbed
97
- end
75
+ value.map { |nested| scrub_secrets(nested) }
98
76
  else
99
- secret_shaped?(value.to_s) ? REDACTED_SENTINEL : value
77
+ string = value.to_s
78
+ scrubbed = Redaction.text(string)
79
+ scrubbed == string ? value : scrubbed
100
80
  end
101
81
  end
102
82
 
103
- def secret_shaped?(string)
104
- return false if string.bytesize < 16
105
-
106
- SECRET_VALUE_PATTERNS.any? { |pattern| pattern.match?(string) }
107
- end
108
-
109
83
  def redacted_key?(key, redacted)
110
84
  return false if redacted.empty?
111
85
 
@@ -27,11 +27,6 @@ module LlmCostTracker
27
27
 
28
28
  tags = build_tags(context_tags: context_tags, metadata: metadata)
29
29
 
30
- if calculation.token_cost.nil? && event.token_usage.total_tokens.positive? &&
31
- calculation.priced_line_items.none?(&:priced?)
32
- Pricing::Unknown.process(event.model, pricing_mode: calculation.mode)
33
- end
34
-
35
30
  event = build_event(event: event, calculation: calculation, tags: tags, latency_ms: latency_ms)
36
31
 
37
32
  if Ingestion.async?
@@ -44,20 +39,32 @@ module LlmCostTracker
44
39
  yield if block_given?
45
40
  notify_subscribers(event)
46
41
  behavior_override = :raise if enforce_budget
47
- Budget.check!(event, behavior_override: behavior_override)
48
- Budget.check_persisted!([event], behavior_override: behavior_override) unless Ingestion.async?
42
+ begin
43
+ signal_unpriced(event, calculation)
44
+ ensure
45
+ Budget.check!(event, behavior_override: behavior_override)
46
+ end
49
47
 
50
48
  event
51
49
  end
52
50
 
53
51
  def build_tags(context_tags:, metadata:)
54
52
  resolved = (context_tags || LlmCostTracker::Tags::Context.tags).to_h
55
- sanitized_metadata = LlmCostTracker::Tags::Sanitizer.call(metadata.to_h)
56
- LlmCostTracker::Tags::Sanitizer.cap(resolved.merge(sanitized_metadata)).freeze
53
+ merged = resolved.merge(LlmCostTracker::Tags::Sanitizer.call(metadata.to_h))
54
+ LlmCostTracker::Tags::Sanitizer.cap(merged.to_a.reverse.uniq { |key, _| key.to_s }.reverse.to_h).freeze
57
55
  end
58
56
 
59
57
  private
60
58
 
59
+ def signal_unpriced(event, calculation)
60
+ iterations, lines = calculation.priced_line_items.partition { |line| line.kind == "model_iteration" }
61
+ models = iterations.select(&:unpriced?).map { |line| line.details[:model] }
62
+ if calculation.token_cost.nil? && event.token_usage.total_tokens.positive? && lines.none?(&:priced?)
63
+ models.unshift(event.model)
64
+ end
65
+ models.each { |model| Pricing::Unknown.process(model, pricing_mode: calculation.mode) }
66
+ end
67
+
61
68
  def notify_subscribers(event)
62
69
  return unless ActiveSupport::Notifications.notifier.listening?(EVENT_NAME)
63
70
 
@@ -68,7 +75,7 @@ module LlmCostTracker
68
75
 
69
76
  def build_event(event:, calculation:, tags:, latency_ms:)
70
77
  event.with(
71
- event_id: SecureRandom.uuid,
78
+ event_id: event.event_id || SecureRandom.uuid,
72
79
  pricing_mode: calculation.mode,
73
80
  cost: calculation.cost,
74
81
  tags: tags,
@@ -27,7 +27,7 @@ module LlmCostTracker
27
27
  end
28
28
 
29
29
  def token_priced
30
- @token_priced ||= all.select(&:token_key).freeze
30
+ @token_priced ||= all.select { |dimension| dimension.token_key && dimension.parent.nil? }.freeze
31
31
  end
32
32
 
33
33
  def find_by(kind:, direction:, modality:, cache_state:, unit:)
@@ -35,8 +35,17 @@ module LlmCostTracker
35
35
  end
36
36
 
37
37
  def token_priced_for(kind:, direction:, cache_state:)
38
- token_priced.find do |dimension|
39
- dimension.kind == kind && dimension.direction == direction && dimension.cache_state == cache_state
38
+ dimension = all.find do |candidate|
39
+ candidate.token? && candidate.kind == kind && candidate.direction == direction &&
40
+ candidate.cache_state == cache_state
41
+ end
42
+ dimension&.parent ? fetch(dimension.parent) : dimension
43
+ end
44
+
45
+ def costs_by_component(rows)
46
+ rows.each_with_object({}) do |(kind, direction, cache_state, cost), totals|
47
+ component = token_priced_for(kind: kind, direction: direction, cache_state: cache_state)
48
+ totals[component.key] = totals.fetch(component.key, 0) + cost if component && cost
40
49
  end
41
50
  end
42
51
 
@@ -60,7 +69,7 @@ module LlmCostTracker
60
69
 
61
70
  def build(attributes)
62
71
  rate_basis = attributes[:rate_basis] || DEFAULT_RATE_BASIS_BY_UNIT.fetch(attributes.fetch(:unit))
63
- Dimension.new(**attributes, rate_basis: rate_basis)
72
+ Dimension.new(parent: nil, **attributes, rate_basis: rate_basis)
64
73
  end
65
74
  end
66
75
  end
@@ -3,7 +3,7 @@
3
3
  module LlmCostTracker
4
4
  module Usage
5
5
  Dimension = Data.define(
6
- :key, :kind, :direction, :modality, :cache_state, :unit, :rate_basis
6
+ :key, :kind, :direction, :modality, :cache_state, :unit, :rate_basis, :parent
7
7
  ) do
8
8
  def token?
9
9
  unit == "token"
@@ -61,6 +61,30 @@
61
61
  cache_state: none
62
62
  unit: token
63
63
 
64
+ - key: audio_cache_read_input
65
+ kind: audio_token
66
+ direction: input
67
+ modality: audio
68
+ cache_state: read
69
+ unit: token
70
+ parent: cache_read_input
71
+
72
+ - key: image_cache_read_input
73
+ kind: image_token
74
+ direction: input
75
+ modality: image
76
+ cache_state: read
77
+ unit: token
78
+ parent: cache_read_input
79
+
80
+ - key: video_input
81
+ kind: video_token
82
+ direction: input
83
+ modality: video
84
+ cache_state: none
85
+ unit: token
86
+ parent: input
87
+
64
88
  - key: web_search_request
65
89
  kind: web_search_request
66
90
  direction: neither
@@ -101,6 +125,13 @@
101
125
  unit: request
102
126
  rate_basis: per_1k_requests
103
127
 
128
+ - key: image_generation_call
129
+ kind: image_generation_call
130
+ direction: output
131
+ modality: image
132
+ cache_state: none
133
+ unit: request
134
+
104
135
  - key: container_session
105
136
  kind: container_session
106
137
  direction: neither
@@ -125,6 +156,22 @@
125
156
  unit: request
126
157
  rate_basis: per_1k_requests
127
158
 
159
+ - key: maps_grounding_request
160
+ kind: maps_grounding_request
161
+ direction: neither
162
+ modality: text
163
+ cache_state: none
164
+ unit: request
165
+ rate_basis: per_1k_requests
166
+
167
+ - key: cache_storage_token_hour
168
+ kind: cache_storage_token_hour
169
+ direction: neither
170
+ modality: none
171
+ cache_state: none
172
+ unit: token_hour
173
+ rate_basis: per_million_token_hours
174
+
128
175
  - key: text_to_speech_character
129
176
  kind: text_to_speech_character
130
177
  direction: output
@@ -140,3 +187,17 @@
140
187
  cache_state: none
141
188
  unit: minute
142
189
  rate_basis: per_minute
190
+
191
+ - key: billed_request
192
+ kind: billed_request
193
+ direction: neither
194
+ modality: none
195
+ cache_state: none
196
+ unit: request
197
+
198
+ - key: model_iteration
199
+ kind: model_iteration
200
+ direction: neither
201
+ modality: none
202
+ cache_state: none
203
+ unit: request
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.14.0"
4
+ VERSION = "0.14.2"
5
5
  end
@@ -57,6 +57,7 @@ module LlmCostTracker
57
57
  yield(config)
58
58
  config.finalize!
59
59
  Pricing::Registry.reset!
60
+ Pricing::Registry.validate_file!(config.pricing.file)
60
61
  Integrations.install!
61
62
  config
62
63
  end
@@ -128,10 +129,11 @@ module LlmCostTracker
128
129
  metadata: tags
129
130
  )
130
131
  yield collector
131
- collector.finish!
132
- rescue StandardError
132
+ rescue Exception # rubocop:disable Lint/RescueException -- record the spend and keep the caller's exception
133
133
  collector&.finish!(errored: true)
134
134
  raise
135
+ else
136
+ collector.finish!
135
137
  end
136
138
  end
137
139
  end