llm_cost_tracker 0.13.0 → 0.14.1

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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -16
  3. data/README.md +17 -29
  4. data/app/controllers/llm_cost_tracker/application_controller.rb +19 -5
  5. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -0
  6. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  7. data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
  8. data/app/controllers/llm_cost_tracker/tags_controller.rb +5 -4
  9. data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
  10. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +16 -0
  11. data/app/models/llm_cost_tracker/call.rb +10 -4
  12. data/app/models/llm_cost_tracker/call_rollup.rb +19 -3
  13. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +13 -0
  14. data/app/services/llm_cost_tracker/dashboard/filter.rb +24 -21
  15. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +1 -1
  16. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -1
  17. data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
  18. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +3 -3
  19. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +5 -3
  20. data/app/views/llm_cost_tracker/calls/show.html.erb +6 -8
  21. data/app/views/llm_cost_tracker/data_quality/index.html.erb +11 -0
  22. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -3
  23. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -3
  24. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -3
  25. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -3
  26. data/app/views/llm_cost_tracker/tags/show.html.erb +3 -0
  27. data/config/routes.rb +6 -1
  28. data/lib/llm_cost_tracker/budget/per_tag.rb +163 -0
  29. data/lib/llm_cost_tracker/budget.rb +120 -32
  30. data/lib/llm_cost_tracker/capture/event_window.rb +99 -0
  31. data/lib/llm_cost_tracker/capture/sse.rb +108 -32
  32. data/lib/llm_cost_tracker/capture/stream_collector.rb +17 -56
  33. data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
  34. data/lib/llm_cost_tracker/capture/stream_tracker.rb +5 -35
  35. data/lib/llm_cost_tracker/charges/cost_status.rb +4 -3
  36. data/lib/llm_cost_tracker/configuration/budgets.rb +93 -0
  37. data/lib/llm_cost_tracker/configuration/capture.rb +42 -0
  38. data/lib/llm_cost_tracker/configuration/ingestion.rb +20 -0
  39. data/lib/llm_cost_tracker/configuration/mutability.rb +33 -0
  40. data/lib/llm_cost_tracker/configuration/pricing.rb +36 -0
  41. data/lib/llm_cost_tracker/configuration/section.rb +59 -0
  42. data/lib/llm_cost_tracker/configuration/tags.rb +52 -0
  43. data/lib/llm_cost_tracker/configuration.rb +69 -125
  44. data/lib/llm_cost_tracker/deprecator.rb +9 -0
  45. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +17 -8
  46. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -1
  47. data/lib/llm_cost_tracker/doctor.rb +4 -4
  48. data/lib/llm_cost_tracker/engine.rb +4 -0
  49. data/lib/llm_cost_tracker/errors.rb +25 -3
  50. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +2 -2
  51. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +2 -2
  52. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +0 -1
  53. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +8 -4
  54. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +50 -30
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +40 -0
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +41 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +30 -0
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +30 -0
  59. data/lib/llm_cost_tracker/ingestion/batch.rb +38 -7
  60. data/lib/llm_cost_tracker/ingestion/pool.rb +9 -2
  61. data/lib/llm_cost_tracker/ingestion.rb +3 -7
  62. data/lib/llm_cost_tracker/integrations/anthropic.rb +7 -1
  63. data/lib/llm_cost_tracker/integrations/base.rb +17 -1
  64. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +16 -13
  65. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +53 -18
  66. data/lib/llm_cost_tracker/ledger/isolation.rb +30 -0
  67. data/lib/llm_cost_tracker/ledger/period/totals.rb +3 -2
  68. data/lib/llm_cost_tracker/ledger/rollups.rb +45 -7
  69. data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
  70. data/lib/llm_cost_tracker/ledger/store.rb +21 -13
  71. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +15 -5
  72. data/lib/llm_cost_tracker/ledger.rb +1 -0
  73. data/lib/llm_cost_tracker/logging.rb +5 -5
  74. data/lib/llm_cost_tracker/middleware/faraday.rb +33 -44
  75. data/lib/llm_cost_tracker/parsers.rb +5 -1
  76. data/lib/llm_cost_tracker/prices.json +2089 -372
  77. data/lib/llm_cost_tracker/pricing/backfill.rb +11 -1
  78. data/lib/llm_cost_tracker/pricing/calculation.rb +4 -4
  79. data/lib/llm_cost_tracker/pricing/effective_prices.rb +26 -15
  80. data/lib/llm_cost_tracker/pricing/matcher.rb +7 -0
  81. data/lib/llm_cost_tracker/pricing/rate.rb +1 -2
  82. data/lib/llm_cost_tracker/pricing/registry.rb +17 -6
  83. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
  84. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
  85. data/lib/llm_cost_tracker/pricing/sync.rb +31 -14
  86. data/lib/llm_cost_tracker/pricing/unknown.rb +11 -8
  87. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +3 -2
  88. data/lib/llm_cost_tracker/providers/azure/parser.rb +19 -0
  89. data/lib/llm_cost_tracker/providers/gemini/parser.rb +4 -0
  90. data/lib/llm_cost_tracker/providers/openai/model_families.rb +0 -7
  91. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +10 -3
  92. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +14 -8
  93. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +6 -2
  94. data/lib/llm_cost_tracker/railtie.rb +3 -7
  95. data/lib/llm_cost_tracker/redaction.rb +32 -0
  96. data/lib/llm_cost_tracker/report/data.rb +2 -2
  97. data/lib/llm_cost_tracker/retention.rb +22 -10
  98. data/lib/llm_cost_tracker/tags/context.rb +3 -3
  99. data/lib/llm_cost_tracker/tags/sanitizer.rb +14 -40
  100. data/lib/llm_cost_tracker/tracker.rb +16 -26
  101. data/lib/llm_cost_tracker/usage/catalog.rb +1 -2
  102. data/lib/llm_cost_tracker/version.rb +1 -1
  103. data/lib/llm_cost_tracker.rb +10 -3
  104. data/lib/tasks/llm_cost_tracker.rake +29 -14
  105. metadata +37 -12
