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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ccb9a8365f4a06026a4352385efa1318ac59ce403cb848e0c9aff992fc80f64c
4
- data.tar.gz: f21503cd322e923dc5bde0139cc61bc1547cef01eac59fe7a3861e1ab33e9860
3
+ metadata.gz: d2cdd5f30c6fbd8c0168549b0853e9d8bc54586e60921733ce11a89a1d86078c
4
+ data.tar.gz: c91384579df6acdeb04d24b62f8bf916040f98156fd2bc882c94afc534f7dba5
5
5
  SHA512:
6
- metadata.gz: 304ab6de6404f070b21b1dd72ce9eae2b44fb2fc7845eae8831a04971ed2b8ec2b6f740bc082fb36cfa42d90f0be59ab5800d43d72b68f04918a113b6d7d8cbd
7
- data.tar.gz: afa2e92a99062bb1e0b4a00ab1d0762ca688f1890e0d76a29801881e2319e68db217c036d8f8c5d99558b580d5d4c039f8b3334283631763ee093fd12d369329
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 month over budget.
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
- uri = URI.parse(url.to_s)
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(request_url, request_body, response_status, response_body)
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&.dig("usage")
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.new)
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
- total = scope.count
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: scope.unknown_pricing.count,
37
- untagged_calls_count: total - scope.with_json_tags.count,
38
- **latency_stats(scope),
39
- **stream_stats(scope),
40
- **provider_response_id_stats(scope),
41
- **usage_stats(scope),
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(scope)
49
- latency_present = LlmCostTracker::LlmApiCall.latency_column?
50
+ def latency_stats(aggregates, model:)
51
+ latency_present = model.latency_column?
50
52
 
