diff --git a/extensions/cli/src/util/formatError.test.ts b/extensions/cli/src/util/formatError.test.ts index 4dcc6f10129..d37770fd448 100644 --- a/extensions/cli/src/util/formatError.test.ts +++ b/extensions/cli/src/util/formatError.test.ts @@ -1,4 +1,8 @@ -import { formatError, formatAnthropicError } from "./formatError.js"; +import { + formatError, + formatAnthropicError, + extractNestedJsonMessage, +} from "./formatError.js"; describe("formatError", () => { it("should format Error objects correctly", () => { @@ -135,6 +139,157 @@ describe("formatError", () => { }); }); +/** + * Real shape from continuedev/continue#12945: an SDK error message that is + * a JSON envelope whose error.message is ITSELF a pretty-printed JSON + * string, hiding the actual cause two parse levels deep. Shared vector for + * the formatError and extractNestedJsonMessage suites. + */ +function quotaErrorMessageVector(): string { + const googleBody = JSON.stringify( + { + error: { + code: 429, + message: + "You exceeded your current quota, please check your plan and billing details. Please retry in 45.191226092s.", + status: "RESOURCE_EXHAUSTED", + }, + }, + null, + 2, + ); + return JSON.stringify({ + error: { + message: `${googleBody}\n`, + code: 429, + status: "Too Many Requests", + }, + }); +} + +describe("formatError nested Gemini-style JSON messages", () => { + it("extracts the innermost message from a double-nested JSON error", () => { + const error = new Error(quotaErrorMessageVector()); + expect(formatError(error)).toBe( + "You exceeded your current quota, please check your plan and billing details. Please retry in 45.191226092s.", + ); + }); + + it("extracts a single-level nested message", () => { + const error = new Error( + JSON.stringify({ + error: { message: "Quota exceeded", status: "RESOURCE_EXHAUSTED" }, + }), + ); + expect(formatError(error)).toBe("Quota exceeded"); + }); + + it("leaves a message-less nested error unchanged", () => { + const raw = JSON.stringify({ error: { status: "RESOURCE_EXHAUSTED" } }); + expect(formatError(new Error(raw))).toBe(raw); + }); + + it("leaves a primitive-valued error field unchanged", () => { + const raw = JSON.stringify({ error: "Invalid API key" }); + expect(formatError(new Error(raw))).toBe(raw); + }); + + it("leaves malformed JSON unchanged", () => { + expect(formatError(new Error("{invalid json"))).toBe("{invalid json"); + }); + + it("leaves plain non-JSON messages unchanged", () => { + expect(formatError(new Error("socket hang up"))).toBe("socket hang up"); + }); + + it("falls back to the raw message when the nested message is empty", () => { + // A blank extracted message is worse than the original envelope — the + // caller must keep the raw text rather than render an empty error. + const raw = JSON.stringify({ error: { message: "" } }); + expect(formatError(new Error(raw))).toBe(raw); + }); + + it("extracts nested messages from raw string errors too", () => { + expect(formatError(quotaErrorMessageVector())).toBe( + "You exceeded your current quota, please check your plan and billing details. Please retry in 45.191226092s.", + ); + }); + + it("extracts nested messages from plain-object message properties", () => { + expect(formatError({ message: quotaErrorMessageVector() })).toBe( + "You exceeded your current quota, please check your plan and billing details. Please retry in 45.191226092s.", + ); + }); +}); + +describe("extractNestedJsonMessage (direct vectors)", () => { + it("extracts the innermost message from the double-nested shape", () => { + expect(extractNestedJsonMessage(quotaErrorMessageVector())).toBe( + "You exceeded your current quota, please check your plan and billing details. Please retry in 45.191226092s.", + ); + }); + + it("extracts a single-level nested message", () => { + expect( + extractNestedJsonMessage( + JSON.stringify({ error: { message: "Quota exceeded" } }), + ), + ).toBe("Quota exceeded"); + }); + + it("returns undefined for message-less JSON", () => { + expect( + extractNestedJsonMessage( + JSON.stringify({ error: { status: "RESOURCE_EXHAUSTED" } }), + ), + ).toBeUndefined(); + }); + + it("returns undefined for a primitive error field", () => { + expect( + extractNestedJsonMessage(JSON.stringify({ error: "Invalid API key" })), + ).toBeUndefined(); + }); + + it("returns undefined for malformed JSON", () => { + expect(extractNestedJsonMessage("{invalid json")).toBeUndefined(); + }); + + it("returns undefined for plain non-JSON text", () => { + expect(extractNestedJsonMessage("socket hang up")).toBeUndefined(); + }); + + it("returns undefined for an empty or whitespace-only nested message", () => { + expect( + extractNestedJsonMessage(JSON.stringify({ error: { message: "" } })), + ).toBeUndefined(); + expect( + extractNestedJsonMessage(JSON.stringify({ error: { message: " " } })), + ).toBeUndefined(); + }); + + /** nest(0) = "CORE"; nest(k) wraps nest(k-1) in one more JSON envelope. */ + function nestedEnvelope(levels: number): string { + let raw = "CORE"; + for (let i = 0; i < levels; i++) { + raw = JSON.stringify({ error: { message: raw } }); + } + return raw; + } + + it("fully unwraps envelopes nested within the depth cap", () => { + expect(extractNestedJsonMessage(nestedEnvelope(5))).toBe("CORE"); + }); + + it("stops unwrapping at the depth cap on pathological nesting", () => { + // MAX_DEPTH = 8: a 10-level envelope stops with the level-2 envelope + // still intact — bounded work, never an unbounded parse chain. + expect(extractNestedJsonMessage(nestedEnvelope(10))).toBe( + nestedEnvelope(2), + ); + }); +}); + describe("formatAnthropicError", () => { it("should format invalid API key authentication errors", () => { const error = new Error( diff --git a/extensions/cli/src/util/formatError.ts b/extensions/cli/src/util/formatError.ts index f806cfca9f4..52a642b25eb 100644 --- a/extensions/cli/src/util/formatError.ts +++ b/extensions/cli/src/util/formatError.ts @@ -1,18 +1,76 @@ +type JsonObject = Record; + +function asJsonObject(value: unknown): JsonObject | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as JsonObject) + : undefined; +} + +/** + * Extract the human-readable message nested inside a provider error blob. + * + * Gemini-style errors arrive as a JSON envelope ({ error: { message, code, + * status } }) whose error.message is often ITSELF a JSON string (see + * continuedev/continue#12945) — without extraction users see raw JSON or + * "Unknown error". Walks the nesting to the innermost message; returns + * undefined for non-JSON, malformed, or message-less input so callers keep + * the original text. Mirrors extractNestedGeminiError in + * packages/openai-adapters/src/apis/Gemini.ts (kept as a small local mirror + * with shared test vectors rather than a new cross-package export). + */ +export function extractNestedJsonMessage(raw: string): string | undefined { + // Bound the unwrap depth so a gateway returning deeply nested error + // envelopes cannot force unbounded sequential parses. + const MAX_DEPTH = 8; + let node: unknown; + try { + node = JSON.parse(raw); + } catch { + return undefined; + } + + let message: string | undefined; + for (let depth = 0; depth < MAX_DEPTH; depth++) { + const obj = asJsonObject(node); + if (!obj) { + break; + } + const target = asJsonObject(obj.error) ?? obj; + if (typeof target.message !== "string") { + break; + } + message = target.message; + try { + node = JSON.parse(target.message.trim()); + } catch { + break; + } + } + + // A blank extracted message is worse than the original text — report + // "nothing found" so callers keep the raw envelope. + const trimmed = message?.trim(); + return trimmed === undefined || trimmed === "" ? undefined : trimmed; +} + /** * Safely formats an error object into a readable string */ export function formatError(error: any): string { if (error instanceof Error) { - return error.message; + return extractNestedJsonMessage(error.message) ?? error.message; } if (typeof error === "string") { - return error; + return extractNestedJsonMessage(error) ?? error; } if (error && typeof error === "object") { // Try to extract common error properties if (error.message) { + if (typeof error.message === "string") { + return extractNestedJsonMessage(error.message) ?? error.message; + } return error.message; } if (error.error) {