From 24fc202d9fa5a3143dab16c97bf8adb151c6b740 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 18 Sep 2026 08:13:43 -0300 Subject: [PATCH] fix(ci): repair the API Route Typecheck base-red blocking every PR (#14079) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate fails on the pure release/v3.8.51 tip (3 files above the frozen baseline), so every PR against the release is born red on it. None of the three is a PR defect — they are drift from merged work: - src/lib/usage/glmResetCards.ts: entered the gate's scope when #12754 added the route that imports it. runWithProxyContext is an untyped async helper (Promise), so runWithConnectionFetch could not return T. Every call site passes an async callback and awaits it — declare that contract: fn: () => Promise → Promise. - src/sse/handlers/chat.ts: handleSingleModelChat had no return annotation, so runWithTransientBackendRetry fell back to the constraint and the value could no longer feed withSessionHeader(Response). Annotated Promise (every return path builds a Response). - src/app/api/internal/codex-responses-ws/route.ts: the bridge helpers return either { error: Response } or a payload, but as unannotated object-literal unions TypeScript synthesised error?: undefined on the success member, and every "error" in x guard stopped narrowing (TS2339 x5 on the destructure). Explicit return types keep the discriminant real; the ApiKeyMetadata alias now points at the policy shape (the wider one both sources are assignable to), which clears the TS2740 self-mismatch; logger.warn → log.warn (the module logger factory has no .warn). check-api-typecheck.mjs: OK — 283 errors, baseline ratcheted DOWN from 294 (codex-responses-ws 7→4 TS2339, TS2740 1→0; combos/test and keys/[id] 1→0). typecheck:core clean; 43/43 unit tests in the touched areas green. --- .../release-v3851-basereds-apitypecheck.md | 1 + config/quality/api-typecheck-baseline.json | 9 +-- .../api/internal/codex-responses-ws/route.ts | 72 ++++++++++++++++--- src/lib/usage/glmResetCards.ts | 9 ++- src/sse/handlers/chat.ts | 2 +- 5 files changed, 72 insertions(+), 21 deletions(-) create mode 100644 changelog.d/maintenance/release-v3851-basereds-apitypecheck.md diff --git a/changelog.d/maintenance/release-v3851-basereds-apitypecheck.md b/changelog.d/maintenance/release-v3851-basereds-apitypecheck.md new file mode 100644 index 0000000000..58af8c1660 --- /dev/null +++ b/changelog.d/maintenance/release-v3851-basereds-apitypecheck.md @@ -0,0 +1 @@ +- **ci:** repair the `API Route Typecheck` base-red on `release/v3.8.51` — type the awaited-callback contract of `runWithConnectionFetch` (glmResetCards), annotate `handleSingleModelChat(): Promise`, and give the codex-responses-ws bridge helpers real `{ error } | payload` discriminants so `"error" in x` narrows again; baseline ratcheted 294 → 283 (no widening). diff --git a/config/quality/api-typecheck-baseline.json b/config/quality/api-typecheck-baseline.json index 0c184969de..c39759bae3 100644 --- a/config/quality/api-typecheck-baseline.json +++ b/config/quality/api-typecheck-baseline.json @@ -48,8 +48,7 @@ "TS2322": 1 }, "src/app/api/combos/test/route.ts": { - "TS2345": 1, - "TS2339": 1 + "TS2345": 1 }, "src/app/api/compression/compare/route.ts": { "TS2345": 1 @@ -70,11 +69,7 @@ "TS2554": 1 }, "src/app/api/internal/codex-responses-ws/route.ts": { - "TS2740": 1, - "TS2339": 7 - }, - "src/app/api/keys/[id]/route.ts": { - "TS2339": 1 + "TS2339": 4 }, "src/app/api/local/redis/start/route.ts": { "TS2339": 1 diff --git a/src/app/api/internal/codex-responses-ws/route.ts b/src/app/api/internal/codex-responses-ws/route.ts index ce3c5e428d..346f973593 100644 --- a/src/app/api/internal/codex-responses-ws/route.ts +++ b/src/app/api/internal/codex-responses-ws/route.ts @@ -34,7 +34,10 @@ import { validateCodexWsDecision, } from "@/lib/reasoningRouting/policy"; import { resolveRequestRoutingTags } from "@/domain/tagRouter"; -import { validateApiKeyRoutingTarget } from "@/shared/utils/apiKeyPolicy"; +import { + validateApiKeyRoutingTarget, + type ApiKeyMetadata as PolicyApiKeyMetadata, +} from "@/shared/utils/apiKeyPolicy"; import { persistResponsesWsCallHistory } from "./history"; import { applyResponsesWsCompression } from "./compression"; import { getComboByName } from "@/lib/db/combos"; @@ -50,7 +53,52 @@ const executor = new CodexExecutor(); const log = logger("RESPONSES_WS"); type JsonRecord = Record; -type ApiKeyMetadata = Awaited>; +// Key metadata reaches this bridge from two sources that each declare their own +// shape: `getApiKeyMetadata()` (every field required) and `enforceApiKeyPolicy()` +// (every field optional). The policy shape is the wider of the two and the db +// shape is assignable to it, so it is the only one that can hold both — pinning +// the alias to the db shape is what produced the "Type 'ApiKeyMetadata' is +// missing … from type 'ApiKeyMetadata'" mismatch at the policy boundary. +type ApiKeyMetadata = PolicyApiKeyMetadata | null; + +/** + * Bridge helpers below either fail with a ready-made HTTP response or return + * their success payload. `error` must exist on exactly ONE member of each union: + * for an unannotated object-literal union TypeScript synthesises `error?: + * undefined` on the success member, and `"error" in x` then keeps that member + * too — which is how every `if ("error" in context)` guard in this file silently + * stopped narrowing. Annotating the returns keeps the discriminant real. + */ +type CodexWsFailure = { error: Response }; + +type CodexWsReasoningRoute = { + decision: Awaited>; + intent: ReturnType; + sourceModels: Awaited>; + routingTags: ReturnType; +}; + +type CodexWsCredentials = { + credentials: NonNullable>>; + leaseId: string; +}; + +type CodexWsRequestContext = CodexWsReasoningRoute & { + authRequest: Request; + apiKey: string | null; + responseBody: JsonRecord; + requestedModel: string; + clientHeaders: Record; + metadata: ApiKeyMetadata; + allowedConnections: string[] | null; +}; + +type CodexWsUpstreamContext = CodexWsRequestContext & + CodexWsCredentials & { + provider: string; + model: string; + reasoningDecision: Awaited>; + }; const bridgePayloadSchema = z .object({ @@ -325,10 +373,10 @@ async function enforceCodexWsApiKeyPolicy( async function prepareReasoningRoute( authRequest: Request, apiKey: string | null, - metadata: ApiKeyMetadata | null, + metadata: ApiKeyMetadata, requestedModel: string, responseBody: JsonRecord -) { +): Promise { const reasoningIntent = extractReasoningIntent(requestedModel, responseBody); const sourceModels = await resolveReasoningSourceModels(reasoningIntent.model, (model) => resolveCodexWsModelInfo(model, getModelInfo) @@ -373,7 +421,7 @@ async function resolveCodexCredentials( provider: string, model: string, allowedConnections: string[] | null -) { +): Promise { const excludedConnectionIds: string[] = []; let credentials: Awaited> = null; @@ -428,7 +476,9 @@ async function resolveCodexCredentials( }; } -async function resolveCodexRequestContext(body: JsonRecord) { +async function resolveCodexRequestContext( + body: JsonRecord +): Promise { if (!isFeatureFlagEnabled("OMNIROUTE_CODEX_WS_ENABLED")) { return { error: jsonError(503, "codex_ws_disabled", "Codex Responses WebSocket transport is disabled"), @@ -477,7 +527,7 @@ async function resolveCodexRequestContext(body: JsonRecord) { requestedModel, responseBody ); - if (reasoningRoute.error) return { error: reasoningRoute.error }; + if ("error" in reasoningRoute) return reasoningRoute; return { authRequest, apiKey, @@ -491,8 +541,8 @@ async function resolveCodexRequestContext(body: JsonRecord) { } async function resolveCodexUpstreamContext( - context: Awaited> -) { + context: CodexWsFailure | CodexWsRequestContext +): Promise { if ("error" in context) return context; const routedModel = context.decision?.targetModel ?? context.requestedModel; const modelInfo = await resolveCodexWsModelInfo(routedModel, getModelInfo); @@ -512,7 +562,7 @@ async function resolveCodexUpstreamContext( model, context.allowedConnections ); - if (credentialResult.error) return credentialResult; + if ("error" in credentialResult) return credentialResult; let reasoningDecision = context.decision; if (!reasoningDecision) { try { @@ -563,7 +613,7 @@ async function resolveCodexProxy(provider: string): Promise try { return proxyConfigToUrl(await resolveProxy(provider)) || undefined; } catch (err) { - logger.warn(`[codex-responses-ws] proxy resolution failed: ${sanitizeErrorMessage(err)}`); + log.warn(`[codex-responses-ws] proxy resolution failed: ${sanitizeErrorMessage(err)}`); return undefined; } } diff --git a/src/lib/usage/glmResetCards.ts b/src/lib/usage/glmResetCards.ts index aafb5f979a..2c2d3b8f17 100644 --- a/src/lib/usage/glmResetCards.ts +++ b/src/lib/usage/glmResetCards.ts @@ -42,8 +42,13 @@ const OPERATION_LEASE_OWNER_PREFIX = "vlo_"; type ProxyLike = Parameters[0]; /** A connection with no proxy must go explicitly direct — passing null to - * runWithProxyContext would inherit an ambient context instead. */ -function runWithConnectionFetch(proxy: ProxyLike, fn: () => T): T { + * runWithProxyContext would inherit an ambient context instead. + * + * `runWithProxyContext` is an untyped `async` JS helper (`Promise`), so the + * awaited-callback contract has to be stated here: every call site passes an + * async callback and awaits the result. Declaring `fn: () => Promise` keeps + * `runWithDirectFetchContext>` returning `Promise` too. */ +function runWithConnectionFetch(proxy: ProxyLike, fn: () => Promise): Promise { return proxy ? runWithProxyContext(proxy, fn) : runWithDirectFetchContext(fn); } diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 23ff7e64f0..bd5a0ad9ef 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -1430,7 +1430,7 @@ async function handleSingleModelChat( } = {}, comboStrategy: string | null = null, isCombo: boolean = false -) { +): Promise { // 1. Resolve model → provider/model const resolved = await resolveModelOrError( modelStr,