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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4ad51b1c13ffc9236e40151891d9c935967c8ca06b982d43e9871e628900e888
4
- data.tar.gz: b814642c835590b0399ff287b0ea2631bf2382766f242ba95a9e8857f9d8a8ae
3
+ metadata.gz: 9d3db818f96038ddf53481fea90ce309a3a01be84d924e8d4707ce121a9232ba
4
+ data.tar.gz: a6aa0363ad9ba1c6c2b45e47645137390254d8c1b0c8067e8de7f6cc64ed7e43
5
5
  SHA512:
6
- metadata.gz: 2bfbd4a381f09305f49fdeb578cbde26cf0599de031efa9af638d88dce48b96c38abbabd52e97071633404cdfa7c52237d05b718389465b25595a333a559b549
7
- data.tar.gz: d510754ec3b2da6fb8564d51d1dd6925f7382423fc9312a4618af126c7174679a4641b62f6e537a2c78f4c6e10d68a299ff455c100093a4f9a1857b4ee9cace8
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
- 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,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:, timeout: DEFAULT_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES)
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
- "authorization" => "Bearer #{@api_key}",
139
- "content-type" => "application/json",
140
- "accept" => "application/json",
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Fast
4
4
  module Completions
5
- VERSION = "0.5.0"
5
+ VERSION = "0.6.0"
6
6
  end
7
7
  end
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.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Jacobs