[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) {