[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:
@@ -10,22 +10,33 @@ function expectNoDirectModelServiceRouteRefresh(source: string): void {
|
||||
expect(source).not.toMatch(/import\s*\{[^}]*\brebuildTokenRoutesFromAvailability\b[^}]*\}\s*from\s*['"][^'"]*modelService\.js['"]/m);
|
||||
}
|
||||
|
||||
function expectImportsRouteRefreshWorkflow(source: string): void {
|
||||
expect(source).toMatch(
|
||||
/import\s+\*\s+as\s+routeRefreshWorkflow\s+from\s+['"][^'"]*routeRefreshWorkflow\.js['"]/m,
|
||||
);
|
||||
}
|
||||
|
||||
function expectCallsSelectProxyChannelForAttempt(source: string): void {
|
||||
expect(source).toMatch(/\bselectProxyChannelForAttempt\s*\(/);
|
||||
}
|
||||
|
||||
function expectCallsRebuildRoutesOnly(source: string): void {
|
||||
expect(source).toMatch(/\brouteRefreshWorkflow\.rebuildRoutesOnly\s*\(/);
|
||||
}
|
||||
|
||||
describe('route refresh workflow architecture boundaries', () => {
|
||||
it('keeps api controllers on the shared route refresh workflow instead of modelService', () => {
|
||||
const tokensSource = readSource('./tokens.ts');
|
||||
const settingsSource = readSource('./settings.ts');
|
||||
const statsSource = readSource('./stats.ts');
|
||||
|
||||
expect(tokensSource).toContain("from '../../services/routeRefreshWorkflow.js'");
|
||||
expect(tokensSource).not.toContain("from '../../services/modelService.js'");
|
||||
for (const source of [tokensSource, settingsSource, statsSource]) {
|
||||
expectImportsRouteRefreshWorkflow(source);
|
||||
expectNoDirectModelServiceRouteRefresh(source);
|
||||
}
|
||||
|
||||
expect(settingsSource).toContain("from '../../services/routeRefreshWorkflow.js'");
|
||||
expect(settingsSource).not.toContain("from '../../services/modelService.js'");
|
||||
|
||||
expect(statsSource).toContain("from '../../services/routeRefreshWorkflow.js'");
|
||||
expectNoDirectModelServiceRouteRefresh(statsSource);
|
||||
expect(tokensSource).toContain('const rebuild = await routeRefreshWorkflow.rebuildRoutesOnly();');
|
||||
expect(statsSource).toContain('const rebuild = await routeRefreshWorkflow.rebuildRoutesOnly();');
|
||||
expectCallsRebuildRoutesOnly(tokensSource);
|
||||
expectCallsRebuildRoutesOnly(statsSource);
|
||||
});
|
||||
|
||||
it('keeps proxy fallback refreshes and scheduler hooks on the route refresh workflow', () => {
|
||||
@@ -39,6 +50,12 @@ describe('route refresh workflow architecture boundaries', () => {
|
||||
const oauthServiceSource = readSource('../../services/oauth/service.ts');
|
||||
const sharedSurfaceSource = readSource('../../proxy-core/surfaces/sharedSurface.ts');
|
||||
const geminiSurfaceSource = readSource('../../proxy-core/surfaces/geminiSurface.ts');
|
||||
const channelSelectionSource = readSource('../../proxy-core/channelSelection.ts');
|
||||
|
||||
for (const source of [schedulerSource, oauthServiceSource, channelSelectionSource]) {
|
||||
expectImportsRouteRefreshWorkflow(source);
|
||||
expectNoDirectModelServiceRouteRefresh(source);
|
||||
}
|
||||
|
||||
for (const source of [
|
||||
completionsSource,
|
||||
@@ -47,13 +64,24 @@ describe('route refresh workflow architecture boundaries', () => {
|
||||
modelsRouteSource,
|
||||
searchSource,
|
||||
videosSource,
|
||||
schedulerSource,
|
||||
oauthServiceSource,
|
||||
sharedSurfaceSource,
|
||||
geminiSurfaceSource,
|
||||
]) {
|
||||
expect(source).toContain('routeRefreshWorkflow');
|
||||
expectNoDirectModelServiceRouteRefresh(source);
|
||||
}
|
||||
|
||||
for (const source of [
|
||||
completionsSource,
|
||||
embeddingsSource,
|
||||
imagesSource,
|
||||
searchSource,
|
||||
videosSource,
|
||||
sharedSurfaceSource,
|
||||
]) {
|
||||
expectCallsSelectProxyChannelForAttempt(source);
|
||||
}
|
||||
|
||||
expect(geminiSurfaceSource).toMatch(/\bselectGeminiChannel\s*\(/);
|
||||
expect(geminiSurfaceSource).toMatch(/\bselectNextGeminiProbeChannel\s*\(/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,6 +73,7 @@ describe('testRoutes proxy tester transport', () => {
|
||||
stream: false,
|
||||
jobMode: false,
|
||||
rawMode: true,
|
||||
forcedChannelId: 77,
|
||||
rawJsonText: JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
include: ['reasoning.encrypted_content'],
|
||||
@@ -85,6 +86,10 @@ describe('testRoutes proxy tester transport', () => {
|
||||
expect(response.statusCode).toBe(200);
|
||||
const [url, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toMatch(/\/v1\/responses$/);
|
||||
expect(requestInit.headers).toMatchObject({
|
||||
'x-metapi-tester-request': '1',
|
||||
'x-metapi-tester-forced-channel-id': '77',
|
||||
});
|
||||
expect(JSON.parse(String(requestInit.body))).toEqual({
|
||||
model: 'gpt-4o-mini',
|
||||
include: ['reasoning.encrypted_content'],
|
||||
@@ -109,6 +114,7 @@ describe('testRoutes proxy tester transport', () => {
|
||||
payload: {
|
||||
model: 'claude-3-7-sonnet',
|
||||
targetFormat: 'claude',
|
||||
forcedChannelId: 55,
|
||||
messages: [
|
||||
{ role: 'system', content: 'be concise' },
|
||||
{ role: 'user', content: 'ping' },
|
||||
@@ -119,6 +125,10 @@ describe('testRoutes proxy tester transport', () => {
|
||||
expect(response.statusCode).toBe(200);
|
||||
const [url, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toMatch(/\/v1\/messages$/);
|
||||
expect(requestInit.headers).toMatchObject({
|
||||
'x-metapi-tester-request': '1',
|
||||
'x-metapi-tester-forced-channel-id': '55',
|
||||
});
|
||||
expect(JSON.parse(String(requestInit.body))).toEqual({
|
||||
model: 'claude-3-7-sonnet',
|
||||
stream: false,
|
||||
|
||||
@@ -182,4 +182,24 @@ describe('/api/test proxy tester routes', () => {
|
||||
max_output_tokens: 4096,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects gemini on legacy /api/test/chat wrapper until a native mapping exists', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/test/chat',
|
||||
payload: {
|
||||
model: 'gemini-2.5-flash',
|
||||
targetFormat: 'gemini',
|
||||
messages: [
|
||||
{ role: 'user', content: 'hello' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({
|
||||
error: 'targetFormat=gemini is not supported on legacy /api/test/chat routes; use the proxy tester Gemini path instead',
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,11 @@ import { randomUUID } from 'node:crypto';
|
||||
import { fetch, File as UndiciFile, FormData as UndiciFormData } from 'undici';
|
||||
import { config } from '../../config.js';
|
||||
import { readRuntimeResponseText } from '../../proxy-core/executors/types.js';
|
||||
import {
|
||||
normalizeForcedChannelId,
|
||||
TESTER_FORCED_CHANNEL_HEADER,
|
||||
TESTER_REQUEST_HEADER,
|
||||
} from '../../proxy-core/channelSelection.js';
|
||||
|
||||
type UndiciRequestInit = Parameters<typeof fetch>[1];
|
||||
|
||||
@@ -14,6 +19,7 @@ type TestChatRequestBody = {
|
||||
messages?: TestChatMessage[];
|
||||
targetFormat?: TestTargetFormat;
|
||||
stream?: boolean;
|
||||
forcedChannelId?: number;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
max_tokens?: number;
|
||||
@@ -27,6 +33,7 @@ type ValidatedTestChatPayload = {
|
||||
messages: TestChatMessage[];
|
||||
targetFormat: TestTargetFormat;
|
||||
stream?: boolean;
|
||||
forcedChannelId?: number | null;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
max_tokens?: number;
|
||||
@@ -52,6 +59,7 @@ type ProxyTestEnvelope = {
|
||||
stream?: boolean;
|
||||
jobMode?: boolean;
|
||||
rawMode?: boolean;
|
||||
forcedChannelId?: number | null;
|
||||
jsonBody?: unknown;
|
||||
rawJsonText?: string;
|
||||
multipartFields?: Record<string, string>;
|
||||
@@ -65,6 +73,7 @@ type ValidatedProxyTestEnvelope = {
|
||||
stream: boolean;
|
||||
jobMode: boolean;
|
||||
rawMode: boolean;
|
||||
forcedChannelId?: number | null;
|
||||
jsonBody?: unknown;
|
||||
rawJsonText?: string;
|
||||
multipartFields?: Record<string, string>;
|
||||
@@ -162,19 +171,25 @@ function validateLegacyPayload(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (body.targetFormat === 'gemini') {
|
||||
reply.code(400).send({
|
||||
error: 'targetFormat=gemini is not supported on legacy /api/test/chat routes; use the proxy tester Gemini path instead',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetFormat: TestTargetFormat = body.targetFormat === 'claude'
|
||||
? 'claude'
|
||||
: body.targetFormat === 'responses'
|
||||
? 'responses'
|
||||
: body.targetFormat === 'gemini'
|
||||
? 'gemini'
|
||||
: 'openai';
|
||||
: 'openai';
|
||||
|
||||
return {
|
||||
model: body.model,
|
||||
messages: body.messages,
|
||||
targetFormat,
|
||||
stream: body.stream,
|
||||
forcedChannelId: normalizeForcedChannelId(body.forcedChannelId),
|
||||
temperature: body.temperature,
|
||||
top_p: body.top_p,
|
||||
max_tokens: body.max_tokens,
|
||||
@@ -289,6 +304,7 @@ function convertLegacyPayloadToEnvelope(
|
||||
stream: forceStream,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
forcedChannelId: payload.forcedChannelId ?? null,
|
||||
jsonBody: convertOpenAiPayloadToClaudeBody(payload, forceStream),
|
||||
};
|
||||
}
|
||||
@@ -301,6 +317,7 @@ function convertLegacyPayloadToEnvelope(
|
||||
stream: forceStream,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
forcedChannelId: payload.forcedChannelId ?? null,
|
||||
jsonBody: convertOpenAiPayloadToResponsesBody(payload, forceStream),
|
||||
};
|
||||
}
|
||||
@@ -312,9 +329,29 @@ function convertLegacyPayloadToEnvelope(
|
||||
stream: forceStream,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
forcedChannelId: payload.forcedChannelId ?? null,
|
||||
jsonBody: {
|
||||
...payload,
|
||||
model: payload.model,
|
||||
messages: payload.messages,
|
||||
stream: forceStream,
|
||||
...(typeof payload.temperature === 'number' && Number.isFinite(payload.temperature)
|
||||
? { temperature: payload.temperature }
|
||||
: {}),
|
||||
...(typeof payload.top_p === 'number' && Number.isFinite(payload.top_p)
|
||||
? { top_p: payload.top_p }
|
||||
: {}),
|
||||
...(typeof payload.max_tokens === 'number' && Number.isFinite(payload.max_tokens)
|
||||
? { max_tokens: payload.max_tokens }
|
||||
: {}),
|
||||
...(typeof payload.frequency_penalty === 'number' && Number.isFinite(payload.frequency_penalty)
|
||||
? { frequency_penalty: payload.frequency_penalty }
|
||||
: {}),
|
||||
...(typeof payload.presence_penalty === 'number' && Number.isFinite(payload.presence_penalty)
|
||||
? { presence_penalty: payload.presence_penalty }
|
||||
: {}),
|
||||
...(typeof payload.seed === 'number' && Number.isFinite(payload.seed)
|
||||
? { seed: payload.seed }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -356,6 +393,7 @@ function validateProxyEnvelope(
|
||||
stream: body.stream === true,
|
||||
jobMode: body.jobMode === true,
|
||||
rawMode: body.rawMode === true,
|
||||
forcedChannelId: normalizeForcedChannelId(body.forcedChannelId),
|
||||
};
|
||||
|
||||
if (requestKind === 'json') {
|
||||
@@ -472,6 +510,10 @@ async function buildUpstreamRequestInit(
|
||||
forceStream: boolean,
|
||||
): Promise<UndiciRequestInit> {
|
||||
const headers: Record<string, string> = createDefaultHeadersForPath(envelope.path);
|
||||
headers[TESTER_REQUEST_HEADER] = '1';
|
||||
if (typeof envelope.forcedChannelId === 'number' && envelope.forcedChannelId > 0) {
|
||||
headers[TESTER_FORCED_CHANNEL_HEADER] = String(envelope.forcedChannelId);
|
||||
}
|
||||
|
||||
if (envelope.requestKind === 'json') {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
|
||||
Reference in New Issue
Block a user