chore: initialize clean metapi repository
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
decryptAccountPassword,
|
||||
encryptAccountPassword,
|
||||
} from './accountCredentialService.js';
|
||||
|
||||
describe('accountCredentialService', () => {
|
||||
it('encrypts and decrypts password values', () => {
|
||||
const cipher = encryptAccountPassword('P@ssw0rd-123');
|
||||
expect(cipher).toBeTruthy();
|
||||
expect(cipher).not.toContain('P@ssw0rd-123');
|
||||
expect(decryptAccountPassword(cipher)).toBe('P@ssw0rd-123');
|
||||
});
|
||||
|
||||
it('returns null for malformed cipher text', () => {
|
||||
expect(decryptAccountPassword('invalid-cipher')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const VERSION = 'v1';
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
|
||||
function buildKey(): Buffer {
|
||||
const secret = (config.accountCredentialSecret || '').trim() || config.authToken || 'change-me';
|
||||
return createHash('sha256').update(secret).digest();
|
||||
}
|
||||
|
||||
export function encryptAccountPassword(password: string): string {
|
||||
const key = buildKey();
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(password, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return [
|
||||
VERSION,
|
||||
iv.toString('base64url'),
|
||||
tag.toString('base64url'),
|
||||
encrypted.toString('base64url'),
|
||||
].join(':');
|
||||
}
|
||||
|
||||
export function decryptAccountPassword(cipherText: string): string | null {
|
||||
const parts = (cipherText || '').split(':');
|
||||
if (parts.length !== 4 || parts[0] !== VERSION) return null;
|
||||
|
||||
try {
|
||||
const [, ivRaw, tagRaw, dataRaw] = parts;
|
||||
const key = buildKey();
|
||||
const iv = Buffer.from(ivRaw, 'base64url');
|
||||
const tag = Buffer.from(tagRaw, 'base64url');
|
||||
const data = Buffer.from(dataRaw, 'base64url');
|
||||
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const plain = Buffer.concat([decipher.update(data), decipher.final()]);
|
||||
return plain.toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getPlatformUserIdFromExtraConfig,
|
||||
guessPlatformUserIdFromUsername,
|
||||
mergeAccountExtraConfig,
|
||||
resolvePlatformUserId,
|
||||
} from './accountExtraConfig.js';
|
||||
|
||||
describe('accountExtraConfig', () => {
|
||||
it('reads platformUserId from extra config when present', () => {
|
||||
expect(getPlatformUserIdFromExtraConfig(JSON.stringify({ platformUserId: 11494 }))).toBe(11494);
|
||||
expect(getPlatformUserIdFromExtraConfig(JSON.stringify({ platformUserId: '7659' }))).toBe(7659);
|
||||
});
|
||||
|
||||
it('guesses platformUserId from username suffix digits', () => {
|
||||
expect(guessPlatformUserIdFromUsername('linuxdo_7659')).toBe(7659);
|
||||
expect(guessPlatformUserIdFromUsername('user11494')).toBe(11494);
|
||||
expect(guessPlatformUserIdFromUsername('abc')).toBeUndefined();
|
||||
expect(guessPlatformUserIdFromUsername('id_12')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prefers configured user id over guessed user id', () => {
|
||||
expect(resolvePlatformUserId(JSON.stringify({ platformUserId: 5001 }), 'linuxdo_7659')).toBe(5001);
|
||||
});
|
||||
|
||||
it('merges platformUserId into existing config without dropping keys', () => {
|
||||
const merged = mergeAccountExtraConfig(
|
||||
JSON.stringify({
|
||||
foo: 'bar',
|
||||
autoRelogin: { username: 'demo', passwordCipher: 'cipher' },
|
||||
}),
|
||||
{ platformUserId: 7659 },
|
||||
);
|
||||
|
||||
expect(merged).toBeTruthy();
|
||||
const parsed = JSON.parse(merged!);
|
||||
expect(parsed.foo).toBe('bar');
|
||||
expect(parsed.autoRelogin?.username).toBe('demo');
|
||||
expect(parsed.platformUserId).toBe(7659);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
type AutoReloginConfig = {
|
||||
username?: unknown;
|
||||
passwordCipher?: unknown;
|
||||
updatedAt?: unknown;
|
||||
};
|
||||
|
||||
type AccountExtraConfig = {
|
||||
platformUserId?: unknown;
|
||||
autoRelogin?: AutoReloginConfig;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function parseExtraConfig(extraConfig?: string | null): AccountExtraConfig {
|
||||
if (!extraConfig) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(extraConfig) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
||||
return parsed as AccountExtraConfig;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUserId(raw: unknown): number | undefined {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) return Math.trunc(raw);
|
||||
if (typeof raw === 'string') {
|
||||
const n = Number.parseInt(raw.trim(), 10);
|
||||
if (!Number.isNaN(n) && n > 0) return n;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getPlatformUserIdFromExtraConfig(extraConfig?: string | null): number | undefined {
|
||||
const parsed = parseExtraConfig(extraConfig);
|
||||
return normalizeUserId(parsed.platformUserId);
|
||||
}
|
||||
|
||||
export function guessPlatformUserIdFromUsername(username?: string | null): number | undefined {
|
||||
const text = (username || '').trim();
|
||||
if (!text) return undefined;
|
||||
const match = text.match(/(\d{3,8})$/);
|
||||
if (!match?.[1]) return undefined;
|
||||
return normalizeUserId(match[1]);
|
||||
}
|
||||
|
||||
export function resolvePlatformUserId(extraConfig?: string | null, username?: string | null): number | undefined {
|
||||
return getPlatformUserIdFromExtraConfig(extraConfig) || guessPlatformUserIdFromUsername(username);
|
||||
}
|
||||
|
||||
export function mergeAccountExtraConfig(
|
||||
extraConfig: string | null | undefined,
|
||||
patch: Record<string, unknown>,
|
||||
): string {
|
||||
const merged: Record<string, unknown> = {
|
||||
...parseExtraConfig(extraConfig),
|
||||
...patch,
|
||||
};
|
||||
return JSON.stringify(merged);
|
||||
}
|
||||
|
||||
export function getAutoReloginConfig(extraConfig?: string | null): {
|
||||
username: string;
|
||||
passwordCipher: string;
|
||||
} | null {
|
||||
const parsed = parseExtraConfig(extraConfig);
|
||||
const relogin = parsed.autoRelogin;
|
||||
if (!relogin || typeof relogin !== 'object' || Array.isArray(relogin)) return null;
|
||||
|
||||
const username = typeof relogin.username === 'string' ? relogin.username.trim() : '';
|
||||
const passwordCipher = typeof relogin.passwordCipher === 'string' ? relogin.passwordCipher.trim() : '';
|
||||
if (!username || !passwordCipher) return null;
|
||||
|
||||
return { username, passwordCipher };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildRuntimeHealthForAccount } from './accountHealthService.js';
|
||||
|
||||
describe('accountHealthService', () => {
|
||||
it('marks disabled when site or account is disabled', () => {
|
||||
expect(
|
||||
buildRuntimeHealthForAccount({ accountStatus: 'active', siteStatus: 'disabled', extraConfig: null }).state,
|
||||
).toBe('disabled');
|
||||
expect(
|
||||
buildRuntimeHealthForAccount({ accountStatus: 'disabled', siteStatus: 'active', extraConfig: null }).state,
|
||||
).toBe('disabled');
|
||||
});
|
||||
|
||||
it('marks unhealthy when account is expired', () => {
|
||||
const health = buildRuntimeHealthForAccount({
|
||||
accountStatus: 'expired',
|
||||
siteStatus: 'active',
|
||||
extraConfig: null,
|
||||
});
|
||||
expect(health.state).toBe('unhealthy');
|
||||
});
|
||||
|
||||
it('returns stored runtime health from extra config when available', () => {
|
||||
const health = buildRuntimeHealthForAccount({
|
||||
accountStatus: 'active',
|
||||
siteStatus: 'active',
|
||||
extraConfig: JSON.stringify({
|
||||
runtimeHealth: {
|
||||
state: 'healthy',
|
||||
reason: '余额刷新成功',
|
||||
source: 'balance',
|
||||
checkedAt: '2026-02-25T12:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(health).toMatchObject({
|
||||
state: 'healthy',
|
||||
reason: '余额刷新成功',
|
||||
source: 'balance',
|
||||
checkedAt: '2026-02-25T12:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to unknown when no runtime health info exists', () => {
|
||||
const health = buildRuntimeHealthForAccount({
|
||||
accountStatus: 'active',
|
||||
siteStatus: 'active',
|
||||
extraConfig: null,
|
||||
});
|
||||
expect(health).toMatchObject({
|
||||
state: 'unknown',
|
||||
source: 'none',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { mergeAccountExtraConfig } from './accountExtraConfig.js';
|
||||
|
||||
export type RuntimeHealthState = 'healthy' | 'unhealthy' | 'degraded' | 'unknown' | 'disabled';
|
||||
|
||||
type RuntimeHealthInfo = {
|
||||
state: RuntimeHealthState;
|
||||
reason: string;
|
||||
source: string;
|
||||
checkedAt: string | null;
|
||||
};
|
||||
|
||||
const VALID_RUNTIME_HEALTH_STATES = new Set<RuntimeHealthState>([
|
||||
'healthy',
|
||||
'unhealthy',
|
||||
'degraded',
|
||||
'unknown',
|
||||
'disabled',
|
||||
]);
|
||||
|
||||
function parseObject(value: string | null | undefined): Record<string, unknown> {
|
||||
if (!value) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRuntimeHealthState(value: unknown): RuntimeHealthState | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!VALID_RUNTIME_HEALTH_STATES.has(normalized as RuntimeHealthState)) return null;
|
||||
return normalized as RuntimeHealthState;
|
||||
}
|
||||
|
||||
function normalizeRuntimeHealthRecord(raw: unknown): RuntimeHealthInfo | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const source = raw as Record<string, unknown>;
|
||||
const state = normalizeRuntimeHealthState(source.state);
|
||||
if (!state) return null;
|
||||
|
||||
const reason = typeof source.reason === 'string'
|
||||
? source.reason.trim().slice(0, 500)
|
||||
: '';
|
||||
const checkedAt = typeof source.checkedAt === 'string' && source.checkedAt.trim()
|
||||
? source.checkedAt.trim()
|
||||
: null;
|
||||
const eventSource = typeof source.source === 'string' && source.source.trim()
|
||||
? source.source.trim().slice(0, 64)
|
||||
: 'unknown';
|
||||
|
||||
return {
|
||||
state,
|
||||
reason: reason || defaultHealthReason(state),
|
||||
source: eventSource,
|
||||
checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultHealthReason(state: RuntimeHealthState): string {
|
||||
switch (state) {
|
||||
case 'healthy':
|
||||
return '运行状态正常';
|
||||
case 'unhealthy':
|
||||
return '最近一次检查失败';
|
||||
case 'degraded':
|
||||
return '运行状态波动';
|
||||
case 'disabled':
|
||||
return '账号或站点已禁用';
|
||||
case 'unknown':
|
||||
default:
|
||||
return '尚未检测';
|
||||
}
|
||||
}
|
||||
|
||||
export function extractRuntimeHealth(extraConfig?: string | null): RuntimeHealthInfo | null {
|
||||
const parsed = parseObject(extraConfig);
|
||||
return normalizeRuntimeHealthRecord(parsed.runtimeHealth);
|
||||
}
|
||||
|
||||
export function buildRuntimeHealthForAccount(input: {
|
||||
accountStatus?: string | null;
|
||||
siteStatus?: string | null;
|
||||
extraConfig?: string | null;
|
||||
}): RuntimeHealthInfo {
|
||||
const accountStatus = (input.accountStatus || 'active').toLowerCase();
|
||||
const siteStatus = (input.siteStatus || 'active').toLowerCase();
|
||||
|
||||
if (accountStatus === 'disabled' || siteStatus === 'disabled') {
|
||||
return {
|
||||
state: 'disabled',
|
||||
reason: defaultHealthReason('disabled'),
|
||||
source: 'system',
|
||||
checkedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (accountStatus === 'expired') {
|
||||
return {
|
||||
state: 'unhealthy',
|
||||
reason: '访问令牌已过期',
|
||||
source: 'auth',
|
||||
checkedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
const stored = extractRuntimeHealth(input.extraConfig);
|
||||
if (stored) return stored;
|
||||
|
||||
return {
|
||||
state: 'unknown',
|
||||
reason: defaultHealthReason('unknown'),
|
||||
source: 'none',
|
||||
checkedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRuntimeHealthPatch(input: {
|
||||
state: RuntimeHealthState;
|
||||
reason?: string | null;
|
||||
source?: string | null;
|
||||
checkedAt?: string | null;
|
||||
}): RuntimeHealthInfo {
|
||||
const state = normalizeRuntimeHealthState(input.state) || 'unknown';
|
||||
const reason = (input.reason || '').trim().slice(0, 500) || defaultHealthReason(state);
|
||||
const source = (input.source || '').trim().slice(0, 64) || 'manual';
|
||||
const checkedAt = (input.checkedAt || '').trim() || new Date().toISOString();
|
||||
|
||||
return {
|
||||
state,
|
||||
reason,
|
||||
source,
|
||||
checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function applyRuntimeHealthToExtraConfig(extraConfig: string | null | undefined, health: RuntimeHealthInfo): string {
|
||||
return mergeAccountExtraConfig(extraConfig, {
|
||||
runtimeHealth: health,
|
||||
});
|
||||
}
|
||||
|
||||
export function setAccountRuntimeHealth(
|
||||
accountId: number,
|
||||
input: {
|
||||
state: RuntimeHealthState;
|
||||
reason?: string | null;
|
||||
source?: string | null;
|
||||
checkedAt?: string | null;
|
||||
},
|
||||
): RuntimeHealthInfo | null {
|
||||
try {
|
||||
const query = db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId)) as any;
|
||||
const account = typeof query?.get === 'function' ? query.get() : null;
|
||||
if (!account) return null;
|
||||
|
||||
const health = buildRuntimeHealthPatch(input);
|
||||
const nextExtraConfig = applyRuntimeHealthToExtraConfig(account.extraConfig, health);
|
||||
|
||||
db.update(schema.accounts)
|
||||
.set({
|
||||
extraConfig: nextExtraConfig,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(schema.accounts.id, accountId))
|
||||
.run();
|
||||
|
||||
return health;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { maskToken, normalizeTokenForDisplay } from './accountTokenService.js';
|
||||
|
||||
describe('maskToken', () => {
|
||||
it('keeps sk- prefix for short tokens', () => {
|
||||
const masked = maskToken('sk-abcde');
|
||||
expect(masked.startsWith('sk-')).toBe(true);
|
||||
expect(masked.includes('***')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps sk- prefix for long tokens', () => {
|
||||
const masked = maskToken('sk-proj-abcdefghijklmnopqrstuvwxyz123456');
|
||||
expect(masked.startsWith('sk-')).toBe(true);
|
||||
expect(masked.includes('***')).toBe(true);
|
||||
});
|
||||
|
||||
it('adds sk- prefix for new-api display when upstream key misses prefix', () => {
|
||||
const masked = maskToken('5Uh6KjAgVOqQxNv97MAS7abkBf7Fg5GphgBXUpAOuvq2IvL4', 'new-api');
|
||||
expect(masked.startsWith('sk-')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeTokenForDisplay', () => {
|
||||
it('adds sk- for any platform when token misses prefix', () => {
|
||||
expect(normalizeTokenForDisplay('abc123', 'new-api')).toBe('sk-abc123');
|
||||
expect(normalizeTokenForDisplay('xyz789', 'one-api')).toBe('sk-xyz789');
|
||||
expect(normalizeTokenForDisplay('pqr456', 'anyrouter')).toBe('sk-pqr456');
|
||||
expect(normalizeTokenForDisplay('uvw000', 'veloera')).toBe('sk-uvw000');
|
||||
});
|
||||
|
||||
it('keeps existing sk- token unchanged', () => {
|
||||
expect(normalizeTokenForDisplay('sk-abc123', 'veloera')).toBe('sk-abc123');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { and, eq, ne } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
|
||||
type UpstreamApiToken = {
|
||||
name?: string | null;
|
||||
key?: string | null;
|
||||
enabled?: boolean | null;
|
||||
};
|
||||
|
||||
export function normalizeTokenForDisplay(token?: string | null, platform?: string | null): string {
|
||||
if (!token) return '';
|
||||
const value = token.trim();
|
||||
if (!value) return '';
|
||||
if (platform !== undefined) {
|
||||
// Keep the parameter for route-level compatibility; display rule is now global.
|
||||
}
|
||||
if (!value.toLowerCase().startsWith('sk-')) {
|
||||
return `sk-${value}`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function maskToken(token?: string | null, platform?: string | null): string {
|
||||
const value = normalizeTokenForDisplay(token, platform);
|
||||
if (!value) return '';
|
||||
if (value.toLowerCase().startsWith('sk-')) {
|
||||
if (value.length <= 7) return 'sk-***';
|
||||
const visibleMiddle = value.slice(3, Math.min(6, value.length));
|
||||
if (value.length <= 12) return `sk-${visibleMiddle}***${value.slice(-2)}`;
|
||||
return `sk-${visibleMiddle}***${value.slice(-4)}`;
|
||||
}
|
||||
if (value.length <= 10) return `${value.slice(0, 2)}***${value.slice(-2)}`;
|
||||
return `${value.slice(0, 4)}***${value.slice(-4)}`;
|
||||
}
|
||||
|
||||
function normalizeTokenName(name: string | null | undefined, fallbackIndex = 1): string {
|
||||
const trimmed = (name || '').trim();
|
||||
if (trimmed) return trimmed;
|
||||
return fallbackIndex === 1 ? 'default' : `token-${fallbackIndex}`;
|
||||
}
|
||||
|
||||
function normalizeTokenValue(token: string | null | undefined): string | null {
|
||||
const trimmed = (token || '').trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function updateAccountApiToken(accountId: number, tokenValue: string | null) {
|
||||
db.update(schema.accounts)
|
||||
.set({ apiToken: tokenValue || null, updatedAt: new Date().toISOString() })
|
||||
.where(eq(schema.accounts.id, accountId))
|
||||
.run();
|
||||
}
|
||||
|
||||
export function getPreferredAccountToken(accountId: number) {
|
||||
const tokens = db.select()
|
||||
.from(schema.accountTokens)
|
||||
.where(and(eq(schema.accountTokens.accountId, accountId), eq(schema.accountTokens.enabled, true)))
|
||||
.all();
|
||||
|
||||
if (tokens.length === 0) return null;
|
||||
|
||||
const preferred = tokens.find((t) => t.isDefault) || tokens[0];
|
||||
return preferred;
|
||||
}
|
||||
|
||||
export function ensureDefaultTokenForAccount(
|
||||
accountId: number,
|
||||
tokenValue: string,
|
||||
options?: { name?: string; source?: string; enabled?: boolean },
|
||||
): number | null {
|
||||
const normalizedToken = normalizeTokenValue(tokenValue);
|
||||
if (!normalizedToken) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const tokens = db.select()
|
||||
.from(schema.accountTokens)
|
||||
.where(eq(schema.accountTokens.accountId, accountId))
|
||||
.all();
|
||||
|
||||
let target = tokens.find((t) => t.token === normalizedToken) || null;
|
||||
if (!target) {
|
||||
target = db.insert(schema.accountTokens)
|
||||
.values({
|
||||
accountId,
|
||||
name: normalizeTokenName(options?.name, tokens.length + 1),
|
||||
token: normalizedToken,
|
||||
source: options?.source || 'manual',
|
||||
enabled: options?.enabled ?? true,
|
||||
isDefault: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
} else {
|
||||
db.update(schema.accountTokens)
|
||||
.set({
|
||||
name: options?.name ? normalizeTokenName(options.name) : target.name,
|
||||
source: options?.source || target.source || 'manual',
|
||||
enabled: options?.enabled ?? target.enabled,
|
||||
isDefault: true,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(schema.accountTokens.id, target.id))
|
||||
.run();
|
||||
}
|
||||
|
||||
db.update(schema.accountTokens)
|
||||
.set({ isDefault: false, updatedAt: now })
|
||||
.where(and(eq(schema.accountTokens.accountId, accountId), ne(schema.accountTokens.id, target.id)))
|
||||
.run();
|
||||
|
||||
updateAccountApiToken(accountId, normalizedToken);
|
||||
return target.id;
|
||||
}
|
||||
|
||||
export function setDefaultToken(tokenId: number): boolean {
|
||||
const target = db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get();
|
||||
if (!target) return false;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
db.update(schema.accountTokens)
|
||||
.set({ isDefault: false, updatedAt: now })
|
||||
.where(eq(schema.accountTokens.accountId, target.accountId))
|
||||
.run();
|
||||
|
||||
db.update(schema.accountTokens)
|
||||
.set({ isDefault: true, enabled: true, updatedAt: now })
|
||||
.where(eq(schema.accountTokens.id, tokenId))
|
||||
.run();
|
||||
|
||||
updateAccountApiToken(target.accountId, target.token);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function repairDefaultToken(accountId: number) {
|
||||
const tokens = db.select()
|
||||
.from(schema.accountTokens)
|
||||
.where(eq(schema.accountTokens.accountId, accountId))
|
||||
.all();
|
||||
|
||||
const enabled = tokens.filter((t) => t.enabled);
|
||||
if (enabled.length === 0) {
|
||||
updateAccountApiToken(accountId, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentDefault = enabled.find((t) => t.isDefault) || enabled[0];
|
||||
const now = new Date().toISOString();
|
||||
|
||||
db.update(schema.accountTokens)
|
||||
.set({ isDefault: false, updatedAt: now })
|
||||
.where(eq(schema.accountTokens.accountId, accountId))
|
||||
.run();
|
||||
|
||||
db.update(schema.accountTokens)
|
||||
.set({ isDefault: true, enabled: true, updatedAt: now })
|
||||
.where(eq(schema.accountTokens.id, currentDefault.id))
|
||||
.run();
|
||||
|
||||
updateAccountApiToken(accountId, currentDefault.token);
|
||||
return currentDefault;
|
||||
}
|
||||
|
||||
export function syncTokensFromUpstream(accountId: number, upstreamTokens: UpstreamApiToken[]) {
|
||||
const now = new Date().toISOString();
|
||||
const existing = db.select()
|
||||
.from(schema.accountTokens)
|
||||
.where(eq(schema.accountTokens.accountId, accountId))
|
||||
.all();
|
||||
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let index = existing.length + 1;
|
||||
|
||||
for (const upstream of upstreamTokens) {
|
||||
const tokenValue = normalizeTokenValue(upstream.key);
|
||||
if (!tokenValue) continue;
|
||||
|
||||
const tokenName = normalizeTokenName(upstream.name, index);
|
||||
const enabled = upstream.enabled ?? true;
|
||||
|
||||
const byToken = existing.find((row) => row.token === tokenValue);
|
||||
if (byToken) {
|
||||
db.update(schema.accountTokens)
|
||||
.set({
|
||||
name: tokenName,
|
||||
source: 'sync',
|
||||
enabled,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(schema.accountTokens.id, byToken.id))
|
||||
.run();
|
||||
byToken.name = tokenName;
|
||||
byToken.enabled = enabled;
|
||||
byToken.source = 'sync';
|
||||
byToken.updatedAt = now;
|
||||
updated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const createdRow = db.insert(schema.accountTokens)
|
||||
.values({
|
||||
accountId,
|
||||
name: tokenName,
|
||||
token: tokenValue,
|
||||
source: 'sync',
|
||||
enabled,
|
||||
isDefault: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
existing.push(createdRow);
|
||||
created++;
|
||||
index++;
|
||||
}
|
||||
|
||||
const repaired = repairDefaultToken(accountId);
|
||||
|
||||
return {
|
||||
created,
|
||||
updated,
|
||||
total: existing.length,
|
||||
defaultTokenId: repaired?.id || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function listTokensWithRelations(accountId?: number) {
|
||||
const base = db.select()
|
||||
.from(schema.accountTokens)
|
||||
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id));
|
||||
|
||||
const rows = accountId
|
||||
? base.where(eq(schema.accountTokens.accountId, accountId)).all()
|
||||
: base.all();
|
||||
|
||||
return rows.map((row) => {
|
||||
const { token, ...tokenMeta } = row.account_tokens;
|
||||
return {
|
||||
...tokenMeta,
|
||||
tokenMasked: maskToken(token, row.sites.platform),
|
||||
account: {
|
||||
id: row.accounts.id,
|
||||
username: row.accounts.username,
|
||||
status: row.accounts.status,
|
||||
},
|
||||
site: {
|
||||
id: row.sites.id,
|
||||
name: row.sites.name,
|
||||
url: row.sites.url,
|
||||
platform: row.sites.platform,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isCloudflareChallenge, isTokenExpiredError } from './alertRules.js';
|
||||
|
||||
describe('alertRules', () => {
|
||||
it('detects cloudflare challenge messages', () => {
|
||||
expect(isCloudflareChallenge('Cloudflare challenge detected')).toBe(true);
|
||||
expect(isCloudflareChallenge('cf challenge required')).toBe(true);
|
||||
expect(isCloudflareChallenge('invalid token')).toBe(false);
|
||||
});
|
||||
|
||||
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({ message: 'jwt expired' })).toBe(true);
|
||||
expect(isTokenExpiredError({ message: 'token invalid' })).toBe(true);
|
||||
expect(isTokenExpiredError({ message: 'invalid access token' })).toBe(true);
|
||||
expect(isTokenExpiredError({ message: 'Token 无效' })).toBe(true);
|
||||
expect(isTokenExpiredError({ message: '无权进行此操作,未登录且未提供 access token' })).toBe(false);
|
||||
expect(isTokenExpiredError({ status: 500, message: 'upstream error' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
export function isCloudflareChallenge(message?: string | null): boolean {
|
||||
if (!message) return false;
|
||||
const text = message.toLowerCase();
|
||||
return text.includes('cloudflare') || text.includes('cf challenge') || text.includes('challenge required');
|
||||
}
|
||||
|
||||
export function isTokenExpiredError(input: { status?: number; message?: string | null }): boolean {
|
||||
if (input.status === 401 || input.status === 403) return true;
|
||||
const text = (input.message || '').toLowerCase();
|
||||
if (!text) return false;
|
||||
|
||||
// NewAPI-like sites may return this when session context is missing for an action,
|
||||
// which does not always mean the account token is expired.
|
||||
if (text.includes('未登录且未提供 access token')) return false;
|
||||
|
||||
const tokenPhrase = text.includes('token') || text.includes('令牌') || text.includes('访问令牌');
|
||||
const hasInvalid = text.includes('invalid') || text.includes('无效');
|
||||
const hasExpired = text.includes('expired') || text.includes('过期');
|
||||
|
||||
return (
|
||||
text.includes('jwt expired') ||
|
||||
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')
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { sendNotification } from './notifyService.js';
|
||||
import { setAccountRuntimeHealth } from './accountHealthService.js';
|
||||
|
||||
export async function reportTokenExpired(params: {
|
||||
accountId: number;
|
||||
username?: string | null;
|
||||
siteName?: string | null;
|
||||
detail?: string;
|
||||
}) {
|
||||
const accountLabel = params.username || `ID:${params.accountId}`;
|
||||
const siteLabel = params.siteName || 'unknown-site';
|
||||
const detail = params.detail ? ` (${params.detail})` : '';
|
||||
|
||||
db.insert(schema.events).values({
|
||||
type: 'token',
|
||||
title: 'Token 已失效',
|
||||
message: `${accountLabel} @ ${siteLabel} 的 Token 无效或已过期${detail}`,
|
||||
level: 'error',
|
||||
relatedId: params.accountId,
|
||||
relatedType: 'account',
|
||||
}).run();
|
||||
|
||||
db.update(schema.accounts).set({
|
||||
status: 'expired',
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.accounts.id, params.accountId)).run();
|
||||
|
||||
setAccountRuntimeHealth(params.accountId, {
|
||||
state: 'unhealthy',
|
||||
reason: params.detail ? `访问令牌失效:${params.detail}` : '访问令牌失效',
|
||||
source: 'auth',
|
||||
});
|
||||
|
||||
await sendNotification(
|
||||
'Token 已失效',
|
||||
`${accountLabel} @ ${siteLabel} 的 Token 无效或已过期${detail}`,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
|
||||
export async function reportProxyAllFailed(params: { model: string; reason: string }) {
|
||||
db.insert(schema.events).values({
|
||||
type: 'proxy',
|
||||
title: '代理全部失败',
|
||||
message: `模型=${params.model}, 原因=${params.reason}`,
|
||||
level: 'error',
|
||||
relatedType: 'route',
|
||||
}).run();
|
||||
|
||||
await sendNotification(
|
||||
'代理全部失败',
|
||||
`模型=${params.model}, 原因=${params.reason}`,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { sendNotification } from './notifyService.js';
|
||||
|
||||
export type BackgroundTaskStatus = 'pending' | 'running' | 'succeeded' | 'failed';
|
||||
|
||||
export type BackgroundTask = {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
status: BackgroundTaskStatus;
|
||||
message: string;
|
||||
error: string | null;
|
||||
result: unknown;
|
||||
dedupeKey: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
expiresAtMs: number;
|
||||
};
|
||||
|
||||
type TaskMessageTemplate = string | ((task: BackgroundTask) => string);
|
||||
|
||||
type BackgroundTaskStartOptions = {
|
||||
type: string;
|
||||
title: string;
|
||||
dedupeKey?: string;
|
||||
keepMs?: number;
|
||||
notifyOnSuccess?: boolean;
|
||||
notifyOnFailure?: boolean;
|
||||
successTitle?: TaskMessageTemplate;
|
||||
failureTitle?: TaskMessageTemplate;
|
||||
successMessage?: TaskMessageTemplate;
|
||||
failureMessage?: TaskMessageTemplate;
|
||||
};
|
||||
|
||||
const TASK_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
const TASK_CLEANUP_INTERVAL_MS = 60 * 1000;
|
||||
|
||||
const tasks = new Map<string, BackgroundTask>();
|
||||
const dedupeTaskIds = new Map<string, string>();
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function summarizeError(error: unknown): string {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
if (typeof error === 'string' && error.trim()) return error.trim();
|
||||
if (error && typeof error === 'object') {
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return 'unknown error';
|
||||
}
|
||||
}
|
||||
return 'unknown error';
|
||||
}
|
||||
|
||||
function resolveTaskMessage(template: TaskMessageTemplate | undefined, task: BackgroundTask, fallback: string): string {
|
||||
if (typeof template === 'function') {
|
||||
try {
|
||||
const value = template(task);
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
} catch {}
|
||||
return fallback;
|
||||
}
|
||||
if (typeof template === 'string' && template.trim()) return template.trim();
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function setTaskStatus(task: BackgroundTask, patch: Partial<BackgroundTask>) {
|
||||
const next: BackgroundTask = {
|
||||
...task,
|
||||
...patch,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
tasks.set(task.id, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function appendTaskEvent(level: 'info' | 'warning' | 'error', title: string, message: string, taskId: string) {
|
||||
try {
|
||||
db.insert(schema.events).values({
|
||||
type: 'status',
|
||||
title,
|
||||
message,
|
||||
level,
|
||||
relatedType: 'task',
|
||||
createdAt: nowIso(),
|
||||
}).run();
|
||||
} catch {}
|
||||
void taskId;
|
||||
}
|
||||
|
||||
async function runTask(taskId: string, options: BackgroundTaskStartOptions, runner: () => Promise<unknown>) {
|
||||
const initialTask = tasks.get(taskId);
|
||||
if (!initialTask) return;
|
||||
|
||||
let task = setTaskStatus(initialTask, {
|
||||
status: 'running',
|
||||
startedAt: nowIso(),
|
||||
message: `${initialTask.title} 正在执行`,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await runner();
|
||||
task = setTaskStatus(task, {
|
||||
status: 'succeeded',
|
||||
finishedAt: nowIso(),
|
||||
result,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const eventTitle = resolveTaskMessage(options.successTitle, task, `${task.title} 已完成`);
|
||||
const eventMessage = resolveTaskMessage(options.successMessage, task, `${task.title} 已完成`);
|
||||
task = setTaskStatus(task, { message: eventMessage });
|
||||
appendTaskEvent('info', eventTitle, eventMessage, task.id);
|
||||
|
||||
if (options.notifyOnSuccess) {
|
||||
await sendNotification(eventTitle, eventMessage, 'info');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorText = summarizeError(error);
|
||||
task = setTaskStatus(task, {
|
||||
status: 'failed',
|
||||
finishedAt: nowIso(),
|
||||
error: errorText,
|
||||
message: `${task.title} 失败:${errorText}`,
|
||||
});
|
||||
|
||||
const eventTitle = resolveTaskMessage(options.failureTitle, task, `${task.title} 失败`);
|
||||
const eventMessage = resolveTaskMessage(options.failureMessage, task, task.message);
|
||||
task = setTaskStatus(task, { message: eventMessage });
|
||||
appendTaskEvent('error', eventTitle, eventMessage, task.id);
|
||||
|
||||
if (options.notifyOnFailure ?? true) {
|
||||
await sendNotification(eventTitle, eventMessage, 'error');
|
||||
}
|
||||
} finally {
|
||||
if (task.dedupeKey && dedupeTaskIds.get(task.dedupeKey) === task.id) {
|
||||
dedupeTaskIds.delete(task.dedupeKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupExpiredTasks() {
|
||||
const now = Date.now();
|
||||
for (const [taskId, task] of tasks.entries()) {
|
||||
if (task.expiresAtMs <= now) {
|
||||
tasks.delete(taskId);
|
||||
if (task.dedupeKey && dedupeTaskIds.get(task.dedupeKey) === taskId) {
|
||||
dedupeTaskIds.delete(task.dedupeKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupTimer = setInterval(cleanupExpiredTasks, TASK_CLEANUP_INTERVAL_MS);
|
||||
cleanupTimer.unref?.();
|
||||
|
||||
export function startBackgroundTask(
|
||||
options: BackgroundTaskStartOptions,
|
||||
runner: () => Promise<unknown>,
|
||||
): { task: BackgroundTask; reused: boolean } {
|
||||
const dedupeKey = options.dedupeKey?.trim() || '';
|
||||
if (dedupeKey) {
|
||||
const existingTaskId = dedupeTaskIds.get(dedupeKey);
|
||||
if (existingTaskId) {
|
||||
const existing = tasks.get(existingTaskId);
|
||||
if (existing && (existing.status === 'pending' || existing.status === 'running')) {
|
||||
return { task: existing, reused: true };
|
||||
}
|
||||
dedupeTaskIds.delete(dedupeKey);
|
||||
}
|
||||
}
|
||||
|
||||
const createdAt = nowIso();
|
||||
const task: BackgroundTask = {
|
||||
id: randomUUID(),
|
||||
type: options.type,
|
||||
title: options.title,
|
||||
status: 'pending',
|
||||
message: `${options.title} 已开始执行`,
|
||||
error: null,
|
||||
result: null,
|
||||
dedupeKey: dedupeKey || null,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
expiresAtMs: Date.now() + Math.max(60_000, options.keepMs ?? TASK_TTL_MS),
|
||||
};
|
||||
|
||||
tasks.set(task.id, task);
|
||||
if (dedupeKey) dedupeTaskIds.set(dedupeKey, task.id);
|
||||
|
||||
appendTaskEvent('info', `${task.title}已开始`, `${task.title} 已开始执行`, task.id);
|
||||
void runTask(task.id, options, runner);
|
||||
return { task, reused: false };
|
||||
}
|
||||
|
||||
export function getBackgroundTask(taskId: string): BackgroundTask | null {
|
||||
return tasks.get(taskId) || null;
|
||||
}
|
||||
|
||||
export function listBackgroundTasks(limit = 50): BackgroundTask[] {
|
||||
const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.min(200, Math.trunc(limit))) : 50;
|
||||
return Array.from(tasks.values())
|
||||
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
|
||||
.slice(0, safeLimit);
|
||||
}
|
||||
|
||||
export function getRunningTaskByDedupeKey(key: string): BackgroundTask | null {
|
||||
const taskId = dedupeTaskIds.get(key.trim());
|
||||
if (!taskId) return null;
|
||||
const task = tasks.get(taskId);
|
||||
if (!task) return null;
|
||||
if (task.status !== 'pending' && task.status !== 'running') return null;
|
||||
return task;
|
||||
}
|
||||
|
||||
export function summarizeCheckinResults(results: Array<{ result?: any }>): { total: number; success: number; skipped: number; failed: number } {
|
||||
const summary = { total: results.length, success: 0, skipped: 0, failed: 0 };
|
||||
for (const item of results) {
|
||||
const status = item?.result?.status;
|
||||
if (status === 'skipped' || item?.result?.skipped) {
|
||||
summary.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (item?.result?.success) {
|
||||
summary.success += 1;
|
||||
continue;
|
||||
}
|
||||
summary.failed += 1;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
export function __resetBackgroundTasksForTests() {
|
||||
tasks.clear();
|
||||
dedupeTaskIds.clear();
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
import { asc } from 'drizzle-orm';
|
||||
import cron from 'node-cron';
|
||||
import { db, schema } from '../db/index.js';
|
||||
|
||||
const BACKUP_VERSION = '2.0';
|
||||
|
||||
export type BackupExportType = 'all' | 'accounts' | 'preferences';
|
||||
|
||||
type SiteRow = typeof schema.sites.$inferSelect;
|
||||
type AccountRow = typeof schema.accounts.$inferSelect;
|
||||
type AccountTokenRow = typeof schema.accountTokens.$inferSelect;
|
||||
type TokenRouteRow = typeof schema.tokenRoutes.$inferSelect;
|
||||
type RouteChannelRow = typeof schema.routeChannels.$inferSelect;
|
||||
|
||||
interface AccountsBackupSection {
|
||||
sites: SiteRow[];
|
||||
accounts: AccountRow[];
|
||||
accountTokens: AccountTokenRow[];
|
||||
tokenRoutes: TokenRouteRow[];
|
||||
routeChannels: RouteChannelRow[];
|
||||
}
|
||||
|
||||
interface PreferencesBackupSection {
|
||||
settings: Array<{ key: string; value: unknown }>;
|
||||
}
|
||||
|
||||
interface BackupFullV2 {
|
||||
version: string;
|
||||
timestamp: number;
|
||||
accounts: AccountsBackupSection;
|
||||
preferences: PreferencesBackupSection;
|
||||
}
|
||||
|
||||
interface BackupAccountsPartialV2 {
|
||||
version: string;
|
||||
timestamp: number;
|
||||
type: 'accounts';
|
||||
accounts: AccountsBackupSection;
|
||||
}
|
||||
|
||||
interface BackupPreferencesPartialV2 {
|
||||
version: string;
|
||||
timestamp: number;
|
||||
type: 'preferences';
|
||||
preferences: PreferencesBackupSection;
|
||||
}
|
||||
|
||||
type BackupV2 = BackupFullV2 | BackupAccountsPartialV2 | BackupPreferencesPartialV2;
|
||||
|
||||
type RawBackupData = Record<string, unknown>;
|
||||
|
||||
interface BackupImportResult {
|
||||
allImported: boolean;
|
||||
sections: {
|
||||
accounts: boolean;
|
||||
preferences: boolean;
|
||||
};
|
||||
appliedSettings: Array<{ key: string; value: unknown }>;
|
||||
}
|
||||
|
||||
const EXCLUDED_SETTING_KEYS = new Set<string>([
|
||||
// Keep current admin login credential unchanged to avoid accidental lock-out.
|
||||
'auth_token',
|
||||
]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function asBoolean(value: unknown, fallback = false): boolean {
|
||||
if (typeof value === 'boolean') return value;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function toIsoString(value: unknown): string {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const d = new Date(value);
|
||||
if (!Number.isNaN(d.getTime())) return d.toISOString();
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
const d = new Date(value);
|
||||
if (!Number.isNaN(d.getTime())) return d.toISOString();
|
||||
}
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function normalizeLegacyQuota(raw: unknown): number {
|
||||
const value = asNumber(raw, 0);
|
||||
if (!Number.isFinite(value) || value <= 0) return 0;
|
||||
// ref-all-api-hub stores quota in raw units for NewAPI-like sites.
|
||||
// Convert obvious raw values to display currency units.
|
||||
if (value >= 10_000) return value / 500_000;
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeLegacyPlatform(raw: string): string {
|
||||
const value = raw.trim().toLowerCase();
|
||||
if (!value) return 'new-api';
|
||||
|
||||
const supported = new Set([
|
||||
'new-api',
|
||||
'one-api',
|
||||
'anyrouter',
|
||||
'one-hub',
|
||||
'done-hub',
|
||||
'sub2api',
|
||||
'veloera',
|
||||
]);
|
||||
if (supported.has(value)) return value;
|
||||
|
||||
if (value.includes('wong')) return 'new-api';
|
||||
if (value.includes('anyrouter')) return 'anyrouter';
|
||||
if (value.includes('done')) return 'done-hub';
|
||||
|
||||
return 'new-api';
|
||||
}
|
||||
|
||||
function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupSection | null {
|
||||
const accountsContainer = isRecord(data.accounts) ? data.accounts : null;
|
||||
const rows = Array.isArray(accountsContainer?.accounts) ? accountsContainer.accounts : null;
|
||||
if (!rows) return null;
|
||||
|
||||
const sites: SiteRow[] = [];
|
||||
const accounts: AccountRow[] = [];
|
||||
const accountTokens: AccountTokenRow[] = [];
|
||||
const tokenRoutes: TokenRouteRow[] = [];
|
||||
const routeChannels: RouteChannelRow[] = [];
|
||||
|
||||
const siteIdByKey = new Map<string, number>();
|
||||
let nextSiteId = 1;
|
||||
let nextAccountId = 1;
|
||||
let nextTokenId = 1;
|
||||
|
||||
for (const item of rows) {
|
||||
if (!isRecord(item)) continue;
|
||||
|
||||
const siteUrl = asString(item.site_url);
|
||||
if (!siteUrl) continue;
|
||||
|
||||
const platform = normalizeLegacyPlatform(asString(item.site_type));
|
||||
const siteName = asString(item.site_name) || siteUrl;
|
||||
const siteKey = `${platform}::${siteUrl}`;
|
||||
|
||||
let siteId = siteIdByKey.get(siteKey) || 0;
|
||||
if (!siteId) {
|
||||
siteId = nextSiteId++;
|
||||
siteIdByKey.set(siteKey, siteId);
|
||||
sites.push({
|
||||
id: siteId,
|
||||
name: siteName,
|
||||
url: siteUrl,
|
||||
platform,
|
||||
status: 'active',
|
||||
apiKey: null,
|
||||
createdAt: toIsoString(item.created_at),
|
||||
updatedAt: toIsoString(item.updated_at),
|
||||
});
|
||||
}
|
||||
|
||||
const accountInfo = isRecord(item.account_info) ? item.account_info : {};
|
||||
const cookieAuth = isRecord(item.cookieAuth) ? item.cookieAuth : {};
|
||||
const authType = asString(item.authType);
|
||||
|
||||
const accountAccessToken =
|
||||
asString(accountInfo.access_token)
|
||||
|| asString(cookieAuth.sessionCookie)
|
||||
|| asString((item as Record<string, unknown>).access_token);
|
||||
if (!accountAccessToken) continue;
|
||||
|
||||
const platformUserId = asNumber(accountInfo.id, 0);
|
||||
const username = asString(accountInfo.username)
|
||||
|| asString(item.username)
|
||||
|| (platformUserId > 0 ? `user-${platformUserId}` : `account-${nextAccountId}`);
|
||||
|
||||
let apiToken: string | null = null;
|
||||
if (authType === 'api_key') {
|
||||
apiToken = accountAccessToken;
|
||||
}
|
||||
|
||||
const createdAt = toIsoString(item.created_at);
|
||||
const updatedAt = toIsoString(item.updated_at);
|
||||
const checkin = isRecord(item.checkIn) ? item.checkIn : {};
|
||||
const extraConfigPayload = {
|
||||
platformUserId: platformUserId > 0 ? platformUserId : undefined,
|
||||
authType: authType || undefined,
|
||||
source: 'ref-all-api-hub',
|
||||
};
|
||||
|
||||
const accountId = nextAccountId++;
|
||||
const importedBalance = normalizeLegacyQuota(accountInfo.quota);
|
||||
const importedUsed = normalizeLegacyQuota(accountInfo.today_quota_consumption);
|
||||
const importedQuota = importedBalance + importedUsed;
|
||||
|
||||
accounts.push({
|
||||
id: accountId,
|
||||
siteId,
|
||||
username,
|
||||
accessToken: accountAccessToken,
|
||||
apiToken,
|
||||
balance: importedBalance,
|
||||
balanceUsed: importedUsed,
|
||||
quota: importedQuota > 0 ? importedQuota : importedBalance,
|
||||
unitCost: null,
|
||||
valueScore: 0,
|
||||
status: asBoolean(item.disabled, false) ? 'disabled' : 'active',
|
||||
checkinEnabled: asBoolean(checkin.autoCheckInEnabled, true),
|
||||
lastCheckinAt: null,
|
||||
lastBalanceRefresh: null,
|
||||
extraConfig: JSON.stringify(extraConfigPayload),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
if (apiToken) {
|
||||
accountTokens.push({
|
||||
id: nextTokenId++,
|
||||
accountId,
|
||||
name: 'default',
|
||||
token: apiToken,
|
||||
source: 'legacy',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sites,
|
||||
accounts,
|
||||
accountTokens,
|
||||
tokenRoutes,
|
||||
routeChannels,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPreferencesSectionFromRefBackup(data: RawBackupData): PreferencesBackupSection | null {
|
||||
const settings: Array<{ key: string; value: unknown }> = [];
|
||||
|
||||
if (isRecord(data.preferences)) {
|
||||
settings.push({ key: 'legacy_preferences_ref_v2', value: data.preferences });
|
||||
}
|
||||
if (isRecord(data.channelConfigs)) {
|
||||
settings.push({ key: 'legacy_channel_configs_ref_v2', value: data.channelConfigs });
|
||||
}
|
||||
if (isRecord(data.apiCredentialProfiles)) {
|
||||
settings.push({ key: 'legacy_api_credential_profiles_ref_v2', value: data.apiCredentialProfiles });
|
||||
}
|
||||
if (isRecord(data.tagStore)) {
|
||||
settings.push({ key: 'legacy_tag_store_ref_v2', value: data.tagStore });
|
||||
}
|
||||
|
||||
if (settings.length === 0) return null;
|
||||
return { settings };
|
||||
}
|
||||
|
||||
function parseSettingValue(raw: string | null): unknown {
|
||||
if (raw === null || raw === undefined) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function stringifySettingValue(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function isSettingValueAcceptable(key: string, value: unknown): boolean {
|
||||
if (key === 'checkin_cron' || key === 'balance_refresh_cron') {
|
||||
return typeof value === 'string' && cron.validate(value);
|
||||
}
|
||||
|
||||
if (key === 'proxy_token') {
|
||||
return typeof value === 'string'
|
||||
&& value.trim().length >= 6
|
||||
&& value.trim().startsWith('sk-');
|
||||
}
|
||||
|
||||
if (key === 'smtp_port') {
|
||||
return isFiniteNumber(value) && value > 0;
|
||||
}
|
||||
|
||||
if (key === 'routing_weights') {
|
||||
if (!isRecord(value)) return false;
|
||||
const keys = ['baseWeightFactor', 'valueScoreFactor', 'costWeight', 'balanceWeight', 'usageWeight'] as const;
|
||||
return keys.every((weightKey) => value[weightKey] === undefined || isFiniteNumber(value[weightKey]));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function exportAccountsSection(): AccountsBackupSection {
|
||||
const sites = db.select().from(schema.sites).orderBy(asc(schema.sites.id)).all();
|
||||
const accounts = db.select().from(schema.accounts).orderBy(asc(schema.accounts.id)).all();
|
||||
const accountTokens = db.select().from(schema.accountTokens).orderBy(asc(schema.accountTokens.id)).all();
|
||||
const tokenRoutes = db.select().from(schema.tokenRoutes).orderBy(asc(schema.tokenRoutes.id)).all();
|
||||
const routeChannels = db.select().from(schema.routeChannels).orderBy(asc(schema.routeChannels.id)).all();
|
||||
|
||||
return {
|
||||
sites,
|
||||
accounts,
|
||||
accountTokens,
|
||||
tokenRoutes,
|
||||
routeChannels,
|
||||
};
|
||||
}
|
||||
|
||||
function exportPreferencesSection(): PreferencesBackupSection {
|
||||
const settings = db.select().from(schema.settings).all()
|
||||
.filter((row) => !EXCLUDED_SETTING_KEYS.has(row.key))
|
||||
.map((row) => ({
|
||||
key: row.key,
|
||||
value: parseSettingValue(row.value),
|
||||
}));
|
||||
|
||||
return { settings };
|
||||
}
|
||||
|
||||
export function exportBackup(type: BackupExportType): BackupV2 {
|
||||
const now = Date.now();
|
||||
if (type === 'accounts') {
|
||||
return {
|
||||
version: BACKUP_VERSION,
|
||||
timestamp: now,
|
||||
type: 'accounts',
|
||||
accounts: exportAccountsSection(),
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'preferences') {
|
||||
return {
|
||||
version: BACKUP_VERSION,
|
||||
timestamp: now,
|
||||
type: 'preferences',
|
||||
preferences: exportPreferencesSection(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
version: BACKUP_VERSION,
|
||||
timestamp: now,
|
||||
accounts: exportAccountsSection(),
|
||||
preferences: exportPreferencesSection(),
|
||||
};
|
||||
}
|
||||
|
||||
function coerceAccountsSection(input: unknown): AccountsBackupSection | null {
|
||||
if (!isRecord(input)) return null;
|
||||
|
||||
const sites = Array.isArray(input.sites) ? input.sites as SiteRow[] : null;
|
||||
const accounts = Array.isArray(input.accounts) ? input.accounts as AccountRow[] : null;
|
||||
const accountTokens = Array.isArray(input.accountTokens) ? input.accountTokens as AccountTokenRow[] : null;
|
||||
const tokenRoutes = Array.isArray(input.tokenRoutes) ? input.tokenRoutes as TokenRouteRow[] : null;
|
||||
const routeChannels = Array.isArray(input.routeChannels) ? input.routeChannels as RouteChannelRow[] : null;
|
||||
|
||||
if (!sites || !accounts || !accountTokens || !tokenRoutes || !routeChannels) return null;
|
||||
|
||||
return {
|
||||
sites,
|
||||
accounts,
|
||||
accountTokens,
|
||||
tokenRoutes,
|
||||
routeChannels,
|
||||
};
|
||||
}
|
||||
|
||||
function coercePreferencesSection(input: unknown): PreferencesBackupSection | null {
|
||||
if (!isRecord(input)) return null;
|
||||
const settingsRaw = input.settings;
|
||||
if (!Array.isArray(settingsRaw)) return null;
|
||||
|
||||
const settings = settingsRaw
|
||||
.map((row) => {
|
||||
if (!isRecord(row)) return null;
|
||||
const key = typeof row.key === 'string' ? row.key.trim() : '';
|
||||
if (!key || EXCLUDED_SETTING_KEYS.has(key)) return null;
|
||||
return { key, value: row.value };
|
||||
})
|
||||
.filter((row): row is { key: string; value: unknown } => !!row);
|
||||
|
||||
return { settings };
|
||||
}
|
||||
|
||||
function detectAccountsSection(data: RawBackupData): AccountsBackupSection | null {
|
||||
const rootMatch = coerceAccountsSection(data);
|
||||
if (rootMatch) return rootMatch;
|
||||
|
||||
if ('accounts' in data) {
|
||||
const nested = coerceAccountsSection(data.accounts);
|
||||
if (nested) return nested;
|
||||
}
|
||||
|
||||
if (isRecord(data.data) && 'accounts' in data.data) {
|
||||
const legacyNested = coerceAccountsSection((data.data as Record<string, unknown>).accounts);
|
||||
if (legacyNested) return legacyNested;
|
||||
}
|
||||
|
||||
const refFormat = buildAccountsSectionFromRefBackup(data);
|
||||
if (refFormat) return refFormat;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectPreferencesSection(data: RawBackupData): PreferencesBackupSection | null {
|
||||
const rootMatch = coercePreferencesSection(data);
|
||||
if (rootMatch) return rootMatch;
|
||||
|
||||
if ('preferences' in data) {
|
||||
const nested = coercePreferencesSection(data.preferences);
|
||||
if (nested) return nested;
|
||||
}
|
||||
|
||||
if (isRecord(data.data) && 'preferences' in data.data) {
|
||||
const legacyNested = coercePreferencesSection((data.data as Record<string, unknown>).preferences);
|
||||
if (legacyNested) return legacyNested;
|
||||
}
|
||||
|
||||
const refFormat = buildPreferencesSectionFromRefBackup(data);
|
||||
if (refFormat) return refFormat;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function importAccountsSection(section: AccountsBackupSection) {
|
||||
db.transaction((tx) => {
|
||||
tx.delete(schema.routeChannels).run();
|
||||
tx.delete(schema.tokenRoutes).run();
|
||||
tx.delete(schema.tokenModelAvailability).run();
|
||||
tx.delete(schema.modelAvailability).run();
|
||||
tx.delete(schema.proxyLogs).run();
|
||||
tx.delete(schema.checkinLogs).run();
|
||||
tx.delete(schema.accountTokens).run();
|
||||
tx.delete(schema.accounts).run();
|
||||
tx.delete(schema.sites).run();
|
||||
|
||||
for (const row of section.sites) {
|
||||
tx.insert(schema.sites).values({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
url: row.url,
|
||||
platform: row.platform,
|
||||
status: row.status || 'active',
|
||||
apiKey: row.apiKey,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}).run();
|
||||
}
|
||||
|
||||
for (const row of section.accounts) {
|
||||
tx.insert(schema.accounts).values({
|
||||
id: row.id,
|
||||
siteId: row.siteId,
|
||||
username: row.username,
|
||||
accessToken: row.accessToken,
|
||||
apiToken: row.apiToken,
|
||||
balance: row.balance,
|
||||
balanceUsed: row.balanceUsed,
|
||||
quota: row.quota,
|
||||
unitCost: row.unitCost,
|
||||
valueScore: row.valueScore,
|
||||
status: row.status,
|
||||
checkinEnabled: row.checkinEnabled,
|
||||
lastCheckinAt: row.lastCheckinAt,
|
||||
lastBalanceRefresh: row.lastBalanceRefresh,
|
||||
extraConfig: row.extraConfig,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}).run();
|
||||
}
|
||||
|
||||
for (const row of section.accountTokens) {
|
||||
tx.insert(schema.accountTokens).values({
|
||||
id: row.id,
|
||||
accountId: row.accountId,
|
||||
name: row.name,
|
||||
token: row.token,
|
||||
source: row.source,
|
||||
enabled: row.enabled,
|
||||
isDefault: row.isDefault,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}).run();
|
||||
}
|
||||
|
||||
for (const row of section.tokenRoutes) {
|
||||
tx.insert(schema.tokenRoutes).values({
|
||||
id: row.id,
|
||||
modelPattern: row.modelPattern,
|
||||
modelMapping: row.modelMapping,
|
||||
enabled: row.enabled,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}).run();
|
||||
}
|
||||
|
||||
for (const row of section.routeChannels) {
|
||||
tx.insert(schema.routeChannels).values({
|
||||
id: row.id,
|
||||
routeId: row.routeId,
|
||||
accountId: row.accountId,
|
||||
tokenId: row.tokenId,
|
||||
priority: row.priority,
|
||||
weight: row.weight,
|
||||
enabled: row.enabled,
|
||||
manualOverride: row.manualOverride,
|
||||
successCount: row.successCount,
|
||||
failCount: row.failCount,
|
||||
totalLatencyMs: row.totalLatencyMs,
|
||||
totalCost: row.totalCost,
|
||||
lastUsedAt: row.lastUsedAt,
|
||||
lastFailAt: row.lastFailAt,
|
||||
cooldownUntil: row.cooldownUntil,
|
||||
}).run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function importPreferencesSection(section: PreferencesBackupSection): Array<{ key: string; value: unknown }> {
|
||||
const applied: Array<{ key: string; value: unknown }> = [];
|
||||
|
||||
db.transaction((tx) => {
|
||||
for (const row of section.settings) {
|
||||
if (!isSettingValueAcceptable(row.key, row.value)) continue;
|
||||
|
||||
tx.insert(schema.settings).values({
|
||||
key: row.key,
|
||||
value: stringifySettingValue(row.value),
|
||||
}).onConflictDoUpdate({
|
||||
target: schema.settings.key,
|
||||
set: { value: stringifySettingValue(row.value) },
|
||||
}).run();
|
||||
applied.push({ key: row.key, value: row.value });
|
||||
}
|
||||
});
|
||||
|
||||
return applied;
|
||||
}
|
||||
|
||||
export function importBackup(data: RawBackupData): BackupImportResult {
|
||||
if (!isRecord(data)) {
|
||||
throw new Error('导入数据格式错误:必须为 JSON 对象');
|
||||
}
|
||||
|
||||
if (!('timestamp' in data) || data.timestamp === null || data.timestamp === undefined) {
|
||||
throw new Error('导入数据格式错误:缺少 timestamp');
|
||||
}
|
||||
|
||||
const accountsSection = detectAccountsSection(data);
|
||||
const preferencesSection = detectPreferencesSection(data);
|
||||
|
||||
const type = typeof data.type === 'string' ? data.type : '';
|
||||
const accountsRequested = type === 'accounts' || !!accountsSection;
|
||||
const preferencesRequested = type === 'preferences' || !!preferencesSection;
|
||||
|
||||
if (!accountsRequested && !preferencesRequested) {
|
||||
throw new Error('导入数据中没有可识别的账号或设置数据');
|
||||
}
|
||||
|
||||
let accountsImported = false;
|
||||
let preferencesImported = false;
|
||||
let appliedSettings: Array<{ key: string; value: unknown }> = [];
|
||||
|
||||
if (accountsRequested) {
|
||||
if (!accountsSection) {
|
||||
throw new Error('导入数据格式错误:账号数据结构不正确');
|
||||
}
|
||||
importAccountsSection(accountsSection);
|
||||
accountsImported = true;
|
||||
}
|
||||
|
||||
if (preferencesRequested) {
|
||||
if (!preferencesSection) {
|
||||
throw new Error('导入数据格式错误:设置数据结构不正确');
|
||||
}
|
||||
appliedSettings = importPreferencesSection(preferencesSection);
|
||||
preferencesImported = true;
|
||||
}
|
||||
|
||||
return {
|
||||
allImported: (!accountsRequested || accountsImported) && (!preferencesRequested || preferencesImported),
|
||||
sections: {
|
||||
accounts: accountsImported,
|
||||
preferences: preferencesImported,
|
||||
},
|
||||
appliedSettings,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const adapterMock = {
|
||||
getBalance: vi.fn(),
|
||||
login: vi.fn(),
|
||||
};
|
||||
|
||||
const selectAllMock = vi.fn();
|
||||
const updateSetMock = vi.fn();
|
||||
const insertValuesMock = vi.fn();
|
||||
const reportTokenExpiredMock = vi.fn();
|
||||
const sendNotificationMock = vi.fn();
|
||||
const decryptPasswordMock = vi.fn();
|
||||
const setAccountRuntimeHealthMock = vi.fn();
|
||||
const extractRuntimeHealthMock = vi.fn();
|
||||
const undiciFetchMock = vi.fn();
|
||||
|
||||
vi.mock('../db/index.js', () => {
|
||||
const selectChain = {
|
||||
all: () => selectAllMock(),
|
||||
where: () => selectChain,
|
||||
innerJoin: () => selectChain,
|
||||
from: () => selectChain,
|
||||
};
|
||||
|
||||
const updateWhereChain = {
|
||||
run: () => ({}),
|
||||
};
|
||||
|
||||
const updateSetChain = {
|
||||
where: () => updateWhereChain,
|
||||
};
|
||||
|
||||
const insertChain = {
|
||||
run: () => ({}),
|
||||
values: (...args: unknown[]) => {
|
||||
insertValuesMock(...args);
|
||||
return insertChain;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: () => selectChain,
|
||||
update: () => ({
|
||||
set: (updates: Record<string, unknown>) => {
|
||||
updateSetMock(updates);
|
||||
return updateSetChain;
|
||||
},
|
||||
}),
|
||||
insert: () => insertChain,
|
||||
},
|
||||
schema: {
|
||||
accounts: { id: 'id', siteId: 'siteId', status: 'status' },
|
||||
sites: { id: 'id' },
|
||||
events: {},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./platforms/index.js', () => ({
|
||||
getAdapter: () => adapterMock,
|
||||
}));
|
||||
|
||||
vi.mock('./alertService.js', () => ({
|
||||
reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('./notifyService.js', () => ({
|
||||
sendNotification: (...args: unknown[]) => sendNotificationMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('./accountCredentialService.js', () => ({
|
||||
decryptAccountPassword: (...args: unknown[]) => decryptPasswordMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('./accountHealthService.js', () => ({
|
||||
setAccountRuntimeHealth: (...args: unknown[]) => setAccountRuntimeHealthMock(...args),
|
||||
extractRuntimeHealth: (...args: unknown[]) => extractRuntimeHealthMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('undici', () => ({
|
||||
fetch: (...args: unknown[]) => undiciFetchMock(...args),
|
||||
}));
|
||||
|
||||
describe('balanceService auto relogin', () => {
|
||||
beforeEach(() => {
|
||||
adapterMock.getBalance.mockReset();
|
||||
adapterMock.login.mockReset();
|
||||
selectAllMock.mockReset();
|
||||
updateSetMock.mockReset();
|
||||
insertValuesMock.mockReset();
|
||||
reportTokenExpiredMock.mockReset();
|
||||
sendNotificationMock.mockReset();
|
||||
decryptPasswordMock.mockReset();
|
||||
setAccountRuntimeHealthMock.mockReset();
|
||||
extractRuntimeHealthMock.mockReset();
|
||||
undiciFetchMock.mockReset();
|
||||
|
||||
extractRuntimeHealthMock.mockReturnValue(null);
|
||||
undiciFetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({}),
|
||||
});
|
||||
});
|
||||
|
||||
it('retries balance fetch once after successful auto relogin', async () => {
|
||||
selectAllMock.mockReturnValue([
|
||||
{
|
||||
accounts: {
|
||||
id: 1,
|
||||
username: 'linuxdo_11494',
|
||||
accessToken: 'stale-token',
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({
|
||||
platformUserId: 11494,
|
||||
autoRelogin: { username: 'linuxdo_11494', passwordCipher: 'cipher' },
|
||||
}),
|
||||
},
|
||||
sites: {
|
||||
id: 3,
|
||||
name: 'wong',
|
||||
url: 'https://wzw.pp.ua',
|
||||
platform: 'new-api',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
adapterMock.getBalance
|
||||
.mockRejectedValueOnce(new Error('HTTP 401: access token required'))
|
||||
.mockResolvedValueOnce({ balance: 12, used: 1, quota: 13 });
|
||||
decryptPasswordMock.mockReturnValue('plain-password');
|
||||
adapterMock.login.mockResolvedValue({ success: true, accessToken: 'fresh-token' });
|
||||
|
||||
const { refreshBalance } = await import('./balanceService.js');
|
||||
const result = await refreshBalance(1);
|
||||
|
||||
expect(result).toEqual({ balance: 12, used: 1, quota: 13 });
|
||||
expect(adapterMock.login).toHaveBeenCalledTimes(1);
|
||||
expect(adapterMock.getBalance).toHaveBeenCalledTimes(2);
|
||||
expect(adapterMock.getBalance.mock.calls[0][1]).toBe('stale-token');
|
||||
expect(adapterMock.getBalance.mock.calls[1][1]).toBe('fresh-token');
|
||||
expect(updateSetMock.mock.calls.some((call) => call[0]?.accessToken === 'fresh-token')).toBe(true);
|
||||
expect(reportTokenExpiredMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports token expired when relogin is unavailable', async () => {
|
||||
selectAllMock.mockReturnValue([
|
||||
{
|
||||
accounts: {
|
||||
id: 2,
|
||||
username: 'linuxdo_7659',
|
||||
accessToken: 'stale-token',
|
||||
status: 'active',
|
||||
extraConfig: null,
|
||||
},
|
||||
sites: {
|
||||
id: 4,
|
||||
name: 'kfc',
|
||||
url: 'https://kfc-api.sxxe.net',
|
||||
platform: 'new-api',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
adapterMock.getBalance.mockRejectedValueOnce(new Error('HTTP 401: access token required'));
|
||||
|
||||
const { refreshBalance } = await import('./balanceService.js');
|
||||
await expect(refreshBalance(2)).rejects.toThrow('access token');
|
||||
|
||||
expect(adapterMock.login).not.toHaveBeenCalled();
|
||||
expect(reportTokenExpiredMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps degraded health when checkin is unsupported but balance refresh succeeds', async () => {
|
||||
selectAllMock.mockReturnValue([
|
||||
{
|
||||
accounts: {
|
||||
id: 3,
|
||||
username: 'ld6jl3djexjf',
|
||||
accessToken: 'active-token',
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({
|
||||
runtimeHealth: {
|
||||
state: 'degraded',
|
||||
reason: 'checkin endpoint not found',
|
||||
source: 'checkin',
|
||||
},
|
||||
}),
|
||||
},
|
||||
sites: {
|
||||
id: 5,
|
||||
name: 'Wind Hub',
|
||||
url: 'https://windhub.cc',
|
||||
platform: 'done-hub',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
adapterMock.getBalance.mockResolvedValueOnce({ balance: 100, used: 2, quota: 102 });
|
||||
extractRuntimeHealthMock.mockReturnValue({
|
||||
state: 'degraded',
|
||||
reason: 'checkin endpoint not found',
|
||||
source: 'checkin',
|
||||
checkedAt: '2026-02-25T12:00:00.000Z',
|
||||
});
|
||||
|
||||
const { refreshBalance } = await import('./balanceService.js');
|
||||
const result = await refreshBalance(3);
|
||||
|
||||
expect(result).toEqual({ balance: 100, used: 2, quota: 102 });
|
||||
expect(setAccountRuntimeHealthMock).toHaveBeenCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
state: 'degraded',
|
||||
reason: 'checkin endpoint not found',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('fills today income snapshot from log endpoint when balance api lacks today_income', async () => {
|
||||
selectAllMock.mockReturnValue([
|
||||
{
|
||||
accounts: {
|
||||
id: 4,
|
||||
username: 'linuxdo_7659',
|
||||
accessToken: 'active-token',
|
||||
status: 'active',
|
||||
extraConfig: null,
|
||||
},
|
||||
sites: {
|
||||
id: 6,
|
||||
name: 'kfc',
|
||||
url: 'https://kfc-api.sxxe.net',
|
||||
platform: 'new-api',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
adapterMock.getBalance.mockResolvedValueOnce({ balance: 12, used: 1, quota: 13 });
|
||||
undiciFetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { items: [], total: 0 } }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
items: [{ quota: 0, content: '签到奖励 2.083650 额度' }],
|
||||
total: 1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const { refreshBalance } = await import('./balanceService.js');
|
||||
const result = await refreshBalance(4);
|
||||
|
||||
expect(result).toEqual({ balance: 12, used: 1, quota: 13, todayIncome: 2.08365 });
|
||||
const updateWithSnapshot = updateSetMock.mock.calls
|
||||
.map((call) => call[0] as Record<string, unknown>)
|
||||
.find((payload) => typeof payload.extraConfig === 'string');
|
||||
expect(updateWithSnapshot).toBeDefined();
|
||||
const parsedExtra = JSON.parse(String(updateWithSnapshot?.extraConfig));
|
||||
expect(parsedExtra.todayIncomeSnapshot?.latest).toBeCloseTo(2.08365, 6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { getAdapter } from './platforms/index.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { sendNotification } from './notifyService.js';
|
||||
import { isTokenExpiredError } from './alertRules.js';
|
||||
import { reportTokenExpired } from './alertService.js';
|
||||
import { getAutoReloginConfig, resolvePlatformUserId } from './accountExtraConfig.js';
|
||||
import { decryptAccountPassword } from './accountCredentialService.js';
|
||||
import { extractRuntimeHealth, setAccountRuntimeHealth } from './accountHealthService.js';
|
||||
import { updateTodayIncomeSnapshot } from './todayIncomeRewardService.js';
|
||||
import type { BalanceInfo } from './platforms/base.js';
|
||||
|
||||
function isSiteDisabled(status?: string | null): boolean {
|
||||
return (status || 'active') === 'disabled';
|
||||
}
|
||||
|
||||
function shouldAttemptAutoRelogin(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') ||
|
||||
text.includes('not login') ||
|
||||
text.includes('not logged')
|
||||
);
|
||||
}
|
||||
|
||||
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')
|
||||
);
|
||||
}
|
||||
|
||||
function isUnsupportedCheckinRuntimeHealth(health: ReturnType<typeof extractRuntimeHealth>): boolean {
|
||||
if (!health || health.state !== 'degraded') return false;
|
||||
if ((health.source || '').toLowerCase() === 'checkin') return true;
|
||||
const reason = (health.reason || '').toLowerCase();
|
||||
return (
|
||||
reason.includes('checkin endpoint not found') ||
|
||||
reason.includes('invalid url (post /api/user/checkin)') ||
|
||||
(reason.includes('http 404') && reason.includes('/api/user/checkin')) ||
|
||||
reason.includes('unsupported checkin endpoint')
|
||||
);
|
||||
}
|
||||
|
||||
const INCOME_LOG_TYPES = [1, 4] as const;
|
||||
const LOG_PAGE_SIZE = 100;
|
||||
const LOG_MAX_PAGES = 6;
|
||||
|
||||
function supportsTodayIncomeLogFallback(platform?: string | null): boolean {
|
||||
const normalized = (platform || '').toLowerCase();
|
||||
return (
|
||||
normalized === 'new-api' ||
|
||||
normalized === 'anyrouter' ||
|
||||
normalized === 'one-api' ||
|
||||
normalized === 'veloera'
|
||||
);
|
||||
}
|
||||
|
||||
function getTodayUnixSecondsRange(now = new Date()): { start: number; end: number } {
|
||||
const startDate = new Date(now);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const endDate = new Date(now);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
|
||||
return {
|
||||
start: Math.floor(startDate.getTime() / 1000),
|
||||
end: Math.floor(endDate.getTime() / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePositiveNumber(value: unknown): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value;
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value.trim());
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function parseIncomeFromContent(content: unknown): number {
|
||||
if (typeof content !== 'string') return 0;
|
||||
const normalized = content.replace(/,/g, '');
|
||||
const match = normalized.match(/[-+]?\d+(?:\.\d+)?/);
|
||||
if (!match) return 0;
|
||||
const parsed = Number(match[0]);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return 0;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function extractLogItems(payload: any): Array<{ quota?: unknown; content?: unknown }> {
|
||||
if (Array.isArray(payload?.data?.items)) return payload.data.items;
|
||||
if (Array.isArray(payload?.items)) return payload.items;
|
||||
if (Array.isArray(payload?.data)) return payload.data;
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractLogTotal(payload: any): number | null {
|
||||
const candidates = [payload?.data?.total, payload?.total];
|
||||
for (const value of candidates) {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value;
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number.parseInt(value.trim(), 10);
|
||||
if (!Number.isNaN(parsed) && parsed >= 0) return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveQuotaConversionFactor(platform?: string | null): number {
|
||||
return (platform || '').toLowerCase() === 'veloera' ? 1_000_000 : 500_000;
|
||||
}
|
||||
|
||||
async function fetchTodayIncomeFromLogs(params: {
|
||||
baseUrl: string;
|
||||
accessToken: string;
|
||||
platform?: string | null;
|
||||
platformUserId?: number;
|
||||
}): Promise<number | null> {
|
||||
const baseUrl = params.baseUrl.trim();
|
||||
const accessToken = params.accessToken.trim();
|
||||
if (!baseUrl || !accessToken) return null;
|
||||
|
||||
const { fetch } = await import('undici');
|
||||
const { start, end } = getTodayUnixSecondsRange();
|
||||
const conversionFactor = resolveQuotaConversionFactor(params.platform);
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (typeof params.platformUserId === 'number' && Number.isFinite(params.platformUserId)) {
|
||||
headers['New-Api-User'] = String(Math.trunc(params.platformUserId));
|
||||
}
|
||||
|
||||
let hasAnyLogResponse = false;
|
||||
let totalIncome = 0;
|
||||
|
||||
for (const logType of INCOME_LOG_TYPES) {
|
||||
let page = 1;
|
||||
while (page <= LOG_MAX_PAGES) {
|
||||
const query = new URLSearchParams({
|
||||
p: String(page),
|
||||
page_size: String(LOG_PAGE_SIZE),
|
||||
type: String(logType),
|
||||
token_name: '',
|
||||
model_name: '',
|
||||
start_timestamp: String(start),
|
||||
end_timestamp: String(end),
|
||||
group: '',
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/log/self?${query.toString()}`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
if (!response.ok) break;
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!payload || typeof payload !== 'object') break;
|
||||
hasAnyLogResponse = true;
|
||||
|
||||
const items = extractLogItems(payload);
|
||||
for (const item of items) {
|
||||
const quotaRaw = parsePositiveNumber(item?.quota);
|
||||
if (quotaRaw > 0) {
|
||||
totalIncome += quotaRaw / conversionFactor;
|
||||
continue;
|
||||
}
|
||||
totalIncome += parseIncomeFromContent(item?.content);
|
||||
}
|
||||
|
||||
const total = extractLogTotal(payload);
|
||||
if (items.length === 0) break;
|
||||
if (total != null && page * LOG_PAGE_SIZE >= total) break;
|
||||
page += 1;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAnyLogResponse) return null;
|
||||
return Math.round(totalIncome * 1_000_000) / 1_000_000;
|
||||
}
|
||||
|
||||
async function tryAutoRelogin(account: any, site: any): Promise<string | null> {
|
||||
const adapter = getAdapter(site.platform);
|
||||
if (!adapter) return null;
|
||||
|
||||
const relogin = getAutoReloginConfig(account.extraConfig);
|
||||
if (!relogin) return null;
|
||||
|
||||
const password = decryptAccountPassword(relogin.passwordCipher);
|
||||
if (!password) return null;
|
||||
|
||||
const loginResult = await adapter.login(site.url, relogin.username, password);
|
||||
if (!loginResult.success || !loginResult.accessToken) return null;
|
||||
|
||||
db.update(schema.accounts)
|
||||
.set({
|
||||
accessToken: loginResult.accessToken,
|
||||
status: account.status === 'expired' ? 'active' : account.status,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(schema.accounts.id, account.id))
|
||||
.run();
|
||||
|
||||
return loginResult.accessToken;
|
||||
}
|
||||
|
||||
export async function refreshBalance(accountId: number) {
|
||||
const rows = db
|
||||
.select()
|
||||
.from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(eq(schema.accounts.id, accountId))
|
||||
.all();
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const account = rows[0].accounts;
|
||||
const site = rows[0].sites;
|
||||
|
||||
if (isSiteDisabled(site.status)) {
|
||||
setAccountRuntimeHealth(account.id, {
|
||||
state: 'disabled',
|
||||
reason: '站点已禁用',
|
||||
source: 'balance',
|
||||
});
|
||||
return {
|
||||
balance: account.balance ?? 0,
|
||||
used: account.balanceUsed ?? 0,
|
||||
quota: account.quota ?? 0,
|
||||
skipped: true,
|
||||
reason: 'site_disabled',
|
||||
};
|
||||
}
|
||||
|
||||
const adapter = getAdapter(site.platform);
|
||||
if (!adapter) return null;
|
||||
|
||||
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
|
||||
let activeAccessToken = account.accessToken;
|
||||
let balanceInfo: BalanceInfo | null = null;
|
||||
|
||||
const readBalance = async (token: string) => adapter.getBalance(site.url, token, platformUserId);
|
||||
const handleBalanceError = async (err: any) => {
|
||||
const message = err?.message || 'unknown error';
|
||||
setAccountRuntimeHealth(account.id, {
|
||||
state: 'unhealthy',
|
||||
reason: message,
|
||||
source: 'balance',
|
||||
});
|
||||
if (shouldReportExpired(message)) {
|
||||
await reportTokenExpired({
|
||||
accountId: account.id,
|
||||
username: account.username,
|
||||
siteName: site.name,
|
||||
detail: message,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
};
|
||||
|
||||
try {
|
||||
balanceInfo = await readBalance(activeAccessToken);
|
||||
} catch (err: any) {
|
||||
const message = err?.message || 'unknown error';
|
||||
if (shouldAttemptAutoRelogin(message)) {
|
||||
const refreshedAccessToken = await tryAutoRelogin(account, site);
|
||||
if (refreshedAccessToken) {
|
||||
activeAccessToken = refreshedAccessToken;
|
||||
try {
|
||||
balanceInfo = await readBalance(activeAccessToken);
|
||||
} catch (retryErr: any) {
|
||||
await handleBalanceError(retryErr);
|
||||
}
|
||||
} else {
|
||||
await handleBalanceError(err);
|
||||
}
|
||||
} else {
|
||||
await handleBalanceError(err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!balanceInfo) {
|
||||
throw new Error('failed to fetch balance');
|
||||
}
|
||||
|
||||
if (
|
||||
!(typeof balanceInfo.todayIncome === 'number' && Number.isFinite(balanceInfo.todayIncome)) &&
|
||||
supportsTodayIncomeLogFallback(site.platform)
|
||||
) {
|
||||
try {
|
||||
const fallbackIncome = await fetchTodayIncomeFromLogs({
|
||||
baseUrl: site.url,
|
||||
accessToken: activeAccessToken,
|
||||
platform: site.platform,
|
||||
platformUserId,
|
||||
});
|
||||
if (typeof fallbackIncome === 'number' && Number.isFinite(fallbackIncome)) {
|
||||
balanceInfo.todayIncome = fallbackIncome;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const existingRuntimeHealth = extractRuntimeHealth(account.extraConfig);
|
||||
const keepUnsupportedCheckinDegraded = isUnsupportedCheckinRuntimeHealth(existingRuntimeHealth);
|
||||
|
||||
const updates: Record<string, unknown> = {
|
||||
balance: balanceInfo.balance,
|
||||
balanceUsed: balanceInfo.used,
|
||||
quota: balanceInfo.quota,
|
||||
status: account.status === 'expired' ? 'active' : account.status,
|
||||
lastBalanceRefresh: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (typeof balanceInfo.todayIncome === 'number' && Number.isFinite(balanceInfo.todayIncome)) {
|
||||
updates.extraConfig = updateTodayIncomeSnapshot(account.extraConfig, balanceInfo.todayIncome);
|
||||
}
|
||||
|
||||
db.update(schema.accounts)
|
||||
.set(updates)
|
||||
.where(eq(schema.accounts.id, accountId))
|
||||
.run();
|
||||
|
||||
setAccountRuntimeHealth(account.id, {
|
||||
state: keepUnsupportedCheckinDegraded ? 'degraded' : 'healthy',
|
||||
reason: keepUnsupportedCheckinDegraded
|
||||
? (existingRuntimeHealth?.reason || '\u7ad9\u70b9\u4e0d\u652f\u6301\u7b7e\u5230\u63a5\u53e3')
|
||||
: '\u4f59\u989d\u5237\u65b0\u6210\u529f',
|
||||
source: keepUnsupportedCheckinDegraded
|
||||
? (existingRuntimeHealth?.source || 'checkin')
|
||||
: 'balance',
|
||||
});
|
||||
|
||||
if (balanceInfo.balance < 1) {
|
||||
db.insert(schema.events).values({
|
||||
type: 'balance',
|
||||
title: '余额不足',
|
||||
message: `${account.username || 'ID:' + accountId} 余额不足: $${balanceInfo.balance.toFixed(2)}`,
|
||||
level: 'warning',
|
||||
relatedId: accountId,
|
||||
relatedType: 'account',
|
||||
}).run();
|
||||
|
||||
await sendNotification(
|
||||
'余额不足提醒',
|
||||
`${account.username || 'ID:' + accountId} 余额不足: $${balanceInfo.balance.toFixed(2)}`,
|
||||
'warning',
|
||||
);
|
||||
}
|
||||
|
||||
return balanceInfo;
|
||||
}
|
||||
|
||||
export async function refreshAllBalances() {
|
||||
const rows = db
|
||||
.select()
|
||||
.from(schema.accounts)
|
||||
.where(eq(schema.accounts.status, 'active'))
|
||||
.all();
|
||||
|
||||
const results: Array<{ accountId: number; balance: number | null }> = [];
|
||||
|
||||
await Promise.all(
|
||||
rows.map(async (account) => {
|
||||
try {
|
||||
const info = await refreshBalance(account.id);
|
||||
results.push({ accountId: account.id, balance: info?.balance ?? null });
|
||||
} catch {
|
||||
results.push({ accountId: account.id, balance: null });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseCheckinRewardAmount } from './checkinRewardParser.js';
|
||||
|
||||
describe('checkinRewardParser', () => {
|
||||
it('parses numeric reward values', () => {
|
||||
expect(parseCheckinRewardAmount(3.5)).toBe(3.5);
|
||||
expect(parseCheckinRewardAmount('12')).toBe(12);
|
||||
});
|
||||
|
||||
it('parses reward strings with text wrappers', () => {
|
||||
expect(parseCheckinRewardAmount('奖励 +2.75')).toBe(2.75);
|
||||
expect(parseCheckinRewardAmount('checkin success, reward=5')).toBe(5);
|
||||
});
|
||||
|
||||
it('returns zero for missing or non-positive values', () => {
|
||||
expect(parseCheckinRewardAmount('')).toBe(0);
|
||||
expect(parseCheckinRewardAmount(null)).toBe(0);
|
||||
expect(parseCheckinRewardAmount('checked in')).toBe(0);
|
||||
expect(parseCheckinRewardAmount('-1')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
function toFiniteNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const parsed = Number(trimmed);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseCheckinRewardAmount(value: unknown): number {
|
||||
const numeric = toFiniteNumber(value);
|
||||
if (numeric != null) {
|
||||
return numeric > 0 ? numeric : 0;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') return 0;
|
||||
const text = value.trim();
|
||||
if (!text) return 0;
|
||||
|
||||
const normalized = text.replace(/,/g, '');
|
||||
const match = normalized.match(/[-+]?\d+(?:\.\d+)?/);
|
||||
if (!match) return 0;
|
||||
|
||||
const parsed = Number(match[0]);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return 0;
|
||||
return parsed;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import cron from 'node-cron';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { config } from '../config.js';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { refreshAllBalances } from './balanceService.js';
|
||||
import { checkinAll } from './checkinService.js';
|
||||
import { refreshModelsAndRebuildRoutes } from './modelService.js';
|
||||
import { sendNotification } from './notifyService.js';
|
||||
import { buildDailySummaryNotification, collectDailySummaryMetrics } from './dailySummaryService.js';
|
||||
|
||||
let checkinTask: cron.ScheduledTask | null = null;
|
||||
let balanceTask: cron.ScheduledTask | null = null;
|
||||
let dailySummaryTask: cron.ScheduledTask | null = null;
|
||||
|
||||
const DAILY_SUMMARY_DEFAULT_CRON = '58 23 * * *';
|
||||
|
||||
function resolveCronSetting(settingKey: string, fallback: string): string {
|
||||
try {
|
||||
const row = db.select().from(schema.settings).where(eq(schema.settings.key, settingKey)).get();
|
||||
if (row?.value) {
|
||||
const parsed = JSON.parse(row.value);
|
||||
if (typeof parsed === 'string' && cron.validate(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function createCheckinTask(cronExpr: string) {
|
||||
return cron.schedule(cronExpr, async () => {
|
||||
console.log(`[Scheduler] Running check-in at ${new Date().toISOString()}`);
|
||||
try {
|
||||
const results = await checkinAll();
|
||||
const success = results.filter((r) => r.result.success).length;
|
||||
const failed = results.length - success;
|
||||
console.log(`[Scheduler] Check-in complete: ${success} success, ${failed} failed`);
|
||||
} catch (err) {
|
||||
console.error('[Scheduler] Check-in error:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createBalanceTask(cronExpr: string) {
|
||||
return cron.schedule(cronExpr, async () => {
|
||||
console.log(`[Scheduler] Refreshing balances at ${new Date().toISOString()}`);
|
||||
try {
|
||||
await refreshAllBalances();
|
||||
await refreshModelsAndRebuildRoutes();
|
||||
console.log('[Scheduler] Balance refresh complete');
|
||||
} catch (err) {
|
||||
console.error('[Scheduler] Balance refresh error:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createDailySummaryTask(cronExpr: string) {
|
||||
return cron.schedule(cronExpr, async () => {
|
||||
console.log(`[Scheduler] Sending daily summary at ${new Date().toISOString()}`);
|
||||
try {
|
||||
const metrics = collectDailySummaryMetrics();
|
||||
const { title, message } = buildDailySummaryNotification(metrics);
|
||||
await sendNotification(title, message, 'info', {
|
||||
bypassThrottle: true,
|
||||
requireChannel: true,
|
||||
throwOnFailure: true,
|
||||
});
|
||||
console.log(`[Scheduler] Daily summary sent: ${title}`);
|
||||
} catch (err) {
|
||||
console.error('[Scheduler] Daily summary error:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function startScheduler() {
|
||||
const activeCheckinCron = resolveCronSetting('checkin_cron', config.checkinCron);
|
||||
const activeBalanceCron = resolveCronSetting('balance_refresh_cron', config.balanceRefreshCron);
|
||||
const activeDailySummaryCron = resolveCronSetting('daily_summary_cron', DAILY_SUMMARY_DEFAULT_CRON);
|
||||
config.checkinCron = activeCheckinCron;
|
||||
config.balanceRefreshCron = activeBalanceCron;
|
||||
|
||||
checkinTask = createCheckinTask(activeCheckinCron);
|
||||
balanceTask = createBalanceTask(activeBalanceCron);
|
||||
dailySummaryTask = createDailySummaryTask(activeDailySummaryCron);
|
||||
|
||||
console.log(`[Scheduler] Check-in cron: ${activeCheckinCron}`);
|
||||
console.log(`[Scheduler] Balance refresh cron: ${activeBalanceCron}`);
|
||||
console.log(`[Scheduler] Daily summary cron: ${activeDailySummaryCron}`);
|
||||
}
|
||||
|
||||
export function updateCheckinCron(cronExpr: string) {
|
||||
if (!cron.validate(cronExpr)) throw new Error(`Invalid cron: ${cronExpr}`);
|
||||
config.checkinCron = cronExpr;
|
||||
checkinTask?.stop();
|
||||
checkinTask = createCheckinTask(cronExpr);
|
||||
}
|
||||
|
||||
export function updateBalanceRefreshCron(cronExpr: string) {
|
||||
if (!cron.validate(cronExpr)) throw new Error(`Invalid cron: ${cronExpr}`);
|
||||
config.balanceRefreshCron = cronExpr;
|
||||
balanceTask?.stop();
|
||||
balanceTask = createBalanceTask(cronExpr);
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const adapterMock = {
|
||||
checkin: vi.fn(),
|
||||
login: vi.fn(),
|
||||
};
|
||||
|
||||
const notifyMock = vi.fn();
|
||||
const reportTokenExpiredMock = vi.fn();
|
||||
const refreshBalanceMock = vi.fn();
|
||||
const decryptPasswordMock = vi.fn();
|
||||
|
||||
const selectAllMock = vi.fn();
|
||||
const insertValuesMock = vi.fn();
|
||||
const updateSetMock = vi.fn();
|
||||
|
||||
vi.mock('../db/index.js', () => {
|
||||
const selectChain = {
|
||||
all: () => selectAllMock(),
|
||||
where: () => selectChain,
|
||||
innerJoin: () => selectChain,
|
||||
from: () => selectChain,
|
||||
};
|
||||
|
||||
const insertChain = {
|
||||
run: () => ({}),
|
||||
values: (...args: unknown[]) => {
|
||||
insertValuesMock(...args);
|
||||
return insertChain;
|
||||
},
|
||||
};
|
||||
|
||||
const updateWhereChain = {
|
||||
run: () => ({}),
|
||||
};
|
||||
|
||||
const updateSetChain = {
|
||||
where: () => updateWhereChain,
|
||||
};
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: () => selectChain,
|
||||
insert: () => insertChain,
|
||||
update: () => ({
|
||||
set: (updates: Record<string, unknown>) => {
|
||||
updateSetMock(updates);
|
||||
return updateSetChain;
|
||||
},
|
||||
}),
|
||||
},
|
||||
schema: {
|
||||
accounts: { id: 'id', siteId: 'siteId', checkinEnabled: 'checkinEnabled', status: 'status' },
|
||||
sites: { id: 'id' },
|
||||
checkinLogs: {},
|
||||
events: {},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./platforms/index.js', () => ({
|
||||
getAdapter: () => adapterMock,
|
||||
}));
|
||||
|
||||
vi.mock('./notifyService.js', () => ({
|
||||
sendNotification: (...args: unknown[]) => notifyMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('./alertService.js', () => ({
|
||||
reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('./balanceService.js', () => ({
|
||||
refreshBalance: (...args: unknown[]) => refreshBalanceMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('./accountCredentialService.js', () => ({
|
||||
decryptAccountPassword: (...args: unknown[]) => decryptPasswordMock(...args),
|
||||
}));
|
||||
|
||||
describe('checkinService auto relogin', () => {
|
||||
beforeEach(() => {
|
||||
adapterMock.checkin.mockReset();
|
||||
adapterMock.login.mockReset();
|
||||
notifyMock.mockReset();
|
||||
reportTokenExpiredMock.mockReset();
|
||||
refreshBalanceMock.mockReset();
|
||||
decryptPasswordMock.mockReset();
|
||||
selectAllMock.mockReset();
|
||||
insertValuesMock.mockReset();
|
||||
updateSetMock.mockReset();
|
||||
});
|
||||
|
||||
it('retries checkin once after auto relogin when access token is missing', async () => {
|
||||
selectAllMock.mockReturnValue([
|
||||
{
|
||||
accounts: {
|
||||
id: 1,
|
||||
username: 'linuxdo_7659',
|
||||
accessToken: 'expired-token',
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({
|
||||
autoRelogin: { username: 'linuxdo_7659', passwordCipher: 'cipher' },
|
||||
}),
|
||||
},
|
||||
sites: {
|
||||
id: 3,
|
||||
name: 'kfc',
|
||||
url: 'https://kfc-api.sxxe.net',
|
||||
platform: 'new-api',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
adapterMock.checkin
|
||||
.mockResolvedValueOnce({ success: false, message: '无权进行此操作,未登录且未提供 access token' })
|
||||
.mockResolvedValueOnce({ success: true, message: 'checked in' });
|
||||
decryptPasswordMock.mockReturnValue('plain-password');
|
||||
adapterMock.login.mockResolvedValue({ success: true, accessToken: 'fresh-token' });
|
||||
|
||||
const { checkinAccount } = await import('./checkinService.js');
|
||||
const result = await checkinAccount(1);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(adapterMock.login).toHaveBeenCalledTimes(1);
|
||||
expect(adapterMock.checkin).toHaveBeenCalledTimes(2);
|
||||
expect(adapterMock.checkin.mock.calls[0][1]).toBe('expired-token');
|
||||
expect(adapterMock.checkin.mock.calls[1][1]).toBe('fresh-token');
|
||||
expect(adapterMock.checkin.mock.calls[0][2]).toBe(7659);
|
||||
expect(updateSetMock).toHaveBeenCalledWith(expect.objectContaining({ accessToken: 'fresh-token' }));
|
||||
});
|
||||
|
||||
it('passes guessed platform user id when config does not include it', async () => {
|
||||
selectAllMock.mockReturnValue([
|
||||
{
|
||||
accounts: {
|
||||
id: 2,
|
||||
username: 'linuxdo_11494',
|
||||
accessToken: 'token',
|
||||
status: 'active',
|
||||
extraConfig: null,
|
||||
},
|
||||
sites: {
|
||||
id: 4,
|
||||
name: 'wong',
|
||||
url: 'https://wzw.pp.ua',
|
||||
platform: 'new-api',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
adapterMock.checkin.mockResolvedValue({ success: true, message: 'checked in' });
|
||||
|
||||
const { checkinAccount } = await import('./checkinService.js');
|
||||
await checkinAccount(2);
|
||||
|
||||
expect(adapterMock.checkin).toHaveBeenCalledTimes(1);
|
||||
expect(adapterMock.checkin.mock.calls[0][2]).toBe(11494);
|
||||
});
|
||||
|
||||
it('keeps successful checkin as success when message is 签到成功', async () => {
|
||||
selectAllMock.mockReturnValue([
|
||||
{
|
||||
accounts: {
|
||||
id: 12,
|
||||
username: 'linuxdo_5566',
|
||||
accessToken: 'token',
|
||||
status: 'active',
|
||||
extraConfig: null,
|
||||
},
|
||||
sites: {
|
||||
id: 12,
|
||||
name: 'demo',
|
||||
url: 'https://example.com',
|
||||
platform: 'new-api',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
adapterMock.checkin.mockResolvedValue({ success: true, message: '签到成功' });
|
||||
|
||||
const { checkinAccount } = await import('./checkinService.js');
|
||||
const result = await checkinAccount(12);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const firstInsertPayload = insertValuesMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(firstInsertPayload?.status).toBe('success');
|
||||
});
|
||||
|
||||
it('infers reward from balance delta when checkin reward text is empty', async () => {
|
||||
selectAllMock.mockReturnValue([
|
||||
{
|
||||
accounts: {
|
||||
id: 13,
|
||||
username: 'linuxdo_7788',
|
||||
accessToken: 'token',
|
||||
status: 'active',
|
||||
balance: 10,
|
||||
extraConfig: null,
|
||||
},
|
||||
sites: {
|
||||
id: 13,
|
||||
name: 'demo',
|
||||
url: 'https://example.com',
|
||||
platform: 'new-api',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
adapterMock.checkin.mockResolvedValue({ success: true, message: 'checkin success' });
|
||||
refreshBalanceMock.mockResolvedValue({ balance: 12.5, used: 0, quota: 12.5 });
|
||||
|
||||
const { checkinAccount } = await import('./checkinService.js');
|
||||
await checkinAccount(13);
|
||||
|
||||
const firstInsertPayload = insertValuesMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(Number(firstInsertPayload?.reward)).toBeCloseTo(2.5, 6);
|
||||
});
|
||||
|
||||
it('treats already checked in responses as successful checkins', async () => {
|
||||
selectAllMock.mockReturnValue([
|
||||
{
|
||||
accounts: {
|
||||
id: 9,
|
||||
username: 'linuxdo_9999',
|
||||
accessToken: 'token',
|
||||
status: 'active',
|
||||
extraConfig: null,
|
||||
},
|
||||
sites: {
|
||||
id: 9,
|
||||
name: 'demo',
|
||||
url: 'https://example.com',
|
||||
platform: 'new-api',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
adapterMock.checkin.mockResolvedValue({ success: false, message: '今天已经签到过啦' });
|
||||
|
||||
const { checkinAccount } = await import('./checkinService.js');
|
||||
const result = await checkinAccount(9);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.status).toBe('success');
|
||||
const firstInsertPayload = insertValuesMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(firstInsertPayload?.status).toBe('success');
|
||||
expect(notifyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats unsupported checkin endpoint responses as skipped', async () => {
|
||||
selectAllMock.mockReturnValue([
|
||||
{
|
||||
accounts: {
|
||||
id: 10,
|
||||
username: 'linuxdo_131936',
|
||||
accessToken: 'token',
|
||||
status: 'active',
|
||||
extraConfig: null,
|
||||
},
|
||||
sites: {
|
||||
id: 10,
|
||||
name: 'anyrouter',
|
||||
url: 'https://anyrouter.top',
|
||||
platform: 'anyrouter',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
adapterMock.checkin.mockResolvedValue({
|
||||
success: false,
|
||||
message: 'HTTP 404: {"error":{"message":"Invalid URL (POST /api/user/checkin)"}}',
|
||||
});
|
||||
|
||||
const { checkinAccount } = await import('./checkinService.js');
|
||||
const result = await checkinAccount(10);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.status).toBe('skipped');
|
||||
const firstInsertPayload = insertValuesMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(firstInsertPayload?.status).toBe('skipped');
|
||||
expect(refreshBalanceMock).not.toHaveBeenCalled();
|
||||
expect(notifyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats turnstile-required responses as skipped', async () => {
|
||||
selectAllMock.mockReturnValue([
|
||||
{
|
||||
accounts: {
|
||||
id: 14,
|
||||
username: 'linuxdo_10277',
|
||||
accessToken: 'token',
|
||||
status: 'active',
|
||||
extraConfig: null,
|
||||
},
|
||||
sites: {
|
||||
id: 14,
|
||||
name: 'run-anytime',
|
||||
url: 'https://runanytime.hxi.me',
|
||||
platform: 'new-api',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
adapterMock.checkin.mockResolvedValue({
|
||||
success: false,
|
||||
message: 'Turnstile token 为空',
|
||||
});
|
||||
|
||||
const { checkinAccount } = await import('./checkinService.js');
|
||||
const result = await checkinAccount(14);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.status).toBe('skipped');
|
||||
const firstInsertPayload = insertValuesMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(firstInsertPayload?.status).toBe('skipped');
|
||||
expect(firstInsertPayload?.message).toBe('站点开启了 Turnstile 校验,需要人工签到');
|
||||
expect(refreshBalanceMock).not.toHaveBeenCalled();
|
||||
expect(notifyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { getAdapter } from './platforms/index.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { sendNotification } from './notifyService.js';
|
||||
import { isCloudflareChallenge, isTokenExpiredError } from './alertRules.js';
|
||||
import { reportTokenExpired } from './alertService.js';
|
||||
import { refreshBalance } from './balanceService.js';
|
||||
import { parseCheckinRewardAmount } from './checkinRewardParser.js';
|
||||
import {
|
||||
getAutoReloginConfig,
|
||||
getPlatformUserIdFromExtraConfig,
|
||||
guessPlatformUserIdFromUsername,
|
||||
mergeAccountExtraConfig,
|
||||
resolvePlatformUserId,
|
||||
} from './accountExtraConfig.js';
|
||||
import { decryptAccountPassword } from './accountCredentialService.js';
|
||||
import { setAccountRuntimeHealth } from './accountHealthService.js';
|
||||
|
||||
type CheckinExecutionStatus = 'success' | 'failed' | 'skipped';
|
||||
|
||||
function isSiteDisabled(status?: string | null): boolean {
|
||||
return (status || 'active') === 'disabled';
|
||||
}
|
||||
|
||||
function isAlreadyCheckedInMessage(message?: string | null): boolean {
|
||||
if (!message) return false;
|
||||
const text = message.trim();
|
||||
if (!text) return false;
|
||||
const normalized = text.toLowerCase();
|
||||
return (
|
||||
normalized.includes('already checked in') ||
|
||||
normalized.includes('already signed') ||
|
||||
normalized.includes('already sign in') ||
|
||||
text.includes('\u4eca\u65e5\u5df2\u7b7e\u5230') ||
|
||||
text.includes('\u4eca\u5929\u5df2\u7b7e\u5230') ||
|
||||
text.includes('\u4eca\u5929\u5df2\u7ecf\u7b7e\u5230') ||
|
||||
text.includes('\u4eca\u65e5\u5df2\u7ecf\u7b7e\u5230') ||
|
||||
text.includes('\u5df2\u7ecf\u7b7e\u5230') ||
|
||||
text.includes('\u5df2\u7b7e\u5230') ||
|
||||
text.includes('\u91cd\u590d\u7b7e\u5230') ||
|
||||
text.includes('\u7b7e\u5230\u8fc7')
|
||||
);
|
||||
}
|
||||
|
||||
function isUnsupportedCheckinMessage(message?: string | null): boolean {
|
||||
if (!message) return false;
|
||||
const text = message.toLowerCase();
|
||||
return (
|
||||
text.includes('invalid url (post /api/user/checkin)') ||
|
||||
(text.includes('http 404') && text.includes('/api/user/checkin')) ||
|
||||
text.includes('checkin endpoint not found')
|
||||
);
|
||||
}
|
||||
|
||||
function isManualVerificationRequiredMessage(message?: string | null): boolean {
|
||||
if (!message) return false;
|
||||
const text = message.toLowerCase();
|
||||
return (
|
||||
text.includes('turnstile token \u4e3a\u7a7a') ||
|
||||
(text.includes('turnstile') && (text.includes('token') || text.includes('\u6821\u9a8c') || text.includes('\u9a8c\u8bc1')))
|
||||
);
|
||||
}
|
||||
|
||||
function shouldAttemptAutoRelogin(message?: string | null): boolean {
|
||||
if (!message) return false;
|
||||
if (isTokenExpiredError({ message })) return true;
|
||||
|
||||
const text = message.toLowerCase();
|
||||
if (text.includes('new-api-user')) return true;
|
||||
if (text.includes('access token')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function inferRewardFromBalanceDelta(previousBalance: unknown, latestBalance: unknown): number {
|
||||
const before = typeof previousBalance === 'number' && Number.isFinite(previousBalance)
|
||||
? previousBalance
|
||||
: null;
|
||||
const after = typeof latestBalance === 'number' && Number.isFinite(latestBalance)
|
||||
? latestBalance
|
||||
: null;
|
||||
if (before == null || after == null) return 0;
|
||||
|
||||
const delta = after - before;
|
||||
if (!Number.isFinite(delta) || delta <= 0) return 0;
|
||||
return Math.round(delta * 1_000_000) / 1_000_000;
|
||||
}
|
||||
|
||||
async function tryAutoRelogin(account: any, site: any): Promise<string | null> {
|
||||
const adapter = getAdapter(site.platform);
|
||||
if (!adapter) return null;
|
||||
|
||||
const relogin = getAutoReloginConfig(account.extraConfig);
|
||||
if (!relogin) return null;
|
||||
|
||||
const password = decryptAccountPassword(relogin.passwordCipher);
|
||||
if (!password) return null;
|
||||
|
||||
const result = await adapter.login(site.url, relogin.username, password);
|
||||
if (!result.success || !result.accessToken) return null;
|
||||
|
||||
db.update(schema.accounts)
|
||||
.set({
|
||||
accessToken: result.accessToken,
|
||||
updatedAt: new Date().toISOString(),
|
||||
status: account.status === 'expired' ? 'active' : account.status,
|
||||
})
|
||||
.where(eq(schema.accounts.id, account.id))
|
||||
.run();
|
||||
|
||||
return result.accessToken;
|
||||
}
|
||||
|
||||
export async function checkinAccount(accountId: number, options?: { skipEvent?: boolean }) {
|
||||
const rows = db
|
||||
.select()
|
||||
.from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(eq(schema.accounts.id, accountId))
|
||||
.all();
|
||||
|
||||
if (rows.length === 0) return { success: false, message: 'account not found' };
|
||||
|
||||
const account = rows[0].accounts;
|
||||
const site = rows[0].sites;
|
||||
|
||||
if (isSiteDisabled(site.status)) {
|
||||
setAccountRuntimeHealth(account.id, {
|
||||
state: 'disabled',
|
||||
reason: '\u7ad9\u70b9\u5df2\u7981\u7528',
|
||||
source: 'checkin',
|
||||
});
|
||||
db.insert(schema.checkinLogs).values({
|
||||
accountId: account.id,
|
||||
status: 'skipped',
|
||||
message: 'site disabled',
|
||||
}).run();
|
||||
|
||||
if (!options?.skipEvent) {
|
||||
db.insert(schema.events).values({
|
||||
type: 'checkin',
|
||||
title: 'checkin skipped',
|
||||
message: `${account.username || 'ID:' + accountId} @ ${site.name}: site disabled`,
|
||||
level: 'info',
|
||||
relatedId: accountId,
|
||||
relatedType: 'account',
|
||||
}).run();
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status: 'skipped' as const,
|
||||
skipped: true,
|
||||
reason: 'site_disabled',
|
||||
message: 'site disabled',
|
||||
};
|
||||
}
|
||||
|
||||
const adapter = getAdapter(site.platform);
|
||||
if (!adapter) return { success: false, status: 'failed' as const, message: `unsupported platform: ${site.platform}` };
|
||||
|
||||
const storedPlatformUserId = getPlatformUserIdFromExtraConfig(account.extraConfig);
|
||||
const guessedPlatformUserId = storedPlatformUserId
|
||||
? undefined
|
||||
: guessPlatformUserIdFromUsername(account.username);
|
||||
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
|
||||
|
||||
let activeAccessToken = account.accessToken;
|
||||
let result = await adapter.checkin(site.url, activeAccessToken, platformUserId);
|
||||
|
||||
if (!result.success && shouldAttemptAutoRelogin(result.message)) {
|
||||
const refreshedAccessToken = await tryAutoRelogin(account, site);
|
||||
if (refreshedAccessToken) {
|
||||
activeAccessToken = refreshedAccessToken;
|
||||
result = await adapter.checkin(site.url, activeAccessToken, platformUserId);
|
||||
}
|
||||
}
|
||||
|
||||
const isCloudflare = isCloudflareChallenge(result.message);
|
||||
const alreadyCheckedIn = isAlreadyCheckedInMessage(result.message);
|
||||
const unsupportedCheckin = isUnsupportedCheckinMessage(result.message);
|
||||
const manualVerificationRequired = isManualVerificationRequiredMessage(result.message);
|
||||
const manualVerificationMessage = '\u7ad9\u70b9\u5f00\u542f\u4e86 Turnstile \u6821\u9a8c\uff0c\u9700\u8981\u4eba\u5de5\u7b7e\u5230';
|
||||
const logMessage = manualVerificationRequired ? manualVerificationMessage : result.message;
|
||||
const effectiveSuccess = result.success || alreadyCheckedIn || unsupportedCheckin || manualVerificationRequired;
|
||||
const shouldRefreshBalance = result.success || alreadyCheckedIn;
|
||||
const directCheckinSuccess = result.success && !alreadyCheckedIn && !unsupportedCheckin;
|
||||
const normalizedStatus: CheckinExecutionStatus = effectiveSuccess
|
||||
? ((unsupportedCheckin || manualVerificationRequired) ? 'skipped' : 'success')
|
||||
: 'failed';
|
||||
let logReward = result.reward;
|
||||
let refreshedBalanceInfo: Awaited<ReturnType<typeof refreshBalance>> | null = null;
|
||||
|
||||
if (effectiveSuccess) {
|
||||
const healthState = (unsupportedCheckin || manualVerificationRequired) ? 'degraded' : 'healthy';
|
||||
const healthReason = unsupportedCheckin
|
||||
? '\u7ad9\u70b9\u4e0d\u652f\u6301\u7b7e\u5230\u63a5\u53e3'
|
||||
: manualVerificationRequired
|
||||
? manualVerificationMessage
|
||||
: (alreadyCheckedIn ? '\u4eca\u65e5\u5df2\u7b7e\u5230' : (result.message || '\u7b7e\u5230\u6210\u529f'));
|
||||
setAccountRuntimeHealth(account.id, {
|
||||
state: healthState,
|
||||
reason: healthReason,
|
||||
source: 'checkin',
|
||||
});
|
||||
|
||||
const updates: Record<string, unknown> = {
|
||||
lastCheckinAt: new Date().toISOString(),
|
||||
};
|
||||
if (!storedPlatformUserId && guessedPlatformUserId) {
|
||||
updates.extraConfig = mergeAccountExtraConfig(account.extraConfig, {
|
||||
platformUserId: guessedPlatformUserId,
|
||||
});
|
||||
}
|
||||
if (account.status === 'expired') {
|
||||
updates.status = 'active';
|
||||
updates.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
db.update(schema.accounts)
|
||||
.set(updates)
|
||||
.where(eq(schema.accounts.id, accountId))
|
||||
.run();
|
||||
|
||||
if (shouldRefreshBalance) {
|
||||
try {
|
||||
refreshedBalanceInfo = await refreshBalance(account.id);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const parsedReward = parseCheckinRewardAmount(logReward) || parseCheckinRewardAmount(result.message);
|
||||
if (directCheckinSuccess && parsedReward <= 0) {
|
||||
const inferredReward = inferRewardFromBalanceDelta(account.balance, refreshedBalanceInfo?.balance);
|
||||
if (inferredReward > 0) {
|
||||
logReward = inferredReward.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.insert(schema.checkinLogs).values({
|
||||
accountId: account.id,
|
||||
status: normalizedStatus,
|
||||
message: logMessage,
|
||||
reward: logReward,
|
||||
}).run();
|
||||
|
||||
if (!options?.skipEvent) {
|
||||
db.insert(schema.events).values({
|
||||
type: 'checkin',
|
||||
title: effectiveSuccess
|
||||
? (normalizedStatus === 'skipped' ? 'checkin skipped' : 'checkin success')
|
||||
: (isCloudflare ? 'checkin failed (cloudflare challenge)' : 'checkin failed'),
|
||||
message: `${account.username || 'ID:' + accountId} @ ${site.name}: ${logMessage}`,
|
||||
level: effectiveSuccess ? 'info' : 'error',
|
||||
relatedId: accountId,
|
||||
relatedType: 'account',
|
||||
}).run();
|
||||
}
|
||||
|
||||
if (!effectiveSuccess) {
|
||||
setAccountRuntimeHealth(account.id, {
|
||||
state: 'unhealthy',
|
||||
reason: result.message || '\u7b7e\u5230\u5931\u8d25',
|
||||
source: 'checkin',
|
||||
});
|
||||
if (isTokenExpiredError({ message: result.message })) {
|
||||
await reportTokenExpired({
|
||||
accountId: account.id,
|
||||
username: account.username,
|
||||
siteName: site.name,
|
||||
detail: result.message,
|
||||
});
|
||||
}
|
||||
|
||||
if (isCloudflare) {
|
||||
await sendNotification(
|
||||
'Cloudflare challenge',
|
||||
`${account.username || 'ID:' + accountId} @ ${site.name}: ${result.message}`,
|
||||
'warning',
|
||||
);
|
||||
}
|
||||
|
||||
if (!unsupportedCheckin && !manualVerificationRequired) {
|
||||
await sendNotification(
|
||||
'checkin failed',
|
||||
`${account.username || 'ID:' + accountId} @ ${site.name}: ${result.message}`,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
...result,
|
||||
success: effectiveSuccess,
|
||||
status: normalizedStatus,
|
||||
...(normalizedStatus === 'skipped' ? { skipped: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkinAll() {
|
||||
const rows = db
|
||||
.select()
|
||||
.from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.accounts.checkinEnabled, true),
|
||||
eq(schema.accounts.status, 'active'),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
|
||||
const results: Array<{ accountId: number; username: string | null; site: string; result: any }> = [];
|
||||
|
||||
const grouped = new Map<number, typeof rows>();
|
||||
for (const row of rows) {
|
||||
const siteId = row.sites.id;
|
||||
if (!grouped.has(siteId)) grouped.set(siteId, []);
|
||||
grouped.get(siteId)!.push(row);
|
||||
}
|
||||
|
||||
const promises = Array.from(grouped.entries()).map(async ([_, siteRows]) => {
|
||||
for (const row of siteRows) {
|
||||
const r = await checkinAccount(row.accounts.id, { skipEvent: true });
|
||||
results.push({
|
||||
accountId: row.accounts.id,
|
||||
username: row.accounts.username,
|
||||
site: row.sites.name,
|
||||
result: r,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildDailySummaryNotification, type DailySummaryMetrics } from './dailySummaryService.js';
|
||||
|
||||
describe('dailySummaryService', () => {
|
||||
it('builds readable daily summary notification text', () => {
|
||||
const metrics: DailySummaryMetrics = {
|
||||
localDay: '2026-02-27',
|
||||
generatedAtLocal: '2026-02-27 23:58:00',
|
||||
timeZone: 'Asia/Shanghai',
|
||||
totalAccounts: 10,
|
||||
activeAccounts: 8,
|
||||
lowBalanceAccounts: 2,
|
||||
checkinTotal: 7,
|
||||
checkinSuccess: 5,
|
||||
checkinSkipped: 1,
|
||||
checkinFailed: 1,
|
||||
proxyTotal: 120,
|
||||
proxySuccess: 114,
|
||||
proxyFailed: 6,
|
||||
proxyTotalTokens: 987654,
|
||||
todaySpend: 12.345678,
|
||||
todayReward: 3.210987,
|
||||
};
|
||||
|
||||
const { title, message } = buildDailySummaryNotification(metrics);
|
||||
expect(title).toBe('每日总结 2026-02-27');
|
||||
expect(message).toContain('生成时间: 2026-02-27 23:58:00 (Asia/Shanghai)');
|
||||
expect(message).toContain('签到统计: 总计 7 | 成功 5 | 跳过 1 | 失败 1');
|
||||
expect(message).toContain('代理统计: 总计 120 | 成功 114 | 失败 6');
|
||||
expect(message).toContain('费用统计: 支出 $12.345678 | 奖励 $3.210987 | 净值 $-9.134691');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { and, eq, gte, lt } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { getLocalDayRangeUtc, formatLocalDateTime, getResolvedTimeZone } from './localTimeService.js';
|
||||
import { parseCheckinRewardAmount } from './checkinRewardParser.js';
|
||||
import { estimateRewardWithTodayIncomeFallback } from './todayIncomeRewardService.js';
|
||||
|
||||
export type DailySummaryMetrics = {
|
||||
localDay: string;
|
||||
generatedAtLocal: string;
|
||||
timeZone: string;
|
||||
totalAccounts: number;
|
||||
activeAccounts: number;
|
||||
lowBalanceAccounts: number;
|
||||
checkinTotal: number;
|
||||
checkinSuccess: number;
|
||||
checkinSkipped: number;
|
||||
checkinFailed: number;
|
||||
proxyTotal: number;
|
||||
proxySuccess: number;
|
||||
proxyFailed: number;
|
||||
proxyTotalTokens: number;
|
||||
todaySpend: number;
|
||||
todayReward: number;
|
||||
};
|
||||
|
||||
function round6(value: number): number {
|
||||
return Math.round(value * 1_000_000) / 1_000_000;
|
||||
}
|
||||
|
||||
export function collectDailySummaryMetrics(now = new Date()): DailySummaryMetrics {
|
||||
const { localDay, startUtc, endUtc } = getLocalDayRangeUtc(now);
|
||||
|
||||
const accountRows = db.select().from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(eq(schema.sites.status, 'active'))
|
||||
.all();
|
||||
const accounts = accountRows.map((row) => row.accounts);
|
||||
|
||||
const activeAccounts = accounts.filter((account) => account.status === 'active').length;
|
||||
const lowBalanceAccounts = accounts.filter((account) => (account.balance || 0) < 1).length;
|
||||
|
||||
const todayCheckinRows = db.select().from(schema.checkinLogs)
|
||||
.innerJoin(schema.accounts, eq(schema.checkinLogs.accountId, schema.accounts.id))
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(and(
|
||||
gte(schema.checkinLogs.createdAt, startUtc),
|
||||
lt(schema.checkinLogs.createdAt, endUtc),
|
||||
eq(schema.sites.status, 'active'),
|
||||
))
|
||||
.all();
|
||||
const todayCheckins = todayCheckinRows.map((row) => row.checkin_logs);
|
||||
const checkinSkipped = todayCheckins.filter((checkin) => checkin.status === 'skipped').length;
|
||||
const checkinFailed = todayCheckins.filter((checkin) => checkin.status === 'failed').length;
|
||||
const checkinSuccess = todayCheckins.length - checkinSkipped - checkinFailed;
|
||||
|
||||
const rewardByAccount: Record<number, number> = {};
|
||||
const successCountByAccount: Record<number, number> = {};
|
||||
const parsedRewardCountByAccount: Record<number, number> = {};
|
||||
for (const row of todayCheckinRows) {
|
||||
const checkin = row.checkin_logs;
|
||||
if (checkin.status !== 'success') continue;
|
||||
const accountId = row.accounts.id;
|
||||
successCountByAccount[accountId] = (successCountByAccount[accountId] || 0) + 1;
|
||||
const rewardValue = parseCheckinRewardAmount(checkin.reward) || parseCheckinRewardAmount(checkin.message);
|
||||
if (rewardValue <= 0) continue;
|
||||
rewardByAccount[accountId] = (rewardByAccount[accountId] || 0) + rewardValue;
|
||||
parsedRewardCountByAccount[accountId] = (parsedRewardCountByAccount[accountId] || 0) + 1;
|
||||
}
|
||||
|
||||
const todayProxyRows = db.select().from(schema.proxyLogs)
|
||||
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
|
||||
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(and(
|
||||
gte(schema.proxyLogs.createdAt, startUtc),
|
||||
lt(schema.proxyLogs.createdAt, endUtc),
|
||||
eq(schema.sites.status, 'active'),
|
||||
))
|
||||
.all();
|
||||
const todayProxyLogs = todayProxyRows.map((row) => row.proxy_logs);
|
||||
const proxySuccess = todayProxyLogs.filter((log) => log.status === 'success').length;
|
||||
const proxyFailed = todayProxyLogs.filter((log) => log.status === 'failed').length;
|
||||
const proxyTotalTokens = todayProxyLogs.reduce((sum, log) => sum + (log.totalTokens || 0), 0);
|
||||
const todaySpend = todayProxyLogs.reduce((sum, log) => sum + (typeof log.estimatedCost === 'number' ? log.estimatedCost : 0), 0);
|
||||
|
||||
const todayReward = accounts.reduce((sum, account) => sum + estimateRewardWithTodayIncomeFallback({
|
||||
day: localDay,
|
||||
successCount: successCountByAccount[account.id] || 0,
|
||||
parsedRewardCount: parsedRewardCountByAccount[account.id] || 0,
|
||||
rewardSum: rewardByAccount[account.id] || 0,
|
||||
extraConfig: account.extraConfig,
|
||||
}), 0);
|
||||
|
||||
return {
|
||||
localDay,
|
||||
generatedAtLocal: formatLocalDateTime(now),
|
||||
timeZone: getResolvedTimeZone(),
|
||||
totalAccounts: accounts.length,
|
||||
activeAccounts,
|
||||
lowBalanceAccounts,
|
||||
checkinTotal: todayCheckins.length,
|
||||
checkinSuccess: Math.max(0, checkinSuccess),
|
||||
checkinSkipped,
|
||||
checkinFailed,
|
||||
proxyTotal: todayProxyLogs.length,
|
||||
proxySuccess,
|
||||
proxyFailed,
|
||||
proxyTotalTokens,
|
||||
todaySpend: round6(todaySpend),
|
||||
todayReward: round6(todayReward),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDailySummaryNotification(metrics: DailySummaryMetrics): { title: string; message: string } {
|
||||
const net = round6(metrics.todayReward - metrics.todaySpend);
|
||||
const title = `每日总结 ${metrics.localDay}`;
|
||||
const message = [
|
||||
`日期: ${metrics.localDay}`,
|
||||
`生成时间: ${metrics.generatedAtLocal} (${metrics.timeZone})`,
|
||||
'',
|
||||
`账号概览: 总计 ${metrics.totalAccounts} | 活跃 ${metrics.activeAccounts} | 低余额(<$1) ${metrics.lowBalanceAccounts}`,
|
||||
`签到统计: 总计 ${metrics.checkinTotal} | 成功 ${metrics.checkinSuccess} | 跳过 ${metrics.checkinSkipped} | 失败 ${metrics.checkinFailed}`,
|
||||
`代理统计: 总计 ${metrics.proxyTotal} | 成功 ${metrics.proxySuccess} | 失败 ${metrics.proxyFailed} | Tokens ${metrics.proxyTotalTokens.toLocaleString()}`,
|
||||
`费用统计: 支出 $${metrics.todaySpend.toFixed(6)} | 奖励 $${metrics.todayReward.toFixed(6)} | 净值 $${net.toFixed(6)}`,
|
||||
].join('\n');
|
||||
return { title, message };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { classifyFailureReason } from './failureReasonService.js';
|
||||
|
||||
describe('failureReasonService', () => {
|
||||
it('classifies turnstile requirement as manual verification', () => {
|
||||
const result = classifyFailureReason({
|
||||
message: 'Turnstile token 为空',
|
||||
status: 'failed',
|
||||
});
|
||||
expect(result.code).toBe('manual_turnstile_required');
|
||||
expect(result.category).toBe('verification');
|
||||
});
|
||||
|
||||
it('classifies cloudflare tunnel outage', () => {
|
||||
const result = classifyFailureReason({
|
||||
message: 'HTTP 530 Cloudflare Tunnel error | Error 1033',
|
||||
status: 'failed',
|
||||
httpStatus: 530,
|
||||
});
|
||||
expect(result.code).toBe('cloudflare_tunnel_unavailable');
|
||||
expect(result.category).toBe('network');
|
||||
});
|
||||
|
||||
it('classifies token errors using status and message', () => {
|
||||
const result = classifyFailureReason({
|
||||
message: 'invalid access token',
|
||||
status: 'failed',
|
||||
httpStatus: 401,
|
||||
});
|
||||
expect(result.code).toBe('token_expired');
|
||||
expect(result.category).toBe('auth');
|
||||
});
|
||||
|
||||
it('classifies already checked in as state info', () => {
|
||||
const result = classifyFailureReason({
|
||||
message: '今天已经签到过啦',
|
||||
status: 'success',
|
||||
});
|
||||
expect(result.code).toBe('already_checked_in');
|
||||
expect(result.category).toBe('state');
|
||||
});
|
||||
|
||||
it('classifies missing checkin endpoint as site capability issue', () => {
|
||||
const result = classifyFailureReason({
|
||||
message: 'checkin endpoint not found',
|
||||
status: 'skipped',
|
||||
});
|
||||
expect(result.code).toBe('checkin_not_supported');
|
||||
expect(result.category).toBe('site');
|
||||
expect(result.title).toBe('站点未开启签到');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { isCloudflareChallenge, isTokenExpiredError } from './alertRules.js';
|
||||
|
||||
type FailureReasonCategory =
|
||||
| 'verification'
|
||||
| 'auth'
|
||||
| 'network'
|
||||
| 'site'
|
||||
| 'state'
|
||||
| 'unknown';
|
||||
|
||||
type FailureReasonCode =
|
||||
| 'site_disabled'
|
||||
| 'checkin_not_supported'
|
||||
| 'manual_turnstile_required'
|
||||
| 'cloudflare_tunnel_unavailable'
|
||||
| 'cloudflare_challenge'
|
||||
| 'token_expired'
|
||||
| 'already_checked_in'
|
||||
| 'network_timeout'
|
||||
| 'upstream_error'
|
||||
| 'unknown_error';
|
||||
|
||||
type FailureReason = {
|
||||
code: FailureReasonCode;
|
||||
category: FailureReasonCategory;
|
||||
title: string;
|
||||
actionHint: string;
|
||||
detailHint: string;
|
||||
};
|
||||
|
||||
function includesAny(text: string, keywords: string[]): boolean {
|
||||
return keywords.some((keyword) => text.includes(keyword));
|
||||
}
|
||||
|
||||
export function classifyFailureReason(
|
||||
input: { message?: string | null; status?: string | null; httpStatus?: number | null },
|
||||
): FailureReason {
|
||||
const rawMessage = String(input.message || '').trim();
|
||||
const text = rawMessage.toLowerCase();
|
||||
const status = (input.status || '').toLowerCase();
|
||||
const httpStatus = typeof input.httpStatus === 'number' ? input.httpStatus : 0;
|
||||
|
||||
if (status === 'skipped' && includesAny(text, ['site disabled'])) {
|
||||
return {
|
||||
code: 'site_disabled',
|
||||
category: 'site',
|
||||
title: '站点已禁用',
|
||||
actionHint: '启用站点后再试',
|
||||
detailHint: '该账号所属站点处于禁用状态,任务会自动跳过。',
|
||||
};
|
||||
}
|
||||
|
||||
if (includesAny(text, ['checkin endpoint not found', '签到端点不存在', '站点不支持签到', 'not support checkin'])) {
|
||||
return {
|
||||
code: 'checkin_not_supported',
|
||||
category: 'site',
|
||||
title: '站点未开启签到',
|
||||
actionHint: '无需重试(非故障)',
|
||||
detailHint: '该站点未提供签到端点,账号会被自动跳过。',
|
||||
};
|
||||
}
|
||||
|
||||
if (includesAny(text, ['turnstile token 为空', 'turnstile']) && includesAny(text, ['校验', 'token', '验证', 'manual'])) {
|
||||
return {
|
||||
code: 'manual_turnstile_required',
|
||||
category: 'verification',
|
||||
title: '需要人工验证',
|
||||
actionHint: '浏览器先人工签到一次',
|
||||
detailHint: '站点开启了 Turnstile 人机验证,自动签到无法直接通过。',
|
||||
};
|
||||
}
|
||||
|
||||
if (includesAny(text, ['cloudflare tunnel error', 'error 1033', 'unable to resolve it'])) {
|
||||
return {
|
||||
code: 'cloudflare_tunnel_unavailable',
|
||||
category: 'network',
|
||||
title: '站点隧道不可用',
|
||||
actionHint: '稍后重试或联系站点方',
|
||||
detailHint: 'Cloudflare Tunnel 当前不可达,通常是站点侧网络或隧道进程问题。',
|
||||
};
|
||||
}
|
||||
|
||||
if (isCloudflareChallenge(rawMessage)) {
|
||||
return {
|
||||
code: 'cloudflare_challenge',
|
||||
category: 'verification',
|
||||
title: '触发 Cloudflare 验证',
|
||||
actionHint: '降低频率并稍后重试',
|
||||
detailHint: '请求触发了防护挑战,建议稍后再试或更换稳定站点。',
|
||||
};
|
||||
}
|
||||
|
||||
if (isTokenExpiredError({ status: httpStatus > 0 ? httpStatus : undefined, message: rawMessage })) {
|
||||
return {
|
||||
code: 'token_expired',
|
||||
category: 'auth',
|
||||
title: '令牌失效',
|
||||
actionHint: '重新登录或同步新令牌',
|
||||
detailHint: '账号访问令牌可能过期或无效,需更新认证信息。',
|
||||
};
|
||||
}
|
||||
|
||||
if (includesAny(text, ['already checked in', 'already signed', '今天已经签到', '今日已签到', '已经签到'])) {
|
||||
return {
|
||||
code: 'already_checked_in',
|
||||
category: 'state',
|
||||
title: '今日已签到',
|
||||
actionHint: '无需重复执行',
|
||||
detailHint: '该账号当天签到已完成,重复请求会被站点拒绝或跳过。',
|
||||
};
|
||||
}
|
||||
|
||||
if (includesAny(text, ['timeout', 'timed out', 'etimedout', '请求超时'])) {
|
||||
return {
|
||||
code: 'network_timeout',
|
||||
category: 'network',
|
||||
title: '请求超时',
|
||||
actionHint: '稍后重试并检查网络',
|
||||
detailHint: '请求在超时时间内未完成,可能是网络波动或站点响应慢。',
|
||||
};
|
||||
}
|
||||
|
||||
if (httpStatus >= 500 || includesAny(text, ['http 5', 'upstream', 'internal server error'])) {
|
||||
return {
|
||||
code: 'upstream_error',
|
||||
category: 'site',
|
||||
title: '上游站点错误',
|
||||
actionHint: '稍后重试',
|
||||
detailHint: '站点返回服务端错误,通常需要站点恢复后才可成功。',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: 'unknown_error',
|
||||
category: 'unknown',
|
||||
title: status === 'success' ? '执行成功' : '未知错误',
|
||||
actionHint: status === 'success' ? '无需操作' : '查看详细日志后重试',
|
||||
detailHint: status === 'success'
|
||||
? '任务已成功完成。'
|
||||
: '暂未识别到明确错误类型,可根据原始信息进一步排查。',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function pad2(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
export function formatLocalDate(value: Date): string {
|
||||
return `${value.getFullYear()}-${pad2(value.getMonth() + 1)}-${pad2(value.getDate())}`;
|
||||
}
|
||||
|
||||
export function formatLocalDateTime(value: Date): string {
|
||||
return `${formatLocalDate(value)} ${pad2(value.getHours())}:${pad2(value.getMinutes())}:${pad2(value.getSeconds())}`;
|
||||
}
|
||||
|
||||
export function getResolvedTimeZone(): string {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Local';
|
||||
}
|
||||
|
||||
export function formatUtcSqlDateTime(value: Date): string {
|
||||
return `${value.getUTCFullYear()}-${pad2(value.getUTCMonth() + 1)}-${pad2(value.getUTCDate())} ${pad2(value.getUTCHours())}:${pad2(value.getUTCMinutes())}:${pad2(value.getUTCSeconds())}`;
|
||||
}
|
||||
|
||||
export function parseStoredUtcDateTime(raw: string | null | undefined): Date | null {
|
||||
if (!raw) return null;
|
||||
const text = raw.trim();
|
||||
if (!text) return null;
|
||||
|
||||
let parsed: Date;
|
||||
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(text)) {
|
||||
parsed = new Date(`${text.replace(' ', 'T')}Z`);
|
||||
} else {
|
||||
parsed = new Date(text);
|
||||
}
|
||||
|
||||
if (Number.isNaN(parsed.getTime())) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function toLocalDayKeyFromStoredUtc(raw: string | null | undefined): string | null {
|
||||
const parsed = parseStoredUtcDateTime(raw);
|
||||
if (!parsed) return null;
|
||||
return formatLocalDate(parsed);
|
||||
}
|
||||
|
||||
export function getLocalDayRangeUtc(now = new Date()): {
|
||||
localDay: string;
|
||||
startUtc: string;
|
||||
endUtc: string;
|
||||
} {
|
||||
const localStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
||||
const nextLocalStart = new Date(localStart.getTime() + DAY_MS);
|
||||
return {
|
||||
localDay: formatLocalDate(now),
|
||||
startUtc: formatUtcSqlDateTime(localStart),
|
||||
endUtc: formatUtcSqlDateTime(nextLocalStart),
|
||||
};
|
||||
}
|
||||
|
||||
export function getLocalRangeStartUtc(days: number, now = new Date()): string {
|
||||
const normalizedDays = Math.max(1, Math.floor(days || 1));
|
||||
const localStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
||||
const start = new Date(localStart.getTime() - (normalizedDays - 1) * DAY_MS);
|
||||
return formatUtcSqlDateTime(start);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildModelAnalysis } from './modelAnalysisService.js';
|
||||
import { formatLocalDate } from './localTimeService.js';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
describe('buildModelAnalysis', () => {
|
||||
it('aggregates spend/calls data in a rolling day window', () => {
|
||||
const logs = [
|
||||
{
|
||||
createdAt: '2026-02-24T02:00:00.000Z',
|
||||
modelActual: 'gpt-4o',
|
||||
modelRequested: null,
|
||||
status: 'success',
|
||||
latencyMs: 800,
|
||||
totalTokens: 1000,
|
||||
estimatedCost: 1.25,
|
||||
},
|
||||
{
|
||||
createdAt: '2026-02-24T06:00:00.000Z',
|
||||
modelActual: null,
|
||||
modelRequested: 'gpt-4o',
|
||||
status: 'failed',
|
||||
latencyMs: 1200,
|
||||
totalTokens: 300,
|
||||
estimatedCost: 0.25,
|
||||
},
|
||||
{
|
||||
createdAt: '2026-02-23T08:00:00.000Z',
|
||||
modelActual: 'claude-3-5-sonnet',
|
||||
modelRequested: null,
|
||||
status: 'success',
|
||||
latencyMs: 500,
|
||||
totalTokens: 2000,
|
||||
estimatedCost: 2,
|
||||
},
|
||||
{
|
||||
createdAt: '2026-02-19T10:00:00.000Z',
|
||||
modelActual: 'gpt-4.1-mini',
|
||||
modelRequested: null,
|
||||
status: 'success',
|
||||
latencyMs: 200,
|
||||
totalTokens: 120,
|
||||
estimatedCost: 0.08,
|
||||
},
|
||||
];
|
||||
|
||||
const now = new Date('2026-02-24T12:00:00.000Z');
|
||||
const result = buildModelAnalysis(logs, {
|
||||
now,
|
||||
days: 3,
|
||||
});
|
||||
|
||||
const endDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
||||
const startDay = new Date(endDay.getTime() - 2 * DAY_MS);
|
||||
const dayKeys = Array.from({ length: 3 }, (_, index) =>
|
||||
formatLocalDate(new Date(startDay.getTime() + index * DAY_MS)),
|
||||
);
|
||||
const daySet = new Set(dayKeys);
|
||||
const spendByDay = new Map<string, number>(dayKeys.map((key) => [key, 0]));
|
||||
for (const log of logs) {
|
||||
const createdAt = new Date(log.createdAt);
|
||||
const dayKey = formatLocalDate(createdAt);
|
||||
if (!daySet.has(dayKey)) continue;
|
||||
spendByDay.set(dayKey, (spendByDay.get(dayKey) ?? 0) + (log.estimatedCost ?? 0));
|
||||
}
|
||||
|
||||
expect(result.window).toEqual({
|
||||
start: dayKeys[0],
|
||||
end: dayKeys[dayKeys.length - 1],
|
||||
days: 3,
|
||||
});
|
||||
expect(result.totals.calls).toBe(3);
|
||||
expect(result.totals.tokens).toBe(3300);
|
||||
expect(result.totals.spend).toBe(3.5);
|
||||
expect(result.spendTrend).toEqual(dayKeys.map((day) => ({
|
||||
day,
|
||||
spend: spendByDay.get(day) ?? 0,
|
||||
})));
|
||||
expect(result.callRanking[0]).toMatchObject({
|
||||
model: 'gpt-4o',
|
||||
calls: 2,
|
||||
successRate: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes unknown model names and invalid numbers', () => {
|
||||
const logs = [
|
||||
{
|
||||
createdAt: '2026-02-24T12:00:00.000Z',
|
||||
modelActual: null,
|
||||
modelRequested: null,
|
||||
status: 'success',
|
||||
latencyMs: null,
|
||||
totalTokens: null,
|
||||
estimatedCost: null,
|
||||
},
|
||||
{
|
||||
createdAt: 'invalid-date',
|
||||
modelActual: 'gpt-4o',
|
||||
modelRequested: null,
|
||||
status: 'success',
|
||||
latencyMs: 10,
|
||||
totalTokens: 10,
|
||||
estimatedCost: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildModelAnalysis(logs, {
|
||||
now: new Date('2026-02-24T12:00:00.000Z'),
|
||||
days: 1,
|
||||
});
|
||||
|
||||
expect(result.totals).toEqual({
|
||||
calls: 1,
|
||||
tokens: 0,
|
||||
spend: 0,
|
||||
});
|
||||
expect(result.callsDistribution[0]).toMatchObject({
|
||||
model: 'unknown',
|
||||
calls: 1,
|
||||
share: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to token-based spend estimation when estimatedCost is missing', () => {
|
||||
const logs = [
|
||||
{
|
||||
createdAt: '2026-02-24T12:30:00.000Z',
|
||||
modelActual: 'gpt-4o-mini',
|
||||
modelRequested: null,
|
||||
status: 'success',
|
||||
latencyMs: 120,
|
||||
totalTokens: 1000,
|
||||
estimatedCost: null,
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildModelAnalysis(logs, {
|
||||
now: new Date('2026-02-24T12:00:00.000Z'),
|
||||
days: 1,
|
||||
});
|
||||
|
||||
expect(result.totals.spend).toBe(0.002);
|
||||
expect(result.spendDistribution[0]).toMatchObject({
|
||||
model: 'gpt-4o-mini',
|
||||
spend: 0.002,
|
||||
calls: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { formatLocalDate, parseStoredUtcDateTime } from './localTimeService.js';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface ProxyLogLike {
|
||||
createdAt: string | null;
|
||||
modelActual: string | null;
|
||||
modelRequested: string | null;
|
||||
status: string | null;
|
||||
latencyMs: number | null;
|
||||
totalTokens: number | null;
|
||||
estimatedCost: number | null;
|
||||
}
|
||||
|
||||
interface ModelAnalysisResult {
|
||||
window: {
|
||||
start: string;
|
||||
end: string;
|
||||
days: number;
|
||||
};
|
||||
totals: {
|
||||
calls: number;
|
||||
tokens: number;
|
||||
spend: number;
|
||||
};
|
||||
spendDistribution: Array<{
|
||||
model: string;
|
||||
spend: number;
|
||||
calls: number;
|
||||
}>;
|
||||
spendTrend: Array<{
|
||||
day: string;
|
||||
spend: number;
|
||||
}>;
|
||||
callsDistribution: Array<{
|
||||
model: string;
|
||||
calls: number;
|
||||
share: number;
|
||||
}>;
|
||||
callRanking: Array<{
|
||||
model: string;
|
||||
calls: number;
|
||||
successRate: number;
|
||||
avgLatencyMs: number;
|
||||
spend: number;
|
||||
tokens: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface BuildOptions {
|
||||
now?: Date;
|
||||
days?: number;
|
||||
maxModels?: number;
|
||||
}
|
||||
|
||||
interface MutableModelStats {
|
||||
model: string;
|
||||
calls: number;
|
||||
success: number;
|
||||
latencyTotal: number;
|
||||
tokens: number;
|
||||
spend: number;
|
||||
}
|
||||
|
||||
function startOfLocalDay(value: Date): Date {
|
||||
return new Date(
|
||||
value.getFullYear(),
|
||||
value.getMonth(),
|
||||
value.getDate(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function dayKey(value: Date): string {
|
||||
return formatLocalDate(value);
|
||||
}
|
||||
|
||||
function toSafeNumber(value: unknown): number {
|
||||
if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) return 0;
|
||||
return value;
|
||||
}
|
||||
|
||||
function toPositiveInt(value: unknown): number {
|
||||
return Math.max(0, Math.round(toSafeNumber(value)));
|
||||
}
|
||||
|
||||
function round(value: number, digits = 2): number {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
function resolveModelName(log: ProxyLogLike): string {
|
||||
const raw = (log.modelActual || log.modelRequested || '').trim();
|
||||
return raw.length > 0 ? raw : 'unknown';
|
||||
}
|
||||
|
||||
function resolveSpend(log: ProxyLogLike, tokens: number): number {
|
||||
const explicit = toSafeNumber(log.estimatedCost);
|
||||
if (explicit > 0) return explicit;
|
||||
if (tokens <= 0) return 0;
|
||||
// Fallback to OneAPI/NewAPI quota conversion when explicit cost is absent.
|
||||
return tokens / 500000;
|
||||
}
|
||||
|
||||
export function buildModelAnalysis(logs: ProxyLogLike[], options: BuildOptions = {}): ModelAnalysisResult {
|
||||
const now = options.now ?? new Date();
|
||||
const days = Math.max(1, options.days ?? 7);
|
||||
const maxModels = Math.max(1, options.maxModels ?? 10);
|
||||
|
||||
const endDay = startOfLocalDay(now);
|
||||
const startDay = new Date(endDay.getTime() - (days - 1) * DAY_MS);
|
||||
|
||||
const dayKeys: string[] = [];
|
||||
for (let i = 0; i < days; i++) {
|
||||
dayKeys.push(dayKey(new Date(startDay.getTime() + i * DAY_MS)));
|
||||
}
|
||||
|
||||
const daySet = new Set(dayKeys);
|
||||
const spendTrendMap = new Map<string, number>(dayKeys.map((key) => [key, 0]));
|
||||
const modelMap = new Map<string, MutableModelStats>();
|
||||
|
||||
for (const log of logs) {
|
||||
if (!log?.createdAt) continue;
|
||||
const createdAt = parseStoredUtcDateTime(log.createdAt);
|
||||
if (!createdAt) continue;
|
||||
|
||||
const createdAtDay = dayKey(createdAt);
|
||||
if (!daySet.has(createdAtDay)) continue;
|
||||
|
||||
const model = resolveModelName(log);
|
||||
const tokens = toPositiveInt(log.totalTokens);
|
||||
const spend = Math.max(0, resolveSpend(log, tokens));
|
||||
const latency = toPositiveInt(log.latencyMs);
|
||||
const isSuccess = (log.status || '').toLowerCase() === 'success';
|
||||
|
||||
const stat = modelMap.get(model) ?? {
|
||||
model,
|
||||
calls: 0,
|
||||
success: 0,
|
||||
latencyTotal: 0,
|
||||
tokens: 0,
|
||||
spend: 0,
|
||||
};
|
||||
|
||||
stat.calls += 1;
|
||||
stat.success += isSuccess ? 1 : 0;
|
||||
stat.latencyTotal += latency;
|
||||
stat.tokens += tokens;
|
||||
stat.spend += spend;
|
||||
modelMap.set(model, stat);
|
||||
|
||||
spendTrendMap.set(createdAtDay, (spendTrendMap.get(createdAtDay) ?? 0) + spend);
|
||||
}
|
||||
|
||||
const stats = Array.from(modelMap.values());
|
||||
const totalCalls = stats.reduce((sum, item) => sum + item.calls, 0);
|
||||
const totalTokens = stats.reduce((sum, item) => sum + item.tokens, 0);
|
||||
const totalSpend = round(stats.reduce((sum, item) => sum + item.spend, 0), 6);
|
||||
|
||||
const spendDistribution = [...stats]
|
||||
.sort((a, b) => b.spend - a.spend)
|
||||
.slice(0, maxModels)
|
||||
.map((item) => ({
|
||||
model: item.model,
|
||||
spend: round(item.spend, 6),
|
||||
calls: item.calls,
|
||||
}));
|
||||
|
||||
const callsDistribution = [...stats]
|
||||
.sort((a, b) => b.calls - a.calls)
|
||||
.slice(0, maxModels)
|
||||
.map((item) => ({
|
||||
model: item.model,
|
||||
calls: item.calls,
|
||||
share: totalCalls > 0 ? round((item.calls / totalCalls) * 100, 2) : 0,
|
||||
}));
|
||||
|
||||
const callRanking = [...stats]
|
||||
.sort((a, b) => b.calls - a.calls)
|
||||
.slice(0, maxModels)
|
||||
.map((item) => ({
|
||||
model: item.model,
|
||||
calls: item.calls,
|
||||
successRate: item.calls > 0 ? round((item.success / item.calls) * 100, 2) : 0,
|
||||
avgLatencyMs: item.calls > 0 ? Math.round(item.latencyTotal / item.calls) : 0,
|
||||
spend: round(item.spend, 6),
|
||||
tokens: item.tokens,
|
||||
}));
|
||||
|
||||
const spendTrend = dayKeys.map((day) => ({
|
||||
day,
|
||||
spend: round(spendTrendMap.get(day) ?? 0, 6),
|
||||
}));
|
||||
|
||||
return {
|
||||
window: {
|
||||
start: dayKeys[0],
|
||||
end: dayKeys[dayKeys.length - 1],
|
||||
days,
|
||||
},
|
||||
totals: {
|
||||
calls: totalCalls,
|
||||
tokens: totalTokens,
|
||||
spend: totalSpend,
|
||||
},
|
||||
spendDistribution,
|
||||
spendTrend,
|
||||
callsDistribution,
|
||||
callRanking,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { calculateModelUsageCost, fallbackTokenCost, type PricingModel } from './modelPricingService.js';
|
||||
|
||||
describe('modelPricingService', () => {
|
||||
it('calculates token-based cost from model ratio and completion ratio', () => {
|
||||
const model: PricingModel = {
|
||||
modelName: 'gpt-4o',
|
||||
quotaType: 0,
|
||||
modelRatio: 2,
|
||||
completionRatio: 1.5,
|
||||
modelPrice: null,
|
||||
enableGroups: ['vip'],
|
||||
};
|
||||
|
||||
const cost = calculateModelUsageCost(
|
||||
model,
|
||||
{
|
||||
promptTokens: 1000,
|
||||
completionTokens: 500,
|
||||
totalTokens: 1500,
|
||||
},
|
||||
{ default: 1, vip: 2 },
|
||||
);
|
||||
|
||||
expect(cost).toBe(0.014);
|
||||
});
|
||||
|
||||
it('falls back to total tokens when split token usage is missing', () => {
|
||||
const model: PricingModel = {
|
||||
modelName: 'claude-sonnet',
|
||||
quotaType: 0,
|
||||
modelRatio: 1,
|
||||
completionRatio: 2,
|
||||
modelPrice: null,
|
||||
enableGroups: ['default'],
|
||||
};
|
||||
|
||||
const cost = calculateModelUsageCost(
|
||||
model,
|
||||
{
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 2000,
|
||||
},
|
||||
{ default: 1 },
|
||||
);
|
||||
|
||||
expect(cost).toBe(0.004);
|
||||
});
|
||||
|
||||
it('calculates per-call cost when quota type is call-based', () => {
|
||||
const model: PricingModel = {
|
||||
modelName: 'gpt-image-1',
|
||||
quotaType: 1,
|
||||
modelRatio: 1,
|
||||
completionRatio: 1,
|
||||
modelPrice: 0.3,
|
||||
enableGroups: ['vip'],
|
||||
};
|
||||
|
||||
const cost = calculateModelUsageCost(
|
||||
model,
|
||||
{
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
},
|
||||
{ default: 1, vip: 1.5 },
|
||||
);
|
||||
|
||||
expect(cost).toBe(0.45);
|
||||
});
|
||||
|
||||
it('calculates times-based per-call cost from input ratio only', () => {
|
||||
const model: PricingModel = {
|
||||
modelName: 'flux-kontext-pro',
|
||||
quotaType: 1,
|
||||
modelRatio: 1,
|
||||
completionRatio: 1,
|
||||
modelPrice: { input: 1, output: 3 },
|
||||
enableGroups: ['vip'],
|
||||
};
|
||||
|
||||
const cost = calculateModelUsageCost(
|
||||
model,
|
||||
{
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
},
|
||||
{ default: 1, vip: 2 },
|
||||
);
|
||||
|
||||
expect(cost).toBe(0.004);
|
||||
});
|
||||
|
||||
it('uses platform-specific fallback token divisor', () => {
|
||||
expect(fallbackTokenCost(1500, 'new-api')).toBe(0.003);
|
||||
expect(fallbackTokenCost(1500, 'veloera')).toBe(0.0015);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,598 @@
|
||||
import type { RequestInit as UndiciRequestInit } from 'undici';
|
||||
|
||||
const PRICE_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const PRICE_CACHE_FAILURE_TTL_MS = 60 * 1000;
|
||||
const PRICING_FETCH_TIMEOUT_MS = 8_000;
|
||||
const DEFAULT_GROUP = 'default';
|
||||
const ONE_HUB_PER_CALL_RATIO = 0.002;
|
||||
const MIN_ROUTING_REFERENCE_COST = 1e-6;
|
||||
const ROUTING_REFERENCE_USAGE = {
|
||||
promptTokens: 500_000,
|
||||
completionTokens: 500_000,
|
||||
totalTokens: 1_000_000,
|
||||
};
|
||||
|
||||
export interface PricingModel {
|
||||
modelName: string;
|
||||
quotaType: number;
|
||||
modelRatio: number;
|
||||
completionRatio: number;
|
||||
modelPrice: number | { input: number; output: number } | null;
|
||||
enableGroups: string[];
|
||||
modelDescription?: string | null;
|
||||
tags?: string[];
|
||||
supportedEndpointTypes?: string[];
|
||||
ownerBy?: string | null;
|
||||
}
|
||||
|
||||
interface PricingData {
|
||||
models: Map<string, PricingModel>;
|
||||
groupRatio: Record<string, number>;
|
||||
}
|
||||
|
||||
interface PricingCacheEntry {
|
||||
fetchedAt: number;
|
||||
ttlMs: number;
|
||||
data: PricingData | null;
|
||||
}
|
||||
|
||||
interface RoutingReferenceCostCacheEntry {
|
||||
fetchedAt: number;
|
||||
ttlMs: number;
|
||||
costs: Map<string, number>;
|
||||
}
|
||||
|
||||
interface EstimateProxyCostInput {
|
||||
site: {
|
||||
id: number;
|
||||
url: string;
|
||||
platform: string;
|
||||
apiKey?: string | null;
|
||||
};
|
||||
account: {
|
||||
id: number;
|
||||
accessToken?: string | null;
|
||||
apiToken?: string | null;
|
||||
};
|
||||
modelName: string;
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
}
|
||||
|
||||
interface ModelGroupPricing {
|
||||
quotaType: number;
|
||||
inputPerMillion?: number;
|
||||
outputPerMillion?: number;
|
||||
perCallInput?: number;
|
||||
perCallOutput?: number;
|
||||
perCallTotal?: number;
|
||||
}
|
||||
|
||||
interface ModelPricingCatalogEntry {
|
||||
modelName: string;
|
||||
quotaType: number;
|
||||
modelDescription: string | null;
|
||||
tags: string[];
|
||||
supportedEndpointTypes: string[];
|
||||
ownerBy: string | null;
|
||||
enableGroups: string[];
|
||||
groupPricing: Record<string, ModelGroupPricing>;
|
||||
}
|
||||
|
||||
interface ModelPricingCatalog {
|
||||
models: ModelPricingCatalogEntry[];
|
||||
groupRatio: Record<string, number>;
|
||||
}
|
||||
|
||||
const pricingCache = new Map<string, PricingCacheEntry>();
|
||||
const routingReferenceCostCache = new Map<string, RoutingReferenceCostCacheEntry>();
|
||||
|
||||
function toNumber(value: unknown, fallback = 0): number {
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
return n;
|
||||
}
|
||||
|
||||
function toPositiveInt(value: unknown): number {
|
||||
return Math.max(0, Math.round(toNumber(value, 0)));
|
||||
}
|
||||
|
||||
function roundCost(value: number): number {
|
||||
return Math.round(Math.max(0, value) * 1_000_000) / 1_000_000;
|
||||
}
|
||||
|
||||
function normalizeModelPrice(value: unknown): number | { input: number; output: number } | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
|
||||
const input = toNumber((value as any).input, Number.NaN);
|
||||
const output = toNumber((value as any).output, Number.NaN);
|
||||
if (Number.isNaN(input) && Number.isNaN(output)) return null;
|
||||
|
||||
return {
|
||||
input: Number.isNaN(input) ? 0 : input,
|
||||
output: Number.isNaN(output) ? 0 : output,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGroupRatio(raw: unknown): Record<string, number> {
|
||||
const result: Record<string, number> = {};
|
||||
if (raw && typeof raw === 'object') {
|
||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
const ratio = toNumber(value, 1);
|
||||
if (ratio > 0) result[key] = ratio;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(result).length === 0) {
|
||||
result[DEFAULT_GROUP] = 1;
|
||||
} else if (!(DEFAULT_GROUP in result)) {
|
||||
result[DEFAULT_GROUP] = 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeStringArray(raw: unknown): string[] {
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map((item) => String(item || '').trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
if (typeof raw === 'string') {
|
||||
return raw
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizePricingModels(rawModels: unknown[]): Map<string, PricingModel> {
|
||||
const models = new Map<string, PricingModel>();
|
||||
|
||||
for (const raw of rawModels) {
|
||||
if (!raw || typeof raw !== 'object') continue;
|
||||
|
||||
const modelName = String((raw as any).model_name || '').trim();
|
||||
if (!modelName) continue;
|
||||
|
||||
const quotaType = toPositiveInt((raw as any).quota_type);
|
||||
const modelRatio = toNumber((raw as any).model_ratio, 1);
|
||||
const completionRatio = toNumber((raw as any).completion_ratio, 1);
|
||||
const enableGroupsRaw = (raw as any).enable_groups;
|
||||
const enableGroups = Array.isArray(enableGroupsRaw)
|
||||
? enableGroupsRaw.map((item: unknown) => String(item || '').trim()).filter(Boolean)
|
||||
: [DEFAULT_GROUP];
|
||||
const modelDescriptionRaw = (raw as any).model_description;
|
||||
const modelDescription = typeof modelDescriptionRaw === 'string'
|
||||
? (modelDescriptionRaw.trim() || null)
|
||||
: null;
|
||||
const tags = normalizeStringArray((raw as any).tags);
|
||||
const supportedEndpointTypes = normalizeStringArray((raw as any).supported_endpoint_types);
|
||||
const ownerByRaw = (raw as any).owner_by;
|
||||
const ownerBy = typeof ownerByRaw === 'string' ? (ownerByRaw.trim() || null) : null;
|
||||
|
||||
models.set(modelName, {
|
||||
modelName,
|
||||
quotaType,
|
||||
modelRatio: modelRatio > 0 ? modelRatio : 1,
|
||||
completionRatio: completionRatio > 0 ? completionRatio : 1,
|
||||
modelPrice: normalizeModelPrice((raw as any).model_price),
|
||||
enableGroups: enableGroups.length > 0 ? enableGroups : [DEFAULT_GROUP],
|
||||
modelDescription,
|
||||
tags,
|
||||
supportedEndpointTypes,
|
||||
ownerBy,
|
||||
});
|
||||
}
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
function unwrapPayload(payload: unknown): unknown {
|
||||
if (!payload || typeof payload !== 'object') return payload;
|
||||
if ('data' in (payload as any)) return (payload as any).data;
|
||||
return payload;
|
||||
}
|
||||
|
||||
function normalizeCommonPricingPayload(payload: unknown): PricingData | null {
|
||||
const maybeData = unwrapPayload(payload);
|
||||
if (!Array.isArray(maybeData)) return null;
|
||||
|
||||
const models = normalizePricingModels(maybeData);
|
||||
if (models.size === 0) return null;
|
||||
|
||||
const groupRatio = normalizeGroupRatio((payload as any)?.group_ratio);
|
||||
return { models, groupRatio };
|
||||
}
|
||||
|
||||
function normalizeOneHubPricingPayload(availablePayload: unknown, groupPayload: unknown): PricingData | null {
|
||||
const available = unwrapPayload(availablePayload);
|
||||
if (!available || typeof available !== 'object') return null;
|
||||
|
||||
const transformed: unknown[] = [];
|
||||
for (const [modelName, rawValue] of Object.entries(available as Record<string, unknown>)) {
|
||||
const item = rawValue as any;
|
||||
const price = item?.price || {};
|
||||
const input = toNumber(price.input, 0);
|
||||
const output = toNumber(price.output, input);
|
||||
const isTokenType = String(price.type || '').toLowerCase() === 'tokens';
|
||||
|
||||
transformed.push({
|
||||
model_name: modelName,
|
||||
model_description: item?.description || item?.desc || '',
|
||||
quota_type: isTokenType ? 0 : 1,
|
||||
model_ratio: 1,
|
||||
completion_ratio: input > 0 ? output / input : 1,
|
||||
model_price: { input, output },
|
||||
enable_groups: Array.isArray(item?.groups) && item.groups.length > 0 ? item.groups : [DEFAULT_GROUP],
|
||||
supported_endpoint_types: Array.isArray(item?.supported_endpoint_types) ? item.supported_endpoint_types : [],
|
||||
tags: Array.isArray(item?.tags) ? item.tags : [],
|
||||
owner_by: item?.owned_by || item?.provider || null,
|
||||
});
|
||||
}
|
||||
|
||||
const models = normalizePricingModels(transformed);
|
||||
if (models.size === 0) return null;
|
||||
|
||||
const groupMap = unwrapPayload(groupPayload);
|
||||
const groupRatioSource: Record<string, number> = {};
|
||||
if (groupMap && typeof groupMap === 'object') {
|
||||
for (const [key, group] of Object.entries(groupMap as Record<string, any>)) {
|
||||
groupRatioSource[key] = toNumber(group?.ratio, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const groupRatio = normalizeGroupRatio(groupRatioSource);
|
||||
return { models, groupRatio };
|
||||
}
|
||||
|
||||
async function fetchJson(url: string, options?: UndiciRequestInit): Promise<unknown> {
|
||||
const { fetch } = await import('undici');
|
||||
const controller = new AbortController();
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, PRICING_FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
body: options?.body ?? undefined,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`pricing fetch timeout (${Math.round(PRICING_FETCH_TIMEOUT_MS / 1000)}s)`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
timeoutHandle = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUrl(baseUrl: string): string {
|
||||
return baseUrl.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function buildTokenCandidates(input: EstimateProxyCostInput): string[] {
|
||||
const candidates = [
|
||||
input.account.accessToken,
|
||||
input.account.apiToken,
|
||||
input.site.apiKey,
|
||||
].filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
|
||||
|
||||
return Array.from(new Set(candidates));
|
||||
}
|
||||
|
||||
async function fetchCommonPricing(baseUrl: string, token?: string): Promise<PricingData | null> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const payload = await fetchJson(`${baseUrl}/api/pricing`, { headers });
|
||||
return normalizeCommonPricingPayload(payload);
|
||||
}
|
||||
|
||||
async function fetchOneHubPricing(baseUrl: string, token?: string): Promise<PricingData | null> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const [availablePayload, groupPayload] = await Promise.all([
|
||||
fetchJson(`${baseUrl}/api/available_model`, { headers }),
|
||||
fetchJson(`${baseUrl}/api/user_group_map`, { headers }),
|
||||
]);
|
||||
|
||||
return normalizeOneHubPricingPayload(availablePayload, groupPayload);
|
||||
}
|
||||
|
||||
function getCacheKey(input: EstimateProxyCostInput): string {
|
||||
return `${input.site.id}:${input.account.id}`;
|
||||
}
|
||||
|
||||
function normalizeModelKey(modelName: string): string {
|
||||
return modelName.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function buildRoutingReferenceCostMap(data: PricingData): Map<string, number> {
|
||||
const costs = new Map<string, number>();
|
||||
for (const model of data.models.values()) {
|
||||
const cost = calculateModelUsageCost(model, ROUTING_REFERENCE_USAGE, data.groupRatio);
|
||||
if (!Number.isFinite(cost)) continue;
|
||||
costs.set(normalizeModelKey(model.modelName), Math.max(cost, MIN_ROUTING_REFERENCE_COST));
|
||||
}
|
||||
return costs;
|
||||
}
|
||||
|
||||
function syncRoutingReferenceCostCache(
|
||||
key: string,
|
||||
fetchedAt: number,
|
||||
ttlMs: number,
|
||||
data: PricingData | null,
|
||||
): void {
|
||||
if (!data) {
|
||||
routingReferenceCostCache.delete(key);
|
||||
return;
|
||||
}
|
||||
|
||||
routingReferenceCostCache.set(key, {
|
||||
fetchedAt,
|
||||
ttlMs,
|
||||
costs: buildRoutingReferenceCostMap(data),
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchPricingData(input: EstimateProxyCostInput): Promise<PricingData | null> {
|
||||
const baseUrl = normalizeUrl(input.site.url);
|
||||
const tokenCandidates = buildTokenCandidates(input);
|
||||
|
||||
const fetcher = input.site.platform === 'one-hub' || input.site.platform === 'done-hub'
|
||||
? fetchOneHubPricing
|
||||
: fetchCommonPricing;
|
||||
|
||||
for (const token of tokenCandidates) {
|
||||
try {
|
||||
const data = await fetcher(baseUrl, token);
|
||||
if (data && data.models.size > 0) return data;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Some sites expose pricing publicly.
|
||||
try {
|
||||
const data = await fetcher(baseUrl, undefined);
|
||||
if (data && data.models.size > 0) return data;
|
||||
} catch {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getPricingDataCached(input: EstimateProxyCostInput): Promise<PricingData | null> {
|
||||
const key = getCacheKey(input);
|
||||
const now = Date.now();
|
||||
const cached = pricingCache.get(key);
|
||||
if (cached && now - cached.fetchedAt < cached.ttlMs) {
|
||||
if (cached.data && !routingReferenceCostCache.has(key)) {
|
||||
syncRoutingReferenceCostCache(key, cached.fetchedAt, cached.ttlMs, cached.data);
|
||||
}
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const data = await fetchPricingData(input);
|
||||
const ttlMs = data ? PRICE_CACHE_TTL_MS : PRICE_CACHE_FAILURE_TTL_MS;
|
||||
pricingCache.set(key, {
|
||||
fetchedAt: now,
|
||||
ttlMs,
|
||||
data,
|
||||
});
|
||||
syncRoutingReferenceCostCache(key, now, ttlMs, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
export function getCachedModelRoutingReferenceCost(input: {
|
||||
siteId: number;
|
||||
accountId: number;
|
||||
modelName: string;
|
||||
}): number | null {
|
||||
const key = `${input.siteId}:${input.accountId}`;
|
||||
const cached = routingReferenceCostCache.get(key);
|
||||
if (!cached) return null;
|
||||
|
||||
if (Date.now() - cached.fetchedAt >= cached.ttlMs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cost = cached.costs.get(normalizeModelKey(input.modelName));
|
||||
if (typeof cost !== 'number' || !Number.isFinite(cost) || cost <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cost;
|
||||
}
|
||||
|
||||
function resolveModel(modelName: string, data: PricingData): PricingModel | null {
|
||||
const exact = data.models.get(modelName);
|
||||
if (exact) return exact;
|
||||
|
||||
const lower = modelName.toLowerCase();
|
||||
for (const [name, model] of data.models.entries()) {
|
||||
if (name.toLowerCase() === lower) return model;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveGroupMultiplier(model: PricingModel, groupRatio: Record<string, number>): number {
|
||||
if (model.enableGroups.includes(DEFAULT_GROUP) && groupRatio[DEFAULT_GROUP]) {
|
||||
return groupRatio[DEFAULT_GROUP];
|
||||
}
|
||||
|
||||
for (const group of model.enableGroups) {
|
||||
if (groupRatio[group]) return groupRatio[group];
|
||||
}
|
||||
|
||||
const first = Object.values(groupRatio).find((ratio) => ratio > 0);
|
||||
return first || 1;
|
||||
}
|
||||
|
||||
function calculatePerCallCost(
|
||||
modelPrice: number | { input: number; output: number } | null,
|
||||
multiplier: number,
|
||||
): number {
|
||||
if (typeof modelPrice === 'number') {
|
||||
return modelPrice * multiplier;
|
||||
}
|
||||
|
||||
if (modelPrice && typeof modelPrice === 'object') {
|
||||
// done-hub/one-hub times pricing follows input ratio only.
|
||||
return toNumber(modelPrice.input, 0) * multiplier * ONE_HUB_PER_CALL_RATIO;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function calculatePerCallPricing(
|
||||
modelPrice: number | { input: number; output: number } | null,
|
||||
multiplier: number,
|
||||
): { input?: number; output?: number; total: number } {
|
||||
if (typeof modelPrice === 'number') {
|
||||
const total = roundCost(modelPrice * multiplier);
|
||||
return { total };
|
||||
}
|
||||
|
||||
if (modelPrice && typeof modelPrice === 'object') {
|
||||
const input = roundCost(toNumber(modelPrice.input, 0) * multiplier * ONE_HUB_PER_CALL_RATIO);
|
||||
const output = roundCost(toNumber(modelPrice.output, 0) * multiplier * ONE_HUB_PER_CALL_RATIO);
|
||||
return {
|
||||
input,
|
||||
output,
|
||||
total: input,
|
||||
};
|
||||
}
|
||||
|
||||
return { total: 0 };
|
||||
}
|
||||
|
||||
export function calculateModelUsageCost(
|
||||
model: PricingModel,
|
||||
usage: { promptTokens: number; completionTokens: number; totalTokens: number },
|
||||
groupRatio: Record<string, number>,
|
||||
): number {
|
||||
const multiplier = resolveGroupMultiplier(model, groupRatio);
|
||||
|
||||
if (model.quotaType === 1) {
|
||||
return roundCost(calculatePerCallCost(model.modelPrice, multiplier));
|
||||
}
|
||||
|
||||
const totalTokens = toPositiveInt(usage.totalTokens);
|
||||
const promptTokens = toPositiveInt(usage.promptTokens);
|
||||
const completionTokens = toPositiveInt(usage.completionTokens);
|
||||
const hasSplit = promptTokens > 0 || completionTokens > 0;
|
||||
const effectivePrompt = hasSplit ? promptTokens : totalTokens;
|
||||
const effectiveCompletion = hasSplit ? completionTokens : 0;
|
||||
|
||||
const inputPerMillion = model.modelRatio * 2 * multiplier;
|
||||
const outputPerMillion = model.modelRatio * model.completionRatio * 2 * multiplier;
|
||||
|
||||
const cost = (effectivePrompt / 1_000_000) * inputPerMillion
|
||||
+ (effectiveCompletion / 1_000_000) * outputPerMillion;
|
||||
return roundCost(cost);
|
||||
}
|
||||
|
||||
export async function fetchModelPricingCatalog(input: EstimateProxyCostInput): Promise<ModelPricingCatalog | null> {
|
||||
const pricingData = await getPricingDataCached(input);
|
||||
if (!pricingData) return null;
|
||||
|
||||
const groups = Array.from(new Set([DEFAULT_GROUP, ...Object.keys(pricingData.groupRatio)]));
|
||||
const defaultMultiplier = pricingData.groupRatio[DEFAULT_GROUP] || 1;
|
||||
|
||||
const models: ModelPricingCatalogEntry[] = Array.from(pricingData.models.values())
|
||||
.map((model) => {
|
||||
const allowedGroups = Array.from(new Set([...(model.enableGroups || []), DEFAULT_GROUP]));
|
||||
const modelGroups = groups.filter((group) => allowedGroups.includes(group));
|
||||
const effectiveGroups = modelGroups.length > 0 ? modelGroups : [DEFAULT_GROUP];
|
||||
|
||||
const groupPricing = effectiveGroups.reduce<Record<string, ModelGroupPricing>>((acc, group) => {
|
||||
const multiplier = pricingData.groupRatio[group] || defaultMultiplier;
|
||||
if (model.quotaType === 1) {
|
||||
const perCall = calculatePerCallPricing(model.modelPrice, multiplier);
|
||||
acc[group] = {
|
||||
quotaType: 1,
|
||||
perCallInput: perCall.input,
|
||||
perCallOutput: perCall.output,
|
||||
perCallTotal: perCall.total,
|
||||
};
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc[group] = {
|
||||
quotaType: 0,
|
||||
inputPerMillion: roundCost(model.modelRatio * 2 * multiplier),
|
||||
outputPerMillion: roundCost(model.modelRatio * model.completionRatio * 2 * multiplier),
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
modelName: model.modelName,
|
||||
quotaType: model.quotaType,
|
||||
modelDescription: model.modelDescription || null,
|
||||
tags: model.tags || [],
|
||||
supportedEndpointTypes: model.supportedEndpointTypes || [],
|
||||
ownerBy: model.ownerBy || null,
|
||||
enableGroups: model.enableGroups || [DEFAULT_GROUP],
|
||||
groupPricing,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.modelName.localeCompare(b.modelName));
|
||||
|
||||
return {
|
||||
models,
|
||||
groupRatio: pricingData.groupRatio,
|
||||
};
|
||||
}
|
||||
|
||||
export function fallbackTokenCost(totalTokens: number, platform: string): number {
|
||||
const divisor = platform === 'veloera' ? 1_000_000 : 500_000;
|
||||
return roundCost(toPositiveInt(totalTokens) / divisor);
|
||||
}
|
||||
|
||||
export async function estimateProxyCost(input: EstimateProxyCostInput): Promise<number> {
|
||||
const promptTokens = toPositiveInt(input.promptTokens);
|
||||
const completionTokens = toPositiveInt(input.completionTokens);
|
||||
const totalTokens = toPositiveInt(input.totalTokens || (promptTokens + completionTokens));
|
||||
|
||||
try {
|
||||
const pricingData = await getPricingDataCached(input);
|
||||
if (!pricingData) {
|
||||
return fallbackTokenCost(totalTokens, input.site.platform);
|
||||
}
|
||||
|
||||
const model = resolveModel(input.modelName, pricingData);
|
||||
if (!model) {
|
||||
return fallbackTokenCost(totalTokens, input.site.platform);
|
||||
}
|
||||
|
||||
return calculateModelUsageCost(model, { promptTokens, completionTokens, totalTokens }, pricingData.groupRatio);
|
||||
} catch {
|
||||
return fallbackTokenCost(totalTokens, input.site.platform);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { getAdapter } from './platforms/index.js';
|
||||
import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accountTokenService.js';
|
||||
import { resolvePlatformUserId } from './accountExtraConfig.js';
|
||||
|
||||
const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
|
||||
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
|
||||
const MODEL_REFRESH_BATCH_SIZE = 3;
|
||||
|
||||
function isSiteDisabled(status?: string | null): boolean {
|
||||
return (status || 'active') === 'disabled';
|
||||
}
|
||||
|
||||
function normalizeModels(models: string[]): string[] {
|
||||
return Array.from(new Set(models.filter((model) => typeof model === 'string' && model.trim().length > 0)));
|
||||
}
|
||||
|
||||
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
fn(),
|
||||
new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshModelsForAccount(accountId: number) {
|
||||
const row = db.select().from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(eq(schema.accounts.id, accountId))
|
||||
.get();
|
||||
|
||||
if (!row) {
|
||||
return { accountId, refreshed: false, modelCount: 0, reason: 'account_not_found' };
|
||||
}
|
||||
|
||||
const account = row.accounts;
|
||||
const site = row.sites;
|
||||
const adapter = getAdapter(site.platform);
|
||||
|
||||
const accountTokens = db.select()
|
||||
.from(schema.accountTokens)
|
||||
.where(eq(schema.accountTokens.accountId, accountId))
|
||||
.all();
|
||||
|
||||
db.delete(schema.modelAvailability)
|
||||
.where(eq(schema.modelAvailability.accountId, accountId))
|
||||
.run();
|
||||
|
||||
for (const token of accountTokens) {
|
||||
db.delete(schema.tokenModelAvailability)
|
||||
.where(eq(schema.tokenModelAvailability.tokenId, token.id))
|
||||
.run();
|
||||
}
|
||||
|
||||
if (isSiteDisabled(site.status)) {
|
||||
return { accountId, refreshed: false, modelCount: 0, reason: 'site_disabled' };
|
||||
}
|
||||
|
||||
if (!adapter || account.status !== 'active') {
|
||||
return { accountId, refreshed: false, modelCount: 0, reason: 'adapter_or_status' };
|
||||
}
|
||||
|
||||
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
|
||||
let discoveredApiToken: string | null = null;
|
||||
|
||||
if (!account.apiToken && account.accessToken) {
|
||||
try {
|
||||
discoveredApiToken = await withTimeout(
|
||||
() => adapter.getApiToken(site.url, account.accessToken, platformUserId),
|
||||
API_TOKEN_DISCOVERY_TIMEOUT_MS,
|
||||
`api token discovery timeout (${Math.round(API_TOKEN_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
|
||||
);
|
||||
if (discoveredApiToken) {
|
||||
ensureDefaultTokenForAccount(account.id, discoveredApiToken, { name: 'default', source: 'sync' });
|
||||
db.update(schema.accounts).set({
|
||||
apiToken: discoveredApiToken,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.accounts.id, account.id)).run();
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
let enabledTokens = db.select()
|
||||
.from(schema.accountTokens)
|
||||
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true)))
|
||||
.all();
|
||||
|
||||
// Last fallback: if still no managed token but account has a legacy apiToken, mirror it into token table.
|
||||
if (enabledTokens.length === 0) {
|
||||
const fallback = discoveredApiToken || account.apiToken || null;
|
||||
if (fallback) {
|
||||
ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' });
|
||||
enabledTokens = db.select()
|
||||
.from(schema.accountTokens)
|
||||
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true)))
|
||||
.all();
|
||||
}
|
||||
}
|
||||
|
||||
const accountModels = new Set<string>();
|
||||
const modelLatency = new Map<string, number | null>();
|
||||
let scannedTokenCount = 0;
|
||||
|
||||
for (const token of enabledTokens) {
|
||||
const startedAt = Date.now();
|
||||
let models: string[] = [];
|
||||
|
||||
try {
|
||||
models = normalizeModels(
|
||||
await withTimeout(
|
||||
() => adapter.getModels(site.url, token.token, platformUserId),
|
||||
MODEL_DISCOVERY_TIMEOUT_MS,
|
||||
`model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
models = [];
|
||||
}
|
||||
|
||||
if (models.length === 0) continue;
|
||||
|
||||
const latencyMs = Date.now() - startedAt;
|
||||
const checkedAt = new Date().toISOString();
|
||||
|
||||
db.insert(schema.tokenModelAvailability).values(
|
||||
models.map((modelName) => ({
|
||||
tokenId: token.id,
|
||||
modelName,
|
||||
available: true,
|
||||
latencyMs,
|
||||
checkedAt,
|
||||
})),
|
||||
).run();
|
||||
|
||||
scannedTokenCount++;
|
||||
|
||||
for (const modelName of models) {
|
||||
accountModels.add(modelName);
|
||||
const prev = modelLatency.get(modelName);
|
||||
if (prev === undefined || prev === null || latencyMs < prev) {
|
||||
modelLatency.set(modelName, latencyMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (accountModels.size > 0) {
|
||||
const checkedAt = new Date().toISOString();
|
||||
db.insert(schema.modelAvailability).values(
|
||||
Array.from(accountModels).map((modelName) => ({
|
||||
accountId: account.id,
|
||||
modelName,
|
||||
available: true,
|
||||
latencyMs: modelLatency.get(modelName) ?? null,
|
||||
checkedAt,
|
||||
})),
|
||||
).run();
|
||||
}
|
||||
|
||||
return {
|
||||
accountId,
|
||||
refreshed: true,
|
||||
modelCount: accountModels.size,
|
||||
tokenScanned: scannedTokenCount,
|
||||
discoveredApiToken: !!discoveredApiToken,
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshModelsForAllActiveAccounts() {
|
||||
const accounts = db.select({ id: schema.accounts.id }).from(schema.accounts)
|
||||
.where(eq(schema.accounts.status, 'active'))
|
||||
.all();
|
||||
|
||||
const results = [];
|
||||
for (let offset = 0; offset < accounts.length; offset += MODEL_REFRESH_BATCH_SIZE) {
|
||||
const batch = accounts.slice(offset, offset + MODEL_REFRESH_BATCH_SIZE);
|
||||
const batchResults = await Promise.all(batch.map(async (account) => refreshModelsForAccount(account.id)));
|
||||
results.push(...batchResults);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export function rebuildTokenRoutesFromAvailability() {
|
||||
const tokenRows = db.select().from(schema.tokenModelAvailability)
|
||||
.innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id))
|
||||
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.tokenModelAvailability.available, true),
|
||||
eq(schema.accountTokens.enabled, true),
|
||||
eq(schema.accounts.status, 'active'),
|
||||
eq(schema.sites.status, 'active'),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
|
||||
const modelTokens = new Map<string, Map<number, number>>();
|
||||
for (const row of tokenRows) {
|
||||
const modelName = row.token_model_availability.modelName;
|
||||
if (!modelTokens.has(modelName)) modelTokens.set(modelName, new Map<number, number>());
|
||||
modelTokens.get(modelName)!.set(row.account_tokens.id, row.accounts.id);
|
||||
}
|
||||
|
||||
const routes = db.select().from(schema.tokenRoutes).all();
|
||||
const channels = db.select().from(schema.routeChannels).all();
|
||||
|
||||
let createdRoutes = 0;
|
||||
let createdChannels = 0;
|
||||
let removedChannels = 0;
|
||||
|
||||
for (const [modelName, tokenAccountMap] of modelTokens.entries()) {
|
||||
let route = routes.find((r) => r.modelPattern === modelName);
|
||||
if (!route) {
|
||||
route = db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: modelName,
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
routes.push(route);
|
||||
createdRoutes++;
|
||||
}
|
||||
|
||||
const routeChannels = channels.filter((channel) => channel.routeId === route.id);
|
||||
const desiredTokenIds = new Set<number>(Array.from(tokenAccountMap.keys()));
|
||||
|
||||
for (const [tokenId, accountId] of tokenAccountMap.entries()) {
|
||||
const exists = routeChannels.some((channel) => channel.tokenId === tokenId);
|
||||
if (exists) continue;
|
||||
|
||||
const created = db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId,
|
||||
tokenId,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
}).returning().get();
|
||||
channels.push(created);
|
||||
createdChannels++;
|
||||
}
|
||||
|
||||
for (const channel of routeChannels) {
|
||||
if (channel.tokenId && desiredTokenIds.has(channel.tokenId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!channel.tokenId) {
|
||||
const preferred = getPreferredAccountToken(channel.accountId);
|
||||
if (preferred && desiredTokenIds.has(preferred.id)) {
|
||||
db.update(schema.routeChannels)
|
||||
.set({ tokenId: preferred.id })
|
||||
.where(eq(schema.routeChannels.id, channel.id))
|
||||
.run();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!channel.manualOverride) {
|
||||
db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).run();
|
||||
removedChannels++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
models: modelTokens.size,
|
||||
createdRoutes,
|
||||
createdChannels,
|
||||
removedChannels,
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshModelsAndRebuildRoutes() {
|
||||
const refresh = await refreshModelsForAllActiveAccounts();
|
||||
const rebuild = rebuildTokenRoutesFromAvailability();
|
||||
return { refresh, rebuild };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createNotificationSignature,
|
||||
evaluateNotificationThrottle,
|
||||
pruneNotificationThrottleState,
|
||||
type NotificationThrottleState,
|
||||
} from './notificationThrottle.js';
|
||||
|
||||
describe('notificationThrottle', () => {
|
||||
it('suppresses duplicate notifications during cooldown and merges suppressed count after cooldown', () => {
|
||||
const state = new Map<string, NotificationThrottleState>();
|
||||
const signature = createNotificationSignature('代理全部失败', '模型=gpt-4o', 'error');
|
||||
|
||||
const first = evaluateNotificationThrottle(state, signature, 1_000, 300_000);
|
||||
expect(first.shouldSend).toBe(true);
|
||||
expect(first.mergedCount).toBe(0);
|
||||
|
||||
const second = evaluateNotificationThrottle(state, signature, 1_020, 300_000);
|
||||
expect(second.shouldSend).toBe(false);
|
||||
expect(second.mergedCount).toBe(0);
|
||||
|
||||
const third = evaluateNotificationThrottle(state, signature, 301_100, 300_000);
|
||||
expect(third.shouldSend).toBe(true);
|
||||
expect(third.mergedCount).toBe(1);
|
||||
});
|
||||
|
||||
it('prunes stale throttle state entries', () => {
|
||||
const state = new Map<string, NotificationThrottleState>();
|
||||
state.set('recent', { lastSentAtMs: 950_000, suppressedCount: 0 });
|
||||
state.set('stale', { lastSentAtMs: 1, suppressedCount: 3 });
|
||||
|
||||
pruneNotificationThrottleState(state, 1_000_000, 60_000);
|
||||
|
||||
expect(state.has('recent')).toBe(true);
|
||||
expect(state.has('stale')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
export type NotificationThrottleState = {
|
||||
lastSentAtMs: number;
|
||||
suppressedCount: number;
|
||||
};
|
||||
|
||||
export function createNotificationSignature(title: string, message: string, level: string): string {
|
||||
return [
|
||||
(level || '').trim().toLowerCase(),
|
||||
(title || '').trim(),
|
||||
(message || '').trim(),
|
||||
].join('||');
|
||||
}
|
||||
|
||||
export function evaluateNotificationThrottle(
|
||||
state: Map<string, NotificationThrottleState>,
|
||||
signature: string,
|
||||
nowMs: number,
|
||||
cooldownMs: number,
|
||||
): { shouldSend: boolean; mergedCount: number } {
|
||||
if (cooldownMs <= 0) {
|
||||
return { shouldSend: true, mergedCount: 0 };
|
||||
}
|
||||
|
||||
const current = state.get(signature);
|
||||
if (!current) {
|
||||
state.set(signature, { lastSentAtMs: nowMs, suppressedCount: 0 });
|
||||
return { shouldSend: true, mergedCount: 0 };
|
||||
}
|
||||
|
||||
if (nowMs - current.lastSentAtMs < cooldownMs) {
|
||||
current.suppressedCount += 1;
|
||||
state.set(signature, current);
|
||||
return { shouldSend: false, mergedCount: 0 };
|
||||
}
|
||||
|
||||
const mergedCount = current.suppressedCount;
|
||||
state.set(signature, { lastSentAtMs: nowMs, suppressedCount: 0 });
|
||||
return { shouldSend: true, mergedCount };
|
||||
}
|
||||
|
||||
export function pruneNotificationThrottleState(
|
||||
state: Map<string, NotificationThrottleState>,
|
||||
nowMs: number,
|
||||
staleMs: number,
|
||||
): void {
|
||||
if (staleMs <= 0) return;
|
||||
|
||||
for (const [key, value] of state.entries()) {
|
||||
if (nowMs - value.lastSentAtMs > staleMs) {
|
||||
state.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const sendMailMock = vi.fn();
|
||||
const createTransportMock = vi.fn(() => ({
|
||||
sendMail: (...args: unknown[]) => sendMailMock(...args),
|
||||
}));
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
vi.mock('nodemailer', () => ({
|
||||
default: {
|
||||
createTransport: (...args: unknown[]) => (createTransportMock as any)(...args),
|
||||
},
|
||||
createTransport: (...args: unknown[]) => (createTransportMock as any)(...args),
|
||||
}));
|
||||
|
||||
vi.mock('undici', () => ({
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
}));
|
||||
|
||||
describe('notifyService', () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
sendMailMock.mockReset();
|
||||
createTransportMock.mockClear();
|
||||
fetchMock.mockReset();
|
||||
|
||||
const { config } = await import('../config.js');
|
||||
config.notifyCooldownSec = 300;
|
||||
config.webhookEnabled = false;
|
||||
config.webhookUrl = '';
|
||||
config.barkEnabled = false;
|
||||
config.barkUrl = '';
|
||||
config.serverChanEnabled = false;
|
||||
config.serverChanKey = '';
|
||||
config.smtpEnabled = true;
|
||||
config.smtpHost = 'smtp.example.com';
|
||||
config.smtpPort = 465;
|
||||
config.smtpSecure = true;
|
||||
config.smtpUser = 'demo-user';
|
||||
config.smtpPass = 'demo-pass';
|
||||
config.smtpFrom = 'sender@example.com';
|
||||
config.smtpTo = 'receiver@example.com';
|
||||
});
|
||||
|
||||
it('bypasses cooldown when bypassThrottle is enabled', async () => {
|
||||
sendMailMock.mockResolvedValue({ accepted: ['receiver@example.com'] });
|
||||
const { sendNotification } = await import('./notifyService.js');
|
||||
|
||||
await (sendNotification as any)('测试通知', 'same-message', 'info', { bypassThrottle: true });
|
||||
await (sendNotification as any)('测试通知', 'same-message', 'info', { bypassThrottle: true });
|
||||
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws when strict delivery is required and no channels are enabled', async () => {
|
||||
const { config } = await import('../config.js');
|
||||
config.smtpEnabled = false;
|
||||
|
||||
const { sendNotification } = await import('./notifyService.js');
|
||||
await expect(
|
||||
(sendNotification as any)('测试通知', 'message', 'info', {
|
||||
requireChannel: true,
|
||||
throwOnFailure: true,
|
||||
}),
|
||||
).rejects.toThrow('未启用任何通知渠道');
|
||||
});
|
||||
|
||||
it('throws when strict delivery is required and all channel sends fail', async () => {
|
||||
sendMailMock.mockRejectedValue(new Error('smtp auth failed'));
|
||||
const { sendNotification } = await import('./notifyService.js');
|
||||
|
||||
await expect(
|
||||
(sendNotification as any)('测试通知', 'message', 'info', {
|
||||
bypassThrottle: true,
|
||||
throwOnFailure: true,
|
||||
}),
|
||||
).rejects.toThrow(/smtp auth failed|通知发送失败/);
|
||||
});
|
||||
|
||||
it('includes local time and utc time labels in smtp payload', async () => {
|
||||
sendMailMock.mockResolvedValue({ accepted: ['receiver@example.com'] });
|
||||
const { sendNotification } = await import('./notifyService.js');
|
||||
|
||||
await sendNotification('测试通知', 'message', 'info', { bypassThrottle: true });
|
||||
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
||||
const payload = sendMailMock.mock.calls[0]?.[0] as { text?: string };
|
||||
expect(payload?.text || '').toContain('Local Time:');
|
||||
expect(payload?.text || '').toContain('UTC Time:');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { fetch } from 'undici';
|
||||
import { config } from '../config.js';
|
||||
import nodemailer, { type Transporter } from 'nodemailer';
|
||||
import {
|
||||
createNotificationSignature,
|
||||
evaluateNotificationThrottle,
|
||||
pruneNotificationThrottleState,
|
||||
type NotificationThrottleState,
|
||||
} from './notificationThrottle.js';
|
||||
import { formatLocalDateTime, getResolvedTimeZone } from './localTimeService.js';
|
||||
|
||||
type NotificationChannel = 'webhook' | 'bark' | 'serverchan' | 'smtp';
|
||||
|
||||
export type SendNotificationOptions = {
|
||||
bypassThrottle?: boolean;
|
||||
requireChannel?: boolean;
|
||||
throwOnFailure?: boolean;
|
||||
};
|
||||
|
||||
export type NotificationDispatchResult = {
|
||||
throttled: boolean;
|
||||
attempted: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
failedChannels: NotificationChannel[];
|
||||
};
|
||||
|
||||
let cachedSmtpFingerprint = '';
|
||||
let cachedTransporter: Transporter | null = null;
|
||||
const notificationThrottleState = new Map<string, NotificationThrottleState>();
|
||||
|
||||
function getSmtpFingerprint() {
|
||||
return [
|
||||
config.smtpHost,
|
||||
config.smtpPort,
|
||||
config.smtpSecure ? '1' : '0',
|
||||
config.smtpUser,
|
||||
config.smtpPass,
|
||||
config.smtpFrom,
|
||||
config.smtpTo,
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function getSmtpTransporter() {
|
||||
const fingerprint = getSmtpFingerprint();
|
||||
if (cachedTransporter && cachedSmtpFingerprint === fingerprint) {
|
||||
return cachedTransporter;
|
||||
}
|
||||
|
||||
cachedTransporter = nodemailer.createTransport({
|
||||
host: config.smtpHost,
|
||||
port: config.smtpPort,
|
||||
secure: config.smtpSecure,
|
||||
auth: config.smtpUser
|
||||
? {
|
||||
user: config.smtpUser,
|
||||
pass: config.smtpPass,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
cachedSmtpFingerprint = fingerprint;
|
||||
return cachedTransporter;
|
||||
}
|
||||
|
||||
function buildTimeFootnote(now: Date): string {
|
||||
const timeZone = getResolvedTimeZone();
|
||||
return [
|
||||
`Local Time: ${formatLocalDateTime(now)} (${timeZone})`,
|
||||
`UTC Time: ${now.toISOString()}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export async function sendNotification(
|
||||
title: string,
|
||||
message: string,
|
||||
level: 'info' | 'warning' | 'error' = 'info',
|
||||
options: SendNotificationOptions = {},
|
||||
): Promise<NotificationDispatchResult> {
|
||||
const now = new Date();
|
||||
const timeFootnote = buildTimeFootnote(now);
|
||||
const { bypassThrottle = false, requireChannel = false, throwOnFailure = false } = options;
|
||||
const cooldownMs = Math.max(0, Math.trunc(config.notifyCooldownSec)) * 1000;
|
||||
let resolvedMessage = message;
|
||||
if (!bypassThrottle && cooldownMs > 0) {
|
||||
const nowMs = Date.now();
|
||||
pruneNotificationThrottleState(notificationThrottleState, nowMs, Math.max(cooldownMs * 6, 600_000));
|
||||
const signature = createNotificationSignature(title, message, level);
|
||||
const decision = evaluateNotificationThrottle(notificationThrottleState, signature, nowMs, cooldownMs);
|
||||
if (!decision.shouldSend) {
|
||||
return {
|
||||
throttled: true,
|
||||
attempted: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
failedChannels: [],
|
||||
};
|
||||
}
|
||||
if (decision.mergedCount > 0) {
|
||||
resolvedMessage = `${message}\n\n[通知合并] 冷静期内已合并 ${decision.mergedCount} 条重复告警`;
|
||||
}
|
||||
}
|
||||
|
||||
const tasks: Array<{ channel: NotificationChannel; run: () => Promise<unknown> }> = [];
|
||||
|
||||
if (config.webhookEnabled && config.webhookUrl) {
|
||||
tasks.push(
|
||||
{
|
||||
channel: 'webhook',
|
||||
run: async () => {
|
||||
const response = await fetch(config.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
message: resolvedMessage,
|
||||
level,
|
||||
timestamp: now.toISOString(),
|
||||
localTime: formatLocalDateTime(now),
|
||||
timeZone: getResolvedTimeZone(),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Webhook 响应状态 ${response.status}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (config.barkEnabled && config.barkUrl) {
|
||||
const barkBase = config.barkUrl.replace(/\/+$/, '');
|
||||
const url = `${barkBase}/${encodeURIComponent(title)}/${encodeURIComponent(resolvedMessage)}?group=AllApiHub&level=${encodeURIComponent(level)}`;
|
||||
tasks.push({
|
||||
channel: 'bark',
|
||||
run: async () => {
|
||||
const response = await fetch(url, { method: 'GET' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Bark 响应状态 ${response.status}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (config.serverChanEnabled && config.serverChanKey) {
|
||||
const form = new URLSearchParams({
|
||||
title,
|
||||
desp: `${resolvedMessage}\n\nLevel: ${level}\n${timeFootnote}`,
|
||||
});
|
||||
tasks.push(
|
||||
{
|
||||
channel: 'serverchan',
|
||||
run: async () => {
|
||||
const response = await fetch(`https://sctapi.ftqq.com/${config.serverChanKey}.send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: form.toString(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server酱响应状态 ${response.status}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
config.smtpEnabled &&
|
||||
config.smtpHost &&
|
||||
config.smtpPort > 0 &&
|
||||
config.smtpFrom &&
|
||||
config.smtpTo
|
||||
) {
|
||||
const transporter = getSmtpTransporter();
|
||||
tasks.push(
|
||||
{
|
||||
channel: 'smtp',
|
||||
run: () => transporter.sendMail({
|
||||
from: config.smtpFrom,
|
||||
to: config.smtpTo,
|
||||
subject: `[metapi][${level.toUpperCase()}] ${title}`,
|
||||
text: `${resolvedMessage}\n\nLevel: ${level}\n${timeFootnote}`,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
if (requireChannel || throwOnFailure) {
|
||||
throw new Error('未启用任何通知渠道,请先开启并保存至少一种通知方式');
|
||||
}
|
||||
return {
|
||||
throttled: false,
|
||||
attempted: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
failedChannels: [],
|
||||
};
|
||||
}
|
||||
|
||||
const results = await Promise.all(tasks.map(async (task) => {
|
||||
try {
|
||||
await task.run();
|
||||
return { channel: task.channel, ok: true as const, error: '' };
|
||||
} catch (error: any) {
|
||||
return {
|
||||
channel: task.channel,
|
||||
ok: false as const,
|
||||
error: error?.message || String(error) || 'unknown error',
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
const failedResults = results.filter((item) => !item.ok);
|
||||
const succeeded = results.length - failedResults.length;
|
||||
const failedChannels = failedResults.map((item) => item.channel);
|
||||
|
||||
if (throwOnFailure && succeeded === 0 && failedResults.length > 0) {
|
||||
throw new Error(`通知发送失败:${failedResults[0].error}`);
|
||||
}
|
||||
|
||||
return {
|
||||
throttled: false,
|
||||
attempted: results.length,
|
||||
succeeded,
|
||||
failed: failedResults.length,
|
||||
failedChannels,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<html><script>var arg1='B8B78DDA1D5CD2598AAEE9DD6975913AA490BAE3';(function(a,c){var G=a0j,d=a();while(!![]){try{var e=-parseInt(G(0x117))/0x1*(parseInt(G(0x111))/0x2)+-parseInt(G(0xfb))/0x3*(parseInt(G(0x10e))/0x4)+-parseInt(G(0x101))/0x5*(-parseInt(G(0xfd))/0x6)+-parseInt(G(0x102))/0x7*(parseInt(G(0x122))/0x8)+parseInt(G(0x112))/0x9+parseInt(G(0x11d))/0xa*(parseInt(G(0x11c))/0xb)+parseInt(G(0x114))/0xc;if(e===c)break;else d['push'](d['shift']());}catch(f){d['push'](d['shift']());}}}(a0i,0x760bf),!(function(){var L=a0j,j=(function(){var B=!![];return function(C,D){var E=B?function(){var H=a0j;if(D){var F=D[H(0x10d)](C,arguments);return D=null,F;}}:function(){};return B=![],E;};}()),k=j(this,function(){var I=a0j;return k[I(0xff)]()[I(0x123)](I(0x10f))[I(0xff)]()[I(0x107)](k)[I(0x123)](I(0x10f));});k();var l=(function(){var B=!![];return function(C,D){var E=B?function(){var J=a0j;if(D){var F=D[J(0x10d)](C,arguments);return D=null,F;}}:function(){};return B=![],E;};}());(function(){l(this,function(){var K=a0j,B=new RegExp(K(0x118)),C=new RegExp(K(0x106),'i'),D=b(K(0x100));!B[K(0x104)](D+K(0x105))||!C[K(0x104)](D+K(0x11b))?D('0'):b();})();}());for(var m=[0xf,0x23,0x1d,0x18,0x21,0x10,0x1,0x26,0xa,0x9,0x13,0x1f,0x28,0x1b,0x16,0x17,0x19,0xd,0x6,0xb,0x27,0x12,0x14,0x8,0xe,0x15,0x20,0x1a,0x2,0x1e,0x7,0x4,0x11,0x5,0x3,0x1c,0x22,0x25,0xc,0x24],p=L(0x115),q=[],u='',v='',w=L(0x116),x=0x0;x<arg1[w];x++)for(var y=arg1[x],z=0x0;z<m[w];z++)m[z]==x+0x1&&(q[z]=y);for(u=q[L(0xfc)](''),x=0x0;x<u[w]&&x<p[w];x+=0x2){var A=(parseInt(u[L(0x11a)](x,x+0x2),0x10)^parseInt(p[L(0x11a)](x,x+0x2),0x10))[L(0xff)](0x10);0x1==A[w]&&(A='0'+A),v+=A;}document[L(0x121)]='acw_sc__v2='+v+L(0x120)+new Date(Date[L(0x119)]()+0x36ee80)[L(0x10c)]()+L(0x109),document[L(0xfe)][L(0x103)]();}()));function a0i(){var N=['mJKZmgTStNvVyq','C3rYAw5N','y2fSBa','o2v4CgLYzxm9','y29VA2LL','mteZmZy3mNLbu2PszW','C2vHCMnO','D2HPBguGkhrYDwuPihT9','mJq1ndi0rKHuthnj','AM9PBG','nNH1rKHOuq','Bg9JyxrPB24','Dg9tDhjPBMC','Aw5PDa','mJi2odi1nwnMre1IyG','n0HxChPJva','CMvSB2fK','DgvZDa','y2HHAw4','xcTCkYaQkd86w2eTEKeTwL8KxvSWltLHlxPblvPFjf0Qkq','y29UC3rYDwn0B3i','y291BNrLCG','o21HEc1Hz2u9mZyWmdTWyxrOps87','zgvIDq','ywn0Aw9U','Dg9htvrtDhjPBMC','yxbWBhK','mJbiru1MChi','kcGOlISPkYKRksSK','z2DLCG','nKHJq01Aqq','nJe5nZu5ogH1twPUDa','C3rHDgvpyMPLy3q','mZu5mdu5mNbcB2Pxyq','mZaWmde3nJaWmdG1nJaWnJa2mtuWmtuZmZaWmZy5mdaYnZGWmdm3nq','BgvUz3rO','mtqWnti2shvUBNDv','zNvUy3rPB24GkLWOicPCkq','BM93','C2XPy2u','Aw5WDxq','ntm5BwrLuMXi'];a0i=function(){return N;};return a0i();}function a0j(a,b){var c=a0i();return a0j=function(d,e){d=d-0xfb;var f=c[d];if(a0j['tGHEKR']===undefined){var g=function(l){var m='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';var n='',o='',p=n+g;for(var q=0x0,r,s,t=0x0;s=l['charAt'](t++);~s&&(r=q%0x4?r*0x40+s:s,q++%0x4)?n+=p['charCodeAt'](t+0xa)-0xa!==0x0?String['fromCharCode'](0xff&r>>(-0x2*q&0x6)):q:0x0){s=m['indexOf'](s);}for(var u=0x0,v=n['length'];u<v;u++){o+='%'+('00'+n['charCodeAt'](u)['toString'](0x10))['slice'](-0x2);}return decodeURIComponent(o);};a0j['CrMtTV']=g,a=arguments,a0j['tGHEKR']=!![];}var h=c[0x0],i=d+h,j=a[i];if(!j){var k=function(l){this['jyamLv']=l,this['WxwaRR']=[0x1,0x0,0x0],this['GuwnVk']=function(){return'newState';},this['CxWuMi']='\x5cw+\x20*\x5c(\x5c)\x20*{\x5cw+\x20*',this['FxAgTK']='[\x27|\x22].+[\x27|\x22];?\x20*}';};k['prototype']['dQvDam']=function(){var l=new RegExp(this['CxWuMi']+this['FxAgTK']),m=l['test'](this['GuwnVk']['toString']())?--this['WxwaRR'][0x1]:--this['WxwaRR'][0x0];return this['fQYikn'](m);},k['prototype']['fQYikn']=function(l){if(!Boolean(~l))return l;return this['Hbwxmd'](this['jyamLv']);},k['prototype']['Hbwxmd']=function(l){for(var m=0x0,n=this['WxwaRR']['length'];m<n;m++){this['WxwaRR']['push'](Math['round'](Math['random']())),n=this['WxwaRR']['length'];}return l(this['WxwaRR'][0x0]);},new k(a0j)['dQvDam'](),f=a0j['CrMtTV'](f),a[i]=f;}else f=j;return f;},a0j(a,b);}function b(a){function c(d){var M=a0j;if(typeof d===M(0x11e))return function(e){}[M(0x107)](M(0x124))[M(0x10d)](M(0x108));else(''+d/d)[M(0x116)]!==0x1||d%0x14===0x0?function(){return!![];}[M(0x107)](M(0x10a)+M(0x110))[M(0x11f)](M(0x10b)):function(){return![];}[M(0x107)](M(0x10a)+M(0x110))[M(0x10d)](M(0x113));c(++d);}try{if(a)return c;else c(0x0);}catch(d){}}</script></html>
|
||||
@@ -0,0 +1,10 @@
|
||||
import { NewApiAdapter } from './newApi.js';
|
||||
|
||||
export class AnyRouterAdapter extends NewApiAdapter {
|
||||
readonly platformName = 'anyrouter';
|
||||
|
||||
async detect(url: string): Promise<boolean> {
|
||||
const normalized = (url || '').toLowerCase();
|
||||
return normalized.includes('anyrouter');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { RequestInit as UndiciRequestInit } from 'undici';
|
||||
|
||||
export interface CheckinResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
reward?: string;
|
||||
}
|
||||
|
||||
export interface BalanceInfo {
|
||||
balance: number;
|
||||
used: number;
|
||||
quota: number;
|
||||
todayIncome?: number;
|
||||
todayQuotaConsumption?: number;
|
||||
}
|
||||
|
||||
interface LoginResult {
|
||||
success: boolean;
|
||||
accessToken?: string;
|
||||
username?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
username: string;
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
role?: number;
|
||||
}
|
||||
|
||||
export interface TokenVerifyResult {
|
||||
tokenType: 'session' | 'apikey' | 'unknown';
|
||||
userInfo?: UserInfo | null;
|
||||
balance?: BalanceInfo | null;
|
||||
apiToken?: string | null;
|
||||
models?: string[];
|
||||
}
|
||||
|
||||
export interface ApiTokenInfo {
|
||||
name: string;
|
||||
key: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateApiTokenOptions {
|
||||
name?: string;
|
||||
group?: string;
|
||||
unlimitedQuota?: boolean;
|
||||
remainQuota?: number;
|
||||
expiredTime?: number;
|
||||
allowIps?: string;
|
||||
modelLimitsEnabled?: boolean;
|
||||
modelLimits?: string;
|
||||
}
|
||||
|
||||
export interface PlatformAdapter {
|
||||
readonly platformName: string;
|
||||
detect(url: string): Promise<boolean>;
|
||||
login(baseUrl: string, username: string, password: string): Promise<LoginResult>;
|
||||
getUserInfo(baseUrl: string, accessToken: string, platformUserId?: number): Promise<UserInfo | null>;
|
||||
verifyToken(baseUrl: string, token: string, platformUserId?: number): Promise<TokenVerifyResult>;
|
||||
checkin(baseUrl: string, accessToken: string, platformUserId?: number): Promise<CheckinResult>;
|
||||
getBalance(baseUrl: string, accessToken: string, platformUserId?: number): Promise<BalanceInfo>;
|
||||
getModels(baseUrl: string, token: string, platformUserId?: number): Promise<string[]>;
|
||||
getApiToken(baseUrl: string, accessToken: string, platformUserId?: number): Promise<string | null>;
|
||||
getApiTokens(baseUrl: string, accessToken: string, platformUserId?: number): Promise<ApiTokenInfo[]>;
|
||||
getUserGroups(baseUrl: string, accessToken: string, platformUserId?: number): Promise<string[]>;
|
||||
createApiToken(baseUrl: string, accessToken: string, platformUserId?: number, options?: CreateApiTokenOptions): Promise<boolean>;
|
||||
deleteApiToken(baseUrl: string, accessToken: string, tokenKey: string, platformUserId?: number): Promise<boolean>;
|
||||
}
|
||||
|
||||
export abstract class BasePlatformAdapter implements PlatformAdapter {
|
||||
abstract readonly platformName: string;
|
||||
|
||||
abstract detect(url: string): Promise<boolean>;
|
||||
abstract checkin(baseUrl: string, accessToken: string): Promise<CheckinResult>;
|
||||
abstract getBalance(baseUrl: string, accessToken: string): Promise<BalanceInfo>;
|
||||
abstract getModels(baseUrl: string, token: string, platformUserId?: number): Promise<string[]>;
|
||||
|
||||
async verifyToken(baseUrl: string, token: string, _platformUserId?: number): Promise<TokenVerifyResult> {
|
||||
// 1. Try as session/access token first (for management APIs)
|
||||
const userInfo = await this.getUserInfo(baseUrl, token);
|
||||
if (userInfo) {
|
||||
let balance: BalanceInfo | null = null;
|
||||
try { balance = await this.getBalance(baseUrl, token); } catch {}
|
||||
let apiToken: string | null = null;
|
||||
try { apiToken = await this.getApiToken(baseUrl, token); } catch {}
|
||||
return { tokenType: 'session', userInfo, balance, apiToken };
|
||||
}
|
||||
|
||||
// 2. Try as API key (for /v1/models)
|
||||
try {
|
||||
const models = await this.getModels(baseUrl, token);
|
||||
if (models && models.length > 0) {
|
||||
return { tokenType: 'apikey', models };
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return { tokenType: 'unknown' };
|
||||
}
|
||||
|
||||
async getUserInfo(baseUrl: string, accessToken: string): Promise<UserInfo | null> {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}/api/user/self`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (res?.success && res?.data) {
|
||||
return {
|
||||
username: res.data.username || res.data.display_name || '',
|
||||
displayName: res.data.display_name,
|
||||
email: res.data.email,
|
||||
role: res.data.role,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async login(baseUrl: string, username: string, password: string): Promise<LoginResult> {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}/api/user/login`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (res?.success && res?.data) {
|
||||
return {
|
||||
success: true,
|
||||
accessToken: typeof res.data === 'string' ? res.data : res.data.token || res.data.access_token,
|
||||
username,
|
||||
};
|
||||
}
|
||||
return { success: false, message: res?.message || '登录失败' };
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message || '登录请求失败' };
|
||||
}
|
||||
}
|
||||
|
||||
async getApiToken(_baseUrl: string, _accessToken: string, _platformUserId?: number): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async getApiTokens(baseUrl: string, accessToken: string, platformUserId?: number): Promise<ApiTokenInfo[]> {
|
||||
const token = await this.getApiToken(baseUrl, accessToken, platformUserId);
|
||||
if (!token) return [];
|
||||
return [{ name: 'default', key: token, enabled: true }];
|
||||
}
|
||||
|
||||
async createApiToken(
|
||||
_baseUrl: string,
|
||||
_accessToken: string,
|
||||
_platformUserId?: number,
|
||||
_options?: CreateApiTokenOptions,
|
||||
): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async getUserGroups(
|
||||
_baseUrl: string,
|
||||
_accessToken: string,
|
||||
_platformUserId?: number,
|
||||
): Promise<string[]> {
|
||||
return ['default'];
|
||||
}
|
||||
|
||||
async deleteApiToken(
|
||||
_baseUrl: string,
|
||||
_accessToken: string,
|
||||
_tokenKey: string,
|
||||
_platformUserId?: number,
|
||||
): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected async fetchJson<T>(url: string, options?: UndiciRequestInit): Promise<T> {
|
||||
const { fetch } = await import('undici');
|
||||
const requestOptions: UndiciRequestInit = {
|
||||
...options,
|
||||
body: options?.body ?? undefined,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
};
|
||||
const res = await fetch(url, requestOptions);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import { DoneHubAdapter } from './doneHub.js';
|
||||
|
||||
describe('DoneHubAdapter', () => {
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||
if (req.url === '/v1/models') {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'unauthorized' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === '/api/available_model') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
'gpt-4o': { price: { input: 0.5, output: 1.5 } },
|
||||
'deepseek-chat': { price: { input: 0.1, output: 0.2 } },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url?.startsWith('/api/token/')) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
data: {
|
||||
data: [
|
||||
{ key: 'donehub-token-key', name: 'sys_playground', status: 1 },
|
||||
],
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'not found' }));
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
const addr = server.address() as AddressInfo;
|
||||
baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err?: Error) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
});
|
||||
|
||||
it('marks checkin as unsupported', async () => {
|
||||
const adapter = new DoneHubAdapter();
|
||||
const result = await adapter.checkin(baseUrl, 'token');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('checkin endpoint not found');
|
||||
});
|
||||
|
||||
it('falls back to /api/available_model when /v1/models is unavailable', async () => {
|
||||
const adapter = new DoneHubAdapter();
|
||||
const models = await adapter.getModels(baseUrl, 'token');
|
||||
expect(models).toEqual(['gpt-4o', 'deepseek-chat']);
|
||||
});
|
||||
|
||||
it('parses token list from nested data.data shape', async () => {
|
||||
const adapter = new DoneHubAdapter();
|
||||
const tokens = await adapter.getApiTokens(baseUrl, 'token');
|
||||
expect(tokens).toEqual([
|
||||
{ key: 'donehub-token-key', name: 'sys_playground', enabled: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { OneApiAdapter } from './oneApi.js';
|
||||
import type { CheckinResult } from './base.js';
|
||||
|
||||
export class DoneHubAdapter extends OneApiAdapter {
|
||||
readonly platformName = 'done-hub';
|
||||
|
||||
async detect(url: string): Promise<boolean> {
|
||||
const normalized = url.toLowerCase();
|
||||
return normalized.includes('donehub') || normalized.includes('done-hub');
|
||||
}
|
||||
|
||||
// DoneHub deployments generally do not expose /api/user/checkin.
|
||||
// Mark as unsupported so higher-level logic records it as skipped instead of failed.
|
||||
override async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
|
||||
return { success: false, message: 'checkin endpoint not found' };
|
||||
}
|
||||
|
||||
override async getModels(baseUrl: string, apiToken: string, _platformUserId?: number): Promise<string[]> {
|
||||
let openAiModels: string[] = [];
|
||||
try {
|
||||
openAiModels = await super.getModels(baseUrl, apiToken, _platformUserId);
|
||||
} catch {}
|
||||
if (openAiModels.length > 0) return openAiModels;
|
||||
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}/api/available_model`, {
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
});
|
||||
|
||||
const payload = (res?.data && typeof res.data === 'object') ? res.data : res;
|
||||
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
||||
const models = Object.keys(payload).filter(Boolean);
|
||||
if (models.length > 0) return models;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { detectPlatform, getAdapter } from './index.js';
|
||||
|
||||
describe('getAdapter platform aliases', () => {
|
||||
it('returns dedicated anyrouter adapter for anyrouter alias', () => {
|
||||
const adapter = getAdapter('anyrouter');
|
||||
expect(adapter?.platformName).toBe('anyrouter');
|
||||
});
|
||||
|
||||
it('handles case-insensitive platform strings', () => {
|
||||
const adapter = getAdapter('Veloera');
|
||||
expect(adapter?.platformName).toBe('veloera');
|
||||
});
|
||||
|
||||
it('returns undefined for unknown platforms', () => {
|
||||
expect(getAdapter('unknown-platform')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('detects anyrouter URL before generic new-api adapter', async () => {
|
||||
const adapter = await detectPlatform('https://anyrouter.top');
|
||||
expect(adapter?.platformName).toBe('anyrouter');
|
||||
});
|
||||
|
||||
it('detects done-hub URL before generic adapters', async () => {
|
||||
const adapter = await detectPlatform('https://demo.donehub.example');
|
||||
expect(adapter?.platformName).toBe('done-hub');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { PlatformAdapter } from './base.js';
|
||||
import { AnyRouterAdapter } from './anyrouter.js';
|
||||
import { NewApiAdapter } from './newApi.js';
|
||||
import { OneApiAdapter } from './oneApi.js';
|
||||
import { VeloeraAdapter } from './veloera.js';
|
||||
import { OneHubAdapter } from './oneHub.js';
|
||||
import { DoneHubAdapter } from './doneHub.js';
|
||||
import { Sub2ApiAdapter } from './sub2api.js';
|
||||
|
||||
const adapters: PlatformAdapter[] = [
|
||||
// Specific forks before generic adapters for better auto-detection.
|
||||
new AnyRouterAdapter(),
|
||||
new DoneHubAdapter(),
|
||||
new OneHubAdapter(),
|
||||
new NewApiAdapter(),
|
||||
new Sub2ApiAdapter(),
|
||||
new OneApiAdapter(),
|
||||
new VeloeraAdapter(),
|
||||
];
|
||||
|
||||
const platformAliases: Record<string, string> = {
|
||||
// NewAPI family aliases
|
||||
anyrouter: 'anyrouter',
|
||||
'wong-gongyi': 'new-api',
|
||||
'vo-api': 'new-api',
|
||||
'super-api': 'new-api',
|
||||
'rix-api': 'new-api',
|
||||
'neo-api': 'new-api',
|
||||
newapi: 'new-api',
|
||||
'new api': 'new-api',
|
||||
// OneAPI family aliases
|
||||
oneapi: 'one-api',
|
||||
'one api': 'one-api',
|
||||
// Keep canonical forms explicit for clarity
|
||||
'new-api': 'new-api',
|
||||
'one-api': 'one-api',
|
||||
veloera: 'veloera',
|
||||
'one-hub': 'one-hub',
|
||||
'done-hub': 'done-hub',
|
||||
sub2api: 'sub2api',
|
||||
};
|
||||
|
||||
function normalizePlatform(platform: string): string {
|
||||
const raw = (platform || '').trim().toLowerCase();
|
||||
return platformAliases[raw] ?? raw;
|
||||
}
|
||||
|
||||
export function getAdapter(platform: string): PlatformAdapter | undefined {
|
||||
const normalized = normalizePlatform(platform);
|
||||
return adapters.find((a) => a.platformName === normalized);
|
||||
}
|
||||
|
||||
export async function detectPlatform(url: string): Promise<PlatformAdapter | undefined> {
|
||||
for (const adapter of adapters) {
|
||||
if (await adapter.detect(url)) return adapter;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { NewApiAdapter } from './newApi.js';
|
||||
|
||||
interface RequestSnapshot {
|
||||
method: string;
|
||||
url: string;
|
||||
headers: IncomingMessage['headers'];
|
||||
}
|
||||
|
||||
const COOKIE_SESSION_TOKEN = 'cookie-session-token';
|
||||
const COOKIE_REQUIRES_USER_TOKEN = 'cookie-requires-user';
|
||||
const CHECKIN_ALREADY_TOKEN = 'checkin-already-token';
|
||||
const CHECKIN_INVALID_URL_TOKEN = 'checkin-invalid-url-token';
|
||||
const CHECKIN_CLOUDFLARE_530_TOKEN = 'checkin-cloudflare-530-token';
|
||||
const BALANCE_FAIL_TOKEN = 'balance-fail-token';
|
||||
const COOKIE_SHIELDED_TOKEN = Buffer.from(
|
||||
`1771864970|${Buffer.from('username=linuxdo_131936').toString('base64')}|sig`,
|
||||
).toString('base64');
|
||||
const ANYROUTER_CHALLENGE_HTML = readFileSync(
|
||||
new URL('./__fixtures__/anyrouter-challenge.html', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const ANYROUTER_CHALLENGE_ACW = '699dbedad126579b6bc0ebb91eaae8d7af3548b5';
|
||||
const CLOUDFLARE_530_HTML = `
|
||||
<!doctype html>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<title>Cloudflare Tunnel error | newapi.tanmw.top | Cloudflare</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1><span>Error</span><span>1033</span></h1>
|
||||
<h2>Cloudflare Tunnel error</h2>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
describe('NewApiAdapter', () => {
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let baseUrl: string;
|
||||
let requests: RequestSnapshot[] = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
requests = [];
|
||||
server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||
requests.push({
|
||||
method: req.method || 'GET',
|
||||
url: req.url || '/',
|
||||
headers: req.headers,
|
||||
});
|
||||
|
||||
if (req.url === '/v1/models') {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'invalid token' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === '/api/user/models') {
|
||||
if (req.headers['new-api-user'] !== '11494') {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: 'missing New-Api-User' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ data: ['gpt-4o', 'gpt-4.1'] }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url?.startsWith('/api/token/')) {
|
||||
if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${COOKIE_SHIELDED_TOKEN}`) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: 'unauthorized' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof req.headers.cookie === 'string' && req.headers.cookie.includes(`session=${COOKIE_SHIELDED_TOKEN}`)) {
|
||||
if (!req.headers.cookie.includes(`acw_sc__v2=${ANYROUTER_CHALLENGE_ACW}`)) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(ANYROUTER_CHALLENGE_HTML);
|
||||
return;
|
||||
}
|
||||
if (req.headers['new-api-user'] !== '131936') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: 'missing New-Api-User' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
data: {
|
||||
items: [{ key: 'shielded-cookie-key' }],
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${COOKIE_SESSION_TOKEN}`) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: 'unauthorized' }));
|
||||
return;
|
||||
}
|
||||
if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${COOKIE_REQUIRES_USER_TOKEN}`) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: 'unauthorized' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof req.headers.cookie === 'string' && req.headers.cookie.includes(`session=${COOKIE_SESSION_TOKEN}`)) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
data: {
|
||||
items: [{ key: 'cookie-api-key' }],
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof req.headers.cookie === 'string' && req.headers.cookie.includes(`session=${COOKIE_REQUIRES_USER_TOKEN}`)) {
|
||||
if (req.headers['new-api-user'] !== '8899') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: 'missing New-Api-User' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
data: {
|
||||
items: [{ key: 'cookie-user-key' }],
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
data: {
|
||||
items: [{ key: 'api-key-from-token-list' }],
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === '/api/user/self') {
|
||||
if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${BALANCE_FAIL_TOKEN}`) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: '閺冪姵娼堟潻娑滎攽濮濄倖鎼锋担婊愮礉閺堫亞娅ヨぐ鏇氱瑬閺堫亝褰佹笟?access token' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof req.headers.cookie === 'string' &&
|
||||
(
|
||||
req.headers.cookie.includes(`session=${BALANCE_FAIL_TOKEN}`) ||
|
||||
req.headers.cookie.includes(`token=${BALANCE_FAIL_TOKEN}`)
|
||||
)
|
||||
) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: '閺冪姵娼堟潻娑滎攽濮濄倖鎼锋担婊愮礉閺堫亞娅ヨぐ鏇氱瑬閺堫亝褰佹笟?access token' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof req.headers.cookie === 'string' && req.headers.cookie.includes(`session=${COOKIE_SHIELDED_TOKEN}`)) {
|
||||
if (!req.headers.cookie.includes(`acw_sc__v2=${ANYROUTER_CHALLENGE_ACW}`)) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(ANYROUTER_CHALLENGE_HTML);
|
||||
return;
|
||||
}
|
||||
if (req.headers['new-api-user'] !== '131936') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: 'missing New-Api-User' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
data: { id: 131936, username: 'linuxdo_131936', quota: 3000000, used_quota: 1200000 },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof req.headers.authorization === 'string' && req.headers.authorization === 'Bearer session-token') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
data: { id: 11494, username: 'demo-user', quota: 1000000, used_quota: 1000 },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${COOKIE_SESSION_TOKEN}`) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: 'invalid token' }));
|
||||
return;
|
||||
}
|
||||
if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${COOKIE_REQUIRES_USER_TOKEN}`) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: 'invalid token' }));
|
||||
return;
|
||||
}
|
||||
if (typeof req.headers.authorization === 'string') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: 'invalid token' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof req.headers.cookie === 'string' && req.headers.cookie.includes(`session=${COOKIE_SESSION_TOKEN}`)) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
data: { id: 7788, username: 'cookie-user', quota: 2000000, used_quota: 500000 },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof req.headers.cookie === 'string' && req.headers.cookie.includes(`session=${COOKIE_REQUIRES_USER_TOKEN}`)) {
|
||||
if (req.headers['new-api-user'] !== '8899') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: 'missing New-Api-User' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
data: { id: 8899, username: 'cookie-user-id-required', quota: 1500000, used_quota: 100000 },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: 'unauthorized' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === '/api/user/checkin') {
|
||||
if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${CHECKIN_CLOUDFLARE_530_TOKEN}`) {
|
||||
res.writeHead(530, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(CLOUDFLARE_530_HTML);
|
||||
return;
|
||||
}
|
||||
if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${CHECKIN_INVALID_URL_TOKEN}`) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'Invalid URL (POST /api/user/checkin)' } }));
|
||||
return;
|
||||
}
|
||||
if (typeof req.headers.cookie === 'string' && req.headers.cookie.includes(`session=${CHECKIN_INVALID_URL_TOKEN}`)) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'Invalid URL (POST /api/user/checkin)' } }));
|
||||
return;
|
||||
}
|
||||
if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${CHECKIN_ALREADY_TOKEN}`) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: '浠婂ぉ宸茬粡绛惧埌杩囧暒' }));
|
||||
return;
|
||||
}
|
||||
if (typeof req.headers.cookie === 'string' && req.headers.cookie.includes(`session=${CHECKIN_ALREADY_TOKEN}`)) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: '鏃犳潈杩涜姝ゆ搷浣滐紝鏈櫥褰曚笖鏈彁渚?access token' }));
|
||||
return;
|
||||
}
|
||||
if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${COOKIE_SHIELDED_TOKEN}`) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: 'unauthorized' }));
|
||||
return;
|
||||
}
|
||||
if (typeof req.headers.cookie === 'string' && req.headers.cookie.includes(`session=${COOKIE_SHIELDED_TOKEN}`)) {
|
||||
if (!req.headers.cookie.includes(`acw_sc__v2=${ANYROUTER_CHALLENGE_ACW}`)) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(ANYROUTER_CHALLENGE_HTML);
|
||||
return;
|
||||
}
|
||||
if (req.headers['new-api-user'] !== '131936') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: 'missing New-Api-User' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, message: 'checked-in-ok' }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (req.url === '/api/user/sign_in') {
|
||||
if (typeof req.headers.cookie === 'string' && req.headers.cookie.includes(`session=${CHECKIN_ALREADY_TOKEN}`)) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: '鏃犳潈杩涜姝ゆ搷浣滐紝鏈櫥褰曚笖鏈彁渚?access token' }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'not found' }));
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
const addr = server.address() as AddressInfo;
|
||||
baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err?: Error) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to session model endpoint when /v1/models rejects token', async () => {
|
||||
const adapter = new NewApiAdapter();
|
||||
const models = await adapter.getModels(baseUrl, 'session-token', 11494);
|
||||
|
||||
expect(models).toEqual(['gpt-4o', 'gpt-4.1']);
|
||||
expect(requests.some((r) => r.url === '/v1/models')).toBe(true);
|
||||
expect(
|
||||
requests.some((r) => r.url === '/api/user/models' && r.headers['new-api-user'] === '11494'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('parses token list response with data.items[] shape', async () => {
|
||||
const adapter = new NewApiAdapter();
|
||||
const token = await adapter.getApiToken(baseUrl, 'session-token', 11494);
|
||||
|
||||
expect(token).toBe('api-key-from-token-list');
|
||||
});
|
||||
|
||||
it('detects cookie session values as session tokens for anyrouter-like deployments', async () => {
|
||||
const adapter = new NewApiAdapter();
|
||||
const result = await adapter.verifyToken(baseUrl, COOKIE_SESSION_TOKEN);
|
||||
|
||||
expect(result.tokenType).toBe('session');
|
||||
expect(result.userInfo?.username).toBe('cookie-user');
|
||||
expect(result.apiToken).toBe('cookie-api-key');
|
||||
expect(
|
||||
requests.some((r) => r.url === '/api/user/self' && typeof r.headers.cookie === 'string' && r.headers.cookie.includes(`session=${COOKIE_SESSION_TOKEN}`)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('auto-probes New-Api-User for cookie sessions when header is required', async () => {
|
||||
const adapter = new NewApiAdapter();
|
||||
const result = await adapter.verifyToken(baseUrl, COOKIE_REQUIRES_USER_TOKEN);
|
||||
|
||||
expect(result.tokenType).toBe('session');
|
||||
expect(result.userInfo?.username).toBe('cookie-user-id-required');
|
||||
expect(result.apiToken).toBe('cookie-user-key');
|
||||
expect(
|
||||
requests.some((r) => r.url === '/api/user/self' && r.headers['new-api-user'] === '8899'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('solves anyrouter acw challenge and probes user id from session payload', async () => {
|
||||
const adapter = new NewApiAdapter();
|
||||
const result = await adapter.verifyToken(baseUrl, COOKIE_SHIELDED_TOKEN);
|
||||
|
||||
expect(result.tokenType).toBe('session');
|
||||
expect(result.userInfo?.username).toBe('linuxdo_131936');
|
||||
expect(typeof result.apiToken === 'string' && result.apiToken.length > 0).toBe(true);
|
||||
expect(
|
||||
requests.some(
|
||||
(r) =>
|
||||
r.url === '/api/user/self' &&
|
||||
typeof r.headers.cookie === 'string' &&
|
||||
r.headers.cookie.includes(`acw_sc__v2=${ANYROUTER_CHALLENGE_ACW}`),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
requests.some((r) => r.url === '/api/user/self' && r.headers['new-api-user'] === '131936'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('uses shielded cookie flow for balance and checkin', async () => {
|
||||
const adapter = new NewApiAdapter();
|
||||
const balance = await adapter.getBalance(baseUrl, COOKIE_SHIELDED_TOKEN);
|
||||
const checkin = await adapter.checkin(baseUrl, COOKIE_SHIELDED_TOKEN);
|
||||
|
||||
expect(balance).toEqual({
|
||||
quota: 8.4,
|
||||
used: 2.4,
|
||||
balance: 6,
|
||||
});
|
||||
expect(checkin.success).toBe(true);
|
||||
expect(
|
||||
requests.some((r) => r.url === '/api/user/checkin' && r.headers['new-api-user'] === '131936'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves upstream balance failure message for UI feedback', async () => {
|
||||
const adapter = new NewApiAdapter();
|
||||
|
||||
await expect(adapter.getBalance(baseUrl, BALANCE_FAIL_TOKEN)).rejects.toThrow('access token');
|
||||
});
|
||||
|
||||
it('preserves nested checkin error message instead of generic fallback', async () => {
|
||||
const adapter = new NewApiAdapter();
|
||||
const result = await adapter.checkin(baseUrl, CHECKIN_INVALID_URL_TOKEN, 11494);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Invalid URL');
|
||||
});
|
||||
|
||||
it('summarizes cloudflare tunnel HTML failures to concise checkin error', async () => {
|
||||
const adapter = new NewApiAdapter();
|
||||
const result = await adapter.checkin(baseUrl, CHECKIN_CLOUDFLARE_530_TOKEN, 11494);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('HTTP 530: Cloudflare Tunnel error (Error 1033)');
|
||||
});
|
||||
|
||||
it('preserves already-checked-in message instead of overriding with cookie fallback error', async () => {
|
||||
const adapter = new NewApiAdapter();
|
||||
const result = await adapter.checkin(baseUrl, CHECKIN_ALREADY_TOKEN, 11494);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('浠婂ぉ宸茬粡绛惧埌杩囧暒');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,216 @@
|
||||
import { ApiTokenInfo, BasePlatformAdapter, CheckinResult, BalanceInfo, CreateApiTokenOptions } from './base.js';
|
||||
|
||||
type CreateApiTokenPayload = {
|
||||
name: string;
|
||||
unlimited_quota: boolean;
|
||||
expired_time: number;
|
||||
remain_quota: number;
|
||||
allow_ips: string;
|
||||
model_limits_enabled: boolean;
|
||||
model_limits: string;
|
||||
group: string;
|
||||
};
|
||||
|
||||
export class OneApiAdapter extends BasePlatformAdapter {
|
||||
readonly platformName: string = 'one-api';
|
||||
|
||||
private normalizeTokenKeyForCompare(value?: string | null): string {
|
||||
const trimmed = (value || '').trim();
|
||||
return trimmed.startsWith('Bearer ') ? trimmed.slice(7).trim() : trimmed;
|
||||
}
|
||||
|
||||
private buildDefaultTokenPayload(options?: CreateApiTokenOptions): CreateApiTokenPayload {
|
||||
const normalizedName = (options?.name || '').trim() || 'metapi';
|
||||
const unlimitedQuota = options?.unlimitedQuota ?? true;
|
||||
const remainQuota = Number.isFinite(options?.remainQuota)
|
||||
? Math.max(0, Math.trunc(options?.remainQuota as number))
|
||||
: 0;
|
||||
const expiredTime = Number.isFinite(options?.expiredTime)
|
||||
? Math.trunc(options?.expiredTime as number)
|
||||
: -1;
|
||||
return {
|
||||
name: normalizedName,
|
||||
unlimited_quota: unlimitedQuota,
|
||||
expired_time: expiredTime,
|
||||
remain_quota: remainQuota,
|
||||
allow_ips: (options?.allowIps || '').trim(),
|
||||
model_limits_enabled: options?.modelLimitsEnabled ?? false,
|
||||
model_limits: (options?.modelLimits || '').trim(),
|
||||
group: (options?.group || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
async detect(url: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${url}/api/status`);
|
||||
return res?.success === true && !res?.data?.system_name;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async checkin(baseUrl: string, accessToken: string): Promise<CheckinResult> {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}/api/user/checkin`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (res?.success) {
|
||||
return { success: true, message: res.message || 'Check-in successful', reward: res.data?.reward?.toString() };
|
||||
}
|
||||
return { success: false, message: res?.message || 'Check-in failed' };
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async getBalance(baseUrl: string, accessToken: string): Promise<BalanceInfo> {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}/api/user/self`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
const data = res?.data;
|
||||
const quota = (data?.quota || 0) / 500000;
|
||||
const used = (data?.used_quota || 0) / 500000;
|
||||
const todayIncome = Number.isFinite(data?.today_income) ? (data.today_income / 500000) : undefined;
|
||||
const todayQuotaConsumption = Number.isFinite(data?.today_quota_consumption) ? (data.today_quota_consumption / 500000) : undefined;
|
||||
return { balance: quota - used, used, quota, todayIncome, todayQuotaConsumption };
|
||||
}
|
||||
|
||||
async getModels(baseUrl: string, apiToken: string, _platformUserId?: number): Promise<string[]> {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}/v1/models`, {
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
});
|
||||
return (res?.data || []).map((m: any) => m.id).filter(Boolean);
|
||||
}
|
||||
|
||||
async getApiTokens(baseUrl: string, accessToken: string): Promise<ApiTokenInfo[]> {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}/api/token/?p=0&size=100`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
const items = (() => {
|
||||
if (Array.isArray(res?.data)) return res.data;
|
||||
if (Array.isArray(res?.data?.items)) return res.data.items;
|
||||
if (Array.isArray(res?.data?.data)) return res.data.data;
|
||||
if (Array.isArray(res?.items)) return res.items;
|
||||
if (Array.isArray(res?.list)) return res.list;
|
||||
if (Array.isArray(res?.data?.list)) return res.data.list;
|
||||
return [];
|
||||
})();
|
||||
|
||||
return items
|
||||
.map((item: any, index: number) => {
|
||||
const key = typeof item?.key === 'string' ? item.key.trim() : '';
|
||||
if (!key) return null;
|
||||
const rawName = typeof item?.name === 'string' ? item.name.trim() : '';
|
||||
const status = typeof item?.status === 'number' ? item.status : undefined;
|
||||
return {
|
||||
name: rawName || (index === 0 ? 'default' : `token-${index + 1}`),
|
||||
key,
|
||||
enabled: status === undefined ? true : status === 1,
|
||||
} satisfies ApiTokenInfo;
|
||||
})
|
||||
.filter((item: ApiTokenInfo | null): item is ApiTokenInfo => !!item);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getApiToken(baseUrl: string, accessToken: string): Promise<string | null> {
|
||||
const tokens = await this.getApiTokens(baseUrl, accessToken);
|
||||
return tokens.find((token) => token.enabled !== false)?.key || tokens[0]?.key || null;
|
||||
}
|
||||
|
||||
async getUserGroups(baseUrl: string, accessToken: string): Promise<string[]> {
|
||||
const headers = { Authorization: `Bearer ${accessToken}` };
|
||||
const extractGroupKeys = (payload: any): string[] => {
|
||||
const source = payload?.data || payload;
|
||||
if (!source || typeof source !== 'object') return [];
|
||||
return Object.keys(source).map((key) => key.trim()).filter(Boolean);
|
||||
};
|
||||
|
||||
try {
|
||||
const groupMap = await this.fetchJson<any>(`${baseUrl}/api/user_group_map`, { headers });
|
||||
const keys = extractGroupKeys(groupMap);
|
||||
if (keys.length > 0) return Array.from(new Set(keys));
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const groups = await this.fetchJson<any>(`${baseUrl}/api/user/self/groups`, { headers });
|
||||
const keys = extractGroupKeys(groups);
|
||||
if (keys.length > 0) return Array.from(new Set(keys));
|
||||
} catch {}
|
||||
|
||||
return ['default'];
|
||||
}
|
||||
|
||||
async createApiToken(
|
||||
baseUrl: string,
|
||||
accessToken: string,
|
||||
_platformUserId?: number,
|
||||
options?: CreateApiTokenOptions,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}/api/token/`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
body: JSON.stringify(this.buildDefaultTokenPayload(options)),
|
||||
});
|
||||
return !!res?.success;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteApiToken(
|
||||
baseUrl: string,
|
||||
accessToken: string,
|
||||
tokenKey: string,
|
||||
): Promise<boolean> {
|
||||
const targetKey = this.normalizeTokenKeyForCompare(tokenKey);
|
||||
if (!targetKey) return false;
|
||||
|
||||
const headers = { Authorization: `Bearer ${accessToken}` };
|
||||
let tokenId: number | null = null;
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}/api/token/?p=0&size=100`, { headers });
|
||||
const items = (() => {
|
||||
if (Array.isArray(res?.data)) return res.data;
|
||||
if (Array.isArray(res?.data?.items)) return res.data.items;
|
||||
if (Array.isArray(res?.items)) return res.items;
|
||||
return [];
|
||||
})();
|
||||
for (const item of items) {
|
||||
const key = this.normalizeTokenKeyForCompare(item?.key);
|
||||
const id = Number.parseInt(String(item?.id), 10);
|
||||
if (key && key === targetKey && Number.isFinite(id)) {
|
||||
tokenId = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!tokenId) return true;
|
||||
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}/api/token/${tokenId}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
if (res?.success) return true;
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}/api/token/${tokenId}/`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
return !!res?.success;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { OneApiAdapter } from './oneApi.js';
|
||||
|
||||
export class OneHubAdapter extends OneApiAdapter {
|
||||
readonly platformName = 'one-hub';
|
||||
|
||||
async detect(url: string): Promise<boolean> {
|
||||
const normalized = url.toLowerCase();
|
||||
return normalized.includes('onehub') || normalized.includes('one-hub');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { OneApiAdapter } from './oneApi.js';
|
||||
|
||||
export class Sub2ApiAdapter extends OneApiAdapter {
|
||||
readonly platformName = 'sub2api';
|
||||
|
||||
async detect(url: string): Promise<boolean> {
|
||||
return url.toLowerCase().includes('sub2api');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { BasePlatformAdapter, CheckinResult, BalanceInfo } from './base.js';
|
||||
|
||||
export class VeloeraAdapter extends BasePlatformAdapter {
|
||||
readonly platformName = 'veloera';
|
||||
|
||||
async detect(url: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${url}/api/status`);
|
||||
return res?.success === true && (
|
||||
res?.data?.system_name?.toLowerCase().includes('veloera') ||
|
||||
res?.data?.version?.includes('veloera')
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async checkin(baseUrl: string, accessToken: string): Promise<CheckinResult> {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}/api/user/checkin`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (res?.success) {
|
||||
return { success: true, message: res.message || 'Check-in successful', reward: res.data?.reward?.toString() };
|
||||
}
|
||||
return { success: false, message: res?.message || 'Check-in failed' };
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async getBalance(baseUrl: string, accessToken: string): Promise<BalanceInfo> {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}/api/user/self`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
const data = res?.data;
|
||||
const quota = (data?.quota || 0) / 1000000;
|
||||
const used = (data?.used_quota || 0) / 1000000;
|
||||
const todayIncome = Number.isFinite(data?.today_income) ? (data.today_income / 1000000) : undefined;
|
||||
const todayQuotaConsumption = Number.isFinite(data?.today_quota_consumption) ? (data.today_quota_consumption / 1000000) : undefined;
|
||||
return { balance: quota - used, used, quota, todayIncome, todayQuotaConsumption };
|
||||
}
|
||||
|
||||
async getModels(baseUrl: string, apiToken: string, _platformUserId?: number): Promise<string[]> {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}/v1/models`, {
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
});
|
||||
return (res?.data || []).map((m: any) => m.id).filter(Boolean);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { shouldRetryProxyRequest } from './proxyRetryPolicy.js';
|
||||
|
||||
describe('proxyRetryPolicy', () => {
|
||||
it('retries on rate limit and server errors', () => {
|
||||
expect(shouldRetryProxyRequest(429, 'rate limit')).toBe(true);
|
||||
expect(shouldRetryProxyRequest(500, 'internal error')).toBe(true);
|
||||
expect(shouldRetryProxyRequest(503, 'service unavailable')).toBe(true);
|
||||
});
|
||||
|
||||
it('retries on model unsupported messages from upstream', () => {
|
||||
expect(
|
||||
shouldRetryProxyRequest(400, '{"error":"当前 API 不支持所选模型 claude-sonnet-4-5-20250929","type":"error"}'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldRetryProxyRequest(400, '{"error":{"message":"unsupported model: claude-3"}}'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldRetryProxyRequest(404, '{"error":{"message":"The model `gpt-4.1` does not exist"}}'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not retry on generic client request errors', () => {
|
||||
expect(
|
||||
shouldRetryProxyRequest(400, '{"error":{"message":"invalid request body"}}'),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldRetryProxyRequest(401, '{"error":{"message":"invalid access token"}}'),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldRetryProxyRequest(403, '{"error":{"message":"forbidden"}}'),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
const MODEL_UNSUPPORTED_PATTERNS: RegExp[] = [
|
||||
/当前\s*api\s*不支持所选模型/i,
|
||||
/不支持所选模型/i,
|
||||
/不支持.*模型/i,
|
||||
/模型.*不支持/i,
|
||||
/unsupported\s+model/i,
|
||||
/model\s+not\s+supported/i,
|
||||
/does\s+not\s+support(?:\s+the)?\s+model/i,
|
||||
/model.*does\s+not\s+exist/i,
|
||||
/no\s+such\s+model/i,
|
||||
/unknown\s+model/i,
|
||||
/invalid\s+model/i,
|
||||
/model[_\s-]?not[_\s-]?found/i,
|
||||
/you\s+do\s+not\s+have\s+access\s+to\s+the\s+model/i,
|
||||
];
|
||||
|
||||
function isModelUnsupportedErrorMessage(rawMessage?: string | null): boolean {
|
||||
const text = (rawMessage || '').trim();
|
||||
if (!text) return false;
|
||||
return MODEL_UNSUPPORTED_PATTERNS.some((pattern) => pattern.test(text));
|
||||
}
|
||||
|
||||
export function shouldRetryProxyRequest(status: number, upstreamErrorText?: string | null): boolean {
|
||||
if (status === 429 || status >= 500) return true;
|
||||
if (status !== 400 && status !== 404 && status !== 422) return false;
|
||||
return isModelUnsupportedErrorMessage(upstreamErrorText);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
extractSelfLogItems,
|
||||
findBestSelfLogMatch,
|
||||
shouldLookupSelfLog,
|
||||
type SelfLogItem,
|
||||
} from './proxyUsageFallbackService.js';
|
||||
|
||||
describe('proxyUsageFallbackService', () => {
|
||||
it('extracts logs from nested /api/log/self payload', () => {
|
||||
const payload = {
|
||||
success: true,
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
model_name: 'gpt-4o',
|
||||
prompt_tokens: 120,
|
||||
completion_tokens: 80,
|
||||
quota: 3300,
|
||||
created_at: 1_700_000_123,
|
||||
request_time: 1450,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const logs = extractSelfLogItems(payload);
|
||||
expect(logs).toEqual([
|
||||
{
|
||||
modelName: 'gpt-4o',
|
||||
tokenName: '',
|
||||
promptTokens: 120,
|
||||
completionTokens: 80,
|
||||
totalTokens: 200,
|
||||
quota: 3300,
|
||||
createdAtMs: 1_700_000_123_000,
|
||||
requestTimeMs: 1450,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches best log by model + time window + request time', () => {
|
||||
const logs: SelfLogItem[] = [
|
||||
{
|
||||
modelName: 'gpt-4o',
|
||||
tokenName: 'default',
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
totalTokens: 15,
|
||||
quota: 1000,
|
||||
createdAtMs: 1_700_000_000_000,
|
||||
requestTimeMs: 800,
|
||||
},
|
||||
{
|
||||
modelName: 'gpt-4o',
|
||||
tokenName: 'default',
|
||||
promptTokens: 200,
|
||||
completionTokens: 100,
|
||||
totalTokens: 300,
|
||||
quota: 4500,
|
||||
createdAtMs: 1_700_000_007_000,
|
||||
requestTimeMs: 3100,
|
||||
},
|
||||
{
|
||||
modelName: 'gpt-4o-mini',
|
||||
tokenName: 'default',
|
||||
promptTokens: 999,
|
||||
completionTokens: 1,
|
||||
totalTokens: 1000,
|
||||
quota: 9999,
|
||||
createdAtMs: 1_700_000_007_000,
|
||||
requestTimeMs: 3100,
|
||||
},
|
||||
];
|
||||
|
||||
const matched = findBestSelfLogMatch(logs, {
|
||||
modelName: 'gpt-4o',
|
||||
requestStartedAtMs: 1_700_000_004_000,
|
||||
requestEndedAtMs: 1_700_000_008_200,
|
||||
localLatencyMs: 3200,
|
||||
});
|
||||
|
||||
expect(matched).toEqual(logs[1]);
|
||||
});
|
||||
|
||||
it('returns null when no safe match exists', () => {
|
||||
const logs: SelfLogItem[] = [
|
||||
{
|
||||
modelName: 'gpt-4o',
|
||||
tokenName: 'default',
|
||||
promptTokens: 120,
|
||||
completionTokens: 80,
|
||||
totalTokens: 200,
|
||||
quota: 3300,
|
||||
createdAtMs: 1_700_000_123_000,
|
||||
requestTimeMs: 1450,
|
||||
},
|
||||
];
|
||||
|
||||
const matched = findBestSelfLogMatch(logs, {
|
||||
modelName: 'gpt-4o',
|
||||
requestStartedAtMs: 1_700_010_000_000,
|
||||
requestEndedAtMs: 1_700_010_004_000,
|
||||
localLatencyMs: 4000,
|
||||
});
|
||||
|
||||
expect(matched).toBeNull();
|
||||
});
|
||||
|
||||
it('matches provider-prefixed model names', () => {
|
||||
const logs: SelfLogItem[] = [
|
||||
{
|
||||
modelName: 'z-ai/glm4.7',
|
||||
tokenName: 'sys_playground',
|
||||
promptTokens: 1095,
|
||||
completionTokens: 2179,
|
||||
totalTokens: 3274,
|
||||
quota: 1890000,
|
||||
createdAtMs: 1_772_024_884_000,
|
||||
requestTimeMs: 59_000,
|
||||
},
|
||||
];
|
||||
|
||||
const matched = findBestSelfLogMatch(logs, {
|
||||
modelName: 'glm4.7',
|
||||
tokenName: 'windhub',
|
||||
requestStartedAtMs: 1_772_024_825_000,
|
||||
requestEndedAtMs: 1_772_024_884_500,
|
||||
localLatencyMs: 59_000,
|
||||
});
|
||||
|
||||
expect(matched).toEqual(logs[0]);
|
||||
});
|
||||
|
||||
it('always enables self-log lookup for done-hub/one-hub', () => {
|
||||
expect(shouldLookupSelfLog('done-hub', { promptTokens: 12, completionTokens: 8, totalTokens: 20 })).toBe(true);
|
||||
expect(shouldLookupSelfLog('one-hub', { promptTokens: 1, completionTokens: 1, totalTokens: 2 })).toBe(true);
|
||||
});
|
||||
|
||||
it('only enables self-log lookup for new-api/anyrouter when usage is missing', () => {
|
||||
expect(shouldLookupSelfLog('new-api', { promptTokens: 0, completionTokens: 0, totalTokens: 0 })).toBe(true);
|
||||
expect(shouldLookupSelfLog('anyrouter', { promptTokens: 0, completionTokens: 0, totalTokens: 0 })).toBe(true);
|
||||
|
||||
expect(shouldLookupSelfLog('new-api', { promptTokens: 12, completionTokens: 3, totalTokens: 15 })).toBe(false);
|
||||
expect(shouldLookupSelfLog('anyrouter', { promptTokens: 12, completionTokens: 3, totalTokens: 15 })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,388 @@
|
||||
import { fetch } from 'undici';
|
||||
import { resolvePlatformUserId } from './accountExtraConfig.js';
|
||||
|
||||
const SELF_LOG_FETCH_TIMEOUT_MS = 8_000;
|
||||
const SELF_LOG_PAGE_SIZE = 20;
|
||||
const MATCH_LOOKBACK_MS = 25_000;
|
||||
const MATCH_LOOKAHEAD_MS = 120_000;
|
||||
const MATCH_MAX_CREATED_DELTA_MS = 90_000;
|
||||
const MATCH_MAX_LATENCY_DELTA_MS = 12_000;
|
||||
const QUOTA_PER_UNIT = 500_000;
|
||||
const SUPPORTED_USAGE_FALLBACK_PLATFORMS = new Set(['done-hub', 'one-hub', 'new-api', 'anyrouter']);
|
||||
const ALWAYS_LOOKUP_SELF_LOG_PLATFORMS = new Set(['done-hub', 'one-hub']);
|
||||
const PLATFORM_REQUIRES_USER_HEADER = new Set(['new-api', 'anyrouter']);
|
||||
|
||||
interface ProxyUsage {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
interface ProxyUsageFallbackInput {
|
||||
site: {
|
||||
url: string;
|
||||
platform: string;
|
||||
apiKey?: string | null;
|
||||
};
|
||||
account: {
|
||||
accessToken?: string | null;
|
||||
apiToken?: string | null;
|
||||
username?: string | null;
|
||||
extraConfig?: string | null;
|
||||
platformUserId?: number | null;
|
||||
};
|
||||
tokenValue?: string | null;
|
||||
tokenName?: string | null;
|
||||
modelName: string;
|
||||
requestStartedAtMs: number;
|
||||
requestEndedAtMs: number;
|
||||
localLatencyMs: number;
|
||||
usage: ProxyUsage;
|
||||
}
|
||||
|
||||
interface ProxyUsageFallbackResult extends ProxyUsage {
|
||||
recoveredFromSelfLog: boolean;
|
||||
estimatedCostFromQuota: number;
|
||||
}
|
||||
|
||||
export interface SelfLogItem {
|
||||
modelName: string;
|
||||
tokenName: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
quota: number;
|
||||
createdAtMs: number;
|
||||
requestTimeMs: number;
|
||||
}
|
||||
|
||||
interface SelfLogMatchInput {
|
||||
modelName: string;
|
||||
tokenName?: string | null;
|
||||
requestStartedAtMs: number;
|
||||
requestEndedAtMs: number;
|
||||
localLatencyMs: number;
|
||||
}
|
||||
|
||||
function toNumber(value: unknown, fallback = 0): number {
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
return n;
|
||||
}
|
||||
|
||||
function toPositiveInt(value: unknown): number {
|
||||
return Math.max(0, Math.round(toNumber(value, 0)));
|
||||
}
|
||||
|
||||
function roundCost(value: number): number {
|
||||
return Math.round(Math.max(0, value) * 1_000_000) / 1_000_000;
|
||||
}
|
||||
|
||||
function toTimestampMs(value: unknown): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
if (value > 1_000_000_000_000) return Math.round(value);
|
||||
if (value > 1_000_000_000) return Math.round(value * 1000);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return 0;
|
||||
|
||||
const numeric = Number(trimmed);
|
||||
if (Number.isFinite(numeric)) {
|
||||
return toTimestampMs(numeric);
|
||||
}
|
||||
|
||||
const parsed = Date.parse(trimmed);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function normalizeUsage(usage: ProxyUsage): ProxyUsage {
|
||||
const promptTokens = toPositiveInt(usage.promptTokens);
|
||||
const completionTokens = toPositiveInt(usage.completionTokens);
|
||||
const totalTokensRaw = toPositiveInt(usage.totalTokens);
|
||||
const totalTokens = totalTokensRaw > 0 ? totalTokensRaw : (promptTokens + completionTokens);
|
||||
return {
|
||||
promptTokens,
|
||||
completionTokens,
|
||||
totalTokens,
|
||||
};
|
||||
}
|
||||
|
||||
function isUsageMissing(usage: ProxyUsage): boolean {
|
||||
return usage.promptTokens <= 0 && usage.completionTokens <= 0 && usage.totalTokens <= 0;
|
||||
}
|
||||
|
||||
export function shouldLookupSelfLog(
|
||||
platform: string,
|
||||
usage: ProxyUsage,
|
||||
): boolean {
|
||||
const normalizedPlatform = String(platform || '').toLowerCase();
|
||||
if (!SUPPORTED_USAGE_FALLBACK_PLATFORMS.has(normalizedPlatform)) return false;
|
||||
if (ALWAYS_LOOKUP_SELF_LOG_PLATFORMS.has(normalizedPlatform)) return true;
|
||||
return isUsageMissing(usage);
|
||||
}
|
||||
|
||||
function normalizeUrl(url: string): string {
|
||||
return url.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function getArrayNode(value: unknown): unknown[] | null {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
if (Array.isArray(record.data)) return record.data;
|
||||
if (Array.isArray(record.items)) return record.items;
|
||||
return null;
|
||||
}
|
||||
|
||||
function modelNameMatches(left: string, right: string): boolean {
|
||||
const normalizedLeft = left.trim().toLowerCase();
|
||||
const normalizedRight = right.trim().toLowerCase();
|
||||
if (!normalizedLeft || !normalizedRight) return false;
|
||||
if (normalizedLeft === normalizedRight) return true;
|
||||
if (normalizedLeft.endsWith(`/${normalizedRight}`)) return true;
|
||||
if (normalizedRight.endsWith(`/${normalizedLeft}`)) return true;
|
||||
|
||||
const leftTail = normalizedLeft.split('/').pop() || '';
|
||||
const rightTail = normalizedRight.split('/').pop() || '';
|
||||
return !!leftTail && leftTail === rightTail;
|
||||
}
|
||||
|
||||
function getPayloadList(payload: unknown): unknown[] {
|
||||
const candidates = [
|
||||
payload,
|
||||
(payload as any)?.data,
|
||||
(payload as any)?.data?.data,
|
||||
(payload as any)?.data?.items,
|
||||
(payload as any)?.items,
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const list = getArrayNode(candidate);
|
||||
if (list) return list;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function mapSelfLogItem(raw: unknown): SelfLogItem | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const row = raw as Record<string, unknown>;
|
||||
|
||||
const modelName = String(row.model_name ?? row.modelName ?? '').trim();
|
||||
if (!modelName) return null;
|
||||
|
||||
const promptTokens = toPositiveInt(row.prompt_tokens ?? row.promptTokens);
|
||||
const completionTokens = toPositiveInt(row.completion_tokens ?? row.completionTokens);
|
||||
const totalTokensRaw = toPositiveInt(row.total_tokens ?? row.totalTokens);
|
||||
const totalTokens = totalTokensRaw > 0 ? totalTokensRaw : (promptTokens + completionTokens);
|
||||
const createdAtMs = toTimestampMs(row.created_at ?? row.createdAt);
|
||||
if (createdAtMs <= 0) return null;
|
||||
|
||||
return {
|
||||
modelName,
|
||||
tokenName: String(row.token_name ?? row.tokenName ?? '').trim(),
|
||||
promptTokens,
|
||||
completionTokens,
|
||||
totalTokens,
|
||||
quota: toPositiveInt(row.quota),
|
||||
createdAtMs,
|
||||
requestTimeMs: toPositiveInt(row.request_time ?? row.requestTime),
|
||||
};
|
||||
}
|
||||
|
||||
function buildTokenCandidates(input: ProxyUsageFallbackInput): string[] {
|
||||
const candidates = [
|
||||
input.account.accessToken,
|
||||
input.tokenValue,
|
||||
input.account.apiToken,
|
||||
input.site.apiKey,
|
||||
]
|
||||
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
||||
.filter(Boolean);
|
||||
return Array.from(new Set(candidates));
|
||||
}
|
||||
|
||||
function resolveSelfLogUserId(input: ProxyUsageFallbackInput): number | undefined {
|
||||
const direct = toPositiveInt(input.account.platformUserId);
|
||||
if (direct > 0) return direct;
|
||||
return resolvePlatformUserId(input.account.extraConfig, input.account.username);
|
||||
}
|
||||
|
||||
async function fetchSelfLogPayload(baseUrl: string, token: string, input: ProxyUsageFallbackInput): Promise<unknown> {
|
||||
const controller = new AbortController();
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, SELF_LOG_FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const query = `p=0&page=1&size=${SELF_LOG_PAGE_SIZE}&order=-created_at`;
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
const platform = String(input.site.platform || '').toLowerCase();
|
||||
if (PLATFORM_REQUIRES_USER_HEADER.has(platform)) {
|
||||
const userId = resolveSelfLogUserId(input);
|
||||
if (userId) {
|
||||
headers['New-Api-User'] = String(userId);
|
||||
}
|
||||
}
|
||||
const response = await fetch(`${baseUrl}/api/log/self?${query}`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text.trim()) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
timeoutHandle = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRecentSelfLogItems(input: ProxyUsageFallbackInput): Promise<SelfLogItem[]> {
|
||||
const baseUrl = normalizeUrl(input.site.url);
|
||||
const tokens = buildTokenCandidates(input);
|
||||
for (const token of tokens) {
|
||||
try {
|
||||
const payload = await fetchSelfLogPayload(baseUrl, token, input);
|
||||
const items = extractSelfLogItems(payload);
|
||||
if (items.length > 0) return items;
|
||||
} catch {}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function extractSelfLogItems(payload: unknown): SelfLogItem[] {
|
||||
const rows = getPayloadList(payload);
|
||||
const items = rows
|
||||
.map((row) => mapSelfLogItem(row))
|
||||
.filter((item): item is SelfLogItem => item !== null)
|
||||
.sort((a, b) => b.createdAtMs - a.createdAtMs);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function findBestSelfLogMatch(items: SelfLogItem[], input: SelfLogMatchInput): SelfLogItem | null {
|
||||
const requestedModel = input.modelName.trim();
|
||||
if (!requestedModel || items.length === 0) return null;
|
||||
|
||||
const windowStart = input.requestStartedAtMs - MATCH_LOOKBACK_MS;
|
||||
const windowEnd = input.requestEndedAtMs + MATCH_LOOKAHEAD_MS;
|
||||
let candidates = items.filter((item) => (
|
||||
modelNameMatches(item.modelName, requestedModel)
|
||||
&& item.createdAtMs >= windowStart
|
||||
&& item.createdAtMs <= windowEnd
|
||||
&& (item.totalTokens > 0 || item.quota > 0 || item.requestTimeMs > 0)
|
||||
));
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
const tokenName = String(input.tokenName || '').trim().toLowerCase();
|
||||
if (tokenName) {
|
||||
const tokenMatched = candidates.filter((item) => item.tokenName.trim().toLowerCase() === tokenName);
|
||||
if (tokenMatched.length > 0) {
|
||||
candidates = tokenMatched;
|
||||
}
|
||||
}
|
||||
|
||||
let best: SelfLogItem | null = null;
|
||||
let bestScore = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const createdAtDelta = Math.abs(candidate.createdAtMs - input.requestEndedAtMs);
|
||||
const latencyDelta = (input.localLatencyMs > 0 && candidate.requestTimeMs > 0)
|
||||
? Math.abs(candidate.requestTimeMs - input.localLatencyMs)
|
||||
: 0;
|
||||
const score = createdAtDelta + (latencyDelta * 2);
|
||||
|
||||
if (score < bestScore) {
|
||||
best = candidate;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
if (!best) return null;
|
||||
|
||||
const createdDelta = Math.abs(best.createdAtMs - input.requestEndedAtMs);
|
||||
if (createdDelta > MATCH_MAX_CREATED_DELTA_MS) return null;
|
||||
|
||||
if (input.localLatencyMs > 0 && best.requestTimeMs > 0) {
|
||||
const latencyDelta = Math.abs(best.requestTimeMs - input.localLatencyMs);
|
||||
if (latencyDelta > MATCH_MAX_LATENCY_DELTA_MS) return null;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
function toQuotaCost(quota: number): number {
|
||||
return roundCost(toPositiveInt(quota) / QUOTA_PER_UNIT);
|
||||
}
|
||||
|
||||
export async function resolveProxyUsageWithSelfLogFallback(
|
||||
input: ProxyUsageFallbackInput,
|
||||
): Promise<ProxyUsageFallbackResult> {
|
||||
const normalizedUsage = normalizeUsage(input.usage);
|
||||
const fallback: ProxyUsageFallbackResult = {
|
||||
...normalizedUsage,
|
||||
recoveredFromSelfLog: false,
|
||||
estimatedCostFromQuota: 0,
|
||||
};
|
||||
|
||||
const platform = String(input.site.platform || '').toLowerCase();
|
||||
if (!shouldLookupSelfLog(platform, normalizedUsage)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
const items = await fetchRecentSelfLogItems(input);
|
||||
const matched = findBestSelfLogMatch(items, {
|
||||
modelName: input.modelName,
|
||||
tokenName: input.tokenName,
|
||||
requestStartedAtMs: input.requestStartedAtMs,
|
||||
requestEndedAtMs: input.requestEndedAtMs,
|
||||
localLatencyMs: input.localLatencyMs,
|
||||
});
|
||||
|
||||
if (!matched) return fallback;
|
||||
|
||||
const matchedUsage: ProxyUsage = {
|
||||
promptTokens: matched.promptTokens,
|
||||
completionTokens: matched.completionTokens,
|
||||
totalTokens: matched.totalTokens,
|
||||
};
|
||||
const normalizedMatched = normalizeUsage(matchedUsage);
|
||||
const useMatchedTokens = normalizedMatched.totalTokens > 0
|
||||
|| normalizedMatched.promptTokens > 0
|
||||
|| normalizedMatched.completionTokens > 0;
|
||||
const resolvedTokens = useMatchedTokens ? normalizedMatched : normalizedUsage;
|
||||
|
||||
return {
|
||||
promptTokens: resolvedTokens.promptTokens,
|
||||
completionTokens: resolvedTokens.completionTokens,
|
||||
totalTokens: resolvedTokens.totalTokens,
|
||||
recoveredFromSelfLog: true,
|
||||
estimatedCostFromQuota: toQuotaCost(matched.quota),
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
mergeProxyUsage,
|
||||
parseProxyUsage,
|
||||
pullSseDataEvents,
|
||||
} from './proxyUsageParser.js';
|
||||
|
||||
describe('proxyUsageParser', () => {
|
||||
it('parses standard OpenAI usage fields', () => {
|
||||
const usage = parseProxyUsage({
|
||||
usage: {
|
||||
prompt_tokens: 123,
|
||||
completion_tokens: 45,
|
||||
total_tokens: 168,
|
||||
},
|
||||
});
|
||||
|
||||
expect(usage).toEqual({
|
||||
promptTokens: 123,
|
||||
completionTokens: 45,
|
||||
totalTokens: 168,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses input/output token style usage fields', () => {
|
||||
const usage = parseProxyUsage({
|
||||
usage: {
|
||||
input_tokens: 80,
|
||||
output_tokens: 20,
|
||||
},
|
||||
});
|
||||
|
||||
expect(usage).toEqual({
|
||||
promptTokens: 80,
|
||||
completionTokens: 20,
|
||||
totalTokens: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses Gemini usageMetadata shape', () => {
|
||||
const usage = parseProxyUsage({
|
||||
usageMetadata: {
|
||||
promptTokenCount: 12,
|
||||
candidatesTokenCount: 34,
|
||||
totalTokenCount: 46,
|
||||
},
|
||||
});
|
||||
|
||||
expect(usage).toEqual({
|
||||
promptTokens: 12,
|
||||
completionTokens: 34,
|
||||
totalTokens: 46,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses deeply nested usage payloads', () => {
|
||||
const usage = parseProxyUsage({
|
||||
data: {
|
||||
result: {
|
||||
response: {
|
||||
usage: {
|
||||
prompt_tokens: 210,
|
||||
completion_tokens: 40,
|
||||
total_tokens: 250,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(usage).toEqual({
|
||||
promptTokens: 210,
|
||||
completionTokens: 40,
|
||||
totalTokens: 250,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to usage detail objects when aggregate fields are absent', () => {
|
||||
const usage = parseProxyUsage({
|
||||
usage: {
|
||||
prompt_tokens_details: {
|
||||
text_tokens: 7,
|
||||
cached_tokens: 3,
|
||||
},
|
||||
completion_tokens_details: {
|
||||
reasoning_tokens: 20,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(usage).toEqual({
|
||||
promptTokens: 10,
|
||||
completionTokens: 20,
|
||||
totalTokens: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it('merges usage snapshots by keeping richer values', () => {
|
||||
const merged = mergeProxyUsage(
|
||||
{ promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
||||
{ promptTokens: 90, completionTokens: 30, totalTokens: 120 },
|
||||
);
|
||||
|
||||
expect(merged).toEqual({
|
||||
promptTokens: 90,
|
||||
completionTokens: 30,
|
||||
totalTokens: 120,
|
||||
});
|
||||
});
|
||||
|
||||
it('pulls SSE data events across chunk boundaries', () => {
|
||||
const first = pullSseDataEvents('data: {"a":1}\n\ndata: {"b":');
|
||||
expect(first.events).toEqual(['{"a":1}']);
|
||||
expect(first.rest).toBe('data: {"b":');
|
||||
|
||||
const second = pullSseDataEvents(`${first.rest}2}\n\ndata: [DONE]\n\n`);
|
||||
expect(second.events).toEqual(['{"b":2}']);
|
||||
expect(second.rest).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
interface ParsedProxyUsage {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
const ZERO_USAGE: ParsedProxyUsage = {
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
};
|
||||
|
||||
function toPositiveInt(value: unknown): number {
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.max(0, Math.round(n));
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function collectUsageCandidates(payload: unknown): Array<Record<string, unknown>> {
|
||||
const candidates: Array<Record<string, unknown>> = [];
|
||||
const visited = new Set<object>();
|
||||
const queue: unknown[] = [];
|
||||
|
||||
const enqueue = (value: unknown) => {
|
||||
if (!value || typeof value !== 'object') return;
|
||||
queue.push(value);
|
||||
};
|
||||
|
||||
enqueue(payload);
|
||||
enqueue((payload as any)?.usage);
|
||||
enqueue((payload as any)?.usageMetadata);
|
||||
enqueue((payload as any)?.usage_metadata);
|
||||
enqueue((payload as any)?.token_usage);
|
||||
enqueue((payload as any)?.tokenUsage);
|
||||
|
||||
// Guard against unexpectedly deep/large payloads.
|
||||
let inspected = 0;
|
||||
const MAX_INSPECT = 200;
|
||||
|
||||
while (queue.length > 0 && inspected < MAX_INSPECT) {
|
||||
const current = queue.shift();
|
||||
inspected += 1;
|
||||
|
||||
if (Array.isArray(current)) {
|
||||
for (const item of current) enqueue(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isRecord(current)) continue;
|
||||
if (visited.has(current)) continue;
|
||||
visited.add(current);
|
||||
candidates.push(current);
|
||||
|
||||
for (const value of Object.values(current)) {
|
||||
enqueue(value);
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function firstPositiveInt(record: Record<string, unknown>, keys: string[]): number {
|
||||
for (const key of keys) {
|
||||
const value = toPositiveInt(record[key]);
|
||||
if (value > 0) return value;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function sumNumericFields(value: unknown): number {
|
||||
if (!isRecord(value)) return 0;
|
||||
return Object.values(value).reduce<number>((sum, item) => sum + toPositiveInt(item), 0);
|
||||
}
|
||||
|
||||
function parseUsageRecord(record: Record<string, unknown>): ParsedProxyUsage {
|
||||
let promptTokens = firstPositiveInt(record, [
|
||||
'prompt_tokens',
|
||||
'promptTokens',
|
||||
'prompt_token_count',
|
||||
'promptTokenCount',
|
||||
'input_tokens',
|
||||
'inputTokens',
|
||||
'input_token_count',
|
||||
'inputTokenCount',
|
||||
]);
|
||||
let completionTokens = firstPositiveInt(record, [
|
||||
'completion_tokens',
|
||||
'completionTokens',
|
||||
'completion_token_count',
|
||||
'completionTokenCount',
|
||||
'candidates_token_count',
|
||||
'candidatesTokenCount',
|
||||
'output_tokens',
|
||||
'outputTokens',
|
||||
'output_token_count',
|
||||
'outputTokenCount',
|
||||
]);
|
||||
let totalTokens = firstPositiveInt(record, [
|
||||
'total_tokens',
|
||||
'totalTokens',
|
||||
'total_token_count',
|
||||
'totalTokenCount',
|
||||
]);
|
||||
|
||||
if (promptTokens <= 0) {
|
||||
promptTokens = Math.max(
|
||||
sumNumericFields(record.prompt_tokens_details),
|
||||
sumNumericFields(record.promptTokensDetails),
|
||||
sumNumericFields(record.input_tokens_details),
|
||||
sumNumericFields(record.inputTokensDetails),
|
||||
);
|
||||
}
|
||||
|
||||
if (completionTokens <= 0) {
|
||||
completionTokens = Math.max(
|
||||
sumNumericFields(record.completion_tokens_details),
|
||||
sumNumericFields(record.completionTokensDetails),
|
||||
sumNumericFields(record.output_tokens_details),
|
||||
sumNumericFields(record.outputTokensDetails),
|
||||
);
|
||||
}
|
||||
|
||||
if (totalTokens <= 0) {
|
||||
totalTokens = promptTokens + completionTokens;
|
||||
}
|
||||
|
||||
if (promptTokens <= 0 && totalTokens > completionTokens) {
|
||||
promptTokens = totalTokens - completionTokens;
|
||||
}
|
||||
if (completionTokens <= 0 && totalTokens > promptTokens) {
|
||||
completionTokens = totalTokens - promptTokens;
|
||||
}
|
||||
|
||||
return {
|
||||
promptTokens,
|
||||
completionTokens,
|
||||
totalTokens: Math.max(totalTokens, promptTokens + completionTokens),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseProxyUsage(payload: unknown): ParsedProxyUsage {
|
||||
if (!payload || typeof payload !== 'object') return { ...ZERO_USAGE };
|
||||
const candidates = collectUsageCandidates(payload);
|
||||
|
||||
let best = { ...ZERO_USAGE };
|
||||
let bestScore = -1;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const parsed = parseUsageRecord(candidate);
|
||||
const score = parsed.totalTokens > 0
|
||||
? (parsed.totalTokens * 10_000 + parsed.promptTokens + parsed.completionTokens)
|
||||
: (parsed.promptTokens + parsed.completionTokens);
|
||||
if (score > bestScore) {
|
||||
best = parsed;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
export function mergeProxyUsage(base: ParsedProxyUsage, incoming: ParsedProxyUsage): ParsedProxyUsage {
|
||||
const baseScore = base.totalTokens > 0 ? base.totalTokens * 10_000 + base.promptTokens + base.completionTokens : (base.promptTokens + base.completionTokens);
|
||||
const incomingScore = incoming.totalTokens > 0
|
||||
? incoming.totalTokens * 10_000 + incoming.promptTokens + incoming.completionTokens
|
||||
: (incoming.promptTokens + incoming.completionTokens);
|
||||
|
||||
if (incomingScore > baseScore) return incoming;
|
||||
|
||||
const promptTokens = Math.max(base.promptTokens, incoming.promptTokens);
|
||||
const completionTokens = Math.max(base.completionTokens, incoming.completionTokens);
|
||||
const totalTokens = Math.max(base.totalTokens, incoming.totalTokens, promptTokens + completionTokens);
|
||||
|
||||
return { promptTokens, completionTokens, totalTokens };
|
||||
}
|
||||
|
||||
export function pullSseDataEvents(buffer: string): { events: string[]; rest: string } {
|
||||
const normalized = buffer.replace(/\r\n/g, '\n');
|
||||
const events: string[] = [];
|
||||
let rest = normalized;
|
||||
|
||||
while (true) {
|
||||
const boundary = rest.indexOf('\n\n');
|
||||
if (boundary < 0) break;
|
||||
const block = rest.slice(0, boundary);
|
||||
rest = rest.slice(boundary + 2);
|
||||
|
||||
if (!block.trim()) continue;
|
||||
|
||||
const dataLines = block
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).trimStart());
|
||||
|
||||
if (dataLines.length <= 0) continue;
|
||||
const payload = dataLines.join('\n').trim();
|
||||
if (!payload || payload === '[DONE]') continue;
|
||||
events.push(payload);
|
||||
}
|
||||
|
||||
return { events, rest };
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { detectPlatform } from './platforms/index.js';
|
||||
|
||||
export async function detectSite(url: string) {
|
||||
const normalizedUrl = url.replace(/\/+$/, '');
|
||||
const adapter = await detectPlatform(normalizedUrl);
|
||||
if (!adapter) return null;
|
||||
return { url: normalizedUrl, platform: adapter.platformName };
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildStartupEndpoints, buildStartupSummaryLines } from './startupInfo.js';
|
||||
|
||||
describe('startupInfo', () => {
|
||||
it('builds single-port endpoint summary for admin and proxy APIs', () => {
|
||||
const endpoints = buildStartupEndpoints({
|
||||
port: 4000,
|
||||
host: '0.0.0.0',
|
||||
authToken: 'admin-token',
|
||||
proxyToken: 'proxy-token',
|
||||
});
|
||||
|
||||
expect(endpoints.baseUrl).toBe('http://127.0.0.1:4000');
|
||||
expect(endpoints.adminDashboardUrl).toBe('http://127.0.0.1:4000');
|
||||
expect(endpoints.adminApiExample).toBe('http://127.0.0.1:4000/api/stats/dashboard');
|
||||
expect(endpoints.proxyApiExample).toBe('http://127.0.0.1:4000/v1/chat/completions');
|
||||
});
|
||||
|
||||
it('renders copy-ready startup summary lines', () => {
|
||||
const lines = buildStartupSummaryLines({
|
||||
port: 4000,
|
||||
host: '0.0.0.0',
|
||||
authToken: 'admin-token',
|
||||
proxyToken: 'proxy-token',
|
||||
});
|
||||
|
||||
expect(lines.some((line) => line.includes('metapi running'))).toBe(true);
|
||||
expect(lines.some((line) => line.includes('Dashboard: http://127.0.0.1:4000'))).toBe(true);
|
||||
expect(lines.some((line) => line.includes('/api/stats/dashboard'))).toBe(true);
|
||||
expect(lines.some((line) => line.includes('/v1/chat/completions'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
type StartupSummaryInput = {
|
||||
port: number;
|
||||
host: string;
|
||||
authToken: string;
|
||||
proxyToken: string;
|
||||
};
|
||||
|
||||
type StartupEndpoints = {
|
||||
baseUrl: string;
|
||||
adminDashboardUrl: string;
|
||||
adminApiExample: string;
|
||||
proxyApiExample: string;
|
||||
adminApiCurl: string;
|
||||
proxyApiCurl: string;
|
||||
};
|
||||
|
||||
function resolveDisplayHost(host: string): string {
|
||||
const trimmed = (host || '').trim();
|
||||
if (!trimmed || trimmed === '0.0.0.0' || trimmed === '::') return '127.0.0.1';
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function buildStartupEndpoints(input: StartupSummaryInput): StartupEndpoints {
|
||||
const displayHost = resolveDisplayHost(input.host);
|
||||
const baseUrl = `http://${displayHost}:${input.port}`;
|
||||
|
||||
const adminApiExample = `${baseUrl}/api/stats/dashboard`;
|
||||
const proxyApiExample = `${baseUrl}/v1/chat/completions`;
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
adminDashboardUrl: baseUrl,
|
||||
adminApiExample,
|
||||
proxyApiExample,
|
||||
adminApiCurl: `curl '${adminApiExample}' -H 'Authorization: Bearer ${input.authToken}'`,
|
||||
proxyApiCurl: `curl '${proxyApiExample}' -H 'Authorization: Bearer ${input.proxyToken}' -H 'Content-Type: application/json' -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"ping"}]}'`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStartupSummaryLines(input: StartupSummaryInput): string[] {
|
||||
const endpoints = buildStartupEndpoints(input);
|
||||
|
||||
return [
|
||||
`metapi running on ${input.host}:${input.port}`,
|
||||
`Dashboard: ${endpoints.adminDashboardUrl}`,
|
||||
`Admin API: ${endpoints.adminApiExample}`,
|
||||
`Proxy API: ${endpoints.proxyApiExample}`,
|
||||
`Admin curl: ${endpoints.adminApiCurl}`,
|
||||
`Proxy curl: ${endpoints.proxyApiCurl}`,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
estimateRewardWithTodayIncomeFallback,
|
||||
getTodayIncomeDelta,
|
||||
getTodayIncomeValue,
|
||||
updateTodayIncomeSnapshot,
|
||||
} from './todayIncomeRewardService.js';
|
||||
import { formatLocalDate } from './localTimeService.js';
|
||||
|
||||
describe('today income reward service', () => {
|
||||
it('records latest income value for same-day snapshots', () => {
|
||||
const now = new Date('2026-02-25T08:00:00.000Z');
|
||||
const later = new Date('2026-02-25T10:00:00.000Z');
|
||||
const day = formatLocalDate(now);
|
||||
|
||||
const first = updateTodayIncomeSnapshot(null, 12, now);
|
||||
expect(getTodayIncomeValue(first, day)).toBe(12);
|
||||
expect(getTodayIncomeDelta(first, day)).toBe(0);
|
||||
|
||||
const second = updateTodayIncomeSnapshot(first, 15.5, later);
|
||||
expect(getTodayIncomeValue(second, day)).toBe(15.5);
|
||||
expect(getTodayIncomeDelta(second, day)).toBe(3.5);
|
||||
});
|
||||
|
||||
it('resets baseline on day change', () => {
|
||||
const firstAt = new Date('2026-02-25T09:00:00.000Z');
|
||||
const secondAt = new Date('2026-02-26T09:00:00.000Z');
|
||||
const firstDay = formatLocalDate(firstAt);
|
||||
const secondDay = formatLocalDate(secondAt);
|
||||
const day1 = updateTodayIncomeSnapshot(null, 10, firstAt);
|
||||
const day2 = updateTodayIncomeSnapshot(day1, 4, secondAt);
|
||||
|
||||
expect(getTodayIncomeValue(day2, firstDay)).toBe(0);
|
||||
expect(getTodayIncomeValue(day2, secondDay)).toBe(4);
|
||||
expect(getTodayIncomeDelta(day2, firstDay)).toBe(0);
|
||||
expect(getTodayIncomeDelta(day2, secondDay)).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to today income value only when parsed rewards are missing', () => {
|
||||
const day = formatLocalDate(new Date('2026-02-25T08:00:00.000Z'));
|
||||
const extraConfig = updateTodayIncomeSnapshot(
|
||||
updateTodayIncomeSnapshot(null, 8, new Date('2026-02-25T08:00:00.000Z')),
|
||||
10.2,
|
||||
new Date('2026-02-25T12:00:00.000Z'),
|
||||
);
|
||||
|
||||
const fallbackReward = estimateRewardWithTodayIncomeFallback({
|
||||
day,
|
||||
successCount: 1,
|
||||
parsedRewardCount: 0,
|
||||
rewardSum: 0,
|
||||
extraConfig,
|
||||
});
|
||||
expect(fallbackReward).toBeCloseTo(10.2, 6);
|
||||
|
||||
const preferParsed = estimateRewardWithTodayIncomeFallback({
|
||||
day,
|
||||
successCount: 1,
|
||||
parsedRewardCount: 1,
|
||||
rewardSum: 1.5,
|
||||
extraConfig,
|
||||
});
|
||||
expect(preferParsed).toBe(1.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { mergeAccountExtraConfig } from './accountExtraConfig.js';
|
||||
import { formatLocalDate } from './localTimeService.js';
|
||||
|
||||
type TodayIncomeSnapshot = {
|
||||
day: string;
|
||||
baseline?: number;
|
||||
latest: number;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
type EstimateRewardInput = {
|
||||
day: string;
|
||||
successCount: number;
|
||||
parsedRewardCount: number;
|
||||
rewardSum: number;
|
||||
extraConfig?: string | null;
|
||||
};
|
||||
|
||||
function parseObject(value: string | null | undefined): Record<string, unknown> {
|
||||
if (!value) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function toNonNegativeNumber(value: unknown): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
||||
if (value < 0) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeSnapshot(raw: unknown): TodayIncomeSnapshot | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const day = typeof record.day === 'string' ? record.day.trim() : '';
|
||||
if (!day) return null;
|
||||
|
||||
const latest = toNonNegativeNumber(record.latest);
|
||||
if (latest == null) return null;
|
||||
|
||||
const baseline = toNonNegativeNumber(record.baseline);
|
||||
|
||||
return {
|
||||
day,
|
||||
baseline: baseline ?? latest,
|
||||
latest,
|
||||
updatedAt: typeof record.updatedAt === 'string' ? record.updatedAt.trim() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function extractTodayIncomeSnapshot(extraConfig?: string | null): TodayIncomeSnapshot | null {
|
||||
const parsed = parseObject(extraConfig);
|
||||
return normalizeSnapshot(parsed.todayIncomeSnapshot);
|
||||
}
|
||||
|
||||
export function getTodayIncomeDelta(extraConfig: string | null | undefined, day: string): number {
|
||||
if (!day) return 0;
|
||||
const snapshot = extractTodayIncomeSnapshot(extraConfig);
|
||||
if (!snapshot || snapshot.day !== day) return 0;
|
||||
|
||||
const baseline = typeof snapshot.baseline === 'number' ? snapshot.baseline : snapshot.latest;
|
||||
const delta = snapshot.latest - baseline;
|
||||
if (!Number.isFinite(delta) || delta <= 0) return 0;
|
||||
return delta;
|
||||
}
|
||||
|
||||
export function getTodayIncomeValue(extraConfig: string | null | undefined, day: string): number {
|
||||
if (!day) return 0;
|
||||
const snapshot = extractTodayIncomeSnapshot(extraConfig);
|
||||
if (!snapshot || snapshot.day !== day) return 0;
|
||||
return snapshot.latest;
|
||||
}
|
||||
|
||||
export function updateTodayIncomeSnapshot(
|
||||
extraConfig: string | null | undefined,
|
||||
todayIncome: number,
|
||||
now = new Date(),
|
||||
): string {
|
||||
const income = toNonNegativeNumber(todayIncome);
|
||||
if (income == null) return extraConfig || '{}';
|
||||
|
||||
const day = formatLocalDate(now);
|
||||
const existing = extractTodayIncomeSnapshot(extraConfig);
|
||||
let baseline = income;
|
||||
let latest = income;
|
||||
|
||||
if (existing && existing.day === day) {
|
||||
baseline = typeof existing.baseline === 'number' ? existing.baseline : existing.latest;
|
||||
if (income < baseline) baseline = income;
|
||||
latest = income;
|
||||
}
|
||||
|
||||
return mergeAccountExtraConfig(extraConfig, {
|
||||
todayIncomeSnapshot: {
|
||||
day,
|
||||
baseline,
|
||||
latest,
|
||||
updatedAt: now.toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function estimateRewardWithTodayIncomeFallback(input: EstimateRewardInput): number {
|
||||
const rewardSum = Number.isFinite(input.rewardSum) && input.rewardSum > 0 ? input.rewardSum : 0;
|
||||
if (!Number.isFinite(input.successCount) || input.successCount <= 0) return rewardSum;
|
||||
|
||||
const hasMissingReward = input.parsedRewardCount < input.successCount;
|
||||
if (!hasMissingReward && rewardSum > 0) return rewardSum;
|
||||
|
||||
const incomeValue = getTodayIncomeValue(input.extraConfig, input.day);
|
||||
return incomeValue > rewardSum ? incomeValue : rewardSum;
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
type DbModule = typeof import('../db/index.js');
|
||||
type TokenRouterModule = typeof import('./tokenRouter.js');
|
||||
type ConfigModule = typeof import('../config.js');
|
||||
|
||||
const mockedCatalogRoutingCost = vi.fn<(
|
||||
input: { siteId: number; accountId: number; modelName: string }
|
||||
) => number | null>(() => null);
|
||||
|
||||
vi.mock('./modelPricingService.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('./modelPricingService.js')>('./modelPricingService.js');
|
||||
return {
|
||||
...actual,
|
||||
getCachedModelRoutingReferenceCost: mockedCatalogRoutingCost,
|
||||
};
|
||||
});
|
||||
|
||||
describe('TokenRouter selection scoring', () => {
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let TokenRouter: TokenRouterModule['TokenRouter'];
|
||||
let config: ConfigModule['config'];
|
||||
let dataDir = '';
|
||||
let idSeed = 0;
|
||||
let originalRoutingWeights: typeof config.routingWeights;
|
||||
let originalRoutingFallbackUnitCost: number;
|
||||
|
||||
const nextId = () => {
|
||||
idSeed += 1;
|
||||
return idSeed;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-token-router-selection-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../db/migrate.js');
|
||||
const dbModule = await import('../db/index.js');
|
||||
const tokenRouterModule = await import('./tokenRouter.js');
|
||||
const configModule = await import('../config.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
TokenRouter = tokenRouterModule.TokenRouter;
|
||||
config = configModule.config;
|
||||
originalRoutingWeights = { ...config.routingWeights };
|
||||
originalRoutingFallbackUnitCost = config.routingFallbackUnitCost;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
idSeed = 0;
|
||||
mockedCatalogRoutingCost.mockReset();
|
||||
mockedCatalogRoutingCost.mockReturnValue(null);
|
||||
db.delete(schema.routeChannels).run();
|
||||
db.delete(schema.tokenRoutes).run();
|
||||
db.delete(schema.accountTokens).run();
|
||||
db.delete(schema.accounts).run();
|
||||
db.delete(schema.sites).run();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
config.routingWeights = { ...originalRoutingWeights };
|
||||
config.routingFallbackUnitCost = originalRoutingFallbackUnitCost;
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
function createRoute(modelPattern: string) {
|
||||
return db.insert(schema.tokenRoutes).values({
|
||||
modelPattern,
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
}
|
||||
|
||||
function createSite(namePrefix: string) {
|
||||
const id = nextId();
|
||||
return db.insert(schema.sites).values({
|
||||
name: `${namePrefix}-${id}`,
|
||||
url: `https://${namePrefix}-${id}.example.com`,
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
}
|
||||
|
||||
function createAccount(siteId: number, usernamePrefix: string) {
|
||||
const id = nextId();
|
||||
return db.insert(schema.accounts).values({
|
||||
siteId,
|
||||
username: `${usernamePrefix}-${id}`,
|
||||
accessToken: `access-${id}`,
|
||||
apiToken: `sk-${id}`,
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
}
|
||||
|
||||
function createToken(accountId: number, name: string) {
|
||||
return db.insert(schema.accountTokens).values({
|
||||
accountId,
|
||||
name,
|
||||
token: `token-${name}-${nextId()}`,
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
}).returning().get();
|
||||
}
|
||||
|
||||
it('normalizes probability across channels on the same site', () => {
|
||||
config.routingWeights = {
|
||||
baseWeightFactor: 1,
|
||||
valueScoreFactor: 0,
|
||||
costWeight: 0,
|
||||
balanceWeight: 0,
|
||||
usageWeight: 0,
|
||||
};
|
||||
|
||||
const route = createRoute('claude-haiku-4-5-20251001');
|
||||
|
||||
const siteA = createSite('site-a');
|
||||
const accountA = createAccount(siteA.id, 'user-a');
|
||||
const tokenA1 = createToken(accountA.id, 'a-1');
|
||||
const tokenA2 = createToken(accountA.id, 'a-2');
|
||||
|
||||
const siteB = createSite('site-b');
|
||||
const accountB = createAccount(siteB.id, 'user-b');
|
||||
const tokenB = createToken(accountB.id, 'b-1');
|
||||
|
||||
const channelA1 = db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountA.id,
|
||||
tokenId: tokenA1.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const channelA2 = db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountA.id,
|
||||
tokenId: tokenA2.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const channelB = db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountB.id,
|
||||
tokenId: tokenB.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const decision = new TokenRouter().explainSelection('claude-haiku-4-5-20251001');
|
||||
const probMap = new Map(decision.candidates.map((candidate) => [candidate.channelId, candidate.probability]));
|
||||
|
||||
const probA1 = probMap.get(channelA1.id) ?? 0;
|
||||
const probA2 = probMap.get(channelA2.id) ?? 0;
|
||||
const probB = probMap.get(channelB.id) ?? 0;
|
||||
|
||||
expect(probA1).toBeCloseTo(25, 1);
|
||||
expect(probA2).toBeCloseTo(25, 1);
|
||||
expect(probB).toBeCloseTo(50, 1);
|
||||
expect(probA1 + probA2).toBeCloseTo(probB, 1);
|
||||
});
|
||||
|
||||
it('uses observed channel cost from real routing results when scoring cost priority', () => {
|
||||
config.routingWeights = {
|
||||
baseWeightFactor: 0.35,
|
||||
valueScoreFactor: 0.65,
|
||||
costWeight: 1,
|
||||
balanceWeight: 0,
|
||||
usageWeight: 0,
|
||||
};
|
||||
|
||||
const route = createRoute('claude-opus-4-6');
|
||||
|
||||
const siteCheap = createSite('cheap-site');
|
||||
const accountCheap = createAccount(siteCheap.id, 'cheap-user');
|
||||
const tokenCheap = createToken(accountCheap.id, 'cheap-token');
|
||||
db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountCheap.id,
|
||||
tokenId: tokenCheap.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
successCount: 10,
|
||||
failCount: 0,
|
||||
totalCost: 0.01,
|
||||
}).run();
|
||||
|
||||
const siteExpensive = createSite('expensive-site');
|
||||
const accountExpensive = createAccount(siteExpensive.id, 'expensive-user');
|
||||
const tokenExpensive = createToken(accountExpensive.id, 'exp-token');
|
||||
db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountExpensive.id,
|
||||
tokenId: tokenExpensive.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
successCount: 10,
|
||||
failCount: 0,
|
||||
totalCost: 0.1,
|
||||
}).run();
|
||||
|
||||
const decision = new TokenRouter().explainSelection('claude-opus-4-6');
|
||||
const cheapCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('cheap-site'));
|
||||
const expensiveCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('expensive-site'));
|
||||
|
||||
expect(cheapCandidate).toBeTruthy();
|
||||
expect(expensiveCandidate).toBeTruthy();
|
||||
expect((cheapCandidate?.probability || 0)).toBeGreaterThan(expensiveCandidate?.probability || 0);
|
||||
expect(cheapCandidate?.reason || '').toContain('成本=实测');
|
||||
expect(expensiveCandidate?.reason || '').toContain('成本=实测');
|
||||
});
|
||||
|
||||
it('uses runtime-configured fallback unit cost when observed and configured costs are missing', () => {
|
||||
config.routingWeights = {
|
||||
baseWeightFactor: 0.35,
|
||||
valueScoreFactor: 0.65,
|
||||
costWeight: 1,
|
||||
balanceWeight: 0,
|
||||
usageWeight: 0,
|
||||
};
|
||||
config.routingFallbackUnitCost = 0.02;
|
||||
|
||||
const route = createRoute('claude-sonnet-4-6');
|
||||
|
||||
const siteFallback = createSite('fallback-site');
|
||||
const accountFallback = createAccount(siteFallback.id, 'fallback-user');
|
||||
const tokenFallback = createToken(accountFallback.id, 'fallback-token');
|
||||
db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountFallback.id,
|
||||
tokenId: tokenFallback.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
successCount: 0,
|
||||
failCount: 0,
|
||||
totalCost: 0,
|
||||
}).run();
|
||||
|
||||
const siteObserved = createSite('observed-site');
|
||||
const accountObserved = createAccount(siteObserved.id, 'observed-user');
|
||||
const tokenObserved = createToken(accountObserved.id, 'observed-token');
|
||||
db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountObserved.id,
|
||||
tokenId: tokenObserved.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
successCount: 10,
|
||||
failCount: 0,
|
||||
totalCost: 2, // unit cost 0.2
|
||||
}).run();
|
||||
|
||||
const decision = new TokenRouter().explainSelection('claude-sonnet-4-6');
|
||||
const fallbackCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('fallback-site'));
|
||||
const observedCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('observed-site'));
|
||||
|
||||
expect(fallbackCandidate).toBeTruthy();
|
||||
expect(observedCandidate).toBeTruthy();
|
||||
expect((fallbackCandidate?.probability || 0)).toBeGreaterThan(observedCandidate?.probability || 0);
|
||||
expect(fallbackCandidate?.reason || '').toContain('成本=默认:0.020000');
|
||||
});
|
||||
|
||||
it('penalizes fallback-cost channels when fallback unit cost is set very high', () => {
|
||||
config.routingWeights = {
|
||||
baseWeightFactor: 0.35,
|
||||
valueScoreFactor: 0.65,
|
||||
costWeight: 0.75,
|
||||
balanceWeight: 0.15,
|
||||
usageWeight: 0.1,
|
||||
};
|
||||
config.routingFallbackUnitCost = 1000;
|
||||
|
||||
const route = createRoute('gpt-5-nano');
|
||||
|
||||
const siteFallback = createSite('fallback-high-balance');
|
||||
const accountFallback = db.insert(schema.accounts).values({
|
||||
siteId: siteFallback.id,
|
||||
username: `fallback-high-balance-${nextId()}`,
|
||||
accessToken: `access-${nextId()}`,
|
||||
apiToken: `sk-${nextId()}`,
|
||||
status: 'active',
|
||||
balance: 10_000,
|
||||
}).returning().get();
|
||||
const tokenFallback = createToken(accountFallback.id, 'fallback-token');
|
||||
db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountFallback.id,
|
||||
tokenId: tokenFallback.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
successCount: 0,
|
||||
failCount: 0,
|
||||
totalCost: 0,
|
||||
}).run();
|
||||
|
||||
const siteObserved = createSite('observed-low-balance');
|
||||
const accountObserved = db.insert(schema.accounts).values({
|
||||
siteId: siteObserved.id,
|
||||
username: `observed-low-balance-${nextId()}`,
|
||||
accessToken: `access-${nextId()}`,
|
||||
apiToken: `sk-${nextId()}`,
|
||||
status: 'active',
|
||||
balance: 0,
|
||||
}).returning().get();
|
||||
const tokenObserved = createToken(accountObserved.id, 'observed-token');
|
||||
db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountObserved.id,
|
||||
tokenId: tokenObserved.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
successCount: 10,
|
||||
failCount: 0,
|
||||
totalCost: 10, // observed unit cost = 1
|
||||
}).run();
|
||||
|
||||
const decision = new TokenRouter().explainSelection('gpt-5-nano');
|
||||
const fallbackCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('fallback-high-balance'));
|
||||
const observedCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('observed-low-balance'));
|
||||
|
||||
expect(fallbackCandidate).toBeTruthy();
|
||||
expect(observedCandidate).toBeTruthy();
|
||||
expect((fallbackCandidate?.probability || 0)).toBeLessThan(1);
|
||||
expect((observedCandidate?.probability || 0)).toBeGreaterThan(99);
|
||||
expect(fallbackCandidate?.reason || '').toContain('成本=默认:1000.000000');
|
||||
});
|
||||
|
||||
it('uses cached catalog routing cost when observed and configured costs are missing', () => {
|
||||
config.routingWeights = {
|
||||
baseWeightFactor: 0.35,
|
||||
valueScoreFactor: 0.65,
|
||||
costWeight: 1,
|
||||
balanceWeight: 0,
|
||||
usageWeight: 0,
|
||||
};
|
||||
config.routingFallbackUnitCost = 100;
|
||||
|
||||
const route = createRoute('claude-sonnet-4-5-20250929');
|
||||
|
||||
const siteCatalog = createSite('catalog-site');
|
||||
const accountCatalog = createAccount(siteCatalog.id, 'catalog-user');
|
||||
const tokenCatalog = createToken(accountCatalog.id, 'catalog-token');
|
||||
db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountCatalog.id,
|
||||
tokenId: tokenCatalog.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
successCount: 0,
|
||||
failCount: 0,
|
||||
totalCost: 0,
|
||||
}).run();
|
||||
|
||||
const siteFallback = createSite('fallback-site');
|
||||
const accountFallback = createAccount(siteFallback.id, 'fallback-user');
|
||||
const tokenFallback = createToken(accountFallback.id, 'fallback-token');
|
||||
db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountFallback.id,
|
||||
tokenId: tokenFallback.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
successCount: 0,
|
||||
failCount: 0,
|
||||
totalCost: 0,
|
||||
}).run();
|
||||
|
||||
mockedCatalogRoutingCost.mockImplementation(({ accountId, modelName }) => {
|
||||
if (accountId !== accountCatalog.id) return null;
|
||||
if (modelName !== 'claude-sonnet-4-5-20250929') return null;
|
||||
return 0.2;
|
||||
});
|
||||
|
||||
const decision = new TokenRouter().explainSelection('claude-sonnet-4-5-20250929');
|
||||
const catalogCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('catalog-site'));
|
||||
const fallbackCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('fallback-site'));
|
||||
|
||||
expect(catalogCandidate).toBeTruthy();
|
||||
expect(fallbackCandidate).toBeTruthy();
|
||||
expect((catalogCandidate?.probability || 0)).toBeGreaterThan(fallbackCandidate?.probability || 0);
|
||||
expect(catalogCandidate?.reason || '').toContain('成本=目录:0.200000');
|
||||
expect(fallbackCandidate?.reason || '').toContain('成本=默认:100.000000');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
type DbModule = typeof import('../db/index.js');
|
||||
type TokenRouterModule = typeof import('./tokenRouter.js');
|
||||
|
||||
describe('TokenRouter site status guard', () => {
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let TokenRouter: TokenRouterModule['TokenRouter'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-token-router-site-status-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../db/migrate.js');
|
||||
const dbModule = await import('../db/index.js');
|
||||
const tokenRouterModule = await import('./tokenRouter.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
TokenRouter = tokenRouterModule.TokenRouter;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.delete(schema.routeChannels).run();
|
||||
db.delete(schema.tokenRoutes).run();
|
||||
db.delete(schema.tokenModelAvailability).run();
|
||||
db.delete(schema.modelAvailability).run();
|
||||
db.delete(schema.accountTokens).run();
|
||||
db.delete(schema.accounts).run();
|
||||
db.delete(schema.sites).run();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('does not select channels from disabled sites', () => {
|
||||
const site = db.insert(schema.sites).values({
|
||||
name: 'disabled-site',
|
||||
url: 'https://disabled.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
db.run(sql`update sites set status = 'disabled' where id = ${site.id}`);
|
||||
|
||||
const account = db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'disabled-user',
|
||||
accessToken: 'access-disabled',
|
||||
apiToken: 'sk-disabled',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const route = db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-4o-mini',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const channel = db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: account.id,
|
||||
tokenId: null,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
}).returning().get();
|
||||
|
||||
const router = new TokenRouter();
|
||||
const selected = router.selectChannel('gpt-4o-mini');
|
||||
expect(selected).toBeNull();
|
||||
|
||||
const decision = router.explainSelection('gpt-4o-mini');
|
||||
expect(decision.matched).toBe(true);
|
||||
const candidate = decision.candidates.find((item) => item.channelId === channel.id);
|
||||
expect(candidate?.eligible).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { filterRecentlyFailedCandidates } from './tokenRouter.js';
|
||||
|
||||
type Candidate = {
|
||||
channel: {
|
||||
failCount?: number | null;
|
||||
lastFailAt?: string | null;
|
||||
};
|
||||
id: string;
|
||||
};
|
||||
|
||||
describe('filterRecentlyFailedCandidates', () => {
|
||||
it('prefers healthy channels when at least one healthy channel exists', () => {
|
||||
const nowMs = Date.now();
|
||||
const candidates: Candidate[] = [
|
||||
{
|
||||
id: 'failed',
|
||||
channel: {
|
||||
failCount: 2,
|
||||
lastFailAt: new Date(nowMs - 30 * 1000).toISOString(),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'healthy',
|
||||
channel: {
|
||||
failCount: 0,
|
||||
lastFailAt: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = filterRecentlyFailedCandidates(candidates, nowMs, 600);
|
||||
expect(result.map((c) => c.id)).toEqual(['healthy']);
|
||||
});
|
||||
|
||||
it('keeps all channels when all channels failed recently', () => {
|
||||
const nowMs = Date.now();
|
||||
const candidates: Candidate[] = [
|
||||
{
|
||||
id: 'a',
|
||||
channel: {
|
||||
failCount: 1,
|
||||
lastFailAt: new Date(nowMs - 20 * 1000).toISOString(),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
channel: {
|
||||
failCount: 3,
|
||||
lastFailAt: new Date(nowMs - 40 * 1000).toISOString(),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = filterRecentlyFailedCandidates(candidates, nowMs, 600);
|
||||
expect(result.map((c) => c.id).sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('does not penalize stale failures outside the avoidance window', () => {
|
||||
const nowMs = Date.now();
|
||||
const candidates: Candidate[] = [
|
||||
{
|
||||
id: 'stale-failure',
|
||||
channel: {
|
||||
failCount: 5,
|
||||
lastFailAt: new Date(nowMs - 20 * 60 * 1000).toISOString(),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'healthy',
|
||||
channel: {
|
||||
failCount: 0,
|
||||
lastFailAt: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = filterRecentlyFailedCandidates(candidates, nowMs, 600);
|
||||
expect(result.map((c) => c.id).sort()).toEqual(['healthy', 'stale-failure']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,603 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { minimatch } from 'minimatch';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { config } from '../config.js';
|
||||
import { getCachedModelRoutingReferenceCost } from './modelPricingService.js';
|
||||
|
||||
interface RouteMatch {
|
||||
route: typeof schema.tokenRoutes.$inferSelect;
|
||||
channels: Array<{
|
||||
channel: typeof schema.routeChannels.$inferSelect;
|
||||
account: typeof schema.accounts.$inferSelect;
|
||||
site: typeof schema.sites.$inferSelect;
|
||||
token: typeof schema.accountTokens.$inferSelect | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
type RouteChannelCandidate = RouteMatch['channels'][number];
|
||||
|
||||
interface SelectedChannel {
|
||||
channel: typeof schema.routeChannels.$inferSelect;
|
||||
account: typeof schema.accounts.$inferSelect;
|
||||
site: typeof schema.sites.$inferSelect;
|
||||
token: typeof schema.accountTokens.$inferSelect | null;
|
||||
tokenValue: string;
|
||||
tokenName: string;
|
||||
actualModel: string;
|
||||
}
|
||||
|
||||
type FailureAwareChannel = {
|
||||
failCount?: number | null;
|
||||
lastFailAt?: string | null;
|
||||
};
|
||||
|
||||
const RECENT_FAILURE_AVOID_SEC = 10 * 60;
|
||||
const MIN_EFFECTIVE_UNIT_COST = 1e-6;
|
||||
|
||||
function isSiteDisabled(status?: string | null): boolean {
|
||||
return (status || 'active') === 'disabled';
|
||||
}
|
||||
|
||||
export function isChannelRecentlyFailed(
|
||||
channel: FailureAwareChannel,
|
||||
nowMs = Date.now(),
|
||||
avoidSec = RECENT_FAILURE_AVOID_SEC,
|
||||
): boolean {
|
||||
if (avoidSec <= 0) return false;
|
||||
if ((channel.failCount ?? 0) <= 0) return false;
|
||||
if (!channel.lastFailAt) return false;
|
||||
|
||||
const failTs = Date.parse(channel.lastFailAt);
|
||||
if (Number.isNaN(failTs)) return false;
|
||||
|
||||
return nowMs - failTs < avoidSec * 1000;
|
||||
}
|
||||
|
||||
export function filterRecentlyFailedCandidates<T extends { channel: FailureAwareChannel }>(
|
||||
candidates: T[],
|
||||
nowMs = Date.now(),
|
||||
avoidSec = RECENT_FAILURE_AVOID_SEC,
|
||||
): T[] {
|
||||
if (candidates.length <= 1) return candidates;
|
||||
if (avoidSec <= 0) return candidates;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface RouteDecisionCandidate {
|
||||
channelId: number;
|
||||
accountId: number;
|
||||
username: string;
|
||||
siteName: string;
|
||||
tokenName: string;
|
||||
priority: number;
|
||||
weight: number;
|
||||
eligible: boolean;
|
||||
recentlyFailed: boolean;
|
||||
avoidedByRecentFailure: boolean;
|
||||
probability: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface RouteDecisionExplanation {
|
||||
requestedModel: string;
|
||||
actualModel: string;
|
||||
matched: boolean;
|
||||
routeId?: number;
|
||||
modelPattern?: string;
|
||||
selectedChannelId?: number;
|
||||
selectedAccountId?: number;
|
||||
selectedLabel?: string;
|
||||
summary: string[];
|
||||
candidates: RouteDecisionCandidate[];
|
||||
}
|
||||
|
||||
type CostSignal = {
|
||||
unitCost: number;
|
||||
source: 'observed' | 'configured' | 'catalog' | 'fallback';
|
||||
};
|
||||
|
||||
function resolveEffectiveUnitCost(candidate: RouteChannelCandidate, modelName: string): CostSignal {
|
||||
const successCount = Math.max(0, candidate.channel.successCount ?? 0);
|
||||
const totalCost = Math.max(0, candidate.channel.totalCost ?? 0);
|
||||
const configured = candidate.account.unitCost ?? null;
|
||||
|
||||
if (successCount > 0 && totalCost > 0) {
|
||||
return {
|
||||
unitCost: Math.max(totalCost / successCount, MIN_EFFECTIVE_UNIT_COST),
|
||||
source: 'observed',
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof configured === 'number' && Number.isFinite(configured) && configured > 0) {
|
||||
return {
|
||||
unitCost: Math.max(configured, MIN_EFFECTIVE_UNIT_COST),
|
||||
source: 'configured',
|
||||
};
|
||||
}
|
||||
|
||||
const catalogCost = getCachedModelRoutingReferenceCost({
|
||||
siteId: candidate.site.id,
|
||||
accountId: candidate.account.id,
|
||||
modelName,
|
||||
});
|
||||
if (typeof catalogCost === 'number' && Number.isFinite(catalogCost) && catalogCost > 0) {
|
||||
return {
|
||||
unitCost: Math.max(catalogCost, MIN_EFFECTIVE_UNIT_COST),
|
||||
source: 'catalog',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
unitCost: Math.max(config.routingFallbackUnitCost || 1, MIN_EFFECTIVE_UNIT_COST),
|
||||
source: 'fallback',
|
||||
};
|
||||
}
|
||||
|
||||
export class TokenRouter {
|
||||
/**
|
||||
* Find matching route and select a channel for the given model.
|
||||
* Returns null if no route/channel available.
|
||||
*/
|
||||
selectChannel(requestedModel: string): SelectedChannel | null {
|
||||
const match = this.findRoute(requestedModel);
|
||||
if (!match) return null;
|
||||
|
||||
// Apply model mapping if configured
|
||||
let actualModel = requestedModel;
|
||||
if (match.route.modelMapping) {
|
||||
try {
|
||||
const mapping = JSON.parse(match.route.modelMapping);
|
||||
if (mapping[requestedModel]) actualModel = mapping[requestedModel];
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Filter channels: enabled, not in cooldown, account/site active with token
|
||||
const nowIso = new Date().toISOString();
|
||||
const nowMs = Date.now();
|
||||
const available = match.channels.filter((c) =>
|
||||
c.channel.enabled &&
|
||||
c.account.status === 'active' &&
|
||||
!isSiteDisabled(c.site.status) &&
|
||||
!!this.resolveChannelTokenValue(c) &&
|
||||
(!c.channel.cooldownUntil || c.channel.cooldownUntil <= nowIso),
|
||||
);
|
||||
|
||||
if (available.length === 0) return null;
|
||||
|
||||
// Group by priority
|
||||
const layers = new Map<number, typeof available>();
|
||||
for (const c of available) {
|
||||
const p = c.channel.priority ?? 0;
|
||||
if (!layers.has(p)) layers.set(p, []);
|
||||
layers.get(p)!.push(c);
|
||||
}
|
||||
|
||||
// Sort layers by priority (ascending = higher priority first)
|
||||
const sortedPriorities = Array.from(layers.keys()).sort((a, b) => a - b);
|
||||
|
||||
// Try each priority layer
|
||||
for (const priority of sortedPriorities) {
|
||||
const candidates = filterRecentlyFailedCandidates(
|
||||
layers.get(priority)!,
|
||||
nowMs,
|
||||
RECENT_FAILURE_AVOID_SEC,
|
||||
);
|
||||
const selected = this.weightedRandomSelect(candidates, actualModel);
|
||||
if (selected) {
|
||||
const tokenValue = this.resolveChannelTokenValue(selected);
|
||||
if (!tokenValue) continue;
|
||||
|
||||
return {
|
||||
...selected,
|
||||
tokenValue,
|
||||
tokenName: selected.token?.name || 'default',
|
||||
actualModel,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select next channel for failover (exclude already-tried channels).
|
||||
*/
|
||||
selectNextChannel(requestedModel: string, excludeChannelIds: number[]): SelectedChannel | null {
|
||||
const match = this.findRoute(requestedModel);
|
||||
if (!match) return null;
|
||||
|
||||
let actualModel = requestedModel;
|
||||
if (match.route.modelMapping) {
|
||||
try {
|
||||
const mapping = JSON.parse(match.route.modelMapping);
|
||||
if (mapping[requestedModel]) actualModel = mapping[requestedModel];
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const nowMs = Date.now();
|
||||
const available = match.channels.filter((c) =>
|
||||
c.channel.enabled &&
|
||||
c.account.status === 'active' &&
|
||||
!isSiteDisabled(c.site.status) &&
|
||||
!!this.resolveChannelTokenValue(c) &&
|
||||
(!c.channel.cooldownUntil || c.channel.cooldownUntil <= nowIso) &&
|
||||
!excludeChannelIds.includes(c.channel.id),
|
||||
);
|
||||
|
||||
if (available.length === 0) return null;
|
||||
|
||||
const layers = new Map<number, typeof available>();
|
||||
for (const c of available) {
|
||||
const p = c.channel.priority ?? 0;
|
||||
if (!layers.has(p)) layers.set(p, []);
|
||||
layers.get(p)!.push(c);
|
||||
}
|
||||
|
||||
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 selected = this.weightedRandomSelect(candidates, actualModel);
|
||||
if (!selected) continue;
|
||||
|
||||
const tokenValue = this.resolveChannelTokenValue(selected);
|
||||
if (!tokenValue) continue;
|
||||
|
||||
return {
|
||||
...selected,
|
||||
tokenValue,
|
||||
tokenName: selected.token?.name || 'default',
|
||||
actualModel,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
explainSelection(requestedModel: string, excludeChannelIds: number[] = []): RouteDecisionExplanation {
|
||||
const match = this.findRoute(requestedModel);
|
||||
if (!match) {
|
||||
return {
|
||||
requestedModel,
|
||||
actualModel: requestedModel,
|
||||
matched: false,
|
||||
summary: ['未匹配到启用的路由'],
|
||||
candidates: [],
|
||||
};
|
||||
}
|
||||
|
||||
let actualModel = requestedModel;
|
||||
if (match.route.modelMapping) {
|
||||
try {
|
||||
const mapping = JSON.parse(match.route.modelMapping) as Record<string, string>;
|
||||
if (mapping[requestedModel]) actualModel = mapping[requestedModel];
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const nowMs = Date.now();
|
||||
const summary: string[] = [`命中路由:${match.route.modelPattern}`];
|
||||
const availableByPriority = new Map<number, RouteChannelCandidate[]>();
|
||||
const candidates: RouteDecisionCandidate[] = [];
|
||||
const candidateMap = new Map<number, RouteDecisionCandidate>();
|
||||
|
||||
for (const row of match.channels) {
|
||||
const reasonParts: string[] = [];
|
||||
if (!row.channel.enabled) reasonParts.push('通道禁用');
|
||||
if (row.account.status !== 'active') reasonParts.push(`账号状态=${row.account.status}`);
|
||||
if (isSiteDisabled(row.site.status)) reasonParts.push(`站点状态=${row.site.status || 'disabled'}`);
|
||||
if (excludeChannelIds.includes(row.channel.id)) reasonParts.push('当前请求已尝试');
|
||||
const tokenValue = this.resolveChannelTokenValue(row);
|
||||
if (!tokenValue) reasonParts.push('令牌不可用');
|
||||
if (row.channel.cooldownUntil && row.channel.cooldownUntil > nowIso) reasonParts.push('冷却中');
|
||||
|
||||
const recentlyFailed = isChannelRecentlyFailed(row.channel, nowMs, RECENT_FAILURE_AVOID_SEC);
|
||||
const eligible = reasonParts.length === 0;
|
||||
const candidate: RouteDecisionCandidate = {
|
||||
channelId: row.channel.id,
|
||||
accountId: row.account.id,
|
||||
username: row.account.username || `account-${row.account.id}`,
|
||||
siteName: row.site.name || 'unknown',
|
||||
tokenName: row.token?.name || 'default',
|
||||
priority: row.channel.priority ?? 0,
|
||||
weight: row.channel.weight ?? 10,
|
||||
eligible,
|
||||
recentlyFailed,
|
||||
avoidedByRecentFailure: false,
|
||||
probability: 0,
|
||||
reason: eligible ? '可用' : reasonParts.join('、'),
|
||||
};
|
||||
candidates.push(candidate);
|
||||
candidateMap.set(candidate.channelId, candidate);
|
||||
|
||||
if (eligible) {
|
||||
const priority = row.channel.priority ?? 0;
|
||||
if (!availableByPriority.has(priority)) availableByPriority.set(priority, []);
|
||||
availableByPriority.get(priority)!.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
if (availableByPriority.size === 0) {
|
||||
summary.push('没有可用通道(全部被禁用、站点不可用、冷却或令牌不可用)');
|
||||
return {
|
||||
requestedModel,
|
||||
actualModel,
|
||||
matched: true,
|
||||
routeId: match.route.id,
|
||||
modelPattern: match.route.modelPattern,
|
||||
summary,
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
const sortedPriorities = Array.from(availableByPriority.keys()).sort((a, b) => a - b);
|
||||
let selected: RouteChannelCandidate | null = null;
|
||||
let selectedPriority = 0;
|
||||
|
||||
for (const priority of sortedPriorities) {
|
||||
const rawLayer = availableByPriority.get(priority) ?? [];
|
||||
if (rawLayer.length === 0) continue;
|
||||
|
||||
const filteredLayer = filterRecentlyFailedCandidates(rawLayer, nowMs, RECENT_FAILURE_AVOID_SEC);
|
||||
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 weighted = this.calculateWeightedSelection(filteredLayer, actualModel);
|
||||
for (const detail of weighted.details) {
|
||||
const target = candidateMap.get(detail.candidate.channel.id);
|
||||
if (!target) continue;
|
||||
target.probability = Number((detail.probability * 100).toFixed(2));
|
||||
if (target.eligible && !target.avoidedByRecentFailure) {
|
||||
target.reason = detail.reason;
|
||||
}
|
||||
}
|
||||
|
||||
if (!weighted.selected) continue;
|
||||
selected = weighted.selected;
|
||||
selectedPriority = priority;
|
||||
summary.push(
|
||||
avoided.length > 0
|
||||
? `优先级 P${priority}:可用 ${rawLayer.length},因最近失败避让 ${avoided.length}`
|
||||
: `优先级 P${priority}:可用 ${rawLayer.length}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
summary.push('本次未选出通道');
|
||||
return {
|
||||
requestedModel,
|
||||
actualModel,
|
||||
matched: true,
|
||||
routeId: match.route.id,
|
||||
modelPattern: match.route.modelPattern,
|
||||
summary,
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
const selectedChannel = candidateMap.get(selected.channel.id);
|
||||
const selectedLabel = selectedChannel
|
||||
? `${selectedChannel.username} @ ${selectedChannel.siteName} / ${selectedChannel.tokenName}`
|
||||
: `channel-${selected.channel.id}`;
|
||||
summary.push(`最终选择:${selectedLabel}(P${selectedPriority})`);
|
||||
|
||||
return {
|
||||
requestedModel,
|
||||
actualModel,
|
||||
matched: true,
|
||||
routeId: match.route.id,
|
||||
modelPattern: match.route.modelPattern,
|
||||
selectedChannelId: selected.channel.id,
|
||||
selectedAccountId: selected.account.id,
|
||||
selectedLabel,
|
||||
summary,
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record success for a channel.
|
||||
*/
|
||||
recordSuccess(channelId: number, latencyMs: number, cost: number) {
|
||||
const ch = db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
|
||||
if (!ch) return;
|
||||
db.update(schema.routeChannels).set({
|
||||
successCount: (ch.successCount ?? 0) + 1,
|
||||
totalLatencyMs: (ch.totalLatencyMs ?? 0) + latencyMs,
|
||||
totalCost: (ch.totalCost ?? 0) + cost,
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
cooldownUntil: null,
|
||||
lastFailAt: null,
|
||||
}).where(eq(schema.routeChannels.id, channelId)).run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record failure and set cooldown.
|
||||
*/
|
||||
recordFailure(channelId: number) {
|
||||
const ch = 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();
|
||||
db.update(schema.routeChannels).set({
|
||||
failCount,
|
||||
lastFailAt: new Date().toISOString(),
|
||||
cooldownUntil,
|
||||
}).where(eq(schema.routeChannels.id, channelId)).run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available models (aggregated from all routes).
|
||||
*/
|
||||
getAvailableModels(): string[] {
|
||||
const routes = db.select().from(schema.tokenRoutes)
|
||||
.where(eq(schema.tokenRoutes.enabled, true)).all();
|
||||
return routes.map((r) => r.modelPattern);
|
||||
}
|
||||
|
||||
// --- Private methods ---
|
||||
|
||||
private findRoute(model: string): RouteMatch | null {
|
||||
const routes = db.select().from(schema.tokenRoutes)
|
||||
.where(eq(schema.tokenRoutes.enabled, true)).all();
|
||||
|
||||
// Find matching route by pattern
|
||||
const matchedRoute = routes.find((r) => {
|
||||
if (r.modelPattern === model) return true; // exact match
|
||||
return minimatch(model, r.modelPattern); // glob match
|
||||
});
|
||||
|
||||
if (!matchedRoute) return null;
|
||||
|
||||
// Get channels with account, site and token info
|
||||
const channels = db
|
||||
.select()
|
||||
.from(schema.routeChannels)
|
||||
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
|
||||
.where(eq(schema.routeChannels.routeId, matchedRoute.id))
|
||||
.all();
|
||||
|
||||
const mapped = channels.map((row) => ({
|
||||
channel: row.route_channels,
|
||||
account: row.accounts,
|
||||
site: row.sites,
|
||||
token: row.account_tokens,
|
||||
}));
|
||||
|
||||
return { route: matchedRoute, channels: mapped };
|
||||
}
|
||||
|
||||
private resolveChannelTokenValue(candidate: {
|
||||
channel: typeof schema.routeChannels.$inferSelect;
|
||||
account: typeof schema.accounts.$inferSelect;
|
||||
token: typeof schema.accountTokens.$inferSelect | null;
|
||||
}): string | null {
|
||||
if (candidate.channel.tokenId) {
|
||||
if (!candidate.token) return null;
|
||||
if (!candidate.token.enabled) return null;
|
||||
const token = candidate.token.token?.trim();
|
||||
return token ? token : null;
|
||||
}
|
||||
|
||||
const fallback = candidate.account.apiToken?.trim();
|
||||
return fallback ? fallback : null;
|
||||
}
|
||||
|
||||
private weightedRandomSelect(
|
||||
candidates: RouteChannelCandidate[],
|
||||
modelName: string,
|
||||
) {
|
||||
return this.calculateWeightedSelection(candidates, modelName).selected;
|
||||
}
|
||||
|
||||
private calculateWeightedSelection(candidates: RouteChannelCandidate[], modelName: string) {
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
selected: null as RouteChannelCandidate | null,
|
||||
details: [] as Array<{ candidate: RouteChannelCandidate; probability: number; reason: string }>,
|
||||
};
|
||||
}
|
||||
|
||||
if (candidates.length === 1) {
|
||||
return {
|
||||
selected: candidates[0],
|
||||
details: [{
|
||||
candidate: candidates[0],
|
||||
probability: 1,
|
||||
reason: '唯一可用候选',
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
const { baseWeightFactor, valueScoreFactor, costWeight, balanceWeight, usageWeight } = config.routingWeights;
|
||||
const effectiveCosts = candidates.map((candidate) => resolveEffectiveUnitCost(candidate, modelName));
|
||||
|
||||
const valueScores = candidates.map((c, i) => {
|
||||
const unitCost = effectiveCosts[i]?.unitCost || 1;
|
||||
const balance = c.account.balance || 0;
|
||||
const totalUsed = (c.channel.successCount ?? 0) + (c.channel.failCount ?? 0);
|
||||
const recentUsage = Math.max(totalUsed, 1);
|
||||
return costWeight * (1 / unitCost) + balanceWeight * balance + usageWeight * (1 / recentUsage);
|
||||
});
|
||||
|
||||
const maxVS = Math.max(...valueScores, 0.001);
|
||||
const minVS = Math.min(...valueScores, 0);
|
||||
const range = maxVS - minVS || 1;
|
||||
const normalizedVS = valueScores.map((v) => (v - minVS) / range);
|
||||
|
||||
const baseContributions = candidates.map((c, i) => {
|
||||
const weight = c.channel.weight ?? 10;
|
||||
return (weight + 10) * (baseWeightFactor + normalizedVS[i] * valueScoreFactor);
|
||||
});
|
||||
|
||||
// Avoid over-favoring a site that has many tokens/channels for the same route.
|
||||
// Site-level total contribution remains comparable, then split across its channels.
|
||||
const siteChannelCounts = new Map<number, number>();
|
||||
for (const candidate of candidates) {
|
||||
siteChannelCounts.set(candidate.site.id, (siteChannelCounts.get(candidate.site.id) || 0) + 1);
|
||||
}
|
||||
|
||||
const contributions = candidates.map((candidate, i) => {
|
||||
const siteChannels = Math.max(1, siteChannelCounts.get(candidate.site.id) || 1);
|
||||
let contribution = baseContributions[i] / siteChannels;
|
||||
|
||||
// If upstream price is unknown and we are using fallback unit cost,
|
||||
// apply an explicit penalty so raising fallback cost meaningfully lowers probability.
|
||||
if (effectiveCosts[i]?.source === 'fallback') {
|
||||
contribution *= 1 / Math.max(1, effectiveCosts[i]?.unitCost || 1);
|
||||
}
|
||||
|
||||
return contribution;
|
||||
});
|
||||
|
||||
const totalContribution = contributions.reduce((a, b) => a + b, 0);
|
||||
const details = candidates.map((candidate, i) => {
|
||||
const probability = totalContribution > 0 ? contributions[i] / totalContribution : 0;
|
||||
const weight = candidate.channel.weight ?? 10;
|
||||
const cost = effectiveCosts[i];
|
||||
const costSourceText = cost?.source === 'observed'
|
||||
? '实测'
|
||||
: (cost?.source === 'configured' ? '配置' : (cost?.source === 'catalog' ? '目录' : '默认'));
|
||||
const siteChannels = Math.max(1, siteChannelCounts.get(candidate.site.id) || 1);
|
||||
return {
|
||||
candidate,
|
||||
probability,
|
||||
reason: `按权重随机(W=${weight},成本=${costSourceText}:${(cost?.unitCost || 1).toFixed(6)},同站点通道=${siteChannels},概率≈${(probability * 100).toFixed(1)}%)`,
|
||||
};
|
||||
});
|
||||
|
||||
let rand = Math.random() * totalContribution;
|
||||
let selected = candidates[candidates.length - 1];
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
rand -= contributions[i];
|
||||
if (rand <= 0) {
|
||||
selected = candidates[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { selected, details };
|
||||
}
|
||||
}
|
||||
|
||||
export const tokenRouter = new TokenRouter();
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mapModelDescriptionsFromPayload } from './upstreamModelDescriptionService.js';
|
||||
|
||||
describe('upstreamModelDescriptionService', () => {
|
||||
it('extracts descriptions from wrapped newapi models payload', () => {
|
||||
const descriptions = mapModelDescriptionsFromPayload({
|
||||
data: [
|
||||
{ model_name: 'gpt-4o', description: 'OpenAI flagship model' },
|
||||
{ model_name: 'claude-sonnet-4-5', description: 'Anthropic balanced model' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(descriptions.get('gpt-4o')).toBe('OpenAI flagship model');
|
||||
expect(descriptions.get('claude-sonnet-4-5')).toBe('Anthropic balanced model');
|
||||
});
|
||||
|
||||
it('accepts alternate model keys and description fields', () => {
|
||||
const descriptions = mapModelDescriptionsFromPayload([
|
||||
{ id: 'Qwen/Qwen3-8B', model_description: 'Qwen open model' },
|
||||
{ name: 'deepseek-v3.2', desc: 'DeepSeek chat model' },
|
||||
]);
|
||||
|
||||
expect(descriptions.get('qwen/qwen3-8b')).toBe('Qwen open model');
|
||||
expect(descriptions.get('deepseek-v3.2')).toBe('DeepSeek chat model');
|
||||
});
|
||||
|
||||
it('ignores blank model names and blank descriptions', () => {
|
||||
const descriptions = mapModelDescriptionsFromPayload({
|
||||
data: [
|
||||
{ model_name: ' ', description: 'invalid' },
|
||||
{ model_name: 'gpt-5-nano', description: ' ' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(descriptions.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { RequestInit as UndiciRequestInit } from 'undici';
|
||||
|
||||
const UPSTREAM_MODELS_TIMEOUT_MS = 8_000;
|
||||
const UPSTREAM_MODELS_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const UPSTREAM_MODELS_FAILURE_TTL_MS = 60 * 1000;
|
||||
|
||||
const DEFAULT_UPSTREAM_MODELS_URLS = [
|
||||
'https://basellm.github.io/llm-metadata/api/i18n/en/newapi/models.json',
|
||||
'https://basellm.github.io/llm-metadata/api/newapi/models.json',
|
||||
];
|
||||
|
||||
interface DescriptionCacheEntry {
|
||||
fetchedAt: number;
|
||||
ttlMs: number;
|
||||
descriptions: Map<string, string>;
|
||||
}
|
||||
|
||||
let descriptionCache: DescriptionCacheEntry | null = null;
|
||||
|
||||
function normalizeModelKey(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function unwrapArrayPayload(payload: unknown): unknown[] {
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (payload && typeof payload === 'object' && Array.isArray((payload as any).data)) {
|
||||
return (payload as any).data;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function readStringField(raw: unknown, keys: string[]): string | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
for (const key of keys) {
|
||||
const value = (raw as Record<string, unknown>)[key];
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function mapModelDescriptionsFromPayload(payload: unknown): Map<string, string> {
|
||||
const rows = unwrapArrayPayload(payload);
|
||||
const descriptions = new Map<string, string>();
|
||||
|
||||
for (const row of rows) {
|
||||
const modelName = readStringField(row, ['model_name', 'id', 'name']);
|
||||
const description = readStringField(row, ['description', 'model_description', 'desc']);
|
||||
if (!modelName || !description) continue;
|
||||
descriptions.set(normalizeModelKey(modelName), description);
|
||||
}
|
||||
|
||||
return descriptions;
|
||||
}
|
||||
|
||||
function getUpstreamModelUrls(): string[] {
|
||||
const envValue = process.env.LLM_METADATA_MODELS_URLS;
|
||||
if (!envValue) return DEFAULT_UPSTREAM_MODELS_URLS;
|
||||
|
||||
const values = envValue
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return values.length > 0 ? values : DEFAULT_UPSTREAM_MODELS_URLS;
|
||||
}
|
||||
|
||||
async function fetchJson(url: string, options?: UndiciRequestInit): Promise<unknown> {
|
||||
const { fetch } = await import('undici');
|
||||
const controller = new AbortController();
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, UPSTREAM_MODELS_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
body: options?.body ?? undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`upstream metadata timeout (${Math.round(UPSTREAM_MODELS_TIMEOUT_MS / 1000)}s)`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
timeoutHandle = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUpstreamModelDescriptionsCached(): Promise<Map<string, string>> {
|
||||
if (descriptionCache && Date.now() - descriptionCache.fetchedAt < descriptionCache.ttlMs) {
|
||||
return descriptionCache.descriptions;
|
||||
}
|
||||
|
||||
let fetched: Map<string, string> = new Map();
|
||||
for (const url of getUpstreamModelUrls()) {
|
||||
try {
|
||||
const payload = await fetchJson(url);
|
||||
const mapped = mapModelDescriptionsFromPayload(payload);
|
||||
if (mapped.size > 0) {
|
||||
fetched = mapped;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
descriptionCache = {
|
||||
fetchedAt: Date.now(),
|
||||
ttlMs: fetched.size > 0 ? UPSTREAM_MODELS_CACHE_TTL_MS : UPSTREAM_MODELS_FAILURE_TTL_MS,
|
||||
descriptions: fetched,
|
||||
};
|
||||
return fetched;
|
||||
}
|
||||
Reference in New Issue
Block a user