llm_cost_tracker 0.10.0 → 0.11.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/README.md +4 -1
  4. data/app/assets/llm_cost_tracker/application.css +782 -801
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +14 -2
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +23 -16
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +0 -3
  8. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  9. data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
  10. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +13 -19
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +15 -3
  13. data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
  14. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
  15. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
  16. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +27 -33
  17. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  18. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
  19. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  20. data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
  21. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  22. data/app/views/llm_cost_tracker/calls/show.html.erb +118 -119
  23. data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
  24. data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
  25. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  26. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  27. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  28. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +49 -58
  29. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  30. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  31. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  32. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  33. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  34. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  35. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  36. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  37. data/app/views/llm_cost_tracker/tags/show.html.erb +83 -102
  38. data/config/routes.rb +1 -0
  39. data/lib/llm_cost_tracker/budget.rb +1 -2
  40. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
  41. data/lib/llm_cost_tracker/capture/stream_collector.rb +2 -16
  42. data/lib/llm_cost_tracker/capture/stream_tracker.rb +1 -5
  43. data/lib/llm_cost_tracker/configuration.rb +3 -20
  44. data/lib/llm_cost_tracker/doctor.rb +74 -8
  45. data/lib/llm_cost_tracker/event.rb +2 -2
  46. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +4 -0
  47. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +4 -0
  48. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +2 -0
  49. data/lib/llm_cost_tracker/ingestion/inbox.rb +1 -1
  50. data/lib/llm_cost_tracker/integrations/anthropic.rb +24 -32
  51. data/lib/llm_cost_tracker/integrations/openai.rb +39 -93
  52. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +19 -63
  53. data/lib/llm_cost_tracker/ledger.rb +13 -0
  54. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -1
  55. data/lib/llm_cost_tracker/parsers/anthropic.rb +14 -31
  56. data/lib/llm_cost_tracker/parsers/gemini.rb +3 -5
  57. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +1 -7
  58. data/lib/llm_cost_tracker/parsers/openai_usage.rb +3 -3
  59. data/lib/llm_cost_tracker/parsers.rb +0 -2
  60. data/lib/llm_cost_tracker/pricing/explainer.rb +1 -1
  61. data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
  62. data/lib/llm_cost_tracker/pricing/service_charges.rb +1 -1
  63. data/lib/llm_cost_tracker/pricing.rb +1 -18
  64. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
  65. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
  66. data/lib/llm_cost_tracker/railtie.rb +0 -4
  67. data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
  68. data/lib/llm_cost_tracker/reconciliation/importer.rb +2 -7
  69. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +6 -30
  70. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
  71. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
  72. data/lib/llm_cost_tracker/report/formatter.rb +32 -19
  73. data/lib/llm_cost_tracker/tracker.rb +1 -1
  74. data/lib/llm_cost_tracker/version.rb +1 -1
  75. metadata +14 -6
  76. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  77. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  78. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  79. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -155
@@ -2,9 +2,9 @@
2
2
 
3
3
  require_relative "base"
4
4
  require_relative "../billing/line_item"
5
- require_relative "../parsers/openai_service_charges"
6
5
  require_relative "../providers/azure/hosts"
7
6
  require_relative "../providers/openai/model_families"
7
+ require_relative "../providers/openai/service_charges"
8
8
 
9
9
  module LlmCostTracker
10
10
  module Integrations
@@ -245,7 +245,7 @@ module LlmCostTracker
245
245
  output_items << chat_search if chat_search
246
246
  return [] if output_items.empty?
247
247
 
