Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion apps/web/src/components/usage/usagePriceTable.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { EnvironmentId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
import { usagePriceCell, usagePriceTableChanges, type UsagePriceDraft } from "./usagePriceTable";
import {
usagePriceCell,
usagePriceTableChanges,
usagePriceTableErrors,
type UsagePriceDraft,
} from "./usagePriceTable";
import type { UsagePriceTarget } from "./usagePriceTargets";

const price = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 };
Expand Down Expand Up @@ -116,3 +121,45 @@ describe("price table edits", () => {
).toBe("Map to a different model.");
});
});

describe("model mapping validation", () => {
const mapping = (model: string, alias: string): UsagePriceDraft => ({
id: `model:${model}`,
model,
alias,
isNew: false,
values: {},
});

it("rejects a cycle created by several mappings in one save", () => {
const edits = [mapping("a", "b"), mapping("b", "c"), mapping("c", "a")];
const result = usagePriceTableChanges(target("local", {}), edits);
expect([...result.errors.keys()]).toEqual(["model:a", "model:b", "model:c"]);
});

it("checks each environment's saved chain when an edited mapping closes it", () => {
const edits = [mapping("c", "a")];
const acyclic = target("one", {}, { a: "b" });
const cyclic = target("two", {}, { a: "b", b: "c" });
expect(usagePriceTableChanges(acyclic, edits).errors.size).toBe(0);
expect(usagePriceTableErrors([acyclic, cyclic], edits).get("model:c")).toBe(
"Model mappings cannot form a cycle on two.",
);
});

it("validates the final batch so a mapping can be reversed in either row order", () => {
const environment = target("local", {}, { constructor: "__proto__" });
const clear = mapping("constructor", "");
const reverse = mapping("__proto__", "constructor");
for (const edits of [
[reverse, clear],
[clear, reverse],
]) {
const result = usagePriceTableChanges(environment, edits);
expect(result.errors.size).toBe(0);
expect(result.changes).toEqual(
edits.map(({ model, alias }) => ({ model, alias: alias || null })),
);
}
});
});
20 changes: 20 additions & 0 deletions apps/web/src/components/usage/usagePriceTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,26 @@ export function usagePriceTableChanges(
if (USAGE_PRICE_FIELDS.some((field) => original[field.key] !== next[field.key]))
changes.push(parsed);
}
// Check the final batch so clearing a mapping and reversing it in one save stays valid.
const aliases = new Map(Object.entries(target.aliases ?? {}));
for (const change of changes) {
if (!("alias" in change)) continue;
if (change.alias === null) aliases.delete(change.model);
else aliases.set(change.model, change.alias);
}
for (const draft of drafts) {
if (draft.removed || errors.has(draft.id)) continue;
let model = draft.model.trim();
const seen = new Set<string>();
while (aliases.has(model)) {
if (seen.has(model)) {
errors.set(draft.id, `Model mappings cannot form a cycle on ${target.label}.`);
break;
}
seen.add(model);
model = aliases.get(model)!;
}
}
return { changes, errors };
}

Expand Down
Loading