Spaces:
Runtime error
Runtime error
File size: 12,153 Bytes
cd8bd0a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 | /**
* 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 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 };
|