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.
@@ -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 = RubyPi.configuration
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 || "unknown"
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 || "unknown",
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
- # If no content blocks were generated (edge case), add an empty text
251
- # block to satisfy Anthropic's requirement for non-empty content.
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
- handle_error_response(response) unless response.success?
335
-
336
- # Parse SSE events from the response body
337
- parse_sse_events(response.body) do |data|
338
- event_type = data["type"]
339
-
340
- case event_type
341
- when "content_block_start"
342
- content_block = data["content_block"] || {}
343
- if content_block["type"] == "tool_use"
344
- current_tool_call = {
345
- id: content_block["id"],
346
- name: content_block["name"]
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
- when "content_block_stop"
367
- if current_tool_call
368
- arguments = current_tool_json.empty? ? {} : JSON.parse(current_tool_json)
369
- accumulated_tool_calls << ToolCall.new(
370
- id: current_tool_call[:id],
371
- name: current_tool_call[:name],
372
- arguments: arguments
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 = nil
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
- when "message_delta"
379
- delta = data["delta"] || {}
380
- finish_reason = delta["stop_reason"]
381
- if data.key?("usage")
382
- usage_info = data["usage"]
383
- usage_data[:completion_tokens] = usage_info["output_tokens"]
384
- end
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
- when "message_start"
387
- if data.dig("message", "usage")
388
- usage_info = data["message"]["usage"]
389
- usage_data[:prompt_tokens] = usage_info["input_tokens"]
390
- end
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 max_retries [Integer, nil] override max retries (defaults to global config)
45
- # @param retry_base_delay [Float, nil] override base delay (defaults to global config)
46
- # @param retry_max_delay [Float, nil] override max delay (defaults to global config)
47
- def initialize(max_retries: nil, retry_base_delay: nil, retry_max_delay: nil)
48
- config = RubyPi.configuration
49
- @max_retries = max_retries || config.max_retries
50
- @retry_base_delay = retry_base_delay || config.retry_base_delay
51
- @retry_max_delay = retry_max_delay || config.retry_max_delay
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
- if attempt < @max_retries
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::NotImplementedError] if not overridden
102
+ # @raise [RubyPi::AbstractMethodError] if not overridden
94
103
  def model_name
95
- raise RubyPi::NotImplementedError, :model_name
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::NotImplementedError] if not overridden
111
+ # @raise [RubyPi::AbstractMethodError] if not overridden
103
112
  def provider_name
104
- raise RubyPi::NotImplementedError, :provider_name
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::NotImplementedError, :perform_complete
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 = RubyPi.configuration.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 retry middleware and standard settings.
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 = RubyPi.configuration
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: 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: 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: 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