mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
Merge litellm_internal_staging into litellm_/snug-churning-dewdrop
This commit is contained in:
commit
d831f98326
24 changed files with 1711 additions and 2770 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
400
ui/litellm-dashboard/package-lock.json
generated
400
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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" />,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
818
ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx
Normal file
818
ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx
Normal 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;
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")>();
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue