[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
@@ -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()),
},
};
}