forked from boos/BoosAPI
[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:
@@ -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
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user