llm_cost_tracker 0.7.1 → 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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +16 -9
  4. data/app/models/llm_cost_tracker/ledger/call.rb +1 -1
  5. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +1 -1
  6. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +9 -9
  7. data/lib/llm_cost_tracker/capture/stream_collector.rb +11 -4
  8. data/lib/llm_cost_tracker/capture/stream_tracker.rb +1 -1
  9. data/lib/llm_cost_tracker/configuration.rb +5 -1
  10. data/lib/llm_cost_tracker/integrations/anthropic.rb +25 -8
  11. data/lib/llm_cost_tracker/integrations/openai.rb +4 -4
  12. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +4 -10
  13. data/lib/llm_cost_tracker/ledger/rollups.rb +7 -7
  14. data/lib/llm_cost_tracker/ledger/store.rb +22 -13
  15. data/lib/llm_cost_tracker/ledger/tags/query.rb +5 -5
  16. data/lib/llm_cost_tracker/ledger/tags/sql.rb +8 -7
  17. data/lib/llm_cost_tracker/middleware/faraday.rb +56 -13
  18. data/lib/llm_cost_tracker/parsers/anthropic.rb +35 -13
  19. data/lib/llm_cost_tracker/parsers/base.rb +2 -2
  20. data/lib/llm_cost_tracker/parsers/gemini.rb +39 -13
  21. data/lib/llm_cost_tracker/parsers/openai.rb +27 -5
  22. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +14 -4
  23. data/lib/llm_cost_tracker/parsers/openai_usage.rb +41 -13
  24. data/lib/llm_cost_tracker/prices.json +316 -32
  25. data/lib/llm_cost_tracker/pricing/effective_prices.rb +23 -17
  26. data/lib/llm_cost_tracker/pricing/explainer.rb +17 -11
  27. data/lib/llm_cost_tracker/pricing/lookup.rb +44 -22
  28. data/lib/llm_cost_tracker/pricing/sync.rb +19 -3
  29. data/lib/llm_cost_tracker/tracker.rb +6 -4
  30. data/lib/llm_cost_tracker/version.rb +1 -1
  31. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fbf918d9a4886e24ba99f93dc1125e016e50b45208437dd3254adda76f58033a
4
- data.tar.gz: 8a22ccfa517549f55d2a302287660a8a5d7faf03568e80c253e6024ad4988743
3
+ metadata.gz: 6950dae400eac9294a57a0ba2fd2bce7977658837962eafffc3836fa4ab9bd2b
4
+ data.tar.gz: c398f5271d3d0fa53cb27e1206e418e0242fb9dff73ed2c405903b92dfaf8a48
5
5
  SHA512:
6
- metadata.gz: 4ef6bc278f6f98ce37a91e515e8b4a004aaff5f573fc8580e312e53b903d1e7700b2cd58b9bea72fc0ec904010cba43fb0209e7993c31bb69205b42564554884
7
- data.tar.gz: 4c9a193d16bb5e8bfa58aaefb6fb9b7d98a632a8e19203927c3fd29b957da6088af71caf5c14f5f2b7452660f78001d7898745f8d7611a4fd6e94311b723bc71
6
+ metadata.gz: c52638e31e7eb0f46308312339bd40cfce87227a8c7ec77c94b3af08ffc931c3cffb9566f2ce15ec70a87700084e5e9bb6d05fe670028b57a12066af4a9ebaf6
7
+ data.tar.gz: 12da45f4cd8c485bd6fde5f9376bdfa2c8e618abd41e7be11c022878ec005348ca5d98578216170f1b7105dcc9e2c0c4b037cb5394e2085e2526e04ee8d5a885
data/CHANGELOG.md CHANGED
@@ -4,6 +4,27 @@ 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
+
13
+ ## [0.7.2] - 2026-05-01
14
+
15
+ ### Added
16
+
17
+ - Groq auto-detection, price scraping, and bundled production text model prices.
18
+
19
+ ### Changed
20
+
21
+ - Bundled prices refreshed from official provider pricing as of 2026-05-01.
22
+ - Bundled prices now include OpenAI Flex/Priority/regional processing, Gemini Flex/Priority, and Anthropic fast/data residency rates.
23
+
24
+ ### Fixed
25
+
26
+ - Streaming capture now snapshots tags when the stream starts.
27
+
7
28
  ## [0.7.1] - 2026-04-30
