fix: address gemini native and route coverage regressions

This commit is contained in:
ciat-777
2026-03-15 21:47:00 +08:00
parent a64a51aa0c
commit 4b42751953
15 changed files with 1006 additions and 74 deletions
+3 -34
View File
@@ -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') {
+67 -1
View File
@@ -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) {
+40 -16
View File
@@ -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
+11
View File
@@ -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([
{