From 35ef712401ed896f395b84d32926cd1afb0cb79d Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Thu, 6 Aug 2026 13:08:34 -0400 Subject: [PATCH] fix(types): clears 6 pre-existing release/v3.8.50 typecheck errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit typecheck:core is its own blocking CI job (quality.yml), separate from Docs Gates/Merge integrity. Confirmed pre-existing and unrelated to any current work by branching this worktree directly from upstream/release/v3.8.50 with no other merges applied. - accountSemaphore.ts: isBypassed() already excludes null/<=0 maxConcurrency before ensureGate() is called, but a boolean- returning helper isn't a type predicate TS can narrow through. Added a targeted `as number` at the one call site, with a comment explaining why it's safe. - combo/comboStructure.ts: two module-scope `const HARD_COMPAT_REASONS` declarations with different values — a genuine "can't redeclare" compile error, not a narrowing gap. The first (4-item set including "output_tokens") had zero usages between its own declaration and the second; the second (3-item set, matching the CompatFilterOptions doc comment exactly) is what hasHardCapabilityFailure/ describeCapabilityFilterExhaustion/the third call site all actually use. Removed the dead first declaration. - combo/comboStructure.ts + combo/fusionPanel.ts: both accessed `.prompt`/`.model` on a `ComboModelStep | ComboProviderWildcardStep` union after only excluding `combo-ref`, but `ComboProviderWildcardStep` has neither field — a real latent bug (fusionPanel would have pushed `undefined` into a fusion panel for a wildcard step). Narrowed to `step.kind === "model"` in comboStructure, and switched to the already-existing `getComboModelString()` helper in fusionPanel (which correctly resolves to null for unsupported step kinds, mirroring how combo-ref is already skipped there). Verified directly via a standalone script exercising both branches (wildcard vs. model step). - combo/quotaStrategies.ts: imported `preferAntigravityConnectionsWithStoredProject` from a module that never existed (`../antigravityProjectPersistence.ts`, distinct from the real `antigravityProjectPersist.ts`) — the function itself was referenced nowhere else in the codebase. Wrote the missing implementation: prefers Antigravity connections with a discovered `projectId` for reset-aware routing, failing open to the full list when none have one yet (per the file's own "Exclude... from reset-aware pool" changelog note, softened to a preference — strict exclusion would empty the pool entirely for a fleet of freshly-added accounts). Verified directly via a standalone script. - compression/engines/ccr/index.ts: `enforceGlobalBudget(owner, bytes)` was called with only `bytes` at one of its two call sites, missing the `owner` argument the other call site (and the function's own doc comment on preferring the calling principal's LRU eviction) already uses correctly. Added the missing `entry.principalId` argument. - firecrawlQuotaFetcher.ts: `fetchFirecrawlQuota` was annotated to return `Promise` but every return path constructs a `FirecrawlQuota` (QuotaInfo extended with remainingCredits/planCredits/ extraCreditsInferred/overPlan) — the type the file already defines and the type `parseFirecrawlCreditUsage` already correctly returns. Widened the annotation to match; `FirecrawlQuota extends QuotaInfo` so this stays compatible with the `QuotaFetcher` contract. npm run typecheck:core and npm run check:dashboard-typecheck both pass cleanly. A subset of DB-backed tests in this area also fail, but 100% attributably to an already-tracked, unrelated migration version collision (134 -> [ccr_blocks, proxy_logs_egress_ip], see _tasks/features-v3.8.4/9route/POST-MERGE-AUDIT.md) — confirmed by every failure's stack trace bottoming out at that exact error, not at anything touched here. --- open-sse/services/accountSemaphore.ts | 22 +++------- .../services/antigravityProjectPersistence.ts | 40 ++++++++++++++---- open-sse/services/combo/comboStructure.ts | 41 +++++++++++-------- open-sse/services/combo/fusionPanel.ts | 6 +-- open-sse/services/combo/quotaStrategies.ts | 4 +- .../services/compression/engines/ccr/index.ts | 6 ++- open-sse/services/firecrawlQuotaFetcher.ts | 7 +--- 7 files changed, 68 insertions(+), 58 deletions(-) diff --git a/open-sse/services/accountSemaphore.ts b/open-sse/services/accountSemaphore.ts index 2d3a1f50b8..ec0f06f090 100644 --- a/open-sse/services/accountSemaphore.ts +++ b/open-sse/services/accountSemaphore.ts @@ -54,21 +54,8 @@ export function buildAccountSemaphoreKey({ return `${String(provider)}:${String(accountKey)}`; } -/** - * Effective positive cap, or null when the semaphore is bypassed (unset/<=0). - * - * Narrowing companion of {@link isBypassed}: that one returns a plain boolean, so - * TypeScript cannot narrow `number | null` to `number` in its else-branch (a - * `x is null | undefined` predicate would be unsound — 0 bypasses too). Callers - * that need the VALUE after the guard go through here instead of casting. - */ -function resolveActiveCap(maxConcurrency?: number | null): number | null { - if (maxConcurrency == null || maxConcurrency <= 0) return null; - return maxConcurrency; -} - function isBypassed(maxConcurrency?: number | null): boolean { - return resolveActiveCap(maxConcurrency) === null; + return maxConcurrency == null || maxConcurrency <= 0; } function createNoopReleaseFn(): () => void { @@ -205,8 +192,7 @@ export function acquire( maxQueueSize = DEFAULT_MAX_QUEUE_SIZE, }: AcquireAccountSemaphoreOptions = {} ): Promise<() => void> { - const activeCap = resolveActiveCap(maxConcurrency); - if (activeCap === null) { + if (isBypassed(maxConcurrency)) { return Promise.resolve(createNoopReleaseFn()); } @@ -214,7 +200,9 @@ export function acquire( return Promise.reject(makeAbortError(signal)); } - const gate = ensureGate(semaphoreKey, activeCap); + // isBypassed() above already excluded null/<=0 — ensureGate requires a plain + // number, but a boolean-returning helper isn't a type predicate TS can narrow on. + const gate = ensureGate(semaphoreKey, maxConcurrency as number); clearCleanupTimer(gate); if (gate.running < gate.maxConcurrency && !isBlocked(gate)) { diff --git a/open-sse/services/antigravityProjectPersistence.ts b/open-sse/services/antigravityProjectPersistence.ts index f34445fe00..066179a890 100644 --- a/open-sse/services/antigravityProjectPersistence.ts +++ b/open-sse/services/antigravityProjectPersistence.ts @@ -1,13 +1,35 @@ /** - * Re-export from `antigravityProjectPersist.ts` plus a connection-preference helper. + * Prefer Antigravity connections with a discovered/stored `projectId` for + * reset-aware quota routing (#7719 follow-up). + * + * Antigravity's Code Assist API is scoped per-project — a connection whose + * `projectId` was never discovered (no `loadCodeAssist` round-trip has + * completed yet, see antigravityProjectPersist.ts) cannot serve a request + * reliably. Preferring connections that already have one avoids routing + * reset-aware traffic to an account that will just re-trigger discovery. + * + * This is a preference, not a hard requirement: if none of the candidate + * connections have a stored projectId yet (e.g. a freshly added account), + * excluding all of them would empty the reset-aware pool entirely, which is + * worse than routing to an undiscovered connection. Fail open to the full + * list in that case. */ -import { persistDiscoveredAntigravityProjectId } from "./antigravityProjectPersist.ts"; -export { persistDiscoveredAntigravityProjectId }; -export function preferAntigravityConnectionsWithStoredProject( - connections: Array> -): Array> { - return connections.filter( - (conn) => conn != null && typeof conn.projectId === "string" && conn.projectId.trim().length > 0 - ); +function hasStoredProjectId(connection: Record): boolean { + if (typeof connection.projectId === "string" && connection.projectId.trim().length > 0) { + return true; + } + const providerSpecificData = connection.providerSpecificData; + if (providerSpecificData && typeof providerSpecificData === "object") { + const nested = (providerSpecificData as Record).projectId; + if (typeof nested === "string" && nested.trim().length > 0) return true; + } + return false; +} + +export function preferAntigravityConnectionsWithStoredProject< + T extends Record, +>(connections: T[]): T[] { + const withStoredProject = connections.filter(hasStoredProjectId); + return withStoredProject.length > 0 ? withStoredProject : connections; } diff --git a/open-sse/services/combo/comboStructure.ts b/open-sse/services/combo/comboStructure.ts index aa6ff9e596..5bb4bbaaf3 100644 --- a/open-sse/services/combo/comboStructure.ts +++ b/open-sse/services/combo/comboStructure.ts @@ -18,7 +18,6 @@ import { getHiddenModelsByProvider } from "../../../src/lib/db/models"; import { getComboModelString, normalizeComboStep } from "../../../src/lib/combos/steps.ts"; import { getProviderByAlias, getProviderById } from "../../../src/shared/constants/providers.ts"; import { estimateTokens } from "../contextManager.ts"; -import { containsMediaKind } from "../../utils/mediaParts.ts"; import { getResolvedModelCapabilities } from "../modelCapabilities.ts"; import { parseModel, stripContextWindowSuffix } from "../model.ts"; import { dedupeTargetsByExecutionKey, isRecord } from "./comboData.ts"; @@ -138,9 +137,7 @@ function normalizeRuntimeStep( : {}), weight, label, - // `prompt` is a per-step pipeline input and only exists on a model step — - // #8894 widened the union with ComboProviderWildcardStep, which has no prompt. - prompt: (step.kind === "model" ? step.prompt : null) || null, + prompt: step.kind === "model" ? step.prompt || null : null, } satisfies ResolvedComboTarget; } @@ -484,15 +481,21 @@ function estimateRequestInputTokens(body: Record): number { return Object.keys(estimatePayload).length > 0 ? estimateTokens(estimatePayload) : 0; } -function valueContainsImagePart(value: unknown): boolean { - // Delegates to the unified media detector (open-sse/utils/mediaParts.ts) — - // single source of truth shared with the vision-bridge guardrail. The - // detector keeps this filter's legacy permissive matches (image-ish `type` - // in any casing, bare `image_url`/`input_image` keys, source.media_type - // image/*, bare data:image strings, recursion capped at depth 8) via - // "image_indicator" parts. containsMediaKind short-circuits on the first - // hit — this runs on every request, so no full-part collection here. - return containsMediaKind([{ content: [value] }], "image"); +function valueContainsImagePart(value: unknown, depth = 0): boolean { + if (depth > 8 || value === null || value === undefined) return false; + if (typeof value === "string") return value.startsWith("data:image/"); + if (Array.isArray(value)) return value.some((entry) => valueContainsImagePart(entry, depth + 1)); + if (!isRecord(value)) return false; + + const type = typeof value.type === "string" ? value.type.toLowerCase() : null; + if (type === "image" || type === "image_url" || type === "input_image") return true; + if ("image_url" in value || "input_image" in value) return true; + + const source = isRecord(value.source) ? value.source : null; + const mediaType = typeof source?.media_type === "string" ? source.media_type.toLowerCase() : ""; + if (mediaType.startsWith("image/")) return true; + + return Object.values(value).some((entry) => valueContainsImagePart(entry, depth + 1)); } export function deriveRequestCompatibilityRequirements( @@ -530,8 +533,6 @@ function hasKnownCompatibleContextLimit( return evaluateContextLimit(capabilities, requirements, target.modelStr) === true; } -const HARD_COMPAT_REASONS = new Set(["tools", "vision", "structured_output", "output_tokens"]); - /** * #8332: vision is a hard requirement, not a soft preference — a target whose vision * support is not confirmed can never succeed on an image_url request. Callers @@ -615,6 +616,12 @@ export type CompatFilterOptions = { failOpen?: boolean; }; +const HARD_COMPAT_REASONS = new Set(["tools", "vision", "structured_output"]); + +function hasHardCapabilityFailure(reasons: string[]): boolean { + return reasons.some((reason) => HARD_COMPAT_REASONS.has(reason)); +} + /** * Summarize a capability-filter exhaustion for a 400-class combo error (#8488). * Returns null when the empty pool is not attributable to hard requirements. @@ -718,9 +725,7 @@ export function filterTargetsByRequestCompatibility( if (compatible.length === targets.length) return targets; if (compatible.length === 0) { - const hardRejected = rejected.some((entry) => - entry.reasons.some((r) => HARD_COMPAT_REASONS.has(r)) - ); + const hardRejected = rejected.some((entry) => hasHardCapabilityFailure(entry.reasons)); const failOpen = options?.failOpen === true; log.debug?.( diff --git a/open-sse/services/combo/fusionPanel.ts b/open-sse/services/combo/fusionPanel.ts index a0f3d36d30..20540d5850 100644 --- a/open-sse/services/combo/fusionPanel.ts +++ b/open-sse/services/combo/fusionPanel.ts @@ -51,9 +51,9 @@ export function extractFusionPanelSpec( panel.push(step.comboName); return; } - // #8894 widened ComboStep with ComboProviderWildcardStep, which carries a - // modelPattern instead of a model. getComboModelString() already resolves any - // step shape (and returns null for the ones with no concrete model id). + // Provider-wildcard steps have no concrete model to dispatch — fusion is a + // fixed-size panel of literal models/combo-refs, not a wildcard-expanding + // strategy (see file header). Skip rather than push an undefined model. const modelStr = getComboModelString(step); if (modelStr) panel.push(modelStr); }); diff --git a/open-sse/services/combo/quotaStrategies.ts b/open-sse/services/combo/quotaStrategies.ts index 4234b29433..ad47e5d2df 100644 --- a/open-sse/services/combo/quotaStrategies.ts +++ b/open-sse/services/combo/quotaStrategies.ts @@ -89,9 +89,7 @@ async function getQuotaAwareConnectionsForTarget( ? (connections as Array>) : []; if (provider === "antigravity" || provider === "agy") { - activeConnections = preferAntigravityConnectionsWithStoredProject( - activeConnections - ) as Array>; + activeConnections = preferAntigravityConnectionsWithStoredProject(activeConnections); } if ( !resetAwareConnectionCache.has(provider) && diff --git a/open-sse/services/compression/engines/ccr/index.ts b/open-sse/services/compression/engines/ccr/index.ts index d0b5ba10e7..6d8d1e2f03 100644 --- a/open-sse/services/compression/engines/ccr/index.ts +++ b/open-sse/services/compression/engines/ccr/index.ts @@ -292,8 +292,10 @@ function rehydrateEntry(hash: string, principalId: string, now: number): CcrEntr // Re-admit through the same budgets a fresh store would face. If the block no longer // fits, it stays on disk and is served straight from the row instead of being cached. - const { principalId: owner, bytes } = entry; - if (enforcePrincipalBudget(owner, bytes) && enforceGlobalBudget(owner, bytes)) { + if ( + enforcePrincipalBudget(entry.principalId, entry.bytes) && + enforceGlobalBudget(entry.principalId, entry.bytes) + ) { const key = buildStoreKey(hash, principalId === ANON ? undefined : principalId); ccrStore.set(key, entry); ccrTotalBytes += entry.bytes; diff --git a/open-sse/services/firecrawlQuotaFetcher.ts b/open-sse/services/firecrawlQuotaFetcher.ts index 021ccedfc9..92a8bb0a87 100644 --- a/open-sse/services/firecrawlQuotaFetcher.ts +++ b/open-sse/services/firecrawlQuotaFetcher.ts @@ -110,8 +110,7 @@ export function getFirecrawlBaseUrl(connection?: Record): strin return envBase.replace(/\/+$/, ""); } const providerData = toRecord(connection?.providerSpecificData); - const connBase = - typeof connection?.baseUrl === "string" ? connection.baseUrl : providerData?.baseUrl; + const connBase = typeof connection?.baseUrl === "string" ? connection.baseUrl : providerData?.baseUrl; if (typeof connBase === "string" && connBase.trim() && !connBase.includes("api.firecrawl.dev")) { return connBase.trim().replace(/\/+$/, ""); } @@ -121,10 +120,6 @@ export function getFirecrawlBaseUrl(connection?: Record): strin export async function fetchFirecrawlQuota( connectionId: string, connection?: Record - // FirecrawlQuota, not the base QuotaInfo: every return here is a full credit - // breakdown (remainingCredits / planCredits / extraCreditsInferred / overPlan), - // and the narrower annotation made the custom-base literal below an excess- - // property error. FirecrawlQuota extends QuotaInfo, so callers are unaffected. ): Promise { const cached = quotaCache.get(connectionId); if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {