llm_cost_tracker 0.13.0 → 0.14.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 +53 -16
- data/README.md +16 -28
- data/app/controllers/llm_cost_tracker/application_controller.rb +3 -2
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -0
- data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +11 -0
- data/app/models/llm_cost_tracker/call.rb +9 -3
- data/app/models/llm_cost_tracker/call_rollup.rb +19 -3
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +13 -0
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +3 -3
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +5 -3
- data/app/views/llm_cost_tracker/calls/show.html.erb +6 -8
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +11 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -3
- data/lib/llm_cost_tracker/budget/per_tag.rb +159 -0
- data/lib/llm_cost_tracker/budget.rb +120 -32
- data/lib/llm_cost_tracker/charges/cost_status.rb +4 -3
- data/lib/llm_cost_tracker/configuration/budgets.rb +93 -0
- data/lib/llm_cost_tracker/configuration/capture.rb +42 -0
- data/lib/llm_cost_tracker/configuration/ingestion.rb +20 -0
- data/lib/llm_cost_tracker/configuration/mutability.rb +33 -0
- data/lib/llm_cost_tracker/configuration/pricing.rb +36 -0
- data/lib/llm_cost_tracker/configuration/section.rb +59 -0
- data/lib/llm_cost_tracker/configuration/tags.rb +52 -0
- data/lib/llm_cost_tracker/configuration.rb +69 -125
- data/lib/llm_cost_tracker/deprecator.rb +9 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +17 -8
- data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
- data/lib/llm_cost_tracker/doctor.rb +4 -4
- data/lib/llm_cost_tracker/engine.rb +4 -0
- data/lib/llm_cost_tracker/errors.rb +12 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +2 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +2 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +0 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +8 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +50 -30
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +40 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +41 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +30 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +30 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +3 -1
- data/lib/llm_cost_tracker/ingestion/pool.rb +9 -2
- data/lib/llm_cost_tracker/ingestion.rb +3 -7
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +10 -12
- data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
- data/lib/llm_cost_tracker/ledger/rollups.rb +34 -4
- data/lib/llm_cost_tracker/ledger/store.rb +9 -2
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +15 -5
- data/lib/llm_cost_tracker/logging.rb +3 -5
- data/lib/llm_cost_tracker/middleware/faraday.rb +1 -1
- data/lib/llm_cost_tracker/prices.json +1291 -313
- data/lib/llm_cost_tracker/pricing/backfill.rb +11 -1
- data/lib/llm_cost_tracker/pricing/calculation.rb +4 -4
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +26 -15
- data/lib/llm_cost_tracker/pricing/matcher.rb +7 -0
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -2
- data/lib/llm_cost_tracker/pricing/registry.rb +5 -5
- data/lib/llm_cost_tracker/pricing/sync.rb +1 -1
- data/lib/llm_cost_tracker/pricing/unknown.rb +11 -8
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +3 -2
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +0 -7
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +5 -2
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +5 -4
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +2 -2
- data/lib/llm_cost_tracker/railtie.rb +3 -7
- data/lib/llm_cost_tracker/report/data.rb +2 -2
- data/lib/llm_cost_tracker/retention.rb +22 -10
- data/lib/llm_cost_tracker/tags/context.rb +3 -3
- data/lib/llm_cost_tracker/tags/sanitizer.rb +4 -4
- data/lib/llm_cost_tracker/tracker.rb +14 -23
- data/lib/llm_cost_tracker/usage/catalog.rb +1 -2
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +6 -1
- data/lib/tasks/llm_cost_tracker.rake +23 -10
- metadata +22 -9
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "section"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
class Configuration
|
|
7
|
+
class Tags < Section
|
|
8
|
+
DEFAULT_REDACTED_KEYS = %w[api_key access_token authorization credential password refresh_token secret].freeze
|
|
9
|
+
|
|
10
|
+
attributes :default, :max_count, :max_value_bytesize
|
|
11
|
+
|
|
12
|
+
attr_reader :redacted_keys, :report_breakdown_keys
|
|
13
|
+
|
|
14
|
+
def initialize(owner)
|
|
15
|
+
super
|
|
16
|
+
@default = {}
|
|
17
|
+
@max_count = 50
|
|
18
|
+
@max_value_bytesize = 1024
|
|
19
|
+
@redacted_keys = DEFAULT_REDACTED_KEYS.dup
|
|
20
|
+
@report_breakdown_keys = []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def redacted_keys=(value)
|
|
24
|
+
ensure_mutable!
|
|
25
|
+
@redacted_keys = Array(value).map(&:to_s)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def report_breakdown_keys=(value)
|
|
29
|
+
ensure_mutable!
|
|
30
|
+
@report_breakdown_keys = Array(value).map { |key| LlmCostTracker::Tags::Key.validate!(key, error_class: Error) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def normalized_redacted_keys
|
|
34
|
+
@normalized_redacted_keys ||=
|
|
35
|
+
Array(@redacted_keys).map { |key| LlmCostTracker::Tags::Sanitizer.normalized_key(key) }.freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def static_sanitized_default
|
|
39
|
+
return nil if @default.respond_to?(:call)
|
|
40
|
+
|
|
41
|
+
@static_sanitized_default ||=
|
|
42
|
+
LlmCostTracker::Tags::Sanitizer.call((@default || {}).to_h, config: owner).freeze
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def finalize!
|
|
46
|
+
@default = deep_freeze(@default || {})
|
|
47
|
+
@redacted_keys = deep_freeze(Array(@redacted_keys))
|
|
48
|
+
@report_breakdown_keys = deep_freeze(Array(@report_breakdown_keys))
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -1,89 +1,61 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "errors"
|
|
4
|
+
require_relative "deprecator"
|
|
4
5
|
require_relative "pricing/registry"
|
|
5
6
|
require_relative "tags/key"
|
|
7
|
+
require_relative "configuration/budgets"
|
|
8
|
+
require_relative "configuration/capture"
|
|
9
|
+
require_relative "configuration/ingestion"
|
|
10
|
+
require_relative "configuration/pricing"
|
|
11
|
+
require_relative "configuration/tags"
|
|
6
12
|
|
|
7
13
|
module LlmCostTracker
|
|
8
14
|
class Configuration
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
15
|
+
include Mutability
|
|
16
|
+
|
|
17
|
+
SECTIONS = {
|
|
18
|
+
budgets: Budgets, capture: Capture, ingestion: Ingestion, pricing: Pricing, tags: Tags
|
|
13
19
|
}.freeze
|
|
14
20
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
SCALAR_ATTRIBUTES = %i[enabled].freeze
|
|
22
|
+
|
|
23
|
+
DEPRECATED_OPTIONS = {
|
|
24
|
+
monthly_budget: { to: %i[budgets monthly] },
|
|
25
|
+
daily_budget: { to: %i[budgets daily] },
|
|
26
|
+
per_call_budget: { to: %i[budgets per_call] },
|
|
27
|
+
budget_exceeded_behavior: { to: %i[budgets exceeded_behavior] },
|
|
28
|
+
on_budget_exceeded: { to: %i[budgets on_exceeded] },
|
|
29
|
+
default_tags: { to: %i[tags default] },
|
|
30
|
+
max_tag_count: { to: %i[tags max_count] },
|
|
31
|
+
max_tag_value_bytesize: { to: %i[tags max_value_bytesize] },
|
|
32
|
+
redacted_tag_keys: { to: %i[tags redacted_keys] },
|
|
33
|
+
report_tag_breakdowns: { to: %i[tags report_breakdown_keys] },
|
|
34
|
+
prices_file: { to: %i[pricing file] },
|
|
35
|
+
pricing_overrides: { to: %i[pricing overrides] },
|
|
36
|
+
unknown_pricing_behavior: { to: %i[pricing unknown_model_behavior] },
|
|
37
|
+
ingestion_pool_size: { to: %i[ingestion pool_size] },
|
|
38
|
+
auto_enable_stream_usage: { to: %i[capture request_stream_usage] },
|
|
39
|
+
openai_compatible_providers: { to: %i[capture openai_compatible_providers] },
|
|
40
|
+
cache_rollups: {
|
|
41
|
+
to: %i[budgets totals_source],
|
|
42
|
+
cast: ->(value) { value ? :cache : :ledger },
|
|
43
|
+
uncast: ->(value) { value == :cache }
|
|
44
|
+
},
|
|
45
|
+
ingestion: { to: %i[ingestion mode], writer_only: true },
|
|
46
|
+
log_level: { to: nil, note: "LlmCostTracker logs through Rails.logger, which owns the level" }
|
|
25
47
|
}.freeze
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
attr_reader(
|
|
29
|
-
*SCALAR_ATTRIBUTES,
|
|
30
|
-
:budget_exceeded_behavior,
|
|
31
|
-
:ingestion,
|
|
32
|
-
:instrumented_integrations,
|
|
33
|
-
:pricing_overrides,
|
|
34
|
-
:report_tag_breakdowns,
|
|
35
|
-
:redacted_tag_keys,
|
|
36
|
-
:unknown_pricing_behavior,
|
|
37
|
-
:openai_compatible_providers
|
|
38
|
-
)
|
|
48
|
+
|
|
49
|
+
attr_reader(*SCALAR_ATTRIBUTES, *SECTIONS.keys, :instrumented_integrations)
|
|
39
50
|
|
|
40
51
|
def initialize
|
|
52
|
+
SECTIONS.each { |name, klass| instance_variable_set(:"@#{name}", klass.new(self)) }
|
|
41
53
|
@enabled = true
|
|
42
|
-
@
|
|
43
|
-
@on_budget_exceeded = nil
|
|
44
|
-
@monthly_budget = nil
|
|
45
|
-
@daily_budget = nil
|
|
46
|
-
@per_call_budget = nil
|
|
47
|
-
self.budget_exceeded_behavior = :notify
|
|
48
|
-
self.unknown_pricing_behavior = :warn
|
|
49
|
-
@log_level = :info
|
|
50
|
-
@prices_file = nil
|
|
51
|
-
@max_tag_count = 50
|
|
52
|
-
@max_tag_value_bytesize = 1024
|
|
53
|
-
@ingestion_pool_size = nil
|
|
54
|
-
self.pricing_overrides = {}
|
|
54
|
+
@log_level = :info
|
|
55
55
|
@instrumented_integrations = Set.new
|
|
56
|
-
@report_tag_breakdowns = []
|
|
57
|
-
@redacted_tag_keys = DEFAULT_REDACTED_TAG_KEYS.dup
|
|
58
|
-
self.openai_compatible_providers = OPENAI_COMPATIBLE_PROVIDERS
|
|
59
|
-
@auto_enable_stream_usage = true
|
|
60
|
-
self.ingestion = :inline
|
|
61
|
-
@cache_rollups = false
|
|
62
56
|
@finalized = false
|
|
63
57
|
end
|
|
64
58
|
|
|
65
|
-
def openai_compatible_providers=(providers)
|
|
66
|
-
ensure_mutable!
|
|
67
|
-
@openai_compatible_providers = normalize_openai_compatible_providers(providers)
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def pricing_overrides=(value)
|
|
71
|
-
ensure_mutable!
|
|
72
|
-
@pricing_overrides = Pricing::Registry.normalize_price_entries(value || {}, context: "pricing_overrides")
|
|
73
|
-
rescue ArgumentError, TypeError => e
|
|
74
|
-
raise Error, "invalid pricing_overrides: #{e.message}"
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def report_tag_breakdowns=(value)
|
|
78
|
-
ensure_mutable!
|
|
79
|
-
@report_tag_breakdowns = Array(value).map { |key| Tags::Key.validate!(key, error_class: Error) }
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def redacted_tag_keys=(value)
|
|
83
|
-
ensure_mutable!
|
|
84
|
-
@redacted_tag_keys = Array(value).map(&:to_s)
|
|
85
|
-
end
|
|
86
|
-
|
|
87
59
|
def instrument(*names)
|
|
88
60
|
ensure_mutable!
|
|
89
61
|
names = names.flatten
|
|
@@ -96,83 +68,55 @@ module LlmCostTracker
|
|
|
96
68
|
end
|
|
97
69
|
|
|
98
70
|
SCALAR_ATTRIBUTES.each do |name|
|
|
99
|
-
define_method("#{name}=") do |value|
|
|
71
|
+
define_method(:"#{name}=") do |value|
|
|
100
72
|
ensure_mutable!
|
|
101
73
|
instance_variable_set(:"@#{name}", value)
|
|
102
74
|
end
|
|
103
75
|
end
|
|
104
76
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
77
|
+
DEPRECATED_OPTIONS.each do |old_name, spec|
|
|
78
|
+
path = spec[:to]
|
|
79
|
+
replacement = path ? "config.#{path.join('.')}" : nil
|
|
80
|
+
|
|
81
|
+
define_method(:"#{old_name}=") do |value|
|
|
82
|
+
LlmCostTracker.deprecator.warn(deprecation_message(old_name, replacement, spec[:note], writer: true))
|
|
83
|
+
ensure_mutable! unless path
|
|
84
|
+
next instance_variable_set(:"@#{old_name}", value) unless path
|
|
85
|
+
|
|
86
|
+
value = spec[:cast].call(value) if spec[:cast]
|
|
87
|
+
target = path[0..-2].inject(self) { |object, step| object.public_send(step) }
|
|
88
|
+
target.public_send(:"#{path.last}=", value)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
next if spec[:writer_only]
|
|
92
|
+
|
|
93
|
+
define_method(old_name) do
|
|
94
|
+
LlmCostTracker.deprecator.warn(deprecation_message(old_name, replacement, spec[:note], writer: false))
|
|
95
|
+
next instance_variable_get(:"@#{old_name}") unless path
|
|
96
|
+
|
|
97
|
+
current = path.inject(self) { |object, step| object.public_send(step) }
|
|
98
|
+
spec[:uncast] ? spec[:uncast].call(current) : current
|
|
109
99
|
end
|
|
110
100
|
end
|
|
111
101
|
|
|
112
102
|
def finalize!
|
|
113
|
-
|
|
114
|
-
@pricing_overrides = deep_freeze(@pricing_overrides || {})
|
|
103
|
+
SECTIONS.each_key { |name| public_send(name).finalize! }
|
|
115
104
|
@instrumented_integrations = deep_freeze(@instrumented_integrations || Set.new)
|
|
116
|
-
@report_tag_breakdowns = deep_freeze(Array(@report_tag_breakdowns))
|
|
117
|
-
@redacted_tag_keys = deep_freeze(Array(@redacted_tag_keys))
|
|
118
|
-
@openai_compatible_providers = deep_freeze(
|
|
119
|
-
normalize_openai_compatible_providers(@openai_compatible_providers)
|
|
120
|
-
)
|
|
121
105
|
@finalized = true
|
|
122
106
|
end
|
|
123
107
|
|
|
124
|
-
def normalized_redacted_tag_keys
|
|
125
|
-
@normalized_redacted_tag_keys ||=
|
|
126
|
-
Array(@redacted_tag_keys).map { |key| Tags::Sanitizer.normalized_key(key) }.freeze
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
def static_sanitized_default_tags
|
|
130
|
-
return nil if @default_tags.respond_to?(:call)
|
|
131
|
-
|
|
132
|
-
@static_sanitized_default_tags ||= Tags::Sanitizer.call((@default_tags || {}).to_h).freeze
|
|
133
|
-
end
|
|
134
|
-
|
|
135
108
|
def finalized?
|
|
136
109
|
@finalized
|
|
137
110
|
end
|
|
138
111
|
|
|
139
112
|
private
|
|
140
113
|
|
|
141
|
-
def
|
|
142
|
-
|
|
143
|
-
|
|
114
|
+
def deprecation_message(old_name, replacement, note, writer:)
|
|
115
|
+
suffix = writer ? "=" : ""
|
|
116
|
+
name = "config.#{old_name}#{suffix}"
|
|
117
|
+
return "#{name} is deprecated; use #{replacement}#{suffix}" if replacement
|
|
144
118
|
|
|
145
|
-
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
def normalize_openai_compatible_providers(providers)
|
|
149
|
-
(providers || {}).each_with_object({}) do |(host, provider), normalized|
|
|
150
|
-
normalized[host.to_s.downcase] = provider.to_s
|
|
151
|
-
end
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
def ensure_mutable!
|
|
155
|
-
return unless finalized?
|
|
156
|
-
|
|
157
|
-
raise FrozenError, "can't modify frozen LlmCostTracker::Configuration"
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
def deep_freeze(value)
|
|
161
|
-
case value
|
|
162
|
-
when Hash
|
|
163
|
-
value.each do |key, nested_value|
|
|
164
|
-
deep_freeze(key)
|
|
165
|
-
deep_freeze(nested_value)
|
|
166
|
-
end
|
|
167
|
-
value.frozen? ? value : value.freeze
|
|
168
|
-
when Array, Set
|
|
169
|
-
value.each { |nested_value| deep_freeze(nested_value) }
|
|
170
|
-
value.frozen? ? value : value.freeze
|
|
171
|
-
when String
|
|
172
|
-
value.frozen? ? value : value.freeze
|
|
173
|
-
else
|
|
174
|
-
value
|
|
175
|
-
end
|
|
119
|
+
"#{name} is deprecated and has no effect; #{note}"
|
|
176
120
|
end
|
|
177
121
|
end
|
|
178
122
|
end
|
|
@@ -11,20 +11,20 @@ module LlmCostTracker
|
|
|
11
11
|
return unless Probe.table_exists?("llm_cost_tracker_calls")
|
|
12
12
|
return inline_check unless LlmCostTracker::Ingestion.async?
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
return async_ok if
|
|
14
|
+
problems = missing_parts + drifted_parts
|
|
15
|
+
return async_ok if problems.empty?
|
|
16
16
|
|
|
17
17
|
Check.new(
|
|
18
18
|
:error,
|
|
19
19
|
"async ingestion",
|
|
20
|
-
"
|
|
20
|
+
"#{problems.join('; ')}; see docs/upgrading.md for the recovery steps"
|
|
21
21
|
)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
private
|
|
25
25
|
|
|
26
26
|
def async_ok
|
|
27
|
-
Check.new(:ok, "async ingestion", "inbox and ingestion lease tables
|
|
27
|
+
Check.new(:ok, "async ingestion", "inbox and ingestion lease tables are on the current schema")
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def inline_check
|
|
@@ -32,14 +32,14 @@ module LlmCostTracker
|
|
|
32
32
|
if leftovers.empty?
|
|
33
33
|
return Check.new(:ok,
|
|
34
34
|
"inline ingestion",
|
|
35
|
-
"config.ingestion = :inline; events write directly to the ledger")
|
|
35
|
+
"config.ingestion.mode = :inline; events write directly to the ledger")
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
Check.new(
|
|
39
39
|
:warn,
|
|
40
40
|
"inline ingestion",
|
|
41
|
-
"config.ingestion = :inline but found unused async ingestion tables: #{leftovers.join(', ')}. " \
|
|
42
|
-
"Set config.ingestion = :async to keep the inbox path or drop the tables."
|
|
41
|
+
"config.ingestion.mode = :inline but found unused async ingestion tables: #{leftovers.join(', ')}. " \
|
|
42
|
+
"Set config.ingestion.mode = :async to keep the inbox path or drop the tables."
|
|
43
43
|
)
|
|
44
44
|
end
|
|
45
45
|
|
|
@@ -48,7 +48,16 @@ module LlmCostTracker
|
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def missing_parts
|
|
51
|
-
async_tables.reject { |table| Probe.table_exists?(table) }
|
|
51
|
+
async_tables.reject { |table| Probe.table_exists?(table) }.map { |table| "missing #{table}" }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def drifted_parts
|
|
55
|
+
LlmCostTracker::Ledger::Schema::ASYNC_SCHEMAS.filter_map do |schema, table|
|
|
56
|
+
next unless Probe.table_exists?(table)
|
|
57
|
+
|
|
58
|
+
errors = schema.current_schema_errors
|
|
59
|
+
"#{table} #{errors.join(', ')}" unless errors.empty?
|
|
60
|
+
end
|
|
52
61
|
end
|
|
53
62
|
|
|
54
63
|
def async_tables
|
|
@@ -11,7 +11,7 @@ module LlmCostTracker
|
|
|
11
11
|
REFRESH_COMMAND = "refresh the source-controlled prices file with bin/rails llm_cost_tracker:prices:refresh"
|
|
12
12
|
|
|
13
13
|
def call
|
|
14
|
-
path = LlmCostTracker.configuration.
|
|
14
|
+
path = LlmCostTracker.configuration.pricing.file
|
|
15
15
|
return bundled_check unless path
|
|
16
16
|
|
|
17
17
|
count = LlmCostTracker::Pricing::Registry.file_prices(path).size
|
|
@@ -164,7 +164,7 @@ module LlmCostTracker
|
|
|
164
164
|
|
|
165
165
|
def call_rollups_check
|
|
166
166
|
return unless llm_cost_tracker_calls_table?
|
|
167
|
-
return live_rollups_check unless LlmCostTracker.configuration.
|
|
167
|
+
return live_rollups_check unless LlmCostTracker.configuration.budgets.totals_source == :cache
|
|
168
168
|
|
|
169
169
|
errors = LlmCostTracker::Ledger::Schema::CallRollups.current_schema_errors
|
|
170
170
|
return Check.new(:ok, "call rollups", "llm_cost_tracker_call_rollups exists") if errors.empty?
|
|
@@ -181,14 +181,14 @@ module LlmCostTracker
|
|
|
181
181
|
Check.new(
|
|
182
182
|
:warn,
|
|
183
183
|
"call rollups",
|
|
184
|
-
"
|
|
185
|
-
"Set config.
|
|
184
|
+
"budgets.totals_source=:ledger but llm_cost_tracker_call_rollups exists. " \
|
|
185
|
+
"Set config.budgets.totals_source = :cache to keep budget reads on the rollups fast path or drop the table."
|
|
186
186
|
)
|
|
187
187
|
else
|
|
188
188
|
Check.new(
|
|
189
189
|
:ok,
|
|
190
190
|
"call rollups",
|
|
191
|
-
"
|
|
191
|
+
"budgets.totals_source=:ledger; budget reads aggregate from llm_cost_tracker_calls directly"
|
|
192
192
|
)
|
|
193
193
|
end
|
|
194
194
|
end
|
|
@@ -9,6 +9,10 @@ module LlmCostTracker
|
|
|
9
9
|
class Engine < ::Rails::Engine
|
|
10
10
|
isolate_namespace LlmCostTracker
|
|
11
11
|
|
|
12
|
+
initializer "llm_cost_tracker.deprecator" do |app|
|
|
13
|
+
app.deprecators[:llm_cost_tracker] = LlmCostTracker.deprecator
|
|
14
|
+
end
|
|
15
|
+
|
|
12
16
|
initializer "llm_cost_tracker.dashboard_setup_state" do |app|
|
|
13
17
|
app.reloader.to_prepare { LlmCostTracker::Dashboard::SetupState.reset! }
|
|
14
18
|
end
|
|
@@ -6,20 +6,29 @@ module LlmCostTracker
|
|
|
6
6
|
class InvalidFilterError < Error; end
|
|
7
7
|
|
|
8
8
|
class BudgetExceededError < Error
|
|
9
|
-
attr_reader :total, :budget, :budget_type, :last_event, :stage
|
|
9
|
+
attr_reader :total, :budget, :budget_type, :last_event, :stage, :scope
|
|
10
10
|
|
|
11
|
-
def initialize(budget:, budget_type:, total:, last_event: nil, stage: :post_spend)
|
|
11
|
+
def initialize(budget:, budget_type:, total:, last_event: nil, stage: :post_spend, scope: nil)
|
|
12
12
|
@total = total
|
|
13
13
|
@budget = budget
|
|
14
14
|
@budget_type = budget_type
|
|
15
15
|
@last_event = last_event
|
|
16
16
|
@stage = stage
|
|
17
|
+
@scope = scope
|
|
17
18
|
|
|
18
19
|
super(
|
|
19
|
-
"LLM #{@budget_type.to_s.tr('_', '-')} budget exceeded: " \
|
|
20
|
+
"LLM #{@budget_type.to_s.tr('_', '-')} budget exceeded#{scope_suffix}: " \
|
|
20
21
|
"$#{format('%.6f', @total)} / $#{format('%.6f', budget)}"
|
|
21
22
|
)
|
|
22
23
|
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def scope_suffix
|
|
28
|
+
return "" unless @scope
|
|
29
|
+
|
|
30
|
+
" for #{@scope[:key]}=#{@scope[:value]}"
|
|
31
|
+
end
|
|
23
32
|
end
|
|
24
33
|
|
|
25
34
|
class UnknownPricingError < Error
|
|
@@ -11,7 +11,7 @@ module LlmCostTracker
|
|
|
11
11
|
source_root File.expand_path("templates", __dir__)
|
|
12
12
|
|
|
13
13
|
desc "Creates the async ingestion tables (llm_cost_tracker_ingestion_inbox_entries + _leases). " \
|
|
14
|
-
"Required when config.ingestion = :async."
|
|
14
|
+
"Required when config.ingestion.mode = :async."
|
|
15
15
|
|
|
16
16
|
def create_migration_file
|
|
17
17
|
migration_template(
|
|
@@ -25,7 +25,7 @@ module LlmCostTracker
|
|
|
25
25
|
After migrating, set the following in config/initializers/llm_cost_tracker.rb:
|
|
26
26
|
|
|
27
27
|
LlmCostTracker.configure do |config|
|
|
28
|
-
config.ingestion = :async
|
|
28
|
+
config.ingestion.mode = :async
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
Without it the async inbox tables stay unused and Tracker keeps writing
|
|
@@ -11,7 +11,7 @@ module LlmCostTracker
|
|
|
11
11
|
source_root File.expand_path("templates", __dir__)
|
|
12
12
|
|
|
13
13
|
desc "Creates the optional llm_cost_tracker_call_rollups table for fast budget reads. " \
|
|
14
|
-
"Required when config.
|
|
14
|
+
"Required when config.budgets.totals_source = :cache."
|
|
15
15
|
|
|
16
16
|
def create_migration_file
|
|
17
17
|
migration_template(
|
|
@@ -25,7 +25,7 @@ module LlmCostTracker
|
|
|
25
25
|
After migrating, set the following in config/initializers/llm_cost_tracker.rb:
|
|
26
26
|
|
|
27
27
|
LlmCostTracker.configure do |config|
|
|
28
|
-
config.
|
|
28
|
+
config.budgets.totals_source = :cache
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
Without it Tracker keeps reading budget totals as live SUM aggregates over
|
|
@@ -23,7 +23,6 @@ class CreateLlmCostTrackerAsyncIngestion < ActiveRecord::Migration<%= migration_
|
|
|
23
23
|
|
|
24
24
|
add_index :llm_cost_tracker_ingestion_inbox_entries, :event_id, unique: true
|
|
25
25
|
add_index :llm_cost_tracker_ingestion_inbox_entries, [:tracked_at, :attempts]
|
|
26
|
-
add_index :llm_cost_tracker_ingestion_inbox_entries, [:locked_at, :id]
|
|
27
26
|
add_index :llm_cost_tracker_ingestion_leases, :name, unique: true
|
|
28
27
|
end
|
|
29
28
|
end
|
data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb
CHANGED
|
@@ -74,20 +74,24 @@ class CreateLlmCostTrackerCalls < ActiveRecord::Migration<%= migration_version %
|
|
|
74
74
|
foreign_key: { to_table: :llm_cost_tracker_calls, on_delete: :cascade }
|
|
75
75
|
t.string :key, null: false
|
|
76
76
|
t.text :value, null: false
|
|
77
|
+
t.decimal :total_cost, precision: 20, scale: 8
|
|
78
|
+
t.datetime :tracked_at
|
|
77
79
|
end
|
|
78
80
|
|
|
79
81
|
add_index :llm_cost_tracker_calls, :event_id, unique: true
|
|
80
82
|
add_index :llm_cost_tracker_calls, :tracked_at
|
|
81
|
-
add_index :llm_cost_tracker_calls, [
|
|
82
|
-
add_index :llm_cost_tracker_calls, [
|
|
83
|
+
add_index :llm_cost_tracker_calls, %i[provider tracked_at]
|
|
84
|
+
add_index :llm_cost_tracker_calls, %i[model tracked_at]
|
|
83
85
|
add_index :llm_cost_tracker_calls, :cost_status
|
|
84
86
|
add_index :llm_cost_tracker_calls, :provider_response_id
|
|
87
|
+
add_index :llm_cost_tracker_calls, :id, name: :index_llm_cost_tracker_calls_on_unpriced,
|
|
88
|
+
where: "total_cost IS NULL"
|
|
85
89
|
add_index :llm_cost_tracker_call_line_items, [:llm_cost_tracker_call_id, :position]
|
|
86
90
|
add_index :llm_cost_tracker_call_tags, :llm_cost_tracker_call_id
|
|
87
91
|
if postgresql?
|
|
88
|
-
add_index :llm_cost_tracker_call_tags, [:key, :value]
|
|
92
|
+
add_index :llm_cost_tracker_call_tags, [:key, :value, :tracked_at]
|
|
89
93
|
elsif mysql?
|
|
90
|
-
add_index :llm_cost_tracker_call_tags, [:key, :value], length: { value: 191 }
|
|
94
|
+
add_index :llm_cost_tracker_call_tags, [:key, :value, :tracked_at], length: { value: 191 }
|
|
91
95
|
end
|
|
92
96
|
end
|
|
93
97
|
|
|
@@ -4,16 +4,16 @@ LlmCostTracker.configure do |config|
|
|
|
4
4
|
# Set to false to temporarily disable tracking without removing middleware.
|
|
5
5
|
config.enabled = true
|
|
6
6
|
|
|
7
|
-
# LLM Cost Tracker logs warnings through Rails.logger when available.
|
|
8
|
-
config.log_level = :info
|
|
9
|
-
|
|
10
7
|
# Tags merged into every event. Use a callable for request/job-time context.
|
|
11
|
-
config.
|
|
8
|
+
config.tags.default = -> { { environment: Rails.env } }
|
|
12
9
|
|
|
13
10
|
# Tag guardrails keep accidental high-cardinality or sensitive values out of the ledger.
|
|
14
|
-
# config.
|
|
15
|
-
# config.
|
|
16
|
-
# config.
|
|
11
|
+
# config.tags.max_count = 50
|
|
12
|
+
# config.tags.max_value_bytesize = 1024
|
|
13
|
+
# config.tags.redacted_keys = <%= LlmCostTracker::Configuration::Tags::DEFAULT_REDACTED_KEYS.inspect %>
|
|
14
|
+
|
|
15
|
+
# Tag keys that get their own cost breakdown in bin/rails llm_cost_tracker:report.
|
|
16
|
+
# config.tags.report_breakdown_keys = %w[feature user_id]
|
|
17
17
|
|
|
18
18
|
# Optional SDK integrations. Provider SDK gems are not installed by LLM Cost Tracker.
|
|
19
19
|
# Enabled integrations are checked at boot, so enable only clients your app loads.
|
|
@@ -23,35 +23,50 @@ LlmCostTracker.configure do |config|
|
|
|
23
23
|
|
|
24
24
|
# Pricing — local file refreshed via bin/rails llm_cost_tracker:prices:refresh
|
|
25
25
|
# plus inline overrides. Rates are per 1M tokens; the snapshot's currency
|
|
26
|
-
# is read from your
|
|
26
|
+
# is read from your pricing file's `metadata.currency` (USD in the bundled
|
|
27
27
|
# snapshot — set a different code per file if you maintain non-USD prices).
|
|
28
28
|
<% if options[:prices] -%>
|
|
29
|
-
config.
|
|
29
|
+
config.pricing.file = Rails.root.join("config/llm_cost_tracker_prices.yml")
|
|
30
30
|
<% else -%>
|
|
31
|
-
# config.
|
|
31
|
+
# config.pricing.file = Rails.root.join("config/llm_cost_tracker_prices.yml")
|
|
32
32
|
<% end -%>
|
|
33
|
-
# config.
|
|
33
|
+
# config.pricing.overrides = {
|
|
34
34
|
# "my-custom-model" => { input: 1.00, output: 2.00 }
|
|
35
35
|
# }
|
|
36
36
|
# :warn (default) records token usage with nil cost when a model has no rate.
|
|
37
|
-
# Use :raise to
|
|
38
|
-
|
|
37
|
+
# Use :raise to reject models that have no rate at all; a model priced with one
|
|
38
|
+
# component rate missing lands as partial and does not trigger it.
|
|
39
|
+
config.pricing.unknown_model_behavior = :warn
|
|
39
40
|
|
|
40
41
|
# Budget guardrails — cumulative monthly/daily and per-call ceilings in USD,
|
|
41
|
-
# plus behavior on crossing (:notify default fires
|
|
42
|
+
# plus behavior on crossing (:notify default fires budgets.on_exceeded; :raise
|
|
42
43
|
# raises after recording; :block_requests preflights supported requests, also
|
|
43
44
|
# estimating the current call's input cost via chars/4 so it can block before
|
|
44
|
-
# send) and an optional callback.
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
# config.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
#
|
|
45
|
+
# send) and an optional callback.
|
|
46
|
+
# config.budgets.monthly = 100.00
|
|
47
|
+
# config.budgets.daily = 10.00
|
|
48
|
+
# config.budgets.per_call = 1.00
|
|
49
|
+
config.budgets.exceeded_behavior = :notify
|
|
50
|
+
|
|
51
|
+
# One budget applied to every distinct value of a tag: each tenant gets its own
|
|
52
|
+
# 1000 a month, not 1000 shared between them. Declare as many tags as you need; a rule
|
|
53
|
+
# can set its own behavior and callback instead of following the global ones. Needs the
|
|
54
|
+
# cost columns from `bin/rails generate llm_cost_tracker:upgrade_per_tag_budgets`.
|
|
55
|
+
# Budget high-cardinality tags only — a tag with a handful of values covers most of the
|
|
56
|
+
# ledger, so its check cannot use an index and every call pays for a near-full scan.
|
|
57
|
+
# config.budgets.per_tag = {
|
|
58
|
+
# tenant_id: { monthly: 1000.00, weekly: 300.00 },
|
|
59
|
+
# user_id: { daily: 25.00, behavior: :notify }
|
|
60
|
+
# }
|
|
61
|
+
# config.budgets.on_exceeded = ->(data) {
|
|
52
62
|
# Rails.logger.warn("LLM #{data[:budget_type]} budget exceeded: $#{data[:total]} / $#{data[:budget]}")
|
|
53
63
|
# }
|
|
54
|
-
|
|
64
|
+
|
|
65
|
+
# Where budget checks read period spend from. :ledger sums llm_cost_tracker_calls on
|
|
66
|
+
# every check; :cache keeps running totals in llm_cost_tracker_call_rollups so checks
|
|
67
|
+
# read one row instead — switch once the live SUM gets slow, and generate the table
|
|
68
|
+
# first with `bin/rails generate llm_cost_tracker:call_rollups`.
|
|
69
|
+
# config.budgets.totals_source = :cache
|
|
55
70
|
|
|
56
71
|
# Ingestion path — :inline (default) writes events synchronously from the request
|
|
57
72
|
# thread. Set to :async for a write-ahead inbox + background worker that batches
|
|
@@ -59,13 +74,18 @@ LlmCostTracker.configure do |config|
|
|
|
59
74
|
# inbox/leases tables created by `bin/rails generate llm_cost_tracker:async_ingestion`.
|
|
60
75
|
# Synchronous inbox writes use a dedicated ActiveRecord pool (defaults to 2 connections)
|
|
61
76
|
# so they don't compete with request threads for the default pool when a tracked call
|
|
62
|
-
# happens inside an open caller transaction. Bump
|
|
77
|
+
# happens inside an open caller transaction. Bump ingestion.pool_size if your Puma
|
|
63
78
|
# worker count outgrows that.
|
|
64
|
-
# config.ingestion
|
|
65
|
-
# config.
|
|
79
|
+
# config.ingestion.mode = :async
|
|
80
|
+
# config.ingestion.pool_size = 5
|
|
81
|
+
|
|
82
|
+
# Register OpenAI-compatible gateway hosts so their calls are attributed to a
|
|
83
|
+
# provider name instead of the generic openai_compatible bucket.
|
|
84
|
+
# config.capture.openai_compatible_providers["llm.my-company.com"] = "internal_gateway"
|
|
85
|
+
|
|
86
|
+
# Streaming calls only report token usage when the request asks for it. Leave this
|
|
87
|
+
# on and the Faraday middleware adds stream_options: { include_usage: true } to
|
|
88
|
+
# OpenAI-compatible streaming request bodies that don't already set it.
|
|
89
|
+
# config.capture.request_stream_usage = false
|
|
66
90
|
|
|
67
|
-
# Register OpenAI-compatible gateway hosts and choose extra tag breakdowns
|
|
68
|
-
# for bin/rails llm_cost_tracker:report.
|
|
69
|
-
# config.openai_compatible_providers["llm.my-company.com"] = "internal_gateway"
|
|
70
|
-
# config.report_tag_breakdowns = %w[feature user_id]
|
|
71
91
|
end
|