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,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/object/blank"
|
|
4
|
+
require "active_support/core_ext/object/deep_dup"
|
|
5
|
+
|
|
6
|
+
require_relative "stream"
|
|
7
|
+
|
|
8
|
+
module LlmCostTracker
|
|
9
|
+
module Capture
|
|
10
|
+
class StreamCollector
|
|
11
|
+
attr_reader :provider
|
|
12
|
+
|
|
13
|
+
def initialize(provider:, model:, latency_ms: nil, provider_response_id: nil, pricing_mode: nil, metadata: {})
|
|
14
|
+
@provider = provider.to_s
|
|
15
|
+
@model = model
|
|
16
|
+
@latency_ms = latency_ms
|
|
17
|
+
@provider_response_id = provider_response_id
|
|
18
|
+
@pricing_mode = pricing_mode
|
|
19
|
+
@metadata = (metadata || {}).deep_dup
|
|
20
|
+
@events = []
|
|
21
|
+
@captured_bytes = 0
|
|
22
|
+
@overflowed = false
|
|
23
|
+
@explicit_usage = nil
|
|
24
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
25
|
+
@finished = false
|
|
26
|
+
@mutex = Mutex.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def model
|
|
30
|
+
@mutex.synchronize { @model }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def metadata
|
|
34
|
+
@mutex.synchronize { @metadata.deep_dup }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def provider_response_id
|
|
38
|
+
@mutex.synchronize { @provider_response_id }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def model=(value)
|
|
42
|
+
@mutex.synchronize do
|
|
43
|
+
ensure_open!
|
|
44
|
+
@model = value
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def provider_response_id=(value)
|
|
49
|
+
@mutex.synchronize do
|
|
50
|
+
ensure_open!
|
|
51
|
+
@provider_response_id = value
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def event(data, type: nil)
|
|
56
|
+
@mutex.synchronize do
|
|
57
|
+
ensure_open!
|
|
58
|
+
capture_event(data, type: type) unless data.nil?
|
|
59
|
+
end
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def usage(input_tokens:, output_tokens:, **extra)
|
|
64
|
+
@mutex.synchronize do
|
|
65
|
+
ensure_open!
|
|
66
|
+
@provider_response_id = extra.delete(:provider_response_id) || @provider_response_id
|
|
67
|
+
@explicit_usage = TokenUsage.from_hash(extra.merge(
|
|
68
|
+
input_tokens: input_tokens.to_i,
|
|
69
|
+
output_tokens: output_tokens.to_i
|
|
70
|
+
))
|
|
71
|
+
end
|
|
72
|
+
self
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def finish!(errored: false)
|
|
76
|
+
snapshot = @mutex.synchronize do
|
|
77
|
+
return if @finished
|
|
78
|
+
|
|
79
|
+
@finished = true
|
|
80
|
+
{
|
|
81
|
+
events: @events.dup,
|
|
82
|
+
overflowed: @overflowed,
|
|
83
|
+
explicit_usage: @explicit_usage,
|
|
84
|
+
model: @model,
|
|
85
|
+
latency_ms: @latency_ms,
|
|
86
|
+
provider_response_id: @provider_response_id,
|
|
87
|
+
pricing_mode: @pricing_mode,
|
|
88
|
+
metadata: @metadata.deep_dup
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
capture = build_usage_capture(snapshot)
|
|
93
|
+
provider_response_id = capture.provider_response_id || snapshot[:provider_response_id]
|
|
94
|
+
capture = capture.with(provider_response_id: provider_response_id)
|
|
95
|
+
|
|
96
|
+
Tracker.record(
|
|
97
|
+
capture: capture,
|
|
98
|
+
latency_ms: snapshot[:latency_ms] ||
|
|
99
|
+
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at) * 1000).round,
|
|
100
|
+
pricing_mode: snapshot[:pricing_mode],
|
|
101
|
+
metadata: (errored ? { stream_errored: true } : {}).merge(snapshot[:metadata])
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def ensure_open!
|
|
108
|
+
return unless @finished
|
|
109
|
+
|
|
110
|
+
raise FrozenError, "can't modify finished LlmCostTracker::Capture::StreamCollector"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def build_usage_capture(snapshot)
|
|
114
|
+
return build_from_explicit_usage(snapshot) if snapshot[:explicit_usage]
|
|
115
|
+
return build_unknown_usage(snapshot) if snapshot[:overflowed]
|
|
116
|
+
|
|
117
|
+
capture = Parsers.find_for_provider(@provider)&.parse_stream(nil, nil, 200, snapshot[:events])
|
|
118
|
+
if capture
|
|
119
|
+
model = present_model(capture.model) || present_model(snapshot[:model]) || UsageCapture::UNKNOWN_MODEL
|
|
120
|
+
return capture.with(provider: @provider, model: model)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
build_unknown_usage(snapshot)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def present_model(value)
|
|
127
|
+
return nil if value.nil?
|
|
128
|
+
|
|
129
|
+
string = value.to_s.presence
|
|
130
|
+
return nil if string.nil? || string == "unknown"
|
|
131
|
+
|
|
132
|
+
string
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def build_from_explicit_usage(snapshot)
|
|
136
|
+
UsageCapture.build(
|
|
137
|
+
provider: @provider,
|
|
138
|
+
model: snapshot[:model] || UsageCapture::UNKNOWN_MODEL,
|
|
139
|
+
token_usage: snapshot[:explicit_usage],
|
|
140
|
+
stream: true,
|
|
141
|
+
usage_source: :manual
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def build_unknown_usage(snapshot)
|
|
146
|
+
UsageCapture.build(
|
|
147
|
+
provider: @provider,
|
|
148
|
+
model: snapshot[:model] || UsageCapture::UNKNOWN_MODEL,
|
|
149
|
+
token_usage: TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
|
|
150
|
+
stream: true,
|
|
151
|
+
usage_source: :unknown
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def capture_event(data, type:)
|
|
156
|
+
size = type.to_s.bytesize + estimated_bytes(data) + 32
|
|
157
|
+
if @captured_bytes + size <= Capture::Stream::LIMIT_BYTES
|
|
158
|
+
@events << { event: type, data: data.deep_dup }
|
|
159
|
+
@captured_bytes += size
|
|
160
|
+
else
|
|
161
|
+
@overflowed = true
|
|
162
|
+
@events.clear
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def estimated_bytes(value)
|
|
167
|
+
case value
|
|
168
|
+
when Hash
|
|
169
|
+
value.sum { |key, nested| estimated_bytes(key) + estimated_bytes(nested) + 4 }
|
|
170
|
+
when Array
|
|
171
|
+
value.sum { |nested| estimated_bytes(nested) + 2 }
|
|
172
|
+
when String
|
|
173
|
+
value.bytesize + 2
|
|
174
|
+
when Numeric, true, false, nil
|
|
175
|
+
value.to_s.bytesize
|
|
176
|
+
else
|
|
177
|
+
value.to_s.bytesize + 2
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "active_support/core_ext/object/deep_dup"
|
|
4
|
+
require "active_support/core_ext/object/try"
|
|
4
5
|
|
|
5
6
|
require_relative "../logging"
|
|
6
|
-
require_relative "../stream_collector"
|
|
7
|
-
require_relative "../value_helpers"
|
|
8
|
-
require_relative "object_reader"
|
|
9
7
|
|
|
10
8
|
module LlmCostTracker
|
|
11
|
-
module
|
|
9
|
+
module Capture
|
|
12
10
|
class StreamTracker
|
|
13
|
-
def self.wrap(stream, collector:, active:, finish: nil) = new(stream, collector, active, finish).wrap
|
|
14
|
-
|
|
15
11
|
def initialize(stream, collector, active, finish)
|
|
16
12
|
@stream = stream
|
|
17
13
|
@collector = collector
|
|
@@ -19,13 +15,22 @@ module LlmCostTracker
|
|
|
19
15
|
@finish = finish || proc { |errored:| @collector.finish!(errored: errored) }
|
|
20
16
|
@finished = false
|
|
21
17
|
@capture_failed = false
|
|
22
|
-
@
|
|
18
|
+
@mutex = Mutex.new
|
|
23
19
|
end
|
|
24
20
|
|
|
25
21
|
def wrap
|
|
26
22
|
return @stream unless @stream
|
|
27
23
|
|
|
28
|
-
iterator_wrapped =
|
|
24
|
+
iterator_wrapped = false
|
|
25
|
+
if @stream.instance_variable_defined?(:@iterator)
|
|
26
|
+
iterator = @stream.instance_variable_get(:@iterator)
|
|
27
|
+
if iterator.respond_to?(:each)
|
|
28
|
+
@stream.instance_variable_set(:@iterator, Enumerator.new do |yielder|
|
|
29
|
+
each_from(iterator) { |event| yielder << event }
|
|
30
|
+
end)
|
|
31
|
+
iterator_wrapped = true
|
|
32
|
+
end
|
|
33
|
+
end
|
|
29
34
|
wrap_each if !iterator_wrapped && @stream.respond_to?(:each)
|
|
30
35
|
|
|
31
36
|
@stream
|
|
@@ -36,14 +41,6 @@ module LlmCostTracker
|
|
|
36
41
|
|
|
37
42
|
private
|
|
38
43
|
|
|
39
|
-
def wrap_iterator?
|
|
40
|
-
iterator = @stream.instance_variable_get(:@iterator)
|
|
41
|
-
return false unless iterator.respond_to?(:each)
|
|
42
|
-
|
|
43
|
-
@stream.instance_variable_set(:@iterator, tracked_iterator(iterator))
|
|
44
|
-
true
|
|
45
|
-
end
|
|
46
|
-
|
|
47
44
|
def wrap_each
|
|
48
45
|
tracker = self
|
|
49
46
|
original_each = @stream.method(:each)
|
|
@@ -54,17 +51,18 @@ module LlmCostTracker
|
|
|
54
51
|
end
|
|
55
52
|
end
|
|
56
53
|
|
|
57
|
-
def tracked_iterator(iterator)
|
|
58
|
-
Enumerator.new do |yielder|
|
|
59
|
-
each_from(iterator) { |event| yielder << event }
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
|
|
63
54
|
def each_from(iterable)
|
|
64
55
|
errored = false
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
56
|
+
if iterable.respond_to?(:each)
|
|
57
|
+
iterable.each do |event|
|
|
58
|
+
capture(event)
|
|
59
|
+
yield event
|
|
60
|
+
end
|
|
61
|
+
else
|
|
62
|
+
iterable.call do |event|
|
|
63
|
+
capture(event)
|
|
64
|
+
yield event
|
|
65
|
+
end
|
|
68
66
|
end
|
|
69
67
|
rescue StandardError
|
|
70
68
|
errored = true
|
|
@@ -73,41 +71,17 @@ module LlmCostTracker
|
|
|
73
71
|
finish!(errored: errored)
|
|
74
72
|
end
|
|
75
73
|
|
|
76
|
-
def iterate(iterable, &)
|
|
77
|
-
if iterable.respond_to?(:each)
|
|
78
|
-
iterable.each(&)
|
|
79
|
-
else
|
|
80
|
-
iterable.call(&)
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
|
|
84
74
|
def capture(event)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
warn_capture_failure(e)
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def event_payload(event)
|
|
92
|
-
if event.respond_to?(:deep_to_h)
|
|
93
|
-
event.deep_to_h
|
|
94
|
-
elsif event.respond_to?(:to_h)
|
|
95
|
-
event.to_h
|
|
96
|
-
else
|
|
97
|
-
event_attributes(event)
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def event_attributes(event)
|
|
102
|
-
%i[type id model usage response message].each_with_object({}) do |key, attributes|
|
|
103
|
-
value = ObjectReader.read(event, key)
|
|
75
|
+
raw_payload = event.try(:deep_to_h) || event.try(:to_h)
|
|
76
|
+
raw_payload ||= %i[type id model usage response message].each_with_object({}) do |key, attributes|
|
|
77
|
+
value = event.try(key)
|
|
104
78
|
attributes[key] = value unless value.nil?
|
|
105
79
|
end
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
80
|
+
payload = normalize(raw_payload)
|
|
81
|
+
type = event.try(:type) || payload["type"]
|
|
82
|
+
@collector.event(payload, type: type&.to_s)
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
warn_capture_failure(e)
|
|
111
85
|
end
|
|
112
86
|
|
|
113
87
|
def normalize(value)
|
|
@@ -123,23 +97,17 @@ module LlmCostTracker
|
|
|
123
97
|
when NilClass
|
|
124
98
|
nil
|
|
125
99
|
else
|
|
126
|
-
converted =
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if value.respond_to?(:deep_to_h)
|
|
133
|
-
value.deep_to_h
|
|
134
|
-
elsif value.respond_to?(:to_h)
|
|
135
|
-
value.to_h
|
|
100
|
+
converted = begin
|
|
101
|
+
value.try(:deep_to_h) || value.try(:to_h)
|
|
102
|
+
rescue StandardError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
converted ? normalize(converted) : value.deep_dup
|
|
136
106
|
end
|
|
137
|
-
rescue StandardError
|
|
138
|
-
nil
|
|
139
107
|
end
|
|
140
108
|
|
|
141
109
|
def warn_capture_failure(error)
|
|
142
|
-
should_warn = @
|
|
110
|
+
should_warn = @mutex.synchronize do
|
|
143
111
|
next false if @capture_failed
|
|
144
112
|
|
|
145
113
|
@capture_failed = true
|
|
@@ -151,7 +119,7 @@ module LlmCostTracker
|
|
|
151
119
|
end
|
|
152
120
|
|
|
153
121
|
def finish!(errored:)
|
|
154
|
-
should_finish = @
|
|
122
|
+
should_finish = @mutex.synchronize do
|
|
155
123
|
next false if @finished
|
|
156
124
|
|
|
157
125
|
@finished = true
|
|
@@ -16,7 +16,7 @@ module LlmCostTracker
|
|
|
16
16
|
def normalize_instrumentation_names(names)
|
|
17
17
|
names.flatten.flat_map do |name|
|
|
18
18
|
key = name.to_sym
|
|
19
|
-
next
|
|
19
|
+
next Integrations.names if key == :all
|
|
20
20
|
|
|
21
21
|
validate_instrumentation_name!(key)
|
|
22
22
|
key
|
|
@@ -24,14 +24,10 @@ module LlmCostTracker
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def validate_instrumentation_name!(name)
|
|
27
|
-
return if
|
|
27
|
+
return if Integrations.names.include?(name)
|
|
28
28
|
|
|
29
29
|
raise Error, "Unknown integration: #{name.inspect}. " \
|
|
30
|
-
"Use one of: #{
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def available_instrumentation_names
|
|
34
|
-
Integrations::Registry.names
|
|
30
|
+
"Use one of: #{Integrations.names.join(', ')}"
|
|
35
31
|
end
|
|
36
32
|
end
|
|
37
33
|
end
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "errors"
|
|
4
|
-
require_relative "
|
|
5
|
-
require_relative "value_helpers"
|
|
4
|
+
require_relative "tags/key"
|
|
6
5
|
require_relative "configuration/instrumentation"
|
|
7
6
|
|
|
8
7
|
module LlmCostTracker
|
|
@@ -12,13 +11,11 @@ module LlmCostTracker
|
|
|
12
11
|
OPENAI_COMPATIBLE_PROVIDERS = { "openrouter.ai" => "openrouter", "api.deepseek.com" => "deepseek" }.freeze
|
|
13
12
|
|
|
14
13
|
BUDGET_EXCEEDED_BEHAVIORS = %i[notify raise block_requests].freeze
|
|
15
|
-
STORAGE_ERROR_BEHAVIORS = %i[ignore warn raise].freeze
|
|
16
14
|
UNKNOWN_PRICING_BEHAVIORS = %i[ignore warn raise].freeze
|
|
17
15
|
SHARED_SCALAR_ATTRIBUTES = %i[enabled on_budget_exceeded monthly_budget daily_budget per_call_budget log_level
|
|
18
16
|
prices_file max_tag_count max_tag_value_bytesize].freeze
|
|
19
17
|
SHARED_ENUM_ATTRIBUTES = {
|
|
20
18
|
budget_exceeded_behavior: [BUDGET_EXCEEDED_BEHAVIORS, :notify],
|
|
21
|
-
storage_error_behavior: [STORAGE_ERROR_BEHAVIORS, :warn],
|
|
22
19
|
unknown_pricing_behavior: [UNKNOWN_PRICING_BEHAVIORS, :warn]
|
|
23
20
|
}.freeze
|
|
24
21
|
DEFAULT_REDACTED_TAG_KEYS = %w[api_key access_token authorization credential password refresh_token secret].freeze
|
|
@@ -31,7 +28,6 @@ module LlmCostTracker
|
|
|
31
28
|
:instrumented_integrations,
|
|
32
29
|
:report_tag_breakdowns,
|
|
33
30
|
:redacted_tag_keys,
|
|
34
|
-
:storage_error_behavior,
|
|
35
31
|
:unknown_pricing_behavior,
|
|
36
32
|
:openai_compatible_providers
|
|
37
33
|
)
|
|
@@ -44,7 +40,6 @@ module LlmCostTracker
|
|
|
44
40
|
@daily_budget = nil
|
|
45
41
|
@per_call_budget = nil
|
|
46
42
|
self.budget_exceeded_behavior = :notify
|
|
47
|
-
self.storage_error_behavior = :warn
|
|
48
43
|
self.unknown_pricing_behavior = :warn
|
|
49
44
|
@log_level = :info
|
|
50
45
|
@prices_file = nil
|
|
@@ -75,7 +70,7 @@ module LlmCostTracker
|
|
|
75
70
|
|
|
76
71
|
def report_tag_breakdowns=(value)
|
|
77
72
|
ensure_shared_configuration_mutable!
|
|
78
|
-
@report_tag_breakdowns =
|
|
73
|
+
@report_tag_breakdowns = Array(value).map { |key| Tags::Key.validate!(key, error_class: Error) }
|
|
79
74
|
end
|
|
80
75
|
|
|
81
76
|
def redacted_tag_keys=(value)
|
|
@@ -98,34 +93,18 @@ module LlmCostTracker
|
|
|
98
93
|
end
|
|
99
94
|
|
|
100
95
|
def finalize!
|
|
101
|
-
@default_tags =
|
|
102
|
-
@pricing_overrides =
|
|
103
|
-
@instrumented_integrations =
|
|
104
|
-
@report_tag_breakdowns =
|
|
105
|
-
@redacted_tag_keys =
|
|
106
|
-
@openai_compatible_providers =
|
|
96
|
+
@default_tags = deep_freeze(@default_tags || {})
|
|
97
|
+
@pricing_overrides = deep_freeze(@pricing_overrides || {})
|
|
98
|
+
@instrumented_integrations = deep_freeze(@instrumented_integrations || [])
|
|
99
|
+
@report_tag_breakdowns = deep_freeze(Array(@report_tag_breakdowns))
|
|
100
|
+
@redacted_tag_keys = deep_freeze(Array(@redacted_tag_keys))
|
|
101
|
+
@openai_compatible_providers = deep_freeze(@openai_compatible_providers || {})
|
|
107
102
|
@finalized = true
|
|
108
103
|
self
|
|
109
104
|
end
|
|
110
105
|
|
|
111
|
-
def finalized?
|
|
112
|
-
|
|
113
|
-
def dup_for_configuration
|
|
114
|
-
copy = dup
|
|
115
|
-
copy.instance_variable_set(:@default_tags, ValueHelpers.deep_dup(@default_tags || {}))
|
|
116
|
-
copy.instance_variable_set(:@pricing_overrides, ValueHelpers.deep_dup(@pricing_overrides || {}))
|
|
117
|
-
copy.instance_variable_set(
|
|
118
|
-
:@instrumented_integrations,
|
|
119
|
-
ValueHelpers.deep_dup(@instrumented_integrations || [])
|
|
120
|
-
)
|
|
121
|
-
copy.instance_variable_set(:@report_tag_breakdowns, ValueHelpers.deep_dup(@report_tag_breakdowns || []))
|
|
122
|
-
copy.instance_variable_set(:@redacted_tag_keys, ValueHelpers.deep_dup(@redacted_tag_keys || []))
|
|
123
|
-
copy.instance_variable_set(
|
|
124
|
-
:@openai_compatible_providers,
|
|
125
|
-
ValueHelpers.deep_dup(@openai_compatible_providers || {})
|
|
126
|
-
)
|
|
127
|
-
copy.instance_variable_set(:@finalized, false)
|
|
128
|
-
copy
|
|
106
|
+
def finalized?
|
|
107
|
+
@finalized
|
|
129
108
|
end
|
|
130
109
|
|
|
131
110
|
private
|
|
@@ -144,14 +123,28 @@ module LlmCostTracker
|
|
|
144
123
|
end
|
|
145
124
|
end
|
|
146
125
|
|
|
147
|
-
def normalize_report_tag_breakdowns(value)
|
|
148
|
-
Array(value).map { |key| TagKey.validate!(key, error_class: Error) }
|
|
149
|
-
end
|
|
150
|
-
|
|
151
126
|
def ensure_shared_configuration_mutable!
|
|
152
127
|
return unless finalized?
|
|
153
128
|
|
|
154
129
|
raise FrozenError, "can't modify frozen LlmCostTracker::Configuration"
|
|
155
130
|
end
|
|
131
|
+
|
|
132
|
+
def deep_freeze(value)
|
|
133
|
+
case value
|
|
134
|
+
when Hash
|
|
135
|
+
value.each do |key, nested_value|
|
|
136
|
+
deep_freeze(key)
|
|
137
|
+
deep_freeze(nested_value)
|
|
138
|
+
end
|
|
139
|
+
value.frozen? ? value : value.freeze
|
|
140
|
+
when Array
|
|
141
|
+
value.each { |nested_value| deep_freeze(nested_value) }
|
|
142
|
+
value.frozen? ? value : value.freeze
|
|
143
|
+
when String
|
|
144
|
+
value.frozen? ? value : value.freeze
|
|
145
|
+
else
|
|
146
|
+
value
|
|
147
|
+
end
|
|
148
|
+
end
|
|
156
149
|
end
|
|
157
150
|
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "check"
|
|
4
|
+
require_relative "../ingestion"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
class Doctor
|
|
8
|
+
class CaptureVerifier
|
|
9
|
+
class << self
|
|
10
|
+
def call
|
|
11
|
+
new.checks
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def report(checks = call)
|
|
15
|
+
(["LLM Cost Tracker capture verification"] + checks.map do |check|
|
|
16
|
+
"[#{check.status}] #{check.name}: #{check.message}"
|
|
17
|
+
end).join("\n")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def healthy?(checks = call)
|
|
21
|
+
checks.none? { |check| check.status == :error }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def checks
|
|
26
|
+
[
|
|
27
|
+
enabled_check,
|
|
28
|
+
*integration_checks,
|
|
29
|
+
*storage_checks
|
|
30
|
+
].compact
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def enabled_check
|
|
36
|
+
return Check.new(:ok, "tracking", "enabled") if LlmCostTracker.configuration.enabled
|
|
37
|
+
|
|
38
|
+
Check.new(:error, "tracking", "disabled; set config.enabled = true before verifying capture")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def integration_checks
|
|
42
|
+
enabled = LlmCostTracker.configuration.instrumented_integrations
|
|
43
|
+
if enabled.empty?
|
|
44
|
+
return [
|
|
45
|
+
Check.new(:ok, "sdk integrations", "none enabled; Faraday middleware and manual capture remain available")
|
|
46
|
+
]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
LlmCostTracker::Integrations.checks.map do |check|
|
|
50
|
+
Check.new(check.status, "sdk integration #{check.name}", check.message)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def storage_checks
|
|
55
|
+
LlmCostTracker::Ingestion.verify
|
|
56
|
+
rescue LlmCostTracker::Error => e
|
|
57
|
+
[Check.new(:error, "storage", e.message)]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|