llm_cost_tracker 0.1.4 → 0.2.0.alpha2
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 +58 -91
- data/PLAN_0.2.md +488 -0
- data/README.md +140 -320
- data/app/controllers/llm_cost_tracker/application_controller.rb +42 -0
- data/app/controllers/llm_cost_tracker/calls_controller.rb +77 -0
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +54 -0
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +10 -0
- data/app/controllers/llm_cost_tracker/models_controller.rb +12 -0
- data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -0
- data/app/helpers/llm_cost_tracker/application_helper.rb +113 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +38 -0
- data/app/services/llm_cost_tracker/dashboard/filter.rb +109 -0
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +87 -0
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +44 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +58 -0
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +125 -0
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +44 -0
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +89 -0
- data/app/services/llm_cost_tracker/pagination.rb +59 -0
- data/app/views/layouts/llm_cost_tracker/application.html.erb +342 -0
- data/app/views/llm_cost_tracker/calls/index.html.erb +127 -0
- data/app/views/llm_cost_tracker/calls/show.html.erb +67 -0
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +145 -0
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +110 -0
- data/app/views/llm_cost_tracker/errors/database.html.erb +8 -0
- data/app/views/llm_cost_tracker/errors/invalid_filter.html.erb +4 -0
- data/app/views/llm_cost_tracker/errors/not_found.html.erb +5 -0
- data/app/views/llm_cost_tracker/models/index.html.erb +95 -0
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +5 -0
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +6 -0
- data/app/views/llm_cost_tracker/tags/index.html.erb +34 -0
- data/app/views/llm_cost_tracker/tags/show.html.erb +69 -0
- data/config/routes.rb +10 -0
- data/lib/llm_cost_tracker/budget.rb +16 -38
- data/lib/llm_cost_tracker/configuration.rb +3 -1
- data/lib/llm_cost_tracker/cost.rb +1 -3
- data/lib/llm_cost_tracker/engine.rb +13 -0
- data/lib/llm_cost_tracker/engine_compatibility.rb +15 -0
- data/lib/llm_cost_tracker/errors.rb +2 -0
- data/lib/llm_cost_tracker/event.rb +1 -3
- data/lib/llm_cost_tracker/event_metadata.rb +9 -18
- data/lib/llm_cost_tracker/llm_api_call.rb +4 -17
- data/lib/llm_cost_tracker/middleware/faraday.rb +4 -4
- data/lib/llm_cost_tracker/parsed_usage.rb +5 -9
- data/lib/llm_cost_tracker/parsers/anthropic.rb +4 -5
- data/lib/llm_cost_tracker/parsers/base.rb +3 -8
- data/lib/llm_cost_tracker/parsers/gemini.rb +3 -3
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +3 -3
- data/lib/llm_cost_tracker/parsers/registry.rb +5 -12
- data/lib/llm_cost_tracker/period_grouping.rb +68 -0
- data/lib/llm_cost_tracker/price_registry.rb +22 -30
- data/lib/llm_cost_tracker/pricing.rb +10 -19
- data/lib/llm_cost_tracker/report.rb +4 -4
- data/lib/llm_cost_tracker/report_data.rb +21 -24
- data/lib/llm_cost_tracker/report_formatter.rb +4 -2
- data/lib/llm_cost_tracker/storage/active_record_store.rb +1 -3
- data/lib/llm_cost_tracker/tag_key.rb +16 -0
- data/lib/llm_cost_tracker/tracker.rb +35 -1
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +3 -6
- data/llm_cost_tracker.gemspec +13 -9
- metadata +91 -20
- data/.rubocop.yml +0 -44
- data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -19
- data/lib/llm_cost_tracker/storage/backends.rb +0 -26
- data/lib/llm_cost_tracker/storage/custom_backend.rb +0 -16
- data/lib/llm_cost_tracker/storage/log_backend.rb +0 -28
- data/lib/llm_cost_tracker/value_object.rb +0 -45
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<section class="lct-panel">
|
|
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>
|
|
16
|
+
|
|
17
|
+
<div class="lct-field">
|
|
18
|
+
<label for="lct-to">To</label>
|
|
19
|
+
<input id="lct-to" type="date" name="to" value="<%= params[:to] %>">
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<div class="lct-field">
|
|
23
|
+
<label for="lct-provider">Provider</label>
|
|
24
|
+
<input id="lct-provider" type="text" name="provider" value="<%= params[:provider] %>">
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<div class="lct-field">
|
|
28
|
+
<label for="lct-model">Model</label>
|
|
29
|
+
<input id="lct-model" type="text" name="model" value="<%= params[:model] %>">
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<div class="lct-button-row">
|
|
33
|
+
<button class="lct-button" type="submit">Apply</button>
|
|
34
|
+
<%= link_to "Reset", tag_path(@tag_key), class: "lct-button lct-button-secondary" %>
|
|
35
|
+
</div>
|
|
36
|
+
</form>
|
|
37
|
+
</section>
|
|
38
|
+
|
|
39
|
+
<% if @rows.empty? %>
|
|
40
|
+
<section class="lct-panel lct-empty">
|
|
41
|
+
<h2 class="lct-section-title">No calls tagged with <%= @tag_key %></h2>
|
|
42
|
+
<p class="lct-muted">Calls carrying this tag will appear here when they match the current filters.</p>
|
|
43
|
+
</section>
|
|
44
|
+
<% else %>
|
|
45
|
+
<section class="lct-panel">
|
|
46
|
+
<div class="lct-table-wrap">
|
|
47
|
+
<table class="lct-table">
|
|
48
|
+
<thead>
|
|
49
|
+
<tr>
|
|
50
|
+
<th>Value</th>
|
|
51
|
+
<th>Calls</th>
|
|
52
|
+
<th>Total cost</th>
|
|
53
|
+
<th>Avg cost / call</th>
|
|
54
|
+
</tr>
|
|
55
|
+
</thead>
|
|
56
|
+
<tbody>
|
|
57
|
+
<% @rows.each do |row| %>
|
|
58
|
+
<tr>
|
|
59
|
+
<td><%= row.value %></td>
|
|
60
|
+
<td><%= number(row.calls) %></td>
|
|
61
|
+
<td><%= money(row.total_cost) %></td>
|
|
62
|
+
<td><%= money(row.average_cost_per_call) %></td>
|
|
63
|
+
</tr>
|
|
64
|
+
<% end %>
|
|
65
|
+
</tbody>
|
|
66
|
+
</table>
|
|
67
|
+
</div>
|
|
68
|
+
</section>
|
|
69
|
+
<% end %>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
LlmCostTracker::Engine.routes.draw do
|
|
4
|
+
root "dashboard#index"
|
|
5
|
+
resources :calls, only: %i[index show], constraints: { id: /\d+/ }, defaults: { format: :html }
|
|
6
|
+
resources :models, only: :index
|
|
7
|
+
get "tags", to: "tags#index", as: :tags
|
|
8
|
+
get "tags/:key", to: "tags#show", as: :tag, format: false
|
|
9
|
+
get "data_quality", to: "data_quality#index", as: :data_quality
|
|
10
|
+
end
|
|
@@ -4,17 +4,15 @@ require_relative "logging"
|
|
|
4
4
|
|
|
5
5
|
module LlmCostTracker
|
|
6
6
|
class Budget
|
|
7
|
-
WARNING_MUTEX = Mutex.new
|
|
8
|
-
private_constant :WARNING_MUTEX
|
|
9
|
-
|
|
10
7
|
class << self
|
|
11
8
|
def enforce!
|
|
12
|
-
|
|
13
|
-
return unless
|
|
14
|
-
return
|
|
9
|
+
config = LlmCostTracker.configuration
|
|
10
|
+
return unless config.monthly_budget
|
|
11
|
+
return unless config.budget_exceeded_behavior == :block_requests
|
|
12
|
+
return unless config.active_record?
|
|
15
13
|
|
|
16
|
-
monthly_total =
|
|
17
|
-
return unless monthly_total >=
|
|
14
|
+
monthly_total = active_record_monthly_total
|
|
15
|
+
return unless monthly_total >= config.monthly_budget
|
|
18
16
|
|
|
19
17
|
handle_exceeded(monthly_total: monthly_total)
|
|
20
18
|
end
|
|
@@ -22,24 +20,20 @@ module LlmCostTracker
|
|
|
22
20
|
def check!(event)
|
|
23
21
|
config = LlmCostTracker.configuration
|
|
24
22
|
return unless config.monthly_budget
|
|
25
|
-
return unless event
|
|
23
|
+
return unless event.cost
|
|
26
24
|
|
|
27
|
-
monthly_total =
|
|
28
|
-
|
|
25
|
+
monthly_total = if config.active_record?
|
|
26
|
+
active_record_monthly_total
|
|
27
|
+
else
|
|
28
|
+
event.cost.total_cost
|
|
29
|
+
end
|
|
30
|
+
return unless monthly_total >= config.monthly_budget
|
|
29
31
|
|
|
30
32
|
handle_exceeded(monthly_total: monthly_total, last_event: event)
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
private
|
|
34
36
|
|
|
35
|
-
def calculate_monthly_total(latest_cost)
|
|
36
|
-
if LlmCostTracker.configuration.active_record?
|
|
37
|
-
active_record_monthly_total
|
|
38
|
-
else
|
|
39
|
-
latest_cost
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
|
|
43
37
|
def active_record_monthly_total
|
|
44
38
|
require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
|
|
45
39
|
require_relative "storage/active_record_store" unless defined?(LlmCostTracker::Storage::ActiveRecordStore)
|
|
@@ -49,18 +43,6 @@ module LlmCostTracker
|
|
|
49
43
|
raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
|
|
50
44
|
end
|
|
51
45
|
|
|
52
|
-
def warn_non_active_record_block_requests
|
|
53
|
-
should_warn = WARNING_MUTEX.synchronize do
|
|
54
|
-
unless @warned_non_active_record_block_requests
|
|
55
|
-
@warned_non_active_record_block_requests = true
|
|
56
|
-
true
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
return unless should_warn
|
|
60
|
-
|
|
61
|
-
Logging.warn(":block_requests preflight requires storage_backend = :active_record; request was not blocked.")
|
|
62
|
-
end
|
|
63
|
-
|
|
64
46
|
def handle_exceeded(monthly_total:, last_event: nil)
|
|
65
47
|
config = LlmCostTracker.configuration
|
|
66
48
|
payload = {
|
|
@@ -70,15 +52,11 @@ module LlmCostTracker
|
|
|
70
52
|
}
|
|
71
53
|
|
|
72
54
|
config.on_budget_exceeded&.call(payload)
|
|
73
|
-
raise BudgetExceededError.new(**payload) if raise_on_exceeded?
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def raise_on_exceeded?
|
|
77
|
-
%i[raise block_requests].include?(behavior)
|
|
55
|
+
raise BudgetExceededError.new(**payload) if raise_on_exceeded?(config)
|
|
78
56
|
end
|
|
79
57
|
|
|
80
|
-
def
|
|
81
|
-
|
|
58
|
+
def raise_on_exceeded?(config)
|
|
59
|
+
%i[raise block_requests].include?(config.budget_exceeded_behavior)
|
|
82
60
|
end
|
|
83
61
|
end
|
|
84
62
|
end
|
|
@@ -22,7 +22,8 @@ module LlmCostTracker
|
|
|
22
22
|
:monthly_budget, # Float, in USD — nil means no limit
|
|
23
23
|
:log_level, # :debug, :info, :warn
|
|
24
24
|
:prices_file, # JSON/YAML file that overrides built-in prices
|
|
25
|
-
:pricing_overrides
|
|
25
|
+
:pricing_overrides, # Hash to override built-in pricing
|
|
26
|
+
:report_tag_breakdowns # Array of tag keys to break down in the rake report
|
|
26
27
|
|
|
27
28
|
attr_reader :budget_exceeded_behavior, # :notify, :raise, :block_requests
|
|
28
29
|
:storage_backend, # :log, :active_record, :custom
|
|
@@ -43,6 +44,7 @@ module LlmCostTracker
|
|
|
43
44
|
@log_level = :info
|
|
44
45
|
@prices_file = nil
|
|
45
46
|
@pricing_overrides = {}
|
|
47
|
+
@report_tag_breakdowns = []
|
|
46
48
|
self.openai_compatible_providers = OPENAI_COMPATIBLE_PROVIDERS
|
|
47
49
|
end
|
|
48
50
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails"
|
|
4
|
+
require_relative "../llm_cost_tracker"
|
|
5
|
+
require_relative "engine_compatibility"
|
|
6
|
+
|
|
7
|
+
LlmCostTracker::EngineCompatibility.check_rails_version!(Rails.version)
|
|
8
|
+
|
|
9
|
+
module LlmCostTracker
|
|
10
|
+
class Engine < ::Rails::Engine
|
|
11
|
+
isolate_namespace LlmCostTracker
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module EngineCompatibility
|
|
5
|
+
REQUIRED_RAILS_VERSION = Gem::Version.new("7.1.0")
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
def check_rails_version!(version)
|
|
9
|
+
return if Gem::Version.new(version) >= REQUIRED_RAILS_VERSION
|
|
10
|
+
|
|
11
|
+
raise LlmCostTracker::Error, "LlmCostTracker::Engine requires Rails 7.1+"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -16,22 +16,17 @@ module LlmCostTracker
|
|
|
16
16
|
|
|
17
17
|
class << self
|
|
18
18
|
def usage_data(input_tokens, output_tokens, metadata)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
:cache_creation_input_tokens,
|
|
23
|
-
:cache_creation_tokens
|
|
24
|
-
)
|
|
25
|
-
cached_input_tokens = integer_metadata(metadata, :cached_input_tokens)
|
|
19
|
+
metadata = metadata.to_h.symbolize_keys
|
|
20
|
+
cache_read = first_integer(metadata, :cache_read_input_tokens, :cache_read_tokens)
|
|
21
|
+
cache_creation = first_integer(metadata, :cache_creation_input_tokens, :cache_creation_tokens)
|
|
26
22
|
|
|
27
23
|
{
|
|
28
24
|
input_tokens: input_tokens.to_i,
|
|
29
25
|
output_tokens: output_tokens.to_i,
|
|
30
|
-
cached_input_tokens: cached_input_tokens,
|
|
31
|
-
cache_read_input_tokens:
|
|
32
|
-
cache_creation_input_tokens:
|
|
33
|
-
total_tokens: input_tokens.to_i + output_tokens.to_i +
|
|
34
|
-
cache_read_input_tokens + cache_creation_input_tokens
|
|
26
|
+
cached_input_tokens: metadata[:cached_input_tokens].to_i,
|
|
27
|
+
cache_read_input_tokens: cache_read,
|
|
28
|
+
cache_creation_input_tokens: cache_creation,
|
|
29
|
+
total_tokens: input_tokens.to_i + output_tokens.to_i + cache_read + cache_creation
|
|
35
30
|
}
|
|
36
31
|
end
|
|
37
32
|
|
|
@@ -41,12 +36,8 @@ module LlmCostTracker
|
|
|
41
36
|
|
|
42
37
|
private
|
|
43
38
|
|
|
44
|
-
def
|
|
45
|
-
keys.each
|
|
46
|
-
value = metadata[key] || metadata[key.to_s]
|
|
47
|
-
return value.to_i unless value.nil?
|
|
48
|
-
end
|
|
49
|
-
|
|
39
|
+
def first_integer(metadata, *keys)
|
|
40
|
+
keys.each { |key| return metadata[key].to_i unless metadata[key].nil? }
|
|
50
41
|
0
|
|
51
42
|
end
|
|
52
43
|
end
|
|
@@ -2,24 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
require "active_record"
|
|
4
4
|
|
|
5
|
+
require_relative "period_grouping"
|
|
5
6
|
require_relative "tag_accessors"
|
|
7
|
+
require_relative "tag_key"
|
|
6
8
|
require_relative "tag_query"
|
|
7
9
|
require_relative "tags_column"
|
|
8
10
|
|
|
9
11
|
module LlmCostTracker
|
|
10
12
|
class LlmApiCall < ActiveRecord::Base
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
private_constant :TAG_KEY_PATTERN
|
|
14
|
-
|
|
13
|
+
extend PeriodGrouping
|
|
15
14
|
extend TagsColumn
|
|
16
15
|
include TagAccessors
|
|
17
16
|
|
|
18
17
|
self.table_name = "llm_api_calls"
|
|
19
18
|
|
|
20
19
|
# Scopes for querying
|
|
21
|
-
scope :by_provider, ->(provider) { where(provider: provider) }
|
|
22
|
-
scope :by_model, ->(model) { where(model: model) }
|
|
23
20
|
scope :with_cost, -> { where.not(total_cost: nil) }
|
|
24
21
|
scope :without_cost, -> { where(total_cost: nil) }
|
|
25
22
|
scope :unknown_pricing, -> { without_cost }
|
|
@@ -92,13 +89,6 @@ module LlmCostTracker
|
|
|
92
89
|
group(:provider).average(:latency_ms).transform_values(&:to_f)
|
|
93
90
|
end
|
|
94
91
|
|
|
95
|
-
def self.daily_costs(days: 30)
|
|
96
|
-
where(tracked_at: days.days.ago..)
|
|
97
|
-
.group("DATE(tracked_at)")
|
|
98
|
-
.sum(:total_cost)
|
|
99
|
-
.transform_keys(&:to_s)
|
|
100
|
-
end
|
|
101
|
-
|
|
102
92
|
def self.tag_label(value)
|
|
103
93
|
value.nil? || value == "" ? "(untagged)" : value.to_s
|
|
104
94
|
end
|
|
@@ -121,10 +111,7 @@ module LlmCostTracker
|
|
|
121
111
|
private_class_method :tag_group_expression
|
|
122
112
|
|
|
123
113
|
def self.validated_tag_key(key)
|
|
124
|
-
key
|
|
125
|
-
return key if key.match?(TAG_KEY_PATTERN)
|
|
126
|
-
|
|
127
|
-
raise ArgumentError, "invalid tag key: #{key.inspect}"
|
|
114
|
+
TagKey.validate!(key)
|
|
128
115
|
end
|
|
129
116
|
private_class_method :validated_tag_key
|
|
130
117
|
|
|
@@ -37,10 +37,10 @@ module LlmCostTracker
|
|
|
37
37
|
return unless parsed
|
|
38
38
|
|
|
39
39
|
Tracker.record(
|
|
40
|
-
provider: parsed
|
|
41
|
-
model: parsed
|
|
42
|
-
input_tokens: parsed
|
|
43
|
-
output_tokens: parsed
|
|
40
|
+
provider: parsed.provider,
|
|
41
|
+
model: parsed.model,
|
|
42
|
+
input_tokens: parsed.input_tokens,
|
|
43
|
+
output_tokens: parsed.output_tokens,
|
|
44
44
|
latency_ms: latency_ms,
|
|
45
45
|
metadata: resolved_tags(request_env).merge(parsed.metadata)
|
|
46
46
|
)
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "value_object"
|
|
4
|
-
|
|
5
3
|
module LlmCostTracker
|
|
6
|
-
ParsedUsage =
|
|
4
|
+
ParsedUsage = Data.define(
|
|
7
5
|
:provider,
|
|
8
6
|
:model,
|
|
9
7
|
:input_tokens,
|
|
@@ -15,10 +13,10 @@ module LlmCostTracker
|
|
|
15
13
|
:reasoning_tokens
|
|
16
14
|
)
|
|
17
15
|
|
|
18
|
-
ParsedUsage
|
|
16
|
+
class ParsedUsage
|
|
17
|
+
TRACKING_KEYS = %i[provider model input_tokens output_tokens total_tokens].freeze
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
def build(**attributes)
|
|
19
|
+
def self.build(**attributes)
|
|
22
20
|
new(
|
|
23
21
|
provider: attributes.fetch(:provider),
|
|
24
22
|
model: attributes.fetch(:model),
|
|
@@ -31,11 +29,9 @@ module LlmCostTracker
|
|
|
31
29
|
reasoning_tokens: attributes[:reasoning_tokens]
|
|
32
30
|
)
|
|
33
31
|
end
|
|
34
|
-
end
|
|
35
32
|
|
|
36
|
-
class ParsedUsage
|
|
37
33
|
def metadata
|
|
38
|
-
except(*TRACKING_KEYS)
|
|
34
|
+
to_h.except(*TRACKING_KEYS)
|
|
39
35
|
end
|
|
40
36
|
|
|
41
37
|
def to_h
|
|
@@ -28,11 +28,10 @@ module LlmCostTracker
|
|
|
28
28
|
ParsedUsage.build(
|
|
29
29
|
provider: "anthropic",
|
|
30
30
|
model: response["model"] || request["model"],
|
|
31
|
-
input_tokens: usage["input_tokens"]
|
|
32
|
-
output_tokens: usage["output_tokens"]
|
|
33
|
-
total_tokens:
|
|
34
|
-
|
|
35
|
-
(usage["cache_creation_input_tokens"] || 0),
|
|
31
|
+
input_tokens: usage["input_tokens"].to_i,
|
|
32
|
+
output_tokens: usage["output_tokens"].to_i,
|
|
33
|
+
total_tokens: usage["input_tokens"].to_i + usage["output_tokens"].to_i +
|
|
34
|
+
usage["cache_read_input_tokens"].to_i + usage["cache_creation_input_tokens"].to_i,
|
|
36
35
|
cache_read_input_tokens: usage["cache_read_input_tokens"],
|
|
37
36
|
cache_creation_input_tokens: usage["cache_creation_input_tokens"]
|
|
38
37
|
)
|
|
@@ -5,15 +5,10 @@ require "json"
|
|
|
5
5
|
module LlmCostTracker
|
|
6
6
|
module Parsers
|
|
7
7
|
class Base
|
|
8
|
-
#
|
|
8
|
+
# Parse a provider response into a {LlmCostTracker::ParsedUsage}, or return
|
|
9
|
+
# nil when the response is not trackable (non-200, missing usage, etc).
|
|
9
10
|
#
|
|
10
|
-
#
|
|
11
|
-
# {
|
|
12
|
-
# provider: "openai",
|
|
13
|
-
# model: "gpt-4o",
|
|
14
|
-
# input_tokens: 150,
|
|
15
|
-
# output_tokens: 42
|
|
16
|
-
# }
|
|
11
|
+
# @return [LlmCostTracker::ParsedUsage, nil]
|
|
17
12
|
def parse(request_url, request_body, response_status, response_body)
|
|
18
13
|
raise NotImplementedError
|
|
19
14
|
end
|
|
@@ -30,9 +30,9 @@ module LlmCostTracker
|
|
|
30
30
|
ParsedUsage.build(
|
|
31
31
|
provider: "gemini",
|
|
32
32
|
model: model,
|
|
33
|
-
input_tokens: usage["promptTokenCount"]
|
|
33
|
+
input_tokens: usage["promptTokenCount"].to_i,
|
|
34
34
|
output_tokens: output_tokens(usage),
|
|
35
|
-
total_tokens: usage["totalTokenCount"]
|
|
35
|
+
total_tokens: usage["totalTokenCount"].to_i,
|
|
36
36
|
cached_input_tokens: usage["cachedContentTokenCount"]
|
|
37
37
|
)
|
|
38
38
|
end
|
|
@@ -40,7 +40,7 @@ module LlmCostTracker
|
|
|
40
40
|
private
|
|
41
41
|
|
|
42
42
|
def output_tokens(usage)
|
|
43
|
-
|
|
43
|
+
usage["candidatesTokenCount"].to_i + usage["thoughtsTokenCount"].to_i
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def extract_model_from_url(url)
|
|
@@ -17,9 +17,9 @@ module LlmCostTracker
|
|
|
17
17
|
ParsedUsage.build(
|
|
18
18
|
provider: provider_for(request_url),
|
|
19
19
|
model: response["model"] || request["model"],
|
|
20
|
-
input_tokens: usage["prompt_tokens"] || usage["input_tokens"]
|
|
21
|
-
output_tokens: usage["completion_tokens"] || usage["output_tokens"]
|
|
22
|
-
total_tokens: usage["total_tokens"]
|
|
20
|
+
input_tokens: (usage["prompt_tokens"] || usage["input_tokens"]).to_i,
|
|
21
|
+
output_tokens: (usage["completion_tokens"] || usage["output_tokens"]).to_i,
|
|
22
|
+
total_tokens: usage["total_tokens"].to_i,
|
|
23
23
|
cached_input_tokens: cached_input_tokens(usage)
|
|
24
24
|
)
|
|
25
25
|
end
|
|
@@ -4,33 +4,26 @@ module LlmCostTracker
|
|
|
4
4
|
module Parsers
|
|
5
5
|
class Registry
|
|
6
6
|
class << self
|
|
7
|
-
PARSERS_MUTEX = Mutex.new
|
|
8
|
-
|
|
9
7
|
def parsers
|
|
10
|
-
@parsers
|
|
8
|
+
@parsers ||= default_parsers
|
|
11
9
|
end
|
|
12
10
|
|
|
13
11
|
def register(parser)
|
|
14
|
-
|
|
12
|
+
parsers.unshift(parser)
|
|
15
13
|
end
|
|
16
14
|
|
|
17
15
|
def find_for(url)
|
|
18
|
-
parsers.find { |
|
|
16
|
+
parsers.find { |parser| parser.match?(url) }
|
|
19
17
|
end
|
|
20
18
|
|
|
21
19
|
def reset!
|
|
22
|
-
|
|
20
|
+
@parsers = nil
|
|
23
21
|
end
|
|
24
22
|
|
|
25
23
|
private
|
|
26
24
|
|
|
27
25
|
def default_parsers
|
|
28
|
-
[
|
|
29
|
-
Openai.new,
|
|
30
|
-
OpenaiCompatible.new,
|
|
31
|
-
Anthropic.new,
|
|
32
|
-
Gemini.new
|
|
33
|
-
]
|
|
26
|
+
[Openai.new, OpenaiCompatible.new, Anthropic.new, Gemini.new]
|
|
34
27
|
end
|
|
35
28
|
end
|
|
36
29
|
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module PeriodGrouping
|
|
5
|
+
PERIOD_FORMATS = {
|
|
6
|
+
day: {
|
|
7
|
+
postgres: "YYYY-MM-DD",
|
|
8
|
+
mysql: "%Y-%m-%d",
|
|
9
|
+
sqlite: "%Y-%m-%d"
|
|
10
|
+
},
|
|
11
|
+
month: {
|
|
12
|
+
postgres: "YYYY-MM",
|
|
13
|
+
mysql: "%Y-%m",
|
|
14
|
+
sqlite: "%Y-%m"
|
|
15
|
+
}
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
private_constant :PERIOD_FORMATS
|
|
19
|
+
|
|
20
|
+
def group_by_period(period, column: :tracked_at)
|
|
21
|
+
group(Arel.sql(period_group_expression(period, column: column)))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def daily_costs(days: 30)
|
|
25
|
+
where(tracked_at: days.days.ago..)
|
|
26
|
+
.group_by_period(:day)
|
|
27
|
+
.sum(:total_cost)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def period_group_expression(period, column:)
|
|
33
|
+
period = validated_period(period)
|
|
34
|
+
column = period_column_expression(column)
|
|
35
|
+
formats = PERIOD_FORMATS.fetch(period)
|
|
36
|
+
|
|
37
|
+
case connection.adapter_name
|
|
38
|
+
when /postgres/i
|
|
39
|
+
postgres_period_expression(period, column, formats)
|
|
40
|
+
when /mysql/i
|
|
41
|
+
"DATE_FORMAT(#{column}, #{connection.quote(formats.fetch(:mysql))})"
|
|
42
|
+
else
|
|
43
|
+
"strftime(#{connection.quote(formats.fetch(:sqlite))}, #{column})"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def postgres_period_expression(period, column, formats)
|
|
48
|
+
"TO_CHAR(" \
|
|
49
|
+
"DATE_TRUNC(#{connection.quote(period.to_s)}, #{column}), " \
|
|
50
|
+
"#{connection.quote(formats.fetch(:postgres))}" \
|
|
51
|
+
")"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def validated_period(period)
|
|
55
|
+
normalized_period = period.respond_to?(:to_sym) ? period.to_sym : nil
|
|
56
|
+
return normalized_period if PERIOD_FORMATS.key?(normalized_period)
|
|
57
|
+
|
|
58
|
+
raise ArgumentError, "invalid period: #{period.inspect}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def period_column_expression(column)
|
|
62
|
+
column = column.to_s
|
|
63
|
+
return "#{quoted_table_name}.#{connection.quote_column_name(column)}" if column_names.include?(column)
|
|
64
|
+
|
|
65
|
+
raise ArgumentError, "invalid period column: #{column.inspect}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|