[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:
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user