Merge litellm_internal_staging into litellm_/snug-churning-dewdrop

This commit is contained in:
Yuneng Jiang 2026-08-18 17:48:54 -07:00
commit d831f98326
No known key found for this signature in database
24 changed files with 1711 additions and 2770 deletions

View file

@ -9433,6 +9433,27 @@ class BaseLLMHTTPHandler:
)
###### VECTOR STORE HANDLER ######
@staticmethod
def _pre_call_direct_vector_store_search(
logging_obj: LiteLLMLoggingObj,
custom_llm_provider: str,
vector_store_id: str,
query: str | Sequence[str],
) -> None:
"""Direct providers have no HTTP request to echo, and an empty api_base makes the debug
logger fall back to dumping model_call_details, which holds stored provider credentials."""
endpoint: Final = f"{custom_llm_provider}://{vector_store_id}"
logging_obj.pre_call(
input="",
api_key="",
additional_args={ # mutable-ok: pre_call's additional_args contract is a dict
"query": query,
"vector_store_id": vector_store_id,
"api_base": endpoint,
"request_str": f"direct vector store search: {endpoint}",
},
)
async def async_vector_store_search_handler(
self,
vector_store_id: str,
@ -9449,13 +9470,11 @@ class BaseLLMHTTPHandler:
_is_async: bool = False,
) -> VectorStoreSearchResponse:
if isinstance(vector_store_provider_config, BaseDirectVectorStoreConfig):
logging_obj.pre_call(
input="",
api_key="",
additional_args={ # mutable-ok: pre_call's additional_args contract is a dict
"query": query,
"vector_store_id": vector_store_id,
},
self._pre_call_direct_vector_store_search(
logging_obj=logging_obj,
custom_llm_provider=custom_llm_provider,
vector_store_id=vector_store_id,
query=query,
)
return await vector_store_provider_config.aexecute_search_vector_store_request(
vector_store_id=vector_store_id,
@ -9580,13 +9599,11 @@ class BaseLLMHTTPHandler:
)
if isinstance(vector_store_provider_config, BaseDirectVectorStoreConfig):
logging_obj.pre_call(
input="",
api_key="",
additional_args={ # mutable-ok: pre_call's additional_args contract is a dict
"query": query,
"vector_store_id": vector_store_id,
},
self._pre_call_direct_vector_store_search(
logging_obj=logging_obj,
custom_llm_provider=custom_llm_provider,
vector_store_id=vector_store_id,
query=query,
)
return vector_store_provider_config.execute_search_vector_store_request(
vector_store_id=vector_store_id,

View file

@ -1,7 +1,9 @@
import asyncio
import json
import logging
import os
import sys
import time
from unittest.mock import AsyncMock, Mock, patch
import httpx
@ -9,6 +11,7 @@ import pytest
sys.path.insert(0, os.path.abspath("../../../..")) # Adds the parent directory to the system path
import litellm
from litellm._logging import verbose_logger
from litellm.integrations.code_interpreter_interception.handler import (
CodeInterpreterInterceptionLogger,
LITELLM_CODE_EXECUTION_TOOL_NAME,
@ -2158,3 +2161,68 @@ async def test_vector_store_search_handler_direct_config_async_skips_http():
pre_call_args = logging_obj.pre_call.call_args.kwargs["additional_args"]
assert pre_call_args["query"] == ["q1", "q2"]
assert pre_call_args["vector_store_id"] == "vs_direct"
def _direct_vector_store_debug_logging_obj():
from litellm.litellm_core_utils.litellm_logging import Logging as LitellmLogging
logging_obj = LitellmLogging(
model="valkey",
messages=[{"role": "user", "content": "q"}],
stream=False,
call_type="vector_store_search",
start_time=time.time(),
litellm_call_id="vs-debug-call-id",
function_id="vs-debug-function-id",
log_raw_request_response=True,
)
logging_obj.update_environment_variables(
model="valkey",
optional_params={"vector_store_id": "vs_direct", "query": "q"},
litellm_params={
"litellm_call_id": "vs-debug-call-id",
"vector_store_id": "vs_direct",
"litellm_request_debug": True,
"metadata": {"user_api_key_alias": "vs-test-key"},
"valkey_host": "valkey.internal",
"valkey_password": "sup3r-s3cret-valkey-pw",
"litellm_embedding_config": {"api_key": "sk-embedding-s3cret"},
},
)
return logging_obj
@pytest.mark.parametrize("is_async", [False, True])
def test_direct_vector_store_search_debug_log_omits_stored_credentials(caplog, is_async):
"""Regression: an empty api_base made pre_call dump the whole model_call_details, so every
search shipped the stored valkey_password / embedding api_key into the raw_request metadata."""
handler = BaseLLMHTTPHandler()
stub_response = {"object": "vector_store.search_results.page", "search_query": "q", "data": []}
config = _make_stub_direct_vector_store_config(stub_response)
logging_obj = _direct_vector_store_debug_logging_obj()
with caplog.at_level(logging.DEBUG, logger=verbose_logger.name):
result = handler.vector_store_search_handler(
vector_store_id="vs_direct",
query="q",
vector_store_search_optional_params={"max_num_results": 4},
vector_store_provider_config=config,
custom_llm_provider="valkey",
litellm_params=GenericLiteLLMParams(
valkey_host="valkey.internal",
valkey_password="sup3r-s3cret-valkey-pw",
),
logging_obj=logging_obj,
_is_async=is_async,
)
if is_async:
result = asyncio.run(result)
assert result is stub_response
raw_request = logging_obj.model_call_details["litellm_params"]["metadata"]["raw_request"]
assert "sup3r-s3cret-valkey-pw" not in raw_request
assert "sk-embedding-s3cret" not in raw_request
assert "valkey://vs_direct" in raw_request
logged = "\n".join(record.getMessage() for record in caplog.records)
assert "sup3r-s3cret-valkey-pw" not in logged
assert "sk-embedding-s3cret" not in logged

View file

@ -1055,11 +1055,6 @@
"count": 1
}
},
"src/app/(dashboard)/policies/_components/impact_popover.test.tsx": {
"react/display-name": {
"count": 1
}
},
"src/app/(dashboard)/policies/_components/impact_popover.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -1073,11 +1068,6 @@
"count": 1
}
},
"src/app/(dashboard)/policies/_components/index.test.tsx": {
"react/display-name": {
"count": 1
}
},
"src/app/(dashboard)/policies/_components/index.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -1826,12 +1816,6 @@
"src/components/add_model/cache_control_settings.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"prefer-const": {
"count": 1
}
},
"src/components/add_model/conditional_public_model_name.test.tsx": {
@ -2063,14 +2047,6 @@
"count": 1
}
},
"src/components/common_components/chartUtils.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-nested-ternary": {
"count": 1
}
},
"src/components/common_components/check_openapi_schema.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -2294,15 +2270,6 @@
"local/filename-pascal-case": {
"count": 1
},
"local/no-complex-jsx-arrow": {
"count": 1
},
"max-lines": {
"count": 1
},
"no-nested-ternary": {
"count": 14
},
"no-restricted-imports": {
"count": 1
},
@ -3062,9 +3029,6 @@
"tests/setupTests.ts": {
"@typescript-eslint/no-this-alias": {
"count": 1
},
"react/display-name": {
"count": 1
}
}
}

View file

