diff --git a/packages/opencode/src/kilocode/provider/error.ts b/packages/opencode/src/kilocode/provider/error.ts index f432a85fc3..f6550544f4 100644 --- a/packages/opencode/src/kilocode/provider/error.ts +++ b/packages/opencode/src/kilocode/provider/error.ts @@ -9,8 +9,8 @@ export type Frame = { } & Record const payload = z.looseObject({ - code: z.union([z.string(), z.number()]).optional(), - message: z.string().optional(), + code: z.union([z.string(), z.number()]).nullish(), + message: z.string().nullish(), }) // OpenAI Responses API terminal frame forwarded by @ai-sdk/openai >= 3.0.82 @@ -29,7 +29,7 @@ const wrapper = z.looseObject({ // fields so gateway wrappers keep the specific inner code const bare = z.looseObject({ type: z.undefined().optional(), - code: z.union([z.string(), z.number()]), + code: z.union([z.string(), z.number(), z.null()]), message: z.string(), }) @@ -49,6 +49,9 @@ export function frame(body: unknown): Frame { } const RETRYABLE = /rate.?limit|too.?many.?requests|rate increased too quickly|exhausted|overload|server|unavailable|timeout/i +// Session.retryable only matched these phrases against free-form message +// text; the wider pattern above is for structured code/type fields only +const RETRYABLE_TEXT = /rate increased too quickly|rate limit|too many requests/i // Must stay at least as permissive as the Session.retryable heuristics that // applied when these frames still surfaced as NamedError.Unknown, or @@ -63,7 +66,7 @@ function retryable(error: Frame["error"], message: string) { } const type = error?.type if (typeof type === "string" && RETRYABLE.test(type)) return true - return RETRYABLE.test(message) + return RETRYABLE_TEXT.test(message) } /** diff --git a/packages/opencode/test/kilocode/provider/error.test.ts b/packages/opencode/test/kilocode/provider/error.test.ts index 3db0548a3a..8139abdcc0 100644 --- a/packages/opencode/test/kilocode/provider/error.test.ts +++ b/packages/opencode/test/kilocode/provider/error.test.ts @@ -187,6 +187,45 @@ describe("responses api terminal frames", () => { expect(result.data.isRetryable).toBe(true) }) + test("normalizes error objects with explicit null fields", () => { + const payload = { + error: { + message: "The server had an error while processing your request", + type: "server_error", + param: null, + code: null, + }, + } + const result = MessageV2.fromError(payload, { providerID: ProviderV2.ID.make("openai") }) + + expect(MessageV2.APIError.isInstance(result)).toBe(true) + if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError") + expect(result.data.message).toBe(payload.error.message) + expect(result.data.isRetryable).toBe(true) + }) + + test("normalizes response.failed frames with a null error code", () => { + const payload = { + type: "response.failed", + response: { error: { code: null, message: "mid-stream failure" }, incomplete_details: null }, + } + const result = MessageV2.fromError(payload, { providerID: ProviderV2.ID.make("openai") }) + + expect(MessageV2.APIError.isInstance(result)).toBe(true) + if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError") + expect(result.data.message).toBe("mid-stream failure") + expect(result.data.isRetryable).toBe(false) + }) + + test("does not retry terminal errors that merely mention availability", () => { + const payload = { code: "invalid_request", message: "The model is unavailable in your region" } + const result = MessageV2.fromError(payload, { providerID: ProviderV2.ID.make("openai") }) + + expect(MessageV2.APIError.isInstance(result)).toBe(true) + if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError") + expect(result.data.isRetryable).toBe(false) + }) + test("ignores response.failed frames without an error payload", () => { const payload = { type: "response.failed", response: { error: null, incomplete_details: null } } const result = MessageV2.fromError(payload, { providerID: ProviderV2.ID.make("openai") })