llm_cost_tracker 0.14.1 → 0.15.0
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 +198 -0
- data/LICENSE.txt +28 -0
- data/README.md +38 -35
- data/app/assets/llm_cost_tracker/application.css +16 -0
- data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -96
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
- data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
- data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -13
- data/app/helpers/llm_cost_tracker/application_helper.rb +24 -54
- data/app/helpers/llm_cost_tracker/chart_helper.rb +58 -113
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +36 -26
- data/app/models/llm_cost_tracker/call.rb +15 -17
- data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
- data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
- data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +67 -222
- data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -10
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
- data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +20 -18
- data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +8 -10
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +2 -4
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -5
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
- data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
- data/app/views/llm_cost_tracker/calls/show.html.erb +7 -32
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
- data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +2 -5
- data/app/views/llm_cost_tracker/tags/show.html.erb +6 -6
- data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
- data/lib/llm_cost_tracker/budget/global.rb +69 -0
- data/lib/llm_cost_tracker/budget/limit.rb +52 -0
- data/lib/llm_cost_tracker/budget/per_tag.rb +63 -25
- data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
- data/lib/llm_cost_tracker/budget.rb +22 -171
- data/lib/llm_cost_tracker/capture/event_window.rb +17 -13
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +26 -17
- data/lib/llm_cost_tracker/capture/sse.rb +7 -5
- data/lib/llm_cost_tracker/capture/stream_collector.rb +99 -107
- data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +36 -55
- data/lib/llm_cost_tracker/capture_verifier.rb +3 -10
- data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
- data/lib/llm_cost_tracker/charges/line_item.rb +36 -34
- data/lib/llm_cost_tracker/configuration/budgets.rb +50 -19
- data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
- data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
- data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
- data/lib/llm_cost_tracker/configuration.rb +5 -9
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
- data/lib/llm_cost_tracker/doctor/price_check.rb +26 -27
- data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
- data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
- data/lib/llm_cost_tracker/doctor.rb +42 -22
- data/lib/llm_cost_tracker/engine.rb +8 -1
- data/lib/llm_cost_tracker/errors.rb +2 -4
- data/lib/llm_cost_tracker/event.rb +28 -27
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -10
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +2 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +2 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +3 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +2 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
- data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
- data/lib/llm_cost_tracker/ingestion/inbox.rb +22 -19
- data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
- data/lib/llm_cost_tracker/ingestion.rb +23 -36
- data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
- data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
- data/lib/llm_cost_tracker/integrations/anthropic.rb +82 -90
- data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
- data/lib/llm_cost_tracker/integrations/base.rb +98 -93
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +27 -23
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +54 -35
- data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +135 -121
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -243
- data/lib/llm_cost_tracker/integrations.rb +1 -20
- data/lib/llm_cost_tracker/ledger/isolation.rb +15 -9
- data/lib/llm_cost_tracker/ledger/period/totals.rb +15 -11
- data/lib/llm_cost_tracker/ledger/rollups.rb +34 -18
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +33 -5
- data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -4
- data/lib/llm_cost_tracker/ledger/store.rb +19 -37
- data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -6
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
- data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
- data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
- data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
- data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
- data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +2 -284
- data/lib/llm_cost_tracker/parsers.rb +19 -40
- data/lib/llm_cost_tracker/prices.json +3241 -187
- data/lib/llm_cost_tracker/pricing/backfill.rb +98 -35
- data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
- data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
- data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
- data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
- data/lib/llm_cost_tracker/pricing/calculation.rb +113 -161
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
- data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
- data/lib/llm_cost_tracker/pricing/matcher.rb +61 -30
- data/lib/llm_cost_tracker/pricing/mode.rb +8 -32
- data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
- data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
- data/lib/llm_cost_tracker/pricing/rate.rb +2 -0
- data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +39 -123
- data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
- data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
- data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
- data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +24 -119
- data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +59 -24
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +12 -4
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +140 -50
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
- data/lib/llm_cost_tracker/providers/azure/parser.rb +6 -13
- data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +167 -106
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +65 -55
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +12 -1
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
- data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
- data/lib/llm_cost_tracker/providers/openai/parser.rb +12 -13
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +100 -98
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +131 -137
- data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +78 -55
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +17 -4
- data/lib/llm_cost_tracker/providers.rb +3 -0
- data/lib/llm_cost_tracker/redaction.rb +13 -0
- data/lib/llm_cost_tracker/report/data.rb +25 -28
- data/lib/llm_cost_tracker/retention.rb +1 -3
- data/lib/llm_cost_tracker/tags/context.rb +22 -7
- data/lib/llm_cost_tracker/tracker.rb +45 -31
- data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
- data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
- data/lib/llm_cost_tracker/usage/dimensions.yml +85 -0
- data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +8 -4
- data/lib/tasks/llm_cost_tracker.rake +58 -25
- data/llm_cost_tracker.gemspec +64 -0
- metadata +65 -11
|
@@ -1,18 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
require_relative "
|
|
6
|
-
require_relative "pricing/estimator"
|
|
7
|
-
require_relative "budget/per_tag"
|
|
8
|
-
require_relative "tags/context"
|
|
3
|
+
require_relative "budget/estimate"
|
|
4
|
+
require_relative "budget/global"
|
|
5
|
+
require_relative "budget/tagged"
|
|
9
6
|
|
|
10
7
|
module LlmCostTracker
|
|
11
8
|
module Budget
|
|
12
|
-
BUDGET_TYPE_TO_PERIOD = { monthly: :month, daily: :day }.freeze
|
|
13
|
-
|
|
14
9
|
class << self
|
|
15
|
-
def enforce!(provider: nil, model: nil, request: nil,
|
|
10
|
+
def enforce!(provider: nil, model: nil, request: nil, tags: nil, force: false)
|
|
16
11
|
config = LlmCostTracker.configuration
|
|
17
12
|
return unless config.enabled
|
|
18
13
|
|
|
@@ -20,57 +15,20 @@ module LlmCostTracker
|
|
|
20
15
|
per_tag = force || PerTag.blocking?
|
|
21
16
|
return unless globally || per_tag
|
|
22
17
|
|
|
23
|
-
estimate
|
|
18
|
+
estimate = Estimate.for(provider: provider, model: model, request: request)
|
|
24
19
|
now = Time.now.utc
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
check_per_tag(tags || Tags::Context.tags,
|
|
29
|
-
time: now,
|
|
30
|
-
estimate: estimate,
|
|
31
|
-
blocking_only: !force) do |rule, window, total, limit|
|
|
32
|
-
raise_pre_send(budget_type: window, total: total, budget: limit, scope: scope_for(rule))
|
|
33
|
-
end
|
|
20
|
+
Global.enforce!(estimate, time: now) if globally
|
|
21
|
+
Tagged.enforce!(tags, estimate.total, time: now, blocking_only: !force) if per_tag
|
|
34
22
|
end
|
|
35
23
|
|
|
36
24
|
def check!(event, behavior_override: nil)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
check_per_call_budget(event, config, behavior_override)
|
|
41
|
-
check_windowed({ daily: config.budgets.daily, monthly: config.budgets.monthly }.compact,
|
|
42
|
-
time: event.tracked_at) do |budget_type, total, budget|
|
|
43
|
-
handle_exceeded(budget_type: budget_type,
|
|
44
|
-
total: total,
|
|
45
|
-
budget: budget,
|
|
46
|
-
previous_total: total - event.total_cost,
|
|
47
|
-
last_event: event,
|
|
48
|
-
behavior: behavior_override)
|
|
49
|
-
end
|
|
25
|
+
errors = event.total_cost ? Global.post_spend_errors(event, behavior_override) : []
|
|
26
|
+
errors.concat(Tagged.post_spend_errors([event], behavior_override)) unless Ingestion.async?
|
|
27
|
+
raise_first(errors)
|
|
50
28
|
end
|
|
51
29
|
|
|
52
30
|
def check_persisted!(events, behavior_override: nil)
|
|
53
|
-
|
|
54
|
-
by_rule = by_rule.reject { |rule, _| rule.on_exceeded.nil? } if behavior_override == :notify
|
|
55
|
-
window_buckets(by_rule).each do |(key, window, bucket), scored|
|
|
56
|
-
totals = PerTag.spend_by_value(key, scored.keys.map(&:value), window, bucket)
|
|
57
|
-
scored.each do |rule, recorded|
|
|
58
|
-
total = totals.fetch(rule.value, 0).to_d
|
|
59
|
-
limit = rule.windows.fetch(window)
|
|
60
|
-
next if total < limit
|
|
61
|
-
|
|
62
|
-
handle_exceeded(
|
|
63
|
-
budget_type: window,
|
|
64
|
-
total: total,
|
|
65
|
-
budget: limit,
|
|
66
|
-
previous_total: total - recorded.sum(&:total_cost),
|
|
67
|
-
last_event: recorded.last,
|
|
68
|
-
scope: scope_for(rule),
|
|
69
|
-
behavior: behavior_override || rule.behavior,
|
|
70
|
-
on_exceeded: rule.on_exceeded
|
|
71
|
-
)
|
|
72
|
-
end
|
|
73
|
-
end
|
|
31
|
+
raise_first(Tagged.post_spend_errors(events, behavior_override))
|
|
74
32
|
end
|
|
75
33
|
|
|
76
34
|
def notify_persisted_safely!(events)
|
|
@@ -79,128 +37,21 @@ module LlmCostTracker
|
|
|
79
37
|
Logging.warn("Per-tag budget check failed after ingest: #{e.class}: #{e.message}")
|
|
80
38
|
end
|
|
81
39
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def window_buckets(by_rule)
|
|
85
|
-
by_rule.each_with_object({}) do |(rule, events), grouped|
|
|
86
|
-
rule.windows.each_key do |window|
|
|
87
|
-
events.group_by { |event| PerTag.window_start(window, event.tracked_at) }
|
|
88
|
-
.each do |bucket, bucket_events|
|
|
89
|
-
(grouped[[rule.key, window, bucket]] ||= {})[rule] = bucket_events
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def estimate_cost(provider:, model:, request:)
|
|
96
|
-
return BigDecimal("0") unless provider && model && request
|
|
97
|
-
|
|
98
|
-
Pricing::Estimator.call(provider: provider, model: model, request: request) || BigDecimal("0")
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def raise_per_call_pre_send(estimate, budget)
|
|
102
|
-
return unless estimate >= budget
|
|
103
|
-
|
|
104
|
-
raise BudgetExceededError.new(**budget_payload(
|
|
105
|
-
budget_type: :per_call, total: estimate, budget: budget, last_event: nil, stage: :pre_send
|
|
106
|
-
))
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def check_per_call_budget(event, config, behavior_override)
|
|
110
|
-
budget = config.budgets.per_call
|
|
111
|
-
return unless budget
|
|
112
|
-
|
|
113
|
-
total = event.total_cost
|
|
114
|
-
return unless total >= budget
|
|
115
|
-
|
|
116
|
-
handle_exceeded(budget_type: :per_call,
|
|
117
|
-
total: total,
|
|
118
|
-
budget: budget,
|
|
119
|
-
previous_total: nil,
|
|
120
|
-
last_event: event,
|
|
121
|
-
behavior: behavior_override)
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def enforce_globally(config, estimate:, time:)
|
|
125
|
-
raise_per_call_pre_send(estimate, config.budgets.per_call) if config.budgets.per_call && estimate.positive?
|
|
126
|
-
|
|
127
|
-
check_windowed({ monthly: config.budgets.monthly, daily: config.budgets.daily }.compact,
|
|
128
|
-
time: time,
|
|
129
|
-
estimate: estimate) do |budget_type, total, budget|
|
|
130
|
-
raise_pre_send(budget_type: budget_type, total: total, budget: budget)
|
|
131
|
-
end
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def raise_pre_send(budget_type:, total:, budget:, scope: nil)
|
|
135
|
-
raise BudgetExceededError.new(**budget_payload(
|
|
136
|
-
budget_type: budget_type, total: total, budget: budget, last_event: nil, stage: :pre_send, scope: scope
|
|
137
|
-
))
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
def check_per_tag(tags, time:, estimate: BigDecimal("0"), blocking_only: false)
|
|
141
|
-
PerTag.rules_for(tags, blocking_only: blocking_only).each do |rule|
|
|
142
|
-
rule.windows.each do |window, limit|
|
|
143
|
-
total = PerTag.spend(rule.key, rule.value, window, time: time) + estimate
|
|
144
|
-
yield(rule, window, total, limit) if total >= limit
|
|
145
|
-
end
|
|
146
|
-
end
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
def scope_for(rule)
|
|
150
|
-
{ key: rule.key, value: rule.value }
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def check_windowed(budgets, time:, estimate: BigDecimal("0"))
|
|
154
|
-
return if budgets.empty?
|
|
40
|
+
def notify_repriced_safely!(changes)
|
|
41
|
+
return if changes.empty?
|
|
155
42
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
def totals_for(budget_types, time:)
|
|
164
|
-
return {} if budget_types.empty?
|
|
165
|
-
|
|
166
|
-
period_for = budget_types.to_h { |type| [type, BUDGET_TYPE_TO_PERIOD.fetch(type)] }
|
|
167
|
-
period_totals = LlmCostTracker::Ledger::Period::Totals.call(period_for.values, time: time)
|
|
168
|
-
period_for.transform_values { |period| period_totals.fetch(period) }
|
|
43
|
+
now = Time.now.utc
|
|
44
|
+
Global.notify_repriced(changes, time: now)
|
|
45
|
+
Tagged.notify_repriced(changes, time: now)
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
Logging.warn("Budget check failed after repricing: #{e.class}: #{e.message}")
|
|
169
48
|
end
|
|
170
49
|
|
|
171
|
-
|
|
172
|
-
total:,
|
|
173
|
-
budget:,
|
|
174
|
-
previous_total:,
|
|
175
|
-
last_event: nil,
|
|
176
|
-
scope: nil,
|
|
177
|
-
behavior: nil,
|
|
178
|
-
on_exceeded: nil)
|
|
179
|
-
config = LlmCostTracker.configuration
|
|
180
|
-
behavior ||= config.budgets.exceeded_behavior
|
|
181
|
-
on_exceeded ||= config.budgets.on_exceeded
|
|
182
|
-
payload = budget_payload(
|
|
183
|
-
budget_type: budget_type,
|
|
184
|
-
total: total,
|
|
185
|
-
budget: budget,
|
|
186
|
-
last_event: last_event,
|
|
187
|
-
stage: :post_spend,
|
|
188
|
-
scope: scope
|
|
189
|
-
)
|
|
190
|
-
|
|
191
|
-
on_exceeded.call(payload) if on_exceeded && (previous_total.nil? || previous_total < budget)
|
|
192
|
-
raise BudgetExceededError.new(**payload) if %i[raise block_requests].include?(behavior)
|
|
193
|
-
end
|
|
50
|
+
private
|
|
194
51
|
|
|
195
|
-
def
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
total: total,
|
|
199
|
-
budget: budget,
|
|
200
|
-
last_event: last_event,
|
|
201
|
-
stage: stage,
|
|
202
|
-
scope: scope
|
|
203
|
-
}
|
|
52
|
+
def raise_first(errors)
|
|
53
|
+
error = errors.compact.first
|
|
54
|
+
raise error if error
|
|
204
55
|
end
|
|
205
56
|
end
|
|
206
57
|
end
|
|
@@ -8,10 +8,11 @@ module LlmCostTracker
|
|
|
8
8
|
HEAD_EVENTS = 16
|
|
9
9
|
TAIL_EVENTS = 32
|
|
10
10
|
HEAVY_STRING_BYTES = 8 * 1024
|
|
11
|
-
IGNORED_PAYLOAD_KEYS = %w[b64_json partial_image_b64].freeze
|
|
11
|
+
IGNORED_PAYLOAD_KEYS = %w[b64_json partial_image_b64 snapshot logprobs embedding].freeze
|
|
12
12
|
|
|
13
|
-
def initialize(notable: nil)
|
|
13
|
+
def initialize(notable: nil, trim: nil)
|
|
14
14
|
@notable = notable
|
|
15
|
+
@trim = trim
|
|
15
16
|
@head = []
|
|
16
17
|
@kept = []
|
|
17
18
|
@tail = []
|
|
@@ -20,18 +21,10 @@ module LlmCostTracker
|
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
def push(data, type: nil)
|
|
23
|
-
return if @overflowed
|
|
24
|
+
return if @overflowed || type&.start_with?("logprobs.")
|
|
24
25
|
|
|
25
|
-
event = { event: type, data: strip_heavy_payload(data) }
|
|
26
|
-
|
|
27
|
-
if @head.size < HEAD_EVENTS
|
|
28
|
-
@head << [event, size]
|
|
29
|
-
else
|
|
30
|
-
@tail << [event, size]
|
|
31
|
-
settle(@tail.shift) if @tail.size > TAIL_EVENTS
|
|
32
|
-
end
|
|
33
|
-
@bytes += size
|
|
34
|
-
overflow! if @bytes > SSE::LIMIT_BYTES
|
|
26
|
+
event = { event: type, data: strip_heavy_payload(@trim ? @trim.call(data) : data) }
|
|
27
|
+
append([event, approximate_bytesize(event)])
|
|
35
28
|
rescue TypeError, SystemStackError
|
|
36
29
|
overflow!
|
|
37
30
|
end
|
|
@@ -46,6 +39,17 @@ module LlmCostTracker
|
|
|
46
39
|
|
|
47
40
|
private
|
|
48
41
|
|
|
42
|
+
def append(entry)
|
|
43
|
+
if @head.size < HEAD_EVENTS
|
|
44
|
+
@head << entry
|
|
45
|
+
else
|
|
46
|
+
@tail << entry
|
|
47
|
+
settle(@tail.shift) if @tail.size > TAIL_EVENTS
|
|
48
|
+
end
|
|
49
|
+
@bytes += entry.last
|
|
50
|
+
overflow! if @bytes > SSE::LIMIT_BYTES
|
|
51
|
+
end
|
|
52
|
+
|
|
49
53
|
def settle(entry)
|
|
50
54
|
return @kept << entry if notable?(entry.first[:data])
|
|
51
55
|
|
|
@@ -3,31 +3,40 @@
|
|
|
3
3
|
require "active_support/core_ext/object/deep_dup"
|
|
4
4
|
require "active_support/core_ext/object/try"
|
|
5
5
|
|
|
6
|
+
require_relative "event_window"
|
|
7
|
+
|
|
6
8
|
module LlmCostTracker
|
|
7
9
|
module Capture
|
|
8
10
|
module SdkPayload
|
|
9
|
-
|
|
11
|
+
class << self
|
|
12
|
+
def normalize(value)
|
|
13
|
+
case value
|
|
14
|
+
when Hash then normalize_hash(value)
|
|
15
|
+
when Array then value.map { |nested| normalize(nested) }
|
|
16
|
+
when Symbol then value.to_s
|
|
17
|
+
when NilClass then nil
|
|
18
|
+
else normalize_object(value)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
10
21
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
nil
|
|
21
|
-
else
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def normalize_hash(hash)
|
|
25
|
+
hash.each_with_object({}) do |(key, nested), out|
|
|
26
|
+
out[key.to_s] = normalize(nested) unless EventWindow::IGNORED_PAYLOAD_KEYS.include?(key.to_s)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def normalize_object(value)
|
|
22
31
|
converted = container_for(value)
|
|
23
32
|
converted ? normalize(converted) : value.deep_dup
|
|
24
33
|
end
|
|
25
|
-
end
|
|
26
34
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
35
|
+
def container_for(value)
|
|
36
|
+
value.try(:to_h)
|
|
37
|
+
rescue StandardError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
31
40
|
end
|
|
32
41
|
end
|
|
33
42
|
end
|
|
@@ -64,11 +64,12 @@ module LlmCostTracker
|
|
|
64
64
|
|
|
65
65
|
def consume_lines
|
|
66
66
|
start = 0
|
|
67
|
-
while (newline = @pending.index("\n", start))
|
|
67
|
+
while (newline = @pending.index("\n", [start, @scan_pos].max))
|
|
68
68
|
consume_line(@pending.byteslice(start, newline - start))
|
|
69
69
|
start = newline + 1
|
|
70
70
|
end
|
|
71
|
-
@pending
|
|
71
|
+
@pending.slice!(0, start) if start.positive?
|
|
72
|
+
@scan_pos = @pending.bytesize
|
|
72
73
|
end
|
|
73
74
|
|
|
74
75
|
def consume_line(raw)
|
|
@@ -140,9 +141,10 @@ module LlmCostTracker
|
|
|
140
141
|
end
|
|
141
142
|
|
|
142
143
|
def trim_array_buffer(pos)
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
@
|
|
144
|
+
size = @pending.bytesize
|
|
145
|
+
keep_from = @object_start || [pos, size].min
|
|
146
|
+
@pending.slice!(0, keep_from) if keep_from.positive?
|
|
147
|
+
@scan_pos = [pos, size].max - keep_from
|
|
146
148
|
@object_start &&= 0
|
|
147
149
|
end
|
|
148
150
|
|
|
@@ -5,15 +5,22 @@ require "active_support/core_ext/object/deep_dup"
|
|
|
5
5
|
require "json"
|
|
6
6
|
|
|
7
7
|
require_relative "event_window"
|
|
8
|
+
require_relative "sdk_payload"
|
|
8
9
|
require_relative "../timing"
|
|
9
10
|
|
|
10
11
|
module LlmCostTracker
|
|
11
12
|
module Capture
|
|
12
13
|
class StreamCollector
|
|
14
|
+
DIMENSIONS = %i[provider_project_id provider_api_key_id provider_workspace_id].freeze
|
|
15
|
+
Snapshot = Data.define(*%i[events overflowed explicit_usage model latency_ms provider_response_id
|
|
16
|
+
capture_dimensions pricing_mode metadata context_tags request])
|
|
17
|
+
private_constant :DIMENSIONS, :Snapshot
|
|
18
|
+
|
|
13
19
|
attr_reader :provider
|
|
14
20
|
|
|
15
21
|
def initialize(provider:,
|
|
16
22
|
model:,
|
|
23
|
+
parsed_as: provider,
|
|
17
24
|
latency_ms: nil,
|
|
18
25
|
provider_response_id: nil,
|
|
19
26
|
provider_project_id: nil,
|
|
@@ -21,46 +28,35 @@ module LlmCostTracker
|
|
|
21
28
|
provider_workspace_id: nil,
|
|
22
29
|
pricing_mode: nil,
|
|
23
30
|
metadata: {},
|
|
24
|
-
context_tags: nil,
|
|
25
31
|
request: nil)
|
|
26
32
|
@provider = provider.to_s
|
|
33
|
+
@parsed_as = parsed_as.to_s
|
|
27
34
|
@model = model
|
|
28
35
|
@latency_ms = latency_ms
|
|
29
36
|
@provider_response_id = provider_response_id
|
|
30
|
-
@
|
|
31
|
-
@provider_api_key_id = provider_api_key_id
|
|
32
|
-
@provider_workspace_id = provider_workspace_id
|
|
37
|
+
@dimensions = { provider_project_id:, provider_api_key_id:, provider_workspace_id: }
|
|
33
38
|
@pricing_mode = pricing_mode
|
|
34
39
|
@metadata = (metadata || {}).deep_dup
|
|
35
|
-
@context_tags =
|
|
40
|
+
@context_tags = LlmCostTracker::Tags::Context.tags.deep_dup
|
|
36
41
|
@request = request
|
|
37
|
-
@window =
|
|
42
|
+
@window = parser_window
|
|
38
43
|
@explicit_usage = nil
|
|
39
44
|
@started_at = LlmCostTracker::Timing.now_monotonic
|
|
40
|
-
@
|
|
41
|
-
@recording = false
|
|
45
|
+
@state = :open
|
|
42
46
|
@mutex = Mutex.new
|
|
43
47
|
end
|
|
44
48
|
|
|
45
49
|
def model=(value)
|
|
46
|
-
@
|
|
47
|
-
ensure_open!
|
|
48
|
-
@model = value
|
|
49
|
-
end
|
|
50
|
+
modify { @model = value }
|
|
50
51
|
end
|
|
51
52
|
|
|
52
53
|
def provider_response_id=(value)
|
|
53
|
-
@
|
|
54
|
-
ensure_open!
|
|
55
|
-
@provider_response_id = value
|
|
56
|
-
end
|
|
54
|
+
modify { @provider_response_id = value }
|
|
57
55
|
end
|
|
58
56
|
|
|
59
57
|
def event(data, type: nil)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
@window.push(data, type: type) unless data.nil?
|
|
63
|
-
end
|
|
58
|
+
data = SdkPayload.normalize(data) if data.is_a?(Hash)
|
|
59
|
+
modify { @window.push(data, type: type&.to_s) unless data.nil? }
|
|
64
60
|
end
|
|
65
61
|
|
|
66
62
|
def usage(input_tokens:, output_tokens:, **extra)
|
|
@@ -70,17 +66,10 @@ module LlmCostTracker
|
|
|
70
66
|
"pass `pricing_mode: :batch` to track_stream"
|
|
71
67
|
end
|
|
72
68
|
|
|
73
|
-
|
|
74
|
-
ensure_open!
|
|
69
|
+
modify do
|
|
75
70
|
@provider_response_id = extra.delete(:provider_response_id) || @provider_response_id
|
|
76
|
-
@
|
|
77
|
-
@
|
|
78
|
-
@provider_workspace_id = extra.delete(:provider_workspace_id) || @provider_workspace_id
|
|
79
|
-
@explicit_usage = Usage::TokenUsage.build(
|
|
80
|
-
**extra,
|
|
81
|
-
input_tokens: input_tokens,
|
|
82
|
-
output_tokens: output_tokens
|
|
83
|
-
)
|
|
71
|
+
DIMENSIONS.each { |key| @dimensions[key] = extra.delete(key) || @dimensions[key] }
|
|
72
|
+
@explicit_usage = Usage::TokenUsage.build(**extra, input_tokens: input_tokens, output_tokens: output_tokens)
|
|
84
73
|
end
|
|
85
74
|
end
|
|
86
75
|
|
|
@@ -91,6 +80,8 @@ module LlmCostTracker
|
|
|
91
80
|
record_snapshot(snapshot, errored: errored)
|
|
92
81
|
rescue TransactionAbortedError
|
|
93
82
|
raise
|
|
83
|
+
rescue ActiveRecord::RecordNotUnique
|
|
84
|
+
nil
|
|
94
85
|
rescue StandardError => e
|
|
95
86
|
raise unless errored
|
|
96
87
|
|
|
@@ -99,49 +90,57 @@ module LlmCostTracker
|
|
|
99
90
|
|
|
100
91
|
private
|
|
101
92
|
|
|
93
|
+
def modify
|
|
94
|
+
@mutex.synchronize do
|
|
95
|
+
raise FrozenError, "can't modify finished LlmCostTracker::Capture::StreamCollector" if @state == :finished
|
|
96
|
+
|
|
97
|
+
yield
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def parser_window
|
|
102
|
+
parser = Parsers.find_for_provider(@parsed_as)
|
|
103
|
+
EventWindow.new(notable: parser&.method(:retain_stream_event?), trim: parser&.method(:trim_stream_event))
|
|
104
|
+
end
|
|
105
|
+
|
|
102
106
|
def claim_recording_slot
|
|
103
107
|
@mutex.synchronize do
|
|
104
|
-
return nil
|
|
105
|
-
|
|
106
|
-
@
|
|
107
|
-
|
|
108
|
-
{
|
|
109
|
-
events: @window.events,
|
|
110
|
-
overflowed: @window.overflowed?,
|
|
111
|
-
explicit_usage: @explicit_usage,
|
|
112
|
-
model: @model,
|
|
113
|
-
latency_ms: @latency_ms,
|
|
114
|
-
provider_response_id: @provider_response_id,
|
|
115
|
-
capture_dimensions: capture_dimensions,
|
|
116
|
-
pricing_mode: pricing_mode,
|
|
117
|
-
metadata: @metadata.deep_dup,
|
|
118
|
-
context_tags: @context_tags.deep_dup,
|
|
119
|
-
request: @request
|
|
120
|
-
}
|
|
108
|
+
return nil unless @state == :open
|
|
109
|
+
|
|
110
|
+
@state = :recording
|
|
111
|
+
snapshot
|
|
121
112
|
end
|
|
122
113
|
end
|
|
123
114
|
|
|
124
|
-
def
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
115
|
+
def snapshot
|
|
116
|
+
pricing_mode = Pricing::Mode.normalize(@pricing_mode)
|
|
117
|
+
Snapshot.new(
|
|
118
|
+
events: @window.events,
|
|
119
|
+
overflowed: @window.overflowed?,
|
|
120
|
+
explicit_usage: @explicit_usage,
|
|
121
|
+
model: @model,
|
|
122
|
+
latency_ms: @latency_ms,
|
|
123
|
+
provider_response_id: @provider_response_id,
|
|
124
|
+
capture_dimensions: @dimensions.transform_values { |value| value.to_s.strip.presence }.compact,
|
|
125
|
+
pricing_mode: pricing_mode,
|
|
126
|
+
metadata: @metadata.deep_dup,
|
|
127
|
+
context_tags: @context_tags.deep_dup,
|
|
128
|
+
request: @request
|
|
129
|
+
)
|
|
130
|
+
end
|
|
132
131
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
132
|
+
def record_snapshot(snapshot, errored:)
|
|
133
|
+
saved = false
|
|
134
|
+
Tracker.record(
|
|
135
|
+
event: event_for(snapshot),
|
|
136
|
+
latency_ms: snapshot.latency_ms || LlmCostTracker::Timing.elapsed_ms(@started_at),
|
|
137
|
+
metadata: (errored ? { stream_errored: true } : {}).merge(snapshot.metadata),
|
|
138
|
+
context_tags: snapshot.context_tags
|
|
139
|
+
) { saved = true }
|
|
140
|
+
ensure
|
|
141
|
+
@mutex.synchronize do
|
|
142
|
+
@state = saved ? :finished : :open
|
|
143
|
+
release_buffers if saved
|
|
145
144
|
end
|
|
146
145
|
end
|
|
147
146
|
|
|
@@ -150,35 +149,39 @@ module LlmCostTracker
|
|
|
150
149
|
@request = nil
|
|
151
150
|
end
|
|
152
151
|
|
|
153
|
-
def
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
152
|
+
def event_for(snapshot)
|
|
153
|
+
event = build_event(snapshot)
|
|
154
|
+
event.with(
|
|
155
|
+
provider_response_id: event.provider_response_id || snapshot.provider_response_id,
|
|
156
|
+
pricing_mode: Pricing::Mode.merge(event.pricing_mode, snapshot.pricing_mode)
|
|
157
|
+
)
|
|
159
158
|
end
|
|
160
159
|
|
|
161
|
-
def
|
|
162
|
-
return
|
|
160
|
+
def build_event(snapshot)
|
|
161
|
+
return build_unparsed_event(snapshot) if snapshot.explicit_usage
|
|
162
|
+
return overflowed_event(snapshot) if snapshot.overflowed
|
|
163
|
+
|
|
164
|
+
parsed = parse_events(snapshot)
|
|
165
|
+
return build_unparsed_event(snapshot) unless parsed
|
|
163
166
|
|
|
164
|
-
|
|
167
|
+
model = present_model(parsed.model) || present_model(snapshot.model) || Event::UNKNOWN_MODEL
|
|
168
|
+
parsed.with(provider: @provider, model: model, **snapshot.capture_dimensions)
|
|
165
169
|
end
|
|
166
170
|
|
|
167
|
-
def
|
|
168
|
-
|
|
169
|
-
|
|
171
|
+
def overflowed_event(snapshot)
|
|
172
|
+
Logging.warn("#{@provider} stream events exceeded #{SSE::LIMIT_BYTES} bytes; " \
|
|
173
|
+
"recording usage_source=#{Usage::Source::UNKNOWN}.")
|
|
174
|
+
build_unparsed_event(snapshot)
|
|
175
|
+
end
|
|
170
176
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
model = present_model(event.model) || present_model(snapshot[:model]) || Event::UNKNOWN_MODEL
|
|
178
|
-
return event.with(provider: @provider, model: model, **snapshot.fetch(:capture_dimensions))
|
|
177
|
+
def parse_events(snapshot)
|
|
178
|
+
request_body = request_body_for(snapshot.request)
|
|
179
|
+
events = Parsers.all_for_provider(@parsed_as).filter_map do |parser|
|
|
180
|
+
parser.parse_stream(
|
|
181
|
+
response_status: 200, events: snapshot.events, request_body: request_body, model: snapshot.model
|
|
182
|
+
)
|
|
179
183
|
end
|
|
180
|
-
|
|
181
|
-
build_unknown_usage(snapshot)
|
|
184
|
+
events.find { |parsed| parsed.usage_source != Usage::Source::UNKNOWN } || events.first
|
|
182
185
|
end
|
|
183
186
|
|
|
184
187
|
def request_body_for(request)
|
|
@@ -194,27 +197,16 @@ module LlmCostTracker
|
|
|
194
197
|
string unless string == Event::UNKNOWN_MODEL
|
|
195
198
|
end
|
|
196
199
|
|
|
197
|
-
def
|
|
198
|
-
|
|
199
|
-
provider: @provider,
|
|
200
|
-
model: snapshot[:model] || Event::UNKNOWN_MODEL,
|
|
201
|
-
token_usage: snapshot[:explicit_usage],
|
|
202
|
-
stream: true,
|
|
203
|
-
usage_source: Usage::Source::MANUAL,
|
|
204
|
-
pricing_mode: snapshot[:pricing_mode],
|
|
205
|
-
**snapshot.fetch(:capture_dimensions)
|
|
206
|
-
)
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
def build_unknown_usage(snapshot)
|
|
200
|
+
def build_unparsed_event(snapshot)
|
|
201
|
+
explicit_usage = snapshot.explicit_usage
|
|
210
202
|
Event.build(
|
|
211
203
|
provider: @provider,
|
|
212
|
-
model: snapshot
|
|
213
|
-
token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
|
|
204
|
+
model: snapshot.model || Event::UNKNOWN_MODEL,
|
|
205
|
+
token_usage: explicit_usage || Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
|
|
214
206
|
stream: true,
|
|
215
|
-
usage_source: Usage::Source::UNKNOWN,
|
|
216
|
-
pricing_mode: snapshot
|
|
217
|
-
**snapshot.
|
|
207
|
+
usage_source: explicit_usage ? Usage::Source::MANUAL : Usage::Source::UNKNOWN,
|
|
208
|
+
pricing_mode: snapshot.pricing_mode,
|
|
209
|
+
**snapshot.capture_dimensions
|
|
218
210
|
)
|
|
219
211
|
end
|
|
220
212
|
end
|
|
@@ -8,8 +8,8 @@ module LlmCostTracker
|
|
|
8
8
|
class StreamTap
|
|
9
9
|
MAX_PENDING_BYTES = 16 * 1_048_576
|
|
10
10
|
|
|
11
|
-
def initialize(notable: nil)
|
|
12
|
-
@window = EventWindow.new(notable: notable)
|
|
11
|
+
def initialize(notable: nil, trim: nil)
|
|
12
|
+
@window = EventWindow.new(notable: notable, trim: trim)
|
|
13
13
|
@reader = SSE::Reader.new { |event| @window.push(event[:data], type: event[:event]) }
|
|
14
14
|
@received = false
|
|
15
15
|
@failed = false
|