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:
@@ -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';
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { fetch } from 'undici';
|
||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { fetch } from 'undici';
|
||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
||||
import { mergeProxyUsage, parseProxyUsage, pullSseDataEvents } from '../../services/proxyUsageParser.js';
|
||||
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
||||
@@ -18,7 +17,13 @@ import { getProxyAuthContext } from '../../middleware/auth.js';
|
||||
import { buildUpstreamUrl } from './upstreamUrl.js';
|
||||
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
|
||||
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||
import { canRetryProxyChannel, getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||
import {
|
||||
buildForcedChannelUnavailableMessage,
|
||||
canRetryChannelSelection,
|
||||
getTesterForcedChannelId,
|
||||
selectProxyChannelForAttempt,
|
||||
} from '../../proxy-core/channelSelection.js';
|
||||
|
||||
export async function completionsProxyRoute(app: FastifyInstance) {
|
||||
app.post('/v1/completions', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
@@ -29,6 +34,10 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
||||
}
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const forcedChannelId = getTesterForcedChannelId({
|
||||
headers: request.headers as Record<string, unknown>,
|
||||
clientIp: request.ip,
|
||||
});
|
||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||
const downstreamPath = '/v1/completions';
|
||||
const clientContext = detectDownstreamClientContext({
|
||||
@@ -38,28 +47,28 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
const isStream = body.stream === true;
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
|
||||
while (retryCount <= getProxyMaxChannelRetries()) {
|
||||
let selected = retryCount === 0
|
||||
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||
|
||||
if (!selected && retryCount === 0) {
|
||||
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: 'No available channels after retries',
|
||||
});
|
||||
return reply.code(503).send({
|
||||
error: { message: 'No available channels for this model', type: 'server_error' },
|
||||
});
|
||||
}
|
||||
const selected = await selectProxyChannelForAttempt({
|
||||
requestedModel,
|
||||
downstreamPolicy,
|
||||
excludeChannelIds,
|
||||
retryCount,
|
||||
forcedChannelId,
|
||||
});
|
||||
|
||||
if (!selected) {
|
||||
const noChannelMessage = buildForcedChannelUnavailableMessage(forcedChannelId);
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: forcedChannelId ? noChannelMessage : 'No available channels after retries',
|
||||
});
|
||||
return reply.code(503).send({
|
||||
error: { message: noChannelMessage, type: 'server_error' },
|
||||
});
|
||||
}
|
||||
|
||||
excludeChannelIds.push(selected.channel.id);
|
||||
|
||||
@@ -112,7 +121,7 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldRetryProxyRequest(upstream.status, errText) && canRetryProxyChannel(retryCount)) {
|
||||
if (shouldRetryProxyRequest(upstream.status, errText) && canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount++;
|
||||
continue;
|
||||
}
|
||||
@@ -258,7 +267,7 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
||||
downstreamPath,
|
||||
);
|
||||
|
||||
if (shouldRetryProxyRequest(failure.status, errText) && canRetryProxyChannel(retryCount)) {
|
||||
if (shouldRetryProxyRequest(failure.status, errText) && canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -341,7 +350,7 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
||||
clientContext,
|
||||
downstreamPath,
|
||||
);
|
||||
if (canRetryProxyChannel(retryCount)) {
|
||||
if (canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { fetch } from 'undici';
|
||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { fetch } from 'undici';
|
||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
||||
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
||||
@@ -17,7 +16,13 @@ import { getProxyAuthContext } from '../../middleware/auth.js';
|
||||
import { buildUpstreamUrl } from './upstreamUrl.js';
|
||||
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
|
||||
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||
import { canRetryProxyChannel, getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||
import {
|
||||
buildForcedChannelUnavailableMessage,
|
||||
canRetryChannelSelection,
|
||||
getTesterForcedChannelId,
|
||||
selectProxyChannelForAttempt,
|
||||
} from '../../proxy-core/channelSelection.js';
|
||||
|
||||
export async function embeddingsProxyRoute(app: FastifyInstance) {
|
||||
app.post('/v1/embeddings', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
@@ -25,10 +30,14 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
|
||||
const requestedModel = body?.model;
|
||||
if (!requestedModel) {
|
||||
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
|
||||
}
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||
}
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const forcedChannelId = getTesterForcedChannelId({
|
||||
headers: request.headers as Record<string, unknown>,
|
||||
clientIp: request.ip,
|
||||
});
|
||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||
const downstreamPath = '/v1/embeddings';
|
||||
const clientContext = detectDownstreamClientContext({
|
||||
downstreamPath,
|
||||
@@ -36,26 +45,26 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
|
||||
body,
|
||||
});
|
||||
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
|
||||
while (retryCount <= getProxyMaxChannelRetries()) {
|
||||
let selected = retryCount === 0
|
||||
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||
|
||||
if (!selected && retryCount === 0) {
|
||||
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: 'No available channels after retries',
|
||||
});
|
||||
return reply.code(503).send({ error: { message: 'No available channels', type: 'server_error' } });
|
||||
}
|
||||
const selected = await selectProxyChannelForAttempt({
|
||||
requestedModel,
|
||||
downstreamPolicy,
|
||||
excludeChannelIds,
|
||||
retryCount,
|
||||
forcedChannelId,
|
||||
});
|
||||
|
||||
if (!selected) {
|
||||
const noChannelMessage = buildForcedChannelUnavailableMessage(forcedChannelId);
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: forcedChannelId ? noChannelMessage : 'No available channels after retries',
|
||||
});
|
||||
return reply.code(503).send({ error: { message: noChannelMessage, type: 'server_error' } });
|
||||
}
|
||||
excludeChannelIds.push(selected.channel.id);
|
||||
|
||||
const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/embeddings');
|
||||
@@ -107,7 +116,7 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldRetryProxyRequest(upstream.status, text) && canRetryProxyChannel(retryCount)) {
|
||||
if (shouldRetryProxyRequest(upstream.status, text) && canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount++;
|
||||
continue;
|
||||
}
|
||||
@@ -178,7 +187,7 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
|
||||
clientContext,
|
||||
downstreamPath,
|
||||
);
|
||||
if (canRetryProxyChannel(retryCount)) {
|
||||
if (canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const refreshModelsAndRebuildRoutesMock = vi.fn();
|
||||
const refreshOauthAccessTokenSingleflightMock = vi.fn();
|
||||
const selectChannelMock = vi.fn();
|
||||
const selectNextChannelMock = vi.fn();
|
||||
const selectPreferredChannelMock = vi.fn();
|
||||
const recordSuccessMock = vi.fn();
|
||||
const recordFailureMock = vi.fn();
|
||||
const explainSelectionMock = vi.fn();
|
||||
@@ -68,6 +69,7 @@ vi.mock('../../services/tokenRouter.js', () => ({
|
||||
tokenRouter: {
|
||||
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
|
||||
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
|
||||
selectPreferredChannel: (...args: unknown[]) => selectPreferredChannelMock(...args),
|
||||
recordSuccess: (...args: unknown[]) => recordSuccessMock(...args),
|
||||
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
|
||||
explainSelection: (...args: unknown[]) => explainSelectionMock(...args),
|
||||
@@ -181,6 +183,7 @@ describe('gemini native proxy routes', () => {
|
||||
refreshOauthAccessTokenSingleflightMock.mockReset();
|
||||
selectChannelMock.mockReset();
|
||||
selectNextChannelMock.mockReset();
|
||||
selectPreferredChannelMock.mockReset();
|
||||
recordSuccessMock.mockReset();
|
||||
recordFailureMock.mockReset();
|
||||
explainSelectionMock.mockReset();
|
||||
@@ -232,6 +235,7 @@ describe('gemini native proxy routes', () => {
|
||||
actualModel: 'gemini-2.5-flash',
|
||||
});
|
||||
selectNextChannelMock.mockReturnValue(null);
|
||||
selectPreferredChannelMock.mockReturnValue(null);
|
||||
recordSuccessMock.mockResolvedValue(undefined);
|
||||
recordFailureMock.mockResolvedValue(undefined);
|
||||
resetUpstreamEndpointRuntimeState();
|
||||
@@ -318,6 +322,60 @@ describe('gemini native proxy routes', () => {
|
||||
expect(secondUrl).toContain('key=gemini-key-2');
|
||||
});
|
||||
|
||||
it('pins /v1beta/models to the forced tester channel when present', async () => {
|
||||
selectPreferredChannelMock
|
||||
.mockReturnValueOnce(null)
|
||||
.mockReturnValueOnce({
|
||||
channel: { id: 77, routeId: 22 },
|
||||
site: { id: 88, name: 'forced-gemini-site', url: 'https://generativelanguage.googleapis.com', platform: 'gemini' },
|
||||
account: { id: 39, username: 'forced-user' },
|
||||
tokenName: 'forced',
|
||||
tokenValue: 'forced-gemini-key',
|
||||
actualModel: 'gemini-2.0-flash',
|
||||
});
|
||||
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
models: [
|
||||
{ name: 'models/gemini-2.0-flash', displayName: 'Gemini 2.0 Flash' },
|
||||
],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/v1beta/models',
|
||||
remoteAddress: '127.0.0.1',
|
||||
headers: {
|
||||
authorization: 'Bearer sk-managed-gemini',
|
||||
'x-metapi-tester-request': '1',
|
||||
'x-metapi-tester-forced-channel-id': '77',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(selectChannelMock).not.toHaveBeenCalled();
|
||||
expect(selectNextChannelMock).not.toHaveBeenCalled();
|
||||
expect(selectPreferredChannelMock).toHaveBeenCalledTimes(2);
|
||||
expect(selectPreferredChannelMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'gemini-2.5-flash',
|
||||
77,
|
||||
expect.anything(),
|
||||
expect.any(Array),
|
||||
);
|
||||
expect(selectPreferredChannelMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'gemini-2.0-flash',
|
||||
77,
|
||||
expect.anything(),
|
||||
expect.any(Array),
|
||||
);
|
||||
const [targetUrl] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(targetUrl).toContain('key=forced-gemini-key');
|
||||
});
|
||||
|
||||
it('serves gemini-cli model list from local static catalog without upstream fetch', async () => {
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 21, routeId: 22 },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { fetch } from 'undici';
|
||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||
import { estimateProxyCost } from '../../services/modelPricingService.js';
|
||||
@@ -16,7 +15,13 @@ import { getProxyAuthContext } from '../../middleware/auth.js';
|
||||
import { buildUpstreamUrl } from './upstreamUrl.js';
|
||||
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
|
||||
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||
import { canRetryProxyChannel, getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||
import {
|
||||
buildForcedChannelUnavailableMessage,
|
||||
canRetryChannelSelection,
|
||||
getTesterForcedChannelId,
|
||||
selectProxyChannelForAttempt,
|
||||
} from '../../proxy-core/channelSelection.js';
|
||||
|
||||
export async function imagesProxyRoute(app: FastifyInstance) {
|
||||
ensureMultipartBufferParser(app);
|
||||
@@ -26,6 +31,10 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
||||
const requestedModel = body?.model || 'gpt-image-1';
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const forcedChannelId = getTesterForcedChannelId({
|
||||
headers: request.headers as Record<string, unknown>,
|
||||
clientIp: request.ip,
|
||||
});
|
||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||
const downstreamPath = '/v1/images/generations';
|
||||
const clientContext = detectDownstreamClientContext({
|
||||
@@ -37,22 +46,22 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
||||
let retryCount = 0;
|
||||
|
||||
while (retryCount <= getProxyMaxChannelRetries()) {
|
||||
let selected = retryCount === 0
|
||||
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||
|
||||
if (!selected && retryCount === 0) {
|
||||
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||
}
|
||||
const selected = await selectProxyChannelForAttempt({
|
||||
requestedModel,
|
||||
downstreamPolicy,
|
||||
excludeChannelIds,
|
||||
retryCount,
|
||||
forcedChannelId,
|
||||
});
|
||||
|
||||
if (!selected) {
|
||||
const noChannelMessage = buildForcedChannelUnavailableMessage(forcedChannelId);
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: 'No available channels after retries',
|
||||
reason: forcedChannelId ? noChannelMessage : 'No available channels after retries',
|
||||
});
|
||||
return reply.code(503).send({
|
||||
error: { message: 'No available channels for this model', type: 'server_error' },
|
||||
error: { message: noChannelMessage, type: 'server_error' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -101,7 +110,7 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
||||
detail: `HTTP ${upstream.status}`,
|
||||
});
|
||||
}
|
||||
if (shouldRetryProxyRequest(upstream.status, text) && canRetryProxyChannel(retryCount)) {
|
||||
if (shouldRetryProxyRequest(upstream.status, text) && canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount++;
|
||||
continue;
|
||||
}
|
||||
@@ -132,7 +141,7 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
||||
downstreamPath,
|
||||
clientContext,
|
||||
);
|
||||
if (canRetryProxyChannel(retryCount)) {
|
||||
if (canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount++;
|
||||
continue;
|
||||
}
|
||||
@@ -184,7 +193,7 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
||||
downstreamPath,
|
||||
clientContext,
|
||||
);
|
||||
if (canRetryProxyChannel(retryCount)) {
|
||||
if (canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount++;
|
||||
continue;
|
||||
}
|
||||
@@ -210,6 +219,10 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
||||
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const forcedChannelId = getTesterForcedChannelId({
|
||||
headers: request.headers as Record<string, unknown>,
|
||||
clientIp: request.ip,
|
||||
});
|
||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||
const downstreamPath = '/v1/images/edits';
|
||||
const clientContext = detectDownstreamClientContext({
|
||||
@@ -221,22 +234,22 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
||||
let retryCount = 0;
|
||||
|
||||
while (retryCount <= getProxyMaxChannelRetries()) {
|
||||
let selected = retryCount === 0
|
||||
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||
|
||||
if (!selected && retryCount === 0) {
|
||||
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||
}
|
||||
const selected = await selectProxyChannelForAttempt({
|
||||
requestedModel,
|
||||
downstreamPolicy,
|
||||
excludeChannelIds,
|
||||
retryCount,
|
||||
forcedChannelId,
|
||||
});
|
||||
|
||||
if (!selected) {
|
||||
const noChannelMessage = buildForcedChannelUnavailableMessage(forcedChannelId);
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: 'No available channels after retries',
|
||||
reason: forcedChannelId ? noChannelMessage : 'No available channels after retries',
|
||||
});
|
||||
return reply.code(503).send({
|
||||
error: { message: 'No available channels for this model', type: 'server_error' },
|
||||
error: { message: noChannelMessage, type: 'server_error' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -297,7 +310,7 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
||||
detail: `HTTP ${upstream.status}`,
|
||||
});
|
||||
}
|
||||
if (shouldRetryProxyRequest(upstream.status, text) && canRetryProxyChannel(retryCount)) {
|
||||
if (shouldRetryProxyRequest(upstream.status, text) && canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount++;
|
||||
continue;
|
||||
}
|
||||
@@ -328,7 +341,7 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
||||
downstreamPath,
|
||||
clientContext,
|
||||
);
|
||||
if (canRetryProxyChannel(retryCount)) {
|
||||
if (canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount++;
|
||||
continue;
|
||||
}
|
||||
@@ -380,7 +393,7 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
||||
downstreamPath,
|
||||
clientContext,
|
||||
);
|
||||
if (canRetryProxyChannel(retryCount)) {
|
||||
if (canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { fetch } from 'undici';
|
||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||
@@ -14,7 +13,13 @@ import { getProxyAuthContext } from '../../middleware/auth.js';
|
||||
import { buildUpstreamUrl } from './upstreamUrl.js';
|
||||
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
|
||||
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||
import { canRetryProxyChannel, getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||
import {
|
||||
buildForcedChannelUnavailableMessage,
|
||||
canRetryChannelSelection,
|
||||
getTesterForcedChannelId,
|
||||
selectProxyChannelForAttempt,
|
||||
} from '../../proxy-core/channelSelection.js';
|
||||
const DEFAULT_SEARCH_MODEL = '__search';
|
||||
const DEFAULT_MAX_RESULTS = 10;
|
||||
const MAX_MAX_RESULTS = 20;
|
||||
@@ -54,6 +59,10 @@ export async function searchProxyRoute(app: FastifyInstance) {
|
||||
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const forcedChannelId = getTesterForcedChannelId({
|
||||
headers: request.headers as Record<string, unknown>,
|
||||
clientIp: request.ip,
|
||||
});
|
||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||
const downstreamPath = '/v1/search';
|
||||
const clientContext = detectDownstreamClientContext({
|
||||
@@ -65,22 +74,22 @@ export async function searchProxyRoute(app: FastifyInstance) {
|
||||
let retryCount = 0;
|
||||
|
||||
while (retryCount <= getProxyMaxChannelRetries()) {
|
||||
let selected = retryCount === 0
|
||||
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||
|
||||
if (!selected && retryCount === 0) {
|
||||
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||
}
|
||||
const selected = await selectProxyChannelForAttempt({
|
||||
requestedModel,
|
||||
downstreamPolicy,
|
||||
excludeChannelIds,
|
||||
retryCount,
|
||||
forcedChannelId,
|
||||
});
|
||||
|
||||
if (!selected) {
|
||||
const noChannelMessage = buildForcedChannelUnavailableMessage(forcedChannelId);
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: 'No available channels after retries',
|
||||
reason: forcedChannelId ? noChannelMessage : 'No available channels after retries',
|
||||
});
|
||||
return reply.code(503).send({
|
||||
error: { message: 'No available channels for this model', type: 'server_error' },
|
||||
error: { message: noChannelMessage, type: 'server_error' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -131,7 +140,7 @@ export async function searchProxyRoute(app: FastifyInstance) {
|
||||
detail: `HTTP ${upstream.status}`,
|
||||
});
|
||||
}
|
||||
if (shouldRetryProxyRequest(upstream.status, text) && canRetryProxyChannel(retryCount)) {
|
||||
if (shouldRetryProxyRequest(upstream.status, text) && canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -170,7 +179,7 @@ export async function searchProxyRoute(app: FastifyInstance) {
|
||||
clientContext,
|
||||
downstreamPath,
|
||||
);
|
||||
if (canRetryProxyChannel(retryCount)) {
|
||||
if (canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -935,6 +935,39 @@ describe('buildUpstreamEndpointRequest', () => {
|
||||
expect(httpRequest.headers['x-codex-beta-features']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses internal websocket transport hints without forwarding internal metapi headers upstream', () => {
|
||||
(config as any).codexHeaderDefaults = {
|
||||
userAgent: 'codex-config-ua/1.0',
|
||||
betaFeatures: 'multi_agent',
|
||||
};
|
||||
|
||||
const request = buildUpstreamEndpointRequest({
|
||||
endpoint: 'responses',
|
||||
modelName: 'gpt-5.4',
|
||||
stream: true,
|
||||
tokenValue: 'oauth-access-token',
|
||||
oauthProvider: 'codex',
|
||||
sitePlatform: 'codex',
|
||||
siteUrl: 'https://chatgpt.com/backend-api/codex',
|
||||
openaiBody: {
|
||||
model: 'gpt-5.4',
|
||||
messages: [{ role: 'user', content: 'hello codex' }],
|
||||
},
|
||||
downstreamFormat: 'openai',
|
||||
downstreamHeaders: {
|
||||
'x-metapi-responses-websocket-transport': '1',
|
||||
'x-metapi-tester-forced-channel-id': '77',
|
||||
},
|
||||
providerHeaders: {
|
||||
Originator: 'codex_cli_rs',
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(request.headers['x-codex-beta-features']).toBe('multi_agent');
|
||||
expect(request.headers['x-metapi-responses-websocket-transport']).toBeUndefined();
|
||||
expect(request.headers['x-metapi-tester-forced-channel-id']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies configured payload rules before preparing codex responses requests while forcing store false', () => {
|
||||
(config as any).payloadRules = {
|
||||
default: [
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import {
|
||||
buildClaudeRuntimeHeaders,
|
||||
buildGeminiCliUserAgent,
|
||||
getInputHeader,
|
||||
headerValueToString,
|
||||
} from '../../proxy-core/providers/headerUtils.js';
|
||||
export {
|
||||
@@ -112,11 +113,20 @@ const BLOCKED_PASSTHROUGH_HEADERS = new Set([
|
||||
'sec-websocket-version',
|
||||
'sec-websocket-extensions',
|
||||
]);
|
||||
const METAPI_INTERNAL_HEADER_BLOCKLIST = new Set([
|
||||
'x-metapi-tester-request',
|
||||
'x-metapi-tester-forced-channel-id',
|
||||
'x-metapi-responses-websocket-mode',
|
||||
'x-metapi-responses-websocket-transport',
|
||||
]);
|
||||
|
||||
const ANTIGRAVITY_RUNTIME_USER_AGENT = 'antigravity/1.19.6 darwin/arm64';
|
||||
|
||||
function shouldSkipPassthroughHeader(key: string): boolean {
|
||||
return HOP_BY_HOP_HEADERS.has(key) || BLOCKED_PASSTHROUGH_HEADERS.has(key);
|
||||
if (HOP_BY_HOP_HEADERS.has(key) || BLOCKED_PASSTHROUGH_HEADERS.has(key)) return true;
|
||||
if (METAPI_INTERNAL_HEADER_BLOCKLIST.has(key)) return true;
|
||||
if (key.startsWith('x-metapi-')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function extractSafePassthroughHeaders(
|
||||
@@ -911,6 +921,10 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
}
|
||||
|
||||
if (input.endpoint === 'responses') {
|
||||
const responsesWebsocketTransport = getInputHeader(
|
||||
input.downstreamHeaders,
|
||||
'x-metapi-responses-websocket-transport',
|
||||
) === '1';
|
||||
const websocketMode = Object.entries(input.downstreamHeaders || {}).find(([rawKey]) => rawKey.trim().toLowerCase() === 'x-metapi-responses-websocket-mode');
|
||||
const preserveWebsocketIncrementalMode = asTrimmedString(websocketMode?.[1]).toLowerCase() === 'incremental';
|
||||
const responsesHeaders = input.downstreamFormat === 'responses'
|
||||
@@ -963,6 +977,7 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
providerHeaders: input.providerHeaders,
|
||||
codexSessionCacheKey: input.codexSessionCacheKey,
|
||||
codexExplicitSessionId: input.codexExplicitSessionId,
|
||||
responsesWebsocketTransport,
|
||||
body: configuredResponsesBody,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { fetch } from 'undici';
|
||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||
import { estimateProxyCost } from '../../services/modelPricingService.js';
|
||||
@@ -17,7 +16,13 @@ import {
|
||||
refreshProxyVideoTaskSnapshot,
|
||||
saveProxyVideoTask,
|
||||
} from '../../services/proxyVideoTaskStore.js';
|
||||
import { canRetryProxyChannel, getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||
import {
|
||||
buildForcedChannelUnavailableMessage,
|
||||
canRetryChannelSelection,
|
||||
getTesterForcedChannelId,
|
||||
selectProxyChannelForAttempt,
|
||||
} from '../../proxy-core/channelSelection.js';
|
||||
|
||||
function rewriteVideoResponsePublicId(payload: unknown, publicId: string): unknown {
|
||||
if (!payload || typeof payload !== 'object') return payload;
|
||||
@@ -47,26 +52,30 @@ export async function videosProxyRoute(app: FastifyInstance) {
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const forcedChannelId = getTesterForcedChannelId({
|
||||
headers: request.headers as Record<string, unknown>,
|
||||
clientIp: request.ip,
|
||||
});
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
|
||||
while (retryCount <= getProxyMaxChannelRetries()) {
|
||||
let selected = retryCount === 0
|
||||
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||
|
||||
if (!selected && retryCount === 0) {
|
||||
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||
}
|
||||
const selected = await selectProxyChannelForAttempt({
|
||||
requestedModel,
|
||||
downstreamPolicy,
|
||||
excludeChannelIds,
|
||||
retryCount,
|
||||
forcedChannelId,
|
||||
});
|
||||
|
||||
if (!selected) {
|
||||
const noChannelMessage = buildForcedChannelUnavailableMessage(forcedChannelId);
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: 'No available channels after retries',
|
||||
reason: forcedChannelId ? noChannelMessage : 'No available channels after retries',
|
||||
});
|
||||
return reply.code(503).send({
|
||||
error: { message: 'No available channels for this model', type: 'server_error' },
|
||||
error: { message: noChannelMessage, type: 'server_error' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -115,7 +124,7 @@ export async function videosProxyRoute(app: FastifyInstance) {
|
||||
detail: `HTTP ${upstream.status}`,
|
||||
});
|
||||
}
|
||||
if (shouldRetryProxyRequest(upstream.status, text) && canRetryProxyChannel(retryCount)) {
|
||||
if (shouldRetryProxyRequest(upstream.status, text) && canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -170,7 +179,7 @@ export async function videosProxyRoute(app: FastifyInstance) {
|
||||
errorText: error?.message || 'network failure',
|
||||
modelName: upstreamModel,
|
||||
}));
|
||||
if (canRetryProxyChannel(retryCount)) {
|
||||
if (canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user