fix: address gemini native and route coverage regressions
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
buildApiPayload,
|
||||
buildEmbeddingsRequestEnvelope,
|
||||
buildFileUploadRequestEnvelope,
|
||||
buildGeminiNativeConversationProxyEnvelope,
|
||||
buildImagesEditRequestEnvelope,
|
||||
buildImagesGenerationsRequestEnvelope,
|
||||
buildRawProxyRequestEnvelope,
|
||||
@@ -1027,30 +1028,6 @@ export default function ModelTester() {
|
||||
};
|
||||
}, [buildConversationMessagesWithSystem, inputs.max_tokens, inputs.model, inputs.stream, inputs.temperature, inputs.top_p, parameterEnabled.max_tokens, parameterEnabled.temperature, parameterEnabled.top_p]);
|
||||
|
||||
const buildGeminiBodyFromMessages = useCallback((baseMessages: ChatMessage[]) => {
|
||||
const effectiveMessages = buildConversationMessagesWithSystem(baseMessages);
|
||||
const systemContents = effectiveMessages
|
||||
.filter((message) => message.role === 'system' || message.role === 'developer')
|
||||
.map((message) => message.content.trim())
|
||||
.filter(Boolean);
|
||||
const contents = effectiveMessages
|
||||
.filter((message) => message.role !== 'system' && message.role !== 'developer')
|
||||
.map((message) => ({
|
||||
role: message.role === 'assistant' ? 'model' : 'user',
|
||||
parts: [{ text: message.content }],
|
||||
}));
|
||||
|
||||
return {
|
||||
...(systemContents.length > 0 ? { systemInstruction: { parts: [{ text: systemContents.join('\n\n') }] } } : {}),
|
||||
contents,
|
||||
generationConfig: {
|
||||
...(parameterEnabled.temperature ? { temperature: inputs.temperature } : {}),
|
||||
...(parameterEnabled.top_p ? { topP: inputs.top_p } : {}),
|
||||
...(parameterEnabled.max_tokens ? { maxOutputTokens: inputs.max_tokens } : {}),
|
||||
},
|
||||
};
|
||||
}, [buildConversationMessagesWithSystem, inputs.max_tokens, inputs.temperature, inputs.top_p, parameterEnabled.max_tokens, parameterEnabled.temperature, parameterEnabled.top_p]);
|
||||
|
||||
const buildConversationProxyEnvelope = useCallback((baseMessages: ChatMessage[]): ProxyTestEnvelope => {
|
||||
const normalizedMessages = buildConversationMessagesWithSystem(baseMessages);
|
||||
|
||||
@@ -1099,15 +1076,7 @@ export default function ModelTester() {
|
||||
}
|
||||
|
||||
if (inputs.protocol === 'gemini') {
|
||||
return {
|
||||
method: 'POST',
|
||||
path: `/gemini/v1beta/models/${encodeURIComponent(inputs.model)}:generateContent${inputs.stream ? '?alt=sse' : ''}`,
|
||||
requestKind: 'json',
|
||||
stream: inputs.stream,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
jsonBody: buildGeminiBodyFromMessages(baseMessages),
|
||||
};
|
||||
return buildGeminiNativeConversationProxyEnvelope(baseMessages, inputs, parameterEnabled);
|
||||
}
|
||||
|
||||
const openAiEnvelope = buildApiPayload(normalizedMessages, { ...inputs, protocol: 'openai' }, parameterEnabled);
|
||||
@@ -1124,7 +1093,7 @@ export default function ModelTester() {
|
||||
rawMode: false,
|
||||
jsonBody: openAiPayload,
|
||||
};
|
||||
}, [buildApiPayload, buildClaudeBodyFromMessages, buildConversationMessagesWithSystem, buildGeminiBodyFromMessages, buildResponsesBodyFromMessages, customRequestBody, customRequestMode, inputs, parameterEnabled]);
|
||||
}, [buildApiPayload, buildClaudeBodyFromMessages, buildConversationMessagesWithSystem, buildResponsesBodyFromMessages, customRequestBody, customRequestMode, inputs, parameterEnabled]);
|
||||
|
||||
const buildModeProxyEnvelope = useCallback((): ProxyTestEnvelope | null => {
|
||||
if (inputs.mode === 'embeddings') {
|
||||
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
RouteDecision,
|
||||
RouteIconOption,
|
||||
MissingTokenRouteSiteActionItem,
|
||||
MissingTokenGroupRouteSiteActionItem,
|
||||
GroupRouteItem,
|
||||
} from './token-routes/types.js';
|
||||
import {
|
||||
@@ -59,6 +60,7 @@ const EMPTY_ROUTE_CANDIDATE_VIEW: RouteCandidateView = {
|
||||
tokenOptionsByAccountId: {},
|
||||
};
|
||||
const EMPTY_MISSING_ITEMS: MissingTokenRouteSiteActionItem[] = [];
|
||||
const EMPTY_MISSING_GROUP_ITEMS: MissingTokenGroupRouteSiteActionItem[] = [];
|
||||
const ROUTE_ICON_OPTIONS: RouteIconOption[] = [
|
||||
{ value: '', label: '自动品牌图标', description: '按模型匹配规则自动识别品牌', iconText: '✦' },
|
||||
];
|
||||
@@ -68,6 +70,7 @@ export default function TokenRoutes() {
|
||||
const [routeSummaries, setRouteSummaries] = useState<RouteSummaryRow[]>([]);
|
||||
const [modelCandidates, setModelCandidates] = useState<RouteModelCandidatesByModelName>({});
|
||||
const [missingTokenModelsByName, setMissingTokenModelsByName] = useState<MissingTokenModelsByName>({});
|
||||
const [missingTokenGroupModelsByName, setMissingTokenGroupModelsByName] = useState<MissingTokenModelsByName>({});
|
||||
const [endpointTypesByModel, setEndpointTypesByModel] = useState<Record<string, string[]>>({});
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -189,6 +192,9 @@ export default function TokenRoutes() {
|
||||
setMissingTokenModelsByName(
|
||||
normalizeMissingTokenModels((candidateRows?.modelsWithoutToken || {}) as MissingTokenModelsByName),
|
||||
);
|
||||
setMissingTokenGroupModelsByName(
|
||||
normalizeMissingTokenModels((candidateRows?.modelsMissingTokenGroups || {}) as MissingTokenModelsByName),
|
||||
);
|
||||
setEndpointTypesByModel(candidateRows?.endpointTypesByModel || {});
|
||||
const decisionPlaceholder: Record<number, RouteDecision | null> = {};
|
||||
for (const route of summaries) {
|
||||
@@ -586,7 +592,12 @@ export default function TokenRoutes() {
|
||||
|
||||
if (search.trim()) {
|
||||
const q = search.trim().toLowerCase();
|
||||
list = list.filter((route) => route.modelPattern.toLowerCase().includes(q));
|
||||
list = list.filter((route) => {
|
||||
const modelPattern = route.modelPattern.toLowerCase();
|
||||
const displayName = (route.displayName || '').toLowerCase();
|
||||
const title = resolveRouteTitle(route).toLowerCase();
|
||||
return modelPattern.includes(q) || displayName.includes(q) || title.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
@@ -636,6 +647,10 @@ export default function TokenRoutes() {
|
||||
() => buildRouteMissingTokenIndex(routePatterns, missingTokenModelsByName, matchesModelPattern),
|
||||
[routePatterns, missingTokenModelsByName],
|
||||
);
|
||||
const routeMissingTokenGroupIndex = useMemo(
|
||||
() => buildRouteMissingTokenIndex(routePatterns, missingTokenGroupModelsByName, matchesModelPattern),
|
||||
[routePatterns, missingTokenGroupModelsByName],
|
||||
);
|
||||
|
||||
const getRouteCandidateView = (routeId: number): RouteCandidateView => {
|
||||
return routeModelCandidateIndex[routeId] || EMPTY_ROUTE_CANDIDATE_VIEW;
|
||||
@@ -782,6 +797,56 @@ export default function TokenRoutes() {
|
||||
return result;
|
||||
}, [routeMissingTokenIndex]);
|
||||
|
||||
const missingTokenGroupItemsByRouteId = useMemo(() => {
|
||||
const result: Record<number, MissingTokenGroupRouteSiteActionItem[]> = {};
|
||||
for (const routeId of Object.keys(routeMissingTokenGroupIndex).map(Number)) {
|
||||
const missingGroupHints = routeMissingTokenGroupIndex[routeId] || [];
|
||||
const siteMap = new Map<string, MissingTokenGroupRouteSiteActionItem>();
|
||||
for (const hint of missingGroupHints) {
|
||||
for (const account of hint.accounts) {
|
||||
if (!Number.isFinite(account.accountId) || account.accountId <= 0) continue;
|
||||
const siteName = (account.siteName || '').trim() || `site-${account.siteId || 'unknown'}`;
|
||||
const key = `${account.siteId || 0}::${siteName.toLowerCase()}`;
|
||||
const accountLabel = account.username || `account-${account.accountId}`;
|
||||
const missingGroups = Array.isArray(account.missingGroups) ? account.missingGroups : [];
|
||||
const requiredGroups = Array.isArray(account.requiredGroups) ? account.requiredGroups : [];
|
||||
const availableGroups = Array.isArray(account.availableGroups) ? account.availableGroups : [];
|
||||
const existing = siteMap.get(key);
|
||||
if (!existing) {
|
||||
siteMap.set(key, {
|
||||
key,
|
||||
siteName,
|
||||
accountId: account.accountId,
|
||||
accountLabel,
|
||||
missingGroups: [...missingGroups],
|
||||
requiredGroups: [...requiredGroups],
|
||||
availableGroups: [...availableGroups],
|
||||
...(account.groupCoverageUncertain === true ? { groupCoverageUncertain: true } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (account.accountId < existing.accountId) {
|
||||
existing.accountId = account.accountId;
|
||||
existing.accountLabel = accountLabel;
|
||||
}
|
||||
existing.missingGroups = Array.from(new Set([...existing.missingGroups, ...missingGroups]))
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||||
existing.requiredGroups = Array.from(new Set([...existing.requiredGroups, ...requiredGroups]))
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||||
existing.availableGroups = Array.from(new Set([...existing.availableGroups, ...availableGroups]))
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||||
if (account.groupCoverageUncertain === true) {
|
||||
existing.groupCoverageUncertain = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
result[routeId] = Array.from(siteMap.values()).sort((a, b) => (
|
||||
a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' })
|
||||
));
|
||||
}
|
||||
return result;
|
||||
}, [routeMissingTokenGroupIndex]);
|
||||
|
||||
// Stable callbacks for RouteCard memo (use refs to avoid dependency on closure variables)
|
||||
const toggleExpandRef = useRef(toggleExpand);
|
||||
toggleExpandRef.current = toggleExpand;
|
||||
@@ -1075,6 +1140,7 @@ export default function TokenRoutes() {
|
||||
onDeleteChannel={stableDeleteChannel}
|
||||
onChannelDragEnd={stableChannelDragEnd}
|
||||
missingTokenSiteItems={missingTokenSiteItemsByRouteId[route.id] || EMPTY_MISSING_ITEMS}
|
||||
missingTokenGroupItems={missingTokenGroupItemsByRouteId[route.id] || EMPTY_MISSING_GROUP_ITEMS}
|
||||
onCreateTokenForMissing={stableCreateTokenForMissing}
|
||||
onAddChannel={stableAddChannel}
|
||||
expandedSourceGroupMap={expandedSourceGroupMap}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
buildApiPayload,
|
||||
buildEmbeddingsRequestEnvelope,
|
||||
buildFileUploadRequestEnvelope,
|
||||
buildGeminiNativeConversationProxyEnvelope,
|
||||
buildRawProxyRequestEnvelope,
|
||||
buildSearchRequestEnvelope,
|
||||
collectModelTesterModelNames,
|
||||
@@ -321,6 +322,39 @@ describe('modelTesterSession', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('builds gemini native proxy envelope without loading assistant placeholders', () => {
|
||||
const payload = buildGeminiNativeConversationProxyEnvelope(
|
||||
[
|
||||
{ id: 'u1', role: 'user', content: 'hello', createAt: 1 },
|
||||
{ id: 'a1', role: 'assistant', content: '', createAt: 2, status: MESSAGE_STATUS.LOADING },
|
||||
{ id: 'a2', role: 'assistant', content: 'partial', createAt: 3, status: MESSAGE_STATUS.INCOMPLETE },
|
||||
{ id: 'a3', role: 'assistant', content: 'done', createAt: 4, status: MESSAGE_STATUS.COMPLETE },
|
||||
],
|
||||
{
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gemini-2.5-pro',
|
||||
protocol: 'gemini',
|
||||
systemPrompt: 'system text',
|
||||
stream: true,
|
||||
},
|
||||
{
|
||||
...DEFAULT_PARAMETER_ENABLED,
|
||||
max_tokens: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload.path).toBe('/gemini/v1beta/models/gemini-2.5-pro:generateContent?alt=sse');
|
||||
expect(payload.jobMode).toBe(false);
|
||||
expect(payload.jsonBody).toEqual({
|
||||
systemInstruction: { parts: [{ text: 'system text' }] },
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: 'hello' }] },
|
||||
{ role: 'model', parts: [{ text: 'done' }] },
|
||||
],
|
||||
generationConfig: { temperature: 0.7, maxOutputTokens: 4096 },
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes conversation file parts into openai and responses payloads', () => {
|
||||
const message = {
|
||||
id: 'u1',
|
||||
|
||||
@@ -949,6 +949,19 @@ export const buildConversationRequestEnvelope = (
|
||||
jsonBody: buildConversationJsonBody(messages, inputs, parameterEnabled),
|
||||
});
|
||||
|
||||
export const buildGeminiNativeConversationProxyEnvelope = (
|
||||
messages: ChatMessage[],
|
||||
inputs: ModelTesterInputs,
|
||||
parameterEnabled: ParameterEnabled,
|
||||
): TesterProxyEnvelope => {
|
||||
const envelope = buildConversationRequestEnvelope(messages, { ...inputs, protocol: 'gemini' }, parameterEnabled);
|
||||
return {
|
||||
...envelope,
|
||||
path: `/gemini${envelope.path}${inputs.stream ? '?alt=sse' : ''}`,
|
||||
jobMode: false,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildEmbeddingsRequestEnvelope = (
|
||||
inputText: string,
|
||||
inputs: ModelTesterInputs,
|
||||
|
||||
@@ -16,6 +16,12 @@ export function buildVisibleRouteList<T extends RouteListVisibilityItem>(
|
||||
isExactModelPattern: (pattern: string) => boolean,
|
||||
matchesModelPattern: (model: string, pattern: string) => boolean,
|
||||
): T[] {
|
||||
const exactModelNames = new Set(
|
||||
routes
|
||||
.filter((route) => isExactModelPattern(route.modelPattern))
|
||||
.map((route) => (route.modelPattern || '').trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
const coveringGroups = routes.filter((route) => (
|
||||
route.enabled
|
||||
&& !isExactModelPattern(route.modelPattern)
|
||||
@@ -33,6 +39,7 @@ export function buildVisibleRouteList<T extends RouteListVisibilityItem>(
|
||||
|
||||
return !coveringGroups.some((groupRoute) => (
|
||||
groupRoute.id !== route.id
|
||||
&& !exactModelNames.has((groupRoute.displayName || '').trim())
|
||||
&& matchesModelPattern(exactModel, groupRoute.modelPattern)
|
||||
));
|
||||
});
|
||||
|
||||
@@ -3,6 +3,10 @@ export type MissingTokenModelAccount = {
|
||||
username: string | null;
|
||||
siteId: number;
|
||||
siteName: string;
|
||||
missingGroups?: string[];
|
||||
requiredGroups?: string[];
|
||||
availableGroups?: string[];
|
||||
groupCoverageUncertain?: boolean;
|
||||
};
|
||||
|
||||
export type MissingTokenModelsByName = Record<string, MissingTokenModelAccount[]>;
|
||||
@@ -29,11 +33,21 @@ export function normalizeMissingTokenModels(
|
||||
if (!account || !Number.isFinite(account.accountId)) continue;
|
||||
const accountName = (account.username || '').trim();
|
||||
const siteName = String(account.siteName || '').trim();
|
||||
const normalizeLabels = (labels: unknown): string[] => Array.isArray(labels)
|
||||
? Array.from(new Set(labels
|
||||
.map((label) => String(label || '').trim())
|
||||
.filter(Boolean)))
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
|
||||
: [];
|
||||
accountMap.set(account.accountId, {
|
||||
accountId: account.accountId,
|
||||
username: accountName || null,
|
||||
siteId: account.siteId,
|
||||
siteName,
|
||||
...(normalizeLabels(account.missingGroups).length > 0 ? { missingGroups: normalizeLabels(account.missingGroups) } : {}),
|
||||
...(normalizeLabels(account.requiredGroups).length > 0 ? { requiredGroups: normalizeLabels(account.requiredGroups) } : {}),
|
||||
...(normalizeLabels(account.availableGroups).length > 0 ? { availableGroups: normalizeLabels(account.availableGroups) } : {}),
|
||||
...(account.groupCoverageUncertain === true ? { groupCoverageUncertain: true } : {}),
|
||||
});
|
||||
}
|
||||
if (accountMap.size > 0) {
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
RouteDecision,
|
||||
RouteDecisionCandidate,
|
||||
MissingTokenRouteSiteActionItem,
|
||||
MissingTokenGroupRouteSiteActionItem,
|
||||
RouteRoutingStrategy,
|
||||
} from './types.js';
|
||||
import type { RouteCandidateView, RouteTokenOption } from '../helpers/routeModelCandidatesIndex.js';
|
||||
@@ -61,6 +62,7 @@ type RouteCardProps = {
|
||||
onChannelDragEnd: (routeId: number, event: DragEndEvent) => void;
|
||||
// Missing token hints
|
||||
missingTokenSiteItems: MissingTokenRouteSiteActionItem[];
|
||||
missingTokenGroupItems: MissingTokenGroupRouteSiteActionItem[];
|
||||
onCreateTokenForMissing: (accountId: number, modelName: string) => void;
|
||||
// Add channel
|
||||
onAddChannel: (routeId: number) => void;
|
||||
@@ -104,6 +106,7 @@ function RouteCardInner({
|
||||
onDeleteChannel,
|
||||
onChannelDragEnd,
|
||||
missingTokenSiteItems,
|
||||
missingTokenGroupItems,
|
||||
onCreateTokenForMissing,
|
||||
onAddChannel,
|
||||
expandedSourceGroupMap,
|
||||
@@ -298,22 +301,43 @@ function RouteCardInner({
|
||||
</div>
|
||||
|
||||
{/* Missing token hints + Add channel button */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
{missingTokenSiteItems.length > 0 ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', flex: 1 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('待注册站点')}:</span>
|
||||
{missingTokenSiteItems.map((item) => (
|
||||
<button
|
||||
key={`missing-${route.id}-${item.key}`}
|
||||
type="button"
|
||||
onClick={() => onCreateTokenForMissing(item.accountId, route.modelPattern)}
|
||||
className="badge badge-info missing-token-site-tag"
|
||||
data-tooltip={`点击跳转到令牌创建(预选 ${item.siteName}/${item.accountLabel})`}
|
||||
style={{ fontSize: 11, cursor: 'pointer' }}
|
||||
>
|
||||
{item.siteName}
|
||||
</button>
|
||||
))}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
{(missingTokenSiteItems.length > 0 || missingTokenGroupItems.length > 0) ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, flex: 1 }}>
|
||||
{missingTokenSiteItems.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('待注册站点')}:</span>
|
||||
{missingTokenSiteItems.map((item) => (
|
||||
<button
|
||||
key={`missing-${route.id}-${item.key}`}
|
||||
type="button"
|
||||
onClick={() => onCreateTokenForMissing(item.accountId, route.modelPattern)}
|
||||
className="badge badge-info missing-token-site-tag"
|
||||
data-tooltip={`点击跳转到令牌创建(预选 ${item.siteName}/${item.accountLabel})`}
|
||||
style={{ fontSize: 11, cursor: 'pointer' }}
|
||||
>
|
||||
{item.siteName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{missingTokenGroupItems.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('缺少分组')}:</span>
|
||||
{missingTokenGroupItems.map((item) => (
|
||||
<button
|
||||
key={`missing-group-${route.id}-${item.key}`}
|
||||
type="button"
|
||||
onClick={() => onCreateTokenForMissing(item.accountId, route.modelPattern)}
|
||||
className="badge badge-warning missing-token-group-tag"
|
||||
data-tooltip={`缺少分组:${item.missingGroups.join('、') || '未知'}${item.availableGroups.length > 0 ? `;已覆盖:${item.availableGroups.join('、')}` : ''}${item.groupCoverageUncertain ? ';当前分组覆盖存在不确定性' : ''}`}
|
||||
style={{ fontSize: 11, cursor: 'pointer' }}
|
||||
>
|
||||
{item.siteName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : <div />}
|
||||
<button
|
||||
|
||||
@@ -124,6 +124,17 @@ export type MissingTokenRouteSiteActionItem = {
|
||||
accountLabel: string;
|
||||
};
|
||||
|
||||
export type MissingTokenGroupRouteSiteActionItem = {
|
||||
key: string;
|
||||
siteName: string;
|
||||
accountId: number;
|
||||
accountLabel: string;
|
||||
missingGroups: string[];
|
||||
requiredGroups: string[];
|
||||
availableGroups: string[];
|
||||
groupCoverageUncertain?: boolean;
|
||||
};
|
||||
|
||||
export type SortableChannelRowProps = {
|
||||
channel: RouteChannel;
|
||||
decisionCandidate?: RouteDecisionCandidate;
|
||||
|
||||
@@ -289,6 +289,63 @@ describe('TokenRoutes grouped source models', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('renders missing-token-group hints separately from missing-token site tags', async () => {
|
||||
apiMock.getRoutesSummary.mockResolvedValue([
|
||||
{
|
||||
id: 1, modelPattern: 'claude-opus-4-6', displayName: 'claude-opus-4-6',
|
||||
displayIcon: null, modelMapping: null, enabled: true,
|
||||
channelCount: 0, enabledChannelCount: 0, siteNames: [],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
]);
|
||||
apiMock.getModelTokenCandidates.mockResolvedValue({
|
||||
models: {},
|
||||
modelsMissingTokenGroups: {
|
||||
'claude-opus-4-6': [
|
||||
{
|
||||
accountId: 101,
|
||||
username: 'linuxdo_4677',
|
||||
siteId: 11,
|
||||
siteName: '香草api',
|
||||
missingGroups: ['opus'],
|
||||
requiredGroups: ['default', 'opus'],
|
||||
availableGroups: ['default'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
apiMock.getRouteChannels.mockResolvedValue([]);
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
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('缺少分组');
|
||||
expect(text).toContain('香草api');
|
||||
expect(text).not.toContain('待注册站点');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('maps endpoint types to expected brand icons in filter panel', async () => {
|
||||
apiMock.getRoutesSummary.mockResolvedValue([
|
||||
{
|
||||
@@ -518,6 +575,86 @@ describe('TokenRoutes grouped source models', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps exact routes visible when a group display name collides with a real exact model', async () => {
|
||||
apiMock.getRoutesSummary.mockResolvedValue([
|
||||
{
|
||||
id: 1, modelPattern: 'gpt-4o-mini', displayName: 'gpt-4o-mini',
|
||||
displayIcon: null, modelMapping: null, enabled: true,
|
||||
channelCount: 0, enabledChannelCount: 0, siteNames: [],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
{
|
||||
id: 2, modelPattern: 'official/gpt-4o-mini', displayName: 'official/gpt-4o-mini',
|
||||
displayIcon: null, modelMapping: null, enabled: true,
|
||||
channelCount: 0, enabledChannelCount: 0, siteNames: [],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
{
|
||||
id: 3, modelPattern: 're:^(gpt-4o-mini|official/gpt-4o-mini)$', displayName: 'gpt-4o-mini',
|
||||
displayIcon: null, modelMapping: null, enabled: true,
|
||||
channelCount: 0, enabledChannelCount: 0, siteNames: [],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
]);
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const normalizedText = collectText(root.root).replace(/\s+/g, '');
|
||||
expect(normalizedText).toContain('共3条路由');
|
||||
expect(normalizedText).not.toContain('共1条路由');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('searches routes by display name as well as model pattern', async () => {
|
||||
apiMock.getRoutesSummary.mockResolvedValue([
|
||||
{
|
||||
id: 31, modelPattern: 're:^claude-(opus|sonnet)-4-6$', displayName: 'claude-4-6-group',
|
||||
displayIcon: null, modelMapping: null, enabled: true,
|
||||
channelCount: 0, enabledChannelCount: 0, siteNames: [],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
]);
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const searchInput = findInputByPlaceholder(root.root, '搜索模型路由');
|
||||
await act(async () => {
|
||||
searchInput.props.onChange({ target: { value: 'claude-4-6-group' } });
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const normalizedText = collectText(root.root).replace(/\s+/g, '');
|
||||
expect(normalizedText).toContain('共1条路由');
|
||||
expect(normalizedText).not.toContain('没有匹配的路由');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('enters edit mode and seeds the group form with the current route values', async () => {
|
||||
apiMock.getRoutesSummary.mockResolvedValue([
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user