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 +4 -4
- data/CHANGELOG.md +73 -0
- data/README.md +17 -0
- data/lib/fast/completions/client.rb +341 -46
- data/lib/fast/completions/telemetry.rb +66 -0
- data/lib/fast/completions/version.rb +1 -1
- data/lib/fast/completions.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 296c0ef9c66b4933e440f14845ecca83e7ab18e96f125a572adda19e853be9b7
|
|
4
|
+
data.tar.gz: 12fe00808afa0c4c81a9b36dc3d898803c63dd570fcb29d86292d6c526b96557
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
105
|
-
#
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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:
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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).
|
|
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
|
data/lib/fast/completions.rb
CHANGED
|
@@ -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.
|
|
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
|