diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index b0d1406365..dd42cc0155 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -387,6 +387,12 @@ import { isRpmExhausted, } from "../services/geminiRateLimitTracker.ts"; import { isSmallEnoughForSemanticCache } from "../utils/estimateSize.ts"; + +type ChatCoreExecutorResult = ReturnType & { + _executionCredentials?: Record; + _accountSemaphoreRelease?: () => void; +}; + /** * Core chat handler - shared between SSE and Worker * Returns { success, response, status, error } for caller to handle fallback @@ -2731,7 +2737,7 @@ export async function handleChatCore({ let releaseRawResultAccountSemaphore = () => {}; try { - const rawResult = await (async () => { + const rawResult: ChatCoreExecutorResult = await (async () => { let attempts = 0; const isModelScopeForRequest = isModelScope(); const maxAttempts = isModelScopeForRequest ? 3 : provider === "codex" ? 3 : 1; @@ -3550,22 +3556,24 @@ export async function handleChatCore({ // stay aligned if this block ever runs after a path that mutates body.model (e.g. fallback). try { const retryModelId = String(translatedBody.model || effectiveModel); - const retryResult = await runWithCapture(providerRequestCapture, () => - executor.execute({ - model: retryModelId, - body: translatedBody, - stream: upstreamStream, - credentials: getExecutionCredentials(), - signal: streamController.signal, - log, - extendedContext, - upstreamExtraHeaders: buildUpstreamHeadersForExecute(retryModelId), - clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent), - clientResponseFormat, - onCredentialsRefreshed, - skipUpstreamRetry: isCombo, - contextEditing: { enabled: contextEditingEnabled }, - }) + const retryResult = normalizeExecutorResult( + await runWithCapture(providerRequestCapture, () => + executor.execute({ + model: retryModelId, + body: translatedBody, + stream: upstreamStream, + credentials: getExecutionCredentials(), + signal: streamController.signal, + log, + extendedContext, + upstreamExtraHeaders: buildUpstreamHeadersForExecute(retryModelId), + clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent), + clientResponseFormat, + onCredentialsRefreshed, + skipUpstreamRetry: isCombo, + contextEditing: { enabled: contextEditingEnabled }, + }) + ) ); if (retryResult.response.ok) { diff --git a/open-sse/handlers/chatCore/executionCredentials.ts b/open-sse/handlers/chatCore/executionCredentials.ts index 1569b61178..8eb96ab14f 100644 --- a/open-sse/handlers/chatCore/executionCredentials.ts +++ b/open-sse/handlers/chatCore/executionCredentials.ts @@ -20,6 +20,10 @@ type CredentialsLike = | null | undefined; +type ResolvedExecutionCredentials = Record & { + providerSpecificData: Record; +}; + function buildKimiThinkingMetadata( modelInfo: Record | null | undefined, staticThinkingPolicy: ReturnType @@ -79,7 +83,7 @@ export function resolveExecutionCredentials(opts: { provider: string | null | undefined; ccSessionId: string | null; modelInfo?: Record | null; -}) { +}): ResolvedExecutionCredentials { const { credentials, nativeCodexPassthrough, diff --git a/open-sse/handlers/chatCore/upstreamTimeouts.ts b/open-sse/handlers/chatCore/upstreamTimeouts.ts index 551b952e2c..5de972dcae 100644 --- a/open-sse/handlers/chatCore/upstreamTimeouts.ts +++ b/open-sse/handlers/chatCore/upstreamTimeouts.ts @@ -98,17 +98,7 @@ export function getExecutorTimeoutMs(executor: unknown, provider?: string, model return resolveProviderTimeoutMs(executor); } -export function normalizeExecutorResult( - result: - | Response - | { - response: Response; - url?: string; - headers?: Record; - transformedBody?: unknown; - transport?: string; - } -): { +export function normalizeExecutorResult(result: unknown): { response: Response; url: string; headers: Record; @@ -118,12 +108,27 @@ export function normalizeExecutorResult( if (result instanceof Response) { return { response: result, url: "", headers: {}, transformedBody: null }; } + if ( + !result || + typeof result !== "object" || + !("response" in result) || + !(result.response instanceof Response) + ) { + throw new TypeError("Executor result must contain a Response"); + } + const normalized = result as { + response: Response; + url?: string; + headers?: Record; + transformedBody?: unknown; + transport?: string; + }; return { - response: result.response, - url: result.url || "", - headers: result.headers || {}, - transformedBody: result.transformedBody ?? null, - transport: result.transport, + response: normalized.response, + url: normalized.url || "", + headers: normalized.headers || {}, + transformedBody: normalized.transformedBody ?? null, + transport: normalized.transport, }; } diff --git a/tests/unit/chatcore-upstream-timeouts.test.ts b/tests/unit/chatcore-upstream-timeouts.test.ts index 7fa90b3331..affad73611 100644 --- a/tests/unit/chatcore-upstream-timeouts.test.ts +++ b/tests/unit/chatcore-upstream-timeouts.test.ts @@ -49,3 +49,11 @@ test("normalizeExecutorResult wraps bare Response and passes through rich result assert.equal(rich.url, "u"); assert.equal(rich.headers.a, "b"); }); + +test("normalizeExecutorResult rejects malformed executor output", () => { + assert.throws(() => normalizeExecutorResult({}), /must contain a Response/); + assert.throws( + () => normalizeExecutorResult({ response: "not-a-response" }), + /must contain a Response/ + ); +});