llm_cost_tracker 0.4.0 → 0.4.1
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 +16 -0
- data/README.md +15 -11
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +46 -55
- data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +81 -0
- data/lib/llm_cost_tracker/budget.rb +34 -37
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +38 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +1 -2
- data/lib/llm_cost_tracker/parsers/anthropic.rb +17 -49
- data/lib/llm_cost_tracker/parsers/base.rb +80 -0
- data/lib/llm_cost_tracker/parsers/gemini.rb +12 -35
- data/lib/llm_cost_tracker/parsers/openai.rb +1 -6
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +6 -15
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +8 -30
- data/lib/llm_cost_tracker/parsers/registry.rb +17 -2
- data/lib/llm_cost_tracker/storage/active_record_rollups.rb +47 -27
- data/lib/llm_cost_tracker/storage/active_record_store.rb +4 -0
- data/lib/llm_cost_tracker/tags_column.rb +62 -24
- data/lib/llm_cost_tracker/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d2cdd5f30c6fbd8c0168549b0853e9d8bc54586e60921733ce11a89a1d86078c
|
|
4
|
+
data.tar.gz: c91384579df6acdeb04d24b62f8bf916040f98156fd2bc882c94afc534f7dba5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 88d61d6714101ee9e8162814f5527bde487eced83663d86a1f938b77bcee1e4fcb4db2c4dde763720a828368a779a8677da57ecf10b2fadec78a959e6fdce6a7
|
|
7
|
+
data.tar.gz: d2d2bb097058507c06c1ea330a0c7d5a63d2e92b824fd8e4e7640a052dff100038eadb2e097edb4500457c382368080414c5261a2cc0a4f69436ae2234fd420d
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,22 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.4.1] - 2026-04-24
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Batched ActiveRecord period rollup writes and budget total reads.
|
|
12
|
+
- Memoized schema capability checks and refreshed them on `reset_column_information`.
|
|
13
|
+
- Install migration adds `[:model, :tracked_at]` composite index and drops redundant single-column `:provider` / `:model` indexes.
|
|
14
|
+
- Data Quality now reads counters and usage sums through one aggregate query.
|
|
15
|
+
- Parser URL matching, stream-event extraction, and custom parser registration now share a smaller base/registry extension surface.
|
|
16
|
+
- Added cookbook recipes for `ruby-openai`, `anthropic-sdk-ruby`, `gemini-ai`, `langchainrb`, Azure OpenAI, and LiteLLM proxy setups.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- `llm_cost_tracker:add_period_totals` now imports legacy monthly rollups and backfills before adding the unique index.
|
|
21
|
+
- Budget docs now describe `:notify` across monthly, daily, and per-call budgets.
|
|
22
|
+
|
|
7
23
|
## [0.4.0] - 2026-04-24
|
|
8
24
|
|
|
9
25
|
### Changed
|
data/README.md
CHANGED
|
@@ -161,6 +161,8 @@ end
|
|
|
161
161
|
|
|
162
162
|
Run `bin/rails g llm_cost_tracker:add_streaming` once on existing installs to add the `stream` and `usage_source` columns. Run `bin/rails g llm_cost_tracker:add_provider_response_id` to persist provider-issued response IDs. Run `bin/rails g llm_cost_tracker:add_usage_breakdown` to add cache-read, cache-write, hidden-output, and pricing-mode columns.
|
|
163
163
|
|
|
164
|
+
More client-specific snippets live in [`docs/cookbook.md`](docs/cookbook.md).
|
|
165
|
+
|
|
164
166
|
### Manual tracking
|
|
165
167
|
|
|
166
168
|
```ruby
|
|
@@ -269,7 +271,7 @@ config.per_call_budget = 1.00
|
|
|
269
271
|
config.budget_exceeded_behavior = :block_requests
|
|
270
272
|
```
|
|
271
273
|
|
|
272
|
-
- `:notify` — fire `on_budget_exceeded` after an event pushes the
|
|
274
|
+
- `:notify` — fire `on_budget_exceeded` after an event pushes the monthly, daily, or per-call budget over the limit.
|
|
273
275
|
- `:raise` — record the event, then raise `BudgetExceededError`.
|
|
274
276
|
- `:block_requests` — block preflight when the stored monthly or daily total is already over budget; still raises post-response on the event that crosses the line. Needs `:active_record` storage for preflight.
|
|
275
277
|
|
|
@@ -456,21 +458,23 @@ Configured hosts are parsed using the OpenAI-compatible usage shape (`prompt_tok
|
|
|
456
458
|
For providers with a non-OpenAI usage shape:
|
|
457
459
|
|
|
458
460
|
```ruby
|
|
459
|
-
require "uri"
|
|
460
|
-
|
|
461
461
|
class AcmeParser < LlmCostTracker::Parsers::Base
|
|
462
|
+
HOSTS = %w[api.acme-llm.example].freeze
|
|
463
|
+
TRACKED_PATHS = %w[/v1/generate].freeze
|
|
464
|
+
|
|
465
|
+
def provider_names
|
|
466
|
+
%w[acme]
|
|
467
|
+
end
|
|
468
|
+
|
|
462
469
|
def match?(url)
|
|
463
|
-
|
|
464
|
-
uri.host == "api.acme-llm.example" && uri.path == "/v1/generate"
|
|
465
|
-
rescue URI::InvalidURIError
|
|
466
|
-
false
|
|
470
|
+
match_uri?(url, hosts: HOSTS, exact_paths: TRACKED_PATHS)
|
|
467
471
|
end
|
|
468
472
|
|
|
469
|
-
def parse(
|
|
473
|
+
def parse(_request_url, _request_body, response_status, response_body)
|
|
470
474
|
return nil unless response_status == 200
|
|
471
475
|
|
|
472
476
|
payload = safe_json_parse(response_body)
|
|
473
|
-
usage = payload
|
|
477
|
+
usage = payload.dig("usage")
|
|
474
478
|
return nil unless usage
|
|
475
479
|
|
|
476
480
|
LlmCostTracker::ParsedUsage.build(
|
|
@@ -482,7 +486,7 @@ class AcmeParser < LlmCostTracker::Parsers::Base
|
|
|
482
486
|
end
|
|
483
487
|
end
|
|
484
488
|
|
|
485
|
-
LlmCostTracker::Parsers::Registry.register(AcmeParser
|
|
489
|
+
LlmCostTracker::Parsers::Registry.register(AcmeParser)
|
|
486
490
|
```
|
|
487
491
|
|
|
488
492
|
## Supported providers
|
|
@@ -531,7 +535,7 @@ The gem is designed for multi-threaded hosts — Puma with `max_threads > 1` and
|
|
|
531
535
|
|
|
532
536
|
## Development
|
|
533
537
|
|
|
534
|
-
Architecture rules for future changes live in [`docs/architecture.md`](docs/architecture.md).
|
|
538
|
+
Architecture rules for future changes live in [`docs/architecture.md`](docs/architecture.md). Integration recipes live in [`docs/cookbook.md`](docs/cookbook.md).
|
|
535
539
|
|
|
536
540
|
```bash
|
|
537
541
|
bundle install
|
|
@@ -29,84 +29,85 @@ module LlmCostTracker
|
|
|
29
29
|
class DataQuality
|
|
30
30
|
class << self
|
|
31
31
|
def call(scope: LlmCostTracker::LlmApiCall.all)
|
|
32
|
-
|
|
32
|
+
model = scope.klass
|
|
33
|
+
aggregates = DataQualityAggregate.call(scope: scope)
|
|
34
|
+
total = aggregates.fetch(:total_calls).to_i
|
|
33
35
|
|
|
34
36
|
DataQualityStats.new(
|
|
35
37
|
total_calls: total,
|
|
36
|
-
unknown_pricing_count:
|
|
37
|
-
untagged_calls_count: total -
|
|
38
|
-
**latency_stats(
|
|
39
|
-
**stream_stats(
|
|
40
|
-
**provider_response_id_stats(
|
|
41
|
-
**usage_stats(
|
|
38
|
+
unknown_pricing_count: aggregates.fetch(:unknown_pricing_count).to_i,
|
|
39
|
+
untagged_calls_count: total - aggregates.fetch(:tagged_calls_count).to_i,
|
|
40
|
+
**latency_stats(aggregates, model:),
|
|
41
|
+
**stream_stats(aggregates, model:),
|
|
42
|
+
**provider_response_id_stats(aggregates, model:),
|
|
43
|
+
**usage_stats(aggregates, model:),
|
|
42
44
|
unknown_pricing_by_model: unknown_pricing_by_model(scope)
|
|
43
45
|
)
|
|
44
46
|
end
|
|
45
47
|
|
|
46
48
|
private
|
|
47
49
|
|
|
48
|
-
def latency_stats(
|
|
49
|
-
latency_present =
|
|
50
|
+
def latency_stats(aggregates, model:)
|
|
51
|
+
latency_present = model.latency_column?
|
|
50
52
|
|
|
51
53
|
{
|
|
52
|
-
missing_latency_count: latency_present ?
|
|
54
|
+
missing_latency_count: latency_present ? aggregates.fetch(:missing_latency_count).to_i : nil,
|
|
53
55
|
latency_column_present: latency_present
|
|
54
56
|
}
|
|
55
57
|
end
|
|
56
58
|
|
|
57
|
-
def stream_stats(
|
|
58
|
-
stream_present =
|
|
59
|
+
def stream_stats(aggregates, model:)
|
|
60
|
+
stream_present = model.stream_column?
|
|
61
|
+
usage_source_present = model.usage_source_column?
|
|
62
|
+
streaming_missing_usage_count = nil
|
|
63
|
+
if stream_present && usage_source_present
|
|
64
|
+
streaming_missing_usage_count = aggregates.fetch(:streaming_missing_usage_count).to_i
|
|
65
|
+
end
|
|
59
66
|
|
|
60
67
|
{
|
|
61
|
-
streaming_count: stream_present ?
|
|
62
|
-
streaming_missing_usage_count: streaming_missing_usage_count
|
|
68
|
+
streaming_count: stream_present ? aggregates.fetch(:streaming_count).to_i : nil,
|
|
69
|
+
streaming_missing_usage_count: streaming_missing_usage_count,
|
|
63
70
|
stream_column_present: stream_present
|
|
64
71
|
}
|
|
65
72
|
end
|
|
66
73
|
|
|
67
|
-
def provider_response_id_stats(
|
|
68
|
-
column_present =
|
|
74
|
+
def provider_response_id_stats(aggregates, model:)
|
|
75
|
+
column_present = model.provider_response_id_column?
|
|
76
|
+
missing_provider_response_id_count = nil
|
|
77
|
+
if column_present
|
|
78
|
+
missing_provider_response_id_count = aggregates.fetch(:missing_provider_response_id_count).to_i
|
|
79
|
+
end
|
|
69
80
|
|
|
70
81
|
{
|
|
71
|
-
missing_provider_response_id_count:
|
|
82
|
+
missing_provider_response_id_count: missing_provider_response_id_count,
|
|
72
83
|
provider_response_id_column_present: column_present
|
|
73
84
|
}
|
|
74
85
|
end
|
|
75
86
|
|
|
76
|
-
def usage_stats(
|
|
77
|
-
usage_breakdown_present =
|
|
78
|
-
usage_breakdown_cost_present =
|
|
79
|
-
|
|
87
|
+
def usage_stats(aggregates, model:)
|
|
88
|
+
usage_breakdown_present = model.usage_breakdown_columns?
|
|
89
|
+
usage_breakdown_cost_present = model.usage_breakdown_cost_columns?
|
|
90
|
+
cache_read_input_cost = nil
|
|
91
|
+
cache_write_input_cost = nil
|
|
92
|
+
if usage_breakdown_cost_present
|
|
93
|
+
cache_read_input_cost = decimal_sum(aggregates.fetch(:cache_read_input_cost))
|
|
94
|
+
cache_write_input_cost = decimal_sum(aggregates.fetch(:cache_write_input_cost))
|
|
95
|
+
end
|
|
80
96
|
|
|
81
97
|
{
|
|
82
98
|
usage_breakdown_column_present: usage_breakdown_present,
|
|
83
|
-
input_tokens:
|
|
84
|
-
cache_read_input_tokens: usage_breakdown_present ?
|
|
85
|
-
cache_write_input_tokens: usage_breakdown_present ?
|
|
86
|
-
output_tokens:
|
|
87
|
-
hidden_output_tokens: usage_breakdown_present ?
|
|
88
|
-
input_cost: decimal_sum(
|
|
89
|
-
cache_read_input_cost:
|
|
90
|
-
cache_write_input_cost:
|
|
91
|
-
output_cost: decimal_sum(
|
|
99
|
+
input_tokens: aggregates.fetch(:input_tokens).to_i,
|
|
100
|
+
cache_read_input_tokens: usage_breakdown_present ? aggregates.fetch(:cache_read_input_tokens).to_i : nil,
|
|
101
|
+
cache_write_input_tokens: usage_breakdown_present ? aggregates.fetch(:cache_write_input_tokens).to_i : nil,
|
|
102
|
+
output_tokens: aggregates.fetch(:output_tokens).to_i,
|
|
103
|
+
hidden_output_tokens: usage_breakdown_present ? aggregates.fetch(:hidden_output_tokens).to_i : nil,
|
|
104
|
+
input_cost: decimal_sum(aggregates.fetch(:input_cost)),
|
|
105
|
+
cache_read_input_cost: cache_read_input_cost,
|
|
106
|
+
cache_write_input_cost: cache_write_input_cost,
|
|
107
|
+
output_cost: decimal_sum(aggregates.fetch(:output_cost))
|
|
92
108
|
}
|
|
93
109
|
end
|
|
94
110
|
|
|
95
|
-
def usage_sum_columns(usage_breakdown_present, usage_breakdown_cost_present)
|
|
96
|
-
columns = %i[input_tokens output_tokens input_cost output_cost]
|
|
97
|
-
if usage_breakdown_present
|
|
98
|
-
columns += %i[cache_read_input_tokens cache_write_input_tokens hidden_output_tokens]
|
|
99
|
-
end
|
|
100
|
-
columns += %i[cache_read_input_cost cache_write_input_cost] if usage_breakdown_cost_present
|
|
101
|
-
columns
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def streaming_missing_usage_count(scope, stream_present)
|
|
105
|
-
return unless stream_present && LlmCostTracker::LlmApiCall.usage_source_column?
|
|
106
|
-
|
|
107
|
-
scope.streaming_missing_usage.count
|
|
108
|
-
end
|
|
109
|
-
|
|
110
111
|
def unknown_pricing_by_model(scope)
|
|
111
112
|
scope.unknown_pricing
|
|
112
113
|
.group(:model)
|
|
@@ -116,16 +117,6 @@ module LlmCostTracker
|
|
|
116
117
|
.to_h
|
|
117
118
|
end
|
|
118
119
|
|
|
119
|
-
def sum_columns(scope, columns)
|
|
120
|
-
values = scope.unscope(:order).pick(*columns.map { |column| sum_expression(scope, column) })
|
|
121
|
-
|
|
122
|
-
columns.zip(values).to_h
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
def sum_expression(scope, column)
|
|
126
|
-
Arel.sql("COALESCE(SUM(#{scope.connection.quote_column_name(column)}), 0)")
|
|
127
|
-
end
|
|
128
|
-
|
|
129
120
|
def decimal_sum(value)
|
|
130
121
|
value.to_f.round(8)
|
|
131
122
|
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Dashboard
|
|
5
|
+
class DataQualityAggregate
|
|
6
|
+
class << self
|
|
7
|
+
def call(scope:)
|
|
8
|
+
model = scope.klass
|
|
9
|
+
expressions = aggregate_expressions(scope, model:)
|
|
10
|
+
values = Array(scope.unscope(:order).pick(*expressions.values))
|
|
11
|
+
|
|
12
|
+
expressions.keys.zip(values).to_h
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def aggregate_expressions(scope, model:)
|
|
18
|
+
usage_breakdown_present = model.usage_breakdown_columns?
|
|
19
|
+
usage_breakdown_cost_present = model.usage_breakdown_cost_columns?
|
|
20
|
+
|
|
21
|
+
expressions = {
|
|
22
|
+
total_calls: Arel.sql("COUNT(*)"),
|
|
23
|
+
unknown_pricing_count: conditional_count_expression("total_cost IS NULL"),
|
|
24
|
+
tagged_calls_count: tagged_calls_expression(model)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if model.latency_column?
|
|
28
|
+
expressions[:missing_latency_count] = conditional_count_expression("latency_ms IS NULL")
|
|
29
|
+
end
|
|
30
|
+
expressions[:streaming_count] = conditional_count_expression("stream") if model.stream_column?
|
|
31
|
+
if model.stream_column? && model.usage_source_column?
|
|
32
|
+
expressions[:streaming_missing_usage_count] =
|
|
33
|
+
conditional_count_expression("stream AND (usage_source = 'unknown' OR usage_source IS NULL)")
|
|
34
|
+
end
|
|
35
|
+
if model.provider_response_id_column?
|
|
36
|
+
expressions[:missing_provider_response_id_count] =
|
|
37
|
+
conditional_count_expression("provider_response_id IS NULL OR provider_response_id = ''")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
usage_sum_columns(usage_breakdown_present, usage_breakdown_cost_present).each do |column|
|
|
41
|
+
expressions[column] = sum_expression(scope, column)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
expressions
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def usage_sum_columns(usage_breakdown_present, usage_breakdown_cost_present)
|
|
48
|
+
columns = %i[input_tokens output_tokens input_cost output_cost]
|
|
49
|
+
if usage_breakdown_present
|
|
50
|
+
columns += %i[cache_read_input_tokens cache_write_input_tokens hidden_output_tokens]
|
|
51
|
+
end
|
|
52
|
+
columns += %i[cache_read_input_cost cache_write_input_cost] if usage_breakdown_cost_present
|
|
53
|
+
columns
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def conditional_count_expression(predicate)
|
|
57
|
+
Arel.sql("COALESCE(SUM(CASE WHEN #{predicate} THEN 1 ELSE 0 END), 0)")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def tagged_calls_expression(model)
|
|
61
|
+
table = model.quoted_table_name
|
|
62
|
+
column = "#{table}.#{model.connection.quote_column_name('tags')}"
|
|
63
|
+
|
|
64
|
+
Arel.sql(case
|
|
65
|
+
when model.tags_jsonb_column?
|
|
66
|
+
"COALESCE(SUM(CASE WHEN #{column} <> '{}'::jsonb THEN 1 ELSE 0 END), 0)"
|
|
67
|
+
when model.tags_mysql_json_column?
|
|
68
|
+
"COALESCE(SUM(CASE WHEN JSON_LENGTH(#{column}) > 0 THEN 1 ELSE 0 END), 0)"
|
|
69
|
+
else
|
|
70
|
+
"COALESCE(SUM(CASE WHEN #{column} IS NOT NULL AND #{column} <> '' " \
|
|
71
|
+
"AND #{column} <> '{}' THEN 1 ELSE 0 END), 0)"
|
|
72
|
+
end)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def sum_expression(scope, column)
|
|
76
|
+
Arel.sql("COALESCE(SUM(#{scope.connection.quote_column_name(column)}), 0)")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -10,8 +10,16 @@ module LlmCostTracker
|
|
|
10
10
|
return unless config.budget_exceeded_behavior == :block_requests
|
|
11
11
|
return unless config.active_record?
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
budgets = enforce_period_budgets(config)
|
|
14
|
+
return if budgets.empty?
|
|
15
|
+
|
|
16
|
+
totals = active_record_totals(budgets.keys, time: Time.now.utc)
|
|
17
|
+
|
|
18
|
+
budgets.each do |period, budget|
|
|
19
|
+
total = totals.fetch(period)
|
|
20
|
+
|
|
21
|
+
handle_exceeded(budget_type: period, total: total, budget: budget) if total >= budget
|
|
22
|
+
end
|
|
15
23
|
end
|
|
16
24
|
|
|
17
25
|
def check!(event)
|
|
@@ -19,21 +27,18 @@ module LlmCostTracker
|
|
|
19
27
|
return unless event.cost
|
|
20
28
|
|
|
21
29
|
check_per_call_budget(event, config)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
end
|
|
30
|
+
budgets = check_period_budgets(config)
|
|
31
|
+
totals = totals_for_check(event, config, budgets)
|
|
25
32
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def enforce_period_budget(period, budget)
|
|
29
|
-
return unless budget
|
|
33
|
+
budgets.each do |period, budget|
|
|
34
|
+
total = totals.fetch(period)
|
|
30
35
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
handle_exceeded(budget_type: period, total: total, budget: budget)
|
|
36
|
+
handle_exceeded(budget_type: period, total: total, budget: budget, last_event: event) if total >= budget
|
|
37
|
+
end
|
|
35
38
|
end
|
|
36
39
|
|
|
40
|
+
private
|
|
41
|
+
|
|
37
42
|
def check_per_call_budget(event, config)
|
|
38
43
|
budget = config.per_call_budget
|
|
39
44
|
return unless budget
|
|
@@ -44,40 +49,32 @@ module LlmCostTracker
|
|
|
44
49
|
handle_exceeded(budget_type: :per_call, total: call_cost, budget: budget, last_event: event)
|
|
45
50
|
end
|
|
46
51
|
|
|
47
|
-
def
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
else
|
|
53
|
-
event.cost.total_cost
|
|
54
|
-
end
|
|
55
|
-
return unless total >= budget
|
|
56
|
-
|
|
57
|
-
handle_exceeded(budget_type: period, total: total, budget: budget, last_event: event)
|
|
52
|
+
def enforce_period_budgets(config)
|
|
53
|
+
{
|
|
54
|
+
monthly: config.monthly_budget,
|
|
55
|
+
daily: config.daily_budget
|
|
56
|
+
}.compact
|
|
58
57
|
end
|
|
59
58
|
|
|
60
|
-
def
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
59
|
+
def check_period_budgets(config)
|
|
60
|
+
{
|
|
61
|
+
daily: config.daily_budget,
|
|
62
|
+
monthly: config.monthly_budget
|
|
63
|
+
}.compact
|
|
65
64
|
end
|
|
66
65
|
|
|
67
|
-
def
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
def totals_for_check(event, config, budgets)
|
|
67
|
+
return {} if budgets.empty?
|
|
68
|
+
return active_record_totals(budgets.keys, time: event.tracked_at) if config.active_record?
|
|
70
69
|
|
|
71
|
-
|
|
72
|
-
rescue LoadError => e
|
|
73
|
-
raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
|
|
70
|
+
budgets.to_h { |period, _budget| [period, event.cost.total_cost] }
|
|
74
71
|
end
|
|
75
72
|
|
|
76
|
-
def
|
|
73
|
+
def active_record_totals(periods, time:)
|
|
77
74
|
require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
|
|
78
75
|
require_relative "storage/active_record_store" unless defined?(LlmCostTracker::Storage::ActiveRecordStore)
|
|
79
76
|
|
|
80
|
-
LlmCostTracker::Storage::ActiveRecordStore.
|
|
77
|
+
LlmCostTracker::Storage::ActiveRecordStore.period_totals(periods, time: time)
|
|
81
78
|
rescue LoadError => e
|
|
82
79
|
raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
|
|
83
80
|
end
|
|
@@ -8,10 +8,10 @@ class AddPeriodTotalsToLlmCostTracker < ActiveRecord::Migration<%= migration_ver
|
|
|
8
8
|
t.timestamps
|
|
9
9
|
end unless table_exists?(:llm_cost_tracker_period_totals)
|
|
10
10
|
|
|
11
|
+
backfill_period_totals
|
|
12
|
+
|
|
11
13
|
add_index :llm_cost_tracker_period_totals, [:period, :period_start],
|
|
12
14
|
unique: true unless index_exists?(:llm_cost_tracker_period_totals, [:period, :period_start])
|
|
13
|
-
|
|
14
|
-
backfill_period_totals
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def down
|
|
@@ -22,23 +22,53 @@ class AddPeriodTotalsToLlmCostTracker < ActiveRecord::Migration<%= migration_ver
|
|
|
22
22
|
private
|
|
23
23
|
|
|
24
24
|
def backfill_period_totals
|
|
25
|
+
backfill_legacy_monthly_totals if table_exists?(:llm_cost_tracker_monthly_totals)
|
|
25
26
|
return unless table_exists?(:llm_api_calls)
|
|
26
27
|
|
|
27
28
|
backfill_period_total("day", day_bucket_sql)
|
|
28
29
|
backfill_period_total("month", month_bucket_sql)
|
|
29
30
|
end
|
|
30
31
|
|
|
32
|
+
def backfill_legacy_monthly_totals
|
|
33
|
+
execute <<~SQL
|
|
34
|
+
INSERT INTO llm_cost_tracker_period_totals (period, period_start, total_cost, created_at, updated_at)
|
|
35
|
+
SELECT #{connection.quote("month")} AS period,
|
|
36
|
+
month AS period_start,
|
|
37
|
+
total_cost,
|
|
38
|
+
CURRENT_TIMESTAMP,
|
|
39
|
+
CURRENT_TIMESTAMP
|
|
40
|
+
FROM llm_cost_tracker_monthly_totals legacy
|
|
41
|
+
WHERE NOT EXISTS (
|
|
42
|
+
SELECT 1
|
|
43
|
+
FROM llm_cost_tracker_period_totals existing
|
|
44
|
+
WHERE existing.period = #{connection.quote("month")}
|
|
45
|
+
AND existing.period_start = legacy.month
|
|
46
|
+
)
|
|
47
|
+
SQL
|
|
48
|
+
end
|
|
49
|
+
|
|
31
50
|
def backfill_period_total(period, bucket_sql)
|
|
32
51
|
execute <<~SQL
|
|
33
52
|
INSERT INTO llm_cost_tracker_period_totals (period, period_start, total_cost, created_at, updated_at)
|
|
34
|
-
SELECT
|
|
35
|
-
|
|
36
|
-
|
|
53
|
+
SELECT aggregated.period,
|
|
54
|
+
aggregated.period_start,
|
|
55
|
+
aggregated.total_cost,
|
|
37
56
|
CURRENT_TIMESTAMP,
|
|
38
57
|
CURRENT_TIMESTAMP
|
|
39
|
-
FROM
|
|
40
|
-
|
|
41
|
-
|
|
58
|
+
FROM (
|
|
59
|
+
SELECT #{connection.quote(period)} AS period,
|
|
60
|
+
#{bucket_sql} AS period_start,
|
|
61
|
+
SUM(total_cost) AS total_cost
|
|
62
|
+
FROM llm_api_calls
|
|
63
|
+
WHERE total_cost IS NOT NULL
|
|
64
|
+
GROUP BY #{bucket_sql}
|
|
65
|
+
) aggregated
|
|
66
|
+
WHERE NOT EXISTS (
|
|
67
|
+
SELECT 1
|
|
68
|
+
FROM llm_cost_tracker_period_totals existing
|
|
69
|
+
WHERE existing.period = aggregated.period
|
|
70
|
+
AND existing.period_start = aggregated.period_start
|
|
71
|
+
)
|
|
42
72
|
SQL
|
|
43
73
|
end
|
|
44
74
|
|
|
@@ -37,10 +37,9 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
|
|
|
37
37
|
t.timestamps
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
add_index :llm_api_calls, :provider
|
|
41
|
-
add_index :llm_api_calls, :model
|
|
42
40
|
add_index :llm_api_calls, :tracked_at
|
|
43
41
|
add_index :llm_api_calls, [:provider, :tracked_at]
|
|
42
|
+
add_index :llm_api_calls, [:model, :tracked_at]
|
|
44
43
|
add_index :llm_api_calls, :stream
|
|
45
44
|
add_index :llm_api_calls, :usage_source
|
|
46
45
|
add_index :llm_api_calls, :provider_response_id
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "uri"
|
|
4
|
-
|
|
5
3
|
require_relative "base"
|
|
6
4
|
|
|
7
5
|
module LlmCostTracker
|
|
@@ -10,10 +8,7 @@ module LlmCostTracker
|
|
|
10
8
|
HOSTS = %w[api.anthropic.com].freeze
|
|
11
9
|
|
|
12
10
|
def match?(url)
|
|
13
|
-
|
|
14
|
-
HOSTS.include?(uri.host.to_s.downcase) && uri.path.include?("/v1/messages")
|
|
15
|
-
rescue URI::InvalidURIError
|
|
16
|
-
false
|
|
11
|
+
match_uri?(url, hosts: HOSTS, path_includes: "/v1/messages")
|
|
17
12
|
end
|
|
18
13
|
|
|
19
14
|
def provider_names
|
|
@@ -52,25 +47,25 @@ module LlmCostTracker
|
|
|
52
47
|
usage = stream_usage(events)
|
|
53
48
|
response_id = stream_response_id(events)
|
|
54
49
|
|
|
55
|
-
|
|
50
|
+
if usage
|
|
51
|
+
build_stream_result(model, usage, response_id)
|
|
52
|
+
else
|
|
53
|
+
build_unknown_stream_usage(
|
|
54
|
+
provider: "anthropic",
|
|
55
|
+
model: model,
|
|
56
|
+
provider_response_id: response_id
|
|
57
|
+
)
|
|
58
|
+
end
|
|
56
59
|
end
|
|
57
60
|
|
|
58
61
|
private
|
|
59
62
|
|
|
60
63
|
def stream_usage(events)
|
|
61
|
-
start_usage =
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
events
|
|
65
|
-
data
|
|
66
|
-
next unless data.is_a?(Hash)
|
|
67
|
-
|
|
68
|
-
case data["type"]
|
|
69
|
-
when "message_start"
|
|
70
|
-
start_usage = data.dig("message", "usage")
|
|
71
|
-
when "message_delta"
|
|
72
|
-
latest_delta = data["usage"] if data["usage"].is_a?(Hash)
|
|
73
|
-
end
|
|
64
|
+
start_usage = find_event_value(events, reverse: true) do |data|
|
|
65
|
+
data.dig("message", "usage") if data["type"] == "message_start"
|
|
66
|
+
end
|
|
67
|
+
latest_delta = find_event_value(events, reverse: true) do |data|
|
|
68
|
+
data["usage"] if data["type"] == "message_delta" && data["usage"].is_a?(Hash)
|
|
74
69
|
end
|
|
75
70
|
|
|
76
71
|
return nil unless start_usage || latest_delta
|
|
@@ -81,25 +76,11 @@ module LlmCostTracker
|
|
|
81
76
|
end
|
|
82
77
|
|
|
83
78
|
def stream_model(events)
|
|
84
|
-
events
|
|
85
|
-
data = event[:data]
|
|
86
|
-
next unless data.is_a?(Hash)
|
|
87
|
-
|
|
88
|
-
model = data.dig("message", "model")
|
|
89
|
-
return model if model && !model.empty?
|
|
90
|
-
end
|
|
91
|
-
nil
|
|
79
|
+
find_event_value(events) { |data| data.dig("message", "model") }
|
|
92
80
|
end
|
|
93
81
|
|
|
94
82
|
def stream_response_id(events)
|
|
95
|
-
events
|
|
96
|
-
data = event[:data]
|
|
97
|
-
next unless data.is_a?(Hash)
|
|
98
|
-
|
|
99
|
-
id = data.dig("message", "id") || data["id"]
|
|
100
|
-
return id if id && !id.to_s.empty?
|
|
101
|
-
end
|
|
102
|
-
nil
|
|
83
|
+
find_event_value(events) { |data| data.dig("message", "id") || data["id"] }
|
|
103
84
|
end
|
|
104
85
|
|
|
105
86
|
def build_stream_result(model, usage, response_id)
|
|
@@ -121,19 +102,6 @@ module LlmCostTracker
|
|
|
121
102
|
usage_source: :stream_final
|
|
122
103
|
)
|
|
123
104
|
end
|
|
124
|
-
|
|
125
|
-
def build_unknown_stream_result(model, response_id)
|
|
126
|
-
ParsedUsage.build(
|
|
127
|
-
provider: "anthropic",
|
|
128
|
-
provider_response_id: response_id,
|
|
129
|
-
model: model,
|
|
130
|
-
input_tokens: 0,
|
|
131
|
-
output_tokens: 0,
|
|
132
|
-
total_tokens: 0,
|
|
133
|
-
stream: true,
|
|
134
|
-
usage_source: :unknown
|
|
135
|
-
)
|
|
136
|
-
end
|
|
137
105
|
end
|
|
138
106
|
end
|
|
139
107
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "uri"
|
|
4
5
|
|
|
5
6
|
module LlmCostTracker
|
|
6
7
|
module Parsers
|
|
@@ -40,6 +41,85 @@ module LlmCostTracker
|
|
|
40
41
|
rescue JSON::ParserError
|
|
41
42
|
{}
|
|
42
43
|
end
|
|
44
|
+
|
|
45
|
+
def uri_matches?(url)
|
|
46
|
+
uri = parsed_uri(url)
|
|
47
|
+
uri ? yield(uri) : false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def match_uri?(url, hosts: nil, exact_paths: nil, path_includes: nil, path_suffixes: nil, path_pattern: nil)
|
|
51
|
+
uri_matches?(url) do |uri|
|
|
52
|
+
host_match = hosts.nil? || host_matches?(uri, hosts)
|
|
53
|
+
path_match = path_matches?(
|
|
54
|
+
uri,
|
|
55
|
+
exact_paths: exact_paths,
|
|
56
|
+
path_includes: path_includes,
|
|
57
|
+
path_suffixes: path_suffixes,
|
|
58
|
+
path_pattern: path_pattern
|
|
59
|
+
)
|
|
60
|
+
extra_match = block_given? ? yield(uri) : true
|
|
61
|
+
|
|
62
|
+
host_match && path_match && extra_match ? true : false
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def parsed_uri(url)
|
|
67
|
+
URI.parse(url.to_s)
|
|
68
|
+
rescue URI::InvalidURIError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def host_matches?(uri, hosts)
|
|
73
|
+
hosts.include?(uri.host.to_s.downcase)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def path_matches?(uri, exact_paths: nil, path_includes: nil, path_suffixes: nil, path_pattern: nil)
|
|
77
|
+
path = uri.path.to_s
|
|
78
|
+
matches = true
|
|
79
|
+
|
|
80
|
+
matches &&= exact_paths.include?(path) if exact_paths
|
|
81
|
+
matches &&= Array(path_includes).all? { |fragment| path.include?(fragment) } if path_includes
|
|
82
|
+
matches &&= path.match?(path_pattern) if path_pattern
|
|
83
|
+
|
|
84
|
+
matches &&= path_suffixes.any? { |suffix| path == suffix || path.end_with?(suffix) } if path_suffixes
|
|
85
|
+
|
|
86
|
+
matches
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def each_event_data(events, reverse: false)
|
|
90
|
+
enumerator = reverse ? events.reverse_each : events.each
|
|
91
|
+
|
|
92
|
+
enumerator.each do |event|
|
|
93
|
+
data = event[:data]
|
|
94
|
+
yield data if data.is_a?(Hash)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def find_event_value(events, reverse: false)
|
|
99
|
+
each_event_data(events, reverse:) do |data|
|
|
100
|
+
value = yield(data)
|
|
101
|
+
return value if event_value_present?(value)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def build_unknown_stream_usage(provider:, model:, provider_response_id:)
|
|
108
|
+
ParsedUsage.build(
|
|
109
|
+
provider: provider,
|
|
110
|
+
provider_response_id: provider_response_id,
|
|
111
|
+
model: model,
|
|
112
|
+
input_tokens: 0,
|
|
113
|
+
output_tokens: 0,
|
|
114
|
+
total_tokens: 0,
|
|
115
|
+
stream: true,
|
|
116
|
+
usage_source: :unknown
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def event_value_present?(value)
|
|
121
|
+
!value.nil? && (!value.respond_to?(:empty?) || !value.empty?)
|
|
122
|
+
end
|
|
43
123
|
end
|
|
44
124
|
end
|
|
45
125
|
end
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "uri"
|
|
4
|
-
|
|
5
3
|
require_relative "base"
|
|
6
4
|
|
|
7
5
|
module LlmCostTracker
|
|
@@ -12,10 +10,7 @@ module LlmCostTracker
|
|
|
12
10
|
STREAM_PATH_PATTERN = /:streamGenerateContent\z/
|
|
13
11
|
|
|
14
12
|
def match?(url)
|
|
15
|
-
|
|
16
|
-
HOSTS.include?(uri.host.to_s.downcase) && uri.path.match?(TRACKED_PATH_PATTERN)
|
|
17
|
-
rescue URI::InvalidURIError
|
|
18
|
-
false
|
|
13
|
+
match_uri?(url, hosts: HOSTS, path_pattern: TRACKED_PATH_PATTERN)
|
|
19
14
|
end
|
|
20
15
|
|
|
21
16
|
def provider_names
|
|
@@ -48,6 +43,7 @@ module LlmCostTracker
|
|
|
48
43
|
|
|
49
44
|
usage = merged_stream_usage(events)
|
|
50
45
|
model = extract_model_from_url(request_url)
|
|
46
|
+
response_id = stream_response_id(events)
|
|
51
47
|
|
|
52
48
|
if usage
|
|
53
49
|
build_parsed_usage(
|
|
@@ -55,18 +51,13 @@ module LlmCostTracker
|
|
|
55
51
|
usage,
|
|
56
52
|
stream: true,
|
|
57
53
|
usage_source: :stream_final,
|
|
58
|
-
provider_response_id:
|
|
54
|
+
provider_response_id: response_id
|
|
59
55
|
)
|
|
60
56
|
else
|
|
61
|
-
|
|
57
|
+
build_unknown_stream_usage(
|
|
62
58
|
provider: "gemini",
|
|
63
|
-
provider_response_id: stream_response_id(events),
|
|
64
59
|
model: model,
|
|
65
|
-
|
|
66
|
-
output_tokens: 0,
|
|
67
|
-
total_tokens: 0,
|
|
68
|
-
stream: true,
|
|
69
|
-
usage_source: :unknown
|
|
60
|
+
provider_response_id: response_id
|
|
70
61
|
)
|
|
71
62
|
end
|
|
72
63
|
end
|
|
@@ -91,15 +82,10 @@ module LlmCostTracker
|
|
|
91
82
|
end
|
|
92
83
|
|
|
93
84
|
def merged_stream_usage(events)
|
|
94
|
-
|
|
95
|
-
events.each do |event|
|
|
96
|
-
data = event[:data]
|
|
97
|
-
next unless data.is_a?(Hash)
|
|
98
|
-
|
|
85
|
+
find_event_value(events, reverse: true) do |data|
|
|
99
86
|
meta = data["usageMetadata"]
|
|
100
|
-
|
|
87
|
+
meta if meta.is_a?(Hash)
|
|
101
88
|
end
|
|
102
|
-
latest
|
|
103
89
|
end
|
|
104
90
|
|
|
105
91
|
def output_tokens(usage)
|
|
@@ -107,28 +93,19 @@ module LlmCostTracker
|
|
|
107
93
|
end
|
|
108
94
|
|
|
109
95
|
def stream_response_id(events)
|
|
110
|
-
events
|
|
111
|
-
data = event[:data]
|
|
112
|
-
next unless data.is_a?(Hash)
|
|
113
|
-
|
|
114
|
-
id = data["responseId"]
|
|
115
|
-
return id if id && !id.to_s.empty?
|
|
116
|
-
end
|
|
117
|
-
nil
|
|
96
|
+
find_event_value(events) { |data| data["responseId"] }
|
|
118
97
|
end
|
|
119
98
|
|
|
120
99
|
def streaming_url?(request_url)
|
|
121
|
-
|
|
122
|
-
rescue URI::InvalidURIError
|
|
123
|
-
false
|
|
100
|
+
match_uri?(request_url, path_pattern: STREAM_PATH_PATTERN)
|
|
124
101
|
end
|
|
125
102
|
|
|
126
103
|
def extract_model_from_url(url)
|
|
127
|
-
uri =
|
|
104
|
+
uri = parsed_uri(url)
|
|
105
|
+
return nil unless uri
|
|
106
|
+
|
|
128
107
|
match = uri.path.match(%r{/models/([^/:]+)})
|
|
129
108
|
match && match[1]
|
|
130
|
-
rescue URI::InvalidURIError
|
|
131
|
-
nil
|
|
132
109
|
end
|
|
133
110
|
end
|
|
134
111
|
end
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "uri"
|
|
4
|
-
|
|
5
3
|
require_relative "base"
|
|
6
4
|
require_relative "openai_usage"
|
|
7
5
|
|
|
@@ -14,10 +12,7 @@ module LlmCostTracker
|
|
|
14
12
|
TRACKED_PATHS = %w[/v1/chat/completions /v1/completions /v1/embeddings /v1/responses].freeze
|
|
15
13
|
|
|
16
14
|
def match?(url)
|
|
17
|
-
|
|
18
|
-
HOSTS.include?(uri.host.to_s.downcase) && TRACKED_PATHS.include?(uri.path)
|
|
19
|
-
rescue URI::InvalidURIError
|
|
20
|
-
false
|
|
15
|
+
match_uri?(url, hosts: HOSTS, exact_paths: TRACKED_PATHS)
|
|
21
16
|
end
|
|
22
17
|
|
|
23
18
|
def provider_names
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "uri"
|
|
4
|
-
|
|
5
3
|
require_relative "base"
|
|
6
4
|
require_relative "openai_usage"
|
|
7
5
|
|
|
@@ -13,10 +11,7 @@ module LlmCostTracker
|
|
|
13
11
|
TRACKED_PATH_SUFFIXES = %w[/chat/completions /completions /embeddings /responses].freeze
|
|
14
12
|
|
|
15
13
|
def match?(url)
|
|
16
|
-
uri
|
|
17
|
-
!provider_for_host(uri.host).nil? && tracked_path?(uri.path)
|
|
18
|
-
rescue URI::InvalidURIError
|
|
19
|
-
false
|
|
14
|
+
match_uri?(url, path_suffixes: TRACKED_PATH_SUFFIXES) { |uri| provider_for_uri(uri) }
|
|
20
15
|
end
|
|
21
16
|
|
|
22
17
|
def provider_names
|
|
@@ -37,18 +32,14 @@ module LlmCostTracker
|
|
|
37
32
|
private
|
|
38
33
|
|
|
39
34
|
def provider_for(request_url)
|
|
40
|
-
uri =
|
|
41
|
-
|
|
42
|
-
rescue URI::InvalidURIError
|
|
43
|
-
"openai_compatible"
|
|
35
|
+
uri = parsed_uri(request_url)
|
|
36
|
+
provider_for_uri(uri) || "openai_compatible"
|
|
44
37
|
end
|
|
45
38
|
|
|
46
|
-
def
|
|
47
|
-
|
|
48
|
-
end
|
|
39
|
+
def provider_for_uri(uri)
|
|
40
|
+
return nil unless uri
|
|
49
41
|
|
|
50
|
-
|
|
51
|
-
TRACKED_PATH_SUFFIXES.any? { |suffix| path == suffix || path.end_with?(suffix) }
|
|
42
|
+
LlmCostTracker.configuration.openai_compatible_providers[uri.host.to_s.downcase]&.to_s
|
|
52
43
|
end
|
|
53
44
|
end
|
|
54
45
|
end
|
|
@@ -34,12 +34,13 @@ module LlmCostTracker
|
|
|
34
34
|
request = safe_json_parse(request_body)
|
|
35
35
|
model = detect_stream_model(events) || request["model"]
|
|
36
36
|
usage = detect_stream_usage(events)
|
|
37
|
+
response_id = detect_stream_response_id(events)
|
|
37
38
|
|
|
38
39
|
if usage
|
|
39
40
|
cache_read = cache_read_input_tokens(usage)
|
|
40
41
|
ParsedUsage.build(
|
|
41
42
|
provider: provider_for(request_url),
|
|
42
|
-
provider_response_id:
|
|
43
|
+
provider_response_id: response_id,
|
|
43
44
|
model: model,
|
|
44
45
|
input_tokens: regular_input_tokens(usage, cache_read),
|
|
45
46
|
output_tokens: (usage["completion_tokens"] || usage["output_tokens"]).to_i,
|
|
@@ -50,50 +51,27 @@ module LlmCostTracker
|
|
|
50
51
|
usage_source: :stream_final
|
|
51
52
|
)
|
|
52
53
|
else
|
|
53
|
-
|
|
54
|
+
build_unknown_stream_usage(
|
|
54
55
|
provider: provider_for(request_url),
|
|
55
|
-
provider_response_id: detect_stream_response_id(events),
|
|
56
56
|
model: model,
|
|
57
|
-
|
|
58
|
-
output_tokens: 0,
|
|
59
|
-
total_tokens: 0,
|
|
60
|
-
stream: true,
|
|
61
|
-
usage_source: :unknown
|
|
57
|
+
provider_response_id: response_id
|
|
62
58
|
)
|
|
63
59
|
end
|
|
64
60
|
end
|
|
65
61
|
|
|
66
62
|
def detect_stream_usage(events)
|
|
67
|
-
events
|
|
68
|
-
data = event[:data]
|
|
69
|
-
next unless data.is_a?(Hash)
|
|
70
|
-
|
|
63
|
+
find_event_value(events, reverse: true) do |data|
|
|
71
64
|
usage = data["usage"]
|
|
72
|
-
|
|
65
|
+
usage if usage.is_a?(Hash)
|
|
73
66
|
end
|
|
74
|
-
nil
|
|
75
67
|
end
|
|
76
68
|
|
|
77
69
|
def detect_stream_model(events)
|
|
78
|
-
events
|
|
79
|
-
data = event[:data]
|
|
80
|
-
next unless data.is_a?(Hash)
|
|
81
|
-
|
|
82
|
-
model = data["model"]
|
|
83
|
-
return model if model && !model.to_s.empty?
|
|
84
|
-
end
|
|
85
|
-
nil
|
|
70
|
+
find_event_value(events) { |data| data["model"] }
|
|
86
71
|
end
|
|
87
72
|
|
|
88
73
|
def detect_stream_response_id(events)
|
|
89
|
-
events
|
|
90
|
-
data = event[:data]
|
|
91
|
-
next unless data.is_a?(Hash)
|
|
92
|
-
|
|
93
|
-
id = data["id"] || data.dig("response", "id")
|
|
94
|
-
return id if id && !id.to_s.empty?
|
|
95
|
-
end
|
|
96
|
-
nil
|
|
74
|
+
find_event_value(events) { |data| data["id"] || data.dig("response", "id") }
|
|
97
75
|
end
|
|
98
76
|
|
|
99
77
|
def regular_input_tokens(usage, cache_read)
|
|
@@ -13,10 +13,14 @@ module LlmCostTracker
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def register(parser)
|
|
16
|
+
parser = coerce_parser(parser)
|
|
17
|
+
|
|
16
18
|
MUTEX.synchronize do
|
|
17
19
|
current = @parsers || default_parsers.freeze
|
|
18
20
|
@parsers = ([parser] + current).freeze
|
|
19
21
|
end
|
|
22
|
+
|
|
23
|
+
parser
|
|
20
24
|
end
|
|
21
25
|
|
|
22
26
|
def find_for(url)
|
|
@@ -24,8 +28,8 @@ module LlmCostTracker
|
|
|
24
28
|
end
|
|
25
29
|
|
|
26
30
|
def find_for_provider(provider)
|
|
27
|
-
provider_name = provider.to_s
|
|
28
|
-
parsers.find { |parser| parser.
|
|
31
|
+
provider_name = provider.to_s.downcase
|
|
32
|
+
parsers.find { |parser| provider_names_for(parser).include?(provider_name) }
|
|
29
33
|
end
|
|
30
34
|
|
|
31
35
|
def reset!
|
|
@@ -34,6 +38,17 @@ module LlmCostTracker
|
|
|
34
38
|
|
|
35
39
|
private
|
|
36
40
|
|
|
41
|
+
def coerce_parser(parser)
|
|
42
|
+
return parser.new if parser.is_a?(Class) && parser <= Base
|
|
43
|
+
return parser if parser.is_a?(Base)
|
|
44
|
+
|
|
45
|
+
raise ArgumentError, "parser must be a LlmCostTracker::Parsers::Base instance or class"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def provider_names_for(parser)
|
|
49
|
+
Array(parser.provider_names).map { |name| name.to_s.downcase }
|
|
50
|
+
end
|
|
51
|
+
|
|
37
52
|
def default_parsers
|
|
38
53
|
[Openai.new, OpenaiCompatible.new, Anthropic.new, Gemini.new]
|
|
39
54
|
end
|
|
@@ -17,47 +17,67 @@ module LlmCostTracker
|
|
|
17
17
|
return unless event.cost&.total_cost
|
|
18
18
|
return unless period_totals_enabled?
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
model = period_total_model
|
|
21
|
+
model.upsert_all(
|
|
22
|
+
period_rows(event),
|
|
23
|
+
on_duplicate: total_upsert_sql(model),
|
|
24
|
+
record_timestamps: true,
|
|
25
|
+
unique_by: unique_by(model, %i[period period_start])
|
|
26
|
+
)
|
|
21
27
|
end
|
|
22
28
|
|
|
23
29
|
def monthly_total(time: Time.now.utc)
|
|
24
|
-
|
|
30
|
+
period_totals(%i[monthly], time: time).fetch(:monthly)
|
|
25
31
|
end
|
|
26
32
|
|
|
27
33
|
def daily_total(time: Time.now.utc)
|
|
28
|
-
|
|
34
|
+
period_totals(%i[daily], time: time).fetch(:daily)
|
|
29
35
|
end
|
|
30
36
|
|
|
31
|
-
|
|
37
|
+
def period_totals(periods, time: Time.now.utc)
|
|
38
|
+
periods = periods.map(&:to_sym).select { |period| PERIODS.key?(period) }
|
|
39
|
+
return {} if periods.empty?
|
|
32
40
|
|
|
33
|
-
def period_total(period, time)
|
|
34
41
|
if period_totals_enabled?
|
|
35
|
-
|
|
36
|
-
.where(period: PERIODS.fetch(period), period_start: bucket_for(period, time))
|
|
37
|
-
.pick(:total_cost)
|
|
38
|
-
.to_f
|
|
42
|
+
rollup_period_totals(periods, time)
|
|
39
43
|
else
|
|
40
|
-
|
|
41
|
-
.where(tracked_at: range_start_for(period, time)..time)
|
|
42
|
-
.sum(:total_cost)
|
|
43
|
-
.to_f
|
|
44
|
+
periods.to_h { |period| [period, fallback_period_total(period, time)] }
|
|
44
45
|
end
|
|
45
46
|
end
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
)
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def period_rows(event)
|
|
51
|
+
PERIODS.map do |period, name|
|
|
52
|
+
{
|
|
53
|
+
period: name,
|
|
54
|
+
period_start: bucket_for(period, event.tracked_at),
|
|
55
|
+
total_cost: event.cost.total_cost
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def rollup_period_totals(periods, time)
|
|
61
|
+
buckets = periods.to_h { |period| [period, bucket_for(period, time)] }
|
|
62
|
+
index = buckets.to_h { |period, bucket| [[PERIODS.fetch(period), bucket], period] }
|
|
63
|
+
totals = periods.to_h { |period| [period, 0.0] }
|
|
64
|
+
|
|
65
|
+
period_total_model
|
|
66
|
+
.where(period: periods.map { |period| PERIODS.fetch(period) }, period_start: buckets.values)
|
|
67
|
+
.pluck(:period, :period_start, :total_cost)
|
|
68
|
+
.each do |name, start, total|
|
|
69
|
+
period = index[[name, start.to_date]]
|
|
70
|
+
totals[period] = total.to_f if period
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
totals
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def fallback_period_total(period, time)
|
|
77
|
+
LlmCostTracker::LlmApiCall
|
|
78
|
+
.where(tracked_at: range_start_for(period, time)..time)
|
|
79
|
+
.sum(:total_cost)
|
|
80
|
+
.to_f
|
|
61
81
|
end
|
|
62
82
|
|
|
63
83
|
def period_totals_enabled?
|
|
@@ -2,58 +2,96 @@
|
|
|
2
2
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
module TagsColumn
|
|
5
|
+
USAGE_BREAKDOWN_COLUMNS = %w[
|
|
6
|
+
cache_read_input_tokens
|
|
7
|
+
cache_write_input_tokens
|
|
8
|
+
hidden_output_tokens
|
|
9
|
+
].freeze
|
|
10
|
+
|
|
11
|
+
USAGE_BREAKDOWN_COST_COLUMNS = %w[
|
|
12
|
+
cache_read_input_cost
|
|
13
|
+
cache_write_input_cost
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
def reset_column_information
|
|
17
|
+
remove_instance_variable(:@lct_schema_capabilities) if instance_variable_defined?(:@lct_schema_capabilities)
|
|
18
|
+
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
|
|
5
22
|
def tags_json_column?
|
|
6
|
-
|
|
23
|
+
capabilities = lct_schema_capabilities
|
|
24
|
+
|
|
25
|
+
capabilities.fetch(:tags_jsonb) || capabilities.fetch(:tags_mysql_json)
|
|
7
26
|
end
|
|
8
27
|
|
|
9
28
|
def tags_jsonb_column?
|
|
10
|
-
|
|
11
|
-
return false unless column
|
|
12
|
-
|
|
13
|
-
column.type == :jsonb || column.sql_type.to_s.downcase == "jsonb"
|
|
29
|
+
lct_schema_capabilities.fetch(:tags_jsonb)
|
|
14
30
|
end
|
|
15
31
|
|
|
16
32
|
def tags_mysql_json_column?
|
|
17
|
-
|
|
18
|
-
return false unless column
|
|
19
|
-
return false if tags_jsonb_column?
|
|
20
|
-
|
|
21
|
-
column.type == :json && connection.adapter_name.match?(/mysql/i)
|
|
33
|
+
lct_schema_capabilities.fetch(:tags_mysql_json)
|
|
22
34
|
end
|
|
23
35
|
|
|
24
36
|
def latency_column?
|
|
25
|
-
|
|
37
|
+
lct_schema_capabilities.fetch(:latency)
|
|
26
38
|
end
|
|
27
39
|
|
|
28
40
|
def stream_column?
|
|
29
|
-
|
|
41
|
+
lct_schema_capabilities.fetch(:stream)
|
|
30
42
|
end
|
|
31
43
|
|
|
32
44
|
def usage_source_column?
|
|
33
|
-
|
|
45
|
+
lct_schema_capabilities.fetch(:usage_source)
|
|
34
46
|
end
|
|
35
47
|
|
|
36
48
|
def provider_response_id_column?
|
|
37
|
-
|
|
49
|
+
lct_schema_capabilities.fetch(:provider_response_id)
|
|
38
50
|
end
|
|
39
51
|
|
|
40
52
|
def pricing_mode_column?
|
|
41
|
-
|
|
53
|
+
lct_schema_capabilities.fetch(:pricing_mode)
|
|
42
54
|
end
|
|
43
55
|
|
|
44
56
|
def usage_breakdown_columns?
|
|
45
|
-
|
|
46
|
-
cache_read_input_tokens
|
|
47
|
-
cache_write_input_tokens
|
|
48
|
-
hidden_output_tokens
|
|
49
|
-
].all? { |column| columns_hash.key?(column) }
|
|
57
|
+
lct_schema_capabilities.fetch(:usage_breakdown)
|
|
50
58
|
end
|
|
51
59
|
|
|
52
60
|
def usage_breakdown_cost_columns?
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
61
|
+
lct_schema_capabilities.fetch(:usage_breakdown_cost)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def lct_schema_capabilities
|
|
67
|
+
columns = columns_hash
|
|
68
|
+
adapter_name = connection.adapter_name
|
|
69
|
+
cache = @lct_schema_capabilities
|
|
70
|
+
|
|
71
|
+
return cache.fetch(:values) if cache && cache.fetch(:columns).equal?(columns) &&
|
|
72
|
+
cache.fetch(:adapter_name) == adapter_name
|
|
73
|
+
|
|
74
|
+
values = build_lct_schema_capabilities(columns, adapter_name)
|
|
75
|
+
@lct_schema_capabilities = { columns: columns, adapter_name: adapter_name, values: values }
|
|
76
|
+
values
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def build_lct_schema_capabilities(columns, adapter_name)
|
|
80
|
+
tag_column = columns["tags"]
|
|
81
|
+
tags_jsonb = tag_column && (tag_column.type == :jsonb || tag_column.sql_type.to_s.downcase == "jsonb")
|
|
82
|
+
tags_mysql_json = tag_column && !tags_jsonb && tag_column.type == :json && adapter_name.match?(/mysql/i)
|
|
83
|
+
|
|
84
|
+
{
|
|
85
|
+
tags_jsonb: tags_jsonb ? true : false,
|
|
86
|
+
tags_mysql_json: tags_mysql_json ? true : false,
|
|
87
|
+
latency: columns.key?("latency_ms"),
|
|
88
|
+
stream: columns.key?("stream"),
|
|
89
|
+
usage_source: columns.key?("usage_source"),
|
|
90
|
+
provider_response_id: columns.key?("provider_response_id"),
|
|
91
|
+
pricing_mode: columns.key?("pricing_mode"),
|
|
92
|
+
usage_breakdown: USAGE_BREAKDOWN_COLUMNS.all? { |column| columns.key?(column) },
|
|
93
|
+
usage_breakdown_cost: USAGE_BREAKDOWN_COST_COLUMNS.all? { |column| columns.key?(column) }
|
|
94
|
+
}
|
|
57
95
|
end
|
|
58
96
|
end
|
|
59
97
|
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.4.
|
|
4
|
+
version: 0.4.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sergii Khomenko
|
|
@@ -240,6 +240,7 @@ files:
|
|
|
240
240
|
- app/helpers/llm_cost_tracker/dashboard_query_helper.rb
|
|
241
241
|
- app/helpers/llm_cost_tracker/pagination_helper.rb
|
|
242
242
|
- app/services/llm_cost_tracker/dashboard/data_quality.rb
|
|
243
|
+
- app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb
|
|
243
244
|
- app/services/llm_cost_tracker/dashboard/filter.rb
|
|
244
245
|
- app/services/llm_cost_tracker/dashboard/overview_stats.rb
|
|
245
246
|
- app/services/llm_cost_tracker/dashboard/provider_breakdown.rb
|