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
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "errors"
|
|
4
4
|
require_relative "value_helpers"
|
|
5
|
+
require_relative "configuration/instrumentation"
|
|
5
6
|
|
|
6
7
|
module LlmCostTracker
|
|
7
8
|
class Configuration
|
|
9
|
+
include ConfigurationInstrumentation
|
|
10
|
+
|
|
8
11
|
OPENAI_COMPATIBLE_PROVIDERS = {
|
|
9
12
|
"openrouter.ai" => "openrouter",
|
|
10
13
|
"api.deepseek.com" => "deepseek"
|
|
@@ -36,6 +39,7 @@ module LlmCostTracker
|
|
|
36
39
|
:budget_exceeded_behavior,
|
|
37
40
|
:default_tags,
|
|
38
41
|
:pricing_overrides,
|
|
42
|
+
:instrumented_integrations,
|
|
39
43
|
:report_tag_breakdowns,
|
|
40
44
|
:storage_backend,
|
|
41
45
|
:storage_error_behavior,
|
|
@@ -58,6 +62,7 @@ module LlmCostTracker
|
|
|
58
62
|
@log_level = :info
|
|
59
63
|
@prices_file = nil
|
|
60
64
|
@pricing_overrides = {}
|
|
65
|
+
@instrumented_integrations = []
|
|
61
66
|
@report_tag_breakdowns = []
|
|
62
67
|
self.openai_compatible_providers = OPENAI_COMPATIBLE_PROVIDERS
|
|
63
68
|
@finalized = false
|
|
@@ -97,13 +102,10 @@ module LlmCostTracker
|
|
|
97
102
|
end
|
|
98
103
|
end
|
|
99
104
|
|
|
100
|
-
def normalize_openai_compatible_providers!
|
|
101
|
-
self.openai_compatible_providers = openai_compatible_providers
|
|
102
|
-
end
|
|
103
|
-
|
|
104
105
|
def finalize!
|
|
105
106
|
@default_tags = ValueHelpers.deep_freeze(@default_tags || {})
|
|
106
107
|
@pricing_overrides = ValueHelpers.deep_freeze(@pricing_overrides || {})
|
|
108
|
+
@instrumented_integrations = ValueHelpers.deep_freeze(@instrumented_integrations || [])
|
|
107
109
|
@report_tag_breakdowns = ValueHelpers.deep_freeze(Array(@report_tag_breakdowns))
|
|
108
110
|
@openai_compatible_providers = ValueHelpers.deep_freeze(@openai_compatible_providers || {})
|
|
109
111
|
@finalized = true
|
|
@@ -116,6 +118,10 @@ module LlmCostTracker
|
|
|
116
118
|
copy = dup
|
|
117
119
|
copy.instance_variable_set(:@default_tags, ValueHelpers.deep_dup(@default_tags || {}))
|
|
118
120
|
copy.instance_variable_set(:@pricing_overrides, ValueHelpers.deep_dup(@pricing_overrides || {}))
|
|
121
|
+
copy.instance_variable_set(
|
|
122
|
+
:@instrumented_integrations,
|
|
123
|
+
ValueHelpers.deep_dup(@instrumented_integrations || [])
|
|
124
|
+
)
|
|
119
125
|
copy.instance_variable_set(:@report_tag_breakdowns, ValueHelpers.deep_dup(@report_tag_breakdowns || []))
|
|
120
126
|
copy.instance_variable_set(
|
|
121
127
|
:@openai_compatible_providers,
|
|
@@ -126,7 +132,6 @@ module LlmCostTracker
|
|
|
126
132
|
end
|
|
127
133
|
|
|
128
134
|
def active_record? = storage_backend == :active_record
|
|
129
|
-
def log? = storage_backend == :log
|
|
130
135
|
|
|
131
136
|
private
|
|
132
137
|
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "price_freshness"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
class Doctor
|
|
7
|
+
Check = Data.define(:status, :name, :message)
|
|
8
|
+
CORE_COLUMNS = %w[provider model input_tokens output_tokens total_tokens total_cost tags tracked_at].freeze
|
|
9
|
+
FEATURE_COLUMNS = {
|
|
10
|
+
"latency_ms" => "bin/rails generate llm_cost_tracker:add_latency_ms",
|
|
11
|
+
"stream" => "bin/rails generate llm_cost_tracker:add_streaming",
|
|
12
|
+
"usage_source" => "bin/rails generate llm_cost_tracker:add_streaming",
|
|
13
|
+
"provider_response_id" => "bin/rails generate llm_cost_tracker:add_provider_response_id",
|
|
14
|
+
"cache_read_input_tokens" => "bin/rails generate llm_cost_tracker:add_usage_breakdown",
|
|
15
|
+
"cache_write_input_tokens" => "bin/rails generate llm_cost_tracker:add_usage_breakdown",
|
|
16
|
+
"hidden_output_tokens" => "bin/rails generate llm_cost_tracker:add_usage_breakdown",
|
|
17
|
+
"pricing_mode" => "bin/rails generate llm_cost_tracker:add_usage_breakdown"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def call = new.checks
|
|
22
|
+
|
|
23
|
+
def report(checks = call)
|
|
24
|
+
(["LLM Cost Tracker doctor"] + checks.map { |check| format_check(check) }).join("\n")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def healthy?(checks = call)
|
|
28
|
+
checks.none? { |check| check.status == :error }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def format_check(check)
|
|
34
|
+
"[#{check.status}] #{check.name}: #{check.message}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def checks
|
|
39
|
+
[
|
|
40
|
+
configuration_check,
|
|
41
|
+
*integration_checks,
|
|
42
|
+
active_record_check,
|
|
43
|
+
table_check,
|
|
44
|
+
column_check,
|
|
45
|
+
period_totals_check,
|
|
46
|
+
prices_check,
|
|
47
|
+
calls_check
|
|
48
|
+
].compact
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def configuration_check
|
|
54
|
+
Check.new(:ok, "configuration", "storage_backend=#{LlmCostTracker.configuration.storage_backend.inspect}")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def integration_checks
|
|
58
|
+
LlmCostTracker::Integrations.checks.map do |check|
|
|
59
|
+
Check.new(check.status, check.name.to_s, check.message)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def active_record_check
|
|
64
|
+
return Check.new(:ok, "storage", "ActiveRecord storage is disabled") unless active_record_storage?
|
|
65
|
+
return Check.new(:ok, "active_record", "available") if active_record_available?
|
|
66
|
+
|
|
67
|
+
Check.new(:error, "active_record", "unavailable; add ActiveRecord/Rails or change storage_backend")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def table_check
|
|
71
|
+
return unless active_record_storage? && active_record_available?
|
|
72
|
+
return Check.new(:ok, "llm_api_calls", "table exists") if llm_api_calls_table?
|
|
73
|
+
|
|
74
|
+
Check.new(
|
|
75
|
+
:error,
|
|
76
|
+
"llm_api_calls",
|
|
77
|
+
"missing; run bin/rails generate llm_cost_tracker:install && bin/rails db:migrate"
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def column_check
|
|
82
|
+
return unless active_record_storage? && llm_api_calls_table?
|
|
83
|
+
|
|
84
|
+
columns = column_names("llm_api_calls")
|
|
85
|
+
missing_core = CORE_COLUMNS - columns
|
|
86
|
+
missing_features = FEATURE_COLUMNS.keys - columns
|
|
87
|
+
if missing_core.any?
|
|
88
|
+
return Check.new(:error, "llm_api_calls columns", "missing core columns: #{missing_core.join(', ')}")
|
|
89
|
+
end
|
|
90
|
+
if missing_features.any?
|
|
91
|
+
return Check.new(
|
|
92
|
+
:warn,
|
|
93
|
+
"llm_api_calls columns",
|
|
94
|
+
"missing optional columns; run #{feature_generators(missing_features).join(' && ')}"
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
Check.new(:ok, "llm_api_calls columns", "current")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def period_totals_check
|
|
102
|
+
return unless active_record_storage? && llm_api_calls_table?
|
|
103
|
+
if table_exists?("llm_cost_tracker_period_totals")
|
|
104
|
+
return Check.new(:ok, "period totals", "llm_cost_tracker_period_totals exists")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
Check.new(:warn, "period totals", "missing; budget preflight falls back to llm_api_calls sums")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def prices_check
|
|
111
|
+
path = LlmCostTracker.configuration.prices_file
|
|
112
|
+
unless path
|
|
113
|
+
return Check.new(
|
|
114
|
+
:warn,
|
|
115
|
+
"prices",
|
|
116
|
+
"using bundled prices updated_at=#{builtin_prices_updated_at}; configure prices_file for production"
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
count = LlmCostTracker::PriceRegistry.file_prices(path).size
|
|
121
|
+
metadata = LlmCostTracker::PriceRegistry.file_metadata(path)
|
|
122
|
+
status, freshness = LlmCostTracker::PriceFreshness.call(metadata)
|
|
123
|
+
Check.new(status, "prices", "loaded #{count} models from #{path}; #{freshness}")
|
|
124
|
+
rescue LlmCostTracker::Error => e
|
|
125
|
+
Check.new(:error, "prices", e.message)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def calls_check
|
|
129
|
+
return unless active_record_storage? && llm_api_calls_table?
|
|
130
|
+
|
|
131
|
+
count = LlmCostTracker::LlmApiCall.count
|
|
132
|
+
return Check.new(:warn, "tracked calls", "none recorded yet") if count.zero?
|
|
133
|
+
|
|
134
|
+
latest = LlmCostTracker::LlmApiCall.maximum(:tracked_at)&.utc&.iso8601
|
|
135
|
+
Check.new(:ok, "tracked calls", "#{count} recorded; latest #{latest}")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def active_record_storage? = LlmCostTracker.configuration.storage_backend == :active_record
|
|
139
|
+
|
|
140
|
+
def active_record_available?
|
|
141
|
+
require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
|
|
142
|
+
LlmCostTracker::LlmApiCall.connection
|
|
143
|
+
true
|
|
144
|
+
rescue LoadError, StandardError
|
|
145
|
+
false
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def llm_api_calls_table?
|
|
149
|
+
active_record_available? && table_exists?("llm_api_calls")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def table_exists?(name)
|
|
153
|
+
LlmCostTracker::LlmApiCall.connection.data_source_exists?(name)
|
|
154
|
+
rescue StandardError
|
|
155
|
+
false
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def column_names(table) = LlmCostTracker::LlmApiCall.connection.columns(table).map(&:name)
|
|
159
|
+
|
|
160
|
+
def feature_generators(columns) = columns.map { |column| FEATURE_COLUMNS.fetch(column) }.uniq
|
|
161
|
+
|
|
162
|
+
def builtin_prices_updated_at
|
|
163
|
+
LlmCostTracker::Pricing.metadata.fetch("updated_at", "unknown")
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -11,6 +11,8 @@ module LlmCostTracker
|
|
|
11
11
|
source_root File.expand_path("templates", __dir__)
|
|
12
12
|
|
|
13
13
|
desc "Creates the LlmCostTracker migration and initializer"
|
|
14
|
+
class_option :dashboard, type: :boolean, default: false
|
|
15
|
+
class_option :prices, type: :boolean, default: false
|
|
14
16
|
|
|
15
17
|
def create_migration_file
|
|
16
18
|
migration_template(
|
|
@@ -26,11 +28,42 @@ module LlmCostTracker
|
|
|
26
28
|
)
|
|
27
29
|
end
|
|
28
30
|
|
|
31
|
+
def create_prices_file
|
|
32
|
+
return unless options[:prices]
|
|
33
|
+
|
|
34
|
+
invoke "llm_cost_tracker:prices"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def mount_engine
|
|
38
|
+
return unless options[:dashboard]
|
|
39
|
+
|
|
40
|
+
add_engine_require
|
|
41
|
+
route %(mount LlmCostTracker::Engine => "/llm-costs")
|
|
42
|
+
end
|
|
43
|
+
|
|
29
44
|
private
|
|
30
45
|
|
|
31
46
|
def migration_version
|
|
32
47
|
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
33
48
|
end
|
|
49
|
+
|
|
50
|
+
def add_engine_require
|
|
51
|
+
return unless File.exist?("config/application.rb")
|
|
52
|
+
|
|
53
|
+
contents = File.read("config/application.rb")
|
|
54
|
+
return if contents.include?(%(require "llm_cost_tracker/engine"))
|
|
55
|
+
|
|
56
|
+
unless contents.include?(%(require "rails/all"\n))
|
|
57
|
+
prepend_to_file("config/application.rb", %(require "llm_cost_tracker/engine"\n))
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
inject_into_file(
|
|
62
|
+
"config/application.rb",
|
|
63
|
+
%(require "llm_cost_tracker/engine"\n),
|
|
64
|
+
after: %(require "rails/all"\n)
|
|
65
|
+
)
|
|
66
|
+
end
|
|
34
67
|
end
|
|
35
68
|
end
|
|
36
69
|
end
|
|
@@ -2,17 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
require "rails/generators"
|
|
4
4
|
|
|
5
|
+
require_relative "../../price_registry"
|
|
6
|
+
require_relative "../../price_sync/registry_loader"
|
|
7
|
+
require_relative "../../price_sync/registry_writer"
|
|
8
|
+
|
|
5
9
|
module LlmCostTracker
|
|
6
10
|
module Generators
|
|
7
11
|
class PricesGenerator < Rails::Generators::Base
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
desc "Creates a local LlmCostTracker price override file"
|
|
12
|
+
desc "Creates a local LLM Cost Tracker price snapshot"
|
|
11
13
|
|
|
12
14
|
def create_prices_file
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
registry = LlmCostTracker::PriceSync::RegistryLoader.new.call(
|
|
16
|
+
path: LlmCostTracker::PriceRegistry::DEFAULT_PRICES_PATH,
|
|
17
|
+
seed_path: LlmCostTracker::PriceRegistry::DEFAULT_PRICES_PATH
|
|
18
|
+
)
|
|
19
|
+
LlmCostTracker::PriceSync::RegistryWriter.new.call(
|
|
20
|
+
path: File.join(destination_root, "config/llm_cost_tracker_prices.yml"),
|
|
21
|
+
registry: registry
|
|
16
22
|
)
|
|
17
23
|
end
|
|
18
24
|
end
|
|
@@ -1,42 +1,74 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
LlmCostTracker.configure do |config|
|
|
4
|
-
#
|
|
4
|
+
# Set to false to temporarily disable tracking without removing middleware.
|
|
5
5
|
config.enabled = true
|
|
6
6
|
|
|
7
|
-
#
|
|
7
|
+
# :active_record stores events in llm_api_calls for dashboards, reports, and shared budgets.
|
|
8
|
+
# Other options: :log for local logging, :custom for your own storage callable.
|
|
8
9
|
config.storage_backend = :active_record
|
|
9
10
|
|
|
10
|
-
#
|
|
11
|
-
|
|
11
|
+
# Tags are merged into every event. Use a callable for request/job-time context.
|
|
12
|
+
config.default_tags = -> { { environment: Rails.env } }
|
|
12
13
|
|
|
13
|
-
#
|
|
14
|
+
# Optional SDK integrations. Provider SDK gems are not installed by LLM Cost Tracker.
|
|
15
|
+
# Enable only the SDKs your app already uses.
|
|
16
|
+
# config.instrument :openai
|
|
17
|
+
# config.instrument :anthropic
|
|
18
|
+
|
|
19
|
+
# Budget behavior: :notify calls on_budget_exceeded, :raise raises after recording,
|
|
20
|
+
# :block_requests preflights monthly/daily budgets before supported requests.
|
|
21
|
+
config.budget_exceeded_behavior = :notify
|
|
22
|
+
|
|
23
|
+
# Storage failures are non-fatal by default so LLM responses can still return.
|
|
24
|
+
# Use :raise if failed ledger writes should fail the request/job.
|
|
25
|
+
config.storage_error_behavior = :warn
|
|
26
|
+
|
|
27
|
+
# Unknown pricing records token usage with nil cost by default. Use :raise if
|
|
28
|
+
# every model must have known pricing before it can be used.
|
|
29
|
+
config.unknown_pricing_behavior = :warn
|
|
30
|
+
|
|
31
|
+
# Used only by the :log storage backend.
|
|
32
|
+
config.log_level = :info
|
|
33
|
+
<% if options[:prices] -%>
|
|
34
|
+
|
|
35
|
+
# Local JSON/YAML pricing file generated by --prices. Keep it in source control
|
|
36
|
+
# and refresh it with bin/rails llm_cost_tracker:prices:sync.
|
|
37
|
+
config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.yml")
|
|
38
|
+
<% end -%>
|
|
39
|
+
|
|
40
|
+
# Cumulative monthly/daily budgets and a single-call ceiling, in USD.
|
|
14
41
|
# config.monthly_budget = 100.00
|
|
15
42
|
# config.daily_budget = 10.00
|
|
16
43
|
# config.per_call_budget = 1.00
|
|
17
|
-
# config.budget_exceeded_behavior = :notify # :notify, :raise, or :block_requests
|
|
18
44
|
|
|
19
|
-
#
|
|
20
|
-
# config.storage_error_behavior = :warn # :ignore, :warn, or :raise
|
|
21
|
-
|
|
22
|
-
# What to do when a model has no built-in price and no pricing_overrides entry.
|
|
23
|
-
# config.unknown_pricing_behavior = :warn # :ignore, :warn, or :raise
|
|
24
|
-
|
|
25
|
-
# Callback when monthly budget is exceeded.
|
|
45
|
+
# Called when :notify is selected and a monthly, daily, or per-call budget is exceeded.
|
|
26
46
|
# config.on_budget_exceeded = ->(data) {
|
|
27
|
-
# Rails.logger.warn
|
|
28
|
-
# "#{data[:budget_type]}
|
|
29
|
-
#
|
|
47
|
+
# Rails.logger.warn(
|
|
48
|
+
# "LLM #{data[:budget_type]} budget exceeded: $#{data[:total]} / $#{data[:budget]}"
|
|
49
|
+
# )
|
|
30
50
|
# }
|
|
31
51
|
|
|
32
|
-
#
|
|
33
|
-
# config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.
|
|
34
|
-
|
|
35
|
-
# Override pricing for specific models in Ruby (per 1M tokens, USD).
|
|
52
|
+
# Local pricing table and small Ruby-side overrides. Prices are USD per 1M tokens.
|
|
53
|
+
# config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.yml")
|
|
36
54
|
# config.pricing_overrides = {
|
|
37
55
|
# "my-custom-model" => { input: 1.00, output: 2.00 }
|
|
38
56
|
# }
|
|
39
57
|
|
|
40
|
-
# OpenAI-compatible
|
|
58
|
+
# Register OpenAI-compatible gateway hosts and choose extra tag breakdowns
|
|
59
|
+
# for bin/rails llm_cost_tracker:report.
|
|
41
60
|
# config.openai_compatible_providers["llm.my-company.com"] = "internal_gateway"
|
|
61
|
+
# config.report_tag_breakdowns = %w[feature user_id]
|
|
62
|
+
|
|
63
|
+
# Use :custom when you want to send events to your own sink instead of ActiveRecord.
|
|
64
|
+
# Return false from custom_storage to skip budget checks for that event.
|
|
65
|
+
# config.storage_backend = :custom
|
|
66
|
+
# config.custom_storage = ->(event) {
|
|
67
|
+
# Rails.logger.info(
|
|
68
|
+
# provider: event.provider,
|
|
69
|
+
# model: event.model,
|
|
70
|
+
# total_cost: event.cost&.total_cost,
|
|
71
|
+
# tags: event.tags
|
|
72
|
+
# )
|
|
73
|
+
# }
|
|
42
74
|
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Integrations
|
|
7
|
+
module Anthropic
|
|
8
|
+
extend Base
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def integration_name = :anthropic
|
|
12
|
+
|
|
13
|
+
def target_patches
|
|
14
|
+
[
|
|
15
|
+
[constant("Anthropic::Resources::Messages"), MessagesPatch],
|
|
16
|
+
[constant("Anthropic::Resources::Beta::Messages"), MessagesPatch]
|
|
17
|
+
]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def record_message(message, request:, latency_ms:)
|
|
21
|
+
return unless active?
|
|
22
|
+
|
|
23
|
+
record_safely do
|
|
24
|
+
usage = ObjectReader.first(message, :usage)
|
|
25
|
+
next unless usage
|
|
26
|
+
|
|
27
|
+
input_tokens = ObjectReader.first(usage, :input_tokens)
|
|
28
|
+
output_tokens = ObjectReader.first(usage, :output_tokens)
|
|
29
|
+
next if input_tokens.nil? && output_tokens.nil?
|
|
30
|
+
|
|
31
|
+
LlmCostTracker::Tracker.record(
|
|
32
|
+
provider: "anthropic",
|
|
33
|
+
model: ObjectReader.first(message, :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(message, :id),
|
|
39
|
+
metadata: usage_metadata(usage)
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def usage_metadata(usage)
|
|
45
|
+
{
|
|
46
|
+
cache_read_input_tokens: ObjectReader.integer(ObjectReader.first(usage, :cache_read_input_tokens)),
|
|
47
|
+
cache_write_input_tokens: ObjectReader.integer(ObjectReader.first(usage, :cache_creation_input_tokens)),
|
|
48
|
+
hidden_output_tokens: hidden_output_tokens(usage)
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def hidden_output_tokens(usage)
|
|
53
|
+
ObjectReader.integer(
|
|
54
|
+
ObjectReader.first(usage, :thinking_tokens, :thinking_output_tokens) ||
|
|
55
|
+
ObjectReader.nested(usage, :output_tokens_details, :reasoning_tokens)
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
module MessagesPatch
|
|
61
|
+
def create(*args, **kwargs)
|
|
62
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
63
|
+
LlmCostTracker::Integrations::Anthropic.enforce_budget!
|
|
64
|
+
message = super
|
|
65
|
+
LlmCostTracker::Integrations::Anthropic.record_message(
|
|
66
|
+
message,
|
|
67
|
+
request: LlmCostTracker::Integrations::Anthropic.request_params(args, kwargs),
|
|
68
|
+
latency_ms: LlmCostTracker::Integrations::Anthropic.elapsed_ms(started_at)
|
|
69
|
+
)
|
|
70
|
+
message
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../logging"
|
|
4
|
+
require_relative "object_reader"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Integrations
|
|
8
|
+
module Base
|
|
9
|
+
Result = Data.define(:name, :status, :message)
|
|
10
|
+
|
|
11
|
+
def active?
|
|
12
|
+
LlmCostTracker.configuration.instrumented?(integration_name)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def install
|
|
16
|
+
target_patches.each { |target, patch| install_patch(target, patch) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def status
|
|
20
|
+
name = integration_name
|
|
21
|
+
installed = target_patches.count { |target, patch| patch_installed?(target, patch) }
|
|
22
|
+
available = target_patches.count { |target, _patch| target }
|
|
23
|
+
return Result.new(name, :ok, "#{name} integration installed") if installed.positive?
|
|
24
|
+
return Result.new(name, :warn, "#{name} SDK classes are not loaded") if available.zero?
|
|
25
|
+
|
|
26
|
+
Result.new(name, :warn, "#{name} integration is enabled but not installed")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def elapsed_ms(started_at)
|
|
30
|
+
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def enforce_budget!
|
|
34
|
+
LlmCostTracker::Tracker.enforce_budget! if active?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def record_safely
|
|
38
|
+
yield
|
|
39
|
+
rescue LlmCostTracker::Error
|
|
40
|
+
raise
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
Logging.warn("#{integration_name} integration failed to record usage: #{e.class}: #{e.message}")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def request_params(args, kwargs)
|
|
46
|
+
params = args.first.is_a?(Hash) ? args.first : {}
|
|
47
|
+
params.merge(kwargs)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def constant(path)
|
|
51
|
+
path.to_s.split("::").reduce(Object) do |scope, const_name|
|
|
52
|
+
return nil unless scope.const_defined?(const_name, false)
|
|
53
|
+
|
|
54
|
+
scope.const_get(const_name, false)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def install_patch(target, patch)
|
|
61
|
+
return unless target
|
|
62
|
+
return if patch_installed?(target, patch)
|
|
63
|
+
|
|
64
|
+
target.prepend(patch)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def patch_installed?(target, patch)
|
|
68
|
+
target&.ancestors&.include?(patch)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Integrations
|
|
5
|
+
module ObjectReader
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def first(object, *keys)
|
|
9
|
+
keys.each do |key|
|
|
10
|
+
value = read(object, key)
|
|
11
|
+
return value unless value.nil?
|
|
12
|
+
end
|
|
13
|
+
nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def nested(object, *path)
|
|
17
|
+
path.reduce(object) do |current, key|
|
|
18
|
+
return nil if current.nil?
|
|
19
|
+
|
|
20
|
+
read(current, key)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def read(object, key)
|
|
25
|
+
return nil if object.nil?
|
|
26
|
+
|
|
27
|
+
read_hash(object, key) || read_method(object, key) || read_index(object, key)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def integer(value)
|
|
31
|
+
value.nil? ? 0 : value.to_i
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def read_hash(object, key)
|
|
35
|
+
return unless object.respond_to?(:key?)
|
|
36
|
+
|
|
37
|
+
return object[key] if object.key?(key)
|
|
38
|
+
|
|
39
|
+
string_key = key.to_s
|
|
40
|
+
object[string_key] if object.key?(string_key)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def read_method(object, key)
|
|
44
|
+
object.public_send(key) if object.respond_to?(key)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def read_index(object, key)
|
|
48
|
+
return unless object.respond_to?(:[])
|
|
49
|
+
|
|
50
|
+
object[key]
|
|
51
|
+
rescue IndexError, TypeError, NoMethodError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|