ruby-pi 0.1.9 → 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.
@@ -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
 
@@ -201,11 +201,10 @@ module RubyPi
201
201
  begin
202
202
  JSON.parse(tc_args)
203
203
  tc_args
204
- rescue JSON::ParserError => e
204
+ rescue JSON::ParserError
205
205
  raise RubyPi::ProviderError.new(
206
206
  "Invalid JSON in assistant tool_call.arguments " \
207
- "for tool '#{tc_name || "unknown"}': #{e.message} " \
208
- "(raw: #{tc_args.inspect})",
207
+ "for tool '#{tc_name || "unknown"}'",
209
208
  provider: :openai
210
209
  )
211
210
  end
@@ -315,20 +314,63 @@ module RubyPi
315
314
  # Issue #21: Accumulate usage data from the final SSE chunk
316
315
  streaming_usage = {}
317
316
 
318
- # Buffer for incomplete SSE lines across on_data chunks. Faraday's
319
- # on_data callback delivers raw bytes as they arrive from the network,
320
- # which may split SSE events mid-line. We accumulate a line buffer and
321
- # process complete lines incrementally so that deltas reach the caller
322
- # as soon as each SSE event is fully received.
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)
317
+ sse_parser = SSEParser.new(provider: provider_name)
318
+ stream_completed = false
329
319
  response_status = nil
330
320
  error_body = (+"").force_encoding(Encoding::BINARY)
331
321
 
322
+ process_payload = proc do |data_str|
323
+ if data_str == "[DONE]"
324
+ stream_completed = true
325
+ next
326
+ end
327
+
328
+ data = sse_parser.parse_json(data_str)
329
+ if data.key?("usage") && data["usage"]
330
+ usage_info = data["usage"]
331
+ streaming_usage = {
332
+ prompt_tokens: usage_info["prompt_tokens"],
333
+ completion_tokens: usage_info["completion_tokens"],
334
+ total_tokens: usage_info["total_tokens"]
335
+ }
336
+ end
337
+
338
+ choice = (data["choices"] || []).first
339
+ next unless choice
340
+
341
+ delta = choice["delta"] || {}
342
+ finish_reason = choice["finish_reason"] if choice["finish_reason"]
343
+
344
+ if delta.key?("content") && delta["content"]
345
+ text = delta["content"]
346
+ append_stream_data!(accumulated_text, text, limit: MAX_STREAM_OUTPUT_BYTES, label: "stream output")
347
+ block.call(StreamEvent.new(type: :text_delta, data: text))
348
+ end
349
+
350
+ (delta["tool_calls"] || []).each do |tc_delta|
351
+ index = tc_delta["index"] || 0
352
+ tool_call_accumulators[index] ||= { id: nil, name: +"", arguments: +"" }
353
+ enforce_stream_tool_call_limit!(tool_call_accumulators.size)
354
+ acc = tool_call_accumulators[index]
355
+ acc[:id] = tc_delta["id"] if tc_delta["id"]
356
+ acc[:name] << tc_delta["function"]["name"] if tc_delta.dig("function", "name")
357
+
358
+ if tc_delta.dig("function", "arguments")
359
+ append_stream_data!(
360
+ acc[:arguments], tc_delta["function"]["arguments"],
361
+ limit: MAX_STREAM_TOOL_ARGUMENT_BYTES, label: "tool arguments"
362
+ )
363
+ end
364
+
365
+ block.call(StreamEvent.new(type: :tool_call_delta, data: {
366
+ index: index,
367
+ id: acc[:id],
368
+ name: acc[:name].dup.freeze,
369
+ arguments_fragment: tc_delta.dig("function", "arguments") || ""
370
+ }))
371
+ end
372
+ end
373
+
332
374
  response = with_transport_errors do
333
375
  conn.post("/v1/chat/completions") do |req|
334
376
  req.headers["Content-Type"] = "application/json"
@@ -344,83 +386,11 @@ module RubyPi
344
386
  # If the HTTP status indicates an error, accumulate the body for
345
387
  # the error handler instead of parsing it as SSE events.
346
388
  if response_status && response_status >= 400
