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 +4 -4
- data/CHANGELOG.md +86 -0
- data/README.md +17 -0
- data/lib/fast/completions/args_streamer.rb +404 -0
- data/lib/fast/completions/client.rb +148 -9
- data/lib/fast/completions/protocol.rb +20 -5
- data/lib/fast/completions/version.rb +1 -1
- 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: 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,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
|
-
|
|
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
|
|
@@ -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
|
-
@
|
|
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 @
|
|
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
|
-
|
|
454
|
-
|
|
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
|
-
|
|
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]},
|
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.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
|