51
53
  {
52
- missing_latency_count: latency_present ? scope.where(latency_ms: nil).count : nil,
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(scope)
58
- stream_present = LlmCostTracker::LlmApiCall.stream_column?
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 ? scope.streaming.count : nil,
62
- streaming_missing_usage_count: streaming_missing_usage_count(scope, stream_present),
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(scope)
68
- column_present = LlmCostTracker::LlmApiCall.provider_response_id_column?
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: column_present ? scope.missing_provider_response_id.count : nil,
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(scope)
77
- usage_breakdown_present = LlmCostTracker::LlmApiCall.usage_breakdown_columns?
78
- usage_breakdown_cost_present = LlmCostTracker::LlmApiCall.usage_breakdown_cost_columns?
79
- sums = sum_columns(scope, usage_sum_columns(usage_breakdown_present, usage_breakdown_cost_present))
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: sums[:input_tokens].to_i,
84
- cache_read_input_tokens: usage_breakdown_present ? sums[:cache_read_input_tokens].to_i : nil,
85
- cache_write_input_tokens: usage_breakdown_present ? sums[:cache_write_input_tokens].to_i : nil,
86
- output_tokens: sums[:output_tokens].to_i,
87
- hidden_output_tokens: usage_breakdown_present ? sums[:hidden_output_tokens].to_i : nil,
88
- input_cost: decimal_sum(sums[:input_cost]),
89
- cache_read_input_cost: usage_breakdown_cost_present ? decimal_sum(sums[:cache_read_input_cost]) : nil,
90
- cache_write_input_cost: usage_breakdown_cost_present ? decimal_sum(sums[:cache_write_input_cost]) : nil,
91
- output_cost: decimal_sum(sums[:output_cost])
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
- enforce_period_budget(:monthly, config.monthly_budget)
14
- enforce_period_budget(:daily, config.daily_budget)
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
- check_period_budget(event, config, :daily, config.daily_budget)
23
- check_period_budget(event, config, :monthly, config.monthly_budget)
24
- end
30
+ budgets = check_period_budgets(config)
31
+ totals = totals_for_check(event, config, budgets)
25
32
 
26
- private
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
- total = active_record_total(period, time: Time.now.utc)
32
- return unless total >= budget
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 check_period_budget(event, config, period, budget)
48
- return unless budget
49
-
50
- total = if config.active_record?
51
- active_record_total(period, time: event.tracked_at)
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 active_record_total(period, time:)
61
- case period
62
- when :monthly then active_record_monthly_total(time: time)
63
- when :daily then active_record_daily_total(time: time)
64
- end
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 active_record_monthly_total(time: Time.now.utc)
68
- require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
69
- require_relative "storage/active_record_store" unless defined?(LlmCostTracker::Storage::ActiveRecordStore)
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
- LlmCostTracker::Storage::ActiveRecordStore.monthly_total(time: time)
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 active_record_daily_total(time: Time.now.utc)
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.daily_total(time: time)
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 #{connection.quote(period)} AS period,
35
- #{bucket_sql} AS period_start,
36
- SUM(total_cost) AS total_cost,
53
+ SELECT aggregated.period,
54
+ aggregated.period_start,
55
+ aggregated.total_cost,
37
56
  CURRENT_TIMESTAMP,
38
57
  CURRENT_TIMESTAMP
39
- FROM llm_api_calls
40
- WHERE total_cost IS NOT NULL
41
- GROUP BY #{bucket_sql}
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
- uri = URI.parse(url.to_s)
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
- usage ? build_stream_result(model, usage, response_id) : build_unknown_stream_result(model, response_id)
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 = nil
62
- latest_delta = nil
63
-
64
- events.each do |event|
65
- data = event[: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.each do |event|
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.each do |event|
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
- uri = URI.parse(url.to_s)
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: stream_response_id(events)
54
+ provider_response_id: response_id
59
55
  )
60
56
  else
61
- ParsedUsage.build(
57
+ build_unknown_stream_usage(
62
58
  provider: "gemini",
63
- provider_response_id: stream_response_id(events),
64
59
  model: model,
65
- input_tokens: 0,
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
- latest = nil
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
- latest = meta if meta.is_a?(Hash)
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.each do |event|
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
- URI.parse(request_url.to_s).path.match?(STREAM_PATH_PATTERN)
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 = URI.parse(url.to_s)
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
- uri = URI.parse(url.to_s)
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 = URI.parse(url.to_s)
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 = URI.parse(request_url.to_s)
41
- provider_for_host(uri.host) || "openai_compatible"
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 provider_for_host(host)
47
- LlmCostTracker.configuration.openai_compatible_providers[host.to_s.downcase]&.to_s
48
- end
39
+ def provider_for_uri(uri)
40
+ return nil unless uri
49
41
 
50
- def tracked_path?(path)
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: detect_stream_response_id(events),
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
- ParsedUsage.build(
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
- input_tokens: 0,
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.reverse_each do |event|
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
- return usage if usage.is_a?(Hash) && !usage.empty?
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.each do |event|
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.each do |event|
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.provider_names.include?(provider_name) }
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
- PERIODS.each_key { |period| increment_period_total(period, event) }
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
- period_total(:monthly, time)
30
+ period_totals(%i[monthly], time: time).fetch(:monthly)
25
31
  end
26
32
 
27
33
  def daily_total(time: Time.now.utc)
28
- period_total(:daily, time)
34
+ period_totals(%i[daily], time: time).fetch(:daily)
29
35
  end
30
36
 
31
- private
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
- period_total_model
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
- LlmCostTracker::LlmApiCall
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
- def increment_period_total(period, event)
48
- model = period_total_model
49
- model.upsert_all(
50
- [
51
- {
52
- period: PERIODS.fetch(period),
53
- period_start: bucket_for(period, event.tracked_at),
54
- total_cost: event.cost.total_cost
55
- }
56
- ],
57
- on_duplicate: total_upsert_sql(model),
58
- record_timestamps: true,
59
- unique_by: unique_by(model, %i[period period_start])
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?
@@ -50,6 +50,10 @@ module LlmCostTracker
50
50
  ActiveRecordRollups.daily_total(time: time)
51
51
  end
52
52
 
53
+ def period_totals(periods, time: Time.now.utc)
54
+ ActiveRecordRollups.period_totals(periods, time: time)
55
+ end
56
+
53
57
  private
54
58
 
55
59
  def stringify_tags(tags)
@@ -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
- tags_jsonb_column? || tags_mysql_json_column?
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
- column = columns_hash["tags"]
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
- column = columns_hash["tags"]
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
- columns_hash.key?("latency_ms")
37
+ lct_schema_capabilities.fetch(:latency)
26
38
  end
27
39
 
28
40
  def stream_column?
29
- columns_hash.key?("stream")
41
+ lct_schema_capabilities.fetch(:stream)
30
42
  end
31
43
 
32
44
  def usage_source_column?
33
- columns_hash.key?("usage_source")
45
+ lct_schema_capabilities.fetch(:usage_source)
34
46
  end
35
47
 
36
48
  def provider_response_id_column?
37
- columns_hash.key?("provider_response_id")
49
+ lct_schema_capabilities.fetch(:provider_response_id)
38
50
  end
39
51
 
40
52
  def pricing_mode_column?
41
- columns_hash.key?("pricing_mode")
53
+ lct_schema_capabilities.fetch(:pricing_mode)
42
54
  end
43
55
 
44
56
  def usage_breakdown_columns?
45
- %w[
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
- %w[
54
- cache_read_input_cost
55
- cache_write_input_cost
56
- ].all? { |column| columns_hash.key?(column) }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.4.0"
4
+ VERSION = "0.4.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm_cost_tracker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
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