mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
Merge branch 'main' into litellm_dev_02_23_2026_p2
This commit is contained in:
commit
2fbe1b3efb
13 changed files with 1421 additions and 90 deletions
19
docs/my-website/docs/proxy/credential_usage_tracking.md
Normal file
19
docs/my-website/docs/proxy/credential_usage_tracking.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Credential Usage Tracking
|
||||
|
||||
When a model is attached to a [reusable credential](./ui_credentials.md), LiteLLM automatically injects the credential name as a tag on every request that uses that model. This means credential-level spend and usage are tracked with zero extra configuration.
|
||||
|
||||
## How It Works
|
||||
|
||||
When you attach a model to a reusable credential via `litellm_credential_name`, each request routed through that model is tagged `Credential: <name>` (for example, `Credential: xAI`). This tag flows into `DailyTagSpend` and appears in the **Tag** view on the Usage page, where you can filter spend and usage by credential.
|
||||
|
||||
If a model has no credential attached, behavior is unchanged—no credential tag is added.
|
||||
|
||||
## Viewing Credential Usage
|
||||
|
||||
In the Admin UI, go to **Usage → Tag** and look for tags with the `Credential: ` prefix. These represent aggregated spend and token usage across all requests that used that credential.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Adding LLM Credentials](./ui_credentials.md) - How to create and attach reusable credentials to models
|
||||
- [Tag Budgets](./tag_budgets.md) - Setting spend limits on tags
|
||||
- [Tag Routing](./tag_routing.md) - Routing requests based on tags
|
||||
|
|
@ -46,6 +46,10 @@ Go to Add Model -> Existing Credentials -> Select your credential in the dropdow
|
|||
|
||||
<Image img={require('../../img/use_model_cred.png')} />
|
||||
|
||||
## Usage Tracking
|
||||
|
||||
Models attached to a reusable credential are automatically tracked in the Usage page. Each request is tagged `Credential: <name>` and appears in the **Tag** view, so you can filter spend and usage by credential without any extra configuration. See [Credential Usage Tracking](./credential_usage_tracking.md) for details.
|
||||
|
||||
## Frequently Asked Questions
|
||||
|
||||
|
||||
|
|
|
|||
93
ui/litellm-dashboard/package-lock.json
generated
93
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(<PricingCalculator {...DEFAULT_PROPS} />);
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the time period toggle with Per Day and Per Month options", () => {
|
||||
renderWithProviders(<PricingCalculator {...DEFAULT_PROPS} />);
|
||||
expect(screen.getByText("Per Day")).toBeInTheDocument();
|
||||
expect(screen.getByText("Per Month")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render an Add Another Model button", () => {
|
||||
renderWithProviders(<PricingCalculator {...DEFAULT_PROPS} />);
|
||||
expect(screen.getByRole("button", { name: /add another model/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the Requests/Month column header by default", () => {
|
||||
renderWithProviders(<PricingCalculator {...DEFAULT_PROPS} />);
|
||||
expect(screen.getByText("Requests/Month")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should add a new row when Add Another Model is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<PricingCalculator {...DEFAULT_PROPS} />);
|
||||
|
||||
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(<PricingCalculator {...DEFAULT_PROPS} />);
|
||||
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(<PricingCalculator {...DEFAULT_PROPS} />);
|
||||
|
||||
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(<PricingCalculator {...DEFAULT_PROPS} />);
|
||||
|
||||
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(<PricingCalculator {...DEFAULT_PROPS} />);
|
||||
|
||||
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(<PricingCalculator {...DEFAULT_PROPS} />);
|
||||
expect(screen.getByText("Model")).toBeInTheDocument();
|
||||
expect(screen.getByText("Input Tokens")).toBeInTheDocument();
|
||||
expect(screen.getByText("Output Tokens")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -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> = {}): 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> = {}): 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(
|
||||
<MultiCostResults multiResult={emptyMultiResult()} timePeriod="month" />
|
||||
);
|
||||
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(<MultiCostResults multiResult={multiResult} timePeriod="month" />);
|
||||
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(<MultiCostResults multiResult={multiResult} timePeriod="month" />);
|
||||
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(
|
||||
<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />
|
||||
);
|
||||
expect(screen.getByText("Cost Estimates")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the Total Per Request statistic", () => {
|
||||
renderWithProviders(
|
||||
<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />
|
||||
);
|
||||
expect(screen.getByText("Total Per Request")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display Total Daily statistic when timePeriod is day", () => {
|
||||
renderWithProviders(
|
||||
<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />
|
||||
);
|
||||
expect(screen.getByText("Total Daily")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display Total Monthly statistic when timePeriod is month", () => {
|
||||
renderWithProviders(
|
||||
<MultiCostResults
|
||||
multiResult={makeMultiResult({
|
||||
totals: { cost_per_request: 0.05, daily_cost: null, monthly_cost: 150.0, margin_per_request: 0, daily_margin: null, monthly_margin: null },
|
||||
})}
|
||||
timePeriod="month"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Total Monthly")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the model name in the summary table", () => {
|
||||
renderWithProviders(
|
||||
<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />
|
||||
);
|
||||
expect(screen.getByText("gpt-4")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the provider tag next to the model name", () => {
|
||||
renderWithProviders(
|
||||
<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />
|
||||
);
|
||||
expect(screen.getByText("openai")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the Export button when results are available", () => {
|
||||
renderWithProviders(
|
||||
<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />
|
||||
);
|
||||
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(
|
||||
<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />
|
||||
);
|
||||
|
||||
// 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(
|
||||
<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />
|
||||
);
|
||||
|
||||
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(<MultiCostResults multiResult={multiResult} timePeriod="day" />);
|
||||
expect(screen.getByText("Margin Fee/Request")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show margin fee details when margin per request is zero", () => {
|
||||
renderWithProviders(
|
||||
<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />
|
||||
);
|
||||
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(<MultiCostResults multiResult={multiResult} timePeriod="day" />);
|
||||
expect(screen.getByText(/no pricing data found/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
<MultiExportDropdown multiResult={makeMultiResult(false)} />
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("should render the Export button when at least one entry has a result", () => {
|
||||
renderWithProviders(<MultiExportDropdown multiResult={makeMultiResult(true)} />);
|
||||
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(<MultiExportDropdown multiResult={makeMultiResult(true)} />);
|
||||
|
||||
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(<MultiExportDropdown multiResult={makeMultiResult(true)} />);
|
||||
|
||||
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(<MultiExportDropdown multiResult={makeMultiResult(true)} />);
|
||||
|
||||
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(<MultiExportDropdown multiResult={makeMultiResult(true)} />);
|
||||
|
||||
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(<MultiExportDropdown multiResult={multiResult} />);
|
||||
|
||||
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(
|
||||
<div>
|
||||
<MultiExportDropdown multiResult={makeMultiResult(true)} />
|
||||
<div data-testid="outside">Outside</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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> = {}): 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> = {}): 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<typeof vi.fn>; close: ReturnType<typeof vi.fn> };
|
||||
print: ReturnType<typeof vi.fn>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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> = {}): ModelEntry {
|
||||
return {
|
||||
id: "entry-1",
|
||||
model: "gpt-4",
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeApiResponse(overrides: Partial<CostEstimateResponse> = {}): 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -2,14 +2,7 @@
|
|||
|
||||
import React, { useCallback, useDeferredValue, useEffect, useState } from "react";
|
||||
import { Select, Switch, Tooltip } from "antd";
|
||||
import {
|
||||
Table,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@tremor/react";
|
||||
import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } from "@tremor/react";
|
||||
import { TimeCell } from "./view_logs/time_cell";
|
||||
import { TableHeaderSortDropdown } from "./common_components/TableHeaderSortDropdown/TableHeaderSortDropdown";
|
||||
import type { SortState } from "./common_components/TableHeaderSortDropdown/TableHeaderSortDropdown";
|
||||
|
|
@ -17,14 +10,13 @@ import FilterComponent, { FilterOption } from "./molecules/filter";
|
|||
import { fetchToolsList, updateToolPolicy, ToolRow } from "./networking";
|
||||
|
||||
const POLICY_OPTIONS = [
|
||||
{ value: "trusted", label: "trusted", color: "#065f46", bg: "#d1fae5", border: "#6ee7b7" },
|
||||
{ value: "blocked", label: "blocked", color: "#991b1b", bg: "#fee2e2", border: "#fca5a5" },
|
||||
{ value: "trusted", label: "trusted", color: "#065f46", bg: "#d1fae5", border: "#6ee7b7" },
|
||||
{ value: "blocked", label: "blocked", color: "#991b1b", bg: "#fee2e2", border: "#fca5a5" },
|
||||
] as const;
|
||||
|
||||
type PolicyValue = "trusted" | "blocked";
|
||||
|
||||
const policyStyle = (p: string) =>
|
||||
POLICY_OPTIONS.find((o) => o.value === p) ?? POLICY_OPTIONS[1];
|
||||
const policyStyle = (p: string) => POLICY_OPTIONS.find((o) => o.value === p) ?? POLICY_OPTIONS[1];
|
||||
|
||||
type SortField = "tool_name" | "call_policy" | "team_id" | "key_alias" | "created_at" | "call_count";
|
||||
|
||||
|
|
@ -56,18 +48,6 @@ const PolicySelect: React.FC<{
|
|||
minWidth: 110,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: style.bg,
|
||||
borderColor: style.border,
|
||||
color: style.color,
|
||||
borderRadius: 999,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 4,
|
||||
},
|
||||
}}
|
||||
popupMatchSelectWidth={false}
|
||||
options={POLICY_OPTIONS.map((o) => ({
|
||||
value: o.value,
|
||||
|
|
@ -133,7 +113,9 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken }) => {
|
|||
}
|
||||
}, [accessToken]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLiveTail) return;
|
||||
|
|
@ -146,9 +128,7 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken }) => {
|
|||
setSaving(toolName);
|
||||
try {
|
||||
await updateToolPolicy(accessToken, toolName, newPolicy);
|
||||
setTools((prev) =>
|
||||
prev.map((t) => (t.tool_name === toolName ? { ...t, call_policy: newPolicy } : t))
|
||||
);
|
||||
setTools((prev) => prev.map((t) => (t.tool_name === toolName ? { ...t, call_policy: newPolicy } : t)));
|
||||
} catch (e: any) {
|
||||
alert(`Failed to update policy: ${e.message}`);
|
||||
} finally {
|
||||
|
|
@ -178,12 +158,14 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken }) => {
|
|||
};
|
||||
|
||||
// Build unique team/key options from loaded data
|
||||
const teamOptions = Array.from(new Set(tools.map((t) => t.team_id).filter(Boolean))).map(
|
||||
(v) => ({ label: v as string, value: v as string })
|
||||
);
|
||||
const keyAliasOptions = Array.from(new Set(tools.map((t) => t.key_alias).filter(Boolean))).map(
|
||||
(v) => ({ label: v as string, value: v as string })
|
||||
);
|
||||
const teamOptions = Array.from(new Set(tools.map((t) => t.team_id).filter(Boolean))).map((v) => ({
|
||||
label: v as string,
|
||||
value: v as string,
|
||||
}));
|
||||
const keyAliasOptions = Array.from(new Set(tools.map((t) => t.key_alias).filter(Boolean))).map((v) => ({
|
||||
label: v as string,
|
||||
value: v as string,
|
||||
}));
|
||||
|
||||
const filterOptions: FilterOption[] = [
|
||||
{
|
||||
|
|
@ -245,7 +227,6 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken }) => {
|
|||
<div className="p-6 w-full">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Tool Policies</h1>
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full box-border">
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="border-b px-6 py-4 w-full max-w-full box-border">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between space-y-4 md:space-y-0 w-full max-w-full box-border">
|
||||
|
|
@ -256,16 +237,29 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken }) => {
|
|||
placeholder="Search by Tool Name"
|
||||
className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
value={searchTerm}
|
||||
onChange={(e) => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
<svg className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
<svg
|
||||
className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900">Live Tail</span>
|
||||
<Switch color="green" checked={isLiveTail} onChange={setIsLiveTail} />
|
||||
<Switch checked={isLiveTail} onChange={setIsLiveTail} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
|
@ -273,8 +267,18 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken }) => {
|
|||
disabled={isButtonLoading}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-60"
|
||||
>
|
||||
<svg className={`w-4 h-4 ${isButtonLoading ? "animate-spin" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
<svg
|
||||
className={`w-4 h-4 ${isButtonLoading ? "animate-spin" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
{isButtonLoading ? "Fetching" : "Fetch"}
|
||||
</button>
|
||||
|
|
@ -282,14 +286,27 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken }) => {
|
|||
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600 whitespace-nowrap">
|
||||
<span>
|
||||
Showing {filtered.length === 0 ? 0 : (currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, filtered.length)} of {filtered.length} results
|
||||
Showing {filtered.length === 0 ? 0 : (currentPage - 1) * pageSize + 1} -{" "}
|
||||
{Math.min(currentPage * pageSize, filtered.length)} of {filtered.length} results
|
||||
</span>
|
||||
<span>
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<span>Page {currentPage} of {totalPages}</span>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => setCurrentPage((p) => Math.max(1, p - 1))} disabled={currentPage === 1}
|
||||
className="px-3 py-1.5 border rounded-md text-sm hover:bg-gray-50 disabled:opacity-40">Previous</button>
|
||||
<button onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages}
|
||||
className="px-3 py-1.5 border rounded-md text-sm hover:bg-gray-50 disabled:opacity-40">Next</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1.5 border rounded-md text-sm hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1.5 border rounded-md text-sm hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -309,7 +326,9 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken }) => {
|
|||
{isLiveTail && (
|
||||
<div className="bg-green-50 border-b border-green-100 px-6 py-2 flex items-center justify-between">
|
||||
<span className="text-sm text-green-700">Auto-refreshing every 15 seconds</span>
|
||||
<button onClick={() => setIsLiveTail(false)} className="text-xs text-green-600 underline">Stop</button>
|
||||
<button onClick={() => setIsLiveTail(false)} className="text-xs text-green-600 underline">
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -321,20 +340,34 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken }) => {
|
|||
<Table className="[&_td]:py-0.5 [&_th]:py-1 w-full">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="py-1 h-8"><SortHeader label="Discovered" field="created_at" /></TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8"><SortHeader label="Tool Name" field="tool_name" /></TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8"><SortHeader label="Policy" field="call_policy" /></TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8"><SortHeader label="# Calls" field="call_count" /></TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8"><SortHeader label="Team Name" field="team_id" /></TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">
|
||||
<SortHeader label="Discovered" field="created_at" />
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">
|
||||
<SortHeader label="Tool Name" field="tool_name" />
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">
|
||||
<SortHeader label="Policy" field="call_policy" />
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">
|
||||
<SortHeader label="# Calls" field="call_count" />
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">
|
||||
<SortHeader label="Team Name" field="team_id" />
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">Key Hash</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8"><SortHeader label="Key Name" field="key_alias" /></TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">
|
||||
<SortHeader label="Key Name" field="key_alias" />
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">Origin</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-8 text-center text-gray-500">Loading tools…</TableCell>
|
||||
<TableCell colSpan={8} className="h-8 text-center text-gray-500">
|
||||
Loading tools…
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : paginated.length === 0 ? (
|
||||
<TableRow>
|
||||
|
|
@ -397,12 +430,25 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken }) => {
|
|||
{/* Bottom pagination (only when > 1 page) */}
|
||||
{totalPages > 1 && (
|
||||
<div className="border-t px-6 py-3 flex items-center justify-between text-sm text-gray-600">
|
||||
<span>Showing {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, sorted.length)} of {sorted.length}</span>
|
||||
<span>
|
||||
Showing {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, sorted.length)} of{" "}
|
||||
{sorted.length}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => setCurrentPage((p) => Math.max(1, p - 1))} disabled={currentPage === 1}
|
||||
className="px-3 py-1.5 border rounded-md hover:bg-gray-50 disabled:opacity-40">Previous</button>
|
||||
<button onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages}
|
||||
className="px-3 py-1.5 border rounded-md hover:bg-gray-50 disabled:opacity-40">Next</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1.5 border rounded-md hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1.5 border rounded-md hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -713,7 +713,7 @@ describe("UsagePage", () => {
|
|||
// Admin should see the user selector select element with the placeholder attribute
|
||||
const userSelects = screen.getAllByRole("combobox");
|
||||
const userSelect = userSelects.find(
|
||||
(el) => el.getAttribute("placeholder") === "All Users (Global View)",
|
||||
(el) => el.getAttribute("placeholder") === "Select user to filter...",
|
||||
);
|
||||
expect(userSelect).toBeDefined();
|
||||
});
|
||||
|
|
@ -828,7 +828,7 @@ describe("UsagePage", () => {
|
|||
// Non-admin should not see the user selector
|
||||
const userSelects = screen.getAllByRole("combobox");
|
||||
const userSelect = userSelects.find(
|
||||
(el) => el.getAttribute("placeholder") === "All Users (Global View)",
|
||||
(el) => el.getAttribute("placeholder") === "Select user to filter...",
|
||||
);
|
||||
expect(userSelect).toBeUndefined();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ vi.mock("../networking", () => ({
|
|||
soft_budget: null,
|
||||
}),
|
||||
fetchMCPAccessGroups: vi.fn().mockResolvedValue([]),
|
||||
getAgentsList: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock("../molecules/notifications_manager", () => ({
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ import { Accordion, AccordionBody, AccordionHeader, Button, Col, Grid, Text, Tex
|
|||
import { Button as Button2, Form, Input, Modal, Radio, Select, Switch, Tag, Tooltip } from "antd";
|
||||
import debounce from "lodash/debounce";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||
import { rolesWithWriteAccess } from "../../utils/roles";
|
||||
import AgentSelector from "../agent_management/AgentSelector";
|
||||
import { mapDisplayToInternalNames } from "../callback_info_helpers";
|
||||
import AccessGroupSelector from "../common_components/AccessGroupSelector";
|
||||
import BudgetDurationDropdown from "../common_components/budget_duration_dropdown";
|
||||
import SchemaFormFields from "../common_components/check_openapi_schema";
|
||||
import KeyLifecycleSettings from "../common_components/KeyLifecycleSettings";
|
||||
|
|
@ -20,7 +20,6 @@ import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSel
|
|||
import PremiumLoggingSettings from "../common_components/PremiumLoggingSettings";
|
||||
import RateLimitTypeFormItem from "../common_components/RateLimitTypeFormItem";
|
||||
import RouterSettingsAccordion, { RouterSettingsAccordionValue } from "../common_components/RouterSettingsAccordion";
|
||||
import AccessGroupSelector from "../common_components/AccessGroupSelector";
|
||||
import TeamDropdown from "../common_components/team_dropdown";
|
||||
import { CreateUserButton } from "../CreateUserButton";
|
||||
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
|
||||
|
|
@ -40,10 +39,10 @@ import {
|
|||
proxyBaseUrl,
|
||||
userFilterUICall,
|
||||
} from "../networking";
|
||||
import CreatedKeyDisplay from "../shared/CreatedKeyDisplay";
|
||||
import NumericalInput from "../shared/numerical_input";
|
||||
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
|
||||
import { simplifyKeyGenerateError } from "./utils";
|
||||
import CreatedKeyDisplay from "../shared/CreatedKeyDisplay";
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
|
|
@ -299,7 +298,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
|
|||
formValues.user_id = userID;
|
||||
} else if (keyOwner === "agent") {
|
||||
if (!selectedAgentId) {
|
||||
message.error("Please select an agent");
|
||||
NotificationsManager.error("Please select an agent");
|
||||
return;
|
||||
}
|
||||
formValues.agent_id = selectedAgentId;
|
||||
|
|
@ -559,7 +558,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
|
|||
<Radio value="you">You</Radio>
|
||||
<Radio value="service_account">Service Account</Radio>
|
||||
{userRole === "Admin" && <Radio value="another_user">Another User</Radio>}
|
||||
<Radio value="agent">Agent <Tag color="purple">New</Tag></Radio>
|
||||
<Radio value="agent">
|
||||
Agent <Tag color="purple">New</Tag>
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
|
|
@ -1005,9 +1006,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
|
|||
style={{ width: "100%" }}
|
||||
disabled={!premiumUser}
|
||||
placeholder={
|
||||
!premiumUser
|
||||
? "Premium feature - Upgrade to set policies by key"
|
||||
: "Select or enter policies"
|
||||
!premiumUser ? "Premium feature - Upgrade to set policies by key" : "Select or enter policies"
|
||||
}
|
||||
options={policiesList.map((name) => ({ value: name, label: name }))}
|
||||
/>
|
||||
|
|
@ -1059,9 +1058,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
|
|||
className="mt-4"
|
||||
help="Select access groups to assign to this key"
|
||||
>
|
||||
<AccessGroupSelector
|
||||
placeholder="Select access groups (optional)"
|
||||
/>
|
||||
<AccessGroupSelector placeholder="Select access groups (optional)" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
|
|
@ -1297,7 +1294,11 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
|
|||
accessToken={accessToken || ""}
|
||||
value={routerSettings || undefined}
|
||||
onChange={setRouterSettings}
|
||||
modelData={userModels.length > 0 ? { data: userModels.map((model) => ({ model_name: model })) } : undefined}
|
||||
modelData={
|
||||
userModels.length > 0
|
||||
? { data: userModels.map((model) => ({ model_name: model })) }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</AccordionBody>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export const pageDescriptions: Record<string, string> = {
|
|||
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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue