llm_cost_tracker 0.14.2 → 0.15.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/CHANGELOG.md +116 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +33 -41
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -94
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
  8. data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
  9. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -14
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +22 -31
  11. data/app/helpers/llm_cost_tracker/chart_helper.rb +57 -108
  12. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
  13. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +30 -0
  15. data/app/models/llm_cost_tracker/call.rb +6 -4
  16. data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
  17. data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
  18. data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
  19. data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
  20. data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +66 -210
  22. data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -1
  23. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
  24. data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
  25. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  26. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +19 -17
  27. data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
  28. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +7 -8
  29. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -4
  30. data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -4
  31. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
  32. data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
  33. data/app/views/llm_cost_tracker/calls/show.html.erb +3 -14
  34. data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
  37. data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
  38. data/lib/llm_cost_tracker/budget/global.rb +69 -0
  39. data/lib/llm_cost_tracker/budget/limit.rb +52 -0
  40. data/lib/llm_cost_tracker/budget/per_tag.rb +59 -27
  41. data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
  42. data/lib/llm_cost_tracker/budget.rb +19 -157
  43. data/lib/llm_cost_tracker/capture/event_window.rb +16 -13
  44. data/lib/llm_cost_tracker/capture/sdk_payload.rb +22 -17
  45. data/lib/llm_cost_tracker/capture/sse.rb +7 -5
  46. data/lib/llm_cost_tracker/capture/stream_collector.rb +89 -96
  47. data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
  48. data/lib/llm_cost_tracker/capture/stream_tracker.rb +32 -37
  49. data/lib/llm_cost_tracker/capture_verifier.rb +2 -3
  50. data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
  51. data/lib/llm_cost_tracker/charges/line_item.rb +31 -33
  52. data/lib/llm_cost_tracker/configuration/budgets.rb +49 -18
  53. data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
  54. data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
  55. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
  56. data/lib/llm_cost_tracker/doctor/price_check.rb +24 -35
  57. data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
  58. data/lib/llm_cost_tracker/doctor.rb +23 -20
  59. data/lib/llm_cost_tracker/engine.rb +8 -0
  60. data/lib/llm_cost_tracker/errors.rb +2 -4
  61. data/lib/llm_cost_tracker/event.rb +22 -29
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -2
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +6 -4
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -0
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +2 -1
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -0
  68. data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
  69. data/lib/llm_cost_tracker/ingestion/inbox.rb +20 -11
  70. data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
  71. data/lib/llm_cost_tracker/ingestion.rb +20 -35
  72. data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
  73. data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
  74. data/lib/llm_cost_tracker/integrations/anthropic.rb +59 -120
  75. data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
  76. data/lib/llm_cost_tracker/integrations/base.rb +65 -81
  77. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +20 -19
  78. data/lib/llm_cost_tracker/integrations/openai/patches.rb +51 -40
  79. data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
  80. data/lib/llm_cost_tracker/integrations/openai.rb +114 -83
  81. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
  82. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
  83. data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
  84. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
  85. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
  86. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
  87. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
  88. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
  89. data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
  90. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -408
  91. data/lib/llm_cost_tracker/ledger/isolation.rb +15 -2
  92. data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
  93. data/lib/llm_cost_tracker/ledger/rollups.rb +24 -8
  94. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +23 -6
  95. data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -5
  96. data/lib/llm_cost_tracker/ledger/store.rb +17 -35
  97. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -2
  98. data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
  99. data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
  100. data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
  101. data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
  102. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -301
  103. data/lib/llm_cost_tracker/parsers.rb +12 -0
  104. data/lib/llm_cost_tracker/prices.json +1179 -208
  105. data/lib/llm_cost_tracker/pricing/backfill.rb +59 -29
  106. data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
  107. data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
  108. data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
  109. data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
  110. data/lib/llm_cost_tracker/pricing/calculation.rb +82 -173
  111. data/lib/llm_cost_tracker/pricing/matcher.rb +31 -14
  112. data/lib/llm_cost_tracker/pricing/mode.rb +6 -19
  113. data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
  114. data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
  115. data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
  116. data/lib/llm_cost_tracker/pricing/registry.rb +38 -111
  117. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
  118. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
  119. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
  120. data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
  121. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
  122. data/lib/llm_cost_tracker/pricing/sync.rb +7 -53
  123. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
  124. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +30 -25
  125. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +5 -3
  126. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +122 -101
  127. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
  128. data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -6
  129. data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
  130. data/lib/llm_cost_tracker/providers/gemini/parser.rb +120 -183
  131. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +66 -47
  132. data/lib/llm_cost_tracker/providers/openai/hosts.rb +11 -0
  133. data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
  134. data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
  135. data/lib/llm_cost_tracker/providers/openai/parser.rb +2 -1
  136. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +81 -149
  137. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +133 -156
  138. data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
  139. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -62
  140. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +15 -4
  141. data/lib/llm_cost_tracker/providers.rb +3 -0
  142. data/lib/llm_cost_tracker/redaction.rb +13 -0
  143. data/lib/llm_cost_tracker/report/data.rb +25 -28
  144. data/lib/llm_cost_tracker/tags/context.rb +22 -7
  145. data/lib/llm_cost_tracker/tracker.rb +32 -26
  146. data/lib/llm_cost_tracker/usage/dimensions.yml +24 -0
  147. data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
  148. data/lib/llm_cost_tracker/version.rb +1 -1
  149. data/lib/llm_cost_tracker.rb +8 -4
  150. data/lib/tasks/llm_cost_tracker.rake +35 -5
  151. data/llm_cost_tracker.gemspec +6 -5
  152. metadata +58 -5
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/hash/keys"
4
+
5
+ require_relative "response_parser"
6
+ require_relative "service_charges"
7
+
8
+ module LlmCostTracker
9
+ module Providers
10
+ module Openai
11
+ module StreamParser
12
+ RETAINED_RESPONSE_FIELDS = %w[id model service_tier background usage].freeze
13
+
14
+ def parse_stream(response_status:, request_url: nil, request_body: nil, events: [], **)
15
+ return nil unless response_status == 200
16
+
17
+ request = safe_json_parse(request_body)
18
+ usage = stream_usage(events)
19
+ attributes = stream_attributes(events, request: request, request_url: request_url, usage: usage)
20
+ background = find_event_value(events) { |data| data.dig("response", "background") }
21
+ if usage
22
+ event = ResponseParser.usage_event(
23
+ usage, stream: true, usage_source: Usage::Source::STREAM_FINAL, **attributes
24
+ )
25
+ return background ? event.keyed_by_response_id : event
26
+ end
27
+
28
+ warn_missing_stream_usage(request_url: request_url, request: request)
29
+ attributes[:service_line_items] = [] if background
30
+ build_unknown_stream_usage(**attributes)
31
+ end
32
+
33
+ def streaming_request?(request_url, request_parsed)
34
+ super || request_parsed["stream_format"] == "sse"
35
+ end
36
+
37
+ def auto_enable_stream_usage?(request_url, _request_parsed)
38
+ openai_chat_completions_url?(request_url)
39
+ end
40
+
41
+ def retain_stream_event?(data)
42
+ data.is_a?(Hash) && (data["item"].is_a?(Hash) || data["response"].is_a?(Hash))
43
+ end
44
+
45
+ def trim_stream_event(data)
46
+ return data unless data.is_a?(Hash)
47
+
48
+ item, response = data.values_at("item", "response")
49
+ data = data.merge("item" => ServiceCharges.billing_fields(item)) if item
50
+ return data unless response.is_a?(Hash)
51
+
52
+ output = Array(response["output"]).filter_map { |output_item| ServiceCharges.billing_fields(output_item) }
53
+ data.merge("response" => response.slice(*RETAINED_RESPONSE_FIELDS).merge("output" => output))
54
+ end
55
+
56
+ private
57
+
58
+ def stream_attributes(events, request:, request_url:, usage:)
59
+ provider = provider_for(request_url)
60
+ model = stream_value(events, "model", reverse: true) || request["model"]
61
+ service_tier = stream_value(events, "service_tier", reverse: true) || usage&.dig(:service_tier) ||
62
+ request["service_tier"]
63
+ {
64
+ provider: provider,
65
+ model: model,
66
+ provider_response_id: stream_value(events, "id"),
67
+ pricing_mode: ResponseParser.combined_pricing_mode(
68
+ provider: provider, host: parsed_uri(request_url)&.host, model: model, service_tier: service_tier
69
+ ),
70
+ service_line_items: ServiceCharges.service_line_items_for(streamed_response(events), request:, model:) +
71
+ ServiceCharges.speech_line_items(request)
72
+ }
73
+ end
74
+
75
+ def stream_value(events, key, reverse: false)
76
+ find_event_value(events, reverse: reverse) { |data| envelope_value(data, key) }
77
+ end
78
+
79
+ def envelope_value(data, key)
80
+ data[key] || data.dig("response", key) || data.dig("chunk", key)
81
+ end
82
+
83
+ def stream_usage(events)
84
+ usage = find_event_value(events, reverse: true) do |data|
85
+ found = envelope_value(data, "usage") || data.dig("x_groq", "usage") || data.dig("chunk", "x_groq", "usage")
86
+ next unless found.is_a?(Hash)
87
+
88
+ audio = { "output_tokens_details" => { "audio_tokens" => found["output_tokens"] } }
89
+ data["type"] == "speech.audio.done" ? found.merge(audio) : found
90
+ end
91
+ usage&.deep_symbolize_keys
92
+ end
93
+
94
+ def streamed_response(events)
95
+ response = { "output" => [] }
96
+ each_event_data(events) do |data|
97
+ response["output"].concat(Array(data.dig("response", "output")))
98
+ response["output"] << data["item"] if data["item"]
99
+ chunk = data["chunk"] || data
100
+ next unless chunk["choices"].is_a?(Array)
101
+
102
+ response["id"] ||= chunk["id"]
103
+ response["choices"] ||= chunk["choices"]
104
+ end
105
+ response
106
+ end
107
+
108
+ def warn_missing_stream_usage(request_url:, request:)
109
+ return unless request_url.nil? || request["stream"]
110
+ return unless request_url ? openai_chat_completions_url?(request_url) : request["messages"]
111
+ return if request.dig("stream_options", "include_usage")
112
+
113
+ Logging.warn(
114
+ "OpenAI-compatible chat-completions stream finished without a final usage chunk. " \
115
+ "Set `stream_options: { include_usage: true }` in your request body so the gem can " \
116
+ "record token counts. This call was stored with usage_source=#{Usage::Source::UNKNOWN}."
117
+ )
118
+ end
119
+
120
+ def openai_chat_completions_url?(request_url)
121
+ uri = parsed_uri(request_url)
122
+ uri && uri.path.to_s.end_with?("/chat/completions")
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -12,78 +12,88 @@ module LlmCostTracker
12
12
  audio_tokens: "audio_cache_read_input", image_tokens: "image_cache_read_input"
