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.
@@ -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
- # The boundary between droppable and preserved can split a tool
91
- # exchange in two ways:
92
- # (a) preserved starts with one or more :tool messages whose
93
- # matching assistant turn is in droppable. Strip those
94
- # orphan tool messages from the head of preserved (move
95
- # them into droppable so they are summarized, not sent).
96
- # (b) the last droppable message is an :assistant with tool_calls,
97
- # but its matching :tool result(s) are in preserved. Pull
98
- # that assistant message back into preserved so the pair
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
- if droppable.last &&
109
- droppable.last[:role] == :assistant &&
110
- droppable.last[:tool_calls].is_a?(Array) &&
111
- !droppable.last[:tool_calls].empty? &&
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
- # Build the compacted history: summary message + preserved.
126
- #
127
- # The summary role MUST NOT be :system (that would overwrite the real
128
- # system prompt on Anthropic, which extracts the last :system message
129
- # as the top-level `system:` parameter).
130
- #
131
- # The summary role must also NOT match the role of the first preserved
132
- # message consecutive same-role messages are rejected by Anthropic.
133
- # We pick :user when the next preserved message is :assistant, and
134
- # :assistant otherwise (covers :user, :tool, and an empty preserved).
135
- # On Anthropic, :tool messages become role :user with tool_result
136
- # blocks, so :assistant is the safe choice when the next message is
137
- # :tool too.
138
- first_preserved_role = preserved.first&.dig(:role)
139
- summary_role = first_preserved_role == :assistant ? :user : :assistant
140
-
141
- summary_message = {
142
- role: summary_role,
143
- content: "[Conversation Summary]\n#{summary}"
144
- }
145
-
146
- [summary_message] + preserved
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[:content].to_s.length
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[:role].to_s.capitalize
201
- content = msg[:content].to_s
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(JSON.parse(response.body))
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
- sse_buffer = +""
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 = delta["stop_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, &block)
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, RubyPi::ProviderError => e
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 = calculate_backoff(attempt)
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"]&.to_f
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
@@ -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, the Fallback now buffers deltas from the
20
- # primary provider. If the primary fails mid-stream, the buffered deltas
21
- # are discarded and the fallback provider streams fresh from the start.
22
- # This prevents the consumer from seeing partial output from the primary
23
- # concatenated with the complete output from the fallback.
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
- &block
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 should use this event
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.
@@ -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(JSON.parse(response.body))
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
- sse_buffer = +""
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
- finish_reason = candidate["finishReason"]&.downcase
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,
@@ -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(JSON.parse(response.body))
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
- sse_buffer = +""
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