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.
@@ -340,7 +340,7 @@ module RubyPi
340
340
  end
341
341
 
342
342
  handle_error_response(response) unless response.success?
343
- parse_response(JSON.parse(response.body))
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
- # Buffer for incomplete SSE lines across on_data chunks. Faraday's
370
- # on_data callback delivers raw bytes as they arrive from the network,
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, overall_received_bytes, env|
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 << chunk.b
408
+ append_error_body(error_body, chunk)
407
409
  next
408
410
  end
409
411
 
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.
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
- # Process any remaining data in the buffer after the connection closes
456
- sse_buffer.force_encoding(Encoding::UTF_8).scrub.each_line do |line|
457
- line = line.strip
458
- next if line.empty?
459
- next unless line.start_with?("data: ")
460
- data_str = line.sub(/\Adata: /, "")
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 << 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
- current_tool_json << json_chunk
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 => e
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]}': #{e.message} " \
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, &block)
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(messages:, tools:, stream:, &block)
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"]&.to_f
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
@@ -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
- partial_chars += event.data.to_s.length if event.text_delta?
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.message,
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.message}. " \
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
@@ -281,7 +281,7 @@ module RubyPi
281
281
  end
282
282
 
283
283
  handle_error_response(response) unless response.success?
284
- parse_response(JSON.parse(response.body))
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
- # Buffer for incomplete SSE lines across on_data chunks. Faraday's
305
- # on_data callback delivers raw bytes as they arrive from the network,
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 << chunk.b
372
+ append_error_body(error_body, chunk)
334
373
  next
335
374
  end
336
375
 
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.
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 || "stop"
405
+ finish_reason: finish_reason
424
406
  )
425
407
  end
426
408