fast-completions 0.2.0 → 0.3.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 +54 -0
- data/lib/fast/completions/client.rb +135 -20
- data/lib/fast/completions/codec.rb +49 -13
- data/lib/fast/completions/errors.rb +17 -3
- data/lib/fast/completions/protocol.rb +11 -1
- data/lib/fast/completions/types.rb +13 -4
- 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: e2cecdfed6a68aef560e112239c184f36f20d906b7122ae1311cbeb92b726b4d
|
|
4
|
+
data.tar.gz: cb1ecd28ceecb63144bc7ad9b9328ffd9624033c26d793c691496d67ea909551
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 12dcd5ced8e6c2ecee077178c138425ccae68061e6e0b42c6ec6540f952a3a93d3862ca319f761cfdebef89c04fc21024c09883f072a9949a0dc1a4ccace96d0
|
|
7
|
+
data.tar.gz: 934df8a658af5d27c9da6aca37ab8e0d999f5ff633149227c646888af30c8b63e6629452b227aab5d34e74aa5fd128bd283bab405bba0993d3e0fed0f06cc881
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,60 @@
|
|
|
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.3.0 (2026-10-15)
|
|
7
|
+
|
|
8
|
+
- Mid-stream salvage: a failure after a 2xx status — a mid-body connection
|
|
9
|
+
abort, the network-error family, or any other exception except
|
|
10
|
+
cancellation — no longer discards the streamed work. The client yields the
|
|
11
|
+
`:error` terminal event FIRST, carrying the live partial (every streamed
|
|
12
|
+
block and the usage so far; a thinking block's streamed
|
|
13
|
+
`reasoning_details` are serialized into its signature exactly as finalize
|
|
14
|
+
would) with no block-end events after the failure, then raises
|
|
15
|
+
(`ConnectionError` for the network family, message form preserved;
|
|
16
|
+
anything else re-raised unchanged). Creation-phase failures (non-2xx
|
|
17
|
+
after the retry decision, network failures with retries exhausted) yield
|
|
18
|
+
the same `:error` event — the accumulator still empty, `:start` still not
|
|
19
|
+
emitted — before their mapped raise.
|
|
20
|
+
- Request-creation retry: the POST — creation through status receipt,
|
|
21
|
+
structurally pre-first-body-byte — now retries bounded at ONE retry by
|
|
22
|
+
default (constructor `max_retries:`, integer ≥ 0; `0` restores the single
|
|
23
|
+
attempt), exactly on pi's provider-retry trigger classes: header
|
|
24
|
+
`x-should-retry: true` (any status) retries; `x-should-retry: false` is
|
|
25
|
+
final even on 429/5xx; statusless errors (network failures during the
|
|
26
|
+
POST, a TTFB watchdog fire) retry; 408/409/429/5xx retry; every other
|
|
27
|
+
status is final. Never after a 2xx — a mid-body failure salvages instead,
|
|
28
|
+
making exactly one request. Cancellation is never retried; retries are
|
|
29
|
+
immediate (backoff out of scope); a retried TTFB deadline re-arms per
|
|
30
|
+
attempt and the final failure still surfaces `TimeoutError`, never
|
|
31
|
+
`CancelledError`.
|
|
32
|
+
- Outgoing-string scrub (I62): every string the codec emits onto the wire
|
|
33
|
+
params passes through a `String#scrub { "" }` deletion scrub (pi
|
|
34
|
+
`sanitizeSurrogates`) at each emission point — the leading instructions
|
|
35
|
+
and system content, user strings and text parts, the assistant's joined
|
|
36
|
+
text and reasoning-field text, tool-result content, and the string
|
|
37
|
+
values inside tool-call `arguments` (deep). Malformed bytes (lone
|
|
38
|
+
surrogates, CESU-8 pairs, any invalid UTF-8 — Ruby's class is broader
|
|
39
|
+
than JS's) are DELETED, never replaced with U+FFFD, so
|
|
40
|
+
`JSON.generate` over a built request no longer raises
|
|
41
|
+
`GeneratorError` from message content. Valid strings pass
|
|
42
|
+
byte-identical — the canonical wire fixtures are untouched.
|
|
43
|
+
- `Message#stop_reason` (I62): nil-default replay metadata with no
|
|
44
|
+
vocabulary validation; `to_h` emits the key only when set, `from_h`
|
|
45
|
+
round-trips it, and the assistant factory accepts it.
|
|
46
|
+
`Codec.normalize` skips assistants whose `stop_reason` is `:error` or
|
|
47
|
+
`:aborted` entirely on replay (pi transform-messages): their tool calls
|
|
48
|
+
never become pending orphans and never synthesize results, while a
|
|
49
|
+
previous valid assistant's unanswered calls still do and surrounding
|
|
50
|
+
user/system messages are unaffected. Histories without errored rows
|
|
51
|
+
are byte-unaffected.
|
|
52
|
+
- Tool-call index-map backfill (I62): a tool-call fragment that resolves
|
|
53
|
+
its block by id (or creation) while carrying an `index` now binds the
|
|
54
|
+
block into the accumulator's index map (pi openai-completions :536-539)
|
|
55
|
+
— first index wins, one index per block, ever — so the
|
|
56
|
+
`{id, name}` → `{id, index, args}` → `{index, args}` fragment shape
|
|
57
|
+
assembles ONE tool call instead of a spurious second block. Boundness
|
|
58
|
+
is tracked accumulator-side; no scratch keys leak onto message blocks.
|
|
59
|
+
|
|
6
60
|
## 0.2.0 (2026-10-15)
|
|
7
61
|
|
|
8
62
|
- Tolerant inbound stream (I61): a malformed SSE chunk no longer kills
|
|
@@ -11,6 +11,10 @@ module Fast
|
|
|
11
11
|
# its own (§6).
|
|
12
12
|
class Client
|
|
13
13
|
DEFAULT_TIMEOUT = 30
|
|
14
|
+
# Bounded creation retry (I62, Grounds R2): ONE retry (two attempts)
|
|
15
|
+
# by default so a transient 429/5xx/connection refusal no longer
|
|
16
|
+
# kills the turn; `max_retries: 0` restores the single attempt.
|
|
17
|
+
DEFAULT_MAX_RETRIES = 1
|
|
14
18
|
|
|
15
19
|
# TTFB deadline + inactivity read-timeout watchdog (D5/OQ-1): one
|
|
16
20
|
# value serves both. A fire cancels the waiting task; `fired` tells
|
|
@@ -26,9 +30,13 @@ module Fast
|
|
|
26
30
|
attr_reader :fired
|
|
27
31
|
|
|
28
32
|
# (Re)arm: TTFB deadline before the POST, re-armed as the
|
|
29
|
-
# inactivity read-timeout after every body chunk.
|
|
33
|
+
# inactivity read-timeout after every body chunk. The fired flag
|
|
34
|
+
# resets with the timer: a retried attempt (I62) must not inherit
|
|
35
|
+
# the previous attempt's fire, or the post-stream guard and the
|
|
36
|
+
# cancel-vs-fired discipline would misread attempt 2 (D5/D8).
|
|
30
37
|
def arm
|
|
31
38
|
stop
|
|
39
|
+
@fired = false
|
|
32
40
|
@timer = Async do
|
|
33
41
|
sleep @seconds
|
|
34
42
|
@fired = true
|
|
@@ -47,12 +55,15 @@ module Fast
|
|
|
47
55
|
Errno::ENETUNREACH, Errno::ETIMEDOUT, EOFError, IOError, SocketError,
|
|
48
56
|
].freeze
|
|
49
57
|
|
|
50
|
-
attr_reader :timeout, :dropped_chunks
|
|
58
|
+
attr_reader :timeout, :max_retries, :dropped_chunks
|
|
59
|
+
|
|
60
|
+
def initialize(base_url:, api_key:, timeout: DEFAULT_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES)
|
|
61
|
+
raise ArgumentError, "max_retries must be an Integer >= 0" unless max_retries.is_a?(Integer) && max_retries >= 0
|
|
51
62
|
|
|
52
|
-
def initialize(base_url:, api_key:, timeout: DEFAULT_TIMEOUT)
|
|
53
63
|
@endpoint = Async::HTTP::Endpoint.parse(base_url)
|
|
54
64
|
@api_key = api_key
|
|
55
65
|
@timeout = timeout
|
|
66
|
+
@max_retries = max_retries
|
|
56
67
|
@client = Async::HTTP::Client.new(@endpoint)
|
|
57
68
|
@path = @endpoint.path.chomp("/") + "/chat/completions"
|
|
58
69
|
end
|
|
@@ -65,6 +76,17 @@ module Fast
|
|
|
65
76
|
# POST {base}/chat/completions, stream the SSE reply, yield every
|
|
66
77
|
# Event to the block, return the final assistant Message (the
|
|
67
78
|
# accumulator itself — §3). Runs inside the caller's task.
|
|
79
|
+
#
|
|
80
|
+
# Failure discipline (I62): EVERY failure raises AFTER the one
|
|
81
|
+
# `:error` terminal event — a streaming-phase failure carries the
|
|
82
|
+
# salvaged partial (pi :707-728 catch-all), a creation-phase
|
|
83
|
+
# failure an empty accumulator (:start is never emitted for a
|
|
84
|
+
# failed creation) — except cancellation, which keeps its
|
|
85
|
+
# no-terminal-event discipline (D8), and the watchdog's read
|
|
86
|
+
# inactivity, reached as Async::Cancel through the same untouched
|
|
87
|
+
# ladder. The rescue order is load-bearing: Async::Cancel first
|
|
88
|
+
# (cancel never salvages, never retries), then the network family,
|
|
89
|
+
# then the StandardError catch-all.
|
|
68
90
|
def stream(model:, messages:, instructions: nil, tools: [], thinking: nil,
|
|
69
91
|
max_tokens: nil, temperature: nil, session_id: nil, &block)
|
|
70
92
|
request = Protocol.build_request(
|
|
@@ -73,31 +95,35 @@ module Fast
|
|
|
73
95
|
temperature: temperature,
|
|
74
96
|
)
|
|
75
97
|
headers = default_headers.merge(session_headers(model.dialect, session_id))
|
|
98
|
+
body = JSON.generate(request)
|
|
76
99
|
accumulator = Protocol::StreamAccumulator.new(model, model.dialect)
|
|
77
100
|
|
|
78
101
|
watchdog = Watchdog.new(Async::Task.current, @timeout)
|
|
102
|
+
response = nil
|
|
79
103
|
begin
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
ensure
|
|
87
|
-
# Complete bodies return the connection to the pool; an aborted
|
|
88
|
-
# read (cancel/timeout) discards it — a cancelled connection is
|
|
89
|
-
# never reused (AC3/D8).
|
|
90
|
-
begin
|
|
91
|
-
response.finish
|
|
92
|
-
rescue StandardError, Async::Cancel
|
|
93
|
-
nil
|
|
94
|
-
end
|
|
95
|
-
end
|
|
104
|
+
# The POST attempt — creation through status receipt (§2),
|
|
105
|
+
# bounded-retried on pi's trigger set; every final creation
|
|
106
|
+
# failure raises its mapped error from in there.
|
|
107
|
+
response = create_response(headers, body, watchdog)
|
|
108
|
+
yield accumulator.start_event if block # :start precedes every block event (§10.6)
|
|
109
|
+
feed_stream(response, accumulator, watchdog, &block)
|
|
96
110
|
rescue Async::Cancel
|
|
97
111
|
watchdog.fired ? raise(TimeoutError, "no data within #{@timeout}s") : raise(CancelledError, "stream cancelled")
|
|
98
112
|
rescue *NETWORK_ERRORS => e
|
|
113
|
+
salvage(accumulator, &block) # the :error terminal carries the partial BEFORE the raise (§1)
|
|
99
114
|
raise ConnectionError, "#{e.class}: #{e.message}"
|
|
115
|
+
rescue StandardError
|
|
116
|
+
salvage(accumulator, &block) # pi's catch-all: any other streaming failure — same terminal, raised unchanged
|
|
117
|
+
raise
|
|
100
118
|
ensure
|
|
119
|
+
# Complete bodies return the connection to the pool; an aborted
|
|
120
|
+
# read (cancel/timeout) discards it — a cancelled connection is
|
|
121
|
+
# never reused (AC3/D8).
|
|
122
|
+
begin
|
|
123
|
+
response.finish
|
|
124
|
+
rescue StandardError, Async::Cancel
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
101
127
|
watchdog.stop
|
|
102
128
|
end
|
|
103
129
|
raise TimeoutError, "no data within #{@timeout}s" if watchdog.fired
|
|
@@ -183,7 +209,96 @@ module Fast
|
|
|
183
209
|
accumulator.partial
|
|
184
210
|
end
|
|
185
211
|
|
|
186
|
-
#
|
|
212
|
+
# The bounded creation retry (I62 §2, pi retryProviderRequest around
|
|
213
|
+
# chat.completions.create — the attempt is creation through status
|
|
214
|
+
# receipt, structurally pre-first-body-byte, Grounds R4). At most
|
|
215
|
+
# @max_retries immediate retries, exactly on pi's trigger set
|
|
216
|
+
# (provider-retry.ts :25-38): header x-should-retry true (any
|
|
217
|
+
# status) / false (FINAL, even on a retryable status), statusless
|
|
218
|
+
# errors (NETWORK_ERRORS at the POST, a TTFB watchdog fire),
|
|
219
|
+
# statuses 408/409/429/5xx. NEVER after a 2xx — the attempt is
|
|
220
|
+
# committed to streaming and any later failure is §1 salvage, never
|
|
221
|
+
# a retry. Cancellation is not a failure and is never retried. Each
|
|
222
|
+
# final failure raises its mapped error; stream's ladder adds the
|
|
223
|
+
# :error terminal before it (the accumulator is still empty — no
|
|
224
|
+
# :start was emitted).
|
|
225
|
+
def create_response(headers, body, watchdog)
|
|
226
|
+
remaining = @max_retries
|
|
227
|
+
loop do
|
|
228
|
+
watchdog.arm # re-armed per attempt: a retried TTFB deadline restarts (D5)
|
|
229
|
+
begin
|
|
230
|
+
response = @client.post(@path, headers: headers, body: body)
|
|
231
|
+
rescue Async::Cancel
|
|
232
|
+
raise unless watchdog.fired # the user's cancel is not a failure — never retried (D8)
|
|
233
|
+
raise TimeoutError, "no data within #{@timeout}s" if remaining.zero?
|
|
234
|
+
remaining -= 1
|
|
235
|
+
next
|
|
236
|
+
rescue *NETWORK_ERRORS => e
|
|
237
|
+
# Statusless — no response was received (pi's statusless trigger).
|
|
238
|
+
raise ConnectionError, "#{e.class}: #{e.message}" if remaining.zero?
|
|
239
|
+
remaining -= 1
|
|
240
|
+
next
|
|
241
|
+
end
|
|
242
|
+
return response if (200...300).cover?(response.status)
|
|
243
|
+
|
|
244
|
+
if retry_response?(response) && remaining.positive?
|
|
245
|
+
finish_quietly(response) # the discarded attempt's connection returns to the pool
|
|
246
|
+
remaining -= 1
|
|
247
|
+
next
|
|
248
|
+
end
|
|
249
|
+
raise_api_error(response) # final non-2xx: the §8 mapping, reached only after the retry decision
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# pi provider-retry.ts :26-34, exact: the header beats the status
|
|
254
|
+
# both ways (strict "true"/"false"); then 408/409/429/5xx; every
|
|
255
|
+
# other status is final.
|
|
256
|
+
def retry_response?(response)
|
|
257
|
+
flag = retry_header(response.headers)
|
|
258
|
+
return true if flag == "true"
|
|
259
|
+
return false if flag == "false"
|
|
260
|
+
|
|
261
|
+
[408, 409, 429].include?(response.status) || (500..599).cover?(response.status)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Header NAMES arrive in wire case (HTTP/1); Protocol::HTTP::Headers
|
|
265
|
+
# collects repeated fields into arrays — scan case-insensitively and
|
|
266
|
+
# unwrap, pi headers.get("x-should-retry") shape.
|
|
267
|
+
def retry_header(headers)
|
|
268
|
+
headers.each do |key, value|
|
|
269
|
+
return Array(value).first if key.to_s.downcase == "x-should-retry"
|
|
270
|
+
end
|
|
271
|
+
nil
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# The salvage arm (pi openai-completions.ts :707-728 catch-all): a
|
|
275
|
+
# streaming-phase failure yields the ONE error-terminal event FIRST
|
|
276
|
+
# — the live partial (every streamed block + usage so far) — with NO
|
|
277
|
+
# block-end events after the failure; the rescue ladder raises
|
|
278
|
+
# after. pi's catch applies the same scratch finalization to the
|
|
279
|
+
# partial (applyStreamedReasoningDetails): `finish` runs
|
|
280
|
+
# finalize_blocks — the thinking reasoning_details serialized into
|
|
281
|
+
# the signature, tool arguments finalized — whose end events we
|
|
282
|
+
# discard here, so a salvaged partial replays like a completed one's.
|
|
283
|
+
def salvage(accumulator)
|
|
284
|
+
return unless block_given?
|
|
285
|
+
|
|
286
|
+
accumulator.finish
|
|
287
|
+
yield Event.new(type: :error, reason: :error, message: accumulator.partial)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Complete bodies return the connection to the pool; an aborted read
|
|
291
|
+
# (cancel/timeout) discards it — a cancelled connection is never
|
|
292
|
+
# reused (AC3/D8). Same discipline as stream's ensure.
|
|
293
|
+
def finish_quietly(response)
|
|
294
|
+
response.finish
|
|
295
|
+
rescue StandardError, Async::Cancel
|
|
296
|
+
nil
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Non-2xx: read the body, map per §8. Now reached only after the
|
|
300
|
+
# retry decision (I62) — stream's ladder yields the :error terminal
|
|
301
|
+
# before this raise.
|
|
187
302
|
def raise_api_error(response)
|
|
188
303
|
body = response.body.read
|
|
189
304
|
raise Errors.class_for_status(response.status, body).new(status: response.status, body: body)
|
|
@@ -20,7 +20,9 @@ module Fast
|
|
|
20
20
|
# ── Pass 1: transform-messages.ts ──────────────────────────────
|
|
21
21
|
|
|
22
22
|
def normalize(messages, model)
|
|
23
|
-
|
|
23
|
+
# filter_map: the transform pass drops errored/aborted assistants
|
|
24
|
+
# ENTIRELY (I62, pi :196-206) so pass 2 never sees their tool calls.
|
|
25
|
+
transformed = messages.filter_map { |msg| transform_message(msg, model) }
|
|
24
26
|
insert_orphan_synthetic_results(transformed)
|
|
25
27
|
end
|
|
26
28
|
|
|
@@ -30,6 +32,12 @@ module Fast
|
|
|
30
32
|
# (Specification §12.3 note; pi :97-99).
|
|
31
33
|
def transform_message(msg, model)
|
|
32
34
|
return msg unless msg.assistant?
|
|
35
|
+
# Errored/aborted turns skip replay entirely (pi :196-206): dropped
|
|
36
|
+
# in THIS pass, so their tool calls never become pending orphans in
|
|
37
|
+
# pass 2 and never synthesize results — while a PREVIOUS valid
|
|
38
|
+
# assistant's unanswered calls still flush there, and user/system
|
|
39
|
+
# messages around the skip are untouched.
|
|
40
|
+
return nil if msg.stop_reason == :error || msg.stop_reason == :aborted
|
|
33
41
|
|
|
34
42
|
same_model = msg.provider == model.provider && msg.model == model.id
|
|
35
43
|
content = msg.content.flat_map do |block|
|
|
@@ -48,7 +56,7 @@ module Fast
|
|
|
48
56
|
end
|
|
49
57
|
end
|
|
50
58
|
Message.new(role: :assistant, content: content, provider: msg.provider,
|
|
51
|
-
model: msg.model, api: msg.api, usage: msg.usage)
|
|
59
|
+
model: msg.model, api: msg.api, usage: msg.usage, stop_reason: msg.stop_reason)
|
|
52
60
|
end
|
|
53
61
|
|
|
54
62
|
# Thinking-block rules (transform-messages.ts :103-118):
|
|
@@ -61,7 +69,9 @@ module Fast
|
|
|
61
69
|
return same_model ? [block] : []
|
|
62
70
|
end
|
|
63
71
|
return [block] if same_model && block[:signature]
|
|
64
|
-
|
|
72
|
+
# scrub: the empty-predicate must not die on defect-class bytes
|
|
73
|
+
# (I62) — the reasoning-field text is a scrubbed emission position.
|
|
74
|
+
return [] if block[:thinking].to_s.scrub { "" }.strip.empty?
|
|
65
75
|
return [block] if same_model
|
|
66
76
|
|
|
67
77
|
[{type: :text, text: block[:thinking]}]
|
|
@@ -122,20 +132,44 @@ module Fast
|
|
|
122
132
|
|
|
123
133
|
# ── Pass 2: convertMessages (openai-completions.ts :1293-1465) ──
|
|
124
134
|
|
|
135
|
+
# pi sanitizeSurrogates (utils/sanitize-unicode.ts :24-27, call sites
|
|
136
|
+
# :1259/:1265/:1274/:1305/:1326/:1426): invalid sequences DELETED —
|
|
137
|
+
# "Text \u{D83D} here" → "Text here", never U+FFFD. String#scrub
|
|
138
|
+
# { "" } deletes Ruby's whole invalid-encoding class (lone high/low
|
|
139
|
+
# surrogates, CESU-8 pairs, any malformed bytes — Ruby's class is
|
|
140
|
+
# broader than JS's) and is a byte-identical no-op on valid strings,
|
|
141
|
+
# so JSON.generate over the built params never raises
|
|
142
|
+
# GeneratorError from message content (Grounds row 3).
|
|
143
|
+
def scrub_text(str)
|
|
144
|
+
str.is_a?(String) ? str.scrub { "" } : str
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Tool-call arguments are message content too (the live-parsed
|
|
148
|
+
# JSON): string VALUES scrubbed, Hash/Array recursed, other leaves
|
|
149
|
+
# (numbers, booleans, nil) pass.
|
|
150
|
+
def scrub_json_values(node)
|
|
151
|
+
case node
|
|
152
|
+
when String then scrub_text(node)
|
|
153
|
+
when Hash then node.each_with_object({}) { |(key, value), out| out[key] = scrub_json_values(value) }
|
|
154
|
+
when Array then node.map { |item| scrub_json_values(item) }
|
|
155
|
+
else node
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
125
159
|
# instructions is the ONE leading system/developer message (§9.1,
|
|
126
160
|
# pi :1233): role "developer" iff model.reasoning AND
|
|
127
161
|
# dialect.supports_developer_role, else "system".
|
|
128
162
|
def to_wire_messages(instructions, messages, model, dialect)
|
|
129
163
|
instruction_role = model.reasoning && dialect.supports_developer_role ? "developer" : "system"
|
|
130
164
|
params = []
|
|
131
|
-
params << {role: instruction_role, content: instructions} if instructions && !instructions.empty?
|
|
165
|
+
params << {role: instruction_role, content: scrub_text(instructions)} if instructions && !instructions.empty?
|
|
132
166
|
|
|
133
167
|
messages.each do |msg|
|
|
134
168
|
case msg.role
|
|
135
169
|
when :system
|
|
136
170
|
next if msg.content.to_s.empty?
|
|
137
171
|
|
|
138
|
-
params << {role: instruction_role, content: msg.content}
|
|
172
|
+
params << {role: instruction_role, content: scrub_text(msg.content)}
|
|
139
173
|
when :user
|
|
140
174
|
wire_user(params, msg)
|
|
141
175
|
when :assistant
|
|
@@ -152,13 +186,13 @@ module Fast
|
|
|
152
186
|
# all-empty blocks skip the message (pi :1319-1342).
|
|
153
187
|
def wire_user(params, msg)
|
|
154
188
|
if msg.content.is_a?(String)
|
|
155
|
-
params << {role: "user", content: msg.content}
|
|
189
|
+
params << {role: "user", content: scrub_text(msg.content)}
|
|
156
190
|
return
|
|
157
191
|
end
|
|
158
192
|
|
|
159
193
|
parts = msg.content
|
|
160
194
|
.select { |block| block[:type] == :text && !block[:text].to_s.empty? }
|
|
161
|
-
.map { |block| {type: "text", text: block[:text]} }
|
|
195
|
+
.map { |block| {type: "text", text: scrub_text(block[:text])} }
|
|
162
196
|
return if parts.empty?
|
|
163
197
|
|
|
164
198
|
params << {role: "user", content: parts}
|
|
@@ -168,7 +202,7 @@ module Fast
|
|
|
168
202
|
# tool_call_id:}; empty → "(no tool output)"; NO name key
|
|
169
203
|
# (requires_tool_result_name unported, §14).
|
|
170
204
|
def wire_tool_result(params, msg)
|
|
171
|
-
text = text_block_texts(msg.content).join("\n")
|
|
205
|
+
text = scrub_text(text_block_texts(msg.content).join("\n"))
|
|
172
206
|
text = "(no tool output)" if text.empty?
|
|
173
207
|
params << {role: "tool", content: text, tool_call_id: msg.tool_call_id}
|
|
174
208
|
end
|
|
@@ -182,7 +216,7 @@ module Fast
|
|
|
182
216
|
thinking_blocks = msg.content.select { |b| b[:type] == :thinking }
|
|
183
217
|
tool_calls = msg.content.select { |b| b[:type] == :tool_call }
|
|
184
218
|
|
|
185
|
-
non_empty_thinking = thinking_blocks.reject { |b| b[:thinking].to_s.strip.empty? }
|
|
219
|
+
non_empty_thinking = thinking_blocks.reject { |b| b[:thinking].to_s.scrub { "" }.strip.empty? } # scrub: I62 defect-class safety
|
|
186
220
|
# pi computes preservedReasoningDetails over ALL thinking blocks
|
|
187
221
|
# (:1313-1316) and emits it independent of the non-empty branch
|
|
188
222
|
# (:1382-1386) — encrypted-only blocks replay too.
|
|
@@ -190,15 +224,15 @@ module Fast
|
|
|
190
224
|
unless non_empty_thinking.empty?
|
|
191
225
|
if preserved_details.nil?
|
|
192
226
|
field = non_empty_thinking.first[:signature]
|
|
193
|
-
assistant[field] = non_empty_thinking.map { |b| b[:thinking] }.join("\n") if REASONING_FIELDS.include?(field)
|
|
227
|
+
assistant[field] = scrub_text(non_empty_thinking.map { |b| b[:thinking] }.join("\n")) if REASONING_FIELDS.include?(field)
|
|
194
228
|
end
|
|
195
229
|
end
|
|
196
230
|
|
|
197
|
-
assistant[:content] = text unless text.empty?
|
|
231
|
+
assistant[:content] = scrub_text(text) unless text.empty?
|
|
198
232
|
unless tool_calls.empty?
|
|
199
233
|
assistant[:tool_calls] = tool_calls.map do |tc|
|
|
200
234
|
{id: tc[:id], type: "function",
|
|
201
|
-
function: {name: tc[:name], arguments: JSON.generate(tc[:arguments])}}
|
|
235
|
+
function: {name: tc[:name], arguments: JSON.generate(scrub_json_values(tc[:arguments]))}}
|
|
202
236
|
end
|
|
203
237
|
end
|
|
204
238
|
assistant["reasoning_details"] = preserved_details if preserved_details
|
|
@@ -326,8 +360,10 @@ module Fast
|
|
|
326
360
|
|
|
327
361
|
def text_block_texts(content, non_whitespace: false)
|
|
328
362
|
blocks = content.is_a?(String) ? [{type: :text, text: content}] : content
|
|
363
|
+
# scrub before the strip predicate (I62): defect-class bytes must
|
|
364
|
+
# reach the emission-point scrub, not die in the whitespace filter.
|
|
329
365
|
blocks.select { |b| b.is_a?(Hash) && b[:type] == :text }
|
|
330
|
-
.map { |b| b[:text].to_s }
|
|
366
|
+
.map { |b| scrub_text(b[:text]).to_s }
|
|
331
367
|
.then { |texts| non_whitespace ? texts.reject { |t| t.strip.empty? } : texts }
|
|
332
368
|
end
|
|
333
369
|
end
|
|
@@ -10,6 +10,9 @@ module Fast
|
|
|
10
10
|
# Non-2xx responses map here (pi maps the OpenAI SDK status error).
|
|
11
11
|
# #message is "<status>: <provider message>" — the provider's
|
|
12
12
|
# error.message when the body parses, else the raw body truncated.
|
|
13
|
+
# I62: a non-2xx raise happens only after the creation-retry decision,
|
|
14
|
+
# and the client-level `:error` event (empty partial, no `:start`)
|
|
15
|
+
# precedes every such raise (§1 creation-phase discipline).
|
|
13
16
|
class ApiError < Error
|
|
14
17
|
attr_reader :status, :body
|
|
15
18
|
|
|
@@ -28,14 +31,25 @@ module Fast
|
|
|
28
31
|
class RateLimitError < ApiError; end # 429
|
|
29
32
|
class ServerError < ApiError; end # 5xx
|
|
30
33
|
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
# TTFB deadline / read inactivity (D5). I62: a TTFB fire before the
|
|
35
|
+
# response is a statusless retry trigger — a retried deadline re-arms
|
|
36
|
+
# per attempt, and the final failure still surfaces this class, never
|
|
37
|
+
# CancelledError. A mid-stream read inactivity arrives as
|
|
38
|
+
# Async::Cancel through the untouched cancel ladder (no retry, no
|
|
39
|
+
# terminal event).
|
|
40
|
+
class TimeoutError < Error; end
|
|
41
|
+
# ECONNREFUSED/ECONNRESET/... (§6). I62: a mid-body network failure
|
|
42
|
+
# salvages the partial as the `:error` terminal BEFORE this raise
|
|
43
|
+
# (message form `"<class>: <message>"` preserved); at creation the
|
|
44
|
+
# same terminal carries the empty accumulator after retries exhaust.
|
|
45
|
+
class ConnectionError < Error; end
|
|
33
46
|
|
|
34
47
|
# Terminal stream errors (pi throws inside stream; the gem emits the
|
|
35
48
|
# :error event first, then raises this — §10.5). NOT the malformed-
|
|
36
49
|
# chunk path since I61: a malformed chunk is repaired at the seam,
|
|
37
50
|
# else dropped and counted while the stream continues. What remains
|
|
38
|
-
# here: a provider error finish_reason or a missing finish.
|
|
51
|
+
# here: a provider error finish_reason or a missing finish. I62 did
|
|
52
|
+
# not change this path — it IS the model for the new salvage arm.
|
|
39
53
|
class StreamError < Error
|
|
40
54
|
attr_reader :stop_reason
|
|
41
55
|
|
|
@@ -403,7 +403,6 @@ module Fast
|
|
|
403
403
|
block ||= @tool_by_id[entry["id"]] if entry["id"]
|
|
404
404
|
unless block
|
|
405
405
|
block = {type: :tool_call, id: entry["id"] || "", name: name, arguments: {}}
|
|
406
|
-
@tool_by_index[index] = block if index
|
|
407
406
|
@tool_by_id[block[:id]] = block unless block[:id].empty?
|
|
408
407
|
@partial.append_block(block)
|
|
409
408
|
events << Event.new(type: :tool_call_start, content_index: content_index(block), partial: @partial)
|
|
@@ -413,6 +412,17 @@ module Fast
|
|
|
413
412
|
@tool_by_id[block[:id]] = block
|
|
414
413
|
end
|
|
415
414
|
block[:name] = name if block[:name].empty? && !name.empty?
|
|
415
|
+
# Index-map backfill (I62, pi :536-539): a fragment that resolved
|
|
416
|
+
# its block — by index, by id, or by creation — while CARRYING an
|
|
417
|
+
# index the block has never bound, binds it here. FIRST index
|
|
418
|
+
# wins; one index per block, ever. Boundness is tracked
|
|
419
|
+
# accumulator-side (identity-keyed, like @args_scratch): the
|
|
420
|
+
# blocks ARE message content — no scratch keys on them.
|
|
421
|
+
@tool_index_bound ||= {}.compare_by_identity
|
|
422
|
+
if index && !@tool_index_bound.key?(block)
|
|
423
|
+
@tool_by_index[index] = block
|
|
424
|
+
@tool_index_bound[block] = true
|
|
425
|
+
end
|
|
416
426
|
|
|
417
427
|
raw = entry.dig("function", "arguments")
|
|
418
428
|
raw = "" unless raw.is_a?(String)
|
|
@@ -138,10 +138,12 @@ module Fast
|
|
|
138
138
|
ROLES = %i[system user assistant tool_result].freeze
|
|
139
139
|
|
|
140
140
|
attr_reader :role, :content, :provider, :model, :api, :usage,
|
|
141
|
-
:tool_call_id, :tool_name, :is_error, :response_id, :response_model
|
|
141
|
+
:tool_call_id, :tool_name, :is_error, :response_id, :response_model,
|
|
142
|
+
:stop_reason
|
|
142
143
|
|
|
143
144
|
def initialize(role:, content:, provider: nil, model: nil, api: nil,
|
|
144
|
-
tool_call_id: nil, tool_name: nil, is_error: false, usage: nil
|
|
145
|
+
tool_call_id: nil, tool_name: nil, is_error: false, usage: nil,
|
|
146
|
+
stop_reason: nil)
|
|
145
147
|
unless ROLES.include?(role)
|
|
146
148
|
raise ArgumentError, "role must be one of #{ROLES.inspect}, got #{role.inspect}"
|
|
147
149
|
end
|
|
@@ -155,6 +157,10 @@ module Fast
|
|
|
155
157
|
@tool_name = tool_name
|
|
156
158
|
@is_error = is_error == true
|
|
157
159
|
@usage = usage
|
|
160
|
+
# Stream/replay metadata (I62, pi transform-messages :196-206): nil
|
|
161
|
+
# default, NO vocabulary validation — consumers may carry their own
|
|
162
|
+
# values (tart's follow-up threads the stream's mapped reasons).
|
|
163
|
+
@stop_reason = stop_reason
|
|
158
164
|
@response_id = nil
|
|
159
165
|
@response_model = nil
|
|
160
166
|
end
|
|
@@ -190,12 +196,13 @@ module Fast
|
|
|
190
196
|
new(role: :system, content: content).freeze
|
|
191
197
|
end
|
|
192
198
|
|
|
193
|
-
def self.assistant(content, provider: nil, model: nil, api: nil)
|
|
199
|
+
def self.assistant(content, provider: nil, model: nil, api: nil, stop_reason: nil)
|
|
194
200
|
unless content.is_a?(Array)
|
|
195
201
|
raise ArgumentError, "assistant content must be an Array of block hashes"
|
|
196
202
|
end
|
|
197
203
|
|
|
198
|
-
new(role: :assistant, content: content, provider: provider, model: model, api: api
|
|
204
|
+
new(role: :assistant, content: content, provider: provider, model: model, api: api,
|
|
205
|
+
stop_reason: stop_reason).freeze
|
|
199
206
|
end
|
|
200
207
|
|
|
201
208
|
def self.tool_result(tool_call_id:, content:, tool_name: nil, is_error: false)
|
|
@@ -219,6 +226,7 @@ module Fast
|
|
|
219
226
|
out[:usage] = @usage.to_h if @usage
|
|
220
227
|
out[:response_id] = @response_id if @response_id
|
|
221
228
|
out[:response_model] = @response_model if @response_model
|
|
229
|
+
out[:stop_reason] = @stop_reason if @stop_reason
|
|
222
230
|
out
|
|
223
231
|
end
|
|
224
232
|
|
|
@@ -234,6 +242,7 @@ module Fast
|
|
|
234
242
|
tool_name: hash[:tool_name],
|
|
235
243
|
is_error: hash[:is_error] == true,
|
|
236
244
|
usage: hash[:usage] && Usage.new(**hash[:usage].transform_keys(&:to_sym)),
|
|
245
|
+
stop_reason: hash[:stop_reason]&.to_sym,
|
|
237
246
|
)
|
|
238
247
|
message.set_response_id(hash[:response_id]) if hash[:response_id]
|
|
239
248
|
message.set_response_model(hash[:response_model]) if hash[:response_model]
|