feat: enhance API key management and fix routing logic

* ```
feat(accounts): 支持批量添加API密钥连接

- 添加accessTokens字段到账户创建载荷模式,允许传递多个API密钥
- 实现批量API密钥解析功能,支持换行符、空格和逗号分隔
- 重构账户创建逻辑以支持单个和批量API密钥处理
- 在前端界面中添加批量API密钥输入检测和处理
- 添加批量创建结果返回和错误处理
- 更新验证流程以支持批量模式下的逐条验证
- 添加详细的批量API密钥测试用例

该更改允许用户一次提交多个API密钥,系统将为每个密钥创建独立的连接并参与负载均衡。
```

* ```
fix(tokenRouter): 调整路由匹配优先级以优先匹配显示名称别名

当精确路由和显式组显示名称冲突时,现在优先选择显式组显示名称
而不是精确路由模式。这修正了路由决策逻辑,使显式组路由能够正
确覆盖精确模型匹配。

相关的测试用例已更新以反映新的匹配行为,确保显式组路由在冲
突情况下获得更高优先级。
```

* ```
fix(accounts): 修复API密钥验证错误消息显示逻辑

当凭证模式不为apikey时显示会话令牌提示信息,否则显示API密钥验证失败信息

feat(accounts): 更新API密钥输入界面提示文本

将批量添加说明从输入框占位符移至独立提示文本,
使界面更清晰易懂
```

* ```
feat(workflow): 在CI/CD工作流中使用动态Docker Hub镜像名称

- 在ci.yml和release.yml工作流中添加DOCKERHUB_IMAGE环境变量
- 将硬编码的Docker Hub用户名替换为从secrets.DOCKERHUB_USERNAME动态获取
- 更新docker.metadata-action配置以使用环境变量中的镜像名称
- 添加测试用例验证Docker Hub镜像名称的正确配置
```

