Procházet zdrojové kódy

fix(core): derive fallback message for empty AI SDK provider errors (#41450)

Aiden Cline před 6 dny
rodič
revize
16aad9e6ad

+ 41 - 5
packages/ai/src/route/executor.ts

@@ -185,15 +185,15 @@ const secretValues = (request: HttpClientRequest.HttpClientRequest) => {
 // Two passes: structural (redact `"name": "value"` and `name=value` patterns
 // for any field name that looks sensitive) plus literal (replace any actual
 // secret values we sent in the request, in case the response echoes one back).
-const redactBody = (body: string, request: HttpClientRequest.HttpClientRequest) =>
-  Array.from(secretValues(request)).reduce(
+const redactBody = (body: string, secrets: ReadonlySet<string>) =>
+  Array.from(secrets).reduce(
     (text, secret) => text.split(secret).join(REDACTED),
     body.replace(REDACT_JSON_FIELD, `$1"${REDACTED}"`).replace(REDACT_QUERY_FIELD, `$1${REDACTED}`),
   )
 
-const responseBody = (body: string | void, request: HttpClientRequest.HttpClientRequest) => {
+const responseBody = (body: string | void, secrets: ReadonlySet<string>) => {
   if (body === undefined) return {}
-  const redacted = redactBody(body, request)
+  const redacted = redactBody(body, secrets)
   if (redacted.length <= BODY_LIMIT) return { body: redacted }
   return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true }
 }
@@ -240,7 +240,7 @@ const statusError =
       const headers = normalizedHeaders(response.headers)
       const retryAfter = retryAfterMs(headers)
       const rateLimit = rateLimitDetails(headers, retryAfter)
-      const details = responseBody(body, request)
+      const details = responseBody(body, secretValues(request))
       return yield* new AIError({
         module: "RequestExecutor",
         method: "execute",
@@ -261,6 +261,42 @@ const statusError =
       })
     })
 
+// Classifies an HTTP failure captured outside the executor (for example by the
+// AI SDK's own fetch) onto the same reason types and redacted HttpContext that
+// executor-driven requests produce. The originating request is not available on
+// that path, so the method is assumed (language model calls are always POST),
+// request headers are empty, and only structural body redaction applies.
+export const classifyHttpFailure = (input: {
+  readonly message: string
+  readonly url: string
+  readonly status?: number | undefined
+  readonly code?: string | undefined
+  readonly responseHeaders?: Record<string, string> | undefined
+  readonly responseBody?: string | undefined
+}) => {
+  const headers = normalizedHeaders(Headers.fromInput(input.responseHeaders))
+  const retryAfter = retryAfterMs(headers)
+  const rateLimit = rateLimitDetails(headers, retryAfter)
+  const details = responseBody(input.responseBody ?? undefined, new Set<string>())
+  return classifyProviderFailure({
+    message: input.message,
+    status: input.status,
+    code: input.code,
+    retryAfterMs: retryAfter,
+    rateLimit,
+    http: new HttpContext({
+      request: new HttpRequestDetails({ method: "POST", url: redactUrl(input.url), headers: {} }),
+      response:
+        input.status === undefined
+          ? undefined
+          : new HttpResponseDetails({ status: input.status, headers: redactHeaders(Headers.fromInput(headers), []) }),
+      ...details,
+      requestId: requestId(headers),
+      rateLimit,
+    }),
+  })
+}
+
 const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
   const transportError = (input: {
     readonly message: string

+ 59 - 2
packages/core/src/aisdk.ts

@@ -1,6 +1,7 @@
 export * as AISDK from "./aisdk"
 
 import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
+import { APICallError } from "@ai-sdk/provider"
 import type {
   JSONSchema7,
   JSONValue,
@@ -22,6 +23,7 @@ import {
   LanguageModel,
   ProviderID,
   ProviderMetadata,
+  TransportReason,
   ToolResultValue,
   UnknownProviderReason,
   type ContentPart,
@@ -29,7 +31,7 @@ import {
   type ToolDefinition,
   type UsageInput,
 } from "@opencode-ai/ai"
-import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
+import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode-ai/ai/route"
 import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
 import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
 import type { ID, Info } from "./model"
@@ -723,7 +725,9 @@ function llmError(method: string, error: unknown) {
   const reason =
     error instanceof AIError
       ? new InvalidProviderOutputReason({ message: error.message })
-      : new UnknownProviderReason({ message: error instanceof Error ? error.message : String(error) })
+      : APICallError.isInstance(error)
+        ? apiCallErrorReason(error)
+        : new UnknownProviderReason({ message: unknownErrorMessage(error) })
   return new AIError({
     module: "AISDK",
     method,
@@ -731,4 +735,57 @@ function llmError(method: string, error: unknown) {
   })
 }
 
+function apiCallErrorReason(error: APICallError) {
+  const details = providerErrorDetails(error)
+  const reason = RequestExecutor.classifyHttpFailure({
+    message: details.message,
+    url: error.url,
+    status: error.statusCode,
+    code: details.code,
+    responseHeaders: error.responseHeaders,
+    responseBody: error.responseBody,
+  })
+  if (error.statusCode !== undefined || !error.isRetryable) return reason
+  return new TransportReason({
+    message: reason.message,
+    kind: error.name,
+    url: error.url,
+    http: "http" in reason ? reason.http : undefined,
+  })
+}
+
+const ProviderErrorCode = Schema.Union([Schema.String, Schema.Finite])
+const ProviderErrorDetail = Schema.Struct({
+  message: Schema.optionalKey(Schema.String),
+  code: Schema.optionalKey(ProviderErrorCode),
+})
+const ProviderErrorBody = Schema.Struct({
+  ...ProviderErrorDetail.fields,
+  error: Schema.optionalKey(ProviderErrorDetail),
+})
+const decodeProviderError = Schema.decodeUnknownOption(
+  Schema.Union([ProviderErrorBody, Schema.fromJsonString(ProviderErrorBody)]),
+)
+
+function unknownErrorMessage(error: unknown) {
+  const message = error instanceof Error ? error.message : String(error)
+  return message.trim() === "" ? "Provider request failed" : message
+}
+
+function providerErrorDetails(error: APICallError) {
+  const data = Option.getOrUndefined(decodeProviderError(error.data))
+  const body = Option.getOrUndefined(decodeProviderError(error.responseBody))
+  const details = [data?.error, data, body?.error, body]
+  const message = details.map((detail) => detail?.message).find((value) => value?.trim())
+  const value = details.map((detail) => detail?.code).find((value) => value !== undefined)
+  const code = value === undefined ? undefined : String(value)
+  const prefix =
+    error.statusCode === undefined ? "Provider request failed" : `Provider request failed with HTTP ${error.statusCode}`
+  return {
+    code,
+    message:
+      error.message.trim() !== "" ? error.message : (message ?? (code === undefined ? prefix : `${prefix}: ${code}`)),
+  }
+}
+
 export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })

+ 171 - 1
packages/core/test/aisdk.test.ts

@@ -1,8 +1,11 @@
+import { APICallError } from "@ai-sdk/provider"
 import type { LanguageModelV3, LanguageModelV3StreamPart } from "@ai-sdk/provider"
 import { AISDK } from "@opencode-ai/core/aisdk"
+import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
+import { toSessionError } from "@opencode-ai/core/session/to-session-error"
 import { Model } from "@opencode-ai/core/model"
 import { Provider } from "@opencode-ai/core/provider"
-import { LLM, AIError, LLMEvent, Message } from "@opencode-ai/ai"
+import { LLM, AIError, LLMEvent, Message, isContextOverflowFailure } from "@opencode-ai/ai"
 import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
 import { compileRequest } from "@opencode-ai/ai/route/client"
 import { expect } from "bun:test"
@@ -337,3 +340,170 @@ it.effect("keeps malformed provider-executed AI SDK input terminal", () =>
     expect(error.message).toContain("Invalid JSON input for aisdk tool call web_search")
   }),
 )
+
+const failingModel = (failure: unknown): LanguageModelV3 => ({
+  specificationVersion: "v3",
+  provider: "test",
+  modelId: "test",
+  supportedUrls: {},
+  doGenerate: () => Promise.reject(new Error("Unexpected non-streaming request")),
+  doStream: () => Promise.reject(failure),
+})
+
+const streamFailure = (failure: unknown) =>
+  Effect.gen(function* () {
+    const aisdk = yield* AISDK.Service
+    yield* aisdk.hook.sdk((event) => {
+      event.sdk = { languageModel: () => failingModel(failure) }
+    })
+    const resolved = yield* aisdk.model(model("test-ai-sdk"))
+    return yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Hello" })).pipe(
+      Effect.provide(client),
+      Effect.flip,
+    )
+  })
+
+it.effect("preserves non-empty AI SDK error messages", () =>
+  Effect.gen(function* () {
+    const error = yield* streamFailure(new Error("Bad Request"))
+    expect(error).toBeInstanceOf(AIError)
+    expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "Bad Request" })
+  }),
+)
+
+const apiCallError = (input: Partial<ConstructorParameters<typeof APICallError>[0]>) =>
+  new APICallError({
+    message: "",
+    url: "https://api.example.com/chat",
+    requestBodyValues: { messages: [{ role: "user", content: "private prompt" }] },
+    responseHeaders: { authorization: "Bearer secret-token" },
+    ...input,
+  })
+
+it.effect("derives status and code when the AI SDK error message is empty", () =>
+  Effect.gen(function* () {
+    const error = yield* streamFailure(
+      apiCallError({
+        statusCode: 404,
+        responseBody: '{"error":{"message":"","code":"not_found"}}',
+        data: { error: { message: "", code: "not_found" } },
+      }),
+    )
+    expect(error.reason.message).toBe("Provider request failed with HTTP 404: not_found")
+    expect(error.reason.message).not.toContain("secret-token")
+    expect(error.reason.message).not.toContain("private prompt")
+    const projected = toSessionError(error)
+    expect(projected.type).toBe("provider.invalid-request")
+    expect(projected.status).toBe(404)
+    expect(projected.message).not.toBe("")
+  }),
+)
+
+it.effect("preserves redacted HTTP context on AI SDK call errors", () =>
+  Effect.gen(function* () {
+    const error = yield* streamFailure(
+      apiCallError({
+        statusCode: 404,
+        responseBody: '{"error":{"message":"","code":"not_found"}}',
+      }),
+    )
+    expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
+    const http = "http" in error.reason ? error.reason.http : undefined
+    expect(http?.request.url).toBe("https://api.example.com/chat")
+    expect(http?.response?.status).toBe(404)
+    expect(http?.response?.headers["authorization"]).toBe("<redacted>")
+    expect(http?.body).toBe('{"error":{"message":"","code":"not_found"}}')
+  }),
+)
+
+it.effect("classifies retryable AI SDK failures with retry-after details", () =>
+  Effect.gen(function* () {
+    const error = yield* streamFailure(
+      apiCallError({
+        statusCode: 429,
+        responseHeaders: { "retry-after": "7" },
+      }),
+    )
+    expect(error.reason).toMatchObject({ _tag: "RateLimit", retryAfterMs: 7000 })
+  }),
+)
+
+it.effect("classifies data-only AI SDK provider codes", () =>
+  Effect.gen(function* () {
+    const error = yield* streamFailure(
+      apiCallError({
+        statusCode: 400,
+        data: { error: { code: "api_error" } },
+      }),
+    )
+    expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 400 })
+    expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
+  }),
+)
+
+it.effect("classifies data-only AI SDK authentication errors", () =>
+  Effect.gen(function* () {
+    const error = yield* streamFailure(
+      apiCallError({
+        statusCode: 400,
+        data: { error: { code: "authentication_error" } },
+      }),
+    )
+    expect(error.reason).toMatchObject({ _tag: "Authentication", kind: "invalid" })
+    expect(SessionRunnerRetry.isRetryable(error)).toBeFalse()
+  }),
+)
+
+it.effect("detects context overflow from data-only AI SDK errors", () =>
+  Effect.gen(function* () {
+    const error = yield* streamFailure(
+      apiCallError({
+        statusCode: 400,
+        data: { error: { code: "context_length_exceeded" } },
+      }),
+    )
+    expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
+    expect(isContextOverflowFailure(error)).toBeTrue()
+  }),
+)
+
+it.effect("retries status-less AI SDK transport failures", () =>
+  Effect.gen(function* () {
+    const error = yield* streamFailure(
+      apiCallError({
+        message: "Cannot connect to API: connection refused",
+        isRetryable: true,
+      }),
+    )
+    expect(error.reason).toMatchObject({ _tag: "Transport", kind: "AI_APICallError" })
+    expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
+    expect("http" in error.reason ? error.reason.http?.request.url : undefined).toBe("https://api.example.com/chat")
+  }),
+)
+
+it.effect("prefers a structured provider message over the code fallback", () =>
+  Effect.gen(function* () {
+    const error = yield* streamFailure(
+      apiCallError({
+        statusCode: 404,
+        data: { error: { code: "not_found" } },
+        responseBody: '{"message":"The requested model does not exist"}',
+      }),
+    )
+    expect(error.reason.message).toBe("The requested model does not exist")
+  }),
+)
+
+it.effect("falls back to the status alone for malformed response bodies", () =>
+  Effect.gen(function* () {
+    const error = yield* streamFailure(
+      apiCallError({
+        statusCode: 502,
+        isRetryable: false,
+        responseBody: "<html>Bad Gateway</html>",
+      }),
+    )
+    expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 502 })
+    expect(error.reason.message).toBe("Provider request failed with HTTP 502")
+  }),
+)