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
+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,