* fix(accounts): 修复批量创建回归并下沉创建流程
This commit is contained in:
puyujian
2026-04-07 14:28:25 +08:00
committed by GitHub
parent 5cab6c83f1
commit 569a15e14c
15 changed files with 655 additions and 282 deletions
+6 -2
View File
@@ -342,6 +342,8 @@ jobs:
if: github.event_name == 'push'
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
env:
DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi
strategy:
fail-fast: false
matrix:
@@ -384,7 +386,7 @@ jobs:
id: meta
uses: docker/metadata-action@v6
with:
images: 1467078763/metapi
images: ${{ env.DOCKERHUB_IMAGE }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch
@@ -424,6 +426,8 @@ jobs:
runs-on: ubuntu-latest
needs: publish-docker-arch
timeout-minutes: 15
env:
DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi
steps:
- name: Checkout
@@ -449,7 +453,7 @@ jobs:
id: meta
uses: docker/metadata-action@v6
with:
images: 1467078763/metapi
images: ${{ env.DOCKERHUB_IMAGE }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch
+40 -36
View File
@@ -283,14 +283,16 @@ jobs:
generate_release_notes: true
files: release-assets/*
publish-docker-arch:
name: Publish Docker Image (${{ matrix.arch }})
needs: build-packages
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
if: startsWith(github.ref, 'refs/tags/')
strategy:
fail-fast: false
publish-docker-arch:
name: Publish Docker Image (${{ matrix.arch }})
needs: build-packages
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
if: startsWith(github.ref, 'refs/tags/')
env:
DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
@@ -334,16 +336,16 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v6
with:
images: |
1467078763/metapi
ghcr.io/${{ github.repository }}
tags: |
type=ref,event=tag
type=raw,value=latest
- name: Docker metadata
id: meta
uses: docker/metadata-action@v6
with:
images: |
${{ env.DOCKERHUB_IMAGE }}
ghcr.io/${{ github.repository }}
tags: |
type=ref,event=tag
type=raw,value=latest
- name: Add architecture suffix to tags
id: arch-tags
@@ -373,14 +375,16 @@ jobs:
cache-from: type=gha,scope=release-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=release-${{ matrix.arch }}
publish-docker:
name: Publish Docker Manifests
needs: publish-docker-arch
runs-on: ubuntu-latest
timeout-minutes: 15
if: startsWith(github.ref, 'refs/tags/')
steps:
publish-docker:
name: Publish Docker Manifests
needs: publish-docker-arch
runs-on: ubuntu-latest
timeout-minutes: 15
if: startsWith(github.ref, 'refs/tags/')
env:
DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -407,16 +411,16 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v6
with:
images: |
1467078763/metapi
ghcr.io/${{ github.repository }}
tags: |
type=ref,event=tag
type=raw,value=latest
- name: Docker metadata
id: meta
uses: docker/metadata-action@v6
with:
images: |
${{ env.DOCKERHUB_IMAGE }}
ghcr.io/${{ github.repository }}
tags: |
type=ref,event=tag
type=raw,value=latest
- name: Create multi-arch manifests
shell: bash
+1
View File
@@ -25,3 +25,4 @@ docker/.env
# Local planning docs
docs/plan/
docs/plans/
.ace-tool/
+11
View File
@@ -16,6 +16,17 @@ describe('docker workflows', () => {
expect(releaseWorkflow).toContain('"${tag}-armv7"');
});
it('derives Docker Hub image names from the configured username secret', () => {
const ciWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/ci.yml'), 'utf8');
const releaseWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8');
expect(ciWorkflow).toContain('DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi');
expect(ciWorkflow).not.toContain('images: 1467078763/metapi');
expect(releaseWorkflow).toContain('DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi');
expect(releaseWorkflow).not.toContain('1467078763/metapi');
});
it('uses an armv7-capable node base image in the Dockerfile', () => {
const dockerfile = readFileSync(resolve(process.cwd(), 'docker/Dockerfile'), 'utf8');
@@ -5,7 +5,8 @@ const accountCredentialModeSchema = z.enum(['auto', 'session', 'apikey']);
const accountCreatePayloadSchema = z.object({
siteId: z.number().int().positive(),
username: z.string().optional(),
accessToken: z.string(),
accessToken: z.string().optional(),
accessTokens: z.array(z.string()).optional(),
apiToken: z.string().optional(),
platformUserId: z.number().int().positive().optional(),
checkinEnabled: z.boolean().optional(),
@@ -95,6 +96,9 @@ function formatAccountsPayloadError(error: z.ZodError): string {
if (firstPath === 'apiToken') {
return 'Invalid apiToken. Expected string or null.';
}
if (firstPath === 'accessTokens') {
return 'Invalid accessTokens. Expected string[].';
}
if (firstPath === 'checkinEnabled') {
return 'Invalid checkinEnabled. Expected boolean.';
}
@@ -268,4 +268,97 @@ describe('accounts api endpoint host selection', { timeout: 15_000 }, () => {
expect(getModelsMock).toHaveBeenNthCalledWith(1, 'https://api-create-a.example.com', 'sk-nihao-create-rotate', undefined);
expect(getModelsMock).toHaveBeenNthCalledWith(2, 'https://api-create-b.example.com', 'sk-nihao-create-rotate', undefined);
});
it('supports batch creating multiple API key connections for one site', async () => {
getModelsMock
.mockResolvedValueOnce(['gpt-4o-mini'])
.mockResolvedValueOnce(['gpt-4.1-mini']);
const site = await db.insert(schema.sites).values({
name: 'Nihao Batch Pool',
url: 'https://console.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
await db.insert(schema.siteApiEndpoints).values({
siteId: site.id,
url: 'https://api.example.com',
enabled: true,
sortOrder: 0,
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/accounts',
payload: {
siteId: site.id,
username: 'batch-key',
accessToken: 'sk-batch-a\nsk-batch-b',
credentialMode: 'apikey',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
batch: true,
totalCount: 2,
createdCount: 2,
failedCount: 0,
});
expect(getModelsMock).toHaveBeenNthCalledWith(1, 'https://api.example.com', 'sk-batch-a', undefined);
expect(getModelsMock).toHaveBeenNthCalledWith(2, 'https://api.example.com', 'sk-batch-b', undefined);
const accounts = await db.select().from(schema.accounts).all();
expect(accounts).toHaveLength(2);
expect(accounts.map((item) => item.apiToken)).toEqual(['sk-batch-a', 'sk-batch-b']);
expect(accounts.map((item) => item.username)).toEqual(['batch-key #1', 'batch-key #2']);
});
it('treats accessTokens payloads as batch API key creation even without credentialMode', async () => {
getModelsMock
.mockResolvedValueOnce(['gpt-4o-mini'])
.mockResolvedValueOnce(['gpt-4.1-mini']);
const site = await db.insert(schema.sites).values({
name: 'Nihao Batch Array',
url: 'https://console.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
await db.insert(schema.siteApiEndpoints).values({
siteId: site.id,
url: 'https://api.example.com',
enabled: true,
sortOrder: 0,
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/accounts',
payload: {
siteId: site.id,
username: 'array-key',
accessTokens: ['sk-array-a', 'sk-array-b'],
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
batch: true,
totalCount: 2,
createdCount: 2,
failedCount: 0,
});
expect(getModelsMock).toHaveBeenNthCalledWith(1, 'https://api.example.com', 'sk-array-a', undefined);
expect(getModelsMock).toHaveBeenNthCalledWith(2, 'https://api.example.com', 'sk-array-b', undefined);
const accounts = await db.select().from(schema.accounts).all();
expect(accounts).toHaveLength(2);
expect(accounts.map((item) => item.apiToken)).toEqual(['sk-array-a', 'sk-array-b']);
expect(accounts.map((item) => item.username)).toEqual(['array-key #1', 'array-key #2']);
});
});
+93 -219
View File
@@ -1,6 +1,6 @@
import { FastifyInstance } from 'fastify';
import { db, schema, runtimeDbDialect } from '../../db/index.js';
import { getInsertedRowId, insertAndGetById } from '../../db/insertHelpers.js';
import { insertAndGetById } from '../../db/insertHelpers.js';
import { and, eq, gte, lt, sql } from 'drizzle-orm';
import { refreshBalance } from '../../services/balanceService.js';
import { getAdapter } from '../../services/platforms/index.js';
@@ -34,6 +34,7 @@ import { appendSessionTokenRebindHint } from '../../services/alertRules.js';
import { parseSiteProxyUrlInput, withAccountProxyOverride, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { createRateLimitGuard } from '../../middleware/requestRateLimit.js';
import {
type AccountCreatePayload,
parseAccountBatchPayload,
parseAccountCreatePayload,
parseAccountHealthRefreshPayload,
@@ -44,6 +45,8 @@ import {
parseAccountVerifyTokenPayload,
} from '../../contracts/accountsRoutePayloads.js';
import { requireSiteApiBaseUrl, runWithSiteApiEndpointPool } from '../../services/siteApiEndpointService.js';
import { buildBatchApiKeyConnectionName, parseBatchApiKeys } from '../../services/apiKeyBatch.js';
import { createManualAccount } from '../../services/manualAccountCreationService.js';
type AccountWithSiteRow = {
accounts: typeof schema.accounts.$inferSelect;
@@ -65,17 +68,6 @@ type AccountCapabilities = {
proxyOnly: boolean;
};
type AccountInitializationParams = {
accountId: number;
site: typeof schema.sites.$inferSelect;
adapter: NonNullable<ReturnType<typeof getAdapter>>;
tokenType: 'session' | 'apikey' | 'unknown';
accessToken: string;
apiToken: string;
platformUserId?: number;
skipModelFetch?: boolean;
};
type VerifyFailureReason = 'needs-user-id' | 'invalid-user-id' | 'shield-blocked' | null;
const limitAccountLogin = createRateLimitGuard({
@@ -150,65 +142,18 @@ function normalizePinnedFlag(input: unknown): boolean | null {
return null;
}
async function initializeAccountInBackground({
accountId,
site,
adapter,
tokenType,
accessToken,
apiToken,
platformUserId,
skipModelFetch,
}: AccountInitializationParams) {
const summary = {
accountId,
syncedTokenCount: 0,
refreshedBalance: false,
refreshedModels: false,
rebuiltRoutes: false,
};
let fetchedUpstreamTokens: Array<{ name?: string | null; key?: string | null; enabled?: boolean | null; tokenGroup?: string | null }> = [];
if (tokenType === 'session' && accessToken) {
try {
const syncedTokens = await adapter.getApiTokens(site.url, accessToken, platformUserId);
summary.syncedTokenCount = Array.isArray(syncedTokens) ? syncedTokens.length : 0;
fetchedUpstreamTokens = Array.isArray(syncedTokens) ? syncedTokens : [];
} catch {}
function resolveRequestedCreateTokens(
body: AccountCreatePayload,
credentialMode: AccountCredentialMode,
): string[] {
if (credentialMode !== 'apikey') {
const single = String(body.accessToken || '').trim();
return single ? [single] : [];
}
const convergence = await convergeAccountMutation({
accountId,
preferredApiToken: tokenType === 'session' ? apiToken : null,
defaultTokenSource: 'manual',
ensurePreferredTokenBeforeSync: tokenType === 'session',
upstreamTokens: fetchedUpstreamTokens,
refreshBalance: tokenType === 'session',
refreshModels: skipModelFetch !== true,
rebuildRoutes: skipModelFetch !== true,
continueOnError: true,
});
summary.refreshedBalance = convergence.refreshedBalance;
summary.refreshedModels = convergence.refreshedModels;
summary.rebuiltRoutes = convergence.rebuiltRoutes;
return summary;
}
function buildQueuedAccountInitializationMessage(
tokenType: 'session' | 'apikey' | 'unknown',
skipModelFetch?: boolean,
) {
if (tokenType === 'session' && skipModelFetch === true) {
return '账号已添加,后台正在同步令牌和余额信息。';
}
if (tokenType === 'session') {
return '账号已添加,后台正在同步令牌、余额和模型信息。';
}
if (skipModelFetch === true) {
return '已添加为 API Key 账号(可用于代理转发)。';
}
return '已添加为 API Key 账号,后台正在同步模型和路由信息。';
const batchTokens = parseBatchApiKeys(body.accessTokens);
if (batchTokens.length > 0) return batchTokens;
return parseBatchApiKeys(body.accessToken);
}
function normalizeSortOrder(input: unknown): number | null {
@@ -1147,174 +1092,103 @@ export async function accountsRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: `platform not supported: ${site.platform}` });
}
const credentialMode = resolveRequestedCredentialMode(body.credentialMode);
const rawAccessToken = (body.accessToken || '').trim();
if (!rawAccessToken) {
const explicitBatchTokens = parseBatchApiKeys(body.accessTokens);
const credentialMode = explicitBatchTokens.length > 0
? 'apikey'
: resolveRequestedCredentialMode(body.credentialMode);
const requestedTokens = explicitBatchTokens.length > 0
? explicitBatchTokens
: resolveRequestedCreateTokens(body, credentialMode);
if (requestedTokens.length === 0) {
return reply.code(400).send({ success: false, message: '请填写 Token' });
}
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' && requestedTokens.length > 1) {
const items: Array<Record<string, unknown>> = [];
let createdCount = 0;
if (credentialMode === 'apikey') {
if (body.skipModelFetch === true) {
tokenType = 'apikey';
accessToken = '';
if (!apiToken) apiToken = rawAccessToken;
} else {
for (const [index, token] of requestedTokens.entries()) {
try {
const models = await getModelsWithSiteApiEndpointPool(
const created = await createManualAccount({
body,
site,
adapter,
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 验证失败',
credentialMode,
rawAccessToken: token,
usernameOverride: buildBatchApiKeyConnectionName(body.username, index, requestedTokens.length) || undefined,
});
createdCount += 1;
items.push({
index,
status: 'created',
id: created.account.id,
username: created.account.username || null,
queued: created.queued === true,
message: created.message || null,
modelCount: created.modelCount || 0,
});
} catch (error: any) {
items.push({
index,
status: 'failed',
message: error?.message || '创建失败',
requiresVerification: error?.requiresVerification === true,
});
}
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) {
if (createdCount === 0) {
return reply.code(400).send({
success: false,
message: appendSessionTokenRebindHint(err?.message || 'Token 验证失败'),
batch: true,
totalCount: requestedTokens.length,
createdCount: 0,
failedCount: requestedTokens.length,
message: `批量添加失败(0/${requestedTokens.length}`,
items,
});
}
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)
: [];
}
return {
success: true,
batch: true,
totalCount: requestedTokens.length,
createdCount,
failedCount: requestedTokens.length - createdCount,
message: `批量添加完成:成功 ${createdCount},失败 ${requestedTokens.length - createdCount}`,
items,
};
}
// 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;
try {
const created = await createManualAccount({
body,
site,
adapter,
credentialMode,
rawAccessToken: requestedTokens[0]!,
});
return {
...created.account,
tokenType: created.tokenType,
credentialMode: resolveStoredCredentialMode(created.account),
capabilities: buildCapabilitiesForAccount(created.account),
modelCount: created.modelCount,
apiTokenFound: created.apiTokenFound,
usernameDetected: created.usernameDetected,
queued: created.queued,
jobId: created.jobId,
message: created.message,
};
} catch (err: any) {
return reply.code(400).send({
success: false,
requiresVerification: err?.requiresVerification === true,
message: credentialMode !== 'apikey'
? appendSessionTokenRebindHint(err?.message || 'Token 验证失败')
: (err?.message || 'API Key 验证失败'),
});
}
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 = await insertAndGetById<typeof schema.accounts.$inferSelect>({
table: schema.accounts,
idColumn: schema.accounts.id,
values: {
siteId: body.siteId,
username: username || undefined,
accessToken,
apiToken: apiToken || undefined,
checkinEnabled: tokenType === 'session' ? (body.checkinEnabled ?? true) : false,
extraConfig,
isPinned: false,
sortOrder: await getNextAccountSortOrder(),
},
insertErrorMessage: '创建账号失败',
loadErrorMessage: '创建账号失败',
});
const shouldQueueInitialization = tokenType === 'session' || body.skipModelFetch !== true;
let queuedTaskId: string | undefined;
let queuedMessage: string | undefined;
if (shouldQueueInitialization) {
const taskTitle = `初始化连接 #${result.id}`;
const { task } = startBackgroundTask(
{
type: 'account-init',
title: taskTitle,
dedupeKey: `account-init-${result.id}`,
notifyOnFailure: true,
successMessage: () => `${taskTitle}已完成`,
failureMessage: (currentTask) => `${taskTitle}失败:${currentTask.error || 'unknown error'}`,
},
async () => initializeAccountInBackground({
accountId: result.id,
site,
adapter,
tokenType,
accessToken,
apiToken,
platformUserId: resolvedPlatformUserId,
skipModelFetch: body.skipModelFetch,
}),
);
queuedTaskId = task.id;
queuedMessage = buildQueuedAccountInitializationMessage(tokenType, body.skipModelFetch);
}
const account = await 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', null);
return {
...account,
tokenType,
credentialMode: finalCredentialMode,
capabilities,
modelCount: verifiedModels.length,
apiTokenFound: !!apiToken,
usernameDetected: !!(!body.username && username),
queued: !!queuedTaskId,
jobId: queuedTaskId,
message: queuedMessage,
};
});
// Update an account
@@ -658,7 +658,7 @@ describe('PUT /api/routes/:id route rebuild', () => {
expect(updated?.tokenId).toBe(seeded.token.id);
});
it('prefers an exact route over a colliding explicit-group display name', async () => {
it('prefers an explicit-group display name over a colliding exact route', async () => {
const exactCandidate = await seedAccountWithToken('claude-opus-4-6');
const groupedCandidate = await seedAccountWithToken('claude-opus-4-5');
@@ -716,9 +716,8 @@ describe('PUT /api/routes/:id route rebuild', () => {
success: true,
decision: {
matched: true,
routeId: exactRoute.id,
modelPattern: 'claude-opus-4-6',
actualModel: 'claude-opus-4-6',
routeId: groupResponse.json().id,
actualModel: 'claude-opus-4-5',
},
});
});
+4
View File
@@ -0,0 +1,4 @@
export {
buildBatchApiKeyConnectionName,
parseBatchApiKeys,
} from './shared/apiKeyBatchCore.js';
@@ -0,0 +1,305 @@
import { eq } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { insertAndGetById } from '../db/insertHelpers.js';
import { startBackgroundTask } from './backgroundTaskService.js';
import { getAdapter } from './platforms/index.js';
import {
guessPlatformUserIdFromUsername,
mergeAccountExtraConfig,
type AccountCredentialMode,
} from './accountExtraConfig.js';
import { runWithSiteApiEndpointPool } from './siteApiEndpointService.js';
import { type AccountCreatePayload } from '../contracts/accountsRoutePayloads.js';
import { convergeAccountMutation } from './accountMutationWorkflow.js';
const ACCOUNT_VERIFY_TIMEOUT_MS = 10_000;
type AccountInitializationParams = {
accountId: number;
site: typeof schema.sites.$inferSelect;
adapter: NonNullable<ReturnType<typeof getAdapter>>;
tokenType: 'session' | 'apikey' | 'unknown';
accessToken: string;
apiToken: string;
platformUserId?: number;
skipModelFetch?: boolean;
};
export type CreateManualAccountParams = {
body: AccountCreatePayload;
site: typeof schema.sites.$inferSelect;
adapter: NonNullable<ReturnType<typeof getAdapter>>;
credentialMode: AccountCredentialMode;
rawAccessToken: string;
usernameOverride?: string;
};
export type CreateManualAccountResult = {
account: typeof schema.accounts.$inferSelect;
tokenType: 'session' | 'apikey' | 'unknown';
modelCount: number;
apiTokenFound: boolean;
usernameDetected: boolean;
queued: boolean;
jobId?: string;
message?: string;
};
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
fn(),
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
function buildAccountVerifyTimeoutMessage(): string {
return `Token verification timed out (${Math.max(1, Math.round(ACCOUNT_VERIFY_TIMEOUT_MS / 1000))}s)`;
}
async function getNextAccountSortOrder(): Promise<number> {
const rows = await db.select({ sortOrder: schema.accounts.sortOrder }).from(schema.accounts).all();
const max = rows.reduce((currentMax, row) => Math.max(currentMax, row.sortOrder || 0), -1);
return max + 1;
}
async function getModelsWithSiteApiEndpointPool(
site: typeof schema.sites.$inferSelect,
adapter: NonNullable<ReturnType<typeof getAdapter>>,
accessToken: string,
platformUserId?: number,
): Promise<string[]> {
const timeoutMessage = buildAccountVerifyTimeoutMessage();
const deadline = Date.now() + ACCOUNT_VERIFY_TIMEOUT_MS;
return runWithSiteApiEndpointPool(site, (target) => {
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
throw new Error(timeoutMessage);
}
return withTimeout(
() => adapter.getModels(target.baseUrl, accessToken, platformUserId),
remainingMs,
timeoutMessage,
);
});
}
async function initializeAccountInBackground({
accountId,
site,
adapter,
tokenType,
accessToken,
apiToken,
platformUserId,
skipModelFetch,
}: AccountInitializationParams) {
const summary = {
accountId,
syncedTokenCount: 0,
refreshedBalance: false,
refreshedModels: false,
rebuiltRoutes: false,
};
let fetchedUpstreamTokens: Array<{ name?: string | null; key?: string | null; enabled?: boolean | null; tokenGroup?: string | null }> = [];
if (tokenType === 'session' && accessToken) {
try {
const syncedTokens = await adapter.getApiTokens(site.url, accessToken, platformUserId);
summary.syncedTokenCount = Array.isArray(syncedTokens) ? syncedTokens.length : 0;
fetchedUpstreamTokens = Array.isArray(syncedTokens) ? syncedTokens : [];
} catch {}
}
const convergence = await convergeAccountMutation({
accountId,
preferredApiToken: tokenType === 'session' ? apiToken : null,
defaultTokenSource: 'manual',
ensurePreferredTokenBeforeSync: tokenType === 'session',
upstreamTokens: fetchedUpstreamTokens,
refreshBalance: tokenType === 'session',
refreshModels: skipModelFetch !== true,
rebuildRoutes: skipModelFetch !== true,
continueOnError: true,
});
summary.refreshedBalance = convergence.refreshedBalance;
summary.refreshedModels = convergence.refreshedModels;
summary.rebuiltRoutes = convergence.rebuiltRoutes;
return summary;
}
function buildQueuedAccountInitializationMessage(
tokenType: 'session' | 'apikey' | 'unknown',
skipModelFetch?: boolean,
) {
if (tokenType === 'session' && skipModelFetch === true) {
return '账号已添加,后台正在同步令牌和余额信息。';
}
if (tokenType === 'session') {
return '账号已添加,后台正在同步令牌、余额和模型信息。';
}
if (skipModelFetch === true) {
return '已添加为 API Key 账号(可用于代理转发)。';
}
return '已添加为 API Key 账号,后台正在同步模型和路由信息。';
}
export async function createManualAccount({
body,
site,
adapter,
credentialMode,
rawAccessToken,
usernameOverride,
}: CreateManualAccountParams): Promise<CreateManualAccountResult> {
let username = typeof usernameOverride === 'string'
? usernameOverride.trim()
: (body.username || '').trim();
let accessToken = rawAccessToken;
let apiToken = (body.apiToken || '').trim();
let tokenType: 'session' | 'apikey' | 'unknown' = 'unknown';
let verifiedModels: string[] = [];
if (credentialMode === 'apikey') {
if (body.skipModelFetch === true) {
tokenType = 'apikey';
accessToken = '';
if (!apiToken) apiToken = rawAccessToken;
} else {
const models = await getModelsWithSiteApiEndpointPool(
site,
adapter,
rawAccessToken,
body.platformUserId,
);
verifiedModels = Array.isArray(models)
? models.filter((item) => typeof item === 'string' && item.trim().length > 0)
: [];
if (verifiedModels.length === 0) {
const error = new Error('API Key 验证失败:未获取到可用模型');
(error as Error & { requiresVerification?: boolean }).requiresVerification = true;
throw error;
}
tokenType = 'apikey';
accessToken = '';
if (!apiToken) apiToken = rawAccessToken;
}
} else {
const verifyResult = await withTimeout(
() => adapter.verifyToken(site.url, rawAccessToken, body.platformUserId),
ACCOUNT_VERIFY_TIMEOUT_MS,
buildAccountVerifyTimeoutMessage(),
);
tokenType = verifyResult.tokenType;
if (tokenType === 'unknown') {
const error = new Error('Token 验证失败,请先点击“验证 Token”,验证成功后再绑定账号');
(error as Error & { requiresVerification?: boolean }).requiresVerification = true;
throw error;
}
if (credentialMode === 'session' && tokenType !== 'session') {
throw new Error('当前凭证是 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)
: [];
}
}
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 = typeof body.refreshToken === 'string' ? body.refreshToken.trim() : '';
const managedTokenExpiresAt = typeof body.tokenExpiresAt === 'number'
? Math.trunc(body.tokenExpiresAt)
: (typeof body.tokenExpiresAt === 'string' ? Number.parseInt(body.tokenExpiresAt.trim(), 10) : undefined);
if (managedRefreshToken) {
extraConfigPatch.sub2apiAuth = managedTokenExpiresAt && Number.isFinite(managedTokenExpiresAt) && managedTokenExpiresAt > 0
? { refreshToken: managedRefreshToken, tokenExpiresAt: managedTokenExpiresAt }
: { refreshToken: managedRefreshToken };
}
}
const extraConfig = mergeAccountExtraConfig(undefined, extraConfigPatch);
const result = await insertAndGetById<typeof schema.accounts.$inferSelect>({
table: schema.accounts,
idColumn: schema.accounts.id,
values: {
siteId: body.siteId,
username: username || undefined,
accessToken,
apiToken: apiToken || undefined,
checkinEnabled: tokenType === 'session' ? (body.checkinEnabled ?? true) : false,
extraConfig,
isPinned: false,
sortOrder: await getNextAccountSortOrder(),
},
insertErrorMessage: '创建账号失败',
loadErrorMessage: '创建账号失败',
});
const shouldQueueInitialization = tokenType === 'session' || body.skipModelFetch !== true;
let queuedTaskId: string | undefined;
let queuedMessage: string | undefined;
if (shouldQueueInitialization) {
const taskTitle = `初始化连接 #${result.id}`;
const { task } = startBackgroundTask(
{
type: 'account-init',
title: taskTitle,
dedupeKey: `account-init-${result.id}`,
notifyOnFailure: true,
successMessage: () => `${taskTitle}已完成`,
failureMessage: (currentTask) => `${taskTitle}失败:${currentTask.error || 'unknown error'}`,
},
async () => initializeAccountInBackground({
accountId: result.id,
site,
adapter,
tokenType,
accessToken,
apiToken,
platformUserId: resolvedPlatformUserId,
skipModelFetch: body.skipModelFetch,
}),
);
queuedTaskId = task.id;
queuedMessage = buildQueuedAccountInitializationMessage(tokenType, body.skipModelFetch);
}
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get();
if (!account) {
throw new Error('创建账号失败');
}
return {
account,
tokenType,
modelCount: verifiedModels.length,
apiTokenFound: !!apiToken,
usernameDetected: !!(!body.username && username),
queued: !!queuedTaskId,
jobId: queuedTaskId,
message: queuedMessage,
};
}
@@ -0,0 +1,26 @@
export function parseBatchApiKeys(input: unknown): string[] {
if (Array.isArray(input)) {
return Array.from(new Set(
input
.map((item) => String(item || '').trim())
.filter((item) => item.length > 0),
));
}
const raw = String(input || '').trim();
if (!raw) return [];
return Array.from(new Set(
raw
.split(/[\r\n,;\s]+/)
.map((item) => item.trim())
.filter((item) => item.length > 0),
));
}
export function buildBatchApiKeyConnectionName(baseName: string | null | undefined, index: number, total: number): string {
const normalized = String(baseName || '').trim();
if (!normalized) return '';
if (total <= 1) return normalized;
return `${normalized} #${index + 1}`;
}
@@ -189,12 +189,11 @@ describe('TokenRouter patterns and model mapping', () => {
expect(exposedModels).toContain('claude-opus-4-6');
});
it('prefers an exact route over a colliding group display-name alias', async () => {
await createRouteWithSingleChannel(
're:^claude-(opus|sonnet)-4-5$',
it('prefers a group display-name alias over a colliding exact route', async () => {
const source = await createRouteWithSingleChannel(
'claude-opus-4-5',
undefined,
{
displayName: 'claude-opus-4-6',
sourceModel: 'claude-opus-4-5',
},
);
@@ -205,15 +204,18 @@ describe('TokenRouter patterns and model mapping', () => {
sourceModel: 'claude-opus-4-6',
},
);
const grouped = await createExplicitGroupRoute('claude-opus-4-6', [source.route.id]);
const router = new TokenRouter();
const selected = await router.selectChannel('claude-opus-4-6');
const decision = await router.explainSelection('claude-opus-4-6');
expect(selected).toBeTruthy();
expect(selected?.channel.id).toBe(exact.channel.id);
expect(selected?.actualModel).toBe('claude-opus-4-6');
expect(decision.actualModel).toBe('claude-opus-4-6');
expect(selected?.channel.routeId).toBe(source.route.id);
expect(selected?.channel.id).not.toBe(exact.channel.id);
expect(selected?.actualModel).toBe('claude-opus-4-5');
expect(decision.routeId).toBe(grouped.id);
expect(decision.actualModel).toBe('claude-opus-4-5');
});
it('falls back to the source exact-route model when explicit-group channels omit sourceModel', async () => {
+6 -6
View File
@@ -3041,12 +3041,12 @@ export class TokenRouter {
routes = routes.filter((route) => allowSet.has(route.id));
}
const matchedRoute = routes.find((route) => (
!isExplicitGroupRoute(route)
&& isExactRouteModelPattern(route.modelPattern)
&& (route.modelPattern || '').trim() === model
))
|| routes.find((route) => isExplicitGroupRoute(route) && isRouteDisplayNameMatch(model, route.displayName))
const matchedRoute = routes.find((route) => isExplicitGroupRoute(route) && isRouteDisplayNameMatch(model, route.displayName))
|| routes.find((route) => (
!isExplicitGroupRoute(route)
&& isExactRouteModelPattern(route.modelPattern)
&& (route.modelPattern || '').trim() === model
))
|| routes.find((route) => !isExplicitGroupRoute(route) && isRouteDisplayNameMatch(model, route.displayName))
|| routes.find((route) => !isExplicitGroupRoute(route) && matchesModelPattern(model, route.modelPattern));
+4
View File
@@ -0,0 +1,4 @@
export {
buildBatchApiKeyConnectionName,
parseBatchApiKeys,
} from '../server/services/shared/apiKeyBatchCore.js';
+49 -7
View File
@@ -29,6 +29,7 @@ import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from '.
import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
import { SITE_DOCS_URL } from '../docsLink.js';
import { getSiteInitializationPreset } from '../../shared/siteInitializationPresets.js';
import { parseBatchApiKeys } from '../../shared/apiKeyBatch.js';
type ConnectionsSegment = 'session' | 'apikey' | 'tokens';
@@ -177,6 +178,11 @@ export default function Accounts() {
() => sites.find((item) => item.id === tokenForm.siteId) || null,
[sites, tokenForm.siteId],
);
const parsedApiKeys = useMemo(
() => activeSegment === 'apikey' ? parseBatchApiKeys(tokenForm.accessToken) : [],
[activeSegment, tokenForm.accessToken],
);
const isBatchApiKeyInput = activeSegment === 'apikey' && parsedApiKeys.length > 1;
const siteSelectOptions = useMemo(
() => [
{ value: '0', label: '选择站点' },
@@ -322,6 +328,10 @@ export default function Accounts() {
const handleVerifyToken = async () => {
if (!tokenForm.siteId || !tokenForm.accessToken) return;
if (isBatchApiKeyInput) {
toast.info(`检测到 ${parsedApiKeys.length} 个 API Key,批量模式会在添加时逐条校验`);
return;
}
const credentialMode = activeSegment === 'apikey' ? 'apikey' : 'session';
setVerifying(true);
setVerifyResult(null);
@@ -352,7 +362,7 @@ export default function Accounts() {
const handleTokenAdd = async () => {
if (!tokenForm.siteId || !tokenForm.accessToken) return;
if (!verifyResult?.success && !tokenForm.skipModelFetch) {
if (!isBatchApiKeyInput && !verifyResult?.success && !tokenForm.skipModelFetch) {
toast.error('请先验证 Token 成功后再添加账号');
return;
}
@@ -364,6 +374,7 @@ export default function Accounts() {
siteId: tokenForm.siteId,
username: tokenForm.username.trim() || undefined,
accessToken: tokenForm.accessToken,
accessTokens: isBatchApiKeyInput ? parsedApiKeys : undefined,
platformUserId: tokenForm.platformUserId ? parseInt(tokenForm.platformUserId) : undefined,
refreshToken: isSub2ApiSelected && tokenForm.refreshToken.trim()
? tokenForm.refreshToken.trim()
@@ -374,6 +385,23 @@ export default function Accounts() {
credentialMode,
skipModelFetch: tokenForm.skipModelFetch,
});
if (result?.batch) {
closeAddPanel();
const createdCount = Number(result.createdCount) || 0;
const failedCount = Number(result.failedCount) || 0;
if (createdCount > 0) {
toast.success(`批量添加完成:成功 ${createdCount},失败 ${failedCount}`);
}
const failedItems = Array.isArray(result.items)
? result.items.filter((item: any) => item?.status === 'failed')
: [];
if (failedItems.length > 0) {
const firstMessage = failedItems[0]?.message || '创建失败';
toast.error(`失败 ${failedItems.length} 条:${firstMessage}`);
}
load();
return;
}
let seededRecommendedModels = false;
const recommendedModels = initializationPreset?.recommendedModels || [];
const createdAccountId = Number(result?.id) || 0;
@@ -951,6 +979,9 @@ export default function Accounts() {
|| (activeSegment === 'session' && verifyResult.tokenType === 'session')
),
);
const canSubmitApiKeyConnection = activeSegment === 'apikey'
? (isBatchApiKeyInput || canAddVerifiedConnection || !!tokenForm.skipModelFetch)
: canAddVerifiedConnection;
return (
<div className="animate-fade-in">
@@ -1421,9 +1452,18 @@ export default function Accounts() {
<textarea
placeholder="粘贴 API Key"
value={tokenForm.accessToken}
onChange={(e) => { setTokenForm((f) => ({ ...f, accessToken: e.target.value.trim(), credentialMode: 'apikey' })); setVerifyResult(null); }}
onChange={(e) => { setTokenForm((f) => ({ ...f, accessToken: e.target.value, credentialMode: 'apikey' })); setVerifyResult(null); }}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', height: 72, resize: 'none' as const }}
/>
{parsedApiKeys.length > 0 && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{parsedApiKeys.length} API Key
{isBatchApiKeyInput ? ',添加时会逐条创建同站点连接并参与轮询' : ''}
</div>
)}
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
API Key
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<input
placeholder="用户 ID(可选)"
@@ -1479,23 +1519,25 @@ export default function Accounts() {
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={handleVerifyToken}
disabled={verifying || !tokenForm.siteId || !tokenForm.accessToken}
disabled={verifying || !tokenForm.siteId || !tokenForm.accessToken || isBatchApiKeyInput}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{verifying ? <><span className="spinner spinner-sm" />...</> : '验证 API Key'}
{verifying ? <><span className="spinner spinner-sm" />...</> : (isBatchApiKeyInput ? '批量添加时校验' : '验证 API Key')}
</button>
<button
onClick={handleTokenAdd}
disabled={saving || !tokenForm.siteId || !tokenForm.accessToken || (!canAddVerifiedConnection && !tokenForm.skipModelFetch)}
disabled={saving || !tokenForm.siteId || !tokenForm.accessToken || !canSubmitApiKeyConnection}
className="btn btn-success"
>
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />...</> : '添加连接'}
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />...</> : (isBatchApiKeyInput ? '批量添加连接' : '添加连接')}
</button>
</div>
{!verifyResult?.success && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{addAccountPrereqHint}
{isBatchApiKeyInput
? '批量模式下无需先点验证,提交后会逐条校验并创建。'
: addAccountPrereqHint}
</div>
)}
</div>