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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a5d394087953583d254479b4fe162adbb5b5a0f4de09c535428d514a6c623e76
4
- data.tar.gz: b3269262ceec2e1f622780e3e44ac33adb1df703e077bee43fcddd7c251a21dc
3
+ metadata.gz: 6950dae400eac9294a57a0ba2fd2bce7977658837962eafffc3836fa4ab9bd2b
4
+ data.tar.gz: c398f5271d3d0fa53cb27e1206e418e0242fb9dff73ed2c405903b92dfaf8a48
5
5
  SHA512:
6
- metadata.gz: 93ce84108bea091e89df70b28a192e280a1e3de92bdb14b8d47ca4057527ebcd4ec1ffa25fc37fc5216df4804539f5b1b9483d6f1fb1afdd292fd19e836431e5
7
- data.tar.gz: f69fed55512f322118e93493b9069821e3cd9b372940b6163b7f80578afc3b95799126b91178b25125c7eba871ebe8b3a8fd32e607f8103649c1f3d4d606923d
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 what your LLM calls actually cost.
3
+ A Rails-native ledger for estimating LLM API spend.
4
4
 
5
5
  [![Gem Version](https://img.shields.io/gem/v/llm_cost_tracker.svg)](https://rubygems.org/gems/llm_cost_tracker)
6
6
  [![CI](https://github.com/sergey-homenko/llm_cost_tracker/actions/workflows/ruby.yml/badge.svg)](https://github.com/sergey-homenko/llm_cost_tracker/actions)
7
7
  [![codecov](https://codecov.io/gh/sergey-homenko/llm_cost_tracker/branch/main/graph/badge.svg)](https://codecov.io/gh/sergey-homenko/llm_cost_tracker)
8
8
 
9
- If you have OpenAI, Anthropic, or Gemini in production and someone keeps asking "where did that bill come from?", this gem records every call 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.
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
  ![Dashboard overview](docs/dashboard-overview.png)
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:
@@ -38,7 +38,7 @@ module LlmCostTracker
38
38
  end
39
39
 
40
40
  def self.by_tags(tags)
41
- Ledger::Tags::Query.apply(self, tags)
41
+ Ledger::Tags::Query.apply(tags)
42
42
  end
43
43
  end
44
44
  end
@@ -48,7 +48,7 @@ module LlmCostTracker
48
48
  end
49
49
 
50
50
  def tag_value_expression(key, table_name: quoted_table_name)
51
- Ledger::Tags::Sql.value_expression(self, key, table_name: table_name)
51
+ Ledger::Tags::Sql.value_expression(key, table_name: table_name)
52
52
  end
53
53
 
54
54
  private
@@ -8,8 +8,7 @@ module LlmCostTracker
8
8
  class DataQuality
9
9
  class << self
10
10
  def call(scope: LlmCostTracker::Ledger::Call.all)
11
- model = scope.klass
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, model:)
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(model)} AS tagged_calls_count",
78
- "COUNT(*) - #{tagged_calls_sql(model)} AS untagged_calls_count",
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(model)
131
- table = model.quoted_table_name
132
- column = "#{table}.#{model.connection.quote_column_name('tags')}"
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?(model.connection)
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(model)
10
- new(model).call
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} = #{model.quoted_table_name}.#{total_cost} + excluded.#{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
- model.connection
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(Period::Total),
18
+ on_duplicate: Ledger::Rollups::UpsertSql.call,
19
19
  record_timestamps: true,
20
- unique_by: unique_by(Period::Total, %i[period period_start])
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(Period::Total),
30
+ on_duplicate: Ledger::Rollups::UpsertSql.call,
31
31
  record_timestamps: true,
32
- unique_by: unique_by(Period::Total, %i[period period_start])
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 unique_by(model, column)
80
- return unless model.connection.supports_insert_conflict_target?
79
+ def period_totals_unique_by
80
+ return unless Period::Total.connection.supports_insert_conflict_target?
81
81
 
82
- column
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
- model = LlmCostTracker::Ledger::Call
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
- model.insert_all!(rows, record_timestamps: true, returning: false)
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.merge(usage).merge(Pricing.stored_cost_attributes(event.cost || {}))
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 new_events(model, events)
48
- existing_ids = model.where(event_id: events.map(&:event_id)).pluck(:event_id).to_set
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 stringify_tag_value(value)
53
- return value.transform_values { |nested| stringify_tag_value(nested) } if value.is_a?(Hash)
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(model, tags)
12
+ def apply(tags)
13
13
  normalized_tags = (tags || {}).to_h.transform_keys(&:to_s).transform_values(&:to_s)
14
- return model.all if normalized_tags.empty?
14
+ return Ledger::Call.all if normalized_tags.empty?
15
15
 
16
- connection = model.connection
16
+ connection = Ledger::Call.connection
17
17
  json = normalized_tags.to_json
18
18
 
19
19
  if Schema::Adapter.postgresql?(connection)
20
- model.where("tags @> ?::jsonb", json)
20
+ Ledger::Call.where("tags @> ?::jsonb", json)
21
21
  else
22
- model.where("JSON_CONTAINS(tags, ?)", json)
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(model, key, table_name:)
11
+ def value_expression(key, table_name:)
12
12
  key = LlmCostTracker::Tags::Key.validate!(key)
13
- column = "#{table_name}.#{model.connection.quote_column_name('tags')}"
13
+ connection = Ledger::Call.connection
14
+ column = "#{table_name}.#{connection.quote_column_name('tags')}"
14
15
 
15
- if Ledger::Schema::Adapter.postgresql?(model.connection)
16
- "#{column}->>#{model.connection.quote(key)}"
17
- elsif Ledger::Schema::Adapter.mysql?(model.connection)
18
- "JSON_UNQUOTE(JSON_EXTRACT(#{column}, #{model.connection.quote(json_path(key))}))"
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!(model.connection)
21
+ Ledger::Schema::Adapter.ensure_supported!(connection)
21
22
  end
22
23
  end
23
24
 
@@ -100,7 +100,7 @@ module LlmCostTracker
100
100
  end
101
101
 
102
102
  def output_tokens(usage)
103
- usage["candidatesTokenCount"].to_i + usage["thoughtsTokenCount"].to_i
103
+ usage["candidatesTokenCount"].to_i
104
104
  end
105
105
 
106
106
  def total_tokens(usage:, cache_read:, tool_use_prompt:)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.7.2"
4
+ VERSION = "0.7.3"
5
5
  end
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.7.2
4
+ version: 0.7.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergii Khomenko