ruby-pi 0.1.3 → 0.1.5
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 +51 -0
- data/README.md +77 -29
- data/lib/ruby_pi/agent/core.rb +59 -4
- data/lib/ruby_pi/agent/events.rb +17 -3
- data/lib/ruby_pi/agent/loop.rb +103 -18
- data/lib/ruby_pi/agent/result.rb +46 -7
- data/lib/ruby_pi/agent/state.rb +12 -0
- data/lib/ruby_pi/configuration.rb +28 -7
- data/lib/ruby_pi/context/compaction.rb +17 -2
- data/lib/ruby_pi/context/transform.rb +67 -3
- data/lib/ruby_pi/errors.rb +19 -1
- data/lib/ruby_pi/llm/anthropic.rb +231 -59
- data/lib/ruby_pi/llm/base_provider.rb +44 -46
- data/lib/ruby_pi/llm/fallback.rb +106 -1
- data/lib/ruby_pi/llm/gemini.rb +161 -41
- data/lib/ruby_pi/llm/openai.rb +173 -42
- data/lib/ruby_pi/llm/stream_event.rb +13 -3
- data/lib/ruby_pi/llm/tool_call.rb +26 -3
- data/lib/ruby_pi/tools/executor.rb +130 -21
- data/lib/ruby_pi/tools/registry.rb +26 -16
- data/lib/ruby_pi/version.rb +1 -1
- data/lib/ruby_pi.rb +2 -1
- metadata +5 -39
|
@@ -37,7 +37,7 @@ module RubyPi
|
|
|
37
37
|
# @param options [Hash] additional options passed to BaseProvider
|
|
38
38
|
def initialize(model: nil, api_key: nil, max_tokens: DEFAULT_MAX_TOKENS, **options)
|
|
39
39
|
super(**options)
|
|
40
|
-
config =
|
|
40
|
+
config = @config
|
|
41
41
|
@model = model || config.default_anthropic_model
|
|
42
42
|
@api_key = api_key || config.anthropic_api_key
|
|
43
43
|
@max_tokens = max_tokens
|
|
@@ -172,9 +172,23 @@ module RubyPi
|
|
|
172
172
|
tool_use_id = msg[:tool_call_id] || msg["tool_call_id"]
|
|
173
173
|
content = msg[:content] || msg["content"]
|
|
174
174
|
|
|
175
|
+
# Fail fast with a descriptive error instead of sending "unknown" as
|
|
176
|
+
# the tool_use_id. Anthropic requires tool_use_id to match a preceding
|
|
177
|
+
# tool_use block; sending "unknown" causes an opaque HTTP 400 with no
|
|
178
|
+
# useful error message. Raising here gives the developer a clear signal
|
|
179
|
+
# about what went wrong.
|
|
180
|
+
if tool_use_id.nil? || tool_use_id.to_s.strip.empty?
|
|
181
|
+
raise RubyPi::ProviderError.new(
|
|
182
|
+
"Missing tool_call_id in tool result message. Anthropic requires " \
|
|
183
|
+
"tool_use_id to match a preceding tool_use block. Ensure every tool " \
|
|
184
|
+
"result message includes a valid :tool_call_id.",
|
|
185
|
+
provider: :anthropic
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
|
|
175
189
|
block = {
|
|
176
190
|
type: "tool_result",
|
|
177
|
-
tool_use_id: tool_use_id
|
|
191
|
+
tool_use_id: tool_use_id
|
|
178
192
|
}
|
|
179
193
|
|
|
180
194
|
# Content can be a simple string or a structured content array.
|
|
@@ -225,10 +239,12 @@ module RubyPi
|
|
|
225
239
|
tc_name = tc[:name] || tc["name"]
|
|
226
240
|
tc_args = tc[:arguments] || tc["arguments"] || {}
|
|
227
241
|
|
|
228
|
-
# Ensure arguments is a Hash; parse JSON string if needed
|
|
242
|
+
# Ensure arguments is a Hash; parse JSON string if needed.
|
|
243
|
+
# Issue #12: Guard against empty strings — they are truthy but
|
|
244
|
+
# cause JSON::ParserError when parsed.
|
|
229
245
|
tc_input = if tc_args.is_a?(Hash)
|
|
230
246
|
tc_args
|
|
231
|
-
elsif tc_args.is_a?(String) && !tc_args.empty?
|
|
247
|
+
elsif tc_args.is_a?(String) && !tc_args.strip.empty?
|
|
232
248
|
begin
|
|
233
249
|
JSON.parse(tc_args)
|
|
234
250
|
rescue JSON::ParserError
|
|
@@ -238,17 +254,32 @@ module RubyPi
|
|
|
238
254
|
{}
|
|
239
255
|
end
|
|
240
256
|
|
|
257
|
+
# Fail fast if tool call ID is missing rather than sending "unknown"
|
|
258
|
+
# which causes an opaque Anthropic API 400 error.
|
|
259
|
+
if tc_id.nil? || tc_id.to_s.strip.empty?
|
|
260
|
+
raise RubyPi::ProviderError.new(
|
|
261
|
+
"Missing tool call ID in assistant message tool_calls. Anthropic " \
|
|
262
|
+
"requires each tool_use block to have a unique ID that subsequent " \
|
|
263
|
+
"tool_result blocks reference. Ensure every tool call includes an :id.",
|
|
264
|
+
provider: :anthropic
|
|
265
|
+
)
|
|
266
|
+
end
|
|
267
|
+
|
|
241
268
|
content_blocks << {
|
|
242
269
|
type: "tool_use",
|
|
243
|
-
id: tc_id
|
|
270
|
+
id: tc_id,
|
|
244
271
|
name: tc_name || "unknown",
|
|
245
272
|
input: tc_input
|
|
246
273
|
}
|
|
247
274
|
end
|
|
248
275
|
end
|
|
249
276
|
|
|
250
|
-
#
|
|
251
|
-
# block
|
|
277
|
+
# Anthropic requires every assistant message to have at least one
|
|
278
|
+
# content block. When an assistant turn contains only tool_use calls
|
|
279
|
+
# with no accompanying text (common in multi-tool responses), the
|
|
280
|
+
# content_blocks array may be empty after processing. Adding an empty
|
|
281
|
+
# text block satisfies the API's non-empty content constraint without
|
|
282
|
+
# altering the semantic content of the message.
|
|
252
283
|
content_blocks << { type: "text", text: "" } if content_blocks.empty?
|
|
253
284
|
|
|
254
285
|
{ role: "assistant", content: content_blocks }
|
|
@@ -310,6 +341,11 @@ module RubyPi
|
|
|
310
341
|
|
|
311
342
|
# Executes a streaming request to the Anthropic API, yielding events.
|
|
312
343
|
#
|
|
344
|
+
# Issue #22: Wraps JSON.parse(current_tool_json) at content_block_stop
|
|
345
|
+
# in a rescue block. If the stream was truncated or the accumulated JSON
|
|
346
|
+
# is malformed, raises a typed ProviderError instead of letting
|
|
347
|
+
# JSON::ParserError propagate and abort the entire stream processing.
|
|
348
|
+
#
|
|
313
349
|
# @param body [Hash] the request body
|
|
314
350
|
# @yield [event] StreamEvent objects
|
|
315
351
|
# @return [RubyPi::LLM::Response] final aggregated response
|
|
@@ -326,71 +362,105 @@ module RubyPi
|
|
|
326
362
|
usage_data = {}
|
|
327
363
|
finish_reason = nil
|
|
328
364
|
|
|
365
|
+
# Buffer for incomplete SSE lines across on_data chunks. Faraday's
|
|
366
|
+
# on_data callback delivers raw bytes as they arrive from the network,
|
|
367
|
+
# which may split SSE events mid-line. We accumulate a line buffer and
|
|
368
|
+
# process complete lines incrementally so that deltas reach the caller
|
|
369
|
+
# as soon as each SSE event is fully received — not after the entire
|
|
370
|
+
# response has been buffered.
|
|
371
|
+
sse_buffer = +""
|
|
372
|
+
response_status = nil
|
|
373
|
+
|
|
374
|
+
# Accumulate error response body separately so ApiError gets the
|
|
375
|
+
# full body even though on_data consumed the chunks.
|
|
376
|
+
error_body = +""
|
|
377
|
+
|
|
329
378
|
response = conn.post("/v1/messages") do |req|
|
|
330
379
|
req.headers["Content-Type"] = "application/json"
|
|
331
380
|
req.body = JSON.generate(body)
|
|
332
|
-
end
|
|
333
381
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
current_tool_json = +""
|
|
349
|
-
end
|
|
350
|
-
|
|
351
|
-
when "content_block_delta"
|
|
352
|
-
delta = data["delta"] || {}
|
|
353
|
-
if delta["type"] == "text_delta"
|
|
354
|
-
text = delta["text"] || ""
|
|
355
|
-
accumulated_text << text
|
|
356
|
-
block.call(StreamEvent.new(type: :text_delta, data: text))
|
|
357
|
-
elsif delta["type"] == "input_json_delta"
|
|
358
|
-
json_chunk = delta["partial_json"] || ""
|
|
359
|
-
current_tool_json << json_chunk
|
|
360
|
-
block.call(StreamEvent.new(type: :tool_call_delta, data: {
|
|
361
|
-
id: current_tool_call&.dig(:id),
|
|
362
|
-
partial_json: json_chunk
|
|
363
|
-
}))
|
|
382
|
+
# Use Faraday's on_data callback for real incremental streaming.
|
|
383
|
+
# Without this, Faraday buffers the entire response body before
|
|
384
|
+
# returning, which means no deltas reach the caller until the model
|
|
385
|
+
# finishes generating (fake streaming).
|
|
386
|
+
req.options.on_data = proc do |chunk, overall_received_bytes, env|
|
|
387
|
+
response_status ||= env&.status
|
|
388
|
+
|
|
389
|
+
# If the HTTP status indicates an error, accumulate the body for
|
|
390
|
+
# the error handler instead of parsing it as SSE events. Faraday
|
|
391
|
+
# calls on_data for error responses too, which would otherwise
|
|
392
|
+
# consume the body and leave response.body empty.
|
|
393
|
+
if response_status && response_status >= 400
|
|
394
|
+
error_body << chunk
|
|
395
|
+
next
|
|
364
396
|
end
|
|
365
397
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
398
|
+
sse_buffer << chunk
|
|
399
|
+
# Process all complete lines in the buffer
|
|
400
|
+
while (line_end = sse_buffer.index("\n"))
|
|
401
|
+
line = sse_buffer.slice!(0, line_end + 1).strip
|
|
402
|
+
next if line.empty?
|
|
403
|
+
next unless line.start_with?("data: ")
|
|
404
|
+
|
|
405
|
+
data_str = line.sub(/\Adata: /, "")
|
|
406
|
+
next if data_str == "[DONE]"
|
|
407
|
+
|
|
408
|
+
begin
|
|
409
|
+
data = JSON.parse(data_str)
|
|
410
|
+
rescue JSON::ParserError
|
|
411
|
+
next
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# --- process each SSE event exactly as before ---
|
|
415
|
+
# Process the SSE event and update mutable locals from the
|
|
416
|
+
# returned hash. This keeps all streaming state method-local,
|
|
417
|
+
# avoiding thread-unsafe instance variables.
|
|
418
|
+
stream_state = process_anthropic_stream_event(
|
|
419
|
+
data, accumulated_text, accumulated_tool_calls,
|
|
420
|
+
current_tool_call, current_tool_json, usage_data, finish_reason, block
|
|
373
421
|
)
|
|
374
|
-
current_tool_call =
|
|
375
|
-
current_tool_json =
|
|
422
|
+
current_tool_call = stream_state[:current_tool_call]
|
|
423
|
+
current_tool_json = stream_state[:current_tool_json]
|
|
424
|
+
finish_reason = stream_state[:finish_reason]
|
|
376
425
|
end
|
|
426
|
+
end
|
|
427
|
+
end
|
|
377
428
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
429
|
+
# Check for HTTP errors. When on_data was active, the response body
|
|
430
|
+
# was consumed by the callback, so we pass the accumulated error_body
|
|
431
|
+
# to handle_error_response for proper error messaging.
|
|
432
|
+
unless response.success?
|
|
433
|
+
# Reconstruct the response body from what on_data accumulated
|
|
434
|
+
error_response = response
|
|
435
|
+
error_body_str = error_body.empty? ? response.body : error_body
|
|
436
|
+
handle_error_response(error_response, override_body: error_body_str)
|
|
437
|
+
end
|
|
385
438
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
439
|
+
# Process any remaining data in the buffer after the connection closes
|
|
440
|
+
sse_buffer.each_line do |line|
|
|
441
|
+
line = line.strip
|
|
442
|
+
next if line.empty?
|
|
443
|
+
next unless line.start_with?("data: ")
|
|
444
|
+
data_str = line.sub(/\Adata: /, "")
|
|
445
|
+
next if data_str == "[DONE]"
|
|
446
|
+
begin
|
|
447
|
+
data = JSON.parse(data_str)
|
|
448
|
+
rescue JSON::ParserError
|
|
449
|
+
next
|
|
391
450
|
end
|
|
451
|
+
stream_state = process_anthropic_stream_event(
|
|
452
|
+
data, accumulated_text, accumulated_tool_calls,
|
|
453
|
+
current_tool_call, current_tool_json, usage_data, finish_reason, block
|
|
454
|
+
)
|
|
455
|
+
current_tool_call = stream_state[:current_tool_call]
|
|
456
|
+
current_tool_json = stream_state[:current_tool_json]
|
|
457
|
+
finish_reason = stream_state[:finish_reason]
|
|
392
458
|
end
|
|
393
459
|
|
|
460
|
+
# (Event processing is now handled incrementally by the on_data callback
|
|
461
|
+
# above, which calls process_anthropic_stream_event for each complete
|
|
462
|
+
# SSE event as it arrives from the network.)
|
|
463
|
+
|
|
394
464
|
# Signal completion
|
|
395
465
|
block.call(StreamEvent.new(type: :done))
|
|
396
466
|
|
|
@@ -407,6 +477,108 @@ module RubyPi
|
|
|
407
477
|
)
|
|
408
478
|
end
|
|
409
479
|
|
|
480
|
+
|
|
481
|
+
# Processes a single Anthropic SSE event during streaming. Called by the
|
|
482
|
+
# on_data callback for each complete SSE event. Updates the mutable
|
|
483
|
+
# accumulator variables and yields deltas to the caller's block.
|
|
484
|
+
#
|
|
485
|
+
# Returns a hash with updated :current_tool_call, :current_tool_json,
|
|
486
|
+
# and :finish_reason values. The caller updates its own local variables
|
|
487
|
+
# from this hash, keeping all streaming state method-scoped and
|
|
488
|
+
# thread-safe.
|
|
489
|
+
#
|
|
490
|
+
# @param data [Hash] parsed SSE event payload
|
|
491
|
+
# @param accumulated_text [String] mutable text accumulator
|
|
492
|
+
# @param accumulated_tool_calls [Array] mutable tool call accumulator
|
|
493
|
+
# @param current_tool_call [Hash, nil] current in-progress tool call
|
|
494
|
+
# @param current_tool_json [String] current tool call JSON accumulator
|
|
495
|
+
# @param usage_data [Hash] mutable usage data accumulator
|
|
496
|
+
# @param finish_reason [String, nil] current finish reason
|
|
497
|
+
# @param block [Proc] the caller's streaming block
|
|
498
|
+
# @return [Hash] updated streaming state with :current_tool_call, :current_tool_json, :finish_reason
|
|
499
|
+
def process_anthropic_stream_event(data, accumulated_text, accumulated_tool_calls,
|
|
500
|
+
current_tool_call, current_tool_json,
|
|
501
|
+
usage_data, finish_reason, block)
|
|
502
|
+
event_type = data["type"]
|
|
503
|
+
|
|
504
|
+
case event_type
|
|
505
|
+
when "content_block_start"
|
|
506
|
+
content_block = data["content_block"] || {}
|
|
507
|
+
if content_block["type"] == "tool_use"
|
|
508
|
+
current_tool_call = {
|
|
509
|
+
id: content_block["id"],
|
|
510
|
+
name: content_block["name"]
|
|
511
|
+
}
|
|
512
|
+
current_tool_json = +""
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
when "content_block_delta"
|
|
516
|
+
delta = data["delta"] || {}
|
|
517
|
+
if delta["type"] == "text_delta"
|
|
518
|
+
text = delta["text"] || ""
|
|
519
|
+
accumulated_text << text
|
|
520
|
+
block.call(StreamEvent.new(type: :text_delta, data: text))
|
|
521
|
+
elsif delta["type"] == "input_json_delta"
|
|
522
|
+
json_chunk = delta["partial_json"] || ""
|
|
523
|
+
current_tool_json << json_chunk
|
|
524
|
+
block.call(StreamEvent.new(type: :tool_call_delta, data: {
|
|
525
|
+
id: current_tool_call&.dig(:id),
|
|
526
|
+
partial_json: json_chunk
|
|
527
|
+
}))
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
when "content_block_stop"
|
|
531
|
+
if current_tool_call
|
|
532
|
+
# Issue #22: Guard JSON.parse against truncated/malformed JSON.
|
|
533
|
+
# If the stream was interrupted mid-tool-call, the accumulated
|
|
534
|
+
# JSON may be incomplete. Rescue JSON::ParserError and raise a
|
|
535
|
+
# typed ProviderError with context about what failed.
|
|
536
|
+
arguments = if current_tool_json.strip.empty?
|
|
537
|
+
{}
|
|
538
|
+
else
|
|
539
|
+
begin
|
|
540
|
+
JSON.parse(current_tool_json)
|
|
541
|
+
rescue JSON::ParserError => e
|
|
542
|
+
raise RubyPi::ProviderError.new(
|
|
543
|
+
"Failed to parse streaming tool call arguments for " \
|
|
544
|
+
"'#{current_tool_call[:name]}': #{e.message} " \
|
|
545
|
+
"(accumulated JSON: #{current_tool_json.inspect})",
|
|
546
|
+
provider: :anthropic
|
|
547
|
+
)
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
accumulated_tool_calls << ToolCall.new(
|
|
551
|
+
id: current_tool_call[:id],
|
|
552
|
+
name: current_tool_call[:name],
|
|
553
|
+
arguments: arguments
|
|
554
|
+
)
|
|
555
|
+
current_tool_call = nil
|
|
556
|
+
current_tool_json = +""
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
when "message_delta"
|
|
560
|
+
delta = data["delta"] || {}
|
|
561
|
+
finish_reason = delta["stop_reason"]
|
|
562
|
+
if data.key?("usage")
|
|
563
|
+
usage_info = data["usage"]
|
|
564
|
+
usage_data[:completion_tokens] = usage_info["output_tokens"]
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
when "message_start"
|
|
568
|
+
if data.dig("message", "usage")
|
|
569
|
+
usage_info = data["message"]["usage"]
|
|
570
|
+
usage_data[:prompt_tokens] = usage_info["input_tokens"]
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# Return mutable state as a hash so the caller can update its locals.
|
|
575
|
+
# This avoids thread-unsafe instance variables that would leak state
|
|
576
|
+
# across concurrent requests on the same provider instance.
|
|
577
|
+
{ current_tool_call: current_tool_call,
|
|
578
|
+
current_tool_json: current_tool_json,
|
|
579
|
+
finish_reason: finish_reason }
|
|
580
|
+
end
|
|
581
|
+
|
|
410
582
|
# Returns the default HTTP headers required by the Anthropic API.
|
|
411
583
|
#
|
|
412
584
|
# @return [Hash] headers hash
|
|
@@ -41,14 +41,18 @@ module RubyPi
|
|
|
41
41
|
|
|
42
42
|
# Initializes the base provider with retry configuration.
|
|
43
43
|
#
|
|
44
|
-
# @param
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
44
|
+
# @param config [RubyPi::Configuration, nil] optional per-agent config override.
|
|
45
|
+
# When provided, the provider uses this config instead of the global
|
|
46
|
+
# RubyPi.configuration singleton. This enables per-agent API keys,
|
|
47
|
+
# timeouts, and retry settings.
|
|
48
|
+
# @param max_retries [Integer, nil] override max retries (defaults to config)
|
|
49
|
+
# @param retry_base_delay [Float, nil] override base delay (defaults to config)
|
|
50
|
+
# @param retry_max_delay [Float, nil] override max delay (defaults to config)
|
|
51
|
+
def initialize(config: nil, max_retries: nil, retry_base_delay: nil, retry_max_delay: nil)
|
|
52
|
+
@config = config || RubyPi.configuration
|
|
53
|
+
@max_retries = max_retries || @config.max_retries
|
|
54
|
+
@retry_base_delay = retry_base_delay || @config.retry_base_delay
|
|
55
|
+
@retry_max_delay = retry_max_delay || @config.retry_max_delay
|
|
52
56
|
end
|
|
53
57
|
|
|
54
58
|
# Sends a completion request to the LLM provider with automatic retry
|
|
@@ -75,7 +79,12 @@ module RubyPi
|
|
|
75
79
|
# Authentication errors are not retryable — raise immediately
|
|
76
80
|
raise
|
|
77
81
|
rescue RubyPi::RateLimitError, RubyPi::ApiError, RubyPi::TimeoutError => e
|
|
78
|
-
|
|
82
|
+
# Retry up to max_retries times AFTER the initial attempt.
|
|
83
|
+
# With max_retries: 3, attempt goes 1 (initial), 2, 3, 4 — the condition
|
|
84
|
+
# `attempt <= @max_retries` allows retries on attempts 1..3, so we get
|
|
85
|
+
# 3 retries + 1 initial = 4 total attempts. Previously used `< @max_retries`
|
|
86
|
+
# which was off-by-one (only 2 retries with max_retries: 3).
|
|
87
|
+
if attempt <= @max_retries
|
|
79
88
|
delay = calculate_backoff(attempt)
|
|
80
89
|
log_retry(attempt, delay, e)
|
|
81
90
|
sleep(delay)
|
|
@@ -90,18 +99,18 @@ module RubyPi
|
|
|
90
99
|
# Subclasses MUST override this method.
|
|
91
100
|
#
|
|
92
101
|
# @return [String] the model identifier
|
|
93
|
-
# @raise [RubyPi::
|
|
102
|
+
# @raise [RubyPi::AbstractMethodError] if not overridden
|
|
94
103
|
def model_name
|
|
95
|
-
raise RubyPi::
|
|
104
|
+
raise RubyPi::AbstractMethodError, :model_name
|
|
96
105
|
end
|
|
97
106
|
|
|
98
107
|
# Returns the provider identifier.
|
|
99
108
|
# Subclasses MUST override this method.
|
|
100
109
|
#
|
|
101
110
|
# @return [Symbol] the provider identifier (e.g., :gemini, :anthropic, :openai)
|
|
102
|
-
# @raise [RubyPi::
|
|
111
|
+
# @raise [RubyPi::AbstractMethodError] if not overridden
|
|
103
112
|
def provider_name
|
|
104
|
-
raise RubyPi::
|
|
113
|
+
raise RubyPi::AbstractMethodError, :provider_name
|
|
105
114
|
end
|
|
106
115
|
|
|
107
116
|
private
|
|
@@ -115,7 +124,7 @@ module RubyPi
|
|
|
115
124
|
# @yield [event] optional block for streaming events
|
|
116
125
|
# @return [RubyPi::LLM::Response]
|
|
117
126
|
def perform_complete(messages:, tools:, stream:, &block)
|
|
118
|
-
raise RubyPi::
|
|
127
|
+
raise RubyPi::AbstractMethodError, :perform_complete
|
|
119
128
|
end
|
|
120
129
|
|
|
121
130
|
# Calculates the backoff delay for a given retry attempt using
|
|
@@ -136,7 +145,7 @@ module RubyPi
|
|
|
136
145
|
# @param error [Exception] the error that triggered the retry
|
|
137
146
|
# @return [void]
|
|
138
147
|
def log_retry(attempt, delay, error)
|
|
139
|
-
logger =
|
|
148
|
+
logger = @config.logger
|
|
140
149
|
return unless logger
|
|
141
150
|
|
|
142
151
|
logger.warn(
|
|
@@ -145,13 +154,21 @@ module RubyPi
|
|
|
145
154
|
)
|
|
146
155
|
end
|
|
147
156
|
|
|
148
|
-
# Builds a Faraday connection with
|
|
157
|
+
# Builds a Faraday connection with standard settings.
|
|
158
|
+
#
|
|
159
|
+
# Issue #20: Removed incorrect retry-middleware claim from the
|
|
160
|
+
# docstring. The faraday-retry gem was listed as a dependency but never
|
|
161
|
+
# wired into the connection builder. Since retry logic is already
|
|
162
|
+
# implemented in BaseProvider#complete with exponential backoff (see
|
|
163
|
+
# the begin/rescue/retry block), the Faraday-level retry middleware is
|
|
164
|
+
# not needed and would cause confusing double-retry behavior. The
|
|
165
|
+
# faraday-retry dependency has been removed from the gemspec.
|
|
149
166
|
#
|
|
150
167
|
# @param base_url [String] the base URL for the API
|
|
151
168
|
# @param headers [Hash] default headers for all requests
|
|
152
169
|
# @return [Faraday::Connection]
|
|
153
170
|
def build_connection(base_url:, headers: {})
|
|
154
|
-
config =
|
|
171
|
+
config = @config
|
|
155
172
|
|
|
156
173
|
Faraday.new(url: base_url) do |conn|
|
|
157
174
|
conn.headers.update(headers)
|
|
@@ -162,59 +179,40 @@ module RubyPi
|
|
|
162
179
|
end
|
|
163
180
|
|
|
164
181
|
# Handles HTTP error responses by raising the appropriate RubyPi error.
|
|
182
|
+
# When streaming with on_data, the response body is consumed by the
|
|
183
|
+
# callback and response.body may be empty. Pass override_body with the
|
|
184
|
+
# accumulated error chunks so the raised error contains the full body.
|
|
165
185
|
#
|
|
166
186
|
# @param response [Faraday::Response] the HTTP response
|
|
187
|
+
# @param override_body [String, nil] optional body to use instead of response.body
|
|
188
|
+
# (used when on_data consumed the body during streaming)
|
|
167
189
|
# @raise [RubyPi::AuthenticationError] on 401 or 403
|
|
168
190
|
# @raise [RubyPi::RateLimitError] on 429
|
|
169
191
|
# @raise [RubyPi::ApiError] on other error status codes
|
|
170
|
-
def handle_error_response(response)
|
|
192
|
+
def handle_error_response(response, override_body: nil)
|
|
193
|
+
body = override_body || response.body
|
|
171
194
|
case response.status
|
|
172
195
|
when 401, 403
|
|
173
196
|
raise RubyPi::AuthenticationError.new(
|
|
174
197
|
"#{provider_name} authentication failed (HTTP #{response.status})",
|
|
175
|
-
response_body:
|
|
198
|
+
response_body: body
|
|
176
199
|
)
|
|
177
200
|
when 429
|
|
178
201
|
retry_after = response.headers["retry-after"]&.to_f
|
|
179
202
|
raise RubyPi::RateLimitError.new(
|
|
180
203
|
"#{provider_name} rate limit exceeded (HTTP 429)",
|
|
181
204
|
retry_after: retry_after,
|
|
182
|
-
response_body:
|
|
205
|
+
response_body: body
|
|
183
206
|
)
|
|
184
207
|
else
|
|
185
208
|
raise RubyPi::ApiError.new(
|
|
186
209
|
"#{provider_name} API error (HTTP #{response.status})",
|
|
187
210
|
status_code: response.status,
|
|
188
|
-
response_body:
|
|
211
|
+
response_body: body
|
|
189
212
|
)
|
|
190
213
|
end
|
|
191
214
|
end
|
|
192
215
|
|
|
193
|
-
# Processes a streaming response body line by line, parsing SSE events.
|
|
194
|
-
# Yields parsed data hashes to the provided block.
|
|
195
|
-
#
|
|
196
|
-
# @param response_body [String] the raw SSE response body
|
|
197
|
-
# @yield [data] parsed SSE event data
|
|
198
|
-
# @yieldparam data [Hash] a parsed JSON event payload
|
|
199
|
-
# @return [void]
|
|
200
|
-
def parse_sse_events(response_body, &block)
|
|
201
|
-
response_body.each_line do |line|
|
|
202
|
-
line = line.strip
|
|
203
|
-
next if line.empty?
|
|
204
|
-
next unless line.start_with?("data: ")
|
|
205
|
-
|
|
206
|
-
data_str = line.sub(/\Adata: /, "")
|
|
207
|
-
next if data_str == "[DONE]"
|
|
208
|
-
|
|
209
|
-
begin
|
|
210
|
-
data = JSON.parse(data_str)
|
|
211
|
-
block.call(data)
|
|
212
|
-
rescue JSON::ParserError
|
|
213
|
-
# Skip malformed SSE data lines
|
|
214
|
-
next
|
|
215
|
-
end
|
|
216
|
-
end
|
|
217
|
-
end
|
|
218
216
|
end
|
|
219
217
|
end
|
|
220
218
|
end
|