1
0
forked from boos/BoosAPI

[codex] add payload rules settings UI (#473)

* Make payload request overrides configurable from Settings

The payload-rules engine already existed in the proxy path, but operators had
no supported runtime UI to manage CPA-style overrides. This wires the setting
through the runtime settings API, adds strict validation, and ships a
low-friction visual editor with an advanced JSON fallback so common cases like
Codex high reasoning can be configured without hand-editing backend state.

Constraint: Must reuse the existing payload-rules execution path and settings table without schema changes
Constraint: Must fit the existing metapi Settings-page patterns rather than introducing a standalone config surface
Rejected: JSON-only editor as the primary UX | too long and too easy to misconfigure for common cases
Rejected: A brand-new backend rule format for the UI | would duplicate the existing payload-rules source of truth
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep visual-rule serialization and advanced JSON editing in sync before extending the rule model
Tested: npm run typecheck
Tested: npx vitest run --root . src/server/services/payloadRules.test.ts src/server/routes/api/settings.events.test.ts src/web/pages/settings.payload-rules.test.tsx
Tested: npm run repo:drift-check
Not-tested: Manual browser interaction smoke on the Settings page in this clean PR worktree

* Prevent payload-rules saves from violating review expectations

Code review surfaced two operator-facing edge cases in the first payload-rules
PR: successful rule parsing could still be committed before a later validation
error, and visual edits could silently overwrite unsaved advanced JSON. This
stages payload-rules until the request finishes validating and adds an explicit
confirmation gate before visual actions discard unsynced advanced edits.

Constraint: Keep the fix narrow to the reviewed payload-rules feature without refactoring unrelated runtime-settings atomicity behavior
Rejected: Silently skipping draft resync while keeping visual edits active | would leave save semantics ambiguous when advanced JSON stays dirty
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If the settings endpoint ever moves toward full request atomicity, re-check this staged payload-rules write path against the broader strategy
Tested: npm run typecheck
Tested: npx vitest run --root . src/server/routes/api/settings.events.test.ts src/web/pages/settings.payload-rules.test.tsx
Tested: npm run repo:drift-check
Not-tested: Manual browser smoke of the confirmation dialog text
This commit is contained in:
Cita
2026-04-10 14:27:43 +08:00
committed by GitHub
parent 2f774e204c
commit 9c766dec1c
8 changed files with 1673 additions and 0 deletions
@@ -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<string, unknown> }>;
overrideRaw?: Array<{ params?: Record<string, unknown> }>;
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',
+26
View File
@@ -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) {
+89
View File
@@ -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',
});
});
});
+171
View File
@@ -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<string, unknown>, 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<string, unknown>, 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<string, unknown>;
+1
View File
@@ -362,6 +362,7 @@ export type RuntimeRoutingWeightsPayload = {
export type RuntimeSettingsPayload = {
proxyToken?: string;
systemProxyUrl?: string;
payloadRules?: Record<string, unknown> | null;
modelAvailabilityProbeEnabled?: boolean;
codexUpstreamWebsocketEnabled?: boolean;
responsesCompactFallbackToResponsesEnabled?: boolean;
+528
View File
@@ -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<PayloadRulesEditorSectionKey, string>;
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<string, unknown>;
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<string, unknown> } | { success: false; message: string } {
const next: Record<string, unknown> = {};
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<SystemProxyTestState>(null);
const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false);
const [payloadVisualRules, setPayloadVisualRules] = useState<VisualPayloadRule[]>([]);
const [payloadRuleDrafts, setPayloadRuleDrafts] = useState<PayloadRulesEditorDrafts>(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<string[] | null>(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<VisualPayloadRule>) => {
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() {
</div>
</div>
<div className="card animate-slide-up stagger-4" style={settingsModernCardStyle} data-settings-card="payload-rules">
<div style={settingsModernHeaderStyle}>
<div style={settingsModernTitleBlockStyle}>
<div style={settingsModernTitleStyle}>Payload 规则</div>
<div style={settingsModernDescriptionStyle}>
对匹配模型的上游请求做默认注入、强制覆盖或字段过滤。规则结构参考 CPA 的 payload 配置,常见场景可直接注入
{' '}
<code style={{ fontFamily: 'var(--font-mono)' }}>reasoning.effort</code>
{' '}
之类的参数。
</div>
</div>
<div style={settingsModernPillRowStyle}>
<span style={getSettingsPillStyle(configuredPayloadRuleCount > 0 ? 'primary' : 'neutral')}>
{configuredPayloadRuleCount > 0 ? `已配置 ${configuredPayloadRuleCount} 条` : '未配置'}
</span>
<span style={getSettingsPillStyle(payloadAdvancedDirty ? 'warning' : 'neutral')}>
{payloadAdvancedDirty ? '高级 JSON 待同步/保存' : '保存后立即生效'}
</span>
</div>
</div>
<div style={settingsModernFieldCardStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'grid', gap: 6, minWidth: 0 }}>
<div style={settingsModernFieldLabelStyle}>常用预设</div>
<div style={settingsModernFieldHintStyle}>
先用预设快速填充,再通过下面的可视化规则编辑器细调。复杂场景仍可回退到高级 JSON。
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={applyCodexDefaultHighReasoningPreset}
>
Codex 默认高推理
</button>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={addPayloadVisualRule}
>
新增规则
</button>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => setShowPayloadRulesEditor((prev) => !prev)}
>
{showPayloadRulesEditor ? '收起高级 JSON 编辑' : '展开高级 JSON 编辑'}
</button>
</div>
</div>
</div>
{payloadVisualRules.length <= 0 ? (
<div style={settingsModernFieldCardStyle}>
<div style={settingsModernFieldLabelStyle}>还没有可视化规则</div>
<div style={settingsModernFieldHintStyle}>
可以先点上面的预设,也可以直接新增一条规则:选择动作、协议、模型匹配、字段路径和值即可。
</div>
</div>
) : (
<div style={{ display: 'grid', gap: 12 }}>
{payloadVisualRules.map((rule, index) => (
<div
key={rule.id}
style={settingsModernFieldCardStyle}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<div style={settingsModernFieldLabelStyle}>规则 {index + 1}</div>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', color: 'var(--color-danger)' }}
onClick={() => removePayloadVisualRule(rule.id)}
>
删除
</button>
</div>
<ResponsiveFormGrid columns={2}>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>动作</div>
<ModernSelect
size="sm"
data-testid={`payload-rule-action-${index + 1}`}
value={rule.action}
onChange={(value) => updatePayloadVisualRule(rule.id, { action: value as PayloadRuleAction })}
options={PAYLOAD_RULE_ACTION_OPTIONS}
placeholder="选择动作"
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>协议</div>
<ModernSelect
size="sm"
data-testid={`payload-rule-protocol-${index + 1}`}
value={rule.protocol}
onChange={(value) => updatePayloadVisualRule(rule.id, { protocol: String(value || '') })}
options={PAYLOAD_RULE_PROTOCOL_OPTIONS}
placeholder="全部协议"
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>模型匹配</div>
<input
type="text"
aria-label={`Payload 规则可视化模型 ${index + 1}`}
value={rule.modelPattern}
onChange={(e) => updatePayloadVisualRule(rule.id, { modelPattern: e.target.value })}
placeholder="例如 gpt-*"
style={inputStyle}
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>字段路径</div>
<input
type="text"
aria-label={`Payload 规则可视化路径 ${index + 1}`}
value={rule.path}
onChange={(e) => updatePayloadVisualRule(rule.id, { path: e.target.value })}
placeholder="例如 reasoning.effort"
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
/>
</div>
</ResponsiveFormGrid>
{rule.action === 'filter' ? (
<div style={settingsModernFieldHintStyle}>
删除字段规则不需要填写值,命中后会从请求中移除这条路径。
</div>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{(rule.action === 'default' || rule.action === 'override') && (
<div style={{ width: isMobile ? '100%' : 180 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>值类型</div>
<ModernSelect
size="sm"
data-testid={`payload-rule-value-mode-${index + 1}`}
value={rule.valueMode}
onChange={(value) => 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="值类型"
/>
</div>
)}
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
{rule.action === 'default-raw' || rule.action === 'override-raw'
? '原始 JSON 值'
: (rule.valueMode === 'json' ? 'JSON 值' : '文本值')}
</div>
{(rule.action === 'default-raw' || rule.action === 'override-raw' || rule.valueMode === 'json') ? (
<textarea
aria-label={`Payload 规则可视化值 ${index + 1}`}
value={rule.value}
onChange={(e) => updatePayloadVisualRule(rule.id, { value: e.target.value })}
placeholder={rule.action === 'default-raw' || rule.action === 'override-raw'
? '{"type":"json_schema"}'
: '{"effort":"high"}'}
rows={3}
style={{
...inputStyle,
minHeight: 88,
fontFamily: 'var(--font-mono)',
lineHeight: 1.6,
resize: 'vertical',
}}
/>
) : (
<input
type="text"
aria-label={`Payload 规则可视化值 ${index + 1}`}
value={rule.value}
onChange={(e) => updatePayloadVisualRule(rule.id, { value: e.target.value })}
placeholder="例如 high"
style={inputStyle}
/>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
<div className={`anim-collapse ${showPayloadRulesEditor ? 'is-open' : ''}`.trim()}>
<div className="anim-collapse-inner" style={{ paddingTop: 2 }}>
<div style={settingsModernFieldCardStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'grid', gap: 6 }}>
<div style={settingsModernFieldLabelStyle}>高级 JSON 编辑</div>
<div style={settingsModernFieldHintStyle}>
适合直接粘贴 CPA 风格规则。手动改完后,可点击“同步到可视化规则”回到上面的低门槛编辑器。
</div>
</div>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={syncVisualRulesFromAdvancedJson}
>
同步到可视化规则
</button>
</div>
</div>
<ResponsiveFormGrid columns={2}>
{PAYLOAD_RULES_EDITOR_SECTIONS.map((section) => (
<div key={section.key} style={settingsModernFieldCardStyle}>
<div style={settingsModernFieldLabelStyle}>{section.title}</div>
<div style={settingsModernFieldHintStyle}>{section.description}</div>
<textarea
aria-label={`Payload 规则 ${section.key}`}
value={payloadRuleDrafts[section.key]}
onChange={(e) => {
const nextValue = e.target.value;
setPayloadRuleDrafts((prev) => ({
...prev,
[section.key]: nextValue,
}));
setPayloadAdvancedDirty(true);
}}
placeholder={section.placeholder}
rows={6}
style={{
...inputStyle,
minHeight: 144,
fontFamily: 'var(--font-mono)',
lineHeight: 1.6,
resize: 'vertical',
}}
/>
</div>
))}
</ResponsiveFormGrid>
</div>
</div>
<div style={settingsModernActionsStyle}>
<button onClick={savePayloadRules} disabled={savingPayloadRules} className="btn btn-primary">
{savingPayloadRules ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存 Payload 规则'}
</button>
</div>
</div>
<div className="card animate-slide-up stagger-4" style={settingsModernCardStyle} data-settings-card="proxy-transport">
<div style={settingsModernHeaderStyle}>
<div style={settingsModernTitleBlockStyle}>
@@ -0,0 +1,447 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import Settings from './Settings.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAuthInfo: vi.fn(),
getRuntimeSettings: vi.fn(),
getDownstreamApiKeys: vi.fn(),
getRoutesLite: vi.fn(),
getRuntimeDatabaseConfig: vi.fn(),
getBrandList: vi.fn(),
updateRuntimeSettings: vi.fn(),
getModelTokenCandidates: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../components/BrandIcon.js', () => ({
BrandGlyph: () => null,
InlineBrandIcon: () => null,
getBrand: () => null,
normalizeBrandIconKey: (icon: string) => icon,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Settings payload rules', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getAuthInfo.mockResolvedValue({ masked: 'sk-****' });
apiMock.getRuntimeSettings.mockResolvedValue({
checkinCron: '0 8 * * *',
checkinScheduleMode: 'interval',
checkinIntervalHours: 6,
balanceRefreshCron: '0 * * * *',
logCleanupCron: '15 4 * * *',
logCleanupUsageLogsEnabled: true,
logCleanupProgramLogsEnabled: true,
logCleanupRetentionDays: 14,
routingFallbackUnitCost: 1,
proxyFirstByteTimeoutSec: 0,
routingWeights: {},
tokenRouterFailureCooldownMaxSec: 30 * 24 * 60 * 60,
adminIpAllowlist: [],
systemProxyUrl: '',
payloadRules: {
override: [
{
models: [{ name: 'gpt-*', protocol: 'codex' }],
params: {
'reasoning.effort': 'high',
},
},
],
},
});
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
apiMock.getRoutesLite.mockResolvedValue([]);
apiMock.getBrandList.mockResolvedValue({ brands: [] });
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
saved: null,
restartRequired: false,
});
apiMock.updateRuntimeSettings.mockResolvedValue({
success: true,
payloadRules: {
default: [],
defaultRaw: [],
override: [
{
models: [{ name: 'gpt-*', protocol: 'codex' }],
params: {
'reasoning.effort': 'high',
},
},
],
overrideRaw: [],
filter: [],
},
});
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
});
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it('loads saved payload rules into the editor', async () => {
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<Settings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const overrideTextarea = root.root.find((node) => (
node.type === 'textarea'
&& node.props['aria-label'] === 'Payload 规则 override'
));
expect(String(overrideTextarea.props.value)).toContain('"reasoning.effort": "high"');
} finally {
root?.unmount();
}
});
it('fills the default section when applying the Codex default high-reasoning preset', async () => {
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<Settings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const presetButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === 'Codex 默认高推理'
));
await act(async () => {
presetButton.props.onClick();
});
const defaultTextarea = root.root.find((node) => (
node.type === 'textarea'
&& node.props['aria-label'] === 'Payload 规则 default'
));
expect(String(defaultTextarea.props.value)).toContain('"reasoning.effort": "high"');
expect(String(defaultTextarea.props.value)).toContain('"protocol": "codex"');
} finally {
root?.unmount();
}
});
it('saves parsed payload rules through updateRuntimeSettings', async () => {
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<Settings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const overrideRawTextarea = root.root.find((node) => (
node.type === 'textarea'
&& node.props['aria-label'] === 'Payload 规则 override-raw'
));
await act(async () => {
overrideRawTextarea.props.onChange({
target: {
value: `[
{
"models": [{ "name": "gpt-*", "protocol": "codex" }],
"params": {
"response_format": "{\\"type\\":\\"json_schema\\"}"
}
}
]`,
},
});
});
const saveButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '保存 Payload 规则'
));
await act(async () => {
saveButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith({
payloadRules: {
override: [
{
models: [{ name: 'gpt-*', protocol: 'codex' }],
params: {
'reasoning.effort': 'high',
},
},
],
'override-raw': [
{
models: [{ name: 'gpt-*', protocol: 'codex' }],
params: {
response_format: '{"type":"json_schema"}',
},
},
],
},
});
} finally {
root?.unmount();
}
});
it('saves a rule created from the visual builder', async () => {
apiMock.getRuntimeSettings.mockResolvedValueOnce({
checkinCron: '0 8 * * *',
checkinScheduleMode: 'interval',
checkinIntervalHours: 6,
balanceRefreshCron: '0 * * * *',
logCleanupCron: '15 4 * * *',
logCleanupUsageLogsEnabled: true,
logCleanupProgramLogsEnabled: true,
logCleanupRetentionDays: 14,
routingFallbackUnitCost: 1,
proxyFirstByteTimeoutSec: 0,
routingWeights: {},
tokenRouterFailureCooldownMaxSec: 30 * 24 * 60 * 60,
adminIpAllowlist: [],
systemProxyUrl: '',
payloadRules: {},
});
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<Settings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const addButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '新增规则'
));
await act(async () => {
addButton.props.onClick();
});
const modelInput = root.root.find((node) => (
node.type === 'input'
&& node.props['aria-label'] === 'Payload 规则可视化模型 1'
));
const pathInput = root.root.find((node) => (
node.type === 'input'
&& node.props['aria-label'] === 'Payload 规则可视化路径 1'
));
const valueInput = root.root.find((node) => (
node.props['aria-label'] === 'Payload 规则可视化值 1'
));
await act(async () => {
modelInput.props.onChange({ target: { value: 'gpt-*' } });
pathInput.props.onChange({ target: { value: 'reasoning.effort' } });
valueInput.props.onChange({ target: { value: 'high' } });
});
const saveButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '保存 Payload 规则'
));
await act(async () => {
saveButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith({
payloadRules: {
default: [
{
models: [{ name: 'gpt-*' }],
params: {
'reasoning.effort': 'high',
},
},
],
'default-raw': [],
override: [],
'override-raw': [],
filter: [],
},
});
} finally {
root?.unmount();
}
});
it('blocks save when a payload-rule section contains invalid JSON', async () => {
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<Settings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const filterTextarea = root.root.find((node) => (
node.type === 'textarea'
&& node.props['aria-label'] === 'Payload 规则 filter'
));
await act(async () => {
filterTextarea.props.onChange({ target: { value: '[' } });
});
const saveButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '保存 Payload 规则'
));
await act(async () => {
saveButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateRuntimeSettings).not.toHaveBeenCalled();
} finally {
root?.unmount();
}
});
it('asks before discarding unsynced advanced JSON when switching back to visual edits', async () => {
const confirmMock = vi.fn().mockReturnValue(false);
vi.stubGlobal('confirm', confirmMock);
apiMock.getRuntimeSettings.mockResolvedValueOnce({
checkinCron: '0 8 * * *',
checkinScheduleMode: 'interval',
checkinIntervalHours: 6,
balanceRefreshCron: '0 * * * *',
logCleanupCron: '15 4 * * *',
logCleanupUsageLogsEnabled: true,
logCleanupProgramLogsEnabled: true,
logCleanupRetentionDays: 14,
routingFallbackUnitCost: 1,
proxyFirstByteTimeoutSec: 0,
routingWeights: {},
tokenRouterFailureCooldownMaxSec: 30 * 24 * 60 * 60,
adminIpAllowlist: [],
systemProxyUrl: '',
payloadRules: {},
});
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<Settings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const overrideTextarea = root.root.find((node) => (
node.type === 'textarea'
&& node.props['aria-label'] === 'Payload 规则 override'
));
const advancedDraft = `[
{
"models": [{ "name": "gpt-*", "protocol": "codex" }],
"params": {
"text.verbosity": "low"
}
}
]`;
await act(async () => {
overrideTextarea.props.onChange({ target: { value: advancedDraft } });
});
const addButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '新增规则'
));
await act(async () => {
addButton.props.onClick();
});
expect(confirmMock).toHaveBeenCalledWith('高级 JSON 有未同步修改,继续会覆盖这些内容。是否继续?');
expect(root.root.findAll((node) => (
node.type === 'input'
&& node.props['aria-label'] === 'Payload 规则可视化模型 1'
))).toHaveLength(0);
expect(String(root.root.find((node) => (
node.type === 'textarea'
&& node.props['aria-label'] === 'Payload 规则 override'
)).props.value)).toBe(advancedDraft);
} finally {
root?.unmount();
}
});
});
@@ -0,0 +1,273 @@
export type PayloadRuleAction = 'default' | 'default-raw' | 'override' | 'override-raw' | 'filter';
export type VisualPayloadRuleValueMode = 'text' | 'json';
export type VisualPayloadRule = {
id: string;
action: PayloadRuleAction;
modelPattern: string;
protocol: string;
path: string;
value: string;
valueMode: VisualPayloadRuleValueMode;
};
let payloadRuleIdCounter = 0;
function nextPayloadRuleId(): string {
payloadRuleIdCounter += 1;
return `payload-rule-${payloadRuleIdCounter}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function formatJsonValue(value: unknown): string {
return JSON.stringify(value, null, 2);
}
function formatRawValue(value: unknown): string {
return typeof value === 'string' ? value : formatJsonValue(value);
}
export function createVisualPayloadRule(input: Partial<VisualPayloadRule> = {}): VisualPayloadRule {
return {
id: input.id || nextPayloadRuleId(),
action: input.action || 'default',
modelPattern: input.modelPattern || '',
protocol: input.protocol || '',
path: input.path || '',
value: input.value || '',
valueMode: input.valueMode || 'text',
};
}
export function isVisualPayloadRuleBlank(rule: VisualPayloadRule): boolean {
return !rule.modelPattern.trim()
&& !rule.protocol.trim()
&& !rule.path.trim()
&& !rule.value.trim();
}
export function createCodexDefaultHighReasoningVisualPreset(): VisualPayloadRule[] {
return [
createVisualPayloadRule({
action: 'default',
modelPattern: 'gpt-*',
protocol: 'codex',
path: 'reasoning.effort',
value: 'high',
valueMode: 'text',
}),
];
}
export function payloadRulesToVisualRules(value: unknown): VisualPayloadRule[] {
if (!isRecord(value)) return [];
const rules: VisualPayloadRule[] = [];
const appendValueRules = (action: Exclude<PayloadRuleAction, 'filter'>, input: unknown) => {
if (!Array.isArray(input)) return;
for (const entry of input) {
if (!isRecord(entry)) continue;
const models = Array.isArray(entry.models) ? entry.models : [];
const params = isRecord(entry.params) ? entry.params : null;
if (!params) continue;
for (const model of models) {
if (!isRecord(model)) continue;
const modelPattern = asTrimmedString(model.name);
if (!modelPattern) continue;
const protocol = asTrimmedString(model.protocol);
for (const [path, rawValue] of Object.entries(params)) {
const normalizedPath = asTrimmedString(path);
if (!normalizedPath) continue;
if (action === 'default-raw' || action === 'override-raw') {
rules.push(createVisualPayloadRule({
action,
modelPattern,
protocol,
path: normalizedPath,
value: formatRawValue(rawValue),
valueMode: 'json',
}));
continue;
}
rules.push(createVisualPayloadRule({
action,
modelPattern,
protocol,
path: normalizedPath,
value: typeof rawValue === 'string' ? rawValue : formatJsonValue(rawValue),
valueMode: typeof rawValue === 'string' ? 'text' : 'json',
}));
}
}
}
};
const appendFilterRules = (input: unknown) => {
if (!Array.isArray(input)) return;
for (const entry of input) {
if (!isRecord(entry)) continue;
const models = Array.isArray(entry.models) ? entry.models : [];
const params = Array.isArray(entry.params) ? entry.params : [];
for (const model of models) {
if (!isRecord(model)) continue;
const modelPattern = asTrimmedString(model.name);
if (!modelPattern) continue;
const protocol = asTrimmedString(model.protocol);
for (const path of params) {
const normalizedPath = asTrimmedString(path);
if (!normalizedPath) continue;
rules.push(createVisualPayloadRule({
action: 'filter',
modelPattern,
protocol,
path: normalizedPath,
value: '',
valueMode: 'text',
}));
}
}
}
};
appendValueRules('default', value.default);
appendValueRules('default-raw', value.defaultRaw ?? value['default-raw']);
appendValueRules('override', value.override);
appendValueRules('override-raw', value.overrideRaw ?? value['override-raw']);
appendFilterRules(value.filter);
return rules;
}
type PayloadRulesSerializeSuccess = {
success: true;
value: Record<string, unknown>;
};
type PayloadRulesSerializeFailure = {
success: false;
message: string;
};
export type VisualPayloadRulesSerializeResult = PayloadRulesSerializeSuccess | PayloadRulesSerializeFailure;
type AggregatedValueRule = {
models: Array<{ name: string; protocol?: string }>;
params: Record<string, unknown>;
};
type AggregatedFilterRule = {
models: Array<{ name: string; protocol?: string }>;
params: string[];
};
function buildModelRule(rule: VisualPayloadRule): { name: string; protocol?: string } {
const protocol = rule.protocol.trim();
return protocol ? { name: rule.modelPattern.trim(), protocol } : { name: rule.modelPattern.trim() };
}
function normalizeValueForRule(rule: VisualPayloadRule): { success: true; value: unknown } | PayloadRulesSerializeFailure {
if (rule.action === 'filter') {
return { success: true, value: undefined };
}
if (rule.action === 'default-raw' || rule.action === 'override-raw') {
const trimmed = rule.value.trim();
if (!trimmed) {
return { success: false, message: '原始 JSON 值不能为空' };
}
try {
JSON.parse(trimmed);
} catch (error: any) {
return { success: false, message: `原始 JSON 无效:${error?.message || '解析失败'}` };
}
return { success: true, value: trimmed };
}
if (rule.valueMode === 'json') {
const trimmed = rule.value.trim();
if (!trimmed) {
return { success: false, message: 'JSON 值不能为空' };
}
try {
return { success: true, value: JSON.parse(trimmed) };
} catch (error: any) {
return { success: false, message: `JSON 值无效:${error?.message || '解析失败'}` };
}
}
if (!rule.value) {
return { success: false, message: '文本值不能为空' };
}
return { success: true, value: rule.value };
}
export function visualRulesToPayloadRules(rules: VisualPayloadRule[]): VisualPayloadRulesSerializeResult {
const sections = {
default: new Map<string, AggregatedValueRule>(),
'default-raw': new Map<string, AggregatedValueRule>(),
override: new Map<string, AggregatedValueRule>(),
'override-raw': new Map<string, AggregatedValueRule>(),
filter: new Map<string, AggregatedFilterRule>(),
};
for (let index = 0; index < rules.length; index += 1) {
const rule = rules[index];
if (isVisualPayloadRuleBlank(rule)) continue;
const modelPattern = rule.modelPattern.trim();
if (!modelPattern) {
return { success: false, message: `第 ${index + 1} 条规则缺少模型匹配` };
}
const path = rule.path.trim();
if (!path) {
return { success: false, message: `第 ${index + 1} 条规则缺少字段路径` };
}
const normalizedValue = normalizeValueForRule(rule);
if (!normalizedValue.success) {
return {
success: false,
message: `第 ${index + 1} 条规则保存失败:${normalizedValue.message}`,
};
}
const groupKey = `${modelPattern}\u0000${rule.protocol.trim()}`;
if (rule.action === 'filter') {
const existing = sections.filter.get(groupKey) || {
models: [buildModelRule(rule)],
params: [],
};
if (!existing.params.includes(path)) {
existing.params.push(path);
}
sections.filter.set(groupKey, existing);
continue;
}
const targetSection = sections[rule.action];
const existing = targetSection.get(groupKey) || {
models: [buildModelRule(rule)],
params: {},
};
existing.params[path] = normalizedValue.value;
targetSection.set(groupKey, existing);
}
return {
success: true,
value: {
default: Array.from(sections.default.values()),
'default-raw': Array.from(sections['default-raw'].values()),
override: Array.from(sections.override.values()),
'override-raw': Array.from(sections['override-raw'].values()),
filter: Array.from(sections.filter.values()),
},
};
}