248
- LlmCostTracker::Parsers::OpenaiServiceCharges.line_items_from_output(
248
+ LlmCostTracker::Providers::Openai::ServiceCharges.line_items_from_output(
249
249
  output_items, request: request, model: model
250
250
  )
251
251
  end
@@ -255,9 +255,9 @@ module LlmCostTracker
255
255
  return nil unless choices.respond_to?(:any?)
256
256
 
257
257
  provider_field = if choices.any? { |choice| choice_used_url_citation?(choice) }
258
- LlmCostTracker::Parsers::OpenaiServiceCharges::CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD
258
+ LlmCostTracker::Providers::Openai::ServiceCharges::CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD
259
259
  elsif LlmCostTracker::Providers::Openai::ModelFamilies.chat_completions_search?(model)
260
- LlmCostTracker::Parsers::OpenaiServiceCharges::CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD
260
+ LlmCostTracker::Providers::Openai::ServiceCharges::CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD
261
261
  end
262
262
  return nil unless provider_field
263
263
 
@@ -355,79 +355,22 @@ module LlmCostTracker
355
355
  end
356
356
  end
357
357
 
358
- module ResponsesPatch
359
- def create(*args, **kwargs)
360
- request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
361
- LlmCostTracker::Integrations::Openai.enforce_budget!(request: request)
362
- started_at = LlmCostTracker::Timing.now_monotonic
363
- response = super(*LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
364
- LlmCostTracker::Integrations::Openai.record_response(
365
- response,
366
- request: request,
367
- latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
368
- host: LlmCostTracker::Integrations::Openai.client_host_for(self)
369
- )
370
- response
371
- end
372
-
373
- def stream(*args, **kwargs)
374
- LlmCostTracker::Integrations::Openai.wrap_stream_call(args, kwargs, self) do |normalized, _|
375
- super(*normalized)
376
- end
377
- end
378
-
379
- def stream_raw(*args, **kwargs)
380
- LlmCostTracker::Integrations::Openai.wrap_stream_call(args, kwargs, self) do |normalized, _|
381
- super(*normalized)
382
- end
383
- end
384
-
385
- def retrieve_streaming(response_id, *args, **kwargs)
386
- LlmCostTracker::Integrations::Openai.wrap_stream_call(args, kwargs, self) do |normalized, collector|
387
- collector.provider_response_id = response_id
388
- super(response_id, *normalized)
389
- end
390
- end
391
- end
392
-
393
- module ChatCompletionsPatch
394
- def create(*args, **kwargs)
395
- request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
396
- LlmCostTracker::Integrations::Openai.enforce_budget!(request: request)
397
- started_at = LlmCostTracker::Timing.now_monotonic
398
- response = super(*LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
399
- LlmCostTracker::Integrations::Openai.record_response(
400
- response,
401
- request: request,
402
- latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
403
- host: LlmCostTracker::Integrations::Openai.client_host_for(self)
404
- )
405
- response
406
- end
407
-
408
- def stream(*args, **kwargs)
409
- LlmCostTracker::Integrations::Openai.wrap_stream_call(args, kwargs, self) do |normalized, _|
410
- super(*normalized)
411
- end
412
- end
413
-
414
- def stream_raw(*args, **kwargs)
415
- LlmCostTracker::Integrations::Openai.wrap_stream_call(args, kwargs, self) do |normalized, _|
416
- super(*normalized)
417
- end
418
- end
419
- end
420
-
421
358
  module PatchBuilder
422
359
  module_function
423
360
 
424
361
  def build(record_method:, methods:)
425
362
  Module.new.tap do |mod|
426
- methods.each { |method_name| define_wrapped_method(mod, method_name, record_method) }
363
+ methods.each { |method_name| define_blocking_method(mod, method_name, record_method) }
427
364
  end
428
365
  end
429
366
 
430
- def define_wrapped_method(mod, method_name, record_method)
367
+ def build_stream(methods:)
368
+ Module.new.tap do |mod|
369
+ methods.each { |method_name| define_stream_method(mod, method_name) }
370
+ end
371
+ end
372
+
373
+ def define_blocking_method(mod, method_name, record_method)
431
374
  mod.define_method(method_name) do |*args, **kwargs, &block|
432
375
  integration = LlmCostTracker::Integrations::Openai
433
376
  request = integration.request_params(args, kwargs)
@@ -443,38 +386,41 @@ module LlmCostTracker
443
386
  response
444
387
  end
445
388
  end
389
+
390
+ def define_stream_method(mod, method_name)
391
+ mod.define_method(method_name) do |*args, **kwargs|
392
+ LlmCostTracker::Integrations::Openai.wrap_stream_call(args, kwargs, self) do |normalized, _|
393
+ super(*normalized)
394
+ end
395
+ end
396
+ end
446
397
  end
447
398
 
448
- EmbeddingsPatch = PatchBuilder.build(record_method: :record_response, methods: %i[create])
449
- ImagesPatch = PatchBuilder.build(record_method: :record_image, methods: %i[generate edit create_variation])
450
- TranscriptionsPatch = PatchBuilder.build(record_method: :record_transcription, methods: %i[create])
451
- TranslationsPatch = PatchBuilder.build(record_method: :record_transcription, methods: %i[create])
452
- SpeechPatch = PatchBuilder.build(record_method: :record_speech, methods: %i[create])
453
- ModerationsPatch = PatchBuilder.build(record_method: :record_moderation, methods: %i[create])
399
+ module ResponsesPatch
400
+ include PatchBuilder.build(record_method: :record_response, methods: %i[create])
401
+ include PatchBuilder.build_stream(methods: %i[stream stream_raw])
454
402
 
455
- module StreamingImagesPatch
456
- %i[generate_stream_raw edit_stream_raw].each do |method_name|
457
- define_method(method_name) do |*args, **kwargs|
458
- request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
459
- LlmCostTracker::Integrations::Openai.enforce_budget!(request: request)
460
- host = LlmCostTracker::Integrations::Openai.client_host_for(self)
461
- collector = LlmCostTracker::Integrations::Openai.stream_collector(request, host: host)
462
- stream = super(*LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
463
- LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
403
+ def retrieve_streaming(response_id, *args, **kwargs)
404
+ LlmCostTracker::Integrations::Openai.wrap_stream_call(args, kwargs, self) do |normalized, collector|
405
+ collector.provider_response_id = response_id
406
+ super(response_id, *normalized)
464
407
  end
465
408
  end
466
409
  end
467
410
 
468
- module StreamingTranscriptionsPatch
469
- def create_streaming(*args, **kwargs)
470
- request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
471
- LlmCostTracker::Integrations::Openai.enforce_budget!(request: request)
472
- host = LlmCostTracker::Integrations::Openai.client_host_for(self)
473
- collector = LlmCostTracker::Integrations::Openai.stream_collector(request, host: host)
474
- stream = super(*LlmCostTracker::Integrations::Openai.normalize_sdk_args(args, kwargs))
475
- LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
476
- end
411
+ module ChatCompletionsPatch
412
+ include PatchBuilder.build(record_method: :record_response, methods: %i[create])
413
+ include PatchBuilder.build_stream(methods: %i[stream stream_raw])
477
414
  end
415
+
416
+ EmbeddingsPatch = PatchBuilder.build(record_method: :record_response, methods: %i[create])
417
+ ImagesPatch = PatchBuilder.build(record_method: :record_image, methods: %i[generate edit create_variation])
418
+ TranscriptionsPatch = PatchBuilder.build(record_method: :record_transcription, methods: %i[create])
419
+ TranslationsPatch = PatchBuilder.build(record_method: :record_transcription, methods: %i[create])
420
+ SpeechPatch = PatchBuilder.build(record_method: :record_speech, methods: %i[create])
421
+ ModerationsPatch = PatchBuilder.build(record_method: :record_moderation, methods: %i[create])
422
+ StreamingImagesPatch = PatchBuilder.build_stream(methods: %i[generate_stream_raw edit_stream_raw])
423
+ StreamingTranscriptionsPatch = PatchBuilder.build_stream(methods: %i[create_streaming])
478
424
  end
479
425
  end
480
426
  end
@@ -31,20 +31,16 @@ module LlmCostTracker
31
31
  ]
32
32
  end
33
33
 
34
- def record_completion(provider, response, request:, latency_ms:, stream:)
34
+ def record_completion(provider, response, request:, latency_ms:, has_block:)
35
35
  record_usage(
36
36
  provider: provider_slug(provider),
37
37
  model: response_model_id(response) || model_id(request[:model]),
38
38
  response: response,
39
39
  latency_ms: latency_ms,
40
- stream: stream
40
+ stream: has_block || request[:stream] == true
41
41
  )
42
42
  end
43
43
 
44
- def streaming_request?(request, has_block:)
45
- has_block || request[:stream] == true
46
- end
47
-
48
44
  def record_embedding(provider, response, request:, latency_ms:)
49
45
  record_usage(
50
46
  provider: provider_slug(provider),
@@ -198,77 +194,37 @@ module LlmCostTracker
198
194
 
199
195
  module ProviderPatch
200
196
  def complete(*args, **kwargs, &)
201
- integration = LlmCostTracker::Integrations::RubyLlm
202
- request = integration.request_params(args, kwargs)
203
- integration.enforce_budget!(request: request)
204
- started_at = LlmCostTracker::Timing.now_monotonic
205
- response = super
206
- integration.record_completion(
207
- self,
208
- response,
209
- request: request,
210
- latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
211
- stream: integration.streaming_request?(request, has_block: block_given?)
212
- )
213
- response
197
+ measure(args, kwargs, recorder: :record_completion, has_block: block_given?) { super }
214
198
  end
215
199
 
216
200
  def embed(*args, **kwargs)
217
- integration = LlmCostTracker::Integrations::RubyLlm
218
- request = integration.request_params(args, kwargs)
219
- integration.enforce_budget!(request: request)
220
- started_at = LlmCostTracker::Timing.now_monotonic
221
- response = super
222
- integration.record_embedding(
223
- self,
224
- response,
225
- request: request,
226
- latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at)
227
- )
228
- response
201
+ measure(args, kwargs, recorder: :record_embedding) { super }
229
202
  end
230
203
 
231
204
  def transcribe(*args, **kwargs)
232
- integration = LlmCostTracker::Integrations::RubyLlm
233
- request = integration.request_params(args, kwargs)
234
- integration.enforce_budget!(request: request)
235
- started_at = LlmCostTracker::Timing.now_monotonic
236
- response = super
237
- integration.record_transcription(
238
- self,
239
- response,
240
- request: request,
241
- latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at)
242
- )
243
- response
205
+ measure(args, kwargs, recorder: :record_transcription) { super }
244
206
  end
245
207
 
246
208
  def paint(*args, **kwargs)
247
- integration = LlmCostTracker::Integrations::RubyLlm
248
- request = integration.request_params(args, kwargs)
249
- integration.enforce_budget!(request: request)
250
- started_at = LlmCostTracker::Timing.now_monotonic
251
- response = super
252
- integration.record_image(
253
- self,
254
- response,
255
- request: request,
256
- latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at)
257
- )
258
- response
209
+ measure(args, kwargs, recorder: :record_image) { super }
259
210
  end
260
211
 
261
212
  def moderate(*args, **kwargs)
262
- integration = LlmCostTracker::Integrations::RubyLlm
263
- request = integration.request_params(args, kwargs)
264
- integration.enforce_budget!(request: request)
213
+ measure(args, kwargs, recorder: :record_moderation) { super }
214
+ end
215
+
216
+ private
217
+
218
+ def measure(args, kwargs, recorder:, **extras)
219
+ request = RubyLlm.request_params(args, kwargs)
220
+ RubyLlm.enforce_budget!(request: request)
265
221
  started_at = LlmCostTracker::Timing.now_monotonic
266
- response = super
267
- integration.record_moderation(
268
- self,
269
- response,
222
+ response = yield
223
+ RubyLlm.public_send(
224
+ recorder, self, response,
270
225
  request: request,
271
- latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at)
226
+ latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
227
+ **extras
272
228
  )
273
229
  response
274
230
  end
@@ -14,3 +14,16 @@ require_relative "ledger/rollups/upsert_sql"
14
14
  require_relative "ledger/rollups"
15
15
  require_relative "ledger/store"
16
16
  require_relative "ledger/period/totals"
17
+
18
+ module LlmCostTracker
19
+ module Ledger
20
+ module Schema
21
+ CORE_SCHEMAS = [
22
+ [Calls, "llm_cost_tracker_calls"],
23
+ [CallLineItems, "llm_cost_tracker_call_line_items"],
24
+ [CallTags, "llm_cost_tracker_call_tags"]
25
+ ].freeze
26
+ CACHE_ROLLUPS_SCHEMA = [CallRollups, "llm_cost_tracker_call_rollups"].freeze
27
+ end
28
+ end
29
+ end
@@ -6,6 +6,7 @@ require "stringio"
6
6
  require "uri"
7
7
 
8
8
  require_relative "../logging"
9
+ require_relative "../capture/sse"
9
10
  require_relative "../capture/stream"
10
11
  require_relative "../timing"
11
12
 
@@ -201,7 +202,7 @@ module LlmCostTracker
201
202
  )
