fast-completions 0.4.1 → 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: 826572d669198677861fb62faea347d0e74bf99ca3ba39d2e899a159161e762b
4
- data.tar.gz: 4e5d12637cc2b273596a38d7cdd39b50ee5f2018a3ba0c18e2c4001a88c32e22
3
+ metadata.gz: 9d3db818f96038ddf53481fea90ce309a3a01be84d924e8d4707ce121a9232ba
4
+ data.tar.gz: a6aa0363ad9ba1c6c2b45e47645137390254d8c1b0c8067e8de7f6cc64ed7e43
5
5
  SHA512:
6
- metadata.gz: 7dde6dc44db9e1993071f6f494a743dad1e89990c67c6b2beeef61638e28cc57aff5af2026b66a42209d4eb27ee8c4848eec559593fbcdcbab1938bf5251d8fc
7
- data.tar.gz: '09c28b1d9d980d29abd13a670feba40ec60fb79f3c65246251bc3b1ac042e3ae093728cf3fd2affbadfc09799c97f1f9f4fe59657c535b2376c2eeb0523a024a'
6
+ metadata.gz: 63a9237cef1bbf316295f96bb6bd13107f5efadef2f0434070070edbd1a74a4668c725d6b7690a73cdfa7d02b750df2c4a85f5a60a1f77b96d0c48d4767c6f20
7
+ data.tar.gz: 90c846372d3c94697e85de1075d61c7b40525b9c0ad7d2dcc11f632c6204d002e946e5ffb9b62529da0695ebeab930af24413475366ef7d09204f3f233ffefea
data/CHANGELOG.md CHANGED
@@ -3,6 +3,92 @@
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
+
49
+ ## 0.5.0 (2026-10-17)
50
+
51
+ - Tool-args streaming is now an incremental string-state machine
52
+ (`Fast::Completions::ArgsStreamer`, a new stdlib-only class): instead
53
+ of eagerly re-parsing the whole arguments scratch on every delta
54
+ (`JSON.parse` → `repair_json` → re-parse, O(n²) across a stream), each
55
+ tool-call block owns a streamer whose parser state carries ACROSS
56
+ deltas and only the NEW fragment's bytes are walked per delta — O(n)
57
+ total, no per-delta copy of any accumulated buffer, and no exception
58
+ on the streaming path (the machine is total on any fragment sequence;
59
+ malformed input freezes the live view rather than crashing the step).
60
+ This is a deliberate departure from pi, which re-parses per delta
61
+ (openai-completions.ts :652-658).
62
+ - The live view of a tool-call block's `arguments` Hash is now a
63
+ first-class feature (operator ruling, plan ENTRY 1 — “watching a
64
+ file write is extremely nice”): the same Hash object is populated in
65
+ place for the whole stream, nested containers materialize at their
66
+ opening token, and an in-flight string value GROWS live — a consumer
67
+ holding the reference sees it grow — instead of the whole pair
68
+ vanishing until the closing quote. Escape sequences split by a delta boundary are held (the
69
+ 0.4.0/0.4.1 crash class becomes a structural non-event); invalid
70
+ escape chars and invalidated `\u` runs contribute their literal text
71
+ (`repair_json` parity, absorbed rather than called); surrogate halves
72
+ never enter the live value, pairs combine; scalars materialize as raw
73
+ text and are replaced by Integer/Float/true/false/null when the token
74
+ terminates (malformed scalars keep their raw text). After the root
75
+ object closes — or the stream becomes unparseable — the live view
76
+ freezes. End states are unchanged and byte-identical to 0.4.1:
77
+ finalize remains the sole authority, parsing the full scratch once
78
+ (`parse_streaming_json`: the JSON.parse fast path when the stream
79
+ completed, the repair walk and `{}` as the last resort otherwise).
80
+ Mid-stream, the two protocol_spec pins that encoded the old
81
+ truncation artifact as `eq({})` are amended to the growing view —
82
+ the ONLY spec changes besides the machine's new unit spec.
83
+ - Bench harness: `compare_refs.rb` now measures a fifth ref — 0.5.0,
84
+ the working tree at HEAD (the lane tip before the integrator's
85
+ landing commit) — beside the four releases, pairs 0.4.1→0.5.0, and
86
+ gains the machine's own pair-scoped floor: tool_args at 60 µs/delta
87
+ (0.4.1 measured 877.4 µs/delta on the frozen 24 KB / 320-fragment
88
+ workload; the machine lands in single-digit µs). BASELINE.md is
89
+ regenerated with the 0.5.0 column. The four bench scripts stay
90
+ byte-frozen — the workload the floors are calibrated to.
91
+
6
92
  ## 0.4.1 (2026-10-16)