@@ -2,10 +2,10 @@
2
2
 
3
3
  require "faraday"
4
4
  require "json"
5
- require "stringio"
6
5
  require "uri"
7
6
 
8
7
  require_relative "../capture/sse"
8
+ require_relative "../capture/stream_tap"
9
9
  require_relative "../timing"
10
10
 
11
11
  module LlmCostTracker
@@ -27,16 +27,17 @@ module LlmCostTracker
27
27
  if streaming
28
28
  request_body = inject_stream_usage_flag(request_env, parser, request_url, request_parsed) || request_body
29
29
  end
30
- stream_buffer = install_stream_tap(request_env) if streaming
30
+ stream_buffer = install_stream_tap(request_env, parser) if streaming
31
31
 
32
32
  if parser
33
+ context_tags, metadata = tag_snapshot(request_env)
33
34
  Budget.enforce!(
34
35
  provider: parser.provider_for(request_url),
35
36
  model: parser.model_for(request_url, request_parsed),
36
- request: request_parsed
37
+ request: request_parsed,
38
+ tags: Tracker.build_tags(context_tags: context_tags, metadata: metadata)
37
39
  )
38
40
  end
39
- context_tags, metadata = tag_snapshot(request_env) if parser
40
41
  started_at = LlmCostTracker::Timing.now_monotonic
41
42
 
42
43
  invoke_app_with_capture(
@@ -94,8 +95,8 @@ module LlmCostTracker
94
95
  end
95
96
 
96
97
  def inject_stream_usage_flag(request_env, parser, request_url, request_parsed)
97
- return nil unless LlmCostTracker.configuration.auto_enable_stream_usage
98
- return nil unless parser&.auto_enable_stream_usage?(request_url)
98
+ return nil unless LlmCostTracker.configuration.capture.request_stream_usage
99
+ return nil unless parser.auto_enable_stream_usage?(request_url, request_parsed)
99
100
 
100
101
  stream_options = request_parsed["stream_options"]
101
102
  return nil if stream_options.is_a?(Hash) && stream_options.key?("include_usage")
@@ -116,21 +117,22 @@ module LlmCostTracker
116
117
  request = parser.safe_json_parse(request_body)
117
118
  event = Event.build(
118
119
  provider: parser.provider_for(request_url),
119
- model: request["model"] || Event::UNKNOWN_MODEL,
120
+ model: parser.model_for(request_url, request) || Event::UNKNOWN_MODEL,
120
121
  token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
121
122
  stream: true,
122
123
  usage_source: Usage::Source::UNKNOWN
123
124
  )
124
- merged_metadata = (metadata || {}).merge(
125
- stream_interrupted: true,
126
- stream_interrupted_error: "#{error.class}: #{error.message}"
127
- )
125
+ merged_metadata = (metadata || {}).merge(stream_interrupted: true, stream_interrupted_error: error.class.name)
126
+ status = error.try(:response_status)
127
+ merged_metadata[:stream_interrupted_status] = status if status
128
128
  Tracker.record(
129
129
  event: event,
130
130
  latency_ms: latency_ms,
131
131
  metadata: merged_metadata,
132
132
  context_tags: context_tags
133
133
  )
134
+ rescue LlmCostTracker::TransactionAbortedError
135
+ raise
134
136
  rescue StandardError => e
135
137
  Logging.warn("Error recording interrupted stream: #{e.class}: #{e.message}")
136
138
  end
@@ -171,7 +173,7 @@ module LlmCostTracker
171
173
  metadata: metadata,
172
174
  context_tags: context_tags
173
175
  )
