diff --git a/provider/openaiprovider/responses.go b/provider/openaiprovider/responses.go index 371e0801..61848804 100644 --- a/provider/openaiprovider/responses.go +++ b/provider/openaiprovider/responses.go @@ -59,7 +59,8 @@ func NewResponsesAgent(oclient openai.Client, config AgentConfig) *agent.Agent { Middlewares: providerMiddlewares, Format: c.formatOf, Unmarshal: c.unmarshal, - }, config.Config) + }, config.Config, + ) } type responsesClient struct { @@ -1040,6 +1041,22 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, u.ContinuationToken = contToken } + case responses.ResponseFailedEvent: + // A failed response carries its error on the response object rather than + // as a top-level ResponseErrorEvent; surface it as ErrorContent instead + // of letting it fall through to the default (empty) update. + u = createUpdate(message.RoleAssistant, nil) + u.CreatedAt = time.Unix(int64(event.Response.CreatedAt), 0) + u.ResponseID = event.Response.ID + u.FinishReason = responsesFinishReason(&event.Response) + u.AdditionalProperties = responsesPopulateAdditionalProperties(&event.Response) + if event.Response.Error.Message != "" || event.Response.Error.Code != "" { + u.Contents = []message.Content{&message.ErrorContent{ + Message: event.Response.Error.Message, + ErrorCode: string(event.Response.Error.Code), + }} + } + case responses.ResponseTextDeltaEvent: u = createUpdate(message.RoleAssistant, []message.Content{ &message.TextContent{Text: event.Delta}, diff --git a/provider/openaiprovider/responses_test.go b/provider/openaiprovider/responses_test.go index 4f060310..305ecae9 100644 --- a/provider/openaiprovider/responses_test.go +++ b/provider/openaiprovider/responses_test.go @@ -5546,3 +5546,91 @@ func responsesBodyEqual(t *testing.T, got string, want string) { t.Errorf("body\ngot %s\nwant %s", gotOut, wantOut) } } + +// TestResponsesStreamingFailedResponseSurfacesError verifies that a streamed +// response.failed event surfaces its error as ErrorContent rather than an +// empty update. +func TestResponsesStreamingFailedResponseSurfacesError(t *testing.T) { + const input = ` + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"test"}]}], + "stream":true + } + ` + const output = `event: response.created +data: {"type":"response.created","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-mini","output":[]}} + +event: response.failed +data: {"type":"response.failed","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"failed","model":"gpt-4o-mini","output":[],"error":{"code":"server_error","message":"Internal error"}}} + +` + server := newTestResponsesServerStreaming(t, input, output) + defer server.Close() + a := newTestResponsesClient(server, "gpt-4o-mini") + + var errContent *message.ErrorContent + for update, err := range a.RunText(t.Context(), "test", agent.Stream(true)) { + if err != nil { + t.Fatalf("error = %v", err) + } + for _, c := range update.Contents { + if ec, ok := c.(*message.ErrorContent); ok { + errContent = ec + } + } + } + if errContent == nil { + t.Fatal("expected an ErrorContent for the failed response, got none") + } + if errContent.Message != "Internal error" { + t.Errorf("error message = %q, want %q", errContent.Message, "Internal error") + } + if errContent.ErrorCode != "server_error" { + t.Errorf("error code = %q, want %q", errContent.ErrorCode, "server_error") + } +} + +// TestResponsesStreamingFailedResponseSurfacesCodeOnlyError guards the case where a +// failed response carries an error code but no message. The failure must still be +// surfaced as ErrorContent rather than collapsing into an empty update. +func TestResponsesStreamingFailedResponseSurfacesCodeOnlyError(t *testing.T) { + const input = ` + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"test"}]}], + "stream":true + } + ` + const output = `event: response.created +data: {"type":"response.created","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-mini","output":[]}} + +event: response.failed +data: {"type":"response.failed","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"failed","model":"gpt-4o-mini","output":[],"error":{"code":"server_error"}}} + +` + server := newTestResponsesServerStreaming(t, input, output) + defer server.Close() + a := newTestResponsesClient(server, "gpt-4o-mini") + + var errContent *message.ErrorContent + for update, err := range a.RunText(t.Context(), "test", agent.Stream(true)) { + if err != nil { + t.Fatalf("error = %v", err) + } + for _, c := range update.Contents { + if ec, ok := c.(*message.ErrorContent); ok { + errContent = ec + } + } + } + if errContent == nil { + t.Fatal("expected an ErrorContent for the code-only failed response, got none") + } + if errContent.ErrorCode != "server_error" { + t.Errorf("error code = %q, want %q", errContent.ErrorCode, "server_error") + } + if errContent.Message != "" { + t.Errorf("error message = %q, want empty", errContent.Message) + } +}