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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +19 -2
- data/lib/ruby_pi/agent/core.rb +6 -0
- data/lib/ruby_pi/agent/result.rb +31 -10
- data/lib/ruby_pi/agent/state.rb +4 -0
- data/lib/ruby_pi/context/compaction.rb +14 -1
- data/lib/ruby_pi/errors.rb +16 -0
- data/lib/ruby_pi/extensions/base.rb +0 -8
- data/lib/ruby_pi/llm/anthropic.rb +33 -70
- data/lib/ruby_pi/llm/base_provider.rb +37 -8
- data/lib/ruby_pi/llm/fallback.rb +13 -4
- data/lib/ruby_pi/llm/gemini.rb +62 -80
- data/lib/ruby_pi/llm/openai.rb +69 -90
- data/lib/ruby_pi/llm/sse_parser.rb +125 -0
- data/lib/ruby_pi/tools/executor.rb +162 -76
- data/lib/ruby_pi/tools/result.rb +17 -2
- data/lib/ruby_pi/version.rb +1 -1
- data/lib/ruby_pi.rb +1 -0
- metadata +3 -2
data/lib/ruby_pi/llm/gemini.rb
CHANGED
|
@@ -301,20 +301,59 @@ module RubyPi
|
|
|
301
301
|
usage_data = {}
|
|
302
302
|
finish_reason = nil
|
|
303
303
|
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
372
|
+
append_error_body(error_body, chunk)
|
|
334
373
|
next
|
|
335
374
|
end
|
|
336
375
|
|
|
337
|
-
|
|
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
|
|
405
|
+
finish_reason: finish_reason
|
|
424
406
|
)
|
|
425
407
|
end
|
|
426
408
|
|
data/lib/ruby_pi/llm/openai.rb
CHANGED
|
@@ -201,11 +201,10 @@ module RubyPi
|
|
|
201
201
|
begin
|
|
202
202
|
JSON.parse(tc_args)
|
|
203
203
|
tc_args
|
|
204
|
-
rescue JSON::ParserError
|
|
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"}'
|
|
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
|
-
|
|
319
|
-
|
|
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
|
|
389
|
+
append_error_body(error_body, chunk)
|
|
348
390
|
next
|
|
349
391
|
end
|
|
350
392
|
|
|
351
|
-
|
|
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
|
|
525
|
+
rescue JSON::ParserError
|
|
547
526
|
raise RubyPi::ProviderError.new(
|
|
548
|
-
"Failed to parse tool call arguments from OpenAI
|
|
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
|