Skip to content

Commit d0cb587

Browse files
authored
fix(llm): surface code, type, and nested fields on provider stream errors (anomalyco#28757)
1 parent a3430db commit d0cb587

4 files changed

Lines changed: 170 additions & 6 deletions

File tree

packages/llm/src/protocols/anthropic-messages.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,13 @@ const AnthropicEvent = Schema.Struct({
206206
content_block: Schema.optional(AnthropicStreamBlock),
207207
delta: Schema.optional(AnthropicStreamDelta),
208208
usage: Schema.optional(AnthropicUsage),
209-
error: Schema.optional(Schema.Struct({ type: Schema.String, message: Schema.String })),
209+
// `type` and `message` are both required per Anthropic's spec, but
210+
// OpenAI-compatible proxies and gateway translations occasionally drop one
211+
// or the other; mark them optional so a partial payload still parses and
212+
// the parser can fall back to whichever field is populated.
213+
error: Schema.optional(
214+
Schema.Struct({ type: Schema.optional(Schema.String), message: Schema.optional(Schema.String) }),
215+
),
210216
})
211217
type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
212218

@@ -701,9 +707,18 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult =
701707
return [{ ...state, lifecycle, usage }, events]
702708
}
703709

710+
// Prefix `error.type` so overloads, rate limits, and quota errors are visible
711+
// even when the provider message is generic or empty.
712+
const providerErrorMessage = (event: AnthropicEvent): string => {
713+
const type = event.error?.type
714+
const message = event.error?.message
715+
if (type && message) return `${type}: ${message}`
716+
return message || type || "Anthropic Messages stream error"
717+
}
718+
704719
const onError = (state: ParserState, event: AnthropicEvent): StepResult => [
705720
state,
706-
[LLMEvent.providerError({ message: event.error?.message ?? "Anthropic Messages stream error" })],
721+
[LLMEvent.providerError({ message: providerErrorMessage(event) })],
707722
]
708723

709724
const step = (state: ParserState, event: AnthropicEvent) => {

packages/llm/src/protocols/openai-responses.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,17 @@ const OpenAIResponsesStreamItem = Schema.Struct({
178178
})
179179
type OpenAIResponsesStreamItem = Schema.Schema.Type<typeof OpenAIResponsesStreamItem>
180180

181+
// OpenAI Responses surfaces provider failures in two related shapes. The
182+
// streaming `error` event carries the details at the top level
183+
// (`{ type: "error", code, message, param, sequence_number }`), while
184+
// `response.failed` carries them under `response.error`. We capture both so
185+
// the parser can surface a useful provider-error message in either path.
186+
const OpenAIResponsesErrorPayload = Schema.Struct({
187+
code: optionalNull(Schema.String),
188+
message: optionalNull(Schema.String),
189+
param: optionalNull(Schema.String),
190+
})
191+
181192
const OpenAIResponsesEvent = Schema.Struct({
182193
type: Schema.String,
183194
delta: Schema.optional(Schema.String),
@@ -190,12 +201,14 @@ const OpenAIResponsesEvent = Schema.Struct({
190201
service_tier: optionalNull(Schema.String),
191202
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.String })),
192203
usage: optionalNull(OpenAIResponsesUsage),
204+
error: optionalNull(OpenAIResponsesErrorPayload),
193205
}),
194206
[Schema.Record(Schema.String, Schema.Unknown)],
195207
),
196208
),
197209
code: Schema.optional(Schema.String),
198210
message: Schema.optional(Schema.String),
211+
param: Schema.optional(Schema.String),
199212
})
200213
type OpenAIResponsesEvent = Schema.Schema.Type<typeof OpenAIResponsesEvent>
201214

@@ -633,14 +646,27 @@ const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): Step
633646
return [{ ...state, lifecycle }, events]
634647
}
635648

649+
// Build a single human-readable message from whatever the provider supplied.
650+
// When both code and message are present, prefix the code so consumers see
651+
// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just
652+
// the bare message — production rate limits and context-length failures used
653+
// to be indistinguishable from generic stream drops.
654+
const providerErrorMessage = (event: OpenAIResponsesEvent, fallback: string): string => {
655+
const nested = event.response?.error ?? undefined
656+
const message = event.message || nested?.message || undefined
657+
const code = event.code || nested?.code || undefined
658+
if (message && code) return `${code}: ${message}`
659+
return message || code || fallback
660+
}
661+
636662
const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
637663
state,
638-
[LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses response failed" })],
664+
[LLMEvent.providerError({ message: providerErrorMessage(event, "OpenAI Responses response failed") })],
639665
]
640666

641667
const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
642668
state,
643-
[LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses stream error" })],
669+
[LLMEvent.providerError({ message: providerErrorMessage(event, "OpenAI Responses stream error") })],
644670
]
645671

