diff --git a/extensions/cli/src/util/formatError.test.ts b/extensions/cli/src/util/formatError.test.ts index 4dcc6f10129..a70290ef8b6 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,108 @@ 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"); + }); +}); + +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(); + }); +}); + 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..1e7441643fc 100644 --- a/extensions/cli/src/util/formatError.ts +++ b/extensions/cli/src/util/formatError.ts @@ -1,9 +1,61 @@ +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; + } + } + + return message?.trim(); +} + /** * 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") {