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.
@@ -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
@@ -291,7 +290,7 @@ module RubyPi
291
290
  end
292
291
 
293
292
  handle_error_response(response) unless response.success?
294
- parse_response(JSON.parse(response.body))
293
+ parse_response(parse_json_response(response.body))
295
294
  end
296
295
 
297
296
  # Executes a streaming request to the OpenAI API, yielding events.
@@ -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
@@ -25,10 +25,10 @@ module RubyPi
25
25
  # end
26
26
  class StreamEvent
27
27
  # Valid event types for stream events.
28
- VALID_TYPES = %i[text_delta tool_call_delta done fallback_start].freeze
28
+ VALID_TYPES = %i[text_delta tool_call_delta done retry_start fallback_start].freeze
29
29
 
30
30
  # @return [Symbol] the type of stream event — one of :text_delta,
31
- # :tool_call_delta, :done, or :fallback_start
31
+ # :tool_call_delta, :done, :retry_start, or :fallback_start
32
32
  attr_reader :type
33
33
 
34
34
  # @return [Object] the event payload. For :text_delta this is a String
@@ -38,7 +38,8 @@ module RubyPi
38
38
 
39
39
  # Creates a new StreamEvent instance.
40
40
  #
41
- # @param type [Symbol] event type (:text_delta, :tool_call_delta, :done, :fallback_start)
41
+ # @param type [Symbol] event type (:text_delta, :tool_call_delta, :done,
42
+ # :retry_start, :fallback_start)
42
43
  # @param data [Object] event payload
43
44
  # @raise [ArgumentError] if the type is not recognized
44
45
  def initialize(type:, data: nil)
@@ -71,6 +72,12 @@ module RubyPi
71
72
  @type == :done
72
73
  end
73
74
 
75
+ # Returns true when a failed streaming attempt is being discarded and
76
+ # the same provider is about to retry from the beginning.
77
+ def retry_start?
78
+ @type == :retry_start
79
+ end
80
+
74
81
  # Returns true if this is a fallback_start event, signaling that the
75
82
  # primary provider failed mid-stream and the fallback provider is
76
83
  # taking over. Consumers should clear any partial output rendered