llm_cost_tracker 0.14.1 → 0.14.2
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 +82 -0
- data/README.md +16 -5
- data/app/assets/llm_cost_tracker/application.css +16 -0
- data/app/controllers/llm_cost_tracker/calls_controller.rb +1 -3
- data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
- data/app/controllers/llm_cost_tracker/tags_controller.rb +5 -4
- data/app/helpers/llm_cost_tracker/application_helper.rb +4 -25
- data/app/helpers/llm_cost_tracker/chart_helper.rb +6 -10
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +8 -28
- data/app/models/llm_cost_tracker/call.rb +10 -14
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +4 -15
- data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -9
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +2 -3
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -0
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -2
- data/app/views/llm_cost_tracker/calls/show.html.erb +8 -22
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -4
- data/app/views/llm_cost_tracker/tags/show.html.erb +5 -5
- data/lib/llm_cost_tracker/budget/per_tag.rb +15 -9
- data/lib/llm_cost_tracker/budget.rb +44 -55
- data/lib/llm_cost_tracker/capture/event_window.rb +3 -2
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +5 -1
- data/lib/llm_cost_tracker/capture/stream_collector.rb +25 -26
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +9 -23
- data/lib/llm_cost_tracker/capture_verifier.rb +1 -7
- data/lib/llm_cost_tracker/charges/line_item.rb +5 -1
- data/lib/llm_cost_tracker/configuration/budgets.rb +1 -1
- data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
- data/lib/llm_cost_tracker/configuration.rb +5 -9
- data/lib/llm_cost_tracker/doctor/price_check.rb +13 -3
- data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
- data/lib/llm_cost_tracker/doctor.rb +19 -2
- data/lib/llm_cost_tracker/engine.rb +0 -1
- data/lib/llm_cost_tracker/event.rb +8 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +11 -9
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
- data/lib/llm_cost_tracker/ingestion/inbox.rb +2 -8
- data/lib/llm_cost_tracker/ingestion.rb +3 -1
- data/lib/llm_cost_tracker/integrations/anthropic.rb +63 -10
- data/lib/llm_cost_tracker/integrations/base.rb +39 -18
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +16 -13
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +8 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +42 -59
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +241 -76
- data/lib/llm_cost_tracker/integrations.rb +1 -20
- data/lib/llm_cost_tracker/ledger/isolation.rb +0 -7
- data/lib/llm_cost_tracker/ledger/period/totals.rb +14 -10
- data/lib/llm_cost_tracker/ledger/rollups.rb +14 -14
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +14 -3
- data/lib/llm_cost_tracker/ledger/schema/base.rb +5 -4
- data/lib/llm_cost_tracker/ledger/store.rb +2 -2
- data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +1 -5
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
- data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
- data/lib/llm_cost_tracker/middleware/faraday.rb +36 -19
- data/lib/llm_cost_tracker/parsers.rb +7 -40
- data/lib/llm_cost_tracker/prices.json +2237 -154
- data/lib/llm_cost_tracker/pricing/backfill.rb +46 -13
- data/lib/llm_cost_tracker/pricing/calculation.rb +118 -75
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
- data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
- data/lib/llm_cost_tracker/pricing/matcher.rb +36 -22
- data/lib/llm_cost_tracker/pricing/mode.rb +2 -13
- data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +5 -16
- data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
- data/lib/llm_cost_tracker/pricing/sync.rb +22 -71
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +33 -3
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +11 -5
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +81 -12
- data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -7
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +172 -48
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +13 -22
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -1
- data/lib/llm_cost_tracker/providers/openai/parser.rb +11 -13
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +96 -26
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +57 -40
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +14 -1
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +3 -1
- data/lib/llm_cost_tracker/report/data.rb +1 -1
- data/lib/llm_cost_tracker/retention.rb +1 -3
- data/lib/llm_cost_tracker/tracker.rb +17 -9
- data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
- data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
- data/lib/llm_cost_tracker/usage/dimensions.yml +61 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/tasks/llm_cost_tracker.rake +24 -21
- data/llm_cost_tracker.gemspec +63 -0
- metadata +8 -7
|
@@ -21,10 +21,11 @@ module LlmCostTracker
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def current_schema_errors
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
Adapter.ensure_supported!(model.connection)
|
|
25
|
+
unless model.table_exists?
|
|
26
|
+
model.connection.schema_cache.clear_data_source_cache!(model.table_name)
|
|
27
|
+
return ["#{model.table_name} table is missing"] unless model.table_exists?
|
|
28
|
+
end
|
|
28
29
|
|
|
29
30
|
columns_hash = model.columns_hash
|
|
30
31
|
cache = @schema_capabilities
|
|
@@ -118,10 +118,10 @@ module LlmCostTracker
|
|
|
118
118
|
|
|
119
119
|
def insert_call_tags(events, call_ids)
|
|
120
120
|
rows = events.flat_map do |event|
|
|
121
|
-
(event.tags || {}).map do |key, value|
|
|
121
|
+
(event.tags || {}).to_h { |key, value| [key.to_s, value] }.map do |key, value|
|
|
122
122
|
{
|
|
123
123
|
llm_cost_tracker_call_id: call_ids.fetch(event.event_id),
|
|
124
|
-
key: key
|
|
124
|
+
key: key,
|
|
125
125
|
value: Tags::Encoding.encode(Storable.clean(value))
|
|
126
126
|
}.merge(budget_columns_for(event))
|
|
127
127
|
end
|
|
@@ -19,7 +19,7 @@ module LlmCostTracker
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def value_arel
|
|
22
|
-
Arel.sql(
|
|
22
|
+
Arel.sql(raw_value_sql(LlmCostTracker::CallTag.connection))
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def label_sql(connection)
|
|
@@ -35,10 +35,6 @@ module LlmCostTracker
|
|
|
35
35
|
def call_tag_table
|
|
36
36
|
LlmCostTracker::CallTag.quoted_table_name
|
|
37
37
|
end
|
|
38
|
-
|
|
39
|
-
def quote_column(name)
|
|
40
|
-
LlmCostTracker::CallTag.connection.quote_column_name(name)
|
|
41
|
-
end
|
|
42
38
|
end
|
|
43
39
|
end
|
|
44
40
|
end
|
|
@@ -10,8 +10,7 @@ module LlmCostTracker
|
|
|
10
10
|
|
|
11
11
|
def self.encode(value)
|
|
12
12
|
encoded = case value
|
|
13
|
-
when Hash then JSON.generate(
|
|
14
|
-
when Array then JSON.generate(normalize_array(value))
|
|
13
|
+
when Hash, Array then JSON.generate(normalize_value(value))
|
|
15
14
|
else value.to_s
|
|
16
15
|
end
|
|
17
16
|
truncate(encoded)
|
|
@@ -24,18 +23,10 @@ module LlmCostTracker
|
|
|
24
23
|
string.byteslice(0, limit).encode("UTF-8", invalid: :replace, undef: :replace)
|
|
25
24
|
end
|
|
26
25
|
|
|
27
|
-
def self.normalize_hash(hash)
|
|
28
|
-
hash.transform_keys(&:to_s).sort.to_h.transform_values { |v| normalize_value(v) }
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def self.normalize_array(array)
|
|
32
|
-
array.map { |v| normalize_value(v) }
|
|
33
|
-
end
|
|
34
|
-
|
|
35
26
|
def self.normalize_value(value)
|
|
36
27
|
case value
|
|
37
|
-
when Hash then
|
|
38
|
-
when Array then
|
|
28
|
+
when Hash then value.transform_keys(&:to_s).sort.to_h.transform_values { |v| normalize_value(v) }
|
|
29
|
+
when Array then value.map { |v| normalize_value(v) }
|
|
39
30
|
else value.to_s
|
|
40
31
|
end
|
|
41
32
|
end
|
|
@@ -9,8 +9,6 @@ module LlmCostTracker
|
|
|
9
9
|
class << self
|
|
10
10
|
def apply(tags)
|
|
11
11
|
normalized_tags = (tags || {}).to_h.transform_keys(&:to_s).transform_values { |v| Encoding.encode(v) }
|
|
12
|
-
return LlmCostTracker::Call.all if normalized_tags.empty?
|
|
13
|
-
|
|
14
12
|
normalized_tags.inject(LlmCostTracker::Call.all) do |relation, (key, value)|
|
|
15
13
|
relation.where(id: LlmCostTracker::CallTag.where(key: key,
|
|
16
14
|
value: value).select(:llm_cost_tracker_call_id))
|
|
@@ -11,6 +11,8 @@ require_relative "../timing"
|
|
|
11
11
|
module LlmCostTracker
|
|
12
12
|
module Middleware
|
|
13
13
|
class Faraday < ::Faraday::Middleware
|
|
14
|
+
MULTIPART_MODEL = /name="model"\r\n(?:[^\r\n]+\r\n)*\r\n([^\r\n]+)\r\n/
|
|
15
|
+
|
|
14
16
|
def initialize(app, **options)
|
|
15
17
|
super(app)
|
|
16
18
|
@tags = options.fetch(:tags, {})
|
|
@@ -20,8 +22,8 @@ module LlmCostTracker
|
|
|
20
22
|
return @app.call(request_env) unless LlmCostTracker.configuration.enabled
|
|
21
23
|
|
|
22
24
|
request_url = request_env.url.to_s
|
|
23
|
-
request_body = read_body(request_env.body)
|
|
24
25
|
parser = Parsers.find_for(request_url)
|
|
26
|
+
request_body = read_body(request_env.body) || multipart_model_body(request_env, parser)
|
|
25
27
|
request_parsed = parser&.safe_json_parse(request_body)
|
|
26
28
|
streaming = parser&.streaming_request?(request_url, request_parsed)
|
|
27
29
|
if streaming
|
|
@@ -29,8 +31,8 @@ module LlmCostTracker
|
|
|
29
31
|
end
|
|
30
32
|
stream_buffer = install_stream_tap(request_env, parser) if streaming
|
|
31
33
|
|
|
32
|
-
if parser
|
|
33
|
-
|
|
34
|
+
context_tags, metadata = tag_snapshot(request_env) if parser
|
|
35
|
+
if parser && request_env.method == :post
|
|
34
36
|
Budget.enforce!(
|
|
35
37
|
provider: parser.provider_for(request_url),
|
|
36
38
|
model: parser.model_for(request_url, request_parsed),
|
|
@@ -72,7 +74,7 @@ module LlmCostTracker
|
|
|
72
74
|
request_url: request_url,
|
|
73
75
|
request_body: request_body,
|
|
74
76
|
response_env: response_env,
|
|
75
|
-
latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
|
|
77
|
+
latency_ms: (LlmCostTracker::Timing.elapsed_ms(started_at) if request_env.method == :post),
|
|
76
78
|
streaming: streaming,
|
|
77
79
|
stream_buffer: stream_buffer,
|
|
78
80
|
context_tags: context_tags,
|
|
@@ -165,16 +167,12 @@ module LlmCostTracker
|
|
|
165
167
|
response_env: response_env
|
|
166
168
|
)
|
|
167
169
|
end
|
|
168
|
-
return unless parsed
|
|
169
170
|
|
|
170
|
-
Tracker.record(
|
|
171
|
-
event: parsed,
|
|
172
|
-
latency_ms: latency_ms,
|
|
173
|
-
metadata: metadata,
|
|
174
|
-
context_tags: context_tags
|
|
175
|
-
)
|
|
171
|
+
Tracker.record(event: parsed, latency_ms: latency_ms, metadata: metadata, context_tags: context_tags) if parsed
|
|
176
172
|
rescue *LlmCostTracker::CALLER_ERRORS
|
|
177
173
|
raise
|
|
174
|
+
rescue ActiveRecord::RecordNotUnique
|
|
175
|
+
nil
|
|
178
176
|
rescue StandardError => e
|
|
179
177
|
Logging.warn("Error processing response: #{e.class}: #{e.message}")
|
|
180
178
|
end
|
|
@@ -261,16 +259,35 @@ module LlmCostTracker
|
|
|
261
259
|
end
|
|
262
260
|
end
|
|
263
261
|
|
|
262
|
+
def multipart_model_body(request_env, parser)
|
|
263
|
+
body = request_env.body
|
|
264
|
+
multipart = request_env.request_headers["Content-Type"].to_s.start_with?("multipart/form-data")
|
|
265
|
+
return nil unless parser && multipart && body.respond_to?(:read) && body.respond_to?(:rewind)
|
|
266
|
+
|
|
267
|
+
model = multipart_model(body)
|
|
268
|
+
model && { "model" => model }.to_json
|
|
269
|
+
rescue StandardError => e
|
|
270
|
+
Logging.warn("Unable to read the model from a multipart request: #{e.class}: #{e.message}")
|
|
271
|
+
nil
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def multipart_model(body)
|
|
275
|
+
window = String.new(encoding: Encoding::BINARY)
|
|
276
|
+
while (chunk = body.read(65_536))
|
|
277
|
+
window << chunk.b
|
|
278
|
+
model = window[MULTIPART_MODEL, 1]
|
|
279
|
+
return model.force_encoding(Encoding::UTF_8) if model
|
|
280
|
+
|
|
281
|
+
window = window.byteslice([window.bytesize - 512, 0].max..)
|
|
282
|
+
end
|
|
283
|
+
ensure
|
|
284
|
+
body.rewind
|
|
285
|
+
end
|
|
286
|
+
|
|
264
287
|
def resolved_tags(request_env)
|
|
265
|
-
tags
|
|
266
|
-
if @tags.respond_to?(:call)
|
|
267
|
-
@tags.arity.zero? ? @tags.call : @tags.call(request_env)
|
|
268
|
-
else
|
|
269
|
-
@tags
|
|
270
|
-
end
|
|
271
|
-
return {} if tags.nil?
|
|
288
|
+
return @tags.to_h unless @tags.respond_to?(:call)
|
|
272
289
|
|
|
273
|
-
tags.to_h
|
|
290
|
+
(@tags.arity.zero? ? @tags.call : @tags.call(request_env)).to_h
|
|
274
291
|
end
|
|
275
292
|
|
|
276
293
|
def tag_snapshot(request_env)
|
|
@@ -8,50 +8,27 @@ require_relative "providers"
|
|
|
8
8
|
|
|
9
9
|
module LlmCostTracker
|
|
10
10
|
module Parsers
|
|
11
|
-
PARSER_PROVIDERS = %i[Openai Azure
|
|
11
|
+
PARSER_PROVIDERS = %i[Openai Azure Anthropic Gemini OpenaiCompatible].freeze
|
|
12
12
|
|
|
13
13
|
def self.find_for(url)
|
|
14
|
-
instances.
|
|
15
|
-
return instance if klass.match?(url)
|
|
16
|
-
end
|
|
17
|
-
nil
|
|
14
|
+
instances.find { |parser| parser.class.match?(url) }
|
|
18
15
|
end
|
|
19
16
|
|
|
20
17
|
def self.find_for_provider(provider)
|
|
21
|
-
|
|
22
|
-
instances.each do |klass, instance|
|
|
23
|
-
return instance if klass.provider_names.include?(provider_name)
|
|
24
|
-
end
|
|
25
|
-
nil
|
|
18
|
+
all_for_provider(provider).first
|
|
26
19
|
end
|
|
27
20
|
|
|
28
|
-
def self.
|
|
29
|
-
|
|
21
|
+
def self.all_for_provider(provider)
|
|
22
|
+
provider_name = provider.to_s.downcase
|
|
23
|
+
instances.select { |parser| parser.class.provider_names.include?(provider_name) }
|
|
30
24
|
end
|
|
31
|
-
private_class_method :parser_classes
|
|
32
25
|
|
|
33
26
|
def self.instances
|
|
34
|
-
@instances ||=
|
|
27
|
+
@instances ||= PARSER_PROVIDERS.map { |name| Providers.const_get(name)::Parser.new }.freeze
|
|
35
28
|
end
|
|
36
29
|
private_class_method :instances
|
|
37
30
|
|
|
38
31
|
module UrlMatchers
|
|
39
|
-
def match_uri?(url, hosts: nil, exact_paths: nil, path_includes: nil, path_suffixes: nil, path_pattern: nil)
|
|
40
|
-
uri_matches?(url) do |uri|
|
|
41
|
-
host_match = hosts.nil? || hosts.include?(uri.host.to_s.downcase)
|
|
42
|
-
path_match = path_matches?(
|
|
43
|
-
uri,
|
|
44
|
-
exact_paths: exact_paths,
|
|
45
|
-
path_includes: path_includes,
|
|
46
|
-
path_suffixes: path_suffixes,
|
|
47
|
-
path_pattern: path_pattern
|
|
48
|
-
)
|
|
49
|
-
extra_match = block_given? ? yield(uri) : true
|
|
50
|
-
|
|
51
|
-
!!(host_match && path_match && extra_match)
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
|
|
55
32
|
def uri_matches?(url)
|
|
56
33
|
uri = parsed_uri(url)
|
|
57
34
|
uri ? yield(uri) : false
|
|
@@ -62,16 +39,6 @@ module LlmCostTracker
|
|
|
62
39
|
rescue URI::InvalidURIError
|
|
63
40
|
nil
|
|
64
41
|
end
|
|
65
|
-
|
|
66
|
-
def path_matches?(uri, exact_paths: nil, path_includes: nil, path_suffixes: nil, path_pattern: nil)
|
|
67
|
-
path = uri.path.to_s
|
|
68
|
-
matches = true
|
|
69
|
-
matches &&= exact_paths.include?(path) if exact_paths
|
|
70
|
-
matches &&= Array(path_includes).all? { |fragment| path.include?(fragment) } if path_includes
|
|
71
|
-
matches &&= path.match?(path_pattern) if path_pattern
|
|
72
|
-
matches &&= path_suffixes.any? { |suffix| path == suffix || path.end_with?(suffix) } if path_suffixes
|
|
73
|
-
matches
|
|
74
|
-
end
|
|
75
42
|
end
|
|
76
43
|
|
|
77
44
|
class Base
|