From 27e163e2c966cfdf9116ef7dbebccd0c781417af Mon Sep 17 00:00:00 2001 From: backryun Date: Fri, 14 Aug 2026 12:58:04 +0900 Subject: [PATCH] fix(types): validate nonstreaming JSON contracts (#10258) --- open-sse/handlers/chatCore.ts | 32 ++++++++++++++----- .../chatCore/clineResponseEnvelope.ts | 5 +++ .../chatCore/nonStreamingResponseParse.ts | 19 +++++++++-- open-sse/handlers/responseSanitizer.ts | 10 ++++++ open-sse/handlers/responseTranslator.ts | 12 +++++++ ...tcore-non-streaming-response-parse.test.ts | 12 +++++++ 6 files changed, 80 insertions(+), 10 deletions(-) diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index dd42cc0155..35e579a45d 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -104,7 +104,13 @@ import { resolveAgentGoalPolicy } from "../utils/agentGoalPolicy.ts"; import { createStreamController } from "../utils/streamHandler.ts"; import * as streamFailure from "../utils/streamFailureFinalization.ts"; import { createSseHeartbeatTransform, shapeForClientFormat } from "../utils/sseHeartbeat.ts"; -import { addBufferToUsage, filterUsageForFormat, estimateUsage, sanitizeUsagePayloadForRequest } from "../utils/usageTracking.ts"; +import { + addBufferToUsage, + filterUsageForFormat, + estimateUsage, + normalizeUsage, + sanitizeUsagePayloadForRequest, +} from "../utils/usageTracking.ts"; import { refreshWithRetry, isUnrecoverableRefreshError, @@ -271,7 +277,10 @@ import { appendNonStreamingSseTerminalSignal, type NonStreamingSseTerminalState, } from "./chatCore/nonStreamingSse.ts"; -import { parseNonStreamingResponseBody } from "./chatCore/nonStreamingResponseParse.ts"; +import { + isJsonRecord, + parseNonStreamingResponseBody, +} from "./chatCore/nonStreamingResponseParse.ts"; import { unwrapClinepassEnvelope } from "../utils/clinepassEnvelope.ts"; import { recordNonStreamingUsageStats } from "./chatCore/nonStreamingUsageStats.ts"; import { @@ -4226,6 +4235,12 @@ export async function handleChatCore({ trackPendingRequest(model, provider, connectionId, false); return createErrorResult(HTTP_STATUS.BAD_GATEWAY, envError.message); } + if (!isJsonRecord(unwrapped)) { + const invalidEnvelopeMessage = "Invalid JSON response from provider"; + persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "clinepass_envelope_error"); + trackPendingRequest(model, provider, connectionId, false); + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, invalidEnvelopeMessage); + } responseBody = unwrapped; } responseBody = unwrapClineNonStreamingEnvelope(provider, responseBody); @@ -4512,13 +4527,14 @@ export async function handleChatCore({ ); translatedResponse = postCallGuardrails.response; - const responseUsage = - (usage && typeof usage === "object" ? usage : null) || - (translatedResponse?.usage && typeof translatedResponse.usage === "object" + const responseUsage = isJsonRecord(usage) + ? usage + : isJsonRecord(translatedResponse.usage) ? translatedResponse.usage - : null); - const estimatedCost = responseUsage - ? await calculateCost(provider, model, responseUsage, { serviceTier: effectiveServiceTier }) + : null; + const costUsage = normalizeUsage(responseUsage); + const estimatedCost = costUsage + ? await calculateCost(provider, model, costUsage, { serviceTier: effectiveServiceTier }) : 0; if (postCallGuardrails.blocked) { diff --git a/open-sse/handlers/chatCore/clineResponseEnvelope.ts b/open-sse/handlers/chatCore/clineResponseEnvelope.ts index 75229773b0..ac2a91f53e 100644 --- a/open-sse/handlers/chatCore/clineResponseEnvelope.ts +++ b/open-sse/handlers/chatCore/clineResponseEnvelope.ts @@ -8,6 +8,11 @@ function hasOpenAIChoices(value: unknown): value is JsonRecord & { choices: unkn return isRecord(value) && Array.isArray(value.choices); } +export function unwrapClineNonStreamingEnvelope( + provider: string, + responseBody: JsonRecord +): JsonRecord; +export function unwrapClineNonStreamingEnvelope(provider: string, responseBody: unknown): unknown; export function unwrapClineNonStreamingEnvelope(provider: string, responseBody: unknown): unknown { if (provider !== "cline" || !isRecord(responseBody)) { return responseBody; diff --git a/open-sse/handlers/chatCore/nonStreamingResponseParse.ts b/open-sse/handlers/chatCore/nonStreamingResponseParse.ts index fcdf4d072e..617ad02c00 100644 --- a/open-sse/handlers/chatCore/nonStreamingResponseParse.ts +++ b/open-sse/handlers/chatCore/nonStreamingResponseParse.ts @@ -28,10 +28,16 @@ type LoggerLike = | null | undefined; +export type JsonRecord = Record; + +export function isJsonRecord(value: unknown): value is JsonRecord { + return !!value && typeof value === "object" && !Array.isArray(value); +} + export type NonStreamingParseResult = | { kind: "ok"; - responseBody: unknown; + responseBody: JsonRecord; responsePayloadFormat: string; looksLikeSSE: boolean; normalizedProviderPayload: unknown; @@ -113,7 +119,16 @@ export async function parseNonStreamingResponseBody(opts: { } try { - const responseBody = rawBody ? JSON.parse(rawBody) : {}; + const responseBody: unknown = rawBody ? JSON.parse(rawBody) : {}; + if (!isJsonRecord(responseBody)) { + return { + kind: "invalid_json", + message: "Invalid JSON response from provider", + detailedError: "Invalid JSON response from provider: expected an object payload", + looksLikeSSE: false, + normalizedProviderPayload, + }; + } return { kind: "ok", responseBody, diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index fcaa5677fb..06d3202f5f 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -257,6 +257,14 @@ export interface SanitizeOpenAIResponseOptions { parseTextualReasoningTags?: boolean; } +export function sanitizeOpenAIResponse( + body: JsonRecord, + options?: SanitizeOpenAIResponseOptions +): JsonRecord; +export function sanitizeOpenAIResponse( + body: unknown, + options?: SanitizeOpenAIResponseOptions +): unknown; export function sanitizeOpenAIResponse( body: unknown, options: SanitizeOpenAIResponseOptions = {} @@ -310,6 +318,8 @@ export function sanitizeOpenAIResponse( return sanitized; } +export function sanitizeResponsesApiResponse(body: JsonRecord): JsonRecord; +export function sanitizeResponsesApiResponse(body: unknown): unknown; export function sanitizeResponsesApiResponse(body: unknown): unknown { const bodyRecord = toRecord(body); if (!bodyRecord) return body; diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index 3527cdfec5..eaa311f0cf 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -133,6 +133,18 @@ function findBestMessageText(output: unknown[]): { * * @param toolNameMap - Optional Map for Claude OAuth tool name stripping */ +export function translateNonStreamingResponse( + responseBody: JsonRecord, + targetFormat: string, + sourceFormat: string, + toolNameMap?: Map | null +): JsonRecord; +export function translateNonStreamingResponse( + responseBody: unknown, + targetFormat: string, + sourceFormat: string, + toolNameMap?: Map | null +): unknown; export function translateNonStreamingResponse( responseBody: unknown, targetFormat: string, diff --git a/tests/unit/chatcore-non-streaming-response-parse.test.ts b/tests/unit/chatcore-non-streaming-response-parse.test.ts index 21b761fa04..c7940041b4 100644 --- a/tests/unit/chatcore-non-streaming-response-parse.test.ts +++ b/tests/unit/chatcore-non-streaming-response-parse.test.ts @@ -62,6 +62,18 @@ test("invalid JSON → invalid_json with short message + detailed error", async assert.equal(res.looksLikeSSE, false); }); +test("valid JSON with a non-object root → invalid_json", async () => { + for (const body of ["null", '"text"', "[]"]) { + const res = await parseNonStreamingResponseBody({ + ...baseOpts, + providerResponse: makeResponse(body, "application/json"), + }); + assert.equal(res.kind, "invalid_json"); + if (res.kind !== "invalid_json") continue; + assert.match(res.detailedError, /expected an object payload/); + } +}); + test("valid SSE payload (by content-type) → ok with SSE-derived format", async () => { const sse = 'data: {"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{"content":"hello"},"index":0,"finish_reason":null}]}\n\n' +