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,476 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fast
|
|
4
|
+
module Completions
|
|
5
|
+
# THE BYTE CONTRACT (Specification §9): Ruby Hash insertion order +
|
|
6
|
+
# JSON.generate must reproduce pi's wire bytes. Key emission order is
|
|
7
|
+
# pi buildParams (openai-completions.ts :802-1000): model, messages,
|
|
8
|
+
# stream, stream_options, store, max_tokens|max_completion_tokens,
|
|
9
|
+
# temperature, tools (+tool_stream), thinking arms, thinking budget.
|
|
10
|
+
# NOTHING else (§14: no tool_choice, no sampling, no prompt_cache_*).
|
|
11
|
+
module Protocol
|
|
12
|
+
# pi api/simple-options.ts :66-73.
|
|
13
|
+
DEFAULT_THINKING_BUDGETS = {minimal: 1024, low: 2048, medium: 8192, high: 16384}.freeze
|
|
14
|
+
MIN_ANSWER_TOKENS = 1024 # pi simple-options.ts :66
|
|
15
|
+
CONTEXT_SAFETY_TOKENS = 4096 # pi simple-options.ts :11
|
|
16
|
+
CHARS_PER_TOKEN = 3.5 # pi utils/estimate.ts :14
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Build the request Hash. `thinking` is the raw requested level
|
|
21
|
+
# (nil = off-equivalent: no effort — pi streamSimple :760-763).
|
|
22
|
+
def build_request(model:, messages:, instructions: nil, tools: [],
|
|
23
|
+
thinking: nil, max_tokens: nil, temperature: nil)
|
|
24
|
+
dialect = model.dialect
|
|
25
|
+
effort = resolve_effort(model, thinking)
|
|
26
|
+
normalized = Codec.normalize(messages, model)
|
|
27
|
+
|
|
28
|
+
params = {
|
|
29
|
+
model: model.id,
|
|
30
|
+
messages: Codec.to_wire_messages(instructions, normalized, model, dialect),
|
|
31
|
+
stream: true,
|
|
32
|
+
}
|
|
33
|
+
params[:stream_options] = {include_usage: true} if dialect.supports_usage_in_streaming # §9.4
|
|
34
|
+
params[:store] = false if dialect.supports_store # §9.5
|
|
35
|
+
|
|
36
|
+
effective_max = clamp_max_tokens(model, instructions, messages, max_tokens)
|
|
37
|
+
if effective_max # §9.6: key omitted only when no cap exists at all
|
|
38
|
+
params[dialect.max_tokens_field == "max_tokens" ? :max_tokens : :max_completion_tokens] = effective_max
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
params[:temperature] = temperature unless temperature.nil? # §9.7: only when explicitly set
|
|
42
|
+
|
|
43
|
+
apply_tools(params, tools, messages, dialect) # §9.8
|
|
44
|
+
apply_thinking_arms(params, model, dialect, effort, effective_max) # §9.9-9.10
|
|
45
|
+
params
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# pi streamSimple :760-763: clamp, then :off means NO effort.
|
|
49
|
+
def resolve_effort(model, thinking)
|
|
50
|
+
return nil if thinking.nil?
|
|
51
|
+
|
|
52
|
+
clamped = model.clamp_thinking_level(thinking)
|
|
53
|
+
clamped == :off ? nil : clamped
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# §9.6 / pi clampMaxTokensToContext simple-options.ts :13-19:
|
|
57
|
+
# min(requested || model.max_tokens, context − estimate − 4096), at
|
|
58
|
+
# least 1; nil only when no requested cap AND model.max_tokens nil.
|
|
59
|
+
def clamp_max_tokens(model, instructions, messages, max_tokens)
|
|
60
|
+
requested = max_tokens || model.max_tokens
|
|
61
|
+
return nil if requested.nil?
|
|
62
|
+
return [1, requested].max if model.context_window <= 0
|
|
63
|
+
|
|
64
|
+
available = model.context_window - Codec.estimate_tokens(instructions, messages) - CONTEXT_SAFETY_TOKENS
|
|
65
|
+
[requested, [1, available].max].min
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# §9.8 / pi buildParams :853-863: tools iff declared (declaration
|
|
69
|
+
# order; parameters VERBATIM — the consumer's schema is the bytes);
|
|
70
|
+
# zai tool_stream right after; the `tools: []` fallback when tool
|
|
71
|
+
# history exists with no declared tools.
|
|
72
|
+
def apply_tools(params, tools, messages, dialect)
|
|
73
|
+
if tools.any?
|
|
74
|
+
params[:tools] = tools.map do |tool|
|
|
75
|
+
{"type" => "function",
|
|
76
|
+
"function" => {
|
|
77
|
+
"name" => tool.name,
|
|
78
|
+
"description" => tool.description,
|
|
79
|
+
"parameters" => tool.parameters,
|
|
80
|
+
}}
|
|
81
|
+
end
|
|
82
|
+
params[:tool_stream] = true if dialect.zai_tool_stream
|
|
83
|
+
elsif tool_history?(messages)
|
|
84
|
+
params[:tools] = []
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# pi hasToolHistory openai-completions.ts :93-101.
|
|
89
|
+
def tool_history?(messages)
|
|
90
|
+
messages.any? do |msg|
|
|
91
|
+
msg.tool_result? ||
|
|
92
|
+
(msg.assistant? && msg.content.any? { |block| block[:type] == :tool_call })
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# §9.9 — model.reasoning gates EVERY arm (a non-reasoning model
|
|
97
|
+
# emits none; pi's arm chain is entered only under
|
|
98
|
+
# thinkingFormat checks conjoint with model.reasoning).
|
|
99
|
+
def apply_thinking_arms(params, model, dialect, effort, effective_max)
|
|
100
|
+
case dialect.thinking_format
|
|
101
|
+
when :zai
|
|
102
|
+
if model.reasoning # pi :880-892
|
|
103
|
+
params[:thinking] = effort ? {"type" => "enabled", "clear_thinking" => false} : {"type" => "disabled"}
|
|
104
|
+
if effort && dialect.supports_reasoning_effort
|
|
105
|
+
mapped = mapped_effort(model, effort)
|
|
106
|
+
params[:reasoning_effort] = mapped if mapped.is_a?(String)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
when :deepseek
|
|
110
|
+
if model.reasoning # pi :928-937
|
|
111
|
+
if effort
|
|
112
|
+
params[:thinking] = {"type" => "enabled"}
|
|
113
|
+
elsif deepseek_emits_disabled?(model)
|
|
114
|
+
# pi: thinkingLevelMap?.off !== null — a map WITHOUT :off
|
|
115
|
+
# also emits disabled; only an explicit off:null suppresses.
|
|
116
|
+
params[:thinking] = {"type" => "disabled"}
|
|
117
|
+
end
|
|
118
|
+
if effort && dialect.supports_reasoning_effort
|
|
119
|
+
params[:reasoning_effort] = mapped_effort(model, effort)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
else # the openai arm, pi :949-957
|
|
123
|
+
if effort && model.reasoning && dialect.supports_reasoning_effort
|
|
124
|
+
params[:reasoning_effort] = mapped_effort(model, effort)
|
|
125
|
+
elsif !effort && model.reasoning && dialect.supports_reasoning_effort
|
|
126
|
+
off = model.thinking_level_map&.[](:off)
|
|
127
|
+
params[:reasoning_effort] = off if off.is_a?(String) # the off-floor
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
apply_thinking_budget(params, model, dialect, effort, effective_max) # §9.10
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# pi map-else-level: thinkingLevelMap?.[effort] ?? effort.
|
|
134
|
+
def mapped_effort(model, effort)
|
|
135
|
+
mapped = model.thinking_level_map&.[](effort)
|
|
136
|
+
mapped.nil? ? effort.to_s : mapped
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def deepseek_emits_disabled?(model)
|
|
140
|
+
map = model.thinking_level_map
|
|
141
|
+
map.nil? || !map.key?(:off) || !map[:off].nil?
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# §9.10 / pi :961-963 + resolveClampedThinkingBudget :1012-1023:
|
|
145
|
+
# budget = DEFAULT_THINKING_BUDGETS[clampReasoning(effort)] clamped
|
|
146
|
+
# to answer room; emitted ONLY when > 0, AFTER the thinking arms.
|
|
147
|
+
def apply_thinking_budget(params, model, dialect, effort, effective_max)
|
|
148
|
+
field = dialect.thinking_token_budget_field ||
|
|
149
|
+
(dialect.supports_thinking_token_budget ? "thinking_token_budget" : nil)
|
|
150
|
+
return unless field && effort && model.reasoning
|
|
151
|
+
|
|
152
|
+
budget = DEFAULT_THINKING_BUDGETS.fetch(clamp_reasoning(effort))
|
|
153
|
+
ceiling = params[:max_tokens] || params[:max_completion_tokens] || model.max_tokens
|
|
154
|
+
budget = [budget, [0, ceiling - MIN_ANSWER_TOKENS].max].min
|
|
155
|
+
params[field.to_sym] = budget if budget > 0
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# pi clampReasoning simple-options.ts :76-79: xhigh/max → high.
|
|
159
|
+
def clamp_reasoning(effort)
|
|
160
|
+
effort == :xhigh || effort == :max ? :high : effort
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# ── §10 delta parse ────────────────────────────────────────────
|
|
164
|
+
# (pi stream :304-734 — the per-chunk order, block machinery, and
|
|
165
|
+
# stream-end discipline.)
|
|
166
|
+
|
|
167
|
+
# pi parseChunkUsage :1519-1547. total_tokens NATIVE (OQ-9):
|
|
168
|
+
# input+output+cache_read+cache_write, pi's formula.
|
|
169
|
+
def parse_chunk_usage(raw)
|
|
170
|
+
return Usage::ZERO unless raw.is_a?(Hash)
|
|
171
|
+
|
|
172
|
+
details = raw["prompt_tokens_details"].is_a?(Hash) ? raw["prompt_tokens_details"] : {}
|
|
173
|
+
cache_read = [details["cached_tokens"], raw["prompt_cache_hit_tokens"], raw["cached_tokens"]].compact.first || 0
|
|
174
|
+
cache_write = details["cache_write_tokens"] || 0
|
|
175
|
+
prompt = raw["prompt_tokens"] || 0
|
|
176
|
+
input = [0, prompt - cache_read - cache_write].max
|
|
177
|
+
output = raw["completion_tokens"] || 0
|
|
178
|
+
reasoning_details = raw["completion_tokens_details"].is_a?(Hash) ? raw["completion_tokens_details"] : {}
|
|
179
|
+
reasoning = reasoning_details["reasoning_tokens"] || 0
|
|
180
|
+
Usage.new(
|
|
181
|
+
input: input, output: output, cache_read: cache_read,
|
|
182
|
+
cache_write: cache_write, reasoning: reasoning,
|
|
183
|
+
total_tokens: input + output + cache_read + cache_write,
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# §10.2 / pi mapStopReason :1562-1589 → [reason, error_message].
|
|
188
|
+
def map_stop_reason(raw_reason)
|
|
189
|
+
case raw_reason
|
|
190
|
+
when nil then [:stop, nil]
|
|
191
|
+
when "stop", "end" then [:stop, nil]
|
|
192
|
+
when "length" then [:length, nil]
|
|
193
|
+
when "function_call", "tool_calls" then [:tool_use, nil]
|
|
194
|
+
else [:error, "Provider finish_reason: #{raw_reason}"]
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# OQ-4: control-character repair (pi utils/json-parse.ts repairJson
|
|
199
|
+
# :20-77), {} as the last resort (pi's partial-json fallback).
|
|
200
|
+
def parse_streaming_json(partial)
|
|
201
|
+
return {} if partial.nil? || partial.strip.empty?
|
|
202
|
+
|
|
203
|
+
JSON.parse(partial)
|
|
204
|
+
rescue JSON::ParserError
|
|
205
|
+
repaired = repair_json(partial)
|
|
206
|
+
repaired == partial ? {} : JSON.parse(repaired)
|
|
207
|
+
rescue JSON::ParserError
|
|
208
|
+
{}
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Escape raw control characters inside strings; double backslashes
|
|
212
|
+
# before invalid escape characters (pi repairJson, verbatim port).
|
|
213
|
+
def repair_json(json)
|
|
214
|
+
valid_escapes = %w[" \\ / b f n r t]
|
|
215
|
+
repaired = +""
|
|
216
|
+
in_string = false
|
|
217
|
+
index = 0
|
|
218
|
+
length = json.length
|
|
219
|
+
while index < length
|
|
220
|
+
char = json[index]
|
|
221
|
+
unless in_string
|
|
222
|
+
repaired << char
|
|
223
|
+
in_string = true if char == '"'
|
|
224
|
+
index += 1
|
|
225
|
+
next
|
|
226
|
+
end
|
|
227
|
+
if char == '"'
|
|
228
|
+
repaired << char
|
|
229
|
+
in_string = false
|
|
230
|
+
index += 1
|
|
231
|
+
next
|
|
232
|
+
end
|
|
233
|
+
if char == "\\"
|
|
234
|
+
nxt = json[index + 1]
|
|
235
|
+
if nxt.nil?
|
|
236
|
+
repaired << "\\\\"
|
|
237
|
+
index += 1
|
|
238
|
+
next
|
|
239
|
+
end
|
|
240
|
+
if nxt == "u"
|
|
241
|
+
digits = json[index + 2, 4].to_s
|
|
242
|
+
if digits.match?(/\A[0-9a-fA-F]{4}\z/)
|
|
243
|
+
repaired << "\\u#{digits}"
|
|
244
|
+
index += 6
|
|
245
|
+
next
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
if valid_escapes.include?(nxt)
|
|
249
|
+
repaired << "\\" << nxt
|
|
250
|
+
index += 2
|
|
251
|
+
next
|
|
252
|
+
end
|
|
253
|
+
repaired << "\\\\"
|
|
254
|
+
index += 1
|
|
255
|
+
next
|
|
256
|
+
end
|
|
257
|
+
repaired << (char.ord <= 0x1f ? escape_control_character(char) : char)
|
|
258
|
+
index += 1
|
|
259
|
+
end
|
|
260
|
+
repaired
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def escape_control_character(char)
|
|
264
|
+
case char
|
|
265
|
+
when "\b" then "\\b"
|
|
266
|
+
when "\f" then "\\f"
|
|
267
|
+
when "\n" then "\\n"
|
|
268
|
+
when "\r" then "\\r"
|
|
269
|
+
when "\t" then "\\t"
|
|
270
|
+
else format("\\u%04x", char.ord)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# The live stream accumulator (pi stream :312-707). `partial` is
|
|
275
|
+
# THE shared Message — events carry it, never a copy (§3).
|
|
276
|
+
class StreamAccumulator
|
|
277
|
+
attr_reader :partial
|
|
278
|
+
|
|
279
|
+
def initialize(model, dialect)
|
|
280
|
+
@model = model
|
|
281
|
+
@dialect = dialect
|
|
282
|
+
# pi output seed :312-327: usage zeros, stop pending.
|
|
283
|
+
@partial = Message.new(role: :assistant, content: [],
|
|
284
|
+
provider: model.provider, model: model.id, api: "openai-completions",
|
|
285
|
+
usage: Usage::ZERO)
|
|
286
|
+
@text_block = nil
|
|
287
|
+
@thinking_block = nil
|
|
288
|
+
@tool_by_index = {}
|
|
289
|
+
@tool_by_id = {}
|
|
290
|
+
@args_scratch = {}.compare_by_identity
|
|
291
|
+
@streamed_details = nil # :334-341
|
|
292
|
+
@has_finish_reason = false
|
|
293
|
+
@stop_reason = nil
|
|
294
|
+
@error_message = nil
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# No event precedes :start (§10.6; pi types.ts :789-794).
|
|
298
|
+
def start_event
|
|
299
|
+
Event.new(type: :start, partial: @partial)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# One JSON chunk in, ordered events out (pi per-chunk loop
|
|
303
|
+
# :558-655).
|
|
304
|
+
def consume(chunk)
|
|
305
|
+
events = []
|
|
306
|
+
chunk = {} unless chunk.is_a?(Hash)
|
|
307
|
+
|
|
308
|
+
# OpenAI docs: every chunk carries the same completion id (pi
|
|
309
|
+
# :561-566). First wins; a differing model name is recorded once.
|
|
310
|
+
@partial.set_response_id(chunk["id"]) if chunk["id"]
|
|
311
|
+
chunk_model = chunk["model"]
|
|
312
|
+
if chunk_model.is_a?(String) && !chunk_model.empty? && chunk_model != @model.id
|
|
313
|
+
@partial.set_response_model(chunk_model)
|
|
314
|
+
end
|
|
315
|
+
@partial.set_usage(Protocol.parse_chunk_usage(chunk["usage"])) if chunk["usage"]
|
|
316
|
+
|
|
317
|
+
choice = chunk["choices"].is_a?(Array) ? chunk["choices"][0] : nil
|
|
318
|
+
return events unless choice.is_a?(Hash)
|
|
319
|
+
|
|
320
|
+
# Moonshot fallback: usage on choice.usage (pi :582-585).
|
|
321
|
+
if !chunk["usage"] && choice["usage"]
|
|
322
|
+
@partial.set_usage(Protocol.parse_chunk_usage(choice["usage"]))
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
if choice["finish_reason"] # §10.3 / pi :587-597
|
|
326
|
+
@stop_reason, @error_message = Protocol.map_stop_reason(choice["finish_reason"])
|
|
327
|
+
@has_finish_reason = true
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
delta = choice["delta"]
|
|
331
|
+
events.concat(consume_delta(delta)) if delta.is_a?(Hash)
|
|
332
|
+
events
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# §10.5: finalize blocks in content order (pi finishBlock loop
|
|
336
|
+
# :679-687), then resolve the terminal discipline. Returns
|
|
337
|
+
# [events, outcome] where outcome = [:done, reason] or
|
|
338
|
+
# [:stream_error, message].
|
|
339
|
+
def finish
|
|
340
|
+
events = finalize_blocks
|
|
341
|
+
|
|
342
|
+
if !@has_finish_reason && !@dialect.supports_finish_reason
|
|
343
|
+
# Infer from content (pi :690-692).
|
|
344
|
+
@stop_reason = @partial.content.any? { |block| block[:type] == :tool_call } ? :tool_use : :stop
|
|
345
|
+
end
|
|
346
|
+
if @stop_reason == :error
|
|
347
|
+
return events, [:stream_error, @error_message || "Provider returned an error stop reason"]
|
|
348
|
+
end
|
|
349
|
+
if (@dialect.supports_finish_reason && !@has_finish_reason) || @stop_reason.nil?
|
|
350
|
+
return events, [:stream_error, "Stream ended without finish_reason"]
|
|
351
|
+
end
|
|
352
|
+
[events, [:done, @stop_reason]]
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
private
|
|
356
|
+
|
|
357
|
+
def consume_delta(delta)
|
|
358
|
+
events = []
|
|
359
|
+
if delta["content"].is_a?(String) && !delta["content"].empty?
|
|
360
|
+
block = ensure_text_block(events)
|
|
361
|
+
block[:text] = block[:text] + delta["content"]
|
|
362
|
+
events << Event.new(type: :text_delta, content_index: content_index(block),
|
|
363
|
+
delta: delta["content"], partial: @partial)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# FIRST non-empty of reasoning_content/reasoning/reasoning_text
|
|
367
|
+
# (pi :616-641); the FIELD NAME is the block signature.
|
|
368
|
+
field = %w[reasoning_content reasoning reasoning_text].find do |name|
|
|
369
|
+
delta[name].is_a?(String) && !delta[name].empty?
|
|
370
|
+
end
|
|
371
|
+
if field
|
|
372
|
+
block = ensure_thinking_block(events, field)
|
|
373
|
+
block[:thinking] = block[:thinking] + delta[field]
|
|
374
|
+
events << Event.new(type: :thinking_delta, content_index: content_index(block),
|
|
375
|
+
delta: delta[field], partial: @partial)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
if delta["tool_calls"].is_a?(Array)
|
|
379
|
+
delta["tool_calls"].each { |entry| events.concat(consume_tool_call(entry)) }
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# reasoning_details deltas (pi :664-677): validated entries
|
|
383
|
+
# accumulate; serialized into the signature at finalize.
|
|
384
|
+
if delta["reasoning_details"].is_a?(Array)
|
|
385
|
+
delta["reasoning_details"].each do |detail|
|
|
386
|
+
next unless Codec.valid_reasoning_detail?(detail)
|
|
387
|
+
|
|
388
|
+
ensure_thinking_block(events, "")
|
|
389
|
+
@streamed_details ||= []
|
|
390
|
+
Codec.append_reasoning_detail(@streamed_details, detail)
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
events
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def consume_tool_call(entry)
|
|
397
|
+
events = []
|
|
398
|
+
entry = {} unless entry.is_a?(Hash)
|
|
399
|
+
index = entry["index"].is_a?(Integer) ? entry["index"] : nil
|
|
400
|
+
name = entry.dig("function", "name") || ""
|
|
401
|
+
|
|
402
|
+
block = index && @tool_by_index[index]
|
|
403
|
+
block ||= @tool_by_id[entry["id"]] if entry["id"]
|
|
404
|
+
unless block
|
|
405
|
+
block = {type: :tool_call, id: entry["id"] || "", name: name, arguments: {}}
|
|
406
|
+
@tool_by_index[index] = block if index
|
|
407
|
+
@tool_by_id[block[:id]] = block unless block[:id].empty?
|
|
408
|
+
@partial.append_block(block)
|
|
409
|
+
events << Event.new(type: :tool_call_start, content_index: content_index(block), partial: @partial)
|
|
410
|
+
end
|
|
411
|
+
if block[:id].empty? && entry["id"] # id backfill (pi :666-668)
|
|
412
|
+
block[:id] = entry["id"]
|
|
413
|
+
@tool_by_id[block[:id]] = block
|
|
414
|
+
end
|
|
415
|
+
block[:name] = name if block[:name].empty? && !name.empty?
|
|
416
|
+
|
|
417
|
+
raw = entry.dig("function", "arguments")
|
|
418
|
+
raw = "" unless raw.is_a?(String)
|
|
419
|
+
@args_scratch[block] = (@args_scratch[block] || "") + raw
|
|
420
|
+
block[:arguments] = Protocol.parse_streaming_json(@args_scratch[block]) # best-effort live parse (OQ-4)
|
|
421
|
+
events << Event.new(type: :tool_call_delta, content_index: content_index(block),
|
|
422
|
+
delta: raw, partial: @partial)
|
|
423
|
+
events
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def finalize_blocks
|
|
427
|
+
events = []
|
|
428
|
+
@partial.content.each do |block|
|
|
429
|
+
index = content_index(block)
|
|
430
|
+
case block[:type]
|
|
431
|
+
when :text
|
|
432
|
+
events << Event.new(type: :text_end, content_index: index, content: block[:text], partial: @partial)
|
|
433
|
+
when :thinking
|
|
434
|
+
# Replay metadata serialized ONCE at finalize (pi :436-443).
|
|
435
|
+
block[:signature] = JSON.generate(@streamed_details) if @streamed_details
|
|
436
|
+
events << Event.new(type: :thinking_end, content_index: index, content: block[:thinking], partial: @partial)
|
|
437
|
+
when :tool_call
|
|
438
|
+
raw = @args_scratch.delete(block) || ""
|
|
439
|
+
block[:arguments] = Protocol.parse_streaming_json(raw)
|
|
440
|
+
events << Event.new(type: :tool_call_end, content_index: index,
|
|
441
|
+
tool_call: {id: block[:id], name: block[:name], arguments: block[:arguments]},
|
|
442
|
+
partial: @partial)
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
events
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# pi ensureTextBlock/ensureThinkingBlock (:518-533): creating the
|
|
449
|
+
# block pushes its *_start event (§3: content_index present on
|
|
450
|
+
# block events).
|
|
451
|
+
def ensure_text_block(events)
|
|
452
|
+
unless @text_block
|
|
453
|
+
@text_block = {type: :text, text: ""}
|
|
454
|
+
@partial.append_block(@text_block)
|
|
455
|
+
events << Event.new(type: :text_start, content_index: content_index(@text_block), partial: @partial)
|
|
456
|
+
end
|
|
457
|
+
@text_block
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def ensure_thinking_block(events, signature)
|
|
461
|
+
unless @thinking_block
|
|
462
|
+
@thinking_block = {type: :thinking, thinking: "", signature: signature}
|
|
463
|
+
@partial.append_block(@thinking_block)
|
|
464
|
+
events << Event.new(type: :thinking_start, content_index: content_index(@thinking_block), partial: @partial)
|
|
465
|
+
end
|
|
466
|
+
@thinking_block
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Identity scan — pi blocks.indexOf (content_index is positional).
|
|
470
|
+
def content_index(block)
|
|
471
|
+
@partial.content.index { |candidate| candidate.equal?(block) }
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fast
|
|
4
|
+
module Completions
|
|
5
|
+
# SSE line parser (Specification §7): arbitrary byte-chunk boundaries
|
|
6
|
+
# in, complete `data:` payloads out, in order.
|
|
7
|
+
#
|
|
8
|
+
# Rules (SSE): events separated by a blank line; `data:` prefix
|
|
9
|
+
# (one space) stripped; `:`-prefixed lines are comments and ignored
|
|
10
|
+
# (the mock's keepalives); CR is stripped (\r\n normalized); a
|
|
11
|
+
# multi-data-line event concatenates with "\n"; `data: [DONE]` is the
|
|
12
|
+
# terminal token and surfaces as the distinguished SSE::DONE
|
|
13
|
+
# sentinel, NOT a payload.
|
|
14
|
+
#
|
|
15
|
+
# Continuation rejoin (I61): a provider that coalesces deltas can cut
|
|
16
|
+
# a payload's JSON with a raw newline; the remainder then arrives
|
|
17
|
+
# UNPREFIXED on the next line. While a data payload is open
|
|
18
|
+
# (mid-event), such a line is appended to it — no separator. Comments
|
|
19
|
+
# and the known SSE fields (event:/id:/retry:) keep their ignore
|
|
20
|
+
# semantics; a healthy wire carries no such lines at all.
|
|
21
|
+
class SSE
|
|
22
|
+
# Distinguished terminal token for `data: [DONE]` (§7).
|
|
23
|
+
DONE = Object.new.freeze
|
|
24
|
+
|
|
25
|
+
def initialize
|
|
26
|
+
@buffer = +""
|
|
27
|
+
@data_lines = []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Feed one network chunk; returns an Array of completed items for
|
|
31
|
+
# this push — String payloads and/or the DONE sentinel, in order.
|
|
32
|
+
# Only COMPLETE lines are consumed; a trailing partial line stays
|
|
33
|
+
# buffered until its \n arrives (chunk-boundary safety).
|
|
34
|
+
def push(chunk)
|
|
35
|
+
@buffer << chunk
|
|
36
|
+
items = []
|
|
37
|
+
while (newline = @buffer.index("\n"))
|
|
38
|
+
line = @buffer.slice!(0..newline).chomp
|
|
39
|
+
item = handle_line(line)
|
|
40
|
+
items << item unless item.nil?
|
|
41
|
+
end
|
|
42
|
+
items
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def handle_line(line)
|
|
48
|
+
# CR stripped: \r\n normalized even when the CR rides at the end
|
|
49
|
+
# of a split chunk's last complete line.
|
|
50
|
+
line = line.delete_suffix("\r")
|
|
51
|
+
if line.start_with?(":") # comment (keepalive) — ignored (§7)
|
|
52
|
+
return nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if line.empty? # blank line = event boundary → dispatch
|
|
56
|
+
return flush_event
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if line.start_with?("data:")
|
|
60
|
+
payload = line.delete_prefix("data:")
|
|
61
|
+
payload = payload[1..] if payload.start_with?(" ") # strip exactly one space
|
|
62
|
+
@data_lines << payload
|
|
63
|
+
return nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Known SSE fields stay ignored, even mid-payload.
|
|
67
|
+
return nil if line.start_with?("event:", "id:", "retry:")
|
|
68
|
+
|
|
69
|
+
# Unprefixed continuation of the open payload (I61); with no
|
|
70
|
+
# payload open, ignored as before.
|
|
71
|
+
@data_lines.last << line unless @data_lines.empty?
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def flush_event
|
|
76
|
+
return nil if @data_lines.empty?
|
|
77
|
+
|
|
78
|
+
payload = @data_lines.join("\n") # multi-data-line concat (SSE spec)
|
|
79
|
+
@data_lines = []
|
|
80
|
+
payload == "[DONE]" ? DONE : payload
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fast
|
|
4
|
+
module Completions
|
|
5
|
+
# Class-level tool declaration (Specification §11): description,
|
|
6
|
+
# optional tool_name override (default = the class name), parameters
|
|
7
|
+
# schema (emitted VERBATIM on the wire — the consumer's schema is the
|
|
8
|
+
# bytes, §9.2). #execute returns a String, a Hash (JSON-generated for
|
|
9
|
+
# the result text), or {error: "..."} for an error result.
|
|
10
|
+
class Tool
|
|
11
|
+
class << self
|
|
12
|
+
def description(text = nil)
|
|
13
|
+
text ? @description = text : @description
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def tool_name(name = nil)
|
|
17
|
+
name ? @tool_name = name : @tool_name
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def parameters(schema = nil, &block)
|
|
21
|
+
if block
|
|
22
|
+
@parameters = block.call
|
|
23
|
+
elsif schema
|
|
24
|
+
@parameters = schema
|
|
25
|
+
end
|
|
26
|
+
@parameters || {"type" => "object", "properties" => {}}
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Instance readers forward to the class declaration.
|
|
31
|
+
def description = self.class.description
|
|
32
|
+
def tool_name = self.class.tool_name
|
|
33
|
+
def parameters = self.class.parameters
|
|
34
|
+
|
|
35
|
+
# The wire name: the declared tool_name if set, else the class name (§11).
|
|
36
|
+
def name
|
|
37
|
+
self.class.tool_name || self.class.name.to_s.split("::").last
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def description = self.class.description
|
|
41
|
+
def parameters = self.class.parameters
|
|
42
|
+
|
|
43
|
+
# Subclasses implement; the Chat calls it with the parsed arguments
|
|
44
|
+
# splashed as keywords.
|
|
45
|
+
def execute(**_arguments)
|
|
46
|
+
raise NotImplementedError, "#{self.class} must implement #execute"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|