From e12fb110f96ef6bf18d63ab779fef4c6a18faadd Mon Sep 17 00:00:00 2001 From: backryun Date: Tue, 1 Sep 2026 02:13:46 +0900 Subject: [PATCH] [URGENT] fix(dev): reduce instrumentation executor fan-out (phase 3) (#12078) * fix(dev): reduce instrumentation executor fan-out * fix(ci): reduce credential refresh complexity --------- Co-authored-by: backryun --- open-sse/executors/credential.ts | 54 +++++++ open-sse/executors/defaultResolver.ts | 13 ++ open-sse/executors/index.ts | 7 +- src/instrumentation-node.ts | 2 +- .../providers/validation/anthropicFormat.ts | 4 +- src/lib/services/quotaAutoPing.ts | 32 +++- src/lib/usage/providerLimits.ts | 152 ++---------------- .../usage/providerLimits/credentialRefresh.ts | 150 +++++++++++++++++ ...ex-quota-sync-no-proactive-refresh.test.ts | 7 +- tests/unit/executor-registry.test.ts | 7 + ...instrumentation-import-graph-12074.test.ts | 107 ++++++++++++ ...stoken-fallback-on-refresh-failure.test.ts | 7 +- ...ider-limits-force-refresh-imported.test.ts | 7 +- ...roid-playwright-static-import-7265.test.ts | 11 +- 14 files changed, 393 insertions(+), 167 deletions(-) create mode 100644 open-sse/executors/credential.ts create mode 100644 open-sse/executors/defaultResolver.ts create mode 100644 src/lib/usage/providerLimits/credentialRefresh.ts create mode 100644 tests/unit/instrumentation-import-graph-12074.test.ts diff --git a/open-sse/executors/credential.ts b/open-sse/executors/credential.ts new file mode 100644 index 0000000000..5de4b7fbfa --- /dev/null +++ b/open-sse/executors/credential.ts @@ -0,0 +1,54 @@ +import { assertCommonChatGptWebProviderAvailable } from "@/shared/constants/chatgptWebRetirement"; +import { assertMicrosoftDesignerWebProviderAvailable } from "@/shared/constants/designerWebRetirement"; +import { assertRuntimeProviderAvailable } from "@/shared/constants/providerRetirement"; +import type { BaseExecutor } from "./base.ts"; +import { getDefaultExecutor } from "./defaultResolver.ts"; + +type CredentialExecutorLoader = () => Promise; + +const specializedCredentialExecutors: Record = { + antigravity: () => import("./antigravity.ts").then((m) => new m.AntigravityExecutor()), + agy: () => import("./antigravity.ts").then((m) => new m.AntigravityExecutor()), + github: () => import("./github.ts").then((m) => new m.GithubExecutor()), + "ghe-copilot": () => import("./ghe-copilot.ts").then((m) => new m.GheCopilotExecutor()), + kiro: () => import("./kiro.ts").then((m) => new m.KiroExecutor()), + "amazon-q": () => import("./kiro.ts").then((m) => new m.KiroExecutor("amazon-q")), + codex: () => import("./codex.ts").then((m) => new m.CodexExecutor()), + cursor: () => import("./cursor.ts").then((m) => new m.CursorExecutor()), + cu: () => import("./cursor.ts").then((m) => new m.CursorExecutor()), + "cursor-api": () => import("./cursor.ts").then((m) => new m.CursorExecutor("cursor-api")), + cua: () => import("./cursor.ts").then((m) => new m.CursorExecutor("cursor-api")), + trae: () => import("./trae.ts").then((m) => new m.TraeExecutor()), + gitlab: () => import("./gitlab.ts").then((m) => new m.GitlabExecutor()), + "gitlab-duo": () => import("./gitlab.ts").then((m) => new m.GitlabExecutor("gitlab-duo")), + "zed-hosted": () => import("./zed-hosted.ts").then((m) => new m.ZedHostedExecutor()), + "grok-cli": () => import("./grok-cli.ts").then((m) => new m.GrokCliExecutor()), + gc: () => import("./grok-cli.ts").then((m) => new m.GrokCliExecutor()), + auggie: () => import("./auggie.ts").then((m) => new m.AuggieExecutor()), + xai: () => import("./xai.ts").then((m) => new m.XaiExecutor()), + "xai-oauth": () => import("./xai.ts").then((m) => new m.XaiExecutor("xai-oauth")), + xao: () => import("./xai.ts").then((m) => new m.XaiExecutor("xai-oauth")), +}; + +const credentialExecutorCache = new Map>(); + +/** Resolve only executors with credential-refresh behavior, without loading the chat registry. */ +export async function getCredentialRefreshExecutor(provider: string): Promise { + assertMicrosoftDesignerWebProviderAvailable(provider); + assertRuntimeProviderAvailable(provider); + assertCommonChatGptWebProviderAvailable(provider); + + let executor = credentialExecutorCache.get(provider); + if (!executor) { + const specializedLoader = specializedCredentialExecutors[provider]; + executor = specializedLoader + ? specializedLoader() + : Promise.resolve(getDefaultExecutor(provider)); + executor = executor.catch((error) => { + credentialExecutorCache.delete(provider); + throw error; + }); + credentialExecutorCache.set(provider, executor); + } + return executor; +} diff --git a/open-sse/executors/defaultResolver.ts b/open-sse/executors/defaultResolver.ts new file mode 100644 index 0000000000..ac3998409e --- /dev/null +++ b/open-sse/executors/defaultResolver.ts @@ -0,0 +1,13 @@ +import { DefaultExecutor } from "./default.ts"; + +const defaultExecutorCache = new Map(); + +/** Resolve the shared fallback executor without initializing the specialized executor registry. */ +export function getDefaultExecutor(provider: string): DefaultExecutor { + let executor = defaultExecutorCache.get(provider); + if (!executor) { + executor = new DefaultExecutor(provider); + defaultExecutorCache.set(provider, executor); + } + return executor; +} diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 5580832f28..fbe9940d80 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -9,7 +9,7 @@ import { } from "./registry.ts"; // Type-only: pulls no runtime code, keeps DefaultExecutor the only eager class. import type { BaseExecutor } from "./base.ts"; -import { DefaultExecutor } from "./default.ts"; +import { getDefaultExecutor } from "./defaultResolver.ts"; // R0.3 — declarative built-in table, made LAZY by #11220. // @@ -207,8 +207,6 @@ for (const [alias, load] of Object.entries(lazyExecutors)) { registerLazyExecutor(alias, load); } -const defaultCache = new Map(); - // #6699 — providers that exist ONLY as Cloud Agent task-API entries // (CLOUD_AGENT_PROVIDERS / staticModels "Available Models" catalog) and have no // chat-completions REGISTRY entry anywhere in open-sse/. Without this guard, @@ -251,8 +249,7 @@ export async function getExecutor(provider: string): Promise { (err as Error & { status?: number }).status = 400; throw err; } - if (!defaultCache.has(provider)) defaultCache.set(provider, new DefaultExecutor(provider)); - return defaultCache.get(provider)!; + return getDefaultExecutor(provider); } export function hasSpecializedExecutor(provider: string): boolean { diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index 37ab73e781..069b8740b8 100755 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -319,7 +319,7 @@ export async function registerNodejs(): Promise { process.title = renameProcessTitle(process.title); // Initialize proxy fetch patch FIRST (before any HTTP requests) - await import("@omniroute/open-sse/index.ts"); + await import("@omniroute/open-sse/utils/proxyFetch.ts"); console.log("[STARTUP] Global fetch proxy patch initialized"); // Register quota fetchers early so combo routing can use real quota-aware diff --git a/src/lib/providers/validation/anthropicFormat.ts b/src/lib/providers/validation/anthropicFormat.ts index da2d567694..2ab86f1316 100644 --- a/src/lib/providers/validation/anthropicFormat.ts +++ b/src/lib/providers/validation/anthropicFormat.ts @@ -9,6 +9,7 @@ import { joinClaudeCodeCompatibleUrl, joinBaseUrlAndPath, } from "@omniroute/open-sse/services/claudeCodeCompatible.ts"; +import { getDefaultExecutor } from "@omniroute/open-sse/executors/defaultResolver.ts"; import { addModelsSuffix, normalizeAnthropicBaseUrl, @@ -137,8 +138,7 @@ export async function validateClaudeOAuthInline({ typeof override === "string" && override ? override : modelId || "claude-haiku-4-5-20251001"; try { - const { getExecutor } = await import("@omniroute/open-sse/executors/index.ts"); - const executed = await (await getExecutor("claude")).execute({ + const executed = await getDefaultExecutor("claude").execute({ model: testModelId, body: { model: testModelId, diff --git a/src/lib/services/quotaAutoPing.ts b/src/lib/services/quotaAutoPing.ts index 21c6466b52..a31f16bdb2 100644 --- a/src/lib/services/quotaAutoPing.ts +++ b/src/lib/services/quotaAutoPing.ts @@ -22,13 +22,12 @@ import { logger } from "@omniroute/open-sse/utils/logger.ts"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; -import { getExecutor } from "@omniroute/open-sse/executors/index.ts"; import type { BaseExecutor } from "@omniroute/open-sse/executors/base"; import { getCodexUsage } from "@omniroute/open-sse/services/usage/codex.ts"; import { getSettings } from "@/lib/db/settings"; import { getProviderConnections, updateProviderConnection } from "@/lib/db/providers"; import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation"; -import { refreshAndUpdateCredentials } from "@/lib/usage/providerLimits"; +import { refreshAndUpdateCredentialsWithResolver } from "@/lib/usage/providerLimits/credentialRefresh"; import { getCircuitBreaker } from "@/shared/utils/circuitBreaker"; import { QUOTA_AUTOPING_FAILURE_COOLDOWN_MS, @@ -63,8 +62,11 @@ export interface QuotaAutoPingDeps { refreshAndUpdateCredentials: ( connection: QuotaAutoPingConnection ) => Promise<{ connection: QuotaAutoPingConnection }>; - getCodexUsage: (accessToken?: string, providerSpecificData?: JsonRecord) => Promise; - getExecutor: (provider: string) => Promise; + getCodexUsage: ( + accessToken?: string, + providerSpecificData?: JsonRecord + ) => Promise; + getExecutor: (provider: "codex") => Promise; canExecuteProvider: (provider: string) => boolean; isConnectionUnavailableToAuxiliaryActivity: (connectionId: string) => Promise; } @@ -79,15 +81,33 @@ export function createQuotaAutoPingState(): QuotaAutoPingState { return { running: false, resetCache: {}, failureCache: {} }; } +let codexExecutorPromise: Promise | null = null; + +async function loadQuotaAutoPingExecutor(provider: string): Promise { + if (provider !== "codex") { + throw new Error(`Quota auto-ping does not support provider "${provider}"`); + } + + try { + codexExecutorPromise ??= import("@omniroute/open-sse/executors/codex.ts").then( + ({ CodexExecutor }) => new CodexExecutor() + ); + return await codexExecutorPromise; + } catch (error) { + codexExecutorPromise = null; + throw error; + } +} + export function createDefaultQuotaAutoPingDeps(): QuotaAutoPingDeps { return { getSettings, getProviderConnections, updateProviderConnection, refreshAndUpdateCredentials: async (connection) => - refreshAndUpdateCredentials(connection as never), + refreshAndUpdateCredentialsWithResolver(connection, loadQuotaAutoPingExecutor), getCodexUsage, - getExecutor, + getExecutor: loadQuotaAutoPingExecutor, canExecuteProvider: (provider) => getCircuitBreaker(provider).canExecute(), isConnectionUnavailableToAuxiliaryActivity, }; diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 79af5cecad..fdea9ab477 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -18,13 +18,10 @@ import { clearRecoveredProviderState } from "@/sse/services/auth"; import { getMachineId } from "@/shared/utils/machine"; import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; import { mergeProviderLimitsCacheEntry, toProviderLimitsCacheEntry } from "./providerLimitsCache"; -import { getExecutor } from "@omniroute/open-sse/executors/index.ts"; +import { getCredentialRefreshExecutor } from "@omniroute/open-sse/executors/credential.ts"; import { getUsageForProvider } from "@omniroute/open-sse/services/usage.ts"; import { cooldownUntilMs } from "@omniroute/open-sse/services/accountFallback.ts"; -import { - rotationGroupFor, - serializeRefresh, -} from "@omniroute/open-sse/services/refreshSerializer.ts"; +import { rotationGroupFor } from "@omniroute/open-sse/services/refreshSerializer.ts"; import { extractCodeAssistOnboardTierId, extractCodeAssistSubscriptionTier, @@ -42,29 +39,15 @@ import { sanitizeUsageQuotasForProvider, } from "./providerLimits/quotaNormalize"; import { syncInChunksWithSpacing } from "./providerLimits/chunkedSpacingSync"; +import { + refreshAndUpdateCredentialsWithResolver, + type CredentialRefreshOptions, + type ProviderConnectionLike, +} from "./providerLimits/credentialRefresh"; +export { shouldAttemptRotatingRefresh } from "./providerLimits/credentialRefresh"; type JsonRecord = Record; type SyncSource = "manual" | "scheduled"; -interface ProviderConnectionLike { - id: string; - provider: string; - authType?: string; - accessToken?: string; - refreshToken?: string; - expiresAt?: string; - tokenExpiresAt?: string; - providerSpecificData?: JsonRecord; - testStatus?: string; - isActive?: boolean; - lastError?: string | null; - lastErrorAt?: string | null; - lastErrorType?: string | null; - lastErrorSource?: string | null; - errorCode?: string | number | null; - rateLimitedUntil?: string | null; - backoffLevel?: number; -} - const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([ "glm", "glm-cn", @@ -218,122 +201,15 @@ async function syncToCloudIfEnabled() { } } -/** - * Whether the quota path may refresh this provider's token. Exported for testing. - * - * Rotating-refresh providers (Codex/OpenAI share one Auth0 client_id, etc.) mint a - * single-use refresh_token on every refresh. The BULK quota-sync path runs many - * connections concurrently; refreshing sibling accounts in parallel makes Auth0 - * revoke the whole token family (openai/codex#9648) and kills every account but - * the last (#3019). So the bulk path never refreshes rotating providers - * (`allowRotatingRefresh` falsy). The on-demand, per-connection path opts in and - * is made safe by `serializeRefresh` (one token mint at a time per rotation group, - * so even N concurrent per-account requests can never refresh siblings in - * parallel). Non-rotating providers are always eligible. - */ -export function shouldAttemptRotatingRefresh( - provider: string, - allowRotatingRefresh: boolean | undefined -): boolean { - if (rotationGroupFor(provider) === null) return true; - return allowRotatingRefresh === true; -} - export async function refreshAndUpdateCredentials( connection: ProviderConnectionLike, - opts: { allowRotatingRefresh?: boolean; force?: boolean } = {} + opts: CredentialRefreshOptions = {} ) { - if (!shouldAttemptRotatingRefresh(connection.provider, opts.allowRotatingRefresh)) { - return { connection, refreshed: false }; - } - const executor = await getExecutor(connection.provider); - const credentials = { - connectionId: connection.id, - accessToken: connection.accessToken, - refreshToken: connection.refreshToken, - expiresAt: connection.tokenExpiresAt || connection.expiresAt || null, - providerSpecificData: connection.providerSpecificData, - copilotToken: connection.providerSpecificData?.copilotToken, - copilotTokenExpiresAt: connection.providerSpecificData?.copilotTokenExpiresAt, - }; - - // `force` is used ONLY on the reactive 401 recovery path (a usage fetch came - // back unauthorized) — it bypasses the proactive `needsRefresh` heuristic so - // imported accounts (expiresAt=null, where needsRefresh is always false) can - // still re-mint. The mint stays serialized per rotation group; this never - // refreshes proactively from the bulk path (#3019 guard above is unchanged). - if (!opts.force && !executor.needsRefresh(credentials)) { - return { connection, refreshed: false }; - } - - // Serialize the actual token mint per rotation group so two sibling accounts - // never hit Auth0 concurrently (passthrough for non-rotating providers). - const refreshResult = (await serializeRefresh(connection.provider, () => - executor.refreshCredentials(credentials, console) - )) as - | (JsonRecord & { - accessToken?: string; - refreshToken?: string; - expiresIn?: number; - expiresAt?: string; - copilotToken?: string; - copilotTokenExpiresAt?: string; - }) - | null; - - if (!refreshResult) { - // Refresh failed but we still have an accessToken — fall back to the - // existing token for ANY OAuth provider (graceful degradation) instead of - // hard-failing. Previously this was qualified to `provider === "github"`, - // which left every other provider stuck on a transient refresh failure even - // when a usable access token was still on hand. - if (connection.accessToken) { - return { connection, refreshed: false }; - } - throw withStatus( - new Error("Failed to refresh credentials. Please re-authorize the connection."), - 401 - ); - } - - const updateData: JsonRecord = { - updatedAt: new Date().toISOString(), - }; - - if (refreshResult.accessToken) { - updateData.accessToken = refreshResult.accessToken; - } - if (refreshResult.refreshToken) { - updateData.refreshToken = refreshResult.refreshToken; - } - if (refreshResult.expiresIn) { - const expiresAt = new Date(Date.now() + refreshResult.expiresIn * 1000).toISOString(); - updateData.expiresAt = expiresAt; - updateData.tokenExpiresAt = expiresAt; - } else if (refreshResult.expiresAt) { - updateData.expiresAt = refreshResult.expiresAt; - updateData.tokenExpiresAt = refreshResult.expiresAt; - } - if (refreshResult.copilotToken || refreshResult.copilotTokenExpiresAt) { - updateData.providerSpecificData = { - ...(connection.providerSpecificData || {}), - copilotToken: refreshResult.copilotToken, - copilotTokenExpiresAt: refreshResult.copilotTokenExpiresAt, - }; - } - - await updateProviderConnection(connection.id, updateData); - - return { - connection: { - ...connection, - ...updateData, - providerSpecificData: - (updateData.providerSpecificData as JsonRecord | undefined) || - connection.providerSpecificData, - }, - refreshed: true, - }; + return refreshAndUpdateCredentialsWithResolver( + connection, + getCredentialRefreshExecutor, + opts + ); } function isUsageAuthError(message: unknown): boolean { diff --git a/src/lib/usage/providerLimits/credentialRefresh.ts b/src/lib/usage/providerLimits/credentialRefresh.ts new file mode 100644 index 0000000000..fd779a5ff1 --- /dev/null +++ b/src/lib/usage/providerLimits/credentialRefresh.ts @@ -0,0 +1,150 @@ +import { updateProviderConnection } from "@/lib/db/providers"; +import type { BaseExecutor } from "@omniroute/open-sse/executors/base"; +import { + rotationGroupFor, + serializeRefresh, +} from "@omniroute/open-sse/services/refreshSerializer.ts"; + +type JsonRecord = Record; + +type CredentialRefreshResult = JsonRecord & { + accessToken?: string; + refreshToken?: string; + expiresIn?: number; + expiresAt?: string; + copilotToken?: string; + copilotTokenExpiresAt?: string; +}; + +export interface ProviderConnectionLike { + id: string; + provider: string; + authType?: string; + accessToken?: string; + refreshToken?: string; + expiresAt?: string | null; + tokenExpiresAt?: string | null; + providerSpecificData?: JsonRecord; + testStatus?: string; + isActive?: boolean; + lastError?: string | null; + lastErrorAt?: string | null; + lastErrorType?: string | null; + lastErrorSource?: string | null; + errorCode?: string | number | null; + rateLimitedUntil?: string | null; + backoffLevel?: number; +} + +export interface CredentialRefreshOptions { + allowRotatingRefresh?: boolean; + force?: boolean; +} + +export type CredentialExecutorResolver = (provider: string) => Promise; + +function withStatus(error: Error, status: number): Error & { status: number } { + return Object.assign(error, { status }); +} + +/** + * Whether the quota path may refresh this provider's token. + * + * Rotating-refresh providers mint a single-use refresh token on every refresh, + * so bulk quota sync must not refresh siblings concurrently. The on-demand path + * explicitly opts in and remains serialized per rotation group. + */ +export function shouldAttemptRotatingRefresh( + provider: string, + allowRotatingRefresh: boolean | undefined +): boolean { + if (rotationGroupFor(provider) === null) return true; + return allowRotatingRefresh === true; +} + +function buildCredentialUpdateData( + connection: ProviderConnectionLike, + refreshResult: CredentialRefreshResult +): JsonRecord { + const updateData: JsonRecord = { + updatedAt: new Date().toISOString(), + }; + + if (refreshResult.accessToken) { + updateData.accessToken = refreshResult.accessToken; + } + if (refreshResult.refreshToken) { + updateData.refreshToken = refreshResult.refreshToken; + } + if (refreshResult.expiresIn) { + const expiresAt = new Date(Date.now() + refreshResult.expiresIn * 1000).toISOString(); + updateData.expiresAt = expiresAt; + updateData.tokenExpiresAt = expiresAt; + } else if (refreshResult.expiresAt) { + updateData.expiresAt = refreshResult.expiresAt; + updateData.tokenExpiresAt = refreshResult.expiresAt; + } + if (refreshResult.copilotToken || refreshResult.copilotTokenExpiresAt) { + updateData.providerSpecificData = { + ...(connection.providerSpecificData || {}), + copilotToken: refreshResult.copilotToken, + copilotTokenExpiresAt: refreshResult.copilotTokenExpiresAt, + }; + } + + return updateData; +} + +/** Refresh and persist credentials using a caller-supplied executor resolver. */ +export async function refreshAndUpdateCredentialsWithResolver( + connection: ProviderConnectionLike, + resolveExecutor: CredentialExecutorResolver, + opts: CredentialRefreshOptions = {} +) { + if (!shouldAttemptRotatingRefresh(connection.provider, opts.allowRotatingRefresh)) { + return { connection, refreshed: false }; + } + const executor = await resolveExecutor(connection.provider); + const credentials = { + connectionId: connection.id, + accessToken: connection.accessToken, + refreshToken: connection.refreshToken, + expiresAt: connection.tokenExpiresAt || connection.expiresAt || null, + providerSpecificData: connection.providerSpecificData, + copilotToken: connection.providerSpecificData?.copilotToken, + copilotTokenExpiresAt: connection.providerSpecificData?.copilotTokenExpiresAt, + }; + + if (!opts.force && !executor.needsRefresh(credentials)) { + return { connection, refreshed: false }; + } + + const refreshResult = (await serializeRefresh(connection.provider, () => + executor.refreshCredentials(credentials, console) + )) as CredentialRefreshResult | null; + + if (!refreshResult) { + if (connection.accessToken) { + return { connection, refreshed: false }; + } + throw withStatus( + new Error("Failed to refresh credentials. Please re-authorize the connection."), + 401 + ); + } + + const updateData = buildCredentialUpdateData(connection, refreshResult); + + await updateProviderConnection(connection.id, updateData); + + return { + connection: { + ...connection, + ...updateData, + providerSpecificData: + (updateData.providerSpecificData as JsonRecord | undefined) || + connection.providerSpecificData, + }, + refreshed: true, + }; +} diff --git a/tests/unit/codex-quota-sync-no-proactive-refresh.test.ts b/tests/unit/codex-quota-sync-no-proactive-refresh.test.ts index 9a2e7496a5..76dae8ebf6 100644 --- a/tests/unit/codex-quota-sync-no-proactive-refresh.test.ts +++ b/tests/unit/codex-quota-sync-no-proactive-refresh.test.ts @@ -27,11 +27,12 @@ import path from "node:path"; process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codex-quota-")); -const { getExecutor } = await import("../../open-sse/executors/index.ts"); +const { getCredentialRefreshExecutor } = + await import("../../open-sse/executors/credential.ts"); const { refreshAndUpdateCredentials } = await import("../../src/lib/usage/providerLimits.ts"); test("codex: quota-sync must NOT proactively rotate the refresh_token (Auth0 family-revocation cascade guard)", async () => { - const exec = await getExecutor("codex"); + const exec = await getCredentialRefreshExecutor("codex"); const origNeeds = exec.needsRefresh; const origRefresh = exec.refreshCredentials; let refreshCalls = 0; @@ -68,7 +69,7 @@ test("codex: quota-sync must NOT proactively rotate the refresh_token (Auth0 fam }); test("non-rotating OAuth provider is still refreshed proactively from quota-sync (gate is not over-broad)", async () => { - const exec = await getExecutor("cursor"); + const exec = await getCredentialRefreshExecutor("cursor"); const origNeeds = exec.needsRefresh; const origRefresh = exec.refreshCredentials; let refreshCalls = 0; diff --git a/tests/unit/executor-registry.test.ts b/tests/unit/executor-registry.test.ts index efb4f8d20a..6c381acff1 100644 --- a/tests/unit/executor-registry.test.ts +++ b/tests/unit/executor-registry.test.ts @@ -15,6 +15,7 @@ const { registerExecutor, getRegisteredExecutor, hasRegisteredExecutor, listExec await import("../../open-sse/executors/registry.ts"); const { getExecutor, hasSpecializedExecutor, BaseExecutor, DefaultExecutor } = await import("../../open-sse/executors/index.ts"); +const { getDefaultExecutor } = await import("../../open-sse/executors/defaultResolver.ts"); test.after(() => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); @@ -54,3 +55,9 @@ test("registry lookup is exact — Object.prototype names are not executors", as assert.ok((await getExecutor(name)) instanceof DefaultExecutor, name); } }); + +test("the registry and leaf resolver share fallback executor instances", async () => { + const provider = "default-resolver-test-provider"; + assert.equal(await getExecutor(provider), getDefaultExecutor(provider)); + assert.equal(getDefaultExecutor(provider), getDefaultExecutor(provider)); +}); diff --git a/tests/unit/instrumentation-import-graph-12074.test.ts b/tests/unit/instrumentation-import-graph-12074.test.ts new file mode 100644 index 0000000000..7c092dcf93 --- /dev/null +++ b/tests/unit/instrumentation-import-graph-12074.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +const instrumentationPath = path.join(process.cwd(), "src/instrumentation-node.ts"); +const quotaAutoPingPath = path.join(process.cwd(), "src/lib/services/quotaAutoPing.ts"); +const credentialRefreshPath = path.join( + process.cwd(), + "src/lib/usage/providerLimits/credentialRefresh.ts" +); +const providerLimitsPath = path.join(process.cwd(), "src/lib/usage/providerLimits.ts"); +const credentialExecutorPath = path.join(process.cwd(), "open-sse/executors/credential.ts"); +const executorDirectory = path.join(process.cwd(), "open-sse/executors"); +const anthropicValidationPath = path.join( + process.cwd(), + "src/lib/providers/validation/anthropicFormat.ts" +); +const defaultExecutorResolverPath = path.join( + process.cwd(), + "open-sse/executors/defaultResolver.ts" +); + +test("node instrumentation loads the proxy patch leaf before quota registration", () => { + const source = fs.readFileSync(instrumentationPath, "utf8"); + const proxyPatchImport = 'await import("@omniroute/open-sse/utils/proxyFetch.ts")'; + const proxyPatchIndex = source.indexOf(proxyPatchImport); + const quotaRegistrationIndex = source.indexOf("await registerQuotaFetchers()"); + + assert.ok(proxyPatchIndex >= 0, "startup must load the proxyFetch side-effect leaf"); + assert.ok(quotaRegistrationIndex > proxyPatchIndex, "proxy patch must run before quota setup"); + assert.doesNotMatch(source, /import\("@omniroute\/open-sse\/index\.ts"\)/); +}); + +test("quota auto-ping lazily loads only the Codex executor", () => { + const source = fs.readFileSync(quotaAutoPingPath, "utf8"); + const credentialRefreshSource = fs.readFileSync(credentialRefreshPath, "utf8"); + + assert.doesNotMatch(source, /open-sse\/executors\/index(?:\.ts)?/); + assert.doesNotMatch(source, /@\/lib\/usage\/providerLimits["']/); + assert.match(source, /import\("@omniroute\/open-sse\/executors\/codex\.ts"\)/); + assert.match(source, /@\/lib\/usage\/providerLimits\/credentialRefresh/); + assert.match(source, /getExecutor: loadQuotaAutoPingExecutor/); + assert.doesNotMatch(credentialRefreshSource, /open-sse\/executors\/index(?:\.ts)?/); +}); + +test("startup still registers bespoke, batch, and generic quota fetchers", async () => { + const [{ registerQuotaFetchers }, { getQuotaFetcher }] = await Promise.all([ + import("../../src/instrumentation-node.ts"), + import("../../open-sse/services/quotaPreflight.ts"), + ]); + + await registerQuotaFetchers(); + + for (const provider of [ + "agentrouter", + "codex", + "bailian-coding-plan", + "qwen-cloud-token-plan", + "crof", + "deepseek", + "openrouter", + "opencode-go", + "grok-web", + "antigravity", + ]) { + assert.equal(typeof getQuotaFetcher(provider), "function", `${provider} quota fetcher missing`); + } +}); + +test("provider-limit startup uses the refresh-only executor resolver", () => { + const providerLimitsSource = fs.readFileSync(providerLimitsPath, "utf8"); + const credentialExecutorSource = fs.readFileSync(credentialExecutorPath, "utf8"); + + assert.doesNotMatch(providerLimitsSource, /open-sse\/executors\/index(?:\.ts)?/); + assert.match(providerLimitsSource, /open-sse\/executors\/credential\.ts/); + assert.doesNotMatch(credentialExecutorSource, /\.\/index(?:\.ts)?/); + assert.match(credentialExecutorSource, /export async function getCredentialRefreshExecutor/); +}); + +test("credential resolver covers every executor with custom refresh behavior", () => { + const credentialExecutorSource = fs.readFileSync(credentialExecutorPath, "utf8"); + const refreshOverrideFiles = fs + .readdirSync(executorDirectory) + .filter((file) => file.endsWith(".ts") && !["base.ts", "default.ts"].includes(file)) + .filter((file) => { + const source = fs.readFileSync(path.join(executorDirectory, file), "utf8"); + return /^\s*(?:async\s+)?(?:needsRefresh|refreshCredentials)\s*\(/m.test(source); + }); + + for (const file of refreshOverrideFiles) { + assert.ok( + credentialExecutorSource.includes(`import("./${file}")`), + `${file} must be registered in the refresh-only resolver` + ); + } +}); + +test("Claude OAuth validation resolves the default executor without the chat registry", () => { + const validationSource = fs.readFileSync(anthropicValidationPath, "utf8"); + const resolverSource = fs.readFileSync(defaultExecutorResolverPath, "utf8"); + + assert.doesNotMatch(validationSource, /open-sse\/executors\/index(?:\.ts)?/); + assert.match(validationSource, /open-sse\/executors\/defaultResolver\.ts/); + assert.doesNotMatch(resolverSource, /\.\/index(?:\.ts)?/); + assert.match(resolverSource, /export function getDefaultExecutor/); +}); diff --git a/tests/unit/provider-limits-accesstoken-fallback-on-refresh-failure.test.ts b/tests/unit/provider-limits-accesstoken-fallback-on-refresh-failure.test.ts index 89f23b7983..1967f4bbf1 100644 --- a/tests/unit/provider-limits-accesstoken-fallback-on-refresh-failure.test.ts +++ b/tests/unit/provider-limits-accesstoken-fallback-on-refresh-failure.test.ts @@ -22,7 +22,8 @@ process.env.DATA_DIR = fs.mkdtempSync( path.join(os.tmpdir(), "omniroute-accesstoken-fallback-") ); -const { getExecutor } = await import("../../open-sse/executors/index.ts"); +const { getCredentialRefreshExecutor } = + await import("../../open-sse/executors/credential.ts"); const { refreshAndUpdateCredentials } = await import("../../src/lib/usage/providerLimits.ts"); // `gemini` is a non-rotating (no rotation lock group), non-github OAuth provider, @@ -39,7 +40,7 @@ function geminiConnection() { } test("falls back to the existing accessToken for a non-github provider when refreshCredentials returns null", async () => { - const exec = await getExecutor("gemini"); + const exec = await getCredentialRefreshExecutor("gemini"); const origNeeds = exec.needsRefresh; const origRefresh = exec.refreshCredentials; exec.needsRefresh = () => true; // force the refresh attempt @@ -65,7 +66,7 @@ test("falls back to the existing accessToken for a non-github provider when refr }); test("still throws when refresh fails AND there is no accessToken to fall back on", async () => { - const exec = await getExecutor("gemini"); + const exec = await getCredentialRefreshExecutor("gemini"); const origNeeds = exec.needsRefresh; const origRefresh = exec.refreshCredentials; exec.needsRefresh = () => true; diff --git a/tests/unit/provider-limits-force-refresh-imported.test.ts b/tests/unit/provider-limits-force-refresh-imported.test.ts index 041b01064f..30ea0bd52d 100644 --- a/tests/unit/provider-limits-force-refresh-imported.test.ts +++ b/tests/unit/provider-limits-force-refresh-imported.test.ts @@ -18,7 +18,8 @@ import path from "node:path"; process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-force-refresh-imported-")); -const { getExecutor } = await import("../../open-sse/executors/index.ts"); +const { getCredentialRefreshExecutor } = + await import("../../open-sse/executors/credential.ts"); const { refreshAndUpdateCredentials } = await import("../../src/lib/usage/providerLimits.ts"); function importedCodexConnection() { @@ -33,7 +34,7 @@ function importedCodexConnection() { } test("force re-mints an imported rotating account that needsRefresh would skip (#3019 reactive)", async () => { - const exec = await getExecutor("codex"); + const exec = await getCredentialRefreshExecutor("codex"); const origNeeds = exec.needsRefresh; const origRefresh = exec.refreshCredentials; let refreshCalls = 0; @@ -65,7 +66,7 @@ test("force re-mints an imported rotating account that needsRefresh would skip ( }); test("force does NOT override the bulk #3019 guard (no allowRotatingRefresh → no mint)", async () => { - const exec = await getExecutor("codex"); + const exec = await getCredentialRefreshExecutor("codex"); const origNeeds = exec.needsRefresh; const origRefresh = exec.refreshCredentials; let refreshCalls = 0; diff --git a/tests/unit/termux-android-playwright-static-import-7265.test.ts b/tests/unit/termux-android-playwright-static-import-7265.test.ts index da6cc858e8..8cd1253cf0 100644 --- a/tests/unit/termux-android-playwright-static-import-7265.test.ts +++ b/tests/unit/termux-android-playwright-static-import-7265.test.ts @@ -9,8 +9,8 @@ import { dirname, join } from "node:path"; // top-level IIFE at *require time* — merely importing the `playwright` package crashes, // no browser needs to be launched. `claudeTurnstileSolver.ts` used to `import { chromium } // from "playwright"` statically, and that module is unconditionally reachable from the -// Next.js instrumentation hook on every boot via open-sse/executors/index.ts, so any -// unsupported platform crashed the whole server at startup regardless of configured provider. +// Next.js instrumentation hook used to reach it on every boot via open-sse/executors/index.ts, +// so any unsupported platform crashed the whole server regardless of configured provider. const HERE = dirname(fileURLToPath(import.meta.url)); const SOLVER = join(HERE, "../../open-sse/services/claudeTurnstileSolver.ts"); @@ -28,10 +28,9 @@ test("importing the real executor chain does not throw on an unsupported process Object.defineProperty(process, "platform", { value: "android", configurable: true }); try { - // This is the exact reachability chain from the Next.js instrumentation hook: - // instrumentation-node.ts -> open-sse/index.ts -> executors/index.ts -> claude-web*.ts - // -> claudeTurnstileSolver.ts. Before the fix, this threw - // "Unsupported platform: android" purely from the static playwright import. + // Keep the executor-registry import safe for direct runtime consumers. Before the + // original fix, this threw "Unsupported platform: android" purely from the static + // playwright import; instrumentation no longer imports this graph at boot (#12074). await import("../../open-sse/executors/index.ts"); } finally { Object.defineProperty(process, "platform", originalDescriptor);