From bd0dffd7815b850389555ffe833bec1862af6717 Mon Sep 17 00:00:00 2001 From: Harsh Mathur <58532371+harshmathurx@users.noreply.github.com> Date: Fri, 10 Jul 2026 01:10:28 +0530 Subject: [PATCH] fix(llm): preserve nested OpenAI stream errors (#36130) Co-authored-by: Harsh Mathur Co-authored-by: Aiden Cline --- .../llm/src/protocols/openai-responses.ts | 19 +++--- .../test/provider/openai-responses.test.ts | 61 ++++++++++++++++++- 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index e2889d5b6d..c6d621f152 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -198,11 +198,11 @@ const OpenAIResponsesStreamItem = Schema.Struct({ }) type OpenAIResponsesStreamItem = Schema.Schema.Type -// OpenAI Responses surfaces provider failures in two related shapes. The -// streaming `error` event carries the details at the top level -// (`{ type: "error", code, message, param, sequence_number }`), while -// `response.failed` carries them under `response.error`. We capture both so -// the parser can surface a useful provider-error message in either path. +// The Responses schema puts streaming error details at the top level and +// response failures under `response.error`. The official SDK also recognizes +// an event-level HTTP-style `error` envelope, so accept all three shapes here. +// https://github.com/openai/openai-openapi/blob/5162af98d3147432c14680df789e8e12d4891e6b/openapi.yaml#L67234-L67382 +// https://github.com/openai/openai-node/blob/61539248cbe04665de68a71e6fd878127ae4db87/src/core/streaming.ts#L58-L85 const OpenAIResponsesErrorPayload = Schema.Struct({ code: optionalNull(Schema.String), message: optionalNull(Schema.String), @@ -227,9 +227,10 @@ const OpenAIResponsesEvent = Schema.Struct({ [Schema.Record(Schema.String, Schema.Unknown)], ), ), - code: Schema.optional(Schema.String), + code: optionalNull(Schema.String), message: Schema.optional(Schema.String), - param: Schema.optional(Schema.String), + param: optionalNull(Schema.String), + error: optionalNull(OpenAIResponsesErrorPayload), }) type OpenAIResponsesEvent = Schema.Schema.Type @@ -899,7 +900,7 @@ const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): Step // the bare message — production rate limits and context-length failures used // to be indistinguishable from generic stream drops. const providerErrorMessage = (event: OpenAIResponsesEvent, fallback: string): string => { - const nested = event.response?.error ?? undefined + const nested = event.error ?? event.response?.error ?? undefined const message = event.message || nested?.message || undefined const code = event.code || nested?.code || undefined if (message && code) return `${code}: ${message}` @@ -907,7 +908,7 @@ const providerErrorMessage = (event: OpenAIResponsesEvent, fallback: string): st } const providerError = (event: OpenAIResponsesEvent, fallback: string) => { - const code = event.code || event.response?.error?.code || undefined + const code = event.code || event.error?.code || event.response?.error?.code || undefined const message = providerErrorMessage(event, fallback) return LLMEvent.providerError({ message, diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 3d048d4eee..421617b6db 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -1443,7 +1443,7 @@ describe("OpenAI Responses route", () => { }), ) - it.effect("surfaces error event details even when they arrive nested under response.error", () => + it.effect("surfaces error event details nested under response.error", () => Effect.gen(function* () { // Some OpenAI-compatible proxies and older SDK versions wrap the // top-level error fields into a nested `response.error` payload @@ -1471,6 +1471,65 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("surfaces error event details nested under error", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents({ + type: "error", + sequence_number: 2, + error: { + type: "invalid_request_error", + code: "context_length_exceeded", + message: "prompt too long", + param: "input", + }, + }), + ), + ), + ) + + expect(response.events).toEqual([ + { + type: "provider-error", + message: "context_length_exceeded: prompt too long", + classification: "context-overflow", + }, + ]) + }), + ) + + it.effect("accepts nullable fields in spec-compliant error events", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents({ + type: "error", + code: null, + message: "Something went wrong", + param: null, + sequence_number: 1, + }), + ), + ), + ) + + expect(response.events).toEqual([{ type: "provider-error", message: "Something went wrong" }]) + }), + ) + + it.effect("falls back to a stable default when error is null", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseEvents({ type: "error", error: null }))), + ) + + expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }]) + }), + ) + it.effect("falls back to a stable default when both error and response are absent", () => Effect.gen(function* () { const response = yield* LLMClient.generate(request).pipe(