[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>;