8
29
 
9
30
  ### Changed
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:
@@ -35,7 +39,7 @@ Drop this into `config/initializers/llm_cost_tracker.rb`:
35
39
 
36
40
  ```ruby
37
41
  LlmCostTracker.configure do |config|
38
- config.default_tags = -> { { environment: Rails.env } }
42
+ config.default_tags = -> { { environment: Rails.env } }
39
43
  config.instrument :openai
40
44
  end
41
45
  ```
@@ -78,7 +82,7 @@ Drop-in for RubyLLM and the official `openai` and `anthropic` gems. `config.inst
78
82
 
79
83
  ```ruby
80
84
  LlmCostTracker.configure do |config|
81
- config.instrument :openai # or :anthropic / :ruby_llm
85
+ config.instrument :openai # or :anthropic / :ruby_llm
82
86
  end
83
87
 
84
88
  LlmCostTracker.with_tags(feature: "support_chat") do
@@ -98,7 +102,7 @@ This patches **only** RubyLLM and the official Ruby SDKs. `ruby-openai` (alexrud
98
102
 
99
103
  ### 2. Faraday middleware
100
104
 
101
- For `ruby-openai`, the Gemini REST API, custom Faraday clients, or anything OpenAI-compatible (OpenRouter, DeepSeek, LiteLLM proxies):
105
+ For `ruby-openai`, the Gemini REST API, custom Faraday clients, or anything OpenAI-compatible (OpenRouter, DeepSeek, Groq, LiteLLM proxies):
102
106
 
103
107
  ```ruby
104
108
  conn = Faraday.new(url: "https://api.openai.com") do |f|
@@ -137,13 +141,15 @@ For streaming the same way, `track_stream` accepts a block, parses provider even
137
141
  Tags answer the only question that matters in attribution: which feature, which user, which job, which tenant. They're free-form strings, stored as JSONB on PostgreSQL or JSON on MySQL, and queryable from both Ruby and the dashboard.
138
142
 
139
143
  ```ruby
140
- LlmCostTracker.with_tags(user_id: current_user.id, feature: "support_chat", trace_id: request.uuid) do
144
+ LlmCostTracker.with_tags(user_id: current_user.id, feature: "support_chat") do
141
145
  client.chat(parameters: { model: "gpt-4o", messages: [...] })
142
146
  end
143
147
  ```
144
148
 
145
149
  `with_tags` is thread- and fiber-isolated, so concurrent requests in Puma or jobs in Sidekiq don't bleed into each other. A `default_tags` callable on configuration runs on every event for things you always want — `environment`, `region`, deployment SHA. Explicit tags passed to `track` win over scoped tags, scoped tags win over defaults.
146
150
 
151
+ Streaming capture snapshots tags when the stream starts, so attribution survives delayed or cross-thread stream consumption.
152
+
147
153
  What you put in tags is **your** input — they're queryable strings. Don't put prompts, completions, emails, or secrets there. Use IDs.
148
154
 
149
155
  ## Pricing
@@ -184,7 +190,7 @@ Budgets are guardrails, not transactional caps:
184
190
  config.monthly_budget = 500.00
185
191
  config.daily_budget = 50.00
186
192
  config.per_call_budget = 2.00
187
- config.budget_exceeded_behavior = :block_requests # or :notify, :raise
193
+ config.budget_exceeded_behavior = :block_requests # or :notify, :raise
188
194
  config.on_budget_exceeded = ->(data) { SlackNotifier.notify("#alerts", "...") }
189
195
  ```
190
196
 
@@ -233,6 +239,7 @@ Auth is your job. Examples for basic auth and Devise: [`docs/dashboard.md`](docs
233
239
  | Google Gemini | Yes | Gemini 2.5 Pro/Flash/Flash-Lite, 2.0 Flash/Flash-Lite |
234
240
  | OpenRouter | Yes | OpenAI-compatible usage; provider-prefixed model IDs are normalized |
235
241
  | DeepSeek | Yes | OpenAI-compatible usage; add `pricing_overrides` for DeepSeek-specific rates |
242
+ | Groq | Yes | OpenAI-compatible usage with bundled prices for production text models |
236
243
  | Other OpenAI-compatible hosts | Configurable | Register the host via `config.openai_compatible_providers` |
237
244
  | Anything else | Manual | Use `LlmCostTracker.track` / `track_stream` |
238
245
 
@@ -275,10 +282,10 @@ is still brief.
275
282
 
276
283
  ```bash
277
284
  bundle install
278
- bin/check # rubocop + rspec + coverage gate
285
+ bin/check # rubocop + rspec + coverage gate
279
286
  ```
280
287
 
281
- Architecture rules and conventions for contributions live in [`AGENTS.md`](AGENTS.md) and [`docs/architecture.md`](docs/architecture.md).
288
+ Architecture rules and conventions for contributions live in [`docs/architecture.md`](docs/architecture.md).
282
289
 
283
290
  ## License
284
291
 
@@ -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)"
@@ -10,13 +10,15 @@ module LlmCostTracker
10
10
  class StreamCollector
11
11
  attr_reader :provider
12
12
 
13
- def initialize(provider:, model:, latency_ms: nil, provider_response_id: nil, pricing_mode: nil, metadata: {})
13
+ def initialize(provider:, model:, latency_ms: nil, provider_response_id: nil, pricing_mode: nil, metadata: {},
14
+ context_tags: nil)
14
15
  @provider = provider.to_s
15
16
  @model = model
16
17
  @latency_ms = latency_ms
17
18
  @provider_response_id = provider_response_id
18
19
  @pricing_mode = pricing_mode
19
20
  @metadata = (metadata || {}).deep_dup
21
+ @context_tags = (context_tags || LlmCostTracker::Tags::Context.tags).deep_dup
20
22
  @events = []
21
23
  @captured_bytes = 0
22
24
  @overflowed = false
@@ -85,7 +87,8 @@ module LlmCostTracker
85
87
  latency_ms: @latency_ms,
86
88
  provider_response_id: @provider_response_id,
87
89
  pricing_mode: @pricing_mode,
88
- metadata: @metadata.deep_dup
90
+ metadata: @metadata.deep_dup,
91
+ context_tags: @context_tags.deep_dup
89
92
  }