202
203
  end
203
204
 
204
- events = overflowed ? [] : Parsers::SSE.parse(body)
205
+ events = overflowed ? [] : Capture::SSE.parse(body)
205
206
  parser.parse_stream(
206
207
  request_url: request_url,
207
208
  request_body: request_body,
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base"
4
+ require_relative "../providers/anthropic/server_tools"
4
5
  require_relative "../providers/anthropic/tier_classification"
5
6
 
6
7
  module LlmCostTracker
@@ -104,36 +105,18 @@ module LlmCostTracker
104
105
  server_tool_use = usage["server_tool_use"]
105
106
  return [] unless server_tool_use.is_a?(Hash)
106
107
 
107
- [
108
- service_line_item(
109
- component_key: :web_search_request,
110
- quantity: server_tool_use["web_search_requests"],
111
- provider_field: "usage.server_tool_use.web_search_requests"
112
- ),
113
- service_line_item(
114
- component_key: :web_fetch_request,
115
- quantity: server_tool_use["web_fetch_requests"],
116
- provider_field: "usage.server_tool_use.web_fetch_requests"
117
- ),
118
- service_line_item(
119
- component_key: :code_execution_request,
120
- quantity: server_tool_use["code_execution_requests"],
121
- provider_field: "usage.server_tool_use.code_execution_requests"
122
- )
123
- ].compact
124
- end
125
-
126
- def service_line_item(component_key:, quantity:, provider_field:)
127
- quantity = quantity.to_i
128
- return if quantity.zero?
108
+ Providers::Anthropic::ServerTools::LINE_ITEMS.filter_map do |component_key, count_key|
109
+ quantity = server_tool_use[count_key.to_s].to_i
110
+ next if quantity.zero?
129
111
 
130
- Billing::LineItem.build(
131
- component_key: component_key,
132
- quantity: quantity,
133
- cost_status: Billing::CostStatus::UNKNOWN,
134
- pricing_basis: :provider_usage,
135
- provider_field: provider_field
136
- )
112
+ Billing::LineItem.build(
113
+ component_key: component_key,
114
+ quantity: quantity,
115
+ cost_status: Billing::CostStatus::UNKNOWN,
116
+ pricing_basis: :provider_usage,
117
+ provider_field: "usage.server_tool_use.#{count_key}"
118
+ )
119
+ end
137
120
  end
138
121
 
139
122
  def token_usage(usage:, cache_read:)
@@ -176,8 +159,8 @@ module LlmCostTracker
176
159
  service_tier = usage&.fetch("service_tier", nil) || request["service_tier"]
177
160
  service_tier = nil if Providers::Anthropic::TierClassification.standard_equivalent_tier?(service_tier)
178
161
 
179
- modes << Pricing.normalize_mode(speed)
180
- modes << Pricing.normalize_mode(service_tier)
162
+ modes << Pricing::Mode.normalize(speed)
163
+ modes << Pricing::Mode.normalize(service_tier)
181
164
  geo = inference_geo(request: request, usage: usage).downcase
182
165
  modes << "data_residency" if Providers::Anthropic::TierClassification.data_residency_geo?(geo)
183
166
 
@@ -152,9 +152,7 @@ module LlmCostTracker
152
152
 
153
153
  def modality_tokens(details, modality)
154
154
  Array(details).sum do |detail|
155
- next 0 unless detail.is_a?(Hash)
156
-
157
- next 0 unless detail["modality"] == modality
155
+ next 0 unless detail.is_a?(Hash) && detail["modality"] == modality
158
156
 
159
157
  (detail["tokenCount"] || detail["token_count"]).to_i
160
158
  end
@@ -174,10 +172,10 @@ module LlmCostTracker
174
172
 
175
173
  def pricing_mode(request:, response_headers:)
176
174
  response_tier = response_header(response_headers, "x-gemini-service-tier")
177
- response_mode = Pricing.normalize_mode(response_tier)
175
+ response_mode = Pricing::Mode.normalize(response_tier)
178
176
  return response_mode if response_mode
179
177
 
180
- request_mode = Pricing.normalize_mode(
178
+ request_mode = Pricing::Mode.normalize(
181
179
  request["service_tier"] ||
182
180
  request["serviceTier"] ||
183
181
  request.dig("config", "service_tier") ||
@@ -30,12 +30,6 @@ module LlmCostTracker
30
30
  @provider_names = names
31
31
  end
32
32
 
33
- def provider_for(request_url)
34
- provider_for_uri(parsed_uri(request_url)) || "openai_compatible"
35
- end
36
-
37
- private
38
-
39
33
  def provider_for_uri(uri)
40
34
  return nil unless uri
41
35
 
@@ -44,7 +38,7 @@ module LlmCostTracker
44
38
  end
45
39
 
46
40
  def provider_for(request_url)
47
- self.class.provider_for(request_url)
41
+ self.class.provider_for_uri(parsed_uri(request_url)) || "openai_compatible"
48
42
  end
49
43
  end
50
44
  end
@@ -1,17 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "openai_service_charges"
4
3
  require_relative "../providers/openai/hosts"
5
4
  require_relative "../providers/openai/model_families"
5
+ require_relative "../providers/openai/service_charges"
6
6
 
7
7
  module LlmCostTracker
8
8
  module Parsers
9
9
  module OpenaiUsage
10
- include OpenaiServiceCharges
10
+ include LlmCostTracker::Providers::Openai::ServiceCharges
11
11
 
12
12
  class << self
13
13
  def combined_pricing_mode(host:, model:, service_tier:)
14
- modes = [Pricing.normalize_mode(service_tier)]
14
+ modes = [Pricing::Mode.normalize(service_tier)]
15
15
  modes << "data_residency" if regional_processing?(host: host, model: model)
16
16
  modes = modes.compact.uniq
17
17
  modes.empty? ? nil : modes.join("_")
@@ -4,8 +4,6 @@ module LlmCostTracker
4
4
  module Parsers
5
5
  autoload :Base, "llm_cost_tracker/parsers/base"
6
6
  autoload :OpenaiUsage, "llm_cost_tracker/parsers/openai_usage"
7
- autoload :OpenaiServiceCharges, "llm_cost_tracker/parsers/openai_service_charges"
8
- autoload :SSE, "llm_cost_tracker/parsers/sse"
9
7
  autoload :Openai, "llm_cost_tracker/parsers/openai"
10
8
  autoload :Azure, "llm_cost_tracker/parsers/azure"
11
9
  autoload :OpenaiCompatible, "llm_cost_tracker/parsers/openai_compatible"
@@ -50,7 +50,7 @@ module LlmCostTracker
50
50
 
51
51
  def explanation(provider:, model:, pricing_mode:, match:, usage:)
52
52
  prices = match&.prices
53
- pricing_mode = Pricing.normalize_mode(pricing_mode)
53
+ pricing_mode = Pricing::Mode.normalize(pricing_mode)
54
54
  effective = if prices
55
55
  EffectivePrices.call(usage: usage, quantities: usage.priced_quantities,
56
56
  prices: prices, pricing_mode: pricing_mode)
@@ -3,10 +3,39 @@
3
3
  module LlmCostTracker
4
4
  module Pricing
5
5
  class Mode
6
- COMPOUND_MODIFIERS = %w[data_residency].freeze
6
+ STANDARD_MODE_VALUES = %i[auto default standard standard_only].freeze
7
+ COMPOUND_MODIFIERS = %i[data_residency].freeze
7
8
 
8
9
  attr_reader :modifiers
9
10
 
11
+ def self.normalize(value)
12
+ return nil if value.nil?
13
+
14
+ symbol = normalize_string(value.to_s)
15
+ return nil unless symbol
16
+
17
+ STANDARD_MODE_VALUES.include?(symbol) ? nil : symbol
18
+ end
19
+
20
+ def self.merge(provider_mode, request_mode)
21
+ return normalize(request_mode) if provider_mode.to_s.strip.empty?
22
+
23
+ provider_tokens = tokenize(provider_mode) - STANDARD_MODE_VALUES
24
+ request_host_tokens = tokenize(request_mode || "") & COMPOUND_MODIFIERS
25
+ combined = provider_tokens | request_host_tokens
26
+ return nil if combined.empty?
27
+
28
+ normalize(combined.join("_"))
29
+ end
30
+
31
+ def self.normalize_string(value)
32
+ normalized = value.strip
33
+ return nil if normalized.empty?
34
+
35
+ normalized.downcase.tr("-", "_").to_sym
36
+ end
37
+ private_class_method :normalize_string
38
+
10
39
  def self.parse(value)
11
40
  return value if value.is_a?(self)
12
41
  return new([]) if value.nil?
@@ -21,11 +50,12 @@ module LlmCostTracker
21
50
  break if remaining.empty?
22
51
 
23
52
  compound = COMPOUND_MODIFIERS.find do |token|
24
- remaining == token || remaining.start_with?("#{token}_")
53
+ name = token.name
54
+ remaining == name || remaining.start_with?("#{name}_")
25
55
  end
26
56
  if compound
27
- tokens << compound.to_sym
28
- remaining = remaining.delete_prefix(compound).delete_prefix("_")
57
+ tokens << compound
58
+ remaining = remaining.delete_prefix(compound.name).delete_prefix("_")
29
59
  else
30
60
  first, _, rest = remaining.partition("_")
31
61
  tokens << first.to_sym unless first.empty?
@@ -68,7 +68,7 @@ module LlmCostTracker
68
68
  end
69
69
 
70
70
  def charge_rate(provider:, component:, pricing_mode:)
71
- pricing_mode = Pricing.normalize_mode(pricing_mode)
71
+ pricing_mode = Pricing::Mode.normalize(pricing_mode)
72
72
  match = charge_rate_match(provider: provider, component: component, pricing_mode: pricing_mode)
73
73
  return nil unless match
74
74
 
@@ -21,20 +21,10 @@ module LlmCostTracker
21
21
  module Pricing # rubocop:disable Metrics/ModuleLength
22
22
  extend ServiceCharges
23
23
 
24
- STANDARD_MODE_VALUES = %i[auto default standard standard_only].freeze
25
24
  RATE_DENOMINATOR_TOKENS = 1_000_000
26
25
  private_constant :RATE_DENOMINATOR_TOKENS
27
26
 
28
27
  class << self
29
- def normalize_mode(value)
30
- return nil if value.nil?
31
-
32
- mode = normalize_string_mode(value.to_s)
33
- return nil unless mode
34
-
35
- STANDARD_MODE_VALUES.include?(mode) ? nil : mode
36
- end
37
-
38
28
  def cost_for(provider:, model:, tokens:, pricing_mode: nil)
39
29
  calculation = calculation_for(
40
30
  provider: provider,
@@ -139,13 +129,6 @@ module LlmCostTracker
139
129
  )
140
130
  end
141
131
 
142
- def normalize_string_mode(value)
143
- normalized = value.strip
144
- return nil if normalized.empty?
145
-
146
- normalized.downcase.tr("-", "_").to_sym
147
- end
148
-
149
132
  def cost_from(calculation)
150
133
  costs = calculation[:costs]
151
134
  values = Billing::Components::TOKEN_PRICED.each_with_object({}) do |component, result|
@@ -184,7 +167,7 @@ module LlmCostTracker
184
167
 
185
168
  token_usage = TokenUsage.build_from_tokens(tokens)
186
169
  quantities = token_usage.priced_quantities
187
- mode = normalize_mode(pricing_mode)
170
+ mode = Mode.normalize(pricing_mode)
188
171
  effective = EffectivePrices.call(usage: token_usage, quantities: quantities, prices: match.prices,
189
172
  pricing_mode: mode)
190
173
  return nil unless any_billable_priced?(quantities, effective)
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Providers
5
+ module Anthropic
6
+ module ServerTools
7
+ LINE_ITEMS = {
8
+ web_search_request: :web_search_requests,
9
+ web_fetch_request: :web_fetch_requests,
10
+ code_execution_request: :code_execution_requests
11
+ }.freeze
12
+ end
13
+ end
14
+ end
15
+ end