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:
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getTesterForcedChannelId,
|
||||
normalizeForcedChannelId,
|
||||
TESTER_FORCED_CHANNEL_HEADER,
|
||||
TESTER_REQUEST_HEADER,
|
||||
} from './channelSelection.js';
|
||||
|
||||
describe('normalizeForcedChannelId', () => {
|
||||
it('accepts positive integer ids and rejects fractional or unsafe values', () => {
|
||||
expect(normalizeForcedChannelId(77)).toBe(77);
|
||||
expect(normalizeForcedChannelId('78')).toBe(78);
|
||||
expect(normalizeForcedChannelId(77.9)).toBeNull();
|
||||
expect(normalizeForcedChannelId('78.5')).toBeNull();
|
||||
expect(normalizeForcedChannelId('9007199254740993')).toBeNull();
|
||||
expect(normalizeForcedChannelId(0)).toBeNull();
|
||||
expect(normalizeForcedChannelId(-1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTesterForcedChannelId', () => {
|
||||
it('ignores forged forced-channel headers without the trusted tester bridge marker', () => {
|
||||
expect(getTesterForcedChannelId({
|
||||
headers: {
|
||||
[TESTER_FORCED_CHANNEL_HEADER]: '77',
|
||||
},
|
||||
clientIp: '127.0.0.1',
|
||||
})).toBeNull();
|
||||
|
||||
expect(getTesterForcedChannelId({
|
||||
headers: {
|
||||
[TESTER_REQUEST_HEADER]: '1',
|
||||
[TESTER_FORCED_CHANNEL_HEADER]: '77',
|
||||
},
|
||||
clientIp: '203.0.113.10',
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts the forced channel id only for loopback tester bridge traffic', () => {
|
||||
expect(getTesterForcedChannelId({
|
||||
headers: {
|
||||
[TESTER_REQUEST_HEADER]: '1',
|
||||
[TESTER_FORCED_CHANNEL_HEADER]: '77',
|
||||
},
|
||||
clientIp: '::1',
|
||||
})).toBe(77);
|
||||
|
||||
expect(getTesterForcedChannelId({
|
||||
headers: {
|
||||
[TESTER_REQUEST_HEADER]: '1',
|
||||
[TESTER_FORCED_CHANNEL_HEADER]: '78',
|
||||
},
|
||||
clientIp: '::ffff:127.0.0.1',
|
||||
})).toBe(78);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import * as routeRefreshWorkflow from '../services/routeRefreshWorkflow.js';
|
||||
import { proxyChannelCoordinator } from '../services/proxyChannelCoordinator.js';
|
||||
import { canRetryProxyChannel } from '../services/proxyChannelRetry.js';
|
||||
import type { DownstreamRoutingPolicy } from '../services/downstreamPolicyTypes.js';
|
||||
import { tokenRouter } from '../services/tokenRouter.js';
|
||||
|
||||
type SelectedChannel = Awaited<ReturnType<typeof tokenRouter.selectChannel>>;
|
||||
|
||||
export const TESTER_FORCED_CHANNEL_HEADER = 'x-metapi-tester-forced-channel-id';
|
||||
export const TESTER_REQUEST_HEADER = 'x-metapi-tester-request';
|
||||
|
||||
function headerValueEquals(
|
||||
headers: Record<string, unknown> | undefined,
|
||||
expectedKey: string,
|
||||
expectedValue: string,
|
||||
): boolean {
|
||||
if (!headers) return false;
|
||||
const normalizedExpectedKey = expectedKey.trim().toLowerCase();
|
||||
const normalizedExpectedValue = expectedValue.trim().toLowerCase();
|
||||
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||
if (rawKey.trim().toLowerCase() !== normalizedExpectedKey) continue;
|
||||
if (typeof rawValue === 'string' && rawValue.trim().toLowerCase() === normalizedExpectedValue) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isLoopbackClientIp(value: string | null | undefined): boolean {
|
||||
const trimmed = (value || '').trim();
|
||||
if (!trimmed) return false;
|
||||
if (trimmed === '::1' || trimmed === '127.0.0.1') return true;
|
||||
if (trimmed.startsWith('::ffff:')) {
|
||||
return trimmed.slice('::ffff:'.length).trim() === '127.0.0.1';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function normalizeForcedChannelId(value: unknown): number | null {
|
||||
const numeric = typeof value === 'number'
|
||||
? value
|
||||
: typeof value === 'string' && value.trim()
|
||||
? Number(value.trim())
|
||||
: NaN;
|
||||
if (!Number.isSafeInteger(numeric) || numeric <= 0) return null;
|
||||
return numeric;
|
||||
}
|
||||
|
||||
type TesterRequestInput = {
|
||||
headers?: Record<string, unknown>;
|
||||
clientIp?: string | null;
|
||||
};
|
||||
|
||||
export function isTrustedTesterRequest(input?: TesterRequestInput): boolean {
|
||||
if (!input) return false;
|
||||
if (!isLoopbackClientIp(input.clientIp)) return false;
|
||||
return headerValueEquals(input.headers, TESTER_REQUEST_HEADER, '1');
|
||||
}
|
||||
|
||||
export function getTesterForcedChannelId(input?: TesterRequestInput): number | null {
|
||||
if (!isTrustedTesterRequest(input)) return null;
|
||||
const headers = input?.headers;
|
||||
if (!headers) return null;
|
||||
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||
if (rawKey.trim().toLowerCase() !== TESTER_FORCED_CHANNEL_HEADER) continue;
|
||||
return normalizeForcedChannelId(rawValue);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildForcedChannelUnavailableMessage(forcedChannelId?: number | null): string {
|
||||
const normalizedForcedChannelId = normalizeForcedChannelId(forcedChannelId);
|
||||
if (normalizedForcedChannelId === null) {
|
||||
return 'No available channels for this model';
|
||||
}
|
||||
return `指定通道 #${normalizedForcedChannelId} 当前不可用,固定通道模式不会自动切换其他通道`;
|
||||
}
|
||||
|
||||
export function canRetryChannelSelection(retryCount: number, forcedChannelId?: number | null): boolean {
|
||||
if (normalizeForcedChannelId(forcedChannelId) !== null) return false;
|
||||
return canRetryProxyChannel(retryCount);
|
||||
}
|
||||
|
||||
export async function selectProxyChannelForAttempt(input: {
|
||||
requestedModel: string;
|
||||
downstreamPolicy: DownstreamRoutingPolicy;
|
||||
excludeChannelIds: number[];
|
||||
retryCount: number;
|
||||
stickySessionKey?: string | null;
|
||||
forcedChannelId?: number | null;
|
||||
}): Promise<SelectedChannel> {
|
||||
const normalizedForcedChannelId = normalizeForcedChannelId(input.forcedChannelId);
|
||||
if (normalizedForcedChannelId !== null) {
|
||||
if (input.retryCount > 0) return null;
|
||||
return await tokenRouter.selectPreferredChannel(
|
||||
input.requestedModel,
|
||||
normalizedForcedChannelId,
|
||||
input.downstreamPolicy,
|
||||
input.excludeChannelIds,
|
||||
);
|
||||
}
|
||||
|
||||
let selected: SelectedChannel = null;
|
||||
let refreshedRoutes = false;
|
||||
|
||||
const refreshRoutesForFirstAttempt = async (): Promise<boolean> => {
|
||||
if (input.retryCount > 0 || refreshedRoutes) return false;
|
||||
refreshedRoutes = true;
|
||||
try {
|
||||
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('[proxy/surface] failed to refresh routes after empty selection', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if (input.retryCount === 0 && input.stickySessionKey) {
|
||||
const preferredChannelId = proxyChannelCoordinator.getStickyChannelId(input.stickySessionKey);
|
||||
if (preferredChannelId && !input.excludeChannelIds.includes(preferredChannelId)) {
|
||||
selected = await tokenRouter.selectPreferredChannel(
|
||||
input.requestedModel,
|
||||
preferredChannelId,
|
||||
input.downstreamPolicy,
|
||||
input.excludeChannelIds,
|
||||
);
|
||||
if (!selected) {
|
||||
const refreshSucceeded = await refreshRoutesForFirstAttempt();
|
||||
selected = await tokenRouter.selectPreferredChannel(
|
||||
input.requestedModel,
|
||||
preferredChannelId,
|
||||
input.downstreamPolicy,
|
||||
input.excludeChannelIds,
|
||||
);
|
||||
if (!selected && refreshSucceeded) {
|
||||
proxyChannelCoordinator.clearStickyChannel(input.stickySessionKey, preferredChannelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
selected = input.retryCount === 0
|
||||
? await tokenRouter.selectChannel(input.requestedModel, input.downstreamPolicy)
|
||||
: await tokenRouter.selectNextChannel(
|
||||
input.requestedModel,
|
||||
input.excludeChannelIds,
|
||||
input.downstreamPolicy,
|
||||
);
|
||||
}
|
||||
|
||||
if (!selected && input.retryCount === 0 && !refreshedRoutes) {
|
||||
await refreshRoutesForFirstAttempt();
|
||||
selected = await tokenRouter.selectChannel(input.requestedModel, input.downstreamPolicy);
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export const codexProviderProfile: ProviderProfile = {
|
||||
id: 'codex',
|
||||
prepareRequest(input: PrepareProviderRequestInput): PreparedProviderRequest {
|
||||
const isCodexOauth = asTrimmedString(input.oauthProvider).toLowerCase() === 'codex';
|
||||
const websocketTransport = getInputHeader(input.baseHeaders, 'x-metapi-responses-websocket-transport') === '1';
|
||||
const websocketTransport = input.responsesWebsocketTransport === true;
|
||||
const configuredUserAgent = isCodexOauth ? asTrimmedString(config.codexHeaderDefaults.userAgent) : '';
|
||||
const configuredBetaFeatures = (
|
||||
isCodexOauth && websocketTransport
|
||||
|
||||
@@ -42,6 +42,7 @@ export type PrepareProviderRequestInput = {
|
||||
claudeHeaders?: Record<string, string>;
|
||||
codexSessionCacheKey?: string | null;
|
||||
codexExplicitSessionId?: string | null;
|
||||
responsesWebsocketTransport?: boolean;
|
||||
body: Record<string, unknown>;
|
||||
action?: ProviderAction;
|
||||
};
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js';
|
||||
import { getRuntimeResponseReader, readRuntimeResponseText } from '../executors/types.js';
|
||||
import { detectDownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
|
||||
import { canRetryProxyChannel, getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||
import {
|
||||
acquireSurfaceChannelLease,
|
||||
bindSurfaceStickyChannel,
|
||||
@@ -72,6 +72,11 @@ import {
|
||||
safeUpdateSurfaceProxyDebugSelection,
|
||||
startSurfaceProxyDebugTrace,
|
||||
} from '../../services/proxyDebugTraceRuntime.js';
|
||||
import {
|
||||
buildForcedChannelUnavailableMessage,
|
||||
canRetryChannelSelection,
|
||||
getTesterForcedChannelId,
|
||||
} from '../channelSelection.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
@@ -92,6 +97,32 @@ function prioritizeEndpointCandidate(
|
||||
];
|
||||
}
|
||||
|
||||
function finalizeRetryAsUpstreamFailure(status: number, message: string) {
|
||||
return {
|
||||
action: 'respond' as const,
|
||||
status,
|
||||
payload: {
|
||||
error: {
|
||||
message,
|
||||
type: 'upstream_error' as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function finalizeRetryAsExecutionFailure(message: string) {
|
||||
return {
|
||||
action: 'respond' as const,
|
||||
status: 502,
|
||||
payload: {
|
||||
error: {
|
||||
message: `Upstream error: ${message}`,
|
||||
type: 'upstream_error' as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleChatSurfaceRequest(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
@@ -120,6 +151,10 @@ export async function handleChatSurfaceRequest(
|
||||
} = requestEnvelope.parsed;
|
||||
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 owner = getProxyResourceOwner(request);
|
||||
let resolvedOpenAiBody = upstreamBody;
|
||||
if (owner) {
|
||||
@@ -199,19 +234,21 @@ export async function handleChatSurfaceRequest(
|
||||
excludeChannelIds,
|
||||
retryCount,
|
||||
stickySessionKey,
|
||||
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',
|
||||
});
|
||||
const payload = {
|
||||
error: { message: 'No available channels for this model', type: 'server_error' as const },
|
||||
error: { message: noChannelMessage, type: 'server_error' as const },
|
||||
};
|
||||
await finalizeDebugFailure(503, payload, null);
|
||||
return reply.code(503).send({
|
||||
error: { message: 'No available channels for this model', type: 'server_error' },
|
||||
error: { message: noChannelMessage, type: 'server_error' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -361,7 +398,7 @@ export async function handleChatSurfaceRequest(
|
||||
errorMessage: busyMessage,
|
||||
retryCount,
|
||||
});
|
||||
if (canRetryProxyChannel(retryCount)) {
|
||||
if (canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -472,16 +509,21 @@ export async function handleChatSurfaceRequest(
|
||||
latencyMs: Date.now() - startTime,
|
||||
retryCount,
|
||||
});
|
||||
if (failureOutcome.action === 'retry') {
|
||||
const terminalFailureOutcome = failureOutcome.action === 'retry'
|
||||
? (canRetryChannelSelection(retryCount, forcedChannelId)
|
||||
? null
|
||||
: finalizeRetryAsUpstreamFailure(endpointResult.status || 502, endpointResult.errText || 'unknown error'))
|
||||
: failureOutcome;
|
||||
if (!terminalFailureOutcome) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
await finalizeDebugFailure(
|
||||
failureOutcome.status,
|
||||
failureOutcome.payload,
|
||||
terminalFailureOutcome.status,
|
||||
terminalFailureOutcome.payload,
|
||||
null,
|
||||
);
|
||||
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||
return reply.code(terminalFailureOutcome.status).send(terminalFailureOutcome.payload);
|
||||
}
|
||||
|
||||
const upstream = endpointResult.upstream;
|
||||
@@ -651,16 +693,21 @@ export async function handleChatSurfaceRequest(
|
||||
totalTokens: parsedUsage.totalTokens,
|
||||
upstreamPath: successfulUpstreamPath,
|
||||
});
|
||||
if (failureOutcome.action === 'retry') {
|
||||
const terminalFailureOutcome = failureOutcome.action === 'retry'
|
||||
? (canRetryChannelSelection(retryCount, forcedChannelId)
|
||||
? null
|
||||
: finalizeRetryAsUpstreamFailure(failure.status, failure.reason))
|
||||
: failureOutcome;
|
||||
if (!terminalFailureOutcome) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
await finalizeDebugFailure(
|
||||
failureOutcome.status,
|
||||
failureOutcome.payload,
|
||||
terminalFailureOutcome.status,
|
||||
terminalFailureOutcome.payload,
|
||||
successfulUpstreamPath,
|
||||
);
|
||||
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||
return reply.code(terminalFailureOutcome.status).send(terminalFailureOutcome.payload);
|
||||
}
|
||||
|
||||
const streamResult = streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, streamResponse);
|
||||
@@ -848,16 +895,21 @@ export async function handleChatSurfaceRequest(
|
||||
totalTokens: parsedUsage.totalTokens,
|
||||
upstreamPath: successfulUpstreamPath,
|
||||
});
|
||||
if (failureOutcome.action === 'retry') {
|
||||
const terminalFailureOutcome = failureOutcome.action === 'retry'
|
||||
? (canRetryChannelSelection(retryCount, forcedChannelId)
|
||||
? null
|
||||
: finalizeRetryAsUpstreamFailure(failure.status, failure.reason))
|
||||
: failureOutcome;
|
||||
if (!terminalFailureOutcome) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
await finalizeDebugFailure(
|
||||
failureOutcome.status,
|
||||
failureOutcome.payload,
|
||||
terminalFailureOutcome.status,
|
||||
terminalFailureOutcome.payload,
|
||||
successfulUpstreamPath,
|
||||
);
|
||||
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||
return reply.code(terminalFailureOutcome.status).send(terminalFailureOutcome.payload);
|
||||
}
|
||||
const normalizedFinal = downstreamTransformer.transformFinalResponse(upstreamData, modelName, rawText);
|
||||
const downstreamResponse = downstreamTransformer.serializeFinalResponse(normalizedFinal, parsedUsage);
|
||||
@@ -905,16 +957,21 @@ export async function handleChatSurfaceRequest(
|
||||
latencyMs: Date.now() - startTime,
|
||||
retryCount,
|
||||
});
|
||||
if (failureOutcome.action === 'retry') {
|
||||
const terminalFailureOutcome = failureOutcome.action === 'retry'
|
||||
? (canRetryChannelSelection(retryCount, forcedChannelId)
|
||||
? null
|
||||
: finalizeRetryAsExecutionFailure(err?.message || 'network failure'))
|
||||
: failureOutcome;
|
||||
if (!terminalFailureOutcome) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
await finalizeDebugFailure(
|
||||
failureOutcome.status,
|
||||
failureOutcome.payload,
|
||||
terminalFailureOutcome.status,
|
||||
terminalFailureOutcome.payload,
|
||||
null,
|
||||
);
|
||||
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||
return reply.code(terminalFailureOutcome.status).send(terminalFailureOutcome.payload);
|
||||
} finally {
|
||||
channelLease.release();
|
||||
}
|
||||
@@ -976,6 +1033,10 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
||||
body: rawBody,
|
||||
});
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const forcedChannelId = getTesterForcedChannelId({
|
||||
headers: request.headers as Record<string, unknown>,
|
||||
clientIp: request.ip,
|
||||
});
|
||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||
const maxRetries = getProxyMaxChannelRetries();
|
||||
const failureToolkit = createSurfaceFailureToolkit({
|
||||
@@ -1034,18 +1095,20 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
||||
excludeChannelIds,
|
||||
retryCount,
|
||||
stickySessionKey,
|
||||
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',
|
||||
});
|
||||
await finalizeDebugFailure(503, {
|
||||
error: { message: 'No available channels for this model', type: 'server_error' },
|
||||
error: { message: noChannelMessage, type: 'server_error' },
|
||||
});
|
||||
return reply.code(503).send({
|
||||
error: { message: 'No available channels for this model', type: 'server_error' },
|
||||
error: { message: noChannelMessage, type: 'server_error' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1090,7 +1153,7 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
||||
},
|
||||
});
|
||||
if (!endpointCandidates.includes('messages')) {
|
||||
if (canRetryProxyChannel(retryCount)) {
|
||||
if (canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -1128,7 +1191,7 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
||||
errorMessage: busyMessage,
|
||||
retryCount,
|
||||
});
|
||||
if (canRetryProxyChannel(retryCount)) {
|
||||
if (canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -1240,12 +1303,17 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
||||
latencyMs: latency,
|
||||
retryCount,
|
||||
});
|
||||
if (failureOutcome.action === 'retry') {
|
||||
const terminalFailureOutcome = failureOutcome.action === 'retry'
|
||||
? (canRetryChannelSelection(retryCount, forcedChannelId)
|
||||
? null
|
||||
: finalizeRetryAsUpstreamFailure(upstream.status, typeof payload === 'string' ? payload : JSON.stringify(payload)))
|
||||
: failureOutcome;
|
||||
if (!terminalFailureOutcome) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
await finalizeDebugFailure(failureOutcome.status, failureOutcome.payload, upstreamRequest.path);
|
||||
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||
await finalizeDebugFailure(terminalFailureOutcome.status, terminalFailureOutcome.payload, upstreamRequest.path);
|
||||
return reply.code(terminalFailureOutcome.status).send(terminalFailureOutcome.payload);
|
||||
}
|
||||
|
||||
tokenRouter.recordSuccess(selected.channel.id, latency, 0, modelName);
|
||||
@@ -1284,12 +1352,17 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
||||
latencyMs: Date.now() - startTime,
|
||||
retryCount,
|
||||
});
|
||||
if (failureOutcome.action === 'retry') {
|
||||
const terminalFailureOutcome = failureOutcome.action === 'retry'
|
||||
? (canRetryChannelSelection(retryCount, forcedChannelId)
|
||||
? null
|
||||
: finalizeRetryAsExecutionFailure(error?.message || 'network failure'))
|
||||
: failureOutcome;
|
||||
if (!terminalFailureOutcome) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
await finalizeDebugFailure(failureOutcome.status, failureOutcome.payload, null);
|
||||
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||
await finalizeDebugFailure(terminalFailureOutcome.status, terminalFailureOutcome.payload, null);
|
||||
return reply.code(terminalFailureOutcome.status).send(terminalFailureOutcome.payload);
|
||||
} finally {
|
||||
channelLease.release();
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ import { detectDownstreamClientContext, type DownstreamClientContext } from '../
|
||||
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||
import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js';
|
||||
import { getRuntimeResponseReader, readRuntimeResponseText } from '../executors/types.js';
|
||||
import { canRetryProxyChannel, getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||
import {
|
||||
buildSurfaceProxyDebugResponseHeaders,
|
||||
captureSurfaceProxyDebugSuccessResponseBody,
|
||||
@@ -53,6 +53,11 @@ import {
|
||||
safeUpdateSurfaceProxyDebugSelection,
|
||||
startSurfaceProxyDebugTrace,
|
||||
} from '../../services/proxyDebugTraceRuntime.js';
|
||||
import {
|
||||
buildForcedChannelUnavailableMessage,
|
||||
canRetryChannelSelection,
|
||||
getTesterForcedChannelId,
|
||||
} from '../channelSelection.js';
|
||||
const GEMINI_MODEL_PROBES = [
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.0-flash',
|
||||
@@ -124,6 +129,24 @@ async function selectNextGeminiProbeChannel(request: FastifyRequest, excludeChan
|
||||
return null;
|
||||
}
|
||||
|
||||
async function selectPreferredGeminiProbeChannel(
|
||||
request: FastifyRequest,
|
||||
forcedChannelId: number,
|
||||
excludeChannelIds: number[],
|
||||
) {
|
||||
const policy = getDownstreamRoutingPolicy(request);
|
||||
for (const candidate of GEMINI_MODEL_PROBES) {
|
||||
const selected = await tokenRouter.selectPreferredChannel(
|
||||
candidate,
|
||||
forcedChannelId,
|
||||
policy,
|
||||
excludeChannelIds,
|
||||
);
|
||||
if (selected) return selected;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveDownstreamPath(request: FastifyRequest): string {
|
||||
const rawUrl = request.raw.url || request.url || '';
|
||||
const withoutQuery = rawUrl.split('?')[0] || rawUrl;
|
||||
@@ -320,15 +343,23 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
||||
});
|
||||
};
|
||||
const excludeChannelIds: number[] = [];
|
||||
const forcedChannelId = getTesterForcedChannelId({
|
||||
headers: request.headers as Record<string, unknown>,
|
||||
clientIp: request.ip,
|
||||
});
|
||||
let retryCount = 0;
|
||||
let lastStatus = 503;
|
||||
let lastText = 'No available channels for Gemini models';
|
||||
let lastContentType = 'application/json';
|
||||
|
||||
while (retryCount <= getProxyMaxChannelRetries()) {
|
||||
const selected = retryCount === 0
|
||||
? await selectGeminiChannel(request)
|
||||
: await selectNextGeminiProbeChannel(request, excludeChannelIds);
|
||||
const selected = forcedChannelId !== null
|
||||
? (retryCount === 0
|
||||
? await selectPreferredGeminiProbeChannel(request, forcedChannelId, excludeChannelIds)
|
||||
: null)
|
||||
: (retryCount === 0
|
||||
? await selectGeminiChannel(request)
|
||||
: await selectNextGeminiProbeChannel(request, excludeChannelIds));
|
||||
if (!selected) {
|
||||
await finalizeDebugFailure(lastStatus, lastText, null);
|
||||
return reply.code(lastStatus).type(lastContentType).send(lastText);
|
||||
@@ -409,7 +440,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
||||
status: upstream.status,
|
||||
errorText: text,
|
||||
});
|
||||
if (canRetryProxyChannel(retryCount)) {
|
||||
if (canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -438,7 +469,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
||||
type: 'upstream_error',
|
||||
},
|
||||
});
|
||||
if (canRetryProxyChannel(retryCount)) {
|
||||
if (canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -490,6 +521,10 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const policy = getDownstreamRoutingPolicy(request);
|
||||
const forcedChannelId = getTesterForcedChannelId({
|
||||
headers: request.headers as Record<string, unknown>,
|
||||
clientIp: request.ip,
|
||||
});
|
||||
const downstreamPath = resolveDownstreamPath(request);
|
||||
const clientContext = detectDownstreamClientContext({
|
||||
downstreamPath,
|
||||
@@ -532,10 +567,24 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
||||
let lastContentType = 'application/json';
|
||||
|
||||
while (retryCount <= getProxyMaxChannelRetries()) {
|
||||
const selected = retryCount === 0
|
||||
? await tokenRouter.selectChannel(requestedModel, policy)
|
||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, policy);
|
||||
const selected = forcedChannelId !== null
|
||||
? (retryCount === 0
|
||||
? await tokenRouter.selectPreferredChannel(requestedModel, forcedChannelId, policy, excludeChannelIds)
|
||||
: null)
|
||||
: (retryCount === 0
|
||||
? await tokenRouter.selectChannel(requestedModel, policy)
|
||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, policy));
|
||||
if (!selected) {
|
||||
if (forcedChannelId !== null) {
|
||||
lastStatus = 503;
|
||||
lastContentType = 'application/json';
|
||||
lastText = JSON.stringify({
|
||||
error: {
|
||||
message: buildForcedChannelUnavailableMessage(forcedChannelId),
|
||||
type: 'server_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
await finalizeDebugFailure(lastStatus, parseSurfaceProxyDebugTextPayload(lastText), null);
|
||||
return reply.code(lastStatus).type(lastContentType).send(lastText);
|
||||
}
|
||||
@@ -588,7 +637,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
||||
status: 500,
|
||||
errorText: 'Gemini CLI OAuth project is missing',
|
||||
});
|
||||
if (canRetryProxyChannel(retryCount)) {
|
||||
if (canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -742,7 +791,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
||||
upstreamPath,
|
||||
clientContext,
|
||||
);
|
||||
if (canRetryProxyChannel(retryCount)) {
|
||||
if (canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -1258,7 +1307,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
||||
null,
|
||||
clientContext,
|
||||
);
|
||||
if (canRetryProxyChannel(retryCount)) {
|
||||
if (canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -1344,7 +1393,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
||||
upstreamPath || null,
|
||||
clientContext,
|
||||
);
|
||||
if (canRetryProxyChannel(retryCount)) {
|
||||
if (canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -70,6 +70,11 @@ import {
|
||||
safeUpdateSurfaceProxyDebugSelection,
|
||||
startSurfaceProxyDebugTrace,
|
||||
} from '../../services/proxyDebugTraceRuntime.js';
|
||||
import {
|
||||
buildForcedChannelUnavailableMessage,
|
||||
canRetryChannelSelection,
|
||||
getTesterForcedChannelId,
|
||||
} from '../channelSelection.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
@@ -166,6 +171,32 @@ function carriesResponsesFileUrlInput(value: unknown): boolean {
|
||||
return Object.values(value).some((entry) => carriesResponsesFileUrlInput(entry));
|
||||
}
|
||||
|
||||
function finalizeRetryAsUpstreamFailure(status: number, message: string) {
|
||||
return {
|
||||
action: 'respond' as const,
|
||||
status,
|
||||
payload: {
|
||||
error: {
|
||||
message,
|
||||
type: 'upstream_error' as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function finalizeRetryAsExecutionFailure(message: string) {
|
||||
return {
|
||||
action: 'respond' as const,
|
||||
status: 502,
|
||||
payload: {
|
||||
error: {
|
||||
message: `Upstream error: ${message}`,
|
||||
type: 'upstream_error' as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function shouldRefreshOauthResponsesRequest(input: {
|
||||
oauthProvider?: string;
|
||||
status: number;
|
||||
@@ -215,6 +246,10 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
}
|
||||
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 maxRetries = getProxyMaxChannelRetries();
|
||||
const failureToolkit = createSurfaceFailureToolkit({
|
||||
@@ -273,19 +308,21 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
excludeChannelIds,
|
||||
retryCount,
|
||||
stickySessionKey,
|
||||
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',
|
||||
});
|
||||
const payload = {
|
||||
error: { message: 'No available channels for this model', type: 'server_error' as const },
|
||||
error: { message: noChannelMessage, type: 'server_error' as const },
|
||||
};
|
||||
await finalizeDebugFailure(503, payload, null);
|
||||
return reply.code(503).send({
|
||||
error: { message: 'No available channels for this model', type: 'server_error' },
|
||||
error: { message: noChannelMessage, type: 'server_error' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -479,8 +516,8 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
errorMessage: busyMessage,
|
||||
retryCount,
|
||||
});
|
||||
retryCount += 1;
|
||||
if (retryCount <= maxRetries) {
|
||||
if (retryCount < maxRetries && canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
await finalizeDebugFailure(503, {
|
||||
@@ -590,16 +627,21 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
latencyMs: Date.now() - startTime,
|
||||
retryCount,
|
||||
});
|
||||
if (failureOutcome.action === 'retry') {
|
||||
const terminalFailureOutcome = failureOutcome.action === 'retry'
|
||||
? (canRetryChannelSelection(retryCount, forcedChannelId)
|
||||
? null
|
||||
: finalizeRetryAsUpstreamFailure(endpointResult.status || 502, endpointResult.errText || 'unknown error'))
|
||||
: failureOutcome;
|
||||
if (!terminalFailureOutcome) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
await finalizeDebugFailure(
|
||||
failureOutcome.status,
|
||||
failureOutcome.payload,
|
||||
terminalFailureOutcome.status,
|
||||
terminalFailureOutcome.payload,
|
||||
null,
|
||||
);
|
||||
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||
return reply.code(terminalFailureOutcome.status).send(terminalFailureOutcome.payload);
|
||||
}
|
||||
|
||||
const upstream = endpointResult.upstream;
|
||||
@@ -748,7 +790,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
const failureOutcome = await failureToolkit.handleDetectedFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
modelName,
|
||||
modelName,
|
||||
failure,
|
||||
latencyMs: latency,
|
||||
retryCount,
|
||||
@@ -756,17 +798,22 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
completionTokens: parsedUsage.completionTokens,
|
||||
totalTokens: parsedUsage.totalTokens,
|
||||
upstreamPath: successfulUpstreamPath,
|
||||
});
|
||||
if (failureOutcome.action === 'retry') {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
await finalizeDebugFailure(
|
||||
failureOutcome.status,
|
||||
failureOutcome.payload,
|
||||
successfulUpstreamPath,
|
||||
);
|
||||
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||
});
|
||||
const terminalFailureOutcome = failureOutcome.action === 'retry'
|
||||
? (canRetryChannelSelection(retryCount, forcedChannelId)
|
||||
? null
|
||||
: finalizeRetryAsUpstreamFailure(failure.status, failure.reason))
|
||||
: failureOutcome;
|
||||
if (!terminalFailureOutcome) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
await finalizeDebugFailure(
|
||||
terminalFailureOutcome.status,
|
||||
terminalFailureOutcome.payload,
|
||||
successfulUpstreamPath,
|
||||
);
|
||||
return reply.code(terminalFailureOutcome.status).send(terminalFailureOutcome.payload);
|
||||
}
|
||||
|
||||
startSseResponse();
|
||||
@@ -1005,7 +1052,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
const failureOutcome = await failureToolkit.handleDetectedFailure({
|
||||
selected,
|
||||
requestedModel,
|
||||
modelName,
|
||||
modelName,
|
||||
failure,
|
||||
latencyMs: latency,
|
||||
retryCount,
|
||||
@@ -1013,17 +1060,22 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
completionTokens: parsedUsage.completionTokens,
|
||||
totalTokens: parsedUsage.totalTokens,
|
||||
upstreamPath: successfulUpstreamPath,
|
||||
});
|
||||
if (failureOutcome.action === 'retry') {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
await finalizeDebugFailure(
|
||||
failureOutcome.status,
|
||||
failureOutcome.payload,
|
||||
successfulUpstreamPath,
|
||||
);
|
||||
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||
});
|
||||
const terminalFailureOutcome = failureOutcome.action === 'retry'
|
||||
? (canRetryChannelSelection(retryCount, forcedChannelId)
|
||||
? null
|
||||
: finalizeRetryAsUpstreamFailure(failure.status, failure.reason))
|
||||
: failureOutcome;
|
||||
if (!terminalFailureOutcome) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
await finalizeDebugFailure(
|
||||
terminalFailureOutcome.status,
|
||||
terminalFailureOutcome.payload,
|
||||
successfulUpstreamPath,
|
||||
);
|
||||
return reply.code(terminalFailureOutcome.status).send(terminalFailureOutcome.payload);
|
||||
}
|
||||
const normalized = openAiResponsesTransformer.transformFinalResponse(
|
||||
upstreamData,
|
||||
@@ -1077,21 +1129,26 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
const failureOutcome = await failureToolkit.handleExecutionError({
|
||||
selected,
|
||||
requestedModel,
|
||||
modelName,
|
||||
errorMessage: err?.message || 'network failure',
|
||||
latencyMs: Date.now() - startTime,
|
||||
retryCount,
|
||||
});
|
||||
if (failureOutcome.action === 'retry') {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
modelName,
|
||||
errorMessage: err?.message || 'network failure',
|
||||
latencyMs: Date.now() - startTime,
|
||||
retryCount,
|
||||
});
|
||||
const terminalFailureOutcome = failureOutcome.action === 'retry'
|
||||
? (canRetryChannelSelection(retryCount, forcedChannelId)
|
||||
? null
|
||||
: finalizeRetryAsExecutionFailure(err?.message || 'network failure'))
|
||||
: failureOutcome;
|
||||
if (!terminalFailureOutcome) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
await finalizeDebugFailure(
|
||||
failureOutcome.status,
|
||||
failureOutcome.payload,
|
||||
null,
|
||||
);
|
||||
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||
await finalizeDebugFailure(
|
||||
terminalFailureOutcome.status,
|
||||
terminalFailureOutcome.payload,
|
||||
null,
|
||||
);
|
||||
return reply.code(terminalFailureOutcome.status).send(terminalFailureOutcome.payload);
|
||||
} finally {
|
||||
channelLease.release();
|
||||
}
|
||||
|
||||
@@ -206,10 +206,63 @@ describe('selectSurfaceChannelForAttempt', () => {
|
||||
expect(clearStickyChannelMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears stale sticky bindings and falls back to regular selection when the preferred channel is unavailable', async () => {
|
||||
it('uses the forced tester channel before sticky or automatic selection', async () => {
|
||||
const selected = { channel: { id: 88 } };
|
||||
selectPreferredChannelMock.mockResolvedValueOnce(selected);
|
||||
|
||||
const { selectSurfaceChannelForAttempt } = await import('./sharedSurface.js');
|
||||
const result = await selectSurfaceChannelForAttempt({
|
||||
requestedModel: 'gpt-5.2',
|
||||
downstreamPolicy: EMPTY_DOWNSTREAM_ROUTING_POLICY,
|
||||
excludeChannelIds: [],
|
||||
retryCount: 0,
|
||||
stickySessionKey: 'sticky-session',
|
||||
forcedChannelId: 88,
|
||||
});
|
||||
|
||||
expect(result).toBe(selected);
|
||||
expect(selectPreferredChannelMock).toHaveBeenCalledWith(
|
||||
'gpt-5.2',
|
||||
88,
|
||||
EMPTY_DOWNSTREAM_ROUTING_POLICY,
|
||||
[],
|
||||
);
|
||||
expect(getStickyChannelIdMock).not.toHaveBeenCalled();
|
||||
expect(selectChannelMock).not.toHaveBeenCalled();
|
||||
expect(selectNextChannelMock).not.toHaveBeenCalled();
|
||||
expect(refreshModelsAndRebuildRoutesMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not refresh or fall back when the forced tester channel is unavailable', async () => {
|
||||
selectPreferredChannelMock.mockResolvedValueOnce(null);
|
||||
|
||||
const { selectSurfaceChannelForAttempt } = await import('./sharedSurface.js');
|
||||
const result = await selectSurfaceChannelForAttempt({
|
||||
requestedModel: 'gpt-5.2',
|
||||
downstreamPolicy: EMPTY_DOWNSTREAM_ROUTING_POLICY,
|
||||
excludeChannelIds: [],
|
||||
retryCount: 0,
|
||||
forcedChannelId: 91,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(selectPreferredChannelMock).toHaveBeenCalledWith(
|
||||
'gpt-5.2',
|
||||
91,
|
||||
EMPTY_DOWNSTREAM_ROUTING_POLICY,
|
||||
[],
|
||||
);
|
||||
expect(selectChannelMock).not.toHaveBeenCalled();
|
||||
expect(selectNextChannelMock).not.toHaveBeenCalled();
|
||||
expect(refreshModelsAndRebuildRoutesMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes and retries the sticky preferred channel before clearing a stale binding', async () => {
|
||||
const selected = { channel: { id: 22 } };
|
||||
getStickyChannelIdMock.mockReturnValueOnce(55);
|
||||
selectPreferredChannelMock.mockResolvedValueOnce(null);
|
||||
selectPreferredChannelMock
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(null);
|
||||
selectChannelMock.mockResolvedValueOnce(selected);
|
||||
|
||||
const { selectSurfaceChannelForAttempt } = await import('./sharedSurface.js');
|
||||
@@ -222,10 +275,35 @@ describe('selectSurfaceChannelForAttempt', () => {
|
||||
});
|
||||
|
||||
expect(result).toBe(selected);
|
||||
expect(refreshModelsAndRebuildRoutesMock).toHaveBeenCalledTimes(1);
|
||||
expect(selectPreferredChannelMock).toHaveBeenCalledTimes(2);
|
||||
expect(clearStickyChannelMock).toHaveBeenCalledWith('sticky-session', 55);
|
||||
expect(selectChannelMock).toHaveBeenCalledWith('gpt-5.2', EMPTY_DOWNSTREAM_ROUTING_POLICY);
|
||||
});
|
||||
|
||||
it('keeps the sticky binding when route refresh recovers the preferred channel', async () => {
|
||||
const selected = { channel: { id: 55 } };
|
||||
getStickyChannelIdMock.mockReturnValueOnce(55);
|
||||
selectPreferredChannelMock
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(selected);
|
||||
|
||||
const { selectSurfaceChannelForAttempt } = await import('./sharedSurface.js');
|
||||
const result = await selectSurfaceChannelForAttempt({
|
||||
requestedModel: 'gpt-5.2',
|
||||
downstreamPolicy: EMPTY_DOWNSTREAM_ROUTING_POLICY,
|
||||
excludeChannelIds: [],
|
||||
retryCount: 0,
|
||||
stickySessionKey: 'sticky-session',
|
||||
});
|
||||
|
||||
expect(result).toBe(selected);
|
||||
expect(refreshModelsAndRebuildRoutesMock).toHaveBeenCalledTimes(1);
|
||||
expect(selectPreferredChannelMock).toHaveBeenCalledTimes(2);
|
||||
expect(clearStickyChannelMock).not.toHaveBeenCalled();
|
||||
expect(selectChannelMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs refresh failures and still retries selection once on the first attempt', async () => {
|
||||
const selected = { channel: { id: 33 } };
|
||||
selectChannelMock
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import type { SiteProxyConfigLike } from '../../services/siteProxy.js';
|
||||
@@ -19,6 +18,7 @@ import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js';
|
||||
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
|
||||
import { proxyChannelCoordinator } from '../../services/proxyChannelCoordinator.js';
|
||||
import { readRuntimeResponseText } from '../executors/types.js';
|
||||
import { selectProxyChannelForAttempt } from '../channelSelection.js';
|
||||
|
||||
type SelectedChannel = Awaited<ReturnType<typeof tokenRouter.selectChannel>>;
|
||||
type SurfaceWarningScope = 'chat' | 'responses';
|
||||
@@ -107,44 +107,9 @@ export async function selectSurfaceChannelForAttempt(input: {
|
||||
excludeChannelIds: number[];
|
||||
retryCount: number;
|
||||
stickySessionKey?: string | null;
|
||||
forcedChannelId?: number | null;
|
||||
}): Promise<SelectedChannel> {
|
||||
let selected: SelectedChannel = null;
|
||||
|
||||
if (input.retryCount === 0 && input.stickySessionKey) {
|
||||
const preferredChannelId = proxyChannelCoordinator.getStickyChannelId(input.stickySessionKey);
|
||||
if (preferredChannelId && !input.excludeChannelIds.includes(preferredChannelId)) {
|
||||
selected = await tokenRouter.selectPreferredChannel(
|
||||
input.requestedModel,
|
||||
preferredChannelId,
|
||||
input.downstreamPolicy,
|
||||
input.excludeChannelIds,
|
||||
);
|
||||
if (!selected) {
|
||||
proxyChannelCoordinator.clearStickyChannel(input.stickySessionKey, preferredChannelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
selected = input.retryCount === 0
|
||||
? await tokenRouter.selectChannel(input.requestedModel, input.downstreamPolicy)
|
||||
: await tokenRouter.selectNextChannel(
|
||||
input.requestedModel,
|
||||
input.excludeChannelIds,
|
||||
input.downstreamPolicy,
|
||||
);
|
||||
}
|
||||
|
||||
if (!selected && input.retryCount === 0) {
|
||||
try {
|
||||
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||
} catch (error) {
|
||||
console.warn('[proxy/surface] failed to refresh routes after empty selection', error);
|
||||
}
|
||||
selected = await tokenRouter.selectChannel(input.requestedModel, input.downstreamPolicy);
|
||||
}
|
||||
|
||||
return selected;
|
||||
return await selectProxyChannelForAttempt(input);
|
||||
}
|
||||
|
||||
export function buildSurfaceStickySessionKey(input: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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