13
13
  }.freeze
14
14
 
15
- def self.token_usage(usage, model: nil)
16
- input_tokens = (usage[:input_tokens] || usage[:prompt_tokens]).to_i
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
20
- cache_read = cache_read_input_tokens(usage)
21
- cache_write = cache_write_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)
25
- audio_output = audio_output_tokens(usage)
26
- image_output, regular_output = split_output(
27
- output_tokens: output_tokens,
28
- image_output_details: image_output_tokens(usage),
29
- text_output_details: text_output_tokens(usage),
30
- audio_output: audio_output,
31
- default_to_image: ModelFamilies.image_output?(model)
32
- )
15
+ class << self
16
+ def token_usage(usage, model: nil, default_to_image: ModelFamilies.image_output?(model))
17
+ cache_read = cache_read_input_tokens(usage)
18
+ cache_write = cache_write_input_tokens(usage)
19
+ Usage::TokenUsage.build(
20
+ **input_counts(usage, cached: cache_read + cache_write),
21
+ **output_counts(usage, default_to_image: default_to_image),
22
+ total_tokens: usage[:total_tokens],
23
+ cache_read_input_tokens: cache_read,
24
+ cache_write_input_tokens: cache_write,
25
+ hidden_output_tokens: hidden_output_tokens(usage)
26
+ )
27
+ end
33
28
 
