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:
co-authored by
Harsh Mathur
Aiden Cline
parent
0ac8f91ed6
commit
bd0dffd781
@@ -198,11 +198,11 @@ const OpenAIResponsesStreamItem = Schema.Struct({
|
|||||||
})
|
})
|
||||||
type OpenAIResponsesStreamItem = Schema.Schema.Type<typeof OpenAIResponsesStreamItem>
|
type OpenAIResponsesStreamItem = Schema.Schema.Type<typeof OpenAIResponsesStreamItem>
|
||||||
|
|
||||||
// OpenAI Responses surfaces provider failures in two related shapes. The
|
// The Responses schema puts streaming error details at the top level and
|
||||||
// streaming `error` event carries the details at the top level
|
// response failures under `response.error`. The official SDK also recognizes
|
||||||
// (`{ type: "error", code, message, param, sequence_number }`), while
|
// an event-level HTTP-style `error` envelope, so accept all three shapes here.
|
||||||
// `response.failed` carries them under `response.error`. We capture both so
|
// https://github.com/openai/openai-openapi/blob/5162af98d3147432c14680df789e8e12d4891e6b/openapi.yaml#L67234-L67382
|
||||||
// the parser can surface a useful provider-error message in either path.
|
// https://github.com/openai/openai-node/blob/61539248cbe04665de68a71e6fd878127ae4db87/src/core/streaming.ts#L58-L85
|
||||||
const OpenAIResponsesErrorPayload = Schema.Struct({
|
const OpenAIResponsesErrorPayload = Schema.Struct({
|
||||||
code: optionalNull(Schema.String),
|
code: optionalNull(Schema.String),
|
||||||
message: optionalNull(Schema.String),
|
message: optionalNull(Schema.String),
|
||||||
@@ -227,9 +227,10 @@ const OpenAIResponsesEvent = Schema.Struct({
|
|||||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
code: Schema.optional(Schema.String),
|
code: optionalNull(Schema.String),
|
||||||
message: Schema.optional(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>
|
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
|
// the bare message — production rate limits and context-length failures used
|
||||||
// to be indistinguishable from generic stream drops.
|
// to be indistinguishable from generic stream drops.
|
||||||
const providerErrorMessage = (event: OpenAIResponsesEvent, fallback: string): string => {
|
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 message = event.message || nested?.message || undefined
|
||||||
const code = event.code || nested?.code || undefined
|
const code = event.code || nested?.code || undefined
|
||||||
if (message && code) return `${code}: ${message}`
|
if (message && code) return `${code}: ${message}`
|
||||||
@@ -907,7 +908,7 @@ const providerErrorMessage = (event: OpenAIResponsesEvent, fallback: string): st
|
|||||||
}
|
}
|
||||||
|
|
||||||
const providerError = (event: OpenAIResponsesEvent, fallback: string) => {
|
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)
|
const message = providerErrorMessage(event, fallback)
|
||||||
return LLMEvent.providerError({
|
return LLMEvent.providerError({
|
||||||
message,
|
message,
|
||||||
|
|||||||
@@ -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* () {
|
Effect.gen(function* () {
|
||||||
// Some OpenAI-compatible proxies and older SDK versions wrap the
|
// Some OpenAI-compatible proxies and older SDK versions wrap the
|
||||||
// top-level error fields into a nested `response.error` payload
|
// 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", () =>
|
it.effect("falls back to a stable default when both error and response are absent", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
|||||||
Reference in New Issue
Block a user