llm_cost_tracker 0.14.0 → 0.14.2
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 +128 -0
- data/README.md +16 -5
- data/app/assets/llm_cost_tracker/application.css +16 -0
- data/app/controllers/llm_cost_tracker/application_controller.rb +16 -3
- data/app/controllers/llm_cost_tracker/calls_controller.rb +1 -3
- data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
- data/app/controllers/llm_cost_tracker/tags_controller.rb +9 -7
- data/app/helpers/llm_cost_tracker/application_helper.rb +5 -26
- data/app/helpers/llm_cost_tracker/chart_helper.rb +6 -10
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -0
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +8 -28
- data/app/models/llm_cost_tracker/call.rb +10 -14
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +4 -15
- data/app/services/llm_cost_tracker/dashboard/filter.rb +29 -30
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
- data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +2 -3
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -0
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -2
- data/app/views/llm_cost_tracker/calls/show.html.erb +8 -22
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
- 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 +1 -4
- data/app/views/llm_cost_tracker/tags/show.html.erb +8 -5
- data/config/routes.rb +6 -1
- data/lib/llm_cost_tracker/budget/per_tag.rb +21 -11
- data/lib/llm_cost_tracker/budget.rb +44 -55
- data/lib/llm_cost_tracker/capture/event_window.rb +100 -0
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +5 -1
- data/lib/llm_cost_tracker/capture/sse.rb +108 -32
- data/lib/llm_cost_tracker/capture/stream_collector.rb +41 -81
- data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +14 -58
- data/lib/llm_cost_tracker/capture_verifier.rb +1 -7
- data/lib/llm_cost_tracker/charges/line_item.rb +5 -1
- data/lib/llm_cost_tracker/configuration/budgets.rb +1 -1
- 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/price_check.rb +14 -3
- data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
- data/lib/llm_cost_tracker/doctor.rb +19 -2
- data/lib/llm_cost_tracker/engine.rb +0 -1
- data/lib/llm_cost_tracker/errors.rb +13 -0
- data/lib/llm_cost_tracker/event.rb +8 -0
- 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 +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -12
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -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 +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -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 +37 -8
- data/lib/llm_cost_tracker/ingestion/inbox.rb +2 -8
- data/lib/llm_cost_tracker/ingestion.rb +3 -1
- data/lib/llm_cost_tracker/integrations/anthropic.rb +70 -11
- data/lib/llm_cost_tracker/integrations/base.rb +56 -19
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +21 -13
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +8 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +42 -59
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +275 -75
- data/lib/llm_cost_tracker/integrations.rb +1 -20
- data/lib/llm_cost_tracker/ledger/isolation.rb +23 -0
- data/lib/llm_cost_tracker/ledger/period/totals.rb +16 -11
- data/lib/llm_cost_tracker/ledger/rollups.rb +25 -17
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +14 -3
- data/lib/llm_cost_tracker/ledger/schema/base.rb +5 -4
- data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
- data/lib/llm_cost_tracker/ledger/store.rb +14 -13
- data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +1 -5
- 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/ledger.rb +1 -0
- data/lib/llm_cost_tracker/logging.rb +3 -1
- data/lib/llm_cost_tracker/middleware/faraday.rb +67 -61
- data/lib/llm_cost_tracker/parsers.rb +12 -41
- data/lib/llm_cost_tracker/prices.json +3071 -249
- data/lib/llm_cost_tracker/pricing/backfill.rb +46 -13
- data/lib/llm_cost_tracker/pricing/calculation.rb +118 -75
- 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 +36 -22
- data/lib/llm_cost_tracker/pricing/mode.rb +2 -13
- data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +17 -17
- data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
- data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
- data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +38 -70
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +33 -3
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +11 -5
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +81 -12
- data/lib/llm_cost_tracker/providers/azure/parser.rb +22 -7
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +175 -47
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +13 -22
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -1
- data/lib/llm_cost_tracker/providers/openai/parser.rb +11 -13
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +101 -27
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +57 -40
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +24 -6
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +7 -1
- data/lib/llm_cost_tracker/redaction.rb +32 -0
- data/lib/llm_cost_tracker/report/data.rb +1 -1
- data/lib/llm_cost_tracker/retention.rb +1 -3
- data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -36
- data/lib/llm_cost_tracker/tracker.rb +17 -10
- 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 +61 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +4 -2
- data/lib/tasks/llm_cost_tracker.rake +26 -21
- data/llm_cost_tracker.gemspec +63 -0
- metadata +23 -10
|
@@ -13,7 +13,6 @@ module LlmCostTracker
|
|
|
13
13
|
:missing_latency_count,
|
|
14
14
|
:streaming_count,
|
|
15
15
|
:streaming_missing_usage,
|
|
16
|
-
:missing_provider_response_id_count,
|
|
17
16
|
:calls_with_pricing,
|
|
18
17
|
:tagged_calls,
|
|
19
18
|
:calls_with_latency,
|
|
@@ -52,7 +51,7 @@ module LlmCostTracker
|
|
|
52
51
|
return nil unless Budget::PerTag.columns?
|
|
53
52
|
return nil unless LlmCostTracker::CallTag.exists?
|
|
54
53
|
|
|
55
|
-
unseen = budgeted.keys.reject { |key| LlmCostTracker::CallTag.
|
|
54
|
+
unseen = budgeted.keys.reject { |key| LlmCostTracker::CallTag.where(key: key).where.not(value: "").exists? }
|
|
56
55
|
return nil if unseen.empty?
|
|
57
56
|
|
|
58
57
|
UnseenBudgetTags.new(keys: unseen)
|
|
@@ -79,7 +78,6 @@ module LlmCostTracker
|
|
|
79
78
|
missing_latency_count,
|
|
80
79
|
streaming_count,
|
|
81
80
|
streaming_missing_usage,
|
|
82
|
-
missing_provider_response_id_count,
|
|
83
81
|
calls_with_pricing,
|
|
84
82
|
tagged_calls,
|
|
85
83
|
calls_with_latency,
|
|
@@ -142,7 +140,6 @@ module LlmCostTracker
|
|
|
142
140
|
token_value = stats[component.token_key].to_i
|
|
143
141
|
|
|
144
142
|
{
|
|
145
|
-
price_key: component.key,
|
|
146
143
|
token_key: component.token_key,
|
|
147
144
|
cost_key: component.cost_key,
|
|
148
145
|
token_value: token_value,
|
|
@@ -154,7 +151,6 @@ module LlmCostTracker
|
|
|
154
151
|
|
|
155
152
|
rows + [
|
|
156
153
|
{
|
|
157
|
-
price_key: nil,
|
|
158
154
|
token_key: :hidden_output_tokens,
|
|
159
155
|
cost_key: nil,
|
|
160
156
|
token_value: stats.hidden_output_tokens.to_i,
|
|
@@ -178,7 +174,7 @@ module LlmCostTracker
|
|
|
178
174
|
Arel.sql("#{line_item_table}.direction"),
|
|
179
175
|
Arel.sql("#{line_item_table}.cache_state"),
|
|
180
176
|
Arel.sql("COALESCE(SUM(#{line_item_table}.cost), 0)"))
|
|
181
|
-
|
|
177
|
+
Usage::Catalog.costs_by_component(rows)
|
|
182
178
|
end
|
|
183
179
|
|
|
184
180
|
def streaming_health_rows(scope, total_streaming:)
|
|
@@ -221,13 +217,6 @@ module LlmCostTracker
|
|
|
221
217
|
|
|
222
218
|
private
|
|
223
219
|
|
|
224
|
-
def index_costs_by_component(rows)
|
|
225
|
-
rows.each_with_object({}) do |(kind, direction, cache_state, cost), accumulator|
|
|
226
|
-
component = Usage::Catalog.token_priced_for(kind: kind, direction: direction, cache_state: cache_state)
|
|
227
|
-
accumulator[component.key] = cost if component
|
|
228
|
-
end
|
|
229
|
-
end
|
|
230
|
-
|
|
231
220
|
def percentage(numerator, denominator)
|
|
232
221
|
return 0.0 unless denominator.positive?
|
|
233
222
|
|
|
@@ -239,7 +228,6 @@ module LlmCostTracker
|
|
|
239
228
|
selects = [
|
|
240
229
|
"COUNT(*) AS total_calls",
|
|
241
230
|
"#{conditional_count_sql(unknown_pricing)} AS unknown_pricing_count",
|
|
242
|
-
"#{tagged_calls_sql(scope)} AS tagged_calls_count",
|
|
243
231
|
"COUNT(*) - #{tagged_calls_sql(scope)} AS untagged_calls_count",
|
|
244
232
|
"#{conditional_count_sql('latency_ms IS NULL')} AS missing_latency_count",
|
|
245
233
|
"#{conditional_count_sql('stream')} AS streaming_count",
|
|
@@ -301,7 +289,8 @@ module LlmCostTracker
|
|
|
301
289
|
tags_table = LlmCostTracker::CallTag.quoted_table_name
|
|
302
290
|
|
|
303
291
|
"COALESCE(SUM(CASE WHEN EXISTS (SELECT 1 FROM #{tags_table} " \
|
|
304
|
-
"WHERE #{tags_table}.llm_cost_tracker_call_id = #{calls_table}.id
|
|
292
|
+
"WHERE #{tags_table}.llm_cost_tracker_call_id = #{calls_table}.id " \
|
|
293
|
+
"AND #{tags_table}.#{scope.connection.quote_column_name('value')} != '') THEN 1 ELSE 0 END), 0)"
|
|
305
294
|
end
|
|
306
295
|
end
|
|
307
296
|
end
|
|
@@ -5,20 +5,23 @@ require "date"
|
|
|
5
5
|
module LlmCostTracker
|
|
6
6
|
module Dashboard
|
|
7
7
|
class Filter
|
|
8
|
+
MAX_TAG_FILTERS = 10
|
|
9
|
+
MATCH_NOTHING = Arel.sql("1 = 0")
|
|
8
10
|
STREAM_FILTER_OPTIONS = [
|
|
9
11
|
["Streaming only", "yes"],
|
|
10
12
|
["Non-streaming only", "no"]
|
|
11
13
|
].freeze
|
|
12
14
|
|
|
13
15
|
class << self
|
|
14
|
-
def call(scope: LlmCostTracker::Call.all, params: {})
|
|
15
|
-
new(scope: scope, params: params).relation
|
|
16
|
+
def call(scope: LlmCostTracker::Call.all, params: {}, tags: {})
|
|
17
|
+
new(scope: scope, params: params, tags: tags).relation
|
|
16
18
|
end
|
|
17
19
|
end
|
|
18
20
|
|
|
19
|
-
def initialize(scope:, params:)
|
|
21
|
+
def initialize(scope:, params:, tags: {})
|
|
20
22
|
@scope = scope
|
|
21
23
|
@params = LlmCostTracker::Dashboard::Params.to_hash(params).symbolize_keys
|
|
24
|
+
@extra_tags = tags
|
|
22
25
|
end
|
|
23
26
|
|
|
24
27
|
def relation
|
|
@@ -27,44 +30,44 @@ module LlmCostTracker
|
|
|
27
30
|
filtered_scope = apply_exact_filter(filtered_scope, :provider)
|
|
28
31
|
filtered_scope = apply_exact_filter(filtered_scope, :model)
|
|
29
32
|
filtered_scope = apply_stream_filter(filtered_scope)
|
|
30
|
-
filtered_scope =
|
|
33
|
+
filtered_scope = apply_exact_filter(filtered_scope, :usage_source)
|
|
31
34
|
apply_tag_filters(filtered_scope)
|
|
32
35
|
end
|
|
33
36
|
|
|
34
37
|
private
|
|
35
38
|
|
|
36
|
-
attr_reader :scope, :params
|
|
39
|
+
attr_reader :scope, :params, :extra_tags
|
|
37
40
|
|
|
38
41
|
def apply_date_filters(relation)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
Dashboard::DateRange.
|
|
42
|
-
|
|
43
|
-
default_range = Dashboard::DateRange.call(params: params)
|
|
44
|
-
from_date ||= default_range.from
|
|
45
|
-
to_date ||= default_range.to
|
|
42
|
+
Dashboard::DateRange.validate!(from: Dashboard::DateRange.parse(params, :from),
|
|
43
|
+
to: Dashboard::DateRange.parse(params, :to))
|
|
44
|
+
range = Dashboard::DateRange.call(params: params)
|
|
46
45
|
|
|
47
46
|
relation
|
|
48
|
-
.where(tracked_at:
|
|
49
|
-
.where(tracked_at: ..
|
|
47
|
+
.where(tracked_at: range.from.beginning_of_day..)
|
|
48
|
+
.where(tracked_at: ..range.to.end_of_day)
|
|
50
49
|
end
|
|
51
50
|
|
|
52
51
|
def apply_exact_filter(relation, key)
|
|
53
|
-
value = normalized_string(params[key])
|
|
52
|
+
value = normalized_string(params[key], key)
|
|
54
53
|
return relation if value.nil?
|
|
55
54
|
|
|
56
|
-
relation.where(key => value)
|
|
55
|
+
matchable?([value]) ? relation.where(key => value) : relation.where(MATCH_NOTHING)
|
|
57
56
|
end
|
|
58
57
|
|
|
59
58
|
def apply_tag_filters(relation)
|
|
60
|
-
tags = tag_params
|
|
59
|
+
tags = tag_params.merge(extra_tags)
|
|
61
60
|
return relation if tags.empty?
|
|
61
|
+
if tags.size > MAX_TAG_FILTERS
|
|
62
|
+
raise LlmCostTracker::InvalidFilterError,
|
|
63
|
+
"at most #{MAX_TAG_FILTERS} tag filters are allowed, got #{tags.size}"
|
|
64
|
+
end
|
|
62
65
|
|
|
63
|
-
relation.by_tags(tags)
|
|
66
|
+
matchable?(tags.values) ? relation.by_tags(tags) : relation.where(MATCH_NOTHING)
|
|
64
67
|
end
|
|
65
68
|
|
|
66
69
|
def apply_stream_filter(relation)
|
|
67
|
-
value = normalized_string(params[:stream])
|
|
70
|
+
value = normalized_string(params[:stream], :stream)
|
|
68
71
|
return relation if value.nil?
|
|
69
72
|
|
|
70
73
|
case value.downcase
|
|
@@ -74,28 +77,24 @@ module LlmCostTracker
|
|
|
74
77
|
end
|
|
75
78
|
end
|
|
76
79
|
|
|
77
|
-
def apply_usage_source_filter(relation)
|
|
78
|
-
value = normalized_string(params[:usage_source])
|
|
79
|
-
return relation if value.nil?
|
|
80
|
-
|
|
81
|
-
relation.where(usage_source: value)
|
|
82
|
-
end
|
|
83
|
-
|
|
84
80
|
def tag_params
|
|
85
81
|
tags = LlmCostTracker::Dashboard::Params.to_hash(params[:tag])
|
|
86
82
|
|
|
87
83
|
tags.each_with_object({}) do |(key, value), normalized|
|
|
88
|
-
value = normalized_string(value)
|
|
84
|
+
value = normalized_string(value, "tag filter value")
|
|
89
85
|
next if value.nil?
|
|
90
86
|
|
|
91
87
|
normalized[LlmCostTracker::Tags::Key.validate!(key, error_class: LlmCostTracker::InvalidFilterError)] = value
|
|
92
88
|
end
|
|
93
89
|
end
|
|
94
90
|
|
|
95
|
-
def
|
|
96
|
-
|
|
91
|
+
def matchable?(values)
|
|
92
|
+
values.none? { |value| value.include?("\0") } ||
|
|
93
|
+
!LlmCostTracker::Ledger::Schema::Adapter.postgresql?(scope.connection)
|
|
94
|
+
end
|
|
97
95
|
|
|
98
|
-
|
|
96
|
+
def normalized_string(value, name)
|
|
97
|
+
LlmCostTracker::Dashboard::Params.scalar(value, name).strip.presence
|
|
99
98
|
end
|
|
100
99
|
end
|
|
101
100
|
end
|
|
@@ -19,11 +19,8 @@ module LlmCostTracker
|
|
|
19
19
|
)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
def self.integer_param(params, key, default:, min:, max:
|
|
23
|
-
|
|
24
|
-
value = [value, min].max
|
|
25
|
-
value = [value, max].min if max
|
|
26
|
-
value
|
|
22
|
+
def self.integer_param(params, key, default:, min:, max:)
|
|
23
|
+
Integer(params[key], 10).clamp(min, max)
|
|
27
24
|
rescue ArgumentError, TypeError
|
|
28
25
|
default
|
|
29
26
|
end
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
module Dashboard
|
|
5
5
|
module Params
|
|
6
|
+
QUERY_KEYS = %i[from to provider model tag tag_value stream usage_source cost_status sort dir page per].freeze
|
|
7
|
+
|
|
6
8
|
class << self
|
|
7
9
|
def to_hash(value)
|
|
8
10
|
return {} if value.nil?
|
|
@@ -17,6 +19,14 @@ module LlmCostTracker
|
|
|
17
19
|
{}
|
|
18
20
|
end
|
|
19
21
|
|
|
22
|
+
def scalar(value, name)
|
|
23
|
+
if value.is_a?(Hash) || value.is_a?(Array) || value.respond_to?(:to_unsafe_h)
|
|
24
|
+
raise InvalidFilterError, "#{name} must be a single value"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
value.to_s
|
|
28
|
+
end
|
|
29
|
+
|
|
20
30
|
def tag_query(value)
|
|
21
31
|
to_hash(value).each_with_object({}) do |(key, tag_value), tags|
|
|
22
32
|
key = key.to_s
|
|
@@ -63,9 +63,10 @@ module LlmCostTracker
|
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def build_rows(prices)
|
|
66
|
+
today = Time.now.utc.to_date.iso8601
|
|
66
67
|
rows = prices.map do |key, rates|
|
|
67
68
|
provider, model = split_key(key.to_s)
|
|
68
|
-
Row.new(provider: provider, model: model, rates: rates)
|
|
69
|
+
Row.new(provider: provider, model: model, rates: Pricing::Matcher.prices_on(rates, today))
|
|
69
70
|
end
|
|
70
71
|
rows.sort_by { |row| [row.provider || "~", row.model] }
|
|
71
72
|
end
|
|
@@ -59,7 +59,7 @@ module LlmCostTracker
|
|
|
59
59
|
.where(tracked_at: window)
|
|
60
60
|
.where.not(total_cost: nil)
|
|
61
61
|
.group(:provider, :model)
|
|
62
|
-
.group_by_period(:day)
|
|
62
|
+
.group_by_period(:day, time_zone: Time.zone)
|
|
63
63
|
.sum(:total_cost)
|
|
64
64
|
.each do |(provider, model, day), total_cost|
|
|
65
65
|
grouped[[provider, model]][Date.iso8601(day.to_s)] += total_cost.to_f
|
|
@@ -4,7 +4,6 @@ module LlmCostTracker
|
|
|
4
4
|
module Dashboard
|
|
5
5
|
class TagBreakdown
|
|
6
6
|
DEFAULT_LIMIT = 100
|
|
7
|
-
SORT_OPTIONS = %w[value calls cost avg_cost].freeze
|
|
8
7
|
DEFAULT_DIRECTIONS = { "value" => "asc", "calls" => "desc", "cost" => "desc", "avg_cost" => "desc" }.freeze
|
|
9
8
|
Row = Data.define(:value, :calls, :total_cost, :average_cost_per_call, :share_percent)
|
|
10
9
|
|
|
@@ -21,7 +20,7 @@ module LlmCostTracker
|
|
|
21
20
|
@key = LlmCostTracker::Tags::Key.validate!(key, error_class: LlmCostTracker::InvalidFilterError)
|
|
22
21
|
limit = limit.to_i
|
|
23
22
|
@limit = limit.positive? ? [limit, DEFAULT_LIMIT].min : DEFAULT_LIMIT
|
|
24
|
-
@sort =
|
|
23
|
+
@sort = DEFAULT_DIRECTIONS.key?(sort.to_s) ? sort.to_s : "cost"
|
|
25
24
|
@direction = Sort::DIRECTIONS.include?(direction.to_s) ? direction.to_s : DEFAULT_DIRECTIONS[@sort]
|
|
26
25
|
end
|
|
27
26
|
|
|
@@ -89,7 +88,7 @@ module LlmCostTracker
|
|
|
89
88
|
def summary_sql
|
|
90
89
|
<<~SQL.squish
|
|
91
90
|
SELECT COUNT(*) AS total_calls,
|
|
92
|
-
COUNT(
|
|
91
|
+
COUNT(CASE WHEN #{tag_present_predicate} THEN 1 END) AS tagged_calls,
|
|
93
92
|
COUNT(DISTINCT CASE WHEN #{tag_present_predicate} THEN #{tag_value_column} END) AS distinct_values
|
|
94
93
|
FROM (#{scope.to_sql}) AS sub
|
|
95
94
|
LEFT OUTER JOIN #{call_tag_table} t ON t.llm_cost_tracker_call_id = sub.id AND t.#{quote_column('key')} = #{quoted_key}
|
|
@@ -38,6 +38,7 @@ module LlmCostTracker
|
|
|
38
38
|
COUNT(DISTINCT t.#{value_column}) AS distinct_values
|
|
39
39
|
FROM (#{scope.to_sql}) AS sub
|
|
40
40
|
INNER JOIN #{tags_table} t ON t.llm_cost_tracker_call_id = sub.id
|
|
41
|
+
WHERE t.#{value_column} != ''
|
|
41
42
|
GROUP BY t.#{key_column}
|
|
42
43
|
ORDER BY calls_count DESC
|
|
43
44
|
LIMIT #{limit}
|
|
@@ -5,18 +5,16 @@ require "date"
|
|
|
5
5
|
module LlmCostTracker
|
|
6
6
|
module Dashboard
|
|
7
7
|
class TimeSeries
|
|
8
|
-
DEFAULT_DAYS = 30
|
|
9
|
-
|
|
10
8
|
class << self
|
|
11
|
-
def call(scope: LlmCostTracker::Call.all
|
|
9
|
+
def call(from:, to:, scope: LlmCostTracker::Call.all)
|
|
12
10
|
new(scope: scope, from: from, to: to).points
|
|
13
11
|
end
|
|
14
12
|
end
|
|
15
13
|
|
|
16
14
|
def initialize(scope:, from:, to:)
|
|
17
15
|
@scope = scope
|
|
16
|
+
@from = from.to_date
|
|
18
17
|
@to = to.to_date
|
|
19
|
-
@from = from ? from.to_date : (@to - (DEFAULT_DAYS - 1))
|
|
20
18
|
end
|
|
21
19
|
|
|
22
20
|
def points
|
|
@@ -35,7 +33,7 @@ module LlmCostTracker
|
|
|
35
33
|
def scoped_costs
|
|
36
34
|
scope
|
|
37
35
|
.where(tracked_at: from.beginning_of_day..to.end_of_day)
|
|
38
|
-
.group_by_period(:day)
|
|
36
|
+
.group_by_period(:day, time_zone: Time.zone)
|
|
39
37
|
.sum(:total_cost)
|
|
40
38
|
.transform_values(&:to_f)
|
|
41
39
|
end
|
|
@@ -4,7 +4,6 @@ module LlmCostTracker
|
|
|
4
4
|
module Dashboard
|
|
5
5
|
class TopModels
|
|
6
6
|
DEFAULT_LIMIT = 5
|
|
7
|
-
SORT_OPTIONS = %w[cost calls avg_cost latency tokens provider name].freeze
|
|
8
7
|
DEFAULT_SORT = "cost"
|
|
9
8
|
DEFAULT_DIRECTIONS = {
|
|
10
9
|
"provider" => "asc",
|
|
@@ -43,7 +42,7 @@ module LlmCostTracker
|
|
|
43
42
|
def initialize(scope:, limit:, sort: DEFAULT_SORT, direction: nil)
|
|
44
43
|
@scope = scope
|
|
45
44
|
@limit = limit
|
|
46
|
-
@sort =
|
|
45
|
+
@sort = DEFAULT_DIRECTIONS.key?(sort.to_s) ? sort.to_s : DEFAULT_SORT
|
|
47
46
|
@direction = Sort::DIRECTIONS.include?(direction.to_s) ? direction.to_s : DEFAULT_DIRECTIONS[@sort]
|
|
48
47
|
end
|
|
49
48
|
|
|
@@ -5,13 +5,10 @@
|
|
|
5
5
|
value = @call[token_key]
|
|
6
6
|
{ label: component.fetch(:label), value: value, formatted_value: number_with_delimiter(value), css_class: component.fetch(:css_class) }
|
|
7
7
|
end %>
|
|
8
|
-
<% cost_segments =
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
{ label: component.fetch(:label), value: value, formatted_value: optional_money(value), css_class: component.fetch(:css_class) }
|
|
13
|
-
end %>
|
|
14
|
-
<% end %>
|
|
8
|
+
<% cost_segments = priced_components.map do |component|
|
|
9
|
+
value = line_item_costs_by_component[component.fetch(:price_key)]
|
|
10
|
+
{ label: component.fetch(:label), value: value, formatted_value: optional_money(value), css_class: component.fetch(:css_class) }
|
|
11
|
+
end %>
|
|
15
12
|
<% service_line_items = @call.line_items.reject { |li| li.unit == "token" }.sort_by(&:position) %>
|
|
16
13
|
|
|
17
14
|
<p class="lct-breadcrumb-back"><%= link_to "← All calls", calls_path %></p>
|
|
@@ -84,22 +81,11 @@ end %>
|
|
|
84
81
|
<dd><%= content_tag(:code, @call.provider_response_id, class: "lct-code-id") %></dd>
|
|
85
82
|
</div>
|
|
86
83
|
<% end %>
|
|
87
|
-
<%
|
|
88
|
-
|
|
89
|
-
<dt>Project ID</dt>
|
|
90
|
-
<dd><%= content_tag(:code, LlmCostTracker::Dashboard::Masking.mask_value(:provider_project_id, @call.provider_project_id), class: "lct-code-id") %></dd>
|
|
91
|
-
</div>
|
|
92
|
-
<% end %>
|
|
93
|
-
<% if @call.provider_api_key_id.present? %>
|
|
94
|
-
<div class="lct-meta-strip-item">
|
|
95
|
-
<dt>API Key ID</dt>
|
|
96
|
-
<dd><%= content_tag(:code, LlmCostTracker::Dashboard::Masking.mask_value(:provider_api_key_id, @call.provider_api_key_id), class: "lct-code-id") %></dd>
|
|
97
|
-
</div>
|
|
98
|
-
<% end %>
|
|
99
|
-
<% if @call.provider_workspace_id.present? %>
|
|
84
|
+
<% { provider_project_id: "Project ID", provider_api_key_id: "API Key ID", provider_workspace_id: "Workspace ID" }.each do |attribute, label| %>
|
|
85
|
+
<% next if @call[attribute].blank? %>
|
|
100
86
|
<div class="lct-meta-strip-item">
|
|
101
|
-
<dt
|
|
102
|
-
<dd><%= content_tag(:code, LlmCostTracker::Dashboard::Masking.mask_value(
|
|
87
|
+
<dt><%= label %></dt>
|
|
88
|
+
<dd><%= content_tag(:code, LlmCostTracker::Dashboard::Masking.mask_value(attribute, @call[attribute]), class: "lct-code-id") %></dd>
|
|
103
89
|
</div>
|
|
104
90
|
<% end %>
|
|
105
91
|
</dl>
|
|
@@ -271,7 +271,7 @@
|
|
|
271
271
|
<h2 class="lct-panel-title">Incomplete pricing by model</h2>
|
|
272
272
|
<span class="lct-panel-meta"><%= link_to "View calls →", calls_path(current_query(cost_status: "incomplete", sort: nil)) %></span>
|
|
273
273
|
</div>
|
|
274
|
-
<p class="lct-panel-intro">These models have line items without configured rates, so totals undercount (a full row is missing all rates; a partial row has some line items priced and others not). After the next price refresh or a <code>pricing_overrides</code> update, run <code>bin/rails llm_cost_tracker:backfill_unknown_pricing</code> to recompute these calls.</p>
|
|
274
|
+
<p class="lct-panel-intro">These models have line items without configured rates, so totals undercount (a full row is missing all rates; a partial row has some line items priced and others not). After the next price refresh or a <code>pricing_overrides</code> update, run <code>bin/rails llm_cost_tracker:backfill_unknown_pricing</code> to recompute these calls. A partial call is left as recorded when a rate it already used has changed since.</p>
|
|
275
275
|
|
|
276
276
|
<div class="lct-table-wrap">
|
|
277
277
|
<table class="lct-tbl">
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
<% variant_class = local_assigns[:variant] == "budget" ? " lct-budget-fill" : "" %>
|
|
2
|
-
|
|
3
1
|
<div class="lct-bar-track" aria-hidden="true">
|
|
4
|
-
<div data-lct-style="<%= inline_style("width: #{bar_width(value, max)}") %>" class="lct-bar-fill
|
|
2
|
+
<div data-lct-style="<%= inline_style("width: #{bar_width(value, max)}") %>" class="lct-bar-fill"></div>
|
|
5
3
|
</div>
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
<% extra_hidden = local_assigns.fetch(:extra_hidden, {}) %>
|
|
2
|
-
<% extra_except = local_assigns.fetch(:extra_except, []) %>
|
|
3
1
|
<details class="lct-filter-pop" name="lct-filter">
|
|
4
2
|
<summary class="lct-filter-pill">
|
|
5
3
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
|
|
@@ -8,8 +6,7 @@
|
|
|
8
6
|
<svg class="lct-chev" width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 1l4 4 4-4"/></svg>
|
|
9
7
|
</summary>
|
|
10
8
|
<%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
|
|
11
|
-
|
|
12
|
-
<%= hidden_query_fields(current_query.except(:from, :to, :page, :per, *extra_except)) %>
|
|
9
|
+
<%= hidden_query_fields(current_query.except(:from, :to, :page, :per)) %>
|
|
13
10
|
<div class="lct-filter-pop-field"><label for="lct-filter-from">From</label><input type="date" name="from" id="lct-filter-from" value="<%= @from_date.iso8601 %>"></div>
|
|
14
11
|
<div class="lct-filter-pop-field"><label for="lct-filter-to">To</label><input type="date" name="to" id="lct-filter-to" value="<%= @to_date.iso8601 %>"></div>
|
|
15
12
|
<button type="submit" class="lct-button lct-button-primary">Apply</button>
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
<% extra_hidden = local_assigns.fetch(:extra_hidden, {}) %>
|
|
2
|
-
<% extra_except = local_assigns.fetch(:extra_except, []) %>
|
|
3
1
|
<% active = params[:model].presence %>
|
|
4
2
|
<details class="lct-filter-pop" name="lct-filter">
|
|
5
3
|
<summary class="lct-filter-pill <%= 'lct-active' if active %>">
|
|
@@ -9,8 +7,7 @@
|
|
|
9
7
|
<svg class="lct-chev" width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 1l4 4 4-4"/></svg>
|
|
10
8
|
</summary>
|
|
11
9
|
<%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
|
|
12
|
-
|
|
13
|
-
<%= hidden_query_fields(current_query.except(:model, :page, :per, *extra_except)) %>
|
|
10
|
+
<%= hidden_query_fields(current_query.except(:model, :page, :per)) %>
|
|
14
11
|
<div class="lct-filter-pop-field">
|
|
15
12
|
<label for="lct-filter-model">Model</label>
|
|
16
13
|
<%= select_tag :model, options_for_select(filter_options_for(:model), active), include_blank: "All models", id: "lct-filter-model" %>
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
<% extra_hidden = local_assigns.fetch(:extra_hidden, {}) %>
|
|
2
|
-
<% extra_except = local_assigns.fetch(:extra_except, []) %>
|
|
3
1
|
<% active = params[:provider].presence %>
|
|
4
2
|
<details class="lct-filter-pop" name="lct-filter">
|
|
5
3
|
<summary class="lct-filter-pill <%= 'lct-active' if active %>">
|
|
@@ -9,8 +7,7 @@
|
|
|
9
7
|
<svg class="lct-chev" width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 1l4 4 4-4"/></svg>
|
|
10
8
|
</summary>
|
|
11
9
|
<%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
|
|
12
|
-
|
|
13
|
-
<%= hidden_query_fields(current_query.except(:provider, :page, :per, *extra_except)) %>
|
|
10
|
+
<%= hidden_query_fields(current_query.except(:provider, :page, :per)) %>
|
|
14
11
|
<div class="lct-filter-pop-field">
|
|
15
12
|
<label for="lct-filter-provider">Provider</label>
|
|
16
13
|
<%= select_tag :provider, options_for_select(filter_options_for(:provider), active), include_blank: "All providers", id: "lct-filter-provider" %>
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
<% extra_hidden = local_assigns.fetch(:extra_hidden, {}) %>
|
|
2
|
-
<% extra_except = local_assigns.fetch(:extra_except, []) %>
|
|
3
1
|
<% active = params[:stream].presence %>
|
|
4
2
|
<% display_value = case active when "yes" then "Streaming" when "no" then "Non-streaming" else "All" end %>
|
|
5
3
|
<details class="lct-filter-pop" name="lct-filter">
|
|
@@ -10,8 +8,7 @@
|
|
|
10
8
|
<svg class="lct-chev" width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 1l4 4 4-4"/></svg>
|
|
11
9
|
</summary>
|
|
12
10
|
<%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
|
|
13
|
-
|
|
14
|
-
<%= hidden_query_fields(current_query.except(:stream, :page, :per, *extra_except)) %>
|
|
11
|
+
<%= hidden_query_fields(current_query.except(:stream, :page, :per)) %>
|
|
15
12
|
<div class="lct-filter-pop-field">
|
|
16
13
|
<label for="lct-filter-stream">Stream</label>
|
|
17
14
|
<%= select_tag :stream, options_for_select(LlmCostTracker::Dashboard::Filter::STREAM_FILTER_OPTIONS, active), include_blank: "All calls", id: "lct-filter-stream" %>
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
<h2 class="lct-page-title">Tag: <code class="lct-code-id"><%= params[:key] %></code> = <code class="lct-code-id"><%= @value %></code></h2>
|
|
6
6
|
|
|
7
7
|
<div class="lct-filter-row">
|
|
8
|
-
<%= render "llm_cost_tracker/shared/filter_pill_date", path: form_url
|
|
9
|
-
<%= render "llm_cost_tracker/shared/filter_pill_provider", path: form_url
|
|
10
|
-
<%= render "llm_cost_tracker/shared/filter_pill_model", path: form_url
|
|
8
|
+
<%= render "llm_cost_tracker/shared/filter_pill_date", path: form_url %>
|
|
9
|
+
<%= render "llm_cost_tracker/shared/filter_pill_provider", path: form_url %>
|
|
10
|
+
<%= render "llm_cost_tracker/shared/filter_pill_model", path: form_url %>
|
|
11
11
|
|
|
12
12
|
<% if params[:provider].present? || params[:model].present? %>
|
|
13
13
|
<%= link_to "× Clear filters", tag_path(params[:key], current_query(provider: nil, model: nil, page: nil)), class: "lct-filter-clear" %>
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
</div>
|
|
36
36
|
<div class="lct-stat">
|
|
37
37
|
<div class="lct-stat-head"><p class="lct-stat-label">Avg cost / call</p></div>
|
|
38
|
-
<p class="lct-stat-value"><%= money(@
|
|
38
|
+
<p class="lct-stat-value"><%= money(@value_total_cost / @value_calls) %></p>
|
|
39
39
|
</div>
|
|
40
40
|
</div>
|
|
41
41
|
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
<h2 class="lct-panel-title">Spend over time</h2>
|
|
45
45
|
<span class="lct-panel-meta"><%= link_to "View calls →", calls_path(calls_query_for_tag(key: @key, value: @value)) %></span>
|
|
46
46
|
</div>
|
|
47
|
-
<%= render "llm_cost_tracker/shared/spend_chart", series: @value_points
|
|
47
|
+
<%= render "llm_cost_tracker/shared/spend_chart", series: @value_points %>
|
|
48
48
|
</section>
|
|
49
49
|
<% end %>
|
|
50
50
|
|
|
@@ -107,6 +107,7 @@
|
|
|
107
107
|
</tr>
|
|
108
108
|
</thead>
|
|
109
109
|
<tbody>
|
|
110
|
+
<% drilldown_allowed = tag_drilldown_allowed?(params[:key]) %>
|
|
110
111
|
<% @breakdown.rows.each do |row| %>
|
|
111
112
|
<tr>
|
|
112
113
|
<td><code class="lct-code-id"><%= row.value %></code></td>
|
|
@@ -117,6 +118,8 @@
|
|
|
117
118
|
<td class="lct-num">
|
|
118
119
|
<% if row.value == "(untagged)" %>
|
|
119
120
|
<span class="lct-num-muted">n/a</span>
|
|
121
|
+
<% elsif !drilldown_allowed %>
|
|
122
|
+
<span class="lct-num-muted">tag filter limit</span>
|
|
120
123
|
<% else %>
|
|
121
124
|
<%= link_to "Trend", tag_path(params[:key], current_query.merge(tag_value: row.value)), class: "lct-page-link" %>
|
|
122
125
|
<%= link_to "Calls", calls_path(calls_query_for_tag(key: params[:key], value: row.value)), class: "lct-page-link" %>
|
data/config/routes.rb
CHANGED
|
@@ -4,7 +4,12 @@ LlmCostTracker::Engine.routes.draw do
|
|
|
4
4
|
root "dashboard#index"
|
|
5
5
|
resources :calls, only: %i[index show], constraints: { id: /\d+/ }, defaults: { format: :html }
|
|
6
6
|
resources :models, only: :index
|
|
7
|
-
resources :tags,
|
|
7
|
+
resources :tags,
|
|
8
|
+
only: %i[index show],
|
|
9
|
+
param: :key,
|
|
10
|
+
format: false,
|
|
11
|
+
constraints: { key: %r{[^/]+} },
|
|
12
|
+
defaults: { format: :html }
|
|
8
13
|
get "data_quality", to: "data_quality#index", as: :data_quality
|
|
9
14
|
get "pricing", to: "pricing#index", as: :pricing
|
|
10
15
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../ledger/isolation"
|
|
3
4
|
require_relative "../ledger/schema/adapter"
|
|
4
5
|
require_relative "../ledger/tags/encoding"
|
|
5
6
|
|
|
@@ -29,7 +30,7 @@ module LlmCostTracker
|
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
def blocking?
|
|
32
|
-
|
|
33
|
+
configured.each_value.any? { |entry| behavior_for(entry) == :block_requests } && active?
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
def rules_for(tags, blocking_only: false)
|
|
@@ -37,13 +38,13 @@ module LlmCostTracker
|
|
|
37
38
|
|
|
38
39
|
normalized = (tags || {}).to_h.transform_keys(&:to_s)
|
|
39
40
|
configured.filter_map do |key, entry|
|
|
40
|
-
|
|
41
|
-
next if
|
|
41
|
+
value = Ledger::Tags::Encoding.encode(normalized[key])
|
|
42
|
+
next if value.empty?
|
|
42
43
|
next if blocking_only && behavior_for(entry) != :block_requests
|
|
43
44
|
|
|
44
45
|
Rule.new(
|
|
45
46
|
key: key,
|
|
46
|
-
value:
|
|
47
|
+
value: value,
|
|
47
48
|
windows: entry[:windows],
|
|
48
49
|
behavior: behavior_for(entry),
|
|
49
50
|
on_exceeded: on_exceeded_for(entry)
|
|
@@ -52,20 +53,28 @@ module LlmCostTracker
|
|
|
52
53
|
end
|
|
53
54
|
|
|
54
55
|
def rules_for_events(events)
|
|
55
|
-
events.each_with_object({}) do |event, grouped|
|
|
56
|
+
events.select(&:total_cost).each_with_object({}) do |event, grouped|
|
|
56
57
|
rules_for(event.tags).each { |rule| (grouped[rule] ||= []) << event }
|
|
57
58
|
end
|
|
58
59
|
end
|
|
59
60
|
|
|
60
61
|
def spend(key, value, window, time:)
|
|
61
|
-
spend_by_value(key, [value], window, window_start(window, time)).fetch(value, 0).to_d
|
|
62
|
+
spend_by_value(key, [value], window, window_start(window, time)).fetch(value, [0]).first.to_d
|
|
62
63
|
end
|
|
63
64
|
|
|
64
|
-
def spend_by_value(key, values, window, bucket)
|
|
65
|
+
def spend_by_value(key, values, window, bucket, upto = nil)
|
|
65
66
|
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
range = window_range(window, bucket)
|
|
68
|
+
upto_sum = LlmCostTracker::CallTag.sanitize_sql_array(
|
|
69
|
+
["SUM(CASE WHEN #{TIME_COLUMN} <= ? THEN #{COST_COLUMN} ELSE 0 END)", upto || range.end]
|
|
70
|
+
)
|
|
71
|
+
totals = Ledger::Isolation.guard(LlmCostTracker::CallTag) do
|
|
72
|
+
LlmCostTracker::CallTag
|
|
73
|
+
.where(key: key, value: values, TIME_COLUMN => range)
|
|
74
|
+
.group(:value)
|
|
75
|
+
.pluck(:value, Arel.sql("SUM(#{COST_COLUMN})"), Arel.sql(upto_sum))
|
|
76
|
+
.to_h { |value, *sums| [value, sums.map(&:to_d)] }
|
|
77
|
+
end
|
|
69
78
|
warn_slow_read(key, window, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at)
|
|
70
79
|
totals
|
|
71
80
|
end
|
|
@@ -88,7 +97,8 @@ module LlmCostTracker
|
|
|
88
97
|
end
|
|
89
98
|
|
|
90
99
|
def columns?
|
|
91
|
-
|
|
100
|
+
LlmCostTracker::CallTag.table_exists? &&
|
|
101
|
+
(LlmCostTracker::CallTag.column_names & [COST_COLUMN, TIME_COLUMN]).size == 2
|
|
92
102
|
end
|
|
93
103
|
|
|
94
104
|
private
|