llm_cost_tracker 0.12.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 +71 -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 +2 -0
- data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +6 -4
- 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/models/llm_cost_tracker/ingestion/inbox_entry.rb +1 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +25 -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 +8 -10
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +22 -0
- data/app/views/llm_cost_tracker/pricing/index.html.erb +1 -1
- 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/capture/stream_collector.rb +5 -4
- data/lib/llm_cost_tracker/charges/cost_status.rb +4 -3
- data/lib/llm_cost_tracker/charges/line_item.rb +3 -2
- 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/event.rb +3 -1
- 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 +27 -7
- 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/anthropic.rb +8 -7
- data/lib/llm_cost_tracker/integrations/base.rb +1 -1
- 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 +16 -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 +1384 -304
- data/lib/llm_cost_tracker/pricing/backfill.rb +11 -1
- data/lib/llm_cost_tracker/pricing/calculation.rb +44 -25
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +26 -15
- data/lib/llm_cost_tracker/pricing/matcher.rb +24 -3
- data/lib/llm_cost_tracker/pricing/mode.rb +7 -7
- 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 +16 -26
- data/lib/llm_cost_tracker/usage/catalog.rb +12 -2
- data/lib/llm_cost_tracker/usage/dimensions.yml +0 -24
- data/lib/llm_cost_tracker/usage/token_usage.rb +20 -75
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +7 -2
- data/lib/tasks/llm_cost_tracker.rake +23 -10
- metadata +22 -9
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "section"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
class Configuration
|
|
7
|
+
class Pricing < Section
|
|
8
|
+
UNKNOWN_BEHAVIORS = %i[ignore warn raise].freeze
|
|
9
|
+
|
|
10
|
+
attributes :file
|
|
11
|
+
enum_attribute :unknown_model_behavior, allowed: UNKNOWN_BEHAVIORS, default: :warn
|
|
12
|
+
|
|
13
|
+
attr_reader :overrides
|
|
14
|
+
|
|
15
|
+
def initialize(owner)
|
|
16
|
+
super
|
|
17
|
+
@file = nil
|
|
18
|
+
self.overrides = {}
|
|
19
|
+
self.unknown_model_behavior = :warn
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def overrides=(value)
|
|
23
|
+
ensure_mutable!
|
|
24
|
+
@overrides = LlmCostTracker::Pricing::Registry.normalize_price_entries(
|
|
25
|
+
value || {}, context: "pricing_overrides"
|
|
26
|
+
)
|
|
27
|
+
rescue ArgumentError, TypeError => e
|
|
28
|
+
raise Error, "invalid pricing.overrides: #{e.message}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def finalize!
|
|
32
|
+
@overrides = deep_freeze(@overrides || {})
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/module/delegation"
|
|
4
|
+
require "active_support/core_ext/string/inflections"
|
|
5
|
+
|
|
6
|
+
require_relative "mutability"
|
|
7
|
+
|
|
8
|
+
module LlmCostTracker
|
|
9
|
+
class Configuration
|
|
10
|
+
class Section
|
|
11
|
+
include Mutability
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def attributes(*names)
|
|
15
|
+
names.each do |name|
|
|
16
|
+
attr_reader name
|
|
17
|
+
|
|
18
|
+
define_method(:"#{name}=") do |value|
|
|
19
|
+
ensure_mutable!
|
|
20
|
+
instance_variable_set(:"@#{name}", value)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def enum_attribute(name, allowed:, default:)
|
|
26
|
+
attr_reader name
|
|
27
|
+
|
|
28
|
+
define_method(:"#{name}=") do |value|
|
|
29
|
+
ensure_mutable!
|
|
30
|
+
instance_variable_set(:"@#{name}", normalize_enum(name, value, allowed, default))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private attr_reader :owner
|
|
36
|
+
|
|
37
|
+
delegate :finalized?, to: :owner
|
|
38
|
+
|
|
39
|
+
def initialize(owner)
|
|
40
|
+
@owner = owner
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def finalize!; end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def section_name
|
|
48
|
+
self.class.name.split("::").last.underscore
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def normalize_enum(name, value, allowed, default)
|
|
52
|
+
value = default if value.nil?
|
|
53
|
+
return value if allowed.include?(value)
|
|
54
|
+
|
|
55
|
+
raise Error, "Unknown #{section_name}.#{name}: #{value.inspect}. Use one of: #{allowed.join(', ')}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -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
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "pricing/mode"
|
|
4
|
+
|
|
3
5
|
module LlmCostTracker
|
|
4
6
|
Event = Data.define(
|
|
5
7
|
:event_id,
|
|
@@ -48,7 +50,7 @@ module LlmCostTracker
|
|
|
48
50
|
end
|
|
49
51
|
|
|
50
52
|
def batch?
|
|
51
|
-
pricing_mode.to_s
|
|
53
|
+
Pricing::Mode.tokenize(pricing_mode.to_s).include?("batch")
|
|
52
54
|
end
|
|
53
55
|
|
|
54
56
|
def self.resolve_line_items(service_items)
|
|
@@ -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
|
|