diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index d784dbf888..b5bf0780b8 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -1,5 +1,7 @@ // Gemini helper functions for translator +import { safeParseJSON } from "./jsonUtil.ts"; + type JsonRecord = Record; // Unsupported JSON Schema constraints that should be removed for Antigravity. @@ -242,14 +244,9 @@ export function extractTextContent(content: unknown): string { return ""; } -// Try parse JSON safely +// Try parse JSON safely (null fallback on parse error; re-export keeps legacy API). export function tryParseJSON(str: unknown): unknown { - if (typeof str !== "string") return str; - try { - return JSON.parse(str); - } catch { - return null; - } + return safeParseJSON(str, null); } // Generate request ID diff --git a/open-sse/translator/helpers/jsonUtil.ts b/open-sse/translator/helpers/jsonUtil.ts new file mode 100644 index 0000000000..0643c53336 --- /dev/null +++ b/open-sse/translator/helpers/jsonUtil.ts @@ -0,0 +1,18 @@ +// Safe JSON.parse helper shared by the translator layer. +// +// Behavior contract (must be preserved by all callers): +// - non-string input is returned unchanged (passthrough) +// - a valid JSON string is parsed and returned +// - on parse error the caller-chosen `fallback` is returned +// +// The `fallback` is explicit so the two historical `tryParseJSON` variants +// can keep their distinct semantics: geminiHelper returns `null` on error, +// while openai-to-claude returns the raw input string (passthrough). +export function safeParseJSON(str: unknown, fallback: TFallback): unknown { + if (typeof str !== "string") return str; + try { + return JSON.parse(str); + } catch { + return fallback; + } +} diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 55a1f28433..6e8d9b0058 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -4,6 +4,7 @@ import { FORMATS } from "../formats.ts"; import { supportsClaudeMaxEffort, supportsXHighEffort } from "../../config/providerModels.ts"; import { adjustMaxTokens } from "../helpers/maxTokensHelper.ts"; import { sanitizeToolId } from "../helpers/schemaCoercion.ts"; +import { safeParseJSON } from "../helpers/jsonUtil.ts"; import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; import { capMaxOutputTokens } from "../../../src/lib/modelCapabilities.ts"; import { isAdaptiveThinkingOnly } from "../../../src/shared/constants/modelSpecs.ts"; @@ -701,14 +702,9 @@ function extractTextContent(content) { return ""; } -// Try parse JSON -function tryParseJSON(str) { - if (typeof str !== "string") return str; - try { - return JSON.parse(str); - } catch { - return str; - } +// Try parse JSON (passthrough fallback: return the raw input string on parse error). +function tryParseJSON(str: unknown): unknown { + return safeParseJSON(str, str); } function stripCacheControl(value: unknown): unknown { diff --git a/tests/unit/translator-json-util.test.ts b/tests/unit/translator-json-util.test.ts new file mode 100644 index 0000000000..0824f52f9c --- /dev/null +++ b/tests/unit/translator-json-util.test.ts @@ -0,0 +1,45 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Unit coverage for the extracted safeParseJSON util and the two distinct +// fallbacks its callers rely on: +// - geminiHelper.tryParseJSON -> null fallback on parse error +// - openai-to-claude tryParseJSON -> raw-string passthrough on parse error +const { safeParseJSON } = await import("../../open-sse/translator/helpers/jsonUtil.ts"); +const gemini = await import("../../open-sse/translator/helpers/geminiHelper.ts"); + +test("safeParseJSON: non-string input is returned unchanged (passthrough)", () => { + assert.equal(safeParseJSON(42, null), 42); + assert.equal(safeParseJSON(null, "fb"), null); + assert.equal(safeParseJSON(undefined, "fb"), undefined); + const obj = { a: 1 }; + assert.equal(safeParseJSON(obj, null), obj); +}); + +test("safeParseJSON: valid JSON strings parse regardless of fallback", () => { + assert.deepEqual(safeParseJSON('{"a":1}', null), { a: 1 }); + assert.deepEqual(safeParseJSON("[1,2,3]", "ignored"), [1, 2, 3]); + assert.equal(safeParseJSON("42", null), 42); + assert.equal(safeParseJSON("true", null), true); + assert.equal(safeParseJSON("null", "ignored"), null); +}); + +test("safeParseJSON: invalid JSON returns the caller-chosen fallback", () => { + // null fallback (geminiHelper semantics) + assert.equal(safeParseJSON("not json", null), null); + assert.equal(safeParseJSON("{broken}", null), null); + assert.equal(safeParseJSON("", null), null); + + // raw-string passthrough fallback (openai-to-claude semantics) + assert.equal(safeParseJSON("not json", "not json"), "not json"); + assert.equal(safeParseJSON("{broken", "{broken"), "{broken"); +}); + +test("geminiHelper.tryParseJSON still returns null on parse error (re-export delegates)", () => { + assert.deepEqual(gemini.tryParseJSON('{"ok":true}'), { ok: true }); + assert.equal(gemini.tryParseJSON("{broken"), null); + assert.equal(gemini.tryParseJSON("not json"), null); + assert.equal(gemini.tryParseJSON(""), null); + // non-string passthrough preserved + assert.equal(gemini.tryParseJSON(123), 123); +});