|
|
@@ -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")
|
|
|
+ }),
|
|
|
+)
|