forked from boos/BoosAPI
[codex] Add model tester fixed-channel selection (#352)
* feat: add model tester forced channel * fix: keep codex websocket defaults and route refresh checks * fix: scope tester-only forced channel overrides * fix: keep tester hydration and websocket hints internal * fix: reject gemini on legacy tester chat routes * fix: harden forced channel selection edges
This commit is contained in:
@@ -232,6 +232,7 @@ type TestChatRequestPayload = {
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
targetFormat?: 'openai' | 'claude' | 'responses' | 'gemini';
|
||||
stream?: boolean;
|
||||
forcedChannelId?: number | null;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
max_tokens?: number;
|
||||
@@ -257,6 +258,7 @@ export type ProxyTestRequestEnvelope = {
|
||||
stream?: boolean;
|
||||
jobMode?: boolean;
|
||||
rawMode?: boolean;
|
||||
forcedChannelId?: number | null;
|
||||
jsonBody?: unknown;
|
||||
rawJsonText?: string;
|
||||
multipartFields?: Record<string, string>;
|
||||
|
||||
+143
-16
@@ -18,6 +18,7 @@ import {
|
||||
buildSearchRequestEnvelope,
|
||||
buildVideoCreateRequestEnvelope,
|
||||
buildVideoInspectRequestEnvelope,
|
||||
attachForcedChannelToEnvelope,
|
||||
countConversationTurns,
|
||||
collectModelTesterModelNames,
|
||||
createLoadingAssistantMessage,
|
||||
@@ -82,6 +83,11 @@ type UploadState = {
|
||||
};
|
||||
|
||||
type ConversationFileState = ConversationDraftFile;
|
||||
type ForcedChannelOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const POLL_INTERVAL_MS = 1200;
|
||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
@@ -659,6 +665,11 @@ export default function ModelTester() {
|
||||
const [inputs, setInputs] = useState<ModelTesterInputs>(DEFAULT_INPUTS);
|
||||
const [modeState, setModeState] = useState<ModelTesterModeState>(DEFAULT_MODE_STATE);
|
||||
const [parameterEnabled, setParameterEnabled] = useState<ParameterEnabled>(DEFAULT_PARAMETER_ENABLED);
|
||||
const [forcedChannelId, setForcedChannelId] = useState<number | null>(null);
|
||||
const [forcedChannelOptions, setForcedChannelOptions] = useState<ForcedChannelOption[]>([]);
|
||||
const [loadingForcedChannels, setLoadingForcedChannels] = useState(false);
|
||||
const [forcedChannelHint, setForcedChannelHint] = useState('');
|
||||
const [forcedChannelHydrationReady, setForcedChannelHydrationReady] = useState(false);
|
||||
|
||||
const [sending, setSending] = useState(false);
|
||||
const [loadingModels, setLoadingModels] = useState(true);
|
||||
@@ -759,6 +770,7 @@ export default function ModelTester() {
|
||||
setParameterEnabled(restored.parameterEnabled);
|
||||
setPendingPayload(restored.pendingPayload);
|
||||
setPendingJobId(restored.pendingJobId || null);
|
||||
setForcedChannelId(restored.forcedChannelId ?? null);
|
||||
setCustomRequestMode(restored.customRequestMode);
|
||||
setCustomRequestBody(restored.customRequestBody);
|
||||
setShowDebugPanel(restored.showDebugPanel);
|
||||
@@ -817,6 +829,7 @@ export default function ModelTester() {
|
||||
pushDebug('error', '获取模型列表失败。');
|
||||
} finally {
|
||||
setLoadingModels(false);
|
||||
setForcedChannelHydrationReady(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -824,6 +837,72 @@ export default function ModelTester() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!forcedChannelHydrationReady) return;
|
||||
|
||||
if (!inputs.model) {
|
||||
setForcedChannelOptions([]);
|
||||
setForcedChannelHint('');
|
||||
setForcedChannelId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (customRequestMode) {
|
||||
setForcedChannelOptions([]);
|
||||
setForcedChannelHint('自定义请求模式下固定通道不可用。');
|
||||
setForcedChannelId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputs.mode === 'videos.inspect') {
|
||||
setForcedChannelOptions([]);
|
||||
setForcedChannelHint('视频查询/删除不会重新选路,不能固定通道。');
|
||||
setForcedChannelId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoadingForcedChannels(true);
|
||||
setForcedChannelHint('');
|
||||
|
||||
void api.getRouteDecision(inputs.model)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
const candidates = Array.isArray((result as any)?.decision?.candidates)
|
||||
? (result as any).decision.candidates as Array<Record<string, unknown>>
|
||||
: [];
|
||||
const nextOptions = candidates
|
||||
.filter((candidate) => candidate?.eligible === true && typeof candidate?.channelId === 'number')
|
||||
.map((candidate) => ({
|
||||
value: String(candidate.channelId),
|
||||
label: `${candidate.username || `account-${candidate.accountId || 'unknown'}`} @ ${candidate.siteName || 'unknown'} / ${candidate.tokenName || 'default'} (P${candidate.priority ?? 0})`,
|
||||
description: typeof candidate.reason === 'string' && candidate.reason.trim().length > 0
|
||||
? candidate.reason
|
||||
: undefined,
|
||||
}));
|
||||
setForcedChannelOptions(nextOptions);
|
||||
if (nextOptions.length === 0) {
|
||||
setForcedChannelHint('当前模型暂无可固定通道。');
|
||||
}
|
||||
if (typeof forcedChannelId === 'number' && !nextOptions.some((option) => option.value === String(forcedChannelId))) {
|
||||
setForcedChannelId(null);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setForcedChannelOptions([]);
|
||||
setForcedChannelHint('加载固定通道候选失败。');
|
||||
setForcedChannelId(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingForcedChannels(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [customRequestMode, forcedChannelHydrationReady, inputs.mode, inputs.model]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inputs.model) return;
|
||||
localStorage.setItem(MODEL_TESTER_STORAGE_KEY, serializeModelTesterSession({
|
||||
@@ -845,6 +924,7 @@ export default function ModelTester() {
|
||||
},
|
||||
pendingPayload,
|
||||
pendingJobId,
|
||||
forcedChannelId,
|
||||
customRequestMode,
|
||||
customRequestBody,
|
||||
showDebugPanel,
|
||||
@@ -854,6 +934,7 @@ export default function ModelTester() {
|
||||
activeDebugTab,
|
||||
customRequestBody,
|
||||
customRequestMode,
|
||||
forcedChannelId,
|
||||
input,
|
||||
inputs,
|
||||
messages,
|
||||
@@ -1158,6 +1239,19 @@ export default function ModelTester() {
|
||||
};
|
||||
}, [buildApiPayload, buildClaudeBodyFromMessages, buildConversationMessagesWithSystem, buildResponsesBodyFromMessages, customRequestBody, customRequestMode, inputs, parameterEnabled]);
|
||||
|
||||
const forcedChannelSelectOptions = useMemo<ForcedChannelOption[]>(() => [
|
||||
{
|
||||
value: '__auto__',
|
||||
label: '自动选路(默认)',
|
||||
description: '按当前路由正常选择通道',
|
||||
},
|
||||
...forcedChannelOptions,
|
||||
], [forcedChannelOptions]);
|
||||
|
||||
const attachEnvelopeForcedChannel = useCallback((envelope: ProxyTestEnvelope) => (
|
||||
attachForcedChannelToEnvelope(envelope, forcedChannelId)
|
||||
), [forcedChannelId]);
|
||||
|
||||
const buildModeProxyEnvelope = useCallback((): ProxyTestEnvelope | null => {
|
||||
if (inputs.mode === 'embeddings') {
|
||||
const trimmed = embeddingInputText.trim();
|
||||
@@ -1298,7 +1392,8 @@ export default function ModelTester() {
|
||||
|
||||
const previewPayload = useMemo(() => {
|
||||
if (inputs.mode !== 'conversation') {
|
||||
return buildModeProxyEnvelope();
|
||||
const envelope = buildModeProxyEnvelope();
|
||||
return envelope ? attachEnvelopeForcedChannel(envelope) : null;
|
||||
}
|
||||
if (customRequestMode) {
|
||||
const raw = customRequestBody.trim();
|
||||
@@ -1310,10 +1405,10 @@ export default function ModelTester() {
|
||||
}
|
||||
}
|
||||
if (inputs.protocol === 'gemini') {
|
||||
return buildConversationProxyEnvelope(messages);
|
||||
return attachEnvelopeForcedChannel(buildConversationProxyEnvelope(messages));
|
||||
}
|
||||
return buildApiPayload(buildConversationMessagesWithSystem(messages), inputs, parameterEnabled);
|
||||
}, [buildConversationMessagesWithSystem, buildConversationProxyEnvelope, buildModeProxyEnvelope, customRequestBody, customRequestMode, inputs, messages, parameterEnabled]);
|
||||
return attachEnvelopeForcedChannel(buildApiPayload(buildConversationMessagesWithSystem(messages), inputs, parameterEnabled));
|
||||
}, [attachEnvelopeForcedChannel, buildConversationMessagesWithSystem, buildConversationProxyEnvelope, buildModeProxyEnvelope, customRequestBody, customRequestMode, inputs, messages, parameterEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
setDebugPreview(formatJson(previewPayload));
|
||||
@@ -1737,39 +1832,43 @@ export default function ModelTester() {
|
||||
payload: TestChatPayload,
|
||||
options?: { syncedCustomBody?: string },
|
||||
) => {
|
||||
const effectivePayload = attachEnvelopeForcedChannel(payload);
|
||||
setMessages(nextMessages);
|
||||
if (options?.syncedCustomBody !== undefined) {
|
||||
setCustomRequestBody(options.syncedCustomBody);
|
||||
}
|
||||
setError('');
|
||||
setPendingPayload(payload);
|
||||
setDebugRequest(formatJson(payload));
|
||||
setPendingPayload(effectivePayload);
|
||||
setDebugRequest(formatJson(effectivePayload));
|
||||
setDebugResponse('');
|
||||
setActiveDebugTab(DEBUG_TABS.REQUEST);
|
||||
setDebugTimestamp(new Date().toISOString());
|
||||
|
||||
if (payload.stream) {
|
||||
await startStream(payload);
|
||||
if (effectivePayload.stream) {
|
||||
await startStream(effectivePayload);
|
||||
} else {
|
||||
await startChatJob(payload);
|
||||
await startChatJob(effectivePayload);
|
||||
}
|
||||
}, [startChatJob, startStream]);
|
||||
}, [attachEnvelopeForcedChannel, startChatJob, startStream]);
|
||||
|
||||
const dispatchProxyEnvelope = useCallback(async (envelope: ProxyTestEnvelope, nextMessages?: ChatMessage[]) => {
|
||||
const effectiveEnvelope = attachEnvelopeForcedChannel(envelope);
|
||||
setError('');
|
||||
setDebugRequest(formatJson(envelope.rawMode ? { path: envelope.path, rawJsonText: envelope.rawJsonText } : envelope));
|
||||
setDebugRequest(formatJson(effectiveEnvelope.rawMode
|
||||
? { path: effectiveEnvelope.path, rawJsonText: effectiveEnvelope.rawJsonText, forcedChannelId: effectiveEnvelope.forcedChannelId }
|
||||
: effectiveEnvelope));
|
||||
setDebugResponse('');
|
||||
setActiveDebugTab(DEBUG_TABS.REQUEST);
|
||||
setDebugTimestamp(new Date().toISOString());
|
||||
|
||||
if (envelope.stream && nextMessages) {
|
||||
await startProxyStream(envelope, nextMessages);
|
||||
if (effectiveEnvelope.stream && nextMessages) {
|
||||
await startProxyStream(effectiveEnvelope, nextMessages);
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
const result = await api.proxyTest(envelope);
|
||||
const result = await api.proxyTest(effectiveEnvelope);
|
||||
setDebugResponse(formatJson(result));
|
||||
setActiveDebugTab(DEBUG_TABS.RESPONSE);
|
||||
setNonConversationResult(result);
|
||||
@@ -1779,7 +1878,7 @@ export default function ModelTester() {
|
||||
}
|
||||
|
||||
setError('');
|
||||
pushDebug('info', `代理请求成功:${envelope.path}`);
|
||||
pushDebug('info', `代理请求成功:${effectiveEnvelope.path}`);
|
||||
} catch (requestError: any) {
|
||||
const message = requestError?.message || '请求失败';
|
||||
if (nextMessages) {
|
||||
@@ -1792,7 +1891,7 @@ export default function ModelTester() {
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [pushDebug, startProxyStream]);
|
||||
}, [attachEnvelopeForcedChannel, pushDebug, startProxyStream]);
|
||||
|
||||
const buildPayloadWithMessages = useCallback((nextMessages: ChatMessage[]): {
|
||||
payload: TestChatPayload | null;
|
||||
@@ -2380,6 +2479,34 @@ export default function ModelTester() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6, fontWeight: 600 }}>
|
||||
固定通道
|
||||
</div>
|
||||
<ModernSelect
|
||||
value={typeof forcedChannelId === 'number' ? String(forcedChannelId) : '__auto__'}
|
||||
onChange={(next) => {
|
||||
if (!next || next === '__auto__') {
|
||||
setForcedChannelId(null);
|
||||
return;
|
||||
}
|
||||
const parsed = Number.parseInt(next, 10);
|
||||
setForcedChannelId(Number.isFinite(parsed) && parsed > 0 ? parsed : null);
|
||||
}}
|
||||
options={forcedChannelSelectOptions}
|
||||
placeholder={loadingForcedChannels ? '加载通道中...' : '自动选路(默认)'}
|
||||
disabled={customRequestMode || inputs.mode === 'videos.inspect' || loadingForcedChannels}
|
||||
emptyLabel="当前模型暂无可固定通道"
|
||||
menuMaxHeight={300}
|
||||
/>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||
{forcedChannelHint
|
||||
|| (typeof forcedChannelId === 'number'
|
||||
? `已固定到通道 #${forcedChannelId},失败不会自动切换。`
|
||||
: '默认自动选路;如需单独排查,可固定到一个候选通道。')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inputs.mode === 'conversation' && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6, fontWeight: 600 }}>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
buildGeminiNativeConversationProxyEnvelope,
|
||||
buildRawProxyRequestEnvelope,
|
||||
buildSearchRequestEnvelope,
|
||||
attachForcedChannelToEnvelope,
|
||||
collectModelTesterModelNames,
|
||||
countConversationTurns,
|
||||
createConversationUserMessage,
|
||||
@@ -74,9 +75,11 @@ describe('modelTesterSession', () => {
|
||||
stream: false,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
forcedChannelId: 44,
|
||||
jsonBody: { model: '__search', query: 'hello', max_results: 7 },
|
||||
},
|
||||
pendingJobId: 'job-1',
|
||||
forcedChannelId: 44,
|
||||
customRequestMode: true,
|
||||
customRequestBody: '{"model":"gemini-2.5-pro","contents":[]}',
|
||||
showDebugPanel: true,
|
||||
@@ -188,6 +191,29 @@ describe('modelTesterSession', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('attaches a forced channel id to tester envelopes without mutating the request body', () => {
|
||||
const base = buildEmbeddingsRequestEnvelope('hello', {
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'text-embedding-3-small',
|
||||
});
|
||||
|
||||
const payload = attachForcedChannelToEnvelope(base, 42);
|
||||
|
||||
expect(payload).toEqual({
|
||||
method: 'POST',
|
||||
path: '/v1/embeddings',
|
||||
requestKind: 'json',
|
||||
stream: false,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
forcedChannelId: 42,
|
||||
jsonBody: {
|
||||
model: 'text-embedding-3-small',
|
||||
input: 'hello',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('builds multipart upload envelopes for /v1/files', () => {
|
||||
expect(buildFileUploadRequestEnvelope({
|
||||
name: 'paper.pdf',
|
||||
|
||||
@@ -93,6 +93,7 @@ export type TesterProxyEnvelope = {
|
||||
stream: boolean;
|
||||
jobMode: boolean;
|
||||
rawMode: boolean;
|
||||
forcedChannelId?: number | null;
|
||||
jsonBody?: unknown;
|
||||
rawJsonText?: string;
|
||||
multipartFields?: Record<string, string>;
|
||||
@@ -146,6 +147,7 @@ export type ModelTesterSessionState = {
|
||||
conversationFiles: ConversationDraftFile[];
|
||||
pendingPayload: TesterProxyEnvelope | null;
|
||||
pendingJobId?: string | null;
|
||||
forcedChannelId?: number | null;
|
||||
customRequestMode: boolean;
|
||||
customRequestBody: string;
|
||||
showDebugPanel: boolean;
|
||||
@@ -233,6 +235,13 @@ const toNullableFiniteNumber = (value: unknown): number | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const toPositiveInteger = (value: unknown): number | null => {
|
||||
const parsed = toNullableFiniteNumber(value);
|
||||
if (parsed === null) return null;
|
||||
const normalized = Math.trunc(parsed);
|
||||
return normalized > 0 ? normalized : null;
|
||||
};
|
||||
|
||||
const sanitizeString = (value: unknown, fallback = ''): string =>
|
||||
typeof value === 'string' ? value : fallback;
|
||||
|
||||
@@ -838,6 +847,8 @@ const parsePendingPayload = (
|
||||
|
||||
if ('jsonBody' in value) pending.jsonBody = value.jsonBody;
|
||||
if (typeof value.rawJsonText === 'string') pending.rawJsonText = value.rawJsonText;
|
||||
const forcedChannelId = toPositiveInteger(value.forcedChannelId);
|
||||
if (forcedChannelId !== null) pending.forcedChannelId = forcedChannelId;
|
||||
if (isRecord(value.multipartFields)) {
|
||||
pending.multipartFields = Object.fromEntries(
|
||||
Object.entries(value.multipartFields)
|
||||
@@ -1152,6 +1163,7 @@ export const parseModelTesterSession = (raw: string | null): ModelTesterSessionS
|
||||
pendingPayload: parsePendingPayload(parsed.pendingPayload, inputs, parameterEnabled),
|
||||
customRequestMode: toBoolean(parsed.customRequestMode, false),
|
||||
customRequestBody: typeof parsed.customRequestBody === 'string' ? parsed.customRequestBody : '',
|
||||
forcedChannelId: toPositiveInteger(parsed.forcedChannelId),
|
||||
showDebugPanel: toBoolean(parsed.showDebugPanel, false),
|
||||
activeDebugTab: typeof parsed.activeDebugTab === 'string' && VALID_DEBUG_TABS.has(parsed.activeDebugTab)
|
||||
? parsed.activeDebugTab as DebugTab
|
||||
@@ -1341,6 +1353,23 @@ export const buildApiPayload = (
|
||||
): TesterProxyEnvelope =>
|
||||
buildConversationRequestEnvelope(messages, inputs, parameterEnabled);
|
||||
|
||||
export const attachForcedChannelToEnvelope = (
|
||||
envelope: TesterProxyEnvelope,
|
||||
forcedChannelId?: number | null,
|
||||
): TesterProxyEnvelope => {
|
||||
const normalizedForcedChannelId = toPositiveInteger(forcedChannelId);
|
||||
if (normalizedForcedChannelId === null) {
|
||||
if (!('forcedChannelId' in envelope)) return envelope;
|
||||
const { forcedChannelId: _forcedChannelId, ...rest } = envelope;
|
||||
return rest;
|
||||
}
|
||||
|
||||
return {
|
||||
...envelope,
|
||||
forcedChannelId: normalizedForcedChannelId,
|
||||
};
|
||||
};
|
||||
|
||||
export const parseCustomRequestBody = (raw: string): Record<string, unknown> | null => {
|
||||
if (!raw.trim()) return null;
|
||||
try {
|
||||
@@ -1496,4 +1525,3 @@ export const findLastLoadingAssistantIndex = (messages: ChatMessage[]): number =
|
||||
|
||||
export const countConversationTurns = (messages: ChatMessage[]): number =>
|
||||
messages.reduce((turns, message) => turns + (message.role === 'user' ? 1 : 0), 0);
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('ModelTester forced channel architecture', () => {
|
||||
it('wires a fixed-channel selector through route decisions and tester envelopes', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/ModelTester.tsx'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
expect(source).toContain('固定通道');
|
||||
expect(source).toContain('api.getRouteDecision(inputs.model)');
|
||||
expect(source).toContain('forcedChannelId');
|
||||
expect(source).toContain('attachForcedChannelToEnvelope');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import ModelTester from './ModelTester.js';
|
||||
import {
|
||||
DEBUG_TABS,
|
||||
DEFAULT_INPUTS,
|
||||
DEFAULT_MODE_STATE,
|
||||
DEFAULT_PARAMETER_ENABLED,
|
||||
MODEL_TESTER_STORAGE_KEY,
|
||||
serializeModelTesterSession,
|
||||
} from './helpers/modelTesterSession.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getModelsMarketplace: vi.fn(),
|
||||
getRoutes: vi.fn(),
|
||||
getRouteDecision: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('../authSession.js', () => ({
|
||||
clearAuthSession: vi.fn(),
|
||||
getAuthToken: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock('./model-tester/ConversationComposer.js', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('./model-tester/DebugPanel.js', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../components/useAnimatedVisibility.js', () => ({
|
||||
useAnimatedVisibility: () => ({ shouldRender: false, isVisible: false }),
|
||||
}));
|
||||
|
||||
vi.mock('../components/useIsMobile.js', () => ({
|
||||
useIsMobile: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('../i18n.js', () => ({
|
||||
tr: (value: string) => value,
|
||||
}));
|
||||
|
||||
function collectText(node: any): string {
|
||||
const children = node?.children || [];
|
||||
return children.map((child: any) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('ModelTester fixed channel behavior', () => {
|
||||
const originalLocalStorage = globalThis.localStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getModelsMarketplace.mockResolvedValue({
|
||||
models: [
|
||||
{ name: 'gpt-4o-mini' },
|
||||
],
|
||||
});
|
||||
apiMock.getRoutes.mockResolvedValue([]);
|
||||
apiMock.getRouteDecision.mockResolvedValue({
|
||||
decision: {
|
||||
candidates: [
|
||||
{
|
||||
channelId: 77,
|
||||
accountId: 12,
|
||||
username: 'tester',
|
||||
siteName: 'site-a',
|
||||
tokenName: 'default',
|
||||
priority: 0,
|
||||
eligible: true,
|
||||
reason: 'ok',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const session = serializeModelTesterSession({
|
||||
input: '',
|
||||
inputs: {
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gpt-4o-mini',
|
||||
},
|
||||
parameterEnabled: DEFAULT_PARAMETER_ENABLED,
|
||||
messages: [],
|
||||
conversationFiles: [],
|
||||
pendingPayload: null,
|
||||
pendingJobId: null,
|
||||
forcedChannelId: 77,
|
||||
customRequestMode: false,
|
||||
customRequestBody: '',
|
||||
showDebugPanel: false,
|
||||
activeDebugTab: DEBUG_TABS.PREVIEW,
|
||||
modeState: DEFAULT_MODE_STATE,
|
||||
});
|
||||
|
||||
const storage = new Map<string, string>([
|
||||
[MODEL_TESTER_STORAGE_KEY, session],
|
||||
]);
|
||||
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: vi.fn((key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
storage.delete(key);
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalLocalStorage) {
|
||||
vi.stubGlobal('localStorage', originalLocalStorage);
|
||||
} else {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('keeps the restored fixed channel selected through initial model hydration', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(<ModelTester />);
|
||||
});
|
||||
await vi.waitFor(async () => {
|
||||
await flushMicrotasks();
|
||||
expect(apiMock.getRouteDecision).toHaveBeenCalledTimes(1);
|
||||
expect(apiMock.getRouteDecision).toHaveBeenCalledWith('gpt-4o-mini');
|
||||
expect(collectText(root.root)).toContain('已固定到通道 #77,失败不会自动切换。');
|
||||
});
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user