90
93
  end
91
94
 
@@ -98,7 +101,8 @@ module LlmCostTracker
98
101
  latency_ms: snapshot[:latency_ms] ||
99
102
  ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at) * 1000).round,
100
103
  pricing_mode: snapshot[:pricing_mode],
101
- metadata: (errored ? { stream_errored: true } : {}).merge(snapshot[:metadata])
104
+ metadata: (errored ? { stream_errored: true } : {}).merge(snapshot[:metadata]),
105
+ context_tags: snapshot[:context_tags]
102
106
  )
103
107
  end
104
108
 
@@ -114,7 +118,10 @@ module LlmCostTracker
114
118
  return build_from_explicit_usage(snapshot) if snapshot[:explicit_usage]
115
119
  return build_unknown_usage(snapshot) if snapshot[:overflowed]
116
120
 
117
- capture = Parsers.find_for_provider(@provider)&.parse_stream(nil, nil, 200, snapshot[:events])
121
+ capture = Parsers.find_for_provider(@provider)&.parse_stream(
122
+ response_status: 200,
123
+ events: snapshot[:events]
124
+ )
118
125
  if capture
119
126
  model = present_model(capture.model) || present_model(snapshot[:model]) || UsageCapture::UNKNOWN_MODEL
120
127
  return capture.with(provider: @provider, model: model)
@@ -8,7 +8,7 @@ require_relative "../logging"
8
8
  module LlmCostTracker
9
9
  module Capture
10
10
  class StreamTracker
11
- def initialize(stream, collector, active, finish)
11
+ def initialize(stream:, collector:, active:, finish: nil)
12
12
  @stream = stream
13
13
  @collector = collector
14
14
  @active = active
