fix(routes): dedupe direct credentials and clarify binding labels
This commit is contained in:
@@ -182,6 +182,8 @@ function ensureTokenManagementSchema() {
|
|||||||
WHERE
|
WHERE
|
||||||
a.api_token IS NOT NULL
|
a.api_token IS NOT NULL
|
||||||
AND trim(a.api_token) <> ''
|
AND trim(a.api_token) <> ''
|
||||||
|
AND a.access_token IS NOT NULL
|
||||||
|
AND trim(a.access_token) <> ''
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM account_tokens AS t
|
SELECT 1 FROM account_tokens AS t
|
||||||
WHERE t.account_id = a.id
|
WHERE t.account_id = a.id
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { oauthRoutes } from './routes/api/oauth.js';
|
|||||||
import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js';
|
import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js';
|
||||||
import { proxyRoutes } from './routes/proxy/router.js';
|
import { proxyRoutes } from './routes/proxy/router.js';
|
||||||
import { startScheduler } from './services/checkinScheduler.js';
|
import { startScheduler } from './services/checkinScheduler.js';
|
||||||
|
import { rebuildTokenRoutesFromAvailability } from './services/modelService.js';
|
||||||
import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
|
import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
|
||||||
import { buildStartupSummaryLines } from './services/startupInfo.js';
|
import { buildStartupSummaryLines } from './services/startupInfo.js';
|
||||||
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
|
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
|
||||||
@@ -329,6 +330,7 @@ try {
|
|||||||
await repairStoredCreatedAtValues();
|
await repairStoredCreatedAtValues();
|
||||||
await migrateSiteApiKeysToAccounts();
|
await migrateSiteApiKeysToAccounts();
|
||||||
await ensureDefaultSitesSeeded();
|
await ensureDefaultSitesSeeded();
|
||||||
|
await rebuildTokenRoutesFromAvailability();
|
||||||
|
|
||||||
console.log('Loaded runtime settings overrides');
|
console.log('Loaded runtime settings overrides');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -248,6 +248,55 @@ describe('refreshModelsForAccount credential discovery', () => {
|
|||||||
expect(parsed.runtimeHealth?.checkedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
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 () => {
|
it('returns structured result when account missing', async () => {
|
||||||
const result = await refreshModelsForAccount(9999);
|
const result = await refreshModelsForAccount(9999);
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,67 @@ describe('rebuildTokenRoutesFromAvailability', () => {
|
|||||||
expect(channels[0]?.manualOverride).toBe(false);
|
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 () => {
|
it('creates an exact route with an account-direct channel for oauth model availability', async () => {
|
||||||
const site = await db.insert(schema.sites).values({
|
const site = await db.insert(schema.sites).values({
|
||||||
name: 'codex-site',
|
name: 'codex-site',
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
isUsableAccountToken,
|
isUsableAccountToken,
|
||||||
} from './accountTokenService.js';
|
} from './accountTokenService.js';
|
||||||
import {
|
import {
|
||||||
getCredentialModeFromExtraConfig,
|
|
||||||
mergeAccountExtraConfig,
|
mergeAccountExtraConfig,
|
||||||
|
requiresManagedAccountTokens,
|
||||||
resolvePlatformUserId,
|
resolvePlatformUserId,
|
||||||
supportsDirectAccountRoutingConnection,
|
supportsDirectAccountRoutingConnection,
|
||||||
} from './accountExtraConfig.js';
|
} from './accountExtraConfig.js';
|
||||||
@@ -141,12 +141,6 @@ function isSiteDisabled(status?: string | null): boolean {
|
|||||||
return (status || 'active') === 'disabled';
|
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[] {
|
function normalizeModels(models: string[]): string[] {
|
||||||
const normalizedModels: string[] = [];
|
const normalizedModels: string[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -821,18 +815,21 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
|
|||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
let enabledTokens = await db.select()
|
const usesManagedTokens = requiresManagedAccountTokens(account);
|
||||||
|
let enabledTokens = usesManagedTokens
|
||||||
|
? await db.select()
|
||||||
.from(schema.accountTokens)
|
.from(schema.accountTokens)
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(schema.accountTokens.accountId, account.id),
|
eq(schema.accountTokens.accountId, account.id),
|
||||||
eq(schema.accountTokens.enabled, true),
|
eq(schema.accountTokens.enabled, true),
|
||||||
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
|
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
|
||||||
))
|
))
|
||||||
.all();
|
.all()
|
||||||
|
: [];
|
||||||
enabledTokens = enabledTokens.filter(isUsableAccountToken);
|
enabledTokens = enabledTokens.filter(isUsableAccountToken);
|
||||||
|
|
||||||
// Last fallback: if still no managed token but account has a legacy apiToken, mirror it into token table.
|
// 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;
|
const fallback = discoveredApiToken || account.apiToken || null;
|
||||||
if (fallback) {
|
if (fallback) {
|
||||||
ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' });
|
ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' });
|
||||||
@@ -1018,7 +1015,10 @@ export async function rebuildTokenRoutesFromAvailability() {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.all();
|
.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)
|
const accountRows = await db.select().from(schema.modelAvailability)
|
||||||
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
|
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
|
||||||
|
|||||||
@@ -1164,12 +1164,17 @@ export default function Accounts() {
|
|||||||
</div>
|
</div>
|
||||||
{isSub2ApiSelected && (
|
{isSub2ApiSelected && (
|
||||||
<>
|
<>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
<input
|
<input
|
||||||
placeholder="Sub2API refresh_token(可选,用于托管自动续期)"
|
placeholder="Sub2API refresh_token(可选,用于托管自动续期)"
|
||||||
value={tokenForm.refreshToken}
|
value={tokenForm.refreshToken}
|
||||||
onChange={(e) => setTokenForm((f) => ({ ...f, refreshToken: e.target.value.trim() }))}
|
onChange={(e) => setTokenForm((f) => ({ ...f, refreshToken: e.target.value.trim() }))}
|
||||||
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
|
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 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
<input
|
<input
|
||||||
placeholder="token_expires_at(可选,毫秒时间戳)"
|
placeholder="token_expires_at(可选,毫秒时间戳)"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
buildFixedTokenOptionDescription,
|
buildFixedTokenOptionDescription,
|
||||||
buildFixedTokenOptionLabel,
|
buildFixedTokenOptionLabel,
|
||||||
describeTokenBinding,
|
describeTokenBinding,
|
||||||
|
resolveTokenBindingConnectionMode,
|
||||||
} from './tokenBindingPresentation.js';
|
} from './tokenBindingPresentation.js';
|
||||||
import { getChannelDecisionState, getPriorityTagStyle, getProbabilityColor } from './utils.js';
|
import { getChannelDecisionState, getPriorityTagStyle, getProbabilityColor } from './utils.js';
|
||||||
|
|
||||||
@@ -54,7 +55,15 @@ export function SortableChannelRow({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const decisionState = getChannelDecisionState(decisionCandidate, channel, isExactRoute, loadingDecision);
|
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 (
|
return (
|
||||||
<div ref={setNodeRef} style={rowStyle}>
|
<div ref={setNodeRef} style={rowStyle}>
|
||||||
@@ -113,10 +122,10 @@ export function SortableChannelRow({
|
|||||||
className="badge"
|
className="badge"
|
||||||
style={{
|
style={{
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
background: tokenBinding.isFollowingAccountDefault
|
background: tokenBinding.badgeTone === 'info'
|
||||||
? 'color-mix(in srgb, var(--color-info) 15%, transparent)'
|
? 'color-mix(in srgb, var(--color-info) 15%, transparent)'
|
||||||
: 'color-mix(in srgb, var(--color-warning) 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}
|
{tokenBinding.bindingModeLabel}
|
||||||
@@ -211,7 +220,7 @@ export function SortableChannelRow({
|
|||||||
options={[
|
options={[
|
||||||
{
|
{
|
||||||
value: '0',
|
value: '0',
|
||||||
label: '跟随账号默认',
|
label: tokenBinding.followOptionLabel,
|
||||||
description: tokenBinding.followOptionDescription,
|
description: tokenBinding.followOptionDescription,
|
||||||
},
|
},
|
||||||
...tokenOptions.map((token) => ({
|
...tokenOptions.map((token) => ({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
buildFixedTokenOptionDescription,
|
buildFixedTokenOptionDescription,
|
||||||
buildFixedTokenOptionLabel,
|
buildFixedTokenOptionLabel,
|
||||||
describeTokenBinding,
|
describeTokenBinding,
|
||||||
|
resolveTokenBindingConnectionMode,
|
||||||
} from './tokenBindingPresentation.js';
|
} from './tokenBindingPresentation.js';
|
||||||
|
|
||||||
describe('tokenBindingPresentation', () => {
|
describe('tokenBindingPresentation', () => {
|
||||||
@@ -13,8 +14,10 @@ describe('tokenBindingPresentation', () => {
|
|||||||
], 0);
|
], 0);
|
||||||
|
|
||||||
expect(result.bindingModeLabel).toBe('跟随账号默认');
|
expect(result.bindingModeLabel).toBe('跟随账号默认');
|
||||||
|
expect(result.badgeTone).toBe('info');
|
||||||
expect(result.effectiveTokenName).toBe('token-a');
|
expect(result.effectiveTokenName).toBe('token-a');
|
||||||
expect(result.helperText).toContain('以后账号默认变化时会自动切换');
|
expect(result.helperText).toContain('以后账号默认变化时会自动切换');
|
||||||
|
expect(result.followOptionLabel).toBe('跟随账号默认');
|
||||||
expect(result.followOptionDescription).toContain('当前生效:token-a');
|
expect(result.followOptionDescription).toContain('当前生效:token-a');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -25,6 +28,7 @@ describe('tokenBindingPresentation', () => {
|
|||||||
], 1);
|
], 1);
|
||||||
|
|
||||||
expect(result.bindingModeLabel).toBe('固定令牌');
|
expect(result.bindingModeLabel).toBe('固定令牌');
|
||||||
|
expect(result.badgeTone).toBe('warning');
|
||||||
expect(result.effectiveTokenName).toBe('default');
|
expect(result.effectiveTokenName).toBe('default');
|
||||||
expect(result.helperText).toContain('它目前也是账号默认');
|
expect(result.helperText).toContain('它目前也是账号默认');
|
||||||
expect(result.helperText).toContain('不会跟着变');
|
expect(result.helperText).toContain('不会跟着变');
|
||||||
@@ -37,8 +41,10 @@ describe('tokenBindingPresentation', () => {
|
|||||||
], 2);
|
], 2);
|
||||||
|
|
||||||
expect(result.bindingModeLabel).toBe('固定令牌');
|
expect(result.bindingModeLabel).toBe('固定令牌');
|
||||||
|
expect(result.badgeTone).toBe('warning');
|
||||||
expect(result.effectiveTokenName).toBe('backup');
|
expect(result.effectiveTokenName).toBe('backup');
|
||||||
expect(result.helperText).toContain('不会随账号默认变化');
|
expect(result.helperText).toContain('不会随账号默认变化');
|
||||||
|
expect(result.followOptionLabel).toBe('跟随账号默认');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('describes follow-account-default mode when no default token exists yet', () => {
|
it('describes follow-account-default mode when no default token exists yet', () => {
|
||||||
@@ -48,11 +54,76 @@ describe('tokenBindingPresentation', () => {
|
|||||||
], 0);
|
], 0);
|
||||||
|
|
||||||
expect(result.bindingModeLabel).toBe('跟随账号默认');
|
expect(result.bindingModeLabel).toBe('跟随账号默认');
|
||||||
|
expect(result.badgeTone).toBe('info');
|
||||||
expect(result.effectiveTokenName).toBe('未设置默认令牌');
|
expect(result.effectiveTokenName).toBe('未设置默认令牌');
|
||||||
expect(result.helperText).toContain('当前账号还没有默认令牌');
|
expect(result.helperText).toContain('当前账号还没有默认令牌');
|
||||||
|
expect(result.followOptionLabel).toBe('跟随账号默认');
|
||||||
expect(result.followOptionDescription).toContain('以后账号默认变化时会自动切换');
|
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', () => {
|
it('formats fixed token options with clearer labels and descriptions', () => {
|
||||||
const token = {
|
const token = {
|
||||||
id: 3,
|
id: 3,
|
||||||
|
|||||||
@@ -8,11 +8,93 @@ export type TokenBindingOption = {
|
|||||||
export type TokenBindingPresentation = {
|
export type TokenBindingPresentation = {
|
||||||
isFollowingAccountDefault: boolean;
|
isFollowingAccountDefault: boolean;
|
||||||
bindingModeLabel: string;
|
bindingModeLabel: string;
|
||||||
|
badgeTone: 'info' | 'warning';
|
||||||
effectiveTokenName: string;
|
effectiveTokenName: string;
|
||||||
helperText: string;
|
helperText: string;
|
||||||
|
followOptionLabel: string;
|
||||||
followOptionDescription: 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 {
|
export function getDefaultTokenOption(options: TokenBindingOption[]): TokenBindingOption | null {
|
||||||
return options.find((option) => option.isDefault) || null;
|
return options.find((option) => option.isDefault) || null;
|
||||||
}
|
}
|
||||||
@@ -21,21 +103,30 @@ export function describeTokenBinding(
|
|||||||
options: TokenBindingOption[],
|
options: TokenBindingOption[],
|
||||||
activeTokenId: number,
|
activeTokenId: number,
|
||||||
fallbackTokenName?: string | null,
|
fallbackTokenName?: string | null,
|
||||||
|
context: TokenBindingContext = {},
|
||||||
): TokenBindingPresentation {
|
): TokenBindingPresentation {
|
||||||
const defaultToken = getDefaultTokenOption(options);
|
const defaultToken = getDefaultTokenOption(options);
|
||||||
const selectedToken = activeTokenId
|
const selectedToken = activeTokenId
|
||||||
? options.find((option) => option.id === activeTokenId) || null
|
? options.find((option) => option.id === activeTokenId) || null
|
||||||
: null;
|
: null;
|
||||||
|
const connectionMode = context.connectionMode || 'session';
|
||||||
|
const accountName = String(context.accountName || '').trim() || '当前连接';
|
||||||
|
|
||||||
if (!activeTokenId) {
|
if (!activeTokenId) {
|
||||||
|
if (connectionMode === 'apikey' || connectionMode === 'oauth') {
|
||||||
|
return buildDirectBindingPresentation(connectionMode, accountName);
|
||||||
|
}
|
||||||
|
|
||||||
const effectiveTokenName = defaultToken?.name || fallbackTokenName || '未设置默认令牌';
|
const effectiveTokenName = defaultToken?.name || fallbackTokenName || '未设置默认令牌';
|
||||||
return {
|
return {
|
||||||
isFollowingAccountDefault: true,
|
isFollowingAccountDefault: true,
|
||||||
bindingModeLabel: '跟随账号默认',
|
bindingModeLabel: '跟随账号默认',
|
||||||
|
badgeTone: 'info',
|
||||||
effectiveTokenName,
|
effectiveTokenName,
|
||||||
helperText: defaultToken
|
helperText: defaultToken
|
||||||
? `跟随账号默认。当前生效的是「${defaultToken.name}」,以后账号默认变化时会自动切换。`
|
? `跟随账号默认。当前生效的是「${defaultToken.name}」,以后账号默认变化时会自动切换。`
|
||||||
: '跟随账号默认。当前账号还没有默认令牌。',
|
: '跟随账号默认。当前账号还没有默认令牌。',
|
||||||
|
followOptionLabel: '跟随账号默认',
|
||||||
followOptionDescription: defaultToken
|
followOptionDescription: defaultToken
|
||||||
? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换`
|
? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换`
|
||||||
: '以后账号默认变化时会自动切换',
|
: '以后账号默认变化时会自动切换',
|
||||||
@@ -46,13 +137,21 @@ export function describeTokenBinding(
|
|||||||
return {
|
return {
|
||||||
isFollowingAccountDefault: false,
|
isFollowingAccountDefault: false,
|
||||||
bindingModeLabel: '固定令牌',
|
bindingModeLabel: '固定令牌',
|
||||||
|
badgeTone: 'warning',
|
||||||
effectiveTokenName,
|
effectiveTokenName,
|
||||||
helperText: selectedToken?.isDefault
|
helperText: selectedToken?.isDefault
|
||||||
? `已固定到「${effectiveTokenName}」。它目前也是账号默认,但以后账号默认变化时,这个通道不会跟着变。`
|
? `已固定到「${effectiveTokenName}」。它目前也是账号默认,但以后账号默认变化时,这个通道不会跟着变。`
|
||||||
: `已固定到「${effectiveTokenName}」,不会随账号默认变化。`,
|
: `已固定到「${effectiveTokenName}」,不会随账号默认变化。`,
|
||||||
followOptionDescription: defaultToken
|
followOptionLabel: connectionMode === 'oauth'
|
||||||
|
? 'OAuth授权'
|
||||||
|
: (connectionMode === 'apikey' ? 'API 设置' : '跟随账号默认'),
|
||||||
|
followOptionDescription: connectionMode === 'oauth'
|
||||||
|
? `直接使用连接「${accountName}」的 OAuth 授权`
|
||||||
|
: (connectionMode === 'apikey'
|
||||||
|
? `直接使用连接「${accountName}」保存的 API Key`
|
||||||
|
: (defaultToken
|
||||||
? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换`
|
? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换`
|
||||||
: '以后账号默认变化时会自动切换',
|
: '以后账号默认变化时会自动切换')),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export type RouteChannel = {
|
|||||||
cooldownUntil?: string | null;
|
cooldownUntil?: string | null;
|
||||||
account?: {
|
account?: {
|
||||||
username: string | null;
|
username: string | null;
|
||||||
|
accessToken?: string | null;
|
||||||
|
extraConfig?: string | null;
|
||||||
|
credentialMode?: string | null;
|
||||||
};
|
};
|
||||||
site?: {
|
site?: {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user