llm_cost_tracker 0.7.0 → 0.7.2
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 +31 -0
- data/README.md +21 -16
- data/app/assets/llm_cost_tracker/application.css +3 -0
- data/app/controllers/llm_cost_tracker/application_controller.rb +22 -4
- data/app/controllers/llm_cost_tracker/calls_controller.rb +6 -11
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +2 -1
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +5 -1
- data/app/controllers/llm_cost_tracker/models_controller.rb +0 -1
- data/app/controllers/llm_cost_tracker/tags_controller.rb +1 -8
- data/app/helpers/llm_cost_tracker/application_helper.rb +2 -1
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +1 -2
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -1
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +10 -27
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +58 -0
- data/app/models/llm_cost_tracker/ingestion/event.rb +13 -0
- data/app/models/llm_cost_tracker/ingestion/lease.rb +11 -0
- data/app/models/llm_cost_tracker/ledger/call.rb +45 -0
- data/app/models/llm_cost_tracker/ledger/call_metrics.rb +66 -0
- data/app/models/llm_cost_tracker/ledger/period/grouping.rb +71 -0
- data/app/models/llm_cost_tracker/ledger/period/total.rb +13 -0
- data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +19 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +111 -94
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +2 -2
- data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -18
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +58 -67
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +59 -0
- data/app/services/llm_cost_tracker/dashboard/params.rb +26 -0
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +18 -20
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -13
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +28 -61
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +8 -21
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +12 -47
- data/app/views/llm_cost_tracker/calls/index.html.erb +12 -18
- data/app/views/llm_cost_tracker/calls/show.html.erb +30 -32
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +17 -19
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +108 -135
- data/app/views/llm_cost_tracker/models/index.html.erb +8 -9
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +13 -2
- data/app/views/llm_cost_tracker/tags/show.html.erb +20 -20
- data/lib/llm_cost_tracker/budget.rb +8 -20
- data/lib/llm_cost_tracker/capture/stream.rb +9 -0
- data/lib/llm_cost_tracker/capture/stream_collector.rb +189 -0
- data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +41 -73
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
- data/lib/llm_cost_tracker/configuration.rb +33 -36
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +61 -0
- data/lib/llm_cost_tracker/doctor/check.rb +7 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +22 -59
- data/lib/llm_cost_tracker/doctor/price_check.rb +60 -0
- data/lib/llm_cost_tracker/doctor.rb +63 -71
- data/lib/llm_cost_tracker/errors.rb +4 -15
- data/lib/llm_cost_tracker/event.rb +6 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +42 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +7 -7
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +3 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +22 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +9 -14
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +0 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +12 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +2 -2
- data/lib/llm_cost_tracker/{storage/active_record_inbox_batch.rb → ingestion/batch.rb} +21 -20
- data/lib/llm_cost_tracker/ingestion/inbox.rb +105 -0
- data/lib/llm_cost_tracker/{storage/active_record_ingestor_lease.rb → ingestion/lease_claim.rb} +5 -7
- data/lib/llm_cost_tracker/{storage/active_record_ingestor.rb → ingestion/worker.rb} +38 -48
- data/lib/llm_cost_tracker/ingestion.rb +129 -0
- data/lib/llm_cost_tracker/integrations/anthropic.rb +66 -31
- data/lib/llm_cost_tracker/integrations/base.rb +73 -34
- data/lib/llm_cost_tracker/integrations/openai.rb +43 -37
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +40 -30
- data/lib/llm_cost_tracker/integrations.rb +43 -0
- data/lib/llm_cost_tracker/ledger/period/totals.rb +66 -0
- data/lib/llm_cost_tracker/{storage/active_record_periods.rb → ledger/period.rb} +2 -2
- data/lib/llm_cost_tracker/ledger/rollups/batch.rb +43 -0
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +46 -0
- data/lib/llm_cost_tracker/ledger/rollups.rb +87 -0
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +51 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +101 -0
- data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +32 -0
- data/lib/llm_cost_tracker/ledger/store.rb +60 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +29 -0
- data/lib/llm_cost_tracker/ledger/tags/sql.rb +33 -0
- data/lib/llm_cost_tracker/ledger.rb +13 -0
- data/lib/llm_cost_tracker/logging.rb +3 -6
- data/lib/llm_cost_tracker/middleware/faraday.rb +88 -46
- data/lib/llm_cost_tracker/parsers/anthropic.rb +62 -29
- data/lib/llm_cost_tracker/parsers/base.rb +12 -21
- data/lib/llm_cost_tracker/parsers/gemini.rb +50 -25
- 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 +58 -25
- data/lib/llm_cost_tracker/parsers/sse.rb +4 -7
- data/lib/llm_cost_tracker/parsers.rb +20 -0
- data/lib/llm_cost_tracker/prices.json +361 -36
- data/lib/llm_cost_tracker/pricing/components.rb +37 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +46 -50
- data/lib/llm_cost_tracker/pricing/explainer.rb +25 -30
- data/lib/llm_cost_tracker/pricing/lookup.rb +67 -46
- data/lib/llm_cost_tracker/pricing/registry.rb +156 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +107 -0
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +53 -0
- data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +63 -0
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +31 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +159 -0
- data/lib/llm_cost_tracker/pricing/unknown.rb +46 -0
- data/lib/llm_cost_tracker/pricing.rb +33 -32
- data/lib/llm_cost_tracker/railtie.rb +7 -8
- data/lib/llm_cost_tracker/report/data.rb +72 -0
- data/lib/llm_cost_tracker/report/formatter.rb +69 -0
- data/lib/llm_cost_tracker/report.rb +8 -8
- data/lib/llm_cost_tracker/retention.rb +27 -10
- data/lib/llm_cost_tracker/tags/context.rb +35 -0
- data/lib/llm_cost_tracker/tags/key.rb +18 -0
- data/lib/llm_cost_tracker/tags/sanitizer.rb +68 -0
- data/lib/llm_cost_tracker/token_usage.rb +67 -0
- data/lib/llm_cost_tracker/tracker.rb +39 -69
- data/lib/llm_cost_tracker/usage_capture.rb +37 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +56 -78
- data/lib/tasks/llm_cost_tracker.rake +18 -13
- metadata +54 -58
- data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +0 -81
- data/app/services/llm_cost_tracker/pagination.rb +0 -57
- data/lib/llm_cost_tracker/active_record_adapter.rb +0 -53
- data/lib/llm_cost_tracker/capture_verifier.rb +0 -64
- data/lib/llm_cost_tracker/cost.rb +0 -12
- data/lib/llm_cost_tracker/doctor/capture_check.rb +0 -39
- data/lib/llm_cost_tracker/event_metadata.rb +0 -52
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +0 -29
- data/lib/llm_cost_tracker/inbox_event.rb +0 -9
- data/lib/llm_cost_tracker/ingestor_lease.rb +0 -9
- data/lib/llm_cost_tracker/integrations/object_reader.rb +0 -56
- data/lib/llm_cost_tracker/integrations/registry.rb +0 -71
- data/lib/llm_cost_tracker/llm_api_call.rb +0 -60
- data/lib/llm_cost_tracker/llm_api_call_metrics.rb +0 -63
- data/lib/llm_cost_tracker/parameter_hash.rb +0 -33
- data/lib/llm_cost_tracker/parsed_usage.rb +0 -72
- data/lib/llm_cost_tracker/parsers/registry.rb +0 -58
- data/lib/llm_cost_tracker/period_grouping.rb +0 -67
- data/lib/llm_cost_tracker/period_total.rb +0 -9
- data/lib/llm_cost_tracker/price_freshness.rb +0 -38
- data/lib/llm_cost_tracker/price_registry.rb +0 -144
- data/lib/llm_cost_tracker/price_sync/fetcher.rb +0 -104
- data/lib/llm_cost_tracker/price_sync/registry_diff.rb +0 -51
- data/lib/llm_cost_tracker/price_sync/registry_loader.rb +0 -61
- data/lib/llm_cost_tracker/price_sync/registry_writer.rb +0 -29
- data/lib/llm_cost_tracker/price_sync.rb +0 -144
- data/lib/llm_cost_tracker/report_data.rb +0 -94
- data/lib/llm_cost_tracker/report_formatter.rb +0 -67
- data/lib/llm_cost_tracker/request_url.rb +0 -20
- data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -167
- data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +0 -13
- data/lib/llm_cost_tracker/storage/active_record_inbox.rb +0 -160
- data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +0 -84
- data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +0 -41
- data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +0 -42
- data/lib/llm_cost_tracker/storage/active_record_rollups.rb +0 -146
- data/lib/llm_cost_tracker/storage/active_record_store.rb +0 -145
- data/lib/llm_cost_tracker/storage/writer.rb +0 -35
- data/lib/llm_cost_tracker/stream_capture.rb +0 -7
- data/lib/llm_cost_tracker/stream_collector.rb +0 -199
- data/lib/llm_cost_tracker/tag_accessors.rb +0 -15
- data/lib/llm_cost_tracker/tag_context.rb +0 -52
- data/lib/llm_cost_tracker/tag_key.rb +0 -16
- data/lib/llm_cost_tracker/tag_query.rb +0 -43
- data/lib/llm_cost_tracker/tag_sanitizer.rb +0 -81
- data/lib/llm_cost_tracker/tag_sql.rb +0 -34
- data/lib/llm_cost_tracker/tags_column.rb +0 -105
- data/lib/llm_cost_tracker/unknown_pricing.rb +0 -54
- data/lib/llm_cost_tracker/usage_breakdown.rb +0 -30
- data/lib/llm_cost_tracker/value_helpers.rb +0 -40
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../period"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ledger
|
|
7
|
+
module Period
|
|
8
|
+
class Totals
|
|
9
|
+
def self.call(periods, time:)
|
|
10
|
+
new(periods, time: time).totals
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(periods, time:)
|
|
14
|
+
@periods = Period.valid_keys(periods)
|
|
15
|
+
@time = time
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def totals
|
|
19
|
+
return {} if periods.empty?
|
|
20
|
+
|
|
21
|
+
snapshot_totals
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
attr_reader :periods, :time
|
|
27
|
+
|
|
28
|
+
def snapshot_totals
|
|
29
|
+
values = periods.to_h { |period| [period, 0.0] }
|
|
30
|
+
sql = periods.map { |period| snapshot_select(period) }.join(" UNION ALL ")
|
|
31
|
+
LlmCostTracker::Ledger::Call.find_by_sql(sql).each do |row|
|
|
32
|
+
values[row.period_key.to_sym] = row.total_cost.to_f
|
|
33
|
+
end
|
|
34
|
+
values
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def snapshot_select(period)
|
|
38
|
+
start = Period.range_start(period, time)
|
|
39
|
+
"SELECT #{connection.quote(period.to_s)} AS period_key, " \
|
|
40
|
+
"(#{rollup_total_sql(period)}) + (#{pending_total_sql(start)}) AS total_cost"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def rollup_total_sql(period)
|
|
44
|
+
table = connection.quote_table_name("llm_cost_tracker_period_totals")
|
|
45
|
+
"COALESCE((SELECT total_cost FROM #{table} " \
|
|
46
|
+
"WHERE period = #{connection.quote(Period::PERIODS.fetch(period))} " \
|
|
47
|
+
"AND period_start = #{connection.quote(Period.bucket(period, time))} LIMIT 1), 0)"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def pending_total_sql(start)
|
|
51
|
+
table = connection.quote_table_name(Ingestion::Event.table_name)
|
|
52
|
+
total_cost = connection.quote_column_name("total_cost")
|
|
53
|
+
tracked_at = connection.quote_column_name("tracked_at")
|
|
54
|
+
attempts = connection.quote_column_name("attempts")
|
|
55
|
+
"COALESCE((SELECT SUM(#{total_cost}) FROM #{table} " \
|
|
56
|
+
"WHERE #{attempts} < #{Ingestion::Event::MAX_ATTEMPTS} " \
|
|
57
|
+
"AND #{tracked_at} BETWEEN #{connection.quote(start)} AND #{connection.quote(time)}), 0)"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def connection
|
|
61
|
+
LlmCostTracker::Ledger::Call.connection
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
require_relative "../period"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
module Ledger
|
|
9
|
+
class Rollups
|
|
10
|
+
class Batch
|
|
11
|
+
def self.rows(events)
|
|
12
|
+
new(events).rows
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(events)
|
|
16
|
+
@events = events
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def rows
|
|
20
|
+
totals.map do |(period, period_start), total_cost|
|
|
21
|
+
{
|
|
22
|
+
period: period,
|
|
23
|
+
period_start: period_start,
|
|
24
|
+
total_cost: total_cost
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
attr_reader :events
|
|
32
|
+
|
|
33
|
+
def totals
|
|
34
|
+
events.each_with_object(Hash.new { |hash, key| hash[key] = BigDecimal("0") }) do |event, rows|
|
|
35
|
+
Period::PERIODS.each do |period, name|
|
|
36
|
+
rows[[name, Period.bucket(period, event.tracked_at)]] += BigDecimal(event.total_cost.to_s)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../schema/adapter"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ledger
|
|
7
|
+
class Rollups
|
|
8
|
+
class UpsertSql
|
|
9
|
+
def self.call(model)
|
|
10
|
+
new(model).call
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(model)
|
|
14
|
+
@model = model
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
return Arel.sql(mysql_sql) if Ledger::Schema::Adapter.mysql?(connection)
|
|
19
|
+
return Arel.sql(postgres_sql) if Ledger::Schema::Adapter.postgresql?(connection)
|
|
20
|
+
|
|
21
|
+
Ledger::Schema::Adapter.ensure_supported!(connection)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
attr_reader :model
|
|
27
|
+
|
|
28
|
+
def postgres_sql
|
|
29
|
+
total_cost = connection.quote_column_name("total_cost")
|
|
30
|
+
updated_at = connection.quote_column_name("updated_at")
|
|
31
|
+
|
|
32
|
+
"#{total_cost} = #{model.quoted_table_name}.#{total_cost} + excluded.#{total_cost}, " \
|
|
33
|
+
"#{updated_at} = excluded.#{updated_at}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def mysql_sql
|
|
37
|
+
"total_cost = total_cost + VALUES(total_cost), updated_at = VALUES(updated_at)"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def connection
|
|
41
|
+
model.connection
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
require_relative "period"
|
|
6
|
+
require_relative "rollups/batch"
|
|
7
|
+
require_relative "rollups/upsert_sql"
|
|
8
|
+
|
|
9
|
+
module LlmCostTracker
|
|
10
|
+
module Ledger
|
|
11
|
+
class Rollups
|
|
12
|
+
class << self
|
|
13
|
+
def increment!(event)
|
|
14
|
+
return unless event.total_cost
|
|
15
|
+
|
|
16
|
+
Period::Total.upsert_all(
|
|
17
|
+
period_rows(event),
|
|
18
|
+
on_duplicate: Ledger::Rollups::UpsertSql.call(Period::Total),
|
|
19
|
+
record_timestamps: true,
|
|
20
|
+
unique_by: unique_by(Period::Total, %i[period period_start])
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def increment_many!(events)
|
|
25
|
+
events = Array(events).select(&:total_cost)
|
|
26
|
+
return if events.empty?
|
|
27
|
+
|
|
28
|
+
Period::Total.upsert_all(
|
|
29
|
+
Ledger::Rollups::Batch.rows(events),
|
|
30
|
+
on_duplicate: Ledger::Rollups::UpsertSql.call(Period::Total),
|
|
31
|
+
record_timestamps: true,
|
|
32
|
+
unique_by: unique_by(Period::Total, %i[period period_start])
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def decrement!(call_rows)
|
|
37
|
+
totals = period_decrement_totals(call_rows)
|
|
38
|
+
return if totals.empty?
|
|
39
|
+
|
|
40
|
+
apply_decrements(totals)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def period_rows(event)
|
|
46
|
+
Period::PERIODS.map do |period, name|
|
|
47
|
+
{
|
|
48
|
+
period: name,
|
|
49
|
+
period_start: Period.bucket(period, event.tracked_at),
|
|
50
|
+
total_cost: event.total_cost
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def period_decrement_totals(call_rows)
|
|
56
|
+
call_rows.each_with_object(Hash.new { |totals, key| totals[key] = BigDecimal("0") }) do |row, totals|
|
|
57
|
+
_id, tracked_at, total_cost = row
|
|
58
|
+
next unless total_cost
|
|
59
|
+
|
|
60
|
+
Period::PERIODS.each_key do |period|
|
|
61
|
+
totals[[period, Period.bucket(period, tracked_at)]] += BigDecimal(total_cost.to_s)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def apply_decrements(totals)
|
|
67
|
+
now = Time.now.utc
|
|
68
|
+
|
|
69
|
+
totals.each do |(period, period_start), amount|
|
|
70
|
+
row = Period::Total.lock.find_by(period: Period::PERIODS.fetch(period),
|
|
71
|
+
period_start: period_start)
|
|
72
|
+
next unless row
|
|
73
|
+
|
|
74
|
+
row.update_columns(total_cost: [BigDecimal(row.total_cost.to_s) - amount, BigDecimal("0")].max,
|
|
75
|
+
updated_at: now)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def unique_by(model, column)
|
|
80
|
+
return unless model.connection.supports_insert_conflict_target?
|
|
81
|
+
|
|
82
|
+
column
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../errors"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ledger
|
|
7
|
+
module Schema
|
|
8
|
+
module Adapter
|
|
9
|
+
MYSQL_ADAPTERS = %w[
|
|
10
|
+
ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter
|
|
11
|
+
ActiveRecord::ConnectionAdapters::Mysql2Adapter
|
|
12
|
+
ActiveRecord::ConnectionAdapters::TrilogyAdapter
|
|
13
|
+
].freeze
|
|
14
|
+
POSTGRESQL_ADAPTERS = %w[
|
|
15
|
+
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
|
|
16
|
+
].freeze
|
|
17
|
+
MYSQL_PATTERN = /mysql|trilogy|mariadb/i
|
|
18
|
+
POSTGRESQL_PATTERN = /postgres/i
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def mysql?(value)
|
|
22
|
+
adapter_instance?(value, MYSQL_ADAPTERS) || adapter_name(value).match?(MYSQL_PATTERN)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def postgresql?(value)
|
|
26
|
+
adapter_instance?(value, POSTGRESQL_ADAPTERS) || adapter_name(value).match?(POSTGRESQL_PATTERN)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def ensure_supported!(value)
|
|
30
|
+
return if mysql?(value) || postgresql?(value)
|
|
31
|
+
|
|
32
|
+
raise Error, "Unsupported database adapter: #{adapter_name(value)}. Use PostgreSQL or MySQL."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def adapter_instance?(value, class_names)
|
|
38
|
+
class_names.any? do |class_name|
|
|
39
|
+
adapter_class = class_name.safe_constantize
|
|
40
|
+
adapter_class && value.is_a?(adapter_class)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def adapter_name(value)
|
|
45
|
+
value.try(:adapter_name).presence || value.to_s
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "llm_cost_tracker/ledger/schema/adapter"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ledger
|
|
7
|
+
module Schema
|
|
8
|
+
module Calls
|
|
9
|
+
CURRENT_SCHEMA_COLUMNS = %w[
|
|
10
|
+
event_id
|
|
11
|
+
provider
|
|
12
|
+
model
|
|
13
|
+
input_tokens
|
|
14
|
+
output_tokens
|
|
15
|
+
total_tokens
|
|
16
|
+
cache_read_input_tokens
|
|
17
|
+
cache_write_input_tokens
|
|
18
|
+
cache_write_1h_input_tokens
|
|
19
|
+
hidden_output_tokens
|
|
20
|
+
input_cost
|
|
21
|
+
output_cost
|
|
22
|
+
total_cost
|
|
23
|
+
cache_read_input_cost
|
|
24
|
+
cache_write_input_cost
|
|
25
|
+
cache_write_1h_input_cost
|
|
26
|
+
latency_ms
|
|
27
|
+
stream
|
|
28
|
+
usage_source
|
|
29
|
+
provider_response_id
|
|
30
|
+
pricing_mode
|
|
31
|
+
tags
|
|
32
|
+
tracked_at
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
def current_schema?
|
|
37
|
+
current_schema_errors.empty?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def current_schema_errors
|
|
41
|
+
schema_capabilities.fetch(:current_schema_errors)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def missing_current_schema_columns
|
|
45
|
+
schema_capabilities.fetch(:missing_current_schema_columns)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def schema_capabilities
|
|
51
|
+
columns = Ledger::Call.columns_hash
|
|
52
|
+
adapter_name = Ledger::Call.connection.adapter_name
|
|
53
|
+
cache = @schema_capabilities
|
|
54
|
+
|
|
55
|
+
return cache.fetch(:values) if cache && cache.fetch(:columns).equal?(columns) &&
|
|
56
|
+
cache.fetch(:adapter_name) == adapter_name
|
|
57
|
+
|
|
58
|
+
values = build_schema_capabilities(columns, adapter_name)
|
|
59
|
+
@schema_capabilities = { columns: columns, adapter_name: adapter_name, values: values }
|
|
60
|
+
values
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def build_schema_capabilities(columns, adapter_name)
|
|
64
|
+
Ledger::Schema::Adapter.ensure_supported!(adapter_name)
|
|
65
|
+
|
|
66
|
+
{
|
|
67
|
+
missing_current_schema_columns: missing_columns_for(columns),
|
|
68
|
+
current_schema_errors: schema_errors_for(columns, adapter_name)
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def schema_errors_for(columns, adapter_name)
|
|
73
|
+
errors = []
|
|
74
|
+
missing = missing_columns_for(columns)
|
|
75
|
+
errors << "missing columns: #{missing.join(', ')}" if missing.any?
|
|
76
|
+
|
|
77
|
+
tag_column = columns["tags"]
|
|
78
|
+
if tag_column
|
|
79
|
+
postgresql = Ledger::Schema::Adapter.postgresql?(adapter_name)
|
|
80
|
+
expected_type = postgresql ? "jsonb" : "json"
|
|
81
|
+
valid_type =
|
|
82
|
+
if postgresql
|
|
83
|
+
tag_column.type == :jsonb || tag_column.sql_type.to_s.downcase == "jsonb"
|
|
84
|
+
else
|
|
85
|
+
tag_column.type == :json
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
errors << "tags column must use #{expected_type}" unless valid_type
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
errors
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def missing_columns_for(columns)
|
|
95
|
+
CURRENT_SCHEMA_COLUMNS - columns.keys
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Ledger
|
|
5
|
+
module Schema
|
|
6
|
+
module PeriodTotals
|
|
7
|
+
REQUIRED_COLUMNS = %w[period period_start total_cost].freeze
|
|
8
|
+
UNIQUE_COLUMNS = %i[period period_start].freeze
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def current_schema_errors
|
|
12
|
+
connection = Ledger::Call.connection
|
|
13
|
+
table_name = Ledger::Period::Total.table_name
|
|
14
|
+
return ["llm_cost_tracker_period_totals table is missing"] unless connection.data_source_exists?(table_name)
|
|
15
|
+
|
|
16
|
+
errors = []
|
|
17
|
+
missing = REQUIRED_COLUMNS - Ledger::Period::Total.columns_hash.keys
|
|
18
|
+
errors << "missing columns: #{missing.join(', ')}" if missing.any?
|
|
19
|
+
errors << "missing unique index: period, period_start" unless unique_period_index?(connection, table_name)
|
|
20
|
+
errors
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def unique_period_index?(connection, table_name)
|
|
26
|
+
connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../pricing"
|
|
4
|
+
require_relative "rollups"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Ledger
|
|
8
|
+
class Store
|
|
9
|
+
class << self
|
|
10
|
+
def insert_many(events)
|
|
11
|
+
events = Array(events)
|
|
12
|
+
return [] if events.empty?
|
|
13
|
+
|
|
14
|
+
model = LlmCostTracker::Ledger::Call
|
|
15
|
+
insertable = new_events(model, events)
|
|
16
|
+
|
|
17
|
+
if insertable.any?
|
|
18
|
+
rows = insertable.map { |event| attributes_for(event) }
|
|
19
|
+
model.insert_all!(rows, record_timestamps: true, returning: false)
|
|
20
|
+
Ledger::Rollups.increment_many!(insertable)
|
|
21
|
+
end
|
|
22
|
+
events
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
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
|
+
attributes = {
|
|
32
|
+
event_id: event.event_id,
|
|
33
|
+
provider: event.provider,
|
|
34
|
+
model: event.model,
|
|
35
|
+
tags: tags,
|
|
36
|
+
tracked_at: event.tracked_at,
|
|
37
|
+
pricing_mode: event.pricing_mode,
|
|
38
|
+
latency_ms: event.latency_ms,
|
|
39
|
+
stream: event.stream,
|
|
40
|
+
usage_source: event.usage_source,
|
|
41
|
+
provider_response_id: event.provider_response_id
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
attributes.merge(usage).merge(Pricing.stored_cost_attributes(event.cost || {}))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def new_events(model, events)
|
|
48
|
+
existing_ids = model.where(event_id: events.map(&:event_id)).pluck(:event_id).to_set
|
|
49
|
+
events.reject { |event| existing_ids.include?(event.event_id) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def stringify_tag_value(value)
|
|
53
|
+
return value.transform_values { |nested| stringify_tag_value(nested) } if value.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
value.to_s
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "../schema/adapter"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
module Ledger
|
|
9
|
+
module Tags
|
|
10
|
+
module Query
|
|
11
|
+
class << self
|
|
12
|
+
def apply(model, tags)
|
|
13
|
+
normalized_tags = (tags || {}).to_h.transform_keys(&:to_s).transform_values(&:to_s)
|
|
14
|
+
return model.all if normalized_tags.empty?
|
|
15
|
+
|
|
16
|
+
connection = model.connection
|
|
17
|
+
json = normalized_tags.to_json
|
|
18
|
+
|
|
19
|
+
if Schema::Adapter.postgresql?(connection)
|
|
20
|
+
model.where("tags @> ?::jsonb", json)
|
|
21
|
+
else
|
|
22
|
+
model.where("JSON_CONTAINS(tags, ?)", json)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../schema/adapter"
|
|
4
|
+
require_relative "../../tags/key"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Ledger
|
|
8
|
+
module Tags
|
|
9
|
+
module Sql
|
|
10
|
+
class << self
|
|
11
|
+
def value_expression(model, key, table_name:)
|
|
12
|
+
key = LlmCostTracker::Tags::Key.validate!(key)
|
|
13
|
+
column = "#{table_name}.#{model.connection.quote_column_name('tags')}"
|
|
14
|
+
|
|
15
|
+
if Ledger::Schema::Adapter.postgresql?(model.connection)
|
|
16
|
+
"#{column}->>#{model.connection.quote(key)}"
|
|
17
|
+
elsif Ledger::Schema::Adapter.mysql?(model.connection)
|
|
18
|
+
"JSON_UNQUOTE(JSON_EXTRACT(#{column}, #{model.connection.quote(json_path(key))}))"
|
|
19
|
+
else
|
|
20
|
+
Ledger::Schema::Adapter.ensure_supported!(model.connection)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def json_path(key)
|
|
27
|
+
"$.\"#{key}\""
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ledger/schema/adapter"
|
|
4
|
+
require_relative "ledger/schema/calls"
|
|
5
|
+
require_relative "ledger/schema/period_totals"
|
|
6
|
+
require_relative "ledger/tags/query"
|
|
7
|
+
require_relative "ledger/tags/sql"
|
|
8
|
+
require_relative "ledger/period"
|
|
9
|
+
require_relative "ledger/rollups/batch"
|
|
10
|
+
require_relative "ledger/rollups/upsert_sql"
|
|
11
|
+
require_relative "ledger/rollups"
|
|
12
|
+
require_relative "ledger/store"
|
|
13
|
+
require_relative "ledger/period/totals"
|
|
@@ -19,9 +19,10 @@ module LlmCostTracker
|
|
|
19
19
|
|
|
20
20
|
def log(level, message)
|
|
21
21
|
message = prefixed(message)
|
|
22
|
+
logger = Rails.logger
|
|
22
23
|
|
|
23
|
-
if
|
|
24
|
-
|
|
24
|
+
if logger
|
|
25
|
+
logger.try(level, message)
|
|
25
26
|
else
|
|
26
27
|
Kernel.warn(message)
|
|
27
28
|
end
|
|
@@ -35,10 +36,6 @@ module LlmCostTracker
|
|
|
35
36
|
|
|
36
37
|
"#{PREFIX} #{message}"
|
|
37
38
|
end
|
|
38
|
-
|
|
39
|
-
def rails_logger
|
|
40
|
-
Rails.logger if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
41
|
-
end
|
|
42
39
|
end
|
|
43
40
|
end
|
|
44
41
|
end
|