34
- Usage::TokenUsage.build(
35
- input_tokens: uncached_input - audio_input - image_input,
36
- output_tokens: regular_output,
37
- total_tokens: usage[:total_tokens],
38
- cache_read_input_tokens: cache_read,
39
- cache_write_input_tokens: cache_write,
40
- audio_input_tokens: audio_input,
41
- audio_output_tokens: audio_output,
42
- image_input_tokens: image_input,
43
- image_output_tokens: image_output,
44
- hidden_output_tokens: reasoning
45
- )
46
- end
29
+ def cache_read_line_items(usage)
30
+ CACHED_MODALITY_DIMENSIONS.filter_map do |detail_key, dimension_key|
31
+ quantity = detail(usage, INPUT_DETAIL_KEYS, :cached_tokens_details, detail_key)
32
+ Charges::LineItem.build(dimension_key: dimension_key, quantity: quantity) if quantity.positive?
33
+ end
34
+ end
47
35
 
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?
36
+ def split_output(output_tokens:,
37
+ image_output_details:,
38
+ text_output_details:,
39
+ audio_output:,
40
+ default_to_image: false)
41
+ if image_output_details.zero? && text_output_details.zero?
42
+ remainder = [output_tokens - audio_output, 0].max
43
+ return default_to_image ? [remainder, 0] : [0, remainder]
44
+ end
45
+
46
+ [image_output_details, [output_tokens - image_output_details - audio_output, 0].max]
52
47
  end
