From 26f9717ad796e46868ec7bbe9d8c7bcff5fcfd4f Mon Sep 17 00:00:00 2001 From: Cita Date: Mon, 30 Mar 2026 20:02:51 +0800 Subject: [PATCH] [codex] add setting to disable cross-protocol fallback (#332) * Add toggle to disable cross-protocol fallback * Tighten runtime settings payload typing --- src/server/config.ts | 1 + src/server/proxy-core/surfaces/chatSurface.ts | 2 + .../proxy-core/surfaces/geminiSurface.ts | 2 + .../surfaces/openAiResponsesSurface.ts | 2 + src/server/routes/api/settings.events.test.ts | 28 ++++++++ src/server/routes/api/settings.ts | 25 ++++++++ src/server/routes/proxy/chat.stream.test.ts | 38 +++++++++++ src/server/routes/proxy/endpointFlow.test.ts | 34 ++++++++++ src/server/routes/proxy/endpointFlow.ts | 7 ++ src/web/api.ts | 64 ++++++++++++++++++- src/web/pages/NotificationSettings.tsx | 4 +- src/web/pages/ProxyLogs.tsx | 3 +- src/web/pages/Settings.tsx | 24 +++++++ 13 files changed, 230 insertions(+), 4 deletions(-) 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() { + +