From 14b785c6e652131bb3ac1c13fbe413716ab080da Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 25 Feb 2026 10:25:25 -0800 Subject: [PATCH] [Test] UI - Pricing Calculator: Add comprehensive unit tests Added unit tests for all pricing calculator components with 64 passing tests across 5 test files: - multi_export_utils.test.ts (16 tests for PDF/CSV export functions) - use_multi_cost_estimate.test.ts (15 tests for cost estimation hook) - multi_export_dropdown.test.tsx (8 tests for export dropdown component) - multi_cost_results.test.tsx (15 tests for results display and UI states) - index.test.tsx (10 tests for main calculator component) Also fixed missing page description for tool-policies page in page_metadata.ts. Co-Authored-By: Claude Haiku 4.5 --- ui/litellm-dashboard/package-lock.json | 93 ++++- .../pricing_calculator/index.test.tsx | 131 +++++++ .../multi_cost_results.test.tsx | 305 ++++++++++++++++ .../multi_export_dropdown.test.tsx | 146 ++++++++ .../multi_export_utils.test.ts | 274 ++++++++++++++ .../use_multi_cost_estimate.test.ts | 342 ++++++++++++++++++ .../src/components/page_metadata.ts | 1 + 7 files changed, 1276 insertions(+), 16 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/index.test.tsx create mode 100644 ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/multi_cost_results.test.tsx create mode 100644 ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/multi_export_dropdown.test.tsx create mode 100644 ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/multi_export_utils.test.ts create mode 100644 ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/use_multi_cost_estimate.test.ts diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 3787f451ad3..fc2aa1599d3 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -90,6 +90,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -1771,6 +1772,7 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1781,6 +1783,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1790,12 +1793,14 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1973,6 +1978,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -1986,6 +1992,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -1995,6 +2002,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -2318,7 +2326,7 @@ "version": "1.58.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.1.tgz", "integrity": "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "playwright": "1.58.1" @@ -3423,12 +3431,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.2.48", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.48.tgz", "integrity": "sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==", + "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -3470,6 +3480,7 @@ "version": "0.26.0", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.26.0.tgz", "integrity": "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==", + "dev": true, "license": "MIT" }, "node_modules/@types/unist": { @@ -4330,12 +4341,14 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, "license": "MIT" }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -4349,6 +4362,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -4361,6 +4375,7 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, "license": "MIT" }, "node_modules/argparse": { @@ -4732,6 +4747,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4757,6 +4773,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -4872,6 +4889,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -4995,6 +5013,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -5019,6 +5038,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -5094,6 +5114,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -5154,6 +5175,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -5567,12 +5589,14 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, "license": "Apache-2.0" }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, "license": "MIT" }, "node_modules/doctrine": { @@ -6486,6 +6510,7 @@ "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -6518,6 +6543,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -6555,6 +6581,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -6715,6 +6742,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -6865,6 +6893,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -7362,6 +7391,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -7414,6 +7444,7 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -7474,6 +7505,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7519,6 +7551,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -7567,6 +7600,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -7843,6 +7877,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -8128,6 +8163,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -8140,6 +8176,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, "license": "MIT" }, "node_modules/locate-path": { @@ -8454,6 +8491,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -8905,6 +8943,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -8918,6 +8957,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -9032,6 +9072,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0", @@ -9243,6 +9284,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9261,6 +9303,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -9605,6 +9648,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, "license": "MIT" }, "node_modules/path-scurry": { @@ -9651,6 +9695,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -9663,6 +9708,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9672,6 +9718,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -9681,7 +9728,7 @@ "version": "1.58.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz", "integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.58.1" @@ -9700,7 +9747,7 @@ "version": "1.58.1", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz", "integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -9723,6 +9770,7 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -9751,6 +9799,7 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, "license": "MIT", "dependencies": { "postcss-value-parser": "^4.0.0", @@ -9768,6 +9817,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -9793,6 +9843,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, "funding": [ { "type": "opencollective", @@ -9835,6 +9886,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -9860,6 +9912,7 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -9873,6 +9926,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, "license": "MIT" }, "node_modules/prelude-ls": { @@ -9986,6 +10040,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, "funding": [ { "type": "github", @@ -10774,6 +10829,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, "license": "MIT", "dependencies": { "pify": "^2.3.0" @@ -10783,6 +10839,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -10795,6 +10852,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -11059,6 +11117,7 @@ "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.1", @@ -11099,6 +11158,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -11154,6 +11214,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, "funding": [ { "type": "github", @@ -11794,6 +11855,7 @@ "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", @@ -11829,6 +11891,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11864,6 +11927,7 @@ "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -11901,6 +11965,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -11917,6 +11982,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -11944,6 +12010,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0" @@ -11953,6 +12020,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, "license": "MIT", "dependencies": { "thenify": ">= 3.1.0 < 4" @@ -11994,6 +12062,7 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -12060,6 +12129,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -12147,6 +12217,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, "license": "Apache-2.0" }, "node_modules/tsconfig-paths": { @@ -12263,7 +12334,7 @@ "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -12465,6 +12536,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, "license": "MIT" }, "node_modules/uuid": { @@ -12918,7 +12990,7 @@ "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -12975,17 +13047,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/index.test.tsx b/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/index.test.tsx new file mode 100644 index 00000000000..b5cf2d8b346 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/index.test.tsx @@ -0,0 +1,131 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../../../tests/test-utils"; +import PricingCalculator from "./index"; +import type { ModelEntry } from "./types"; +import type { MultiModelResult } from "./types"; + +vi.mock("./use_multi_cost_estimate", () => ({ + useMultiCostEstimate: vi.fn(() => ({ + debouncedFetchForEntry: vi.fn(), + removeEntry: vi.fn(), + getMultiModelResult: vi.fn((entries: ModelEntry[]): MultiModelResult => ({ + entries: entries.map((e) => ({ entry: e, result: null, loading: false, error: null })), + totals: { + cost_per_request: 0, + daily_cost: null, + monthly_cost: null, + margin_per_request: 0, + daily_margin: null, + monthly_margin: null, + }, + })), + })), +})); + +vi.mock("./multi_export_utils", () => ({ + exportMultiToPDF: vi.fn(), + exportMultiToCSV: vi.fn(), +})); + +vi.mock("@/utils/dataUtils", () => ({ + formatNumberWithCommas: vi.fn((v: number, d: number = 0) => + Number.isFinite(v) ? v.toFixed(d) : "-" + ), +})); + +const DEFAULT_PROPS = { + accessToken: "test-token", + models: ["gpt-4", "gpt-3.5-turbo", "claude-3-sonnet"], +}; + +describe("PricingCalculator", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the calculator with an initial model row", () => { + renderWithProviders(); + expect(screen.getByRole("table")).toBeInTheDocument(); + }); + + it("should render the time period toggle with Per Day and Per Month options", () => { + renderWithProviders(); + expect(screen.getByText("Per Day")).toBeInTheDocument(); + expect(screen.getByText("Per Month")).toBeInTheDocument(); + }); + + it("should render an Add Another Model button", () => { + renderWithProviders(); + expect(screen.getByRole("button", { name: /add another model/i })).toBeInTheDocument(); + }); + + it("should show the Requests/Month column header by default", () => { + renderWithProviders(); + expect(screen.getByText("Requests/Month")).toBeInTheDocument(); + }); + + it("should add a new row when Add Another Model is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const table = screen.getByRole("table"); + const initialRows = within(table).getAllByRole("row"); + + await user.click(screen.getByRole("button", { name: /add another model/i })); + + const updatedRows = within(table).getAllByRole("row"); + // One new data row added (header row + data rows) + expect(updatedRows.length).toBeGreaterThan(initialRows.length); + }); + + it("should have the delete button disabled when there is only one row", () => { + renderWithProviders(); + const allButtons = screen.getAllByRole("button"); + const disabledButtons = allButtons.filter((btn) => btn.hasAttribute("disabled")); + expect(disabledButtons.length).toBeGreaterThan(0); + }); + + it("should have no disabled buttons after adding a second row", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /add another model/i })); + + // With two rows, no delete buttons should be disabled + const allButtons = screen.getAllByRole("button"); + const disabledButtons = allButtons.filter((btn) => btn.hasAttribute("disabled")); + expect(disabledButtons.length).toBe(0); + }); + + describe("time period toggle", () => { + it("should switch the column header to Requests/Day when Per Day is selected", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByText("Per Day")); + + expect(screen.getByText("Requests/Day")).toBeInTheDocument(); + }); + + it("should switch the column header back to Requests/Month when Per Month is selected", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByText("Per Day")); + expect(screen.getByText("Requests/Day")).toBeInTheDocument(); + + await user.click(screen.getByText("Per Month")); + expect(screen.getByText("Requests/Month")).toBeInTheDocument(); + }); + }); + + it("should render column headers for Model, Input Tokens, and Output Tokens", () => { + renderWithProviders(); + expect(screen.getByText("Model")).toBeInTheDocument(); + expect(screen.getByText("Input Tokens")).toBeInTheDocument(); + expect(screen.getByText("Output Tokens")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/multi_cost_results.test.tsx b/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/multi_cost_results.test.tsx new file mode 100644 index 00000000000..6f6522f395e --- /dev/null +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/multi_cost_results.test.tsx @@ -0,0 +1,305 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../../../tests/test-utils"; +import MultiCostResults from "./multi_cost_results"; +import type { MultiModelResult } from "./types"; +import type { CostEstimateResponse } from "../types"; + +vi.mock("./multi_export_utils", () => ({ + exportMultiToPDF: vi.fn(), + exportMultiToCSV: vi.fn(), +})); + +vi.mock("@/utils/dataUtils", () => ({ + formatNumberWithCommas: vi.fn((v: number, d: number = 0) => + Number.isFinite(v) ? v.toFixed(d) : "-" + ), +})); + +function makeCostResponse(overrides: Partial = {}): CostEstimateResponse { + return { + model: "gpt-4", + input_tokens: 1000, + output_tokens: 500, + num_requests_per_day: 100, + num_requests_per_month: null, + cost_per_request: 0.05, + input_cost_per_request: 0.03, + output_cost_per_request: 0.02, + margin_cost_per_request: 0, + daily_cost: 5.0, + daily_input_cost: 3.0, + daily_output_cost: 2.0, + daily_margin_cost: 0, + monthly_cost: null, + monthly_input_cost: null, + monthly_output_cost: null, + monthly_margin_cost: null, + input_cost_per_token: null, + output_cost_per_token: null, + provider: "openai", + ...overrides, + }; +} + +function makeMultiResult(overrides: Partial = {}): MultiModelResult { + return { + entries: [ + { + entry: { id: "e1", model: "gpt-4", input_tokens: 1000, output_tokens: 500 }, + result: makeCostResponse(), + loading: false, + error: null, + }, + ], + totals: { + cost_per_request: 0.05, + daily_cost: 5.0, + monthly_cost: null, + margin_per_request: 0, + daily_margin: null, + monthly_margin: null, + }, + ...overrides, + }; +} + +function emptyMultiResult(): MultiModelResult { + return { + entries: [ + { + entry: { id: "e1", model: "", input_tokens: 1000, output_tokens: 500 }, + result: null, + loading: false, + error: null, + }, + ], + totals: { + cost_per_request: 0, + daily_cost: null, + monthly_cost: null, + margin_per_request: 0, + daily_margin: null, + monthly_margin: null, + }, + }; +} + +describe("MultiCostResults", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("when no model has been selected", () => { + it("should show a prompt to select models", () => { + renderWithProviders( + + ); + expect(screen.getByText(/select models above to see cost estimates/i)).toBeInTheDocument(); + }); + }); + + describe("when results are loading and no data has arrived yet", () => { + it("should show a calculating costs spinner", () => { + const multiResult: MultiModelResult = { + entries: [ + { + entry: { id: "e1", model: "gpt-4", input_tokens: 1000, output_tokens: 500 }, + result: null, + loading: true, + error: null, + }, + ], + totals: { + cost_per_request: 0, + daily_cost: null, + monthly_cost: null, + margin_per_request: 0, + daily_margin: null, + monthly_margin: null, + }, + }; + + renderWithProviders(); + expect(screen.getByText(/calculating costs/i)).toBeInTheDocument(); + }); + }); + + describe("when there are errors but no valid results", () => { + it("should display the error message with the model name", () => { + const multiResult: MultiModelResult = { + entries: [ + { + entry: { id: "e1", model: "bad-model", input_tokens: 0, output_tokens: 0 }, + result: null, + loading: false, + error: "Pricing not found", + }, + ], + totals: { + cost_per_request: 0, + daily_cost: null, + monthly_cost: null, + margin_per_request: 0, + daily_margin: null, + monthly_margin: null, + }, + }; + + renderWithProviders(); + expect(screen.getByText(/bad-model/i)).toBeInTheDocument(); + expect(screen.getByText(/Pricing not found/i)).toBeInTheDocument(); + }); + }); + + describe("when valid results are available", () => { + it("should show the Cost Estimates heading", () => { + renderWithProviders( + + ); + expect(screen.getByText("Cost Estimates")).toBeInTheDocument(); + }); + + it("should display the Total Per Request statistic", () => { + renderWithProviders( + + ); + expect(screen.getByText("Total Per Request")).toBeInTheDocument(); + }); + + it("should display Total Daily statistic when timePeriod is day", () => { + renderWithProviders( + + ); + expect(screen.getByText("Total Daily")).toBeInTheDocument(); + }); + + it("should display Total Monthly statistic when timePeriod is month", () => { + renderWithProviders( + + ); + expect(screen.getByText("Total Monthly")).toBeInTheDocument(); + }); + + it("should show the model name in the summary table", () => { + renderWithProviders( + + ); + expect(screen.getByText("gpt-4")).toBeInTheDocument(); + }); + + it("should show the provider tag next to the model name", () => { + renderWithProviders( + + ); + expect(screen.getByText("openai")).toBeInTheDocument(); + }); + + it("should show the Export button when results are available", () => { + renderWithProviders( + + ); + expect(screen.getByRole("button", { name: /export/i })).toBeInTheDocument(); + }); + + it("should expand the model breakdown row when the expand button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + + // The expand column renders a button (RightOutlined icon) for rows without errors + const expandButtons = screen.getAllByRole("button"); + // Find the small expand button (not the Export button) + const expandButton = expandButtons.find( + (btn) => !btn.textContent?.toLowerCase().includes("export") + ); + expect(expandButton).toBeDefined(); + + await user.click(expandButton!); + + // After expanding, the SingleModelBreakdown should be visible + expect(screen.getByText("Total/Request")).toBeInTheDocument(); + }); + + it("should show the collapse icon after expanding a row", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + + const getExpandButton = () => { + const allButtons = screen.getAllByRole("button"); + return allButtons.find((btn) => !btn.textContent?.toLowerCase().includes("export")); + }; + + // Before expand: button has the "down" aria-label (RightOutlined renders as down in ant icons) + // Just verify clicking works and the breakdown content appears + await user.click(getExpandButton()!); + expect(screen.getByText("Total/Request")).toBeInTheDocument(); + + // After a second click, the row collapses — content may be hidden or removed + await user.click(getExpandButton()!); + // The expanded content should no longer be visible + expect(screen.queryByText("Total/Request")).not.toBeVisible(); + }); + }); + + describe("margin section", () => { + it("should show margin fee details when margin per request is greater than zero", () => { + const multiResult = makeMultiResult({ + entries: [ + { + entry: { id: "e1", model: "gpt-4", input_tokens: 1000, output_tokens: 500 }, + result: makeCostResponse({ margin_cost_per_request: 0.01, daily_margin_cost: 1.0 }), + loading: false, + error: null, + }, + ], + totals: { + cost_per_request: 0.06, + daily_cost: 6.0, + monthly_cost: null, + margin_per_request: 0.01, + daily_margin: 1.0, + monthly_margin: null, + }, + }); + + renderWithProviders(); + expect(screen.getByText("Margin Fee/Request")).toBeInTheDocument(); + }); + + it("should not show margin fee details when margin per request is zero", () => { + renderWithProviders( + + ); + expect(screen.queryByText("Margin Fee/Request")).not.toBeInTheDocument(); + }); + }); + + describe("when a model has zero cost", () => { + it("should show a warning about missing pricing data", () => { + const multiResult = makeMultiResult({ + entries: [ + { + entry: { id: "e1", model: "custom-model", input_tokens: 1000, output_tokens: 500 }, + result: makeCostResponse({ model: "custom-model", cost_per_request: 0 }), + loading: false, + error: null, + }, + ], + }); + + renderWithProviders(); + expect(screen.getByText(/no pricing data found/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/multi_export_dropdown.test.tsx b/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/multi_export_dropdown.test.tsx new file mode 100644 index 00000000000..be1a89cf77f --- /dev/null +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/multi_export_dropdown.test.tsx @@ -0,0 +1,146 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../../../tests/test-utils"; +import MultiExportDropdown from "./multi_export_dropdown"; +import type { MultiModelResult } from "./types"; + +vi.mock("./multi_export_utils", () => ({ + exportMultiToPDF: vi.fn(), + exportMultiToCSV: vi.fn(), +})); + +import { exportMultiToPDF, exportMultiToCSV } from "./multi_export_utils"; + +function makeMultiResult(hasResult: boolean): MultiModelResult { + return { + entries: [ + { + entry: { id: "e1", model: "gpt-4", input_tokens: 1000, output_tokens: 500 }, + result: hasResult + ? { + model: "gpt-4", + input_tokens: 1000, + output_tokens: 500, + num_requests_per_day: null, + num_requests_per_month: null, + cost_per_request: 0.05, + input_cost_per_request: 0.03, + output_cost_per_request: 0.02, + margin_cost_per_request: 0, + daily_cost: null, + daily_input_cost: null, + daily_output_cost: null, + daily_margin_cost: null, + monthly_cost: null, + monthly_input_cost: null, + monthly_output_cost: null, + monthly_margin_cost: null, + input_cost_per_token: null, + output_cost_per_token: null, + provider: "openai", + } + : null, + loading: false, + error: null, + }, + ], + totals: { + cost_per_request: hasResult ? 0.05 : 0, + daily_cost: null, + monthly_cost: null, + margin_per_request: 0, + daily_margin: null, + monthly_margin: null, + }, + }; +} + +describe("MultiExportDropdown", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should not render anything when no entries have results", () => { + const { container } = renderWithProviders( + + ); + expect(container.firstChild).toBeNull(); + }); + + it("should render the Export button when at least one entry has a result", () => { + renderWithProviders(); + expect(screen.getByRole("button", { name: /^export$/i })).toBeInTheDocument(); + }); + + it("should show the export menu when the Export button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /^export$/i })); + + expect(screen.getByText("Export as PDF")).toBeInTheDocument(); + expect(screen.getByText("Export as CSV")).toBeInTheDocument(); + }); + + it("should hide the export menu when the Export button is clicked again", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /^export$/i })); + expect(screen.getByText("Export as PDF")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /^export$/i })); + expect(screen.queryByText("Export as PDF")).not.toBeInTheDocument(); + }); + + it("should call exportMultiToPDF and close the menu when Export as PDF is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /^export$/i })); + await user.click(screen.getByText("Export as PDF")); + + expect(exportMultiToPDF).toHaveBeenCalledTimes(1); + expect(screen.queryByText("Export as PDF")).not.toBeInTheDocument(); + }); + + it("should call exportMultiToCSV and close the menu when Export as CSV is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /^export$/i })); + await user.click(screen.getByText("Export as CSV")); + + expect(exportMultiToCSV).toHaveBeenCalledTimes(1); + expect(screen.queryByText("Export as CSV")).not.toBeInTheDocument(); + }); + + it("should pass the multiResult to the export functions", async () => { + const user = userEvent.setup(); + const multiResult = makeMultiResult(true); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /^export$/i })); + await user.click(screen.getByText("Export as PDF")); + + expect(exportMultiToPDF).toHaveBeenCalledWith(multiResult); + }); + + it("should close the menu when clicking outside", async () => { + const user = userEvent.setup(); + renderWithProviders( +
+ +
Outside
+
+ ); + + await user.click(screen.getByRole("button", { name: /^export$/i })); + expect(screen.getByText("Export as PDF")).toBeInTheDocument(); + + fireEvent.mouseDown(screen.getByTestId("outside")); + expect(screen.queryByText("Export as PDF")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/multi_export_utils.test.ts b/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/multi_export_utils.test.ts new file mode 100644 index 00000000000..40d6879d1af --- /dev/null +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/multi_export_utils.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { exportMultiToPDF, exportMultiToCSV } from "./multi_export_utils"; +import type { MultiModelResult } from "./types"; +import type { CostEstimateResponse } from "../types"; + +vi.mock("@/utils/dataUtils", () => ({ + formatNumberWithCommas: vi.fn((v: number, d: number = 0) => + Number.isFinite(v) ? v.toFixed(d) : "-" + ), +})); + +function makeCostResponse(overrides: Partial = {}): CostEstimateResponse { + return { + model: "gpt-4", + input_tokens: 1000, + output_tokens: 500, + num_requests_per_day: 100, + num_requests_per_month: 3000, + cost_per_request: 0.05, + input_cost_per_request: 0.03, + output_cost_per_request: 0.02, + margin_cost_per_request: 0, + daily_cost: 5.0, + daily_input_cost: 3.0, + daily_output_cost: 2.0, + daily_margin_cost: 0, + monthly_cost: 150.0, + monthly_input_cost: 90.0, + monthly_output_cost: 60.0, + monthly_margin_cost: 0, + input_cost_per_token: 0.00003, + output_cost_per_token: 0.00004, + provider: "openai", + ...overrides, + }; +} + +function makeMultiResult(overrides: Partial = {}): MultiModelResult { + return { + entries: [ + { + entry: { id: "entry-1", model: "gpt-4", input_tokens: 1000, output_tokens: 500 }, + result: makeCostResponse(), + loading: false, + error: null, + }, + ], + totals: { + cost_per_request: 0.05, + daily_cost: 5.0, + monthly_cost: 150.0, + margin_per_request: 0, + daily_margin: null, + monthly_margin: null, + }, + ...overrides, + }; +} + +describe("exportMultiToPDF", () => { + let mockPrintWindow: { + document: { write: ReturnType; close: ReturnType }; + print: ReturnType; + onload: (() => void) | null; + }; + + beforeEach(() => { + mockPrintWindow = { + document: { write: vi.fn(), close: vi.fn() }, + print: vi.fn(), + onload: null, + }; + vi.spyOn(window, "open").mockReturnValue(mockPrintWindow as unknown as Window); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should open a new popup window", () => { + exportMultiToPDF(makeMultiResult()); + expect(window.open).toHaveBeenCalledWith("", "_blank"); + }); + + it("should write HTML containing the report title", () => { + exportMultiToPDF(makeMultiResult()); + const html = mockPrintWindow.document.write.mock.calls[0][0] as string; + expect(html).toContain("LLM Cost Estimate Report"); + }); + + it("should include model name and provider in the generated HTML", () => { + exportMultiToPDF(makeMultiResult()); + const html = mockPrintWindow.document.write.mock.calls[0][0] as string; + expect(html).toContain("gpt-4"); + expect(html).toContain("openai"); + }); + + it("should close the document after writing", () => { + exportMultiToPDF(makeMultiResult()); + expect(mockPrintWindow.document.close).toHaveBeenCalledTimes(1); + }); + + it("should call print after the window finishes loading", () => { + exportMultiToPDF(makeMultiResult()); + expect(mockPrintWindow.print).not.toHaveBeenCalled(); + mockPrintWindow.onload!(); + expect(mockPrintWindow.print).toHaveBeenCalledTimes(1); + }); + + it("should show the margin section when margin per request is greater than zero", () => { + const multiResult = makeMultiResult({ + totals: { + cost_per_request: 0.06, + daily_cost: 5.0, + monthly_cost: 150.0, + margin_per_request: 0.01, + daily_margin: 1.0, + monthly_margin: 30.0, + }, + }); + exportMultiToPDF(multiResult); + const html = mockPrintWindow.document.write.mock.calls[0][0] as string; + expect(html).toContain("Margin/Request"); + }); + + it("should not show the margin section when margin per request is zero", () => { + exportMultiToPDF(makeMultiResult()); + const html = mockPrintWindow.document.write.mock.calls[0][0] as string; + expect(html).not.toContain("Margin/Request"); + }); + + it("should alert when popup is blocked", () => { + vi.spyOn(window, "open").mockReturnValue(null); + const alertSpy = vi.spyOn(window, "alert").mockImplementation(() => {}); + exportMultiToPDF(makeMultiResult()); + expect(alertSpy).toHaveBeenCalledWith("Please allow popups to export PDF"); + }); + + it("should only include entries that have a result", () => { + const multiResult: MultiModelResult = { + entries: [ + { entry: { id: "e1", model: "gpt-4", input_tokens: 1000, output_tokens: 500 }, result: null, loading: false, error: null }, + { entry: { id: "e2", model: "claude-3", input_tokens: 500, output_tokens: 250 }, result: makeCostResponse({ model: "claude-3", provider: "anthropic" }), loading: false, error: null }, + ], + totals: { cost_per_request: 0.05, daily_cost: 5.0, monthly_cost: 150.0, margin_per_request: 0, daily_margin: null, monthly_margin: null }, + }; + exportMultiToPDF(multiResult); + const html = mockPrintWindow.document.write.mock.calls[0][0] as string; + expect(html).toContain("1 model configured"); + expect(html).toContain("claude-3"); + }); + + it("should show plural 'models' when multiple results are present", () => { + const multiResult: MultiModelResult = { + entries: [ + { entry: { id: "e1", model: "gpt-4", input_tokens: 1000, output_tokens: 500 }, result: makeCostResponse(), loading: false, error: null }, + { entry: { id: "e2", model: "claude-3", input_tokens: 500, output_tokens: 250 }, result: makeCostResponse({ model: "claude-3" }), loading: false, error: null }, + ], + totals: { cost_per_request: 0.10, daily_cost: 10.0, monthly_cost: 300.0, margin_per_request: 0, daily_margin: null, monthly_margin: null }, + }; + exportMultiToPDF(multiResult); + const html = mockPrintWindow.document.write.mock.calls[0][0] as string; + expect(html).toContain("2 models configured"); + }); +}); + +describe("exportMultiToCSV", () => { + beforeEach(() => { + document.body.innerHTML = ""; + window.URL.createObjectURL = vi.fn(() => "blob:mock-url"); + window.URL.revokeObjectURL = vi.fn(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should create an object URL and revoke it after download", () => { + exportMultiToCSV(makeMultiResult()); + expect(window.URL.createObjectURL).toHaveBeenCalledTimes(1); + expect(window.URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-url"); + }); + + it("should set the download filename to include today's date", () => { + const createdAnchors: HTMLAnchorElement[] = []; + const originalCreate = document.createElement.bind(document); + vi.spyOn(document, "createElement").mockImplementation((tag: string) => { + const el = originalCreate(tag); + if (tag === "a") createdAnchors.push(el as HTMLAnchorElement); + return el; + }); + + const today = new Date().toISOString().split("T")[0]; + exportMultiToCSV(makeMultiResult()); + + expect(createdAnchors[0].download).toBe(`cost_estimate_multi_model_${today}.csv`); + }); + + it("should generate CSV content containing a header row and model data", () => { + let csvContent = ""; + const OriginalBlob = globalThis.Blob; + globalThis.Blob = class extends OriginalBlob { + constructor(parts?: BlobPart[], options?: BlobPropertyBag) { + super(parts, options); + if (typeof parts?.[0] === "string") csvContent = parts[0]; + } + } as unknown as typeof Blob; + + exportMultiToCSV(makeMultiResult()); + globalThis.Blob = OriginalBlob; + + expect(csvContent).toContain("Model"); + expect(csvContent).toContain("Cost/Request"); + expect(csvContent).toContain("gpt-4"); + expect(csvContent).toContain("openai"); + }); + + it("should include the combined totals section in CSV", () => { + let csvContent = ""; + const OriginalBlob = globalThis.Blob; + globalThis.Blob = class extends OriginalBlob { + constructor(parts?: BlobPart[], options?: BlobPropertyBag) { + super(parts, options); + if (typeof parts?.[0] === "string") csvContent = parts[0]; + } + } as unknown as typeof Blob; + + exportMultiToCSV(makeMultiResult()); + globalThis.Blob = OriginalBlob; + + expect(csvContent).toContain("COMBINED TOTALS"); + }); + + it("should create a blob with the correct CSV mime type", () => { + let capturedType = ""; + const OriginalBlob = globalThis.Blob; + globalThis.Blob = class extends OriginalBlob { + constructor(parts?: BlobPart[], options?: BlobPropertyBag) { + super(parts, options); + if (options?.type) capturedType = options.type; + } + } as unknown as typeof Blob; + + exportMultiToCSV(makeMultiResult()); + globalThis.Blob = OriginalBlob; + + expect(capturedType).toBe("text/csv;charset=utf-8;"); + }); + + it("should skip entries with null results", () => { + const multiResult: MultiModelResult = { + entries: [ + { entry: { id: "e1", model: "gpt-4", input_tokens: 1000, output_tokens: 500 }, result: null, loading: false, error: null }, + ], + totals: { cost_per_request: 0, daily_cost: null, monthly_cost: null, margin_per_request: 0, daily_margin: null, monthly_margin: null }, + }; + + let csvContent = ""; + const OriginalBlob = globalThis.Blob; + globalThis.Blob = class extends OriginalBlob { + constructor(parts?: BlobPart[], options?: BlobPropertyBag) { + super(parts, options); + if (typeof parts?.[0] === "string") csvContent = parts[0]; + } + } as unknown as typeof Blob; + + exportMultiToCSV(multiResult); + globalThis.Blob = OriginalBlob; + + // CSV should have metadata rows but no model data row for gpt-4 + const lines = csvContent.split("\n").filter((l) => l.includes('"gpt-4"')); + expect(lines).toHaveLength(0); + }); +}); diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/use_multi_cost_estimate.test.ts b/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/use_multi_cost_estimate.test.ts new file mode 100644 index 00000000000..f5715194f58 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/pricing_calculator/use_multi_cost_estimate.test.ts @@ -0,0 +1,342 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useMultiCostEstimate } from "./use_multi_cost_estimate"; +import type { ModelEntry } from "./types"; +import type { CostEstimateResponse } from "../types"; + +vi.mock("@/components/networking", () => ({ + getProxyBaseUrl: vi.fn(() => ""), + getGlobalLitellmHeaderName: vi.fn(() => "Authorization"), +})); + +function makeEntry(overrides: Partial = {}): ModelEntry { + return { + id: "entry-1", + model: "gpt-4", + input_tokens: 1000, + output_tokens: 500, + ...overrides, + }; +} + +function makeApiResponse(overrides: Partial = {}): CostEstimateResponse { + return { + model: "gpt-4", + input_tokens: 1000, + output_tokens: 500, + num_requests_per_day: null, + num_requests_per_month: null, + cost_per_request: 0.05, + input_cost_per_request: 0.03, + output_cost_per_request: 0.02, + margin_cost_per_request: 0, + daily_cost: null, + daily_input_cost: null, + daily_output_cost: null, + daily_margin_cost: null, + monthly_cost: null, + monthly_input_cost: null, + monthly_output_cost: null, + monthly_margin_cost: null, + input_cost_per_token: 0.00003, + output_cost_per_token: 0.00004, + provider: "openai", + ...overrides, + }; +} + +describe("useMultiCostEstimate", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("debouncedFetchForEntry", () => { + it("should not fetch when access token is null", async () => { + const fetchSpy = vi.spyOn(global, "fetch"); + const { result } = renderHook(() => useMultiCostEstimate(null)); + + await act(async () => { + result.current.debouncedFetchForEntry(makeEntry()); + await vi.runAllTimersAsync(); + }); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("should not fetch when the model field is empty", async () => { + const fetchSpy = vi.spyOn(global, "fetch"); + const { result } = renderHook(() => useMultiCostEstimate("token123")); + + await act(async () => { + result.current.debouncedFetchForEntry(makeEntry({ model: "" })); + await vi.runAllTimersAsync(); + }); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("should not fetch immediately — only after the debounce delay", async () => { + const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: async () => makeApiResponse(), + } as Response); + + const { result } = renderHook(() => useMultiCostEstimate("token123")); + + act(() => { + result.current.debouncedFetchForEntry(makeEntry()); + }); + + expect(fetchSpy).not.toHaveBeenCalled(); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("should cancel an in-flight debounce when called again for the same entry", async () => { + const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: async () => makeApiResponse(), + } as Response); + + const { result } = renderHook(() => useMultiCostEstimate("token123")); + + await act(async () => { + result.current.debouncedFetchForEntry(makeEntry()); + vi.advanceTimersByTime(200); + result.current.debouncedFetchForEntry(makeEntry()); + vi.advanceTimersByTime(200); + result.current.debouncedFetchForEntry(makeEntry()); + await vi.runAllTimersAsync(); + }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("should store the API result after a successful fetch", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: async () => makeApiResponse(), + } as Response); + + const { result } = renderHook(() => useMultiCostEstimate("token123")); + const entry = makeEntry(); + + await act(async () => { + result.current.debouncedFetchForEntry(entry); + await vi.runAllTimersAsync(); + }); + + const multiResult = result.current.getMultiModelResult([entry]); + expect(multiResult.entries[0].result).not.toBeNull(); + expect(multiResult.entries[0].result?.cost_per_request).toBe(0.05); + expect(multiResult.entries[0].loading).toBe(false); + expect(multiResult.entries[0].error).toBeNull(); + }); + + it("should set an error message when the API returns a non-ok response", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: false, + json: async () => ({ detail: { error: "Model not found" } }), + } as Response); + + const { result } = renderHook(() => useMultiCostEstimate("token123")); + const entry = makeEntry(); + + await act(async () => { + result.current.debouncedFetchForEntry(entry); + await vi.runAllTimersAsync(); + }); + + const multiResult = result.current.getMultiModelResult([entry]); + expect(multiResult.entries[0].result).toBeNull(); + expect(multiResult.entries[0].error).toBe("Model not found"); + }); + + it("should fall back to detail string when error has no nested error field", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: false, + json: async () => ({ detail: "Bad request" }), + } as Response); + + const { result } = renderHook(() => useMultiCostEstimate("token123")); + const entry = makeEntry(); + + await act(async () => { + result.current.debouncedFetchForEntry(entry); + await vi.runAllTimersAsync(); + }); + + const multiResult = result.current.getMultiModelResult([entry]); + expect(multiResult.entries[0].error).toBe("Bad request"); + }); + + it("should set 'Network error' when fetch throws", async () => { + vi.spyOn(global, "fetch").mockRejectedValue(new Error("connection refused")); + + const { result } = renderHook(() => useMultiCostEstimate("token123")); + const entry = makeEntry(); + + await act(async () => { + result.current.debouncedFetchForEntry(entry); + await vi.runAllTimersAsync(); + }); + + const multiResult = result.current.getMultiModelResult([entry]); + expect(multiResult.entries[0].error).toBe("Network error"); + expect(multiResult.entries[0].result).toBeNull(); + }); + }); + + describe("removeEntry", () => { + it("should remove an entry's cached result", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: async () => makeApiResponse(), + } as Response); + + const { result } = renderHook(() => useMultiCostEstimate("token123")); + const entry = makeEntry(); + + await act(async () => { + result.current.debouncedFetchForEntry(entry); + await vi.runAllTimersAsync(); + }); + + // Confirm result was stored + expect(result.current.getMultiModelResult([entry]).entries[0].result).not.toBeNull(); + + act(() => { + result.current.removeEntry(entry.id); + }); + + // After removal, the entry should return as if it never fetched + const multiResult = result.current.getMultiModelResult([entry]); + expect(multiResult.entries[0].result).toBeNull(); + }); + + it("should cancel a pending debounce for the removed entry", async () => { + const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: async () => makeApiResponse(), + } as Response); + + const { result } = renderHook(() => useMultiCostEstimate("token123")); + const entry = makeEntry(); + + act(() => { + result.current.debouncedFetchForEntry(entry); + result.current.removeEntry(entry.id); + }); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + }); + + describe("getMultiModelResult", () => { + it("should return zero totals when no entries have results", () => { + const { result } = renderHook(() => useMultiCostEstimate("token123")); + const multiResult = result.current.getMultiModelResult([makeEntry()]); + + expect(multiResult.totals.cost_per_request).toBe(0); + expect(multiResult.totals.margin_per_request).toBe(0); + expect(multiResult.totals.daily_cost).toBeNull(); + expect(multiResult.totals.monthly_cost).toBeNull(); + }); + + it("should return an empty entries array for an empty input list", () => { + const { result } = renderHook(() => useMultiCostEstimate("token123")); + const multiResult = result.current.getMultiModelResult([]); + + expect(multiResult.entries).toHaveLength(0); + expect(multiResult.totals.daily_cost).toBeNull(); + expect(multiResult.totals.monthly_cost).toBeNull(); + }); + + it("should sum cost_per_request across multiple loaded entries", async () => { + const entry1 = makeEntry({ id: "e1", model: "gpt-4" }); + const entry2 = makeEntry({ id: "e2", model: "claude-3" }); + + let callIndex = 0; + const responses = [ + makeApiResponse({ cost_per_request: 0.05, margin_cost_per_request: 0 }), + makeApiResponse({ model: "claude-3", cost_per_request: 0.10, margin_cost_per_request: 0 }), + ]; + + vi.spyOn(global, "fetch").mockImplementation(async () => ({ + ok: true, + json: async () => responses[callIndex++], + } as Response)); + + const { result } = renderHook(() => useMultiCostEstimate("token123")); + + await act(async () => { + result.current.debouncedFetchForEntry(entry1); + result.current.debouncedFetchForEntry(entry2); + await vi.runAllTimersAsync(); + }); + + const multiResult = result.current.getMultiModelResult([entry1, entry2]); + expect(multiResult.totals.cost_per_request).toBeCloseTo(0.15); + }); + + it("should accumulate daily cost only when entries have a daily cost", async () => { + const entry1 = makeEntry({ id: "e1", model: "gpt-4" }); + const entry2 = makeEntry({ id: "e2", model: "claude-3" }); + + let callIndex = 0; + const responses = [ + makeApiResponse({ daily_cost: 5.0, daily_margin_cost: 0, monthly_cost: null, monthly_margin_cost: null }), + makeApiResponse({ model: "claude-3", daily_cost: 10.0, daily_margin_cost: 0, monthly_cost: null, monthly_margin_cost: null }), + ]; + + vi.spyOn(global, "fetch").mockImplementation(async () => ({ + ok: true, + json: async () => responses[callIndex++], + } as Response)); + + const { result } = renderHook(() => useMultiCostEstimate("token123")); + + await act(async () => { + result.current.debouncedFetchForEntry(entry1); + result.current.debouncedFetchForEntry(entry2); + await vi.runAllTimersAsync(); + }); + + const multiResult = result.current.getMultiModelResult([entry1, entry2]); + expect(multiResult.totals.daily_cost).toBeCloseTo(15.0); + expect(multiResult.totals.monthly_cost).toBeNull(); + }); + + it("should mark each entry's loading and error state from cached data", async () => { + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: false, + json: async () => ({ detail: "Not found" }), + } as Response); + + const { result } = renderHook(() => useMultiCostEstimate("token123")); + const entry = makeEntry(); + + await act(async () => { + result.current.debouncedFetchForEntry(entry); + await vi.runAllTimersAsync(); + }); + + const multiResult = result.current.getMultiModelResult([entry]); + expect(multiResult.entries[0].error).toBe("Not found"); + expect(multiResult.entries[0].loading).toBe(false); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/page_metadata.ts b/ui/litellm-dashboard/src/components/page_metadata.ts index 2459410d7a3..5e2c0ccc0b9 100644 --- a/ui/litellm-dashboard/src/components/page_metadata.ts +++ b/ui/litellm-dashboard/src/components/page_metadata.ts @@ -13,6 +13,7 @@ export const pageDescriptions: Record = { guardrails: "Set up content moderation and safety guardrails", policies: "Define access control and usage policies", "search-tools": "Configure RAG search and retrieval tools", + "tool-policies": "Configure tool use policies and permissions", "vector-stores": "Manage vector databases for embeddings", new_usage: "View usage analytics and metrics", logs: "Access request and response logs",