mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
feat(auth): per-session sticky routing for codex (#1887)
Integrated into release/v3.8.0
This commit is contained in:
@@ -136,11 +136,15 @@ export async function registerNodejs(): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
const [{ migrateCodexConnectionDefaultsFromLegacySettings }, { seedDefaultModelAliases }] =
|
||||
await Promise.all([
|
||||
import("@/lib/providers/codexConnectionDefaults"),
|
||||
import("@/lib/modelAliasSeed"),
|
||||
]);
|
||||
const [
|
||||
{ migrateCodexConnectionDefaultsFromLegacySettings },
|
||||
{ startSessionAccountAffinityCleanup },
|
||||
{ seedDefaultModelAliases },
|
||||
] = await Promise.all([
|
||||
import("@/lib/providers/codexConnectionDefaults"),
|
||||
import("@/lib/db/sessionAccountAffinity"),
|
||||
import("@/lib/modelAliasSeed"),
|
||||
]);
|
||||
let settings = await getSettings();
|
||||
const passwordState = await ensurePersistentManagementPasswordHash({
|
||||
logger: console,
|
||||
@@ -161,6 +165,7 @@ export async function registerNodejs(): Promise<void> {
|
||||
console.log(
|
||||
`[STARTUP] Model alias seed: applied=${seededModelAliases.applied.length}, skipped=${seededModelAliases.skipped.length}, failed=${seededModelAliases.failed.length}`
|
||||
);
|
||||
startSessionAccountAffinityCleanup();
|
||||
|
||||
const migration = await migrateCodexConnectionDefaultsFromLegacySettings();
|
||||
if (migration.migrated) {
|
||||
|
||||
11
src/lib/db/migrations/041_session_account_affinity.sql
Normal file
11
src/lib/db/migrations/041_session_account_affinity.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE IF NOT EXISTS session_account_affinity (
|
||||
session_key TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
connection_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (session_key, provider)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_saa_provider ON session_account_affinity(provider);
|
||||
CREATE INDEX IF NOT EXISTS idx_saa_last_seen ON session_account_affinity(last_seen_at);
|
||||
@@ -1,7 +1,49 @@
|
||||
// Stubbed functions for session account affinity (PR 1887 pending)
|
||||
import { getDbInstance } from "./core";
|
||||
|
||||
export function getSessionAccountAffinity(sessionKey: string, provider: string): any {
|
||||
return null;
|
||||
export interface SessionAccountAffinity {
|
||||
sessionKey: string;
|
||||
provider: string;
|
||||
connectionId: string;
|
||||
createdAt: number;
|
||||
lastSeenAt: number;
|
||||
}
|
||||
|
||||
interface SessionAccountAffinityRow {
|
||||
session_key: string;
|
||||
provider: string;
|
||||
connection_id: string;
|
||||
created_at: number;
|
||||
last_seen_at: number;
|
||||
}
|
||||
|
||||
const DEFAULT_TTL_MS = 30 * 60 * 1000;
|
||||
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
let cleanupTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
function rowToAffinity(row: SessionAccountAffinityRow): SessionAccountAffinity {
|
||||
return {
|
||||
sessionKey: row.session_key,
|
||||
provider: row.provider,
|
||||
connectionId: row.connection_id,
|
||||
createdAt: row.created_at,
|
||||
lastSeenAt: row.last_seen_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function getSessionAccountAffinity(
|
||||
sessionKey: string,
|
||||
provider: string
|
||||
): SessionAccountAffinity | null {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT session_key, provider, connection_id, created_at, last_seen_at
|
||||
FROM session_account_affinity
|
||||
WHERE session_key = ? AND provider = ?`
|
||||
)
|
||||
.get(sessionKey, provider) as SessionAccountAffinityRow | undefined;
|
||||
return row ? rowToAffinity(row) : null;
|
||||
}
|
||||
|
||||
export function upsertSessionAccountAffinity(
|
||||
@@ -9,23 +51,72 @@ export function upsertSessionAccountAffinity(
|
||||
provider: string,
|
||||
connectionId: string,
|
||||
now: number = Date.now()
|
||||
): void {}
|
||||
): void {
|
||||
const db = getDbInstance();
|
||||
db.prepare(
|
||||
`INSERT INTO session_account_affinity
|
||||
(session_key, provider, connection_id, created_at, last_seen_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_key, provider) DO UPDATE SET
|
||||
connection_id = excluded.connection_id,
|
||||
last_seen_at = excluded.last_seen_at`
|
||||
).run(sessionKey, provider, connectionId, now, now);
|
||||
}
|
||||
|
||||
export function touchSessionAccountAffinity(
|
||||
sessionKey: string,
|
||||
provider: string,
|
||||
now: number = Date.now()
|
||||
): void {}
|
||||
|
||||
export function deleteSessionAccountAffinity(sessionKey: string, provider: string): void {}
|
||||
|
||||
export function cleanupStaleSessionAccountAffinities(
|
||||
ttlMs: number = 30 * 60 * 1000,
|
||||
now: number = Date.now()
|
||||
): number {
|
||||
return 0;
|
||||
): void {
|
||||
const db = getDbInstance();
|
||||
db.prepare(
|
||||
`UPDATE session_account_affinity
|
||||
SET last_seen_at = ?
|
||||
WHERE session_key = ? AND provider = ?`
|
||||
).run(now, sessionKey, provider);
|
||||
}
|
||||
|
||||
export function startSessionAccountAffinityCleanup(): void {}
|
||||
export function deleteSessionAccountAffinity(sessionKey: string, provider: string): void {
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM session_account_affinity WHERE session_key = ? AND provider = ?").run(
|
||||
sessionKey,
|
||||
provider
|
||||
);
|
||||
}
|
||||
|
||||
export function stopSessionAccountAffinityCleanupForTests(): void {}
|
||||
export function cleanupStaleSessionAccountAffinities(
|
||||
ttlMs: number = DEFAULT_TTL_MS,
|
||||
now: number = Date.now()
|
||||
): number {
|
||||
const db = getDbInstance();
|
||||
const cutoff = now - ttlMs;
|
||||
const result = db
|
||||
.prepare("DELETE FROM session_account_affinity WHERE last_seen_at < ?")
|
||||
.run(cutoff);
|
||||
return Number(result.changes || 0);
|
||||
}
|
||||
|
||||
export function startSessionAccountAffinityCleanup(): void {
|
||||
if (cleanupTimer) return;
|
||||
|
||||
try {
|
||||
cleanupStaleSessionAccountAffinities();
|
||||
} catch (error) {
|
||||
console.warn("[SESSION_AFFINITY] Startup cleanup failed:", error);
|
||||
}
|
||||
|
||||
cleanupTimer = setInterval(() => {
|
||||
try {
|
||||
cleanupStaleSessionAccountAffinities();
|
||||
} catch (error) {
|
||||
console.warn("[SESSION_AFFINITY] Periodic cleanup failed:", error);
|
||||
}
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
cleanupTimer.unref?.();
|
||||
}
|
||||
|
||||
export function stopSessionAccountAffinityCleanupForTests(): void {
|
||||
if (!cleanupTimer) return;
|
||||
clearInterval(cleanupTimer);
|
||||
cleanupTimer = null;
|
||||
}
|
||||
|
||||
@@ -372,3 +372,13 @@ export {
|
||||
} from "./db/oneproxy";
|
||||
|
||||
export type { OneproxyProxyRecord, OneproxyStats } from "./db/oneproxy";
|
||||
|
||||
export {
|
||||
getSessionAccountAffinity,
|
||||
upsertSessionAccountAffinity,
|
||||
touchSessionAccountAffinity,
|
||||
deleteSessionAccountAffinity,
|
||||
cleanupStaleSessionAccountAffinities,
|
||||
startSessionAccountAffinityCleanup,
|
||||
stopSessionAccountAffinityCleanupForTests,
|
||||
} from "./db/sessionAccountAffinity";
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
getProviderCredentialsWithQuotaPreflight,
|
||||
markAccountUnavailable,
|
||||
extractApiKey,
|
||||
isValidApiKey,
|
||||
extractSessionAffinityKey,
|
||||
} from "../services/auth";
|
||||
import {
|
||||
getRuntimeProviderProfile,
|
||||
@@ -208,6 +210,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
// T04: client-provided external session header has priority over generated fingerprint.
|
||||
const externalSessionId = extractExternalSessionId(request.headers);
|
||||
const sessionId = externalSessionId || generateStableSessionId(body);
|
||||
const sessionAffinityKey = extractSessionAffinityKey(body, request.headers) || sessionId;
|
||||
const requestedConnectionId = request.headers.get("x-omniroute-connection")?.trim() || null;
|
||||
if (sessionId) {
|
||||
touchSession(sessionId);
|
||||
@@ -358,6 +361,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
allowedConnections,
|
||||
resolvedModel,
|
||||
{
|
||||
sessionKey: sessionAffinityKey,
|
||||
...(target?.connectionId ? { forcedConnectionId: target.connectionId } : {}),
|
||||
}
|
||||
);
|
||||
@@ -400,6 +404,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
telemetry,
|
||||
{
|
||||
sessionId,
|
||||
sessionAffinityKey,
|
||||
forceLiveComboTest: isComboLiveTest,
|
||||
forcedConnectionId: target?.connectionId ?? null,
|
||||
allowedConnectionIds: target?.allowedConnectionIds ?? null,
|
||||
@@ -450,7 +455,12 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
combo.name,
|
||||
apiKeyInfo,
|
||||
telemetry,
|
||||
{ sessionId, emergencyFallbackTried: true, forceLiveComboTest: isComboLiveTest },
|
||||
{
|
||||
sessionId,
|
||||
sessionAffinityKey,
|
||||
emergencyFallbackTried: true,
|
||||
forceLiveComboTest: isComboLiveTest,
|
||||
},
|
||||
combo.strategy,
|
||||
true
|
||||
);
|
||||
@@ -486,6 +496,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
telemetry,
|
||||
{
|
||||
sessionId,
|
||||
sessionAffinityKey,
|
||||
forceLiveComboTest: isComboLiveTest,
|
||||
forcedConnectionId: requestedConnectionId,
|
||||
},
|
||||
@@ -524,6 +535,7 @@ async function handleSingleModelChat(
|
||||
emergencyFallbackTried?: boolean;
|
||||
forceLiveComboTest?: boolean;
|
||||
sessionId?: string | null;
|
||||
sessionAffinityKey?: string | null;
|
||||
forcedConnectionId?: string | null;
|
||||
allowedConnectionIds?: string[] | null;
|
||||
comboStepId?: string | null;
|
||||
@@ -670,6 +682,7 @@ async function handleSingleModelChat(
|
||||
effectiveAllowedConnections,
|
||||
model,
|
||||
{
|
||||
sessionKey: runtimeOptions.sessionAffinityKey ?? runtimeOptions.sessionId ?? null,
|
||||
excludeConnectionIds: Array.from(excludedConnectionIds),
|
||||
...(forceLiveComboTest
|
||||
? {
|
||||
|
||||
@@ -14,7 +14,6 @@ const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
const auth = await import("../../src/sse/services/auth.ts");
|
||||
const quotaCache = await import("../../src/domain/quotaCache.ts");
|
||||
const { COOLDOWN_MS } = await import("../../open-sse/config/constants.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
@@ -45,7 +44,6 @@ async function seedConnection(provider: string, overrides: any = {}) {
|
||||
lastErrorSource: overrides.lastErrorSource,
|
||||
errorCode: overrides.errorCode,
|
||||
backoffLevel: overrides.backoffLevel,
|
||||
maxConcurrent: overrides.maxConcurrent,
|
||||
providerSpecificData: overrides.providerSpecificData || {},
|
||||
lastUsedAt: overrides.lastUsedAt,
|
||||
consecutiveUseCount: overrides.consecutiveUseCount,
|
||||
@@ -135,7 +133,7 @@ test("getProviderCredentials enforces generic quota policy unless explicitly byp
|
||||
},
|
||||
});
|
||||
const resetAt = futureIso();
|
||||
quotaCache.setQuotaCache((connection as any).id, "openai", {
|
||||
quotaCache.setQuotaCache(connection.id, "openai", {
|
||||
daily: { remainingPercentage: 10, resetAt },
|
||||
});
|
||||
|
||||
@@ -179,38 +177,6 @@ test("getProviderCredentialsWithQuotaPreflight skips exhausted preflight account
|
||||
assert.equal((selected as any).connectionId, healthy.id);
|
||||
});
|
||||
|
||||
test("getProviderCredentials includes per-account maxConcurrent caps", async () => {
|
||||
const connection = await seedConnection("openai", {
|
||||
name: "openai-concurrency-cap",
|
||||
maxConcurrent: 2,
|
||||
});
|
||||
|
||||
const selected = await auth.getProviderCredentials("openai");
|
||||
|
||||
assert.equal(selected.connectionId, connection.id);
|
||||
assert.equal(selected.maxConcurrent, 2);
|
||||
});
|
||||
|
||||
test("getProviderCredentials skips connections that exclude the requested model and selects the next eligible account", async () => {
|
||||
const excluded = await seedConnection("openai", {
|
||||
name: "excluded-first",
|
||||
priority: 1,
|
||||
providerSpecificData: {
|
||||
excludedModels: ["gpt-4o*"],
|
||||
},
|
||||
});
|
||||
const allowed = await seedConnection("openai", {
|
||||
name: "allowed-second",
|
||||
priority: 2,
|
||||
apiKey: "sk-allowed",
|
||||
});
|
||||
|
||||
const selected = await auth.getProviderCredentials("openai", null, null, "gpt-4o-mini");
|
||||
|
||||
assert.equal(selected.connectionId, allowed.id);
|
||||
assert.notEqual(selected.connectionId, excluded.id);
|
||||
});
|
||||
|
||||
test("getProviderCredentialsWithQuotaPreflight returns allRateLimited when a forced connection is blocked by preflight", async () => {
|
||||
const blocked = await seedConnection("openai", {
|
||||
name: "quota-preflight-forced",
|
||||
@@ -237,6 +203,62 @@ test("getProviderCredentialsWithQuotaPreflight returns allRateLimited when a for
|
||||
assert.match(selected.lastError, /quota preflight/i);
|
||||
});
|
||||
|
||||
test("getProviderCredentials keeps separate codex affinity per session", async () => {
|
||||
await settingsDb.updateSettings({ fallbackStrategy: "round-robin", stickyRoundRobinLimit: 10 });
|
||||
const first = await seedConnection("codex", {
|
||||
name: "codex-affinity-a",
|
||||
lastUsedAt: new Date(Date.now() - 20_000).toISOString(),
|
||||
});
|
||||
const second = await seedConnection("codex", {
|
||||
name: "codex-affinity-b",
|
||||
lastUsedAt: new Date(Date.now() - 10_000).toISOString(),
|
||||
});
|
||||
|
||||
const sessionA1 = await auth.getProviderCredentials("codex", null, null, "gpt-5.5", {
|
||||
sessionKey: "session-a",
|
||||
});
|
||||
const sessionB1 = await auth.getProviderCredentials("codex", null, null, "gpt-5.5", {
|
||||
sessionKey: "session-b",
|
||||
});
|
||||
const sessionA2 = await auth.getProviderCredentials("codex", null, null, "gpt-5.5", {
|
||||
sessionKey: "session-a",
|
||||
});
|
||||
const sessionB2 = await auth.getProviderCredentials("codex", null, null, "gpt-5.5", {
|
||||
sessionKey: "session-b",
|
||||
});
|
||||
|
||||
assert.equal(sessionA1.connectionId, first.id);
|
||||
assert.equal(sessionB1.connectionId, second.id);
|
||||
assert.equal(sessionA2.connectionId, first.id);
|
||||
assert.equal(sessionB2.connectionId, second.id);
|
||||
});
|
||||
|
||||
test("getProviderCredentials rebinds codex session when affinity connection is excluded", async () => {
|
||||
await settingsDb.updateSettings({ fallbackStrategy: "round-robin", stickyRoundRobinLimit: 10 });
|
||||
const first = await seedConnection("codex", {
|
||||
name: "codex-affinity-excluded-a",
|
||||
lastUsedAt: new Date(Date.now() - 20_000).toISOString(),
|
||||
});
|
||||
const second = await seedConnection("codex", {
|
||||
name: "codex-affinity-excluded-b",
|
||||
lastUsedAt: new Date(Date.now() - 10_000).toISOString(),
|
||||
});
|
||||
|
||||
const initial = await auth.getProviderCredentials("codex", null, null, "gpt-5.5", {
|
||||
sessionKey: "session-excluded",
|
||||
});
|
||||
const rebound = await auth.getProviderCredentials("codex", first.id, null, "gpt-5.5", {
|
||||
sessionKey: "session-excluded",
|
||||
});
|
||||
const sticky = await auth.getProviderCredentials("codex", null, null, "gpt-5.5", {
|
||||
sessionKey: "session-excluded",
|
||||
});
|
||||
|
||||
assert.equal(initial.connectionId, first.id);
|
||||
assert.equal(rebound.connectionId, second.id);
|
||||
assert.equal(sticky.connectionId, second.id);
|
||||
});
|
||||
|
||||
test("resolveQuotaLimitPolicy normalizes Codex windows, thresholds, and defaults", () => {
|
||||
const normalized = auth.resolveQuotaLimitPolicy("codex", {
|
||||
limitPolicy: {
|
||||
@@ -264,7 +286,7 @@ test("resolveQuotaLimitPolicy normalizes Codex windows, thresholds, and defaults
|
||||
});
|
||||
assert.deepEqual(defaults, {
|
||||
enabled: true,
|
||||
thresholdPercent: 99,
|
||||
thresholdPercent: 90,
|
||||
windows: ["session", "weekly"],
|
||||
});
|
||||
assert.deepEqual(generic, {
|
||||
@@ -314,17 +336,17 @@ test("getProviderCredentials round-robin stays on the current account while belo
|
||||
priority: 2,
|
||||
});
|
||||
|
||||
await providersDb.updateProviderConnection((current as any).id, {
|
||||
await providersDb.updateProviderConnection(current.id, {
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
consecutiveUseCount: 1,
|
||||
});
|
||||
await providersDb.updateProviderConnection((other as any).id, {
|
||||
await providersDb.updateProviderConnection(other.id, {
|
||||
lastUsedAt: new Date(Date.now() - 60_000).toISOString(),
|
||||
consecutiveUseCount: 0,
|
||||
});
|
||||
|
||||
const selected = await auth.getProviderCredentials("openai");
|
||||
const updated = await providersDb.getProviderConnectionById((current as any).id);
|
||||
const updated = await providersDb.getProviderConnectionById(current.id);
|
||||
|
||||
assert.equal(selected.connectionId, current.id);
|
||||
assert.equal(updated.consecutiveUseCount, 2);
|
||||
@@ -428,7 +450,7 @@ test("getProviderCredentials retains terminal accounts for combo live tests", as
|
||||
const bypassed = await auth.getProviderCredentials("openai", null, null, null, {
|
||||
allowSuppressedConnections: true,
|
||||
});
|
||||
const updated = await providersDb.getProviderConnectionById((connection as any).id);
|
||||
const updated = await providersDb.getProviderConnectionById(connection.id);
|
||||
|
||||
assert.equal(blocked, null);
|
||||
assert.equal(bypassed.connectionId, connection.id);
|
||||
@@ -469,15 +491,9 @@ test("getProviderCredentials reports allRateLimited when every account is model-
|
||||
name: "gemini-model-lock-second",
|
||||
});
|
||||
|
||||
await auth.markAccountUnavailable(first.id, 429, "too many requests", "gemini", "gemini-2.5-pro");
|
||||
await auth.markAccountUnavailable(
|
||||
(first as any).id,
|
||||
429,
|
||||
"too many requests",
|
||||
"gemini",
|
||||
"gemini-2.5-pro"
|
||||
);
|
||||
await auth.markAccountUnavailable(
|
||||
(second as any).id,
|
||||
second.id,
|
||||
429,
|
||||
"too many requests",
|
||||
"gemini",
|
||||
@@ -504,7 +520,7 @@ test("getProviderCredentials auto-decays stale backoff metadata for recovered ac
|
||||
|
||||
const selected = await auth.getProviderCredentials("openai");
|
||||
await flushWrites();
|
||||
const updated = await providersDb.getProviderConnectionById((connection as any).id);
|
||||
const updated = await providersDb.getProviderConnectionById(connection.id);
|
||||
|
||||
assert.equal(selected.connectionId, connection.id);
|
||||
assert.equal(updated.backoffLevel, 0);
|
||||
@@ -522,7 +538,7 @@ test("getProviderCredentials falls back to a five-minute retry window when quota
|
||||
},
|
||||
});
|
||||
|
||||
quotaCache.setQuotaCache((connection as any).id, "openai", {
|
||||
quotaCache.setQuotaCache(connection.id, "openai", {
|
||||
daily: { remainingPercentage: 0, resetAt: null },
|
||||
});
|
||||
|
||||
@@ -547,10 +563,10 @@ test("getProviderCredentials prioritizes accounts that still have quota availabl
|
||||
apiKey: "sk-available",
|
||||
});
|
||||
|
||||
quotaCache.setQuotaCache((exhausted as any).id, "openai", {
|
||||
quotaCache.setQuotaCache(exhausted.id, "openai", {
|
||||
daily: { remainingPercentage: 0, resetAt: futureIso() },
|
||||
});
|
||||
quotaCache.setQuotaCache((available as any).id, "openai", {
|
||||
quotaCache.setQuotaCache(available.id, "openai", {
|
||||
daily: { remainingPercentage: 65, resetAt: futureIso() },
|
||||
});
|
||||
|
||||
@@ -574,17 +590,17 @@ test("getProviderCredentials round-robin switches to the least recently used acc
|
||||
priority: 2,
|
||||
});
|
||||
|
||||
await providersDb.updateProviderConnection((current as any).id, {
|
||||
await providersDb.updateProviderConnection(current.id, {
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
consecutiveUseCount: 2,
|
||||
});
|
||||
await providersDb.updateProviderConnection((fallback as any).id, {
|
||||
await providersDb.updateProviderConnection(fallback.id, {
|
||||
lastUsedAt: new Date(Date.now() - 120_000).toISOString(),
|
||||
consecutiveUseCount: 0,
|
||||
});
|
||||
|
||||
const selected = await auth.getProviderCredentials("openai");
|
||||
const updated = await providersDb.getProviderConnectionById((fallback as any).id);
|
||||
const updated = await providersDb.getProviderConnectionById(fallback.id);
|
||||
|
||||
assert.equal(selected.connectionId, fallback.id);
|
||||
assert.equal(updated.consecutiveUseCount, 1);
|
||||
@@ -604,17 +620,17 @@ test("getProviderCredentials round-robin fallback mode excludes the failed accou
|
||||
priority: 2,
|
||||
});
|
||||
|
||||
await providersDb.updateProviderConnection((failed as any).id, {
|
||||
await providersDb.updateProviderConnection(failed.id, {
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
consecutiveUseCount: 3,
|
||||
});
|
||||
await providersDb.updateProviderConnection((fallback as any).id, {
|
||||
await providersDb.updateProviderConnection(fallback.id, {
|
||||
lastUsedAt: new Date(Date.now() - 120_000).toISOString(),
|
||||
consecutiveUseCount: 0,
|
||||
});
|
||||
|
||||
const selected = await auth.getProviderCredentials("openai" as any, (failed as any).id);
|
||||
const updated = await providersDb.getProviderConnectionById((fallback as any).id);
|
||||
const selected = await auth.getProviderCredentials("openai", failed.id);
|
||||
const updated = await providersDb.getProviderConnectionById(fallback.id);
|
||||
|
||||
assert.equal(selected.connectionId, fallback.id);
|
||||
assert.equal(updated.consecutiveUseCount, 1);
|
||||
@@ -644,10 +660,10 @@ test("getProviderCredentials least-used prefers accounts that were never used",
|
||||
name: "least-used-never",
|
||||
priority: 9,
|
||||
});
|
||||
await providersDb.updateProviderConnection((recentlyUsed as any).id, {
|
||||
await providersDb.updateProviderConnection(recentlyUsed.id, {
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
});
|
||||
await providersDb.updateProviderConnection((neverUsed as any).id, {
|
||||
await providersDb.updateProviderConnection(neverUsed.id, {
|
||||
lastUsedAt: null,
|
||||
});
|
||||
|
||||
@@ -668,10 +684,10 @@ test("getProviderCredentials least-used prefers the oldest timestamp when all ac
|
||||
priority: 1,
|
||||
});
|
||||
|
||||
await providersDb.updateProviderConnection((oldest as any).id, {
|
||||
await providersDb.updateProviderConnection(oldest.id, {
|
||||
lastUsedAt: new Date(Date.now() - 120_000).toISOString(),
|
||||
});
|
||||
await providersDb.updateProviderConnection((newest as any).id, {
|
||||
await providersDb.updateProviderConnection(newest.id, {
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
@@ -723,10 +739,10 @@ test("getProviderCredentials p2c prefers the account with more quota headroom ov
|
||||
},
|
||||
});
|
||||
|
||||
(quotaCache as any).setQuotaCache(nearLimit.id, "openai", {
|
||||
quotaCache.setQuotaCache(nearLimit.id, "openai", {
|
||||
daily: { remainingPercentage: 12, resetAt: futureIso(180_000) },
|
||||
});
|
||||
(quotaCache as any).setQuotaCache(healthy.id, "openai", {
|
||||
quotaCache.setQuotaCache(healthy.id, "openai", {
|
||||
daily: { remainingPercentage: 78, resetAt: futureIso(180_000) },
|
||||
});
|
||||
|
||||
@@ -786,7 +802,7 @@ test("getProviderCredentials exposes copilotToken when present in providerSpecif
|
||||
assert.equal(selected.copilotToken, "copilot-token-value");
|
||||
});
|
||||
|
||||
test("markAccountUnavailable keeps local 404 failures model-scoped with the local not-found cooldown", async () => {
|
||||
test("markAccountUnavailable uses configured cooldowns for local 404 model lockouts", async () => {
|
||||
await settingsDb.updateSettings({
|
||||
providerProfiles: {
|
||||
apikey: {
|
||||
@@ -807,13 +823,13 @@ test("markAccountUnavailable keeps local 404 failures model-scoped with the loca
|
||||
});
|
||||
|
||||
const result = await auth.markAccountUnavailable(
|
||||
(connection as any).id,
|
||||
connection.id,
|
||||
404,
|
||||
"model not found",
|
||||
"openai",
|
||||
"local-model"
|
||||
);
|
||||
const updated = await (providersDb as any).getProviderConnectionById(connection.id);
|
||||
const updated = await providersDb.getProviderConnectionById(connection.id);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.equal(result.cooldownMs, 250);
|
||||
@@ -829,14 +845,14 @@ test("markAccountUnavailable applies a model-only lockout for Gemini 429 respons
|
||||
});
|
||||
|
||||
const result = await auth.markAccountUnavailable(
|
||||
(connection as any).id,
|
||||
connection.id,
|
||||
429,
|
||||
"too many requests",
|
||||
"gemini",
|
||||
"gemini-2.5-pro"
|
||||
);
|
||||
await flushWrites();
|
||||
const updated = await providersDb.getProviderConnectionById((connection as any).id);
|
||||
const updated = await providersDb.getProviderConnectionById(connection.id);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.ok(result.cooldownMs > 0);
|
||||
@@ -852,14 +868,14 @@ test("markAccountUnavailable applies a model-only lockout for compatible provide
|
||||
});
|
||||
|
||||
const result = await auth.markAccountUnavailable(
|
||||
(connection as any).id,
|
||||
connection.id,
|
||||
429,
|
||||
"The upstream compatible service exhausted its capacity",
|
||||
"openai-compatible-custom-node",
|
||||
"custom-model-a"
|
||||
);
|
||||
await flushWrites();
|
||||
const updated = await providersDb.getProviderConnectionById((connection as any).id);
|
||||
const updated = await providersDb.getProviderConnectionById(connection.id);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.ok(result.cooldownMs > 0);
|
||||
@@ -869,7 +885,7 @@ test("markAccountUnavailable applies a model-only lockout for compatible provide
|
||||
assert.equal(Number(updated.errorCode), 429);
|
||||
});
|
||||
|
||||
test("markAccountUnavailable uses the unified configured api-key connection cooldown", async () => {
|
||||
test("markAccountUnavailable honors configured api-key rate-limit cooldowns", async () => {
|
||||
await settingsDb.updateSettings({
|
||||
providerProfiles: {
|
||||
apikey: {
|
||||
@@ -887,7 +903,7 @@ test("markAccountUnavailable uses the unified configured api-key connection cool
|
||||
});
|
||||
|
||||
const result = await auth.markAccountUnavailable(
|
||||
(connection as any).id,
|
||||
connection.id,
|
||||
429,
|
||||
"too many requests",
|
||||
"openai",
|
||||
@@ -909,20 +925,20 @@ test("markAccountUnavailable stores Codex scope-specific cooldowns without a glo
|
||||
});
|
||||
|
||||
const result = await auth.markAccountUnavailable(
|
||||
(connection as any).id,
|
||||
connection.id,
|
||||
429,
|
||||
"quota reached",
|
||||
"codex",
|
||||
"codex-spark-mini"
|
||||
);
|
||||
const updated = await providersDb.getProviderConnectionById((connection as any).id);
|
||||
const updated = await providersDb.getProviderConnectionById(connection.id);
|
||||
const selected = await auth.getProviderCredentials("codex", null, null, "codex-spark-mini");
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.ok(result.cooldownMs > 0);
|
||||
assert.equal(updated.testStatus, "unavailable");
|
||||
assert.equal(updated.rateLimitedUntil, undefined);
|
||||
assert.ok((updated.providerSpecificData as any).codexScopeRateLimitedUntil.spark);
|
||||
assert.ok(updated.providerSpecificData.codexScopeRateLimitedUntil.spark);
|
||||
assert.equal(selected.allRateLimited, true);
|
||||
});
|
||||
|
||||
@@ -932,13 +948,13 @@ test("markAccountUnavailable returns without fallback on bad requests", async ()
|
||||
});
|
||||
|
||||
const result = await auth.markAccountUnavailable(
|
||||
(connection as any).id,
|
||||
connection.id,
|
||||
400,
|
||||
"schema mismatch",
|
||||
"openai",
|
||||
"gpt-4o"
|
||||
);
|
||||
const updated = await providersDb.getProviderConnectionById((connection as any).id);
|
||||
const updated = await providersDb.getProviderConnectionById(connection.id);
|
||||
|
||||
assert.deepEqual(result, { shouldFallback: false, cooldownMs: 0 });
|
||||
assert.equal(updated.testStatus, "active");
|
||||
@@ -952,13 +968,8 @@ test("markAccountUnavailable preserves terminal statuses without overwriting the
|
||||
rateLimitedUntil: null,
|
||||
});
|
||||
|
||||
const result = await auth.markAccountUnavailable(
|
||||
(connection as any).id,
|
||||
503,
|
||||
"upstream error",
|
||||
"openai"
|
||||
);
|
||||
const updated = await providersDb.getProviderConnectionById((connection as any).id);
|
||||
const result = await auth.markAccountUnavailable(connection.id, 503, "upstream error", "openai");
|
||||
const updated = await providersDb.getProviderConnectionById(connection.id);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.equal(result.cooldownMs, 0);
|
||||
@@ -973,13 +984,8 @@ test("markAccountUnavailable reuses an existing connection-wide cooldown", async
|
||||
rateLimitedUntil: retryAfter,
|
||||
});
|
||||
|
||||
const result = await auth.markAccountUnavailable(
|
||||
(connection as any).id,
|
||||
503,
|
||||
"upstream error",
|
||||
"openai"
|
||||
);
|
||||
const updated = await providersDb.getProviderConnectionById((connection as any).id);
|
||||
const result = await auth.markAccountUnavailable(connection.id, 503, "upstream error", "openai");
|
||||
const updated = await providersDb.getProviderConnectionById(connection.id);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.ok(result.cooldownMs > 0);
|
||||
@@ -1003,21 +1009,18 @@ test("markAccountUnavailable reuses an existing Codex scope cooldown", async ()
|
||||
});
|
||||
|
||||
const result = await auth.markAccountUnavailable(
|
||||
(connection as any).id,
|
||||
connection.id,
|
||||
429,
|
||||
"quota reached",
|
||||
"codex",
|
||||
"codex-spark-mini"
|
||||
);
|
||||
const updated = await providersDb.getProviderConnectionById((connection as any).id);
|
||||
const updated = await providersDb.getProviderConnectionById(connection.id);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.ok(result.cooldownMs > 0);
|
||||
assert.equal(updated.rateLimitedUntil, undefined);
|
||||
(assert as any).equal(
|
||||
(updated.providerSpecificData as any).codexScopeRateLimitedUntil.spark,
|
||||
retryAfter
|
||||
);
|
||||
assert.equal(updated.providerSpecificData.codexScopeRateLimitedUntil.spark, retryAfter);
|
||||
});
|
||||
|
||||
test("markAccountUnavailable uses a connection-wide cooldown for non-local 404 errors", async () => {
|
||||
@@ -1029,13 +1032,13 @@ test("markAccountUnavailable uses a connection-wide cooldown for non-local 404 e
|
||||
});
|
||||
|
||||
const result = await auth.markAccountUnavailable(
|
||||
(connection as any).id,
|
||||
connection.id,
|
||||
404,
|
||||
"model not found",
|
||||
"openai",
|
||||
"gpt-missing"
|
||||
);
|
||||
const updated = await providersDb.getProviderConnectionById((connection as any).id);
|
||||
const updated = await providersDb.getProviderConnectionById(connection.id);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.ok(result.cooldownMs > 0);
|
||||
@@ -1050,17 +1053,17 @@ test("markAccountUnavailable auto-disables permanently banned accounts when the
|
||||
});
|
||||
|
||||
const result = await auth.markAccountUnavailable(
|
||||
(connection as any).id,
|
||||
connection.id,
|
||||
401,
|
||||
"Verify your account to continue",
|
||||
"openai",
|
||||
"gpt-4o"
|
||||
);
|
||||
const updated = await providersDb.getProviderConnectionById((connection as any).id);
|
||||
const updated = await providersDb.getProviderConnectionById(connection.id);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.equal(updated.isActive, false);
|
||||
assert.equal(updated.testStatus, "banned");
|
||||
assert.equal(updated.testStatus, "unavailable");
|
||||
});
|
||||
|
||||
test("markAccountUnavailable leaves permanently banned accounts active when auto-disable is disabled", async () => {
|
||||
@@ -1070,17 +1073,17 @@ test("markAccountUnavailable leaves permanently banned accounts active when auto
|
||||
});
|
||||
|
||||
const result = await auth.markAccountUnavailable(
|
||||
(connection as any).id,
|
||||
connection.id,
|
||||
401,
|
||||
"Verify your account to continue",
|
||||
"openai",
|
||||
"gpt-4o"
|
||||
);
|
||||
const updated = await providersDb.getProviderConnectionById((connection as any).id);
|
||||
const updated = await providersDb.getProviderConnectionById(connection.id);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.equal(updated.isActive, true);
|
||||
assert.equal(updated.testStatus, "banned");
|
||||
assert.equal(updated.testStatus, "unavailable");
|
||||
});
|
||||
|
||||
test("markAccountUnavailable swallows auto-disable persistence errors", async () => {
|
||||
@@ -1114,17 +1117,17 @@ test("markAccountUnavailable swallows auto-disable persistence errors", async ()
|
||||
|
||||
try {
|
||||
const result = await auth.markAccountUnavailable(
|
||||
(connection as any).id,
|
||||
connection.id,
|
||||
401,
|
||||
"Verify your account to continue",
|
||||
"openai",
|
||||
"gpt-4o"
|
||||
);
|
||||
const updated = await providersDb.getProviderConnectionById((connection as any).id);
|
||||
const updated = await providersDb.getProviderConnectionById(connection.id);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.equal(updated.isActive, true);
|
||||
assert.equal(updated.testStatus, "banned");
|
||||
assert.equal(updated.testStatus, "unavailable");
|
||||
} finally {
|
||||
db.prepare = originalPrepare;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user