llm_cost_tracker 0.7.2 → 0.7.3
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 +6 -0
- data/README.md +6 -2
- data/app/models/llm_cost_tracker/ledger/call.rb +1 -1
- data/app/models/llm_cost_tracker/ledger/call_metrics.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +9 -9
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +4 -10
- data/lib/llm_cost_tracker/ledger/rollups.rb +7 -7
- data/lib/llm_cost_tracker/ledger/store.rb +22 -13
- data/lib/llm_cost_tracker/ledger/tags/query.rb +5 -5
- data/lib/llm_cost_tracker/ledger/tags/sql.rb +8 -7
- data/lib/llm_cost_tracker/parsers/gemini.rb +1 -1
- data/lib/llm_cost_tracker/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6950dae400eac9294a57a0ba2fd2bce7977658837962eafffc3836fa4ab9bd2b
|
|
4
|
+
data.tar.gz: c398f5271d3d0fa53cb27e1206e418e0242fb9dff73ed2c405903b92dfaf8a48
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c52638e31e7eb0f46308312339bd40cfce87227a8c7ec77c94b3af08ffc931c3cffb9566f2ce15ec70a87700084e5e9bb6d05fe670028b57a12066af4a9ebaf6
|
|
7
|
+
data.tar.gz: 12da45f4cd8c485bd6fde5f9376bdfa2c8e618abd41e7be11c022878ec005348ca5d98578216170f1b7105dcc9e2c0c4b037cb5394e2085e2526e04ee8d5a885
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.7.3] - 2026-05-01
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Gemini API thinking tokens no longer get added to output tokens twice.
|
|
12
|
+
|
|
7
13
|
## [0.7.2] - 2026-05-01
|
|
8
14
|
|
|
9
15
|
### Added
|
data/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# LLM Cost Tracker
|
|
2
2
|
|
|
3
|
-
A Rails-native ledger for
|
|
3
|
+
A Rails-native ledger for estimating LLM API spend.
|
|
4
4
|
|
|
5
5
|
[](https://rubygems.org/gems/llm_cost_tracker)
|
|
6
6
|
[](https://github.com/sergey-homenko/llm_cost_tracker/actions)
|
|
7
7
|
[](https://codecov.io/gh/sergey-homenko/llm_cost_tracker)
|
|
8
8
|
|
|
9
|
-
If
|
|
9
|
+
If someone keeps asking "where did that LLM bill come from?", this gem records provider-reported usage into your own database, prices it locally, and gives you a dashboard you can mount in five minutes. No proxy, no SaaS account, no extra service to deploy.
|
|
10
10
|
|
|
11
11
|
It is not Langfuse, Helicone, or LiteLLM. It does not capture prompts, score completions, or replay traces. It does one thing: tells you which provider, which model, which feature, and which user burned how much money. That's the entire pitch.
|
|
12
12
|
|
|
@@ -14,6 +14,10 @@ Requires Ruby 3.3+, Rails 7.1+, PostgreSQL or MySQL, and Faraday 2.0+.
|
|
|
14
14
|
|
|
15
15
|

|
|
16
16
|
|
|
17
|
+
## Accuracy model
|
|
18
|
+
|
|
19
|
+
LLM Cost Tracker estimates spend from provider-reported usage and configured prices. It is useful for explaining spend by provider, model, and tags, but it is not invoice-grade billing. For reconciliation, each call keeps `provider_response_id`, `usage_source`, token breakdowns, and `pricing_mode`.
|
|
20
|
+
|
|
17
21
|
## Quickstart
|
|
18
22
|
|
|
19
23
|
Add to your Gemfile alongside whatever LLM client you already use:
|
|
@@ -8,8 +8,7 @@ module LlmCostTracker
|
|
|
8
8
|
class DataQuality
|
|
9
9
|
class << self
|
|
10
10
|
def call(scope: LlmCostTracker::Ledger::Call.all)
|
|
11
|
-
|
|
12
|
-
scope.unscope(:order).select(aggregate_selects(scope, model:)).take
|
|
11
|
+
scope.unscope(:order).select(aggregate_selects(scope)).take
|
|
13
12
|
end
|
|
14
13
|
|
|
15
14
|
def unknown_pricing_by_model(scope)
|
|
@@ -70,12 +69,12 @@ module LlmCostTracker
|
|
|
70
69
|
|
|
71
70
|
private
|
|
72
71
|
|
|
73
|
-
def aggregate_selects(scope
|
|
72
|
+
def aggregate_selects(scope)
|
|
74
73
|
selects = [
|
|
75
74
|
"COUNT(*) AS total_calls",
|
|
76
75
|
"#{conditional_count_sql('total_cost IS NULL')} AS unknown_pricing_count",
|
|
77
|
-
"#{tagged_calls_sql(
|
|
78
|
-
"COUNT(*) - #{tagged_calls_sql(
|
|
76
|
+
"#{tagged_calls_sql(scope)} AS tagged_calls_count",
|
|
77
|
+
"COUNT(*) - #{tagged_calls_sql(scope)} AS untagged_calls_count",
|
|
79
78
|
"#{conditional_count_sql('latency_ms IS NULL')} AS missing_latency_count",
|
|
80
79
|
"#{conditional_count_sql('stream')} AS streaming_count",
|
|
81
80
|
"#{streaming_missing_usage_select} AS streaming_missing_usage_count",
|
|
@@ -127,11 +126,12 @@ module LlmCostTracker
|
|
|
127
126
|
conditional_count_sql(predicate)
|
|
128
127
|
end
|
|
129
128
|
|
|
130
|
-
def tagged_calls_sql(
|
|
131
|
-
table =
|
|
132
|
-
|
|
129
|
+
def tagged_calls_sql(scope)
|
|
130
|
+
table = scope.klass.quoted_table_name
|
|
131
|
+
connection = scope.connection
|
|
132
|
+
column = "#{table}.#{connection.quote_column_name('tags')}"
|
|
133
133
|
|
|
134
|
-
if Ledger::Schema::Adapter.postgresql?(
|
|
134
|
+
if Ledger::Schema::Adapter.postgresql?(connection)
|
|
135
135
|
"COALESCE(SUM(CASE WHEN #{column} <> '{}'::jsonb THEN 1 ELSE 0 END), 0)"
|
|
136
136
|
else
|
|
137
137
|
"COALESCE(SUM(CASE WHEN JSON_LENGTH(#{column}) > 0 THEN 1 ELSE 0 END), 0)"
|
|
@@ -6,12 +6,8 @@ module LlmCostTracker
|
|
|
6
6
|
module Ledger
|
|
7
7
|
class Rollups
|
|
8
8
|
class UpsertSql
|
|
9
|
-
def self.call
|
|
10
|
-
new
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def initialize(model)
|
|
14
|
-
@model = model
|
|
9
|
+
def self.call
|
|
10
|
+
new.call
|
|
15
11
|
end
|
|
16
12
|
|
|
17
13
|
def call
|
|
@@ -23,13 +19,11 @@ module LlmCostTracker
|
|
|
23
19
|
|
|
24
20
|
private
|
|
25
21
|
|
|
26
|
-
attr_reader :model
|
|
27
|
-
|
|
28
22
|
def postgres_sql
|
|
29
23
|
total_cost = connection.quote_column_name("total_cost")
|
|
30
24
|
updated_at = connection.quote_column_name("updated_at")
|
|
31
25
|
|
|
32
|
-
"#{total_cost} = #{
|
|
26
|
+
"#{total_cost} = #{Period::Total.quoted_table_name}.#{total_cost} + excluded.#{total_cost}, " \
|
|
33
27
|
"#{updated_at} = excluded.#{updated_at}"
|
|
34
28
|
end
|
|
35
29
|
|
|
@@ -38,7 +32,7 @@ module LlmCostTracker
|
|
|
38
32
|
end
|
|
39
33
|
|
|
40
34
|
def connection
|
|
41
|
-
|
|
35
|
+
Period::Total.connection
|
|
42
36
|
end
|
|
43
37
|
end
|
|
44
38
|
end
|
|
@@ -15,9 +15,9 @@ module LlmCostTracker
|
|
|
15
15
|
|
|
16
16
|
Period::Total.upsert_all(
|
|
17
17
|
period_rows(event),
|
|
18
|
-
on_duplicate: Ledger::Rollups::UpsertSql.call
|
|
18
|
+
on_duplicate: Ledger::Rollups::UpsertSql.call,
|
|
19
19
|
record_timestamps: true,
|
|
20
|
-
unique_by:
|
|
20
|
+
unique_by: period_totals_unique_by
|
|
21
21
|
)
|
|
22
22
|
end
|
|
23
23
|
|
|
@@ -27,9 +27,9 @@ module LlmCostTracker
|
|
|
27
27
|
|
|
28
28
|
Period::Total.upsert_all(
|
|
29
29
|
Ledger::Rollups::Batch.rows(events),
|
|
30
|
-
on_duplicate: Ledger::Rollups::UpsertSql.call
|
|
30
|
+
on_duplicate: Ledger::Rollups::UpsertSql.call,
|
|
31
31
|
record_timestamps: true,
|
|
32
|
-
unique_by:
|
|
32
|
+
unique_by: period_totals_unique_by
|
|
33
33
|
)
|
|
34
34
|
end
|
|
35
35
|
|
|
@@ -76,10 +76,10 @@ module LlmCostTracker
|
|
|
76
76
|
end
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
-
def
|
|
80
|
-
return unless
|
|
79
|
+
def period_totals_unique_by
|
|
80
|
+
return unless Period::Total.connection.supports_insert_conflict_target?
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
%i[period period_start]
|
|
83
83
|
end
|
|
84
84
|
end
|
|
85
85
|
end
|
|
@@ -11,12 +11,11 @@ module LlmCostTracker
|
|
|
11
11
|
events = Array(events)
|
|
12
12
|
return [] if events.empty?
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
insertable = new_events(model, events)
|
|
14
|
+
insertable = insertable_events(events)
|
|
16
15
|
|
|
17
16
|
if insertable.any?
|
|
18
17
|
rows = insertable.map { |event| attributes_for(event) }
|
|
19
|
-
|
|
18
|
+
Ledger::Call.insert_all!(rows, record_timestamps: true, returning: false)
|
|
20
19
|
Ledger::Rollups.increment_many!(insertable)
|
|
21
20
|
end
|
|
22
21
|
events
|
|
@@ -25,14 +24,11 @@ module LlmCostTracker
|
|
|
25
24
|
private
|
|
26
25
|
|
|
27
26
|
def attributes_for(event)
|
|
28
|
-
tags = (event.tags || {}).transform_keys(&:to_s).transform_values { |value| stringify_tag_value(value) }
|
|
29
|
-
usage = event.token_usage.stored_attributes
|
|
30
|
-
|
|
31
27
|
attributes = {
|
|
32
28
|
event_id: event.event_id,
|
|
33
29
|
provider: event.provider,
|
|
34
30
|
model: event.model,
|
|
35
|
-
tags: tags,
|
|
31
|
+
tags: stored_tags(event.tags),
|
|
36
32
|
tracked_at: event.tracked_at,
|
|
37
33
|
pricing_mode: event.pricing_mode,
|
|
38
34
|
latency_ms: event.latency_ms,
|
|
@@ -41,16 +37,29 @@ module LlmCostTracker
|
|
|
41
37
|
provider_response_id: event.provider_response_id
|
|
42
38
|
}
|
|
43
39
|
|
|
44
|
-
attributes
|
|
40
|
+
attributes
|
|
41
|
+
.merge(event.token_usage.stored_attributes)
|
|
42
|
+
.merge(Pricing.stored_cost_attributes(event.cost || {}))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def insertable_events(events)
|
|
46
|
+
existing_ids = Ledger::Call.where(event_id: events.map(&:event_id)).pluck(:event_id).to_set
|
|
47
|
+
seen_ids = Set.new
|
|
48
|
+
|
|
49
|
+
events.select do |event|
|
|
50
|
+
event_id = event.event_id
|
|
51
|
+
!existing_ids.include?(event_id) && seen_ids.add?(event_id)
|
|
52
|
+
end
|
|
45
53
|
end
|
|
46
54
|
|
|
47
|
-
def
|
|
48
|
-
|
|
49
|
-
events.reject { |event| existing_ids.include?(event.event_id) }
|
|
55
|
+
def stored_tags(tags)
|
|
56
|
+
(tags || {}).transform_keys(&:to_s).transform_values { |value| stored_tag_value(value) }
|
|
50
57
|
end
|
|
51
58
|
|
|
52
|
-
def
|
|
53
|
-
|
|
59
|
+
def stored_tag_value(value)
|
|
60
|
+
if value.is_a?(Hash)
|
|
61
|
+
return value.transform_keys(&:to_s).transform_values { |nested| stored_tag_value(nested) }
|
|
62
|
+
end
|
|
54
63
|
|
|
55
64
|
value.to_s
|
|
56
65
|
end
|
|
@@ -9,17 +9,17 @@ module LlmCostTracker
|
|
|
9
9
|
module Tags
|
|
10
10
|
module Query
|
|
11
11
|
class << self
|
|
12
|
-
def apply(
|
|
12
|
+
def apply(tags)
|
|
13
13
|
normalized_tags = (tags || {}).to_h.transform_keys(&:to_s).transform_values(&:to_s)
|
|
14
|
-
return
|
|
14
|
+
return Ledger::Call.all if normalized_tags.empty?
|
|
15
15
|
|
|
16
|
-
connection =
|
|
16
|
+
connection = Ledger::Call.connection
|
|
17
17
|
json = normalized_tags.to_json
|
|
18
18
|
|
|
19
19
|
if Schema::Adapter.postgresql?(connection)
|
|
20
|
-
|
|
20
|
+
Ledger::Call.where("tags @> ?::jsonb", json)
|
|
21
21
|
else
|
|
22
|
-
|
|
22
|
+
Ledger::Call.where("JSON_CONTAINS(tags, ?)", json)
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
end
|
|
@@ -8,16 +8,17 @@ module LlmCostTracker
|
|
|
8
8
|
module Tags
|
|
9
9
|
module Sql
|
|
10
10
|
class << self
|
|
11
|
-
def value_expression(
|
|
11
|
+
def value_expression(key, table_name:)
|
|
12
12
|
key = LlmCostTracker::Tags::Key.validate!(key)
|
|
13
|
-
|
|
13
|
+
connection = Ledger::Call.connection
|
|
14
|
+
column = "#{table_name}.#{connection.quote_column_name('tags')}"
|
|
14
15
|
|
|
15
|
-
if Ledger::Schema::Adapter.postgresql?(
|
|
16
|
-
"#{column}->>#{
|
|
17
|
-
elsif Ledger::Schema::Adapter.mysql?(
|
|
18
|
-
"JSON_UNQUOTE(JSON_EXTRACT(#{column}, #{
|
|
16
|
+
if Ledger::Schema::Adapter.postgresql?(connection)
|
|
17
|
+
"#{column}->>#{connection.quote(key)}"
|
|
18
|
+
elsif Ledger::Schema::Adapter.mysql?(connection)
|
|
19
|
+
"JSON_UNQUOTE(JSON_EXTRACT(#{column}, #{connection.quote(json_path(key))}))"
|
|
19
20
|
else
|
|
20
|
-
Ledger::Schema::Adapter.ensure_supported!(
|
|
21
|
+
Ledger::Schema::Adapter.ensure_supported!(connection)
|
|
21
22
|
end
|
|
22
23
|
end
|
|
23
24
|
|