Files
OmniRoute/open-sse/handlers/chatCore/nonStreamingClientTranslate.ts
Bob.Hou d6f315018a fix(chat): continue after a server-owned tool on Chat Completions (#12867)
Validado numa worktree combinada com as 16 PRs desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-file-size e check-changelog-integrity OK, complexity 2788/3218 e cognitive 1261/1437, ESLint 0 erros nos 152 arquivos alterados, e a suíte vitest:ui completa (2149) verde.

Sobre esta PR especificamente: rodei os **23 arquivos de teste** que ela toca sobre o tip final, depois do merge da base — **392/392**. A migration `174_server_tool_executions.sql` não colide (o tip está em 173, e você já a renumerou em `c35f0fd7`).

O dono foi consultado antes do merge, porque o loop está atrás da flag `SERVER_OWNED_TOOL_LOOP_ENABLED` mas o primeiro send não-streaming mudou de dono sem flag, e a verificação manual em combo com Memory continuava desmarcada. A condição dele foi: entra se os testes focados passarem aqui. Passaram.

O lock de passthrough (`fetchCalls.length === 1`) é a parte que mais me convenceu — o double-dispatch que um `if (stream)` em volta do send existente causaria é exatamente o tipo de regressão que não aparece em teste de comportamento, só em contagem de chamada.

**Três ajustes meus na sua branch:**

1. `tests/unit/chatcore-stream-error-result.test.ts` procurava `"const legResult = await runNonStreamingProviderLeg"`, mas o seu commit final `6077b9dd` passou a reatribuir `legResult` e trocou para `let`. O guard falhava na sua própria branch (confirmei que o arquivo e o `chatCore.ts` eram byte-idênticos ao head da PR, então não era efeito da leva). Passou a aceitar `const|let` — a intenção do guard é o try/catch em volta da chamada, não a palavra-chave.

2. `tests/integration/skills-pipeline.test.ts` foi de 1156 para 1338 linhas e estourou o `testCap` de 1200. Segui o mesmo caminho que você já tinha tomado em `a1d2d20d` para os testes unitários: extraí os três casos do server-owned tool loop para `tests/integration/server-owned-tool-loop-pipeline.test.ts` (259 linhas), com instância própria do harness. O glob `tests/integration/*.test.ts` pega o arquivo novo sem registro adicional. 3/3 verdes isolados.

3. O arquivo novo herdou cinco `any` do original — que só passavam por estarem congelados no `eslint-suppressions.json` sob o nome antigo. Tipei como `Record<string, unknown>`. E `tests/unit/non-streaming-finalization.test.ts` tinha dois argumentos não usados em `trackPendingRequest`, agora prefixados com `_`.

Nada disso toca produção nem enfraquece asserção.
2026-09-07 09:15:00 -03:00

160 lines
6.5 KiB
TypeScript

/**
* Client translation for non-streaming responses.
* Extracted from chatCore.ts (lines ~5098-5195) by symbol boundaries.
*
* Handles: translate, tool-name restore, finish-reason normalization, sanitize,
* reasoning replay capture, and client usage buffer application.
*
* Phase distinction:
* - "final": applies applyClientUsageBuffer (normalizes visible usage fields)
* - "intermediate": skips usage buffer (raw usage preserved for aggregation)
*/
import type {
NonStreamingClientTranslateInput,
NonStreamingClientTranslateResult,
} from "@/lib/skills/toolLoopTypes.ts";
import { needsTranslation } from "../../translator/index.ts";
import { FORMATS } from "../../translator/formats.ts";
import { translateNonStreamingResponse } from "../responseTranslator.ts";
import { extractToolSchemaMap } from "../../translator/response/openai-responses/toolSchemas.ts";
import { stripMarkdownCodeFence } from "../../utils/aiSdkCompat.ts";
import { normalizeOpenAIToolFinishReasons } from "./passthroughToolNames.ts";
import {
cacheReasoningFromAssistantMessage,
requiresReasoningReplay,
} from "../../services/reasoningCache.ts";
import {
sanitizeOpenAIResponse,
sanitizeResponsesApiResponse,
shouldParseTextualReasoningTags,
} from "../responseSanitizer.ts";
import { isStripReasoningRequested } from "./headers.ts";
import { applyClientUsageBuffer } from "./clientUsageBuffer.ts";
export type { NonStreamingClientTranslateInput, NonStreamingClientTranslateResult };
/**
* Translate a non-streaming provider response to the client's expected format.
*
* All of: translate, tool-name identity restore, finish-reason normalization,
* and sanitize are applied every round (both intermediate and final).
* `applyClientUsageBuffer` is applied only for `phase === "final"`.
* Reasoning replay capture runs every round.
*/
export function translateNonStreamingClientResponse(
input: NonStreamingClientTranslateInput
): NonStreamingClientTranslateResult {
const {
responseBody,
responsePayloadFormat,
clientResponseFormat,
sourceFormat,
provider,
model,
requestBody,
responseToolNameMap,
requestToolIdentityMap,
reasoningCacheScope,
clientHeaders,
isClaudeCodeCompatible,
phase,
} = input;
// ── Extract tool schemas for schema-aware translation ──────────────────────
const finalBody = requestBody as Record<string, unknown> | null;
const responseToolSchemas = extractToolSchemaMap(finalBody || responseBody);
// ── Translate response to client's expected format ─────────────────────────
let translatedResponse = needsTranslation(responsePayloadFormat, clientResponseFormat)
? translateNonStreamingResponse(
responseBody,
responsePayloadFormat,
clientResponseFormat,
responseToolNameMap,
responseToolSchemas
)
: responseBody;
const responseForMemoryExtraction = translatedResponse;
// ── T26: Strip markdown code blocks if provider format is Claude ───────────
if (sourceFormat === "claude") {
if (typeof translatedResponse?.choices?.[0]?.message?.content === "string") {
translatedResponse.choices[0].message.content = stripMarkdownCodeFence(
translatedResponse.choices[0].message.content
) as string;
}
}
// ── T18: Normalize finish_reason to 'tool_calls' if tool calls present ─────
normalizeOpenAIToolFinishReasons(translatedResponse);
// ── Reasoning Replay Cache (#1628) ────────────────────────────────────────
// Capture reasoning_content from non-streaming responses with tool_calls
// so it can be replayed on subsequent turns.
try {
const cacheResponse = translatedResponse?.choices?.[0]
? translatedResponse
: needsTranslation(responsePayloadFormat, FORMATS.OPENAI)
? translateNonStreamingResponse(
responseBody,
responsePayloadFormat,
FORMATS.OPENAI,
responseToolNameMap,
responseToolSchemas
)
: responseBody;
const firstChoice = cacheResponse?.choices?.[0];
const msg = firstChoice?.message;
// Prefer explicit historyMessages (parent: translatedBody.messages). Do not
// overload requestBody — Responses-shaped finalBody has `input`, not `messages`.
const historyMessages = Array.isArray(input.historyMessages)
? input.historyMessages
: (finalBody as { messages?: unknown[] } | null | undefined)?.messages;
if (requiresReasoningReplay({ provider, model })) {
cacheReasoningFromAssistantMessage(msg, provider, model, {
scope: reasoningCacheScope,
historyMessages: Array.isArray(historyMessages) ? historyMessages : [],
});
}
} catch {
// Cache capture is non-critical — never block the response
}
// ── Sanitize response for SDK compatibility ────────────────────────────────
if (clientResponseFormat === FORMATS.OPENAI_RESPONSES) {
translatedResponse = sanitizeResponsesApiResponse(translatedResponse);
// Restore {namespace, name} on function_call items for round-trip closure (#7936)
const responseOutput = translatedResponse?.output;
if (requestToolIdentityMap && Array.isArray(responseOutput)) {
for (const item of responseOutput) {
if (item?.type !== "function_call") continue;
const identity = requestToolIdentityMap.get(item.name);
if (identity) {
item.namespace = identity.namespace;
item.name = identity.name;
}
}
}
} else if (clientResponseFormat === FORMATS.OPENAI) {
const stripReasoning = isStripReasoningRequested(clientHeaders ?? null);
translatedResponse = sanitizeOpenAIResponse(translatedResponse, {
stripReasoning,
parseTextualReasoningTags: shouldParseTextualReasoningTags(provider, model),
});
}
// ── Client usage buffer (#8331) ───────────────────────────────────────────
// Only apply for final phase; intermediate preserves raw usage for aggregation.
if (phase === "final") {
applyClientUsageBuffer(translatedResponse, finalBody || responseBody, clientResponseFormat, {
preserveContextBudgetInVisibleUsage: isClaudeCodeCompatible,
});
}
return {
response: translatedResponse,
responseForMemoryExtraction,
};
}