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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -0
  3. data/README.md +182 -100
  4. data/lib/llm_cost_tracker/configuration/instrumentation.rb +37 -0
  5. data/lib/llm_cost_tracker/configuration.rb +10 -5
  6. data/lib/llm_cost_tracker/doctor.rb +166 -0
  7. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +33 -0
  8. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +12 -6
  9. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +53 -21
  10. data/lib/llm_cost_tracker/integrations/anthropic.rb +75 -0
  11. data/lib/llm_cost_tracker/integrations/base.rb +72 -0
  12. data/lib/llm_cost_tracker/integrations/object_reader.rb +56 -0
  13. data/lib/llm_cost_tracker/integrations/openai.rb +95 -0
  14. data/lib/llm_cost_tracker/integrations/registry.rb +41 -0
  15. data/lib/llm_cost_tracker/middleware/faraday.rb +4 -3
  16. data/lib/llm_cost_tracker/parsed_usage.rb +8 -1
  17. data/lib/llm_cost_tracker/parsers/base.rb +1 -1
  18. data/lib/llm_cost_tracker/parsers/openai_usage.rb +1 -1
  19. data/lib/llm_cost_tracker/price_freshness.rb +38 -0
  20. data/lib/llm_cost_tracker/price_registry.rb +14 -0
  21. data/lib/llm_cost_tracker/price_sync/fetcher.rb +2 -1
  22. data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +4 -2
  23. data/lib/llm_cost_tracker/price_sync.rb +10 -0
  24. data/lib/llm_cost_tracker/prices.json +394 -41
  25. data/lib/llm_cost_tracker/pricing.rb +8 -1
  26. data/lib/llm_cost_tracker/request_url.rb +20 -0
  27. data/lib/llm_cost_tracker/stream_collector.rb +3 -3
  28. data/lib/llm_cost_tracker/tag_context.rb +52 -0
  29. data/lib/llm_cost_tracker/tracker.rb +5 -2
  30. data/lib/llm_cost_tracker/version.rb +1 -1
  31. data/lib/llm_cost_tracker.rb +14 -4
  32. data/lib/tasks/llm_cost_tracker.rake +21 -3
  33. metadata +12 -3
  34. 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
@@ -108,7 +108,7 @@ module LlmCostTracker
108
108
  ParsedUsage.build(
109
109
  provider: provider,
110
110
  provider_response_id: provider_response_id,
111
- model: model,
111
+ model: model || ParsedUsage::UNKNOWN_MODEL,
112
112
  input_tokens: 0,
113
113
  output_tokens: 0,
114
114
  total_tokens: 0,
@@ -67,7 +67,7 @@ module LlmCostTracker
67
67
  end
68
68
 
69
69
  def detect_stream_model(events)
70
- find_event_value(events) { |data| data["model"] }
70
+ find_event_value(events) { |data| data["model"] || data.dig("response", "model") }
71
71
  end
72
72
 
73
73
  def detect_stream_response_id(events)
@@ -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(