fast-completions 0.2.0 → 0.4.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 +75 -0
- data/lib/fast/completions/client.rb +135 -20
- data/lib/fast/completions/codec.rb +49 -13
- data/lib/fast/completions/constrained_sampling.rb +191 -0
- data/lib/fast/completions/dialects.rb +5 -1
- data/lib/fast/completions/errors.rb +31 -3
- data/lib/fast/completions/protocol.rb +34 -10
- data/lib/fast/completions/tool.rb +22 -0
- data/lib/fast/completions/types.rb +13 -4
- data/lib/fast/completions/version.rb +1 -1
- data/lib/fast/completions.rb +1 -0
- 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: 5741f8a7684abbb66479904f8b49d14afebfd783cf3069e2e2c91717a0765238
|
|
4
|
+
data.tar.gz: 1a20474b48f9278d815b1561101a34cc229ac37c1cfcaeb83ffe9f1ba5bbcf9a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bae0596f622cdee7289c271939aca77dc8e10d539aeed94b8c18388ad445957db4b19beb45a6359da7c971d3173d24e42ca917720499cfd081b398fd4e70f3ec
|
|
7
|
+
data.tar.gz: 30d7cc0ac12964163ca196e3e6a87e2e9e150de3db334ad342d55d923b55f0c522073108def6618c727a78593c5fb5d67f90d2e6d3267e794753575d9ab36bfe
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,81 @@
|
|
|
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.4.0 (2026-10-14)
|
|
7
|
+
|
|
8
|
+
- Strict-sampling tool schemas (pi constrained-sampling, JSON-schema half):
|
|
9
|
+
tools declare `constrained_sampling` — `false` (explicit opt-out) or
|
|
10
|
+
`{"type" => "json_schema", "strict" => "prefer"|"require"}`; any other
|
|
11
|
+
shape raises `ArgumentError` at declaration (grammar-constrained sampling
|
|
12
|
+
stays out of scope — `type: "grammar"` raises). On a dialect declaring
|
|
13
|
+
`supports_strict_mode`, a declared tool ships the strict rewrite of its
|
|
14
|
+
parameters — every property required, non-required null-less properties
|
|
15
|
+
wrapped in an `anyOf` of the property and `{"type": "null"}`,
|
|
16
|
+
`additionalProperties: false` appended — with `"strict": true` right after
|
|
17
|
+
`parameters`; every other function tool on a strict dialect ships
|
|
18
|
+
`"strict": false` with parameters verbatim (the declared Hash is never
|
|
19
|
+
mutated — the rewrite deep-copies); tools on non-strict dialects carry no
|
|
20
|
+
strict key ever, parameters verbatim. `"prefer"` falls back to verbatim
|
|
21
|
+
silently when the schema is unsupportable; `"require"` raises the new
|
|
22
|
+
`ConstrainedSamplingError` with pi's message templates. The Dialect
|
|
23
|
+
vocabulary grows to 14 keys (`supports_strict_mode`, default `false` —
|
|
24
|
+
OpenAI compatibility alone does not imply strict tool support); the
|
|
25
|
+
shipped `:zai` declares `true` (pi's glm compat).
|
|
26
|
+
|
|
27
|
+
## 0.3.0 (2026-10-15)
|
|
28
|
+
|
|
29
|
+
- Mid-stream salvage: a failure after a 2xx status — a mid-body connection
|
|
30
|
+
abort, the network-error family, or any other exception except
|
|
31
|
+
cancellation — no longer discards the streamed work. The client yields the
|
|
32
|
+
`:error` terminal event FIRST, carrying the live partial (every streamed
|
|
33
|
+
block and the usage so far; a thinking block's streamed
|
|
34
|
+
`reasoning_details` are serialized into its signature exactly as finalize
|
|
35
|
+
would) with no block-end events after the failure, then raises
|
|
36
|
+
(`ConnectionError` for the network family, message form preserved;
|
|
37
|
+
anything else re-raised unchanged). Creation-phase failures (non-2xx
|
|
38
|
+
after the retry decision, network failures with retries exhausted) yield
|
|
39
|
+
the same `:error` event — the accumulator still empty, `:start` still not
|
|
40
|
+
emitted — before their mapped raise.
|
|
41
|
+
- Request-creation retry: the POST — creation through status receipt,
|
|
42
|
+
structurally pre-first-body-byte — now retries bounded at ONE retry by
|
|
43
|
+
default (constructor `max_retries:`, integer ≥ 0; `0` restores the single
|
|
44
|
+
attempt), exactly on pi's provider-retry trigger classes: header
|
|
45
|
+
`x-should-retry: true` (any status) retries; `x-should-retry: false` is
|
|
46
|
+
final even on 429/5xx; statusless errors (network failures during the
|
|
47
|
+
POST, a TTFB watchdog fire) retry; 408/409/429/5xx retry; every other
|
|
48
|
+
status is final. Never after a 2xx — a mid-body failure salvages instead,
|
|
49
|
+
making exactly one request. Cancellation is never retried; retries are
|
|
50
|
+
immediate (backoff out of scope); a retried TTFB deadline re-arms per
|
|
51
|
+
attempt and the final failure still surfaces `TimeoutError`, never
|
|
52
|
+
`CancelledError`.
|
|
53
|
+
- Outgoing-string scrub (I62): every string the codec emits onto the wire
|
|
54
|
+
params passes through a `String#scrub { "" }` deletion scrub (pi
|
|
55
|
+
`sanitizeSurrogates`) at each emission point — the leading instructions
|
|
56
|
+
and system content, user strings and text parts, the assistant's joined
|
|
57
|
+
text and reasoning-field text, tool-result content, and the string
|
|
58
|
+
values inside tool-call `arguments` (deep). Malformed bytes (lone
|
|
59
|
+
surrogates, CESU-8 pairs, any invalid UTF-8 — Ruby's class is broader
|
|
60
|
+
than JS's) are DELETED, never replaced with U+FFFD, so
|
|
61
|
+
`JSON.generate` over a built request no longer raises
|
|
62
|
+
`GeneratorError` from message content. Valid strings pass
|
|
63
|
+
byte-identical — the canonical wire fixtures are untouched.
|
|
64
|
+
- `Message#stop_reason` (I62): nil-default replay metadata with no
|
|
65
|
+
vocabulary validation; `to_h` emits the key only when set, `from_h`
|
|
66
|
+
round-trips it, and the assistant factory accepts it.
|
|
67
|
+
`Codec.normalize` skips assistants whose `stop_reason` is `:error` or
|
|
68
|
+
`:aborted` entirely on replay (pi transform-messages): their tool calls
|
|
69
|
+
never become pending orphans and never synthesize results, while a
|
|
70
|
+
previous valid assistant's unanswered calls still do and surrounding
|
|
71
|
+
user/system messages are unaffected. Histories without errored rows
|
|
72
|
+
are byte-unaffected.
|
|
73
|
+
- Tool-call index-map backfill (I62): a tool-call fragment that resolves
|
|
74
|
+
its block by id (or creation) while carrying an `index` now binds the
|
|
75
|
+
block into the accumulator's index map (pi openai-completions :536-539)
|
|
76
|
+
— first index wins, one index per block, ever — so the
|
|
77
|
+
`{id, name}` → `{id, index, args}` → `{index, args}` fragment shape
|
|
78
|
+
assembles ONE tool call instead of a spurious second block. Boundness
|
|
79
|
+
is tracked accumulator-side; no scratch keys leak onto message blocks.
|
|
80
|
+
|
|
6
81
|
## 0.2.0 (2026-10-15)
|
|
7
82
|
|
|
8
83
|
- 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
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fast
|
|
4
|
+
module Completions
|
|
5
|
+
# JSON-schema constrained sampling (I63) — the strict rewrite that lets
|
|
6
|
+
# a declared tool ship forced property emission on a strict dialect.
|
|
7
|
+
# Port of pi packages/ai/src/api/constrained-sampling.ts @ f1b2e77f5
|
|
8
|
+
# (the JSON-schema half; the grammar half is deliberately NOT ported —
|
|
9
|
+
# nothing half-anticipates grammar). Message bytes are pi's literals
|
|
10
|
+
# verbatim: they are the parity observable. Check order inside
|
|
11
|
+
# make_json_schema_node_strict is pi's exactly — which message throws
|
|
12
|
+
# first is observable behavior.
|
|
13
|
+
module ConstrainedSampling
|
|
14
|
+
# pi constrained-sampling.ts :17-34 (16 keys — the frozen order IS
|
|
15
|
+
# the first-present-key-wins order). NOTE: the iteration Grounds say
|
|
16
|
+
# "20 keys"; pi's array holds 16 and the Grounds enumerate the same
|
|
17
|
+
# 16 in the same order — the count numeral in the brief is wrong,
|
|
18
|
+
# pi is the oracle.
|
|
19
|
+
UNSUPPORTED_STRICT_SCHEMA_KEYS = [
|
|
20
|
+
"$ref",
|
|
21
|
+
"$defs",
|
|
22
|
+
"definitions",
|
|
23
|
+
"allOf",
|
|
24
|
+
"oneOf",
|
|
25
|
+
"patternProperties",
|
|
26
|
+
"dependentSchemas",
|
|
27
|
+
"dependencies",
|
|
28
|
+
"unevaluatedProperties",
|
|
29
|
+
"propertyNames",
|
|
30
|
+
"contains",
|
|
31
|
+
"prefixItems",
|
|
32
|
+
"not",
|
|
33
|
+
"if",
|
|
34
|
+
"then",
|
|
35
|
+
"else",
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
# pi :156-178. `true` (strict) or `nil` (loose). Only
|
|
41
|
+
# UnsupportedStrictJsonSchemaError is caught — any other error
|
|
42
|
+
# propagates (pi's rethrow).
|
|
43
|
+
def resolve_json_schema_strict_sampling(tool, supports_strict_mode)
|
|
44
|
+
config = tool.constrained_sampling
|
|
45
|
+
return nil if config.nil? || config == false || config["type"] != "json_schema"
|
|
46
|
+
|
|
47
|
+
if supports_strict_mode
|
|
48
|
+
begin
|
|
49
|
+
make_strict_json_schema(tool.parameters)
|
|
50
|
+
return true
|
|
51
|
+
rescue UnsupportedStrictJsonSchemaError => reason
|
|
52
|
+
return nil if config["strict"] != "require" # silent loose fallback
|
|
53
|
+
|
|
54
|
+
raise ConstrainedSamplingError,
|
|
55
|
+
"Tool \"#{tool.name}\" requires JSON-schema constrained sampling, but #{reason.message}."
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
if config["strict"] == "require"
|
|
59
|
+
raise ConstrainedSamplingError,
|
|
60
|
+
"Tool \"#{tool.name}\" requires JSON-schema constrained sampling, but strict tools are unsupported."
|
|
61
|
+
end
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# pi makeStrictJsonSchema :137-151: deep-copy FIRST (the caller's
|
|
66
|
+
# schema — nested included — is never mutated), then the root arms
|
|
67
|
+
# use the root-specific message (differs from the nested
|
|
68
|
+
# "boolean schemas…" arm), then the root type must be "object".
|
|
69
|
+
def make_strict_json_schema(schema)
|
|
70
|
+
cloned = deep_copy(schema)
|
|
71
|
+
raise UnsupportedStrictJsonSchemaError, "root schema must have type object" unless cloned.is_a?(Hash)
|
|
72
|
+
|
|
73
|
+
make_json_schema_node_strict(cloned)
|
|
74
|
+
raise UnsupportedStrictJsonSchemaError, "root schema must have type object" unless cloned["type"] == "object"
|
|
75
|
+
|
|
76
|
+
cloned
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# pi makeJsonSchemaNodeStrict :64-134, IN PLACE on the (cloned) node.
|
|
80
|
+
def make_json_schema_node_strict(node)
|
|
81
|
+
# JS isJsonSchemaObject: object, not null, not array. Everything
|
|
82
|
+
# else — booleans, null, arrays, scalars — throws here (a nil
|
|
83
|
+
# property value hits this arm: JS undefined equivalence).
|
|
84
|
+
raise UnsupportedStrictJsonSchemaError, "boolean schemas are unsupported" unless node.is_a?(Hash)
|
|
85
|
+
|
|
86
|
+
# JS `schema[key] !== undefined`: present-with-any-value (JSON null
|
|
87
|
+
# included) throws; Ruby key? is the exact equivalence.
|
|
88
|
+
UNSUPPORTED_STRICT_SCHEMA_KEYS.each do |key|
|
|
89
|
+
raise UnsupportedStrictJsonSchemaError, "#{key} schemas are unsupported" if node.key?(key)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# pi's optional isUnsupportedKeyword provider hook is NOT ported:
|
|
93
|
+
# pi's own call sites pass none (Recorded residual R2).
|
|
94
|
+
|
|
95
|
+
if node.key?("anyOf") # pi :76-89
|
|
96
|
+
raise UnsupportedStrictJsonSchemaError, "anyOf must contain at least one schema" if
|
|
97
|
+
!node["anyOf"].is_a?(Array) || node["anyOf"].empty?
|
|
98
|
+
|
|
99
|
+
node["anyOf"].each do |variant|
|
|
100
|
+
raise UnsupportedStrictJsonSchemaError, "object and array unions are unsupported" if structured_schema?(variant)
|
|
101
|
+
|
|
102
|
+
make_json_schema_node_strict(variant)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
if node.key?("items") # pi :91-96
|
|
107
|
+
raise UnsupportedStrictJsonSchemaError, "tuple schemas are unsupported" if node["items"].is_a?(Array)
|
|
108
|
+
|
|
109
|
+
make_json_schema_node_strict(node["items"])
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# JS `schema.type === "object"` — the string, strictly.
|
|
113
|
+
object_schema = node["type"] == "object"
|
|
114
|
+
if node.key?("properties") && !object_schema
|
|
115
|
+
raise UnsupportedStrictJsonSchemaError, "properties require type object"
|
|
116
|
+
end
|
|
117
|
+
return unless object_schema # non-object-typed nodes return here
|
|
118
|
+
|
|
119
|
+
# JS `!== false`: only exactly boolean false passes.
|
|
120
|
+
if node.key?("additionalProperties") && node["additionalProperties"] != false
|
|
121
|
+
raise UnsupportedStrictJsonSchemaError, "schema-valued or true additionalProperties is unsupported"
|
|
122
|
+
end
|
|
123
|
+
if node.key?("properties") && !node["properties"].is_a?(Hash)
|
|
124
|
+
raise UnsupportedStrictJsonSchemaError, "object properties must be a schema map"
|
|
125
|
+
end
|
|
126
|
+
if node.key?("required") &&
|
|
127
|
+
(!node["required"].is_a?(Array) || node["required"].any? { |key| !key.is_a?(String) })
|
|
128
|
+
raise UnsupportedStrictJsonSchemaError, "object required must be a string array"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
properties = node.key?("properties") ? node["properties"] : {}
|
|
132
|
+
property_names = properties.keys
|
|
133
|
+
required = node.key?("required") ? node["required"] : []
|
|
134
|
+
if required.any? { |key| !property_names.include?(key) }
|
|
135
|
+
raise UnsupportedStrictJsonSchemaError, "required contains an unknown property"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
properties.each do |key, property|
|
|
139
|
+
make_json_schema_node_strict(property)
|
|
140
|
+
if !required.include?(key) && !schema_allows_null(property)
|
|
141
|
+
# Reassignment keeps the key's hash position (JS and Ruby alike).
|
|
142
|
+
properties[key] = {"anyOf" => [property, {"type" => "null"}]}
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
node["required"] = property_names # existing key keeps its position
|
|
146
|
+
node["additionalProperties"] = false # absent key appends
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# pi schemaAllowsNull :54-60. The const arm needs the key-present
|
|
150
|
+
# check: JS `schema.const === null` is false when the key is absent
|
|
151
|
+
# (undefined ≠ null); Ruby `hash["const"].nil?` alone would read an
|
|
152
|
+
# ABSENT const as null-allowing and skip the wrap.
|
|
153
|
+
def schema_allows_null(schema)
|
|
154
|
+
return false unless schema.is_a?(Hash)
|
|
155
|
+
|
|
156
|
+
type = schema["type"]
|
|
157
|
+
return true if type == "null" || (type.is_a?(Array) && type.include?("null"))
|
|
158
|
+
return true if schema.key?("const") && schema["const"].nil?
|
|
159
|
+
return true if schema["enum"].is_a?(Array) && schema["enum"].include?(nil)
|
|
160
|
+
|
|
161
|
+
schema["anyOf"].is_a?(Array) && schema["anyOf"].any? { |variant| schema_allows_null(variant) }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# pi isStructuredSchema :42-51.
|
|
165
|
+
def structured_schema?(schema)
|
|
166
|
+
return false unless schema.is_a?(Hash)
|
|
167
|
+
|
|
168
|
+
types =
|
|
169
|
+
if schema["type"].is_a?(String) then [schema["type"]]
|
|
170
|
+
elsif schema["type"].is_a?(Array) then schema["type"]
|
|
171
|
+
else []
|
|
172
|
+
end
|
|
173
|
+
types.include?("object") || types.include?("array") ||
|
|
174
|
+
schema.key?("properties") || schema.key?("items")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# structuredClone's role: fresh containers all the way down, so the
|
|
178
|
+
# rewrite (property wraps, required/additionalProperties writes)
|
|
179
|
+
# never reaches the declared Hash. Leaves (strings, numbers, booleans,
|
|
180
|
+
# nil) are immutable in Ruby — no copy needed; frozen strings dup
|
|
181
|
+
# clean by simply being carried over.
|
|
182
|
+
def deep_copy(value)
|
|
183
|
+
case value
|
|
184
|
+
when Hash then value.each_with_object({}) { |(key, val), copy| copy[key] = deep_copy(val) }
|
|
185
|
+
when Array then value.map { |val| deep_copy(val) }
|
|
186
|
+
else value
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -6,7 +6,7 @@ module Fast
|
|
|
6
6
|
# the codec arms. Nothing here is provider detection, a catalog, or a
|
|
7
7
|
# base-URL constant — the consumer declares, the codec obeys (§4).
|
|
8
8
|
#
|
|
9
|
-
# The vocabulary is CLOSED to the
|
|
9
|
+
# The vocabulary is CLOSED to the 14 keys declared below. Unknown keys
|
|
10
10
|
# and unknown enum values raise ArgumentError AT DECLARATION TIME —
|
|
11
11
|
# fail loud, never silently mistranslate (the no-universal-translation
|
|
12
12
|
# discipline).
|
|
@@ -27,6 +27,9 @@ module Fast
|
|
|
27
27
|
supports_thinking_token_budget: [false, nil],
|
|
28
28
|
send_session_affinity_headers: [false, nil],
|
|
29
29
|
session_affinity_format: [:openai, %i[openai openai_nosession openrouter].freeze],
|
|
30
|
+
# I63 (pi detectCompat :1675): OpenAI compatibility alone does not
|
|
31
|
+
# imply strict JSON-schema tool support — data, not detection.
|
|
32
|
+
supports_strict_mode: [false, nil],
|
|
30
33
|
}.freeze
|
|
31
34
|
|
|
32
35
|
attr_reader :name
|
|
@@ -154,6 +157,7 @@ module Fast
|
|
|
154
157
|
supports_developer_role false
|
|
155
158
|
supports_reasoning_effort true
|
|
156
159
|
supports_store false
|
|
160
|
+
supports_strict_mode true # pi zai.json glm compat: supportsStrictMode:true
|
|
157
161
|
end
|
|
158
162
|
|
|
159
163
|
Dialects.declare(:deepseek) do
|
|
@@ -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
|
|
|
@@ -47,6 +61,20 @@ module Fast
|
|
|
47
61
|
|
|
48
62
|
class CancelledError < StandardError; end # NOT < Error (D8)
|
|
49
63
|
|
|
64
|
+
# I63 constrained sampling (pi api/constrained-sampling.ts). INTERNAL:
|
|
65
|
+
# a tool schema outside the strict subset pi's makeStrictJsonSchema
|
|
66
|
+
# accepts — the reason bytes are pi's literals verbatim. Never escapes
|
|
67
|
+
# the resolver: "prefer" falls back to loose on it, "require" converts
|
|
68
|
+
# it to the public ConstrainedSamplingError (pi rethrows only other
|
|
69
|
+
# error classes).
|
|
70
|
+
class UnsupportedStrictJsonSchemaError < Error; end
|
|
71
|
+
|
|
72
|
+
# PUBLIC: a strict:"require" tool whose schema cannot be strict-rewritten,
|
|
73
|
+
# or that runs on a dialect without supports_strict_mode — pi's two
|
|
74
|
+
# message templates verbatim (the inner reason carries no trailing
|
|
75
|
+
# period; the template adds it).
|
|
76
|
+
class ConstrainedSamplingError < Error; end
|
|
77
|
+
|
|
50
78
|
# Internal: error classification helpers (Specification §8).
|
|
51
79
|
module Errors
|
|
52
80
|
# pi utils/overflow.ts :33-96 — the FULL pattern list, ported
|
|
@@ -66,18 +66,32 @@ module Fast
|
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
# §9.8 / pi buildParams :853-863: tools iff declared (declaration
|
|
69
|
-
# order
|
|
70
|
-
#
|
|
71
|
-
#
|
|
69
|
+
# order). Parameters stay VERBATIM — the consumer's schema is the
|
|
70
|
+
# bytes — EXCEPT the I63 strict arm: a tool declaring
|
|
71
|
+
# constrained_sampling on a strict dialect ships the strict REWRITE
|
|
72
|
+
# (pi convertTools :1505-1516, getJsonSchemaToolParameters) and the
|
|
73
|
+
# function object carries "strict" right after "parameters" (pi key
|
|
74
|
+
# order: name, description, parameters, strict) — declared-strict
|
|
75
|
+
# true, undeclared/loose false. VERBATIM remains the discipline for
|
|
76
|
+
# undeclared tools and non-strict dialects: no strict key ever there,
|
|
77
|
+
# and the emitted parameters are the declared object itself
|
|
78
|
+
# (object-identity emission). zai tool_stream right after; the
|
|
79
|
+
# `tools: []` fallback when tool history exists with no declared
|
|
80
|
+
# tools.
|
|
72
81
|
def apply_tools(params, tools, messages, dialect)
|
|
73
82
|
if tools.any?
|
|
83
|
+
strict_capable = dialect.supports_strict_mode
|
|
74
84
|
params[:tools] = tools.map do |tool|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
strict = ConstrainedSampling.resolve_json_schema_strict_sampling(tool, strict_capable)
|
|
86
|
+
function = {
|
|
87
|
+
"name" => tool.name,
|
|
88
|
+
"description" => tool.description,
|
|
89
|
+
"parameters" => strict == true ? ConstrainedSampling.make_strict_json_schema(tool.parameters) : tool.parameters,
|
|
90
|
+
}
|
|
91
|
+
# pi :1513 — the explicit key on EVERY function tool when the
|
|
92
|
+
# dialect supports strict mode ("Some reject unknown fields").
|
|
93
|
+
function["strict"] = strict || false if strict_capable
|
|
94
|
+
{"type" => "function", "function" => function}
|
|
81
95
|
end
|
|
82
96
|
params[:tool_stream] = true if dialect.zai_tool_stream
|
|
83
97
|
elsif tool_history?(messages)
|
|
@@ -403,7 +417,6 @@ module Fast
|
|
|
403
417
|
block ||= @tool_by_id[entry["id"]] if entry["id"]
|
|
404
418
|
unless block
|
|
405
419
|
block = {type: :tool_call, id: entry["id"] || "", name: name, arguments: {}}
|
|
406
|
-
@tool_by_index[index] = block if index
|
|
407
420
|
@tool_by_id[block[:id]] = block unless block[:id].empty?
|
|
408
421
|
@partial.append_block(block)
|
|
409
422
|
events << Event.new(type: :tool_call_start, content_index: content_index(block), partial: @partial)
|
|
@@ -413,6 +426,17 @@ module Fast
|
|
|
413
426
|
@tool_by_id[block[:id]] = block
|
|
414
427
|
end
|
|
415
428
|
block[:name] = name if block[:name].empty? && !name.empty?
|
|
429
|
+
# Index-map backfill (I62, pi :536-539): a fragment that resolved
|
|
430
|
+
# its block — by index, by id, or by creation — while CARRYING an
|
|
431
|
+
# index the block has never bound, binds it here. FIRST index
|
|
432
|
+
# wins; one index per block, ever. Boundness is tracked
|
|
433
|
+
# accumulator-side (identity-keyed, like @args_scratch): the
|
|
434
|
+
# blocks ARE message content — no scratch keys on them.
|
|
435
|
+
@tool_index_bound ||= {}.compare_by_identity
|
|
436
|
+
if index && !@tool_index_bound.key?(block)
|
|
437
|
+
@tool_by_index[index] = block
|
|
438
|
+
@tool_index_bound[block] = true
|
|
439
|
+
end
|
|
416
440
|
|
|
417
441
|
raw = entry.dig("function", "arguments")
|
|
418
442
|
raw = "" unless raw.is_a?(String)
|
|
@@ -25,12 +25,34 @@ module Fast
|
|
|
25
25
|
end
|
|
26
26
|
@parameters || {"type" => "object", "properties" => {}}
|
|
27
27
|
end
|
|
28
|
+
|
|
29
|
+
# pi types.ts :736-754: constrainedSampling?: false |
|
|
30
|
+
# {type:"json_schema", strict:"prefer"|"require"}. Default nil
|
|
31
|
+
# (undeclared — no strict participation); false is the explicit
|
|
32
|
+
# opt-out, wire-equivalent to nil. ANY other shape raises here, at
|
|
33
|
+
# declaration time — fail loud, never silently mistranslate
|
|
34
|
+
# (unknown type — grammar included, the named exclusion — unknown
|
|
35
|
+
# strict value, missing keys, non-Hash values).
|
|
36
|
+
UNSET = Object.new.freeze # nil is NOT a passable value (raises below)
|
|
37
|
+
|
|
38
|
+
def constrained_sampling(config = UNSET)
|
|
39
|
+
return @constrained_sampling if config.equal?(UNSET)
|
|
40
|
+
|
|
41
|
+
unless config == false || (config.is_a?(Hash) &&
|
|
42
|
+
config["type"] == "json_schema" && %w[prefer require].include?(config["strict"]))
|
|
43
|
+
raise ArgumentError,
|
|
44
|
+
"constrained_sampling must be false or {\"type\" => \"json_schema\", " \
|
|
45
|
+
"\"strict\" => \"prefer\"|\"require\"}; got #{config.inspect}"
|
|
46
|
+
end
|
|
47
|
+
@constrained_sampling = config
|
|
48
|
+
end
|
|
28
49
|
end
|
|
29
50
|
|
|
30
51
|
# Instance readers forward to the class declaration.
|
|
31
52
|
def description = self.class.description
|
|
32
53
|
def tool_name = self.class.tool_name
|
|
33
54
|
def parameters = self.class.parameters
|
|
55
|
+
def constrained_sampling = self.class.constrained_sampling
|
|
34
56
|
|
|
35
57
|
# The wire name: the declared tool_name if set, else the class name (§11).
|
|
36
58
|
def name
|
|
@@ -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]
|
data/lib/fast/completions.rb
CHANGED
|
@@ -11,6 +11,7 @@ require_relative "completions/types"
|
|
|
11
11
|
require_relative "completions/dialects"
|
|
12
12
|
require_relative "completions/sse"
|
|
13
13
|
require_relative "completions/codec"
|
|
14
|
+
require_relative "completions/constrained_sampling"
|
|
14
15
|
require_relative "completions/protocol"
|
|
15
16
|
require_relative "completions/client"
|
|
16
17
|
require_relative "completions/tool"
|
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.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eric Jacobs
|
|
@@ -38,6 +38,7 @@ files:
|
|
|
38
38
|
- lib/fast/completions/chat.rb
|
|
39
39
|
- lib/fast/completions/client.rb
|
|
40
40
|
- lib/fast/completions/codec.rb
|
|
41
|
+
- lib/fast/completions/constrained_sampling.rb
|
|
41
42
|
- lib/fast/completions/dialects.rb
|
|
42
43
|
- lib/fast/completions/errors.rb
|
|
43
44
|
- lib/fast/completions/protocol.rb
|