#7

`error` and `response.failed` frames terminate the stream successfully, hiding API errors

Openbalassitopened 3mo ago

Summary

When the Responses API sends an error or response.failed frame, the shim treats it exactly like response.completed: it forwards the frame, emits data: [DONE], and closes the stream cleanly. Consumers therefore see a successful, empty response rather than an error.

With the AI SDK this means streamText resolves normally with no text, zero token usage, and an SDK-synthesised response id (aitxt-…, from id: result.response?.id ?? generateId()), because the provider never sent one. Nothing throws and nothing is logged.

Where

packages/ai-sdk-openai-websocket-fetch/src/index.ts, in onMessage:

const event = JSON.parse(text);
if (
  event.type === 'response.completed' ||
  event.type === 'response.failed' ||
  event.type === 'response.incomplete' ||
  event.type === 'error'
) {
  controller.enqueue(encoder.encode('data: [DONE]\n\n'));
  cleanup();
  controller.close();
}

All four types take the same branch. error and response.failed are failures and shouldn't terminate the stream successfully. (response.incomplete is arguably fine to close cleanly — the partial output is still usable.)

The frame is forwarded before [DONE], but a bare {"type":"error", ...} isn't something the AI SDK's Responses parser recognises as an error, so it's effectively discarded.

Reproduction

Any request the API rejects at validation time. The one we hit:

// request
{ "tools": [ /* ...only non-deferred tools... */, { "type": "tool_search" } ] }
// frame received over the WebSocket
{
  "type": "error",
  "status": 400,
  "error": {
    "type": "invalid_request_error",
    "param": "tools.tool_search",
    "message": "Invalid Value: 'tools.tool_search'. tools.tool_search requires at least one deferred tool."
  }
}

Over plain HTTPS this surfaces as an AI_APICallError with the message above. Over the WebSocket transport it surfaces as a completed turn with an empty string.

Impact

This is a hard 400 — a deterministic, fully-diagnosable client error — presented to the application as success. In our case users got blank assistant replies with nothing in the logs at any level; we only recovered the message above by putting a logging WebSocket proxy between the app and the API. Any validation error on the request body behaves this way.

Suggested fix

Split the terminal types and error the stream on failures, e.g.:

const FAILURE_EVENT_TYPES = new Set(['error', 'response.failed']);

if (event && FAILURE_EVENT_TYPES.has(event.type)) {
  const detail = event.error ?? event.response?.error ?? {};
  cleanup();
  controller.error(
    new Error(
      `OpenAI WebSocket ${event.type}` +
      (detail.message ? `: ${detail.message}` : '') +
      (detail.code ?? detail.type ? ` (${detail.code ?? detail.type})` : ''),
    ),
  );
  return;
}

controller.error() propagates through the SSE response body to the SDK, so callers get a rejection instead of an empty result.

Environment

  • ai-sdk-openai-websocket-fetch @ main (bb59a5e)
  • ai@7.0.48, @ai-sdk/openai@4.0.27
  • Node 24, wss://api.openai.com/v1/responses, OpenAI-Beta: responses_websockets=2026-02-06

Activity

0

No activity yet.