From fb4df51bb6fa8b2ea2632fa934c2dc460d4a8abc Mon Sep 17 00:00:00 2001 From: ktx-vaidehi Date: Wed, 4 Nov 2026 13:24:12 +0530 Subject: [PATCH] fix(model-pricing): keep saved tier rules as rules when another rule is edited A saved rule keyed like a usage field (keys ["input"]) was moved into the tier condition or renamed to usage.input on any edit, which blocked the save. Only a newly added usage threshold may now take the free condition slot. --- .../filterBar/codecs/codecs.spec.ts | 39 +++++++++++++++++++ .../filterBar/codecs/modelPricingCodec.ts | 34 +++++++++++++--- .../settings/ModelPricingEditor.spec.ts | 33 ++++++++++++++++ 3 files changed, 100 insertions(+), 6 deletions(-) diff --git a/web/src/components/filterBar/codecs/codecs.spec.ts b/web/src/components/filterBar/codecs/codecs.spec.ts index 0a91da3aae..391d3763dd 100644 --- a/web/src/components/filterBar/codecs/codecs.spec.ts +++ b/web/src/components/filterBar/codecs/codecs.spec.ts @@ -17,6 +17,7 @@ import { describe, expect, it } from "vitest"; import type { DimensionCondition } from "@/services/downtimes"; import { appendNode, + insertNode, makeCondition, removeNode, replaceNode, @@ -864,6 +865,44 @@ describe("model pricing codec", () => { expect(out.rules?.[3]).toEqual({ keys: ["usage.input"], op: "lte", value: 10 }); }); + describe("a tier with no condition and a rule keyed like a usage field", () => { + const tier = { + rules: [ + { keys: ["input"], op: "gt", value: 5 }, + { keys: ["tier"], op: "in", values: ["x"] }, + ], + }; + + it("keeps the untouched rule as it was when another rule is edited", () => { + const tree = codec.parse(clone(tier), usage); + const out = codec.serialize(edit(tree, 1, { values: ["x", "y"] }), usage); + expect(out.condition).toBeNull(); + expect(out.rules).toEqual([tier.rules[0], { keys: ["tier"], op: "in", values: ["x", "y"] }]); + }); + + it("keeps an edited rule a rule with its original keys", () => { + const tree = codec.parse(clone(tier), usage); + const out = codec.serialize(edit(tree, 0, { values: ["7"] }), usage); + expect(out.condition).toBeNull(); + expect(out.rules).toEqual([{ keys: ["input"], op: "gt", value: 7 }, tier.rules[1]]); + }); + + it("still lets a newly added usage threshold become the tier condition", () => { + const tree = codec.parse(clone(tier), usage); + const out = codec.serialize(appendNode(tree, makeCondition("input", "lte", ["10"])), usage); + expect(out.condition).toEqual({ usage_key: "input", operator: "lte", value: 10 }); + expect(out.rules).toEqual(tier.rules); + }); + }); + + it("keeps the saved condition when a usage threshold is inserted before it", () => { + const tree = codec.parse(clone(saved), usage); + const inserted = insertNode(tree, makeCondition("input", "lte", ["10"]), null, 0); + const out = codec.serialize(inserted, usage); + expect(out.condition).toEqual(saved.condition); + expect(out.rules?.[0]).toEqual({ keys: ["usage.input"], op: "lte", value: 10 }); + }); + it("leaves an unfinished threshold rule without a value instead of writing 0 or null", () => { let tree = codec.parse(clone(saved), usage); tree = appendNode(tree, makeCondition("service_tier", "gt", [])); diff --git a/web/src/components/filterBar/codecs/modelPricingCodec.ts b/web/src/components/filterBar/codecs/modelPricingCodec.ts index 7fe7e7d7e9..d9dc68a922 100644 --- a/web/src/components/filterBar/codecs/modelPricingCodec.ts +++ b/web/src/components/filterBar/codecs/modelPricingCodec.ts @@ -142,11 +142,21 @@ function parseRule(rule: TierRule): FilterNode { return stamp(node, rule); } +const savedRuleOf = (node: FilterNode): TierRule | undefined => { + const raw = rawOf(node); + return node.id !== TIER_CONDITION_ID && Array.isArray(raw?.keys) ? raw : undefined; +}; + function writeRule(node: FilterCondition, usageKey: boolean): TierRule { const raw = rawOf(node); if (isPristine(node) && raw) return raw; + const saved = savedRuleOf(node); const op = RULE_WIRE[node.operator] ?? node.operator; - const keys = keysOf(node.field).map((key) => (usageKey ? `usage.${key}` : key)); + // A saved rule keeps its keys while its field is unchanged; `input` there is an attribute, not usage. + const keys = + saved && node.field === saved.keys.join(", ") + ? [...saved.keys] + : keysOf(node.field).map((key) => (usageKey ? `usage.${key}` : key)); const out: TierRule = { keys, op }; const threshold = (node.values[0] ?? "").trim(); if (node.operator === "in" || node.operator === "not_in") out.values = [...node.values]; @@ -156,7 +166,7 @@ function writeRule(node: FilterCondition, usageKey: boolean): TierRule { } function writeCondition(node: FilterCondition): TierCondition { - const raw = rawOf(node); + const raw = node.id === TIER_CONDITION_ID ? rawOf(node) : undefined; if (isPristine(node) && raw) return raw; return { ...raw, @@ -180,19 +190,31 @@ export function modelPricingCodec(): FilterCodec { if (isTreePristine(tree) && raw) return raw; let condition: TierCondition | null = null; const rules: TierRule[] = []; - for (const node of tree.items.flatMap((item) => + const nodes = tree.items.flatMap((item) => item.kind === "group" ? leafConditions(item) : [item], - )) { + ); + const representable = (node: FilterNode) => + node.kind === "raw" || + (node.kind === "condition" && Object.hasOwn(CONDITION_OPERATORS, node.operator)); + // The saved condition keeps its slot wherever a new line was inserted. + const keepsCondition = nodes.some( + (node) => node.id === TIER_CONDITION_ID && representable(node), + ); + for (const node of nodes) { if (node.kind === "raw") { if (node.id === TIER_CONDITION_ID) condition = rawOf(node)!; else rules.push(rawOf(node)!); continue; } const usage = isUsageField(context, node.field); + // Only a new line may claim the free slot by its field; a saved rule moves only for = / !=, which no rule holds. + const claims = savedRuleOf(node) + ? TIER_CONDITION_ONLY.includes(node.operator) + : usage || TIER_CONDITION_ONLY.includes(node.operator); const isCondition = !condition && - (node.id === TIER_CONDITION_ID || usage || TIER_CONDITION_ONLY.includes(node.operator)) && - Object.hasOwn(CONDITION_OPERATORS, node.operator); + (node.id === TIER_CONDITION_ID || (!keepsCondition && claims)) && + representable(node); if (isCondition) condition = writeCondition(node); else rules.push(writeRule(node, usage && !node.field.startsWith("usage."))); } diff --git a/web/src/components/settings/ModelPricingEditor.spec.ts b/web/src/components/settings/ModelPricingEditor.spec.ts index fd62e7a839..22f37735fa 100644 --- a/web/src/components/settings/ModelPricingEditor.spec.ts +++ b/web/src/components/settings/ModelPricingEditor.spec.ts @@ -1230,6 +1230,39 @@ describe("ModelPricingEditor.vue", () => { "regex", ); }); + it("keeps a usage-keyed rule a rule when another rule's values are edited in the builder", async () => { + const kept = { keys: ["input"], op: "gt", value: 5 }; + const wrapper = await loadTiers([ + { name: "Default", prices: { input: 0.000001 } }, + { + name: "Tagged", + rules: [kept, { keys: ["tier"], op: "in", values: ["x"] }], + prices: { input: 0.000002 }, + }, + ]); + const dt = "model-pricing-tier-filter-builder-1"; + await wrapper.find(`[data-test="${dt}-chip-tier-value"]`).trigger("click"); + await flushPromises(); + const search = document.body.querySelector(`[data-test="${dt}-search"]`)!; + search.value = "y"; + search.dispatchEvent(new Event("input")); + await flushPromises(); + search.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await flushPromises(); + document.body + .querySelector(`[data-test="${dt}-apply"]`)! + .dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flushPromises(); + expect(wrapper.find('[data-test="model-pricing-tier-filter-errors-1"]').exists()).toBe(false); + await getForm(wrapper).handleSubmit(); + await flushPromises(); + const call = mockService.update.mock.calls[0]; + const saved = call[call.length - 1].tiers[1]; + expect(saved.condition ?? null).toBeNull(); + expect(saved.rules).toEqual([kept, { keys: ["tier"], op: "in", values: ["x", "y"] }]); + wrapper.unmount(); + }); + it("round-trips a priority on the default tier", async () => { const wrapper = await loadTiers([ { name: "Default", priority: 3, prices: { input: 0.000001 } },