refactor: consolidate single-source routing and protocol helpers

This commit is contained in:
Cita
2026-03-22 21:27:09 +08:00
parent 96e7614835
commit 9290e51ecc
64 changed files with 3571 additions and 2681 deletions
+5
View File
@@ -149,4 +149,9 @@ describe('api proxy test timeout handling', () => {
data: 'data:application/pdf;base64,UERG',
});
});
it('reuses the same proxy test implementations for legacy aliases', () => {
expect(api.proxyTest).toBe(api.testProxy);
expect(api.proxyTestStream).toBe(api.testProxyStream);
});
});
+29 -44
View File
@@ -194,6 +194,31 @@ function resolveProxyTestTimeoutMs(data: ProxyTestRequestEnvelope) {
return DEFAULT_PROXY_TEST_TIMEOUT_MS;
}
function proxyTestRequest(data: ProxyTestRequestEnvelope) {
return request('/api/test/proxy', {
method: 'POST',
body: JSON.stringify(data),
timeoutMs: resolveProxyTestTimeoutMs(data),
});
}
async function proxyTestStreamRequest(data: ProxyTestRequestEnvelope, signal?: AbortSignal) {
const token = getAuthToken(localStorage);
if (!token) {
clearAuthSession(localStorage);
throw new Error('Session expired');
}
return fetch('/api/test/proxy/stream', {
method: 'POST',
signal,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
}
export type ProxyTestJobResponse = {
jobId: string;
status: 'pending' | 'succeeded' | 'failed' | 'cancelled';
@@ -721,52 +746,12 @@ export const api = {
data: `data:${mimeType};base64,${base64}`,
};
},
testProxy: (data: ProxyTestRequestEnvelope) =>
request('/api/test/proxy', {
method: 'POST',
body: JSON.stringify(data),
timeoutMs: resolveProxyTestTimeoutMs(data),
}),
proxyTest: (data: ProxyTestRequestEnvelope) =>
request('/api/test/proxy', {
method: 'POST',
body: JSON.stringify(data),
timeoutMs: resolveProxyTestTimeoutMs(data),
}),
testProxy: proxyTestRequest,
proxyTest: proxyTestRequest,
testChat: (data: TestChatRequestPayload) =>
request('/api/test/chat', { method: 'POST', body: JSON.stringify(data) }),
testProxyStream: async (data: ProxyTestRequestEnvelope, signal?: AbortSignal) => {
const token = getAuthToken(localStorage);
if (!token) {
clearAuthSession(localStorage);
throw new Error('Session expired');
}
return fetch('/api/test/proxy/stream', {
method: 'POST',
signal,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
},
proxyTestStream: async (data: ProxyTestRequestEnvelope, signal?: AbortSignal) => {
const token = getAuthToken(localStorage);
if (!token) {
clearAuthSession(localStorage);
throw new Error('Session expired');
}
return fetch('/api/test/proxy/stream', {
method: 'POST',
signal,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
},
testProxyStream: proxyTestStreamRequest,
proxyTestStream: proxyTestStreamRequest,
testChatStream: async (data: TestChatRequestPayload, signal?: AbortSignal) => {
const token = getAuthToken(localStorage);
if (!token) {
+5 -23
View File
@@ -16,6 +16,11 @@ import {
buildVerifyFailureHint,
normalizeVerifyFailureMessage,
} from './helpers/accountVerifyFeedback.js';
import {
isTruthyFlag,
parsePositiveInt,
resolveAccountCredentialMode,
} from './helpers/accountConnection.js';
import { clearFocusParams, readFocusAccountIntent } from './helpers/navigationFocus.js';
import { TokensPanel } from './Tokens.js';
import { tr } from '../i18n.js';
@@ -58,17 +63,6 @@ function createRebindForm(platformUserId = '') {
return { accessToken: '', platformUserId, refreshToken: '', tokenExpiresAt: '' };
}
function isTruthyFlag(value: string | null): boolean {
if (!value) return false;
const normalized = value.trim().toLowerCase();
return normalized === '1' || normalized === 'true' || normalized === 'yes';
}
function parsePositiveInt(value: string | null): number {
const parsed = Number.parseInt(String(value || '').trim(), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
function resolveConnectionsSegment(search: string): ConnectionsSegment {
const rawSegment = new URLSearchParams(search).get('segment');
if (rawSegment === 'apikey' || rawSegment === 'tokens') return rawSegment;
@@ -194,18 +188,6 @@ export default function Accounts() {
resetAddForms();
};
const resolveAccountCredentialMode = (account: any): 'session' | 'apikey' => {
const rawMode = String(account?.credentialMode || '').trim().toLowerCase();
if (rawMode === 'apikey') return 'apikey';
if (rawMode === 'session') return 'session';
const fromServer = account?.capabilities;
if (fromServer && typeof fromServer.proxyOnly === 'boolean') {
return fromServer.proxyOnly ? 'apikey' : 'session';
}
const hasSession = typeof account?.accessToken === 'string' && account.accessToken.trim().length > 0;
return hasSession ? 'session' : 'apikey';
};
const resolveAccountDisplayName = (account: any) => {
const username = typeof account?.username === 'string' ? account.username.trim() : '';
if (username) return username;
+14 -33
View File
@@ -6,7 +6,7 @@ import { useToast } from '../components/Toast.js';
import { useIsMobile } from '../components/useIsMobile.js';
import ChangeKeyModal from '../components/ChangeKeyModal.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { BrandGlyph, InlineBrandIcon, getBrand, normalizeBrandIconKey } from '../components/BrandIcon.js';
import { BrandGlyph, InlineBrandIcon, getBrand } from '../components/BrandIcon.js';
import ModernSelect from '../components/ModernSelect.js';
import {
applyRoutingProfilePreset,
@@ -17,11 +17,15 @@ import { fuzzyMatch } from './helpers/fuzzySearch.js';
import { clearAuthSession } from '../authSession.js';
import { clearAppInstallationState } from '../appLocalState.js';
import { tr } from '../i18n.js';
import { ROUTE_ICON_NONE_VALUE } from './token-routes/utils.js';
import {
isExactModelPattern,
parseBrandIconValue,
resolveRouteTitle,
ROUTE_ICON_NONE_VALUE,
} from './token-routes/utils.js';
import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
const PROXY_TOKEN_PREFIX = 'sk-';
const ROUTE_BRAND_ICON_PREFIX = 'brand:';
const FACTORY_RESET_ADMIN_TOKEN = 'change-me-admin-token';
const FACTORY_RESET_CONFIRM_SECONDS = 3;
const CHECKIN_SCHEDULE_MODE_OPTIONS = [
@@ -174,31 +178,8 @@ function inferUrlDialect(connectionString: string): 'mysql' | 'postgres' | null
return null;
}
function isRegexModelPattern(pattern: string): boolean {
return pattern.trim().toLowerCase().startsWith('re:');
}
function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim();
if (!normalized) return false;
if (isRegexModelPattern(normalized)) return false;
return !/[\*\?]/.test(normalized);
}
function routeTitle(route: RouteSelectorItem): string {
const displayName = (route.displayName || '').trim();
return displayName || route.modelPattern;
}
function parseBrandIconValue(raw: string | null | undefined): string | null {
const normalized = (raw || '').trim();
if (!normalized.startsWith(ROUTE_BRAND_ICON_PREFIX)) return null;
const icon = normalized.slice(ROUTE_BRAND_ICON_PREFIX.length).trim();
return normalizeBrandIconKey(icon);
}
function resolveRouteBrandSource(route: RouteSelectorItem): string {
const title = routeTitle(route);
const title = resolveRouteTitle(route);
if (getBrand(title)) return title;
return route.modelPattern;
}
@@ -302,7 +283,7 @@ export default function Settings() {
const groupRouteOptions = useMemo(() => (
selectorRoutes
.filter((route) => !isExactModelPattern(route.modelPattern))
.sort((a, b) => routeTitle(a).localeCompare(routeTitle(b), undefined, { sensitivity: 'base' }))
.sort((a, b) => resolveRouteTitle(a).localeCompare(resolveRouteTitle(b), undefined, { sensitivity: 'base' }))
), [selectorRoutes]);
const filteredExactModelOptions = useMemo(() => {
@@ -315,7 +296,7 @@ export default function Settings() {
const query = selectorGroupSearch.trim();
if (!query) return groupRouteOptions;
return groupRouteOptions.filter((route) => {
const matchText = `${routeTitle(route)} ${route.modelPattern} ${route.displayName || ''}`;
const matchText = `${resolveRouteTitle(route)} ${route.modelPattern} ${route.displayName || ''}`;
return fuzzyMatch(matchText, query);
});
}, [groupRouteOptions, selectorGroupSearch]);
@@ -1926,7 +1907,7 @@ export default function Settings() {
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>没有匹配的群组</div>
) : filteredGroupRouteOptions.map((route) => {
const checked = downstreamCreate.selectedGroupRouteIds.includes(route.id);
const explicitBrandIcon = parseBrandIconValue(route.displayIcon);
const explicitBrandIcon = parseBrandIconValue(route.displayIcon || '');
const explicitNoIcon = (route.displayIcon || '').trim() === ROUTE_ICON_NONE_VALUE;
const textIcon = explicitBrandIcon || explicitNoIcon ? '' : (route.displayIcon || '').trim();
return (
@@ -1985,9 +1966,9 @@ export default function Settings() {
{explicitBrandIcon ? (
<BrandGlyph
icon={explicitBrandIcon}
alt={routeTitle(route)}
alt={resolveRouteTitle(route)}
size={18}
fallbackText={routeTitle(route)}
fallbackText={resolveRouteTitle(route)}
/>
) : textIcon ? (
textIcon
@@ -1996,7 +1977,7 @@ export default function Settings() {
)}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{routeTitle(route)}
{resolveRouteTitle(route)}
</span>
{!route.enabled && (
<span
+5 -24
View File
@@ -7,6 +7,11 @@ import MobileFilterSheet from '../components/MobileFilterSheet.js';
import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js';
import { useToast } from '../components/Toast.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
import {
isTruthyFlag,
parsePositiveInt,
resolveAccountCredentialMode,
} from './helpers/accountConnection.js';
import ModernSelect from '../components/ModernSelect.js';
import { MobileCard, MobileField } from '../components/MobileCard.js';
import { useIsMobile } from '../components/useIsMobile.js';
@@ -39,18 +44,6 @@ type AccountTokenSyncResult = {
};
};
const resolveAccountCredentialMode = (account: any): 'session' | 'apikey' => {
const rawMode = String(account?.credentialMode || '').trim().toLowerCase();
if (rawMode === 'apikey') return 'apikey';
if (rawMode === 'session') return 'session';
if (typeof account?.capabilities?.proxyOnly === 'boolean') {
return account.capabilities.proxyOnly ? 'apikey' : 'session';
}
return typeof account?.accessToken === 'string' && account.accessToken.trim()
? 'session'
: 'apikey';
};
const isAccountSyncable = (account: any) =>
resolveAccountCredentialMode(account) === 'session'
&& account?.status === 'active'
@@ -105,18 +98,6 @@ 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 function TokensPanel({ embedded = false, onEmbeddedActionsChange }: TokensPanelProps) {
const location = useLocation();
const navigate = useNavigate();
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import {
isTruthyFlag,
parsePositiveInt,
resolveAccountCredentialMode,
} from './accountConnection.js';
describe('accountConnection helpers', () => {
it('resolves account credential mode from explicit mode, capabilities, and token presence', () => {
expect(resolveAccountCredentialMode({ credentialMode: 'apikey' })).toBe('apikey');
expect(resolveAccountCredentialMode({ capabilities: { proxyOnly: true } })).toBe('apikey');
expect(resolveAccountCredentialMode({ accessToken: ' session-token ' })).toBe('session');
expect(resolveAccountCredentialMode({})).toBe('apikey');
});
it('parses positive integers from query values', () => {
expect(parsePositiveInt('42')).toBe(42);
expect(parsePositiveInt(' 0 ')).toBe(0);
expect(parsePositiveInt('abc')).toBe(0);
expect(parsePositiveInt(null)).toBe(0);
});
it('treats common truthy query flags as enabled', () => {
expect(isTruthyFlag('1')).toBe(true);
expect(isTruthyFlag(' TRUE ')).toBe(true);
expect(isTruthyFlag('yes')).toBe(true);
expect(isTruthyFlag('no')).toBe(false);
expect(isTruthyFlag(null)).toBe(false);
});
});
@@ -0,0 +1,23 @@
export function resolveAccountCredentialMode(account: any): 'session' | 'apikey' {
const rawMode = String(account?.credentialMode || '').trim().toLowerCase();
if (rawMode === 'apikey') return 'apikey';
if (rawMode === 'session') return 'session';
if (typeof account?.capabilities?.proxyOnly === 'boolean') {
return account.capabilities.proxyOnly ? 'apikey' : 'session';
}
return typeof account?.accessToken === 'string' && account.accessToken.trim()
? 'session'
: 'apikey';
}
export function parsePositiveInt(input: string | null): number {
const value = Number.parseInt(String(input || '').trim(), 10);
if (!Number.isFinite(value) || value <= 0) return 0;
return value;
}
export function isTruthyFlag(input: string | null): boolean {
if (!input) return false;
const normalized = input.trim().toLowerCase();
return normalized === '1' || normalized === 'true' || normalized === 'yes';
}
+3 -1
View File
@@ -1,3 +1,5 @@
import { normalizeTokenRouteMode } from '../../../shared/tokenRouteContract.js';
export type RouteListVisibilityItem = {
id: number;
modelPattern: string;
@@ -8,7 +10,7 @@ export type RouteListVisibilityItem = {
};
function normalizeRouteMode(routeMode: string | null | undefined): 'pattern' | 'explicit_group' {
return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern';
return normalizeTokenRouteMode(routeMode);
}
function isExplicitGroupRoute(route: Pick<RouteListVisibilityItem, 'routeMode'>): boolean {
+1 -27
View File
@@ -1,13 +1,12 @@
import type { ReactNode } from 'react';
import type { BrandInfo } from '../../components/BrandIcon.js';
import type { RouteDecision, RouteDecisionCandidate, RouteMode } from '../../../shared/tokenRouteContract.js';
export type RouteSortBy = 'modelPattern' | 'channelCount';
export type RouteSortDir = 'asc' | 'desc';
export type GroupFilter = null | '__all__' | number;
export type RouteRoutingStrategy = 'weighted' | 'round_robin' | 'stable_first';
export type RouteRowKind = 'persisted' | 'zero_channel';
export type RouteMode = 'pattern' | 'explicit_group';
export type RouteChannelDraft = {
accountId: number;
tokenId: number;
@@ -81,31 +80,6 @@ export type RouteSummaryRow = {
isVirtual?: boolean;
};
export type RouteDecisionCandidate = {
channelId: number;
accountId: number;
username: string;
siteName: string;
tokenName: string;
priority: number;
weight: number;
eligible: boolean;
recentlyFailed: boolean;
avoidedByRecentFailure: boolean;
probability: number;
reason: string;
};
export type RouteDecision = {
requestedModel: string;
actualModel: string;
matched: boolean;
selectedChannelId?: number;
selectedLabel?: string;
summary: string[];
candidates: RouteDecisionCandidate[];
};
export type ChannelDecisionState = {
probability: number;
showBar: boolean;
+13 -79
View File
@@ -1,6 +1,13 @@
import type { CSSProperties } from 'react';
import { getBrand, normalizeBrandIconKey, type BrandInfo } from '../../components/BrandIcon.js';
import type { RouteRow, RouteChannel, RouteDecisionCandidate, ChannelDecisionState, RouteSummaryRow, RouteMode } from './types.js';
import { normalizeTokenRouteMode, type RouteDecisionCandidate, type RouteMode } from '../../../shared/tokenRouteContract.js';
import type { RouteRow, RouteChannel, ChannelDecisionState, RouteSummaryRow } from './types.js';
import {
isExactTokenRouteModelPattern,
isTokenRouteRegexPattern,
matchesTokenRouteModelPattern,
parseTokenRouteRegexPattern,
} from '../../../shared/tokenRoutePatterns.js';
export const AUTO_ROUTE_DECISION_LIMIT = 80;
export const ROUTE_RENDER_CHUNK = 40;
@@ -39,18 +46,15 @@ export const PLATFORM_ALIASES: Record<string, string> = {
};
export function isRegexModelPattern(modelPattern: string): boolean {
return modelPattern.trim().toLowerCase().startsWith('re:');
return isTokenRouteRegexPattern(modelPattern);
}
export function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim();
if (!normalized) return false;
if (isRegexModelPattern(normalized)) return false;
return !/[\*\?]/.test(normalized);
return isExactTokenRouteModelPattern(modelPattern);
}
export function normalizeRouteMode(routeMode: RouteMode | string | null | undefined): RouteMode {
return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern';
return normalizeTokenRouteMode(routeMode);
}
export function isExplicitGroupRoute(route: Pick<RouteRow | RouteSummaryRow, 'routeMode'>): boolean {
@@ -62,81 +66,11 @@ export function isRouteExactModel(route: Pick<RouteRow | RouteSummaryRow, 'model
}
export function parseRegexModelPattern(modelPattern: string): { regex: RegExp | null; error: string | null } {
if (!isRegexModelPattern(modelPattern)) return { regex: null, error: null };
const body = modelPattern.trim().slice(3).trim();
if (!body) return { regex: null, error: 're: 后缺少正则表达式' };
try {
return { regex: new RegExp(body), error: null };
} catch (error) {
return { regex: null, error: (error as Error)?.message || '无效正则' };
}
return parseTokenRouteRegexPattern(modelPattern);
}
function globToRegexSource(glob: string): string {
let source = '';
for (let i = 0; i < glob.length; i += 1) {
const ch = glob[i];
if (ch === '*') {
source += '.*';
continue;
}
if (ch === '?') {
source += '.';
continue;
}
if (ch === '[') {
const closeIndex = glob.indexOf(']', i + 1);
if (closeIndex > i + 1) {
source += glob.slice(i, closeIndex + 1);
i = closeIndex;
continue;
}
source += '\\[';
continue;
}
source += ch.replace(/[\\^$+?.()|{}]/g, '\\$&');
}
return source;
}
const compiledGlobCache = new Map<string, RegExp | null>();
function matchesGlobPattern(model: string, pattern: string): boolean {
let re = compiledGlobCache.get(pattern);
if (re === undefined) {
try {
re = new RegExp(`^${globToRegexSource(pattern)}$`);
} catch {
re = null;
}
compiledGlobCache.set(pattern, re);
}
return re ? re.test(model) : false;
}
const matchCache = new Map<string, boolean>();
const MATCH_CACHE_LIMIT = 4000;
export function matchesModelPattern(model: string, pattern: string): boolean {
const normalized = (pattern || '').trim();
if (!normalized) return false;
if (normalized === model) return true;
const cacheKey = `${model}\0${normalized}`;
const cached = matchCache.get(cacheKey);
if (cached !== undefined) return cached;
let result: boolean;
if (isRegexModelPattern(normalized)) {
const parsed = parseRegexModelPattern(normalized);
result = !!parsed.regex && parsed.regex.test(model);
} else {
result = matchesGlobPattern(model, normalized);
}
if (matchCache.size >= MATCH_CACHE_LIMIT) matchCache.clear();
matchCache.set(cacheKey, result);
return result;
return matchesTokenRouteModelPattern(model, pattern);
}
export function getModelPatternError(modelPattern: string): string | null {