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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0d1192ed209333057bd2522173d05530b4f45c6bb63242189c75354a83b5a746
4
- data.tar.gz: 486555221b66a0da6cb867d207fb76349f0d56a23da623418da35aab7672875f
3
+ metadata.gz: cb8f35c3d464bc7adea3b8376055e23c2ff704d815247de8ec40c82cbab41d29
4
+ data.tar.gz: ec3fecd6c98f9c3c9664f12db2ca411440d698f5743d2ac77827e86f04fca380
5
5
  SHA512:
6
- metadata.gz: 8e74531effe3fc425de0384c13c7717c54b8be8d683493c92665b356ed8142d2629c9ce555f5fff703c2cd4a676d69e82efb39de767fc75fdbdcabdca9289f2c
7
- data.tar.gz: 38e9744e157248e67bebcdd818b356c06b923d75a216b406f96f0b1b10368d4a118cbb9593461e2a19d19bdb5b10fc115c2f871d15fcf8669e656ad8ea8e034a
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 enforcement skips events with unknown pricing.
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 || "openai"
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 enforcement is 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:
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 enforcement.
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 feature
352
+ # Filter by one tag
344
353
  LlmCostTracker::LlmApiCall.by_tag("feature", "chat").this_month.total_cost
345
354
 
346
- # Filter by user
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` where possible.
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
- :cost_by_feature,
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
- cost_by_feature: cost_by_feature(scope),
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 cost_by_feature(scope)
69
- costs = Hash.new(0.0)
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
- append_cost_section(lines, "By feature", @data.cost_by_feature)
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:"
@@ -11,13 +11,5 @@ module LlmCostTracker
11
11
  rescue JSON::ParserError
12
12
  {}
13
13
  end
14
-
15
- def feature
16
- parsed_tags["feature"]
17
- end
18
-
19
- def user_id
20
- parsed_tags["user_id"]
21
- end
22
14
  end
23
15
  end
@@ -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 enforcement will be skipped for this event. " \
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.1.3"
4
+ VERSION = "0.1.4"
5
5
  end
@@ -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, budget alerts, and budget enforcement."
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.3
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, budget
146
- alerts, and budget enforcement.
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: []