fast-completions 0.2.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 +7 -0
- data/CHANGELOG.md +51 -0
- data/README.md +132 -0
- data/lib/fast/completions/cancellation.rb +22 -0
- data/lib/fast/completions/chat.rb +257 -0
- data/lib/fast/completions/client.rb +193 -0
- data/lib/fast/completions/codec.rb +335 -0
- data/lib/fast/completions/dialects.rb +170 -0
- data/lib/fast/completions/errors.rb +149 -0
- data/lib/fast/completions/protocol.rb +476 -0
- data/lib/fast/completions/sse.rb +84 -0
- data/lib/fast/completions/tool.rb +50 -0
- data/lib/fast/completions/types.rb +277 -0
- data/lib/fast/completions/version.rb +7 -0
- data/lib/fast/completions.rb +39 -0
- metadata +74 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fast
|
|
4
|
+
module Completions
|
|
5
|
+
# Replay normalization + message conversion (Specification §9.4–§9.5;
|
|
6
|
+
# pi utils/transform-messages.ts + api/openai-completions.ts
|
|
7
|
+
# convertMessages :1300-1402).
|
|
8
|
+
#
|
|
9
|
+
# Codec.normalize ports transformMessages (thinking folding, redacted
|
|
10
|
+
# rules, orphaned tool-call synthetics, system hold-back). Codec
|
|
11
|
+
# .to_wire_messages ports convertMessages (the wire shapes). The two
|
|
12
|
+
# passes mirror pi's split: stream() resolves the transcript, then
|
|
13
|
+
# convertMessages shapes it.
|
|
14
|
+
module Codec
|
|
15
|
+
# pi OPENAI_COMPLETIONS_REASONING_FIELDS openai-completions.ts :273.
|
|
16
|
+
REASONING_FIELDS = %w[reasoning reasoning_content reasoning_text].freeze
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# ── Pass 1: transform-messages.ts ──────────────────────────────
|
|
21
|
+
|
|
22
|
+
def normalize(messages, model)
|
|
23
|
+
transformed = messages.map { |msg| transform_message(msg, model) }
|
|
24
|
+
insert_orphan_synthetic_results(transformed)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# First pass (transform-messages.ts :80-130). Same-model is the
|
|
28
|
+
# three-way identity reduced to (provider, model) — the gem has no
|
|
29
|
+
# provider registry; the comparison is consumer-controlled
|
|
30
|
+
# (Specification §12.3 note; pi :97-99).
|
|
31
|
+
def transform_message(msg, model)
|
|
32
|
+
return msg unless msg.assistant?
|
|
33
|
+
|
|
34
|
+
same_model = msg.provider == model.provider && msg.model == model.id
|
|
35
|
+
content = msg.content.flat_map do |block|
|
|
36
|
+
case block[:type]
|
|
37
|
+
when :thinking
|
|
38
|
+
normalize_thinking_block(block, same_model)
|
|
39
|
+
when :text
|
|
40
|
+
# Same or cross: text stays text (pi :119-123).
|
|
41
|
+
{type: :text, text: block[:text]}
|
|
42
|
+
else
|
|
43
|
+
# tool_call: pi strips the thoughtSignature cross-model and
|
|
44
|
+
# keeps it same-model (:124-134). The gem's tool_call block
|
|
45
|
+
# hash may carry :signature — the openai wire never emits it,
|
|
46
|
+
# so the strip/keep is a no-op at the wire layer.
|
|
47
|
+
block
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
Message.new(role: :assistant, content: content, provider: msg.provider,
|
|
51
|
+
model: msg.model, api: msg.api, usage: msg.usage)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Thinking-block rules (transform-messages.ts :103-118):
|
|
55
|
+
# redacted → same-model keep, cross-model drop; same-model keeps
|
|
56
|
+
# blocks WITH signatures even when the text is empty (OpenAI
|
|
57
|
+
# encrypted reasoning); empty/whitespace text drops; cross-model
|
|
58
|
+
# non-empty folds to a text block.
|
|
59
|
+
def normalize_thinking_block(block, same_model)
|
|
60
|
+
if block[:redacted]
|
|
61
|
+
return same_model ? [block] : []
|
|
62
|
+
end
|
|
63
|
+
return [block] if same_model && block[:signature]
|
|
64
|
+
return [] if block[:thinking].nil? || block[:thinking].strip.empty?
|
|
65
|
+
return [block] if same_model
|
|
66
|
+
|
|
67
|
+
[{type: :text, text: block[:thinking]}]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Second pass (transform-messages.ts :143-201): synthetic results
|
|
71
|
+
# for orphaned tool calls; system messages landing between a call
|
|
72
|
+
# and its results are held back and emitted after them (:166-169).
|
|
73
|
+
def insert_orphan_synthetic_results(messages)
|
|
74
|
+
result = []
|
|
75
|
+
pending_tool_calls = []
|
|
76
|
+
satisfied_ids = []
|
|
77
|
+
held_system_messages = []
|
|
78
|
+
|
|
79
|
+
close_pending = lambda do
|
|
80
|
+
if pending_tool_calls.any?
|
|
81
|
+
pending_tool_calls.each do |tc|
|
|
82
|
+
next if satisfied_ids.include?(tc[:id])
|
|
83
|
+
|
|
84
|
+
result << Message.tool_result(
|
|
85
|
+
tool_call_id: tc[:id], tool_name: tc[:name],
|
|
86
|
+
content: "No result provided", is_error: true,
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
pending_tool_calls = []
|
|
90
|
+
satisfied_ids = []
|
|
91
|
+
end
|
|
92
|
+
result.concat(held_system_messages)
|
|
93
|
+
held_system_messages = []
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
messages.each do |msg|
|
|
97
|
+
if msg.assistant?
|
|
98
|
+
close_pending.call
|
|
99
|
+
calls = msg.content.select { |b| b[:type] == :tool_call }
|
|
100
|
+
unless calls.empty?
|
|
101
|
+
pending_tool_calls = calls
|
|
102
|
+
satisfied_ids = []
|
|
103
|
+
end
|
|
104
|
+
result << msg
|
|
105
|
+
elsif msg.tool_result?
|
|
106
|
+
satisfied_ids << msg.tool_call_id
|
|
107
|
+
result << msg
|
|
108
|
+
elsif msg.system?
|
|
109
|
+
if pending_tool_calls.any?
|
|
110
|
+
held_system_messages << msg
|
|
111
|
+
else
|
|
112
|
+
result << msg
|
|
113
|
+
end
|
|
114
|
+
else # user
|
|
115
|
+
close_pending.call
|
|
116
|
+
result << msg
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
close_pending.call
|
|
120
|
+
result
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# ── Pass 2: convertMessages (openai-completions.ts :1293-1465) ──
|
|
124
|
+
|
|
125
|
+
# instructions is the ONE leading system/developer message (§9.1,
|
|
126
|
+
# pi :1233): role "developer" iff model.reasoning AND
|
|
127
|
+
# dialect.supports_developer_role, else "system".
|
|
128
|
+
def to_wire_messages(instructions, messages, model, dialect)
|
|
129
|
+
instruction_role = model.reasoning && dialect.supports_developer_role ? "developer" : "system"
|
|
130
|
+
params = []
|
|
131
|
+
params << {role: instruction_role, content: instructions} if instructions && !instructions.empty?
|
|
132
|
+
|
|
133
|
+
messages.each do |msg|
|
|
134
|
+
case msg.role
|
|
135
|
+
when :system
|
|
136
|
+
next if msg.content.to_s.empty?
|
|
137
|
+
|
|
138
|
+
params << {role: instruction_role, content: msg.content}
|
|
139
|
+
when :user
|
|
140
|
+
wire_user(params, msg)
|
|
141
|
+
when :assistant
|
|
142
|
+
wire_assistant(params, msg, model, dialect)
|
|
143
|
+
when :tool_result
|
|
144
|
+
wire_tool_result(params, msg)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
params
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# User: a String passes verbatim (fixture fact); a block array
|
|
151
|
+
# passes as text parts (non-empty text only — images cut, D7);
|
|
152
|
+
# all-empty blocks skip the message (pi :1319-1342).
|
|
153
|
+
def wire_user(params, msg)
|
|
154
|
+
if msg.content.is_a?(String)
|
|
155
|
+
params << {role: "user", content: msg.content}
|
|
156
|
+
return
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
parts = msg.content
|
|
160
|
+
.select { |block| block[:type] == :text && !block[:text].to_s.empty? }
|
|
161
|
+
.map { |block| {type: "text", text: block[:text]} }
|
|
162
|
+
return if parts.empty?
|
|
163
|
+
|
|
164
|
+
params << {role: "user", content: parts}
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Tool result (§9.1, pi :1404-1427): {role: "tool", content: joined,
|
|
168
|
+
# tool_call_id:}; empty → "(no tool output)"; NO name key
|
|
169
|
+
# (requires_tool_result_name unported, §14).
|
|
170
|
+
def wire_tool_result(params, msg)
|
|
171
|
+
text = text_block_texts(msg.content).join("\n")
|
|
172
|
+
text = "(no tool output)" if text.empty?
|
|
173
|
+
params << {role: "tool", content: text, tool_call_id: msg.tool_call_id}
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Assistant replay (§9.4, pi :1348-1402). Wire key order is pi's
|
|
177
|
+
# insertion order: role, content, [reasoning field], [tool_calls],
|
|
178
|
+
# [reasoning_details], [deepseek reasoning_content:""].
|
|
179
|
+
def wire_assistant(params, msg, model, dialect)
|
|
180
|
+
assistant = {role: "assistant", content: nil}
|
|
181
|
+
text = text_block_texts(msg.content, non_whitespace: true).join("")
|
|
182
|
+
thinking_blocks = msg.content.select { |b| b[:type] == :thinking }
|
|
183
|
+
tool_calls = msg.content.select { |b| b[:type] == :tool_call }
|
|
184
|
+
|
|
185
|
+
non_empty_thinking = thinking_blocks.reject { |b| b[:thinking].to_s.strip.empty? }
|
|
186
|
+
# pi computes preservedReasoningDetails over ALL thinking blocks
|
|
187
|
+
# (:1313-1316) and emits it independent of the non-empty branch
|
|
188
|
+
# (:1382-1386) — encrypted-only blocks replay too.
|
|
189
|
+
preserved_details = reasoning_details_for(thinking_blocks)
|
|
190
|
+
unless non_empty_thinking.empty?
|
|
191
|
+
if preserved_details.nil?
|
|
192
|
+
field = non_empty_thinking.first[:signature]
|
|
193
|
+
assistant[field] = non_empty_thinking.map { |b| b[:thinking] }.join("\n") if REASONING_FIELDS.include?(field)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
assistant[:content] = text unless text.empty?
|
|
198
|
+
unless tool_calls.empty?
|
|
199
|
+
assistant[:tool_calls] = tool_calls.map do |tc|
|
|
200
|
+
{id: tc[:id], type: "function",
|
|
201
|
+
function: {name: tc[:name], arguments: JSON.generate(tc[:arguments])}}
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
assistant["reasoning_details"] = preserved_details if preserved_details
|
|
205
|
+
# deepseek: reasoning_content:"" on every assistant when required
|
|
206
|
+
# and none was emitted (§9.5, pi :1387-1393).
|
|
207
|
+
if dialect.requires_reasoning_content_on_assistant_messages && model.reasoning &&
|
|
208
|
+
!assistant.key?("reasoning_content")
|
|
209
|
+
assistant["reasoning_content"] = ""
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Skip empty assistants (no text, no tool_calls) — pi :1394-1402.
|
|
213
|
+
# A reasoning-field-only assistant (content null, no tool_calls)
|
|
214
|
+
# is skipped too: pi's skip predicate checks content+tool_calls.
|
|
215
|
+
has_content = assistant[:content].is_a?(String) && !assistant[:content].empty?
|
|
216
|
+
return if !has_content && !assistant[:tool_calls]
|
|
217
|
+
|
|
218
|
+
params << assistant
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# §9.5: the FIRST thinking-block signature (over ALL thinking
|
|
222
|
+
# blocks, pi :1313-1316) that parses as a JSON array of valid
|
|
223
|
+
# reasoning_details wins — emit `reasoning_details` and no raw
|
|
224
|
+
# reasoning field.
|
|
225
|
+
def reasoning_details_for(thinking_blocks)
|
|
226
|
+
thinking_blocks.each do |block|
|
|
227
|
+
details = parse_reasoning_details(block[:signature])
|
|
228
|
+
return details if details
|
|
229
|
+
end
|
|
230
|
+
nil
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# pi parseOpenAIReasoningDetails openai-completions.ts :220-228:
|
|
234
|
+
# signature → JSON array (non-empty) of valid detail objects, else
|
|
235
|
+
# nil.
|
|
236
|
+
def parse_reasoning_details(signature)
|
|
237
|
+
return nil if signature.nil? || signature.empty?
|
|
238
|
+
|
|
239
|
+
parsed = JSON.parse(signature)
|
|
240
|
+
return nil unless parsed.is_a?(Array) && !parsed.empty?
|
|
241
|
+
return nil unless parsed.all? { |detail| valid_reasoning_detail?(detail) }
|
|
242
|
+
|
|
243
|
+
parsed
|
|
244
|
+
rescue JSON::ParserError, TypeError
|
|
245
|
+
nil
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# pi isOpenAIReasoningDetail :135-165.
|
|
249
|
+
def valid_reasoning_detail?(detail)
|
|
250
|
+
return false unless detail.is_a?(Hash)
|
|
251
|
+
|
|
252
|
+
id = detail["id"]
|
|
253
|
+
return false unless id.nil? || id.is_a?(String)
|
|
254
|
+
return false unless detail["format"].nil? || detail["format"].is_a?(String)
|
|
255
|
+
return false unless detail["index"].nil? || detail["index"].is_a?(Numeric)
|
|
256
|
+
|
|
257
|
+
case detail["type"]
|
|
258
|
+
when "reasoning.summary" then detail["summary"].is_a?(String)
|
|
259
|
+
when "reasoning.encrypted" then detail["data"].is_a?(String)
|
|
260
|
+
when "reasoning.text"
|
|
261
|
+
detail["text"].is_a?(String) &&
|
|
262
|
+
(detail["signature"].nil? || detail["signature"].is_a?(String))
|
|
263
|
+
else false
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# pi appendOpenAIReasoningDetail :240-265 — consecutive text/text
|
|
268
|
+
# and summary/summary entries merge; encrypted entries stay opaque
|
|
269
|
+
# and discrete.
|
|
270
|
+
def append_reasoning_detail(details, detail)
|
|
271
|
+
last = details.last
|
|
272
|
+
case detail["type"]
|
|
273
|
+
when "reasoning.text"
|
|
274
|
+
if last.is_a?(Hash) && last["type"] == "reasoning.text"
|
|
275
|
+
last["text"] = last["text"] + detail["text"]
|
|
276
|
+
last["signature"] ||= detail["signature"]
|
|
277
|
+
fill_missing_common_fields(last, detail)
|
|
278
|
+
return
|
|
279
|
+
end
|
|
280
|
+
when "reasoning.summary"
|
|
281
|
+
if last.is_a?(Hash) && last["type"] == "reasoning.summary"
|
|
282
|
+
last["summary"] = last["summary"] + detail["summary"]
|
|
283
|
+
fill_missing_common_fields(last, detail)
|
|
284
|
+
return
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
details << detail.dup
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def fill_missing_common_fields(target, source)
|
|
291
|
+
# JS ??= with an undefined source is a no-op on the wire (JSON
|
|
292
|
+
# drops undefined) — never introduce explicit nulls.
|
|
293
|
+
if target["id"].nil? && !source["id"].nil?
|
|
294
|
+
target["id"] = source["id"]
|
|
295
|
+
end
|
|
296
|
+
if (target["format"].nil? || target["format"].empty?) && !source["format"].nil? # JS ||= (falsy overwrite)
|
|
297
|
+
target["format"] = source["format"]
|
|
298
|
+
end
|
|
299
|
+
if target["index"].nil? && !source["index"].nil?
|
|
300
|
+
target["index"] = source["index"]
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# ── §9.6 estimate ──────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
# ceil(chars / 3.5) over the message texts (pi CHARS_PER_TOKEN 3.5,
|
|
307
|
+
# utils/estimate.ts :14). Texts = string contents, text/thinking
|
|
308
|
+
# block texts, tool-result content.
|
|
309
|
+
def estimate_tokens(instructions, messages)
|
|
310
|
+
chars = instructions.to_s.length
|
|
311
|
+
messages.each do |msg|
|
|
312
|
+
case msg.content
|
|
313
|
+
when String then chars += msg.content.length
|
|
314
|
+
when Array
|
|
315
|
+
msg.content.each do |block|
|
|
316
|
+
if block.is_a?(Hash) && (block[:type] == :text || block[:type] == :thinking)
|
|
317
|
+
chars += block[:text].to_s.length
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
(chars / 3.5).ceil
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
module_function
|
|
326
|
+
|
|
327
|
+
def text_block_texts(content, non_whitespace: false)
|
|
328
|
+
blocks = content.is_a?(String) ? [{type: :text, text: content}] : content
|
|
329
|
+
blocks.select { |b| b.is_a?(Hash) && b[:type] == :text }
|
|
330
|
+
.map { |b| b[:text].to_s }
|
|
331
|
+
.then { |texts| non_whitespace ? texts.reject { |t| t.strip.empty? } : texts }
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fast
|
|
4
|
+
module Completions
|
|
5
|
+
# Dialects are DATA (D2): a closed declaration vocabulary consumed by
|
|
6
|
+
# the codec arms. Nothing here is provider detection, a catalog, or a
|
|
7
|
+
# base-URL constant — the consumer declares, the codec obeys (§4).
|
|
8
|
+
#
|
|
9
|
+
# The vocabulary is CLOSED to the 13 keys declared below. Unknown keys
|
|
10
|
+
# and unknown enum values raise ArgumentError AT DECLARATION TIME —
|
|
11
|
+
# fail loud, never silently mistranslate (the no-universal-translation
|
|
12
|
+
# discipline).
|
|
13
|
+
class Dialect
|
|
14
|
+
# key => [default, allowed-values-or-nil] in canonical (to_h-stable)
|
|
15
|
+
# order; nil allowed-values = free boolean.
|
|
16
|
+
VOCABULARY = {
|
|
17
|
+
thinking_format: [:openai, %i[openai zai deepseek].freeze],
|
|
18
|
+
max_tokens_field: ["max_completion_tokens", %w[max_completion_tokens max_tokens].freeze],
|
|
19
|
+
supports_developer_role: [true, nil],
|
|
20
|
+
supports_reasoning_effort: [true, nil],
|
|
21
|
+
supports_store: [true, nil],
|
|
22
|
+
supports_usage_in_streaming: [true, nil],
|
|
23
|
+
supports_finish_reason: [true, nil],
|
|
24
|
+
requires_reasoning_content_on_assistant_messages: [false, nil],
|
|
25
|
+
zai_tool_stream: [false, nil],
|
|
26
|
+
thinking_token_budget_field: [nil, [nil, "thinking_token_budget", "thinking_budget", "thinking_budget_tokens"].freeze],
|
|
27
|
+
supports_thinking_token_budget: [false, nil],
|
|
28
|
+
send_session_affinity_headers: [false, nil],
|
|
29
|
+
session_affinity_format: [:openai, %i[openai openai_nosession openrouter].freeze],
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
attr_reader :name
|
|
33
|
+
|
|
34
|
+
def initialize(name = nil, **overrides)
|
|
35
|
+
values = {}
|
|
36
|
+
VOCABULARY.each_key { |key| values[key] = VOCABULARY[key][0] }
|
|
37
|
+
overrides.each do |key, value|
|
|
38
|
+
unless VOCABULARY.key?(key)
|
|
39
|
+
raise ArgumentError,
|
|
40
|
+
"unknown dialect key #{key.inspect}; the vocabulary is closed to #{VOCABULARY.keys.inspect}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
allowed = VOCABULARY[key][1]
|
|
44
|
+
unless allowed.nil? || allowed.include?(value)
|
|
45
|
+
raise ArgumentError, "invalid value #{value.inspect} for dialect key #{key.inspect}; allowed: #{allowed.inspect}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
values[key] = value
|
|
49
|
+
end
|
|
50
|
+
@name = name
|
|
51
|
+
@values = values.freeze
|
|
52
|
+
freeze
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
VOCABULARY.each_key do |key|
|
|
56
|
+
define_method(key) { @values[key] }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Declaration-order-stable (§4): canonical key order regardless of
|
|
60
|
+
# the order the consumer declared in.
|
|
61
|
+
def to_h
|
|
62
|
+
@values.dup
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def ==(other)
|
|
66
|
+
other.is_a?(Dialect) && other.to_h == to_h
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def inspect
|
|
70
|
+
"#<Fast::Completions::Dialect #{(@name || :anonymous).inspect} #{to_h.inspect}>"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The declaration DSL receiver: `thinking_format :zai`-style setters
|
|
74
|
+
# validating against the same closed vocabulary (§4).
|
|
75
|
+
class DSL
|
|
76
|
+
Dialect::VOCABULARY.each_key do |key|
|
|
77
|
+
define_method(key) { |value| @overrides[key] = value }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def initialize(name)
|
|
81
|
+
@name = name
|
|
82
|
+
@overrides = {}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
attr_reader :name, :overrides
|
|
86
|
+
|
|
87
|
+
def build
|
|
88
|
+
Dialect.new(@name, **@overrides)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The declaration registry + lookup (§4). Re-declaring a name
|
|
94
|
+
# OVERRIDES — last declaration wins (documented consumer contract).
|
|
95
|
+
module Dialects
|
|
96
|
+
module_function
|
|
97
|
+
|
|
98
|
+
# Declare with block DSL and/or keyword overrides; empty overrides
|
|
99
|
+
# declare the all-defaults dialect (the :fireworks shape). Lookup
|
|
100
|
+
# goes through Fast::Completions.dialect (the facade, §3).
|
|
101
|
+
def declare(name, **overrides, &block)
|
|
102
|
+
dialect =
|
|
103
|
+
if block
|
|
104
|
+
dsl = Dialect::DSL.new(name)
|
|
105
|
+
dsl.instance_eval(&block)
|
|
106
|
+
Dialect.new(name, **dsl.overrides.merge(overrides))
|
|
107
|
+
else
|
|
108
|
+
Dialect.new(name, **overrides)
|
|
109
|
+
end
|
|
110
|
+
registry[name] = dialect
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def fetch(name)
|
|
114
|
+
registry.fetch(name) { raise KeyError, "no dialect declared under #{name.inspect}" }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def registered
|
|
118
|
+
registry.keys.freeze
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Frozen name→Dialect dup (§3 .dialects).
|
|
122
|
+
def all
|
|
123
|
+
registry.dup.freeze
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The plain arm: every default, thinking_format :openai (§4).
|
|
127
|
+
def default
|
|
128
|
+
@default ||= Dialect.new(nil).freeze
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def reset! # test hook only
|
|
132
|
+
@registry = nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def registry
|
|
136
|
+
@registry ||= {}
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The well-known trio, SHIPPED as declarations, registered at require
|
|
141
|
+
# time (§4):
|
|
142
|
+
# - :zai — the I46 five-key subset + pi's zai store suppression
|
|
143
|
+
# (pi openai-completions.ts detectCompat :1642-1694: zai is
|
|
144
|
+
# non-standard ⇒ no store, no developer role, max_tokens;
|
|
145
|
+
# supportsReasoningEffort re-enabled per the I46 live behavior).
|
|
146
|
+
# - :deepseek — pi's deepseek detection: non-standard, max_tokens,
|
|
147
|
+
# requiresReasoningContentOnAssistantMessages.
|
|
148
|
+
# - :fireworks — the empty declaration (fireworks detects nothing in
|
|
149
|
+
# pi — the plain arm).
|
|
150
|
+
Dialects.declare(:zai) do
|
|
151
|
+
thinking_format :zai
|
|
152
|
+
zai_tool_stream true
|
|
153
|
+
max_tokens_field "max_tokens"
|
|
154
|
+
supports_developer_role false
|
|
155
|
+
supports_reasoning_effort true
|
|
156
|
+
supports_store false
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
Dialects.declare(:deepseek) do
|
|
160
|
+
thinking_format :deepseek
|
|
161
|
+
max_tokens_field "max_tokens"
|
|
162
|
+
supports_developer_role false
|
|
163
|
+
supports_reasoning_effort true
|
|
164
|
+
requires_reasoning_content_on_assistant_messages true
|
|
165
|
+
supports_store false
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
Dialects.declare(:fireworks)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fast
|
|
4
|
+
module Completions
|
|
5
|
+
# The frozen error taxonomy (Specification §8). CancelledError is
|
|
6
|
+
# deliberately NOT under Error so rescue ladders that catch the API
|
|
7
|
+
# error tree never swallow a cancellation (D8).
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
# Non-2xx responses map here (pi maps the OpenAI SDK status error).
|
|
11
|
+
# #message is "<status>: <provider message>" — the provider's
|
|
12
|
+
# error.message when the body parses, else the raw body truncated.
|
|
13
|
+
class ApiError < Error
|
|
14
|
+
attr_reader :status, :body
|
|
15
|
+
|
|
16
|
+
def initialize(status:, body: "")
|
|
17
|
+
@status = status
|
|
18
|
+
@body = body.to_s
|
|
19
|
+
super(Errors.api_message(status, @body))
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class BadRequestError < ApiError; end # 400
|
|
24
|
+
class ContextLengthExceededError < BadRequestError; end # 400 + overflow pattern
|
|
25
|
+
class AuthenticationError < ApiError; end # 401
|
|
26
|
+
class PermissionError < ApiError; end # 403
|
|
27
|
+
class NotFoundError < ApiError; end # 404
|
|
28
|
+
class RateLimitError < ApiError; end # 429
|
|
29
|
+
class ServerError < ApiError; end # 5xx
|
|
30
|
+
|
|
31
|
+
class TimeoutError < Error; end # TTFB deadline / read inactivity (D5)
|
|
32
|
+
class ConnectionError < Error; end # ECONNREFUSED/ECONNRESET/... (§6)
|
|
33
|
+
|
|
34
|
+
# Terminal stream errors (pi throws inside stream; the gem emits the
|
|
35
|
+
# :error event first, then raises this — §10.5). NOT the malformed-
|
|
36
|
+
# chunk path since I61: a malformed chunk is repaired at the seam,
|
|
37
|
+
# else dropped and counted while the stream continues. What remains
|
|
38
|
+
# here: a provider error finish_reason or a missing finish.
|
|
39
|
+
class StreamError < Error
|
|
40
|
+
attr_reader :stop_reason
|
|
41
|
+
|
|
42
|
+
def initialize(message, stop_reason:)
|
|
43
|
+
@stop_reason = stop_reason
|
|
44
|
+
super(message)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class CancelledError < StandardError; end # NOT < Error (D8)
|
|
49
|
+
|
|
50
|
+
# Internal: error classification helpers (Specification §8).
|
|
51
|
+
module Errors
|
|
52
|
+
# pi utils/overflow.ts :33-96 — the FULL pattern list, ported
|
|
53
|
+
# verbatim as data (regexes already case-insensitive).
|
|
54
|
+
OVERFLOW_PATTERNS = [
|
|
55
|
+
/prompt (?:is )?too long/i, # Anthropic and z.ai token overflow
|
|
56
|
+
/prompt exceeds max length/i, # z.ai CN endpoint token overflow
|
|
57
|
+
/request_too_large/i, # Anthropic request byte-size overflow (HTTP 413)
|
|
58
|
+
/input is too long for requested model/i, # Amazon Bedrock
|
|
59
|
+
/exceeds the context window/i, # OpenAI (Completions & Responses API)
|
|
60
|
+
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i, # LiteLLM
|
|
61
|
+
/input token count.*exceeds the maximum/i, # Google (Gemini)
|
|
62
|
+
/maximum prompt length is \d+/i, # xAI (Grok)
|
|
63
|
+
/reduce the length of the messages/i, # Groq
|
|
64
|
+
/maximum context length is \d+ tokens/i, # OpenRouter (most backends)
|
|
65
|
+
/exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i, # OpenRouter/Poolside
|
|
66
|
+
/input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i, # Together AI
|
|
67
|
+
/exceeds the limit of \d+/i, # GitHub Copilot
|
|
68
|
+
/exceeds the available context size/i, # llama.cpp server
|
|
69
|
+
/greater than the context length/i, # LM Studio
|
|
70
|
+
/context window exceeds limit/i, # MiniMax
|
|
71
|
+
/exceeded model token limit/i, # Kimi For Coding
|
|
72
|
+
/too large for model with \d+ maximum context length/i, # Mistral
|
|
73
|
+
/prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i, # DS4 server
|
|
74
|
+
/model_context_window_exceeded/i, # z.ai non-standard finish_reason as error text
|
|
75
|
+
/prompt too long; exceeded (?:max )?context length/i, # Ollama explicit overflow error
|
|
76
|
+
/range of input length should be/i, # DashScope / Qwen Token Plan
|
|
77
|
+
/context[_ ]length[_ ]exceeded/i, # Generic fallback
|
|
78
|
+
/too many tokens/i, # Generic fallback
|
|
79
|
+
/token limit exceeded/i, # Generic fallback
|
|
80
|
+
].freeze
|
|
81
|
+
|
|
82
|
+
STATUS_CLASSES = {
|
|
83
|
+
401 => AuthenticationError,
|
|
84
|
+
403 => PermissionError,
|
|
85
|
+
404 => NotFoundError,
|
|
86
|
+
429 => RateLimitError,
|
|
87
|
+
}.freeze
|
|
88
|
+
|
|
89
|
+
module_function
|
|
90
|
+
|
|
91
|
+
# Map a non-2xx status + body to the frozen class. 400 classifies
|
|
92
|
+
# as ContextLengthExceededError when ANY string in the parsed body
|
|
93
|
+
# (or the raw body) matches any overflow pattern (§8).
|
|
94
|
+
def class_for_status(status, body)
|
|
95
|
+
case status
|
|
96
|
+
when 400
|
|
97
|
+
overflow?(body) ? ContextLengthExceededError : BadRequestError
|
|
98
|
+
when 500..599
|
|
99
|
+
ServerError
|
|
100
|
+
else
|
|
101
|
+
STATUS_CLASSES.fetch(status, ApiError)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def overflow?(body)
|
|
106
|
+
raw = body.to_s
|
|
107
|
+
strings_in(raw).any? do |text|
|
|
108
|
+
OVERFLOW_PATTERNS.any? { |pattern| pattern.match?(text) }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# ApiError#message: "<status>: <message>" — the provider's
|
|
113
|
+
# error.message when parseable, else the raw body truncated to
|
|
114
|
+
# ~500 chars (Specification §8).
|
|
115
|
+
def api_message(status, body)
|
|
116
|
+
"#{status}: #{provider_message(body)}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def provider_message(body)
|
|
120
|
+
raw = body.to_s
|
|
121
|
+
parsed = JSON.parse(raw)
|
|
122
|
+
message = parsed["error"]["message"] if parsed.is_a?(Hash) && parsed["error"].is_a?(Hash)
|
|
123
|
+
message ||= parsed["message"] if parsed.is_a?(Hash)
|
|
124
|
+
message || raw[0, 500]
|
|
125
|
+
rescue JSON::ParserError, TypeError
|
|
126
|
+
raw[0, 500]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Every string value in a (possibly JSON) body — the pattern set is
|
|
130
|
+
# matched against each, so a nested {"error":{"message": ...}} is
|
|
131
|
+
# covered without pinning one shape.
|
|
132
|
+
def strings_in(raw)
|
|
133
|
+
strings = [raw]
|
|
134
|
+
parsed = JSON.parse(raw)
|
|
135
|
+
walk = ->(node) do
|
|
136
|
+
case node
|
|
137
|
+
when String then strings << node
|
|
138
|
+
when Array then node.each { |item| walk.call(item) }
|
|
139
|
+
when Hash then node.each_value { |item| walk.call(item) }
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
walk.call(parsed) if parsed.is_a?(Hash) || parsed.is_a?(Array)
|
|
143
|
+
strings
|
|
144
|
+
rescue JSON::ParserError, TypeError
|
|
145
|
+
strings
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|