diff --git a/src/server/routes/api/settings.events.test.ts b/src/server/routes/api/settings.events.test.ts index e853f21..743cd88 100644 --- a/src/server/routes/api/settings.events.test.ts +++ b/src/server/routes/api/settings.events.test.ts @@ -68,6 +68,13 @@ describe('settings and auth events', () => { (config as any).proxyFirstByteTimeoutSec = 0; (config as any).tokenRouterFailureCooldownMaxSec = 30 * 24 * 60 * 60; (config as any).disableCrossProtocolFallback = false; + (config as any).payloadRules = { + default: [], + defaultRaw: [], + override: [], + overrideRaw: [], + filter: [], + }; (config as any).telegramEnabled = false; (config as any).telegramApiBaseUrl = 'https://api.telegram.org'; (config as any).telegramBotToken = ''; @@ -219,6 +226,137 @@ describe('settings and auth events', () => { expect(savedMaxBodyBytes?.value).toBe(JSON.stringify(131072)); }); + it('persists payload rules and returns the normalized runtime value', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + payload: { + payloadRules: { + override: [ + { + models: [{ name: 'gpt-*', protocol: 'codex' }], + params: { + 'reasoning.effort': 'high', + }, + }, + ], + 'override-raw': [ + { + models: [{ name: 'gpt-*', protocol: 'codex' }], + params: { + response_format: '{"type":"json_schema"}', + }, + }, + ], + }, + }, + }); + + expect(response.statusCode).toBe(200); + const payload = response.json() as { + payloadRules?: { + default?: unknown[]; + defaultRaw?: unknown[]; + override?: Array<{ params?: Record }>; + overrideRaw?: Array<{ params?: Record }>; + filter?: unknown[]; + }; + }; + expect(payload.payloadRules).toEqual({ + default: [], + defaultRaw: [], + override: [ + { + models: [{ name: 'gpt-*', protocol: 'codex' }], + params: { + 'reasoning.effort': 'high', + }, + }, + ], + overrideRaw: [ + { + models: [{ name: 'gpt-*', protocol: 'codex' }], + params: { + response_format: '{"type":"json_schema"}', + }, + }, + ], + filter: [], + }); + + const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'payload_rules')).get(); + expect(saved).toBeTruthy(); + expect(JSON.parse(String(saved?.value))).toEqual(payload.payloadRules); + expect(config.payloadRules).toEqual(payload.payloadRules); + + const events = await db.select().from(schema.events).all(); + expect(events.some((event) => (event.message || '').includes('Payload 规则'))).toBe(true); + }); + + it('rejects invalid payload raw rules with a clear message', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + payload: { + payloadRules: { + 'override-raw': [ + { + models: [{ name: 'gpt-*', protocol: 'codex' }], + params: { + response_format: '{broken-json', + }, + }, + ], + }, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + success: false, + message: 'Payload 规则 override-raw 第 1 条的 response_format 不是合法 JSON', + }); + + const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'payload_rules')).get(); + expect(saved).toBeFalsy(); + }); + + it('does not persist payload rules when a later runtime setting fails validation', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + payload: { + payloadRules: { + override: [ + { + models: [{ name: 'gpt-*', protocol: 'codex' }], + params: { + 'reasoning.effort': 'high', + }, + }, + ], + }, + modelAvailabilityProbeEnabled: 'invalid-boolean', + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + success: false, + message: '批量测活开关格式无效:需要 boolean', + }); + + const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'payload_rules')).get(); + expect(saved).toBeFalsy(); + expect(config.payloadRules).toEqual({ + default: [], + defaultRaw: [], + override: [], + overrideRaw: [], + filter: [], + }); + }); + it('returns current recognized admin IP in runtime settings response', async () => { const response = await app.inject({ method: 'GET', diff --git a/src/server/routes/api/settings.ts b/src/server/routes/api/settings.ts index a6acc6c..4186397 100644 --- a/src/server/routes/api/settings.ts +++ b/src/server/routes/api/settings.ts @@ -44,12 +44,14 @@ import { startModelAvailabilityProbeScheduler, stopModelAvailabilityProbeScheduler, } from '../../services/modelAvailabilityProbeService.js'; +import { parsePayloadRulesConfigInput } from '../../services/payloadRules.js'; type RoutingWeights = typeof config.routingWeights; interface RuntimeSettingsBody { proxyToken?: string; systemProxyUrl?: string; + payloadRules?: unknown; modelAvailabilityProbeEnabled?: boolean; codexUpstreamWebsocketEnabled?: boolean; responsesCompactFallbackToResponsesEnabled?: boolean; @@ -759,6 +761,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') { currentAdminIp, serverTimeZone: getResolvedTimeZone(), systemProxyUrl: config.systemProxyUrl, + payloadRules: config.payloadRules, proxyErrorKeywords: config.proxyErrorKeywords, proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled, proxyTokenMasked: maskSecret(config.proxyToken), @@ -902,6 +905,7 @@ export async function settingsRoutes(app: FastifyInstance) { const body = parsedBody.data as RuntimeSettingsBody; const changedLabels: string[] = []; const currentRequestIp = extractClientIp(request.ip, request.headers['x-forwarded-for']); + let pendingPayloadRules: typeof config.payloadRules | undefined; const webhookTouched = body.webhookUrl !== undefined || body.webhookEnabled !== undefined; const nextWebhookUrl = body.webhookUrl !== undefined @@ -1132,6 +1136,23 @@ export async function settingsRoutes(app: FastifyInstance) { invalidateSiteProxyCache(); } + if (body.payloadRules !== undefined) { + const parsedPayloadRules = parsePayloadRulesConfigInput(body.payloadRules); + if (!parsedPayloadRules.success) { + return reply.code(400).send({ + success: false, + message: parsedPayloadRules.message, + }); + } + + const previousRules = JSON.stringify(config.payloadRules); + const nextRules = JSON.stringify(parsedPayloadRules.normalized); + if (previousRules !== nextRules) { + changedLabels.push('Payload 规则'); + } + pendingPayloadRules = parsedPayloadRules.normalized; + } + if (body.modelAvailabilityProbeEnabled !== undefined) { let nextValue = false; try { @@ -1697,6 +1718,11 @@ export async function settingsRoutes(app: FastifyInstance) { upsertSetting('token_router_failure_cooldown_max_sec', normalized); } + if (pendingPayloadRules !== undefined) { + config.payloadRules = pendingPayloadRules; + await upsertSetting('payload_rules', pendingPayloadRules); + } + if (changedLabels.length > 0) { let eventType: 'checkin' | 'balance' | 'proxy' | 'status' | 'token' = 'status'; if (changedLabels.length === 1) { diff --git a/src/server/services/payloadRules.test.ts b/src/server/services/payloadRules.test.ts new file mode 100644 index 0000000..4e61c75 --- /dev/null +++ b/src/server/services/payloadRules.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { createEmptyPayloadRulesConfig, parsePayloadRulesConfigInput } from './payloadRules.js'; + +describe('parsePayloadRulesConfigInput', () => { + it('accepts CPA-style dashed raw keys and normalizes them to camelCase sections', () => { + const result = parsePayloadRulesConfigInput({ + override: [ + { + models: [{ name: 'gpt-*', protocol: 'codex' }], + params: { + 'reasoning.effort': 'high', + }, + }, + ], + 'override-raw': [ + { + models: [{ name: 'gpt-*', protocol: 'codex' }], + params: { + response_format: '{"type":"json_schema"}', + }, + }, + ], + filter: [ + { + models: [{ name: 'gpt-*', protocol: 'codex' }], + params: ['safety_identifier'], + }, + ], + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.normalized).toEqual({ + ...createEmptyPayloadRulesConfig(), + override: [ + { + models: [{ name: 'gpt-*', protocol: 'codex' }], + params: { + 'reasoning.effort': 'high', + }, + }, + ], + overrideRaw: [ + { + models: [{ name: 'gpt-*', protocol: 'codex' }], + params: { + response_format: '{"type":"json_schema"}', + }, + }, + ], + filter: [ + { + models: [{ name: 'gpt-*', protocol: 'codex' }], + params: ['safety_identifier'], + }, + ], + }); + }); + + it('rejects invalid raw JSON fragments', () => { + const result = parsePayloadRulesConfigInput({ + 'override-raw': [ + { + models: [{ name: 'gpt-*', protocol: 'codex' }], + params: { + response_format: '{invalid-json', + }, + }, + ], + }); + + expect(result).toEqual({ + success: false, + message: 'Payload 规则 override-raw 第 1 条的 response_format 不是合法 JSON', + }); + }); + + it('rejects unknown sections so the settings UI cannot silently save ignored data', () => { + const result = parsePayloadRulesConfigInput({ + override: [], + unexpected: [], + }); + + expect(result).toEqual({ + success: false, + message: 'Payload 规则包含未知分组:unexpected', + }); + }); +}); diff --git a/src/server/services/payloadRules.ts b/src/server/services/payloadRules.ts index 76f7bcb..8f5a39e 100644 --- a/src/server/services/payloadRules.ts +++ b/src/server/services/payloadRules.ts @@ -23,6 +23,18 @@ export type PayloadRulesConfig = { filter: PayloadFilterRule[]; }; +type PayloadRulesParseSuccess = { + success: true; + normalized: PayloadRulesConfig; +}; + +type PayloadRulesParseFailure = { + success: false; + message: string; +}; + +export type PayloadRulesParseResult = PayloadRulesParseSuccess | PayloadRulesParseFailure; + function asTrimmedString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } @@ -230,6 +242,165 @@ export function normalizePayloadRulesConfig(value: unknown): PayloadRulesConfig }; } +const PAYLOAD_RULE_SECTION_LABELS = { + default: 'default', + defaultRaw: 'default-raw', + override: 'override', + overrideRaw: 'override-raw', + filter: 'filter', +} as const; + +const PAYLOAD_RULE_ALLOWED_KEYS = new Set([ + 'default', + 'defaultRaw', + 'default-raw', + 'override', + 'overrideRaw', + 'override-raw', + 'filter', +]); + +function getPayloadRulesSectionValue(value: Record, key: keyof typeof PAYLOAD_RULE_SECTION_LABELS): unknown { + if (key === 'defaultRaw') return value.defaultRaw ?? value['default-raw']; + if (key === 'overrideRaw') return value.overrideRaw ?? value['override-raw']; + return value[key]; +} + +function hasConflictingPayloadRuleSectionKeys(value: Record, camelKey: 'defaultRaw' | 'overrideRaw', dashedKey: 'default-raw' | 'override-raw'): boolean { + return Object.prototype.hasOwnProperty.call(value, camelKey) && Object.prototype.hasOwnProperty.call(value, dashedKey); +} + +function validatePayloadRuleModelsInput(value: unknown, section: string, ruleIndex: number): string | null { + if (!Array.isArray(value) || value.length <= 0) { + return `Payload 规则 ${section} 第 ${ruleIndex} 条的 models 必须是非空数组`; + } + + for (let modelIndex = 0; modelIndex < value.length; modelIndex += 1) { + const model = value[modelIndex]; + if (!isRecord(model)) { + return `Payload 规则 ${section} 第 ${ruleIndex} 条的 models[${modelIndex + 1}] 必须是对象`; + } + if (!asTrimmedString(model.name)) { + return `Payload 规则 ${section} 第 ${ruleIndex} 条的 models[${modelIndex + 1}].name 不能为空`; + } + if (model.protocol !== undefined && typeof model.protocol !== 'string') { + return `Payload 规则 ${section} 第 ${ruleIndex} 条的 models[${modelIndex + 1}].protocol 必须是字符串`; + } + } + + return null; +} + +function validatePayloadValueRuleSectionInput(value: unknown, section: string, raw: boolean): string | null { + if (value === undefined) return null; + if (!Array.isArray(value)) { + return `Payload 规则 ${section} 必须是数组`; + } + + for (let index = 0; index < value.length; index += 1) { + const item = value[index]; + if (!isRecord(item)) { + return `Payload 规则 ${section} 第 ${index + 1} 条必须是对象`; + } + + const modelsError = validatePayloadRuleModelsInput(item.models, section, index + 1); + if (modelsError) return modelsError; + + if (!isRecord(item.params) || Object.keys(item.params).length <= 0) { + return `Payload 规则 ${section} 第 ${index + 1} 条的 params 必须是非空对象`; + } + + for (const [path, rawValue] of Object.entries(item.params)) { + if (!asTrimmedString(path)) { + return `Payload 规则 ${section} 第 ${index + 1} 条包含空参数路径`; + } + if (!raw) continue; + if (typeof rawValue !== 'string') continue; + const trimmed = rawValue.trim(); + if (!trimmed) { + return `Payload 规则 ${section} 第 ${index + 1} 条的 ${path} 不能为空字符串`; + } + try { + JSON.parse(trimmed); + } catch { + return `Payload 规则 ${section} 第 ${index + 1} 条的 ${path} 不是合法 JSON`; + } + } + } + + return null; +} + +function validatePayloadFilterRuleSectionInput(value: unknown, section: string): string | null { + if (value === undefined) return null; + if (!Array.isArray(value)) { + return `Payload 规则 ${section} 必须是数组`; + } + + for (let index = 0; index < value.length; index += 1) { + const item = value[index]; + if (!isRecord(item)) { + return `Payload 规则 ${section} 第 ${index + 1} 条必须是对象`; + } + + const modelsError = validatePayloadRuleModelsInput(item.models, section, index + 1); + if (modelsError) return modelsError; + + if (!Array.isArray(item.params) || item.params.length <= 0) { + return `Payload 规则 ${section} 第 ${index + 1} 条的 params 必须是非空字符串数组`; + } + + for (let pathIndex = 0; pathIndex < item.params.length; pathIndex += 1) { + if (!asTrimmedString(item.params[pathIndex])) { + return `Payload 规则 ${section} 第 ${index + 1} 条的 params[${pathIndex + 1}] 不能为空`; + } + } + } + + return null; +} + +export function parsePayloadRulesConfigInput(value: unknown): PayloadRulesParseResult { + if (value == null) { + return { success: true, normalized: createEmptyPayloadRulesConfig() }; + } + if (!isRecord(value)) { + return { success: false, message: 'Payload 规则必须是 JSON 对象' }; + } + + if (hasConflictingPayloadRuleSectionKeys(value, 'defaultRaw', 'default-raw')) { + return { success: false, message: 'Payload 规则不能同时包含 defaultRaw 和 default-raw' }; + } + if (hasConflictingPayloadRuleSectionKeys(value, 'overrideRaw', 'override-raw')) { + return { success: false, message: 'Payload 规则不能同时包含 overrideRaw 和 override-raw' }; + } + + const unknownKeys = Object.keys(value).filter((key) => !PAYLOAD_RULE_ALLOWED_KEYS.has(key)); + if (unknownKeys.length > 0) { + return { + success: false, + message: `Payload 规则包含未知分组:${unknownKeys.join(', ')}`, + }; + } + + const valueSectionError = validatePayloadValueRuleSectionInput(value.default, PAYLOAD_RULE_SECTION_LABELS.default, false) + ?? validatePayloadValueRuleSectionInput(getPayloadRulesSectionValue(value, 'defaultRaw'), PAYLOAD_RULE_SECTION_LABELS.defaultRaw, true) + ?? validatePayloadValueRuleSectionInput(value.override, PAYLOAD_RULE_SECTION_LABELS.override, false) + ?? validatePayloadValueRuleSectionInput(getPayloadRulesSectionValue(value, 'overrideRaw'), PAYLOAD_RULE_SECTION_LABELS.overrideRaw, true) + ?? validatePayloadFilterRuleSectionInput(value.filter, PAYLOAD_RULE_SECTION_LABELS.filter); + if (valueSectionError) { + return { + success: false, + message: valueSectionError, + }; + } + + return { + success: true, + normalized: normalizePayloadRulesConfig(value), + }; +} + export function applyPayloadRules(input: { rules: PayloadRulesConfig; payload: Record; diff --git a/src/web/api.ts b/src/web/api.ts index c95702e..6d42185 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -362,6 +362,7 @@ export type RuntimeRoutingWeightsPayload = { export type RuntimeSettingsPayload = { proxyToken?: string; systemProxyUrl?: string; + payloadRules?: Record | null; modelAvailabilityProbeEnabled?: boolean; codexUpstreamWebsocketEnabled?: boolean; responsesCompactFallbackToResponsesEnabled?: boolean; diff --git a/src/web/pages/Settings.tsx b/src/web/pages/Settings.tsx index 1c889d7..17ebc6d 100644 --- a/src/web/pages/Settings.tsx +++ b/src/web/pages/Settings.tsx @@ -8,6 +8,16 @@ import ModernSelect from '../components/ModernSelect.js'; import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js'; import FactoryResetModal from './settings/FactoryResetModal.js'; import ModelAvailabilityProbeConfirmModal from './settings/ModelAvailabilityProbeConfirmModal.js'; +import { + createCodexDefaultHighReasoningVisualPreset, + createVisualPayloadRule, + isVisualPayloadRuleBlank, + payloadRulesToVisualRules, + type PayloadRuleAction, + type VisualPayloadRule, + type VisualPayloadRuleValueMode, + visualRulesToPayloadRules, +} from './settings/payloadRulesVisual.js'; import UpdateCenterSection from './settings/UpdateCenterSection.js'; import { applyRoutingProfilePreset, @@ -44,6 +54,8 @@ const CHECKIN_INTERVAL_OPTIONS = Array.from({ length: 24 }, (_, index) => { type DbDialect = 'sqlite' | 'mysql' | 'postgres'; type RouteCooldownUnit = typeof ROUTE_COOLDOWN_UNIT_OPTIONS[number]['value']; type SettingsPillTone = 'neutral' | 'primary' | 'danger' | 'warning'; +type PayloadRulesEditorSectionKey = PayloadRuleAction; +type PayloadRulesEditorDrafts = Record; type RuntimeSettings = { checkinCron: string; @@ -118,6 +130,154 @@ type ShorthandConnection = { database: string; }; +const PAYLOAD_RULES_EDITOR_SECTIONS = [ + { + key: 'default', + title: 'default', + description: '字段缺失时才注入,适合补默认参数。', + placeholder: `[ + { + "models": [{ "name": "gpt-*", "protocol": "codex" }], + "params": { + "reasoning.effort": "high" + } + } +]`, + }, + { + key: 'default-raw', + title: 'default-raw', + description: '字段缺失时注入原始 JSON,适合 schema、复杂对象等值。', + placeholder: `[ + { + "models": [{ "name": "gpt-*", "protocol": "codex" }], + "params": { + "response_format": "{\"type\":\"json_schema\"}" + } + } +]`, + }, + { + key: 'override', + title: 'override', + description: '无论原请求是否已有该字段,都强制覆盖。', + placeholder: `[ + { + "models": [{ "name": "gpt-*", "protocol": "codex" }], + "params": { + "text.verbosity": "low" + } + } +]`, + }, + { + key: 'override-raw', + title: 'override-raw', + description: '无论原请求是否已有该字段,都强制覆盖为原始 JSON。', + placeholder: `[ + { + "models": [{ "name": "gemini-*", "protocol": "gemini" }], + "params": { + "generationConfig.responseJsonSchema": "{\"type\":\"object\"}" + } + } +]`, + }, + { + key: 'filter', + title: 'filter', + description: '删除匹配请求中的字段。', + placeholder: `[ + { + "models": [{ "name": "gpt-*", "protocol": "codex" }], + "params": ["safety_identifier"] + } +]`, + }, +] as const satisfies ReadonlyArray<{ + key: PayloadRulesEditorSectionKey; + title: string; + description: string; + placeholder: string; +}>; + +const PAYLOAD_RULE_ACTION_OPTIONS: Array<{ value: PayloadRuleAction; label: string }> = [ + { value: 'default', label: '默认注入' }, + { value: 'default-raw', label: '默认注入 JSON' }, + { value: 'override', label: '强制覆盖' }, + { value: 'override-raw', label: '强制覆盖 JSON' }, + { value: 'filter', label: '删除字段' }, +]; + +const PAYLOAD_RULE_PROTOCOL_OPTIONS = [ + { value: '', label: '全部协议' }, + { value: 'codex', label: 'Codex' }, + { value: 'openai', label: 'OpenAI' }, + { value: 'claude', label: 'Claude' }, + { value: 'gemini', label: 'Gemini' }, + { value: 'antigravity', label: 'Antigravity' }, +]; + +const PAYLOAD_RULE_VALUE_MODE_OPTIONS: Array<{ value: VisualPayloadRuleValueMode; label: string }> = [ + { value: 'text', label: '文本' }, + { value: 'json', label: 'JSON' }, +]; + +function createEmptyPayloadRuleDrafts(): PayloadRulesEditorDrafts { + return { + default: '', + 'default-raw': '', + override: '', + 'override-raw': '', + filter: '', + }; +} + +function formatPayloadRuleSectionForEditor(value: unknown): string { + if (value == null) return ''; + if (Array.isArray(value) && value.length <= 0) return ''; + return JSON.stringify(value, null, 2); +} + +function normalizePayloadRulesForEditor(value: unknown): PayloadRulesEditorDrafts { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return createEmptyPayloadRuleDrafts(); + } + + const record = value as Record; + return { + default: formatPayloadRuleSectionForEditor(record.default), + 'default-raw': formatPayloadRuleSectionForEditor(record.defaultRaw ?? record['default-raw']), + override: formatPayloadRuleSectionForEditor(record.override), + 'override-raw': formatPayloadRuleSectionForEditor(record.overrideRaw ?? record['override-raw']), + filter: formatPayloadRuleSectionForEditor(record.filter), + }; +} + +function parsePayloadRulesFromDrafts( + drafts: PayloadRulesEditorDrafts, +): { success: true; value: Record } | { success: false; message: string } { + const next: Record = {}; + + for (const section of PAYLOAD_RULES_EDITOR_SECTIONS) { + const raw = drafts[section.key].trim(); + if (!raw) continue; + try { + next[section.key] = JSON.parse(raw); + } catch (error: any) { + return { + success: false, + message: `Payload 规则 ${section.title} 不是合法 JSON:${error?.message || '解析失败'}`, + }; + } + } + + return { + success: true, + value: next, + }; +} + const defaultWeights: RoutingWeights = { baseWeightFactor: 0.5, valueScoreFactor: 0.5, @@ -225,6 +385,11 @@ export default function Settings() { const [testingSystemProxy, setTestingSystemProxy] = useState(false); const [systemProxyTestState, setSystemProxyTestState] = useState(null); const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false); + const [payloadVisualRules, setPayloadVisualRules] = useState([]); + const [payloadRuleDrafts, setPayloadRuleDrafts] = useState(createEmptyPayloadRuleDrafts()); + const [payloadAdvancedDirty, setPayloadAdvancedDirty] = useState(false); + const [savingPayloadRules, setSavingPayloadRules] = useState(false); + const [showPayloadRulesEditor, setShowPayloadRulesEditor] = useState(false); const [savingRouting, setSavingRouting] = useState(false); const [showAdvancedRouting, setShowAdvancedRouting] = useState(false); const [allBrandNames, setAllBrandNames] = useState(null); @@ -272,6 +437,11 @@ export default function Settings() { [runtime.routingWeights], ); + const configuredPayloadRuleCount = useMemo( + () => payloadVisualRules.filter((rule) => !isVisualPayloadRuleBlank(rule)).length, + [payloadVisualRules], + ); + const generatedConnectionString = useMemo(() => ( buildShorthandConnectionString(migrationDialect, shorthandConnection) ), [migrationDialect, shorthandConnection]); @@ -465,6 +635,38 @@ export default function Settings() { ? '已启用' : '已关闭'; + const syncPayloadRuleDraftsFromObject = (value: unknown) => { + setPayloadRuleDrafts(normalizePayloadRulesForEditor(value)); + setPayloadAdvancedDirty(false); + }; + + const syncPayloadVisualRulesFromObject = (value: unknown) => { + setPayloadVisualRules(payloadRulesToVisualRules(value)); + }; + + const applyVisualPayloadRules = ( + nextRulesOrUpdater: VisualPayloadRule[] | ((current: VisualPayloadRule[]) => VisualPayloadRule[]), + ) => { + if ( + payloadAdvancedDirty + && typeof globalThis.confirm === 'function' + && !globalThis.confirm('高级 JSON 有未同步修改,继续会覆盖这些内容。是否继续?') + ) { + return; + } + + setPayloadVisualRules((currentRules) => { + const nextRules = typeof nextRulesOrUpdater === 'function' + ? nextRulesOrUpdater(currentRules) + : nextRulesOrUpdater; + const serialized = visualRulesToPayloadRules(nextRules); + if (serialized.success) { + syncPayloadRuleDraftsFromObject(serialized.value); + } + return nextRules; + }); + }; + const loadSettings = async () => { setLoading(true); try { @@ -531,6 +733,8 @@ export default function Settings() { ? runtimeInfo.proxyErrorKeywords.filter((item: unknown) => typeof item === 'string').join('\n') : '', ); + syncPayloadRuleDraftsFromObject(runtimeInfo.payloadRules); + syncPayloadVisualRulesFromObject(runtimeInfo.payloadRules); setAdminIpAllowlistText( Array.isArray(runtimeInfo.adminIpAllowlist) ? runtimeInfo.adminIpAllowlist.join('\n') @@ -783,6 +987,80 @@ export default function Settings() { } }; + const savePayloadRules = async () => { + const nextPayloadRules = payloadAdvancedDirty + ? parsePayloadRulesFromDrafts(payloadRuleDrafts) + : visualRulesToPayloadRules(payloadVisualRules); + if (!nextPayloadRules.success) { + toast.error(nextPayloadRules.message); + return; + } + + setSavingPayloadRules(true); + try { + const res = await api.updateRuntimeSettings({ + payloadRules: nextPayloadRules.value, + }); + syncPayloadRuleDraftsFromObject(res?.payloadRules); + syncPayloadVisualRulesFromObject(res?.payloadRules); + toast.success('Payload 规则已保存'); + } catch (err: any) { + toast.error(err?.message || '保存 Payload 规则失败'); + } finally { + setSavingPayloadRules(false); + } + }; + + const applyCodexDefaultHighReasoningPreset = () => { + applyVisualPayloadRules((currentRules) => [ + ...currentRules.filter((rule) => !isVisualPayloadRuleBlank(rule)), + ...createCodexDefaultHighReasoningVisualPreset(), + ]); + setShowPayloadRulesEditor(true); + toast.success('已填入 Codex 默认高推理预设'); + }; + + const addPayloadVisualRule = () => { + applyVisualPayloadRules((currentRules) => [ + ...currentRules, + createVisualPayloadRule(), + ]); + }; + + const updatePayloadVisualRule = (ruleId: string, patch: Partial) => { + applyVisualPayloadRules((currentRules) => currentRules.map((rule) => { + if (rule.id !== ruleId) return rule; + const nextAction = (patch.action ?? rule.action) as PayloadRuleAction; + const nextValueMode = patch.valueMode ?? ( + nextAction === 'default-raw' || nextAction === 'override-raw' + ? 'json' + : rule.valueMode + ); + return { + ...rule, + ...patch, + action: nextAction, + valueMode: nextAction === 'filter' ? 'text' : nextValueMode, + value: nextAction === 'filter' ? '' : (patch.value ?? rule.value), + }; + })); + }; + + const removePayloadVisualRule = (ruleId: string) => { + applyVisualPayloadRules((currentRules) => currentRules.filter((rule) => rule.id !== ruleId)); + }; + + const syncVisualRulesFromAdvancedJson = () => { + const parsedPayloadRules = parsePayloadRulesFromDrafts(payloadRuleDrafts); + if (!parsedPayloadRules.success) { + toast.error(parsedPayloadRules.message); + return; + } + syncPayloadVisualRulesFromObject(parsedPayloadRules.value); + setPayloadAdvancedDirty(false); + toast.success('已将高级 JSON 同步到可视化规则'); + }; + const saveRouting = async () => { setSavingRouting(true); try { @@ -1264,6 +1542,256 @@ export default function Settings() { +
+
+
+
Payload 规则
+
+ 对匹配模型的上游请求做默认注入、强制覆盖或字段过滤。规则结构参考 CPA 的 payload 配置,常见场景可直接注入 + {' '} + reasoning.effort + {' '} + 之类的参数。 +
+
+
+ 0 ? 'primary' : 'neutral')}> + {configuredPayloadRuleCount > 0 ? `已配置 ${configuredPayloadRuleCount} 条` : '未配置'} + + + {payloadAdvancedDirty ? '高级 JSON 待同步/保存' : '保存后立即生效'} + +
+
+
+
+
+
常用预设
+
+ 先用预设快速填充,再通过下面的可视化规则编辑器细调。复杂场景仍可回退到高级 JSON。 +
+
+
+ + + +
+
+
+ {payloadVisualRules.length <= 0 ? ( +
+
还没有可视化规则
+
+ 可以先点上面的预设,也可以直接新增一条规则:选择动作、协议、模型匹配、字段路径和值即可。 +
+
+ ) : ( +
+ {payloadVisualRules.map((rule, index) => ( +
+
+
规则 {index + 1}
+ +
+ +
+
动作
+ updatePayloadVisualRule(rule.id, { action: value as PayloadRuleAction })} + options={PAYLOAD_RULE_ACTION_OPTIONS} + placeholder="选择动作" + /> +
+
+
协议
+ updatePayloadVisualRule(rule.id, { protocol: String(value || '') })} + options={PAYLOAD_RULE_PROTOCOL_OPTIONS} + placeholder="全部协议" + /> +
+
+
模型匹配
+ updatePayloadVisualRule(rule.id, { modelPattern: e.target.value })} + placeholder="例如 gpt-*" + style={inputStyle} + /> +
+
+
字段路径
+ updatePayloadVisualRule(rule.id, { path: e.target.value })} + placeholder="例如 reasoning.effort" + style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }} + /> +
+
+ {rule.action === 'filter' ? ( +
+ 删除字段规则不需要填写值,命中后会从请求中移除这条路径。 +
+ ) : ( +
+ {(rule.action === 'default' || rule.action === 'override') && ( +
+
值类型
+ updatePayloadVisualRule(rule.id, { + valueMode: value as VisualPayloadRuleValueMode, + value: value === 'json' && rule.valueMode !== 'json' + ? (rule.value ? JSON.stringify(rule.value) : '') + : rule.value, + })} + options={PAYLOAD_RULE_VALUE_MODE_OPTIONS} + placeholder="值类型" + /> +
+ )} +
+
+ {rule.action === 'default-raw' || rule.action === 'override-raw' + ? '原始 JSON 值' + : (rule.valueMode === 'json' ? 'JSON 值' : '文本值')} +
+ {(rule.action === 'default-raw' || rule.action === 'override-raw' || rule.valueMode === 'json') ? ( +