fix(routes): dedupe direct credentials and clarify binding labels

This commit is contained in:
Brucents
2026-03-20 20:41:32 +08:00
parent 088d0455a9
commit 5ee493778f
10 changed files with 331 additions and 30 deletions
+2
View File
@@ -182,6 +182,8 @@ function ensureTokenManagementSchema() {
WHERE
a.api_token IS NOT NULL
AND trim(a.api_token) <> ''
AND a.access_token IS NOT NULL
AND trim(a.access_token) <> ''
AND NOT EXISTS (
SELECT 1 FROM account_tokens AS t
WHERE t.account_id = a.id
+2
View File
@@ -22,6 +22,7 @@ import { oauthRoutes } from './routes/api/oauth.js';
import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js';
import { proxyRoutes } from './routes/proxy/router.js';
import { startScheduler } from './services/checkinScheduler.js';
import { rebuildTokenRoutesFromAvailability } from './services/modelService.js';
import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
import { buildStartupSummaryLines } from './services/startupInfo.js';
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
@@ -329,6 +330,7 @@ try {
await repairStoredCreatedAtValues();
await migrateSiteApiKeysToAccounts();
await ensureDefaultSitesSeeded();
await rebuildTokenRoutesFromAvailability();
console.log('Loaded runtime settings overrides');
} catch (error) {
@@ -248,6 +248,55 @@ describe('refreshModelsForAccount credential discovery', () => {
expect(parsed.runtimeHealth?.checkedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
});
it('does not scan hidden managed tokens for direct apikey connections', async () => {
getApiTokenMock.mockResolvedValue(null);
getModelsMock.mockImplementation(async (_baseUrl: string, token: string) => (
token === 'sk-direct-credential' ? ['gpt-4.1'] : ['legacy-should-not-be-used']
));
const site = await db.insert(schema.sites).values({
name: 'apikey-direct-site',
url: 'https://apikey-direct.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'apikey-direct-user',
accessToken: '',
apiToken: 'sk-direct-credential',
status: 'active',
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
}).returning().get();
const hiddenToken = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'legacy-hidden',
token: 'sk-legacy-hidden',
source: 'legacy',
enabled: true,
isDefault: true,
}).returning().get();
const result = await refreshModelsForAccount(account.id);
expect(result).toMatchObject({
accountId: account.id,
refreshed: true,
status: 'success',
modelCount: 1,
modelsPreview: ['gpt-4.1'],
tokenScanned: 0,
discoveredByCredential: true,
});
const tokenRows = await db.select().from(schema.tokenModelAvailability)
.where(eq(schema.tokenModelAvailability.tokenId, hiddenToken.id))
.all();
expect(tokenRows).toHaveLength(0);
});
it('returns structured result when account missing', async () => {
const result = await refreshModelsForAccount(9999);
+61
View File
@@ -85,6 +85,67 @@ describe('rebuildTokenRoutesFromAvailability', () => {
expect(channels[0]?.manualOverride).toBe(false);
});
it('ignores hidden account_tokens for direct apikey connections when rebuilding routes', async () => {
const site = await db.insert(schema.sites).values({
name: 'apikey-legacy-site',
url: 'https://apikey-legacy.example.com',
platform: 'new-api',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'apikey-legacy-user',
accessToken: '',
apiToken: 'sk-direct-credential',
status: 'active',
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
}).returning().get();
const hiddenToken = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'legacy-hidden',
token: 'sk-hidden-legacy-token',
source: 'legacy',
enabled: true,
isDefault: true,
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-4.1',
available: true,
latencyMs: 200,
checkedAt: '2026-03-20T08:00:00.000Z',
}).run();
await db.insert(schema.tokenModelAvailability).values({
tokenId: hiddenToken.id,
modelName: 'gpt-4.1',
available: true,
latencyMs: 180,
checkedAt: '2026-03-20T08:00:00.000Z',
}).run();
const rebuild = await rebuildTokenRoutesFromAvailability();
expect(rebuild.models).toBe(1);
const route = await db.select().from(schema.tokenRoutes)
.where(eq(schema.tokenRoutes.modelPattern, 'gpt-4.1'))
.get();
expect(route).toBeDefined();
const channels = await db.select().from(schema.routeChannels)
.where(and(
eq(schema.routeChannels.routeId, route!.id),
eq(schema.routeChannels.accountId, account.id),
))
.all();
expect(channels).toHaveLength(1);
expect(channels[0]?.tokenId ?? null).toBeNull();
});
it('creates an exact route with an account-direct channel for oauth model availability', async () => {
const site = await db.insert(schema.sites).values({
name: 'codex-site',
+11 -11
View File
@@ -10,8 +10,8 @@ import {
isUsableAccountToken,
} from './accountTokenService.js';
import {
getCredentialModeFromExtraConfig,
mergeAccountExtraConfig,
requiresManagedAccountTokens,
resolvePlatformUserId,
supportsDirectAccountRoutingConnection,
} from './accountExtraConfig.js';
@@ -141,12 +141,6 @@ function isSiteDisabled(status?: string | null): boolean {
return (status || 'active') === 'disabled';
}
function isApiKeyConnection(account: typeof schema.accounts.$inferSelect): boolean {
const explicit = getCredentialModeFromExtraConfig(account.extraConfig);
if (explicit && explicit !== 'auto') return explicit === 'apikey';
return !(account.accessToken || '').trim();
}
function normalizeModels(models: string[]): string[] {
const normalizedModels: string[] = [];
const seen = new Set<string>();
@@ -821,18 +815,21 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
} catch { }
}
let enabledTokens = await db.select()
const usesManagedTokens = requiresManagedAccountTokens(account);
let enabledTokens = usesManagedTokens
? await db.select()
.from(schema.accountTokens)
.where(and(
eq(schema.accountTokens.accountId, account.id),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
))
.all();
.all()
: [];
enabledTokens = enabledTokens.filter(isUsableAccountToken);
// Last fallback: if still no managed token but account has a legacy apiToken, mirror it into token table.
if (!isApiKeyConnection(account) && enabledTokens.length === 0) {
if (usesManagedTokens && enabledTokens.length === 0) {
const fallback = discoveredApiToken || account.apiToken || null;
if (fallback) {
ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' });
@@ -1018,7 +1015,10 @@ export async function rebuildTokenRoutesFromAvailability() {
),
)
.all();
const usableTokenRows = tokenRows.filter((row) => isUsableAccountToken(row.account_tokens));
const usableTokenRows = tokenRows.filter((row) => (
isUsableAccountToken(row.account_tokens)
&& requiresManagedAccountTokens(row.accounts)
));
const accountRows = await db.select().from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
+5
View File
@@ -1164,12 +1164,17 @@ export default function Accounts() {
</div>
{isSub2ApiSelected && (
<>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<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={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
可在浏览器控制台执行 <code style={{ fontFamily: 'var(--font-mono)' }}>localStorage.getItem('refresh_token')</code> 获取。
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<input
placeholder="token_expires_at(可选,毫秒时间戳)"
@@ -7,6 +7,7 @@ import {
buildFixedTokenOptionDescription,
buildFixedTokenOptionLabel,
describeTokenBinding,
resolveTokenBindingConnectionMode,
} from './tokenBindingPresentation.js';
import { getChannelDecisionState, getPriorityTagStyle, getProbabilityColor } from './utils.js';
@@ -54,7 +55,15 @@ export function SortableChannelRow({
};
const decisionState = getChannelDecisionState(decisionCandidate, channel, isExactRoute, loadingDecision);
const tokenBinding = describeTokenBinding(tokenOptions, activeTokenId, channel.token?.name ?? null);
const tokenBinding = describeTokenBinding(
tokenOptions,
activeTokenId,
channel.token?.name ?? null,
{
connectionMode: resolveTokenBindingConnectionMode(channel.account),
accountName: channel.account?.username || `account-${channel.accountId}`,
},
);
return (
<div ref={setNodeRef} style={rowStyle}>
@@ -113,10 +122,10 @@ export function SortableChannelRow({
className="badge"
style={{
fontSize: 10,
background: tokenBinding.isFollowingAccountDefault
background: tokenBinding.badgeTone === 'info'
? 'color-mix(in srgb, var(--color-info) 15%, transparent)'
: 'color-mix(in srgb, var(--color-warning) 15%, transparent)',
color: tokenBinding.isFollowingAccountDefault ? 'var(--color-info)' : 'var(--color-warning)',
color: tokenBinding.badgeTone === 'info' ? 'var(--color-info)' : 'var(--color-warning)',
}}
>
{tokenBinding.bindingModeLabel}
@@ -211,7 +220,7 @@ export function SortableChannelRow({
options={[
{
value: '0',
label: '跟随账号默认',
label: tokenBinding.followOptionLabel,
description: tokenBinding.followOptionDescription,
},
...tokenOptions.map((token) => ({
@@ -3,6 +3,7 @@ import {
buildFixedTokenOptionDescription,
buildFixedTokenOptionLabel,
describeTokenBinding,
resolveTokenBindingConnectionMode,
} from './tokenBindingPresentation.js';
describe('tokenBindingPresentation', () => {
@@ -13,8 +14,10 @@ describe('tokenBindingPresentation', () => {
], 0);
expect(result.bindingModeLabel).toBe('跟随账号默认');
expect(result.badgeTone).toBe('info');
expect(result.effectiveTokenName).toBe('token-a');
expect(result.helperText).toContain('以后账号默认变化时会自动切换');
expect(result.followOptionLabel).toBe('跟随账号默认');
expect(result.followOptionDescription).toContain('当前生效:token-a');
});
@@ -25,6 +28,7 @@ describe('tokenBindingPresentation', () => {
], 1);
expect(result.bindingModeLabel).toBe('固定令牌');
expect(result.badgeTone).toBe('warning');
expect(result.effectiveTokenName).toBe('default');
expect(result.helperText).toContain('它目前也是账号默认');
expect(result.helperText).toContain('不会跟着变');
@@ -37,8 +41,10 @@ describe('tokenBindingPresentation', () => {
], 2);
expect(result.bindingModeLabel).toBe('固定令牌');
expect(result.badgeTone).toBe('warning');
expect(result.effectiveTokenName).toBe('backup');
expect(result.helperText).toContain('不会随账号默认变化');
expect(result.followOptionLabel).toBe('跟随账号默认');
});
it('describes follow-account-default mode when no default token exists yet', () => {
@@ -48,11 +54,76 @@ describe('tokenBindingPresentation', () => {
], 0);
expect(result.bindingModeLabel).toBe('跟随账号默认');
expect(result.badgeTone).toBe('info');
expect(result.effectiveTokenName).toBe('未设置默认令牌');
expect(result.helperText).toContain('当前账号还没有默认令牌');
expect(result.followOptionLabel).toBe('跟随账号默认');
expect(result.followOptionDescription).toContain('以后账号默认变化时会自动切换');
});
it('describes follow-api-setting mode for direct apikey connections', () => {
const result = describeTokenBinding([], 0, null, {
connectionMode: 'apikey',
accountName: 'elysiver_api',
});
expect(result.bindingModeLabel).toBe('API令牌');
expect(result.badgeTone).toBe('warning');
expect(result.isFollowingAccountDefault).toBe(false);
expect(result.effectiveTokenName).toBe('elysiver_api');
expect(result.helperText).toContain('连接「elysiver_api」保存的 API Key');
expect(result.followOptionLabel).toBe('固定使用:elysiver_api(跟随 API Key 设置)');
expect(result.followOptionDescription).toContain('API Key');
});
it('describes oauth direct binding without default-token wording', () => {
const result = describeTokenBinding([], 0, null, {
connectionMode: 'oauth',
accountName: 'mail@urlk.cn',
});
expect(result.bindingModeLabel).toBe('OAuth授权');
expect(result.badgeTone).toBe('warning');
expect(result.isFollowingAccountDefault).toBe(false);
expect(result.effectiveTokenName).toBe('mail@urlk.cn');
expect(result.helperText).toContain('OAuth 授权');
expect(result.helperText).not.toContain('默认令牌');
expect(result.followOptionLabel).toBe('固定使用:mail@urlk.cn(OAuth 授权)');
expect(result.followOptionDescription).toContain('OAuth 授权');
});
it('uses oauth fallback copy when a fixed token route is switched back to oauth direct binding', () => {
const result = describeTokenBinding([
{ id: 1, name: 'default', isDefault: true },
{ id: 2, name: 'backup', isDefault: false },
], 2, null, {
connectionMode: 'oauth',
accountName: 'mail@urlk.cn',
});
expect(result.bindingModeLabel).toBe('固定令牌');
expect(result.followOptionLabel).toBe('OAuth授权');
expect(result.followOptionDescription).toContain('mail@urlk.cn');
expect(result.followOptionDescription).not.toContain('当前生效:default');
});
it('resolves token binding connection mode from stored account fields', () => {
expect(resolveTokenBindingConnectionMode({
accessToken: '',
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
})).toBe('apikey');
expect(resolveTokenBindingConnectionMode({
accessToken: 'session-token',
extraConfig: JSON.stringify({ credentialMode: 'session' }),
})).toBe('session');
expect(resolveTokenBindingConnectionMode({
accessToken: 'oauth-access-token',
extraConfig: JSON.stringify({ credentialMode: 'session', oauth: { provider: 'codex' } }),
})).toBe('oauth');
});
it('formats fixed token options with clearer labels and descriptions', () => {
const token = {
id: 3,
@@ -8,11 +8,93 @@ export type TokenBindingOption = {
export type TokenBindingPresentation = {
isFollowingAccountDefault: boolean;
bindingModeLabel: string;
badgeTone: 'info' | 'warning';
effectiveTokenName: string;
helperText: string;
followOptionLabel: string;
followOptionDescription: string;
};
type TokenBindingConnectionMode = 'session' | 'apikey' | 'oauth';
type TokenBindingContext = {
connectionMode?: TokenBindingConnectionMode;
accountName?: string | null;
};
function parseExtraConfigHints(extraConfig?: string | null): {
credentialMode: Extract<TokenBindingConnectionMode, 'session' | 'apikey'> | null;
hasOauthProvider: boolean;
} {
if (typeof extraConfig !== 'string' || !extraConfig.trim()) {
return {
credentialMode: null,
hasOauthProvider: false,
};
}
try {
const parsed = JSON.parse(extraConfig) as {
credentialMode?: unknown;
oauth?: { provider?: unknown } | null;
};
const raw = String(parsed?.credentialMode || '').trim().toLowerCase();
return {
credentialMode: raw === 'session' || raw === 'apikey' ? raw : null,
hasOauthProvider: typeof parsed?.oauth?.provider === 'string' && parsed.oauth.provider.trim().length > 0,
};
} catch {}
return {
credentialMode: null,
hasOauthProvider: false,
};
}
function buildDirectBindingPresentation(
connectionMode: Extract<TokenBindingConnectionMode, 'apikey' | 'oauth'>,
accountName: string,
): TokenBindingPresentation {
if (connectionMode === 'oauth') {
return {
isFollowingAccountDefault: false,
bindingModeLabel: 'OAuth授权',
badgeTone: 'warning',
effectiveTokenName: accountName,
helperText: `当前直接使用连接「${accountName}」的 OAuth 授权,不依赖账号令牌。`,
followOptionLabel: `固定使用:${accountName}(OAuth 授权)`,
followOptionDescription: `直接使用连接「${accountName}」的 OAuth 授权`,
};
}
return {
isFollowingAccountDefault: false,
bindingModeLabel: 'API令牌',
badgeTone: 'warning',
effectiveTokenName: accountName,
helperText: `当前直接使用连接「${accountName}」保存的 API Key,不依赖账号令牌。`,
followOptionLabel: `固定使用:${accountName}(跟随 API Key 设置)`,
followOptionDescription: `直接使用连接「${accountName}」保存的 API Key`,
};
}
export function resolveTokenBindingConnectionMode(account?: {
accessToken?: string | null;
extraConfig?: string | null;
credentialMode?: string | null;
} | null): TokenBindingConnectionMode {
const parsedHints = parseExtraConfigHints(account?.extraConfig);
if (parsedHints.hasOauthProvider) return 'oauth';
const rawMode = String(account?.credentialMode || '').trim().toLowerCase();
if (rawMode === 'session') return 'session';
if (rawMode === 'apikey') return 'apikey';
if (parsedHints.credentialMode) return parsedHints.credentialMode;
return typeof account?.accessToken === 'string' && account.accessToken.trim()
? 'session'
: 'apikey';
}
export function getDefaultTokenOption(options: TokenBindingOption[]): TokenBindingOption | null {
return options.find((option) => option.isDefault) || null;
}
@@ -21,21 +103,30 @@ export function describeTokenBinding(
options: TokenBindingOption[],
activeTokenId: number,
fallbackTokenName?: string | null,
context: TokenBindingContext = {},
): TokenBindingPresentation {
const defaultToken = getDefaultTokenOption(options);
const selectedToken = activeTokenId
? options.find((option) => option.id === activeTokenId) || null
: null;
const connectionMode = context.connectionMode || 'session';
const accountName = String(context.accountName || '').trim() || '当前连接';
if (!activeTokenId) {
if (connectionMode === 'apikey' || connectionMode === 'oauth') {
return buildDirectBindingPresentation(connectionMode, accountName);
}
const effectiveTokenName = defaultToken?.name || fallbackTokenName || '未设置默认令牌';
return {
isFollowingAccountDefault: true,
bindingModeLabel: '跟随账号默认',
badgeTone: 'info',
effectiveTokenName,
helperText: defaultToken
? `跟随账号默认。当前生效的是「${defaultToken.name}」,以后账号默认变化时会自动切换。`
: '跟随账号默认。当前账号还没有默认令牌。',
followOptionLabel: '跟随账号默认',
followOptionDescription: defaultToken
? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换`
: '以后账号默认变化时会自动切换',
@@ -46,13 +137,21 @@ export function describeTokenBinding(
return {
isFollowingAccountDefault: false,
bindingModeLabel: '固定令牌',
badgeTone: 'warning',
effectiveTokenName,
helperText: selectedToken?.isDefault
? `已固定到「${effectiveTokenName}」。它目前也是账号默认,但以后账号默认变化时,这个通道不会跟着变。`
: `已固定到「${effectiveTokenName}」,不会随账号默认变化。`,
followOptionDescription: defaultToken
followOptionLabel: connectionMode === 'oauth'
? 'OAuth授权'
: (connectionMode === 'apikey' ? 'API 设置' : '跟随账号默认'),
followOptionDescription: connectionMode === 'oauth'
? `直接使用连接「${accountName}」的 OAuth 授权`
: (connectionMode === 'apikey'
? `直接使用连接「${accountName}」保存的 API Key`
: (defaultToken
? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换`
: '以后账号默认变化时会自动切换',
: '以后账号默认变化时会自动切换')),
};
}
+3
View File
@@ -28,6 +28,9 @@ export type RouteChannel = {
cooldownUntil?: string | null;
account?: {
username: string | null;
accessToken?: string | null;
extraConfig?: string | null;
credentialMode?: string | null;
};
site?: {
id: number;