347
- error_body << chunk.b
389
+ append_error_body(error_body, chunk)
348
390
  next
349
391
  end
350
392
 
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.
356
- while (line_end = sse_buffer.index("\n"))
357
- line = sse_buffer.slice!(0, line_end + 1).force_encoding(Encoding::UTF_8).scrub.strip
358
- next if line.empty?
359
- next unless line.start_with?("data: ")
360
-
361
- data_str = line.sub(/\Adata: /, "")
362
- next if data_str == "[DONE]"
363
-
364
- begin
365
- data = JSON.parse(data_str)
366
- rescue JSON::ParserError
367
- next
368
- end
369
-
370
- # Issue #21: Capture usage data from the final SSE chunk.
371
- # OpenAI sends usage in a dedicated chunk when include_usage is true.
372
- if data.key?("usage") && data["usage"]
373
- usage_info = data["usage"]
374
- streaming_usage = {
375
- prompt_tokens: usage_info["prompt_tokens"],
376
- completion_tokens: usage_info["completion_tokens"],
377
- total_tokens: usage_info["total_tokens"]
378
- }
379
- end
380
-
381
- # Process this SSE event
382
- choices = data["choices"] || []
383
- choice = choices.first
384
- next unless choice
385
-
386
- delta = choice["delta"] || {}
387
- finish_reason = choice["finish_reason"] if choice["finish_reason"]
388
-
389
- # Handle text content deltas
390
- if delta.key?("content") && delta["content"]
391
- text = delta["content"]
392
- accumulated_text << text
393
- block.call(StreamEvent.new(type: :text_delta, data: text))
394
- end
395
-
396
- # Handle tool call deltas
397
- if delta.key?("tool_calls")
398
- delta["tool_calls"].each do |tc_delta|
399
- index = tc_delta["index"] || 0
400
-
401
- # Initialize accumulator for this tool call
402
- tool_call_accumulators[index] ||= { id: nil, name: +"", arguments: +"" }
403
- acc = tool_call_accumulators[index]
404
-
405
- acc[:id] = tc_delta["id"] if tc_delta["id"]
406
-
407
- if tc_delta.dig("function", "name")
408
- acc[:name] << tc_delta["function"]["name"]
409
- end
410
-
411
- if tc_delta.dig("function", "arguments")
412
- acc[:arguments] << tc_delta["function"]["arguments"]
413
- end
414
-
415
- block.call(StreamEvent.new(type: :tool_call_delta, data: {
416
- index: index,
417
- id: acc[:id],
418
- name: acc[:name],
419
- arguments_fragment: tc_delta.dig("function", "arguments") || ""
420
- }))
421
- end
422
- end
423
- end
393
+ sse_parser.feed(chunk, &process_payload)
424
394
  end
425
395
  end # conn.post
426
396
  end # with_transport_errors
@@ -433,6 +403,15 @@ module RubyPi
433
403
  handle_error_response(response, override_body: error_body_str)
434
404
  end
435
405
 
406
+ sse_parser.finish(&process_payload)
407
+
408
+ unless stream_completed
409
+ raise RubyPi::StreamingProtocolError.new(
410
+ "openai stream ended before data: [DONE]",
411
+ status_code: nil
412
+ )
413
+ end
414
+
436
415
  # Build final tool calls from accumulators
437
416
  # Issue #12: Guard JSON.parse against empty strings. An empty string
438
417
  # is truthy in Ruby, so the previous `empty? ? {} : JSON.parse(...)` check
@@ -543,9 +522,9 @@ module RubyPi
543
522
 
544
523
  # Attempt to parse the JSON string
545
524
  JSON.parse(raw_args)
546
- rescue JSON::ParserError => e
525
+ rescue JSON::ParserError
547
526
  raise RubyPi::ProviderError.new(
548
- "Failed to parse tool call arguments from OpenAI: #{e.message} (raw: #{raw_args.inspect})",
527
+ "Failed to parse tool call arguments from OpenAI",
549
528
  provider: :openai
550
529
  )
551
530
  end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RubyPi
