From e6403b717c2707bc8b3b7e790225cbbf640dea6e Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 8 Apr 2025 13:55:20 -0700 Subject: [PATCH 01/28] [Security fix - CVE-2025-0330] - Leakage of Langfuse API keys in team exception handling (#9830) * fix team id exception in get team config * test_team_info_masking * test ref --- litellm/proxy/proxy_config.yaml | 17 ++++++++------- litellm/proxy/proxy_server.py | 6 +++++- tests/litellm/proxy/test_proxy_server.py | 27 ++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/proxy_config.yaml b/litellm/proxy/proxy_config.yaml index 709cf08729..23de923db7 100644 --- a/litellm/proxy/proxy_config.yaml +++ b/litellm/proxy/proxy_config.yaml @@ -10,11 +10,12 @@ model_list: api_key: fake-key litellm_settings: - require_auth_for_metrics_endpoint: true - - - callbacks: ["prometheus"] - service_callback: ["prometheus_system"] - -router_settings: - enable_tag_filtering: True # 👈 Key Change \ No newline at end of file + default_team_settings: + - team_id: test_dev + success_callback: ["langfuse", "s3"] + langfuse_secret: secret-test-key + langfuse_public_key: public-test-key + - team_id: my_workflows + success_callback: ["langfuse", "s3"] + langfuse_secret: secret-workflows-key + langfuse_public_key: public-workflows-key diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index c270d41cf0..ddfb7118d7 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -139,6 +139,7 @@ from litellm.litellm_core_utils.core_helpers import ( ) from litellm.litellm_core_utils.credential_accessor import CredentialAccessor from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler from litellm.proxy._experimental.mcp_server.server import router as mcp_router from litellm.proxy._experimental.mcp_server.tool_registry import ( @@ -387,6 +388,7 @@ global_max_parallel_request_retries_env: Optional[str] = os.getenv( "LITELLM_GLOBAL_MAX_PARALLEL_REQUEST_RETRIES" ) proxy_state = ProxyState() +SENSITIVE_DATA_MASKER = SensitiveDataMasker() if global_max_parallel_request_retries_env is None: global_max_parallel_request_retries: int = 3 else: @@ -1397,7 +1399,9 @@ class ProxyConfig: team_config: dict = {} for team in all_teams_config: if "team_id" not in team: - raise Exception(f"team_id missing from team: {team}") + raise Exception( + f"team_id missing from team: {SENSITIVE_DATA_MASKER.mask_dict(team)}" + ) if team_id == team["team_id"]: team_config = team break diff --git a/tests/litellm/proxy/test_proxy_server.py b/tests/litellm/proxy/test_proxy_server.py index 1c05e80012..919a00d670 100644 --- a/tests/litellm/proxy/test_proxy_server.py +++ b/tests/litellm/proxy/test_proxy_server.py @@ -162,3 +162,30 @@ async def test_aaaproxy_startup_master_key(mock_prisma, monkeypatch, tmp_path): from litellm.proxy.proxy_server import master_key assert master_key == test_resolved_key + + +def test_team_info_masking(): + """ + Test that sensitive team information is properly masked + + Ref: https://huntr.com/bounties/661b388a-44d8-4ad5-862b-4dc5b80be30a + """ + from litellm.proxy.proxy_server import ProxyConfig + + proxy_config = ProxyConfig() + # Test team object with sensitive data + team1_info = { + "success_callback": "['langfuse', 's3']", + "langfuse_secret": "secret-test-key", + "langfuse_public_key": "public-test-key", + } + + with pytest.raises(Exception) as exc_info: + proxy_config._get_team_config( + team_id="test_dev", + all_teams_config=[team1_info], + ) + + print("Got exception: {}".format(exc_info.value)) + assert "secret-test-key" not in str(exc_info.value) + assert "public-test-key" not in str(exc_info.value) From 441c7275ed2715f47650a7c2e525055c804073a9 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 8 Apr 2025 13:55:37 -0700 Subject: [PATCH 02/28] test fix post call rules (#9826) --- litellm/proxy/types_utils/utils.py | 32 +++++++++ tests/litellm/proxy/types_utils/test_utils.py | 72 +++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 tests/litellm/proxy/types_utils/test_utils.py diff --git a/litellm/proxy/types_utils/utils.py b/litellm/proxy/types_utils/utils.py index 788849b3d5..f3dbfda6b2 100644 --- a/litellm/proxy/types_utils/utils.py +++ b/litellm/proxy/types_utils/utils.py @@ -14,6 +14,9 @@ def get_instance_fn(value: str, config_file_path: Optional[str] = None) -> Any: module_name = ".".join(parts[:-1]) instance_name = parts[-1] + # Security: Check if the module name contains any dangerous modules that can execute arbitrary code + security_checks(module_name=module_name) + # If config_file_path is provided, use it to determine the module spec and load the module if config_file_path is not None: directory = os.path.dirname(config_file_path) @@ -47,6 +50,35 @@ def get_instance_fn(value: str, config_file_path: Optional[str] = None) -> Any: raise e +def security_checks( + module_name: str, +): + """ + This function checks if the module name contains any dangerous modules that can execute arbitrary code. + + Reference: https://huntr.com/bounties/1d98bebb-6cf4-46c9-87c3-d3b1972973b5 + """ + DANGEROUS_MODULES = [ + "os", + "sys", + "subprocess", + "shutil", + "socket", + "multiprocessing", + "threading", + "ctypes", + "pickle", + "marshal", + "builtins", + "__builtin__", + ] + # Security: Check if the module name contains any dangerous modules + if any(dangerous in module_name.lower() for dangerous in DANGEROUS_MODULES): + raise ImportError( + f"Importing from module {module_name} is not allowed for security reasons" + ) + + def validate_custom_validate_return_type( fn: Optional[Callable[..., Any]] ) -> Optional[Callable[..., Literal[True]]]: diff --git a/tests/litellm/proxy/types_utils/test_utils.py b/tests/litellm/proxy/types_utils/test_utils.py new file mode 100644 index 0000000000..5685489bfc --- /dev/null +++ b/tests/litellm/proxy/types_utils/test_utils.py @@ -0,0 +1,72 @@ +import json +import os +import sys + +import pytest +from fastapi.testclient import TestClient + +from litellm.proxy.types_utils.utils import security_checks + +sys.path.insert( + 0, os.path.abspath("../../..") +) # Adds the parent directory to the system path + + +def test_security_checks_blocks_dangerous_modules(): + """ + Resolves: https://huntr.com/bounties/1d98bebb-6cf4-46c9-87c3-d3b1972973b5 + + This test checks if the security_checks function correctly blocks the import of dangerous modules. + """ + dangerous_module = "/usr/lib/python3/os.system" + with pytest.raises(ImportError) as exc_info: + security_checks(dangerous_module) + + assert "not allowed for security reasons" in str(exc_info.value) + assert dangerous_module in str(exc_info.value) + + +def test_security_checks_various_dangerous_modules(): + dangerous_modules = [ + "subprocess.run", + "socket.socket", + "pickle.loads", + "marshal.loads", + "ctypes.CDLL", + "builtins.eval", + "__builtin__.exec", + "shutil.rmtree", + "multiprocessing.Process", + "threading.Thread", + ] + + for module in dangerous_modules: + with pytest.raises(ImportError) as exc_info: + security_checks(module) + assert "not allowed for security reasons" in str(exc_info.value) + assert module in str(exc_info.value) + + +def test_security_checks_case_insensitive(): + # Test that the check is case-insensitive + variations = ["OS.system", "os.System", "Os.SyStEm", "SUBPROCESS.run"] + + for module in variations: + with pytest.raises(ImportError) as exc_info: + security_checks(module) + assert "not allowed for security reasons" in str(exc_info.value) + + +def test_security_checks_nested_paths(): + # Test nested paths that contain dangerous modules + nested_paths = [ + "some/path/to/os/system", + "myproject/utils/subprocess_wrapper", + "lib/helpers/socket_utils", + "../../../system/os.py", + ] + + for path in nested_paths: + with pytest.raises(ImportError) as exc_info: + security_checks(path) + assert "not allowed for security reasons" in str(exc_info.value) From 73356b3a9fc66aba6c42d91adc05550d03818b8a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Apr 2025 14:15:19 -0700 Subject: [PATCH 03/28] Bump next from 14.2.25 to 14.2.26 in /ui/litellm-dashboard (#9716) Bumps [next](https://github.com/vercel/next.js) from 14.2.25 to 14.2.26. - [Release notes](https://github.com/vercel/next.js/releases) - [Changelog](https://github.com/vercel/next.js/blob/canary/release.js) - [Commits](https://github.com/vercel/next.js/compare/v14.2.25...v14.2.26) --- updated-dependencies: - dependency-name: next dependency-version: 14.2.26 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- ui/litellm-dashboard/package-lock.json | 88 +++++++++++++------------- ui/litellm-dashboard/package.json | 2 +- 2 files changed, 45 insertions(+), 45 deletions(-) diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 39ab75d8c7..960b9e865b 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -21,7 +21,7 @@ "jsonwebtoken": "^9.0.2", "jwt-decode": "^4.0.0", "moment": "^2.30.1", - "next": "^14.2.25", + "next": "^14.2.26", "openai": "^4.28.0", "papaparse": "^5.5.2", "react": "^18", @@ -418,9 +418,9 @@ } }, "node_modules/@next/env": { - "version": "14.2.25", - "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.25.tgz", - "integrity": "sha512-JnzQ2cExDeG7FxJwqAksZ3aqVJrHjFwZQAEJ9gQZSoEhIow7SNoKZzju/AwQ+PLIR4NY8V0rhcVozx/2izDO0w==", + "version": "14.2.26", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.26.tgz", + "integrity": "sha512-vO//GJ/YBco+H7xdQhzJxF7ub3SUwft76jwaeOyVVQFHCi5DCnkP16WHB+JBylo4vOKPoZBlR94Z8xBxNBdNJA==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -433,9 +433,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "14.2.25", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.25.tgz", - "integrity": "sha512-09clWInF1YRd6le00vt750s3m7SEYNehz9C4PUcSu3bAdCTpjIV4aTYQZ25Ehrr83VR1rZeqtKUPWSI7GfuKZQ==", + "version": "14.2.26", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.26.tgz", + "integrity": "sha512-zDJY8gsKEseGAxG+C2hTMT0w9Nk9N1Sk1qV7vXYz9MEiyRoF5ogQX2+vplyUMIfygnjn9/A04I6yrUTRTuRiyQ==", "cpu": [ "arm64" ], @@ -449,9 +449,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "14.2.25", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.25.tgz", - "integrity": "sha512-V+iYM/QR+aYeJl3/FWWU/7Ix4b07ovsQ5IbkwgUK29pTHmq+5UxeDr7/dphvtXEq5pLB/PucfcBNh9KZ8vWbug==", + "version": "14.2.26", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.26.tgz", + "integrity": "sha512-U0adH5ryLfmTDkahLwG9sUQG2L0a9rYux8crQeC92rPhi3jGQEY47nByQHrVrt3prZigadwj/2HZ1LUUimuSbg==", "cpu": [ "x64" ], @@ -465,9 +465,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.2.25", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.25.tgz", - "integrity": "sha512-LFnV2899PJZAIEHQ4IMmZIgL0FBieh5keMnriMY1cK7ompR+JUd24xeTtKkcaw8QmxmEdhoE5Mu9dPSuDBgtTg==", + "version": "14.2.26", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.26.tgz", + "integrity": "sha512-SINMl1I7UhfHGM7SoRiw0AbwnLEMUnJ/3XXVmhyptzriHbWvPPbbm0OEVG24uUKhuS1t0nvN/DBvm5kz6ZIqpg==", "cpu": [ "arm64" ], @@ -481,9 +481,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.2.25", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.25.tgz", - "integrity": "sha512-QC5y5PPTmtqFExcKWKYgUNkHeHE/z3lUsu83di488nyP0ZzQ3Yse2G6TCxz6nNsQwgAx1BehAJTZez+UQxzLfw==", + "version": "14.2.26", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.26.tgz", + "integrity": "sha512-s6JaezoyJK2DxrwHWxLWtJKlqKqTdi/zaYigDXUJ/gmx/72CrzdVZfMvUc6VqnZ7YEvRijvYo+0o4Z9DencduA==", "cpu": [ "arm64" ], @@ -497,9 +497,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.2.25", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.25.tgz", - "integrity": "sha512-y6/ML4b9eQ2D/56wqatTJN5/JR8/xdObU2Fb1RBidnrr450HLCKr6IJZbPqbv7NXmje61UyxjF5kvSajvjye5w==", + "version": "14.2.26", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.26.tgz", + "integrity": "sha512-FEXeUQi8/pLr/XI0hKbe0tgbLmHFRhgXOUiPScz2hk0hSmbGiU8aUqVslj/6C6KA38RzXnWoJXo4FMo6aBxjzg==", "cpu": [ "x64" ], @@ -513,9 +513,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "14.2.25", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.25.tgz", - "integrity": "sha512-sPX0TSXHGUOZFvv96GoBXpB3w4emMqKeMgemrSxI7A6l55VBJp/RKYLwZIB9JxSqYPApqiREaIIap+wWq0RU8w==", + "version": "14.2.26", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.26.tgz", + "integrity": "sha512-BUsomaO4d2DuXhXhgQCVt2jjX4B4/Thts8nDoIruEJkhE5ifeQFtvW5c9JkdOtYvE5p2G0hcwQ0UbRaQmQwaVg==", "cpu": [ "x64" ], @@ -529,9 +529,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.2.25", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.25.tgz", - "integrity": "sha512-ReO9S5hkA1DU2cFCsGoOEp7WJkhFzNbU/3VUF6XxNGUCQChyug6hZdYL/istQgfT/GWE6PNIg9cm784OI4ddxQ==", + "version": "14.2.26", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.26.tgz", + "integrity": "sha512-5auwsMVzT7wbB2CZXQxDctpWbdEnEW/e66DyXO1DcgHxIyhP06awu+rHKshZE+lPLIGiwtjo7bsyeuubewwxMw==", "cpu": [ "arm64" ], @@ -545,9 +545,9 @@ } }, "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.2.25", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.25.tgz", - "integrity": "sha512-DZ/gc0o9neuCDyD5IumyTGHVun2dCox5TfPQI/BJTYwpSNYM3CZDI4i6TOdjeq1JMo+Ug4kPSMuZdwsycwFbAw==", + "version": "14.2.26", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.26.tgz", + "integrity": "sha512-GQWg/Vbz9zUGi9X80lOeGsz1rMH/MtFO/XqigDznhhhTfDlDoynCM6982mPCbSlxJ/aveZcKtTlwfAjwhyxDpg==", "cpu": [ "ia32" ], @@ -561,9 +561,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.2.25", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.25.tgz", - "integrity": "sha512-KSznmS6eFjQ9RJ1nEc66kJvtGIL1iZMYmGEXsZPh2YtnLtqrgdVvKXJY2ScjjoFnG6nGLyPFR0UiEvDwVah4Tw==", + "version": "14.2.26", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.26.tgz", + "integrity": "sha512-2rdB3T1/Gp7bv1eQTTm9d1Y1sv9UuJ2LAwOE0Pe2prHKe32UNscj7YS13fRB37d0GAiGNR+Y7ZcW8YjDI8Ns0w==", "cpu": [ "x64" ], @@ -5011,12 +5011,12 @@ "dev": true }, "node_modules/next": { - "version": "14.2.25", - "resolved": "https://registry.npmjs.org/next/-/next-14.2.25.tgz", - "integrity": "sha512-N5M7xMc4wSb4IkPvEV5X2BRRXUmhVHNyaXwEM86+voXthSZz8ZiRyQW4p9mwAoAPIm6OzuVZtn7idgEJeAJN3Q==", + "version": "14.2.26", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.26.tgz", + "integrity": "sha512-b81XSLihMwCfwiUVRRja3LphLo4uBBMZEzBBWMaISbKTwOmq3wPknIETy/8000tr7Gq4WmbuFYPS7jOYIf+ZJw==", "license": "MIT", "dependencies": { - "@next/env": "14.2.25", + "@next/env": "14.2.26", "@swc/helpers": "0.5.5", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", @@ -5031,15 +5031,15 @@ "node": ">=18.17.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "14.2.25", - "@next/swc-darwin-x64": "14.2.25", - "@next/swc-linux-arm64-gnu": "14.2.25", - "@next/swc-linux-arm64-musl": "14.2.25", - "@next/swc-linux-x64-gnu": "14.2.25", - "@next/swc-linux-x64-musl": "14.2.25", - "@next/swc-win32-arm64-msvc": "14.2.25", - "@next/swc-win32-ia32-msvc": "14.2.25", - "@next/swc-win32-x64-msvc": "14.2.25" + "@next/swc-darwin-arm64": "14.2.26", + "@next/swc-darwin-x64": "14.2.26", + "@next/swc-linux-arm64-gnu": "14.2.26", + "@next/swc-linux-arm64-musl": "14.2.26", + "@next/swc-linux-x64-gnu": "14.2.26", + "@next/swc-linux-x64-musl": "14.2.26", + "@next/swc-win32-arm64-msvc": "14.2.26", + "@next/swc-win32-ia32-msvc": "14.2.26", + "@next/swc-win32-x64-msvc": "14.2.26" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 895e2576cc..c951796020 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -22,7 +22,7 @@ "jsonwebtoken": "^9.0.2", "jwt-decode": "^4.0.0", "moment": "^2.30.1", - "next": "^14.2.25", + "next": "^14.2.26", "openai": "^4.28.0", "papaparse": "^5.5.2", "react": "^18", From 8a596dbe8c9782e042aecb889cb6922cad258c1a Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 8 Apr 2025 16:27:09 -0700 Subject: [PATCH 04/28] pip install wheel --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index b1e08084fa..24f08f6bf4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -610,6 +610,7 @@ jobs: name: Install Dependencies command: | python -m pip install --upgrade pip + pip install wheel python -m pip install -r requirements.txt pip install "pytest==7.3.1" pip install "respx==0.21.1" From c403dfb6157d01e7ab8998d38239e203ff4ac809 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 8 Apr 2025 16:38:44 -0700 Subject: [PATCH 05/28] pip install --upgrade pip wheel setuptools --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 24f08f6bf4..14a22a5995 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -611,6 +611,7 @@ jobs: command: | python -m pip install --upgrade pip pip install wheel + pip install --upgrade pip wheel setuptools python -m pip install -r requirements.txt pip install "pytest==7.3.1" pip install "respx==0.21.1" From a3ea079583dde19820281154f4c335e608b0056f Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Tue, 8 Apr 2025 16:41:20 -0700 Subject: [PATCH 06/28] docs(gemini.md): show how to call google search via litellm Addresses https://github.com/BerriAI/litellm/issues/361#issuecomment-2787497217 --- docs/my-website/docs/providers/gemini.md | 173 +++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/docs/my-website/docs/providers/gemini.md b/docs/my-website/docs/providers/gemini.md index 42783286f1..db63d33d8d 100644 --- a/docs/my-website/docs/providers/gemini.md +++ b/docs/my-website/docs/providers/gemini.md @@ -438,6 +438,179 @@ assert isinstance( ``` +### Google Search Tool + + + + +```python +from litellm import completion +import os + +os.environ["GEMINI_API_KEY"] = ".." + +tools = [{"googleSearch": {}}] # 👈 ADD GOOGLE SEARCH + +response = completion( + model="gemini/gemini-2.0-flash", + messages=[{"role": "user", "content": "What is the weather in San Francisco?"}], + tools=tools, +) + +print(response) +``` + + + + +1. Setup config.yaml +```yaml +model_list: + - model_name: gemini-2.0-flash + litellm_params: + model: gemini/gemini-2.0-flash + api_key: os.environ/GEMINI_API_KEY +``` + +2. Start Proxy +```bash +$ litellm --config /path/to/config.yaml +``` + +3. Make Request! +```bash +curl -X POST 'http://0.0.0.0:4000/chat/completions' \ +-H 'Content-Type: application/json' \ +-H 'Authorization: Bearer sk-1234' \ +-d '{ + "model": "gemini-2.0-flash", + "messages": [{"role": "user", "content": "What is the weather in San Francisco?"}], + "tools": [{"googleSearch": {}}] +} +' +``` + + + + +### Google Search Retrieval + + + + + +```python +from litellm import completion +import os + +os.environ["GEMINI_API_KEY"] = ".." + +tools = [{"googleSearchRetrieval": {}}] # 👈 ADD GOOGLE SEARCH + +response = completion( + model="gemini/gemini-2.0-flash", + messages=[{"role": "user", "content": "What is the weather in San Francisco?"}], + tools=tools, +) + +print(response) +``` + + + + +1. Setup config.yaml +```yaml +model_list: + - model_name: gemini-2.0-flash + litellm_params: + model: gemini/gemini-2.0-flash + api_key: os.environ/GEMINI_API_KEY +``` + +2. Start Proxy +```bash +$ litellm --config /path/to/config.yaml +``` + +3. Make Request! +```bash +curl -X POST 'http://0.0.0.0:4000/chat/completions' \ +-H 'Content-Type: application/json' \ +-H 'Authorization: Bearer sk-1234' \ +-d '{ + "model": "gemini-2.0-flash", + "messages": [{"role": "user", "content": "What is the weather in San Francisco?"}], + "tools": [{"googleSearchRetrieval": {}}] +} +' +``` + + + + + +### Code Execution Tool + + + + + +```python +from litellm import completion +import os + +os.environ["GEMINI_API_KEY"] = ".." + +tools = [{"codeExecution": {}}] # 👈 ADD GOOGLE SEARCH + +response = completion( + model="gemini/gemini-2.0-flash", + messages=[{"role": "user", "content": "What is the weather in San Francisco?"}], + tools=tools, +) + +print(response) +``` + + + + +1. Setup config.yaml +```yaml +model_list: + - model_name: gemini-2.0-flash + litellm_params: + model: gemini/gemini-2.0-flash + api_key: os.environ/GEMINI_API_KEY +``` + +2. Start Proxy +```bash +$ litellm --config /path/to/config.yaml +``` + +3. Make Request! +```bash +curl -X POST 'http://0.0.0.0:4000/chat/completions' \ +-H 'Content-Type: application/json' \ +-H 'Authorization: Bearer sk-1234' \ +-d '{ + "model": "gemini-2.0-flash", + "messages": [{"role": "user", "content": "What is the weather in San Francisco?"}], + "tools": [{"codeExecution": {}}] +} +' +``` + + + + + + + + + ## JSON Mode From 11389535d53ccb429876b744514afd89aa8d8463 Mon Sep 17 00:00:00 2001 From: Li Yang <76434265+hewliyang@users.noreply.github.com> Date: Wed, 9 Apr 2025 07:43:09 +0800 Subject: [PATCH 07/28] chore: fix haiku cache read pricing per token (#9834) --- litellm/model_prices_and_context_window_backup.json | 2 +- model_prices_and_context_window.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 79aa57f466..ea33bdb02b 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -3467,7 +3467,7 @@ "input_cost_per_token": 0.0000008, "output_cost_per_token": 0.000004, "cache_creation_input_token_cost": 0.000001, - "cache_read_input_token_cost": 0.0000008, + "cache_read_input_token_cost": 0.00000008, "litellm_provider": "anthropic", "mode": "chat", "supports_function_calling": true, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 79aa57f466..ea33bdb02b 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -3467,7 +3467,7 @@ "input_cost_per_token": 0.0000008, "output_cost_per_token": 0.000004, "cache_creation_input_token_cost": 0.000001, - "cache_read_input_token_cost": 0.0000008, + "cache_read_input_token_cost": 0.00000008, "litellm_provider": "anthropic", "mode": "chat", "supports_function_calling": true, From 9f33e9b3e8a376fe8beb93b7b52c65ebe8a7bf79 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 8 Apr 2025 17:24:08 -0700 Subject: [PATCH 08/28] pin ml-dtypes==0.4.0 --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 20ef862715..7f5ca9d7fa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ gunicorn==23.0.0 # server dep uvloop==0.21.0 # uvicorn dep, gives us much better performance under load boto3==1.34.34 # aws bedrock/sagemaker calls redis==5.2.1 # redis caching +ml-dtypes==0.4.0 # used by redisvl redisvl==0.4.1 # semantic caching prisma==0.11.0 # for db mangum==0.17.0 # for aws lambda functions From 357f081d1cf5404f8cae4a882cdc446c34227f10 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 8 Apr 2025 17:25:28 -0700 Subject: [PATCH 09/28] fix mldtypes dep --- requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7f5ca9d7fa..20ef862715 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,6 @@ gunicorn==23.0.0 # server dep uvloop==0.21.0 # uvicorn dep, gives us much better performance under load boto3==1.34.34 # aws bedrock/sagemaker calls redis==5.2.1 # redis caching -ml-dtypes==0.4.0 # used by redisvl redisvl==0.4.1 # semantic caching prisma==0.11.0 # for db mangum==0.17.0 # for aws lambda functions From cc7d59a11ed33566d89ce6bb76d7666c8d2b6b79 Mon Sep 17 00:00:00 2001 From: Marcus Hynfield Date: Wed, 9 Apr 2025 00:42:09 -0400 Subject: [PATCH 10/28] Add service annotations to litellm-helm chart (#9840) --- deploy/charts/litellm-helm/templates/service.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/deploy/charts/litellm-helm/templates/service.yaml b/deploy/charts/litellm-helm/templates/service.yaml index 40e7f27f16..d8d81e78c8 100644 --- a/deploy/charts/litellm-helm/templates/service.yaml +++ b/deploy/charts/litellm-helm/templates/service.yaml @@ -2,6 +2,10 @@ apiVersion: v1 kind: Service metadata: name: {{ include "litellm.fullname" . }} + {{- with .Values.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} labels: {{- include "litellm.labels" . | nindent 4 }} spec: From d4e5da87be358ffde020eabb5df96ab926a481cd Mon Sep 17 00:00:00 2001 From: Christian Owusu <36159205+crisshaker@users.noreply.github.com> Date: Wed, 9 Apr 2025 14:47:16 +0000 Subject: [PATCH 11/28] Reflect key and team update in UI (#9825) * Reflect updates to keys in UI instantly * Reflect updates to teams in UI instantly --- .../src/components/all_keys_table.tsx | 21 +++++++++++++++++++ .../src/components/key_info_view.tsx | 10 ++++++++- .../components/key_team_helpers/key_list.tsx | 4 +++- .../src/components/networking.tsx | 3 ++- .../src/components/team/team_info.tsx | 10 +++++++-- ui/litellm-dashboard/src/components/teams.tsx | 17 +++++++++++++++ .../src/components/view_key_table.tsx | 1 + ui/litellm-dashboard/src/types.ts | 1 + ui/litellm-dashboard/src/utils/dataUtils.ts | 14 +++++++++++++ 9 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 ui/litellm-dashboard/src/types.ts create mode 100644 ui/litellm-dashboard/src/utils/dataUtils.ts diff --git a/ui/litellm-dashboard/src/components/all_keys_table.tsx b/ui/litellm-dashboard/src/components/all_keys_table.tsx index b0313c241f..3a2bf61c5f 100644 --- a/ui/litellm-dashboard/src/components/all_keys_table.tsx +++ b/ui/litellm-dashboard/src/components/all_keys_table.tsx @@ -13,9 +13,12 @@ import { Organization, userListCall } from "./networking"; import { createTeamSearchFunction } from "./key_team_helpers/team_search_fn"; import { createOrgSearchFunction } from "./key_team_helpers/organization_search_fn"; import { useFilterLogic } from "./key_team_helpers/filter_logic"; +import { Setter } from "@/types"; +import { updateExistingKeys } from "@/utils/dataUtils"; interface AllKeysTableProps { keys: KeyResponse[]; + setKeys: Setter; isLoading?: boolean; pagination: { currentPage: number; @@ -87,6 +90,7 @@ const TeamFilter = ({ */ export function AllKeysTable({ keys, + setKeys, isLoading = false, pagination, onPageChange, @@ -364,6 +368,23 @@ export function AllKeysTable({ keyId={selectedKeyId} onClose={() => setSelectedKeyId(null)} keyData={keys.find(k => k.token === selectedKeyId)} + onKeyDataUpdate={(updatedKeyData) => { + setKeys(keys => keys.map(key => { + if (key.token === updatedKeyData.token) { + // The shape of key is different from that of + // updatedKeyData(received from keyUpdateCall in networking.tsx). + // Hence, we can't replace key with updatedKeys since it might lead + // to unintended bugs/behaviors. + // So instead, we only update fields that are present in both. + return updateExistingKeys(key, updatedKeyData) + } + + return key + })) + }} + onDelete={() => { + setKeys(keys => keys.filter(key => key.token !== selectedKeyId)) + }} accessToken={accessToken} userID={userID} userRole={userRole} diff --git a/ui/litellm-dashboard/src/components/key_info_view.tsx b/ui/litellm-dashboard/src/components/key_info_view.tsx index 9d50be6cf7..b7ebdc651a 100644 --- a/ui/litellm-dashboard/src/components/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/key_info_view.tsx @@ -27,13 +27,15 @@ interface KeyInfoViewProps { keyId: string; onClose: () => void; keyData: KeyResponse | undefined; + onKeyDataUpdate?: (data: Partial) => void; + onDelete?: () => void; accessToken: string | null; userID: string | null; userRole: string | null; teams: any[] | null; } -export default function KeyInfoView({ keyId, onClose, keyData, accessToken, userID, userRole, teams }: KeyInfoViewProps) { +export default function KeyInfoView({ keyId, onClose, keyData, accessToken, userID, userRole, teams, onKeyDataUpdate, onDelete }: KeyInfoViewProps) { const [isEditing, setIsEditing] = useState(false); const [form] = Form.useForm(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -93,6 +95,9 @@ export default function KeyInfoView({ keyId, onClose, keyData, accessToken, user } const newKeyValues = await keyUpdateCall(accessToken, formValues); + if (onKeyDataUpdate) { + onKeyDataUpdate(newKeyValues) + } message.success("Key updated successfully"); setIsEditing(false); // Refresh key data here if needed @@ -107,6 +112,9 @@ export default function KeyInfoView({ keyId, onClose, keyData, accessToken, user if (!accessToken) return; await keyDeleteCall(accessToken as string, keyData.token); message.success("Key deleted successfully"); + if (onDelete) { + onDelete() + } onClose(); } catch (error) { console.error("Error deleting the key:", error); diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx index 4c2a18d2b5..4ca0ea5720 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { keyListCall, Organization } from '../networking'; +import { Setter } from '@/types'; export interface Team { team_id: string; @@ -94,13 +95,14 @@ totalPages: number; totalCount: number; } + interface UseKeyListReturn { keys: KeyResponse[]; isLoading: boolean; error: Error | null; pagination: PaginationData; refresh: (params?: Record) => Promise; -setKeys: (newKeysOrUpdater: KeyResponse[] | ((prevKeys: KeyResponse[]) => KeyResponse[])) => void; +setKeys: Setter; } const useKeyList = ({ diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index ac79237fb8..025f0c72c4 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -4,6 +4,7 @@ import { all_admin_roles } from "@/utils/roles"; import { message } from "antd"; import { TagNewRequest, TagUpdateRequest, TagDeleteRequest, TagInfoRequest, TagListResponse, TagInfoResponse } from "./tag_management/types"; +import { Team } from "./key_team_helpers/key_list"; const isLocal = process.env.NODE_ENV === "development"; export const proxyBaseUrl = isLocal ? "http://localhost:4000" : null; @@ -2983,7 +2984,7 @@ export const teamUpdateCall = async ( console.error("Error response from the server:", errorData); throw new Error("Network response was not ok"); } - const data = await response.json(); + const data = await response.json() as { data: Team, team_id: string }; console.log("Update Team Response:", data); return data; // Handle success - you might want to update some state or UI based on the created key diff --git a/ui/litellm-dashboard/src/components/team/team_info.tsx b/ui/litellm-dashboard/src/components/team/team_info.tsx index e04680b53a..20e9d23ccf 100644 --- a/ui/litellm-dashboard/src/components/team/team_info.tsx +++ b/ui/litellm-dashboard/src/components/team/team_info.tsx @@ -30,6 +30,7 @@ import { PencilAltIcon, PlusIcon, TrashIcon } from "@heroicons/react/outline"; import MemberModal from "./edit_membership"; import UserSearchModal from "@/components/common_components/user_search_modal"; import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; +import { Team } from "../key_team_helpers/key_list"; interface TeamData { @@ -69,6 +70,7 @@ interface TeamInfoProps { is_proxy_admin: boolean; userModels: string[]; editTeam: boolean; + onUpdate?: (team: Team) => void } const TeamInfoView: React.FC = ({ @@ -78,7 +80,8 @@ const TeamInfoView: React.FC = ({ is_team_admin, is_proxy_admin, userModels, - editTeam + editTeam, + onUpdate }) => { const [teamData, setTeamData] = useState(null); const [loading, setLoading] = useState(true); @@ -199,7 +202,10 @@ const TeamInfoView: React.FC = ({ }; const response = await teamUpdateCall(accessToken, updateData); - + if (onUpdate) { + onUpdate(response.data) + } + message.success("Team settings updated successfully"); setIsEditing(false); fetchTeamInfo(); diff --git a/ui/litellm-dashboard/src/components/teams.tsx b/ui/litellm-dashboard/src/components/teams.tsx index 6f516f06e2..7e3b607267 100644 --- a/ui/litellm-dashboard/src/components/teams.tsx +++ b/ui/litellm-dashboard/src/components/teams.tsx @@ -84,6 +84,7 @@ import { modelAvailableCall, teamListCall } from "./networking"; +import { updateExistingKeys } from "@/utils/dataUtils"; const getOrganizationModels = (organization: Organization | null, userModels: string[]) => { let tempModelsToPick = []; @@ -321,6 +322,22 @@ const Teams: React.FC = ({ {selectedTeamId ? ( { + setTeams(teams => { + if (teams == null) { + return teams; + } + + return teams.map(team => { + if (data.team_id === team.team_id) { + return updateExistingKeys(team, data) + } + + return team + }) + }) + + }} onClose={() => { setSelectedTeamId(null); setEditTeam(false); diff --git a/ui/litellm-dashboard/src/components/view_key_table.tsx b/ui/litellm-dashboard/src/components/view_key_table.tsx index f3661c8c64..57467efa18 100644 --- a/ui/litellm-dashboard/src/components/view_key_table.tsx +++ b/ui/litellm-dashboard/src/components/view_key_table.tsx @@ -418,6 +418,7 @@ const ViewKeyTable: React.FC = ({
= (newValueOrUpdater: T | ((previousValue: T) => T)) => void \ No newline at end of file diff --git a/ui/litellm-dashboard/src/utils/dataUtils.ts b/ui/litellm-dashboard/src/utils/dataUtils.ts new file mode 100644 index 0000000000..f51940f2ef --- /dev/null +++ b/ui/litellm-dashboard/src/utils/dataUtils.ts @@ -0,0 +1,14 @@ +export function updateExistingKeys( + target: Source, + source: Object +): Source { + const clonedTarget = structuredClone(target); + + for (const [key, value] of Object.entries(source)) { + if (key in clonedTarget) { + (clonedTarget as any)[key] = value; + } + } + + return clonedTarget; +} From dc9bfae053c7c7b7b4a8e83af8e8bd0c06dab81d Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt P Suorra Date: Wed, 9 Apr 2025 22:16:35 +0200 Subject: [PATCH 12/28] Add user alias to API endpoint (#9859) Co-authored-by: Jacob Hagstedt --- litellm/proxy/_types.py | 1 + 1 file changed, 1 insertion(+) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index ae4bdc7b8c..b64ff6c827 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1625,6 +1625,7 @@ class LiteLLM_UserTable(LiteLLMPydanticObjectBase): model_max_budget: Optional[Dict] = {} model_spend: Optional[Dict] = {} user_email: Optional[str] = None + user_alias: Optional[str] = None models: list = [] tpm_limit: Optional[int] = None rpm_limit: Optional[int] = None From d5e362459cb5387446c0c5a07ddb55c386f653f4 Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Wed, 9 Apr 2025 15:17:00 -0500 Subject: [PATCH 13/28] Update Azure Phi-4 pricing (#9862) Updates Phi-4 family model prices with recently published info --- model_prices_and_context_window.json | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index ea33bdb02b..7e5be4dc6b 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -2409,25 +2409,26 @@ "max_tokens": 4096, "max_input_tokens": 131072, "max_output_tokens": 4096, - "input_cost_per_token": 0, - "output_cost_per_token": 0, + "input_cost_per_token": 0.000000075, + "output_cost_per_token": 0.0000003, "litellm_provider": "azure_ai", "mode": "chat", "supports_function_calling": true, - "source": "https://learn.microsoft.com/en-us/azure/ai-foundry/concepts/models-featured#microsoft" + "source": "https://techcommunity.microsoft.com/blog/Azure-AI-Services-blog/announcing-new-phi-pricing-empowering-your-business-with-small-language-models/4395112" }, "azure_ai/Phi-4-multimodal-instruct": { "max_tokens": 4096, "max_input_tokens": 131072, "max_output_tokens": 4096, - "input_cost_per_token": 0, - "output_cost_per_token": 0, + "input_cost_per_token": 0.00000008, + "input_cost_per_audio_token": 0.000004, + "output_cost_per_token": 0.00032, "litellm_provider": "azure_ai", "mode": "chat", "supports_audio_input": true, "supports_function_calling": true, "supports_vision": true, - "source": "https://learn.microsoft.com/en-us/azure/ai-foundry/concepts/models-featured#microsoft" + "source": "https://techcommunity.microsoft.com/blog/Azure-AI-Services-blog/announcing-new-phi-pricing-empowering-your-business-with-small-language-models/4395112" }, "azure_ai/Phi-4": { "max_tokens": 16384, From 93532e00dbedb3e8b547d5fe83f18989ee5a7272 Mon Sep 17 00:00:00 2001 From: qvalentin <36446499+qvalentin@users.noreply.github.com> Date: Wed, 9 Apr 2025 20:17:48 +0000 Subject: [PATCH 14/28] feat: add enterpriseWebSearch tool for vertex-ai (#9856) --- docs/my-website/docs/providers/vertex.md | 2 ++ .../vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py | 5 +++++ litellm/types/llms/vertex_ai.py | 1 + tests/llm_translation/test_vertex.py | 1 + 4 files changed, 9 insertions(+) diff --git a/docs/my-website/docs/providers/vertex.md b/docs/my-website/docs/providers/vertex.md index ab13a51137..cdd3fce6c6 100644 --- a/docs/my-website/docs/providers/vertex.md +++ b/docs/my-website/docs/providers/vertex.md @@ -398,6 +398,8 @@ curl http://localhost:4000/v1/chat/completions \ +You can also use the `enterpriseWebSearch` tool for an [enterprise compliant search](https://cloud.google.com/vertex-ai/generative-ai/docs/grounding/web-grounding-enterprise). + #### **Moving from Vertex AI SDK to LiteLLM (GROUNDING)** diff --git a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py index 749b6d9428..e7d3d2b060 100644 --- a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py +++ b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py @@ -240,6 +240,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): gtool_func_declarations = [] googleSearch: Optional[dict] = None googleSearchRetrieval: Optional[dict] = None + enterpriseWebSearch: Optional[dict] = None code_execution: Optional[dict] = None # remove 'additionalProperties' from tools value = _remove_additional_properties(value) @@ -273,6 +274,8 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): googleSearch = tool["googleSearch"] elif tool.get("googleSearchRetrieval", None) is not None: googleSearchRetrieval = tool["googleSearchRetrieval"] + elif tool.get("enterpriseWebSearch", None) is not None: + enterpriseWebSearch = tool["enterpriseWebSearch"] elif tool.get("code_execution", None) is not None: code_execution = tool["code_execution"] elif openai_function_object is not None: @@ -299,6 +302,8 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): _tools["googleSearch"] = googleSearch if googleSearchRetrieval is not None: _tools["googleSearchRetrieval"] = googleSearchRetrieval + if enterpriseWebSearch is not None: + _tools["enterpriseWebSearch"] = enterpriseWebSearch if code_execution is not None: _tools["code_execution"] = code_execution return [_tools] diff --git a/litellm/types/llms/vertex_ai.py b/litellm/types/llms/vertex_ai.py index 7fa167938f..2e25f259b0 100644 --- a/litellm/types/llms/vertex_ai.py +++ b/litellm/types/llms/vertex_ai.py @@ -187,6 +187,7 @@ class Tools(TypedDict, total=False): function_declarations: List[FunctionDeclaration] googleSearch: dict googleSearchRetrieval: dict + enterpriseWebSearch: dict code_execution: dict retrieval: Retrieval diff --git a/tests/llm_translation/test_vertex.py b/tests/llm_translation/test_vertex.py index d821fb415e..9118d94a6f 100644 --- a/tests/llm_translation/test_vertex.py +++ b/tests/llm_translation/test_vertex.py @@ -141,6 +141,7 @@ def test_build_vertex_schema(): [ ([{"googleSearch": {}}], "googleSearch"), ([{"googleSearchRetrieval": {}}], "googleSearchRetrieval"), + ([{"enterpriseWebSearch": {}}], "enterpriseWebSearch"), ([{"code_execution": {}}], "code_execution"), ], ) From 6ba3c4a4f84f57a00c4f1a10e6857a7b0d8c8f19 Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Wed, 9 Apr 2025 14:01:48 -0700 Subject: [PATCH 15/28] VertexAI non-jsonl file storage support (#9781) * test: add initial e2e test * fix(vertex_ai/files): initial commit adding sync file create support * refactor: initial commit of vertex ai non-jsonl files reaching gcp endpoint * fix(vertex_ai/files/transformation.py): initial working commit of non-jsonl file call reaching backend endpoint * fix(vertex_ai/files/transformation.py): working e2e non-jsonl file upload * test: working e2e jsonl call * test: unit testing for jsonl file creation * fix(vertex_ai/transformation.py): reset file pointer after read allow multiple reads on same file object * fix: fix linting errors * fix: fix ruff linting errors * fix: fix import * fix: fix linting error * fix: fix linting error * fix(vertex_ai/files/transformation.py): fix linting error * test: update test * test: update tests * fix: fix linting errors * fix: fix test * fix: fix linting error --- .../litellm_core_utils/get_litellm_params.py | 3 + .../prompt_templates/common_utils.py | 76 ++++- .../aiohttp_openai/chat/transformation.py | 1 + litellm/llms/anthropic/chat/handler.py | 1 + litellm/llms/anthropic/chat/transformation.py | 1 + .../anthropic/completion/transformation.py | 1 + litellm/llms/azure/chat/gpt_transformation.py | 1 + litellm/llms/azure_ai/chat/transformation.py | 1 + litellm/llms/base_llm/chat/transformation.py | 1 + litellm/llms/base_llm/files/transformation.py | 25 +- .../image_variations/transformation.py | 1 + .../bedrock/chat/converse_transformation.py | 1 + .../base_invoke_transformation.py | 1 + litellm/llms/clarifai/chat/transformation.py | 1 + .../llms/cloudflare/chat/transformation.py | 1 + litellm/llms/cohere/chat/transformation.py | 1 + .../llms/cohere/completion/transformation.py | 1 + litellm/llms/custom_httpx/aiohttp_handler.py | 2 + litellm/llms/custom_httpx/http_handler.py | 7 +- litellm/llms/custom_httpx/llm_http_handler.py | 163 +++++---- .../llms/databricks/chat/transformation.py | 1 + .../audio_transcription/transformation.py | 1 + litellm/llms/fireworks_ai/common_utils.py | 1 + litellm/llms/gemini/common_utils.py | 1 + litellm/llms/gemini/files/transformation.py | 66 +--- .../llms/huggingface/chat/transformation.py | 20 +- litellm/llms/huggingface/embedding/handler.py | 38 ++- .../huggingface/embedding/transformation.py | 1 + litellm/llms/nlp_cloud/chat/handler.py | 1 + litellm/llms/nlp_cloud/chat/transformation.py | 1 + .../llms/ollama/completion/transformation.py | 1 + litellm/llms/oobabooga/chat/oobabooga.py | 2 + litellm/llms/oobabooga/chat/transformation.py | 1 + .../llms/openai/chat/gpt_transformation.py | 1 + litellm/llms/openai/openai.py | 1 + .../transcriptions/whisper_transformation.py | 1 + .../llms/petals/completion/transformation.py | 1 + litellm/llms/predibase/chat/handler.py | 3 +- litellm/llms/predibase/chat/transformation.py | 1 + litellm/llms/replicate/chat/handler.py | 1 + litellm/llms/replicate/chat/transformation.py | 1 + litellm/llms/sagemaker/completion/handler.py | 6 + .../sagemaker/completion/transformation.py | 1 + litellm/llms/snowflake/chat/transformation.py | 1 + .../topaz/image_variations/transformation.py | 1 + .../llms/triton/completion/transformation.py | 1 + .../llms/triton/embedding/transformation.py | 1 + litellm/llms/vertex_ai/files/handler.py | 19 +- .../llms/vertex_ai/files/transformation.py | 315 +++++++++++++++++- .../vertex_and_google_ai_studio_gemini.py | 6 +- .../embedding_handler.py | 1 + .../multimodal_embeddings/transformation.py | 1 + litellm/llms/vertex_ai/vertex_llm_base.py | 3 +- .../llms/voyage/embedding/transformation.py | 1 + litellm/llms/watsonx/chat/handler.py | 1 + litellm/llms/watsonx/common_utils.py | 1 + litellm/main.py | 1 + litellm/types/llms/vertex_ai.py | 45 +++ litellm/types/utils.py | 19 +- litellm/utils.py | 4 + .../test_openai_batches_and_files.py | 35 +- .../test_huggingface_chat_completion.py | 3 +- tests/local_testing/example.jsonl | 2 + tests/local_testing/test_gcs_bucket.py | 62 +++- 64 files changed, 780 insertions(+), 185 deletions(-) create mode 100644 tests/local_testing/example.jsonl diff --git a/litellm/litellm_core_utils/get_litellm_params.py b/litellm/litellm_core_utils/get_litellm_params.py index 4f2f43f0de..f40f1ae4c7 100644 --- a/litellm/litellm_core_utils/get_litellm_params.py +++ b/litellm/litellm_core_utils/get_litellm_params.py @@ -110,5 +110,8 @@ def get_litellm_params( "azure_password": kwargs.get("azure_password"), "max_retries": max_retries, "timeout": kwargs.get("timeout"), + "bucket_name": kwargs.get("bucket_name"), + "vertex_credentials": kwargs.get("vertex_credentials"), + "vertex_project": kwargs.get("vertex_project"), } return litellm_params diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 0f2d0da388..44b680d487 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -2,7 +2,10 @@ Common utility functions used for translating messages across providers """ -from typing import Dict, List, Literal, Optional, Union, cast +import io +import mimetypes +from os import PathLike +from typing import Dict, List, Literal, Mapping, Optional, Union, cast from litellm.types.llms.openai import ( AllMessageValues, @@ -10,7 +13,13 @@ from litellm.types.llms.openai import ( ChatCompletionFileObject, ChatCompletionUserMessage, ) -from litellm.types.utils import Choices, ModelResponse, StreamingChoices +from litellm.types.utils import ( + Choices, + ExtractedFileData, + FileTypes, + ModelResponse, + StreamingChoices, +) DEFAULT_USER_CONTINUE_MESSAGE = ChatCompletionUserMessage( content="Please continue.", role="user" @@ -350,6 +359,68 @@ def update_messages_with_model_file_ids( return messages +def extract_file_data(file_data: FileTypes) -> ExtractedFileData: + """ + Extracts and processes file data from various input formats. + + Args: + file_data: Can be a tuple of (filename, content, [content_type], [headers]) or direct file content + + Returns: + ExtractedFileData containing: + - filename: Name of the file if provided + - content: The file content in bytes + - content_type: MIME type of the file + - headers: Any additional headers + """ + # Parse the file_data based on its type + filename = None + file_content = None + content_type = None + file_headers: Mapping[str, str] = {} + + if isinstance(file_data, tuple): + if len(file_data) == 2: + filename, file_content = file_data + elif len(file_data) == 3: + filename, file_content, content_type = file_data + elif len(file_data) == 4: + filename, file_content, content_type, file_headers = file_data + else: + file_content = file_data + # Convert content to bytes + if isinstance(file_content, (str, PathLike)): + # If it's a path, open and read the file + with open(file_content, "rb") as f: + content = f.read() + elif isinstance(file_content, io.IOBase): + # If it's a file-like object + content = file_content.read() + + if isinstance(content, str): + content = content.encode("utf-8") + # Reset file pointer to beginning + file_content.seek(0) + elif isinstance(file_content, bytes): + content = file_content + else: + raise ValueError(f"Unsupported file content type: {type(file_content)}") + + # Use provided content type or guess based on filename + if not content_type: + content_type = ( + mimetypes.guess_type(filename)[0] + if filename + else "application/octet-stream" + ) + + return ExtractedFileData( + filename=filename, + content=content, + content_type=content_type, + headers=file_headers, + ) + def unpack_defs(schema, defs): properties = schema.get("properties", None) if properties is None: @@ -381,3 +452,4 @@ def unpack_defs(schema, defs): unpack_defs(ref, defs) value["items"] = ref continue + diff --git a/litellm/llms/aiohttp_openai/chat/transformation.py b/litellm/llms/aiohttp_openai/chat/transformation.py index af073fe8e3..c2d4e5adcd 100644 --- a/litellm/llms/aiohttp_openai/chat/transformation.py +++ b/litellm/llms/aiohttp_openai/chat/transformation.py @@ -50,6 +50,7 @@ class AiohttpOpenAIChatConfig(OpenAILikeChatConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/anthropic/chat/handler.py b/litellm/llms/anthropic/chat/handler.py index f2a5542dcd..44567facf9 100644 --- a/litellm/llms/anthropic/chat/handler.py +++ b/litellm/llms/anthropic/chat/handler.py @@ -301,6 +301,7 @@ class AnthropicChatCompletion(BaseLLM): model=model, messages=messages, optional_params={**optional_params, "is_vertex_request": is_vertex_request}, + litellm_params=litellm_params, ) config = ProviderConfigManager.get_provider_chat_config( diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 8a2048f95a..9b66249630 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -868,6 +868,7 @@ class AnthropicConfig(BaseConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> Dict: diff --git a/litellm/llms/anthropic/completion/transformation.py b/litellm/llms/anthropic/completion/transformation.py index e4e04df4d6..9e3287aa8a 100644 --- a/litellm/llms/anthropic/completion/transformation.py +++ b/litellm/llms/anthropic/completion/transformation.py @@ -87,6 +87,7 @@ class AnthropicTextConfig(BaseConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/azure/chat/gpt_transformation.py b/litellm/llms/azure/chat/gpt_transformation.py index e30d68f97d..ea61ef2c9a 100644 --- a/litellm/llms/azure/chat/gpt_transformation.py +++ b/litellm/llms/azure/chat/gpt_transformation.py @@ -293,6 +293,7 @@ class AzureOpenAIConfig(BaseConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/azure_ai/chat/transformation.py b/litellm/llms/azure_ai/chat/transformation.py index 007a4303c8..839f875f75 100644 --- a/litellm/llms/azure_ai/chat/transformation.py +++ b/litellm/llms/azure_ai/chat/transformation.py @@ -39,6 +39,7 @@ class AzureAIStudioConfig(OpenAIConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/base_llm/chat/transformation.py b/litellm/llms/base_llm/chat/transformation.py index 5279a44201..fa278c805e 100644 --- a/litellm/llms/base_llm/chat/transformation.py +++ b/litellm/llms/base_llm/chat/transformation.py @@ -262,6 +262,7 @@ class BaseConfig(ABC): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/base_llm/files/transformation.py b/litellm/llms/base_llm/files/transformation.py index 0f1f46352f..9925004c89 100644 --- a/litellm/llms/base_llm/files/transformation.py +++ b/litellm/llms/base_llm/files/transformation.py @@ -1,5 +1,5 @@ from abc import abstractmethod -from typing import TYPE_CHECKING, Any, List, Optional +from typing import TYPE_CHECKING, Any, List, Optional, Union import httpx @@ -33,23 +33,22 @@ class BaseFilesConfig(BaseConfig): ) -> List[OpenAICreateFileRequestOptionalParams]: pass - def get_complete_url( + def get_complete_file_url( self, api_base: Optional[str], api_key: Optional[str], model: str, optional_params: dict, litellm_params: dict, - stream: Optional[bool] = None, - ) -> str: - """ - OPTIONAL - - Get the complete url for the request - - Some providers need `model` in `api_base` - """ - return api_base or "" + data: CreateFileRequest, + ): + return self.get_complete_url( + api_base=api_base, + api_key=api_key, + model=model, + optional_params=optional_params, + litellm_params=litellm_params, + ) @abstractmethod def transform_create_file_request( @@ -58,7 +57,7 @@ class BaseFilesConfig(BaseConfig): create_file_data: CreateFileRequest, optional_params: dict, litellm_params: dict, - ) -> dict: + ) -> Union[dict, str, bytes]: pass @abstractmethod diff --git a/litellm/llms/base_llm/image_variations/transformation.py b/litellm/llms/base_llm/image_variations/transformation.py index 3ed446a84e..60444d0fb7 100644 --- a/litellm/llms/base_llm/image_variations/transformation.py +++ b/litellm/llms/base_llm/image_variations/transformation.py @@ -65,6 +65,7 @@ class BaseImageVariationConfig(BaseConfig, ABC): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 8ce2c4818b..fbe2dc4937 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -831,6 +831,7 @@ class AmazonConverseConfig(BaseConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/bedrock/chat/invoke_transformations/base_invoke_transformation.py b/litellm/llms/bedrock/chat/invoke_transformations/base_invoke_transformation.py index cb12f779cc..67194e83e7 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/base_invoke_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/base_invoke_transformation.py @@ -442,6 +442,7 @@ class AmazonInvokeConfig(BaseConfig, BaseAWSLLM): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/clarifai/chat/transformation.py b/litellm/llms/clarifai/chat/transformation.py index 916da73883..73be89fc6e 100644 --- a/litellm/llms/clarifai/chat/transformation.py +++ b/litellm/llms/clarifai/chat/transformation.py @@ -118,6 +118,7 @@ class ClarifaiConfig(BaseConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/cloudflare/chat/transformation.py b/litellm/llms/cloudflare/chat/transformation.py index 1874bb5115..9e59782bf7 100644 --- a/litellm/llms/cloudflare/chat/transformation.py +++ b/litellm/llms/cloudflare/chat/transformation.py @@ -60,6 +60,7 @@ class CloudflareChatConfig(BaseConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/cohere/chat/transformation.py b/litellm/llms/cohere/chat/transformation.py index 70677214a7..5dd44aca80 100644 --- a/litellm/llms/cohere/chat/transformation.py +++ b/litellm/llms/cohere/chat/transformation.py @@ -118,6 +118,7 @@ class CohereChatConfig(BaseConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/cohere/completion/transformation.py b/litellm/llms/cohere/completion/transformation.py index bdfcda020e..f96ef89d3c 100644 --- a/litellm/llms/cohere/completion/transformation.py +++ b/litellm/llms/cohere/completion/transformation.py @@ -101,6 +101,7 @@ class CohereTextConfig(BaseConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/custom_httpx/aiohttp_handler.py b/litellm/llms/custom_httpx/aiohttp_handler.py index 72092cf261..13141fc19a 100644 --- a/litellm/llms/custom_httpx/aiohttp_handler.py +++ b/litellm/llms/custom_httpx/aiohttp_handler.py @@ -229,6 +229,7 @@ class BaseLLMAIOHTTPHandler: model=model, messages=messages, optional_params=optional_params, + litellm_params=litellm_params, api_base=api_base, ) @@ -498,6 +499,7 @@ class BaseLLMAIOHTTPHandler: model=model, messages=[{"role": "user", "content": "test"}], optional_params=optional_params, + litellm_params=litellm_params, api_base=api_base, ) diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index 23d7fe4b4d..f1aa5627dc 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -192,7 +192,7 @@ class AsyncHTTPHandler: async def post( self, url: str, - data: Optional[Union[dict, str]] = None, # type: ignore + data: Optional[Union[dict, str, bytes]] = None, # type: ignore json: Optional[dict] = None, params: Optional[dict] = None, headers: Optional[dict] = None, @@ -427,7 +427,7 @@ class AsyncHTTPHandler: self, url: str, client: httpx.AsyncClient, - data: Optional[Union[dict, str]] = None, # type: ignore + data: Optional[Union[dict, str, bytes]] = None, # type: ignore json: Optional[dict] = None, params: Optional[dict] = None, headers: Optional[dict] = None, @@ -527,7 +527,7 @@ class HTTPHandler: def post( self, url: str, - data: Optional[Union[dict, str]] = None, + data: Optional[Union[dict, str, bytes]] = None, json: Optional[Union[dict, str, List]] = None, params: Optional[dict] = None, headers: Optional[dict] = None, @@ -573,7 +573,6 @@ class HTTPHandler: setattr(e, "text", error_text) setattr(e, "status_code", e.response.status_code) - raise e except Exception as e: raise e diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 5778f0228f..b7c72e89ef 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -247,6 +247,7 @@ class BaseLLMHTTPHandler: messages=messages, optional_params=optional_params, api_base=api_base, + litellm_params=litellm_params, ) api_base = provider_config.get_complete_url( @@ -625,6 +626,7 @@ class BaseLLMHTTPHandler: model=model, messages=[], optional_params=optional_params, + litellm_params=litellm_params, ) api_base = provider_config.get_complete_url( @@ -896,6 +898,7 @@ class BaseLLMHTTPHandler: model=model, messages=[], optional_params=optional_params, + litellm_params=litellm_params, ) if client is None or not isinstance(client, HTTPHandler): @@ -1228,15 +1231,19 @@ class BaseLLMHTTPHandler: model="", messages=[], optional_params={}, + litellm_params=litellm_params, ) - api_base = provider_config.get_complete_url( + api_base = provider_config.get_complete_file_url( api_base=api_base, api_key=api_key, model="", optional_params={}, litellm_params=litellm_params, + data=create_file_data, ) + if api_base is None: + raise ValueError("api_base is required for create_file") # Get the transformed request data for both steps transformed_request = provider_config.transform_create_file_request( @@ -1263,48 +1270,57 @@ class BaseLLMHTTPHandler: else: sync_httpx_client = client - try: - # Step 1: Initial request to get upload URL - initial_response = sync_httpx_client.post( - url=api_base, - headers={ - **headers, - **transformed_request["initial_request"]["headers"], - }, - data=json.dumps(transformed_request["initial_request"]["data"]), - timeout=timeout, - ) - - # Extract upload URL from response headers - upload_url = initial_response.headers.get("X-Goog-Upload-URL") - - if not upload_url: - raise ValueError("Failed to get upload URL from initial request") - - # Step 2: Upload the actual file + if isinstance(transformed_request, str) or isinstance( + transformed_request, bytes + ): upload_response = sync_httpx_client.post( - url=upload_url, - headers=transformed_request["upload_request"]["headers"], - data=transformed_request["upload_request"]["data"], + url=api_base, + headers=headers, + data=transformed_request, timeout=timeout, ) + else: + try: + # Step 1: Initial request to get upload URL + initial_response = sync_httpx_client.post( + url=api_base, + headers={ + **headers, + **transformed_request["initial_request"]["headers"], + }, + data=json.dumps(transformed_request["initial_request"]["data"]), + timeout=timeout, + ) - return provider_config.transform_create_file_response( - model=None, - raw_response=upload_response, - logging_obj=logging_obj, - litellm_params=litellm_params, - ) + # Extract upload URL from response headers + upload_url = initial_response.headers.get("X-Goog-Upload-URL") - except Exception as e: - raise self._handle_error( - e=e, - provider_config=provider_config, - ) + if not upload_url: + raise ValueError("Failed to get upload URL from initial request") + + # Step 2: Upload the actual file + upload_response = sync_httpx_client.post( + url=upload_url, + headers=transformed_request["upload_request"]["headers"], + data=transformed_request["upload_request"]["data"], + timeout=timeout, + ) + except Exception as e: + raise self._handle_error( + e=e, + provider_config=provider_config, + ) + + return provider_config.transform_create_file_response( + model=None, + raw_response=upload_response, + logging_obj=logging_obj, + litellm_params=litellm_params, + ) async def async_create_file( self, - transformed_request: dict, + transformed_request: Union[bytes, str, dict], litellm_params: dict, provider_config: BaseFilesConfig, headers: dict, @@ -1323,45 +1339,54 @@ class BaseLLMHTTPHandler: else: async_httpx_client = client - try: - # Step 1: Initial request to get upload URL - initial_response = await async_httpx_client.post( - url=api_base, - headers={ - **headers, - **transformed_request["initial_request"]["headers"], - }, - data=json.dumps(transformed_request["initial_request"]["data"]), - timeout=timeout, - ) - - # Extract upload URL from response headers - upload_url = initial_response.headers.get("X-Goog-Upload-URL") - - if not upload_url: - raise ValueError("Failed to get upload URL from initial request") - - # Step 2: Upload the actual file + if isinstance(transformed_request, str) or isinstance( + transformed_request, bytes + ): upload_response = await async_httpx_client.post( - url=upload_url, - headers=transformed_request["upload_request"]["headers"], - data=transformed_request["upload_request"]["data"], + url=api_base, + headers=headers, + data=transformed_request, timeout=timeout, ) + else: + try: + # Step 1: Initial request to get upload URL + initial_response = await async_httpx_client.post( + url=api_base, + headers={ + **headers, + **transformed_request["initial_request"]["headers"], + }, + data=json.dumps(transformed_request["initial_request"]["data"]), + timeout=timeout, + ) - return provider_config.transform_create_file_response( - model=None, - raw_response=upload_response, - logging_obj=logging_obj, - litellm_params=litellm_params, - ) + # Extract upload URL from response headers + upload_url = initial_response.headers.get("X-Goog-Upload-URL") - except Exception as e: - verbose_logger.exception(f"Error creating file: {e}") - raise self._handle_error( - e=e, - provider_config=provider_config, - ) + if not upload_url: + raise ValueError("Failed to get upload URL from initial request") + + # Step 2: Upload the actual file + upload_response = await async_httpx_client.post( + url=upload_url, + headers=transformed_request["upload_request"]["headers"], + data=transformed_request["upload_request"]["data"], + timeout=timeout, + ) + except Exception as e: + verbose_logger.exception(f"Error creating file: {e}") + raise self._handle_error( + e=e, + provider_config=provider_config, + ) + + return provider_config.transform_create_file_response( + model=None, + raw_response=upload_response, + logging_obj=logging_obj, + litellm_params=litellm_params, + ) def list_files(self): """ diff --git a/litellm/llms/databricks/chat/transformation.py b/litellm/llms/databricks/chat/transformation.py index 1940f09608..6f5738fb4b 100644 --- a/litellm/llms/databricks/chat/transformation.py +++ b/litellm/llms/databricks/chat/transformation.py @@ -116,6 +116,7 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/deepgram/audio_transcription/transformation.py b/litellm/llms/deepgram/audio_transcription/transformation.py index b4803576e0..f1b18808f7 100644 --- a/litellm/llms/deepgram/audio_transcription/transformation.py +++ b/litellm/llms/deepgram/audio_transcription/transformation.py @@ -171,6 +171,7 @@ class DeepgramAudioTranscriptionConfig(BaseAudioTranscriptionConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/fireworks_ai/common_utils.py b/litellm/llms/fireworks_ai/common_utils.py index 293403b133..17aa67b525 100644 --- a/litellm/llms/fireworks_ai/common_utils.py +++ b/litellm/llms/fireworks_ai/common_utils.py @@ -41,6 +41,7 @@ class FireworksAIMixin: model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/gemini/common_utils.py b/litellm/llms/gemini/common_utils.py index ace24e982f..fef41f7d58 100644 --- a/litellm/llms/gemini/common_utils.py +++ b/litellm/llms/gemini/common_utils.py @@ -20,6 +20,7 @@ class GeminiModelInfo(BaseLLMModelInfo): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/gemini/files/transformation.py b/litellm/llms/gemini/files/transformation.py index a1f99c6903..e98e76dabc 100644 --- a/litellm/llms/gemini/files/transformation.py +++ b/litellm/llms/gemini/files/transformation.py @@ -4,11 +4,12 @@ Supports writing files to Google AI Studio Files API. For vertex ai, check out the vertex_ai/files/handler.py file. """ import time -from typing import List, Mapping, Optional +from typing import List, Optional import httpx from litellm._logging import verbose_logger +from litellm.litellm_core_utils.prompt_templates.common_utils import extract_file_data from litellm.llms.base_llm.files.transformation import ( BaseFilesConfig, LiteLLMLoggingObj, @@ -91,66 +92,28 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): if file_data is None: raise ValueError("File data is required") - # Parse the file_data based on its type - filename = None - file_content = None - content_type = None - file_headers: Mapping[str, str] = {} - - if isinstance(file_data, tuple): - if len(file_data) == 2: - filename, file_content = file_data - elif len(file_data) == 3: - filename, file_content, content_type = file_data - elif len(file_data) == 4: - filename, file_content, content_type, file_headers = file_data - else: - file_content = file_data - - # Handle the file content based on its type - import io - from os import PathLike - - # Convert content to bytes - if isinstance(file_content, (str, PathLike)): - # If it's a path, open and read the file - with open(file_content, "rb") as f: - content = f.read() - elif isinstance(file_content, io.IOBase): - # If it's a file-like object - content = file_content.read() - if isinstance(content, str): - content = content.encode("utf-8") - elif isinstance(file_content, bytes): - content = file_content - else: - raise ValueError(f"Unsupported file content type: {type(file_content)}") + # Use the common utility function to extract file data + extracted_data = extract_file_data(file_data) # Get file size - file_size = len(content) - - # Use provided content type or guess based on filename - if not content_type: - import mimetypes - - content_type = ( - mimetypes.guess_type(filename)[0] - if filename - else "application/octet-stream" - ) + file_size = len(extracted_data["content"]) # Step 1: Initial resumable upload request headers = { "X-Goog-Upload-Protocol": "resumable", "X-Goog-Upload-Command": "start", "X-Goog-Upload-Header-Content-Length": str(file_size), - "X-Goog-Upload-Header-Content-Type": content_type, + "X-Goog-Upload-Header-Content-Type": extracted_data["content_type"], "Content-Type": "application/json", } - headers.update(file_headers) # Add any custom headers + headers.update(extracted_data["headers"]) # Add any custom headers # Initial metadata request body - initial_data = {"file": {"display_name": filename or str(int(time.time()))}} + initial_data = { + "file": { + "display_name": extracted_data["filename"] or str(int(time.time())) + } + } # Step 2: Actual file upload data upload_headers = { @@ -161,7 +124,10 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): return { "initial_request": {"headers": headers, "data": initial_data}, - "upload_request": {"headers": upload_headers, "data": content}, + "upload_request": { + "headers": upload_headers, + "data": extracted_data["content"], + }, } def transform_create_file_response( diff --git a/litellm/llms/huggingface/chat/transformation.py b/litellm/llms/huggingface/chat/transformation.py index c84f03ab93..0ad93be763 100644 --- a/litellm/llms/huggingface/chat/transformation.py +++ b/litellm/llms/huggingface/chat/transformation.py @@ -1,6 +1,6 @@ import logging import os -from typing import TYPE_CHECKING, Any, List, Optional, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union import httpx @@ -18,7 +18,6 @@ from litellm.llms.base_llm.chat.transformation import BaseLLMException from ...openai.chat.gpt_transformation import OpenAIGPTConfig from ..common_utils import HuggingFaceError, _fetch_inference_provider_mapping - logger = logging.getLogger(__name__) BASE_URL = "https://router.huggingface.co" @@ -34,7 +33,8 @@ class HuggingFaceChatConfig(OpenAIGPTConfig): headers: dict, model: str, messages: List[AllMessageValues], - optional_params: dict, + optional_params: Dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: @@ -51,7 +51,9 @@ class HuggingFaceChatConfig(OpenAIGPTConfig): def get_error_class( self, error_message: str, status_code: int, headers: Union[dict, httpx.Headers] ) -> BaseLLMException: - return HuggingFaceError(status_code=status_code, message=error_message, headers=headers) + return HuggingFaceError( + status_code=status_code, message=error_message, headers=headers + ) def get_base_url(self, model: str, base_url: Optional[str]) -> Optional[str]: """ @@ -82,7 +84,9 @@ class HuggingFaceChatConfig(OpenAIGPTConfig): if api_base is not None: complete_url = api_base elif os.getenv("HF_API_BASE") or os.getenv("HUGGINGFACE_API_BASE"): - complete_url = str(os.getenv("HF_API_BASE")) or str(os.getenv("HUGGINGFACE_API_BASE")) + complete_url = str(os.getenv("HF_API_BASE")) or str( + os.getenv("HUGGINGFACE_API_BASE") + ) elif model.startswith(("http://", "https://")): complete_url = model # 4. Default construction with provider @@ -138,4 +142,8 @@ class HuggingFaceChatConfig(OpenAIGPTConfig): ) mapped_model = provider_mapping["providerId"] messages = self._transform_messages(messages=messages, model=mapped_model) - return dict(ChatCompletionRequest(model=mapped_model, messages=messages, **optional_params)) + return dict( + ChatCompletionRequest( + model=mapped_model, messages=messages, **optional_params + ) + ) diff --git a/litellm/llms/huggingface/embedding/handler.py b/litellm/llms/huggingface/embedding/handler.py index 7277fbd0e3..bfd73c1346 100644 --- a/litellm/llms/huggingface/embedding/handler.py +++ b/litellm/llms/huggingface/embedding/handler.py @@ -1,15 +1,6 @@ import json import os -from typing import ( - Any, - Callable, - Dict, - List, - Literal, - Optional, - Union, - get_args, -) +from typing import Any, Callable, Dict, List, Literal, Optional, Union, get_args import httpx @@ -35,8 +26,9 @@ hf_tasks_embeddings = Literal[ # pipeline tags + hf tei endpoints - https://hug ] - -def get_hf_task_embedding_for_model(model: str, task_type: Optional[str], api_base: str) -> Optional[str]: +def get_hf_task_embedding_for_model( + model: str, task_type: Optional[str], api_base: str +) -> Optional[str]: if task_type is not None: if task_type in get_args(hf_tasks_embeddings): return task_type @@ -57,7 +49,9 @@ def get_hf_task_embedding_for_model(model: str, task_type: Optional[str], api_ba return pipeline_tag -async def async_get_hf_task_embedding_for_model(model: str, task_type: Optional[str], api_base: str) -> Optional[str]: +async def async_get_hf_task_embedding_for_model( + model: str, task_type: Optional[str], api_base: str +) -> Optional[str]: if task_type is not None: if task_type in get_args(hf_tasks_embeddings): return task_type @@ -116,7 +110,9 @@ class HuggingFaceEmbedding(BaseLLM): input: List, optional_params: dict, ) -> dict: - hf_task = await async_get_hf_task_embedding_for_model(model=model, task_type=task_type, api_base=HF_HUB_URL) + hf_task = await async_get_hf_task_embedding_for_model( + model=model, task_type=task_type, api_base=HF_HUB_URL + ) data = self._transform_input_on_pipeline_tag(input=input, pipeline_tag=hf_task) @@ -173,7 +169,9 @@ class HuggingFaceEmbedding(BaseLLM): task_type = optional_params.pop("input_type", None) if call_type == "sync": - hf_task = get_hf_task_embedding_for_model(model=model, task_type=task_type, api_base=HF_HUB_URL) + hf_task = get_hf_task_embedding_for_model( + model=model, task_type=task_type, api_base=HF_HUB_URL + ) elif call_type == "async": return self._async_transform_input( model=model, task_type=task_type, embed_url=embed_url, input=input @@ -325,6 +323,7 @@ class HuggingFaceEmbedding(BaseLLM): input: list, model_response: EmbeddingResponse, optional_params: dict, + litellm_params: dict, logging_obj: LiteLLMLoggingObj, encoding: Callable, api_key: Optional[str] = None, @@ -341,9 +340,12 @@ class HuggingFaceEmbedding(BaseLLM): model=model, optional_params=optional_params, messages=[], + litellm_params=litellm_params, ) task_type = optional_params.pop("input_type", None) - task = get_hf_task_embedding_for_model(model=model, task_type=task_type, api_base=HF_HUB_URL) + task = get_hf_task_embedding_for_model( + model=model, task_type=task_type, api_base=HF_HUB_URL + ) # print_verbose(f"{model}, {task}") embed_url = "" if "https" in model: @@ -355,7 +357,9 @@ class HuggingFaceEmbedding(BaseLLM): elif "HUGGINGFACE_API_BASE" in os.environ: embed_url = os.getenv("HUGGINGFACE_API_BASE", "") else: - embed_url = f"https://router.huggingface.co/hf-inference/pipeline/{task}/{model}" + embed_url = ( + f"https://router.huggingface.co/hf-inference/pipeline/{task}/{model}" + ) ## ROUTING ## if aembedding is True: diff --git a/litellm/llms/huggingface/embedding/transformation.py b/litellm/llms/huggingface/embedding/transformation.py index f803157768..60bd5dcd61 100644 --- a/litellm/llms/huggingface/embedding/transformation.py +++ b/litellm/llms/huggingface/embedding/transformation.py @@ -355,6 +355,7 @@ class HuggingFaceEmbeddingConfig(BaseConfig): model: str, messages: List[AllMessageValues], optional_params: Dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> Dict: diff --git a/litellm/llms/nlp_cloud/chat/handler.py b/litellm/llms/nlp_cloud/chat/handler.py index b0abdda587..b0563d8b55 100644 --- a/litellm/llms/nlp_cloud/chat/handler.py +++ b/litellm/llms/nlp_cloud/chat/handler.py @@ -36,6 +36,7 @@ def completion( model=model, messages=messages, optional_params=optional_params, + litellm_params=litellm_params, ) ## Load Config diff --git a/litellm/llms/nlp_cloud/chat/transformation.py b/litellm/llms/nlp_cloud/chat/transformation.py index b7967249ab..8037a45832 100644 --- a/litellm/llms/nlp_cloud/chat/transformation.py +++ b/litellm/llms/nlp_cloud/chat/transformation.py @@ -93,6 +93,7 @@ class NLPCloudConfig(BaseConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/ollama/completion/transformation.py b/litellm/llms/ollama/completion/transformation.py index 64544bd269..789b728337 100644 --- a/litellm/llms/ollama/completion/transformation.py +++ b/litellm/llms/ollama/completion/transformation.py @@ -353,6 +353,7 @@ class OllamaConfig(BaseConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/oobabooga/chat/oobabooga.py b/litellm/llms/oobabooga/chat/oobabooga.py index 8829d2233e..5eb68a03d4 100644 --- a/litellm/llms/oobabooga/chat/oobabooga.py +++ b/litellm/llms/oobabooga/chat/oobabooga.py @@ -32,6 +32,7 @@ def completion( model=model, messages=messages, optional_params=optional_params, + litellm_params=litellm_params, ) if "https" in model: completion_url = model @@ -123,6 +124,7 @@ def embedding( model=model, messages=[], optional_params=optional_params, + litellm_params={}, ) response = litellm.module_level_client.post( embeddings_url, headers=headers, json=data diff --git a/litellm/llms/oobabooga/chat/transformation.py b/litellm/llms/oobabooga/chat/transformation.py index 6fd56f934e..e87b70130c 100644 --- a/litellm/llms/oobabooga/chat/transformation.py +++ b/litellm/llms/oobabooga/chat/transformation.py @@ -88,6 +88,7 @@ class OobaboogaConfig(OpenAIGPTConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/openai/chat/gpt_transformation.py b/litellm/llms/openai/chat/gpt_transformation.py index fcab43901a..434214639e 100644 --- a/litellm/llms/openai/chat/gpt_transformation.py +++ b/litellm/llms/openai/chat/gpt_transformation.py @@ -321,6 +321,7 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/openai/openai.py b/litellm/llms/openai/openai.py index 3b6be1a034..13412ef96a 100644 --- a/litellm/llms/openai/openai.py +++ b/litellm/llms/openai/openai.py @@ -286,6 +286,7 @@ class OpenAIConfig(BaseConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/openai/transcriptions/whisper_transformation.py b/litellm/llms/openai/transcriptions/whisper_transformation.py index 2d3d611dac..c0ccc71579 100644 --- a/litellm/llms/openai/transcriptions/whisper_transformation.py +++ b/litellm/llms/openai/transcriptions/whisper_transformation.py @@ -53,6 +53,7 @@ class OpenAIWhisperAudioTranscriptionConfig(BaseAudioTranscriptionConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/petals/completion/transformation.py b/litellm/llms/petals/completion/transformation.py index a9e37d27fc..24910cba8f 100644 --- a/litellm/llms/petals/completion/transformation.py +++ b/litellm/llms/petals/completion/transformation.py @@ -131,6 +131,7 @@ class PetalsConfig(BaseConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/predibase/chat/handler.py b/litellm/llms/predibase/chat/handler.py index cd80fa53e4..79936764ac 100644 --- a/litellm/llms/predibase/chat/handler.py +++ b/litellm/llms/predibase/chat/handler.py @@ -228,10 +228,10 @@ class PredibaseChatCompletion: api_key: str, logging_obj, optional_params: dict, + litellm_params: dict, tenant_id: str, timeout: Union[float, httpx.Timeout], acompletion=None, - litellm_params=None, logger_fn=None, headers: dict = {}, ) -> Union[ModelResponse, CustomStreamWrapper]: @@ -241,6 +241,7 @@ class PredibaseChatCompletion: messages=messages, optional_params=optional_params, model=model, + litellm_params=litellm_params, ) completion_url = "" input_text = "" diff --git a/litellm/llms/predibase/chat/transformation.py b/litellm/llms/predibase/chat/transformation.py index 8ef0eea173..9fbb9d6c9e 100644 --- a/litellm/llms/predibase/chat/transformation.py +++ b/litellm/llms/predibase/chat/transformation.py @@ -164,6 +164,7 @@ class PredibaseConfig(BaseConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/replicate/chat/handler.py b/litellm/llms/replicate/chat/handler.py index d954416381..e4bb64fed7 100644 --- a/litellm/llms/replicate/chat/handler.py +++ b/litellm/llms/replicate/chat/handler.py @@ -141,6 +141,7 @@ def completion( model=model, messages=messages, optional_params=optional_params, + litellm_params=litellm_params, ) # Start a prediction and get the prediction URL version_id = replicate_config.model_to_version_id(model) diff --git a/litellm/llms/replicate/chat/transformation.py b/litellm/llms/replicate/chat/transformation.py index 604e6eefe6..4c61086801 100644 --- a/litellm/llms/replicate/chat/transformation.py +++ b/litellm/llms/replicate/chat/transformation.py @@ -312,6 +312,7 @@ class ReplicateConfig(BaseConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/sagemaker/completion/handler.py b/litellm/llms/sagemaker/completion/handler.py index 296689c31c..ebd96ac5b1 100644 --- a/litellm/llms/sagemaker/completion/handler.py +++ b/litellm/llms/sagemaker/completion/handler.py @@ -96,6 +96,7 @@ class SagemakerLLM(BaseAWSLLM): model: str, data: dict, messages: List[AllMessageValues], + litellm_params: dict, optional_params: dict, aws_region_name: str, extra_headers: Optional[dict] = None, @@ -122,6 +123,7 @@ class SagemakerLLM(BaseAWSLLM): model=model, messages=messages, optional_params=optional_params, + litellm_params=litellm_params, ) request = AWSRequest( method="POST", url=api_base, data=encoded_data, headers=headers @@ -198,6 +200,7 @@ class SagemakerLLM(BaseAWSLLM): data=data, messages=messages, optional_params=optional_params, + litellm_params=litellm_params, credentials=credentials, aws_region_name=aws_region_name, ) @@ -274,6 +277,7 @@ class SagemakerLLM(BaseAWSLLM): "model": model, "data": _data, "optional_params": optional_params, + "litellm_params": litellm_params, "credentials": credentials, "aws_region_name": aws_region_name, "messages": messages, @@ -426,6 +430,7 @@ class SagemakerLLM(BaseAWSLLM): "model": model, "data": data, "optional_params": optional_params, + "litellm_params": litellm_params, "credentials": credentials, "aws_region_name": aws_region_name, "messages": messages, @@ -496,6 +501,7 @@ class SagemakerLLM(BaseAWSLLM): "model": model, "data": data, "optional_params": optional_params, + "litellm_params": litellm_params, "credentials": credentials, "aws_region_name": aws_region_name, "messages": messages, diff --git a/litellm/llms/sagemaker/completion/transformation.py b/litellm/llms/sagemaker/completion/transformation.py index df3d028c99..bfc0b6e5f6 100644 --- a/litellm/llms/sagemaker/completion/transformation.py +++ b/litellm/llms/sagemaker/completion/transformation.py @@ -263,6 +263,7 @@ class SagemakerConfig(BaseConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/snowflake/chat/transformation.py b/litellm/llms/snowflake/chat/transformation.py index 574c4704cd..2b92911b05 100644 --- a/litellm/llms/snowflake/chat/transformation.py +++ b/litellm/llms/snowflake/chat/transformation.py @@ -92,6 +92,7 @@ class SnowflakeConfig(OpenAIGPTConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/topaz/image_variations/transformation.py b/litellm/llms/topaz/image_variations/transformation.py index 4d14f1ad24..afbd89b9bc 100644 --- a/litellm/llms/topaz/image_variations/transformation.py +++ b/litellm/llms/topaz/image_variations/transformation.py @@ -37,6 +37,7 @@ class TopazImageVariationConfig(BaseImageVariationConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/triton/completion/transformation.py b/litellm/llms/triton/completion/transformation.py index 49126917f2..21fcf2eefb 100644 --- a/litellm/llms/triton/completion/transformation.py +++ b/litellm/llms/triton/completion/transformation.py @@ -48,6 +48,7 @@ class TritonConfig(BaseConfig): model: str, messages: List[AllMessageValues], optional_params: Dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> Dict: diff --git a/litellm/llms/triton/embedding/transformation.py b/litellm/llms/triton/embedding/transformation.py index 4744ec0834..8ab0277e36 100644 --- a/litellm/llms/triton/embedding/transformation.py +++ b/litellm/llms/triton/embedding/transformation.py @@ -42,6 +42,7 @@ class TritonEmbeddingConfig(BaseEmbeddingConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/vertex_ai/files/handler.py b/litellm/llms/vertex_ai/files/handler.py index 87c1cb8320..a666a2c37f 100644 --- a/litellm/llms/vertex_ai/files/handler.py +++ b/litellm/llms/vertex_ai/files/handler.py @@ -1,3 +1,4 @@ +import asyncio from typing import Any, Coroutine, Optional, Union import httpx @@ -11,9 +12,9 @@ from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.types.llms.openai import CreateFileRequest, OpenAIFileObject from litellm.types.llms.vertex_ai import VERTEX_CREDENTIALS_TYPES -from .transformation import VertexAIFilesTransformation +from .transformation import VertexAIJsonlFilesTransformation -vertex_ai_files_transformation = VertexAIFilesTransformation() +vertex_ai_files_transformation = VertexAIJsonlFilesTransformation() class VertexAIFilesHandler(GCSBucketBase): @@ -92,5 +93,15 @@ class VertexAIFilesHandler(GCSBucketBase): timeout=timeout, max_retries=max_retries, ) - - return None # type: ignore + else: + return asyncio.run( + self.async_create_file( + create_file_data=create_file_data, + api_base=api_base, + vertex_credentials=vertex_credentials, + vertex_project=vertex_project, + vertex_location=vertex_location, + timeout=timeout, + max_retries=max_retries, + ) + ) diff --git a/litellm/llms/vertex_ai/files/transformation.py b/litellm/llms/vertex_ai/files/transformation.py index 89c6ff9deb..c795367e48 100644 --- a/litellm/llms/vertex_ai/files/transformation.py +++ b/litellm/llms/vertex_ai/files/transformation.py @@ -1,7 +1,17 @@ import json +import os +import time import uuid from typing import Any, Dict, List, Optional, Tuple, Union +from httpx import Headers, Response + +from litellm.litellm_core_utils.prompt_templates.common_utils import extract_file_data +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.llms.base_llm.files.transformation import ( + BaseFilesConfig, + LiteLLMLoggingObj, +) from litellm.llms.vertex_ai.common_utils import ( _convert_vertex_datetime_to_openai_datetime, ) @@ -10,14 +20,317 @@ from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( VertexGeminiConfig, ) from litellm.types.llms.openai import ( + AllMessageValues, CreateFileRequest, FileTypes, + OpenAICreateFileRequestOptionalParams, OpenAIFileObject, PathLike, ) +from litellm.types.llms.vertex_ai import GcsBucketResponse +from litellm.types.utils import ExtractedFileData, LlmProviders + +from ..common_utils import VertexAIError +from ..vertex_llm_base import VertexBase -class VertexAIFilesTransformation(VertexGeminiConfig): +class VertexAIFilesConfig(VertexBase, BaseFilesConfig): + """ + Config for VertexAI Files + """ + + def __init__(self): + self.jsonl_transformation = VertexAIJsonlFilesTransformation() + super().__init__() + + @property + def custom_llm_provider(self) -> LlmProviders: + return LlmProviders.VERTEX_AI + + def validate_environment( + self, + headers: dict, + model: str, + messages: List[AllMessageValues], + optional_params: dict, + litellm_params: dict, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + ) -> dict: + if not api_key: + api_key, _ = self.get_access_token( + credentials=litellm_params.get("vertex_credentials"), + project_id=litellm_params.get("vertex_project"), + ) + if not api_key: + raise ValueError("api_key is required") + headers["Authorization"] = f"Bearer {api_key}" + return headers + + def _get_content_from_openai_file(self, openai_file_content: FileTypes) -> str: + """ + Helper to extract content from various OpenAI file types and return as string. + + Handles: + - Direct content (str, bytes, IO[bytes]) + - Tuple formats: (filename, content, [content_type], [headers]) + - PathLike objects + """ + content: Union[str, bytes] = b"" + # Extract file content from tuple if necessary + if isinstance(openai_file_content, tuple): + # Take the second element which is always the file content + file_content = openai_file_content[1] + else: + file_content = openai_file_content + + # Handle different file content types + if isinstance(file_content, str): + # String content can be used directly + content = file_content + elif isinstance(file_content, bytes): + # Bytes content can be decoded + content = file_content + elif isinstance(file_content, PathLike): # PathLike + with open(str(file_content), "rb") as f: + content = f.read() + elif hasattr(file_content, "read"): # IO[bytes] + # File-like objects need to be read + content = file_content.read() + + # Ensure content is string + if isinstance(content, bytes): + content = content.decode("utf-8") + + return content + + def _get_gcs_object_name_from_batch_jsonl( + self, + openai_jsonl_content: List[Dict[str, Any]], + ) -> str: + """ + Gets a unique GCS object name for the VertexAI batch prediction job + + named as: litellm-vertex-{model}-{uuid} + """ + _model = openai_jsonl_content[0].get("body", {}).get("model", "") + if "publishers/google/models" not in _model: + _model = f"publishers/google/models/{_model}" + object_name = f"litellm-vertex-files/{_model}/{uuid.uuid4()}" + return object_name + + def get_object_name( + self, extracted_file_data: ExtractedFileData, purpose: str + ) -> str: + """ + Get the object name for the request + """ + extracted_file_data_content = extracted_file_data.get("content") + + if extracted_file_data_content is None: + raise ValueError("file content is required") + + if purpose == "batch": + ## 1. If jsonl, check if there's a model name + file_content = self._get_content_from_openai_file( + extracted_file_data_content + ) + + # Split into lines and parse each line as JSON + openai_jsonl_content = [ + json.loads(line) for line in file_content.splitlines() if line.strip() + ] + if len(openai_jsonl_content) > 0: + return self._get_gcs_object_name_from_batch_jsonl(openai_jsonl_content) + + ## 2. If not jsonl, return the filename + filename = extracted_file_data.get("filename") + if filename: + return filename + ## 3. If no file name, return timestamp + return str(int(time.time())) + + def get_complete_file_url( + self, + api_base: Optional[str], + api_key: Optional[str], + model: str, + optional_params: Dict, + litellm_params: Dict, + data: CreateFileRequest, + ) -> str: + """ + Get the complete url for the request + """ + bucket_name = litellm_params.get("bucket_name") or os.getenv("GCS_BUCKET_NAME") + if not bucket_name: + raise ValueError("GCS bucket_name is required") + file_data = data.get("file") + purpose = data.get("purpose") + if file_data is None: + raise ValueError("file is required") + if purpose is None: + raise ValueError("purpose is required") + extracted_file_data = extract_file_data(file_data) + object_name = self.get_object_name(extracted_file_data, purpose) + endpoint = ( + f"upload/storage/v1/b/{bucket_name}/o?uploadType=media&name={object_name}" + ) + api_base = api_base or "https://storage.googleapis.com" + if not api_base: + raise ValueError("api_base is required") + + return f"{api_base}/{endpoint}" + + def get_supported_openai_params( + self, model: str + ) -> List[OpenAICreateFileRequestOptionalParams]: + return [] + + def map_openai_params( + self, + non_default_params: dict, + optional_params: dict, + model: str, + drop_params: bool, + ) -> dict: + return optional_params + + def _map_openai_to_vertex_params( + self, + openai_request_body: Dict[str, Any], + ) -> Dict[str, Any]: + """ + wrapper to call VertexGeminiConfig.map_openai_params + """ + from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( + VertexGeminiConfig, + ) + + config = VertexGeminiConfig() + _model = openai_request_body.get("model", "") + vertex_params = config.map_openai_params( + model=_model, + non_default_params=openai_request_body, + optional_params={}, + drop_params=False, + ) + return vertex_params + + def _transform_openai_jsonl_content_to_vertex_ai_jsonl_content( + self, openai_jsonl_content: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: + """ + Transforms OpenAI JSONL content to VertexAI JSONL content + + jsonl body for vertex is {"request": } + Example Vertex jsonl + {"request":{"contents": [{"role": "user", "parts": [{"text": "What is the relation between the following video and image samples?"}, {"fileData": {"fileUri": "gs://cloud-samples-data/generative-ai/video/animals.mp4", "mimeType": "video/mp4"}}, {"fileData": {"fileUri": "gs://cloud-samples-data/generative-ai/image/cricket.jpeg", "mimeType": "image/jpeg"}}]}]}} + {"request":{"contents": [{"role": "user", "parts": [{"text": "Describe what is happening in this video."}, {"fileData": {"fileUri": "gs://cloud-samples-data/generative-ai/video/another_video.mov", "mimeType": "video/mov"}}]}]}} + """ + + vertex_jsonl_content = [] + for _openai_jsonl_content in openai_jsonl_content: + openai_request_body = _openai_jsonl_content.get("body") or {} + vertex_request_body = _transform_request_body( + messages=openai_request_body.get("messages", []), + model=openai_request_body.get("model", ""), + optional_params=self._map_openai_to_vertex_params(openai_request_body), + custom_llm_provider="vertex_ai", + litellm_params={}, + cached_content=None, + ) + vertex_jsonl_content.append({"request": vertex_request_body}) + return vertex_jsonl_content + + def transform_create_file_request( + self, + model: str, + create_file_data: CreateFileRequest, + optional_params: dict, + litellm_params: dict, + ) -> Union[bytes, str, dict]: + """ + 2 Cases: + 1. Handle basic file upload + 2. Handle batch file upload (.jsonl) + """ + file_data = create_file_data.get("file") + if file_data is None: + raise ValueError("file is required") + extracted_file_data = extract_file_data(file_data) + extracted_file_data_content = extracted_file_data.get("content") + if ( + create_file_data.get("purpose") == "batch" + and extracted_file_data.get("content_type") == "application/jsonl" + and extracted_file_data_content is not None + ): + ## 1. If jsonl, check if there's a model name + file_content = self._get_content_from_openai_file( + extracted_file_data_content + ) + + # Split into lines and parse each line as JSON + openai_jsonl_content = [ + json.loads(line) for line in file_content.splitlines() if line.strip() + ] + vertex_jsonl_content = ( + self._transform_openai_jsonl_content_to_vertex_ai_jsonl_content( + openai_jsonl_content + ) + ) + return json.dumps(vertex_jsonl_content) + elif isinstance(extracted_file_data_content, bytes): + return extracted_file_data_content + else: + raise ValueError("Unsupported file content type") + + def transform_create_file_response( + self, + model: Optional[str], + raw_response: Response, + logging_obj: LiteLLMLoggingObj, + litellm_params: dict, + ) -> OpenAIFileObject: + """ + Transform VertexAI File upload response into OpenAI-style FileObject + """ + response_json = raw_response.json() + + try: + response_object = GcsBucketResponse(**response_json) # type: ignore + except Exception as e: + raise VertexAIError( + status_code=raw_response.status_code, + message=f"Error reading GCS bucket response: {e}", + headers=raw_response.headers, + ) + + gcs_id = response_object.get("id", "") + # Remove the last numeric ID from the path + gcs_id = "/".join(gcs_id.split("/")[:-1]) if gcs_id else "" + + return OpenAIFileObject( + purpose=response_object.get("purpose", "batch"), + id=f"gs://{gcs_id}", + filename=response_object.get("name", ""), + created_at=_convert_vertex_datetime_to_openai_datetime( + vertex_datetime=response_object.get("timeCreated", "") + ), + status="uploaded", + bytes=int(response_object.get("size", 0)), + object="file", + ) + + def get_error_class( + self, error_message: str, status_code: int, headers: Union[Dict, Headers] + ) -> BaseLLMException: + return VertexAIError( + status_code=status_code, message=error_message, headers=headers + ) + + +class VertexAIJsonlFilesTransformation(VertexGeminiConfig): """ Transforms OpenAI /v1/files/* requests to VertexAI /v1/files/* requests """ diff --git a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py index e7d3d2b060..b3b7857ea1 100644 --- a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py +++ b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py @@ -905,6 +905,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): model: str, messages: List[AllMessageValues], optional_params: Dict, + litellm_params: Dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> Dict: @@ -1022,7 +1023,7 @@ class VertexLLM(VertexBase): logging_obj, stream, optional_params: dict, - litellm_params=None, + litellm_params: dict, logger_fn=None, api_base: Optional[str] = None, client: Optional[AsyncHTTPHandler] = None, @@ -1063,6 +1064,7 @@ class VertexLLM(VertexBase): model=model, messages=messages, optional_params=optional_params, + litellm_params=litellm_params, ) ## LOGGING @@ -1149,6 +1151,7 @@ class VertexLLM(VertexBase): model=model, messages=messages, optional_params=optional_params, + litellm_params=litellm_params, ) request_body = await async_transform_request_body(**data) # type: ignore @@ -1322,6 +1325,7 @@ class VertexLLM(VertexBase): model=model, messages=messages, optional_params=optional_params, + litellm_params=litellm_params, ) ## TRANSFORMATION ## diff --git a/litellm/llms/vertex_ai/multimodal_embeddings/embedding_handler.py b/litellm/llms/vertex_ai/multimodal_embeddings/embedding_handler.py index 88d7339449..8aebd83cc4 100644 --- a/litellm/llms/vertex_ai/multimodal_embeddings/embedding_handler.py +++ b/litellm/llms/vertex_ai/multimodal_embeddings/embedding_handler.py @@ -94,6 +94,7 @@ class VertexMultimodalEmbedding(VertexLLM): optional_params=optional_params, api_key=auth_header, api_base=api_base, + litellm_params=litellm_params, ) ## LOGGING diff --git a/litellm/llms/vertex_ai/multimodal_embeddings/transformation.py b/litellm/llms/vertex_ai/multimodal_embeddings/transformation.py index afa58c7e5c..5bf02ad765 100644 --- a/litellm/llms/vertex_ai/multimodal_embeddings/transformation.py +++ b/litellm/llms/vertex_ai/multimodal_embeddings/transformation.py @@ -47,6 +47,7 @@ class VertexAIMultimodalEmbeddingConfig(BaseEmbeddingConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/vertex_ai/vertex_llm_base.py b/litellm/llms/vertex_ai/vertex_llm_base.py index 994e46b50b..8f3037c791 100644 --- a/litellm/llms/vertex_ai/vertex_llm_base.py +++ b/litellm/llms/vertex_ai/vertex_llm_base.py @@ -10,7 +10,6 @@ from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Tuple from litellm._logging import verbose_logger from litellm.litellm_core_utils.asyncify import asyncify -from litellm.llms.base import BaseLLM from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler from litellm.types.llms.vertex_ai import VERTEX_CREDENTIALS_TYPES @@ -22,7 +21,7 @@ else: GoogleCredentialsObject = Any -class VertexBase(BaseLLM): +class VertexBase: def __init__(self) -> None: super().__init__() self.access_token: Optional[str] = None diff --git a/litellm/llms/voyage/embedding/transformation.py b/litellm/llms/voyage/embedding/transformation.py index df6ef91a41..91811e0392 100644 --- a/litellm/llms/voyage/embedding/transformation.py +++ b/litellm/llms/voyage/embedding/transformation.py @@ -83,6 +83,7 @@ class VoyageEmbeddingConfig(BaseEmbeddingConfig): model: str, messages: List[AllMessageValues], optional_params: dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: diff --git a/litellm/llms/watsonx/chat/handler.py b/litellm/llms/watsonx/chat/handler.py index aeb0167595..45378c5529 100644 --- a/litellm/llms/watsonx/chat/handler.py +++ b/litellm/llms/watsonx/chat/handler.py @@ -49,6 +49,7 @@ class WatsonXChatHandler(OpenAILikeChatHandler): messages=messages, optional_params=optional_params, api_key=api_key, + litellm_params=litellm_params, ) ## UPDATE PAYLOAD (optional params) diff --git a/litellm/llms/watsonx/common_utils.py b/litellm/llms/watsonx/common_utils.py index 4916cd1c75..e13e015add 100644 --- a/litellm/llms/watsonx/common_utils.py +++ b/litellm/llms/watsonx/common_utils.py @@ -165,6 +165,7 @@ class IBMWatsonXMixin: model: str, messages: List[AllMessageValues], optional_params: Dict, + litellm_params: dict, api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> Dict: diff --git a/litellm/main.py b/litellm/main.py index cd7d255e21..3f1d9a1e76 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -3616,6 +3616,7 @@ def embedding( # noqa: PLR0915 optional_params=optional_params, client=client, aembedding=aembedding, + litellm_params=litellm_params_dict, ) elif custom_llm_provider == "bedrock": if isinstance(input, str): diff --git a/litellm/types/llms/vertex_ai.py b/litellm/types/llms/vertex_ai.py index 2e25f259b0..55273371fc 100644 --- a/litellm/types/llms/vertex_ai.py +++ b/litellm/types/llms/vertex_ai.py @@ -498,6 +498,51 @@ class OutputConfig(TypedDict, total=False): gcsDestination: GcsDestination +class GcsBucketResponse(TypedDict): + """ + TypedDict for GCS bucket upload response + + Attributes: + kind: The kind of item this is. For objects, this is always storage#object + id: The ID of the object + selfLink: The link to this object + mediaLink: The link to download the object + name: The name of the object + bucket: The name of the bucket containing this object + generation: The content generation of this object + metageneration: The metadata generation of this object + contentType: The content type of the object + storageClass: The storage class of the object + size: The size of the object in bytes + md5Hash: The MD5 hash of the object + crc32c: The CRC32c checksum of the object + etag: The ETag of the object + timeCreated: The creation time of the object + updated: The last update time of the object + timeStorageClassUpdated: The time the storage class was last updated + timeFinalized: The time the object was finalized + """ + + kind: Literal["storage#object"] + id: str + selfLink: str + mediaLink: str + name: str + bucket: str + generation: str + metageneration: str + contentType: str + storageClass: str + size: str + md5Hash: str + crc32c: str + etag: str + timeCreated: str + updated: str + timeStorageClassUpdated: str + timeFinalized: str + + class VertexAIBatchPredictionJob(TypedDict): displayName: str model: str diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 8439037758..6f0c26d301 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -2,7 +2,7 @@ import json import time import uuid from enum import Enum -from typing import Any, Dict, List, Literal, Optional, Tuple, Union +from typing import Any, Dict, List, Literal, Mapping, Optional, Tuple, Union from aiohttp import FormData from openai._models import BaseModel as OpenAIObject @@ -2170,3 +2170,20 @@ class CreateCredentialItem(CredentialBase): if not values.get("credential_values") and not values.get("model_id"): raise ValueError("Either credential_values or model_id must be set") return values + + +class ExtractedFileData(TypedDict): + """ + TypedDict for storing processed file data + + Attributes: + filename: Name of the file if provided + content: The file content in bytes + content_type: MIME type of the file + headers: Any additional headers for the file + """ + + filename: Optional[str] + content: bytes + content_type: Optional[str] + headers: Mapping[str, str] diff --git a/litellm/utils.py b/litellm/utils.py index f807990f60..f809d8a77b 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -6517,6 +6517,10 @@ class ProviderConfigManager: ) return GoogleAIStudioFilesHandler() + elif LlmProviders.VERTEX_AI == provider: + from litellm.llms.vertex_ai.files.transformation import VertexAIFilesConfig + + return VertexAIFilesConfig() return None diff --git a/tests/batches_tests/test_openai_batches_and_files.py b/tests/batches_tests/test_openai_batches_and_files.py index 4669a2def6..b2826419e8 100644 --- a/tests/batches_tests/test_openai_batches_and_files.py +++ b/tests/batches_tests/test_openai_batches_and_files.py @@ -423,25 +423,35 @@ mock_vertex_batch_response = { @pytest.mark.asyncio -async def test_avertex_batch_prediction(): - with patch( +async def test_avertex_batch_prediction(monkeypatch): + monkeypatch.setenv("GCS_BUCKET_NAME", "litellm-local") + from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler + + client = AsyncHTTPHandler() + + async def mock_side_effect(*args, **kwargs): + print("args", args, "kwargs", kwargs) + url = kwargs.get("url", "") + if "files" in url: + mock_response.json.return_value = mock_file_response + elif "batch" in url: + mock_response.json.return_value = mock_vertex_batch_response + mock_response.status_code = 200 + return mock_response + + with patch.object( + client, "post", side_effect=mock_side_effect + ) as mock_post, patch( "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post" - ) as mock_post: + ) as mock_global_post: # Configure mock responses mock_response = MagicMock() mock_response.raise_for_status.return_value = None # Set up different responses for different API calls - async def mock_side_effect(*args, **kwargs): - url = kwargs.get("url", "") - if "files" in url: - mock_response.json.return_value = mock_file_response - elif "batch" in url: - mock_response.json.return_value = mock_vertex_batch_response - mock_response.status_code = 200 - return mock_response - + mock_post.side_effect = mock_side_effect + mock_global_post.side_effect = mock_side_effect # load_vertex_ai_credentials() litellm.set_verbose = True @@ -455,6 +465,7 @@ async def test_avertex_batch_prediction(): file=open(file_path, "rb"), purpose="batch", custom_llm_provider="vertex_ai", + client=client ) print("Response from creating file=", file_obj) diff --git a/tests/llm_translation/test_huggingface_chat_completion.py b/tests/llm_translation/test_huggingface_chat_completion.py index 9f1e89aeb1..7d498b96df 100644 --- a/tests/llm_translation/test_huggingface_chat_completion.py +++ b/tests/llm_translation/test_huggingface_chat_completion.py @@ -323,7 +323,8 @@ class TestHuggingFace(BaseLLMChatTest): model="huggingface/fireworks-ai/meta-llama/Meta-Llama-3-8B-Instruct", messages=[{"role": "user", "content": "Hello"}], optional_params={}, - api_key="test_api_key" + api_key="test_api_key", + litellm_params={} ) assert headers["Authorization"] == "Bearer test_api_key" diff --git a/tests/local_testing/example.jsonl b/tests/local_testing/example.jsonl new file mode 100644 index 0000000000..fc3ca40808 --- /dev/null +++ b/tests/local_testing/example.jsonl @@ -0,0 +1,2 @@ +{"custom_id": "request-1", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "gemini-1.5-flash-001", "messages": [{"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "Hello world!"}], "max_tokens": 10}} +{"custom_id": "request-2", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "gemini-1.5-flash-001", "messages": [{"role": "system", "content": "You are an unhelpful assistant."}, {"role": "user", "content": "Hello world!"}], "max_tokens": 10}} diff --git a/tests/local_testing/test_gcs_bucket.py b/tests/local_testing/test_gcs_bucket.py index 1a8deed8a8..b64475c227 100644 --- a/tests/local_testing/test_gcs_bucket.py +++ b/tests/local_testing/test_gcs_bucket.py @@ -21,7 +21,7 @@ from litellm.integrations.gcs_bucket.gcs_bucket import ( StandardLoggingPayload, ) from litellm.types.utils import StandardCallbackDynamicParams - +from unittest.mock import patch verbose_logger.setLevel(logging.DEBUG) @@ -687,3 +687,63 @@ async def test_basic_gcs_logger_with_folder_in_bucket_name(): # clean up if old_bucket_name is not None: os.environ["GCS_BUCKET_NAME"] = old_bucket_name + +@pytest.mark.skip(reason="This test is flaky on ci/cd") +def test_create_file_e2e(): + """ + Asserts 'create_file' is called with the correct arguments + """ + load_vertex_ai_credentials() + test_file_content = b"test audio content" + test_file = ("test.wav", test_file_content, "audio/wav") + + from litellm import create_file + response = create_file( + file=test_file, + purpose="user_data", + custom_llm_provider="vertex_ai", + ) + print("response", response) + assert response is not None + +@pytest.mark.skip(reason="This test is flaky on ci/cd") +def test_create_file_e2e_jsonl(): + """ + Asserts 'create_file' is called with the correct arguments + """ + load_vertex_ai_credentials() + from litellm.llms.custom_httpx.http_handler import HTTPHandler + + client = HTTPHandler() + + example_jsonl = [{"custom_id": "request-1", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "gemini-1.5-flash-001", "messages": [{"role": "system", "content": "You are a helpful assistant."},{"role": "user", "content": "Hello world!"}],"max_tokens": 10}},{"custom_id": "request-2", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "gemini-1.5-flash-001", "messages": [{"role": "system", "content": "You are an unhelpful assistant."},{"role": "user", "content": "Hello world!"}],"max_tokens": 10}}] + + # Create and write to the file + file_path = "example.jsonl" + with open(file_path, "w") as f: + for item in example_jsonl: + f.write(json.dumps(item) + "\n") + + # Verify file content + with open(file_path, "r") as f: + content = f.read() + print("File content:", content) + assert len(content) > 0, "File is empty" + + from litellm import create_file + with patch.object(client, "post") as mock_create_file: + try: + response = create_file( + file=open(file_path, "rb"), + purpose="user_data", + custom_llm_provider="vertex_ai", + client=client, + ) + except Exception as e: + print("error", e) + + mock_create_file.assert_called_once() + + print(f"kwargs: {mock_create_file.call_args.kwargs}") + + assert mock_create_file.call_args.kwargs["data"] is not None and len(mock_create_file.call_args.kwargs["data"]) > 0 \ No newline at end of file From 08a3620414d0596a39cf74d8737181f6176b43f0 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Wed, 9 Apr 2025 15:29:20 -0700 Subject: [PATCH 16/28] [Bug Fix] Add support for UploadFile on LLM Pass through endpoints (OpenAI, Azure etc) (#9853) * http passthrough file handling * fix make_multipart_http_request * test_pass_through_file_operations * unit tests for file handling --- .../pass_through_endpoints.py | 125 +++++++++++++++--- .../test_pass_through_endpoints.py | 116 ++++++++++++++++ .../test_openai_assistants_passthrough.py | 19 ++- 3 files changed, 241 insertions(+), 19 deletions(-) create mode 100644 tests/litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index a6b1b3e614..563d0cb543 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -4,16 +4,26 @@ import json import uuid from base64 import b64encode from datetime import datetime -from typing import Dict, List, Optional, Union +from typing import Dict, List, Optional, Tuple, Union from urllib.parse import parse_qs, urlencode, urlparse import httpx -from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from fastapi import ( + APIRouter, + Depends, + HTTPException, + Request, + Response, + UploadFile, + status, +) from fastapi.responses import StreamingResponse +from starlette.datastructures import UploadFile as StarletteUploadFile import litellm from litellm._logging import verbose_proxy_logger from litellm.integrations.custom_logger import CustomLogger +from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.proxy._types import ( ConfigFieldInfo, @@ -358,6 +368,92 @@ class HttpPassThroughEndpointHelpers: ) return response + @staticmethod + async def non_streaming_http_request_handler( + request: Request, + async_client: httpx.AsyncClient, + url: httpx.URL, + headers: dict, + requested_query_params: Optional[dict] = None, + _parsed_body: Optional[dict] = None, + ) -> httpx.Response: + """ + Handle non-streaming HTTP requests + + Handles special cases when GET requests, multipart/form-data requests, and generic httpx requests + """ + if request.method == "GET": + response = await async_client.request( + method=request.method, + url=url, + headers=headers, + params=requested_query_params, + ) + elif HttpPassThroughEndpointHelpers.is_multipart(request) is True: + return await HttpPassThroughEndpointHelpers.make_multipart_http_request( + request=request, + async_client=async_client, + url=url, + headers=headers, + requested_query_params=requested_query_params, + ) + else: + # Generic httpx method + response = await async_client.request( + method=request.method, + url=url, + headers=headers, + params=requested_query_params, + json=_parsed_body, + ) + return response + + @staticmethod + def is_multipart(request: Request) -> bool: + """Check if the request is a multipart/form-data request""" + return "multipart/form-data" in request.headers.get("content-type", "") + + @staticmethod + async def _build_request_files_from_upload_file( + upload_file: Union[UploadFile, StarletteUploadFile], + ) -> Tuple[Optional[str], bytes, Optional[str]]: + """Build a request files dict from an UploadFile object""" + file_content = await upload_file.read() + return (upload_file.filename, file_content, upload_file.content_type) + + @staticmethod + async def make_multipart_http_request( + request: Request, + async_client: httpx.AsyncClient, + url: httpx.URL, + headers: dict, + requested_query_params: Optional[dict] = None, + ) -> httpx.Response: + """Process multipart/form-data requests, handling both files and form fields""" + form_data = await request.form() + files = {} + form_data_dict = {} + + for field_name, field_value in form_data.items(): + if isinstance(field_value, (StarletteUploadFile, UploadFile)): + files[field_name] = ( + await HttpPassThroughEndpointHelpers._build_request_files_from_upload_file( + upload_file=field_value + ) + ) + else: + form_data_dict[field_name] = field_value + + response = await async_client.request( + method=request.method, + url=url, + headers=headers, + params=requested_query_params, + files=files, + data=form_data_dict, + ) + return response + async def pass_through_request( # noqa: PLR0915 request: Request, @@ -424,7 +520,7 @@ async def pass_through_request( # noqa: PLR0915 start_time = datetime.now() logging_obj = Logging( model="unknown", - messages=[{"role": "user", "content": json.dumps(_parsed_body)}], + messages=[{"role": "user", "content": safe_dumps(_parsed_body)}], stream=False, call_type="pass_through_endpoint", start_time=start_time, @@ -453,7 +549,6 @@ async def pass_through_request( # noqa: PLR0915 logging_obj.model_call_details["litellm_call_id"] = litellm_call_id # combine url with query params for logging - requested_query_params: Optional[dict] = ( query_params or request.query_params.__dict__ ) @@ -474,7 +569,7 @@ async def pass_through_request( # noqa: PLR0915 logging_url = str(url) + "?" + requested_query_params_str logging_obj.pre_call( - input=[{"role": "user", "content": json.dumps(_parsed_body)}], + input=[{"role": "user", "content": safe_dumps(_parsed_body)}], api_key="", additional_args={ "complete_input_dict": _parsed_body, @@ -525,22 +620,16 @@ async def pass_through_request( # noqa: PLR0915 ) verbose_proxy_logger.debug("request body: {}".format(_parsed_body)) - if request.method == "GET": - response = await async_client.request( - method=request.method, + response = ( + await HttpPassThroughEndpointHelpers.non_streaming_http_request_handler( + request=request, + async_client=async_client, url=url, headers=headers, - params=requested_query_params, + requested_query_params=requested_query_params, + _parsed_body=_parsed_body, ) - else: - response = await async_client.request( - method=request.method, - url=url, - headers=headers, - params=requested_query_params, - json=_parsed_body, - ) - + ) verbose_proxy_logger.debug("response.headers= %s", response.headers) if _is_streaming_response(response) is True: diff --git a/tests/litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py b/tests/litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py new file mode 100644 index 0000000000..43d4dd9cd8 --- /dev/null +++ b/tests/litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py @@ -0,0 +1,116 @@ +import json +import os +import sys +from io import BytesIO +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest +from fastapi import Request, UploadFile +from fastapi.testclient import TestClient +from starlette.datastructures import Headers +from starlette.datastructures import UploadFile as StarletteUploadFile + +sys.path.insert( + 0, os.path.abspath("../../..") +) # Adds the parent directory to the system path + +from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( + HttpPassThroughEndpointHelpers, +) + + +# Test is_multipart +def test_is_multipart(): + # Test with multipart content type + request = MagicMock(spec=Request) + request.headers = Headers({"content-type": "multipart/form-data; boundary=123"}) + assert HttpPassThroughEndpointHelpers.is_multipart(request) is True + + # Test with non-multipart content type + request.headers = Headers({"content-type": "application/json"}) + assert HttpPassThroughEndpointHelpers.is_multipart(request) is False + + # Test with no content type + request.headers = Headers({}) + assert HttpPassThroughEndpointHelpers.is_multipart(request) is False + + +# Test _build_request_files_from_upload_file +@pytest.mark.asyncio +async def test_build_request_files_from_upload_file(): + # Test with FastAPI UploadFile + file_content = b"test content" + file = BytesIO(file_content) + # Create SpooledTemporaryFile with content type headers + headers = {"content-type": "text/plain"} + upload_file = UploadFile(file=file, filename="test.txt", headers=headers) + upload_file.read = AsyncMock(return_value=file_content) + + result = await HttpPassThroughEndpointHelpers._build_request_files_from_upload_file( + upload_file + ) + assert result == ("test.txt", file_content, "text/plain") + + # Test with Starlette UploadFile + file2 = BytesIO(file_content) + starlette_file = StarletteUploadFile( + file=file2, + filename="test2.txt", + headers=Headers({"content-type": "text/plain"}), + ) + starlette_file.read = AsyncMock(return_value=file_content) + + result = await HttpPassThroughEndpointHelpers._build_request_files_from_upload_file( + starlette_file + ) + assert result == ("test2.txt", file_content, "text/plain") + + +# Test make_multipart_http_request +@pytest.mark.asyncio +async def test_make_multipart_http_request(): + # Mock request with file and form field + request = MagicMock(spec=Request) + request.method = "POST" + + # Mock form data + file_content = b"test file content" + file = BytesIO(file_content) + # Create SpooledTemporaryFile with content type headers + headers = {"content-type": "text/plain"} + upload_file = UploadFile(file=file, filename="test.txt", headers=headers) + upload_file.read = AsyncMock(return_value=file_content) + + form_data = {"file": upload_file, "text_field": "test value"} + request.form = AsyncMock(return_value=form_data) + + # Mock httpx client + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {} + + async_client = MagicMock() + async_client.request = AsyncMock(return_value=mock_response) + + # Test the function + response = await HttpPassThroughEndpointHelpers.make_multipart_http_request( + request=request, + async_client=async_client, + url=httpx.URL("http://test.com"), + headers={}, + requested_query_params=None, + ) + + # Verify the response + assert response == mock_response + + # Verify the client call + async_client.request.assert_called_once() + call_args = async_client.request.call_args[1] + + assert call_args["method"] == "POST" + assert str(call_args["url"]) == "http://test.com" + assert isinstance(call_args["files"], dict) + assert isinstance(call_args["data"], dict) + assert call_args["data"]["text_field"] == "test value" diff --git a/tests/pass_through_tests/test_openai_assistants_passthrough.py b/tests/pass_through_tests/test_openai_assistants_passthrough.py index 694d3c090e..40361ab39f 100644 --- a/tests/pass_through_tests/test_openai_assistants_passthrough.py +++ b/tests/pass_through_tests/test_openai_assistants_passthrough.py @@ -2,14 +2,31 @@ import pytest import openai import aiohttp import asyncio +import tempfile from typing_extensions import override from openai import AssistantEventHandler + client = openai.OpenAI(base_url="http://0.0.0.0:4000/openai", api_key="sk-1234") +def test_pass_through_file_operations(): + # Create a temporary file + with tempfile.NamedTemporaryFile(mode='w+', suffix='.txt', delete=False) as temp_file: + temp_file.write("This is a test file for the OpenAI Assistants API.") + temp_file.flush() + + # create a file + file = client.files.create( + file=open(temp_file.name, "rb"), + purpose="assistants", + ) + print("file created", file) + + # delete the file + delete_file = client.files.delete(file.id) + print("file deleted", delete_file) def test_openai_assistants_e2e_operations(): - assistant = client.beta.assistants.create( name="Math Tutor", instructions="You are a personal math tutor. Write and run code to answer math questions.", From 6f7e9b9728799347a4004a51602157ee07b24e9a Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Wed, 9 Apr 2025 15:29:35 -0700 Subject: [PATCH 17/28] [Feat SSO] Debug route - allow admins to debug SSO JWT fields (#9835) * refactor SSO handler * render sso JWT on ui * docs debug sso * fix sso login flow use await * fix ui sso debug JWT * test ui sso * remove redis vl * fix redisvl==0.5.1 * fix ml dtypes * fix redisvl * fix redis vl * fix debug_sso_callback * fix linting error * fix redis semantic caching dep --- docs/my-website/docs/proxy/self_serve.md | 37 +- docs/my-website/img/debug_sso.png | Bin 0 -> 170731 bytes .../html_forms/jwt_display_template.py | 284 ++++++++ litellm/proxy/management_endpoints/ui_sso.py | 623 +++++++++++++----- .../proxy/management_endpoints/test_ui_sso.py | 131 +++- 5 files changed, 900 insertions(+), 175 deletions(-) create mode 100644 docs/my-website/img/debug_sso.png create mode 100644 litellm/proxy/common_utils/html_forms/jwt_display_template.py diff --git a/docs/my-website/docs/proxy/self_serve.md b/docs/my-website/docs/proxy/self_serve.md index 604ceee3e5..5e7438585e 100644 --- a/docs/my-website/docs/proxy/self_serve.md +++ b/docs/my-website/docs/proxy/self_serve.md @@ -198,6 +198,7 @@ This budget does not apply to keys created under non-default teams. ### Auto-add SSO users to teams + 1. Specify the JWT field that contains the team ids, that the user belongs to. ```yaml @@ -207,7 +208,8 @@ general_settings: team_ids_jwt_field: "groups" # 👈 CAN BE ANY FIELD ``` -This is assuming your SSO token looks like this: +This is assuming your SSO token looks like this. **If you need to inspect the JWT fields received from your SSO provider by LiteLLM, follow these instructions [here](#debugging-sso-jwt-fields)** + ``` { ..., @@ -231,6 +233,39 @@ curl -X POST '/team/new' \ Here's a walkthrough of [how it works](https://www.loom.com/share/8959be458edf41fd85937452c29a33f3?sid=7ebd6d37-569a-4023-866e-e0cde67cb23e) +### Debugging SSO JWT fields + +If you need to inspect the JWT fields received from your SSO provider by LiteLLM, follow these instructions. This guide walks you through setting up a debug callback to view the JWT data during the SSO process. + + + +
+ +1. Add `/sso/debug/callback` as a redirect URL in your SSO provider + + In your SSO provider's settings, add the following URL as a new redirect (callback) URL: + + ```bash showLineNumbers title="Redirect URL" + http:///sso/debug/callback + ``` + + +2. Navigate to the debug login page on your browser + + Navigate to the following URL on your browser: + + ```bash showLineNumbers title="URL to navigate to" + https:///sso/debug/login + ``` + + This will initiate the standard SSO flow. You will be redirected to your SSO provider's login screen, and after successful authentication, you will be redirected back to LiteLLM's debug callback route. + + +3. View the JWT fields + +Once redirected, you should see a page called "SSO Debug Information". This page displays the JWT fields received from your SSO provider (as shown in the image above) + + ### Restrict Users from creating personal keys This is useful if you only want users to create keys under a specific team. diff --git a/docs/my-website/img/debug_sso.png b/docs/my-website/img/debug_sso.png new file mode 100644 index 0000000000000000000000000000000000000000..d7dde36892050f2512b7897e1fbde8dca8059e3c GIT binary patch literal 170731 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 literal 0 HcmV?d00001 diff --git a/litellm/proxy/common_utils/html_forms/jwt_display_template.py b/litellm/proxy/common_utils/html_forms/jwt_display_template.py new file mode 100644 index 0000000000..03dff78dba --- /dev/null +++ b/litellm/proxy/common_utils/html_forms/jwt_display_template.py @@ -0,0 +1,284 @@ +# JWT display template for SSO debug callback +jwt_display_template = """ + + + + + LiteLLM SSO Debug - JWT Information + + + + + + + + + +""" diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index 970587ded9..c9388bc4eb 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -3,6 +3,9 @@ Has all /sso/* routes /sso/key/generate - handles user signing in with SSO and redirects to /sso/callback /sso/callback - returns JWT Redirect Response that redirects to LiteLLM UI + +/sso/debug/login - handles user signing in with SSO and redirects to /sso/debug/callback +/sso/debug/callback - returns the OpenID object returned by the SSO provider """ import asyncio @@ -36,6 +39,9 @@ from litellm.proxy.common_utils.admin_ui_utils import ( admin_ui_disabled, show_missing_vars_in_env, ) +from litellm.proxy.common_utils.html_forms.jwt_display_template import ( + jwt_display_template, +) from litellm.proxy.common_utils.html_forms.ui_login import html_form from litellm.proxy.management_endpoints.internal_user_endpoints import new_user from litellm.proxy.management_endpoints.sso_helper_utils import ( @@ -92,131 +98,29 @@ async def google_login(request: Request): # noqa: PLR0915 missing_env_vars = show_missing_vars_in_env() if missing_env_vars is not None: return missing_env_vars + ui_username = os.getenv("UI_USERNAME") # get url from request - redirect_url = os.getenv("PROXY_BASE_URL", str(request.base_url)) - ui_username = os.getenv("UI_USERNAME") - if redirect_url.endswith("/"): - redirect_url += "sso/callback" - else: - redirect_url += "/sso/callback" - # Google SSO Auth - if google_client_id is not None: - from fastapi_sso.sso.google import GoogleSSO + redirect_url = SSOAuthenticationHandler.get_redirect_url_for_sso( + request=request, + sso_callback_route="sso/callback", + ) - google_client_secret = os.getenv("GOOGLE_CLIENT_SECRET", None) - if google_client_secret is None: - raise ProxyException( - message="GOOGLE_CLIENT_SECRET not set. Set it in .env file", - type=ProxyErrorTypes.auth_error, - param="GOOGLE_CLIENT_SECRET", - code=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - google_sso = GoogleSSO( - client_id=google_client_id, - client_secret=google_client_secret, - redirect_uri=redirect_url, + # Check if we should use SSO handler + if ( + SSOAuthenticationHandler.should_use_sso_handler( + microsoft_client_id=microsoft_client_id, + google_client_id=google_client_id, + generic_client_id=generic_client_id, ) - verbose_proxy_logger.info( - f"In /google-login/key/generate, \nGOOGLE_REDIRECT_URI: {redirect_url}\nGOOGLE_CLIENT_ID: {google_client_id}" + is True + ): + return await SSOAuthenticationHandler.get_sso_login_redirect( + redirect_url=redirect_url, + microsoft_client_id=microsoft_client_id, + google_client_id=google_client_id, + generic_client_id=generic_client_id, ) - with google_sso: - return await google_sso.get_login_redirect() - # Microsoft SSO Auth - elif microsoft_client_id is not None: - from fastapi_sso.sso.microsoft import MicrosoftSSO - - microsoft_client_secret = os.getenv("MICROSOFT_CLIENT_SECRET", None) - microsoft_tenant = os.getenv("MICROSOFT_TENANT", None) - if microsoft_client_secret is None: - raise ProxyException( - message="MICROSOFT_CLIENT_SECRET not set. Set it in .env file", - type=ProxyErrorTypes.auth_error, - param="MICROSOFT_CLIENT_SECRET", - code=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - microsoft_sso = MicrosoftSSO( - client_id=microsoft_client_id, - client_secret=microsoft_client_secret, - tenant=microsoft_tenant, - redirect_uri=redirect_url, - allow_insecure_http=True, - ) - with microsoft_sso: - return await microsoft_sso.get_login_redirect() - elif generic_client_id is not None: - from fastapi_sso.sso.base import DiscoveryDocument - from fastapi_sso.sso.generic import create_provider - - generic_client_secret = os.getenv("GENERIC_CLIENT_SECRET", None) - generic_scope = os.getenv("GENERIC_SCOPE", "openid email profile").split(" ") - generic_authorization_endpoint = os.getenv( - "GENERIC_AUTHORIZATION_ENDPOINT", None - ) - generic_token_endpoint = os.getenv("GENERIC_TOKEN_ENDPOINT", None) - generic_userinfo_endpoint = os.getenv("GENERIC_USERINFO_ENDPOINT", None) - if generic_client_secret is None: - raise ProxyException( - message="GENERIC_CLIENT_SECRET not set. Set it in .env file", - type=ProxyErrorTypes.auth_error, - param="GENERIC_CLIENT_SECRET", - code=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - if generic_authorization_endpoint is None: - raise ProxyException( - message="GENERIC_AUTHORIZATION_ENDPOINT not set. Set it in .env file", - type=ProxyErrorTypes.auth_error, - param="GENERIC_AUTHORIZATION_ENDPOINT", - code=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - if generic_token_endpoint is None: - raise ProxyException( - message="GENERIC_TOKEN_ENDPOINT not set. Set it in .env file", - type=ProxyErrorTypes.auth_error, - param="GENERIC_TOKEN_ENDPOINT", - code=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - if generic_userinfo_endpoint is None: - raise ProxyException( - message="GENERIC_USERINFO_ENDPOINT not set. Set it in .env file", - type=ProxyErrorTypes.auth_error, - param="GENERIC_USERINFO_ENDPOINT", - code=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - verbose_proxy_logger.debug( - f"authorization_endpoint: {generic_authorization_endpoint}\ntoken_endpoint: {generic_token_endpoint}\nuserinfo_endpoint: {generic_userinfo_endpoint}" - ) - verbose_proxy_logger.debug( - f"GENERIC_REDIRECT_URI: {redirect_url}\nGENERIC_CLIENT_ID: {generic_client_id}\n" - ) - discovery = DiscoveryDocument( - authorization_endpoint=generic_authorization_endpoint, - token_endpoint=generic_token_endpoint, - userinfo_endpoint=generic_userinfo_endpoint, - ) - SSOProvider = create_provider(name="oidc", discovery_document=discovery) - generic_sso = SSOProvider( - client_id=generic_client_id, - client_secret=generic_client_secret, - redirect_uri=redirect_url, - allow_insecure_http=True, - scope=generic_scope, - ) - with generic_sso: - # TODO: state should be a random string and added to the user session with cookie - # or a cryptographicly signed state that we can verify stateless - # For simplification we are using a static state, this is not perfect but some - # SSO providers do not allow stateless verification - redirect_params = {} - state = os.getenv("GENERIC_CLIENT_STATE", None) - - if state: - redirect_params["state"] = state - elif "okta" in generic_authorization_endpoint: - redirect_params[ - "state" - ] = uuid.uuid4().hex # set state param for okta - required - return await generic_sso.get_login_redirect(**redirect_params) # type: ignore elif ui_username is not None: # No Google, Microsoft SSO # Use UI Credentials set in .env @@ -271,7 +175,7 @@ async def get_generic_sso_response( jwt_handler: JWTHandler, generic_client_id: str, redirect_url: str, -) -> Optional[OpenID]: +) -> Union[OpenID, dict]: # make generic sso provider from fastapi_sso.sso.base import DiscoveryDocument from fastapi_sso.sso.generic import create_provider @@ -348,7 +252,7 @@ async def get_generic_sso_response( request, params={"include_client_id": generic_include_client_id} ) verbose_proxy_logger.debug("generic result: %s", result) - return result + return result or {} async def create_team_member_add_task(team_id, user_info): @@ -443,54 +347,16 @@ async def auth_callback(request: Request): # noqa: PLR0915 result = None if google_client_id is not None: - from fastapi_sso.sso.google import GoogleSSO - - google_client_secret = os.getenv("GOOGLE_CLIENT_SECRET", None) - if google_client_secret is None: - raise ProxyException( - message="GOOGLE_CLIENT_SECRET not set. Set it in .env file", - type=ProxyErrorTypes.auth_error, - param="GOOGLE_CLIENT_SECRET", - code=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - google_sso = GoogleSSO( - client_id=google_client_id, - redirect_uri=redirect_url, - client_secret=google_client_secret, - ) - result = await google_sso.verify_and_process(request) - elif microsoft_client_id is not None: - from fastapi_sso.sso.microsoft import MicrosoftSSO - - microsoft_client_secret = os.getenv("MICROSOFT_CLIENT_SECRET", None) - microsoft_tenant = os.getenv("MICROSOFT_TENANT", None) - if microsoft_client_secret is None: - raise ProxyException( - message="MICROSOFT_CLIENT_SECRET not set. Set it in .env file", - type=ProxyErrorTypes.auth_error, - param="MICROSOFT_CLIENT_SECRET", - code=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - if microsoft_tenant is None: - raise ProxyException( - message="MICROSOFT_TENANT not set. Set it in .env file", - type=ProxyErrorTypes.auth_error, - param="MICROSOFT_TENANT", - code=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - microsoft_sso = MicrosoftSSO( - client_id=microsoft_client_id, - client_secret=microsoft_client_secret, - tenant=microsoft_tenant, - redirect_uri=redirect_url, - allow_insecure_http=True, - ) - original_msft_result = await microsoft_sso.verify_and_process( + result = await GoogleSSOHandler.get_google_callback_response( request=request, - convert_response=False, + google_client_id=google_client_id, + redirect_url=redirect_url, ) - result = MicrosoftSSOHandler.openid_from_response( - response=original_msft_result, + elif microsoft_client_id is not None: + result = await MicrosoftSSOHandler.get_microsoft_callback_response( + request=request, + microsoft_client_id=microsoft_client_id, + redirect_url=redirect_url, jwt_handler=jwt_handler, ) elif generic_client_id is not None: @@ -705,7 +571,7 @@ async def auth_callback(request: Request): # noqa: PLR0915 async def insert_sso_user( - result_openid: Optional[OpenID], + result_openid: Optional[Union[OpenID, dict]], user_defined_values: Optional[SSOUserDefinedValues] = None, ) -> NewUserResponse: """ @@ -721,6 +587,10 @@ async def insert_sso_user( verbose_proxy_logger.debug( f"Inserting SSO user into DB. User values: {user_defined_values}" ) + if result_openid is None: + raise ValueError("result_openid is None") + if isinstance(result_openid, dict): + result_openid = OpenID(**result_openid) if user_defined_values is None: raise ValueError("user_defined_values is None") @@ -733,9 +603,9 @@ async def insert_sso_user( if user_defined_values.get("max_budget") is None: user_defined_values["max_budget"] = litellm.max_internal_user_budget if user_defined_values.get("budget_duration") is None: - user_defined_values[ - "budget_duration" - ] = litellm.internal_user_budget_duration + user_defined_values["budget_duration"] = ( + litellm.internal_user_budget_duration + ) if user_defined_values["user_role"] is None: user_defined_values["user_role"] = LitellmUserRoles.INTERNAL_USER_VIEW_ONLY @@ -789,11 +659,242 @@ async def get_ui_settings(request: Request): } +class SSOAuthenticationHandler: + """ + Handler for SSO Authentication across all SSO providers + """ + + @staticmethod + async def get_sso_login_redirect( + redirect_url: str, + google_client_id: Optional[str] = None, + microsoft_client_id: Optional[str] = None, + generic_client_id: Optional[str] = None, + ) -> Optional[RedirectResponse]: + """ + Step 1. Call Get Login Redirect for the SSO provider. Send the redirect response to `redirect_url` + + Args: + redirect_url (str): The URL to redirect the user to after login + google_client_id (Optional[str], optional): The Google Client ID. Defaults to None. + microsoft_client_id (Optional[str], optional): The Microsoft Client ID. Defaults to None. + generic_client_id (Optional[str], optional): The Generic Client ID. Defaults to None. + + Returns: + RedirectResponse: The redirect response from the SSO provider + """ + # Google SSO Auth + if google_client_id is not None: + from fastapi_sso.sso.google import GoogleSSO + + google_client_secret = os.getenv("GOOGLE_CLIENT_SECRET", None) + if google_client_secret is None: + raise ProxyException( + message="GOOGLE_CLIENT_SECRET not set. Set it in .env file", + type=ProxyErrorTypes.auth_error, + param="GOOGLE_CLIENT_SECRET", + code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + google_sso = GoogleSSO( + client_id=google_client_id, + client_secret=google_client_secret, + redirect_uri=redirect_url, + ) + verbose_proxy_logger.info( + f"In /google-login/key/generate, \nGOOGLE_REDIRECT_URI: {redirect_url}\nGOOGLE_CLIENT_ID: {google_client_id}" + ) + with google_sso: + return await google_sso.get_login_redirect() + # Microsoft SSO Auth + elif microsoft_client_id is not None: + from fastapi_sso.sso.microsoft import MicrosoftSSO + + microsoft_client_secret = os.getenv("MICROSOFT_CLIENT_SECRET", None) + microsoft_tenant = os.getenv("MICROSOFT_TENANT", None) + if microsoft_client_secret is None: + raise ProxyException( + message="MICROSOFT_CLIENT_SECRET not set. Set it in .env file", + type=ProxyErrorTypes.auth_error, + param="MICROSOFT_CLIENT_SECRET", + code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + microsoft_sso = MicrosoftSSO( + client_id=microsoft_client_id, + client_secret=microsoft_client_secret, + tenant=microsoft_tenant, + redirect_uri=redirect_url, + allow_insecure_http=True, + ) + with microsoft_sso: + return await microsoft_sso.get_login_redirect() + elif generic_client_id is not None: + from fastapi_sso.sso.base import DiscoveryDocument + from fastapi_sso.sso.generic import create_provider + + generic_client_secret = os.getenv("GENERIC_CLIENT_SECRET", None) + generic_scope = os.getenv("GENERIC_SCOPE", "openid email profile").split( + " " + ) + generic_authorization_endpoint = os.getenv( + "GENERIC_AUTHORIZATION_ENDPOINT", None + ) + generic_token_endpoint = os.getenv("GENERIC_TOKEN_ENDPOINT", None) + generic_userinfo_endpoint = os.getenv("GENERIC_USERINFO_ENDPOINT", None) + if generic_client_secret is None: + raise ProxyException( + message="GENERIC_CLIENT_SECRET not set. Set it in .env file", + type=ProxyErrorTypes.auth_error, + param="GENERIC_CLIENT_SECRET", + code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + if generic_authorization_endpoint is None: + raise ProxyException( + message="GENERIC_AUTHORIZATION_ENDPOINT not set. Set it in .env file", + type=ProxyErrorTypes.auth_error, + param="GENERIC_AUTHORIZATION_ENDPOINT", + code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + if generic_token_endpoint is None: + raise ProxyException( + message="GENERIC_TOKEN_ENDPOINT not set. Set it in .env file", + type=ProxyErrorTypes.auth_error, + param="GENERIC_TOKEN_ENDPOINT", + code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + if generic_userinfo_endpoint is None: + raise ProxyException( + message="GENERIC_USERINFO_ENDPOINT not set. Set it in .env file", + type=ProxyErrorTypes.auth_error, + param="GENERIC_USERINFO_ENDPOINT", + code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + verbose_proxy_logger.debug( + f"authorization_endpoint: {generic_authorization_endpoint}\ntoken_endpoint: {generic_token_endpoint}\nuserinfo_endpoint: {generic_userinfo_endpoint}" + ) + verbose_proxy_logger.debug( + f"GENERIC_REDIRECT_URI: {redirect_url}\nGENERIC_CLIENT_ID: {generic_client_id}\n" + ) + discovery = DiscoveryDocument( + authorization_endpoint=generic_authorization_endpoint, + token_endpoint=generic_token_endpoint, + userinfo_endpoint=generic_userinfo_endpoint, + ) + SSOProvider = create_provider(name="oidc", discovery_document=discovery) + generic_sso = SSOProvider( + client_id=generic_client_id, + client_secret=generic_client_secret, + redirect_uri=redirect_url, + allow_insecure_http=True, + scope=generic_scope, + ) + with generic_sso: + # TODO: state should be a random string and added to the user session with cookie + # or a cryptographicly signed state that we can verify stateless + # For simplification we are using a static state, this is not perfect but some + # SSO providers do not allow stateless verification + redirect_params = {} + state = os.getenv("GENERIC_CLIENT_STATE", None) + + if state: + redirect_params["state"] = state + elif "okta" in generic_authorization_endpoint: + redirect_params["state"] = ( + uuid.uuid4().hex + ) # set state param for okta - required + return await generic_sso.get_login_redirect(**redirect_params) # type: ignore + raise ValueError( + "Unknown SSO provider. Please setup SSO with client IDs https://docs.litellm.ai/docs/proxy/admin_ui_sso" + ) + + @staticmethod + def should_use_sso_handler( + google_client_id: Optional[str] = None, + microsoft_client_id: Optional[str] = None, + generic_client_id: Optional[str] = None, + ) -> bool: + if ( + google_client_id is not None + or microsoft_client_id is not None + or generic_client_id is not None + ): + return True + return False + + @staticmethod + def get_redirect_url_for_sso( + request: Request, + sso_callback_route: str, + ) -> str: + """ + Get the redirect URL for SSO + """ + redirect_url = os.getenv("PROXY_BASE_URL", str(request.base_url)) + if redirect_url.endswith("/"): + redirect_url += sso_callback_route + else: + redirect_url += "/" + sso_callback_route + return redirect_url + + class MicrosoftSSOHandler: """ Handles Microsoft SSO callback response and returns a CustomOpenID object """ + @staticmethod + async def get_microsoft_callback_response( + request: Request, + microsoft_client_id: str, + redirect_url: str, + jwt_handler: JWTHandler, + return_raw_sso_response: bool = False, + ) -> Union[CustomOpenID, OpenID, dict]: + """ + Get the Microsoft SSO callback response + + Args: + return_raw_sso_response: If True, return the raw SSO response + """ + from fastapi_sso.sso.microsoft import MicrosoftSSO + + microsoft_client_secret = os.getenv("MICROSOFT_CLIENT_SECRET", None) + microsoft_tenant = os.getenv("MICROSOFT_TENANT", None) + if microsoft_client_secret is None: + raise ProxyException( + message="MICROSOFT_CLIENT_SECRET not set. Set it in .env file", + type=ProxyErrorTypes.auth_error, + param="MICROSOFT_CLIENT_SECRET", + code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + if microsoft_tenant is None: + raise ProxyException( + message="MICROSOFT_TENANT not set. Set it in .env file", + type=ProxyErrorTypes.auth_error, + param="MICROSOFT_TENANT", + code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + microsoft_sso = MicrosoftSSO( + client_id=microsoft_client_id, + client_secret=microsoft_client_secret, + tenant=microsoft_tenant, + redirect_uri=redirect_url, + allow_insecure_http=True, + ) + original_msft_result = await microsoft_sso.verify_and_process( + request=request, + convert_response=False, + ) + + # if user is trying to get the raw sso response for debugging, return the raw sso response + if return_raw_sso_response: + return original_msft_result or {} + + result = MicrosoftSSOHandler.openid_from_response( + response=original_msft_result, + jwt_handler=jwt_handler, + ) + return result + @staticmethod def openid_from_response( response: Optional[dict], jwt_handler: JWTHandler @@ -811,3 +912,181 @@ class MicrosoftSSOHandler: ) verbose_proxy_logger.debug(f"Microsoft SSO OpenID Response: {openid_response}") return openid_response + + +class GoogleSSOHandler: + """ + Handles Google SSO callback response and returns a CustomOpenID object + """ + + @staticmethod + async def get_google_callback_response( + request: Request, + google_client_id: str, + redirect_url: str, + return_raw_sso_response: bool = False, + ) -> Union[OpenID, dict]: + """ + Get the Google SSO callback response + + Args: + return_raw_sso_response: If True, return the raw SSO response + """ + from fastapi_sso.sso.google import GoogleSSO + + google_client_secret = os.getenv("GOOGLE_CLIENT_SECRET", None) + if google_client_secret is None: + raise ProxyException( + message="GOOGLE_CLIENT_SECRET not set. Set it in .env file", + type=ProxyErrorTypes.auth_error, + param="GOOGLE_CLIENT_SECRET", + code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + google_sso = GoogleSSO( + client_id=google_client_id, + redirect_uri=redirect_url, + client_secret=google_client_secret, + ) + + # if user is trying to get the raw sso response for debugging, return the raw sso response + if return_raw_sso_response: + return ( + await google_sso.verify_and_process( + request=request, + convert_response=False, + ) + or {} + ) + + result = await google_sso.verify_and_process(request) + return result or {} + + +@router.get("/sso/debug/login", tags=["experimental"], include_in_schema=False) +async def debug_sso_login(request: Request): + """ + Create Proxy API Keys using Google Workspace SSO. Requires setting PROXY_BASE_URL in .env + PROXY_BASE_URL should be the your deployed proxy endpoint, e.g. PROXY_BASE_URL="https://litellm-production-7002.up.railway.app/" + Example: + """ + from litellm.proxy.proxy_server import premium_user + + microsoft_client_id = os.getenv("MICROSOFT_CLIENT_ID", None) + google_client_id = os.getenv("GOOGLE_CLIENT_ID", None) + generic_client_id = os.getenv("GENERIC_CLIENT_ID", None) + + ####### Check if user is a Enterprise / Premium User ####### + if ( + microsoft_client_id is not None + or google_client_id is not None + or generic_client_id is not None + ): + if premium_user is not True: + raise ProxyException( + message="You must be a LiteLLM Enterprise user to use SSO. If you have a license please set `LITELLM_LICENSE` in your env. If you want to obtain a license meet with us here: https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat You are seeing this error message because You set one of `MICROSOFT_CLIENT_ID`, `GOOGLE_CLIENT_ID`, or `GENERIC_CLIENT_ID` in your env. Please unset this", + type=ProxyErrorTypes.auth_error, + param="premium_user", + code=status.HTTP_403_FORBIDDEN, + ) + + # get url from request + redirect_url = SSOAuthenticationHandler.get_redirect_url_for_sso( + request=request, + sso_callback_route="sso/debug/callback", + ) + + # Check if we should use SSO handler + if ( + SSOAuthenticationHandler.should_use_sso_handler( + microsoft_client_id=microsoft_client_id, + google_client_id=google_client_id, + generic_client_id=generic_client_id, + ) + is True + ): + return await SSOAuthenticationHandler.get_sso_login_redirect( + redirect_url=redirect_url, + microsoft_client_id=microsoft_client_id, + google_client_id=google_client_id, + generic_client_id=generic_client_id, + ) + + +@router.get("/sso/debug/callback", tags=["experimental"], include_in_schema=False) +async def debug_sso_callback(request: Request): + """ + Returns the OpenID object returned by the SSO provider + """ + import json + + from fastapi.responses import HTMLResponse + + from litellm.proxy.proxy_server import jwt_handler + + microsoft_client_id = os.getenv("MICROSOFT_CLIENT_ID", None) + google_client_id = os.getenv("GOOGLE_CLIENT_ID", None) + generic_client_id = os.getenv("GENERIC_CLIENT_ID", None) + + redirect_url = os.getenv("PROXY_BASE_URL", str(request.base_url)) + if redirect_url.endswith("/"): + redirect_url += "sso/debug/callback" + else: + redirect_url += "/sso/debug/callback" + + result = None + if google_client_id is not None: + result = await GoogleSSOHandler.get_google_callback_response( + request=request, + google_client_id=google_client_id, + redirect_url=redirect_url, + return_raw_sso_response=True, + ) + elif microsoft_client_id is not None: + result = await MicrosoftSSOHandler.get_microsoft_callback_response( + request=request, + microsoft_client_id=microsoft_client_id, + redirect_url=redirect_url, + jwt_handler=jwt_handler, + return_raw_sso_response=True, + ) + elif generic_client_id is not None: + result = await get_generic_sso_response( + request=request, + jwt_handler=jwt_handler, + generic_client_id=generic_client_id, + redirect_url=redirect_url, + ) + + # If result is None, return a basic error message + if result is None: + return HTMLResponse( + content="

SSO Authentication Failed

No data was returned from the SSO provider.

", + status_code=400, + ) + + # Convert the OpenID object to a dictionary + if hasattr(result, "__dict__"): + result_dict = result.__dict__ + else: + result_dict = dict(result) + + # Filter out any None values and convert to JSON serializable format + filtered_result = {} + for key, value in result_dict.items(): + if value is not None and not key.startswith("_"): + if isinstance(value, (str, int, float, bool)) or value is None: + filtered_result[key] = value + else: + try: + # Try to convert to string or another JSON serializable format + filtered_result[key] = str(value) + except Exception as e: + filtered_result[key] = f"Complex value (not displayable): {str(e)}" + + # Replace the placeholder in the template with the actual data + html_content = jwt_display_template.replace( + "const userData = SSO_DATA;", + f"const userData = {json.dumps(filtered_result, indent=2)};", + ) + + return HTMLResponse(content=html_content) diff --git a/tests/litellm/proxy/management_endpoints/test_ui_sso.py b/tests/litellm/proxy/management_endpoints/test_ui_sso.py index 7ad520f7d5..b785b01f8c 100644 --- a/tests/litellm/proxy/management_endpoints/test_ui_sso.py +++ b/tests/litellm/proxy/management_endpoints/test_ui_sso.py @@ -1,3 +1,4 @@ +import asyncio import json import os import sys @@ -5,15 +6,19 @@ from typing import Optional, cast from unittest.mock import MagicMock, patch import pytest +from fastapi import Request from fastapi.testclient import TestClient sys.path.insert( - 0, os.path.abspath("../../..") + 0, os.path.abspath("../../../") ) # Adds the parent directory to the system path from litellm.proxy.auth.handle_jwt import JWTHandler from litellm.proxy.management_endpoints.types import CustomOpenID -from litellm.proxy.management_endpoints.ui_sso import MicrosoftSSOHandler +from litellm.proxy.management_endpoints.ui_sso import ( + GoogleSSOHandler, + MicrosoftSSOHandler, +) def test_microsoft_sso_handler_openid_from_response(): @@ -79,3 +84,125 @@ def test_microsoft_sso_handler_with_empty_response(): # Make sure the JWT handler was called with an empty dict mock_jwt_handler.get_team_ids_from_jwt.assert_called_once_with({}) + + +def test_get_microsoft_callback_response(): + # Arrange + mock_request = MagicMock(spec=Request) + mock_jwt_handler = MagicMock(spec=JWTHandler) + mock_response = { + "mail": "microsoft_user@example.com", + "displayName": "Microsoft User", + "id": "msft123", + "givenName": "Microsoft", + "surname": "User", + } + + future = asyncio.Future() + future.set_result(mock_response) + + with patch.dict( + os.environ, + {"MICROSOFT_CLIENT_SECRET": "mock_secret", "MICROSOFT_TENANT": "mock_tenant"}, + ): + with patch( + "fastapi_sso.sso.microsoft.MicrosoftSSO.verify_and_process", + return_value=future, + ): + # Act + result = asyncio.run( + MicrosoftSSOHandler.get_microsoft_callback_response( + request=mock_request, + microsoft_client_id="mock_client_id", + redirect_url="http://mock_redirect_url", + jwt_handler=mock_jwt_handler, + ) + ) + + # Assert + assert isinstance(result, CustomOpenID) + assert result.email == "microsoft_user@example.com" + assert result.display_name == "Microsoft User" + assert result.provider == "microsoft" + assert result.id == "msft123" + assert result.first_name == "Microsoft" + assert result.last_name == "User" + + +def test_get_microsoft_callback_response_raw_sso_response(): + # Arrange + mock_request = MagicMock(spec=Request) + mock_jwt_handler = MagicMock(spec=JWTHandler) + mock_response = { + "mail": "microsoft_user@example.com", + "displayName": "Microsoft User", + "id": "msft123", + "givenName": "Microsoft", + "surname": "User", + } + + future = asyncio.Future() + future.set_result(mock_response) + with patch.dict( + os.environ, + {"MICROSOFT_CLIENT_SECRET": "mock_secret", "MICROSOFT_TENANT": "mock_tenant"}, + ): + with patch( + "fastapi_sso.sso.microsoft.MicrosoftSSO.verify_and_process", + return_value=future, + ): + # Act + result = asyncio.run( + MicrosoftSSOHandler.get_microsoft_callback_response( + request=mock_request, + microsoft_client_id="mock_client_id", + redirect_url="http://mock_redirect_url", + jwt_handler=mock_jwt_handler, + return_raw_sso_response=True, + ) + ) + + # Assert + print("result from verify_and_process", result) + assert isinstance(result, dict) + assert result["mail"] == "microsoft_user@example.com" + assert result["displayName"] == "Microsoft User" + assert result["id"] == "msft123" + assert result["givenName"] == "Microsoft" + assert result["surname"] == "User" + + +def test_get_google_callback_response(): + # Arrange + mock_request = MagicMock(spec=Request) + mock_response = { + "email": "google_user@example.com", + "name": "Google User", + "sub": "google123", + "given_name": "Google", + "family_name": "User", + } + + future = asyncio.Future() + future.set_result(mock_response) + + with patch.dict(os.environ, {"GOOGLE_CLIENT_SECRET": "mock_secret"}): + with patch( + "fastapi_sso.sso.google.GoogleSSO.verify_and_process", return_value=future + ): + # Act + result = asyncio.run( + GoogleSSOHandler.get_google_callback_response( + request=mock_request, + google_client_id="mock_client_id", + redirect_url="http://mock_redirect_url", + ) + ) + + # Assert + assert isinstance(result, dict) + assert result.get("email") == "google_user@example.com" + assert result.get("name") == "Google User" + assert result.get("sub") == "google123" + assert result.get("given_name") == "Google" + assert result.get("family_name") == "User" From 9ec1972926d1ee57f420e081d3b9287bffd4f691 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 9 Apr 2025 15:36:57 -0700 Subject: [PATCH 18/28] fix(internal_user_endpoints.py): increase default page size for `/user/daily/activity` --- litellm/model_prices_and_context_window_backup.json | 13 +++++++------ litellm/proxy/common_utils/encrypt_decrypt_utils.py | 3 --- .../management_endpoints/internal_user_endpoints.py | 2 +- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index ea33bdb02b..7e5be4dc6b 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -2409,25 +2409,26 @@ "max_tokens": 4096, "max_input_tokens": 131072, "max_output_tokens": 4096, - "input_cost_per_token": 0, - "output_cost_per_token": 0, + "input_cost_per_token": 0.000000075, + "output_cost_per_token": 0.0000003, "litellm_provider": "azure_ai", "mode": "chat", "supports_function_calling": true, - "source": "https://learn.microsoft.com/en-us/azure/ai-foundry/concepts/models-featured#microsoft" + "source": "https://techcommunity.microsoft.com/blog/Azure-AI-Services-blog/announcing-new-phi-pricing-empowering-your-business-with-small-language-models/4395112" }, "azure_ai/Phi-4-multimodal-instruct": { "max_tokens": 4096, "max_input_tokens": 131072, "max_output_tokens": 4096, - "input_cost_per_token": 0, - "output_cost_per_token": 0, + "input_cost_per_token": 0.00000008, + "input_cost_per_audio_token": 0.000004, + "output_cost_per_token": 0.00032, "litellm_provider": "azure_ai", "mode": "chat", "supports_audio_input": true, "supports_function_calling": true, "supports_vision": true, - "source": "https://learn.microsoft.com/en-us/azure/ai-foundry/concepts/models-featured#microsoft" + "source": "https://techcommunity.microsoft.com/blog/Azure-AI-Services-blog/announcing-new-phi-pricing-empowering-your-business-with-small-language-models/4395112" }, "azure_ai/Phi-4": { "max_tokens": 16384, diff --git a/litellm/proxy/common_utils/encrypt_decrypt_utils.py b/litellm/proxy/common_utils/encrypt_decrypt_utils.py index 3452734867..348c81101f 100644 --- a/litellm/proxy/common_utils/encrypt_decrypt_utils.py +++ b/litellm/proxy/common_utils/encrypt_decrypt_utils.py @@ -51,9 +51,6 @@ def decrypt_value_helper(value: str): # if it's not str - do not decrypt it, return the value return value except Exception as e: - import traceback - - traceback.print_stack() verbose_proxy_logger.error( f"Error decrypting value, Did your master_key/salt key change recently? \nError: {str(e)}\nSet permanent salt key - https://docs.litellm.ai/docs/proxy/prod#5-set-litellm-salt-key" ) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index fe5c1bacab..efc1bafa15 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1434,7 +1434,7 @@ async def get_user_daily_activity( default=1, description="Page number for pagination", ge=1 ), page_size: int = fastapi.Query( - default=50, description="Items per page", ge=1, le=100 + default=50, description="Items per page", ge=1, le=1000 ), user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ) -> SpendAnalyticsPaginatedResponse: From b11c08bde31cbd8075a47313252d22b971821a8d Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 9 Apr 2025 15:53:44 -0700 Subject: [PATCH 19/28] fix(new_usage.tsx): increase page size + iterate through all pages if multiple pages --- .../src/components/networking.tsx | 4 +- .../src/components/new_usage.tsx | 39 ++++++++++++++++--- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 025f0c72c4..a2b8afae39 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1075,7 +1075,7 @@ export const organizationDeleteCall = async ( }; -export const userDailyActivityCall = async (accessToken: String, startTime: Date, endTime: Date) => { +export const userDailyActivityCall = async (accessToken: String, startTime: Date, endTime: Date, page: number = 1) => { /** * Get daily user activity on proxy */ @@ -1084,6 +1084,8 @@ export const userDailyActivityCall = async (accessToken: String, startTime: Date const queryParams = new URLSearchParams(); queryParams.append('start_date', startTime.toISOString()); queryParams.append('end_date', endTime.toISOString()); + queryParams.append('page_size', '1000'); + queryParams.append('page', page.toString()); const queryString = queryParams.toString(); if (queryString) { url += `?${queryString}`; diff --git a/ui/litellm-dashboard/src/components/new_usage.tsx b/ui/litellm-dashboard/src/components/new_usage.tsx index 9a68fe25f9..7ea0f2f8e8 100644 --- a/ui/litellm-dashboard/src/components/new_usage.tsx +++ b/ui/litellm-dashboard/src/components/new_usage.tsx @@ -22,15 +22,13 @@ import ViewUserSpend from "./view_user_spend"; import TopKeyView from "./top_key_view"; import { ActivityMetrics, processActivityData } from './activity_metrics'; import { SpendMetrics, DailyData, ModelActivityData, MetricWithMetadata, KeyMetricWithMetadata } from './usage/types'; + interface NewUsagePageProps { accessToken: string | null; userRole: string | null; userID: string | null; } - - - const NewUsagePage: React.FC = ({ accessToken, userRole, @@ -177,8 +175,39 @@ const NewUsagePage: React.FC = ({ if (!accessToken || !dateValue.from || !dateValue.to) return; const startTime = dateValue.from; const endTime = dateValue.to; - const data = await userDailyActivityCall(accessToken, startTime, endTime); - setUserSpendData(data); + + try { + // Get first page + const firstPageData = await userDailyActivityCall(accessToken, startTime, endTime); + + // Check if we need to fetch more pages + if (firstPageData.metadata.total_pages > 10) { + throw new Error("Too many pages of data (>10). Please select a smaller date range."); + } + + // If only one page, just set the data + if (firstPageData.metadata.total_pages === 1) { + setUserSpendData(firstPageData); + return; + } + + // Fetch all pages + const allResults = [...firstPageData.results]; + + for (let page = 2; page <= firstPageData.metadata.total_pages; page++) { + const pageData = await userDailyActivityCall(accessToken, startTime, endTime, page); + allResults.push(...pageData.results); + } + + // Combine all results with the first page's metadata + setUserSpendData({ + results: allResults, + metadata: firstPageData.metadata + }); + } catch (error) { + console.error("Error fetching user spend data:", error); + throw error; + } }; useEffect(() => { From 3f3afabda91eb00cb9a2f131f69bf2a53bb3e7e1 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 9 Apr 2025 15:58:28 -0700 Subject: [PATCH 20/28] feat(leftnav.tsx): show api playground on UI allows easy testing on UI --- ui/litellm-dashboard/src/app/page.tsx | 2 ++ ui/litellm-dashboard/src/components/leftnav.tsx | 1 + 2 files changed, 3 insertions(+) diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 592c7bf0f2..df47c528dc 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -314,6 +314,8 @@ export default function CreateKeyPage() { ) : page == "guardrails" ? ( + ): page == "transform-request" ? ( + ): page == "general-settings" ? ( = ({ { key: "10", page: "budgets", label: "Budgets", icon: , roles: all_admin_roles }, { key: "11", page: "guardrails", label: "Guardrails", icon: , roles: all_admin_roles }, { key: "12", page: "new_usage", label: "New Usage", icon: , roles: [...all_admin_roles, ...internalUserRoles] }, + { key: "20", page: "transform-request", label: "API Playground", icon: , roles: [...all_admin_roles, ...internalUserRoles] }, { key: "18", page: "mcp-tools", label: "MCP Tools", icon: , roles: all_admin_roles }, { key: "19", page: "tag-management", label: "Tag Management", icon: , roles: all_admin_roles }, ] From 5ca93a19504833eb6538e66460045ebbaa0686b8 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 9 Apr 2025 16:18:17 -0700 Subject: [PATCH 21/28] docs: initial commit adding api playground to docs makes it easy to see how litellm transforms your request --- .../docs/playground/transform_request.mdx | 9 + docs/my-website/package-lock.json | 1215 ++++++++++++++++- docs/my-website/package.json | 5 +- docs/my-website/sidebars.js | 7 + .../components/TransformRequestPlayground.tsx | 161 +++ .../components/transform_request.module.css | 215 +++ .../src/components/transform_request.tsx | 210 +++ 7 files changed, 1803 insertions(+), 19 deletions(-) create mode 100644 docs/my-website/docs/playground/transform_request.mdx create mode 100644 docs/my-website/src/components/TransformRequestPlayground.tsx create mode 100644 docs/my-website/src/components/transform_request.module.css create mode 100644 docs/my-website/src/components/transform_request.tsx diff --git a/docs/my-website/docs/playground/transform_request.mdx b/docs/my-website/docs/playground/transform_request.mdx new file mode 100644 index 0000000000..2de803d6cc --- /dev/null +++ b/docs/my-website/docs/playground/transform_request.mdx @@ -0,0 +1,9 @@ +--- +title: Transform Request Playground +description: See how LiteLLM transforms your requests for different providers +hide_table_of_contents: true +--- + +import TransformRequestPlayground from '@site/src/components/TransformRequestPlayground'; + + \ No newline at end of file diff --git a/docs/my-website/package-lock.json b/docs/my-website/package-lock.json index 06251b16bb..58befc47d0 100644 --- a/docs/my-website/package-lock.json +++ b/docs/my-website/package-lock.json @@ -8,11 +8,14 @@ "name": "my-website", "version": "0.0.0", "dependencies": { + "@ant-design/icons": "^4.8.0", "@docusaurus/core": "2.4.1", "@docusaurus/plugin-google-gtag": "^2.4.1", "@docusaurus/plugin-ideal-image": "^2.4.1", "@docusaurus/preset-classic": "2.4.1", "@mdx-js/react": "^1.6.22", + "@tremor/react": "^2.0.0", + "antd": "^4.24.0", "clsx": "^1.2.1", "docusaurus": "^1.14.7", "prism-react-renderer": "^1.3.5", @@ -391,6 +394,54 @@ "node": ">=6.0.0" } }, + "node_modules/@ant-design/colors": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-6.0.0.tgz", + "integrity": "sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==", + "dependencies": { + "@ctrl/tinycolor": "^3.4.0" + } + }, + "node_modules/@ant-design/icons": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-4.8.3.tgz", + "integrity": "sha512-HGlIQZzrEbAhpJR6+IGdzfbPym94Owr6JZkJ2QCCnOkPVIWMO2xgIVcOKnl8YcpijIo39V7l2qQL5fmtw56cMw==", + "dependencies": { + "@ant-design/colors": "^6.0.0", + "@ant-design/icons-svg": "^4.3.0", + "@babel/runtime": "^7.11.2", + "classnames": "^2.2.6", + "lodash": "^4.17.15", + "rc-util": "^5.9.4" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", + "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==" + }, + "node_modules/@ant-design/react-slick": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.0.2.tgz", + "integrity": "sha512-Wj8onxL/T8KQLFFiCA4t8eIRGpRR+UPgOdac2sYzonv+i0n3kXHmvHLLiOYL655DQx2Umii9Y9nNgL7ssu5haQ==", + "dependencies": { + "@babel/runtime": "^7.10.4", + "classnames": "^2.2.5", + "json2mq": "^0.2.0", + "resize-observer-polyfill": "^1.5.1", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": ">=16.9.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", @@ -2224,6 +2275,14 @@ "node": ">=0.1.90" } }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "engines": { + "node": ">=10" + } + }, "node_modules/@discoveryjs/json-ext": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", @@ -3666,6 +3725,54 @@ "react-waypoint": ">=9.0.2" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", + "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "dependencies": { + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", + "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.9" + } + }, + "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==", + "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==", + "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.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==" + }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", @@ -3955,6 +4062,23 @@ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz", "integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==" }, + "node_modules/@rc-component/portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz", + "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, "node_modules/@sideway/address": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", @@ -4253,6 +4377,22 @@ "node": ">=6" } }, + "node_modules/@tremor/react": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tremor/react/-/react-2.11.0.tgz", + "integrity": "sha512-UdIpFHVm5F7vRml9IL3CrIl7doxgg8a5q89ht/dUF7hVBjd7veSbYdfCQCHV1yfjmCqTXjeMAeP3N1ObfwAwCw==", + "dependencies": { + "@floating-ui/react": "^0.19.1", + "date-fns": "^2.28.0", + "react-transition-group": "^4.4.5", + "recharts": "^2.3.2", + "tailwind-merge": "^1.9.1" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", @@ -4295,6 +4435,60 @@ "@types/node": "*" } }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -4984,6 +5178,64 @@ "node": ">=0.10.0" } }, + "node_modules/antd": { + "version": "4.24.16", + "resolved": "https://registry.npmjs.org/antd/-/antd-4.24.16.tgz", + "integrity": "sha512-zZrK4UYxHtU6tGOOf0uG/kBRx1kTvypfuSB3GqE/SBQxFhZ/TZ+yj7Z1qwI8vGfMtUUJdLeuoCAqGDa1zPsXnQ==", + "dependencies": { + "@ant-design/colors": "^6.0.0", + "@ant-design/icons": "^4.8.2", + "@ant-design/react-slick": "~1.0.2", + "@babel/runtime": "^7.18.3", + "@ctrl/tinycolor": "^3.6.1", + "classnames": "^2.2.6", + "copy-to-clipboard": "^3.2.0", + "lodash": "^4.17.21", + "moment": "^2.29.2", + "rc-cascader": "~3.7.3", + "rc-checkbox": "~3.0.1", + "rc-collapse": "~3.4.2", + "rc-dialog": "~9.0.2", + "rc-drawer": "~6.3.0", + "rc-dropdown": "~4.0.1", + "rc-field-form": "~1.38.2", + "rc-image": "~5.13.0", + "rc-input": "~0.1.4", + "rc-input-number": "~7.3.11", + "rc-mentions": "~1.13.1", + "rc-menu": "~9.8.4", + "rc-motion": "^2.9.0", + "rc-notification": "~4.6.1", + "rc-pagination": "~3.2.0", + "rc-picker": "~2.7.6", + "rc-progress": "~3.4.2", + "rc-rate": "~2.9.3", + "rc-resize-observer": "^1.3.1", + "rc-segmented": "~2.3.0", + "rc-select": "~14.1.18", + "rc-slider": "~10.0.1", + "rc-steps": "~5.0.0", + "rc-switch": "~3.2.2", + "rc-table": "~7.26.0", + "rc-tabs": "~12.5.10", + "rc-textarea": "~0.4.7", + "rc-tooltip": "~5.2.2", + "rc-tree": "~5.7.12", + "rc-tree-select": "~5.5.5", + "rc-trigger": "^5.3.4", + "rc-upload": "~4.3.6", + "rc-util": "^5.37.0", + "scroll-into-view-if-needed": "^2.2.25" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -5044,6 +5296,17 @@ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, + "node_modules/aria-hidden": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", + "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/arr-diff": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", @@ -5096,6 +5359,11 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "node_modules/array-tree-filter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-tree-filter/-/array-tree-filter-2.1.0.tgz", + "integrity": "sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==" + }, "node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -5260,6 +5528,11 @@ "lodash": "^4.17.14" } }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -6968,6 +7241,11 @@ "node": ">= 0.6" } }, + "node_modules/compute-scroll-into-view": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", + "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -7104,6 +7382,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, "node_modules/copy-webpack-plugin": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz", @@ -7695,6 +7981,116 @@ "node": ">=0.10.0" } }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } + }, "node_modules/dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -7754,6 +8150,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==" + }, "node_modules/debounce": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", @@ -7783,6 +8199,11 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" + }, "node_modules/decode-uri-component": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", @@ -9920,6 +10341,11 @@ "node": ">= 8" } }, + "node_modules/dom-align": { + "version": "1.12.4", + "resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.4.tgz", + "integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==" + }, "node_modules/dom-converter": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", @@ -9928,6 +10354,15 @@ "utila": "~0.4" } }, + "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==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -10819,6 +11254,14 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, + "node_modules/fast-equals": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", + "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", @@ -13198,6 +13641,14 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, "node_modules/interpret": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", @@ -14205,6 +14656,14 @@ "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==" }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "dependencies": { + "string-convert": "^0.2.0" + } + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -15078,6 +15537,14 @@ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "engines": { + "node": "*" + } + }, "node_modules/moo": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", @@ -17336,6 +17803,599 @@ "rc": "cli.js" } }, + "node_modules/rc-align": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/rc-align/-/rc-align-4.0.15.tgz", + "integrity": "sha512-wqJtVH60pka/nOX7/IspElA8gjPNQKIx/ZqJ6heATCkXpe1Zg4cPVrMD2vC96wjsFFL8WsmhPbx9tdMo1qqlIA==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "dom-align": "^1.7.0", + "rc-util": "^5.26.0", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-cascader": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.7.3.tgz", + "integrity": "sha512-KBpT+kzhxDW+hxPiNk4zaKa99+Lie2/8nnI11XF+FIOPl4Bj9VlFZi61GrnWzhLGA7VEN+dTxAkNOjkySDa0dA==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "array-tree-filter": "^2.1.0", + "classnames": "^2.3.1", + "rc-select": "~14.1.0", + "rc-tree": "~5.7.0", + "rc-util": "^5.6.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-checkbox": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.0.1.tgz", + "integrity": "sha512-k7nxDWxYF+jDI0ZcCvuvj71xONmWRVe5+1MKcERRR9MRyP3tZ69b+yUCSXXh+sik4/Hc9P5wHr2nnUoGS2zBjA==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.25.2" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-collapse": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.4.2.tgz", + "integrity": "sha512-jpTwLgJzkhAgp2Wpi3xmbTbbYExg6fkptL67Uu5LCRVEj6wqmy0DHTjjeynsjOLsppHGHu41t1ELntZ0lEvS/Q==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.3.4", + "rc-util": "^5.2.1", + "shallowequal": "^1.1.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dialog": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.0.4.tgz", + "integrity": "sha512-pmnPRZKd9CGzGgf4a1ysBvMhxm8Afx5fF6M7AzLtJ0qh8X1bshurDlqnK4MBNAB4hAeAMMbz6Ytb1rkGMvKFbQ==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/portal": "^1.0.0-8", + "classnames": "^2.2.6", + "rc-motion": "^2.3.0", + "rc-util": "^5.21.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-drawer": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-6.3.0.tgz", + "integrity": "sha512-uBZVb3xTAR+dBV53d/bUhTctCw3pwcwJoM7g5aX+7vgwt2zzVzoJ6aqFjYJpBlZ9zp0dVYN8fV+hykFE7c4lig==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/portal": "^1.1.1", + "classnames": "^2.2.6", + "rc-motion": "^2.6.1", + "rc-util": "^5.21.2" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dropdown": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.0.1.tgz", + "integrity": "sha512-OdpXuOcme1rm45cR0Jzgfl1otzmU4vuBVb+etXM8vcaULGokAKVpKlw8p6xzspG7jGd/XxShvq+N3VNEfk/l5g==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.6", + "rc-trigger": "^5.3.1", + "rc-util": "^5.17.0" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/rc-field-form": { + "version": "1.38.2", + "resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-1.38.2.tgz", + "integrity": "sha512-O83Oi1qPyEv31Sg+Jwvsj6pXc8uQI2BtIAkURr5lvEYHVggXJhdU/nynK8wY1gbw0qR48k731sN5ON4egRCROA==", + "dependencies": { + "@babel/runtime": "^7.18.0", + "async-validator": "^4.1.0", + "rc-util": "^5.32.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-image": { + "version": "5.13.0", + "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-5.13.0.tgz", + "integrity": "sha512-iZTOmw5eWo2+gcrJMMcnd7SsxVHl3w5xlyCgsULUdJhJbnuI8i/AL0tVOsE7aLn9VfOh1qgDT3mC2G75/c7mqg==", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/portal": "^1.0.2", + "classnames": "^2.2.6", + "rc-dialog": "~9.0.0", + "rc-motion": "^2.6.2", + "rc-util": "^5.0.6" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-input": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-0.1.4.tgz", + "integrity": "sha512-FqDdNz+fV2dKNgfXzcSLKvC+jEs1709t7nD+WdfjrdSaOcefpgc7BUJYadc3usaING+b7ediMTfKxuJBsEFbXA==", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.18.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-input-number": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-7.3.11.tgz", + "integrity": "sha512-aMWPEjFeles6PQnMqP5eWpxzsvHm9rh1jQOWXExUEIxhX62Fyl/ptifLHOn17+waDG1T/YUb6flfJbvwRhHrbA==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.23.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-mentions": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-1.13.1.tgz", + "integrity": "sha512-FCkaWw6JQygtOz0+Vxz/M/NWqrWHB9LwqlY2RtcuFqWJNFK9njijOOzTSsBGANliGufVUzx/xuPHmZPBV0+Hgw==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.6", + "rc-menu": "~9.8.0", + "rc-textarea": "^0.4.0", + "rc-trigger": "^5.0.4", + "rc-util": "^5.22.5" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-menu": { + "version": "9.8.4", + "resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.8.4.tgz", + "integrity": "sha512-lmw2j8I2fhdIzHmC9ajfImfckt0WDb2KVJJBBRIsxPEw2kGkEfjLMUoB1NgiNT/Q5cC8PdjGOGQjHJIJMwyNMw==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.4.3", + "rc-overflow": "^1.2.8", + "rc-trigger": "^5.1.2", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-motion": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz", + "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.44.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-notification": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-4.6.1.tgz", + "integrity": "sha512-NSmFYwrrdY3+un1GvDAJQw62Xi9LNMSsoQyo95tuaYrcad5Bn9gJUL8AREufRxSQAQnr64u3LtP3EUyLYT6bhw==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.2.0", + "rc-util": "^5.20.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-overflow": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.4.1.tgz", + "integrity": "sha512-3MoPQQPV1uKyOMVNd6SZfONi+f3st0r8PksexIdBTeIYbMX0Jr+k7pHEDvsXtR4BpCv90/Pv2MovVNhktKrwvw==", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.37.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-pagination": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-3.2.0.tgz", + "integrity": "sha512-5tIXjB670WwwcAJzAqp2J+cOBS9W3cH/WU1EiYwXljuZ4vtZXKlY2Idq8FZrnYBz8KhN3vwPo9CoV/SJS6SL1w==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-picker": { + "version": "2.7.6", + "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-2.7.6.tgz", + "integrity": "sha512-H9if/BUJUZBOhPfWcPeT15JUI3/ntrG9muzERrXDkSoWmDj4yzmBvumozpxYrHwjcKnjyDGAke68d+whWwvhHA==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "date-fns": "2.x", + "dayjs": "1.x", + "moment": "^2.24.0", + "rc-trigger": "^5.0.4", + "rc-util": "^5.37.0", + "shallowequal": "^1.1.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-progress": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-3.4.2.tgz", + "integrity": "sha512-iAGhwWU+tsayP+Jkl9T4+6rHeQTG9kDz8JAHZk4XtQOcYN5fj9H34NXNEdRdZx94VUDHMqCb1yOIvi8eJRh67w==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.6", + "rc-util": "^5.16.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-rate": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.9.3.tgz", + "integrity": "sha512-2THssUSnRhtqIouQIIXqsZGzRczvp4WsH4WvGuhiwm+LG2fVpDUJliP9O1zeDOZvYfBE/Bup4SgHun/eCkbjgQ==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.0.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-resize-observer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz", + "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==", + "dependencies": { + "@babel/runtime": "^7.20.7", + "classnames": "^2.2.1", + "rc-util": "^5.44.1", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-segmented": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.3.0.tgz", + "integrity": "sha512-I3FtM5Smua/ESXutFfb8gJ8ZPcvFR+qUgeeGFQHBOvRiRKyAk4aBE5nfqrxXx+h8/vn60DQjOt6i4RNtrbOobg==", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-motion": "^2.4.4", + "rc-util": "^5.17.0" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-select": { + "version": "14.1.18", + "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.1.18.tgz", + "integrity": "sha512-4JgY3oG2Yz68ECMUSCON7mtxuJvCSj+LJpHEg/AONaaVBxIIrmI/ZTuMJkyojall/X50YdBe5oMKqHHPNiPzEg==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-overflow": "^1.0.0", + "rc-trigger": "^5.0.4", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.2.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-slider": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-10.0.1.tgz", + "integrity": "sha512-igTKF3zBet7oS/3yNiIlmU8KnZ45npmrmHlUUio8PNbIhzMcsh+oE/r2UD42Y6YD2D/s+kzCQkzQrPD6RY435Q==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.18.1", + "shallowequal": "^1.1.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-steps": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/rc-steps/-/rc-steps-5.0.0.tgz", + "integrity": "sha512-9TgRvnVYirdhbV0C3syJFj9EhCRqoJAsxt4i1rED5o8/ZcSv5TLIYyo4H8MCjLPvbe2R+oBAm/IYBEtC+OS1Rw==", + "dependencies": { + "@babel/runtime": "^7.16.7", + "classnames": "^2.2.3", + "rc-util": "^5.16.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-switch": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-3.2.2.tgz", + "integrity": "sha512-+gUJClsZZzvAHGy1vZfnwySxj+MjLlGRyXKXScrtCTcmiYNPzxDFOxdQ/3pK1Kt/0POvwJ/6ALOR8gwdXGhs+A==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "rc-util": "^5.0.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-table": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.26.0.tgz", + "integrity": "sha512-0cD8e6S+DTGAt5nBZQIPFYEaIukn17sfa5uFL98faHlH/whZzD8ii3dbFL4wmUDEL4BLybhYop+QUfZJ4CPvNQ==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.22.5", + "shallowequal": "^1.1.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tabs": { + "version": "12.5.10", + "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-12.5.10.tgz", + "integrity": "sha512-Ay0l0jtd4eXepFH9vWBvinBjqOpqzcsJTerBGwJy435P2S90Uu38q8U/mvc1sxUEVOXX5ZCFbxcWPnfG3dH+tQ==", + "dependencies": { + "@babel/runtime": "^7.11.2", + "classnames": "2.x", + "rc-dropdown": "~4.0.0", + "rc-menu": "~9.8.0", + "rc-motion": "^2.6.2", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.16.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-textarea": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-0.4.7.tgz", + "integrity": "sha512-IQPd1CDI3mnMlkFyzt2O4gQ2lxUsnBAeJEoZGJnkkXgORNqyM9qovdrCj9NzcRfpHgLdzaEbU3AmobNFGUznwQ==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.24.4", + "shallowequal": "^1.1.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tooltip": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-5.2.2.tgz", + "integrity": "sha512-jtQzU/18S6EI3lhSGoDYhPqNpWajMtS5VV/ld1LwyfrDByQpYmw/LW6U7oFXXLukjfDHQ7Ju705A82PRNFWYhg==", + "dependencies": { + "@babel/runtime": "^7.11.2", + "classnames": "^2.3.1", + "rc-trigger": "^5.0.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tree": { + "version": "5.7.12", + "resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.7.12.tgz", + "integrity": "sha512-LXA5nY2hG5koIAlHW5sgXgLpOMz+bFRbnZZ+cCg0tQs4Wv1AmY7EDi1SK7iFXhslYockbqUerQan82jljoaItg==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-tree-select": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.5.5.tgz", + "integrity": "sha512-k2av7jF6tW9bIO4mQhaVdV4kJ1c54oxV3/hHVU+oD251Gb5JN+m1RbJFTMf1o0rAFqkvto33rxMdpafaGKQRJw==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-select": "~14.1.0", + "rc-tree": "~5.7.0", + "rc-util": "^5.16.1" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-trigger": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/rc-trigger/-/rc-trigger-5.3.4.tgz", + "integrity": "sha512-mQv+vas0TwKcjAO2izNPkqR4j86OemLRmvL2nOzdP9OWNWA1ivoTt5hzFqYNW9zACwmTezRiN8bttrC7cZzYSw==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.6", + "rc-align": "^4.0.0", + "rc-motion": "^2.0.0", + "rc-util": "^5.19.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-upload": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.3.6.tgz", + "integrity": "sha512-Bt7ESeG5tT3IY82fZcP+s0tQU2xmo1W6P3S8NboUUliquJLQYLkUcsaExi3IlBVr43GQMCjo30RA2o0i70+NjA==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.5", + "rc-util": "^5.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.44.4", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util/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==" + }, + "node_modules/rc-virtual-list": { + "version": "3.18.5", + "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.18.5.tgz", + "integrity": "sha512-1FuxVSxhzTj3y8k5xMPbhXCB0t2TOiI3Tq+qE2Bu+GGV7f+ECVuQl4OUg6lZ2qT5fordTW7CBpr9czdzXCI7Pg==", + "dependencies": { + "@babel/runtime": "^7.20.0", + "classnames": "^2.2.6", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, "node_modules/rc/node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -17673,6 +18733,20 @@ "isarray": "0.0.1" } }, + "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==", + "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-textarea-autosize": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.6.tgz", @@ -17689,6 +18763,21 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.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==", + "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-waypoint": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/react-waypoint/-/react-waypoint-10.3.0.tgz", @@ -17812,6 +18901,49 @@ "resolved": "https://registry.npmjs.org/reading-time/-/reading-time-1.5.0.tgz", "integrity": "sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==" }, + "node_modules/recharts": { + "version": "2.15.2", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.2.tgz", + "integrity": "sha512-xv9lVztv3ingk7V3Jf05wfAZbM9Q2umJzu5t/cfnAK7LUslNrGT7LPBr74G+ok8kSCeFMaePmWMg0rcYOnczTw==", + "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/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/recharts/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==" + }, "node_modules/rechoir": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", @@ -18450,6 +19582,11 @@ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" + }, "node_modules/resolve": { "version": "1.22.9", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.9.tgz", @@ -18765,11 +19902,13 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/search-insights": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", - "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", - "peer": true + "node_modules/scroll-into-view-if-needed": { + "version": "2.2.31", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz", + "integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==", + "dependencies": { + "compute-scroll-into-view": "^1.0.20" + } }, "node_modules/section-matter": { "version": "1.0.0", @@ -19949,6 +21088,11 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==" + }, "node_modules/string-template": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/string-template/-/string-template-0.2.1.tgz", @@ -20313,6 +21457,20 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" + }, + "node_modules/tailwind-merge": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-1.14.0.tgz", + "integrity": "sha512-3mFKyCo/MBcgyOTlrY8T7odzZFx+w+qKSMAmdFzRvqBfLlSigU6TZnlFHK0lkMwj9Bj8OYU+9yW9lmGuS0QEnQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", @@ -20564,6 +21722,14 @@ "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==" }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "engines": { + "node": ">=12.22" + } + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -20723,6 +21889,11 @@ "node": ">=0.10.0" } }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -21007,19 +22178,6 @@ "is-typedarray": "^1.0.0" } }, - "node_modules/typescript": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", - "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/ua-parser-js": { "version": "1.0.39", "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.39.tgz", @@ -21796,6 +22954,27 @@ "url": "https://opencollective.com/unified" } }, + "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==", + "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/wait-on": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-6.0.1.tgz", diff --git a/docs/my-website/package.json b/docs/my-website/package.json index b6ad649e62..a97febd46c 100644 --- a/docs/my-website/package.json +++ b/docs/my-website/package.json @@ -25,7 +25,10 @@ "react": "^17.0.2", "react-dom": "^17.0.2", "sharp": "^0.32.6", - "uuid": "^9.0.1" + "uuid": "^9.0.1", + "antd": "^4.24.0", + "@ant-design/icons": "^4.8.0", + "@tremor/react": "^2.0.0" }, "devDependencies": { "@docusaurus/module-type-aliases": "2.4.1" diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index b8591cb993..3fc2038dd1 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -519,6 +519,13 @@ const sidebars = { ], }, "troubleshoot", + { + type: 'category', + label: 'Playground', + items: [ + 'playground/transform_request', + ], + }, ], }; diff --git a/docs/my-website/src/components/TransformRequestPlayground.tsx b/docs/my-website/src/components/TransformRequestPlayground.tsx new file mode 100644 index 0000000000..8f22e5e198 --- /dev/null +++ b/docs/my-website/src/components/TransformRequestPlayground.tsx @@ -0,0 +1,161 @@ +import React, { useState } from 'react'; +import styles from './transform_request.module.css'; + +const DEFAULT_REQUEST = { + "model": "bedrock/gpt-4", + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant." + }, + { + "role": "user", + "content": "Explain quantum computing in simple terms" + } + ], + "temperature": 0.7, + "max_tokens": 500, + "stream": true +}; + +type ViewMode = 'split' | 'request' | 'transformed'; + +const TransformRequestPlayground: React.FC = () => { + const [request, setRequest] = useState(JSON.stringify(DEFAULT_REQUEST, null, 2)); + const [transformedRequest, setTransformedRequest] = useState(''); + const [viewMode, setViewMode] = useState('split'); + + const handleTransform = async () => { + try { + // Here you would make the actual API call to transform the request + // For now, we'll just set a sample response + const sampleResponse = `curl -X POST \\ + https://api.openai.com/v1/chat/completions \\ + -H 'Authorization: Bearer sk-xxx' \\ + -H 'Content-Type: application/json' \\ + -d '{ + "model": "gpt-4", + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant." + } + ], + "temperature": 0.7 + }'`; + setTransformedRequest(sampleResponse); + } catch (error) { + console.error('Error transforming request:', error); + } + }; + + const handleCopy = () => { + navigator.clipboard.writeText(transformedRequest); + }; + + const renderContent = () => { + switch (viewMode) { + case 'request': + return ( +
+
+

Original Request

+

The request you would send to LiteLLM /chat/completions endpoint.

+
+