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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -0
- data/LICENSE.txt +28 -0
- data/README.md +33 -41
- data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -94
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
- data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -14
- data/app/helpers/llm_cost_tracker/application_helper.rb +22 -31
- data/app/helpers/llm_cost_tracker/chart_helper.rb +57 -108
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +30 -0
- data/app/models/llm_cost_tracker/call.rb +6 -4
- data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
- data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
- data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +66 -210
- data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
- data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +19 -17
- data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +7 -8
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -4
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -4
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
- data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
- data/app/views/llm_cost_tracker/calls/show.html.erb +3 -14
- data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
- data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
- data/lib/llm_cost_tracker/budget/global.rb +69 -0
- data/lib/llm_cost_tracker/budget/limit.rb +52 -0
- data/lib/llm_cost_tracker/budget/per_tag.rb +59 -27
- data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
- data/lib/llm_cost_tracker/budget.rb +19 -157
- data/lib/llm_cost_tracker/capture/event_window.rb +16 -13
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +22 -17
- data/lib/llm_cost_tracker/capture/sse.rb +7 -5
- data/lib/llm_cost_tracker/capture/stream_collector.rb +89 -96
- data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +32 -37
- data/lib/llm_cost_tracker/capture_verifier.rb +2 -3
- data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
- data/lib/llm_cost_tracker/charges/line_item.rb +31 -33
- data/lib/llm_cost_tracker/configuration/budgets.rb +49 -18
- data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
- data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
- data/lib/llm_cost_tracker/doctor/price_check.rb +24 -35
- data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
- data/lib/llm_cost_tracker/doctor.rb +23 -20
- data/lib/llm_cost_tracker/engine.rb +8 -0
- data/lib/llm_cost_tracker/errors.rb +2 -4
- data/lib/llm_cost_tracker/event.rb +22 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +6 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +2 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
- data/lib/llm_cost_tracker/ingestion/inbox.rb +20 -11
- data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
- data/lib/llm_cost_tracker/ingestion.rb +20 -35
- data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
- data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
- data/lib/llm_cost_tracker/integrations/anthropic.rb +59 -120
- data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
- data/lib/llm_cost_tracker/integrations/base.rb +65 -81
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +20 -19
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +51 -40
- data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +114 -83
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -408
- data/lib/llm_cost_tracker/ledger/isolation.rb +15 -2
- data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
- data/lib/llm_cost_tracker/ledger/rollups.rb +24 -8
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +23 -6
- data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -5
- data/lib/llm_cost_tracker/ledger/store.rb +17 -35
- data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -2
- data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
- data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
- data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
- data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +2 -301
- data/lib/llm_cost_tracker/parsers.rb +12 -0
- data/lib/llm_cost_tracker/prices.json +1179 -208
- data/lib/llm_cost_tracker/pricing/backfill.rb +59 -29
- data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
- data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
- data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
- data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
- data/lib/llm_cost_tracker/pricing/calculation.rb +82 -173
- data/lib/llm_cost_tracker/pricing/matcher.rb +31 -14
- data/lib/llm_cost_tracker/pricing/mode.rb +6 -19
- data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
- data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +38 -111
- data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
- data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
- data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +7 -53
- data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +30 -25
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +5 -3
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +122 -101
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
- data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -6
- data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +120 -183
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +66 -47
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +11 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
- data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
- data/lib/llm_cost_tracker/providers/openai/parser.rb +2 -1
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +81 -149
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +133 -156
- data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -62
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +15 -4
- data/lib/llm_cost_tracker/providers.rb +3 -0
- data/lib/llm_cost_tracker/redaction.rb +13 -0
- data/lib/llm_cost_tracker/report/data.rb +25 -28
- data/lib/llm_cost_tracker/tags/context.rb +22 -7
- data/lib/llm_cost_tracker/tracker.rb +32 -26
- data/lib/llm_cost_tracker/usage/dimensions.yml +24 -0
- data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +8 -4
- data/lib/tasks/llm_cost_tracker.rake +35 -5
- data/llm_cost_tracker.gemspec +6 -5
- 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
|