diff --git a/src/server/config.ts b/src/server/config.ts
index f17c17e..ff3fa34 100644
--- a/src/server/config.ts
+++ b/src/server/config.ts
@@ -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),
diff --git a/src/server/proxy-core/surfaces/chatSurface.ts b/src/server/proxy-core/surfaces/chatSurface.ts
index cf1e9dc..b07b942 100644
--- a/src/server/proxy-core/surfaces/chatSurface.ts
+++ b/src/server/proxy-core/surfaces/chatSurface.ts
@@ -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,
diff --git a/src/server/proxy-core/surfaces/geminiSurface.ts b/src/server/proxy-core/surfaces/geminiSurface.ts
index 7c139c1..02b3810 100644
--- a/src/server/proxy-core/surfaces/geminiSurface.ts
+++ b/src/server/proxy-core/surfaces/geminiSurface.ts
@@ -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,
diff --git a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts
index b600517..e376cac 100644
--- a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts
+++ b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts
@@ -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,
diff --git a/src/server/routes/api/settings.events.test.ts b/src/server/routes/api/settings.events.test.ts
index b6e324a..9d9f197 100644
--- a/src/server/routes/api/settings.events.test.ts
+++ b/src/server/routes/api/settings.events.test.ts
@@ -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',
diff --git a/src/server/routes/api/settings.ts b/src/server/routes/api/settings.ts
index 20e9ad4..fc364d5 100644
--- a/src/server/routes/api/settings.ts
+++ b/src/server/routes/api/settings.ts
@@ -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) {
diff --git a/src/server/routes/proxy/chat.stream.test.ts b/src/server/routes/proxy/chat.stream.test.ts
index 26dc9c6..cc3d081 100644
--- a/src/server/routes/proxy/chat.stream.test.ts
+++ b/src/server/routes/proxy/chat.stream.test.ts
@@ -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(
+ '
502 Bad GatewayCloudflare',
+ {
+ 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 },
diff --git a/src/server/routes/proxy/endpointFlow.test.ts b/src/server/routes/proxy/endpointFlow.test.ts
index 89b0232..9a0a4b6 100644
--- a/src/server/routes/proxy/endpointFlow.test.ts
+++ b/src/server/routes/proxy/endpointFlow.test.ts
@@ -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({
diff --git a/src/server/routes/proxy/endpointFlow.ts b/src/server/routes/proxy/endpointFlow.ts
index 183be2c..464ce80 100644
--- a/src/server/routes/proxy/endpointFlow.ts
+++ b/src/server/routes/proxy/endpointFlow.ts
@@ -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, {
diff --git a/src/web/api.ts b/src/web/api.ts
index e4e038f..a8ce1bd 100644
--- a/src/web/api.ts
+++ b/src/web/api.ts
@@ -329,6 +329,68 @@ export type SystemProxyTestResponse = {
latencyMs: number;
};
+export type RuntimeRoutingWeightsPayload = {
+ baseWeightFactor?: number;
+ valueScoreFactor?: number;
+ costWeight?: number;
+ balanceWeight?: number;
+ usageWeight?: number;
+};
+
+export type RuntimeSettingsPayload = {
+ proxyToken?: string;
+ systemProxyUrl?: string;
+ codexUpstreamWebsocketEnabled?: boolean;
+ disableCrossProtocolFallback?: boolean;
+ proxySessionChannelConcurrencyLimit?: number;
+ proxySessionChannelQueueWaitMs?: number;
+ proxyDebugTraceEnabled?: boolean;
+ proxyDebugCaptureHeaders?: boolean;
+ proxyDebugCaptureBodies?: boolean;
+ proxyDebugCaptureStreamChunks?: boolean;
+ proxyDebugTargetSessionId?: string;
+ proxyDebugTargetClientKind?: string;
+ proxyDebugTargetModel?: string;
+ proxyDebugRetentionHours?: number;
+ proxyDebugMaxBodyBytes?: number;
+ checkinCron?: string;
+ checkinScheduleMode?: 'cron' | 'interval';
+ checkinIntervalHours?: number;
+ balanceRefreshCron?: string;
+ logCleanupCron?: string;
+ logCleanupUsageLogsEnabled?: boolean;
+ logCleanupProgramLogsEnabled?: boolean;
+ logCleanupRetentionDays?: number;
+ webhookUrl?: string;
+ barkUrl?: string;
+ webhookEnabled?: boolean;
+ barkEnabled?: boolean;
+ serverChanEnabled?: boolean;
+ serverChanKey?: string;
+ telegramEnabled?: boolean;
+ telegramApiBaseUrl?: string;
+ telegramBotToken?: string;
+ telegramChatId?: string;
+ telegramUseSystemProxy?: boolean;
+ telegramMessageThreadId?: string;
+ smtpEnabled?: boolean;
+ smtpHost?: string;
+ smtpPort?: number;
+ smtpSecure?: boolean;
+ smtpUser?: string;
+ smtpPass?: string;
+ smtpFrom?: string;
+ smtpTo?: string;
+ notifyCooldownSec?: number;
+ adminIpAllowlist?: string[] | string;
+ routingFallbackUnitCost?: number;
+ routingWeights?: RuntimeRoutingWeightsPayload;
+ proxyErrorKeywords?: string[] | string;
+ proxyEmptyContentFailEnabled?: boolean;
+ globalBlockedBrands?: string[];
+ globalAllowedModels?: string[];
+};
+
export type ProxyLogStatusFilter = 'all' | 'success' | 'failed';
export type ProxyLogClientConfidence = 'exact' | 'heuristic' | 'unknown' | null;
@@ -753,7 +815,7 @@ export const api = {
}),
getRuntimeSettings: () => request('/api/settings/runtime'),
getBrandList: () => request('/api/settings/brand-list'),
- updateRuntimeSettings: (data: any) => request('/api/settings/runtime', {
+ updateRuntimeSettings: (data: RuntimeSettingsPayload) => request('/api/settings/runtime', {
method: 'PUT',
body: JSON.stringify(data),
}),
diff --git a/src/web/pages/NotificationSettings.tsx b/src/web/pages/NotificationSettings.tsx
index ce86e2a..fb996a5 100644
--- a/src/web/pages/NotificationSettings.tsx
+++ b/src/web/pages/NotificationSettings.tsx
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
-import { api } from '../api.js';
+import { api, type RuntimeSettingsPayload } from '../api.js';
import { useToast } from '../components/Toast.js';
import { tr } from '../i18n.js';
@@ -112,7 +112,7 @@ export default function NotificationSettings() {
const saveNotify = async () => {
setSavingNotify(true);
try {
- const payload: any = {
+ const payload: RuntimeSettingsPayload = {
webhookUrl: runtime.webhookUrl,
barkUrl: runtime.barkUrl,
webhookEnabled: runtime.webhookEnabled,
diff --git a/src/web/pages/ProxyLogs.tsx b/src/web/pages/ProxyLogs.tsx
index 2acbec7..e66b672 100644
--- a/src/web/pages/ProxyLogs.tsx
+++ b/src/web/pages/ProxyLogs.tsx
@@ -2,6 +2,7 @@ import React, { useDeferredValue, useEffect, useMemo, useRef, useState, useCallb
import { useLocation, useNavigate } from 'react-router-dom';
import {
api,
+ type RuntimeSettingsPayload,
type ProxyDebugTraceDetail,
type ProxyDebugTraceListItem,
type ProxyLogBillingDetails,
@@ -490,7 +491,7 @@ function normalizeProxyDebugSettings(value: any): ProxyDebugSettingsState {
};
}
-function buildProxyDebugSettingsPayload(settings: ProxyDebugSettingsState) {
+function buildProxyDebugSettingsPayload(settings: ProxyDebugSettingsState): RuntimeSettingsPayload {
return {
proxyDebugTraceEnabled: settings.proxyDebugTraceEnabled,
proxyDebugCaptureHeaders: settings.proxyDebugCaptureHeaders,
diff --git a/src/web/pages/Settings.tsx b/src/web/pages/Settings.tsx
index 4199105..6b925a8 100644
--- a/src/web/pages/Settings.tsx
+++ b/src/web/pages/Settings.tsx
@@ -51,6 +51,7 @@ type RuntimeSettings = {
logCleanupProgramLogsEnabled: boolean;
logCleanupRetentionDays: number;
codexUpstreamWebsocketEnabled: boolean;
+ disableCrossProtocolFallback: boolean;
proxySessionChannelConcurrencyLimit: number;
proxySessionChannelQueueWaitMs: number;
routingFallbackUnitCost: number;
@@ -196,6 +197,7 @@ export default function Settings() {
logCleanupProgramLogsEnabled: false,
logCleanupRetentionDays: 30,
codexUpstreamWebsocketEnabled: false,
+ disableCrossProtocolFallback: false,
proxySessionChannelConcurrencyLimit: 2,
proxySessionChannelQueueWaitMs: 1500,
routingFallbackUnitCost: 1,
@@ -426,6 +428,7 @@ export default function Settings() {
? Math.trunc(Number(runtimeInfo.logCleanupRetentionDays))
: 30,
codexUpstreamWebsocketEnabled: !!runtimeInfo.codexUpstreamWebsocketEnabled,
+ disableCrossProtocolFallback: !!runtimeInfo.disableCrossProtocolFallback,
proxySessionChannelConcurrencyLimit: Number(runtimeInfo.proxySessionChannelConcurrencyLimit) >= 0
? Math.trunc(Number(runtimeInfo.proxySessionChannelConcurrencyLimit))
: 2,
@@ -842,6 +845,7 @@ export default function Settings() {
await api.updateRuntimeSettings({
routingWeights: runtime.routingWeights,
routingFallbackUnitCost: runtime.routingFallbackUnitCost,
+ disableCrossProtocolFallback: runtime.disableCrossProtocolFallback,
});
toast.success('Routing weights saved');
} catch (err: any) {
@@ -1534,6 +1538,26 @@ export default function Settings() {
+
+