ruby-pi 0.1.8 → 0.1.10
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 +44 -0
- data/README.md +31 -7
- data/lib/ruby_pi/agent/core.rb +6 -0
- data/lib/ruby_pi/agent/events.rb +3 -0
- data/lib/ruby_pi/agent/loop.rb +26 -2
- data/lib/ruby_pi/agent/result.rb +60 -11
- data/lib/ruby_pi/agent/state.rb +29 -5
- data/lib/ruby_pi/configuration.rb +6 -3
- data/lib/ruby_pi/context/compaction.rb +27 -7
- data/lib/ruby_pi/errors.rb +16 -0
- data/lib/ruby_pi/extensions/base.rb +0 -8
- data/lib/ruby_pi/llm/anthropic.rb +34 -71
- data/lib/ruby_pi/llm/base_provider.rb +127 -9
- data/lib/ruby_pi/llm/fallback.rb +20 -5
- data/lib/ruby_pi/llm/gemini.rb +63 -81
- data/lib/ruby_pi/llm/openai.rb +70 -91
- data/lib/ruby_pi/llm/sse_parser.rb +125 -0
- data/lib/ruby_pi/llm/stream_event.rb +10 -3
- data/lib/ruby_pi/tools/executor.rb +162 -76
- data/lib/ruby_pi/tools/result.rb +17 -2
- data/lib/ruby_pi/version.rb +1 -1
- data/lib/ruby_pi.rb +1 -0
- metadata +3 -2
|
@@ -340,7 +340,7 @@ module RubyPi
|
|
|
340
340
|
end
|
|
341
341
|
|
|
342
342
|
handle_error_response(response) unless response.success?
|
|
343
|
-
parse_response(
|
|
343
|
+
parse_response(parse_json_response(response.body))
|
|
344
344
|
end
|
|
345
345
|
|
|
346
346
|
# Executes a streaming request to the Anthropic API, yielding events.
|
|
@@ -366,26 +366,28 @@ module RubyPi
|
|
|
366
366
|
usage_data = {}
|
|
367
367
|
finish_reason = nil
|
|
368
368
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
# which may split SSE events mid-line. We accumulate a line buffer and
|
|
372
|
-
# process complete lines incrementally so that deltas reach the caller
|
|
373
|
-
# as soon as each SSE event is fully received — not after the entire
|
|
374
|
-
# response has been buffered.
|
|
375
|
-
#
|
|
376
|
-
# The buffer is BINARY because chunks arrive as ASCII-8BIT and may end
|
|
377
|
-
# mid-way through a multi-byte UTF-8 character; appending such a chunk
|
|
378
|
-
# to a UTF-8 buffer that already holds non-ASCII text raises
|
|
379
|
-
# Encoding::CompatibilityError. Each complete line is re-encoded to
|
|
380
|
-
# UTF-8 (and scrubbed) before parsing, so deltas reach the caller as
|
|
381
|
-
# valid UTF-8 strings.
|
|
382
|
-
sse_buffer = (+"").force_encoding(Encoding::BINARY)
|
|
369
|
+
sse_parser = SSEParser.new(provider: provider_name)
|
|
370
|
+
stream_completed = false
|
|
383
371
|
response_status = nil
|
|
384
372
|
|
|
385
373
|
# Accumulate error response body separately so ApiError gets the
|
|
386
374
|
# full body even though on_data consumed the chunks.
|
|
387
375
|
error_body = (+"").force_encoding(Encoding::BINARY)
|
|
388
376
|
|
|
377
|
+
process_payload = proc do |data_str|
|
|
378
|
+
next if data_str == "[DONE]"
|
|
379
|
+
|
|
380
|
+
data = sse_parser.parse_json(data_str)
|
|
381
|
+
stream_completed = true if data["type"] == "message_stop"
|
|
382
|
+
stream_state = process_anthropic_stream_event(
|
|
383
|
+
data, accumulated_text, accumulated_tool_calls,
|
|
384
|
+
current_tool_call, current_tool_json, usage_data, finish_reason, block
|
|
385
|
+
)
|
|
386
|
+
current_tool_call = stream_state[:current_tool_call]
|
|
387
|
+
current_tool_json = stream_state[:current_tool_json]
|
|
388
|
+
finish_reason = stream_state[:finish_reason]
|
|
389
|
+
end
|
|
390
|
+
|
|
389
391
|
response = with_transport_errors do
|
|
390
392
|
conn.post("/v1/messages") do |req|
|
|
391
393
|
req.headers["Content-Type"] = "application/json"
|
|
@@ -395,7 +397,7 @@ module RubyPi
|
|
|
395
397
|
# Without this, Faraday buffers the entire response body before
|
|
396
398
|
# returning, which means no deltas reach the caller until the model
|
|
397
399
|
# finishes generating (fake streaming).
|
|
398
|
-
req.options.on_data = proc do |chunk,
|
|
400
|
+
req.options.on_data = proc do |chunk, _overall_received_bytes, env|
|
|
399
401
|
response_status ||= env&.status
|
|
400
402
|
|
|
401
403
|
# If the HTTP status indicates an error, accumulate the body for
|
|
@@ -403,41 +405,11 @@ module RubyPi
|
|
|
403
405
|
# calls on_data for error responses too, which would otherwise
|
|
404
406
|
# consume the body and leave response.body empty.
|
|
405
407
|
if response_status && response_status >= 400
|
|
406
|
-
error_body
|
|
408
|
+
append_error_body(error_body, chunk)
|
|
407
409
|
next
|
|
408
410
|
end
|
|
409
411
|
|
|
410
|
-
|
|
411
|
-
# Process all complete lines in the buffer. A complete line holds
|
|
412
|
-
# complete UTF-8 sequences (multi-byte characters split across
|
|
413
|
-
# chunks are repaired by the buffering), so re-encode it to UTF-8
|
|
414
|
-
# here; scrub guards against a server sending invalid bytes.
|
|
415
|
-
while (line_end = sse_buffer.index("\n"))
|
|
416
|
-
line = sse_buffer.slice!(0, line_end + 1).force_encoding(Encoding::UTF_8).scrub.strip
|
|
417
|
-
next if line.empty?
|
|
418
|
-
next unless line.start_with?("data: ")
|
|
419
|
-
|
|
420
|
-
data_str = line.sub(/\Adata: /, "")
|
|
421
|
-
next if data_str == "[DONE]"
|
|
422
|
-
|
|
423
|
-
begin
|
|
424
|
-
data = JSON.parse(data_str)
|
|
425
|
-
rescue JSON::ParserError
|
|
426
|
-
next
|
|
427
|
-
end
|
|
428
|
-
|
|
429
|
-
# --- process each SSE event exactly as before ---
|
|
430
|
-
# Process the SSE event and update mutable locals from the
|
|
431
|
-
# returned hash. This keeps all streaming state method-local,
|
|
432
|
-
# avoiding thread-unsafe instance variables.
|
|
433
|
-
stream_state = process_anthropic_stream_event(
|
|
434
|
-
data, accumulated_text, accumulated_tool_calls,
|
|
435
|
-
current_tool_call, current_tool_json, usage_data, finish_reason, block
|
|
436
|
-
)
|
|
437
|
-
current_tool_call = stream_state[:current_tool_call]
|
|
438
|
-
current_tool_json = stream_state[:current_tool_json]
|
|
439
|
-
finish_reason = stream_state[:finish_reason]
|
|
440
|
-
end
|
|
412
|
+
sse_parser.feed(chunk, &process_payload)
|
|
441
413
|
end
|
|
442
414
|
end # conn.post
|
|
443
415
|
end # with_transport_errors
|
|
@@ -452,25 +424,13 @@ module RubyPi
|
|
|
452
424
|
handle_error_response(error_response, override_body: error_body_str)
|
|
453
425
|
end
|
|
454
426
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
next if data_str == "[DONE]"
|
|
462
|
-
begin
|
|
463
|
-
data = JSON.parse(data_str)
|
|
464
|
-
rescue JSON::ParserError
|
|
465
|
-
next
|
|
466
|
-
end
|
|
467
|
-
stream_state = process_anthropic_stream_event(
|
|
468
|
-
data, accumulated_text, accumulated_tool_calls,
|
|
469
|
-
current_tool_call, current_tool_json, usage_data, finish_reason, block
|
|
427
|
+
sse_parser.finish(&process_payload)
|
|
428
|
+
|
|
429
|
+
unless stream_completed
|
|
430
|
+
raise RubyPi::StreamingProtocolError.new(
|
|
431
|
+
"anthropic stream ended before message_stop",
|
|
432
|
+
status_code: nil
|
|
470
433
|
)
|
|
471
|
-
current_tool_call = stream_state[:current_tool_call]
|
|
472
|
-
current_tool_json = stream_state[:current_tool_json]
|
|
473
|
-
finish_reason = stream_state[:finish_reason]
|
|
474
434
|
end
|
|
475
435
|
|
|
476
436
|
# (Event processing is now handled incrementally by the on_data callback
|
|
@@ -532,11 +492,14 @@ module RubyPi
|
|
|
532
492
|
delta = data["delta"] || {}
|
|
533
493
|
if delta["type"] == "text_delta"
|
|
534
494
|
text = delta["text"] || ""
|
|
535
|
-
accumulated_text
|
|
495
|
+
append_stream_data!(accumulated_text, text, limit: MAX_STREAM_OUTPUT_BYTES, label: "stream output")
|
|
536
496
|
block.call(StreamEvent.new(type: :text_delta, data: text))
|
|
537
497
|
elsif delta["type"] == "input_json_delta"
|
|
538
498
|
json_chunk = delta["partial_json"] || ""
|
|
539
|
-
|
|
499
|
+
append_stream_data!(
|
|
500
|
+
current_tool_json, json_chunk,
|
|
501
|
+
limit: MAX_STREAM_TOOL_ARGUMENT_BYTES, label: "tool arguments"
|
|
502
|
+
)
|
|
540
503
|
block.call(StreamEvent.new(type: :tool_call_delta, data: {
|
|
541
504
|
id: current_tool_call&.dig(:id),
|
|
542
505
|
partial_json: json_chunk
|
|
@@ -554,11 +517,10 @@ module RubyPi
|
|
|
554
517
|
else
|
|
555
518
|
begin
|
|
556
519
|
JSON.parse(current_tool_json)
|
|
557
|
-
rescue JSON::ParserError
|
|
520
|
+
rescue JSON::ParserError
|
|
558
521
|
raise RubyPi::ProviderError.new(
|
|
559
522
|
"Failed to parse streaming tool call arguments for " \
|
|
560
|
-
"'#{current_tool_call[:name]}'
|
|
561
|
-
"(accumulated JSON: #{current_tool_json.inspect})",
|
|
523
|
+
"'#{current_tool_call[:name]}'",
|
|
562
524
|
provider: :anthropic
|
|
563
525
|
)
|
|
564
526
|
end
|
|
@@ -568,6 +530,7 @@ module RubyPi
|
|
|
568
530
|
name: current_tool_call[:name],
|
|
569
531
|
arguments: arguments
|
|
570
532
|
)
|
|
533
|
+
enforce_stream_tool_call_limit!(accumulated_tool_calls.size)
|
|
571
534
|
current_tool_call = nil
|
|
572
535
|
current_tool_json = +""
|
|
573
536
|
end
|
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
# providers (Gemini, Anthropic, OpenAI) must subclass this and implement the
|
|
8
8
|
# abstract methods.
|
|
9
9
|
|
|
10
|
+
require "json"
|
|
11
|
+
require "time"
|
|
12
|
+
|
|
10
13
|
module RubyPi
|
|
11
14
|
module LLM
|
|
12
15
|
# Abstract base class that defines the contract every LLM provider must
|
|
@@ -30,6 +33,11 @@ module RubyPi
|
|
|
30
33
|
# end
|
|
31
34
|
# end
|
|
32
35
|
class BaseProvider
|
|
36
|
+
MAX_STREAM_OUTPUT_BYTES = 16 * 1024 * 1024
|
|
37
|
+
MAX_STREAM_TOOL_ARGUMENT_BYTES = 1024 * 1024
|
|
38
|
+
MAX_STREAM_TOOL_CALLS = 128
|
|
39
|
+
MAX_ERROR_BODY_BYTES = 64 * 1024
|
|
40
|
+
RETRY_AFTER_CEILING = 60.0
|
|
33
41
|
# @return [Integer] maximum number of retry attempts
|
|
34
42
|
attr_reader :max_retries
|
|
35
43
|
|
|
@@ -53,6 +61,8 @@ module RubyPi
|
|
|
53
61
|
@max_retries = max_retries || @config.max_retries
|
|
54
62
|
@retry_base_delay = retry_base_delay || @config.retry_base_delay
|
|
55
63
|
@retry_max_delay = retry_max_delay || @config.retry_max_delay
|
|
64
|
+
|
|
65
|
+
validate_retry_configuration!
|
|
56
66
|
end
|
|
57
67
|
|
|
58
68
|
# Sends a completion request to the LLM provider with automatic retry
|
|
@@ -71,10 +81,22 @@ module RubyPi
|
|
|
71
81
|
# @raise [RubyPi::TimeoutError] on request timeouts
|
|
72
82
|
def complete(messages:, tools: [], stream: false, &block)
|
|
73
83
|
attempt = 0
|
|
84
|
+
partial_chars = 0
|
|
85
|
+
partial_tool_calls = false
|
|
86
|
+
|
|
87
|
+
attempt_block = if stream && block
|
|
88
|
+
proc do |event|
|
|
89
|
+
partial_chars += event.data.to_s.length if event.text_delta?
|
|
90
|
+
partial_tool_calls = true if event.tool_call_delta?
|
|
91
|
+
block.call(event)
|
|
92
|
+
end
|
|
93
|
+
else
|
|
94
|
+
block
|
|
95
|
+
end
|
|
74
96
|
|
|
75
97
|
begin
|
|
76
98
|
attempt += 1
|
|
77
|
-
perform_complete(messages: messages, tools: tools, stream: stream, &
|
|
99
|
+
perform_complete(messages: messages, tools: tools, stream: stream, &attempt_block)
|
|
78
100
|
rescue RubyPi::AuthenticationError
|
|
79
101
|
# Authentication errors are not retryable — raise immediately
|
|
80
102
|
raise
|
|
@@ -93,9 +115,23 @@ module RubyPi
|
|
|
93
115
|
# `attempt <= @max_retries` allows retries on attempts 1..3, so we get
|
|
94
116
|
# 3 retries + 1 initial = 4 total attempts. Previously used `< @max_retries`
|
|
95
117
|
# which was off-by-one (only 2 retries with max_retries: 3).
|
|
96
|
-
if attempt <= @max_retries
|
|
118
|
+
if retryable_error?(e) && attempt <= @max_retries
|
|
97
119
|
delay = retry_delay_for(e, attempt)
|
|
98
120
|
log_retry(attempt, delay, e)
|
|
121
|
+
|
|
122
|
+
if stream && block
|
|
123
|
+
block.call(StreamEvent.new(type: :retry_start, data: {
|
|
124
|
+
provider: provider_name,
|
|
125
|
+
error: e.message,
|
|
126
|
+
attempt: attempt + 1,
|
|
127
|
+
partial_output: partial_chars.positive? || partial_tool_calls,
|
|
128
|
+
partial_chars: partial_chars,
|
|
129
|
+
partial_tool_calls: partial_tool_calls
|
|
130
|
+
}))
|
|
131
|
+
partial_chars = 0
|
|
132
|
+
partial_tool_calls = false
|
|
133
|
+
end
|
|
134
|
+
|
|
99
135
|
sleep(delay)
|
|
100
136
|
retry
|
|
101
137
|
else
|
|
@@ -124,6 +160,31 @@ module RubyPi
|
|
|
124
160
|
|
|
125
161
|
private
|
|
126
162
|
|
|
163
|
+
def append_stream_data!(buffer, chunk, limit:, label:)
|
|
164
|
+
if buffer.bytesize + chunk.to_s.bytesize > limit
|
|
165
|
+
raise RubyPi::StreamingProtocolError.new(
|
|
166
|
+
"#{provider_name} #{label} exceeded #{limit} bytes",
|
|
167
|
+
status_code: nil
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
buffer << chunk
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def append_error_body(error_body, chunk)
|
|
175
|
+
remaining = MAX_ERROR_BODY_BYTES - error_body.bytesize
|
|
176
|
+
error_body << chunk.to_s.b.byteslice(0, remaining) if remaining.positive?
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def enforce_stream_tool_call_limit!(count)
|
|
180
|
+
return if count <= MAX_STREAM_TOOL_CALLS
|
|
181
|
+
|
|
182
|
+
raise RubyPi::StreamingProtocolError.new(
|
|
183
|
+
"#{provider_name} stream exceeded #{MAX_STREAM_TOOL_CALLS} tool calls",
|
|
184
|
+
status_code: nil
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
|
|
127
188
|
# Performs the actual completion request. Subclasses MUST implement this
|
|
128
189
|
# method with provider-specific HTTP logic.
|
|
129
190
|
#
|
|
@@ -132,15 +193,10 @@ module RubyPi
|
|
|
132
193
|
# @param stream [Boolean] streaming mode flag
|
|
133
194
|
# @yield [event] optional block for streaming events
|
|
134
195
|
# @return [RubyPi::LLM::Response]
|
|
135
|
-
def perform_complete(
|
|
196
|
+
def perform_complete(...)
|
|
136
197
|
raise RubyPi::AbstractMethodError, :perform_complete
|
|
137
198
|
end
|
|
138
199
|
|
|
139
|
-
# Maximum delay (seconds) honored from a server-provided Retry-After
|
|
140
|
-
# header. Caps pathological or misconfigured server values so a single
|
|
141
|
-
# 429 cannot stall the client indefinitely.
|
|
142
|
-
RETRY_AFTER_CEILING = 60.0
|
|
143
|
-
|
|
144
200
|
# Picks the delay before the next retry. A server-provided Retry-After
|
|
145
201
|
# on a 429 takes precedence over the local exponential backoff: the
|
|
146
202
|
# server knows its own cooldown window, and retrying earlier just burns
|
|
@@ -159,6 +215,17 @@ module RubyPi
|
|
|
159
215
|
end
|
|
160
216
|
end
|
|
161
217
|
|
|
218
|
+
# Only transient failures should be retried. Deterministic client errors
|
|
219
|
+
# (400, 404, 422, etc.) cannot improve on an identical retry and merely
|
|
220
|
+
# waste quota and add the full backoff delay.
|
|
221
|
+
def retryable_error?(error)
|
|
222
|
+
return true if error.is_a?(RubyPi::RateLimitError) || error.is_a?(RubyPi::TimeoutError)
|
|
223
|
+
return false unless error.is_a?(RubyPi::ApiError)
|
|
224
|
+
|
|
225
|
+
status = error.status_code
|
|
226
|
+
status.nil? || status == 408 || status == 409 || status == 425 || status >= 500
|
|
227
|
+
end
|
|
228
|
+
|
|
162
229
|
# Calculates the backoff delay for a given retry attempt using
|
|
163
230
|
# exponential backoff with jitter.
|
|
164
231
|
#
|
|
@@ -170,6 +237,27 @@ module RubyPi
|
|
|
170
237
|
[base + jitter, @retry_max_delay].min
|
|
171
238
|
end
|
|
172
239
|
|
|
240
|
+
# Validates constructor-level overrides. Configuration's writers perform
|
|
241
|
+
# the same checks, but provider keyword overrides bypass those writers.
|
|
242
|
+
# In particular, an infinite max_retries value makes the retry loop run
|
|
243
|
+
# forever because every finite attempt is <= Infinity.
|
|
244
|
+
def validate_retry_configuration!
|
|
245
|
+
unless @max_retries.is_a?(Integer) && @max_retries >= 0
|
|
246
|
+
raise ArgumentError,
|
|
247
|
+
"max_retries must be a non-negative integer, got #{@max_retries.inspect}"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
{
|
|
251
|
+
retry_base_delay: @retry_base_delay,
|
|
252
|
+
retry_max_delay: @retry_max_delay
|
|
253
|
+
}.each do |name, value|
|
|
254
|
+
next if value.is_a?(Numeric) && !value.is_a?(Complex) && value.finite? && value >= 0
|
|
255
|
+
|
|
256
|
+
raise ArgumentError,
|
|
257
|
+
"#{name} must be a finite non-negative number, got #{value.inspect}"
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
173
261
|
# Logs a retry attempt if a logger is configured.
|
|
174
262
|
#
|
|
175
263
|
# @param attempt [Integer] current attempt number
|
|
@@ -269,7 +357,7 @@ module RubyPi
|
|
|
269
357
|
response_body: body
|
|
270
358
|
)
|
|
271
359
|
when 429
|
|
272
|
-
retry_after = response.headers["retry-after"]
|
|
360
|
+
retry_after = parse_retry_after(response.headers["retry-after"])
|
|
273
361
|
raise RubyPi::RateLimitError.new(
|
|
274
362
|
"#{provider_name} rate limit exceeded (HTTP 429)",
|
|
275
363
|
retry_after: retry_after,
|
|
@@ -284,6 +372,36 @@ module RubyPi
|
|
|
284
372
|
end
|
|
285
373
|
end
|
|
286
374
|
|
|
375
|
+
# Parses a successful JSON response into a Hash while preserving the
|
|
376
|
+
# provider error contract. A proxy can truncate a nominal HTTP 200 body;
|
|
377
|
+
# treating that as a transient ApiError allows retry/fallback instead of
|
|
378
|
+
# leaking JSON::ParserError or accepting an empty response.
|
|
379
|
+
def parse_json_response(body)
|
|
380
|
+
JSON.parse(body)
|
|
381
|
+
rescue JSON::ParserError => e
|
|
382
|
+
raise RubyPi::ApiError.new(
|
|
383
|
+
"#{provider_name} returned malformed JSON: #{e.message}",
|
|
384
|
+
status_code: nil,
|
|
385
|
+
response_body: body
|
|
386
|
+
)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Parses both Retry-After forms allowed by HTTP: delta-seconds and an
|
|
390
|
+
# HTTP-date. Fractional seconds are retained for compatibility with API
|
|
391
|
+
# providers and fast test environments. Invalid or past dates return nil
|
|
392
|
+
# so the normal exponential backoff policy applies.
|
|
393
|
+
def parse_retry_after(value)
|
|
394
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
395
|
+
|
|
396
|
+
seconds = Float(value, exception: false)
|
|
397
|
+
return seconds if seconds&.finite? && seconds.positive?
|
|
398
|
+
|
|
399
|
+
delay = Time.httpdate(value.to_s) - Time.now
|
|
400
|
+
delay.positive? ? delay : nil
|
|
401
|
+
rescue ArgumentError
|
|
402
|
+
nil
|
|
403
|
+
end
|
|
404
|
+
|
|
287
405
|
end
|
|
288
406
|
end
|
|
289
407
|
end
|
data/lib/ruby_pi/llm/fallback.rb
CHANGED
|
@@ -157,8 +157,18 @@ module RubyPi
|
|
|
157
157
|
# carries partial_output/partial_chars so consumers can deterministically
|
|
158
158
|
# truncate what they already rendered.
|
|
159
159
|
partial_chars = 0
|
|
160
|
+
partial_tool_calls = false
|
|
160
161
|
counting_block = proc do |event|
|
|
161
|
-
|
|
162
|
+
if event.retry_start?
|
|
163
|
+
# The provider tells consumers to discard its previous attempt,
|
|
164
|
+
# so only count text from the current attempt for a later fallback.
|
|
165
|
+
partial_chars = 0
|
|
166
|
+
partial_tool_calls = false
|
|
167
|
+
elsif event.text_delta?
|
|
168
|
+
partial_chars += event.data.to_s.length
|
|
169
|
+
elsif event.tool_call_delta?
|
|
170
|
+
partial_tool_calls = true
|
|
171
|
+
end
|
|
162
172
|
block.call(event)
|
|
163
173
|
end
|
|
164
174
|
|
|
@@ -187,10 +197,11 @@ module RubyPi
|
|
|
187
197
|
# this amount if appending to a shared buffer)
|
|
188
198
|
block.call(StreamEvent.new(type: :fallback_start, data: {
|
|
189
199
|
failed_provider: @primary.provider_name,
|
|
190
|
-
error: e
|
|
200
|
+
error: safe_error_message(e),
|
|
191
201
|
fallback_provider: @fallback.provider_name,
|
|
192
|
-
partial_output: partial_chars.positive
|
|
193
|
-
partial_chars: partial_chars
|
|
202
|
+
partial_output: partial_chars.positive? || partial_tool_calls,
|
|
203
|
+
partial_chars: partial_chars,
|
|
204
|
+
partial_tool_calls: partial_tool_calls
|
|
194
205
|
}))
|
|
195
206
|
|
|
196
207
|
# Stream directly from the fallback to the consumer's block.
|
|
@@ -213,10 +224,14 @@ module RubyPi
|
|
|
213
224
|
|
|
214
225
|
logger.warn(
|
|
215
226
|
"[RubyPi::Fallback] Primary provider (#{@primary.provider_name}/#{@primary.model_name}) " \
|
|
216
|
-
"failed with #{error.class}: #{error
|
|
227
|
+
"failed with #{error.class}: #{safe_error_message(error)}. " \
|
|
217
228
|
"Falling back to #{@fallback.provider_name}/#{@fallback.model_name}."
|
|
218
229
|
)
|
|
219
230
|
end
|
|
231
|
+
|
|
232
|
+
def safe_error_message(error)
|
|
233
|
+
error.message.to_s.gsub(/[[:cntrl:]]+/, " ").slice(0, 512)
|
|
234
|
+
end
|
|
220
235
|
end
|
|
221
236
|
end
|
|
222
237
|
end
|
data/lib/ruby_pi/llm/gemini.rb
CHANGED
|
@@ -281,7 +281,7 @@ module RubyPi
|
|
|
281
281
|
end
|
|
282
282
|
|
|
283
283
|
handle_error_response(response) unless response.success?
|
|
284
|
-
parse_response(
|
|
284
|
+
parse_response(parse_json_response(response.body))
|
|
285
285
|
end
|
|
286
286
|
|
|
287
287
|
# Executes a streaming request to the Gemini API, yielding events.
|
|
@@ -301,20 +301,59 @@ module RubyPi
|
|
|
301
301
|
usage_data = {}
|
|
302
302
|
finish_reason = nil
|
|
303
303
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
# which may split SSE events mid-line. We accumulate a line buffer and
|
|
307
|
-
# process complete lines incrementally so that deltas reach the caller
|
|
308
|
-
# as soon as each SSE event is fully received.
|
|
309
|
-
# BINARY buffer: chunks arrive as ASCII-8BIT and may end mid-way
|
|
310
|
-
# through a multi-byte UTF-8 character; appending such a chunk to a
|
|
311
|
-
# UTF-8 buffer holding non-ASCII text raises
|
|
312
|
-
# Encoding::CompatibilityError. Complete lines are re-encoded to
|
|
313
|
-
# UTF-8 (and scrubbed) before parsing.
|
|
314
|
-
sse_buffer = (+"").force_encoding(Encoding::BINARY)
|
|
304
|
+
sse_parser = SSEParser.new(provider: provider_name)
|
|
305
|
+
stream_completed = false
|
|
315
306
|
response_status = nil
|
|
316
307
|
error_body = (+"").force_encoding(Encoding::BINARY)
|
|
317
308
|
|
|
309
|
+
process_payload = proc do |data_str|
|
|
310
|
+
next if data_str == "[DONE]"
|
|
311
|
+
|
|
312
|
+
data = sse_parser.parse_json(data_str)
|
|
313
|
+
block_reason = data.dig("promptFeedback", "blockReason")
|
|
314
|
+
if block_reason && !block_reason.to_s.empty?
|
|
315
|
+
stream_completed = true
|
|
316
|
+
finish_reason = block_reason.to_s.downcase
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
candidates = data["candidates"] || []
|
|
320
|
+
candidate = candidates.first
|
|
321
|
+
next unless candidate
|
|
322
|
+
|
|
323
|
+
parts = candidate.dig("content", "parts") || []
|
|
324
|
+
parts.each do |part|
|
|
325
|
+
if part.key?("text")
|
|
326
|
+
text_chunk = part["text"]
|
|
327
|
+
append_stream_data!(accumulated_text, text_chunk, limit: MAX_STREAM_OUTPUT_BYTES, label: "stream output")
|
|
328
|
+
block.call(StreamEvent.new(type: :text_delta, data: text_chunk))
|
|
329
|
+
elsif part.key?("functionCall")
|
|
330
|
+
fc = part["functionCall"]
|
|
331
|
+
tool_call = ToolCall.new(
|
|
332
|
+
id: "gemini_#{SecureRandom.hex(8)}",
|
|
333
|
+
name: fc["name"],
|
|
334
|
+
arguments: fc["args"] || {}
|
|
335
|
+
)
|
|
336
|
+
accumulated_tool_calls << tool_call
|
|
337
|
+
enforce_stream_tool_call_limit!(accumulated_tool_calls.size)
|
|
338
|
+
block.call(StreamEvent.new(type: :tool_call_delta, data: tool_call.to_h))
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
if candidate["finishReason"] && !candidate["finishReason"].to_s.empty?
|
|
343
|
+
finish_reason = candidate["finishReason"].to_s.downcase
|
|
344
|
+
stream_completed = true
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
if data.key?("usageMetadata")
|
|
348
|
+
meta = data["usageMetadata"]
|
|
349
|
+
usage_data = {
|
|
350
|
+
prompt_tokens: meta["promptTokenCount"],
|
|
351
|
+
completion_tokens: meta["candidatesTokenCount"],
|
|
352
|
+
total_tokens: meta["totalTokenCount"]
|
|
353
|
+
}
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
318
357
|
response = with_transport_errors do
|
|
319
358
|
conn.post(url) do |req|
|
|
320
359
|
req.headers["Content-Type"] = "application/json"
|
|
@@ -330,77 +369,11 @@ module RubyPi
|
|
|
330
369
|
# If the HTTP status indicates an error, accumulate the body for
|
|
331
370
|
# the error handler instead of parsing it as SSE events.
|
|
332
371
|
if response_status && response_status >= 400
|
|
333
|
-
error_body
|
|
372
|
+
append_error_body(error_body, chunk)
|
|
334
373
|
next
|
|
335
374
|
end
|
|
336
375
|
|
|
337
|
-
|
|
338
|
-
# Process all complete lines in the buffer. A complete line holds
|
|
339
|
-
# complete UTF-8 sequences (multi-byte characters split across
|
|
340
|
-
# chunks are repaired by the buffering), so re-encode it to UTF-8
|
|
341
|
-
# here; scrub guards against a server sending invalid bytes.
|
|
342
|
-
while (line_end = sse_buffer.index("\n"))
|
|
343
|
-
line = sse_buffer.slice!(0, line_end + 1).force_encoding(Encoding::UTF_8).scrub.strip
|
|
344
|
-
next if line.empty?
|
|
345
|
-
next unless line.start_with?("data: ")
|
|
346
|
-
|
|
347
|
-
data_str = line.sub(/\Adata: /, "")
|
|
348
|
-
next if data_str == "[DONE]"
|
|
349
|
-
|
|
350
|
-
begin
|
|
351
|
-
data = JSON.parse(data_str)
|
|
352
|
-
rescue JSON::ParserError
|
|
353
|
-
next
|
|
354
|
-
end
|
|
355
|
-
|
|
356
|
-
# Process this SSE event
|
|
357
|
-
candidates = data.dig("candidates") || []
|
|
358
|
-
candidate = candidates.first
|
|
359
|
-
next unless candidate
|
|
360
|
-
|
|
361
|
-
parts = candidate.dig("content", "parts") || []
|
|
362
|
-
parts.each do |part|
|
|
363
|
-
if part.key?("text")
|
|
364
|
-
text_chunk = part["text"]
|
|
365
|
-
accumulated_text << text_chunk
|
|
366
|
-
block.call(StreamEvent.new(type: :text_delta, data: text_chunk))
|
|
367
|
-
elsif part.key?("functionCall")
|
|
368
|
-
fc = part["functionCall"]
|
|
369
|
-
tool_call = ToolCall.new(
|
|
370
|
-
# Generate a globally-unique ID per tool call. A simple
|
|
371
|
-
# length-based counter ("gemini_0", "gemini_1") collides
|
|
372
|
-
# across turns since each response restarts numbering at
|
|
373
|
-
# 0, breaking any caller that uses ID as a hash key for
|
|
374
|
-
# observability or result correlation.
|
|
375
|
-
id: "gemini_#{SecureRandom.hex(8)}",
|
|
376
|
-
name: fc["name"],
|
|
377
|
-
arguments: fc["args"] || {}
|
|
378
|
-
)
|
|
379
|
-
accumulated_tool_calls << tool_call
|
|
380
|
-
block.call(StreamEvent.new(type: :tool_call_delta, data: tool_call.to_h))
|
|
381
|
-
end
|
|
382
|
-
end
|
|
383
|
-
|
|
384
|
-
# Parse the actual finish reason from the streaming response
|
|
385
|
-
# instead of hardcoding "stop". Gemini sends finishReason in
|
|
386
|
-
# the candidate object (e.g., "STOP", "MAX_TOKENS", "SAFETY").
|
|
387
|
-
# Coerce via to_s before downcase so a non-String payload can
|
|
388
|
-
# never raise NoMethodError mid-stream (mirrors the &.to_s in
|
|
389
|
-
# the non-streaming parse path).
|
|
390
|
-
if candidate["finishReason"]
|
|
391
|
-
finish_reason = candidate["finishReason"].to_s.downcase
|
|
392
|
-
end
|
|
393
|
-
|
|
394
|
-
# Capture usage metadata if present
|
|
395
|
-
if data.key?("usageMetadata")
|
|
396
|
-
meta = data["usageMetadata"]
|
|
397
|
-
usage_data = {
|
|
398
|
-
prompt_tokens: meta["promptTokenCount"],
|
|
399
|
-
completion_tokens: meta["candidatesTokenCount"],
|
|
400
|
-
total_tokens: meta["totalTokenCount"]
|
|
401
|
-
}
|
|
402
|
-
end
|
|
403
|
-
end
|
|
376
|
+
sse_parser.feed(chunk, &process_payload)
|
|
404
377
|
end
|
|
405
378
|
end # conn.post
|
|
406
379
|
end # with_transport_errors
|
|
@@ -413,6 +386,15 @@ module RubyPi
|
|
|
413
386
|
handle_error_response(response, override_body: error_body_str)
|
|
414
387
|
end
|
|
415
388
|
|
|
389
|
+
sse_parser.finish(&process_payload)
|
|
390
|
+
|
|
391
|
+
unless stream_completed
|
|
392
|
+
raise RubyPi::StreamingProtocolError.new(
|
|
393
|
+
"gemini stream ended before a finish reason",
|
|
394
|
+
status_code: nil
|
|
395
|
+
)
|
|
396
|
+
end
|
|
397
|
+
|
|
416
398
|
# Signal completion
|
|
417
399
|
block.call(StreamEvent.new(type: :done))
|
|
418
400
|
|
|
@@ -420,7 +402,7 @@ module RubyPi
|
|
|
420
402
|
content: accumulated_text.empty? ? nil : accumulated_text,
|
|
421
403
|
tool_calls: accumulated_tool_calls,
|
|
422
404
|
usage: usage_data,
|
|
423
|
-
finish_reason: finish_reason
|
|
405
|
+
finish_reason: finish_reason
|
|
424
406
|
)
|
|
425
407
|
end
|
|
426
408
|
|