llm_cost_tracker 0.14.1 → 0.15.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 +198 -0
- data/LICENSE.txt +28 -0
- data/README.md +38 -35
- data/app/assets/llm_cost_tracker/application.css +16 -0
- data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -96
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
- data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
- data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -13
- data/app/helpers/llm_cost_tracker/application_helper.rb +24 -54
- data/app/helpers/llm_cost_tracker/chart_helper.rb +58 -113
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +36 -26
- data/app/models/llm_cost_tracker/call.rb +15 -17
- data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
- data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
- data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +67 -222
- data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -10
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
- data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +20 -18
- data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +8 -10
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +2 -4
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -5
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
- data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
- data/app/views/llm_cost_tracker/calls/show.html.erb +7 -32
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
- data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +2 -5
- data/app/views/llm_cost_tracker/tags/show.html.erb +6 -6
- data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
- data/lib/llm_cost_tracker/budget/global.rb +69 -0
- data/lib/llm_cost_tracker/budget/limit.rb +52 -0
- data/lib/llm_cost_tracker/budget/per_tag.rb +63 -25
- data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
- data/lib/llm_cost_tracker/budget.rb +22 -171
- data/lib/llm_cost_tracker/capture/event_window.rb +17 -13
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +26 -17
- data/lib/llm_cost_tracker/capture/sse.rb +7 -5
- data/lib/llm_cost_tracker/capture/stream_collector.rb +99 -107
- data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +36 -55
- data/lib/llm_cost_tracker/capture_verifier.rb +3 -10
- data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
- data/lib/llm_cost_tracker/charges/line_item.rb +36 -34
- data/lib/llm_cost_tracker/configuration/budgets.rb +50 -19
- data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
- data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
- data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
- data/lib/llm_cost_tracker/configuration.rb +5 -9
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
- data/lib/llm_cost_tracker/doctor/price_check.rb +26 -27
- data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
- data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
- data/lib/llm_cost_tracker/doctor.rb +42 -22
- data/lib/llm_cost_tracker/engine.rb +8 -1
- data/lib/llm_cost_tracker/errors.rb +2 -4
- data/lib/llm_cost_tracker/event.rb +28 -27
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -10
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +2 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +2 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +3 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +2 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
- data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
- data/lib/llm_cost_tracker/ingestion/inbox.rb +22 -19
- data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
- data/lib/llm_cost_tracker/ingestion.rb +23 -36
- data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
- data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
- data/lib/llm_cost_tracker/integrations/anthropic.rb +82 -90
- data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
- data/lib/llm_cost_tracker/integrations/base.rb +98 -93
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +27 -23
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +54 -35
- data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +135 -121
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -243
- data/lib/llm_cost_tracker/integrations.rb +1 -20
- data/lib/llm_cost_tracker/ledger/isolation.rb +15 -9
- data/lib/llm_cost_tracker/ledger/period/totals.rb +15 -11
- data/lib/llm_cost_tracker/ledger/rollups.rb +34 -18
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +33 -5
- data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -4
- data/lib/llm_cost_tracker/ledger/store.rb +19 -37
- data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -6
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
- data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
- data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
- data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
- data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
- data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +2 -284
- data/lib/llm_cost_tracker/parsers.rb +19 -40
- data/lib/llm_cost_tracker/prices.json +3241 -187
- data/lib/llm_cost_tracker/pricing/backfill.rb +98 -35
- data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
- data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
- data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
- data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
- data/lib/llm_cost_tracker/pricing/calculation.rb +113 -161
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
- data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
- data/lib/llm_cost_tracker/pricing/matcher.rb +61 -30
- data/lib/llm_cost_tracker/pricing/mode.rb +8 -32
- data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
- data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
- data/lib/llm_cost_tracker/pricing/rate.rb +2 -0
- data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +39 -123
- data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
- data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
- data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
- data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +24 -119
- data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +59 -24
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +12 -4
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +140 -50
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
- data/lib/llm_cost_tracker/providers/azure/parser.rb +6 -13
- data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +167 -106
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +65 -55
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +12 -1
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
- data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
- data/lib/llm_cost_tracker/providers/openai/parser.rb +12 -13
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +100 -98
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +131 -137
- data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +78 -55
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +17 -4
- data/lib/llm_cost_tracker/providers.rb +3 -0
- data/lib/llm_cost_tracker/redaction.rb +13 -0
- data/lib/llm_cost_tracker/report/data.rb +25 -28
- data/lib/llm_cost_tracker/retention.rb +1 -3
- data/lib/llm_cost_tracker/tags/context.rb +22 -7
- data/lib/llm_cost_tracker/tracker.rb +45 -31
- data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
- data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
- data/lib/llm_cost_tracker/usage/dimensions.yml +85 -0
- data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +8 -4
- data/lib/tasks/llm_cost_tracker.rake +58 -25
- data/llm_cost_tracker.gemspec +64 -0
- metadata +65 -11
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "active_support/core_ext/object/deep_dup"
|
|
4
|
-
require "active_support/core_ext/object/try"
|
|
5
|
-
|
|
6
|
-
require_relative "sdk_payload"
|
|
7
|
-
|
|
8
3
|
module LlmCostTracker
|
|
9
4
|
module Capture
|
|
10
5
|
class StreamTracker
|
|
@@ -21,23 +16,12 @@ module LlmCostTracker
|
|
|
21
16
|
def wrap
|
|
22
17
|
return @stream unless @stream
|
|
23
18
|
|
|
24
|
-
|
|
25
|
-
if
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
@stream.instance_variable_set(:@iterator,
|
|
29
|
-
Enumerator.new do |yielder|
|
|
30
|
-
each_from(iterator) { |event| yielder << event }
|
|
31
|
-
end)
|
|
32
|
-
iterator_wrapped = true
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
each_wrapped = false
|
|
36
|
-
if !iterator_wrapped && @stream.respond_to?(:each)
|
|
19
|
+
iterator = @stream.instance_variable_get(:@iterator)
|
|
20
|
+
if iterator.respond_to?(:each)
|
|
21
|
+
wrap_iterator(iterator)
|
|
22
|
+
elsif @stream.respond_to?(:each)
|
|
37
23
|
wrap_each
|
|
38
|
-
|
|
39
|
-
end
|
|
40
|
-
unless iterator_wrapped || each_wrapped
|
|
24
|
+
else
|
|
41
25
|
Logging.warn(
|
|
42
26
|
"stream integration found no wrappable iterator on #{@stream.class} " \
|
|
43
27
|
"(missing both `@iterator` ivar and `#each`); usage will not be captured"
|
|
@@ -52,30 +36,40 @@ module LlmCostTracker
|
|
|
52
36
|
|
|
53
37
|
private
|
|
54
38
|
|
|
39
|
+
def wrap_iterator(iterator)
|
|
40
|
+
relayed = Enumerator.new { |yielder| relay(iterator.method(:each)) { |event| yielder << event } }
|
|
41
|
+
@stream.instance_variable_set(:@iterator, relayed)
|
|
42
|
+
close_through(iterator) if @stream.respond_to?(:close)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def close_through(iterator)
|
|
46
|
+
close = @stream.method(:close)
|
|
47
|
+
finish = method(:finish!)
|
|
48
|
+
@stream.define_singleton_method(:close) do
|
|
49
|
+
@iterator = iterator
|
|
50
|
+
close.call
|
|
51
|
+
ensure
|
|
52
|
+
finish.call(errored: false)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
55
56
|
def wrap_each
|
|
56
|
-
tracker = self
|
|
57
57
|
original_each = @stream.method(:each)
|
|
58
|
+
relayed_each = ->(&block) { relay(original_each, &block) }
|
|
58
59
|
@stream.define_singleton_method(:each) do |&block|
|
|
59
60
|
next enum_for(:each) unless block
|
|
60
61
|
|
|
61
|
-
|
|
62
|
+
relayed_each.call(&block)
|
|
62
63
|
end
|
|
63
64
|
end
|
|
64
65
|
|
|
65
|
-
def
|
|
66
|
+
def relay(source)
|
|
66
67
|
errored = false
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
yield event
|
|
71
|
-
end
|
|
72
|
-
else
|
|
73
|
-
iterable.call do |event|
|
|
74
|
-
capture(event)
|
|
75
|
-
yield event
|
|
76
|
-
end
|
|
68
|
+
source.call do |event|
|
|
69
|
+
capture(event)
|
|
70
|
+
yield event
|
|
77
71
|
end
|
|
78
|
-
rescue Exception # rubocop:disable Lint/RescueException
|
|
72
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
79
73
|
errored = true
|
|
80
74
|
raise
|
|
81
75
|
ensure
|
|
@@ -83,34 +77,21 @@ module LlmCostTracker
|
|
|
83
77
|
end
|
|
84
78
|
|
|
85
79
|
def capture(event)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
@collector.event(payload, type: type&.to_s)
|
|
80
|
+
data = event.to_h if event.respond_to?(:to_h)
|
|
81
|
+
type = event.type if event.respond_to?(:type)
|
|
82
|
+
@collector.event(data || {}, type: type&.to_s)
|
|
90
83
|
rescue StandardError => e
|
|
91
84
|
warn_capture_failure(e)
|
|
92
85
|
end
|
|
93
86
|
|
|
94
87
|
def warn_capture_failure(error)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
@capture_failed = true
|
|
99
|
-
true
|
|
100
|
-
end
|
|
101
|
-
return unless should_warn
|
|
102
|
-
|
|
103
|
-
Logging.warn("stream integration failed to capture event: #{error.class}: #{error.message}")
|
|
88
|
+
first_failure = @mutex.synchronize { !@capture_failed && (@capture_failed = true) }
|
|
89
|
+
Logging.warn("stream integration failed to capture event: #{error.class}: #{error.message}") if first_failure
|
|
104
90
|
end
|
|
105
91
|
|
|
106
92
|
def finish!(errored:)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
@finished = true
|
|
111
|
-
true
|
|
112
|
-
end
|
|
113
|
-
return unless should_finish && @active.call
|
|
93
|
+
claimed = @mutex.synchronize { !@finished && (@finished = true) }
|
|
94
|
+
return unless claimed && @active.call
|
|
114
95
|
|
|
115
96
|
begin
|
|
116
97
|
@finish.call(errored)
|
|
@@ -11,9 +11,8 @@ module LlmCostTracker
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def report(checks = call)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
end).join("\n")
|
|
14
|
+
lines = checks.map { |check| "[#{check.status}] #{check.name}: #{check.message}" }
|
|
15
|
+
["LLM Cost Tracker capture verification", *lines].join("\n")
|
|
17
16
|
end
|
|
18
17
|
|
|
19
18
|
def healthy?(checks = call)
|
|
@@ -25,7 +24,7 @@ module LlmCostTracker
|
|
|
25
24
|
[
|
|
26
25
|
enabled_check,
|
|
27
26
|
*integration_checks,
|
|
28
|
-
*
|
|
27
|
+
*LlmCostTracker::Ingestion.verify
|
|
29
28
|
].compact
|
|
30
29
|
end
|
|
31
30
|
|
|
@@ -49,11 +48,5 @@ module LlmCostTracker
|
|
|
49
48
|
check.with(name: "sdk integration #{check.name}")
|
|
50
49
|
end
|
|
51
50
|
end
|
|
52
|
-
|
|
53
|
-
def storage_checks
|
|
54
|
-
LlmCostTracker::Ingestion.verify
|
|
55
|
-
rescue LlmCostTracker::Error => e
|
|
56
|
-
[Check.new(:error, "storage", e.message)]
|
|
57
|
-
end
|
|
58
51
|
end
|
|
59
52
|
end
|
|
@@ -11,12 +11,18 @@ module LlmCostTracker
|
|
|
11
11
|
UNKNOWN = "unknown"
|
|
12
12
|
INCOMPLETE = [UNKNOWN, PARTIAL].freeze
|
|
13
13
|
|
|
14
|
+
TokenCharge = Data.define(:cost, :partial) do
|
|
15
|
+
def priced? = !cost.nil?
|
|
16
|
+
|
|
17
|
+
def unpriced? = cost.nil? || partial
|
|
18
|
+
end
|
|
19
|
+
private_constant :TokenCharge
|
|
20
|
+
|
|
14
21
|
def self.unknown_pricing_sql(total_cost: "total_cost", cost_status: "cost_status")
|
|
15
|
-
statuses = INCOMPLETE.map { |status| ActiveRecord::Base.
|
|
22
|
+
statuses = INCOMPLETE.map { |status| ActiveRecord::Base.lease_connection.quote(status) }.join(", ")
|
|
16
23
|
"#{total_cost} IS NULL OR #{cost_status} IN (#{statuses})"
|
|
17
24
|
end
|
|
18
25
|
|
|
19
|
-
# rubocop:disable-next Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
20
26
|
def self.call(token_usage:,
|
|
21
27
|
usage_source:,
|
|
22
28
|
token_cost:,
|
|
@@ -25,28 +31,26 @@ module LlmCostTracker
|
|
|
25
31
|
token_pricing_partial: false)
|
|
26
32
|
return UNKNOWN if usage_source == Usage::Source::UNKNOWN
|
|
27
33
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
service_priced = false
|
|
31
|
-
service_unpriced = false
|
|
32
|
-
service_line_items.each do |line_item|
|
|
33
|
-
next unless line_item.billable?
|
|
34
|
+
charges = billable_charges(token_usage, token_cost, token_pricing_partial, service_line_items)
|
|
35
|
+
return for_cost(total_cost) if charges.none?(&:unpriced?)
|
|
34
36
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
charges.any?(&:priced?) ? PARTIAL : UNKNOWN
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.for_cost(cost)
|
|
41
|
+
return UNKNOWN if cost.nil?
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return UNKNOWN if unpriced && !priced
|
|
44
|
-
return PARTIAL if unpriced
|
|
43
|
+
cost.zero? ? FREE : COMPLETE
|
|
44
|
+
end
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
def self.billable_charges(token_usage, token_cost, token_pricing_partial, service_line_items)
|
|
47
|
+
charges = service_line_items.select(&:billable?)
|
|
48
|
+
return charges unless token_usage.total_tokens.to_i.positive? ||
|
|
49
|
+
token_usage.priced_quantities.any? { |_key, quantity| quantity.positive? }
|
|
47
50
|
|
|
48
|
-
|
|
51
|
+
charges + [TokenCharge.new(cost: token_cost, partial: token_pricing_partial)]
|
|
49
52
|
end
|
|
53
|
+
private_class_method :billable_charges
|
|
50
54
|
end
|
|
51
55
|
end
|
|
52
56
|
end
|
|
@@ -30,35 +30,27 @@ module LlmCostTracker
|
|
|
30
30
|
)
|
|
31
31
|
|
|
32
32
|
class LineItem
|
|
33
|
+
DIMENSION_FIELDS = %i[kind direction modality cache_state unit].freeze
|
|
34
|
+
DIMENSION_DEFAULTS = { cache_state: "none" }.freeze
|
|
35
|
+
AMOUNT_DEFAULTS = {
|
|
36
|
+
quantity: BigDecimal("0"), rate_amount: nil, rate_quantity: BigDecimal("1"), cost: nil
|
|
37
|
+
}.freeze
|
|
38
|
+
SOURCE_FIELDS = %i[pricing_basis price_key price_source].freeze
|
|
39
|
+
private_constant :DIMENSION_FIELDS, :DIMENSION_DEFAULTS, :AMOUNT_DEFAULTS, :SOURCE_FIELDS
|
|
40
|
+
|
|
33
41
|
def self.build(attributes)
|
|
34
42
|
attributes = attributes.to_h
|
|
35
|
-
|
|
36
|
-
new(
|
|
37
|
-
kind: attributes[:kind]&.to_s || dimension&.kind,
|
|
38
|
-
direction: attributes[:direction]&.to_s || dimension&.direction,
|
|
39
|
-
modality: attributes[:modality]&.to_s || dimension&.modality,
|
|
40
|
-
cache_state: attributes[:cache_state]&.to_s || dimension&.cache_state || "none",
|
|
41
|
-
quantity: decimal_or_nil(attributes[:quantity]) || BigDecimal("0"),
|
|
42
|
-
unit: attributes[:unit]&.to_s || dimension&.unit,
|
|
43
|
-
rate_amount: decimal_or_nil(attributes[:rate_amount]),
|
|
44
|
-
rate_quantity: decimal_or_nil(attributes[:rate_quantity]) || BigDecimal("1"),
|
|
45
|
-
cost: decimal_or_nil(attributes[:cost]),
|
|
46
|
-
currency: canonical_currency(attributes[:currency]),
|
|
47
|
-
cost_status: cost_status_for(attributes),
|
|
48
|
-
pricing_basis: attributes[:pricing_basis]&.to_s,
|
|
49
|
-
price_key: attributes[:price_key]&.to_s,
|
|
50
|
-
price_source: attributes[:price_source]&.to_s,
|
|
51
|
-
price_source_version: attributes[:price_source_version],
|
|
52
|
-
provider_field: attributes[:provider_field],
|
|
53
|
-
provider_item_id: attributes[:provider_item_id],
|
|
54
|
-
details: attributes[:details] || {}
|
|
55
|
-
)
|
|
43
|
+
new(**dimension_fields(attributes), **amount_fields(attributes), **source_fields(attributes))
|
|
56
44
|
end
|
|
57
45
|
|
|
58
46
|
def self.from_token_usage(token_usage)
|
|
59
47
|
return [] unless token_usage
|
|
60
48
|
|
|
61
|
-
token_usage.priced_quantities
|
|
49
|
+
from_quantities(token_usage.priced_quantities)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.from_quantities(quantities)
|
|
53
|
+
quantities.filter_map do |key, quantity|
|
|
62
54
|
next unless quantity.positive?
|
|
63
55
|
|
|
64
56
|
dimension = Usage::Catalog.fetch(key)
|
|
@@ -73,14 +65,28 @@ module LlmCostTracker
|
|
|
73
65
|
end
|
|
74
66
|
end
|
|
75
67
|
|
|
76
|
-
def self.
|
|
77
|
-
|
|
78
|
-
|
|
68
|
+
def self.dimension_fields(attributes)
|
|
69
|
+
dimension = dimension_for(attributes).to_h
|
|
70
|
+
DIMENSION_FIELDS.to_h do |field|
|
|
71
|
+
[field, attributes[field]&.to_s || dimension[field] || DIMENSION_DEFAULTS[field]]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
79
74
|
|
|
80
|
-
|
|
81
|
-
|
|
75
|
+
def self.amount_fields(attributes)
|
|
76
|
+
amounts = AMOUNT_DEFAULTS.to_h { |field, default| [field, decimal_or_nil(attributes[field]) || default] }
|
|
77
|
+
amounts.merge(
|
|
78
|
+
currency: (attributes[:currency] || LlmCostTracker::DEFAULT_CURRENCY).to_s.upcase,
|
|
79
|
+
cost_status: (attributes[:cost_status] || CostStatus.for_cost(amounts[:cost])).to_s
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
def self.source_fields(attributes)
|
|
84
|
+
SOURCE_FIELDS.to_h { |field| [field, attributes[field]&.to_s] }.merge(
|
|
85
|
+
price_source_version: attributes[:price_source_version],
|
|
86
|
+
provider_field: attributes[:provider_field],
|
|
87
|
+
provider_item_id: attributes[:provider_item_id],
|
|
88
|
+
details: attributes[:details] || {}
|
|
89
|
+
)
|
|
84
90
|
end
|
|
85
91
|
|
|
86
92
|
def self.dimension_for(attributes)
|
|
@@ -96,11 +102,7 @@ module LlmCostTracker
|
|
|
96
102
|
BigDecimal(value.to_s)
|
|
97
103
|
end
|
|
98
104
|
|
|
99
|
-
|
|
100
|
-
(value || LlmCostTracker::DEFAULT_CURRENCY).to_s.upcase
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
private_class_method :cost_status_for, :dimension_for, :decimal_or_nil, :canonical_currency
|
|
105
|
+
private_class_method :dimension_fields, :amount_fields, :source_fields, :dimension_for, :decimal_or_nil
|
|
104
106
|
|
|
105
107
|
def billable?
|
|
106
108
|
quantity.positive?
|
|
@@ -135,7 +137,7 @@ module LlmCostTracker
|
|
|
135
137
|
rate_quantity: rate.quantity,
|
|
136
138
|
cost: applied_cost,
|
|
137
139
|
currency: rate.currency.upcase,
|
|
138
|
-
cost_status: applied_cost
|
|
140
|
+
cost_status: CostStatus.for_cost(applied_cost),
|
|
139
141
|
price_key: rate.source_key,
|
|
140
142
|
price_source: rate.source,
|
|
141
143
|
price_source_version: rate.source_version
|
|
@@ -9,14 +9,28 @@ module LlmCostTracker
|
|
|
9
9
|
class Budgets < Section
|
|
10
10
|
EXCEEDED_BEHAVIORS = %i[notify raise block_requests].freeze
|
|
11
11
|
TOTALS_SOURCES = %i[ledger cache].freeze
|
|
12
|
-
PER_TAG_WINDOWS = %i[daily weekly monthly].freeze
|
|
12
|
+
PER_TAG_WINDOWS = %i[daily weekly monthly total calls].freeze
|
|
13
13
|
PER_TAG_OPTIONS = %i[behavior on_exceeded].freeze
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
LIMITS = %i[monthly daily per_call].freeze
|
|
16
|
+
|
|
16
17
|
enum_attribute :exceeded_behavior, allowed: EXCEEDED_BEHAVIORS, default: :notify
|
|
17
18
|
enum_attribute :totals_source, allowed: TOTALS_SOURCES, default: :ledger
|
|
18
19
|
|
|
19
|
-
attr_reader :per_tag
|
|
20
|
+
attr_reader(*LIMITS, :on_exceeded, :per_tag)
|
|
21
|
+
|
|
22
|
+
LIMITS.each do |name|
|
|
23
|
+
define_method(:"#{name}=") do |value|
|
|
24
|
+
ensure_mutable!
|
|
25
|
+
amount = validated_amount("budgets.#{name}", value, minimum: 0) unless value.nil?
|
|
26
|
+
instance_variable_set(:"@#{name}", value.is_a?(Numeric) ? value : amount)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def on_exceeded=(value)
|
|
31
|
+
ensure_mutable!
|
|
32
|
+
@on_exceeded = validated_callback("budgets.on_exceeded", value)
|
|
33
|
+
end
|
|
20
34
|
|
|
21
35
|
def initialize(owner)
|
|
22
36
|
super
|
|
@@ -35,7 +49,7 @@ module LlmCostTracker
|
|
|
35
49
|
end
|
|
36
50
|
|
|
37
51
|
def finalize!
|
|
38
|
-
@per_tag = deep_freeze(@per_tag
|
|
52
|
+
@per_tag = deep_freeze(@per_tag)
|
|
39
53
|
end
|
|
40
54
|
|
|
41
55
|
private
|
|
@@ -48,19 +62,32 @@ module LlmCostTracker
|
|
|
48
62
|
raise Error, "budgets.per_tag[#{key.inspect}] must be a hash" unless entry.is_a?(Hash)
|
|
49
63
|
|
|
50
64
|
normalized = entry.to_h.transform_keys(&:to_sym)
|
|
51
|
-
|
|
65
|
+
{
|
|
66
|
+
windows: validated_windows(key, normalized.except(*PER_TAG_OPTIONS)),
|
|
67
|
+
behavior: validated_behavior(key, normalized[:behavior]),
|
|
68
|
+
on_exceeded: validated_callback("budgets.per_tag[#{key.inspect}][:on_exceeded]", normalized[:on_exceeded])
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def validated_amount(name, value, minimum: nil)
|
|
73
|
+
number = Float(value, exception: false)
|
|
74
|
+
return BigDecimal(value.to_s) if number && (minimum ? number >= minimum : number.positive?)
|
|
75
|
+
|
|
76
|
+
raise Error, "#{name} must be a #{minimum ? 'non-negative' : 'positive'} number, got #{value.inspect}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validated_callback(name, value)
|
|
80
|
+
return value if value.nil? || value.respond_to?(:call)
|
|
81
|
+
|
|
82
|
+
raise Error, "#{name} must respond to call, got #{value.inspect}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def validated_windows(key, windows)
|
|
52
86
|
if windows.empty?
|
|
53
|
-
raise Error,
|
|
54
|
-
"budgets.per_tag[#{key.inspect}] needs at least one of: #{PER_TAG_WINDOWS.join(', ')}"
|
|
87
|
+
raise Error, "budgets.per_tag[#{key.inspect}] needs at least one of: #{PER_TAG_WINDOWS.join(', ')}"
|
|
55
88
|
end
|
|
56
89
|
|
|
57
|
-
{
|
|
58
|
-
windows: windows.to_h do |window, limit|
|
|
59
|
-
[validated_window(key, window), validated_limit(key, window, limit)]
|
|
60
|
-
end,
|
|
61
|
-
behavior: validated_behavior(key, options[:behavior]),
|
|
62
|
-
on_exceeded: options[:on_exceeded]
|
|
63
|
-
}
|
|
90
|
+
windows.to_h { |window, limit| [validated_window(key, window), validated_limit(key, window, limit)] }
|
|
64
91
|
end
|
|
65
92
|
|
|
66
93
|
def validated_behavior(key, behavior)
|
|
@@ -81,12 +108,16 @@ module LlmCostTracker
|
|
|
81
108
|
end
|
|
82
109
|
|
|
83
110
|
def validated_limit(key, window, limit)
|
|
84
|
-
|
|
85
|
-
return BigDecimal(limit.to_s) if numeric&.positive?
|
|
111
|
+
return validated_calls(key, limit) if window == :calls
|
|
86
112
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
113
|
+
validated_amount("budgets.per_tag[#{key.inspect}][#{window.inspect}]", limit)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def validated_calls(key, limit)
|
|
117
|
+
count = Integer(limit.to_s, exception: false)
|
|
118
|
+
return count if count&.positive?
|
|
119
|
+
|
|
120
|
+
raise Error, "budgets.per_tag[#{key.inspect}][:calls] must be a positive integer, got #{limit.inspect}"
|
|
90
121
|
end
|
|
91
122
|
end
|
|
92
123
|
end
|
|
@@ -8,7 +8,13 @@ module LlmCostTracker
|
|
|
8
8
|
OPENAI_COMPATIBLE_PROVIDERS = {
|
|
9
9
|
"openrouter.ai" => "openrouter",
|
|
10
10
|
"api.deepseek.com" => "deepseek",
|
|
11
|
-
"api.groq.com" => "groq"
|
|
11
|
+
"api.groq.com" => "groq",
|
|
12
|
+
"api.x.ai" => "xai",
|
|
13
|
+
"us.api.x.ai" => "xai",
|
|
14
|
+
"api.mistral.ai" => "mistral",
|
|
15
|
+
"api.eu.mistral.ai" => "mistral",
|
|
16
|
+
"api.us.mistral.ai" => "mistral",
|
|
17
|
+
"api.perplexity.ai" => "perplexity"
|
|
12
18
|
}.freeze
|
|
13
19
|
|
|
14
20
|
attributes :request_stream_usage
|
|
@@ -18,12 +18,12 @@ module LlmCostTracker
|
|
|
18
18
|
deep_freeze(key)
|
|
19
19
|
deep_freeze(nested_value)
|
|
20
20
|
end
|
|
21
|
-
value.
|
|
21
|
+
value.freeze
|
|
22
22
|
when Array, Set
|
|
23
23
|
value.each { |nested_value| deep_freeze(nested_value) }
|
|
24
|
-
value.
|
|
24
|
+
value.freeze
|
|
25
25
|
when String
|
|
26
|
-
value.
|
|
26
|
+
value.freeze
|
|
27
27
|
else
|
|
28
28
|
value
|
|
29
29
|
end
|
|
@@ -18,8 +18,6 @@ module LlmCostTracker
|
|
|
18
18
|
budgets: Budgets, capture: Capture, ingestion: Ingestion, pricing: Pricing, tags: Tags
|
|
19
19
|
}.freeze
|
|
20
20
|
|
|
21
|
-
SCALAR_ATTRIBUTES = %i[enabled].freeze
|
|
22
|
-
|
|
23
21
|
DEPRECATED_OPTIONS = {
|
|
24
22
|
monthly_budget: { to: %i[budgets monthly] },
|
|
25
23
|
daily_budget: { to: %i[budgets daily] },
|
|
@@ -46,7 +44,7 @@ module LlmCostTracker
|
|
|
46
44
|
log_level: { to: nil, note: "LlmCostTracker logs through Rails.logger, which owns the level" }
|
|
47
45
|
}.freeze
|
|
48
46
|
|
|
49
|
-
attr_reader(
|
|
47
|
+
attr_reader(:enabled, *SECTIONS.keys, :instrumented_integrations)
|
|
50
48
|
|
|
51
49
|
def initialize
|
|
52
50
|
SECTIONS.each { |name, klass| instance_variable_set(:"@#{name}", klass.new(self)) }
|
|
@@ -67,11 +65,9 @@ module LlmCostTracker
|
|
|
67
65
|
@instrumented_integrations.include?(name)
|
|
68
66
|
end
|
|
69
67
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
instance_variable_set(:"@#{name}", value)
|
|
74
|
-
end
|
|
68
|
+
def enabled=(value)
|
|
69
|
+
ensure_mutable!
|
|
70
|
+
@enabled = value
|
|
75
71
|
end
|
|
76
72
|
|
|
77
73
|
DEPRECATED_OPTIONS.each do |old_name, spec|
|
|
@@ -101,7 +97,7 @@ module LlmCostTracker
|
|
|
101
97
|
|
|
102
98
|
def finalize!
|
|
103
99
|
SECTIONS.each_key { |name| public_send(name).finalize! }
|
|
104
|
-
@instrumented_integrations = deep_freeze(@instrumented_integrations
|
|
100
|
+
@instrumented_integrations = deep_freeze(@instrumented_integrations)
|
|
105
101
|
@finalized = true
|
|
106
102
|
end
|
|
107
103
|
|
|
@@ -24,7 +24,17 @@ module LlmCostTracker
|
|
|
24
24
|
private
|
|
25
25
|
|
|
26
26
|
def async_ok
|
|
27
|
-
|
|
27
|
+
quarantined = LlmCostTracker::Ingestion::InboxEntry.quarantined.count
|
|
28
|
+
if quarantined.zero?
|
|
29
|
+
return Check.new(:ok, "async ingestion", "inbox and ingestion lease tables are on the current schema")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
Check.new(
|
|
33
|
+
:warn,
|
|
34
|
+
"async ingestion",
|
|
35
|
+
"#{quarantined} inbox row(s) are quarantined, so their spend is not in the ledger; " \
|
|
36
|
+
"requeue them as docs/operations.md shows once the cause is fixed"
|
|
37
|
+
)
|
|
28
38
|
end
|
|
29
39
|
|
|
30
40
|
def inline_check
|
|
@@ -38,11 +48,19 @@ module LlmCostTracker
|
|
|
38
48
|
Check.new(
|
|
39
49
|
:warn,
|
|
40
50
|
"inline ingestion",
|
|
41
|
-
"config.ingestion.mode = :inline but found unused async ingestion tables: #{leftovers.join(', ')}
|
|
51
|
+
"config.ingestion.mode = :inline but found unused async ingestion tables: #{leftovers.join(', ')}" \
|
|
52
|
+
"#{undrained_note(leftovers)}. " \
|
|
42
53
|
"Set config.ingestion.mode = :async to keep the inbox path or drop the tables."
|
|
43
54
|
)
|
|
44
55
|
end
|
|
45
56
|
|
|
57
|
+
def undrained_note(leftovers)
|
|
58
|
+
return unless leftovers.include?(LlmCostTracker::Ingestion::InboxEntry.table_name)
|
|
59
|
+
|
|
60
|
+
rows = LlmCostTracker::Ingestion::InboxEntry.count
|
|
61
|
+
"; #{rows} inbox row(s) never reached the ledger, so drain them in :async mode first" if rows.positive?
|
|
62
|
+
end
|
|
63
|
+
|
|
46
64
|
def inline_leftover_tables
|
|
47
65
|
async_tables.select { |table| Probe.table_exists?(table) }
|
|
48
66
|
end
|
|
@@ -15,29 +15,9 @@ module LlmCostTracker
|
|
|
15
15
|
return bundled_check unless path
|
|
16
16
|
return Check.new(:error, "prices", "#{path} does not exist; #{REFRESH_COMMAND}") unless File.exist?(path)
|
|
17
17
|
|
|
18
|
-
count =
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return configured_check(:warn, path, count, "metadata.updated_at missing; #{REFRESH_COMMAND}") unless updated_at
|
|
22
|
-
|
|
23
|
-
age_days = (Date.today - Date.iso8601(updated_at.to_s)).to_i
|
|
24
|
-
if age_days > STALE_AFTER_DAYS
|
|
25
|
-
return configured_check(
|
|
26
|
-
:warn,
|
|
27
|
-
path,
|
|
28
|
-
count,
|
|
29
|
-
"updated_at=#{updated_at} is older than #{STALE_AFTER_DAYS} days; #{REFRESH_COMMAND}"
|
|
30
|
-
)
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
configured_check(:ok, path, count, "updated_at=#{updated_at}")
|
|
34
|
-
rescue Date::Error
|
|
35
|
-
configured_check(
|
|
36
|
-
:warn,
|
|
37
|
-
path,
|
|
38
|
-
count,
|
|
39
|
-
"metadata.updated_at=#{updated_at.inspect} is invalid; #{REFRESH_COMMAND}"
|
|
40
|
-
)
|
|
18
|
+
count = Pricing::Registry.file_prices(path).size
|
|
19
|
+
status, freshness = freshness(Pricing::Registry.file_metadata(path))
|
|
20
|
+
Check.new(status, "prices", "loaded #{count} models from #{path}; #{freshness}")
|
|
41
21
|
rescue LlmCostTracker::Error => e
|
|
42
22
|
Check.new(:error, "prices", e.message)
|
|
43
23
|
end
|
|
@@ -45,16 +25,35 @@ module LlmCostTracker
|
|
|
45
25
|
private
|
|
46
26
|
|
|
47
27
|
def bundled_check
|
|
48
|
-
updated_at =
|
|
28
|
+
updated_at = Pricing::Registry.metadata.fetch("updated_at", "unknown")
|
|
49
29
|
Check.new(
|
|
50
30
|
:warn,
|
|
51
31
|
"prices",
|
|
52
|
-
"using bundled prices updated_at=#{updated_at};
|
|
32
|
+
"using bundled prices updated_at=#{updated_at}; " \
|
|
33
|
+
"commit a prices_file (config.pricing.file) for production releases"
|
|
53
34
|
)
|
|
54
35
|
end
|
|
55
36
|
|
|
56
|
-
def
|
|
57
|
-
|
|
37
|
+
def freshness(metadata)
|
|
38
|
+
updated_at = metadata["updated_at"] || metadata[:updated_at]
|
|
39
|
+
return [:warn, "metadata.updated_at missing; #{REFRESH_COMMAND}"] unless updated_at
|
|
40
|
+
|
|
41
|
+
reason = staleness(updated_at)
|
|
42
|
+
reason ? [:warn, "#{reason}; #{REFRESH_COMMAND}"] : [:ok, "updated_at=#{updated_at}"]
|
|
43
|
+
rescue Date::Error
|
|
44
|
+
[:warn, "metadata.updated_at=#{updated_at.inspect} is invalid; #{REFRESH_COMMAND}"]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def staleness(updated_at)
|
|
48
|
+
file_date = Date.iso8601(updated_at.to_s)
|
|
49
|
+
if (Date.today - file_date).to_i > STALE_AFTER_DAYS
|
|
50
|
+
return "updated_at=#{updated_at} is older than #{STALE_AFTER_DAYS} days"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
bundled_at = Pricing::Registry.metadata["updated_at"]
|
|
54
|
+
return unless bundled_at && file_date < Date.iso8601(bundled_at.to_s)
|
|
55
|
+
|
|
56
|
+
"updated_at=#{updated_at} is older than the bundled prices (#{bundled_at})"
|
|
58
57
|
end
|
|
59
58
|
end
|
|
60
59
|
end
|
|
@@ -6,7 +6,7 @@ module LlmCostTracker
|
|
|
6
6
|
class Doctor
|
|
7
7
|
module Probe
|
|
8
8
|
def self.table_exists?(name)
|
|
9
|
-
LlmCostTracker::Call.
|
|
9
|
+
LlmCostTracker::Call.lease_connection.data_source_exists?(name)
|
|
10
10
|
rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError,
|
|
11
11
|
ActiveRecord::ConnectionFailed, ActiveRecord::StatementInvalid
|
|
12
12
|
false
|
|
@@ -22,8 +22,7 @@ module LlmCostTracker
|
|
|
22
22
|
Check.new(
|
|
23
23
|
:error,
|
|
24
24
|
@name,
|
|
25
|
-
"current schema required; #{errors.join('; ')}; "
|
|
26
|
-
"run bin/rails generate llm_cost_tracker:install && bin/rails db:migrate"
|
|
25
|
+
"current schema required; #{errors.join('; ')}; see docs/upgrading.md"
|
|
27
26
|
)
|
|
28
27
|
end
|
|
29
28
|
end
|