@@ -8,7 +8,11 @@ module LlmCostTracker
8
8
  class Configuration
9
9
  include ConfigurationInstrumentation
10
10
 
11
- OPENAI_COMPATIBLE_PROVIDERS = { "openrouter.ai" => "openrouter", "api.deepseek.com" => "deepseek" }.freeze
11
+ OPENAI_COMPATIBLE_PROVIDERS = {
12
+ "openrouter.ai" => "openrouter",
13
+ "api.deepseek.com" => "deepseek",
14
+ "api.groq.com" => "groq"
15
+ }.freeze
12
16
 
13
17
  BUDGET_EXCEEDED_BEHAVIORS = %i[notify raise block_requests].freeze
14
18
  UNKNOWN_PRICING_BEHAVIORS = %i[ignore warn raise].freeze
@@ -49,9 +49,8 @@ module LlmCostTracker
49
49
  capture: UsageCapture.build(
50
50
  provider: "anthropic",
51
51
  model: object_value(message, :model) || request[:model],
52
- pricing_mode: object_value(usage, :service_tier) || object_value(message, :service_tier) ||
53
- request[:service_tier],
54
- token_usage: token_usage(usage, input_tokens, output_tokens),
52
+ pricing_mode: pricing_mode(message: message, request: request, usage: usage),
53
+ token_usage: token_usage(usage: usage, input_tokens: input_tokens, output_tokens: output_tokens),
55
54
  usage_source: :sdk_response,
56
55
  provider_response_id: object_value(message, :id)
57
56
  ),
@@ -60,7 +59,7 @@ module LlmCostTracker
60
59
  end
61
60
  end
62
61
 
63
- def token_usage(usage, input_tokens, output_tokens)
62
+ def token_usage(usage:, input_tokens:, output_tokens:)
64
63
  cache_write_1h = object_dig(usage, :cache_creation, :ephemeral_1h_input_tokens).to_i
65
64
  cache_write_5m = object_dig(usage, :cache_creation, :ephemeral_5m_input_tokens)
66
65
  cache_write = if cache_write_5m.nil?
@@ -84,14 +83,32 @@ module LlmCostTracker
84
83
  )
85
84
  end
86
85
 
86
+ def pricing_mode(message:, request:, usage:)
87
+ modes = [
88
+ Pricing.normalize_mode(object_value(usage, :speed) || object_value(message, :speed) || request[:speed]),
89
+ Pricing.normalize_mode(
90
+ object_value(usage, :service_tier) || object_value(message, :service_tier) || request[:service_tier]
91
+ )
92
+ ]
93
+ modes << "data_residency" if inference_geo(message: message, request: request, usage: usage).to_s == "us"
94
+ modes = modes.compact.uniq
95
+ modes.empty? ? nil : modes.join("_")
96
+ end
97
+
98
+ def inference_geo(message:, request:, usage:)
99
+ object_value(usage, :inference_geo) ||
100
+ object_value(message, :inference_geo) ||
101
+ request[:inference_geo]
102
+ end
103
+
87
104
  def track_stream(stream, collector:)
88
105
  return stream unless active?
89
106
 
90
107
  LlmCostTracker::Capture::StreamTracker.new(
91
- stream,
92
- collector,
93
- -> { active? },
94
- ->(errored:) { finish_stream(collector, errored: errored) }
108
+ stream: stream,
109
+ collector: collector,
110
+ active: -> { active? },
111
+ finish: ->(errored:) { finish_stream(collector, errored: errored) }
95
112
  ).wrap
96
113
  end
97
114
 
@@ -90,10 +90,10 @@ module LlmCostTracker
90
90
  return stream unless active?
91
91
 
92
92
  LlmCostTracker::Capture::StreamTracker.new(
93
- stream,
94
- collector,
95
- -> { active? },
96
- ->(errored:) { finish_stream(collector, errored: errored) }
93
+ stream: stream,
94
+ collector: collector,
95
+ active: -> { active? },
96
+ finish: ->(errored:) { finish_stream(collector, errored: errored) }
97
97
  ).wrap
98
98
  end
99
99
 
@@ -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