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.
@@ -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