@ -18,7 +18,6 @@
"@tanstack/react-pacer": "0.22.1",
"@tanstack/react-query": "5.100.7",
"@tanstack/react-table": "8.21.3",
"@tremor/react": "3.18.7",
"@types/papaparse": "5.5.2",
"antd": "5.29.3",
"cva": "1.0.0-beta.4",
@ -1460,87 +1459,12 @@
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/react": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.19.2.tgz",
"integrity": "sha512-JyNk4A0Ezirq8FlXECvRtQOX/iBe5Ize0W/pLkrZjfHW9GUV7Xnq6zm6fyZuQzaHHqEnVizmvlA96e1/CkZv+w==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^1.3.0",
"aria-hidden": "^1.1.3",
"tabbable": "^6.0.1"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-1.3.0.tgz",
"integrity": "sha512-htwHm67Ji5E/pROEAr7f8IKFShuiCKHwUC/UY4vC3I5jiSvGFAYnSYiZO5MlGmads+QqvUkR9ANHEguGrDv72g==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.2.1"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@headlessui/react": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.0.tgz",
"integrity": "sha512-RzCEg+LXsuI7mHiSomsu/gBJSjpupm6A1qIZ5sWjd7JhARNlMiSA4kKfJpCKwU9tE+zMRterhhrP74PvfJrpXQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.26.16",
"@react-aria/focus": "^3.17.1",
"@react-aria/interactions": "^3.21.3",
"@tanstack/react-virtual": "^3.8.1"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/@headlessui/react/node_modules/@floating-ui/react": {
"version": "0.26.28",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
"integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.2",
"@floating-ui/utils": "^0.2.8",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@headlessui/react/node_modules/@floating-ui/react-dom": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
"integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.6"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@headlessui/tailwindcss": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@headlessui/tailwindcss/-/tailwindcss-0.2.2.tgz",
@ -2141,33 +2065,6 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@internationalized/date": {
"version": "3.12.1",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.1.tgz",
"integrity": "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@internationalized/number": {
"version": "3.6.6",
"resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.6.tgz",
"integrity": "sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@internationalized/string": {
"version": "3.2.8",
"resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.8.tgz",
"integrity": "sha512-NdbMQUSfXLYIQol5VyMtinm9pZDciiMfN7RtmSuSB78io1hqwJ0naYfxyW6vgxWBkzWymQa/3uLDlbfmshtCaA==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
@ -2876,44 +2773,6 @@
"react-dom": ">=16.9.0"
}
},
"node_modules/@react-aria/focus": {
"version": "3.22.0",
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.22.0.tgz",
"integrity": "sha512-ZfDOVuVhqDsM9mkNji3QUZ/d40JhlVgXrDkrfXylM1035QCrcTHN7m2DpbE95sU2A8EQb4wikvt5jM6K/73BPg==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0",
"react-aria": "3.48.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/interactions": {
"version": "3.28.0",
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.28.0.tgz",
"integrity": "sha512-OXwdU1EWFdMxmr/K1CXNGJzmNlCClByb+PuCaqUyzBymHPCGVhawirLIon/CrIN5psh3AiWpHSh4H0WeJdVpng==",
"license": "Apache-2.0",
"dependencies": {
"@react-types/shared": "^3.34.0",
"@swc/helpers": "^0.5.0",
"react-aria": "3.48.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-types/shared": {
"version": "3.34.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.34.0.tgz",
"integrity": "sha512-gp6xo/s2lX54AlTjOiqwDnxA7UW79BNvI9dB9pr3LZTzRKCd1ZA+ZbgKw/ReIiWuvvVw/8QFJpnqeeFyLocMcQ==",
"license": "Apache-2.0",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@redocly/ajv": {
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
@ -3839,23 +3698,6 @@
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.24",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz",
"integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.14.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/store": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.0.tgz",
@ -3879,16 +3721,6 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz",
"integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@ -3978,93 +3810,6 @@
"@testing-library/dom": ">=7.21.4"
}
},
"node_modules/@tremor/react": {
"version": "3.18.7",
"resolved": "https://registry.npmjs.org/@tremor/react/-/react-3.18.7.tgz",
"integrity": "sha512-nmqvf/1m0GB4LXc7v2ftdfSLoZhy5WLrhV6HNf0SOriE6/l8WkYeWuhQq8QsBjRi94mUIKLJ/VC3/Y/pj6VubQ==",
"license": "Apache 2.0",
"dependencies": {
"@floating-ui/react": "^0.19.2",
"@headlessui/react": "2.2.0",
"date-fns": "^3.6.0",
"react-day-picker": "^8.10.1",
"react-transition-state": "^2.1.2",
"recharts": "^2.13.3",
"tailwind-merge": "^2.5.2"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/@tremor/react/node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/@tremor/react/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/@tremor/react/node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
"deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tremor/react/node_modules/tailwind-merge": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
"integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/@tremor/react/node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@ -5203,18 +4948,6 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
@ -6314,16 +6047,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -7201,15 +6924,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz",
"integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-glob": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
@ -9229,12 +8943,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -11868,27 +11576,6 @@
"node": ">=0.10.0"
}
},
"node_modules/react-aria": {
"version": "3.48.0",
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.48.0.tgz",
"integrity": "sha512-jQjd4rBEIMqecBaAKYJbVGK6EqIHLa5znVQ7jwFyK5vCyljoj6KhgtiahmcIPsG5vG5vEDLw+ba+bEWn6A2P4w==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.1",
"@internationalized/number": "^3.6.6",
"@internationalized/string": "^3.2.8",
"@react-types/shared": "^3.34.0",
"@swc/helpers": "^0.5.0",
"aria-hidden": "^1.2.3",
"clsx": "^2.0.0",
"react-stately": "3.46.0",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/react-copy-to-clipboard": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.1.tgz",
@ -11902,20 +11589,6 @@
"react": ">=15.3.0"
}
},
"node_modules/react-day-picker": {
"version": "8.10.2",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.2.tgz",
"integrity": "sha512-LK68OTbHB3oJNhl9cA0qVizzp3o26w61YSjAFkYi67N86iro32wx86kSNeFU/hq+gI8m1yzWhnomMLfZ041RzQ==",
"license": "MIT",
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"date-fns": "^2.28.0 || ^3.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@ -12013,38 +11686,6 @@
}
}
},
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"license": "MIT",
"dependencies": {
"fast-equals": "^5.0.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-stately": {
"version": "3.46.0",
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.46.0.tgz",
"integrity": "sha512-OdxhWvHgs2L4OJGIs7hnuTr5WjjMM6enhNEAMRqiekhF8+ITvA2LRwNftOZwcogaoCslGYq5S2VQTQwnm0GbCA==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.1",
"@internationalized/number": "^3.6.6",
"@internationalized/string": "^3.2.8",
"@react-types/shared": "^3.34.0",
"@swc/helpers": "^0.5.0",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/react-syntax-highlighter": {
"version": "15.6.6",
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.6.tgz",
@ -12062,32 +11703,6 @@
"react": ">= 0.14.0"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/react-transition-state": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/react-transition-state/-/react-transition-state-2.3.3.tgz",
"integrity": "sha512-wsIyg07ohlWEAYDZHvuXh/DY7mxlcLb0iqVv2aMXJ0gwgPVKNWKhOyNyzuJy/tt/6urSq0WT6BBZ/tdpybaAsQ==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/recharts": {
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.2.tgz",
@ -12118,15 +11733,6 @@
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts-scale": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
"license": "MIT",
"dependencies": {
"decimal.js-light": "^2.4.1"
}
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@ -13200,12 +12806,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/tabbable": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
"license": "MIT"
},
"node_modules/tailwind-merge": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",

View file

@ -31,7 +31,6 @@
"@tanstack/react-pacer": "0.22.1",
"@tanstack/react-query": "5.100.7",
"@tanstack/react-table": "8.21.3",
"@tremor/react": "3.18.7",
"@types/papaparse": "5.5.2",
"antd": "5.29.3",
"cva": "1.0.0-beta.4",
@ -103,7 +102,6 @@
"axios": "1.13.6",
"postcss": "8.5.23",
"esbuild": "0.28.1",
"date-fns": "^4.4.0",
"sharp": "^0.35.0"
},
"engines": {

View file

@ -3,11 +3,6 @@ import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import PriceDataManagementTab from "./PriceDataManagementTab";
// Deliberately do NOT mock @tremor/react. These tab components render standalone
// (inside antd Tabs / directly as a route page), no longer inside a Tremor
// <TabGroup>. A Tremor <TabPanel> root renders nothing without that context, so
// this asserts the component's content is visible on its own — reverting the root
// back to <TabPanel> makes the title disappear and fails this test.
vi.mock("@/components/price_data_reload", () => ({ default: () => <div>reload</div> }));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({ accessToken: "sk-test" }) }));
vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({
@ -15,7 +10,7 @@ vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({
}));
describe("PriceDataManagementTab", () => {
it("renders its content standalone, without a Tremor TabGroup ancestor", () => {
it("renders its content standalone, without a tab-panel ancestor", () => {
const { getByText } = render(<PriceDataManagementTab />);
expect(getByText("Price Data Management")).toBeInTheDocument();
});

View file

@ -30,16 +30,6 @@ vi.mock("@heroicons/react/outline", () => ({
},
}));
vi.mock("@tremor/react", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tremor/react")>();
return {
...actual,
Icon: React.forwardRef<HTMLButtonElement, LegacyIconProps>(({ icon: _icon, ...props }, ref) => (
<button ref={ref} type="button" {...props} />
)),
};
});
vi.mock("antd", async (importOriginal) => {
const actual = await importOriginal<typeof import("antd")>();
return {

View file

@ -56,33 +56,6 @@ vi.mock("./impact_popover", () => ({
default: () => <button type="button" aria-label="View blast radius" />,
}));
vi.mock("@tremor/react", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tremor/react")>();
return {
...actual,
Button: React.forwardRef<HTMLButtonElement, any>(({ children, ...props }, ref) =>
React.createElement("button", { ...props, ref }, children),
),
Tooltip: ({ children }: { children?: React.ReactNode }) => React.createElement(React.Fragment, null, children),
Switch: ({
checked,
onChange,
className,
}: {
checked?: boolean;
onChange?: (v: boolean) => void;
className?: string;
}) =>
React.createElement("input", {
type: "checkbox",
role: "switch",
checked,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => onChange?.(e.target.checked),
className,
}),
};
});
vi.mock("./policy_templates", () => ({
__esModule: true,
default: () => <div data-testid="policy-templates-stub" />,

View file

@ -2,9 +2,6 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "./tremor-v3-compat.css" layer(utilities);
@source '../../node_modules/@tremor/react';
@plugin '@headlessui/tailwindcss';
@plugin '@tailwindcss/forms';
@ -95,6 +92,9 @@
--accent: oklch(0.967 0.003 264.542);
--accent-foreground: oklch(0.21 0.034 264.665);
--destructive: oklch(0.577 0.245 27.325);
--success: oklch(0.527 0.154 150.069);
--warning: oklch(0.555 0.163 48.998);
--info: oklch(0.546 0.245 262.881);
--border: oklch(0.928 0.006 264.531);
--input: oklch(0.928 0.006 264.531);
--ring: oklch(0.707 0.022 261.325);
@ -130,6 +130,9 @@
--accent: oklch(0.278 0.033 256.848);
--accent-foreground: oklch(0.985 0.002 247.839);
--destructive: oklch(0.704 0.191 22.216);
--success: oklch(0.792 0.209 151.711);
--warning: oklch(0.828 0.189 84.429);
--info: oklch(0.707 0.165 254.624);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
@ -168,6 +171,9 @@
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-success: var(--success);
--color-warning: var(--warning);
--color-info: var(--info);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
@ -186,66 +192,6 @@
--color-sidebar-ring: var(--sidebar-ring);
}
@theme {
--color-tremor-brand-muted: #8688ef;
--color-tremor-brand-subtle: #8e91eb;
--color-tremor-brand: #6366f1;
--color-tremor-brand-emphasis: #4338ca;
--color-tremor-brand-inverted: #ffffff;
--color-tremor-background-muted: #f9fafb;
--color-tremor-background-subtle: #f3f4f6;
--color-tremor-background: #ffffff;
--color-tremor-background-emphasis: #374151;
--color-tremor-border: #e5e7eb;
--color-tremor-ring: #e5e7eb;
--color-tremor-content-subtle: #9ca3af;
--color-tremor-content: #6b7280;
--color-tremor-content-emphasis: #374151;
--color-tremor-content-strong: #111827;
--color-tremor-content-inverted: #ffffff;
--color-dark-tremor-brand-faint: #0b1229;
--color-dark-tremor-brand-muted: #1e1b4b;
--color-dark-tremor-brand-subtle: #3730a3;
--color-dark-tremor-brand: #6366f1;
--color-dark-tremor-brand-emphasis: #818cf8;
--color-dark-tremor-brand-inverted: #1e1b4b;
--color-dark-tremor-background-muted: #131a2b;
--color-dark-tremor-background-subtle: #1f2937;
--color-dark-tremor-background: #111827;
--color-dark-tremor-background-emphasis: #d1d5db;
--color-dark-tremor-border: #374151;
--color-dark-tremor-ring: #1f2937;
--color-dark-tremor-content-subtle: #4b5563;
--color-dark-tremor-content: #6b7280;
--color-dark-tremor-content-emphasis: #e5e7eb;
--color-dark-tremor-content-strong: #f9fafb;
--color-dark-tremor-content-inverted: #030712;
--shadow-tremor-input: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-tremor-card: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-tremor-dropdown: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-dark-tremor-input: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-dark-tremor-card: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-dark-tremor-dropdown: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--radius-tremor-small: 0.375rem;
--radius-tremor-default: 0.5rem;
--radius-tremor-full: 9999px;
--text-tremor-label: 0.75rem;
--text-tremor-label--line-height: 0.3rem;
--text-tremor-default: 0.775rem;
--text-tremor-default--line-height: 1.15rem;
--text-tremor-title: 1.025rem;
--text-tremor-title--line-height: 1.65rem;
--text-tremor-metric: 1.675rem;
--text-tremor-metric--line-height: 2.15rem;
}
@source inline("{,hover:,ui-selected:}{bg,text,border}-{slate,gray,zinc,neutral,stone,red,orange,amber,yellow,lime,green,emerald,teal,cyan,sky,blue,indigo,violet,purple,fuchsia,pink,rose}-{50,{100..900..100},950}");
@source inline("{ring,stroke,fill}-{slate,gray,zinc,neutral,stone,red,orange,amber,yellow,lime,green,emerald,teal,cyan,sky,blue,indigo,violet,purple,fuchsia,pink,rose}-{50,{100..900..100},950}");
@layer base {
*,
::after,

View file

@ -1,615 +0,0 @@
.bg-slate-500.bg-opacity-10 {
background-color: color-mix(in oklab, var(--color-slate-500) 10%, transparent);
}
.bg-slate-500.bg-opacity-20 {
background-color: color-mix(in oklab, var(--color-slate-500) 20%, transparent);
}
.bg-slate-500.bg-opacity-40 {
background-color: color-mix(in oklab, var(--color-slate-500) 40%, transparent);
}
.hover\:bg-slate-500.hover\:bg-opacity-20:hover {
background-color: color-mix(in oklab, var(--color-slate-500) 20%, transparent);
}
.group:hover .bg-slate-500.group-hover\:bg-opacity-30 {
background-color: color-mix(in oklab, var(--color-slate-500) 30%, transparent);
}
.ring-slate-500.ring-opacity-20 {
--tw-ring-color: color-mix(in oklab, var(--color-slate-500) 20%, transparent);
}
.ring-slate-300.ring-opacity-40 {
--tw-ring-color: color-mix(in oklab, var(--color-slate-300) 40%, transparent);
}
.bg-gray-500.bg-opacity-10 {
background-color: color-mix(in oklab, var(--color-gray-500) 10%, transparent);
}
.bg-gray-500.bg-opacity-20 {
background-color: color-mix(in oklab, var(--color-gray-500) 20%, transparent);
}
.bg-gray-500.bg-opacity-40 {
background-color: color-mix(in oklab, var(--color-gray-500) 40%, transparent);
}
.hover\:bg-gray-500.hover\:bg-opacity-20:hover {
background-color: color-mix(in oklab, var(--color-gray-500) 20%, transparent);
}
.group:hover .bg-gray-500.group-hover\:bg-opacity-30 {
background-color: color-mix(in oklab, var(--color-gray-500) 30%, transparent);
}
.ring-gray-500.ring-opacity-20 {
--tw-ring-color: color-mix(in oklab, var(--color-gray-500) 20%, transparent);
}
.ring-gray-300.ring-opacity-40 {
--tw-ring-color: color-mix(in oklab, var(--color-gray-300) 40%, transparent);
}
.bg-zinc-500.bg-opacity-10 {
background-color: color-mix(in oklab, var(--color-zinc-500) 10%, transparent);
}
.bg-zinc-500.bg-opacity-20 {
background-color: color-mix(in oklab, var(--color-zinc-500) 20%, transparent);
}
.bg-zinc-500.bg-opacity-40 {
background-color: color-mix(in oklab, var(--color-zinc-500) 40%, transparent);
}
.hover\:bg-zinc-500.hover\:bg-opacity-20:hover {
background-color: color-mix(in oklab, var(--color-zinc-500) 20%, transparent);
}
.group:hover .bg-zinc-500.group-hover\:bg-opacity-30 {
background-color: color-mix(in oklab, var(--color-zinc-500) 30%, transparent);
}
.ring-zinc-500.ring-opacity-20 {
--tw-ring-color: color-mix(in oklab, var(--color-zinc-500) 20%, transparent);
}
.ring-zinc-300.ring-opacity-40 {
--tw-ring-color: color-mix(in oklab, var(--color-zinc-300) 40%, transparent);
}
.bg-neutral-500.bg-opacity-10 {
background-color: color-mix(in oklab, var(--color-neutral-500) 10%, transparent);
}
.bg-neutral-500.bg-opacity-20 {
background-color: color-mix(in oklab, var(--color-neutral-500) 20%, transparent);
}
.bg-neutral-500.bg-opacity-40 {
background-color: color-mix(in oklab, var(--color-neutral-500) 40%, transparent);
}
.hover\:bg-neutral-500.hover\:bg-opacity-20:hover {
background-color: color-mix(in oklab, var(--color-neutral-500) 20%, transparent);
}
.group:hover .bg-neutral-500.group-hover\:bg-opacity-30 {
background-color: color-mix(in oklab, var(--color-neutral-500) 30%, transparent);
}
.ring-neutral-500.ring-opacity-20 {
--tw-ring-color: color-mix(in oklab, var(--color-neutral-500) 20%, transparent);
}
.ring-neutral-300.ring-opacity-40 {
--tw-ring-color: color-mix(in oklab, var(--color-neutral-300) 40%, transparent);
}
.bg-stone-500.bg-opacity-10 {
background-color: color-mix(in oklab, var(--color-stone-500) 10%, transparent);
}
.bg-stone-500.bg-opacity-20 {
background-color: color-mix(in oklab, var(--color-stone-500) 20%, transparent);
}
.bg-stone-500.bg-opacity-40 {
background-color: color-mix(in oklab, var(--color-stone-500) 40%, transparent);
}
.hover\:bg-stone-500.hover\:bg-opacity-20:hover {
background-color: color-mix(in oklab, var(--color-stone-500) 20%, transparent);
}
.group:hover .bg-stone-500.group-hover\:bg-opacity-30 {
background-color: color-mix(in oklab, var(--color-stone-500) 30%, transparent);
}
.ring-stone-500.ring-opacity-20 {
--tw-ring-color: color-mix(in oklab, var(--color-stone-500) 20%, transparent);
}
.ring-stone-300.ring-opacity-40 {
--tw-ring-color: color-mix(in oklab, var(--color-stone-300) 40%, transparent);
}
.bg-red-500.bg-opacity-10 {
background-color: color-mix(in oklab, var(--color-red-500) 10%, transparent);
}
.bg-red-500.bg-opacity-20 {
background-color: color-mix(in oklab, var(--color-red-500) 20%, transparent);
}
.bg-red-500.bg-opacity-40 {
background-color: color-mix(in oklab, var(--color-red-500) 40%, transparent);
}
.hover\:bg-red-500.hover\:bg-opacity-20:hover {
background-color: color-mix(in oklab, var(--color-red-500) 20%, transparent);
}
.group:hover .bg-red-500.group-hover\:bg-opacity-30 {
background-color: color-mix(in oklab, var(--color-red-500) 30%, transparent);
}
.ring-red-500.ring-opacity-20 {
--tw-ring-color: color-mix(in oklab, var(--color-red-500) 20%, transparent);
}
.ring-red-300.ring-opacity-40 {
--tw-ring-color: color-mix(in oklab, var(--color-red-300) 40%, transparent);
}
.bg-orange-500.bg-opacity-10 {
background-color: color-mix(in oklab, var(--color-orange-500) 10%, transparent);
}
.bg-orange-500.bg-opacity-20 {
background-color: color-mix(in oklab, var(--color-orange-500) 20%, transparent);
}
.bg-orange-500.bg-opacity-40 {
background-color: color-mix(in oklab, var(--color-orange-500) 40%, transparent);
}
.hover\:bg-orange-500.hover\:bg-opacity-20:hover {
background-color: color-mix(in oklab, var(--color-orange-500) 20%, transparent);
}
.group:hover .bg-orange-500.group-hover\:bg-opacity-30 {
background-color: color-mix(in oklab, var(--color-orange-500) 30%, transparent);
}
.ring-orange-500.ring-opacity-20 {
--tw-ring-color: color-mix(in oklab, var(--color-orange-500) 20%, transparent);
}
.ring-orange-300.ring-opacity-40 {
--tw-ring-color: color-mix(in oklab, var(--color-orange-300) 40%, transparent);
}
.bg-amber-500.bg-opacity-10 {
background-color: color-mix(in oklab, var(--color-amber-500) 10%, transparent);
}
.bg-amber-500.bg-opacity-20 {
background-color: color-mix(in oklab, var(--color-amber-500) 20%, transparent);
}
.bg-amber-500.bg-opacity-40 {
background-color: color-mix(in oklab, var(--color-amber-500) 40%, transparent);
}
.hover\:bg-amber-500.hover\:bg-opacity-20:hover {
background-color: color-mix(in oklab, var(--color-amber-500) 20%, transparent);
}
.group:hover .bg-amber-500.group-hover\:bg-opacity-30 {
background-color: color-mix(in oklab, var(--color-amber-500) 30%, transparent);
}
.ring-amber-500.ring-opacity-20 {
--tw-ring-color: color-mix(in oklab, var(--color-amber-500) 20%, transparent);
}
.ring-amber-300.ring-opacity-40 {
--tw-ring-color: color-mix(in oklab, var(--color-amber-300) 40%, transparent);
}
.bg-yellow-500.bg-opacity-10 {
background-color: color-mix(in oklab, var(--color-yellow-500) 10%, transparent);
}
.bg-yellow-500.bg-opacity-20 {
background-color: color-mix(in oklab, var(--color-yellow-500) 20%, transparent);
}
.bg-yellow-500.bg-opacity-40 {
background-color: color-mix(in oklab, var(--color-yellow-500) 40%, transparent);
}
.hover\:bg-yellow-500.hover\:bg-opacity-20:hover {
background-color: color-mix(in oklab, var(--color-yellow-500) 20%, transparent);
}
.group:hover .bg-yellow-500.group-hover\:bg-opacity-30 {
background-color: color-mix(in oklab, var(--color-yellow-500) 30%, transparent);
}
.ring-yellow-500.ring-opacity-20 {
--tw-ring-color: color-mix(in oklab, var(--color-yellow-500) 20%, transparent);
}
.ring-yellow-300.ring-opacity-40 {
--tw-ring-color: color-mix(in oklab, var(--color-yellow-300) 40%, transparent);
}
.bg-lime-500.bg-opacity-10 {
background-color: color-mix(in oklab, var(--color-lime-500) 10%, transparent);
}
.bg-lime-500.bg-opacity-20 {
background-color: color-mix(in oklab, var(--color-lime-500) 20%, transparent);
}
.bg-lime-500.bg-opacity-40 {
background-color: color-mix(in oklab, var(--color-lime-500) 40%, transparent);
}
.hover\:bg-lime-500.hover\:bg-opacity-20:hover {
background-color: color-mix(in oklab, var(--color-lime-500) 20%, transparent);
}
.group:hover .bg-lime-500.group-hover\:bg-opacity-30 {
background-color: color-mix(in oklab, var(--color-lime-500) 30%, transparent);
}
.ring-lime-500.ring-opacity-20 {
--tw-ring-color: color-mix(in oklab, var(--color-lime-500) 20%, transparent);
}
.ring-lime-300.ring-opacity-40 {
--tw-ring-color: color-mix(in oklab, var(--color-lime-300) 40%, transparent);
}
.bg-green-500.bg-opacity-10 {
background-color: color-mix(in oklab, var(--color-green-500) 10%, transparent);
}
.bg-green-500.bg-opacity-20 {
background-color: color-mix(in oklab, var(--color-green-500) 20%, transparent);
}
.bg-green-500.bg-opacity-40 {
background-color: color-mix(in oklab, var(--color-green-500) 40%, transparent);
}
.hover\:bg-green-500.hover\:bg-opacity-20:hover {
background-color: color-mix(in oklab, var(--color-green-500) 20%, transparent);
}
.group:hover .bg-green-500.group-hover\:bg-opacity-30 {
background-color: color-mix(in oklab, var(--color-green-500) 30%, transparent);
}
.ring-green-500.ring-opacity-20 {
--tw-ring-color: color-mix(in oklab, var(--color-green-500) 20%, transparent);
}
.ring-green-300.ring-opacity-40 {
--tw-ring-color: color-mix(in oklab, var(--color-green-300) 40%, transparent);
}
.bg-emerald-500.bg-opacity-10 {
background-color: color-mix(in oklab, var(--color-emerald-500) 10%, transparent);
}
.bg-emerald-500.bg-opacity-20 {
background-color: color-mix(in oklab, var(--color-emerald-500) 20%, transparent);
}
.bg-emerald-500.bg-opacity-40 {
background-color: color-mix(in oklab, var(--color-emerald-500) 40%, transparent);
}
.hover\:bg-emerald-500.hover\:bg-opacity-20:hover {
background-color: color-mix(in oklab, var(--color-emerald-500) 20%, transparent);
}
.group:hover .bg-emerald-500.group-hover\:bg-opacity-30 {
background-color: color-mix(in oklab, var(--color-emerald-500) 30%, transparent);
}
.ring-emerald-500.ring-opacity-20 {
--tw-ring-color: color-mix(in oklab, var(--color-emerald-500) 20%, transparent);
}
.ring-emerald-300.ring-opacity-40 {
--tw-ring-color: color-mix(in oklab, var(--color-emerald-300) 40%, transparent);
}
.bg-teal-500.bg-opacity-10 {
background-color: color-mix(in oklab, var(--color-teal-500) 10%, transparent);
}
.bg-teal-500.bg-opacity-20 {
background-color: color-mix(in oklab, var(--color-teal-500) 20%, transparent);
}
.bg-teal-500.bg-opacity-40 {
background-color: color-mix(in oklab, var(--color-teal-500) 40%, transparent);
}
.hover\:bg-teal-500.hover\:bg-opacity-20:hover {
background-color: color-mix(in oklab, var(--color-teal-500) 20%, transparent);
}
.group:hover .bg-teal-500.group-hover\:bg-opacity-30 {
background-color: color-mix(in oklab, var(--color-teal-500) 30%, transparent);
}
.ring-teal-500.ring-opacity-20 {
--tw-ring-color: color-mix(in oklab, var(--color-teal-500) 20%, transparent);
}
.ring-teal-300.ring-opacity-40 {
--tw-ring-color: color-mix(in oklab, var(--color-teal-300) 40%, transparent);
}
.bg-cyan-500.bg-opacity-10 {
background-color: color-mix(in oklab, var(--color-cyan-500) 10%, transparent);
}
.bg-cyan-500.bg-opacity-20 {
background-color: color-mix(in oklab, var(--color-cyan-500) 20%, transparent);
}
.bg-cyan-500.bg-opacity-40 {
background-color: color-mix(in oklab, var(--color-cyan-500) 40%, transparent);
}
.hover\:bg-cyan-500.hover\:bg-opacity-20:hover {
background-color: color-mix(in oklab, var(--color-cyan-500) 20%, transparent);
}
.group:hover .bg-cyan-500.group-hover\:bg-opacity-30 {
background-color: color-mix(in oklab, var(--color-cyan-500) 30%, transparent);
}
.ring-cyan-500.ring-opacity-20 {
--tw-ring-color: color-mix(in oklab, var(--color-cyan-500) 20%, transparent);
}
.ring-cyan-300.ring-opacity-40 {
--tw-ring-color: color-mix(in oklab, var(--color-cyan-300) 40%, transparent);
}
.bg-sky-500.bg-opacity-10 {
background-color: color-mix(in oklab, var(--color-sky-500) 10%, transparent);
}
.bg-sky-500.bg-opacity-20 {
background-color: color-mix(in oklab, var(--color-sky-500) 20%, transparent);
}
.bg-sky-500.bg-opacity-40 {
background-color: color-mix(in oklab, var(--color-sky-500) 40%, transparent);
}
.hover\:bg-sky-500.hover\:bg-opacity-20:hover {
background-color: color-mix(in oklab, var(--color-sky-500) 20%, transparent);
}
.group:hover .bg-sky-500.group-hover\:bg-opacity-30 {
background-color: color-mix(in oklab, var(--color-sky-500) 30%, transparent);
}
.ring-sky-500.ring-opacity-20 {
--tw-ring-color: color-mix(in oklab, var(--color-sky-500) 20%, transparent);
}
.ring-sky-300.ring-opacity-40 {
--tw-ring-color: color-mix(in oklab, var(--color-sky-300) 40%, transparent);
}
.bg-blue-500.bg-opacity-10 {
background-color: color-mix(in oklab, var(--color-blue-500) 10%, transparent);
}
.bg-blue-500.bg-opacity-20 {
background-color: color-mix(in oklab, var(--color-blue-500) 20%, transparent);
}
.bg-blue-500.bg-opacity-40 {
background-color: color-mix(in oklab, var(--color-blue-500) 40%, transparent);
}
.hover\:bg-blue-500.hover\:bg-opacity-20:hover {
background-color: color-mix(in oklab, var(--color-blue-500) 20%, transparent);
}
.group:hover .bg-blue-500.group-hover\:bg-opacity-30 {
background-color: color-mix(in oklab, var(--color-blue-500) 30%, transparent);
}
.ring-blue-500.ring-opacity-20 {
--tw-ring-color: color-mix(in oklab, var(--color-blue-500) 20%, transparent);
}
.ring-blue-300.ring-opacity-40 {
--tw-ring-color: color-mix(in oklab, var(--color-blue-300) 40%, transparent);
}
.bg-indigo-500.bg-opacity-10 {
background-color: color-mix(in oklab, var(--color-indigo-500) 10%, transparent);
}
.bg-indigo-500.bg-opacity-20 {
background-color: color-mix(in oklab, var(--color-indigo-500) 20%, transparent);
}
.bg-indigo-500.bg-opacity-40 {
background-color: color-mix(in oklab, var(--color-indigo-500) 40%, transparent);
}
.hover\:bg-indigo-500.hover\:bg-opacity-20:hover {
background-color: color-mix(in oklab, var(--color-indigo-500) 20%, transparent);
}
.group:hover .bg-indigo-500.group-hover\:bg-opacity-30 {
background-color: color-mix(in oklab, var(--color-indigo-500) 30%, transparent);
}
.ring-indigo-500.ring-opacity-20 {
--tw-ring-color: color-mix(in oklab, var(--color-indigo-500) 20%, transparent);
}
.ring-indigo-300.ring-opacity-40 {
--tw-ring-color: color-mix(in oklab, var(--color-indigo-300) 40%, transparent);
}
.bg-violet-500.bg-opacity-10 {
background-color: color-mix(in oklab, var(--color-violet-500) 10%, transparent);
}
.bg-violet-500.bg-opacity-20 {
background-color: color-mix(in oklab, var(--color-violet-500) 20%, transparent);
}
.bg-violet-500.bg-opacity-40 {
background-color: color-mix(in oklab, var(--color-violet-500) 40%, transparent);
}
.hover\:bg-violet-500.hover\:bg-opacity-20:hover {
background-color: color-mix(in oklab, var(--color-violet-500) 20%, transparent);
}
.group:hover .bg-violet-500.group-hover\:bg-opacity-30 {
background-color: color-mix(in oklab, var(--color-violet-500) 30%, transparent);
}
.ring-violet-500.ring-opacity-20 {
--tw-ring-color: color-mix(in oklab, var(--color-violet-500) 20%, transparent);
}
.ring-violet-300.ring-opacity-40 {
--tw-ring-color: color-mix(in oklab, var(--color-violet-300) 40%, transparent);
}
.bg-purple-500.bg-opacity-10 {
background-color: color-mix(in oklab, var(--color-purple-500) 10%, transparent);
}
.bg-purple-500.bg-opacity-20 {
background-color: color-mix(in oklab, var(--color-purple-500) 20%, transparent);
}
.bg-purple-500.bg-opacity-40 {
background-color: color-mix(in oklab, var(--color-purple-500) 40%, transparent);
}
.hover\:bg-purple-500.hover\:bg-opacity-20:hover {
background-color: color-mix(in oklab, var(--color-purple-500) 20%, transparent);
}
.group:hover .bg-purple-500.group-hover\:bg-opacity-30 {
background-color: color-mix(in oklab, var(--color-purple-500) 30%, transparent);
}
.ring-purple-500.ring-opacity-20 {
--tw-ring-color: color-mix(in oklab, var(--color-purple-500) 20%, transparent);
}
.ring-purple-300.ring-opacity-40 {
--tw-ring-color: color-mix(in oklab, var(--color-purple-300) 40%, transparent);
}
.bg-fuchsia-500.bg-opacity-10 {
background-color: color-mix(in oklab, var(--color-fuchsia-500) 10%, transparent);
}
.bg-fuchsia-500.bg-opacity-20 {
background-color: color-mix(in oklab, var(--color-fuchsia-500) 20%, transparent);
}
.bg-fuchsia-500.bg-opacity-40 {
background-color: color-mix(in oklab, var(--color-fuchsia-500) 40%, transparent);
}
.hover\:bg-fuchsia-500.hover\:bg-opacity-20:hover {
background-color: color-mix(in oklab, var(--color-fuchsia-500) 20%, transparent);
}
.group:hover .bg-fuchsia-500.group-hover\:bg-opacity-30 {
background-color: color-mix(in oklab, var(--color-fuchsia-500) 30%, transparent);
}
.ring-fuchsia-500.ring-opacity-20 {
--tw-ring-color: color-mix(in oklab, var(--color-fuchsia-500) 20%, transparent);
}
.ring-fuchsia-300.ring-opacity-40 {
--tw-ring-color: color-mix(in oklab, var(--color-fuchsia-300) 40%, transparent);
}
.bg-pink-500.bg-opacity-10 {
background-color: color-mix(in oklab, var(--color-pink-500) 10%, transparent);
}
.bg-pink-500.bg-opacity-20 {
background-color: color-mix(in oklab, var(--color-pink-500) 20%, transparent);
}
.bg-pink-500.bg-opacity-40 {
background-color: color-mix(in oklab, var(--color-pink-500) 40%, transparent);
}
.hover\:bg-pink-500.hover\:bg-opacity-20:hover {
background-color: color-mix(in oklab, var(--color-pink-500) 20%, transparent);
}
.group:hover .bg-pink-500.group-hover\:bg-opacity-30 {
background-color: color-mix(in oklab, var(--color-pink-500) 30%, transparent);
}
.ring-pink-500.ring-opacity-20 {
--tw-ring-color: color-mix(in oklab, var(--color-pink-500) 20%, transparent);
}
.ring-pink-300.ring-opacity-40 {
--tw-ring-color: color-mix(in oklab, var(--color-pink-300) 40%, transparent);
}
.bg-rose-500.bg-opacity-10 {
background-color: color-mix(in oklab, var(--color-rose-500) 10%, transparent);
}
.bg-rose-500.bg-opacity-20 {
background-color: color-mix(in oklab, var(--color-rose-500) 20%, transparent);
}
.bg-rose-500.bg-opacity-40 {
background-color: color-mix(in oklab, var(--color-rose-500) 40%, transparent);
}
.hover\:bg-rose-500.hover\:bg-opacity-20:hover {
background-color: color-mix(in oklab, var(--color-rose-500) 20%, transparent);
}
.group:hover .bg-rose-500.group-hover\:bg-opacity-30 {
background-color: color-mix(in oklab, var(--color-rose-500) 30%, transparent);
}
.ring-rose-500.ring-opacity-20 {
--tw-ring-color: color-mix(in oklab, var(--color-rose-500) 20%, transparent);
}
.ring-rose-300.ring-opacity-40 {
--tw-ring-color: color-mix(in oklab, var(--color-rose-300) 40%, transparent);
}

View file

@ -0,0 +1,818 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
// eslint-disable-next-line no-restricted-imports -- the dashboard has no shadcn date-time picker; the PTU window fields need one
import { DatePicker } from "antd";
import { CircleHelp } from "lucide-react";
import type { Dayjs } from "dayjs";
import * as React from "react";
import { useForm, type Resolver } from "react-hook-form";
import { z } from "zod/v4";
import { TagsInput } from "@/app/(dashboard)/guardrails/_components/content_filter/TagsInput";
import { FormField } from "@/components/shared/form/FormField";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import CacheControlInjectionPoints, {
CACHE_CONTROL_LABEL,
CACHE_CONTROL_TOOLTIP,
type CacheControlInjectionPoint,
} from "./add_model/cache_control_settings";
import type { CredentialItem } from "./networking";
import NumericalInput from "./shared/numerical_input";
import type { Tag } from "./tag_management/types";
import VectorStoreSelector from "./vector_store_management/VectorStoreSelector";
import { formatPtuUtcDisplay, utcIsoToPickerValue } from "../utils/ptuDatetime";
import { isMaskedSecret } from "../utils/maskedSecretUtils";
import {
MAX_COST_PER_PTU_PER_HOUR,
MAX_PTU_COUNT,
PTU_COUNT_FIELD,
PTU_END_FIELD,
PTU_RATE_FIELD,
PTU_START_FIELD,
isFilledPtuValue,
isNonNegativePtuRate,
isPositiveWholePtuCount,
ptuWindowIsOrdered,
} from "../utils/ptuValidation";
interface PtuEditField {
name: string;
label: string;
input: "number" | "datetime";
placeholder?: string;
isCount?: boolean;
}
const PTU_EDIT_FIELDS: PtuEditField[] = [
{ name: PTU_COUNT_FIELD, label: "PTU Count", input: "number", placeholder: "e.g. 15", isCount: true },
{ name: PTU_RATE_FIELD, label: "Cost per PTU / Hour (USD)", input: "number", placeholder: "e.g. 2.00" },
{ name: PTU_START_FIELD, label: "PTU Effective From (UTC)", input: "datetime" },
{ name: PTU_END_FIELD, label: "PTU Effective To (UTC)", input: "datetime" },
];
export type TouchedPricingField = "input_cost" | "output_cost" | "cache_read_cost" | "cache_write_cost";
const PRICING_FIELDS: readonly TouchedPricingField[] = [
"input_cost",
"output_cost",
"cache_read_cost",
"cache_write_cost",
] as const;
const COST_SOURCES: Record<TouchedPricingField, { param: string; info: string }> = {
input_cost: { param: "input_cost_per_token", info: "input_cost_per_token" },
output_cost: { param: "output_cost_per_token", info: "output_cost_per_token" },
cache_read_cost: { param: "cache_read_input_token_cost", info: "cache_read_input_token_cost" },
cache_write_cost: { param: "cache_creation_input_token_cost", info: "cache_creation_input_token_cost" },
};
export interface ModelEditFormValues {
model_name?: string;
litellm_model_name?: string;
api_base?: string;
custom_llm_provider?: string;
organization?: string;
tpm?: string | number | null;
rpm?: string | number | null;
max_retries?: string | number | null;
timeout?: string | number | null;
stream_timeout?: string | number | null;
input_cost?: string | number | null;
output_cost?: string | number | null;
cache_read_cost?: string | number | null;
cache_write_cost?: string | number | null;
ptu_count?: string | number | null;
cost_per_ptu_per_hour?: string | number | null;
ptu_effective_from?: Dayjs | null;
ptu_effective_to?: Dayjs | null;
cache_control?: boolean;
cache_control_injection_points?: CacheControlInjectionPoint[];
model_access_group?: string[];
guardrails?: string[];
vector_store_ids?: string[];
tags?: string[];
health_check_model?: string | null;
litellm_credential_name?: string;
litellm_extra_params?: string;
model_info?: string;
}
type ModelEditFieldName = keyof ModelEditFormValues;
const scalar = z.union([z.string(), z.number(), z.null()]).optional();
const textish = z.string().optional();
const modelEditShape = {
model_name: textish,
litellm_model_name: textish,
api_base: textish,
custom_llm_provider: textish,
organization: textish,
tpm: scalar,
rpm: scalar,
max_retries: scalar,
timeout: scalar,
stream_timeout: scalar,
input_cost: scalar,
output_cost: scalar,
cache_read_cost: scalar,
cache_write_cost: scalar,
ptu_count: scalar,
cost_per_ptu_per_hour: scalar,
ptu_effective_from: z.custom<Dayjs | null>().nullish(),
ptu_effective_to: z.custom<Dayjs | null>().nullish(),
cache_control: z.boolean().optional(),
cache_control_injection_points: z.array(z.custom<CacheControlInjectionPoint>()).optional(),
model_access_group: z.array(z.string()).optional(),
guardrails: z.array(z.string()).optional(),
vector_store_ids: z.array(z.string()).optional(),
tags: z.array(z.string()).optional(),
health_check_model: z.string().nullish(),
litellm_credential_name: textish,
litellm_extra_params: textish,
model_info: textish,
};
const isJson = (value: string): boolean => {
try {
JSON.parse(value);
return true;
} catch {
return false;
}
};
const buildSchema = (ptuEnabled: boolean, isFieldTouched: (field: TouchedPricingField) => boolean) =>
z.object(modelEditShape).superRefine((values, ctx) => {
const reject = (path: ModelEditFieldName, message: string) =>
ctx.addIssue({ code: "custom", path: [path], message });
if (values.litellm_extra_params && !isJson(values.litellm_extra_params)) {
reject("litellm_extra_params", "Please enter valid JSON");
}
// antd validates only mounted fields, and the PTU block does not render when the flag is off.
if (!ptuEnabled) {
return;
}
if (!isPositiveWholePtuCount(values.ptu_count)) {
reject("ptu_count", `PTU Count must be a whole number between 1 and ${MAX_PTU_COUNT.toLocaleString()}`);
}
if (!isNonNegativePtuRate(values.cost_per_ptu_per_hour)) {
reject(
"cost_per_ptu_per_hour",
`Cost per PTU / Hour must be between 0 and ${MAX_COST_PER_PTU_PER_HOUR.toLocaleString()}`,
);
}
if (isFilledPtuValue(values.ptu_count) !== isFilledPtuValue(values.cost_per_ptu_per_hour)) {
const message = "PTU Count and Cost per PTU / Hour must be set together";
reject("ptu_count", message);
reject("cost_per_ptu_per_hour", message);
}
if (isFilledPtuValue(values.ptu_count) && !isFilledPtuValue(values.ptu_effective_from)) {
reject("ptu_effective_from", "PTU Effective From is required when PTU Count is set");
}
if (!ptuWindowIsOrdered(values.ptu_effective_from, values.ptu_effective_to)) {
const message = "PTU Effective To must be after PTU Effective From";
reject("ptu_effective_from", message);
reject("ptu_effective_to", message);
}
for (const field of PRICING_FIELDS) {
const value = values[field];
if (
isFieldTouched(field) &&
isFilledPtuValue(values.ptu_count) &&
isFilledPtuValue(value) &&
Number(value) !== 0
) {
reject(field, "A PTU deployment bills by reserved capacity, so this cost must be 0 or blank");
}
}
});
const perMillionTokens = (...rates: (number | null | undefined)[]): number | null => {
const rate = rates.find((candidate) => candidate != null);
return rate == null ? null : rate * 1_000_000;
};
export const toModelEditFormValues = (localModelData: any, isWildcardModel: boolean): ModelEditFormValues => ({
model_name: localModelData.model_name,
litellm_model_name: localModelData.litellm_model_name,
api_base: localModelData.litellm_params.api_base,
custom_llm_provider: localModelData.litellm_params.custom_llm_provider,
organization: localModelData.litellm_params.organization,
tpm: localModelData.litellm_params.tpm,
rpm: localModelData.litellm_params.rpm,
max_retries: localModelData.litellm_params.max_retries,
timeout: localModelData.litellm_params.timeout,
stream_timeout: localModelData.litellm_params.stream_timeout,
input_cost: perMillionTokens(
localModelData.litellm_params.input_cost_per_token,
localModelData.model_info?.input_cost_per_token,
),
output_cost: perMillionTokens(
localModelData.litellm_params?.output_cost_per_token,
localModelData.model_info?.output_cost_per_token,
),
ptu_count: localModelData.model_info?.ptu_count ?? null,
cost_per_ptu_per_hour: localModelData.model_info?.cost_per_ptu_per_hour ?? null,
ptu_effective_from: utcIsoToPickerValue(localModelData.model_info?.ptu_effective_from),
ptu_effective_to: utcIsoToPickerValue(localModelData.model_info?.ptu_effective_to),
cache_read_cost: perMillionTokens(
localModelData.litellm_params?.cache_read_input_token_cost,
localModelData.model_info?.cache_read_input_token_cost,
),
cache_write_cost: perMillionTokens(
localModelData.litellm_params?.cache_creation_input_token_cost,
localModelData.model_info?.cache_creation_input_token_cost,
),
cache_control: localModelData.litellm_params?.cache_control_injection_points ? true : false,
cache_control_injection_points: localModelData.litellm_params?.cache_control_injection_points || [],
model_access_group: Array.isArray(localModelData.model_info?.access_groups)
? localModelData.model_info.access_groups
: [],
guardrails: Array.isArray(localModelData.litellm_params?.guardrails) ? localModelData.litellm_params.guardrails : [],
vector_store_ids:
Array.isArray(localModelData.litellm_params?.vector_store_ids) &&
localModelData.litellm_params.vector_store_ids.length > 0
? localModelData.litellm_params.vector_store_ids
: undefined,
tags: Array.isArray(localModelData.litellm_params?.tags) ? localModelData.litellm_params.tags : [],
// antd never mounted this field for a non-wildcard model, so the key must be absent, not null.
...(isWildcardModel ? { health_check_model: localModelData.model_info?.health_check_model } : {}),
litellm_credential_name: localModelData.litellm_params?.litellm_credential_name || "",
litellm_extra_params: JSON.stringify(
Object.fromEntries(
Object.entries(localModelData.litellm_params || {}).filter(
([key, value]) => key !== "litellm_credential_name" && !isMaskedSecret(value),
),
),
null,
2,
),
});
const displayCost = (localModelData: any, field: TouchedPricingField): string => {
const { param, info } = COST_SOURCES[field];
const rate = localModelData?.litellm_params?.[param] ?? localModelData?.model_info?.[info];
return rate != null ? (Number(rate) * 1_000_000).toFixed(4) : "Not Set";
};
interface ModelInfoEditFormProps {
localModelData: any;
modelData: { model_info: { team_id?: string | null } & Record<string, unknown> };
accessToken: string | null;
isEditing: boolean;
isSaving: boolean;
isWildcardModel: boolean;
ptuCostAttributionEnabled: boolean;
showCacheControl: boolean;
setShowCacheControl: (checked: boolean) => void;
onCancel: () => void;
onSubmit: (values: ModelEditFormValues, isFieldTouched: (field: TouchedPricingField) => boolean) => Promise<void>;
modelAccessGroups: string[] | null;
guardrailsList: string[];
tagsList: Record<string, Tag>;
credentialsList: CredentialItem[];
healthCheckModelOptions: { value: string; label: string }[];
}
const Display: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div className="mt-1 rounded-sm bg-muted p-2">{children}</div>
);
const FieldLabel: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<p className="text-sm font-medium text-foreground">{children}</p>
);
const Hint: React.FC<{ text: string }> = ({ text }) => (
<Tooltip>
<TooltipTrigger
render={<CircleHelp className="ml-1 inline size-3.5 shrink-0 cursor-help text-muted-foreground" />}
/>
<TooltipContent className="max-w-xs">{text}</TooltipContent>
</Tooltip>
);
const DocsHint: React.FC<{ text: string; href: string }> = ({ text, href }) => (
<a href={href} target="_blank" rel="noopener noreferrer" onClick={(event) => event.stopPropagation()}>
<Hint text={text} />
</a>
);
const ChipList: React.FC<{ values: unknown; emptyLabel: string }> = ({ values, emptyLabel }) => {
if (!values) {
return <>Not Set</>;
}
if (!Array.isArray(values)) {
return <>{String(values)}</>;
}
if (values.length === 0) {
return <>{emptyLabel}</>;
}
return (
<div className="flex flex-wrap gap-1">
{values.map((entry: string, index: number) => (
<Badge key={index} variant="secondary">
{entry}
</Badge>
))}
</div>
);
};
const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
localModelData,
modelData,
accessToken,
isEditing,
isSaving,
isWildcardModel,
ptuCostAttributionEnabled,
showCacheControl,
setShowCacheControl,
onCancel,
onSubmit,
modelAccessGroups,
guardrailsList,
tagsList,
credentialsList,
healthCheckModelOptions,
}) => {
// Neither RHF's blur-based touchedFields nor its resettable dirtyFields matches antd's touched-on-change.
const touchedRef = React.useRef<ReadonlySet<string>>(new Set<string>());
const isFieldTouched = React.useCallback((field: TouchedPricingField) => touchedRef.current.has(field), []);
const markTouched = (field: string) => {
touchedRef.current = new Set([...touchedRef.current, field]);
};
// react-hook-form refreshes control._options every render, so this rebuild is what the next submit runs.
const resolver: Resolver<ModelEditFormValues> = (values, context, options) =>
zodResolver(buildSchema(ptuCostAttributionEnabled, isFieldTouched))(values, context, options);
const form = useForm<ModelEditFormValues>({
resolver,
defaultValues: toModelEditFormValues(localModelData, isWildcardModel),
});
const submit = (event: React.FormEvent<HTMLFormElement>) =>
form.handleSubmit(async (values) => {
await onSubmit(values, isFieldTouched);
})(event);
const cancel = () => {
form.reset(toModelEditFormValues(localModelData, isWildcardModel));
touchedRef.current = new Set<string>();
onCancel();
};
const textField = (name: ModelEditFieldName, label: string, placeholder: string, stored: unknown) => (
<div>
<FieldLabel>{label}</FieldLabel>
{isEditing ? (
<FormField control={form.control} name={name}>
{({ value, ...control }) => <Input {...control} value={(value as string) ?? ""} placeholder={placeholder} />}
</FormField>
) : (
<Display>{(stored as string) || "Not Set"}</Display>
)}
</div>
);
const numberField = (name: ModelEditFieldName, label: string, placeholder: string, stored: unknown) => (
<div>
<FieldLabel>{label}</FieldLabel>
{isEditing ? (
<FormField control={form.control} name={name}>
{({ value, ...control }) => <NumericalInput {...control} value={value ?? ""} placeholder={placeholder} />}
</FormField>
) : (
<Display>{(stored as string) || "Not Set"}</Display>
)}
</div>
);
const pricingField = (name: TouchedPricingField, label: string, placeholder: string, description?: string) => (
<div>
<FieldLabel>{label}</FieldLabel>
{isEditing ? (
<FormField control={form.control} name={name} description={description}>
{({ value, onChange, ...control }) => (
<NumericalInput
{...control}
value={value ?? ""}
placeholder={placeholder}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
markTouched(name);
onChange(event);
}}
/>
)}
</FormField>
) : (
<Display>{displayCost(localModelData, name)}</Display>
)}
</div>
);
const tagsField = (
name: "model_access_group" | "guardrails" | "tags",
options: { value: string; label: string }[],
placeholder: string,
) => (
<FormField control={form.control} name={name}>
{({ id, value, onChange }) => (
<TagsInput
id={id}
value={(value as string[]) ?? []}
onValueChange={onChange}
options={options}
placeholder={placeholder}
tokenSeparators={[","]}
/>
)}
</FormField>
);
return (
<TooltipProvider>
<form onSubmit={submit}>
<div className="space-y-4">
<div className="space-y-4">
{textField("model_name", "Model Name", "Enter model name", localModelData.model_name)}
{textField(
"litellm_model_name",
"LiteLLM Model Name",
"Enter LiteLLM model name",
localModelData.litellm_model_name,
)}
{pricingField("input_cost", "Input Cost (per 1M tokens)", "Enter input cost")}
{pricingField("output_cost", "Output Cost (per 1M tokens)", "Enter output cost")}
{ptuCostAttributionEnabled &&
PTU_EDIT_FIELDS.map((ptuField) => (
<div key={ptuField.name}>
<FieldLabel>{ptuField.label}</FieldLabel>
{isEditing ? (
<FormField control={form.control} name={ptuField.name as ModelEditFieldName}>
{({ value, onChange, ...control }) =>
ptuField.input === "number" ? (
<NumericalInput
{...control}
onChange={onChange}
value={value ?? ""}
placeholder={ptuField.placeholder}
step={ptuField.isCount ? 1 : undefined}
min={ptuField.isCount ? 1 : 0}
/>
) : (
<DatePicker
showTime
style={{ width: "100%" }}
value={(value as Dayjs | null) ?? null}
onChange={onChange}
/>
)
}
</FormField>
) : (
<Display>
{(ptuField.input === "datetime"
? formatPtuUtcDisplay(localModelData?.model_info?.[ptuField.name])
: localModelData?.model_info?.[ptuField.name]) ?? "Not Set"}
</Display>
)}
</div>
))}
{pricingField(
"cache_read_cost",
"Cache Read Cost (per 1M tokens)",
"Defaults to Input Cost if blank",
"If left blank on save, defaults to Input Cost.",
)}
{pricingField(
"cache_write_cost",
"Cache Write Cost (per 1M tokens)",
"Defaults to Input Cost if blank",
"If left blank on save, defaults to Input Cost (backend falls back to input_cost_per_token).",
)}
{textField("api_base", "API Base", "Enter API base", localModelData.litellm_params?.api_base)}
{textField(
"custom_llm_provider",
"Custom LLM Provider",
"Enter custom LLM provider",
localModelData.litellm_params?.custom_llm_provider,
)}
{textField(
"organization",
"Organization",
"Enter organization",
localModelData.litellm_params?.organization,
)}
{numberField("tpm", "TPM (Tokens per Minute)", "Enter TPM", localModelData.litellm_params?.tpm)}
{numberField("rpm", "RPM (Requests per Minute)", "Enter RPM", localModelData.litellm_params?.rpm)}
{numberField("max_retries", "Max Retries", "Enter max retries", localModelData.litellm_params?.max_retries)}
{numberField("timeout", "Timeout (seconds)", "Enter timeout", localModelData.litellm_params?.timeout)}
{numberField(
"stream_timeout",
"Stream Timeout (seconds)",
"Enter stream timeout",
localModelData.litellm_params?.stream_timeout,
)}
<div>
<FieldLabel>Model Access Groups</FieldLabel>
{isEditing ? (
tagsField(
"model_access_group",
(modelAccessGroups ?? []).map((group) => ({ value: group, label: group })),
"Select existing groups or type to create new ones",
)
) : (
<Display>
<ChipList values={localModelData.model_info?.access_groups} emptyLabel="No groups assigned" />
</Display>
)}
</div>
<div>
<FieldLabel>
Guardrails
<DocsHint
text="Apply safety guardrails to this model to filter content or enforce policies"
href="https://docs.litellm.ai/docs/proxy/guardrails/quick_start"
/>
</FieldLabel>
{isEditing ? (
tagsField(
"guardrails",
guardrailsList.map((name) => ({ value: name, label: name })),
"Select existing guardrails or type to create new ones",
)
) : (
<Display>
<ChipList values={localModelData.litellm_params?.guardrails} emptyLabel="No guardrails assigned" />
</Display>
)}
</div>
<div>
<FieldLabel>
Attached Knowledge Bases (RAG)
<DocsHint
text="Vector stores used for RAG. Every request to this model will automatically retrieve context from these knowledge bases."
href="https://docs.litellm.ai/docs/completion/knowledgebase"
/>
</FieldLabel>
{isEditing ? (
<FormField control={form.control} name="vector_store_ids">
{({ value, onChange }) => (
<VectorStoreSelector
value={value as string[] | undefined}
onChange={onChange}
accessToken={accessToken || ""}
placeholder="Select knowledge bases (optional)"
/>
)}
</FormField>
) : (
<Display>
<ChipList
values={localModelData.litellm_params?.vector_store_ids}
emptyLabel="No knowledge bases attached"
/>
</Display>
)}
</div>
<div>
<FieldLabel>Tags</FieldLabel>
{isEditing ? (
tagsField(
"tags",
Object.values(tagsList).map((tag: Tag) => ({ value: tag.name, label: tag.name })),
"Select existing tags or type to create new ones",
)
) : (
<Display>
<ChipList values={localModelData.litellm_params?.tags} emptyLabel="No tags assigned" />
</Display>
)}
</div>
<div>
<FieldLabel>Existing Credentials</FieldLabel>
{isEditing ? (
<FormField control={form.control} name="litellm_credential_name">
{({ id, value, onChange, onBlur }) => {
const items = [
{ value: "", label: "None" },
...credentialsList.map((credential) => ({
value: credential.credential_name,
label: credential.credential_name,
})),
];
return (
<Select
items={items}
value={(value as string) ?? ""}
onValueChange={(selected: string | null) => onChange(selected ?? "")}
>
<SelectTrigger id={id} className="w-full" onBlur={onBlur}>
<SelectValue placeholder="Select or search for existing credentials" />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}}
</FormField>
) : (
<Display>{localModelData.litellm_params?.litellm_credential_name || "Manual"}</Display>
)}
</div>
{isWildcardModel && (
<div>
<FieldLabel>Health Check Model</FieldLabel>
{isEditing ? (
<FormField control={form.control} name="health_check_model">
{({ id, value, onChange, onBlur }) => (
<Select
items={healthCheckModelOptions}
value={(value as string | null) ?? null}
onValueChange={onChange}
>
<SelectTrigger id={id} className="w-full" onBlur={onBlur}>
<SelectValue placeholder="Select existing health check model" />
</SelectTrigger>
<SelectContent>
<SelectItem value={null}>None</SelectItem>
{healthCheckModelOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</FormField>
) : (
<Display>{localModelData.model_info?.health_check_model || "Not Set"}</Display>
)}
</div>
)}
{isEditing ? (
<>
<FormField
control={form.control}
name="cache_control"
label={
<>
{CACHE_CONTROL_LABEL}
<Hint text={CACHE_CONTROL_TOOLTIP} />
</>
}
orientation="horizontal"
>
{({ id, value, onChange, onBlur }) => (
<Switch
id={id}
onBlur={onBlur}
checked={Boolean(value)}
onCheckedChange={(checked: boolean) => {
onChange(checked);
setShowCacheControl(checked);
}}
/>
)}
</FormField>
{showCacheControl && (
<FormField control={form.control} name="cache_control_injection_points">
{({ value, onChange }) => (
<CacheControlInjectionPoints
value={(value as CacheControlInjectionPoint[]) ?? []}
onChange={onChange}
/>
)}
</FormField>
)}
</>
) : (
<div>
<FieldLabel>Cache Control</FieldLabel>
<Display>
{localModelData.litellm_params?.cache_control_injection_points ? (
<div>
<p>Enabled</p>
<div className="mt-2">
{localModelData.litellm_params.cache_control_injection_points.map((point: any, i: number) => (
<div key={i} className="mb-1 text-sm text-muted-foreground">
Location: {point.location},{point.role && <span> Role: {point.role}</span>}
{point.index !== undefined && <span> Index: {point.index}</span>}
</div>
))}
</div>
</div>
) : (
"Disabled"
)}
</Display>
</div>
)}
<div>
<FieldLabel>Model Info</FieldLabel>
{isEditing ? (
<FormField control={form.control} name="model_info">
{({ value, ...control }) => (
<Textarea
{...control}
rows={4}
placeholder={'{"gpt-4": 100, "claude-v1": 200}'}
defaultValue={JSON.stringify(modelData.model_info, null, 2)}
/>
)}
</FormField>
) : (
<Display>
<pre className="mt-1 overflow-auto rounded-sm bg-muted p-2 text-xs">
{JSON.stringify(localModelData.model_info, null, 2)}
</pre>
</Display>
)}
</div>
<div>
<FieldLabel>
LiteLLM Params
<DocsHint
text="Optional litellm params used for making a litellm.completion() call. Some params are automatically added by LiteLLM."
href="https://docs.litellm.ai/docs/completion/input"
/>
</FieldLabel>
{isEditing ? (
<FormField control={form.control} name="litellm_extra_params">
{({ value, ...control }) => (
<Textarea
{...control}
value={(value as string) ?? ""}
rows={4}
placeholder={'{\n "rpm": 100,\n "timeout": 0,\n "stream_timeout": 0\n}'}
/>
)}
</FormField>
) : (
<Display>
<pre className="mt-1 overflow-auto rounded-sm bg-muted p-2 text-xs">
{JSON.stringify(localModelData.litellm_params, null, 2)}
</pre>
</Display>
)}
</div>
<div>
<FieldLabel>Team ID</FieldLabel>
<Display>{modelData.model_info.team_id || "Not Set"}</Display>
</div>
</div>
{isEditing && (
<div className="mt-6 flex justify-end gap-2">
<Button type="submit" variant="secondary" onClick={cancel} disabled={isSaving}>
Cancel
</Button>
<Button type="submit" disabled={isSaving} aria-busy={isSaving}>
{isSaving && <UiLoadingSpinner className="size-4" />}
Save Changes
</Button>
</div>
)}
</div>
</form>
</TooltipProvider>
);
};
export default ModelInfoEditForm;

View file

@ -1,5 +1,5 @@
import { renderHook, screen, waitFor, renderWithProviders } from "../../../tests/test-utils";
import userEvent from "@testing-library/user-event";
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
import { Form } from "antd";
import type { UploadProps } from "antd/es/upload";
import { describe, expect, it, vi } from "vitest";
@ -310,4 +310,90 @@ describe("AddModelForm", () => {
expect((await screen.findAllByRole("button", { name: "Test Connect" })).length).toBeGreaterThan(0);
expect(await screen.findByRole("button", { name: "Add Model" })).toBeInTheDocument();
});
describe("cache control bindings reach the parent form store", () => {
const renderWithForm = async () => {
const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized"));
mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser("proxy_admin", "user-1", true));
const props = createTestProps();
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
renderWithProviders(<AddModelForm {...props} />);
await screen.findByText("Provider");
return {
user,
openCacheControl: async () => {
await user.click(await screen.findByText("Advanced Settings"));
await user.click(screen.getByLabelText("Cache Control Injection Points"));
await screen.findByText("Add Injection Point");
},
closeCacheControl: async () => {
await user.click(screen.getByLabelText("Cache Control Injection Points"));
await waitFor(() => expect(screen.queryByText("Add Injection Point")).not.toBeInTheDocument());
},
// AddModelPanel builds the wire payload from form.validateFields(), which reports exactly
// the mounted registered set. Reading the same instance the same way keeps this on the
// real payload path; a rejection still carries the same `values` object.
mountedValues: async (): Promise<Record<string, unknown>> => {
try {
return await props.form.validateFields();
} catch (error) {
return (error as { values: Record<string, unknown> }).values;
}
},
};
};
it("omits both cache control keys while the section is untouched", async () => {
const { mountedValues } = await renderWithForm();
const values = await mountedValues();
expect(values).not.toHaveProperty("cache_control_injection_points");
expect(values.cache_control).toBeUndefined();
});
it("sends the seeded injection point once the toggle is on", async () => {
const { openCacheControl, mountedValues } = await renderWithForm();
await openCacheControl();
const values = await mountedValues();
expect(values.cache_control).toBe(true);
expect(values.cache_control_injection_points).toEqual([{ location: "message" }]);
});
it("carries an edited role and keeps the index a string, as the antd control did", async () => {
const { user, openCacheControl, mountedValues } = await renderWithForm();
await openCacheControl();
await user.click(screen.getByText("Select a role"));
await user.click(await screen.findByText("System"));
await user.type(screen.getByPlaceholderText("Optional"), "3");
const values = await mountedValues();
expect(values.cache_control_injection_points).toEqual([{ location: "message", role: "system", index: "3" }]);
});
it("adds a second injection point row", async () => {
const { user, openCacheControl, mountedValues } = await renderWithForm();
await openCacheControl();
await user.click(screen.getByText("Add Injection Point"));
await waitFor(() => expect(screen.getAllByPlaceholderText("Optional")).toHaveLength(2));
await user.type(screen.getAllByPlaceholderText("Optional")[1], "7");
const values = await mountedValues();
expect(values.cache_control_injection_points).toEqual([
{ location: "message" },
{ location: "message", index: "7" },
]);
});
it("drops the injection points again when the toggle goes back off", async () => {
const { openCacheControl, closeCacheControl, mountedValues } = await renderWithForm();
await openCacheControl();
await closeCacheControl();
const values = await mountedValues();
expect(values.cache_control).toBe(false);
expect(values).not.toHaveProperty("cache_control_injection_points");
});
});
});

View file

@ -7,7 +7,11 @@ import { Row, Col, Typography } from "antd";
import TextArea from "antd/es/input/TextArea";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Team } from "../key_team_helpers/key_list";
import CacheControlSettings from "./cache_control_settings";
import CacheControlInjectionPoints, {
CACHE_CONTROL_LABEL,
CACHE_CONTROL_TOOLTIP,
NEW_CACHE_CONTROL_POINT,
} from "./cache_control_settings";
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
import { Tag } from "../tag_management/types";
import { formItemValidateJSON } from "../../utils/textUtils";
@ -332,11 +336,21 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
<Switch onChange={handlePassThroughChange} className="bg-gray-600" />
</Form.Item>
<CacheControlSettings
form={form}
showCacheControl={showCacheControl}
onCacheControlChange={handleCacheControlChange}
/>
<Form.Item
label={CACHE_CONTROL_LABEL}
name="cache_control"
valuePropName="checked"
className="mb-4"
tooltip={CACHE_CONTROL_TOOLTIP}
>
<Switch onChange={handleCacheControlChange} className="bg-gray-600" />
</Form.Item>
{showCacheControl && (
<Form.Item name="cache_control_injection_points" initialValue={[NEW_CACHE_CONTROL_POINT]} noStyle>
<CacheControlInjectionPoints />
</Form.Item>
)}
<Form.Item
label="LiteLLM Params"
name="litellm_extra_params"

View file

@ -1,155 +1,141 @@
import { Minus, Plus } from "lucide-react";
import React from "react";
import { Form, Switch, Select, Typography } from "antd";
import { PlusOutlined, MinusCircleOutlined } from "@ant-design/icons";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import NumericalInput from "../shared/numerical_input";
const { Text } = Typography;
export const CACHE_CONTROL_LABEL = "Cache Control Injection Points";
interface CacheControlInjectionPoint {
export const CACHE_CONTROL_TOOLTIP =
"Tell litellm where to inject cache control checkpoints. You can specify either by role (to apply to all messages of that role) or by specific message index.";
export const CACHE_CONTROL_DESCRIPTION =
"Providers like Anthropic, Bedrock API require users to specify where to inject cache control checkpoints, litellm can automatically add them for you as a cost saving feature.";
export type CacheControlRole = "user" | "system" | "assistant";
export interface CacheControlInjectionPoint {
location: "message";
role?: "user" | "system" | "assistant";
index?: number;
role?: CacheControlRole;
index?: string | number;
}
interface CacheControlSettingsProps {
form: any; // Form instance from parent
showCacheControl: boolean;
onCacheControlChange: (checked: boolean) => void;
export const NEW_CACHE_CONTROL_POINT: CacheControlInjectionPoint = { location: "message" };
const LOCATION_ITEMS = [{ value: "message", label: "Message" }] as const;
const ROLE_ITEMS = [
{ value: "user", label: "User" },
{ value: "system", label: "System" },
{ value: "assistant", label: "Assistant" },
] as const;
interface CacheControlInjectionPointsProps {
value?: CacheControlInjectionPoint[];
onChange?: (points: CacheControlInjectionPoint[]) => void;
}
const CacheControlSettings: React.FC<CacheControlSettingsProps> = ({
form,
showCacheControl,
onCacheControlChange,
}) => {
const updateCacheControlPoints = (injectionPoints: CacheControlInjectionPoint[]) => {
const currentParams = form.getFieldValue("litellm_extra_params");
try {
let paramsObj = currentParams ? JSON.parse(currentParams) : {};
if (injectionPoints.length > 0) {
paramsObj.cache_control_injection_points = injectionPoints;
} else {
delete paramsObj.cache_control_injection_points;
}
if (Object.keys(paramsObj).length > 0) {
form.setFieldValue("litellm_extra_params", JSON.stringify(paramsObj, null, 2));
} else {
form.setFieldValue("litellm_extra_params", "");
}
} catch (error) {
console.error("Error updating cache control points:", error);
}
};
/**
* Editor for `cache_control_injection_points`. It holds no form state of its own so that an antd
* `Form.Item` and a react-hook-form `FormField` can each host it while their pages migrate
* independently; both hand a child exactly `value` and `onChange`.
*/
const CacheControlInjectionPoints: React.FC<CacheControlInjectionPointsProps> = ({ value, onChange }) => {
const points = value ?? [];
const replaceAt = (index: number, point: CacheControlInjectionPoint) =>
onChange?.(points.map((existing, position) => (position === index ? point : existing)));
return (
<>
<Form.Item
label="Cache Control Injection Points"
name="cache_control"
valuePropName="checked"
className="mb-4"
tooltip="Tell litellm where to inject cache control checkpoints. You can specify either by role (to apply to all messages of that role) or by specific message index."
>
<Switch onChange={onCacheControlChange} className="bg-gray-600" />
</Form.Item>
<div className="ml-6 border-l-2 border-border pl-4">
<p className="mb-4 block text-sm text-muted-foreground">{CACHE_CONTROL_DESCRIPTION}</p>
{showCacheControl && (
<div className="ml-6 pl-4 border-l-2 border-gray-200">
<Text className="text-sm text-gray-500 block mb-4">
Providers like Anthropic, Bedrock API require users to specify where to inject cache control checkpoints,
litellm can automatically add them for you as a cost saving feature.
</Text>
<Form.List name="cache_control_injection_points" initialValue={[{ location: "message" }]}>
{(fields, { add, remove }) => (
<>
{fields.map((field, index) => (
<div key={field.key} className="flex items-center mb-4 gap-4">
<Form.Item
{...field}
label="Type"
name={[field.name, "location"]}
initialValue="message"
className="mb-0"
style={{ width: "180px" }}
>
<Select disabled options={[{ value: "message", label: "Message" }]} />
</Form.Item>
<Form.Item
{...field}
label="Role"
name={[field.name, "role"]}
className="mb-0"
style={{ width: "180px" }}
tooltip="LiteLLM will mark all messages of this role as cacheable"
>
<Select
placeholder="Select a role"
allowClear
options={[
{ value: "user", label: "User" },
{ value: "system", label: "System" },
{ value: "assistant", label: "Assistant" },
]}
onChange={() => {
const values = form.getFieldValue("cache_control_points");
updateCacheControlPoints(values);
}}
/>
</Form.Item>
<Form.Item
{...field}
label="Index"
name={[field.name, "index"]}
className="mb-0"
style={{ width: "180px" }}
tooltip="(Optional) If set litellm will mark the message at this index as cacheable"
>
<NumericalInput
type="number"
placeholder="Optional"
step={1}
onChange={() => {
const values = form.getFieldValue("cache_control_points");
updateCacheControlPoints(values);
}}
/>
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined
className="text-red-500 cursor-pointer text-lg ml-12"
onClick={() => {
remove(field.name);
setTimeout(() => {
const values = form.getFieldValue("cache_control_points");
updateCacheControlPoints(values);
}, 0);
}}
/>
)}
</div>
{points.map((point, index) => (
<div key={index} className="mb-4 flex items-end gap-4">
<div className="w-[180px] space-y-1">
<Label>Type</Label>
<Select items={LOCATION_ITEMS} value={point.location} disabled>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOCATION_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Form.Item>
<button
type="button"
className="flex items-center justify-center w-full border border-dashed border-gray-300 py-2 px-4 text-gray-600 hover:text-blue-600 hover:border-blue-300 transition-all rounded-sm"
onClick={() => add()}
>
<PlusOutlined className="mr-2" />
Add Injection Point
</button>
</Form.Item>
</>
)}
</Form.List>
<div className="w-[180px] space-y-1">
<Label>Role</Label>
<Select
items={ROLE_ITEMS}
value={point.role ?? null}
onValueChange={(selected) =>
replaceAt(index, { ...point, role: (selected as CacheControlRole | null) ?? undefined })
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem value={null}>None</SelectItem>
{ROLE_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-[180px] space-y-1">
<Label>Index</Label>
<NumericalInput
type="number"
placeholder="Optional"
step={1}
value={point.index ?? ""}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
replaceAt(index, {
...point,
index: event.target.value === "" ? undefined : event.target.value,
})
}
/>
</div>
{points.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`Remove injection point ${index + 1}`}
className="text-destructive"
onClick={() => onChange?.(points.filter((_, position) => position !== index))}
>
<Minus className="size-4" />
</Button>
)}
</div>
)}
</>
))}
<Button
type="button"
variant="outline"
className="w-full border-dashed"
onClick={() => onChange?.([...points, NEW_CACHE_CONTROL_POINT])}
>
<Plus className="mr-2 size-4" />
Add Injection Point
</Button>
</div>
);
};
export default CacheControlSettings;
export default CacheControlInjectionPoints;

View file

@ -1,384 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { CustomLegend, CustomTooltip } from "./chartUtils";
import type { ChartTooltipProps } from "@/components/shared/charts/chart_tooltip";
import { SpendMetrics } from "../UsagePage/types";
type TooltipPayload = NonNullable<ChartTooltipProps["payload"]>;
describe("CustomTooltip", () => {
const mockPayload = [
{
dataKey: "metrics.total_tokens",
value: 1000,
color: "blue",
payload: {
date: "2024-01-15",
metrics: {
total_tokens: 1000,
prompt_tokens: 600,
completion_tokens: 400,
spend: 0.05,
api_requests: 10,
successful_requests: 9,
failed_requests: 1,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
} as SpendMetrics,
},
},
];
it("should render", () => {
const props: ChartTooltipProps = {
active: true,
payload: mockPayload as unknown as TooltipPayload,
label: "2024-01-15",
};
render(<CustomTooltip {...props} />);
expect(screen.getByText("2024-01-15")).toBeInTheDocument();
});
it("should return null when not active", () => {
const props: ChartTooltipProps = {
active: false,
payload: mockPayload as unknown as TooltipPayload,
label: "2024-01-15",
};
const { container } = render(<CustomTooltip {...props} />);
expect(container).toBeEmptyDOMElement();
});
it("should return null when payload is empty", () => {
const props: ChartTooltipProps = {
active: true,
payload: [] as unknown as TooltipPayload,
label: "2024-01-15",
};
const { container } = render(<CustomTooltip {...props} />);
expect(container).toBeEmptyDOMElement();
});
it("should display formatted category names", () => {
const props: ChartTooltipProps = {
active: true,
payload: mockPayload as unknown as TooltipPayload,
label: "2024-01-15",
};
render(<CustomTooltip {...props} />);
expect(screen.getByText("Total Tokens")).toBeInTheDocument();
});
it("should format category names by removing metrics prefix and replacing underscores", () => {
const payloadWithUnderscores = [
{
dataKey: "metrics.prompt_tokens",
value: 600,
color: "green",
payload: {
date: "2024-01-15",
metrics: {
prompt_tokens: 600,
total_tokens: 1000,
completion_tokens: 400,
spend: 0.05,
api_requests: 10,
successful_requests: 9,
failed_requests: 1,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
} as SpendMetrics,
},
},
];
const props: ChartTooltipProps = {
active: true,
payload: payloadWithUnderscores as unknown as TooltipPayload,
label: "2024-01-15",
};
render(<CustomTooltip {...props} />);
expect(screen.getByText("Prompt Tokens")).toBeInTheDocument();
});
it("should format spend values with dollar sign and two decimal places", () => {
const spendPayload = [
{
dataKey: "metrics.spend",
value: 1234.567,
color: "red",
payload: {
date: "2024-01-15",
metrics: {
spend: 1234.567,
total_tokens: 1000,
prompt_tokens: 600,
completion_tokens: 400,
api_requests: 10,
successful_requests: 9,
failed_requests: 1,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
} as SpendMetrics,
},
},
];
const props: ChartTooltipProps = {
active: true,
payload: spendPayload as unknown as TooltipPayload,
label: "2024-01-15",
};
render(<CustomTooltip {...props} />);
expect(screen.getByText("$1,234.57")).toBeInTheDocument();
});
it("should format non-spend numeric values with locale string", () => {
const props: ChartTooltipProps = {
active: true,
payload: mockPayload as unknown as TooltipPayload,
label: "2024-01-15",
};
render(<CustomTooltip {...props} />);
expect(screen.getByText("1,000")).toBeInTheDocument();
});
it("should display N/A when value is undefined", () => {
const payloadWithUndefined = [
{
dataKey: "metrics.nonexistent",
value: undefined,
color: "blue",
payload: {
date: "2024-01-15",
metrics: {
total_tokens: 1000,
prompt_tokens: 600,
completion_tokens: 400,
spend: 0.05,
api_requests: 10,
successful_requests: 9,
failed_requests: 1,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
} as SpendMetrics,
},
},
];
const props: ChartTooltipProps = {
active: true,
payload: payloadWithUndefined as unknown as TooltipPayload,
label: "2024-01-15",
};
render(<CustomTooltip {...props} />);
expect(screen.getByText("N/A")).toBeInTheDocument();
});
it("should handle multiple payload items", () => {
const multiplePayload = [
{
dataKey: "metrics.total_tokens",
value: 1000,
color: "blue",
payload: {
date: "2024-01-15",
metrics: {
total_tokens: 1000,
prompt_tokens: 600,
completion_tokens: 400,
spend: 0.05,
api_requests: 10,
successful_requests: 9,
failed_requests: 1,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
} as SpendMetrics,
},
},
{
dataKey: "metrics.spend",
value: 0.05,
color: "green",
payload: {
date: "2024-01-15",
metrics: {
total_tokens: 1000,
prompt_tokens: 600,
completion_tokens: 400,
spend: 0.05,
api_requests: 10,
successful_requests: 9,
failed_requests: 1,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
} as SpendMetrics,
},
},
];
const props: ChartTooltipProps = {
active: true,
payload: multiplePayload as unknown as TooltipPayload,
label: "2024-01-15",
};
render(<CustomTooltip {...props} />);
expect(screen.getByText("Total Tokens")).toBeInTheDocument();
expect(screen.getByText("Spend")).toBeInTheDocument();
});
it("should convert color names to hex values", () => {
const props: ChartTooltipProps = {
active: true,
payload: mockPayload as unknown as TooltipPayload,
label: "2024-01-15",
};
const { container } = render(<CustomTooltip {...props} />);
const colorIndicator = container.querySelector('span[style*="background-color"]');
expect(colorIndicator).toHaveStyle({ backgroundColor: "#3b82f6" });
});
it("should use hex color directly when color is not a known color name", () => {
const payloadWithHexColor = [
{
dataKey: "metrics.total_tokens",
value: 1000,
color: "#ff0000",
payload: {
date: "2024-01-15",
metrics: {
total_tokens: 1000,
prompt_tokens: 600,
completion_tokens: 400,
spend: 0.05,
api_requests: 10,
successful_requests: 9,
failed_requests: 1,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
} as SpendMetrics,
},
},
];
const props: ChartTooltipProps = {
active: true,
payload: payloadWithHexColor as unknown as TooltipPayload,
label: "2024-01-15",
};
const { container } = render(<CustomTooltip {...props} />);
const colorIndicator = container.querySelector('span[style*="background-color"]');
expect(colorIndicator).toHaveStyle({ backgroundColor: "#ff0000" });
});
it("should skip items without dataKey", () => {
const payloadWithoutDataKey = [
{
dataKey: undefined,
value: 1000,
color: "blue",
payload: {
date: "2024-01-15",
metrics: {
total_tokens: 1000,
prompt_tokens: 600,
completion_tokens: 400,
spend: 0.05,
api_requests: 10,
successful_requests: 9,
failed_requests: 1,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
} as SpendMetrics,
},
},
];
const props: ChartTooltipProps = {
active: true,
payload: payloadWithoutDataKey as unknown as TooltipPayload,
label: "2024-01-15",
};
render(<CustomTooltip {...props} />);
expect(screen.queryByText("Total Tokens")).not.toBeInTheDocument();
});
it("should skip items without payload", () => {
const payloadWithoutPayload = [
{
dataKey: "metrics.total_tokens",
value: 1000,
color: "blue",
payload: undefined,
},
];
const props: ChartTooltipProps = {
active: true,
payload: payloadWithoutPayload as unknown as TooltipPayload,
label: "2024-01-15",
};
render(<CustomTooltip {...props} />);
expect(screen.queryByText("Total Tokens")).not.toBeInTheDocument();
});
});
describe("CustomLegend", () => {
it("should render", () => {
render(<CustomLegend categories={["metrics.total_tokens"]} colors={["blue"]} />);
expect(screen.getByText("Total Tokens")).toBeInTheDocument();
});
it("should display multiple categories", () => {
render(
<CustomLegend
categories={["metrics.total_tokens", "metrics.spend", "metrics.prompt_tokens"]}
colors={["blue", "green", "red"]}
/>,
);
expect(screen.getByText("Total Tokens")).toBeInTheDocument();
expect(screen.getByText("Spend")).toBeInTheDocument();
expect(screen.getByText("Prompt Tokens")).toBeInTheDocument();
});
it("should format category names by removing metrics prefix and replacing underscores", () => {
render(<CustomLegend categories={["metrics.api_requests"]} colors={["blue"]} />);
expect(screen.getByText("Api Requests")).toBeInTheDocument();
});
it("should capitalize first letter of each word", () => {
render(<CustomLegend categories={["metrics.successful_requests"]} colors={["green"]} />);
expect(screen.getByText("Successful Requests")).toBeInTheDocument();
});
it("should convert color names to hex values", () => {
const { container } = render(<CustomLegend categories={["metrics.total_tokens"]} colors={["cyan"]} />);
const colorIndicator = container.querySelector('span[style*="background-color"]');
expect(colorIndicator).toHaveStyle({ backgroundColor: "#06b6d4" });
});
it("should use hex color directly when color is not a known color name", () => {
const { container } = render(<CustomLegend categories={["metrics.total_tokens"]} colors={["#ff00ff"]} />);
const colorIndicator = container.querySelector('span[style*="background-color"]');
expect(colorIndicator).toHaveStyle({ backgroundColor: "#ff00ff" });
});
it("should handle all supported color names", () => {
const colors = ["blue", "cyan", "indigo", "green", "red", "purple", "emerald"];
const categories = colors.map((_, idx) => `metrics.category_${idx}`);
render(<CustomLegend categories={categories} colors={colors} />);
expect(screen.getByText("Category 0")).toBeInTheDocument();
});
it("should match categories and colors by index", () => {
render(
<CustomLegend
categories={["metrics.first", "metrics.second", "metrics.third"]}
colors={["blue", "green", "red"]}
/>,
);
const { container } = render(
<CustomLegend
categories={["metrics.first", "metrics.second", "metrics.third"]}
colors={["blue", "green", "red"]}
/>,
);
const colorIndicators = container.querySelectorAll('span[style*="background-color"]');
expect(colorIndicators[0]).toHaveStyle({ backgroundColor: "#3b82f6" });
expect(colorIndicators[1]).toHaveStyle({ backgroundColor: "#22c55e" });
expect(colorIndicators[2]).toHaveStyle({ backgroundColor: "#ef4444" });
});
});

View file

@ -1,104 +0,0 @@
import type { ChartTooltipProps } from "@/components/shared/charts/chart_tooltip";
import { SpendMetrics } from "../UsagePage/types";
interface ChartDataPoint {
date: string;
metrics: SpendMetrics;
}
const colorNameToHex: { [key: string]: string } = {
blue: "#3b82f6",
cyan: "#06b6d4",
indigo: "#6366f1",
green: "#22c55e",
red: "#ef4444",
purple: "#8b5cf6",
emerald: "#37bc7d",
};
export const CustomTooltip = ({ active, payload, label }: ChartTooltipProps) => {
if (active && payload && payload.length) {
const formatCategoryName = (name: string): string => {
return name
.replace("metrics.", "")
.replace(/_/g, " ")
.split(" ")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
};
const getRawValue = (dataPoint: ChartDataPoint, key: string): number | undefined => {
// key is like "metrics.total_tokens"
const metricKey = key.substring(key.indexOf(".") + 1) as keyof SpendMetrics;
if (dataPoint.metrics && metricKey in dataPoint.metrics) {
return dataPoint.metrics[metricKey];
}
return undefined;
};
return (
<div className="w-56 rounded-tremor-default border border-tremor-border bg-tremor-background p-2 text-tremor-default shadow-tremor-dropdown">
<p className="text-tremor-content-strong">{label}</p>
{payload.map((item) => {
const dataKey = item.dataKey?.toString();
if (!dataKey || !item.payload) return null;
const rawValue = getRawValue(item.payload, dataKey);
const isSpend = dataKey.includes("spend");
const formattedValue =
rawValue !== undefined
? isSpend
? `$${rawValue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
: rawValue.toLocaleString()
: "N/A";
const colorName = item.color as keyof typeof colorNameToHex;
const hexColor = colorNameToHex[colorName] || item.color;
return (
<div key={dataKey} className="flex items-center justify-between space-x-4">
<div className="flex items-center space-x-2">
<span
className={`h-2 w-2 shrink-0 rounded-full ring-2 ring-white drop-shadow-md`}
style={{ backgroundColor: hexColor }}
/>
<p className="font-medium text-tremor-content dark:text-dark-tremor-content">
{formatCategoryName(dataKey)}
</p>
</div>
<p className="font-medium text-tremor-content-emphasis dark:text-dark-tremor-content-emphasis">
{formattedValue}
</p>
</div>
);
})}
</div>
);
}
return null;
};
export const CustomLegend = ({ categories, colors }: { categories: string[]; colors: string[] }) => {
const formatCategoryName = (name: string): string => {
return name
.replace("metrics.", "")
.replace(/_/g, " ")
.split(" ")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
};
return (
<div className="flex items-center justify-end space-x-4">
{categories.map((category, idx) => {
const colorName = colors[idx] as keyof typeof colorNameToHex;
const hexColor = colorNameToHex[colorName] || colors[idx];
return (
<div key={category} className="flex items-center space-x-2">
<span className={`h-2 w-2 shrink-0 rounded-full ring-4 ring-white`} style={{ backgroundColor: hexColor }} />
<p className="text-sm text-tremor-content dark:text-dark-tremor-content">{formatCategoryName(category)}</p>
</div>
);
})}
</div>
);
};

View file

@ -26,6 +26,7 @@ vi.mock("./networking", () => ({
modelPatchUpdateCall: vi.fn(),
modelDeleteCall: vi.fn(),
credentialCreateCall: vi.fn(),
vectorStoreListCall: vi.fn(),
}));
const mockUseModelsInfo = vi.fn();
@ -57,6 +58,7 @@ const mockTestModelGroupConnection = vi.mocked(networking.testModelGroupConnecti
const mockModelPatchUpdateCall = vi.mocked(networking.modelPatchUpdateCall);
const mockModelDeleteCall = vi.mocked(networking.modelDeleteCall);
const mockCredentialCreateCall = vi.mocked(networking.credentialCreateCall);
const mockVectorStoreListCall = vi.mocked(networking.vectorStoreListCall);
describe("ModelInfoView", () => {
let queryClient: QueryClient;
@ -166,6 +168,12 @@ describe("ModelInfoView", () => {
status: "success",
});
mockVectorStoreListCall.mockResolvedValue({
data: [
{ vector_store_id: "vs-alpha", vector_store_name: "Alpha" },
{ vector_store_id: "vs-beta", vector_store_name: "Beta" },
],
} as never);
mockModelPatchUpdateCall.mockResolvedValue({});
mockModelDeleteCall.mockResolvedValue({});
mockCredentialCreateCall.mockResolvedValue({});
@ -768,6 +776,99 @@ describe("ModelInfoView", () => {
});
});
const enterPtuEdit = async (user: ReturnType<typeof userEvent.setup>) => {
mockUsePtuCostAttributionEnabled.mockReturnValue(true);
renderWithPtuModel();
expect(await screen.findByRole("button", { name: /edit settings/i })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /edit settings/i }));
expect(await screen.findByPlaceholderText("e.g. 15")).toBeInTheDocument();
};
const expectBlocked = async (user: ReturnType<typeof userEvent.setup>, message: RegExp) => {
await user.click(screen.getByRole("button", { name: /save changes/i }));
expect(await screen.findAllByText(message)).not.toHaveLength(0);
expect(mockModelPatchUpdateCall).not.toHaveBeenCalled();
};
it("skips PTU validation entirely when the feature is disabled, so a half-set stored record still saves", async () => {
mockUsePtuCostAttributionEnabled.mockReturnValue(false);
const halfSetPtuModel = {
...ptuModelData,
model_info: { ...ptuModelData.model_info, cost_per_ptu_per_hour: null, ptu_effective_from: null },
};
mockUseModelsInfo.mockReturnValue({ data: { data: [halfSetPtuModel] }, isLoading: false, error: null });
mockModelInfoV1Call.mockResolvedValue({ data: [halfSetPtuModel] });
const user = userEvent.setup();
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
expect(await screen.findByRole("button", { name: /edit settings/i })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /edit settings/i }));
expect(await screen.findByRole("button", { name: /save changes/i })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(mockModelPatchUpdateCall).toHaveBeenCalled());
expect(screen.queryByText(/must be set together/i)).not.toBeInTheDocument();
});
it("blocks a PTU count above the backend ceiling", async () => {
const user = userEvent.setup();
await enterPtuEdit(user);
await user.clear(screen.getByPlaceholderText("e.g. 15"));
await user.type(screen.getByPlaceholderText("e.g. 15"), "1000001");
await expectBlocked(user, /PTU Count must be a whole number between 1 and 1,000,000/i);
});
it("blocks a cost per PTU hour above the backend ceiling", async () => {
const user = userEvent.setup();
await enterPtuEdit(user);
await user.clear(screen.getByPlaceholderText("e.g. 2.00"));
await user.type(screen.getByPlaceholderText("e.g. 2.00"), "2000000");
await expectBlocked(user, /Cost per PTU \/ Hour must be between 0 and 1,000,000/i);
});
it("blocks a half-set PTU count and rate pair", async () => {
const user = userEvent.setup();
await enterPtuEdit(user);
await user.clear(screen.getByPlaceholderText("e.g. 2.00"));
await expectBlocked(user, /PTU Count and Cost per PTU \/ Hour must be set together/i);
});
it("blocks PTU config with no effective start", async () => {
mockUsePtuCostAttributionEnabled.mockReturnValue(true);
const undatedPtuModel = {
...ptuModelData,
model_info: { ...ptuModelData.model_info, ptu_effective_from: null, ptu_effective_to: null },
};
mockUseModelsInfo.mockReturnValue({ data: { data: [undatedPtuModel] }, isLoading: false, error: null });
mockModelInfoV1Call.mockResolvedValue({ data: [undatedPtuModel] });
const user = userEvent.setup();
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
expect(await screen.findByRole("button", { name: /edit settings/i })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /edit settings/i }));
expect(await screen.findByPlaceholderText("e.g. 15")).toBeInTheDocument();
await expectBlocked(user, /PTU Effective From is required when PTU Count is set/i);
});
it("blocks a PTU window whose end is not after its start", async () => {
const user = userEvent.setup();
await enterPtuEdit(user);
const to = screen.getAllByPlaceholderText("Select date")[1];
await user.clear(to);
await user.type(to, "2026-06-01 00:00:00");
await user.tab();
await expectBlocked(user, /PTU Effective To must be after PTU Effective From/i);
});
it("sends the PTU fields on save when enabled", async () => {
mockUsePtuCostAttributionEnabled.mockReturnValue(true);
const user = userEvent.setup();
@ -791,6 +892,64 @@ describe("ModelInfoView", () => {
expect(modelInfo.ptu_count).toBe(15);
expect(modelInfo.cost_per_ptu_per_hour).toBe(2);
});
it("routes each edited PTU field into its own model_info key", async () => {
mockUsePtuCostAttributionEnabled.mockReturnValue(true);
const user = userEvent.setup();
renderWithPtuModel();
expect(await screen.findByRole("button", { name: /edit settings/i })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /edit settings/i }));
expect(await screen.findByRole("button", { name: /save changes/i })).toBeInTheDocument();
await user.clear(screen.getByPlaceholderText("e.g. 15"));
await user.type(screen.getByPlaceholderText("e.g. 15"), "20");
await user.clear(screen.getByPlaceholderText("e.g. 2.00"));
await user.type(screen.getByPlaceholderText("e.g. 2.00"), "3.5");
const dates = () => screen.getAllByPlaceholderText("Select date");
expect(dates()[0]).toHaveValue("2026-07-01 00:00:00");
expect(dates()[1]).toHaveValue("2026-08-01 00:00:00");
const setDate = async (index: number, value: string) => {
await user.clear(dates()[index]);
await user.type(dates()[index], value);
await user.tab();
};
await setDate(1, "2026-10-03 02:00:00");
await setDate(0, "2026-09-02 01:00:00");
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(mockModelPatchUpdateCall).toHaveBeenCalled());
const modelInfo = mockModelPatchUpdateCall.mock.calls[0][1].model_info;
expect(modelInfo.ptu_count).toBe(20);
expect(modelInfo.cost_per_ptu_per_hour).toBe(3.5);
expect(modelInfo.ptu_effective_from).toBe("2026-09-02T01:00:00.000Z");
expect(modelInfo.ptu_effective_to).toBe("2026-10-03T02:00:00.000Z");
});
});
it("blocks the save when the LiteLLM Params box does not hold valid JSON", async () => {
const user = userEvent.setup();
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
expect(await screen.findByRole("button", { name: /edit settings/i })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /edit settings/i }));
const extraParams = screen
.getAllByRole("textbox")
.find(
(input) =>
input.tagName === "TEXTAREA" && (input as HTMLTextAreaElement).value.includes('"custom_llm_provider"'),
) as HTMLTextAreaElement;
await user.clear(extraParams);
await user.paste("{not json");
await user.click(screen.getByRole("button", { name: /save changes/i }));
expect(await screen.findByText("Please enter valid JSON")).toBeInTheDocument();
expect(mockModelPatchUpdateCall).not.toHaveBeenCalled();
});
it("should not include input_cost_per_token or output_cost_per_token in update payload when user does not touch cost fields", async () => {
@ -1257,4 +1416,312 @@ describe("ModelInfoView", () => {
expect(await screen.findByTestId("test-connection-button")).toBeInTheDocument();
});
});
describe("payload parity pins", () => {
const enterEditMode = async (user: ReturnType<typeof userEvent.setup>) => {
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
expect(await screen.findByRole("button", { name: /edit settings/i })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /edit settings/i }));
expect(await screen.findByRole("button", { name: /save changes/i })).toBeInTheDocument();
};
const save = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(mockModelPatchUpdateCall).toHaveBeenCalled());
return mockModelPatchUpdateCall.mock.calls[0][1] as {
model_name: string;
litellm_params: Record<string, unknown>;
model_info: Record<string, unknown>;
};
};
it("sends the whole edit payload for an untouched save", async () => {
const user = userEvent.setup();
await enterEditMode(user);
const payload = await save(user);
expect(payload).toEqual({
model_name: "GPT-4",
litellm_params: {
model: "gpt-4",
api_base: "https://api.openai.com/v1",
custom_llm_provider: "openai",
litellm_credential_name: "selected-credential",
tags: [],
guardrails: [],
},
model_info: {
id: "123",
created_by: "123",
created_at: "2024-01-01T00:00:00Z",
db_model: true,
input_cost_per_token: 0.00003,
output_cost_per_token: 0.00006,
access_groups: [],
},
});
});
it("omits health_check_model for a model that is not a wildcard, whose field never renders", async () => {
const user = userEvent.setup();
await enterEditMode(user);
const payload = await save(user);
expect(payload.model_info).not.toHaveProperty("health_check_model");
});
it("routes each edited field into its own payload key", async () => {
const user = userEvent.setup();
await enterEditMode(user);
await user.clear(screen.getByPlaceholderText("Enter model name"));
await user.type(screen.getByPlaceholderText("Enter model name"), "renamed-model");
await user.clear(screen.getByPlaceholderText("Enter LiteLLM model name"));
await user.type(screen.getByPlaceholderText("Enter LiteLLM model name"), "gpt-4o");
await user.clear(screen.getByPlaceholderText("Enter API base"));
await user.type(screen.getByPlaceholderText("Enter API base"), "https://example.test/v1");
await user.clear(screen.getByPlaceholderText("Enter custom LLM provider"));
await user.type(screen.getByPlaceholderText("Enter custom LLM provider"), "azure");
await user.type(screen.getByPlaceholderText("Enter organization"), "org-9");
await user.type(screen.getByPlaceholderText("Enter TPM"), "111");
await user.type(screen.getByPlaceholderText("Enter RPM"), "222");
await user.type(screen.getByPlaceholderText("Enter max retries"), "4");
await user.type(screen.getByPlaceholderText("Enter timeout"), "33");
await user.type(screen.getByPlaceholderText("Enter stream timeout"), "44");
const payload = await save(user);
expect(payload.model_name).toBe("renamed-model");
expect(payload.litellm_params).toMatchObject({
model: "gpt-4o",
api_base: "https://example.test/v1",
custom_llm_provider: "azure",
organization: "org-9",
tpm: "111",
rpm: "222",
max_retries: "4",
timeout: "33",
stream_timeout: "44",
});
});
it("routes each edited pricing field into its own payload key", async () => {
const user = userEvent.setup();
await enterEditMode(user);
await user.clear(screen.getByPlaceholderText("Enter output cost"));
await user.type(screen.getByPlaceholderText("Enter output cost"), "12");
const [cacheRead, cacheWrite] = screen.getAllByPlaceholderText("Defaults to Input Cost if blank");
await user.type(cacheRead, "5");
await user.type(cacheWrite, "9");
const payload = await save(user);
expect(payload.litellm_params).toMatchObject({
output_cost_per_token: 0.000012,
cache_read_input_token_cost: 0.000005,
cache_creation_input_token_cost: 0.000009,
});
});
const addTag = async (user: ReturnType<typeof userEvent.setup>, placeholder: string, tag: string) => {
const input = screen.getByPlaceholderText(placeholder);
await user.type(input, tag);
await user.keyboard("{Enter}");
};
it("routes each typed collection field into its own payload key", async () => {
const user = userEvent.setup();
await enterEditMode(user);
await addTag(user, "Select existing groups or type to create new ones", "beta-testers");
await addTag(user, "Select existing guardrails or type to create new ones", "content_filter");
await addTag(user, "Select existing tags or type to create new ones", "production_tag");
const payload = await save(user);
expect(payload.model_info.access_groups).toEqual(["beta-testers"]);
expect(payload.litellm_params.guardrails).toEqual(["content_filter"]);
expect(payload.litellm_params.tags).toEqual(["production_tag"]);
});
it("sends the edited model info JSON", async () => {
const user = userEvent.setup();
await enterEditMode(user);
const modelInfo = screen.getByPlaceholderText('{"gpt-4": 100, "claude-v1": 200}');
await user.clear(modelInfo);
await user.paste('{"id":"123","team_id":"team-7"}');
const payload = await save(user);
expect(payload.model_info).toMatchObject({ team_id: "team-7" });
});
it("sends the edited LiteLLM extra params", async () => {
const user = userEvent.setup();
await enterEditMode(user);
const extraParams = screen
.getAllByRole("textbox")
.find(
(input) =>
input.tagName === "TEXTAREA" && (input as HTMLTextAreaElement).value.includes('"custom_llm_provider"'),
) as HTMLTextAreaElement;
await user.clear(extraParams);
await user.paste('{"drop_params":true}');
const payload = await save(user);
expect(payload.litellm_params.drop_params).toBe(true);
});
it("sends the credential picked in the selector", async () => {
mockCredentialListCall.mockResolvedValue({
credentials: [
{ credential_name: "selected-credential", credential_values: {}, credential_info: {} },
{ credential_name: "other-credential", credential_values: {}, credential_info: {} },
],
} as never);
const user = userEvent.setup();
await enterEditMode(user);
await user.click(await screen.findByText("selected-credential"));
await user.click(await screen.findByText("other-credential"));
const payload = await save(user);
expect(payload.litellm_params.litellm_credential_name).toBe("other-credential");
});
it("sends the vector stores picked in the knowledge base selector", async () => {
const user = userEvent.setup();
await enterEditMode(user);
await user.click(screen.getByPlaceholderText("Select knowledge bases (optional)"));
await user.click(await screen.findByText("Beta (vs-beta)"));
await user.keyboard("{Escape}");
const payload = await save(user);
expect(payload.litellm_params.vector_store_ids).toEqual(["vs-beta"]);
});
it("sends the health check model picked for a wildcard deployment", async () => {
const wildcard = {
...defaultModelData,
litellm_params: { ...defaultModelData.litellm_params, model: "openai/gpt-4*" },
};
mockUseModelsInfo.mockReturnValue({ data: { data: [wildcard] }, isLoading: false, error: null });
mockModelInfoV1Call.mockResolvedValue({ data: [wildcard] });
mockUseModelHub.mockReturnValue({
data: { data: [{ model_group: "openai/gpt-4o", providers: ["openai"] }] },
isLoading: false,
error: null,
});
const user = userEvent.setup();
await enterEditMode(user);
await user.click(screen.getByText("Select existing health check model"));
await user.click(await screen.findByText("openai/gpt-4o"));
const payload = await save(user);
expect(payload.model_info.health_check_model).toBe("openai/gpt-4o");
});
it("keeps a pricing field in the payload after the operator types a value and restores the original", async () => {
// antd marks a field touched on change and never clears it, so retyping the seeded value
// still ships the key. RHF's dirtyFields resets on a value returning to its default, which
// would silently drop input_cost_per_token here.
const user = userEvent.setup();
await enterEditMode(user);
const inputCost = screen.getByPlaceholderText("Enter input cost") as HTMLInputElement;
const seeded = inputCost.value;
expect(seeded).toBe("30");
await user.clear(inputCost);
await user.type(inputCost, "7");
await user.clear(inputCost);
await user.type(inputCost, seeded);
const payload = await save(user);
expect(payload.litellm_params.input_cost_per_token).toBe(0.00003);
expect(payload.litellm_params.cache_read_input_token_cost).toBe(0.00003);
});
it("clears a pricing override with an explicit null once the field is emptied", async () => {
const user = userEvent.setup();
await enterEditMode(user);
await user.clear(screen.getByPlaceholderText("Enter input cost"));
const payload = await save(user);
expect(payload.litellm_params.input_cost_per_token).toBeNull();
expect(payload.litellm_params).not.toHaveProperty("cache_read_input_token_cost");
});
describe("cache control injection points", () => {
const withCachePoints = (points: unknown) => {
const data = {
...defaultModelData,
litellm_params: { ...defaultModelData.litellm_params, cache_control_injection_points: points },
};
mockUseModelsInfo.mockReturnValue({ data: { data: [data] }, isLoading: false, error: null });
mockModelInfoV1Call.mockResolvedValue({ data: [data] });
};
it("omits the key when the deployment has none and the operator leaves the toggle alone", async () => {
const user = userEvent.setup();
await enterEditMode(user);
const payload = await save(user);
expect(payload.litellm_params).not.toHaveProperty("cache_control_injection_points");
});
it("hides the injection point rows until the toggle is on", async () => {
const user = userEvent.setup();
await enterEditMode(user);
expect(screen.queryByRole("button", { name: /add injection point/i })).not.toBeInTheDocument();
await user.click(screen.getByRole("switch"));
expect(await screen.findByRole("button", { name: /add injection point/i })).toBeInTheDocument();
});
it("round-trips the stored injection points on an untouched save", async () => {
withCachePoints([{ location: "message", role: "user" }]);
const user = userEvent.setup();
await enterEditMode(user);
const payload = await save(user);
expect(payload.litellm_params.cache_control_injection_points).toEqual([{ location: "message", role: "user" }]);
});
it("drops the stored injection points when the operator turns the toggle off", async () => {
withCachePoints([{ location: "message", role: "user" }]);
const user = userEvent.setup();
await enterEditMode(user);
await user.click(screen.getByRole("switch"));
const payload = await save(user);
expect(payload.litellm_params).not.toHaveProperty("cache_control_injection_points");
});
it("adds a typed index as a string, matching what the deployment already stores", async () => {
withCachePoints([{ location: "message" }]);
const user = userEvent.setup();
await enterEditMode(user);
await user.type(screen.getByPlaceholderText("Optional"), "2");
const payload = await save(user);
expect(payload.litellm_params.cache_control_injection_points).toEqual([{ location: "message", index: "2" }]);
});
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -9,9 +9,8 @@ const alertVariants = cva({
variant: {
default: "bg-card text-card-foreground",
destructive: "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
info: "border-blue-200 bg-blue-50 text-blue-900 *:data-[slot=alert-description]:text-blue-800 *:[svg]:text-blue-600",
warning:
"border-amber-200 bg-amber-50 text-amber-900 *:data-[slot=alert-description]:text-amber-800 *:[svg]:text-amber-600",
info: "border-info/20 bg-info/5 text-info *:[svg]:text-current",
warning: "border-warning/20 bg-warning/5 text-warning *:[svg]:text-current",
error: "border-red-200 bg-red-50 text-red-900 *:data-[slot=alert-description]:text-red-800 *:[svg]:text-red-600",
},
},

View file

@ -22,17 +22,13 @@ interface NumericalInputProps {
* @param {Function} [props.onChange] - On change handler
* @param {any} props.rest - Additional props passed to Input
*/
const NumericalInput: React.FC<NumericalInputProps> = ({
step = 0.01,
style = { width: "100%" },
placeholder = "Enter a numerical value",
min,
max,
onChange,
...rest
}) => {
return (
const NumericalInput = React.forwardRef<HTMLInputElement, NumericalInputProps>(
(
{ step = 0.01, style = { width: "100%" }, placeholder = "Enter a numerical value", min, max, onChange, ...rest },
ref,
) => (
<Input
ref={ref}
type="number"
onWheel={(event) => event.currentTarget.blur()}
step={step}
@ -43,7 +39,8 @@ const NumericalInput: React.FC<NumericalInputProps> = ({
onChange={onChange}
{...rest}
/>
);
};
),
);
NumericalInput.displayName = "NumericalInput";
export default NumericalInput;

View file

@ -58,70 +58,6 @@ vi.mock("../shared/errorUtils", () => ({
parseErrorMessage: (e: any) => String(e),
}));
// Tremor components -> async factory, local React import, and named passthroughs
vi.mock("@tremor/react", async () => {
const React = await import("react");
const makeNamedPassthrough = (tag: any, name: string) => {
function Named(props: any) {
const { children, ...rest } = props;
return React.createElement(tag, rest, children);
}
(Named as any).displayName = name;
return Named;
};
const Card = makeNamedPassthrough("div", "Card");
const Text = makeNamedPassthrough("span", "Text");
const Grid = makeNamedPassthrough("div", "Grid");
const Col = makeNamedPassthrough("div", "Col");
const TabGroup = makeNamedPassthrough("div", "TabGroup");
const TabList = makeNamedPassthrough("div", "TabList");
const TabPanels = makeNamedPassthrough("div", "TabPanels");
const TabPanel = makeNamedPassthrough("div", "TabPanel");
const Title = makeNamedPassthrough("h1", "Title");
const Badge = makeNamedPassthrough("span", "Badge");
function Button(props: any) {
const { children, onClick, ...rest } = props;
return React.createElement("button", { onClick, ...rest }, children);
}
(Button as any).displayName = "Button";
function Tab(props: any) {
const { children, ...rest } = props;
return React.createElement("button", { ...rest }, children);
}
(Tab as any).displayName = "Tab";
function TextInput(props: any) {
return React.createElement("input", { ...props });
}
(TextInput as any).displayName = "TextInput";
function TremorSelect(props: any) {
return React.createElement("select", { ...props });
}
(TremorSelect as any).displayName = "TremorSelect";
return {
Card,
Text,
Button,
Grid,
Col,
Tab,
TabList,
TabGroup,
TabPanel,
TabPanels,
Title,
Badge,
TextInput,
Select: TremorSelect,
};
});
// antd bits -> async factory & local React
vi.mock("antd", async (importOriginal) => {
const actual = await importOriginal<typeof import("antd")>();

View file

@ -115,16 +115,12 @@ const ViewUserSpend: React.FC<ViewUserSpendProps> = ({ userSpend, userMaxBudget,
<div className="flex items-center">
<div className="flex justify-between gap-x-6">
<div>
<p className="text-tremor-default text-tremor-content dark:text-dark-tremor-content">Total Spend</p>
<p className="text-2xl text-tremor-content-strong dark:text-dark-tremor-content-strong font-semibold">
${roundedSpend}
</p>
<p className="text-sm text-muted-foreground">Total Spend</p>
<p className="text-2xl font-semibold text-foreground">${roundedSpend}</p>
</div>
<div>
<p className="text-tremor-default text-tremor-content dark:text-dark-tremor-content">Max Budget</p>
<p className="text-2xl text-tremor-content-strong dark:text-dark-tremor-content-strong font-semibold">
{displayMaxBudget}
</p>
<p className="text-sm text-muted-foreground">Max Budget</p>
<p className="text-2xl font-semibold text-foreground">{displayMaxBudget}</p>
</div>
</div>
{/* <div className="ml-auto">

View file

@ -18,10 +18,10 @@ export const PTU_END_FIELD = "ptu_effective_to";
export const MAX_PTU_COUNT = 1_000_000;
export const MAX_COST_PER_PTU_PER_HOUR = 1_000_000;
const isFilled = (value: unknown): boolean => value !== undefined && value !== null && value !== "";
export const isFilledPtuValue = (value: unknown): boolean => value !== undefined && value !== null && value !== "";
const isPositiveWholeNumber = (value: unknown): boolean => {
if (!isFilled(value)) {
export const isPositiveWholePtuCount = (value: unknown): boolean => {
if (!isFilledPtuValue(value)) {
return true;
}
const parsed = Number(value);
@ -32,14 +32,14 @@ const isPositiveWholeNumber = (value: unknown): boolean => {
export const ptuCountRules: ValidatorRule[] = [
{
validator: (_, value) =>
isPositiveWholeNumber(value)
isPositiveWholePtuCount(value)
? Promise.resolve()
: Promise.reject(new Error(`PTU Count must be a whole number between 1 and ${MAX_PTU_COUNT.toLocaleString()}`)),
},
];
const isNonNegativeNumber = (value: unknown): boolean => {
if (!isFilled(value)) {
export const isNonNegativePtuRate = (value: unknown): boolean => {
if (!isFilledPtuValue(value)) {
return true;
}
const parsed = Number(value);
@ -50,7 +50,7 @@ const isNonNegativeNumber = (value: unknown): boolean => {
export const ptuRateRules: ValidatorRule[] = [
{
validator: (_, value) =>
isNonNegativeNumber(value)
isNonNegativePtuRate(value)
? Promise.resolve()
: Promise.reject(
new Error(`Cost per PTU / Hour must be between 0 and ${MAX_COST_PER_PTU_PER_HOUR.toLocaleString()}`),
@ -67,7 +67,7 @@ export const ptuPairRule =
(siblingField: string) =>
({ getFieldValue }: FormInstance): ValidatorRule => ({
validator: (_, value) =>
isFilled(value) === isFilled(getFieldValue(siblingField))
isFilledPtuValue(value) === isFilledPtuValue(getFieldValue(siblingField))
? Promise.resolve()
: Promise.reject(new Error("PTU Count and Cost per PTU / Hour must be set together")),
});
@ -85,7 +85,7 @@ export const ptuNoUsageCostRule =
// for an unpriced deployment is the public cost map. Refusing it would block every
// attempt to put an existing deployment on PTU, and the save omits it anyway.
const echoed = thisField !== undefined && isFieldTouched !== undefined && !isFieldTouched(thisField);
return echoed || !isFilled(getFieldValue(countField)) || !isFilled(value) || Number(value) === 0
return echoed || !isFilledPtuValue(getFieldValue(countField)) || !isFilledPtuValue(value) || Number(value) === 0
? Promise.resolve()
: Promise.reject(new Error("A PTU deployment bills by reserved capacity, so this cost must be 0 or blank"));
},
@ -100,7 +100,7 @@ export const ptuStartRequiredRule =
(countField: string) =>
({ getFieldValue }: FormInstance): ValidatorRule => ({
validator: (_, value) =>
isFilled(value) || !isFilled(getFieldValue(countField))
isFilledPtuValue(value) || !isFilledPtuValue(getFieldValue(countField))
? Promise.resolve()
: Promise.reject(new Error("PTU Effective From is required when PTU Count is set")),
});
@ -118,19 +118,24 @@ const toEpochMs = (value: unknown): number => {
* cannot anticipate. Pair this with `dependencies` on the sibling bound so the error clears
* once the pair is ordered.
*/
export const ptuWindowIsOrdered = (start: unknown, end: unknown): boolean => {
if (!isFilledPtuValue(start) || !isFilledPtuValue(end)) {
return true;
}
const startMs = toEpochMs(start);
const endMs = toEpochMs(end);
return Number.isNaN(startMs) || Number.isNaN(endMs) || endMs > startMs;
};
export const ptuWindowOrderRule =
(siblingField: string, thisBound: "start" | "end") =>
({ getFieldValue }: FormInstance): ValidatorRule => ({
validator: (_, value) => {
const sibling = getFieldValue(siblingField);
if (!isFilled(value) || !isFilled(sibling)) {
return Promise.resolve();
}
const startMs = toEpochMs(thisBound === "start" ? value : sibling);
const endMs = toEpochMs(thisBound === "start" ? sibling : value);
if (Number.isNaN(startMs) || Number.isNaN(endMs) || endMs > startMs) {
return Promise.resolve();
}
return Promise.reject(new Error("PTU Effective To must be after PTU Effective From"));
const start = thisBound === "start" ? value : sibling;
const end = thisBound === "start" ? sibling : value;
return ptuWindowIsOrdered(start, end)
? Promise.resolve()
: Promise.reject(new Error("PTU Effective To must be after PTU Effective From"));
},
});

View file

@ -1,6 +1,5 @@
import "@testing-library/jest-dom";
import { cleanup } from "@testing-library/react";
import React from "react";
import { afterEach, vi } from "vitest";
const ensureTestLocalStorage = () => {
@ -100,39 +99,6 @@ vi.mock("@/lib/toast", () => ({
},
}));
vi.mock("@tremor/react", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tremor/react")>();
return {
...actual,
Button: React.forwardRef<HTMLButtonElement, any>(({ children, ...props }, ref) =>
// Render as a native button to avoid Tremor-specific behaviors in tests
React.createElement("button", { ...props, ref }, children),
),
Tooltip: ({ children, ..._props }: { children?: React.ReactNode; [key: string]: unknown }) => {
// Return children directly without tooltip functionality to prevent flaky tests
// This avoids issues with hover states, positioning, and DOM queries in tests
return React.createElement(React.Fragment, null, children);
},
// Render as a plain checkbox so toggle interactions are testable without Tremor internals
Switch: ({
checked,
onChange,
className,
}: {
checked?: boolean;
onChange?: (v: boolean) => void;
className?: string;
}) =>
React.createElement("input", {
type: "checkbox",
role: "switch",
checked,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => onChange?.(e.target.checked),
className,
}),
};
});
// Global mock for useAuthorized hook to avoid repeating the same mock in every test file
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => ({
@ -232,9 +198,8 @@ if (typeof window !== "undefined") {
// JSDOM has no layout, so for observers inside a shadcn ChartContainer ([data-slot="chart"])
// the mock immediately reports a fixed 800x400 box; recharts renders nothing until it
// observes a size. Scoped to chart subtrees only: firing for every observer re-enters
// React mid-effect for tremor/headlessui consumers whose tests assume the old no-op
// (chart text would duplicate getByText targets, popover clicks go stale). Widen or
// drop the scoping once tremor is gone.
// React mid-effect for headlessui consumers whose tests assume the old no-op
// (chart text would duplicate getByText targets, popover clicks go stale).
const MOCK_RESIZE_BOX = { inlineSize: 800, blockSize: 400 };
const MOCK_RESIZE_RECT: DOMRectReadOnly = {
width: 800,