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:
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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>({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(' ');
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user