fix(ai): handle remaining provider failures
This commit is contained in:
parent
7c655f6a61
commit
0eacf8d736
8 changed files with 332 additions and 40 deletions
|
|
@ -492,12 +492,53 @@ it.effect("classifies structured AI SDK stream errors", () =>
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies structured stream messages without codes", () =>
|
||||
it.effect("classifies AI Gateway errors with statusCode", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
streamingLanguage({ type: "error", error: { message: "Rate limit exceeded" } }),
|
||||
streamingLanguage({
|
||||
type: "error",
|
||||
error: { type: "internal_server_error", message: "Gateway failed", statusCode: 503 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal", status: 503 } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies AI Gateway timeouts as retryable", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
streamingLanguage({
|
||||
type: "error",
|
||||
error: { type: "timeout_error", message: "Gateway timed out", statusCode: 408 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "Transport", kind: "Timeout" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies stripped Bedrock model stream errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
streamingLanguage({
|
||||
type: "error",
|
||||
error: {
|
||||
message: "The model stream failed",
|
||||
originalMessage: "Upstream provider failed",
|
||||
originalStatusCode: 424,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal", status: 424 } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies structured stream messages without codes", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(streamingLanguage({ type: "error", error: { message: "Rate limit exceeded" } }))
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "RateLimit" } })
|
||||
}),
|
||||
)
|
||||
|
|
@ -528,11 +569,81 @@ it.effect("classifies numeric string stream statuses", () =>
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies readable stream failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure({
|
||||
...failingLanguage(new Error("unused")),
|
||||
doStream: async () => ({
|
||||
stream: new ReadableStream<LanguageModelV3StreamPart>({
|
||||
start(controller) {
|
||||
controller.error(Object.assign(new Error("connection reset"), { code: "ECONNRESET" }))
|
||||
},
|
||||
}),
|
||||
request: { body: {} },
|
||||
}),
|
||||
})
|
||||
|
||||
expect(error).toMatchObject({ method: "readStream", reason: { _tag: "Transport", kind: "ECONNRESET" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("times out stalled SSE chunks", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
port: 0,
|
||||
fetch: () =>
|
||||
new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(": connected\n\n"))
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
}),
|
||||
),
|
||||
(server) =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
let wrappedFetch: typeof fetch | undefined
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
wrappedFetch = event.options.fetch
|
||||
event.sdk = {}
|
||||
})
|
||||
yield* aisdk.hook.language((event) => {
|
||||
event.language = {
|
||||
...failingLanguage(new Error("unused")),
|
||||
doStream: async () => {
|
||||
const fetcher = wrappedFetch
|
||||
if (!fetcher) throw new Error("AI SDK fetch was not configured")
|
||||
const response = await fetcher(server.url, { method: "POST" })
|
||||
if (!response.body) throw new Error("AI SDK response body was missing")
|
||||
return {
|
||||
stream: response.body.pipeThrough(
|
||||
new TransformStream<Uint8Array, LanguageModelV3StreamPart>({ transform() {} }),
|
||||
),
|
||||
request: { body: {} },
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
const resolved = yield* aisdk.model(model("@ai-sdk/openai", { chunkTimeout: 10 }))
|
||||
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||
const prepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(request)
|
||||
const error = yield* resolved.route
|
||||
.streamPrepared(prepared.body, request, { http: { execute: () => Effect.die("unused") } })
|
||||
.pipe(Stream.runDrain, Effect.flip)
|
||||
|
||||
expect(error).toMatchObject({ method: "readStream", reason: { _tag: "Transport", kind: "Timeout" } })
|
||||
}),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("classifies missing AI SDK API keys", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
failingLanguage(new LoadAPIKeyError({ message: "API key is missing" })),
|
||||
)
|
||||
const error = yield* streamFailure(failingLanguage(new LoadAPIKeyError({ message: "API key is missing" })))
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "Authentication", kind: "missing" } })
|
||||
}),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue