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:
@@ -349,7 +349,7 @@ Metapi 完全自托管,所有数据(账号、令牌、路由、日志)均
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
[](https://www.star-history.com/#cita-777/metapi&type=date&legend=top-left)
|
||||
[](https://www.star-history.com/#cita-777/metapi&type=date&legend=top-left)
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
+44
@@ -4,6 +4,50 @@
|
||||
|
||||
---
|
||||
|
||||
<a id="account-auth-guide"></a>
|
||||
|
||||
## 账号认证方式与兼容性(Access Token / Cookie)
|
||||
|
||||
### Q: Access Token 和 Cookie 有什么区别?优先用哪个?
|
||||
|
||||
**A:** Metapi 支持两种 Session 凭证来源(都用于管理接口,如余额、签到等):
|
||||
|
||||
| 凭证 | 特点 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| **Access Token(系统访问令牌)** | 支持多账号、通常更稳定、更适合长期使用 | 标准 NewAPI / OneAPI / OneHub / DoneHub / Veloera 等站点 |
|
||||
| **Cookie(浏览器登录态)** | 兼容性强,但可能过期,且多账号体验较差 | 访问令牌不可用、魔改站点、特殊兼容场景 |
|
||||
|
||||
> 结论:优先使用 **Access Token**。仅在站点不支持或令牌不可用时使用 Cookie。
|
||||
|
||||
### Q: 在 Metapi 里怎么切换认证方式?
|
||||
|
||||
**A:** 在「账号管理 → 添加账号 → Cookie / Token 导入」中:
|
||||
|
||||
1. 选择站点
|
||||
2. 在「凭证模式」下拉选择:
|
||||
- 自动识别(推荐)
|
||||
- Session 模式(Access Token / Cookie)
|
||||
- API Key 模式(仅代理)
|
||||
3. 粘贴凭证并点击「验证 Token」
|
||||
|
||||
### Q: 以 NewAPI 为例,Access Token 在哪里生成?
|
||||
|
||||
**A:** 通常路径是:**控制台 → 个人设置 → 安全设置 → 系统访问令牌**。
|
||||
如果你确实需要 Cookie 兜底,可在浏览器 `F12 → Application → Cookie` 获取对应登录态。
|
||||
|
||||
### Q: AnyRouter 这类魔改站点为什么更容易报错?
|
||||
|
||||
**A:** AnyRouter 为魔改实现,删除了Access Token入口,也有其他类似的魔改中转站。建议:
|
||||
|
||||
1. 先尝试 Session 模式 + Access Token
|
||||
2. 若站点不支持或报认证相关错误,再尝试 Cookie
|
||||
|
||||
### Q: Sub2API 站点怎么处理?
|
||||
|
||||
**A:** Sub2API 常见 JWT 短期会话机制,和传统 NewAPI 站点差异较大。当前建议:
|
||||
|
||||
|
||||
|
||||
## 部署相关
|
||||
|
||||
### Q: 启动后无法访问管理后台
|
||||
|
||||
@@ -34,6 +34,7 @@ function ensureTokenManagementSchema() {
|
||||
account_id integer NOT NULL,
|
||||
name text NOT NULL,
|
||||
token text NOT NULL,
|
||||
token_group text,
|
||||
source text DEFAULT 'manual',
|
||||
enabled integer DEFAULT true,
|
||||
is_default integer DEFAULT false,
|
||||
@@ -47,6 +48,10 @@ function ensureTokenManagementSchema() {
|
||||
sqlite.exec('ALTER TABLE route_channels ADD COLUMN token_id integer;');
|
||||
}
|
||||
|
||||
if (!tableColumnExists('account_tokens', 'token_group')) {
|
||||
sqlite.exec('ALTER TABLE account_tokens ADD COLUMN token_group text;');
|
||||
}
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO account_tokens (account_id, name, token, source, enabled, is_default, created_at, updated_at)
|
||||
SELECT
|
||||
@@ -123,6 +128,33 @@ function ensureSiteProxySchema() {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSiteExternalCheckinUrlSchema() {
|
||||
if (!tableExists('sites')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tableColumnExists('sites', 'external_checkin_url')) {
|
||||
sqlite.exec(`ALTER TABLE sites ADD COLUMN external_checkin_url text;`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSiteGlobalWeightSchema() {
|
||||
if (!tableExists('sites')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tableColumnExists('sites', 'global_weight')) {
|
||||
sqlite.exec(`ALTER TABLE sites ADD COLUMN global_weight real DEFAULT 1;`);
|
||||
}
|
||||
|
||||
sqlite.exec(`
|
||||
UPDATE sites
|
||||
SET global_weight = 1
|
||||
WHERE global_weight IS NULL
|
||||
OR global_weight <= 0;
|
||||
`);
|
||||
}
|
||||
|
||||
function ensureRouteGroupingSchema() {
|
||||
if (!tableExists('token_routes') || !tableExists('route_channels')) {
|
||||
return;
|
||||
@@ -184,6 +216,8 @@ function ensureDownstreamApiKeySchema() {
|
||||
ensureTokenManagementSchema();
|
||||
ensureSiteStatusSchema();
|
||||
ensureSiteProxySchema();
|
||||
ensureSiteExternalCheckinUrlSchema();
|
||||
ensureSiteGlobalWeightSchema();
|
||||
ensureRouteGroupingSchema();
|
||||
ensureDownstreamApiKeySchema();
|
||||
|
||||
|
||||
@@ -5,11 +5,13 @@ export const sites = sqliteTable('sites', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
name: text('name').notNull(),
|
||||
url: text('url').notNull(),
|
||||
externalCheckinUrl: text('external_checkin_url'),
|
||||
platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' | 'openai' | 'claude' | 'gemini'
|
||||
proxyUrl: text('proxy_url'),
|
||||
status: text('status').notNull().default('active'), // 'active' | 'disabled'
|
||||
isPinned: integer('is_pinned', { mode: 'boolean' }).default(false),
|
||||
sortOrder: integer('sort_order').default(0),
|
||||
globalWeight: real('global_weight').default(1),
|
||||
apiKey: text('api_key'),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
@@ -48,6 +50,7 @@ export const accountTokens = sqliteTable('account_tokens', {
|
||||
accountId: integer('account_id').notNull().references(() => accounts.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
token: text('token').notNull(),
|
||||
tokenGroup: text('token_group'),
|
||||
source: text('source').default('manual'), // 'manual' | 'sync' | 'legacy'
|
||||
enabled: integer('enabled', { mode: 'boolean' }).default(true),
|
||||
isDefault: integer('is_default', { mode: 'boolean' }).default(false),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}%)`,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
+32
-13
@@ -7,6 +7,8 @@ import { api } from './api.js';
|
||||
import { clearAuthSession, hasValidAuthSession, persistAuthSession } from './authSession.js';
|
||||
import { I18nProvider, useI18n } from './i18n.js';
|
||||
import { resolveLoginErrorMessage } from './loginError.js';
|
||||
import { SITE_DOCS_URL } from './docsLink.js';
|
||||
import { useAnimatedVisibility } from './components/useAnimatedVisibility.js';
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard.js'));
|
||||
const Sites = lazy(() => import('./pages/Sites.js'));
|
||||
const Accounts = lazy(() => import('./pages/Accounts.js'));
|
||||
@@ -33,6 +35,7 @@ type UserProfile = {
|
||||
|
||||
const THEME_MODE_STORAGE_KEY = 'theme_mode';
|
||||
const USER_PROFILE_STORAGE_KEY = 'user_profile';
|
||||
const FIRST_USE_DOC_REMINDER_KEY = 'metapi_first_use_docs_reminder_seen_v1';
|
||||
const DICEBEAR_STYLES = [
|
||||
'pixel-art',
|
||||
'pixel-art-neutral',
|
||||
@@ -205,6 +208,7 @@ function UserProfileModal({
|
||||
onSave: (nextProfile: UserProfile) => void;
|
||||
t: (text: string) => string;
|
||||
}) {
|
||||
const presence = useAnimatedVisibility(open, 200);
|
||||
const [name, setName] = useState(profile.name);
|
||||
const [avatarSeed, setAvatarSeed] = useState(profile.avatarSeed);
|
||||
const [avatarStyle, setAvatarStyle] = useState(profile.avatarStyle);
|
||||
@@ -218,7 +222,7 @@ function UserProfileModal({
|
||||
setError('');
|
||||
}, [open, profile]);
|
||||
|
||||
if (!open) return null;
|
||||
if (!presence.shouldRender) return null;
|
||||
|
||||
const avatarUrl = buildDicebearAvatarUrl(avatarStyle, avatarSeed);
|
||||
|
||||
@@ -259,8 +263,8 @@ function UserProfileModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 440 }}>
|
||||
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
|
||||
<div className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={(e) => e.stopPropagation()} style={{ maxWidth: 440 }}>
|
||||
<div className="modal-header">{t('个人信息')}</div>
|
||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 2 }}>
|
||||
@@ -371,6 +375,8 @@ function AppShell() {
|
||||
const [showProfileModal, setShowProfileModal] = useState(false);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
const themeMenuPresence = useAnimatedVisibility(showThemeMenu, 160);
|
||||
const userMenuPresence = useAnimatedVisibility(showUserMenu, 160);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const notifBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const latestTaskEventIdRef = useRef(0);
|
||||
@@ -487,6 +493,13 @@ function AppShell() {
|
||||
return () => clearInterval(timer);
|
||||
}, [authed, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authed) return;
|
||||
if (localStorage.getItem(FIRST_USE_DOC_REMINDER_KEY)) return;
|
||||
localStorage.setItem(FIRST_USE_DOC_REMINDER_KEY, '1');
|
||||
toast.info(`${t('首次使用建议先阅读站点文档:')}${SITE_DOCS_URL}`);
|
||||
}, [authed, t, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
|
||||
@@ -544,19 +557,20 @@ function AppShell() {
|
||||
<div className="topbar-right">
|
||||
<button
|
||||
className="topbar-icon-btn"
|
||||
title={language === 'zh' ? 'Switch to English' : '切换到中文'}
|
||||
data-tooltip={language === 'zh' ? 'Switch to English' : '切换到中文'}
|
||||
aria-label={language === 'zh' ? 'Switch to English' : '切换到中文'}
|
||||
onClick={toggleLanguage}
|
||||
style={{ minWidth: 36, fontSize: 12, fontWeight: 700 }}
|
||||
>
|
||||
{language === 'zh' ? 'EN' : '中'}
|
||||
</button>
|
||||
<button className="topbar-search-trigger" title={t('搜索 (Ctrl+K)')} onClick={() => setShowSearch(true)}>
|
||||
<button className="topbar-search-trigger" data-tooltip={t('搜索 (Ctrl+K)')} aria-label={t('搜索 (Ctrl+K)')} onClick={() => setShowSearch(true)}>
|
||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
<span className="topbar-search-label">{t('搜索')}</span>
|
||||
<kbd className="topbar-search-kbd">Ctrl K</kbd>
|
||||
</button>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button ref={notifBtnRef} className="topbar-icon-btn" title={t('通知')} onClick={() => setShowNotifications(!showNotifications)}>
|
||||
<button ref={notifBtnRef} className="topbar-icon-btn" data-tooltip={t('通知')} aria-label={t('通知')} onClick={() => setShowNotifications(!showNotifications)}>
|
||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>
|
||||
{unreadCount > 0 && (
|
||||
<span className="topbar-badge">
|
||||
@@ -569,7 +583,10 @@ function AppShell() {
|
||||
<div ref={themeMenuRef} style={{ position: 'relative' }}>
|
||||
<button
|
||||
className="topbar-icon-btn"
|
||||
title={themeMode === 'system'
|
||||
data-tooltip={themeMode === 'system'
|
||||
? `${t('跟随系统')} (${resolvedThemeLabel})`
|
||||
: (themeMode === 'light' ? t('浅色模式') : t('深色模式'))}
|
||||
aria-label={themeMode === 'system'
|
||||
? `${t('跟随系统')} (${resolvedThemeLabel})`
|
||||
: (themeMode === 'light' ? t('浅色模式') : t('深色模式'))}
|
||||
onClick={() => {
|
||||
@@ -585,8 +602,8 @@ function AppShell() {
|
||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
|
||||
)}
|
||||
</button>
|
||||
{showThemeMenu && (
|
||||
<div className="user-dropdown" style={{ right: 0, left: 'auto', minWidth: 168 }}>
|
||||
{themeMenuPresence.shouldRender && (
|
||||
<div className={`user-dropdown ${themeMenuPresence.isVisible ? '' : 'is-closing'}`.trim()} style={{ right: 0, left: 'auto', minWidth: 168 }}>
|
||||
<button
|
||||
className="user-dropdown-item"
|
||||
onClick={() => handleSelectThemeMode('system')}
|
||||
@@ -614,7 +631,8 @@ function AppShell() {
|
||||
<div ref={userMenuRef} style={{ position: 'relative' }}>
|
||||
<div
|
||||
className="topbar-avatar"
|
||||
title={displayName}
|
||||
data-tooltip={displayName}
|
||||
aria-label={displayName}
|
||||
onClick={() => {
|
||||
setShowUserMenu(!showUserMenu);
|
||||
setShowThemeMenu(false);
|
||||
@@ -626,8 +644,8 @@ function AppShell() {
|
||||
style={{ width: '100%', height: '100%', borderRadius: '50%', objectFit: 'cover' }}
|
||||
/>
|
||||
</div>
|
||||
{showUserMenu && (
|
||||
<div className="user-dropdown">
|
||||
{userMenuPresence.shouldRender && (
|
||||
<div className={`user-dropdown ${userMenuPresence.isVisible ? '' : 'is-closing'}`.trim()}>
|
||||
<button
|
||||
className="user-dropdown-item"
|
||||
onClick={() => {
|
||||
@@ -662,7 +680,8 @@ function AppShell() {
|
||||
to={item.to}
|
||||
end={item.to === '/' || item.to === '/settings'}
|
||||
className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`}
|
||||
title={sidebarCollapsed ? t(item.label) : undefined}
|
||||
data-tooltip={sidebarCollapsed ? t(item.label) : undefined}
|
||||
aria-label={sidebarCollapsed ? t(item.label) : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
{!sidebarCollapsed && <span>{t(item.label)}</span>}
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@ export const api = {
|
||||
getAccounts: () => request('/api/accounts'),
|
||||
addAccount: (data: any) => request('/api/accounts', { method: 'POST', body: JSON.stringify(data) }),
|
||||
loginAccount: (data: { siteId: number; username: string; password: string }) => request('/api/accounts/login', { method: 'POST', body: JSON.stringify(data) }),
|
||||
verifyToken: (data: { siteId: number; accessToken: string; platformUserId?: number }) => request('/api/accounts/verify-token', { method: 'POST', body: JSON.stringify(data) }),
|
||||
verifyToken: (data: { siteId: number; accessToken: string; platformUserId?: number; credentialMode?: 'auto' | 'session' | 'apikey' }) => request('/api/accounts/verify-token', { method: 'POST', body: JSON.stringify(data) }),
|
||||
rebindAccountSession: (id: number, data: { accessToken: string; platformUserId?: number }) =>
|
||||
request(`/api/accounts/${id}/rebind-session`, { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateAccount: (id: number, data: any) => request(`/api/accounts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
|
||||
@@ -3,8 +3,10 @@ import { createPortal } from 'react-dom';
|
||||
import { api } from '../api.js';
|
||||
import { useToast } from './Toast.js';
|
||||
import { persistAuthSession } from '../authSession.js';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
|
||||
export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const presence = useAnimatedVisibility(open, 200);
|
||||
const [oldToken, setOldToken] = useState('');
|
||||
const [newToken, setNewToken] = useState('');
|
||||
const [confirmToken, setConfirmToken] = useState('');
|
||||
@@ -56,7 +58,7 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
if (!presence.shouldRender) return null;
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)',
|
||||
@@ -65,8 +67,8 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
|
||||
};
|
||||
|
||||
const modal = (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: 420 }}>
|
||||
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
|
||||
<div className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={e => e.stopPropagation()} style={{ maxWidth: 420 }}>
|
||||
<div className="modal-header">修改管理员 Token</div>
|
||||
|
||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { api } from '../api.js';
|
||||
import { formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js';
|
||||
import { buildEventNavigationPath } from '../pages/helpers/navigationFocus.js';
|
||||
import { useI18n } from '../i18n.js';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
info: 'var(--color-info)',
|
||||
@@ -31,6 +32,7 @@ export default function NotificationPanel({
|
||||
onUnreadCountChange?: (count: number) => void;
|
||||
}) {
|
||||
const { t: tr } = useI18n();
|
||||
const presence = useAnimatedVisibility(open, 160);
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filter, setFilter] = useState<string>('');
|
||||
@@ -77,10 +79,14 @@ export default function NotificationPanel({
|
||||
onUnreadCountChange?.(0);
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
if (!presence.shouldRender) return null;
|
||||
|
||||
return (
|
||||
<div ref={panelRef} className="user-dropdown" style={{ right: 0, top: '100%', width: 360, maxHeight: 480, padding: 0, marginTop: 4 }}>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`user-dropdown ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
style={{ right: 0, top: '100%', width: 360, maxHeight: 480, padding: 0, marginTop: 4 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', borderBottom: '1px solid var(--color-border-light)' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>{tr('通知')}</span>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { api } from '../api.js';
|
||||
import { formatDateLocal, formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js';
|
||||
import { buildAccountFocusPath, buildSiteFocusPath } from '../pages/helpers/navigationFocus.js';
|
||||
import { useI18n } from '../i18n.js';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
|
||||
interface SiteResult {
|
||||
id: number;
|
||||
@@ -52,6 +53,7 @@ interface SearchResult {
|
||||
|
||||
export default function SearchModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const presence = useAnimatedVisibility(open, 180);
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -109,7 +111,7 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
if (!presence.shouldRender) return null;
|
||||
|
||||
const hasResults = results && (
|
||||
results.models.length
|
||||
@@ -120,8 +122,8 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal-content" style={{ maxWidth: 560, padding: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
|
||||
<div className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()} style={{ maxWidth: 560, padding: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '14px 16px', borderBottom: '1px solid var(--color-border-light)' }}>
|
||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="var(--color-text-muted)">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useAnimatedVisibility(visible: boolean, durationMs = 220) {
|
||||
const [shouldRender, setShouldRender] = useState(visible);
|
||||
const [isVisible, setIsVisible] = useState(visible);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setShouldRender(true);
|
||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||
const rafId = window.requestAnimationFrame(() => setIsVisible(true));
|
||||
return () => window.cancelAnimationFrame(rafId);
|
||||
}
|
||||
setIsVisible(true);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
setIsVisible(false);
|
||||
if (durationMs <= 0) {
|
||||
setShouldRender(false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timerId = globalThis.setTimeout(() => setShouldRender(false), durationMs);
|
||||
return () => globalThis.clearTimeout(timerId);
|
||||
}, [visible, durationMs]);
|
||||
|
||||
return { shouldRender, isVisible };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const SITE_DOCS_URL = 'https://metapi.cita777.me/faq';
|
||||
@@ -65,8 +65,10 @@ const zhToEn: Record<string, string> = {
|
||||
'模型测试': 'Model Testing',
|
||||
'关于': 'About',
|
||||
'关于 Metapi': 'About Metapi',
|
||||
'站点文档': 'Site Docs',
|
||||
'任务状态已更新': 'Task status updated',
|
||||
'会话已过期,请重新登录': 'Session expired, please sign in again',
|
||||
'首次使用建议先阅读站点文档:': 'For first-time setup, read site docs: ',
|
||||
'个人信息已保存': 'Profile saved',
|
||||
'搜索': 'Search',
|
||||
'搜索 (Ctrl+K)': 'Search (Ctrl+K)',
|
||||
|
||||
@@ -100,6 +100,18 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -136,6 +148,30 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dropdown-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes count-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -1370,6 +1406,121 @@ body {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.route-enable-toggle {
|
||||
border: 1px solid transparent;
|
||||
transition: background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.route-enable-toggle:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.route-enable-toggle:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--color-primary) 40%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-tooltip]::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 10px);
|
||||
transform: translate(-50%, 6px) scale(0.98);
|
||||
width: max-content;
|
||||
min-width: 220px;
|
||||
max-width: min(520px, calc(100vw - 40px));
|
||||
padding: 6px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
pointer-events: none;
|
||||
z-index: 40;
|
||||
opacity: 0;
|
||||
color: color-mix(in srgb, var(--color-text-primary) 90%, #0f172a);
|
||||
background: color-mix(in srgb, #ffffff 92%, var(--color-primary) 8%);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 22%, transparent);
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.14);
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
[data-tooltip]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 4px);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
pointer-events: none;
|
||||
z-index: 39;
|
||||
opacity: 0;
|
||||
background: color-mix(in srgb, #ffffff 92%, var(--color-primary) 8%);
|
||||
border-right: 1px solid color-mix(in srgb, var(--color-primary) 22%, transparent);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-primary) 22%, transparent);
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
[data-tooltip]:hover::after,
|
||||
[data-tooltip]:hover::before,
|
||||
[data-tooltip]:focus-visible::after,
|
||||
[data-tooltip]:focus-visible::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-tooltip]:hover::after,
|
||||
[data-tooltip]:focus-visible::after {
|
||||
transform: translate(-50%, 0) scale(1);
|
||||
}
|
||||
|
||||
.missing-token-site-tag {
|
||||
border: 1px solid color-mix(in srgb, var(--color-info) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--color-info) 12%, var(--color-bg));
|
||||
transition: transform 0.18s ease, background-color 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.missing-token-site-tag:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: color-mix(in srgb, var(--color-info) 65%, transparent);
|
||||
background: color-mix(in srgb, var(--color-info) 22%, var(--color-bg));
|
||||
box-shadow: 0 6px 14px color-mix(in srgb, var(--color-info) 22%, transparent);
|
||||
}
|
||||
|
||||
.missing-token-site-tag:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--color-info) 50%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.route-enable-toggle.is-enabled {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
border-color: color-mix(in srgb, #16a34a 28%, transparent);
|
||||
}
|
||||
|
||||
.route-enable-toggle.is-enabled:hover {
|
||||
background: color-mix(in srgb, #dcfce7 72%, white);
|
||||
box-shadow: 0 2px 8px color-mix(in srgb, #16a34a 24%, transparent);
|
||||
}
|
||||
|
||||
.route-enable-toggle.is-disabled {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: var(--color-text-muted);
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.route-enable-toggle.is-disabled:hover {
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-bg-card));
|
||||
color: var(--color-primary);
|
||||
border-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
|
||||
box-shadow: 0 2px 8px color-mix(in srgb, var(--color-primary) 22%, transparent);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .badge-success {
|
||||
background: #052e16;
|
||||
color: #4ade80;
|
||||
@@ -1401,6 +1552,45 @@ body {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .missing-token-site-tag:hover {
|
||||
background: color-mix(in srgb, var(--color-info) 26%, var(--color-surface));
|
||||
}
|
||||
|
||||
[data-theme="dark"] [data-tooltip]::after {
|
||||
color: color-mix(in srgb, #e2e8f0 94%, white);
|
||||
background: color-mix(in srgb, #0f172a 88%, var(--color-primary) 12%);
|
||||
border-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
|
||||
box-shadow: 0 12px 30px rgba(2, 6, 23, 0.48);
|
||||
}
|
||||
|
||||
[data-theme="dark"] [data-tooltip]::before {
|
||||
background: color-mix(in srgb, #0f172a 88%, var(--color-primary) 12%);
|
||||
border-right-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
|
||||
border-bottom-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .route-enable-toggle.is-enabled {
|
||||
background: #052e16;
|
||||
color: #4ade80;
|
||||
border-color: color-mix(in srgb, #4ade80 35%, transparent);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .route-enable-toggle.is-enabled:hover {
|
||||
background: color-mix(in srgb, #052e16 70%, #1f2937);
|
||||
box-shadow: 0 2px 8px color-mix(in srgb, #4ade80 28%, transparent);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .route-enable-toggle.is-disabled {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--color-text-muted);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .route-enable-toggle.is-disabled:hover {
|
||||
background: color-mix(in srgb, var(--color-primary) 18%, var(--color-bg-card));
|
||||
color: #a5b4fc;
|
||||
}
|
||||
|
||||
/* ===== Tabs ===== */
|
||||
.tabs {
|
||||
display: flex;
|
||||
@@ -1628,6 +1818,11 @@ textarea {
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal-backdrop.is-closing {
|
||||
animation: fade-out 0.2s ease both;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--color-bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -1640,6 +1835,10 @@ textarea {
|
||||
animation: scale-in 0.25s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.modal-content.is-closing {
|
||||
animation: scale-out 0.22s cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 20px 24px 0;
|
||||
font-size: 16px;
|
||||
@@ -2018,6 +2217,11 @@ textarea {
|
||||
animation: slide-down 0.2s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.user-dropdown.is-closing {
|
||||
animation: dropdown-out 0.16s ease both;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.user-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2170,6 +2374,26 @@ textarea {
|
||||
top: calc(var(--topbar-height) + 24px);
|
||||
}
|
||||
|
||||
.filter-panel.filter-collapsible {
|
||||
transition:
|
||||
width 0.26s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 0.2s ease,
|
||||
transform 0.26s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
padding 0.26s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
border-color 0.2s ease;
|
||||
transform-origin: left top;
|
||||
}
|
||||
|
||||
.filter-panel.filter-collapsible.is-closing {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
padding: 0;
|
||||
border-color: transparent;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.filter-panel-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@@ -2435,6 +2659,42 @@ textarea {
|
||||
animation: fade-in 0.2s ease both;
|
||||
}
|
||||
|
||||
.anim-collapse {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
grid-template-rows 0.26s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 0.2s ease,
|
||||
transform 0.24s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.anim-collapse.is-open {
|
||||
grid-template-rows: 1fr;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.anim-collapse-inner {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-presence {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
transition: opacity 0.2s ease, transform 0.24s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.panel-presence.is-closing {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px) scale(0.985);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ===== Pagination ===== */
|
||||
.pagination {
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { tr } from '../i18n.js';
|
||||
import { SITE_DOCS_URL } from '../docsLink.js';
|
||||
|
||||
const VERSION = '1.2.0';
|
||||
|
||||
@@ -25,6 +26,7 @@ const TECH_STACK = [
|
||||
const LINKS = [
|
||||
{ label: 'GitHub', href: 'https://github.com/cita-777/metapi', icon: '📂' },
|
||||
{ label: 'Docker Hub', href: 'https://hub.docker.com/r/1467078763/metapi', icon: '🐳' },
|
||||
{ label: '站点文档', href: SITE_DOCS_URL, icon: '📚' },
|
||||
];
|
||||
|
||||
export default function About() {
|
||||
@@ -125,7 +127,7 @@ export default function About() {
|
||||
onMouseLeave={(e) => (e.currentTarget.style.borderColor = 'var(--color-border-light)')}
|
||||
>
|
||||
<span>{l.icon}</span>
|
||||
<span>{l.label}</span>
|
||||
<span>{tr(l.label)}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+292
-145
@@ -3,10 +3,12 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
import { getAccountsAddPanelStyle } from './helpers/accountsPanelStyle.js';
|
||||
import { clearFocusParams, readFocusAccountIntent } from './helpers/navigationFocus.js';
|
||||
import { tr } from '../i18n.js';
|
||||
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
|
||||
import { SITE_DOCS_URL } from '../docsLink.js';
|
||||
|
||||
export default function Accounts() {
|
||||
const location = useLocation();
|
||||
@@ -19,7 +21,15 @@ export default function Accounts() {
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [addMode, setAddMode] = useState<'token' | 'login'>('token');
|
||||
const [loginForm, setLoginForm] = useState({ siteId: 0, username: '', password: '' });
|
||||
const [tokenForm, setTokenForm] = useState({ siteId: 0, accessToken: '', platformUserId: '' });
|
||||
const [tokenForm, setTokenForm] = useState({
|
||||
siteId: 0,
|
||||
username: '',
|
||||
accessToken: '',
|
||||
platformUserId: '',
|
||||
refreshToken: '',
|
||||
tokenExpiresAt: '',
|
||||
credentialMode: 'auto' as 'auto' | 'session' | 'apikey',
|
||||
});
|
||||
const [verifyResult, setVerifyResult] = useState<any>(null);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -31,11 +41,16 @@ export default function Accounts() {
|
||||
const [rebindSaving, setRebindSaving] = useState(false);
|
||||
const [highlightRebindPanel, setHighlightRebindPanel] = useState(false);
|
||||
const [rebindFocusTrigger, setRebindFocusTrigger] = useState(0);
|
||||
const addPanelPresence = useAnimatedVisibility(showAdd, 220);
|
||||
const rebindPanelPresence = useAnimatedVisibility(Boolean(rebindTarget), 220);
|
||||
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
|
||||
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const rebindPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
const rebindPanelTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastRebindTargetRef = useRef<any | null>(null);
|
||||
const toast = useToast();
|
||||
if (rebindTarget) lastRebindTargetRef.current = rebindTarget;
|
||||
const activeRebindTarget = rebindTarget || lastRebindTargetRef.current;
|
||||
|
||||
const load = async () => {
|
||||
const [accountsResult, sitesResult] = await Promise.allSettled([
|
||||
@@ -54,6 +69,12 @@ export default function Accounts() {
|
||||
};
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
const selectedTokenSite = useMemo(
|
||||
() => sites.find((item) => item.id === tokenForm.siteId) || null,
|
||||
[sites, tokenForm.siteId],
|
||||
);
|
||||
const isSub2ApiSelected = (selectedTokenSite?.platform || '').toLowerCase() === 'sub2api';
|
||||
|
||||
const sortedAccounts = useMemo(
|
||||
() => sortItemsForDisplay(accounts, sortMode, (account) => account.balance || 0),
|
||||
[accounts, sortMode],
|
||||
@@ -102,10 +123,15 @@ export default function Accounts() {
|
||||
siteId: tokenForm.siteId,
|
||||
accessToken: tokenForm.accessToken,
|
||||
platformUserId: tokenForm.platformUserId ? parseInt(tokenForm.platformUserId) : undefined,
|
||||
credentialMode: tokenForm.credentialMode,
|
||||
});
|
||||
setVerifyResult(result);
|
||||
if (result.success) {
|
||||
toast.success(`验证成功: ${result.userInfo?.username || '未知用户'}`);
|
||||
if (result.tokenType === 'apikey') {
|
||||
toast.success(`API Key 验证成功(可用模型 ${result.modelCount || 0} 个)`);
|
||||
} else {
|
||||
toast.success(`Session 验证成功: ${result.userInfo?.username || '未知用户'}`);
|
||||
}
|
||||
} else {
|
||||
toast.error(result.message || 'Token 无效');
|
||||
}
|
||||
@@ -127,11 +153,27 @@ export default function Accounts() {
|
||||
try {
|
||||
const result = await api.addAccount({
|
||||
siteId: tokenForm.siteId,
|
||||
username: tokenForm.username.trim() || undefined,
|
||||
accessToken: tokenForm.accessToken,
|
||||
platformUserId: tokenForm.platformUserId ? parseInt(tokenForm.platformUserId) : undefined,
|
||||
refreshToken: isSub2ApiSelected && tokenForm.refreshToken.trim()
|
||||
? tokenForm.refreshToken.trim()
|
||||
: undefined,
|
||||
tokenExpiresAt: isSub2ApiSelected && tokenForm.tokenExpiresAt.trim()
|
||||
? Number.parseInt(tokenForm.tokenExpiresAt.trim(), 10)
|
||||
: undefined,
|
||||
credentialMode: tokenForm.credentialMode,
|
||||
});
|
||||
setShowAdd(false);
|
||||
setTokenForm({ siteId: 0, accessToken: '', platformUserId: '' });
|
||||
setTokenForm({
|
||||
siteId: 0,
|
||||
username: '',
|
||||
accessToken: '',
|
||||
platformUserId: '',
|
||||
refreshToken: '',
|
||||
tokenExpiresAt: '',
|
||||
credentialMode: 'auto',
|
||||
});
|
||||
setVerifyResult(null);
|
||||
if (result.tokenType === 'apikey') {
|
||||
toast.success('已添加为 API Key 账号(可用于代理转发)');
|
||||
@@ -192,6 +234,23 @@ export default function Accounts() {
|
||||
return { state, reason, ...cfg };
|
||||
};
|
||||
|
||||
const resolveAccountCapabilities = (account: any) => {
|
||||
const fromServer = account?.capabilities;
|
||||
if (fromServer && typeof fromServer === 'object') {
|
||||
return {
|
||||
canCheckin: !!fromServer.canCheckin,
|
||||
canRefreshBalance: !!fromServer.canRefreshBalance,
|
||||
proxyOnly: !!fromServer.proxyOnly,
|
||||
};
|
||||
}
|
||||
const hasSession = typeof account?.accessToken === 'string' && account.accessToken.trim().length > 0;
|
||||
return {
|
||||
canCheckin: hasSession,
|
||||
canRefreshBalance: hasSession,
|
||||
proxyOnly: !hasSession,
|
||||
};
|
||||
};
|
||||
|
||||
const handleRefreshRuntimeHealth = async () => {
|
||||
setActionLoading((s) => ({ ...s, 'health-refresh': true }));
|
||||
try {
|
||||
@@ -261,7 +320,7 @@ export default function Accounts() {
|
||||
const raw = parsed?.platformUserId;
|
||||
const value = Number.parseInt(String(raw ?? ''), 10);
|
||||
if (Number.isFinite(value) && value > 0) return String(value);
|
||||
} catch {}
|
||||
} catch { }
|
||||
const guessed = Number.parseInt(String(account?.username || '').match(/(\d{3,8})$/)?.[1] || '', 10);
|
||||
return Number.isFinite(guessed) && guessed > 0 ? String(guessed) : '';
|
||||
};
|
||||
@@ -308,6 +367,7 @@ export default function Accounts() {
|
||||
siteId: rebindTarget.siteId,
|
||||
accessToken: rebindForm.accessToken.trim(),
|
||||
platformUserId: rebindForm.platformUserId ? Number.parseInt(rebindForm.platformUserId, 10) : undefined,
|
||||
credentialMode: 'session',
|
||||
});
|
||||
setRebindVerifyResult(result);
|
||||
if (result.success && result.tokenType === 'session') {
|
||||
@@ -412,8 +472,8 @@ export default function Accounts() {
|
||||
</div>
|
||||
|
||||
{/* Add Panel */}
|
||||
{showAdd && (
|
||||
<div className="card animate-scale-in" style={getAccountsAddPanelStyle()}>
|
||||
{addPanelPresence.shouldRender && (
|
||||
<div className={`card panel-presence ${addPanelPresence.isVisible ? '' : 'is-closing'}`.trim()} style={getAccountsAddPanelStyle()}>
|
||||
{/* Mode tabs */}
|
||||
<div style={{ display: 'flex', gap: 0, background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', padding: 3, marginBottom: 16 }}>
|
||||
<button onClick={() => { setAddMode('token'); setVerifyResult(null); }}
|
||||
@@ -440,11 +500,22 @@ export default function Accounts() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div className="info-tip">
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>支持两种凭证类型,系统自动识别</div>
|
||||
<div><strong>API Key</strong>(在站点「令牌」页面生成)→ 用于代理转发</div>
|
||||
<div><strong>Session Cookie</strong>(从浏览器获取)→ 支持签到、余额查询等全部功能</div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>凭证模式:Session / API Key(可切换,默认自动识别)</div>
|
||||
<div><strong>API Key</strong>(在站点「令牌」页面生成)→ 仅代理转发</div>
|
||||
<div><strong>Session 模式</strong>支持两种凭证:<strong>系统访问令牌(Access Token,推荐)</strong> 或 <strong>浏览器 Cookie</strong></div>
|
||||
<div style={{ marginTop: 2 }}>以 NewAPI 为例:控制台 → 个人设置 → 安全设置 → 生成「系统访问令牌」</div>
|
||||
<div style={{ opacity: 0.7, borderTop: '1px solid rgba(0,0,0,0.1)', paddingTop: 6, marginTop: 6 }}>
|
||||
获取 Session Cookie: <kbd style={{ padding: '1px 5px', background: 'var(--color-bg-card)', border: '1px solid var(--color-border)', borderRadius: 3, fontSize: 11 }}>F12</kbd> → Application → Local Storage</div>
|
||||
推荐优先使用 Access Token(更稳定、可多账号);Cookie 仅用于兼容场景。获取 Cookie: <kbd style={{ padding: '1px 5px', background: 'var(--color-bg-card)', border: '1px solid var(--color-border)', borderRadius: 3, fontSize: 11 }}>F12</kbd> → Application → Cookie</div>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<a
|
||||
href={SITE_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: 12, color: 'var(--color-primary)', textDecoration: 'underline' }}
|
||||
>
|
||||
查看认证方式与特殊站点说明文档
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ModernSelect
|
||||
@@ -463,17 +534,75 @@ export default function Accounts() {
|
||||
]}
|
||||
placeholder="选择站点"
|
||||
/>
|
||||
<textarea placeholder="粘贴 Session Cookie 或 API Key (系统会自动识别凭证类型)"
|
||||
<input
|
||||
placeholder="账号名称(可选,API Key 模式建议填写)"
|
||||
value={tokenForm.username}
|
||||
onChange={(e) => setTokenForm((f) => ({ ...f, username: e.target.value }))}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<ModernSelect
|
||||
value={tokenForm.credentialMode}
|
||||
onChange={(nextValue) => {
|
||||
const nextMode = (nextValue as 'auto' | 'session' | 'apikey') || 'auto';
|
||||
setTokenForm((f) => ({ ...f, credentialMode: nextMode }));
|
||||
setVerifyResult(null);
|
||||
}}
|
||||
options={[
|
||||
{ value: 'auto', label: '自动识别(推荐)' },
|
||||
{ value: 'session', label: 'Session 模式(Access Token / Cookie)' },
|
||||
{ value: 'apikey', label: 'API Key 模式' },
|
||||
]}
|
||||
placeholder="选择凭证模式"
|
||||
/>
|
||||
<textarea placeholder={
|
||||
tokenForm.credentialMode === 'session'
|
||||
? '粘贴系统访问令牌(Access Token)或 Session Cookie'
|
||||
: (tokenForm.credentialMode === 'apikey'
|
||||
? '粘贴 API Key'
|
||||
: '粘贴 Access Token / Session Cookie / API Key(自动识别)')
|
||||
}
|
||||
value={tokenForm.accessToken}
|
||||
onChange={e => { setTokenForm(f => ({ ...f, accessToken: e.target.value.trim() })); setVerifyResult(null); }}
|
||||
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', height: 72, resize: 'none' as const }} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<input
|
||||
placeholder="用户 ID(可选)"
|
||||
value={tokenForm.platformUserId}
|
||||
onChange={e => { setTokenForm(f => ({ ...f, platformUserId: e.target.value.replace(/\D/g, '') })); setVerifyResult(null); }}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
若站点要求 New-Api-User / User-ID,请在这里提前填写。
|
||||
</div>
|
||||
</div>
|
||||
{isSub2ApiSelected && (
|
||||
<>
|
||||
<input
|
||||
placeholder="Sub2API refresh_token(可选,用于托管自动续期)"
|
||||
value={tokenForm.refreshToken}
|
||||
onChange={(e) => setTokenForm((f) => ({ ...f, refreshToken: e.target.value.trim() }))}
|
||||
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<input
|
||||
placeholder="token_expires_at(可选,毫秒时间戳)"
|
||||
value={tokenForm.tokenExpiresAt}
|
||||
onChange={(e) => setTokenForm((f) => ({ ...f, tokenExpiresAt: e.target.value.replace(/\D/g, '') }))}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
配置 refresh_token 后,metapi 会在 JWT 临近过期或 401 时自动续期并回写新 token。
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Verify results */}
|
||||
{verifyResult && verifyResult.success && verifyResult.tokenType === 'session' && (
|
||||
<div className="alert alert-success animate-scale-in">
|
||||
<div className="alert-title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
Session Cookie 有效
|
||||
Session 凭证有效(Access Token / Cookie)
|
||||
</div>
|
||||
<div style={{ fontSize: 12, lineHeight: 1.8 }}>
|
||||
<div>用户名: <strong>{verifyResult.userInfo?.username || '未知'}</strong></div>
|
||||
@@ -499,11 +628,8 @@ export default function Accounts() {
|
||||
{verifyResult && !verifyResult.success && verifyResult.needsUserId && (
|
||||
<div className="alert alert-warning animate-scale-in">
|
||||
<div className="alert-title">
|
||||
Token 已识别,但此站点需要提供用户 ID
|
||||
此站点要求用户 ID,请补充后重新验证
|
||||
</div>
|
||||
<input placeholder="用户 ID(数字)" value={tokenForm.platformUserId}
|
||||
onChange={e => setTokenForm(f => ({ ...f, platformUserId: e.target.value.replace(/\D/g, '') }))}
|
||||
style={{ ...inputStyle, borderColor: 'color-mix(in srgb, var(--color-warning) 45%, transparent)' }} />
|
||||
</div>
|
||||
)}
|
||||
{verifyResult && !verifyResult.success && !verifyResult.needsUserId && (
|
||||
@@ -562,10 +688,10 @@ export default function Accounts() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rebindTarget && (
|
||||
{rebindPanelPresence.shouldRender && activeRebindTarget && (
|
||||
<div
|
||||
ref={rebindPanelRef}
|
||||
className={`card animate-scale-in rebind-panel ${highlightRebindPanel ? 'rebind-panel-highlight' : ''}`}
|
||||
className={`card panel-presence rebind-panel ${rebindPanelPresence.isVisible ? '' : 'is-closing'} ${highlightRebindPanel ? 'rebind-panel-highlight' : ''}`.trim()}
|
||||
style={{ marginBottom: 16, padding: 16 }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
@@ -575,7 +701,7 @@ export default function Accounts() {
|
||||
<button className="btn btn-ghost" onClick={closeRebindPanel}>关闭</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||
账号: {rebindTarget.username || '未命名'} @ {rebindTarget.site?.name || '-'}。请粘贴新的 Session Token,验证成功后再绑定。
|
||||
账号: {activeRebindTarget.username || '未命名'} @ {activeRebindTarget.site?.name || '-'}。请粘贴新的 Session Token,验证成功后再绑定。
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 220px', gap: 10, marginBottom: 10 }}>
|
||||
@@ -654,140 +780,161 @@ export default function Accounts() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedAccounts.map((a: any, i: number) => (
|
||||
<tr
|
||||
key={a.id}
|
||||
ref={(node) => {
|
||||
if (node) rowRefs.current.set(a.id, node);
|
||||
else rowRefs.current.delete(a.id);
|
||||
}}
|
||||
className={`animate-slide-up stagger-${Math.min(i + 1, 5)} ${highlightAccountId === a.id ? 'row-focus-highlight' : ''}`}
|
||||
>
|
||||
<td style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>{a.username || '未命名'}</td>
|
||||
<td>
|
||||
{a.site?.url ? (
|
||||
<a
|
||||
href={a.site.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="badge-link"
|
||||
>
|
||||
{sortedAccounts.map((a: any, i: number) => {
|
||||
const capabilities = resolveAccountCapabilities(a);
|
||||
return (
|
||||
<tr
|
||||
key={a.id}
|
||||
ref={(node) => {
|
||||
if (node) rowRefs.current.set(a.id, node);
|
||||
else rowRefs.current.delete(a.id);
|
||||
}}
|
||||
className={`animate-slide-up stagger-${Math.min(i + 1, 5)} ${highlightAccountId === a.id ? 'row-focus-highlight' : ''}`}
|
||||
>
|
||||
<td style={{ color: 'var(--color-text-primary)' }}>
|
||||
<div style={{ fontWeight: 600 }}>{a.username || '未命名'}</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
|
||||
{capabilities.proxyOnly && (
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>仅代理</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{a.site?.url ? (
|
||||
<a
|
||||
href={a.site.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="badge-link"
|
||||
>
|
||||
<span className="badge badge-muted" style={{ fontSize: 11 }}>
|
||||
{a.site?.name || '-'}
|
||||
</span>
|
||||
</a>
|
||||
) : (
|
||||
<span className="badge badge-muted" style={{ fontSize: 11 }}>
|
||||
{a.site?.name || '-'}
|
||||
</span>
|
||||
</a>
|
||||
) : (
|
||||
<span className="badge badge-muted" style={{ fontSize: 11 }}>
|
||||
{a.site?.name || '-'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{(() => {
|
||||
const health = resolveRuntimeHealth(a);
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span className={`badge ${health.cls}`} style={{ fontSize: 11, display: 'inline-flex', alignItems: 'center', gap: 4, width: 'fit-content' }}>
|
||||
<span className={`status-dot ${health.dotClass} ${health.pulse ? 'animate-pulse-dot' : ''}`} style={{ marginRight: 0 }} />
|
||||
{health.label}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--color-text-muted)',
|
||||
maxWidth: 200,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={health.reason}
|
||||
>
|
||||
{health.reason}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
<div style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>${(a.balance || 0).toFixed(2)}</div>
|
||||
<div style={{ fontSize: 11, color: (a.todayReward || 0) > 0 ? 'var(--color-success)' : 'var(--color-text-muted)', fontWeight: 500 }}>
|
||||
+{(a.todayReward || 0).toFixed(2)}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>
|
||||
<div>${(a.balanceUsed || 0).toFixed(2)}</div>
|
||||
<div style={{ fontSize: 11, color: (a.todaySpend || 0) > 0 ? 'var(--color-danger)' : 'var(--color-text-muted)', fontWeight: 500 }}>
|
||||
-{(a.todaySpend || 0).toFixed(2)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className={`checkin-toggle-badge ${a.checkinEnabled ? 'is-on' : 'is-off'}`}
|
||||
onClick={() => handleToggleCheckin(a)}
|
||||
disabled={!!actionLoading[`checkin-toggle-${a.id}`]}
|
||||
title={a.checkinEnabled ? '点击关闭签到,全部签到会忽略此账号' : '点击开启签到'}
|
||||
>
|
||||
{actionLoading[`checkin-toggle-${a.id}`]
|
||||
? <span className="spinner spinner-sm" />
|
||||
: (a.checkinEnabled ? '开启' : '关闭')}
|
||||
</button>
|
||||
</td>
|
||||
<td className="accounts-actions-cell" style={{ textAlign: 'right' }}>
|
||||
<div className="accounts-row-actions">
|
||||
<button
|
||||
onClick={() => handleTogglePin(a)}
|
||||
disabled={!!actionLoading[`pin-toggle-${a.id}`]}
|
||||
className={`btn btn-link ${a.isPinned ? 'btn-link-warning' : 'btn-link-primary'}`}
|
||||
>
|
||||
{actionLoading[`pin-toggle-${a.id}`] ? <span className="spinner spinner-sm" /> : (a.isPinned ? '取消置顶' : '置顶')}
|
||||
</button>
|
||||
{sortMode === 'custom' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleMoveCustomOrder(a, 'up')}
|
||||
disabled={!!actionLoading[`reorder-${a.id}`]}
|
||||
className="btn btn-link btn-link-muted"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMoveCustomOrder(a, 'down')}
|
||||
disabled={!!actionLoading[`reorder-${a.id}`]}
|
||||
className="btn btn-link btn-link-muted"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => withLoading(`refresh-${a.id}`, () => api.refreshBalance(a.id), '余额已刷新')} disabled={actionLoading[`refresh-${a.id}`]}
|
||||
className="btn btn-link btn-link-primary">
|
||||
{actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'}
|
||||
</button>
|
||||
<button onClick={() => withLoading(`models-${a.id}`, () => api.checkModels(a.id), '模型已更新')} disabled={actionLoading[`models-${a.id}`]}
|
||||
className="btn btn-link btn-link-info">
|
||||
{actionLoading[`models-${a.id}`] ? <span className="spinner spinner-sm" /> : '模型'}
|
||||
</button>
|
||||
<button onClick={() => withLoading(`checkin-${a.id}`, () => api.triggerCheckin(a.id), '签到完成')} disabled={actionLoading[`checkin-${a.id}`]}
|
||||
className="btn btn-link btn-link-warning">
|
||||
{actionLoading[`checkin-${a.id}`] ? <span className="spinner spinner-sm" /> : '签到'}
|
||||
</button>
|
||||
{a.status === 'expired' && (
|
||||
</td>
|
||||
<td>
|
||||
{(() => {
|
||||
const health = resolveRuntimeHealth(a);
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span className={`badge ${health.cls}`} style={{ fontSize: 11, display: 'inline-flex', alignItems: 'center', gap: 4, width: 'fit-content' }}>
|
||||
<span className={`status-dot ${health.dotClass} ${health.pulse ? 'animate-pulse-dot' : ''}`} style={{ marginRight: 0 }} />
|
||||
{health.label}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--color-text-muted)',
|
||||
maxWidth: 200,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
data-tooltip={health.reason}
|
||||
>
|
||||
{health.reason}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
<div style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>${(a.balance || 0).toFixed(2)}</div>
|
||||
<div style={{ fontSize: 11, color: (a.todayReward || 0) > 0 ? 'var(--color-success)' : 'var(--color-text-muted)', fontWeight: 500 }}>
|
||||
+{(a.todayReward || 0).toFixed(2)}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>
|
||||
<div>${(a.balanceUsed || 0).toFixed(2)}</div>
|
||||
<div style={{ fontSize: 11, color: (a.todaySpend || 0) > 0 ? 'var(--color-danger)' : 'var(--color-text-muted)', fontWeight: 500 }}>
|
||||
-{(a.todaySpend || 0).toFixed(2)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{capabilities.canCheckin ? (
|
||||
<button
|
||||
onClick={() => openRebindPanel(a)}
|
||||
className="btn btn-link btn-link-warning"
|
||||
type="button"
|
||||
className={`checkin-toggle-badge ${a.checkinEnabled ? 'is-on' : 'is-off'}`}
|
||||
onClick={() => handleToggleCheckin(a)}
|
||||
disabled={!!actionLoading[`checkin-toggle-${a.id}`]}
|
||||
data-tooltip={a.checkinEnabled ? '点击关闭签到,全部签到会忽略此账号' : '点击开启签到'}
|
||||
aria-label={a.checkinEnabled ? '点击关闭签到,全部签到会忽略此账号' : '点击开启签到'}
|
||||
>
|
||||
重新绑定
|
||||
{actionLoading[`checkin-toggle-${a.id}`]
|
||||
? <span className="spinner spinner-sm" />
|
||||
: (a.checkinEnabled ? '开启' : '关闭')}
|
||||
</button>
|
||||
) : (
|
||||
<span className="badge badge-muted" style={{ fontSize: 11 }}>
|
||||
不支持
|
||||
</span>
|
||||
)}
|
||||
<button onClick={() => withLoading(`delete-${a.id}`, () => api.deleteAccount(a.id), '已删除')} disabled={actionLoading[`delete-${a.id}`]}
|
||||
className="btn btn-link btn-link-danger">
|
||||
{actionLoading[`delete-${a.id}`] ? <span className="spinner spinner-sm" /> : '删除'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</td>
|
||||
<td className="accounts-actions-cell" style={{ textAlign: 'right' }}>
|
||||
<div className="accounts-row-actions">
|
||||
<button
|
||||
onClick={() => handleTogglePin(a)}
|
||||
disabled={!!actionLoading[`pin-toggle-${a.id}`]}
|
||||
className={`btn btn-link ${a.isPinned ? 'btn-link-warning' : 'btn-link-primary'}`}
|
||||
>
|
||||
{actionLoading[`pin-toggle-${a.id}`] ? <span className="spinner spinner-sm" /> : (a.isPinned ? '取消置顶' : '置顶')}
|
||||
</button>
|
||||
{sortMode === 'custom' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleMoveCustomOrder(a, 'up')}
|
||||
disabled={!!actionLoading[`reorder-${a.id}`]}
|
||||
className="btn btn-link btn-link-muted"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMoveCustomOrder(a, 'down')}
|
||||
disabled={!!actionLoading[`reorder-${a.id}`]}
|
||||
className="btn btn-link btn-link-muted"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{capabilities.canRefreshBalance && (
|
||||
<button onClick={() => withLoading(`refresh-${a.id}`, () => api.refreshBalance(a.id), '余额已刷新')} disabled={actionLoading[`refresh-${a.id}`]}
|
||||
className="btn btn-link btn-link-primary">
|
||||
{actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => withLoading(`models-${a.id}`, () => api.checkModels(a.id), '模型已更新')} disabled={actionLoading[`models-${a.id}`]}
|
||||
className="btn btn-link btn-link-info">
|
||||
{actionLoading[`models-${a.id}`] ? <span className="spinner spinner-sm" /> : '模型'}
|
||||
</button>
|
||||
{capabilities.canCheckin && (
|
||||
<button onClick={() => withLoading(`checkin-${a.id}`, () => api.triggerCheckin(a.id), '签到完成')} disabled={actionLoading[`checkin-${a.id}`]}
|
||||
className="btn btn-link btn-link-warning">
|
||||
{actionLoading[`checkin-${a.id}`] ? <span className="spinner spinner-sm" /> : '签到'}
|
||||
</button>
|
||||
)}
|
||||
{a.status === 'expired' && (
|
||||
<button
|
||||
onClick={() => openRebindPanel(a)}
|
||||
className="btn btn-link btn-link-warning"
|
||||
>
|
||||
重新绑定
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => withLoading(`delete-${a.id}`, () => api.deleteAccount(a.id), '已删除')} disabled={actionLoading[`delete-${a.id}`]}
|
||||
className="btn btn-link btn-link-danger">
|
||||
{actionLoading[`delete-${a.id}`] ? <span className="spinner spinner-sm" /> : '删除'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
|
||||
@@ -176,7 +176,7 @@ export default function CheckinLog() {
|
||||
</td>
|
||||
<td>
|
||||
{reason ? (
|
||||
<span className="badge badge-info" title={reason.detailHint}>
|
||||
<span className="badge badge-info" data-tooltip={reason.detailHint}>
|
||||
{reason.title}
|
||||
</span>
|
||||
) : (
|
||||
@@ -198,7 +198,7 @@ export default function CheckinLog() {
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontSize: 12,
|
||||
}}
|
||||
title={reason?.detailHint || ''}
|
||||
data-tooltip={reason?.detailHint || ''}
|
||||
>
|
||||
{reason?.actionHint || '-'}
|
||||
</span>
|
||||
|
||||
@@ -216,7 +216,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||
<h2 className="greeting">{getGreeting() + '\uFF0C' + normalizedAdminName}</h2>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={() => { load(true); loadSiteStats(); }} disabled={refreshing} className="topbar-icon-btn" title="刷新">
|
||||
<button onClick={() => { load(true); loadSiteStats(); }} disabled={refreshing} className="topbar-icon-btn" data-tooltip="刷新" aria-label="刷新">
|
||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ animation: refreshing ? 'spin 1s linear infinite' : 'none' }}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type TestChatPayload,
|
||||
} from './helpers/modelTesterSession.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
import { tr } from '../i18n.js';
|
||||
|
||||
type ChatJobResponse = {
|
||||
@@ -561,6 +562,7 @@ export default function ModelTester() {
|
||||
const [customRequestMode, setCustomRequestMode] = useState(false);
|
||||
const [customRequestBody, setCustomRequestBody] = useState('');
|
||||
const [showDebugPanel, setShowDebugPanel] = useState(false);
|
||||
const debugPanelPresence = useAnimatedVisibility(showDebugPanel, 220);
|
||||
const [activeDebugTab, setActiveDebugTab] = useState<DebugTab>(DEBUG_TABS.PREVIEW);
|
||||
const [debugRequest, setDebugRequest] = useState('');
|
||||
const [debugResponse, setDebugResponse] = useState('');
|
||||
@@ -1264,7 +1266,7 @@ export default function ModelTester() {
|
||||
return debugResponse;
|
||||
}, [activeDebugTab, debugPreview, debugRequest, debugResponse]);
|
||||
|
||||
const layoutColumns = showDebugPanel
|
||||
const layoutColumns = debugPanelPresence.shouldRender
|
||||
? '340px minmax(0, 1fr) 360px'
|
||||
: '340px minmax(0, 1fr)';
|
||||
|
||||
@@ -1461,8 +1463,8 @@ export default function ModelTester() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{customRequestMode && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div className={`anim-collapse ${customRequestMode ? 'is-open' : ''}`.trim()} style={{ marginBottom: 14 }}>
|
||||
<div className="anim-collapse-inner">
|
||||
<textarea
|
||||
value={customRequestBody}
|
||||
onChange={(event) => setCustomRequestBody(event.target.value)}
|
||||
@@ -1487,7 +1489,7 @@ export default function ModelTester() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 8, fontWeight: 600 }}>
|
||||
采样参数
|
||||
@@ -1702,18 +1704,20 @@ export default function ModelTester() {
|
||||
<span>{isLoading ? '思考中...' : '推理过程'}</span>
|
||||
<span>{message.isReasoningExpanded ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
{message.isReasoningExpanded && (
|
||||
<div style={{
|
||||
padding: '8px 10px',
|
||||
borderTop: '1px solid color-mix(in srgb, var(--color-primary) 20%, transparent)',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.7,
|
||||
whiteSpace: 'pre-wrap',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}>
|
||||
{message.reasoningContent}
|
||||
<div className={`anim-collapse ${message.isReasoningExpanded ? 'is-open' : ''}`.trim()}>
|
||||
<div className="anim-collapse-inner">
|
||||
<div style={{
|
||||
padding: '8px 10px',
|
||||
borderTop: '1px solid color-mix(in srgb, var(--color-primary) 20%, transparent)',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.7,
|
||||
whiteSpace: 'pre-wrap',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}>
|
||||
{message.reasoningContent}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1850,8 +1854,8 @@ export default function ModelTester() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showDebugPanel && (
|
||||
<div className="card" style={{ padding: 14, minHeight: 680, maxHeight: 740, display: 'flex', flexDirection: 'column' }}>
|
||||
{debugPanelPresence.shouldRender && (
|
||||
<div className={`card panel-presence ${debugPanelPresence.isVisible ? '' : 'is-closing'}`.trim()} style={{ padding: 14, minHeight: 680, maxHeight: 740, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 15 }}>调试</h3>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
|
||||
|
||||
+28
-21
@@ -4,6 +4,7 @@ import { api } from '../api.js';
|
||||
import { getBrand, hashColor, BrandIcon, type BrandInfo, useIconCdn } from '../components/BrandIcon.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
import { mergeMarketplaceMetadata, shouldHydrateMarketplaceMetadata } from './helpers/modelsMarketplaceMetadata.js';
|
||||
import { tr } from '../i18n.js';
|
||||
|
||||
@@ -130,6 +131,7 @@ export default function Models() {
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const [filterCollapsed, setFilterCollapsed] = useState(false);
|
||||
const [metadataHydrating, setMetadataHydrating] = useState(false);
|
||||
const filterPanelPresence = useAnimatedVisibility(!filterCollapsed, 220);
|
||||
const latestPrimaryRequestRef = useRef(0);
|
||||
const latestMetadataRequestRef = useRef(0);
|
||||
const location = useLocation();
|
||||
@@ -332,8 +334,8 @@ export default function Models() {
|
||||
return (
|
||||
<div className="animate-fade-in" style={{ display: 'flex', gap: 24, minHeight: 400 }}>
|
||||
{/* ====== LEFT: Filter Panel ====== */}
|
||||
{!filterCollapsed && (
|
||||
<div className="filter-panel">
|
||||
{filterPanelPresence.shouldRender && (
|
||||
<div className={`filter-panel filter-collapsible ${filterPanelPresence.isVisible ? '' : 'is-closing'}`.trim()}>
|
||||
{/* Brand filter */}
|
||||
<div className="filter-panel-section">
|
||||
<div className="filter-panel-title">
|
||||
@@ -475,10 +477,10 @@ export default function Models() {
|
||||
<span className="badge badge-muted" style={{ fontSize: 11 }}>{tr('加载元数据中...')}</span>
|
||||
)}
|
||||
<div className="view-toggle">
|
||||
<button className={`view-toggle-btn ${viewMode === 'card' ? 'active' : ''}`} onClick={() => setViewMode('card')} title={tr('卡片视图')}>
|
||||
<button className={`view-toggle-btn ${viewMode === 'card' ? 'active' : ''}`} onClick={() => setViewMode('card')} data-tooltip={tr('卡片视图')} aria-label={tr('卡片视图')}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" /></svg>
|
||||
</button>
|
||||
<button className={`view-toggle-btn ${viewMode === 'table' ? 'active' : ''}`} onClick={() => setViewMode('table')} title={tr('表格视图')}>
|
||||
<button className={`view-toggle-btn ${viewMode === 'table' ? 'active' : ''}`} onClick={() => setViewMode('table')} data-tooltip={tr('表格视图')} aria-label={tr('表格视图')}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M3 14h18M3 6h18M3 18h18M10 3v18M14 3v18" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -499,10 +501,10 @@ export default function Models() {
|
||||
</div>
|
||||
{/* Quick stats */}
|
||||
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--color-text-muted)', alignItems: 'center' }}>
|
||||
<span title={tr('所有模型 accountCount 累计值,同一账号在多个模型中会重复计数')}>
|
||||
<span data-tooltip={tr('所有模型 accountCount 累计值,同一账号在多个模型中会重复计数')}>
|
||||
{tr('覆盖槽位')} <b style={{ color: 'var(--color-text-primary)' }}>{totalCoverageSlots}</b>
|
||||
</span>
|
||||
<span title={tr('当前筛选范围内去重后的唯一账号数')}>
|
||||
<span data-tooltip={tr('当前筛选范围内去重后的唯一账号数')}>
|
||||
{tr('去重账号')} <b style={{ color: 'var(--color-text-primary)' }}>{uniqueAccountCount}</b>
|
||||
</span>
|
||||
<span>{tr('平均延迟')} <b style={{ color: getMetricColor(avgLatency) }}>{avgLatency}ms</b></span>
|
||||
@@ -541,21 +543,21 @@ export default function Models() {
|
||||
<span
|
||||
className={`badge ${getLatencyBadgeClass(m.avgLatency)}`}
|
||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||
title={tr('平均延迟')}
|
||||
data-tooltip={tr('平均延迟')}
|
||||
>
|
||||
{tr('延迟')} {m.avgLatency}ms
|
||||
</span>
|
||||
<span
|
||||
className={`badge ${getSuccessBadgeClass(m.successRate)}`}
|
||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||
title={tr('成功率')}
|
||||
data-tooltip={tr('成功率')}
|
||||
>
|
||||
{tr('成功率')} {m.successRate != null ? `${m.successRate}%` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="model-card-actions" onClick={e => e.stopPropagation()}>
|
||||
<button className="model-card-action-btn" title={tr('复制模型名')} onClick={() => copyName(m.name)}>
|
||||
<button className="model-card-action-btn" data-tooltip={tr('复制模型名')} aria-label={tr('复制模型名')} onClick={() => copyName(m.name)}>
|
||||
{copied === m.name ? (
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="var(--color-success)"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
||||
) : (
|
||||
@@ -564,7 +566,8 @@ export default function Models() {
|
||||
</button>
|
||||
<button
|
||||
className="model-card-action-btn"
|
||||
title={expanded === m.name ? tr('收起') : tr('展开')}
|
||||
data-tooltip={expanded === m.name ? tr('收起') : tr('展开')}
|
||||
aria-label={expanded === m.name ? tr('收起') : tr('展开')}
|
||||
>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ transform: expanded === m.name ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
@@ -593,8 +596,9 @@ export default function Models() {
|
||||
</div>
|
||||
|
||||
{/* Expand: Account Details */}
|
||||
{expanded === m.name && (
|
||||
<div className="model-card-expand" onClick={e => e.stopPropagation()}>
|
||||
<div className={`anim-collapse ${expanded === m.name ? 'is-open' : ''}`.trim()} onClick={e => e.stopPropagation()}>
|
||||
<div className="anim-collapse-inner">
|
||||
<div className="model-card-expand">
|
||||
<div style={{ display: 'grid', gap: 8, marginBottom: 10 }}>
|
||||
<div className="card" style={{ padding: 10 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('基础信息')}</div>
|
||||
@@ -675,8 +679,9 @@ export default function Models() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -736,7 +741,7 @@ export default function Models() {
|
||||
</span>
|
||||
</td>
|
||||
<td onClick={e => e.stopPropagation()}>
|
||||
<button className="model-card-action-btn" title={tr('复制')} onClick={() => copyName(m.name)}>
|
||||
<button className="model-card-action-btn" data-tooltip={tr('复制')} aria-label={tr('复制')} onClick={() => copyName(m.name)}>
|
||||
{copied === m.name ? (
|
||||
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="var(--color-success)"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
||||
) : (
|
||||
@@ -745,10 +750,11 @@ export default function Models() {
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded === m.name && (
|
||||
<tr className="log-detail-row">
|
||||
<td colSpan={7} style={{ padding: 0 }}>
|
||||
<div style={{ padding: '12px 16px 12px 54px' }}>
|
||||
<tr className={expanded === m.name ? 'log-detail-row' : ''}>
|
||||
<td colSpan={7} style={{ padding: 0 }}>
|
||||
<div className={`anim-collapse ${expanded === m.name ? 'is-open' : ''}`.trim()}>
|
||||
<div className="anim-collapse-inner">
|
||||
<div style={{ padding: '12px 16px 12px 54px' }}>
|
||||
<div style={{ display: 'grid', gap: 8, marginBottom: 10 }}>
|
||||
<div className="card" style={{ padding: 10 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('基础信息')}</div>
|
||||
@@ -825,10 +831,11 @@ export default function Models() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
import { tr } from '../i18n.js';
|
||||
|
||||
type MonitorSite = {
|
||||
@@ -76,6 +77,8 @@ export default function Monitors() {
|
||||
}, [activeSiteId, reloadSeed]);
|
||||
|
||||
const usingCookieProxy = activeSite.id === 'ldoh-105117' && monitorConfig.ldohCookieConfigured;
|
||||
const oauthHintPresence = useAnimatedVisibility(Boolean(activeSite.requiresLinuxDoOAuth), 220);
|
||||
const fallbackHintPresence = useAnimatedVisibility(showFallbackHint && !loaded, 180);
|
||||
const directSiteUrl = `${activeSite.url.replace(/\/$/, '')}/`;
|
||||
const iframeUrl = usingCookieProxy ? '/monitor-proxy/ldoh/' : directSiteUrl;
|
||||
const ldohOauthUrl = `${directSiteUrl}api/oauth/initiate?returnTo=%2F`;
|
||||
@@ -114,7 +117,8 @@ export default function Monitors() {
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
onClick={() => setReloadSeed((prev) => prev + 1)}
|
||||
title="重新加载当前站点"
|
||||
data-tooltip="重新加载当前站点"
|
||||
aria-label="重新加载当前站点"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
@@ -122,7 +126,8 @@ export default function Monitors() {
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => window.open(directSiteUrl, '_blank', 'noopener,noreferrer')}
|
||||
title="在新窗口直接打开目标站点"
|
||||
data-tooltip="在新窗口直接打开目标站点"
|
||||
aria-label="在新窗口直接打开目标站点"
|
||||
>
|
||||
新窗口打开
|
||||
</button>
|
||||
@@ -143,8 +148,8 @@ export default function Monitors() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeSite.requiresLinuxDoOAuth && (
|
||||
<div className="monitor-oauth-hint card">
|
||||
{oauthHintPresence.shouldRender && (
|
||||
<div className={`monitor-oauth-hint card panel-presence ${oauthHintPresence.isVisible ? '' : 'is-closing'}`.trim()}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>
|
||||
{usingCookieProxy ? '已启用 Cookie 代理模式' : '该站点需要 LinuxDo OAuth 授权'}
|
||||
</div>
|
||||
@@ -205,8 +210,8 @@ export default function Monitors() {
|
||||
)}
|
||||
|
||||
<div className="monitor-frame-shell card">
|
||||
{showFallbackHint && !loaded && (
|
||||
<div className="monitor-hint">
|
||||
{fallbackHintPresence.shouldRender && (
|
||||
<div className={`monitor-hint panel-presence ${fallbackHintPresence.isVisible ? '' : 'is-closing'}`.trim()}>
|
||||
{fallbackHint}
|
||||
</div>
|
||||
)}
|
||||
|
||||
+33
-26
@@ -4,6 +4,7 @@ import { useToast } from '../components/Toast.js';
|
||||
import { ModelBadge } from '../components/BrandIcon.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { parseProxyLogPathMeta } from './helpers/proxyLogPathMeta.js';
|
||||
import { tr } from '../i18n.js';
|
||||
|
||||
type StatusFilter = 'all' | 'success' | 'failed';
|
||||
@@ -51,17 +52,6 @@ function latencyBgColor(ms: number) {
|
||||
return 'color-mix(in srgb, var(--color-success) 12%, transparent)';
|
||||
}
|
||||
|
||||
function extractUpstreamPath(errorMessage?: string): string | null {
|
||||
if (!errorMessage) return null;
|
||||
const matched = errorMessage.match(/\[upstream:(\/v1\/[^\]]+)\]/i);
|
||||
return matched?.[1] || null;
|
||||
}
|
||||
|
||||
function stripUpstreamPrefix(errorMessage?: string): string {
|
||||
if (!errorMessage) return '';
|
||||
return errorMessage.replace(/^\[upstream:\/v1\/[^\]]+\]\s*/i, '');
|
||||
}
|
||||
|
||||
export default function ProxyLogs() {
|
||||
const [logs, setLogs] = useState<ProxyLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -189,8 +179,7 @@ export default function ProxyLogs() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.map(log => {
|
||||
const upstreamPath = extractUpstreamPath(log.errorMessage);
|
||||
const errorMessageDisplay = stripUpstreamPrefix(log.errorMessage);
|
||||
const pathMeta = parseProxyLogPathMeta(log.errorMessage);
|
||||
return (
|
||||
<React.Fragment key={log.id}>
|
||||
<tr
|
||||
@@ -255,10 +244,11 @@ export default function ProxyLogs() {
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{expanded === log.id && (
|
||||
<tr style={{ background: 'var(--color-bg)' }}>
|
||||
<td colSpan={9} style={{ padding: 0 }}>
|
||||
<div className="animate-fade-in" style={{
|
||||
<tr style={{ background: 'var(--color-bg)' }}>
|
||||
<td colSpan={9} style={{ padding: 0 }}>
|
||||
<div className={`anim-collapse ${expanded === log.id ? 'is-open' : ''}`.trim()}>
|
||||
<div className="anim-collapse-inner">
|
||||
<div className="animate-fade-in" style={{
|
||||
padding: '14px 20px 14px 40px',
|
||||
borderTop: '1px solid var(--color-border-light)',
|
||||
borderBottom: '1px solid var(--color-border-light)',
|
||||
@@ -294,16 +284,32 @@ export default function ProxyLogs() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 请求路径 */}
|
||||
{/* 下游请求路径 */}
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-primary)', flexShrink: 0 }}>请求路径</span>
|
||||
{upstreamPath ? (
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-primary)', flexShrink: 0 }}>下游请求路径</span>
|
||||
{pathMeta.downstreamPath ? (
|
||||
<code style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12,
|
||||
background: 'var(--color-bg-card)', padding: '1px 8px', borderRadius: 4,
|
||||
border: '1px solid var(--color-border-light)',
|
||||
}}>
|
||||
{upstreamPath}
|
||||
{pathMeta.downstreamPath}
|
||||
</code>
|
||||
) : (
|
||||
<span style={{ color: 'var(--color-text-muted)' }}>未记录</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 上游请求路径 */}
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-primary)', flexShrink: 0 }}>上游请求路径</span>
|
||||
{pathMeta.upstreamPath ? (
|
||||
<code style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12,
|
||||
background: 'var(--color-bg-card)', padding: '1px 8px', borderRadius: 4,
|
||||
border: '1px solid var(--color-border-light)',
|
||||
}}>
|
||||
{pathMeta.upstreamPath}
|
||||
</code>
|
||||
) : (
|
||||
<span style={{ color: 'var(--color-text-muted)' }}>未记录</span>
|
||||
@@ -311,16 +317,17 @@ export default function ProxyLogs() {
|
||||
</div>
|
||||
|
||||
{/* 错误信息 */}
|
||||
{errorMessageDisplay.trim().length > 0 && (
|
||||
{pathMeta.errorMessage.trim().length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-danger)', flexShrink: 0 }}>错误信息</span>
|
||||
<span style={{ color: 'var(--color-danger)' }}>{errorMessageDisplay}</span>
|
||||
<span style={{ color: 'var(--color-danger)' }}>{pathMeta.errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</React.Fragment>
|
||||
)})}
|
||||
</tbody>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
import { api } from '../api.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import ChangeKeyModal from '../components/ChangeKeyModal.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
import { InlineBrandIcon, getBrand, useIconCdn } from '../components/BrandIcon.js';
|
||||
import {
|
||||
applyRoutingProfilePreset,
|
||||
@@ -125,7 +126,9 @@ export default function Settings() {
|
||||
const [downstreamOps, setDownstreamOps] = useState<Record<number, boolean>>({});
|
||||
const [editingDownstreamId, setEditingDownstreamId] = useState<number | null>(null);
|
||||
const [downstreamModalOpen, setDownstreamModalOpen] = useState(false);
|
||||
const downstreamModalPresence = useAnimatedVisibility(downstreamModalOpen, 220);
|
||||
const [selectorOpen, setSelectorOpen] = useState(false);
|
||||
const selectorModalPresence = useAnimatedVisibility(selectorOpen, 220);
|
||||
const [selectorLoading, setSelectorLoading] = useState(false);
|
||||
const [selectorRoutes, setSelectorRoutes] = useState<RouteSelectorItem[]>([]);
|
||||
const [selectorModelSearch, setSelectorModelSearch] = useState('');
|
||||
@@ -794,8 +797,9 @@ export default function Settings() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAdvancedRouting && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div className={`anim-collapse ${showAdvancedRouting ? 'is-open' : ''}`.trim()}>
|
||||
<div className="anim-collapse-inner" style={{ paddingTop: 2 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
{([
|
||||
['baseWeightFactor', '基础权重因子'],
|
||||
['valueScoreFactor', '价值分因子'],
|
||||
@@ -824,8 +828,9 @@ export default function Settings() {
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<button onClick={saveRouting} disabled={savingRouting} className="btn btn-primary">
|
||||
@@ -880,11 +885,11 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{downstreamModalOpen && (() => {
|
||||
{downstreamModalPresence.shouldRender && (() => {
|
||||
const modal = (
|
||||
<div className="modal-backdrop" onClick={closeDownstreamModal}>
|
||||
<div className={`modal-backdrop ${downstreamModalPresence.isVisible ? '' : 'is-closing'}`.trim()} onClick={closeDownstreamModal}>
|
||||
<div
|
||||
className="modal-content"
|
||||
className={`modal-content ${downstreamModalPresence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
style={{ maxWidth: 860 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -979,11 +984,11 @@ export default function Settings() {
|
||||
);
|
||||
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
|
||||
})()}
|
||||
{selectorOpen && (() => {
|
||||
{selectorModalPresence.shouldRender && (() => {
|
||||
const modal = (
|
||||
<div className="modal-backdrop" onClick={closeSelectorModal}>
|
||||
<div className={`modal-backdrop ${selectorModalPresence.isVisible ? '' : 'is-closing'}`.trim()} onClick={closeSelectorModal}>
|
||||
<div
|
||||
className="modal-content"
|
||||
className={`modal-content ${selectorModalPresence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
style={{ maxWidth: 860 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
+120
-26
@@ -3,6 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
import { clearFocusParams, readFocusSiteId } from './helpers/navigationFocus.js';
|
||||
import { tr } from '../i18n.js';
|
||||
@@ -19,10 +20,12 @@ type SiteRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
externalCheckinUrl?: string | null;
|
||||
platform?: string;
|
||||
status?: string;
|
||||
apiKey?: string;
|
||||
proxyUrl?: string | null;
|
||||
globalWeight?: number;
|
||||
isPinned?: boolean;
|
||||
sortOrder?: number;
|
||||
totalBalance?: number;
|
||||
@@ -32,6 +35,7 @@ type SiteRow = {
|
||||
const platformColors: Record<string, string> = {
|
||||
'new-api': 'badge-info',
|
||||
'one-api': 'badge-success',
|
||||
anyrouter: 'badge-warning',
|
||||
veloera: 'badge-warning',
|
||||
'one-hub': 'badge-muted',
|
||||
'done-hub': 'badge-muted',
|
||||
@@ -39,8 +43,24 @@ const platformColors: Record<string, string> = {
|
||||
openai: 'badge-success',
|
||||
claude: 'badge-warning',
|
||||
gemini: 'badge-info',
|
||||
cliproxyapi: 'badge-info',
|
||||
};
|
||||
|
||||
const SITE_PLATFORM_OPTIONS = [
|
||||
{ value: '', label: '平台类型(可自动检测)' },
|
||||
{ value: 'new-api', label: 'new-api' },
|
||||
{ value: 'one-api', label: 'one-api' },
|
||||
{ value: 'anyrouter', label: 'anyrouter' },
|
||||
{ value: 'veloera', label: 'veloera' },
|
||||
{ value: 'one-hub', label: 'one-hub' },
|
||||
{ value: 'done-hub', label: 'done-hub' },
|
||||
{ value: 'sub2api', label: 'sub2api' },
|
||||
{ value: 'openai', label: 'openai' },
|
||||
{ value: 'claude', label: 'claude' },
|
||||
{ value: 'gemini', label: 'gemini' },
|
||||
{ value: 'cliproxyapi', label: 'cliproxyapi' },
|
||||
];
|
||||
|
||||
export default function Sites() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -56,12 +76,16 @@ export default function Sites() {
|
||||
const [togglingSiteId, setTogglingSiteId] = useState<number | null>(null);
|
||||
const [orderingSiteId, setOrderingSiteId] = useState<number | null>(null);
|
||||
const [pinningSiteId, setPinningSiteId] = useState<number | null>(null);
|
||||
const editorPresence = useAnimatedVisibility(Boolean(editor), 220);
|
||||
const lastEditorRef = useRef<SiteEditorState | null>(null);
|
||||
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
|
||||
const highlightTimerRef = useRef<number | null>(null);
|
||||
const toast = useToast();
|
||||
|
||||
const isEditing = editor?.mode === 'edit';
|
||||
const isAdding = editor?.mode === 'add';
|
||||
if (editor) lastEditorRef.current = editor;
|
||||
const activeEditor = editor || lastEditorRef.current;
|
||||
const isEditing = activeEditor?.mode === 'edit';
|
||||
const isAdding = activeEditor?.mode === 'add';
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
@@ -83,6 +107,17 @@ export default function Sites() {
|
||||
[sites, sortMode],
|
||||
);
|
||||
|
||||
const platformOptions = useMemo(() => {
|
||||
const current = form.platform.trim();
|
||||
if (!current || SITE_PLATFORM_OPTIONS.some((option) => option.value === current)) {
|
||||
return SITE_PLATFORM_OPTIONS;
|
||||
}
|
||||
return [
|
||||
...SITE_PLATFORM_OPTIONS,
|
||||
{ value: current, label: `${current}(当前值)` },
|
||||
];
|
||||
}, [form.platform]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (highlightTimerRef.current) {
|
||||
@@ -117,6 +152,13 @@ export default function Sites() {
|
||||
setForm(emptySiteForm());
|
||||
};
|
||||
|
||||
const scrollToEditorTop = () => {
|
||||
const scrollTo = (globalThis as { scrollTo?: (options?: ScrollToOptions) => void }).scrollTo;
|
||||
if (typeof scrollTo === 'function') {
|
||||
scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
const openAdd = () => {
|
||||
if (isAdding) {
|
||||
closeEditor();
|
||||
@@ -124,21 +166,31 @@ export default function Sites() {
|
||||
}
|
||||
setEditor({ mode: 'add' });
|
||||
setForm(emptySiteForm());
|
||||
scrollToEditorTop();
|
||||
};
|
||||
|
||||
const openEdit = (site: SiteRow) => {
|
||||
setEditor({ mode: 'edit', editingSiteId: site.id });
|
||||
setForm(siteFormFromSite(site));
|
||||
scrollToEditorTop();
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editor) return;
|
||||
const parsedGlobalWeight = Number(form.globalWeight);
|
||||
if (!Number.isFinite(parsedGlobalWeight) || parsedGlobalWeight <= 0) {
|
||||
toast.error('全局权重必须是大于 0 的数字');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
url: form.url.trim(),
|
||||
externalCheckinUrl: form.externalCheckinUrl.trim(),
|
||||
platform: form.platform.trim(),
|
||||
apiKey: form.apiKey.trim(),
|
||||
proxyUrl: form.proxyUrl.trim(),
|
||||
globalWeight: Number(parsedGlobalWeight.toFixed(3)),
|
||||
};
|
||||
if (!payload.name || !payload.url) {
|
||||
toast.error('请填写站点名称和 URL');
|
||||
@@ -265,8 +317,12 @@ export default function Sites() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editor && (
|
||||
<div className="card animate-scale-in" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<div className="info-tip" style={{ marginBottom: 12 }}>
|
||||
站点权重说明:最终站点倍率 = 站点全局权重 × 设置页中下游 API Key 的站点倍率。它会与路由策略因子(基础权重、价值分、成本、余额、使用频次)共同作用。数值越大,该站点在同优先级下越容易被选中。建议范围 0.5-3,默认 1;长期不建议超过 5。
|
||||
</div>
|
||||
|
||||
{editorPresence.shouldRender && activeEditor && (
|
||||
<div className={`card panel-presence ${editorPresence.isVisible ? '' : 'is-closing'}`.trim()} style={{ padding: 20, marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>
|
||||
{isEditing ? '编辑站点' : '添加站点'}
|
||||
@@ -321,20 +377,34 @@ export default function Sites() {
|
||||
{detecting ? <><span className="spinner spinner-sm" /> 检测中</> : '自动检测'}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
border: `1px solid ${form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 48%, transparent)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 10%, var(--color-bg))' : 'var(--color-bg)',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
<ModernSelect
|
||||
value={form.platform}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, platform: value }))}
|
||||
options={platformOptions}
|
||||
placeholder="平台类型(可自动检测)"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
placeholder="平台类型(可自动检测)"
|
||||
value={form.platform}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, platform: e.target.value }))}
|
||||
placeholder="外部签到/福利站点 URL(可选)"
|
||||
value={form.externalCheckinUrl}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, externalCheckinUrl: e.target.value }))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
border: `1px solid ${form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 48%, transparent)' : 'var(--color-border)'}`,
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 13,
|
||||
outline: 'none',
|
||||
background: form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 10%, var(--color-bg))' : 'var(--color-bg)',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
@@ -367,6 +437,24 @@ export default function Sites() {
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
placeholder="站点全局权重(默认 1)"
|
||||
value={form.globalWeight}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, globalWeight: e.target.value }))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 13,
|
||||
outline: 'none',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
越大越容易被路由选中。建议 0.5-3,默认 1。
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !form.name.trim() || !form.url.trim()}
|
||||
@@ -385,9 +473,10 @@ export default function Sites() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>URL</th>
|
||||
<th>外部签到站URL</th>
|
||||
<th>总余额</th>
|
||||
<th>状态</th>
|
||||
<th>权重</th>
|
||||
<th>平台</th>
|
||||
<th>创建时间</th>
|
||||
<th className="sites-actions-col" style={{ textAlign: 'right' }}>操作</th>
|
||||
@@ -417,21 +506,23 @@ export default function Sites() {
|
||||
</a>
|
||||
</td>
|
||||
<td className="sites-url-cell" style={{ maxWidth: 300 }}>
|
||||
<a
|
||||
href={site.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sites-url-link"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-primary)',
|
||||
textDecoration: 'underline',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{site.url}
|
||||
</a>
|
||||
{site.externalCheckinUrl ? (
|
||||
<a
|
||||
href={site.externalCheckinUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sites-url-link"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-primary)',
|
||||
textDecoration: 'underline',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{site.externalCheckinUrl}
|
||||
</a>
|
||||
) : null}
|
||||
</td>
|
||||
<td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
|
||||
${(site.totalBalance || 0).toFixed(2)}
|
||||
@@ -441,6 +532,9 @@ export default function Sites() {
|
||||
{site.status === 'disabled' ? '禁用' : '启用'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
|
||||
{(site.globalWeight || 1).toFixed(2)}
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href={site.url}
|
||||
|
||||
+550
-69
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
|
||||
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
@@ -17,9 +18,10 @@ import {
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { api } from '../api.js';
|
||||
import { InlineBrandIcon, getBrand, useIconCdn, type BrandInfo } from '../components/BrandIcon.js';
|
||||
import { InlineBrandIcon, getBrand, hashColor, useIconCdn, type BrandInfo } from '../components/BrandIcon.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
import { tr } from '../i18n.js';
|
||||
import {
|
||||
buildRouteModelCandidatesIndex,
|
||||
@@ -27,6 +29,11 @@ import {
|
||||
type RouteModelCandidatesByModelName,
|
||||
} from './helpers/routeModelCandidatesIndex.js';
|
||||
import { getInitialVisibleCount, getNextVisibleCount } from './helpers/progressiveRender.js';
|
||||
import {
|
||||
buildRouteMissingTokenIndex,
|
||||
normalizeMissingTokenModels,
|
||||
type MissingTokenModelsByName,
|
||||
} from './helpers/routeMissingTokenHints.js';
|
||||
|
||||
type RouteSortBy = 'modelPattern' | 'channelCount';
|
||||
type RouteSortDir = 'asc' | 'desc';
|
||||
@@ -54,7 +61,9 @@ type RouteChannel = {
|
||||
username: string | null;
|
||||
};
|
||||
site?: {
|
||||
id: number;
|
||||
name: string | null;
|
||||
platform: string | null;
|
||||
};
|
||||
token?: {
|
||||
id: number;
|
||||
@@ -122,6 +131,13 @@ type RouteIconOption = {
|
||||
iconText?: string;
|
||||
};
|
||||
|
||||
type MissingTokenRouteSiteActionItem = {
|
||||
key: string;
|
||||
siteName: string;
|
||||
accountId: number;
|
||||
accountLabel: string;
|
||||
};
|
||||
|
||||
type SortableChannelRowProps = {
|
||||
channel: RouteChannel;
|
||||
decisionCandidate?: RouteDecisionCandidate;
|
||||
@@ -147,6 +163,34 @@ const EMPTY_ROUTE_CANDIDATE_VIEW: RouteCandidateView = {
|
||||
const ROUTE_ICON_OPTIONS: RouteIconOption[] = [
|
||||
{ value: '', label: '自动品牌图标', description: '按模型匹配规则自动识别品牌', iconText: '✦' },
|
||||
];
|
||||
const ENDPOINT_TYPE_ICON_MODEL_MAP: Record<string, string> = {
|
||||
openai: 'chatgpt',
|
||||
gemini: 'gemini',
|
||||
anthropic: 'claude',
|
||||
anthroic: 'claude',
|
||||
claude: 'claude',
|
||||
};
|
||||
const PLATFORM_ENDPOINT_FALLBACK_MAP: Record<string, string[]> = {
|
||||
openai: ['openai'],
|
||||
'new-api': ['openai'],
|
||||
'one-api': ['openai'],
|
||||
'one-hub': ['openai'],
|
||||
'done-hub': ['openai'],
|
||||
sub2api: ['openai'],
|
||||
veloera: ['openai'],
|
||||
cliproxyapi: ['openai'],
|
||||
claude: ['anthropic'],
|
||||
gemini: ['gemini'],
|
||||
anyrouter: ['openai', 'anthropic'],
|
||||
};
|
||||
const PLATFORM_ALIASES: Record<string, string> = {
|
||||
anthropic: 'claude',
|
||||
google: 'gemini',
|
||||
'new api': 'new-api',
|
||||
newapi: 'new-api',
|
||||
'one api': 'one-api',
|
||||
oneapi: 'one-api',
|
||||
};
|
||||
|
||||
function isRegexModelPattern(modelPattern: string): boolean {
|
||||
return modelPattern.trim().toLowerCase().startsWith('re:');
|
||||
@@ -252,6 +296,39 @@ function parseBrandIconValue(raw: string): string | null {
|
||||
return icon || null;
|
||||
}
|
||||
|
||||
function resolveEndpointTypeIconModel(endpointType: string): string | null {
|
||||
const key = String(endpointType || '').trim().toLowerCase();
|
||||
if (!key) return null;
|
||||
return ENDPOINT_TYPE_ICON_MODEL_MAP[key] || null;
|
||||
}
|
||||
|
||||
function normalizePlatformKey(platform: string | null | undefined): string {
|
||||
const raw = String(platform || '').trim().toLowerCase();
|
||||
if (!raw) return '';
|
||||
return PLATFORM_ALIASES[raw] || raw;
|
||||
}
|
||||
|
||||
function inferEndpointTypesFromPlatform(platform: string | null | undefined): string[] {
|
||||
const key = normalizePlatformKey(platform);
|
||||
if (!key) return [];
|
||||
const mapped = PLATFORM_ENDPOINT_FALLBACK_MAP[key];
|
||||
if (Array.isArray(mapped) && mapped.length > 0) return mapped;
|
||||
|
||||
if (key.includes('claude') || key.includes('anthropic')) return ['anthropic'];
|
||||
if (key.includes('gemini')) return ['gemini'];
|
||||
if (key.includes('openai') || key.includes('new-api') || key.includes('one-api')) return ['openai'];
|
||||
return [];
|
||||
}
|
||||
|
||||
function siteAvatarLetters(siteName: string): string {
|
||||
const normalized = String(siteName || '').trim();
|
||||
if (!normalized) return 'S';
|
||||
const parts = normalized.replace(/[-_/.]/g, ' ').split(/\s+/).filter(Boolean);
|
||||
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
||||
const compact = normalized.replace(/\s+/g, '');
|
||||
return compact.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function resolveRouteIcon(route: RouteRow): { kind: 'none' } | { kind: 'text'; value: string } | { kind: 'brand'; value: string } {
|
||||
const icon = (route.displayIcon || '').trim();
|
||||
if (!icon) return { kind: 'none' };
|
||||
@@ -276,6 +353,11 @@ function normalizeRoutes(routeRows: any[]): RouteRow[] {
|
||||
});
|
||||
}
|
||||
|
||||
function buildSourceGroupKey(routeId: number, sourceModel: string): string {
|
||||
const normalizedSourceModel = sourceModel.trim() || '__ungrouped__';
|
||||
return `${routeId}::${normalizedSourceModel}`;
|
||||
}
|
||||
|
||||
function getPriorityTagStyle(priority: number): CSSProperties {
|
||||
if (priority <= 0) {
|
||||
return {
|
||||
@@ -449,7 +531,8 @@ function SortableChannelRow({
|
||||
color: 'var(--color-text-muted)',
|
||||
cursor: isSavingPriority ? 'not-allowed' : 'grab',
|
||||
}}
|
||||
title="拖拽调整优先级"
|
||||
data-tooltip="拖拽调整优先级"
|
||||
aria-label="拖拽调整优先级"
|
||||
>
|
||||
<svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden>
|
||||
<circle cx="3" cy="2" r="1" />
|
||||
@@ -502,7 +585,7 @@ function SortableChannelRow({
|
||||
<span
|
||||
className="badge badge-warning"
|
||||
style={{ fontSize: 10 }}
|
||||
title="该通道由用户手动添加,而非系统自动生成"
|
||||
data-tooltip="该通道由用户手动添加,而非系统自动生成"
|
||||
>
|
||||
手动配置
|
||||
</span>
|
||||
@@ -512,7 +595,7 @@ function SortableChannelRow({
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>选中概率</span>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, minWidth: 120 }}>
|
||||
<div
|
||||
title={decisionState.probability <= 0 ? decisionState.reasonText : undefined}
|
||||
data-tooltip={decisionState.probability <= 0 ? decisionState.reasonText : undefined}
|
||||
style={{
|
||||
width: 80,
|
||||
height: 6,
|
||||
@@ -532,7 +615,7 @@ function SortableChannelRow({
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
title={decisionState.probability <= 0 ? decisionState.reasonText : undefined}
|
||||
data-tooltip={decisionState.probability <= 0 ? decisionState.reasonText : undefined}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: decisionState.probability > 0 ? 'var(--color-text-secondary)' : decisionState.reasonColor,
|
||||
@@ -589,19 +672,38 @@ function SortableChannelRow({
|
||||
);
|
||||
}
|
||||
|
||||
function AnimatedCollapseSection({ open, children }: { open: boolean; children: ReactNode }) {
|
||||
const presence = useAnimatedVisibility(open, 220);
|
||||
if (!presence.shouldRender) return null;
|
||||
return (
|
||||
<div className={`anim-collapse ${presence.isVisible ? 'is-open' : ''}`.trim()}>
|
||||
<div className="anim-collapse-inner">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TokenRoutes() {
|
||||
const navigate = useNavigate();
|
||||
const cdn = useIconCdn();
|
||||
const [routes, setRoutes] = useState<RouteRow[]>([]);
|
||||
const [modelCandidates, setModelCandidates] = useState<RouteModelCandidatesByModelName>({});
|
||||
const [missingTokenModelsByName, setMissingTokenModelsByName] = useState<MissingTokenModelsByName>({});
|
||||
const [endpointTypesByModel, setEndpointTypesByModel] = useState<Record<string, string[]>>({});
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeBrand, setActiveBrand] = useState<string | null>(null);
|
||||
const [activeSite, setActiveSite] = useState<string | null>(null);
|
||||
const [activeEndpointType, setActiveEndpointType] = useState<string | null>(null);
|
||||
const [activeGroupFilter, setActiveGroupFilter] = useState<GroupFilter>(null);
|
||||
const [filterCollapsed, setFilterCollapsed] = useState(false);
|
||||
const [sortBy, setSortBy] = useState<RouteSortBy>('channelCount');
|
||||
const [sortDir, setSortDir] = useState<RouteSortDir>('desc');
|
||||
|
||||
const [showManual, setShowManual] = useState(false);
|
||||
const filterPanelPresence = useAnimatedVisibility(!filterCollapsed, 220);
|
||||
const manualPanelPresence = useAnimatedVisibility(showManual, 220);
|
||||
const [form, setForm] = useState({ modelPattern: '', displayName: '', displayIcon: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [rebuilding, setRebuilding] = useState(false);
|
||||
@@ -615,6 +717,7 @@ export default function TokenRoutes() {
|
||||
const [loadingDecision, setLoadingDecision] = useState(false);
|
||||
const [decisionAutoSkipped, setDecisionAutoSkipped] = useState(false);
|
||||
const [visibleRouteCount, setVisibleRouteCount] = useState(ROUTE_RENDER_CHUNK);
|
||||
const [expandedSourceGroupMap, setExpandedSourceGroupMap] = useState<Record<string, boolean>>({});
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -693,7 +796,11 @@ export default function TokenRoutes() {
|
||||
|
||||
const normalizedRoutes = normalizeRoutes(routeRows || []);
|
||||
setRoutes(normalizedRoutes);
|
||||
setModelCandidates((candidateRows?.models || {}) as Record<string, ModelCandidate[]>);
|
||||
setModelCandidates((candidateRows?.models || {}) as RouteModelCandidatesByModelName);
|
||||
setMissingTokenModelsByName(
|
||||
normalizeMissingTokenModels((candidateRows?.modelsWithoutToken || {}) as MissingTokenModelsByName),
|
||||
);
|
||||
setEndpointTypesByModel(candidateRows?.endpointTypesByModel || {});
|
||||
void loadRouteDecisions(normalizedRoutes, { force: true });
|
||||
};
|
||||
|
||||
@@ -809,6 +916,22 @@ export default function TokenRoutes() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleRouteEnabled = async (route: RouteRow) => {
|
||||
const newEnabled = !route.enabled;
|
||||
setRoutes((prev) =>
|
||||
prev.map((item) => (item.id === route.id ? { ...item, enabled: newEnabled } : item)),
|
||||
);
|
||||
try {
|
||||
await api.updateRoute(route.id, { enabled: newEnabled });
|
||||
toast.success(newEnabled ? '路由已启用' : '路由已禁用');
|
||||
} catch (e: any) {
|
||||
setRoutes((prev) =>
|
||||
prev.map((item) => (item.id === route.id ? { ...item, enabled: route.enabled } : item)),
|
||||
);
|
||||
toast.error(e.message || '切换路由状态失败');
|
||||
}
|
||||
};
|
||||
|
||||
const routeBrandById = useMemo(() => {
|
||||
const next = new Map<number, BrandInfo | null>();
|
||||
for (const route of routes) {
|
||||
@@ -845,6 +968,76 @@ export default function TokenRoutes() {
|
||||
};
|
||||
}, [routes, routeBrandById]);
|
||||
|
||||
const siteList = useMemo(() => {
|
||||
const grouped = new Map<string, { count: number; siteId: number }>();
|
||||
|
||||
for (const route of routes) {
|
||||
const seenSites = new Set<string>();
|
||||
for (const channel of route.channels || []) {
|
||||
const siteName = channel.site?.name;
|
||||
const siteId = channel.site?.id;
|
||||
if (!siteName || !siteId || seenSites.has(siteName)) continue;
|
||||
seenSites.add(siteName);
|
||||
|
||||
const existing = grouped.get(siteName);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
grouped.set(siteName, { count: 1, siteId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...grouped.entries()].sort((a, b) => {
|
||||
if (a[1].count === b[1].count) return a[0].localeCompare(b[0]);
|
||||
return b[1].count - a[1].count;
|
||||
});
|
||||
}, [routes]);
|
||||
|
||||
const routeEndpointTypesByRouteId = useMemo(() => {
|
||||
const index: Record<number, Set<string>> = {};
|
||||
const entries = Object.entries(endpointTypesByModel || {});
|
||||
for (const route of routes) {
|
||||
const pattern = (route.modelPattern || '').trim();
|
||||
if (!pattern) {
|
||||
index[route.id] = new Set<string>();
|
||||
continue;
|
||||
}
|
||||
const endpointTypes = new Set<string>();
|
||||
for (const [modelName, rawTypes] of entries) {
|
||||
if (!matchesModelPattern(modelName, pattern)) continue;
|
||||
for (const rawType of Array.isArray(rawTypes) ? rawTypes : []) {
|
||||
const endpointType = String(rawType || '').trim();
|
||||
if (!endpointType) continue;
|
||||
endpointTypes.add(endpointType);
|
||||
}
|
||||
}
|
||||
if (endpointTypes.size === 0) {
|
||||
for (const channel of route.channels || []) {
|
||||
for (const endpointType of inferEndpointTypesFromPlatform(channel.site?.platform)) {
|
||||
endpointTypes.add(endpointType);
|
||||
}
|
||||
}
|
||||
}
|
||||
index[route.id] = endpointTypes;
|
||||
}
|
||||
return index;
|
||||
}, [routes, endpointTypesByModel]);
|
||||
|
||||
const endpointTypeList = useMemo(() => {
|
||||
const grouped = new Map<string, number>();
|
||||
for (const route of routes) {
|
||||
const endpointTypes = routeEndpointTypesByRouteId[route.id] || new Set<string>();
|
||||
for (const endpointType of endpointTypes) {
|
||||
grouped.set(endpointType, (grouped.get(endpointType) || 0) + 1);
|
||||
}
|
||||
}
|
||||
return [...grouped.entries()].sort((a, b) => {
|
||||
if (a[1] === b[1]) return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
|
||||
return b[1] - a[1];
|
||||
});
|
||||
}, [routes, routeEndpointTypesByRouteId]);
|
||||
|
||||
const routeBrandIconCandidates = useMemo(() => {
|
||||
const byIcon = new Map<string, BrandInfo>();
|
||||
|
||||
@@ -930,13 +1123,34 @@ export default function TokenRoutes() {
|
||||
}
|
||||
}
|
||||
|
||||
if (activeSite) {
|
||||
list = list.filter((route) =>
|
||||
route.channels?.some((channel) => channel.site?.name === activeSite)
|
||||
);
|
||||
}
|
||||
|
||||
if (activeEndpointType) {
|
||||
list = list.filter((route) =>
|
||||
(routeEndpointTypesByRouteId[route.id] || new Set<string>()).has(activeEndpointType)
|
||||
);
|
||||
}
|
||||
|
||||
if (search.trim()) {
|
||||
const q = search.trim().toLowerCase();
|
||||
list = list.filter((route) => route.modelPattern.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [sortedRoutes, activeGroupFilter, activeBrand, search, routeBrandById]);
|
||||
}, [
|
||||
sortedRoutes,
|
||||
activeGroupFilter,
|
||||
activeBrand,
|
||||
activeSite,
|
||||
activeEndpointType,
|
||||
search,
|
||||
routeBrandById,
|
||||
routeEndpointTypesByRouteId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleRouteCount(getInitialVisibleCount(filteredRoutes.length, ROUTE_RENDER_CHUNK));
|
||||
@@ -977,10 +1191,24 @@ export default function TokenRoutes() {
|
||||
[routes, modelCandidates],
|
||||
);
|
||||
|
||||
const routeMissingTokenIndex = useMemo(
|
||||
() => buildRouteMissingTokenIndex(routes, missingTokenModelsByName, matchesModelPattern),
|
||||
[routes, missingTokenModelsByName],
|
||||
);
|
||||
|
||||
const getRouteCandidateView = (routeId: number): RouteCandidateView => {
|
||||
return routeModelCandidateIndex[routeId] || EMPTY_ROUTE_CANDIDATE_VIEW;
|
||||
};
|
||||
|
||||
const handleCreateTokenForMissingAccount = (accountId: number, modelName: string) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('create', '1');
|
||||
params.set('accountId', String(accountId));
|
||||
params.set('model', modelName);
|
||||
params.set('from', 'routes');
|
||||
navigate(`/tokens?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleRouteAccountChange = (route: RouteRow, accountId: number) => {
|
||||
const tokenOptions = getRouteCandidateView(route.id).tokenOptionsByAccountId[accountId] || [];
|
||||
const defaultTokenOption = tokenOptions.find((token) => token.isDefault) || tokenOptions[0] || null;
|
||||
@@ -1121,8 +1349,8 @@ export default function TokenRoutes() {
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in" style={{ display: 'flex', gap: 24, minHeight: 400 }}>
|
||||
{!filterCollapsed && (
|
||||
<div className="filter-panel">
|
||||
{filterPanelPresence.shouldRender && (
|
||||
<div className={`filter-panel filter-collapsible ${filterPanelPresence.isVisible ? '' : 'is-closing'}`.trim()}>
|
||||
<div className="filter-panel-section">
|
||||
<div className="filter-panel-title">
|
||||
品牌
|
||||
@@ -1235,6 +1463,94 @@ export default function TokenRoutes() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{siteList.length > 0 && (
|
||||
<div className="filter-panel-section">
|
||||
<div className="filter-panel-title">
|
||||
站点
|
||||
{activeSite && <button onClick={() => setActiveSite(null)}>重置</button>}
|
||||
</div>
|
||||
|
||||
<div className={`filter-item ${!activeSite ? 'active' : ''}`} onClick={() => setActiveSite(null)}>
|
||||
<span
|
||||
className="filter-item-icon"
|
||||
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
|
||||
>
|
||||
⚡
|
||||
</span>
|
||||
全部站点
|
||||
<span className="filter-item-count">{routes.length}</span>
|
||||
</div>
|
||||
|
||||
{siteList.map(([siteName, { count }]) => (
|
||||
<div
|
||||
key={siteName}
|
||||
className={`filter-item ${activeSite === siteName ? 'active' : ''}`}
|
||||
onClick={() => setActiveSite(activeSite === siteName ? null : siteName)}
|
||||
>
|
||||
<span
|
||||
className="filter-item-icon"
|
||||
style={{ background: hashColor(siteName), color: 'white', fontSize: 9, borderRadius: 4 }}
|
||||
>
|
||||
{siteAvatarLetters(siteName)}
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{siteName}</span>
|
||||
<span className="filter-item-count">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="filter-panel-section">
|
||||
<div className="filter-panel-title">
|
||||
接口能力
|
||||
{activeEndpointType && <button onClick={() => setActiveEndpointType(null)}>重置</button>}
|
||||
</div>
|
||||
|
||||
<div className={`filter-item ${!activeEndpointType ? 'active' : ''}`} onClick={() => setActiveEndpointType(null)}>
|
||||
<span
|
||||
className="filter-item-icon"
|
||||
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
|
||||
>
|
||||
⚙
|
||||
</span>
|
||||
全部能力
|
||||
<span className="filter-item-count">{routes.length}</span>
|
||||
</div>
|
||||
|
||||
{endpointTypeList.map(([endpointType, count]) => (
|
||||
<div
|
||||
key={endpointType}
|
||||
className={`filter-item ${activeEndpointType === endpointType ? 'active' : ''}`}
|
||||
onClick={() => setActiveEndpointType(activeEndpointType === endpointType ? null : endpointType)}
|
||||
>
|
||||
<span
|
||||
className="filter-item-icon"
|
||||
style={{
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: 10,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const iconModel = resolveEndpointTypeIconModel(endpointType);
|
||||
if (!iconModel) return <span style={{ fontSize: 10 }}>⚙</span>;
|
||||
return <InlineBrandIcon model={iconModel} size={14} />;
|
||||
})()}
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{endpointType}</span>
|
||||
<span className="filter-item-count">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{endpointTypeList.length === 0 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', padding: '4px 10px 0' }}>
|
||||
暂无接口能力数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
@@ -1365,7 +1681,8 @@ export default function TokenRoutes() {
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)', padding: '8px 12px', fontSize: 12 }}
|
||||
onClick={() => setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'))}
|
||||
title={tr('切换排序方向')}
|
||||
data-tooltip={tr('切换排序方向')}
|
||||
aria-label={tr('切换排序方向')}
|
||||
>
|
||||
{sortDir === 'asc' ? tr('升序 ↑') : tr('降序 ↓')}
|
||||
</button>
|
||||
@@ -1377,8 +1694,8 @@ export default function TokenRoutes() {
|
||||
{decisionAutoSkipped ? ` ${tr('当前精确路由')} ${exactRouteCount} ${tr('条,为避免首屏卡顿,默认不自动计算概率,点击“加载选择解释”后按需获取。')}` : ''}
|
||||
</div>
|
||||
|
||||
{showManual && (
|
||||
<div className="card animate-scale-in" style={{ padding: 20, marginBottom: 16 }}>
|
||||
{manualPanelPresence.shouldRender && (
|
||||
<div className={`card panel-presence ${manualPanelPresence.isVisible ? '' : 'is-closing'}`.trim()} style={{ padding: 20, marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
|
||||
{tr('用于创建群组路由(聚合多个上游模型为一个下游模型名,即模型重定向);自动路由仍会保持开启。')}
|
||||
</div>
|
||||
@@ -1519,6 +1836,34 @@ export default function TokenRoutes() {
|
||||
? (candidateView.tokenOptionsByAccountId[selectedAccountId] || [])
|
||||
: [];
|
||||
const candidateMode = routeCandidates.length > 0;
|
||||
const missingTokenHints = routeMissingTokenIndex[route.id] || [];
|
||||
const missingTokenSiteItems = (() => {
|
||||
const siteMap = new Map<string, MissingTokenRouteSiteActionItem>();
|
||||
for (const hint of missingTokenHints) {
|
||||
for (const account of hint.accounts) {
|
||||
const siteName = (account.siteName || '').trim() || `site-${account.siteId || 'unknown'}`;
|
||||
const key = `${account.siteId || 0}::${siteName.toLowerCase()}`;
|
||||
const accountLabel = account.username || `account-${account.accountId}`;
|
||||
const existing = siteMap.get(key);
|
||||
if (!existing) {
|
||||
siteMap.set(key, {
|
||||
key,
|
||||
siteName,
|
||||
accountId: account.accountId,
|
||||
accountLabel,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (account.accountId < existing.accountId) {
|
||||
existing.accountId = account.accountId;
|
||||
existing.accountLabel = accountLabel;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(siteMap.values()).sort((a, b) => (
|
||||
a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' })
|
||||
));
|
||||
})();
|
||||
const routeDecision = decisionByRoute[route.id] || null;
|
||||
const exactRoute = isExactModelPattern(route.modelPattern);
|
||||
const decisionMap = new Map<number, RouteDecisionCandidate>(
|
||||
@@ -1618,17 +1963,32 @@ export default function TokenRoutes() {
|
||||
{route.modelPattern}
|
||||
</span>
|
||||
) : null}
|
||||
<span className={`badge ${route.enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>
|
||||
<button
|
||||
className={`badge route-enable-toggle ${route.enabled ? 'is-enabled' : 'is-disabled'}`}
|
||||
style={{ fontSize: 11, cursor: 'pointer', border: 'none' }}
|
||||
onClick={(e) => { e.stopPropagation(); handleToggleRouteEnabled(route); }}
|
||||
data-tooltip={route.enabled ? '点击禁用此路由' : '点击启用此路由'}
|
||||
aria-label={route.enabled ? '点击禁用此路由' : '点击启用此路由'}
|
||||
>
|
||||
{route.enabled ? tr('启用') : tr('禁用')}
|
||||
</span>
|
||||
</button>
|
||||
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||
{route.channels?.length || 0} {tr('通道')}
|
||||
</span>
|
||||
{candidateMode && (
|
||||
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||
<span
|
||||
className="badge badge-info"
|
||||
style={{ fontSize: 10 }}
|
||||
data-tooltip="添加通道时,仅展示可覆盖当前模型模式的账号与令牌,自动过滤不支持该模型的令牌。"
|
||||
>
|
||||
{tr('按模型过滤')}
|
||||
</span>
|
||||
)}
|
||||
{missingTokenSiteItems.length > 0 && (
|
||||
<span className="badge badge-warning" style={{ fontSize: 10 }}>
|
||||
待注册站点 {missingTokenSiteItems.length}
|
||||
</span>
|
||||
)}
|
||||
{channelGroups.length > 1 && (
|
||||
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||
{channelGroups.length} 来源分组
|
||||
@@ -1714,6 +2074,43 @@ export default function TokenRoutes() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{missingTokenSiteItems.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
padding: '8px 10px',
|
||||
border: '1px solid color-mix(in srgb, var(--color-warning) 35%, transparent)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'color-mix(in srgb, var(--color-warning) 10%, var(--color-bg))',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
||||
以下站点已提供该模型,但你未注册对应站点令牌;点击站点标签可跳转创建对应站点令牌:
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6 }}>
|
||||
{missingTokenSiteItems.map((item) => (
|
||||
<button
|
||||
key={`missing-create-${route.id}-${item.key}`}
|
||||
type="button"
|
||||
onClick={() => handleCreateTokenForMissingAccount(item.accountId, route.modelPattern)}
|
||||
className="badge badge-info missing-token-site-tag"
|
||||
data-tooltip={`点击跳转到令牌创建(预选 ${item.siteName}/${item.accountLabel})`}
|
||||
aria-label={`点击跳转到令牌创建(预选 ${item.siteName}/${item.accountLabel})`}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{item.siteName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{route.channels?.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<DndContext
|
||||
@@ -1726,60 +2123,144 @@ export default function TokenRoutes() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{channelGroups.map((group) => (
|
||||
<div key={`${route.id}-${group.sourceModel || '__ungrouped__'}`} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{group.sourceModel ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingLeft: 2,
|
||||
}}
|
||||
>
|
||||
<span>来源模型</span>
|
||||
<code
|
||||
style={{
|
||||
fontSize: 11,
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 6,
|
||||
padding: '2px 6px',
|
||||
background: 'var(--color-bg)',
|
||||
}}
|
||||
>
|
||||
{group.sourceModel}
|
||||
</code>
|
||||
<span style={{ color: 'var(--color-text-muted)' }}>{group.channels.length} 通道</span>
|
||||
</div>
|
||||
) : null}
|
||||
<SortableContext
|
||||
items={group.channels.map((channel) => channel.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{group.channels.map((channel) => {
|
||||
const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
|
||||
const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
|
||||
{(() => {
|
||||
const groupKey = buildSourceGroupKey(route.id, group.sourceModel || '');
|
||||
const supportsCollapse = !exactRoute && !!group.sourceModel;
|
||||
const isGroupExpanded = supportsCollapse ? !!expandedSourceGroupMap[groupKey] : true;
|
||||
|
||||
return (
|
||||
<SortableChannelRow
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
decisionCandidate={decisionMap.get(channel.id)}
|
||||
isExactRoute={exactRoute}
|
||||
loadingDecision={loadingDecision}
|
||||
isSavingPriority={!!savingPriorityByRoute[route.id]}
|
||||
tokenOptions={tokenOptions}
|
||||
activeTokenId={activeTokenId}
|
||||
isUpdatingToken={!!updatingChannel[channel.id]}
|
||||
onTokenDraftChange={(channelId, tokenId) =>
|
||||
setChannelTokenDraft((prev) => ({ ...prev, [channelId]: tokenId }))
|
||||
}
|
||||
onSaveToken={() => handleChannelTokenSave(route, channel.id, channel.accountId)}
|
||||
onDeleteChannel={() => handleDeleteChannel(channel.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
return (
|
||||
<>
|
||||
{group.sourceModel ? (
|
||||
supportsCollapse ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setExpandedSourceGroupMap((prev) => ({
|
||||
...prev,
|
||||
[groupKey]: !prev[groupKey],
|
||||
}));
|
||||
}}
|
||||
aria-expanded={isGroupExpanded}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 8,
|
||||
padding: '4px 6px',
|
||||
border: '1px dashed var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'transparent',
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>来源模型</span>
|
||||
<code
|
||||
style={{
|
||||
fontSize: 11,
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 6,
|
||||
padding: '2px 6px',
|
||||
background: 'var(--color-bg)',
|
||||
}}
|
||||
>
|
||||
{group.sourceModel}
|
||||
</code>
|
||||
<span style={{ color: 'var(--color-text-muted)' }}>{group.channels.length} 通道</span>
|
||||
</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--color-text-muted)' }}>
|
||||
{isGroupExpanded ? '收起' : '展开'}
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{
|
||||
transform: isGroupExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
<path d="m5 7 5 6 5-6" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingLeft: 2,
|
||||
}}
|
||||
>
|
||||
<span>来源模型</span>
|
||||
<code
|
||||
style={{
|
||||
fontSize: 11,
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 6,
|
||||
padding: '2px 6px',
|
||||
background: 'var(--color-bg)',
|
||||
}}
|
||||
>
|
||||
{group.sourceModel}
|
||||
</code>
|
||||
<span style={{ color: 'var(--color-text-muted)' }}>{group.channels.length} 通道</span>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
|
||||
<AnimatedCollapseSection open={isGroupExpanded}>
|
||||
<SortableContext
|
||||
items={group.channels.map((channel) => channel.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{group.channels.map((channel) => {
|
||||
const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
|
||||
const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
|
||||
|
||||
return (
|
||||
<SortableChannelRow
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
decisionCandidate={decisionMap.get(channel.id)}
|
||||
isExactRoute={exactRoute}
|
||||
loadingDecision={loadingDecision}
|
||||
isSavingPriority={!!savingPriorityByRoute[route.id]}
|
||||
tokenOptions={tokenOptions}
|
||||
activeTokenId={activeTokenId}
|
||||
isUpdatingToken={!!updatingChannel[channel.id]}
|
||||
onTokenDraftChange={(channelId, tokenId) =>
|
||||
setChannelTokenDraft((prev) => ({ ...prev, [channelId]: tokenId }))
|
||||
}
|
||||
onSaveToken={() => handleChannelTokenSave(route, channel.id, channel.accountId)}
|
||||
onDeleteChannel={() => handleDeleteChannel(channel.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</AnimatedCollapseSection>
|
||||
{!isGroupExpanded ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--color-text-muted)',
|
||||
paddingLeft: 6,
|
||||
}}
|
||||
>
|
||||
已收起,点击展开查看通道
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { getAccountsAddPanelStyle } from './helpers/accountsPanelStyle.js';
|
||||
@@ -69,7 +71,21 @@ async function copyText(text: string) {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
function parsePositiveInt(input: string | null): number {
|
||||
const value = Number.parseInt(String(input || '').trim(), 10);
|
||||
if (!Number.isFinite(value) || value <= 0) return 0;
|
||||
return value;
|
||||
}
|
||||
|
||||
function isTruthyFlag(input: string | null): boolean {
|
||||
if (!input) return false;
|
||||
const normalized = input.trim().toLowerCase();
|
||||
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
||||
}
|
||||
|
||||
export default function Tokens() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const initialCreateForm = {
|
||||
accountId: 0,
|
||||
name: '',
|
||||
@@ -88,10 +104,12 @@ export default function Tokens() {
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncingAll, setSyncingAll] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [createHintModelName, setCreateHintModelName] = useState('');
|
||||
const [rowLoading, setRowLoading] = useState<Record<string, boolean>>({});
|
||||
const [form, setForm] = useState(initialCreateForm);
|
||||
const [groupOptions, setGroupOptions] = useState<string[]>(['default']);
|
||||
const [groupLoading, setGroupLoading] = useState(false);
|
||||
const addPanelPresence = useAnimatedVisibility(showAdd, 220);
|
||||
const toast = useToast();
|
||||
|
||||
const load = async () => {
|
||||
@@ -159,6 +177,44 @@ export default function Tokens() {
|
||||
|
||||
const activeAccounts = useMemo(() => accounts.filter(isAccountSyncable), [accounts]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const shouldOpenCreate = isTruthyFlag(params.get('create'));
|
||||
const requestedAccountId = parsePositiveInt(params.get('accountId'));
|
||||
const requestedModel = (params.get('model') || '').trim();
|
||||
if (!shouldOpenCreate || !requestedAccountId) return;
|
||||
|
||||
const preferredAccount = activeAccounts.find((account) => account.id === requestedAccountId);
|
||||
const fallbackAccount = preferredAccount || activeAccounts[0] || null;
|
||||
if (!fallbackAccount) return;
|
||||
|
||||
setShowAdd(true);
|
||||
setCreateHintModelName(requestedModel);
|
||||
setSyncingAccountId(fallbackAccount.id);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
accountId: fallbackAccount.id,
|
||||
group: 'default',
|
||||
}));
|
||||
|
||||
if (!preferredAccount) {
|
||||
toast.info('指定账号不可用,已自动切换到首个可用账号');
|
||||
}
|
||||
|
||||
params.delete('create');
|
||||
params.delete('accountId');
|
||||
params.delete('model');
|
||||
params.delete('from');
|
||||
const nextSearch = params.toString();
|
||||
navigate(
|
||||
{
|
||||
pathname: location.pathname,
|
||||
search: nextSearch ? `?${nextSearch}` : '',
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [activeAccounts, location.pathname, location.search, navigate, toast]);
|
||||
|
||||
const withRowLoading = async (key: string, fn: () => Promise<void>) => {
|
||||
setRowLoading((prev) => ({ ...prev, [key]: true }));
|
||||
try {
|
||||
@@ -208,6 +264,7 @@ export default function Tokens() {
|
||||
toast.success('已在站点创建并同步令牌');
|
||||
setForm(initialCreateForm);
|
||||
setShowAdd(false);
|
||||
setCreateHintModelName('');
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '创建令牌失败');
|
||||
@@ -340,7 +397,16 @@ export default function Tokens() {
|
||||
>
|
||||
{syncingAll ? <><span className="spinner spinner-sm" /> 同步中...</> : '同步全部账号'}
|
||||
</button>
|
||||
<button onClick={() => setShowAdd((prev) => !prev)} className="btn btn-primary">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAdd((prev) => {
|
||||
const nextOpen = !prev;
|
||||
if (!nextOpen) setCreateHintModelName('');
|
||||
return nextOpen;
|
||||
});
|
||||
}}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{showAdd ? '取消' : '+ 新增令牌'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -350,8 +416,8 @@ export default function Tokens() {
|
||||
新增令牌会调用站点 API 创建新密钥,再自动同步到本地。支持设置分组、额度、过期时间和 IP 白名单;已存在密钥可直接用“同步站点令牌”读取。
|
||||
</div>
|
||||
|
||||
{showAdd && (
|
||||
<div className="card animate-scale-in" style={getAccountsAddPanelStyle()}>
|
||||
{addPanelPresence.shouldRender && (
|
||||
<div className={`card panel-presence ${addPanelPresence.isVisible ? '' : 'is-closing'}`.trim()} style={getAccountsAddPanelStyle()}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div style={{ gridColumn: '1 / span 2' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>所属账号</div>
|
||||
@@ -374,6 +440,21 @@ export default function Tokens() {
|
||||
placeholder="选择账号"
|
||||
/>
|
||||
</div>
|
||||
{createHintModelName ? (
|
||||
<div
|
||||
style={{
|
||||
gridColumn: '1 / span 2',
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
background: 'color-mix(in srgb, var(--color-info) 10%, var(--color-bg))',
|
||||
border: '1px solid color-mix(in srgb, var(--color-info) 30%, transparent)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: '6px 8px',
|
||||
}}
|
||||
>
|
||||
来自路由提醒:为模型 <code style={{ fontSize: 11 }}>{createHintModelName}</code> 补充该账号令牌后,可自动生成对应通道。
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>令牌名称(可选)</div>
|
||||
<input
|
||||
|
||||
@@ -50,6 +50,7 @@ describe('Accounts refresh action', () => {
|
||||
balanceUsed: 0,
|
||||
todayReward: 0,
|
||||
todaySpend: 0,
|
||||
accessToken: 'session-token',
|
||||
status: 'active',
|
||||
checkinEnabled: true,
|
||||
site: { id: 10, name: 'Demo Site', status: 'active', url: 'https://example.com' },
|
||||
@@ -64,6 +65,7 @@ describe('Accounts refresh action', () => {
|
||||
balanceUsed: 0,
|
||||
todayReward: 0,
|
||||
todaySpend: 0,
|
||||
accessToken: 'session-token',
|
||||
status: 'expired',
|
||||
checkinEnabled: true,
|
||||
site: { id: 10, name: 'Demo Site', status: 'active', url: 'https://example.com' },
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseProxyLogPathMeta } from './proxyLogPathMeta.js';
|
||||
|
||||
describe('parseProxyLogPathMeta', () => {
|
||||
it('parses downstream and upstream paths from prefixed message', () => {
|
||||
const parsed = parseProxyLogPathMeta('[downstream:/v1/responses] [upstream:/v1/chat/completions] {"error":"x"}');
|
||||
expect(parsed.downstreamPath).toBe('/v1/responses');
|
||||
expect(parsed.upstreamPath).toBe('/v1/chat/completions');
|
||||
expect(parsed.errorMessage).toBe('{"error":"x"}');
|
||||
});
|
||||
|
||||
it('supports historical upstream-only logs', () => {
|
||||
const parsed = parseProxyLogPathMeta('[upstream:/v1/messages] messages is required');
|
||||
expect(parsed.downstreamPath).toBe(null);
|
||||
expect(parsed.upstreamPath).toBe('/v1/messages');
|
||||
expect(parsed.errorMessage).toBe('messages is required');
|
||||
});
|
||||
|
||||
it('keeps plain message when no metadata exists', () => {
|
||||
const parsed = parseProxyLogPathMeta('network timeout');
|
||||
expect(parsed.downstreamPath).toBe(null);
|
||||
expect(parsed.upstreamPath).toBe(null);
|
||||
expect(parsed.errorMessage).toBe('network timeout');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
type ProxyLogPathMeta = {
|
||||
downstreamPath: string | null;
|
||||
upstreamPath: string | null;
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
export function parseProxyLogPathMeta(message?: string): ProxyLogPathMeta {
|
||||
const raw = typeof message === 'string' ? message.trim() : '';
|
||||
const downstreamMatch = raw.match(/\[downstream:([^\]]+)\]/i);
|
||||
const upstreamMatch = raw.match(/\[upstream:([^\]]+)\]/i);
|
||||
const stripped = raw.replace(/^\s*(?:\[(?:downstream|upstream):[^\]]+\]\s*)+/i, '').trim();
|
||||
|
||||
return {
|
||||
downstreamPath: downstreamMatch?.[1]?.trim() || null,
|
||||
upstreamPath: upstreamMatch?.[1]?.trim() || null,
|
||||
errorMessage: stripped,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildRouteMissingTokenIndex,
|
||||
normalizeMissingTokenModels,
|
||||
type MissingTokenModelsByName,
|
||||
} from './routeMissingTokenHints.js';
|
||||
|
||||
function matchesModelPattern(model: string, pattern: string): boolean {
|
||||
if (!pattern) return false;
|
||||
if (pattern === '*') return true;
|
||||
if (pattern.endsWith('*')) return model.startsWith(pattern.slice(0, -1));
|
||||
return model === pattern;
|
||||
}
|
||||
|
||||
describe('buildRouteMissingTokenIndex', () => {
|
||||
it('matches missing-token models by route pattern and deduplicates accounts', () => {
|
||||
const routes = [
|
||||
{ id: 1, modelPattern: 'claude-*' },
|
||||
{ id: 2, modelPattern: 'gpt-4o-mini' },
|
||||
{ id: 3, modelPattern: '' },
|
||||
];
|
||||
|
||||
const missingByModel: MissingTokenModelsByName = {
|
||||
'claude-opus-4-6': [
|
||||
{ accountId: 11, username: 'alice', siteId: 1, siteName: 'site-a' },
|
||||
{ accountId: 11, username: 'alice', siteId: 1, siteName: 'site-a' },
|
||||
],
|
||||
'claude-code-4-6': [
|
||||
{ accountId: 22, username: 'bob', siteId: 2, siteName: 'site-b' },
|
||||
],
|
||||
'gpt-4o-mini': [
|
||||
{ accountId: 33, username: 'charlie', siteId: 3, siteName: 'site-c' },
|
||||
],
|
||||
};
|
||||
|
||||
const index = buildRouteMissingTokenIndex(routes, missingByModel, matchesModelPattern);
|
||||
expect(index[1].map((item) => item.modelName)).toEqual(['claude-code-4-6', 'claude-opus-4-6']);
|
||||
expect(index[1][1].accounts).toEqual([{ accountId: 11, username: 'alice', siteId: 1, siteName: 'site-a' }]);
|
||||
expect(index[2].map((item) => item.modelName)).toEqual(['gpt-4o-mini']);
|
||||
expect(index[3]).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty entries when missing model map is empty', () => {
|
||||
const index = buildRouteMissingTokenIndex([{ id: 1, modelPattern: 'claude-*' }], {}, matchesModelPattern);
|
||||
expect(index[1]).toEqual([]);
|
||||
});
|
||||
|
||||
it('normalizes missing-token map by trimming model name and deduplicating account', () => {
|
||||
const merged = normalizeMissingTokenModels({
|
||||
' claude-opus-4-6 ': [
|
||||
{ accountId: 1, username: 'alice', siteId: 11, siteName: 'site-a' },
|
||||
{ accountId: 1, username: 'alice', siteId: 11, siteName: 'site-a' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(merged['claude-opus-4-6']).toEqual([
|
||||
{
|
||||
accountId: 1,
|
||||
username: 'alice',
|
||||
siteId: 11,
|
||||
siteName: 'site-a',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
export type MissingTokenModelAccount = {
|
||||
accountId: number;
|
||||
username: string | null;
|
||||
siteId: number;
|
||||
siteName: string;
|
||||
};
|
||||
|
||||
export type MissingTokenModelsByName = Record<string, MissingTokenModelAccount[]>;
|
||||
|
||||
export type RouteMissingTokenHint = {
|
||||
modelName: string;
|
||||
accounts: MissingTokenModelAccount[];
|
||||
};
|
||||
|
||||
type RoutePatternLike = {
|
||||
id: number;
|
||||
modelPattern: string;
|
||||
};
|
||||
|
||||
export function normalizeMissingTokenModels(
|
||||
withoutTokenByModel: MissingTokenModelsByName,
|
||||
): MissingTokenModelsByName {
|
||||
const normalized: MissingTokenModelsByName = {};
|
||||
for (const modelName of Object.keys(withoutTokenByModel || {})) {
|
||||
const normalizedModelName = String(modelName || '').trim();
|
||||
if (!normalizedModelName) continue;
|
||||
const accountMap = new Map<number, MissingTokenModelAccount>();
|
||||
for (const account of withoutTokenByModel[modelName] || []) {
|
||||
if (!account || !Number.isFinite(account.accountId)) continue;
|
||||
const accountName = (account.username || '').trim();
|
||||
const siteName = String(account.siteName || '').trim();
|
||||
accountMap.set(account.accountId, {
|
||||
accountId: account.accountId,
|
||||
username: accountName || null,
|
||||
siteId: account.siteId,
|
||||
siteName,
|
||||
});
|
||||
}
|
||||
if (accountMap.size > 0) {
|
||||
normalized[normalizedModelName] = Array.from(accountMap.values()).sort((a, b) => a.accountId - b.accountId);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function buildRouteMissingTokenIndex(
|
||||
routes: RoutePatternLike[],
|
||||
missingByModel: MissingTokenModelsByName,
|
||||
matchesModelPattern: (model: string, pattern: string) => boolean,
|
||||
): Record<number, RouteMissingTokenHint[]> {
|
||||
const index: Record<number, RouteMissingTokenHint[]> = {};
|
||||
|
||||
for (const route of routes || []) {
|
||||
const modelPattern = (route.modelPattern || '').trim();
|
||||
if (!modelPattern) {
|
||||
index[route.id] = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchedHints: RouteMissingTokenHint[] = [];
|
||||
for (const [modelName, accounts] of Object.entries(missingByModel || {})) {
|
||||
if (!matchesModelPattern(modelName, modelPattern)) continue;
|
||||
const dedupedAccounts = new Map<number, MissingTokenModelAccount>();
|
||||
for (const account of accounts || []) {
|
||||
if (!account || !Number.isFinite(account.accountId)) continue;
|
||||
dedupedAccounts.set(account.accountId, account);
|
||||
}
|
||||
|
||||
matchedHints.push({
|
||||
modelName,
|
||||
accounts: Array.from(dedupedAccounts.values()).sort((a, b) => {
|
||||
const siteCompare = String(a.siteName || '').localeCompare(String(b.siteName || ''), undefined, { sensitivity: 'base' });
|
||||
if (siteCompare !== 0) return siteCompare;
|
||||
return a.accountId - b.accountId;
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
matchedHints.sort((a, b) => a.modelName.localeCompare(b.modelName, undefined, { sensitivity: 'base' }));
|
||||
index[route.id] = matchedHints;
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
@@ -8,9 +8,11 @@ describe('buildSiteSaveAction', () => {
|
||||
{
|
||||
name: 'site-a',
|
||||
url: 'https://a.example.com/',
|
||||
externalCheckinUrl: 'https://checkin.a.example.com',
|
||||
platform: 'new-api',
|
||||
apiKey: 'sk-1',
|
||||
proxyUrl: 'http://127.0.0.1:7890',
|
||||
globalWeight: '1.2',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -19,9 +21,11 @@ describe('buildSiteSaveAction', () => {
|
||||
payload: {
|
||||
name: 'site-a',
|
||||
url: 'https://a.example.com/',
|
||||
externalCheckinUrl: 'https://checkin.a.example.com',
|
||||
platform: 'new-api',
|
||||
apiKey: 'sk-1',
|
||||
proxyUrl: 'http://127.0.0.1:7890',
|
||||
globalWeight: '1.2',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -29,7 +33,15 @@ describe('buildSiteSaveAction', () => {
|
||||
it('returns update action in edit mode with site id', () => {
|
||||
const action = buildSiteSaveAction(
|
||||
{ mode: 'edit', editingSiteId: 12 },
|
||||
{ name: 'site-b', url: 'https://b.example.com', platform: 'one-api', apiKey: '', proxyUrl: '' },
|
||||
{
|
||||
name: 'site-b',
|
||||
url: 'https://b.example.com',
|
||||
externalCheckinUrl: '',
|
||||
platform: 'one-api',
|
||||
apiKey: '',
|
||||
proxyUrl: '',
|
||||
globalWeight: '0.8',
|
||||
},
|
||||
);
|
||||
|
||||
expect(action).toEqual({
|
||||
@@ -38,9 +50,11 @@ describe('buildSiteSaveAction', () => {
|
||||
payload: {
|
||||
name: 'site-b',
|
||||
url: 'https://b.example.com',
|
||||
externalCheckinUrl: '',
|
||||
platform: 'one-api',
|
||||
apiKey: '',
|
||||
proxyUrl: '',
|
||||
globalWeight: '0.8',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -49,7 +63,15 @@ describe('buildSiteSaveAction', () => {
|
||||
expect(() =>
|
||||
buildSiteSaveAction(
|
||||
{ mode: 'edit' },
|
||||
{ name: 'site-c', url: 'https://c.example.com', platform: '', apiKey: '', proxyUrl: '' },
|
||||
{
|
||||
name: 'site-c',
|
||||
url: 'https://c.example.com',
|
||||
externalCheckinUrl: '',
|
||||
platform: '',
|
||||
apiKey: '',
|
||||
proxyUrl: '',
|
||||
globalWeight: '1',
|
||||
},
|
||||
),
|
||||
).toThrow('editingSiteId is required in edit mode');
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
export type SiteForm = {
|
||||
name: string;
|
||||
url: string;
|
||||
externalCheckinUrl: string;
|
||||
platform: string;
|
||||
apiKey: string;
|
||||
proxyUrl: string;
|
||||
globalWeight: string;
|
||||
};
|
||||
|
||||
export type SiteEditorState =
|
||||
@@ -15,16 +17,24 @@ type SiteSaveAction =
|
||||
| { kind: 'update'; id: number; payload: SiteForm };
|
||||
|
||||
export function emptySiteForm(): SiteForm {
|
||||
return { name: '', url: '', platform: '', apiKey: '', proxyUrl: '' };
|
||||
return { name: '', url: '', externalCheckinUrl: '', platform: '', apiKey: '', proxyUrl: '', globalWeight: '1' };
|
||||
}
|
||||
|
||||
export function siteFormFromSite(site: Partial<SiteForm> & { proxyUrl?: string | null }): SiteForm {
|
||||
export function siteFormFromSite(site: Partial<SiteForm> & {
|
||||
proxyUrl?: string | null;
|
||||
externalCheckinUrl?: string | null;
|
||||
globalWeight?: number | string | null;
|
||||
}): SiteForm {
|
||||
const globalWeightRaw = Number(site.globalWeight);
|
||||
const globalWeight = Number.isFinite(globalWeightRaw) && globalWeightRaw > 0 ? String(globalWeightRaw) : '1';
|
||||
return {
|
||||
name: site.name ?? '',
|
||||
url: site.url ?? '',
|
||||
externalCheckinUrl: site.externalCheckinUrl ?? '',
|
||||
platform: site.platform ?? '',
|
||||
apiKey: site.apiKey ?? '',
|
||||
proxyUrl: site.proxyUrl ?? '',
|
||||
globalWeight,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Sites from './Sites.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getSites: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
const children = node.children || [];
|
||||
return children.map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Sites edit behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('scrolls to page top when entering edit mode', async () => {
|
||||
apiMock.getSites.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Demo Site',
|
||||
url: 'https://example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
},
|
||||
]);
|
||||
|
||||
const scrollToMock = vi.fn();
|
||||
Object.defineProperty(globalThis, 'scrollTo', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: scrollToMock,
|
||||
});
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/sites']}>
|
||||
<ToastProvider>
|
||||
<Sites />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const editButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '编辑'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
editButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(scrollToMock).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,367 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import TokenRoutes from './TokenRoutes.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getRoutes: vi.fn(),
|
||||
getModelTokenCandidates: vi.fn(),
|
||||
getRouteDecisionsBatch: vi.fn(),
|
||||
getRouteWideDecisionsBatch: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('../components/BrandIcon.js', () => ({
|
||||
InlineBrandIcon: ({ model }: { model: string }) => model ? <span>{model}</span> : null,
|
||||
getBrand: () => null,
|
||||
useIconCdn: () => 'https://cdn.test',
|
||||
hashColor: () => 'linear-gradient(135deg,#4f46e5,#818cf8)',
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
const children = node.children || [];
|
||||
return children.map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function findButtonByText(root: ReactTestInstance, text: string): ReactTestInstance {
|
||||
return root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes(text)
|
||||
));
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('TokenRoutes grouped source models', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
|
||||
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
|
||||
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('collapses source-model groups by default for wildcard routes', async () => {
|
||||
apiMock.getRoutes.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
modelPattern: 're:^claude-opus-(4-6|4-5)$',
|
||||
displayName: 'claude-opus-4-6',
|
||||
enabled: true,
|
||||
channels: [
|
||||
{
|
||||
id: 11,
|
||||
accountId: 101,
|
||||
tokenId: 1001,
|
||||
sourceModel: 'claude-opus-4-5',
|
||||
priority: 0,
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
successCount: 0,
|
||||
failCount: 0,
|
||||
account: { username: 'user_a' },
|
||||
site: { name: 'site-a' },
|
||||
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
accountId: 102,
|
||||
tokenId: 1002,
|
||||
sourceModel: 'claude-opus-4-6',
|
||||
priority: 0,
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
successCount: 0,
|
||||
failCount: 0,
|
||||
account: { username: 'user_b' },
|
||||
site: { name: 'site-b' },
|
||||
token: { id: 1002, name: 'token-b', accountId: 102, enabled: true, isDefault: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('claude-opus-4-5');
|
||||
expect(text).toContain('claude-opus-4-6');
|
||||
expect(text).not.toContain('user_a');
|
||||
expect(text).not.toContain('user_b');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('expands a source-model group after user click', async () => {
|
||||
apiMock.getRoutes.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
modelPattern: 're:^claude-opus-(4-6|4-5)$',
|
||||
displayName: 'claude-opus-4-6',
|
||||
enabled: true,
|
||||
channels: [
|
||||
{
|
||||
id: 11,
|
||||
accountId: 101,
|
||||
tokenId: 1001,
|
||||
sourceModel: 'claude-opus-4-5',
|
||||
priority: 0,
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
successCount: 0,
|
||||
failCount: 0,
|
||||
account: { username: 'user_a' },
|
||||
site: { name: 'site-a' },
|
||||
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(collectText(root.root)).not.toContain('user_a');
|
||||
|
||||
const toggleButton = findButtonByText(root.root, 'claude-opus-4-5');
|
||||
await act(async () => {
|
||||
toggleButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(collectText(root.root)).toContain('user_a');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders missing-token site tags with interactive hover class', async () => {
|
||||
apiMock.getRoutes.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
modelPattern: 'gpt-5.2-codex',
|
||||
displayName: 'gpt-5.2-codex',
|
||||
enabled: true,
|
||||
channels: [],
|
||||
},
|
||||
]);
|
||||
apiMock.getModelTokenCandidates.mockResolvedValue({
|
||||
models: {},
|
||||
modelsWithoutToken: {
|
||||
'gpt-5.2-codex': [
|
||||
{
|
||||
accountId: 101,
|
||||
username: 'linuxdo_11494',
|
||||
siteId: 11,
|
||||
siteName: 'Wong',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const siteButton = findButtonByText(root.root, 'Wong');
|
||||
expect(String(siteButton.props.className || '')).toContain('missing-token-site-tag');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('maps endpoint types to expected brand icons in filter panel', async () => {
|
||||
apiMock.getRoutes.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
modelPattern: 'gpt-5.2-codex',
|
||||
displayName: 'gpt-5.2-codex',
|
||||
enabled: true,
|
||||
channels: [
|
||||
{
|
||||
id: 11,
|
||||
accountId: 101,
|
||||
tokenId: 1001,
|
||||
sourceModel: 'gpt-5.2-codex',
|
||||
priority: 0,
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
successCount: 0,
|
||||
failCount: 0,
|
||||
account: { username: 'user_a' },
|
||||
site: { id: 1, name: 'Wong' },
|
||||
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
apiMock.getModelTokenCandidates.mockResolvedValue({
|
||||
models: {},
|
||||
endpointTypesByModel: {
|
||||
'gpt-5.2-codex': ['openai', 'gemini', 'anthropic'],
|
||||
},
|
||||
});
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('chatgpt');
|
||||
expect(text).toContain('gemini');
|
||||
expect(text).toContain('claude');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to site platform endpoint grouping when endpoint metadata cache is empty', async () => {
|
||||
apiMock.getRoutes.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
modelPattern: 'gpt-4o-mini',
|
||||
displayName: 'gpt-4o-mini',
|
||||
enabled: true,
|
||||
channels: [
|
||||
{
|
||||
id: 11,
|
||||
accountId: 101,
|
||||
tokenId: 1001,
|
||||
sourceModel: null,
|
||||
priority: 0,
|
||||
weight: 1,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
successCount: 0,
|
||||
failCount: 0,
|
||||
account: { username: 'user_a' },
|
||||
site: { id: 1, name: 'site-a', platform: 'new-api' },
|
||||
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
apiMock.getModelTokenCandidates.mockResolvedValue({
|
||||
models: {},
|
||||
endpointTypesByModel: {},
|
||||
});
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('接口能力');
|
||||
expect(text).toContain('openai');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('still shows endpoint group section with empty hint when no endpoint data can be inferred', async () => {
|
||||
apiMock.getRoutes.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
modelPattern: 'custom-model-without-channel',
|
||||
displayName: 'custom-model-without-channel',
|
||||
enabled: true,
|
||||
channels: [],
|
||||
},
|
||||
]);
|
||||
apiMock.getModelTokenCandidates.mockResolvedValue({
|
||||
models: {},
|
||||
endpointTypesByModel: {},
|
||||
});
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('接口能力');
|
||||
expect(text).toContain('暂无接口能力数据');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user