[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).proxyFirstByteTimeoutSec = 0;
|
||||||
(config as any).tokenRouterFailureCooldownMaxSec = 30 * 24 * 60 * 60;
|
(config as any).tokenRouterFailureCooldownMaxSec = 30 * 24 * 60 * 60;
|
||||||
(config as any).disableCrossProtocolFallback = false;
|
(config as any).disableCrossProtocolFallback = false;
|
||||||
|
(config as any).payloadRules = {
|
||||||
|
default: [],
|
||||||
|
defaultRaw: [],
|
||||||
|
override: [],
|
||||||
|
overrideRaw: [],
|
||||||
|
filter: [],
|
||||||
|
};
|
||||||
(config as any).telegramEnabled = false;
|
(config as any).telegramEnabled = false;
|
||||||
(config as any).telegramApiBaseUrl = 'https://api.telegram.org';
|
(config as any).telegramApiBaseUrl = 'https://api.telegram.org';
|
||||||
(config as any).telegramBotToken = '';
|
(config as any).telegramBotToken = '';
|
||||||
@@ -219,6 +226,137 @@ describe('settings and auth events', () => {
|
|||||||
expect(savedMaxBodyBytes?.value).toBe(JSON.stringify(131072));
|
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 () => {
|
it('returns current recognized admin IP in runtime settings response', async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|||||||
@@ -44,12 +44,14 @@ import {
|
|||||||
startModelAvailabilityProbeScheduler,
|
startModelAvailabilityProbeScheduler,
|
||||||
stopModelAvailabilityProbeScheduler,
|
stopModelAvailabilityProbeScheduler,
|
||||||
} from '../../services/modelAvailabilityProbeService.js';
|
} from '../../services/modelAvailabilityProbeService.js';
|
||||||
|
import { parsePayloadRulesConfigInput } from '../../services/payloadRules.js';
|
||||||
|
|
||||||
type RoutingWeights = typeof config.routingWeights;
|
type RoutingWeights = typeof config.routingWeights;
|
||||||
|
|
||||||
interface RuntimeSettingsBody {
|
interface RuntimeSettingsBody {
|
||||||
proxyToken?: string;
|
proxyToken?: string;
|
||||||
systemProxyUrl?: string;
|
systemProxyUrl?: string;
|
||||||
|
payloadRules?: unknown;
|
||||||
modelAvailabilityProbeEnabled?: boolean;
|
modelAvailabilityProbeEnabled?: boolean;
|
||||||
codexUpstreamWebsocketEnabled?: boolean;
|
codexUpstreamWebsocketEnabled?: boolean;
|
||||||
responsesCompactFallbackToResponsesEnabled?: boolean;
|
responsesCompactFallbackToResponsesEnabled?: boolean;
|
||||||
@@ -759,6 +761,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
|
|||||||
currentAdminIp,
|
currentAdminIp,
|
||||||
serverTimeZone: getResolvedTimeZone(),
|
serverTimeZone: getResolvedTimeZone(),
|
||||||
systemProxyUrl: config.systemProxyUrl,
|
systemProxyUrl: config.systemProxyUrl,
|
||||||
|
payloadRules: config.payloadRules,
|
||||||
proxyErrorKeywords: config.proxyErrorKeywords,
|
proxyErrorKeywords: config.proxyErrorKeywords,
|
||||||
proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled,
|
proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled,
|
||||||
proxyTokenMasked: maskSecret(config.proxyToken),
|
proxyTokenMasked: maskSecret(config.proxyToken),
|
||||||
@@ -902,6 +905,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
const body = parsedBody.data as RuntimeSettingsBody;
|
const body = parsedBody.data as RuntimeSettingsBody;
|
||||||
const changedLabels: string[] = [];
|
const changedLabels: string[] = [];
|
||||||
const currentRequestIp = extractClientIp(request.ip, request.headers['x-forwarded-for']);
|
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 webhookTouched = body.webhookUrl !== undefined || body.webhookEnabled !== undefined;
|
||||||
const nextWebhookUrl = body.webhookUrl !== undefined
|
const nextWebhookUrl = body.webhookUrl !== undefined
|
||||||
@@ -1132,6 +1136,23 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
invalidateSiteProxyCache();
|
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) {
|
if (body.modelAvailabilityProbeEnabled !== undefined) {
|
||||||
let nextValue = false;
|
let nextValue = false;
|
||||||
try {
|
try {
|
||||||
@@ -1697,6 +1718,11 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
upsertSetting('token_router_failure_cooldown_max_sec', normalized);
|
upsertSetting('token_router_failure_cooldown_max_sec', normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pendingPayloadRules !== undefined) {
|
||||||
|
config.payloadRules = pendingPayloadRules;
|
||||||
|
await upsertSetting('payload_rules', pendingPayloadRules);
|
||||||
|
}
|
||||||
|
|
||||||
if (changedLabels.length > 0) {
|
if (changedLabels.length > 0) {
|
||||||
let eventType: 'checkin' | 'balance' | 'proxy' | 'status' | 'token' = 'status';
|
let eventType: 'checkin' | 'balance' | 'proxy' | 'status' | 'token' = 'status';
|
||||||
if (changedLabels.length === 1) {
|
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[];
|
filter: PayloadFilterRule[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PayloadRulesParseSuccess = {
|
||||||
|
success: true;
|
||||||
|
normalized: PayloadRulesConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PayloadRulesParseFailure = {
|
||||||
|
success: false;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PayloadRulesParseResult = PayloadRulesParseSuccess | PayloadRulesParseFailure;
|
||||||
|
|
||||||
function asTrimmedString(value: unknown): string {
|
function asTrimmedString(value: unknown): string {
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
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: {
|
export function applyPayloadRules(input: {
|
||||||
rules: PayloadRulesConfig;
|
rules: PayloadRulesConfig;
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
|
|||||||
@@ -362,6 +362,7 @@ export type RuntimeRoutingWeightsPayload = {
|
|||||||
export type RuntimeSettingsPayload = {
|
export type RuntimeSettingsPayload = {
|
||||||
proxyToken?: string;
|
proxyToken?: string;
|
||||||
systemProxyUrl?: string;
|
systemProxyUrl?: string;
|
||||||
|
payloadRules?: Record<string, unknown> | null;
|
||||||
modelAvailabilityProbeEnabled?: boolean;
|
modelAvailabilityProbeEnabled?: boolean;
|
||||||
codexUpstreamWebsocketEnabled?: boolean;
|
codexUpstreamWebsocketEnabled?: boolean;
|
||||||
responsesCompactFallbackToResponsesEnabled?: boolean;
|
responsesCompactFallbackToResponsesEnabled?: boolean;
|
||||||
|
|||||||
@@ -8,6 +8,16 @@ import ModernSelect from '../components/ModernSelect.js';
|
|||||||
import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js';
|
import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js';
|
||||||
import FactoryResetModal from './settings/FactoryResetModal.js';
|
import FactoryResetModal from './settings/FactoryResetModal.js';
|
||||||
import ModelAvailabilityProbeConfirmModal from './settings/ModelAvailabilityProbeConfirmModal.js';
|
import ModelAvailabilityProbeConfirmModal from './settings/ModelAvailabilityProbeConfirmModal.js';
|
||||||
|
import {
|
||||||
|
createCodexDefaultHighReasoningVisualPreset,
|
||||||
|
createVisualPayloadRule,
|
||||||
|
isVisualPayloadRuleBlank,
|
||||||
|
payloadRulesToVisualRules,
|
||||||
|
type PayloadRuleAction,
|
||||||
|
type VisualPayloadRule,
|
||||||
|
type VisualPayloadRuleValueMode,
|
||||||
|
visualRulesToPayloadRules,
|
||||||
|
} from './settings/payloadRulesVisual.js';
|
||||||
import UpdateCenterSection from './settings/UpdateCenterSection.js';
|
import UpdateCenterSection from './settings/UpdateCenterSection.js';
|
||||||
import {
|
import {
|
||||||
applyRoutingProfilePreset,
|
applyRoutingProfilePreset,
|
||||||
@@ -44,6 +54,8 @@ const CHECKIN_INTERVAL_OPTIONS = Array.from({ length: 24 }, (_, index) => {
|
|||||||
type DbDialect = 'sqlite' | 'mysql' | 'postgres';
|
type DbDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||||
type RouteCooldownUnit = typeof ROUTE_COOLDOWN_UNIT_OPTIONS[number]['value'];
|
type RouteCooldownUnit = typeof ROUTE_COOLDOWN_UNIT_OPTIONS[number]['value'];
|
||||||
type SettingsPillTone = 'neutral' | 'primary' | 'danger' | 'warning';
|
type SettingsPillTone = 'neutral' | 'primary' | 'danger' | 'warning';
|
||||||
|
type PayloadRulesEditorSectionKey = PayloadRuleAction;
|
||||||
|
type PayloadRulesEditorDrafts = Record<PayloadRulesEditorSectionKey, string>;
|
||||||
|
|
||||||
type RuntimeSettings = {
|
type RuntimeSettings = {
|
||||||
checkinCron: string;
|
checkinCron: string;
|
||||||
@@ -118,6 +130,154 @@ type ShorthandConnection = {
|
|||||||
database: string;
|
database: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PAYLOAD_RULES_EDITOR_SECTIONS = [
|
||||||
|
{
|
||||||
|
key: 'default',
|
||||||
|
title: 'default',
|
||||||
|
description: '字段缺失时才注入,适合补默认参数。',
|
||||||
|
placeholder: `[
|
||||||
|
{
|
||||||
|
"models": [{ "name": "gpt-*", "protocol": "codex" }],
|
||||||
|
"params": {
|
||||||
|
"reasoning.effort": "high"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'default-raw',
|
||||||
|
title: 'default-raw',
|
||||||
|
description: '字段缺失时注入原始 JSON,适合 schema、复杂对象等值。',
|
||||||
|
placeholder: `[
|
||||||
|
{
|
||||||
|
"models": [{ "name": "gpt-*", "protocol": "codex" }],
|
||||||
|
"params": {
|
||||||
|
"response_format": "{\"type\":\"json_schema\"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'override',
|
||||||
|
title: 'override',
|
||||||
|
description: '无论原请求是否已有该字段,都强制覆盖。',
|
||||||
|
placeholder: `[
|
||||||
|
{
|
||||||
|
"models": [{ "name": "gpt-*", "protocol": "codex" }],
|
||||||
|
"params": {
|
||||||
|
"text.verbosity": "low"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'override-raw',
|
||||||
|
title: 'override-raw',
|
||||||
|
description: '无论原请求是否已有该字段,都强制覆盖为原始 JSON。',
|
||||||
|
placeholder: `[
|
||||||
|
{
|
||||||
|
"models": [{ "name": "gemini-*", "protocol": "gemini" }],
|
||||||
|
"params": {
|
||||||
|
"generationConfig.responseJsonSchema": "{\"type\":\"object\"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'filter',
|
||||||
|
title: 'filter',
|
||||||
|
description: '删除匹配请求中的字段。',
|
||||||
|
placeholder: `[
|
||||||
|
{
|
||||||
|
"models": [{ "name": "gpt-*", "protocol": "codex" }],
|
||||||
|
"params": ["safety_identifier"]
|
||||||
|
}
|
||||||
|
]`,
|
||||||
|
},
|
||||||
|
] as const satisfies ReadonlyArray<{
|
||||||
|
key: PayloadRulesEditorSectionKey;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
placeholder: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const PAYLOAD_RULE_ACTION_OPTIONS: Array<{ value: PayloadRuleAction; label: string }> = [
|
||||||
|
{ value: 'default', label: '默认注入' },
|
||||||
|
{ value: 'default-raw', label: '默认注入 JSON' },
|
||||||
|
{ value: 'override', label: '强制覆盖' },
|
||||||
|
{ value: 'override-raw', label: '强制覆盖 JSON' },
|
||||||
|
{ value: 'filter', label: '删除字段' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PAYLOAD_RULE_PROTOCOL_OPTIONS = [
|
||||||
|
{ value: '', label: '全部协议' },
|
||||||
|
{ value: 'codex', label: 'Codex' },
|
||||||
|
{ value: 'openai', label: 'OpenAI' },
|
||||||
|
{ value: 'claude', label: 'Claude' },
|
||||||
|
{ value: 'gemini', label: 'Gemini' },
|
||||||
|
{ value: 'antigravity', label: 'Antigravity' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PAYLOAD_RULE_VALUE_MODE_OPTIONS: Array<{ value: VisualPayloadRuleValueMode; label: string }> = [
|
||||||
|
{ value: 'text', label: '文本' },
|
||||||
|
{ value: 'json', label: 'JSON' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function createEmptyPayloadRuleDrafts(): PayloadRulesEditorDrafts {
|
||||||
|
return {
|
||||||
|
default: '',
|
||||||
|
'default-raw': '',
|
||||||
|
override: '',
|
||||||
|
'override-raw': '',
|
||||||
|
filter: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPayloadRuleSectionForEditor(value: unknown): string {
|
||||||
|
if (value == null) return '';
|
||||||
|
if (Array.isArray(value) && value.length <= 0) return '';
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePayloadRulesForEditor(value: unknown): PayloadRulesEditorDrafts {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return createEmptyPayloadRuleDrafts();
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
default: formatPayloadRuleSectionForEditor(record.default),
|
||||||
|
'default-raw': formatPayloadRuleSectionForEditor(record.defaultRaw ?? record['default-raw']),
|
||||||
|
override: formatPayloadRuleSectionForEditor(record.override),
|
||||||
|
'override-raw': formatPayloadRuleSectionForEditor(record.overrideRaw ?? record['override-raw']),
|
||||||
|
filter: formatPayloadRuleSectionForEditor(record.filter),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePayloadRulesFromDrafts(
|
||||||
|
drafts: PayloadRulesEditorDrafts,
|
||||||
|
): { success: true; value: Record<string, unknown> } | { success: false; message: string } {
|
||||||
|
const next: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const section of PAYLOAD_RULES_EDITOR_SECTIONS) {
|
||||||
|
const raw = drafts[section.key].trim();
|
||||||
|
if (!raw) continue;
|
||||||
|
try {
|
||||||
|
next[section.key] = JSON.parse(raw);
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Payload 规则 ${section.title} 不是合法 JSON:${error?.message || '解析失败'}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
value: next,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const defaultWeights: RoutingWeights = {
|
const defaultWeights: RoutingWeights = {
|
||||||
baseWeightFactor: 0.5,
|
baseWeightFactor: 0.5,
|
||||||
valueScoreFactor: 0.5,
|
valueScoreFactor: 0.5,
|
||||||
@@ -225,6 +385,11 @@ export default function Settings() {
|
|||||||
const [testingSystemProxy, setTestingSystemProxy] = useState(false);
|
const [testingSystemProxy, setTestingSystemProxy] = useState(false);
|
||||||
const [systemProxyTestState, setSystemProxyTestState] = useState<SystemProxyTestState>(null);
|
const [systemProxyTestState, setSystemProxyTestState] = useState<SystemProxyTestState>(null);
|
||||||
const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false);
|
const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false);
|
||||||
|
const [payloadVisualRules, setPayloadVisualRules] = useState<VisualPayloadRule[]>([]);
|
||||||
|
const [payloadRuleDrafts, setPayloadRuleDrafts] = useState<PayloadRulesEditorDrafts>(createEmptyPayloadRuleDrafts());
|
||||||
|
const [payloadAdvancedDirty, setPayloadAdvancedDirty] = useState(false);
|
||||||
|
const [savingPayloadRules, setSavingPayloadRules] = useState(false);
|
||||||
|
const [showPayloadRulesEditor, setShowPayloadRulesEditor] = useState(false);
|
||||||
const [savingRouting, setSavingRouting] = useState(false);
|
const [savingRouting, setSavingRouting] = useState(false);
|
||||||
const [showAdvancedRouting, setShowAdvancedRouting] = useState(false);
|
const [showAdvancedRouting, setShowAdvancedRouting] = useState(false);
|
||||||
const [allBrandNames, setAllBrandNames] = useState<string[] | null>(null);
|
const [allBrandNames, setAllBrandNames] = useState<string[] | null>(null);
|
||||||
@@ -272,6 +437,11 @@ export default function Settings() {
|
|||||||
[runtime.routingWeights],
|
[runtime.routingWeights],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const configuredPayloadRuleCount = useMemo(
|
||||||
|
() => payloadVisualRules.filter((rule) => !isVisualPayloadRuleBlank(rule)).length,
|
||||||
|
[payloadVisualRules],
|
||||||
|
);
|
||||||
|
|
||||||
const generatedConnectionString = useMemo(() => (
|
const generatedConnectionString = useMemo(() => (
|
||||||
buildShorthandConnectionString(migrationDialect, shorthandConnection)
|
buildShorthandConnectionString(migrationDialect, shorthandConnection)
|
||||||
), [migrationDialect, shorthandConnection]);
|
), [migrationDialect, shorthandConnection]);
|
||||||
@@ -465,6 +635,38 @@ export default function Settings() {
|
|||||||
? '已启用'
|
? '已启用'
|
||||||
: '已关闭';
|
: '已关闭';
|
||||||
|
|
||||||
|
const syncPayloadRuleDraftsFromObject = (value: unknown) => {
|
||||||
|
setPayloadRuleDrafts(normalizePayloadRulesForEditor(value));
|
||||||
|
setPayloadAdvancedDirty(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncPayloadVisualRulesFromObject = (value: unknown) => {
|
||||||
|
setPayloadVisualRules(payloadRulesToVisualRules(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyVisualPayloadRules = (
|
||||||
|
nextRulesOrUpdater: VisualPayloadRule[] | ((current: VisualPayloadRule[]) => VisualPayloadRule[]),
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
payloadAdvancedDirty
|
||||||
|
&& typeof globalThis.confirm === 'function'
|
||||||
|
&& !globalThis.confirm('高级 JSON 有未同步修改,继续会覆盖这些内容。是否继续?')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPayloadVisualRules((currentRules) => {
|
||||||
|
const nextRules = typeof nextRulesOrUpdater === 'function'
|
||||||
|
? nextRulesOrUpdater(currentRules)
|
||||||
|
: nextRulesOrUpdater;
|
||||||
|
const serialized = visualRulesToPayloadRules(nextRules);
|
||||||
|
if (serialized.success) {
|
||||||
|
syncPayloadRuleDraftsFromObject(serialized.value);
|
||||||
|
}
|
||||||
|
return nextRules;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -531,6 +733,8 @@ export default function Settings() {
|
|||||||
? runtimeInfo.proxyErrorKeywords.filter((item: unknown) => typeof item === 'string').join('\n')
|
? runtimeInfo.proxyErrorKeywords.filter((item: unknown) => typeof item === 'string').join('\n')
|
||||||
: '',
|
: '',
|
||||||
);
|
);
|
||||||
|
syncPayloadRuleDraftsFromObject(runtimeInfo.payloadRules);
|
||||||
|
syncPayloadVisualRulesFromObject(runtimeInfo.payloadRules);
|
||||||
setAdminIpAllowlistText(
|
setAdminIpAllowlistText(
|
||||||
Array.isArray(runtimeInfo.adminIpAllowlist)
|
Array.isArray(runtimeInfo.adminIpAllowlist)
|
||||||
? runtimeInfo.adminIpAllowlist.join('\n')
|
? runtimeInfo.adminIpAllowlist.join('\n')
|
||||||
@@ -783,6 +987,80 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const savePayloadRules = async () => {
|
||||||
|
const nextPayloadRules = payloadAdvancedDirty
|
||||||
|
? parsePayloadRulesFromDrafts(payloadRuleDrafts)
|
||||||
|
: visualRulesToPayloadRules(payloadVisualRules);
|
||||||
|
if (!nextPayloadRules.success) {
|
||||||
|
toast.error(nextPayloadRules.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingPayloadRules(true);
|
||||||
|
try {
|
||||||
|
const res = await api.updateRuntimeSettings({
|
||||||
|
payloadRules: nextPayloadRules.value,
|
||||||
|
});
|
||||||
|
syncPayloadRuleDraftsFromObject(res?.payloadRules);
|
||||||
|
syncPayloadVisualRulesFromObject(res?.payloadRules);
|
||||||
|
toast.success('Payload 规则已保存');
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.message || '保存 Payload 规则失败');
|
||||||
|
} finally {
|
||||||
|
setSavingPayloadRules(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyCodexDefaultHighReasoningPreset = () => {
|
||||||
|
applyVisualPayloadRules((currentRules) => [
|
||||||
|
...currentRules.filter((rule) => !isVisualPayloadRuleBlank(rule)),
|
||||||
|
...createCodexDefaultHighReasoningVisualPreset(),
|
||||||
|
]);
|
||||||
|
setShowPayloadRulesEditor(true);
|
||||||
|
toast.success('已填入 Codex 默认高推理预设');
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPayloadVisualRule = () => {
|
||||||
|
applyVisualPayloadRules((currentRules) => [
|
||||||
|
...currentRules,
|
||||||
|
createVisualPayloadRule(),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePayloadVisualRule = (ruleId: string, patch: Partial<VisualPayloadRule>) => {
|
||||||
|
applyVisualPayloadRules((currentRules) => currentRules.map((rule) => {
|
||||||
|
if (rule.id !== ruleId) return rule;
|
||||||
|
const nextAction = (patch.action ?? rule.action) as PayloadRuleAction;
|
||||||
|
const nextValueMode = patch.valueMode ?? (
|
||||||
|
nextAction === 'default-raw' || nextAction === 'override-raw'
|
||||||
|
? 'json'
|
||||||
|
: rule.valueMode
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...rule,
|
||||||
|
...patch,
|
||||||
|
action: nextAction,
|
||||||
|
valueMode: nextAction === 'filter' ? 'text' : nextValueMode,
|
||||||
|
value: nextAction === 'filter' ? '' : (patch.value ?? rule.value),
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePayloadVisualRule = (ruleId: string) => {
|
||||||
|
applyVisualPayloadRules((currentRules) => currentRules.filter((rule) => rule.id !== ruleId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncVisualRulesFromAdvancedJson = () => {
|
||||||
|
const parsedPayloadRules = parsePayloadRulesFromDrafts(payloadRuleDrafts);
|
||||||
|
if (!parsedPayloadRules.success) {
|
||||||
|
toast.error(parsedPayloadRules.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncPayloadVisualRulesFromObject(parsedPayloadRules.value);
|
||||||
|
setPayloadAdvancedDirty(false);
|
||||||
|
toast.success('已将高级 JSON 同步到可视化规则');
|
||||||
|
};
|
||||||
|
|
||||||
const saveRouting = async () => {
|
const saveRouting = async () => {
|
||||||
setSavingRouting(true);
|
setSavingRouting(true);
|
||||||
try {
|
try {
|
||||||
@@ -1264,6 +1542,256 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="card animate-slide-up stagger-4" style={settingsModernCardStyle} data-settings-card="payload-rules">
|
||||||
|
<div style={settingsModernHeaderStyle}>
|
||||||
|
<div style={settingsModernTitleBlockStyle}>
|
||||||
|
<div style={settingsModernTitleStyle}>Payload 规则</div>
|
||||||
|
<div style={settingsModernDescriptionStyle}>
|
||||||
|
对匹配模型的上游请求做默认注入、强制覆盖或字段过滤。规则结构参考 CPA 的 payload 配置,常见场景可直接注入
|
||||||
|
{' '}
|
||||||
|
<code style={{ fontFamily: 'var(--font-mono)' }}>reasoning.effort</code>
|
||||||
|
{' '}
|
||||||
|
之类的参数。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={settingsModernPillRowStyle}>
|
||||||
|
<span style={getSettingsPillStyle(configuredPayloadRuleCount > 0 ? 'primary' : 'neutral')}>
|
||||||
|
{configuredPayloadRuleCount > 0 ? `已配置 ${configuredPayloadRuleCount} 条` : '未配置'}
|
||||||
|
</span>
|
||||||
|
<span style={getSettingsPillStyle(payloadAdvancedDirty ? 'warning' : 'neutral')}>
|
||||||
|
{payloadAdvancedDirty ? '高级 JSON 待同步/保存' : '保存后立即生效'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={settingsModernFieldCardStyle}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ display: 'grid', gap: 6, minWidth: 0 }}>
|
||||||
|
<div style={settingsModernFieldLabelStyle}>常用预设</div>
|
||||||
|
<div style={settingsModernFieldHintStyle}>
|
||||||
|
先用预设快速填充,再通过下面的可视化规则编辑器细调。复杂场景仍可回退到高级 JSON。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
|
onClick={applyCodexDefaultHighReasoningPreset}
|
||||||
|
>
|
||||||
|
Codex 默认高推理
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
|
onClick={addPayloadVisualRule}
|
||||||
|
>
|
||||||
|
新增规则
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
|
onClick={() => setShowPayloadRulesEditor((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{showPayloadRulesEditor ? '收起高级 JSON 编辑' : '展开高级 JSON 编辑'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{payloadVisualRules.length <= 0 ? (
|
||||||
|
<div style={settingsModernFieldCardStyle}>
|
||||||
|
<div style={settingsModernFieldLabelStyle}>还没有可视化规则</div>
|
||||||
|
<div style={settingsModernFieldHintStyle}>
|
||||||
|
可以先点上面的预设,也可以直接新增一条规则:选择动作、协议、模型匹配、字段路径和值即可。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gap: 12 }}>
|
||||||
|
{payloadVisualRules.map((rule, index) => (
|
||||||
|
<div
|
||||||
|
key={rule.id}
|
||||||
|
style={settingsModernFieldCardStyle}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||||
|
<div style={settingsModernFieldLabelStyle}>规则 {index + 1}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ border: '1px solid var(--color-border)', color: 'var(--color-danger)' }}
|
||||||
|
onClick={() => removePayloadVisualRule(rule.id)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ResponsiveFormGrid columns={2}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>动作</div>
|
||||||
|
<ModernSelect
|
||||||
|
size="sm"
|
||||||
|
data-testid={`payload-rule-action-${index + 1}`}
|
||||||
|
value={rule.action}
|
||||||
|
onChange={(value) => updatePayloadVisualRule(rule.id, { action: value as PayloadRuleAction })}
|
||||||
|
options={PAYLOAD_RULE_ACTION_OPTIONS}
|
||||||
|
placeholder="选择动作"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>协议</div>
|
||||||
|
<ModernSelect
|
||||||
|
size="sm"
|
||||||
|
data-testid={`payload-rule-protocol-${index + 1}`}
|
||||||
|
value={rule.protocol}
|
||||||
|
onChange={(value) => updatePayloadVisualRule(rule.id, { protocol: String(value || '') })}
|
||||||
|
options={PAYLOAD_RULE_PROTOCOL_OPTIONS}
|
||||||
|
placeholder="全部协议"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>模型匹配</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
aria-label={`Payload 规则可视化模型 ${index + 1}`}
|
||||||
|
value={rule.modelPattern}
|
||||||
|
onChange={(e) => updatePayloadVisualRule(rule.id, { modelPattern: e.target.value })}
|
||||||
|
placeholder="例如 gpt-*"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>字段路径</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
aria-label={`Payload 规则可视化路径 ${index + 1}`}
|
||||||
|
value={rule.path}
|
||||||
|
onChange={(e) => updatePayloadVisualRule(rule.id, { path: e.target.value })}
|
||||||
|
placeholder="例如 reasoning.effort"
|
||||||
|
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ResponsiveFormGrid>
|
||||||
|
{rule.action === 'filter' ? (
|
||||||
|
<div style={settingsModernFieldHintStyle}>
|
||||||
|
删除字段规则不需要填写值,命中后会从请求中移除这条路径。
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gap: 8 }}>
|
||||||
|
{(rule.action === 'default' || rule.action === 'override') && (
|
||||||
|
<div style={{ width: isMobile ? '100%' : 180 }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>值类型</div>
|
||||||
|
<ModernSelect
|
||||||
|
size="sm"
|
||||||
|
data-testid={`payload-rule-value-mode-${index + 1}`}
|
||||||
|
value={rule.valueMode}
|
||||||
|
onChange={(value) => updatePayloadVisualRule(rule.id, {
|
||||||
|
valueMode: value as VisualPayloadRuleValueMode,
|
||||||
|
value: value === 'json' && rule.valueMode !== 'json'
|
||||||
|
? (rule.value ? JSON.stringify(rule.value) : '')
|
||||||
|
: rule.value,
|
||||||
|
})}
|
||||||
|
options={PAYLOAD_RULE_VALUE_MODE_OPTIONS}
|
||||||
|
placeholder="值类型"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
||||||
|
{rule.action === 'default-raw' || rule.action === 'override-raw'
|
||||||
|
? '原始 JSON 值'
|
||||||
|
: (rule.valueMode === 'json' ? 'JSON 值' : '文本值')}
|
||||||
|
</div>
|
||||||
|
{(rule.action === 'default-raw' || rule.action === 'override-raw' || rule.valueMode === 'json') ? (
|
||||||
|
<textarea
|
||||||
|
aria-label={`Payload 规则可视化值 ${index + 1}`}
|
||||||
|
value={rule.value}
|
||||||
|
onChange={(e) => updatePayloadVisualRule(rule.id, { value: e.target.value })}
|
||||||
|
placeholder={rule.action === 'default-raw' || rule.action === 'override-raw'
|
||||||
|
? '{"type":"json_schema"}'
|
||||||
|
: '{"effort":"high"}'}
|
||||||
|
rows={3}
|
||||||
|
style={{
|
||||||
|
...inputStyle,
|
||||||
|
minHeight: 88,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
resize: 'vertical',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
aria-label={`Payload 规则可视化值 ${index + 1}`}
|
||||||
|
value={rule.value}
|
||||||
|
onChange={(e) => updatePayloadVisualRule(rule.id, { value: e.target.value })}
|
||||||
|
placeholder="例如 high"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`anim-collapse ${showPayloadRulesEditor ? 'is-open' : ''}`.trim()}>
|
||||||
|
<div className="anim-collapse-inner" style={{ paddingTop: 2 }}>
|
||||||
|
<div style={settingsModernFieldCardStyle}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ display: 'grid', gap: 6 }}>
|
||||||
|
<div style={settingsModernFieldLabelStyle}>高级 JSON 编辑</div>
|
||||||
|
<div style={settingsModernFieldHintStyle}>
|
||||||
|
适合直接粘贴 CPA 风格规则。手动改完后,可点击“同步到可视化规则”回到上面的低门槛编辑器。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
|
onClick={syncVisualRulesFromAdvancedJson}
|
||||||
|
>
|
||||||
|
同步到可视化规则
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ResponsiveFormGrid columns={2}>
|
||||||
|
{PAYLOAD_RULES_EDITOR_SECTIONS.map((section) => (
|
||||||
|
<div key={section.key} style={settingsModernFieldCardStyle}>
|
||||||
|
<div style={settingsModernFieldLabelStyle}>{section.title}</div>
|
||||||
|
<div style={settingsModernFieldHintStyle}>{section.description}</div>
|
||||||
|
<textarea
|
||||||
|
aria-label={`Payload 规则 ${section.key}`}
|
||||||
|
value={payloadRuleDrafts[section.key]}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nextValue = e.target.value;
|
||||||
|
setPayloadRuleDrafts((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[section.key]: nextValue,
|
||||||
|
}));
|
||||||
|
setPayloadAdvancedDirty(true);
|
||||||
|
}}
|
||||||
|
placeholder={section.placeholder}
|
||||||
|
rows={6}
|
||||||
|
style={{
|
||||||
|
...inputStyle,
|
||||||
|
minHeight: 144,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
resize: 'vertical',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ResponsiveFormGrid>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={settingsModernActionsStyle}>
|
||||||
|
<button onClick={savePayloadRules} disabled={savingPayloadRules} className="btn btn-primary">
|
||||||
|
{savingPayloadRules ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存 Payload 规则'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="card animate-slide-up stagger-4" style={settingsModernCardStyle} data-settings-card="proxy-transport">
|
<div className="card animate-slide-up stagger-4" style={settingsModernCardStyle} data-settings-card="proxy-transport">
|
||||||
<div style={settingsModernHeaderStyle}>
|
<div style={settingsModernHeaderStyle}>
|
||||||
<div style={settingsModernTitleBlockStyle}>
|
<div style={settingsModernTitleBlockStyle}>
|
||||||
|
|||||||
@@ -0,0 +1,447 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { ToastProvider } from '../components/Toast.js';
|
||||||
|
import Settings from './Settings.js';
|
||||||
|
|
||||||
|
const { apiMock } = vi.hoisted(() => ({
|
||||||
|
apiMock: {
|
||||||
|
getAuthInfo: vi.fn(),
|
||||||
|
getRuntimeSettings: vi.fn(),
|
||||||
|
getDownstreamApiKeys: vi.fn(),
|
||||||
|
getRoutesLite: vi.fn(),
|
||||||
|
getRuntimeDatabaseConfig: vi.fn(),
|
||||||
|
getBrandList: vi.fn(),
|
||||||
|
updateRuntimeSettings: vi.fn(),
|
||||||
|
getModelTokenCandidates: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../api.js', () => ({
|
||||||
|
api: apiMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/BrandIcon.js', () => ({
|
||||||
|
BrandGlyph: () => null,
|
||||||
|
InlineBrandIcon: () => null,
|
||||||
|
getBrand: () => null,
|
||||||
|
normalizeBrandIconKey: (icon: string) => icon,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function collectText(node: ReactTestInstance): string {
|
||||||
|
return (node.children || []).map((child) => {
|
||||||
|
if (typeof child === 'string') return child;
|
||||||
|
return collectText(child);
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushMicrotasks() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Settings payload rules', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
apiMock.getAuthInfo.mockResolvedValue({ masked: 'sk-****' });
|
||||||
|
apiMock.getRuntimeSettings.mockResolvedValue({
|
||||||
|
checkinCron: '0 8 * * *',
|
||||||
|
checkinScheduleMode: 'interval',
|
||||||
|
checkinIntervalHours: 6,
|
||||||
|
balanceRefreshCron: '0 * * * *',
|
||||||
|
logCleanupCron: '15 4 * * *',
|
||||||
|
logCleanupUsageLogsEnabled: true,
|
||||||
|
logCleanupProgramLogsEnabled: true,
|
||||||
|
logCleanupRetentionDays: 14,
|
||||||
|
routingFallbackUnitCost: 1,
|
||||||
|
proxyFirstByteTimeoutSec: 0,
|
||||||
|
routingWeights: {},
|
||||||
|
tokenRouterFailureCooldownMaxSec: 30 * 24 * 60 * 60,
|
||||||
|
adminIpAllowlist: [],
|
||||||
|
systemProxyUrl: '',
|
||||||
|
payloadRules: {
|
||||||
|
override: [
|
||||||
|
{
|
||||||
|
models: [{ name: 'gpt-*', protocol: 'codex' }],
|
||||||
|
params: {
|
||||||
|
'reasoning.effort': 'high',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
|
||||||
|
apiMock.getRoutesLite.mockResolvedValue([]);
|
||||||
|
apiMock.getBrandList.mockResolvedValue({ brands: [] });
|
||||||
|
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
|
||||||
|
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
|
||||||
|
saved: null,
|
||||||
|
restartRequired: false,
|
||||||
|
});
|
||||||
|
apiMock.updateRuntimeSettings.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
payloadRules: {
|
||||||
|
default: [],
|
||||||
|
defaultRaw: [],
|
||||||
|
override: [
|
||||||
|
{
|
||||||
|
models: [{ name: 'gpt-*', protocol: 'codex' }],
|
||||||
|
params: {
|
||||||
|
'reasoning.effort': 'high',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
overrideRaw: [],
|
||||||
|
filter: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads saved payload rules into the editor', async () => {
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ToastProvider>
|
||||||
|
<Settings />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const overrideTextarea = root.root.find((node) => (
|
||||||
|
node.type === 'textarea'
|
||||||
|
&& node.props['aria-label'] === 'Payload 规则 override'
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(String(overrideTextarea.props.value)).toContain('"reasoning.effort": "high"');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills the default section when applying the Codex default high-reasoning preset', async () => {
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ToastProvider>
|
||||||
|
<Settings />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const presetButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).trim() === 'Codex 默认高推理'
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
presetButton.props.onClick();
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultTextarea = root.root.find((node) => (
|
||||||
|
node.type === 'textarea'
|
||||||
|
&& node.props['aria-label'] === 'Payload 规则 default'
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(String(defaultTextarea.props.value)).toContain('"reasoning.effort": "high"');
|
||||||
|
expect(String(defaultTextarea.props.value)).toContain('"protocol": "codex"');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves parsed payload rules through updateRuntimeSettings', async () => {
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ToastProvider>
|
||||||
|
<Settings />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const overrideRawTextarea = root.root.find((node) => (
|
||||||
|
node.type === 'textarea'
|
||||||
|
&& node.props['aria-label'] === 'Payload 规则 override-raw'
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
overrideRawTextarea.props.onChange({
|
||||||
|
target: {
|
||||||
|
value: `[
|
||||||
|
{
|
||||||
|
"models": [{ "name": "gpt-*", "protocol": "codex" }],
|
||||||
|
"params": {
|
||||||
|
"response_format": "{\\"type\\":\\"json_schema\\"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).trim() === '保存 Payload 规则'
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
saveButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith({
|
||||||
|
payloadRules: {
|
||||||
|
override: [
|
||||||
|
{
|
||||||
|
models: [{ name: 'gpt-*', protocol: 'codex' }],
|
||||||
|
params: {
|
||||||
|
'reasoning.effort': 'high',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'override-raw': [
|
||||||
|
{
|
||||||
|
models: [{ name: 'gpt-*', protocol: 'codex' }],
|
||||||
|
params: {
|
||||||
|
response_format: '{"type":"json_schema"}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves a rule created from the visual builder', async () => {
|
||||||
|
apiMock.getRuntimeSettings.mockResolvedValueOnce({
|
||||||
|
checkinCron: '0 8 * * *',
|
||||||
|
checkinScheduleMode: 'interval',
|
||||||
|
checkinIntervalHours: 6,
|
||||||
|
balanceRefreshCron: '0 * * * *',
|
||||||
|
logCleanupCron: '15 4 * * *',
|
||||||
|
logCleanupUsageLogsEnabled: true,
|
||||||
|
logCleanupProgramLogsEnabled: true,
|
||||||
|
logCleanupRetentionDays: 14,
|
||||||
|
routingFallbackUnitCost: 1,
|
||||||
|
proxyFirstByteTimeoutSec: 0,
|
||||||
|
routingWeights: {},
|
||||||
|
tokenRouterFailureCooldownMaxSec: 30 * 24 * 60 * 60,
|
||||||
|
adminIpAllowlist: [],
|
||||||
|
systemProxyUrl: '',
|
||||||
|
payloadRules: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ToastProvider>
|
||||||
|
<Settings />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const addButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).trim() === '新增规则'
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
addButton.props.onClick();
|
||||||
|
});
|
||||||
|
|
||||||
|
const modelInput = root.root.find((node) => (
|
||||||
|
node.type === 'input'
|
||||||
|
&& node.props['aria-label'] === 'Payload 规则可视化模型 1'
|
||||||
|
));
|
||||||
|
const pathInput = root.root.find((node) => (
|
||||||
|
node.type === 'input'
|
||||||
|
&& node.props['aria-label'] === 'Payload 规则可视化路径 1'
|
||||||
|
));
|
||||||
|
const valueInput = root.root.find((node) => (
|
||||||
|
node.props['aria-label'] === 'Payload 规则可视化值 1'
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
modelInput.props.onChange({ target: { value: 'gpt-*' } });
|
||||||
|
pathInput.props.onChange({ target: { value: 'reasoning.effort' } });
|
||||||
|
valueInput.props.onChange({ target: { value: 'high' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).trim() === '保存 Payload 规则'
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
saveButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith({
|
||||||
|
payloadRules: {
|
||||||
|
default: [
|
||||||
|
{
|
||||||
|
models: [{ name: 'gpt-*' }],
|
||||||
|
params: {
|
||||||
|
'reasoning.effort': 'high',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'default-raw': [],
|
||||||
|
override: [],
|
||||||
|
'override-raw': [],
|
||||||
|
filter: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks save when a payload-rule section contains invalid JSON', async () => {
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ToastProvider>
|
||||||
|
<Settings />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const filterTextarea = root.root.find((node) => (
|
||||||
|
node.type === 'textarea'
|
||||||
|
&& node.props['aria-label'] === 'Payload 规则 filter'
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
filterTextarea.props.onChange({ target: { value: '[' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).trim() === '保存 Payload 规则'
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
saveButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(apiMock.updateRuntimeSettings).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks before discarding unsynced advanced JSON when switching back to visual edits', async () => {
|
||||||
|
const confirmMock = vi.fn().mockReturnValue(false);
|
||||||
|
vi.stubGlobal('confirm', confirmMock);
|
||||||
|
|
||||||
|
apiMock.getRuntimeSettings.mockResolvedValueOnce({
|
||||||
|
checkinCron: '0 8 * * *',
|
||||||
|
checkinScheduleMode: 'interval',
|
||||||
|
checkinIntervalHours: 6,
|
||||||
|
balanceRefreshCron: '0 * * * *',
|
||||||
|
logCleanupCron: '15 4 * * *',
|
||||||
|
logCleanupUsageLogsEnabled: true,
|
||||||
|
logCleanupProgramLogsEnabled: true,
|
||||||
|
logCleanupRetentionDays: 14,
|
||||||
|
routingFallbackUnitCost: 1,
|
||||||
|
proxyFirstByteTimeoutSec: 0,
|
||||||
|
routingWeights: {},
|
||||||
|
tokenRouterFailureCooldownMaxSec: 30 * 24 * 60 * 60,
|
||||||
|
adminIpAllowlist: [],
|
||||||
|
systemProxyUrl: '',
|
||||||
|
payloadRules: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ToastProvider>
|
||||||
|
<Settings />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const overrideTextarea = root.root.find((node) => (
|
||||||
|
node.type === 'textarea'
|
||||||
|
&& node.props['aria-label'] === 'Payload 规则 override'
|
||||||
|
));
|
||||||
|
|
||||||
|
const advancedDraft = `[
|
||||||
|
{
|
||||||
|
"models": [{ "name": "gpt-*", "protocol": "codex" }],
|
||||||
|
"params": {
|
||||||
|
"text.verbosity": "low"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]`;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
overrideTextarea.props.onChange({ target: { value: advancedDraft } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const addButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).trim() === '新增规则'
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
addButton.props.onClick();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(confirmMock).toHaveBeenCalledWith('高级 JSON 有未同步修改,继续会覆盖这些内容。是否继续?');
|
||||||
|
expect(root.root.findAll((node) => (
|
||||||
|
node.type === 'input'
|
||||||
|
&& node.props['aria-label'] === 'Payload 规则可视化模型 1'
|
||||||
|
))).toHaveLength(0);
|
||||||
|
expect(String(root.root.find((node) => (
|
||||||
|
node.type === 'textarea'
|
||||||
|
&& node.props['aria-label'] === 'Payload 规则 override'
|
||||||
|
)).props.value)).toBe(advancedDraft);
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user