53
- end
54
48
 
55
- def self.split_output(output_tokens:,
56
- image_output_details:,
57
- text_output_details:,
58
- audio_output:,
59
- default_to_image: false)
60
- if image_output_details.zero? && text_output_details.zero?
61
- remainder = [output_tokens - audio_output, 0].max
62
- return default_to_image ? [remainder, 0] : [0, remainder]
49
+ def cache_read_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :cached_tokens)
50
+ def cache_write_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :cache_write_tokens)
51
+ def hidden_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :reasoning_tokens)
52
+ def audio_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :audio_tokens)
53
+ def audio_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :audio_tokens)
54
+ def image_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :image_tokens)
55
+ def image_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :image_tokens)
56
+ def text_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :text_tokens)
57
+
58
+ def uncached_input_tokens(usage, key)
59
+ detail(usage, INPUT_DETAIL_KEYS, key) - detail(usage, INPUT_DETAIL_KEYS, :cached_tokens_details, key)
63
60
  end
64
61
 
65
- [image_output_details, [output_tokens - image_output_details - audio_output, 0].max]
66
- end
62
+ def detail(usage, containers, *path)
63
+ containers.each do |container|
64
+ value = usage.dig(container, *path)
65
+ return value.to_i if value
66
+ end
67
+ 0
68
+ end
67
69
 
68
- def self.cache_read_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :cached_tokens)
69
- def self.cache_write_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :cache_write_tokens)
70
- def self.hidden_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :reasoning_tokens)
71
- def self.audio_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :audio_tokens)
72
- def self.audio_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :audio_tokens)
73
- def self.image_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :image_tokens)
74
- def self.image_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :image_tokens)
75
- def self.text_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :text_tokens)
70
+ private
76
71
 
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
72
+ def input_counts(usage, cached:)
73
+ uncached = [prompt_tokens(usage) - cached, 0].max
74
+ image = uncached_input_tokens(usage, :image_tokens).clamp(0, uncached)
75
+ audio = uncached_input_tokens(usage, :audio_tokens).clamp(0, uncached - image)
76
+ { input_tokens: uncached - audio - image, image_input_tokens: image, audio_input_tokens: audio }
77
+ end
78
+
79
+ def output_counts(usage, default_to_image:)
80
+ output = (usage[:output_tokens] || usage[:completion_tokens]).to_i
81
+ reasoning = hidden_output_tokens(usage)
82
+ output += reasoning if usage[:total_tokens].to_i == prompt_tokens(usage) + output + reasoning
83
+ audio = audio_output_tokens(usage)
84
+ image, regular = split_output(
85
+ output_tokens: output,
86
+ image_output_details: image_output_tokens(usage),
87
+ text_output_details: text_output_tokens(usage),
88
+ audio_output: audio,
89
+ default_to_image: default_to_image
90
+ )
91
+ { output_tokens: regular, image_output_tokens: image, audio_output_tokens: audio }
92
+ end
80
93
 
81
- def self.detail(usage, containers, *path)
82
- containers.each do |container|
83
- value = usage.dig(container, *path)
84
- return value.to_i if value
94
+ def prompt_tokens(usage)
95
+ (usage[:input_tokens] || usage[:prompt_tokens]).to_i
85
96
  end
