fix(types): validate nonstreaming JSON contracts (#10258)

This commit is contained in:
backryun
2026-08-14 12:58:04 +09:00
committed by GitHub
parent 13098989e8
commit 27e163e2c9
6 changed files with 80 additions and 10 deletions

View File

@@ -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) {

View File

@@ -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;

View File

@@ -28,10 +28,16 @@ type LoggerLike =
| null
| undefined;
export type JsonRecord = Record<string, unknown>;
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,

View File

@@ -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;

View File

@@ -133,6 +133,18 @@ function findBestMessageText(output: unknown[]): {
*
* @param toolNameMap - Optional Map<prefixedName, originalName> for Claude OAuth tool name stripping
*/
export function translateNonStreamingResponse(
responseBody: JsonRecord,
targetFormat: string,
sourceFormat: string,
toolNameMap?: Map<string, string> | null
): JsonRecord;
export function translateNonStreamingResponse(
responseBody: unknown,
targetFormat: string,
sourceFormat: string,
toolNameMap?: Map<string, string> | null
): unknown;
export function translateNonStreamingResponse(
responseBody: unknown,
targetFormat: string,

View File

@@ -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' +