[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:
@@ -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))),
|
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))),
|
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),
|
codexUpstreamWebsocketEnabled: parseBoolean(env.CODEX_UPSTREAM_WEBSOCKET_ENABLED, false),
|
||||||
|
disableCrossProtocolFallback: parseBoolean(env.DISABLE_CROSS_PROTOCOL_FALLBACK, false),
|
||||||
proxyDebugTraceEnabled: parseBoolean(env.PROXY_DEBUG_TRACE_ENABLED, false),
|
proxyDebugTraceEnabled: parseBoolean(env.PROXY_DEBUG_TRACE_ENABLED, false),
|
||||||
proxyDebugCaptureHeaders: parseBoolean(env.PROXY_DEBUG_CAPTURE_HEADERS, true),
|
proxyDebugCaptureHeaders: parseBoolean(env.PROXY_DEBUG_CAPTURE_HEADERS, true),
|
||||||
proxyDebugCaptureBodies: parseBoolean(env.PROXY_DEBUG_CAPTURE_BODIES, false),
|
proxyDebugCaptureBodies: parseBoolean(env.PROXY_DEBUG_CAPTURE_BODIES, false),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { TextDecoder } from 'node:util';
|
import { TextDecoder } from 'node:util';
|
||||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { config } from '../../config.js';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { reportProxyAllFailed } from '../../services/alertService.js';
|
import { reportProxyAllFailed } from '../../services/alertService.js';
|
||||||
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||||
@@ -383,6 +384,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
const debugAttemptBase = reserveSurfaceProxyDebugAttemptBase(debugTrace, endpointCandidates.length);
|
const debugAttemptBase = reserveSurfaceProxyDebugAttemptBase(debugTrace, endpointCandidates.length);
|
||||||
const endpointResult = await executeEndpointFlow({
|
const endpointResult = await executeEndpointFlow({
|
||||||
siteUrl: selected.site.url,
|
siteUrl: selected.site.url,
|
||||||
|
disableCrossProtocolFallback: config.disableCrossProtocolFallback,
|
||||||
endpointCandidates,
|
endpointCandidates,
|
||||||
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
||||||
dispatchRequest,
|
dispatchRequest,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
|||||||
import { TextDecoder } from 'node:util';
|
import { TextDecoder } from 'node:util';
|
||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { and, eq } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { config } from '../../config.js';
|
||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
|
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||||
@@ -1169,6 +1170,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
const debugAttemptBase = reserveSurfaceProxyDebugAttemptBase(debugTrace, endpointCandidates.length);
|
const debugAttemptBase = reserveSurfaceProxyDebugAttemptBase(debugTrace, endpointCandidates.length);
|
||||||
const endpointResult = await executeEndpointFlow({
|
const endpointResult = await executeEndpointFlow({
|
||||||
siteUrl: selected.site.url,
|
siteUrl: selected.site.url,
|
||||||
|
disableCrossProtocolFallback: config.disableCrossProtocolFallback,
|
||||||
endpointCandidates,
|
endpointCandidates,
|
||||||
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
||||||
dispatchRequest,
|
dispatchRequest,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { TextDecoder } from 'node:util';
|
import { TextDecoder } from 'node:util';
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { config } from '../../config.js';
|
||||||
import { reportProxyAllFailed } from '../../services/alertService.js';
|
import { reportProxyAllFailed } from '../../services/alertService.js';
|
||||||
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||||
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
||||||
@@ -501,6 +502,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
const debugAttemptBase = reserveSurfaceProxyDebugAttemptBase(debugTrace, endpointCandidates.length);
|
const debugAttemptBase = reserveSurfaceProxyDebugAttemptBase(debugTrace, endpointCandidates.length);
|
||||||
const endpointResult = await executeEndpointFlow({
|
const endpointResult = await executeEndpointFlow({
|
||||||
siteUrl: selected.site.url,
|
siteUrl: selected.site.url,
|
||||||
|
disableCrossProtocolFallback: config.disableCrossProtocolFallback,
|
||||||
endpointCandidates,
|
endpointCandidates,
|
||||||
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
||||||
dispatchRequest,
|
dispatchRequest,
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ describe('settings and auth events', () => {
|
|||||||
(config as any).proxyDebugRetentionHours = 24;
|
(config as any).proxyDebugRetentionHours = 24;
|
||||||
(config as any).proxyDebugMaxBodyBytes = 262144;
|
(config as any).proxyDebugMaxBodyBytes = 262144;
|
||||||
config.routingFallbackUnitCost = 1;
|
config.routingFallbackUnitCost = 1;
|
||||||
|
(config as any).disableCrossProtocolFallback = false;
|
||||||
(config as any).telegramEnabled = false;
|
(config as any).telegramEnabled = false;
|
||||||
(config as any).telegramApiBaseUrl = 'https://api.telegram.org';
|
(config as any).telegramApiBaseUrl = 'https://api.telegram.org';
|
||||||
(config as any).telegramBotToken = '';
|
(config as any).telegramBotToken = '';
|
||||||
@@ -431,6 +432,33 @@ describe('settings and auth events', () => {
|
|||||||
expect(runtime.routingFallbackUnitCost).toBe(0.25);
|
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 () => {
|
it('persists and returns system proxy url from runtime settings', async () => {
|
||||||
const updateResponse = await app.inject({
|
const updateResponse = await app.inject({
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ interface RuntimeSettingsBody {
|
|||||||
proxyToken?: string;
|
proxyToken?: string;
|
||||||
systemProxyUrl?: string;
|
systemProxyUrl?: string;
|
||||||
codexUpstreamWebsocketEnabled?: boolean;
|
codexUpstreamWebsocketEnabled?: boolean;
|
||||||
|
disableCrossProtocolFallback?: boolean;
|
||||||
proxySessionChannelConcurrencyLimit?: number;
|
proxySessionChannelConcurrencyLimit?: number;
|
||||||
proxySessionChannelQueueWaitMs?: number;
|
proxySessionChannelQueueWaitMs?: number;
|
||||||
proxyDebugTraceEnabled?: boolean;
|
proxyDebugTraceEnabled?: boolean;
|
||||||
@@ -399,6 +400,11 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
|
|||||||
config.codexUpstreamWebsocketEnabled = value;
|
config.codexUpstreamWebsocketEnabled = value;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case 'disable_cross_protocol_fallback': {
|
||||||
|
if (typeof value !== 'boolean') return;
|
||||||
|
config.disableCrossProtocolFallback = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
case 'proxy_error_keywords': {
|
case 'proxy_error_keywords': {
|
||||||
try {
|
try {
|
||||||
config.proxyErrorKeywords = parseProxyErrorKeywords(value);
|
config.proxyErrorKeywords = parseProxyErrorKeywords(value);
|
||||||
@@ -667,6 +673,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
|
|||||||
logCleanupProgramLogsEnabled: config.logCleanupProgramLogsEnabled,
|
logCleanupProgramLogsEnabled: config.logCleanupProgramLogsEnabled,
|
||||||
logCleanupRetentionDays: config.logCleanupRetentionDays,
|
logCleanupRetentionDays: config.logCleanupRetentionDays,
|
||||||
codexUpstreamWebsocketEnabled: config.codexUpstreamWebsocketEnabled,
|
codexUpstreamWebsocketEnabled: config.codexUpstreamWebsocketEnabled,
|
||||||
|
disableCrossProtocolFallback: config.disableCrossProtocolFallback,
|
||||||
proxySessionChannelConcurrencyLimit: config.proxySessionChannelConcurrencyLimit,
|
proxySessionChannelConcurrencyLimit: config.proxySessionChannelConcurrencyLimit,
|
||||||
proxySessionChannelQueueWaitMs: config.proxySessionChannelQueueWaitMs,
|
proxySessionChannelQueueWaitMs: config.proxySessionChannelQueueWaitMs,
|
||||||
proxyDebugTraceEnabled: config.proxyDebugTraceEnabled,
|
proxyDebugTraceEnabled: config.proxyDebugTraceEnabled,
|
||||||
@@ -1080,6 +1087,24 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
upsertSetting('codex_upstream_websocket_enabled', config.codexUpstreamWebsocketEnabled);
|
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) {
|
if (body.proxySessionChannelConcurrencyLimit !== undefined) {
|
||||||
const limit = Number(body.proxySessionChannelConcurrencyLimit);
|
const limit = Number(body.proxySessionChannelConcurrencyLimit);
|
||||||
if (!Number.isFinite(limit) || limit < 0) {
|
if (!Number.isFinite(limit) || limit < 0) {
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ describe('chat proxy stream behavior', () => {
|
|||||||
overrideRaw: [],
|
overrideRaw: [],
|
||||||
filter: [],
|
filter: [],
|
||||||
};
|
};
|
||||||
|
(config as any).disableCrossProtocolFallback = false;
|
||||||
config.proxyEmptyContentFailEnabled = false;
|
config.proxyEmptyContentFailEnabled = false;
|
||||||
config.proxyErrorKeywords = [];
|
config.proxyErrorKeywords = [];
|
||||||
});
|
});
|
||||||
@@ -3077,6 +3078,43 @@ describe('chat proxy stream behavior', () => {
|
|||||||
expect(body?.choices?.[0]?.message?.content).toContain('ok via responses fallback after 502');
|
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 () => {
|
it('continues to /v1/responses when /v1/messages dispatch is denied for /v1/chat/completions', async () => {
|
||||||
selectChannelMock.mockReturnValue({
|
selectChannelMock.mockReturnValue({
|
||||||
channel: { id: 11, routeId: 22 },
|
channel: { id: 11, routeId: 22 },
|
||||||
|
|||||||
@@ -156,6 +156,40 @@ describe('executeEndpointFlow', () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
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 () => {
|
it('emits attempt callbacks for failed and successful endpoint probes', async () => {
|
||||||
fetchMock
|
fetchMock
|
||||||
.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
|
.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export function withUpstreamPath(path: string, message: string): string {
|
|||||||
type ExecuteEndpointFlowInput = {
|
type ExecuteEndpointFlowInput = {
|
||||||
siteUrl: string;
|
siteUrl: string;
|
||||||
proxyUrl?: string | null;
|
proxyUrl?: string | null;
|
||||||
|
disableCrossProtocolFallback?: boolean;
|
||||||
endpointCandidates: UpstreamEndpoint[];
|
endpointCandidates: UpstreamEndpoint[];
|
||||||
buildRequest: (endpoint: UpstreamEndpoint, endpointIndex: number) => BuiltEndpointRequest;
|
buildRequest: (endpoint: UpstreamEndpoint, endpointIndex: number) => BuiltEndpointRequest;
|
||||||
dispatchRequest?: (
|
dispatchRequest?: (
|
||||||
@@ -189,6 +190,12 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
|
|||||||
}, 'onAttemptFailure');
|
}, 'onAttemptFailure');
|
||||||
|
|
||||||
const isLastEndpoint = endpointIndex >= endpointCount - 1;
|
const isLastEndpoint = endpointIndex >= endpointCount - 1;
|
||||||
|
if (input.disableCrossProtocolFallback && !isLastEndpoint) {
|
||||||
|
finalStatus = response.status;
|
||||||
|
finalErrText = errText;
|
||||||
|
finalRawErrText = rawErrText;
|
||||||
|
break;
|
||||||
|
}
|
||||||
const shouldDowngrade = !isLastEndpoint && !!input.shouldDowngrade?.(baseContext);
|
const shouldDowngrade = !isLastEndpoint && !!input.shouldDowngrade?.(baseContext);
|
||||||
if (shouldDowngrade) {
|
if (shouldDowngrade) {
|
||||||
await runEndpointFlowHook(input.onDowngrade, {
|
await runEndpointFlowHook(input.onDowngrade, {
|
||||||
|
|||||||
+63
-1
@@ -329,6 +329,68 @@ export type SystemProxyTestResponse = {
|
|||||||
latencyMs: number;
|
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 ProxyLogStatusFilter = 'all' | 'success' | 'failed';
|
||||||
export type ProxyLogClientConfidence = 'exact' | 'heuristic' | 'unknown' | null;
|
export type ProxyLogClientConfidence = 'exact' | 'heuristic' | 'unknown' | null;
|
||||||
|
|
||||||
@@ -753,7 +815,7 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
getRuntimeSettings: () => request('/api/settings/runtime'),
|
getRuntimeSettings: () => request('/api/settings/runtime'),
|
||||||
getBrandList: () => request('/api/settings/brand-list'),
|
getBrandList: () => request('/api/settings/brand-list'),
|
||||||
updateRuntimeSettings: (data: any) => request('/api/settings/runtime', {
|
updateRuntimeSettings: (data: RuntimeSettingsPayload) => request('/api/settings/runtime', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
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 { useToast } from '../components/Toast.js';
|
||||||
import { tr } from '../i18n.js';
|
import { tr } from '../i18n.js';
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ export default function NotificationSettings() {
|
|||||||
const saveNotify = async () => {
|
const saveNotify = async () => {
|
||||||
setSavingNotify(true);
|
setSavingNotify(true);
|
||||||
try {
|
try {
|
||||||
const payload: any = {
|
const payload: RuntimeSettingsPayload = {
|
||||||
webhookUrl: runtime.webhookUrl,
|
webhookUrl: runtime.webhookUrl,
|
||||||
barkUrl: runtime.barkUrl,
|
barkUrl: runtime.barkUrl,
|
||||||
webhookEnabled: runtime.webhookEnabled,
|
webhookEnabled: runtime.webhookEnabled,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useDeferredValue, useEffect, useMemo, useRef, useState, useCallb
|
|||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
|
type RuntimeSettingsPayload,
|
||||||
type ProxyDebugTraceDetail,
|
type ProxyDebugTraceDetail,
|
||||||
type ProxyDebugTraceListItem,
|
type ProxyDebugTraceListItem,
|
||||||
type ProxyLogBillingDetails,
|
type ProxyLogBillingDetails,
|
||||||
@@ -490,7 +491,7 @@ function normalizeProxyDebugSettings(value: any): ProxyDebugSettingsState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildProxyDebugSettingsPayload(settings: ProxyDebugSettingsState) {
|
function buildProxyDebugSettingsPayload(settings: ProxyDebugSettingsState): RuntimeSettingsPayload {
|
||||||
return {
|
return {
|
||||||
proxyDebugTraceEnabled: settings.proxyDebugTraceEnabled,
|
proxyDebugTraceEnabled: settings.proxyDebugTraceEnabled,
|
||||||
proxyDebugCaptureHeaders: settings.proxyDebugCaptureHeaders,
|
proxyDebugCaptureHeaders: settings.proxyDebugCaptureHeaders,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ type RuntimeSettings = {
|
|||||||
logCleanupProgramLogsEnabled: boolean;
|
logCleanupProgramLogsEnabled: boolean;
|
||||||
logCleanupRetentionDays: number;
|
logCleanupRetentionDays: number;
|
||||||
codexUpstreamWebsocketEnabled: boolean;
|
codexUpstreamWebsocketEnabled: boolean;
|
||||||
|
disableCrossProtocolFallback: boolean;
|
||||||
proxySessionChannelConcurrencyLimit: number;
|
proxySessionChannelConcurrencyLimit: number;
|
||||||
proxySessionChannelQueueWaitMs: number;
|
proxySessionChannelQueueWaitMs: number;
|
||||||
routingFallbackUnitCost: number;
|
routingFallbackUnitCost: number;
|
||||||
@@ -196,6 +197,7 @@ export default function Settings() {
|
|||||||
logCleanupProgramLogsEnabled: false,
|
logCleanupProgramLogsEnabled: false,
|
||||||
logCleanupRetentionDays: 30,
|
logCleanupRetentionDays: 30,
|
||||||
codexUpstreamWebsocketEnabled: false,
|
codexUpstreamWebsocketEnabled: false,
|
||||||
|
disableCrossProtocolFallback: false,
|
||||||
proxySessionChannelConcurrencyLimit: 2,
|
proxySessionChannelConcurrencyLimit: 2,
|
||||||
proxySessionChannelQueueWaitMs: 1500,
|
proxySessionChannelQueueWaitMs: 1500,
|
||||||
routingFallbackUnitCost: 1,
|
routingFallbackUnitCost: 1,
|
||||||
@@ -426,6 +428,7 @@ export default function Settings() {
|
|||||||
? Math.trunc(Number(runtimeInfo.logCleanupRetentionDays))
|
? Math.trunc(Number(runtimeInfo.logCleanupRetentionDays))
|
||||||
: 30,
|
: 30,
|
||||||
codexUpstreamWebsocketEnabled: !!runtimeInfo.codexUpstreamWebsocketEnabled,
|
codexUpstreamWebsocketEnabled: !!runtimeInfo.codexUpstreamWebsocketEnabled,
|
||||||
|
disableCrossProtocolFallback: !!runtimeInfo.disableCrossProtocolFallback,
|
||||||
proxySessionChannelConcurrencyLimit: Number(runtimeInfo.proxySessionChannelConcurrencyLimit) >= 0
|
proxySessionChannelConcurrencyLimit: Number(runtimeInfo.proxySessionChannelConcurrencyLimit) >= 0
|
||||||
? Math.trunc(Number(runtimeInfo.proxySessionChannelConcurrencyLimit))
|
? Math.trunc(Number(runtimeInfo.proxySessionChannelConcurrencyLimit))
|
||||||
: 2,
|
: 2,
|
||||||
@@ -842,6 +845,7 @@ export default function Settings() {
|
|||||||
await api.updateRuntimeSettings({
|
await api.updateRuntimeSettings({
|
||||||
routingWeights: runtime.routingWeights,
|
routingWeights: runtime.routingWeights,
|
||||||
routingFallbackUnitCost: runtime.routingFallbackUnitCost,
|
routingFallbackUnitCost: runtime.routingFallbackUnitCost,
|
||||||
|
disableCrossProtocolFallback: runtime.disableCrossProtocolFallback,
|
||||||
});
|
});
|
||||||
toast.success('Routing weights saved');
|
toast.success('Routing weights saved');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -1534,6 +1538,26 @@ export default function Settings() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, marginBottom: 12, cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={runtime.disableCrossProtocolFallback}
|
||||||
|
onChange={(e) => setRuntime((prev) => ({
|
||||||
|
...prev,
|
||||||
|
disableCrossProtocolFallback: e.target.checked,
|
||||||
|
}))}
|
||||||
|
style={{ marginTop: 2 }}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span style={{ display: 'block', fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>
|
||||||
|
失败时不尝试其他协议
|
||||||
|
</span>
|
||||||
|
<span style={{ display: 'block', fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.7 }}>
|
||||||
|
仅影响 chat / messages / responses 之间的协议切换;不会关闭同协议兼容重试、OAuth 刷新或通道级重试。
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div className={`anim-collapse ${showAdvancedRouting ? 'is-open' : ''}`.trim()}>
|
<div className={`anim-collapse ${showAdvancedRouting ? 'is-open' : ''}`.trim()}>
|
||||||
<div className="anim-collapse-inner" style={{ paddingTop: 2 }}>
|
<div className="anim-collapse-inner" style={{ paddingTop: 2 }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 12 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 12 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user