[codex] add oauth route pools and proxy save flow (#440)

* feat: add oauth route pools and proxy save flow

* fix: align oauth route pool schema and rate limits

* fix: harden oauth route pool review fixes

* fix: address oauth ci regressions

* fix: close oauth codeql alerts

* fix: harden oauth route unit recovery

* fix: split oauth route limiters for codeql

* fix: address remaining oauth review comments
This commit is contained in:
Cita
2026-04-06 11:55:20 +08:00
committed by GitHub
parent a789ddf7f8
commit 2f0e63a1e0
29 changed files with 8902 additions and 328 deletions
+591 -38
View File
@@ -14,8 +14,11 @@ const { apiMock, openMock, focusMock, confirmMock, promptMock } = vi.hoisted(()
refreshOAuthConnectionQuota: vi.fn(),
refreshOAuthConnectionQuotaBatch: vi.fn(),
rebindOAuthConnection: vi.fn(),
updateOAuthConnectionProxy: vi.fn(),
deleteOAuthConnection: vi.fn(),
importOAuthConnections: vi.fn(),
createOAuthRouteUnit: vi.fn(),
deleteOAuthRouteUnit: vi.fn(),
getAccountModels: vi.fn(),
checkModels: vi.fn(),
},
@@ -59,6 +62,13 @@ function findOauthSettingInput(root: WebTestRenderer, key: string) {
));
}
function findOauthImportSettingInput(root: WebTestRenderer, key: string) {
return root.root.find((node) => (
node.type === 'input'
&& node.props['data-oauth-import-setting'] === key
));
}
function findAllByClassName(root: WebTestRenderer, className: string) {
return root.root.findAll((node) => (
typeof node.props?.className === 'string'
@@ -393,6 +403,477 @@ describe('OAuthManagement page', () => {
}
});
it('renders route participation summaries and can batch merge selected oauth accounts into a route pool', async () => {
apiMock.getOAuthProviders.mockResolvedValue({
providers: [
{
provider: 'codex',
label: 'Codex',
platform: 'codex',
enabled: true,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
],
});
apiMock.getOAuthConnections
.mockResolvedValueOnce({
items: [
{
accountId: 21,
siteId: 8,
provider: 'codex',
email: 'pool-a@example.com',
planType: 'team',
modelCount: 2,
modelsPreview: ['gpt-5.4'],
status: 'healthy',
routeParticipation: {
kind: 'single',
},
site: {
id: 8,
name: 'ChatGPT Codex OAuth',
url: 'https://chatgpt.com/backend-api/codex',
platform: 'codex',
},
},
{
accountId: 22,
siteId: 8,
provider: 'codex',
email: 'pool-b@example.com',
planType: 'team',
modelCount: 2,
modelsPreview: ['gpt-5.4'],
status: 'healthy',
routeParticipation: {
kind: 'single',
},
site: {
id: 8,
name: 'ChatGPT Codex OAuth',
url: 'https://chatgpt.com/backend-api/codex',
platform: 'codex',
},
},
],
total: 2,
limit: 100,
offset: 0,
})
.mockResolvedValueOnce({
items: [
{
accountId: 21,
siteId: 8,
provider: 'codex',
email: 'pool-a@example.com',
planType: 'team',
modelCount: 2,
modelsPreview: ['gpt-5.4'],
status: 'healthy',
routeParticipation: {
kind: 'route_unit',
routeUnitId: 90,
name: 'Codex Pool',
strategy: 'round_robin',
memberCount: 2,
},
site: {
id: 8,
name: 'ChatGPT Codex OAuth',
url: 'https://chatgpt.com/backend-api/codex',
platform: 'codex',
},
},
{
accountId: 22,
siteId: 8,
provider: 'codex',
email: 'pool-b@example.com',
planType: 'team',
modelCount: 2,
modelsPreview: ['gpt-5.4'],
status: 'healthy',
routeParticipation: {
kind: 'route_unit',
routeUnitId: 90,
name: 'Codex Pool',
strategy: 'round_robin',
memberCount: 2,
},
site: {
id: 8,
name: 'ChatGPT Codex OAuth',
url: 'https://chatgpt.com/backend-api/codex',
platform: 'codex',
},
},
],
total: 2,
limit: 100,
offset: 0,
});
apiMock.createOAuthRouteUnit.mockResolvedValue({
success: true,
routeUnit: {
id: 90,
name: 'Codex Pool',
strategy: 'round_robin',
memberCount: 2,
},
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter>
<OAuthManagement />
</MemoryRouter>
</ToastProvider>,
);
});
await flushMicrotasks();
expect(collectText(root.root)).toContain('单体');
const selectAll = root.root.find((node) => (
node.type === 'input'
&& node.props.type === 'checkbox'
&& node.props['data-testid'] === 'oauth-select-all'
));
await act(async () => {
selectAll.props.onChange({ target: { checked: true } });
});
await clickButton(root, '合并参与路由');
const nameInput = root.root.find((node) => (
node.type === 'input'
&& node.props['data-testid'] === 'oauth-route-unit-name'
));
await act(async () => {
nameInput.props.onChange({ target: { value: 'Codex Pool' } });
});
await clickButton(root, '创建路由池');
expect(apiMock.createOAuthRouteUnit).toHaveBeenCalledWith({
accountIds: [21, 22],
name: 'Codex Pool',
strategy: 'round_robin',
});
expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2);
expect(collectText(root.root)).toContain('路由池:Codex Pool · 2 个成员 · 轮询');
} finally {
root?.unmount();
}
});
it('can batch split selected oauth accounts out of the same route pool', async () => {
apiMock.getOAuthProviders.mockResolvedValue({
providers: [
{
provider: 'codex',
label: 'Codex',
platform: 'codex',
enabled: true,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
],
});
apiMock.getOAuthConnections
.mockResolvedValueOnce({
items: [
{
accountId: 31,
siteId: 8,
provider: 'codex',
email: 'split-a@example.com',
planType: 'team',
modelCount: 2,
modelsPreview: ['gpt-5.4'],
status: 'healthy',
routeParticipation: {
kind: 'route_unit',
routeUnitId: 95,
name: 'Split Pool',
strategy: 'stick_until_unavailable',
memberCount: 2,
},
site: {
id: 8,
name: 'ChatGPT Codex OAuth',
url: 'https://chatgpt.com/backend-api/codex',
platform: 'codex',
},
},
{
accountId: 32,
siteId: 8,
provider: 'codex',
email: 'split-b@example.com',
planType: 'team',
modelCount: 2,
modelsPreview: ['gpt-5.4'],
status: 'healthy',
routeParticipation: {
kind: 'route_unit',
routeUnitId: 95,
name: 'Split Pool',
strategy: 'stick_until_unavailable',
memberCount: 2,
},
site: {
id: 8,
name: 'ChatGPT Codex OAuth',
url: 'https://chatgpt.com/backend-api/codex',
platform: 'codex',
},
},
],
total: 2,
limit: 100,
offset: 0,
})
.mockResolvedValueOnce({
items: [
{
accountId: 31,
siteId: 8,
provider: 'codex',
email: 'split-a@example.com',
planType: 'team',
modelCount: 2,
modelsPreview: ['gpt-5.4'],
status: 'healthy',
routeParticipation: {
kind: 'single',
},
site: {
id: 8,
name: 'ChatGPT Codex OAuth',
url: 'https://chatgpt.com/backend-api/codex',
platform: 'codex',
},
},
{
accountId: 32,
siteId: 8,
provider: 'codex',
email: 'split-b@example.com',
planType: 'team',
modelCount: 2,
modelsPreview: ['gpt-5.4'],
status: 'healthy',
routeParticipation: {
kind: 'single',
},
site: {
id: 8,
name: 'ChatGPT Codex OAuth',
url: 'https://chatgpt.com/backend-api/codex',
platform: 'codex',
},
},
],
total: 2,
limit: 100,
offset: 0,
});
apiMock.deleteOAuthRouteUnit.mockResolvedValue({ success: true });
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter>
<OAuthManagement />
</MemoryRouter>
</ToastProvider>,
);
});
await flushMicrotasks();
const selectAll = root.root.find((node) => (
node.type === 'input'
&& node.props.type === 'checkbox'
&& node.props['data-testid'] === 'oauth-select-all'
));
await act(async () => {
selectAll.props.onChange({ target: { checked: true } });
});
await clickButton(root, '拆回单体');
expect(apiMock.deleteOAuthRouteUnit).toHaveBeenCalledWith(95);
expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2);
expect(collectText(root.root)).toContain('单体');
} finally {
root?.unmount();
}
});
it('only enables splitting when the full route pool is selected and falls back to routeUnit ids', async () => {
apiMock.getOAuthProviders.mockResolvedValue({
providers: [
{
provider: 'codex',
label: 'Codex',
platform: 'codex',
enabled: true,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
],
});
apiMock.getOAuthConnections.mockResolvedValue({
items: [
{
accountId: 41,
siteId: 8,
provider: 'codex',
email: 'partial-a@example.com',
planType: 'team',
modelCount: 2,
modelsPreview: ['gpt-5.4'],
status: 'healthy',
routeParticipation: {
kind: 'route_unit',
name: 'Large Pool',
strategy: 'round_robin',
memberCount: 3,
},
routeUnit: {
id: 97,
name: 'Large Pool',
strategy: 'round_robin',
memberCount: 3,
},
site: {
id: 8,
name: 'ChatGPT Codex OAuth',
url: 'https://chatgpt.com/backend-api/codex',
platform: 'codex',
},
},
{
accountId: 42,
siteId: 8,
provider: 'codex',
email: 'partial-b@example.com',
planType: 'team',
modelCount: 2,
modelsPreview: ['gpt-5.4'],
status: 'healthy',
routeParticipation: {
kind: 'route_unit',
name: 'Large Pool',
strategy: 'round_robin',
memberCount: 3,
},
routeUnit: {
id: 97,
name: 'Large Pool',
strategy: 'round_robin',
memberCount: 3,
},
site: {
id: 8,
name: 'ChatGPT Codex OAuth',
url: 'https://chatgpt.com/backend-api/codex',
platform: 'codex',
},
},
{
accountId: 43,
siteId: 8,
provider: 'codex',
email: 'partial-c@example.com',
planType: 'team',
modelCount: 2,
modelsPreview: ['gpt-5.4'],
status: 'healthy',
routeParticipation: {
kind: 'route_unit',
name: 'Large Pool',
strategy: 'round_robin',
memberCount: 3,
},
routeUnit: {
id: 97,
name: 'Large Pool',
strategy: 'round_robin',
memberCount: 3,
},
site: {
id: 8,
name: 'ChatGPT Codex OAuth',
url: 'https://chatgpt.com/backend-api/codex',
platform: 'codex',
},
},
],
total: 3,
limit: 100,
offset: 0,
});
apiMock.deleteOAuthRouteUnit.mockResolvedValue({ success: true });
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter>
<OAuthManagement />
</MemoryRouter>
</ToastProvider>,
);
});
await flushMicrotasks();
const checkboxes = root.root.findAll((node) => (
node.type === 'input'
&& node.props.type === 'checkbox'
));
expect(checkboxes).toHaveLength(4);
await act(async () => {
checkboxes[1]?.props.onChange({ target: { checked: true } });
checkboxes[2]?.props.onChange({ target: { checked: true } });
});
expect(collectText(root.root)).not.toContain('拆回单体');
await act(async () => {
checkboxes[3]?.props.onChange({ target: { checked: true } });
});
await clickButton(root, '拆回单体');
expect(apiMock.deleteOAuthRouteUnit).toHaveBeenCalledWith(97);
} finally {
root?.unmount();
}
});
it('renders supported quota windows without duplicating raw counters beside the percent', async () => {
apiMock.getOAuthProviders.mockResolvedValue({
providers: [
@@ -579,8 +1060,11 @@ describe('OAuthManagement page', () => {
}
});
it('supports selecting multiple json files and imports them sequentially', async () => {
it('supports selecting multiple json files, defaults import to system proxy, and sends one batch request', async () => {
apiMock.getOAuthProviders.mockResolvedValue({
defaults: {
systemProxyConfigured: true,
},
providers: [
{
provider: 'codex',
@@ -613,25 +1097,11 @@ describe('OAuthManagement page', () => {
total: 1,
limit: 100,
offset: 0,
});
});
apiMock.importOAuthConnections
.mockResolvedValueOnce({
success: true,
imported: 1,
skipped: 0,
failed: 0,
items: [
{
name: 'Workspace A',
status: 'imported',
accountId: 7,
provider: 'codex',
},
],
})
.mockResolvedValueOnce({
success: true,
imported: 1,
imported: 2,
skipped: 1,
failed: 0,
items: [
@@ -641,6 +1111,12 @@ describe('OAuthManagement page', () => {
accountId: 9,
provider: 'codex',
},
{
name: 'Workspace A',
status: 'imported',
accountId: 7,
provider: 'codex',
},
{
name: 'Skipped API Key',
status: 'skipped',
@@ -703,19 +1179,25 @@ describe('OAuthManagement page', () => {
expect(importText).toContain('workspace-b.json');
expect(importText).toContain('已识别 2 份 JSON');
expect(importText).toContain('结构有效');
expect(findOauthImportSettingInput(root, 'use-system-proxy').props.checked).toBe(true);
await clickButton(root, '添加');
expect(apiMock.importOAuthConnections).toHaveBeenCalledTimes(2);
expect(apiMock.importOAuthConnections).toHaveBeenNthCalledWith(1, {
type: 'codex',
access_token: 'oauth-access-token-a',
email: 'workspace-a@example.com',
});
expect(apiMock.importOAuthConnections).toHaveBeenNthCalledWith(2, {
type: 'codex',
access_token: 'oauth-access-token-b',
email: 'workspace-b@example.com',
expect(apiMock.importOAuthConnections).toHaveBeenCalledTimes(1);
expect(apiMock.importOAuthConnections).toHaveBeenCalledWith({
items: [
{
type: 'codex',
access_token: 'oauth-access-token-a',
email: 'workspace-a@example.com',
},
{
type: 'codex',
access_token: 'oauth-access-token-b',
email: 'workspace-b@example.com',
},
],
useSystemProxy: true,
});
await act(async () => {
vi.advanceTimersByTime(300);
@@ -1477,7 +1959,76 @@ describe('OAuthManagement page', () => {
}
});
it('can clear the stored account proxy by turning off both proxy options', async () => {
it('can clear the stored account proxy without forcing reauthorization', async () => {
apiMock.getOAuthProviders.mockResolvedValue({
providers: [
{
provider: 'gemini-cli',
label: 'Gemini CLI',
platform: 'gemini-cli',
enabled: true,
loginType: 'oauth',
requiresProjectId: true,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
],
});
apiMock.getOAuthConnections.mockResolvedValue({
items: [
{
accountId: 11,
provider: 'gemini-cli',
email: 'gemini@example.com',
projectId: 'project-demo',
modelCount: 5,
modelsPreview: ['gemini-2.5-pro'],
status: 'healthy',
proxyUrl: 'http://127.0.0.1:7890',
},
],
total: 1,
limit: 100,
offset: 0,
});
apiMock.updateOAuthConnectionProxy.mockResolvedValue({ success: true });
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter>
<OAuthManagement />
</MemoryRouter>
</ToastProvider>,
);
});
await flushMicrotasks();
await clickButton(root!, '代理设置');
const customProxyToggle = findOauthSettingInput(root!, 'use-custom-proxy');
await act(async () => {
customProxyToggle.props.onChange({ target: { checked: false } });
});
await clickButton(root!, '保存代理');
await flushMicrotasks();
expect(apiMock.updateOAuthConnectionProxy).toHaveBeenCalledWith(11, {
proxyUrl: null,
useSystemProxy: false,
});
expect(apiMock.rebindOAuthConnection).not.toHaveBeenCalled();
expect(openMock).not.toHaveBeenCalled();
} finally {
root?.unmount();
}
});
it('keeps a secondary save-and-reauthorize action in the proxy drawer', async () => {
apiMock.getOAuthProviders.mockResolvedValue({
providers: [
{
@@ -1512,8 +2063,8 @@ describe('OAuthManagement page', () => {
});
apiMock.rebindOAuthConnection.mockResolvedValue({
provider: 'gemini-cli',
state: 'gemini-rebind-clear',
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-rebind-clear',
state: 'gemini-rebind-save-and-reauthorize',
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-rebind-save-and-reauthorize',
instructions: {
redirectUri: 'http://localhost:8085/oauth2callback',
callbackPort: 8085,
@@ -1523,7 +2074,7 @@ describe('OAuthManagement page', () => {
});
apiMock.getOAuthSession.mockResolvedValue({
provider: 'gemini-cli',
state: 'gemini-rebind-clear',
state: 'gemini-rebind-save-and-reauthorize',
status: 'pending',
});
@@ -1541,19 +2092,21 @@ describe('OAuthManagement page', () => {
await flushMicrotasks();
await clickButton(root!, '代理设置');
const customProxyToggle = findOauthSettingInput(root!, 'use-custom-proxy');
expect(collectText(root!.root)).toContain('保存代理');
expect(collectText(root!.root)).toContain('保存并重新授权');
await act(async () => {
customProxyToggle.props.onChange({ target: { checked: false } });
});
await clickButton(root!, '保存代理并重新授权');
await clickButton(root!, '保存并重新授权');
await flushMicrotasks();
expect(apiMock.rebindOAuthConnection).toHaveBeenCalledWith(11, {
proxyUrl: null,
proxyUrl: 'http://127.0.0.1:7890',
useSystemProxy: false,
});
expect(openMock).toHaveBeenCalledWith(
'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-rebind-save-and-reauthorize',
'oauth-gemini-cli',
expect.stringContaining('width=540'),
);
} finally {
root?.unmount();
}
+452 -49
View File
@@ -23,6 +23,8 @@ import {
api,
type OAuthConnectionInfo,
type OAuthProviderInfo,
type OAuthRouteParticipation,
type OAuthRouteUnitStrategy,
type OAuthQuotaInfo,
type OAuthQuotaWindowInfo,
type OAuthStartInstructions,
@@ -94,6 +96,12 @@ type OAuthModelsModalState = {
siteName: string;
};
type OAuthRouteUnitModalState = {
open: boolean;
name: string;
strategy: OAuthRouteUnitStrategy;
};
const COLUMN_OPTIONS: Array<{ key: ColumnKey; label: string }> = [
{ key: 'identity', label: '账号 / Provider' },
{ key: 'site', label: '站点' },
@@ -450,6 +458,47 @@ function hasOauthProxySelection(connection: OAuthConnectionInfo): boolean {
return !!connection.useSystemProxy || !!asTrimmedString(connection.proxyUrl);
}
function resolveRouteUnitStrategyLabel(strategy?: OAuthRouteUnitStrategy | null): string {
return strategy === 'stick_until_unavailable' ? '单个用到不可用再切' : '轮询';
}
function resolveConnectionRouteParticipation(
connection: OAuthConnectionInfo,
): OAuthRouteParticipation {
if (connection.routeParticipation?.kind === 'route_unit') {
return {
...connection.routeParticipation,
id: connection.routeParticipation.id ?? connection.routeUnit?.id,
routeUnitId: connection.routeParticipation.routeUnitId
?? connection.routeParticipation.id
?? connection.routeUnit?.routeUnitId
?? connection.routeUnit?.id,
};
}
if (connection.routeParticipation?.kind === 'single') {
return connection.routeParticipation;
}
if (connection.routeUnit) {
return {
kind: 'route_unit',
routeUnitId: connection.routeUnit.routeUnitId ?? connection.routeUnit.id,
id: connection.routeUnit.id,
name: connection.routeUnit.name,
strategy: connection.routeUnit.strategy,
memberCount: connection.routeUnit.memberCount,
};
}
return { kind: 'single' };
}
function resolveRouteParticipationSummary(connection: OAuthConnectionInfo): string {
const participation = resolveConnectionRouteParticipation(connection);
if (participation.kind !== 'route_unit') {
return '单体';
}
return `路由池:${participation.name} · ${participation.memberCount} 个成员 · ${resolveRouteUnitStrategyLabel(participation.strategy)}`;
}
function renderCodeBlock(value: string) {
return (
<code className="oauth-code-block">{value}</code>
@@ -588,12 +637,16 @@ export default function OAuthManagement() {
const [showColumnMenu, setShowColumnMenu] = useState(false);
const [autoRefreshSeconds, setAutoRefreshSeconds] = useState<number>(0);
const [autoRefreshCountdown, setAutoRefreshCountdown] = useState<number>(0);
const [runtimeSystemProxyConfigured, setRuntimeSystemProxyConfigured] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const importFileInputRef = useRef<HTMLInputElement | null>(null);
const [importJsonText, setImportJsonText] = useState('');
const [importDrafts, setImportDrafts] = useState<OAuthImportDraft[]>([]);
const [importDragOver, setImportDragOver] = useState(false);
const [importing, setImporting] = useState(false);
const [importCustomProxyEnabled, setImportCustomProxyEnabled] = useState(false);
const [importSystemProxyEnabled, setImportSystemProxyEnabled] = useState(false);
const [importProxyUrl, setImportProxyUrl] = useState('');
const [drawerOpen, setDrawerOpen] = useState(false);
const [drawerIntent, setDrawerIntent] = useState<DrawerIntent>({ mode: 'create' });
const [selectedProviderKey, setSelectedProviderKey] = useState('');
@@ -615,6 +668,11 @@ export default function OAuthManagement() {
disabledCount: 0,
siteName: '',
});
const [routeUnitModal, setRouteUnitModal] = useState<OAuthRouteUnitModalState>({
open: false,
name: '',
strategy: 'round_robin',
});
const resetOauthProxySettings = useCallback(() => {
setOauthCustomProxyEnabled(false);
@@ -622,6 +680,12 @@ export default function OAuthManagement() {
setOauthProxyUrl('');
}, []);
const resetImportProxySettings = useCallback((defaultToSystem = false) => {
setImportCustomProxyEnabled(false);
setImportSystemProxyEnabled(defaultToSystem);
setImportProxyUrl('');
}, []);
const resetImportState = useCallback(() => {
setImportJsonText('');
setImportDrafts([]);
@@ -631,12 +695,14 @@ export default function OAuthManagement() {
const closeImportModal = useCallback(() => {
setImportOpen(false);
resetImportState();
}, [resetImportState]);
resetImportProxySettings(false);
}, [resetImportProxySettings, resetImportState]);
const openImportModal = useCallback(() => {
resetImportState();
resetImportProxySettings(runtimeSystemProxyConfigured);
setImportOpen(true);
}, [resetImportState]);
}, [resetImportProxySettings, resetImportState, runtimeSystemProxyConfigured]);
const loadConnections = useCallback(async () => {
const response = await api.getOAuthConnections({
@@ -656,6 +722,7 @@ export default function OAuthManagement() {
loadConnections(),
]);
const nextProviders = Array.isArray(providersResponse?.providers) ? providersResponse.providers : [];
setRuntimeSystemProxyConfigured(providersResponse?.defaults?.systemProxyConfigured === true);
setProviders(nextProviders);
setSelectedProviderKey((current) => current || nextProviders[0]?.provider || '');
} catch (error: any) {
@@ -815,6 +882,53 @@ export default function OAuthManagement() {
const allVisibleSelected = filteredConnections.length > 0
&& filteredConnections.every((connection) => selectedConnectionIds.includes(connection.accountId));
const selectedConnections = useMemo(
() => connections.filter((connection) => selectedConnectionIds.includes(connection.accountId)),
[connections, selectedConnectionIds],
);
const selectedRouteUnitParticipation = useMemo(() => {
if (selectedConnections.length <= 0) return null;
const participations = selectedConnections.map(resolveConnectionRouteParticipation);
if (participations.some((item) => item.kind !== 'route_unit')) return null;
const first = participations[0];
if (!first || first.kind !== 'route_unit') return null;
const routeUnitId = first.routeUnitId ?? first.id;
if (!routeUnitId) return null;
const allSameRouteUnit = participations.every((item) => (
item.kind === 'route_unit'
&& (item.routeUnitId ?? item.id) === routeUnitId
));
if (!allSameRouteUnit) return null;
const totalRouteUnitMembers = connections.filter((connection) => {
const participation = resolveConnectionRouteParticipation(connection);
return participation.kind === 'route_unit'
&& (participation.routeUnitId ?? participation.id) === routeUnitId;
}).length;
if (totalRouteUnitMembers !== selectedConnections.length) return null;
return {
...first,
memberCount: Math.max(first.memberCount, totalRouteUnitMembers),
};
}, [connections, selectedConnections]);
const canMergeSelectedIntoRouteUnit = useMemo(() => {
if (selectedConnections.length < 2) return false;
const first = selectedConnections[0];
if (!first) return false;
const firstSiteId = first.siteId;
const firstProvider = first.provider;
return selectedConnections.every((connection) => (
connection.siteId === firstSiteId
&& connection.provider === firstProvider
&& resolveConnectionRouteParticipation(connection).kind === 'single'
));
}, [selectedConnections]);
const canSplitSelectedRouteUnit = selectedRouteUnitParticipation != null;
const openCreateDrawer = (provider?: string) => {
setDrawerIntent({ mode: 'create', provider });
setSelectedProviderKey(provider || providers[0]?.provider || '');
@@ -842,7 +956,100 @@ export default function OAuthManagement() {
setOauthProxyUrl(connection.useSystemProxy ? '' : asTrimmedString(connection.proxyUrl));
setDrawerOpen(true);
setShowColumnMenu(false);
setSessionMessage('已打开 OAuth 代理设置,修改后点击“保存代理并重新授权”。');
setSessionMessage('已打开 OAuth 代理设置,修改后可直接保存代理,或保存后重新授权。');
};
const openRouteUnitModal = () => {
setRouteUnitModal({
open: true,
name: '',
strategy: 'round_robin',
});
};
const closeRouteUnitModal = () => {
setRouteUnitModal((current) => ({
...current,
open: false,
}));
};
const resolveProxySettingsPayload = ({
customEnabled,
systemEnabled,
proxyValue,
fallbackAccount,
clearToSiteFallback = false,
}: {
customEnabled: boolean;
systemEnabled: boolean;
proxyValue: string;
fallbackAccount?: OAuthConnectionInfo | null;
clearToSiteFallback?: boolean;
}): { proxyUrl?: string | null; useSystemProxy?: boolean } => {
const customProxyUrl = asTrimmedString(proxyValue);
if (customEnabled) {
return {
proxyUrl: customProxyUrl,
useSystemProxy: false,
};
}
if (systemEnabled) {
return {
proxyUrl: null,
useSystemProxy: true,
};
}
if (clearToSiteFallback) {
return {
proxyUrl: null,
useSystemProxy: false,
};
}
if (fallbackAccount?.useSystemProxy) {
return {
proxyUrl: null,
useSystemProxy: true,
};
}
if (fallbackAccount?.proxyUrl !== undefined) {
return {
proxyUrl: asTrimmedString(fallbackAccount.proxyUrl) || null,
useSystemProxy: false,
};
}
return {};
};
const handleSaveProxy = async () => {
if (drawerIntent.mode !== 'proxy') return;
const customProxyUrl = asTrimmedString(oauthProxyUrl);
if (oauthCustomProxyEnabled && !customProxyUrl) {
setSessionMessage('已开启代理,请先输入完整代理地址');
return;
}
const actionKey = `save-proxy:${drawerIntent.account.accountId}`;
setActionLoadingKey(actionKey);
try {
await api.updateOAuthConnectionProxy(
drawerIntent.account.accountId,
resolveProxySettingsPayload({
customEnabled: oauthCustomProxyEnabled,
systemEnabled: oauthSystemProxyEnabled,
proxyValue: oauthProxyUrl,
clearToSiteFallback: true,
}),
);
await loadConnections();
setDrawerOpen(false);
resetOauthProxySettings();
setSessionMessage('代理已保存');
} catch (error: any) {
setSessionMessage(error?.message || '保存代理失败');
} finally {
setActionLoadingKey('');
}
};
const handleStart = async () => {
@@ -869,24 +1076,13 @@ export default function OAuthManagement() {
return;
}
let resolvedProxyUrl: string | null | undefined;
let resolvedUseSystemProxy: boolean | undefined;
if (oauthCustomProxyEnabled) {
resolvedProxyUrl = customProxyUrl;
resolvedUseSystemProxy = false;
} else if (oauthSystemProxyEnabled) {
resolvedProxyUrl = null;
resolvedUseSystemProxy = true;
} else if (drawerIntent.mode === 'proxy') {
resolvedProxyUrl = null;
resolvedUseSystemProxy = false;
} else if (rebindAccount?.useSystemProxy) {
resolvedProxyUrl = null;
resolvedUseSystemProxy = true;
} else if (rebindAccount?.proxyUrl !== undefined) {
resolvedProxyUrl = asTrimmedString(rebindAccount.proxyUrl) || null;
resolvedUseSystemProxy = false;
}
const proxySettings = resolveProxySettingsPayload({
customEnabled: oauthCustomProxyEnabled,
systemEnabled: oauthSystemProxyEnabled,
proxyValue: oauthProxyUrl,
fallbackAccount: rebindAccount,
clearToSiteFallback: drawerIntent.mode === 'proxy',
});
const actionKey = `start:${provider.provider}:${accountId || 0}`;
setActionLoadingKey(actionKey);
@@ -898,14 +1094,12 @@ export default function OAuthManagement() {
? await api.rebindOAuthConnection(
accountId,
{
...(resolvedProxyUrl !== undefined ? { proxyUrl: resolvedProxyUrl } : {}),
...(resolvedUseSystemProxy !== undefined ? { useSystemProxy: resolvedUseSystemProxy } : {}),
...proxySettings,
},
)
: await api.startOAuthProvider(provider.provider, {
projectId,
...(resolvedProxyUrl !== undefined ? { proxyUrl: resolvedProxyUrl } : {}),
...(resolvedUseSystemProxy !== undefined ? { useSystemProxy: resolvedUseSystemProxy } : {}),
...proxySettings,
});
setSessionMessage('等待授权完成');
@@ -1155,21 +1349,42 @@ export default function OAuthManagement() {
setSessionMessage('请先修正无效的 OAuth JSON');
return;
}
if (importCustomProxyEnabled && !asTrimmedString(importProxyUrl)) {
setSessionMessage('已开启代理,请先输入完整代理地址');
return;
}
setImporting(true);
try {
let importedCount = 0;
for (const item of importPreviewSummary.items) {
if (!item.valid || !item.parsedData) continue;
const result = await api.importOAuthConnections(item.parsedData);
importedCount += result.imported;
}
const parsedItems = importPreviewSummary.items
.filter((item) => item.valid && item.parsedData)
.map((item) => item.parsedData as Record<string, unknown>);
const importProxySettings = importSystemProxyEnabled && !importCustomProxyEnabled
? { useSystemProxy: true as const }
: resolveProxySettingsPayload({
customEnabled: importCustomProxyEnabled,
systemEnabled: importSystemProxyEnabled,
proxyValue: importProxyUrl,
});
const result = parsedItems.length === 1
&& !('proxyUrl' in importProxySettings)
&& !('useSystemProxy' in importProxySettings)
? await api.importOAuthConnections(parsedItems[0]!)
: await api.importOAuthConnections({
items: parsedItems,
...importProxySettings,
});
await loadConnections();
const successMessage = `已添加 ${importedCount} 个 OAuth 连接`;
toast.success(successMessage);
setSessionMessage(successMessage);
const importMessage = result.failed > 0
? `批量导入完成,成功 ${result.imported} 个,失败 ${result.failed} 个`
: `已添加 ${result.imported} 个 OAuth 连接`;
if (result.failed > 0) {
toast.info(importMessage);
} else {
toast.success(importMessage);
}
setSessionMessage(importMessage);
closeImportModal();
} catch (error: any) {
const message = error?.message || '导入 OAuth JSON 失败';
@@ -1180,6 +1395,50 @@ export default function OAuthManagement() {
}
};
const handleCreateRouteUnit = async () => {
const name = asTrimmedString(routeUnitModal.name);
if (!canMergeSelectedIntoRouteUnit || selectedConnections.length < 2) return;
if (!name) {
setSessionMessage('请先输入路由池名称');
return;
}
setActionLoadingKey('route-unit:create');
try {
await api.createOAuthRouteUnit({
accountIds: selectedConnections.map((connection) => connection.accountId),
name,
strategy: routeUnitModal.strategy,
});
await loadConnections();
setSelectedConnectionIds([]);
closeRouteUnitModal();
setSessionMessage('已合并为路由池');
} catch (error: any) {
setSessionMessage(error?.message || '创建路由池失败');
} finally {
setActionLoadingKey('');
}
};
const handleDeleteSelectedRouteUnit = async () => {
if (!selectedRouteUnitParticipation) return;
const routeUnitId = selectedRouteUnitParticipation.routeUnitId ?? selectedRouteUnitParticipation.id;
if (!routeUnitId) return;
setActionLoadingKey('route-unit:delete');
try {
await api.deleteOAuthRouteUnit(routeUnitId);
await loadConnections();
setSelectedConnectionIds([]);
setSessionMessage('已拆回单体');
} catch (error: any) {
setSessionMessage(error?.message || '拆回单体失败');
} finally {
setActionLoadingKey('');
}
};
const toggleColumn = (key: ColumnKey) => {
setVisibleColumns((current) => ({
...current,
@@ -1489,6 +1748,7 @@ export default function OAuthManagement() {
<td className="oauth-col-proxy">
<div className="oauth-cell-stack">
<div className="oauth-cell-secondary">{resolveProxyProjectSummary(connection)}</div>
<div className="oauth-cell-secondary">{resolveRouteParticipationSummary(connection)}</div>
<div className="oauth-cell-tertiary">{resolveProxyDisplayText(connection)}</div>
<button
type="button"
@@ -1566,6 +1826,7 @@ export default function OAuthManagement() {
<MobileField label="站点" value={connection.site?.name || '--'} />
<MobileField label="邮箱" value={resolveConnectionEmailLabel(connection) || '--'} />
<MobileField label="计划 / 项目" value={connection.projectId ? `${connection.planType || '--'} · ${connection.projectId}` : (connection.planType || '--')} />
<MobileField label="路由参与" value={resolveRouteParticipationSummary(connection)} />
<MobileField
label="账号代理"
value={(
@@ -1719,6 +1980,25 @@ export default function OAuthManagement() {
>
{actionLoadingKey === 'quota:selected' ? '刷新中...' : '批量刷新额度'}
</button>
{canMergeSelectedIntoRouteUnit ? (
<button
type="button"
className="btn btn-ghost oauth-outline-button"
onClick={openRouteUnitModal}
>
合并参与路由
</button>
) : null}
{canSplitSelectedRouteUnit ? (
<button
type="button"
className="btn btn-ghost oauth-outline-button"
onClick={handleDeleteSelectedRouteUnit}
disabled={actionLoadingKey === 'route-unit:delete'}
>
{actionLoadingKey === 'route-unit:delete' ? '拆分中...' : '拆回单体'}
</button>
) : null}
<button
type="button"
className="btn btn-link btn-link-danger"
@@ -1806,7 +2086,7 @@ export default function OAuthManagement() {
<div className="oauth-form-note">
{drawerIntent.mode === 'proxy'
? '这里修改的是账号级 OAuth 代理。保存后会立刻按当前设置重新走一次授权,后续生成的账号也会沿用这份代理配置。若两项都不勾选,则回退到站点代理配置。'
? '这里修改的是账号级 OAuth 代理。点击“保存代理”会立即落库并刷新列表;只有“保存并重新授权”才会重新走授权流程。若两项都不勾选,则回退到站点代理配置。'
: '这里的设置会作用于下一次“连接”或“重新授权”。填写代理地址后,本次 OAuth 换 token 和后续生成的账号都会直接带上这份账号级代理配置;若不勾选,则回退到站点代理配置。'}
</div>
@@ -1855,20 +2135,47 @@ export default function OAuthManagement() {
/>
</div>
<button
type="button"
className="btn btn-primary"
onClick={handleStart}
disabled={!selectedProvider || !selectedProvider.enabled || actionLoadingKey.startsWith('start:')}
>
{actionLoadingKey.startsWith('start:')
? '启动中...'
: drawerIntent.mode === 'proxy'
? '保存代理并重新授权'
{drawerIntent.mode === 'proxy' ? (
<div className="oauth-inline-actions">
<button
type="button"
className="btn btn-primary"
onClick={handleSaveProxy}
disabled={
actionLoadingKey === `save-proxy:${drawerIntent.account.accountId}`
|| actionLoadingKey.startsWith('start:')
}
>
{actionLoadingKey === `save-proxy:${drawerIntent.account.accountId}` ? '保存中...' : '保存代理'}
</button>
<button
type="button"
className="btn btn-ghost"
onClick={handleStart}
disabled={
!selectedProvider
|| !selectedProvider.enabled
|| actionLoadingKey.startsWith('start:')
|| actionLoadingKey === `save-proxy:${drawerIntent.account.accountId}`
}
>
{actionLoadingKey.startsWith('start:') ? '启动中...' : '保存并重新授权'}
</button>
</div>
) : (
<button
type="button"
className="btn btn-primary"
onClick={handleStart}
disabled={!selectedProvider || !selectedProvider.enabled || actionLoadingKey.startsWith('start:')}
>
{actionLoadingKey.startsWith('start:')
? '启动中...'
: drawerIntent.mode === 'rebind'
? `重新授权 ${selectedProvider?.label || ''}`.trim()
: `连接 ${selectedProvider?.label || ''}`.trim()}
</button>
? `重新授权 ${selectedProvider?.label || ''}`.trim()
: `连接 ${selectedProvider?.label || ''}`.trim()}
</button>
)}
</div>
</div>
@@ -2025,6 +2332,52 @@ export default function OAuthManagement() {
</>
)}
</div>
<div className="oauth-form-note">
导入后的账号代理可在这里一次性指定;如果当前运行时已配置系统代理,会默认预选“使用系统级代理”。
</div>
<div className="oauth-toggle-group">
<label className="oauth-toggle">
<input
type="checkbox"
checked={importSystemProxyEnabled}
data-oauth-import-setting="use-system-proxy"
onChange={(event) => {
const checked = !!event.target.checked;
setImportSystemProxyEnabled(checked);
if (checked) {
setImportCustomProxyEnabled(false);
setImportProxyUrl('');
}
}}
/>
<span>使用系统级代理</span>
</label>
<label className="oauth-toggle">
<input
type="checkbox"
checked={importCustomProxyEnabled}
data-oauth-import-setting="use-custom-proxy"
onChange={(event) => {
const checked = !!event.target.checked;
setImportCustomProxyEnabled(checked);
if (checked) setImportSystemProxyEnabled(false);
}}
/>
<span>使用自定义代理</span>
</label>
</div>
<div className="oauth-form-field">
<div className="oauth-field-label">代理地址</div>
<input
type="text"
className="oauth-input"
value={importProxyUrl}
data-oauth-import-setting="proxy-url"
onChange={(event) => setImportProxyUrl(event.target.value)}
placeholder="如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080"
disabled={!importCustomProxyEnabled}
/>
</div>
<div className="oauth-import-copy">或者手动粘贴单个 JSON 内容:</div>
<textarea
className="oauth-textarea oauth-mono"
@@ -2070,6 +2423,56 @@ export default function OAuthManagement() {
</div>
) : null}
</CenteredModal>
<CenteredModal
open={routeUnitModal.open}
onClose={closeRouteUnitModal}
title="创建路由池"
maxWidth={520}
bodyStyle={{ display: 'grid', gap: 16 }}
footer={(
<>
<button type="button" className="btn btn-ghost" onClick={closeRouteUnitModal}>
取消
</button>
<button
type="button"
className="btn btn-primary"
onClick={handleCreateRouteUnit}
disabled={actionLoadingKey === 'route-unit:create' || !asTrimmedString(routeUnitModal.name)}
>
{actionLoadingKey === 'route-unit:create' ? '创建中...' : '创建路由池'}
</button>
</>
)}
>
<div className="oauth-form-field">
<div className="oauth-field-label">路由池名称</div>
<input
type="text"
className="oauth-input"
data-testid="oauth-route-unit-name"
value={routeUnitModal.name}
onChange={(event) => setRouteUnitModal((current) => ({ ...current, name: event.target.value }))}
placeholder="例如 Codex Pool"
/>
</div>
<div className="oauth-form-field">
<div className="oauth-field-label">策略</div>
<ModernSelect
value={routeUnitModal.strategy}
onChange={(value) => setRouteUnitModal((current) => ({
...current,
strategy: String(value || 'round_robin') as OAuthRouteUnitStrategy,
}))}
options={[
{ value: 'round_robin', label: '轮询' },
{ value: 'stick_until_unavailable', label: '单个用到不可用再切' },
]}
placeholder="选择路由池策略"
/>
</div>
</CenteredModal>
</div>
);
}
@@ -78,6 +78,73 @@ function buildChannel(overrides: Partial<RouteChannel> = {}): RouteChannel {
}
describe('RouteCard', () => {
it('renders oauth route unit summary and member labels on expanded channels', () => {
const root = create(
<RouteCard
route={buildRoute({
modelPattern: 'gpt-4.1',
displayName: 'gpt-4.1',
channelCount: 1,
enabledChannelCount: 1,
})}
brand={null}
expanded
onToggleExpand={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onToggleEnabled={vi.fn()}
onClearCooldown={vi.fn()}
clearingCooldown={false}
onRoutingStrategyChange={vi.fn()}
updatingRoutingStrategy={false}
channels={[
buildChannel({
account: { username: 'route-unit-anchor' },
routeUnit: {
id: 'pool-1',
name: 'Codex Pool A',
strategy: 'round_robin',
memberCount: 3,
members: [
{ accountId: 101, username: 'route-unit-anchor', siteName: 'site-a' },
{ accountId: 102, username: 'route-unit-backup', siteName: 'site-b' },
{ accountId: 103, username: 'route-unit-third', siteName: 'site-c' },
],
},
}),
]}
loadingChannels={false}
routeDecision={null}
loadingDecision={false}
candidateView={{ routeCandidates: [], accountOptions: [], tokenOptionsByAccountId: {} }}
channelTokenDraft={{}}
updatingChannel={{}}
savingPriority={false}
onTokenDraftChange={vi.fn()}
onSaveToken={vi.fn()}
onDeleteChannel={vi.fn()}
onToggleChannelEnabled={vi.fn()}
onChannelDragEnd={vi.fn()}
missingTokenSiteItems={[]}
missingTokenGroupItems={[]}
onCreateTokenForMissing={vi.fn()}
onAddChannel={vi.fn()}
onSiteBlockModel={vi.fn()}
expandedSourceGroupMap={{}}
onToggleSourceGroup={vi.fn()}
/>,
);
const text = collectText(root.root);
expect(text).toContain('Codex Pool A');
expect(text).toContain('3 个成员');
expect(text).toContain('轮询');
expect(text).toContain('成员摘要');
expect(text).toContain('route-unit-anchor');
expect(text).toContain('route-unit-backup');
expect(text).toContain('route-unit-third');
});
it('truncates the collapsed regex badge while keeping the group name primary', () => {
const root = create(
<RouteCard
@@ -371,6 +438,70 @@ describe('RouteCard', () => {
expect(p0RailNode.props.style.color).not.toBe(p1RailNode.props.style.color);
});
it('renders oauth route unit summary badges on expanded cards', () => {
const root = create(
<RouteCard
route={buildRoute()}
brand={null}
expanded
onToggleExpand={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onToggleEnabled={vi.fn()}
onClearCooldown={vi.fn()}
clearingCooldown={false}
onRoutingStrategyChange={vi.fn()}
updatingRoutingStrategy={false}
channels={[
buildChannel({
id: 11,
account: { username: 'pool-representative' },
site: { id: 1, name: 'site-a', platform: 'openai' },
...( {
routeUnit: {
id: 7,
name: 'Codex 池',
memberCount: 3,
strategy: 'round_robin',
members: [
{ accountId: 101, username: 'user_a', siteName: 'site-a' },
{ accountId: 102, username: 'user_b', siteName: 'site-b' },
{ accountId: 103, username: 'user_c', siteName: 'site-c' },
],
},
} as any ),
}),
]}
loadingChannels={false}
routeDecision={null}
loadingDecision={false}
candidateView={{ routeCandidates: [], accountOptions: [], tokenOptionsByAccountId: {} }}
channelTokenDraft={{}}
updatingChannel={{}}
savingPriority={false}
onTokenDraftChange={vi.fn()}
onSaveToken={vi.fn()}
onDeleteChannel={vi.fn()}
onToggleChannelEnabled={vi.fn()}
onChannelDragEnd={vi.fn()}
missingTokenSiteItems={[]}
missingTokenGroupItems={[]}
onCreateTokenForMissing={vi.fn()}
onAddChannel={vi.fn()}
onSiteBlockModel={vi.fn()}
expandedSourceGroupMap={{}}
onToggleSourceGroup={vi.fn()}
/>,
);
const text = collectText(root.root);
expect(text).toContain('OAuth 路由池');
expect(text).toContain('Codex 池');
expect(text).toContain('3 个成员');
expect(text).toContain('轮询');
expect(text).toContain('成员摘要');
});
it('uses translate-only rect sorting for flat channel shell rows', () => {
const root = create(
<RouteCard
+46
View File
@@ -25,6 +25,7 @@ import { formatDateTimeMinuteLocal } from '../helpers/checkinLogTime.js';
import type {
RouteSummaryRow,
RouteChannel,
RouteChannelRouteUnit,
RouteDecision,
RouteDecisionCandidate,
MissingTokenRouteSiteActionItem,
@@ -100,6 +101,24 @@ type RouteCardProps = {
onToggleSourceGroup: (groupKey: string) => void;
};
function getRouteUnitStrategyLabel(strategy: string | null | undefined): string {
return strategy === 'stick_until_unavailable' ? '单个用到不可用再切' : '轮询';
}
function collectRouteUnits(channels: RouteChannel[] | undefined): RouteChannelRouteUnit[] {
if (!Array.isArray(channels) || channels.length === 0) return [];
const unitsById = new Map<string, RouteChannelRouteUnit>();
for (const channel of channels) {
const routeUnit = channel.routeUnit;
if (!routeUnit) continue;
const key = String(routeUnit.id);
if (!unitsById.has(key)) {
unitsById.set(key, routeUnit);
}
}
return Array.from(unitsById.values());
}
function PriorityRailNewLayerRow({
id,
highlighted,
@@ -566,6 +585,7 @@ function RouteCardInner({
: undefined;
const showAddChannelButton = !readOnlyRoute && !channelManagementDisabled;
const showMissingTokenHints = !channelManagementDisabled && (missingTokenSiteItems.length > 0 || missingTokenGroupItems.length > 0);
const routeUnits = collectRouteUnits(channels);
const routingStrategyOptions = [
{
value: 'weighted',
@@ -975,6 +995,32 @@ function RouteCardInner({
</div>
) : null}
{routeUnits.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 8 }}>
<div style={{ fontSize: 11.5, color: 'var(--color-text-secondary)' }}>
OAuth 路由池
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
{routeUnits.map((routeUnit) => (
<span
key={`route-unit-${String(routeUnit.id)}`}
className="badge badge-info"
style={{
fontSize: 10.5,
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={`${routeUnit.name?.trim() || 'OAuth 路由池'} · ${routeUnit.memberCount} 个成员 · ${getRouteUnitStrategyLabel(routeUnit.strategy)}`}
>
{(routeUnit.name?.trim() || 'OAuth 路由池')} · {routeUnit.memberCount} 个成员 · {getRouteUnitStrategyLabel(routeUnit.strategy)}
</span>
))}
</div>
</div>
) : null}
{!readOnlyRoute && (
<div
data-testid={compact ? 'compact-route-action-row' : undefined}
@@ -9,6 +9,16 @@ import {
} from './tokenBindingPresentation.js';
import { getChannelDecisionState, getPriorityTagStyle, getProbabilityColor } from './utils.js';
function getRouteUnitStrategyLabel(strategy: string | null | undefined): string {
return strategy === 'stick_until_unavailable' ? '单个用到不可用再切' : '轮询';
}
function formatRouteUnitMemberLabel(member: { accountId: number; username: string | null; siteName: string | null }): string {
const accountLabel = member.username?.trim() || `account-${member.accountId}`;
const siteLabel = member.siteName?.trim();
return siteLabel ? `${accountLabel} @ ${siteLabel}` : accountLabel;
}
export function SortableChannelRow({
channel,
displayPriority,
@@ -87,6 +97,13 @@ export function SortableChannelRow({
accountName: channel.account?.username || `account-${channel.accountId}`,
},
);
const routeUnit = channel.routeUnit ?? null;
const routeUnitName = routeUnit?.name?.trim() || 'OAuth 路由池';
const routeUnitStrategyLabel = routeUnit ? getRouteUnitStrategyLabel(routeUnit.strategy) : '';
const routeUnitMemberSummary = routeUnit?.members?.length
? routeUnit.members.map((member) => formatRouteUnitMemberLabel(member)).join('、')
: null;
const routeUnitMemberSummaryText = routeUnitMemberSummary ? `成员:${routeUnitMemberSummary}` : null;
const [mobileDetailsOpen, setMobileDetailsOpen] = useState(false);
@@ -193,8 +210,36 @@ export function SortableChannelRow({
手动配置
</span>
) : null}
{routeUnit ? (
<>
<span className="badge badge-muted" style={{ fontSize: 10 }}>
OAuth 路由池
</span>
<span className="badge badge-info" style={{ fontSize: 10 }}>
{routeUnitName}
</span>
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{routeUnit.memberCount} 成员
</span>
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{routeUnitStrategyLabel}
</span>
</>
) : null}
</div>
{routeUnitMemberSummaryText ? (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
成员摘要({routeUnit?.memberCount || 0} 个成员 · {routeUnitStrategyLabel})
</span>
<span style={{ fontSize: 11, color: 'var(--color-text-secondary)', lineHeight: 1.45 }}>
{routeUnitMemberSummaryText}
</span>
</div>
) : null}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>选中概率</span>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, minWidth: 96 }}>
@@ -404,6 +449,34 @@ export function SortableChannelRow({
<span className="badge badge-muted" style={{ fontSize: 10 }}>已禁用</span>
) : null}
{routeUnit ? (
<>
<span className="badge badge-muted" style={{ fontSize: 10 }}>
OAuth 路由池
</span>
<span className="badge badge-info" style={{ fontSize: 10 }}>
{routeUnitName}
</span>
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{routeUnit.memberCount} 成员
</span>
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{routeUnitStrategyLabel}
</span>
</>
) : null}
{routeUnitMemberSummaryText ? (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 6, width: '100%', flexWrap: 'wrap' }}>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
成员摘要({routeUnit?.memberCount || 0} 个成员 · {routeUnitStrategyLabel})
</span>
<span style={{ fontSize: 11, color: 'var(--color-text-secondary)', lineHeight: 1.45 }}>
{routeUnitMemberSummaryText}
</span>
</div>
) : null}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', marginTop: mobile ? 0 : 1, flexWrap: 'wrap' }}>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>选中概率</span>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, minWidth: 96 }}>
+17
View File
@@ -7,6 +7,7 @@ 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 OAuthRouteUnitStrategy = 'round_robin' | 'stick_until_unavailable';
export type RouteRowKind = 'persisted' | 'zero_channel';
export type RouteChannelDraft = {
accountId: number;
@@ -14,6 +15,20 @@ export type RouteChannelDraft = {
sourceModel: string;
};
export type RouteChannelRouteUnitMember = {
accountId: number;
username: string | null;
siteName: string | null;
};
export type RouteChannelRouteUnit = {
id: number | string;
name: string | null;
strategy: OAuthRouteUnitStrategy;
memberCount: number;
members?: RouteChannelRouteUnitMember[];
};
export type RouteChannel = {
id: number;
routeId?: number;
@@ -45,6 +60,8 @@ export type RouteChannel = {
enabled: boolean;
isDefault: boolean;
} | null;
oauthRouteUnitId?: number | null;
routeUnit?: RouteChannelRouteUnit | null;
};
export type RouteRow = {
@@ -226,6 +226,137 @@ describe('TokenRoutes grouped source models', () => {
}
});
it('renders oauth route unit summary and members after expanding a pooled route', async () => {
apiMock.getRoutesSummary.mockResolvedValue([
{
id: 31, modelPattern: 'gpt-4.1', displayName: null,
displayIcon: null, modelMapping: null, enabled: true,
channelCount: 1, enabledChannelCount: 1, siteNames: ['codex-oauth'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
apiMock.getRouteChannels.mockResolvedValue([
{
id: 511, routeId: 31, accountId: 901, tokenId: null, sourceModel: 'gpt-4.1',
priority: 0, weight: 1, enabled: true, manualOverride: false,
successCount: 5, failCount: 1,
account: { username: 'route-unit-anchor', credentialMode: 'oauth' },
site: { id: 41, name: 'codex-oauth', platform: 'openai' },
token: null,
routeUnit: {
id: 'pool-31',
name: 'Codex Pool A',
strategy: 'stick_until_unavailable',
memberCount: 3,
members: [
{ accountId: 901, username: 'route-unit-anchor', siteName: 'codex-oauth' },
{ accountId: 902, username: 'route-unit-backup', siteName: 'codex-oauth' },
{ accountId: 903, username: 'route-unit-third', siteName: 'codex-oauth' },
],
},
},
]);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const expandBtn = root.root.find((node) =>
node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
);
await act(async () => {
expandBtn.props.onClick();
});
await flushMicrotasks();
const text = collectText(root.root);
expect(text).toContain('Codex Pool A');
expect(text).toContain('3 个成员');
expect(text).toContain('单个用到不可用再切');
expect(text).toContain('成员摘要');
expect(text).toContain('route-unit-anchor');
expect(text).toContain('route-unit-backup');
expect(text).toContain('route-unit-third');
} finally {
root?.unmount();
}
});
it('shows oauth route unit summary and member details after expanding a pooled route', async () => {
apiMock.getRoutesSummary.mockResolvedValue([
{
id: 1, modelPattern: 'gpt-5-codex', displayName: 'gpt-5-codex',
displayIcon: null, modelMapping: null, enabled: true,
channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
apiMock.getRouteChannels.mockResolvedValue([
{
id: 11, routeId: 1, accountId: 101, tokenId: null, sourceModel: 'gpt-5-codex',
priority: 0, weight: 1, enabled: true, manualOverride: false,
successCount: 0, failCount: 0,
account: { username: 'pool-representative', credentialMode: 'oauth' },
site: { id: 1, name: 'site-a', platform: 'openai' },
token: null,
routeUnit: {
id: 17,
name: 'Codex 池',
memberCount: 3,
strategy: 'stick_until_unavailable',
members: [
{ accountId: 101, username: 'user_a', siteName: 'site-a' },
{ accountId: 102, username: 'user_b', siteName: 'site-b' },
{ accountId: 103, username: 'user_c', siteName: 'site-c' },
],
},
},
]);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const expandBtn = root.root.find((node) =>
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
&& collectText(node).includes('gpt-5-codex'),
);
await act(async () => {
expandBtn.props.onClick();
});
await flushMicrotasks();
const expandedText = collectText(root.root);
expect(expandedText).toContain('OAuth 路由池');
expect(expandedText).toContain('Codex 池');
expect(expandedText).toContain('3 个成员');
expect(expandedText).toContain('单个用到不可用再切');
expect(expandedText).toContain('成员摘要');
expect(expandedText).toContain('user_a @ site-a、user_b @ site-b、user_c @ site-c');
} finally {
root?.unmount();
}
});
it('writes explicit-group priority edits back to source channels and confirms shared-source impact', async () => {
apiMock.getRoutesSummary.mockResolvedValue([
{