fast-completions 0.5.0 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4ad51b1c13ffc9236e40151891d9c935967c8ca06b982d43e9871e628900e888
4
- data.tar.gz: b814642c835590b0399ff287b0ea2631bf2382766f242ba95a9e8857f9d8a8ae
3
+ metadata.gz: 296c0ef9c66b4933e440f14845ecca83e7ab18e96f125a572adda19e853be9b7
4
+ data.tar.gz: 12fe00808afa0c4c81a9b36dc3d898803c63dd570fcb29d86292d6c526b96557
5
5
  SHA512:
6
- metadata.gz: 2bfbd4a381f09305f49fdeb578cbde26cf0599de031efa9af638d88dce48b96c38abbabd52e97071633404cdfa7c52237d05b718389465b25595a333a559b549
7
- data.tar.gz: d510754ec3b2da6fb8564d51d1dd6925f7382423fc9312a4618af126c7174679a4641b62f6e537a2c78f4c6e10d68a299ff455c100093a4f9a1857b4ee9cace8
6
+ metadata.gz: 63b785f46a6a15265ddf700f6039d920670059a5a0c39b4b40c92892462577fc9bc1b02c0dc33645be44a28fc991961bdbd437568ed32188081e6b62105f9ba4
7
+ data.tar.gz: c02fdca12bb4133152fc6f41a4c2c2157e011937b93f2eb2dff319c18bda3124836d2a588386ddf96a00022488f13b31b1623edb609c86b7f0c29ea0d77f33c8
data/CHANGELOG.md CHANGED
@@ -3,6 +3,79 @@
3
3
  Notable changes to the fast-completions gem. The format is prose-first,
4
4
  one section per released version, newest first.
5
5
 
