diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 21149a2cf389..51ef6668d3bd 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -105,45 +105,68 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined if (!body) return const responseBody = JSON.stringify(body) - if (body.type !== "error") return - - switch (body?.error?.code) { - case "context_length_exceeded": - return { - type: "context_overflow", - message: "Input exceeds context window of this model", - responseBody, - } - case "insufficient_quota": - return { - type: "api_error", - message: "Quota exceeded. Check your plan and billing details.", - isRetryable: false, - responseBody, - } - case "usage_not_included": - return { - type: "api_error", - message: "To use Codex with your ChatGPT plan, upgrade to Plus: https://chatgpt.com/explore/plus.", - isRetryable: false, - responseBody, - } - case "invalid_prompt": - return { - type: "api_error", - message: typeof body?.error?.message === "string" ? body?.error?.message : "Invalid prompt.", - isRetryable: false, - responseBody, - } - case "server_is_overloaded": - case "server_error": - return { - type: "api_error", - message: typeof body?.error?.message === "string" ? body?.error?.message : "Server error.", - isRetryable: true, - responseBody, - } + const outerError = typeof body.error === "object" && body.error !== null ? body.error : undefined + const wrappedError = + typeof outerError?.error === "object" && outerError.error !== null ? outerError.error : outerError + if (body.type !== "error" && !wrappedError) return + + const code = typeof wrappedError?.code === "string" ? wrappedError.code : undefined + const type = typeof wrappedError?.type === "string" ? wrappedError.type : undefined + const message = + typeof wrappedError?.message === "string" && wrappedError.message.length > 0 + ? wrappedError.message + : typeof body?.message === "string" && body.message.length > 0 + ? body.message + : undefined + + const fromCode = (value: string | undefined, isRetryable = false): ParsedStreamError | undefined => { + switch (value) { + case "context_length_exceeded": + return { + type: "context_overflow", + message: "Input exceeds context window of this model", + responseBody, + } + case "insufficient_quota": + return { + type: "api_error", + message: "Quota exceeded. Check your plan and billing details.", + isRetryable: false, + responseBody, + } + case "usage_not_included": + return { + type: "api_error", + message: "To use Codex with your ChatGPT plan, upgrade to Plus: https://chatgpt.com/explore/plus.", + isRetryable: false, + responseBody, + } + case "invalid_prompt": + return { + type: "api_error", + message: message ?? "Invalid prompt.", + isRetryable: false, + responseBody, + } + case "server_is_overloaded": + case "server_error": + case "service_unavailable_error": + return { + type: "api_error", + message: message ?? "Server error.", + isRetryable, + responseBody, + } + } } + + const byCode = fromCode(code, true) + if (byCode) return byCode + + const byType = fromCode(type, true) + if (byType) return byType + + if (code === undefined && body.type === "error") return } export type ParsedAPICallError = diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index c57fcd53c96f..85651960ce88 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -685,7 +685,7 @@ export function fromError( }, { cause: e }, ).toObject() - case APICallError.isInstance(e): + case APICallError.isInstance(e): { const parsed = ProviderError.parseAPICallError({ providerID: ctx.providerID, error: e, @@ -711,8 +711,32 @@ export function fromError( }, { cause: e }, ).toObject() - case e instanceof Error: + } + case e instanceof Error: { + const parsed = ProviderError.parseStreamError(e) + if (parsed) { + if (parsed.type === "context_overflow") { + return new ContextOverflowError( + { + message: parsed.message, + responseBody: parsed.responseBody, + }, + { cause: e }, + ).toObject() + } + return new APIError( + { + message: parsed.message, + isRetryable: parsed.isRetryable, + responseBody: parsed.responseBody, + }, + { + cause: e, + }, + ).toObject() + } return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject() + } default: try { const parsed = ProviderError.parseStreamError(e) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 4139665bd2bd..6698dd1b6036 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -145,6 +145,12 @@ export function retryable(error: Err, provider: string) { if (code.includes("exhausted") || code.includes("unavailable")) { return { message: "Provider is overloaded" } } + if ( + json.type === "error" && + (json.error?.code === "server_is_overloaded" || json.error?.type === "service_unavailable_error") + ) { + return { message: typeof json.error?.message === "string" ? json.error.message : "Provider is overloaded" } + } if (json.type === "error" && typeof json.error?.code === "string" && json.error.code.includes("rate_limit")) { return { message: "Rate Limited" } } diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 1ac9d4d9ad87..ffa82a48f1be 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -9,6 +9,7 @@ import { MessageV2 } from "../session/message-v2" import { Agent } from "../agent/agent" import { deriveSubagentSessionPermission } from "../agent/subagent-permissions" import type { SessionPrompt } from "../session/prompt" +import { errorMessage } from "@/util/error" import { Config } from "@/config/config" import { Effect, Exit, Schema, Scope } from "effect" import { EffectBridge } from "@/effect/bridge" @@ -188,6 +189,9 @@ export const TaskTool = Tool.define( }, parts, }) + if (result.info.role === "assistant" && result.info.error) { + return yield* Effect.fail(new Error(`Child assistant failed: ${errorMessage(result.info.error)}`)) + } return result.parts.findLast((item) => item.type === "text")?.text ?? "" }) diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index f5edf1af24b1..159e504530ea 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -127,6 +127,23 @@ describe("session.retry.retryable", () => { expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Provider is overloaded" }) }) + test("maps persisted nested OpenAI overload errors", () => { + const error = wrap( + JSON.stringify({ + type: "error", + error: { + type: "service_unavailable_error", + code: "server_is_overloaded", + message: "Our servers are currently overloaded. Please try again later.", + }, + }), + ) + + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ + message: "Our servers are currently overloaded. Please try again later.", + }) + }) + test("does not retry unknown json messages", () => { const error = wrap(JSON.stringify({ error: { message: "no_kv_space" } })) expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined() @@ -436,4 +453,74 @@ describe("session.message-v2.fromError", () => { message: "An error occurred while processing your request.", }) }) + + test("maps wrapped OpenAI stream errors to retryable APIError", () => { + const result = MessageV2.fromError( + new Error( + JSON.stringify({ + error: { + code: "server_is_overloaded", + type: "server_error", + message: "The model is currently overloaded.", + }, + }), + ), + { providerID: ProviderV2.ID.make("openai") }, + ) + + expect(SessionV1.APIError.isInstance(result)).toBe(true) + if (!SessionV1.APIError.isInstance(result)) throw new Error("expected APIError") + + expect(result.data.isRetryable).toBe(true) + expect(SessionRetry.retryable(result, retryProvider)).toBeDefined() + }) + + test("maps wrapped OpenAI stream error types with empty codes to retryable APIError", () => { + const result = MessageV2.fromError( + new Error( + JSON.stringify({ + error: { + code: "", + type: "server_error", + message: "The upstream service failed.", + }, + }), + ), + { providerID: ProviderV2.ID.make("openai") }, + ) + + expect(SessionV1.APIError.isInstance(result)).toBe(true) + if (!SessionV1.APIError.isInstance(result)) throw new Error("expected APIError") + + expect(result.data.isRetryable).toBe(true) + expect(SessionRetry.retryable(result, retryProvider)).toBeDefined() + }) + + test("maps double-wrapped OpenAI stream errors to retryable APIError", () => { + const result = MessageV2.fromError( + new Error( + JSON.stringify({ + error: { + type: "error", + sequence_number: 2, + error: { + code: "server_is_overloaded", + type: "service_unavailable_error", + message: "Our servers are currently overloaded. Please try again later.", + }, + }, + }), + ), + { providerID: ProviderV2.ID.make("openai") }, + ) + + expect(SessionV1.APIError.isInstance(result)).toBe(true) + if (!SessionV1.APIError.isInstance(result)) throw new Error("expected APIError") + + expect(result.data.message).toBe("Our servers are currently overloaded. Please try again later.") + expect(result.data.isRetryable).toBe(true) + expect(SessionRetry.retryable(result, retryProvider)).toEqual({ + message: "Our servers are currently overloaded. Please try again later.", + }) + }) }) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 6a9604a01efa..18cd5c938394 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Database } from "@opencode-ai/core/database/database" -import { Deferred, Effect, Exit, Fiber, Layer } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import { Agent } from "../../src/agent/agent" import { BackgroundJob } from "@/background/job" import { EventV2Bridge } from "@/event-v2-bridge" @@ -380,6 +380,57 @@ describe("tool.task", () => { }), ) + it.instance("execute fails when child prompt returns an error without text", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + + const result = yield* def + .execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { + promptOps: { + ...stubOps(), + prompt: (input) => + Effect.sync(() => { + const text = reply(input, "ok") + return { + ...text, + info: { + ...text.info, + error: new SessionV1.APIError({ + message: "child failed", + isRetryable: false, + }).toObject(), + }, + parts: [], + } + }), + } satisfies TaskPromptOps, + }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(result)).toBe(true) + if (!Exit.isFailure(result)) return + expect(Cause.pretty(result.cause)).toContain("Child assistant failed") + }), + ) + it.instance( "execute shapes child permissions for task, todowrite, and primary tools", () =>