diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 3a9a1299da..6e62715f84 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -7,38 +7,16 @@ import { FORMATS } from "../formats.ts"; import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts"; import { fallbackToolCallId } from "../helpers/toolCallHelper.ts"; import { shouldParseTextualReasoningTags } from "../../handlers/responseSanitizer.ts"; +import { + normalizeToolName, + stripEmptyOptionalToolArgs, + normalizeOutputIndex, + normalizeUpstreamFailure, + extractResponsesReasoningSummaryText, +} from "./openai-responses/pureHelpers.ts"; -function normalizeToolName(value) { - return typeof value === "string" ? value.trim() : ""; -} - -function stripEmptyOptionalToolArgs(value, toolName) { - if (value == null) return value; - - if (typeof value === "string") { - // JSON-string cleanup is intentionally scoped to Claude Code's Read tool. - // For arbitrary tools, empty strings/arrays may be valid user payloads. - if (toolName !== "Read") return value; - try { - const parsed = JSON.parse(value); - if (Array.isArray(parsed) || typeof parsed !== "object" || parsed === null) return value; - const cleaned = stripEmptyOptionalToolArgs(parsed, toolName); - return JSON.stringify(cleaned ?? {}); - } catch { - return value; - } - } - - if (Array.isArray(value) || typeof value !== "object") return value; - - const cleaned = { ...value }; - for (const [key, entry] of Object.entries(cleaned)) { - if (entry === "" || (Array.isArray(entry) && entry.length === 0)) { - delete cleaned[key]; - } - } - return cleaned; -} +// normalizeUpstreamFailure is re-exported for external importers (tests). +export { normalizeUpstreamFailure } from "./openai-responses/pureHelpers.ts"; /** * Translate OpenAI chunk to Responses API events @@ -192,11 +170,6 @@ export function openaiToOpenAIResponsesResponse(chunk, state) { } // Normalize output_index to a non-negative integer (replaces fragile parseInt calls) -function normalizeOutputIndex(outputIndex) { - const normalized = Number(outputIndex); - return Number.isInteger(normalized) && normalized >= 0 ? normalized : 0; -} - // Record a finalized item keyed by output_index so buildDenseOutput can sort later function recordCompletedItem(state, outputIndex, item) { if (!Array.isArray(state.completedOutputItems)) { @@ -564,50 +537,6 @@ function flushEvents(state) { return events; } -export function normalizeUpstreamFailure(data, fallbackType = "server_error") { - const response = data?.response && typeof data.response === "object" ? data.response : null; - const error = - response?.error && typeof response.error === "object" - ? response.error - : data?.error && typeof data.error === "object" - ? data.error - : null; - - const code = typeof error?.code === "string" ? error.code : ""; - const message = - typeof error?.message === "string" - ? error.message - : typeof data?.message === "string" - ? data.message - : "Upstream failure"; - - // Preserve upstream error semantics: - // - context_length_exceeded → 400 (client can retry with smaller context) - // - rate_limit_exceeded → 429 (client should back off) - // - Everything else → 502 (upstream failure) - const isContextOverflow = code === "context_length_exceeded"; - const isRateLimit = code === "rate_limit_exceeded" || code === "rate_limited"; - let status: number; - let type: string; - if (isRateLimit) { - status = 429; - type = "rate_limit_error"; - } else if (isContextOverflow) { - status = 400; - type = "invalid_request_error"; - } else { - status = 502; - type = fallbackType; - } - - return { - status, - type, - code: code || (isRateLimit ? "rate_limit_exceeded" : "bad_gateway"), - message, - }; -} - /** * OpenAI Chat Completions streams announce the assistant role on the FIRST delta * (e.g. `{ "role": "assistant", "content": "" }` or `{ "role": "assistant", @@ -680,15 +609,6 @@ function buildResponsesReasoningDeltaChunk(state, text) { }; } -function extractResponsesReasoningSummaryText(item) { - if (!item || !Array.isArray(item.summary)) return ""; - return item.summary - .map((part) => - part && typeof part === "object" && typeof part.text === "string" ? part.text : "" - ) - .join(""); -} - /** * Translate OpenAI Responses API chunk to OpenAI Chat Completions format * This is for when Codex returns data and we need to send it to an OpenAI-compatible client diff --git a/open-sse/translator/response/openai-responses/pureHelpers.ts b/open-sse/translator/response/openai-responses/pureHelpers.ts new file mode 100644 index 0000000000..3ed559fb55 --- /dev/null +++ b/open-sse/translator/response/openai-responses/pureHelpers.ts @@ -0,0 +1,92 @@ +// Pure, stateless helpers for the OpenAI Responses <-> Chat response translator. +// Extracted verbatim from response/openai-responses.ts (no host imports, no stream state). + +export function normalizeToolName(value) { + return typeof value === "string" ? value.trim() : ""; +} + +export function stripEmptyOptionalToolArgs(value, toolName) { + if (value == null) return value; + + if (typeof value === "string") { + // JSON-string cleanup is intentionally scoped to Claude Code's Read tool. + // For arbitrary tools, empty strings/arrays may be valid user payloads. + if (toolName !== "Read") return value; + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed) || typeof parsed !== "object" || parsed === null) return value; + const cleaned = stripEmptyOptionalToolArgs(parsed, toolName); + return JSON.stringify(cleaned ?? {}); + } catch { + return value; + } + } + + if (Array.isArray(value) || typeof value !== "object") return value; + + const cleaned = { ...value }; + for (const [key, entry] of Object.entries(cleaned)) { + if (entry === "" || (Array.isArray(entry) && entry.length === 0)) { + delete cleaned[key]; + } + } + return cleaned; +} + +export function normalizeOutputIndex(outputIndex) { + const normalized = Number(outputIndex); + return Number.isInteger(normalized) && normalized >= 0 ? normalized : 0; +} + +export function normalizeUpstreamFailure(data, fallbackType = "server_error") { + const response = data?.response && typeof data.response === "object" ? data.response : null; + const error = + response?.error && typeof response.error === "object" + ? response.error + : data?.error && typeof data.error === "object" + ? data.error + : null; + + const code = typeof error?.code === "string" ? error.code : ""; + const message = + typeof error?.message === "string" + ? error.message + : typeof data?.message === "string" + ? data.message + : "Upstream failure"; + + // Preserve upstream error semantics: + // - context_length_exceeded → 400 (client can retry with smaller context) + // - rate_limit_exceeded → 429 (client should back off) + // - Everything else → 502 (upstream failure) + const isContextOverflow = code === "context_length_exceeded"; + const isRateLimit = code === "rate_limit_exceeded" || code === "rate_limited"; + let status: number; + let type: string; + if (isRateLimit) { + status = 429; + type = "rate_limit_error"; + } else if (isContextOverflow) { + status = 400; + type = "invalid_request_error"; + } else { + status = 502; + type = fallbackType; + } + + return { + status, + type, + code: code || (isRateLimit ? "rate_limit_exceeded" : "bad_gateway"), + message, + }; +} + +export function extractResponsesReasoningSummaryText(item) { + if (!item || !Array.isArray(item.summary)) return ""; + return item.summary + .map((part) => + part && typeof part === "object" && typeof part.text === "string" ? part.text : "" + ) + .join(""); +} diff --git a/tests/unit/response-openai-responses-purehelpers-split.test.ts b/tests/unit/response-openai-responses-purehelpers-split.test.ts new file mode 100644 index 0000000000..7b8485471e --- /dev/null +++ b/tests/unit/response-openai-responses-purehelpers-split.test.ts @@ -0,0 +1,60 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +// Split-guard for the response/openai-responses pure-helper extraction. +// The stateless helpers (normalizeToolName / stripEmptyOptionalToolArgs / +// normalizeOutputIndex / normalizeUpstreamFailure / extractResponsesReasoningSummaryText) +// live in the pure leaf `openai-responses/pureHelpers.ts` (no stream state, no host import). +// The host imports them back and re-exports normalizeUpstreamFailure for external importers. +const HERE = dirname(fileURLToPath(import.meta.url)); +const RESP = join(HERE, "../../open-sse/translator/response"); +const HOST = join(RESP, "openai-responses.ts"); +const LEAF = join(RESP, "openai-responses/pureHelpers.ts"); + +test("leaf hosts the pure helpers, has no stream state and no host import", () => { + const src = readFileSync(LEAF, "utf8"); + for (const sym of [ + "normalizeToolName", + "stripEmptyOptionalToolArgs", + "normalizeOutputIndex", + "normalizeUpstreamFailure", + "extractResponsesReasoningSummaryText", + ]) { + assert.match(src, new RegExp(`export function ${sym}\\b`)); + } + assert.doesNotMatch(src, /from "\.\.\/openai-responses\.ts"/); + // No stream-state parameter leaked into the pure leaf (ignore comments). + const code = src + .split("\n") + .filter((l) => !l.trim().startsWith("//")) + .join("\n"); + assert.doesNotMatch(code, /\bstate\b/); +}); + +test("host imports helpers back and re-exports normalizeUpstreamFailure", () => { + const src = readFileSync(HOST, "utf8"); + assert.match(src, /from "\.\/openai-responses\/pureHelpers\.ts"/); + assert.match( + src, + /export \{ normalizeUpstreamFailure \} from "\.\/openai-responses\/pureHelpers\.ts"/ + ); +}); + +test("normalizeUpstreamFailure preserves upstream error semantics", async () => { + const { normalizeUpstreamFailure } = + await import("../../open-sse/translator/response/openai-responses/pureHelpers.ts"); + assert.equal( + normalizeUpstreamFailure({ error: { code: "rate_limit_exceeded", message: "slow down" } }) + .status, + 429 + ); + assert.equal( + normalizeUpstreamFailure({ error: { code: "context_length_exceeded", message: "too big" } }) + .status, + 400 + ); + assert.equal(normalizeUpstreamFailure({ message: "boom" }).status, 502); +});