86
- 0
87
97
  end
88
98
  end
89
99
  end
@@ -5,13 +5,24 @@ module LlmCostTracker
5
5
  module OpenaiCompatible
6
6
  class Parser < LlmCostTracker::Parsers::Base
7
7
  include Openai::ResponseParser
8
-
9
- TRACKED_PATH_SUFFIXES = %w[/chat/completions /completions /embeddings /responses].freeze
8
+ include Openai::StreamParser
9
+
10
+ TRACKED_PATH_SUFFIXES = %w[
11
+ /chat/completions /completions /embeddings /responses /images/generations /images/edits /v1/sonar /v1/agent
12
+ /v1/ocr /audio/transcriptions /audio/translations /audio/speech /moderations
13
+ ].freeze
14
+ RETRIEVE_PATH = %r{/(?:responses|agent)/resp_[^/]+\z}
15
+ STREAM_USAGE_HOSTS = %w[
16
+ openrouter.ai api.deepseek.com api.groq.com api.x.ai us.api.x.ai
17
+ api.mistral.ai api.eu.mistral.ai api.us.mistral.ai
18
+ ].freeze
10
19
 
11
20
  class << self
12
21
  def match?(url)
13
22
  uri_matches?(url) do |uri|
14
- TRACKED_PATH_SUFFIXES.any? { |suffix| uri.path.to_s.end_with?(suffix) } && !provider_for_uri(uri).nil?
23
+ path = uri.path.to_s
24
+ (path.end_with?(*TRACKED_PATH_SUFFIXES) || path.match?(RETRIEVE_PATH)) &&
25
+ !provider_for_uri(uri).nil?
15
26
  end
16
27
  end
17
28
 
@@ -34,7 +45,7 @@ module LlmCostTracker
34
45
  end
35
46
 
36
47
  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)
48
+ super && STREAM_USAGE_HOSTS.include?(parsed_uri(request_url).host.to_s.downcase)
38
49
  end
39
50
  end
40
51
  end
@@ -14,6 +14,7 @@ module LlmCostTracker
14
14
  end
15
15
 
16
16
  module Gemini
17
+ autoload :Grounding, "llm_cost_tracker/providers/gemini/grounding"
17
18
  autoload :ModelFamilies, "llm_cost_tracker/providers/gemini/model_families"
18
19
  autoload :Parser, "llm_cost_tracker/providers/gemini/parser"
19
20
  autoload :UsageExtractor, "llm_cost_tracker/providers/gemini/usage_extractor"
@@ -22,8 +23,10 @@ module LlmCostTracker
22
23
  module Openai
23
24
  autoload :Hosts, "llm_cost_tracker/providers/openai/hosts"
24
25
  autoload :ModelFamilies, "llm_cost_tracker/providers/openai/model_families"
26
+ autoload :OutputItem, "llm_cost_tracker/providers/openai/output_item"
25
27
  autoload :Parser, "llm_cost_tracker/providers/openai/parser"
26
28
  autoload :ServiceCharges, "llm_cost_tracker/providers/openai/service_charges"
29
+ autoload :StreamParser, "llm_cost_tracker/providers/openai/stream_parser"
27
30
  autoload :UsageExtractor, "llm_cost_tracker/providers/openai/usage_extractor"
28
31
  autoload :ResponseParser, "llm_cost_tracker/providers/openai/response_parser"
29
32
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "uri"
4
+
3
5
  module LlmCostTracker
4
6
  module Redaction
5
7
  REDACTED = "[REDACTED]"
@@ -28,5 +30,16 @@ module LlmCostTracker
28
30
  .gsub(PARAM, "\\1=#{REDACTED}")
29
31
  .gsub(EMBEDDED_TOKEN, REDACTED)
30
32
  end
33
+
34
+ def self.url(value)
35
+ uri = URI.parse(value.to_s)
36
+ uri.query = nil
37
+ uri.fragment = nil
38
+ uri.user = nil
39
+ uri.password = nil
40
+ uri.to_s
41
+ rescue URI::InvalidURIError
42
+ value.to_s.split("?", 2).first
43
+ end
31
44
  end
32
45
  end
@@ -26,46 +26,43 @@ module LlmCostTracker
26
26
  TOP_LIMIT = 5
