[codex] add setting to disable cross-protocol fallback (#332)

* Add toggle to disable cross-protocol fallback

* Tighten runtime settings payload typing
This commit is contained in:
Cita
2026-03-30 20:02:51 +08:00
committed by GitHub
parent 6726da987f
commit 26f9717ad7
13 changed files with 230 additions and 4 deletions
+1
View File
@@ -117,6 +117,7 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
proxySessionChannelLeaseTtlMs: Math.max(5_000, Math.trunc(parseNumber(env.PROXY_SESSION_CHANNEL_LEASE_TTL_MS, 90_000))),
proxySessionChannelLeaseKeepaliveMs: Math.max(1_000, Math.trunc(parseNumber(env.PROXY_SESSION_CHANNEL_LEASE_KEEPALIVE_MS, 15_000))),
codexUpstreamWebsocketEnabled: parseBoolean(env.CODEX_UPSTREAM_WEBSOCKET_ENABLED, false),
disableCrossProtocolFallback: parseBoolean(env.DISABLE_CROSS_PROTOCOL_FALLBACK, false),
proxyDebugTraceEnabled: parseBoolean(env.PROXY_DEBUG_TRACE_ENABLED, false),
proxyDebugCaptureHeaders: parseBoolean(env.PROXY_DEBUG_CAPTURE_HEADERS, true),
proxyDebugCaptureBodies: parseBoolean(env.PROXY_DEBUG_CAPTURE_BODIES, false),
@@ -1,5 +1,6 @@
import { TextDecoder } from 'node:util';
import type { FastifyRequest, FastifyReply } from 'fastify';
import { config } from '../../config.js';
import { tokenRouter } from '../../services/tokenRouter.js';
import { reportProxyAllFailed } from '../../services/alertService.js';
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
@@ -383,6 +384,7 @@ export async function handleChatSurfaceRequest(
const debugAttemptBase = reserveSurfaceProxyDebugAttemptBase(debugTrace, endpointCandidates.length);
const endpointResult = await executeEndpointFlow({
siteUrl: selected.site.url,
disableCrossProtocolFallback: config.disableCrossProtocolFallback,
endpointCandidates,
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
dispatchRequest,
@@ -2,6 +2,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { TextDecoder } from 'node:util';
import { fetch } from 'undici';
import { and, eq } from 'drizzle-orm';
import { config } from '../../config.js';
import { db, schema } from '../../db/index.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
@@ -1169,6 +1170,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
const debugAttemptBase = reserveSurfaceProxyDebugAttemptBase(debugTrace, endpointCandidates.length);
const endpointResult = await executeEndpointFlow({
siteUrl: selected.site.url,
disableCrossProtocolFallback: config.disableCrossProtocolFallback,
endpointCandidates,
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
dispatchRequest,
@@ -1,5 +1,6 @@
import { TextDecoder } from 'node:util';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { config } from '../../config.js';
import { reportProxyAllFailed } from '../../services/alertService.js';
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
@@ -501,6 +502,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
const debugAttemptBase = reserveSurfaceProxyDebugAttemptBase(debugTrace, endpointCandidates.length);
const endpointResult = await executeEndpointFlow({
siteUrl: selected.site.url,
disableCrossProtocolFallback: config.disableCrossProtocolFallback,
endpointCandidates,
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
dispatchRequest,
@@ -65,6 +65,7 @@ describe('settings and auth events', () => {
(config as any).proxyDebugRetentionHours = 24;
(config as any).proxyDebugMaxBodyBytes = 262144;
config.routingFallbackUnitCost = 1;
(config as any).disableCrossProtocolFallback = false;
(config as any).telegramEnabled = false;
(config as any).telegramApiBaseUrl = 'https://api.telegram.org';
(config as any).telegramBotToken = '';
@@ -431,6 +432,33 @@ describe('settings and auth events', () => {
expect(runtime.routingFallbackUnitCost).toBe(0.25);
});
it('persists and returns disable cross protocol fallback from runtime settings', async () => {
const updateResponse = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
disableCrossProtocolFallback: true,
},
});
expect(updateResponse.statusCode).toBe(200);
const updated = updateResponse.json() as { disableCrossProtocolFallback?: boolean };
expect(updated.disableCrossProtocolFallback).toBe(true);
expect((config as any).disableCrossProtocolFallback).toBe(true);
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'disable_cross_protocol_fallback')).get();
expect(saved?.value).toBe(JSON.stringify(true));
const getResponse = await app.inject({
method: 'GET',
url: '/api/settings/runtime',
});
expect(getResponse.statusCode).toBe(200);
const runtime = getResponse.json() as { disableCrossProtocolFallback?: boolean };
expect(runtime.disableCrossProtocolFallback).toBe(true);
});
it('persists and returns system proxy url from runtime settings', async () => {
const updateResponse = await app.inject({
method: 'PUT',
+25
View File
@@ -39,6 +39,7 @@ interface RuntimeSettingsBody {
proxyToken?: string;
systemProxyUrl?: string;
codexUpstreamWebsocketEnabled?: boolean;
disableCrossProtocolFallback?: boolean;
proxySessionChannelConcurrencyLimit?: number;
proxySessionChannelQueueWaitMs?: number;
proxyDebugTraceEnabled?: boolean;
@@ -399,6 +400,11 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
config.codexUpstreamWebsocketEnabled = value;
return;
}
case 'disable_cross_protocol_fallback': {
if (typeof value !== 'boolean') return;
config.disableCrossProtocolFallback = value;
return;
}
case 'proxy_error_keywords': {
try {
config.proxyErrorKeywords = parseProxyErrorKeywords(value);
@@ -667,6 +673,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
logCleanupProgramLogsEnabled: config.logCleanupProgramLogsEnabled,
logCleanupRetentionDays: config.logCleanupRetentionDays,
codexUpstreamWebsocketEnabled: config.codexUpstreamWebsocketEnabled,
disableCrossProtocolFallback: config.disableCrossProtocolFallback,
proxySessionChannelConcurrencyLimit: config.proxySessionChannelConcurrencyLimit,
proxySessionChannelQueueWaitMs: config.proxySessionChannelQueueWaitMs,
proxyDebugTraceEnabled: config.proxyDebugTraceEnabled,
@@ -1080,6 +1087,24 @@ export async function settingsRoutes(app: FastifyInstance) {
upsertSetting('codex_upstream_websocket_enabled', config.codexUpstreamWebsocketEnabled);
}
if (body.disableCrossProtocolFallback !== undefined) {
let nextValue = false;
try {
nextValue = parseBooleanFlag(body.disableCrossProtocolFallback, '跨协议回退开关');
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || '跨协议回退开关格式无效',
});
}
if (nextValue !== config.disableCrossProtocolFallback) {
changedLabels.push('失败时不尝试其他协议');
}
config.disableCrossProtocolFallback = nextValue;
upsertSetting('disable_cross_protocol_fallback', config.disableCrossProtocolFallback);
}
if (body.proxySessionChannelConcurrencyLimit !== undefined) {
const limit = Number(body.proxySessionChannelConcurrencyLimit);
if (!Number.isFinite(limit) || limit < 0) {
@@ -127,6 +127,7 @@ describe('chat proxy stream behavior', () => {
overrideRaw: [],
filter: [],
};
(config as any).disableCrossProtocolFallback = false;
config.proxyEmptyContentFailEnabled = false;
config.proxyErrorKeywords = [];
});
@@ -3077,6 +3078,43 @@ describe('chat proxy stream behavior', () => {
expect(body?.choices?.[0]?.message?.content).toContain('ok via responses fallback after 502');
});
it('stops after the first failed protocol when cross protocol fallback is disabled', async () => {
(config as any).disableCrossProtocolFallback = true;
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'generic-site', url: 'https://generic.example.com', platform: 'new-api' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-generic',
actualModel: 'claude-haiku-4-5-20251001',
});
fetchMock.mockResolvedValueOnce(new Response(
'<!DOCTYPE html><html><head><title>502 Bad Gateway</title></head><body>Cloudflare</body></html>',
{
status: 502,
headers: { 'content-type': 'text/html; charset=UTF-8' },
},
));
const response = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'claude-haiku-4-5-20251001',
stream: false,
messages: [{ role: 'user', content: 'hello' }],
},
});
expect(response.statusCode).toBe(502);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [firstUrl] = fetchMock.mock.calls[0] as [string, any];
expect(firstUrl).toContain('/v1/messages');
const body = response.json();
expect(body?.error?.message).toContain('/v1/messages');
});
it('continues to /v1/responses when /v1/messages dispatch is denied for /v1/chat/completions', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
@@ -156,6 +156,40 @@ describe('executeEndpointFlow', () => {
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('does not downgrade to the next endpoint when cross protocol fallback is disabled', async () => {
fetchMock
.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
error: { message: 'unsupported endpoint', type: 'invalid_request_error' },
}), {
status: 404,
headers: { 'content-type': 'application/json' },
})))
.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
})));
const onDowngrade = vi.fn();
const result = await executeEndpointFlow({
siteUrl: 'https://example.com',
endpointCandidates: ['responses', 'chat'],
buildRequest: (endpoint) => endpoint === 'responses'
? requestFor('/v1/responses')
: { ...requestFor('/v1/chat/completions'), endpoint },
shouldDowngrade: () => true,
disableCrossProtocolFallback: true,
onDowngrade,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(404);
expect(result.errText).toContain('/v1/responses');
}
expect(onDowngrade).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('emits attempt callbacks for failed and successful endpoint probes', async () => {
fetchMock
.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
+7
View File
@@ -65,6 +65,7 @@ export function withUpstreamPath(path: string, message: string): string {
type ExecuteEndpointFlowInput = {
siteUrl: string;
proxyUrl?: string | null;
disableCrossProtocolFallback?: boolean;
endpointCandidates: UpstreamEndpoint[];
buildRequest: (endpoint: UpstreamEndpoint, endpointIndex: number) => BuiltEndpointRequest;
dispatchRequest?: (
@@ -189,6 +190,12 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
}, 'onAttemptFailure');
const isLastEndpoint = endpointIndex >= endpointCount - 1;
if (input.disableCrossProtocolFallback && !isLastEndpoint) {
finalStatus = response.status;
finalErrText = errText;
finalRawErrText = rawErrText;
break;
}
const shouldDowngrade = !isLastEndpoint && !!input.shouldDowngrade?.(baseContext);
if (shouldDowngrade) {
await runEndpointFlowHook(input.onDowngrade, {