6
+ ## 0.7.0 (2026-10-25)
7
+
8
+ - Telemetry sink interface: `Client.new` accepts `telemetry:` — any
9
+ Enumerable of sink objects (an Array, a Set, …), validated loudly at
10
+ construction (a non-Enumerable, or a
11
+ sink not responding to all five hooks, raises `ArgumentError` naming
12
+ the offender and the missing hook; duck-typed sinks are accepted
13
+ without module inclusion; the client keeps its own frozen Array —
14
+ `entries` — so the caller's container is never frozen or mutated) — and dispatches five
15
+ observation hooks around a completion, ONE `Client#stream` invocation
16
+ (creation retries inside, tool execution outside):
17
+ `on_completion_start(model.id, body bytesize)` before the first POST,
18
+ `on_completion_ttfb` at the first parser item of the successful
19
+ attempt (a retried attempt's clock restarts), `on_event` for every
20
+ Event — before its consumer twin, terminals and the salvage `:error`
21
+ included, and even with no consumer block — `on_completion_end(stop
22
+ reason, duration, live message)` when `stream` returns, and
23
+ `on_completion_error(error Symbol derived from the raising class,
24
+ duration)` on every raise-out, cancellation included. Multiple sinks
25
+ dispatch in registration order. The allocation discipline is pinned
26
+ by spec measurement gates: zero added heap objects on the hot paths
27
+ with no sink registered, zero ADDED objects with a no-op sink (only
28
+ refs and immediates cross the boundary, durations are flonum
29
+ differences, every call site short-circuits on a precomputed
30
+ emptiness check), and zero added `Process.clock_gettime` reads. The
31
+ new reference page `docs/reference/telemetry.md` documents the
32
+ protocol, the ordering rule (telemetry is the ground-truth record
33
+ when consumer code is the suspect), the read-only convention for
34
+ sinks, and that metric naming belongs to the adapter.
35
+
36
+ ## 0.6.0 (2026-10-25)
37
+
38
+ - `Client#raw_stream` — the raw completions surface a proxy needs: the
39
+ caller's body forwarded verbatim (a String as-is, anything else
40
+ JSON-generated), every parsed SSE payload yielded to the block in
41
+ arrival order with the wire's terminal DONE token in its wire
42
+ position, and `nil` returned — the block is the only output channel.
43
+ No message accumulation, no tool interpretation, no stop-reason
44
+ mapping, no salvage, no StreamError, and exactly ONE POST attempt —
45
+ no auto-retry of any kind; a non-2xx raises the §8 mapping
46
+ immediately. A malformed payload still rides the I61 chunk seam:
47
+ one repair pass, then drop-and-count on `dropped_chunks` while the
48
+ stream continues.
49
+ - The watchdog is now two-deadline: `ttfb_timeout` (status + headers)
50
+ and `inactivity_timeout` (re-armed after every body chunk), each an
51
+ optional constructor kwarg and per-call override on `raw_stream`,
52
+ validated as a positive Numeric, falling back to the shared
53
+ `timeout` when unset; prefill-scale values (1400 s) are first-class.
54
+ A fire surfaces the phase-named `TimeoutError` ("no response headers
55
+ within Ns" / "no data within Ns") — never `CancelledError` — and a
56
+ user cancel still surfaces `CancelledError`. `Client#stream` keeps
57
+ its single shared timeout and its byte-identical behavior; the
58
+ additive default on `Watchdog#arm` is the only existing-signature
59
+ change.
60
+ - Authentication is optional: `api_key` defaults to nil and the
61
+ `authorization` header is sent only when the key is present and
62
+ non-empty — a local engine needs no key. With a key present the
63
+ header set is unchanged.
64
+ - Docs: a README "Raw streaming" section plus the full reference page
65
+ (`docs/reference/raw-stream.md`); the README owner-prose gate now
66
+ rules two zones. Platform unchanged: ruby floor `>= 3.3`, CI matrix
67
+ 3.3/3.4/4.0, no new dependencies.
68
+ - Internal refactor of `Client#raw_stream` (operator review of #7): the
69
+ method is short orchestration over three private seams — the request
70
+ seam (payload resolution + the ONE POST under the TTFB arm), the
71
+ each-event seam (the SSE parser, the per-chunk inactivity re-arm, the
72
+ DONE yield, the drop-and-count reset), and the cancel→timeout
73
+ translation — with the timeout phase derived from the response
74
+ instead of a threading flag. Behavior-identical: signature, yielded
75
+ events, error classes and messages, no-retry, and the
76
+ connection-finish discipline are unchanged, and the raw_stream spec
77
+ passes byte-unmodified.
78
+
6
79
  ## 0.5.0 (2026-10-17)
7
80
 
8
81
  - Tool-args streaming is now an incremental string-state machine
data/README.md CHANGED
@@ -126,6 +126,23 @@ requests run inside the caller's reactor — the gem never creates its
126
126
  own. Cancellation (`Async::Cancel` / `Chat#cancel`) aborts the
127
127
  in-flight request, discards the connection, and raises
128
128
  `CancelledError` with no phantom terminal event.
129
+
130
+ ## Raw streaming
131
+
132
+ `Client#raw_stream` is the low-level surface a proxy or gateway needs:
133
+ the request body goes over the wire verbatim (a Hash is JSON-encoded
134
+ as given, a String is sent untouched — no merging, no filtering, no
135
+ `stream` injection), and the block receives every parsed SSE payload
136
+ in arrival order, with the wire's terminal DONE token yielded in its
137
+ wire position. No message accumulation, no tool interpretation, no
138
+ stop-reason mapping; exactly one POST attempt — nothing is retried.
139
+ The watchdog is two-deadline: `ttfb_timeout` bounds the wait for the
140
+ response headers and `inactivity_timeout` bounds the gap between body
141
+ chunks (re-armed after every chunk) — prefill-scale values such as
142
+ 1400 s are first-class, and an unset knob falls back to the shared
143
+ `timeout`. The `authorization` header is sent only when `api_key` is
144
+ present and non-empty, so a local engine needs no key. See
145
+ [the raw-stream reference](docs/reference/raw-stream.md).
129
146
  [see the tool docs]: docs/reference/tools.md
130
147
  [see the dialect docs]: docs/reference/dialects.md
131
148
  [Dialect Docs]: docs/reference/dialects.md
@@ -34,11 +34,14 @@ module Fast
34
34
  # resets with the timer: a retried attempt (I62) must not inherit
35
35
  # the previous attempt's fire, or the post-stream guard and the
36
36
  # cancel-vs-fired discipline would misread attempt 2 (D5/D8).
37
- def arm
37
+ # The additive seconds parameter (I03) lets one watchdog arm
38
+ # deadlines of different lengths — raw_stream's split phases —
39
+ # while a bare arm keeps the constructor value (stream, unchanged).
40
+ def arm(seconds = @seconds)
38
41
  stop
39
42
  @fired = false
40
43
  @timer = Async do
41
- sleep @seconds
44
+ sleep seconds
42
45
  @fired = true
43
46
  @task.cancel
44
47
  end
@@ -55,17 +58,48 @@ module Fast
55
58
  Errno::ENETUNREACH, Errno::ETIMEDOUT, EOFError, IOError, SocketError,
56
59
  ].freeze
57
60
 
58
- attr_reader :timeout, :max_retries, :dropped_chunks
61
+ # The five Telemetry::Sink hooks a registered sink must respond to
62
+ # (I01). Checked ONCE at construction — the only sanctioned
63
+ # respond_to? use; notification-time dispatch never reflects.
64
+ TELEMETRY_HOOKS = %i[
65
+ on_completion_start on_completion_ttfb on_event on_completion_end on_completion_error
66
+ ].freeze
67
+
68
+ attr_reader :timeout, :max_retries, :dropped_chunks, :ttfb_timeout, :inactivity_timeout
59
69
 
60
- def initialize(base_url:, api_key:, timeout: DEFAULT_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES)
70
+ def initialize(base_url:, api_key: nil, timeout: DEFAULT_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES,
71
+ ttfb_timeout: nil, inactivity_timeout: nil, telemetry: [])
61
72
  raise ArgumentError, "max_retries must be an Integer >= 0" unless max_retries.is_a?(Integer) && max_retries >= 0
73
+ validate_deadline!(:ttfb_timeout, ttfb_timeout)
74
+ validate_deadline!(:inactivity_timeout, inactivity_timeout)
75
+ unless telemetry.is_a?(Enumerable)
76
+ raise ArgumentError, "telemetry must be an Enumerable of sink objects, got #{telemetry.class}"
77
+ end
78
+
79
+ telemetry.each do |sink|
80
+ missing = TELEMETRY_HOOKS.reject { |hook| sink.respond_to?(hook) }
81
+ next if missing.empty?
82
+
83
+ raise ArgumentError,
84
+ "telemetry sink #{sink.inspect} does not respond to #{missing.join(", ")} (Telemetry::Sink hooks)"
85
+ end
62
86
 
63
87
  @endpoint = Async::HTTP::Endpoint.parse(base_url)
64
88
  @api_key = api_key
65
89
  @timeout = timeout
66
90
  @max_retries = max_retries
91
+ @ttfb_timeout = ttfb_timeout
92
+ @inactivity_timeout = inactivity_timeout
67
93
  @client = Async::HTTP::Client.new(@endpoint)
68
94
  @path = @endpoint.path.chomp("/") + "/chat/completions"
95
+ # I01: the client's own frozen copy (the caller's container is
96
+ # never frozen or mutated) + the precomputed emptiness check every
97
+ # call site short-circuits on — zero allocation, zero added
98
+ # clock reads when no sink is registered. `entries` always
99
+ # builds a fresh Array — for an Array input too, so any
100
+ # Enumerable input lands in the same frozen-Array shape.
101
+ @telemetry = telemetry.entries.freeze
102
+ @telemetry_active = !@telemetry.empty?
69
103
  end
70
104
 
71
105
  def close
@@ -97,48 +131,129 @@ module Fast
97
131
  headers = default_headers.merge(session_headers(model.dialect, session_id))
98
132
  body = JSON.generate(request)
99
133
  accumulator = Protocol::StreamAccumulator.new(model, model.dialect)
100
-
134
+ started_at = notify_start(model.id, body.bytesize) # I01 §1: the completion clock, nil without sinks
101
135
  watchdog = Watchdog.new(Async::Task.current, @timeout)
102
136
  response = nil
137
+ post_started_at = nil # the committed attempt's POST clock (I01 §2), set by create_response's yield
103
138
  begin
104
- # The POST attempt — creation through status receipt (§2),
105
- # bounded-retried on pi's trigger set; every final creation
106
- # failure raises its mapped error from in there.
107
- response = create_response(headers, body, watchdog)
108
- yield accumulator.start_event if block # :start precedes every block event (§10.6)
109
- feed_stream(response, accumulator, watchdog, &block)
139
+ response = create_response(headers, body, watchdog) { |t0| post_started_at = t0 }
140
+ if block || @telemetry_active # :start even blockless (I01 §4); a consumer raise flies to the ladder
141
+ dispatch_event(accumulator.start_event, &block)
142
+ end
143
+ feed_stream(response, accumulator, watchdog, post_started_at, &block)
110
144
  rescue Async::Cancel
111
- watchdog.fired ? raise(TimeoutError, "no data within #{@timeout}s") : raise(CancelledError, "stream cancelled")
145
+ if watchdog.fired # I01 §7: fired → timeout, else user cancel (D8); cancel never salvages (AC3)
146
+ notify_error(:timeout_error, started_at)
147
+ raise(TimeoutError, "no data within #{@timeout}s")
148
+ end
149
+ notify_error(:cancelled_error, started_at)
150
+ raise(CancelledError, "stream cancelled")
112
151
  rescue *NETWORK_ERRORS => e
113
- salvage(accumulator, &block) # the :error terminal carries the partial BEFORE the raise (§1)
152
+ salvage(accumulator, started_at, &block) # the :error terminal carries the partial BEFORE the raise (§1)
153
+ notify_error(:connection_error, started_at) # I01 §7: the symbol of the ConnectionError about to raise
114
154
  raise ConnectionError, "#{e.class}: #{e.message}"
115
- rescue StandardError
116
- salvage(accumulator, &block) # pi's catch-all: any other streaming failure — same terminal, raised unchanged
155
+ rescue StandardError => e
156
+ salvage(accumulator, started_at, &block) # pi's catch-all: same terminal, raised unchanged
157
+ notify_error(telemetry_symbol_for(e), started_at) # I01 §7: derived from the re-raised class
117
158
  raise
118
159
  ensure
119
- # Complete bodies return the connection to the pool; an aborted
120
- # read (cancel/timeout) discards it — a cancelled connection is
121
- # never reused (AC3/D8).
122
- begin
123
- response.finish
124
- rescue StandardError, Async::Cancel
125
- nil
126
- end
160
+ finish_quietly(response) # nil-safe: no response yet, or an aborted read (AC3/D8)
127
161
  watchdog.stop
128
162
  end
129
- raise TimeoutError, "no data within #{@timeout}s" if watchdog.fired
163
+ if watchdog.fired # I01 §7: the post-ensure watchdog raise
164
+ notify_error(:timeout_error, started_at)
165
+ raise TimeoutError, "no data within #{@timeout}s"
166
+ end
167
+ deliver_terminal(accumulator, started_at, &block)
168
+ end
130
169
 
131
- deliver_terminal(accumulator, &block)
170
+ # The raw surface a proxy needs (I03, BRIEF §3): the caller's
171
+ # request body forwarded VERBATIM (a String as-is, anything else
172
+ # JSON.generate'd — no merging, no filtering, no stream
173
+ # injection, no Model/dialect involvement, no session-affinity
174
+ # headers), every parsed SSE data payload yielded to the block in
175
+ # arrival order — the SSE::DONE sentinel in its wire position —
176
+ # and nil returned: the block is the only output channel. No
177
+ # Message accumulation, no tool interpretation, no stop-reason
178
+ # mapping, no salvage, no StreamError — a stream that ends without
179
+ # the terminal token simply ends. Exactly ONE POST attempt: no
180
+ # create_response, no retry logic, no x-should-retry consultation;
181
+ # a non-2xx raises the existing §8 mapping (raise_api_error).
182
+ #
183
+ # Watchdog, split by I03: two deadlines. TTFB (status + headers)
184
+ # arms before the POST; from response receipt on the inactivity
185
+ # deadline re-arms after every body chunk (D5). Each resolves per
186
+ # phase — call kwarg > client kwarg > the shared timeout. A fire
187
+ # cancels the waiting task and surfaces the phase-named
188
+ # TimeoutError, never CancelledError (D8); a user cancel surfaces
189
+ # CancelledError. Runs inside the caller's task (§6).
190
+ #
191
+ # I05 revision (operator review of #7 — "the raw_stream method
192
+ # is way too long and not idiomatic ruby"): the method is now
193
+ # short orchestration over three private seams — the request seam
194
+ # (post_raw_body), the each-event seam (each_raw_event), and the
195
+ # cancel translation (raise_raw_cancel). The phase a timeout
196
+ # fires in is derived from the response itself — headers are
197
+ # outstanding exactly until the POST returns — so no phase flag
198
+ # threads through the ladder.
199
+ def raw_stream(body, ttfb_timeout: nil, inactivity_timeout: nil, &block)
200
+ ttfb = resolve_deadline(:ttfb_timeout, ttfb_timeout, @ttfb_timeout)
201
+ idle = resolve_deadline(:inactivity_timeout, inactivity_timeout, @inactivity_timeout)
202
+
203
+ watchdog = Watchdog.new(Async::Task.current, ttfb)
204
+ response = nil
205
+ begin
206
+ response = post_raw_body(body, watchdog)
207
+ raise_api_error(response) unless (200...300).cover?(response.status)
208
+
209
+ each_raw_event(response, idle, watchdog, &block)
210
+ rescue Async::Cancel
211
+ raise_raw_cancel(watchdog, response, ttfb, idle)
212
+ rescue *NETWORK_ERRORS => e
213
+ raise ConnectionError, "#{e.class}: #{e.message}"
214
+ ensure
215
+ finish_quietly(response)
216
+ watchdog.stop
217
+ end
218
+ nil
132
219
  end
133
220
 
134
221
  private
135
222
 
136
223
  def default_headers
137
- {
138
- "authorization" => "Bearer #{@api_key}",
139
- "content-type" => "application/json",
140
- "accept" => "application/json",
141
- }
224
+ # Authorization is optional (I03): sent only when a key is
225
+ # present and non-empty — a local engine needs none. With a key
226
+ # the header set is byte-identical to before.
227
+ if @api_key && !@api_key.empty?
228
+ {
229
+ "authorization" => "Bearer #{@api_key}",
230
+ "content-type" => "application/json",
231
+ "accept" => "application/json",
232
+ }
233
+ else
234
+ {"content-type" => "application/json", "accept" => "application/json"}
235
+ end
236
+ end
237
+
238
+ # The deadline knobs (I03): nil = unset (resolution falls through
239
+ # to the shared timeout); any explicitly-given value must be a
240
+ # positive Numeric. Applies at the constructor and, via
241
+ # resolve_deadline, to per-call overrides on raw_stream.
242
+ def validate_deadline!(name, value)
243
+ return if value.nil?
244
+ return if value.is_a?(Numeric) && value.positive?
245
+
246
+ raise ArgumentError, "#{name} must be a positive Numeric"
247
+ end
248
+
249
+ # Per-phase deadline resolution for raw_stream (I03): the call
250
+ # kwarg, else the client's, else the shared timeout. A call kwarg
251
+ # validates even though the client's own did — both are public
252
+ # surfaces. Runs BEFORE any reactor dependency, so a bad knob
253
+ # raises ArgumentError outside a reactor too.
254
+ def resolve_deadline(name, call_value, client_value)
255
+ validate_deadline!(name, call_value) unless call_value.nil?
256
+ call_value || client_value || @timeout
142
257
  end
143
258
 
144
259
  # Session-affinity headers per dialect when a session id is supplied
@@ -156,19 +271,83 @@ module Fast
156
271
  end
157
272
  end
158
273
 
159
- def feed_stream(response, accumulator, watchdog, &block)
274
+ # The raw request seam (I05 revision): resolve the payload (a
275
+ # String verbatim, anything else JSON-generated) and make the ONE
276
+ # POST, the TTFB deadline armed for creation through status
277
+ # receipt. The status check stays with the caller — raw_stream's
278
+ # ensure ladder must see even a non-2xx response to keep the
279
+ # connection-finish discipline (raise_api_error reads the body;
280
+ # finish then returns the connection to the pool).
281
+ def post_raw_body(body, watchdog)
282
+ payload = body.is_a?(String) ? body : JSON.generate(body)
283
+ watchdog.arm
284
+ @client.post(@path, headers: default_headers, body: payload)
285
+ end
286
+
287
+ # The raw each-event seam (I05 revision) — a reusable "each
288
+ # event" concern: owns the SSE parser, the per-chunk inactivity
289
+ # re-arm (D5 — the first chunk's arm is the TTFB→inactivity
290
+ # transition), and the I61 drop-and-count reset; yields the DONE
291
+ # sentinel in its wire position and every surviving payload
292
+ # parsed. The block is optional — a blockless call drains.
293
+ def each_raw_event(response, idle, watchdog, &block)
160
294
  parser = SSE.new
161
295
  @dropped_chunks = 0
296
+ response.body.each do |chunk|
297
+ watchdog.arm(idle) # re-arm the inactivity read-timeout per chunk (D5)
298
+ parser.push(chunk).each do |item|
299
+ if item.equal?(SSE::DONE) # the wire's terminal token: yielded in place, no payload
300
+ block&.call(item)
301
+ next
302
+ end
303
+
304
+ json = parse_chunk(item)
305
+ next if json.nil? # dropped (I61): counted, the stream continues
306
+
307
+ block&.call(json)
308
+ end
309
+ end
310
+ end
311
+
312
+ # Cancel→timeout translation (I05 revision): a watchdog fire
313
+ # surfaces the PHASE-named TimeoutError — headers while the POST
314
+ # is outstanding (the response is still nil), read inactivity
315
+ # once the body has begun — while a user's cancel stays
316
+ # CancelledError (D8). The phase needs no flag: headers are
317
+ # outstanding exactly until post_raw_body returns.
318
+ def raise_raw_cancel(watchdog, response, ttfb, idle)
319
+ if watchdog.fired
320
+ message = response.nil? ? "no response headers within #{ttfb}s" : "no data within #{idle}s"
321
+ raise TimeoutError, message
322
+ end
323
+ raise CancelledError, "stream cancelled"
324
+ end
325
+
326
+ # The wire read (§3): SSE parse → accumulator → dispatch. TTFB
327
+ # fires at the FIRST parser-yielded item of the SUCCESSFUL attempt
328
+ # (I01 §3 — a `[DONE]` sentinel counts; comments and keepalives
329
+ # never yield items, sse.rb drops them structurally), measured
330
+ # from that attempt's POST (post_started_at, threaded in from
331
+ # create_response; a retried attempt's clock restarts), at most
332
+ # once per completion. Consumer raises here fly to stream's
333
+ # ladder, which closes the record after salvage.
334
+ def feed_stream(response, accumulator, watchdog, post_started_at, &block)
335
+ parser = SSE.new
336
+ @dropped_chunks = 0
337
+ ttfb_fired = false
162
338
  response.body.each do |chunk|
163
339
  watchdog.arm # re-arm the inactivity read-timeout per chunk (D5)
164
340
  parser.push(chunk).each do |item|
341
+ if @telemetry_active && !ttfb_fired # before this item's dispatches; a flonum difference — no heap
342
+ ttfb_fired = true
343
+ notify_ttfb(Process.clock_gettime(Process::CLOCK_MONOTONIC) - post_started_at)
344
+ end
165
345
  next if item == SSE::DONE # terminal token; no payload
166
346
 
167
347
  json = parse_chunk(item)
168
348
  next if json.nil? # dropped (I61): counted, the stream continues
169
349
 
170
- events = accumulator.consume(json)
171
- events.each { |event| yield event } if block
350
+ accumulator.consume(json).each { |event| dispatch_event(event, &block) }
172
351
  end
173
352
  end
174
353
  end
@@ -198,14 +377,29 @@ module Fast
198
377
  # stop and a missing finish both emit :error FIRST, then raise
199
378
  # StreamError; otherwise :done with the mapped reason. Cancel never
200
379
  # reaches here — it unwinds mid-read with NO terminal event (AC3).
201
- def deliver_terminal(accumulator, &block)
380
+ # I01 §5: every terminal dispatch reaches the sinks (blockless
381
+ # included), on_completion_error rides the StreamError raise, and
382
+ # on_completion_end fires after the :done dispatches. The terminal
383
+ # Events are built only under the observer guard (zero-path
384
+ # discipline); every yield below is a ladder-UNCOVERED site — this
385
+ # method runs AFTER the ladder — so dispatch_event closes the
386
+ # record on a consumer raise here (see dispatch_event).
387
+ def deliver_terminal(accumulator, started_at, &block)
202
388
  events, outcome = accumulator.finish
203
- events.each { |event| yield event } if block
204
- if outcome[0] == :stream_error
205
- yield Event.new(type: :error, reason: :error, message: accumulator.partial) if block
206
- raise StreamError.new(outcome[1], stop_reason: :error)
389
+ if block || @telemetry_active
390
+ events.each { |event| dispatch_event(event, started_at, closing: true, &block) }
391
+ if outcome[0] == :stream_error
392
+ dispatch_event(Event.new(type: :error, reason: :error, message: accumulator.partial),
393
+ started_at, closing: true, &block)
394
+ notify_error(:stream_error, started_at) # the symbol of the StreamError about to raise
395
+ else
396
+ dispatch_event(Event.new(type: :done, reason: outcome[1], message: accumulator.partial),
397
+ started_at, closing: true, &block)
398
+ notify_end(outcome[1], accumulator.partial, started_at) # after the :done dispatches, before the return
399
+ end
207
400
  end
208
- yield Event.new(type: :done, reason: outcome[1], message: accumulator.partial) if block
401
+ raise StreamError.new(outcome[1], stop_reason: :error) if outcome[0] == :stream_error
402
+
209
403
  accumulator.partial
210
404
  end
211
405
 
@@ -226,6 +420,7 @@ module Fast
226
420
  remaining = @max_retries
227
421
  loop do
228
422
  watchdog.arm # re-armed per attempt: a retried TTFB deadline restarts (D5)
423
+ t0 = @telemetry_active ? Process.clock_gettime(Process::CLOCK_MONOTONIC) : nil # I01 §2, guarded (gate 3)
229
424
  begin
230
425
  response = @client.post(@path, headers: headers, body: body)
231
426
  rescue Async::Cancel
@@ -234,12 +429,14 @@ module Fast
234
429
  remaining -= 1
235
430
  next
236
431
  rescue *NETWORK_ERRORS => e
237
- # Statusless — no response was received (pi's statusless trigger).
238
- raise ConnectionError, "#{e.class}: #{e.message}" if remaining.zero?
432
+ raise ConnectionError, "#{e.class}: #{e.message}" if remaining.zero? # statusless trigger
239
433
  remaining -= 1
240
434
  next
241
435
  end
242
- return response if (200...300).cover?(response.status)
436
+ if (200...300).cover?(response.status) # committed to streaming: never retried past a 2xx
437
+ yield(t0) if block_given? # the committed attempt's POST clock → stream's TTFB local (allocation-free)
438
+ return response
439
+ end
243
440
 
244
441
  if retry_response?(response) && remaining.positive?
245
442
  finish_quietly(response) # the discarded attempt's connection returns to the pool
@@ -280,16 +477,22 @@ module Fast
280
477
  # finalize_blocks — the thinking reasoning_details serialized into
281
478
  # the signature, tool arguments finalized — whose end events we
282
479
  # discard here, so a salvaged partial replays like a completed one's.
283
- def salvage(accumulator)
284
- return unless block_given?
480
+ # The yield below runs INSIDE a rescue arm — ladder-uncovered — so
481
+ # dispatch_event closes the record on a consumer raise; the early
482
+ # return keeps the blockless-no-sink path byte-identical (I01 §6).
483
+ def salvage(accumulator, started_at, &block)
484
+ return unless block_given? || @telemetry_active # the blockless-no-sink early return (I01 §6)
285
485
 
286
486
  accumulator.finish
287
- yield Event.new(type: :error, reason: :error, message: accumulator.partial)
487
+ dispatch_event(Event.new(type: :error, reason: :error, message: accumulator.partial),
488
+ started_at, closing: true, &block)
288
489
  end
289
490
 
290
491
  # Complete bodies return the connection to the pool; an aborted read
291
492
  # (cancel/timeout) discards it — a cancelled connection is never
292
- # reused (AC3/D8). Same discipline as stream's ensure.
493
+ # reused (AC3/D8). Serves the discarded retry attempt and stream's
494
+ # ensure; nil-safe (no response yet → the rescue swallows the
495
+ # NoMethodError exactly as the inline form did).
293
496
  def finish_quietly(response)
294
497
  response.finish
295
498
  rescue StandardError, Async::Cancel
@@ -303,6 +506,98 @@ module Fast
303
506
  body = response.body.read
304
507
  raise Errors.class_for_status(response.status, body).new(status: response.status, body: body)
305
508
  end
509
+
510
+ # ── I01 telemetry dispatch ─────────────────────────────────────
511
+ # Every helper short-circuits on the precomputed emptiness check
512
+ # BEFORE any clock read or dispatch: with no sink registered the
513
+ # call sites cost one no-op method call, zero allocations, zero
514
+ # Process.clock_gettime reads. Per-completion state (the
515
+ # completion-start clock, the per-attempt POST clock, the
516
+ # ttfb-once flag) is threaded as locals/args, never Client ivars
517
+ # (I03); durations are flonum differences; dispatch is in
518
+ # registration order; the loop block itself is never materialized,
519
+ # so a no-op sink adds zero heap objects (gate 2).
520
+
521
+ # The ONE event dispatch seam: sinks first, consumer second (I01
522
+ # §4/§5 — telemetry is the ground-truth record). `closing:` marks
523
+ # yield sites the rescue ladder does NOT cover — deliver_terminal
524
+ # runs after it, salvage inside a rescue arm — where a consumer
525
+ # raise closes the record here (the raiser's symbol) before the
526
+ # unchanged, backtrace-preserving re-raise; on StandardError only,
527
+ # the ladder's own catch scope, so cancel and Exception-class
528
+ # raises keep today's escape semantics. At the ladder-covered
529
+ # sites the StandardError arm does the closing (after salvage) —
530
+ # closing here too would double-fire the record. Dispatch is
531
+ # implicit yield/block_given? (no captured &block of its own — no
532
+ # Proc materialization, gate 2); callers forward their
533
+ # already-captured Proc at the call site, which is free.
534
+ def dispatch_event(event, started_at = nil, closing: false)
535
+ return unless block_given? || @telemetry_active
536
+
537
+ notify_event(event)
538
+ if closing
539
+ begin
540
+ yield event if block_given?
541
+ rescue StandardError => e
542
+ notify_error(telemetry_symbol_for(e), started_at)
543
+ raise
544
+ end
545
+ else
546
+ yield event if block_given?
547
+ end
548
+ end
549
+
550
+ # I01 §1: the completion clock (every duration below differs from
551
+ # it) + on_completion_start, before the first POST of the first
552
+ # attempt. Returns the clock — nil without a sink, zero added
553
+ # clock reads (gate 3).
554
+ def notify_start(model_id, request_bytes)
555
+ return nil unless @telemetry_active
556
+
557
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
558
+ @telemetry.each { |sink| sink.on_completion_start(model_id, request_bytes) }
559
+ started_at
560
+ end
561
+
562
+ def notify_ttfb(elapsed)
563
+ return unless @telemetry_active
564
+
565
+ @telemetry.each { |sink| sink.on_completion_ttfb(elapsed) }
566
+ end
567
+
568
+ def notify_event(event)
569
+ return unless @telemetry_active
570
+
571
+ @telemetry.each { |sink| sink.on_event(event) }
572
+ end
573
+
574
+ def notify_end(stop_reason, message, started_at)
575
+ return unless @telemetry_active
576
+
577
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
578
+ @telemetry.each { |sink| sink.on_completion_end(stop_reason, duration, message) }
579
+ end
580
+
581
+ def notify_error(error, started_at)
582
+ return unless @telemetry_active
583
+
584
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
585
+ @telemetry.each { |sink| sink.on_completion_error(error, duration) }
586
+ end
587
+
588
+ # The re-raise arm's error Symbol: derived from the raising class —
589
+ # demodulized, underscored (RateLimitError → :rate_limit_error,
590
+ # RuntimeError → :runtime_error). Computed only on the raise path,
591
+ # under the active guard; an anonymous class has no name.
592
+ def telemetry_symbol_for(error)
593
+ name = error.class.name
594
+ return :anonymous_error if name.nil?
595
+
596
+ name.split("::").last
597
+ .gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
598
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
599
+ .downcase.to_sym
600
+ end
306
601
  end
307
602
  end
308
603
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fast
4
+ module Completions
5
+ # The telemetry observation protocol (I01): a sink includes this
6
+ # module and overrides what it wants — the defaults are no-ops. No
7
+ # base class, no respond_to? dispatch at notification time (Client
8
+ # validates the five hooks ONCE at construction, the only sanctioned
9
+ # respond_to? use); duck-typed sinks that define all five are
10
+ # accepted without including the module.
11
+ #
12
+ # Unit of observation: a COMPLETION — ONE Client#stream invocation
13
+ # (one logical request-response on POST {base}/chat/completions with
14
+ # stream: true). Creation retries are INSIDE the completion (a
15
+ # retried completion is one completion); tool execution (the Chat
16
+ # batch) is OUTSIDE it.
17
+ #
18
+ # All hook arguments are existing references or immediates — no Hash
19
+ # payloads, no structs, no interpolated strings, no keyword splats —
20
+ # so dispatching to a no-op sink allocates nothing on the hot paths.
21
+ module Telemetry
22
+ # A sink includes this module and overrides what it wants. The
23
+ # defaults are no-ops. No base class; no respond_to? dispatch at
24
+ # notification time.
25
+ module Sink
26
+ # Exactly once per completion, before the first POST of the
27
+ # first attempt. `model_id` is the Model#id String ref
28
+ # (equal?-stable, never a copy); `request_bytes` is the Integer
29
+ # bytesize of the serialized request body actually POSTed.
30
+ def on_completion_start(model_id, request_bytes); end
31
+
32
+ # At most once, at the FIRST item the SSE parser yields for the
33
+ # SUCCESSFUL (2xx) attempt — measured from that attempt's POST
34
+ # initiation (a retried attempt's clock restarts). Seconds since
35
+ # the POST, a Float. Does not fire when the completion fails
36
+ # before any item.
37
+ def on_completion_ttfb(seconds); end
38
+
39
+ # Every Event the consumer block sees, BEFORE the consumer
40
+ # block, same order — terminals (:done, :error) and the
41
+ # salvage-path :error included; with a registered sink but no
42
+ # consumer block, sinks still receive every Event produced at
43
+ # the yield sites. The argument is the Event ref the accumulator
44
+ # already allocated.
45
+ def on_event(event); end
46
+
47
+ # Exactly once, when Client#stream returns: after the :done
48
+ # event's dispatches. `stop_reason` is the mapped Symbol (:stop,
49
+ # :length, :tool_use); `duration` is the Float from the
50
+ # completion-start clock; `message` is the accumulator's LIVE
51
+ # assistant Message (see the read-only convention in
52
+ # docs/reference/telemetry.md).
53
+ def on_completion_end(stop_reason, duration, message); end
54
+
55
+ # Exactly once on EVERY raise-out of Client#stream — the mapped
56
+ # paths and any other raise re-raised through the ladder.
57
+ # `error` is a Symbol derived from the raising class
58
+ # (demodulized, underscored: :rate_limit_error,
59
+ # :connection_error, :timeout_error, :cancelled_error,
60
+ # :stream_error, :runtime_error, …); `duration` is the Float
61
+ # start-to-raise on the same completion-start clock.
62
+ def on_completion_error(error, duration); end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Fast
4
4
  module Completions
5
- VERSION = "0.5.0"
5
+ VERSION = "0.7.0"
6
6
  end
7
7
  end
@@ -13,6 +13,7 @@ require_relative "completions/sse"
13
13
  require_relative "completions/codec"
14
14
  require_relative "completions/constrained_sampling"
15
15
  require_relative "completions/protocol"
16
+ require_relative "completions/telemetry"
16
17
  require_relative "completions/client"
17
18
  require_relative "completions/tool"
18
19
  require_relative "completions/cancellation"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fast-completions
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Jacobs
@@ -44,6 +44,7 @@ files:
44
44
  - lib/fast/completions/errors.rb
45
45
  - lib/fast/completions/protocol.rb
46
46
  - lib/fast/completions/sse.rb
47
+ - lib/fast/completions/telemetry.rb
47
48
  - lib/fast/completions/tool.rb
48
49
  - lib/fast/completions/types.rb
49
50
  - lib/fast/completions/version.rb