diff --git a/RELEASE_NOTE.md b/RELEASE_NOTE.md
index 8faa3139..f4d7f732 100644
--- a/RELEASE_NOTE.md
+++ b/RELEASE_NOTE.md
@@ -22,6 +22,7 @@
- **Vertex model listing.** Vertex model-list/model-get now route OpenAI clients through Gemini response conversion and send empty GET bodies to Google, fixing local 500s when pulling publisher models.
- **Structured-output conversion cleanup.** OpenAI-to-Claude transforms drop deprecated `output_format`, avoid unsupported permissive JSON-object shims, and keep schema serialization strict.
- **TOML export for rewrite rules.** Model alias/suffix rewrite rules no longer export empty filter dimensions as JSON null, avoiding `unsupported unit type` during config export (#94).
+- **Console rewrite-rule deletion persists.** Deleting parameter rewrite rules from the console now saves the fresh `rewrite_rules` JSON immediately, so removed rules do not reappear after reload (#96).
- **Console cache-breakpoint TTL display.** The cache breakpoint editor now reads API-returned `ttl5m` / `ttl1h` values as `5m` / `1h` instead of rendering them as `auto` (#97).
- **Responses/image stream schema tolerance.** Responses keepalive events and partial image-generation output items are accepted instead of turning valid upstream streams into local 500s.
@@ -48,6 +49,7 @@
- **Vertex 模型列表.** Vertex 的 model-list/model-get 现在会把 OpenAI 客户端路由到 Gemini 响应转换,并向 Google 发送空 GET body,修复拉取 publisher models 时本地 500 的问题。
- **结构化输出转换清理.** OpenAI → Claude 转换删除废弃的 `output_format`,避免生成上游不支持的宽松 JSON-object shim,并保持 schema 序列化严格。
- **rewrite rules TOML 导出.** 模型别名 / 后缀变体自动生成的 rewrite rules 不再把空 filter 维度导出成 JSON null,避免配置导出时报 `unsupported unit type`(#94)。
+- **控制台删除 rewrite rule 会持久化.** 在控制台删除参数改写规则时,现在会立刻保存最新的 `rewrite_rules` JSON,删除后的规则不会刷新后又出现(#96)。
- **控制台缓存断点 TTL 显示修复.** cache breakpoint 编辑器现在会把 API 返回的 `ttl5m` / `ttl1h` 识别为 `5m` / `1h`,不再显示成 `auto`(#97)。
- **Responses / image stream schema 兼容.** Responses keepalive 事件和 image-generation 的 partial output item 现在会被接受,不再把有效上游流误转成本地 500。
diff --git a/frontend/console/src/modules/admin/providers/RewriteRulesTab.test.tsx b/frontend/console/src/modules/admin/providers/RewriteRulesTab.test.tsx
new file mode 100644
index 00000000..5a9f8e8b
--- /dev/null
+++ b/frontend/console/src/modules/admin/providers/RewriteRulesTab.test.tsx
@@ -0,0 +1,83 @@
+// @vitest-environment jsdom
+
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { describe, expect, it, vi } from "vitest";
+
+import { I18nProvider } from "../../../app/i18n";
+import { RewriteRulesTab } from "./RewriteRulesTab";
+import type { ProviderFormState } from "./index";
+
+// @ts-expect-error React's test-only act flag is intentionally set on globalThis.
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+function getButtonByText(container: HTMLElement, text: string): HTMLButtonElement {
+ const button = Array.from(container.querySelectorAll("button")).find(
+ (item): item is HTMLButtonElement => item.textContent?.trim() === text,
+ );
+ if (!button) {
+ throw new Error(`button not found: ${text}`);
+ }
+ return button;
+}
+
+function getButtonContaining(container: HTMLElement, text: string): HTMLButtonElement {
+ const button = Array.from(container.querySelectorAll("button")).find((item): item is HTMLButtonElement =>
+ item.textContent?.includes(text) ?? false,
+ );
+ if (!button) {
+ throw new Error(`button containing text not found: ${text}`);
+ }
+ return button;
+}
+
+const initialRules = [
+ { path: "temperature", action: { type: "set" as const, value: 0.2 } },
+ { path: "metadata.trace", action: { type: "remove" as const } },
+];
+
+const baseForm: ProviderFormState = {
+ id: "1",
+ name: "anthropic",
+ label: "Anthropic",
+ channel: "anthropic",
+ settings: { rewrite_rules: JSON.stringify(initialRules) },
+ routingRules: [],
+};
+
+describe("RewriteRulesTab", () => {
+ it("persists the fresh rewrite_rules JSON after deleting an existing rule", async () => {
+ const container = document.createElement("div");
+ const root = createRoot(container);
+ const onSave = vi.fn();
+
+ try {
+ await act(async () => {
+ root.render(
+
+ {}}
+ onSave={onSave}
+ notify={() => {}}
+ />
+ ,
+ );
+ });
+
+ await act(async () => {
+ getButtonContaining(container, "temperature").click();
+ });
+
+ await act(async () => {
+ getButtonByText(container, "Delete").click();
+ });
+
+ expect(onSave).toHaveBeenCalledWith(JSON.stringify([initialRules[1]]));
+ } finally {
+ await act(async () => {
+ root.unmount();
+ });
+ }
+ });
+});
diff --git a/frontend/console/src/modules/admin/providers/RewriteRulesTab.tsx b/frontend/console/src/modules/admin/providers/RewriteRulesTab.tsx
index eaf82d61..17e3097b 100644
--- a/frontend/console/src/modules/admin/providers/RewriteRulesTab.tsx
+++ b/frontend/console/src/modules/admin/providers/RewriteRulesTab.tsx
@@ -46,9 +46,11 @@ export function RewriteRulesTab({
);
const commit = (next: RewriteRule[]) => {
+ const nextJson = JSON.stringify(next);
onChange({
- settings: { ...form.settings, rewrite_rules: JSON.stringify(next) },
+ settings: { ...form.settings, rewrite_rules: nextJson },
});
+ return nextJson;
};
const batch = useBatchSelection({
@@ -57,9 +59,10 @@ export function RewriteRulesTab({
onBatchDelete: async (keys) => {
const keySet = new Set(keys);
const next = rules.filter((_, idx) => !keySet.has(String(idx)));
- commit(next);
+ const nextJson = commit(next);
setSelectedIdx(null);
setDraft(null);
+ onSave(nextJson);
},
onSuccess: (count) => {
notify("success", t("batch.deleted", { count }));
@@ -76,9 +79,11 @@ export function RewriteRulesTab({
};
const remove = (idx: number) => {
- commit(rules.filter((_, i) => i !== idx));
+ const next = rules.filter((_, i) => i !== idx);
+ const nextJson = commit(next);
if (selectedIdx === idx) setSelectedIdx(null);
else if (selectedIdx != null && selectedIdx > idx) setSelectedIdx(selectedIdx - 1);
+ onSave(nextJson);
};
/// Current rule being edited (either draft or an existing one).
@@ -104,8 +109,7 @@ export function RewriteRulesTab({
const save = () => {
if (isDraft && draft) {
const next = [...rules, draft];
- const nextJson = JSON.stringify(next);
- commit(next);
+ const nextJson = commit(next);
// After this render cycle, `rules` will include the new entry and we
// want the list to highlight it. Use a timeout so the commit propagates
// through the parent and our `rules` memo re-runs with the new array.