Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 61 additions & 38 deletions packages/opencode/src/provider/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
28 changes: 26 additions & 2 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/session/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 ?? ""
})

Expand Down
87 changes: 87 additions & 0 deletions packages/opencode/test/session/retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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.",
})
})
})
53 changes: 52 additions & 1 deletion packages/opencode/test/tool/task.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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",
() =>
Expand Down
Loading