27
27
 
28
28
  def self.build(days: DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil, breakdown_limit: nil)
29
- days = days.to_i
30
- days = DEFAULT_DAYS unless days.positive?
31
- unless breakdown_limit.nil?
32
- breakdown_limit = breakdown_limit.to_i
33
- breakdown_limit = nil unless breakdown_limit.positive?
34
- end
29
+ days = positive_integer(days) || DEFAULT_DAYS
30
+ limit = positive_integer(breakdown_limit)
35
31
  from = now - days.days
36
32
  scope = LlmCostTracker::Call.where(tracked_at: from..now)
37
- tag_breakdowns ||= LlmCostTracker.configuration.tags.report_breakdown_keys
38
- aggregate = totals(scope)
33
+ tag_keys = tag_breakdowns || LlmCostTracker.configuration.tags.report_breakdown_keys
39
34
 
40
35
  new(
41
36
  days: days,
42
37
  from_time: from,
43
38
  to_time: now,
44
- total_cost: aggregate.total_cost.to_f,
45
- requests_count: aggregate.requests_count.to_i,
46
- average_latency_ms: aggregate.average_latency_ms&.to_f,
47
- unknown_pricing_count: aggregate.unknown_pricing_count.to_i,
48
- cost_by_provider: scope.cost_by_provider(limit: breakdown_limit).to_a,
49
- cost_by_model: scope.cost_by_model(limit: breakdown_limit).to_a,
50
- cost_by_tags: cost_by_tags(scope, tag_breakdowns, limit: breakdown_limit),
39
+ **totals(scope),
40
+ cost_by_provider: scope.cost_by_provider(limit: limit).to_a,
41
+ cost_by_model: scope.cost_by_model(limit: limit).to_a,
42
+ cost_by_tags: tag_keys.to_h { |key| [key, scope.cost_by_tag(key, limit: limit).to_a] },
51
43
  top_calls: top_calls(scope)
52
44
  )
53
45
  end
54
46
 
55
- def self.totals(scope)
56
- scope
57
- .select(
58
- "COALESCE(SUM(#{LlmCostTracker::Call.qualified(:total_cost)}), 0) AS total_cost, " \
59
- "COUNT(*) AS requests_count, " \
60
- "AVG(latency_ms) AS average_latency_ms, " \
61
- "COALESCE(SUM(CASE WHEN #{Charges::CostStatus.unknown_pricing_sql} " \
62
- "THEN 1 ELSE 0 END), 0) AS unknown_pricing_count"
63
- )
64
- .take
47
+ def self.positive_integer(value)
48
+ integer = value.to_i
49
+ integer if integer.positive?
65
50
  end
66
51
 
67
- def self.cost_by_tags(scope, keys, limit:)
68
- keys.to_h { |key| [key, scope.cost_by_tag(key, limit: limit).to_a] }
52
+ def self.totals(scope)
53
+ row = scope.select(
54
+ "COALESCE(SUM(#{LlmCostTracker::Call.qualified(:total_cost)}), 0) AS total_cost, " \
55
+ "COUNT(*) AS requests_count, " \
56
+ "AVG(latency_ms) AS average_latency_ms, " \
57
+ "COALESCE(SUM(CASE WHEN #{Charges::CostStatus.unknown_pricing_sql} " \
58
+ "THEN 1 ELSE 0 END), 0) AS unknown_pricing_count"
59
+ ).take
60
+ {
61
+ total_cost: row.total_cost.to_f,
62
+ requests_count: row.requests_count.to_i,
63
+ average_latency_ms: row.average_latency_ms&.to_f,
64
+ unknown_pricing_count: row.unknown_pricing_count.to_i
65
+ }
69
66
  end
70
67
 
71
68
  def self.top_calls(scope)
@@ -76,7 +73,7 @@ module LlmCostTracker
76
73
  .to_a
77
74
  end
78
75
 
79
- private_class_method :cost_by_tags, :top_calls, :totals
76
+ private_class_method :positive_integer, :totals, :top_calls
80
77
  end
81
78
  end
82
79
  end
@@ -9,26 +9,41 @@ module LlmCostTracker
9
9
 
10
10
  class << self
11
11
  def with(tags)
