ruby-pi 0.1.6 → 0.1.9
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 +63 -0
- data/README.md +13 -6
- 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 +66 -27
- data/lib/ruby_pi/agent/result.rb +39 -11
- data/lib/ruby_pi/agent/state.rb +31 -5
- data/lib/ruby_pi/configuration.rb +53 -5
- data/lib/ruby_pi/context/compaction.rb +59 -50
- data/lib/ruby_pi/llm/anthropic.rb +27 -10
- data/lib/ruby_pi/llm/base_provider.rb +126 -5
- data/lib/ruby_pi/llm/fallback.rb +36 -9
- data/lib/ruby_pi/llm/gemini.rb +25 -11
- data/lib/ruby_pi/llm/openai.rb +18 -8
- data/lib/ruby_pi/llm/stream_event.rb +10 -3
- data/lib/ruby_pi/llm/tool_call.rb +2 -0
- data/lib/ruby_pi/tools/definition.rb +39 -4
- data/lib/ruby_pi/tools/executor.rb +14 -6
- data/lib/ruby_pi/tools/schema.rb +10 -0
- data/lib/ruby_pi/version.rb +1 -1
- data/lib/ruby_pi.rb +7 -0
- metadata +17 -3
|
@@ -87,34 +87,22 @@ module RubyPi
|
|
|
87
87
|
# call but keep the matching tool_result, the API rejects the
|
|
88
88
|
# request with "tool_result without preceding tool_use".
|
|
89
89
|
#
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
|
|
99
|
-
# stays intact.
|
|
100
|
-
#
|
|
101
|
-
# We apply (a) first: it's the common case (preserve_last_n=4 cuts
|
|
102
|
-
# mid-pair, leaving a stranded tool message). Then (b) catches the
|
|
103
|
-
# mirror case.
|
|
104
|
-
while preserved.first && preserved.first[:role] == :tool
|
|
90
|
+
# When the boundary between droppable and preserved cuts mid-exchange,
|
|
91
|
+
# preserved can start with one or more orphan :tool messages whose
|
|
92
|
+
# matching assistant turn is in droppable. Strip those off the head of
|
|
93
|
+
# preserved and move them into droppable so they are summarized away
|
|
94
|
+
# rather than sent. Because the originating assistant message is older,
|
|
95
|
+
# it is already in droppable, so the pair stays together there — there
|
|
96
|
+
# is no mirror case to handle (once a tool result is moved across, its
|
|
97
|
+
# assistant is never left stranded on the preserved side).
|
|
98
|
+
while preserved.first && message_value(preserved.first, :role).to_s == "tool"
|
|
105
99
|
droppable << preserved.shift
|
|
106
100
|
end
|
|
107
101
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
preserved.first && preserved.first[:role] == :tool
|
|
113
|
-
preserved.unshift(droppable.pop)
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
# After the boundary fix-ups, droppable may have become empty.
|
|
117
|
-
return nil if droppable.empty?
|
|
102
|
+
# The orphan-strip only moves messages INTO droppable, so droppable
|
|
103
|
+
# cannot have shrunk; it is still non-empty here. preserved, however,
|
|
104
|
+
# may now be empty (the whole window was tool results) — the summary
|
|
105
|
+
# construction below handles that case.
|
|
118
106
|
|
|
119
107
|
# Generate a summary of the dropped messages
|
|
120
108
|
summary = summarize(droppable)
|
|
@@ -122,28 +110,43 @@ module RubyPi
|
|
|
122
110
|
# Emit compaction event if an emitter is available
|
|
123
111
|
@emitter&.emit(:compaction, dropped_count: droppable.size, summary: summary)
|
|
124
112
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
113
|
+
build_compacted_history(summary, preserved)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Builds the compacted history: a summary message followed by the
|
|
117
|
+
# preserved tail.
|
|
118
|
+
#
|
|
119
|
+
# The summary becomes the FIRST message of the compacted history, so it
|
|
120
|
+
# must satisfy the strictest provider constraints (Anthropic):
|
|
121
|
+
# 1. The summary role MUST NOT be :system — that would overwrite the
|
|
122
|
+
# real system prompt on Anthropic, which promotes the last :system
|
|
123
|
+
# message to the top-level `system:` parameter.
|
|
124
|
+
# 2. The first message MUST use role :user.
|
|
125
|
+
# 3. Consecutive same-role messages are rejected.
|
|
126
|
+
#
|
|
127
|
+
# A :user summary satisfies (1) and (2). For (3): the orphan-strip above
|
|
128
|
+
# guarantees the first preserved message is :assistant, :user, or absent
|
|
129
|
+
# (never :tool). When it is :assistant or absent, a standalone :user
|
|
130
|
+
# summary alternates correctly. When it is :user, a separate :user
|
|
131
|
+
# summary would create two consecutive user messages, so we instead
|
|
132
|
+
# merge the summary text into that existing user message — keeping the
|
|
133
|
+
# first message a single :user message with no role collision.
|
|
134
|
+
#
|
|
135
|
+
# @param summary [String] the generated summary text
|
|
136
|
+
# @param preserved [Array<Hash>] the preserved tail of messages
|
|
137
|
+
# @return [Array<Hash>] the compacted history
|
|
138
|
+
def build_compacted_history(summary, preserved)
|
|
139
|
+
summary_text = "[Conversation Summary]\n#{summary}"
|
|
140
|
+
first_preserved = preserved.first
|
|
141
|
+
|
|
142
|
+
if first_preserved && message_value(first_preserved, :role).to_s == "user"
|
|
143
|
+
merged = first_preserved.dup
|
|
144
|
+
content_key = first_preserved.key?(:content) ? :content : "content"
|
|
145
|
+
merged[content_key] = "#{summary_text}\n\n#{message_value(first_preserved, :content)}"
|
|
146
|
+
[merged] + preserved.drop(1)
|
|
147
|
+
else
|
|
148
|
+
[{ role: :user, content: summary_text }] + preserved
|
|
149
|
+
end
|
|
147
150
|
end
|
|
148
151
|
|
|
149
152
|
# Estimates the total token count for a system prompt and message array
|
|
@@ -156,7 +159,7 @@ module RubyPi
|
|
|
156
159
|
total_chars = system_prompt.to_s.length
|
|
157
160
|
|
|
158
161
|
messages.each do |msg|
|
|
159
|
-
total_chars += msg
|
|
162
|
+
total_chars += message_value(msg, :content).to_s.length
|
|
160
163
|
# Account for role and structural overhead (~10 tokens per message)
|
|
161
164
|
total_chars += 40
|
|
162
165
|
end
|
|
@@ -197,14 +200,20 @@ module RubyPi
|
|
|
197
200
|
# @return [String] formatted prompt for summarization
|
|
198
201
|
def build_summary_prompt(messages)
|
|
199
202
|
transcript = messages.map do |msg|
|
|
200
|
-
role = msg
|
|
201
|
-
content = msg
|
|
203
|
+
role = message_value(msg, :role).to_s.capitalize
|
|
204
|
+
content = message_value(msg, :content).to_s
|
|
202
205
|
"#{role}: #{content}"
|
|
203
206
|
end.join("\n\n")
|
|
204
207
|
|
|
205
208
|
"Summarize the following conversation, preserving all key facts, " \
|
|
206
209
|
"decisions, and tool call results:\n\n#{transcript}"
|
|
207
210
|
end
|
|
211
|
+
|
|
212
|
+
# Reads normalized symbol-keyed messages and histories loaded from JSON,
|
|
213
|
+
# whose top-level keys are strings.
|
|
214
|
+
def message_value(message, key)
|
|
215
|
+
message[key] || message[key.to_s]
|
|
216
|
+
end
|
|
208
217
|
end
|
|
209
218
|
end
|
|
210
219
|
end
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
# the Anthropic Messages API for both synchronous and streaming completions,
|
|
7
7
|
# including tool_use block support.
|
|
8
8
|
|
|
9
|
+
require "json"
|
|
10
|
+
|
|
9
11
|
module RubyPi
|
|
10
12
|
module LLM
|
|
11
13
|
# Anthropic Claude provider implementation. Communicates with the Anthropic
|
|
@@ -338,7 +340,7 @@ module RubyPi
|
|
|
338
340
|
end
|
|
339
341
|
|
|
340
342
|
handle_error_response(response) unless response.success?
|
|
341
|
-
parse_response(
|
|
343
|
+
parse_response(parse_json_response(response.body))
|
|
342
344
|
end
|
|
343
345
|
|
|
344
346
|
# Executes a streaming request to the Anthropic API, yielding events.
|
|
@@ -370,12 +372,19 @@ module RubyPi
|
|
|
370
372
|
# process complete lines incrementally so that deltas reach the caller
|
|
371
373
|
# as soon as each SSE event is fully received — not after the entire
|
|
372
374
|
# response has been buffered.
|
|
373
|
-
|
|
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)
|
|
374
383
|
response_status = nil
|
|
375
384
|
|
|
376
385
|
# Accumulate error response body separately so ApiError gets the
|
|
377
386
|
# full body even though on_data consumed the chunks.
|
|
378
|
-
error_body = +""
|
|
387
|
+
error_body = (+"").force_encoding(Encoding::BINARY)
|
|
379
388
|
|
|
380
389
|
response = with_transport_errors do
|
|
381
390
|
conn.post("/v1/messages") do |req|
|
|
@@ -394,14 +403,17 @@ module RubyPi
|
|
|
394
403
|
# calls on_data for error responses too, which would otherwise
|
|
395
404
|
# consume the body and leave response.body empty.
|
|
396
405
|
if response_status && response_status >= 400
|
|
397
|
-
error_body << chunk
|
|
406
|
+
error_body << chunk.b
|
|
398
407
|
next
|
|
399
408
|
end
|
|
400
409
|
|
|
401
|
-
sse_buffer << chunk
|
|
402
|
-
# Process all complete lines in the buffer
|
|
410
|
+
sse_buffer << chunk.b
|
|
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.
|
|
403
415
|
while (line_end = sse_buffer.index("\n"))
|
|
404
|
-
line = sse_buffer.slice!(0, line_end + 1).strip
|
|
416
|
+
line = sse_buffer.slice!(0, line_end + 1).force_encoding(Encoding::UTF_8).scrub.strip
|
|
405
417
|
next if line.empty?
|
|
406
418
|
next unless line.start_with?("data: ")
|
|
407
419
|
|
|
@@ -436,12 +448,12 @@ module RubyPi
|
|
|
436
448
|
unless response.success?
|
|
437
449
|
# Reconstruct the response body from what on_data accumulated
|
|
438
450
|
error_response = response
|
|
439
|
-
error_body_str = error_body.empty? ? response.body : error_body
|
|
451
|
+
error_body_str = error_body.empty? ? response.body : error_body.force_encoding(Encoding::UTF_8).scrub
|
|
440
452
|
handle_error_response(error_response, override_body: error_body_str)
|
|
441
453
|
end
|
|
442
454
|
|
|
443
455
|
# Process any remaining data in the buffer after the connection closes
|
|
444
|
-
sse_buffer.each_line do |line|
|
|
456
|
+
sse_buffer.force_encoding(Encoding::UTF_8).scrub.each_line do |line|
|
|
445
457
|
line = line.strip
|
|
446
458
|
next if line.empty?
|
|
447
459
|
next unless line.start_with?("data: ")
|
|
@@ -562,7 +574,12 @@ module RubyPi
|
|
|
562
574
|
|
|
563
575
|
when "message_delta"
|
|
564
576
|
delta = data["delta"] || {}
|
|
565
|
-
finish_reason
|
|
577
|
+
# Only overwrite finish_reason when this delta actually carries a
|
|
578
|
+
# stop_reason. Anthropic emits the stop_reason on a single
|
|
579
|
+
# message_delta near the end of the stream; a later message_delta
|
|
580
|
+
# without one must not clobber the captured value back to nil
|
|
581
|
+
# (which would yield a Response with no finish_reason).
|
|
582
|
+
finish_reason = delta["stop_reason"] if delta["stop_reason"]
|
|
566
583
|
if data.key?("usage")
|
|
567
584
|
usage_info = data["usage"]
|
|
568
585
|
usage_data[:completion_tokens] = usage_info["output_tokens"]
|
|
@@ -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
|
|
@@ -53,6 +56,8 @@ module RubyPi
|
|
|
53
56
|
@max_retries = max_retries || @config.max_retries
|
|
54
57
|
@retry_base_delay = retry_base_delay || @config.retry_base_delay
|
|
55
58
|
@retry_max_delay = retry_max_delay || @config.retry_max_delay
|
|
59
|
+
|
|
60
|
+
validate_retry_configuration!
|
|
56
61
|
end
|
|
57
62
|
|
|
58
63
|
# Sends a completion request to the LLM provider with automatic retry
|
|
@@ -71,22 +76,53 @@ module RubyPi
|
|
|
71
76
|
# @raise [RubyPi::TimeoutError] on request timeouts
|
|
72
77
|
def complete(messages:, tools: [], stream: false, &block)
|
|
73
78
|
attempt = 0
|
|
79
|
+
partial_chars = 0
|
|
80
|
+
|
|
81
|
+
attempt_block = if stream && block
|
|
82
|
+
proc do |event|
|
|
83
|
+
partial_chars += event.data.to_s.length if event.text_delta?
|
|
84
|
+
block.call(event)
|
|
85
|
+
end
|
|
86
|
+
else
|
|
87
|
+
block
|
|
88
|
+
end
|
|
74
89
|
|
|
75
90
|
begin
|
|
76
91
|
attempt += 1
|
|
77
|
-
perform_complete(messages: messages, tools: tools, stream: stream, &
|
|
92
|
+
perform_complete(messages: messages, tools: tools, stream: stream, &attempt_block)
|
|
78
93
|
rescue RubyPi::AuthenticationError
|
|
79
94
|
# Authentication errors are not retryable — raise immediately
|
|
80
95
|
raise
|
|
81
|
-
rescue RubyPi::RateLimitError, RubyPi::ApiError, RubyPi::TimeoutError
|
|
96
|
+
rescue RubyPi::RateLimitError, RubyPi::ApiError, RubyPi::TimeoutError => e
|
|
97
|
+
# NOTE: RubyPi::ProviderError is intentionally NOT retried. Provider
|
|
98
|
+
# errors are overwhelmingly deterministic request-construction
|
|
99
|
+
# failures (missing tool_call_id, invalid tool-argument JSON, missing
|
|
100
|
+
# tool name) raised by build_request_body BEFORE any HTTP call. They
|
|
101
|
+
# produce the identical error on every attempt, so retrying only
|
|
102
|
+
# burns the backoff schedule before surfacing the same failure.
|
|
103
|
+
# Fallback wrappers still rescue RubyPi::Error (the ProviderError
|
|
104
|
+
# superclass), so provider failover is unaffected.
|
|
105
|
+
#
|
|
82
106
|
# Retry up to max_retries times AFTER the initial attempt.
|
|
83
107
|
# With max_retries: 3, attempt goes 1 (initial), 2, 3, 4 — the condition
|
|
84
108
|
# `attempt <= @max_retries` allows retries on attempts 1..3, so we get
|
|
85
109
|
# 3 retries + 1 initial = 4 total attempts. Previously used `< @max_retries`
|
|
86
110
|
# which was off-by-one (only 2 retries with max_retries: 3).
|
|
87
|
-
if attempt <= @max_retries
|
|
88
|
-
delay =
|
|
111
|
+
if retryable_error?(e) && attempt <= @max_retries
|
|
112
|
+
delay = retry_delay_for(e, attempt)
|
|
89
113
|
log_retry(attempt, delay, e)
|
|
114
|
+
|
|
115
|
+
if stream && block
|
|
116
|
+
block.call(StreamEvent.new(type: :retry_start, data: {
|
|
117
|
+
provider: provider_name,
|
|
118
|
+
error: e.message,
|
|
119
|
+
attempt: attempt + 1,
|
|
120
|
+
partial_output: partial_chars.positive?,
|
|
121
|
+
partial_chars: partial_chars
|
|
122
|
+
}))
|
|
123
|
+
partial_chars = 0
|
|
124
|
+
end
|
|
125
|
+
|
|
90
126
|
sleep(delay)
|
|
91
127
|
retry
|
|
92
128
|
else
|
|
@@ -127,6 +163,40 @@ module RubyPi
|
|
|
127
163
|
raise RubyPi::AbstractMethodError, :perform_complete
|
|
128
164
|
end
|
|
129
165
|
|
|
166
|
+
# Maximum delay (seconds) honored from a server-provided Retry-After
|
|
167
|
+
# header. Caps pathological or misconfigured server values so a single
|
|
168
|
+
# 429 cannot stall the client indefinitely.
|
|
169
|
+
RETRY_AFTER_CEILING = 60.0
|
|
170
|
+
|
|
171
|
+
# Picks the delay before the next retry. A server-provided Retry-After
|
|
172
|
+
# on a 429 takes precedence over the local exponential backoff: the
|
|
173
|
+
# server knows its own cooldown window, and retrying earlier just burns
|
|
174
|
+
# the retry budget against guaranteed 429s. Retry-After parsed from an
|
|
175
|
+
# HTTP-date (rather than delta-seconds) arrives as 0.0 and falls through
|
|
176
|
+
# to the computed backoff.
|
|
177
|
+
#
|
|
178
|
+
# @param error [Exception] the error that triggered the retry
|
|
179
|
+
# @param attempt [Integer] the current attempt number (1-based)
|
|
180
|
+
# @return [Float] delay in seconds
|
|
181
|
+
def retry_delay_for(error, attempt)
|
|
182
|
+
if error.is_a?(RubyPi::RateLimitError) && error.retry_after&.positive?
|
|
183
|
+
[error.retry_after, RETRY_AFTER_CEILING].min
|
|
184
|
+
else
|
|
185
|
+
calculate_backoff(attempt)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Only transient failures should be retried. Deterministic client errors
|
|
190
|
+
# (400, 404, 422, etc.) cannot improve on an identical retry and merely
|
|
191
|
+
# waste quota and add the full backoff delay.
|
|
192
|
+
def retryable_error?(error)
|
|
193
|
+
return true if error.is_a?(RubyPi::RateLimitError) || error.is_a?(RubyPi::TimeoutError)
|
|
194
|
+
return false unless error.is_a?(RubyPi::ApiError)
|
|
195
|
+
|
|
196
|
+
status = error.status_code
|
|
197
|
+
status.nil? || status == 408 || status == 409 || status == 425 || status >= 500
|
|
198
|
+
end
|
|
199
|
+
|
|
130
200
|
# Calculates the backoff delay for a given retry attempt using
|
|
131
201
|
# exponential backoff with jitter.
|
|
132
202
|
#
|
|
@@ -138,6 +208,27 @@ module RubyPi
|
|
|
138
208
|
[base + jitter, @retry_max_delay].min
|
|
139
209
|
end
|
|
140
210
|
|
|
211
|
+
# Validates constructor-level overrides. Configuration's writers perform
|
|
212
|
+
# the same checks, but provider keyword overrides bypass those writers.
|
|
213
|
+
# In particular, an infinite max_retries value makes the retry loop run
|
|
214
|
+
# forever because every finite attempt is <= Infinity.
|
|
215
|
+
def validate_retry_configuration!
|
|
216
|
+
unless @max_retries.is_a?(Integer) && @max_retries >= 0
|
|
217
|
+
raise ArgumentError,
|
|
218
|
+
"max_retries must be a non-negative integer, got #{@max_retries.inspect}"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
{
|
|
222
|
+
retry_base_delay: @retry_base_delay,
|
|
223
|
+
retry_max_delay: @retry_max_delay
|
|
224
|
+
}.each do |name, value|
|
|
225
|
+
next if value.is_a?(Numeric) && !value.is_a?(Complex) && value.finite? && value >= 0
|
|
226
|
+
|
|
227
|
+
raise ArgumentError,
|
|
228
|
+
"#{name} must be a finite non-negative number, got #{value.inspect}"
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
141
232
|
# Logs a retry attempt if a logger is configured.
|
|
142
233
|
#
|
|
143
234
|
# @param attempt [Integer] current attempt number
|
|
@@ -237,7 +328,7 @@ module RubyPi
|
|
|
237
328
|
response_body: body
|
|
238
329
|
)
|
|
239
330
|
when 429
|
|
240
|
-
retry_after = response.headers["retry-after"]
|
|
331
|
+
retry_after = parse_retry_after(response.headers["retry-after"])
|
|
241
332
|
raise RubyPi::RateLimitError.new(
|
|
242
333
|
"#{provider_name} rate limit exceeded (HTTP 429)",
|
|
243
334
|
retry_after: retry_after,
|
|
@@ -252,6 +343,36 @@ module RubyPi
|
|
|
252
343
|
end
|
|
253
344
|
end
|
|
254
345
|
|
|
346
|
+
# Parses a successful JSON response into a Hash while preserving the
|
|
347
|
+
# provider error contract. A proxy can truncate a nominal HTTP 200 body;
|
|
348
|
+
# treating that as a transient ApiError allows retry/fallback instead of
|
|
349
|
+
# leaking JSON::ParserError or accepting an empty response.
|
|
350
|
+
def parse_json_response(body)
|
|
351
|
+
JSON.parse(body)
|
|
352
|
+
rescue JSON::ParserError => e
|
|
353
|
+
raise RubyPi::ApiError.new(
|
|
354
|
+
"#{provider_name} returned malformed JSON: #{e.message}",
|
|
355
|
+
status_code: nil,
|
|
356
|
+
response_body: body
|
|
357
|
+
)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Parses both Retry-After forms allowed by HTTP: delta-seconds and an
|
|
361
|
+
# HTTP-date. Fractional seconds are retained for compatibility with API
|
|
362
|
+
# providers and fast test environments. Invalid or past dates return nil
|
|
363
|
+
# so the normal exponential backoff policy applies.
|
|
364
|
+
def parse_retry_after(value)
|
|
365
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
366
|
+
|
|
367
|
+
seconds = Float(value, exception: false)
|
|
368
|
+
return seconds if seconds&.finite? && seconds.positive?
|
|
369
|
+
|
|
370
|
+
delay = Time.httpdate(value.to_s) - Time.now
|
|
371
|
+
delay.positive? ? delay : nil
|
|
372
|
+
rescue ArgumentError
|
|
373
|
+
nil
|
|
374
|
+
end
|
|
375
|
+
|
|
255
376
|
end
|
|
256
377
|
end
|
|
257
378
|
end
|
data/lib/ruby_pi/llm/fallback.rb
CHANGED
|
@@ -16,11 +16,14 @@ module RubyPi
|
|
|
16
16
|
# Authentication errors are NOT retried with the fallback since they
|
|
17
17
|
# indicate a configuration problem rather than a transient failure.
|
|
18
18
|
#
|
|
19
|
-
# Issue #23: When streaming,
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
19
|
+
# Issue #23 + Issue #12: When streaming, events flow from the primary
|
|
20
|
+
# provider directly to the consumer in real time (no buffering), preserving
|
|
21
|
+
# the streaming UX on the happy path. If the primary fails mid-stream, a
|
|
22
|
+
# :fallback_start StreamEvent is emitted before the fallback takes over, so
|
|
23
|
+
# the consumer can discard any partial output already rendered from the
|
|
24
|
+
# failed primary. (The agent loop translates :fallback_start into a
|
|
25
|
+
# :provider_fallback event; raw Fallback consumers should handle
|
|
26
|
+
# :fallback_start themselves.)
|
|
24
27
|
#
|
|
25
28
|
# @example Setting up a fallback chain
|
|
26
29
|
# primary = RubyPi::LLM.model(:gemini, "gemini-2.0-flash")
|
|
@@ -146,6 +149,25 @@ module RubyPi
|
|
|
146
149
|
# @yield [event] the consumer's streaming block
|
|
147
150
|
# @return [RubyPi::LLM::Response]
|
|
148
151
|
def perform_complete_with_streaming_fallback(messages:, tools:, &block)
|
|
152
|
+
# Count the characters of text already delivered to the consumer from
|
|
153
|
+
# the primary. If the primary fails mid-stream AFTER yielding text,
|
|
154
|
+
# the fallback streams a complete fresh response — a consumer that
|
|
155
|
+
# merely appends deltas would render the primary's partial text
|
|
156
|
+
# followed by the full fallback text. The :fallback_start payload
|
|
157
|
+
# carries partial_output/partial_chars so consumers can deterministically
|
|
158
|
+
# truncate what they already rendered.
|
|
159
|
+
partial_chars = 0
|
|
160
|
+
counting_block = proc do |event|
|
|
161
|
+
if event.retry_start?
|
|
162
|
+
# The provider tells consumers to discard its previous attempt,
|
|
163
|
+
# so only count text from the current attempt for a later fallback.
|
|
164
|
+
partial_chars = 0
|
|
165
|
+
elsif event.text_delta?
|
|
166
|
+
partial_chars += event.data.to_s.length
|
|
167
|
+
end
|
|
168
|
+
block.call(event)
|
|
169
|
+
end
|
|
170
|
+
|
|
149
171
|
begin
|
|
150
172
|
# Stream primary events directly to the consumer for real-time UX.
|
|
151
173
|
# No buffering — tokens appear immediately as they arrive.
|
|
@@ -153,7 +175,7 @@ module RubyPi
|
|
|
153
175
|
messages: messages,
|
|
154
176
|
tools: tools,
|
|
155
177
|
stream: true,
|
|
156
|
-
&
|
|
178
|
+
&counting_block
|
|
157
179
|
)
|
|
158
180
|
|
|
159
181
|
response
|
|
@@ -164,12 +186,17 @@ module RubyPi
|
|
|
164
186
|
log_fallback(e)
|
|
165
187
|
|
|
166
188
|
# Signal the consumer that the primary failed mid-stream and a
|
|
167
|
-
# fallback provider is taking over. Consumers
|
|
168
|
-
# to clear any partial output from the failed primary
|
|
189
|
+
# fallback provider is taking over. Consumers MUST use this event
|
|
190
|
+
# to clear any partial output from the failed primary:
|
|
191
|
+
# partial_output — true when the primary yielded any text deltas
|
|
192
|
+
# partial_chars — how many characters were yielded (truncate by
|
|
193
|
+
# this amount if appending to a shared buffer)
|
|
169
194
|
block.call(StreamEvent.new(type: :fallback_start, data: {
|
|
170
195
|
failed_provider: @primary.provider_name,
|
|
171
196
|
error: e.message,
|
|
172
|
-
fallback_provider: @fallback.provider_name
|
|
197
|
+
fallback_provider: @fallback.provider_name,
|
|
198
|
+
partial_output: partial_chars.positive?,
|
|
199
|
+
partial_chars: partial_chars
|
|
173
200
|
}))
|
|
174
201
|
|
|
175
202
|
# Stream directly from the fallback to the consumer's block.
|
data/lib/ruby_pi/llm/gemini.rb
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
# the Gemini REST API for both synchronous and streaming completions, including
|
|
7
7
|
# tool/function calling support.
|
|
8
8
|
|
|
9
|
+
require "json"
|
|
9
10
|
require "securerandom"
|
|
10
11
|
|
|
11
12
|
module RubyPi
|
|
@@ -280,7 +281,7 @@ module RubyPi
|
|
|
280
281
|
end
|
|
281
282
|
|
|
282
283
|
handle_error_response(response) unless response.success?
|
|
283
|
-
parse_response(
|
|
284
|
+
parse_response(parse_json_response(response.body))
|
|
284
285
|
end
|
|
285
286
|
|
|
286
287
|
# Executes a streaming request to the Gemini API, yielding events.
|
|
@@ -305,9 +306,14 @@ module RubyPi
|
|
|
305
306
|
# which may split SSE events mid-line. We accumulate a line buffer and
|
|
306
307
|
# process complete lines incrementally so that deltas reach the caller
|
|
307
308
|
# as soon as each SSE event is fully received.
|
|
308
|
-
|
|
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)
|
|
309
315
|
response_status = nil
|
|
310
|
-
error_body = +""
|
|
316
|
+
error_body = (+"").force_encoding(Encoding::BINARY)
|
|
311
317
|
|
|
312
318
|
response = with_transport_errors do
|
|
313
319
|
conn.post(url) do |req|
|
|
@@ -324,14 +330,17 @@ module RubyPi
|
|
|
324
330
|
# If the HTTP status indicates an error, accumulate the body for
|
|
325
331
|
# the error handler instead of parsing it as SSE events.
|
|
326
332
|
if response_status && response_status >= 400
|
|
327
|
-
error_body << chunk
|
|
333
|
+
error_body << chunk.b
|
|
328
334
|
next
|
|
329
335
|
end
|
|
330
336
|
|
|
331
|
-
sse_buffer << chunk
|
|
332
|
-
# Process all complete lines in the buffer
|
|
337
|
+
sse_buffer << chunk.b
|
|
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.
|
|
333
342
|
while (line_end = sse_buffer.index("\n"))
|
|
334
|
-
line = sse_buffer.slice!(0, line_end + 1).strip
|
|
343
|
+
line = sse_buffer.slice!(0, line_end + 1).force_encoding(Encoding::UTF_8).scrub.strip
|
|
335
344
|
next if line.empty?
|
|
336
345
|
next unless line.start_with?("data: ")
|
|
337
346
|
|
|
@@ -375,8 +384,11 @@ module RubyPi
|
|
|
375
384
|
# Parse the actual finish reason from the streaming response
|
|
376
385
|
# instead of hardcoding "stop". Gemini sends finishReason in
|
|
377
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).
|
|
378
390
|
if candidate["finishReason"]
|
|
379
|
-
finish_reason = candidate["finishReason"].downcase
|
|
391
|
+
finish_reason = candidate["finishReason"].to_s.downcase
|
|
380
392
|
end
|
|
381
393
|
|
|
382
394
|
# Capture usage metadata if present
|
|
@@ -397,7 +409,7 @@ module RubyPi
|
|
|
397
409
|
# callback. Pass the accumulated error_body so ApiError carries the
|
|
398
410
|
# full server message instead of an empty body.
|
|
399
411
|
unless response.success?
|
|
400
|
-
error_body_str = error_body.empty? ? response.body : error_body
|
|
412
|
+
error_body_str = error_body.empty? ? response.body : error_body.force_encoding(Encoding::UTF_8).scrub
|
|
401
413
|
handle_error_response(response, override_body: error_body_str)
|
|
402
414
|
end
|
|
403
415
|
|
|
@@ -450,8 +462,10 @@ module RubyPi
|
|
|
450
462
|
}
|
|
451
463
|
end
|
|
452
464
|
|
|
453
|
-
# Map Gemini finish reason to normalized string
|
|
454
|
-
|
|
465
|
+
# Map Gemini finish reason to normalized string. to_s guards against
|
|
466
|
+
# a non-String payload (mirrors the streaming path); &. keeps a
|
|
467
|
+
# missing finishReason as nil.
|
|
468
|
+
finish_reason = candidate["finishReason"]&.to_s&.downcase
|
|
455
469
|
|
|
456
470
|
Response.new(
|
|
457
471
|
content: content,
|
data/lib/ruby_pi/llm/openai.rb
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
# OpenAI Chat Completions API for both synchronous and streaming completions,
|
|
7
7
|
# including function/tool calling support.
|
|
8
8
|
|
|
9
|
+
require "json"
|
|
10
|
+
|
|
9
11
|
module RubyPi
|
|
10
12
|
module LLM
|
|
11
13
|
# OpenAI provider implementation. Communicates with the OpenAI Chat
|
|
@@ -289,7 +291,7 @@ module RubyPi
|
|
|
289
291
|
end
|
|
290
292
|
|
|
291
293
|
handle_error_response(response) unless response.success?
|
|
292
|
-
parse_response(
|
|
294
|
+
parse_response(parse_json_response(response.body))
|
|
293
295
|
end
|
|
294
296
|
|
|
295
297
|
# Executes a streaming request to the OpenAI API, yielding events.
|
|
@@ -318,9 +320,14 @@ module RubyPi
|
|
|
318
320
|
# which may split SSE events mid-line. We accumulate a line buffer and
|
|
319
321
|
# process complete lines incrementally so that deltas reach the caller
|
|
320
322
|
# as soon as each SSE event is fully received.
|
|
321
|
-
|
|
323
|
+
# BINARY buffer: chunks arrive as ASCII-8BIT and may end mid-way
|
|
324
|
+
# through a multi-byte UTF-8 character; appending such a chunk to a
|
|
325
|
+
# UTF-8 buffer holding non-ASCII text raises
|
|
326
|
+
# Encoding::CompatibilityError. Complete lines are re-encoded to
|
|
327
|
+
# UTF-8 (and scrubbed) before parsing.
|
|
328
|
+
sse_buffer = (+"").force_encoding(Encoding::BINARY)
|
|
322
329
|
response_status = nil
|
|
323
|
-
error_body = +""
|
|
330
|
+
error_body = (+"").force_encoding(Encoding::BINARY)
|
|
324
331
|
|
|
325
332
|
response = with_transport_errors do
|
|
326
333
|
conn.post("/v1/chat/completions") do |req|
|
|
@@ -337,14 +344,17 @@ module RubyPi
|
|
|
337
344
|
# If the HTTP status indicates an error, accumulate the body for
|
|
338
345
|
# the error handler instead of parsing it as SSE events.
|
|
339
346
|
if response_status && response_status >= 400
|
|
340
|
-
error_body << chunk
|
|
347
|
+
error_body << chunk.b
|
|
341
348
|
next
|
|
342
349
|
end
|
|
343
350
|
|
|
344
|
-
sse_buffer << chunk
|
|
345
|
-
# Process all complete lines in the buffer
|
|
351
|
+
sse_buffer << chunk.b
|
|
352
|
+
# Process all complete lines in the buffer. A complete line holds
|
|
353
|
+
# complete UTF-8 sequences (multi-byte characters split across
|
|
354
|
+
# chunks are repaired by the buffering), so re-encode it to UTF-8
|
|
355
|
+
# here; scrub guards against a server sending invalid bytes.
|
|
346
356
|
while (line_end = sse_buffer.index("\n"))
|
|
347
|
-
line = sse_buffer.slice!(0, line_end + 1).strip
|
|
357
|
+
line = sse_buffer.slice!(0, line_end + 1).force_encoding(Encoding::UTF_8).scrub.strip
|
|
348
358
|
next if line.empty?
|
|
349
359
|
next unless line.start_with?("data: ")
|
|
350
360
|
|
|
@@ -419,7 +429,7 @@ module RubyPi
|
|
|
419
429
|
# callback. Pass the accumulated error_body so ApiError carries the
|
|
420
430
|
# full server message instead of an empty body.
|
|
421
431
|
unless response.success?
|
|
422
|
-
error_body_str = error_body.empty? ? response.body : error_body
|
|
432
|
+
error_body_str = error_body.empty? ? response.body : error_body.force_encoding(Encoding::UTF_8).scrub
|
|
423
433
|
handle_error_response(response, override_body: error_body_str)
|
|
424
434
|
end
|
|
425
435
|
|