fast-completions 0.5.0 → 0.6.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 +43 -0
- data/README.md +17 -0
- data/lib/fast/completions/client.rb +148 -9
- data/lib/fast/completions/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9d3db818f96038ddf53481fea90ce309a3a01be84d924e8d4707ce121a9232ba
|
|
4
|
+
data.tar.gz: a6aa0363ad9ba1c6c2b45e47645137390254d8c1b0c8067e8de7f6cc64ed7e43
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 63a9237cef1bbf316295f96bb6bd13107f5efadef2f0434070070edbd1a74a4668c725d6b7690a73cdfa7d02b750df2c4a85f5a60a1f77b96d0c48d4767c6f20
|
|
7
|
+
data.tar.gz: 90c846372d3c94697e85de1075d61c7b40525b9c0ad7d2dcc11f632c6204d002e946e5ffb9b62529da0695ebeab930af24413475366ef7d09204f3f233ffefea
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,49 @@
|
|
|
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.6.0 (2026-10-25)
|
|
7
|
+
|
|
8
|
+
- `Client#raw_stream` — the raw completions surface a proxy needs: the
|
|
9
|
+
caller's body forwarded verbatim (a String as-is, anything else
|
|
10
|
+
JSON-generated), every parsed SSE payload yielded to the block in
|
|
11
|
+
arrival order with the wire's terminal DONE token in its wire
|
|
12
|
+
position, and `nil` returned — the block is the only output channel.
|
|
13
|
+
No message accumulation, no tool interpretation, no stop-reason
|
|
14
|
+
mapping, no salvage, no StreamError, and exactly ONE POST attempt —
|
|
15
|
+
no auto-retry of any kind; a non-2xx raises the §8 mapping
|
|
16
|
+
immediately. A malformed payload still rides the I61 chunk seam:
|
|
17
|
+
one repair pass, then drop-and-count on `dropped_chunks` while the
|
|
18
|
+
stream continues.
|
|
19
|
+
- The watchdog is now two-deadline: `ttfb_timeout` (status + headers)
|
|
20
|
+
and `inactivity_timeout` (re-armed after every body chunk), each an
|
|
21
|
+
optional constructor kwarg and per-call override on `raw_stream`,
|
|
22
|
+
validated as a positive Numeric, falling back to the shared
|
|
23
|
+
`timeout` when unset; prefill-scale values (1400 s) are first-class.
|
|
24
|
+
A fire surfaces the phase-named `TimeoutError` ("no response headers
|
|
25
|
+
within Ns" / "no data within Ns") — never `CancelledError` — and a
|
|
26
|
+
user cancel still surfaces `CancelledError`. `Client#stream` keeps
|
|
27
|
+
its single shared timeout and its byte-identical behavior; the
|
|
28
|
+
additive default on `Watchdog#arm` is the only existing-signature
|
|
29
|
+
change.
|
|
30
|
+
- Authentication is optional: `api_key` defaults to nil and the
|
|
31
|
+
`authorization` header is sent only when the key is present and
|
|
32
|
+
non-empty — a local engine needs no key. With a key present the
|
|
33
|
+
header set is unchanged.
|
|
34
|
+
- Docs: a README "Raw streaming" section plus the full reference page
|
|
35
|
+
(`docs/reference/raw-stream.md`); the README owner-prose gate now
|
|
36
|
+
rules two zones. Platform unchanged: ruby floor `>= 3.3`, CI matrix
|
|
37
|
+
3.3/3.4/4.0, no new dependencies.
|
|
38
|
+
- Internal refactor of `Client#raw_stream` (operator review of #7): the
|
|
39
|
+
method is short orchestration over three private seams — the request
|
|
40
|
+
seam (payload resolution + the ONE POST under the TTFB arm), the
|
|
41
|
+
each-event seam (the SSE parser, the per-chunk inactivity re-arm, the
|
|
42
|
+
DONE yield, the drop-and-count reset), and the cancel→timeout
|
|
43
|
+
translation — with the timeout phase derived from the response
|
|
44
|
+
instead of a threading flag. Behavior-identical: signature, yielded
|
|
45
|
+
events, error classes and messages, no-retry, and the
|
|
46
|
+
connection-finish discipline are unchanged, and the raw_stream spec
|
|
47
|
+
passes byte-unmodified.
|
|
48
|
+
|
|
6
49
|
## 0.5.0 (2026-10-17)
|
|
7
50
|
|
|
8
51
|
- 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,15 +58,20 @@ 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
|
+
attr_reader :timeout, :max_retries, :dropped_chunks, :ttfb_timeout, :inactivity_timeout
|
|
59
62
|
|
|
60
|
-
def initialize(base_url:, api_key
|
|
63
|
+
def initialize(base_url:, api_key: nil, timeout: DEFAULT_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES,
|
|
64
|
+
ttfb_timeout: nil, inactivity_timeout: nil)
|
|
61
65
|
raise ArgumentError, "max_retries must be an Integer >= 0" unless max_retries.is_a?(Integer) && max_retries >= 0
|
|
66
|
+
validate_deadline!(:ttfb_timeout, ttfb_timeout)
|
|
67
|
+
validate_deadline!(:inactivity_timeout, inactivity_timeout)
|
|
62
68
|
|
|
63
69
|
@endpoint = Async::HTTP::Endpoint.parse(base_url)
|
|
64
70
|
@api_key = api_key
|
|
65
71
|
@timeout = timeout
|
|
66
72
|
@max_retries = max_retries
|
|
73
|
+
@ttfb_timeout = ttfb_timeout
|
|
74
|
+
@inactivity_timeout = inactivity_timeout
|
|
67
75
|
@client = Async::HTTP::Client.new(@endpoint)
|
|
68
76
|
@path = @endpoint.path.chomp("/") + "/chat/completions"
|
|
69
77
|
end
|
|
@@ -131,14 +139,93 @@ module Fast
|
|
|
131
139
|
deliver_terminal(accumulator, &block)
|
|
132
140
|
end
|
|
133
141
|
|
|
142
|
+
# The raw surface a proxy needs (I03, BRIEF §3): the caller's
|
|
143
|
+
# request body forwarded VERBATIM (a String as-is, anything else
|
|
144
|
+
# JSON.generate'd — no merging, no filtering, no stream
|
|
145
|
+
# injection, no Model/dialect involvement, no session-affinity
|
|
146
|
+
# headers), every parsed SSE data payload yielded to the block in
|
|
147
|
+
# arrival order — the SSE::DONE sentinel in its wire position —
|
|
148
|
+
# and nil returned: the block is the only output channel. No
|
|
149
|
+
# Message accumulation, no tool interpretation, no stop-reason
|
|
150
|
+
# mapping, no salvage, no StreamError — a stream that ends without
|
|
151
|
+
# the terminal token simply ends. Exactly ONE POST attempt: no
|
|
152
|
+
# create_response, no retry logic, no x-should-retry consultation;
|
|
153
|
+
# a non-2xx raises the existing §8 mapping (raise_api_error).
|
|
154
|
+
#
|
|
155
|
+
# Watchdog, split by I03: two deadlines. TTFB (status + headers)
|
|
156
|
+
# arms before the POST; from response receipt on the inactivity
|
|
157
|
+
# deadline re-arms after every body chunk (D5). Each resolves per
|
|
158
|
+
# phase — call kwarg > client kwarg > the shared timeout. A fire
|
|
159
|
+
# cancels the waiting task and surfaces the phase-named
|
|
160
|
+
# TimeoutError, never CancelledError (D8); a user cancel surfaces
|
|
161
|
+
# CancelledError. Runs inside the caller's task (§6).
|
|
162
|
+
#
|
|
163
|
+
# I05 revision (operator review of #7 — "the raw_stream method
|
|
164
|
+
# is way too long and not idiomatic ruby"): the method is now
|
|
165
|
+
# short orchestration over three private seams — the request seam
|
|
166
|
+
# (post_raw_body), the each-event seam (each_raw_event), and the
|
|
167
|
+
# cancel translation (raise_raw_cancel). The phase a timeout
|
|
168
|
+
# fires in is derived from the response itself — headers are
|
|
169
|
+
# outstanding exactly until the POST returns — so no phase flag
|
|
170
|
+
# threads through the ladder.
|
|
171
|
+
def raw_stream(body, ttfb_timeout: nil, inactivity_timeout: nil, &block)
|
|
172
|
+
ttfb = resolve_deadline(:ttfb_timeout, ttfb_timeout, @ttfb_timeout)
|
|
173
|
+
idle = resolve_deadline(:inactivity_timeout, inactivity_timeout, @inactivity_timeout)
|
|
174
|
+
|
|
175
|
+
watchdog = Watchdog.new(Async::Task.current, ttfb)
|
|
176
|
+
response = nil
|
|
177
|
+
begin
|
|
178
|
+
response = post_raw_body(body, watchdog)
|
|
179
|
+
raise_api_error(response) unless (200...300).cover?(response.status)
|
|
180
|
+
|
|
181
|
+
each_raw_event(response, idle, watchdog, &block)
|
|
182
|
+
rescue Async::Cancel
|
|
183
|
+
raise_raw_cancel(watchdog, response, ttfb, idle)
|
|
184
|
+
rescue *NETWORK_ERRORS => e
|
|
185
|
+
raise ConnectionError, "#{e.class}: #{e.message}"
|
|
186
|
+
ensure
|
|
187
|
+
finish_quietly(response)
|
|
188
|
+
watchdog.stop
|
|
189
|
+
end
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
|
|
134
193
|
private
|
|
135
194
|
|
|
136
195
|
def default_headers
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
196
|
+
# Authorization is optional (I03): sent only when a key is
|
|
197
|
+
# present and non-empty — a local engine needs none. With a key
|
|
198
|
+
# the header set is byte-identical to before.
|
|
199
|
+
if @api_key && !@api_key.empty?
|
|
200
|
+
{
|
|
201
|
+
"authorization" => "Bearer #{@api_key}",
|
|
202
|
+
"content-type" => "application/json",
|
|
203
|
+
"accept" => "application/json",
|
|
204
|
+
}
|
|
205
|
+
else
|
|
206
|
+
{"content-type" => "application/json", "accept" => "application/json"}
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The deadline knobs (I03): nil = unset (resolution falls through
|
|
211
|
+
# to the shared timeout); any explicitly-given value must be a
|
|
212
|
+
# positive Numeric. Applies at the constructor and, via
|
|
213
|
+
# resolve_deadline, to per-call overrides on raw_stream.
|
|
214
|
+
def validate_deadline!(name, value)
|
|
215
|
+
return if value.nil?
|
|
216
|
+
return if value.is_a?(Numeric) && value.positive?
|
|
217
|
+
|
|
218
|
+
raise ArgumentError, "#{name} must be a positive Numeric"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Per-phase deadline resolution for raw_stream (I03): the call
|
|
222
|
+
# kwarg, else the client's, else the shared timeout. A call kwarg
|
|
223
|
+
# validates even though the client's own did — both are public
|
|
224
|
+
# surfaces. Runs BEFORE any reactor dependency, so a bad knob
|
|
225
|
+
# raises ArgumentError outside a reactor too.
|
|
226
|
+
def resolve_deadline(name, call_value, client_value)
|
|
227
|
+
validate_deadline!(name, call_value) unless call_value.nil?
|
|
228
|
+
call_value || client_value || @timeout
|
|
142
229
|
end
|
|
143
230
|
|
|
144
231
|
# Session-affinity headers per dialect when a session id is supplied
|
|
@@ -156,6 +243,58 @@ module Fast
|
|
|
156
243
|
end
|
|
157
244
|
end
|
|
158
245
|
|
|
246
|
+
# The raw request seam (I05 revision): resolve the payload (a
|
|
247
|
+
# String verbatim, anything else JSON-generated) and make the ONE
|
|
248
|
+
# POST, the TTFB deadline armed for creation through status
|
|
249
|
+
# receipt. The status check stays with the caller — raw_stream's
|
|
250
|
+
# ensure ladder must see even a non-2xx response to keep the
|
|
251
|
+
# connection-finish discipline (raise_api_error reads the body;
|
|
252
|
+
# finish then returns the connection to the pool).
|
|
253
|
+
def post_raw_body(body, watchdog)
|
|
254
|
+
payload = body.is_a?(String) ? body : JSON.generate(body)
|
|
255
|
+
watchdog.arm
|
|
256
|
+
@client.post(@path, headers: default_headers, body: payload)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# The raw each-event seam (I05 revision) — a reusable "each
|
|
260
|
+
# event" concern: owns the SSE parser, the per-chunk inactivity
|
|
261
|
+
# re-arm (D5 — the first chunk's arm is the TTFB→inactivity
|
|
262
|
+
# transition), and the I61 drop-and-count reset; yields the DONE
|
|
263
|
+
# sentinel in its wire position and every surviving payload
|
|
264
|
+
# parsed. The block is optional — a blockless call drains.
|
|
265
|
+
def each_raw_event(response, idle, watchdog, &block)
|
|
266
|
+
parser = SSE.new
|
|
267
|
+
@dropped_chunks = 0
|
|
268
|
+
response.body.each do |chunk|
|
|
269
|
+
watchdog.arm(idle) # re-arm the inactivity read-timeout per chunk (D5)
|
|
270
|
+
parser.push(chunk).each do |item|
|
|
271
|
+
if item.equal?(SSE::DONE) # the wire's terminal token: yielded in place, no payload
|
|
272
|
+
block&.call(item)
|
|
273
|
+
next
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
json = parse_chunk(item)
|
|
277
|
+
next if json.nil? # dropped (I61): counted, the stream continues
|
|
278
|
+
|
|
279
|
+
block&.call(json)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Cancel→timeout translation (I05 revision): a watchdog fire
|
|
285
|
+
# surfaces the PHASE-named TimeoutError — headers while the POST
|
|
286
|
+
# is outstanding (the response is still nil), read inactivity
|
|
287
|
+
# once the body has begun — while a user's cancel stays
|
|
288
|
+
# CancelledError (D8). The phase needs no flag: headers are
|
|
289
|
+
# outstanding exactly until post_raw_body returns.
|
|
290
|
+
def raise_raw_cancel(watchdog, response, ttfb, idle)
|
|
291
|
+
if watchdog.fired
|
|
292
|
+
message = response.nil? ? "no response headers within #{ttfb}s" : "no data within #{idle}s"
|
|
293
|
+
raise TimeoutError, message
|
|
294
|
+
end
|
|
295
|
+
raise CancelledError, "stream cancelled"
|
|
296
|
+
end
|
|
297
|
+
|
|
159
298
|
def feed_stream(response, accumulator, watchdog, &block)
|
|
160
299
|
parser = SSE.new
|
|
161
300
|
@dropped_chunks = 0
|