6
+ module LLM
7
+ # Incremental, bounded Server-Sent Events decoder.
8
+ #
9
+ # Faraday delivers arbitrary byte chunks, including chunks split inside a
10
+ # UTF-8 character or line ending. This decoder keeps bytes in BINARY form
11
+ # until a complete SSE event is available, then validates UTF-8 and yields
12
+ # the joined value of its data fields.
13
+ class SSEParser
14
+ DEFAULT_MAX_EVENT_BYTES = 1_048_576
15
+
16
+ def initialize(provider:, max_event_bytes: DEFAULT_MAX_EVENT_BYTES)
17
+ @provider = provider
18
+ @max_event_bytes = max_event_bytes
19
+ @buffer = (+"").force_encoding(Encoding::BINARY)
20
+ @data_lines = []
21
+ @event_bytes = 0
22
+ end
23
+
24
+ def feed(chunk, &block)
25
+ @buffer << chunk.to_s.b
26
+ extract_lines(finishing: false, &block)
27
+ enforce_size!(@buffer.bytesize + @event_bytes)
28
+ end
29
+
30
+ def finish(&block)
31
+ extract_lines(finishing: true, &block)
32
+
33
+ unless @buffer.empty?
34
+ line = @buffer.slice!(0, @buffer.bytesize)
35
+ process_line(line, &block)
36
+ end
37
+
38
+ dispatch(&block)
39
+ end
40
+
41
+ def parse_json(payload)
42
+ JSON.parse(payload)
43
+ rescue JSON::ParserError => e
44
+ raise RubyPi::StreamingProtocolError.new(
45
+ "#{@provider} stream contained malformed JSON: #{e.message}",
46
+ status_code: nil
47
+ )
48
+ end
49
+
50
+ private
51
+
52
+ def extract_lines(finishing:, &block)
53
+ loop do
54
+ line_end = next_line_ending
55
+ break unless line_end
56
+
57
+ if @buffer.getbyte(line_end) == 13 && line_end == @buffer.bytesize - 1 && !finishing
58
+ break
59
+ end
60
+
61
+ terminator_size = if @buffer.getbyte(line_end) == 13 && @buffer.getbyte(line_end + 1) == 10
62
+ 2
63
+ else
64
+ 1
65
+ end
66
+ line = @buffer.slice!(0, line_end + terminator_size)
67
+ line = line.byteslice(0, line.bytesize - terminator_size)
68
+ process_line(line, &block)
69
+ end
70
+ end
71
+
72
+ def next_line_ending
73
+ cr = @buffer.index("\r")
74
+ lf = @buffer.index("\n")
75
+ [cr, lf].compact.min
76
+ end
77
+
78
+ def process_line(raw_line, &block)
79
+ line = raw_line.dup.force_encoding(Encoding::UTF_8)
80
+ unless line.valid_encoding?
81
+ raise RubyPi::StreamingProtocolError.new(
82
+ "#{@provider} stream contained invalid UTF-8",
83
+ status_code: nil
84
+ )
85
+ end
86
+
87
+ if line.empty?
88
+ dispatch(&block)
89
+ return
90
+ end
91
+
92
+ return if line.start_with?(":")
93
+
94
+ field, separator, value = line.partition(":")
95
+ return unless field == "data"
96
+
97
+ value = "" unless separator == ":"
98
+ value = value.byteslice(1..) if value.start_with?(" ")
99
+ separator_bytes = @data_lines.empty? ? 0 : 1
100
+ prospective_bytes = @event_bytes + separator_bytes + value.bytesize
101
+ enforce_size!(prospective_bytes)
102
+ @data_lines << value
103
+ @event_bytes = prospective_bytes
104
+ end
105
+
106
+ def dispatch
107
+ return if @data_lines.empty?
108
+
109
+ payload = @data_lines.join("\n")
110
+ @data_lines.clear
111
+ @event_bytes = 0
112
+ yield payload
113
+ end
114
+
115
+ def enforce_size!(bytes)
116
+ return if bytes <= @max_event_bytes
117
+
118
+ raise RubyPi::StreamingProtocolError.new(
119
+ "#{@provider} stream event exceeded #{@max_event_bytes} bytes",
120
+ status_code: nil
121
+ )
122
+ end
123
+ end
124
+ end
125
+ end