174
- rescue LlmCostTracker::Error
176
+ rescue *LlmCostTracker::CALLER_ERRORS
175
177
  raise
176
178
  rescue StandardError => e
177
179
  Logging.warn("Error processing response: #{e.class}: #{e.message}")
@@ -198,32 +200,28 @@ module LlmCostTracker
198
200
  end
199
201
 
200
202
  def parse_stream(parser:, request_url:, request_body:, response_env:, stream_buffer:)
201
- overflowed = stream_buffer&.dig(:overflowed) == true
202
- Logging.warn(capture_warning(request_url, stream_buffer)) if overflowed
203
-
204
- body = stream_buffer&.dig(:buffer)&.string
205
- body = read_body(response_env.body) if body.blank?
206
-
207
- if body.blank?
208
- Logging.warn(capture_warning(request_url, stream_buffer)) unless overflowed
209
- return parser.parse_stream(
210
- request_url: request_url,
211
- request_body: request_body,
212
- response_status: response_env.status,
213
- response_headers: response_env.response_headers
214
- )
215
- end
216
-
217
- events = overflowed ? [] : Capture::SSE.parse(body)
218
203
  parser.parse_stream(
219
204
  request_url: request_url,
220
205
  request_body: request_body,
221
206
  response_status: response_env.status,
222
- events: events,
207
+ events: stream_events(request_url, response_env, stream_buffer),
223
208
  response_headers: response_env.response_headers
224
209
  )
225
210
  end
226
211
 
212
+ def stream_events(request_url, response_env, stream_buffer)
213
+ if stream_buffer&.received?
214
+ events = stream_buffer.events
215
+ return events unless stream_buffer.overflowed? || stream_buffer.failed?
216
+ else
217
+ body = read_body(response_env.body)
218
+ return Capture::SSE.parse(body) if body.present?
219
+ end
220
+
221
+ Logging.warn(capture_warning(request_url, stream_buffer))
222
+ []
223
+ end
224
+
227
225
  def forward_on_data_chunk(callable, chunk, size, env)
228
226
  arity = callable.arity
229
227
  return callable.call(chunk, size, env) if arity.negative?
@@ -235,28 +233,19 @@ module LlmCostTracker
235
233
  end
236
234
  end
237
235
 
238
- def install_stream_tap(request_env)
236
+ def install_stream_tap(request_env, parser)
239
237
  request = request_env.request
240
238
  return nil unless request
241
239
 
242
240
  original = request.on_data
243
241
  return nil unless original
244
242
 
245
- state = { buffer: StringIO.new, bytes: 0, overflowed: false }
243
+ tap = Capture::StreamTap.new(notable: parser.method(:retain_stream_event?))
246
244
  request.on_data = proc do |chunk, size, env|
247
- chunk = chunk.to_s
248
- remaining = Capture::SSE::LIMIT_BYTES - state[:bytes]
249
- if chunk.bytesize <= remaining
250
- state[:buffer] << chunk
251
- state[:bytes] += chunk.bytesize
252
- else
253
- state[:buffer] << chunk.byteslice(0, remaining) if remaining.positive?
254
- state[:bytes] += [remaining, 0].max
255
- state[:overflowed] = true
256
- end
245
+ tap << chunk
257
246
  forward_on_data_chunk(original, chunk, size, env)
258
247
  end
259
- state
248
+ tap
260
249
  rescue StandardError => e
261
250
  Logging.warn("Unable to install streaming tap: #{e.class}: #{e.message}")
262
251
  nil
@@ -295,7 +284,7 @@ module LlmCostTracker
295
284
  suffix = "recording usage_source=#{Usage::Source::UNKNOWN}. " \
296
285
  "Use LlmCostTracker.track_stream for manual capture."
297
286
  label = request_url_label(request_url)
298
- return "Unable to capture streaming response for #{label}; #{suffix}" unless stream_buffer&.dig(:overflowed)
287
+ return "Unable to capture streaming response for #{label}; #{suffix}" unless stream_buffer&.overflowed?
299
288
 
300
289
  "Streaming response for #{label} exceeded #{Capture::SSE::LIMIT_BYTES} bytes; #{suffix}"
301
290
  end
@@ -104,7 +104,11 @@ module LlmCostTracker
104
104
  nil
105
105
  end
106
106
 
107
- def auto_enable_stream_usage?(_request_url)
107
+ def retain_stream_event?(_data)
108
+ false
109
+ end
110
+
111
+ def auto_enable_stream_usage?(_request_url, _request_parsed)
108
112
  false
109
113
  end
110
114