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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b689b98f53f49ee1e7dcf541b2339f24683d34c6b8331994764ddffc0bdb86df
4
- data.tar.gz: 815c13df95de3782a8dfd8303d3ad50173fd9c0d5c8795fa8cdd7fc9173df8c6
3
+ metadata.gz: e2cecdfed6a68aef560e112239c184f36f20d906b7122ae1311cbeb92b726b4d
4
+ data.tar.gz: cb1ecd28ceecb63144bc7ad9b9328ffd9624033c26d793c691496d67ea909551
5
5
  SHA512:
6
- metadata.gz: 5025050d712a676ac2bb5972506260cc63fa4aaff9ca47d20409e18a9ea756b0096deeea9a5168729d5f3757bf03820a88c680107e718d097946b52b0c3ae9dc
7
- data.tar.gz: 4715c20690b34fc1d569a9b41ff9f291f3c110d32ed66daa9945989f54b18f53de09b265f6d7c1c11815b0f95201327a3627339968ebe2b0b0f37fbc89468317
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
- watchdog.arm
81
- response = @client.post(@path, headers: headers, body: JSON.generate(request))
82
- begin
83
- raise_api_error(response) unless (200...300).cover?(response.status)
84
- yield accumulator.start_event if block # :start precedes every block event (§10.6)
85
- feed_stream(response, accumulator, watchdog, &block)
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
- # Non-2xx: read the body, map per §8.
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
- transformed = messages.map { |msg| transform_message(msg, model) }
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
- return [] if block[:thinking].nil? || block[:thinking].strip.empty?
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
- class TimeoutError < Error; end # TTFB deadline / read inactivity (D5)
32
- class ConnectionError < Error; end # ECONNREFUSED/ECONNRESET/... (§6)
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).freeze
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]
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Fast
4
4
  module Completions
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.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.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Jacobs