llm_cost_tracker 0.13.0 → 0.14.1
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 +99 -16
- data/README.md +17 -29
- data/app/controllers/llm_cost_tracker/application_controller.rb +19 -5
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -0
- data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
- data/app/controllers/llm_cost_tracker/tags_controller.rb +5 -4
- data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +16 -0
- data/app/models/llm_cost_tracker/call.rb +10 -4
- data/app/models/llm_cost_tracker/call_rollup.rb +19 -3
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +13 -0
- data/app/services/llm_cost_tracker/dashboard/filter.rb +24 -21
- 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/params.rb +10 -0
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +3 -3
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +5 -3
- data/app/views/llm_cost_tracker/calls/show.html.erb +6 -8
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +11 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -3
- data/app/views/llm_cost_tracker/tags/show.html.erb +3 -0
- data/config/routes.rb +6 -1
- data/lib/llm_cost_tracker/budget/per_tag.rb +163 -0
- data/lib/llm_cost_tracker/budget.rb +120 -32
- data/lib/llm_cost_tracker/capture/event_window.rb +99 -0
- data/lib/llm_cost_tracker/capture/sse.rb +108 -32
- data/lib/llm_cost_tracker/capture/stream_collector.rb +17 -56
- data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +5 -35
- data/lib/llm_cost_tracker/charges/cost_status.rb +4 -3
- data/lib/llm_cost_tracker/configuration/budgets.rb +93 -0
- data/lib/llm_cost_tracker/configuration/capture.rb +42 -0
- data/lib/llm_cost_tracker/configuration/ingestion.rb +20 -0
- data/lib/llm_cost_tracker/configuration/mutability.rb +33 -0
- data/lib/llm_cost_tracker/configuration/pricing.rb +36 -0
- data/lib/llm_cost_tracker/configuration/section.rb +59 -0
- data/lib/llm_cost_tracker/configuration/tags.rb +52 -0
- data/lib/llm_cost_tracker/configuration.rb +69 -125
- data/lib/llm_cost_tracker/deprecator.rb +9 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +17 -8
- data/lib/llm_cost_tracker/doctor/price_check.rb +2 -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 +25 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +2 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +2 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +0 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +8 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +50 -30
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +40 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +41 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +30 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +30 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +38 -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 +7 -1
- data/lib/llm_cost_tracker/integrations/base.rb +17 -1
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +16 -13
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +53 -18
- data/lib/llm_cost_tracker/ledger/isolation.rb +30 -0
- data/lib/llm_cost_tracker/ledger/period/totals.rb +3 -2
- data/lib/llm_cost_tracker/ledger/rollups.rb +45 -7
- data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
- data/lib/llm_cost_tracker/ledger/store.rb +21 -13
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +15 -5
- data/lib/llm_cost_tracker/ledger.rb +1 -0
- data/lib/llm_cost_tracker/logging.rb +5 -5
- data/lib/llm_cost_tracker/middleware/faraday.rb +33 -44
- data/lib/llm_cost_tracker/parsers.rb +5 -1
- data/lib/llm_cost_tracker/prices.json +2089 -372
- data/lib/llm_cost_tracker/pricing/backfill.rb +11 -1
- data/lib/llm_cost_tracker/pricing/calculation.rb +4 -4
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +26 -15
- data/lib/llm_cost_tracker/pricing/matcher.rb +7 -0
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -2
- data/lib/llm_cost_tracker/pricing/registry.rb +17 -6
- data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
- data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +31 -14
- 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/azure/parser.rb +19 -0
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +4 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +0 -7
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +10 -3
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +14 -8
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +6 -2
- data/lib/llm_cost_tracker/railtie.rb +3 -7
- data/lib/llm_cost_tracker/redaction.rb +32 -0
- 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 +14 -40
- data/lib/llm_cost_tracker/tracker.rb +16 -26
- data/lib/llm_cost_tracker/usage/catalog.rb +1 -2
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +10 -3
- data/lib/tasks/llm_cost_tracker.rake +29 -14
- metadata +37 -12
|
@@ -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,13 +30,13 @@ 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
42
|
from_date = Dashboard::DateRange.parse(params, :from)
|
|
@@ -50,21 +53,25 @@ module LlmCostTracker
|
|
|
50
53
|
end
|
|
51
54
|
|
|
52
55
|
def apply_exact_filter(relation, key)
|
|
53
|
-
value = normalized_string(params[key])
|
|
56
|
+
value = normalized_string(params[key], key)
|
|
54
57
|
return relation if value.nil?
|
|
55
58
|
|
|
56
|
-
relation.where(key => value)
|
|
59
|
+
matchable?([value]) ? relation.where(key => value) : relation.where(MATCH_NOTHING)
|
|
57
60
|
end
|
|
58
61
|
|
|
59
62
|
def apply_tag_filters(relation)
|
|
60
|
-
tags = tag_params
|
|
63
|
+
tags = tag_params.merge(extra_tags)
|
|
61
64
|
return relation if tags.empty?
|
|
65
|
+
if tags.size > MAX_TAG_FILTERS
|
|
66
|
+
raise LlmCostTracker::InvalidFilterError,
|
|
67
|
+
"at most #{MAX_TAG_FILTERS} tag filters are allowed, got #{tags.size}"
|
|
68
|
+
end
|
|
62
69
|
|
|
63
|
-
relation.by_tags(tags)
|
|
70
|
+
matchable?(tags.values) ? relation.by_tags(tags) : relation.where(MATCH_NOTHING)
|
|
64
71
|
end
|
|
65
72
|
|
|
66
73
|
def apply_stream_filter(relation)
|
|
67
|
-
value = normalized_string(params[:stream])
|
|
74
|
+
value = normalized_string(params[:stream], :stream)
|
|
68
75
|
return relation if value.nil?
|
|
69
76
|
|
|
70
77
|
case value.downcase
|
|
@@ -74,28 +81,24 @@ module LlmCostTracker
|
|
|
74
81
|
end
|
|
75
82
|
end
|
|
76
83
|
|
|
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
84
|
def tag_params
|
|
85
85
|
tags = LlmCostTracker::Dashboard::Params.to_hash(params[:tag])
|
|
86
86
|
|
|
87
87
|
tags.each_with_object({}) do |(key, value), normalized|
|
|
88
|
-
value = normalized_string(value)
|
|
88
|
+
value = normalized_string(value, "tag filter value")
|
|
89
89
|
next if value.nil?
|
|
90
90
|
|
|
91
91
|
normalized[LlmCostTracker::Tags::Key.validate!(key, error_class: LlmCostTracker::InvalidFilterError)] = value
|
|
92
92
|
end
|
|
93
93
|
end
|
|
94
94
|
|
|
95
|
-
def
|
|
96
|
-
|
|
95
|
+
def matchable?(values)
|
|
96
|
+
values.none? { |value| value.include?("\0") } ||
|
|
97
|
+
!LlmCostTracker::Ledger::Schema::Adapter.postgresql?(scope.connection)
|
|
98
|
+
end
|
|
97
99
|
|
|
98
|
-
|
|
100
|
+
def normalized_string(value, name)
|
|
101
|
+
LlmCostTracker::Dashboard::Params.scalar(value, name).strip.presence
|
|
99
102
|
end
|
|
100
103
|
end
|
|
101
104
|
end
|
|
@@ -6,6 +6,7 @@ module LlmCostTracker
|
|
|
6
6
|
DEFAULT_PER = 50
|
|
7
7
|
MAX_PER = 200
|
|
8
8
|
MIN_PAGE = 1
|
|
9
|
+
MAX_PAGE = 100_000
|
|
9
10
|
MIN_PER = 1
|
|
10
11
|
|
|
11
12
|
attr_reader :page, :per
|
|
@@ -13,7 +14,7 @@ module LlmCostTracker
|
|
|
13
14
|
def self.call(params)
|
|
14
15
|
params = Params.to_hash(params).symbolize_keys
|
|
15
16
|
new(
|
|
16
|
-
page: integer_param(params, :page, default: MIN_PAGE, min: MIN_PAGE),
|
|
17
|
+
page: integer_param(params, :page, default: MIN_PAGE, min: MIN_PAGE, max: MAX_PAGE),
|
|
17
18
|
per: integer_param(params, :per, default: DEFAULT_PER, min: MIN_PER, max: MAX_PER)
|
|
18
19
|
)
|
|
19
20
|
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
|
|
@@ -46,8 +46,8 @@ module LlmCostTracker
|
|
|
46
46
|
|
|
47
47
|
def subtitle_for(key)
|
|
48
48
|
case key
|
|
49
|
-
when :overrides then "config.
|
|
50
|
-
when :file then LlmCostTracker.configuration.
|
|
49
|
+
when :overrides then "config.pricing.overrides"
|
|
50
|
+
when :file then LlmCostTracker.configuration.pricing.file.to_s
|
|
51
51
|
when :bundled then "ships with the gem"
|
|
52
52
|
end
|
|
53
53
|
end
|
|
@@ -55,7 +55,7 @@ module LlmCostTracker
|
|
|
55
55
|
def updated_at_for(key)
|
|
56
56
|
case key
|
|
57
57
|
when :file
|
|
58
|
-
path = LlmCostTracker.configuration.
|
|
58
|
+
path = LlmCostTracker.configuration.pricing.file
|
|
59
59
|
Pricing::Registry.file_metadata(path)["updated_at"] || Pricing::Registry.prices_file_mtime_iso
|
|
60
60
|
when :bundled
|
|
61
61
|
Pricing::Registry.metadata["updated_at"]
|
|
@@ -7,13 +7,15 @@ module LlmCostTracker
|
|
|
7
7
|
|
|
8
8
|
class << self
|
|
9
9
|
def current
|
|
10
|
-
return
|
|
10
|
+
return if @healthy
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
state = compute
|
|
13
|
+
@healthy = state.nil?
|
|
14
|
+
state
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def reset!
|
|
16
|
-
|
|
18
|
+
@healthy = false
|
|
17
19
|
end
|
|
18
20
|
|
|
19
21
|
private
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<% line_item_costs_by_component = call_line_item_costs_by_component(@call) %>
|
|
3
3
|
<% token_segments = priced_components.map do |component|
|
|
4
4
|
token_key = component.fetch(:token_key)
|
|
5
|
-
value = @call
|
|
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
8
|
<% cost_segments = [] %>
|
|
@@ -68,13 +68,11 @@ end %>
|
|
|
68
68
|
<dt>Batch</dt>
|
|
69
69
|
<dd><%= @call.batch? ? "yes" : "no" %></dd>
|
|
70
70
|
</div>
|
|
71
|
-
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
<% end %>
|
|
77
|
-
<% if @call.has_attribute?(:usage_source) && @call.usage_source.present? %>
|
|
71
|
+
<div class="lct-meta-strip-item">
|
|
72
|
+
<dt>Stream</dt>
|
|
73
|
+
<dd><%= @call.stream? ? "yes" : "no" %></dd>
|
|
74
|
+
</div>
|
|
75
|
+
<% if @call.usage_source.present? %>
|
|
78
76
|
<div class="lct-meta-strip-item">
|
|
79
77
|
<dt>Usage source</dt>
|
|
80
78
|
<dd><code class="lct-code-id"><%= @call.usage_source %></code></dd>
|
|
@@ -21,6 +21,17 @@
|
|
|
21
21
|
</div>
|
|
22
22
|
<% end %>
|
|
23
23
|
|
|
24
|
+
<% if @unseen_budget_tags %>
|
|
25
|
+
<h3 class="lct-stat-section-label">Budgets</h3>
|
|
26
|
+
<div class="lct-stat-grid">
|
|
27
|
+
<div class="lct-stat lct-stat-warn">
|
|
28
|
+
<div class="lct-stat-head"><p class="lct-stat-label">Budgeted tags never recorded</p></div>
|
|
29
|
+
<p class="lct-stat-value"><%= @unseen_budget_tags.keys.join(", ") %></p>
|
|
30
|
+
<p class="lct-stat-foot">No call carries <%= @unseen_budget_tags.keys.one? ? "this tag" : "these tags" %> yet, so <%= @unseen_budget_tags.keys.one? ? "its budget never fires" : "their budgets never fire" %>. Expected on a young ledger; otherwise check the name against the tags you pass.</p>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
<% end %>
|
|
34
|
+
|
|
24
35
|
<% if @summary.total.zero? %>
|
|
25
36
|
<section class="lct-panel lct-empty">
|
|
26
37
|
<h2 class="lct-state-title">No data yet</h2>
|
|
@@ -9,9 +9,7 @@
|
|
|
9
9
|
</summary>
|
|
10
10
|
<%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
|
|
11
11
|
<% extra_hidden.each do |k, v| %><%= hidden_field_tag k, v %><% end %>
|
|
12
|
-
|
|
13
|
-
<% Array(value).each do |v| %><%= hidden_field_tag(value.is_a?(Array) ? "#{key}[]" : key.to_s, v) %><% end %>
|
|
14
|
-
<% end %>
|
|
12
|
+
<%= hidden_query_fields(current_query.except(:from, :to, :page, :per, *extra_except)) %>
|
|
15
13
|
<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>
|
|
16
14
|
<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>
|
|
17
15
|
<button type="submit" class="lct-button lct-button-primary">Apply</button>
|
|
@@ -10,9 +10,7 @@
|
|
|
10
10
|
</summary>
|
|
11
11
|
<%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
|
|
12
12
|
<% extra_hidden.each do |k, v| %><%= hidden_field_tag k, v %><% end %>
|
|
13
|
-
|
|
14
|
-
<% Array(value).each do |v| %><%= hidden_field_tag(value.is_a?(Array) ? "#{key}[]" : key.to_s, v) %><% end %>
|
|
15
|
-
<% end %>
|
|
13
|
+
<%= hidden_query_fields(current_query.except(:model, :page, :per, *extra_except)) %>
|
|
16
14
|
<div class="lct-filter-pop-field">
|
|
17
15
|
<label for="lct-filter-model">Model</label>
|
|
18
16
|
<%= select_tag :model, options_for_select(filter_options_for(:model), active), include_blank: "All models", id: "lct-filter-model" %>
|
|
@@ -10,9 +10,7 @@
|
|
|
10
10
|
</summary>
|
|
11
11
|
<%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
|
|
12
12
|
<% extra_hidden.each do |k, v| %><%= hidden_field_tag k, v %><% end %>
|
|
13
|
-
|
|
14
|
-
<% Array(value).each do |v| %><%= hidden_field_tag(value.is_a?(Array) ? "#{key}[]" : key.to_s, v) %><% end %>
|
|
15
|
-
<% end %>
|
|
13
|
+
<%= hidden_query_fields(current_query.except(:provider, :page, :per, *extra_except)) %>
|
|
16
14
|
<div class="lct-filter-pop-field">
|
|
17
15
|
<label for="lct-filter-provider">Provider</label>
|
|
18
16
|
<%= select_tag :provider, options_for_select(filter_options_for(:provider), active), include_blank: "All providers", id: "lct-filter-provider" %>
|
|
@@ -11,9 +11,7 @@
|
|
|
11
11
|
</summary>
|
|
12
12
|
<%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
|
|
13
13
|
<% extra_hidden.each do |k, v| %><%= hidden_field_tag k, v %><% end %>
|
|
14
|
-
|
|
15
|
-
<% Array(value).each do |v| %><%= hidden_field_tag(value.is_a?(Array) ? "#{key}[]" : key.to_s, v) %><% end %>
|
|
16
|
-
<% end %>
|
|
14
|
+
<%= hidden_query_fields(current_query.except(:stream, :page, :per, *extra_except)) %>
|
|
17
15
|
<div class="lct-filter-pop-field">
|
|
18
16
|
<label for="lct-filter-stream">Stream</label>
|
|
19
17
|
<%= select_tag :stream, options_for_select(LlmCostTracker::Dashboard::Filter::STREAM_FILTER_OPTIONS, active), include_blank: "All calls", id: "lct-filter-stream" %>
|
|
@@ -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
|
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../ledger/isolation"
|
|
4
|
+
require_relative "../ledger/schema/adapter"
|
|
5
|
+
require_relative "../ledger/tags/encoding"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
module Budget
|
|
9
|
+
module PerTag
|
|
10
|
+
COST_COLUMN = "total_cost"
|
|
11
|
+
TIME_COLUMN = "tracked_at"
|
|
12
|
+
WINDOW_STARTS = { daily: :beginning_of_day, weekly: :beginning_of_week, monthly: :beginning_of_month }.freeze
|
|
13
|
+
WINDOW_NEXTS = { daily: :next_day, weekly: :next_week, monthly: :next_month }.freeze
|
|
14
|
+
SLOW_READ_SECONDS = 0.1
|
|
15
|
+
DEFAULT_BACKFILL_BATCH = 5_000
|
|
16
|
+
|
|
17
|
+
Rule = Data.define(:key, :value, :windows, :behavior, :on_exceeded)
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def configured
|
|
21
|
+
LlmCostTracker.configuration.budgets.per_tag
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def active?
|
|
25
|
+
return false if configured.empty?
|
|
26
|
+
return true if columns?
|
|
27
|
+
|
|
28
|
+
warn_missing_columns
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def blocking?
|
|
33
|
+
active? && configured.each_value.any? { |entry| behavior_for(entry) == :block_requests }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def rules_for(tags, blocking_only: false)
|
|
37
|
+
return [] unless active?
|
|
38
|
+
|
|
39
|
+
normalized = (tags || {}).to_h.transform_keys(&:to_s)
|
|
40
|
+
configured.filter_map do |key, entry|
|
|
41
|
+
raw = normalized[key]
|
|
42
|
+
next if raw.nil?
|
|
43
|
+
next if blocking_only && behavior_for(entry) != :block_requests
|
|
44
|
+
|
|
45
|
+
Rule.new(
|
|
46
|
+
key: key,
|
|
47
|
+
value: Ledger::Tags::Encoding.encode(raw),
|
|
48
|
+
windows: entry[:windows],
|
|
49
|
+
behavior: behavior_for(entry),
|
|
50
|
+
on_exceeded: on_exceeded_for(entry)
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def rules_for_events(events)
|
|
56
|
+
events.each_with_object({}) do |event, grouped|
|
|
57
|
+
rules_for(event.tags).each { |rule| (grouped[rule] ||= []) << event }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def spend(key, value, window, time:)
|
|
62
|
+
spend_by_value(key, [value], window, window_start(window, time)).fetch(value, 0).to_d
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def spend_by_value(key, values, window, bucket)
|
|
66
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
67
|
+
totals = Ledger::Isolation.guard(LlmCostTracker::CallTag) do
|
|
68
|
+
LlmCostTracker::CallTag
|
|
69
|
+
.where(key: key, value: values, TIME_COLUMN => window_range(window, bucket))
|
|
70
|
+
.group(:value).sum(COST_COLUMN)
|
|
71
|
+
end
|
|
72
|
+
warn_slow_read(key, window, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at)
|
|
73
|
+
totals
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def window_start(window, time)
|
|
77
|
+
time.to_time.utc.public_send(WINDOW_STARTS.fetch(window))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def backfill(batch_size: DEFAULT_BACKFILL_BATCH)
|
|
81
|
+
return 0 unless columns?
|
|
82
|
+
|
|
83
|
+
filled = 0
|
|
84
|
+
loop do
|
|
85
|
+
copied = copy_next_batch(batch_size)
|
|
86
|
+
break if copied.zero?
|
|
87
|
+
|
|
88
|
+
filled += copied
|
|
89
|
+
end
|
|
90
|
+
filled
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def columns?
|
|
94
|
+
LlmCostTracker::CallTag.table_exists? &&
|
|
95
|
+
(LlmCostTracker::CallTag.column_names & [COST_COLUMN, TIME_COLUMN]).size == 2
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def window_range(window, bucket)
|
|
101
|
+
bucket...bucket.public_send(WINDOW_NEXTS.fetch(window))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def copy_next_batch(batch_size)
|
|
105
|
+
ids = LlmCostTracker::CallTag.where(TIME_COLUMN => nil).limit(batch_size).pluck(:id)
|
|
106
|
+
return 0 if ids.empty?
|
|
107
|
+
|
|
108
|
+
LlmCostTracker::CallTag.connection.update(copy_sql(ids))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def copy_sql(ids)
|
|
112
|
+
tags = LlmCostTracker::CallTag.quoted_table_name
|
|
113
|
+
calls = LlmCostTracker::Call.quoted_table_name
|
|
114
|
+
list = ids.join(",")
|
|
115
|
+
return <<~SQL.squish unless Ledger::Schema::Adapter.postgresql?(LlmCostTracker::CallTag.connection)
|
|
116
|
+
UPDATE #{tags} t JOIN #{calls} c ON c.id = t.llm_cost_tracker_call_id
|
|
117
|
+
SET t.#{COST_COLUMN} = c.#{COST_COLUMN}, t.#{TIME_COLUMN} = c.#{TIME_COLUMN}
|
|
118
|
+
WHERE t.id IN (#{list})
|
|
119
|
+
SQL
|
|
120
|
+
|
|
121
|
+
<<~SQL.squish
|
|
122
|
+
UPDATE #{tags} AS t
|
|
123
|
+
SET #{COST_COLUMN} = c.#{COST_COLUMN}, #{TIME_COLUMN} = c.#{TIME_COLUMN}
|
|
124
|
+
FROM #{calls} AS c
|
|
125
|
+
WHERE c.id = t.llm_cost_tracker_call_id AND t.id IN (#{list})
|
|
126
|
+
SQL
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def behavior_for(entry)
|
|
130
|
+
entry[:behavior] || LlmCostTracker.configuration.budgets.exceeded_behavior
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def on_exceeded_for(entry)
|
|
134
|
+
entry[:on_exceeded] || LlmCostTracker.configuration.budgets.on_exceeded
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def warn_slow_read(key, window, seconds)
|
|
138
|
+
return if seconds < SLOW_READ_SECONDS
|
|
139
|
+
|
|
140
|
+
@slow_keys ||= Set.new
|
|
141
|
+
return unless @slow_keys.add?(key)
|
|
142
|
+
|
|
143
|
+
Logging.warn(
|
|
144
|
+
"config.budgets.per_tag[#{key.inspect}] #{window} read took #{(seconds * 1000).round} ms. " \
|
|
145
|
+
"A tag with few distinct values covers most of the ledger, so its budget check cannot use " \
|
|
146
|
+
"an index effectively. Budget high-cardinality tags such as a tenant or user id."
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def warn_missing_columns
|
|
151
|
+
return if @missing_columns_warned
|
|
152
|
+
|
|
153
|
+
@missing_columns_warned = true
|
|
154
|
+
Logging.warn(
|
|
155
|
+
"config.budgets.per_tag is set but llm_cost_tracker_call_tags is missing " \
|
|
156
|
+
"#{COST_COLUMN} / #{TIME_COLUMN}; per-tag budgets are not enforced. Run the " \
|
|
157
|
+
"upgrade_per_tag_budgets generator and migrate, or clear config.budgets.per_tag."
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|