646672
const step = (state: ParserState, event: OpenAIResponsesEvent) => {

packages/llm/test/provider/anthropic-messages.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,29 @@ describe("Anthropic Messages route", () => {
337337
),
338338
)
339339

340-
expect(response.events).toEqual([{ type: "provider-error", message: "Overloaded" }])
340+
// Prefix the error type so consumers can distinguish overloads, rate
341+
// limits, and quota errors without parsing the message string.
342+
expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error: Overloaded" }])
343+
}),
344+
)
345+
346+
it.effect("falls back to error type when no message is present", () =>
347+
Effect.gen(function* () {
348+
const response = yield* LLMClient.generate(request).pipe(
349+
Effect.provide(fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "" } }))),
350+
)
351+
352+
expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error" }])
353+
}),
354+
)
355+
356+
it.effect("falls back to a stable default when error payload is absent", () =>
357+
Effect.gen(function* () {
358+
const response = yield* LLMClient.generate(request).pipe(
359+
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
360+
)
361+
362+
expect(response.events).toEqual([{ type: "provider-error", message: "Anthropic Messages stream error" }])
341363
}),
342364
)
343365

packages/llm/test/provider/openai-responses.test.ts

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -877,7 +877,11 @@ describe("OpenAI Responses route", () => {
877877
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", message: "Slow down" }))),
878878
)
879879

880-
expect(response.events).toEqual([{ type: "provider-error", message: "Slow down" }])
880+
// Prefix the code so consumers see the failure mode, not just the
881+
// sometimes-generic provider message. The bare message alone meant
882+
// production errors like rate limits were indistinguishable from
883+
// unrelated stream failures.
884+
expect(response.events).toEqual([{ type: "provider-error", message: "rate_limit_exceeded: Slow down" }])
881885
}),
882886
)
883887

@@ -891,6 +895,103 @@ describe("OpenAI Responses route", () => {
891895
}),
892896
)
893897

898+
it.effect("falls back to error code when message is empty", () =>
899+
Effect.gen(function* () {
900+
const response = yield* LLMClient.generate(request).pipe(
901+
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error", message: "" }))),
902+
)
903+
904+
expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }])
905+
}),
906+
)
907+
908+
// Regression: `response.failed` carries the failure details under
909+
// `response.error`, not at the top level. The previous handler only
910+
// checked top-level `message`/`code` and so always emitted the bare
911+
// "OpenAI Responses response failed" string, hiding the real cause.
912+
it.effect("surfaces response.failed details from response.error", () =>
913+
Effect.gen(function* () {
914+
const response = yield* LLMClient.generate(request).pipe(
915+
Effect.provide(
916+
fixedResponse(
917+
sseEvents({
918+
type: "response.failed",
919+
response: {
920+
id: "resp_failed_1",
921+
error: { code: "server_error", message: "Upstream model unavailable" },
922+
},
923+
}),
924+
),
925+
),
926+
)
927+
928+
expect(response.events).toEqual([
929+
{ type: "provider-error", message: "server_error: Upstream model unavailable" },
930+
])
931+
}),
932+
)
933+
934+
it.effect("surfaces response.failed code when no nested message is present", () =>
935+
Effect.gen(function* () {
936+
const response = yield* LLMClient.generate(request).pipe(
937+
Effect.provide(
938+
fixedResponse(
939+
sseEvents({
940+
type: "response.failed",
941+
response: { id: "resp_failed_2", error: { code: "invalid_prompt" } },
942+
}),
943+
),
944+
),
945+
)
946+
947+
expect(response.events).toEqual([{ type: "provider-error", message: "invalid_prompt" }])
948+
}),
949+
)
950+
951+
it.effect("surfaces error event details even when they arrive nested under response.error", () =>
952+
Effect.gen(function* () {
953+
// Some OpenAI-compatible proxies and older SDK versions wrap the
954+
// top-level error fields into a nested `response.error` payload
955+
// when they bubble up an HTTP error as an SSE `error` event. Honour
956+
// both shapes so the user still sees the underlying cause instead
957+
// of the catch-all string.
958+
const response = yield* LLMClient.generate(request).pipe(
959+
Effect.provide(
960+
fixedResponse(
961+
sseEvents({
962+
type: "error",
963+
response: { error: { code: "context_length_exceeded", message: "prompt too long" } },
964+
}),
965+
),
966+
),
967+
)
968+
969+
expect(response.events).toEqual([
970+
{ type: "provider-error", message: "context_length_exceeded: prompt too long" },
971+
])
972+
}),
973+
)
974+
975+
it.effect("falls back to a stable default when both error and response are absent", () =>
976+
Effect.gen(function* () {
977+
const response = yield* LLMClient.generate(request).pipe(
978+
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
979+
)
980+
981+
expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }])
982+
}),
983+
)
984+
985+
it.effect("falls back to a stable default when response.failed has no error payload", () =>
986+
Effect.gen(function* () {
987+
const response = yield* LLMClient.generate(request).pipe(
988+
Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))),
989+
)
990+
991+
expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses response failed" }])
992+
}),
993+
)
994+
894995
it.effect("fails HTTP provider errors before stream parsing", () =>
895996
Effect.gen(function* () {
896997
const error = yield* LLMClient.generate(request).pipe(

0 commit comments

Comments
 (0)