fix(llm): preserve nested OpenAI stream errors (#36130)

Co-authored-by: Harsh Mathur <jeevesforharsh@gmail.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
This commit is contained in:
Harsh Mathur 2026-07-10 01:10:28 +05:30 committed by GitHub
commit bd0dffd781
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 70 additions and 10 deletions

View file

@ -198,11 +198,11 @@ const OpenAIResponsesStreamItem = Schema.Struct({
})
type OpenAIResponsesStreamItem = Schema.Schema.Type<typeof OpenAIResponsesStreamItem>
// 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<typeof OpenAIResponsesEvent>
@ -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,

View file

@ -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(