12
- stack = ActiveSupport::IsolatedExecutionState[KEY] || []
13
- ActiveSupport::IsolatedExecutionState[KEY] = stack + [Sanitizer.call((tags || {}).to_h)]
12
+ stack = Fiber[KEY] || []
13
+ scope = [Thread.current, Sanitizer.call((tags || {}).to_h)]
14
+ shared = scope.dup
15
+ Fiber[KEY] = stack + [scope]
16
+ ActiveSupport::IsolatedExecutionState[KEY] = shared_scopes + [shared]
14
17
  yield
15
18
  ensure
16
- ActiveSupport::IsolatedExecutionState[KEY] = stack
19
+ scope&.clear
20
+ Fiber[KEY] = stack
21
+ ActiveSupport::IsolatedExecutionState[KEY] = shared_scopes.reject { |entry| entry.equal?(shared) } if shared
17
22
  end
18
23
 
19
24
  def tags
20
25
  config = LlmCostTracker.configuration
21
- base = config.tags.static_sanitized_default ||
22
- Sanitizer.call(call_default_tags(config.tags.default).to_h)
23
- base.merge(*Array(ActiveSupport::IsolatedExecutionState[KEY]))
26
+ base = config.tags.static_sanitized_default || Sanitizer.call(call_default_tags(config.tags.default))
27
+ base.merge(scoped)
28
+ end
29
+
30
+ def scoped
31
+ current = Thread.current
32
+ shared = shared_scopes.reject { |owner, _| owner.equal?(current) }
33
+ own = Array(Fiber[KEY]).select { |owner, _| owner.equal?(current) }
34
+ (shared + own).each_with_object({}) { |(_, tags), merged| merged.merge!(tags) }
24
35
  end
25
36
 
26
37
  def call_default_tags(proc_or_lambda)
27
- proc_or_lambda.call
38
+ (proc_or_lambda.call || {}).to_h
28
39
  rescue StandardError => e
29
40
  Logging.warn("LlmCostTracker tags.default proc raised: #{e.class}: #{e.message}; using empty default tags")
30
41
  {}
31
42
  end
43
+
44
+ private
45
+
46
+ def shared_scopes = Array(ActiveSupport::IsolatedExecutionState[KEY])
32
47
  end
33
48
  end
34
49
  end
@@ -16,33 +16,17 @@ module LlmCostTracker
16
16
  def record(event:, latency_ms: nil, metadata: {}, context_tags: nil, enforce_budget: false)
17
17
  return unless LlmCostTracker.configuration.enabled
18
18
 
19
- calculation = Pricing::Calculation.for(
20
- provider: event.provider,
21
- model: event.model,
22
- tokens: event.token_usage,
23
- line_items: event.line_items,
24
- pricing_mode: event.pricing_mode,
25
- usage_source: event.usage_source
26
- )
27
-
19
+ tracked_at = Time.now.utc
20
+ calculation = price(event, at: tracked_at)
28
21
  tags = build_tags(context_tags: context_tags, metadata: metadata)
29
-
30
- event = build_event(event: event, calculation: calculation, tags: tags, latency_ms: latency_ms)
31
-
32
- if Ingestion.async?
33
- Ingestion::Inbox.save(event)
34
- Ingestion::Worker.ensure_started
35
- else
36
- Ledger::Store.insert(event)
37
- end
38
-
22
+ event = build_event(event:, calculation:, tags:, latency_ms:, tracked_at:)
23
+ persist(event)
39
24
  yield if block_given?
40
25
  notify_subscribers(event)
41
- behavior_override = :raise if enforce_budget
42
26
  begin
43
27
  signal_unpriced(event, calculation)
44
28
  ensure
45
- Budget.check!(event, behavior_override: behavior_override)
29
+ Budget.check!(event, behavior_override: enforce_budget ? :raise : nil)
46
30
  end
47
31
 
48
32
  event
@@ -56,15 +40,37 @@ module LlmCostTracker
56
40
 
57
41
  private
58
42
 
43
+ def price(event, at:)
44
+ Pricing::Calculation.for(
45
+ provider: event.provider,
46
+ model: event.model,
47
+ tokens: event.token_usage,
48
+ line_items: event.line_items,
49
+ pricing_mode: event.pricing_mode,
50
+ usage_source: event.usage_source,
51
+ at: at
52
+ )
53
+ end
54
+
55
+ def persist(event)
56
+ return Ledger::Store.insert(event) unless Ingestion.async?
57
+
58
+ Ingestion::Inbox.save(event)
59
+ Ingestion::Worker.ensure_started
60
+ end
61
+
59
62
  def signal_unpriced(event, calculation)
