diff --git a/CHANGELOG.md b/CHANGELOG.md index e7748dbe0..0d7042f63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Responses streaming failures retain their error details** (`activeagent`). + `error` and `response.failed` events raise a provider error with the API + code and message instead of an unexpected chunk type error. - **The dashboard keeps a catalog's pass bars** (`actionagent`). A product's and a set's `pass` blocks (`min_pass_rate`, `threshold`, `gated`) parsed in `ActiveAgent::Evals::Catalog`, but neither diff --git a/lib/active_agent/providers/open_ai/responses_provider.rb b/lib/active_agent/providers/open_ai/responses_provider.rb index cf124dda1..b0032a1a3 100644 --- a/lib/active_agent/providers/open_ai/responses_provider.rb +++ b/lib/active_agent/providers/open_ai/responses_provider.rb @@ -110,6 +110,7 @@ def api_response_normalize(api_response) # reasoning progress, kept from the reasoning item once it is done # - `:"response.content_part.done"` - content part completed # - `:"response.output_item.done"` - message, reasoning or function call completed + # - `:error`, `:"response.failed"` - provider failure # - `:"response.completed"` - response finished # - `:"response.incomplete"` - response cut short (e.g. max_output_tokens) # @@ -174,6 +175,12 @@ def process_stream_chunk(api_response_event) when :"response.output_item.done" process_stream_output_item_done(api_response_event) + # Errors arrive as events even though the HTTP request succeeded. + when :error, :"response.failed" + error = api_response_event.type == :error ? api_response_event : api_response_event.response.error + message = [ error&.code, error&.message ].compact.join(": ") + raise Errors::ProviderError.new(message.presence || "OpenAI response failed", provider_tag: tag_name) + # Response Completed, or cut short (e.g. by max_output_tokens) with # the output it has so far when :"response.completed", :"response.incomplete" diff --git a/test/providers/open_ai/responses/streaming_errors_test.rb b/test/providers/open_ai/responses/streaming_errors_test.rb new file mode 100644 index 000000000..37dc54dee --- /dev/null +++ b/test/providers/open_ai/responses/streaming_errors_test.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "../../../../lib/active_agent/providers/open_ai/responses_provider" + +module Providers + module OpenAI + module Responses + class StreamingErrorsTest < ActiveSupport::TestCase + include WebMock::API + + test "an error event preserves the API error message and code" do + assert_stream_error({ type: "error", code: "server_error", message: "Please try again", param: nil }, + "server_error: Please try again") + end + + test "a failed response preserves its nested error" do + assert_stream_error({ type: "response.failed", response: { + id: "resp_failed", status: "failed", error: { code: "server_error", message: "Generation failed" } + } }, "server_error: Generation failed") + end + + test "a failed response without error details still raises a provider error" do + assert_stream_error({ type: "response.failed", response: { + id: "resp_failed", status: "failed", error: nil + } }, "OpenAI response failed") + end + + private + + def assert_stream_error(event, message) + events = [ { type: "response.created", response: { + id: "resp_failed", status: "in_progress", output: [] + } }, event ] + body = events.each_with_index.map do |chunk, index| + "event: #{chunk[:type]}\ndata: #{chunk.merge(sequence_number: index).to_json}\n\n" + end.join + stub_request(:post, "https://api.openai.com/v1/responses").to_return( + status: 200, headers: { "Content-Type" => "text/event-stream" }, body: + ) + provider = ActiveAgent::Providers::OpenAI::ResponsesProvider.new( + service: "OpenAI", api_key: "test-key", model: "gpt-5", stream: true, + messages: [ { role: "user", content: "Hello" } ], + stream_broadcaster: ->(_message, _delta, _event_type) { } + ) + + error = assert_raises(ActiveAgent::Providers::Errors::ProviderError) { provider.prompt } + assert_equal message, error.message + end + end + end + end +end