diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 5c7ef0b8c4..8fbfc42539 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,6 +1,7 @@ { "_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.", "_rebaseline_2026_06_18_4180_gemini_default_thinking": "PR #4180 own growth: openai-to-gemini.ts 844->864 (+20 = default includeThoughts for modern Gemini 2.5+ at the existing openaiToGeminiBase chokepoint — when the client set no thinkingConfig, inject one (includeThoughts:true + a capped budget) so the model's reasoning is marked thought:true and routed to reasoning_content instead of leaking into visible content, #4170). Cohesive translator branch gated to gemini-2.5+/3 (excludes gemini-1.x and non-thinking 2.0); not extractable. Reconciled here because #4180 merged without the baseline bump.", + "_rebaseline_2026_06_18_qg9_chatcore_split_prA": "QG v2 Fase 9 T5 C2-C3-C5: chatCore.ts 5445->5265 (wc -l 5264 + 1). Fase 2 of the chatCore split following #4159 (which created 10 leaf modules). Three more sibling leaves created under open-sse/handlers/chatCore/, all 866 (+21) and EditConnectionModal.tsx 1174->1204 (+30) = the 'import only free models' connection option — a free-models Toggle gated by providerHasFreeModels() plus its form-state wiring (importFreeModelsOnly field + providerSpecificData persistence, explicit-false on edit so the PUT merge doesn't keep a stale true) added to both connection modals, mirroring the prior per-modal toggle bumps #3879 (redact-thinking) and #2997 (disable-cooling). Detection lives in the new shared src/shared/utils/freeModels.ts (112 LOC, 2531 (+19 = one PROVIDER_MODELS_CONFIG entry for `qwen-web` + a 4-line comment). qwen-web was missing from the config map so its model-discovery page returned nothing (the OAuth fallback only fires for provider===qwen). Pure additive config entry pointing at the public chat.qwen.ai/api/v2/models endpoint; standard per-provider addition, not extractable.", "_rebaseline_2026_06_18_4165_queue_timeout_msg": "Issue #4165 own growth: rateLimitManager.ts 1022->1035 (+13 at the existing withRateLimit catch chokepoint). Bottleneck's raw `This job timed out after ms.` is rewritten into a clear OmniRoute-owned error (names resilienceSettings.requestQueue.maxWaitMs, disclaims upstream, keeps the original as `cause`, tags code=RATE_LIMIT_QUEUE_TIMEOUT) so queue-saturation 502s stop masquerading as provider outages. The branch already existed (it only logged); this adds the error construction at the same point. Not extractable — closes over provider/model/maxWaitMs locals of the single catch.", @@ -59,7 +60,7 @@ "open-sse/executors/muse-spark-web.ts": 1284, "open-sse/executors/perplexity-web.ts": 1013, "open-sse/handlers/audioSpeech.ts": 965, - "open-sse/handlers/chatCore.ts": 5445, + "open-sse/handlers/chatCore.ts": 5265, "open-sse/handlers/imageGeneration.ts": 3777, "open-sse/handlers/responseSanitizer.ts": 1103, "open-sse/handlers/search.ts": 1546, diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 45f3b13625..5ef0aceb02 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -4,6 +4,29 @@ import { checkSemanticCache } from "./chatCore/semanticCache.ts"; import { sanitizeChatRequestBody } from "./chatCore/sanitization.ts"; import { cloneBoundedChatLogPayload, truncateForLog } from "./chatCore/logTruncation.ts"; import { getHeaderValueCaseInsensitive } from "./chatCore/headers.ts"; +import { + shouldUseNativeCodexPassthrough, + redactPassthroughThinkingSignatures, + isClaudeCodeSemanticPassthroughRequest, +} from "./chatCore/passthroughHelpers.ts"; +import { + buildStreamingResponseHeaders, + materializeDeduplicatedExecutionResult, + stripStaleForwardingHeaders, +} from "./chatCore/responseHeaders.ts"; +import { + forwardDashboardEventToLiveWs, + maybeSyncClaudeExtraUsageState, +} from "./chatCore/telemetryHelpers.ts"; +// Re-export the previously inline-defined helpers so existing importers of these +// symbols from chatCore.ts (tests, sibling modules) keep resolving after the split. +export { + shouldUseNativeCodexPassthrough, + redactPassthroughThinkingSignatures, + isClaudeCodeSemanticPassthroughRequest, + buildStreamingResponseHeaders, + stripStaleForwardingHeaders, +}; import { extractMemoryTextFromResponse, extractMemoryTextFromRequestBody, @@ -259,181 +282,6 @@ import { incrementRequestCount } from "../services/geminiRateLimitTracker.ts"; import { isSmallEnoughForSemanticCache } from "../utils/estimateSize.ts"; -async function forwardDashboardEventToLiveWs(event: string, payload: unknown): Promise { - const port = process.env.LIVE_WS_PORT || "20129"; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 1_500); - try { - await fetch(`http://127.0.0.1:${port}/__omniroute_event`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ event, payload, timestamp: Date.now() }), - signal: controller.signal, - }); - } catch { - // Best-effort sidecar bridge; do not affect the chat hot path. - } finally { - clearTimeout(timeout); - } -} - -async function maybeSyncClaudeExtraUsageState({ - provider, - connectionId, - providerSpecificData, - log, -}: { - provider: string | null | undefined; - connectionId: string | null | undefined; - providerSpecificData: unknown; - log?: { debug?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void } | null; -}) { - if (!connectionId || !isClaudeExtraUsageBlockEnabled(provider, providerSpecificData)) { - return; - } - - try { - await fetchLiveProviderLimits(connectionId); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log?.debug?.("CLAUDE_USAGE", `Failed to sync Claude extra-usage state: ${message}`); - } -} - -export function shouldUseNativeCodexPassthrough({ - provider, - sourceFormat, - endpointPath, -}: { - provider?: string | null; - sourceFormat?: string | null; - endpointPath?: string | null; -}): boolean { - if (provider !== "codex") return false; - if (sourceFormat !== FORMATS.OPENAI_RESPONSES) return false; - let normalizedEndpoint = String(endpointPath || ""); - while (normalizedEndpoint.endsWith("/")) normalizedEndpoint = normalizedEndpoint.slice(0, -1); - const segments = normalizedEndpoint.split("/"); - return segments.includes("responses"); -} - -/** - * Pass `thinking` / `redacted_thinking` blocks through UNCHANGED. - * - * This used to rewrite every assistant thinking block to `redacted_thinking` - * carrying a synthetic signature, on the assumption that a thinking signature is - * bound to the auth token that produced it and would be rejected after a token / - * model switch with 400 "Invalid signature in thinking block" (issue #2454). - * - * That rewrite is the actual cause of a different, far more common failure on the - * Anthropic-native Claude OAuth passthrough: - * - * 400 messages.N.content.M: `thinking` or `redacted_thinking` blocks in the - * latest assistant message cannot be modified. These blocks must remain as - * they were in the original response. - * - * The Messages API validates submitted thinking blocks against the original - * response and rejects ANY modification — so converting them to - * `redacted_thinking` makes every multi-turn request with thinking fail (most - * visible on long Claude Code tool-loops). The thinking-block signature is - * validated server-side by Anthropic and stays valid when the blocks are replayed, - * including under a different OAuth token — verified by preserving the blocks - * across a mid-conversation account switch with zero "Invalid signature" - * responses. The redaction is therefore both unnecessary and the cause of the - * regression, so the blocks are now returned verbatim. The `signature` parameter - * is kept for call-site compatibility. - */ -export function redactPassthroughThinkingSignatures( - messages: unknown, - _signature: string -): unknown { - return messages; -} - -export function isClaudeCodeSemanticPassthroughRequest({ - provider, - sourceFormat, - targetFormat, - headers, - userAgent, -}: { - provider?: string | null; - sourceFormat?: string | null; - targetFormat?: string | null; - headers?: Record | Headers | null; - userAgent?: string | null; -}): boolean { - const isDirectClaudeCodeProvider = - provider === "claude" || isClaudeCodeCompatibleProvider(provider); - if (!isDirectClaudeCodeProvider) return false; - if (sourceFormat !== FORMATS.CLAUDE) return false; - if (targetFormat !== FORMATS.CLAUDE) return false; - - const headerUserAgent = getHeaderValueCaseInsensitive(headers, "user-agent"); - const ua = `${userAgent || ""} ${headerUserAgent || ""}`.toLowerCase(); - if (ua.includes("claude-code") || ua.includes("claude-cli")) return true; - - const appHeader = getHeaderValueCaseInsensitive(headers, "x-app"); - if (typeof appHeader === "string" && appHeader.trim().toLowerCase() === "cli") return true; - - const sessionId = getHeaderValueCaseInsensitive(headers, "x-claude-code-session-id"); - return typeof sessionId === "string" && sessionId.trim().length > 0; -} - -const STREAMING_RESPONSE_HEADER_DENYLIST = new Set([ - "content-type", - "content-encoding", - "content-length", - "transfer-encoding", -]); - -export function buildStreamingResponseHeaders( - providerHeaders: Headers, - meta: Parameters[0] -): Record { - const forwardedHeaders: [string, string][] = []; - providerHeaders.forEach((value, key) => { - if (!STREAMING_RESPONSE_HEADER_DENYLIST.has(key.toLowerCase())) { - forwardedHeaders.push([key, value]); - } - }); - - return { - ...Object.fromEntries(forwardedHeaders), - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache, no-transform", - Connection: "keep-alive", - "X-Accel-Buffering": "no", - [OMNIROUTE_RESPONSE_HEADERS.cache]: "MISS", - ...buildOmniRouteResponseMetaHeaders(meta), - }; -} - -function materializeDeduplicatedExecutionResult>(result: T): T { - const snapshot = - result && typeof result === "object" - ? ((result as Record)._dedupSnapshot as - | { - status: number; - statusText: string; - headers: [string, string][]; - payload: string; - } - | undefined) - : undefined; - - if (!snapshot) return result; - - return { - ...result, - response: new Response(snapshot.payload, { - status: snapshot.status, - statusText: snapshot.statusText, - headers: snapshot.headers, - }), - } as T; -} - function getSkillsProviderForFormat(format: string): "openai" | "anthropic" | "google" | "other" { switch (format) { case FORMATS.CLAUDE: @@ -456,34 +304,6 @@ function getSkillsModelIdForFormat(format: string): string { } } -/** - * Strip hop-by-hop headers that describe the upstream wire encoding. - * - * `readNonStreamingResponseBody` reads (and, for compressed responses, also - * decompresses via fetch's auto-decoder) the full upstream body into a JS - * string before we re-emit it to the client. Once that happens, the original - * `Content-Encoding`, `Content-Length`, and `Transfer-Encoding` all describe - * a payload that no longer exists: - * - * - `Content-Length` is the *compressed* byte count, so clients honoring it - * read only the first N bytes of the decompressed JSON and surface - * "Unterminated string in JSON at position …" parse failures (observed - * on gzipped Gemini responses). - * - `Content-Encoding` advertises a compression we have already undone. - * - `Transfer-Encoding` is hop-by-hop per RFC 7230 §6.1 and must not be - * forwarded across a buffering proxy — its presence alongside a - * re-emitted body is undefined behavior. - * - * Deleting all three lets the response framework set a fresh, correct - * `Content-Length` (or fall back to `Transfer-Encoding: chunked`) for the - * payload we are actually sending. - */ -export function stripStaleForwardingHeaders(headers: Headers): void { - headers.delete("content-encoding"); - headers.delete("content-length"); - headers.delete("transfer-encoding"); -} - async function readNonStreamingResponseBody( response: Response, contentType: string, diff --git a/open-sse/handlers/chatCore/passthroughHelpers.ts b/open-sse/handlers/chatCore/passthroughHelpers.ts new file mode 100644 index 0000000000..3220c43c3c --- /dev/null +++ b/open-sse/handlers/chatCore/passthroughHelpers.ts @@ -0,0 +1,83 @@ +import { FORMATS } from "../../translator/formats.ts"; +import { isClaudeCodeCompatibleProvider } from "../../services/claudeCodeCompatible.ts"; +import { getHeaderValueCaseInsensitive } from "./headers.ts"; + +export function shouldUseNativeCodexPassthrough({ + provider, + sourceFormat, + endpointPath, +}: { + provider?: string | null; + sourceFormat?: string | null; + endpointPath?: string | null; +}): boolean { + if (provider !== "codex") return false; + if (sourceFormat !== FORMATS.OPENAI_RESPONSES) return false; + let normalizedEndpoint = String(endpointPath || ""); + while (normalizedEndpoint.endsWith("/")) normalizedEndpoint = normalizedEndpoint.slice(0, -1); + const segments = normalizedEndpoint.split("/"); + return segments.includes("responses"); +} + +/** + * Pass `thinking` / `redacted_thinking` blocks through UNCHANGED. + * + * This used to rewrite every assistant thinking block to `redacted_thinking` + * carrying a synthetic signature, on the assumption that a thinking signature is + * bound to the auth token that produced it and would be rejected after a token / + * model switch with 400 "Invalid signature in thinking block" (issue #2454). + * + * That rewrite is the actual cause of a different, far more common failure on the + * Anthropic-native Claude OAuth passthrough: + * + * 400 messages.N.content.M: `thinking` or `redacted_thinking` blocks in the + * latest assistant message cannot be modified. These blocks must remain as + * they were in the original response. + * + * The Messages API validates submitted thinking blocks against the original + * response and rejects ANY modification — so converting them to + * `redacted_thinking` makes every multi-turn request with thinking fail (most + * visible on long Claude Code tool-loops). The thinking-block signature is + * validated server-side by Anthropic and stays valid when the blocks are replayed, + * including under a different OAuth token — verified by preserving the blocks + * across a mid-conversation account switch with zero "Invalid signature" + * responses. The redaction is therefore both unnecessary and the cause of the + * regression, so the blocks are now returned verbatim. The `signature` parameter + * is kept for call-site compatibility. + */ +export function redactPassthroughThinkingSignatures( + messages: unknown, + _signature: string +): unknown { + return messages; +} + +export function isClaudeCodeSemanticPassthroughRequest({ + provider, + sourceFormat, + targetFormat, + headers, + userAgent, +}: { + provider?: string | null; + sourceFormat?: string | null; + targetFormat?: string | null; + headers?: Record | Headers | null; + userAgent?: string | null; +}): boolean { + const isDirectClaudeCodeProvider = + provider === "claude" || isClaudeCodeCompatibleProvider(provider); + if (!isDirectClaudeCodeProvider) return false; + if (sourceFormat !== FORMATS.CLAUDE) return false; + if (targetFormat !== FORMATS.CLAUDE) return false; + + const headerUserAgent = getHeaderValueCaseInsensitive(headers, "user-agent"); + const ua = `${userAgent || ""} ${headerUserAgent || ""}`.toLowerCase(); + if (ua.includes("claude-code") || ua.includes("claude-cli")) return true; + + const appHeader = getHeaderValueCaseInsensitive(headers, "x-app"); + if (typeof appHeader === "string" && appHeader.trim().toLowerCase() === "cli") return true; + + const sessionId = getHeaderValueCaseInsensitive(headers, "x-claude-code-session-id"); + return typeof sessionId === "string" && sessionId.trim().length > 0; +} diff --git a/open-sse/handlers/chatCore/responseHeaders.ts b/open-sse/handlers/chatCore/responseHeaders.ts new file mode 100644 index 0000000000..39e394c01f --- /dev/null +++ b/open-sse/handlers/chatCore/responseHeaders.ts @@ -0,0 +1,86 @@ +import { buildOmniRouteResponseMetaHeaders } from "@/domain/omnirouteResponseMeta"; +import { OMNIROUTE_RESPONSE_HEADERS } from "@/shared/constants/headers"; + +const STREAMING_RESPONSE_HEADER_DENYLIST = new Set([ + "content-type", + "content-encoding", + "content-length", + "transfer-encoding", +]); + +export function buildStreamingResponseHeaders( + providerHeaders: Headers, + meta: Parameters[0] +): Record { + const forwardedHeaders: [string, string][] = []; + providerHeaders.forEach((value, key) => { + if (!STREAMING_RESPONSE_HEADER_DENYLIST.has(key.toLowerCase())) { + forwardedHeaders.push([key, value]); + } + }); + + return { + ...Object.fromEntries(forwardedHeaders), + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + [OMNIROUTE_RESPONSE_HEADERS.cache]: "MISS", + ...buildOmniRouteResponseMetaHeaders(meta), + }; +} + +export function materializeDeduplicatedExecutionResult>( + result: T +): T { + const snapshot = + result && typeof result === "object" + ? ((result as Record)._dedupSnapshot as + | { + status: number; + statusText: string; + headers: [string, string][]; + payload: string; + } + | undefined) + : undefined; + + if (!snapshot) return result; + + return { + ...result, + response: new Response(snapshot.payload, { + status: snapshot.status, + statusText: snapshot.statusText, + headers: snapshot.headers, + }), + } as T; +} + +/** + * Strip hop-by-hop headers that describe the upstream wire encoding. + * + * `readNonStreamingResponseBody` reads (and, for compressed responses, also + * decompresses via fetch's auto-decoder) the full upstream body into a JS + * string before we re-emit it to the client. Once that happens, the original + * `Content-Encoding`, `Content-Length`, and `Transfer-Encoding` all describe + * a payload that no longer exists: + * + * - `Content-Length` is the *compressed* byte count, so clients honoring it + * read only the first N bytes of the decompressed JSON and surface + * "Unterminated string in JSON at position …" parse failures (observed + * on gzipped Gemini responses). + * - `Content-Encoding` advertises a compression we have already undone. + * - `Transfer-Encoding` is hop-by-hop per RFC 7230 §6.1 and must not be + * forwarded across a buffering proxy — its presence alongside a + * re-emitted body is undefined behavior. + * + * Deleting all three lets the response framework set a fresh, correct + * `Content-Length` (or fall back to `Transfer-Encoding: chunked`) for the + * payload we are actually sending. + */ +export function stripStaleForwardingHeaders(headers: Headers): void { + headers.delete("content-encoding"); + headers.delete("content-length"); + headers.delete("transfer-encoding"); +} diff --git a/open-sse/handlers/chatCore/telemetryHelpers.ts b/open-sse/handlers/chatCore/telemetryHelpers.ts new file mode 100644 index 0000000000..263029cf10 --- /dev/null +++ b/open-sse/handlers/chatCore/telemetryHelpers.ts @@ -0,0 +1,46 @@ +import { fetchLiveProviderLimits } from "@/lib/usage/providerLimits"; +import { isClaudeExtraUsageBlockEnabled } from "@/lib/providers/claudeExtraUsage"; + +export async function forwardDashboardEventToLiveWs( + event: string, + payload: unknown +): Promise { + const port = process.env.LIVE_WS_PORT || "20129"; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 1_500); + try { + await fetch(`http://127.0.0.1:${port}/__omniroute_event`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ event, payload, timestamp: Date.now() }), + signal: controller.signal, + }); + } catch { + // Best-effort sidecar bridge; do not affect the chat hot path. + } finally { + clearTimeout(timeout); + } +} + +export async function maybeSyncClaudeExtraUsageState({ + provider, + connectionId, + providerSpecificData, + log, +}: { + provider: string | null | undefined; + connectionId: string | null | undefined; + providerSpecificData: unknown; + log?: { debug?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void } | null; +}) { + if (!connectionId || !isClaudeExtraUsageBlockEnabled(provider, providerSpecificData)) { + return; + } + + try { + await fetchLiveProviderLimits(connectionId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log?.debug?.("CLAUDE_USAGE", `Failed to sync Claude extra-usage state: ${message}`); + } +}