60
63
  iterations, lines = calculation.priced_line_items.partition { |line| line.kind == "model_iteration" }
61
64
  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.unshift(event.model) if unpriced_tokens?(event, calculation, lines)
65
66
  models.each { |model| Pricing::Unknown.process(model, pricing_mode: calculation.mode) }
66
67
  end
67
68
 
69
+ def unpriced_tokens?(event, calculation, lines)
70
+ calculation.token_cost.nil? && event.token_usage.total_tokens.positive? && lines.none?(&:priced?) &&
71
+ !calculation.unsplit_total?
72
+ end
73
+
68
74
  def notify_subscribers(event)
69
75
  return unless ActiveSupport::Notifications.notifier.listening?(EVENT_NAME)
70
76
 
@@ -73,14 +79,14 @@ module LlmCostTracker
73
79
  Logging.warn("Subscriber raised on #{EVENT_NAME}: #{e.class}: #{e.message}")
74
80
  end
75
81
 
76
- def build_event(event:, calculation:, tags:, latency_ms:)
82
+ def build_event(event:, calculation:, tags:, latency_ms:, tracked_at:)
77
83
  event.with(
78
84
  event_id: event.event_id || SecureRandom.uuid,
79
85
  pricing_mode: calculation.mode,
80
86
  cost: calculation.cost,
81
87
  tags: tags,
82
88
  latency_ms: finite_latency_ms(latency_ms),
83
- tracked_at: Time.now.utc,
89
+ tracked_at: tracked_at,
84
90
  cost_status: calculation.cost_status,
85
91
  pricing_snapshot: calculation.snapshot,
86
92
  line_items: calculation.priced_line_items
@@ -85,6 +85,14 @@
85
85
  unit: token
86
86
  parent: input
87
87
 
88
+ - key: video_output
89
+ kind: video_token
90
+ direction: output
91
+ modality: video
92
+ cache_state: none
93
+ unit: token
94
+ parent: output
95
+
88
96
  - key: web_search_request
89
97
  kind: web_search_request
90
98
  direction: neither
@@ -188,6 +196,22 @@
188
196
  unit: minute
189
197
  rate_basis: per_minute
190
198
 
199
+ - key: ocr_page
200
+ kind: ocr_page
201
+ direction: input
202
+ modality: image
203
+ cache_state: none
204
+ unit: page
205
+ rate_basis: per_1k_pages
206
+
207
+ - key: rerank_search_unit
208
+ kind: rerank_search_unit
209
+ direction: input
210
+ modality: text
211
+ cache_state: none
212
+ unit: request
213
+ rate_basis: per_1k_requests
214
+
191
215
  - key: billed_request
192
216
  kind: billed_request
193
217
  direction: neither
@@ -16,10 +16,9 @@ module LlmCostTracker
16
16
  priced = Catalog.token_priced.to_h do |dimension|
17
17
  [dimension.token_key, non_negative_int(values[dimension.token_key])]
18
18
  end
19
- subtotal = priced.values.sum
20
- declared_total = values[:total_tokens]
21
- total = declared_total ? [non_negative_int(declared_total), subtotal].max : subtotal
22
- new(**priced, total_tokens: total, hidden_output_tokens: non_negative_int(values[:hidden_output_tokens]))
19
+ new(**priced,
20
+ total_tokens: total(values[:total_tokens], priced.values.sum),
21
+ hidden_output_tokens: non_negative_int(values[:hidden_output_tokens]))
23
22
  end
24
23
 
25
24
  def self.build_from_tokens(tokens)
@@ -40,6 +39,11 @@ module LlmCostTracker
40
39
  def self.non_negative_int(value)
41
40
  [value.to_i, 0].max
42
41
  end
42
+
43
+ def self.total(declared, counted)
44
+ declared ? [non_negative_int(declared), counted].max : counted
45
+ end
46
+ private_class_method :total
43
47
  end
44
48
  end
45
49
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.14.2"
4
+ VERSION = "0.15.0"
5
5
  end