feat: Implement compatibility strategies for responses and chat endpoints

- Added `routeCompatibility.ts` to handle response compatibility for different endpoints.
- Introduced `chatEndpointStrategy.ts` to manage chat endpoint recovery and downgrading logic.
- Created tests for chat formats and input file handling in `chatFormatsCore.test.ts` and `inputFile.test.ts`.
- Developed `endpointCompatibility.ts` to define compatibility checks and header building for various endpoints.
- Implemented protocol lifecycle management in `protocolLifecycle.ts` and corresponding tests in `protocolModel.test.ts`.
- Enhanced tooltip functionality with a new `TooltipLayer` component and associated tests.
- Refactored existing components to improve tooltip handling and ensure proper rendering in the application.
This commit is contained in:
cita
2026-03-09 22:48:10 +08:00
parent 15d4b207cc
commit 0f8176cd04
74 changed files with 4645 additions and 1565 deletions
+16 -1
View File
@@ -10,7 +10,8 @@ describe('alertRules', () => {
it('detects token expiration by status or message', () => {
expect(isTokenExpiredError({ status: 401, message: 'Unauthorized' })).toBe(true);
expect(isTokenExpiredError({ status: 403, message: 'Forbidden' })).toBe(true);
expect(isTokenExpiredError({ status: 403, message: 'Forbidden' })).toBe(false);
expect(isTokenExpiredError({ message: 'HTTP 401: access token required' })).toBe(true);
expect(isTokenExpiredError({ message: 'jwt expired' })).toBe(true);
expect(isTokenExpiredError({ message: 'token invalid' })).toBe(true);
expect(isTokenExpiredError({ message: 'invalid access token' })).toBe(true);
@@ -19,6 +20,20 @@ describe('alertRules', () => {
expect(isTokenExpiredError({ status: 500, message: 'upstream error' })).toBe(false);
});
it('does not treat endpoint dispatch denial as token expiration', () => {
expect(isTokenExpiredError({
status: 403,
message: 'This group does not allow /v1/messages dispatch',
})).toBe(false);
expect(isTokenExpiredError({
status: 403,
message: 'dispatch denied for /v1/responses',
})).toBe(false);
expect(isTokenExpiredError({
message: 'unauthorized',
})).toBe(false);
});
it('appends rebind hint for invalid access token messages', () => {
expect(appendSessionTokenRebindHint('无权进行此操作,access token 无效'))
.toContain('请在中转站重新生成系统访问令牌后重新绑定账号');
+18 -4
View File
@@ -6,9 +6,25 @@ export function isCloudflareChallenge(message?: string | null): boolean {
const SESSION_TOKEN_REBIND_HINT = '请在中转站重新生成系统访问令牌后重新绑定账号';
function isEndpointDispatchDeniedMessage(message?: string | null): boolean {
if (!message) return false;
const text = message.toLowerCase();
return (
/does\s+not\s+allow\s+\/v1\/[a-z0-9/_:-]+\s+dispatch/i.test(message)
|| text.includes('dispatch denied')
);
}
function containsHttpStatus(message: string | null | undefined, status: number): boolean {
if (!message) return false;
return new RegExp(`(?:^|\\b)(?:http\\s*)?${status}(?:\\b|:)`, 'i').test(message);
}
export function isTokenExpiredError(input: { status?: number; message?: string | null }): boolean {
if (input.status === 401 || input.status === 403) return true;
const rawMessage = input.message || '';
const text = (input.message || '').toLowerCase();
if (isEndpointDispatchDeniedMessage(rawMessage)) return false;
if (input.status === 401 || containsHttpStatus(rawMessage, 401)) return true;
if (!text) return false;
// NewAPI-like sites may return this when session context is missing for an action,
@@ -24,9 +40,7 @@ export function isTokenExpiredError(input: { status?: number; message?: string |
text.includes('token expired') ||
(tokenPhrase && (hasInvalid || hasExpired)) ||
/invalid\s+access\s+token/.test(text) ||
/access\s+token\s+is\s+invalid/.test(text) ||
text.includes('unauthorized') ||
text.includes('forbidden')
/access\s+token\s+is\s+invalid/.test(text)
);
}
@@ -172,6 +172,60 @@ describe('balanceService auto relogin', () => {
expect(reportTokenExpiredMock).toHaveBeenCalledTimes(1);
});
it('does not report token expired for generic forbidden balance errors', async () => {
selectAllMock.mockReturnValue([
{
accounts: {
id: 12,
username: 'linuxdo_forbidden',
accessToken: 'stale-token',
status: 'active',
extraConfig: null,
},
sites: {
id: 12,
name: 'kfc',
url: 'https://kfc-api.sxxe.net',
platform: 'new-api',
},
},
]);
adapterMock.getBalance.mockRejectedValueOnce(new Error('HTTP 403: forbidden'));
const { refreshBalance } = await import('./balanceService.js');
await expect(refreshBalance(12)).rejects.toThrow('forbidden');
expect(reportTokenExpiredMock).not.toHaveBeenCalled();
});
it('does not report token expired for missing new-api-user errors', async () => {
selectAllMock.mockReturnValue([
{
accounts: {
id: 13,
username: 'linuxdo_missing_user',
accessToken: 'stale-token',
status: 'active',
extraConfig: null,
},
sites: {
id: 13,
name: 'kfc',
url: 'https://kfc-api.sxxe.net',
platform: 'new-api',
},
},
]);
adapterMock.getBalance.mockRejectedValueOnce(new Error('HTTP 400: new-api-user required'));
const { refreshBalance } = await import('./balanceService.js');
await expect(refreshBalance(13)).rejects.toThrow('new-api-user');
expect(reportTokenExpiredMock).not.toHaveBeenCalled();
});
it('proactively refreshes sub2api token when managed refresh token is near expiry', async () => {
selectAllMock.mockReturnValue([
{
+1 -9
View File
@@ -43,15 +43,7 @@ function shouldAttemptAutoRelogin(message?: string | null): boolean {
function shouldReportExpired(message?: string | null): boolean {
if (!message) return false;
if (isTokenExpiredError({ message })) return true;
const text = message.toLowerCase();
return (
text.includes('access token') ||
text.includes('new-api-user') ||
text.includes('unauthorized') ||
text.includes('forbidden')
);
return isTokenExpiredError({ message });
}
function isUnsupportedCheckinRuntimeHealth(health: ReturnType<typeof extractRuntimeHealth>): boolean {
+25 -228
View File
@@ -1,14 +1,13 @@
import Database from 'better-sqlite3';
import mysql from 'mysql2/promise';
import pg from 'pg';
import { mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { db, schema } from '../db/index.js';
import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from '../db/siteSchemaCompatibility.js';
import { ensureRouteGroupingSchemaCompatibility } from '../db/routeGroupingSchemaCompatibility.js';
import { ensureProxyFileSchemaCompatibility } from '../db/proxyFileSchemaCompatibility.js';
import {
createRuntimeSchemaClient,
ensureRuntimeDatabaseSchema,
type RuntimeSchemaClient,
type RuntimeSchemaDialect,
} from '../db/runtimeSchemaBootstrap.js';
export type MigrationDialect = 'sqlite' | 'mysql' | 'postgres';
export type MigrationDialect = RuntimeSchemaDialect;
export interface DatabaseMigrationInput {
dialect?: unknown;
@@ -67,15 +66,7 @@ export interface DatabaseMigrationSummary {
};
}
interface SqlClient {
dialect: MigrationDialect;
begin(): Promise<void>;
commit(): Promise<void>;
rollback(): Promise<void>;
execute(sqlText: string, params?: unknown[]): Promise<unknown>;
queryScalar(sqlText: string, params?: unknown[]): Promise<number>;
close(): Promise<void>;
}
type SqlClient = RuntimeSchemaClient;
interface InsertStatement {
table: string;
@@ -230,216 +221,8 @@ async function toBackupSnapshot(): Promise<BackupSnapshot> {
};
}
async function createPostgresClient(connectionString: string, ssl: boolean): Promise<SqlClient> {
const clientOptions: pg.ClientConfig = { connectionString };
if (ssl) {
clientOptions.ssl = { rejectUnauthorized: false };
}
const client = new pg.Client(clientOptions);
await client.connect();
return {
dialect: 'postgres',
begin: async () => { await client.query('BEGIN'); },
commit: async () => { await client.query('COMMIT'); },
rollback: async () => { await client.query('ROLLBACK'); },
execute: async (sqlText, params = []) => client.query(sqlText, params),
queryScalar: async (sqlText, params = []) => {
const result = await client.query(sqlText, params);
const row = result.rows[0] as Record<string, unknown> | undefined;
if (!row) return 0;
return Number(Object.values(row)[0]) || 0;
},
close: async () => { await client.end(); },
};
}
async function createMySqlClient(connectionString: string, ssl: boolean): Promise<SqlClient> {
const connectionOptions: mysql.ConnectionOptions = { uri: connectionString };
if (ssl) {
connectionOptions.ssl = { rejectUnauthorized: false };
}
const connection = await mysql.createConnection(connectionOptions);
return {
dialect: 'mysql',
begin: async () => { await connection.beginTransaction(); },
commit: async () => { await connection.commit(); },
rollback: async () => { await connection.rollback(); },
execute: async (sqlText, params = []) => connection.execute(sqlText, params as any[]),
queryScalar: async (sqlText, params = []) => {
const [rows] = await connection.query(sqlText, params as any[]);
if (!Array.isArray(rows) || rows.length === 0) return 0;
const row = rows[0] as Record<string, unknown>;
return Number(Object.values(row)[0]) || 0;
},
close: async () => { await connection.end(); },
};
}
async function createSqliteClient(connectionString: string): Promise<SqlClient> {
const filePath = resolve(connectionString);
mkdirSync(dirname(filePath), { recursive: true });
const sqlite = new Database(filePath);
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
return {
dialect: 'sqlite',
begin: async () => { sqlite.exec('BEGIN'); },
commit: async () => { sqlite.exec('COMMIT'); },
rollback: async () => { sqlite.exec('ROLLBACK'); },
execute: async (sqlText, params = []) => {
const lowered = sqlText.trim().toLowerCase();
const stmt = sqlite.prepare(sqlText);
if (lowered.startsWith('select')) return await stmt.all(...params);
return await stmt.run(...params);
},
queryScalar: async (sqlText, params = []) => {
const row = await sqlite.prepare(sqlText).get(...params) as Record<string, unknown> | undefined;
if (!row) return 0;
return Number(Object.values(row)[0]) || 0;
},
close: async () => { sqlite.close(); },
};
}
async function createClient(input: NormalizedDatabaseMigrationInput): Promise<SqlClient> {
if (input.dialect === 'postgres') return createPostgresClient(input.connectionString, input.ssl);
if (input.dialect === 'mysql') return createMySqlClient(input.connectionString, input.ssl);
return createSqliteClient(input.connectionString);
}
function validateIdentifier(identifier: string): string {
if (!/^[a-z_][a-z0-9_]*$/i.test(identifier)) {
throw new Error(`Invalid SQL identifier: ${identifier}`);
}
return identifier;
}
function createSiteSchemaInspector(client: SqlClient): SiteSchemaInspector {
if (client.dialect === 'sqlite') {
return {
dialect: 'sqlite',
tableExists: async (table) => {
const normalizedTable = validateIdentifier(table);
return (await client.queryScalar(
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = '${normalizedTable}'`,
)) > 0;
},
columnExists: async (table, column) => {
const normalizedTable = validateIdentifier(table);
const normalizedColumn = validateIdentifier(column);
return (await client.queryScalar(
`SELECT COUNT(*) FROM pragma_table_info('${normalizedTable}') WHERE name = '${normalizedColumn}'`,
)) > 0;
},
execute: async (sqlText) => {
await client.execute(sqlText);
},
};
}
if (client.dialect === 'mysql') {
return {
dialect: 'mysql',
tableExists: async (table) => {
return (await client.queryScalar(
'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?',
[table],
)) > 0;
},
columnExists: async (table, column) => {
return (await client.queryScalar(
'SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?',
[table, column],
)) > 0;
},
execute: async (sqlText) => {
await client.execute(sqlText);
},
};
}
return {
dialect: 'postgres',
tableExists: async (table) => {
return (await client.queryScalar(
'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = $1',
[table],
)) > 0;
},
columnExists: async (table, column) => {
return (await client.queryScalar(
'SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2',
[table, column],
)) > 0;
},
execute: async (sqlText) => {
await client.execute(sqlText);
},
};
}
async function ensureSchema(client: SqlClient): Promise<void> {
const statements = client.dialect === 'postgres'
? [
`CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "use_system_proxy" BOOLEAN DEFAULT FALSE, "status" TEXT DEFAULT 'active', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "global_weight" DOUBLE PRECISION DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "checkin_enabled" BOOLEAN DEFAULT TRUE, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "token_group" TEXT, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT TRUE, "is_default" BOOLEAN DEFAULT FALSE, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT TRUE, "manual_override" BOOLEAN DEFAULT FALSE, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "expires_at" TEXT, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" BOOLEAN DEFAULT FALSE, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`,
]
: client.dialect === 'mysql'
? [
`CREATE TABLE IF NOT EXISTS \`sites\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`url\` TEXT NOT NULL, \`external_checkin_url\` TEXT NULL, \`platform\` VARCHAR(64) NOT NULL, \`proxy_url\` TEXT NULL, \`use_system_proxy\` BOOLEAN DEFAULT FALSE, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`global_weight\` DOUBLE DEFAULT 1, \`api_key\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`accounts\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`site_id\` INT NOT NULL, \`username\` TEXT NULL, \`access_token\` TEXT NOT NULL, \`api_token\` TEXT NULL, \`balance\` DOUBLE DEFAULT 0, \`balance_used\` DOUBLE DEFAULT 0, \`quota\` DOUBLE DEFAULT 0, \`unit_cost\` DOUBLE NULL, \`value_score\` DOUBLE DEFAULT 0, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`checkin_enabled\` BOOLEAN DEFAULT TRUE, \`last_checkin_at\` TEXT NULL, \`last_balance_refresh\` TEXT NULL, \`extra_config\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, CONSTRAINT \`accounts_site_fk\` FOREIGN KEY (\`site_id\`) REFERENCES \`sites\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`account_tokens\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`name\` TEXT NOT NULL, \`token\` TEXT NOT NULL, \`token_group\` TEXT NULL, \`source\` VARCHAR(32) DEFAULT 'manual', \`enabled\` BOOLEAN DEFAULT TRUE, \`is_default\` BOOLEAN DEFAULT FALSE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, CONSTRAINT \`account_tokens_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`checkin_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`status\` VARCHAR(32) NOT NULL, \`message\` TEXT NULL, \`reward\` TEXT NULL, \`created_at\` TEXT NULL, CONSTRAINT \`checkin_logs_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`model_name\` TEXT NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`model_availability_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`token_model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`token_id\` INT NOT NULL, \`model_name\` TEXT NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`token_model_availability_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`token_routes\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`model_pattern\` TEXT NOT NULL, \`display_name\` TEXT NULL, \`display_icon\` TEXT NULL, \`model_mapping\` TEXT NULL, \`decision_snapshot\` TEXT NULL, \`decision_refreshed_at\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`route_channels\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NOT NULL, \`account_id\` INT NOT NULL, \`token_id\` INT NULL, \`source_model\` TEXT NULL, \`priority\` INT DEFAULT 0, \`weight\` INT DEFAULT 10, \`enabled\` BOOLEAN DEFAULT TRUE, \`manual_override\` BOOLEAN DEFAULT FALSE, \`success_count\` INT DEFAULT 0, \`fail_count\` INT DEFAULT 0, \`total_latency_ms\` INT DEFAULT 0, \`total_cost\` DOUBLE DEFAULT 0, \`last_used_at\` TEXT NULL, \`last_fail_at\` TEXT NULL, \`cooldown_until\` TEXT NULL, CONSTRAINT \`route_channels_route_fk\` FOREIGN KEY (\`route_id\`) REFERENCES \`token_routes\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE SET NULL)`,
`CREATE TABLE IF NOT EXISTS \`proxy_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`model_requested\` TEXT NULL, \`model_actual\` TEXT NULL, \`status\` VARCHAR(32) NULL, \`http_status\` INT NULL, \`latency_ms\` INT NULL, \`prompt_tokens\` INT NULL, \`completion_tokens\` INT NULL, \`total_tokens\` INT NULL, \`estimated_cost\` DOUBLE NULL, \`billing_details\` TEXT NULL, \`error_message\` TEXT NULL, \`retry_count\` INT DEFAULT 0, \`created_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`proxy_video_tasks\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`upstream_video_id\` TEXT NOT NULL, \`site_url\` TEXT NOT NULL, \`token_value\` TEXT NOT NULL, \`requested_model\` TEXT NULL, \`actual_model\` TEXT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`status_snapshot\` TEXT NULL, \`upstream_response_meta\` TEXT NULL, \`last_upstream_status\` INT NULL, \`last_polled_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`proxy_files\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`owner_type\` VARCHAR(64) NOT NULL, \`owner_id\` VARCHAR(191) NOT NULL, \`filename\` TEXT NOT NULL, \`mime_type\` VARCHAR(191) NOT NULL, \`purpose\` TEXT NULL, \`byte_size\` INT NOT NULL, \`sha256\` VARCHAR(191) NOT NULL, \`content_base64\` LONGTEXT NOT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, \`deleted_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`downstream_api_keys\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`key\` VARCHAR(191) NOT NULL UNIQUE, \`description\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`expires_at\` TEXT NULL, \`max_cost\` DOUBLE NULL, \`used_cost\` DOUBLE DEFAULT 0, \`max_requests\` INT NULL, \`used_requests\` INT DEFAULT 0, \`supported_models\` TEXT NULL, \`allowed_route_ids\` TEXT NULL, \`site_weight_multipliers\` TEXT NULL, \`last_used_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`events\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`type\` VARCHAR(32) NOT NULL, \`title\` TEXT NOT NULL, \`message\` TEXT NULL, \`level\` VARCHAR(16) DEFAULT 'info', \`read\` BOOLEAN DEFAULT FALSE, \`related_id\` INT NULL, \`related_type\` VARCHAR(32) NULL, \`created_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`settings\` (\`key\` VARCHAR(191) PRIMARY KEY, \`value\` TEXT NULL)`,
]
: [
`CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "use_system_proxy" INTEGER DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "global_weight" REAL DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" REAL DEFAULT 0, "balance_used" REAL DEFAULT 0, "quota" REAL DEFAULT 0, "unit_cost" REAL, "value_score" REAL DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "checkin_enabled" INTEGER DEFAULT 1, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "token_group" TEXT, "source" TEXT DEFAULT 'manual', "enabled" INTEGER DEFAULT 1, "is_default" INTEGER DEFAULT 0, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "enabled" INTEGER DEFAULT 1, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" INTEGER DEFAULT 1, "manual_override" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" REAL DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" REAL, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" INTEGER DEFAULT 1, "expires_at" TEXT, "max_cost" REAL, "used_cost" REAL DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" INTEGER DEFAULT 0, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`,
];
for (const sqlText of statements) {
await client.execute(sqlText);
}
await ensureSiteSchemaCompatibility(createSiteSchemaInspector(client));
await ensureRouteGroupingSchemaCompatibility(createSiteSchemaInspector(client));
await ensureProxyFileSchemaCompatibility(createSiteSchemaInspector(client));
return createRuntimeSchemaClient(input);
}
async function ensureTargetState(client: SqlClient, overwrite: boolean): Promise<void> {
@@ -755,13 +538,27 @@ async function syncPostgresSequences(client: SqlClient): Promise<void> {
}
}
export async function bootstrapRuntimeDatabaseSchema(input: Pick<NormalizedDatabaseMigrationInput, 'dialect' | 'connectionString' | 'ssl'>): Promise<void> {
const client = await createClient({
dialect: input.dialect,
connectionString: input.connectionString,
overwrite: true,
ssl: input.ssl,
});
try {
await ensureRuntimeDatabaseSchema(client);
} finally {
await client.close();
}
}
export async function migrateCurrentDatabase(input: DatabaseMigrationInput): Promise<DatabaseMigrationSummary> {
const normalized = normalizeMigrationInput(input);
const snapshot = await toBackupSnapshot();
const client = await createClient(normalized);
try {
await ensureSchema(client);
await ensureRuntimeDatabaseSchema(client);
await ensureTargetState(client, normalized.overwrite);
await client.begin();
@@ -819,6 +616,6 @@ export async function testDatabaseConnection(input: DatabaseMigrationInput): Pro
}
export const __databaseMigrationServiceTestUtils = {
ensureSchema,
ensureSchema: ensureRuntimeDatabaseSchema,
buildStatements,
};
@@ -100,4 +100,72 @@ describe('TokenRouter runtime cache', () => {
const refreshedSelection = await router.selectChannel('gpt-4o-mini');
expect(refreshedSelection).toBeNull();
});
it('uses fibonacci-style cooldown across repeated failures', async () => {
const site = await db.insert(schema.sites).values({
name: 'cooldown-site',
url: 'https://cooldown-site.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'cooldown-user',
accessToken: 'cooldown-access-token',
apiToken: 'cooldown-api-token',
status: 'active',
}).returning().get();
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'cooldown-token',
token: 'sk-cooldown-token',
enabled: true,
isDefault: true,
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-4o-mini',
enabled: true,
}).returning().get();
const channel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: token.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
const firstStartedAt = Date.now();
await router.recordFailure(channel.id);
const firstRecord = await db.select().from(schema.routeChannels)
.where(eq(schema.routeChannels.id, channel.id))
.get();
const firstCooldownMs = Date.parse(String(firstRecord?.cooldownUntil || '')) - firstStartedAt;
expect(firstCooldownMs).toBeGreaterThanOrEqual(10_000);
expect(firstCooldownMs).toBeLessThanOrEqual(20_000);
const secondStartedAt = Date.now();
await router.recordFailure(channel.id);
const secondRecord = await db.select().from(schema.routeChannels)
.where(eq(schema.routeChannels.id, channel.id))
.get();
const secondCooldownMs = Date.parse(String(secondRecord?.cooldownUntil || '')) - secondStartedAt;
expect(secondCooldownMs).toBeGreaterThanOrEqual(10_000);
expect(secondCooldownMs).toBeLessThanOrEqual(20_000);
const thirdStartedAt = Date.now();
await router.recordFailure(channel.id);
const thirdRecord = await db.select().from(schema.routeChannels)
.where(eq(schema.routeChannels.id, channel.id))
.get();
const thirdCooldownMs = Date.parse(String(thirdRecord?.cooldownUntil || '')) - thirdStartedAt;
expect(thirdCooldownMs).toBeGreaterThanOrEqual(25_000);
expect(thirdCooldownMs).toBeLessThanOrEqual(35_000);
});
});
+21 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { filterRecentlyFailedCandidates } from './tokenRouter.js';
import { filterRecentlyFailedCandidates, isChannelRecentlyFailed } from './tokenRouter.js';
type Candidate = {
channel: {
@@ -10,6 +10,26 @@ type Candidate = {
};
describe('filterRecentlyFailedCandidates', () => {
it('uses a short default recent-failure window', () => {
const nowMs = Date.now();
expect(isChannelRecentlyFailed({
failCount: 1,
lastFailAt: new Date(nowMs - 20 * 1000).toISOString(),
}, nowMs)).toBe(false);
});
it('expands the avoidance window with fibonacci-style backoff', () => {
const nowMs = Date.now();
expect(isChannelRecentlyFailed({
failCount: 4,
lastFailAt: new Date(nowMs - 40 * 1000).toISOString(),
}, nowMs)).toBe(true);
expect(isChannelRecentlyFailed({
failCount: 4,
lastFailAt: new Date(nowMs - 50 * 1000).toISOString(),
}, nowMs)).toBe(false);
});
it('prefers healthy channels when at least one healthy channel exists', () => {
const nowMs = Date.now();
const candidates: Candidate[] = [
+57 -43
View File
@@ -27,13 +27,30 @@ interface SelectedChannel {
actualModel: string;
}
type FailureAwareChannel = {
failCount?: number | null;
lastFailAt?: string | null;
};
const RECENT_FAILURE_AVOID_SEC = 10 * 60;
const MIN_EFFECTIVE_UNIT_COST = 1e-6;
type FailureAwareChannel = {
failCount?: number | null;
lastFailAt?: string | null;
};
const FAILURE_BACKOFF_BASE_SEC = 15;
const MIN_EFFECTIVE_UNIT_COST = 1e-6;
function fibonacciNumber(index: number): number {
if (index <= 2) return 1;
let prev = 1;
let current = 1;
for (let i = 3; i <= index; i += 1) {
const next = prev + current;
prev = current;
current = next;
}
return current;
}
function resolveFailureBackoffSec(failCount?: number | null): number {
const normalizedFailCount = Math.max(1, Math.trunc(failCount ?? 0));
return FAILURE_BACKOFF_BASE_SEC * fibonacciNumber(normalizedFailCount);
}
type RouteRow = typeof schema.tokenRoutes.$inferSelect;
type ChannelRow = typeof schema.routeChannels.$inferSelect;
@@ -130,11 +147,11 @@ function isSiteDisabled(status?: string | null): boolean {
return (status || 'active') === 'disabled';
}
export function isChannelRecentlyFailed(
channel: FailureAwareChannel,
nowMs = Date.now(),
avoidSec = RECENT_FAILURE_AVOID_SEC,
): boolean {
export function isChannelRecentlyFailed(
channel: FailureAwareChannel,
nowMs = Date.now(),
avoidSec = resolveFailureBackoffSec(channel.failCount),
): boolean {
if (avoidSec <= 0) return false;
if ((channel.failCount ?? 0) <= 0) return false;
if (!channel.lastFailAt) return false;
@@ -145,15 +162,15 @@ export function isChannelRecentlyFailed(
return nowMs - failTs < avoidSec * 1000;
}
export function filterRecentlyFailedCandidates<T extends { channel: FailureAwareChannel }>(
candidates: T[],
nowMs = Date.now(),
avoidSec = RECENT_FAILURE_AVOID_SEC,
): T[] {
export function filterRecentlyFailedCandidates<T extends { channel: FailureAwareChannel }>(
candidates: T[],
nowMs = Date.now(),
avoidSec?: number,
): T[] {
if (candidates.length <= 1) return candidates;
if (avoidSec <= 0) return candidates;
const healthy = candidates.filter((candidate) => !isChannelRecentlyFailed(candidate.channel, nowMs, avoidSec));
const healthy = candidates.filter((candidate) => !isChannelRecentlyFailed(candidate.channel, nowMs, avoidSec));
// If all channels failed recently, keep them all and let weight/random decide.
return healthy.length > 0 ? healthy : candidates;
}
@@ -421,11 +438,10 @@ export class TokenRouter {
// Try each priority layer
for (const priority of sortedPriorities) {
const candidates = filterRecentlyFailedCandidates(
layers.get(priority)!,
nowMs,
RECENT_FAILURE_AVOID_SEC,
);
const candidates = filterRecentlyFailedCandidates(
layers.get(priority)!,
nowMs,
);
const selected = this.weightedRandomSelect(
candidates,
requestedByDisplayName
@@ -494,11 +510,10 @@ export class TokenRouter {
const sortedPriorities = Array.from(layers.keys()).sort((a, b) => a - b);
for (const priority of sortedPriorities) {
const candidates = filterRecentlyFailedCandidates(
layers.get(priority)!,
nowMs,
RECENT_FAILURE_AVOID_SEC,
);
const candidates = filterRecentlyFailedCandidates(
layers.get(priority)!,
nowMs,
);
const selected = this.weightedRandomSelect(
candidates,
requestedByDisplayName
@@ -631,7 +646,7 @@ export class TokenRouter {
nowIso,
});
const recentlyFailed = isChannelRecentlyFailed(row.channel, nowMs, RECENT_FAILURE_AVOID_SEC);
const recentlyFailed = isChannelRecentlyFailed(row.channel, nowMs);
const eligible = reasonParts.length === 0;
const candidate: RouteDecisionCandidate = {
channelId: row.channel.id,
@@ -678,16 +693,16 @@ export class TokenRouter {
const rawLayer = availableByPriority.get(priority) ?? [];
if (rawLayer.length === 0) continue;
const filteredLayer = filterRecentlyFailedCandidates(rawLayer, nowMs, RECENT_FAILURE_AVOID_SEC);
const filteredLayer = filterRecentlyFailedCandidates(rawLayer, nowMs);
const avoided = rawLayer.filter((row) => !filteredLayer.some((item) => item.channel.id === row.channel.id));
if (avoided.length > 0) {
for (const row of avoided) {
const target = candidateMap.get(row.channel.id);
if (!target) continue;
target.avoidedByRecentFailure = true;
target.reason = `最近失败,优先避让(${RECENT_FAILURE_AVOID_SEC / 60} 分钟窗口)`;
}
}
const target = candidateMap.get(row.channel.id);
if (!target) continue;
target.avoidedByRecentFailure = true;
target.reason = `最近失败,优先避让(${resolveFailureBackoffSec(row.channel.failCount)} 秒窗口)`;
}
}
const weighted = this.calculateWeightedSelection(
filteredLayer,
@@ -829,13 +844,12 @@ export class TokenRouter {
/**
* Record failure and set cooldown.
*/
async recordFailure(channelId: number) {
const ch = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
if (!ch) return;
const failCount = (ch.failCount ?? 0) + 1;
// Exponential backoff cooldown: 30s, 60s, 120s, 240s, max 5min
const cooldownSec = Math.min(30 * Math.pow(2, failCount - 1), 300);
const cooldownUntil = new Date(Date.now() + cooldownSec * 1000).toISOString();
async recordFailure(channelId: number) {
const ch = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
if (!ch) return;
const failCount = (ch.failCount ?? 0) + 1;
const cooldownSec = resolveFailureBackoffSec(failCount);
const cooldownUntil = new Date(Date.now() + cooldownSec * 1000).toISOString();
const nowIso = new Date().toISOString();
await db.update(schema.routeChannels).set({
failCount,