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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user