llm_cost_tracker 0.7.0 → 0.7.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 +11 -9
- 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 +182 -0
- data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +40 -72
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
- data/lib/llm_cost_tracker/configuration.rb +28 -35
- 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 +52 -34
- data/lib/llm_cost_tracker/integrations/base.rb +73 -34
- data/lib/llm_cost_tracker/integrations/openai.rb +45 -39
- 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 +35 -36
- data/lib/llm_cost_tracker/parsers/anthropic.rb +38 -27
- data/lib/llm_cost_tracker/parsers/base.rb +10 -19
- data/lib/llm_cost_tracker/parsers/gemini.rb +15 -16
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +24 -19
- 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 +52 -11
- data/lib/llm_cost_tracker/pricing/components.rb +37 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +40 -50
- data/lib/llm_cost_tracker/pricing/explainer.rb +12 -23
- data/lib/llm_cost_tracker/pricing/lookup.rb +24 -25
- 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 +143 -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 +38 -70
- 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,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
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
require "faraday"
|
|
4
4
|
require "json"
|
|
5
|
+
require "uri"
|
|
5
6
|
|
|
6
7
|
require_relative "../logging"
|
|
7
|
-
require_relative "../
|
|
8
|
-
require_relative "../stream_capture"
|
|
8
|
+
require_relative "../capture/stream"
|
|
9
9
|
|
|
10
10
|
module LlmCostTracker
|
|
11
11
|
module Middleware
|
|
@@ -20,12 +20,12 @@ module LlmCostTracker
|
|
|
20
20
|
|
|
21
21
|
request_url = request_env.url.to_s
|
|
22
22
|
request_body = read_body(request_env.body) || ""
|
|
23
|
-
parser = Parsers
|
|
23
|
+
parser = Parsers.find_for(request_url)
|
|
24
24
|
streaming = parser&.streaming_request?(request_url, request_body)
|
|
25
25
|
stream_buffer = install_stream_tap(request_env) if streaming
|
|
26
26
|
|
|
27
27
|
Tracker.enforce_budget! if parser
|
|
28
|
-
started_at =
|
|
28
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
29
29
|
|
|
30
30
|
@app.call(request_env).on_complete do |response_env|
|
|
31
31
|
process(
|
|
@@ -34,7 +34,7 @@ module LlmCostTracker
|
|
|
34
34
|
request_url: request_url,
|
|
35
35
|
request_body: request_body,
|
|
36
36
|
response_env: response_env,
|
|
37
|
-
latency_ms:
|
|
37
|
+
latency_ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round,
|
|
38
38
|
streaming: streaming,
|
|
39
39
|
stream_buffer: stream_buffer
|
|
40
40
|
)
|
|
@@ -56,15 +56,9 @@ module LlmCostTracker
|
|
|
56
56
|
return unless parsed
|
|
57
57
|
|
|
58
58
|
Tracker.record(
|
|
59
|
-
|
|
60
|
-
model: parsed.model,
|
|
61
|
-
input_tokens: parsed.input_tokens,
|
|
62
|
-
output_tokens: parsed.output_tokens,
|
|
59
|
+
capture: parsed,
|
|
63
60
|
latency_ms: latency_ms,
|
|
64
|
-
|
|
65
|
-
usage_source: parsed.usage_source,
|
|
66
|
-
provider_response_id: parsed.provider_response_id,
|
|
67
|
-
metadata: resolved_tags(request_env).merge(parsed.metadata)
|
|
61
|
+
metadata: resolved_tags(request_env)
|
|
68
62
|
)
|
|
69
63
|
rescue LlmCostTracker::Error
|
|
70
64
|
raise
|
|
@@ -76,7 +70,7 @@ module LlmCostTracker
|
|
|
76
70
|
response_body = read_body(response_env.body)
|
|
77
71
|
unless response_body
|
|
78
72
|
Logging.warn(
|
|
79
|
-
"Unable to read response body for #{
|
|
73
|
+
"Unable to read response body for #{request_url_label(request_url)}; " \
|
|
80
74
|
"known streaming responses are captured automatically, or via LlmCostTracker.track_stream " \
|
|
81
75
|
"for custom clients."
|
|
82
76
|
)
|
|
@@ -93,9 +87,9 @@ module LlmCostTracker
|
|
|
93
87
|
end
|
|
94
88
|
|
|
95
89
|
body = stream_buffer&.dig(:buffer)&.string
|
|
96
|
-
body = read_body(response_env.body) if body.
|
|
90
|
+
body = read_body(response_env.body) if body.blank?
|
|
97
91
|
|
|
98
|
-
if body.
|
|
92
|
+
if body.blank?
|
|
99
93
|
Logging.warn(capture_warning(request_url, stream_buffer))
|
|
100
94
|
return parser.parse_stream(request_url, request_body, response_env.status, [])
|
|
101
95
|
end
|
|
@@ -105,16 +99,17 @@ module LlmCostTracker
|
|
|
105
99
|
end
|
|
106
100
|
|
|
107
101
|
def install_stream_tap(request_env)
|
|
108
|
-
|
|
102
|
+
request = request_env.try(:request)
|
|
103
|
+
return nil unless request
|
|
109
104
|
|
|
110
|
-
original =
|
|
105
|
+
original = request.on_data
|
|
111
106
|
return nil unless original
|
|
112
107
|
|
|
113
108
|
state = { buffer: StringIO.new, bytes: 0, overflowed: false }
|
|
114
|
-
|
|
109
|
+
request.on_data = proc do |chunk, size, env|
|
|
115
110
|
chunk = chunk.to_s
|
|
116
111
|
unless state[:overflowed]
|
|
117
|
-
if state[:bytes] + chunk.bytesize <=
|
|
112
|
+
if state[:bytes] + chunk.bytesize <= Capture::Stream::LIMIT_BYTES
|
|
118
113
|
state[:buffer] << chunk
|
|
119
114
|
state[:bytes] += chunk.bytesize
|
|
120
115
|
else
|
|
@@ -136,38 +131,42 @@ module LlmCostTracker
|
|
|
136
131
|
when nil then ""
|
|
137
132
|
when Hash, Array then body.to_json
|
|
138
133
|
else
|
|
139
|
-
body.
|
|
134
|
+
body.try(:to_str)
|
|
140
135
|
end
|
|
141
136
|
end
|
|
142
137
|
|
|
143
138
|
def resolved_tags(request_env)
|
|
144
|
-
tags =
|
|
139
|
+
tags =
|
|
140
|
+
if @tags.respond_to?(:call)
|
|
141
|
+
@tags.arity.zero? ? @tags.call : @tags.call(request_env)
|
|
142
|
+
else
|
|
143
|
+
@tags
|
|
144
|
+
end
|
|
145
145
|
return {} if tags.nil?
|
|
146
146
|
|
|
147
147
|
tags.to_h
|
|
148
148
|
end
|
|
149
149
|
|
|
150
|
-
def call_tags(request_env)
|
|
151
|
-
@tags.arity.zero? ? @tags.call : @tags.call(request_env)
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
def monotonic_time
|
|
155
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
def elapsed_ms(started_at)
|
|
159
|
-
((monotonic_time - started_at) * 1000).round
|
|
160
|
-
end
|
|
161
|
-
|
|
162
150
|
def capture_warning(request_url, stream_buffer)
|
|
163
151
|
unless stream_buffer&.dig(:overflowed)
|
|
164
|
-
return "Unable to capture streaming response for #{
|
|
152
|
+
return "Unable to capture streaming response for #{request_url_label(request_url)}; " \
|
|
165
153
|
"recording usage_source=unknown. Use LlmCostTracker.track_stream for manual capture."
|
|
166
154
|
end
|
|
167
155
|
|
|
168
|
-
"Streaming response for #{
|
|
156
|
+
"Streaming response for #{request_url_label(request_url)} exceeded #{Capture::Stream::LIMIT_BYTES} bytes; " \
|
|
169
157
|
"recording usage_source=unknown. Use LlmCostTracker.track_stream for manual capture."
|
|
170
158
|
end
|
|
159
|
+
|
|
160
|
+
def request_url_label(value)
|
|
161
|
+
uri = URI.parse(value.to_s)
|
|
162
|
+
uri.query = nil
|
|
163
|
+
uri.fragment = nil
|
|
164
|
+
uri.try(:user=, nil)
|
|
165
|
+
uri.try(:password=, nil)
|
|
166
|
+
uri.to_s
|
|
167
|
+
rescue URI::InvalidURIError
|
|
168
|
+
value.to_s.split("?", 2).first
|
|
169
|
+
end
|
|
171
170
|
end
|
|
172
171
|
end
|
|
173
172
|
end
|