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
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "active_record_inbox"
|
|
4
|
-
require_relative "active_record_period_totals"
|
|
5
|
-
require_relative "active_record_rollups"
|
|
6
|
-
|
|
7
|
-
module LlmCostTracker
|
|
8
|
-
module Storage
|
|
9
|
-
class ActiveRecordStore
|
|
10
|
-
class << self
|
|
11
|
-
def reset!
|
|
12
|
-
ActiveRecordRollups.reset!
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def save(event)
|
|
16
|
-
model = LlmCostTracker::LlmApiCall
|
|
17
|
-
attributes = attributes_for(event, model)
|
|
18
|
-
|
|
19
|
-
model.transaction do
|
|
20
|
-
call = model.create!(attributes)
|
|
21
|
-
ActiveRecordRollups.increment!(event)
|
|
22
|
-
call
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def insert_many(events)
|
|
27
|
-
events = Array(events)
|
|
28
|
-
return [] if events.empty?
|
|
29
|
-
|
|
30
|
-
model = LlmCostTracker::LlmApiCall
|
|
31
|
-
insertable = new_events(model, events)
|
|
32
|
-
|
|
33
|
-
if insertable.any?
|
|
34
|
-
rows = insertable.map { |event| attributes_for(event, model) }
|
|
35
|
-
model.insert_all!(rows, **insert_options)
|
|
36
|
-
ActiveRecordRollups.increment_many!(insertable)
|
|
37
|
-
end
|
|
38
|
-
events
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def attributes_for(event, model = LlmCostTracker::LlmApiCall)
|
|
42
|
-
tags = stringify_tags(event.tags || {})
|
|
43
|
-
columns = model.columns_hash
|
|
44
|
-
|
|
45
|
-
attributes = {
|
|
46
|
-
provider: event.provider,
|
|
47
|
-
model: event.model,
|
|
48
|
-
input_tokens: event.input_tokens,
|
|
49
|
-
output_tokens: event.output_tokens,
|
|
50
|
-
total_tokens: event.total_tokens,
|
|
51
|
-
input_cost: event.cost&.input_cost,
|
|
52
|
-
output_cost: event.cost&.output_cost,
|
|
53
|
-
total_cost: event.cost&.total_cost,
|
|
54
|
-
tags: tags_for_storage(tags, model),
|
|
55
|
-
tracked_at: event.tracked_at
|
|
56
|
-
}
|
|
57
|
-
attributes[:event_id] = event.event_id if columns.key?("event_id")
|
|
58
|
-
optional_attributes(event).each do |name, value|
|
|
59
|
-
attributes[name] = value if columns.key?(name.to_s)
|
|
60
|
-
end
|
|
61
|
-
attributes[:latency_ms] = event.latency_ms if columns.key?("latency_ms")
|
|
62
|
-
attributes[:stream] = event.stream if columns.key?("stream")
|
|
63
|
-
attributes[:usage_source] = event.usage_source if columns.key?("usage_source")
|
|
64
|
-
attributes[:provider_response_id] = event.provider_response_id if columns.key?("provider_response_id")
|
|
65
|
-
|
|
66
|
-
attributes
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def monthly_total(time: Time.now.utc)
|
|
70
|
-
period_totals(%i[monthly], time: time).fetch(:monthly)
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def daily_total(time: Time.now.utc)
|
|
74
|
-
period_totals(%i[daily], time: time).fetch(:daily)
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def period_totals(periods, time: Time.now.utc)
|
|
78
|
-
ActiveRecordPeriodTotals.call(periods, time: time)
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def prune(cutoff:, batch_size:)
|
|
82
|
-
deleted = 0
|
|
83
|
-
loop do
|
|
84
|
-
batch = prune_batch(cutoff, batch_size)
|
|
85
|
-
deleted += batch
|
|
86
|
-
break if batch < batch_size
|
|
87
|
-
end
|
|
88
|
-
deleted
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
private
|
|
92
|
-
|
|
93
|
-
def new_events(model, events)
|
|
94
|
-
return events unless model.columns_hash.key?("event_id")
|
|
95
|
-
|
|
96
|
-
existing_ids = model.where(event_id: events.map(&:event_id)).pluck(:event_id).to_set
|
|
97
|
-
events.reject { |event| existing_ids.include?(event.event_id) }
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def insert_options = { record_timestamps: true, returning: false }
|
|
101
|
-
|
|
102
|
-
def prune_batch(cutoff, batch_size)
|
|
103
|
-
LlmCostTracker::LlmApiCall.transaction do
|
|
104
|
-
rows = LlmCostTracker::LlmApiCall
|
|
105
|
-
.where(tracked_at: ...cutoff)
|
|
106
|
-
.order(:id)
|
|
107
|
-
.limit(batch_size)
|
|
108
|
-
.lock
|
|
109
|
-
.pluck(:id, :tracked_at, :total_cost)
|
|
110
|
-
next 0 if rows.empty?
|
|
111
|
-
|
|
112
|
-
deleted = LlmCostTracker::LlmApiCall.where(id: rows.map(&:first)).delete_all
|
|
113
|
-
ActiveRecordRollups.decrement!(rows) if deleted.positive?
|
|
114
|
-
deleted
|
|
115
|
-
end
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def stringify_tags(tags)
|
|
119
|
-
tags.transform_keys(&:to_s).transform_values { |value| stringify_tag_value(value) }
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def tags_for_storage(tags, model)
|
|
123
|
-
model.tags_json_column? ? tags : tags.to_json
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def optional_attributes(event)
|
|
127
|
-
{
|
|
128
|
-
cache_read_input_tokens: event.cache_read_input_tokens,
|
|
129
|
-
cache_write_input_tokens: event.cache_write_input_tokens,
|
|
130
|
-
hidden_output_tokens: event.hidden_output_tokens,
|
|
131
|
-
cache_read_input_cost: event.cost&.cache_read_input_cost,
|
|
132
|
-
cache_write_input_cost: event.cost&.cache_write_input_cost,
|
|
133
|
-
pricing_mode: event.pricing_mode
|
|
134
|
-
}
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def stringify_tag_value(value)
|
|
138
|
-
return value.transform_values { |nested| stringify_tag_value(nested) } if value.is_a?(Hash)
|
|
139
|
-
|
|
140
|
-
value.to_s
|
|
141
|
-
end
|
|
142
|
-
end
|
|
143
|
-
end
|
|
144
|
-
end
|
|
145
|
-
end
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "../errors"
|
|
4
|
-
require_relative "../logging"
|
|
5
|
-
require_relative "active_record_backend"
|
|
6
|
-
|
|
7
|
-
module LlmCostTracker
|
|
8
|
-
module Storage
|
|
9
|
-
class Writer
|
|
10
|
-
class << self
|
|
11
|
-
def save(event)
|
|
12
|
-
ActiveRecordBackend.save(event)
|
|
13
|
-
rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError
|
|
14
|
-
raise
|
|
15
|
-
rescue StandardError => e
|
|
16
|
-
handle_error(e)
|
|
17
|
-
false
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
private
|
|
21
|
-
|
|
22
|
-
def handle_error(error)
|
|
23
|
-
case LlmCostTracker.configuration.storage_error_behavior
|
|
24
|
-
when :ignore
|
|
25
|
-
nil
|
|
26
|
-
when :warn
|
|
27
|
-
Logging.warn("ActiveRecord ledger write failed: #{error.class}: #{error.message}")
|
|
28
|
-
when :raise
|
|
29
|
-
raise StorageError, error
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
end
|
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "monitor"
|
|
4
|
-
|
|
5
|
-
require_relative "stream_capture"
|
|
6
|
-
require_relative "value_helpers"
|
|
7
|
-
|
|
8
|
-
module LlmCostTracker
|
|
9
|
-
class StreamCollector
|
|
10
|
-
attr_reader :provider
|
|
11
|
-
|
|
12
|
-
def initialize(provider:, model:, latency_ms: nil, provider_response_id: nil, pricing_mode: nil, metadata: {})
|
|
13
|
-
@provider = provider.to_s
|
|
14
|
-
@model = model
|
|
15
|
-
@latency_ms = latency_ms
|
|
16
|
-
@provider_response_id = provider_response_id
|
|
17
|
-
@pricing_mode = pricing_mode
|
|
18
|
-
@metadata = ValueHelpers.deep_dup(metadata || {})
|
|
19
|
-
@events = []
|
|
20
|
-
@captured_bytes = 0
|
|
21
|
-
@overflowed = false
|
|
22
|
-
@explicit_usage = nil
|
|
23
|
-
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
24
|
-
@finished = false
|
|
25
|
-
@monitor = Monitor.new
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def model = @monitor.synchronize { @model }
|
|
29
|
-
|
|
30
|
-
def metadata = @monitor.synchronize { ValueHelpers.deep_dup(@metadata) }
|
|
31
|
-
|
|
32
|
-
def provider_response_id = @monitor.synchronize { @provider_response_id }
|
|
33
|
-
|
|
34
|
-
def model=(value)
|
|
35
|
-
@monitor.synchronize do
|
|
36
|
-
ensure_open!
|
|
37
|
-
@model = value
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def provider_response_id=(value)
|
|
42
|
-
@monitor.synchronize do
|
|
43
|
-
ensure_open!
|
|
44
|
-
@provider_response_id = value
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def event(data, type: nil)
|
|
49
|
-
@monitor.synchronize do
|
|
50
|
-
ensure_open!
|
|
51
|
-
capture_event(data, type: type) unless data.nil?
|
|
52
|
-
end
|
|
53
|
-
self
|
|
54
|
-
end
|
|
55
|
-
alias chunk event
|
|
56
|
-
|
|
57
|
-
def usage(input_tokens:, output_tokens:, **extra)
|
|
58
|
-
@monitor.synchronize do
|
|
59
|
-
ensure_open!
|
|
60
|
-
@explicit_usage = ValueHelpers.deep_dup(
|
|
61
|
-
extra.merge(
|
|
62
|
-
input_tokens: input_tokens.to_i,
|
|
63
|
-
output_tokens: output_tokens.to_i
|
|
64
|
-
)
|
|
65
|
-
)
|
|
66
|
-
end
|
|
67
|
-
self
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def finish!(errored: false)
|
|
71
|
-
snapshot = @monitor.synchronize do
|
|
72
|
-
return if @finished
|
|
73
|
-
|
|
74
|
-
@finished = true
|
|
75
|
-
{
|
|
76
|
-
events: @events.dup,
|
|
77
|
-
overflowed: @overflowed,
|
|
78
|
-
explicit_usage: ValueHelpers.deep_dup(@explicit_usage),
|
|
79
|
-
model: @model,
|
|
80
|
-
latency_ms: @latency_ms,
|
|
81
|
-
provider_response_id: @provider_response_id,
|
|
82
|
-
pricing_mode: @pricing_mode,
|
|
83
|
-
metadata: ValueHelpers.deep_dup(@metadata)
|
|
84
|
-
}
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
parsed = build_parsed_usage(snapshot)
|
|
88
|
-
Tracker.record(
|
|
89
|
-
provider: parsed.provider,
|
|
90
|
-
model: parsed.model,
|
|
91
|
-
input_tokens: parsed.input_tokens,
|
|
92
|
-
output_tokens: parsed.output_tokens,
|
|
93
|
-
latency_ms: snapshot[:latency_ms] || elapsed_ms,
|
|
94
|
-
stream: true,
|
|
95
|
-
usage_source: parsed.usage_source,
|
|
96
|
-
provider_response_id: parsed.provider_response_id || snapshot[:provider_response_id],
|
|
97
|
-
pricing_mode: snapshot[:pricing_mode],
|
|
98
|
-
metadata: error_metadata(errored).merge(snapshot[:metadata]).merge(parsed.metadata)
|
|
99
|
-
)
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
private
|
|
103
|
-
|
|
104
|
-
def ensure_open!
|
|
105
|
-
return unless @finished
|
|
106
|
-
|
|
107
|
-
raise FrozenError, "can't modify finished LlmCostTracker::StreamCollector"
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def build_parsed_usage(snapshot)
|
|
111
|
-
return build_from_explicit_usage(snapshot) if snapshot[:explicit_usage]
|
|
112
|
-
return build_unknown_usage(snapshot) if snapshot[:overflowed]
|
|
113
|
-
|
|
114
|
-
parsed = Parsers::Registry.find_for_provider(@provider)&.parse_stream(nil, nil, 200, snapshot[:events])
|
|
115
|
-
return finalize(parsed, snapshot) if parsed
|
|
116
|
-
|
|
117
|
-
build_unknown_usage(snapshot)
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def finalize(parsed, snapshot)
|
|
121
|
-
parsed.with(
|
|
122
|
-
provider: @provider,
|
|
123
|
-
model: present_model(parsed.model) || present_model(snapshot[:model]) || ParsedUsage::UNKNOWN_MODEL
|
|
124
|
-
)
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def present_model(value)
|
|
128
|
-
return nil if value.nil?
|
|
129
|
-
|
|
130
|
-
string = value.to_s
|
|
131
|
-
return nil if string.empty? || string == "unknown"
|
|
132
|
-
|
|
133
|
-
string
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def build_from_explicit_usage(snapshot)
|
|
137
|
-
explicit = snapshot[:explicit_usage]
|
|
138
|
-
input = explicit[:input_tokens]
|
|
139
|
-
output = explicit[:output_tokens]
|
|
140
|
-
extras = explicit.except(:input_tokens, :output_tokens)
|
|
141
|
-
|
|
142
|
-
ParsedUsage.build(
|
|
143
|
-
provider: @provider,
|
|
144
|
-
model: snapshot[:model] || ParsedUsage::UNKNOWN_MODEL,
|
|
145
|
-
input_tokens: input,
|
|
146
|
-
output_tokens: output,
|
|
147
|
-
stream: true,
|
|
148
|
-
usage_source: :manual,
|
|
149
|
-
**extras
|
|
150
|
-
)
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def build_unknown_usage(snapshot)
|
|
154
|
-
ParsedUsage.build(
|
|
155
|
-
provider: @provider,
|
|
156
|
-
model: snapshot[:model] || ParsedUsage::UNKNOWN_MODEL,
|
|
157
|
-
input_tokens: 0,
|
|
158
|
-
output_tokens: 0,
|
|
159
|
-
total_tokens: 0,
|
|
160
|
-
stream: true,
|
|
161
|
-
usage_source: :unknown
|
|
162
|
-
)
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
def capture_event(data, type:)
|
|
166
|
-
size = event_bytes(data, type)
|
|
167
|
-
if @captured_bytes + size <= StreamCapture::LIMIT_BYTES
|
|
168
|
-
@events << { event: type, data: ValueHelpers.deep_dup(data) }
|
|
169
|
-
@captured_bytes += size
|
|
170
|
-
else
|
|
171
|
-
@overflowed = true
|
|
172
|
-
@events.clear
|
|
173
|
-
end
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
def event_bytes(data, type)
|
|
177
|
-
type.to_s.bytesize + estimated_bytes(data) + 32
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
def estimated_bytes(value)
|
|
181
|
-
case value
|
|
182
|
-
when Hash
|
|
183
|
-
value.sum { |key, nested| estimated_bytes(key) + estimated_bytes(nested) + 4 }
|
|
184
|
-
when Array
|
|
185
|
-
value.sum { |nested| estimated_bytes(nested) + 2 }
|
|
186
|
-
when String
|
|
187
|
-
value.bytesize + 2
|
|
188
|
-
when Numeric, true, false, nil
|
|
189
|
-
value.to_s.bytesize
|
|
190
|
-
else
|
|
191
|
-
value.to_s.bytesize + 2
|
|
192
|
-
end
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
def error_metadata(errored) = errored ? { stream_errored: true } : {}
|
|
196
|
-
|
|
197
|
-
def elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at) * 1000).round
|
|
198
|
-
end
|
|
199
|
-
end
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
|
|
5
|
-
module LlmCostTracker
|
|
6
|
-
module TagAccessors
|
|
7
|
-
def parsed_tags
|
|
8
|
-
return tags.transform_keys(&:to_s) if tags.is_a?(Hash)
|
|
9
|
-
|
|
10
|
-
JSON.parse(tags || "{}")
|
|
11
|
-
rescue JSON::ParserError
|
|
12
|
-
{}
|
|
13
|
-
end
|
|
14
|
-
end
|
|
15
|
-
end
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "active_support/isolated_execution_state"
|
|
4
|
-
|
|
5
|
-
require_relative "value_helpers"
|
|
6
|
-
|
|
7
|
-
module LlmCostTracker
|
|
8
|
-
module TagContext
|
|
9
|
-
KEY = :llm_cost_tracker_tags
|
|
10
|
-
|
|
11
|
-
class << self
|
|
12
|
-
def with(tags)
|
|
13
|
-
stack = current_stack
|
|
14
|
-
ActiveSupport::IsolatedExecutionState[KEY] = stack + [normalize(tags)]
|
|
15
|
-
yield
|
|
16
|
-
ensure
|
|
17
|
-
ActiveSupport::IsolatedExecutionState[KEY] = stack
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def tags
|
|
21
|
-
config_tags.merge(scoped_tags)
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def clear!
|
|
25
|
-
ActiveSupport::IsolatedExecutionState[KEY] = []
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
private
|
|
29
|
-
|
|
30
|
-
def config_tags
|
|
31
|
-
normalize(resolve_default_tags)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def resolve_default_tags
|
|
35
|
-
tags = LlmCostTracker.configuration.default_tags
|
|
36
|
-
tags.respond_to?(:call) ? tags.call : tags
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def scoped_tags
|
|
40
|
-
current_stack.reduce({}) { |merged, tags| merged.merge(tags) }
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def current_stack
|
|
44
|
-
ActiveSupport::IsolatedExecutionState[KEY] || []
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def normalize(tags)
|
|
48
|
-
ValueHelpers.deep_dup(tags || {}).to_h
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
end
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module LlmCostTracker
|
|
4
|
-
module TagKey
|
|
5
|
-
PATTERN = /\A[\w.-]+\z/
|
|
6
|
-
|
|
7
|
-
class << self
|
|
8
|
-
def validate!(key, error_class: ArgumentError)
|
|
9
|
-
key = key.to_s
|
|
10
|
-
return key if key.match?(PATTERN)
|
|
11
|
-
|
|
12
|
-
raise error_class, "invalid tag key: #{key.inspect}"
|
|
13
|
-
end
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
end
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
|
|
5
|
-
module LlmCostTracker
|
|
6
|
-
module TagQuery
|
|
7
|
-
class << self
|
|
8
|
-
def apply(model, tags)
|
|
9
|
-
normalized_tags = normalize_tags(tags)
|
|
10
|
-
return model.all if normalized_tags.empty?
|
|
11
|
-
|
|
12
|
-
return postgres_json_query(model, normalized_tags) if model.tags_jsonb_column?
|
|
13
|
-
return mysql_json_query(model, normalized_tags) if model.tags_mysql_json_column?
|
|
14
|
-
|
|
15
|
-
text_query(model, normalized_tags)
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def normalize_tags(tags)
|
|
19
|
-
(tags || {}).to_h.transform_keys(&:to_s).transform_values(&:to_s)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
private
|
|
23
|
-
|
|
24
|
-
def postgres_json_query(model, tags)
|
|
25
|
-
model.where("tags @> ?::jsonb", tags.to_json)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def mysql_json_query(model, tags)
|
|
29
|
-
model.where("JSON_CONTAINS(tags, ?)", tags.to_json)
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def text_query(model, tags)
|
|
33
|
-
tags.reduce(model.all) do |relation, (key, value)|
|
|
34
|
-
relation.where("tags LIKE ? ESCAPE '\\'", "%#{model.sanitize_sql_like(json_tag_fragment(key, value))}%")
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def json_tag_fragment(key, value)
|
|
39
|
-
JSON.generate(key => value).delete_prefix("{").delete_suffix("}")
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
end
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
|
|
5
|
-
module LlmCostTracker
|
|
6
|
-
module TagSanitizer
|
|
7
|
-
REDACTED_VALUE = "[REDACTED]"
|
|
8
|
-
|
|
9
|
-
class << self
|
|
10
|
-
def call(tags, config: LlmCostTracker.configuration)
|
|
11
|
-
tags = (tags || {}).to_h
|
|
12
|
-
tags.first(max_tag_count(config)).each_with_object({}) do |(key, value), sanitized|
|
|
13
|
-
sanitized[key] = sanitized_value(key, value, config)
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
private
|
|
18
|
-
|
|
19
|
-
def sanitized_value(key, value, config)
|
|
20
|
-
return REDACTED_VALUE if redacted_key?(key, config)
|
|
21
|
-
|
|
22
|
-
string = value_string(value)
|
|
23
|
-
return value if string.bytesize <= max_tag_value_bytesize(config)
|
|
24
|
-
|
|
25
|
-
truncate_bytes(string, max_tag_value_bytesize(config))
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def redacted_key?(key, config)
|
|
29
|
-
normalized = normalized_key(key)
|
|
30
|
-
redacted_keys(config).any? do |candidate|
|
|
31
|
-
redacted_key_component?(normalized, candidate)
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def redacted_keys(config)
|
|
36
|
-
Array(config.redacted_tag_keys).map { |key| normalized_key(key) }
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def normalized_key(key)
|
|
40
|
-
key.to_s
|
|
41
|
-
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
42
|
-
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
43
|
-
.downcase
|
|
44
|
-
.gsub(/[^a-z0-9]+/, "_")
|
|
45
|
-
.gsub(/_+/, "_")
|
|
46
|
-
.delete_prefix("_")
|
|
47
|
-
.delete_suffix("_")
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def redacted_key_component?(key, candidate)
|
|
51
|
-
key == candidate ||
|
|
52
|
-
key.start_with?("#{candidate}_") ||
|
|
53
|
-
key.end_with?("_#{candidate}") ||
|
|
54
|
-
key.include?("_#{candidate}_")
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def value_string(value)
|
|
58
|
-
case value
|
|
59
|
-
when Hash, Array
|
|
60
|
-
JSON.generate(value)
|
|
61
|
-
else
|
|
62
|
-
value.to_s
|
|
63
|
-
end
|
|
64
|
-
rescue JSON::GeneratorError, TypeError
|
|
65
|
-
value.to_s
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def truncate_bytes(string, limit)
|
|
69
|
-
string.byteslice(0, limit).to_s.encode("UTF-8", invalid: :replace, undef: :replace)
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def max_tag_count(config)
|
|
73
|
-
[config.max_tag_count.to_i, 0].max
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def max_tag_value_bytesize(config)
|
|
77
|
-
[config.max_tag_value_bytesize.to_i, 0].max
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
end
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "active_record_adapter"
|
|
4
|
-
require_relative "tag_key"
|
|
5
|
-
|
|
6
|
-
module LlmCostTracker
|
|
7
|
-
module TagSql
|
|
8
|
-
class << self
|
|
9
|
-
def value_expression(model, key, table_name:)
|
|
10
|
-
key = TagKey.validate!(key)
|
|
11
|
-
column = "#{table_name}.#{model.connection.quote_column_name('tags')}"
|
|
12
|
-
|
|
13
|
-
if ActiveRecordAdapter.postgresql?(model.connection)
|
|
14
|
-
json_column = model.tags_jsonb_column? ? column : "(#{column})::jsonb"
|
|
15
|
-
"#{json_column}->>#{model.connection.quote(key)}"
|
|
16
|
-
elsif ActiveRecordAdapter.mysql?(model.connection)
|
|
17
|
-
"JSON_UNQUOTE(JSON_EXTRACT(#{column}, #{model.connection.quote(json_path(key))}))"
|
|
18
|
-
else
|
|
19
|
-
ActiveRecordAdapter.ensure_supported!(model.connection)
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def value_label(value)
|
|
24
|
-
value.nil? || value == "" ? "(untagged)" : value.to_s
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
private
|
|
28
|
-
|
|
29
|
-
def json_path(key)
|
|
30
|
-
"$.\"#{key}\""
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|