mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-17 12:22:34 +03:00
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.
160 lines
6.5 KiB
TypeScript
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,
|
|
};
|
|
}
|