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:
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getCredentialModeFromExtraConfig,
|
||||
getPlatformUserIdFromExtraConfig,
|
||||
getSub2ApiAuthFromExtraConfig,
|
||||
guessPlatformUserIdFromUsername,
|
||||
mergeAccountExtraConfig,
|
||||
normalizeCredentialMode,
|
||||
resolvePlatformUserId,
|
||||
} from './accountExtraConfig.js';
|
||||
|
||||
@@ -38,4 +41,30 @@ describe('accountExtraConfig', () => {
|
||||
expect(parsed.autoRelogin?.username).toBe('demo');
|
||||
expect(parsed.platformUserId).toBe(7659);
|
||||
});
|
||||
|
||||
it('parses credential mode from extra config', () => {
|
||||
expect(getCredentialModeFromExtraConfig(JSON.stringify({ credentialMode: 'apikey' }))).toBe('apikey');
|
||||
expect(getCredentialModeFromExtraConfig(JSON.stringify({ credentialMode: 'session' }))).toBe('session');
|
||||
expect(getCredentialModeFromExtraConfig(JSON.stringify({ credentialMode: 'AUTO' }))).toBe('auto');
|
||||
expect(getCredentialModeFromExtraConfig(JSON.stringify({ credentialMode: 'unknown' }))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('normalizes credential mode input', () => {
|
||||
expect(normalizeCredentialMode(' apikey ')).toBe('apikey');
|
||||
expect(normalizeCredentialMode('session')).toBe('session');
|
||||
expect(normalizeCredentialMode('AUTO')).toBe('auto');
|
||||
expect(normalizeCredentialMode('abc')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses managed sub2api refresh token config from extra config', () => {
|
||||
expect(getSub2ApiAuthFromExtraConfig(JSON.stringify({
|
||||
sub2apiAuth: { refreshToken: 'refresh-1', tokenExpiresAt: 1760000000000 },
|
||||
}))).toEqual({
|
||||
refreshToken: 'refresh-1',
|
||||
tokenExpiresAt: 1760000000000,
|
||||
});
|
||||
expect(getSub2ApiAuthFromExtraConfig(JSON.stringify({
|
||||
sub2apiAuth: { refreshToken: ' ' },
|
||||
}))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,24 @@ type AutoReloginConfig = {
|
||||
updatedAt?: unknown;
|
||||
};
|
||||
|
||||
type Sub2ApiAuthConfig = {
|
||||
refreshToken?: unknown;
|
||||
tokenExpiresAt?: unknown;
|
||||
};
|
||||
|
||||
export type AccountCredentialMode = 'auto' | 'session' | 'apikey';
|
||||
|
||||
const VALID_CREDENTIAL_MODES = new Set<AccountCredentialMode>([
|
||||
'auto',
|
||||
'session',
|
||||
'apikey',
|
||||
]);
|
||||
|
||||
type AccountExtraConfig = {
|
||||
platformUserId?: unknown;
|
||||
credentialMode?: unknown;
|
||||
autoRelogin?: AutoReloginConfig;
|
||||
sub2apiAuth?: Sub2ApiAuthConfig;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -30,11 +45,55 @@ function normalizeUserId(raw: unknown): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeNonEmptyString(raw: unknown): string | undefined {
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
const trimmed = raw.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizeTimestampMs(raw: unknown): number | undefined {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) return Math.trunc(raw);
|
||||
if (typeof raw === 'string') {
|
||||
const parsed = Number.parseInt(raw.trim(), 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function normalizeCredentialMode(raw: unknown): AccountCredentialMode | undefined {
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (!VALID_CREDENTIAL_MODES.has(normalized as AccountCredentialMode)) return undefined;
|
||||
return normalized as AccountCredentialMode;
|
||||
}
|
||||
|
||||
export function getPlatformUserIdFromExtraConfig(extraConfig?: string | null): number | undefined {
|
||||
const parsed = parseExtraConfig(extraConfig);
|
||||
return normalizeUserId(parsed.platformUserId);
|
||||
}
|
||||
|
||||
export function getCredentialModeFromExtraConfig(extraConfig?: string | null): AccountCredentialMode | undefined {
|
||||
const parsed = parseExtraConfig(extraConfig);
|
||||
return normalizeCredentialMode(parsed.credentialMode);
|
||||
}
|
||||
|
||||
export type ManagedSub2ApiAuth = {
|
||||
refreshToken: string;
|
||||
tokenExpiresAt?: number;
|
||||
};
|
||||
|
||||
export function getSub2ApiAuthFromExtraConfig(extraConfig?: string | null): ManagedSub2ApiAuth | null {
|
||||
const parsed = parseExtraConfig(extraConfig);
|
||||
const raw = parsed.sub2apiAuth;
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const refreshToken = normalizeNonEmptyString(raw.refreshToken);
|
||||
if (!refreshToken) return null;
|
||||
const tokenExpiresAt = normalizeTimestampMs(raw.tokenExpiresAt);
|
||||
return tokenExpiresAt
|
||||
? { refreshToken, tokenExpiresAt }
|
||||
: { refreshToken };
|
||||
}
|
||||
|
||||
export function guessPlatformUserIdFromUsername(username?: string | null): number | undefined {
|
||||
const text = (username || '').trim();
|
||||
if (!text) return undefined;
|
||||
|
||||
@@ -5,6 +5,7 @@ type UpstreamApiToken = {
|
||||
name?: string | null;
|
||||
key?: string | null;
|
||||
enabled?: boolean | null;
|
||||
tokenGroup?: string | null;
|
||||
};
|
||||
|
||||
export function normalizeTokenForDisplay(token?: string | null, platform?: string | null): string {
|
||||
@@ -44,6 +45,20 @@ function normalizeTokenValue(token: string | null | undefined): string | null {
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeTokenGroup(value: string | null | undefined, tokenName?: string | null): string | null {
|
||||
const explicit = (value || '').trim();
|
||||
if (explicit.length > 0) return explicit;
|
||||
|
||||
const name = (tokenName || '').trim();
|
||||
if (!name) return null;
|
||||
const normalized = name.toLowerCase();
|
||||
if (normalized === 'default' || normalized === '默认' || /^default($|[-_\s])/.test(normalized)) {
|
||||
return 'default';
|
||||
}
|
||||
if (/^token-\d+$/.test(normalized)) return null;
|
||||
return name;
|
||||
}
|
||||
|
||||
function updateAccountApiToken(accountId: number, tokenValue: string | null) {
|
||||
db.update(schema.accounts)
|
||||
.set({ apiToken: tokenValue || null, updatedAt: new Date().toISOString() })
|
||||
@@ -66,10 +81,11 @@ export function getPreferredAccountToken(accountId: number) {
|
||||
export function ensureDefaultTokenForAccount(
|
||||
accountId: number,
|
||||
tokenValue: string,
|
||||
options?: { name?: string; source?: string; enabled?: boolean },
|
||||
options?: { name?: string; source?: string; enabled?: boolean; tokenGroup?: string | null },
|
||||
): number | null {
|
||||
const normalizedToken = normalizeTokenValue(tokenValue);
|
||||
if (!normalizedToken) return null;
|
||||
const tokenGroup = normalizeTokenGroup(options?.tokenGroup, options?.name) || 'default';
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const tokens = db.select()
|
||||
@@ -84,6 +100,7 @@ export function ensureDefaultTokenForAccount(
|
||||
accountId,
|
||||
name: normalizeTokenName(options?.name, tokens.length + 1),
|
||||
token: normalizedToken,
|
||||
tokenGroup,
|
||||
source: options?.source || 'manual',
|
||||
enabled: options?.enabled ?? true,
|
||||
isDefault: true,
|
||||
@@ -96,6 +113,7 @@ export function ensureDefaultTokenForAccount(
|
||||
db.update(schema.accountTokens)
|
||||
.set({
|
||||
name: options?.name ? normalizeTokenName(options.name) : target.name,
|
||||
tokenGroup,
|
||||
source: options?.source || target.source || 'manual',
|
||||
enabled: options?.enabled ?? target.enabled,
|
||||
isDefault: true,
|
||||
@@ -179,12 +197,14 @@ export function syncTokensFromUpstream(accountId: number, upstreamTokens: Upstre
|
||||
|
||||
const tokenName = normalizeTokenName(upstream.name, index);
|
||||
const enabled = upstream.enabled ?? true;
|
||||
const tokenGroup = normalizeTokenGroup(upstream.tokenGroup, tokenName);
|
||||
|
||||
const byToken = existing.find((row) => row.token === tokenValue);
|
||||
if (byToken) {
|
||||
db.update(schema.accountTokens)
|
||||
.set({
|
||||
name: tokenName,
|
||||
tokenGroup,
|
||||
source: 'sync',
|
||||
enabled,
|
||||
updatedAt: now,
|
||||
@@ -192,6 +212,7 @@ export function syncTokensFromUpstream(accountId: number, upstreamTokens: Upstre
|
||||
.where(eq(schema.accountTokens.id, byToken.id))
|
||||
.run();
|
||||
byToken.name = tokenName;
|
||||
byToken.tokenGroup = tokenGroup;
|
||||
byToken.enabled = enabled;
|
||||
byToken.source = 'sync';
|
||||
byToken.updatedAt = now;
|
||||
@@ -204,6 +225,7 @@ export function syncTokensFromUpstream(accountId: number, upstreamTokens: Upstre
|
||||
accountId,
|
||||
name: tokenName,
|
||||
token: tokenValue,
|
||||
tokenGroup,
|
||||
source: 'sync',
|
||||
enabled,
|
||||
isDefault: false,
|
||||
|
||||
@@ -159,11 +159,13 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
|
||||
id: siteId,
|
||||
name: siteName,
|
||||
url: siteUrl,
|
||||
externalCheckinUrl: null,
|
||||
platform,
|
||||
proxyUrl: null,
|
||||
status: 'active',
|
||||
isPinned: false,
|
||||
sortOrder: sites.length,
|
||||
globalWeight: 1,
|
||||
apiKey: null,
|
||||
createdAt: toIsoString(item.created_at),
|
||||
updatedAt: toIsoString(item.updated_at),
|
||||
@@ -232,6 +234,7 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
|
||||
accountId,
|
||||
name: 'default',
|
||||
token: apiToken,
|
||||
tokenGroup: 'default',
|
||||
source: 'legacy',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
@@ -465,6 +468,7 @@ function importAccountsSection(section: AccountsBackupSection) {
|
||||
status: row.status || 'active',
|
||||
isPinned: row.isPinned ?? false,
|
||||
sortOrder: row.sortOrder ?? 0,
|
||||
globalWeight: row.globalWeight ?? 1,
|
||||
apiKey: row.apiKey,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
@@ -501,6 +505,7 @@ function importAccountsSection(section: AccountsBackupSection) {
|
||||
accountId: row.accountId,
|
||||
name: row.name,
|
||||
token: row.token,
|
||||
tokenGroup: row.tokenGroup ?? null,
|
||||
source: row.source,
|
||||
enabled: row.enabled,
|
||||
isDefault: row.isDefault,
|
||||
|
||||
@@ -172,6 +172,113 @@ describe('balanceService auto relogin', () => {
|
||||
expect(reportTokenExpiredMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('proactively refreshes sub2api token when managed refresh token is near expiry', async () => {
|
||||
selectAllMock.mockReturnValue([
|
||||
{
|
||||
accounts: {
|
||||
id: 5,
|
||||
username: 'sub2-user',
|
||||
accessToken: 'old-access-token',
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({
|
||||
sub2apiAuth: {
|
||||
refreshToken: 'refresh-token-1',
|
||||
tokenExpiresAt: Date.now() + 60_000,
|
||||
},
|
||||
}),
|
||||
},
|
||||
sites: {
|
||||
id: 7,
|
||||
name: 'sub2',
|
||||
url: 'https://sub2.example.com',
|
||||
platform: 'sub2api',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
undiciFetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'refresh-token-2',
|
||||
expires_in: 3600,
|
||||
},
|
||||
}),
|
||||
});
|
||||
adapterMock.getBalance.mockResolvedValueOnce({ balance: 20, used: 1, quota: 21 });
|
||||
|
||||
const { refreshBalance } = await import('./balanceService.js');
|
||||
const result = await refreshBalance(5);
|
||||
|
||||
expect(result).toEqual({ balance: 20, used: 1, quota: 21 });
|
||||
expect(adapterMock.getBalance).toHaveBeenCalledTimes(1);
|
||||
expect(adapterMock.getBalance.mock.calls[0]?.[1]).toBe('new-access-token');
|
||||
expect(updateSetMock.mock.calls.some((call) => call[0]?.accessToken === 'new-access-token')).toBe(true);
|
||||
const updateWithSub2ApiAuth = updateSetMock.mock.calls
|
||||
.map((call) => call[0] as Record<string, unknown>)
|
||||
.find((payload) => typeof payload.extraConfig === 'string' && String(payload.extraConfig).includes('refresh-token-2'));
|
||||
expect(updateWithSub2ApiAuth).toBeDefined();
|
||||
const parsedExtra = JSON.parse(String(updateWithSub2ApiAuth?.extraConfig)) as {
|
||||
sub2apiAuth?: { refreshToken?: string; tokenExpiresAt?: number };
|
||||
};
|
||||
expect(parsedExtra.sub2apiAuth?.refreshToken).toBe('refresh-token-2');
|
||||
expect(typeof parsedExtra.sub2apiAuth?.tokenExpiresAt).toBe('number');
|
||||
});
|
||||
|
||||
it('retries once via sub2api managed refresh token when balance returns 401', async () => {
|
||||
selectAllMock.mockReturnValue([
|
||||
{
|
||||
accounts: {
|
||||
id: 6,
|
||||
username: 'sub2-user',
|
||||
accessToken: 'expired-access-token',
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({
|
||||
sub2apiAuth: {
|
||||
refreshToken: 'refresh-token-3',
|
||||
},
|
||||
}),
|
||||
},
|
||||
sites: {
|
||||
id: 8,
|
||||
name: 'sub2',
|
||||
url: 'https://sub2.example.com',
|
||||
platform: 'sub2api',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
adapterMock.getBalance
|
||||
.mockRejectedValueOnce(new Error('HTTP 401: unauthorized'))
|
||||
.mockResolvedValueOnce({ balance: 8, used: 1, quota: 9 });
|
||||
adapterMock.login.mockResolvedValue({ success: false });
|
||||
undiciFetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
access_token: 'retried-access-token',
|
||||
refresh_token: 'refresh-token-4',
|
||||
expires_in: 3600,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const { refreshBalance } = await import('./balanceService.js');
|
||||
const result = await refreshBalance(6);
|
||||
|
||||
expect(result).toEqual({ balance: 8, used: 1, quota: 9 });
|
||||
expect(adapterMock.getBalance).toHaveBeenCalledTimes(2);
|
||||
expect(adapterMock.getBalance.mock.calls[0]?.[1]).toBe('expired-access-token');
|
||||
expect(adapterMock.getBalance.mock.calls[1]?.[1]).toBe('retried-access-token');
|
||||
expect(adapterMock.login).not.toHaveBeenCalled();
|
||||
expect(reportTokenExpiredMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps degraded health when checkin is unsupported but balance refresh succeeds', async () => {
|
||||
selectAllMock.mockReturnValue([
|
||||
{
|
||||
@@ -264,4 +371,33 @@ describe('balanceService auto relogin', () => {
|
||||
const parsedExtra = JSON.parse(String(updateWithSnapshot?.extraConfig));
|
||||
expect(parsedExtra.todayIncomeSnapshot?.latest).toBeCloseTo(2.08365, 6);
|
||||
});
|
||||
|
||||
it('does not send low balance reminder notification when balance is below threshold', async () => {
|
||||
selectAllMock.mockReturnValue([
|
||||
{
|
||||
accounts: {
|
||||
id: 7,
|
||||
username: 'linuxdo_low_balance',
|
||||
accessToken: 'active-token',
|
||||
status: 'active',
|
||||
extraConfig: null,
|
||||
},
|
||||
sites: {
|
||||
id: 9,
|
||||
name: 'wong',
|
||||
url: 'https://wzw.pp.ua',
|
||||
platform: 'new-api',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
adapterMock.getBalance.mockResolvedValueOnce({ balance: 0.5, used: 1, quota: 1.5 });
|
||||
|
||||
const { refreshBalance } = await import('./balanceService.js');
|
||||
const result = await refreshBalance(7);
|
||||
|
||||
expect(result).toEqual({ balance: 0.5, used: 1, quota: 1.5 });
|
||||
expect(sendNotificationMock).not.toHaveBeenCalled();
|
||||
expect(insertValuesMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { getAdapter } from './platforms/index.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { sendNotification } from './notifyService.js';
|
||||
import { appendSessionTokenRebindHint, isTokenExpiredError } from './alertRules.js';
|
||||
import { reportTokenExpired } from './alertService.js';
|
||||
import { getAutoReloginConfig, resolvePlatformUserId } from './accountExtraConfig.js';
|
||||
import {
|
||||
getAutoReloginConfig,
|
||||
getSub2ApiAuthFromExtraConfig,
|
||||
mergeAccountExtraConfig,
|
||||
resolvePlatformUserId,
|
||||
} from './accountExtraConfig.js';
|
||||
import { decryptAccountPassword } from './accountCredentialService.js';
|
||||
import { extractRuntimeHealth, setAccountRuntimeHealth } from './accountHealthService.js';
|
||||
import { updateTodayIncomeSnapshot } from './todayIncomeRewardService.js';
|
||||
@@ -124,6 +128,111 @@ function resolveQuotaConversionFactor(platform?: string | null): number {
|
||||
return (platform || '').toLowerCase() === 'veloera' ? 1_000_000 : 500_000;
|
||||
}
|
||||
|
||||
const SUB2API_TOKEN_REFRESH_BUFFER_MS = 120 * 1000;
|
||||
|
||||
function isSub2ApiPlatform(platform?: string | null): boolean {
|
||||
return (platform || '').trim().toLowerCase() === 'sub2api';
|
||||
}
|
||||
|
||||
function isNearTokenExpiry(tokenExpiresAt?: number): boolean {
|
||||
if (!(typeof tokenExpiresAt === 'number' && Number.isFinite(tokenExpiresAt) && tokenExpiresAt > 0)) {
|
||||
return false;
|
||||
}
|
||||
return tokenExpiresAt - Date.now() <= SUB2API_TOKEN_REFRESH_BUFFER_MS;
|
||||
}
|
||||
|
||||
type Sub2ApiRefreshedCredentials = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
tokenExpiresAt: number;
|
||||
};
|
||||
|
||||
function parseSub2ApiRefreshPayload(payload: unknown): Sub2ApiRefreshedCredentials | null {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
|
||||
const body = payload as Record<string, unknown>;
|
||||
const code = typeof body.code === 'number'
|
||||
? body.code
|
||||
: (typeof body.code === 'string' ? Number.parseInt(body.code.trim(), 10) : NaN);
|
||||
if (!Number.isFinite(code) || code !== 0) return null;
|
||||
if (!body.data || typeof body.data !== 'object' || Array.isArray(body.data)) return null;
|
||||
const data = body.data as Record<string, unknown>;
|
||||
|
||||
const accessToken = typeof data.access_token === 'string' ? data.access_token.trim() : '';
|
||||
const refreshToken = typeof data.refresh_token === 'string' ? data.refresh_token.trim() : '';
|
||||
const expiresInSeconds = typeof data.expires_in === 'number' && Number.isFinite(data.expires_in)
|
||||
? data.expires_in
|
||||
: (typeof data.expires_in === 'string' ? Number.parseInt(data.expires_in.trim(), 10) : NaN);
|
||||
|
||||
if (!accessToken || !refreshToken || !Number.isFinite(expiresInSeconds) || expiresInSeconds <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
tokenExpiresAt: Date.now() + Math.trunc(expiresInSeconds) * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshSub2ApiManagedSession(params: {
|
||||
account: typeof schema.accounts.$inferSelect;
|
||||
site: typeof schema.sites.$inferSelect;
|
||||
currentAccessToken: string;
|
||||
currentExtraConfig: string | null;
|
||||
}): Promise<{ accessToken: string; extraConfig: string }> {
|
||||
const managedAuth = getSub2ApiAuthFromExtraConfig(params.currentExtraConfig);
|
||||
const refreshToken = managedAuth?.refreshToken || '';
|
||||
if (!refreshToken) throw new Error('sub2api managed refresh token missing');
|
||||
|
||||
const endpoint = `${params.site.url.replace(/\/+$/, '')}/api/v1/auth/refresh`;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const authHeaderToken = (params.currentAccessToken || '').trim();
|
||||
if (authHeaderToken) {
|
||||
headers.Authorization = `Bearer ${authHeaderToken}`;
|
||||
}
|
||||
|
||||
const { fetch } = await import('undici');
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
const response = await fetch(endpoint, withExplicitProxyRequestInit(params.site.proxyUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
}));
|
||||
payload = await response.json().catch(() => null);
|
||||
} catch (err: any) {
|
||||
throw new Error(err?.message || 'sub2api token refresh request failed');
|
||||
}
|
||||
|
||||
const refreshed = parseSub2ApiRefreshPayload(payload);
|
||||
if (!refreshed) {
|
||||
throw new Error('sub2api token refresh failed');
|
||||
}
|
||||
|
||||
const nextExtraConfig = mergeAccountExtraConfig(params.currentExtraConfig, {
|
||||
sub2apiAuth: {
|
||||
refreshToken: refreshed.refreshToken,
|
||||
tokenExpiresAt: refreshed.tokenExpiresAt,
|
||||
},
|
||||
});
|
||||
db.update(schema.accounts)
|
||||
.set({
|
||||
accessToken: refreshed.accessToken,
|
||||
extraConfig: nextExtraConfig,
|
||||
status: params.account.status === 'expired' ? 'active' : params.account.status,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(schema.accounts.id, params.account.id))
|
||||
.run();
|
||||
|
||||
return {
|
||||
accessToken: refreshed.accessToken,
|
||||
extraConfig: nextExtraConfig,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchTodayIncomeFromLogs(params: {
|
||||
baseUrl: string;
|
||||
accessToken: string;
|
||||
@@ -256,8 +365,25 @@ export async function refreshBalance(accountId: number) {
|
||||
|
||||
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
|
||||
let activeAccessToken = account.accessToken;
|
||||
let activeExtraConfig = account.extraConfig;
|
||||
let balanceInfo: BalanceInfo | null = null;
|
||||
|
||||
if (isSub2ApiPlatform(site.platform)) {
|
||||
const managedAuth = getSub2ApiAuthFromExtraConfig(activeExtraConfig);
|
||||
if (managedAuth?.refreshToken && isNearTokenExpiry(managedAuth.tokenExpiresAt)) {
|
||||
try {
|
||||
const refreshed = await refreshSub2ApiManagedSession({
|
||||
account,
|
||||
site,
|
||||
currentAccessToken: activeAccessToken,
|
||||
currentExtraConfig: activeExtraConfig,
|
||||
});
|
||||
activeAccessToken = refreshed.accessToken;
|
||||
activeExtraConfig = refreshed.extraConfig;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const readBalance = async (token: string) => adapter.getBalance(site.url, token, platformUserId);
|
||||
const handleBalanceError = async (err: any) => {
|
||||
const message = appendSessionTokenRebindHint(err?.message || 'unknown error');
|
||||
@@ -281,7 +407,26 @@ export async function refreshBalance(accountId: number) {
|
||||
balanceInfo = await readBalance(activeAccessToken);
|
||||
} catch (err: any) {
|
||||
const message = err?.message || 'unknown error';
|
||||
if (shouldAttemptAutoRelogin(message)) {
|
||||
const canTryManagedSub2ApiRefresh =
|
||||
isSub2ApiPlatform(site.platform)
|
||||
&& !!getSub2ApiAuthFromExtraConfig(activeExtraConfig)?.refreshToken
|
||||
&& shouldAttemptAutoRelogin(message);
|
||||
|
||||
if (canTryManagedSub2ApiRefresh) {
|
||||
try {
|
||||
const refreshed = await refreshSub2ApiManagedSession({
|
||||
account,
|
||||
site,
|
||||
currentAccessToken: activeAccessToken,
|
||||
currentExtraConfig: activeExtraConfig,
|
||||
});
|
||||
activeAccessToken = refreshed.accessToken;
|
||||
activeExtraConfig = refreshed.extraConfig;
|
||||
balanceInfo = await readBalance(activeAccessToken);
|
||||
} catch (retryErr: any) {
|
||||
await handleBalanceError(retryErr);
|
||||
}
|
||||
} else if (shouldAttemptAutoRelogin(message)) {
|
||||
const refreshedAccessToken = await tryAutoRelogin(account, site);
|
||||
if (refreshedAccessToken) {
|
||||
activeAccessToken = refreshedAccessToken;
|
||||
@@ -320,7 +465,12 @@ export async function refreshBalance(accountId: number) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const existingRuntimeHealth = extractRuntimeHealth(account.extraConfig);
|
||||
let nextExtraConfig = activeExtraConfig;
|
||||
if (typeof balanceInfo.todayIncome === 'number' && Number.isFinite(balanceInfo.todayIncome)) {
|
||||
nextExtraConfig = updateTodayIncomeSnapshot(nextExtraConfig, balanceInfo.todayIncome);
|
||||
}
|
||||
|
||||
const existingRuntimeHealth = extractRuntimeHealth(nextExtraConfig);
|
||||
const keepUnsupportedCheckinDegraded = isUnsupportedCheckinRuntimeHealth(existingRuntimeHealth);
|
||||
|
||||
const updates: Record<string, unknown> = {
|
||||
@@ -331,8 +481,8 @@ export async function refreshBalance(accountId: number) {
|
||||
lastBalanceRefresh: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (typeof balanceInfo.todayIncome === 'number' && Number.isFinite(balanceInfo.todayIncome)) {
|
||||
updates.extraConfig = updateTodayIncomeSnapshot(account.extraConfig, balanceInfo.todayIncome);
|
||||
if (nextExtraConfig !== account.extraConfig) {
|
||||
updates.extraConfig = nextExtraConfig;
|
||||
}
|
||||
|
||||
db.update(schema.accounts)
|
||||
@@ -350,23 +500,6 @@ export async function refreshBalance(accountId: number) {
|
||||
: 'balance',
|
||||
});
|
||||
|
||||
if (balanceInfo.balance < 1) {
|
||||
db.insert(schema.events).values({
|
||||
type: 'balance',
|
||||
title: '余额不足',
|
||||
message: `${account.username || 'ID:' + accountId} 余额不足: $${balanceInfo.balance.toFixed(2)}`,
|
||||
level: 'warning',
|
||||
relatedId: accountId,
|
||||
relatedType: 'account',
|
||||
}).run();
|
||||
|
||||
await sendNotification(
|
||||
'余额不足提醒',
|
||||
`${account.username || 'ID:' + accountId} 余额不足: $${balanceInfo.balance.toFixed(2)}`,
|
||||
'warning',
|
||||
);
|
||||
}
|
||||
|
||||
return balanceInfo;
|
||||
}
|
||||
|
||||
|
||||
@@ -283,6 +283,41 @@ describe('checkinService auto relogin', () => {
|
||||
expect(notifyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats sub2api checkin unsupported message as skipped', async () => {
|
||||
selectAllMock.mockReturnValue([
|
||||
{
|
||||
accounts: {
|
||||
id: 15,
|
||||
username: 'sub2_user',
|
||||
accessToken: 'token',
|
||||
status: 'active',
|
||||
extraConfig: null,
|
||||
},
|
||||
sites: {
|
||||
id: 15,
|
||||
name: 'sub2',
|
||||
url: 'https://sub2.example.com',
|
||||
platform: 'sub2api',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
adapterMock.checkin.mockResolvedValue({
|
||||
success: false,
|
||||
message: 'Check-in is not supported by Sub2API',
|
||||
});
|
||||
|
||||
const { checkinAccount } = await import('./checkinService.js');
|
||||
const result = await checkinAccount(15);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.status).toBe('skipped');
|
||||
const firstInsertPayload = insertValuesMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(firstInsertPayload?.status).toBe('skipped');
|
||||
expect(refreshBalanceMock).not.toHaveBeenCalled();
|
||||
expect(notifyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats turnstile-required responses as skipped', async () => {
|
||||
selectAllMock.mockReturnValue([
|
||||
{
|
||||
|
||||
@@ -48,7 +48,11 @@ function isUnsupportedCheckinMessage(message?: string | null): boolean {
|
||||
return (
|
||||
text.includes('invalid url (post /api/user/checkin)') ||
|
||||
(text.includes('http 404') && text.includes('/api/user/checkin')) ||
|
||||
text.includes('checkin endpoint not found')
|
||||
text.includes('checkin endpoint not found') ||
|
||||
text.includes('check-in is not supported') ||
|
||||
text.includes('checkin is not supported') ||
|
||||
text.includes('does not support checkin') ||
|
||||
text.includes('not support checkin')
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,4 +49,13 @@ describe('failureReasonService', () => {
|
||||
expect(result.category).toBe('site');
|
||||
expect(result.title).toBe('站点未开启签到');
|
||||
});
|
||||
|
||||
it('classifies sub2api unsupported checkin message as site capability issue', () => {
|
||||
const result = classifyFailureReason({
|
||||
message: 'Check-in is not supported by Sub2API',
|
||||
status: 'failed',
|
||||
});
|
||||
expect(result.code).toBe('checkin_not_supported');
|
||||
expect(result.category).toBe('site');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,7 +50,15 @@ export function classifyFailureReason(
|
||||
};
|
||||
}
|
||||
|
||||
if (includesAny(text, ['checkin endpoint not found', '签到端点不存在', '站点不支持签到', 'not support checkin'])) {
|
||||
if (includesAny(text, [
|
||||
'checkin endpoint not found',
|
||||
'签到端点不存在',
|
||||
'站点不支持签到',
|
||||
'not support checkin',
|
||||
'check-in is not supported',
|
||||
'checkin is not supported',
|
||||
'does not support checkin',
|
||||
])) {
|
||||
return {
|
||||
code: 'checkin_not_supported',
|
||||
category: 'site',
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
const getApiTokenMock = vi.fn();
|
||||
const getModelsMock = vi.fn();
|
||||
|
||||
vi.mock('./platforms/index.js', () => ({
|
||||
getAdapter: () => ({
|
||||
getApiToken: (...args: unknown[]) => getApiTokenMock(...args),
|
||||
getModels: (...args: unknown[]) => getModelsMock(...args),
|
||||
}),
|
||||
}));
|
||||
|
||||
type DbModule = typeof import('../db/index.js');
|
||||
type ModelServiceModule = typeof import('./modelService.js');
|
||||
|
||||
describe('refreshModelsForAccount credential discovery', () => {
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let refreshModelsForAccount: ModelServiceModule['refreshModelsForAccount'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-model-discovery-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../db/migrate.js');
|
||||
const dbModule = await import('../db/index.js');
|
||||
const modelService = await import('./modelService.js');
|
||||
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
refreshModelsForAccount = modelService.refreshModelsForAccount;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
getApiTokenMock.mockReset();
|
||||
getModelsMock.mockReset();
|
||||
|
||||
db.delete(schema.routeChannels).run();
|
||||
db.delete(schema.tokenRoutes).run();
|
||||
db.delete(schema.tokenModelAvailability).run();
|
||||
db.delete(schema.modelAvailability).run();
|
||||
db.delete(schema.accountTokens).run();
|
||||
db.delete(schema.accounts).run();
|
||||
db.delete(schema.sites).run();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('discovers models from account session credential without account_tokens', async () => {
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
getModelsMock.mockImplementation(async (_baseUrl: string, token: string) => (
|
||||
token === 'session-token' ? ['claude-sonnet-4-5-20250929', 'claude-opus-4-6'] : []
|
||||
));
|
||||
|
||||
const site = db.insert(schema.sites).values({
|
||||
name: 'site-a',
|
||||
url: 'https://site-a.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'alice',
|
||||
accessToken: 'session-token',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const result = await refreshModelsForAccount(account.id);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
accountId: account.id,
|
||||
refreshed: true,
|
||||
modelCount: 2,
|
||||
tokenScanned: 0,
|
||||
discoveredByCredential: true,
|
||||
});
|
||||
|
||||
const rows = db.select().from(schema.modelAvailability)
|
||||
.where(eq(schema.modelAvailability.accountId, account.id))
|
||||
.all();
|
||||
expect(rows.map((row) => row.modelName).sort()).toEqual([
|
||||
'claude-opus-4-6',
|
||||
'claude-sonnet-4-5-20250929',
|
||||
]);
|
||||
|
||||
const tokenRows = db.select().from(schema.tokenModelAvailability).all();
|
||||
expect(tokenRows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -114,6 +114,51 @@ export async function refreshModelsForAccount(accountId: number) {
|
||||
const accountModels = new Set<string>();
|
||||
const modelLatency = new Map<string, number | null>();
|
||||
let scannedTokenCount = 0;
|
||||
let discoveredByCredential = false;
|
||||
const attemptedCredentials = new Set<string>();
|
||||
|
||||
const mergeDiscoveredModels = (models: string[], latencyMs: number | null) => {
|
||||
for (const modelName of models) {
|
||||
accountModels.add(modelName);
|
||||
const prev = modelLatency.get(modelName);
|
||||
if (prev === undefined || prev === null) {
|
||||
modelLatency.set(modelName, latencyMs);
|
||||
continue;
|
||||
}
|
||||
if (latencyMs === null) continue;
|
||||
if (latencyMs < prev) modelLatency.set(modelName, latencyMs);
|
||||
}
|
||||
};
|
||||
|
||||
const discoverModelsWithCredential = async (credentialRaw: string | null | undefined) => {
|
||||
const credential = (credentialRaw || '').trim();
|
||||
if (!credential) return;
|
||||
if (attemptedCredentials.has(credential)) return;
|
||||
attemptedCredentials.add(credential);
|
||||
|
||||
const startedAt = Date.now();
|
||||
let models: string[] = [];
|
||||
try {
|
||||
models = normalizeModels(
|
||||
await withTimeout(
|
||||
() => adapter.getModels(site.url, credential, platformUserId),
|
||||
MODEL_DISCOVERY_TIMEOUT_MS,
|
||||
`model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
models = [];
|
||||
}
|
||||
if (models.length === 0) return;
|
||||
discoveredByCredential = true;
|
||||
const latencyMs = Date.now() - startedAt;
|
||||
mergeDiscoveredModels(models, latencyMs);
|
||||
};
|
||||
|
||||
// Prefer account-level credential discovery so model availability does not rely on managed tokens.
|
||||
await discoverModelsWithCredential(account.apiToken);
|
||||
await discoverModelsWithCredential(discoveredApiToken);
|
||||
await discoverModelsWithCredential(account.accessToken);
|
||||
|
||||
for (const token of enabledTokens) {
|
||||
const startedAt = Date.now();
|
||||
@@ -147,14 +192,7 @@ export async function refreshModelsForAccount(accountId: number) {
|
||||
).run();
|
||||
|
||||
scannedTokenCount++;
|
||||
|
||||
for (const modelName of models) {
|
||||
accountModels.add(modelName);
|
||||
const prev = modelLatency.get(modelName);
|
||||
if (prev === undefined || prev === null || latencyMs < prev) {
|
||||
modelLatency.set(modelName, latencyMs);
|
||||
}
|
||||
}
|
||||
mergeDiscoveredModels(models, latencyMs);
|
||||
}
|
||||
|
||||
if (accountModels.size > 0) {
|
||||
@@ -175,6 +213,7 @@ export async function refreshModelsForAccount(accountId: number) {
|
||||
refreshed: true,
|
||||
modelCount: accountModels.size,
|
||||
tokenScanned: scannedTokenCount,
|
||||
discoveredByCredential,
|
||||
discoveredApiToken: !!discoveredApiToken,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -108,6 +108,53 @@ describe('notifyService', () => {
|
||||
).rejects.toThrow(/webhook|bark|Webhook 响应状态|Bark 响应状态/i);
|
||||
});
|
||||
|
||||
it('sends enterprise wechat webhook payload as structured text message', async () => {
|
||||
const { config } = await import('../config.js');
|
||||
config.webhookEnabled = true;
|
||||
config.webhookUrl = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=demo-key';
|
||||
config.smtpEnabled = false;
|
||||
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({ errcode: 0, errmsg: 'ok' }),
|
||||
});
|
||||
|
||||
const { sendNotification } = await import('./notifyService.js');
|
||||
await sendNotification('测试通知', 'message', 'info', { bypassThrottle: true, throwOnFailure: true });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const call = fetchMock.mock.calls[0] as [string, { body?: string }];
|
||||
expect(call[0]).toContain('qyapi.weixin.qq.com/cgi-bin/webhook/send');
|
||||
|
||||
const payload = JSON.parse(call[1]?.body || '{}') as { msgtype?: string; text?: { content?: string } };
|
||||
expect(Array.isArray(payload)).toBe(false);
|
||||
expect(payload.msgtype).toBe('text');
|
||||
expect(payload.text?.content || '').toContain('[metapi][INFO] 测试通知');
|
||||
expect(payload.text?.content || '').toContain('message');
|
||||
});
|
||||
|
||||
it('fails when enterprise wechat webhook returns non-zero errcode', async () => {
|
||||
const { config } = await import('../config.js');
|
||||
config.webhookEnabled = true;
|
||||
config.webhookUrl = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=demo-key';
|
||||
config.smtpEnabled = false;
|
||||
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({ errcode: 93000, errmsg: 'invalid json' }),
|
||||
});
|
||||
|
||||
const { sendNotification } = await import('./notifyService.js');
|
||||
await expect(
|
||||
sendNotification('测试通知', 'message', 'info', {
|
||||
bypassThrottle: true,
|
||||
throwOnFailure: true,
|
||||
}),
|
||||
).rejects.toThrow(/企业微信|93000|invalid json/);
|
||||
});
|
||||
|
||||
it('includes local time and utc time labels in smtp payload', async () => {
|
||||
sendMailMock.mockResolvedValue({ accepted: ['receiver@example.com'] });
|
||||
const { sendNotification } = await import('./notifyService.js');
|
||||
|
||||
@@ -82,6 +82,27 @@ function buildTelegramText(
|
||||
return `${raw.slice(0, maxTextLength)}\n\n...(truncated)`;
|
||||
}
|
||||
|
||||
function isWeComBotWebhook(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.hostname === 'qyapi.weixin.qq.com' && parsed.pathname.includes('/cgi-bin/webhook/send');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildWeComText(
|
||||
title: string,
|
||||
message: string,
|
||||
level: 'info' | 'warning' | 'error',
|
||||
timeFootnote: string,
|
||||
): string {
|
||||
const maxLength = 1900;
|
||||
const raw = `[metapi][${level.toUpperCase()}] ${title}\n\n${message}\n\n${timeFootnote}`;
|
||||
if (raw.length <= maxLength) return raw;
|
||||
return `${raw.slice(0, maxLength)}\n...(truncated)`;
|
||||
}
|
||||
|
||||
export async function sendNotification(
|
||||
title: string,
|
||||
message: string,
|
||||
@@ -119,21 +140,42 @@ export async function sendNotification(
|
||||
{
|
||||
channel: 'webhook',
|
||||
run: async () => {
|
||||
const isWeComWebhook = isWeComBotWebhook(config.webhookUrl);
|
||||
const response = await fetch(config.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
message: resolvedMessage,
|
||||
level,
|
||||
timestamp: now.toISOString(),
|
||||
localTime: formatLocalDateTime(now),
|
||||
timeZone: getResolvedTimeZone(),
|
||||
}),
|
||||
body: JSON.stringify(
|
||||
isWeComWebhook
|
||||
? {
|
||||
msgtype: 'text',
|
||||
text: {
|
||||
content: buildWeComText(title, resolvedMessage, level, timeFootnote),
|
||||
},
|
||||
}
|
||||
: {
|
||||
title,
|
||||
message: resolvedMessage,
|
||||
level,
|
||||
timestamp: now.toISOString(),
|
||||
localTime: formatLocalDateTime(now),
|
||||
timeZone: getResolvedTimeZone(),
|
||||
},
|
||||
),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Webhook 响应状态 ${response.status}`);
|
||||
}
|
||||
if (isWeComWebhook) {
|
||||
let payload: { errcode?: number; errmsg?: string } | null = null;
|
||||
try {
|
||||
payload = await response.json() as { errcode?: number; errmsg?: string };
|
||||
} catch {
|
||||
throw new Error('企业微信 Webhook 返回了无效 JSON');
|
||||
}
|
||||
if (typeof payload?.errcode === 'number' && payload.errcode !== 0) {
|
||||
throw new Error(`企业微信 Webhook 返回错误 ${payload.errcode}: ${payload.errmsg || 'unknown error'}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface ApiTokenInfo {
|
||||
name: string;
|
||||
key: string;
|
||||
enabled?: boolean;
|
||||
tokenGroup?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateApiTokenOptions {
|
||||
@@ -145,7 +146,7 @@ export abstract class BasePlatformAdapter implements PlatformAdapter {
|
||||
async getApiTokens(baseUrl: string, accessToken: string, platformUserId?: number): Promise<ApiTokenInfo[]> {
|
||||
const token = await this.getApiToken(baseUrl, accessToken, platformUserId);
|
||||
if (!token) return [];
|
||||
return [{ name: 'default', key: token, enabled: true }];
|
||||
return [{ name: 'default', key: token, enabled: true, tokenGroup: 'default' }];
|
||||
}
|
||||
|
||||
async createApiToken(
|
||||
|
||||
@@ -176,12 +176,19 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
const key = typeof item?.key === 'string' ? item.key.trim() : '';
|
||||
if (!key) continue;
|
||||
const rawName = typeof item?.name === 'string' ? item.name.trim() : '';
|
||||
const rawGroup = typeof item?.group === 'string'
|
||||
? item.group.trim()
|
||||
: (typeof item?.group_name === 'string'
|
||||
? item.group_name.trim()
|
||||
: (typeof item?.token_group === 'string' ? item.token_group.trim() : ''));
|
||||
const status = typeof item?.status === 'number' ? item.status : undefined;
|
||||
normalized.push({
|
||||
const tokenInfo: ApiTokenInfo = {
|
||||
name: rawName || (index === 0 ? 'default' : `token-${index + 1}`),
|
||||
key,
|
||||
enabled: status === undefined ? true : status === 1,
|
||||
});
|
||||
};
|
||||
if (rawGroup) tokenInfo.tokenGroup = rawGroup;
|
||||
normalized.push(tokenInfo);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -104,12 +104,17 @@ export class OneApiAdapter extends BasePlatformAdapter {
|
||||
const key = typeof item?.key === 'string' ? item.key.trim() : '';
|
||||
if (!key) return null;
|
||||
const rawName = typeof item?.name === 'string' ? item.name.trim() : '';
|
||||
const rawGroup = typeof item?.group === 'string'
|
||||
? item.group.trim()
|
||||
: (typeof item?.token_group === 'string' ? item.token_group.trim() : '');
|
||||
const status = typeof item?.status === 'number' ? item.status : undefined;
|
||||
return {
|
||||
const tokenInfo: ApiTokenInfo = {
|
||||
name: rawName || (index === 0 ? 'default' : `token-${index + 1}`),
|
||||
key,
|
||||
enabled: status === undefined ? true : status === 1,
|
||||
} satisfies ApiTokenInfo;
|
||||
};
|
||||
if (rawGroup) tokenInfo.tokenGroup = rawGroup;
|
||||
return tokenInfo;
|
||||
})
|
||||
.filter((item: ApiTokenInfo | null): item is ApiTokenInfo => !!item);
|
||||
} catch {
|
||||
|
||||
@@ -154,6 +154,46 @@ describe('Sub2ApiAdapter', () => {
|
||||
expect(models).toEqual(['gpt-4o', 'claude-3-opus']);
|
||||
});
|
||||
|
||||
it('fetches models via api key discovered from /api/v1/keys when JWT cannot call /v1/models directly', async () => {
|
||||
await startServer((req, res) => {
|
||||
const auth = req.headers.authorization || '';
|
||||
if (req.url === '/v1/models' && auth === 'Bearer jwt-token') {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
code: 'API_KEY_REQUIRED',
|
||||
message: 'API key is required',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (req.url === '/api/v1/keys?page=1&page_size=100' && auth === 'Bearer jwt-token') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
items: [
|
||||
{ id: 1, key: 'sk-sub2-active', name: 'default', status: 'active' },
|
||||
{ id: 2, key: 'sk-sub2-disabled', name: 'old', status: 'inactive' },
|
||||
],
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (req.url === '/v1/models' && auth === 'Bearer sk-sub2-active') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
object: 'list',
|
||||
data: [{ id: 'gpt-4o-mini' }, { id: 'claude-3-5-sonnet' }],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
|
||||
const models = await adapter.getModels(baseUrl, 'jwt-token');
|
||||
expect(models).toEqual(['gpt-4o-mini', 'claude-3-5-sonnet']);
|
||||
});
|
||||
|
||||
it('handles non-zero code as error in /api/v1/auth/me', async () => {
|
||||
await startServer((req, res) => {
|
||||
if (req.url === '/api/v1/auth/me') {
|
||||
@@ -176,10 +216,152 @@ describe('Sub2ApiAdapter', () => {
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('token management returns empty/false', async () => {
|
||||
expect(await adapter.getApiToken('http://localhost', 'token')).toBeNull();
|
||||
expect(await adapter.getApiTokens('http://localhost', 'token')).toEqual([]);
|
||||
expect(await adapter.createApiToken('http://localhost', 'token')).toBe(false);
|
||||
expect(await adapter.deleteApiToken('http://localhost', 'token', 'key')).toBe(false);
|
||||
it('lists api keys from /api/v1/keys and picks active key as default api token', async () => {
|
||||
await startServer((req, res) => {
|
||||
if (req.url === '/api/v1/keys?page=1&page_size=100') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
items: [
|
||||
{ id: 10, key: 'sk-disabled', name: 'old', status: 'inactive' },
|
||||
{ id: 11, key: 'sk-active', name: 'default', status: 'active' },
|
||||
],
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
|
||||
const tokens = await adapter.getApiTokens(baseUrl, 'jwt-token');
|
||||
expect(tokens).toEqual([
|
||||
{ key: 'sk-disabled', name: 'old', enabled: false },
|
||||
{ key: 'sk-active', name: 'default', enabled: true },
|
||||
]);
|
||||
expect(await adapter.getApiToken(baseUrl, 'jwt-token')).toBe('sk-active');
|
||||
});
|
||||
|
||||
it('fetches user groups from /api/v1/groups', async () => {
|
||||
await startServer((req, res) => {
|
||||
if (req.url === '/api/v1/groups?page=1&page_size=100') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
items: [
|
||||
{ id: 1, name: 'default' },
|
||||
{ id: 2, name: 'vip' },
|
||||
],
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
|
||||
const groups = await adapter.getUserGroups(baseUrl, 'jwt-token');
|
||||
expect(groups).toEqual(['1', '2']);
|
||||
});
|
||||
|
||||
it('falls back to infer groups from /api/v1/keys when group endpoint is unavailable', async () => {
|
||||
await startServer((req, res) => {
|
||||
if (req.url === '/api/v1/groups?page=1&page_size=100') {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
if (req.url === '/api/v1/groups') {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
if (req.url === '/api/v1/group?page=1&page_size=100') {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
if (req.url === '/api/v1/group') {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
if (req.url === '/api/v1/keys?page=1&page_size=100') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
items: [
|
||||
{ id: 11, key: 'sk-1', group_id: 7, status: 'active' },
|
||||
{ id: 12, key: 'sk-2', group_id: 7, status: 'inactive' },
|
||||
{ id: 13, key: 'sk-3', group_id: 9, status: 'active' },
|
||||
],
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
|
||||
const groups = await adapter.getUserGroups(baseUrl, 'jwt-token');
|
||||
expect(groups).toEqual(['7', '9']);
|
||||
});
|
||||
|
||||
it('creates api key via /api/v1/keys', async () => {
|
||||
await startServer((req, res) => {
|
||||
if (req.url === '/api/v1/keys' && req.method === 'POST') {
|
||||
let rawBody = '';
|
||||
req.on('data', (chunk) => { rawBody += chunk; });
|
||||
req.on('end', () => {
|
||||
const body = JSON.parse(rawBody || '{}');
|
||||
expect(body.name).toBe('metapi-e2e');
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
id: 1,
|
||||
key: 'sk-created',
|
||||
name: body.name,
|
||||
},
|
||||
}));
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
|
||||
const created = await adapter.createApiToken(baseUrl, 'jwt-token', undefined, { name: 'metapi-e2e' });
|
||||
expect(created).toBe(true);
|
||||
});
|
||||
|
||||
it('deletes api key by key value via /api/v1/keys/:id', async () => {
|
||||
await startServer((req, res) => {
|
||||
if (req.url === '/api/v1/keys?page=1&page_size=100') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
items: [
|
||||
{ id: 31, key: 'sk-delete-me', name: 'to-delete', status: 'active' },
|
||||
],
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (req.url === '/api/v1/keys/31' && req.method === 'DELETE') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: { id: 31 },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
|
||||
const deleted = await adapter.deleteApiToken(baseUrl, 'jwt-token', 'sk-delete-me');
|
||||
expect(deleted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { BasePlatformAdapter, CheckinResult, BalanceInfo, UserInfo } from './base.js';
|
||||
import {
|
||||
ApiTokenInfo,
|
||||
BasePlatformAdapter,
|
||||
CheckinResult,
|
||||
BalanceInfo,
|
||||
CreateApiTokenOptions,
|
||||
UserInfo,
|
||||
} from './base.js';
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string): string {
|
||||
return (baseUrl || '').replace(/\/+$/, '');
|
||||
@@ -8,12 +15,268 @@ function normalizeBaseUrl(baseUrl: string): string {
|
||||
* Sub2API adapter.
|
||||
*
|
||||
* Sub2API uses JWT-based auth with endpoints under /api/v1/*.
|
||||
* It does NOT support: login, check-in, token management, or usage stats.
|
||||
* It does NOT support: login or check-in.
|
||||
* Balance is derived from a USD amount returned by /api/v1/auth/me.
|
||||
*/
|
||||
export class Sub2ApiAdapter extends BasePlatformAdapter {
|
||||
readonly platformName = 'sub2api';
|
||||
|
||||
private normalizeTokenKeyForCompare(value?: string | null): string {
|
||||
const trimmed = (value || '').trim();
|
||||
return trimmed.startsWith('Bearer ') ? trimmed.slice(7).trim() : trimmed;
|
||||
}
|
||||
|
||||
private parseTokenEnabled(status: unknown): boolean {
|
||||
if (typeof status === 'boolean') return status;
|
||||
if (typeof status === 'number') return status === 1;
|
||||
if (typeof status !== 'string') return true;
|
||||
const normalized = status.trim().toLowerCase();
|
||||
if (!normalized) return true;
|
||||
if (['inactive', 'disabled', 'false', '0', 'off'].includes(normalized)) return false;
|
||||
if (['active', 'enabled', 'true', '1', 'on'].includes(normalized)) return true;
|
||||
return true;
|
||||
}
|
||||
|
||||
private parseTokenItems(payload: any): Array<{ id: number; key: string; name: string; enabled: boolean; tokenGroup: string | null }> {
|
||||
const source = payload?.data ?? payload;
|
||||
const rawItems = (() => {
|
||||
if (Array.isArray(source)) return source;
|
||||
if (Array.isArray(source?.items)) return source.items;
|
||||
if (Array.isArray(source?.list)) return source.list;
|
||||
if (Array.isArray(source?.data)) return source.data;
|
||||
return [];
|
||||
})();
|
||||
|
||||
const items: Array<{ id: number; key: string; name: string; enabled: boolean; tokenGroup: string | null }> = [];
|
||||
for (const item of rawItems) {
|
||||
const key = typeof item?.key === 'string' ? item.key.trim() : '';
|
||||
if (!key) continue;
|
||||
const id = Number.parseInt(String(item?.id), 10);
|
||||
if (!Number.isFinite(id) || id <= 0) continue;
|
||||
const name = typeof item?.name === 'string' && item.name.trim()
|
||||
? item.name.trim()
|
||||
: `token-${id}`;
|
||||
const tokenGroup = (() => {
|
||||
const fromNumeric = Number.parseInt(String(item?.group_id ?? item?.groupId ?? ''), 10);
|
||||
if (Number.isFinite(fromNumeric) && fromNumeric > 0) return String(fromNumeric);
|
||||
const fromText = typeof item?.group_name === 'string'
|
||||
? item.group_name.trim()
|
||||
: (typeof item?.group === 'string' ? item.group.trim() : '');
|
||||
return fromText || null;
|
||||
})();
|
||||
items.push({
|
||||
id,
|
||||
key,
|
||||
name,
|
||||
enabled: this.parseTokenEnabled(item?.status),
|
||||
tokenGroup,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private parseGroupItems(payload: any): string[] {
|
||||
const source = payload?.data ?? payload;
|
||||
const rawItems = (() => {
|
||||
if (Array.isArray(source)) return source;
|
||||
if (Array.isArray(source?.items)) return source.items;
|
||||
if (Array.isArray(source?.list)) return source.list;
|
||||
if (Array.isArray(source?.groups)) return source.groups;
|
||||
if (Array.isArray(source?.data)) return source.data;
|
||||
return [];
|
||||
})();
|
||||
|
||||
const groups: string[] = [];
|
||||
for (const item of rawItems) {
|
||||
if (item == null) continue;
|
||||
if (typeof item === 'number' && Number.isFinite(item) && item > 0) {
|
||||
groups.push(String(Math.trunc(item)));
|
||||
continue;
|
||||
}
|
||||
if (typeof item === 'string') {
|
||||
const normalized = item.trim();
|
||||
if (normalized) groups.push(normalized);
|
||||
continue;
|
||||
}
|
||||
if (typeof item !== 'object') continue;
|
||||
|
||||
const numericCandidates = [
|
||||
(item as any).group_id,
|
||||
(item as any).groupId,
|
||||
(item as any).id,
|
||||
(item as any).value,
|
||||
];
|
||||
let picked = '';
|
||||
for (const candidate of numericCandidates) {
|
||||
const parsed = Number.parseInt(String(candidate), 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
picked = String(parsed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (picked) {
|
||||
groups.push(picked);
|
||||
continue;
|
||||
}
|
||||
|
||||
const textCandidates = [
|
||||
(item as any).name,
|
||||
(item as any).group_name,
|
||||
(item as any).groupName,
|
||||
(item as any).title,
|
||||
(item as any).label,
|
||||
(item as any).code,
|
||||
];
|
||||
for (const candidate of textCandidates) {
|
||||
if (typeof candidate !== 'string') continue;
|
||||
const normalized = candidate.trim();
|
||||
if (!normalized) continue;
|
||||
groups.push(normalized);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(new Set(groups));
|
||||
}
|
||||
|
||||
private async listGroups(baseUrl: string, accessToken: string): Promise<string[]> {
|
||||
const endpoints = [
|
||||
'/api/v1/groups?page=1&page_size=100',
|
||||
'/api/v1/groups',
|
||||
'/api/v1/group?page=1&page_size=100',
|
||||
'/api/v1/group',
|
||||
];
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
const parsed = (() => {
|
||||
try {
|
||||
return this.parseSub2ApiEnvelope<any>(res, endpoint);
|
||||
} catch {
|
||||
return res;
|
||||
}
|
||||
})();
|
||||
const groups = this.parseGroupItems(parsed);
|
||||
if (groups.length > 0) return groups;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private parseGroupIdsFromTokenPayload(payload: any): string[] {
|
||||
const source = payload?.data ?? payload;
|
||||
const rawItems = (() => {
|
||||
if (Array.isArray(source)) return source;
|
||||
if (Array.isArray(source?.items)) return source.items;
|
||||
if (Array.isArray(source?.list)) return source.list;
|
||||
if (Array.isArray(source?.data)) return source.data;
|
||||
return [];
|
||||
})();
|
||||
|
||||
const groups: string[] = [];
|
||||
for (const item of rawItems) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const groupId = Number.parseInt(String((item as any).group_id ?? (item as any).groupId ?? ''), 10);
|
||||
if (!Number.isFinite(groupId) || groupId <= 0) continue;
|
||||
groups.push(String(groupId));
|
||||
}
|
||||
return Array.from(new Set(groups));
|
||||
}
|
||||
|
||||
private async inferGroupsFromKeys(baseUrl: string, accessToken: string): Promise<string[]> {
|
||||
const endpoints = [
|
||||
'/api/v1/keys?page=1&page_size=100',
|
||||
'/api/v1/api-keys?page=1&page_size=100',
|
||||
];
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
const parsed = (() => {
|
||||
try {
|
||||
return this.parseSub2ApiEnvelope<any>(res, endpoint);
|
||||
} catch {
|
||||
return res;
|
||||
}
|
||||
})();
|
||||
const groups = this.parseGroupIdsFromTokenPayload(parsed);
|
||||
if (groups.length > 0) return groups;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private extractModelIds(payload: any): string[] {
|
||||
const source = payload?.data ?? payload;
|
||||
const rawModels = (() => {
|
||||
if (Array.isArray(source)) return source;
|
||||
if (Array.isArray(source?.items)) return source.items;
|
||||
if (Array.isArray(source?.models)) return source.models;
|
||||
return [];
|
||||
})();
|
||||
|
||||
const models = rawModels
|
||||
.map((item: any) => (typeof item === 'string' ? item : item?.id))
|
||||
.map((value: unknown) => String(value || '').trim())
|
||||
.filter(Boolean);
|
||||
return Array.from(new Set(models));
|
||||
}
|
||||
|
||||
private async listApiKeys(baseUrl: string, accessToken: string): Promise<Array<{ id: number; key: string; name: string; enabled: boolean; tokenGroup: string | null }>> {
|
||||
const endpoints = [
|
||||
'/api/v1/keys?page=1&page_size=100',
|
||||
'/api/v1/api-keys?page=1&page_size=100',
|
||||
];
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
const data = this.parseSub2ApiEnvelope<any>(res, endpoint);
|
||||
const items = this.parseTokenItems(data);
|
||||
if (items.length > 0) return items;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private async fetchModelsByToken(baseUrl: string, token: string): Promise<string[]> {
|
||||
const authToken = this.normalizeTokenKeyForCompare(token);
|
||||
if (!authToken) return [];
|
||||
|
||||
const endpoints = ['/v1/models', '/api/v1/models'];
|
||||
for (const endpoint of endpoints) {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, {
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
const models = this.extractModelIds(res);
|
||||
if (models.length > 0) return models;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private resolveExpiresInDays(expiredTime?: number): number | undefined {
|
||||
if (!Number.isFinite(expiredTime)) return undefined;
|
||||
const raw = Math.trunc(expiredTime as number);
|
||||
if (raw <= 0) return undefined;
|
||||
const expiresAtMs = raw > 10_000_000_000 ? raw : raw * 1000;
|
||||
const deltaMs = expiresAtMs - Date.now();
|
||||
const days = Math.max(1, Math.ceil(deltaMs / (24 * 60 * 60 * 1000)));
|
||||
return Number.isFinite(days) ? Math.min(days, 3650) : undefined;
|
||||
}
|
||||
|
||||
async detect(url: string): Promise<boolean> {
|
||||
const normalized = (url || '').toLowerCase();
|
||||
if (normalized.includes('sub2api')) return true;
|
||||
@@ -196,13 +459,128 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
|
||||
|
||||
// --- Models: Standard OpenAI-compatible endpoint ---
|
||||
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
|
||||
const normalizedBase = normalizeBaseUrl(baseUrl);
|
||||
const directModels = await this.fetchModelsByToken(normalizedBase, apiToken);
|
||||
if (directModels.length > 0) return directModels;
|
||||
|
||||
// Session JWT cannot access /v1/models directly; discover a user key first.
|
||||
const discoveredApiToken = await this.getApiToken(normalizedBase, apiToken);
|
||||
if (!discoveredApiToken) return [];
|
||||
if (this.normalizeTokenKeyForCompare(discoveredApiToken) === this.normalizeTokenKeyForCompare(apiToken)) {
|
||||
return [];
|
||||
}
|
||||
return this.fetchModelsByToken(normalizedBase, discoveredApiToken);
|
||||
}
|
||||
|
||||
override async getApiTokens(baseUrl: string, accessToken: string): Promise<ApiTokenInfo[]> {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}/v1/models`, {
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
const keys = await this.listApiKeys(normalizeBaseUrl(baseUrl), accessToken);
|
||||
return keys.map((item) => {
|
||||
const tokenInfo: ApiTokenInfo = {
|
||||
name: item.name,
|
||||
key: item.key,
|
||||
enabled: item.enabled,
|
||||
};
|
||||
if (item.tokenGroup) tokenInfo.tokenGroup = item.tokenGroup;
|
||||
return tokenInfo;
|
||||
});
|
||||
return (res?.data || []).map((m: any) => m.id).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
override async getApiToken(baseUrl: string, accessToken: string): Promise<string | null> {
|
||||
const tokens = await this.getApiTokens(baseUrl, accessToken);
|
||||
return tokens.find((token) => token.enabled !== false)?.key || tokens[0]?.key || null;
|
||||
}
|
||||
|
||||
override async getUserGroups(baseUrl: string, accessToken: string): Promise<string[]> {
|
||||
const normalizedBase = normalizeBaseUrl(baseUrl);
|
||||
const directGroups = await this.listGroups(normalizedBase, accessToken);
|
||||
if (directGroups.length > 0) return directGroups;
|
||||
|
||||
const inferredFromKeys = await this.inferGroupsFromKeys(normalizedBase, accessToken);
|
||||
if (inferredFromKeys.length > 0) return inferredFromKeys;
|
||||
|
||||
return ['default'];
|
||||
}
|
||||
|
||||
override async createApiToken(
|
||||
baseUrl: string,
|
||||
accessToken: string,
|
||||
_platformUserId?: number,
|
||||
options?: CreateApiTokenOptions,
|
||||
): Promise<boolean> {
|
||||
const normalizedBase = normalizeBaseUrl(baseUrl);
|
||||
const payload: Record<string, unknown> = {
|
||||
name: (options?.name || '').trim() || 'metapi',
|
||||
};
|
||||
|
||||
const groupId = Number.parseInt((options?.group || '').trim(), 10);
|
||||
if (Number.isFinite(groupId) && groupId > 0) {
|
||||
payload.group_id = groupId;
|
||||
}
|
||||
|
||||
const expiresInDays = this.resolveExpiresInDays(options?.expiredTime);
|
||||
if (expiresInDays) {
|
||||
payload.expires_in_days = expiresInDays;
|
||||
}
|
||||
|
||||
if (options?.unlimitedQuota === false && Number.isFinite(options.remainQuota)) {
|
||||
payload.quota = Math.max(0, Number(options.remainQuota));
|
||||
}
|
||||
|
||||
const endpoints = ['/api/v1/keys', '/api/v1/api-keys'];
|
||||
for (const endpoint of endpoints) {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${normalizedBase}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
this.parseSub2ApiEnvelope<any>(res, endpoint);
|
||||
return true;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
override async deleteApiToken(
|
||||
baseUrl: string,
|
||||
accessToken: string,
|
||||
tokenKey: string,
|
||||
): Promise<boolean> {
|
||||
const targetKey = this.normalizeTokenKeyForCompare(tokenKey);
|
||||
if (!targetKey) return false;
|
||||
|
||||
const normalizedBase = normalizeBaseUrl(baseUrl);
|
||||
let tokenId: number | null = null;
|
||||
try {
|
||||
const items = await this.listApiKeys(normalizedBase, accessToken);
|
||||
tokenId = items.find((item) => this.normalizeTokenKeyForCompare(item.key) === targetKey)?.id || null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Upstream key already absent means local deletion is safe.
|
||||
if (!tokenId) return true;
|
||||
|
||||
const endpoints = [
|
||||
`/api/v1/keys/${tokenId}`,
|
||||
`/api/v1/api-keys/${tokenId}`,
|
||||
];
|
||||
for (const endpoint of endpoints) {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${normalizedBase}${endpoint}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
this.parseSub2ApiEnvelope<any>(res, endpoint);
|
||||
return true;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +179,90 @@ describe('TokenRouter downstream policy', () => {
|
||||
expect((highCandidate?.probability || 0)).toBeGreaterThan(lowCandidate?.probability || 0);
|
||||
});
|
||||
|
||||
it('combines site global weight with downstream site multiplier', () => {
|
||||
const siteGlobalHigh = db.insert(schema.sites).values({
|
||||
name: 'global-high-site',
|
||||
url: 'https://global-high.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
globalWeight: 3,
|
||||
}).returning().get();
|
||||
|
||||
const siteGlobalLow = db.insert(schema.sites).values({
|
||||
name: 'global-low-site',
|
||||
url: 'https://global-low.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
globalWeight: 1,
|
||||
}).returning().get();
|
||||
|
||||
const accountGlobalHigh = db.insert(schema.accounts).values({
|
||||
siteId: siteGlobalHigh.id,
|
||||
username: 'user-global-high',
|
||||
accessToken: 'access-global-high',
|
||||
apiToken: 'sk-global-high',
|
||||
status: 'active',
|
||||
unitCost: 1,
|
||||
balance: 100,
|
||||
}).returning().get();
|
||||
|
||||
const accountGlobalLow = db.insert(schema.accounts).values({
|
||||
siteId: siteGlobalLow.id,
|
||||
username: 'user-global-low',
|
||||
accessToken: 'access-global-low',
|
||||
apiToken: 'sk-global-low',
|
||||
status: 'active',
|
||||
unitCost: 1,
|
||||
balance: 100,
|
||||
}).returning().get();
|
||||
|
||||
const route = db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-5-mini',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const highChannel = db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountGlobalHigh.id,
|
||||
tokenId: null,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const lowChannel = db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountGlobalLow.id,
|
||||
tokenId: null,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const router = new TokenRouter();
|
||||
const decision = router.explainSelectionForRoute(
|
||||
route.id,
|
||||
'gpt-5-mini',
|
||||
[],
|
||||
{
|
||||
allowedRouteIds: [route.id],
|
||||
supportedModels: [],
|
||||
siteWeightMultipliers: {
|
||||
[siteGlobalHigh.id]: 0.5,
|
||||
[siteGlobalLow.id]: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const highCandidate = decision.candidates.find((candidate) => candidate.channelId === highChannel.id);
|
||||
const lowCandidate = decision.candidates.find((candidate) => candidate.channelId === lowChannel.id);
|
||||
|
||||
expect(highCandidate).toBeTruthy();
|
||||
expect(lowCandidate).toBeTruthy();
|
||||
// combined multiplier: high=3*0.5=1.5, low=1*1=1
|
||||
expect((highCandidate?.probability || 0)).toBeGreaterThan(lowCandidate?.probability || 0);
|
||||
});
|
||||
|
||||
it('supports union semantics between supportedModels and allowedRouteIds', () => {
|
||||
const site = db.insert(schema.sites).values({
|
||||
name: 'site-union',
|
||||
|
||||
@@ -816,9 +816,18 @@ export class TokenRouter {
|
||||
const contributions = candidates.map((candidate, i) => {
|
||||
const siteChannels = Math.max(1, siteChannelCounts.get(candidate.site.id) || 1);
|
||||
let contribution = baseContributions[i] / siteChannels;
|
||||
const siteMultiplier = downstreamPolicy.siteWeightMultipliers[candidate.site.id] ?? 1;
|
||||
if (siteMultiplier > 0 && Number.isFinite(siteMultiplier)) {
|
||||
contribution *= siteMultiplier;
|
||||
const downstreamSiteMultiplier = downstreamPolicy.siteWeightMultipliers[candidate.site.id] ?? 1;
|
||||
const normalizedDownstreamSiteMultiplier =
|
||||
(Number.isFinite(downstreamSiteMultiplier) && downstreamSiteMultiplier > 0)
|
||||
? downstreamSiteMultiplier
|
||||
: 1;
|
||||
const siteGlobalWeight =
|
||||
(Number.isFinite(candidate.site.globalWeight) && (candidate.site.globalWeight || 0) > 0)
|
||||
? (candidate.site.globalWeight as number)
|
||||
: 1;
|
||||
const combinedSiteWeight = siteGlobalWeight * normalizedDownstreamSiteMultiplier;
|
||||
if (combinedSiteWeight > 0 && Number.isFinite(combinedSiteWeight)) {
|
||||
contribution *= combinedSiteWeight;
|
||||
}
|
||||
|
||||
// If upstream price is unknown and we are using fallback unit cost,
|
||||
@@ -839,11 +848,20 @@ export class TokenRouter {
|
||||
? '实测'
|
||||
: (cost?.source === 'configured' ? '配置' : (cost?.source === 'catalog' ? '目录' : '默认'));
|
||||
const siteChannels = Math.max(1, siteChannelCounts.get(candidate.site.id) || 1);
|
||||
const siteMultiplier = downstreamPolicy.siteWeightMultipliers[candidate.site.id] ?? 1;
|
||||
const downstreamSiteMultiplier = downstreamPolicy.siteWeightMultipliers[candidate.site.id] ?? 1;
|
||||
const normalizedDownstreamSiteMultiplier =
|
||||
(Number.isFinite(downstreamSiteMultiplier) && downstreamSiteMultiplier > 0)
|
||||
? downstreamSiteMultiplier
|
||||
: 1;
|
||||
const siteGlobalWeight =
|
||||
(Number.isFinite(candidate.site.globalWeight) && (candidate.site.globalWeight || 0) > 0)
|
||||
? (candidate.site.globalWeight as number)
|
||||
: 1;
|
||||
const combinedSiteWeight = siteGlobalWeight * normalizedDownstreamSiteMultiplier;
|
||||
return {
|
||||
candidate,
|
||||
probability,
|
||||
reason: `按权重随机(W=${weight},成本=${costSourceText}:${(cost?.unitCost || 1).toFixed(6)},站点倍率=${siteMultiplier.toFixed(2)},同站点通道=${siteChannels},概率≈${(probability * 100).toFixed(1)}%)`,
|
||||
reason: `按权重随机(W=${weight},成本=${costSourceText}:${(cost?.unitCost || 1).toFixed(6)},站点权重=${siteGlobalWeight.toFixed(2)}x下游倍率=${normalizedDownstreamSiteMultiplier.toFixed(2)}=${combinedSiteWeight.toFixed(2)},同站点通道=${siteChannels},概率≈${(probability * 100).toFixed(1)}%)`,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user