feat: add comprehensive tests for token candidates and proxy log path metadata

- Implemented tests for the `/api/models/token-candidates` endpoint to verify model availability and token coverage scenarios.
- Added tests for `composeProxyLogMessage` to ensure proper formatting of log messages with downstream and upstream metadata.
- Created utility functions for parsing proxy log path metadata and handling missing token hints.
- Developed tests for the `Sites` and `TokenRoutes` components to validate UI behavior and interactions, including scrolling and group expansion.
- Introduced a custom hook `useAnimatedVisibility` for managing visibility animations in components.
- Enhanced the documentation link for site resources.
This commit is contained in:
cita
2026-03-04 13:46:31 +08:00
parent d49fcefa05
commit e281e45e84
71 changed files with 5435 additions and 636 deletions
+34
View File
@@ -34,6 +34,7 @@ function ensureTokenManagementSchema() {
account_id integer NOT NULL,
name text NOT NULL,
token text NOT NULL,
token_group text,
source text DEFAULT 'manual',
enabled integer DEFAULT true,
is_default integer DEFAULT false,
@@ -47,6 +48,10 @@ function ensureTokenManagementSchema() {
sqlite.exec('ALTER TABLE route_channels ADD COLUMN token_id integer;');
}
if (!tableColumnExists('account_tokens', 'token_group')) {
sqlite.exec('ALTER TABLE account_tokens ADD COLUMN token_group text;');
}
sqlite.exec(`
INSERT INTO account_tokens (account_id, name, token, source, enabled, is_default, created_at, updated_at)
SELECT
@@ -123,6 +128,33 @@ function ensureSiteProxySchema() {
}
}
function ensureSiteExternalCheckinUrlSchema() {
if (!tableExists('sites')) {
return;
}
if (!tableColumnExists('sites', 'external_checkin_url')) {
sqlite.exec(`ALTER TABLE sites ADD COLUMN external_checkin_url text;`);
}
}
function ensureSiteGlobalWeightSchema() {
if (!tableExists('sites')) {
return;
}
if (!tableColumnExists('sites', 'global_weight')) {
sqlite.exec(`ALTER TABLE sites ADD COLUMN global_weight real DEFAULT 1;`);
}
sqlite.exec(`
UPDATE sites
SET global_weight = 1
WHERE global_weight IS NULL
OR global_weight <= 0;
`);
}
function ensureRouteGroupingSchema() {
if (!tableExists('token_routes') || !tableExists('route_channels')) {
return;
@@ -184,6 +216,8 @@ function ensureDownstreamApiKeySchema() {
ensureTokenManagementSchema();
ensureSiteStatusSchema();
ensureSiteProxySchema();
ensureSiteExternalCheckinUrlSchema();
ensureSiteGlobalWeightSchema();
ensureRouteGroupingSchema();
ensureDownstreamApiKeySchema();
+3
View File
@@ -5,11 +5,13 @@ export const sites = sqliteTable('sites', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
url: text('url').notNull(),
externalCheckinUrl: text('external_checkin_url'),
platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' | 'openai' | 'claude' | 'gemini'
proxyUrl: text('proxy_url'),
status: text('status').notNull().default('active'), // 'active' | 'disabled'
isPinned: integer('is_pinned', { mode: 'boolean' }).default(false),
sortOrder: integer('sort_order').default(0),
globalWeight: real('global_weight').default(1),
apiKey: text('api_key'),
createdAt: text('created_at').default(sql`(datetime('now'))`),
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
@@ -48,6 +50,7 @@ export const accountTokens = sqliteTable('account_tokens', {
accountId: integer('account_id').notNull().references(() => accounts.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
token: text('token').notNull(),
tokenGroup: text('token_group'),
source: text('source').default('manual'), // 'manual' | 'sync' | 'legacy'
enabled: integer('enabled', { mode: 'boolean' }).default(true),
isDefault: integer('is_default', { mode: 'boolean' }).default(false),
+2 -1
View File
@@ -215,7 +215,7 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise<SyncExe
`token sync timeout (${Math.round(TOKEN_SYNC_TIMEOUT_MS / 1000)}s)`,
);
if (fallback) {
tokens = [{ name: 'default', key: fallback, enabled: true }];
tokens = [{ name: 'default', key: fallback, enabled: true, tokenGroup: 'default' }];
}
}
@@ -351,6 +351,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
accountId: body.accountId,
name: (body.name || '').trim() || (existing.length === 0 ? 'default' : `token-${existing.length + 1}`),
token: tokenValue,
tokenGroup: (body.group || '').trim() || null,
source: body.source || 'manual',
enabled: body.enabled ?? true,
isDefault: body.isDefault ?? false,
@@ -0,0 +1,217 @@
import Fastify, { type FastifyInstance } from 'fastify';
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';
import { eq } from 'drizzle-orm';
const verifyTokenMock = vi.fn();
const getModelsMock = vi.fn();
const getApiTokensMock = vi.fn();
vi.mock('../../services/platforms/index.js', () => ({
getAdapter: () => ({
verifyToken: (...args: unknown[]) => verifyTokenMock(...args),
getModels: (...args: unknown[]) => getModelsMock(...args),
getApiTokens: (...args: unknown[]) => getApiTokensMock(...args),
}),
}));
type DbModule = typeof import('../../db/index.js');
describe('accounts credential mode', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-credential-mode-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./accounts.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.accountsRoutes);
});
beforeEach(() => {
verifyTokenMock.mockReset();
getModelsMock.mockReset();
getApiTokensMock.mockReset();
getApiTokensMock.mockResolvedValue([]);
db.delete(schema.proxyLogs).run();
db.delete(schema.checkinLogs).run();
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(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('uses API-key fast verify path when credentialMode is apikey', async () => {
verifyTokenMock.mockRejectedValueOnce(new Error('verifyToken should not be called'));
getModelsMock.mockResolvedValueOnce(['gpt-5-mini', 'gpt-4o-mini']);
const site = db.insert(schema.sites).values({
name: 'Fast Verify Site',
url: 'https://fast-verify.example.com',
platform: 'new-api',
}).returning().get();
const response = await app.inject({
method: 'POST',
url: '/api/accounts/verify-token',
payload: {
siteId: site.id,
accessToken: 'sk-fast-verify',
credentialMode: 'apikey',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
tokenType: 'apikey',
modelCount: 2,
});
expect(getModelsMock).toHaveBeenCalledTimes(1);
expect(verifyTokenMock).not.toHaveBeenCalled();
});
it('adds account as proxy-only when credentialMode is apikey', async () => {
verifyTokenMock.mockRejectedValueOnce(new Error('verifyToken should not be called'));
getModelsMock.mockResolvedValueOnce(['gpt-4o-mini']);
const site = db.insert(schema.sites).values({
name: 'Proxy Only Site',
url: 'https://proxy-only.example.com',
platform: 'new-api',
}).returning().get();
const response = await app.inject({
method: 'POST',
url: '/api/accounts',
payload: {
siteId: site.id,
accessToken: 'sk-proxy-only',
credentialMode: 'apikey',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as { tokenType?: string; capabilities?: { proxyOnly?: boolean } };
expect(body.tokenType).toBe('apikey');
expect(body.capabilities?.proxyOnly).toBe(true);
const accounts = db.select().from(schema.accounts).all();
expect(accounts).toHaveLength(1);
expect(accounts[0]?.accessToken || '').toBe('');
expect((accounts[0]?.apiToken || '').startsWith('sk-')).toBe(true);
expect(accounts[0]?.checkinEnabled).toBe(false);
const parsedExtra = JSON.parse(accounts[0]?.extraConfig || '{}') as { credentialMode?: string };
expect(parsedExtra.credentialMode).toBe('apikey');
});
it('stores managed refresh token for sub2api session account', async () => {
verifyTokenMock.mockResolvedValueOnce({
tokenType: 'session',
userInfo: { username: 'sub2-user' },
});
const site = db.insert(schema.sites).values({
name: 'Sub2 Site',
url: 'https://sub2.example.com',
platform: 'sub2api',
}).returning().get();
const response = await app.inject({
method: 'POST',
url: '/api/accounts',
payload: {
siteId: site.id,
accessToken: 'jwt-access-token',
refreshToken: 'jwt-refresh-token',
tokenExpiresAt: 1760000000000,
},
});
expect(response.statusCode).toBe(200);
const created = db.select().from(schema.accounts).all()[0];
const parsedExtra = JSON.parse(created?.extraConfig || '{}') as {
credentialMode?: string;
sub2apiAuth?: {
refreshToken?: string;
tokenExpiresAt?: number;
};
};
expect(parsedExtra.credentialMode).toBe('session');
expect(parsedExtra.sub2apiAuth?.refreshToken).toBe('jwt-refresh-token');
expect(parsedExtra.sub2apiAuth?.tokenExpiresAt).toBe(1760000000000);
});
it('updates and clears managed refresh token via account update API', async () => {
const site = db.insert(schema.sites).values({
name: 'Sub2 Site',
url: 'https://sub2.example.com',
platform: 'sub2api',
}).returning().get();
const account = db.insert(schema.accounts).values({
siteId: site.id,
username: 'sub2-user',
accessToken: 'access-token',
extraConfig: JSON.stringify({
credentialMode: 'session',
sub2apiAuth: {
refreshToken: 'old-refresh-token',
tokenExpiresAt: 1750000000000,
},
}),
}).returning().get();
const updateResponse = await app.inject({
method: 'PUT',
url: `/api/accounts/${account.id}`,
payload: {
refreshToken: 'new-refresh-token',
tokenExpiresAt: 1760000000000,
},
});
expect(updateResponse.statusCode).toBe(200);
const updated = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
const parsedUpdated = JSON.parse(updated?.extraConfig || '{}') as {
sub2apiAuth?: { refreshToken?: string; tokenExpiresAt?: number };
};
expect(parsedUpdated.sub2apiAuth?.refreshToken).toBe('new-refresh-token');
expect(parsedUpdated.sub2apiAuth?.tokenExpiresAt).toBe(1760000000000);
const clearResponse = await app.inject({
method: 'PUT',
url: `/api/accounts/${account.id}`,
payload: {
refreshToken: '',
},
});
expect(clearResponse.statusCode).toBe(200);
const cleared = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
const parsedCleared = JSON.parse(cleared?.extraConfig || '{}') as {
sub2apiAuth?: { refreshToken?: string; tokenExpiresAt?: number };
};
expect(parsedCleared.sub2apiAuth).toBeUndefined();
});
});
+405 -191
View File
@@ -5,7 +5,15 @@ import { refreshBalance } from '../../services/balanceService.js';
import { getAdapter } from '../../services/platforms/index.js';
import { refreshModelsForAccount, rebuildTokenRoutesFromAvailability } from '../../services/modelService.js';
import { ensureDefaultTokenForAccount, syncTokensFromUpstream } from '../../services/accountTokenService.js';
import { guessPlatformUserIdFromUsername, mergeAccountExtraConfig, resolvePlatformUserId } from '../../services/accountExtraConfig.js';
import {
getCredentialModeFromExtraConfig,
guessPlatformUserIdFromUsername,
getSub2ApiAuthFromExtraConfig,
mergeAccountExtraConfig,
normalizeCredentialMode as normalizeCredentialModeInput,
resolvePlatformUserId,
type AccountCredentialMode,
} from '../../services/accountExtraConfig.js';
import { encryptAccountPassword } from '../../services/accountCredentialService.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import { parseCheckinRewardAmount } from '../../services/checkinRewardParser.js';
@@ -28,11 +36,50 @@ type AccountHealthRefreshResult = {
accountId: number;
username: string | null;
siteName: string;
status: 'success' | 'failed' | 'skipped';
state: RuntimeHealthState;
status: 'success' | 'failed' | 'skipped';
state: RuntimeHealthState;
message: string;
};
type AccountCapabilities = {
canCheckin: boolean;
canRefreshBalance: boolean;
proxyOnly: boolean;
};
function hasSessionTokenValue(value: string | null | undefined): boolean {
return typeof value === 'string' && value.trim().length > 0;
}
function resolveRequestedCredentialMode(input: unknown): AccountCredentialMode {
return normalizeCredentialModeInput(input) || 'auto';
}
function resolveStoredCredentialMode(account: typeof schema.accounts.$inferSelect): AccountCredentialMode {
const fromConfig = getCredentialModeFromExtraConfig(account.extraConfig);
if (fromConfig && fromConfig !== 'auto') return fromConfig;
return hasSessionTokenValue(account.accessToken) ? 'session' : 'apikey';
}
function buildCapabilitiesFromCredentialMode(
credentialMode: AccountCredentialMode,
hasSessionToken: boolean,
): AccountCapabilities {
const sessionCapable = credentialMode === 'session'
? hasSessionToken
: (credentialMode === 'apikey' ? false : hasSessionToken);
return {
canCheckin: sessionCapable,
canRefreshBalance: sessionCapable,
proxyOnly: !sessionCapable,
};
}
function buildCapabilitiesForAccount(account: typeof schema.accounts.$inferSelect): AccountCapabilities {
const credentialMode = resolveStoredCredentialMode(account);
return buildCapabilitiesFromCredentialMode(credentialMode, hasSessionTokenValue(account.accessToken));
}
function normalizePinnedFlag(input: unknown): boolean | null {
if (input === undefined || input === null) return null;
if (typeof input === 'boolean') return input;
@@ -52,6 +99,21 @@ function normalizeSortOrder(input: unknown): number | null {
return Math.max(0, parsed);
}
function normalizeManagedRefreshToken(input: unknown): string | undefined {
if (typeof input !== 'string') return undefined;
const trimmed = input.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function normalizeManagedTokenExpiresAt(input: unknown): number | undefined {
if (typeof input === 'number' && Number.isFinite(input) && input > 0) return Math.trunc(input);
if (typeof input === 'string') {
const parsed = Number.parseInt(input.trim(), 10);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return undefined;
}
function getNextAccountSortOrder(): number {
const rows = db.select({ sortOrder: schema.accounts.sortOrder }).from(schema.accounts).all();
const max = rows.reduce((currentMax, row) => Math.max(currentMax, row.sortOrder || 0), -1);
@@ -195,24 +257,32 @@ export async function accountsRoutes(app: FastifyInstance) {
parsedRewardCountByAccount[log.accountId] = (parsedRewardCountByAccount[log.accountId] || 0) + 1;
}
return rows.map((r) => ({
...r.accounts,
site: r.sites,
todaySpend: Math.round((spendByAccount[r.accounts.id] || 0) * 1_000_000) / 1_000_000,
todayReward: Math.round(estimateRewardWithTodayIncomeFallback({
day: localDay,
successCount: successCountByAccount[r.accounts.id] || 0,
parsedRewardCount: parsedRewardCountByAccount[r.accounts.id] || 0,
rewardSum: rewardByAccount[r.accounts.id] || 0,
extraConfig: r.accounts.extraConfig,
}) * 1_000_000) / 1_000_000,
runtimeHealth: buildRuntimeHealthForAccount({
accountStatus: r.accounts.status,
siteStatus: r.sites.status,
extraConfig: r.accounts.extraConfig,
}),
}));
});
return rows.map((r) => {
const credentialMode = resolveStoredCredentialMode(r.accounts);
return {
...r.accounts,
site: r.sites,
credentialMode,
capabilities: buildCapabilitiesFromCredentialMode(
credentialMode,
hasSessionTokenValue(r.accounts.accessToken),
),
todaySpend: Math.round((spendByAccount[r.accounts.id] || 0) * 1_000_000) / 1_000_000,
todayReward: Math.round(estimateRewardWithTodayIncomeFallback({
day: localDay,
successCount: successCountByAccount[r.accounts.id] || 0,
parsedRewardCount: parsedRewardCountByAccount[r.accounts.id] || 0,
rewardSum: rewardByAccount[r.accounts.id] || 0,
extraConfig: r.accounts.extraConfig,
}) * 1_000_000) / 1_000_000,
runtimeHealth: buildRuntimeHealthForAccount({
accountStatus: r.accounts.status,
siteStatus: r.sites.status,
extraConfig: r.accounts.extraConfig,
}),
};
});
});
// Login to a site and auto-create account
app.post<{ Body: { siteId: number; username: string; password: string } }>('/api/accounts/login', async (request) => {
@@ -249,12 +319,13 @@ export async function accountsRoutes(app: FastifyInstance) {
.where(and(eq(schema.accounts.siteId, siteId), eq(schema.accounts.username, username)))
.get();
const extraConfigPatch: Record<string, unknown> = {
autoRelogin: {
username,
passwordCipher: encryptAccountPassword(password),
updatedAt: new Date().toISOString(),
},
const extraConfigPatch: Record<string, unknown> = {
credentialMode: 'session',
autoRelogin: {
username,
passwordCipher: encryptAccountPassword(password),
updatedAt: new Date().toISOString(),
},
};
if (guessedPlatformUserId) {
extraConfigPatch.platformUserId = guessedPlatformUserId;
@@ -318,15 +389,45 @@ export async function accountsRoutes(app: FastifyInstance) {
};
});
// Verify a token against a site - auto-detects token type (session vs API key)
app.post<{ Body: { siteId: number; accessToken: string; platformUserId?: number } }>('/api/accounts/verify-token', async (request) => {
const { siteId, accessToken, platformUserId } = request.body;
const site = db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get();
if (!site) return { success: false, message: 'site not found' };
const adapter = getAdapter(site.platform);
if (!adapter) return { success: false, message: `婵炴垶鎸哥粔鐢稿极椤曗偓楠炴劖鎷呴悜妯兼殸濡ょ姷鍋涢崯鑳亹? ${site.platform}` };
// Verify credentials against a site.
app.post<{ Body: { siteId: number; accessToken: string; platformUserId?: number; credentialMode?: AccountCredentialMode } }>('/api/accounts/verify-token', async (request) => {
const { siteId, platformUserId } = request.body;
const accessToken = (request.body.accessToken || '').trim();
const credentialMode = resolveRequestedCredentialMode(request.body.credentialMode);
const site = db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get();
if (!site) return { success: false, message: 'site not found' };
if (!accessToken) {
return { success: false, message: 'Token 不能为空' };
}
const adapter = getAdapter(site.platform);
if (!adapter) return { success: false, message: `婵炴垶鎸哥粔鐢稿极椤曗偓楠炴劖鎷呴悜妯兼殸濡ょ姷鍋涢崯鑳亹? ${site.platform}` };
if (credentialMode === 'apikey') {
try {
const models = await adapter.getModels(site.url, accessToken, platformUserId);
const availableModels = Array.isArray(models) ? models.filter((item) => typeof item === 'string' && item.trim().length > 0) : [];
if (availableModels.length === 0) {
return {
success: false,
message: 'API Key 验证失败:未获取到可用模型',
};
}
return {
success: true,
tokenType: 'apikey',
modelCount: availableModels.length,
models: availableModels.slice(0, 10),
};
} catch (err: any) {
return {
success: false,
message: err?.message || 'API Key 验证失败',
};
}
}
let result: any;
try {
result = await adapter.verifyToken(site.url, accessToken, platformUserId);
@@ -336,103 +437,110 @@ export async function accountsRoutes(app: FastifyInstance) {
message: appendSessionTokenRebindHint(err?.message || 'Token 验证失败'),
};
}
if (result.tokenType === 'session') {
return {
success: true,
tokenType: 'session',
userInfo: result.userInfo,
balance: result.balance,
apiToken: result.apiToken,
};
}
if (result.tokenType === 'apikey') {
return {
success: true,
tokenType: 'apikey',
modelCount: result.models?.length || 0,
models: result.models?.slice(0, 10),
};
}
// Try to explain unknown failures: missing user id vs anti-bot challenge page.
type VerifyFailureReason = 'needs-user-id' | 'shield-blocked' | null;
const detectVerifyFailureReason = async (): Promise<VerifyFailureReason> => {
const parseFailureReason = (bodyText: string, contentType: string): VerifyFailureReason => {
const text = bodyText || '';
const ct = (contentType || '').toLowerCase();
if (ct.includes('text/html') && /var\s+arg1\s*=|acw_sc__v2|cdn_sec_tc|<script/i.test(text)) {
return 'shield-blocked';
}
try {
const body = JSON.parse(text) as any;
const message = typeof body?.message === 'string' ? body.message : '';
if (/mismatch|new-api-user|user id/i.test(message)) return 'needs-user-id';
if (/shield|challenge|captcha|acw_sc__v2|arg1/i.test(message)) return 'shield-blocked';
} catch { }
return null;
};
try {
const { fetch } = await import('undici');
const candidates = new Set<string>();
const trimmed = (accessToken || '').trim();
const raw = trimmed.startsWith('Bearer ') ? trimmed.slice(7).trim() : trimmed;
if (raw) {
if (raw.includes('=')) candidates.add(raw);
candidates.add(`session=${raw}`);
candidates.add(`token=${raw}`);
}
const headerVariants: Record<string, string>[] = [
{ Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'New-Api-User': '0' },
];
for (const cookie of candidates) {
headerVariants.push({
Cookie: cookie,
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
});
}
for (const headers of headerVariants) {
try {
if (result.tokenType === 'session') {
return {
success: true,
tokenType: 'session',
userInfo: result.userInfo,
balance: result.balance,
apiToken: result.apiToken,
};
}
if (result.tokenType === 'apikey') {
if (credentialMode === 'session') {
return {
success: false,
message: '当前凭证是 API Key,请切换到 API Key 模式,或改用 Session Token',
};
}
return {
success: true,
tokenType: 'apikey',
modelCount: result.models?.length || 0,
models: result.models?.slice(0, 10),
};
}
// Try to explain unknown failures: missing user id vs anti-bot challenge page.
type VerifyFailureReason = 'needs-user-id' | 'shield-blocked' | null;
const detectVerifyFailureReason = async (): Promise<VerifyFailureReason> => {
const parseFailureReason = (bodyText: string, contentType: string): VerifyFailureReason => {
const text = bodyText || '';
const ct = (contentType || '').toLowerCase();
if (ct.includes('text/html') && /var\s+arg1\s*=|acw_sc__v2|cdn_sec_tc|<script/i.test(text)) {
return 'shield-blocked';
}
try {
const body = JSON.parse(text) as any;
const message = typeof body?.message === 'string' ? body.message : '';
if (/mismatch|new-api-user|user id/i.test(message)) return 'needs-user-id';
if (/shield|challenge|captcha|acw_sc__v2|arg1/i.test(message)) return 'shield-blocked';
} catch { }
return null;
};
try {
const { fetch } = await import('undici');
const candidates = new Set<string>();
const raw = accessToken.startsWith('Bearer ') ? accessToken.slice(7).trim() : accessToken;
if (raw) {
if (raw.includes('=')) candidates.add(raw);
candidates.add(`session=${raw}`);
candidates.add(`token=${raw}`);
}
const headerVariants: Record<string, string>[] = [
{ Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'New-Api-User': '0' },
];
for (const cookie of candidates) {
headerVariants.push({
Cookie: cookie,
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
});
}
for (const headers of headerVariants) {
try {
const testRes = await fetch(`${site.url}/api/user/self`, withExplicitProxyRequestInit(site.proxyUrl, { headers }));
const bodyText = await testRes.text();
const contentType = testRes.headers.get('content-type') || '';
const reason = parseFailureReason(bodyText, contentType);
if (reason) return reason;
} catch { }
}
} catch { }
return null;
};
const failureReason = await detectVerifyFailureReason();
if (failureReason === 'needs-user-id') {
return {
success: false,
needsUserId: true,
message: 'This site requires a user ID. Please fill in your site user ID.',
};
}
if (failureReason === 'shield-blocked') {
return {
success: false,
shieldBlocked: true,
message: 'This site is shielded by anti-bot challenge. Create an API key on the target site and import that key.',
};
}
const bodyText = await testRes.text();
const contentType = testRes.headers.get('content-type') || '';
const reason = parseFailureReason(bodyText, contentType);
if (reason) return reason;
} catch { }
}
} catch { }
return null;
};
const failureReason = await detectVerifyFailureReason();
if (failureReason === 'needs-user-id') {
return {
success: false,
needsUserId: true,
message: 'This site requires a user ID. Please fill in your site user ID.',
};
}
if (failureReason === 'shield-blocked') {
return {
success: false,
shieldBlocked: true,
message: 'This site is shielded by anti-bot challenge. Create an API key on the target site and import that key.',
};
}
return {
success: false,
message: 'Token invalid: cannot use it as session cookie or API key',
message: credentialMode === 'session'
? 'Session Token 验证失败'
: 'Token invalid: cannot use it as session cookie or API key',
};
});
@@ -510,9 +618,11 @@ export async function accountsRoutes(app: FastifyInstance) {
if (nextApiToken) {
updates.apiToken = nextApiToken;
}
const extraConfigPatch: Record<string, unknown> = { credentialMode: 'session' };
if (resolvedPlatformUserId) {
updates.extraConfig = mergeAccountExtraConfig(account.extraConfig, { platformUserId: resolvedPlatformUserId });
extraConfigPatch.platformUserId = resolvedPlatformUserId;
}
updates.extraConfig = mergeAccountExtraConfig(account.extraConfig, extraConfigPatch);
db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, accountId)).run();
@@ -534,13 +644,16 @@ export async function accountsRoutes(app: FastifyInstance) {
return {
success: true,
account: latest,
tokenType: 'session',
credentialMode: 'session',
capabilities: latest ? buildCapabilitiesForAccount(latest) : buildCapabilitiesFromCredentialMode('session', true),
apiTokenFound: !!nextApiToken,
};
},
);
// Add an account (manual token input) - auto-detects token type and fetches info
app.post<{ Body: { siteId: number; username?: string; accessToken: string; apiToken?: string; platformUserId?: number; checkinEnabled?: boolean } }>('/api/accounts', async (request, reply) => {
// Add an account (manual credential input)
app.post<{ Body: { siteId: number; username?: string; accessToken: string; apiToken?: string; platformUserId?: number; checkinEnabled?: boolean; credentialMode?: AccountCredentialMode; refreshToken?: string; tokenExpiresAt?: number | string } }>('/api/accounts', async (request, reply) => {
const body = request.body;
const site = db.select().from(schema.sites).where(eq(schema.sites.id, body.siteId)).get();
if (!site) {
@@ -552,46 +665,100 @@ export async function accountsRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: `platform not supported: ${site.platform}` });
}
let username = body.username;
let accessToken = body.accessToken;
let apiToken = body.apiToken;
let verifyResult: any;
try {
verifyResult = await adapter.verifyToken(site.url, body.accessToken, body.platformUserId);
} catch (err: any) {
return reply.code(400).send({
success: false,
message: appendSessionTokenRebindHint(err?.message || 'Token 验证失败'),
});
}
const tokenType = verifyResult.tokenType;
if (tokenType === 'unknown') {
return reply.code(400).send({
success: false,
requiresVerification: true,
message: 'Token 验证失败,请先点击“验证 Token”,验证成功后再绑定账号',
});
const credentialMode = resolveRequestedCredentialMode(body.credentialMode);
const rawAccessToken = (body.accessToken || '').trim();
if (!rawAccessToken) {
return reply.code(400).send({ success: false, message: '请填写 Token' });
}
if (tokenType === 'session') {
// Token is a session cookie - can do management ops
if (!username && verifyResult.userInfo?.username) username = verifyResult.userInfo.username;
if (!apiToken && verifyResult.apiToken) apiToken = verifyResult.apiToken;
} else if (tokenType === 'apikey') {
// Token is an API key - store as apiToken, not accessToken
apiToken = body.accessToken;
accessToken = ''; // no session cookie available
let username = (body.username || '').trim();
let accessToken = rawAccessToken;
let apiToken = (body.apiToken || '').trim();
let tokenType: 'session' | 'apikey' | 'unknown' = 'unknown';
let verifiedModels: string[] = [];
if (credentialMode === 'apikey') {
try {
const models = await adapter.getModels(site.url, rawAccessToken, body.platformUserId);
verifiedModels = Array.isArray(models)
? models.filter((item) => typeof item === 'string' && item.trim().length > 0)
: [];
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || 'API Key 验证失败',
});
}
if (verifiedModels.length === 0) {
return reply.code(400).send({
success: false,
requiresVerification: true,
message: 'API Key 验证失败:未获取到可用模型',
});
}
tokenType = 'apikey';
accessToken = '';
if (!apiToken) apiToken = rawAccessToken;
} else {
let verifyResult: any;
try {
verifyResult = await adapter.verifyToken(site.url, rawAccessToken, body.platformUserId);
} catch (err: any) {
return reply.code(400).send({
success: false,
message: appendSessionTokenRebindHint(err?.message || 'Token 验证失败'),
});
}
tokenType = verifyResult.tokenType;
if (tokenType === 'unknown') {
return reply.code(400).send({
success: false,
requiresVerification: true,
message: 'Token 验证失败,请先点击“验证 Token”,验证成功后再绑定账号',
});
}
if (credentialMode === 'session' && tokenType !== 'session') {
return reply.code(400).send({
success: false,
message: '当前凭证是 API Key,请切换到 API Key 模式,或改用 Session Token',
});
}
if (tokenType === 'session') {
if (!username && verifyResult.userInfo?.username) username = String(verifyResult.userInfo.username).trim();
if (!apiToken && verifyResult.apiToken) apiToken = String(verifyResult.apiToken).trim();
} else if (tokenType === 'apikey') {
accessToken = '';
if (!apiToken) apiToken = rawAccessToken;
verifiedModels = Array.isArray(verifyResult.models)
? verifyResult.models.filter((item: unknown) => typeof item === 'string' && item.trim().length > 0)
: [];
}
}
// Store platformUserId in extraConfig for NewAPI sites that need it
const resolvedPlatformUserId =
body.platformUserId || guessPlatformUserIdFromUsername(username) || undefined;
let extraConfig: string | undefined;
if (resolvedPlatformUserId) {
extraConfig = mergeAccountExtraConfig(undefined, { platformUserId: resolvedPlatformUserId });
}
// Store platformUserId and credential mode in extraConfig.
const resolvedPlatformUserId =
body.platformUserId || guessPlatformUserIdFromUsername(username) || undefined;
const resolvedCredentialMode: AccountCredentialMode = tokenType === 'apikey' ? 'apikey' : 'session';
const extraConfigPatch: Record<string, unknown> = { credentialMode: resolvedCredentialMode };
if (resolvedPlatformUserId) {
extraConfigPatch.platformUserId = resolvedPlatformUserId;
}
if ((site.platform || '').toLowerCase() === 'sub2api') {
const managedRefreshToken = normalizeManagedRefreshToken(body.refreshToken);
const managedTokenExpiresAt = normalizeManagedTokenExpiresAt(body.tokenExpiresAt);
if (managedRefreshToken) {
extraConfigPatch.sub2apiAuth = managedTokenExpiresAt
? { refreshToken: managedRefreshToken, tokenExpiresAt: managedTokenExpiresAt }
: { refreshToken: managedRefreshToken };
}
}
const extraConfig = mergeAccountExtraConfig(undefined, extraConfigPatch);
const result = db.insert(schema.accounts).values({
siteId: body.siteId,
username: username || undefined,
@@ -602,13 +769,13 @@ export async function accountsRoutes(app: FastifyInstance) {
isPinned: false,
sortOrder: getNextAccountSortOrder(),
}).returning().get();
if (apiToken) {
try {
ensureDefaultTokenForAccount(result.id, apiToken, { name: 'default', source: 'manual' });
} catch { }
}
if (apiToken) {
try {
ensureDefaultTokenForAccount(result.id, apiToken, { name: 'default', source: 'manual' });
} catch { }
}
if (tokenType === 'session' && accessToken) {
try {
const syncedTokens = await adapter.getApiTokens(site.url, accessToken, resolvedPlatformUserId);
@@ -617,29 +784,76 @@ export async function accountsRoutes(app: FastifyInstance) {
}
} catch { }
}
// Try to refresh balance
if (tokenType === 'session') {
try { await refreshBalance(result.id); } catch { }
}
try {
await refreshModelsForAccount(result.id);
rebuildTokenRoutesFromAvailability();
} catch { }
const account = db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get();
const finalCredentialMode = account ? resolveStoredCredentialMode(account) : resolvedCredentialMode;
const capabilities = account
? buildCapabilitiesForAccount(account)
: buildCapabilitiesFromCredentialMode(finalCredentialMode, tokenType === 'session');
return {
...account,
tokenType,
credentialMode: finalCredentialMode,
capabilities,
modelCount: verifiedModels.length,
apiTokenFound: !!apiToken,
usernameDetected: !!(!body.username && username),
};
});
// Try to refresh balance
if (tokenType === 'session') {
try { await refreshBalance(result.id); } catch { }
}
try {
await refreshModelsForAccount(result.id);
rebuildTokenRoutesFromAvailability();
} catch { }
const account = db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get();
return { ...account, tokenType, apiTokenFound: !!apiToken, usernameDetected: !!(!body.username && username) };
});
// Update an account
// Update an account
app.put<{ Params: { id: string }; Body: any }>('/api/accounts/:id', async (request, reply) => {
const id = parseInt(request.params.id);
const body = request.body as Record<string, unknown>;
const row = db.select()
.from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.accounts.id, id))
.get();
if (!row) {
return reply.code(404).send({ message: 'account not found' });
}
const account = row.accounts;
const site = row.sites;
const updates: any = {};
for (const key of ['username', 'accessToken', 'apiToken', 'status', 'checkinEnabled', 'unitCost', 'extraConfig']) {
if (body[key] !== undefined) updates[key] = body[key];
}
const wantsManagedSub2ApiAuthPatch =
Object.prototype.hasOwnProperty.call(body, 'refreshToken')
|| Object.prototype.hasOwnProperty.call(body, 'tokenExpiresAt');
if (wantsManagedSub2ApiAuthPatch && (site.platform || '').toLowerCase() === 'sub2api') {
const baseExtraConfig = typeof updates.extraConfig === 'string'
? updates.extraConfig
: account.extraConfig;
const existingManagedAuth = getSub2ApiAuthFromExtraConfig(baseExtraConfig);
const nextRefreshToken = Object.prototype.hasOwnProperty.call(body, 'refreshToken')
? normalizeManagedRefreshToken(body.refreshToken)
: existingManagedAuth?.refreshToken;
const nextTokenExpiresAt = Object.prototype.hasOwnProperty.call(body, 'tokenExpiresAt')
? normalizeManagedTokenExpiresAt(body.tokenExpiresAt)
: existingManagedAuth?.tokenExpiresAt;
updates.extraConfig = mergeAccountExtraConfig(baseExtraConfig, {
sub2apiAuth: nextRefreshToken
? (nextTokenExpiresAt
? { refreshToken: nextRefreshToken, tokenExpiresAt: nextTokenExpiresAt }
: { refreshToken: nextRefreshToken })
: undefined,
});
}
if (body.isPinned !== undefined) {
const normalizedPinned = normalizePinnedFlag(body.isPinned);
if (normalizedPinned === null) {
+42 -2
View File
@@ -36,7 +36,7 @@ describe('sites proxy url settings', () => {
delete process.env.DATA_DIR;
});
it('stores proxy url when creating a site', async () => {
it('stores proxy url and external checkin url when creating a site', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/sites',
@@ -45,12 +45,20 @@ describe('sites proxy url settings', () => {
url: 'https://proxy-site.example.com',
platform: 'new-api',
proxyUrl: 'http://127.0.0.1:7890',
externalCheckinUrl: 'https://checkin.example.com/welfare',
globalWeight: 1.5,
},
});
expect(response.statusCode).toBe(200);
const payload = response.json() as { proxyUrl?: string | null };
const payload = response.json() as {
proxyUrl?: string | null;
externalCheckinUrl?: string | null;
globalWeight?: number;
};
expect(payload.proxyUrl).toBe('http://127.0.0.1:7890');
expect(payload.externalCheckinUrl).toBe('https://checkin.example.com/welfare');
expect(payload.globalWeight).toBe(1.5);
});
it('rejects invalid proxy url', async () => {
@@ -68,4 +76,36 @@ describe('sites proxy url settings', () => {
expect(response.statusCode).toBe(400);
expect((response.json() as { error?: string }).error).toContain('Invalid proxyUrl');
});
it('rejects invalid site global weight', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/sites',
payload: {
name: 'weight-site',
url: 'https://weight-site.example.com',
platform: 'new-api',
globalWeight: 0,
},
});
expect(response.statusCode).toBe(400);
expect((response.json() as { error?: string }).error).toContain('Invalid globalWeight');
});
it('rejects invalid external checkin url', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/sites',
payload: {
name: 'welfare-site',
url: 'https://weight-site.example.com',
platform: 'new-api',
externalCheckinUrl: 'ftp://invalid.example.com',
},
});
expect(response.statusCode).toBe(400);
expect((response.json() as { error?: string }).error).toContain('Invalid externalCheckinUrl');
});
});
+82 -3
View File
@@ -31,6 +31,43 @@ function normalizeSortOrder(input: unknown): number | null {
return Math.max(0, parsed);
}
function normalizeGlobalWeight(input: unknown): number | null {
if (input === undefined || input === null || input === '') return null;
const parsed = Number(input);
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return Math.max(0.01, Math.min(100, Number(parsed.toFixed(3))));
}
function normalizeOptionalExternalCheckinUrl(input: unknown): {
valid: boolean;
present: boolean;
url: string | null;
} {
if (input === undefined) {
return { valid: true, present: false, url: null };
}
if (input === null) {
return { valid: true, present: true, url: null };
}
if (typeof input !== 'string') {
return { valid: false, present: true, url: null };
}
const trimmed = input.trim();
if (!trimmed) {
return { valid: true, present: true, url: null };
}
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
return { valid: false, present: true, url: null };
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { valid: false, present: true, url: null };
}
return { valid: true, present: true, url: parsed.toString().replace(/\/+$/, '') };
}
export async function sitesRoutes(app: FastifyInstance) {
// List all sites
app.get('/api/sites', async () => {
@@ -54,8 +91,19 @@ export async function sitesRoutes(app: FastifyInstance) {
});
// Add a site
app.post<{ Body: { name: string; url: string; platform?: string; apiKey?: string; proxyUrl?: string | null; status?: string; isPinned?: boolean; sortOrder?: number } }>('/api/sites', async (request, reply) => {
const { name, url, platform, apiKey, proxyUrl, status, isPinned, sortOrder } = request.body;
app.post<{ Body: {
name: string;
url: string;
platform?: string;
apiKey?: string;
proxyUrl?: string | null;
externalCheckinUrl?: string | null;
status?: string;
isPinned?: boolean;
sortOrder?: number;
globalWeight?: number;
} }>('/api/sites', async (request, reply) => {
const { name, url, platform, apiKey, proxyUrl, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body;
const normalizedStatus = normalizeSiteStatus(status);
if (status !== undefined && !normalizedStatus) {
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
@@ -64,6 +112,10 @@ export async function sitesRoutes(app: FastifyInstance) {
if (!parsedProxyUrl.valid) {
return reply.code(400).send({ error: 'Invalid proxyUrl. Expected a valid http(s)/socks proxy URL.' });
}
const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(externalCheckinUrl);
if (!normalizedExternalCheckinUrl.valid) {
return reply.code(400).send({ error: 'Invalid externalCheckinUrl. Expected a valid http(s) URL.' });
}
const normalizedPinned = normalizePinnedFlag(isPinned);
if (isPinned !== undefined && normalizedPinned === null) {
return reply.code(400).send({ error: 'Invalid isPinned value. Expected boolean.' });
@@ -72,6 +124,10 @@ export async function sitesRoutes(app: FastifyInstance) {
if (sortOrder !== undefined && normalizedSortOrder === null) {
return reply.code(400).send({ error: 'Invalid sortOrder value. Expected non-negative integer.' });
}
const normalizedGlobalWeight = normalizeGlobalWeight(globalWeight);
if (globalWeight !== undefined && normalizedGlobalWeight === null) {
return reply.code(400).send({ error: 'Invalid globalWeight value. Expected a positive number.' });
}
const existingSites = db.select().from(schema.sites).all();
const maxSortOrder = existingSites.reduce((max, site) => Math.max(max, site.sortOrder || 0), -1);
@@ -90,16 +146,29 @@ export async function sitesRoutes(app: FastifyInstance) {
platform: detectedPlatform,
apiKey,
proxyUrl: parsedProxyUrl.proxyUrl,
externalCheckinUrl: normalizedExternalCheckinUrl.url,
status: normalizedStatus ?? 'active',
isPinned: normalizedPinned ?? false,
sortOrder: normalizedSortOrder ?? (maxSortOrder + 1),
globalWeight: normalizedGlobalWeight ?? 1,
}).returning().get();
invalidateSiteProxyCache();
return result;
});
// Update a site
app.put<{ Params: { id: string }; Body: { name?: string; url?: string; platform?: string; apiKey?: string; proxyUrl?: string | null; status?: string; isPinned?: boolean; sortOrder?: number } }>('/api/sites/:id', async (request, reply) => {
app.put<{ Params: { id: string }; Body: {
name?: string;
url?: string;
platform?: string;
apiKey?: string;
proxyUrl?: string | null;
externalCheckinUrl?: string | null;
status?: string;
isPinned?: boolean;
sortOrder?: number;
globalWeight?: number;
} }>('/api/sites/:id', async (request, reply) => {
const id = parseInt(request.params.id);
if (Number.isNaN(id)) {
return reply.code(400).send({ error: 'Invalid site id' });
@@ -120,6 +189,10 @@ export async function sitesRoutes(app: FastifyInstance) {
if (!parsedProxyUrl.valid) {
return reply.code(400).send({ error: 'Invalid proxyUrl. Expected a valid http(s)/socks proxy URL.' });
}
const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(body.externalCheckinUrl);
if (!normalizedExternalCheckinUrl.valid) {
return reply.code(400).send({ error: 'Invalid externalCheckinUrl. Expected a valid http(s) URL.' });
}
const normalizedPinned = normalizePinnedFlag(body.isPinned);
if (body.isPinned !== undefined && normalizedPinned === null) {
return reply.code(400).send({ error: 'Invalid isPinned value. Expected boolean.' });
@@ -128,15 +201,21 @@ export async function sitesRoutes(app: FastifyInstance) {
if (body.sortOrder !== undefined && normalizedSortOrder === null) {
return reply.code(400).send({ error: 'Invalid sortOrder value. Expected non-negative integer.' });
}
const normalizedGlobalWeight = normalizeGlobalWeight(body.globalWeight);
if (body.globalWeight !== undefined && normalizedGlobalWeight === null) {
return reply.code(400).send({ error: 'Invalid globalWeight value. Expected a positive number.' });
}
if (body.name !== undefined) updates.name = body.name;
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
if (body.platform !== undefined) updates.platform = body.platform;
if (body.apiKey !== undefined) updates.apiKey = body.apiKey;
if (parsedProxyUrl.present) updates.proxyUrl = parsedProxyUrl.proxyUrl;
if (normalizedExternalCheckinUrl.present) updates.externalCheckinUrl = normalizedExternalCheckinUrl.url;
if (body.status !== undefined) updates.status = normalizedStatus;
if (body.isPinned !== undefined) updates.isPinned = normalizedPinned;
if (body.sortOrder !== undefined) updates.sortOrder = normalizedSortOrder;
if (body.globalWeight !== undefined) updates.globalWeight = normalizedGlobalWeight;
updates.updatedAt = new Date().toISOString();
db.update(schema.sites).set(updates).where(eq(schema.sites.id, id)).run();
invalidateSiteProxyCache();
@@ -0,0 +1,115 @@
import Fastify, { type FastifyInstance } from 'fastify';
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';
import { and, eq } from 'drizzle-orm';
type DbModule = typeof import('../../db/index.js');
describe('/api/models/marketplace', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-stats-marketplace-'));
process.env.DATA_DIR = dataDir;
vi.resetModules();
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./stats.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.statsRoutes);
});
beforeEach(() => {
db.delete(schema.proxyLogs).run();
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.checkinLogs).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('returns account-level discovered models even when account has no managed tokens', async () => {
const site = db.insert(schema.sites).values({
name: 'site-no-token',
url: 'https://site-no-token.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = db.insert(schema.accounts).values({
siteId: site.id,
username: 'alice',
accessToken: 'session-token',
status: 'active',
balance: 12.5,
}).returning().get();
db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'claude-sonnet-4-5-20250929',
available: true,
latencyMs: 233,
}).run();
const visibleRows = db.select().from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
and(
eq(schema.modelAvailability.available, true),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.all();
expect(visibleRows).toHaveLength(1);
const response = await app.inject({
method: 'GET',
url: '/api/models/marketplace',
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
models: Array<{
name: string;
accountCount: number;
tokenCount: number;
accounts: Array<{
id: number;
site: string;
username: string | null;
tokens: Array<{ id: number; name: string; isDefault: boolean }>;
}>;
}>;
};
const model = body.models.find((item) => item.name === 'claude-sonnet-4-5-20250929');
expect(model).toBeDefined();
expect(model?.accountCount).toBe(1);
expect(model?.tokenCount).toBe(0);
expect(model?.accounts).toHaveLength(1);
expect(model?.accounts[0]).toMatchObject({
id: account.id,
site: 'site-no-token',
username: 'alice',
tokens: [],
});
});
});
@@ -0,0 +1,366 @@
import Fastify, { type FastifyInstance } from 'fastify';
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';
const fetchModelPricingCatalogMock = vi.fn();
vi.mock('../../services/modelPricingService.js', async () => {
const actual = await vi.importActual<typeof import('../../services/modelPricingService.js')>('../../services/modelPricingService.js');
return {
...actual,
fetchModelPricingCatalog: (...args: unknown[]) => fetchModelPricingCatalogMock(...args),
};
});
type DbModule = typeof import('../../db/index.js');
describe('/api/models/token-candidates', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-stats-token-candidates-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./stats.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.statsRoutes);
});
beforeEach(() => {
fetchModelPricingCatalogMock.mockReset();
fetchModelPricingCatalogMock.mockResolvedValue(null);
db.delete(schema.proxyLogs).run();
db.delete(schema.checkinLogs).run();
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(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('returns modelsWithoutToken for models available in account but not covered by enabled tokens', async () => {
const site = db.insert(schema.sites).values({
name: 'site-a',
url: 'https://site-a.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = db.insert(schema.accounts).values({
siteId: site.id,
username: 'alice',
accessToken: 'acc-token',
status: 'active',
}).returning().get();
const token = db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'default',
token: 'tk-default',
enabled: true,
isDefault: true,
}).returning().get();
db.insert(schema.modelAvailability).values([
{
accountId: account.id,
modelName: 'claude-haiku-4-5-20251001',
available: true,
},
{
accountId: account.id,
modelName: 'claude-opus-4-6',
available: true,
},
]).run();
db.insert(schema.tokenModelAvailability).values({
tokenId: token.id,
modelName: 'claude-haiku-4-5-20251001',
available: true,
}).run();
const response = await app.inject({
method: 'GET',
url: '/api/models/token-candidates',
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
models: Record<string, Array<{ tokenId: number }>>;
modelsWithoutToken: Record<string, Array<{ accountId: number; username: string | null; siteId: number; siteName: string }>>;
};
expect(body.models['claude-haiku-4-5-20251001']?.map((item) => item.tokenId)).toEqual([token.id]);
expect(body.modelsWithoutToken['claude-opus-4-6']).toEqual([
{
accountId: account.id,
username: 'alice',
siteId: site.id,
siteName: 'site-a',
},
]);
expect(body.modelsWithoutToken['claude-haiku-4-5-20251001']).toBeUndefined();
});
it('returns modelsMissingTokenGroups when account has partial group token coverage', async () => {
const site = db.insert(schema.sites).values({
name: 'site-b',
url: 'https://site-b.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = db.insert(schema.accounts).values({
siteId: site.id,
username: 'bob',
accessToken: 'acc-token-b',
status: 'active',
}).returning().get();
const defaultToken = db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'default-token',
token: 'sk-default',
tokenGroup: 'default',
enabled: true,
isDefault: true,
}).returning().get();
db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'claude-opus-4-6',
available: true,
}).run();
db.insert(schema.tokenModelAvailability).values({
tokenId: defaultToken.id,
modelName: 'claude-opus-4-6',
available: true,
}).run();
fetchModelPricingCatalogMock.mockResolvedValue({
models: [
{
modelName: 'claude-opus-4-6',
quotaType: 0,
modelDescription: null,
tags: [],
supportedEndpointTypes: [],
ownerBy: null,
enableGroups: ['default', 'opus'],
groupPricing: {},
},
],
groupRatio: { default: 1, opus: 2 },
});
const response = await app.inject({
method: 'GET',
url: '/api/models/token-candidates',
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
modelsWithoutToken: Record<string, unknown>;
modelsMissingTokenGroups: Record<string, Array<{
accountId: number;
missingGroups: string[];
requiredGroups: string[];
availableGroups: string[];
}>>;
};
expect(body.modelsWithoutToken['claude-opus-4-6']).toBeUndefined();
expect(body.modelsMissingTokenGroups['claude-opus-4-6']).toEqual([
{
accountId: account.id,
username: 'bob',
siteId: site.id,
siteName: 'site-b',
missingGroups: ['opus'],
requiredGroups: ['default', 'opus'],
availableGroups: ['default'],
},
]);
});
it('infers default group from token name when token_group is empty', async () => {
const site = db.insert(schema.sites).values({
name: 'site-c',
url: 'https://site-c.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = db.insert(schema.accounts).values({
siteId: site.id,
username: 'charlie',
accessToken: 'acc-token-c',
status: 'active',
}).returning().get();
const token = db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'default',
token: 'sk-default-c',
tokenGroup: null,
enabled: true,
isDefault: true,
}).returning().get();
db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'claude-sonnet-4-5-20250929',
available: true,
}).run();
db.insert(schema.tokenModelAvailability).values({
tokenId: token.id,
modelName: 'claude-sonnet-4-5-20250929',
available: true,
}).run();
fetchModelPricingCatalogMock.mockResolvedValue({
models: [
{
modelName: 'claude-sonnet-4-5-20250929',
quotaType: 0,
modelDescription: null,
tags: [],
supportedEndpointTypes: [],
ownerBy: null,
enableGroups: ['default', 'vip'],
groupPricing: {},
},
],
groupRatio: { default: 1, vip: 2 },
});
const response = await app.inject({
method: 'GET',
url: '/api/models/token-candidates',
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
modelsMissingTokenGroups: Record<string, Array<{
accountId: number;
missingGroups: string[];
requiredGroups: string[];
availableGroups: string[];
groupCoverageUncertain?: boolean;
}>>;
};
expect(body.modelsMissingTokenGroups['claude-sonnet-4-5-20250929']).toEqual([
{
accountId: account.id,
username: 'charlie',
siteId: site.id,
siteName: 'site-c',
missingGroups: ['vip'],
requiredGroups: ['default', 'vip'],
availableGroups: ['default'],
},
]);
});
it('marks coverage as uncertain when token group cannot be inferred', async () => {
const site = db.insert(schema.sites).values({
name: 'site-d',
url: 'https://site-d.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = db.insert(schema.accounts).values({
siteId: site.id,
username: 'david',
accessToken: 'acc-token-d',
status: 'active',
}).returning().get();
const token = db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'token-1',
token: 'sk-token-1',
tokenGroup: null,
enabled: true,
isDefault: true,
}).returning().get();
db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'claude-opus-4-5-20251101',
available: true,
}).run();
db.insert(schema.tokenModelAvailability).values({
tokenId: token.id,
modelName: 'claude-opus-4-5-20251101',
available: true,
}).run();
fetchModelPricingCatalogMock.mockResolvedValue({
models: [
{
modelName: 'claude-opus-4-5-20251101',
quotaType: 0,
modelDescription: null,
tags: [],
supportedEndpointTypes: [],
ownerBy: null,
enableGroups: ['default', 'vip'],
groupPricing: {},
},
],
groupRatio: { default: 1, vip: 2 },
});
const response = await app.inject({
method: 'GET',
url: '/api/models/token-candidates',
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
modelsMissingTokenGroups: Record<string, Array<{
accountId: number;
missingGroups: string[];
requiredGroups: string[];
availableGroups: string[];
groupCoverageUncertain?: boolean;
}>>;
};
expect(body.modelsMissingTokenGroups['claude-opus-4-5-20251101']).toEqual([
{
accountId: account.id,
username: 'david',
siteId: site.id,
siteName: 'site-d',
missingGroups: ['default', 'vip'],
requiredGroups: ['default', 'vip'],
availableGroups: [],
groupCoverageUncertain: true,
},
]);
});
});
+246 -2
View File
@@ -228,6 +228,17 @@ export async function statsRoutes(app: FastifyInstance) {
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.all();
const accountAvailability = db.select().from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
and(
eq(schema.modelAvailability.available, true),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.all();
const last7d = getLocalRangeStartUtc(7);
const recentLogs = db.select().from(schema.proxyLogs)
@@ -381,6 +392,38 @@ export async function statsRoutes(app: FastifyInstance) {
}
}
for (const row of accountAvailability) {
const m = row.model_availability;
const a = row.accounts;
const s = row.sites;
if (!m.available || a.status !== 'active' || s.status !== 'active') continue;
if (!modelMap[m.modelName]) {
modelMap[m.modelName] = { name: m.modelName, accountsById: new Map() };
}
const existingAccount = modelMap[m.modelName].accountsById.get(a.id);
if (!existingAccount) {
modelMap[m.modelName].accountsById.set(a.id, {
id: a.id,
site: s.name,
username: a.username,
latency: m.latencyMs,
unitCost: a.unitCost,
balance: a.balance || 0,
tokens: [],
});
continue;
}
const nextLatency = (() => {
if (existingAccount.latency == null) return m.latencyMs;
if (m.latencyMs == null) return existingAccount.latency;
return Math.min(existingAccount.latency, m.latencyMs);
})();
existingAccount.latency = nextLatency;
}
let upstreamDescriptionMap = new Map<string, string>();
if (includePricing) {
const hasMissingDescription = Object.keys(modelMap).some((modelName) => {
@@ -428,6 +471,20 @@ export async function statsRoutes(app: FastifyInstance) {
});
app.get('/api/models/token-candidates', async () => {
const resolveTokenGroupLabel = (tokenGroup: string | null, tokenName: string | null): string | null => {
const explicit = (tokenGroup || '').trim();
if (explicit) return explicit;
const name = (tokenName || '').trim();
if (!name) return null;
const normalized = name.toLowerCase();
if (normalized === 'default' || normalized === '默认' || /^default($|[-_\s])/.test(normalized)) {
return 'default';
}
if (/^token-\d+$/.test(normalized)) return null;
return name;
};
const rows = 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))
@@ -441,6 +498,24 @@ export async function statsRoutes(app: FastifyInstance) {
),
)
.all();
const availableModelRows = db.select({
modelName: schema.modelAvailability.modelName,
accountId: schema.accounts.id,
username: schema.accounts.username,
siteId: schema.sites.id,
siteName: schema.sites.name,
})
.from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
and(
eq(schema.modelAvailability.available, true),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.all();
const result: Record<string, Array<{
accountId: number;
@@ -451,9 +526,47 @@ export async function statsRoutes(app: FastifyInstance) {
siteId: number;
siteName: string;
}>> = {};
const coveredAccountModelSet = new Set<string>();
const coveredGroupsByAccountModel = new Map<string, Map<string, string>>();
const unknownGroupCoverageByAccountModel = new Set<string>();
const modelsWithoutToken: Record<string, Array<{
accountId: number;
username: string | null;
siteId: number;
siteName: string;
}>> = {};
const modelsMissingTokenGroups: Record<string, Array<{
accountId: number;
username: string | null;
siteId: number;
siteName: string;
missingGroups: string[];
requiredGroups: string[];
availableGroups: string[];
groupCoverageUncertain?: boolean;
}>> = {};
let hasAnyTokenGroupSignals = false;
for (const row of rows) {
const modelName = row.token_model_availability.modelName;
const modelName = (row.token_model_availability.modelName || '').trim();
if (!modelName) continue;
const accountModelKey = `${row.accounts.id}::${modelName.toLowerCase()}`;
coveredAccountModelSet.add(accountModelKey);
const resolvedTokenGroup = resolveTokenGroupLabel(row.account_tokens.tokenGroup, row.account_tokens.name);
if (resolvedTokenGroup) {
hasAnyTokenGroupSignals = true;
if (!coveredGroupsByAccountModel.has(accountModelKey)) {
coveredGroupsByAccountModel.set(accountModelKey, new Map<string, string>());
}
const groupKey = resolvedTokenGroup.toLowerCase();
if (!coveredGroupsByAccountModel.get(accountModelKey)!.has(groupKey)) {
coveredGroupsByAccountModel.get(accountModelKey)!.set(groupKey, resolvedTokenGroup);
}
} else {
unknownGroupCoverageByAccountModel.add(accountModelKey);
}
if (!result[modelName]) result[modelName] = [];
if (result[modelName].some((item) => item.tokenId === row.account_tokens.id)) continue;
result[modelName].push({
@@ -467,7 +580,138 @@ export async function statsRoutes(app: FastifyInstance) {
});
}
return { models: result };
for (const row of availableModelRows) {
const modelName = (row.modelName || '').trim();
if (!modelName) continue;
const coverageKey = `${row.accountId}::${modelName.toLowerCase()}`;
if (coveredAccountModelSet.has(coverageKey)) continue;
if (!modelsWithoutToken[modelName]) modelsWithoutToken[modelName] = [];
if (modelsWithoutToken[modelName].some((item) => item.accountId === row.accountId)) continue;
modelsWithoutToken[modelName].push({
accountId: row.accountId,
username: row.username,
siteId: row.siteId,
siteName: row.siteName,
});
}
const accountIdsForGroupHints = new Set(availableModelRows.map((row) => row.accountId));
const requiredGroupsByAccountModel = new Map<string, Map<string, string>>();
const hasPotentialGroupHints = hasAnyTokenGroupSignals || unknownGroupCoverageByAccountModel.size > 0;
if (hasPotentialGroupHints && accountIdsForGroupHints.size > 0) {
const accountRows = db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
and(
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.all();
const metadataResults = await Promise.all(
accountRows
.filter((row) => accountIdsForGroupHints.has(row.accounts.id))
.map(async (row) => {
try {
const catalog = await fetchModelPricingCatalog({
site: {
id: row.sites.id,
url: row.sites.url,
platform: row.sites.platform,
apiKey: row.sites.apiKey,
},
account: {
id: row.accounts.id,
accessToken: row.accounts.accessToken,
apiToken: row.accounts.apiToken,
},
modelName: '__metadata__',
totalTokens: 0,
});
return { accountId: row.accounts.id, catalog };
} catch {
return { accountId: row.accounts.id, catalog: null as Awaited<ReturnType<typeof fetchModelPricingCatalog>> };
}
}),
);
for (const result of metadataResults) {
if (!result.catalog) continue;
for (const model of result.catalog.models) {
const modelName = (model.modelName || '').trim();
if (!modelName) continue;
const groups = new Map<string, string>();
for (const rawGroup of model.enableGroups || []) {
const group = String(rawGroup || '').trim();
if (!group) continue;
const groupKey = group.toLowerCase();
if (!groups.has(groupKey)) groups.set(groupKey, group);
}
if (groups.size === 0) continue;
requiredGroupsByAccountModel.set(`${result.accountId}::${modelName.toLowerCase()}`, groups);
}
}
}
for (const row of availableModelRows) {
const modelName = (row.modelName || '').trim();
if (!modelName) continue;
const accountModelKey = `${row.accountId}::${modelName.toLowerCase()}`;
const requiredGroups = requiredGroupsByAccountModel.get(accountModelKey);
if (!requiredGroups || requiredGroups.size === 0) continue;
const availableGroups = coveredGroupsByAccountModel.get(accountModelKey) || new Map<string, string>();
const missingGroups = Array.from(requiredGroups.entries())
.filter(([groupKey]) => !availableGroups.has(groupKey))
.map(([, label]) => label);
if (missingGroups.length === 0) continue;
if (!modelsMissingTokenGroups[modelName]) modelsMissingTokenGroups[modelName] = [];
if (modelsMissingTokenGroups[modelName].some((item) => item.accountId === row.accountId)) continue;
const hintRow = {
accountId: row.accountId,
username: row.username,
siteId: row.siteId,
siteName: row.siteName,
missingGroups: missingGroups.sort((a, b) => a.localeCompare(b)),
requiredGroups: Array.from(requiredGroups.values()).sort((a, b) => a.localeCompare(b)),
availableGroups: Array.from(availableGroups.values()).sort((a, b) => a.localeCompare(b)),
} as {
accountId: number;
username: string | null;
siteId: number;
siteName: string;
missingGroups: string[];
requiredGroups: string[];
availableGroups: string[];
groupCoverageUncertain?: boolean;
};
if (unknownGroupCoverageByAccountModel.has(accountModelKey)) {
hintRow.groupCoverageUncertain = true;
}
modelsMissingTokenGroups[modelName].push(hintRow);
}
const endpointTypesByModel: Record<string, string[]> = {};
const cachedPricing = readModelsMarketplaceCache(true);
const cachedBase = cachedPricing || readModelsMarketplaceCache(false);
if (cachedBase) {
for (const model of cachedBase) {
if (Array.isArray(model.supportedEndpointTypes) && model.supportedEndpointTypes.length > 0) {
endpointTypesByModel[model.name] = model.supportedEndpointTypes;
}
}
}
return {
models: result,
modelsWithoutToken,
modelsMissingTokenGroups,
endpointTypesByModel,
};
});
// Refresh models for one account and rebuild routes.
+113
View File
@@ -531,6 +531,61 @@ describe('chat proxy stream behavior', () => {
expect(secondBody.messages[0]?.content).toContain('hello');
});
it('downgrades to next endpoint when normalized Claude fallback still returns messages is required', async () => {
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
error: {
type: '<nil>',
message: 'messages is required',
},
}), {
status: 400,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
error: {
type: '<nil>',
message: 'messages is required',
},
}), {
status: 400,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'chatcmpl_downgraded_ok',
object: 'chat.completion',
model: 'upstream-gpt',
choices: [{
index: 0,
message: { role: 'assistant', content: 'ok after endpoint downgrade' },
finish_reason: 'stop',
}],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/messages',
payload: {
model: 'claude-opus-4-6',
max_tokens: 256,
messages: [{ role: 'user', content: 'hello' }],
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock.mock.calls.length).toBe(3);
const [firstUrl] = fetchMock.mock.calls[0] as [string, any];
const [secondUrl] = fetchMock.mock.calls[1] as [string, any];
const [thirdUrl] = fetchMock.mock.calls[2] as [string, any];
expect(firstUrl).toContain('/v1/messages');
expect(secondUrl).toContain('/v1/messages');
expect(thirdUrl).toContain('/v1/chat/completions');
expect(response.json()?.type).toBe('message');
});
it('passes through Claude tool_use SSE events on /v1/messages for CLI tool execution', async () => {
const encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({
@@ -603,6 +658,64 @@ describe('chat proxy stream behavior', () => {
expect(forwarded.input).toBe('hello');
});
it('continues downgrade to /v1/messages when /v1/chat/completions returns messages is required for /v1/responses', async () => {
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
error: {
message: 'openai_error',
type: 'bad_response_status_code',
code: 'bad_response_status_code',
},
}), {
status: 404,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
error: {
message: 'messages is required',
type: 'upstream_error',
code: null,
},
}), {
status: 400,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'msg_responses_retry_messages',
type: 'message',
model: 'upstream-gpt',
content: [{ type: 'text', text: 'ok from messages fallback' }],
stop_reason: 'end_turn',
usage: { input_tokens: 5, output_tokens: 2, total_tokens: 7 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'gpt-5.2',
input: 'hello',
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(3);
const [firstUrl] = fetchMock.mock.calls[0] as [string, any];
const [secondUrl] = fetchMock.mock.calls[1] as [string, any];
const [thirdUrl] = fetchMock.mock.calls[2] as [string, any];
expect(firstUrl).toContain('/v1/responses');
expect(secondUrl).toContain('/v1/chat/completions');
expect(thirdUrl).toContain('/v1/messages');
const body = response.json();
expect(body.object).toBe('response');
expect(body.output_text).toContain('ok from messages fallback');
});
it('passes through /v1/responses SSE payloads', async () => {
const encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({
+44 -5
View File
@@ -33,6 +33,7 @@ import {
getDownstreamRoutingPolicy,
recordDownstreamCostUsage,
} from './downstreamPolicy.js';
import { composeProxyLogMessage } from './logPathMeta.js';
const MAX_RETRIES = 2;
const CLAUDE_SSE_EVENT_NAMES = new Set([
@@ -62,6 +63,10 @@ function shouldRetryClaudeMessagesWithNormalizedBody(
return /messages\s+is\s+required/i.test(upstreamErrorText);
}
function isMessagesRequiredError(upstreamErrorText: string): boolean {
return /messages\s+is\s+required/i.test(upstreamErrorText);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
@@ -134,6 +139,7 @@ async function handleChatProxyRequest(
}
const { requestedModel, isStream, upstreamBody, claudeOriginalBody } = parsedRequest.value!;
const downstreamPath = downstreamFormat === 'claude' ? '/v1/messages' : '/v1/chat/completions';
if (!ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
@@ -238,6 +244,32 @@ async function handleChatProxyRequest(
}
const normalizedErrText = await normalizedResponse.text().catch(() => 'unknown error');
const shouldDowngradeEndpoint = (
endpointIndex < endpointCandidates.length - 1
&& (
isEndpointDowngradeError(normalizedResponse.status, normalizedErrText)
|| isMessagesRequiredError(normalizedErrText)
)
);
if (shouldDowngradeEndpoint) {
const errText = withUpstreamPath(
normalizedClaudeRequest.path,
`normalized-claude-body fallback failed: ${normalizedErrText}`,
);
logProxy(
selected,
requestedModel,
'failed',
normalizedResponse.status,
Date.now() - startTime,
errText,
retryCount,
downstreamPath,
);
continue;
}
finalStatus = normalizedResponse.status;
finalErrText = withUpstreamPath(
normalizedClaudeRequest.path,
@@ -253,7 +285,7 @@ async function handleChatProxyRequest(
);
if (shouldDowngradeEndpoint) {
logProxy(selected, requestedModel, 'failed', response.status, Date.now() - startTime, errText, retryCount);
logProxy(selected, requestedModel, 'failed', response.status, Date.now() - startTime, errText, retryCount, downstreamPath);
continue;
}
@@ -266,7 +298,7 @@ async function handleChatProxyRequest(
const status = finalStatus || 502;
const errText = finalErrText || 'unknown error';
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', status, Date.now() - startTime, errText, retryCount);
logProxy(selected, requestedModel, 'failed', status, Date.now() - startTime, errText, retryCount, downstreamPath);
if (isTokenExpiredError({ status, message: errText })) {
await reportTokenExpired({
@@ -400,6 +432,7 @@ async function handleChatProxyRequest(
latency,
null,
retryCount,
downstreamPath,
resolvedUsage.promptTokens,
resolvedUsage.completionTokens,
resolvedUsage.totalTokens,
@@ -539,6 +572,7 @@ async function handleChatProxyRequest(
latency,
null,
retryCount,
downstreamPath,
resolvedUsage.promptTokens,
resolvedUsage.completionTokens,
resolvedUsage.totalTokens,
@@ -600,6 +634,7 @@ async function handleChatProxyRequest(
latency,
null,
retryCount,
downstreamPath,
resolvedUsage.promptTokens,
resolvedUsage.completionTokens,
resolvedUsage.totalTokens,
@@ -610,7 +645,7 @@ async function handleChatProxyRequest(
return reply.send(downstreamResponse);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err?.message || 'network error', retryCount);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err?.message || 'network error', retryCount, downstreamPath);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
@@ -640,6 +675,7 @@ function logProxy(
latencyMs: number,
errorMessage: string | null,
retryCount: number,
downstreamPath: string,
promptTokens = 0,
completionTokens = 0,
totalTokens = 0,
@@ -647,8 +683,11 @@ function logProxy(
upstreamPath: string | null = null,
) {
try {
const normalizedErrorMessage = errorMessage
|| (upstreamPath ? `[upstream:${upstreamPath}]` : null);
const normalizedErrorMessage = composeProxyLogMessage({
downstreamPath,
upstreamPath,
errorMessage,
});
db.insert(schema.proxyLogs).values({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
+8 -3
View File
@@ -11,6 +11,7 @@ import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageF
import { mergeProxyUsage, parseProxyUsage, pullSseDataEvents } from '../../services/proxyUsageParser.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
import { composeProxyLogMessage } from './logPathMeta.js';
const MAX_RETRIES = 2;
@@ -239,9 +240,13 @@ function logProxy(
completionTokens = 0,
totalTokens = 0,
estimatedCost = 0,
) {
) {
try {
db.insert(schema.proxyLogs).values({
const normalizedErrorMessage = composeProxyLogMessage({
downstreamPath: '/v1/completions',
errorMessage,
});
db.insert(schema.proxyLogs).values({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
@@ -254,7 +259,7 @@ function logProxy(
completionTokens,
totalTokens,
estimatedCost,
errorMessage,
errorMessage: normalizedErrorMessage,
retryCount,
}).run();
} catch (error) {
+8 -3
View File
@@ -11,6 +11,7 @@ import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageF
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
import { composeProxyLogMessage } from './logPathMeta.js';
const MAX_RETRIES = 2;
@@ -154,9 +155,13 @@ function logProxy(
completionTokens = 0,
totalTokens = 0,
estimatedCost = 0,
) {
) {
try {
db.insert(schema.proxyLogs).values({
const normalizedErrorMessage = composeProxyLogMessage({
downstreamPath: '/v1/embeddings',
errorMessage,
});
db.insert(schema.proxyLogs).values({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
@@ -169,7 +174,7 @@ function logProxy(
completionTokens,
totalTokens,
estimatedCost,
errorMessage,
errorMessage: normalizedErrorMessage,
retryCount,
}).run();
} catch (error) {
+8 -3
View File
@@ -9,6 +9,7 @@ import { estimateProxyCost } from '../../services/modelPricingService.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
import { composeProxyLogMessage } from './logPathMeta.js';
const MAX_RETRIES = 2;
@@ -124,9 +125,13 @@ function logProxy(
errorMessage: string | null,
retryCount: number,
estimatedCost = 0,
) {
) {
try {
db.insert(schema.proxyLogs).values({
const normalizedErrorMessage = composeProxyLogMessage({
downstreamPath: '/v1/images/generations',
errorMessage,
});
db.insert(schema.proxyLogs).values({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
@@ -139,7 +144,7 @@ function logProxy(
completionTokens: 0,
totalTokens: 0,
estimatedCost,
errorMessage,
errorMessage: normalizedErrorMessage,
retryCount,
}).run();
} catch (error) {
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { composeProxyLogMessage } from './logPathMeta.js';
describe('composeProxyLogMessage', () => {
it('adds downstream path metadata before plain errors', () => {
expect(composeProxyLogMessage({
downstreamPath: '/v1/responses',
errorMessage: 'upstream failed',
})).toBe('[downstream:/v1/responses] upstream failed');
});
it('keeps existing upstream metadata and avoids duplicate prefixes', () => {
expect(composeProxyLogMessage({
downstreamPath: '/v1/responses',
errorMessage: '[upstream:/v1/chat/completions] bad request',
})).toBe('[downstream:/v1/responses] [upstream:/v1/chat/completions] bad request');
});
it('returns metadata-only message when no error text exists', () => {
expect(composeProxyLogMessage({
downstreamPath: '/v1/chat/completions',
upstreamPath: '/v1/messages',
errorMessage: null,
})).toBe('[downstream:/v1/chat/completions] [upstream:/v1/messages]');
});
it('returns null when nothing can be recorded', () => {
expect(composeProxyLogMessage({
downstreamPath: null,
upstreamPath: null,
errorMessage: '',
})).toBe(null);
});
});
+42
View File
@@ -0,0 +1,42 @@
type ComposeProxyLogMessageArgs = {
downstreamPath?: string | null;
upstreamPath?: string | null;
errorMessage?: string | null;
};
type ParsedPathMeta = {
downstreamPath: string | null;
upstreamPath: string | null;
messageText: string;
};
function parseExistingPathMeta(rawMessage: string): ParsedPathMeta {
const downstreamMatch = rawMessage.match(/\[downstream:([^\]]+)\]/i);
const upstreamMatch = rawMessage.match(/\[upstream:([^\]]+)\]/i);
const messageText = rawMessage.replace(/^\s*(?:\[(?:downstream|upstream):[^\]]+\]\s*)+/i, '').trim();
return {
downstreamPath: downstreamMatch?.[1]?.trim() || null,
upstreamPath: upstreamMatch?.[1]?.trim() || null,
messageText,
};
}
export function composeProxyLogMessage({
downstreamPath,
upstreamPath,
errorMessage,
}: ComposeProxyLogMessageArgs): string | null {
const rawMessage = typeof errorMessage === 'string' ? errorMessage.trim() : '';
const parsed = parseExistingPathMeta(rawMessage);
const finalDownstreamPath = (downstreamPath || parsed.downstreamPath || '').trim();
const finalUpstreamPath = (upstreamPath || parsed.upstreamPath || '').trim();
const finalMessageText = parsed.messageText.trim();
const prefixParts: string[] = [];
if (finalDownstreamPath) prefixParts.push(`[downstream:${finalDownstreamPath}]`);
if (finalUpstreamPath) prefixParts.push(`[upstream:${finalUpstreamPath}]`);
if (prefixParts.length === 0 && !finalMessageText) return null;
if (finalMessageText) return `${prefixParts.join(' ')} ${finalMessageText}`.trim();
return prefixParts.join(' ');
}
+31 -8
View File
@@ -24,6 +24,7 @@ import {
type UpstreamEndpoint,
} from './upstreamEndpoint.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
import { composeProxyLogMessage } from './logPathMeta.js';
const MAX_RETRIES = 2;
@@ -31,6 +32,16 @@ function withUpstreamPath(path: string, message: string): string {
return `[upstream:${path}] ${message}`;
}
function shouldDowngradeFromChatToMessagesForResponses(
endpointPath: string,
status: number,
upstreamErrorText: string,
): boolean {
if (!endpointPath.includes('/chat/completions')) return false;
if (status < 400 || status >= 500) return false;
return /messages\s+is\s+required/i.test(upstreamErrorText);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
@@ -927,6 +938,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
app.post('/v1/responses', async (request: FastifyRequest, reply: FastifyReply) => {
const body = request.body as any;
const requestedModel = typeof body?.model === 'string' ? body.model.trim() : '';
const downstreamPath = '/v1/responses';
if (!requestedModel) {
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
}
@@ -1014,11 +1026,18 @@ export async function responsesProxyRoute(app: FastifyInstance) {
const errText = withUpstreamPath(endpointRequest.path, rawErrText);
const shouldDowngradeEndpoint = (
endpointIndex < endpointCandidates.length - 1
&& isEndpointDowngradeError(response.status, rawErrText)
&& (
isEndpointDowngradeError(response.status, rawErrText)
|| shouldDowngradeFromChatToMessagesForResponses(
endpointRequest.path,
response.status,
rawErrText,
)
)
);
if (shouldDowngradeEndpoint) {
logProxy(selected, requestedModel, 'failed', response.status, Date.now() - startTime, errText, retryCount);
logProxy(selected, requestedModel, 'failed', response.status, Date.now() - startTime, errText, retryCount, downstreamPath);
continue;
}
@@ -1031,7 +1050,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
const status = finalStatus || 502;
const errText = finalErrText || 'unknown error';
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', status, Date.now() - startTime, errText, retryCount);
logProxy(selected, requestedModel, 'failed', status, Date.now() - startTime, errText, retryCount, downstreamPath);
if (isTokenExpiredError({ status, message: errText })) {
await reportTokenExpired({
@@ -1196,7 +1215,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount,
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
successfulUpstreamPath,
);
@@ -1248,14 +1267,14 @@ export async function responsesProxyRoute(app: FastifyInstance) {
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount,
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
successfulUpstreamPath,
);
return reply.send(downstreamData);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, downstreamPath);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
@@ -1280,6 +1299,7 @@ function logProxy(
latencyMs: number,
errorMessage: string | null,
retryCount: number,
downstreamPath: string,
promptTokens = 0,
completionTokens = 0,
totalTokens = 0,
@@ -1287,8 +1307,11 @@ function logProxy(
upstreamPath: string | null = null,
) {
try {
const normalizedErrorMessage = errorMessage
|| (upstreamPath ? `[upstream:${upstreamPath}]` : null);
const normalizedErrorMessage = composeProxyLogMessage({
downstreamPath,
upstreamPath,
errorMessage,
});
db.insert(schema.proxyLogs).values({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
@@ -1,8 +1,11 @@
import { describe, expect, it } from 'vitest';
import {
getCredentialModeFromExtraConfig,
getPlatformUserIdFromExtraConfig,
getSub2ApiAuthFromExtraConfig,
guessPlatformUserIdFromUsername,
mergeAccountExtraConfig,
normalizeCredentialMode,
resolvePlatformUserId,
} from './accountExtraConfig.js';
@@ -38,4 +41,30 @@ describe('accountExtraConfig', () => {
expect(parsed.autoRelogin?.username).toBe('demo');
expect(parsed.platformUserId).toBe(7659);
});
it('parses credential mode from extra config', () => {
expect(getCredentialModeFromExtraConfig(JSON.stringify({ credentialMode: 'apikey' }))).toBe('apikey');
expect(getCredentialModeFromExtraConfig(JSON.stringify({ credentialMode: 'session' }))).toBe('session');
expect(getCredentialModeFromExtraConfig(JSON.stringify({ credentialMode: 'AUTO' }))).toBe('auto');
expect(getCredentialModeFromExtraConfig(JSON.stringify({ credentialMode: 'unknown' }))).toBeUndefined();
});
it('normalizes credential mode input', () => {
expect(normalizeCredentialMode(' apikey ')).toBe('apikey');
expect(normalizeCredentialMode('session')).toBe('session');
expect(normalizeCredentialMode('AUTO')).toBe('auto');
expect(normalizeCredentialMode('abc')).toBeUndefined();
});
it('parses managed sub2api refresh token config from extra config', () => {
expect(getSub2ApiAuthFromExtraConfig(JSON.stringify({
sub2apiAuth: { refreshToken: 'refresh-1', tokenExpiresAt: 1760000000000 },
}))).toEqual({
refreshToken: 'refresh-1',
tokenExpiresAt: 1760000000000,
});
expect(getSub2ApiAuthFromExtraConfig(JSON.stringify({
sub2apiAuth: { refreshToken: ' ' },
}))).toBeNull();
});
});
+59
View File
@@ -4,9 +4,24 @@ type AutoReloginConfig = {
updatedAt?: unknown;
};
type Sub2ApiAuthConfig = {
refreshToken?: unknown;
tokenExpiresAt?: unknown;
};
export type AccountCredentialMode = 'auto' | 'session' | 'apikey';
const VALID_CREDENTIAL_MODES = new Set<AccountCredentialMode>([
'auto',
'session',
'apikey',
]);
type AccountExtraConfig = {
platformUserId?: unknown;
credentialMode?: unknown;
autoRelogin?: AutoReloginConfig;
sub2apiAuth?: Sub2ApiAuthConfig;
[key: string]: unknown;
};
@@ -30,11 +45,55 @@ function normalizeUserId(raw: unknown): number | undefined {
return undefined;
}
function normalizeNonEmptyString(raw: unknown): string | undefined {
if (typeof raw !== 'string') return undefined;
const trimmed = raw.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function normalizeTimestampMs(raw: unknown): number | undefined {
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) return Math.trunc(raw);
if (typeof raw === 'string') {
const parsed = Number.parseInt(raw.trim(), 10);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return undefined;
}
export function normalizeCredentialMode(raw: unknown): AccountCredentialMode | undefined {
if (typeof raw !== 'string') return undefined;
const normalized = raw.trim().toLowerCase();
if (!VALID_CREDENTIAL_MODES.has(normalized as AccountCredentialMode)) return undefined;
return normalized as AccountCredentialMode;
}
export function getPlatformUserIdFromExtraConfig(extraConfig?: string | null): number | undefined {
const parsed = parseExtraConfig(extraConfig);
return normalizeUserId(parsed.platformUserId);
}
export function getCredentialModeFromExtraConfig(extraConfig?: string | null): AccountCredentialMode | undefined {
const parsed = parseExtraConfig(extraConfig);
return normalizeCredentialMode(parsed.credentialMode);
}
export type ManagedSub2ApiAuth = {
refreshToken: string;
tokenExpiresAt?: number;
};
export function getSub2ApiAuthFromExtraConfig(extraConfig?: string | null): ManagedSub2ApiAuth | null {
const parsed = parseExtraConfig(extraConfig);
const raw = parsed.sub2apiAuth;
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const refreshToken = normalizeNonEmptyString(raw.refreshToken);
if (!refreshToken) return null;
const tokenExpiresAt = normalizeTimestampMs(raw.tokenExpiresAt);
return tokenExpiresAt
? { refreshToken, tokenExpiresAt }
: { refreshToken };
}
export function guessPlatformUserIdFromUsername(username?: string | null): number | undefined {
const text = (username || '').trim();
if (!text) return undefined;
+23 -1
View File
@@ -5,6 +5,7 @@ type UpstreamApiToken = {
name?: string | null;
key?: string | null;
enabled?: boolean | null;
tokenGroup?: string | null;
};
export function normalizeTokenForDisplay(token?: string | null, platform?: string | null): string {
@@ -44,6 +45,20 @@ function normalizeTokenValue(token: string | null | undefined): string | null {
return trimmed.length > 0 ? trimmed : null;
}
function normalizeTokenGroup(value: string | null | undefined, tokenName?: string | null): string | null {
const explicit = (value || '').trim();
if (explicit.length > 0) return explicit;
const name = (tokenName || '').trim();
if (!name) return null;
const normalized = name.toLowerCase();
if (normalized === 'default' || normalized === '默认' || /^default($|[-_\s])/.test(normalized)) {
return 'default';
}
if (/^token-\d+$/.test(normalized)) return null;
return name;
}
function updateAccountApiToken(accountId: number, tokenValue: string | null) {
db.update(schema.accounts)
.set({ apiToken: tokenValue || null, updatedAt: new Date().toISOString() })
@@ -66,10 +81,11 @@ export function getPreferredAccountToken(accountId: number) {
export function ensureDefaultTokenForAccount(
accountId: number,
tokenValue: string,
options?: { name?: string; source?: string; enabled?: boolean },
options?: { name?: string; source?: string; enabled?: boolean; tokenGroup?: string | null },
): number | null {
const normalizedToken = normalizeTokenValue(tokenValue);
if (!normalizedToken) return null;
const tokenGroup = normalizeTokenGroup(options?.tokenGroup, options?.name) || 'default';
const now = new Date().toISOString();
const tokens = db.select()
@@ -84,6 +100,7 @@ export function ensureDefaultTokenForAccount(
accountId,
name: normalizeTokenName(options?.name, tokens.length + 1),
token: normalizedToken,
tokenGroup,
source: options?.source || 'manual',
enabled: options?.enabled ?? true,
isDefault: true,
@@ -96,6 +113,7 @@ export function ensureDefaultTokenForAccount(
db.update(schema.accountTokens)
.set({
name: options?.name ? normalizeTokenName(options.name) : target.name,
tokenGroup,
source: options?.source || target.source || 'manual',
enabled: options?.enabled ?? target.enabled,
isDefault: true,
@@ -179,12 +197,14 @@ export function syncTokensFromUpstream(accountId: number, upstreamTokens: Upstre
const tokenName = normalizeTokenName(upstream.name, index);
const enabled = upstream.enabled ?? true;
const tokenGroup = normalizeTokenGroup(upstream.tokenGroup, tokenName);
const byToken = existing.find((row) => row.token === tokenValue);
if (byToken) {
db.update(schema.accountTokens)
.set({
name: tokenName,
tokenGroup,
source: 'sync',
enabled,
updatedAt: now,
@@ -192,6 +212,7 @@ export function syncTokensFromUpstream(accountId: number, upstreamTokens: Upstre
.where(eq(schema.accountTokens.id, byToken.id))
.run();
byToken.name = tokenName;
byToken.tokenGroup = tokenGroup;
byToken.enabled = enabled;
byToken.source = 'sync';
byToken.updatedAt = now;
@@ -204,6 +225,7 @@ export function syncTokensFromUpstream(accountId: number, upstreamTokens: Upstre
accountId,
name: tokenName,
token: tokenValue,
tokenGroup,
source: 'sync',
enabled,
isDefault: false,
+5
View File
@@ -159,11 +159,13 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
id: siteId,
name: siteName,
url: siteUrl,
externalCheckinUrl: null,
platform,
proxyUrl: null,
status: 'active',
isPinned: false,
sortOrder: sites.length,
globalWeight: 1,
apiKey: null,
createdAt: toIsoString(item.created_at),
updatedAt: toIsoString(item.updated_at),
@@ -232,6 +234,7 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
accountId,
name: 'default',
token: apiToken,
tokenGroup: 'default',
source: 'legacy',
enabled: true,
isDefault: true,
@@ -465,6 +468,7 @@ function importAccountsSection(section: AccountsBackupSection) {
status: row.status || 'active',
isPinned: row.isPinned ?? false,
sortOrder: row.sortOrder ?? 0,
globalWeight: row.globalWeight ?? 1,
apiKey: row.apiKey,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
@@ -501,6 +505,7 @@ function importAccountsSection(section: AccountsBackupSection) {
accountId: row.accountId,
name: row.name,
token: row.token,
tokenGroup: row.tokenGroup ?? null,
source: row.source,
enabled: row.enabled,
isDefault: row.isDefault,
@@ -172,6 +172,113 @@ describe('balanceService auto relogin', () => {
expect(reportTokenExpiredMock).toHaveBeenCalledTimes(1);
});
it('proactively refreshes sub2api token when managed refresh token is near expiry', async () => {
selectAllMock.mockReturnValue([
{
accounts: {
id: 5,
username: 'sub2-user',
accessToken: 'old-access-token',
status: 'active',
extraConfig: JSON.stringify({
sub2apiAuth: {
refreshToken: 'refresh-token-1',
tokenExpiresAt: Date.now() + 60_000,
},
}),
},
sites: {
id: 7,
name: 'sub2',
url: 'https://sub2.example.com',
platform: 'sub2api',
},
},
]);
undiciFetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({
code: 0,
message: 'success',
data: {
access_token: 'new-access-token',
refresh_token: 'refresh-token-2',
expires_in: 3600,
},
}),
});
adapterMock.getBalance.mockResolvedValueOnce({ balance: 20, used: 1, quota: 21 });
const { refreshBalance } = await import('./balanceService.js');
const result = await refreshBalance(5);
expect(result).toEqual({ balance: 20, used: 1, quota: 21 });
expect(adapterMock.getBalance).toHaveBeenCalledTimes(1);
expect(adapterMock.getBalance.mock.calls[0]?.[1]).toBe('new-access-token');
expect(updateSetMock.mock.calls.some((call) => call[0]?.accessToken === 'new-access-token')).toBe(true);
const updateWithSub2ApiAuth = updateSetMock.mock.calls
.map((call) => call[0] as Record<string, unknown>)
.find((payload) => typeof payload.extraConfig === 'string' && String(payload.extraConfig).includes('refresh-token-2'));
expect(updateWithSub2ApiAuth).toBeDefined();
const parsedExtra = JSON.parse(String(updateWithSub2ApiAuth?.extraConfig)) as {
sub2apiAuth?: { refreshToken?: string; tokenExpiresAt?: number };
};
expect(parsedExtra.sub2apiAuth?.refreshToken).toBe('refresh-token-2');
expect(typeof parsedExtra.sub2apiAuth?.tokenExpiresAt).toBe('number');
});
it('retries once via sub2api managed refresh token when balance returns 401', async () => {
selectAllMock.mockReturnValue([
{
accounts: {
id: 6,
username: 'sub2-user',
accessToken: 'expired-access-token',
status: 'active',
extraConfig: JSON.stringify({
sub2apiAuth: {
refreshToken: 'refresh-token-3',
},
}),
},
sites: {
id: 8,
name: 'sub2',
url: 'https://sub2.example.com',
platform: 'sub2api',
},
},
]);
adapterMock.getBalance
.mockRejectedValueOnce(new Error('HTTP 401: unauthorized'))
.mockResolvedValueOnce({ balance: 8, used: 1, quota: 9 });
adapterMock.login.mockResolvedValue({ success: false });
undiciFetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({
code: 0,
message: 'success',
data: {
access_token: 'retried-access-token',
refresh_token: 'refresh-token-4',
expires_in: 3600,
},
}),
});
const { refreshBalance } = await import('./balanceService.js');
const result = await refreshBalance(6);
expect(result).toEqual({ balance: 8, used: 1, quota: 9 });
expect(adapterMock.getBalance).toHaveBeenCalledTimes(2);
expect(adapterMock.getBalance.mock.calls[0]?.[1]).toBe('expired-access-token');
expect(adapterMock.getBalance.mock.calls[1]?.[1]).toBe('retried-access-token');
expect(adapterMock.login).not.toHaveBeenCalled();
expect(reportTokenExpiredMock).not.toHaveBeenCalled();
});
it('keeps degraded health when checkin is unsupported but balance refresh succeeds', async () => {
selectAllMock.mockReturnValue([
{
@@ -264,4 +371,33 @@ describe('balanceService auto relogin', () => {
const parsedExtra = JSON.parse(String(updateWithSnapshot?.extraConfig));
expect(parsedExtra.todayIncomeSnapshot?.latest).toBeCloseTo(2.08365, 6);
});
it('does not send low balance reminder notification when balance is below threshold', async () => {
selectAllMock.mockReturnValue([
{
accounts: {
id: 7,
username: 'linuxdo_low_balance',
accessToken: 'active-token',
status: 'active',
extraConfig: null,
},
sites: {
id: 9,
name: 'wong',
url: 'https://wzw.pp.ua',
platform: 'new-api',
},
},
]);
adapterMock.getBalance.mockResolvedValueOnce({ balance: 0.5, used: 1, quota: 1.5 });
const { refreshBalance } = await import('./balanceService.js');
const result = await refreshBalance(7);
expect(result).toEqual({ balance: 0.5, used: 1, quota: 1.5 });
expect(sendNotificationMock).not.toHaveBeenCalled();
expect(insertValuesMock).not.toHaveBeenCalled();
});
});
+156 -23
View File
@@ -1,10 +1,14 @@
import { db, schema } from '../db/index.js';
import { getAdapter } from './platforms/index.js';
import { eq } from 'drizzle-orm';
import { sendNotification } from './notifyService.js';
import { appendSessionTokenRebindHint, isTokenExpiredError } from './alertRules.js';
import { reportTokenExpired } from './alertService.js';
import { getAutoReloginConfig, resolvePlatformUserId } from './accountExtraConfig.js';
import {
getAutoReloginConfig,
getSub2ApiAuthFromExtraConfig,
mergeAccountExtraConfig,
resolvePlatformUserId,
} from './accountExtraConfig.js';
import { decryptAccountPassword } from './accountCredentialService.js';
import { extractRuntimeHealth, setAccountRuntimeHealth } from './accountHealthService.js';
import { updateTodayIncomeSnapshot } from './todayIncomeRewardService.js';
@@ -124,6 +128,111 @@ function resolveQuotaConversionFactor(platform?: string | null): number {
return (platform || '').toLowerCase() === 'veloera' ? 1_000_000 : 500_000;
}
const SUB2API_TOKEN_REFRESH_BUFFER_MS = 120 * 1000;
function isSub2ApiPlatform(platform?: string | null): boolean {
return (platform || '').trim().toLowerCase() === 'sub2api';
}
function isNearTokenExpiry(tokenExpiresAt?: number): boolean {
if (!(typeof tokenExpiresAt === 'number' && Number.isFinite(tokenExpiresAt) && tokenExpiresAt > 0)) {
return false;
}
return tokenExpiresAt - Date.now() <= SUB2API_TOKEN_REFRESH_BUFFER_MS;
}
type Sub2ApiRefreshedCredentials = {
accessToken: string;
refreshToken: string;
tokenExpiresAt: number;
};
function parseSub2ApiRefreshPayload(payload: unknown): Sub2ApiRefreshedCredentials | null {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
const body = payload as Record<string, unknown>;
const code = typeof body.code === 'number'
? body.code
: (typeof body.code === 'string' ? Number.parseInt(body.code.trim(), 10) : NaN);
if (!Number.isFinite(code) || code !== 0) return null;
if (!body.data || typeof body.data !== 'object' || Array.isArray(body.data)) return null;
const data = body.data as Record<string, unknown>;
const accessToken = typeof data.access_token === 'string' ? data.access_token.trim() : '';
const refreshToken = typeof data.refresh_token === 'string' ? data.refresh_token.trim() : '';
const expiresInSeconds = typeof data.expires_in === 'number' && Number.isFinite(data.expires_in)
? data.expires_in
: (typeof data.expires_in === 'string' ? Number.parseInt(data.expires_in.trim(), 10) : NaN);
if (!accessToken || !refreshToken || !Number.isFinite(expiresInSeconds) || expiresInSeconds <= 0) {
return null;
}
return {
accessToken,
refreshToken,
tokenExpiresAt: Date.now() + Math.trunc(expiresInSeconds) * 1000,
};
}
async function refreshSub2ApiManagedSession(params: {
account: typeof schema.accounts.$inferSelect;
site: typeof schema.sites.$inferSelect;
currentAccessToken: string;
currentExtraConfig: string | null;
}): Promise<{ accessToken: string; extraConfig: string }> {
const managedAuth = getSub2ApiAuthFromExtraConfig(params.currentExtraConfig);
const refreshToken = managedAuth?.refreshToken || '';
if (!refreshToken) throw new Error('sub2api managed refresh token missing');
const endpoint = `${params.site.url.replace(/\/+$/, '')}/api/v1/auth/refresh`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const authHeaderToken = (params.currentAccessToken || '').trim();
if (authHeaderToken) {
headers.Authorization = `Bearer ${authHeaderToken}`;
}
const { fetch } = await import('undici');
let payload: unknown = null;
try {
const response = await fetch(endpoint, withExplicitProxyRequestInit(params.site.proxyUrl, {
method: 'POST',
headers,
body: JSON.stringify({ refresh_token: refreshToken }),
}));
payload = await response.json().catch(() => null);
} catch (err: any) {
throw new Error(err?.message || 'sub2api token refresh request failed');
}
const refreshed = parseSub2ApiRefreshPayload(payload);
if (!refreshed) {
throw new Error('sub2api token refresh failed');
}
const nextExtraConfig = mergeAccountExtraConfig(params.currentExtraConfig, {
sub2apiAuth: {
refreshToken: refreshed.refreshToken,
tokenExpiresAt: refreshed.tokenExpiresAt,
},
});
db.update(schema.accounts)
.set({
accessToken: refreshed.accessToken,
extraConfig: nextExtraConfig,
status: params.account.status === 'expired' ? 'active' : params.account.status,
updatedAt: new Date().toISOString(),
})
.where(eq(schema.accounts.id, params.account.id))
.run();
return {
accessToken: refreshed.accessToken,
extraConfig: nextExtraConfig,
};
}
async function fetchTodayIncomeFromLogs(params: {
baseUrl: string;
accessToken: string;
@@ -256,8 +365,25 @@ export async function refreshBalance(accountId: number) {
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
let activeAccessToken = account.accessToken;
let activeExtraConfig = account.extraConfig;
let balanceInfo: BalanceInfo | null = null;
if (isSub2ApiPlatform(site.platform)) {
const managedAuth = getSub2ApiAuthFromExtraConfig(activeExtraConfig);
if (managedAuth?.refreshToken && isNearTokenExpiry(managedAuth.tokenExpiresAt)) {
try {
const refreshed = await refreshSub2ApiManagedSession({
account,
site,
currentAccessToken: activeAccessToken,
currentExtraConfig: activeExtraConfig,
});
activeAccessToken = refreshed.accessToken;
activeExtraConfig = refreshed.extraConfig;
} catch {}
}
}
const readBalance = async (token: string) => adapter.getBalance(site.url, token, platformUserId);
const handleBalanceError = async (err: any) => {
const message = appendSessionTokenRebindHint(err?.message || 'unknown error');
@@ -281,7 +407,26 @@ export async function refreshBalance(accountId: number) {
balanceInfo = await readBalance(activeAccessToken);
} catch (err: any) {
const message = err?.message || 'unknown error';
if (shouldAttemptAutoRelogin(message)) {
const canTryManagedSub2ApiRefresh =
isSub2ApiPlatform(site.platform)
&& !!getSub2ApiAuthFromExtraConfig(activeExtraConfig)?.refreshToken
&& shouldAttemptAutoRelogin(message);
if (canTryManagedSub2ApiRefresh) {
try {
const refreshed = await refreshSub2ApiManagedSession({
account,
site,
currentAccessToken: activeAccessToken,
currentExtraConfig: activeExtraConfig,
});
activeAccessToken = refreshed.accessToken;
activeExtraConfig = refreshed.extraConfig;
balanceInfo = await readBalance(activeAccessToken);
} catch (retryErr: any) {
await handleBalanceError(retryErr);
}
} else if (shouldAttemptAutoRelogin(message)) {
const refreshedAccessToken = await tryAutoRelogin(account, site);
if (refreshedAccessToken) {
activeAccessToken = refreshedAccessToken;
@@ -320,7 +465,12 @@ export async function refreshBalance(accountId: number) {
} catch {}
}
const existingRuntimeHealth = extractRuntimeHealth(account.extraConfig);
let nextExtraConfig = activeExtraConfig;
if (typeof balanceInfo.todayIncome === 'number' && Number.isFinite(balanceInfo.todayIncome)) {
nextExtraConfig = updateTodayIncomeSnapshot(nextExtraConfig, balanceInfo.todayIncome);
}
const existingRuntimeHealth = extractRuntimeHealth(nextExtraConfig);
const keepUnsupportedCheckinDegraded = isUnsupportedCheckinRuntimeHealth(existingRuntimeHealth);
const updates: Record<string, unknown> = {
@@ -331,8 +481,8 @@ export async function refreshBalance(accountId: number) {
lastBalanceRefresh: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
if (typeof balanceInfo.todayIncome === 'number' && Number.isFinite(balanceInfo.todayIncome)) {
updates.extraConfig = updateTodayIncomeSnapshot(account.extraConfig, balanceInfo.todayIncome);
if (nextExtraConfig !== account.extraConfig) {
updates.extraConfig = nextExtraConfig;
}
db.update(schema.accounts)
@@ -350,23 +500,6 @@ export async function refreshBalance(accountId: number) {
: '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;
}
@@ -283,6 +283,41 @@ describe('checkinService auto relogin', () => {
expect(notifyMock).not.toHaveBeenCalled();
});
it('treats sub2api checkin unsupported message as skipped', async () => {
selectAllMock.mockReturnValue([
{
accounts: {
id: 15,
username: 'sub2_user',
accessToken: 'token',
status: 'active',
extraConfig: null,
},
sites: {
id: 15,
name: 'sub2',
url: 'https://sub2.example.com',
platform: 'sub2api',
},
},
]);
adapterMock.checkin.mockResolvedValue({
success: false,
message: 'Check-in is not supported by Sub2API',
});
const { checkinAccount } = await import('./checkinService.js');
const result = await checkinAccount(15);
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([
{
+5 -1
View File
@@ -48,7 +48,11 @@ function isUnsupportedCheckinMessage(message?: string | null): boolean {
return (
text.includes('invalid url (post /api/user/checkin)') ||
(text.includes('http 404') && text.includes('/api/user/checkin')) ||
text.includes('checkin endpoint not found')
text.includes('checkin endpoint not found') ||
text.includes('check-in is not supported') ||
text.includes('checkin is not supported') ||
text.includes('does not support checkin') ||
text.includes('not support checkin')
);
}
@@ -49,4 +49,13 @@ describe('failureReasonService', () => {
expect(result.category).toBe('site');
expect(result.title).toBe('站点未开启签到');
});
it('classifies sub2api unsupported checkin message as site capability issue', () => {
const result = classifyFailureReason({
message: 'Check-in is not supported by Sub2API',
status: 'failed',
});
expect(result.code).toBe('checkin_not_supported');
expect(result.category).toBe('site');
});
});
+9 -1
View File
@@ -50,7 +50,15 @@ export function classifyFailureReason(
};
}
if (includesAny(text, ['checkin endpoint not found', '签到端点不存在', '站点不支持签到', 'not support checkin'])) {
if (includesAny(text, [
'checkin endpoint not found',
'签到端点不存在',
'站点不支持签到',
'not support checkin',
'check-in is not supported',
'checkin is not supported',
'does not support checkin',
])) {
return {
code: 'checkin_not_supported',
category: 'site',
@@ -0,0 +1,98 @@
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';
import { eq } from 'drizzle-orm';
const getApiTokenMock = vi.fn();
const getModelsMock = vi.fn();
vi.mock('./platforms/index.js', () => ({
getAdapter: () => ({
getApiToken: (...args: unknown[]) => getApiTokenMock(...args),
getModels: (...args: unknown[]) => getModelsMock(...args),
}),
}));
type DbModule = typeof import('../db/index.js');
type ModelServiceModule = typeof import('./modelService.js');
describe('refreshModelsForAccount credential discovery', () => {
let db: DbModule['db'];
let schema: DbModule['schema'];
let refreshModelsForAccount: ModelServiceModule['refreshModelsForAccount'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-model-discovery-'));
process.env.DATA_DIR = dataDir;
await import('../db/migrate.js');
const dbModule = await import('../db/index.js');
const modelService = await import('./modelService.js');
db = dbModule.db;
schema = dbModule.schema;
refreshModelsForAccount = modelService.refreshModelsForAccount;
});
beforeEach(() => {
getApiTokenMock.mockReset();
getModelsMock.mockReset();
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('discovers models from account session credential without account_tokens', async () => {
getApiTokenMock.mockResolvedValue(null);
getModelsMock.mockImplementation(async (_baseUrl: string, token: string) => (
token === 'session-token' ? ['claude-sonnet-4-5-20250929', 'claude-opus-4-6'] : []
));
const site = db.insert(schema.sites).values({
name: 'site-a',
url: 'https://site-a.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = db.insert(schema.accounts).values({
siteId: site.id,
username: 'alice',
accessToken: 'session-token',
apiToken: null,
status: 'active',
}).returning().get();
const result = await refreshModelsForAccount(account.id);
expect(result).toMatchObject({
accountId: account.id,
refreshed: true,
modelCount: 2,
tokenScanned: 0,
discoveredByCredential: true,
});
const rows = db.select().from(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, account.id))
.all();
expect(rows.map((row) => row.modelName).sort()).toEqual([
'claude-opus-4-6',
'claude-sonnet-4-5-20250929',
]);
const tokenRows = db.select().from(schema.tokenModelAvailability).all();
expect(tokenRows).toHaveLength(0);
});
});
+47 -8
View File
@@ -114,6 +114,51 @@ export async function refreshModelsForAccount(accountId: number) {
const accountModels = new Set<string>();
const modelLatency = new Map<string, number | null>();
let scannedTokenCount = 0;
let discoveredByCredential = false;
const attemptedCredentials = new Set<string>();
const mergeDiscoveredModels = (models: string[], latencyMs: number | null) => {
for (const modelName of models) {
accountModels.add(modelName);
const prev = modelLatency.get(modelName);
if (prev === undefined || prev === null) {
modelLatency.set(modelName, latencyMs);
continue;
}
if (latencyMs === null) continue;
if (latencyMs < prev) modelLatency.set(modelName, latencyMs);
}
};
const discoverModelsWithCredential = async (credentialRaw: string | null | undefined) => {
const credential = (credentialRaw || '').trim();
if (!credential) return;
if (attemptedCredentials.has(credential)) return;
attemptedCredentials.add(credential);
const startedAt = Date.now();
let models: string[] = [];
try {
models = normalizeModels(
await withTimeout(
() => adapter.getModels(site.url, credential, platformUserId),
MODEL_DISCOVERY_TIMEOUT_MS,
`model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
),
);
} catch {
models = [];
}
if (models.length === 0) return;
discoveredByCredential = true;
const latencyMs = Date.now() - startedAt;
mergeDiscoveredModels(models, latencyMs);
};
// Prefer account-level credential discovery so model availability does not rely on managed tokens.
await discoverModelsWithCredential(account.apiToken);
await discoverModelsWithCredential(discoveredApiToken);
await discoverModelsWithCredential(account.accessToken);
for (const token of enabledTokens) {
const startedAt = Date.now();
@@ -147,14 +192,7 @@ export async function refreshModelsForAccount(accountId: number) {
).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);
}
}
mergeDiscoveredModels(models, latencyMs);
}
if (accountModels.size > 0) {
@@ -175,6 +213,7 @@ export async function refreshModelsForAccount(accountId: number) {
refreshed: true,
modelCount: accountModels.size,
tokenScanned: scannedTokenCount,
discoveredByCredential,
discoveredApiToken: !!discoveredApiToken,
};
}
+47
View File
@@ -108,6 +108,53 @@ describe('notifyService', () => {
).rejects.toThrow(/webhook|bark|Webhook 响应状态|Bark 响应状态/i);
});
it('sends enterprise wechat webhook payload as structured text message', async () => {
const { config } = await import('../config.js');
config.webhookEnabled = true;
config.webhookUrl = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=demo-key';
config.smtpEnabled = false;
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({ errcode: 0, errmsg: 'ok' }),
});
const { sendNotification } = await import('./notifyService.js');
await sendNotification('测试通知', 'message', 'info', { bypassThrottle: true, throwOnFailure: true });
expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0] as [string, { body?: string }];
expect(call[0]).toContain('qyapi.weixin.qq.com/cgi-bin/webhook/send');
const payload = JSON.parse(call[1]?.body || '{}') as { msgtype?: string; text?: { content?: string } };
expect(Array.isArray(payload)).toBe(false);
expect(payload.msgtype).toBe('text');
expect(payload.text?.content || '').toContain('[metapi][INFO] 测试通知');
expect(payload.text?.content || '').toContain('message');
});
it('fails when enterprise wechat webhook returns non-zero errcode', async () => {
const { config } = await import('../config.js');
config.webhookEnabled = true;
config.webhookUrl = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=demo-key';
config.smtpEnabled = false;
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({ errcode: 93000, errmsg: 'invalid json' }),
});
const { sendNotification } = await import('./notifyService.js');
await expect(
sendNotification('测试通知', 'message', 'info', {
bypassThrottle: true,
throwOnFailure: true,
}),
).rejects.toThrow(/企业微信|93000|invalid json/);
});
it('includes local time and utc time labels in smtp payload', async () => {
sendMailMock.mockResolvedValue({ accepted: ['receiver@example.com'] });
const { sendNotification } = await import('./notifyService.js');
+50 -8
View File
@@ -82,6 +82,27 @@ function buildTelegramText(
return `${raw.slice(0, maxTextLength)}\n\n...(truncated)`;
}
function isWeComBotWebhook(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.hostname === 'qyapi.weixin.qq.com' && parsed.pathname.includes('/cgi-bin/webhook/send');
} catch {
return false;
}
}
function buildWeComText(
title: string,
message: string,
level: 'info' | 'warning' | 'error',
timeFootnote: string,
): string {
const maxLength = 1900;
const raw = `[metapi][${level.toUpperCase()}] ${title}\n\n${message}\n\n${timeFootnote}`;
if (raw.length <= maxLength) return raw;
return `${raw.slice(0, maxLength)}\n...(truncated)`;
}
export async function sendNotification(
title: string,
message: string,
@@ -119,21 +140,42 @@ export async function sendNotification(
{
channel: 'webhook',
run: async () => {
const isWeComWebhook = isWeComBotWebhook(config.webhookUrl);
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(),
}),
body: JSON.stringify(
isWeComWebhook
? {
msgtype: 'text',
text: {
content: buildWeComText(title, resolvedMessage, level, timeFootnote),
},
}
: {
title,
message: resolvedMessage,
level,
timestamp: now.toISOString(),
localTime: formatLocalDateTime(now),
timeZone: getResolvedTimeZone(),
},
),
});
if (!response.ok) {
throw new Error(`Webhook 响应状态 ${response.status}`);
}
if (isWeComWebhook) {
let payload: { errcode?: number; errmsg?: string } | null = null;
try {
payload = await response.json() as { errcode?: number; errmsg?: string };
} catch {
throw new Error('企业微信 Webhook 返回了无效 JSON');
}
if (typeof payload?.errcode === 'number' && payload.errcode !== 0) {
throw new Error(`企业微信 Webhook 返回错误 ${payload.errcode}: ${payload.errmsg || 'unknown error'}`);
}
}
},
},
);
+2 -1
View File
@@ -41,6 +41,7 @@ export interface ApiTokenInfo {
name: string;
key: string;
enabled?: boolean;
tokenGroup?: string | null;
}
export interface CreateApiTokenOptions {
@@ -145,7 +146,7 @@ export abstract class BasePlatformAdapter implements PlatformAdapter {
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 }];
return [{ name: 'default', key: token, enabled: true, tokenGroup: 'default' }];
}
async createApiToken(
+9 -2
View File
@@ -176,12 +176,19 @@ export class NewApiAdapter extends BasePlatformAdapter {
const key = typeof item?.key === 'string' ? item.key.trim() : '';
if (!key) continue;
const rawName = typeof item?.name === 'string' ? item.name.trim() : '';
const rawGroup = typeof item?.group === 'string'
? item.group.trim()
: (typeof item?.group_name === 'string'
? item.group_name.trim()
: (typeof item?.token_group === 'string' ? item.token_group.trim() : ''));
const status = typeof item?.status === 'number' ? item.status : undefined;
normalized.push({
const tokenInfo: ApiTokenInfo = {
name: rawName || (index === 0 ? 'default' : `token-${index + 1}`),
key,
enabled: status === undefined ? true : status === 1,
});
};
if (rawGroup) tokenInfo.tokenGroup = rawGroup;
normalized.push(tokenInfo);
}
return normalized;
}
+7 -2
View File
@@ -104,12 +104,17 @@ export class OneApiAdapter extends BasePlatformAdapter {
const key = typeof item?.key === 'string' ? item.key.trim() : '';
if (!key) return null;
const rawName = typeof item?.name === 'string' ? item.name.trim() : '';
const rawGroup = typeof item?.group === 'string'
? item.group.trim()
: (typeof item?.token_group === 'string' ? item.token_group.trim() : '');
const status = typeof item?.status === 'number' ? item.status : undefined;
return {
const tokenInfo: ApiTokenInfo = {
name: rawName || (index === 0 ? 'default' : `token-${index + 1}`),
key,
enabled: status === undefined ? true : status === 1,
} satisfies ApiTokenInfo;
};
if (rawGroup) tokenInfo.tokenGroup = rawGroup;
return tokenInfo;
})
.filter((item: ApiTokenInfo | null): item is ApiTokenInfo => !!item);
} catch {
+187 -5
View File
@@ -154,6 +154,46 @@ describe('Sub2ApiAdapter', () => {
expect(models).toEqual(['gpt-4o', 'claude-3-opus']);
});
it('fetches models via api key discovered from /api/v1/keys when JWT cannot call /v1/models directly', async () => {
await startServer((req, res) => {
const auth = req.headers.authorization || '';
if (req.url === '/v1/models' && auth === 'Bearer jwt-token') {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 'API_KEY_REQUIRED',
message: 'API key is required',
}));
return;
}
if (req.url === '/api/v1/keys?page=1&page_size=100' && auth === 'Bearer jwt-token') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 0,
message: 'success',
data: {
items: [
{ id: 1, key: 'sk-sub2-active', name: 'default', status: 'active' },
{ id: 2, key: 'sk-sub2-disabled', name: 'old', status: 'inactive' },
],
},
}));
return;
}
if (req.url === '/v1/models' && auth === 'Bearer sk-sub2-active') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
object: 'list',
data: [{ id: 'gpt-4o-mini' }, { id: 'claude-3-5-sonnet' }],
}));
return;
}
res.writeHead(404).end();
});
const models = await adapter.getModels(baseUrl, 'jwt-token');
expect(models).toEqual(['gpt-4o-mini', 'claude-3-5-sonnet']);
});
it('handles non-zero code as error in /api/v1/auth/me', async () => {
await startServer((req, res) => {
if (req.url === '/api/v1/auth/me') {
@@ -176,10 +216,152 @@ describe('Sub2ApiAdapter', () => {
expect(result.success).toBe(false);
});
it('token management returns empty/false', async () => {
expect(await adapter.getApiToken('http://localhost', 'token')).toBeNull();
expect(await adapter.getApiTokens('http://localhost', 'token')).toEqual([]);
expect(await adapter.createApiToken('http://localhost', 'token')).toBe(false);
expect(await adapter.deleteApiToken('http://localhost', 'token', 'key')).toBe(false);
it('lists api keys from /api/v1/keys and picks active key as default api token', async () => {
await startServer((req, res) => {
if (req.url === '/api/v1/keys?page=1&page_size=100') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 0,
message: 'success',
data: {
items: [
{ id: 10, key: 'sk-disabled', name: 'old', status: 'inactive' },
{ id: 11, key: 'sk-active', name: 'default', status: 'active' },
],
},
}));
return;
}
res.writeHead(404).end();
});
const tokens = await adapter.getApiTokens(baseUrl, 'jwt-token');
expect(tokens).toEqual([
{ key: 'sk-disabled', name: 'old', enabled: false },
{ key: 'sk-active', name: 'default', enabled: true },
]);
expect(await adapter.getApiToken(baseUrl, 'jwt-token')).toBe('sk-active');
});
it('fetches user groups from /api/v1/groups', async () => {
await startServer((req, res) => {
if (req.url === '/api/v1/groups?page=1&page_size=100') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 0,
message: 'success',
data: {
items: [
{ id: 1, name: 'default' },
{ id: 2, name: 'vip' },
],
},
}));
return;
}
res.writeHead(404).end();
});
const groups = await adapter.getUserGroups(baseUrl, 'jwt-token');
expect(groups).toEqual(['1', '2']);
});
it('falls back to infer groups from /api/v1/keys when group endpoint is unavailable', async () => {
await startServer((req, res) => {
if (req.url === '/api/v1/groups?page=1&page_size=100') {
res.writeHead(404).end();
return;
}
if (req.url === '/api/v1/groups') {
res.writeHead(404).end();
return;
}
if (req.url === '/api/v1/group?page=1&page_size=100') {
res.writeHead(404).end();
return;
}
if (req.url === '/api/v1/group') {
res.writeHead(404).end();
return;
}
if (req.url === '/api/v1/keys?page=1&page_size=100') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 0,
message: 'success',
data: {
items: [
{ id: 11, key: 'sk-1', group_id: 7, status: 'active' },
{ id: 12, key: 'sk-2', group_id: 7, status: 'inactive' },
{ id: 13, key: 'sk-3', group_id: 9, status: 'active' },
],
},
}));
return;
}
res.writeHead(404).end();
});
const groups = await adapter.getUserGroups(baseUrl, 'jwt-token');
expect(groups).toEqual(['7', '9']);
});
it('creates api key via /api/v1/keys', async () => {
await startServer((req, res) => {
if (req.url === '/api/v1/keys' && req.method === 'POST') {
let rawBody = '';
req.on('data', (chunk) => { rawBody += chunk; });
req.on('end', () => {
const body = JSON.parse(rawBody || '{}');
expect(body.name).toBe('metapi-e2e');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 0,
message: 'success',
data: {
id: 1,
key: 'sk-created',
name: body.name,
},
}));
});
return;
}
res.writeHead(404).end();
});
const created = await adapter.createApiToken(baseUrl, 'jwt-token', undefined, { name: 'metapi-e2e' });
expect(created).toBe(true);
});
it('deletes api key by key value via /api/v1/keys/:id', async () => {
await startServer((req, res) => {
if (req.url === '/api/v1/keys?page=1&page_size=100') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 0,
message: 'success',
data: {
items: [
{ id: 31, key: 'sk-delete-me', name: 'to-delete', status: 'active' },
],
},
}));
return;
}
if (req.url === '/api/v1/keys/31' && req.method === 'DELETE') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 0,
message: 'success',
data: { id: 31 },
}));
return;
}
res.writeHead(404).end();
});
const deleted = await adapter.deleteApiToken(baseUrl, 'jwt-token', 'sk-delete-me');
expect(deleted).toBe(true);
});
});
+383 -5
View File
@@ -1,4 +1,11 @@
import { BasePlatformAdapter, CheckinResult, BalanceInfo, UserInfo } from './base.js';
import {
ApiTokenInfo,
BasePlatformAdapter,
CheckinResult,
BalanceInfo,
CreateApiTokenOptions,
UserInfo,
} from './base.js';
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
@@ -8,12 +15,268 @@ function normalizeBaseUrl(baseUrl: string): string {
* Sub2API adapter.
*
* Sub2API uses JWT-based auth with endpoints under /api/v1/*.
* It does NOT support: login, check-in, token management, or usage stats.
* It does NOT support: login or check-in.
* Balance is derived from a USD amount returned by /api/v1/auth/me.
*/
export class Sub2ApiAdapter extends BasePlatformAdapter {
readonly platformName = 'sub2api';
private normalizeTokenKeyForCompare(value?: string | null): string {
const trimmed = (value || '').trim();
return trimmed.startsWith('Bearer ') ? trimmed.slice(7).trim() : trimmed;
}
private parseTokenEnabled(status: unknown): boolean {
if (typeof status === 'boolean') return status;
if (typeof status === 'number') return status === 1;
if (typeof status !== 'string') return true;
const normalized = status.trim().toLowerCase();
if (!normalized) return true;
if (['inactive', 'disabled', 'false', '0', 'off'].includes(normalized)) return false;
if (['active', 'enabled', 'true', '1', 'on'].includes(normalized)) return true;
return true;
}
private parseTokenItems(payload: any): Array<{ id: number; key: string; name: string; enabled: boolean; tokenGroup: string | null }> {
const source = payload?.data ?? payload;
const rawItems = (() => {
if (Array.isArray(source)) return source;
if (Array.isArray(source?.items)) return source.items;
if (Array.isArray(source?.list)) return source.list;
if (Array.isArray(source?.data)) return source.data;
return [];
})();
const items: Array<{ id: number; key: string; name: string; enabled: boolean; tokenGroup: string | null }> = [];
for (const item of rawItems) {
const key = typeof item?.key === 'string' ? item.key.trim() : '';
if (!key) continue;
const id = Number.parseInt(String(item?.id), 10);
if (!Number.isFinite(id) || id <= 0) continue;
const name = typeof item?.name === 'string' && item.name.trim()
? item.name.trim()
: `token-${id}`;
const tokenGroup = (() => {
const fromNumeric = Number.parseInt(String(item?.group_id ?? item?.groupId ?? ''), 10);
if (Number.isFinite(fromNumeric) && fromNumeric > 0) return String(fromNumeric);
const fromText = typeof item?.group_name === 'string'
? item.group_name.trim()
: (typeof item?.group === 'string' ? item.group.trim() : '');
return fromText || null;
})();
items.push({
id,
key,
name,
enabled: this.parseTokenEnabled(item?.status),
tokenGroup,
});
}
return items;
}
private parseGroupItems(payload: any): string[] {
const source = payload?.data ?? payload;
const rawItems = (() => {
if (Array.isArray(source)) return source;
if (Array.isArray(source?.items)) return source.items;
if (Array.isArray(source?.list)) return source.list;
if (Array.isArray(source?.groups)) return source.groups;
if (Array.isArray(source?.data)) return source.data;
return [];
})();
const groups: string[] = [];
for (const item of rawItems) {
if (item == null) continue;
if (typeof item === 'number' && Number.isFinite(item) && item > 0) {
groups.push(String(Math.trunc(item)));
continue;
}
if (typeof item === 'string') {
const normalized = item.trim();
if (normalized) groups.push(normalized);
continue;
}
if (typeof item !== 'object') continue;
const numericCandidates = [
(item as any).group_id,
(item as any).groupId,
(item as any).id,
(item as any).value,
];
let picked = '';
for (const candidate of numericCandidates) {
const parsed = Number.parseInt(String(candidate), 10);
if (Number.isFinite(parsed) && parsed > 0) {
picked = String(parsed);
break;
}
}
if (picked) {
groups.push(picked);
continue;
}
const textCandidates = [
(item as any).name,
(item as any).group_name,
(item as any).groupName,
(item as any).title,
(item as any).label,
(item as any).code,
];
for (const candidate of textCandidates) {
if (typeof candidate !== 'string') continue;
const normalized = candidate.trim();
if (!normalized) continue;
groups.push(normalized);
break;
}
}
return Array.from(new Set(groups));
}
private async listGroups(baseUrl: string, accessToken: string): Promise<string[]> {
const endpoints = [
'/api/v1/groups?page=1&page_size=100',
'/api/v1/groups',
'/api/v1/group?page=1&page_size=100',
'/api/v1/group',
];
for (const endpoint of endpoints) {
try {
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const parsed = (() => {
try {
return this.parseSub2ApiEnvelope<any>(res, endpoint);
} catch {
return res;
}
})();
const groups = this.parseGroupItems(parsed);
if (groups.length > 0) return groups;
} catch {}
}
return [];
}
private parseGroupIdsFromTokenPayload(payload: any): string[] {
const source = payload?.data ?? payload;
const rawItems = (() => {
if (Array.isArray(source)) return source;
if (Array.isArray(source?.items)) return source.items;
if (Array.isArray(source?.list)) return source.list;
if (Array.isArray(source?.data)) return source.data;
return [];
})();
const groups: string[] = [];
for (const item of rawItems) {
if (!item || typeof item !== 'object') continue;
const groupId = Number.parseInt(String((item as any).group_id ?? (item as any).groupId ?? ''), 10);
if (!Number.isFinite(groupId) || groupId <= 0) continue;
groups.push(String(groupId));
}
return Array.from(new Set(groups));
}
private async inferGroupsFromKeys(baseUrl: string, accessToken: string): Promise<string[]> {
const endpoints = [
'/api/v1/keys?page=1&page_size=100',
'/api/v1/api-keys?page=1&page_size=100',
];
for (const endpoint of endpoints) {
try {
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const parsed = (() => {
try {
return this.parseSub2ApiEnvelope<any>(res, endpoint);
} catch {
return res;
}
})();
const groups = this.parseGroupIdsFromTokenPayload(parsed);
if (groups.length > 0) return groups;
} catch {}
}
return [];
}
private extractModelIds(payload: any): string[] {
const source = payload?.data ?? payload;
const rawModels = (() => {
if (Array.isArray(source)) return source;
if (Array.isArray(source?.items)) return source.items;
if (Array.isArray(source?.models)) return source.models;
return [];
})();
const models = rawModels
.map((item: any) => (typeof item === 'string' ? item : item?.id))
.map((value: unknown) => String(value || '').trim())
.filter(Boolean);
return Array.from(new Set(models));
}
private async listApiKeys(baseUrl: string, accessToken: string): Promise<Array<{ id: number; key: string; name: string; enabled: boolean; tokenGroup: string | null }>> {
const endpoints = [
'/api/v1/keys?page=1&page_size=100',
'/api/v1/api-keys?page=1&page_size=100',
];
for (const endpoint of endpoints) {
try {
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const data = this.parseSub2ApiEnvelope<any>(res, endpoint);
const items = this.parseTokenItems(data);
if (items.length > 0) return items;
} catch {}
}
return [];
}
private async fetchModelsByToken(baseUrl: string, token: string): Promise<string[]> {
const authToken = this.normalizeTokenKeyForCompare(token);
if (!authToken) return [];
const endpoints = ['/v1/models', '/api/v1/models'];
for (const endpoint of endpoints) {
try {
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, {
headers: { Authorization: `Bearer ${authToken}` },
});
const models = this.extractModelIds(res);
if (models.length > 0) return models;
} catch {}
}
return [];
}
private resolveExpiresInDays(expiredTime?: number): number | undefined {
if (!Number.isFinite(expiredTime)) return undefined;
const raw = Math.trunc(expiredTime as number);
if (raw <= 0) return undefined;
const expiresAtMs = raw > 10_000_000_000 ? raw : raw * 1000;
const deltaMs = expiresAtMs - Date.now();
const days = Math.max(1, Math.ceil(deltaMs / (24 * 60 * 60 * 1000)));
return Number.isFinite(days) ? Math.min(days, 3650) : undefined;
}
async detect(url: string): Promise<boolean> {
const normalized = (url || '').toLowerCase();
if (normalized.includes('sub2api')) return true;
@@ -196,13 +459,128 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
// --- Models: Standard OpenAI-compatible endpoint ---
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
const normalizedBase = normalizeBaseUrl(baseUrl);
const directModels = await this.fetchModelsByToken(normalizedBase, apiToken);
if (directModels.length > 0) return directModels;
// Session JWT cannot access /v1/models directly; discover a user key first.
const discoveredApiToken = await this.getApiToken(normalizedBase, apiToken);
if (!discoveredApiToken) return [];
if (this.normalizeTokenKeyForCompare(discoveredApiToken) === this.normalizeTokenKeyForCompare(apiToken)) {
return [];
}
return this.fetchModelsByToken(normalizedBase, discoveredApiToken);
}
override async getApiTokens(baseUrl: string, accessToken: string): Promise<ApiTokenInfo[]> {
try {
const res = await this.fetchJson<any>(`${baseUrl}/v1/models`, {
headers: { Authorization: `Bearer ${apiToken}` },
const keys = await this.listApiKeys(normalizeBaseUrl(baseUrl), accessToken);
return keys.map((item) => {
const tokenInfo: ApiTokenInfo = {
name: item.name,
key: item.key,
enabled: item.enabled,
};
if (item.tokenGroup) tokenInfo.tokenGroup = item.tokenGroup;
return tokenInfo;
});
return (res?.data || []).map((m: any) => m.id).filter(Boolean);
} catch {
return [];
}
}
override 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;
}
override async getUserGroups(baseUrl: string, accessToken: string): Promise<string[]> {
const normalizedBase = normalizeBaseUrl(baseUrl);
const directGroups = await this.listGroups(normalizedBase, accessToken);
if (directGroups.length > 0) return directGroups;
const inferredFromKeys = await this.inferGroupsFromKeys(normalizedBase, accessToken);
if (inferredFromKeys.length > 0) return inferredFromKeys;
return ['default'];
}
override async createApiToken(
baseUrl: string,
accessToken: string,
_platformUserId?: number,
options?: CreateApiTokenOptions,
): Promise<boolean> {
const normalizedBase = normalizeBaseUrl(baseUrl);
const payload: Record<string, unknown> = {
name: (options?.name || '').trim() || 'metapi',
};
const groupId = Number.parseInt((options?.group || '').trim(), 10);
if (Number.isFinite(groupId) && groupId > 0) {
payload.group_id = groupId;
}
const expiresInDays = this.resolveExpiresInDays(options?.expiredTime);
if (expiresInDays) {
payload.expires_in_days = expiresInDays;
}
if (options?.unlimitedQuota === false && Number.isFinite(options.remainQuota)) {
payload.quota = Math.max(0, Number(options.remainQuota));
}
const endpoints = ['/api/v1/keys', '/api/v1/api-keys'];
for (const endpoint of endpoints) {
try {
const res = await this.fetchJson<any>(`${normalizedBase}${endpoint}`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
body: JSON.stringify(payload),
});
this.parseSub2ApiEnvelope<any>(res, endpoint);
return true;
} catch {}
}
return false;
}
override async deleteApiToken(
baseUrl: string,
accessToken: string,
tokenKey: string,
): Promise<boolean> {
const targetKey = this.normalizeTokenKeyForCompare(tokenKey);
if (!targetKey) return false;
const normalizedBase = normalizeBaseUrl(baseUrl);
let tokenId: number | null = null;
try {
const items = await this.listApiKeys(normalizedBase, accessToken);
tokenId = items.find((item) => this.normalizeTokenKeyForCompare(item.key) === targetKey)?.id || null;
} catch {
return false;
}
// Upstream key already absent means local deletion is safe.
if (!tokenId) return true;
const endpoints = [
`/api/v1/keys/${tokenId}`,
`/api/v1/api-keys/${tokenId}`,
];
for (const endpoint of endpoints) {
try {
const res = await this.fetchJson<any>(`${normalizedBase}${endpoint}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
});
this.parseSub2ApiEnvelope<any>(res, endpoint);
return true;
} catch {}
}
return false;
}
}
@@ -179,6 +179,90 @@ describe('TokenRouter downstream policy', () => {
expect((highCandidate?.probability || 0)).toBeGreaterThan(lowCandidate?.probability || 0);
});
it('combines site global weight with downstream site multiplier', () => {
const siteGlobalHigh = db.insert(schema.sites).values({
name: 'global-high-site',
url: 'https://global-high.example.com',
platform: 'new-api',
status: 'active',
globalWeight: 3,
}).returning().get();
const siteGlobalLow = db.insert(schema.sites).values({
name: 'global-low-site',
url: 'https://global-low.example.com',
platform: 'new-api',
status: 'active',
globalWeight: 1,
}).returning().get();
const accountGlobalHigh = db.insert(schema.accounts).values({
siteId: siteGlobalHigh.id,
username: 'user-global-high',
accessToken: 'access-global-high',
apiToken: 'sk-global-high',
status: 'active',
unitCost: 1,
balance: 100,
}).returning().get();
const accountGlobalLow = db.insert(schema.accounts).values({
siteId: siteGlobalLow.id,
username: 'user-global-low',
accessToken: 'access-global-low',
apiToken: 'sk-global-low',
status: 'active',
unitCost: 1,
balance: 100,
}).returning().get();
const route = db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5-mini',
enabled: true,
}).returning().get();
const highChannel = db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountGlobalHigh.id,
tokenId: null,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const lowChannel = db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountGlobalLow.id,
tokenId: null,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
const decision = router.explainSelectionForRoute(
route.id,
'gpt-5-mini',
[],
{
allowedRouteIds: [route.id],
supportedModels: [],
siteWeightMultipliers: {
[siteGlobalHigh.id]: 0.5,
[siteGlobalLow.id]: 1,
},
},
);
const highCandidate = decision.candidates.find((candidate) => candidate.channelId === highChannel.id);
const lowCandidate = decision.candidates.find((candidate) => candidate.channelId === lowChannel.id);
expect(highCandidate).toBeTruthy();
expect(lowCandidate).toBeTruthy();
// combined multiplier: high=3*0.5=1.5, low=1*1=1
expect((highCandidate?.probability || 0)).toBeGreaterThan(lowCandidate?.probability || 0);
});
it('supports union semantics between supportedModels and allowedRouteIds', () => {
const site = db.insert(schema.sites).values({
name: 'site-union',
+23 -5
View File
@@ -816,9 +816,18 @@ export class TokenRouter {
const contributions = candidates.map((candidate, i) => {
const siteChannels = Math.max(1, siteChannelCounts.get(candidate.site.id) || 1);
let contribution = baseContributions[i] / siteChannels;
const siteMultiplier = downstreamPolicy.siteWeightMultipliers[candidate.site.id] ?? 1;
if (siteMultiplier > 0 && Number.isFinite(siteMultiplier)) {
contribution *= siteMultiplier;
const downstreamSiteMultiplier = downstreamPolicy.siteWeightMultipliers[candidate.site.id] ?? 1;
const normalizedDownstreamSiteMultiplier =
(Number.isFinite(downstreamSiteMultiplier) && downstreamSiteMultiplier > 0)
? downstreamSiteMultiplier
: 1;
const siteGlobalWeight =
(Number.isFinite(candidate.site.globalWeight) && (candidate.site.globalWeight || 0) > 0)
? (candidate.site.globalWeight as number)
: 1;
const combinedSiteWeight = siteGlobalWeight * normalizedDownstreamSiteMultiplier;
if (combinedSiteWeight > 0 && Number.isFinite(combinedSiteWeight)) {
contribution *= combinedSiteWeight;
}
// If upstream price is unknown and we are using fallback unit cost,
@@ -839,11 +848,20 @@ export class TokenRouter {
? '实测'
: (cost?.source === 'configured' ? '配置' : (cost?.source === 'catalog' ? '目录' : '默认'));
const siteChannels = Math.max(1, siteChannelCounts.get(candidate.site.id) || 1);
const siteMultiplier = downstreamPolicy.siteWeightMultipliers[candidate.site.id] ?? 1;
const downstreamSiteMultiplier = downstreamPolicy.siteWeightMultipliers[candidate.site.id] ?? 1;
const normalizedDownstreamSiteMultiplier =
(Number.isFinite(downstreamSiteMultiplier) && downstreamSiteMultiplier > 0)
? downstreamSiteMultiplier
: 1;
const siteGlobalWeight =
(Number.isFinite(candidate.site.globalWeight) && (candidate.site.globalWeight || 0) > 0)
? (candidate.site.globalWeight as number)
: 1;
const combinedSiteWeight = siteGlobalWeight * normalizedDownstreamSiteMultiplier;
return {
candidate,
probability,
reason: `按权重随机(W=${weight},成本=${costSourceText}:${(cost?.unitCost || 1).toFixed(6)},站点倍率=${siteMultiplier.toFixed(2)},同站点通道=${siteChannels},概率≈${(probability * 100).toFixed(1)}%)`,
reason: `按权重随机(W=${weight},成本=${costSourceText}:${(cost?.unitCost || 1).toFixed(6)},站点权重=${siteGlobalWeight.toFixed(2)}x下游倍率=${normalizedDownstreamSiteMultiplier.toFixed(2)}=${combinedSiteWeight.toFixed(2)},同站点通道=${siteChannels},概率≈${(probability * 100).toFixed(1)}%)`,
};
});