llm_cost_tracker 0.1.3 → 0.1.4
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 +17 -1
- data/README.md +16 -11
- data/lib/llm_cost_tracker/llm_api_call.rb +49 -2
- data/lib/llm_cost_tracker/report_data.rb +5 -8
- data/lib/llm_cost_tracker/report_formatter.rb +7 -1
- data/lib/llm_cost_tracker/tag_accessors.rb +0 -8
- data/lib/llm_cost_tracker/unknown_pricing.rb +1 -1
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/llm_cost_tracker.gemspec +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cb8f35c3d464bc7adea3b8376055e23c2ff704d815247de8ec40c82cbab41d29
|
|
4
|
+
data.tar.gz: ec3fecd6c98f9c3c9664f12db2ca411440d698f5743d2ac77827e86f04fca380
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 76ccf3b2d160eb3d9e7dd22545e7a53515f48334f6fd7a6aae89684ad660721eb1fcedc27f572b669f95da91e4afe01b9bc5e6762801a36b0ac78fbbb5229b80
|
|
7
|
+
data.tar.gz: 7456155944b169f3a5c293c0d256b1a4200de22f24f1433f387475fdefedb9a5ad1e66776b0263eba9ea7be4de2e9fbd4315346c05cc1d0dfa76cadd94699199
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.1.4] - 2026-04-18
|
|
9
|
+
|
|
10
|
+
### Breaking Changes
|
|
11
|
+
|
|
12
|
+
- Removed `LlmApiCall.by_user(id)` and `LlmApiCall.by_feature(name)` convenience scopes. Use
|
|
13
|
+
`by_tag("user_id", id)`, `by_tag("feature", name)`, or `by_tags(...)` for filters.
|
|
14
|
+
- Removed `LlmApiCall#user_id` and `LlmApiCall#feature` tag accessors. Use
|
|
15
|
+
`parsed_tags["user_id"]` or `parsed_tags["feature"]` when reading stored tags.
|
|
16
|
+
- Removed `ReportData#cost_by_feature`. Use `ReportData#cost_by_tags.fetch("feature")` or
|
|
17
|
+
`LlmApiCall.cost_by_tag("feature")`.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- Add SQL-side `group_by_tag(key)` and `cost_by_tag(key)` aggregations across any attribution tag.
|
|
22
|
+
- Use generic tag breakdowns in reports instead of feature-specific report data.
|
|
23
|
+
|
|
8
24
|
## [0.1.3] - 2026-04-18
|
|
9
25
|
|
|
10
26
|
### Thread-safety, pricing UX, and internal hardening
|
|
@@ -17,7 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
17
33
|
|
|
18
34
|
- Warn on unknown keys in local prices files.
|
|
19
35
|
- Add `llm_cost_tracker:prices` generator for creating a local price override template.
|
|
20
|
-
- Document that budget
|
|
36
|
+
- Document that budget guardrails skip events with unknown pricing.
|
|
21
37
|
|
|
22
38
|
**Onboarding UX**
|
|
23
39
|
|
data/README.md
CHANGED
|
@@ -20,7 +20,7 @@ By model:
|
|
|
20
20
|
claude-sonnet-4-6 $31.200000
|
|
21
21
|
gemini-2.5-flash $14.120000
|
|
22
22
|
|
|
23
|
-
By feature:
|
|
23
|
+
By tag (feature):
|
|
24
24
|
chat $73.500000
|
|
25
25
|
summarizer $29.220000
|
|
26
26
|
translate $24.700000
|
|
@@ -104,7 +104,7 @@ OpenAI.configure do |config|
|
|
|
104
104
|
f.use :llm_cost_tracker, tags: -> {
|
|
105
105
|
{
|
|
106
106
|
user_id: Current.user&.id,
|
|
107
|
-
feature: Current.llm_feature || "
|
|
107
|
+
feature: Current.llm_feature || "chat"
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
end
|
|
@@ -220,7 +220,7 @@ config.unknown_pricing_behavior = :raise # fail fast with UnknownPricingError
|
|
|
220
220
|
config.unknown_pricing_behavior = :ignore # keep tracking tokens silently
|
|
221
221
|
```
|
|
222
222
|
|
|
223
|
-
When pricing is unknown, the event can still be recorded with token counts, but `cost` is `nil` and budget
|
|
223
|
+
When pricing is unknown, the event can still be recorded with token counts, but `cost` is `nil` and budget guardrails are skipped for that event. Use `prices_file` or `pricing_overrides` to ensure all production models are priced. Check this ActiveRecord query for a list of unpriced models in your data:
|
|
224
224
|
|
|
225
225
|
```ruby
|
|
226
226
|
LlmCostTracker::LlmApiCall.unknown_pricing.group(:model).count
|
|
@@ -289,7 +289,7 @@ end
|
|
|
289
289
|
|
|
290
290
|
Pre-request blocking needs `storage_backend = :active_record` because the middleware must query your stored monthly total before sending the request. With `:log` or `:custom` storage, `:raise` and the post-response part of `:block_requests` still work for the event being tracked.
|
|
291
291
|
|
|
292
|
-
`:block_requests` is a best-effort guardrail, not a transactional hard quota. In highly concurrent deployments, multiple workers can pass the preflight check at the same time before any of them records its final cost. The request that first pushes the month over budget is stored before the post-response `BudgetExceededError` is raised; later Faraday requests are blocked during preflight once the stored monthly total is exhausted. Use provider-side limits or a gateway-level quota if you need strict cross-process
|
|
292
|
+
`:block_requests` is a best-effort guardrail, not a transactional hard quota. In highly concurrent deployments, multiple workers can pass the preflight check at the same time before any of them records its final cost. The request that first pushes the month over budget is stored before the post-response `BudgetExceededError` is raised; later Faraday requests are blocked during preflight once the stored monthly total is exhausted. Use provider-side limits or a gateway-level quota if you need strict cross-process caps.
|
|
293
293
|
|
|
294
294
|
## Querying Costs (ActiveRecord)
|
|
295
295
|
|
|
@@ -332,6 +332,15 @@ LlmCostTracker::LlmApiCall.this_month.cost_by_model
|
|
|
332
332
|
LlmCostTracker::LlmApiCall.this_month.cost_by_provider
|
|
333
333
|
# => { "openai" => 8.20, "anthropic" => 4.25 }
|
|
334
334
|
|
|
335
|
+
# SQL-side cost breakdown by any tag key
|
|
336
|
+
calls = LlmCostTracker::LlmApiCall.this_month
|
|
337
|
+
calls.group_by_tag("feature").sum(:total_cost)
|
|
338
|
+
# => { "chat" => 7.10, "summarizer" => 1.10 }
|
|
339
|
+
|
|
340
|
+
# Convenience wrapper with "(untagged)" labels and float values
|
|
341
|
+
calls.cost_by_tag("feature")
|
|
342
|
+
# => { "chat" => 7.10, "summarizer" => 1.10 }
|
|
343
|
+
|
|
335
344
|
# Daily cost trend
|
|
336
345
|
LlmCostTracker::LlmApiCall.daily_costs(days: 7)
|
|
337
346
|
# => { "2026-04-10" => 1.5, "2026-04-11" => 2.3, ... }
|
|
@@ -340,19 +349,15 @@ LlmCostTracker::LlmApiCall.daily_costs(days: 7)
|
|
|
340
349
|
LlmCostTracker::LlmApiCall.with_latency.average_latency_ms
|
|
341
350
|
LlmCostTracker::LlmApiCall.this_month.latency_by_model
|
|
342
351
|
|
|
343
|
-
# Filter by
|
|
352
|
+
# Filter by one tag
|
|
344
353
|
LlmCostTracker::LlmApiCall.by_tag("feature", "chat").this_month.total_cost
|
|
345
354
|
|
|
346
|
-
# Filter by
|
|
355
|
+
# Filter by another tag
|
|
347
356
|
LlmCostTracker::LlmApiCall.by_tag("user_id", "42").today.total_cost
|
|
348
|
-
LlmCostTracker::LlmApiCall.by_user(42).today.total_cost
|
|
349
357
|
|
|
350
358
|
# Filter by multiple tags
|
|
351
359
|
LlmCostTracker::LlmApiCall.by_tags(user_id: 42, feature: "chat").this_month.total_cost
|
|
352
360
|
|
|
353
|
-
# Feature shortcut
|
|
354
|
-
LlmCostTracker::LlmApiCall.by_feature("summarizer").this_month.total_cost
|
|
355
|
-
|
|
356
361
|
# Find models without pricing
|
|
357
362
|
LlmCostTracker::LlmApiCall.unknown_pricing.group(:model).count
|
|
358
363
|
LlmCostTracker::LlmApiCall.with_cost.this_month.total_cost
|
|
@@ -481,7 +486,7 @@ This covers OpenRouter, DeepSeek, and private gateways that expose OpenAI-style
|
|
|
481
486
|
- Treat `:block_requests` as best-effort in concurrent systems, not a strict quota.
|
|
482
487
|
- Keep `unknown_pricing_behavior = :warn` or `:raise` until pricing overrides are complete.
|
|
483
488
|
- Add `pricing_overrides` for custom, fine-tuned, gateway-specific, or newly released models.
|
|
484
|
-
- Tag calls with `tenant_id`, `user_id`, and `feature
|
|
489
|
+
- Tag calls with useful business context such as `tenant_id`, `user_id`, and `feature`.
|
|
485
490
|
- Check `LlmCostTracker::LlmApiCall.unknown_pricing.group(:model).count` after deploys.
|
|
486
491
|
- Track `latency_ms` and watch `latency_by_model` for slow or degraded providers.
|
|
487
492
|
|
|
@@ -8,6 +8,10 @@ require_relative "tags_column"
|
|
|
8
8
|
|
|
9
9
|
module LlmCostTracker
|
|
10
10
|
class LlmApiCall < ActiveRecord::Base
|
|
11
|
+
TAG_KEY_PATTERN = /\A[\w.-]+\z/
|
|
12
|
+
|
|
13
|
+
private_constant :TAG_KEY_PATTERN
|
|
14
|
+
|
|
11
15
|
extend TagsColumn
|
|
12
16
|
include TagAccessors
|
|
13
17
|
|
|
@@ -16,8 +20,6 @@ module LlmCostTracker
|
|
|
16
20
|
# Scopes for querying
|
|
17
21
|
scope :by_provider, ->(provider) { where(provider: provider) }
|
|
18
22
|
scope :by_model, ->(model) { where(model: model) }
|
|
19
|
-
scope :by_user, ->(user_id) { by_tag("user_id", user_id) }
|
|
20
|
-
scope :by_feature, ->(feature) { by_tag("feature", feature) }
|
|
21
23
|
scope :with_cost, -> { where.not(total_cost: nil) }
|
|
22
24
|
scope :without_cost, -> { where(total_cost: nil) }
|
|
23
25
|
scope :unknown_pricing, -> { without_cost }
|
|
@@ -61,6 +63,17 @@ module LlmCostTracker
|
|
|
61
63
|
group(:provider).sum(:total_cost)
|
|
62
64
|
end
|
|
63
65
|
|
|
66
|
+
def self.group_by_tag(key)
|
|
67
|
+
group(Arel.sql(tag_group_expression(key)))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.cost_by_tag(key)
|
|
71
|
+
costs = group_by_tag(key).sum(:total_cost).each_with_object(Hash.new(0.0)) do |(tag_value, cost), grouped|
|
|
72
|
+
grouped[tag_label(tag_value)] += cost.to_f
|
|
73
|
+
end
|
|
74
|
+
costs.sort_by { |_label, cost| -cost }.to_h
|
|
75
|
+
end
|
|
76
|
+
|
|
64
77
|
def self.average_latency_ms
|
|
65
78
|
return nil unless latency_column?
|
|
66
79
|
|
|
@@ -85,5 +98,39 @@ module LlmCostTracker
|
|
|
85
98
|
.sum(:total_cost)
|
|
86
99
|
.transform_keys(&:to_s)
|
|
87
100
|
end
|
|
101
|
+
|
|
102
|
+
def self.tag_label(value)
|
|
103
|
+
value.nil? || value == "" ? "(untagged)" : value.to_s
|
|
104
|
+
end
|
|
105
|
+
private_class_method :tag_label
|
|
106
|
+
|
|
107
|
+
def self.tag_group_expression(key)
|
|
108
|
+
key = validated_tag_key(key)
|
|
109
|
+
column = "#{quoted_table_name}.#{connection.quote_column_name('tags')}"
|
|
110
|
+
|
|
111
|
+
case connection.adapter_name
|
|
112
|
+
when /postgres/i
|
|
113
|
+
json_column = tags_json_column? ? column : "(#{column})::jsonb"
|
|
114
|
+
"#{json_column}->>#{connection.quote(key)}"
|
|
115
|
+
when /mysql/i
|
|
116
|
+
"JSON_UNQUOTE(JSON_EXTRACT(#{column}, #{connection.quote(json_path(key))}))"
|
|
117
|
+
else
|
|
118
|
+
"json_extract(#{column}, #{connection.quote(json_path(key))})"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
private_class_method :tag_group_expression
|
|
122
|
+
|
|
123
|
+
def self.validated_tag_key(key)
|
|
124
|
+
key = key.to_s
|
|
125
|
+
return key if key.match?(TAG_KEY_PATTERN)
|
|
126
|
+
|
|
127
|
+
raise ArgumentError, "invalid tag key: #{key.inspect}"
|
|
128
|
+
end
|
|
129
|
+
private_class_method :validated_tag_key
|
|
130
|
+
|
|
131
|
+
def self.json_path(key)
|
|
132
|
+
"$.\"#{key}\""
|
|
133
|
+
end
|
|
134
|
+
private_class_method :json_path
|
|
88
135
|
end
|
|
89
136
|
end
|
|
@@ -15,12 +15,13 @@ module LlmCostTracker
|
|
|
15
15
|
:unknown_pricing_count,
|
|
16
16
|
:cost_by_provider,
|
|
17
17
|
:cost_by_model,
|
|
18
|
-
:
|
|
18
|
+
:cost_by_tags,
|
|
19
19
|
:top_calls
|
|
20
20
|
)
|
|
21
21
|
|
|
22
22
|
ReportData.const_set(:DEFAULT_DAYS, 30)
|
|
23
23
|
ReportData.const_set(:TOP_LIMIT, 5)
|
|
24
|
+
ReportData.const_set(:DEFAULT_TAG_BREAKDOWNS, %w[feature].freeze)
|
|
24
25
|
|
|
25
26
|
class << ReportData
|
|
26
27
|
def build(days: ReportData::DEFAULT_DAYS, now: Time.now.utc)
|
|
@@ -39,7 +40,7 @@ module LlmCostTracker
|
|
|
39
40
|
unknown_pricing_count: scope.where(total_cost: nil).count,
|
|
40
41
|
cost_by_provider: cost_by(scope, :provider),
|
|
41
42
|
cost_by_model: cost_by(scope, :model),
|
|
42
|
-
|
|
43
|
+
cost_by_tags: cost_by_tags(scope, ReportData::DEFAULT_TAG_BREAKDOWNS),
|
|
43
44
|
top_calls: top_calls(scope)
|
|
44
45
|
)
|
|
45
46
|
end
|
|
@@ -65,12 +66,8 @@ module LlmCostTracker
|
|
|
65
66
|
scope.group(column).sum(:total_cost).transform_values(&:to_f).sort_by { |_name, cost| -cost }
|
|
66
67
|
end
|
|
67
68
|
|
|
68
|
-
def
|
|
69
|
-
|
|
70
|
-
scope.select(:id, :tags, :total_cost).find_each do |call|
|
|
71
|
-
costs[call.feature || "(untagged)"] += call.total_cost.to_f
|
|
72
|
-
end
|
|
73
|
-
costs.sort_by { |_feature, cost| -cost }
|
|
69
|
+
def cost_by_tags(scope, keys)
|
|
70
|
+
keys.to_h { |key| [key, scope.cost_by_tag(key).to_a] }
|
|
74
71
|
end
|
|
75
72
|
|
|
76
73
|
def top_calls(scope)
|
|
@@ -13,7 +13,7 @@ module LlmCostTracker
|
|
|
13
13
|
append_summary(lines)
|
|
14
14
|
append_cost_section(lines, "By provider", @data.cost_by_provider)
|
|
15
15
|
append_cost_section(lines, "By model", @data.cost_by_model)
|
|
16
|
-
|
|
16
|
+
append_tag_sections(lines)
|
|
17
17
|
append_top_calls(lines)
|
|
18
18
|
lines.join("\n")
|
|
19
19
|
end
|
|
@@ -37,6 +37,12 @@ module LlmCostTracker
|
|
|
37
37
|
end
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
def append_tag_sections(lines)
|
|
41
|
+
@data.cost_by_tags.each do |tag_key, rows|
|
|
42
|
+
append_cost_section(lines, "By tag (#{tag_key})", rows)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
40
46
|
def append_top_calls(lines)
|
|
41
47
|
lines << ""
|
|
42
48
|
lines << "Top expensive calls:"
|
|
@@ -27,7 +27,7 @@ module LlmCostTracker
|
|
|
27
27
|
def warn_missing(model)
|
|
28
28
|
Logging.warn(
|
|
29
29
|
"No pricing configured for model #{model.inspect}. " \
|
|
30
|
-
"Cost and budget
|
|
30
|
+
"Cost and budget guardrails will be skipped for this event. " \
|
|
31
31
|
"Add a pricing_overrides entry or set unknown_pricing_behavior."
|
|
32
32
|
)
|
|
33
33
|
end
|
data/llm_cost_tracker.gemspec
CHANGED
|
@@ -12,7 +12,7 @@ Gem::Specification.new do |spec|
|
|
|
12
12
|
spec.description = "Tracks token usage and estimated costs for OpenAI, Anthropic, Google Gemini, " \
|
|
13
13
|
"OpenRouter, DeepSeek, and OpenAI-compatible calls. " \
|
|
14
14
|
"Works as Faraday middleware for Ruby clients, with ActiveRecord storage, " \
|
|
15
|
-
"per-user/per-feature attribution,
|
|
15
|
+
"per-user/per-feature attribution, and budget guardrails."
|
|
16
16
|
spec.homepage = "https://github.com/sergey-homenko/llm_cost_tracker"
|
|
17
17
|
spec.license = "MIT"
|
|
18
18
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: llm_cost_tracker
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sergii Khomenko
|
|
@@ -142,8 +142,8 @@ dependencies:
|
|
|
142
142
|
version: '3.0'
|
|
143
143
|
description: Tracks token usage and estimated costs for OpenAI, Anthropic, Google
|
|
144
144
|
Gemini, OpenRouter, DeepSeek, and OpenAI-compatible calls. Works as Faraday middleware
|
|
145
|
-
for Ruby clients, with ActiveRecord storage, per-user/per-feature attribution,
|
|
146
|
-
|
|
145
|
+
for Ruby clients, with ActiveRecord storage, per-user/per-feature attribution, and
|
|
146
|
+
budget guardrails.
|
|
147
147
|
email:
|
|
148
148
|
- sergey@mm.st
|
|
149
149
|
executables: []
|