7
93
 
8
94
  - Fix a mid-stream crash in the tool-call live-parse (regression against
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
@@ -0,0 +1,404 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fast
4
+ module Completions
5
+ # Incremental tool-args string-state machine (I02, 0.5.0). A DELIBERATE
6
+ # departure from pi: pi re-parses the whole arguments scratch on every
7
+ # delta (openai-completions.ts :652-658) — O(n²) across a stream and
8
+ # the pair-in-flight vanishes until its closing quote. This machine
9
+ # instead carries per-block parser state ACROSS deltas and walks only
10
+ # the NEW fragment's bytes each time (O(n) total, no per-delta copy of
11
+ # any accumulated buffer, no exception on the streaming path), growing
12
+ # the live view (`block[:arguments]`, the root_target below) IN PLACE.
13
+ # The authoritative whole-buffer parse still happens exactly once, at
14
+ # finalize (Protocol.parse_streaming_json — the unchanged pi parity).
15
+ #
16
+ # THE LIVE-VIEW CONTRACT (frozen, Specification L1-L9). L8 totality:
17
+ # this source throws no exceptions and installs none — the machine
18
+ # is total on any fragment sequence: malformed input freezes the
19
+ # live view and never crashes the stream. The frozen interface:
20
+ # ArgsStreamer.new(
21
+ # root_target) — the block's arguments Hash, populated in place (L1);
22
+ # #consume(String) -> self — append to the scratch, walk the new bytes;
23
+ # #scratch -> String — the raw bytes accumulated, for the finalize
24
+ # parse. Everything below the interface is this file's own design.
25
+ class ArgsStreamer
26
+ attr_reader :scratch
27
+
28
+ # L3 scalar classification (frozen): a scalar that terminates is
29
+ # REPLACED by Integer/Float/true/false/nil when its raw text is a
30
+ # WELL-FORMED scalar, and keeps its raw-text String when malformed
31
+ # — the frozen clause's own examples are `01` and `1.`, so JSON's
32
+ # grammar (leading zeros, dangling fractions) gates the
33
+ # classification; the Integer/Float shapes below are the frozen
34
+ # forms minus the leading-zero overlap the malformed clause rules
35
+ # out. Integer for -?(0|[1-9]\d*), Float for that mantissa plus a
36
+ # fraction and/or exponent.
37
+ INTEGER_TEXT = /\A-?(?:0|[1-9]\d*)\z/
38
+ FLOAT_TEXT = /\A-?(?:(?:0|[1-9]\d*)\.\d+(?:[eE][+-]?\d+)?|(?:0|[1-9]\d*)(?:\.\d+)?[eE][+-]?\d+)\z/
39
+
40
+ # Fast path: a run of string-content bytes needing no per-byte
41
+ # handling — not a quote, not a backslash, not a raw control char,
42
+ # not a UTF-8 lead/continuation byte. Binary (/n) so it matches the
43
+ # byte view of the fragment.
44
+ PLAIN_RUN = /[^"\\\x00-\x1f\x80-\xff]+/n
45
+
46
+ # Byte constants (structure is always ASCII; content may be any
47
+ # byte — multibyte sequences are assembled, never mistaken for it).
48
+ QUOTE = 0x22
49
+ BACKSLASH = 0x5C
50
+ COLON = 0x3A
51
+ COMMA = 0x2C
52
+ OPEN_BRACE = 0x7B
53
+ CLOSE_BRACE = 0x7D
54
+ OPEN_BRACKET = 0x5B
55
+ CLOSE_BRACKET = 0x5D
56
+
57
+ def initialize(root_target)
58
+ @root = root_target
59
+ @scratch = +""
60
+ # Machine state (:value/:after_value refer to @stack.last; the
61
+ # frame layout is [container, :hash|:array]):
62
+ # :scan_root — before the root token (whitespace skipped)
63
+ # :frozen — root closed or structurally unparseable (L7);
64
+ # later bytes never mutate the live view again
65
+ # :key_first — object after `{`: first key or `}`
66
+ # :key — object after a comma: a key is required
67
+ # :colon — between a completed key and its value
68
+ # :value — a value token may start (or `]` closing an
69
+ # EMPTY array, per @may_close)
70
+ # :after_value— expecting `,` or the container's closer
71
+ # :in_key / :in_string — accumulating a key / a string value
72
+ # :in_scalar — accumulating raw scalar text
73
+ # :escape / :u_escape — mid-escape (held across deltas, L5/L6)
74
+ @state = :scan_root
75
+ @stack = []
76
+ @key = nil # completed key text for the pending pair
77
+ @buf = nil # the String being grown (a live value or a key)
78
+ @str_back = nil # :in_key or :in_string — where an escape returns
79
+ @cell = nil # [container, key_or_index] — the in-flight scalar slot
80
+ @scalar = nil # raw scalar text (replaced on termination, L3)
81
+ @digits = nil # collected hex digits of a \u run
82
+ @pending_high = nil # held high surrogate (L6)
83
+ @utf8_bytes = nil # partial multibyte sequence being assembled
84
+ @utf8_len = 0
85
+ @may_close = false # :value may see `]` (freshly opened array)
86
+ end
87
+
88
+ # Append the fragment to the scratch and walk ONLY its bytes. An
89
+ # empty fragment is a no-op (L8); a frozen machine still accumulates
90
+ # the scratch — finalize, not this walk, is the authority (L9).
91
+ def consume(fragment)
92
+ return self unless fragment.is_a?(String)
93
+
94
+ # Scratch discipline (L8 totality — no exception ever leaves this
95
+ # file): the scratch stays UTF-8 so the finalize parse gets a
96
+ # JSON-parseable string. A same-encoding or ASCII-only fragment
97
+ # appends with no copy; anything else (a binary fragment cut
98
+ # mid-multibyte, odd encodings) is normalized byte-for-byte to
99
+ # UTF-8 first — invalid bytes stay invalid and finalize's repair
100
+ # path owns them. `bytes` is the walk view: binary for the
101
+ # byte-level regex, the fragment itself when it is ASCII-only
102
+ # (the regex accepts an ASCII-only string in any encoding).
103
+ bytes = fragment.ascii_only? ? fragment : fragment.b
104
+ if fragment.encoding == Encoding::UTF_8 || fragment.ascii_only?
105
+ @scratch << fragment
106
+ else
107
+ @scratch << bytes.dup.force_encoding(Encoding::UTF_8)
108
+ end
109
+ return self if @state == :frozen
110
+
111
+ frag = bytes
112
+ i = 0
113
+ length = frag.bytesize
114
+ while i < length
115
+ byte = frag.getbyte(i)
116
+ if ((@state == :in_string || @state == :in_key) && @utf8_bytes.nil?) &&
117
+ byte != QUOTE && byte != BACKSLASH && byte >= 0x20 && byte < 0x80
118
+ # Fast path: a plain run — one in-place append, one regex scan
119
+ # of the NEW bytes only. A held high surrogate drops here: its
120
+ # pair never lands (L6).
121
+ @pending_high = nil
122
+ run = PLAIN_RUN.match(frag, i)[0]
123
+ @buf << run
124
+ i += run.bytesize
125
+ next
126
+ end
127
+ i += 1
128
+ case @state
129
+ when :in_string, :in_key
130
+ consume_string_byte(byte) || (i -= 1) # false = reprocess the byte
131
+ when :escape
132
+ consume_escape_byte(byte) || (i -= 1)
133
+ when :u_escape
134
+ consume_unicode_byte(byte) || (i -= 1)
135
+ when :scan_root
136
+ case byte
137
+ when 0x09, 0x0A, 0x0D, 0x20 then nil # whitespace skipped
138
+ when OPEN_BRACE
139
+ @stack.push([@root, :hash]) # the live Hash populates (L7)
140
+ @state = :key_first
141
+ else # array, scalar, garbage before `{` — empty and frozen
142
+ @state = :frozen
143
+ end
144
+ when :key_first, :key
145
+ case byte
146
+ when 0x09, 0x0A, 0x0D, 0x20 then nil
147
+ when QUOTE
148
+ @buf = +""
149
+ @pending_high = nil
150
+ @utf8_bytes = nil
151
+ @state = :in_key
152
+ when CLOSE_BRACE
153
+ if @state == :key_first
154
+ close_container # `{}` — the empty object closes
155
+ else # after a comma a key is required: unparseable
156
+ @state = :frozen
157
+ end
158
+ else
159
+ @state = :frozen
160
+ end
161
+ when :colon
162
+ case byte
163
+ when 0x09, 0x0A, 0x0D, 0x20 then nil
164
+ when COLON
165
+ @may_close = false
166
+ @state = :value
167
+ else
168
+ @state = :frozen
169
+ end
170
+ when :value
171
+ case byte
172
+ when 0x09, 0x0A, 0x0D, 0x20 then nil
173
+ when QUOTE then open_string_value
174
+ when OPEN_BRACE then open_container({}, :hash)
175
+ when OPEN_BRACKET then open_container([], :array)
176
+ when CLOSE_BRACKET
177
+ if @may_close # `[]`; any other `]` here is unparseable
178
+ close_container
179
+ else
180
+ @state = :frozen
181
+ end
182
+ when CLOSE_BRACE, COMMA then @state = :frozen # no value can start with these
183
+ else
184
+ open_scalar(byte)
185
+ end
186
+ when :after_value
187
+ case byte
188
+ when 0x09, 0x0A, 0x0D, 0x20 then nil
189
+ when COMMA
190
+ if @stack.last[1] == :hash
191
+ @state = :key
192
+ else
193
+ @may_close = false
194
+ @state = :value
195
+ end
196
+ when CLOSE_BRACE
197
+ @stack.last[1] == :hash ? close_container : @state = :frozen
198
+ when CLOSE_BRACKET
199
+ @stack.last[1] == :array ? close_container : @state = :frozen
200
+ else
201
+ @state = :frozen
202
+ end
203
+ when :in_scalar
204
+ case byte
205
+ when COMMA, CLOSE_BRACE, CLOSE_BRACKET, 0x09, 0x0A, 0x0D, 0x20
206
+ # Token terminated (delimiter, closer, or whitespace): the
207
+ # raw-text String is REPLACED in its slot by the classified
208
+ # value (L3); the terminator itself is reprocessed below.
209
+ @cell[0][@cell[1]] = scalar_value(@scalar)
210
+ @state = :after_value
211
+ i -= 1
212
+ else
213
+ @scalar << byte
214
+ end
215
+ end
216
+ end
217
+ self
218
+ end
219
+
220
+ private
221
+
222
+ # One byte inside a key or a string value. Returns false when the
223
+ # byte must be reprocessed (a broken multibyte sequence is dropped
224
+ # and the offending byte starts over).
225
+ def consume_string_byte(byte)
226
+ if @utf8_bytes
227
+ if byte >= 0x80 && byte <= 0xBF
228
+ @utf8_bytes << byte
229
+ if @utf8_bytes.size == @utf8_len
230
+ sequence = @utf8_bytes.pack("C*").force_encoding(Encoding::UTF_8)
231
+ @buf << sequence if sequence.valid_encoding?
232
+ @utf8_bytes = nil
233
+ end
234
+ else # the sequence never completes: dropped, byte reprocessed
235
+ @utf8_bytes = nil
236
+ return false
237
+ end
238
+ elsif byte >= 0x80
239
+ @pending_high = nil # a held high's pair never lands (L6)
240
+ if byte >= 0xC2 && byte <= 0xF4 # a valid lead byte; anything else is dropped
241
+ @utf8_len = byte < 0xE0 ? 2 : (byte < 0xF0 ? 3 : 4)
242
+ @utf8_bytes = [byte]
243
+ end
244
+ elsif byte == QUOTE # the string closes
245
+ @pending_high = nil # a held high's pair never lands (L6)
246
+ if @state == :in_key
247
+ @key = @buf
248
+ @state = :colon
249
+ else
250
+ @state = :after_value
251
+ end
252
+ elsif byte == BACKSLASH
253
+ @str_back = @state
254
+ @state = :escape
255
+ else # plain ASCII content, raw control characters included (L5)
256
+ @pending_high = nil
257
+ @buf << byte
258
+ end
259
+ true
260
+ end
261
+
262
+ # The byte after a backslash. Returns false when the byte must be
263
+ # reprocessed (an invalid escape char contributes the literal
264
+ # backslash, then the char itself is handled as content, L5).
265
+ def consume_escape_byte(byte)
266
+ case byte
267
+ when 0x75 # u — the hex run starts; a held high may still pair (L6)
268
+ @digits = +""
269
+ @state = :u_escape
270
+ when QUOTE then settle_escape("\"")
271
+ when BACKSLASH then settle_escape("\\")
272
+ when 0x2F then settle_escape("/") # \/ — repair_json parity
273
+ when 0x62 then settle_escape("\b")
274
+ when 0x66 then settle_escape("\f")
275
+ when 0x6E then settle_escape("\n")
276
+ when 0x72 then settle_escape("\r")
277
+ when 0x74 then settle_escape("\t")
278
+ else
279
+ @pending_high = nil
280
+ @buf << "\\" # invalid escape char: literal `\`, char reprocessed
281
+ @state = @str_back
282
+ return false
283
+ end
284
+ true
285
+ end
286
+
287
+ def settle_escape(text)
288
+ @pending_high = nil # a held high's pair never lands (L6)
289
+ @buf << text
290
+ @state = @str_back
291
+ end
292
+
293
+ # One byte of a \u run. Returns false when the byte must be
294
+ # reprocessed (an invalidated run contributes literal `\u` + the
295
+ # digits collected, then the offending byte is handled as content,
296
+ # L5 — a short run invalidated by the string's closing quote is the
297
+ # "short at close" case).
298
+ def consume_unicode_byte(byte)
299
+ if hex_byte?(byte)
300
+ @digits << byte
301
+ if @digits.size == 4
302
+ codepoint = @digits.to_i(16)
303
+ if codepoint >= 0xD800 && codepoint <= 0xDBFF
304
+ @pending_high = codepoint # held for a possible pair (L6)
305
+ elsif codepoint >= 0xDC00 && codepoint <= 0xDFFF
306
+ if @pending_high # the pair combines into one codepoint (L6)
307
+ pair = 0x10000 + ((@pending_high & 0x3FF) << 10) + (codepoint & 0x3FF)
308
+ @buf << pair
309
+ @pending_high = nil
310
+ end # a lone low half never enters the live value (L6)
311
+ else
312
+ @pending_high = nil
313
+ @buf << codepoint
314
+ end
315
+ @state = @str_back
316
+ end
317
+ else
318
+ @pending_high = nil
319
+ @buf << "\\u"
320
+ @buf << @digits
321
+ @state = @str_back
322
+ return false
323
+ end
324
+ true
325
+ end
326
+
327
+ def hex_byte?(byte)
328
+ (byte >= 0x30 && byte <= 0x39) || (byte >= 0x41 && byte <= 0x46) ||
329
+ (byte >= 0x61 && byte <= 0x66)
330
+ end
331
+
332
+ # A string value materializes at its OPENING quote — the slot exists
333
+ # with "" before any content byte (L3) — and grows by in-place
334
+ # append for the rest of the stream (L1).
335
+ def open_string_value
336
+ value = +""
337
+ attach(value)
338
+ @buf = value
339
+ @pending_high = nil
340
+ @utf8_bytes = nil
341
+ @state = :in_string
342
+ end
343
+
344
+ # An object/array value materializes at its opening token and is
345
+ # inserted into its parent immediately (L3), then mutated in place
346
+ # (L1). A fresh array may still be closed empty while @may_close.
347
+ def open_container(container, kind)
348
+ attach(container)
349
+ @stack.push([container, kind])
350
+ if kind == :hash
351
+ @state = :key_first
352
+ else
353
+ @may_close = true
354
+ @state = :value
355
+ end
356
+ end
357
+
358
+ # A scalar materializes as its raw-text String at its FIRST byte
359
+ # (L3), inserted into its parent; the slot is replaced when the
360
+ # token terminates (see :in_scalar above).
361
+ def open_scalar(byte)
362
+ @scalar = +""
363
+ @scalar << byte
364
+ attach(@scalar)
365
+ @state = :in_scalar
366
+ end
367
+
368
+ # Insert a fresh value into the innermost container. Duplicate keys
369
+ # collapse to one slot, last value wins (L2); array elements append.
370
+ # The slot pair also serves scalar replacement — `container[key]=`
371
+ # and `container[index]=` are the same expression for both kinds.
372
+ def attach(value)
373
+ frame = @stack.last
374
+ if frame[1] == :hash
375
+ frame[0][@key] = value
376
+ @cell = [frame[0], @key]
377
+ else
378
+ @cell = [frame[0], frame[0].size]
379
+ frame[0] << value
380
+ end
381
+ end
382
+
383
+ # Close the innermost container. The root object closing (or the
384
+ # last frame popping empty) freezes the live view (L7).
385
+ def close_container
386
+ @stack.pop
387
+ @state = @stack.empty? ? :frozen : :after_value
388
+ end
389
+
390
+ def scalar_value(text)
391
+ case text
392
+ when "true" then true
393
+ when "false" then false
394
+ when "null" then nil
395
+ else
396
+ if text.match?(INTEGER_TEXT) then text.to_i
397
+ elsif text.match?(FLOAT_TEXT) then text.to_f
398
+ else text # malformed (`01`, `1.` at close): keeps its raw text
399
+ end
400
+ end
401
+ end
402
+ end
403
+ end
404
+ end
@@ -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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "args_streamer"
4
+
3
5
  module Fast
4
6
  module Completions
5
7
  # THE BYTE CONTRACT (Specification §9): Ruby Hash insertion order +
@@ -311,7 +313,7 @@ module Fast
311
313
  @thinking_block = nil
312
314
  @tool_by_index = {}
313
315
  @tool_by_id = {}
314
- @args_scratch = {}.compare_by_identity
316
+ @args_streamers = {}.compare_by_identity # per-block ArgsStreamer (I02); each owns its scratch
315
317
  @streamed_details = nil # :334-341
316
318
  @has_finish_reason = false
317
319
  @stop_reason = nil
@@ -440,7 +442,7 @@ module Fast
440
442
  # its block — by index, by id, or by creation — while CARRYING an
441
443
  # index the block has never bound, binds it here. FIRST index
442
444
  # wins; one index per block, ever. Boundness is tracked
443
- # accumulator-side (identity-keyed, like @args_scratch): the
445
+ # accumulator-side (identity-keyed, like @args_streamers): the
444
446
  # blocks ARE message content — no scratch keys on them.
445
447
  @tool_index_bound ||= {}.compare_by_identity
446
448
  if index && !@tool_index_bound.key?(block)
@@ -450,8 +452,15 @@ module Fast
450
452
 
451
453
  raw = entry.dig("function", "arguments")
452
454
  raw = "" unless raw.is_a?(String)
453
- @args_scratch[block] = (@args_scratch[block] || "") + raw
454
- block[:arguments] = Protocol.parse_streaming_json(@args_scratch[block]) # best-effort live parse (OQ-4)
455
+ # I02 (0.5.0): the incremental args machine. The old seam — a
456
+ # full-buffer copy plus an eager whole-scratch re-parse per delta
457
+ # — is gone. This is a DELIBERATE departure from pi, which
458
+ # re-parses per delta (openai-completions.ts :652-658): the
459
+ # streamer carries parser state across deltas, walks ONLY the
460
+ # new fragment's bytes, and grows the live view in place (the
461
+ # block's arguments Hash); the one authoritative whole-buffer
462
+ # parse still happens at finalize (finalize_blocks below).
463
+ (@args_streamers[block] ||= ArgsStreamer.new(block[:arguments])).consume(raw)
455
464
  events << Event.new(type: :tool_call_delta, content_index: content_index(block),
456
465
  delta: raw, partial: @partial)
457
466
  events
@@ -469,7 +478,13 @@ module Fast
469
478
  block[:signature] = JSON.generate(@streamed_details) if @streamed_details
470
479
  events << Event.new(type: :thinking_end, content_index: index, content: block[:thinking], partial: @partial)
471
480
  when :tool_call
472
- raw = @args_scratch.delete(block) || ""
481
+ streamer = @args_streamers.delete(block)
482
+ raw = streamer ? streamer.scratch : "" # no-fragment blocks finalize an empty scratch
483
+ # Finalize is the SOLE authority (L9): one whole-buffer parse
484
+ # of the raw bytes — the JSON.parse fast path when the stream
485
+ # completed, the repair walk + {} last resort otherwise
486
+ # (pi :453 / utils/json-parse.ts :20-90). The live Hash the
487
+ # machine grew is replaced here, exactly once.
473
488
  block[:arguments] = Protocol.parse_streaming_json(raw)
474
489
  events << Event.new(type: :tool_call_end, content_index: index,
475
490
  tool_call: {id: block[:id], name: block[:name], arguments: block[:arguments]},
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Fast
4
4
  module Completions
5
- VERSION = "0.4.1"
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.4.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Jacobs
@@ -34,6 +34,7 @@ files:
34
34
  - CHANGELOG.md
35
35
  - README.md
36
36
  - lib/fast/completions.rb
37
+ - lib/fast/completions/args_streamer.rb
37
38
  - lib/fast/completions/cancellation.rb
38
39
  - lib/fast/completions/chat.rb
39
40
  - lib/fast/completions/client.rb