llm_cost_tracker 0.2.0.alpha2 → 0.3.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 +48 -1
- data/README.md +114 -70
- data/Rakefile +2 -0
- data/app/assets/llm_cost_tracker/application.css +760 -0
- data/app/controllers/llm_cost_tracker/application_controller.rb +1 -7
- data/app/controllers/llm_cost_tracker/assets_controller.rb +12 -0
- data/app/controllers/llm_cost_tracker/calls_controller.rb +29 -12
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +5 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +46 -5
- data/app/helpers/llm_cost_tracker/chart_helper.rb +133 -0
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +47 -0
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +34 -0
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +58 -0
- data/app/helpers/llm_cost_tracker/pagination_helper.rb +18 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +16 -1
- data/app/services/llm_cost_tracker/dashboard/filter.rb +22 -3
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +16 -1
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +79 -0
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +19 -46
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +17 -8
- data/app/services/llm_cost_tracker/pagination.rb +6 -0
- data/app/views/layouts/llm_cost_tracker/application.html.erb +35 -333
- data/app/views/llm_cost_tracker/calls/index.html.erb +116 -74
- data/app/views/llm_cost_tracker/calls/show.html.erb +58 -1
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +211 -111
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +224 -78
- data/app/views/llm_cost_tracker/errors/database.html.erb +3 -3
- data/app/views/llm_cost_tracker/errors/invalid_filter.html.erb +3 -3
- data/app/views/llm_cost_tracker/errors/not_found.html.erb +3 -3
- data/app/views/llm_cost_tracker/models/index.html.erb +66 -58
- data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +16 -0
- data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +23 -0
- data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +18 -0
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +15 -0
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +3 -2
- data/app/views/llm_cost_tracker/tags/index.html.erb +55 -12
- data/app/views/llm_cost_tracker/tags/show.html.erb +88 -39
- data/config/routes.rb +3 -0
- data/lib/llm_cost_tracker/assets.rb +19 -0
- data/lib/llm_cost_tracker/configuration.rb +78 -42
- data/lib/llm_cost_tracker/engine.rb +2 -0
- data/lib/llm_cost_tracker/event.rb +2 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +25 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +4 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +8 -1
- data/lib/llm_cost_tracker/llm_api_call.rb +9 -1
- data/lib/llm_cost_tracker/middleware/faraday.rb +57 -9
- data/lib/llm_cost_tracker/parsed_usage.rb +7 -3
- data/lib/llm_cost_tracker/parsers/anthropic.rb +79 -1
- data/lib/llm_cost_tracker/parsers/base.rb +17 -5
- data/lib/llm_cost_tracker/parsers/gemini.rb +59 -6
- data/lib/llm_cost_tracker/parsers/openai.rb +8 -0
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +8 -0
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +55 -1
- data/lib/llm_cost_tracker/parsers/registry.rb +15 -3
- data/lib/llm_cost_tracker/parsers/sse.rb +81 -0
- data/lib/llm_cost_tracker/price_registry.rb +18 -7
- data/lib/llm_cost_tracker/price_sync/fetcher.rb +72 -0
- data/lib/llm_cost_tracker/price_sync/merger.rb +72 -0
- data/lib/llm_cost_tracker/price_sync/model_catalog.rb +77 -0
- data/lib/llm_cost_tracker/price_sync/raw_price.rb +35 -0
- data/lib/llm_cost_tracker/price_sync/source.rb +29 -0
- data/lib/llm_cost_tracker/price_sync/source_result.rb +7 -0
- data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +91 -0
- data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +94 -0
- data/lib/llm_cost_tracker/price_sync/validator.rb +66 -0
- data/lib/llm_cost_tracker/price_sync.rb +310 -0
- data/lib/llm_cost_tracker/pricing.rb +19 -6
- data/lib/llm_cost_tracker/retention.rb +34 -0
- data/lib/llm_cost_tracker/storage/active_record_store.rb +3 -1
- data/lib/llm_cost_tracker/stream_collector.rb +158 -0
- data/lib/llm_cost_tracker/tag_query.rb +7 -2
- data/lib/llm_cost_tracker/tags_column.rb +21 -1
- data/lib/llm_cost_tracker/tracker.rb +15 -12
- data/lib/llm_cost_tracker/value_helpers.rb +40 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +51 -29
- data/lib/tasks/llm_cost_tracker.rake +124 -0
- data/llm_cost_tracker.gemspec +9 -8
- metadata +40 -12
- data/PLAN_0.2.md +0 -488
|
@@ -1,19 +1,63 @@
|
|
|
1
|
+
<section class="lct-panel lct-toolbar">
|
|
2
|
+
<div class="lct-toolbar-head">
|
|
3
|
+
<h2 class="lct-section-title">Tag keys</h2>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<form class="lct-filters" action="<%= tags_path %>" method="get">
|
|
7
|
+
<div class="lct-filter-row lct-filter-row-basic">
|
|
8
|
+
<div class="lct-field">
|
|
9
|
+
<label for="lct-tags-from">From</label>
|
|
10
|
+
<input id="lct-tags-from" data-lct-filter-input type="date" name="from" value="<%= params[:from] %>">
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<div class="lct-field">
|
|
14
|
+
<label for="lct-tags-to">To</label>
|
|
15
|
+
<input id="lct-tags-to" type="date" name="to" value="<%= params[:to] %>">
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<div class="lct-field">
|
|
19
|
+
<label for="lct-tags-provider">Provider</label>
|
|
20
|
+
<%= select_tag :provider,
|
|
21
|
+
options_for_select(provider_filter_options, params[:provider]),
|
|
22
|
+
include_blank: "All providers",
|
|
23
|
+
id: "lct-tags-provider" %>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<div class="lct-field">
|
|
27
|
+
<label for="lct-tags-model">Model</label>
|
|
28
|
+
<%= select_tag :model,
|
|
29
|
+
options_for_select(model_filter_options, params[:model]),
|
|
30
|
+
include_blank: "All models",
|
|
31
|
+
id: "lct-tags-model" %>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div class="lct-filter-actions">
|
|
35
|
+
<button class="lct-button" type="submit">Apply</button>
|
|
36
|
+
<%= link_to("Reset", tags_path, class: "lct-button lct-button-secondary") if any_filter_applied? %>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</form>
|
|
40
|
+
|
|
41
|
+
<%= render "llm_cost_tracker/shared/active_filters", chips: active_tag_filters, clear_path: tags_path %>
|
|
42
|
+
</section>
|
|
43
|
+
|
|
1
44
|
<% if @rows.empty? %>
|
|
2
45
|
<section class="lct-panel lct-empty">
|
|
3
|
-
<h2 class="lct-
|
|
4
|
-
<p class="lct-
|
|
46
|
+
<h2 class="lct-state-title">No tag keys found</h2>
|
|
47
|
+
<p class="lct-state-copy">Tag keys will appear here once tagged calls exist in the current slice.</p>
|
|
48
|
+
<div class="lct-state-actions">
|
|
49
|
+
<%= link_to "Clear filters", tags_path, class: "lct-button lct-button-secondary" %>
|
|
50
|
+
</div>
|
|
5
51
|
</section>
|
|
6
52
|
<% else %>
|
|
7
53
|
<section class="lct-panel">
|
|
8
|
-
<h2 class="lct-section-title">Tag Keys</h2>
|
|
9
|
-
<p class="lct-muted">All tag keys present in the dataset. Click a key to explore spend and call distribution by value.</p>
|
|
10
54
|
<div class="lct-table-wrap">
|
|
11
|
-
<table class="lct-table">
|
|
55
|
+
<table class="lct-table lct-table-compact">
|
|
12
56
|
<thead>
|
|
13
57
|
<tr>
|
|
14
|
-
<th>Tag
|
|
15
|
-
<th>Calls with this key</th>
|
|
16
|
-
<th>Distinct values</th>
|
|
58
|
+
<th>Tag key</th>
|
|
59
|
+
<th class="lct-num">Calls with this key</th>
|
|
60
|
+
<th class="lct-num">Distinct values</th>
|
|
17
61
|
<th></th>
|
|
18
62
|
</tr>
|
|
19
63
|
</thead>
|
|
@@ -21,9 +65,9 @@
|
|
|
21
65
|
<% @rows.each do |row| %>
|
|
22
66
|
<tr>
|
|
23
67
|
<td><code class="lct-code"><%= row.key %></code></td>
|
|
24
|
-
<td><%= number(row.calls_count) %></td>
|
|
25
|
-
<td><%= number(row.distinct_values) %></td>
|
|
26
|
-
<td><%= link_to "Breakdown
|
|
68
|
+
<td class="lct-num"><%= number(row.calls_count) %></td>
|
|
69
|
+
<td class="lct-num"><%= number(row.distinct_values) %></td>
|
|
70
|
+
<td><%= link_to "Breakdown", tag_path(row.key, current_query), class: "lct-button lct-button-secondary lct-button-compact" %></td>
|
|
27
71
|
</tr>
|
|
28
72
|
<% end %>
|
|
29
73
|
</tbody>
|
|
@@ -31,4 +75,3 @@
|
|
|
31
75
|
</div>
|
|
32
76
|
</section>
|
|
33
77
|
<% end %>
|
|
34
|
-
|
|
@@ -1,65 +1,114 @@
|
|
|
1
|
-
|
|
2
|
-
<p class="lct-muted"><%= link_to "← All tag keys", tags_path %></p>
|
|
3
|
-
<h2 class="lct-section-title">Tag key: <code class="lct-code"><%= @tag_key %></code></h2>
|
|
4
|
-
<% if @total_calls.positive? %>
|
|
5
|
-
<p class="lct-muted">
|
|
6
|
-
<%= number(@tagged_calls) %> of <%= number(@total_calls) %> calls have this key
|
|
7
|
-
(<%= percent(coverage_percent(@tagged_calls, @total_calls)) %> coverage) ·
|
|
8
|
-
<%= number(@distinct_values) %> distinct <%= @distinct_values == 1 ? "value" : "values" %>
|
|
9
|
-
</p>
|
|
10
|
-
<% end %>
|
|
11
|
-
<form class="lct-filters" action="<%= tag_path(@tag_key) %>" method="get">
|
|
12
|
-
<div class="lct-field">
|
|
13
|
-
<label for="lct-from">From</label>
|
|
14
|
-
<input id="lct-from" type="date" name="from" value="<%= params[:from] %>">
|
|
15
|
-
</div>
|
|
1
|
+
<% share_base = @tagged_calls.positive? ? @tagged_calls.to_f : 1.0 %>
|
|
16
2
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
3
|
+
<section class="lct-panel lct-toolbar">
|
|
4
|
+
<div class="lct-toolbar-head">
|
|
5
|
+
<div>
|
|
6
|
+
<p class="lct-muted"><%= link_to "← All tag keys", tags_path(current_query) %></p>
|
|
7
|
+
<h2 class="lct-section-title">Tag: <code class="lct-code"><%= @tag_key %></code></h2>
|
|
20
8
|
</div>
|
|
9
|
+
</div>
|
|
21
10
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
<
|
|
25
|
-
|
|
11
|
+
<form class="lct-filters" action="<%= tag_path(@tag_key) %>" method="get">
|
|
12
|
+
<div class="lct-filter-row lct-filter-row-basic">
|
|
13
|
+
<div class="lct-field">
|
|
14
|
+
<label for="lct-tag-show-from">From</label>
|
|
15
|
+
<input id="lct-tag-show-from" data-lct-filter-input type="date" name="from" value="<%= params[:from] %>">
|
|
16
|
+
</div>
|
|
26
17
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
18
|
+
<div class="lct-field">
|
|
19
|
+
<label for="lct-tag-show-to">To</label>
|
|
20
|
+
<input id="lct-tag-show-to" type="date" name="to" value="<%= params[:to] %>">
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<div class="lct-field">
|
|
24
|
+
<label for="lct-tag-show-provider">Provider</label>
|
|
25
|
+
<%= select_tag :provider,
|
|
26
|
+
options_for_select(provider_filter_options, params[:provider]),
|
|
27
|
+
include_blank: "All providers",
|
|
28
|
+
id: "lct-tag-show-provider" %>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div class="lct-field">
|
|
32
|
+
<label for="lct-tag-show-model">Model</label>
|
|
33
|
+
<%= select_tag :model,
|
|
34
|
+
options_for_select(model_filter_options, params[:model]),
|
|
35
|
+
include_blank: "All models",
|
|
36
|
+
id: "lct-tag-show-model" %>
|
|
37
|
+
</div>
|
|
31
38
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
<div class="lct-filter-actions">
|
|
40
|
+
<button class="lct-button" type="submit">Apply</button>
|
|
41
|
+
<%= link_to("Reset", tag_path(@tag_key), class: "lct-button lct-button-secondary") if any_filter_applied? %>
|
|
42
|
+
</div>
|
|
35
43
|
</div>
|
|
36
44
|
</form>
|
|
45
|
+
|
|
46
|
+
<%= render "llm_cost_tracker/shared/active_filters", chips: active_tag_filters, clear_path: tag_path(@tag_key) %>
|
|
47
|
+
|
|
48
|
+
<p class="lct-summary-row">
|
|
49
|
+
<span><strong><%= number(@tagged_calls) %></strong> tagged calls</span>
|
|
50
|
+
<span><strong><%= percent(coverage_percent(@tagged_calls, @total_calls)) %></strong> coverage</span>
|
|
51
|
+
<span><strong><%= number(@distinct_values) %></strong> distinct values</span>
|
|
52
|
+
</p>
|
|
37
53
|
</section>
|
|
38
54
|
|
|
39
55
|
<% if @rows.empty? %>
|
|
40
56
|
<section class="lct-panel lct-empty">
|
|
41
|
-
<h2 class="lct-
|
|
42
|
-
<p class="lct-
|
|
57
|
+
<h2 class="lct-state-title">No calls tagged with <%= @tag_key %></h2>
|
|
58
|
+
<p class="lct-state-copy">Values for this key will appear here once matching calls carry the tag in the current slice.</p>
|
|
59
|
+
<div class="lct-state-actions">
|
|
60
|
+
<%= link_to "Clear filters", tag_path(@tag_key), class: "lct-button lct-button-secondary" %>
|
|
61
|
+
</div>
|
|
43
62
|
</section>
|
|
44
63
|
<% else %>
|
|
64
|
+
<section class="lct-stat-grid lct-stat-grid-spaced">
|
|
65
|
+
<article class="lct-stat">
|
|
66
|
+
<p class="lct-stat-label">Tagged calls</p>
|
|
67
|
+
<p class="lct-stat-value"><%= number(@tagged_calls) %></p>
|
|
68
|
+
<p class="lct-stat-copy">Rows that include <code class="lct-code"><%= @tag_key %></code></p>
|
|
69
|
+
</article>
|
|
70
|
+
|
|
71
|
+
<article class="lct-stat">
|
|
72
|
+
<p class="lct-stat-label">Coverage</p>
|
|
73
|
+
<p class="lct-stat-value"><%= percent(coverage_percent(@tagged_calls, @total_calls)) %></p>
|
|
74
|
+
<p class="lct-stat-copy"><%= number(@total_calls) %> total calls in this slice</p>
|
|
75
|
+
</article>
|
|
76
|
+
|
|
77
|
+
<article class="lct-stat">
|
|
78
|
+
<p class="lct-stat-label">Distinct values</p>
|
|
79
|
+
<p class="lct-stat-value"><%= number(@distinct_values) %></p>
|
|
80
|
+
<p class="lct-stat-copy">Unique values currently visible</p>
|
|
81
|
+
</article>
|
|
82
|
+
</section>
|
|
83
|
+
|
|
45
84
|
<section class="lct-panel">
|
|
46
85
|
<div class="lct-table-wrap">
|
|
47
|
-
<table class="lct-table">
|
|
86
|
+
<table class="lct-table lct-table-compact">
|
|
48
87
|
<thead>
|
|
49
88
|
<tr>
|
|
50
89
|
<th>Value</th>
|
|
51
|
-
<th>Calls</th>
|
|
52
|
-
<th>
|
|
53
|
-
<th>
|
|
90
|
+
<th class="lct-num">Calls</th>
|
|
91
|
+
<th class="lct-num">Share</th>
|
|
92
|
+
<th class="lct-num">Total cost</th>
|
|
93
|
+
<th class="lct-num">Avg cost / call</th>
|
|
94
|
+
<th></th>
|
|
54
95
|
</tr>
|
|
55
96
|
</thead>
|
|
56
97
|
<tbody>
|
|
57
98
|
<% @rows.each do |row| %>
|
|
58
99
|
<tr>
|
|
59
|
-
<td><%= row.value %></td>
|
|
60
|
-
<td><%= number(row.calls) %></td>
|
|
61
|
-
<td><%=
|
|
62
|
-
<td><%= money(row.
|
|
100
|
+
<td><code class="lct-code"><%= row.value %></code></td>
|
|
101
|
+
<td class="lct-num"><%= number(row.calls) %></td>
|
|
102
|
+
<td class="lct-num"><%= percent((row.calls / share_base) * 100.0) %></td>
|
|
103
|
+
<td class="lct-num"><%= money(row.total_cost) %></td>
|
|
104
|
+
<td class="lct-num"><%= money(row.average_cost_per_call) %></td>
|
|
105
|
+
<td>
|
|
106
|
+
<% if row.value == "(untagged)" %>
|
|
107
|
+
<span class="lct-muted">n/a</span>
|
|
108
|
+
<% else %>
|
|
109
|
+
<%= link_to "Calls", calls_path(calls_query_for_tag(key: @tag_key, value: row.value)), class: "lct-button lct-button-secondary lct-button-compact" %>
|
|
110
|
+
<% end %>
|
|
111
|
+
</td>
|
|
63
112
|
</tr>
|
|
64
113
|
<% end %>
|
|
65
114
|
</tbody>
|
data/config/routes.rb
CHANGED
|
@@ -7,4 +7,7 @@ LlmCostTracker::Engine.routes.draw do
|
|
|
7
7
|
get "tags", to: "tags#index", as: :tags
|
|
8
8
|
get "tags/:key", to: "tags#show", as: :tag, format: false
|
|
9
9
|
get "data_quality", to: "data_quality#index", as: :data_quality
|
|
10
|
+
|
|
11
|
+
get "assets/#{LlmCostTracker::Assets.stylesheet_filename}",
|
|
12
|
+
to: "assets#stylesheet", as: :stylesheet
|
|
10
13
|
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Assets
|
|
7
|
+
ROOT = File.expand_path("../../app/assets/llm_cost_tracker", __dir__)
|
|
8
|
+
STYLESHEET = "application.css"
|
|
9
|
+
STYLESHEET_PATH = File.join(ROOT, STYLESHEET).freeze
|
|
10
|
+
STYLESHEET_FINGERPRINT = Digest::SHA256.file(STYLESHEET_PATH).hexdigest[0, 12].freeze
|
|
11
|
+
STYLESHEET_FILENAME = "application-#{STYLESHEET_FINGERPRINT}.css".freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def root = ROOT
|
|
15
|
+
def stylesheet_fingerprint = STYLESHEET_FINGERPRINT
|
|
16
|
+
def stylesheet_filename = STYLESHEET_FILENAME
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "errors"
|
|
4
|
+
require_relative "value_helpers"
|
|
4
5
|
|
|
5
6
|
module LlmCostTracker
|
|
6
7
|
class Configuration
|
|
@@ -14,22 +15,32 @@ module LlmCostTracker
|
|
|
14
15
|
STORAGE_ERROR_BEHAVIORS = %i[ignore warn raise].freeze
|
|
15
16
|
STORAGE_BACKENDS = %i[log active_record custom].freeze
|
|
16
17
|
UNKNOWN_PRICING_BEHAVIORS = %i[ignore warn raise].freeze
|
|
18
|
+
SHARED_SCALAR_ATTRIBUTES = %i[
|
|
19
|
+
enabled
|
|
20
|
+
custom_storage
|
|
21
|
+
on_budget_exceeded
|
|
22
|
+
monthly_budget
|
|
23
|
+
log_level
|
|
24
|
+
prices_file
|
|
25
|
+
].freeze
|
|
26
|
+
SHARED_ENUM_ATTRIBUTES = {
|
|
27
|
+
storage_backend: [STORAGE_BACKENDS, :log],
|
|
28
|
+
budget_exceeded_behavior: [BUDGET_EXCEEDED_BEHAVIORS, :notify],
|
|
29
|
+
storage_error_behavior: [STORAGE_ERROR_BEHAVIORS, :warn],
|
|
30
|
+
unknown_pricing_behavior: [UNKNOWN_PRICING_BEHAVIORS, :warn]
|
|
31
|
+
}.freeze
|
|
17
32
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
:storage_backend, # :log, :active_record, :custom
|
|
30
|
-
:storage_error_behavior, # :ignore, :warn, :raise
|
|
31
|
-
:unknown_pricing_behavior, # :ignore, :warn, :raise
|
|
32
|
-
:openai_compatible_providers
|
|
33
|
+
attr_reader(
|
|
34
|
+
*SHARED_SCALAR_ATTRIBUTES,
|
|
35
|
+
:budget_exceeded_behavior,
|
|
36
|
+
:default_tags,
|
|
37
|
+
:pricing_overrides,
|
|
38
|
+
:report_tag_breakdowns,
|
|
39
|
+
:storage_backend,
|
|
40
|
+
:storage_error_behavior,
|
|
41
|
+
:unknown_pricing_behavior,
|
|
42
|
+
:openai_compatible_providers
|
|
43
|
+
)
|
|
33
44
|
|
|
34
45
|
def initialize
|
|
35
46
|
@enabled = true
|
|
@@ -46,55 +57,74 @@ module LlmCostTracker
|
|
|
46
57
|
@pricing_overrides = {}
|
|
47
58
|
@report_tag_breakdowns = []
|
|
48
59
|
self.openai_compatible_providers = OPENAI_COMPATIBLE_PROVIDERS
|
|
60
|
+
@finalized = false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def default_tags=(value)
|
|
64
|
+
ensure_shared_configuration_mutable!
|
|
65
|
+
@default_tags = value
|
|
49
66
|
end
|
|
50
67
|
|
|
51
68
|
def openai_compatible_providers=(providers)
|
|
69
|
+
ensure_shared_configuration_mutable!
|
|
52
70
|
@openai_compatible_providers = normalize_openai_compatible_providers(providers)
|
|
53
71
|
end
|
|
54
72
|
|
|
55
|
-
def
|
|
56
|
-
|
|
73
|
+
def pricing_overrides=(value)
|
|
74
|
+
ensure_shared_configuration_mutable!
|
|
75
|
+
@pricing_overrides = value
|
|
57
76
|
end
|
|
58
77
|
|
|
59
|
-
def
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
value,
|
|
63
|
-
BUDGET_EXCEEDED_BEHAVIORS,
|
|
64
|
-
default: :notify
|
|
65
|
-
)
|
|
78
|
+
def report_tag_breakdowns=(value)
|
|
79
|
+
ensure_shared_configuration_mutable!
|
|
80
|
+
@report_tag_breakdowns = value
|
|
66
81
|
end
|
|
67
82
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
value
|
|
72
|
-
|
|
73
|
-
default: :warn
|
|
74
|
-
)
|
|
83
|
+
SHARED_SCALAR_ATTRIBUTES.each do |name|
|
|
84
|
+
define_method("#{name}=") do |value|
|
|
85
|
+
ensure_shared_configuration_mutable!
|
|
86
|
+
instance_variable_set(:"@#{name}", value)
|
|
87
|
+
end
|
|
75
88
|
end
|
|
76
89
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
value,
|
|
81
|
-
|
|
82
|
-
default: :warn
|
|
83
|
-
)
|
|
90
|
+
SHARED_ENUM_ATTRIBUTES.each do |name, (allowed, default)|
|
|
91
|
+
define_method("#{name}=") do |value|
|
|
92
|
+
ensure_shared_configuration_mutable!
|
|
93
|
+
instance_variable_set(:"@#{name}", normalize_enum(name, value, allowed, default: default))
|
|
94
|
+
end
|
|
84
95
|
end
|
|
85
96
|
|
|
86
97
|
def normalize_openai_compatible_providers!
|
|
87
98
|
self.openai_compatible_providers = openai_compatible_providers
|
|
88
99
|
end
|
|
89
100
|
|
|
90
|
-
def
|
|
91
|
-
|
|
101
|
+
def finalize!
|
|
102
|
+
@default_tags = ValueHelpers.deep_freeze(@default_tags || {})
|
|
103
|
+
@pricing_overrides = ValueHelpers.deep_freeze(@pricing_overrides || {})
|
|
104
|
+
@report_tag_breakdowns = ValueHelpers.deep_freeze(Array(@report_tag_breakdowns))
|
|
105
|
+
@openai_compatible_providers = ValueHelpers.deep_freeze(@openai_compatible_providers || {})
|
|
106
|
+
@finalized = true
|
|
107
|
+
self
|
|
92
108
|
end
|
|
93
109
|
|
|
94
|
-
def
|
|
95
|
-
|
|
110
|
+
def finalized? = @finalized
|
|
111
|
+
|
|
112
|
+
def dup_for_configuration
|
|
113
|
+
copy = dup
|
|
114
|
+
copy.instance_variable_set(:@default_tags, ValueHelpers.deep_dup(@default_tags || {}))
|
|
115
|
+
copy.instance_variable_set(:@pricing_overrides, ValueHelpers.deep_dup(@pricing_overrides || {}))
|
|
116
|
+
copy.instance_variable_set(:@report_tag_breakdowns, ValueHelpers.deep_dup(@report_tag_breakdowns || []))
|
|
117
|
+
copy.instance_variable_set(
|
|
118
|
+
:@openai_compatible_providers,
|
|
119
|
+
ValueHelpers.deep_dup(@openai_compatible_providers || {})
|
|
120
|
+
)
|
|
121
|
+
copy.instance_variable_set(:@finalized, false)
|
|
122
|
+
copy
|
|
96
123
|
end
|
|
97
124
|
|
|
125
|
+
def active_record? = storage_backend == :active_record
|
|
126
|
+
def log? = storage_backend == :log
|
|
127
|
+
|
|
98
128
|
private
|
|
99
129
|
|
|
100
130
|
def normalize_enum(name, value, allowed, default:)
|
|
@@ -110,5 +140,11 @@ module LlmCostTracker
|
|
|
110
140
|
normalized[host.to_s.downcase] = provider.to_s
|
|
111
141
|
end
|
|
112
142
|
end
|
|
143
|
+
|
|
144
|
+
def ensure_shared_configuration_mutable!
|
|
145
|
+
return unless finalized?
|
|
146
|
+
|
|
147
|
+
raise FrozenError, "can't modify frozen LlmCostTracker::Configuration"
|
|
148
|
+
end
|
|
113
149
|
end
|
|
114
150
|
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Generators
|
|
8
|
+
class AddStreamingGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Creates a migration to add llm_api_calls.stream and llm_api_calls.usage_source"
|
|
14
|
+
|
|
15
|
+
def create_migration_file
|
|
16
|
+
migration_template(
|
|
17
|
+
"add_streaming_to_llm_api_calls.rb.erb",
|
|
18
|
+
"db/migrate/add_streaming_to_llm_api_calls.rb"
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def migration_version
|
|
25
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
class AddStreamingToLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def up
|
|
3
|
+
unless column_exists?(:llm_api_calls, :stream)
|
|
4
|
+
add_column :llm_api_calls, :stream, :boolean, null: false, default: false
|
|
5
|
+
add_index :llm_api_calls, :stream
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
unless column_exists?(:llm_api_calls, :usage_source)
|
|
9
|
+
add_column :llm_api_calls, :usage_source, :string
|
|
10
|
+
add_index :llm_api_calls, :usage_source
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def down
|
|
15
|
+
if column_exists?(:llm_api_calls, :usage_source)
|
|
16
|
+
remove_index :llm_api_calls, :usage_source if index_exists?(:llm_api_calls, :usage_source)
|
|
17
|
+
remove_column :llm_api_calls, :usage_source
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
if column_exists?(:llm_api_calls, :stream)
|
|
21
|
+
remove_index :llm_api_calls, :stream if index_exists?(:llm_api_calls, :stream)
|
|
22
|
+
remove_column :llm_api_calls, :stream
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -10,6 +10,8 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
|
|
|
10
10
|
t.decimal :output_cost, precision: 20, scale: 8
|
|
11
11
|
t.decimal :total_cost, precision: 20, scale: 8
|
|
12
12
|
t.integer :latency_ms
|
|
13
|
+
t.boolean :stream, null: false, default: false
|
|
14
|
+
t.string :usage_source
|
|
13
15
|
if postgresql?
|
|
14
16
|
t.jsonb :tags, null: false, default: {}
|
|
15
17
|
else
|
|
@@ -24,6 +26,8 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
|
|
|
24
26
|
add_index :llm_api_calls, :model
|
|
25
27
|
add_index :llm_api_calls, :tracked_at
|
|
26
28
|
add_index :llm_api_calls, [:provider, :tracked_at]
|
|
29
|
+
add_index :llm_api_calls, :stream
|
|
30
|
+
add_index :llm_api_calls, :usage_source
|
|
27
31
|
add_index :llm_api_calls, :tags, using: :gin if postgresql?
|
|
28
32
|
end
|
|
29
33
|
|
data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb
CHANGED
|
@@ -14,8 +14,11 @@
|
|
|
14
14
|
#
|
|
15
15
|
# Optional metadata keys, ignored by cost calculation:
|
|
16
16
|
# - _source
|
|
17
|
+
# - _source_version
|
|
18
|
+
# - _fetched_at
|
|
17
19
|
# - _updated
|
|
18
20
|
# - _notes
|
|
21
|
+
# - _validator_override
|
|
19
22
|
#
|
|
20
23
|
# Example: custom fine-tune
|
|
21
24
|
# models:
|
|
@@ -30,7 +33,11 @@
|
|
|
30
33
|
# "gpt-4o":
|
|
31
34
|
# input: 2.00
|
|
32
35
|
# output: 8.00
|
|
33
|
-
# _source: "
|
|
36
|
+
# _source: "manual"
|
|
34
37
|
# _updated: "2026-04-18"
|
|
38
|
+
#
|
|
39
|
+
# Use _source: "manual" for custom or orphaned entries you never want sync to touch.
|
|
40
|
+
# Use _validator_override: ["skip_relative_change"] if a negotiated price would
|
|
41
|
+
# otherwise trip the >3x sync warning.
|
|
35
42
|
|
|
36
43
|
models:
|
|
@@ -21,6 +21,14 @@ module LlmCostTracker
|
|
|
21
21
|
scope :without_cost, -> { where(total_cost: nil) }
|
|
22
22
|
scope :unknown_pricing, -> { without_cost }
|
|
23
23
|
scope :with_latency, -> { latency_column? ? where.not(latency_ms: nil) : none }
|
|
24
|
+
scope :streaming, -> { stream_column? ? where(stream: true) : none }
|
|
25
|
+
scope :non_streaming, -> { stream_column? ? where(stream: [false, nil]) : all }
|
|
26
|
+
scope :by_usage_source, ->(source) { usage_source_column? ? where(usage_source: source.to_s) : none }
|
|
27
|
+
scope :streaming_missing_usage, lambda {
|
|
28
|
+
return none unless stream_column? && usage_source_column?
|
|
29
|
+
|
|
30
|
+
where(stream: true).where(usage_source: ["unknown", nil])
|
|
31
|
+
}
|
|
24
32
|
|
|
25
33
|
scope :with_json_tags, lambda {
|
|
26
34
|
if tags_json_column?
|
|
@@ -100,7 +108,7 @@ module LlmCostTracker
|
|
|
100
108
|
|
|
101
109
|
case connection.adapter_name
|
|
102
110
|
when /postgres/i
|
|
103
|
-
json_column =
|
|
111
|
+
json_column = tags_jsonb_column? ? column : "(#{column})::jsonb"
|
|
104
112
|
"#{json_column}->>#{connection.quote(key)}"
|
|
105
113
|
when /mysql/i
|
|
106
114
|
"JSON_UNQUOTE(JSON_EXTRACT(#{column}, #{connection.quote(json_path(key))}))"
|