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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +16 -9
- 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/capture/stream_collector.rb +11 -4
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +1 -1
- data/lib/llm_cost_tracker/configuration.rb +5 -1
- data/lib/llm_cost_tracker/integrations/anthropic.rb +25 -8
- data/lib/llm_cost_tracker/integrations/openai.rb +4 -4
- 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/middleware/faraday.rb +56 -13
- data/lib/llm_cost_tracker/parsers/anthropic.rb +35 -13
- data/lib/llm_cost_tracker/parsers/base.rb +2 -2
- data/lib/llm_cost_tracker/parsers/gemini.rb +39 -13
- data/lib/llm_cost_tracker/parsers/openai.rb +27 -5
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +14 -4
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +41 -13
- data/lib/llm_cost_tracker/prices.json +316 -32
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +23 -17
- data/lib/llm_cost_tracker/pricing/explainer.rb +17 -11
- data/lib/llm_cost_tracker/pricing/lookup.rb +44 -22
- data/lib/llm_cost_tracker/pricing/sync.rb +19 -3
- data/lib/llm_cost_tracker/tracker.rb +6 -4
- data/lib/llm_cost_tracker/version.rb +1 -1
- metadata +2 -2
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,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
|
|
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:
|
|
@@ -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
|
|
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
|
|
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"
|
|
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
|
|
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
|
|
285
|
+
bin/check # rubocop + rspec + coverage gate
|
|
279
286
|
```
|
|
280
287
|
|
|
281
|
-
Architecture rules and conventions for contributions live in [`
|
|
288
|
+
Architecture rules and conventions for contributions live in [`docs/architecture.md`](docs/architecture.md).
|
|
282
289
|
|
|
283
290
|
## License
|
|
284
291
|
|
|
@@ -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)"
|
|
@@ -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(
|
|
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
|
|
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 = {
|
|
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:
|
|
53
|
-
|
|
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
|
|
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
|
|
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
|
|