mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 00:02:20 +03:00
* feat(media): Adobe Firefly image + video generation provider
Add unofficial adobe-firefly media provider with full OpenAI-compatible
image and video generation: Nano Banana / GPT Image families, Sora 2,
Veo 3.1 (standard/fast/reference), and Kling 3.0.
Supports browser session cookies (auto IMS token exchange) or direct
IMS access tokens, async submit-and-poll against Firefly 3P endpoints,
aspect-ratio and resolution controls, and multi-account web-session UX.
Chat completions are intentionally rejected (media-only surface).
Includes unit coverage for registry wiring, payload builders, auth
resolution, and mocked generate happy-paths.
* fix(adobe-firefly): clio auth, discovery fallback, credits balance
Root-cause 401 invalid token against live firefly.adobe.com captures:
generate/discovery use x-api-key + IMS client_id clio-playground-web
(not projectx_webapp). Align headers/origin, dual cookie to IMS exchange
(clio first, Express fallback), BKS poll rewrite for /jobs/result.
Models: parse POST /v2/models/discovery + static fallback catalog from
adobe/get_models.txt; expand image/video registries.
Limits: GET firefly.adobe.io/v1/credits/balance (SunbreakWebUI1) with
total/remaining + free/plan detail quotas. Clarify cookie vs JWT UX.
Unit tests: 27/27 pass.
* fix(adobe-firefly): reject guest tokens from page-only cookies
Live repro with firefly.adobe.com Cookie export: IMS check with
guest_allowed=true returns account_type=guest (no AdobeID). That token
fails generate (401 invalid token) and credits/balance (403
ErrMismatchOauthToken). guest_allowed=false needs adobelogin.com IMS
session cookies which are not present in a page-only Cookie paste.
- Detect/reject guest JWTs; clear error tells user to paste Bearer JWT
- Prefer user JWT from HAR/mixed paste; improve credential extraction
- Update web-cookie + credential UX to recommend Authorization Bearer
Unit tests 29/29.
* fix(adobe-firefly): production auth, Limits, and 408 load handling
Live validation against firefly.adobe.com + packaged VibeProxy:
Auth / credentials
- Prefer IMS user JWT (Bearer from firefly-3p); reject guest tokens from
page-only cookies with an actionable error
- Extract JWT from Bearer, access_token=, IMS sessionStorage tokenValue,
and mixed HAR pastes; prefer non-guest tokens
- Strip JWT from Cookie header (undici Headers.append crash on mixed paste)
- Keep sherlockToken → x-arp-session-id + sanitized Cookie for generate
Limits
- credits/balance → Record quotas (firefly_total / free / plan) so
providerLimits caches them (arrays were ignored)
- Allowlist adobe-firefly + firefly in USAGE_SUPPORTED + APIKEY limits
- Live: 10000 plan credits parsed end-to-end after refresh
Generate
- Browser-shaped gpt-image body (size auto, no extra top-level size)
- Exponential 408 "system under load" retries (8 attempts) with clear
client message that 408 is Adobe capacity, not invalid token
- Live: generate returns proper 408 under load; balance/models stay 200
Tests: adobe-firefly unit suite 33/33 pass.
* fix(adobe-firefly): match live capture headers; add gpt-image-2
- Do not send firefly.adobe.com Cookie to firefly-3p (wrong-origin; soft 408)
- Lift sherlockToken only into x-arp-session-id
- Poll headers match status_check.txt (Bearer + accept, no x-api-key)
- Catalog gpt-image-2 alias → upstream modelVersion "2" (GPT Image 2)
- Shorter 408 retry budget so clients fail fast with clear message
- Unit suite 34/34
* fix(adobe-firefly): always send x-arp-session-id on generate (fixes 408)
Root cause of Bearer JWT → HTTP 408 colligo "system under load":
submit only set x-arp-session-id when sherlockToken was present in a
cookie paste. JWT-only credentials never sent the header, and Adobe
soft-blocks those requests with instant 408 (x-colligo-timeout:0.0).
A/B against a real user IMS token:
- det nonce + synthetic ARP → 200
- random nonce + synthetic ARP → 200
- det nonce without ARP → 408
Match adobe2api / GPT2Image-Pro:
- buildAdobeSubmitNonce = sha256(user_id + prompt[:256])
- buildAdobeArpSessionId = base64({sid, ftr}) synthetic session
- buildAdobeSubmitHeaders always sets both headers
Live adobeFireflyGenerateImage end-to-end: submit + poll → S3 presigned URL.
* fix(adobe-firefly): drop literal cred fallbacks + type-clean tests
Addresses pre-merge review feedback on #8006:
- Removes the `|| "literal"` fallback after resolvePublicCred() in
adobeFireflyApiKey()/adobeFireflyExpressClientId()/adobeFireflyBalanceApiKey()
(open-sse/services/adobeFireflyClient.ts). resolvePublicCred() already
always returns the decoded embedded default, so the literal fallback
was dead code that reproduced the exact env-or-literal anti-pattern
docs/security/PUBLIC_CREDS.md documents as BAD (Hard Rule #11).
- Replaces the 11 `@typescript-eslint/no-explicit-any` casts in
tests/unit/adobe-firefly.test.ts with concrete types
(Record<string, unknown>, Headers, Error-narrowing on the
assert.rejects predicate), matching the pattern already used
elsewhere in this suite. `no-explicit-any` is a hard ESLint error
under tests/ in this repo.
- Freezes file-size baseline entries for the new
open-sse/services/adobeFireflyClient.ts (1958 LOC, new-file cap 800,
mirrors the qoderCli.ts precedent for a legitimately large new
provider client), open-sse/config/imageRegistry.ts (800->821, new
adobe-firefly registry entry) and the +3 LOC growth in
src/lib/usage/providerLimits.ts (1000->1003).
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: artickc <artickc@users.noreply.github.com>
1006 lines
35 KiB
TypeScript
1006 lines
35 KiB
TypeScript
import {
|
|
getAllProviderLimitsCache,
|
|
getProviderConnectionById,
|
|
getProviderConnections,
|
|
getProviderLimitsCache,
|
|
getSettings,
|
|
resolveProxyForConnection,
|
|
setProviderLimitsCache,
|
|
setProviderLimitsCacheBatch,
|
|
updateProviderConnection,
|
|
updateSettings,
|
|
type ProviderLimitsCacheEntry,
|
|
} from "@/lib/localDb";
|
|
import { syncToCloud } from "@/lib/cloudSync";
|
|
import { setQuotaCache } from "@/domain/quotaCache";
|
|
import { buildClaudeExtraUsageConnectionUpdate } from "@/lib/providers/claudeExtraUsage";
|
|
import { clearRecoveredProviderState } from "@/sse/services/auth";
|
|
import { getMachineId } from "@/shared/utils/machine";
|
|
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
|
|
import { getExecutor } from "@omniroute/open-sse/executors/index.ts";
|
|
import { getUsageForProvider } from "@omniroute/open-sse/services/usage.ts";
|
|
import {
|
|
rotationGroupFor,
|
|
serializeRefresh,
|
|
} from "@omniroute/open-sse/services/refreshSerializer.ts";
|
|
import {
|
|
extractCodeAssistOnboardTierId,
|
|
extractCodeAssistSubscriptionTier,
|
|
} from "@omniroute/open-sse/services/codeAssistSubscription.ts";
|
|
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
|
import { onUsageRecorded } from "./usageEvents";
|
|
import {
|
|
isRecord,
|
|
isUsageQuotaKeyAllowed,
|
|
normalizeUsageQuotasForProvider,
|
|
sanitizeUsageQuotasForProvider,
|
|
} from "./providerLimits/quotaNormalize";
|
|
import { syncInChunksWithSpacing } from "./providerLimits/chunkedSpacingSync";
|
|
|
|
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",
|
|
"zai",
|
|
"glmt",
|
|
"opencode-go",
|
|
"ollama-cloud",
|
|
"minimax",
|
|
"minimax-cn",
|
|
"crof",
|
|
"nanogpt",
|
|
"deepseek",
|
|
"xiaomi-mimo",
|
|
"vertex",
|
|
"vertex-partner",
|
|
"kimi-coding-apikey",
|
|
"kiro",
|
|
// Qoder connections are PAT-based (authType "apikey"); the usage fetcher
|
|
// exchanges the PAT for a job token and reads openapi.qoder.sh/user/status.
|
|
"qoder",
|
|
"promptql", // PromptQL playground JWT → getCreditSummary USD credits
|
|
"pql",
|
|
// Adobe Firefly: web-cookie / JWT stored as apikey → credits/balance
|
|
"adobe-firefly",
|
|
"firefly",
|
|
// HyperAgent session cookie → billing/usage creditBlocks
|
|
"hyperagent",
|
|
"ha",
|
|
]);
|
|
const DEFAULT_PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES = 70;
|
|
const PROVIDER_LIMITS_AUTO_SYNC_SETTING_KEY = "provider_limits_auto_sync_last_run";
|
|
const DEFAULT_PROVIDER_LIMITS_POST_USAGE_REFRESH_DELAY_MS = 5_000;
|
|
const pendingPostUsageRefreshes = new Set<string>();
|
|
|
|
function toProviderLimitsCacheEntry(
|
|
usage: JsonRecord,
|
|
source: SyncSource,
|
|
fetchedAt = new Date().toISOString()
|
|
): ProviderLimitsCacheEntry {
|
|
const value = Number(usage.bankedResetCredits);
|
|
return {
|
|
quotas: isRecord(usage.quotas) ? usage.quotas : null,
|
|
plan: usage.plan ?? null,
|
|
message: typeof usage.message === "string" ? usage.message : null,
|
|
fetchedAt,
|
|
source,
|
|
bankedResetCredits: Number.isFinite(value) ? value : undefined,
|
|
};
|
|
}
|
|
|
|
function getProviderLimitsPostUsageRefreshDelayMs(): number {
|
|
const raw = Number(process.env.PROVIDER_LIMITS_POST_USAGE_REFRESH_DELAY_MS ?? "");
|
|
return Number.isFinite(raw) && raw >= 0
|
|
? raw
|
|
: DEFAULT_PROVIDER_LIMITS_POST_USAGE_REFRESH_DELAY_MS;
|
|
}
|
|
|
|
function scheduleProviderLimitsPostUsageRefresh(connectionId: string): void {
|
|
if (!connectionId || pendingPostUsageRefreshes.has(connectionId)) return;
|
|
|
|
pendingPostUsageRefreshes.add(connectionId);
|
|
const timer = setTimeout(() => {
|
|
pendingPostUsageRefreshes.delete(connectionId);
|
|
void fetchAndPersistProviderLimits(connectionId, "scheduled", {
|
|
allowRotatingRefresh: true,
|
|
}).catch((error) => {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.warn(
|
|
`[ProviderLimits] Post-usage refresh failed for connection ${connectionId}: ${message}`
|
|
);
|
|
});
|
|
}, getProviderLimitsPostUsageRefreshDelayMs());
|
|
timer.unref?.();
|
|
}
|
|
|
|
export function notifyProviderUsageRecorded(
|
|
provider: string | null | undefined,
|
|
connectionId: string | null | undefined
|
|
): void {
|
|
if ((provider !== "antigravity" && provider !== "agy") || !connectionId) return;
|
|
scheduleProviderLimitsPostUsageRefresh(connectionId);
|
|
}
|
|
|
|
// Subscribe at module load so usageHistory can emit usage events without importing
|
|
// this module (and its executors/translator import graph). This module is loaded by
|
|
// the provider-limits route and the background auto-sync scheduler at server boot.
|
|
onUsageRecorded(notifyProviderUsageRecorded);
|
|
|
|
function hasRetrieveUserQuotaSource(
|
|
provider: string,
|
|
cache: ProviderLimitsCacheEntry | undefined
|
|
): boolean {
|
|
if (provider !== "antigravity" && provider !== "agy") return true;
|
|
if (!cache?.quotas) return false;
|
|
return Object.values(cache.quotas).some((quota) => {
|
|
if (!isRecord(quota)) return false;
|
|
return quota.quotaSource === "retrieveUserQuota";
|
|
});
|
|
}
|
|
|
|
function sanitizeProviderLimitsCacheForConnection(
|
|
connection: ProviderConnectionLike | null | undefined,
|
|
entry: ProviderLimitsCacheEntry | null
|
|
): ProviderLimitsCacheEntry | null {
|
|
if (!connection || !entry || !entry.quotas) return entry;
|
|
if (connection.provider !== "antigravity" && connection.provider !== "agy") return entry;
|
|
|
|
const sanitizedQuotas = normalizeUsageQuotasForProvider(connection.provider, entry.quotas);
|
|
return sanitizedQuotas === entry.quotas ? entry : { ...entry, quotas: sanitizedQuotas };
|
|
}
|
|
|
|
function shouldRefreshProviderLimitsCache(
|
|
connection: ProviderConnectionLike,
|
|
cache: ProviderLimitsCacheEntry | undefined
|
|
): boolean {
|
|
if (!cache?.quotas) return true;
|
|
if (connection.provider !== "antigravity" && connection.provider !== "agy") return false;
|
|
|
|
return (
|
|
!hasRetrieveUserQuotaSource(connection.provider, cache) ||
|
|
Object.keys(cache.quotas).some(
|
|
(quotaKey) => !isUsageQuotaKeyAllowed(connection.provider, quotaKey)
|
|
)
|
|
);
|
|
}
|
|
|
|
export function isSupportedUsageConnection(connection: ProviderConnectionLike | null): boolean {
|
|
if (
|
|
!connection ||
|
|
!connection.provider ||
|
|
!USAGE_SUPPORTED_PROVIDERS.includes(connection.provider)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (connection.authType === "oauth") return true;
|
|
return (
|
|
(connection.authType === "apikey" || connection.authType === "api_key") &&
|
|
PROVIDER_LIMITS_APIKEY_PROVIDERS.has(connection.provider)
|
|
);
|
|
}
|
|
|
|
function withStatus(error: Error, status: number): Error & { status: number } {
|
|
return Object.assign(error, { status });
|
|
}
|
|
|
|
async function syncToCloudIfEnabled() {
|
|
try {
|
|
const machineId = await getMachineId();
|
|
if (!machineId) return;
|
|
await syncToCloud(machineId);
|
|
} catch (error) {
|
|
console.error("[ProviderLimits] Error syncing refreshed credentials to cloud:", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 } = {}
|
|
) {
|
|
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,
|
|
};
|
|
}
|
|
|
|
function isUsageAuthError(message: unknown): boolean {
|
|
if (typeof message !== "string") return false;
|
|
const m = message.toLowerCase();
|
|
return (
|
|
m.includes("token expired") ||
|
|
m.includes("unauthorized") ||
|
|
m.includes("re-authenticate") ||
|
|
m.includes("access denied") ||
|
|
m.includes("invalidated") ||
|
|
m.includes("401")
|
|
);
|
|
}
|
|
|
|
function isNetworkFailureMessage(message: unknown): boolean {
|
|
if (typeof message !== "string") return false;
|
|
return (
|
|
message.includes("fetch failed") ||
|
|
message.includes("ECONNREFUSED") ||
|
|
message.includes("ETIMEDOUT") ||
|
|
message.includes("Proxy unreachable") ||
|
|
message.includes("UND_ERR_CONNECT_TIMEOUT")
|
|
);
|
|
}
|
|
|
|
function isAccountScopedProxyResolution(proxyInfo: unknown): boolean {
|
|
if (!isRecord(proxyInfo)) return false;
|
|
if (!proxyInfo.proxy) return false;
|
|
return proxyInfo.level === "key" || proxyInfo.level === "account";
|
|
}
|
|
|
|
function shouldFailClosedForProviderLimitsProxy(
|
|
connection: ProviderConnectionLike,
|
|
proxyInfo: unknown
|
|
): boolean {
|
|
return connection.authType === "oauth" && isAccountScopedProxyResolution(proxyInfo);
|
|
}
|
|
|
|
/**
|
|
* Decide whether the quota-sync path should flag a connection `expired` from an
|
|
* auth-style usage error. Exported for unit testing.
|
|
*
|
|
* Rotating-refresh providers (Codex/OpenAI/Claude/etc. — see refreshSerializer's
|
|
* ROTATION_LOCK_GROUP) have their access_token deliberately NOT proactively
|
|
* refreshed in this quota path (#3019, to avoid the Auth0 family-revocation
|
|
* cascade). So a "token expired" from the quota fetch is a recoverable
|
|
* false-negative: the credential is still valid (its `expires_at` is in the
|
|
* future) and the reactive, serialized 401 path refreshes the access_token on
|
|
* next use. Flagging it `expired` hides a healthy account from the quota page
|
|
* (observed: freshly-added Codex accounts flagged expired while a providers-page
|
|
* refresh turns them green). So never mark a rotating provider expired from the
|
|
* quota sync — leave its status to the reactive path / connection test.
|
|
*/
|
|
export function quotaPathShouldMarkExpired(
|
|
provider: string,
|
|
usageMessage: unknown,
|
|
currentTestStatus: string | null | undefined
|
|
): boolean {
|
|
if (currentTestStatus === "expired") return false;
|
|
|
|
const message = typeof usageMessage === "string" ? usageMessage.toLowerCase() : "";
|
|
const isAuthError =
|
|
message.includes("token expired") ||
|
|
message.includes("access denied") ||
|
|
message.includes("re-authenticate") ||
|
|
message.includes("unauthorized");
|
|
if (!isAuthError) return false;
|
|
|
|
if (rotationGroupFor(provider) !== null) return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
const TERMINAL_STATUSES_FOR_QUOTA_RECOVERY = new Set([
|
|
"credits_exhausted",
|
|
"banned",
|
|
"expired",
|
|
"deactivated",
|
|
]);
|
|
|
|
function isTerminalStatusForQuotaRecovery(testStatus: string | null | undefined): boolean {
|
|
if (!testStatus) return false;
|
|
return TERMINAL_STATUSES_FOR_QUOTA_RECOVERY.has(testStatus);
|
|
}
|
|
|
|
export function hasUsableQuota(usage: JsonRecord): boolean {
|
|
const quotas = usage?.quotas;
|
|
if (!isRecord(quotas)) return false;
|
|
for (const value of Object.values(quotas)) {
|
|
if (!isRecord(value)) continue;
|
|
if (value.unlimited === true) return true;
|
|
const remaining =
|
|
typeof value.remaining === "number"
|
|
? value.remaining
|
|
: typeof value.remainingPercentage === "number"
|
|
? value.remainingPercentage
|
|
: null;
|
|
if (remaining !== null && remaining > 0) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export async function maybeClearRecoveredQuotaState(
|
|
connection: ProviderConnectionLike,
|
|
usage: JsonRecord
|
|
): Promise<ProviderConnectionLike> {
|
|
if (!hasUsableQuota(usage)) return connection;
|
|
if (isTerminalStatusForQuotaRecovery(connection.testStatus)) return connection;
|
|
|
|
const hasTransientState =
|
|
connection.testStatus === "unavailable" ||
|
|
Boolean(connection.rateLimitedUntil) ||
|
|
Boolean(connection.lastError) ||
|
|
Boolean(connection.errorCode) ||
|
|
Boolean(connection.lastErrorType) ||
|
|
Boolean(connection.lastErrorSource) ||
|
|
(connection.backoffLevel ?? 0) > 0;
|
|
|
|
if (!hasTransientState) return connection;
|
|
|
|
let cleared = true;
|
|
try {
|
|
const result = await clearRecoveredProviderState(
|
|
{
|
|
connectionId: connection.id,
|
|
testStatus: connection.testStatus,
|
|
lastError: connection.lastError ?? null,
|
|
rateLimitedUntil: connection.rateLimitedUntil ?? null,
|
|
errorCode: connection.errorCode ?? null,
|
|
lastErrorType: connection.lastErrorType ?? null,
|
|
lastErrorSource: connection.lastErrorSource ?? null,
|
|
},
|
|
{
|
|
testStatus: connection.testStatus ?? null,
|
|
lastErrorAt: connection.lastErrorAt ?? null,
|
|
rateLimitedUntil: connection.rateLimitedUntil ?? null,
|
|
}
|
|
);
|
|
cleared = result.applied;
|
|
} catch (dbError) {
|
|
console.warn("[ProviderLimits] Failed to clear recovered quota state:", dbError);
|
|
return connection;
|
|
}
|
|
|
|
if (!cleared) {
|
|
// CAS miss — a concurrent writer (markAccountUnavailable, etc.) updated
|
|
// the row between our read and the clear. Return the original snapshot;
|
|
// the next read from DB will surface the fresh state.
|
|
return connection;
|
|
}
|
|
|
|
return {
|
|
...connection,
|
|
testStatus: "active",
|
|
lastError: null,
|
|
lastErrorAt: null,
|
|
lastErrorType: null,
|
|
lastErrorSource: null,
|
|
errorCode: null,
|
|
rateLimitedUntil: null,
|
|
backoffLevel: 0,
|
|
};
|
|
}
|
|
|
|
async function syncExpiredStatusIfNeeded(
|
|
connection: ProviderConnectionLike,
|
|
usage: JsonRecord
|
|
): Promise<ProviderConnectionLike> {
|
|
if (!quotaPathShouldMarkExpired(connection.provider, usage.message, connection.testStatus)) {
|
|
return connection;
|
|
}
|
|
|
|
try {
|
|
await updateProviderConnection(connection.id, {
|
|
testStatus: "expired",
|
|
lastErrorType: "token_expired",
|
|
lastErrorAt: new Date().toISOString(),
|
|
});
|
|
} catch (dbError) {
|
|
console.error("[ProviderLimits] Failed to sync expired status to DB:", dbError);
|
|
return connection;
|
|
}
|
|
|
|
return {
|
|
...connection,
|
|
testStatus: "expired",
|
|
lastErrorType: "token_expired",
|
|
};
|
|
}
|
|
|
|
async function syncClaudeExtraUsageStateIfNeeded(
|
|
connection: ProviderConnectionLike,
|
|
usage: JsonRecord
|
|
): Promise<ProviderConnectionLike> {
|
|
const update = buildClaudeExtraUsageConnectionUpdate(connection, usage);
|
|
if (!update) return connection;
|
|
|
|
await updateProviderConnection(connection.id, update);
|
|
return {
|
|
...connection,
|
|
...update,
|
|
};
|
|
}
|
|
|
|
/** Persist Antigravity tier from live loadCodeAssist on quota refresh (not only OAuth). */
|
|
async function syncAntigravitySubscriptionIfNeeded(
|
|
connection: ProviderConnectionLike,
|
|
usage: JsonRecord
|
|
): Promise<ProviderConnectionLike> {
|
|
if (connection.provider !== "antigravity" && connection.provider !== "agy") return connection;
|
|
|
|
const subscriptionInfo = usage.subscriptionInfo;
|
|
if (!subscriptionInfo) return connection;
|
|
|
|
const psd = (connection.providerSpecificData || {}) as JsonRecord;
|
|
const nextPsd: JsonRecord = { ...psd };
|
|
let changed = false;
|
|
|
|
const tierId = extractCodeAssistOnboardTierId(subscriptionInfo);
|
|
if (tierId && tierId !== "legacy-tier" && psd.tier !== tierId) {
|
|
nextPsd.tier = tierId;
|
|
changed = true;
|
|
}
|
|
|
|
const subscriptionTier = extractCodeAssistSubscriptionTier(subscriptionInfo);
|
|
if (subscriptionTier && psd.subscriptionTier !== subscriptionTier) {
|
|
nextPsd.subscriptionTier = subscriptionTier;
|
|
changed = true;
|
|
}
|
|
|
|
const plan = typeof usage.plan === "string" ? usage.plan.trim() : "";
|
|
if (plan && psd.plan !== plan) {
|
|
nextPsd.plan = plan;
|
|
changed = true;
|
|
}
|
|
|
|
if (!changed) return connection;
|
|
|
|
await updateProviderConnection(connection.id, { providerSpecificData: nextPsd });
|
|
return { ...connection, providerSpecificData: nextPsd };
|
|
}
|
|
|
|
/** Persist refreshed Claude bootstrap fields into psd; writes only on diff. */
|
|
async function syncClaudeBootstrapIfNeeded(
|
|
connection: ProviderConnectionLike,
|
|
usage: JsonRecord
|
|
): Promise<ProviderConnectionLike> {
|
|
if (connection.provider !== "claude") return connection;
|
|
const bootstrap = usage?.bootstrap as Record<string, string | null> | null | undefined;
|
|
if (!bootstrap || typeof bootstrap !== "object") return connection;
|
|
|
|
const psd = (connection.providerSpecificData || {}) as JsonRecord;
|
|
const mapping: Array<[keyof typeof bootstrap, string]> = [
|
|
["account_uuid", "accountUUID"],
|
|
["organization_uuid", "organizationUUID"],
|
|
["organization_name", "organizationName"],
|
|
["organization_type", "organizationType"],
|
|
["organization_rate_limit_tier", "organizationRateLimitTier"],
|
|
];
|
|
|
|
const nextPsd: JsonRecord = { ...psd };
|
|
let changed = false;
|
|
for (const [bsKey, psdKey] of mapping) {
|
|
const next = bootstrap[bsKey];
|
|
if (typeof next === "string" && next.length > 0 && psd[psdKey] !== next) {
|
|
nextPsd[psdKey] = next;
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (!changed) return connection;
|
|
|
|
await updateProviderConnection(connection.id, { providerSpecificData: nextPsd });
|
|
return {
|
|
...connection,
|
|
providerSpecificData: nextPsd,
|
|
};
|
|
}
|
|
|
|
export function getProviderLimitsSyncIntervalMinutes(): number {
|
|
const raw = Number.parseInt(process.env.PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES ?? "", 10);
|
|
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES;
|
|
}
|
|
|
|
export function getProviderLimitsSyncIntervalMs(): number {
|
|
return getProviderLimitsSyncIntervalMinutes() * 60 * 1000;
|
|
}
|
|
|
|
/** Default gap (ms) inserted between two consecutive OAuth quota fetches. */
|
|
const DEFAULT_PROVIDER_LIMITS_SYNC_SPACING_MS = 1500;
|
|
|
|
/**
|
|
* Spacing (ms) applied between consecutive provider-limits fetch batches in a
|
|
* bulk sync, for BOTH the OAuth and local/API-key paths.
|
|
*
|
|
* OAuth providers (Codex/Claude/Kimi-coding/…) are fetched ONE AT A TIME with
|
|
* this gap so a single host never bursts several simultaneous usage/refresh
|
|
* requests to the same upstream — bursts read as automated traffic and
|
|
* contribute to session termination / anomaly flags (and, for rotating-token
|
|
* providers, to the Auth0 family-revocation race). Local/API-key connections
|
|
* (e.g. Ollama) keep their fast in-chunk concurrent path, but the gap is now
|
|
* also applied BETWEEN concurrency chunks so a local endpoint isn't hit by a
|
|
* simultaneous refresh burst either (#6916). Tunable via
|
|
* `PROVIDER_LIMITS_SYNC_SPACING_MS`; set to `"0"` to opt out on either path.
|
|
*/
|
|
export function getProviderLimitsSyncSpacingMs(): number {
|
|
const rawEnv = process.env.PROVIDER_LIMITS_SYNC_SPACING_MS;
|
|
if (rawEnv === undefined || rawEnv === "") return DEFAULT_PROVIDER_LIMITS_SYNC_SPACING_MS;
|
|
const raw = Number(rawEnv);
|
|
return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_PROVIDER_LIMITS_SYNC_SPACING_MS;
|
|
}
|
|
|
|
export async function getLastProviderLimitsAutoSyncTime(): Promise<string | null> {
|
|
try {
|
|
const settings = await getSettings();
|
|
const value = settings[PROVIDER_LIMITS_AUTO_SYNC_SETTING_KEY];
|
|
return typeof value === "string" && value.trim() ? value : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function setLastProviderLimitsAutoSyncTime(timestamp: string): Promise<void> {
|
|
await updateSettings({ [PROVIDER_LIMITS_AUTO_SYNC_SETTING_KEY]: timestamp });
|
|
}
|
|
|
|
export function getCachedProviderLimitsMap(): Record<string, ProviderLimitsCacheEntry> {
|
|
return getAllProviderLimitsCache();
|
|
}
|
|
|
|
export async function getSanitizedCachedProviderLimitsMap(): Promise<
|
|
Record<string, ProviderLimitsCacheEntry>
|
|
> {
|
|
const caches = getAllProviderLimitsCache();
|
|
// Sanitization only rewrites Antigravity/agy quota keys; every other provider's cache
|
|
// entry is returned untouched (see sanitizeProviderLimitsCacheForConnection). The
|
|
// dashboard polls this on an auto-refresh interval, so avoid the unconditional
|
|
// `SELECT * FROM provider_connections` + per-row credential decryption that the
|
|
// previous implementation paid on every poll: skip the scan entirely when nothing is
|
|
// cached, and otherwise fetch ONLY the Antigravity/agy connections. For any other
|
|
// provider, byId.get(id) is undefined and the entry is returned verbatim — identical
|
|
// output to scanning every active connection, but without decrypting unrelated keys.
|
|
// (LEDGER-2 / #3821-review)
|
|
const connectionIds = Object.keys(caches);
|
|
if (connectionIds.length === 0) return {};
|
|
|
|
const sanitizableConnections = [
|
|
...((await getProviderConnections({
|
|
isActive: true,
|
|
provider: "antigravity",
|
|
})) as unknown as ProviderConnectionLike[]),
|
|
...((await getProviderConnections({
|
|
isActive: true,
|
|
provider: "agy",
|
|
})) as unknown as ProviderConnectionLike[]),
|
|
];
|
|
if (sanitizableConnections.length === 0) {
|
|
// No connection can change the cache → return the raw entries unchanged.
|
|
return { ...caches };
|
|
}
|
|
|
|
const byId = new Map(sanitizableConnections.map((conn) => [conn.id, conn]));
|
|
const sanitized: Record<string, ProviderLimitsCacheEntry> = {};
|
|
for (const [connectionId, entry] of Object.entries(caches)) {
|
|
sanitized[connectionId] =
|
|
sanitizeProviderLimitsCacheForConnection(byId.get(connectionId), entry) || entry;
|
|
}
|
|
return sanitized;
|
|
}
|
|
|
|
export async function fetchLiveProviderLimits(connectionId: string): Promise<{
|
|
connection: ProviderConnectionLike;
|
|
usage: JsonRecord;
|
|
}> {
|
|
return fetchLiveProviderLimitsWithOptions(connectionId, { forceRefresh: false });
|
|
}
|
|
|
|
async function fetchLiveProviderLimitsWithOptions(
|
|
connectionId: string,
|
|
options: { forceRefresh?: boolean; allowRotatingRefresh?: boolean } = {}
|
|
): Promise<{
|
|
connection: ProviderConnectionLike;
|
|
usage: JsonRecord;
|
|
}> {
|
|
let connection = (await getProviderConnectionById(
|
|
connectionId
|
|
)) as unknown as ProviderConnectionLike | null;
|
|
if (!connection) {
|
|
throw withStatus(new Error("Connection not found"), 404);
|
|
}
|
|
|
|
if (!isSupportedUsageConnection(connection)) {
|
|
throw withStatus(new Error("Usage not available for this connection"), 400);
|
|
}
|
|
|
|
if (connection.authType !== "oauth") {
|
|
// L3: route the API-key usage/quota fetch through the connection's proxy context,
|
|
// mirroring the OAuth branch below (proxyInfo?.proxy ?? null). Without this, API-key
|
|
// usage egresses on the host IP, ignoring the connection's assigned proxy.
|
|
const apiKeyProxy = await resolveProxyForConnection(connectionId);
|
|
const usage = sanitizeUsageQuotasForProvider(
|
|
connection.provider,
|
|
(await runWithProxyContext(apiKeyProxy?.proxy ?? null, () =>
|
|
getUsageForProvider(connection as unknown as JsonRecord, options)
|
|
)) as JsonRecord
|
|
);
|
|
if (isRecord(usage.quotas)) {
|
|
setQuotaCache(connectionId, connection.provider, usage.quotas);
|
|
}
|
|
connection = await syncExpiredStatusIfNeeded(connection, usage);
|
|
connection = await syncClaudeExtraUsageStateIfNeeded(connection, usage);
|
|
connection = await syncClaudeBootstrapIfNeeded(connection, usage);
|
|
connection = await syncAntigravitySubscriptionIfNeeded(connection, usage);
|
|
connection = await maybeClearRecoveredQuotaState(connection, usage);
|
|
return { connection, usage };
|
|
}
|
|
|
|
const proxyInfo = await resolveProxyForConnection(connectionId);
|
|
|
|
const fetchUsageWithContext = async (proxyConfig: unknown) =>
|
|
runWithProxyContext(proxyConfig, async () => {
|
|
let conn = connection as ProviderConnectionLike;
|
|
let wasRefreshed = false;
|
|
|
|
const result = await refreshAndUpdateCredentials(conn, {
|
|
allowRotatingRefresh: options.allowRotatingRefresh,
|
|
});
|
|
conn = result.connection;
|
|
wasRefreshed = result.refreshed;
|
|
|
|
if (wasRefreshed) {
|
|
await syncToCloudIfEnabled();
|
|
}
|
|
|
|
let usageData = sanitizeUsageQuotasForProvider(
|
|
conn.provider,
|
|
(await getUsageForProvider(conn as unknown as JsonRecord, options)) as JsonRecord
|
|
);
|
|
|
|
// Reactive 401 recovery (on-demand/force path only): an unauthorized usage
|
|
// response means the access token is actually dead. Force ONE serialized
|
|
// re-mint and retry once. This recovers imported accounts (expiresAt=null,
|
|
// where the proactive needsRefresh heuristic never fires) without ever
|
|
// refreshing proactively from the bulk path.
|
|
if (options.allowRotatingRefresh && !wasRefreshed && isUsageAuthError(usageData?.message)) {
|
|
const forced = await refreshAndUpdateCredentials(conn, {
|
|
allowRotatingRefresh: true,
|
|
force: true,
|
|
});
|
|
if (forced.refreshed) {
|
|
conn = forced.connection;
|
|
await syncToCloudIfEnabled();
|
|
usageData = sanitizeUsageQuotasForProvider(
|
|
conn.provider,
|
|
(await getUsageForProvider(conn as unknown as JsonRecord, options)) as JsonRecord
|
|
);
|
|
}
|
|
}
|
|
|
|
connection = conn;
|
|
return { usage: usageData };
|
|
});
|
|
|
|
let result: { usage: JsonRecord };
|
|
const proxyConfig = proxyInfo?.proxy || null;
|
|
const failClosedOnProxyFailure = shouldFailClosedForProviderLimitsProxy(connection, proxyInfo);
|
|
|
|
try {
|
|
result = await fetchUsageWithContext(proxyConfig);
|
|
} catch (error: any) {
|
|
const isThrownNetworkError =
|
|
error?.message === "fetch failed" ||
|
|
error?.code === "PROXY_UNREACHABLE" ||
|
|
error?.code === "UND_ERR_CONNECT_TIMEOUT" ||
|
|
error?.cause?.code === "ECONNREFUSED";
|
|
|
|
if (proxyConfig && isThrownNetworkError) {
|
|
if (failClosedOnProxyFailure) {
|
|
console.warn(
|
|
"[ProviderLimits] Account-scoped %s proxy fetch failed for %s; failing closed without direct retry:",
|
|
connection.provider,
|
|
connectionId,
|
|
error?.message
|
|
);
|
|
throw error;
|
|
}
|
|
|
|
console.warn(
|
|
"[ProviderLimits] Proxy fetch threw for %s, retrying without proxy:",
|
|
connectionId,
|
|
error?.message
|
|
);
|
|
result = await fetchUsageWithContext(null);
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
if (proxyConfig && isNetworkFailureMessage(result.usage?.message)) {
|
|
if (failClosedOnProxyFailure) {
|
|
const message =
|
|
typeof result.usage.message === "string"
|
|
? result.usage.message
|
|
: "Provider-limits proxy request failed";
|
|
console.warn(
|
|
"[ProviderLimits] Account-scoped %s proxy usage failed for %s; failing closed without direct retry:",
|
|
connection.provider,
|
|
connectionId,
|
|
message
|
|
);
|
|
throw withStatus(new Error(message), 503);
|
|
}
|
|
|
|
console.warn(
|
|
"[ProviderLimits] Proxy usage returned network error for %s, retrying without proxy:",
|
|
connectionId,
|
|
result.usage.message
|
|
);
|
|
result = await fetchUsageWithContext(null);
|
|
}
|
|
|
|
if (isRecord(result.usage.quotas)) {
|
|
setQuotaCache(connectionId, connection.provider, result.usage.quotas);
|
|
}
|
|
connection = await syncExpiredStatusIfNeeded(connection, result.usage);
|
|
connection = await syncClaudeExtraUsageStateIfNeeded(connection, result.usage);
|
|
connection = await syncClaudeBootstrapIfNeeded(connection, result.usage);
|
|
connection = await syncAntigravitySubscriptionIfNeeded(connection, result.usage);
|
|
connection = await maybeClearRecoveredQuotaState(connection, result.usage);
|
|
|
|
return {
|
|
connection,
|
|
usage: result.usage,
|
|
};
|
|
}
|
|
|
|
export async function fetchAndPersistProviderLimits(
|
|
connectionId: string,
|
|
source: SyncSource = "manual",
|
|
opts: { allowRotatingRefresh?: boolean } = {}
|
|
): Promise<{
|
|
connection: ProviderConnectionLike;
|
|
usage: JsonRecord;
|
|
cache: ProviderLimitsCacheEntry;
|
|
}> {
|
|
const { connection, usage } = await fetchLiveProviderLimitsWithOptions(connectionId, {
|
|
forceRefresh: source === "manual",
|
|
allowRotatingRefresh: opts.allowRotatingRefresh,
|
|
});
|
|
const newCache = toProviderLimitsCacheEntry(usage, source);
|
|
|
|
// Don't persist error-only entries (429 etc.) — would wipe prior good cache.
|
|
// Serve the prior entry instead; only successful fetches update the cache.
|
|
const fetchFailed = !newCache.quotas && newCache.message;
|
|
if (fetchFailed) {
|
|
const previous = getProviderLimitsCache(connectionId);
|
|
if (previous?.quotas && Object.keys(previous.quotas).length > 0) {
|
|
const staleUsage: JsonRecord = {
|
|
...usage,
|
|
quotas: previous.quotas,
|
|
plan: previous.plan ?? usage.plan ?? null,
|
|
bankedResetCredits: previous.bankedResetCredits,
|
|
message: null,
|
|
_stale: true,
|
|
_staleSince: previous.fetchedAt,
|
|
_staleReason: newCache.message,
|
|
};
|
|
return { connection, usage: staleUsage, cache: previous };
|
|
}
|
|
return { connection, usage, cache: newCache };
|
|
}
|
|
|
|
setProviderLimitsCache(connectionId, newCache);
|
|
return { connection, usage, cache: newCache };
|
|
}
|
|
|
|
export async function syncAllProviderLimits(
|
|
options: {
|
|
source?: SyncSource;
|
|
concurrency?: number;
|
|
} = {}
|
|
): Promise<{
|
|
total: number;
|
|
succeeded: number;
|
|
failed: number;
|
|
caches: Record<string, ProviderLimitsCacheEntry>;
|
|
errors: Record<string, string>;
|
|
}> {
|
|
const { source = "manual", concurrency = 5 } = options;
|
|
const connections = (
|
|
(await getProviderConnections({ isActive: true })) as unknown as ProviderConnectionLike[]
|
|
).filter(isSupportedUsageConnection);
|
|
const cacheEntries: Array<{ connectionId: string; entry: ProviderLimitsCacheEntry }> = [];
|
|
const caches: Record<string, ProviderLimitsCacheEntry> = {};
|
|
const errors: Record<string, string> = {};
|
|
|
|
const recordResult = (
|
|
connectionId: string,
|
|
result: PromiseSettledResult<{ connectionId: string; cache: ProviderLimitsCacheEntry }>
|
|
) => {
|
|
if (result.status === "fulfilled") {
|
|
const { cache } = result.value;
|
|
// Don't persist error-only entries; show prior cache or pass through.
|
|
if (!cache.quotas && cache.message) {
|
|
const previous = getProviderLimitsCache(connectionId);
|
|
if (previous?.quotas && Object.keys(previous.quotas).length > 0) {
|
|
caches[connectionId] = previous;
|
|
} else {
|
|
caches[connectionId] = cache;
|
|
}
|
|
return;
|
|
}
|
|
cacheEntries.push({ connectionId, entry: cache });
|
|
caches[connectionId] = cache;
|
|
return;
|
|
}
|
|
const reason = result.reason as { message?: string } | undefined;
|
|
errors[connectionId] = reason?.message || "Failed to refresh provider limits";
|
|
};
|
|
|
|
const fetchOne = async (connection: ProviderConnectionLike) => {
|
|
const existingCache = getProviderLimitsCache(connection.id);
|
|
const forceRefresh =
|
|
source === "manual" ||
|
|
shouldRefreshProviderLimitsCache(connection, existingCache || undefined);
|
|
const { usage } = await fetchLiveProviderLimitsWithOptions(connection.id, {
|
|
forceRefresh,
|
|
});
|
|
const cache = toProviderLimitsCacheEntry(usage, source);
|
|
return { connectionId: connection.id, cache };
|
|
};
|
|
|
|
// OAuth connections are processed STRICTLY SEQUENTIALLY (chunk size 1) with a
|
|
// spacing gap so a single host never bursts simultaneous usage/refresh
|
|
// requests to the same upstream (anomaly/session-termination guard; see
|
|
// getProviderLimitsSyncSpacingMs). Local/API-key connections keep their fast
|
|
// in-chunk concurrent path, spaced BETWEEN chunks (#6916).
|
|
const oauthConnections = connections.filter((c) => c.authType === "oauth");
|
|
const otherConnections = connections.filter((c) => c.authType !== "oauth");
|
|
const spacingMs = getProviderLimitsSyncSpacingMs();
|
|
|
|
const recordChunk = (
|
|
chunk: ProviderConnectionLike[],
|
|
results: PromiseSettledResult<{ connectionId: string; cache: ProviderLimitsCacheEntry }>[]
|
|
) => {
|
|
results.forEach((result, index) => {
|
|
const connectionId = chunk[index]?.id;
|
|
if (connectionId) recordResult(connectionId, result);
|
|
});
|
|
};
|
|
|
|
await syncInChunksWithSpacing(otherConnections, concurrency, spacingMs, fetchOne, recordChunk);
|
|
await syncInChunksWithSpacing(oauthConnections, 1, spacingMs, fetchOne, recordChunk);
|
|
|
|
if (cacheEntries.length > 0) {
|
|
setProviderLimitsCacheBatch(cacheEntries);
|
|
}
|
|
|
|
if (source === "scheduled") {
|
|
await setLastProviderLimitsAutoSyncTime(new Date().toISOString());
|
|
}
|
|
|
|
return {
|
|
total: connections.length,
|
|
succeeded: cacheEntries.length,
|
|
failed: connections.length - cacheEntries.length,
|
|
caches,
|
|
errors,
|
|
};
|
|
}
|