[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:
Cita
2026-04-01 12:56:13 +08:00
committed by GitHub
parent 8c3be1a613
commit bafceb855b
27 changed files with 1331 additions and 288 deletions
+143 -16
View File
@@ -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',
+29 -1
View File
@@ -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();
}
});
});