llm_cost_tracker 0.4.1 → 0.5.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 +19 -0
- data/README.md +182 -100
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +37 -0
- data/lib/llm_cost_tracker/configuration.rb +10 -5
- data/lib/llm_cost_tracker/doctor.rb +166 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +33 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +12 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +53 -21
- data/lib/llm_cost_tracker/integrations/anthropic.rb +75 -0
- data/lib/llm_cost_tracker/integrations/base.rb +72 -0
- data/lib/llm_cost_tracker/integrations/object_reader.rb +56 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +95 -0
- data/lib/llm_cost_tracker/integrations/registry.rb +41 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +4 -3
- data/lib/llm_cost_tracker/parsed_usage.rb +8 -1
- data/lib/llm_cost_tracker/parsers/base.rb +1 -1
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +1 -1
- data/lib/llm_cost_tracker/price_freshness.rb +38 -0
- data/lib/llm_cost_tracker/price_registry.rb +14 -0
- data/lib/llm_cost_tracker/price_sync/fetcher.rb +2 -1
- data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +4 -2
- data/lib/llm_cost_tracker/price_sync.rb +10 -0
- data/lib/llm_cost_tracker/prices.json +394 -41
- data/lib/llm_cost_tracker/pricing.rb +8 -1
- data/lib/llm_cost_tracker/request_url.rb +20 -0
- data/lib/llm_cost_tracker/stream_collector.rb +3 -3
- data/lib/llm_cost_tracker/tag_context.rb +52 -0
- data/lib/llm_cost_tracker/tracker.rb +5 -2
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +14 -4
- data/lib/tasks/llm_cost_tracker.rake +21 -3
- metadata +12 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +0 -51
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Integrations
|
|
7
|
+
module Openai
|
|
8
|
+
extend Base
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def integration_name = :openai
|
|
12
|
+
|
|
13
|
+
def target_patches
|
|
14
|
+
[
|
|
15
|
+
[constant("OpenAI::Resources::Responses"), ResponsesPatch],
|
|
16
|
+
[constant("OpenAI::Resources::Chat::Completions"), ChatCompletionsPatch]
|
|
17
|
+
]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def record_response(response, request:, latency_ms:)
|
|
21
|
+
return unless active?
|
|
22
|
+
|
|
23
|
+
record_safely do
|
|
24
|
+
usage = ObjectReader.first(response, :usage)
|
|
25
|
+
next unless usage
|
|
26
|
+
|
|
27
|
+
input_tokens = ObjectReader.first(usage, :input_tokens, :prompt_tokens)
|
|
28
|
+
output_tokens = ObjectReader.first(usage, :output_tokens, :completion_tokens)
|
|
29
|
+
next if input_tokens.nil? && output_tokens.nil?
|
|
30
|
+
|
|
31
|
+
LlmCostTracker::Tracker.record(
|
|
32
|
+
provider: "openai",
|
|
33
|
+
model: ObjectReader.first(response, :model) || request[:model],
|
|
34
|
+
input_tokens: ObjectReader.integer(input_tokens),
|
|
35
|
+
output_tokens: ObjectReader.integer(output_tokens),
|
|
36
|
+
latency_ms: latency_ms,
|
|
37
|
+
usage_source: :sdk_response,
|
|
38
|
+
provider_response_id: ObjectReader.first(response, :id),
|
|
39
|
+
metadata: usage_metadata(usage)
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def usage_metadata(usage)
|
|
45
|
+
{
|
|
46
|
+
cache_read_input_tokens: cache_read_input_tokens(usage),
|
|
47
|
+
hidden_output_tokens: hidden_output_tokens(usage)
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def cache_read_input_tokens(usage)
|
|
52
|
+
ObjectReader.integer(
|
|
53
|
+
ObjectReader.nested(usage, :input_tokens_details, :cached_tokens) ||
|
|
54
|
+
ObjectReader.nested(usage, :prompt_tokens_details, :cached_tokens)
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def hidden_output_tokens(usage)
|
|
59
|
+
ObjectReader.integer(
|
|
60
|
+
ObjectReader.nested(usage, :output_tokens_details, :reasoning_tokens) ||
|
|
61
|
+
ObjectReader.nested(usage, :completion_tokens_details, :reasoning_tokens)
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
module ResponsesPatch
|
|
67
|
+
def create(*args, **kwargs)
|
|
68
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
69
|
+
LlmCostTracker::Integrations::Openai.enforce_budget!
|
|
70
|
+
response = super
|
|
71
|
+
LlmCostTracker::Integrations::Openai.record_response(
|
|
72
|
+
response,
|
|
73
|
+
request: LlmCostTracker::Integrations::Openai.request_params(args, kwargs),
|
|
74
|
+
latency_ms: LlmCostTracker::Integrations::Openai.elapsed_ms(started_at)
|
|
75
|
+
)
|
|
76
|
+
response
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
module ChatCompletionsPatch
|
|
81
|
+
def create(*args, **kwargs)
|
|
82
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
83
|
+
LlmCostTracker::Integrations::Openai.enforce_budget!
|
|
84
|
+
response = super
|
|
85
|
+
LlmCostTracker::Integrations::Openai.record_response(
|
|
86
|
+
response,
|
|
87
|
+
request: LlmCostTracker::Integrations::Openai.request_params(args, kwargs),
|
|
88
|
+
latency_ms: LlmCostTracker::Integrations::Openai.elapsed_ms(started_at)
|
|
89
|
+
)
|
|
90
|
+
response
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "openai"
|
|
4
|
+
require_relative "anthropic"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Integrations
|
|
8
|
+
module Registry
|
|
9
|
+
INTEGRATIONS = {
|
|
10
|
+
openai: Openai,
|
|
11
|
+
anthropic: Anthropic
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def install!(names = LlmCostTracker.configuration.instrumented_integrations)
|
|
17
|
+
normalize(names).each { |name| fetch(name).install }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def checks(names = LlmCostTracker.configuration.instrumented_integrations)
|
|
21
|
+
return [Base::Result.new(:integrations, :ok, "no SDK integrations enabled")] if names.empty?
|
|
22
|
+
|
|
23
|
+
normalize(names).map { |name| fetch(name).status }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def normalize(names)
|
|
27
|
+
Array(names).flatten.map(&:to_sym).uniq
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def fetch(name)
|
|
31
|
+
INTEGRATIONS.fetch(name.to_sym) do
|
|
32
|
+
message = "Unknown integration: #{name.inspect}. Use one of: #{INTEGRATIONS.keys.join(', ')}"
|
|
33
|
+
raise LlmCostTracker::Error, message
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.install! = Registry.install!
|
|
39
|
+
def self.checks = Registry.checks
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -4,6 +4,7 @@ require "faraday"
|
|
|
4
4
|
require "json"
|
|
5
5
|
|
|
6
6
|
require_relative "../logging"
|
|
7
|
+
require_relative "../request_url"
|
|
7
8
|
|
|
8
9
|
module LlmCostTracker
|
|
9
10
|
module Middleware
|
|
@@ -76,7 +77,7 @@ module LlmCostTracker
|
|
|
76
77
|
response_body = read_body(response_env.body)
|
|
77
78
|
unless response_body
|
|
78
79
|
Logging.warn(
|
|
79
|
-
"Unable to read response body for #{request_url}; " \
|
|
80
|
+
"Unable to read response body for #{RequestUrl.label(request_url)}; " \
|
|
80
81
|
"streaming responses are captured automatically for OpenAI/Anthropic/Gemini " \
|
|
81
82
|
"or via LlmCostTracker.track_stream for custom clients."
|
|
82
83
|
)
|
|
@@ -156,11 +157,11 @@ module LlmCostTracker
|
|
|
156
157
|
|
|
157
158
|
def capture_warning(request_url, stream_buffer)
|
|
158
159
|
unless stream_buffer&.dig(:overflowed)
|
|
159
|
-
return "Unable to capture streaming response for #{request_url}; " \
|
|
160
|
+
return "Unable to capture streaming response for #{RequestUrl.label(request_url)}; " \
|
|
160
161
|
"recording usage_source=unknown. Use LlmCostTracker.track_stream for manual capture."
|
|
161
162
|
end
|
|
162
163
|
|
|
163
|
-
"Streaming response for #{request_url} exceeded #{STREAM_CAPTURE_LIMIT_BYTES} bytes; " \
|
|
164
|
+
"Streaming response for #{RequestUrl.label(request_url)} exceeded #{STREAM_CAPTURE_LIMIT_BYTES} bytes; " \
|
|
164
165
|
"recording usage_source=unknown. Use LlmCostTracker.track_stream for manual capture."
|
|
165
166
|
end
|
|
166
167
|
end
|
|
@@ -16,6 +16,7 @@ module LlmCostTracker
|
|
|
16
16
|
)
|
|
17
17
|
|
|
18
18
|
class ParsedUsage
|
|
19
|
+
UNKNOWN_MODEL = "unknown"
|
|
19
20
|
TRACKING_KEYS = %i[
|
|
20
21
|
provider
|
|
21
22
|
model
|
|
@@ -30,7 +31,7 @@ module LlmCostTracker
|
|
|
30
31
|
def self.build(**attributes)
|
|
31
32
|
new(
|
|
32
33
|
provider: attributes.fetch(:provider),
|
|
33
|
-
model: attributes.fetch(:model),
|
|
34
|
+
model: normalize_model(attributes.fetch(:model)),
|
|
34
35
|
input_tokens: attributes.fetch(:input_tokens).to_i,
|
|
35
36
|
output_tokens: attributes.fetch(:output_tokens).to_i,
|
|
36
37
|
total_tokens: attributes.fetch(:total_tokens, usage_breakdown(attributes).total_tokens).to_i,
|
|
@@ -61,5 +62,11 @@ module LlmCostTracker
|
|
|
61
62
|
)
|
|
62
63
|
end
|
|
63
64
|
private_class_method :usage_breakdown
|
|
65
|
+
|
|
66
|
+
def self.normalize_model(value)
|
|
67
|
+
model = value.to_s.strip
|
|
68
|
+
model.empty? ? UNKNOWN_MODEL : model
|
|
69
|
+
end
|
|
70
|
+
private_class_method :normalize_model
|
|
64
71
|
end
|
|
65
72
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module PriceFreshness
|
|
7
|
+
STALE_AFTER_DAYS = 30
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def call(metadata, today: Date.today)
|
|
11
|
+
updated_at = metadata["updated_at"] || metadata[:updated_at]
|
|
12
|
+
return missing unless updated_at
|
|
13
|
+
|
|
14
|
+
date = Date.iso8601(updated_at.to_s)
|
|
15
|
+
age_days = (today - date).to_i
|
|
16
|
+
return stale(updated_at) if age_days > STALE_AFTER_DAYS
|
|
17
|
+
|
|
18
|
+
[:ok, "updated_at=#{updated_at}"]
|
|
19
|
+
rescue Date::Error
|
|
20
|
+
[:warn, "metadata.updated_at=#{updated_at.inspect} is invalid; run bin/rails llm_cost_tracker:prices:sync"]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def missing
|
|
26
|
+
[:warn, "metadata.updated_at missing; run bin/rails llm_cost_tracker:prices:sync"]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def stale(updated_at)
|
|
30
|
+
[
|
|
31
|
+
:warn,
|
|
32
|
+
"updated_at=#{updated_at} is older than #{STALE_AFTER_DAYS} days; " \
|
|
33
|
+
"run bin/rails llm_cost_tracker:prices:sync"
|
|
34
|
+
]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -25,6 +25,20 @@ module LlmCostTracker
|
|
|
25
25
|
@metadata ||= MUTEX.synchronize { @metadata || raw_registry.fetch("metadata", {}).freeze }
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
def file_metadata(path)
|
|
29
|
+
return {} unless path
|
|
30
|
+
|
|
31
|
+
registry = load_price_file(path.to_s)
|
|
32
|
+
raise ArgumentError, "prices_file must be a hash" unless registry.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
metadata = registry.fetch("metadata", {})
|
|
35
|
+
raise ArgumentError, "prices_file metadata must be a hash" unless metadata.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
metadata
|
|
38
|
+
rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
|
|
39
|
+
raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
|
|
40
|
+
end
|
|
41
|
+
|
|
28
42
|
def normalize_price_table(table)
|
|
29
43
|
normalize_price_entries(table, context: "price table")
|
|
30
44
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require "net/http"
|
|
5
|
+
require "openssl"
|
|
5
6
|
require "time"
|
|
6
7
|
require "uri"
|
|
7
8
|
|
|
@@ -52,7 +53,7 @@ module LlmCostTracker
|
|
|
52
53
|
else
|
|
53
54
|
raise Error, "Unable to fetch #{url}: HTTP #{response.code}"
|
|
54
55
|
end
|
|
55
|
-
rescue SocketError, SystemCallError, Timeout::Error => e
|
|
56
|
+
rescue OpenSSL::SSL::SSLError, SocketError, SystemCallError, Timeout::Error => e
|
|
56
57
|
raise Error, "Unable to fetch #{url}: #{e.class}: #{e.message}"
|
|
57
58
|
end
|
|
58
59
|
|
|
@@ -31,7 +31,7 @@ module LlmCostTracker
|
|
|
31
31
|
),
|
|
32
32
|
accepted: validated.accepted,
|
|
33
33
|
changes: price_changes(current_models, updated_models),
|
|
34
|
-
orphaned_models: compute_orphaned(current_models, merged.keys),
|
|
34
|
+
orphaned_models: compute_orphaned(current_models, merged.keys, source_results),
|
|
35
35
|
failed_sources: failed_sources,
|
|
36
36
|
discrepancies: discrepancies,
|
|
37
37
|
rejected: validated.rejected,
|
|
@@ -70,7 +70,9 @@ module LlmCostTracker
|
|
|
70
70
|
merged.sort.to_h
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
-
def compute_orphaned(current_models, merged_models)
|
|
73
|
+
def compute_orphaned(current_models, merged_models, source_results)
|
|
74
|
+
return [] if source_results.empty?
|
|
75
|
+
|
|
74
76
|
seed_models(current_models).keys.reject do |model|
|
|
75
77
|
manual_model?(current_models[model]) || merged_models.include?(model)
|
|
76
78
|
end.sort
|
|
@@ -67,6 +67,16 @@ module LlmCostTracker
|
|
|
67
67
|
end
|
|
68
68
|
|
|
69
69
|
class << self
|
|
70
|
+
def configured_output_path(env: ENV, config: LlmCostTracker.configuration)
|
|
71
|
+
output = env["OUTPUT"].to_s.strip
|
|
72
|
+
return output unless output.empty?
|
|
73
|
+
|
|
74
|
+
prices_file = config.prices_file
|
|
75
|
+
return prices_file.to_s if prices_file
|
|
76
|
+
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
70
80
|
def sync(path: DEFAULT_OUTPUT_PATH, seed_path: DEFAULT_OUTPUT_PATH, preview: false, strict: false,
|
|
71
81
|
fetcher: Fetcher.new, today: Date.today)
|
|
72
82
|
plan = RefreshPlanBuilder.new(sources: sources).call(
|