mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 21:02:12 +03:00
* chore(release): v3.7.9 — gemini-cli cloud code separation * chore(provider): Update Jina AI model catalog (#1874) Integrated into release/v3.7.9 * docs: update CHANGELOG for PR 1874 and retroactive credits * fix: resolve stream defaults and codex prompt mapping (#1873, #1872) * chore(compression): start caveman compression update * feat(compression): expand caveman compression and analytics pipeline Add caveman intensity levels, output mode instructions, validation, and preview diffs across the compression pipeline. Extend MCP and dashboard settings to support auto-trigger mode, system prompt preservation, MCP description compression, and caveman rule metadata. Record richer compression analytics with receipt fields, validation fallbacks, output mode data, and add the related database migration. Improve preservation handling for code, URLs, markdown, math, and other protected content while adding broad unit, integration, and golden-set coverage for caveman parity and compression behavior. * feat(compression): expose rule intensities and track usd savings Add estimated USD savings to compression analytics so saved tokens can be reported in cost terms alongside existing token metrics. Expose caveman rule intensity metadata for settings consumers and add a settings API route alias for rule lookup. Also preserve system prompts when aggressive compression falls back to lite mode. * feat(compression): RTK compression roadmap (#1889) * chore(rtk): initialize compression roadmap branch * feat(compression): add RTK engine and compression combos Introduce RTK command-aware tool-output compression alongside stacked RTK -> Caveman pipelines for mixed prompt contexts. Add engine registration, declarative RTK filter packs, language-aware Caveman rule loading, compression combo persistence and assignments, analytics grouped by engine/combo, and new MCP/API endpoints for configuration, previews, filters, and combo management. Expose the new capabilities in the dashboard with dedicated Context & Cache pages for Caveman, RTK, and compression combos, and update docs, i18n strings, migrations, and tests to cover the expanded compression surface. * feat(compression): expand RTK DSL, filter catalog, and recovery APIs Add RTK parity features across the compression pipeline, dashboard, and management APIs. This expands the built-in filter catalog, adds trust-gated custom filter loading, inline filter verification, code stripping, smarter detection, and optional redacted raw-output retention for authenticated recovery. Also extend Caveman with file-based multilingual rule packs, localized output-mode instructions, stricter preview/config schemas, engine registry metadata, analytics fields, and broad unit test coverage for RTK, rule loading, and stacked compression behavior. * fix(auth): protect oauth routes and health reset operations Require authenticated dashboard access for OAuth endpoints that can create or import provider connections when login enforcement is enabled. Move `/api/monitoring/health` to the readonly public route list so safe methods remain public while DELETE now returns 401 for anonymous requests. Also update Next.js native `.node` handling to avoid webpack parse failures from external packages such as ngrok and keytar, and add coverage for the new auth behavior. * build(compression): ship RTK rule and filter assets with app bundles Include compression JSON assets in Next output tracing, prepublish copies, and pack artifact policy checks so standalone and packaged builds can load RTK filters and caveman rule packs at runtime. Also harden compression runtime behavior by resolving alternate asset directories, scoping rule cache entries by source path, carrying RTK raw output pointers through stacked runs, degrading oversized preview diffs, and applying combo language/output mode defaults during chat routing. Add coverage for packaging rules, provider-scoped model parsing, smart truncate edge cases, raw output retention, and combo-driven compression behavior. * docs(workflows): update local repo paths to OmniRoute Replace outdated `/home/diegosouzapw/dev/proxys/9router` references with the current `OmniRoute` directory across deploy, release, and version bump workflow guides so local command examples match the renamed repository layout * feat(compression): complete RTK parity coverage * test(build): align next config assertions --------- Co-authored-by: diegosouzapw <diego.souza.pw@gmail.com> * feat(compression): expand caveman parity and MCP metadata compression Compress MCP registry and list metadata descriptions for tools, prompts, resources, and resource templates while keeping tool-response bodies unchanged. Expose those savings in compression status as `mcp_metadata_estimate` metadata rather than provider usage. Add Caveman rule-pack support for custom regex flags and match-specific replacement maps, update English rules for upstream parity, and tighten article and pleasantry handling. Also process RTK multipart text blocks independently so mixed media content compresses safely without duplicating output. * feat(compression): unify config validation and persist MCP savings Centralize compression config schemas across settings, preview, RTK, and combo APIs to enforce consistent validation for stacked pipelines and engine-specific options. Expand caveman and stacked compression behavior by applying default combos at runtime, surfacing validation and fallback metadata, and exposing aggressive and ultra adapter schemas for configuration UI and tests. Persist MCP description compression snapshots into analytics without counting them as provider usage, and extend the dashboard with the new RTK controls and localized labels. * fix(auth): require dashboard management auth for compression preview Block preview requests unless they come from a valid management session token so protected settings cannot be probed through the preview API. Add unit coverage for unauthenticated requests, invalid bearer tokens, and successful authenticated preview execution. * fix(compression): preserve stacked defaults and secure metadata routes Only apply saved default compression combos when they contain a stacked pipeline so seeded Caveman-only defaults do not replace the builtin stacked behavior. Also require management auth for compression language pack and rules metadata endpoints, and defer usage receipt attachment until compression analytics writes have completed to keep analytics records consistent. * fix(compression): align seeded standard savings combo with stacked default Update the seeded default compression combo to use the RTK then Caveman pipeline in both fresh installs and upgraded databases. Add a targeted migration and runtime guard that only rewrites the legacy seeded record when its original metadata and single-step pipeline still match, preserving user-customized default combos. Refresh docs and tests to reflect the stacked default and expanded RTK filter catalog. * docs(compression): document RTK+Caveman stacked savings ranges Refresh the compression docs and README to describe the default stacked pipeline in terms of eligible-context savings instead of the older generic token-saving range. Add upstream RTK and Caveman benchmark references, explain the multiplicative savings math behind the stacked default, and update feature summaries plus package metadata to match the revised positioning. * feat(image-gen): add NanoGPT image generation provider (#1899) Integrated into release/v3.7.9 * fix(codex): sanitize raw responses input (#1895) Integrated into release/v3.7.9 * Fix combo provider breaker profile handling (#1891) Integrated into release/v3.7.9 * fix(combos): align strategy contracts (#1892) Integrated into release/v3.7.9 * feat(proxy): move proxy configuration to dedicated System → Proxy page (#1907) Integrated into release/v3.7.9 * feat: add K/M/B/T cost shortener to prevent UI overflow (#1902) Integrated into release/v3.7.9 * feat(providers): implement bulk paste for extra API keys (#1916) Integrated into release/v3.7.9 * fix(migrations): treat duplicate-column ALTER as no-op (#1886) Integrated into release/v3.7.9 * fix(analytics): robust model pricing resolution, dark mode charts and SQL aggregation fixes (#1896) Integrated into release/v3.7.9 (migration renumbered to 044) * fix(oauth): per-connection mutex for rotating refresh tokens (#1885) Integrated into release/v3.7.9 * fix: resolve 3 bugs — Codex tool normalization (#1914), image gen proxy (#1904), zero-arg MCP tools (#1898) - fix(codex): flatten Chat Completions tool format to Responses format in normalizeCodexTools. Prevents 'Missing required parameter: tools[0].name' upstream errors when clients send {type:'function', function:{name,...}} instead of {type:'function', name,...}. - fix(proxy): add proxy-aware execution context to image generation route. Image requests now correctly use proxy settings from the connection's ProxyRegistry assignment, matching the pattern used by chat pipeline. - fix(translator): inject properties:{} into zero-argument MCP tool schemas during Anthropic→OpenAI translation. OpenAI strict mode requires explicit properties even for empty object schemas. Closes #1914, Closes #1904, Closes #1898 * chore(release): v3.7.9 — all changes in ONE commit * fix: allow local ollama provider connections (#1893) * fix(copilot): emit compatible reasoning text deltas (#1919) Integrated into release/v3.7.9 * fix(api-manager): show validation errors inline in modals, not behind backdrop (#1920) Integrated into release/v3.7.9 * docs: update changelog and pr body with merged prs * fix(providers): route agentrouter through anthropic endpoint headers Update the AgentRouter provider registry to use the Claude-compatible messages API and required Anthropic-style authentication headers. This bypasses unauthorized_client_error responses and exposes the supported model list through passthrough configuration. Also update the changelog and release PR notes to document the fix for #1921 * feat(logs): show compression tokens in request log UI (#1923) * docs: add PR #1923 to changelog --------- Co-authored-by: diegosouzapw <diego.souza.pw@gmail.com> Co-authored-by: backryun <bakryun0718@proton.me> Co-authored-by: Aculeasis <42580940+Aculeasis@users.noreply.github.com> Co-authored-by: Raxxoor <manker_lol@hotmail.com> Co-authored-by: Randi <55005611+rdself@users.noreply.github.com> Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com> Co-authored-by: Tubagus <54710482+0xtbug@users.noreply.github.com> Co-authored-by: smartenok-ops <smartenok@gmail.com> Co-authored-by: Gi99lin <74502520+Gi99lin@users.noreply.github.com> Co-authored-by: ivan-mezentsev <ivan@mezentsev.me> Co-authored-by: Andrew Munsell <andrew@wizardapps.net>
1203 lines
36 KiB
TypeScript
Executable File
1203 lines
36 KiB
TypeScript
Executable File
// @ts-nocheck
|
|
import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts";
|
|
import { getGitHubCopilotRefreshHeaders } from "../config/providerHeaderProfiles.ts";
|
|
import { pbkdf2Sync } from "node:crypto";
|
|
import { runWithProxyContext } from "../utils/proxyFetch.ts";
|
|
|
|
// Token expiry buffer (refresh if expires within 5 minutes)
|
|
export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
|
|
|
const CACHE_SECRET = "omniroute-token-cache";
|
|
|
|
// In-flight refresh promise cache to prevent race conditions
|
|
// Key: "provider:sha256(refreshToken)" → Value: Promise<result>
|
|
const refreshPromiseCache = new Map();
|
|
|
|
// Per-connection mutex: prevents parallel OAuth refresh for rotating tokens.
|
|
// Key: connectionId → Value: { promise, waiters }
|
|
// Primary dedup when credentials.connectionId is present; refreshPromiseCache is fallback.
|
|
const connectionRefreshMutex = new Map();
|
|
|
|
type RefreshLogger = {
|
|
info?: (tag: string, message: string, data?: Record<string, unknown>) => void;
|
|
warn?: (tag: string, message: string, data?: Record<string, unknown>) => void;
|
|
error?: (tag: string, message: string, data?: Record<string, unknown>) => void;
|
|
debug?: (tag: string, message: string, data?: Record<string, unknown>) => void;
|
|
} | null;
|
|
|
|
function buildFormParams(entries: Record<string, unknown>): URLSearchParams {
|
|
const params = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(entries)) {
|
|
if (typeof value === "string" && value.length > 0) {
|
|
params.set(key, value);
|
|
}
|
|
}
|
|
return params;
|
|
}
|
|
|
|
function getRefreshCacheKey(provider, refreshToken) {
|
|
const tokenHash = pbkdf2Sync(refreshToken, CACHE_SECRET, 1000, 32, "sha256").toString("hex");
|
|
return `${provider}:${tokenHash}`;
|
|
}
|
|
|
|
/**
|
|
* Refresh OAuth access token using refresh token
|
|
*/
|
|
export async function refreshAccessToken(
|
|
provider,
|
|
refreshToken,
|
|
credentials,
|
|
log,
|
|
proxyConfig: unknown = null
|
|
) {
|
|
const config = PROVIDERS[provider];
|
|
|
|
const refreshEndpoint = config?.refreshUrl || config?.tokenUrl;
|
|
if (!config || !refreshEndpoint) {
|
|
log?.warn?.("TOKEN_REFRESH", `No refresh endpoint configured for provider: ${provider}`);
|
|
return null;
|
|
}
|
|
|
|
if (!refreshToken) {
|
|
log?.warn?.("TOKEN_REFRESH", `No refresh token available for provider: ${provider}`);
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const params = new URLSearchParams({
|
|
grant_type: "refresh_token",
|
|
refresh_token: refreshToken,
|
|
});
|
|
if (config.clientId) params.set("client_id", config.clientId);
|
|
if (config.clientSecret) params.set("client_secret", config.clientSecret);
|
|
|
|
const response = await runWithProxyContext(proxyConfig, () =>
|
|
fetch(refreshEndpoint, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
Accept: "application/json",
|
|
},
|
|
body: params,
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
log?.error?.("TOKEN_REFRESH", `Failed to refresh token for ${provider}`, {
|
|
status: response.status,
|
|
error: errorText,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const tokens = await response.json();
|
|
|
|
log?.info?.("TOKEN_REFRESH", `Successfully refreshed token for ${provider}`, {
|
|
hasNewAccessToken: !!tokens.access_token,
|
|
hasNewRefreshToken: !!tokens.refresh_token,
|
|
expiresIn: tokens.expires_in,
|
|
});
|
|
|
|
return {
|
|
accessToken: tokens.access_token,
|
|
refreshToken: tokens.refresh_token || refreshToken,
|
|
expiresIn: tokens.expires_in,
|
|
};
|
|
} catch (error) {
|
|
log?.error?.("TOKEN_REFRESH", `Error refreshing token for ${provider}`, {
|
|
error: error.message,
|
|
});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Specialized refresh for Cline OAuth tokens.
|
|
* Cline refresh endpoint expects JSON body and returns camelCase fields.
|
|
*/
|
|
export async function refreshClineToken(refreshToken, log, proxyConfig: unknown = null) {
|
|
const endpoint = PROVIDERS.cline?.refreshUrl;
|
|
if (!endpoint) {
|
|
log?.warn?.("TOKEN_REFRESH", "No refresh URL configured for Cline");
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const response = await runWithProxyContext(proxyConfig, () =>
|
|
fetch(endpoint, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
refreshToken,
|
|
grantType: "refresh_token",
|
|
clientType: "extension",
|
|
}),
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh Cline token", {
|
|
status: response.status,
|
|
error: errorText,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const payload = await response.json();
|
|
const data = payload?.data || payload;
|
|
const expiresAtIso = data?.expiresAt;
|
|
const expiresIn = expiresAtIso
|
|
? Math.max(1, Math.floor((new Date(expiresAtIso).getTime() - Date.now()) / 1000))
|
|
: undefined;
|
|
|
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Cline token", {
|
|
hasNewAccessToken: !!data?.accessToken,
|
|
hasNewRefreshToken: !!data?.refreshToken,
|
|
expiresIn,
|
|
});
|
|
|
|
return {
|
|
accessToken: data?.accessToken,
|
|
refreshToken: data?.refreshToken || refreshToken,
|
|
expiresIn,
|
|
};
|
|
} catch (error) {
|
|
log?.error?.("TOKEN_REFRESH", `Network error refreshing Cline token: ${error.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Specialized refresh for Kimi Coding OAuth tokens.
|
|
* Uses custom X-Msh-* headers required by Kimi OAuth API.
|
|
*/
|
|
export async function refreshKimiCodingToken(refreshToken, log, proxyConfig: unknown = null) {
|
|
const endpoint = PROVIDERS["kimi-coding"]?.refreshUrl || PROVIDERS["kimi-coding"]?.tokenUrl;
|
|
if (!endpoint) {
|
|
log?.warn?.("TOKEN_REFRESH", "No refresh URL configured for Kimi Coding");
|
|
return null;
|
|
}
|
|
|
|
// Generate device info for headers (same as OAuth flow)
|
|
const deviceId = "kimi-refresh-" + Date.now();
|
|
const platform = "omniroute";
|
|
const version = "2.1.2";
|
|
const deviceModel =
|
|
typeof process !== "undefined" ? `${process.platform} ${process.arch}` : "unknown";
|
|
|
|
try {
|
|
const params = new URLSearchParams({
|
|
grant_type: "refresh_token",
|
|
refresh_token: refreshToken,
|
|
client_id: PROVIDERS["kimi-coding"]?.clientId || "",
|
|
});
|
|
|
|
const response = await runWithProxyContext(proxyConfig, () =>
|
|
fetch(endpoint, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
Accept: "application/json",
|
|
"X-Msh-Platform": platform,
|
|
"X-Msh-Version": version,
|
|
"X-Msh-Device-Model": deviceModel,
|
|
"X-Msh-Device-Id": deviceId,
|
|
},
|
|
body: params,
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kimi Coding token", {
|
|
status: response.status,
|
|
error: errorText,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const tokens = await response.json();
|
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kimi Coding token", {
|
|
hasNewAccessToken: !!tokens.access_token,
|
|
hasNewRefreshToken: !!tokens.refresh_token,
|
|
expiresIn: tokens.expires_in,
|
|
});
|
|
|
|
return {
|
|
accessToken: tokens.access_token,
|
|
refreshToken: tokens.refresh_token || refreshToken,
|
|
expiresIn: tokens.expires_in,
|
|
tokenType: tokens.token_type,
|
|
scope: tokens.scope,
|
|
};
|
|
} catch (error) {
|
|
log?.error?.("TOKEN_REFRESH", `Network error refreshing Kimi Coding token: ${error.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Specialized refresh for Claude OAuth tokens
|
|
*/
|
|
export async function refreshClaudeOAuthToken(refreshToken, log, proxyConfig: unknown = null) {
|
|
try {
|
|
// Standard OAuth2 token refresh uses form-urlencoded (not JSON)
|
|
const params = buildFormParams({
|
|
grant_type: "refresh_token",
|
|
refresh_token: refreshToken,
|
|
client_id: PROVIDERS.claude.clientId,
|
|
});
|
|
|
|
const response = await runWithProxyContext(proxyConfig, () =>
|
|
fetch(OAUTH_ENDPOINTS.anthropic.token, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
Accept: "application/json",
|
|
"anthropic-beta": "oauth-2025-04-20",
|
|
},
|
|
body: params.toString(),
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", {
|
|
status: response.status,
|
|
error: errorText,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const tokens = await response.json();
|
|
|
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Claude OAuth token", {
|
|
hasNewAccessToken: !!tokens.access_token,
|
|
hasNewRefreshToken: !!tokens.refresh_token,
|
|
expiresIn: tokens.expires_in,
|
|
});
|
|
|
|
return {
|
|
accessToken: tokens.access_token,
|
|
refreshToken: tokens.refresh_token || refreshToken,
|
|
expiresIn: tokens.expires_in,
|
|
};
|
|
} catch (error) {
|
|
log?.error?.("TOKEN_REFRESH", `Network error refreshing Claude token: ${error.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Specialized refresh for Google providers (Gemini, Antigravity)
|
|
*/
|
|
export async function refreshGoogleToken(
|
|
refreshToken,
|
|
clientId,
|
|
clientSecret,
|
|
log,
|
|
proxyConfig: unknown = null
|
|
) {
|
|
const response = await runWithProxyContext(proxyConfig, () =>
|
|
fetch(OAUTH_ENDPOINTS.google.token, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
Accept: "application/json",
|
|
},
|
|
body: buildFormParams({
|
|
grant_type: "refresh_token",
|
|
refresh_token: refreshToken,
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
}),
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh Google token", {
|
|
status: response.status,
|
|
error: errorText,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const tokens = await response.json();
|
|
|
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Google token", {
|
|
hasNewAccessToken: !!tokens.access_token,
|
|
hasNewRefreshToken: !!tokens.refresh_token,
|
|
expiresIn: tokens.expires_in,
|
|
});
|
|
|
|
return {
|
|
accessToken: tokens.access_token,
|
|
refreshToken: tokens.refresh_token || refreshToken,
|
|
expiresIn: tokens.expires_in,
|
|
};
|
|
}
|
|
|
|
export async function refreshQwenToken(refreshToken, log, proxyConfig: unknown = null) {
|
|
const endpoint = OAUTH_ENDPOINTS.qwen.token;
|
|
|
|
try {
|
|
const response = await runWithProxyContext(proxyConfig, () =>
|
|
fetch(endpoint, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
Accept: "application/json",
|
|
},
|
|
body: buildFormParams({
|
|
grant_type: "refresh_token",
|
|
refresh_token: refreshToken,
|
|
client_id: PROVIDERS.qwen.clientId,
|
|
}),
|
|
})
|
|
);
|
|
|
|
if (response.status === 200) {
|
|
const tokens = await response.json();
|
|
|
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Qwen token", {
|
|
hasNewAccessToken: !!tokens.access_token,
|
|
hasNewRefreshToken: !!tokens.refresh_token,
|
|
expiresIn: tokens.expires_in,
|
|
});
|
|
|
|
return {
|
|
accessToken: tokens.access_token,
|
|
refreshToken: tokens.refresh_token || refreshToken,
|
|
expiresIn: tokens.expires_in,
|
|
providerSpecificData: tokens.resource_url
|
|
? { resourceUrl: tokens.resource_url }
|
|
: undefined,
|
|
};
|
|
} else {
|
|
const errorText = await response.text().catch(() => "");
|
|
|
|
// Detect unrecoverable invalid_request (expired/revoked refresh token or bad client_id)
|
|
let errorCode = null;
|
|
try {
|
|
const parsed = JSON.parse(errorText);
|
|
errorCode = parsed?.error;
|
|
} catch {
|
|
// not JSON, ignore
|
|
}
|
|
|
|
if (errorCode === "invalid_request") {
|
|
log?.error?.(
|
|
"TOKEN_REFRESH",
|
|
"Qwen refresh token is invalid or expired. Re-authentication required.",
|
|
{
|
|
status: response.status,
|
|
}
|
|
);
|
|
return { error: "invalid_request" };
|
|
}
|
|
|
|
log?.warn?.("TOKEN_REFRESH", `Error with Qwen endpoint`, {
|
|
status: response.status,
|
|
error: errorText,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
log?.warn?.("TOKEN_REFRESH", `Network error trying Qwen endpoint`, {
|
|
error: error.message,
|
|
});
|
|
}
|
|
|
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh Qwen token");
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Specialized refresh for Codex (OpenAI) OAuth tokens.
|
|
* OpenAI uses rotating (one-time-use) refresh tokens.
|
|
* Returns { error: 'unrecoverable_refresh_error', code } when the token has already been
|
|
* consumed or is invalid, so callers can stop retrying and request re-authentication.
|
|
*/
|
|
export async function refreshCodexToken(refreshToken, log, proxyConfig: unknown = null) {
|
|
try {
|
|
const response = await runWithProxyContext(proxyConfig, () =>
|
|
fetch(OAUTH_ENDPOINTS.openai.token, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
Accept: "application/json",
|
|
},
|
|
body: buildFormParams({
|
|
grant_type: "refresh_token",
|
|
refresh_token: refreshToken,
|
|
client_id: PROVIDERS.codex.clientId,
|
|
scope: "openid profile email offline_access",
|
|
}),
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
|
|
// Detect unrecoverable "refresh_token_reused" or "invalid_grant" error from OpenAI
|
|
// This means the token was already consumed or has expired.
|
|
// Retrying with the same token will never succeed.
|
|
let errorCode = null;
|
|
try {
|
|
const parsed = JSON.parse(errorText);
|
|
errorCode =
|
|
parsed?.error?.code || (typeof parsed?.error === "string" ? parsed.error : null);
|
|
} catch {
|
|
// not JSON, ignore
|
|
}
|
|
|
|
if (
|
|
errorCode === "refresh_token_reused" ||
|
|
errorCode === "invalid_grant" ||
|
|
errorCode === "token_expired" ||
|
|
errorCode === "invalid_token"
|
|
) {
|
|
log?.error?.(
|
|
"TOKEN_REFRESH",
|
|
"Codex refresh token already used or invalid. Re-authentication required.",
|
|
{
|
|
status: response.status,
|
|
errorCode,
|
|
}
|
|
);
|
|
return { error: "unrecoverable_refresh_error", code: errorCode };
|
|
}
|
|
|
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh Codex token", {
|
|
status: response.status,
|
|
error: errorText,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const tokens = await response.json();
|
|
|
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Codex token", {
|
|
hasNewAccessToken: !!tokens.access_token,
|
|
hasNewRefreshToken: !!tokens.refresh_token,
|
|
expiresIn: tokens.expires_in,
|
|
});
|
|
|
|
return {
|
|
accessToken: tokens.access_token,
|
|
refreshToken: tokens.refresh_token || refreshToken,
|
|
expiresIn: tokens.expires_in,
|
|
};
|
|
} catch (error) {
|
|
log?.error?.("TOKEN_REFRESH", `Network error refreshing Codex token: ${error.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Specialized refresh for Kiro (AWS CodeWhisperer) tokens
|
|
* Supports both AWS SSO OIDC (Builder ID/IDC) and Social Auth (Google/GitHub)
|
|
*/
|
|
export async function refreshKiroToken(
|
|
refreshToken,
|
|
providerSpecificData,
|
|
log,
|
|
proxyConfig: unknown = null
|
|
) {
|
|
try {
|
|
const authMethod = providerSpecificData?.authMethod;
|
|
const clientId = providerSpecificData?.clientId;
|
|
const clientSecret = providerSpecificData?.clientSecret;
|
|
const region = providerSpecificData?.region;
|
|
|
|
// AWS SSO OIDC (Builder ID or IDC)
|
|
// If clientId and clientSecret exist, assume AWS SSO OIDC (default to builder-id if authMethod not specified)
|
|
if (clientId && clientSecret) {
|
|
const endpoint = `https://oidc.${region || "us-east-1"}.amazonaws.com/token`;
|
|
|
|
const response = await runWithProxyContext(proxyConfig, () =>
|
|
fetch(endpoint, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
clientId: clientId,
|
|
clientSecret: clientSecret,
|
|
refreshToken: refreshToken,
|
|
grantType: "refresh_token",
|
|
}),
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro AWS token", {
|
|
status: response.status,
|
|
error: errorText,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const tokens = await response.json();
|
|
|
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro AWS token", {
|
|
hasNewAccessToken: !!tokens.accessToken,
|
|
expiresIn: tokens.expiresIn,
|
|
});
|
|
|
|
return {
|
|
accessToken: tokens.accessToken,
|
|
refreshToken: tokens.refreshToken || refreshToken,
|
|
expiresIn: tokens.expiresIn,
|
|
};
|
|
}
|
|
|
|
// Social Auth (Google/GitHub) - use Kiro's refresh endpoint
|
|
const tokenUrl = PROVIDERS.kiro.tokenUrl;
|
|
if (!tokenUrl) {
|
|
log?.error?.("TOKEN_REFRESH", "Missing Kiro token endpoint");
|
|
return null;
|
|
}
|
|
const response = await runWithProxyContext(proxyConfig, () =>
|
|
fetch(tokenUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
refreshToken: refreshToken,
|
|
}),
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro social token", {
|
|
status: response.status,
|
|
error: errorText,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const tokens = await response.json();
|
|
|
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro social token", {
|
|
hasNewAccessToken: !!tokens.accessToken,
|
|
expiresIn: tokens.expiresIn,
|
|
});
|
|
|
|
return {
|
|
accessToken: tokens.accessToken,
|
|
refreshToken: tokens.refreshToken || refreshToken,
|
|
expiresIn: tokens.expiresIn,
|
|
};
|
|
} catch (error) {
|
|
log?.error?.("TOKEN_REFRESH", `Network error refreshing Kiro token: ${error.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Specialized refresh for Qoder OAuth tokens
|
|
*/
|
|
export async function refreshQoderToken(refreshToken, log, proxyConfig: unknown = null) {
|
|
if (!OAUTH_ENDPOINTS.qoder.token || !PROVIDERS.qoder.clientId || !PROVIDERS.qoder.clientSecret) {
|
|
log?.warn?.(
|
|
"TOKEN_REFRESH",
|
|
"Qoder OAuth refresh skipped: browser OAuth is not configured in this environment"
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const basicAuth = btoa(`${PROVIDERS.qoder.clientId}:${PROVIDERS.qoder.clientSecret}`);
|
|
|
|
const response = await runWithProxyContext(proxyConfig, () =>
|
|
fetch(OAUTH_ENDPOINTS.qoder.token, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
Accept: "application/json",
|
|
Authorization: `Basic ${basicAuth}`,
|
|
},
|
|
body: buildFormParams({
|
|
grant_type: "refresh_token",
|
|
refresh_token: refreshToken,
|
|
client_id: PROVIDERS.qoder.clientId,
|
|
client_secret: PROVIDERS.qoder.clientSecret,
|
|
}),
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh Qoder token", {
|
|
status: response.status,
|
|
error: errorText,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const tokens = await response.json();
|
|
|
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Qoder token", {
|
|
hasNewAccessToken: !!tokens.access_token,
|
|
hasNewRefreshToken: !!tokens.refresh_token,
|
|
expiresIn: tokens.expires_in,
|
|
});
|
|
|
|
return {
|
|
accessToken: tokens.access_token,
|
|
refreshToken: tokens.refresh_token || refreshToken,
|
|
expiresIn: tokens.expires_in,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Specialized refresh for GitHub Copilot OAuth tokens
|
|
*/
|
|
export async function refreshGitHubToken(refreshToken, log, proxyConfig: unknown = null) {
|
|
const response = await runWithProxyContext(proxyConfig, () =>
|
|
fetch(OAUTH_ENDPOINTS.github.token, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
Accept: "application/json",
|
|
},
|
|
body: buildFormParams({
|
|
grant_type: "refresh_token",
|
|
refresh_token: refreshToken,
|
|
client_id: PROVIDERS.github.clientId,
|
|
client_secret: PROVIDERS.github.clientSecret,
|
|
}),
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh GitHub token", {
|
|
status: response.status,
|
|
error: errorText,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const tokens = await response.json();
|
|
|
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed GitHub token", {
|
|
hasNewAccessToken: !!tokens.access_token,
|
|
hasNewRefreshToken: !!tokens.refresh_token,
|
|
expiresIn: tokens.expires_in,
|
|
});
|
|
|
|
return {
|
|
accessToken: tokens.access_token,
|
|
refreshToken: tokens.refresh_token || refreshToken,
|
|
expiresIn: tokens.expires_in,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Refresh GitHub Copilot token using GitHub access token
|
|
*/
|
|
export async function refreshCopilotToken(githubAccessToken, log, proxyConfig: unknown = null) {
|
|
try {
|
|
const response = await runWithProxyContext(proxyConfig, () =>
|
|
fetch("https://api.github.com/copilot_internal/v2/token", {
|
|
headers: getGitHubCopilotRefreshHeaders(`token ${githubAccessToken}`),
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh Copilot token", {
|
|
status: response.status,
|
|
error: errorText,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Copilot token", {
|
|
hasToken: !!data.token,
|
|
expiresAt: data.expires_at,
|
|
});
|
|
|
|
return {
|
|
token: data.token,
|
|
expiresAt: data.expires_at,
|
|
};
|
|
} catch (error) {
|
|
log?.error?.("TOKEN_REFRESH", "Error refreshing Copilot token", {
|
|
error: error.message,
|
|
});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get access token for a specific provider (internal, does the actual work)
|
|
*/
|
|
async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: unknown = null) {
|
|
switch (provider) {
|
|
case "gemini":
|
|
case "gemini-cli":
|
|
case "antigravity":
|
|
return await refreshGoogleToken(
|
|
credentials.refreshToken,
|
|
PROVIDERS[provider].clientId,
|
|
PROVIDERS[provider].clientSecret,
|
|
log,
|
|
proxyConfig
|
|
);
|
|
|
|
case "claude":
|
|
return await refreshClaudeOAuthToken(credentials.refreshToken, log, proxyConfig);
|
|
|
|
case "codex":
|
|
return await refreshCodexToken(credentials.refreshToken, log, proxyConfig);
|
|
|
|
case "qwen":
|
|
return await refreshQwenToken(credentials.refreshToken, log, proxyConfig);
|
|
|
|
case "qoder":
|
|
return await refreshQoderToken(credentials.refreshToken, log, proxyConfig);
|
|
|
|
case "github":
|
|
return await refreshGitHubToken(credentials.refreshToken, log, proxyConfig);
|
|
|
|
case "kiro":
|
|
case "amazon-q":
|
|
return await refreshKiroToken(
|
|
credentials.refreshToken,
|
|
credentials.providerSpecificData,
|
|
log,
|
|
proxyConfig
|
|
);
|
|
|
|
case "cline":
|
|
return await refreshClineToken(credentials.refreshToken, log, proxyConfig);
|
|
|
|
case "kimi-coding":
|
|
return await refreshKimiCodingToken(credentials.refreshToken, log, proxyConfig);
|
|
|
|
default:
|
|
// Fallback to generic OAuth refresh for unknown providers
|
|
return refreshAccessToken(provider, credentials.refreshToken, credentials, log, proxyConfig);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether a provider has a supported refresh path in this service.
|
|
*/
|
|
export function supportsTokenRefresh(provider) {
|
|
const explicitlySupported = new Set([
|
|
"gemini",
|
|
"gemini-cli",
|
|
"antigravity",
|
|
"claude",
|
|
"codex",
|
|
"qwen",
|
|
"qoder",
|
|
"github",
|
|
"kiro",
|
|
"amazon-q",
|
|
"cline",
|
|
"kimi-coding",
|
|
]);
|
|
if (explicitlySupported.has(provider)) return true;
|
|
const config = PROVIDERS[provider];
|
|
return !!(config?.refreshUrl || config?.tokenUrl);
|
|
}
|
|
|
|
/**
|
|
* Check if a refresh result indicates an unrecoverable error
|
|
* (e.g. the refresh token was already consumed and cannot be reused).
|
|
* Callers should stop retrying and request re-authentication.
|
|
*/
|
|
export function isUnrecoverableRefreshError(result) {
|
|
return (
|
|
result &&
|
|
typeof result === "object" &&
|
|
(result.error === "unrecoverable_refresh_error" ||
|
|
result.error === "refresh_token_reused" ||
|
|
result.error === "invalid_request" ||
|
|
result.error === "invalid_grant")
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get access token for a specific provider (with deduplication).
|
|
*
|
|
* Deduplication strategy (two layers):
|
|
* 1. Per-connection mutex (primary): if credentials.connectionId is present, all concurrent
|
|
* callers for that connection share one in-flight promise regardless of which token they
|
|
* loaded. This prevents refresh_token_reused errors with rotating (one-time-use) tokens,
|
|
* e.g. Codex/OpenAI, where callers that loaded credentials at different times may hold
|
|
* different token strings but refer to the same connection.
|
|
* 2. Token-hash fallback: if no connectionId, dedup by provider+sha256(refreshToken) as before.
|
|
*
|
|
* Additionally, when connectionId is present, the stale-token check reads the DB to detect
|
|
* whether another process already refreshed the token. If the DB token is still valid it is
|
|
* returned immediately without a new upstream call.
|
|
*/
|
|
export async function getAccessToken(provider, credentials, log, proxyConfig: unknown = null) {
|
|
if (!credentials || !credentials.refreshToken || typeof credentials.refreshToken !== "string") {
|
|
log?.warn?.("TOKEN_REFRESH", `No valid refresh token available for provider: ${provider}`);
|
|
return null;
|
|
}
|
|
|
|
const connectionId = credentials.connectionId;
|
|
|
|
// ── Layer 1: per-connection mutex ──────────────────────────────────────────
|
|
if (connectionId && typeof connectionId === "string") {
|
|
const existing = connectionRefreshMutex.get(connectionId);
|
|
if (existing) {
|
|
existing.waiters++;
|
|
log?.info?.("TOKEN_REFRESH", "Concurrent refresh detected — sharing in-flight refresh", {
|
|
provider,
|
|
connectionId,
|
|
waiters: existing.waiters,
|
|
});
|
|
return existing.promise;
|
|
}
|
|
|
|
const entry = { promise: null, waiters: 0 };
|
|
entry.promise = _getAccessTokenWithStalenessCheck(
|
|
provider,
|
|
credentials,
|
|
log,
|
|
proxyConfig
|
|
).finally(() => {
|
|
connectionRefreshMutex.delete(connectionId);
|
|
});
|
|
connectionRefreshMutex.set(connectionId, entry);
|
|
return entry.promise;
|
|
}
|
|
|
|
// ── Layer 2: token-hash fallback (no connectionId) ─────────────────────────
|
|
const cacheKey = getRefreshCacheKey(provider, credentials.refreshToken);
|
|
|
|
if (refreshPromiseCache.has(cacheKey)) {
|
|
log?.info?.("TOKEN_REFRESH", `Reusing in-flight refresh for ${provider}`);
|
|
return refreshPromiseCache.get(cacheKey);
|
|
}
|
|
|
|
const refreshPromise = _getAccessTokenInternal(provider, credentials, log, proxyConfig).finally(
|
|
() => {
|
|
refreshPromiseCache.delete(cacheKey);
|
|
}
|
|
);
|
|
|
|
refreshPromiseCache.set(cacheKey, refreshPromise);
|
|
return refreshPromise;
|
|
}
|
|
|
|
/**
|
|
* Internal helper: performs the DB staleness check then calls the actual refresh.
|
|
* Only called from the per-connection mutex path (Layer 1 above).
|
|
*/
|
|
async function _getAccessTokenWithStalenessCheck(provider, credentials, log, proxyConfig) {
|
|
// RACE CONDITION PREVENTION:
|
|
// If the credentials object in memory is stale (e.g. it waited in a semaphore while another
|
|
// request refreshed the token), using its OLD refreshToken will cause the provider (e.g. OpenAI)
|
|
// to reject it with 'refresh_token_reused' and revoke the new token family.
|
|
// We MUST check if the DB has a newer token before proceeding with a network refresh.
|
|
if (credentials.connectionId) {
|
|
try {
|
|
const { getProviderConnectionById } = await import("../../src/lib/db/providers");
|
|
const dbConnection = await getProviderConnectionById(credentials.connectionId);
|
|
if (
|
|
dbConnection &&
|
|
dbConnection.refreshToken &&
|
|
dbConnection.refreshToken !== credentials.refreshToken
|
|
) {
|
|
log?.info?.(
|
|
"TOKEN_REFRESH",
|
|
`Stale token detected in memory for ${provider}. Using refreshed token from DB.`
|
|
);
|
|
|
|
// If the DB token is not expired, we can just return it!
|
|
const now = Date.now();
|
|
const dbExpiresAt = dbConnection.expiresAt ? new Date(dbConnection.expiresAt).getTime() : 0;
|
|
|
|
if (dbExpiresAt > now + 60000) {
|
|
// 60 seconds buffer
|
|
log?.info?.("TOKEN_REFRESH", `DB token is still valid. Skipping OAuth refresh.`);
|
|
return {
|
|
accessToken: dbConnection.accessToken,
|
|
refreshToken: dbConnection.refreshToken,
|
|
expiresIn: dbConnection.expiresIn,
|
|
};
|
|
} else {
|
|
// DB token is also expired, but it's the NEWEST one. We must use it to refresh.
|
|
credentials.refreshToken = dbConnection.refreshToken;
|
|
credentials.accessToken = dbConnection.accessToken;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
log?.warn?.(
|
|
"TOKEN_REFRESH",
|
|
`Failed to check DB for stale token: ${e instanceof Error ? e.message : String(e)}`
|
|
);
|
|
}
|
|
}
|
|
|
|
return _getAccessTokenInternal(provider, credentials, log, proxyConfig);
|
|
}
|
|
|
|
/**
|
|
* Refresh token by provider type (alias for getAccessToken)
|
|
* @deprecated Since v0.2.70 — use getAccessToken() directly.
|
|
* Still exported because open-sse/index.js and src/sse wrapper use it.
|
|
* Will be removed in a future major version.
|
|
*/
|
|
export const refreshTokenByProvider = getAccessToken;
|
|
|
|
/**
|
|
* Format credentials for provider
|
|
*/
|
|
export function formatProviderCredentials(provider, credentials, log) {
|
|
const config = PROVIDERS[provider];
|
|
if (!config) {
|
|
log?.warn?.("TOKEN_REFRESH", `No configuration found for provider: ${provider}`);
|
|
return null;
|
|
}
|
|
|
|
switch (provider) {
|
|
case "gemini":
|
|
return {
|
|
apiKey: credentials.apiKey,
|
|
accessToken: credentials.accessToken,
|
|
projectId: credentials.projectId,
|
|
};
|
|
|
|
case "claude":
|
|
return {
|
|
apiKey: credentials.apiKey,
|
|
accessToken: credentials.accessToken,
|
|
};
|
|
|
|
case "codex":
|
|
case "qwen":
|
|
case "qoder":
|
|
case "openai":
|
|
case "openrouter":
|
|
return {
|
|
apiKey: credentials.apiKey,
|
|
accessToken: credentials.accessToken,
|
|
};
|
|
|
|
case "antigravity":
|
|
case "gemini-cli":
|
|
return {
|
|
accessToken: credentials.accessToken,
|
|
refreshToken: credentials.refreshToken,
|
|
};
|
|
|
|
default:
|
|
return {
|
|
apiKey: credentials.apiKey,
|
|
accessToken: credentials.accessToken,
|
|
refreshToken: credentials.refreshToken,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all access tokens for a user
|
|
*/
|
|
export async function getAllAccessTokens(userInfo, log) {
|
|
const results = {};
|
|
|
|
if (userInfo.connections && Array.isArray(userInfo.connections)) {
|
|
for (const connection of userInfo.connections) {
|
|
if (connection.isActive && connection.provider) {
|
|
const token = await getAccessToken(
|
|
connection.provider,
|
|
{
|
|
refreshToken: connection.refreshToken,
|
|
},
|
|
log
|
|
);
|
|
|
|
if (token) {
|
|
results[connection.provider] = token;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Refresh token with retry and exponential backoff
|
|
* Retries on failure with increasing delay: 1s, 2s, 3s...
|
|
*
|
|
* Includes:
|
|
* - Per-provider circuit breaker (5 consecutive failures → 30min pause)
|
|
* - 30s timeout per refresh attempt to prevent hanging connections
|
|
*
|
|
* @param {function} refreshFn - Async function that returns token or null
|
|
* @param {number} maxRetries - Max retry attempts (default 3)
|
|
* @param {object} log - Logger instance (optional)
|
|
* @param {string} provider - Provider ID for circuit breaker tracking (optional)
|
|
* @returns {Promise<object|null>} Token result or null if all retries fail
|
|
*/
|
|
|
|
// ─── Circuit Breaker State ──────────────────────────────────────────────────
|
|
const _circuitBreaker: Record<string, { failures: number; blockedUntil: number }> = {};
|
|
const CIRCUIT_BREAKER_THRESHOLD = 5; // consecutive failures before tripping
|
|
const CIRCUIT_BREAKER_COOLDOWN = 30 * 60 * 1000; // 30 minutes
|
|
const REFRESH_TIMEOUT_MS = 30_000; // 30s max per refresh attempt
|
|
|
|
interface CircuitBreakerStatusEntry {
|
|
failures: number;
|
|
blocked: boolean;
|
|
blockedUntil: string | null;
|
|
remainingMs: number;
|
|
}
|
|
|
|
interface RefreshLoggerLike {
|
|
error?: (scope: string, message: string) => void;
|
|
warn?: (scope: string, message: string) => void;
|
|
}
|
|
|
|
/**
|
|
* Check if a provider is circuit-breaker blocked.
|
|
*/
|
|
export function isProviderBlocked(provider: string): boolean {
|
|
const state = _circuitBreaker[provider];
|
|
if (!state) return false;
|
|
if (!state.blockedUntil) return false;
|
|
if (state.blockedUntil > Date.now()) return true;
|
|
// Cooldown expired — reset
|
|
delete _circuitBreaker[provider];
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get active per-connection mutex entries (for diagnostics/metrics).
|
|
* Returns a snapshot of connections that have an in-flight refresh and their waiter count.
|
|
*/
|
|
export function getConnectionRefreshMutexStatus(): Record<string, { waiters: number }> {
|
|
const result: Record<string, { waiters: number }> = {};
|
|
for (const [connectionId, entry] of connectionRefreshMutex.entries()) {
|
|
result[connectionId] = { waiters: entry.waiters };
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get circuit breaker status for all providers (for diagnostics).
|
|
*/
|
|
export function getCircuitBreakerStatus(): Record<string, CircuitBreakerStatusEntry> {
|
|
const result: Record<string, CircuitBreakerStatusEntry> = {};
|
|
for (const [provider, state] of Object.entries(_circuitBreaker)) {
|
|
result[provider] = {
|
|
failures: state.failures,
|
|
blocked: state.blockedUntil > Date.now(),
|
|
blockedUntil:
|
|
state.blockedUntil > Date.now() ? new Date(state.blockedUntil).toISOString() : null,
|
|
remainingMs: Math.max(0, state.blockedUntil - Date.now()),
|
|
};
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Record a successful refresh — resets circuit breaker for provider.
|
|
*/
|
|
function recordSuccess(provider: string) {
|
|
if (_circuitBreaker[provider]) {
|
|
delete _circuitBreaker[provider];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record a failed refresh — increments circuit breaker counter.
|
|
*/
|
|
function recordFailure(provider: string, log: RefreshLoggerLike | null = null) {
|
|
if (!_circuitBreaker[provider]) {
|
|
_circuitBreaker[provider] = { failures: 0, blockedUntil: 0 };
|
|
}
|
|
_circuitBreaker[provider].failures++;
|
|
|
|
if (_circuitBreaker[provider].failures >= CIRCUIT_BREAKER_THRESHOLD) {
|
|
_circuitBreaker[provider].blockedUntil = Date.now() + CIRCUIT_BREAKER_COOLDOWN;
|
|
log?.error?.(
|
|
"TOKEN_REFRESH",
|
|
`🔴 Circuit breaker tripped for ${provider}: ${CIRCUIT_BREAKER_THRESHOLD} consecutive failures. ` +
|
|
`Blocked for ${CIRCUIT_BREAKER_COOLDOWN / 60000}min. Provider needs re-authentication.`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute a function with a timeout.
|
|
*/
|
|
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number): Promise<T | null> {
|
|
return await new Promise<T | null>((resolve, reject) => {
|
|
const timer = setTimeout(() => resolve(null), timeoutMs);
|
|
if (typeof timer === "object" && "unref" in timer) {
|
|
(timer as { unref?: () => void }).unref?.();
|
|
}
|
|
|
|
fn().then(
|
|
(result) => {
|
|
clearTimeout(timer);
|
|
resolve(result);
|
|
},
|
|
(error) => {
|
|
clearTimeout(timer);
|
|
reject(error);
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
export async function refreshWithRetry(
|
|
refreshFn,
|
|
maxRetries = 3,
|
|
log: RefreshLogger = null,
|
|
provider = "unknown"
|
|
) {
|
|
// Circuit breaker check
|
|
if (isProviderBlocked(provider)) {
|
|
log?.warn?.("TOKEN_REFRESH", `⚡ Circuit breaker active for ${provider}, skipping refresh`);
|
|
return null;
|
|
}
|
|
|
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
if (attempt > 0) {
|
|
const delay = attempt * 1000;
|
|
log?.debug?.("TOKEN_REFRESH", `Retry ${attempt}/${maxRetries} after ${delay}ms`);
|
|
await new Promise((r) => setTimeout(r, delay));
|
|
}
|
|
|
|
try {
|
|
const result = await withTimeout(refreshFn, REFRESH_TIMEOUT_MS);
|
|
if (result) {
|
|
recordSuccess(provider);
|
|
return result;
|
|
}
|
|
} catch (error) {
|
|
log?.warn?.("TOKEN_REFRESH", `Attempt ${attempt + 1}/${maxRetries} failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// All retries exhausted — record failure for circuit breaker
|
|
recordFailure(provider, log);
|
|
log?.error?.("TOKEN_REFRESH", `All ${maxRetries} retry attempts failed for ${provider}`);
|
|
return null;
|
|
}
|