import { describe, it, expect } from "vitest"; import { computeSavings, coversCapabilities, type ModelUsageAgg } from "@/lib/usage/savings"; import type { PolyModel, ModelCapabilities } from "@/lib/ai/core/types"; const caps = (over: Partial = {}): ModelCapabilities => ({ text: true, vision: true, audioInput: false, audioOutput: false, imageGeneration: false, video: false, reasoning: true, tools: true, structuredOutput: true, streaming: true, files: true, webSearch: false, ...over }); function model(key: string, input: number, output: number, extra: Partial = {}): PolyModel { const [provider, id] = key.split("/") as [PolyModel["provider"], string]; return { key, id, provider, displayName: id, family: "Claude", capabilities: caps(), parameters: {}, status: "active", pricing: { inputPerMillion: input, outputPerMillion: output, cachedInputPerMillion: input / 10 }, ...extra }; } const registry = new Map( [ model("anthropic/claude-opus-4", 15, 75, { displayName: "Claude Opus 4" }), model("anthropic/claude-sonnet-4", 3, 15, { displayName: "Claude Sonnet 4" }), model("anthropic/claude-haiku-4", 0.8, 4, { displayName: "Claude Haiku 4", capabilities: caps({ files: false }) }), model("anthropic/claude-old", 1, 5, { status: "deprecated" }), model("openai/gpt-x", 2, 8, { family: "GPT", capabilities: caps() }), ].map((m) => [m.key, m]), ); const opusRow: ModelUsageAgg = { modelKey: "anthropic/claude-opus-4", provider: "anthropic", requests: 40, inputTokens: 1_000_000, outputTokens: 200_000, cachedTokens: 0, reasoningTokens: 0, costUsd: 30 }; describe("savings opportunities", () => { it("picks the closest cheaper sibling on the same provider, not blindly the cheapest", () => { const [opp] = computeSavings([opusRow], registry); expect(opp).toBeDefined(); expect(opp.toModelKey).toBe("anthropic/claude-sonnet-4"); // Haiku is cheaper but Sonnet is the next tier and still saves > 40 % // Opus: 1M × $15 + 0.2M × $75 = $30 ; Sonnet: $3 + $3 = $6 expect(opp.currentCostUsd).toBeCloseTo(30, 4); expect(opp.alternativeCostUsd).toBeCloseTo(6, 4); expect(opp.savingsUsd).toBeCloseTo(24, 4); expect(opp.savingsPct).toBe(80); expect(opp.headline).toContain("Claude Opus 4"); expect(opp.headline).toContain("Claude Sonnet 4"); expect(opp.headline).toMatch(/\$24\.00/); }); it("re-prices cached input at the cached rate", () => { const row = { ...opusRow, cachedTokens: 500_000 }; const [opp] = computeSavings([row], registry); // Opus: 0.5M × 15 + 0.5M × 1.5 + 0.2M × 75 = 7.5 + 0.75 + 15 = 23.25 expect(opp.currentCostUsd).toBeCloseTo(23.25, 4); }); it("never proposes a sibling that lacks a capability the source has", () => { const only = new Map(registry); only.delete("anthropic/claude-sonnet-4"); // leaves Haiku (no files) as the only cheaper sibling expect(computeSavings([opusRow], only)).toEqual([]); expect(coversCapabilities(registry.get("anthropic/claude-haiku-4")!, registry.get("anthropic/claude-opus-4")!)).toBe(false); expect(coversCapabilities(registry.get("anthropic/claude-sonnet-4")!, registry.get("anthropic/claude-opus-4")!)).toBe(true); }); it("ignores deprecated siblings, other providers, unpriced sources and tiny amounts", () => { const sonnetRow: ModelUsageAgg = { ...opusRow, modelKey: "anthropic/claude-sonnet-4", costUsd: 6 }; const noHaiku = new Map(registry); noHaiku.delete("anthropic/claude-haiku-4"); expect(computeSavings([sonnetRow], noHaiku)).toEqual([]); // only deprecated claude-old and OpenAI remain const unpriced = new Map(registry); unpriced.set("anthropic/claude-opus-4", { ...registry.get("anthropic/claude-opus-4")!, pricing: null }); expect(computeSavings([opusRow], unpriced)).toEqual([]); const tiny: ModelUsageAgg = { ...opusRow, inputTokens: 100, outputTokens: 10, costUsd: 0.002 }; expect(computeSavings([tiny], registry)).toEqual([]); }); it("requires the sibling's context window to fit the average prompt", () => { const small = new Map(registry); small.set("anthropic/claude-sonnet-4", { ...registry.get("anthropic/claude-sonnet-4")!, limits: { contextTokens: 8_000 } }); small.delete("anthropic/claude-haiku-4"); const row = { ...opusRow, requests: 10 }; // 100K tokens per request expect(computeSavings([row], small)).toEqual([]); }); it("returns the top 3 sorted by savings", () => { const rows: ModelUsageAgg[] = ["a", "b", "c", "d"].map((s, i) => ({ ...opusRow, modelKey: `anthropic/claude-opus-4`, requests: 10, inputTokens: (i + 1) * 100_000, outputTokens: 0, costUsd: 1 })); const out = computeSavings(rows, registry, { top: 3 }); expect(out).toHaveLength(3); expect(out[0].savingsUsd).toBeGreaterThanOrEqual(out[1].savingsUsd); expect(out[1].savingsUsd).toBeGreaterThanOrEqual(out[2].savingsUsd); }); });