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,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Integrations
5
+ module RubyLlm
6
+ module V1
7
+ class Reply
8
+ KEPT_BODY = :@llm_cost_tracker_body
9
+
10
+ attr_reader :slug, :model
11
+
12
+ def self.body(response)
13
+ raw = response.try(:raw)
14
+ body = raw.respond_to?(:body) ? raw.body : raw
15
+ body = (raw || response).instance_variable_get(KEPT_BODY) unless body.is_a?(Hash)
16
+ body.is_a?(Hash) ? body : {}
17
+ end
18
+
19
+ def self.response_id(response)
20
+ body = body(response)
21
+ body["id"] || body["responseId"]
22
+ end
23
+
24
+ def initialize(provider, response, requested_model)
25
+ @provider = provider
26
+ @response = response
27
+ @slug = provider.slug.to_s
28
+ @model = (response.try(:model_id) || response.try(:model))&.to_s || requested_model
29
+ @body = Reply.body(response)
30
+ end
31
+
32
+ def usage = @usage ||= (@body["usage"] || {}).deep_symbolize_keys
33
+
34
+ def event(stream:, output_tokens: nil, audio_input_tokens: 0, service_line_items: [], usage_source: nil)
35
+ counts = token_counts
36
+ output_tokens = counts[:output] if output_tokens.nil?
37
+ return if counts[:input].nil? && output_tokens.nil? && service_line_items.empty? && usage_source.nil?
38
+
39
+ Event.build(
40
+ provider: @slug,
41
+ model: @model,
42
+ pricing_mode: pricing_mode,
43
+ token_usage: token_usage(counts, output_tokens, audio_input_tokens),
44
+ service_line_items: service_line_items + gemini_line_items,
45
+ stream: stream,
46
+ usage_source: usage_source || Usage::Source::SDK_RESPONSE,
47
+ provider_response_id: Reply.response_id(@response)
48
+ )
49
+ end
50
+
51
+ def service_line_items
52
+ case @slug
53
+ when "anthropic"
54
+ server_tool_use = @body.dig("usage", "server_tool_use")&.symbolize_keys
55
+ Providers::Anthropic::UsageExtractor.service_line_items(server_tool_use: server_tool_use)
56
+ when "openai" then Providers::Openai::ServiceCharges.service_line_items_for(@body, model: @model)
57
+ when "gemini" then Providers::Gemini::Parser.new.service_line_items_for(@body, model: @model)
58
+ when "openrouter", "xai", "perplexity" then Providers::Openai::ServiceCharges.billed_line_items(usage)
59
+ else []
60
+ end
61
+ end
62
+
63
+ def token_counts
64
+ tokens = @response.try(:tokens)
65
+ return { input: @response.try(:input_tokens), output: @response.try(:output_tokens) } unless tokens
66
+
67
+ usage = @body["usage"] || {}
68
+ { input: input_tokens(tokens, usage), output: output_tokens(tokens, usage), cache_read: tokens.cache_read,
69
+ cache_write: tokens.cache_write, thinking: tokens.thinking }
70
+ end
71
+
72
+ private
73
+
74
+ def token_usage(counts, output_tokens, audio_input_tokens)
75
+ gemini = gemini_usage
76
+ return Providers::Gemini::UsageExtractor.token_usage(gemini) if gemini
77
+
78
+ five_minute, one_hour = cache_writes(counts[:cache_write])
79
+ Usage::TokenUsage.build(
80
+ input_tokens: counts[:input].to_i - audio_input_tokens,
81
+ audio_input_tokens: audio_input_tokens,
82
+ output_tokens: output_tokens.to_i,
83
+ cache_read_input_tokens: counts[:cache_read].to_i,
84
+ cache_write_input_tokens: five_minute,
85
+ cache_write_extended_input_tokens: one_hour,
86
+ hidden_output_tokens: counts[:thinking].to_i
87
+ )
88
+ end
89
+
90
+ def gemini_line_items
91
+ gemini = gemini_usage
92
+ gemini ? Providers::Gemini::UsageExtractor.line_items(gemini) : []
93
+ end
94
+
95
+ def pricing_mode
96
+ case @slug
97
+ when "anthropic", "bedrock"
98
+ Providers::Anthropic::UsageExtractor.pricing_mode(
99
+ request: { model: @model, service_tier: @body["serviceTier"].try(:[], "type") },
100
+ usage: @body["usage"]&.deep_symbolize_keys
101
+ )
102
+ when "gemini", "vertexai"
103
+ Providers::Gemini::Parser.new.pricing_mode(
104
+ request: {}, usage: @body["usageMetadata"], response_headers: nil, host: host, model: @model
105
+ )
106
+ when "openai", "xai", "mistral"
107
+ service_tier = @body["service_tier"] || @body.dig("usage", "service_tier")
108
+ Providers::Openai::ResponseParser.combined_pricing_mode(
109
+ provider: @slug, host: host, model: @model, service_tier: service_tier
110
+ )
111
+ else @body["service_tier"]
112
+ end
113
+ end
114
+
115
+ def host = URI(@provider.api_base).host
116
+
117
+ def gemini_usage
118
+ gemini = @body["usageMetadata"] if @slug == "gemini"
119
+ gemini if gemini.is_a?(Hash)
120
+ end
121
+
122
+ def input_tokens(tokens, usage)
123
+ input = usage["inputTokens"] || tokens.input
124
+ @slug == "anthropic" ? [input, usage["input_tokens"]].compact.max : input
125
+ end
126
+
127
+ def output_tokens(tokens, usage)
128
+ output = tokens.output
129
+ thinking = tokens.thinking.to_i
130
+ input = (usage["input_tokens"] || usage["prompt_tokens"]).to_i
131
+ output && usage["total_tokens"] == input + output + thinking ? output + thinking : output
132
+ end
133
+
134
+ def cache_writes(total)
135
+ cache = cache_creation
136
+ return [total.to_i, 0] unless cache.is_a?(Hash)
137
+
138
+ five_minute = cache["ephemeral_5m_input_tokens"].to_i
139
+ one_hour = cache["ephemeral_1h_input_tokens"].to_i
140
+ [five_minute + [total.to_i - five_minute - one_hour, 0].max, one_hour]
141
+ end
142
+
143
+ def cache_creation
144
+ usage = @body["usage"] || {}
145
+ case @slug
146
+ when "anthropic" then usage["cache_creation"]
147
+ when "bedrock"
148
+ Array(usage["cacheDetails"]).to_h { |d| ["ephemeral_#{d['ttl']}_input_tokens", d["inputTokens"]] }
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base"
4
+ require_relative "v1/patches"
5
+ require_relative "v1/reply"
6
+
7
+ module LlmCostTracker
8
+ module Integrations
9
+ module RubyLlm
10
+ module V1
11
+ extend Base
12
+
13
+ minimum_version "1.15.0"
14
+ maximum_version "2.0.0"
15
+
16
+ class << self
17
+ def integration_name = :ruby_llm
18
+
19
+ def patch_targets
20
+ [
21
+ patch_target("RubyLLM::Provider", with: ProviderPatch),
22
+ patch_target("RubyLLM::Providers::Gemini::Transcription", with: GeminiTranscriptionPatch, optional: true),
23
+ patch_target("RubyLLM::Streaming", with: StreamPatch, optional: true),
24
+ *%w[RubyLLM::Providers::OpenAI RubyLLM::Providers::Gemini].map do |name|
25
+ patch_target(name, with: ResponseBodyPatch, optional: true, skip_when_methods_missing: true)
26
+ end
27
+ ]
28
+ end
29
+
30
+ def blocking_seam(resource, record_method, **extras)
31
+ {
32
+ provider: resource.slug.to_s,
33
+ record: lambda do |response, request, latency_ms|
34
+ public_send(record_method, resource, response, request: request, latency_ms: latency_ms, **extras)
35
+ end
36
+ }
37
+ end
38
+
39
+ def request_params(args, kwargs)
40
+ input = args.first
41
+ if input.is_a?(Array)
42
+ input = input.map { |msg| msg.try(:content).then { |content| content.try(:text) || content } || msg }
43
+ end
44
+ kwargs.merge(input: input, model: model_id_from_request(kwargs[:model])).with_indifferent_access
45
+ end
46
+
47
+ def provider_response_id_for(response) = Reply.response_id(response)
48
+
49
+ def keep_usage(result, body)
50
+ usage = body.slice("usage", "usageMetadata") if body.is_a?(Hash)
51
+ result.instance_variable_set(Reply::KEPT_BODY, usage) if usage
52
+ result
53
+ end
54
+
55
+ def record_completion(provider, response, request:, latency_ms:, has_block:)
56
+ reply = reply_for(provider, response, request)
57
+ stream = has_block || request[:stream] == true
58
+ record_usage(reply, latency_ms, stream: stream, service_line_items: reply.service_line_items)
59
+ end
60
+
61
+ def record_embedding(provider, response, request:, latency_ms:)
62
+ return unless active?
63
+
64
+ record_usage(reply_for(provider, response, request), latency_ms, stream: false, output_tokens: 0)
65
+ end
66
+
67
+ def record_transcription(provider, response, request:, latency_ms:)
68
+ reply = reply_for(provider, response, request)
69
+ counts = reply.token_counts
70
+ no_tokens = counts[:input].to_i.zero? && counts[:output].to_i.zero?
71
+ duration = billed_duration(reply.usage, response, no_tokens)
72
+ line_items = Providers::Openai::ServiceCharges.transcription_line_items(duration)
73
+ record_usage(
74
+ reply,
75
+ latency_ms,
76
+ stream: false,
77
+ audio_input_tokens: audio_input_tokens(reply, counts),
78
+ service_line_items: line_items,
79
+ usage_source: (Usage::Source::UNKNOWN if no_tokens && line_items.empty?)
80
+ )
81
+ end
82
+
83
+ def record_image(provider, response, request:, latency_ms:)
84
+ reply = reply_for(provider, response, request)
85
+ usage = response.try(:usage)
86
+ usage = (usage.is_a?(Hash) ? usage : {}).with_indifferent_access
87
+ record_passthrough(
88
+ provider: reply.slug,
89
+ model: reply.model,
90
+ response: response,
91
+ latency_ms: latency_ms,
92
+ usage_source: usage.empty? ? Usage::Source::UNKNOWN : Usage::Source::SDK_RESPONSE,
93
+ **image_tokens(usage, reply.model)
94
+ )
95
+ end
96
+
97
+ def record_moderation(provider, response, request:, latency_ms:)
98
+ reply = reply_for(provider, response, request)
99
+ record_passthrough(
100
+ provider: reply.slug, model: reply.model, response:, latency_ms:, input_tokens: 0, output_tokens: 0
101
+ )
102
+ end
103
+
104
+ private
105
+
106
+ def reply_for(provider, response, request)
107
+ Reply.new(provider, response, model_id_from_request(request[:model]))
108
+ end
109
+
110
+ def model_id_from_request(value)
111
+ return value.to_s if value.is_a?(String) || value.is_a?(Symbol)
112
+
113
+ (value.try(:id) || value.try(:model_id) || value.try(:model))&.to_s
114
+ end
115
+
116
+ def record_usage(reply, latency_ms, **options)
117
+ return unless active?
118
+
119
+ record_safely do
120
+ event = reply.event(**options)
121
+ LlmCostTracker::Tracker.record(event: event, latency_ms: latency_ms) if event
122
+ end
123
+ end
124
+
125
+ def billed_duration(usage, response, no_tokens)
126
+ return usage if usage[:type].to_s == "duration" || usage[:prompt_audio_seconds]
127
+
128
+ { type: "duration", seconds: response.duration&.ceil } if no_tokens
129
+ end
130
+
131
+ def audio_input_tokens(reply, counts)
132
+ tokens = Providers::Openai::UsageExtractor.audio_input_tokens(reply.usage)
133
+ match = Pricing::Matcher.lookup(provider: reply.slug, model: reply.model) if tokens.zero?
134
+ match&.prices&.key?("audio_input") ? counts[:input].to_i : tokens
135
+ end
136
+
137
+ def image_tokens(usage, model)
138
+ extractor = Providers::Openai::UsageExtractor
139
+ image_input = extractor.image_input_tokens(usage)
140
+ image_output, text_output = extractor.split_output(
141
+ output_tokens: usage[:output_tokens].to_i,
142
+ image_output_details: extractor.image_output_tokens(usage),
143
+ text_output_details: extractor.text_output_tokens(usage),
144
+ audio_output: 0,
145
+ default_to_image: model.to_s.match?(/\A(gpt-image-|gemini-.*-image)/)
146
+ )
147
+ { input_tokens: [usage[:input_tokens].to_i - image_input, 0].max, image_input_tokens: image_input,
148
+ output_tokens: text_output, image_output_tokens: image_output }
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Integrations
5
+ module RubyLlm
6
+ module V2
7
+ class Attempt
8
+ class TokenCounts
9
+ REFUSED = { input_tokens: 0, output_tokens: 0 }.freeze
10
+
11
+ def initialize(usage, payload, raw:, request:)
12
+ @usage = usage
13
+ @tokens = usage[:tokens]
14
+ @payload = payload
15
+ @raw = raw
16
+ @request = request
17
+ end
18
+
19
+ def event(result:, response_id: nil)
20
+ return if @usage[:status] != :succeeded && @tokens.to_h == REFUSED
21
+
22
+ model = @payload[:response_model] || @usage[:model]
23
+ line_items = line_items(model, result)
24
+ source = usage_source(known? || line_items.any?, result)
25
+ return unless source
26
+
27
+ Event.build(
28
+ provider: @usage[:provider],
29
+ model: model,
30
+ token_usage: token_usage(model),
31
+ pricing_mode: pricing_mode(model),
32
+ stream: @payload[:streaming],
33
+ usage_source: source,
34
+ provider_response_id: result.try(:id) || response_id,
35
+ service_line_items: line_items
36
+ )
37
+ end
38
+
39
+ def converse_event(counts)
40
+ five_minute, one_hour = cache_writes(counts["cacheWriteInputTokens"].to_i, counts["cacheDetails"])
41
+ Event.build(
42
+ provider: @usage[:provider],
43
+ model: @usage[:model],
44
+ token_usage: Usage::TokenUsage.build(
45
+ input_tokens: counts["inputTokens"],
46
+ output_tokens: counts["outputTokens"],
47
+ cache_read_input_tokens: counts["cacheReadInputTokens"],
48
+ cache_write_input_tokens: five_minute,
49
+ cache_write_extended_input_tokens: one_hour,
50
+ hidden_output_tokens: @tokens.thinking
51
+ ),
52
+ pricing_mode: pricing_mode(@usage[:model]),
53
+ stream: @payload[:streaming],
54
+ usage_source: Usage::Source::SDK_RESPONSE
55
+ )
56
+ end
57
+
58
+ def host
59
+ base = @raw ? @raw.env.url : RubyLLM.config.try("#{@usage[:provider]}_api_base")
60
+ URI(base.to_s).host if base
61
+ end
62
+
63
+ private
64
+
65
+ def known? = @tokens.to_h.any? || !@tokens.reported_cost.nil?
66
+
67
+ def usage_source(known, result)
68
+ return Usage::Source::SDK_RESPONSE if known
69
+ return Usage::Source::UNKNOWN unless @usage[:status] == :succeeded
70
+ return if result.nil? || @usage[:operation] == :chat
71
+
72
+ @usage[:operation] == :moderation ? Usage::Source::SDK_RESPONSE : Usage::Source::UNKNOWN
73
+ end
74
+
75
+ def token_usage(model)
76
+ input = @tokens.input.to_i
77
+ output = @tokens.output.to_i
78
+ audio = @usage[:operation] == :transcription && audio_priced?(model) ? input : 0
79
+ image = @usage[:operation] == :image ? output : 0
80
+ five_minute, one_hour = cache_writes(@tokens.cache_write.to_i, nil)
81
+ Usage::TokenUsage.build(
82
+ input_tokens: input - audio,
83
+ audio_input_tokens: audio,
84
+ image_input_tokens: @usage[:image_tokens],
85
+ output_tokens: output - image,
86
+ image_output_tokens: image,
87
+ cache_read_input_tokens: @tokens.cache_read.to_i,
88
+ cache_write_input_tokens: five_minute,
89
+ cache_write_extended_input_tokens: one_hour,
90
+ hidden_output_tokens: @tokens.thinking.to_i
91
+ )
92
+ end
93
+
94
+ def audio_priced?(model)
95
+ Pricing::Matcher.lookup(provider: @usage[:provider], model: model)&.prices&.key?("audio_input")
96
+ end
97
+
98
+ def cache_writes(total, details)
99
+ one_hour = if details.is_a?(Array)
100
+ details.grep(Hash).sum { |detail| detail["ttl"] == "1h" ? detail["inputTokens"].to_i : 0 }
101
+ else
102
+ @payload[:caching].try(:[], :ttl).to_s == "1h" ? total : 0
103
+ end
104
+ [[total - one_hour, 0].max, one_hour]
105
+ end
106
+
107
+ def line_items(model, result)
108
+ return result_line_items(model, result) unless known? && @usage[:operation] != :speech
109
+
110
+ service_line_items(model, result)
111
+ end
112
+
113
+ def service_line_items(model, result)
114
+ cost = @tokens.reported_cost
115
+ return Providers::Openai::ServiceCharges.billed_line_items(cost: cost) if cost
116
+
117
+ calls = Array(result.try(:server_tool_calls)).map(&:raw).grep(Hash)
118
+ grounding = { "candidates" => calls.map { |call| { "groundingMetadata" => call } } }
119
+ server_tool_use = @tokens.server_tool_use&.symbolize_keys
120
+ Providers::Anthropic::UsageExtractor.service_line_items(server_tool_use: server_tool_use) +
121
+ Providers::Openai::ServiceCharges.line_items_from_output(calls, request: @request, model: model) +
122
+ Providers::Gemini::Parser.new.service_line_items_for(grounding, model: model)
123
+ end
124
+
125
+ def result_line_items(model, result)
126
+ return [] unless result
127
+
128
+ charges = Providers::Openai::ServiceCharges
129
+ case @usage[:operation]
130
+ when :speech then charges.speech_line_items("input" => @payload[:input], "model" => model)
131
+ when :transcription
132
+ seconds = result.try(:duration)
133
+ seconds ? charges.transcription_line_items(type: "duration", seconds: seconds.to_f.ceil) : []
134
+ when :ocr then charges.ocr_line_items(result.try(:raw).to_h)
135
+ when :rerank then charges.rerank_line_items(result.try(:raw).to_h)
136
+ else []
137
+ end
138
+ end
139
+
140
+ def pricing_mode(model)
141
+ case @usage[:provider]
142
+ when "anthropic", "bedrock"
143
+ Providers::Anthropic::UsageExtractor.pricing_mode(request: @request.merge(model: model), usage: nil)
144
+ when "gemini"
145
+ Providers::Gemini::Parser.new.pricing_mode(
146
+ request: @request, usage: nil, response_headers: @raw&.headers
147
+ )
148
+ else
149
+ Providers::Openai::ResponseParser.combined_pricing_mode(
150
+ provider: @usage[:provider], host: host, model: model, service_tier: @request[:service_tier]
151
+ )
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "attempt/token_counts"
5
+
6
+ module LlmCostTracker
7
+ module Integrations
8
+ module RubyLlm
9
+ module V2
10
+ class Attempt
11
+ attr_reader :usage, :latency_ms
12
+
13
+ class << self
14
+ def stream_window
15
+ Capture::EventWindow.new(notable: method(:notable_event?),
16
+ trim: stream_parsers.last.method(:trim_stream_event))
17
+ end
18
+
19
+ def batch_event(usage, result, base)
20
+ base = nil unless URI(base.to_s).host.to_s.match?(/\A(?:us|eu)\.|aiplatform\./i)
21
+ raw = Faraday::Response.new(status: 200, response_body: result.try(:raw), url: URI(base.to_s))
22
+ parsed = new(usage, { response: result }, final: true, raw: raw).event
23
+ return unless parsed
24
+
25
+ source = parsed.usage_source == Usage::Source::UNKNOWN ? parsed.usage_source : Usage::Source::SDK_BATCH_RESULT
26
+ parsed.with(pricing_mode: Pricing::Mode.merge("batch", parsed.pricing_mode), usage_source: source)
27
+ end
28
+
29
+ def request_params(raw, payload)
30
+ body = raw&.env&.request_body
31
+ params = body.is_a?(String) ? JSON.parse(body) : body
32
+ ((params.presence if params.is_a?(Hash)) || payload[:provider_options].to_h).with_indifferent_access
33
+ rescue JSON::ParserError
34
+ payload[:provider_options].to_h.with_indifferent_access
35
+ end
36
+
37
+ def stream_parsers
38
+ @stream_parsers ||= [Providers::Anthropic::Parser.new, Providers::Gemini::Parser.new,
39
+ Providers::Openai::Parser.new]
40
+ end
41
+
42
+ private
43
+
44
+ def notable_event?(data) = stream_parsers.any? { |parser| parser.retain_stream_event?(data) }
45
+ end
46
+
47
+ def initialize(usage, payload, final:, latency_ms: nil, events: nil, response: nil, raw: nil, provider: nil)
48
+ @usage = usage
49
+ @payload = payload
50
+ @final = final
51
+ @latency_ms = latency_ms
52
+ @events = events
53
+ @response = response
54
+ @raw = raw
55
+ @provider = provider
56
+ end
57
+
58
+ def event
59
+ base = Faraday::Response.new(url: URI(@provider.api_base.to_s)) if @provider
60
+ @response.is_a?(Hash) ? transcript_event(base) : response_event(base)
61
+ end
62
+
63
+ private
64
+
65
+ def transcript_event(base)
66
+ counts = TokenCounts.new(@usage, @payload, raw: base, request: Attempt.request_params(base, @payload))
67
+ if @final && openai_usage?(@response["usage"])
68
+ event = openai_event(@usage, @response, Attempt.request_params(base, @payload), counts.host)
69
+ end
70
+ (event || counts.event(result: (@payload[:result] if @final)))&.with(stream: true)
71
+ end
72
+
73
+ def response_event(base)
74
+ own = faraday_response(@raw, *(shared_responses if @final))
75
+ raw = faraday_response(@raw, *shared_responses) || base
76
+ request = Attempt.request_params(raw, @payload)
77
+ usage = billed_units(own)
78
+ converse_event(usage, own) || stream_event(usage, raw, request) || parsed_event(usage, own, request) ||
79
+ TokenCounts.new(usage, @payload, raw: raw, request: request)
80
+ .event(result: (result if @final), response_id: response_id(own))
81
+ end
82
+
83
+ def result = @payload[:response] || @payload[:result]
84
+
85
+ def shared_responses = [@response, result.try(:raw)]
86
+
87
+ def faraday_response(*candidates) = candidates.find { |candidate| candidate.is_a?(Faraday::Response) }
88
+
89
+ def converse_event(usage, own)
90
+ data = bodies(own).reverse.find { |body| body["usage"].try(:key?, "inputTokens") }
91
+ return unless data
92
+
93
+ request = { service_tier: data["serviceTier"].try(:[], "type") }
94
+ TokenCounts.new(usage, @payload, raw: nil, request: request).converse_event(data["usage"])
95
+ end
96
+
97
+ def stream_event(usage, raw, request)
98
+ return unless @events
99
+
100
+ context = raw_context(raw, request).merge(response_status: 200, events: @events)
101
+ Attempt.stream_parsers.lazy.filter_map { |parser| parser.parse_stream(**context) }
102
+ .find { |parsed| parsed.usage_source != Usage::Source::UNKNOWN }
103
+ &.with(provider: usage[:provider], usage_source: Usage::Source::SDK_RESPONSE)
104
+ end
105
+
106
+ def parsed_event(usage, own, request)
107
+ body = own&.body
108
+ return unless body.is_a?(Hash)
109
+
110
+ response = raw_context(own, request).merge(response_status: own.status, response_body: body)
111
+ event = body_event(usage, own, request, response)
112
+ event&.with(provider: usage[:provider], usage_source: Usage::Source::SDK_RESPONSE)
113
+ end
114
+
115
+ def body_event(usage, own, request, response)
116
+ body = response[:response_body]
117
+ if body["type"] == "message" then Providers::Anthropic::Parser.new.parse(**response)
118
+ elsif body.key?("usageMetadata") || body["object"] == "interaction" then gemini_event(response)
119
+ elsif openai_usage?(body["usage"]) then openai_event(usage, body, request, own.env.url.host)
120
+ end
121
+ end
122
+
123
+ def raw_context(raw, request)
124
+ { request_url: raw.env.url.to_s, request_body: request, response_headers: raw.headers }
125
+ end
126
+
127
+ def gemini_event(response)
128
+ event = Providers::Gemini::Parser.new.parse(**response)
129
+ body = response[:response_body]
130
+ details = body.dig("usageMetadata", body.key?("embedding") ? "promptTokensDetails" : "promptTokenDetails")
131
+ video = Providers::Gemini::UsageExtractor.modality_tokens(details, "VIDEO")
132
+ return event unless event && video.positive?
133
+
134
+ event.with(line_items: [*event.line_items,
135
+ Charges::LineItem.build(dimension_key: "video_input", quantity: video)])
136
+ end
137
+
138
+ def openai_event(usage, body, request, host)
139
+ request[:model] ||= usage[:model]
140
+ Providers::Openai::ResponseParser.event_from_response(
141
+ response: body,
142
+ request: request,
143
+ provider: usage[:provider],
144
+ host: host,
145
+ usage_source: Usage::Source::SDK_RESPONSE
146
+ )
147
+ end
148
+
149
+ def openai_usage?(usage)
150
+ usage.is_a?(Hash) &&
151
+ (usage.key?("input_tokens") || usage.key?("prompt_tokens") || usage.key?("cost_in_usd_ticks") ||
152
+ usage["type"] == "duration")
153
+ end
154
+
155
+ def bodies(own) = [own&.body, *@events&.map { |event| event[:data] }].grep(Hash)
156
+
157
+ def response_id(own) = bodies(own).filter_map { |body| body["id"] || body["responseId"] }.first
158
+
159
+ def billed_units(own)
160
+ units = bodies(own).filter_map do |data|
161
+ (data["type"] == "message-end" ? data.dig("delta", "usage") : data["usage"] || data["meta"])
162
+ .try(:[], "billed_units")
163
+ end.last
164
+ return @usage unless units
165
+
166
+ tokens = @usage[:tokens]
167
+ @usage.merge(tokens: RubyLLM::Tokens.new(input: units["input_tokens"] || tokens.input,
168
+ output: units["output_tokens"] || tokens.output),
169
+ image_tokens: units["image_tokens"])
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end