mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-13 18:32:12 +03:00
[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 <backryun@daonlab.local>
This commit is contained in:
54
open-sse/executors/credential.ts
Normal file
54
open-sse/executors/credential.ts
Normal file
@@ -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<BaseExecutor>;
|
||||
|
||||
const specializedCredentialExecutors: Record<string, CredentialExecutorLoader> = {
|
||||
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<string, Promise<BaseExecutor>>();
|
||||
|
||||
/** Resolve only executors with credential-refresh behavior, without loading the chat registry. */
|
||||
export async function getCredentialRefreshExecutor(provider: string): Promise<BaseExecutor> {
|
||||
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;
|
||||
}
|
||||
13
open-sse/executors/defaultResolver.ts
Normal file
13
open-sse/executors/defaultResolver.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { DefaultExecutor } from "./default.ts";
|
||||
|
||||
const defaultExecutorCache = new Map<string, DefaultExecutor>();
|
||||
|
||||
/** 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;
|
||||
}
|
||||
@@ -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<BaseExecutor> {
|
||||
(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 {
|
||||
|
||||
@@ -319,7 +319,7 @@ export async function registerNodejs(): Promise<void> {
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<JsonRecord>;
|
||||
getExecutor: (provider: string) => Promise<BaseExecutor>;
|
||||
getCodexUsage: (
|
||||
accessToken?: string,
|
||||
providerSpecificData?: JsonRecord
|
||||
) => Promise<JsonRecord>;
|
||||
getExecutor: (provider: "codex") => Promise<BaseExecutor>;
|
||||
canExecuteProvider: (provider: string) => boolean;
|
||||
isConnectionUnavailableToAuxiliaryActivity: (connectionId: string) => Promise<boolean>;
|
||||
}
|
||||
@@ -79,15 +81,33 @@ export function createQuotaAutoPingState(): QuotaAutoPingState {
|
||||
return { running: false, resetCache: {}, failureCache: {} };
|
||||
}
|
||||
|
||||
let codexExecutorPromise: Promise<BaseExecutor> | null = null;
|
||||
|
||||
async function loadQuotaAutoPingExecutor(provider: string): Promise<BaseExecutor> {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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 {
|
||||
|
||||
150
src/lib/usage/providerLimits/credentialRefresh.ts
Normal file
150
src/lib/usage/providerLimits/credentialRefresh.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
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<BaseExecutor>;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
107
tests/unit/instrumentation-import-graph-12074.test.ts
Normal file
107
tests/unit/instrumentation-import-graph-12074.test.ts
Normal file
@@ -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/);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user