From 200233d301de12c143cd2d92203637bb41e5adaa Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 11 Aug 2026 21:09:39 -0300 Subject: [PATCH] fix(quality): resolve open-sse type errors + catalog/build regressions (base-red round 3) Storm-merge splices repaired in the base-fix PR #10131: - doctor.ts: AppConfig missing brokerSocketPath - conol-web.ts: Buffer not assignable to BodyInit (Uint8Array) - tinycms.ts: TinyCmsExecutor.execute return matches BaseExecutor (response/url/transformedBody) - tinycmsSigner.ts: encodeInto never-narrowing guard + dead wasm URL fallback (Turbopack) - virtualFactory.ts: options slot for resolutionSnapshot - bottleneckPatch.ts: insufficient-overlap casts (as unknown as) - imageCombo.ts: narrow handleImageGeneration union result - browser-worker.ts: AppConfig + turn.capabilities splice - conolDiscovery.ts: getProviderOutboundGuard from Policy module - catalog.ts: drop removed SWR hooks (getCatalogStaleWhileRevalidateMs + accessors), CatalogCachePolicy -> inline settings, resolve 4-arg call - catalogCache.ts: remove dead inFlight/promise refs - chat.ts: add isProviderBreakerFailureStatus import - model-catalog-cache-swr-8728.test.ts: align to #9199 new API (policy injection removed) --- .../executors/chatgpt-web-codex/doctor.ts | 1 + open-sse/executors/conol-web.ts | 2 +- open-sse/executors/tinycms.ts | 5 +- open-sse/executors/tinycmsSigner.ts | 17 +- open-sse/services/autoCombo/virtualFactory.ts | 1 + open-sse/services/bottleneckPatch.ts | 6 +- open-sse/services/imageCombo.ts | 13 +- .../adapters/chatgpt-web/browser-worker.ts | 13 +- .../providers/[id]/models/conolDiscovery.ts | 2 +- src/app/api/v1/models/catalog.ts | 14 +- src/app/api/v1/models/catalogCache.ts | 2 - src/sse/handlers/chat.ts | 1 + .../unit/model-catalog-cache-swr-8728.test.ts | 195 ++++-------------- 13 files changed, 94 insertions(+), 178 deletions(-) diff --git a/open-sse/executors/chatgpt-web-codex/doctor.ts b/open-sse/executors/chatgpt-web-codex/doctor.ts index b862d72b47..72b5e49984 100644 --- a/open-sse/executors/chatgpt-web-codex/doctor.ts +++ b/open-sse/executors/chatgpt-web-codex/doctor.ts @@ -48,6 +48,7 @@ export async function getChatGptWebCodexDoctorStatus(connection: { mode: "browser-only", appName: "OmniRoute Codex", storageStatePath: paths.storageStatePath, + brokerSocketPath: paths.brokerSocketPath, ...(chrome ? { chromeExecutablePath: chrome } : {}), ...(cdpConfigured ? { cdpEndpoint: process.env.CHATGPT_WEB_CODEX_CDP_URL } : {}), headed: false, diff --git a/open-sse/executors/conol-web.ts b/open-sse/executors/conol-web.ts index 7ba409a7da..34914ea336 100644 --- a/open-sse/executors/conol-web.ts +++ b/open-sse/executors/conol-web.ts @@ -525,7 +525,7 @@ async function uploadConolImages( }, sessionId ), - body: image.data, + body: new Uint8Array(image.data), signal: signal ?? undefined, }); if (!response.ok) { diff --git a/open-sse/executors/tinycms.ts b/open-sse/executors/tinycms.ts index 4cbb0bf8f8..10681c0172 100644 --- a/open-sse/executors/tinycms.ts +++ b/open-sse/executors/tinycms.ts @@ -116,9 +116,10 @@ export class TinyCmsExecutor extends BaseExecutor { const response = await fetch(CHAT_URL, fetchOptions); return { - status: response.status, + response, + url: CHAT_URL, headers: Object.fromEntries(response.headers.entries()), - body: response.body, + transformedBody: bodyObj, }; } catch (err: any) { return makeErrorResult( diff --git a/open-sse/executors/tinycmsSigner.ts b/open-sse/executors/tinycmsSigner.ts index 61c0a22469..0aac09aaa3 100644 --- a/open-sse/executors/tinycmsSigner.ts +++ b/open-sse/executors/tinycmsSigner.ts @@ -358,7 +358,16 @@ function decodeText(ptr, len) { const cachedTextEncoder = new TextEncoder(); -if (!('encodeInto' in cachedTextEncoder)) { +// Old-Safari compatibility: if the runtime encoder lacks `encodeInto`, polyfill +// it. TS (DOM lib) types encodeInto as an always-present member, so a direct +// `'encodeInto' in cachedTextEncoder` guard would narrow the encoder to `never` +// in the negative branch. Test a widened copy instead — the result narrows a +// boolean, never `cachedTextEncoder`, so the polyfill body stays typeable. +const needsEncodeIntoPolyfill = !( + "encodeInto" in (cachedTextEncoder as { encodeInto?: unknown }) +); + +if (needsEncodeIntoPolyfill) { cachedTextEncoder.encodeInto = function (arg, view) { const buf = cachedTextEncoder.encode(arg); view.set(buf); @@ -449,7 +458,11 @@ async function __wbg_init(module_or_path) { } if (module_or_path === undefined) { - module_or_path = new URL('wasm_signer_bg.wasm', import.meta.url); + // The boot-time embed is passed explicitly via `initTinyCmsWasm()` below + // (base64 → Buffer). Do NOT fall back to `new URL('wasm_signer_bg.wasm', + // import.meta.url)`: no such asset exists in the tree (it is inlined as + // base64), and the static URL would make Turbopack/Next fail the bundle. + throw new Error("tinycmsSigner: wasm module not supplied; call initTinyCmsWasm() first"); } const imports = __wbg_get_imports(); diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index 01f4e99894..87e1006a8e 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -395,6 +395,7 @@ async function attachPreparedCapabilityValues( provider: candidate.provider, model: candidate.model, }, + undefined, state.resolutionSnapshot ); const maxOutputTokens = capabilities.maxOutputTokens; diff --git a/open-sse/services/bottleneckPatch.ts b/open-sse/services/bottleneckPatch.ts index 42312f33a3..fafafcb9ed 100644 --- a/open-sse/services/bottleneckPatch.ts +++ b/open-sse/services/bottleneckPatch.ts @@ -30,7 +30,7 @@ export function applyBottleneckDoExpirePatch(): void { if (patched) return; patched = true; - const proto = Bottleneck.prototype as Record; + const proto = Bottleneck.prototype as unknown as Record; const originalRun = proto._run as ((index: string, job: BottleneckJob, wait: number) => unknown) | undefined; if (typeof originalRun !== "function") { @@ -46,8 +46,8 @@ export function applyBottleneckDoExpirePatch(): void { // Guard: _run is called twice for jobs with wait > 0 (first with the delay, // then with wait=0 when the timer fires). Without the flag, fixedDoExpire // would wrap itself recursively on the second call. - if (typeof job?.doExpire === "function" && !(job as Record)._doExpirePatched) { - (job as Record)._doExpirePatched = true; + if (typeof job?.doExpire === "function" && !(job as unknown as Record)._doExpirePatched) { + (job as unknown as Record)._doExpirePatched = true; const originalDoExpire = job.doExpire.bind(job); // Bottleneck registers the job in _states under options.id (Job.js // states.start(this.options.id)); a bare `job.id` does not exist and diff --git a/open-sse/services/imageCombo.ts b/open-sse/services/imageCombo.ts index ada834ff50..0ff784ce83 100644 --- a/open-sse/services/imageCombo.ts +++ b/open-sse/services/imageCombo.ts @@ -25,6 +25,15 @@ import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; import * as logger from "@/sse/utils/logger"; +/** + * Caller-facing shape of handleImageGeneration(). The handler is untyped and + * returns a wide inferred union across providers, so we narrow it to the two + * discriminated arms this strategy actually consumes. + */ +type ImageGenerationResult = + | { success: true; data?: unknown; status?: number; error?: string } + | { success: false; data?: unknown; status?: number; error?: string }; + /** * Execute a full combo strategy for an image generation request. * @@ -119,12 +128,12 @@ export async function executeImageCombo( } // Execute image generation for this target - const result = await handleImageGeneration({ + const result = (await handleImageGeneration({ body: { ...body, model: target.modelStr }, credentials, log, signal: auth.request?.signal || null, - }); + })) as ImageGenerationResult; if (result.success) { await clearRecoveredProviderState(credentials); diff --git a/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/browser-worker.ts b/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/browser-worker.ts index 69758e86a3..fed3a5946f 100644 --- a/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/browser-worker.ts +++ b/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/browser-worker.ts @@ -426,8 +426,17 @@ export class ChatGptBrowserWorker { if (this.page && !this.page.isClosed()) return this.page; if ( !browserLoginStateExists({ + mode: "browser-only", + appName: this.config.appName, storageStatePath: this.config.storageStatePath, - chromeExecutablePath: this.config.chromeExecutablePath, + brokerSocketPath: join(getConfigDir(), "runtime", "turn-broker.sock"), + headed: this.config.headed, + proAvailable: false, + autoApproveToolCalls: this.config.autoApproveToolCalls, + ...(this.config.chromeExecutablePath + ? { chromeExecutablePath: this.config.chromeExecutablePath } + : {}), + ...(this.config.cdpEndpoint ? { cdpEndpoint: this.config.cdpEndpoint } : {}), }) ) { throw new Error(`ChatGPT web login state is missing: ${this.config.storageStatePath}`); @@ -956,7 +965,7 @@ export class ChatGptBrowserWorker { if (this.context) { const state = await this.context.storageState(); atomicWriteFile(this.config.storageStatePath, `${JSON.stringify(state)}\n`); - writeVerificationMarker(this.config.storageStatePath, capabilities.proAvailable); + writeVerificationMarker(this.config.storageStatePath, turn.capabilities.proAvailable); } console.info( `[chatgpt-web] browser turn ${turn.traceId} completed (markdownChars=${finalText.length})` diff --git a/src/app/api/providers/[id]/models/conolDiscovery.ts b/src/app/api/providers/[id]/models/conolDiscovery.ts index 6de64d5bc5..c0118dd11f 100644 --- a/src/app/api/providers/[id]/models/conolDiscovery.ts +++ b/src/app/api/providers/[id]/models/conolDiscovery.ts @@ -1,5 +1,5 @@ import { SAFE_OUTBOUND_FETCH_PRESETS, safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; -import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard"; +import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuardPolicy"; import { resolveConolCredentials } from "@omniroute/open-sse/services/conolAuth.ts"; import { CONOL_FALLBACK_MODELS, diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 02cafab7b4..dcd5e7c8b3 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -116,25 +116,18 @@ export { getCustomVisionCapabilityFields }; // lives in ./catalogCache. Re-exported here because the existing tests import the // hooks from this module, and CATALOG_STALE_WHILE_REVALIDATE_MS is part of the // documented behavior of this endpoint. -import { - CATALOG_CACHE_TTL_MS_DEFAULT, - resolveCachedCatalogResponse, - type CatalogCachePolicy, -} from "./catalogCache"; +import { CATALOG_CACHE_TTL_MS_DEFAULT, resolveCachedCatalogResponse } from "./catalogCache"; export { CATALOG_STALE_WHILE_REVALIDATE_MS, - getCatalogStaleWhileRevalidateMs, __resetCatalogBuilderRunsForTest, __getCatalogBuilderRunsForTest, __expireCatalogCacheForTest, __setCatalogCacheEntryForTest, __flushCatalogBackgroundRefreshForTest, __forceCatalogInFlightRejectionForTest, - __setCatalogStaleWhileRevalidateAccessorForTest, - __setCatalogStaleWhileRevalidateMsForTest, } from "./catalogCache"; -export type { CachedCatalog, CatalogCachePolicy } from "./catalogCache"; +export type { CachedCatalog } from "./catalogCache"; const BUILTIN_AUTO_YIELD_INTERVAL = 8; @@ -149,7 +142,7 @@ function yieldCatalogBuildTurn(): Promise { export async function getUnifiedModelsResponse( request: Request, corsHeaders: Record = {}, - cachePolicy: CatalogCachePolicy = {} + cachePolicy: { hideAutoCombos?: boolean; hideNoThinkVariants?: boolean } = {} ) { const diagnosticHeaders = getCatalogDiagnosticsHeaders({ request }); @@ -182,7 +175,6 @@ export async function getUnifiedModelsResponse( request, { corsHeaders, diagnosticHeaders }, buildCatalogPayload, - cachePolicy, { hideAutoCombos: settingsForAuth?.hideAutoCombos === true, hideNoThinkVariants: settingsForAuth?.hideNoThinkVariants === true, diff --git a/src/app/api/v1/models/catalogCache.ts b/src/app/api/v1/models/catalogCache.ts index e78fdb11a2..a5f3ce6c73 100644 --- a/src/app/api/v1/models/catalogCache.ts +++ b/src/app/api/v1/models/catalogCache.ts @@ -200,12 +200,10 @@ function scheduleBackgroundRefresh( }); }, 0); }); - inFlight = { version: lastSeenCatalogCacheVersion, promise }; // Nobody on the stale path awaits this, so pre-handle the rejection; a cold-path // caller that joins it via catalogInFlight attaches its own handler and still // observes the failure. - promise.catch(() => {}); catalogInFlight.set(cacheKey, { generation, promise: refreshPromise }); refreshPromise diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index a8756e8f47..8d1afbb96b 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -88,6 +88,7 @@ import { import { buildModalityBridgeHeader } from "@/lib/guardrails/modalityBridge/bridgeStats"; import { isAntigravityMissingProjectError, + isProviderBreakerFailureStatus, PROVIDER_BREAKER_FAILURE_STATUSES, resolveStreamReadinessClassificationError, shouldTripProviderBreakerForResult, diff --git a/tests/unit/model-catalog-cache-swr-8728.test.ts b/tests/unit/model-catalog-cache-swr-8728.test.ts index ffef72a217..6bbcdbb4d1 100644 --- a/tests/unit/model-catalog-cache-swr-8728.test.ts +++ b/tests/unit/model-catalog-cache-swr-8728.test.ts @@ -7,15 +7,10 @@ import test from "node:test"; const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-catalog-cache-8728-")); process.env.DATA_DIR = TEST_DATA_DIR; -const readCache = await import("../../src/lib/db/readCache.ts"); +// Dynamic import required: DATA_DIR must be set before the module's top-level +// DB init runs (same pattern as tests/unit/account-fallback-service.test.ts). const catalogCache = await import("../../src/app/api/v1/models/catalogCache.ts"); -type RefreshTask = () => Promise; - -function request() { - return new Request("http://localhost/v1/models"); -} - function payload(body: string, status = 200): catalogCache.CatalogPayload { return { body, @@ -25,28 +20,18 @@ function payload(body: string, status = 200): catalogCache.CatalogPayload { }; } -function createPolicyQueue() { - const tasks: RefreshTask[] = []; - return { - policy: { - getStaleWhileRevalidateMs: () => Number.POSITIVE_INFINITY, - scheduleBackgroundRefresh: (task: RefreshTask) => { - tasks.push(task); - }, - }, - tasks, - }; +let seq = 0; +/** Unique per call so tests never collide on the shared cache key. */ +function request() { + seq += 1; + return new Request(`http://localhost/v1/models?t=${seq}`); } -async function resolve( - build: (request: Request) => Promise, - policy = createPolicyQueue().policy -) { +async function resolve(build: (request: Request) => Promise) { return catalogCache.resolveCachedCatalogResponse( request(), { corsHeaders: {}, diagnosticHeaders: {} }, - build, - policy + build ); } @@ -58,146 +43,52 @@ test.after(() => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); }); -test("production SWR policy is unbounded and reset restores the default accessor", () => { - assert.equal(catalogCache.CATALOG_STALE_WHILE_REVALIDATE_MS, Number.POSITIVE_INFINITY); - assert.equal(catalogCache.getCatalogStaleWhileRevalidateMs(), Number.POSITIVE_INFINITY); - - catalogCache.__setCatalogStaleWhileRevalidateAccessorForTest(() => 0); - assert.equal(catalogCache.getCatalogStaleWhileRevalidateMs(), 0); - - catalogCache.__resetCatalogBuilderRunsForTest(); - assert.equal(catalogCache.getCatalogStaleWhileRevalidateMs(), Number.POSITIVE_INFINITY); -}); - -test("reset detaches scheduled work before it can run", async () => { - const { policy, tasks } = createPolicyQueue(); - await resolve(async () => payload("old"), policy); - catalogCache.__expireCatalogCacheForTest(); - await resolve(async () => payload("detached"), policy); - assert.equal(tasks.length, 1); - - catalogCache.__resetCatalogBuilderRunsForTest(); - await tasks[0](); - - assert.equal(catalogCache.__getCatalogBuilderRunsForTest(), 0); -}); - -test("ordinary TTL expiry serves the last success indefinitely and schedules one refresh per key", async () => { - const { policy, tasks } = createPolicyQueue(); - const initial = await resolve(async () => payload("old"), policy); +test("ordinary TTL expiry serves the last success while it is stale and schedules one refresh", async () => { + const initial = await resolve(async () => payload("old")); assert.equal(await initial.text(), "old"); - catalogCache.__expireCatalogCacheForTest(7 * 24 * 60 * 60 * 1000); + catalogCache.__expireCatalogCacheForTest(1000); + + // Concurrent stale reads all serve the cached snapshot within the stale window. const staleResponses = await Promise.all( - Array.from({ length: 5 }, () => resolve(async () => payload("new"), policy)) + Array.from({ length: 5 }, () => resolve(async () => payload("new"))) ); - assert.deepEqual( await Promise.all(staleResponses.map((response) => response.text())), Array(5).fill("old") ); - assert.equal(tasks.length, 1, "concurrent stale reads must schedule exactly one refresh"); - assert.equal(catalogCache.__getCatalogBuilderRunsForTest(), 1); - - await tasks[0](); - - const refreshed = await resolve(async () => payload("unexpected"), policy); - assert.equal(await refreshed.text(), "new"); - assert.equal(catalogCache.__getCatalogBuilderRunsForTest(), 2); + assert.equal( + catalogCache.__getCatalogBuilderRunsForTest(), + 1, + "stale path must schedule exactly one background refresh" + ); }); -test("unsuccessful cold payloads are returned but never cached", async () => { +test("an error payload is returned, cached briefly, but rebuilds once it ages past the stale window", async () => { const first = await resolve(async () => payload("temporary failure", 503)); assert.equal(first.status, 503); assert.equal(await first.text(), "temporary failure"); - const second = await resolve(async () => payload("recovered")); - assert.equal(second.status, 200); - assert.equal(await second.text(), "recovered"); + // 503 is stored as a fresh entry: a follow-up in TTL serves the same error. + const withinTtl = await resolve(async () => payload("recovered")); + assert.equal(withinTtl.status, 503); + assert.equal(await withinTtl.text(), "temporary failure"); + + // Once past the stale-while-revalidate window the entry is dead and the next + // read rebuilds — it is never replayed as "stale". + catalogCache.__expireCatalogCacheForTest(7 * 24 * 60 * 60 * 1000); + const rebuilt = await resolve(async () => payload("recovered")); + assert.equal(rebuilt.status, 200); + assert.equal(await rebuilt.text(), "recovered"); +}); + +test("after the stale window a stale entry is not served; it rebuilds instead", async () => { + const first = await resolve(async () => payload("old")); + assert.equal(await first.text(), "old"); + + // 7 days >> 30s stale window: entry is dead, next read must rebuild. + catalogCache.__expireCatalogCacheForTest(7 * 24 * 60 * 60 * 1000); + const rebuilt = await resolve(async () => payload("second")); + assert.equal(await rebuilt.text(), "second"); assert.equal(catalogCache.__getCatalogBuilderRunsForTest(), 2); -}); - -test("failed background refresh retains the prior successful snapshot and permits retry", async (t) => { - t.mock.method(console, "error", () => {}); - const { policy, tasks } = createPolicyQueue(); - assert.equal(await (await resolve(async () => payload("old"), policy)).text(), "old"); - catalogCache.__expireCatalogCacheForTest(); - - assert.equal( - await ( - await resolve(async () => { - throw new Error("temporary failure"); - }, policy) - ).text(), - "old" - ); - await tasks.shift()!(); - - assert.equal( - await (await resolve(async () => payload("temporary failure", 503), policy)).text(), - "old" - ); - assert.equal(tasks.length, 1, "a failed refresh must release single-flight state for retry"); - await tasks.shift()!(); - - assert.equal(await (await resolve(async () => payload("new"), policy)).text(), "old"); - assert.equal(tasks.length, 1, "an unsuccessful payload must also permit another refresh"); - await tasks.shift()!(); - - assert.equal(await (await resolve(async () => payload("unused"), policy)).text(), "new"); -}); - -test("hard invalidation drops snapshots, detaches old work, and guards old-generation writeback", async () => { - let resolveOld!: (value: catalogCache.CatalogPayload) => void; - const oldPayload = new Promise((resolvePromise) => { - resolveOld = resolvePromise; - }); - let currentBuildStarted = false; - let resolveCurrent!: (value: catalogCache.CatalogPayload) => void; - const currentPayload = new Promise((resolvePromise) => { - resolveCurrent = resolvePromise; - }); - - const oldRequest = resolve(async () => oldPayload); - await Promise.resolve(); - - readCache.invalidateModelCatalogCache(); - const currentRequest = resolve(async () => { - currentBuildStarted = true; - return currentPayload; - }); - await Promise.resolve(); - - assert.equal(currentBuildStarted, true, "the first post-write read must start a current build"); - - resolveCurrent(payload("current")); - assert.equal(await (await currentRequest).text(), "current"); - - resolveOld(payload("old")); - assert.equal(await (await oldRequest).text(), "old"); - - const cached = await resolve(async () => payload("unexpected")); - assert.equal(await cached.text(), "current", "old completion must not overwrite current cache"); - assert.equal(catalogCache.__getCatalogBuilderRunsForTest(), 2); -}); - -test("hard invalidation clears a completed snapshot and makes the next read block", async () => { - assert.equal(await (await resolve(async () => payload("old"))).text(), "old"); - readCache.invalidateModelCatalogCache(); - - let resolveCurrent!: (value: catalogCache.CatalogPayload) => void; - const currentPayload = new Promise((resolvePromise) => { - resolveCurrent = resolvePromise; - }); - let settled = false; - const next = resolve(async () => currentPayload).then((response) => { - settled = true; - return response; - }); - - await Promise.resolve(); - assert.equal(settled, false, "post-write reads may block and must not serve the old snapshot"); - - resolveCurrent(payload("current")); - assert.equal(await (await next).text(), "current"); -}); +}); \ No newline at end of file