386 lines
13 KiB
TypeScript
386 lines
13 KiB
TypeScript
/**
|
|
* apiKeyRotator.ts — T07: API Key Round-Robin with Health Tracking
|
|
*
|
|
* Rotates between a primary API key and extra API keys stored in
|
|
* providerSpecificData.extraApiKeys[]. Uses round-robin by default.
|
|
*
|
|
* Extra keys are stored as plain strings in providerSpecificData.extraApiKeys.
|
|
* Example: { extraApiKeys: ["sk-abc...", "sk-def...", "sk-ghi..."] }
|
|
*
|
|
* The in-memory rotation index resets on process restart, which is intentional —
|
|
* it ensures even distribution across restarts without persistence overhead.
|
|
*
|
|
* Health tracking: monitors per-key authentication failures. Keys that fail
|
|
* 3+ consecutive times are marked as "invalid" and skipped during rotation.
|
|
* Health status is persisted in providerSpecificData.apiKeyHealth.
|
|
*/
|
|
|
|
// In-memory round-robin index per connection
|
|
const _keyIndexes = new Map<string, number>();
|
|
|
|
// Tracks which connections have extra API keys (for A3 guard in chatCore.ts)
|
|
// Used to prevent disabling an entire connection when only one key fails.
|
|
const _connectionExtraKeys = new Map<string, boolean>();
|
|
// Eviction limits to prevent unbounded memory growth under heavy load
|
|
const MAX_KEY_HEALTH_ENTRIES = 500;
|
|
const MAX_CONNECTION_EXTRA_KEYS = 500;
|
|
|
|
/**
|
|
* Record whether a connection has extra API keys.
|
|
* Called by chatCore.ts when a 401 is detected, to inform the A3 guard.
|
|
*/
|
|
export function trackConnectionExtraKeys(connectionId: string, extraKeys: string[]): void {
|
|
const validExtras = extraKeys.filter((k) => typeof k === "string" && k.trim().length > 0);
|
|
if (!_connectionExtraKeys.has(connectionId) && _connectionExtraKeys.size >= MAX_CONNECTION_EXTRA_KEYS) {
|
|
const oldest = _connectionExtraKeys.keys().next().value;
|
|
if (oldest !== undefined) _connectionExtraKeys.delete(oldest);
|
|
}
|
|
_connectionExtraKeys.set(connectionId, validExtras.length > 0);
|
|
}
|
|
|
|
/**
|
|
* Check if a connection has extra API keys (for the A3 guard).
|
|
* Uses the in-memory cache (populated during request execution) and falls back
|
|
* to direct extraKeys data when provided, ensuring reliability across restarts.
|
|
*/
|
|
export function connectionHasExtraKeys(connectionId: string, extraKeys?: string[]): boolean {
|
|
// Direct data check is always authoritative
|
|
if (extraKeys && extraKeys.length > 0) return true;
|
|
// Fall back to in-memory cache (populated as side-effect during execution)
|
|
return _connectionExtraKeys.get(connectionId) ?? false;
|
|
}
|
|
|
|
// In-memory health status (synced to DB on state changes)
|
|
// Key format: "primary" | "extra_0" | "extra_1" | ...
|
|
interface KeyHealth {
|
|
status: "active" | "warning" | "invalid";
|
|
failures: number; // consecutive failures
|
|
lastFailure: string | null; // ISO timestamp
|
|
lastSuccess: string | null; // ISO timestamp
|
|
totalRequests: number;
|
|
totalFailures: number;
|
|
}
|
|
|
|
const _keyHealth = new Map<string, KeyHealth>();
|
|
|
|
const FAILURE_THRESHOLD = 2; // Mark as invalid after 2 consecutive failures
|
|
|
|
/**
|
|
* Get or create health status for a specific key within a connection scope.
|
|
*/
|
|
function getOrCreateHealth(connectionId: string, keyId: string): KeyHealth {
|
|
const scopedKey = `${connectionId}:${keyId}`;
|
|
if (!_keyHealth.has(scopedKey)) {
|
|
if (_keyHealth.size >= MAX_KEY_HEALTH_ENTRIES) {
|
|
const oldest = _keyHealth.keys().next().value;
|
|
if (oldest !== undefined) _keyHealth.delete(oldest);
|
|
}
|
|
_keyHealth.set(scopedKey, {
|
|
status: "active",
|
|
failures: 0,
|
|
lastFailure: null,
|
|
lastSuccess: null,
|
|
totalRequests: 0,
|
|
totalFailures: 0,
|
|
});
|
|
}
|
|
return _keyHealth.get(scopedKey)!;
|
|
}
|
|
|
|
/**
|
|
* Get the next valid API key in round-robin rotation.
|
|
* Skips keys marked as "invalid" in health status.
|
|
*
|
|
* @param connectionId - Unique connection identifier (for index isolation)
|
|
* @param primaryKey - The main api_key from the connection
|
|
* @param extraKeys - Additional API keys from providerSpecificData.extraApiKeys
|
|
* @param health - Optional health status from providerSpecificData.apiKeyHealth
|
|
* @returns The selected API key, or null if no valid keys available
|
|
*/
|
|
export function getValidApiKey(
|
|
connectionId: string,
|
|
primaryKey: string,
|
|
extraKeys: string[] = [],
|
|
health?: Record<string, KeyHealth>
|
|
): { key: string; keyId: string } | null {
|
|
const validExtras = extraKeys.filter((k) => typeof k === "string" && k.trim().length > 0);
|
|
|
|
// Build list of all keys with their IDs
|
|
const allKeys: Array<{ key: string; keyId: string }> = [];
|
|
|
|
// Add primary key if valid
|
|
if (primaryKey) {
|
|
const primaryHealth = health?.["primary"] || getOrCreateHealth(connectionId, "primary");
|
|
if (primaryHealth.status !== "invalid") {
|
|
allKeys.push({ key: primaryKey, keyId: "primary" });
|
|
} else {
|
|
console.warn(
|
|
`[KeyRotator] Skipping invalid primary key for connection ${connectionId.slice(0, 8)}`
|
|
);
|
|
}
|
|
}
|
|
|
|
// Add extra keys if valid
|
|
for (let i = 0; i < validExtras.length; i++) {
|
|
const keyId = `extra_${i}`;
|
|
const keyHealth = health?.[keyId] || getOrCreateHealth(connectionId, keyId);
|
|
if (keyHealth.status !== "invalid") {
|
|
allKeys.push({ key: validExtras[i], keyId });
|
|
}
|
|
}
|
|
|
|
if (allKeys.length === 0) return null;
|
|
if (allKeys.length === 1) {
|
|
return { key: allKeys[0].key, keyId: allKeys[0].keyId };
|
|
}
|
|
|
|
// Round-robin among valid keys only
|
|
const current = _keyIndexes.get(connectionId) ?? 0;
|
|
const idx = current % allKeys.length;
|
|
_keyIndexes.set(connectionId, current + 1);
|
|
|
|
return { key: allKeys[idx].key, keyId: allKeys[idx].keyId };
|
|
}
|
|
|
|
/**
|
|
* Get the next API key in round-robin rotation (legacy, without health check).
|
|
* @deprecated Use getValidApiKey() instead
|
|
*/
|
|
export function getRotatingApiKey(
|
|
connectionId: string,
|
|
primaryKey: string,
|
|
extraKeys: string[] = []
|
|
): string {
|
|
const validExtras = extraKeys.filter((k) => typeof k === "string" && k.trim().length > 0);
|
|
|
|
if (validExtras.length === 0) return primaryKey;
|
|
|
|
const allKeys = [primaryKey, ...validExtras].filter(Boolean);
|
|
if (allKeys.length <= 1) return primaryKey;
|
|
|
|
const current = _keyIndexes.get(connectionId) ?? 0;
|
|
const idx = current % allKeys.length;
|
|
_keyIndexes.set(connectionId, current + 1);
|
|
|
|
return allKeys[idx];
|
|
}
|
|
|
|
/**
|
|
* Record a failed authentication attempt for a key.
|
|
* Increments failure count and marks as "invalid" if threshold exceeded.
|
|
*
|
|
* @param connectionId - Connection scope for health state isolation
|
|
* @param keyId - Key identifier ("primary" | "extra_0" | ...)
|
|
* @returns Updated health status
|
|
*/
|
|
export function recordKeyFailure(connectionId: string, keyId: string): KeyHealth {
|
|
const health = getOrCreateHealth(connectionId, keyId);
|
|
health.failures++;
|
|
health.totalRequests++;
|
|
health.totalFailures++;
|
|
health.lastFailure = new Date().toISOString();
|
|
|
|
if (health.failures >= FAILURE_THRESHOLD) {
|
|
health.status = "invalid";
|
|
} else if (health.failures > 0) {
|
|
health.status = "warning";
|
|
}
|
|
|
|
return { ...health };
|
|
}
|
|
|
|
/**
|
|
* Record a terminal failure for a key — e.g. HTTP 402 "Insufficient account
|
|
* balance". Unlike recordKeyFailure() (which only invalidates after
|
|
* FAILURE_THRESHOLD consecutive failures), this marks the key "invalid"
|
|
* immediately in a single call, because the condition will not recover
|
|
* mid-session (the depleted key must not be returned by the rotator again
|
|
* until credits are added / an operator resets it).
|
|
*
|
|
* @param connectionId - Connection scope for health state isolation
|
|
* @param keyId - Key identifier ("primary" | "extra_0" | ...)
|
|
* @returns Updated health status
|
|
*/
|
|
export function recordKeyTerminal(connectionId: string, keyId: string): KeyHealth {
|
|
const health = getOrCreateHealth(connectionId, keyId);
|
|
health.failures = Math.max(health.failures + 1, FAILURE_THRESHOLD);
|
|
health.totalRequests++;
|
|
health.totalFailures++;
|
|
health.lastFailure = new Date().toISOString();
|
|
health.status = "invalid";
|
|
|
|
return { ...health };
|
|
}
|
|
|
|
/**
|
|
* Record a successful authentication attempt for a key.
|
|
* Resets failure count and marks as "active".
|
|
*
|
|
* @param connectionId - Connection scope for health state isolation
|
|
* @param keyId - Key identifier ("primary" | "extra_0" | ...)
|
|
* @returns Updated health status
|
|
*/
|
|
export function recordKeySuccess(connectionId: string, keyId: string): KeyHealth {
|
|
const health = getOrCreateHealth(connectionId, keyId);
|
|
health.failures = 0;
|
|
health.totalRequests++;
|
|
health.lastSuccess = new Date().toISOString();
|
|
health.status = "active";
|
|
|
|
return { ...health };
|
|
}
|
|
|
|
/**
|
|
* Get count of invalid keys (for notification).
|
|
*/
|
|
export function getInvalidKeyCount(health?: Record<string, KeyHealth>): number {
|
|
if (!health) return 0;
|
|
return Object.values(health).filter((h) => h.status === "invalid").length;
|
|
}
|
|
|
|
/**
|
|
* Get health statistics for display.
|
|
*/
|
|
export function getKeyHealthStats(
|
|
connectionId: string,
|
|
primaryKey: string,
|
|
extraKeys: string[] = [],
|
|
health?: Record<string, KeyHealth>
|
|
): {
|
|
total: number;
|
|
active: number;
|
|
warning: number;
|
|
invalid: number;
|
|
} {
|
|
const total = (primaryKey ? 1 : 0) + extraKeys.filter((k) => k.trim().length > 0).length;
|
|
const keys = ["primary", ...extraKeys.map((_, i) => `extra_${i}`)];
|
|
|
|
let active = 0;
|
|
let warning = 0;
|
|
let invalid = 0;
|
|
|
|
for (const keyId of keys) {
|
|
const h = health?.[keyId] || getOrCreateHealth(connectionId, keyId);
|
|
if (h.status === "active") active++;
|
|
else if (h.status === "warning") warning++;
|
|
else if (h.status === "invalid") invalid++;
|
|
}
|
|
|
|
return { total, active, warning, invalid };
|
|
}
|
|
|
|
/**
|
|
* Reset a key's health status to active.
|
|
* Called manually from Dashboard to recover from false positives.
|
|
*/
|
|
export function resetKeyStatus(connectionId: string, keyId: string): KeyHealth {
|
|
const health = getOrCreateHealth(connectionId, keyId);
|
|
health.failures = 0;
|
|
health.status = "active";
|
|
health.lastFailure = null;
|
|
return { ...health };
|
|
}
|
|
|
|
/**
|
|
* Get full health status for all keys.
|
|
*/
|
|
export function getAllKeyHealth(): Record<string, KeyHealth> {
|
|
const result: Record<string, KeyHealth> = {};
|
|
for (const [keyId, health] of _keyHealth.entries()) {
|
|
result[keyId] = { ...health };
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Sync health status from DB (on connection load).
|
|
*/
|
|
export function syncHealthFromDB(connectionId: string, health?: Record<string, KeyHealth>): void {
|
|
if (!health) return;
|
|
|
|
for (const [keyId, keyHealth] of Object.entries(health)) {
|
|
const scopedKey = `${connectionId}:${keyId}`;
|
|
if (!_keyHealth.has(scopedKey) && _keyHealth.size >= MAX_KEY_HEALTH_ENTRIES) {
|
|
const oldest = _keyHealth.keys().next().value;
|
|
if (oldest !== undefined) _keyHealth.delete(oldest);
|
|
}
|
|
_keyHealth.set(scopedKey, keyHealth);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset the rotation index for a connection.
|
|
* Call this when a key fails (401/403) to skip the bad key next time.
|
|
*
|
|
* @param connectionId - Connection to reset
|
|
* @deprecated Use recordKeyFailure() instead
|
|
*/
|
|
export function resetRotationIndex(connectionId: string): void {
|
|
_keyIndexes.delete(connectionId);
|
|
}
|
|
|
|
/**
|
|
* Get the total number of API keys available for a connection.
|
|
* Used for logging/observability.
|
|
*/
|
|
export function getApiKeyCount(primaryKey: string, extraKeys: string[] = []): number {
|
|
const validExtras = extraKeys.filter((k) => typeof k === "string" && k.trim().length > 0);
|
|
return (primaryKey ? 1 : 0) + validExtras.length;
|
|
}
|
|
|
|
/**
|
|
* Resolve the API key and its health status for an ongoing request.
|
|
*
|
|
* Unlike getValidApiKey() (which does round-robin for every call), this
|
|
* method re-uses the previously selected keyId when available — ensuring
|
|
* that a multi-turn request stream keeps using the same key. If no key
|
|
* was selected yet or the stored key is no longer valid, it falls back
|
|
* to fresh round-robin via getValidApiKey().
|
|
*
|
|
* @returns The resolved key+keyId, or null if no valid keys remain.
|
|
*/
|
|
export function resolveKeyForRequest(
|
|
connectionId: string,
|
|
primaryKey: string,
|
|
extraKeys: string[],
|
|
selectedKeyId: string | null
|
|
): { key: string; keyId: string } | null {
|
|
if (selectedKeyId) {
|
|
const health = getOrCreateHealth(connectionId, selectedKeyId);
|
|
if (health.status !== "invalid") {
|
|
if (selectedKeyId === "primary" && primaryKey) {
|
|
return { key: primaryKey, keyId: "primary" };
|
|
}
|
|
const match = /^extra_(\d+)$/.exec(selectedKeyId);
|
|
if (match) {
|
|
const idx = Number.parseInt(match[1], 10);
|
|
if (idx >= 0 && idx < extraKeys.length && extraKeys[idx].trim().length > 0) {
|
|
return { key: extraKeys[idx], keyId: selectedKeyId };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return getValidApiKey(connectionId, primaryKey, extraKeys);
|
|
}
|
|
|
|
export function removeConnectionHealth(connectionId: string): void {
|
|
for (const key of _keyHealth.keys()) {
|
|
if (key.startsWith(`${connectionId}:`)) {
|
|
_keyHealth.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function removeConnectionIndex(connectionId: string): void {
|
|
_keyIndexes.delete(connectionId);
|
|
_connectionExtraKeys.delete(connectionId);
|
|
for (const key of _keyHealth.keys()) {
|
|
if (key.startsWith(`${connectionId}:`)) {
|
|
_keyHealth.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
export type { KeyHealth };
|