diff --git a/.circleci/config.yml b/.circleci/config.yml index e30dc02b2ab..d98fdaa4a4d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3476,7 +3476,6 @@ jobs: name: Install Playwright Browsers command: | npx playwright install - - run: name: Build Docker image command: docker build -t my-app:latest -f ./docker/Dockerfile.database . @@ -3515,7 +3514,10 @@ jobs: - run: name: Run Playwright Tests command: | - npx playwright test e2e_ui_tests/ --reporter=html --output=test-results + npx playwright test \ + --config ui/litellm-dashboard/e2e_tests/playwright.config.ts \ + --reporter=html \ + --output=test-results no_output_timeout: 120m - store_artifacts: path: test-results @@ -3973,4 +3975,4 @@ workflows: - proxy_pass_through_endpoint_tests - check_code_and_doc_quality - publish_proxy_extras - - guardrails_testing \ No newline at end of file + - guardrails_testing diff --git a/.gitguardian.yaml b/.gitguardian.yaml new file mode 100644 index 00000000000..af8f2489eec --- /dev/null +++ b/.gitguardian.yaml @@ -0,0 +1,104 @@ +version: 2 + +secret: + # Exclude files and paths by globbing + ignored_paths: + - "**/*.whl" + - "**/*.pyc" + - "**/__pycache__/**" + - "**/node_modules/**" + - "**/dist/**" + - "**/build/**" + - "**/.git/**" + - "**/venv/**" + - "**/.venv/**" + + # Large data/metadata files that don't need scanning + - "**/model_prices_and_context_window*.json" + - "**/*_metadata/*.txt" + - "**/tokenizers/*.json" + - "**/tokenizers/*" + - "miniconda.sh" + + # Build outputs and static assets + - "litellm/proxy/_experimental/out/**" + - "ui/litellm-dashboard/public/**" + - "**/swagger/*.js" + - "**/*.woff" + - "**/*.woff2" + - "**/*.avif" + - "**/*.webp" + + # Test data files + - "**/tests/**/data_map.txt" + - "tests/**/*.txt" + + # Documentation and other non-code files + - "docs/**" + - "**/*.md" + - "**/*.lock" + - "poetry.lock" + - "package-lock.json" + + # Ignore security incidents with the SHA256 of the occurrence (false positives) + ignored_matches: + # === Current detected false positives (SHA-based) === + + # gcs_pub_sub_body - folder name, not a password + - name: GCS pub/sub test folder name + match: 75f377c456eede69e5f6e47399ccee6016a2a93cc5dd11db09cc5b1359ae569a + + # os.environ/APORIA_API_KEY_1 - environment variable reference + - name: Environment variable reference APORIA_API_KEY_1 + match: e2ddeb8b88eca97a402559a2be2117764e11c074d86159ef9ad2375dea188094 + + # os.environ/APORIA_API_KEY_2 - environment variable reference + - name: Environment variable reference APORIA_API_KEY_2 + match: 09aa39a29e050b86603aa55138af1ff08fb86a4582aa965c1bd0672e1575e052 + + # oidc/circleci_v2/ - test authentication path, not a secret + - name: OIDC CircleCI test path + match: feb3475e1f89a65b7b7815ac4ec597e18a9ec1847742ad445c36ca617b536e15 + + # text-davinci-003 - OpenAI model identifier, not a secret + - name: OpenAI model identifier text-davinci-003 + match: c489000cf6c7600cee0eefb80ad0965f82921cfb47ece880930eb7e7635cf1f1 + + # Base64 Basic Auth in test_pass_through_endpoints.py - test fixture, not a real secret + - name: Test Base64 Basic Auth header in pass_through_endpoints test + match: 61bac0491f395040617df7ef6d06029eac4d92a4457ac784978db80d97be1ae0 + + # PostgreSQL password "postgres" in CI configs - standard test database password + - name: Test PostgreSQL password in CI configurations + match: 6e0d657eb1f0fbc40cf0b8f3c3873ef627cc9cb7c4108d1c07d979c04bc8a4bb + + # Bearer token in locustfile.py - test/example API key for load testing + - name: Test Bearer token in locustfile load test + match: 2a0abc2b0c3c1760a51ffcdf8d6b1d384cef69af740504b1cfa82dd70cdc7ff9 + + # Inkeep API key in docusaurus.config.js - public documentation site key + - name: Inkeep API key in documentation config + match: c366657791bfb5fc69045ec11d49452f09a0aebbc8648f94e2469b4025e29a75 + + # Langfuse credentials in test_completion.py - test credentials for integration test + - name: Langfuse test credentials in test_completion + match: c39310f68cc3d3e22f7b298bb6353c4f45759adcc37080d8b7f4e535d3cfd7f4 + + # === Preventive patterns for test keys (pattern-based) === + + # Test API keys (124 instances across 45 files) + - name: Test API keys with sk-test prefix + match: sk-test- + + # Mock API keys + - name: Mock API keys with sk-mock prefix + match: sk-mock- + + # Fake API keys + - name: Fake API keys with sk-fake prefix + match: sk-fake- + + # Generic test API key patterns + - name: Test API key patterns + match: test-api-key + diff --git a/.gitignore b/.gitignore index aa973201fd1..9a4f666bf47 100644 --- a/.gitignore +++ b/.gitignore @@ -100,3 +100,7 @@ update_model_cost_map.py tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py litellm/proxy/_experimental/out/guardrails/index.html scripts/test_vertex_ai_search.py +LAZY_LOADING_IMPROVEMENTS.md +**/test-results +**/playwright-report +**/*.storageState.json \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 2c778dc0d71..61afbd035fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,27 @@ LiteLLM is a unified interface for 100+ LLMs that: - Test provider-specific functionality thoroughly - Consider adding load tests for performance-critical changes +### MAKING CODE CHANGES FOR THE UI (IGNORE FOR BACKEND) + +1. **Use Common Components as much as possible**: + - These are usually defined in the `common_components` directory + - Use these components as much as possible and avoid building new components unless needed + - Tremor components are deprecated; prefer using Ant Design (AntD) as much as possible + +2. **Testing**: + - The codebase uses **Vitest** and **React Testing Library** + - **Query Priority Order**: Use query methods in this order: `getByRole`, `getByLabelText`, `getByPlaceholderText`, `getByText`, `getByTestId` + - **Always use `screen`** instead of destructuring from `render()` (e.g., use `screen.getByText()` not `getByText`) + - **Wrap user interactions in `act()`**: Always wrap `fireEvent` calls with `act()` to ensure React state updates are properly handled + - **Use `query` methods for absence checks**: Use `queryBy*` methods (not `getBy*`) when expecting an element to NOT be present + - **Test names must start with "should"**: All test names should follow the pattern `it("should ...")` + - **Mock external dependencies**: Check `setupTests.ts` for global mocks and mock child components/networking calls as needed + - **Structure tests properly**: + - First test should verify the component renders successfully + - Subsequent tests should focus on functionality and user interactions + - Use `waitFor` for async operations that aren't already awaited + - **Avoid using `querySelector`**: Prefer React Testing Library queries over direct DOM manipulation + ### IMPORTANT PATTERNS 1. **Function/Tool Calling**: diff --git a/ci_cd/TEST_KEY_PATTERNS.md b/ci_cd/TEST_KEY_PATTERNS.md new file mode 100644 index 00000000000..bd59f582839 --- /dev/null +++ b/ci_cd/TEST_KEY_PATTERNS.md @@ -0,0 +1,40 @@ +# Test Key Patterns Standard + +Standard patterns for test/mock keys and credentials in the LiteLLM codebase to avoid triggering secret detection. + +## How GitGuardian Works + +GitGuardian uses **machine learning and entropy analysis**, not just pattern matching: +- **Low entropy** values (like `sk-1234`, `postgres`) are automatically ignored +- **High entropy** values (realistic-looking secrets) trigger detection +- **Context-aware** detection understands code syntax like `os.environ["KEY"]` + +## Recommended Test Key Patterns + +### Option 1: Low Entropy Values (Simplest) +These won't trigger GitGuardian's ML detector: + +```python +api_key = "sk-1234" +api_key = "sk-12345" +database_password = "postgres" +token = "test123" +``` + +### Option 2: High Entropy with Test Prefixes +If you need realistic-looking test keys with high entropy, use these prefixes: + +```python +api_key = "sk-test-abc123def456ghi789..." # OpenAI-style test key +api_key = "sk-mock-1234567890abcdef1234..." # Mock key +api_key = "sk-fake-xyz789uvw456rst123..." # Fake key +token = "test-api-key-with-high-entropy" +``` + +## Configured Ignore Patterns + +These patterns are in `.gitguardian.yaml` for high-entropy test keys: +- `sk-test-*` - OpenAI-style test keys +- `sk-mock-*` - Mock API keys +- `sk-fake-*` - Fake API keys +- `test-api-key` - Generic test tokens diff --git a/ci_cd/security_scans.sh b/ci_cd/security_scans.sh index 9f4da6fd8df..0036a304417 100755 --- a/ci_cd/security_scans.sh +++ b/ci_cd/security_scans.sh @@ -58,20 +58,20 @@ run_secret_detection() { # Use --recursive for directory scanning and auto-confirm if prompted # .gitguardian.yaml will automatically exclude binary files, wheel files, etc. # GITGUARDIAN_API_KEY environment variable will be used for authentication - # echo y | ggshield secret scan path . --recursive || { - # echo "" - # echo "==========================================" - # echo "ERROR: Secret Detection Failed" - # echo "==========================================" - # echo "ggshield has detected secrets in the codebase." - # echo "Please review discovered secrets above, revoke any actively used secrets" - # echo "from underlying systems and make changes to inject secrets dynamically at runtime." - # echo "" - # echo "For more information, see: https://docs.gitguardian.com/secrets-detection/" - # echo "==========================================" - # echo "" - # exit 1 - # } + echo y | ggshield secret scan path . --recursive || { + echo "" + echo "==========================================" + echo "ERROR: Secret Detection Failed" + echo "==========================================" + echo "ggshield has detected secrets in the codebase." + echo "Please review discovered secrets above, revoke any actively used secrets" + echo "from underlying systems and make changes to inject secrets dynamically at runtime." + echo "" + echo "For more information, see: https://docs.gitguardian.com/secrets-detection/" + echo "==========================================" + echo "" + exit 1 + } echo "Secret detection scans completed successfully" } diff --git a/cookbook/LiteLLM_PromptLayer.ipynb b/cookbook/LiteLLM_PromptLayer.ipynb index 3552636011a..8fd54941027 100644 --- a/cookbook/LiteLLM_PromptLayer.ipynb +++ b/cookbook/LiteLLM_PromptLayer.ipynb @@ -39,7 +39,7 @@ "import os\n", "os.environ['OPENAI_API_KEY'] = \"\"\n", "os.environ['REPLICATE_API_TOKEN'] = \"\"\n", - "os.environ['PROMPTLAYER_API_KEY'] = \"pl_4ea2bb00a4dca1b8a70cebf2e9e11564\"\n", + "os.environ['PROMPTLAYER_API_KEY'] = \"test-promptlayer-key-123\"\n", "\n", "# Set Promptlayer as a success callback\n", "litellm.success_callback =['promptlayer']\n", diff --git a/cookbook/Migrating_to_LiteLLM_Proxy_from_OpenAI_Azure_OpenAI.ipynb b/cookbook/Migrating_to_LiteLLM_Proxy_from_OpenAI_Azure_OpenAI.ipynb index 39677ed2a8a..740e7c7a4c8 100644 --- a/cookbook/Migrating_to_LiteLLM_Proxy_from_OpenAI_Azure_OpenAI.ipynb +++ b/cookbook/Migrating_to_LiteLLM_Proxy_from_OpenAI_Azure_OpenAI.ipynb @@ -1,21 +1,10 @@ { - "nbformat": 4, - "nbformat_minor": 0, - "metadata": { - "colab": { - "provenance": [] - }, - "kernelspec": { - "name": "python3", - "display_name": "Python 3" - }, - "language_info": { - "name": "python" - } - }, "cells": [ { "cell_type": "markdown", + "metadata": { + "id": "kccfk0mHZ4Ad" + }, "source": [ "# Migrating to LiteLLM Proxy from OpenAI/Azure OpenAI\n", "\n", @@ -32,29 +21,26 @@ "To pass provider-specific args, [go here](https://docs.litellm.ai/docs/completion/provider_specific_params#proxy-usage)\n", "\n", "To drop unsupported params (E.g. frequency_penalty for bedrock with librechat), [go here](https://docs.litellm.ai/docs/completion/drop_params#openai-proxy-usage)\n" - ], - "metadata": { - "id": "kccfk0mHZ4Ad" - } + ] }, { "cell_type": "markdown", + "metadata": { + "id": "nmSClzCPaGH6" + }, "source": [ "## /chat/completion\n", "\n" - ], - "metadata": { - "id": "nmSClzCPaGH6" - } + ] }, { "cell_type": "markdown", - "source": [ - "### OpenAI Python SDK" - ], "metadata": { "id": "_vqcjwOVaKpO" - } + }, + "source": [ + "### OpenAI Python SDK" + ] }, { "cell_type": "code", @@ -94,15 +80,20 @@ }, { "cell_type": "markdown", - "source": [ - "## Function Calling" - ], "metadata": { "id": "AqkyKk9Scxgj" - } + }, + "source": [ + "## Function Calling" + ] }, { "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "wDg10VqLczE1" + }, + "outputs": [], "source": [ "from openai import OpenAI\n", "client = OpenAI(\n", @@ -139,24 +130,24 @@ ")\n", "\n", "print(completion)\n" - ], - "metadata": { - "id": "wDg10VqLczE1" - }, - "execution_count": null, - "outputs": [] + ] }, { "cell_type": "markdown", - "source": [ - "### Azure OpenAI Python SDK" - ], "metadata": { "id": "YYoxLloSaNWW" - } + }, + "source": [ + "### Azure OpenAI Python SDK" + ] }, { "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "yA1XcgowaSRy" + }, + "outputs": [], "source": [ "import openai\n", "client = openai.AzureOpenAI(\n", @@ -184,24 +175,24 @@ ")\n", "\n", "print(response)" - ], - "metadata": { - "id": "yA1XcgowaSRy" - }, - "execution_count": null, - "outputs": [] + ] }, { "cell_type": "markdown", - "source": [ - "### Langchain Python" - ], "metadata": { "id": "yl9qhDvnaTpL" - } + }, + "source": [ + "### Langchain Python" + ] }, { "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "5MUZgSquaW5t" + }, + "outputs": [], "source": [ "from langchain.chat_models import ChatOpenAI\n", "from langchain.prompts.chat import (\n", @@ -239,24 +230,22 @@ "response = chat(messages)\n", "\n", "print(response)" - ], - "metadata": { - "id": "5MUZgSquaW5t" - }, - "execution_count": null, - "outputs": [] + ] }, { "cell_type": "markdown", - "source": [ - "### Curl" - ], "metadata": { "id": "B9eMgnULbRaz" - } + }, + "source": [ + "### Curl" + ] }, { "cell_type": "markdown", + "metadata": { + "id": "VWCCk5PFcmhS" + }, "source": [ "\n", "\n", @@ -280,22 +269,24 @@ "}'\n", "```\n", "\n" - ], - "metadata": { - "id": "VWCCk5PFcmhS" - } + ] }, { "cell_type": "markdown", - "source": [ - "### LlamaIndex" - ], "metadata": { "id": "drBAm2e1b6xe" - } + }, + "source": [ + "### LlamaIndex" + ] }, { "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "d0bZcv8fb9mL" + }, + "outputs": [], "source": [ "import os, dotenv\n", "\n", @@ -326,24 +317,24 @@ "query_engine = index.as_query_engine()\n", "response = query_engine.query(\"What did the author do growing up?\")\n", "print(response)\n" - ], - "metadata": { - "id": "d0bZcv8fb9mL" - }, - "execution_count": null, - "outputs": [] + ] }, { "cell_type": "markdown", - "source": [ - "### Langchain JS" - ], "metadata": { "id": "xypvNdHnb-Yy" - } + }, + "source": [ + "### Langchain JS" + ] }, { "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "R55mK2vCcBN2" + }, + "outputs": [], "source": [ "import { ChatOpenAI } from \"@langchain/openai\";\n", "\n", @@ -359,24 +350,24 @@ "const message = await model.invoke(\"Hi there!\");\n", "\n", "console.log(message);\n" - ], - "metadata": { - "id": "R55mK2vCcBN2" - }, - "execution_count": null, - "outputs": [] + ] }, { "cell_type": "markdown", - "source": [ - "### OpenAI JS" - ], "metadata": { "id": "nC4bLifCcCiW" - } + }, + "source": [ + "### OpenAI JS" + ] }, { "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "MICH8kIMcFpg" + }, + "outputs": [], "source": [ "const { OpenAI } = require('openai');\n", "\n", @@ -398,24 +389,24 @@ "}\n", "\n", "main();\n" - ], - "metadata": { - "id": "MICH8kIMcFpg" - }, - "execution_count": null, - "outputs": [] + ] }, { "cell_type": "markdown", - "source": [ - "### Anthropic SDK" - ], "metadata": { "id": "D1Q07pEAcGTb" - } + }, + "source": [ + "### Anthropic SDK" + ] }, { "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "qBjFcAvgcI3t" + }, + "outputs": [], "source": [ "import os\n", "\n", @@ -423,7 +414,7 @@ "\n", "client = Anthropic(\n", " base_url=\"http://localhost:4000\", # proxy endpoint\n", - " api_key=\"sk-s4xN1IiLTCytwtZFJaYQrA\", # litellm proxy virtual key\n", + " api_key=\"sk-test-proxy-key-123\", # litellm proxy virtual key (example)\n", ")\n", "\n", "message = client.messages.create(\n", @@ -437,33 +428,33 @@ " model=\"claude-3-opus-20240229\",\n", ")\n", "print(message.content)" - ], - "metadata": { - "id": "qBjFcAvgcI3t" - }, - "execution_count": null, - "outputs": [] + ] }, { "cell_type": "markdown", - "source": [ - "## /embeddings" - ], "metadata": { "id": "dFAR4AJGcONI" - } + }, + "source": [ + "## /embeddings" + ] }, { "cell_type": "markdown", - "source": [ - "### OpenAI Python SDK" - ], "metadata": { "id": "lgNoM281cRzR" - } + }, + "source": [ + "### OpenAI Python SDK" + ] }, { "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "NY3DJhPfcQhA" + }, + "outputs": [], "source": [ "import openai\n", "from openai import OpenAI\n", @@ -478,24 +469,24 @@ ")\n", "\n", "print(response)\n" - ], - "metadata": { - "id": "NY3DJhPfcQhA" - }, - "execution_count": null, - "outputs": [] + ] }, { "cell_type": "markdown", - "source": [ - "### Langchain Embeddings" - ], "metadata": { "id": "hmbg-DW6cUZs" - } + }, + "source": [ + "### Langchain Embeddings" + ] }, { "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "lX2S8Nl1cWVP" + }, + "outputs": [], "source": [ "from langchain.embeddings import OpenAIEmbeddings\n", "\n", @@ -526,24 +517,22 @@ "\n", "print(f\"TITAN EMBEDDINGS\")\n", "print(query_result[:5])" - ], - "metadata": { - "id": "lX2S8Nl1cWVP" - }, - "execution_count": null, - "outputs": [] + ] }, { "cell_type": "markdown", - "source": [ - "### Curl Request" - ], "metadata": { "id": "oqGbWBCQcYfd" - } + }, + "source": [ + "### Curl Request" + ] }, { "cell_type": "markdown", + "metadata": { + "id": "7rkIMV9LcdwQ" + }, "source": [ "\n", "\n", @@ -556,10 +545,21 @@ " }'\n", "```\n", "\n" - ], - "metadata": { - "id": "7rkIMV9LcdwQ" - } + ] } - ] -} \ No newline at end of file + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/docker/Dockerfile.non_root b/docker/Dockerfile.non_root index 7e9147a124e..af1bb5b2022 100644 --- a/docker/Dockerfile.non_root +++ b/docker/Dockerfile.non_root @@ -40,7 +40,7 @@ COPY . . ENV LITELLM_NON_ROOT=true # Build Admin UI using the upstream command order while keeping a single RUN layer -RUN mkdir -p /tmp/litellm_ui && \ +RUN mkdir -p /var/lib/litellm/ui && \ npm install -g npm@latest && npm cache clean --force && \ cd /app/ui/litellm-dashboard && \ if [ -f "/app/enterprise/enterprise_ui/enterprise_colors.json" ]; then \ @@ -49,10 +49,10 @@ RUN mkdir -p /tmp/litellm_ui && \ rm -f package-lock.json && \ npm install --legacy-peer-deps && \ npm run build && \ - cp -r /app/ui/litellm-dashboard/out/* /tmp/litellm_ui/ && \ - mkdir -p /tmp/litellm_assets && \ - cp /app/litellm/proxy/logo.jpg /tmp/litellm_assets/logo.jpg && \ - ( cd /tmp/litellm_ui && \ + cp -r /app/ui/litellm-dashboard/out/* /var/lib/litellm/ui/ && \ + mkdir -p /var/lib/litellm/assets && \ + cp /app/litellm/proxy/logo.jpg /var/lib/litellm/assets/logo.jpg && \ + ( cd /var/lib/litellm/ui && \ for html_file in *.html; do \ if [ "$html_file" != "index.html" ] && [ -f "$html_file" ]; then \ folder_name="${html_file%.html}" && \ @@ -111,8 +111,8 @@ COPY --from=builder /app/docker/entrypoint.sh /app/docker/prod_entrypoint.sh /ap COPY --from=builder /app/docker/supervisord.conf /etc/supervisord.conf COPY --from=builder /app/schema.prisma /app/ COPY --from=builder /wheels/ /wheels/ -COPY --from=builder /tmp/litellm_ui /tmp/litellm_ui -COPY --from=builder /tmp/litellm_assets /tmp/litellm_assets +COPY --from=builder /var/lib/litellm/ui /var/lib/litellm/ui +COPY --from=builder /var/lib/litellm/assets /var/lib/litellm/assets COPY --from=builder /app/.cache /app/.cache COPY --from=builder /app/litellm-proxy-extras /app/litellm-proxy-extras COPY --from=builder \ @@ -145,8 +145,8 @@ RUN pip install --no-index --find-links=/wheels/ -r requirements.txt && \ # Permissions, cleanup, and Prisma prep RUN chmod +x docker/entrypoint.sh docker/prod_entrypoint.sh && \ - mkdir -p /nonexistent /.npm /tmp/litellm_assets /tmp/litellm_ui && \ - chown -R nobody:nogroup /app /tmp/litellm_ui /tmp/litellm_assets /nonexistent /.npm && \ + mkdir -p /nonexistent /.npm /var/lib/litellm/assets /var/lib/litellm/ui && \ + chown -R nobody:nogroup /app /var/lib/litellm/ui /var/lib/litellm/assets /nonexistent /.npm && \ pip uninstall jwt -y || true && \ pip uninstall PyJWT -y || true && \ pip install --no-index --find-links=/wheels/ PyJWT==2.10.1 --no-cache-dir && \ @@ -156,11 +156,11 @@ RUN chmod +x docker/entrypoint.sh docker/prod_entrypoint.sh && \ LITELLM_PKG_MIGRATIONS_PATH="$(python -c 'import os, litellm_proxy_extras; print(os.path.dirname(litellm_proxy_extras.__file__))' 2>/dev/null || echo '')/migrations" && \ [ -n "$LITELLM_PKG_MIGRATIONS_PATH" ] && chown -R nobody:nogroup $LITELLM_PKG_MIGRATIONS_PATH && \ LITELLM_PROXY_EXTRAS_PATH=$(python -c "import os, litellm_proxy_extras; print(os.path.dirname(litellm_proxy_extras.__file__))" 2>/dev/null || echo "") && \ - chgrp -R 0 $PRISMA_PATH /tmp/litellm_ui /tmp/litellm_assets && \ + chgrp -R 0 $PRISMA_PATH /var/lib/litellm/ui /var/lib/litellm/assets && \ [ -n "$LITELLM_PROXY_EXTRAS_PATH" ] && chgrp -R 0 $LITELLM_PROXY_EXTRAS_PATH || true && \ - chmod -R g=u $PRISMA_PATH /tmp/litellm_ui /tmp/litellm_assets && \ + chmod -R g=u $PRISMA_PATH /var/lib/litellm/ui /var/lib/litellm/assets && \ [ -n "$LITELLM_PROXY_EXTRAS_PATH" ] && chmod -R g=u $LITELLM_PROXY_EXTRAS_PATH || true && \ - chmod -R g+w $PRISMA_PATH /tmp/litellm_ui /tmp/litellm_assets && \ + chmod -R g+w $PRISMA_PATH /var/lib/litellm/ui /var/lib/litellm/assets && \ [ -n "$LITELLM_PROXY_EXTRAS_PATH" ] && chmod -R g+w $LITELLM_PROXY_EXTRAS_PATH || true && \ chmod -R g+rX $PRISMA_PATH && \ chmod -R g+rX /app/.cache && \ diff --git a/docs/my-website/docs/interactions.md b/docs/my-website/docs/interactions.md index 5458a4463f5..1cd0f7be867 100644 --- a/docs/my-website/docs/interactions.md +++ b/docs/my-website/docs/interactions.md @@ -8,7 +8,7 @@ import TabItem from '@theme/TabItem'; | Logging | ✅ | Works across all integrations | | Streaming | ✅ | | | Loadbalancing | ✅ | Between supported models | -| Supported Providers | `gemini` | [Google Interactions API](https://ai.google.dev/gemini-api/docs/interactions) | +| Supported LLM providers | **All LiteLLM supported providers** | `openai`, `anthropic`, `bedrock`, `vertex_ai`, `gemini`, `azure`, `azure_ai` etc. | ## **LiteLLM Python SDK Usage** @@ -207,8 +207,63 @@ for chunk in client.interactions.create_stream( } ``` +## **Calling non-Interactions API endpoints (`/interactions` to `/responses` Bridge)** + +LiteLLM allows you to call non-Interactions API models via a bridge to LiteLLM's `/responses` endpoint. This is useful for calling OpenAI, Anthropic, and other providers that don't natively support the Interactions API. + +#### Python SDK Usage + +```python showLineNumbers title="SDK Usage" +import litellm +import os + +# Set API key +os.environ["OPENAI_API_KEY"] = "your-openai-api-key" + +# Non-streaming interaction +response = litellm.interactions.create( + model="gpt-4o", + input="Tell me a short joke about programming." +) + +print(response.outputs[-1].text) +``` + +#### LiteLLM Proxy Usage + +**Setup Config:** + +```yaml showLineNumbers title="Example Configuration" +model_list: +- model_name: openai-model + litellm_params: + model: gpt-4o + api_key: os.environ/OPENAI_API_KEY +``` + +**Start Proxy:** + +```bash showLineNumbers title="Start LiteLLM Proxy" +litellm --config /path/to/config.yaml + +# RUNNING on http://0.0.0.0:4000 +``` + +**Make Request:** + +```bash showLineNumbers title="non-Interactions API Model Request" +curl http://localhost:4000/v1beta/interactions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer sk-1234" \ + -d '{ + "model": "openai-model", + "input": "Tell me a short joke about programming." + }' +``` + ## **Supported Providers** | Provider | Link to Usage | |----------|---------------| | Google AI Studio | [Usage](#quick-start) | +| All other LiteLLM providers | [Bridge Usage](#calling-non-interactions-api-endpoints-interactions-to-responses-bridge) | diff --git a/docs/my-website/docs/mcp.md b/docs/my-website/docs/mcp.md index f9c9cbb4562..a70e3d24188 100644 --- a/docs/my-website/docs/mcp.md +++ b/docs/my-website/docs/mcp.md @@ -746,8 +746,33 @@ curl --location 'http://localhost:4000/github_mcp/mcp' \ 3. **Header Forwarding**: LiteLLM automatically forwards matching headers to the backend MCP server 4. **Authentication**: The backend MCP server receives both the configured auth headers and the custom headers ---- +### Passing Request Headers to STDIO env Vars + +If your stdio MCP server needs per-request credentials, you can map HTTP headers from the client request directly into the environment for the launched stdio process. Reference the header name in the env value using the `${X-HEADER_NAME}` syntax. LiteLLM will read that header from the incoming request and set the env var before starting the command. + +```json title="Forward X-GITHUB_PERSONAL_ACCESS_TOKEN header to stdio env" showLineNumbers +{ + "mcpServers": { + "github": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${X-GITHUB_PERSONAL_ACCESS_TOKEN}" + } + } + } +} +``` + +In this example, when a client makes a request with the `X-GITHUB_PERSONAL_ACCESS_TOKEN` header, the proxy forwards that value into the stdio process as the `GITHUB_PERSONAL_ACCESS_TOKEN` environment variable. ## Using your MCP with client side credentials diff --git a/docs/my-website/docs/observability/cloudzero.md b/docs/my-website/docs/observability/cloudzero.md index f213ef64e13..19f6d80ca8b 100644 --- a/docs/my-website/docs/observability/cloudzero.md +++ b/docs/my-website/docs/observability/cloudzero.md @@ -65,6 +65,52 @@ Start your LiteLLM proxy with the configuration: litellm --config /path/to/config.yaml ``` +## Setup on UI + +1\. Click "Settings" + +![](https://ajeuwbhvhr.cloudimg.io/https://colony-recorder.s3.amazonaws.com/files/2025-12-22/5ac36280-c688-41a3-8d0e-23e19c6a470b/ascreenshot.jpeg?tl_px=0,332&br_px=1308,1064&force_format=jpeg&q=100&width=1120.0&wat=1&wat_opacity=0.7&wat_gravity=northwest&wat_url=https://colony-recorder.s3.us-west-1.amazonaws.com/images/watermarks/FB923C_standard.png&wat_pad=119,444) + + +2\. Click "Logging & Alerts" + +![](https://ajeuwbhvhr.cloudimg.io/https://colony-recorder.s3.amazonaws.com/files/2025-12-22/13f76b09-e0c4-4738-ba05-2d5111c6ad3e/ascreenshot.jpeg?tl_px=0,332&br_px=1308,1064&force_format=jpeg&q=100&width=1120.0&wat=1&wat_opacity=0.7&wat_gravity=northwest&wat_url=https://colony-recorder.s3.us-west-1.amazonaws.com/images/watermarks/FB923C_standard.png&wat_pad=58,507) + + +3\. Click "CloudZero Cost Tracking" + +![](https://ajeuwbhvhr.cloudimg.io/https://colony-recorder.s3.amazonaws.com/files/2025-12-22/f96cc1e5-7bc0-4d7c-9aeb-5cbbec549b12/ascreenshot.jpeg?tl_px=0,0&br_px=1308,731&force_format=jpeg&q=100&width=1120.0&wat=1&wat_opacity=0.7&wat_gravity=northwest&wat_url=https://colony-recorder.s3.us-west-1.amazonaws.com/images/watermarks/FB923C_standard.png&wat_pad=389,56) + + +4\. Click "Add CloudZero Integration" + +![](https://ajeuwbhvhr.cloudimg.io/https://colony-recorder.s3.amazonaws.com/files/2025-12-22/04fbc748-0e6f-43bb-8a57-dd2e83dbfcb5/ascreenshot.jpeg?tl_px=0,90&br_px=1308,821&force_format=jpeg&q=100&width=1120.0&wat=1&wat_opacity=0.7&wat_gravity=northwest&wat_url=https://colony-recorder.s3.us-west-1.amazonaws.com/images/watermarks/FB923C_standard.png&wat_pad=616,277) + + +5\. Enter your CloudZero API Key. + +![](https://ajeuwbhvhr.cloudimg.io/https://colony-recorder.s3.amazonaws.com/files/2025-12-22/080e82f1-f94f-4ed7-8014-e495380336f3/ascreenshot.jpeg?tl_px=0,0&br_px=1308,731&force_format=jpeg&q=100&width=1120.0&wat=1&wat_opacity=0.7&wat_gravity=northwest&wat_url=https://colony-recorder.s3.us-west-1.amazonaws.com/images/watermarks/FB923C_standard.png&wat_pad=506,129) + + +6\. Enter your CloudZero Connection ID. + +![](https://ajeuwbhvhr.cloudimg.io/https://colony-recorder.s3.amazonaws.com/files/2025-12-22/af417aa2-67a8-4dee-a014-84b1892dc07e/ascreenshot.jpeg?tl_px=0,0&br_px=1308,731&force_format=jpeg&q=100&width=1120.0&wat=1&wat_opacity=0.7&wat_gravity=northwest&wat_url=https://colony-recorder.s3.us-west-1.amazonaws.com/images/watermarks/FB923C_standard.png&wat_pad=488,213) + + +7\. Click "Create" + +![](https://ajeuwbhvhr.cloudimg.io/https://colony-recorder.s3.amazonaws.com/files/2025-12-22/647e672f-9a4a-4754-a7b0-abf1397abad4/ascreenshot.jpeg?tl_px=0,88&br_px=1308,819&force_format=jpeg&q=100&width=1120.0&wat=1&wat_opacity=0.7&wat_gravity=northwest&wat_url=https://colony-recorder.s3.us-west-1.amazonaws.com/images/watermarks/FB923C_standard.png&wat_pad=711,277) + + +8\. Test your payload with "Run Dry Run Simulation" + +![](https://ajeuwbhvhr.cloudimg.io/https://colony-recorder.s3.amazonaws.com/files/2025-12-22/7447cbe0-3450-4be5-bdc4-37fb8280aa58/ascreenshot.jpeg?tl_px=0,125&br_px=1308,856&force_format=jpeg&q=100&width=1120.0&wat=1&wat_opacity=0.7&wat_gravity=northwest&wat_url=https://colony-recorder.s3.us-west-1.amazonaws.com/images/watermarks/FB923C_standard.png&wat_pad=334,277) + + +10\. Click "Export Data Now" to export to CLoudZero + +![](https://ajeuwbhvhr.cloudimg.io/https://colony-recorder.s3.amazonaws.com/files/2025-12-22/7be9bd48-6e27-4c68-bc75-946f3ab593d9/ascreenshot.jpeg?tl_px=0,130&br_px=1308,861&force_format=jpeg&q=100&width=1120.0&wat=1&wat_opacity=0.7&wat_gravity=northwest&wat_url=https://colony-recorder.s3.us-west-1.amazonaws.com/images/watermarks/FB923C_standard.png&wat_pad=518,277) + ## Testing Your Setup ### Dry Run Export diff --git a/docs/my-website/docs/oidc.md b/docs/my-website/docs/oidc.md index 3db4b6ecdc5..b541329aa38 100644 --- a/docs/my-website/docs/oidc.md +++ b/docs/my-website/docs/oidc.md @@ -106,7 +106,7 @@ model_list: aws_region_name: us-west-2 aws_session_name: "my-test-session" aws_role_name: "arn:aws:iam::335785316107:role/litellm-github-unit-tests-circleci" - aws_web_identity_token: "oidc/circleci_v2/" + aws_web_identity_token: "oidc/example-provider/" ``` #### Amazon IAM Role Configuration for CircleCI v2 -> Bedrock diff --git a/docs/my-website/docs/providers/apertis.md b/docs/my-website/docs/providers/apertis.md new file mode 100644 index 00000000000..967de8147e2 --- /dev/null +++ b/docs/my-website/docs/providers/apertis.md @@ -0,0 +1,129 @@ +# Apertis AI (Stima API) + +## Overview + +| Property | Details | +|-------|-------| +| Description | Apertis AI (formerly Stima API) is a unified API platform providing access to 430+ AI models through a single interface, with cost savings of up to 50%. | +| Provider Route on LiteLLM | `apertis/` | +| Link to Provider Doc | [Apertis AI Website ↗](https://api.stima.tech) | +| Base URL | `https://api.stima.tech/v1` | +| Supported Operations | [`/chat/completions`](#sample-usage) | + +
+ +## What is Apertis AI? + +Apertis AI is a unified API platform that lets developers: +- **Access 430+ AI Models**: All models through a single API +- **Save 50% on Costs**: Competitive pricing with significant discounts +- **Unified Billing**: Single bill for all model usage +- **Quick Setup**: Start with just $2 registration +- **GitHub Integration**: Link with your GitHub account + +## Required Variables + +```python showLineNumbers title="Environment Variables" +os.environ["STIMA_API_KEY"] = "" # your Apertis AI API key +``` + +Get your Apertis AI API key from [api.stima.tech](https://api.stima.tech). + +## Usage - LiteLLM Python SDK + +### Non-streaming + +```python showLineNumbers title="Apertis AI Non-streaming Completion" +import os +import litellm +from litellm import completion + +os.environ["STIMA_API_KEY"] = "" # your Apertis AI API key + +messages = [{"content": "What is the capital of France?", "role": "user"}] + +# Apertis AI call +response = completion( + model="apertis/model-name", # Replace with actual model name + messages=messages +) + +print(response) +``` + +### Streaming + +```python showLineNumbers title="Apertis AI Streaming Completion" +import os +import litellm +from litellm import completion + +os.environ["STIMA_API_KEY"] = "" # your Apertis AI API key + +messages = [{"content": "Write a short poem about AI", "role": "user"}] + +# Apertis AI call with streaming +response = completion( + model="apertis/model-name", # Replace with actual model name + messages=messages, + stream=True +) + +for chunk in response: + print(chunk) +``` + +## Usage - LiteLLM Proxy Server + +### 1. Save key in your environment + +```bash +export STIMA_API_KEY="" +``` + +### 2. Start the proxy + +```yaml +model_list: + - model_name: apertis-model + litellm_params: + model: apertis/model-name # Replace with actual model name + api_key: os.environ/STIMA_API_KEY +``` + +## Supported OpenAI Parameters + +Apertis AI supports all standard OpenAI-compatible parameters: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `messages` | array | **Required**. Array of message objects with 'role' and 'content' | +| `model` | string | **Required**. Model ID from 430+ available models | +| `stream` | boolean | Optional. Enable streaming responses | +| `temperature` | float | Optional. Sampling temperature | +| `top_p` | float | Optional. Nucleus sampling parameter | +| `max_tokens` | integer | Optional. Maximum tokens to generate | +| `frequency_penalty` | float | Optional. Penalize frequent tokens | +| `presence_penalty` | float | Optional. Penalize tokens based on presence | +| `stop` | string/array | Optional. Stop sequences | +| `tools` | array | Optional. List of available tools/functions | +| `tool_choice` | string/object | Optional. Control tool/function calling | + +## Cost Benefits + +Apertis AI offers significant cost advantages: +- **50% Cost Savings**: Save money compared to direct provider costs +- **Unified Billing**: Single invoice for all your AI model usage +- **Low Entry**: Start with just $2 registration + +## Model Availability + +With access to 430+ AI models, Apertis AI provides: +- Multiple providers through one API +- Latest model releases +- Various model types (text, image, video) + +## Additional Resources + +- [Apertis AI Website](https://api.stima.tech) +- [Apertis AI Enterprise](https://api.stima.tech/enterprise) diff --git a/docs/my-website/docs/providers/chutes.md b/docs/my-website/docs/providers/chutes.md new file mode 100644 index 00000000000..e2b81837c34 --- /dev/null +++ b/docs/my-website/docs/providers/chutes.md @@ -0,0 +1,172 @@ +# Chutes + +## Overview + +| Property | Details | +|-------|-------| +| Description | Chutes is a cloud-native AI deployment platform that allows you to deploy, run, and scale LLM applications with OpenAI-compatible APIs using pre-built templates for popular frameworks like vLLM and SGLang. | +| Provider Route on LiteLLM | `chutes/` | +| Link to Provider Doc | [Chutes Website ↗](https://chutes.ai) | +| Base URL | `https://llm.chutes.ai/v1/` | +| Supported Operations | [`/chat/completions`](#sample-usage), Embeddings | + +
+ +## What is Chutes? + +Chutes is a powerful AI deployment and serving platform that provides: +- **Pre-built Templates**: Ready-to-use configurations for vLLM, SGLang, diffusion models, and embeddings +- **OpenAI-Compatible APIs**: Use standard OpenAI SDKs and clients +- **Multi-GPU Scaling**: Support for large models across multiple GPUs +- **Streaming Responses**: Real-time model outputs +- **Custom Configurations**: Override any parameter for your specific needs +- **Performance Optimization**: Pre-configured optimization settings + +## Required Variables + +```python showLineNumbers title="Environment Variables" +os.environ["CHUTES_API_KEY"] = "" # your Chutes API key +``` + +Get your Chutes API key from [chutes.ai](https://chutes.ai). + +## Usage - LiteLLM Python SDK + +### Non-streaming + +```python showLineNumbers title="Chutes Non-streaming Completion" +import os +import litellm +from litellm import completion + +os.environ["CHUTES_API_KEY"] = "" # your Chutes API key + +messages = [{"content": "What is the capital of France?", "role": "user"}] + +# Chutes call +response = completion( + model="chutes/model-name", # Replace with actual model name + messages=messages +) + +print(response) +``` + +### Streaming + +```python showLineNumbers title="Chutes Streaming Completion" +import os +import litellm +from litellm import completion + +os.environ["CHUTES_API_KEY"] = "" # your Chutes API key + +messages = [{"content": "Write a short poem about AI", "role": "user"}] + +# Chutes call with streaming +response = completion( + model="chutes/model-name", # Replace with actual model name + messages=messages, + stream=True +) + +for chunk in response: + print(chunk) +``` + +## Usage - LiteLLM Proxy Server + +### 1. Save key in your environment + +```bash +export CHUTES_API_KEY="" +``` + +### 2. Start the proxy + +```yaml +model_list: + - model_name: chutes-model + litellm_params: + model: chutes/model-name # Replace with actual model name + api_key: os.environ/CHUTES_API_KEY +``` + +## Supported OpenAI Parameters + +Chutes supports all standard OpenAI-compatible parameters: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `messages` | array | **Required**. Array of message objects with 'role' and 'content' | +| `model` | string | **Required**. Model ID or HuggingFace model identifier | +| `stream` | boolean | Optional. Enable streaming responses | +| `temperature` | float | Optional. Sampling temperature | +| `top_p` | float | Optional. Nucleus sampling parameter | +| `max_tokens` | integer | Optional. Maximum tokens to generate | +| `frequency_penalty` | float | Optional. Penalize frequent tokens | +| `presence_penalty` | float | Optional. Penalize tokens based on presence | +| `stop` | string/array | Optional. Stop sequences | +| `tools` | array | Optional. List of available tools/functions | +| `tool_choice` | string/object | Optional. Control tool/function calling | +| `response_format` | object | Optional. Response format specification | + +## Support Frameworks + +Chutes provides optimized templates for popular AI frameworks: + +### vLLM (High-Performance LLM Serving) +- OpenAI-compatible endpoints +- Multi-GPU scaling support +- Advanced optimization settings +- Best for production workloads + +### SGLang (Advanced LLM Serving) +- Structured generation capabilities +- Advanced features and controls +- Custom configuration options +- Best for complex use cases + +### Diffusion Models (Image Generation) +- Pre-configured image generation templates +- Optimized settings for best results +- Support for popular diffusion models + +### Embedding Models +- Text embedding templates +- Vector search optimization +- Support for popular embedding models + +## Authentication + +Chutes supports multiple authentication methods: +- API Key via `X-API-Key` header +- Bearer token via `Authorization` header + +Example for LiteLLM (uses environment variable): +```python +os.environ["CHUTES_API_KEY"] = "your-api-key" +``` + +## Performance Optimization + +Chutes offers hardware selection and optimization: +- **Small Models (7B-13B)**: 1 GPU with 24GB VRAM +- **Medium Models (30B-70B)**: 4 GPUs with 80GB VRAM each +- **Large Models (100B+)**: 8 GPUs with 140GB+ VRAM each + +Engine optimization parameters available for fine-tuning performance. + +## Deployment Options + +Chutes provides flexible deployment: +- **Quick Setup**: Use pre-built templates for instant deployment +- **Custom Images**: Deploy with custom Docker images +- **Scaling**: Configure max instances and auto-scaling thresholds +- **Hardware**: Choose specific GPU types and configurations + +## Additional Resources + +- [Chutes Documentation](https://chutes.ai/docs) +- [Chutes Getting Started](https://chutes.ai/docs/getting-started/running-a-chute) +- [Chutes API Reference](https://chutes.ai/docs/sdk-reference) diff --git a/docs/my-website/docs/providers/databricks.md b/docs/my-website/docs/providers/databricks.md index 921b06a17b7..2791d55dff1 100644 --- a/docs/my-website/docs/providers/databricks.md +++ b/docs/my-website/docs/providers/databricks.md @@ -11,6 +11,99 @@ LiteLLM supports all models on Databricks ::: +## Authentication + +LiteLLM supports multiple authentication methods for Databricks, listed in order of preference: + +### OAuth M2M (Recommended for Production) + +OAuth Machine-to-Machine authentication using Service Principal credentials is the **recommended method for production** deployments per Databricks Partner requirements. + +```python +import os +from litellm import completion + +# Set OAuth credentials (Service Principal) +os.environ["DATABRICKS_CLIENT_ID"] = "your-service-principal-application-id" +os.environ["DATABRICKS_CLIENT_SECRET"] = "your-service-principal-secret" +os.environ["DATABRICKS_API_BASE"] = "https://adb-xxx.azuredatabricks.net/serving-endpoints" + +response = completion( + model="databricks/databricks-dbrx-instruct", + messages=[{"role": "user", "content": "Hello!"}], +) +``` + +### Personal Access Token (PAT) + +PAT authentication is supported for development and testing scenarios. + +```python +import os +from litellm import completion + +os.environ["DATABRICKS_API_KEY"] = "dapi..." # Your Personal Access Token +os.environ["DATABRICKS_API_BASE"] = "https://adb-xxx.azuredatabricks.net/serving-endpoints" + +response = completion( + model="databricks/databricks-dbrx-instruct", + messages=[{"role": "user", "content": "Hello!"}], +) +``` + +### Databricks SDK Authentication (Automatic) + +If no credentials are provided, LiteLLM will use the Databricks SDK for automatic authentication. This supports OAuth, Azure AD, and other unified auth methods configured in your environment. + +```python +from litellm import completion + +# No environment variables needed - uses Databricks SDK unified auth +# Requires: pip install databricks-sdk +response = completion( + model="databricks/databricks-dbrx-instruct", + messages=[{"role": "user", "content": "Hello!"}], +) +``` + +## Custom User-Agent for Partner Attribution + +If you're building a product on top of LiteLLM that integrates with Databricks, you can pass your own partner identifier for proper attribution in Databricks telemetry. + +The partner name will be prefixed to the LiteLLM user agent: + +```python +# Via parameter +response = completion( + model="databricks/databricks-dbrx-instruct", + messages=[{"role": "user", "content": "Hello!"}], + user_agent="mycompany/1.0.0", +) +# Resulting User-Agent: mycompany_litellm/1.79.1 + +# Via environment variable +os.environ["DATABRICKS_USER_AGENT"] = "mycompany/1.0.0" +# Resulting User-Agent: mycompany_litellm/1.79.1 +``` + +| Input | Resulting User-Agent | +|-------|---------------------| +| (none) | `litellm/1.79.1` | +| `mycompany/1.0.0` | `mycompany_litellm/1.79.1` | +| `partner_product/2.5.0` | `partner_product_litellm/1.79.1` | +| `acme` | `acme_litellm/1.79.1` | + +**Note:** The version from your custom user agent is ignored; LiteLLM's version is always used. + +## Security + +LiteLLM automatically redacts sensitive information (tokens, secrets, API keys) from all debug logs to prevent credential leakage. This includes: + +- Authorization headers +- API keys and tokens +- Client secrets +- Personal access tokens (PATs) + ## Usage @@ -51,6 +144,7 @@ response = completion( model: databricks/databricks-dbrx-instruct api_key: os.environ/DATABRICKS_API_KEY api_base: os.environ/DATABRICKS_API_BASE + user_agent: "mycompany/1.0.0" # Optional: for partner attribution ``` diff --git a/docs/my-website/docs/providers/groq.md b/docs/my-website/docs/providers/groq.md index ebed31f720f..55c222635d2 100644 --- a/docs/my-website/docs/providers/groq.md +++ b/docs/my-website/docs/providers/groq.md @@ -150,15 +150,15 @@ We support ALL Groq models, just set `groq/` as a prefix when sending completion | Model Name | Usage | |--------------------|---------------------------------------------------------| -| llama-3.1-8b-instant | `completion(model="groq/llama-3.1-8b-instant", messages)` | -| llama-3.1-70b-versatile | `completion(model="groq/llama-3.1-70b-versatile", messages)` | -| llama3-8b-8192 | `completion(model="groq/llama3-8b-8192", messages)` | -| llama3-70b-8192 | `completion(model="groq/llama3-70b-8192", messages)` | -| llama2-70b-4096 | `completion(model="groq/llama2-70b-4096", messages)` | -| mixtral-8x7b-32768 | `completion(model="groq/mixtral-8x7b-32768", messages)` | -| gemma-7b-it | `completion(model="groq/gemma-7b-it", messages)` | -| moonshotai/kimi-k2-instruct | `completion(model="groq/moonshotai/kimi-k2-instruct", messages)` | -| qwen3-32b | `completion(model="groq/qwen/qwen3-32b", messages)` | +| llama-3.3-70b-versatile | `completion(model="groq/llama-3.3-70b-versatile", messages)` | +| llama-3.1-8b-instant | `completion(model="groq/llama-3.1-8b-instant", messages)` | +| meta-llama/llama-4-scout-17b-16e-instruct | `completion(model="groq/meta-llama/llama-4-scout-17b-16e-instruct", messages)` | +| meta-llama/llama-4-maverick-17b-128e-instruct | `completion(model="groq/meta-llama/llama-4-maverick-17b-128e-instruct", messages)` | +| meta-llama/llama-guard-4-12b | `completion(model="groq/meta-llama/llama-guard-4-12b", messages)` | +| qwen/qwen3-32b | `completion(model="groq/qwen/qwen3-32b", messages)` | +| moonshotai/kimi-k2-instruct-0905 | `completion(model="groq/moonshotai/kimi-k2-instruct-0905", messages)` | +| openai/gpt-oss-120b | `completion(model="groq/openai/gpt-oss-120b", messages)` | +| openai/gpt-oss-20b | `completion(model="groq/openai/gpt-oss-20b", messages)` | ## Groq - Tool / Function Calling Example @@ -261,31 +261,28 @@ if tool_calls: print("second response\n", second_response) ``` -## Groq - Vision Example +## Groq - Vision Example -Select Groq models support vision. Check out their [model list](https://console.groq.com/docs/vision) for more details. +Groq's Llama 4 models support vision. Check out their [model list](https://console.groq.com/docs/vision) for more details. ```python -from litellm import completion - -import os +import os from litellm import completion os.environ["GROQ_API_KEY"] = "your-api-key" -# openai call response = completion( - model = "groq/llama-3.2-11b-vision-preview", + model = "groq/meta-llama/llama-4-scout-17b-16e-instruct", messages=[ { "role": "user", "content": [ { "type": "text", - "text": "What’s in this image?" + "text": "What's in this image?" }, { "type": "image_url", diff --git a/docs/my-website/docs/providers/minimax.md b/docs/my-website/docs/providers/minimax.md new file mode 100644 index 00000000000..9505c26aade --- /dev/null +++ b/docs/my-website/docs/providers/minimax.md @@ -0,0 +1,639 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# MiniMax + +# MiniMax - v1/messages + +## Overview + +Litellm provides anthropic specs compatible support for minmax + +## Supported Models + +MiniMax offers three models through their Anthropic-compatible API: + +| Model | Description | Input Cost | Output Cost | Prompt Caching Read | Prompt Caching Write | +|-------|-------------|------------|-------------|---------------------|----------------------| +| **MiniMax-M2.1** | Powerful Multi-Language Programming with Enhanced Programming Experience (~60 tps) | $0.3/M tokens | $1.2/M tokens | $0.03/M tokens | $0.375/M tokens | +| **MiniMax-M2.1-lightning** | Faster and More Agile (~100 tps) | $0.3/M tokens | $2.4/M tokens | $0.03/M tokens | $0.375/M tokens | +| **MiniMax-M2** | Agentic capabilities, Advanced reasoning | $0.3/M tokens | $1.2/M tokens | $0.03/M tokens | $0.375/M tokens | + + +## Usage Examples + +### Basic Chat Completion + +```python +import litellm + +response = litellm.anthropic.messages.acreate( + model="minimax/MiniMax-M2.1", + messages=[{"role": "user", "content": "Hello, how are you?"}], + api_key="your-minimax-api-key", + api_base="https://api.minimax.io/anthropic/v1/messages", + max_tokens=1000 +) + +print(response.choices[0].message.content) +``` + +### Using Environment Variables + +```bash +export MINIMAX_API_KEY="your-minimax-api-key" +export MINIMAX_API_BASE="https://api.minimax.io/anthropic/v1/messages" +``` + +```python +import litellm + +response = litellm.anthropic.messages.acreate( + model="minimax/MiniMax-M2.1", + messages=[{"role": "user", "content": "Hello!"}], + max_tokens=1000 +) +``` + +### With Thinking (M2.1 Feature) + +```python +response = litellm.anthropic.messages.acreate( + model="minimax/MiniMax-M2.1", + messages=[{"role": "user", "content": "Solve: 2+2=?"}], + thinking={"type": "enabled", "budget_tokens": 1000}, + api_key="your-minimax-api-key" +) + +# Access thinking content +for block in response.choices[0].message.content: + if hasattr(block, 'type') and block.type == 'thinking': + print(f"Thinking: {block.thinking}") +``` + +### With Tool Calling + +```python +tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get current weather", + "parameters": { + "type": "object", + "properties": { + "location": {"type": "string"} + }, + "required": ["location"] + } + } + } +] + +response = litellm.anthropic.messages.acreate( + model="minimax/MiniMax-M2.1", + messages=[{"role": "user", "content": "What's the weather in SF?"}], + tools=tools, + api_key="your-minimax-api-key", + max_tokens=1000 +) +``` + + + +## Usage with LiteLLM Proxy + +You can use MiniMax models with the Anthropic SDK by routing through LiteLLM Proxy: + +| Step | Description | +|------|-------------| +| **1. Start LiteLLM Proxy** | Configure proxy with MiniMax models in `config.yaml` | +| **2. Set Environment Variables** | Point Anthropic SDK to proxy endpoint | +| **3. Use Anthropic SDK** | Call MiniMax models using native Anthropic SDK | + +### Step 1: Configure LiteLLM Proxy + +Create a `config.yaml`: + +```yaml +model_list: + - model_name: minimax/MiniMax-M2.1 + litellm_params: + model: minimax/MiniMax-M2.1 + api_key: os.environ/MINIMAX_API_KEY + api_base: https://api.minimax.io/anthropic/v1/messages +``` + +Start the proxy: + +```bash +litellm --config config.yaml +``` + +### Step 2: Use with Anthropic SDK + +```python +import os +os.environ["ANTHROPIC_BASE_URL"] = "http://localhost:4000" +os.environ["ANTHROPIC_API_KEY"] = "sk-1234" # Your LiteLLM proxy key + +import anthropic + +client = anthropic.Anthropic() + +message = client.messages.create( + model="minimax/MiniMax-M2.1", + max_tokens=1000, + system="You are a helpful assistant.", + messages=[ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Hi, how are you?" + } + ] + } + ] +) + +for block in message.content: + if block.type == "thinking": + print(f"Thinking:\n{block.thinking}\n") + elif block.type == "text": + print(f"Text:\n{block.text}\n") +``` + +# MiniMax - v1/chat/completions + +## Usage with LiteLLM SDK + +You can use MiniMax's OpenAI-compatible API directly with LiteLLM: + +### Basic Chat Completion + +```python +import litellm + +response = litellm.completion( + model="minimax/MiniMax-M2.1", + messages=[ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Hello, how are you?"} + ], + api_key="your-minimax-api-key", + api_base="https://api.minimax.io/v1" +) + +print(response.choices[0].message.content) +``` + +### Using Environment Variables + +```bash +export MINIMAX_API_KEY="your-minimax-api-key" +export MINIMAX_API_BASE="https://api.minimax.io/v1" +``` + +```python +import litellm + +response = litellm.completion( + model="minimax/MiniMax-M2.1", + messages=[{"role": "user", "content": "Hello!"}] +) +``` + +### With Reasoning Split + +```python +response = litellm.completion( + model="minimax/MiniMax-M2.1", + messages=[ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Solve: 2+2=?"} + ], + extra_body={"reasoning_split": True}, + api_key="your-minimax-api-key", + api_base="https://api.minimax.io/v1" +) + +# Access reasoning details if available +if hasattr(response.choices[0].message, 'reasoning_details'): + print(f"Thinking: {response.choices[0].message.reasoning_details}") +print(f"Response: {response.choices[0].message.content}") +``` + +### With Tool Calling + +```python +tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get current weather", + "parameters": { + "type": "object", + "properties": { + "location": {"type": "string"} + }, + "required": ["location"] + } + } + } +] + +response = litellm.completion( + model="minimax/MiniMax-M2.1", + messages=[{"role": "user", "content": "What's the weather in SF?"}], + tools=tools, + api_key="your-minimax-api-key", + api_base="https://api.minimax.io/v1" +) +``` + +### Streaming + +```python +response = litellm.completion( + model="minimax/MiniMax-M2.1", + messages=[{"role": "user", "content": "Tell me a story"}], + stream=True, + api_key="your-minimax-api-key", + api_base="https://api.minimax.io/v1" +) + +for chunk in response: + if chunk.choices[0].delta.content: + print(chunk.choices[0].delta.content, end="") +``` + + +## Usage with OpenAI SDK via LiteLLM Proxy + +You can also use MiniMax models with the OpenAI SDK by routing through LiteLLM Proxy: + +| Step | Description | +|------|-------------| +| **1. Start LiteLLM Proxy** | Configure proxy with MiniMax models in `config.yaml` | +| **2. Set Environment Variables** | Point OpenAI SDK to proxy endpoint | +| **3. Use OpenAI SDK** | Call MiniMax models using native OpenAI SDK | + +### Step 1: Configure LiteLLM Proxy + +Create a `config.yaml`: + +```yaml +model_list: + - model_name: minimax/MiniMax-M2.1 + litellm_params: + model: minimax/MiniMax-M2.1 + api_key: os.environ/MINIMAX_API_KEY + api_base: https://api.minimax.io/v1 +``` + +Start the proxy: + +```bash +litellm --config config.yaml +``` + +### Step 2: Use with OpenAI SDK + +```python +import os +os.environ["OPENAI_BASE_URL"] = "http://localhost:4000" +os.environ["OPENAI_API_KEY"] = "sk-1234" # Your LiteLLM proxy key + +from openai import OpenAI + +client = OpenAI() + +response = client.chat.completions.create( + model="minimax/MiniMax-M2.1", + messages=[ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Hi, how are you?"}, + ], + # Set reasoning_split=True to separate thinking content + extra_body={"reasoning_split": True}, +) + +# Access thinking and response +if hasattr(response.choices[0].message, 'reasoning_details'): + print(f"Thinking:\n{response.choices[0].message.reasoning_details[0]['text']}\n") +print(f"Text:\n{response.choices[0].message.content}\n") +``` + +### Streaming with OpenAI SDK + +```python +from openai import OpenAI + +client = OpenAI() + +stream = client.chat.completions.create( + model="minimax/MiniMax-M2.1", + messages=[ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Tell me a story"}, + ], + extra_body={"reasoning_split": True}, + stream=True, +) + +reasoning_buffer = "" +text_buffer = "" + +for chunk in stream: + if hasattr(chunk.choices[0].delta, "reasoning_details") and chunk.choices[0].delta.reasoning_details: + for detail in chunk.choices[0].delta.reasoning_details: + if "text" in detail: + reasoning_text = detail["text"] + new_reasoning = reasoning_text[len(reasoning_buffer):] + if new_reasoning: + print(new_reasoning, end="", flush=True) + reasoning_buffer = reasoning_text + + if chunk.choices[0].delta.content: + content_text = chunk.choices[0].delta.content + new_text = content_text[len(text_buffer):] if text_buffer else content_text + if new_text: + print(new_text, end="", flush=True) + text_buffer = content_text +``` + +## Cost Calculation + +Cost calculation works automatically using the pricing information in `model_prices_and_context_window.json`. + +Example: +```python +response = litellm.completion( + model="minimax/MiniMax-M2.1", + messages=[{"role": "user", "content": "Hello!"}], + api_key="your-minimax-api-key" +) + +# Access cost information +print(f"Cost: ${response._hidden_params.get('response_cost', 0)}") +``` + +# MiniMax - Text-to-Speech + +## Quick Start + +## **LiteLLM Python SDK Usage** + +### Basic Usage + +```python +from pathlib import Path +from litellm import speech +import os + +os.environ["MINIMAX_API_KEY"] = "your-api-key" + +speech_file_path = Path(__file__).parent / "speech.mp3" +response = speech( + model="minimax/speech-2.6-hd", + voice="alloy", + input="The quick brown fox jumped over the lazy dogs", +) +response.stream_to_file(speech_file_path) +``` + +### Async Usage + +```python +from litellm import aspeech +from pathlib import Path +import os, asyncio + +os.environ["MINIMAX_API_KEY"] = "your-api-key" + +async def test_async_speech(): + speech_file_path = Path(__file__).parent / "speech.mp3" + response = await aspeech( + model="minimax/speech-2.6-hd", + voice="alloy", + input="The quick brown fox jumped over the lazy dogs", + ) + response.stream_to_file(speech_file_path) + +asyncio.run(test_async_speech()) +``` + +### Voice Selection + +MiniMax supports many voices. LiteLLM provides OpenAI-compatible voice names that map to MiniMax voices: + +```python +from litellm import speech + +# OpenAI-compatible voice names +voices = ["alloy", "echo", "fable", "onyx", "nova", "shimmer"] + +for voice in voices: + response = speech( + model="minimax/speech-2.6-hd", + voice=voice, + input=f"This is the {voice} voice", + ) + response.stream_to_file(f"speech_{voice}.mp3") +``` + +You can also use MiniMax-native voice IDs directly: + +```python +response = speech( + model="minimax/speech-2.6-hd", + voice="male-qn-qingse", # MiniMax native voice ID + input="Using native MiniMax voice ID", +) +``` + +### Custom Parameters + +MiniMax TTS supports additional parameters for fine-tuning audio output: + +```python +from litellm import speech + +response = speech( + model="minimax/speech-2.6-hd", + voice="alloy", + input="Custom audio parameters", + speed=1.5, # Speed: 0.5 to 2.0 + response_format="mp3", # Format: mp3, pcm, wav, flac + extra_body={ + "vol": 1.2, # Volume: 0.1 to 10 + "pitch": 2, # Pitch adjustment: -12 to 12 + "sample_rate": 32000, # 16000, 24000, or 32000 + "bitrate": 128000, # For MP3: 64000, 128000, 192000, 256000 + "channel": 1, # 1 for mono, 2 for stereo + } +) +response.stream_to_file("custom_speech.mp3") +``` + +### Response Formats + +```python +from litellm import speech + +# MP3 format (default) +response = speech( + model="minimax/speech-2.6-hd", + voice="alloy", + input="MP3 format audio", + response_format="mp3", +) + +# PCM format +response = speech( + model="minimax/speech-2.6-hd", + voice="alloy", + input="PCM format audio", + response_format="pcm", +) + +# WAV format +response = speech( + model="minimax/speech-2.6-hd", + voice="alloy", + input="WAV format audio", + response_format="wav", +) + +# FLAC format +response = speech( + model="minimax/speech-2.6-hd", + voice="alloy", + input="FLAC format audio", + response_format="flac", +) +``` + +## **LiteLLM Proxy Usage** + +LiteLLM provides an OpenAI-compatible `/audio/speech` endpoint for MiniMax TTS. + +### Setup + +Add MiniMax to your proxy configuration: + +```yaml +model_list: + - model_name: tts + litellm_params: + model: minimax/speech-2.6-hd + api_key: os.environ/MINIMAX_API_KEY + + - model_name: tts-turbo + litellm_params: + model: minimax/speech-2.6-turbo + api_key: os.environ/MINIMAX_API_KEY +``` + +Start the proxy: + +```bash +litellm --config /path/to/config.yaml + +# RUNNING on http://0.0.0.0:4000 +``` + +### Making Requests + +```bash +curl http://0.0.0.0:4000/v1/audio/speech \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "tts", + "input": "The quick brown fox jumped over the lazy dog.", + "voice": "alloy" + }' \ + --output speech.mp3 +``` + +With custom parameters: + +```bash +curl http://0.0.0.0:4000/v1/audio/speech \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "tts", + "input": "Custom parameters example.", + "voice": "nova", + "speed": 1.5, + "response_format": "mp3", + "extra_body": { + "vol": 1.2, + "pitch": 1, + "sample_rate": 32000 + } + }' \ + --output custom_speech.mp3 +``` + +## Voice Mappings + +LiteLLM maps OpenAI-compatible voice names to MiniMax voice IDs: + +| OpenAI Voice | MiniMax Voice ID | Description | +|--------------|------------------|-------------| +| alloy | male-qn-qingse | Male voice | +| echo | male-qn-jingying | Male voice | +| fable | female-shaonv | Female voice | +| onyx | male-qn-badao | Male voice | +| nova | female-yujie | Female voice | +| shimmer | female-tianmei | Female voice | + +You can also use any MiniMax-native voice ID directly by passing it as the `voice` parameter. + + +### Streaming (WebSocket) + +:::note +The current implementation uses MiniMax's HTTP endpoint. For WebSocket streaming support, please refer to MiniMax's official documentation at [https://platform.minimax.io/docs](https://platform.minimax.io/docs). +::: + +## Error Handling + +```python +from litellm import speech +import litellm + +try: + response = speech( + model="minimax/speech-2.6-hd", + voice="alloy", + input="Test input", + ) + response.stream_to_file("output.mp3") +except litellm.exceptions.BadRequestError as e: + print(f"Bad request: {e}") +except litellm.exceptions.AuthenticationError as e: + print(f"Authentication failed: {e}") +except Exception as e: + print(f"Error: {e}") +``` + +### Extra Body Parameters + +Pass these via `extra_body`: + +| Parameter | Type | Description | Default | +|-----------|------|-------------|---------| +| vol | float | Volume (0.1 to 10) | 1.0 | +| pitch | int | Pitch adjustment (-12 to 12) | 0 | +| sample_rate | int | Sample rate: 16000, 24000, 32000 | 32000 | +| bitrate | int | Bitrate for MP3: 64000, 128000, 192000, 256000 | 128000 | +| channel | int | Audio channels: 1 (mono) or 2 (stereo) | 1 | +| output_format | string | Output format: "hex" or "url" (url returns a URL valid for 24 hours) | hex | diff --git a/docs/my-website/docs/providers/nano-gpt.md b/docs/my-website/docs/providers/nano-gpt.md new file mode 100644 index 00000000000..4e46c032c75 --- /dev/null +++ b/docs/my-website/docs/providers/nano-gpt.md @@ -0,0 +1,170 @@ +# NanoGPT + +## Overview + +| Property | Details | +|-------|-------| +| Description | NanoGPT is a pay-per-prompt and subscription based AI service providing instant access to over 200+ powerful AI models with no subscriptions or registration required. | +| Provider Route on LiteLLM | `nano-gpt/` | +| Link to Provider Doc | [NanoGPT Website ↗](https://nano-gpt.com) | +| Base URL | `https://nano-gpt.com/api/v1` | +| Supported Operations | [`/chat/completions`](#sample-usage), [`/completions`](#text-completion), [`/embeddings`](#embeddings) | + +
+ +## What is NanoGPT? + +NanoGPT is a flexible AI API service that offers: +- **Pay-Per-Prompt Pricing**: No subscriptions, pay only for what you use +- **200+ AI Models**: Access to text, image, and video generation models +- **No Registration Required**: Get started instantly +- **OpenAI-Compatible API**: Easy integration with existing code +- **Streaming Support**: Real-time response streaming +- **Tool Calling**: Support for function calling + +## Required Variables + +```python showLineNumbers title="Environment Variables" +os.environ["NANOGPT_API_KEY"] = "" # your NanoGPT API key +``` + +Get your NanoGPT API key from [nano-gpt.com](https://nano-gpt.com). + +## Usage - LiteLLM Python SDK + +### Non-streaming + +```python showLineNumbers title="NanoGPT Non-streaming Completion" +import os +import litellm +from litellm import completion + +os.environ["NANOGPT_API_KEY"] = "" # your NanoGPT API key + +messages = [{"content": "What is the capital of France?", "role": "user"}] + +# NanoGPT call +response = completion( + model="nano-gpt/model-name", # Replace with actual model name + messages=messages +) + +print(response) +``` + +### Streaming + +```python showLineNumbers title="NanoGPT Streaming Completion" +import os +import litellm +from litellm import completion + +os.environ["NANOGPT_API_KEY"] = "" # your NanoGPT API key + +messages = [{"content": "Write a short poem about AI", "role": "user"}] + +# NanoGPT call with streaming +response = completion( + model="nano-gpt/model-name", # Replace with actual model name + messages=messages, + stream=True +) + +for chunk in response: + print(chunk) +``` + +### Tool Calling + +```python showLineNumbers title="NanoGPT Tool Calling" +import os +import litellm + +os.environ["NANOGPT_API_KEY"] = "" + +tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get current weather", + "parameters": { + "type": "object", + "properties": { + "location": {"type": "string"} + } + } + } + } +] + +response = litellm.completion( + model="nano-gpt/model-name", + messages=[{"role": "user", "content": "What's the weather in Paris?"}], + tools=tools +) +``` + +## Usage - LiteLLM Proxy Server + +### 1. Save key in your environment + +```bash +export NANOGPT_API_KEY="" +``` + +### 2. Start the proxy + +```yaml +model_list: + - model_name: nano-gpt-model + litellm_params: + model: nano-gpt/model-name # Replace with actual model name + api_key: os.environ/NANOGPT_API_KEY +``` + +## Supported OpenAI Parameters + +NanoGPT supports all standard OpenAI-compatible parameters: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `messages` | array | **Required**. Array of message objects with 'role' and 'content' | +| `model` | string | **Required**. Model ID from 200+ available models | +| `stream` | boolean | Optional. Enable streaming responses | +| `temperature` | float | Optional. Sampling temperature | +| `top_p` | float | Optional. Nucleus sampling parameter | +| `max_tokens` | integer | Optional. Maximum tokens to generate | +| `frequency_penalty` | float | Optional. Penalize frequent tokens | +| `presence_penalty` | float | Optional. Penalize tokens based on presence | +| `stop` | string/array | Optional. Stop sequences | +| `n` | integer | Optional. Number of completions to generate | +| `tools` | array | Optional. List of available tools/functions | +| `tool_choice` | string/object | Optional. Control tool/function calling | +| `response_format` | object | Optional. Response format specification | +| `user` | string | Optional. User identifier | + +## Model Categories + +NanoGPT provides access to multiple model categories: +- **Text Generation**: 200+ LLMs for chat, completion, and analysis +- **Image Generation**: AI models for creating images +- **Video Generation**: AI models for video creation +- **Embedding Models**: Text embedding models for vector search + +## Pricing Model + +NanoGPT offers a flexible pricing structure: +- **Pay-Per-Prompt**: No subscription required +- **No Registration**: Get started immediately +- **Transparent Pricing**: Pay only for what you use + +## API Documentation + +For detailed API documentation, visit [docs.nano-gpt.com](https://docs.nano-gpt.com). + +## Additional Resources + +- [NanoGPT Website](https://nano-gpt.com) +- [NanoGPT API Documentation](https://nano-gpt.com/api) +- [NanoGPT Model List](https://docs.nano-gpt.com/api-reference/endpoint/models) diff --git a/docs/my-website/docs/providers/openai.md b/docs/my-website/docs/providers/openai.md index 509a106d8a4..80645a51ac5 100644 --- a/docs/my-website/docs/providers/openai.md +++ b/docs/my-website/docs/providers/openai.md @@ -495,7 +495,7 @@ curl -X POST 'http://0.0.0.0:4000/chat/completions' \ |-------|----------------------|------------------| | `gpt-5.1` | `none` | `none`, `low`, `medium`, `high` | | `gpt-5` | `medium` | `minimal`, `low`, `medium`, `high` | -| `gpt-5-mini` | `medium` | `none`, `minimal`, `low`, `medium`, `high` | +| `gpt-5-mini` | `medium` | `minimal`, `low`, `medium`, `high` | | `gpt-5-nano` | `none` | `none`, `low`, `medium`, `high` | | `gpt-5-codex` | `adaptive` | `low`, `medium`, `high` (no `minimal`) | | `gpt-5.1-codex` | `adaptive` | `low`, `medium`, `high` (no `minimal`) | diff --git a/docs/my-website/docs/providers/poe.md b/docs/my-website/docs/providers/poe.md new file mode 100644 index 00000000000..ba4089ae6a4 --- /dev/null +++ b/docs/my-website/docs/providers/poe.md @@ -0,0 +1,139 @@ +# Poe + +## Overview + +| Property | Details | +|-------|-------| +| Description | Poe is Quora's AI platform that provides access to more than 100 models across text, image, video, and voice modalities through a developer-friendly API. | +| Provider Route on LiteLLM | `poe/` | +| Link to Provider Doc | [Poe Website ↗](https://poe.com) | +| Base URL | `https://api.poe.com/v1` | +| Supported Operations | [`/chat/completions`](#sample-usage) | + +
+ +## What is Poe? + +Poe is Quora's comprehensive AI platform that offers: +- **100+ Models**: Access to a wide variety of AI models +- **Multiple Modalities**: Text, image, video, and voice AI +- **Popular Models**: Including OpenAI's GPT series and Anthropic's Claude +- **Developer API**: Easy integration for applications +- **Extensive Reach**: Benefits from Quora's 400M monthly unique visitors + +## Required Variables + +```python showLineNumbers title="Environment Variables" +os.environ["POE_API_KEY"] = "" # your Poe API key +``` + +Get your Poe API key from the [Poe platform](https://poe.com). + +## Usage - LiteLLM Python SDK + +### Non-streaming + +```python showLineNumbers title="Poe Non-streaming Completion" +import os +import litellm +from litellm import completion + +os.environ["POE_API_KEY"] = "" # your Poe API key + +messages = [{"content": "What is the capital of France?", "role": "user"}] + +# Poe call +response = completion( + model="poe/model-name", # Replace with actual model name + messages=messages +) + +print(response) +``` + +### Streaming + +```python showLineNumbers title="Poe Streaming Completion" +import os +import litellm +from litellm import completion + +os.environ["POE_API_KEY"] = "" # your Poe API key + +messages = [{"content": "Write a short poem about AI", "role": "user"}] + +# Poe call with streaming +response = completion( + model="poe/model-name", # Replace with actual model name + messages=messages, + stream=True +) + +for chunk in response: + print(chunk) +``` + +## Usage - LiteLLM Proxy Server + +### 1. Save key in your environment + +```bash +export POE_API_KEY="" +``` + +### 2. Start the proxy + +```yaml +model_list: + - model_name: poe-model + litellm_params: + model: poe/model-name # Replace with actual model name + api_key: os.environ/POE_API_KEY +``` + +## Supported OpenAI Parameters + +Poe supports all standard OpenAI-compatible parameters: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `messages` | array | **Required**. Array of message objects with 'role' and 'content' | +| `model` | string | **Required**. Model ID from 100+ available models | +| `stream` | boolean | Optional. Enable streaming responses | +| `temperature` | float | Optional. Sampling temperature | +| `top_p` | float | Optional. Nucleus sampling parameter | +| `max_tokens` | integer | Optional. Maximum tokens to generate | +| `frequency_penalty` | float | Optional. Penalize frequent tokens | +| `presence_penalty` | float | Optional. Penalize tokens based on presence | +| `stop` | string/array | Optional. Stop sequences | +| `tools` | array | Optional. List of available tools/functions | +| `tool_choice` | string/object | Optional. Control tool/function calling | +| `response_format` | object | Optional. Response format specification | +| `user` | string | Optional. User identifier | + +## Available Model Categories + +Poe provides access to models across multiple providers: +- **OpenAI Models**: Including GPT-4, GPT-4 Turbo, GPT-3.5 Turbo +- **Anthropic Models**: Including Claude 3 Opus, Sonnet, Haiku +- **Other Popular Models**: Various provider models available +- **Multi-Modal**: Text, image, video, and voice models + +## Platform Benefits + +Using Poe through LiteLLM offers several advantages: +- **Unified Access**: Single API for many different models +- **Quora Integration**: Access to large user base and content ecosystem +- **Content Sharing**: Capabilities to share model outputs with followers +- **Content Distribution**: Best AI content distributed to all users +- **Model Discovery**: Efficient way to explore new AI models + +## Developer Resources + +Poe is actively building developer features and welcomes early access requests for API integration. + +## Additional Resources + +- [Poe Website](https://poe.com) +- [Poe AI Quora Space](https://poeai.quora.com) +- [Quora Blog Post about Poe](https://quorablog.quora.com/Poe) diff --git a/docs/my-website/docs/providers/synthetic.md b/docs/my-website/docs/providers/synthetic.md new file mode 100644 index 00000000000..b3ba3d0a9e7 --- /dev/null +++ b/docs/my-website/docs/providers/synthetic.md @@ -0,0 +1,119 @@ +# Synthetic + +## Overview + +| Property | Details | +|-------|-------| +| Description | Synthetic runs open-source AI models in secure datacenters within the US and EU, with a focus on privacy. They never train on your data and auto-delete API data within 14 days. | +| Provider Route on LiteLLM | `synthetic/` | +| Link to Provider Doc | [Synthetic Website ↗](https://synthetic.new) | +| Base URL | `https://api.synthetic.new/openai/v1` | +| Supported Operations | [`/chat/completions`](#sample-usage) | + +
+ +## What is Synthetic? + +Synthetic is a privacy-focused AI platform that provides access to open-source LLMs with the following guarantees: +- **Privacy-First**: Data never used for training +- **Secure Hosting**: Models run in secure datacenters in US and EU +- **Auto-Deletion**: API data automatically deleted within 14 days +- **Open Source**: Runs open-source AI models + +## Required Variables + +```python showLineNumbers title="Environment Variables" +os.environ["SYNTHETIC_API_KEY"] = "" # your Synthetic API key +``` + +Get your Synthetic API key from [synthetic.new](https://synthetic.new). + +## Usage - LiteLLM Python SDK + +### Non-streaming + +```python showLineNumbers title="Synthetic Non-streaming Completion" +import os +import litellm +from litellm import completion + +os.environ["SYNTHETIC_API_KEY"] = "" # your Synthetic API key + +messages = [{"content": "What is the capital of France?", "role": "user"}] + +# Synthetic call +response = completion( + model="synthetic/model-name", # Replace with actual model name + messages=messages +) + +print(response) +``` + +### Streaming + +```python showLineNumbers title="Synthetic Streaming Completion" +import os +import litellm +from litellm import completion + +os.environ["SYNTHETIC_API_KEY"] = "" # your Synthetic API key + +messages = [{"content": "Write a short poem about AI", "role": "user"}] + +# Synthetic call with streaming +response = completion( + model="synthetic/model-name", # Replace with actual model name + messages=messages, + stream=True +) + +for chunk in response: + print(chunk) +``` + +## Usage - LiteLLM Proxy Server + +### 1. Save key in your environment + +```bash +export SYNTHETIC_API_KEY="" +``` + +### 2. Start the proxy + +```yaml +model_list: + - model_name: synthetic-model + litellm_params: + model: synthetic/model-name # Replace with actual model name + api_key: os.environ/SYNTHETIC_API_KEY +``` + +## Supported OpenAI Parameters + +Synthetic supports all standard OpenAI-compatible parameters: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `messages` | array | **Required**. Array of message objects with 'role' and 'content' | +| `model` | string | **Required**. Model ID | +| `stream` | boolean | Optional. Enable streaming responses | +| `temperature` | float | Optional. Sampling temperature | +| `top_p` | float | Optional. Nucleus sampling parameter | +| `max_tokens` | integer | Optional. Maximum tokens to generate | +| `frequency_penalty` | float | Optional. Penalize frequent tokens | +| `presence_penalty` | float | Optional. Penalize tokens based on presence | +| `stop` | string/array | Optional. Stop sequences | + +## Privacy & Security + +Synthetic provides enterprise-grade privacy protections: +- Data auto-deleted within 14 days +- No data used for model training +- Secure hosting in US and EU datacenters +- Compliance-friendly architecture + +## Additional Resources + +- [Synthetic Website](https://synthetic.new) diff --git a/docs/my-website/docs/proxy/alerting.md b/docs/my-website/docs/proxy/alerting.md index 4cbcd0cffce..38d6d47be44 100644 --- a/docs/my-website/docs/proxy/alerting.md +++ b/docs/my-website/docs/proxy/alerting.md @@ -215,16 +215,16 @@ general_settings: alerting: ["slack"] alerting_threshold: 0.0001 # (Seconds) set an artificially low threshold for testing alerting alert_to_webhook_url: { - "llm_exceptions": "https://hooks.slack.com/services/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", - "llm_too_slow": "https://hooks.slack.com/services/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", - "llm_requests_hanging": "https://hooks.slack.com/services/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", - "budget_alerts": "https://hooks.slack.com/services/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", - "db_exceptions": "https://hooks.slack.com/services/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", - "daily_reports": "https://hooks.slack.com/services/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", - "spend_reports": "https://hooks.slack.com/services/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", - "cooldown_deployment": "https://hooks.slack.com/services/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", - "new_model_added": "https://hooks.slack.com/services/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", - "outage_alerts": "https://hooks.slack.com/services/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", + "llm_exceptions": "example-slack-webhook-url", + "llm_too_slow": "example-slack-webhook-url", + "llm_requests_hanging": "example-slack-webhook-url", + "budget_alerts": "example-slack-webhook-url", + "db_exceptions": "example-slack-webhook-url", + "daily_reports": "example-slack-webhook-url", + "spend_reports": "example-slack-webhook-url", + "cooldown_deployment": "example-slack-webhook-url", + "new_model_added": "example-slack-webhook-url", + "outage_alerts": "example-slack-webhook-url", } litellm_settings: @@ -399,7 +399,7 @@ curl -X GET --location 'http://0.0.0.0:4000/health/services?service=webhook' \ { "spend": 1, # the spend for the 'event_group' "max_budget": 0, # the 'max_budget' set for the 'event_group' - "token": "88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b", + "token": "example-api-key-123", "user_id": "default_user_id", "team_id": null, "user_email": null, diff --git a/docs/my-website/docs/proxy/call_hooks.md b/docs/my-website/docs/proxy/call_hooks.md index fa420009cf1..fe865f67e09 100644 --- a/docs/my-website/docs/proxy/call_hooks.md +++ b/docs/my-website/docs/proxy/call_hooks.md @@ -17,6 +17,7 @@ import Image from '@theme/IdealImage'; | `async_pre_call_hook` | Modify incoming request before it's sent to model | Before the LLM API call is made | | `async_moderation_hook` | Run checks on input in parallel to LLM API call | In parallel with the LLM API call | | `async_post_call_success_hook` | Modify outgoing response (non-streaming) | After successful LLM API call, for non-streaming responses | +| `async_post_call_failure_hook` | Transform error responses sent to clients | After failed LLM API call | | `async_post_call_streaming_hook` | Modify outgoing response (streaming) | After successful LLM API call, for streaming responses | See a complete example with our [parallel request rate limiter](https://github.com/BerriAI/litellm/blob/main/litellm/proxy/hooks/parallel_request_limiter.py) @@ -60,7 +61,21 @@ class MyCustomHandler(CustomLogger): # https://docs.litellm.ai/docs/observabilit original_exception: Exception, user_api_key_dict: UserAPIKeyAuth, traceback_str: Optional[str] = None, - ): + ) -> Optional[HTTPException]: + """ + Transform error responses sent to clients. + + Return an HTTPException to replace the original error with a user-friendly message. + Return None to use the original exception. + + Example: + if isinstance(original_exception, litellm.ContextWindowExceededError): + return HTTPException( + status_code=400, + detail="Your prompt is too long. Please reduce the length and try again." + ) + return None # Use original exception + """ pass async def async_post_call_success_hook( @@ -339,3 +354,38 @@ curl --location 'http://0.0.0.0:4000/chat/completions' \ "usage": {} } ``` + +## Advanced - Transform Error Responses + +Transform technical API errors into user-friendly messages using `async_post_call_failure_hook`. Return an `HTTPException` to replace the original error, or `None` to use the original exception. + +```python +from litellm.integrations.custom_logger import CustomLogger +from fastapi import HTTPException +from typing import Optional +import litellm + +class MyErrorTransformer(CustomLogger): + async def async_post_call_failure_hook( + self, + request_data: dict, + original_exception: Exception, + user_api_key_dict: UserAPIKeyAuth, + traceback_str: Optional[str] = None, + ) -> Optional[HTTPException]: + if isinstance(original_exception, litellm.ContextWindowExceededError): + return HTTPException( + status_code=400, + detail="Your prompt is too long. Please reduce the length and try again." + ) + if isinstance(original_exception, litellm.RateLimitError): + return HTTPException( + status_code=429, + detail="Rate limit exceeded. Please try again in a moment." + ) + return None # Use original exception + +proxy_handler_instance = MyErrorTransformer() +``` + +**Result:** Clients receive `"Your prompt is too long..."` instead of `"ContextWindowExceededError: Prompt exceeds context window"`. diff --git a/docs/my-website/docs/proxy/configs.md b/docs/my-website/docs/proxy/configs.md index ba4ca190aa9..bc2f6a13362 100644 --- a/docs/my-website/docs/proxy/configs.md +++ b/docs/my-website/docs/proxy/configs.md @@ -116,7 +116,7 @@ curl --location 'http://0.0.0.0:4000/chat/completions' \ "role": "user", "content": "what llm are you" } - ], + ] } ' ``` diff --git a/docs/my-website/docs/proxy/cost_tracking.md b/docs/my-website/docs/proxy/cost_tracking.md index 019cd62c620..26a4920c093 100644 --- a/docs/my-website/docs/proxy/cost_tracking.md +++ b/docs/my-website/docs/proxy/cost_tracking.md @@ -722,7 +722,7 @@ curl -X GET 'http://localhost:4000/global/spend/report?start_date=2024-04-01&end ```shell [ { - "api_key": "88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b", + "api_key": "example-api-key-123", "total_cost": 0.3201286305151999, "total_input_tokens": 36.0, "total_output_tokens": 1593.0, @@ -766,7 +766,7 @@ curl -X GET 'http://localhost:4000/global/spend/report?start_date=2024-04-01&end ```shell [ { - "api_key": "88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b", + "api_key": "example-api-key-123", "total_cost": 0.00013132, "total_input_tokens": 105.0, "total_output_tokens": 872.0, @@ -1151,7 +1151,7 @@ curl -X GET "http://0.0.0.0:4000/spend/logs?request_id= datadog -> _service_logger -> utils) +from litellm.types.secret_managers.main import KeyManagementSettings +_key_management_settings: KeyManagementSettings = KeyManagementSettings() + # client must be imported immediately as it's used as a decorator at function definition time from .utils import client # Note: Most other utils imports are lazy-loaded via __getattr__ to avoid loading utils.py @@ -1069,32 +1068,11 @@ from .utils import client from .llms.custom_llm import CustomLLM from .llms.anthropic.common_utils import AnthropicModelInfo from .llms.ai21.chat.transformation import AI21ChatConfig, AI21ChatConfig as AI21Config -from .llms.meta_llama.chat.transformation import LlamaAPIConfig -from .llms.anthropic.experimental_pass_through.messages.transformation import ( - AnthropicMessagesConfig, -) -from .llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation import ( - AmazonAnthropicClaudeMessagesConfig, -) -from .llms.together_ai.chat import TogetherAIConfig -from .llms.together_ai.completion.transformation import TogetherAITextCompletionConfig -from .llms.cloudflare.chat.transformation import CloudflareChatConfig -from .llms.novita.chat.transformation import NovitaConfig from .llms.deprecated_providers.palm import ( PalmConfig, ) # here to prevent breaking changes -from .llms.nlp_cloud.chat.handler import NLPCloudConfig -from .llms.petals.completion.transformation import PetalsConfig from .llms.deprecated_providers.aleph_alpha import AlephAlphaConfig -from .llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( - VertexGeminiConfig, - VertexGeminiConfig as VertexAIConfig, -) from .llms.gemini.common_utils import GeminiModelInfo -from .llms.gemini.chat.transformation import ( - GoogleAIStudioGeminiConfig, - GoogleAIStudioGeminiConfig as GeminiConfig, # aliased to maintain backwards compatibility -) from .llms.vertex_ai.vertex_embeddings.transformation import ( @@ -1103,227 +1081,21 @@ from .llms.vertex_ai.vertex_embeddings.transformation import ( vertexAITextEmbeddingConfig = VertexAITextEmbeddingConfig() -from .llms.vertex_ai.vertex_ai_partner_models.anthropic.transformation import ( - VertexAIAnthropicConfig, -) -from .llms.vertex_ai.vertex_ai_partner_models.llama3.transformation import ( - VertexAILlama3Config, -) -from .llms.vertex_ai.vertex_ai_partner_models.ai21.transformation import ( - VertexAIAi21Config, -) -from .llms.ollama.chat.transformation import OllamaChatConfig -from .llms.ollama.completion.transformation import OllamaConfig -from .llms.sagemaker.completion.transformation import SagemakerConfig -from .llms.sagemaker.chat.transformation import SagemakerChatConfig -from .llms.bedrock.chat.invoke_handler import ( - AmazonCohereChatConfig, - bedrock_tool_name_mappings, -) -from .llms.bedrock.common_utils import ( - AmazonBedrockGlobalConfig, -) -from .llms.bedrock.chat.invoke_transformations.amazon_ai21_transformation import ( - AmazonAI21Config, -) -from .llms.bedrock.chat.invoke_transformations.amazon_nova_transformation import ( - AmazonInvokeNovaConfig, -) -from .llms.bedrock.chat.invoke_transformations.amazon_qwen2_transformation import ( - AmazonQwen2Config, -) -from .llms.bedrock.chat.invoke_transformations.amazon_qwen3_transformation import ( - AmazonQwen3Config, -) -from .llms.bedrock.chat.invoke_transformations.anthropic_claude2_transformation import ( - AmazonAnthropicConfig, -) -from .llms.bedrock.chat.invoke_transformations.anthropic_claude3_transformation import ( - AmazonAnthropicClaudeConfig, -) -from .llms.bedrock.chat.invoke_transformations.amazon_cohere_transformation import ( - AmazonCohereConfig, -) -from .llms.bedrock.chat.invoke_transformations.amazon_llama_transformation import ( - AmazonLlamaConfig, -) -from .llms.bedrock.chat.invoke_transformations.amazon_deepseek_transformation import ( - AmazonDeepSeekR1Config, -) -from .llms.bedrock.chat.invoke_transformations.amazon_mistral_transformation import ( - AmazonMistralConfig, -) -from .llms.bedrock.chat.invoke_transformations.amazon_titan_transformation import ( - AmazonTitanConfig, -) -from .llms.bedrock.chat.invoke_transformations.amazon_twelvelabs_pegasus_transformation import ( - AmazonTwelveLabsPegasusConfig, -) -from .llms.bedrock.chat.invoke_transformations.base_invoke_transformation import ( - AmazonInvokeConfig, -) -from .llms.bedrock.chat.invoke_transformations.amazon_openai_transformation import ( - AmazonBedrockOpenAIConfig, -) - -from .llms.bedrock.image_generation.amazon_stability1_transformation import AmazonStabilityConfig -from .llms.bedrock.image_generation.amazon_stability3_transformation import AmazonStability3Config -from .llms.bedrock.image_generation.amazon_nova_canvas_transformation import AmazonNovaCanvasConfig -from .llms.bedrock.embed.amazon_titan_g1_transformation import AmazonTitanG1Config -from .llms.bedrock.embed.amazon_titan_multimodal_transformation import ( - AmazonTitanMultimodalEmbeddingG1Config, -) from .llms.bedrock.embed.amazon_titan_v2_transformation import ( AmazonTitanV2Config, ) -from .llms.cohere.chat.transformation import CohereChatConfig -from .llms.cohere.chat.v2_transformation import CohereV2ChatConfig -from .llms.bedrock.embed.cohere_transformation import BedrockCohereEmbeddingConfig -from .llms.bedrock.embed.twelvelabs_marengo_transformation import ( - TwelveLabsMarengoEmbeddingConfig, -) -from .llms.bedrock.embed.amazon_nova_transformation import ( - AmazonNovaEmbeddingConfig, -) -from .llms.openai.openai import OpenAIConfig, MistralEmbeddingConfig -from .llms.openai.image_variations.transformation import OpenAIImageVariationConfig -from .llms.deepinfra.chat.transformation import DeepInfraConfig -from .llms.deepgram.audio_transcription.transformation import ( - DeepgramAudioTranscriptionConfig, -) from .llms.topaz.common_utils import TopazModelInfo -from .llms.topaz.image_variations.transformation import TopazImageVariationConfig -from litellm.llms.openai.completion.transformation import OpenAITextCompletionConfig -from .llms.groq.chat.transformation import GroqChatConfig -from .llms.sap.chat.transformation import GenAIHubOrchestrationConfig -from .llms.voyage.embedding.transformation import VoyageEmbeddingConfig -from .llms.voyage.embedding.transformation_contextual import ( - VoyageContextualEmbeddingConfig, -) -from .llms.infinity.embedding.transformation import InfinityEmbeddingConfig -from .llms.azure_ai.chat.transformation import AzureAIStudioConfig -from .llms.mistral.chat.transformation import MistralConfig -from .llms.openai.responses.transformation import OpenAIResponsesAPIConfig -from .llms.azure.responses.transformation import AzureOpenAIResponsesAPIConfig -from .llms.azure.responses.o_series_transformation import ( - AzureOpenAIOSeriesResponsesAPIConfig, -) -from .llms.xai.responses.transformation import XAIResponsesAPIConfig -from .llms.litellm_proxy.responses.transformation import ( - LiteLLMProxyResponsesAPIConfig, -) -from .llms.gemini.interactions.transformation import GoogleAIStudioInteractionsConfig -from .llms.openai.chat.o_series_transformation import ( - OpenAIOSeriesConfig as OpenAIO1Config, # maintain backwards compatibility - OpenAIOSeriesConfig, -) -from .llms.anthropic.skills.transformation import AnthropicSkillsConfig -from .llms.base_llm.skills.transformation import BaseSkillsAPIConfig -from .llms.gradient_ai.chat.transformation import GradientAIConfig - -openaiOSeriesConfig = OpenAIOSeriesConfig() -from .llms.openai.chat.gpt_transformation import ( - OpenAIGPTConfig, -) -from .llms.openai.chat.gpt_5_transformation import ( - OpenAIGPT5Config, -) -from .llms.openai.transcriptions.whisper_transformation import ( - OpenAIWhisperAudioTranscriptionConfig, -) -from .llms.openai.transcriptions.gpt_transformation import ( - OpenAIGPTAudioTranscriptionConfig, -) - -openAIGPTConfig = OpenAIGPTConfig() -from .llms.openai.chat.gpt_audio_transformation import ( - OpenAIGPTAudioConfig, -) - -openAIGPTAudioConfig = OpenAIGPTAudioConfig() -openAIGPT5Config = OpenAIGPT5Config() - -from .llms.nvidia_nim.chat.transformation import NvidiaNimConfig -from .llms.nvidia_nim.embed import NvidiaNimEmbeddingConfig - -nvidiaNimConfig = NvidiaNimConfig() -nvidiaNimEmbeddingConfig = NvidiaNimEmbeddingConfig() - -from .llms.featherless_ai.chat.transformation import FeatherlessAIConfig -from .llms.cerebras.chat import CerebrasConfig -from .llms.baseten.chat import BasetenConfig -from .llms.sambanova.chat import SambanovaConfig -from .llms.sambanova.embedding.transformation import SambaNovaEmbeddingConfig -from .llms.fireworks_ai.chat.transformation import FireworksAIConfig -from .llms.fireworks_ai.completion.transformation import FireworksAITextCompletionConfig -from .llms.fireworks_ai.audio_transcription.transformation import ( - FireworksAIAudioTranscriptionConfig, -) -from .llms.fireworks_ai.embed.fireworks_ai_transformation import ( - FireworksAIEmbeddingConfig, -) -from .llms.friendliai.chat.transformation import FriendliaiChatConfig -from .llms.jina_ai.embedding.transformation import JinaAIEmbeddingConfig -from .llms.xai.chat.transformation import XAIChatConfig +# OpenAIOSeriesConfig is lazy loaded - openaiOSeriesConfig will be created on first access +# OpenAIGPTConfig, OpenAIGPT5Config, etc. are lazy loaded - instances will be created on first access from .llms.xai.common_utils import XAIModelInfo -from .llms.zai.chat.transformation import ZAIChatConfig -from .llms.aiml.chat.transformation import AIMLChatConfig -from .llms.volcengine.chat.transformation import ( - VolcEngineChatConfig as VolcEngineConfig, -) -from .llms.codestral.completion.transformation import CodestralTextCompletionConfig -from .llms.azure.azure import ( - AzureOpenAIError, - AzureOpenAIAssistantsAPIConfig, -) -from .llms.heroku.chat.transformation import HerokuChatConfig -from .llms.cometapi.chat.transformation import CometAPIConfig -from .llms.azure.chat.gpt_transformation import AzureOpenAIConfig -from .llms.azure.chat.gpt_5_transformation import AzureOpenAIGPT5Config -from .llms.azure.completion.transformation import AzureOpenAITextConfig -from .llms.hosted_vllm.chat.transformation import HostedVLLMChatConfig -from .llms.llamafile.chat.transformation import LlamafileChatConfig -from .llms.litellm_proxy.chat.transformation import LiteLLMProxyChatConfig -from .llms.vllm.completion.transformation import VLLMConfig -from .llms.deepseek.chat.transformation import DeepSeekChatConfig -from .llms.lm_studio.chat.transformation import LMStudioChatConfig -from .llms.lm_studio.embed.transformation import LmStudioEmbeddingConfig -from .llms.nscale.chat.transformation import NscaleConfig -from .llms.perplexity.chat.transformation import PerplexityChatConfig -from .llms.azure.chat.o_series_transformation import AzureOpenAIO1Config -from .llms.watsonx.completion.transformation import IBMWatsonXAIConfig -from .llms.watsonx.chat.transformation import IBMWatsonXChatConfig -from .llms.watsonx.embed.transformation import IBMWatsonXEmbeddingConfig -from .llms.sap.embed.transformation import GenAIHubEmbeddingConfig -from .llms.watsonx.audio_transcription.transformation import ( - IBMWatsonXAudioTranscriptionConfig, -) -from .llms.github_copilot.chat.transformation import GithubCopilotConfig -from .llms.github_copilot.responses.transformation import ( - GithubCopilotResponsesAPIConfig, -) -from .llms.github_copilot.embedding.transformation import GithubCopilotEmbeddingConfig -from .llms.nebius.chat.transformation import NebiusConfig -from .llms.wandb.chat.transformation import WandbConfig -from .llms.dashscope.chat.transformation import DashScopeChatConfig -from .llms.moonshot.chat.transformation import MoonshotChatConfig # PublicAI now uses JSON-based configuration (see litellm/llms/openai_like/providers.json) -from .llms.docker_model_runner.chat.transformation import DockerModelRunnerChatConfig -from .llms.v0.chat.transformation import V0ChatConfig -from .llms.oci.chat.transformation import OCIChatConfig -from .llms.morph.chat.transformation import MorphChatConfig -from .llms.ragflow.chat.transformation import RAGFlowConfig -from .llms.lambda_ai.chat.transformation import LambdaAIChatConfig -from .llms.hyperbolic.chat.transformation import HyperbolicChatConfig -from .llms.vercel_ai_gateway.chat.transformation import VercelAIGatewayConfig -from .llms.ovhcloud.chat.transformation import OVHCloudChatConfig -from .llms.ovhcloud.embedding.transformation import OVHCloudEmbeddingConfig -from .llms.cometapi.embed.transformation import CometAPIEmbeddingConfig -from .llms.lemonade.chat.transformation import LemonadeChatConfig -from .llms.snowflake.embedding.transformation import SnowflakeEmbeddingConfig -from .llms.amazon_nova.chat.transformation import AmazonNovaChatConfig +# All remaining configs are now lazy loaded - see _lazy_imports_registry.py + +# Import LlmProviders here (before main import) because it's imported during import time +# in multiple places including openai.py (via main import) +from litellm.types.utils import LlmProviders ## Lazy loading this is not straightforward, will leave it here for now. from .main import * # type: ignore @@ -1485,6 +1257,7 @@ if TYPE_CHECKING: from .llms.bytez.chat.transformation import BytezChatConfig as BytezChatConfig from .llms.compactifai.chat.transformation import CompactifAIChatConfig as CompactifAIChatConfig from .llms.empower.chat.transformation import EmpowerChatConfig as EmpowerChatConfig + from .llms.minimax.chat.transformation import MinimaxChatConfig as MinimaxChatConfig from .llms.aiohttp_openai.chat.transformation import AiohttpOpenAIChatConfig as AiohttpOpenAIChatConfig from .llms.huggingface.chat.transformation import HuggingFaceChatConfig as HuggingFaceChatConfig from .llms.huggingface.embedding.transformation import HuggingFaceEmbeddingConfig as HuggingFaceEmbeddingConfig @@ -1519,6 +1292,167 @@ if TYPE_CHECKING: from .llms.voyage.rerank.transformation import VoyageRerankConfig as VoyageRerankConfig from .llms.clarifai.chat.transformation import ClarifaiConfig as ClarifaiConfig from .llms.ai21.chat.transformation import AI21ChatConfig as AI21ChatConfig + from .llms.meta_llama.chat.transformation import LlamaAPIConfig as LlamaAPIConfig + from .llms.together_ai.completion.transformation import TogetherAITextCompletionConfig as TogetherAITextCompletionConfig + from .llms.cloudflare.chat.transformation import CloudflareChatConfig as CloudflareChatConfig + from .llms.novita.chat.transformation import NovitaConfig as NovitaConfig + from .llms.petals.completion.transformation import PetalsConfig as PetalsConfig + from .llms.ollama.chat.transformation import OllamaChatConfig as OllamaChatConfig + from .llms.ollama.completion.transformation import OllamaConfig as OllamaConfig + from .llms.sagemaker.completion.transformation import SagemakerConfig as SagemakerConfig + from .llms.sagemaker.chat.transformation import SagemakerChatConfig as SagemakerChatConfig + from .llms.cohere.chat.transformation import CohereChatConfig as CohereChatConfig + from .llms.anthropic.experimental_pass_through.messages.transformation import AnthropicMessagesConfig as AnthropicMessagesConfig + from .llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation import AmazonAnthropicClaudeMessagesConfig as AmazonAnthropicClaudeMessagesConfig + from .llms.together_ai.chat import TogetherAIConfig as TogetherAIConfig + from .llms.nlp_cloud.chat.handler import NLPCloudConfig as NLPCloudConfig + from .llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexGeminiConfig as VertexGeminiConfig + from .llms.gemini.chat.transformation import GoogleAIStudioGeminiConfig as GoogleAIStudioGeminiConfig + from .llms.vertex_ai.vertex_ai_partner_models.anthropic.transformation import VertexAIAnthropicConfig as VertexAIAnthropicConfig + from .llms.vertex_ai.vertex_ai_partner_models.llama3.transformation import VertexAILlama3Config as VertexAILlama3Config + from .llms.vertex_ai.vertex_ai_partner_models.ai21.transformation import VertexAIAi21Config as VertexAIAi21Config + from .llms.bedrock.chat.invoke_handler import AmazonCohereChatConfig as AmazonCohereChatConfig + from .llms.bedrock.common_utils import AmazonBedrockGlobalConfig as AmazonBedrockGlobalConfig + from .llms.bedrock.chat.invoke_transformations.amazon_ai21_transformation import AmazonAI21Config as AmazonAI21Config + from .llms.bedrock.chat.invoke_transformations.amazon_nova_transformation import AmazonInvokeNovaConfig as AmazonInvokeNovaConfig + from .llms.bedrock.chat.invoke_transformations.amazon_qwen2_transformation import AmazonQwen2Config as AmazonQwen2Config + from .llms.bedrock.chat.invoke_transformations.amazon_qwen3_transformation import AmazonQwen3Config as AmazonQwen3Config + from .llms.bedrock.chat.invoke_transformations.anthropic_claude2_transformation import AmazonAnthropicConfig as AmazonAnthropicConfig + from .llms.bedrock.chat.invoke_transformations.anthropic_claude3_transformation import AmazonAnthropicClaudeConfig as AmazonAnthropicClaudeConfig + from .llms.bedrock.chat.invoke_transformations.amazon_cohere_transformation import AmazonCohereConfig as AmazonCohereConfig + from .llms.bedrock.chat.invoke_transformations.amazon_llama_transformation import AmazonLlamaConfig as AmazonLlamaConfig + from .llms.bedrock.chat.invoke_transformations.amazon_deepseek_transformation import AmazonDeepSeekR1Config as AmazonDeepSeekR1Config + from .llms.bedrock.chat.invoke_transformations.amazon_mistral_transformation import AmazonMistralConfig as AmazonMistralConfig + from .llms.bedrock.chat.invoke_transformations.amazon_titan_transformation import AmazonTitanConfig as AmazonTitanConfig + from .llms.bedrock.chat.invoke_transformations.amazon_twelvelabs_pegasus_transformation import AmazonTwelveLabsPegasusConfig as AmazonTwelveLabsPegasusConfig + from .llms.bedrock.chat.invoke_transformations.base_invoke_transformation import AmazonInvokeConfig as AmazonInvokeConfig + from .llms.bedrock.chat.invoke_transformations.amazon_openai_transformation import AmazonBedrockOpenAIConfig as AmazonBedrockOpenAIConfig + from .llms.bedrock.image_generation.amazon_stability1_transformation import AmazonStabilityConfig as AmazonStabilityConfig + from .llms.bedrock.image_generation.amazon_stability3_transformation import AmazonStability3Config as AmazonStability3Config + from .llms.bedrock.image_generation.amazon_nova_canvas_transformation import AmazonNovaCanvasConfig as AmazonNovaCanvasConfig + from .llms.bedrock.embed.amazon_titan_g1_transformation import AmazonTitanG1Config as AmazonTitanG1Config + from .llms.bedrock.embed.amazon_titan_multimodal_transformation import AmazonTitanMultimodalEmbeddingG1Config as AmazonTitanMultimodalEmbeddingG1Config + from .llms.cohere.chat.v2_transformation import CohereV2ChatConfig as CohereV2ChatConfig + from .llms.bedrock.embed.cohere_transformation import BedrockCohereEmbeddingConfig as BedrockCohereEmbeddingConfig + from .llms.bedrock.embed.twelvelabs_marengo_transformation import TwelveLabsMarengoEmbeddingConfig as TwelveLabsMarengoEmbeddingConfig + from .llms.bedrock.embed.amazon_nova_transformation import AmazonNovaEmbeddingConfig as AmazonNovaEmbeddingConfig + from .llms.openai.openai import OpenAIConfig as OpenAIConfig, MistralEmbeddingConfig as MistralEmbeddingConfig + from .llms.openai.image_variations.transformation import OpenAIImageVariationConfig as OpenAIImageVariationConfig + from .llms.deepgram.audio_transcription.transformation import DeepgramAudioTranscriptionConfig as DeepgramAudioTranscriptionConfig + from .llms.topaz.image_variations.transformation import TopazImageVariationConfig as TopazImageVariationConfig + from litellm.llms.openai.completion.transformation import OpenAITextCompletionConfig as OpenAITextCompletionConfig + from .llms.groq.chat.transformation import GroqChatConfig as GroqChatConfig + from .llms.voyage.embedding.transformation import VoyageEmbeddingConfig as VoyageEmbeddingConfig + from .llms.voyage.embedding.transformation_contextual import VoyageContextualEmbeddingConfig as VoyageContextualEmbeddingConfig + from .llms.infinity.embedding.transformation import InfinityEmbeddingConfig as InfinityEmbeddingConfig + from .llms.azure_ai.chat.transformation import AzureAIStudioConfig as AzureAIStudioConfig + from .llms.mistral.chat.transformation import MistralConfig as MistralConfig + from .llms.openai.responses.transformation import OpenAIResponsesAPIConfig as OpenAIResponsesAPIConfig + from .llms.azure.responses.transformation import AzureOpenAIResponsesAPIConfig as AzureOpenAIResponsesAPIConfig + from .llms.azure.responses.o_series_transformation import AzureOpenAIOSeriesResponsesAPIConfig as AzureOpenAIOSeriesResponsesAPIConfig + from .llms.xai.responses.transformation import XAIResponsesAPIConfig as XAIResponsesAPIConfig + from .llms.litellm_proxy.responses.transformation import LiteLLMProxyResponsesAPIConfig as LiteLLMProxyResponsesAPIConfig + from .llms.gemini.interactions.transformation import GoogleAIStudioInteractionsConfig as GoogleAIStudioInteractionsConfig + from .llms.openai.chat.o_series_transformation import OpenAIOSeriesConfig as OpenAIOSeriesConfig, OpenAIOSeriesConfig as OpenAIO1Config + from .llms.anthropic.skills.transformation import AnthropicSkillsConfig as AnthropicSkillsConfig + from .llms.base_llm.skills.transformation import BaseSkillsAPIConfig as BaseSkillsAPIConfig + from .llms.gradient_ai.chat.transformation import GradientAIConfig as GradientAIConfig + from .llms.openai.chat.gpt_transformation import OpenAIGPTConfig as OpenAIGPTConfig + from .llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config as OpenAIGPT5Config + from .llms.openai.transcriptions.whisper_transformation import OpenAIWhisperAudioTranscriptionConfig as OpenAIWhisperAudioTranscriptionConfig + from .llms.openai.transcriptions.gpt_transformation import OpenAIGPTAudioTranscriptionConfig as OpenAIGPTAudioTranscriptionConfig + from .llms.openai.chat.gpt_audio_transformation import OpenAIGPTAudioConfig as OpenAIGPTAudioConfig + from .llms.nvidia_nim.chat.transformation import NvidiaNimConfig as NvidiaNimConfig + from .llms.nvidia_nim.embed import NvidiaNimEmbeddingConfig as NvidiaNimEmbeddingConfig + + # Type stubs for lazy-loaded config instances + openaiOSeriesConfig: OpenAIOSeriesConfig + openAIGPTConfig: OpenAIGPTConfig + openAIGPTAudioConfig: OpenAIGPTAudioConfig + openAIGPT5Config: OpenAIGPT5Config + nvidiaNimConfig: NvidiaNimConfig + nvidiaNimEmbeddingConfig: NvidiaNimEmbeddingConfig + + # Import config classes that need type stubs (for mypy) - import with _ prefix to avoid circular reference + from .llms.vllm.completion.transformation import VLLMConfig as _VLLMConfig + from .llms.deepseek.chat.transformation import DeepSeekChatConfig as _DeepSeekChatConfig + from .llms.sap.chat.transformation import GenAIHubOrchestrationConfig as _GenAIHubOrchestrationConfig + from .llms.sap.embed.transformation import GenAIHubEmbeddingConfig as _GenAIHubEmbeddingConfig + from .llms.azure.chat.o_series_transformation import AzureOpenAIO1Config as _AzureOpenAIO1Config + from .llms.perplexity.chat.transformation import PerplexityChatConfig as _PerplexityChatConfig + from .llms.nscale.chat.transformation import NscaleConfig as _NscaleConfig + from .llms.watsonx.chat.transformation import IBMWatsonXChatConfig as _IBMWatsonXChatConfig + from .llms.watsonx.completion.transformation import IBMWatsonXAIConfig as _IBMWatsonXAIConfig + from .llms.litellm_proxy.chat.transformation import LiteLLMProxyChatConfig as _LiteLLMProxyChatConfig + from .llms.deepinfra.chat.transformation import DeepInfraConfig as _DeepInfraConfig + from .llms.llamafile.chat.transformation import LlamafileChatConfig as _LlamafileChatConfig + from .llms.lm_studio.chat.transformation import LMStudioChatConfig as _LMStudioChatConfig + from .llms.lm_studio.embed.transformation import LmStudioEmbeddingConfig as _LmStudioEmbeddingConfig + from .llms.watsonx.embed.transformation import IBMWatsonXEmbeddingConfig as _IBMWatsonXEmbeddingConfig + from .llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexGeminiConfig as _VertexGeminiConfig + + # Type stubs for lazy-loaded config classes (to help mypy understand types) + VLLMConfig: Type[_VLLMConfig] + DeepSeekChatConfig: Type[_DeepSeekChatConfig] + GenAIHubOrchestrationConfig: Type[_GenAIHubOrchestrationConfig] + GenAIHubEmbeddingConfig: Type[_GenAIHubEmbeddingConfig] + AzureOpenAIO1Config: Type[_AzureOpenAIO1Config] + PerplexityChatConfig: Type[_PerplexityChatConfig] + NscaleConfig: Type[_NscaleConfig] + IBMWatsonXChatConfig: Type[_IBMWatsonXChatConfig] + IBMWatsonXAIConfig: Type[_IBMWatsonXAIConfig] + LiteLLMProxyChatConfig: Type[_LiteLLMProxyChatConfig] + DeepInfraConfig: Type[_DeepInfraConfig] + LlamafileChatConfig: Type[_LlamafileChatConfig] + LMStudioChatConfig: Type[_LMStudioChatConfig] + LmStudioEmbeddingConfig: Type[_LmStudioEmbeddingConfig] + IBMWatsonXEmbeddingConfig: Type[_IBMWatsonXEmbeddingConfig] + VertexAIConfig: Type[_VertexGeminiConfig] # Alias for VertexGeminiConfig + + from .llms.featherless_ai.chat.transformation import FeatherlessAIConfig as FeatherlessAIConfig + from .llms.cerebras.chat import CerebrasConfig as CerebrasConfig + from .llms.baseten.chat import BasetenConfig as BasetenConfig + from .llms.sambanova.chat import SambanovaConfig as SambanovaConfig + from .llms.sambanova.embedding.transformation import SambaNovaEmbeddingConfig as SambaNovaEmbeddingConfig + from .llms.fireworks_ai.chat.transformation import FireworksAIConfig as FireworksAIConfig + from .llms.fireworks_ai.completion.transformation import FireworksAITextCompletionConfig as FireworksAITextCompletionConfig + from .llms.fireworks_ai.audio_transcription.transformation import FireworksAIAudioTranscriptionConfig as FireworksAIAudioTranscriptionConfig + from .llms.fireworks_ai.embed.fireworks_ai_transformation import FireworksAIEmbeddingConfig as FireworksAIEmbeddingConfig + from .llms.friendliai.chat.transformation import FriendliaiChatConfig as FriendliaiChatConfig + from .llms.jina_ai.embedding.transformation import JinaAIEmbeddingConfig as JinaAIEmbeddingConfig + from .llms.xai.chat.transformation import XAIChatConfig as XAIChatConfig + from .llms.zai.chat.transformation import ZAIChatConfig as ZAIChatConfig + from .llms.aiml.chat.transformation import AIMLChatConfig as AIMLChatConfig + from .llms.volcengine.chat.transformation import VolcEngineChatConfig as VolcEngineChatConfig, VolcEngineChatConfig as VolcEngineConfig + from .llms.codestral.completion.transformation import CodestralTextCompletionConfig as CodestralTextCompletionConfig + from .llms.azure.azure import AzureOpenAIAssistantsAPIConfig as AzureOpenAIAssistantsAPIConfig + from .llms.heroku.chat.transformation import HerokuChatConfig as HerokuChatConfig + from .llms.cometapi.chat.transformation import CometAPIConfig as CometAPIConfig + from .llms.azure.chat.gpt_transformation import AzureOpenAIConfig as AzureOpenAIConfig + from .llms.azure.chat.gpt_5_transformation import AzureOpenAIGPT5Config as AzureOpenAIGPT5Config + from .llms.azure.completion.transformation import AzureOpenAITextConfig as AzureOpenAITextConfig + from .llms.hosted_vllm.chat.transformation import HostedVLLMChatConfig as HostedVLLMChatConfig + from .llms.github_copilot.chat.transformation import GithubCopilotConfig as GithubCopilotConfig + from .llms.github_copilot.responses.transformation import GithubCopilotResponsesAPIConfig as GithubCopilotResponsesAPIConfig + from .llms.github_copilot.embedding.transformation import GithubCopilotEmbeddingConfig as GithubCopilotEmbeddingConfig + from .llms.nebius.chat.transformation import NebiusConfig as NebiusConfig + from .llms.wandb.chat.transformation import WandbConfig as WandbConfig + from .llms.dashscope.chat.transformation import DashScopeChatConfig as DashScopeChatConfig + from .llms.moonshot.chat.transformation import MoonshotChatConfig as MoonshotChatConfig + from .llms.docker_model_runner.chat.transformation import DockerModelRunnerChatConfig as DockerModelRunnerChatConfig + from .llms.v0.chat.transformation import V0ChatConfig as V0ChatConfig + from .llms.oci.chat.transformation import OCIChatConfig as OCIChatConfig + from .llms.morph.chat.transformation import MorphChatConfig as MorphChatConfig + from .llms.ragflow.chat.transformation import RAGFlowConfig as RAGFlowConfig + from .llms.lambda_ai.chat.transformation import LambdaAIChatConfig as LambdaAIChatConfig + from .llms.hyperbolic.chat.transformation import HyperbolicChatConfig as HyperbolicChatConfig + from .llms.vercel_ai_gateway.chat.transformation import VercelAIGatewayConfig as VercelAIGatewayConfig + from .llms.ovhcloud.chat.transformation import OVHCloudChatConfig as OVHCloudChatConfig + from .llms.ovhcloud.embedding.transformation import OVHCloudEmbeddingConfig as OVHCloudEmbeddingConfig + from .llms.cometapi.embed.transformation import CometAPIEmbeddingConfig as CometAPIEmbeddingConfig + from .llms.lemonade.chat.transformation import LemonadeChatConfig as LemonadeChatConfig + from .llms.snowflake.embedding.transformation import SnowflakeEmbeddingConfig as SnowflakeEmbeddingConfig + from .llms.amazon_nova.chat.transformation import AmazonNovaChatConfig as AmazonNovaChatConfig from litellm.caching.llm_caching_handler import LLMClientCache from litellm.types.llms.bedrock import COHERE_EMBEDDING_INPUT_TYPES from litellm.types.utils import ( @@ -1528,6 +1462,10 @@ if TYPE_CHECKING: StandardKeyGenerationConfig, ) from litellm.types.guardrails import GuardrailItem + from litellm.types.proxy.management_endpoints.ui_sso import ( + DefaultTeamSSOParams, + LiteLLM_UpperboundKeyGenerateParams, + ) # Cost calculator functions cost_per_token: Callable[..., Tuple[float, float]] @@ -1572,97 +1510,149 @@ if TYPE_CHECKING: module_level_aclient: AsyncHTTPHandler module_level_client: HTTPHandler + # Bedrock tool name mappings instance (lazy-loaded) + from litellm.caching.caching import InMemoryCache + bedrock_tool_name_mappings: InMemoryCache + + # Azure exception class (lazy-loaded) + from litellm.llms.azure.common_utils import AzureOpenAIError + + # Secret manager types (lazy-loaded) + from litellm.types.secret_managers.main import ( + KeyManagementSystem, + KeyManagementSettings, # Not lazy-loaded - needed for _key_management_settings initialization + ) + + # Custom logger class (lazy-loaded) + from litellm.integrations.custom_logger import CustomLogger + + # Logging callback manager class and instance (lazy-loaded) + from litellm.litellm_core_utils.logging_callback_manager import LoggingCallbackManager + logging_callback_manager: LoggingCallbackManager + + # provider_list is lazy-loaded + from litellm.types.utils import LlmProviders + provider_list: List[Union[LlmProviders, str]] + # Note: AmazonConverseConfig and OpenAILikeChatConfig are imported above in TYPE_CHECKING block +# Track if async client cleanup has been registered (for lazy loading) +_async_client_cleanup_registered = False + + def __getattr__(name: str) -> Any: - """Lazy import handler""" - from ._lazy_imports import ( - COST_CALCULATOR_NAMES, - LITELLM_LOGGING_NAMES, - UTILS_NAMES, - TOKEN_COUNTER_NAMES, - LLM_CLIENT_CACHE_NAMES, - BEDROCK_TYPES_NAMES, - TYPES_UTILS_NAMES, - CACHING_NAMES, - HTTP_HANDLER_NAMES, - DOTPROMPT_NAMES, - LLM_CONFIG_NAMES, - TYPES_NAMES, - ) + """Lazy import handler with cached registry for improved performance.""" + global _async_client_cleanup_registered + # Register async client cleanup on first access (only once) + if not _async_client_cleanup_registered: + from litellm.llms.custom_httpx.async_client_cleanup import register_async_client_cleanup + register_async_client_cleanup() + _async_client_cleanup_registered = True - # Lazy load cost_calculator functions - if name in COST_CALCULATOR_NAMES: - from ._lazy_imports import _lazy_import_cost_calculator - return _lazy_import_cost_calculator(name) - - # Lazy load litellm_logging functions - if name in LITELLM_LOGGING_NAMES: - from ._lazy_imports import _lazy_import_litellm_logging - return _lazy_import_litellm_logging(name) - - # Lazy load utils functions - if name in UTILS_NAMES: - from ._lazy_imports import _lazy_import_utils - return _lazy_import_utils(name) + # Use cached registry from _lazy_imports instead of importing tuples every time + from ._lazy_imports import _get_lazy_import_registry - # Lazy load token counter utilities - if name in TOKEN_COUNTER_NAMES: - from ._lazy_imports import _lazy_import_token_counter - return _lazy_import_token_counter(name) + registry = _get_lazy_import_registry() - # Lazy load Bedrock type aliases - if name in BEDROCK_TYPES_NAMES: - from ._lazy_imports import _lazy_import_bedrock_types - return _lazy_import_bedrock_types(name) - - # Lazy load common types.utils symbols - if name in TYPES_UTILS_NAMES: - from ._lazy_imports import _lazy_import_types_utils - return _lazy_import_types_utils(name) - - # Lazy load LLM client cache and its singleton - if name in LLM_CLIENT_CACHE_NAMES: - from ._lazy_imports import _lazy_import_llm_client_cache - return _lazy_import_llm_client_cache(name) - - # Lazy load caching classes - if name in CACHING_NAMES: - from ._lazy_imports import _lazy_import_caching - return _lazy_import_caching(name) - - # Lazy-load HTTP handler singletons used across the codebase - if name in HTTP_HANDLER_NAMES: - from ._lazy_imports import _lazy_import_http_handlers - - return _lazy_import_http_handlers(name) - - # Lazy load dotprompt integration globals - if name in DOTPROMPT_NAMES: - from ._lazy_imports import _lazy_import_dotprompt - - return _lazy_import_dotprompt(name) - - # Lazy load LLM config classes - if name in LLM_CONFIG_NAMES: - from ._lazy_imports import _lazy_import_llm_configs - - return _lazy_import_llm_configs(name) - - # Lazy load types - if name in TYPES_NAMES: - from ._lazy_imports import _lazy_import_types - - return _lazy_import_types(name) + # Check if name is in registry and call the cached handler function + if name in registry: + handler_func = registry[name] + return handler_func(name) # Lazy load encoding from main.py to avoid heavy tiktoken import if name == "encoding": - from .main import encoding as _encoding - # Cache it in the module's __dict__ for subsequent accesses - import sys - sys.modules[__name__].__dict__["encoding"] = _encoding - return _encoding + from ._lazy_imports import _get_litellm_globals + _globals = _get_litellm_globals() + # Check if already cached + if "encoding" not in _globals: + from .main import encoding as _encoding + _globals["encoding"] = _encoding + return _globals["encoding"] + + # Lazy load bedrock_tool_name_mappings instance + if name == "bedrock_tool_name_mappings": + from ._lazy_imports import _get_litellm_globals + _globals = _get_litellm_globals() + # Check if already cached + if "bedrock_tool_name_mappings" not in _globals: + from .llms.bedrock.chat.invoke_handler import bedrock_tool_name_mappings as _bedrock_tool_name_mappings + _globals["bedrock_tool_name_mappings"] = _bedrock_tool_name_mappings + return _globals["bedrock_tool_name_mappings"] + + # Lazy load AzureOpenAIError exception class + if name == "AzureOpenAIError": + from ._lazy_imports import _get_litellm_globals + _globals = _get_litellm_globals() + # Check if already cached + if "AzureOpenAIError" not in _globals: + from .llms.azure.common_utils import AzureOpenAIError as _AzureOpenAIError + _globals["AzureOpenAIError"] = _AzureOpenAIError + return _globals["AzureOpenAIError"] + + # Lazy load openaiOSeriesConfig instance + if name == "openaiOSeriesConfig": + from ._lazy_imports import _get_litellm_globals + _globals = _get_litellm_globals() + if "openaiOSeriesConfig" not in _globals: + # Import the config class and instantiate it + config_class = __getattr__("OpenAIOSeriesConfig") + _globals["openaiOSeriesConfig"] = config_class() + return _globals["openaiOSeriesConfig"] + + # Lazy load other config instances + _config_instances = { + "openAIGPTConfig": "OpenAIGPTConfig", + "openAIGPTAudioConfig": "OpenAIGPTAudioConfig", + "openAIGPT5Config": "OpenAIGPT5Config", + "nvidiaNimConfig": "NvidiaNimConfig", + "nvidiaNimEmbeddingConfig": "NvidiaNimEmbeddingConfig", + } + if name in _config_instances: + from ._lazy_imports import _get_litellm_globals + _globals = _get_litellm_globals() + if name not in _globals: + # Import the config class and instantiate it + config_class = __getattr__(_config_instances[name]) + _globals[name] = config_class() + return _globals[name] + + # Handle OpenAIO1Config alias + if name == "OpenAIO1Config": + return __getattr__("OpenAIOSeriesConfig") + + # Lazy load provider_list + if name == "provider_list": + from ._lazy_imports import _get_litellm_globals + _globals = _get_litellm_globals() + # Check if already cached + if "provider_list" not in _globals: + # LlmProviders is eagerly imported above, so we can import it directly + from litellm.types.utils import LlmProviders + _globals["provider_list"] = list(LlmProviders) + return _globals["provider_list"] + + # Lazy load priority_reservation_settings instance + if name == "priority_reservation_settings": + from ._lazy_imports import _get_litellm_globals + _globals = _get_litellm_globals() + # Check if already cached + if "priority_reservation_settings" not in _globals: + # Import the class and instantiate it + PriorityReservationSettings = __getattr__("PriorityReservationSettings") + _globals["priority_reservation_settings"] = PriorityReservationSettings() + return _globals["priority_reservation_settings"] + + # Lazy load logging_callback_manager instance + if name == "logging_callback_manager": + from ._lazy_imports import _get_litellm_globals + _globals = _get_litellm_globals() + # Check if already cached + if "logging_callback_manager" not in _globals: + # Import the class and instantiate it + LoggingCallbackManager = __getattr__("LoggingCallbackManager") + _globals["logging_callback_manager"] = LoggingCallbackManager() + return _globals["logging_callback_manager"] raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/litellm/_lazy_imports.py b/litellm/_lazy_imports.py index 6f96f9f8ff3..c1b3e1df976 100644 --- a/litellm/_lazy_imports.py +++ b/litellm/_lazy_imports.py @@ -1,12 +1,66 @@ +""" +Lazy Import System + +This module implements lazy loading for LiteLLM attributes. Instead of importing +everything when the module loads, we only import things when they're actually used. + +How it works: +1. When someone accesses `litellm.some_attribute`, Python calls __getattr__ in __init__.py +2. __getattr__ looks up the attribute name in a registry +3. The registry points to a handler function (like _lazy_import_utils) +4. The handler function imports the module and returns the attribute +5. The result is cached so we don't import it again + +This makes importing litellm much faster because we don't load heavy dependencies +until they're actually needed. +""" +import importlib import sys -from typing import Any, Optional, cast +from typing import Any, Optional, cast, Callable + +# Import all the data structures that define what can be lazy-loaded +# These are just lists of names and maps of where to find them +from ._lazy_imports_registry import ( + # Name tuples + COST_CALCULATOR_NAMES, + LITELLM_LOGGING_NAMES, + UTILS_NAMES, + TOKEN_COUNTER_NAMES, + LLM_CLIENT_CACHE_NAMES, + BEDROCK_TYPES_NAMES, + TYPES_UTILS_NAMES, + CACHING_NAMES, + HTTP_HANDLER_NAMES, + DOTPROMPT_NAMES, + LLM_CONFIG_NAMES, + TYPES_NAMES, + # Import maps + _UTILS_IMPORT_MAP, + _COST_CALCULATOR_IMPORT_MAP, + _TYPES_UTILS_IMPORT_MAP, + _TOKEN_COUNTER_IMPORT_MAP, + _BEDROCK_TYPES_IMPORT_MAP, + _CACHING_IMPORT_MAP, + _LITELLM_LOGGING_IMPORT_MAP, + _DOTPROMPT_IMPORT_MAP, + _TYPES_IMPORT_MAP, + _LLM_CONFIGS_IMPORT_MAP, +) def _get_litellm_globals() -> dict: - """Helper to get the globals dictionary of the litellm module.""" + """ + Get the globals dictionary of the litellm module. + + This is where we cache imported attributes so we don't import them twice. + When you do `litellm.some_function`, it gets stored in this dictionary. + """ return sys.modules["litellm"].__dict__ -# Lazy loader for default encoding to avoid importing tiktoken at module import time +# These are special lazy loaders for things that are used internally +# They're separate from the main lazy import system because they have specific use cases + +# Lazy loader for default encoding - avoids importing heavy tiktoken library at startup _default_encoding: Optional[Any] = None @@ -75,935 +129,251 @@ def _get_token_counter_new() -> Any: _token_counter_new_func = _token_counter_imported return _token_counter_new_func -# Cost calculator names that support lazy loading via _lazy_import_cost_calculator -COST_CALCULATOR_NAMES = ( - "completion_cost", - "cost_per_token", - "response_cost_calculator", -) -# Litellm logging names that support lazy loading via _lazy_import_litellm_logging -LITELLM_LOGGING_NAMES = ( - "Logging", - "modify_integration", -) +# ============================================================================ +# MAIN LAZY IMPORT SYSTEM +# ============================================================================ -# Utils names that support lazy loading via _lazy_import_utils -UTILS_NAMES = ( - "exception_type", "get_optional_params", "get_response_string", "token_counter", - "create_pretrained_tokenizer", "create_tokenizer", "supports_function_calling", - "supports_web_search", "supports_url_context", "supports_response_schema", - "supports_parallel_function_calling", "supports_vision", "supports_audio_input", - "supports_audio_output", "supports_system_messages", "supports_reasoning", - "get_litellm_params", "acreate", "get_max_tokens", "get_model_info", - "register_prompt_template", "validate_environment", "check_valid_key", - "register_model", "encode", "decode", "_calculate_retry_after", "_should_retry", - "get_supported_openai_params", "get_api_base", "get_first_chars_messages", - "ModelResponse", "ModelResponseStream", "EmbeddingResponse", "ImageResponse", - "TranscriptionResponse", "TextCompletionResponse", "get_provider_fields", - "ModelResponseListIterator", "get_valid_models", -) +# This registry maps attribute names (like "ModelResponse") to handler functions +# It's built once the first time someone accesses a lazy-loaded attribute +# Example: {"ModelResponse": _lazy_import_utils, "Cache": _lazy_import_caching, ...} +_LAZY_IMPORT_REGISTRY: Optional[dict[str, Callable[[str], Any]]] = None -# Token counter names that support lazy loading via _lazy_import_token_counter -TOKEN_COUNTER_NAMES = ( - "get_modified_max_tokens", -) -# LLM client cache names that support lazy loading via _lazy_import_llm_client_cache -LLM_CLIENT_CACHE_NAMES = ( - "LLMClientCache", - "in_memory_llm_clients_cache", -) +def _get_lazy_import_registry() -> dict[str, Callable[[str], Any]]: + """ + Build the registry that maps attribute names to their handler functions. + + This is called once, the first time someone accesses a lazy-loaded attribute. + After that, we just look up the handler function in this dictionary. + + Returns: + Dictionary like {"ModelResponse": _lazy_import_utils, ...} + """ + global _LAZY_IMPORT_REGISTRY + if _LAZY_IMPORT_REGISTRY is None: + # Build the registry by going through each category and mapping + # all the names in that category to their handler function + _LAZY_IMPORT_REGISTRY = {} + # For each category, map all its names to the handler function + # Example: All names in UTILS_NAMES get mapped to _lazy_import_utils + for name in COST_CALCULATOR_NAMES: + _LAZY_IMPORT_REGISTRY[name] = _lazy_import_cost_calculator + for name in LITELLM_LOGGING_NAMES: + _LAZY_IMPORT_REGISTRY[name] = _lazy_import_litellm_logging + for name in UTILS_NAMES: + _LAZY_IMPORT_REGISTRY[name] = _lazy_import_utils + for name in TOKEN_COUNTER_NAMES: + _LAZY_IMPORT_REGISTRY[name] = _lazy_import_token_counter + for name in LLM_CLIENT_CACHE_NAMES: + _LAZY_IMPORT_REGISTRY[name] = _lazy_import_llm_client_cache + for name in BEDROCK_TYPES_NAMES: + _LAZY_IMPORT_REGISTRY[name] = _lazy_import_bedrock_types + for name in TYPES_UTILS_NAMES: + _LAZY_IMPORT_REGISTRY[name] = _lazy_import_types_utils + for name in CACHING_NAMES: + _LAZY_IMPORT_REGISTRY[name] = _lazy_import_caching + for name in HTTP_HANDLER_NAMES: + _LAZY_IMPORT_REGISTRY[name] = _lazy_import_http_handlers + for name in DOTPROMPT_NAMES: + _LAZY_IMPORT_REGISTRY[name] = _lazy_import_dotprompt + for name in LLM_CONFIG_NAMES: + _LAZY_IMPORT_REGISTRY[name] = _lazy_import_llm_configs + for name in TYPES_NAMES: + _LAZY_IMPORT_REGISTRY[name] = _lazy_import_types + + return _LAZY_IMPORT_REGISTRY -# Bedrock type names that support lazy loading via _lazy_import_bedrock_types -BEDROCK_TYPES_NAMES = ( - "COHERE_EMBEDDING_INPUT_TYPES", -) -# Common types from litellm.types.utils that support lazy loading via -# _lazy_import_types_utils -TYPES_UTILS_NAMES = ( - "ImageObject", - "BudgetConfig", - "all_litellm_params", - "_litellm_completion_params", - "CredentialItem", - "PriorityReservationDict", - "StandardKeyGenerationConfig", - "SearchProviders", - "GenericStreamingChunk", -) - -# Caching / cache classes that support lazy loading via _lazy_import_caching -CACHING_NAMES = ( - "Cache", - "DualCache", - "RedisCache", - "InMemoryCache", -) - -# HTTP handler names that support lazy loading via _lazy_import_http_handlers -HTTP_HANDLER_NAMES = ( - "module_level_aclient", - "module_level_client", -) - -# Dotprompt integration names that support lazy loading via _lazy_import_dotprompt -DOTPROMPT_NAMES = ( - "global_prompt_manager", - "global_prompt_directory", - "set_global_prompt_directory", -) - -# LLM config classes that support lazy loading via _lazy_import_llm_configs -LLM_CONFIG_NAMES = ( - "AmazonConverseConfig", - "OpenAILikeChatConfig", - "GaladrielChatConfig", - "GithubChatConfig", - "AzureAnthropicConfig", - "BytezChatConfig", - "CompactifAIChatConfig", - "EmpowerChatConfig", - "AiohttpOpenAIChatConfig", - "HuggingFaceChatConfig", - "HuggingFaceEmbeddingConfig", - "OobaboogaConfig", - "MaritalkConfig", - "OpenrouterConfig", - "DataRobotConfig", - "AnthropicConfig", - "AnthropicTextConfig", - "GroqSTTConfig", - "TritonConfig", - "TritonGenerateConfig", - "TritonInferConfig", - "TritonEmbeddingConfig", - "HuggingFaceRerankConfig", - "DatabricksConfig", - "DatabricksEmbeddingConfig", - "PredibaseConfig", - "ReplicateConfig", - "SnowflakeConfig", - "CohereRerankConfig", - "CohereRerankV2Config", - "AzureAIRerankConfig", - "InfinityRerankConfig", - "JinaAIRerankConfig", - "DeepinfraRerankConfig", - "HostedVLLMRerankConfig", - "NvidiaNimRerankConfig", - "NvidiaNimRankingConfig", - "VertexAIRerankConfig", - "FireworksAIRerankConfig", - "VoyageRerankConfig", - "ClarifaiConfig", -) - -# Types that support lazy loading via _lazy_import_types -TYPES_NAMES = ( - "GuardrailItem", -) - -# Lazy import for utils module - imports only the requested item by name. -# Note: PLR0915 (too many statements) is suppressed because the many if statements -# are intentional - each attribute is imported individually only when requested, -# ensuring true lazy imports rather than importing the entire utils module. -def _lazy_import_utils(name: str) -> Any: # noqa: PLR0915 - """Lazy import for utils module - imports only the requested item by name.""" +def _generic_lazy_import(name: str, import_map: dict[str, tuple[str, str]], category: str) -> Any: + """ + Generic function that handles lazy importing for most attributes. + + This is the workhorse function - it does the actual importing and caching. + Most handler functions just call this with their specific import map. + + Steps: + 1. Check if the name exists in the import map (if not, raise error) + 2. Check if we've already imported it (if yes, return cached value) + 3. Look up where to find it (module_path and attr_name from the map) + 4. Import the module (Python caches this automatically) + 5. Get the attribute from the module + 6. Cache it in _globals so we don't import again + 7. Return it + + Args: + name: The attribute name someone is trying to access (e.g., "ModelResponse") + import_map: Dictionary telling us where to find each attribute + Format: {"ModelResponse": (".utils", "ModelResponse")} + category: Just for error messages (e.g., "Utils", "Cost calculator") + """ + # Step 1: Make sure this attribute exists in our map + if name not in import_map: + raise AttributeError(f"{category} lazy import: unknown attribute {name!r}") + + # Step 2: Get the cache (where we store imported things) _globals = _get_litellm_globals() - if name == "exception_type": - from .utils import exception_type as _exception_type - _globals["exception_type"] = _exception_type - return _exception_type - if name == "get_optional_params": - from .utils import get_optional_params as _get_optional_params - _globals["get_optional_params"] = _get_optional_params - return _get_optional_params + # Step 3: If we've already imported it, just return the cached version + if name in _globals: + return _globals[name] - if name == "get_response_string": - from .utils import get_response_string as _get_response_string - _globals["get_response_string"] = _get_response_string - return _get_response_string + # Step 4: Look up where to find this attribute + # The map tells us: (module_path, attribute_name) + # Example: (".utils", "ModelResponse") means "look in .utils module, get ModelResponse" + module_path, attr_name = import_map[name] - if name == "token_counter": - from .utils import token_counter as _token_counter - _globals["token_counter"] = _token_counter - return _token_counter + # Step 5: Import the module + # Python automatically caches modules in sys.modules, so calling this twice is fast + # If module_path starts with ".", it's a relative import (needs package="litellm") + # Otherwise it's an absolute import (like "litellm.caching.caching") + if module_path.startswith("."): + module = importlib.import_module(module_path, package="litellm") + else: + module = importlib.import_module(module_path) - if name == "create_pretrained_tokenizer": - from .utils import create_pretrained_tokenizer as _create_pretrained_tokenizer - _globals["create_pretrained_tokenizer"] = _create_pretrained_tokenizer - return _create_pretrained_tokenizer + # Step 6: Get the actual attribute from the module + # Example: getattr(utils_module, "ModelResponse") returns the ModelResponse class + value = getattr(module, attr_name) - if name == "create_tokenizer": - from .utils import create_tokenizer as _create_tokenizer - _globals["create_tokenizer"] = _create_tokenizer - return _create_tokenizer + # Step 7: Cache it so we don't have to import again next time + _globals[name] = value - if name == "supports_function_calling": - from .utils import supports_function_calling as _supports_function_calling - _globals["supports_function_calling"] = _supports_function_calling - return _supports_function_calling - - if name == "supports_web_search": - from .utils import supports_web_search as _supports_web_search - _globals["supports_web_search"] = _supports_web_search - return _supports_web_search - - if name == "supports_url_context": - from .utils import supports_url_context as _supports_url_context - _globals["supports_url_context"] = _supports_url_context - return _supports_url_context - - if name == "supports_response_schema": - from .utils import supports_response_schema as _supports_response_schema - _globals["supports_response_schema"] = _supports_response_schema - return _supports_response_schema - - if name == "supports_parallel_function_calling": - from .utils import ( - supports_parallel_function_calling as _supports_parallel_function_calling, - ) - _globals["supports_parallel_function_calling"] = _supports_parallel_function_calling - return _supports_parallel_function_calling - - if name == "supports_vision": - from .utils import supports_vision as _supports_vision - _globals["supports_vision"] = _supports_vision - return _supports_vision - - if name == "supports_audio_input": - from .utils import supports_audio_input as _supports_audio_input - _globals["supports_audio_input"] = _supports_audio_input - return _supports_audio_input - - if name == "supports_audio_output": - from .utils import supports_audio_output as _supports_audio_output - _globals["supports_audio_output"] = _supports_audio_output - return _supports_audio_output - - if name == "supports_system_messages": - from .utils import supports_system_messages as _supports_system_messages - _globals["supports_system_messages"] = _supports_system_messages - return _supports_system_messages - - if name == "supports_reasoning": - from .utils import supports_reasoning as _supports_reasoning - _globals["supports_reasoning"] = _supports_reasoning - return _supports_reasoning - - if name == "get_litellm_params": - from .utils import get_litellm_params as _get_litellm_params - _globals["get_litellm_params"] = _get_litellm_params - return _get_litellm_params - - if name == "acreate": - from .utils import acreate as _acreate - _globals["acreate"] = _acreate - return _acreate - - if name == "get_max_tokens": - from .utils import get_max_tokens as _get_max_tokens - _globals["get_max_tokens"] = _get_max_tokens - return _get_max_tokens - - if name == "get_model_info": - from .utils import get_model_info as _get_model_info - _globals["get_model_info"] = _get_model_info - return _get_model_info - - if name == "register_prompt_template": - from .utils import register_prompt_template as _register_prompt_template - _globals["register_prompt_template"] = _register_prompt_template - return _register_prompt_template - - if name == "validate_environment": - from .utils import validate_environment as _validate_environment - _globals["validate_environment"] = _validate_environment - return _validate_environment - - if name == "check_valid_key": - from .utils import check_valid_key as _check_valid_key - _globals["check_valid_key"] = _check_valid_key - return _check_valid_key - - if name == "register_model": - from .utils import register_model as _register_model - _globals["register_model"] = _register_model - return _register_model - - if name == "encode": - from .utils import encode as _encode - _globals["encode"] = _encode - return _encode - - if name == "decode": - from .utils import decode as _decode - _globals["decode"] = _decode - return _decode - - if name == "_calculate_retry_after": - from .utils import _calculate_retry_after as __calculate_retry_after - _globals["_calculate_retry_after"] = __calculate_retry_after - return __calculate_retry_after - - if name == "_should_retry": - from .utils import _should_retry as __should_retry - _globals["_should_retry"] = __should_retry - return __should_retry - - if name == "get_supported_openai_params": - from .utils import get_supported_openai_params as _get_supported_openai_params - _globals["get_supported_openai_params"] = _get_supported_openai_params - return _get_supported_openai_params - - if name == "get_api_base": - from .utils import get_api_base as _get_api_base - _globals["get_api_base"] = _get_api_base - return _get_api_base - - if name == "get_first_chars_messages": - from .utils import get_first_chars_messages as _get_first_chars_messages - _globals["get_first_chars_messages"] = _get_first_chars_messages - return _get_first_chars_messages - - if name == "ModelResponse": - from .utils import ModelResponse as _ModelResponse - _globals["ModelResponse"] = _ModelResponse - return _ModelResponse - - if name == "ModelResponseStream": - from .utils import ModelResponseStream as _ModelResponseStream - _globals["ModelResponseStream"] = _ModelResponseStream - return _ModelResponseStream - - if name == "EmbeddingResponse": - from .utils import EmbeddingResponse as _EmbeddingResponse - _globals["EmbeddingResponse"] = _EmbeddingResponse - return _EmbeddingResponse - - if name == "ImageResponse": - from .utils import ImageResponse as _ImageResponse - _globals["ImageResponse"] = _ImageResponse - return _ImageResponse - - if name == "TranscriptionResponse": - from .utils import TranscriptionResponse as _TranscriptionResponse - _globals["TranscriptionResponse"] = _TranscriptionResponse - return _TranscriptionResponse - - if name == "TextCompletionResponse": - from .utils import TextCompletionResponse as _TextCompletionResponse - _globals["TextCompletionResponse"] = _TextCompletionResponse - return _TextCompletionResponse - - if name == "get_provider_fields": - from .utils import get_provider_fields as _get_provider_fields - _globals["get_provider_fields"] = _get_provider_fields - return _get_provider_fields - - if name == "ModelResponseListIterator": - from .utils import ModelResponseListIterator as _ModelResponseListIterator - _globals["ModelResponseListIterator"] = _ModelResponseListIterator - return _ModelResponseListIterator - - if name == "get_valid_models": - from .utils import get_valid_models as _get_valid_models - _globals["get_valid_models"] = _get_valid_models - return _get_valid_models - - raise AttributeError(f"Utils lazy import: unknown attribute {name!r}") + # Step 8: Return it + return value + + +# ============================================================================ +# HANDLER FUNCTIONS +# ============================================================================ +# These functions are called when someone accesses a lazy-loaded attribute. +# Most of them just call _generic_lazy_import with their specific import map. +# The registry (above) maps attribute names to these handler functions. + +def _lazy_import_utils(name: str) -> Any: + """Handler for utils module attributes (ModelResponse, token_counter, etc.)""" + return _generic_lazy_import(name, _UTILS_IMPORT_MAP, "Utils") def _lazy_import_cost_calculator(name: str) -> Any: - """Lazy import for cost_calculator functions.""" - _globals = _get_litellm_globals() - if name == "completion_cost": - from .cost_calculator import completion_cost as _completion_cost - _globals["completion_cost"] = _completion_cost - return _completion_cost - - if name == "cost_per_token": - from .cost_calculator import cost_per_token as _cost_per_token - _globals["cost_per_token"] = _cost_per_token - return _cost_per_token - - if name == "response_cost_calculator": - from .cost_calculator import ( - response_cost_calculator as _response_cost_calculator, - ) - _globals["response_cost_calculator"] = _response_cost_calculator - return _response_cost_calculator - - raise AttributeError(f"Cost calculator lazy import: unknown attribute {name!r}") + """Handler for cost calculator functions (completion_cost, cost_per_token, etc.)""" + return _generic_lazy_import(name, _COST_CALCULATOR_IMPORT_MAP, "Cost calculator") def _lazy_import_token_counter(name: str) -> Any: - """Lazy import for token_counter utilities.""" - _globals = _get_litellm_globals() - - if name == "get_modified_max_tokens": - from litellm.litellm_core_utils.token_counter import ( - get_modified_max_tokens as _get_modified_max_tokens, - ) - - _globals["get_modified_max_tokens"] = _get_modified_max_tokens - return _get_modified_max_tokens - - raise AttributeError(f"Token counter lazy import: unknown attribute {name!r}") + """Handler for token counter utilities""" + return _generic_lazy_import(name, _TOKEN_COUNTER_IMPORT_MAP, "Token counter") def _lazy_import_bedrock_types(name: str) -> Any: - """Lazy import for Bedrock type aliases.""" - _globals = _get_litellm_globals() - - if name == "COHERE_EMBEDDING_INPUT_TYPES": - from litellm.types.llms.bedrock import ( - COHERE_EMBEDDING_INPUT_TYPES as _COHERE_EMBEDDING_INPUT_TYPES, - ) - - _globals["COHERE_EMBEDDING_INPUT_TYPES"] = _COHERE_EMBEDDING_INPUT_TYPES - return _COHERE_EMBEDDING_INPUT_TYPES - - raise AttributeError(f"Bedrock types lazy import: unknown attribute {name!r}") + """Handler for Bedrock type aliases""" + return _generic_lazy_import(name, _BEDROCK_TYPES_IMPORT_MAP, "Bedrock types") def _lazy_import_types_utils(name: str) -> Any: - """Lazy import for common types and constants from litellm.types.utils.""" - _globals = _get_litellm_globals() - - if name == "ImageObject": - from .types.utils import ImageObject as _ImageObject - - _globals["ImageObject"] = _ImageObject - return _ImageObject - - if name == "BudgetConfig": - from .types.utils import BudgetConfig as _BudgetConfig - - _globals["BudgetConfig"] = _BudgetConfig - return _BudgetConfig - - if name == "all_litellm_params": - from .types.utils import all_litellm_params as _all_litellm_params - - _globals["all_litellm_params"] = _all_litellm_params - return _all_litellm_params - - if name == "_litellm_completion_params": - from .types.utils import all_litellm_params as _all_litellm_params - - _globals["_litellm_completion_params"] = _all_litellm_params - return _all_litellm_params - - if name == "CredentialItem": - from .types.utils import CredentialItem as _CredentialItem - - _globals["CredentialItem"] = _CredentialItem - return _CredentialItem - - if name == "PriorityReservationDict": - from .types.utils import PriorityReservationDict as _PriorityReservationDict - - _globals["PriorityReservationDict"] = _PriorityReservationDict - return _PriorityReservationDict - - if name == "StandardKeyGenerationConfig": - from .types.utils import ( - StandardKeyGenerationConfig as _StandardKeyGenerationConfig, - ) - - _globals["StandardKeyGenerationConfig"] = _StandardKeyGenerationConfig - return _StandardKeyGenerationConfig - - if name == "SearchProviders": - from .types.utils import SearchProviders as _SearchProviders - - _globals["SearchProviders"] = _SearchProviders - return _SearchProviders - - if name == "GenericStreamingChunk": - from .types.utils import GenericStreamingChunk as _GenericStreamingChunk - - _globals["GenericStreamingChunk"] = _GenericStreamingChunk - return _GenericStreamingChunk - - raise AttributeError(f"Types utils lazy import: unknown attribute {name!r}") + """Handler for types from litellm.types.utils (BudgetConfig, ImageObject, etc.)""" + return _generic_lazy_import(name, _TYPES_UTILS_IMPORT_MAP, "Types utils") def _lazy_import_caching(name: str) -> Any: - """Lazy import for caching module classes.""" - _globals = _get_litellm_globals() + """Handler for caching classes (Cache, DualCache, RedisCache, etc.)""" + return _generic_lazy_import(name, _CACHING_IMPORT_MAP, "Caching") - if name == "Cache": - from litellm.caching.caching import Cache as _Cache +def _lazy_import_dotprompt(name: str) -> Any: + """Handler for dotprompt integration globals""" + return _generic_lazy_import(name, _DOTPROMPT_IMPORT_MAP, "Dotprompt") - _globals["Cache"] = _Cache - return _Cache - if name == "DualCache": - from litellm.caching.caching import DualCache as _DualCache +def _lazy_import_types(name: str) -> Any: + """Handler for type classes (GuardrailItem, etc.)""" + return _generic_lazy_import(name, _TYPES_IMPORT_MAP, "Types") - _globals["DualCache"] = _DualCache - return _DualCache - if name == "RedisCache": - from litellm.caching.caching import RedisCache as _RedisCache +def _lazy_import_llm_configs(name: str) -> Any: + """Handler for LLM config classes (AnthropicConfig, OpenAILikeChatConfig, etc.)""" + return _generic_lazy_import(name, _LLM_CONFIGS_IMPORT_MAP, "LLM config") - _globals["RedisCache"] = _RedisCache - return _RedisCache - - if name == "InMemoryCache": - from litellm.caching.caching import InMemoryCache as _InMemoryCache - - _globals["InMemoryCache"] = _InMemoryCache - return _InMemoryCache - - raise AttributeError(f"Caching lazy import: unknown attribute {name!r}") +def _lazy_import_litellm_logging(name: str) -> Any: + """Handler for litellm_logging module (Logging, modify_integration)""" + return _generic_lazy_import(name, _LITELLM_LOGGING_IMPORT_MAP, "Litellm logging") +# ============================================================================ +# SPECIAL HANDLERS +# ============================================================================ +# These handlers have custom logic that doesn't fit the generic pattern def _lazy_import_llm_client_cache(name: str) -> Any: - """Lazy import for LLM client cache class and singleton.""" + """ + Handler for LLM client cache - has special logic for singleton instance. + + This one is different because: + - "LLMClientCache" is the class itself + - "in_memory_llm_clients_cache" is a singleton instance of that class + So we need custom logic to handle both cases. + """ _globals = _get_litellm_globals() - + + # If already cached, return it + if name in _globals: + return _globals[name] + + # Import the class + module = importlib.import_module("litellm.caching.llm_caching_handler") + LLMClientCache = getattr(module, "LLMClientCache") + + # If they want the class itself, return it if name == "LLMClientCache": - from litellm.caching.llm_caching_handler import ( - LLMClientCache as _LLMClientCache, - ) - - _globals["LLMClientCache"] = _LLMClientCache - return _LLMClientCache - + _globals["LLMClientCache"] = LLMClientCache + return LLMClientCache + + # If they want the singleton instance, create it (only once) if name == "in_memory_llm_clients_cache": - from litellm.caching.llm_caching_handler import ( - LLMClientCache as _LLMClientCache, - ) - - instance = _LLMClientCache() - # Only populate the requested singleton name to keep lazy-import - # semantics consistent with other helpers (no extra symbols). + instance = LLMClientCache() _globals["in_memory_llm_clients_cache"] = instance return instance - + raise AttributeError(f"LLM client cache lazy import: unknown attribute {name!r}") -def _lazy_import_litellm_logging(name: str) -> Any: - """Lazy import for litellm_logging module.""" - _globals = _get_litellm_globals() - if name == "Logging": - from litellm.litellm_core_utils.litellm_logging import Logging as _Logging - _globals["Logging"] = _Logging - return _Logging - - if name == "modify_integration": - from litellm.litellm_core_utils.litellm_logging import ( - modify_integration as _modify_integration, - ) - _globals["modify_integration"] = _modify_integration - return _modify_integration - - raise AttributeError(f"Litellm logging lazy import: unknown attribute {name!r}") - - def _lazy_import_http_handlers(name: str) -> Any: - """Lazy import and instantiate module-level HTTP handlers.""" + """ + Handler for HTTP clients - has special logic for creating client instances. + + This one is different because: + - These aren't just imports, they're actual client instances that need to be created + - They need configuration (timeout, etc.) from the module globals + - They use factory functions instead of direct instantiation + """ _globals = _get_litellm_globals() if name == "module_level_aclient": - # Use shared async client factory instead of directly instantiating AsyncHTTPHandler + # Create an async HTTP client using the factory function from litellm.llms.custom_httpx.http_handler import get_async_httpx_client + # Get timeout from module config (if set) timeout = _globals.get("request_timeout") params = {"timeout": timeout, "client_alias": "module level aclient"} - # llm_provider is only used for cache keying; use a string identifier but - # cast to Any so static type checkers don't complain about the literal. + + # Create the client instance provider_id = cast(Any, "litellm_module_level_client") async_client = get_async_httpx_client( llm_provider=provider_id, params=params, ) + + # Cache it so we don't create it again _globals["module_level_aclient"] = async_client return async_client if name == "module_level_client": - # Import handler type locally to avoid heavy imports at module load time + # Create a sync HTTP client from litellm.llms.custom_httpx.http_handler import HTTPHandler timeout = _globals.get("request_timeout") sync_client = HTTPHandler(timeout=timeout) + + # Cache it _globals["module_level_client"] = sync_client return sync_client raise AttributeError(f"HTTP handlers lazy import: unknown attribute {name!r}") - - -def _lazy_import_dotprompt(name: str) -> Any: - """Lazy import for dotprompt integration globals.""" - _globals = _get_litellm_globals() - - if name == "global_prompt_manager": - from litellm.integrations.dotprompt import ( - global_prompt_manager as _global_prompt_manager, - ) - - _globals["global_prompt_manager"] = _global_prompt_manager - return _global_prompt_manager - - if name == "global_prompt_directory": - from litellm.integrations.dotprompt import ( - global_prompt_directory as _global_prompt_directory, - ) - - _globals["global_prompt_directory"] = _global_prompt_directory - return _global_prompt_directory - - if name == "set_global_prompt_directory": - from litellm.integrations.dotprompt import ( - set_global_prompt_directory as _set_global_prompt_directory, - ) - - _globals["set_global_prompt_directory"] = _set_global_prompt_directory - return _set_global_prompt_directory - - raise AttributeError(f"Dotprompt lazy import: unknown attribute {name!r}") - - -def _lazy_import_types(name: str) -> Any: - """Lazy import for type classes.""" - _globals = _get_litellm_globals() - - if name == "GuardrailItem": - from litellm.types.guardrails import GuardrailItem as _GuardrailItem - - _globals["GuardrailItem"] = _GuardrailItem - return _GuardrailItem - - raise AttributeError(f"Types lazy import: unknown attribute {name!r}") - - -def _lazy_import_llm_configs(name: str) -> Any: # noqa: PLR0915 - """Lazy import for LLM config classes.""" - _globals = _get_litellm_globals() - - if name == "AmazonConverseConfig": - from .llms.bedrock.chat.converse_transformation import ( - AmazonConverseConfig as _AmazonConverseConfig, - ) - - _globals["AmazonConverseConfig"] = _AmazonConverseConfig - return _AmazonConverseConfig - - if name == "OpenAILikeChatConfig": - from .llms.openai_like.chat.handler import ( - OpenAILikeChatConfig as _OpenAILikeChatConfig, - ) - - _globals["OpenAILikeChatConfig"] = _OpenAILikeChatConfig - return _OpenAILikeChatConfig - - if name == "GaladrielChatConfig": - from .llms.galadriel.chat.transformation import ( - GaladrielChatConfig as _GaladrielChatConfig, - ) - - _globals["GaladrielChatConfig"] = _GaladrielChatConfig - return _GaladrielChatConfig - - if name == "GithubChatConfig": - from .llms.github.chat.transformation import ( - GithubChatConfig as _GithubChatConfig, - ) - - _globals["GithubChatConfig"] = _GithubChatConfig - return _GithubChatConfig - - if name == "AzureAnthropicConfig": - from .llms.azure_ai.anthropic.transformation import ( - AzureAnthropicConfig as _AzureAnthropicConfig, - ) - - _globals["AzureAnthropicConfig"] = _AzureAnthropicConfig - return _AzureAnthropicConfig - - if name == "BytezChatConfig": - from .llms.bytez.chat.transformation import BytezChatConfig as _BytezChatConfig - - _globals["BytezChatConfig"] = _BytezChatConfig - return _BytezChatConfig - - if name == "CompactifAIChatConfig": - from .llms.compactifai.chat.transformation import ( - CompactifAIChatConfig as _CompactifAIChatConfig, - ) - - _globals["CompactifAIChatConfig"] = _CompactifAIChatConfig - return _CompactifAIChatConfig - - if name == "EmpowerChatConfig": - from .llms.empower.chat.transformation import ( - EmpowerChatConfig as _EmpowerChatConfig, - ) - - _globals["EmpowerChatConfig"] = _EmpowerChatConfig - return _EmpowerChatConfig - - if name == "AiohttpOpenAIChatConfig": - from .llms.aiohttp_openai.chat.transformation import ( - AiohttpOpenAIChatConfig as _AiohttpOpenAIChatConfig, - ) - - _globals["AiohttpOpenAIChatConfig"] = _AiohttpOpenAIChatConfig - return _AiohttpOpenAIChatConfig - - if name == "HuggingFaceChatConfig": - from .llms.huggingface.chat.transformation import ( - HuggingFaceChatConfig as _HuggingFaceChatConfig, - ) - - _globals["HuggingFaceChatConfig"] = _HuggingFaceChatConfig - return _HuggingFaceChatConfig - - if name == "HuggingFaceEmbeddingConfig": - from .llms.huggingface.embedding.transformation import ( - HuggingFaceEmbeddingConfig as _HuggingFaceEmbeddingConfig, - ) - - _globals["HuggingFaceEmbeddingConfig"] = _HuggingFaceEmbeddingConfig - return _HuggingFaceEmbeddingConfig - - if name == "OobaboogaConfig": - from .llms.oobabooga.chat.transformation import ( - OobaboogaConfig as _OobaboogaConfig, - ) - - _globals["OobaboogaConfig"] = _OobaboogaConfig - return _OobaboogaConfig - - if name == "MaritalkConfig": - from .llms.maritalk import MaritalkConfig as _MaritalkConfig - - _globals["MaritalkConfig"] = _MaritalkConfig - return _MaritalkConfig - - if name == "OpenrouterConfig": - from .llms.openrouter.chat.transformation import ( - OpenrouterConfig as _OpenrouterConfig, - ) - - _globals["OpenrouterConfig"] = _OpenrouterConfig - return _OpenrouterConfig - - if name == "DataRobotConfig": - from .llms.datarobot.chat.transformation import ( - DataRobotConfig as _DataRobotConfig, - ) - - _globals["DataRobotConfig"] = _DataRobotConfig - return _DataRobotConfig - - if name == "AnthropicConfig": - from .llms.anthropic.chat.transformation import ( - AnthropicConfig as _AnthropicConfig, - ) - - _globals["AnthropicConfig"] = _AnthropicConfig - return _AnthropicConfig - - if name == "AnthropicTextConfig": - from .llms.anthropic.completion.transformation import ( - AnthropicTextConfig as _AnthropicTextConfig, - ) - - _globals["AnthropicTextConfig"] = _AnthropicTextConfig - return _AnthropicTextConfig - - if name == "GroqSTTConfig": - from .llms.groq.stt.transformation import GroqSTTConfig as _GroqSTTConfig - - _globals["GroqSTTConfig"] = _GroqSTTConfig - return _GroqSTTConfig - - if name == "TritonConfig": - from .llms.triton.completion.transformation import TritonConfig as _TritonConfig - - _globals["TritonConfig"] = _TritonConfig - return _TritonConfig - - if name == "TritonGenerateConfig": - from .llms.triton.completion.transformation import ( - TritonGenerateConfig as _TritonGenerateConfig, - ) - - _globals["TritonGenerateConfig"] = _TritonGenerateConfig - return _TritonGenerateConfig - - if name == "TritonInferConfig": - from .llms.triton.completion.transformation import ( - TritonInferConfig as _TritonInferConfig, - ) - - _globals["TritonInferConfig"] = _TritonInferConfig - return _TritonInferConfig - - if name == "TritonEmbeddingConfig": - from .llms.triton.embedding.transformation import ( - TritonEmbeddingConfig as _TritonEmbeddingConfig, - ) - - _globals["TritonEmbeddingConfig"] = _TritonEmbeddingConfig - return _TritonEmbeddingConfig - - if name == "HuggingFaceRerankConfig": - from .llms.huggingface.rerank.transformation import ( - HuggingFaceRerankConfig as _HuggingFaceRerankConfig, - ) - - _globals["HuggingFaceRerankConfig"] = _HuggingFaceRerankConfig - return _HuggingFaceRerankConfig - - if name == "DatabricksConfig": - from .llms.databricks.chat.transformation import ( - DatabricksConfig as _DatabricksConfig, - ) - - _globals["DatabricksConfig"] = _DatabricksConfig - return _DatabricksConfig - - if name == "DatabricksEmbeddingConfig": - from .llms.databricks.embed.transformation import ( - DatabricksEmbeddingConfig as _DatabricksEmbeddingConfig, - ) - - _globals["DatabricksEmbeddingConfig"] = _DatabricksEmbeddingConfig - return _DatabricksEmbeddingConfig - - if name == "PredibaseConfig": - from .llms.predibase.chat.transformation import ( - PredibaseConfig as _PredibaseConfig, - ) - - _globals["PredibaseConfig"] = _PredibaseConfig - return _PredibaseConfig - - if name == "ReplicateConfig": - from .llms.replicate.chat.transformation import ( - ReplicateConfig as _ReplicateConfig, - ) - - _globals["ReplicateConfig"] = _ReplicateConfig - return _ReplicateConfig - - if name == "SnowflakeConfig": - from .llms.snowflake.chat.transformation import ( - SnowflakeConfig as _SnowflakeConfig, - ) - - _globals["SnowflakeConfig"] = _SnowflakeConfig - return _SnowflakeConfig - - if name == "CohereRerankConfig": - from .llms.cohere.rerank.transformation import ( - CohereRerankConfig as _CohereRerankConfig, - ) - - _globals["CohereRerankConfig"] = _CohereRerankConfig - return _CohereRerankConfig - - if name == "CohereRerankV2Config": - from .llms.cohere.rerank_v2.transformation import ( - CohereRerankV2Config as _CohereRerankV2Config, - ) - - _globals["CohereRerankV2Config"] = _CohereRerankV2Config - return _CohereRerankV2Config - - if name == "AzureAIRerankConfig": - from .llms.azure_ai.rerank.transformation import ( - AzureAIRerankConfig as _AzureAIRerankConfig, - ) - - _globals["AzureAIRerankConfig"] = _AzureAIRerankConfig - return _AzureAIRerankConfig - - if name == "InfinityRerankConfig": - from .llms.infinity.rerank.transformation import ( - InfinityRerankConfig as _InfinityRerankConfig, - ) - - _globals["InfinityRerankConfig"] = _InfinityRerankConfig - return _InfinityRerankConfig - - if name == "JinaAIRerankConfig": - from .llms.jina_ai.rerank.transformation import ( - JinaAIRerankConfig as _JinaAIRerankConfig, - ) - - _globals["JinaAIRerankConfig"] = _JinaAIRerankConfig - return _JinaAIRerankConfig - - if name == "DeepinfraRerankConfig": - from .llms.deepinfra.rerank.transformation import ( - DeepinfraRerankConfig as _DeepinfraRerankConfig, - ) - - _globals["DeepinfraRerankConfig"] = _DeepinfraRerankConfig - return _DeepinfraRerankConfig - - if name == "HostedVLLMRerankConfig": - from .llms.hosted_vllm.rerank.transformation import ( - HostedVLLMRerankConfig as _HostedVLLMRerankConfig, - ) - - _globals["HostedVLLMRerankConfig"] = _HostedVLLMRerankConfig - return _HostedVLLMRerankConfig - - if name == "NvidiaNimRerankConfig": - from .llms.nvidia_nim.rerank.transformation import ( - NvidiaNimRerankConfig as _NvidiaNimRerankConfig, - ) - - _globals["NvidiaNimRerankConfig"] = _NvidiaNimRerankConfig - return _NvidiaNimRerankConfig - - if name == "NvidiaNimRankingConfig": - from .llms.nvidia_nim.rerank.ranking_transformation import ( - NvidiaNimRankingConfig as _NvidiaNimRankingConfig, - ) - - _globals["NvidiaNimRankingConfig"] = _NvidiaNimRankingConfig - return _NvidiaNimRankingConfig - - if name == "VertexAIRerankConfig": - from .llms.vertex_ai.rerank.transformation import ( - VertexAIRerankConfig as _VertexAIRerankConfig, - ) - - _globals["VertexAIRerankConfig"] = _VertexAIRerankConfig - return _VertexAIRerankConfig - - if name == "FireworksAIRerankConfig": - from .llms.fireworks_ai.rerank.transformation import ( - FireworksAIRerankConfig as _FireworksAIRerankConfig, - ) - - _globals["FireworksAIRerankConfig"] = _FireworksAIRerankConfig - return _FireworksAIRerankConfig - - if name == "VoyageRerankConfig": - from .llms.voyage.rerank.transformation import ( - VoyageRerankConfig as _VoyageRerankConfig, - ) - - _globals["VoyageRerankConfig"] = _VoyageRerankConfig - return _VoyageRerankConfig - - if name == "ClarifaiConfig": - from .llms.clarifai.chat.transformation import ClarifaiConfig as _ClarifaiConfig - - _globals["ClarifaiConfig"] = _ClarifaiConfig - return _ClarifaiConfig - - raise AttributeError(f"LLM config lazy import: unknown attribute {name!r}") \ No newline at end of file diff --git a/litellm/_lazy_imports_registry.py b/litellm/_lazy_imports_registry.py new file mode 100644 index 00000000000..e2f80a14391 --- /dev/null +++ b/litellm/_lazy_imports_registry.py @@ -0,0 +1,602 @@ +""" +Registry data for lazy imports. + +This module contains all the name tuples and import maps used by the lazy import system. +Separated from the handler functions for better organization. +""" + +# Cost calculator names that support lazy loading via _lazy_import_cost_calculator +COST_CALCULATOR_NAMES = ( + "completion_cost", + "cost_per_token", + "response_cost_calculator", +) + +# Litellm logging names that support lazy loading via _lazy_import_litellm_logging +LITELLM_LOGGING_NAMES = ( + "Logging", + "modify_integration", +) + +# Utils names that support lazy loading via _lazy_import_utils +UTILS_NAMES = ( + "exception_type", "get_optional_params", "get_response_string", "token_counter", + "create_pretrained_tokenizer", "create_tokenizer", "supports_function_calling", + "supports_web_search", "supports_url_context", "supports_response_schema", + "supports_parallel_function_calling", "supports_vision", "supports_audio_input", + "supports_audio_output", "supports_system_messages", "supports_reasoning", + "get_litellm_params", "acreate", "get_max_tokens", "get_model_info", + "register_prompt_template", "validate_environment", "check_valid_key", + "register_model", "encode", "decode", "_calculate_retry_after", "_should_retry", + "get_supported_openai_params", "get_api_base", "get_first_chars_messages", + "ModelResponse", "ModelResponseStream", "EmbeddingResponse", "ImageResponse", + "TranscriptionResponse", "TextCompletionResponse", "get_provider_fields", + "ModelResponseListIterator", "get_valid_models", "timeout", +) + +# Token counter names that support lazy loading via _lazy_import_token_counter +TOKEN_COUNTER_NAMES = ( + "get_modified_max_tokens", +) + +# LLM client cache names that support lazy loading via _lazy_import_llm_client_cache +LLM_CLIENT_CACHE_NAMES = ( + "LLMClientCache", + "in_memory_llm_clients_cache", +) + +# Bedrock type names that support lazy loading via _lazy_import_bedrock_types +BEDROCK_TYPES_NAMES = ( + "COHERE_EMBEDDING_INPUT_TYPES", +) + +# Common types from litellm.types.utils that support lazy loading via +# _lazy_import_types_utils +TYPES_UTILS_NAMES = ( + "ImageObject", + "BudgetConfig", + "all_litellm_params", + "_litellm_completion_params", + "CredentialItem", + "PriorityReservationDict", + "StandardKeyGenerationConfig", + "SearchProviders", + "GenericStreamingChunk", +) + +# Caching / cache classes that support lazy loading via _lazy_import_caching +CACHING_NAMES = ( + "Cache", + "DualCache", + "RedisCache", + "InMemoryCache", +) + +# HTTP handler names that support lazy loading via _lazy_import_http_handlers +HTTP_HANDLER_NAMES = ( + "module_level_aclient", + "module_level_client", +) + +# Dotprompt integration names that support lazy loading via _lazy_import_dotprompt +DOTPROMPT_NAMES = ( + "global_prompt_manager", + "global_prompt_directory", + "set_global_prompt_directory", +) + +# LLM config classes that support lazy loading via _lazy_import_llm_configs +LLM_CONFIG_NAMES = ( + "AmazonConverseConfig", + "OpenAILikeChatConfig", + "GaladrielChatConfig", + "GithubChatConfig", + "AzureAnthropicConfig", + "BytezChatConfig", + "CompactifAIChatConfig", + "EmpowerChatConfig", + "MinimaxChatConfig", + "AiohttpOpenAIChatConfig", + "HuggingFaceChatConfig", + "HuggingFaceEmbeddingConfig", + "OobaboogaConfig", + "MaritalkConfig", + "OpenrouterConfig", + "DataRobotConfig", + "AnthropicConfig", + "AnthropicTextConfig", + "GroqSTTConfig", + "TritonConfig", + "TritonGenerateConfig", + "TritonInferConfig", + "TritonEmbeddingConfig", + "HuggingFaceRerankConfig", + "DatabricksConfig", + "DatabricksEmbeddingConfig", + "PredibaseConfig", + "ReplicateConfig", + "SnowflakeConfig", + "CohereRerankConfig", + "CohereRerankV2Config", + "AzureAIRerankConfig", + "InfinityRerankConfig", + "JinaAIRerankConfig", + "DeepinfraRerankConfig", + "HostedVLLMRerankConfig", + "NvidiaNimRerankConfig", + "NvidiaNimRankingConfig", + "VertexAIRerankConfig", + "FireworksAIRerankConfig", + "VoyageRerankConfig", + "ClarifaiConfig", + "AI21ChatConfig", + "LlamaAPIConfig", + "TogetherAITextCompletionConfig", + "CloudflareChatConfig", + "NovitaConfig", + "PetalsConfig", + "OllamaChatConfig", + "OllamaConfig", + "SagemakerConfig", + "SagemakerChatConfig", + "CohereChatConfig", + "AnthropicMessagesConfig", + "AmazonAnthropicClaudeMessagesConfig", + "TogetherAIConfig", + "NLPCloudConfig", + "VertexGeminiConfig", + "GoogleAIStudioGeminiConfig", + "VertexAIAnthropicConfig", + "VertexAILlama3Config", + "VertexAIAi21Config", + "AmazonCohereChatConfig", + "AmazonBedrockGlobalConfig", + "AmazonAI21Config", + "AmazonInvokeNovaConfig", + "AmazonQwen2Config", + "AmazonQwen3Config", + # Aliases for backwards compatibility + "VertexAIConfig", # Alias for VertexGeminiConfig + "GeminiConfig", # Alias for GoogleAIStudioGeminiConfig + "AmazonAnthropicConfig", + "AmazonAnthropicClaudeConfig", + "AmazonCohereConfig", + "AmazonLlamaConfig", + "AmazonDeepSeekR1Config", + "AmazonMistralConfig", + "AmazonTitanConfig", + "AmazonTwelveLabsPegasusConfig", + "AmazonInvokeConfig", + "AmazonBedrockOpenAIConfig", + "AmazonStabilityConfig", + "AmazonStability3Config", + "AmazonNovaCanvasConfig", + "AmazonTitanG1Config", + "AmazonTitanMultimodalEmbeddingG1Config", + "CohereV2ChatConfig", + "BedrockCohereEmbeddingConfig", + "TwelveLabsMarengoEmbeddingConfig", + "AmazonNovaEmbeddingConfig", + "OpenAIConfig", + "MistralEmbeddingConfig", + "OpenAIImageVariationConfig", + "DeepInfraConfig", + "DeepgramAudioTranscriptionConfig", + "TopazImageVariationConfig", + "OpenAITextCompletionConfig", + "GroqChatConfig", + "GenAIHubOrchestrationConfig", + "VoyageEmbeddingConfig", + "VoyageContextualEmbeddingConfig", + "InfinityEmbeddingConfig", + "AzureAIStudioConfig", + "MistralConfig", + "OpenAIResponsesAPIConfig", + "AzureOpenAIResponsesAPIConfig", + "AzureOpenAIOSeriesResponsesAPIConfig", + "XAIResponsesAPIConfig", + "LiteLLMProxyResponsesAPIConfig", + "GoogleAIStudioInteractionsConfig", + "OpenAIOSeriesConfig", + "AnthropicSkillsConfig", + "BaseSkillsAPIConfig", + "GradientAIConfig", + # Alias for backwards compatibility + "OpenAIO1Config", # Alias for OpenAIOSeriesConfig + "OpenAIGPTConfig", + "OpenAIGPT5Config", + "OpenAIWhisperAudioTranscriptionConfig", + "OpenAIGPTAudioTranscriptionConfig", + "OpenAIGPTAudioConfig", + "NvidiaNimConfig", + "NvidiaNimEmbeddingConfig", + "FeatherlessAIConfig", + "CerebrasConfig", + "BasetenConfig", + "SambanovaConfig", + "SambaNovaEmbeddingConfig", + "FireworksAIConfig", + "FireworksAITextCompletionConfig", + "FireworksAIAudioTranscriptionConfig", + "FireworksAIEmbeddingConfig", + "FriendliaiChatConfig", + "JinaAIEmbeddingConfig", + "XAIChatConfig", + "ZAIChatConfig", + "AIMLChatConfig", + "VolcEngineChatConfig", + "CodestralTextCompletionConfig", + "AzureOpenAIAssistantsAPIConfig", + "HerokuChatConfig", + "CometAPIConfig", + "AzureOpenAIConfig", + "AzureOpenAIGPT5Config", + "AzureOpenAITextConfig", + "HostedVLLMChatConfig", + # Alias for backwards compatibility + "VolcEngineConfig", # Alias for VolcEngineChatConfig + "LlamafileChatConfig", + "LiteLLMProxyChatConfig", + "VLLMConfig", + "DeepSeekChatConfig", + "LMStudioChatConfig", + "LmStudioEmbeddingConfig", + "NscaleConfig", + "PerplexityChatConfig", + "AzureOpenAIO1Config", + "IBMWatsonXAIConfig", + "IBMWatsonXChatConfig", + "IBMWatsonXEmbeddingConfig", + "GenAIHubEmbeddingConfig", + "IBMWatsonXAudioTranscriptionConfig", + "GithubCopilotConfig", + "GithubCopilotResponsesAPIConfig", + "GithubCopilotEmbeddingConfig", + "NebiusConfig", + "WandbConfig", + "DashScopeChatConfig", + "MoonshotChatConfig", + "DockerModelRunnerChatConfig", + "V0ChatConfig", + "OCIChatConfig", + "MorphChatConfig", + "RAGFlowConfig", + "LambdaAIChatConfig", + "HyperbolicChatConfig", + "VercelAIGatewayConfig", + "OVHCloudChatConfig", + "OVHCloudEmbeddingConfig", + "CometAPIEmbeddingConfig", + "LemonadeChatConfig", + "SnowflakeEmbeddingConfig", + "AmazonNovaChatConfig", +) + +# Types that support lazy loading via _lazy_import_types +TYPES_NAMES = ( + "GuardrailItem", + "DefaultTeamSSOParams", + "LiteLLM_UpperboundKeyGenerateParams", + "KeyManagementSystem", + "PriorityReservationSettings", + "CustomLogger", + "LoggingCallbackManager", + # Note: LlmProviders is NOT lazy-loaded because it's imported during import time + # in multiple places including openai.py (via main import) + # Note: KeyManagementSettings is NOT lazy-loaded because _key_management_settings + # is accessed during import time in secret_managers/main.py +) + +# Import maps for registry pattern - reduces repetition +_UTILS_IMPORT_MAP = { + "exception_type": (".utils", "exception_type"), + "get_optional_params": (".utils", "get_optional_params"), + "get_response_string": (".utils", "get_response_string"), + "token_counter": (".utils", "token_counter"), + "create_pretrained_tokenizer": (".utils", "create_pretrained_tokenizer"), + "create_tokenizer": (".utils", "create_tokenizer"), + "supports_function_calling": (".utils", "supports_function_calling"), + "supports_web_search": (".utils", "supports_web_search"), + "supports_url_context": (".utils", "supports_url_context"), + "supports_response_schema": (".utils", "supports_response_schema"), + "supports_parallel_function_calling": (".utils", "supports_parallel_function_calling"), + "supports_vision": (".utils", "supports_vision"), + "supports_audio_input": (".utils", "supports_audio_input"), + "supports_audio_output": (".utils", "supports_audio_output"), + "supports_system_messages": (".utils", "supports_system_messages"), + "supports_reasoning": (".utils", "supports_reasoning"), + "get_litellm_params": (".utils", "get_litellm_params"), + "acreate": (".utils", "acreate"), + "get_max_tokens": (".utils", "get_max_tokens"), + "get_model_info": (".utils", "get_model_info"), + "register_prompt_template": (".utils", "register_prompt_template"), + "validate_environment": (".utils", "validate_environment"), + "check_valid_key": (".utils", "check_valid_key"), + "register_model": (".utils", "register_model"), + "encode": (".utils", "encode"), + "decode": (".utils", "decode"), + "_calculate_retry_after": (".utils", "_calculate_retry_after"), + "_should_retry": (".utils", "_should_retry"), + "get_supported_openai_params": (".utils", "get_supported_openai_params"), + "get_api_base": (".utils", "get_api_base"), + "get_first_chars_messages": (".utils", "get_first_chars_messages"), + "ModelResponse": (".utils", "ModelResponse"), + "ModelResponseStream": (".utils", "ModelResponseStream"), + "EmbeddingResponse": (".utils", "EmbeddingResponse"), + "ImageResponse": (".utils", "ImageResponse"), + "TranscriptionResponse": (".utils", "TranscriptionResponse"), + "TextCompletionResponse": (".utils", "TextCompletionResponse"), + "get_provider_fields": (".utils", "get_provider_fields"), + "ModelResponseListIterator": (".utils", "ModelResponseListIterator"), + "get_valid_models": (".utils", "get_valid_models"), + "timeout": (".timeout", "timeout"), +} + +_COST_CALCULATOR_IMPORT_MAP = { + "completion_cost": (".cost_calculator", "completion_cost"), + "cost_per_token": (".cost_calculator", "cost_per_token"), + "response_cost_calculator": (".cost_calculator", "response_cost_calculator"), +} + +_TYPES_UTILS_IMPORT_MAP = { + "ImageObject": (".types.utils", "ImageObject"), + "BudgetConfig": (".types.utils", "BudgetConfig"), + "all_litellm_params": (".types.utils", "all_litellm_params"), + "_litellm_completion_params": (".types.utils", "all_litellm_params"), # Alias + "CredentialItem": (".types.utils", "CredentialItem"), + "PriorityReservationDict": (".types.utils", "PriorityReservationDict"), + "StandardKeyGenerationConfig": (".types.utils", "StandardKeyGenerationConfig"), + "SearchProviders": (".types.utils", "SearchProviders"), + "GenericStreamingChunk": (".types.utils", "GenericStreamingChunk"), +} + +_TOKEN_COUNTER_IMPORT_MAP = { + "get_modified_max_tokens": ("litellm.litellm_core_utils.token_counter", "get_modified_max_tokens"), +} + +_BEDROCK_TYPES_IMPORT_MAP = { + "COHERE_EMBEDDING_INPUT_TYPES": ("litellm.types.llms.bedrock", "COHERE_EMBEDDING_INPUT_TYPES"), +} + +_CACHING_IMPORT_MAP = { + "Cache": ("litellm.caching.caching", "Cache"), + "DualCache": ("litellm.caching.caching", "DualCache"), + "RedisCache": ("litellm.caching.caching", "RedisCache"), + "InMemoryCache": ("litellm.caching.caching", "InMemoryCache"), +} + +_LITELLM_LOGGING_IMPORT_MAP = { + "Logging": ("litellm.litellm_core_utils.litellm_logging", "Logging"), + "modify_integration": ("litellm.litellm_core_utils.litellm_logging", "modify_integration"), +} + +_DOTPROMPT_IMPORT_MAP = { + "global_prompt_manager": ("litellm.integrations.dotprompt", "global_prompt_manager"), + "global_prompt_directory": ("litellm.integrations.dotprompt", "global_prompt_directory"), + "set_global_prompt_directory": ("litellm.integrations.dotprompt", "set_global_prompt_directory"), +} + +_TYPES_IMPORT_MAP = { + "GuardrailItem": ("litellm.types.guardrails", "GuardrailItem"), + "DefaultTeamSSOParams": ("litellm.types.proxy.management_endpoints.ui_sso", "DefaultTeamSSOParams"), + "LiteLLM_UpperboundKeyGenerateParams": ("litellm.types.proxy.management_endpoints.ui_sso", "LiteLLM_UpperboundKeyGenerateParams"), + "KeyManagementSystem": ("litellm.types.secret_managers.main", "KeyManagementSystem"), + "PriorityReservationSettings": ("litellm.types.utils", "PriorityReservationSettings"), + "CustomLogger": ("litellm.integrations.custom_logger", "CustomLogger"), + "LoggingCallbackManager": ("litellm.litellm_core_utils.logging_callback_manager", "LoggingCallbackManager"), +} + +_LLM_CONFIGS_IMPORT_MAP = { + "AmazonConverseConfig": (".llms.bedrock.chat.converse_transformation", "AmazonConverseConfig"), + "OpenAILikeChatConfig": (".llms.openai_like.chat.handler", "OpenAILikeChatConfig"), + "GaladrielChatConfig": (".llms.galadriel.chat.transformation", "GaladrielChatConfig"), + "GithubChatConfig": (".llms.github.chat.transformation", "GithubChatConfig"), + "AzureAnthropicConfig": (".llms.azure_ai.anthropic.transformation", "AzureAnthropicConfig"), + "BytezChatConfig": (".llms.bytez.chat.transformation", "BytezChatConfig"), + "CompactifAIChatConfig": (".llms.compactifai.chat.transformation", "CompactifAIChatConfig"), + "EmpowerChatConfig": (".llms.empower.chat.transformation", "EmpowerChatConfig"), + "MinimaxChatConfig": (".llms.minimax.chat.transformation", "MinimaxChatConfig"), + "AiohttpOpenAIChatConfig": (".llms.aiohttp_openai.chat.transformation", "AiohttpOpenAIChatConfig"), + "HuggingFaceChatConfig": (".llms.huggingface.chat.transformation", "HuggingFaceChatConfig"), + "HuggingFaceEmbeddingConfig": (".llms.huggingface.embedding.transformation", "HuggingFaceEmbeddingConfig"), + "OobaboogaConfig": (".llms.oobabooga.chat.transformation", "OobaboogaConfig"), + "MaritalkConfig": (".llms.maritalk", "MaritalkConfig"), + "OpenrouterConfig": (".llms.openrouter.chat.transformation", "OpenrouterConfig"), + "DataRobotConfig": (".llms.datarobot.chat.transformation", "DataRobotConfig"), + "AnthropicConfig": (".llms.anthropic.chat.transformation", "AnthropicConfig"), + "AnthropicTextConfig": (".llms.anthropic.completion.transformation", "AnthropicTextConfig"), + "GroqSTTConfig": (".llms.groq.stt.transformation", "GroqSTTConfig"), + "TritonConfig": (".llms.triton.completion.transformation", "TritonConfig"), + "TritonGenerateConfig": (".llms.triton.completion.transformation", "TritonGenerateConfig"), + "TritonInferConfig": (".llms.triton.completion.transformation", "TritonInferConfig"), + "TritonEmbeddingConfig": (".llms.triton.embedding.transformation", "TritonEmbeddingConfig"), + "HuggingFaceRerankConfig": (".llms.huggingface.rerank.transformation", "HuggingFaceRerankConfig"), + "DatabricksConfig": (".llms.databricks.chat.transformation", "DatabricksConfig"), + "DatabricksEmbeddingConfig": (".llms.databricks.embed.transformation", "DatabricksEmbeddingConfig"), + "PredibaseConfig": (".llms.predibase.chat.transformation", "PredibaseConfig"), + "ReplicateConfig": (".llms.replicate.chat.transformation", "ReplicateConfig"), + "SnowflakeConfig": (".llms.snowflake.chat.transformation", "SnowflakeConfig"), + "CohereRerankConfig": (".llms.cohere.rerank.transformation", "CohereRerankConfig"), + "CohereRerankV2Config": (".llms.cohere.rerank_v2.transformation", "CohereRerankV2Config"), + "AzureAIRerankConfig": (".llms.azure_ai.rerank.transformation", "AzureAIRerankConfig"), + "InfinityRerankConfig": (".llms.infinity.rerank.transformation", "InfinityRerankConfig"), + "JinaAIRerankConfig": (".llms.jina_ai.rerank.transformation", "JinaAIRerankConfig"), + "DeepinfraRerankConfig": (".llms.deepinfra.rerank.transformation", "DeepinfraRerankConfig"), + "HostedVLLMRerankConfig": (".llms.hosted_vllm.rerank.transformation", "HostedVLLMRerankConfig"), + "NvidiaNimRerankConfig": (".llms.nvidia_nim.rerank.transformation", "NvidiaNimRerankConfig"), + "NvidiaNimRankingConfig": (".llms.nvidia_nim.rerank.ranking_transformation", "NvidiaNimRankingConfig"), + "VertexAIRerankConfig": (".llms.vertex_ai.rerank.transformation", "VertexAIRerankConfig"), + "FireworksAIRerankConfig": (".llms.fireworks_ai.rerank.transformation", "FireworksAIRerankConfig"), + "VoyageRerankConfig": (".llms.voyage.rerank.transformation", "VoyageRerankConfig"), + "ClarifaiConfig": (".llms.clarifai.chat.transformation", "ClarifaiConfig"), + "AI21ChatConfig": (".llms.ai21.chat.transformation", "AI21ChatConfig"), + "LlamaAPIConfig": (".llms.meta_llama.chat.transformation", "LlamaAPIConfig"), + "TogetherAITextCompletionConfig": (".llms.together_ai.completion.transformation", "TogetherAITextCompletionConfig"), + "CloudflareChatConfig": (".llms.cloudflare.chat.transformation", "CloudflareChatConfig"), + "NovitaConfig": (".llms.novita.chat.transformation", "NovitaConfig"), + "PetalsConfig": (".llms.petals.completion.transformation", "PetalsConfig"), + "OllamaChatConfig": (".llms.ollama.chat.transformation", "OllamaChatConfig"), + "OllamaConfig": (".llms.ollama.completion.transformation", "OllamaConfig"), + "SagemakerConfig": (".llms.sagemaker.completion.transformation", "SagemakerConfig"), + "SagemakerChatConfig": (".llms.sagemaker.chat.transformation", "SagemakerChatConfig"), + "CohereChatConfig": (".llms.cohere.chat.transformation", "CohereChatConfig"), + "AnthropicMessagesConfig": (".llms.anthropic.experimental_pass_through.messages.transformation", "AnthropicMessagesConfig"), + "AmazonAnthropicClaudeMessagesConfig": (".llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation", "AmazonAnthropicClaudeMessagesConfig"), + "TogetherAIConfig": (".llms.together_ai.chat", "TogetherAIConfig"), + "NLPCloudConfig": (".llms.nlp_cloud.chat.handler", "NLPCloudConfig"), + "VertexGeminiConfig": (".llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini", "VertexGeminiConfig"), + "GoogleAIStudioGeminiConfig": (".llms.gemini.chat.transformation", "GoogleAIStudioGeminiConfig"), + "VertexAIAnthropicConfig": (".llms.vertex_ai.vertex_ai_partner_models.anthropic.transformation", "VertexAIAnthropicConfig"), + "VertexAILlama3Config": (".llms.vertex_ai.vertex_ai_partner_models.llama3.transformation", "VertexAILlama3Config"), + "VertexAIAi21Config": (".llms.vertex_ai.vertex_ai_partner_models.ai21.transformation", "VertexAIAi21Config"), + "AmazonCohereChatConfig": (".llms.bedrock.chat.invoke_handler", "AmazonCohereChatConfig"), + "AmazonBedrockGlobalConfig": (".llms.bedrock.common_utils", "AmazonBedrockGlobalConfig"), + "AmazonAI21Config": (".llms.bedrock.chat.invoke_transformations.amazon_ai21_transformation", "AmazonAI21Config"), + "AmazonInvokeNovaConfig": (".llms.bedrock.chat.invoke_transformations.amazon_nova_transformation", "AmazonInvokeNovaConfig"), + "AmazonQwen2Config": (".llms.bedrock.chat.invoke_transformations.amazon_qwen2_transformation", "AmazonQwen2Config"), + "AmazonQwen3Config": (".llms.bedrock.chat.invoke_transformations.amazon_qwen3_transformation", "AmazonQwen3Config"), + # Aliases for backwards compatibility + "VertexAIConfig": (".llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini", "VertexGeminiConfig"), # Alias + "GeminiConfig": (".llms.gemini.chat.transformation", "GoogleAIStudioGeminiConfig"), # Alias + "AmazonAnthropicConfig": (".llms.bedrock.chat.invoke_transformations.anthropic_claude2_transformation", "AmazonAnthropicConfig"), + "AmazonAnthropicClaudeConfig": (".llms.bedrock.chat.invoke_transformations.anthropic_claude3_transformation", "AmazonAnthropicClaudeConfig"), + "AmazonCohereConfig": (".llms.bedrock.chat.invoke_transformations.amazon_cohere_transformation", "AmazonCohereConfig"), + "AmazonLlamaConfig": (".llms.bedrock.chat.invoke_transformations.amazon_llama_transformation", "AmazonLlamaConfig"), + "AmazonDeepSeekR1Config": (".llms.bedrock.chat.invoke_transformations.amazon_deepseek_transformation", "AmazonDeepSeekR1Config"), + "AmazonMistralConfig": (".llms.bedrock.chat.invoke_transformations.amazon_mistral_transformation", "AmazonMistralConfig"), + "AmazonTitanConfig": (".llms.bedrock.chat.invoke_transformations.amazon_titan_transformation", "AmazonTitanConfig"), + "AmazonTwelveLabsPegasusConfig": (".llms.bedrock.chat.invoke_transformations.amazon_twelvelabs_pegasus_transformation", "AmazonTwelveLabsPegasusConfig"), + "AmazonInvokeConfig": (".llms.bedrock.chat.invoke_transformations.base_invoke_transformation", "AmazonInvokeConfig"), + "AmazonBedrockOpenAIConfig": (".llms.bedrock.chat.invoke_transformations.amazon_openai_transformation", "AmazonBedrockOpenAIConfig"), + "AmazonStabilityConfig": (".llms.bedrock.image_generation.amazon_stability1_transformation", "AmazonStabilityConfig"), + "AmazonStability3Config": (".llms.bedrock.image_generation.amazon_stability3_transformation", "AmazonStability3Config"), + "AmazonNovaCanvasConfig": (".llms.bedrock.image_generation.amazon_nova_canvas_transformation", "AmazonNovaCanvasConfig"), + "AmazonTitanG1Config": (".llms.bedrock.embed.amazon_titan_g1_transformation", "AmazonTitanG1Config"), + "AmazonTitanMultimodalEmbeddingG1Config": (".llms.bedrock.embed.amazon_titan_multimodal_transformation", "AmazonTitanMultimodalEmbeddingG1Config"), + "CohereV2ChatConfig": (".llms.cohere.chat.v2_transformation", "CohereV2ChatConfig"), + "BedrockCohereEmbeddingConfig": (".llms.bedrock.embed.cohere_transformation", "BedrockCohereEmbeddingConfig"), + "TwelveLabsMarengoEmbeddingConfig": (".llms.bedrock.embed.twelvelabs_marengo_transformation", "TwelveLabsMarengoEmbeddingConfig"), + "AmazonNovaEmbeddingConfig": (".llms.bedrock.embed.amazon_nova_transformation", "AmazonNovaEmbeddingConfig"), + "OpenAIConfig": (".llms.openai.openai", "OpenAIConfig"), + "MistralEmbeddingConfig": (".llms.openai.openai", "MistralEmbeddingConfig"), + "OpenAIImageVariationConfig": (".llms.openai.image_variations.transformation", "OpenAIImageVariationConfig"), + "DeepInfraConfig": (".llms.deepinfra.chat.transformation", "DeepInfraConfig"), + "DeepgramAudioTranscriptionConfig": (".llms.deepgram.audio_transcription.transformation", "DeepgramAudioTranscriptionConfig"), + "TopazImageVariationConfig": (".llms.topaz.image_variations.transformation", "TopazImageVariationConfig"), + "OpenAITextCompletionConfig": ("litellm.llms.openai.completion.transformation", "OpenAITextCompletionConfig"), + "GroqChatConfig": (".llms.groq.chat.transformation", "GroqChatConfig"), + "GenAIHubOrchestrationConfig": (".llms.sap.chat.transformation", "GenAIHubOrchestrationConfig"), + "VoyageEmbeddingConfig": (".llms.voyage.embedding.transformation", "VoyageEmbeddingConfig"), + "VoyageContextualEmbeddingConfig": (".llms.voyage.embedding.transformation_contextual", "VoyageContextualEmbeddingConfig"), + "InfinityEmbeddingConfig": (".llms.infinity.embedding.transformation", "InfinityEmbeddingConfig"), + "AzureAIStudioConfig": (".llms.azure_ai.chat.transformation", "AzureAIStudioConfig"), + "MistralConfig": (".llms.mistral.chat.transformation", "MistralConfig"), + "OpenAIResponsesAPIConfig": (".llms.openai.responses.transformation", "OpenAIResponsesAPIConfig"), + "AzureOpenAIResponsesAPIConfig": (".llms.azure.responses.transformation", "AzureOpenAIResponsesAPIConfig"), + "AzureOpenAIOSeriesResponsesAPIConfig": (".llms.azure.responses.o_series_transformation", "AzureOpenAIOSeriesResponsesAPIConfig"), + "XAIResponsesAPIConfig": (".llms.xai.responses.transformation", "XAIResponsesAPIConfig"), + "LiteLLMProxyResponsesAPIConfig": (".llms.litellm_proxy.responses.transformation", "LiteLLMProxyResponsesAPIConfig"), + "GoogleAIStudioInteractionsConfig": (".llms.gemini.interactions.transformation", "GoogleAIStudioInteractionsConfig"), + "OpenAIOSeriesConfig": (".llms.openai.chat.o_series_transformation", "OpenAIOSeriesConfig"), + "AnthropicSkillsConfig": (".llms.anthropic.skills.transformation", "AnthropicSkillsConfig"), + "BaseSkillsAPIConfig": (".llms.base_llm.skills.transformation", "BaseSkillsAPIConfig"), + "GradientAIConfig": (".llms.gradient_ai.chat.transformation", "GradientAIConfig"), + # Alias for backwards compatibility + "OpenAIO1Config": (".llms.openai.chat.o_series_transformation", "OpenAIOSeriesConfig"), # Alias + "OpenAIGPTConfig": (".llms.openai.chat.gpt_transformation", "OpenAIGPTConfig"), + "OpenAIGPT5Config": (".llms.openai.chat.gpt_5_transformation", "OpenAIGPT5Config"), + "OpenAIWhisperAudioTranscriptionConfig": (".llms.openai.transcriptions.whisper_transformation", "OpenAIWhisperAudioTranscriptionConfig"), + "OpenAIGPTAudioTranscriptionConfig": (".llms.openai.transcriptions.gpt_transformation", "OpenAIGPTAudioTranscriptionConfig"), + "OpenAIGPTAudioConfig": (".llms.openai.chat.gpt_audio_transformation", "OpenAIGPTAudioConfig"), + "NvidiaNimConfig": (".llms.nvidia_nim.chat.transformation", "NvidiaNimConfig"), + "NvidiaNimEmbeddingConfig": (".llms.nvidia_nim.embed", "NvidiaNimEmbeddingConfig"), + "FeatherlessAIConfig": (".llms.featherless_ai.chat.transformation", "FeatherlessAIConfig"), + "CerebrasConfig": (".llms.cerebras.chat", "CerebrasConfig"), + "BasetenConfig": (".llms.baseten.chat", "BasetenConfig"), + "SambanovaConfig": (".llms.sambanova.chat", "SambanovaConfig"), + "SambaNovaEmbeddingConfig": (".llms.sambanova.embedding.transformation", "SambaNovaEmbeddingConfig"), + "FireworksAIConfig": (".llms.fireworks_ai.chat.transformation", "FireworksAIConfig"), + "FireworksAITextCompletionConfig": (".llms.fireworks_ai.completion.transformation", "FireworksAITextCompletionConfig"), + "FireworksAIAudioTranscriptionConfig": (".llms.fireworks_ai.audio_transcription.transformation", "FireworksAIAudioTranscriptionConfig"), + "FireworksAIEmbeddingConfig": (".llms.fireworks_ai.embed.fireworks_ai_transformation", "FireworksAIEmbeddingConfig"), + "FriendliaiChatConfig": (".llms.friendliai.chat.transformation", "FriendliaiChatConfig"), + "JinaAIEmbeddingConfig": (".llms.jina_ai.embedding.transformation", "JinaAIEmbeddingConfig"), + "XAIChatConfig": (".llms.xai.chat.transformation", "XAIChatConfig"), + "ZAIChatConfig": (".llms.zai.chat.transformation", "ZAIChatConfig"), + "AIMLChatConfig": (".llms.aiml.chat.transformation", "AIMLChatConfig"), + "VolcEngineChatConfig": (".llms.volcengine.chat.transformation", "VolcEngineChatConfig"), + "CodestralTextCompletionConfig": (".llms.codestral.completion.transformation", "CodestralTextCompletionConfig"), + "AzureOpenAIAssistantsAPIConfig": (".llms.azure.azure", "AzureOpenAIAssistantsAPIConfig"), + "HerokuChatConfig": (".llms.heroku.chat.transformation", "HerokuChatConfig"), + "CometAPIConfig": (".llms.cometapi.chat.transformation", "CometAPIConfig"), + "AzureOpenAIConfig": (".llms.azure.chat.gpt_transformation", "AzureOpenAIConfig"), + "AzureOpenAIGPT5Config": (".llms.azure.chat.gpt_5_transformation", "AzureOpenAIGPT5Config"), + "AzureOpenAITextConfig": (".llms.azure.completion.transformation", "AzureOpenAITextConfig"), + "HostedVLLMChatConfig": (".llms.hosted_vllm.chat.transformation", "HostedVLLMChatConfig"), + # Alias for backwards compatibility + "VolcEngineConfig": (".llms.volcengine.chat.transformation", "VolcEngineChatConfig"), # Alias + "LlamafileChatConfig": (".llms.llamafile.chat.transformation", "LlamafileChatConfig"), + "LiteLLMProxyChatConfig": (".llms.litellm_proxy.chat.transformation", "LiteLLMProxyChatConfig"), + "VLLMConfig": (".llms.vllm.completion.transformation", "VLLMConfig"), + "DeepSeekChatConfig": (".llms.deepseek.chat.transformation", "DeepSeekChatConfig"), + "LMStudioChatConfig": (".llms.lm_studio.chat.transformation", "LMStudioChatConfig"), + "LmStudioEmbeddingConfig": (".llms.lm_studio.embed.transformation", "LmStudioEmbeddingConfig"), + "NscaleConfig": (".llms.nscale.chat.transformation", "NscaleConfig"), + "PerplexityChatConfig": (".llms.perplexity.chat.transformation", "PerplexityChatConfig"), + "AzureOpenAIO1Config": (".llms.azure.chat.o_series_transformation", "AzureOpenAIO1Config"), + "IBMWatsonXAIConfig": (".llms.watsonx.completion.transformation", "IBMWatsonXAIConfig"), + "IBMWatsonXChatConfig": (".llms.watsonx.chat.transformation", "IBMWatsonXChatConfig"), + "IBMWatsonXEmbeddingConfig": (".llms.watsonx.embed.transformation", "IBMWatsonXEmbeddingConfig"), + "GenAIHubEmbeddingConfig": (".llms.sap.embed.transformation", "GenAIHubEmbeddingConfig"), + "IBMWatsonXAudioTranscriptionConfig": (".llms.watsonx.audio_transcription.transformation", "IBMWatsonXAudioTranscriptionConfig"), + "GithubCopilotConfig": (".llms.github_copilot.chat.transformation", "GithubCopilotConfig"), + "GithubCopilotResponsesAPIConfig": (".llms.github_copilot.responses.transformation", "GithubCopilotResponsesAPIConfig"), + "GithubCopilotEmbeddingConfig": (".llms.github_copilot.embedding.transformation", "GithubCopilotEmbeddingConfig"), + "NebiusConfig": (".llms.nebius.chat.transformation", "NebiusConfig"), + "WandbConfig": (".llms.wandb.chat.transformation", "WandbConfig"), + "DashScopeChatConfig": (".llms.dashscope.chat.transformation", "DashScopeChatConfig"), + "MoonshotChatConfig": (".llms.moonshot.chat.transformation", "MoonshotChatConfig"), + "DockerModelRunnerChatConfig": (".llms.docker_model_runner.chat.transformation", "DockerModelRunnerChatConfig"), + "V0ChatConfig": (".llms.v0.chat.transformation", "V0ChatConfig"), + "OCIChatConfig": (".llms.oci.chat.transformation", "OCIChatConfig"), + "MorphChatConfig": (".llms.morph.chat.transformation", "MorphChatConfig"), + "RAGFlowConfig": (".llms.ragflow.chat.transformation", "RAGFlowConfig"), + "LambdaAIChatConfig": (".llms.lambda_ai.chat.transformation", "LambdaAIChatConfig"), + "HyperbolicChatConfig": (".llms.hyperbolic.chat.transformation", "HyperbolicChatConfig"), + "VercelAIGatewayConfig": (".llms.vercel_ai_gateway.chat.transformation", "VercelAIGatewayConfig"), + "OVHCloudChatConfig": (".llms.ovhcloud.chat.transformation", "OVHCloudChatConfig"), + "OVHCloudEmbeddingConfig": (".llms.ovhcloud.embedding.transformation", "OVHCloudEmbeddingConfig"), + "CometAPIEmbeddingConfig": (".llms.cometapi.embed.transformation", "CometAPIEmbeddingConfig"), + "LemonadeChatConfig": (".llms.lemonade.chat.transformation", "LemonadeChatConfig"), + "SnowflakeEmbeddingConfig": (".llms.snowflake.embedding.transformation", "SnowflakeEmbeddingConfig"), + "AmazonNovaChatConfig": (".llms.amazon_nova.chat.transformation", "AmazonNovaChatConfig"), +} + +# Export all name tuples and import maps for use in _lazy_imports.py +__all__ = [ + # Name tuples + "COST_CALCULATOR_NAMES", + "LITELLM_LOGGING_NAMES", + "UTILS_NAMES", + "TOKEN_COUNTER_NAMES", + "LLM_CLIENT_CACHE_NAMES", + "BEDROCK_TYPES_NAMES", + "TYPES_UTILS_NAMES", + "CACHING_NAMES", + "HTTP_HANDLER_NAMES", + "DOTPROMPT_NAMES", + "LLM_CONFIG_NAMES", + "TYPES_NAMES", + # Import maps + "_UTILS_IMPORT_MAP", + "_COST_CALCULATOR_IMPORT_MAP", + "_TYPES_UTILS_IMPORT_MAP", + "_TOKEN_COUNTER_IMPORT_MAP", + "_BEDROCK_TYPES_IMPORT_MAP", + "_CACHING_IMPORT_MAP", + "_LITELLM_LOGGING_IMPORT_MAP", + "_DOTPROMPT_IMPORT_MAP", + "_TYPES_IMPORT_MAP", + "_LLM_CONFIGS_IMPORT_MAP", +] + diff --git a/litellm/constants.py b/litellm/constants.py index 511cbafc748..e8524a87c41 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -556,6 +556,11 @@ openai_compatible_endpoints: List = [ "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "https://api.moonshot.ai/v1", "https://api.publicai.co/v1", + "https://api.synthetic.new/openai/v1", + "https://api.stima.tech/v1", + "https://nano-gpt.com/api/v1", + "https://api.poe.com/v1", + "https://llm.chutes.ai/v1/", "https://api.v0.dev/v1", "https://api.morphllm.com/v1", "https://api.lambda.ai/v1", @@ -599,12 +604,16 @@ openai_compatible_providers: List = [ "novita", "meta_llama", "publicai", # PublicAI - JSON-configured provider + "synthetic", # Synthetic - JSON-configured provider + "apertis", # Apertis - JSON-configured provider + "nano-gpt", # Nano-GPT - JSON-configured provider + "poe", # Poe - JSON-configured provider + "chutes", # Chutes - JSON-configured provider "featherless_ai", "nscale", "nebius", "dashscope", "moonshot", - "publicai", "v0", "helicone", "morph", @@ -630,6 +639,11 @@ openai_text_completion_compatible_providers: List = ( "dashscope", "moonshot", "publicai", + "synthetic", + "apertis", + "nano-gpt", + "poe", + "chutes", "v0", "lambda_ai", "hyperbolic", @@ -1186,6 +1200,8 @@ LITELLM_SETTINGS_SAFE_DB_OVERRIDES = [ "public_agent_groups", "public_model_groups", "public_model_groups_links", + "cost_discount_config", + "cost_margin_config", ] SPECIAL_LITELLM_AUTH_TOKEN = ["ui-token"] DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL = int( diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 371e53283de..af7dd078107 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -708,6 +708,69 @@ def _apply_cost_discount( return base_cost, discount_percent, discount_amount +def _apply_cost_margin( + base_cost: float, + custom_llm_provider: Optional[str], +) -> Tuple[float, float, float, float]: + """ + Apply provider-specific or global cost margin from module-level config. + + Args: + base_cost: The base cost before margin (after discount if applicable) + custom_llm_provider: The LLM provider name + + Returns: + Tuple of (final_cost, margin_percent, margin_fixed_amount, margin_total_amount) + """ + original_cost = base_cost + margin_percent = 0.0 + margin_fixed_amount = 0.0 + margin_total_amount = 0.0 + + # Get margin config - check provider-specific first, then global + margin_config = None + if custom_llm_provider and custom_llm_provider in litellm.cost_margin_config: + margin_config = litellm.cost_margin_config[custom_llm_provider] + verbose_logger.debug( + f"Found provider-specific margin config for {custom_llm_provider}: {margin_config}" + ) + elif "global" in litellm.cost_margin_config: + margin_config = litellm.cost_margin_config["global"] + verbose_logger.debug(f"Using global margin config: {margin_config}") + else: + verbose_logger.debug( + f"No margin config found. Provider: {custom_llm_provider}, " + f"Available configs: {list(litellm.cost_margin_config.keys())}" + ) + + if margin_config is not None: + # Handle different margin config formats + if isinstance(margin_config, (int, float)): + # Simple percentage: {"openai": 0.10} + margin_percent = float(margin_config) + margin_total_amount = original_cost * margin_percent + elif isinstance(margin_config, dict): + # Complex config: {"percentage": 0.08, "fixed_amount": 0.0005} + if "percentage" in margin_config: + margin_percent = float(margin_config["percentage"]) + margin_total_amount += original_cost * margin_percent + if "fixed_amount" in margin_config: + margin_fixed_amount = float(margin_config["fixed_amount"]) + margin_total_amount += margin_fixed_amount + + final_cost = original_cost + margin_total_amount + + verbose_logger.debug( + f"Applied margin to {custom_llm_provider or 'global'}: " + f"${original_cost:.6f} -> ${final_cost:.6f} " + f"(margin: {margin_percent*100 if margin_percent > 0 else 0}% + ${margin_fixed_amount:.6f} = ${margin_total_amount:.6f})" + ) + + return final_cost, margin_percent, margin_fixed_amount, margin_total_amount + + return base_cost, margin_percent, margin_fixed_amount, margin_total_amount + + def _store_cost_breakdown_in_logging_obj( litellm_logging_obj: Optional[LitellmLoggingObject], prompt_tokens_cost_usd_dollar: float, @@ -717,6 +780,9 @@ def _store_cost_breakdown_in_logging_obj( original_cost: Optional[float] = None, discount_percent: Optional[float] = None, discount_amount: Optional[float] = None, + margin_percent: Optional[float] = None, + margin_fixed_amount: Optional[float] = None, + margin_total_amount: Optional[float] = None, ) -> None: """ Helper function to store cost breakdown in the logging object. @@ -730,6 +796,9 @@ def _store_cost_breakdown_in_logging_obj( original_cost: Cost before discount discount_percent: Discount percentage applied (0.05 = 5%) discount_amount: Discount amount in USD + margin_percent: Margin percentage applied (0.10 = 10%) + margin_fixed_amount: Fixed margin amount in USD + margin_total_amount: Total margin added in USD """ if litellm_logging_obj is None: return @@ -744,6 +813,9 @@ def _store_cost_breakdown_in_logging_obj( original_cost=original_cost, discount_percent=discount_percent, discount_amount=discount_amount, + margin_percent=margin_percent, + margin_fixed_amount=margin_fixed_amount, + margin_total_amount=margin_total_amount, ) except Exception as breakdown_error: @@ -1106,6 +1178,17 @@ def completion_cost( # noqa: PLR0915 custom_llm_provider=custom_llm_provider, ) + # Apply margin from module-level config if configured + ( + _final_cost, + margin_percent, + margin_fixed_amount, + margin_total_amount, + ) = _apply_cost_margin( + base_cost=_final_cost, + custom_llm_provider=custom_llm_provider, + ) + # Store cost breakdown in logging object if available _store_cost_breakdown_in_logging_obj( litellm_logging_obj=litellm_logging_obj, @@ -1116,6 +1199,9 @@ def completion_cost( # noqa: PLR0915 original_cost=original_cost, discount_percent=discount_percent, discount_amount=discount_amount, + margin_percent=margin_percent, + margin_fixed_amount=margin_fixed_amount, + margin_total_amount=margin_total_amount, ) return _final_cost @@ -1239,6 +1325,17 @@ def completion_cost( # noqa: PLR0915 custom_llm_provider=custom_llm_provider, ) + # Apply margin from module-level config if configured + ( + _final_cost, + margin_percent, + margin_fixed_amount, + margin_total_amount, + ) = _apply_cost_margin( + base_cost=_final_cost, + custom_llm_provider=custom_llm_provider, + ) + # Store cost breakdown in logging object if available _store_cost_breakdown_in_logging_obj( litellm_logging_obj=litellm_logging_obj, @@ -1249,6 +1346,9 @@ def completion_cost( # noqa: PLR0915 original_cost=original_cost, discount_percent=discount_percent, discount_amount=discount_amount, + margin_percent=margin_percent, + margin_fixed_amount=margin_fixed_amount, + margin_total_amount=margin_total_amount, ) return _final_cost diff --git a/litellm/images/main.py b/litellm/images/main.py index 03c0e36ad93..cf588cbcf0f 100644 --- a/litellm/images/main.py +++ b/litellm/images/main.py @@ -2,7 +2,18 @@ import asyncio import contextvars import importlib from functools import partial -from typing import TYPE_CHECKING, Any, Coroutine, Dict, List, Literal, Optional, Union, cast, overload +from typing import ( + TYPE_CHECKING, + Any, + Coroutine, + Dict, + List, + Literal, + Optional, + Union, + cast, + overload, +) if TYPE_CHECKING: from litellm.images.utils import ImageEditRequestUtils @@ -10,7 +21,7 @@ if TYPE_CHECKING: import httpx import litellm -from litellm.utils import exception_type, get_litellm_params + # client is imported from litellm as it's a decorator from litellm import client from litellm.constants import DEFAULT_IMAGE_ENDPOINT_MODEL @@ -23,6 +34,7 @@ from litellm.llms.base_llm import BaseImageEditConfig, BaseImageGenerationConfig from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler from litellm.llms.custom_llm import CustomLLM +from litellm.utils import exception_type, get_litellm_params #################### Initialize provider clients #################### llm_http_handler: BaseLLMHTTPHandler = BaseLLMHTTPHandler() @@ -32,8 +44,8 @@ from litellm.main import ( azure_chat_completions, base_llm_aiohttp_handler, base_llm_http_handler, - bedrock_image_generation, bedrock_image_edit, + bedrock_image_generation, openai_chat_completions, openai_image_variations, ) @@ -330,11 +342,36 @@ def image_generation( # noqa: PLR0915 azure_ad_token = optional_params.pop( "azure_ad_token", None ) or get_secret_str("AZURE_AD_TOKEN") + + # Create azure_ad_token_provider from tenant_id, client_id, client_secret if not already provided + if azure_ad_token_provider is None: + from litellm.llms.azure.common_utils import ( + get_azure_ad_token_from_entra_id, + ) + + # Extract Azure AD credentials from litellm_params + tenant_id = litellm_params_dict.get("tenant_id") + client_id = litellm_params_dict.get("client_id") + client_secret = litellm_params_dict.get("client_secret") + azure_scope = litellm_params_dict.get("azure_scope") or "https://cognitiveservices.azure.com/.default" + + # Create token provider if credentials are available + if tenant_id and client_id and client_secret: + azure_ad_token_provider = get_azure_ad_token_from_entra_id( + tenant_id=tenant_id, + client_id=client_id, + client_secret=client_secret, + scope=azure_scope, + ) default_headers = { "Content-Type": "application/json", - "api-key": api_key, } + # Only add api-key header if api_key is not None + # Azure AD authentication will use Authorization header instead + if api_key is not None: + default_headers["api-key"] = api_key + for k, v in default_headers.items(): if k not in headers: headers[k] = v @@ -399,8 +436,12 @@ def image_generation( # noqa: PLR0915 default_headers = { "Content-Type": "application/json", - "api-key": api_key, } + # Only add api-key header if api_key is not None + # Azure AD authentication will use Authorization header instead + if api_key is not None: + default_headers["api-key"] = api_key + for k, v in default_headers.items(): if k not in headers: headers[k] = v @@ -983,6 +1024,7 @@ def __getattr__(name: str) -> Any: if name == "ImageEditRequestUtils": # Lazy load ImageEditRequestUtils to avoid heavy import from images.utils at module load time from .utils import ImageEditRequestUtils as _ImageEditRequestUtils + # Cache it in the module's __dict__ for subsequent accesses module = importlib.import_module(__name__) module.__dict__["ImageEditRequestUtils"] = _ImageEditRequestUtils diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index fe0ce208ee6..6a76b57e7f7 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -243,14 +243,14 @@ class CustomGuardrail(CustomLogger): def _is_valid_response_type(self, result: Any) -> bool: """ Check if result is a valid LLMResponseTypes instance. - + Safely handles TypedDict types which don't support isinstance checks. For non-LiteLLM responses (like passthrough httpx.Response), returns True to allow them through. """ if result is None: return False - + try: # Try isinstance check on valid types that support it response_types = get_args(LLMResponseTypes) @@ -506,6 +506,7 @@ class CustomGuardrail(CustomLogger): duration: Optional[float] = None, masked_entity_count: Optional[Dict[str, int]] = None, guardrail_provider: Optional[str] = None, + event_type: Optional[GuardrailEventHooks] = None, ) -> None: """ Builds `StandardLoggingGuardrailInformation` and adds it to the request metadata so it can be used for logging to DataDog, Langfuse, etc. @@ -514,14 +515,19 @@ class CustomGuardrail(CustomLogger): guardrail_json_response = str(guardrail_json_response) from litellm.types.utils import GuardrailMode + # Use event_type if provided, otherwise fall back to self.event_hook + guardrail_mode: Union[GuardrailEventHooks, GuardrailMode, List[GuardrailEventHooks]] + if event_type is not None: + guardrail_mode = event_type + elif isinstance(self.event_hook, Mode): + guardrail_mode = GuardrailMode(**dict(self.event_hook.model_dump())) # type: ignore[typeddict-item] + else: + guardrail_mode = self.event_hook # type: ignore[assignment] + slg = StandardLoggingGuardrailInformation( guardrail_name=self.guardrail_name, guardrail_provider=guardrail_provider, - guardrail_mode=( - GuardrailMode(**self.event_hook.model_dump()) # type: ignore - if isinstance(self.event_hook, Mode) - else self.event_hook - ), + guardrail_mode=guardrail_mode, guardrail_response=guardrail_json_response, guardrail_status=guardrail_status, start_time=start_time, @@ -589,6 +595,7 @@ class CustomGuardrail(CustomLogger): start_time: Optional[float] = None, end_time: Optional[float] = None, duration: Optional[float] = None, + event_type: Optional[GuardrailEventHooks] = None, ): """ Add StandardLoggingGuardrailInformation to the request data @@ -605,6 +612,7 @@ class CustomGuardrail(CustomLogger): duration=duration, start_time=start_time, end_time=end_time, + event_type=event_type, ) return response @@ -615,6 +623,7 @@ class CustomGuardrail(CustomLogger): start_time: Optional[float] = None, end_time: Optional[float] = None, duration: Optional[float] = None, + event_type: Optional[GuardrailEventHooks] = None, ): """ Add StandardLoggingGuardrailInformation to the request data @@ -628,6 +637,7 @@ class CustomGuardrail(CustomLogger): duration=duration, start_time=start_time, end_time=end_time, + event_type=event_type, ) raise e @@ -712,16 +722,32 @@ def log_guardrail_information(func): Logs for: - pre_call - during_call - - TODO: log post_call. This is more involved since the logs are sent to DD, s3 before the guardrail is even run + - post_call """ import asyncio import functools + def _infer_event_type_from_function_name( + func_name: str, + ) -> Optional[GuardrailEventHooks]: + """Infer the actual event type from the function name""" + if func_name == "async_pre_call_hook": + return GuardrailEventHooks.pre_call + elif func_name == "async_moderation_hook": + return GuardrailEventHooks.during_call + elif func_name in ( + "async_post_call_success_hook", + "async_post_call_streaming_hook", + ): + return GuardrailEventHooks.post_call + return None + @functools.wraps(func) async def async_wrapper(*args, **kwargs): start_time = datetime.now() # Move start_time inside the wrapper self: CustomGuardrail = args[0] request_data: dict = kwargs.get("data") or kwargs.get("request_data") or {} + event_type = _infer_event_type_from_function_name(func.__name__) try: response = await func(*args, **kwargs) return self._process_response( @@ -730,6 +756,7 @@ def log_guardrail_information(func): start_time=start_time.timestamp(), end_time=datetime.now().timestamp(), duration=(datetime.now() - start_time).total_seconds(), + event_type=event_type, ) except Exception as e: return self._process_error( @@ -738,6 +765,7 @@ def log_guardrail_information(func): start_time=start_time.timestamp(), end_time=datetime.now().timestamp(), duration=(datetime.now() - start_time).total_seconds(), + event_type=event_type, ) @functools.wraps(func) @@ -745,18 +773,21 @@ def log_guardrail_information(func): start_time = datetime.now() # Move start_time inside the wrapper self: CustomGuardrail = args[0] request_data: dict = kwargs.get("data") or kwargs.get("request_data") or {} + event_type = _infer_event_type_from_function_name(func.__name__) try: response = func(*args, **kwargs) return self._process_response( response=response, request_data=request_data, duration=(datetime.now() - start_time).total_seconds(), + event_type=event_type, ) except Exception as e: return self._process_error( e=e, request_data=request_data, duration=(datetime.now() - start_time).total_seconds(), + event_type=event_type, ) @functools.wraps(func) diff --git a/litellm/integrations/custom_logger.py b/litellm/integrations/custom_logger.py index 6771999cd35..4c4e6fa6342 100644 --- a/litellm/integrations/custom_logger.py +++ b/litellm/integrations/custom_logger.py @@ -32,6 +32,8 @@ from litellm.types.utils import ( ) if TYPE_CHECKING: + from fastapi import HTTPException + from litellm.caching.caching import DualCache from opentelemetry.trace import Span as _Span @@ -348,7 +350,20 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac original_exception: Exception, user_api_key_dict: UserAPIKeyAuth, traceback_str: Optional[str] = None, - ): + ) -> Optional["HTTPException"]: + """ + Called after an LLM API call fails. Can return or raise HTTPException to transform error responses. + + Args: + - request_data: dict - The request data. + - original_exception: Exception - The original exception that occurred. + - user_api_key_dict: UserAPIKeyAuth - The user API key dictionary. + - traceback_str: Optional[str] - The traceback string. + + Returns: + - Optional[HTTPException]: Return an HTTPException to transform the error response sent to the client. + Return None to use the original exception. + """ pass async def async_post_call_success_hook( diff --git a/litellm/integrations/datadog/datadog_llm_obs.py b/litellm/integrations/datadog/datadog_llm_obs.py index 65ed8a795c0..6ffdbc0a005 100644 --- a/litellm/integrations/datadog/datadog_llm_obs.py +++ b/litellm/integrations/datadog/datadog_llm_obs.py @@ -217,8 +217,14 @@ class DataDogLLMObsLogger(CustomBatchLogger): error_info = self._assemble_error_info(standard_logging_payload) + metadata_parent_id: Optional[str] = None + if isinstance(metadata, dict): + metadata_parent_id = metadata.get("parent_id") + meta = Meta( - kind=self._get_datadog_span_kind(standard_logging_payload.get("call_type")), + kind=self._get_datadog_span_kind( + standard_logging_payload.get("call_type"), metadata_parent_id + ), input=input_meta, output=output_meta, metadata=self._get_dd_llm_obs_payload_metadata(standard_logging_payload), @@ -237,7 +243,7 @@ class DataDogLLMObsLogger(CustomBatchLogger): ) payload: LLMObsPayload = LLMObsPayload( - parent_id=metadata.get("parent_id", "undefined"), + parent_id=metadata_parent_id if metadata_parent_id else "undefined", trace_id=standard_logging_payload.get("trace_id", str(uuid.uuid4())), span_id=metadata.get("span_id", str(uuid.uuid4())), name=metadata.get("name", "litellm_llm_call"), @@ -367,14 +373,16 @@ class DataDogLLMObsLogger(CustomBatchLogger): return [] def _get_datadog_span_kind( - self, call_type: Optional[str] + self, call_type: Optional[str], parent_id: Optional[str] = None ) -> Literal["llm", "tool", "task", "embedding", "retrieval"]: """ Map liteLLM call_type to appropriate DataDog LLM Observability span kind. Available DataDog span kinds: "llm", "tool", "task", "embedding", "retrieval" + see: https://docs.datadoghq.com/ja/llm_observability/terms/ """ - if call_type is None: + # Non llm/workflow/agent kinds cannot be root spans, so fallback to "llm" when parent metadata is missing + if call_type is None or parent_id is None: return "llm" # Embedding operations @@ -392,6 +400,8 @@ class DataDogLLMObsLogger(CustomBatchLogger): CallTypes.generate_content_stream.value, CallTypes.agenerate_content_stream.value, CallTypes.anthropic_messages.value, + CallTypes.responses.value, + CallTypes.aresponses.value, ]: return "llm" @@ -417,8 +427,6 @@ class DataDogLLMObsLogger(CustomBatchLogger): CallTypes.aretrieve_batch.value, CallTypes.retrieve_fine_tuning_job.value, CallTypes.aretrieve_fine_tuning_job.value, - CallTypes.responses.value, - CallTypes.aresponses.value, CallTypes.alist_input_items.value, ]: return "retrieval" diff --git a/litellm/interactions/litellm_responses_transformation/__init__.py b/litellm/interactions/litellm_responses_transformation/__init__.py new file mode 100644 index 00000000000..2450a9f3d20 --- /dev/null +++ b/litellm/interactions/litellm_responses_transformation/__init__.py @@ -0,0 +1,16 @@ +""" +Bridge module for connecting Interactions API to Responses API via litellm.responses(). +""" + +from litellm.interactions.litellm_responses_transformation.handler import ( + LiteLLMResponsesInteractionsHandler, +) +from litellm.interactions.litellm_responses_transformation.transformation import ( + LiteLLMResponsesInteractionsConfig, +) + +__all__ = [ + "LiteLLMResponsesInteractionsHandler", + "LiteLLMResponsesInteractionsConfig", # Transformation config class (not BaseInteractionsAPIConfig) +] + diff --git a/litellm/interactions/litellm_responses_transformation/handler.py b/litellm/interactions/litellm_responses_transformation/handler.py new file mode 100644 index 00000000000..c2df8f96eff --- /dev/null +++ b/litellm/interactions/litellm_responses_transformation/handler.py @@ -0,0 +1,156 @@ +""" +Handler for transforming interactions API requests to litellm.responses requests. +""" + +from typing import ( + Any, + AsyncIterator, + Coroutine, + Dict, + Iterator, + Optional, + Union, + cast, +) + +import litellm +from litellm.interactions.litellm_responses_transformation.streaming_iterator import ( + LiteLLMResponsesInteractionsStreamingIterator, +) +from litellm.interactions.litellm_responses_transformation.transformation import ( + LiteLLMResponsesInteractionsConfig, +) +from litellm.responses.streaming_iterator import BaseResponsesAPIStreamingIterator +from litellm.types.interactions import ( + InteractionInput, + InteractionsAPIOptionalRequestParams, + InteractionsAPIResponse, + InteractionsAPIStreamingResponse, +) +from litellm.types.llms.openai import ResponsesAPIResponse + + +class LiteLLMResponsesInteractionsHandler: + """Handler for bridging Interactions API to Responses API via litellm.responses().""" + + def interactions_api_handler( + self, + model: str, + input: Optional[InteractionInput], + optional_params: InteractionsAPIOptionalRequestParams, + custom_llm_provider: Optional[str] = None, + _is_async: bool = False, + stream: Optional[bool] = None, + **kwargs, + ) -> Union[ + InteractionsAPIResponse, + Iterator[InteractionsAPIStreamingResponse], + Coroutine[ + Any, + Any, + Union[ + InteractionsAPIResponse, + AsyncIterator[InteractionsAPIStreamingResponse], + ], + ], + ]: + """ + Handle Interactions API request by calling litellm.responses(). + + Args: + model: The model to use + input: The input content + optional_params: Optional parameters for the request + custom_llm_provider: Override LLM provider + _is_async: Whether this is an async call + stream: Whether to stream the response + **kwargs: Additional parameters + + Returns: + InteractionsAPIResponse or streaming iterator + """ + # Transform interactions request to responses request + responses_request = ( + LiteLLMResponsesInteractionsConfig.transform_interactions_request_to_responses_request( + model=model, + input=input, + optional_params=optional_params, + custom_llm_provider=custom_llm_provider, + stream=stream, + **kwargs, + ) + ) + + if _is_async: + return self.async_interactions_api_handler( + responses_request=responses_request, + model=model, + input=input, + optional_params=optional_params, + **kwargs, + ) + + # Call litellm.responses() + # Note: litellm.responses() returns Union[ResponsesAPIResponse, BaseResponsesAPIStreamingIterator] + # but the type checker may see it as a coroutine in some contexts + responses_response = litellm.responses( + **responses_request, + ) + + # Handle streaming response + if isinstance(responses_response, BaseResponsesAPIStreamingIterator): + return LiteLLMResponsesInteractionsStreamingIterator( + model=model, + litellm_custom_stream_wrapper=responses_response, + request_input=input, + optional_params=optional_params, + custom_llm_provider=custom_llm_provider, + litellm_metadata=kwargs.get("litellm_metadata", {}), + ) + + # At this point, responses_response must be ResponsesAPIResponse (not streaming) + # Cast to satisfy type checker since we've already checked it's not a streaming iterator + responses_api_response = cast(ResponsesAPIResponse, responses_response) + + # Transform responses response to interactions response + return LiteLLMResponsesInteractionsConfig.transform_responses_response_to_interactions_response( + responses_response=responses_api_response, + model=model, + ) + + async def async_interactions_api_handler( + self, + responses_request: Dict[str, Any], + model: str, + input: Optional[InteractionInput], + optional_params: InteractionsAPIOptionalRequestParams, + **kwargs, + ) -> Union[InteractionsAPIResponse, AsyncIterator[InteractionsAPIStreamingResponse]]: + """Async handler for interactions API requests.""" + # Call litellm.aresponses() + # Note: litellm.aresponses() returns Union[ResponsesAPIResponse, BaseResponsesAPIStreamingIterator] + responses_response = await litellm.aresponses( + **responses_request, + ) + + # Handle streaming response + if isinstance(responses_response, BaseResponsesAPIStreamingIterator): + return LiteLLMResponsesInteractionsStreamingIterator( + model=model, + litellm_custom_stream_wrapper=responses_response, + request_input=input, + optional_params=optional_params, + custom_llm_provider=responses_request.get("custom_llm_provider"), + litellm_metadata=kwargs.get("litellm_metadata", {}), + ) + + # At this point, responses_response must be ResponsesAPIResponse (not streaming) + # Cast to satisfy type checker since we've already checked it's not a streaming iterator + responses_api_response = cast(ResponsesAPIResponse, responses_response) + + # Transform responses response to interactions response + return LiteLLMResponsesInteractionsConfig.transform_responses_response_to_interactions_response( + responses_response=responses_api_response, + model=model, + ) + diff --git a/litellm/interactions/litellm_responses_transformation/streaming_iterator.py b/litellm/interactions/litellm_responses_transformation/streaming_iterator.py new file mode 100644 index 00000000000..511b69e83b2 --- /dev/null +++ b/litellm/interactions/litellm_responses_transformation/streaming_iterator.py @@ -0,0 +1,260 @@ +""" +Streaming iterator for transforming Responses API stream to Interactions API stream. +""" + +from typing import Any, AsyncIterator, Dict, Iterator, Optional, cast + +from litellm.responses.streaming_iterator import ( + BaseResponsesAPIStreamingIterator, + ResponsesAPIStreamingIterator, + SyncResponsesAPIStreamingIterator, +) +from litellm.types.interactions import ( + InteractionInput, + InteractionsAPIOptionalRequestParams, + InteractionsAPIStreamingResponse, +) +from litellm.types.llms.openai import ( + OutputTextDeltaEvent, + ResponseCompletedEvent, + ResponseCreatedEvent, + ResponseInProgressEvent, + ResponsesAPIStreamingResponse, +) + + +class LiteLLMResponsesInteractionsStreamingIterator: + """ + Iterator that wraps Responses API streaming and transforms chunks to Interactions API format. + + This class handles both sync and async iteration, transforming Responses API + streaming events (output.text.delta, response.completed, etc.) to Interactions + API streaming events (content.delta, interaction.complete, etc.). + """ + + def __init__( + self, + model: str, + litellm_custom_stream_wrapper: BaseResponsesAPIStreamingIterator, + request_input: Optional[InteractionInput], + optional_params: InteractionsAPIOptionalRequestParams, + custom_llm_provider: Optional[str] = None, + litellm_metadata: Optional[Dict[str, Any]] = None, + ): + self.model = model + self.responses_stream_iterator = litellm_custom_stream_wrapper + self.request_input = request_input + self.optional_params = optional_params + self.custom_llm_provider = custom_llm_provider + self.litellm_metadata = litellm_metadata or {} + self.finished = False + self.collected_text = "" + self.sent_interaction_start = False + self.sent_content_start = False + + def _transform_responses_chunk_to_interactions_chunk( + self, + responses_chunk: ResponsesAPIStreamingResponse, + ) -> Optional[InteractionsAPIStreamingResponse]: + """ + Transform a Responses API streaming chunk to an Interactions API streaming chunk. + + Responses API events: + - output.text.delta -> content.delta + - response.completed -> interaction.complete + + Interactions API events: + - interaction.start + - content.start + - content.delta + - content.stop + - interaction.complete + """ + if not responses_chunk: + return None + + # Handle OutputTextDeltaEvent -> content.delta + if isinstance(responses_chunk, OutputTextDeltaEvent): + delta_text = responses_chunk.delta if isinstance(responses_chunk.delta, str) else "" + self.collected_text += delta_text + + # Send interaction.start if not sent + if not self.sent_interaction_start: + self.sent_interaction_start = True + return InteractionsAPIStreamingResponse( + event_type="interaction.start", + id=getattr(responses_chunk, "item_id", None) or f"interaction_{id(self)}", + object="interaction", + status="in_progress", + model=self.model, + ) + + # Send content.start if not sent + if not self.sent_content_start: + self.sent_content_start = True + return InteractionsAPIStreamingResponse( + event_type="content.start", + id=getattr(responses_chunk, "item_id", None), + object="content", + delta={"type": "text", "text": ""}, + ) + + # Send content.delta + return InteractionsAPIStreamingResponse( + event_type="content.delta", + id=getattr(responses_chunk, "item_id", None), + object="content", + delta={"text": delta_text}, + ) + + # Handle ResponseCreatedEvent or ResponseInProgressEvent -> interaction.start + if isinstance(responses_chunk, (ResponseCreatedEvent, ResponseInProgressEvent)): + if not self.sent_interaction_start: + self.sent_interaction_start = True + response_id = getattr(responses_chunk.response, "id", None) if hasattr(responses_chunk, "response") else None + return InteractionsAPIStreamingResponse( + event_type="interaction.start", + id=response_id or f"interaction_{id(self)}", + object="interaction", + status="in_progress", + model=self.model, + ) + + # Handle ResponseCompletedEvent -> interaction.complete + if isinstance(responses_chunk, ResponseCompletedEvent): + self.finished = True + response = responses_chunk.response + + # Send content.stop first if content was started + if self.sent_content_start: + # Note: We'll send this in the iterator, not here + pass + + # Send interaction.complete + return InteractionsAPIStreamingResponse( + event_type="interaction.complete", + id=getattr(response, "id", None) or f"interaction_{id(self)}", + object="interaction", + status="completed", + model=self.model, + outputs=[ + { + "type": "text", + "text": self.collected_text, + } + ], + ) + + # For other event types, return None (skip) + return None + + def __iter__(self) -> Iterator[InteractionsAPIStreamingResponse]: + """Sync iterator implementation.""" + return self + + def __next__(self) -> InteractionsAPIStreamingResponse: + """Get next chunk in sync mode.""" + if self.finished: + raise StopIteration + + # Check if we have a pending interaction.complete to send + if hasattr(self, "_pending_interaction_complete"): + pending: InteractionsAPIStreamingResponse = getattr(self, "_pending_interaction_complete") + delattr(self, "_pending_interaction_complete") + return pending + + # Use a loop instead of recursion to avoid stack overflow + sync_iterator = cast(SyncResponsesAPIStreamingIterator, self.responses_stream_iterator) + while True: + try: + # Get next chunk from responses API stream + chunk = next(sync_iterator) + + # Transform chunk (chunk is already a ResponsesAPIStreamingResponse) + transformed = self._transform_responses_chunk_to_interactions_chunk(chunk) + + if transformed: + # If we finished and content was started, send content.stop before interaction.complete + if self.finished and self.sent_content_start and transformed.event_type == "interaction.complete": + # Send content.stop first + content_stop = InteractionsAPIStreamingResponse( + event_type="content.stop", + id=transformed.id, + object="content", + delta={"type": "text", "text": self.collected_text}, + ) + # Store the interaction.complete to send next + self._pending_interaction_complete = transformed + return content_stop + return transformed + + # If no transformation, continue to next chunk (loop continues) + + except StopIteration: + self.finished = True + + # Send final events if needed + if self.sent_content_start: + return InteractionsAPIStreamingResponse( + event_type="content.stop", + object="content", + delta={"type": "text", "text": self.collected_text}, + ) + + raise StopIteration + + def __aiter__(self) -> AsyncIterator[InteractionsAPIStreamingResponse]: + """Async iterator implementation.""" + return self + + async def __anext__(self) -> InteractionsAPIStreamingResponse: + """Get next chunk in async mode.""" + if self.finished: + raise StopAsyncIteration + + # Check if we have a pending interaction.complete to send + if hasattr(self, "_pending_interaction_complete"): + pending: InteractionsAPIStreamingResponse = getattr(self, "_pending_interaction_complete") + delattr(self, "_pending_interaction_complete") + return pending + + # Use a loop instead of recursion to avoid stack overflow + async_iterator = cast(ResponsesAPIStreamingIterator, self.responses_stream_iterator) + while True: + try: + # Get next chunk from responses API stream + chunk = await async_iterator.__anext__() + + # Transform chunk (chunk is already a ResponsesAPIStreamingResponse) + transformed = self._transform_responses_chunk_to_interactions_chunk(chunk) + + if transformed: + # If we finished and content was started, send content.stop before interaction.complete + if self.finished and self.sent_content_start and transformed.event_type == "interaction.complete": + # Send content.stop first + content_stop = InteractionsAPIStreamingResponse( + event_type="content.stop", + id=transformed.id, + object="content", + delta={"type": "text", "text": self.collected_text}, + ) + # Store the interaction.complete to send next + self._pending_interaction_complete = transformed + return content_stop + return transformed + + # If no transformation, continue to next chunk (loop continues) + + except StopAsyncIteration: + self.finished = True + + # Send final events if needed + if self.sent_content_start: + return InteractionsAPIStreamingResponse( + event_type="content.stop", + object="content", + delta={"type": "text", "text": self.collected_text}, + ) + + raise StopAsyncIteration + diff --git a/litellm/interactions/litellm_responses_transformation/transformation.py b/litellm/interactions/litellm_responses_transformation/transformation.py new file mode 100644 index 00000000000..24b2c5dbde7 --- /dev/null +++ b/litellm/interactions/litellm_responses_transformation/transformation.py @@ -0,0 +1,277 @@ +""" +Transformation utilities for bridging Interactions API to Responses API. + +This module handles transforming between: +- Interactions API format (Google's format with Turn[], system_instruction, etc.) +- Responses API format (OpenAI's format with input[], instructions, etc.) +""" + +from typing import Any, Dict, List, Optional, cast + +from litellm.types.interactions import ( + InteractionInput, + InteractionsAPIOptionalRequestParams, + InteractionsAPIResponse, + Turn, +) +from litellm.types.llms.openai import ( + ResponseInputParam, + ResponsesAPIResponse, +) + + +class LiteLLMResponsesInteractionsConfig: + """Configuration class for transforming between Interactions API and Responses API.""" + + @staticmethod + def transform_interactions_request_to_responses_request( + model: str, + input: Optional[InteractionInput], + optional_params: InteractionsAPIOptionalRequestParams, + **kwargs, + ) -> Dict[str, Any]: + """ + Transform an Interactions API request to a Responses API request. + + Key transformations: + - system_instruction -> instructions + - input (string | Turn[]) -> input (ResponseInputParam) + - tools -> tools (similar format) + - generation_config -> temperature, top_p, etc. + """ + responses_request: Dict[str, Any] = { + "model": model, + } + + # Transform input + if input is not None: + responses_request["input"] = ( + LiteLLMResponsesInteractionsConfig._transform_interactions_input_to_responses_input( + input + ) + ) + + # Transform system_instruction -> instructions + if optional_params.get("system_instruction"): + responses_request["instructions"] = optional_params["system_instruction"] + + # Transform tools (similar format, pass through for now) + if optional_params.get("tools"): + responses_request["tools"] = optional_params["tools"] + + # Transform generation_config to temperature, top_p, etc. + generation_config = optional_params.get("generation_config") + if generation_config: + if isinstance(generation_config, dict): + if "temperature" in generation_config: + responses_request["temperature"] = generation_config["temperature"] + if "top_p" in generation_config: + responses_request["top_p"] = generation_config["top_p"] + if "top_k" in generation_config: + # Responses API doesn't have top_k, skip it + pass + if "max_output_tokens" in generation_config: + responses_request["max_output_tokens"] = generation_config["max_output_tokens"] + + # Pass through other optional params that match + passthrough_params = ["stream", "store", "metadata", "user"] + for param in passthrough_params: + if param in optional_params and optional_params[param] is not None: + responses_request[param] = optional_params[param] + + # Add any extra kwargs + responses_request.update(kwargs) + + return responses_request + + @staticmethod + def _transform_interactions_input_to_responses_input( + input: InteractionInput, + ) -> ResponseInputParam: + """ + Transform Interactions API input to Responses API input format. + + Interactions API input can be: + - string: "Hello" + - Turn[]: [{"role": "user", "content": [...]}] + - Content object + + Responses API input is: + - string: "Hello" + - Message[]: [{"role": "user", "content": [...]}] + """ + if isinstance(input, str): + # ResponseInputParam accepts str + return cast(ResponseInputParam, input) + + if isinstance(input, list): + # Turn[] format - convert to Responses API Message[] format + messages = [] + for turn in input: + if isinstance(turn, dict): + role = turn.get("role", "user") + content = turn.get("content", []) + + # Transform content array + transformed_content = ( + LiteLLMResponsesInteractionsConfig._transform_content_array(content) + ) + + messages.append({ + "role": role, + "content": transformed_content, + }) + elif isinstance(turn, Turn): + # Pydantic model + role = turn.role if hasattr(turn, "role") else "user" + content = turn.content if hasattr(turn, "content") else [] + + # Ensure content is a list for _transform_content_array + # Cast to List[Any] to handle various content types + if isinstance(content, list): + content_list: List[Any] = list(content) + elif content is not None: + content_list = [content] + else: + content_list = [] + + transformed_content = ( + LiteLLMResponsesInteractionsConfig._transform_content_array(content_list) + ) + + messages.append({ + "role": role, + "content": transformed_content, + }) + + return cast(ResponseInputParam, messages) + + # Single content object - wrap in message + if isinstance(input, dict): + return cast(ResponseInputParam, [{ + "role": "user", + "content": LiteLLMResponsesInteractionsConfig._transform_content_array( + input.get("content", []) if isinstance(input.get("content"), list) else [input] + ), + }]) + + # Fallback: convert to string + return cast(ResponseInputParam, str(input)) + + @staticmethod + def _transform_content_array(content: List[Any]) -> List[Dict[str, Any]]: + """Transform Interactions API content array to Responses API format.""" + if not isinstance(content, list): + # Single content item - wrap in array + content = [content] + + transformed: List[Dict[str, Any]] = [] + for item in content: + if isinstance(item, dict): + # Already in dict format, pass through + transformed.append(item) + elif isinstance(item, str): + # Plain string - wrap in text format + transformed.append({"type": "text", "text": item}) + else: + # Pydantic model or other - convert to dict + if hasattr(item, "model_dump"): + dumped = item.model_dump() + if isinstance(dumped, dict): + transformed.append(dumped) + else: + # Fallback: wrap in text format + transformed.append({"type": "text", "text": str(dumped)}) + elif hasattr(item, "dict"): + dumped = item.dict() + if isinstance(dumped, dict): + transformed.append(dumped) + else: + # Fallback: wrap in text format + transformed.append({"type": "text", "text": str(dumped)}) + else: + # Fallback: wrap in text format + transformed.append({"type": "text", "text": str(item)}) + + return transformed + + @staticmethod + def transform_responses_response_to_interactions_response( + responses_response: ResponsesAPIResponse, + model: Optional[str] = None, + ) -> InteractionsAPIResponse: + """ + Transform a Responses API response to an Interactions API response. + + Key transformations: + - Extract text from output[].content[].text + - Convert created_at (int) to created (ISO string) + - Map status + - Extract usage + """ + # Extract text from outputs + outputs = [] + if hasattr(responses_response, "output") and responses_response.output: + for output_item in responses_response.output: + # Use getattr with None default to safely access content + content = getattr(output_item, "content", None) + if content is not None: + content_items = content if isinstance(content, list) else [content] + for content_item in content_items: + # Check if content_item has text attribute + text = getattr(content_item, "text", None) + if text is not None: + outputs.append({ + "type": "text", + "text": text, + }) + elif isinstance(content_item, dict) and content_item.get("type") == "text": + outputs.append(content_item) + + # Convert created_at to ISO string + created_at = getattr(responses_response, "created_at", None) + if isinstance(created_at, int): + from datetime import datetime + created = datetime.fromtimestamp(created_at).isoformat() + elif created_at is not None and hasattr(created_at, "isoformat"): + created = created_at.isoformat() + else: + created = None + + # Map status + status = getattr(responses_response, "status", "completed") + if status == "completed": + interactions_status = "completed" + elif status == "in_progress": + interactions_status = "in_progress" + else: + interactions_status = status + + # Build interactions response + interactions_response_dict: Dict[str, Any] = { + "id": getattr(responses_response, "id", ""), + "object": "interaction", + "status": interactions_status, + "outputs": outputs, + "model": model or getattr(responses_response, "model", ""), + "created": created, + } + + # Add usage if available + # Map Responses API usage (input_tokens, output_tokens) to Interactions API spec format + # (total_input_tokens, total_output_tokens) + usage = getattr(responses_response, "usage", None) + if usage: + interactions_response_dict["usage"] = { + "total_input_tokens": getattr(usage, "input_tokens", 0), + "total_output_tokens": getattr(usage, "output_tokens", 0), + } + + # Add role + interactions_response_dict["role"] = "model" + + # Add updated (same as created for now) + interactions_response_dict["updated"] = created + + return InteractionsAPIResponse(**interactions_response_dict) + diff --git a/litellm/interactions/main.py b/litellm/interactions/main.py index 9fb58fc73d6..fb811b25b2f 100644 --- a/litellm/interactions/main.py +++ b/litellm/interactions/main.py @@ -272,18 +272,30 @@ def create( model=model, ) - if interactions_api_config is None: - raise ValueError( - f"Interactions API is not supported for provider: {custom_llm_provider}. " - "Currently only 'gemini' is supported." - ) - # Get optional params using utility (similar to responses API pattern) local_vars.update(kwargs) optional_params = InteractionsAPIRequestUtils.get_requested_interactions_api_optional_params( local_vars ) + # Check if this is a bridge provider (litellm_responses) - similar to responses API + # Either provider is explicitly "litellm_responses" or no config found (bridge to responses) + if custom_llm_provider == "litellm_responses" or interactions_api_config is None: + # Bridge to litellm.responses() for non-native providers + from litellm.interactions.litellm_responses_transformation.handler import ( + LiteLLMResponsesInteractionsHandler, + ) + handler = LiteLLMResponsesInteractionsHandler() + return handler.interactions_api_handler( + model=model or "", + input=input, + optional_params=optional_params, + custom_llm_provider=custom_llm_provider, + _is_async=_is_async, + stream=stream, + **kwargs, + ) + litellm_logging_obj.update_environment_variables( model=model, optional_params=dict(optional_params), diff --git a/litellm/litellm_core_utils/default_encoding.py b/litellm/litellm_core_utils/default_encoding.py index 93b3132912c..41bfcbb63f4 100644 --- a/litellm/litellm_core_utils/default_encoding.py +++ b/litellm/litellm_core_utils/default_encoding.py @@ -19,5 +19,22 @@ os.environ["TIKTOKEN_CACHE_DIR"] = os.getenv( "CUSTOM_TIKTOKEN_CACHE_DIR", filename ) # use local copy of tiktoken b/c of - https://github.com/BerriAI/litellm/issues/1071 import tiktoken +import time +import random -encoding = tiktoken.get_encoding("cl100k_base") +# Retry logic to handle race conditions when multiple processes try to create +# the tiktoken cache file simultaneously (common in parallel test execution on Windows) +_max_retries = 5 +_retry_delay = 0.1 # Start with 100ms + +for attempt in range(_max_retries): + try: + encoding = tiktoken.get_encoding("cl100k_base") + break + except (FileExistsError, OSError): + if attempt == _max_retries - 1: + # Last attempt, re-raise the exception + raise + # Exponential backoff with jitter to reduce collision probability + delay = _retry_delay * (2 ** attempt) + random.uniform(0, 0.1) + time.sleep(delay) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 7bf95ca3404..1517d1e776d 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -78,9 +78,7 @@ class ExceptionCheckers: "is longer than the model's context length", "input tokens exceed the configured limit", "`inputs` tokens + `max_new_tokens` must be", - # Gemini pattern: "The input token count exceeds the maximum number of tokens allowed" - # See: https://github.com/BerriAI/litellm/issues/XXXX - "input token count exceeds the maximum number of tokens allowed", + "exceeds the maximum number of tokens allowed", # Gemini ] for substring in known_exception_substrings: if substring in _error_str_lowercase: @@ -1262,6 +1260,14 @@ def exception_type( # type: ignore # noqa: PLR0915 model=model, llm_provider=custom_llm_provider, ) + elif ExceptionCheckers.is_error_str_context_window_exceeded(error_str): + exception_mapping_worked = True + raise ContextWindowExceededError( + message=f"ContextWindowExceededError: {custom_llm_provider.capitalize()}Exception - {error_str}", + model=model, + llm_provider=custom_llm_provider, + litellm_debug_info=extra_information, + ) elif ( "None Unknown Error." in error_str or "Content has no parts." in error_str diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index a23fce891b9..164e2a73e65 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -4,8 +4,8 @@ import httpx import litellm from litellm.constants import REPLICATE_MODEL_NAME_WITH_ID_LENGTH -from litellm.secret_managers.main import get_secret, get_secret_str from litellm.llms.openai_like.json_loader import JSONProviderRegistry +from litellm.secret_managers.main import get_secret, get_secret_str from ..types.router import LiteLLM_Params @@ -267,9 +267,30 @@ def get_llm_provider( # noqa: PLR0915 elif endpoint == "api.moonshot.ai/v1": custom_llm_provider = "moonshot" dynamic_api_key = get_secret_str("MOONSHOT_API_KEY") + elif endpoint == "api.minimax.io/anthropic" or endpoint == "api.minimaxi.com/anthropic": + custom_llm_provider = "minimax" + dynamic_api_key = get_secret_str("MINIMAX_API_KEY") + elif endpoint == "api.minimax.io/v1" or endpoint == "api.minimaxi.com/v1": + custom_llm_provider = "minimax" + dynamic_api_key = get_secret_str("MINIMAX_API_KEY") elif endpoint == "platform.publicai.co/v1": custom_llm_provider = "publicai" dynamic_api_key = get_secret_str("PUBLICAI_API_KEY") + elif endpoint == "https://api.synthetic.new/openai/v1": + custom_llm_provider = "synthetic" + dynamic_api_key = get_secret_str("SYNTHETIC_API_KEY") + elif endpoint == "https://api.stima.tech/v1": + custom_llm_provider = "apertis" + dynamic_api_key = get_secret_str("STIMA_API_KEY") + elif endpoint == "https://nano-gpt.com/api/v1": + custom_llm_provider = "nano-gpt" + dynamic_api_key = get_secret_str("NANOGPT_API_KEY") + elif endpoint == "https://api.poe.com/v1": + custom_llm_provider = "poe" + dynamic_api_key = get_secret_str("POE_API_KEY") + elif endpoint == "https://llm.chutes.ai/v1/": + custom_llm_provider = "chutes" + dynamic_api_key = get_secret_str("CHUTES_API_KEY") elif endpoint == "https://api.v0.dev/v1": custom_llm_provider = "v0" dynamic_api_key = get_secret_str("V0_API_KEY") diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index aa75b811692..550b92568dd 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -1291,6 +1291,9 @@ class Logging(LiteLLMLoggingBaseClass): original_cost: Optional[float] = None, discount_percent: Optional[float] = None, discount_amount: Optional[float] = None, + margin_percent: Optional[float] = None, + margin_fixed_amount: Optional[float] = None, + margin_total_amount: Optional[float] = None, ) -> None: """ Helper method to store cost breakdown in the logging object. @@ -1303,6 +1306,9 @@ class Logging(LiteLLMLoggingBaseClass): original_cost: Cost before discount discount_percent: Discount percentage (0.05 = 5%) discount_amount: Discount amount in USD + margin_percent: Margin percentage applied (0.10 = 10%) + margin_fixed_amount: Fixed margin amount in USD + margin_total_amount: Total margin added in USD """ self.cost_breakdown = CostBreakdown( @@ -1320,6 +1326,14 @@ class Logging(LiteLLMLoggingBaseClass): if discount_amount is not None: self.cost_breakdown["discount_amount"] = discount_amount + # Store margin information if provided + if margin_percent is not None: + self.cost_breakdown["margin_percent"] = margin_percent + if margin_fixed_amount is not None: + self.cost_breakdown["margin_fixed_amount"] = margin_fixed_amount + if margin_total_amount is not None: + self.cost_breakdown["margin_total_amount"] = margin_total_amount + def _response_cost_calculator( self, result: Union[ diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 9cfbf1b6d8d..8868fabdcef 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -740,9 +740,7 @@ class LiteLLMAnthropicMessagesAdapter: from litellm.types.llms.anthropic import TextBlock, ToolUseBlock for choice in choices: - if choice.delta.content is not None and len(choice.delta.content) > 0: - return "text", TextBlock(type="text", text="") - elif ( + if ( choice.delta.tool_calls is not None and len(choice.delta.tool_calls) > 0 and choice.delta.tool_calls[0].function is not None @@ -753,6 +751,8 @@ class LiteLLMAnthropicMessagesAdapter: name=choice.delta.tool_calls[0].function.name or "", input={}, # type: ignore[typeddict-item] ) + elif choice.delta.content is not None and len(choice.delta.content) > 0: + return "text", TextBlock(type="text", text="") elif isinstance(choice, StreamingChoices) and hasattr( choice.delta, "thinking_blocks" ): @@ -796,7 +796,7 @@ class LiteLLMAnthropicMessagesAdapter: for choice in choices: if choice.delta.content is not None and len(choice.delta.content) > 0: text += choice.delta.content - elif choice.delta.tool_calls is not None: + if choice.delta.tool_calls is not None: partial_json = "" for tool in choice.delta.tool_calls: if ( diff --git a/litellm/llms/bedrock/image_generation/image_handler.py b/litellm/llms/bedrock/image_generation/image_handler.py index 0a4cde90b27..7270b96ab88 100644 --- a/litellm/llms/bedrock/image_generation/image_handler.py +++ b/litellm/llms/bedrock/image_generation/image_handler.py @@ -12,6 +12,9 @@ from litellm.litellm_core_utils.litellm_logging import Logging as LitellmLogging from litellm.llms.bedrock.image_generation.amazon_nova_canvas_transformation import ( AmazonNovaCanvasConfig, ) +from litellm.llms.bedrock.image_generation.amazon_stability1_transformation import ( + AmazonStabilityConfig, +) from litellm.llms.bedrock.image_generation.amazon_stability3_transformation import ( AmazonStability3Config, ) @@ -50,7 +53,7 @@ BedrockImageConfigClass = Union[ type[AmazonTitanImageGenerationConfig], type[AmazonNovaCanvasConfig], type[AmazonStability3Config], - type[litellm.AmazonStabilityConfig], + type[AmazonStabilityConfig], ] diff --git a/litellm/llms/databricks/chat/transformation.py b/litellm/llms/databricks/chat/transformation.py index ac3be0c3518..2b7f5dd5995 100644 --- a/litellm/llms/databricks/chat/transformation.py +++ b/litellm/llms/databricks/chat/transformation.py @@ -2,6 +2,7 @@ Translates from OpenAI's `/v1/chat/completions` to Databricks' `/chat/completions` """ +import os from typing import ( TYPE_CHECKING, Any, @@ -26,7 +27,7 @@ from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response impo _should_convert_tool_call_to_json_mode, ) from litellm.litellm_core_utils.prompt_templates.common_utils import ( - strip_name_from_message + strip_name_from_message, ) from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator from litellm.types.llms.anthropic import AllAnthropicToolsValues @@ -124,12 +125,24 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: + # Check for custom user agent in optional_params or environment + # This allows partners building on LiteLLM to set their own telemetry + # Use pop() to remove these keys so they don't get sent to the API + custom_user_agent = ( + optional_params.pop("user_agent", None) + or optional_params.pop("databricks_user_agent", None) + or litellm_params.get("user_agent") + or os.getenv("LITELLM_USER_AGENT") + or os.getenv("DATABRICKS_USER_AGENT") + ) + api_base, headers = self.databricks_validate_environment( api_base=api_base, api_key=api_key, endpoint_type="chat_completions", custom_endpoint=False, headers=headers, + custom_user_agent=custom_user_agent, ) # Ensure Content-Type header is set headers["Content-Type"] = "application/json" @@ -173,9 +186,9 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): # Build DatabricksFunction explicitly to avoid parameter conflicts function_params: DatabricksFunction = { "name": tool["name"], - "parameters": cast(dict, tool.get("input_schema") or {}) + "parameters": cast(dict, tool.get("input_schema") or {}), } - + # Only add description if it exists description = tool.get("description") if description is not None: @@ -229,7 +242,7 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): Databricks supports Anthropic-style cache control for Claude models. Databricks ignores the cache_control flag with other models. """ - # TODO: Think about how to best design the request transformation so that + # TODO: Think about how to best design the request transformation so that # every request doesn't have to be transformed for to OpenAI and Anthropic request formats. return messages, tools @@ -347,15 +360,17 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): messages=new_messages, model=model, is_async=cast(Literal[False], False) ) - def _move_cache_control_into_string_content_block(self, message: AllMessageValues) -> AllMessageValues: + def _move_cache_control_into_string_content_block( + self, message: AllMessageValues + ) -> AllMessageValues: """ Moves message-level cache_control into a content block when content is a string. - + Transforms: {"role": "user", "content": "text", "cache_control": {...}} Into: {"role": "user", "content": [{"type": "text", "text": "text", "cache_control": {...}}]} - + This is required for Anthropic's prompt caching API when cache_control is specified at the message level but content is a simple string (not already an array of content blocks). """ @@ -371,7 +386,6 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): } ] return cast(AllMessageValues, transformed_message) - @staticmethod def extract_content_str( @@ -509,9 +523,9 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): reasoning_content=reasoning_content, thinking_blocks=thinking_blocks, tool_calls=choice["message"].get("tool_calls"), - provider_specific_fields={"citations": citations} - if citations is not None - else None, + provider_specific_fields=( + {"citations": citations} if citations is not None else None + ), ) if finish_reason is None: @@ -543,12 +557,15 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): api_key: Optional[str] = None, json_mode: Optional[bool] = None, ) -> ModelResponse: - ## LOGGING + # Redact sensitive data before logging to prevent credential leakage + redacted_request_data = self.redact_sensitive_data(request_data) + + ## LOGGING - Never log actual API keys logging_obj.post_call( input=messages, - api_key=api_key, + api_key="[REDACTED]", original_response=raw_response.text, - additional_args={"complete_input_dict": request_data}, + additional_args={"complete_input_dict": redacted_request_data}, ) ## RESPONSE OBJECT diff --git a/litellm/llms/databricks/common_utils.py b/litellm/llms/databricks/common_utils.py index 1353b5b13f6..608f29a03a7 100644 --- a/litellm/llms/databricks/common_utils.py +++ b/litellm/llms/databricks/common_utils.py @@ -1,4 +1,18 @@ -from typing import Literal, Optional, Tuple +""" +Databricks integration utilities for LiteLLM. + +This module provides authentication, telemetry, and security utilities +for the Databricks LLM provider integration. + +Authentication priority: +1. OAuth M2M (DATABRICKS_CLIENT_ID + DATABRICKS_CLIENT_SECRET) - Recommended for production +2. PAT (DATABRICKS_API_KEY) - Supported for development +3. Databricks SDK automatic auth - Fallback (uses unified auth) +""" + +import os +import re +from typing import Any, Dict, Literal, Optional, Tuple from litellm.llms.base_llm.chat.transformation import BaseLLMException @@ -8,17 +22,175 @@ class DatabricksException(BaseLLMException): class DatabricksBase: + """ + Base class for Databricks integration with authentication, + telemetry, and security utilities. + """ + + # Patterns to redact in logs + SENSITIVE_PATTERNS = [ + (re.compile(r"(Bearer\s+)[A-Za-z0-9\-_\.]+", re.IGNORECASE), r"\1[REDACTED]"), + (re.compile(r"(Authorization:\s*)[^\s,}]+", re.IGNORECASE), r"\1[REDACTED]"), + ( + re.compile(r'(api[_-]?key["\s:=]+)[^\s,}"\']+', re.IGNORECASE), + r"\1[REDACTED]", + ), + ( + re.compile(r'(client[_-]?secret["\s:=]+)[^\s,}"\']+', re.IGNORECASE), + r"\1[REDACTED]", + ), + (re.compile(r"(dapi[a-zA-Z0-9]{32,})", re.IGNORECASE), r"[REDACTED_PAT]"), + ( + re.compile(r'(access[_-]?token["\s:=]+)[^\s,}"\']+', re.IGNORECASE), + r"\1[REDACTED]", + ), + ] + + @classmethod + def redact_sensitive_data(cls, data: Any) -> Any: + """ + Redact sensitive information (tokens, secrets) from data before logging. + + Handles strings, dicts, and lists recursively. Keys containing sensitive + terms (authorization, api_key, token, secret, password, credential) are + fully redacted. + + Args: + data: String, dict, or other data structure to redact + + Returns: + Redacted version of the data safe for logging + """ + if data is None: + return None + + if isinstance(data, str): + result = data + for pattern, replacement in cls.SENSITIVE_PATTERNS: + result = pattern.sub(replacement, result) + return result + + if isinstance(data, dict): + redacted = {} + for key, value in data.items(): + lower_key = key.lower() + if any( + sensitive in lower_key + for sensitive in [ + "authorization", + "api_key", + "apikey", + "token", + "secret", + "password", + "credential", + ] + ): + redacted[key] = "[REDACTED]" + else: + redacted[key] = cls.redact_sensitive_data(value) + return redacted + + if isinstance(data, list): + return [cls.redact_sensitive_data(item) for item in data] + + return data + + @classmethod + def redact_headers_for_logging(cls, headers: Dict[str, str]) -> Dict[str, str]: + """ + Create a copy of headers with sensitive values redacted for safe logging. + + Shows first 8 characters of sensitive values for debugging purposes, + with the rest redacted. + + Args: + headers: HTTP headers dictionary + + Returns: + New dictionary with sensitive headers redacted + """ + if not headers: + return {} + + redacted = {} + sensitive_headers = { + "authorization", + "x-api-key", + "api-key", + "x-databricks-token", + } + + for key, value in headers.items(): + if key.lower() in sensitive_headers: + if len(value) > 10: + redacted[key] = f"{value[:8]}...[REDACTED]" + else: + redacted[key] = "[REDACTED]" + else: + redacted[key] = value + + return redacted + + @staticmethod + def _build_user_agent(custom_user_agent: Optional[str] = None) -> str: + """ + Build the User-Agent string for Databricks API calls. + + If a custom user agent is provided, the partner name (part before /) + is extracted and prefixed to the litellm user agent with an underscore. + The custom version is ignored; LiteLLM's version is always used. + + Args: + custom_user_agent: Optional custom user agent string (e.g., "mycompany/1.0.0") + + Returns: + User-Agent string in format: + - Default: "litellm/{version}" + - With custom: "{partner}_litellm/{version}" + + Examples: + - None -> "litellm/1.79.1" + - "mycompany/1.0.0" -> "mycompany_litellm/1.79.1" + - "partner_product/2.0.0" -> "partner_product_litellm/1.79.1" + - "acme" -> "acme_litellm/1.79.1" + """ + try: + from litellm._version import version + except Exception: + version = "0.0.0" + + if custom_user_agent: + custom_user_agent = custom_user_agent.strip() + + # Extract partner name (part before / if present) + if "/" in custom_user_agent: + partner_name = custom_user_agent.split("/")[0].strip() + else: + partner_name = custom_user_agent + + # Validate partner name: alphanumeric, underscore, hyphen only + if ( + partner_name + and partner_name.replace("_", "").replace("-", "").isalnum() + ): + return f"{partner_name}_litellm/{version}" + + # Default: just litellm + return f"litellm/{version}" + def _get_api_base(self, api_base: Optional[str]) -> str: + """ + Get the Databricks API base URL. + + If not provided, attempts to get it from the Databricks SDK. + """ if api_base is None: try: from databricks.sdk import WorkspaceClient databricks_client = WorkspaceClient() - - api_base = ( - api_base or f"{databricks_client.config.host}/serving-endpoints" - ) - + api_base = f"{databricks_client.config.host}/serving-endpoints" return api_base except ImportError: raise DatabricksException( @@ -30,12 +202,87 @@ class DatabricksBase: ) return api_base + def _get_oauth_m2m_token( + self, + api_base: str, + client_id: str, + client_secret: str, + ) -> str: + """ + Obtain an OAuth M2M access token using client credentials flow. + + This is the recommended authentication method for production integrations + per Databricks Partner requirements. + + Args: + api_base: Databricks workspace URL + client_id: OAuth client ID (Service Principal application ID) + client_secret: OAuth client secret + + Returns: + Access token string + + Raises: + DatabricksException: If token request fails + """ + import requests + + # Extract workspace URL from api_base + workspace_url = api_base.rstrip("/") + if "/serving-endpoints" in workspace_url: + workspace_url = workspace_url.replace("/serving-endpoints", "") + + token_url = f"{workspace_url}/oidc/v1/token" + + try: + response = requests.post( + token_url, + data={ + "grant_type": "client_credentials", + "scope": "all-apis", + }, + auth=(client_id, client_secret), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=30, + ) + except requests.RequestException as e: + raise DatabricksException( + status_code=500, + message=f"OAuth M2M token request failed: {str(e)}", + ) + + if response.status_code != 200: + raise DatabricksException( + status_code=response.status_code, + message=f"OAuth M2M token request failed: {response.text}", + ) + + token_data = response.json() + return token_data["access_token"] + def _get_databricks_credentials( self, api_key: Optional[str], api_base: Optional[str], headers: Optional[dict] ) -> Tuple[str, dict]: + """ + Get Databricks credentials using the Databricks SDK. + + Also registers LiteLLM as a partner for proper telemetry attribution + in Databricks system.access.audit table. + + Args: + api_key: Optional API key (PAT) + api_base: Optional API base URL + headers: Optional existing headers + + Returns: + Tuple of (api_base, headers) + """ headers = headers or {"Content-Type": "application/json"} try: - from databricks.sdk import WorkspaceClient + from databricks.sdk import WorkspaceClient, useragent + + # Register LiteLLM as partner for Databricks telemetry attribution + useragent.with_partner("litellm") databricks_client = WorkspaceClient() @@ -66,14 +313,53 @@ class DatabricksBase: endpoint_type: Literal["chat_completions", "embeddings"], custom_endpoint: Optional[bool], headers: Optional[dict], + custom_user_agent: Optional[str] = None, ) -> Tuple[str, dict]: - if api_key is None and not headers: # handle empty headers + """ + Validate and configure the Databricks environment. + + Authentication priority: + 1. OAuth M2M (DATABRICKS_CLIENT_ID + DATABRICKS_CLIENT_SECRET) - Recommended + 2. PAT (DATABRICKS_API_KEY) - Supported for development + 3. Databricks SDK automatic auth - Fallback (uses unified auth) + + Args: + api_key: Personal access token (PAT) + api_base: Databricks workspace URL with /serving-endpoints + endpoint_type: Type of endpoint (chat_completions or embeddings) + custom_endpoint: Whether using a custom endpoint URL + headers: Existing headers dict + custom_user_agent: Optional custom user agent to prefix + + Returns: + Tuple of (api_base, headers) with authentication configured + """ + from litellm._logging import verbose_logger + + # Check for OAuth M2M credentials (recommended for production) + client_id = os.getenv("DATABRICKS_CLIENT_ID") + client_secret = os.getenv("DATABRICKS_CLIENT_SECRET") + + # Determine api_base first + if api_base is None: + api_base = os.getenv("DATABRICKS_API_BASE") + + if client_id and client_secret and api_base: + # Use OAuth M2M flow (preferred for production) + verbose_logger.debug("Using OAuth M2M authentication for Databricks") + access_token = self._get_oauth_m2m_token(api_base, client_id, client_secret) + headers = headers or {} + headers["Authorization"] = f"Bearer {access_token}" + headers["Content-Type"] = "application/json" + elif api_key is None and not headers: if custom_endpoint is True: raise DatabricksException( status_code=400, message="Missing API Key - A call is being made to LLM Provider but no key is set either in the environment variables ({LLM_PROVIDER}_API_KEY) or via params", ) else: + # Fallback to Databricks SDK (registers partner telemetry) + verbose_logger.debug("Using Databricks SDK for authentication") api_base, headers = self._get_databricks_credentials( api_base=api_base, api_key=api_key, headers=headers ) @@ -101,8 +387,17 @@ class DatabricksBase: if api_key is not None: headers["Authorization"] = f"Bearer {api_key}" + # Set User-Agent with optional custom prefix + headers["User-Agent"] = self._build_user_agent(custom_user_agent) + + # Debug logging with redaction (never log actual tokens) + verbose_logger.debug( + f"Databricks request headers: {self.redact_headers_for_logging(headers)}" + ) + if endpoint_type == "chat_completions" and custom_endpoint is not True: api_base = "{}/chat/completions".format(api_base) elif endpoint_type == "embeddings" and custom_endpoint is not True: api_base = "{}/embeddings".format(api_base) + return api_base, headers diff --git a/litellm/llms/databricks/embed/handler.py b/litellm/llms/databricks/embed/handler.py index 2eabcdbc866..227824f72d0 100644 --- a/litellm/llms/databricks/embed/handler.py +++ b/litellm/llms/databricks/embed/handler.py @@ -2,6 +2,7 @@ Calling logic for Databricks embeddings """ +import os from typing import Optional from litellm.utils import EmbeddingResponse @@ -26,12 +27,23 @@ class DatabricksEmbeddingHandler(OpenAILikeEmbeddingHandler, DatabricksBase): custom_endpoint: Optional[bool] = None, headers: Optional[dict] = None, ) -> EmbeddingResponse: + # Check for custom user agent in optional_params or environment + # This allows partners building on LiteLLM to set their own telemetry + # Use pop() to remove these keys so they don't get sent to the API + custom_user_agent = ( + optional_params.pop("user_agent", None) + or optional_params.pop("databricks_user_agent", None) + or os.getenv("LITELLM_USER_AGENT") + or os.getenv("DATABRICKS_USER_AGENT") + ) + api_base, headers = self.databricks_validate_environment( api_base=api_base, api_key=api_key, endpoint_type="embeddings", custom_endpoint=custom_endpoint, headers=headers, + custom_user_agent=custom_user_agent, ) return super().embedding( model=model, diff --git a/litellm/llms/minimax/__init__.py b/litellm/llms/minimax/__init__.py new file mode 100644 index 00000000000..19093c2dadb --- /dev/null +++ b/litellm/llms/minimax/__init__.py @@ -0,0 +1,14 @@ +""" +MiniMax LLM Provider +""" + +from .text_to_speech.transformation import ( + MinimaxException, + MinimaxTextToSpeechConfig, +) + +__all__ = [ + "MinimaxTextToSpeechConfig", + "MinimaxException", +] + diff --git a/litellm/llms/minimax/chat/__init__.py b/litellm/llms/minimax/chat/__init__.py new file mode 100644 index 00000000000..45bcfd03b49 --- /dev/null +++ b/litellm/llms/minimax/chat/__init__.py @@ -0,0 +1,4 @@ +""" +MiniMax OpenAI-compatible chat API +""" + diff --git a/litellm/llms/minimax/chat/transformation.py b/litellm/llms/minimax/chat/transformation.py new file mode 100644 index 00000000000..ed80ff8aed1 --- /dev/null +++ b/litellm/llms/minimax/chat/transformation.py @@ -0,0 +1,83 @@ +""" +MiniMax OpenAI transformation config - extends OpenAI chat config for MiniMax's OpenAI-compatible API +""" +from typing import Optional + +import litellm +from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig +from litellm.secret_managers.main import get_secret_str + + +class MinimaxChatConfig(OpenAIGPTConfig): + """ + MiniMax OpenAI configuration that extends OpenAIGPTConfig. + MiniMax provides an OpenAI-compatible API at: + - International: https://api.minimax.io/v1 + - China: https://api.minimaxi.com/v1 + + Supported models: + - MiniMax-M2.1 + - MiniMax-M2.1-lightning + - MiniMax-M2 + """ + + @staticmethod + def get_api_key(api_key: Optional[str] = None) -> Optional[str]: + """ + Get MiniMax API key from environment or parameters. + """ + return ( + api_key + or get_secret_str("MINIMAX_API_KEY") + or litellm.api_key + ) + + @staticmethod + def get_api_base( + api_base: Optional[str] = None, + ) -> str: + """ + Get MiniMax API base URL. + Defaults to international endpoint: https://api.minimax.io/v1 + For China, set to: https://api.minimaxi.com/v1 + """ + return ( + api_base + or get_secret_str("MINIMAX_API_BASE") + or "https://api.minimax.io/v1" + ) + + def get_complete_url( + self, + api_base: Optional[str], + api_key: Optional[str], + model: str, + optional_params: dict, + litellm_params: dict, + stream: Optional[bool] = None, + ) -> str: + """ + Get the complete URL for MiniMax OpenAI API. + Override to ensure we use MiniMax's endpoint. + """ + # Get the base URL (either provided or default MiniMax endpoint) + base_url = self.get_api_base(api_base=api_base) + + # Ensure it ends with /chat/completions + if base_url.endswith("/chat/completions"): + return base_url + elif base_url.endswith("/v1"): + return f"{base_url}/chat/completions" + elif base_url.endswith("/"): + return f"{base_url}v1/chat/completions" + else: + return f"{base_url}/v1/chat/completions" + + def get_supported_openai_params(self, model: str) -> list: + """ + Get supported OpenAI parameters for MiniMax. + Adds reasoning_split to the list of supported params. + """ + base_params = super().get_supported_openai_params(model=model) + return base_params + ["reasoning_split"] + diff --git a/litellm/llms/minimax/messages/transformation.py b/litellm/llms/minimax/messages/transformation.py new file mode 100644 index 00000000000..27d28f02d83 --- /dev/null +++ b/litellm/llms/minimax/messages/transformation.py @@ -0,0 +1,81 @@ +""" +MiniMax Anthropic transformation config - extends AnthropicConfig for MiniMax's Anthropic-compatible API +""" +from typing import Optional + +import litellm +from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, +) +from litellm.secret_managers.main import get_secret_str + + +class MinimaxMessagesConfig(AnthropicMessagesConfig): + """ + MiniMax Anthropic configuration that extends AnthropicConfig. + MiniMax provides an Anthropic-compatible API at: + - International: https://api.minimax.io/anthropic + - China: https://api.minimaxi.com/anthropic + + Supported models: + - MiniMax-M2.1 + - MiniMax-M2.1-lightning + - MiniMax-M2 + """ + + @property + def custom_llm_provider(self) -> Optional[str]: + return "minimax" + + @staticmethod + def get_api_key(api_key: Optional[str] = None) -> Optional[str]: + """ + Get MiniMax API key from environment or parameters. + """ + return ( + api_key + or get_secret_str("MINIMAX_API_KEY") + or litellm.api_key + ) + + @staticmethod + def get_api_base( + api_base: Optional[str] = None, + ) -> str: + """ + Get MiniMax API base URL. + Defaults to international endpoint: https://api.minimax.io/anthropic + For China, set to: https://api.minimaxi.com/anthropic + """ + return ( + api_base + or get_secret_str("MINIMAX_API_BASE") + or "https://api.minimax.io/anthropic/v1/messages" + ) + + def get_complete_url( + self, + api_base: Optional[str], + api_key: Optional[str], + model: str, + optional_params: dict, + litellm_params: dict, + stream: Optional[bool] = None, + ) -> str: + """ + Get the complete URL for MiniMax API. + Override to ensure we use MiniMax's endpoint, not Anthropic's. + """ + # Get the base URL (either provided or default MiniMax endpoint) + base_url = self.get_api_base(api_base=api_base) + + # If the base URL already includes the full path, return it + if base_url.endswith("/v1/messages"): + return base_url + + # Otherwise append the messages endpoint + if base_url.endswith("/"): + return f"{base_url}v1/messages" + else: + return f"{base_url}/v1/messages" + diff --git a/litellm/llms/minimax/text_to_speech/__init__.py b/litellm/llms/minimax/text_to_speech/__init__.py new file mode 100644 index 00000000000..e3fcddeb05f --- /dev/null +++ b/litellm/llms/minimax/text_to_speech/__init__.py @@ -0,0 +1,8 @@ +""" +MiniMax Text-to-Speech module +""" + +from .transformation import MinimaxException, MinimaxTextToSpeechConfig + +__all__ = ["MinimaxTextToSpeechConfig", "MinimaxException"] + diff --git a/litellm/llms/minimax/text_to_speech/transformation.py b/litellm/llms/minimax/text_to_speech/transformation.py new file mode 100644 index 00000000000..a3a75d220ff --- /dev/null +++ b/litellm/llms/minimax/text_to_speech/transformation.py @@ -0,0 +1,421 @@ +""" +MiniMax Text-to-Speech transformation + +Maps OpenAI TTS spec to MiniMax TTS API (WebSocket-based HTTP API) +Reference: https://platform.minimax.io/docs +""" + +from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union + +import httpx +from httpx import Headers + +import litellm +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.llms.base_llm.text_to_speech.transformation import ( + BaseTextToSpeechConfig, + TextToSpeechRequestData, +) +from litellm.secret_managers.main import get_secret_str + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + from litellm.types.llms.openai import HttpxBinaryResponseContent +else: + LiteLLMLoggingObj = Any + HttpxBinaryResponseContent = Any + + +class MinimaxException(BaseLLMException): + """Custom exception for MiniMax API errors""" + + def __init__( + self, + status_code: int, + message: str, + headers: Optional[Union[dict, Headers]] = None, + ): + super().__init__(status_code=status_code, message=message, headers=headers) + + +class MinimaxTextToSpeechConfig(BaseTextToSpeechConfig): + """ + Configuration for MiniMax Text-to-Speech + + Reference: https://platform.minimax.io/docs + + MiniMax TTS API supports both WebSocket and HTTP endpoints. + This implementation uses the HTTP endpoint for simplicity. + """ + + TTS_BASE_URL = "https://api.minimax.io" + TTS_ENDPOINT_PATH = "/v1/t2a_v2" + + # Voice mappings from OpenAI-style voices to MiniMax voice IDs + # MiniMax supports many voices, these are common mappings + VOICE_MAPPINGS = { + "alloy": "male-qn-qingse", + "echo": "male-qn-jingying", + "fable": "female-shaonv", + "onyx": "male-qn-badao", + "nova": "female-yujie", + "shimmer": "female-tianmei", + } + + # Response format mappings from OpenAI to MiniMax + FORMAT_MAPPINGS = { + "mp3": "mp3", + "pcm": "pcm", + "wav": "wav", + "flac": "flac", + } + + def get_supported_openai_params(self, model: str) -> list: + """ + MiniMax TTS supports these OpenAI parameters + """ + return ["voice", "response_format", "speed"] + + def _extract_voice_id(self, voice: str) -> str: + """ + Normalize the provided voice information into a MiniMax voice_id. + """ + normalized_voice = voice.strip() + mapped_voice = self.VOICE_MAPPINGS.get(normalized_voice.lower()) + return mapped_voice or normalized_voice + + def _resolve_voice_id( + self, + voice: Optional[Union[str, Dict[str, Any]]], + params: Dict[str, Any], + ) -> str: + """ + Determine the MiniMax voice_id based on provided voice input or parameters. + """ + mapped_voice: Optional[str] = None + + if isinstance(voice, str) and voice.strip(): + mapped_voice = self._extract_voice_id(voice) + elif isinstance(voice, dict): + for key in ("voice_id", "id", "name"): + candidate = voice.get(key) + if isinstance(candidate, str) and candidate.strip(): + mapped_voice = self._extract_voice_id(candidate) + break + elif voice is not None: + mapped_voice = self._extract_voice_id(str(voice)) + + if mapped_voice is None: + voice_override = params.pop("voice_id", None) + if isinstance(voice_override, str) and voice_override.strip(): + mapped_voice = self._extract_voice_id(voice_override) + + if mapped_voice is None: + # Default to a common voice if not specified + mapped_voice = "male-qn-qingse" + + return mapped_voice + + def map_openai_params( + self, + model: str, + optional_params: Dict, + voice: Optional[Union[str, Dict]] = None, + drop_params: bool = False, + kwargs: Optional[Dict[str, Any]] = None, + ) -> Tuple[Optional[str], Dict]: + """ + Map OpenAI parameters to MiniMax TTS parameters + """ + mapped_params: Dict[str, Any] = {} + + # Work on a copy so we don't mutate the caller's dictionary + params = dict(optional_params) if optional_params else {} + + # Extract voice identifier + mapped_voice = self._resolve_voice_id(voice, params) + + # Response/output format + response_format = params.pop("response_format", None) + if isinstance(response_format, str): + mapped_format = self.FORMAT_MAPPINGS.get(response_format, "mp3") + mapped_params["format"] = mapped_format + else: + mapped_params["format"] = "mp3" # Default format + + # Speed parameter (MiniMax supports speed from 0.5 to 2.0) + speed = params.pop("speed", None) + if speed is not None: + try: + speed_value = float(speed) + # Clamp speed to MiniMax's supported range + speed_value = max(0.5, min(2.0, speed_value)) + mapped_params["speed"] = speed_value + except (TypeError, ValueError): + mapped_params["speed"] = 1.0 + else: + mapped_params["speed"] = 1.0 + + # Instructions parameter is OpenAI-specific; omit to prevent API errors + params.pop("instructions", None) + + # Store voice_id for later use in request construction + mapped_params["voice_id"] = mapped_voice + + # Handle extra_body for additional MiniMax-specific parameters + extra_body = params.pop("extra_body", None) + if isinstance(extra_body, dict): + for key, value in extra_body.items(): + if value is not None: + mapped_params[key] = value + + # Pass through any remaining parameters + for key, value in params.items(): + if value is not None: + mapped_params[key] = value + + return mapped_voice, mapped_params + + def validate_environment( + self, + headers: dict, + model: str, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + ) -> dict: + """ + Validate MiniMax environment and set up authentication headers + """ + api_key = ( + api_key + or litellm.api_key + or get_secret_str("MINIMAX_API_KEY") + ) + + if api_key is None: + raise ValueError( + "MiniMax API key is required. Set MINIMAX_API_KEY environment variable or pass api_key parameter." + ) + + headers.update( + { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + ) + + return headers + + def get_error_class( + self, error_message: str, status_code: int, headers: Union[dict, Headers] + ) -> BaseLLMException: + return MinimaxException( + message=error_message, status_code=status_code, headers=headers + ) + + def transform_text_to_speech_request( + self, + model: str, + input: str, + voice: Optional[str], + optional_params: Dict, + litellm_params: Dict, + headers: dict, + ) -> TextToSpeechRequestData: + """ + Build the MiniMax TTS request payload. + + MiniMax uses a different structure than OpenAI: + - model: The TTS model to use + - text: The input text + - voice_setting: Voice configuration + - audio_setting: Audio output configuration + """ + params = dict(optional_params) if optional_params else {} + + # Extract parameters + voice_id = params.pop("voice_id", voice or "male-qn-qingse") + speed = params.pop("speed", 1.0) + audio_format = params.pop("format", "mp3") + + # Extract additional voice settings + vol = params.pop("vol", 1.0) # Volume (0.1 to 10) + pitch = params.pop("pitch", 0) # Pitch adjustment (-12 to 12) + + # Extract audio settings + sample_rate = params.pop("sample_rate", 32000) # 16000, 24000, 32000 + bitrate = params.pop("bitrate", 128000) # For MP3: 64000, 128000, 192000, 256000 + channel = params.pop("channel", 1) # 1 for mono, 2 for stereo + + # Output format: 'url' or 'hex' (default is 'hex') + output_format = params.pop("output_format", "hex") + + request_body: Dict[str, Any] = { + "model": model, + "text": input, + "stream": False, # HTTP endpoint doesn't support streaming + "output_format": output_format, # 'url' or 'hex' + "voice_setting": { + "voice_id": voice_id, + "speed": speed, + "vol": vol, + "pitch": pitch, + }, + "audio_setting": { + "sample_rate": sample_rate, + "bitrate": bitrate, + "format": audio_format, + "channel": channel, + }, + } + + # Handle any remaining parameters from extra_body + extra_body = params.pop("extra_body", None) + if isinstance(extra_body, dict): + for key, value in extra_body.items(): + if value is not None and key not in request_body: + request_body[key] = value + + return TextToSpeechRequestData( + dict_body=request_body, + headers={"Content-Type": "application/json"}, + ) + + def transform_text_to_speech_response( + self, + model: str, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + ) -> "HttpxBinaryResponseContent": + """ + Transform MiniMax response to standard format. + + MiniMax returns JSON with base64-encoded audio data: + { + "base_resp": {"status_code": 0, "status_msg": "success"}, + "audio_file": "", + "extra_info": {...} + } + + We need to decode the base64 audio and return it as binary content. + """ + import base64 + import json + + from litellm.types.llms.openai import HttpxBinaryResponseContent + + try: + # Parse JSON response + response_json = raw_response.json() + + # MiniMax API response format check + # The API can return different structures: + # 1. {"data": {"audio": "..."}, "status": 0, ...} for HTTP endpoint + # 2. {"base_resp": {"status_code": 0, ...}, "audio_file": "..."} for older versions + + # Check for errors - MiniMax uses "status" field in HTTP endpoint response + # status: 0 = success, 2 = invalid api key, etc. + status = response_json.get("status") + if status is not None and status != 0: + ced = response_json.get("ced", "Unknown error") + error_detail = ced if ced else f"API returned status {status}" + raise MinimaxException( + status_code=raw_response.status_code, + message=f"MiniMax TTS error: {error_detail}", + headers=dict(raw_response.headers), + ) + + # Extract audio data + # MiniMax returns audio in "data" field + data = response_json.get("data", {}) + + # Check if response contains a URL (output_format='url') + audio_url = data.get("audio_url", None) + if audio_url: + # If URL format is used, we need to fetch the audio from the URL + # For now, return a response indicating URL mode (TODO: fetch audio from URL) + raise MinimaxException( + status_code=500, + message=f"URL output format is not yet supported. Use 'hex' format or fetch from URL: {audio_url}", + headers=dict(raw_response.headers), + ) + + # Get hex-encoded audio data + audio_hex = data.get("audio", "") or response_json.get("audio_file", "") + + if not audio_hex: + raise MinimaxException( + status_code=500, + message=f"No audio data in MiniMax response. Response keys: {list(response_json.keys())}", + headers=dict(raw_response.headers), + ) + + # MiniMax returns hex-encoded audio by default + # Try hex decoding first, fall back to base64 if that fails + try: + audio_bytes = bytes.fromhex(audio_hex) + except ValueError: + # If hex decoding fails, try base64 (for older API versions) + try: + audio_bytes = base64.b64decode(audio_hex) + except Exception as e: + raise MinimaxException( + status_code=500, + message=f"Failed to decode audio data: {str(e)}", + headers=dict(raw_response.headers), + ) + + # Create a new response with binary audio content + # We need to create a response that contains the decoded audio bytes + # Remove gzip encoding headers to avoid decompression issues + clean_headers = dict(raw_response.headers) + clean_headers.pop('content-encoding', None) + clean_headers.pop('transfer-encoding', None) + clean_headers['content-length'] = str(len(audio_bytes)) + + # Create a new response object with the binary content + binary_response = httpx.Response( + status_code=200, + headers=clean_headers, + content=audio_bytes, + request=raw_response.request, + ) + + return HttpxBinaryResponseContent(binary_response) + + except json.JSONDecodeError as e: + raise MinimaxException( + status_code=500, + message=f"Failed to parse MiniMax response: {str(e)}", + headers=dict(raw_response.headers), + ) + except Exception as e: + if isinstance(e, MinimaxException): + raise + raise MinimaxException( + status_code=500, + message=f"Error processing MiniMax response: {str(e)}", + headers=dict(raw_response.headers), + ) + + def get_complete_url( + self, + model: str, + api_base: Optional[str], + litellm_params: dict, + ) -> str: + """ + Construct the MiniMax endpoint URL. + """ + base_url = ( + api_base + or get_secret_str("MINIMAX_API_BASE") + or self.TTS_BASE_URL + ) + base_url = base_url.rstrip("/") + + # MiniMax uses a simple endpoint path + url = f"{base_url}{self.TTS_ENDPOINT_PATH}" + + return url + diff --git a/litellm/llms/openai_like/providers.json b/litellm/llms/openai_like/providers.json index d9351c8b6b8..a5455f4a6d1 100644 --- a/litellm/llms/openai_like/providers.json +++ b/litellm/llms/openai_like/providers.json @@ -25,5 +25,40 @@ "param_mappings": { "max_completion_tokens": "max_tokens" } + }, + "synthetic": { + "base_url": "https://api.synthetic.new/openai/v1", + "api_key_env": "SYNTHETIC_API_KEY", + "param_mappings": { + "max_completion_tokens": "max_tokens" + } + }, + "apertis": { + "base_url": "https://api.stima.tech/v1", + "api_key_env": "STIMA_API_KEY", + "param_mappings": { + "max_completion_tokens": "max_tokens" + } + }, + "nano-gpt": { + "base_url": "https://nano-gpt.com/api/v1", + "api_key_env": "NANOGPT_API_KEY", + "param_mappings": { + "max_completion_tokens": "max_tokens" + } + }, + "poe": { + "base_url": "https://api.poe.com/v1", + "api_key_env": "POE_API_KEY", + "param_mappings": { + "max_completion_tokens": "max_tokens" + } + }, + "chutes": { + "base_url": "https://llm.chutes.ai/v1/", + "api_key_env": "CHUTES_API_KEY", + "param_mappings": { + "max_completion_tokens": "max_tokens" + } } -} \ No newline at end of file +} diff --git a/litellm/llms/vertex_ai/agent_engine/transformation.py b/litellm/llms/vertex_ai/agent_engine/transformation.py index 4c07e8455e3..42032079f94 100644 --- a/litellm/llms/vertex_ai/agent_engine/transformation.py +++ b/litellm/llms/vertex_ai/agent_engine/transformation.py @@ -23,6 +23,7 @@ from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMExcepti from litellm.llms.vertex_ai.agent_engine.sse_iterator import ( VertexAgentEngineResponseIterator, ) +from litellm.llms.vertex_ai.common_utils import get_vertex_base_url from litellm.llms.vertex_ai.vertex_llm_base import VertexBase from litellm.types.llms.openai import AllMessageValues from litellm.types.utils import Choices, Message, ModelResponse, Usage @@ -130,8 +131,7 @@ class VertexAgentEngineConfig(BaseConfig, VertexBase): ) resource_path = f"projects/{vertex_project}/locations/{vertex_location}/reasoningEngines/{engine_id}" - # Build the base URL - base_url = f"https://{vertex_location}-aiplatform.googleapis.com" + base_url = get_vertex_base_url(vertex_location) # Always use :streamQuery endpoint for actual queries # The :query endpoint only supports session management methods diff --git a/litellm/llms/vertex_ai/batches/handler.py b/litellm/llms/vertex_ai/batches/handler.py index edae91ff9a3..12ce8b48aaf 100644 --- a/litellm/llms/vertex_ai/batches/handler.py +++ b/litellm/llms/vertex_ai/batches/handler.py @@ -8,6 +8,7 @@ from litellm.llms.custom_httpx.http_handler import ( _get_httpx_client, get_async_httpx_client, ) +from litellm.llms.vertex_ai.common_utils import get_vertex_base_url from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexLLM from litellm.types.llms.openai import CreateBatchRequest from litellm.types.llms.vertex_ai import ( @@ -128,7 +129,8 @@ class VertexAIBatchPrediction(VertexLLM): ) -> str: """Return the base url for the vertex garden models""" # POST https://LOCATION-aiplatform.googleapis.com/v1/projects/PROJECT_ID/locations/LOCATION/batchPredictionJobs - return f"https://{vertex_location}-aiplatform.googleapis.com/v1/projects/{vertex_project}/locations/{vertex_location}/batchPredictionJobs" + base_url = get_vertex_base_url(vertex_location) + return f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}/batchPredictionJobs" def retrieve_batch( self, diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 03fa5b98928..7d84b7c9098 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -193,6 +193,18 @@ def get_vertex_base_model_name(model: str) -> str: return model +def get_vertex_base_url( + vertex_location: Optional[str], +) -> str: + """ + Get the base URL for Vertex AI API calls. + """ + if vertex_location == "global": + return "https://aiplatform.googleapis.com" + else: + return f"https://{vertex_location}-aiplatform.googleapis.com" + + def _get_embedding_url( model: str, vertex_project: Optional[str], @@ -212,10 +224,18 @@ def _get_embedding_url( # Strip routing prefixes (bge/, gemma/, etc.) for endpoint URL construction model = get_vertex_base_model_name(model=model) - url = f"https://{vertex_location}-aiplatform.googleapis.com/v1/projects/{vertex_project}/locations/{vertex_location}/publishers/google/models/{model}:{endpoint}" + # Get base URL (handles global vs regional) + base_url = get_vertex_base_url(vertex_location) + if model.isdigit(): # https://us-central1-aiplatform.googleapis.com/v1/projects/$PROJECT_ID/locations/us-central1/endpoints/$ENDPOINT_ID:predict - url = f"https://{vertex_location}-aiplatform.googleapis.com/{vertex_api_version}/projects/{vertex_project}/locations/{vertex_location}/endpoints/{model}:{endpoint}" + # https://aiplatform.googleapis.com/v1/projects/$PROJECT_ID/locations/global/endpoints/$ENDPOINT_ID:predict + url = f"{base_url}/{vertex_api_version}/projects/{vertex_project}/locations/{vertex_location}/endpoints/{model}:{endpoint}" + else: + # Regular model -> publisher model + # https://us-central1-aiplatform.googleapis.com/v1/projects/$PROJECT_ID/locations/us-central1/publishers/google/models/{model}:predict + # https://aiplatform.googleapis.com/v1/projects/$PROJECT_ID/locations/global/publishers/google/models/{model}:predict + url = f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}/publishers/google/models/{model}:{endpoint}" return url, endpoint @@ -236,26 +256,23 @@ def _get_vertex_url( if mode == "chat": ### SET RUNTIME ENDPOINT ### endpoint = "generateContent" + base_url = get_vertex_base_url(vertex_location) + if stream is True: endpoint = "streamGenerateContent" - if vertex_location == "global": - url = f"https://aiplatform.googleapis.com/{vertex_api_version}/projects/{vertex_project}/locations/global/publishers/google/models/{model}:{endpoint}?alt=sse" - else: - url = f"https://{vertex_location}-aiplatform.googleapis.com/{vertex_api_version}/projects/{vertex_project}/locations/{vertex_location}/publishers/google/models/{model}:{endpoint}?alt=sse" - else: - if vertex_location == "global": - url = f"https://aiplatform.googleapis.com/{vertex_api_version}/projects/{vertex_project}/locations/global/publishers/google/models/{model}:{endpoint}" - else: - url = f"https://{vertex_location}-aiplatform.googleapis.com/{vertex_api_version}/projects/{vertex_project}/locations/{vertex_location}/publishers/google/models/{model}:{endpoint}" - + # if model is only numeric chars then it's a fine tuned gemini model # model = 4965075652664360960 - # send to this url: url = f"https://{vertex_location}-aiplatform.googleapis.com/{version}/projects/{vertex_project}/locations/{vertex_location}/endpoints/{model}:{endpoint}" + # send to this url: url = f"{base_url}/{version}/projects/{vertex_project}/locations/{vertex_location}/endpoints/{model}:{endpoint}" if model.isdigit(): - # It's a fine-tuned Gemini model - url = f"https://{vertex_location}-aiplatform.googleapis.com/{vertex_api_version}/projects/{vertex_project}/locations/{vertex_location}/endpoints/{model}:{endpoint}" - if stream is True: - url += "?alt=sse" + # It's a fine-tuned Gemini model - use endpoints/ path + url = f"{base_url}/{vertex_api_version}/projects/{vertex_project}/locations/{vertex_location}/endpoints/{model}:{endpoint}" + else: + # Regular model - use publishers/google/models/ path + url = f"{base_url}/{vertex_api_version}/projects/{vertex_project}/locations/{vertex_location}/publishers/google/models/{model}:{endpoint}" + + if stream is True: + url += "?alt=sse" elif mode == "embedding": return _get_embedding_url( model=model, @@ -265,15 +282,17 @@ def _get_vertex_url( ) elif mode == "image_generation": endpoint = "predict" - url = f"https://{vertex_location}-aiplatform.googleapis.com/v1/projects/{vertex_project}/locations/{vertex_location}/publishers/google/models/{model}:{endpoint}" + base_url = get_vertex_base_url(vertex_location) if model.isdigit(): - url = f"https://{vertex_location}-aiplatform.googleapis.com/{vertex_api_version}/projects/{vertex_project}/locations/{vertex_location}/endpoints/{model}:{endpoint}" + # Numeric model -> custom endpoint + url = f"{base_url}/{vertex_api_version}/projects/{vertex_project}/locations/{vertex_location}/endpoints/{model}:{endpoint}" + else: + # Regular model -> publisher model + url = f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}/publishers/google/models/{model}:{endpoint}" elif mode == "count_tokens": endpoint = "countTokens" - if vertex_location == "global": - url = f"https://aiplatform.googleapis.com/{vertex_api_version}/projects/{vertex_project}/locations/global/publishers/google/models/{model}:{endpoint}" - else: - url = f"https://{vertex_location}-aiplatform.googleapis.com/{vertex_api_version}/projects/{vertex_project}/locations/{vertex_location}/publishers/google/models/{model}:{endpoint}" + base_url = get_vertex_base_url(vertex_location) + url = f"{base_url}/{vertex_api_version}/projects/{vertex_project}/locations/{vertex_location}/publishers/google/models/{model}:{endpoint}" if not url or not endpoint: raise ValueError(f"Unable to get vertex url/endpoint for mode: {mode}") return url, endpoint diff --git a/litellm/llms/vertex_ai/fine_tuning/handler.py b/litellm/llms/vertex_ai/fine_tuning/handler.py index 6372f8ea305..e2cd052fffd 100644 --- a/litellm/llms/vertex_ai/fine_tuning/handler.py +++ b/litellm/llms/vertex_ai/fine_tuning/handler.py @@ -8,6 +8,7 @@ import httpx import litellm from litellm._logging import verbose_logger from litellm.llms.custom_httpx.http_handler import HTTPHandler, get_async_httpx_client +from litellm.llms.vertex_ai.common_utils import get_vertex_base_url from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexLLM from litellm.types.fine_tuning import OpenAIFineTuningHyperparameters from litellm.types.llms.openai import FineTuningJobCreate @@ -261,7 +262,8 @@ class VertexFineTuningAPI(VertexLLM): original_hyperparameters=original_hyperparameters or {}, ) - fine_tuning_url = f"https://{vertex_location}-aiplatform.googleapis.com/v1/projects/{vertex_project}/locations/{vertex_location}/tuningJobs" + base_url = get_vertex_base_url(vertex_location) + fine_tuning_url = f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}/tuningJobs" if _is_async is True: return self.acreate_fine_tuning_job( # type: ignore fine_tuning_url=fine_tuning_url, @@ -329,19 +331,21 @@ class VertexFineTuningAPI(VertexLLM): "Content-Type": "application/json", } + base_url = get_vertex_base_url(vertex_location) + url = None if request_route == "/tuningJobs": - url = f"https://{vertex_location}-aiplatform.googleapis.com/v1/projects/{vertex_project}/locations/{vertex_location}/tuningJobs" + url = f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}/tuningJobs" elif "/tuningJobs/" in request_route and "cancel" in request_route: - url = f"https://{vertex_location}-aiplatform.googleapis.com/v1/projects/{vertex_project}/locations/{vertex_location}/tuningJobs{request_route}" + url = f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}/tuningJobs{request_route}" elif "generateContent" in request_route: - url = f"https://{vertex_location}-aiplatform.googleapis.com/v1/projects/{vertex_project}/locations/{vertex_location}{request_route}" + url = f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}{request_route}" elif "predict" in request_route: - url = f"https://{vertex_location}-aiplatform.googleapis.com/v1/projects/{vertex_project}/locations/{vertex_location}{request_route}" + url = f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}{request_route}" elif "/batchPredictionJobs" in request_route: - url = f"https://{vertex_location}-aiplatform.googleapis.com/v1/projects/{vertex_project}/locations/{vertex_location}{request_route}" + url = f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}{request_route}" elif "countTokens" in request_route: - url = f"https://{vertex_location}-aiplatform.googleapis.com/v1/projects/{vertex_project}/locations/{vertex_location}{request_route}" + url = f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}{request_route}" elif "cachedContents" in request_route: _model = request_data.get("model") if _model is not None and "/publishers/google/models/" not in _model: @@ -349,7 +353,7 @@ class VertexFineTuningAPI(VertexLLM): f"projects/{vertex_project}/locations/{vertex_location}/publishers/google/models/{_model}" ) - url = f"https://{vertex_location}-aiplatform.googleapis.com/v1beta1/projects/{vertex_project}/locations/{vertex_location}{request_route}" + url = f"{base_url}/v1beta1/projects/{vertex_project}/locations/{vertex_location}{request_route}" else: raise ValueError(f"Unsupported Vertex AI request route: {request_route}") if self.async_handler is None: 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 b1810b40cf9..a5cc3dca8c1 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 @@ -1318,13 +1318,11 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): _tool_response_chunk["provider_specific_fields"] = { # type: ignore "thought_signature": thought_signature } - # Only embed in ID if preview features are enabled - if litellm.enable_preview_features: - _tool_response_chunk[ - "id" - ] = _encode_tool_call_id_with_signature( - _tool_response_chunk["id"] or "", thought_signature - ) + _tool_response_chunk[ + "id" + ] = _encode_tool_call_id_with_signature( + _tool_response_chunk["id"] or "", thought_signature + ) _tools.append(_tool_response_chunk) cumulative_tool_call_idx += 1 if len(_tools) == 0: diff --git a/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py b/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py index d575c5862e8..174d05cf7cf 100644 --- a/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py +++ b/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py @@ -10,6 +10,7 @@ from httpx._types import RequestFiles import litellm from litellm.images.utils import ImageEditRequestUtils from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig +from litellm.llms.vertex_ai.common_utils import get_vertex_base_url from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexLLM from litellm.secret_managers.main import get_secret_str from litellm.types.images.main import ImageEditOptionalRequestParams @@ -143,11 +144,7 @@ class VertexAIGeminiImageEditConfig(BaseImageEditConfig, VertexLLM): if not vertex_project or not vertex_location: raise ValueError("vertex_project and vertex_location are required for Vertex AI") - # Handle global location differently (no region prefix in URL) - if vertex_location == "global": - base_url = "https://aiplatform.googleapis.com" - else: - base_url = f"https://{vertex_location}-aiplatform.googleapis.com" + base_url = get_vertex_base_url(vertex_location) return f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}/publishers/google/models/{model_name}:generateContent" diff --git a/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py b/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py index ad650e38499..b61af6ffd3a 100644 --- a/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py +++ b/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py @@ -9,9 +9,9 @@ import httpx from httpx._types import RequestFiles import litellm - from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig +from litellm.llms.vertex_ai.common_utils import get_vertex_base_url from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexLLM from litellm.secret_managers.main import get_secret_str from litellm.types.images.main import ImageEditOptionalRequestParams @@ -136,7 +136,7 @@ class VertexAIImagenImageEditConfig(BaseImageEditConfig, VertexLLM): if api_base: base_url = api_base.rstrip("/") else: - base_url = f"https://{vertex_location}-aiplatform.googleapis.com" + base_url = get_vertex_base_url(vertex_location) return f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}/publishers/google/models/{model_name}:predict" diff --git a/litellm/llms/vertex_ai/image_generation/vertex_gemini_transformation.py b/litellm/llms/vertex_ai/image_generation/vertex_gemini_transformation.py index 619bd006300..89ed9f1a8a5 100644 --- a/litellm/llms/vertex_ai/image_generation/vertex_gemini_transformation.py +++ b/litellm/llms/vertex_ai/image_generation/vertex_gemini_transformation.py @@ -7,13 +7,19 @@ import litellm from litellm.llms.base_llm.image_generation.transformation import ( BaseImageGenerationConfig, ) +from litellm.llms.vertex_ai.common_utils import get_vertex_base_url from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexLLM from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import ( AllMessageValues, OpenAIImageGenerationOptionalParams, ) -from litellm.types.utils import ImageObject, ImageResponse, ImageUsage, ImageUsageInputTokensDetails +from litellm.types.utils import ( + ImageObject, + ImageResponse, + ImageUsage, + ImageUsageInputTokensDetails, +) if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj @@ -140,11 +146,7 @@ class VertexAIGeminiImageGenerationConfig(BaseImageGenerationConfig, VertexLLM): if not vertex_project or not vertex_location: raise ValueError("vertex_project and vertex_location are required for Vertex AI") - # Handle global location differently (no region prefix in URL) - if vertex_location == "global": - base_url = "https://aiplatform.googleapis.com" - else: - base_url = f"https://{vertex_location}-aiplatform.googleapis.com" + base_url = get_vertex_base_url(vertex_location) return f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}/publishers/google/models/{model_name}:generateContent" diff --git a/litellm/llms/vertex_ai/image_generation/vertex_imagen_transformation.py b/litellm/llms/vertex_ai/image_generation/vertex_imagen_transformation.py index 33f416f9ca8..6f9e3874173 100644 --- a/litellm/llms/vertex_ai/image_generation/vertex_imagen_transformation.py +++ b/litellm/llms/vertex_ai/image_generation/vertex_imagen_transformation.py @@ -7,6 +7,7 @@ import litellm from litellm.llms.base_llm.image_generation.transformation import ( BaseImageGenerationConfig, ) +from litellm.llms.vertex_ai.common_utils import get_vertex_base_url from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexLLM from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import ( @@ -140,7 +141,7 @@ class VertexAIImagenImageGenerationConfig(BaseImageGenerationConfig, VertexLLM): if not vertex_project or not vertex_location: raise ValueError("vertex_project and vertex_location are required for Vertex AI") - base_url = f"https://{vertex_location}-aiplatform.googleapis.com" + base_url = get_vertex_base_url(vertex_location) return f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}/publishers/google/models/{model_name}:predict" diff --git a/litellm/llms/vertex_ai/ocr/transformation.py b/litellm/llms/vertex_ai/ocr/transformation.py index f4482939851..849e332dae3 100644 --- a/litellm/llms/vertex_ai/ocr/transformation.py +++ b/litellm/llms/vertex_ai/ocr/transformation.py @@ -10,6 +10,7 @@ from litellm.litellm_core_utils.prompt_templates.image_handling import ( ) from litellm.llms.base_llm.ocr.transformation import DocumentType, OCRRequestData from litellm.llms.mistral.ocr.transformation import MistralOCRConfig +from litellm.llms.vertex_ai.common_utils import get_vertex_base_url from litellm.llms.vertex_ai.vertex_llm_base import VertexBase @@ -104,7 +105,7 @@ class VertexAIOCRConfig(MistralOCRConfig): # Get API base URL if api_base is None: - api_base = f"https://{vertex_location}-aiplatform.googleapis.com" + api_base = get_vertex_base_url(vertex_location) # Ensure no trailing slash api_base = api_base.rstrip("/") diff --git a/litellm/llms/vertex_ai/rag_engine/transformation.py b/litellm/llms/vertex_ai/rag_engine/transformation.py index b601da1951a..7e70202fb75 100644 --- a/litellm/llms/vertex_ai/rag_engine/transformation.py +++ b/litellm/llms/vertex_ai/rag_engine/transformation.py @@ -8,6 +8,7 @@ from typing import Any, Dict, Optional from litellm._logging import verbose_logger from litellm.constants import DEFAULT_CHUNK_OVERLAP, DEFAULT_CHUNK_SIZE +from litellm.llms.vertex_ai.common_utils import get_vertex_base_url from litellm.llms.vertex_ai.vertex_llm_base import VertexBase from litellm.types.rag import RAGChunkingStrategy @@ -37,8 +38,8 @@ class VertexAIRAGTransformation(VertexBase): Note: The REST endpoint for importRagFiles may not be publicly available. Vertex AI RAG Engine primarily uses gRPC-based SDK. """ - base_url = f"https://{vertex_location}-aiplatform.googleapis.com/v1" - return f"{base_url}/projects/{vertex_project}/locations/{vertex_location}/ragCorpora/{corpus_id}:importRagFiles" + base_url = get_vertex_base_url(vertex_location) + return f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}/ragCorpora/{corpus_id}:importRagFiles" def get_retrieve_contexts_url( self, @@ -46,8 +47,8 @@ class VertexAIRAGTransformation(VertexBase): vertex_location: str, ) -> str: """Get the URL for retrieving contexts (search).""" - base_url = f"https://{vertex_location}-aiplatform.googleapis.com/v1" - return f"{base_url}/projects/{vertex_project}/locations/{vertex_location}:retrieveContexts" + base_url = get_vertex_base_url(vertex_location) + return f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}:retrieveContexts" def transform_chunking_strategy_to_vertex_format( self, diff --git a/litellm/llms/vertex_ai/vector_stores/rag_api/transformation.py b/litellm/llms/vertex_ai/vector_stores/rag_api/transformation.py index 6f258bc04a6..08b93145e50 100644 --- a/litellm/llms/vertex_ai/vector_stores/rag_api/transformation.py +++ b/litellm/llms/vertex_ai/vector_stores/rag_api/transformation.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union import httpx from litellm.llms.base_llm.vector_store.transformation import BaseVectorStoreConfig +from litellm.llms.vertex_ai.common_utils import get_vertex_base_url from litellm.llms.vertex_ai.vertex_llm_base import VertexBase from litellm.types.router import GenericLiteLLMParams from litellm.types.vector_stores import ( @@ -88,7 +89,8 @@ class VertexVectorStoreConfig(BaseVectorStoreConfig, VertexBase): return api_base.rstrip("/") # Vertex AI RAG API endpoint for retrieveContexts - return f"https://{vertex_location}-aiplatform.googleapis.com/v1/projects/{vertex_project}/locations/{vertex_location}" + base_url = get_vertex_base_url(vertex_location) + return f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}" def transform_search_vector_store_request( self, diff --git a/litellm/llms/vertex_ai/vertex_ai_partner_models/count_tokens/handler.py b/litellm/llms/vertex_ai/vertex_ai_partner_models/count_tokens/handler.py index ae1a758bf20..3842159fd7b 100644 --- a/litellm/llms/vertex_ai/vertex_ai_partner_models/count_tokens/handler.py +++ b/litellm/llms/vertex_ai/vertex_ai_partner_models/count_tokens/handler.py @@ -8,6 +8,7 @@ their respective publisher-specific count-tokens endpoints. from typing import Any, Dict, Optional from litellm.llms.custom_httpx.http_handler import get_async_httpx_client +from litellm.llms.vertex_ai.common_utils import get_vertex_base_url from litellm.llms.vertex_ai.vertex_llm_base import VertexBase @@ -65,10 +66,8 @@ class VertexAIPartnerModelsTokenCounter(VertexBase): # Use custom api_base if provided, otherwise construct default if api_base: base_url = api_base - elif vertex_location == "global": - base_url = "https://aiplatform.googleapis.com" else: - base_url = f"https://{vertex_location}-aiplatform.googleapis.com" + base_url = get_vertex_base_url(vertex_location) # Construct the count-tokens endpoint # Format: /v1/projects/{project}/locations/{location}/publishers/{publisher}/models/count-tokens:rawPredict diff --git a/litellm/llms/vertex_ai/vertex_model_garden/main.py b/litellm/llms/vertex_ai/vertex_model_garden/main.py index fe7d0862e02..c37bb449ecf 100644 --- a/litellm/llms/vertex_ai/vertex_model_garden/main.py +++ b/litellm/llms/vertex_ai/vertex_model_garden/main.py @@ -20,6 +20,7 @@ from typing import Callable, Optional, Union import httpx # type: ignore +from litellm.llms.vertex_ai.common_utils import get_vertex_base_url from litellm.utils import ModelResponse from ..common_utils import VertexAIError, get_vertex_base_model_name @@ -34,8 +35,8 @@ def create_vertex_url( api_base: Optional[str] = None, ) -> str: """Return the base url for the vertex garden models""" - # f"https://{self.endpoint.location}-aiplatform.googleapis.com/v1beta1/projects/{PROJECT_ID}/locations/{self.endpoint.location}" - return f"https://{vertex_location}-aiplatform.googleapis.com/v1beta1/projects/{vertex_project}/locations/{vertex_location}/endpoints/{model}" + base_url = get_vertex_base_url(vertex_location) + return f"{base_url}/v1beta1/projects/{vertex_project}/locations/{vertex_location}/endpoints/{model}" class VertexAIModelGardenModels(VertexBase): diff --git a/litellm/llms/vertex_ai/videos/transformation.py b/litellm/llms/vertex_ai/videos/transformation.py index 8a542ae4ef0..66cd1437642 100644 --- a/litellm/llms/vertex_ai/videos/transformation.py +++ b/litellm/llms/vertex_ai/videos/transformation.py @@ -17,6 +17,7 @@ from litellm.images.utils import ImageEditRequestUtils from litellm.llms.base_llm.videos.transformation import BaseVideoConfig from litellm.llms.vertex_ai.common_utils import ( _convert_vertex_datetime_to_openai_datetime, + get_vertex_base_url, ) from litellm.llms.vertex_ai.vertex_llm_base import VertexBase from litellm.types.router import GenericLiteLLMParams @@ -222,10 +223,8 @@ class VertexAIVideoConfig(BaseVideoConfig, VertexBase): # Construct the URL if api_base: base_url = api_base.rstrip("/") - elif vertex_location == "global": - base_url = "https://aiplatform.googleapis.com" else: - base_url = f"https://{vertex_location}-aiplatform.googleapis.com" + base_url = get_vertex_base_url(vertex_location) url = f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}/publishers/google/models/{model_name}" diff --git a/litellm/main.py b/litellm/main.py index 60fe3eb2dec..a0f3461b45c 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -68,7 +68,6 @@ from litellm.constants import ( DEFAULT_MOCK_RESPONSE_PROMPT_TOKEN_COUNT, ) from litellm.exceptions import LiteLLMUnknownProvider -from litellm.llms.openai_like.json_loader import JSONProviderRegistry from litellm.integrations.custom_logger import CustomLogger from litellm.litellm_core_utils.asyncify import run_async_function from litellm.litellm_core_utils.audio_utils.utils import ( @@ -98,6 +97,7 @@ from litellm.llms.base_llm.base_model_iterator import ( from litellm.llms.bedrock.common_utils import BedrockModelInfo from litellm.llms.cohere.common_utils import CohereModelInfo from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler +from litellm.llms.openai_like.json_loader import JSONProviderRegistry from litellm.llms.vertex_ai.common_utils import ( VertexAIModelRoute, get_vertex_ai_model_route, @@ -110,6 +110,7 @@ from litellm.types.utils import ( RawRequestTypedDict, StreamingChoices, ) + from litellm.utils import ( Choices, CustomStreamWrapper, @@ -2247,6 +2248,42 @@ def completion( # type: ignore # noqa: PLR0915 logging.post_call( input=messages, api_key=api_key, original_response=response ) + elif custom_llm_provider == "minimax": + api_key = ( + api_key + or get_secret_str("MINIMAX_API_KEY") + or litellm.api_key + ) + + api_base = ( + api_base + or litellm.api_base + or get_secret_str("MINIMAX_API_BASE") + or "https://api.minimax.io/v1" + ) + + response = base_llm_http_handler.completion( + model=model, + messages=messages, + api_base=api_base, + custom_llm_provider=custom_llm_provider, + model_response=model_response, + encoding=_get_encoding(), + logging_obj=logging, + optional_params=optional_params, + timeout=timeout, + litellm_params=litellm_params, + shared_session=shared_session, + acompletion=acompletion, + stream=stream, + api_key=api_key, + headers=headers, + client=client, + provider_config=provider_config, + ) + logging.post_call( + input=messages, api_key=api_key, original_response=response + ) elif ( model in litellm.open_ai_chat_completion_models or custom_llm_provider == "custom_openai" @@ -6471,6 +6508,46 @@ def speech( # noqa: PLR0915 api_key=api_key, **kwargs, ) + elif custom_llm_provider == "minimax": + from litellm.llms.minimax.text_to_speech.transformation import ( + MinimaxTextToSpeechConfig, + ) + + # MiniMax Text-to-Speech + if text_to_speech_provider_config is None: + text_to_speech_provider_config = MinimaxTextToSpeechConfig() + + minimax_config = cast( + MinimaxTextToSpeechConfig, text_to_speech_provider_config + ) + + if api_base is not None: + litellm_params_dict["api_base"] = api_base + if api_key is not None: + litellm_params_dict["api_key"] = api_key + + # Convert voice to string if it's a dict (minimax handler expects Optional[str]) + voice_str: Optional[str] = None + if isinstance(voice, str): + voice_str = voice + elif isinstance(voice, dict): + # Extract voice_id from dict if needed + voice_str = voice.get("voice_id") or voice.get("id") or voice.get("name") + + response = base_llm_http_handler.text_to_speech_handler( + model=model, + input=input, + voice=voice_str, + text_to_speech_provider_config=minimax_config, + text_to_speech_optional_params=optional_params, + custom_llm_provider=custom_llm_provider, + litellm_params=litellm_params_dict, + logging_obj=logging_obj, + timeout=timeout, + extra_headers=extra_headers, + client=client, + _is_async=aspeech or False, + ) elif custom_llm_provider == "aws_polly": from litellm.llms.aws_polly.text_to_speech.transformation import ( AWSPollyTextToSpeechConfig, diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index f4b42d1fd6e..513a4a554e0 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -1357,6 +1357,20 @@ "litellm_provider": "azure", "mode": "chat" }, + "azure_ai/gpt-oss-120b": { + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "litellm_provider": "azure_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "source": "https://azure.microsoft.com/en-us/pricing/details/cognitive-services/openai-service/", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, "azure/eu/gpt-4o-2024-08-06": { "deprecation_date": "2026-02-27", "cache_read_input_token_cost": 1.375e-06, @@ -3707,6 +3721,32 @@ "/v1/images/generations" ] }, + "azure/gpt-image-1.5": { + "cache_read_input_image_token_cost": 2e-06, + "cache_read_input_token_cost": 1.25e-06, + "input_cost_per_token": 5e-06, + "input_cost_per_image_token": 8e-06, + "litellm_provider": "azure", + "mode": "image_generation", + "output_cost_per_image_token": 3.2e-05, + "supported_endpoints": [ + "/v1/images/generations", + "/v1/images/edits" + ] + }, + "azure/gpt-image-1.5-2025-12-16": { + "cache_read_input_image_token_cost": 2e-06, + "cache_read_input_token_cost": 1.25e-06, + "input_cost_per_token": 5e-06, + "input_cost_per_image_token": 8e-06, + "litellm_provider": "azure", + "mode": "image_generation", + "output_cost_per_image_token": 3.2e-05, + "supported_endpoints": [ + "/v1/images/generations", + "/v1/images/edits" + ] + }, "azure/low/1024-x-1024/gpt-image-1-mini": { "input_cost_per_pixel": 2.0751953125e-09, "litellm_provider": "azure", @@ -18053,75 +18093,6 @@ "supports_response_schema": true, "supports_vision": true }, - "groq/deepseek-r1-distill-llama-70b": { - "input_cost_per_token": 7.5e-07, - "litellm_provider": "groq", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 9.9e-07, - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, - "groq/distil-whisper-large-v3-en": { - "input_cost_per_second": 5.56e-06, - "litellm_provider": "groq", - "mode": "audio_transcription", - "output_cost_per_second": 0.0 - }, - "groq/gemma-7b-it": { - "deprecation_date": "2024-12-18", - "input_cost_per_token": 7e-08, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 7e-08, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, - "groq/gemma2-9b-it": { - "input_cost_per_token": 2e-07, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 2e-07, - "supports_function_calling": false, - "supports_response_schema": false, - "supports_tool_choice": false - }, - "groq/llama-3.1-405b-reasoning": { - "input_cost_per_token": 5.9e-07, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 7.9e-07, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, - "groq/llama-3.1-70b-versatile": { - "deprecation_date": "2025-01-24", - "input_cost_per_token": 5.9e-07, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 7.9e-07, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, "groq/llama-3.1-8b-instant": { "input_cost_per_token": 5e-08, "litellm_provider": "groq", @@ -18134,97 +18105,6 @@ "supports_response_schema": false, "supports_tool_choice": true }, - "groq/llama-3.2-11b-text-preview": { - "deprecation_date": "2024-10-28", - "input_cost_per_token": 1.8e-07, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 1.8e-07, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, - "groq/llama-3.2-11b-vision-preview": { - "deprecation_date": "2025-04-14", - "input_cost_per_token": 1.8e-07, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 1.8e-07, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_vision": true - }, - "groq/llama-3.2-1b-preview": { - "deprecation_date": "2025-04-14", - "input_cost_per_token": 4e-08, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 4e-08, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, - "groq/llama-3.2-3b-preview": { - "deprecation_date": "2025-04-14", - "input_cost_per_token": 6e-08, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 6e-08, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, - "groq/llama-3.2-90b-text-preview": { - "deprecation_date": "2024-11-25", - "input_cost_per_token": 9e-07, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 9e-07, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, - "groq/llama-3.2-90b-vision-preview": { - "deprecation_date": "2025-04-14", - "input_cost_per_token": 9e-07, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 9e-07, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_vision": true - }, - "groq/llama-3.3-70b-specdec": { - "deprecation_date": "2025-04-14", - "input_cost_per_token": 5.9e-07, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 9.9e-07, - "supports_tool_choice": true - }, "groq/llama-3.3-70b-versatile": { "input_cost_per_token": 5.9e-07, "litellm_provider": "groq", @@ -18237,7 +18117,7 @@ "supports_response_schema": false, "supports_tool_choice": true }, - "groq/llama-guard-3-8b": { + "groq/meta-llama/llama-guard-4-12b": { "input_cost_per_token": 2e-07, "litellm_provider": "groq", "max_input_tokens": 8192, @@ -18246,44 +18126,6 @@ "mode": "chat", "output_cost_per_token": 2e-07 }, - "groq/llama2-70b-4096": { - "input_cost_per_token": 7e-07, - "litellm_provider": "groq", - "max_input_tokens": 4096, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 8e-07, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, - "groq/llama3-groq-70b-8192-tool-use-preview": { - "deprecation_date": "2025-01-06", - "input_cost_per_token": 8.9e-07, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 8.9e-07, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, - "groq/llama3-groq-8b-8192-tool-use-preview": { - "deprecation_date": "2025-01-06", - "input_cost_per_token": 1.9e-07, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 1.9e-07, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, "groq/meta-llama/llama-4-maverick-17b-128e-instruct": { "input_cost_per_token": 2e-07, "litellm_provider": "groq", @@ -18294,7 +18136,8 @@ "output_cost_per_token": 6e-07, "supports_function_calling": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": true }, "groq/meta-llama/llama-4-scout-17b-16e-instruct": { "input_cost_per_token": 1.1e-07, @@ -18306,41 +18149,8 @@ "output_cost_per_token": 3.4e-07, "supports_function_calling": true, "supports_response_schema": true, - "supports_tool_choice": true - }, - "groq/mistral-saba-24b": { - "input_cost_per_token": 7.9e-07, - "litellm_provider": "groq", - "max_input_tokens": 32000, - "max_output_tokens": 32000, - "max_tokens": 32000, - "mode": "chat", - "output_cost_per_token": 7.9e-07 - }, - "groq/mixtral-8x7b-32768": { - "deprecation_date": "2025-03-20", - "input_cost_per_token": 2.4e-07, - "litellm_provider": "groq", - "max_input_tokens": 32768, - "max_output_tokens": 32768, - "max_tokens": 32768, - "mode": "chat", - "output_cost_per_token": 2.4e-07, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, - "groq/moonshotai/kimi-k2-instruct": { - "input_cost_per_token": 1e-06, - "litellm_provider": "groq", - "max_input_tokens": 131072, - "max_output_tokens": 16384, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 3e-06, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": true }, "groq/moonshotai/kimi-k2-instruct-0905": { "input_cost_per_token": 1e-06, @@ -19580,6 +19390,80 @@ "output_cost_per_token": 1.2e-06, "supports_system_messages": true }, + "minimax/speech-02-hd": { + "input_cost_per_character": 0.0001, + "litellm_provider": "minimax", + "mode": "audio_speech", + "supported_endpoints": [ + "/v1/audio/speech" + ] + }, + "minimax/speech-02-turbo": { + "input_cost_per_character": 0.00006, + "litellm_provider": "minimax", + "mode": "audio_speech", + "supported_endpoints": [ + "/v1/audio/speech" + ] + }, + "minimax/speech-2.6-hd": { + "input_cost_per_character": 0.0001, + "litellm_provider": "minimax", + "mode": "audio_speech", + "supported_endpoints": [ + "/v1/audio/speech" + ] + }, + "minimax/speech-2.6-turbo": { + "input_cost_per_character": 0.00006, + "litellm_provider": "minimax", + "mode": "audio_speech", + "supported_endpoints": [ + "/v1/audio/speech" + ] + }, + "minimax/MiniMax-M2.1": { + "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.2e-06, + "cache_read_input_token_cost": 3e-08, + "cache_creation_input_token_cost": 3.75e-07, + "litellm_provider": "minimax", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "max_input_tokens": 1000000, + "max_output_tokens": 8192 + }, + "minimax/MiniMax-M2.1-lightning": { + "input_cost_per_token": 3e-07, + "output_cost_per_token": 2.4e-06, + "cache_read_input_token_cost": 3e-08, + "cache_creation_input_token_cost": 3.75e-07, + "litellm_provider": "minimax", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "max_input_tokens": 1000000, + "max_output_tokens": 8192 + }, + "minimax/MiniMax-M2": { + "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.2e-06, + "cache_read_input_token_cost": 3e-08, + "cache_creation_input_token_cost": 3.75e-07, + "litellm_provider": "minimax", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "max_input_tokens": 200000, + "max_output_tokens": 8192 + }, "mistral.magistral-small-2509": { "input_cost_per_token": 5e-07, "litellm_provider": "bedrock_converse", @@ -25111,6 +24995,7 @@ "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/Qwen/Qwen2.5-7B-Instruct-Turbo": { @@ -25118,6 +25003,7 @@ "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput": { @@ -25129,6 +25015,7 @@ "source": "https://www.together.ai/models/qwen3-235b-a22b-instruct-2507-fp8", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/Qwen/Qwen3-235B-A22B-Thinking-2507": { @@ -25140,6 +25027,7 @@ "source": "https://www.together.ai/models/qwen3-235b-a22b-thinking-2507", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/Qwen/Qwen3-235B-A22B-fp8-tput": { @@ -25162,6 +25050,7 @@ "source": "https://www.together.ai/models/qwen3-coder-480b-a35b-instruct", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/deepseek-ai/DeepSeek-R1": { @@ -25174,6 +25063,7 @@ "output_cost_per_token": 7e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/deepseek-ai/DeepSeek-R1-0528-tput": { @@ -25185,6 +25075,7 @@ "source": "https://www.together.ai/models/deepseek-r1-0528-throughput", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/deepseek-ai/DeepSeek-V3": { @@ -25197,6 +25088,7 @@ "output_cost_per_token": 1.25e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/deepseek-ai/DeepSeek-V3.1": { @@ -25216,6 +25108,7 @@ "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo": { @@ -25245,6 +25138,7 @@ "output_cost_per_token": 8.5e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/meta-llama/Llama-4-Scout-17B-16E-Instruct": { @@ -25254,6 +25148,7 @@ "output_cost_per_token": 5.9e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo": { @@ -25263,6 +25158,7 @@ "output_cost_per_token": 3.5e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo": { @@ -25318,6 +25214,7 @@ "source": "https://www.together.ai/models/kimi-k2-instruct", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/openai/gpt-oss-120b": { @@ -25329,6 +25226,7 @@ "source": "https://www.together.ai/models/gpt-oss-120b", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/openai/gpt-oss-20b": { @@ -25340,6 +25238,7 @@ "source": "https://www.together.ai/models/gpt-oss-20b", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/togethercomputer/CodeLlama-34b-Instruct": { @@ -25358,6 +25257,7 @@ "source": "https://www.together.ai/models/glm-4-5-air", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/zai-org/GLM-4.6": { @@ -25394,6 +25294,7 @@ "source": "https://www.together.ai/models/qwen3-next-80b-a3b-instruct", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/Qwen/Qwen3-Next-80B-A3B-Thinking": { @@ -25405,6 +25306,7 @@ "source": "https://www.together.ai/models/qwen3-next-80b-a3b-thinking", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "tts-1": { diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 8c9d8630457..2d3ea1e827f 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -11,7 +11,7 @@ import datetime import hashlib import json import re -from typing import Any, Dict, List, Optional, Set, Tuple, Union, cast +from typing import Any, Dict, List, Literal, Optional, Set, Tuple, Union, cast from urllib.parse import urlparse from fastapi import HTTPException @@ -84,6 +84,8 @@ def _deserialize_json_dict(data: Any) -> Optional[Dict[str, str]]: class MCPServerManager: + _STDIO_ENV_TEMPLATE_PATTERN = re.compile(r"^\$\{(X-[^}]+)\}$") + def __init__(self): self.registry: Dict[str, MCPServer] = {} self.config_mcp_servers: Dict[str, MCPServer] = {} @@ -671,11 +673,39 @@ class MCPServerManager: ######################################################### # Methods that call the upstream MCP servers ######################################################### + def _build_stdio_env( + self, + server: MCPServer, + raw_headers: Optional[Dict[str, str]] = None, + ) -> Optional[Dict[str, str]]: + """Resolve stdio env values, supporting header-driven placeholders.""" + + if server.transport != MCPTransport.stdio or not server.env: + return None + + resolved_env: Dict[str, str] = {} + normalized_headers = {k.lower(): v for k, v in (raw_headers or {}).items()} + + for env_key, env_value in server.env.items(): + stripped_value = env_value.strip() + match = self._STDIO_ENV_TEMPLATE_PATTERN.match(stripped_value) + if match: + header_name = match.group(1) + header_value = normalized_headers.get(header_name.lower()) + if header_value is None: + continue + resolved_env[env_key] = header_value + else: + resolved_env[env_key] = env_value + + return resolved_env + def _create_mcp_client( self, server: MCPServer, mcp_auth_header: Optional[Union[str, Dict[str, str]]] = None, extra_headers: Optional[Dict[str, str]] = None, + stdio_env: Optional[Dict[str, str]] = None, ) -> MCPClient: """ Create an MCPClient instance for the given server. @@ -692,10 +722,13 @@ class MCPServerManager: # Handle stdio transport if transport == MCPTransport.stdio: # For stdio, we need to get the stdio config from the server + resolved_env = stdio_env if stdio_env is not None else server.env or {} stdio_config: Optional[MCPStdioConfig] = None if server.command and server.args is not None: stdio_config = MCPStdioConfig( - command=server.command, args=server.args, env=server.env or {} + command=server.command, + args=server.args, + env=resolved_env, ) return MCPClient( @@ -725,6 +758,7 @@ class MCPServerManager: mcp_auth_header: Optional[Union[str, Dict[str, str]]] = None, extra_headers: Optional[Dict[str, str]] = None, add_prefix: bool = True, + raw_headers: Optional[Dict[str, str]] = None, ) -> List[MCPTool]: """ Helper method to get tools from a single MCP server with prefixed names. @@ -751,10 +785,13 @@ class MCPServerManager: extra_headers = {} extra_headers.update(server.static_headers) + stdio_env = self._build_stdio_env(server, raw_headers) + client = self._create_mcp_client( server=server, mcp_auth_header=mcp_auth_header, extra_headers=extra_headers, + stdio_env=stdio_env, ) ## HANDLE OPENAPI TOOLS @@ -784,6 +821,7 @@ class MCPServerManager: mcp_auth_header: Optional[Union[str, Dict[str, str]]] = None, extra_headers: Optional[Dict[str, str]] = None, add_prefix: bool = True, + raw_headers: Optional[Dict[str, str]] = None, ) -> List[Prompt]: """ Helper method to get prompts from a single MCP server with prefixed names. @@ -807,10 +845,13 @@ class MCPServerManager: extra_headers = {} extra_headers.update(server.static_headers) + stdio_env = self._build_stdio_env(server, raw_headers) + client = self._create_mcp_client( server=server, mcp_auth_header=mcp_auth_header, extra_headers=extra_headers, + stdio_env=stdio_env, ) prompts = await client.list_prompts() @@ -833,6 +874,7 @@ class MCPServerManager: mcp_auth_header: Optional[Union[str, Dict[str, str]]] = None, extra_headers: Optional[Dict[str, str]] = None, add_prefix: bool = True, + raw_headers: Optional[Dict[str, str]] = None, ) -> List[Resource]: """Fetch available resources from a single MCP server.""" @@ -847,10 +889,13 @@ class MCPServerManager: extra_headers = {} extra_headers.update(server.static_headers) + stdio_env = self._build_stdio_env(server, raw_headers) + client = self._create_mcp_client( server=server, mcp_auth_header=mcp_auth_header, extra_headers=extra_headers, + stdio_env=stdio_env, ) resources = await client.list_resources() @@ -873,6 +918,7 @@ class MCPServerManager: mcp_auth_header: Optional[Union[str, Dict[str, str]]] = None, extra_headers: Optional[Dict[str, str]] = None, add_prefix: bool = True, + raw_headers: Optional[Dict[str, str]] = None, ) -> List[ResourceTemplate]: """Fetch available resource templates from a single MCP server.""" @@ -887,10 +933,13 @@ class MCPServerManager: extra_headers = {} extra_headers.update(server.static_headers) + stdio_env = self._build_stdio_env(server, raw_headers) + client = self._create_mcp_client( server=server, mcp_auth_header=mcp_auth_header, extra_headers=extra_headers, + stdio_env=stdio_env, ) resource_templates = await client.list_resource_templates() @@ -913,6 +962,7 @@ class MCPServerManager: url: AnyUrl, mcp_auth_header: Optional[Union[str, Dict[str, str]]] = None, extra_headers: Optional[Dict[str, str]] = None, + raw_headers: Optional[Dict[str, str]] = None, ) -> ReadResourceResult: """Read resource contents from a specific MCP server.""" @@ -924,10 +974,13 @@ class MCPServerManager: extra_headers = {} extra_headers.update(server.static_headers) + stdio_env = self._build_stdio_env(server, raw_headers) + client = self._create_mcp_client( server=server, mcp_auth_header=mcp_auth_header, extra_headers=extra_headers, + stdio_env=stdio_env, ) return await client.read_resource(url) @@ -939,6 +992,7 @@ class MCPServerManager: arguments: Optional[Dict[str, Any]] = None, mcp_auth_header: Optional[Union[str, Dict[str, str]]] = None, extra_headers: Optional[Dict[str, str]] = None, + raw_headers: Optional[Dict[str, str]] = None, ) -> GetPromptResult: """Fetch a specific prompt definition from a single MCP server.""" @@ -950,10 +1004,13 @@ class MCPServerManager: extra_headers = {} extra_headers.update(server.static_headers) + stdio_env = self._build_stdio_env(server, raw_headers) + client = self._create_mcp_client( server=server, mcp_auth_header=mcp_auth_header, extra_headers=extra_headers, + stdio_env=stdio_env, ) get_prompt_request_params = GetPromptRequestParams( @@ -1742,10 +1799,13 @@ class MCPServerManager: extra_headers = {} extra_headers.update(mcp_server.static_headers) + stdio_env = self._build_stdio_env(mcp_server, raw_headers) + client = self._create_mcp_client( server=mcp_server, mcp_auth_header=server_auth_header, extra_headers=extra_headers, + stdio_env=stdio_env, ) call_tool_params = MCPCallToolRequestParams( @@ -2067,7 +2127,7 @@ class MCPServerManager: async def health_check_server( self, server_id: str, mcp_auth_header: Optional[str] = None - ) -> Dict[str, Any]: + ) -> LiteLLM_MCPServerTable: """ Perform a health check on a specific MCP server. @@ -2078,206 +2138,180 @@ class MCPServerManager: Returns: Dict containing health check results """ - import time from datetime import datetime server = self.get_mcp_server_by_id(server_id) if not server: - return { - "server_id": server_id, - "server_name": None, - "status": "unknown", - "error": "Server not found", - "last_health_check": datetime.now().isoformat(), - "response_time_ms": None, - } - - start_time = time.time() - try: - # Try to get tools from the server as a health check - tools = await self._get_tools_from_server(server, mcp_auth_header) - response_time = (time.time() - start_time) * 1000 - - return { - "server_id": server_id, - "server_name": server.name, - "status": "healthy", - "tools_count": len(tools), - "last_health_check": datetime.now().isoformat(), - "response_time_ms": round(response_time, 2), - "error": None, - } - except Exception as e: - response_time = (time.time() - start_time) * 1000 - error_message = str(e) - - return { - "server_id": server_id, - "server_name": server.name, - "status": "unhealthy", - "last_health_check": datetime.now().isoformat(), - "response_time_ms": round(response_time, 2), - "error": error_message, - } - - async def health_check_all_servers( - self, mcp_auth_header: Optional[str] = None - ) -> Dict[str, Any]: - """ - Perform health checks on all MCP servers. - - Args: - mcp_auth_header: Optional authentication header for the MCP servers - - Returns: - Dict containing health check results for all servers - """ - all_servers = self.get_registry() - results = {} - - for server_id, server in all_servers.items(): - results[server_id] = await self.health_check_server( - server_id, mcp_auth_header + verbose_logger.warning(f"MCP Server {server_id} not found") + return LiteLLM_MCPServerTable( + server_id=server_id, + server_name=None, + transport=MCPTransport.http, # Default transport for not found servers + status="unknown", + health_check_error="Server not found", + last_health_check=datetime.now(), ) - return results + status: Literal["healthy", "unhealthy", "unknown"] = "unknown" + health_check_error = None - async def health_check_allowed_servers( - self, - user_api_key_auth: Optional[UserAPIKeyAuth] = None, - mcp_auth_header: Optional[str] = None, - ) -> Dict[str, Any]: - """ - Perform health checks on all MCP servers that the user has access to. + # Check if we should skip health check based on auth configuration + should_skip_health_check = False - Args: - user_api_key_auth: User authentication info for access control - mcp_auth_header: Optional authentication header for the MCP servers + # Skip if auth_type is oauth2 + if server.auth_type == MCPAuth.oauth2: + should_skip_health_check = True + # Skip if auth_type is not none and authentication_token is missing + elif ( + server.auth_type + and server.auth_type != MCPAuth.none + and not server.authentication_token + ): + should_skip_health_check = True - Returns: - Dict containing health check results for accessible servers - """ - # Get allowed servers for the user - allowed_server_ids = await self.get_allowed_mcp_servers(user_api_key_auth) + if not should_skip_health_check: + extra_headers = {} + if server.static_headers: + extra_headers.update(server.static_headers) - # Perform health checks on allowed servers - results = {} - for server_id in allowed_server_ids: - results[server_id] = await self.health_check_server( - server_id, mcp_auth_header + client = self._create_mcp_client( + server=server, + mcp_auth_header=None, + extra_headers=extra_headers, + stdio_env=None, ) - return results + try: + + async def _noop(session): + return "ok" + + # Add timeout wrapper to prevent hanging + await asyncio.wait_for(client.run_with_session(_noop), timeout=10.0) + status = "healthy" + except asyncio.TimeoutError: + health_check_error = "Health check timed out after 10 seconds" + status = "unhealthy" + except Exception as e: + health_check_error = str(e) + status = "unhealthy" + + return LiteLLM_MCPServerTable( + server_id=server.server_id, + server_name=server.server_name, + alias=server.alias, + description=( + server.mcp_info.get("description") if server.mcp_info else None + ), + url=server.url, + transport=server.transport, + auth_type=server.auth_type, + created_at=datetime.now(), + updated_at=datetime.now(), + teams=[], + mcp_access_groups=server.access_groups or [], + allowed_tools=server.allowed_tools or [], + extra_headers=server.extra_headers or [], + mcp_info=server.mcp_info, + static_headers=server.static_headers, + status=status, + last_health_check=datetime.now(), + health_check_error=health_check_error, + command=getattr(server, "command", None), + args=getattr(server, "args", None) or [], + env=getattr(server, "env", None) or {}, + ) async def get_all_mcp_servers_with_health_and_teams( self, user_api_key_auth: Optional[UserAPIKeyAuth] = None, - include_health: bool = True, + server_ids: Optional[List[str]] = None, ) -> List[LiteLLM_MCPServerTable]: """ Get all MCP servers that the user has access to, with health status and team information. Args: user_api_key_auth: User authentication info for access control - include_health: Whether to include health check information + server_ids: Optional list of server IDs to filter. If provided, only these servers + will be checked (subject to access control). If None, all accessible servers are checked. Returns: List of MCP server objects with health and team data """ - from litellm.proxy._experimental.mcp_server.db import ( - get_all_mcp_servers, - get_mcp_servers, - ) - from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view - from litellm.proxy.proxy_server import prisma_client # Get allowed server IDs allowed_server_ids = await self.get_allowed_mcp_servers(user_api_key_auth) - # Get servers from database + # Filter by requested server_ids if provided + if server_ids: + # Only check servers that are both requested AND accessible + target_server_ids = [sid for sid in server_ids if sid in allowed_server_ids] + else: + # Check all accessible servers + target_server_ids = allowed_server_ids + + # Run health checks concurrently + tasks = [self.health_check_server(server_id) for server_id in target_server_ids] + results = await asyncio.gather(*tasks) + + # Filter out None results (servers that were not found) + list_mcp_servers = [server for server in results if server is not None] + + return list_mcp_servers + + async def get_all_allowed_mcp_servers( + self, + user_api_key_auth: Optional[UserAPIKeyAuth] = None, + ) -> List[LiteLLM_MCPServerTable]: + """ + Get all MCP servers that the user has access to. + + Args: + user_api_key_auth: User authentication info for access control + + Returns: + List of MCP server objects without health status + """ + from datetime import datetime + + # Get allowed server IDs + allowed_server_ids = await self.get_allowed_mcp_servers(user_api_key_auth) + list_mcp_servers: List[LiteLLM_MCPServerTable] = [] - if prisma_client is not None: - list_mcp_servers = await get_mcp_servers(prisma_client, allowed_server_ids) - # If admin, also get all servers from database - if user_api_key_auth and _user_has_admin_view(user_api_key_auth): - all_mcp_servers = await get_all_mcp_servers(prisma_client) - for server in all_mcp_servers: - if server.server_id not in allowed_server_ids: - list_mcp_servers.append(server) + for server_id in allowed_server_ids: + server = self.get_mcp_server_by_id(server_id) + if not server: + verbose_logger.warning(f"MCP Server {server_id} not found in registry") + continue - # Add config.yaml servers - for _server_id, _server_config in self.config_mcp_servers.items(): - if _server_id in allowed_server_ids: - list_mcp_servers.append( - LiteLLM_MCPServerTable( - **{ - **_server_config.model_dump(), - "created_at": datetime.datetime.now(), - "updated_at": datetime.datetime.now(), - "description": ( - _server_config.mcp_info.get("description") - if _server_config.mcp_info - else None - ), - "allowed_tools": _server_config.allowed_tools or [], - "mcp_info": _server_config.mcp_info, - "mcp_access_groups": _server_config.access_groups or [], - "extra_headers": _server_config.extra_headers or [], - "command": getattr(_server_config, "command", None), - "args": getattr(_server_config, "args", None) or [], - "env": getattr(_server_config, "env", None) or {}, - } - ) - ) - - # Get team information for non-admin users - server_to_teams_map: Dict[str, List[Dict[str, str]]] = {} - if ( - user_api_key_auth - and not _user_has_admin_view(user_api_key_auth) - and prisma_client is not None - ): - teams = await prisma_client.db.litellm_teamtable.find_many( - include={"object_permission": True} + # Build LiteLLM_MCPServerTable without health check + mcp_server_table = LiteLLM_MCPServerTable( + server_id=server.server_id, + server_name=server.server_name, + alias=server.alias, + description=( + server.mcp_info.get("description") if server.mcp_info else None + ), + url=server.url, + transport=server.transport, + auth_type=server.auth_type, + created_at=datetime.now(), + updated_at=datetime.now(), + teams=[], + mcp_access_groups=server.access_groups or [], + allowed_tools=server.allowed_tools or [], + extra_headers=server.extra_headers or [], + mcp_info=server.mcp_info, + static_headers=server.static_headers, + status=None, # No health check performed + last_health_check=None, # No health check performed + health_check_error=None, + command=getattr(server, "command", None), + args=getattr(server, "args", None) or [], + env=getattr(server, "env", None) or {}, ) - - user_teams = [] - for team in teams: - if team.members_with_roles: - for member in team.members_with_roles: - if ( - "user_id" in member - and member["user_id"] is not None - and member["user_id"] == user_api_key_auth.user_id - ): - user_teams.append(team) - - # Create a mapping of server_id to teams that have access to it - for team in user_teams: - if team.object_permission and team.object_permission.mcp_servers: - for server_id in team.object_permission.mcp_servers: - if server_id not in server_to_teams_map: - server_to_teams_map[server_id] = [] - server_to_teams_map[server_id].append( - { - "team_id": team.team_id, - "team_alias": team.team_alias, - "organization_id": team.organization_id, - } - ) - - ## mark invalid servers w/ reason for being invalid - valid_server_ids = self.get_all_mcp_server_ids() - for server in list_mcp_servers: - if server.server_id not in valid_server_ids: - server.status = "unhealthy" - ## try adding server to registry to get error - try: - await self.add_update_server(server) - except Exception as e: - server.health_check_error = str(e) - server.health_check_error = "Server is not in in memory registry yet. This could be a temporary sync issue." + list_mcp_servers.append(mcp_server_table) return list_mcp_servers diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index 032331ece02..4c947b99ba3 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -1,5 +1,4 @@ import importlib -import traceback from typing import Dict, List, Optional, Union from fastapi import APIRouter, Depends, Query, Request @@ -71,12 +70,17 @@ if MCP_AVAILABLE: for tool in tools ] - async def _get_tools_for_single_server(server, server_auth_header): + async def _get_tools_for_single_server( + server, + server_auth_header, + raw_headers: Optional[Dict[str, str]] = None, + ): """Helper function to get tools for a single server.""" tools = await global_mcp_server_manager._get_tools_from_server( server=server, mcp_auth_header=server_auth_header, add_prefix=False, + raw_headers=raw_headers, ) # Filter tools based on allowed_tools configuration @@ -122,6 +126,7 @@ if MCP_AVAILABLE: try: # Extract auth headers from request headers = request.headers + raw_headers_from_request = dict(headers) mcp_auth_header = MCPRequestHandler._get_mcp_auth_header_from_headers( headers ) @@ -148,7 +153,7 @@ if MCP_AVAILABLE: try: list_tools_result = await _get_tools_for_single_server( - server, server_auth_header + server, server_auth_header, raw_headers_from_request ) except Exception as e: verbose_logger.exception( @@ -169,7 +174,7 @@ if MCP_AVAILABLE: try: tools_result = await _get_tools_for_single_server( - server, server_auth_header + server, server_auth_header, raw_headers_from_request ) list_tools_result.extend(tools_result) except Exception as e: @@ -232,13 +237,13 @@ if MCP_AVAILABLE: # but they weren't being extracted and passed to call_mcp_tool. # This fix ensures auth headers are properly extracted from the HTTP request # and passed through to the MCP server for authentication. + headers = request.headers + raw_headers_from_request = dict(headers) mcp_auth_header = MCPRequestHandler._get_mcp_auth_header_from_headers( - request.headers + headers ) mcp_server_auth_headers = ( - MCPRequestHandler._get_mcp_server_auth_headers_from_headers( - request.headers - ) + MCPRequestHandler._get_mcp_server_auth_headers_from_headers(headers) ) # Add extracted headers to data dict to pass to call_mcp_tool @@ -246,6 +251,7 @@ if MCP_AVAILABLE: data["mcp_auth_header"] = mcp_auth_header if mcp_server_auth_headers: data["mcp_server_auth_headers"] = mcp_server_auth_headers + data["raw_headers"] = raw_headers_from_request result = await call_mcp_tool(**data) return result @@ -300,6 +306,7 @@ if MCP_AVAILABLE: operation, mcp_auth_header: Optional[Union[str, Dict[str, str]]] = None, oauth2_headers: Optional[Dict[str, str]] = None, + raw_headers: Optional[Dict[str, str]] = None, ): """ Common helper to create MCP client, execute operation, and ensure proper cleanup. @@ -312,33 +319,43 @@ if MCP_AVAILABLE: Operation result or error response """ try: + server_model = MCPServer( + server_id=request.server_id or "", + name=request.alias or request.server_name or "", + url=request.url, + transport=request.transport, + auth_type=request.auth_type, + mcp_info=request.mcp_info, + command=request.command, + args=request.args, + env=request.env, + ) + + stdio_env = global_mcp_server_manager._build_stdio_env( + server_model, raw_headers + ) + client = global_mcp_server_manager._create_mcp_client( - server=MCPServer( - server_id=request.server_id or "", - name=request.alias or request.server_name or "", - url=request.url, - transport=request.transport, - auth_type=request.auth_type, - mcp_info=request.mcp_info, - ), + server=server_model, mcp_auth_header=mcp_auth_header, extra_headers=oauth2_headers, + stdio_env=stdio_env, ) return await operation(client) except Exception as e: verbose_logger.error(f"Error in MCP operation: {e}", exc_info=True) - stack_trace = traceback.format_exc() return { "status": "error", - "message": f"An internal error has occurred: {str(e)}", - "stack_trace": stack_trace, + "message": "An internal error has occurred while testing the MCP server.", } - @router.post("/test/connection") + @router.post("/test/connection", dependencies=[Depends(user_api_key_auth)]) async def test_connection( - request: NewMCPServerRequest, + request: Request, + new_mcp_server_request: NewMCPServerRequest, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ Test if we can connect to the provided MCP server before adding it @@ -351,7 +368,11 @@ if MCP_AVAILABLE: await client.run_with_session(_noop) return {"status": "ok"} - return await _execute_with_mcp_client(request, _test_connection_operation) + return await _execute_with_mcp_client( + new_mcp_server_request, + _test_connection_operation, + raw_headers=dict(request.headers), + ) @router.post("/test/tools/list") async def test_tools_list( @@ -405,4 +426,5 @@ if MCP_AVAILABLE: _list_tools_operation, mcp_auth_header=mcp_auth_header, oauth2_headers=oauth2_headers, + raw_headers=dict(request.headers), ) diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index bdff60c932b..e00fdbfb930 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -775,6 +775,7 @@ if MCP_AVAILABLE: mcp_auth_header=server_auth_header, extra_headers=extra_headers, add_prefix=add_prefix, + raw_headers=raw_headers, ) filtered_tools = filter_tools_by_allowed_tools(tools, server) @@ -854,6 +855,7 @@ if MCP_AVAILABLE: mcp_auth_header=server_auth_header, extra_headers=extra_headers, add_prefix=add_prefix, + raw_headers=raw_headers, ) all_prompts.extend(prompts) @@ -912,6 +914,7 @@ if MCP_AVAILABLE: mcp_auth_header=server_auth_header, extra_headers=extra_headers, add_prefix=add_prefix, + raw_headers=raw_headers, ) all_resources.extend(resources) @@ -969,6 +972,7 @@ if MCP_AVAILABLE: mcp_auth_header=server_auth_header, extra_headers=extra_headers, add_prefix=add_prefix, + raw_headers=raw_headers, ) ) all_resource_templates.extend(resource_templates) @@ -1392,6 +1396,7 @@ if MCP_AVAILABLE: arguments=arguments, mcp_auth_header=server_auth_header, extra_headers=extra_headers, + raw_headers=raw_headers, ) async def mcp_read_resource( @@ -1440,6 +1445,7 @@ if MCP_AVAILABLE: url=url, mcp_auth_header=server_auth_header, extra_headers=extra_headers, + raw_headers=raw_headers, ) def _get_standard_logging_mcp_tool_call( diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 06067035c18..a94b4d4a077 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -33,6 +33,7 @@ from litellm.types.router import RouterErrors, UpdateRouterConfig from litellm.types.secret_managers.main import KeyManagementSystem from litellm.types.utils import ( CallTypes, + CostBreakdown, EmbeddingResponse, GenericBudgetConfigType, ImageResponse, @@ -388,6 +389,8 @@ class LiteLLMRoutes(enum.Enum): litellm_native_routes = [ "/rag/ingest", "/v1/rag/ingest", + "/rag/query", + "/v1/rag/query", ] anthropic_routes = [ @@ -2149,6 +2152,7 @@ class UserAPIKeyAuth( user_rpm_limit: Optional[int] = None user_email: Optional[str] = None request_route: Optional[str] = None + user: Optional[Any] = None # Expanded user object when expand=user is used model_config = ConfigDict(arbitrary_types_allowed=True) @@ -2736,6 +2740,9 @@ class SpendLogsMetadata(TypedDict): litellm_overhead_time_ms: Optional[ float ] # LiteLLM overhead time in milliseconds + cost_breakdown: Optional[ + CostBreakdown + ] # Detailed cost breakdown (input_cost, output_cost, margin, discount, etc.) class SpendLogsPayload(TypedDict): diff --git a/litellm/proxy/auth/auth_exception_handler.py b/litellm/proxy/auth/auth_exception_handler.py index 2b9c4cdce6e..9c306acd2c6 100644 --- a/litellm/proxy/auth/auth_exception_handler.py +++ b/litellm/proxy/auth/auth_exception_handler.py @@ -2,7 +2,6 @@ Handles Authentication Errors """ -import asyncio from typing import TYPE_CHECKING, Any, Optional, Union from fastapi import HTTPException, Request, status @@ -90,15 +89,17 @@ class UserAPIKeyAuthExceptionHandler: api_key=api_key, request_route=route, ) - asyncio.create_task( - proxy_logging_obj.post_call_failure_hook( - request_data=request_data, - original_exception=e, - user_api_key_dict=user_api_key_dict, - error_type=ProxyErrorTypes.auth_error, - route=route, - ) + # Allow callbacks to transform the error response + transformed_exception = await proxy_logging_obj.post_call_failure_hook( + request_data=request_data, + original_exception=e, + user_api_key_dict=user_api_key_dict, + error_type=ProxyErrorTypes.auth_error, + route=route, ) + # Use transformed exception if callback returned one, otherwise use original + if transformed_exception is not None: + e = transformed_exception if isinstance(e, litellm.BudgetExceededError): raise ProxyException( diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index 66973da7ee4..24f53b16bee 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -293,6 +293,9 @@ class RouteChecks: if route in LiteLLMRoutes.anthropic_routes.value: return True + + if route in LiteLLMRoutes.google_routes.value: + return True if RouteChecks.check_route_access( route=route, allowed_routes=LiteLLMRoutes.mcp_routes.value @@ -315,13 +318,28 @@ class RouteChecks: ): return True + # Check for Google routes with placeholders like "/v1beta/models/{model_name}:generateContent" + for google_route in LiteLLMRoutes.google_routes.value: + if "{" in google_route: + if RouteChecks._route_matches_pattern( + route=route, pattern=google_route + ): + return True + + # Check for Anthropic routes with placeholders + for anthropic_route in LiteLLMRoutes.anthropic_routes.value: + if "{" in anthropic_route: + if RouteChecks._route_matches_pattern( + route=route, pattern=anthropic_route + ): + return True + if RouteChecks._is_azure_openai_route(route=route): return True for _llm_passthrough_route in LiteLLMRoutes.mapped_pass_through_routes.value: if _llm_passthrough_route in route: return True - return False @staticmethod diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index f798d218f1d..34049a44c8c 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -179,24 +179,26 @@ async def create_streaming_response( def _get_cost_breakdown_from_logging_obj( litellm_logging_obj: Optional[LiteLLMLoggingObj], -) -> Tuple[Optional[float], Optional[float]]: +) -> Tuple[Optional[float], Optional[float], Optional[float], Optional[float]]: """ - Extract discount information from logging object's cost breakdown. + Extract discount and margin information from logging object's cost breakdown. Returns: - Tuple of (original_cost, discount_amount) + Tuple of (original_cost, discount_amount, margin_total_amount, margin_percent) """ if not litellm_logging_obj or not hasattr(litellm_logging_obj, "cost_breakdown"): - return None, None + return None, None, None, None cost_breakdown = litellm_logging_obj.cost_breakdown if not cost_breakdown: - return None, None + return None, None, None, None original_cost = cost_breakdown.get("original_cost") discount_amount = cost_breakdown.get("discount_amount") + margin_total_amount = cost_breakdown.get("margin_total_amount") + margin_percent = cost_breakdown.get("margin_percent") - return original_cost, discount_amount + return original_cost, discount_amount, margin_total_amount, margin_percent class ProxyBaseLLMRequestProcessing: @@ -224,8 +226,8 @@ class ProxyBaseLLMRequestProcessing: exclude_values = {"", None, "None"} hidden_params = hidden_params or {} - # Extract discount info from cost_breakdown if available - original_cost, discount_amount = _get_cost_breakdown_from_logging_obj( + # Extract discount and margin info from cost_breakdown if available + original_cost, discount_amount, margin_total_amount, margin_percent = _get_cost_breakdown_from_logging_obj( litellm_logging_obj=litellm_logging_obj ) @@ -258,6 +260,12 @@ class ProxyBaseLLMRequestProcessing: "x-litellm-response-cost-discount-amount": ( str(discount_amount) if discount_amount is not None else None ), + "x-litellm-response-cost-margin-amount": ( + str(margin_total_amount) if margin_total_amount is not None else None + ), + "x-litellm-response-cost-margin-percent": ( + str(margin_percent) if margin_percent is not None else None + ), "x-litellm-key-tpm-limit": str(user_api_key_dict.tpm_limit), "x-litellm-key-rpm-limit": str(user_api_key_dict.rpm_limit), "x-litellm-key-max-budget": str(user_api_key_dict.max_budget), @@ -786,11 +794,15 @@ class ProxyBaseLLMRequestProcessing: verbose_proxy_logger.exception( f"litellm.proxy.proxy_server._handle_llm_api_exception(): Exception occured - {str(e)}" ) - await proxy_logging_obj.post_call_failure_hook( + # Allow callbacks to transform the error response + transformed_exception = await proxy_logging_obj.post_call_failure_hook( user_api_key_dict=user_api_key_dict, original_exception=e, request_data=self.data, ) + # Use transformed exception if callback returned one, otherwise use original + if transformed_exception is not None: + e = transformed_exception litellm_debug_info = getattr(e, "litellm_debug_info", "") verbose_proxy_logger.debug( "\033[1;31mAn error occurred: %s %s\n\n Debug this by setting `--debug`, e.g. `litellm --model gpt-3.5-turbo --debug`", @@ -970,11 +982,15 @@ class ProxyBaseLLMRequestProcessing: str(e) ) ) - await proxy_logging_obj.post_call_failure_hook( + # Allow callbacks to transform the error response + transformed_exception = await proxy_logging_obj.post_call_failure_hook( user_api_key_dict=user_api_key_dict, original_exception=e, request_data=request_data, ) + # Use transformed exception if callback returned one, otherwise use original + if transformed_exception is not None: + e = transformed_exception verbose_proxy_logger.debug( f"\033[1;31mAn error occurred: {e}\n\n Debug this by setting `--debug`, e.g. `litellm --model gpt-3.5-turbo --debug`" ) diff --git a/litellm/proxy/common_utils/http_parsing_utils.py b/litellm/proxy/common_utils/http_parsing_utils.py index 259755f5ef9..1d94b10f6a4 100644 --- a/litellm/proxy/common_utils/http_parsing_utils.py +++ b/litellm/proxy/common_utils/http_parsing_utils.py @@ -329,8 +329,8 @@ def populate_request_with_path_params( request_data: dict, request: Request ) -> dict: """ - Copy FastAPI path params into the request payload so downstream checks - (e.g. vector store RBAC) see them the same way as body params. + Copy FastAPI path params and query params into the request payload so downstream checks + (e.g. vector store RBAC, organization RBAC) see them the same way as body params. Since path_params may not be available during dependency injection, we parse the URL path directly for known patterns. @@ -340,8 +340,15 @@ def populate_request_with_path_params( request: The FastAPI Request object Returns: - dict: Updated request_data with path parameters added + dict: Updated request_data with path parameters and query parameters added """ + # Add query parameters to request_data (for GET requests, etc.) + query_params = _safe_get_request_query_params(request) + if query_params: + for key, value in query_params.items(): + # Don't overwrite existing values from request body + request_data.setdefault(key, value) + # Try to get path_params if available (sometimes populated by FastAPI) path_params = getattr(request, "path_params", None) if isinstance(path_params, dict) and path_params: diff --git a/litellm/proxy/discovery_endpoints/ui_discovery_endpoints.py b/litellm/proxy/discovery_endpoints/ui_discovery_endpoints.py index 9aaa2fb8381..cbe28849b1e 100644 --- a/litellm/proxy/discovery_endpoints/ui_discovery_endpoints.py +++ b/litellm/proxy/discovery_endpoints/ui_discovery_endpoints.py @@ -18,9 +18,11 @@ async def get_ui_config(): from litellm.proxy.auth.auth_utils import _has_user_setup_sso auto_redirect_ui_login_to_sso = os.getenv("AUTO_REDIRECT_UI_LOGIN_TO_SSO", "true").lower() == "true" + admin_ui_disabled = os.getenv("DISABLE_ADMIN_UI", "false").lower() == "true" return UiDiscoveryEndpoints( server_root_path=get_server_root_path(), proxy_base_url=get_proxy_base_url(), auto_redirect_to_sso=_has_user_setup_sso() and auto_redirect_ui_login_to_sso, + admin_ui_disabled=admin_ui_disabled, ) diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index 62c997659bd..c8cef6e2790 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -449,6 +449,12 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): prepared_request.headers, ) + event_type = ( + GuardrailEventHooks.pre_call + if source == "INPUT" + else GuardrailEventHooks.post_call + ) + try: httpx_response = await self.async_handler.post( url=prepared_request.url, @@ -469,6 +475,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): start_time=start_time.timestamp(), end_time=datetime.now().timestamp(), duration=(datetime.now() - start_time).total_seconds(), + event_type=event_type, ) # Re-raise the exception to maintain existing behavior raise @@ -486,6 +493,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): start_time=start_time.timestamp(), end_time=datetime.now().timestamp(), duration=(datetime.now() - start_time).total_seconds(), + event_type=event_type, ) ######################################################### if httpx_response.status_code == 200: @@ -605,10 +613,10 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): """ Only raise exception for "BLOCKED" actions, not for "ANONYMIZED" actions. - If `self.mask_request_content` or `self.mask_response_content` is set to `True`, + If `self.mask_request_content` or `self.mask_response_content` is set to `True`, then use the output from the guardrail to mask the request or response content. - - However, even with masking enabled, content with action="BLOCKED" should still + + However, even with masking enabled, content with action="BLOCKED" should still raise an exception, only content with action="ANONYMIZED" should be masked. """ @@ -731,9 +739,9 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): ######################################################### ########## 1. Make the Bedrock API request ########## ######################################################### - bedrock_guardrail_response: Optional[Union[BedrockGuardrailResponse, str]] = ( - None - ) + bedrock_guardrail_response: Optional[ + Union[BedrockGuardrailResponse, str] + ] = None try: bedrock_guardrail_response = await self.make_bedrock_api_request( source="INPUT", messages=filtered_messages, request_data=data @@ -803,9 +811,9 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): ######################################################### ########## 1. Make the Bedrock API request ########## ######################################################### - bedrock_guardrail_response: Optional[Union[BedrockGuardrailResponse, str]] = ( - None - ) + bedrock_guardrail_response: Optional[ + Union[BedrockGuardrailResponse, str] + ] = None try: bedrock_guardrail_response = await self.make_bedrock_api_request( source="INPUT", messages=filtered_messages, request_data=data diff --git a/litellm/proxy/guardrails/guardrail_hooks/dynamoai/dynamoai.py b/litellm/proxy/guardrails/guardrail_hooks/dynamoai/dynamoai.py index 6915286a2d7..59381149809 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/dynamoai/dynamoai.py +++ b/litellm/proxy/guardrails/guardrail_hooks/dynamoai/dynamoai.py @@ -97,6 +97,7 @@ class DynamoAIGuardrails(CustomGuardrail): async def _call_dynamoai_guardrails( self, messages: List[Dict[str, Any]], + event_type: GuardrailEventHooks, text_type: str = "input", request_data: Optional[dict] = None, ) -> DynamoAIResponse: @@ -157,6 +158,7 @@ class DynamoAIGuardrails(CustomGuardrail): start_time=start_time.timestamp(), end_time=end_time.timestamp(), duration=duration, + event_type=event_type, ) return response_json @@ -177,6 +179,7 @@ class DynamoAIGuardrails(CustomGuardrail): start_time=start_time.timestamp(), end_time=end_time.timestamp(), duration=duration, + event_type=event_type, ) raise @@ -332,6 +335,7 @@ class DynamoAIGuardrails(CustomGuardrail): messages=_messages, text_type="input", request_data=data, + event_type=GuardrailEventHooks.pre_call, ) verbose_proxy_logger.debug( @@ -380,6 +384,7 @@ class DynamoAIGuardrails(CustomGuardrail): messages=_messages, text_type="input", request_data=data, + event_type=GuardrailEventHooks.during_call, ) verbose_proxy_logger.debug( @@ -460,6 +465,7 @@ class DynamoAIGuardrails(CustomGuardrail): messages=dynamoai_messages, text_type="output", request_data=data, + event_type=GuardrailEventHooks.post_call, ) verbose_proxy_logger.debug( diff --git a/litellm/proxy/guardrails/guardrail_hooks/ibm_guardrails/ibm_detector.py b/litellm/proxy/guardrails/guardrail_hooks/ibm_guardrails/ibm_detector.py index 55fa17c21e7..2fc05213640 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/ibm_guardrails/ibm_detector.py +++ b/litellm/proxy/guardrails/guardrail_hooks/ibm_guardrails/ibm_detector.py @@ -108,6 +108,7 @@ class IBMGuardrailDetector(CustomGuardrail): async def _call_detector_server( self, contents: List[str], + event_type: GuardrailEventHooks, request_data: Optional[dict] = None, ) -> List[List[IBMDetectorDetection]]: """ @@ -142,7 +143,6 @@ class IBMGuardrailDetector(CustomGuardrail): ) try: - response = await self.async_handler.post( url=self.api_url, json=payload, @@ -172,6 +172,7 @@ class IBMGuardrailDetector(CustomGuardrail): start_time=start_time.timestamp(), end_time=end_time.timestamp(), duration=duration, + event_type=event_type, ) return response_json @@ -192,6 +193,7 @@ class IBMGuardrailDetector(CustomGuardrail): start_time=start_time.timestamp(), end_time=end_time.timestamp(), duration=duration, + event_type=event_type, ) raise @@ -199,6 +201,7 @@ class IBMGuardrailDetector(CustomGuardrail): async def _call_orchestrator( self, content: str, + event_type: GuardrailEventHooks, request_data: Optional[dict] = None, ) -> List[IBMDetectorDetection]: """ @@ -258,6 +261,7 @@ class IBMGuardrailDetector(CustomGuardrail): start_time=start_time.timestamp(), end_time=end_time.timestamp(), duration=duration, + event_type=event_type, ) return response_json.get("detections", []) @@ -278,6 +282,7 @@ class IBMGuardrailDetector(CustomGuardrail): start_time=start_time.timestamp(), end_time=end_time.timestamp(), duration=duration, + event_type=event_type, ) raise @@ -472,6 +477,7 @@ class IBMGuardrailDetector(CustomGuardrail): result = await self._call_detector_server( contents=contents_to_check, request_data=data, + event_type=GuardrailEventHooks.pre_call, ) verbose_proxy_logger.debug( @@ -500,6 +506,7 @@ class IBMGuardrailDetector(CustomGuardrail): orchestrator_result = await self._call_orchestrator( content=content, request_data=data, + event_type=GuardrailEventHooks.pre_call, ) verbose_proxy_logger.debug( @@ -557,6 +564,7 @@ class IBMGuardrailDetector(CustomGuardrail): result = await self._call_detector_server( contents=contents_to_check, request_data=data, + event_type=GuardrailEventHooks.during_call, ) verbose_proxy_logger.debug( @@ -585,6 +593,7 @@ class IBMGuardrailDetector(CustomGuardrail): orchestrator_result = await self._call_orchestrator( content=content, request_data=data, + event_type=GuardrailEventHooks.during_call, ) verbose_proxy_logger.debug( @@ -673,6 +682,7 @@ class IBMGuardrailDetector(CustomGuardrail): result = await self._call_detector_server( contents=contents_to_check, request_data=data, + event_type=GuardrailEventHooks.post_call, ) verbose_proxy_logger.debug( @@ -702,6 +712,7 @@ class IBMGuardrailDetector(CustomGuardrail): orchestrator_result = await self._call_orchestrator( content=content, request_data=data, + event_type=GuardrailEventHooks.post_call, ) verbose_proxy_logger.debug( diff --git a/litellm/proxy/guardrails/guardrail_hooks/javelin/javelin.py b/litellm/proxy/guardrails/guardrail_hooks/javelin/javelin.py index 6d4ed089818..953275acf14 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/javelin/javelin.py +++ b/litellm/proxy/guardrails/guardrail_hooks/javelin/javelin.py @@ -83,6 +83,7 @@ class JavelinGuardrail(CustomGuardrail): async def call_javelin_guard( self, request: JavelinGuardRequest, + event_type: GuardrailEventHooks, ) -> JavelinGuardResponse: """ Call the Javelin guard API. @@ -158,6 +159,7 @@ class JavelinGuardrail(CustomGuardrail): start_time=start_time.timestamp(), end_time=datetime.now().timestamp(), duration=(datetime.now() - start_time).total_seconds(), + event_type=event_type, ) async def async_pre_call_hook( @@ -208,7 +210,9 @@ class JavelinGuardrail(CustomGuardrail): config=self.config if self.config else {}, ) - javelin_response = await self.call_javelin_guard(request=javelin_guard_request) + javelin_response = await self.call_javelin_guard( + request=javelin_guard_request, event_type=GuardrailEventHooks.pre_call + ) assessments = javelin_response.get("assessments", []) reject_prompt = "" diff --git a/litellm/proxy/guardrails/guardrail_hooks/lakera_ai_v2.py b/litellm/proxy/guardrails/guardrail_hooks/lakera_ai_v2.py index 6d98866eadf..732331349e0 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/lakera_ai_v2.py +++ b/litellm/proxy/guardrails/guardrail_hooks/lakera_ai_v2.py @@ -70,6 +70,7 @@ class LakeraAIGuardrail(CustomGuardrail): self, messages: List[AllMessageValues], request_data: Dict, + event_type: GuardrailEventHooks, ) -> Tuple[LakeraAIResponse, Dict]: """ Call the Lakera AI v2 guard API. @@ -128,6 +129,7 @@ class LakeraAIGuardrail(CustomGuardrail): end_time=datetime.now().timestamp(), duration=(datetime.now() - start_time).total_seconds(), masked_entity_count=masked_entity_count, + event_type=event_type, ) def _mask_pii_in_messages( @@ -214,6 +216,7 @@ class LakeraAIGuardrail(CustomGuardrail): lakera_guardrail_response, masked_entity_count = await self.call_v2_guard( messages=new_messages, request_data=data, + event_type=GuardrailEventHooks.pre_call, ) ######################################################### @@ -279,6 +282,7 @@ class LakeraAIGuardrail(CustomGuardrail): lakera_guardrail_response, masked_entity_count = await self.call_v2_guard( messages=new_messages, request_data=data, + event_type=GuardrailEventHooks.during_call, ) ######################################################### diff --git a/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py b/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py index 51136c29eca..a12eb2486d2 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py +++ b/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py @@ -295,7 +295,9 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase): filters = ( list(filter_results.values()) if isinstance(filter_results, dict) - else filter_results if isinstance(filter_results, list) else [] + else filter_results + if isinstance(filter_results, list) + else [] ) # Prefer sanitized text from deidentifyResult if present @@ -327,6 +329,7 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase): start_time: Optional[float] = None, end_time: Optional[float] = None, duration: Optional[float] = None, + event_type: Optional[GuardrailEventHooks] = None, ): """ Override to store only the Model Armor API response, not the entire data dict. @@ -351,6 +354,7 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase): duration=duration, start_time=start_time, end_time=end_time, + event_type=event_type, ) return response diff --git a/litellm/proxy/guardrails/guardrail_hooks/noma/noma.py b/litellm/proxy/guardrails/guardrail_hooks/noma/noma.py index a0ea90ccf21..1794751a08c 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/noma/noma.py +++ b/litellm/proxy/guardrails/guardrail_hooks/noma/noma.py @@ -119,9 +119,7 @@ class NomaGuardrail(CustomGuardrail): self.api_base = api_base or os.environ.get( "NOMA_API_BASE", NomaGuardrail._DEFAULT_API_BASE ) - self.application_id = application_id or os.environ.get( - "NOMA_APPLICATION_ID" - ) + self.application_id = application_id or os.environ.get("NOMA_APPLICATION_ID") self.default_application_id = "litellm" if monitor_mode is None: @@ -163,6 +161,7 @@ class NomaGuardrail(CustomGuardrail): self, request_data: dict, user_auth: UserAPIKeyAuth, + event_type: Optional[GuardrailEventHooks] = None, ) -> Optional[str]: """Shared logic for processing user message checks""" start_time = datetime.now() @@ -213,6 +212,7 @@ class NomaGuardrail(CustomGuardrail): start_time=start_time.timestamp(), end_time=end_time.timestamp(), duration=duration, + event_type=event_type, ) if self.monitor_mode: @@ -242,6 +242,7 @@ class NomaGuardrail(CustomGuardrail): request_data: dict, response: LLMResponse, user_auth: UserAPIKeyAuth, + event_type: Optional[GuardrailEventHooks] = None, ) -> Optional[str]: """Shared logic for processing LLM response checks""" @@ -293,6 +294,7 @@ class NomaGuardrail(CustomGuardrail): start_time=start_time.timestamp(), end_time=end_time.timestamp(), duration=duration, + event_type=event_type, ) if self.monitor_mode: @@ -578,7 +580,6 @@ class NomaGuardrail(CustomGuardrail): data: dict, call_type: CallTypesLiteral, ) -> Optional[Union[Exception, str, dict]]: - verbose_proxy_logger.debug("Running Noma pre-call hook") if ( @@ -602,7 +603,9 @@ class NomaGuardrail(CustomGuardrail): return data try: - return await self._check_user_message(data, user_api_key_dict) + return await self._check_user_message( + data, user_api_key_dict, GuardrailEventHooks.pre_call + ) except NomaBlockedMessage: # Blocked requests were already logged in _process_user_message_check with "blocked" status raise @@ -619,6 +622,7 @@ class NomaGuardrail(CustomGuardrail): start_time=start_time.timestamp(), end_time=start_time.timestamp(), duration=0.0, + event_type=GuardrailEventHooks.pre_call, ) verbose_proxy_logger.error(f"Noma pre-call hook failed: {str(e)}") @@ -650,7 +654,9 @@ class NomaGuardrail(CustomGuardrail): return data try: - return await self._check_user_message(data, user_api_key_dict) + return await self._check_user_message( + data, user_api_key_dict, GuardrailEventHooks.during_call + ) except NomaBlockedMessage: # Blocked requests were already logged in _process_user_message_check with "blocked" status raise @@ -667,6 +673,7 @@ class NomaGuardrail(CustomGuardrail): start_time=start_time.timestamp(), end_time=start_time.timestamp(), duration=0.0, + event_type=GuardrailEventHooks.during_call, ) verbose_proxy_logger.error(f"Noma moderation hook failed: {str(e)}") @@ -700,7 +707,9 @@ class NomaGuardrail(CustomGuardrail): return response try: - return await self._check_llm_response(data, response, user_api_key_dict) + return await self._check_llm_response( + data, response, user_api_key_dict, GuardrailEventHooks.post_call + ) except NomaBlockedMessage: # Blocked requests were already logged in _process_llm_response_check with "blocked" status raise @@ -717,6 +726,7 @@ class NomaGuardrail(CustomGuardrail): start_time=start_time.timestamp(), end_time=start_time.timestamp(), duration=0.0, + event_type=GuardrailEventHooks.post_call, ) verbose_proxy_logger.error(f"Noma post-call hook failed: {str(e)}") @@ -728,9 +738,12 @@ class NomaGuardrail(CustomGuardrail): self, request_data: dict, user_auth: UserAPIKeyAuth, + event_type: Optional[GuardrailEventHooks] = None, ) -> Union[Exception, str, dict, None]: """Check user message for policy violations""" - user_message = await self._process_user_message_check(request_data, user_auth) + user_message = await self._process_user_message_check( + request_data, user_auth, event_type + ) if not user_message: return request_data @@ -741,10 +754,11 @@ class NomaGuardrail(CustomGuardrail): request_data: dict, response: LLMResponse, user_auth: UserAPIKeyAuth, + event_type: Optional[GuardrailEventHooks] = None, ) -> Any: """Check LLM response for policy violations""" content = await self._process_llm_response_check( - request_data, response, user_auth + request_data, response, user_auth, event_type ) if not content: return response @@ -858,7 +872,10 @@ class NomaGuardrail(CustomGuardrail): if isinstance(assembled_model_response, ModelResponse): try: processed_response = await self._check_llm_response( - request_data, assembled_model_response, user_api_key_dict + request_data, + assembled_model_response, + user_api_key_dict, + GuardrailEventHooks.post_call, ) except NomaBlockedMessage: raise diff --git a/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py b/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py index 88145ae9e47..02e481acddd 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py +++ b/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py @@ -24,6 +24,7 @@ from litellm.llms.custom_httpx.http_handler import ( httpxSpecialProvider, ) from litellm.proxy._types import UserAPIKeyAuth +from litellm.types.guardrails import GuardrailEventHooks from litellm.types.utils import CallTypesLiteral, ModelResponse if TYPE_CHECKING: @@ -523,6 +524,7 @@ class PanwPrismaAirsHandler(CustomGuardrail): scan_result: Dict[str, Any], data: Dict[str, Any], start_time: datetime, + event_type: GuardrailEventHooks, is_response: bool = False, ) -> Optional[Dict[str, Any]]: """Handle API errors with fail-open/fail-closed logic.""" @@ -542,6 +544,7 @@ class PanwPrismaAirsHandler(CustomGuardrail): start_time=start_time.timestamp(), end_time=end_time.timestamp(), duration=duration, + event_type=event_type, ) if scan_result.get("_always_block"): @@ -735,7 +738,11 @@ class PanwPrismaAirsHandler(CustomGuardrail): if scan_result.get("_is_transient") or scan_result.get("_always_block"): return self._handle_api_error_with_logging( - scan_result, data, start_time, is_response=False + scan_result, + data, + start_time, + is_response=False, + event_type=GuardrailEventHooks.pre_call, ) end_time = datetime.now() @@ -749,6 +756,7 @@ class PanwPrismaAirsHandler(CustomGuardrail): start_time=start_time.timestamp(), end_time=end_time.timestamp(), duration=(end_time - start_time).total_seconds(), + event_type=GuardrailEventHooks.pre_call, ) action = scan_result.get("action", "block") @@ -872,7 +880,11 @@ class PanwPrismaAirsHandler(CustomGuardrail): if scan_result.get("_is_transient") or scan_result.get("_always_block"): self._handle_api_error_with_logging( - scan_result, data, start_time, is_response=True + scan_result, + data, + start_time, + is_response=True, + event_type=GuardrailEventHooks.post_call, ) return response @@ -887,6 +899,7 @@ class PanwPrismaAirsHandler(CustomGuardrail): start_time=start_time.timestamp(), end_time=end_time.timestamp(), duration=(end_time - start_time).total_seconds(), + event_type=GuardrailEventHooks.post_call, ) action = scan_result.get("action", "block") @@ -1066,7 +1079,11 @@ class PanwPrismaAirsHandler(CustomGuardrail): if scan_result.get("_is_transient") or scan_result.get("_always_block"): self._handle_api_error_with_logging( - scan_result, request_data, start_time, is_response=True + scan_result, + request_data, + start_time, + is_response=True, + event_type=EventHooks.post_call, ) for chunk in all_chunks: yield chunk @@ -1083,6 +1100,7 @@ class PanwPrismaAirsHandler(CustomGuardrail): start_time=start_time.timestamp(), end_time=end_time.timestamp(), duration=(end_time - start_time).total_seconds(), + event_type=EventHooks.post_call, ) # Add guardrail to applied guardrails header for observability diff --git a/litellm/proxy/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py index 65de1bd7393..d27e0036235 100644 --- a/litellm/proxy/health_endpoints/_health_endpoints.py +++ b/litellm/proxy/health_endpoints/_health_endpoints.py @@ -4,7 +4,7 @@ import os import time import traceback from datetime import datetime, timedelta -from typing import Dict, Literal, Optional, Union +from typing import Any, Dict, Literal, Optional, Union, cast import fastapi from fastapi import APIRouter, Depends, HTTPException, Request, Response, status @@ -16,6 +16,7 @@ from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler from litellm.proxy._types import ( AlertType, CallInfo, + EnterpriseLicenseData, Litellm_EntityType, ProxyErrorTypes, ProxyException, @@ -960,6 +961,91 @@ async def shared_health_check_status_endpoint( ) +def _read_license_data() -> Optional[Dict[str, Any]]: + from litellm.proxy.proxy_server import ( + _license_check, + premium_user_data, + ) + + license_data: Optional[EnterpriseLicenseData] = ( + premium_user_data or _license_check.airgapped_license_data + ) + + if ( + license_data is None + and getattr(_license_check, "license_str", None) + and getattr(_license_check, "public_key", None) + ): + try: + verification_result = _license_check.verify_license_without_api_request( + public_key=_license_check.public_key, + license_key=_license_check.license_str, + ) + if verification_result is True: + license_data = _license_check.airgapped_license_data + except Exception: + pass + + if license_data is None: + return None + return cast(Dict[str, Any], license_data) + + +def _read_allowed_features(license_data: Dict[str, Any]) -> list: + raw_allowed_features = license_data.get("allowed_features") + if isinstance(raw_allowed_features, list): + return list(raw_allowed_features) + if raw_allowed_features is None: + return [] + return [raw_allowed_features] + + +@router.get( + "/health/license", + tags=["health"], + dependencies=[Depends(user_api_key_auth)], +) +async def health_license_endpoint( + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """Return metadata about the configured LiteLLM license without exposing the key.""" + from litellm.proxy.proxy_server import ( + _license_check, + premium_user, + ) + + license_data = _read_license_data() + has_license = bool(getattr(_license_check, "license_str", None)) + license_type = "enterprise" if premium_user else "community" + + if license_data is None: + return { + "has_license": has_license, + "license_type": license_type, + "expiration_date": None, + "allowed_features": [], + "limits": { + "max_users": None, + "max_teams": None, + }, + } + + expiration_date = license_data.get("expiration_date") + max_users = license_data.get("max_users") + max_teams = license_data.get("max_teams") + + return { + "has_license": has_license, + "license_type": license_type, + "expiration_date": expiration_date, + "allowed_features": _read_allowed_features(license_data), + "limits": { + "max_users": max_users, + "max_teams": max_teams, + }, + } + + db_health_cache = {"status": "unknown", "last_updated": datetime.now()} diff --git a/litellm/proxy/management_endpoints/cost_tracking_settings.py b/litellm/proxy/management_endpoints/cost_tracking_settings.py index 328dafc80db..86433a232c0 100644 --- a/litellm/proxy/management_endpoints/cost_tracking_settings.py +++ b/litellm/proxy/management_endpoints/cost_tracking_settings.py @@ -1,13 +1,15 @@ """ COST TRACKING SETTINGS MANAGEMENT -Endpoints for managing cost discount configuration +Endpoints for managing cost discount and margin configuration GET /config/cost_discount_config - Get current cost discount configuration PATCH /config/cost_discount_config - Update cost discount configuration +GET /config/cost_margin_config - Get current cost margin configuration +PATCH /config/cost_margin_config - Update cost margin configuration """ -from typing import Dict +from typing import Dict, Union from fastapi import APIRouter, Depends, HTTPException @@ -163,3 +165,185 @@ async def update_cost_discount_config( detail={"error": f"Failed to update cost discount config: {str(e)}"} ) + +@router.get( + "/config/cost_margin_config", + tags=["Cost Tracking"], + dependencies=[Depends(user_api_key_auth)], +) +async def get_cost_margin_config( + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Get current cost margin configuration. + + Returns the cost_margin_config from litellm_settings. + """ + from litellm.proxy.proxy_server import prisma_client, proxy_config + + if prisma_client is None: + raise HTTPException( + status_code=500, + detail={"error": CommonProxyErrors.db_not_connected_error.value}, + ) + + try: + # Load config from DB + config = await proxy_config.get_config() + + # Get cost_margin_config from litellm_settings + litellm_settings = config.get("litellm_settings", {}) + cost_margin_config = litellm_settings.get("cost_margin_config", {}) + + return {"values": cost_margin_config} + except Exception as e: + verbose_proxy_logger.error( + f"Error fetching cost margin config: {str(e)}" + ) + return {"values": {}} + + +@router.patch( + "/config/cost_margin_config", + tags=["Cost Tracking"], + dependencies=[Depends(user_api_key_auth)], +) +async def update_cost_margin_config( + cost_margin_config: Dict[str, Union[float, Dict[str, float]]], + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Update cost margin configuration. + + Updates the cost_margin_config in litellm_settings. + Margins can be: + - Percentage: {"openai": 0.10} = 10% margin + - Fixed amount: {"openai": {"fixed_amount": 0.001}} = $0.001 per request + - Combined: {"vertex_ai": {"percentage": 0.08, "fixed_amount": 0.0005}} + - Global: {"global": 0.05} = 5% global margin on all providers + + Example: + ```json + { + "global": 0.05, + "openai": 0.10, + "anthropic": {"fixed_amount": 0.001}, + "vertex_ai": {"percentage": 0.08, "fixed_amount": 0.0005} + } + ``` + """ + from litellm.proxy.proxy_server import ( + prisma_client, + proxy_config, + store_model_in_db, + ) + + if prisma_client is None: + raise HTTPException( + status_code=500, + detail={"error": CommonProxyErrors.db_not_connected_error.value}, + ) + + if store_model_in_db is not True: + raise HTTPException( + status_code=500, + detail={ + "error": "Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature." + }, + ) + + # Validate that all providers are valid LiteLLM providers (except "global") + invalid_providers = [] + for provider in cost_margin_config.keys(): + if provider != "global" and provider not in LlmProvidersSet: + invalid_providers.append(provider) + + if invalid_providers: + raise HTTPException( + status_code=400, + detail={ + "error": f"Invalid provider(s): {', '.join(invalid_providers)}. Must be valid LiteLLM providers or 'global'. See https://docs.litellm.ai/docs/providers for the full list." + }, + ) + + # Validate margin values + for provider, margin_value in cost_margin_config.items(): + if isinstance(margin_value, (int, float)): + # Simple percentage format: {"openai": 0.10} + if not (0 <= margin_value <= 10): # Allow up to 1000% margin + raise HTTPException( + status_code=400, + detail=f"Margin percentage for {provider} must be between 0 and 10 (0% to 1000%)" + ) + elif isinstance(margin_value, dict): + # Complex format: {"percentage": 0.08, "fixed_amount": 0.0005} + if "percentage" in margin_value: + percentage = margin_value["percentage"] + if not isinstance(percentage, (int, float)): + raise HTTPException( + status_code=400, + detail=f"Margin percentage for {provider} must be a number" + ) + if not (0 <= percentage <= 10): + raise HTTPException( + status_code=400, + detail=f"Margin percentage for {provider} must be between 0 and 10 (0% to 1000%)" + ) + if "fixed_amount" in margin_value: + fixed_amount = margin_value["fixed_amount"] + if not isinstance(fixed_amount, (int, float)): + raise HTTPException( + status_code=400, + detail=f"Fixed margin amount for {provider} must be a number" + ) + if fixed_amount < 0: + raise HTTPException( + status_code=400, + detail=f"Fixed margin amount for {provider} must be non-negative" + ) + if not margin_value: # Empty dict + raise HTTPException( + status_code=400, + detail=f"Margin config for {provider} cannot be empty. Must include 'percentage' and/or 'fixed_amount'" + ) + else: + raise HTTPException( + status_code=400, + detail=f"Margin for {provider} must be a number (percentage) or dict with 'percentage' and/or 'fixed_amount'" + ) + + try: + # Load existing config + config = await proxy_config.get_config() + + # Ensure litellm_settings exists + if "litellm_settings" not in config: + config["litellm_settings"] = {} + + # Update cost_margin_config + config["litellm_settings"]["cost_margin_config"] = cost_margin_config + + # Save the updated config to DB + await proxy_config.save_config(new_config=config) + + # Update in-memory litellm.cost_margin_config + litellm.cost_margin_config = cost_margin_config + + verbose_proxy_logger.info( + f"Updated cost_margin_config: {cost_margin_config}" + ) + + return { + "message": "Cost margin configuration updated successfully", + "status": "success", + "values": cost_margin_config + } + except Exception as e: + verbose_proxy_logger.error( + f"Error updating cost margin config: {str(e)}" + ) + raise HTTPException( + status_code=500, + detail={"error": f"Failed to update cost margin config: {str(e)}"} + ) + diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 3e37657504a..4f9534521c3 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -507,7 +507,11 @@ async def _common_key_generation_helper( # noqa: PLR0915 upperbound_duration = duration_in_seconds( duration=upperbound_value ) - user_duration = duration_in_seconds(duration=value) + # Handle special case where duration is "-1" (never expires) + if value == "-1": + user_duration = float('inf') # Infinite duration + else: + user_duration = duration_in_seconds(duration=value) if user_duration > upperbound_duration: raise HTTPException( status_code=400, @@ -1339,7 +1343,10 @@ async def prepare_key_update_data( if "duration" in non_default_values: duration = non_default_values.pop("duration") - if duration and (isinstance(duration, str)) and len(duration) > 0: + if duration == "-1": + # Set expires to None to indicate the key never expires + non_default_values["expires"] = None + elif duration and (isinstance(duration, str)) and len(duration) > 0: duration_s = duration_in_seconds(duration=duration) expires = datetime.now(timezone.utc) + timedelta(seconds=duration_s) non_default_values["expires"] = expires @@ -1452,7 +1459,7 @@ async def update_key_fn( - tpm_limit_type: Optional[str] - TPM rate limit type - "best_effort_throughput", "guaranteed_throughput", or "dynamic" - rpm_limit_type: Optional[str] - RPM rate limit type - "best_effort_throughput", "guaranteed_throughput", or "dynamic" - allowed_cache_controls: Optional[list] - List of allowed cache control values - - duration: Optional[str] - Key validity duration ("30d", "1h", etc.) + - duration: Optional[str] - Key validity duration ("30d", "1h", etc.) or "-1" to never expire - permissions: Optional[dict] - Key-specific permissions - send_invite_email: Optional[bool] - Send invite email to user_id - guardrails: Optional[List[str]] - List of active guardrails for the key @@ -1910,14 +1917,14 @@ async def info_key_fn( Example Curl: ``` - curl -X GET "http://0.0.0.0:4000/key/info?key=sk-02Wr4IAlN3NvPXvL5JVvDA" \ + curl -X GET "http://0.0.0.0:4000/key/info?key=sk-test-example-key-123" \ -H "Authorization: Bearer sk-1234" ``` Example Curl - if no key is passed, it will use the Key Passed in Authorization Header ``` curl -X GET "http://0.0.0.0:4000/key/info" \ --H "Authorization: Bearer sk-02Wr4IAlN3NvPXvL5JVvDA" +-H "Authorization: Bearer sk-test-example-key-123" ``` """ from litellm.proxy.proxy_server import prisma_client @@ -3013,10 +3020,14 @@ async def list_keys( description="Column to sort by (e.g. 'user_id', 'created_at', 'spend')", ), sort_order: str = Query(default="desc", description="Sort order ('asc' or 'desc')"), + expand: Optional[List[str]] = Query(None, description="Expand related objects (e.g. 'user')"), ) -> KeyListResponseObject: """ List all keys for a given user / team / organization. + Parameters: + expand: Optional[List[str]] - Expand related objects (e.g. 'user' to include user information) + Returns: { "keys": List[str] or List[UserAPIKeyAuth], @@ -3024,6 +3035,9 @@ async def list_keys( "current_page": int, "total_pages": int, } + + When expand includes "user", each key object will include a "user" field with the associated user object. + Note: When expand=user is specified, full key objects are returned regardless of the return_full_object parameter. """ try: from litellm.proxy.proxy_server import prisma_client @@ -3073,6 +3087,7 @@ async def list_keys( include_created_by_keys=include_created_by_keys, sort_by=sort_by, sort_order=sort_order, + expand=expand, ) verbose_proxy_logger.debug("Successfully prepared response") @@ -3208,45 +3223,17 @@ def _validate_sort_params( return order_by -async def _list_key_helper( - prisma_client: PrismaClient, - page: int, - size: int, +def _build_key_filter_conditions( user_id: Optional[str], team_id: Optional[str], organization_id: Optional[str], key_alias: Optional[str], key_hash: Optional[str], - exclude_team_id: Optional[str] = None, - return_full_object: bool = False, - admin_team_ids: Optional[ - List[str] - ] = None, # New parameter for teams where user is admin - include_created_by_keys: bool = False, - sort_by: Optional[str] = None, - sort_order: str = "desc", -) -> KeyListResponseObject: - """ - Helper function to list keys - Args: - page: int - size: int - user_id: Optional[str] - team_id: Optional[str] - key_alias: Optional[str] - exclude_team_id: Optional[str] # exclude a specific team_id - return_full_object: bool # when true, will return UserAPIKeyAuth objects instead of just the token - admin_team_ids: Optional[List[str]] # list of team IDs where the user is an admin - - Returns: - KeyListResponseObject - { - "keys": List[str] or List[UserAPIKeyAuth], # Updated to reflect possible return types - "total_count": int, - "current_page": int, - "total_pages": int, - } - """ + exclude_team_id: Optional[str], + admin_team_ids: Optional[List[str]], + include_created_by_keys: bool, +) -> Dict[str, Union[str, Dict[str, Any], List[Dict[str, Any]]]]: + """Build filter conditions for key listing.""" # Prepare filter conditions where: Dict[str, Union[str, Dict[str, Any], List[Dict[str, Any]]]] = {} where.update(_get_condition_to_filter_out_ui_session_tokens()) @@ -3287,6 +3274,59 @@ async def _list_key_helper( where.update(or_conditions[0]) verbose_proxy_logger.debug(f"Filter conditions: {where}") + return where + + +async def _list_key_helper( + prisma_client: PrismaClient, + page: int, + size: int, + user_id: Optional[str], + team_id: Optional[str], + organization_id: Optional[str], + key_alias: Optional[str], + key_hash: Optional[str], + exclude_team_id: Optional[str] = None, + return_full_object: bool = False, + admin_team_ids: Optional[ + List[str] + ] = None, # New parameter for teams where user is admin + include_created_by_keys: bool = False, + sort_by: Optional[str] = None, + sort_order: str = "desc", + expand: Optional[List[str]] = None, +) -> KeyListResponseObject: + """ + Helper function to list keys + Args: + page: int + size: int + user_id: Optional[str] + team_id: Optional[str] + key_alias: Optional[str] + exclude_team_id: Optional[str] # exclude a specific team_id + return_full_object: bool # when true, will return UserAPIKeyAuth objects instead of just the token + admin_team_ids: Optional[List[str]] # list of team IDs where the user is an admin + + Returns: + KeyListResponseObject + { + "keys": List[str] or List[UserAPIKeyAuth], # Updated to reflect possible return types + "total_count": int, + "current_page": int, + "total_pages": int, + } + """ + where = _build_key_filter_conditions( + user_id=user_id, + team_id=team_id, + organization_id=organization_id, + key_alias=key_alias, + key_hash=key_hash, + exclude_team_id=exclude_team_id, + admin_team_ids=admin_team_ids, + include_created_by_keys=include_created_by_keys, + ) # Calculate skip for pagination skip = (page - 1) * size @@ -3327,13 +3367,28 @@ async def _list_key_helper( # Calculate total pages total_pages = -(-total_count // size) # Ceiling division + # Fetch user information if expand includes "user" + user_map = {} + if expand and "user" in expand: + user_ids = [key.user_id for key in keys if key.user_id] + if user_ids: + users = await prisma_client.db.litellm_usertable.find_many( + where={"user_id": {"in": list(set(user_ids))}} # Remove duplicates + ) + user_map = {user.user_id: user for user in users} + # Prepare response key_list: List[Union[str, UserAPIKeyAuth]] = [] for key in keys: key_dict = key.dict() # Attach object_permission if object_permission_id is set key_dict = await attach_object_permission_to_dict(key_dict, prisma_client) - if return_full_object is True: + + # Include user information if expand includes "user" + if expand and "user" in expand and key.user_id and key.user_id in user_map: + key_dict["user"] = user_map[key.user_id].dict() + + if return_full_object is True or (expand and "user" in expand): key_list.append(UserAPIKeyAuth(**key_dict)) # Return full key object else: _token = key_dict.get("token") diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index f0eddcc8683..500323d3beb 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -16,7 +16,7 @@ Endpoints here: import importlib from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Any, Dict, Iterable, List, Optional +from typing import Any, Dict, Iterable, List, Literal, Optional from fastapi import ( APIRouter, @@ -24,6 +24,7 @@ from fastapi import ( Form, Header, HTTPException, + Query, Request, Response, status, @@ -296,117 +297,6 @@ if MCP_AVAILABLE: access_groups_list = sorted(list(access_groups)) return {"access_groups": access_groups_list} - @router.get( - "/server/{server_id}/health", - description="Perform health check on a specific MCP server", - dependencies=[Depends(user_api_key_auth)], - ) - async def health_check_mcp_server( - server_id: str, - user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), - ): - """ - Perform a health check on the MCP server specified by the `server_id` - Parameters: - - server_id: str - Required. The unique identifier of the mcp server to health check. - ``` - curl --location 'http://localhost:4000/v1/mcp/server/{server_id}/health' \ - --header 'Authorization: Bearer your_api_key_here' - ``` - """ - # Check if server exists and user has access - prisma_client = get_prisma_client_or_throw( - "Database not connected. Connect a database to your proxy" - ) - - # check to see if server exists for all users - mcp_server = await get_mcp_server(prisma_client, server_id) - if mcp_server is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail={"error": f"MCP Server with id {server_id} not found"}, - ) - - # Implement authz restriction from requested user - if not _user_has_admin_view(user_api_key_dict): - # Perform authz check to filter the mcp servers user has access to - mcp_server_records = await get_all_mcp_servers_for_user( - prisma_client, user_api_key_dict - ) - exists = does_mcp_server_exist(mcp_server_records, server_id) - - if not exists: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail={ - "error": f"User does not have permission to access mcp server with id {server_id}. You can only access mcp servers that you have access to." - }, - ) - - # Perform health check using server manager - try: - health_result = await global_mcp_server_manager.health_check_server( - server_id - ) - return health_result - except Exception as e: - verbose_proxy_logger.exception( - f"Error performing health check on MCP server {server_id}: {str(e)}" - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail={"error": f"Error performing health check: {str(e)}"}, - ) - - @router.get( - "/server/health", - description="Perform health check on all accessible MCP servers", - dependencies=[Depends(user_api_key_auth)], - ) - async def health_check_all_mcp_servers( - user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), - ): - """ - Perform health checks on all MCP servers accessible to the user - ``` - curl --location 'http://localhost:4000/v1/mcp/server/health' \ - --header 'Authorization: Bearer your_api_key_here' - ``` - """ - # Use server manager to get health checks for allowed servers - try: - all_health_results = ( - await global_mcp_server_manager.health_check_allowed_servers( - user_api_key_auth=user_api_key_dict - ) - ) - - return { - "total_servers": len(all_health_results), - "healthy_count": len( - [r for r in all_health_results.values() if r["status"] == "healthy"] - ), - "unhealthy_count": len( - [ - r - for r in all_health_results.values() - if r["status"] == "unhealthy" - ] - ), - "unknown_count": len( - [r for r in all_health_results.values() if r["status"] == "unknown"] - ), - "servers": all_health_results, - } - except Exception as e: - verbose_proxy_logger.exception( - f"Error performing health checks on MCP servers: {str(e)}" - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail={"error": f"Error performing health checks: {str(e)}"}, - ) - ## FastAPI Routes @router.get( "/server", @@ -429,7 +319,7 @@ if MCP_AVAILABLE: aggregated_servers: Dict[str, LiteLLM_MCPServerTable] = {} for auth_context in auth_contexts: - servers = await global_mcp_server_manager.get_all_mcp_servers_with_health_and_teams( + servers = await global_mcp_server_manager.get_all_allowed_mcp_servers( user_api_key_auth=auth_context ) for server in servers: @@ -447,6 +337,56 @@ if MCP_AVAILABLE: server.mcp_info["is_public"] = True return redacted_mcp_servers + @router.get( + "/server/health", + description="Health check for MCP servers", + dependencies=[Depends(user_api_key_auth)], + ) + async def health_check_servers( + server_ids: Optional[List[str]] = Query( + None, + description="Server IDs to check. If not provided, checks all accessible servers.", + ), + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + ): + """ + Perform health checks on one or more MCP servers. + + Parameters: + - server_ids: Optional list of server IDs. If not provided, checks all accessible servers. + + Returns: + - Health check results for requested servers + + ``` + # Check all accessible servers + curl --location 'http://localhost:4000/v1/mcp/server/health' \ + --header 'Authorization: Bearer your_api_key_here' + + # Check specific servers + curl --location 'http://localhost:4000/v1/mcp/server/health?server_ids=server-1&server_ids=server-2' \ + --header 'Authorization: Bearer your_api_key_here' + ``` + """ + auth_contexts = await build_effective_auth_contexts(user_api_key_dict) + + server_status_map: Dict[ + str, Optional[Literal["healthy", "unhealthy", "unknown"]] + ] = {} + for auth_context in auth_contexts: + servers = await global_mcp_server_manager.get_all_mcp_servers_with_health_and_teams( + user_api_key_auth=auth_context, + server_ids=server_ids, + ) + for server in servers: + if server.server_id not in server_status_map: + server_status_map[server.server_id] = server.status + + return [ + {"server_id": server_id, "status": status} + for server_id, status in server_status_map.items() + ] + @router.get( "/server/{server_id}", description="Returns the mcp server info", @@ -484,15 +424,11 @@ if MCP_AVAILABLE: server_id ) # Update the server object with health check results - mcp_server.status = health_result.get("status", "unknown") - mcp_server.last_health_check = ( - datetime.fromisoformat( - health_result.get("last_health_check", datetime.now().isoformat()) - ) - if health_result.get("last_health_check") - else None + mcp_server.status = ( + health_result.status if health_result.status else "unknown" ) - mcp_server.health_check_error = health_result.get("error") + mcp_server.last_health_check = health_result.last_health_check + mcp_server.health_check_error = health_result.health_check_error except Exception as e: verbose_proxy_logger.debug( f"Error performing health check on server {server_id}: {e}" diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index d4dfd86744d..dc976e1ce64 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -85,6 +85,58 @@ else: router = APIRouter() +def determine_role_from_groups( + user_groups: List[str], + role_mappings: "RoleMappings", +) -> Optional[LitellmUserRoles]: + """ + Determine the highest privilege role for a user based on their groups. + + Role hierarchy (highest to lowest): + - proxy_admin + - proxy_admin_viewer + - internal_user + - internal_user_viewer + + Args: + user_groups: List of group names from the SSO token + role_mappings: RoleMappings configuration object + + Returns: + The highest privilege role found, or default_role if no matches, or None + """ + if not role_mappings.roles: + # No role mappings configured, return default_role + return role_mappings.default_role + + # Role hierarchy (highest to lowest) + role_hierarchy = [ + LitellmUserRoles.PROXY_ADMIN, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + LitellmUserRoles.INTERNAL_USER, + LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, + ] + + # Convert user_groups to a set for efficient lookup + user_groups_set = set(user_groups) if isinstance(user_groups, list) else set() + + # Find the highest privilege role the user belongs to + for role in role_hierarchy: + if role in role_mappings.roles: + role_groups = role_mappings.roles[role] + if isinstance(role_groups, list) and user_groups_set.intersection(set(role_groups)): + verbose_proxy_logger.debug( + f"User groups {user_groups} matched role '{role.value}' via groups: {role_groups}" + ) + return role + + # No matching groups found, return default_role + verbose_proxy_logger.debug( + f"User groups {user_groups} did not match any role mappings, using default_role: {role_mappings.default_role}" + ) + return role_mappings.default_role + + def process_sso_jwt_access_token( access_token_str: Optional[str], sso_jwt_handler: Optional[JWTHandler], @@ -243,6 +295,7 @@ def generic_response_convertor( response, jwt_handler: JWTHandler, sso_jwt_handler: Optional[JWTHandler] = None, + role_mappings: Optional["RoleMappings"] = None, ) -> CustomOpenID: generic_user_id_attribute_name = os.getenv( "GENERIC_USER_ID_ATTRIBUTE", "preferred_username" @@ -281,16 +334,48 @@ def generic_response_convertor( team_ids = jwt_handler.get_team_ids_from_jwt(cast(dict, response)) all_teams.extend(team_ids) - # Extract user role from SSO response - user_role_from_sso = get_nested_value(response, generic_user_role_attribute_name) + # Determine user role based on role_mappings if available + # Only apply role_mappings for GENERIC SSO provider user_role: Optional[LitellmUserRoles] = None - if user_role_from_sso is not None: - role = get_litellm_user_role(user_role_from_sso) - if role is not None: - user_role = role + + if role_mappings is not None and role_mappings.provider.lower() in ["generic", "okta"]: + # Use role_mappings to determine role from groups + group_claim = role_mappings.group_claim + user_groups_raw = get_nested_value(response, group_claim) + + # Handle different formats: could be a list, string (comma-separated), or single value + user_groups: List[str] = [] + if isinstance(user_groups_raw, list): + user_groups = [str(g) for g in user_groups_raw] + elif isinstance(user_groups_raw, str): + # Handle comma-separated string + user_groups = [g.strip() for g in user_groups_raw.split(",") if g.strip()] + elif user_groups_raw is not None: + # Single value + user_groups = [str(user_groups_raw)] + + if user_groups: + user_role = determine_role_from_groups(user_groups, role_mappings) verbose_proxy_logger.debug( - f"Found valid LitellmUserRoles '{role.value}' from SSO attribute '{generic_user_role_attribute_name}'" + f"Determined role '{user_role.value if user_role else None}' from groups '{user_groups}' using role_mappings" ) + else: + # No groups found, use default_role + user_role = role_mappings.default_role + verbose_proxy_logger.debug( + f"No groups found in '{group_claim}', using default_role: {role_mappings.default_role}" + ) + + # Fallback to existing logic if role_mappings not used + if user_role is None: + user_role_from_sso = get_nested_value(response, generic_user_role_attribute_name) + if user_role_from_sso is not None: + role = get_litellm_user_role(user_role_from_sso) + if role is not None: + user_role = role + verbose_proxy_logger.debug( + f"Found valid LitellmUserRoles '{role.value}' from SSO attribute '{generic_user_role_attribute_name}'" + ) return CustomOpenID( id=get_nested_value(response, generic_user_id_attribute_name), @@ -306,20 +391,8 @@ def generic_response_convertor( ) -async def get_generic_sso_response( - request: Request, - jwt_handler: JWTHandler, - sso_jwt_handler: Optional[ - JWTHandler - ], # sso specific jwt handler - used for restricted sso group access control - generic_client_id: str, - redirect_url: str, -) -> Tuple[Union[OpenID, dict], Optional[dict]]: # return received response - # make generic sso provider - from fastapi_sso.sso.base import DiscoveryDocument - from fastapi_sso.sso.generic import create_provider - - received_response: Optional[dict] = None +def _setup_generic_sso_env_vars(generic_client_id: str, redirect_url: str) -> Tuple[str, List[str], str, str, str, bool]: + """Setup and validate Generic SSO environment variables.""" 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) @@ -328,6 +401,8 @@ async def get_generic_sso_response( generic_include_client_id = ( os.getenv("GENERIC_INCLUDE_CLIENT_ID", "false").lower() == "true" ) + + # Validate required environment variables if generic_client_secret is None: raise ProxyException( message="GENERIC_CLIENT_SECRET not set. Set it in .env file", @@ -356,6 +431,7 @@ async def get_generic_sso_response( 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}" ) @@ -363,12 +439,89 @@ async def get_generic_sso_response( f"GENERIC_REDIRECT_URI: {redirect_url}\nGENERIC_CLIENT_ID: {generic_client_id}\n" ) + return ( + generic_client_secret, + generic_scope, + generic_authorization_endpoint, + generic_token_endpoint, + generic_userinfo_endpoint, + generic_include_client_id, + ) + + +async def _setup_role_mappings() -> Optional["RoleMappings"]: + """Setup role mappings from SSO database settings.""" + role_mappings: Optional["RoleMappings"] = None + try: + from litellm.proxy.utils import get_prisma_client_or_throw + + prisma_client = get_prisma_client_or_throw( + "Prisma client is None, connect a database to your proxy" + ) + + # Get SSO config from dedicated table + sso_db_record = await prisma_client.db.litellm_ssoconfig.find_unique( + where={"id": "sso_config"} + ) + + if sso_db_record and sso_db_record.sso_settings: + sso_settings_dict = dict(sso_db_record.sso_settings) + role_mappings_data = sso_settings_dict.get("role_mappings") + + if role_mappings_data: + from litellm.types.proxy.management_endpoints.ui_sso import RoleMappings + if isinstance(role_mappings_data, dict): + role_mappings = RoleMappings(**role_mappings_data) + elif isinstance(role_mappings_data, RoleMappings): + role_mappings = role_mappings_data + + if role_mappings: + verbose_proxy_logger.debug( + f"Loaded role_mappings for provider '{role_mappings.provider}'" + ) + except Exception as e: + # If we can't load role_mappings, continue with existing logic + verbose_proxy_logger.debug( + f"Could not load role_mappings from database: {e}. Continuing with existing role logic." + ) + + return role_mappings + + +async def get_generic_sso_response( + request: Request, + jwt_handler: JWTHandler, + sso_jwt_handler: Optional[ + JWTHandler + ], # sso specific jwt handler - used for restricted sso group access control + generic_client_id: str, + redirect_url: str, +) -> Tuple[Union[OpenID, dict], Optional[dict]]: # return received response + # make generic sso provider + from fastapi_sso.sso.base import DiscoveryDocument + from fastapi_sso.sso.generic import create_provider + + received_response: Optional[dict] = None + + # Setup environment variables + ( + generic_client_secret, + generic_scope, + generic_authorization_endpoint, + generic_token_endpoint, + generic_userinfo_endpoint, + generic_include_client_id, + ) = _setup_generic_sso_env_vars(generic_client_id, redirect_url) + discovery = DiscoveryDocument( authorization_endpoint=generic_authorization_endpoint, token_endpoint=generic_token_endpoint, userinfo_endpoint=generic_userinfo_endpoint, ) + # Get role_mappings from SSO settings if available + role_mappings = await _setup_role_mappings() + def response_convertor(response, client): nonlocal received_response # return for user debugging received_response = response @@ -376,6 +529,7 @@ async def get_generic_sso_response( response=response, jwt_handler=jwt_handler, sso_jwt_handler=sso_jwt_handler, + role_mappings=role_mappings, ) SSOProvider = create_provider( @@ -1053,8 +1207,44 @@ async def insert_sso_user( if user_defined_values is None: raise ValueError("user_defined_values is None") + # Check if role_mappings is configured in SSO settings + role_mappings_configured = False + try: + from litellm.proxy.utils import get_prisma_client_or_throw + + prisma_client = get_prisma_client_or_throw( + "Prisma client is None, connect a database to your proxy" + ) + + # Get SSO config from dedicated table + sso_db_record = await prisma_client.db.litellm_ssoconfig.find_unique( + where={"id": "sso_config"} + ) + + if sso_db_record and sso_db_record.sso_settings: + sso_settings_dict = dict(sso_db_record.sso_settings) + role_mappings_data = sso_settings_dict.get("role_mappings") + role_mappings_configured = role_mappings_data is not None + except Exception as e: + # If we can't check role_mappings, continue with existing logic + verbose_proxy_logger.debug( + f"Could not check role_mappings configuration: {e}. Using default behavior." + ) + + # Apply default_internal_user_params if litellm.default_internal_user_params: - user_defined_values.update(litellm.default_internal_user_params) # type: ignore + # If role_mappings is configured and user_role is already set from SSO, preserve it + if role_mappings_configured and user_defined_values.get("user_role") is not None: + # Preserve the SSO-extracted role, but apply other defaults + preserved_role = user_defined_values.get("user_role") + user_defined_values.update(litellm.default_internal_user_params) # type: ignore + user_defined_values["user_role"] = preserved_role # Restore preserved role + verbose_proxy_logger.debug( + f"Preserved SSO-extracted role '{preserved_role}' (role_mappings configured)" + ) + else: + # Default behavior: update all values including role + user_defined_values.update(litellm.default_internal_user_params) # type: ignore # Set budget for internal users if user_defined_values.get("user_role") == LitellmUserRoles.INTERNAL_USER.value: @@ -1777,7 +1967,15 @@ class SSOAuthenticationHandler: ) user_id = getattr(result, "id", None) user_email = getattr(result, "email", None) - user_role = getattr(result, generic_user_role_attribute_name, None) # type: ignore + if user_role is None: + _role_from_attr = getattr(result, generic_user_role_attribute_name, None) # type: ignore + if _role_from_attr is not None: + # Convert enum to string if needed + user_role = ( + _role_from_attr.value + if isinstance(_role_from_attr, LitellmUserRoles) + else _role_from_attr + ) if user_id is None and result is not None: _first_name = getattr(result, "first_name", "") or "" diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 267e0d77422..f56c0c2b07a 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -946,20 +946,19 @@ try: # This prevents mutating the packaged UI directory (e.g. site-packages or the repo checkout) # and ensures extensionless routes like /ui/login work via /index.html. is_non_root = os.getenv("LITELLM_NON_ROOT", "").lower() == "true" - runtime_ui_path = "/tmp/litellm_ui" - if _dir_has_content(runtime_ui_path): - if is_non_root: + # Only use runtime UI path in Docker/non-root environments + # In local development, use the packaged UI directly + if is_non_root: + # Use /var/lib/litellm/ui for Docker (more secure than /tmp) + runtime_ui_path = "/var/lib/litellm/ui" + + if _dir_has_content(runtime_ui_path): verbose_proxy_logger.info( f"Using pre-built UI for non-root Docker: {runtime_ui_path}" ) + ui_path = runtime_ui_path else: - verbose_proxy_logger.info( - f"Using cached runtime UI directory: {runtime_ui_path}" - ) - ui_path = runtime_ui_path - else: - if is_non_root: verbose_proxy_logger.error( f"UI not found at {runtime_ui_path}. Attempting to populate it from packaged UI." ) @@ -967,33 +966,32 @@ try: f"Path exists: {os.path.exists(runtime_ui_path)}, Has content: {_dir_has_content(runtime_ui_path)}" ) - try: - os.makedirs(runtime_ui_path, exist_ok=True) - if not _dir_has_content(runtime_ui_path) and _dir_has_content( - packaged_ui_path - ): - shutil.copytree( - packaged_ui_path, - runtime_ui_path, - dirs_exist_ok=True, - ) - except Exception as e: - if is_non_root: + try: + os.makedirs(runtime_ui_path, exist_ok=True) + if not _dir_has_content(runtime_ui_path) and _dir_has_content( + packaged_ui_path + ): + shutil.copytree( + packaged_ui_path, + runtime_ui_path, + dirs_exist_ok=True, + ) + except Exception as e: verbose_proxy_logger.exception( f"Failed to populate runtime UI directory {runtime_ui_path} from {packaged_ui_path}: {e}" ) - else: - if _dir_has_content(runtime_ui_path): - if is_non_root: + else: + if _dir_has_content(runtime_ui_path): verbose_proxy_logger.info( f"Using populated UI for non-root Docker: {runtime_ui_path}" ) - else: - verbose_proxy_logger.info( - f"Using populated runtime UI directory: {runtime_ui_path}" - ) - ui_path = runtime_ui_path - + ui_path = runtime_ui_path + else: + # Local development: use packaged UI directly, no runtime copy needed + verbose_proxy_logger.info( + f"Using packaged UI directory for local development: {packaged_ui_path}" + ) + ui_path = packaged_ui_path # Only modify files if a custom server root path is set if server_root_path and server_root_path != "/": # Iterate through files in the UI directory @@ -1079,18 +1077,22 @@ try: continue # Handle HTML file restructuring - # Always restructure the directory we actually serve, but avoid mutating the packaged UI. + # Always restructure the directory we actually serve. # This is critical for extensionless routes like /ui/login (expects login/index.html). - if ui_path != packaged_ui_path: - try: - _restructure_ui_html_files(ui_path) - except PermissionError as e: - verbose_proxy_logger.exception( - f"Permission error while restructuring UI directory {ui_path}: {e}" - ) - else: + # In development, we restructure directly in _experimental/out. + # In non-root Docker, we restructure in /var/lib/litellm/ui. + try: + _restructure_ui_html_files(ui_path) verbose_proxy_logger.info( - f"Skipping runtime HTML restructuring for packaged UI directory: {ui_path}" + f"Restructured UI directory: {ui_path}" + ) + except PermissionError as e: + verbose_proxy_logger.exception( + f"Permission error while restructuring UI directory {ui_path}: {e}" + ) + except Exception as e: + verbose_proxy_logger.exception( + f"Error while restructuring UI directory {ui_path}: {e}" ) except Exception: @@ -3645,6 +3647,7 @@ class ProxyConfig: ) if sso_settings is not None: # Capitalize all keys in sso_settings dictionary + sso_settings.sso_settings.pop("role_mappings", None) uppercase_sso_settings = { key.upper(): value for key, value in sso_settings.sso_settings.items() @@ -8884,7 +8887,7 @@ def get_image(): default_site_logo = os.path.join(current_dir, "logo.jpg") is_non_root = os.getenv("LITELLM_NON_ROOT", "").lower() == "true" - assets_dir = "/tmp/litellm_assets" if is_non_root else current_dir + assets_dir = "/var/lib/litellm/assets" if is_non_root else current_dir if is_non_root: os.makedirs(assets_dir, exist_ok=True) diff --git a/litellm/proxy/rag_endpoints/endpoints.py b/litellm/proxy/rag_endpoints/endpoints.py index c0b5103f47f..79b4fd6873d 100644 --- a/litellm/proxy/rag_endpoints/endpoints.py +++ b/litellm/proxy/rag_endpoints/endpoints.py @@ -1,8 +1,9 @@ """ -RAG Ingest Endpoints for LiteLLM Proxy. +RAG Endpoints for LiteLLM Proxy. -Provides an all-in-one API for document ingestion: -Upload -> (OCR) -> Chunk -> Embed -> Vector Store +Provides: +- /rag/ingest: All-in-one document ingestion pipeline (Upload -> Chunk -> Embed -> Vector Store) +- /rag/query: RAG query pipeline (Search -> Rerank -> LLM Completion) """ import base64 @@ -198,3 +199,145 @@ async def rag_ingest( status_code=500, detail={"error": str(e)}, ) + + +@router.post( + "/v1/rag/query", + dependencies=[Depends(user_api_key_auth)], + response_class=ORJSONResponse, + tags=["rag"], +) +@router.post( + "/rag/query", + dependencies=[Depends(user_api_key_auth)], + response_class=ORJSONResponse, + tags=["rag"], +) +async def rag_query( + request: Request, + fastapi_response: Response, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + RAG Query endpoint - search vector store, optionally rerank, and generate LLM response. + + This endpoint: + 1. Extracts the query from the last user message + 2. Searches the vector store for relevant context + 3. Optionally reranks the results + 4. Generates an LLM response with the retrieved context + + ## Example Request: + ```bash + curl -X POST "http://localhost:4000/v1/rag/query" \\ + -H "Authorization: Bearer sk-1234" \\ + -H "Content-Type: application/json" \\ + -d '{ + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "What is LiteLLM?"}], + "retrieval_config": { + "vector_store_id": "vs_abc123", + "custom_llm_provider": "openai", + "top_k": 5 + } + }' + ``` + + ## With Reranking: + ```bash + curl -X POST "http://localhost:4000/v1/rag/query" \\ + -H "Authorization: Bearer sk-1234" \\ + -H "Content-Type: application/json" \\ + -d '{ + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "What is LiteLLM?"}], + "retrieval_config": { + "vector_store_id": "vs_abc123", + "custom_llm_provider": "openai", + "top_k": 10 + }, + "rerank": { + "enabled": true, + "model": "cohere/rerank-english-v3.0", + "top_n": 3 + } + }' + ``` + """ + from litellm.proxy.proxy_server import ( + add_litellm_data_to_request, + general_settings, + llm_router, + proxy_config, + version, + ) + + try: + # Parse request body + data = await _read_request_body(request) + + # Extract required fields + model = data.get("model") + messages = data.get("messages") + retrieval_config = data.get("retrieval_config") + rerank = data.get("rerank") + stream = data.get("stream", False) + + # Validate required fields + if not model: + raise HTTPException( + status_code=400, + detail={"error": "model is required"}, + ) + if not messages: + raise HTTPException( + status_code=400, + detail={"error": "messages is required"}, + ) + if not retrieval_config: + raise HTTPException( + status_code=400, + detail={"error": "retrieval_config is required"}, + ) + if "vector_store_id" not in retrieval_config: + raise HTTPException( + status_code=400, + detail={"error": "retrieval_config must contain 'vector_store_id'"}, + ) + + # Add litellm data + request_data: Dict[str, Any] = {} + request_data = await add_litellm_data_to_request( + data=request_data, + request=request, + general_settings=general_settings, + user_api_key_dict=user_api_key_dict, + version=version, + proxy_config=proxy_config, + ) + + verbose_proxy_logger.debug( + f"RAG Query - model: {model}, retrieval_config: {retrieval_config}" + ) + + # Call query + response = await litellm.aquery( + model=model, + messages=messages, + retrieval_config=retrieval_config, + rerank=rerank, + stream=stream, + router=llm_router, + **request_data, + ) + + return response + + except HTTPException: + raise + except Exception as e: + verbose_proxy_logger.exception(f"RAG Query failed: {e}") + raise HTTPException( + status_code=500, + detail={"error": str(e)}, + ) diff --git a/litellm/proxy/spend_tracking/cloudzero_endpoints.py b/litellm/proxy/spend_tracking/cloudzero_endpoints.py index 2cf4ce8f16a..172169f2c7a 100644 --- a/litellm/proxy/spend_tracking/cloudzero_endpoints.py +++ b/litellm/proxy/spend_tracking/cloudzero_endpoints.py @@ -69,7 +69,7 @@ async def _get_cloudzero_settings(): Retrieve CloudZero settings from the database with decrypted API key. Returns: - dict: CloudZero settings with decrypted API key + dict: CloudZero settings with decrypted API key, or empty dict if not configured """ from litellm.proxy.proxy_server import prisma_client @@ -82,10 +82,16 @@ async def _get_cloudzero_settings(): cloudzero_config = await prisma_client.db.litellm_config.find_first( where={"param_name": "cloudzero_settings"} ) - if cloudzero_config is None: + if cloudzero_config is None or cloudzero_config.param_value is None: return {} - settings = dict(cloudzero_config.param_value) + # Handle both dict and JSON string cases + if isinstance(cloudzero_config.param_value, dict): + settings = cloudzero_config.param_value + elif isinstance(cloudzero_config.param_value, str): + settings = json.loads(cloudzero_config.param_value) + else: + settings = dict(cloudzero_config.param_value) # Decrypt the API key encrypted_api_key = settings.get("api_key") @@ -119,6 +125,7 @@ async def get_cloudzero_settings( Returns the current CloudZero configuration with the API key masked for security. Only the first 4 and last 4 characters of the API key are shown. + Returns null/empty values when settings are not configured (consistent with other settings endpoints). Only admin users can view CloudZero settings. """ @@ -133,22 +140,27 @@ async def get_cloudzero_settings( # Get CloudZero settings using the accessor method settings = await _get_cloudzero_settings() + # If settings are empty, return null/empty values (consistent with other endpoints) + if not settings: + return CloudZeroSettingsView( + api_key_masked=None, + connection_id=None, + timezone=None, + status=None, + ) + # Use SensitiveDataMasker to mask the API key masked_settings = _sensitive_masker.mask_dict(settings) return CloudZeroSettingsView( - api_key_masked=masked_settings["api_key"], - connection_id=settings["connection_id"], - timezone=settings["timezone"], + api_key_masked=masked_settings.get("api_key"), + connection_id=settings.get("connection_id"), + timezone=settings.get("timezone"), status="configured", ) except HTTPException as e: - if e.status_code == 400: - # Settings not configured - raise HTTPException( - status_code=404, detail={"error": "CloudZero settings not configured"} - ) + # Re-raise HTTPExceptions as-is raise e except Exception as e: verbose_proxy_logger.error(f"Error retrieving CloudZero settings: {str(e)}") diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index ca7e327dbd9..dcdc17ef318 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -1678,6 +1678,9 @@ async def ui_view_spend_logs( # noqa: PLR0915 end_user: Optional[str] = fastapi.Query( default=None, description="Filter logs by end user" ), + error_code: Optional[str] = fastapi.Query( + default=None, description="Filter logs by error code (e.g., '404', '500')" + ), ): """ View spend logs with pagination support. @@ -1757,12 +1760,27 @@ async def ui_view_spend_logs( # noqa: PLR0915 if model is not None: where_conditions["model"] = model + # Build metadata filters + metadata_filters = [] if key_alias is not None: - where_conditions["metadata"] = { + metadata_filters.append({ "path": ["user_api_key_alias"], "string_contains": key_alias, - } + }) + if error_code is not None: + metadata_filters.append({ + "path": ["error_information", "error_code"], + "equals": f'"{error_code}"', + }) + + if metadata_filters: + if len(metadata_filters) == 1: + where_conditions["metadata"] = metadata_filters[0] + else: + where_conditions["AND"] = where_conditions.get("AND", []) + [ + {"metadata": filter_cond} for filter_cond in metadata_filters + ] if end_user is not None: where_conditions["end_user"] = end_user @@ -1938,7 +1956,7 @@ async def view_spend_logs( # noqa: PLR0915 Example Request for specific api_key ``` - curl -X GET "http://0.0.0.0:8000/spend/logs?api_key=sk-Fn8Ej39NkBQmUagFEoUWPQ" \ + curl -X GET "http://0.0.0.0:8000/spend/logs?api_key=sk-test-example-key-123" \ -H "Authorization: Bearer sk-1234" ``` diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index 687af8a4514..1861c44b699 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -16,6 +16,7 @@ from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.proxy._types import SpendLogsMetadata, SpendLogsPayload from litellm.proxy.utils import PrismaClient, hash_token from litellm.types.utils import ( + CostBreakdown, StandardLoggingGuardrailInformation, StandardLoggingMCPToolCall, StandardLoggingModelInformation, @@ -56,6 +57,7 @@ def _get_spend_logs_metadata( model_map_information: Optional[StandardLoggingModelInformation] = None, cold_storage_object_key: Optional[str] = None, litellm_overhead_time_ms: Optional[float] = None, + cost_breakdown: Optional[CostBreakdown] = None, ) -> SpendLogsMetadata: if metadata is None: return SpendLogsMetadata( @@ -80,6 +82,7 @@ def _get_spend_logs_metadata( guardrail_information=None, cold_storage_object_key=cold_storage_object_key, litellm_overhead_time_ms=None, + cost_breakdown=None, ) verbose_proxy_logger.debug( "getting payload for SpendLogs, available keys in metadata: " @@ -105,6 +108,7 @@ def _get_spend_logs_metadata( clean_metadata["model_map_information"] = model_map_information clean_metadata["cold_storage_object_key"] = cold_storage_object_key clean_metadata["litellm_overhead_time_ms"] = litellm_overhead_time_ms + clean_metadata["cost_breakdown"] = cost_breakdown return clean_metadata @@ -353,6 +357,11 @@ def get_logging_payload( # noqa: PLR0915 else None ), litellm_overhead_time_ms=litellm_overhead_time_ms, + cost_breakdown=( + standard_logging_payload.get("cost_breakdown", None) + if standard_logging_payload is not None + else None + ), ) special_usage_fields = ["completion_tokens", "prompt_tokens", "total_tokens"] diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 9c99b625e9f..d9a41d38b22 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -433,10 +433,21 @@ async def get_sso_settings(): if sso_db_record and sso_db_record.sso_settings: # Load settings from database sso_settings_dict = dict(sso_db_record.sso_settings) + + # Extract role_mappings before removing it (it's a dict, not an env variable) + role_mappings_data = sso_settings_dict.pop("role_mappings", None) + role_mappings = None + if role_mappings_data: + from litellm.types.proxy.management_endpoints.ui_sso import RoleMappings + if isinstance(role_mappings_data, dict): + role_mappings = RoleMappings(**role_mappings_data) + elif isinstance(role_mappings_data, RoleMappings): + role_mappings = role_mappings_data decrypted_sso_settings_dict = proxy_config._decrypt_and_set_db_env_variables(environment_variables=sso_settings_dict) # Build SSO config with database values or environment fallback + sso_config = SSOConfig( google_client_id=decrypted_sso_settings_dict.get("google_client_id", None), google_client_secret=decrypted_sso_settings_dict.get("google_client_secret", None), @@ -451,6 +462,7 @@ async def get_sso_settings(): proxy_base_url=decrypted_sso_settings_dict.get("proxy_base_url", None), user_email=decrypted_sso_settings_dict.get("user_email"), ui_access_mode=decrypted_sso_settings_dict.get("ui_access_mode"), + role_mappings=role_mappings, ) # Get the schema for UI display diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index ec86139c73c..d595db4a2e0 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -1465,9 +1465,10 @@ class ProxyLogging: error_type: Optional[ProxyErrorTypes] = None, route: Optional[str] = None, traceback_str: Optional[str] = None, - ): + ) -> Optional[HTTPException]: """ Allows users to raise custom exceptions/log when a call fails, without having to deal with parsing Request body. + Callbacks can return or raise HTTPException to transform error responses sent to clients. Covers: 1. /chat/completions @@ -1481,6 +1482,10 @@ class ProxyLogging: - error_type: Optional[ProxyErrorTypes] - The error type. - route: Optional[str] - The route. - traceback_str: Optional[str] - The traceback string, sometimes upstream endpoints might need to send the upstream traceback. In which case we use this + + Returns: + - Optional[HTTPException]: If any callback returns or raises an HTTPException, the first one found is returned. + Otherwise, returns None and the original exception is used. """ ### ALERTING ### @@ -1522,6 +1527,9 @@ class ProxyLogging: original_exception=original_exception, ) + # Track the first HTTPException returned or raised by any callback + transformed_exception: Optional[HTTPException] = None + for callback in litellm.callbacks: try: _callback: Optional[CustomLogger] = None @@ -1532,19 +1540,31 @@ class ProxyLogging: else: _callback = callback # type: ignore if _callback is not None and isinstance(_callback, CustomLogger): - asyncio.create_task( - _callback.async_post_call_failure_hook( + try: + hook_result = await _callback.async_post_call_failure_hook( request_data=request_data, user_api_key_dict=user_api_key_dict, original_exception=original_exception, traceback_str=traceback_str, ) - ) + # If callback returned an HTTPException, use it (first one wins) + if isinstance(hook_result, HTTPException) and transformed_exception is None: + transformed_exception = hook_result + except HTTPException as e: + # If callback raised an HTTPException, use it (first one wins) + if transformed_exception is None: + transformed_exception = e + except Exception as e: + # Log non-HTTPException errors from callbacks but don't break the flow + verbose_proxy_logger.exception( + f"[Non-Blocking] Error in async_post_call_failure_hook callback: {e}" + ) except Exception as e: verbose_proxy_logger.exception( - f"[Non-Blocking] Error in post_call_failure_hook: {e}" + f"[Non-Blocking] Error setting up post_call_failure_hook callback: {e}" ) - return + + return transformed_exception def _is_proxy_only_llm_api_error( self, diff --git a/litellm/types/proxy/cloudzero_endpoints.py b/litellm/types/proxy/cloudzero_endpoints.py index 1d909bf7f8c..fc48717e80a 100644 --- a/litellm/types/proxy/cloudzero_endpoints.py +++ b/litellm/types/proxy/cloudzero_endpoints.py @@ -45,10 +45,10 @@ class CloudZeroExportResponse(BaseModel): class CloudZeroSettingsView(BaseModel): """Response model for viewing CloudZero settings with masked API key""" - api_key_masked: str = Field(..., description="Masked API key showing only first 4 and last 4 characters") - connection_id: str = Field(..., description="CloudZero connection ID for data submission") - timezone: str = Field(..., description="Timezone for date handling") - status: str = Field(..., description="Configuration status") + api_key_masked: Optional[str] = Field(None, description="Masked API key showing only first 4 and last 4 characters") + connection_id: Optional[str] = Field(None, description="CloudZero connection ID for data submission") + timezone: Optional[str] = Field(None, description="Timezone for date handling") + status: Optional[str] = Field(None, description="Configuration status") class CloudZeroSettingsUpdate(BaseModel): diff --git a/litellm/types/proxy/discovery_endpoints/ui_discovery_endpoints.py b/litellm/types/proxy/discovery_endpoints/ui_discovery_endpoints.py index f100dd35fa6..dc167667bc0 100644 --- a/litellm/types/proxy/discovery_endpoints/ui_discovery_endpoints.py +++ b/litellm/types/proxy/discovery_endpoints/ui_discovery_endpoints.py @@ -7,3 +7,4 @@ class UiDiscoveryEndpoints(BaseModel): server_root_path: str proxy_base_url: Optional[str] auto_redirect_to_sso: bool + admin_ui_disabled: bool diff --git a/litellm/types/proxy/management_endpoints/ui_sso.py b/litellm/types/proxy/management_endpoints/ui_sso.py index 820b0164400..187d8c97c05 100644 --- a/litellm/types/proxy/management_endpoints/ui_sso.py +++ b/litellm/types/proxy/management_endpoints/ui_sso.py @@ -1,10 +1,12 @@ -from typing import List, Literal, Optional, Union +from typing import Dict, List, Literal, Optional, Union from pydantic import Field from typing_extensions import TypedDict from litellm.types.utils import LiteLLMPydanticObjectBase +from litellm.proxy._types import LitellmUserRoles + class LiteLLM_UpperboundKeyGenerateParams(LiteLLMPydanticObjectBase): """ @@ -60,6 +62,30 @@ class AccessControl_UI_AccessMode(LiteLLMPydanticObjectBase): sso_group_jwt_field: str +class RoleMappings(LiteLLMPydanticObjectBase): + """ + Configuration for mapping SSO groups to LiteLLM roles. + + The system will look at the group_claim field in the SSO token to determine + which role to assign the user based on the roles mapping. + """ + + provider: str = Field( + description="SSO Provider name (e.g., 'google', 'microsoft', 'generic')" + ) + group_claim: str = Field( + description="The field name in the SSO token that contains the groups array (e.g., 'groups', 'roles')" + ) + default_role: Optional[LitellmUserRoles] = Field( + default=None, + description="Default role to assign if user's groups don't match any role mappings. Must be a valid LitellmUserRoles value (e.g., 'proxy_admin', 'internal_user', 'proxy_admin_viewer')" + ) + roles: Dict[LitellmUserRoles, List[str]] = Field( + default_factory=dict, + description="Mapping of LiteLLM role names to arrays of SSO group names. Example: {'proxy_admin': ['group-1', 'group-2'], 'proxy_admin_viewer': ['group-3']}" + ) + + class SSOConfig(LiteLLMPydanticObjectBase): """ Configuration for SSO environment variables and settings @@ -127,6 +153,12 @@ class SSOConfig(LiteLLMPydanticObjectBase): description="Access mode for the UI", ) + # Role Mappings + role_mappings: Optional[RoleMappings] = Field( + default=None, + description="Configuration for mapping SSO groups to LiteLLM roles based on group claims in the SSO token", + ) + class DefaultTeamSSOParams(LiteLLMPydanticObjectBase): """ diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 3416459bc28..144e503acdf 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -2564,6 +2564,9 @@ class CostBreakdown(TypedDict, total=False): original_cost: float # Cost before discount (optional) discount_percent: float # Discount percentage applied (e.g., 0.05 = 5%) (optional) discount_amount: float # Discount amount in USD (optional) + margin_percent: float # Margin percentage applied (e.g., 0.10 = 10%) (optional) + margin_fixed_amount: float # Fixed margin amount in USD (optional) + margin_total_amount: float # Total margin added in USD (optional) class StandardLoggingPayloadStatusFields(TypedDict, total=False): @@ -3014,6 +3017,13 @@ class LlmProviders(str, Enum): AMAZON_NOVA = "amazon_nova" A2A_AGENT = "a2a_agent" LANGGRAPH = "langgraph" + MINIMAX = "minimax" + SYNTHETIC = "synthetic" + APERTIS = "apertis" + NANOGPT = "nano-gpt" + POE = "poe" + CHUTES = "chutes" + # Create a set of all provider values for quick lookup diff --git a/litellm/utils.py b/litellm/utils.py index 805fbafcfce..102df5d595e 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -568,6 +568,111 @@ def get_dynamic_callbacks( return returned_callbacks +def _is_gemini_model(model: Optional[str], custom_llm_provider: Optional[str]) -> bool: + """ + Check if the target model is a Gemini or Vertex AI Gemini model. + """ + if custom_llm_provider in ["gemini", "vertex_ai", "vertex_ai_beta"]: + # For vertex_ai, check if it's actually a Gemini model + if custom_llm_provider in ["vertex_ai", "vertex_ai_beta"]: + return model is not None and "gemini" in model.lower() + return True + + # Check if model name contains gemini + return model is not None and "gemini" in model.lower() + + +def _remove_thought_signature_from_id(tool_call_id: str, separator: str) -> str: + """ + Remove thought signature from a tool call ID. + """ + if separator in tool_call_id: + return tool_call_id.split(separator, 1)[0] + return tool_call_id + + +def _process_assistant_message_tool_calls( + msg_copy: dict, thought_signature_separator: str +) -> dict: + """ + Process assistant message to remove thought signatures from tool call IDs. + """ + role = msg_copy.get("role") + tool_calls = msg_copy.get("tool_calls") + + if role == "assistant" and isinstance(tool_calls, list): + new_tool_calls = [] + for tc in tool_calls: + # Handle both dict and Pydantic model tool calls + if hasattr(tc, "model_dump"): + # It's a Pydantic model, convert to dict + tc_dict = tc.model_dump() + elif isinstance(tc, dict): + tc_dict = tc.copy() + else: + new_tool_calls.append(tc) + continue + + # Remove thought signature from ID if present + if isinstance(tc_dict.get("id"), str): + if thought_signature_separator in tc_dict["id"]: + tc_dict["id"] = _remove_thought_signature_from_id( + tc_dict["id"], thought_signature_separator + ) + + new_tool_calls.append(tc_dict) + msg_copy["tool_calls"] = new_tool_calls + + return msg_copy + + +def _process_tool_message_id(msg_copy: dict, thought_signature_separator: str) -> dict: + """ + Process tool message to remove thought signature from tool_call_id. + """ + if msg_copy.get("role") == "tool" and isinstance( + msg_copy.get("tool_call_id"), str + ): + if thought_signature_separator in msg_copy["tool_call_id"]: + msg_copy["tool_call_id"] = _remove_thought_signature_from_id( + msg_copy["tool_call_id"], thought_signature_separator + ) + + return msg_copy + + +def _remove_thought_signatures_from_messages( + messages: List, thought_signature_separator: str +) -> List: + """ + Remove thought signatures from tool call IDs in all messages. + """ + processed_messages = [] + + for msg in messages: + # Handle Pydantic models (convert to dict) + if hasattr(msg, "model_dump"): + msg_dict = msg.model_dump() + elif isinstance(msg, dict): + msg_dict = msg.copy() + else: + # Unknown type, keep as is + processed_messages.append(msg) + continue + + # Process assistant messages with tool_calls + msg_dict = _process_assistant_message_tool_calls( + msg_dict, thought_signature_separator + ) + + # Process tool messages with tool_call_id + msg_dict = _process_tool_message_id(msg_dict, thought_signature_separator) + + processed_messages.append(msg_dict) + + return processed_messages + + def function_setup( # noqa: PLR0915 original_function: str, rules_obj, start_time, *args, **kwargs ): # just run once to check if user wants to send their data anywhere - PostHog/Sentry/Slack/etc. @@ -779,6 +884,58 @@ def function_setup( # noqa: PLR0915 input=buffer.getvalue(), model=model, ) + + ### REMOVE THOUGHT SIGNATURES FROM TOOL CALL IDS FOR NON-GEMINI MODELS ### + # Gemini models embed thought signatures in tool call IDs. When sending + # messages with tool calls to non-Gemini providers, we need to remove these + # signatures to ensure compatibility. + if isinstance(messages, list) and len(messages) > 0: + try: + from litellm.litellm_core_utils.get_llm_provider_logic import ( + get_llm_provider, + ) + from litellm.litellm_core_utils.prompt_templates.factory import ( + THOUGHT_SIGNATURE_SEPARATOR, + ) + + # Get custom_llm_provider to determine target provider + custom_llm_provider = kwargs.get("custom_llm_provider") + + # If custom_llm_provider not in kwargs, try to determine it from the model + if not custom_llm_provider and model: + try: + _, custom_llm_provider, _, _ = get_llm_provider( + model=model, + custom_llm_provider=custom_llm_provider, + ) + except Exception: + # If we can't determine the provider, skip this processing + pass + + # Only process if target is NOT a Gemini model + if not _is_gemini_model(model, custom_llm_provider): + verbose_logger.debug( + "Removing thought signatures from tool call IDs for non-Gemini model" + ) + + # Process messages to remove thought signatures + processed_messages = _remove_thought_signatures_from_messages( + messages, THOUGHT_SIGNATURE_SEPARATOR + ) + + # Update messages in kwargs or args + if "messages" in kwargs: + kwargs["messages"] = processed_messages + elif len(args) > 1: + args_list = list(args) + args_list[1] = processed_messages + args = tuple(args_list) + + except Exception as e: + # Log the error but don't fail the request + verbose_logger.warning( + f"Error removing thought signatures from tool call IDs: {str(e)}" + ) elif ( call_type == CallTypes.embedding.value or call_type == CallTypes.aembedding.value @@ -7224,6 +7381,8 @@ class ProviderConfigManager: return litellm.IBMWatsonXAIConfig() elif litellm.LlmProviders.EMPOWER == provider: return litellm.EmpowerChatConfig() + elif litellm.LlmProviders.MINIMAX == provider: + return litellm.MinimaxChatConfig() elif litellm.LlmProviders.GITHUB == provider: return litellm.GithubChatConfig() elif litellm.LlmProviders.COMPACTIFAI == provider: @@ -7501,6 +7660,12 @@ class ProviderConfigManager: ) return AzureAnthropicMessagesConfig() + elif litellm.LlmProviders.MINIMAX == provider: + from litellm.llms.minimax.messages.transformation import ( + MinimaxMessagesConfig, + ) + + return MinimaxMessagesConfig() return None @staticmethod @@ -8096,6 +8261,12 @@ class ProviderConfigManager: ) return VertexAITextToSpeechConfig() + elif litellm.LlmProviders.MINIMAX == provider: + from litellm.llms.minimax.text_to_speech.transformation import ( + MinimaxTextToSpeechConfig, + ) + + return MinimaxTextToSpeechConfig() elif litellm.LlmProviders.AWS_POLLY == provider: from litellm.llms.aws_polly.text_to_speech.transformation import ( AWSPollyTextToSpeechConfig, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index f4b42d1fd6e..4651107c5b8 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -249,6 +249,30 @@ "/v1/images/generations" ] }, + "aiml/google/imagen-4.0-ultra-generate-001": { + "litellm_provider": "aiml", + "metadata": { + "notes": "Imagen 4.0 Ultra Generate API - Photorealistic image generation with precise text rendering" + }, + "mode": "image_generation", + "output_cost_per_image": 0.063, + "source": "https://docs.aimlapi.com/api-references/image-models/google/imagen-4-ultra-generate", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "aiml/google/nano-banana-pro": { + "litellm_provider": "aiml", + "metadata": { + "notes": "Gemini 3 Pro Image (Nano Banana Pro) - Advanced text-to-image generation with reasoning and 4K resolution support" + }, + "mode": "image_generation", + "output_cost_per_image": 0.1575, + "source": "https://docs.aimlapi.com/api-references/image-models/google/gemini-3-pro-image-preview", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, "amazon.nova-canvas-v1:0": { "litellm_provider": "bedrock", "max_input_tokens": 2600, @@ -1357,6 +1381,20 @@ "litellm_provider": "azure", "mode": "chat" }, + "azure_ai/gpt-oss-120b": { + "input_cost_per_token": 1.5e-7, + "output_cost_per_token": 6e-7, + "litellm_provider": "azure_ai", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "source": "https://azure.microsoft.com/en-us/pricing/details/cognitive-services/openai-service/", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, "azure/eu/gpt-4o-2024-08-06": { "deprecation_date": "2026-02-27", "cache_read_input_token_cost": 1.375e-06, @@ -3494,6 +3532,40 @@ "supports_service_tier": true, "supports_vision": true }, + "azure/gpt-5.2-chat": { + "cache_read_input_token_cost": 1.75e-07, + "cache_read_input_token_cost_priority": 3.5e-07, + "input_cost_per_token": 1.75e-06, + "input_cost_per_token_priority": 3.5e-06, + "litellm_provider": "azure", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1.4e-05, + "output_cost_per_token_priority": 2.8e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, "azure/gpt-5.2-chat-2025-12-11": { "cache_read_input_token_cost": 1.75e-07, "cache_read_input_token_cost_priority": 3.5e-07, @@ -3707,6 +3779,32 @@ "/v1/images/generations" ] }, + "azure/gpt-image-1.5": { + "cache_read_input_image_token_cost": 2e-06, + "cache_read_input_token_cost": 1.25e-06, + "input_cost_per_token": 5e-06, + "input_cost_per_image_token": 8e-06, + "litellm_provider": "azure", + "mode": "image_generation", + "output_cost_per_image_token": 3.2e-05, + "supported_endpoints": [ + "/v1/images/generations", + "/v1/images/edits" + ] + }, + "azure/gpt-image-1.5-2025-12-16": { + "cache_read_input_image_token_cost": 2e-06, + "cache_read_input_token_cost": 1.25e-06, + "input_cost_per_token": 5e-06, + "input_cost_per_image_token": 8e-06, + "litellm_provider": "azure", + "mode": "image_generation", + "output_cost_per_image_token": 3.2e-05, + "supported_endpoints": [ + "/v1/images/generations", + "/v1/images/edits" + ] + }, "azure/low/1024-x-1024/gpt-image-1-mini": { "input_cost_per_pixel": 2.0751953125e-09, "litellm_provider": "azure", @@ -18053,75 +18151,6 @@ "supports_response_schema": true, "supports_vision": true }, - "groq/deepseek-r1-distill-llama-70b": { - "input_cost_per_token": 7.5e-07, - "litellm_provider": "groq", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 9.9e-07, - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, - "groq/distil-whisper-large-v3-en": { - "input_cost_per_second": 5.56e-06, - "litellm_provider": "groq", - "mode": "audio_transcription", - "output_cost_per_second": 0.0 - }, - "groq/gemma-7b-it": { - "deprecation_date": "2024-12-18", - "input_cost_per_token": 7e-08, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 7e-08, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, - "groq/gemma2-9b-it": { - "input_cost_per_token": 2e-07, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 2e-07, - "supports_function_calling": false, - "supports_response_schema": false, - "supports_tool_choice": false - }, - "groq/llama-3.1-405b-reasoning": { - "input_cost_per_token": 5.9e-07, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 7.9e-07, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, - "groq/llama-3.1-70b-versatile": { - "deprecation_date": "2025-01-24", - "input_cost_per_token": 5.9e-07, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 7.9e-07, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, "groq/llama-3.1-8b-instant": { "input_cost_per_token": 5e-08, "litellm_provider": "groq", @@ -18134,97 +18163,6 @@ "supports_response_schema": false, "supports_tool_choice": true }, - "groq/llama-3.2-11b-text-preview": { - "deprecation_date": "2024-10-28", - "input_cost_per_token": 1.8e-07, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 1.8e-07, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, - "groq/llama-3.2-11b-vision-preview": { - "deprecation_date": "2025-04-14", - "input_cost_per_token": 1.8e-07, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 1.8e-07, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_vision": true - }, - "groq/llama-3.2-1b-preview": { - "deprecation_date": "2025-04-14", - "input_cost_per_token": 4e-08, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 4e-08, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, - "groq/llama-3.2-3b-preview": { - "deprecation_date": "2025-04-14", - "input_cost_per_token": 6e-08, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 6e-08, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, - "groq/llama-3.2-90b-text-preview": { - "deprecation_date": "2024-11-25", - "input_cost_per_token": 9e-07, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 9e-07, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, - "groq/llama-3.2-90b-vision-preview": { - "deprecation_date": "2025-04-14", - "input_cost_per_token": 9e-07, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 9e-07, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_vision": true - }, - "groq/llama-3.3-70b-specdec": { - "deprecation_date": "2025-04-14", - "input_cost_per_token": 5.9e-07, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 9.9e-07, - "supports_tool_choice": true - }, "groq/llama-3.3-70b-versatile": { "input_cost_per_token": 5.9e-07, "litellm_provider": "groq", @@ -18237,7 +18175,7 @@ "supports_response_schema": false, "supports_tool_choice": true }, - "groq/llama-guard-3-8b": { + "groq/meta-llama/llama-guard-4-12b": { "input_cost_per_token": 2e-07, "litellm_provider": "groq", "max_input_tokens": 8192, @@ -18246,44 +18184,6 @@ "mode": "chat", "output_cost_per_token": 2e-07 }, - "groq/llama2-70b-4096": { - "input_cost_per_token": 7e-07, - "litellm_provider": "groq", - "max_input_tokens": 4096, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 8e-07, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, - "groq/llama3-groq-70b-8192-tool-use-preview": { - "deprecation_date": "2025-01-06", - "input_cost_per_token": 8.9e-07, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 8.9e-07, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, - "groq/llama3-groq-8b-8192-tool-use-preview": { - "deprecation_date": "2025-01-06", - "input_cost_per_token": 1.9e-07, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 1.9e-07, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, "groq/meta-llama/llama-4-maverick-17b-128e-instruct": { "input_cost_per_token": 2e-07, "litellm_provider": "groq", @@ -18294,7 +18194,8 @@ "output_cost_per_token": 6e-07, "supports_function_calling": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": true }, "groq/meta-llama/llama-4-scout-17b-16e-instruct": { "input_cost_per_token": 1.1e-07, @@ -18306,41 +18207,8 @@ "output_cost_per_token": 3.4e-07, "supports_function_calling": true, "supports_response_schema": true, - "supports_tool_choice": true - }, - "groq/mistral-saba-24b": { - "input_cost_per_token": 7.9e-07, - "litellm_provider": "groq", - "max_input_tokens": 32000, - "max_output_tokens": 32000, - "max_tokens": 32000, - "mode": "chat", - "output_cost_per_token": 7.9e-07 - }, - "groq/mixtral-8x7b-32768": { - "deprecation_date": "2025-03-20", - "input_cost_per_token": 2.4e-07, - "litellm_provider": "groq", - "max_input_tokens": 32768, - "max_output_tokens": 32768, - "max_tokens": 32768, - "mode": "chat", - "output_cost_per_token": 2.4e-07, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, - "groq/moonshotai/kimi-k2-instruct": { - "input_cost_per_token": 1e-06, - "litellm_provider": "groq", - "max_input_tokens": 131072, - "max_output_tokens": 16384, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 3e-06, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": true }, "groq/moonshotai/kimi-k2-instruct-0905": { "input_cost_per_token": 1e-06, @@ -19580,6 +19448,80 @@ "output_cost_per_token": 1.2e-06, "supports_system_messages": true }, + "minimax/speech-02-hd": { + "input_cost_per_character": 0.0001, + "litellm_provider": "minimax", + "mode": "audio_speech", + "supported_endpoints": [ + "/v1/audio/speech" + ] + }, + "minimax/speech-02-turbo": { + "input_cost_per_character": 0.00006, + "litellm_provider": "minimax", + "mode": "audio_speech", + "supported_endpoints": [ + "/v1/audio/speech" + ] + }, + "minimax/speech-2.6-hd": { + "input_cost_per_character": 0.0001, + "litellm_provider": "minimax", + "mode": "audio_speech", + "supported_endpoints": [ + "/v1/audio/speech" + ] + }, + "minimax/speech-2.6-turbo": { + "input_cost_per_character": 0.00006, + "litellm_provider": "minimax", + "mode": "audio_speech", + "supported_endpoints": [ + "/v1/audio/speech" + ] + }, + "minimax/MiniMax-M2.1": { + "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.2e-06, + "cache_read_input_token_cost": 3e-08, + "cache_creation_input_token_cost": 3.75e-07, + "litellm_provider": "minimax", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "max_input_tokens": 1000000, + "max_output_tokens": 8192 + }, + "minimax/MiniMax-M2.1-lightning": { + "input_cost_per_token": 3e-07, + "output_cost_per_token": 2.4e-06, + "cache_read_input_token_cost": 3e-08, + "cache_creation_input_token_cost": 3.75e-07, + "litellm_provider": "minimax", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "max_input_tokens": 1000000, + "max_output_tokens": 8192 + }, + "minimax/MiniMax-M2": { + "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.2e-06, + "cache_read_input_token_cost": 3e-08, + "cache_creation_input_token_cost": 3.75e-07, + "litellm_provider": "minimax", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "max_input_tokens": 200000, + "max_output_tokens": 8192 + }, "mistral.magistral-small-2509": { "input_cost_per_token": 5e-07, "litellm_provider": "bedrock_converse", @@ -25111,6 +25053,7 @@ "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/Qwen/Qwen2.5-7B-Instruct-Turbo": { @@ -25118,6 +25061,7 @@ "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput": { @@ -25129,6 +25073,7 @@ "source": "https://www.together.ai/models/qwen3-235b-a22b-instruct-2507-fp8", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/Qwen/Qwen3-235B-A22B-Thinking-2507": { @@ -25140,6 +25085,7 @@ "source": "https://www.together.ai/models/qwen3-235b-a22b-thinking-2507", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/Qwen/Qwen3-235B-A22B-fp8-tput": { @@ -25162,6 +25108,7 @@ "source": "https://www.together.ai/models/qwen3-coder-480b-a35b-instruct", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/deepseek-ai/DeepSeek-R1": { @@ -25174,6 +25121,7 @@ "output_cost_per_token": 7e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/deepseek-ai/DeepSeek-R1-0528-tput": { @@ -25185,6 +25133,7 @@ "source": "https://www.together.ai/models/deepseek-r1-0528-throughput", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/deepseek-ai/DeepSeek-V3": { @@ -25197,6 +25146,7 @@ "output_cost_per_token": 1.25e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/deepseek-ai/DeepSeek-V3.1": { @@ -25216,6 +25166,7 @@ "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo": { @@ -25245,6 +25196,7 @@ "output_cost_per_token": 8.5e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/meta-llama/Llama-4-Scout-17B-16E-Instruct": { @@ -25254,6 +25206,7 @@ "output_cost_per_token": 5.9e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo": { @@ -25263,6 +25216,7 @@ "output_cost_per_token": 3.5e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo": { @@ -25318,6 +25272,7 @@ "source": "https://www.together.ai/models/kimi-k2-instruct", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/openai/gpt-oss-120b": { @@ -25329,6 +25284,7 @@ "source": "https://www.together.ai/models/gpt-oss-120b", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/openai/gpt-oss-20b": { @@ -25340,6 +25296,7 @@ "source": "https://www.together.ai/models/gpt-oss-20b", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/togethercomputer/CodeLlama-34b-Instruct": { @@ -25358,6 +25315,7 @@ "source": "https://www.together.ai/models/glm-4-5-air", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/zai-org/GLM-4.6": { @@ -25394,6 +25352,7 @@ "source": "https://www.together.ai/models/qwen3-next-80b-a3b-instruct", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/Qwen/Qwen3-Next-80B-A3B-Thinking": { @@ -25405,6 +25364,7 @@ "source": "https://www.together.ai/models/qwen3-next-80b-a3b-thinking", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_response_schema": true, "supports_tool_choice": true }, "tts-1": { diff --git a/poetry.lock b/poetry.lock index 4eae35c7f36..ee97c00594c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "aiofiles" diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 152b3df52e6..45ee47c01bc 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -47,7 +47,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "ai21": { @@ -64,7 +65,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "ai21_chat": { @@ -81,7 +83,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "amazon_nova": { @@ -98,7 +101,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "anthropic": { @@ -116,7 +120,8 @@ "batches": true, "rerank": false, "skills": true, - "a2a": true + "a2a": true, + "interactions": true } }, "anthropic_text": { @@ -134,7 +139,24 @@ "batches": true, "rerank": false, "skills": true, - "a2a": true + "a2a": true, + "interactions": true + } + }, + "apertis": { + "display_name": "Apertis (`apertis`)", + "endpoints": { + "chat_completions": true, + "messages": false, + "responses": false, + "embeddings": true, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": false, + "a2a": false } }, "assemblyai": { @@ -151,7 +173,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "auto_router": { @@ -168,7 +191,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "bedrock": { @@ -185,7 +209,8 @@ "moderations": false, "batches": false, "rerank": true, - "a2a": true + "a2a": true, + "interactions": true } }, "sagemaker": { @@ -202,7 +227,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "aws_polly": { @@ -235,7 +261,8 @@ "moderations": true, "batches": true, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "azure_ai": { @@ -253,7 +280,8 @@ "batches": true, "rerank": false, "ocr": true, - "a2a": true + "a2a": true, + "interactions": true } }, "azure_ai/doc-intelligence": { @@ -287,7 +315,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "azure_text": { @@ -304,7 +333,8 @@ "moderations": true, "batches": true, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "baseten": { @@ -321,7 +351,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "bytez": { @@ -338,7 +369,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "cerebras": { @@ -355,7 +387,24 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true + } + }, + "chutes": { + "display_name": "Chutes (`chutes`)", + "endpoints": { + "chat_completions": true, + "messages": false, + "responses": false, + "embeddings": true, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": false, + "a2a": false } }, "clarifai": { @@ -372,7 +421,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "cloudflare": { @@ -389,7 +439,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "codestral": { @@ -406,7 +457,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "cohere": { @@ -423,7 +475,8 @@ "moderations": false, "batches": false, "rerank": true, - "a2a": true + "a2a": true, + "interactions": true } }, "cohere_chat": { @@ -440,7 +493,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "cometapi": { @@ -457,7 +511,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "compactifai": { @@ -474,7 +529,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "custom": { @@ -491,7 +547,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "custom_openai": { @@ -508,7 +565,8 @@ "moderations": true, "batches": true, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "dashscope": { @@ -525,7 +583,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "databricks": { @@ -542,7 +601,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "dataforseo": { @@ -576,7 +636,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "deepgram": { @@ -593,7 +654,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "deepinfra": { @@ -610,7 +672,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "deepseek": { @@ -627,7 +690,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "elevenlabs": { @@ -644,7 +708,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "exa_ai": { @@ -678,7 +743,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "fal_ai": { @@ -695,7 +761,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "featherless_ai": { @@ -712,7 +779,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "fireworks_ai": { @@ -729,7 +797,8 @@ "moderations": false, "batches": false, "rerank": true, - "a2a": true + "a2a": true, + "interactions": true } }, "firecrawl": { @@ -780,7 +849,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "galadriel": { @@ -797,7 +867,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "github_copilot": { @@ -814,7 +885,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "github": { @@ -831,7 +903,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "vertex_ai": { @@ -849,7 +922,8 @@ "batches": false, "rerank": false, "ocr": true, - "a2a": true + "a2a": true, + "interactions": true } }, "vertex_ai/chirp": { @@ -900,7 +974,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "groq": { @@ -917,7 +992,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "heroku": { @@ -934,7 +1010,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "hosted_vllm": { @@ -952,7 +1029,8 @@ "batches": true, "files": true, "rerank": true, - "a2a": true + "a2a": true, + "interactions": true } }, "huggingface": { @@ -969,7 +1047,8 @@ "moderations": false, "batches": false, "rerank": true, - "a2a": true + "a2a": true, + "interactions": true } }, "hyperbolic": { @@ -986,7 +1065,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "watsonx": { @@ -1003,7 +1083,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "infinity": { @@ -1052,7 +1133,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "lemonade": { @@ -1069,7 +1151,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "litellm_proxy": { @@ -1086,7 +1169,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "llamafile": { @@ -1103,7 +1187,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "lm_studio": { @@ -1120,7 +1205,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "maritalk": { @@ -1137,7 +1223,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "meta_llama": { @@ -1154,7 +1241,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "mistral": { @@ -1172,7 +1260,8 @@ "batches": false, "rerank": false, "ocr": true, - "a2a": true + "a2a": true, + "interactions": true } }, "moonshot": { @@ -1189,7 +1278,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "docker_model_runner": { @@ -1206,7 +1296,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "morph": { @@ -1223,7 +1314,24 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true + } + }, + "nanogpt": { + "display_name": "NanoGPT (`nanogpt`)", + "endpoints": { + "chat_completions": true, + "messages": false, + "responses": false, + "embeddings": true, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": false, + "a2a": false } }, "nebius": { @@ -1240,7 +1348,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "nlp_cloud": { @@ -1257,7 +1366,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "novita": { @@ -1274,7 +1384,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "nscale": { @@ -1291,7 +1402,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "nvidia_nim": { @@ -1308,7 +1420,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "oci": { @@ -1325,7 +1438,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "ollama": { @@ -1342,7 +1456,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "ollama_chat": { @@ -1359,7 +1474,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "oobabooga": { @@ -1376,7 +1492,8 @@ "moderations": true, "batches": true, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "openai": { @@ -1402,7 +1519,8 @@ "retrieve_container_file": true, "retrieve_container_file_content": true, "delete_container_file": true, - "a2a": true + "a2a": true, + "interactions": true } }, "openai_like": { @@ -1435,7 +1553,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "ovhcloud": { @@ -1452,7 +1571,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "parallel_ai": { @@ -1487,7 +1607,8 @@ "batches": false, "rerank": false, "search": true, - "a2a": true + "a2a": true, + "interactions": true } }, "petals": { @@ -1504,7 +1625,24 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true + } + }, + "poe": { + "display_name": "Poe (`poe`)", + "endpoints": { + "chat_completions": true, + "messages": false, + "responses": false, + "embeddings": true, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": false, + "a2a": false } }, "publicai": { @@ -1521,7 +1659,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "predibase": { @@ -1538,7 +1677,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "recraft": { @@ -1571,7 +1711,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "runwayml": { @@ -1605,7 +1746,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "searxng": { @@ -1639,7 +1781,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "sap": { @@ -1656,7 +1799,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "snowflake": { @@ -1673,7 +1817,24 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true + } + }, + "synthetic": { + "display_name": "Synthetic (`synthetic`)", + "endpoints": { + "chat_completions": true, + "messages": true, + "responses": true, + "embeddings": true, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": false, + "a2a": false } }, "text-completion-codestral": { @@ -1690,7 +1851,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "text-completion-openai": { @@ -1707,7 +1869,8 @@ "moderations": true, "batches": true, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "together_ai": { @@ -1724,7 +1887,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "topaz": { @@ -1741,7 +1905,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "tavily": { @@ -1775,7 +1940,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "v0": { @@ -1792,7 +1958,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "vercel_ai_gateway": { @@ -1809,7 +1976,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "vllm": { @@ -1827,7 +1995,8 @@ "batches": true, "files": true, "rerank": true, - "a2a": true + "a2a": true, + "interactions": true } }, "volcengine": { @@ -1844,7 +2013,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "voyage": { @@ -1877,7 +2047,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "watsonx_text": { @@ -1894,7 +2065,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "xai": { @@ -1911,7 +2083,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "xinference": { @@ -1944,7 +2117,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "ragflow": { @@ -1962,7 +2136,8 @@ "batches": false, "rerank": false, "vector_stores": true, - "a2a": true + "a2a": true, + "interactions": true } }, "cursor": { @@ -1979,7 +2154,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "langgraph": { @@ -1996,7 +2172,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "vertex_ai/agent_engine": { @@ -2013,7 +2190,8 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } }, "pydantic_ai_agents": { @@ -2064,8 +2242,9 @@ "moderations": false, "batches": false, "rerank": false, - "a2a": true + "a2a": true, + "interactions": true } } } -} \ No newline at end of file +} diff --git a/tests/agent_tests/local_vertex_agent.py b/tests/agent_tests/local_vertex_agent.py index 3cc9f868612..cfc202936b3 100644 --- a/tests/agent_tests/local_vertex_agent.py +++ b/tests/agent_tests/local_vertex_agent.py @@ -21,7 +21,7 @@ from google.auth.transport.requests import Request import httpx # Configuration - update these for your agent -PROJECT_ID = "gen-lang-client-0682925754" # Your GCP project ID +PROJECT_ID = "test-gcp-project-id-123" # Your GCP project ID (test value) LOCATION = "us-central1" # Your agent's location # For Reasoning Engines, use just the numeric ID at the end diff --git a/tests/litellm_utils_tests/test_cyberark.py b/tests/litellm_utils_tests/test_cyberark.py index b94e5949534..b7cb25791a9 100644 --- a/tests/litellm_utils_tests/test_cyberark.py +++ b/tests/litellm_utils_tests/test_cyberark.py @@ -13,7 +13,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from litellm._uuid import uuid # Set up environment variables for testing -os.environ["CYBERARK_API_KEY"] = "2syke5r262b6je2f4et1x3jptmry3frfx83t65e6417zad632e5qq8a" +os.environ["CYBERARK_API_KEY"] = "test-cyberark-api-key-909" os.environ["CYBERARK_API_BASE"] = "http://0.0.0.0:8080" os.environ["CYBERARK_ACCOUNT"] = "default" os.environ["CYBERARK_USERNAME"] = "admin" diff --git a/tests/llm_translation/test_azure_openai.py b/tests/llm_translation/test_azure_openai.py index e36b6952d7a..3fd908f86d7 100644 --- a/tests/llm_translation/test_azure_openai.py +++ b/tests/llm_translation/test_azure_openai.py @@ -196,7 +196,7 @@ def test_process_azure_endpoint_url(api_base, model, expected_endpoint): "azure_deployment": model, "max_retries": 2, "timeout": 600, - "api_key": "f28ab7b695af4154bc53498e5bdccb07", + "api_key": "sk-test-mock-key-505", }, "model": model, } diff --git a/tests/llm_translation/test_bedrock_agentcore.py b/tests/llm_translation/test_bedrock_agentcore.py index 029bdf4e37b..3afb01482ac 100644 --- a/tests/llm_translation/test_bedrock_agentcore.py +++ b/tests/llm_translation/test_bedrock_agentcore.py @@ -218,7 +218,7 @@ def test_bedrock_agentcore_with_api_key_bearer_token(): from litellm.llms.custom_httpx.http_handler import HTTPHandler client = HTTPHandler() - test_jwt_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + test_jwt_token = "test-jwt-token-header.payload.signature" with patch.object(client, "post", return_value=MagicMock()) as mock_post: try: diff --git a/tests/llm_translation/test_bedrock_completion.py b/tests/llm_translation/test_bedrock_completion.py index bd08d4444f6..78c9f94239b 100644 --- a/tests/llm_translation/test_bedrock_completion.py +++ b/tests/llm_translation/test_bedrock_completion.py @@ -295,7 +295,7 @@ def bedrock_session_token_creds(): aws_role_name = ( "arn:aws:iam::335785316107:role/litellm-github-unit-tests-circleci" ) - aws_web_identity_token = "oidc/circleci_v2/" + aws_web_identity_token = "test-oidc-token-123" creds = bllm.get_credentials( aws_region_name=aws_region_name, diff --git a/tests/llm_translation/test_minimax_tts.py b/tests/llm_translation/test_minimax_tts.py new file mode 100644 index 00000000000..88ddf9be0b1 --- /dev/null +++ b/tests/llm_translation/test_minimax_tts.py @@ -0,0 +1,371 @@ +""" +Tests for MiniMax Text-to-Speech integration +""" + +import os +import sys +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import pytest + +sys.path.insert( + 0, os.path.abspath("../..") +) # Adds the parent directory to the system path + +import litellm +from litellm import speech +from litellm.llms.minimax.text_to_speech.transformation import ( + MinimaxTextToSpeechConfig, +) + + +class TestMinimaxTextToSpeechConfig: + """Test MiniMax TTS configuration and parameter mapping""" + + def test_get_supported_openai_params(self): + """Test that supported OpenAI params are correctly defined""" + config = MinimaxTextToSpeechConfig() + supported_params = config.get_supported_openai_params("speech-2.6-hd") + + assert "voice" in supported_params + assert "response_format" in supported_params + assert "speed" in supported_params + + def test_voice_mapping(self): + """Test OpenAI voice to MiniMax voice_id mapping""" + config = MinimaxTextToSpeechConfig() + + # Test OpenAI voice mappings + assert config._extract_voice_id("alloy") == "male-qn-qingse" + assert config._extract_voice_id("echo") == "male-qn-jingying" + assert config._extract_voice_id("nova") == "female-yujie" + + # Test custom voice passthrough + assert config._extract_voice_id("custom-voice-id") == "custom-voice-id" + + def test_format_mapping(self): + """Test response format mapping""" + config = MinimaxTextToSpeechConfig() + + assert config.FORMAT_MAPPINGS["mp3"] == "mp3" + assert config.FORMAT_MAPPINGS["pcm"] == "pcm" + assert config.FORMAT_MAPPINGS["wav"] == "wav" + assert config.FORMAT_MAPPINGS["flac"] == "flac" + + def test_map_openai_params_basic(self): + """Test basic parameter mapping from OpenAI to MiniMax format""" + config = MinimaxTextToSpeechConfig() + + optional_params = { + "response_format": "mp3", + "speed": 1.5, + } + + voice, mapped_params = config.map_openai_params( + model="speech-2.6-hd", + optional_params=optional_params, + voice="alloy", + ) + + assert voice == "male-qn-qingse" + assert mapped_params["format"] == "mp3" + assert mapped_params["speed"] == 1.5 + assert mapped_params["voice_id"] == "male-qn-qingse" + + def test_map_openai_params_speed_clamping(self): + """Test that speed is clamped to MiniMax's supported range""" + config = MinimaxTextToSpeechConfig() + + # Test speed too high + optional_params = {"speed": 5.0} + _, mapped_params = config.map_openai_params( + model="speech-2.6-hd", + optional_params=optional_params, + voice="alloy", + ) + assert mapped_params["speed"] == 2.0 # Clamped to max + + # Test speed too low + optional_params = {"speed": 0.1} + _, mapped_params = config.map_openai_params( + model="speech-2.6-hd", + optional_params=optional_params, + voice="alloy", + ) + assert mapped_params["speed"] == 0.5 # Clamped to min + + def test_map_openai_params_with_extra_body(self): + """Test that extra_body parameters are passed through""" + config = MinimaxTextToSpeechConfig() + + optional_params = { + "extra_body": { + "vol": 1.5, + "pitch": 2, + "sample_rate": 24000, + } + } + + _, mapped_params = config.map_openai_params( + model="speech-2.6-hd", + optional_params=optional_params, + voice="alloy", + ) + + assert mapped_params["vol"] == 1.5 + assert mapped_params["pitch"] == 2 + assert mapped_params["sample_rate"] == 24000 + + def test_validate_environment_with_api_key(self): + """Test environment validation with API key""" + config = MinimaxTextToSpeechConfig() + headers = {} + + result_headers = config.validate_environment( + headers=headers, + model="speech-2.6-hd", + api_key="test-api-key", + ) + + assert "Authorization" in result_headers + assert result_headers["Authorization"] == "Bearer test-api-key" + assert result_headers["Content-Type"] == "application/json" + + def test_validate_environment_missing_api_key(self): + """Test that validation fails without API key""" + config = MinimaxTextToSpeechConfig() + headers = {} + + # Mock both litellm.api_key and get_secret_str to return None + import litellm + from unittest.mock import patch + + original_api_key = litellm.api_key + try: + litellm.api_key = None + with patch("litellm.llms.minimax.text_to_speech.transformation.get_secret_str", return_value=None): + with pytest.raises(ValueError, match="MiniMax API key is required"): + config.validate_environment( + headers=headers, + model="speech-2.6-hd", + api_key=None, + ) + finally: + litellm.api_key = original_api_key + + def test_transform_text_to_speech_request(self): + """Test request transformation to MiniMax format""" + config = MinimaxTextToSpeechConfig() + + optional_params = { + "voice_id": "male-qn-qingse", + "speed": 1.2, + "format": "mp3", + "vol": 1.0, + "pitch": 0, + "sample_rate": 32000, + "bitrate": 128000, + "channel": 1, + } + + result = config.transform_text_to_speech_request( + model="speech-2.6-hd", + input="Hello, world!", + voice="male-qn-qingse", + optional_params=optional_params, + litellm_params={}, + headers={}, + ) + + assert "dict_body" in result + body = result["dict_body"] + + assert body["model"] == "speech-2.6-hd" + assert body["text"] == "Hello, world!" + assert body["stream"] is False + assert body["voice_setting"]["voice_id"] == "male-qn-qingse" + assert body["voice_setting"]["speed"] == 1.2 + assert body["audio_setting"]["format"] == "mp3" + assert body["audio_setting"]["sample_rate"] == 32000 + + def test_get_complete_url(self): + """Test URL construction""" + config = MinimaxTextToSpeechConfig() + + url = config.get_complete_url( + model="speech-2.6-hd", + api_base=None, + litellm_params={}, + ) + + assert url == "https://api.minimax.io/v1/t2a_v2" + + def test_get_complete_url_custom_base(self): + """Test URL construction with custom API base""" + config = MinimaxTextToSpeechConfig() + + url = config.get_complete_url( + model="speech-2.6-hd", + api_base="https://custom.api.com", + litellm_params={}, + ) + + assert url == "https://custom.api.com/v1/t2a_v2" + + +class TestMinimaxSpeechIntegration: + """Integration tests for MiniMax TTS via litellm.speech()""" + + @pytest.mark.skip(reason="Requires MiniMax API key") + def test_speech_basic(self): + """Test basic speech synthesis call""" + # This test requires a real API key + os.environ["MINIMAX_API_KEY"] = "your-api-key-here" + + speech_file_path = Path(__file__).parent / "test_minimax_speech.mp3" + + response = speech( + model="minimax/speech-2.6-hd", + voice="alloy", + input="Hello, this is a test of MiniMax text to speech.", + ) + + response.stream_to_file(speech_file_path) + + # Verify file was created + assert speech_file_path.exists() + assert speech_file_path.stat().st_size > 0 + + # Clean up + speech_file_path.unlink() + + @pytest.mark.skip(reason="Requires MiniMax API key") + def test_speech_with_custom_params(self): + """Test speech synthesis with custom parameters""" + os.environ["MINIMAX_API_KEY"] = "your-api-key-here" + + speech_file_path = Path(__file__).parent / "test_minimax_speech_custom.mp3" + + response = speech( + model="minimax/speech-2.6-turbo", + voice="nova", + input="Testing custom parameters.", + speed=1.5, + response_format="mp3", + extra_body={ + "vol": 1.2, + "pitch": 1, + "sample_rate": 24000, + }, + ) + + response.stream_to_file(speech_file_path) + + # Verify file was created + assert speech_file_path.exists() + assert speech_file_path.stat().st_size > 0 + + # Clean up + speech_file_path.unlink() + + def test_speech_mock_response(self): + """Test speech synthesis with mocked response""" + from unittest.mock import MagicMock, patch + + # Create mock audio data (hex-encoded as MiniMax returns) + mock_audio_bytes = b"fake audio data for testing" + mock_audio_hex = mock_audio_bytes.hex() + + mock_response_json = { + "data": { + "audio": mock_audio_hex, + "status": 0, + "ced": "" + }, + "extra_info": {}, + } + + with patch("litellm.llms.custom_httpx.llm_http_handler.BaseLLMHTTPHandler.text_to_speech_handler") as mock_tts: + # Create a mock httpx.Response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {} + mock_response.json.return_value = mock_response_json + mock_response.content = mock_audio_bytes + + # Mock the response wrapper + from litellm.types.llms.openai import HttpxBinaryResponseContent + mock_binary_response = HttpxBinaryResponseContent(mock_response) + mock_tts.return_value = mock_binary_response + + # This would normally make a real API call + # but we're mocking it for testing + response = speech( + model="minimax/speech-2.6-hd", + voice="alloy", + input="Test input", + api_key="test-key", + ) + + # Verify the mock was called + assert mock_tts.called + + +class TestMinimaxProviderRegistration: + """Test that MiniMax is properly registered as a provider""" + + def test_minimax_in_llm_providers(self): + """Test that MINIMAX is in LlmProviders enum""" + from litellm.types.utils import LlmProviders + + assert hasattr(LlmProviders, "MINIMAX") + assert LlmProviders.MINIMAX.value == "minimax" + + def test_minimax_in_provider_list(self): + """Test that minimax is in the provider list""" + assert litellm.LlmProviders.MINIMAX in litellm.provider_list + + def test_get_provider_text_to_speech_config(self): + """Test that MiniMax TTS config can be retrieved""" + from litellm.utils import ProviderConfigManager + + config = ProviderConfigManager.get_provider_text_to_speech_config( + model="speech-2.6-hd", + provider=litellm.LlmProviders.MINIMAX, + ) + + assert config is not None + assert isinstance(config, MinimaxTextToSpeechConfig) + + def test_get_llm_provider_minimax(self): + """Test that get_llm_provider correctly identifies MiniMax models""" + from litellm import get_llm_provider + + model, provider, api_key, api_base = get_llm_provider( + model="minimax/speech-2.6-hd" + ) + + assert model == "speech-2.6-hd" + assert provider == "minimax" + + +if __name__ == "__main__": + # Run basic tests + test_config = TestMinimaxTextToSpeechConfig() + test_config.test_get_supported_openai_params() + test_config.test_voice_mapping() + test_config.test_format_mapping() + test_config.test_map_openai_params_basic() + test_config.test_map_openai_params_speed_clamping() + test_config.test_transform_text_to_speech_request() + test_config.test_get_complete_url() + + test_registration = TestMinimaxProviderRegistration() + test_registration.test_minimax_in_llm_providers() + test_registration.test_minimax_in_provider_list() + test_registration.test_get_provider_text_to_speech_config() + test_registration.test_get_llm_provider_minimax() + + print("All basic tests passed!") + diff --git a/tests/local_testing/test_alangfuse.py b/tests/local_testing/test_alangfuse.py index a20370135f9..306c7749f18 100644 --- a/tests/local_testing/test_alangfuse.py +++ b/tests/local_testing/test_alangfuse.py @@ -1019,7 +1019,7 @@ generation_params = { ], }, }, - "user_api_key": "88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b", + "user_api_key": "sk-test-mock-api-key-123", "litellm_api_version": "0.0.0", "user_api_key_user_id": "default_user_id", "user_api_key_spend": 0.0, @@ -1142,7 +1142,7 @@ def test_langfuse_prompt_type(prompt): ], }, }, - "user_api_key": "88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b", + "user_api_key": "sk-test-mock-api-key-123", "litellm_api_version": "0.0.0", "user_api_key_user_id": "default_user_id", "user_api_key_spend": 0.0, diff --git a/tests/local_testing/test_completion_cost.py b/tests/local_testing/test_completion_cost.py index 40efcc23868..2f78f27361e 100644 --- a/tests/local_testing/test_completion_cost.py +++ b/tests/local_testing/test_completion_cost.py @@ -401,7 +401,7 @@ def test_dalle_3_azure_cost_tracking(): { "b64_json": None, "revised_prompt": "A close-up image of an adorable baby sea otter. Its fur is thick and fluffy to provide buoyancy and insulation against the cold water. Its eyes are round, curious and full of life. It's lying on its back, floating effortlessly on the calm sea surface under the warm sun. Surrounding the otter are patches of colorful kelp drifting along the gentle waves, giving the scene a touch of vibrancy. The sea otter has its small paws folded on its chest, and it seems to be taking a break from its play.", - "url": "https://dalleprodsec.blob.core.windows.net/private/images/3e5d00f3-700e-4b75-869d-2de73c3c975d/generated_00.png?se=2024-03-13T17%3A49%3A51Z&sig=R9RJD5oOSe0Vp9Eg7ze%2FZ8QR7ldRyGH6XhMxiau16Jc%3D&ske=2024-03-19T11%3A08%3A03Z&skoid=e52d5ed7-0657-4f62-bc12-7e5dbb260a96&sks=b&skt=2024-03-12T11%3A08%3A03Z&sktid=33e01921-4d64-4f8c-a055-5bdaffd5e33d&skv=2020-10-02&sp=r&spr=https&sr=b&sv=2020-10-02", + "url": "test-azure-blob-url-with-sas-token", } ], ) diff --git a/tests/local_testing/test_exceptions.py b/tests/local_testing/test_exceptions.py index a27a64dd6e3..987c213d5ca 100644 --- a/tests/local_testing/test_exceptions.py +++ b/tests/local_testing/test_exceptions.py @@ -176,7 +176,7 @@ def invalid_auth(model): # set the model key to an invalid key, depending on th elif "togethercomputer" in model: temporary_key = os.environ["TOGETHERAI_API_KEY"] os.environ["TOGETHERAI_API_KEY"] = ( - "84060c79880fc49df126d3e87b53f8a463ff6e1c6d27fe64207cde25cdfcd1f24a" + "sk-test-togetherai-key-808" ) elif model in litellm.openrouter_models: temporary_key = os.environ["OPENROUTER_API_KEY"] diff --git a/tests/local_testing/test_function_setup.py b/tests/local_testing/test_function_setup.py index 5cc3ce12304..23a82fd7a6e 100644 --- a/tests/local_testing/test_function_setup.py +++ b/tests/local_testing/test_function_setup.py @@ -9,9 +9,10 @@ import os, io sys.path.insert( 0, os.path.abspath("../..") -) # Adds the parent directory to the, system path +) # Adds the parent directory to the system path import pytest, uuid from litellm.utils import function_setup, Rules +from litellm.litellm_core_utils.prompt_templates.factory import THOUGHT_SIGNATURE_SEPARATOR from datetime import datetime @@ -31,3 +32,176 @@ def test_empty_content(): messages=[], litellm_call_id=str(uuid.uuid4()), ) + + +def test_thought_signature_removal_for_non_gemini(): + """ + Test that thought signatures are removed from tool call IDs when sending to non-Gemini models + """ + rules_obj = Rules() + + # Create messages with thought signatures (as would come from Gemini) + messages = [ + {"role": "user", "content": "What's the weather?"}, + { + "role": "assistant", + "tool_calls": [ + { + "id": f"call_123{THOUGHT_SIGNATURE_SEPARATOR}sig1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"location": "SF"}' + } + } + ] + }, + { + "role": "tool", + "tool_call_id": f"call_123{THOUGHT_SIGNATURE_SEPARATOR}sig1", + "content": "Sunny, 72°F" + } + ] + + # Call function_setup with OpenAI model (non-Gemini) + logging_obj, kwargs = function_setup( + original_function="acompletion", + rules_obj=rules_obj, + start_time=datetime.now(), + model="gpt-4", + messages=messages, + litellm_call_id=str(uuid.uuid4()), + custom_llm_provider="openai" + ) + + # Verify thought signatures were removed + processed_messages = kwargs["messages"] + assert processed_messages[1]["tool_calls"][0]["id"] == "call_123" + assert processed_messages[2]["tool_call_id"] == "call_123" + assert THOUGHT_SIGNATURE_SEPARATOR not in processed_messages[1]["tool_calls"][0]["id"] + assert THOUGHT_SIGNATURE_SEPARATOR not in processed_messages[2]["tool_call_id"] + + +def test_thought_signature_preserved_for_gemini(): + """ + Test that thought signatures are preserved when sending to Gemini models + """ + rules_obj = Rules() + + # Create messages with thought signatures + messages = [ + {"role": "user", "content": "What's the weather?"}, + { + "role": "assistant", + "tool_calls": [ + { + "id": f"call_456{THOUGHT_SIGNATURE_SEPARATOR}sig2", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"location": "NYC"}' + } + } + ] + }, + { + "role": "tool", + "tool_call_id": f"call_456{THOUGHT_SIGNATURE_SEPARATOR}sig2", + "content": "Rainy, 65°F" + } + ] + + # Call function_setup with Gemini model + logging_obj, kwargs = function_setup( + original_function="acompletion", + rules_obj=rules_obj, + start_time=datetime.now(), + model="gemini-1.5-pro", + messages=messages, + litellm_call_id=str(uuid.uuid4()), + custom_llm_provider="vertex_ai" + ) + + # Verify thought signatures were preserved (messages should be unchanged) + processed_messages = kwargs["messages"] + assert THOUGHT_SIGNATURE_SEPARATOR in processed_messages[1]["tool_calls"][0]["id"] + assert THOUGHT_SIGNATURE_SEPARATOR in processed_messages[2]["tool_call_id"] + + +def test_thought_signature_removal_with_multiple_tool_calls(): + """ + Test that thought signatures are removed from multiple tool calls + """ + rules_obj = Rules() + + messages = [ + {"role": "user", "content": "Get weather and time"}, + { + "role": "assistant", + "tool_calls": [ + { + "id": f"call_1{THOUGHT_SIGNATURE_SEPARATOR}sig1", + "type": "function", + "function": {"name": "get_weather", "arguments": "{}"} + }, + { + "id": f"call_2{THOUGHT_SIGNATURE_SEPARATOR}sig2", + "type": "function", + "function": {"name": "get_time", "arguments": "{}"} + } + ] + }, + { + "role": "tool", + "tool_call_id": f"call_1{THOUGHT_SIGNATURE_SEPARATOR}sig1", + "content": "Sunny" + }, + { + "role": "tool", + "tool_call_id": f"call_2{THOUGHT_SIGNATURE_SEPARATOR}sig2", + "content": "3:00 PM" + } + ] + + logging_obj, kwargs = function_setup( + original_function="acompletion", + rules_obj=rules_obj, + start_time=datetime.now(), + model="claude-3-opus", + messages=messages, + litellm_call_id=str(uuid.uuid4()), + custom_llm_provider="anthropic" + ) + + processed_messages = kwargs["messages"] + + # Check all tool call IDs are cleaned + assert processed_messages[1]["tool_calls"][0]["id"] == "call_1" + assert processed_messages[1]["tool_calls"][1]["id"] == "call_2" + assert processed_messages[2]["tool_call_id"] == "call_1" + assert processed_messages[3]["tool_call_id"] == "call_2" + + +def test_messages_without_tool_calls_unchanged(): + """ + Test that messages without tool calls pass through unchanged + """ + rules_obj = Rules() + + messages = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"} + ] + + logging_obj, kwargs = function_setup( + original_function="acompletion", + rules_obj=rules_obj, + start_time=datetime.now(), + model="gpt-4", + messages=messages, + litellm_call_id=str(uuid.uuid4()), + custom_llm_provider="openai" + ) + + # Messages should be unchanged + assert kwargs["messages"] == messages diff --git a/tests/local_testing/test_gcs_bucket.py b/tests/local_testing/test_gcs_bucket.py index fbca0e0060d..2f7d5cd0dec 100644 --- a/tests/local_testing/test_gcs_bucket.py +++ b/tests/local_testing/test_gcs_bucket.py @@ -83,7 +83,7 @@ async def test_aaabasic_gcs_logger(): mock_response="Hi!", metadata={ "tags": ["model-anthropic-claude-v2.1", "app-ishaan-prod"], - "user_api_key": "88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b", + "user_api_key": "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456", "user_api_key_alias": None, "user_api_end_user_max_budget": None, "litellm_api_version": "0.0.0", @@ -155,7 +155,7 @@ async def test_aaabasic_gcs_logger(): assert ( gcs_payload["metadata"]["user_api_key_hash"] - == "88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b" + == "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456" ) assert gcs_payload["metadata"]["user_api_key_user_id"] == "116544810872468347480" @@ -191,7 +191,7 @@ async def test_basic_gcs_logger_failure(): metadata={ "gcs_log_id": gcs_log_id, "tags": ["model-anthropic-claude-v2.1", "app-ishaan-prod"], - "user_api_key": "88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b", + "user_api_key": "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456", "user_api_key_alias": None, "user_api_end_user_max_budget": None, "litellm_api_version": "0.0.0", @@ -259,7 +259,7 @@ async def test_basic_gcs_logger_failure(): assert ( gcs_payload["metadata"]["user_api_key_hash"] - == "88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b" + == "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456" ) assert gcs_payload["metadata"]["user_api_key_user_id"] == "116544810872468347480" @@ -599,7 +599,7 @@ async def test_basic_gcs_logger_with_folder_in_bucket_name(): mock_response="Hi!", metadata={ "tags": ["model-anthropic-claude-v2.1", "app-ishaan-prod"], - "user_api_key": "88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b", + "user_api_key": "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456", "user_api_key_alias": None, "user_api_end_user_max_budget": None, "litellm_api_version": "0.0.0", @@ -671,7 +671,7 @@ async def test_basic_gcs_logger_with_folder_in_bucket_name(): assert ( gcs_payload["metadata"]["user_api_key_hash"] - == "88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b" + == "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456" ) assert gcs_payload["metadata"]["user_api_key_user_id"] == "116544810872468347480" diff --git a/tests/logging_callback_tests/test_alerting.py b/tests/logging_callback_tests/test_alerting.py index ac7f5cd6aa1..8a691e7618d 100644 --- a/tests/logging_callback_tests/test_alerting.py +++ b/tests/logging_callback_tests/test_alerting.py @@ -488,7 +488,7 @@ async def test_send_token_budget_crossed_alerts(alerting_type): with patch.object(slack_alerting, "send_alert", new=AsyncMock()) as mock_send_alert: user_info = { - "token": "50e55ca5bfbd0759697538e8d23c0cd5031f52d9e19e176d7233b20c7c4d3403", + "token": "sk-test-mock-token-606", "spend": 86, "max_budget": 100, "user_id": "ishaan@berri.ai", @@ -528,7 +528,7 @@ async def test_webhook_alerting(alerting_type): slack_alerting, "send_webhook_alert", new=AsyncMock() ) as mock_send_alert: user_info = { - "token": "50e55ca5bfbd0759697538e8d23c0cd5031f52d9e19e176d7233b20c7c4d3403", + "token": "sk-test-mock-token-606", "spend": 1, "max_budget": 0, "user_id": "ishaan@berri.ai", @@ -559,7 +559,7 @@ async def test_webhook_alerting(alerting_type): # slack_alerting, "send_webhook_alert", new=AsyncMock() # ) as mock_send_alert: # user_info = { -# "token": "50e55ca5bfbd0759697538e8d23c0cd5031f52d9e19e176d7233b20c7c4d3403", +# "token": "sk-test-mock-token-606", # "spend": 1, # "max_budget": 0, # "user_id": "ishaan@berri.ai", diff --git a/tests/logging_callback_tests/test_spend_logs.py b/tests/logging_callback_tests/test_spend_logs.py index 10c067b7bc9..4f6d4438285 100644 --- a/tests/logging_callback_tests/test_spend_logs.py +++ b/tests/logging_callback_tests/test_spend_logs.py @@ -54,7 +54,7 @@ def test_spend_logs_payload(model_id: Optional[str]): }, "litellm_params": { "acompletion": True, - "api_key": "23c217a5b59f41b6b7a198017f4792f2", + "api_key": "sk-test-mock-key-707", "force_timeout": 600, "logger_fn": None, "verbose": False, @@ -65,7 +65,7 @@ def test_spend_logs_payload(model_id: Optional[str]): "completion_call_id": None, "metadata": { "tags": ["model-anthropic-claude-v2.1", "app-ishaan-prod"], - "user_api_key": "88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b", + "user_api_key": "sk-test-mock-api-key-123", "user_api_key_alias": "custom-key-alias", "user_api_end_user_max_budget": None, "litellm_api_version": "0.0.0", @@ -243,7 +243,7 @@ def test_spend_logs_payload_whisper(): "litellm_params": { "api_base": "", "metadata": { - "user_api_key": "88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b", + "user_api_key": "sk-test-mock-api-key-123", "user_api_key_alias": None, "user_api_key_end_user_id": "test-user", "user_api_end_user_max_budget": None, diff --git a/tests/logging_callback_tests/test_view_request_resp_logs.py b/tests/logging_callback_tests/test_view_request_resp_logs.py index 34e8d01303a..ea778a44e67 100644 --- a/tests/logging_callback_tests/test_view_request_resp_logs.py +++ b/tests/logging_callback_tests/test_view_request_resp_logs.py @@ -42,7 +42,7 @@ mock_response_data = { "response_time": 0.1622769832611084, "model": "my-fake-model", "metadata": { - "user_api_key_hash": "88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b", + "user_api_key_hash": "sk-test-mock-api-key-123", "user_api_key_alias": None, "user_api_key_team_id": None, "user_api_key_org_id": None, diff --git a/tests/mcp_tests/test_mcp_server.py b/tests/mcp_tests/test_mcp_server.py index d3112714a9c..9242dfc75f4 100644 --- a/tests/mcp_tests/test_mcp_server.py +++ b/tests/mcp_tests/test_mcp_server.py @@ -812,10 +812,19 @@ async def test_get_tools_from_mcp_servers(): return_value=["server1_id", "server2_id"] ) mock_manager_2.get_mcp_server_by_id = lambda server_id: mock_server_1 if server_id == "server1_id" else mock_server_2 + async def mock_get_tools_side_effect( + server, + mcp_auth_header=None, + extra_headers=None, + add_prefix=False, + raw_headers=None, + ): + if server.server_id == "server1_id": + return [mock_tool_1] + return [mock_tool_2] + mock_manager_2._get_tools_from_server = AsyncMock( - side_effect=lambda server, mcp_auth_header=None, extra_headers=None, add_prefix=False: ( - [mock_tool_1] if server.server_id == "server1_id" else [mock_tool_2] - ) + side_effect=mock_get_tools_side_effect ) with patch( @@ -1693,6 +1702,7 @@ async def test_get_tools_for_single_server(): server=mock_server, mcp_auth_header="Bearer test_token", add_prefix=False, + raw_headers=None, ) # Verify the result diff --git a/tests/old_proxy_tests/tests/test_anthropic_sdk.py b/tests/old_proxy_tests/tests/test_anthropic_sdk.py index 073fafb079b..289fc845549 100644 --- a/tests/old_proxy_tests/tests/test_anthropic_sdk.py +++ b/tests/old_proxy_tests/tests/test_anthropic_sdk.py @@ -6,7 +6,7 @@ client = Anthropic( # This is the default and can be omitted base_url="http://localhost:4000", # this is a litellm proxy key :) - not a real anthropic key - api_key="sk-s4xN1IiLTCytwtZFJaYQrA", + api_key="sk-test-proxy-key-123", ) message = client.messages.create( diff --git a/tests/pass_through_unit_tests/test_unit_test_anthropic_pass_through.py b/tests/pass_through_unit_tests/test_unit_test_anthropic_pass_through.py index 581f1d19793..97a1f2eecc7 100644 --- a/tests/pass_through_unit_tests/test_unit_test_anthropic_pass_through.py +++ b/tests/pass_through_unit_tests/test_unit_test_anthropic_pass_through.py @@ -105,7 +105,7 @@ def test_create_anthropic_response_logging_payload(mock_logging_obj, metadata_pa kwargs={ "litellm_params": { "metadata": { - "user_api_key": "88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b", + "user_api_key": "sk-test-mock-api-key-123", "user_api_key_user_id": "default_user_id", "user_api_key_team_id": None, "user_api_key_end_user_id": ("test" if metadata_params else ""), diff --git a/tests/proxy_unit_tests/test_db_schema_migration.py b/tests/proxy_unit_tests/test_db_schema_migration.py index b3178183759..a8fa3242129 100644 --- a/tests/proxy_unit_tests/test_db_schema_migration.py +++ b/tests/proxy_unit_tests/test_db_schema_migration.py @@ -21,7 +21,7 @@ def test_aaaasschema_migration_check(schema_setup, monkeypatch): """Test to check if schema requires migration""" # Set test database URL test_db_url = f"postgresql://{schema_setup.info.user}:@{schema_setup.info.host}:{schema_setup.info.port}/{schema_setup.info.dbname}" - # test_db_url = "postgresql://neondb_owner:npg_JiZPS0DAhRn4@ep-delicate-wave-a55cvbuc.us-east-2.aws.neon.tech/neondb?sslmode=require" + # test_db_url = "postgresql://test-user:test-password@test-host.example.com/test-db?sslmode=require" monkeypatch.setenv("DATABASE_URL", test_db_url) deploy_dir = Path("./litellm-proxy-extras/litellm_proxy_extras") diff --git a/tests/proxy_unit_tests/test_jwt.py b/tests/proxy_unit_tests/test_jwt.py index 57434993977..2af61aa2653 100644 --- a/tests/proxy_unit_tests/test_jwt.py +++ b/tests/proxy_unit_tests/test_jwt.py @@ -1266,7 +1266,7 @@ def test_user_api_key_auth_jwt_hashing(): from litellm.proxy.auth.handle_jwt import JWTHandler # Test with a JWT token (3 parts separated by dots) - jwt_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + jwt_token = "test-jwt-token-header.payload.signature" # Create UserAPIKeyAuth instance with JWT user_auth = UserAPIKeyAuth(api_key=jwt_token) @@ -1303,7 +1303,7 @@ def test_jwt_handler_is_jwt_static_method(): from litellm.proxy.auth.handle_jwt import JWTHandler # Test with valid JWT format - valid_jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + valid_jwt = "test-jwt-token-header.payload.signature" assert JWTHandler.is_jwt(valid_jwt) == True # Test with invalid JWT format (only 2 parts) diff --git a/tests/proxy_unit_tests/test_proxy_utils.py b/tests/proxy_unit_tests/test_proxy_utils.py index c88efe2ebe2..5c3c3948920 100644 --- a/tests/proxy_unit_tests/test_proxy_utils.py +++ b/tests/proxy_unit_tests/test_proxy_utils.py @@ -238,7 +238,7 @@ def test_dynamic_logging_metadata_key_and_team_metadata(callback_vars): proxy_config = ProxyConfig() user_api_key_dict = UserAPIKeyAuth( - token="6f8688eaff1d37555bb9e9a6390b6d7032b3ab2526ba0152da87128eab956432", + token="sk-test-mock-token-789", key_name="sk-...63Fg", key_alias=None, spend=0.000111, @@ -287,7 +287,7 @@ def test_dynamic_logging_metadata_key_and_team_metadata(callback_vars): end_user_rpm_limit=None, end_user_max_budget=None, last_refreshed_at=1726101560.967527, - api_key="7c305cc48fe72272700dc0d67dc691c2d1f2807490ef5eb2ee1d3a3ca86e12b1", + api_key="sk-test-mock-api-key-202", user_role=LitellmUserRoles.INTERNAL_USER, allowed_model_region=None, parent_otel_span=None, @@ -320,7 +320,7 @@ def test_dynamic_turn_off_message_logging(callback_vars): proxy_config = ProxyConfig() user_api_key_dict = UserAPIKeyAuth( - token="6f8688eaff1d37555bb9e9a6390b6d7032b3ab2526ba0152da87128eab956432", + token="sk-test-mock-token-789", key_name="sk-...63Fg", key_alias=None, spend=0.000111, @@ -368,7 +368,7 @@ def test_dynamic_turn_off_message_logging(callback_vars): end_user_rpm_limit=None, end_user_max_budget=None, last_refreshed_at=1726101560.967527, - api_key="7c305cc48fe72272700dc0d67dc691c2d1f2807490ef5eb2ee1d3a3ca86e12b1", + api_key="sk-test-mock-api-key-202", user_role=LitellmUserRoles.INTERNAL_USER, allowed_model_region=None, parent_otel_span=None, @@ -678,6 +678,11 @@ async def test_prepare_key_update_data(): updated_data = await prepare_key_update_data(data, existing_key_row) assert updated_data["metadata"] is None + # Test duration "-1" sets expires to None (never expires) + data = UpdateKeyRequest(key="test_key", duration="-1") + updated_data = await prepare_key_update_data(data, existing_key_row) + assert updated_data["expires"] is None + @pytest.mark.parametrize( "env_vars, expected_url", @@ -1267,7 +1272,7 @@ def test_litellm_verification_token_view_response_with_budget_table( from litellm.proxy._types import LiteLLM_VerificationTokenView args: Dict[str, Any] = { - "token": "78b627d4d14bc3acf5571ae9cb6834e661bc8794d1209318677387add7621ce1", + "token": "sk-test-mock-token-303", "key_name": "sk-...if_g", "key_alias": None, "soft_budget_cooldown": False, diff --git a/tests/proxy_unit_tests/test_user_api_key_auth.py b/tests/proxy_unit_tests/test_user_api_key_auth.py index ec61c7305bb..72d13aadad3 100644 --- a/tests/proxy_unit_tests/test_user_api_key_auth.py +++ b/tests/proxy_unit_tests/test_user_api_key_auth.py @@ -696,7 +696,7 @@ def test_is_allowed_route(): "request": request, "request_data": {"input": ["hello world"], "model": "embedding-small"}, "valid_token": UserAPIKeyAuth( - token="9644159bc181998825c44c788b1526341ed2e825d1b6f562e23173759e14bb86", + token="sk-test-mock-token-101", key_name="sk-...CJjQ", key_alias=None, spend=0.0, diff --git a/tests/test_callbacks_on_proxy.py b/tests/test_callbacks_on_proxy.py index 831ca449f83..3bc07da8db1 100644 --- a/tests/test_callbacks_on_proxy.py +++ b/tests/test_callbacks_on_proxy.py @@ -26,7 +26,7 @@ async def config_update(session, routing_strategy=None): }, "general_settings": { "alert_to_webhook_url": { - "llm_exceptions": "https://hooks.slack.com/services/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" + "llm_exceptions": "example-slack-webhook-url" }, "alert_types": ["llm_exceptions", "db_exceptions"], }, diff --git a/tests/test_litellm/integrations/cloudzero/test_cloudzero.py b/tests/test_litellm/integrations/cloudzero/test_cloudzero.py index 863d79d7456..db6726a234d 100644 --- a/tests/test_litellm/integrations/cloudzero/test_cloudzero.py +++ b/tests/test_litellm/integrations/cloudzero/test_cloudzero.py @@ -47,7 +47,7 @@ class TestCloudZeroHourlyExport: { "team_id": ["a3d6b0bb-098f-4260-81d6-fabae695b622"], "key_alias": ["key_1"], - "token": ["c1465c9a821f420927b3d81972323fb516745bc93a4a54ceca0ce6ddf6100c39"], + "token": ["sk-test-cloudzero-token-010"], } ) diff --git a/tests/test_litellm/integrations/datadog/test_datadog_llm_observability.py b/tests/test_litellm/integrations/datadog/test_datadog_llm_observability.py index 464cb0026e5..48dec1fbc5a 100644 --- a/tests/test_litellm/integrations/datadog/test_datadog_llm_observability.py +++ b/tests/test_litellm/integrations/datadog/test_datadog_llm_observability.py @@ -257,41 +257,57 @@ class TestDataDogLLMObsLogger: logger = DataDogLLMObsLogger() # Test embedding operations - assert logger._get_datadog_span_kind(CallTypes.embedding.value) == "embedding" - assert logger._get_datadog_span_kind(CallTypes.aembedding.value) == "embedding" + assert logger._get_datadog_span_kind(CallTypes.embedding.value, "123") == "embedding" + assert logger._get_datadog_span_kind(CallTypes.aembedding.value, "123") == "embedding" # Test LLM completion operations - assert logger._get_datadog_span_kind(CallTypes.completion.value) == "llm" - assert logger._get_datadog_span_kind(CallTypes.acompletion.value) == "llm" - assert logger._get_datadog_span_kind(CallTypes.text_completion.value) == "llm" - assert logger._get_datadog_span_kind(CallTypes.generate_content.value) == "llm" + assert logger._get_datadog_span_kind(CallTypes.completion.value, None) == "llm" + assert logger._get_datadog_span_kind(CallTypes.acompletion.value, None) == "llm" + assert logger._get_datadog_span_kind(CallTypes.text_completion.value, None) == "llm" + assert logger._get_datadog_span_kind(CallTypes.generate_content.value, None) == "llm" assert ( - logger._get_datadog_span_kind(CallTypes.anthropic_messages.value) == "llm" + logger._get_datadog_span_kind(CallTypes.anthropic_messages.value, None) == "llm" ) + assert logger._get_datadog_span_kind(CallTypes.responses.value, None) == "llm" + assert logger._get_datadog_span_kind(CallTypes.aresponses.value, None) == "llm" # Test tool operations - assert logger._get_datadog_span_kind(CallTypes.call_mcp_tool.value) == "tool" + assert logger._get_datadog_span_kind(CallTypes.call_mcp_tool.value, "123") == "tool" # Test retrieval operations assert ( - logger._get_datadog_span_kind(CallTypes.get_assistants.value) == "retrieval" + logger._get_datadog_span_kind(CallTypes.get_assistants.value, "123") == "retrieval" ) assert ( - logger._get_datadog_span_kind(CallTypes.file_retrieve.value) == "retrieval" + logger._get_datadog_span_kind(CallTypes.file_retrieve.value, "123") == "retrieval" ) assert ( - logger._get_datadog_span_kind(CallTypes.retrieve_batch.value) == "retrieval" + logger._get_datadog_span_kind(CallTypes.retrieve_batch.value, "123") == "retrieval" ) # Test task operations - assert logger._get_datadog_span_kind(CallTypes.create_batch.value) == "task" - assert logger._get_datadog_span_kind(CallTypes.image_generation.value) == "task" - assert logger._get_datadog_span_kind(CallTypes.moderation.value) == "task" - assert logger._get_datadog_span_kind(CallTypes.transcription.value) == "task" + assert logger._get_datadog_span_kind(CallTypes.create_batch.value, "123") == "task" + assert logger._get_datadog_span_kind(CallTypes.image_generation.value, "123") == "task" + assert logger._get_datadog_span_kind(CallTypes.moderation.value, "123") == "task" + assert logger._get_datadog_span_kind(CallTypes.transcription.value, "123") == "task" # Test default fallback - assert logger._get_datadog_span_kind("unknown_call_type") == "llm" - assert logger._get_datadog_span_kind(None) == "llm" + assert logger._get_datadog_span_kind("unknown_call_type", None) == "llm" + assert logger._get_datadog_span_kind(None, None) == "llm" + + def test_datadog_span_kind_defaults_without_parent(self, mock_env_vars): + """Test that non-llm kinds fallback to llm when no parent span is provided""" + from litellm.types.utils import CallTypes + + with patch( + "litellm.integrations.datadog.datadog_llm_obs.get_async_httpx_client" + ), patch("asyncio.create_task"): + logger = DataDogLLMObsLogger() + + # Tool/task/retrieval span kinds should fallback to llm when parent_id missing + assert logger._get_datadog_span_kind(CallTypes.call_mcp_tool.value, None) == "llm" + assert logger._get_datadog_span_kind(CallTypes.create_batch.value, None) == "llm" + assert logger._get_datadog_span_kind(CallTypes.get_assistants.value, None) == "llm" @pytest.mark.asyncio async def test_async_log_failure_event(self, mock_env_vars): @@ -796,7 +812,7 @@ class TestDataDogLLMObsLoggerToolCalls: from litellm.types.utils import CallTypes assert ( - logger._get_datadog_span_kind(CallTypes.call_mcp_tool.value) == "tool" + logger._get_datadog_span_kind(CallTypes.call_mcp_tool.value, "123") == "tool" ) def test_tool_call_payload_creation(self, mock_env_vars): diff --git a/tests/test_litellm/integrations/test_custom_guardrail.py b/tests/test_litellm/integrations/test_custom_guardrail.py index a719d102a7c..a322dfe9a2b 100644 --- a/tests/test_litellm/integrations/test_custom_guardrail.py +++ b/tests/test_litellm/integrations/test_custom_guardrail.py @@ -498,7 +498,7 @@ class TestPassthroughCallTypeHandling: def test_get_pre_call_type_with_allm_passthrough_route(self): """ Test that _get_pre_call_type correctly maps allm_passthrough_route. - + This tests Fix #1: allm_passthrough_route was not being handled, causing call_type to be None. """ from litellm.proxy.common_request_processing import ( @@ -509,14 +509,14 @@ class TestPassthroughCallTypeHandling: result = ProxyBaseLLMRequestProcessing._get_pre_call_type( route_type="allm_passthrough_route" ) - + # Should return allm_passthrough_route, not None assert result == "allm_passthrough_route" def test_get_pre_call_type_preserves_standard_mappings(self): """ Test that _get_pre_call_type still correctly maps standard route types. - + Ensures Fix #1 didn't break existing functionality. """ from litellm.proxy.common_request_processing import ( @@ -536,3 +536,235 @@ class TestPassthroughCallTypeHandling: ProxyBaseLLMRequestProcessing._get_pre_call_type(route_type="aresponses") == "responses" ) + + +class TestEventTypeLogging: + """Tests for event_type logging in guardrail information.""" + + @pytest.mark.asyncio + async def test_log_guardrail_information_infers_event_type_from_async_pre_call_hook( + self, + ): + """ + Test that log_guardrail_information decorator correctly infers GuardrailEventHooks.pre_call + from async_pre_call_hook function name. + """ + from litellm.integrations.custom_guardrail import log_guardrail_information + from litellm.types.guardrails import GuardrailEventHooks + + class TestGuardrail(CustomGuardrail): + def __init__(self): + super().__init__( + guardrail_name="test_event_type_guardrail", + event_hook=[ + GuardrailEventHooks.pre_call, + GuardrailEventHooks.post_call, + ], + ) + + @log_guardrail_information + async def async_pre_call_hook(self, data: dict, **kwargs): + return {"result": "pre_call_executed"} + + guardrail = TestGuardrail() + request_data = {"metadata": {}} + + await guardrail.async_pre_call_hook(data=request_data) + + # Check that the guardrail_mode was set to pre_call (not the full list) + logged_info = request_data["metadata"]["standard_logging_guardrail_information"] + assert len(logged_info) == 1 + assert logged_info[0]["guardrail_mode"] == GuardrailEventHooks.pre_call + + @pytest.mark.asyncio + async def test_log_guardrail_information_infers_event_type_from_async_post_call_success_hook( + self, + ): + """ + Test that log_guardrail_information decorator correctly infers GuardrailEventHooks.post_call + from async_post_call_success_hook function name. + """ + from litellm.integrations.custom_guardrail import log_guardrail_information + from litellm.types.guardrails import GuardrailEventHooks + + class TestGuardrail(CustomGuardrail): + def __init__(self): + super().__init__( + guardrail_name="test_event_type_guardrail", + event_hook=[ + GuardrailEventHooks.pre_call, + GuardrailEventHooks.post_call, + ], + ) + + @log_guardrail_information + async def async_post_call_success_hook(self, data: dict, **kwargs): + return {"result": "post_call_executed"} + + guardrail = TestGuardrail() + request_data = {"metadata": {}} + + await guardrail.async_post_call_success_hook(data=request_data) + + # Check that the guardrail_mode was set to post_call (not the full list) + logged_info = request_data["metadata"]["standard_logging_guardrail_information"] + assert len(logged_info) == 1 + assert logged_info[0]["guardrail_mode"] == GuardrailEventHooks.post_call + + @pytest.mark.asyncio + async def test_log_guardrail_information_infers_event_type_from_async_moderation_hook( + self, + ): + """ + Test that log_guardrail_information decorator correctly infers GuardrailEventHooks.during_call + from async_moderation_hook function name. + """ + from litellm.integrations.custom_guardrail import log_guardrail_information + from litellm.types.guardrails import GuardrailEventHooks + + class TestGuardrail(CustomGuardrail): + def __init__(self): + super().__init__( + guardrail_name="test_event_type_guardrail", + event_hook=[ + GuardrailEventHooks.during_call, + GuardrailEventHooks.post_call, + ], + ) + + @log_guardrail_information + async def async_moderation_hook(self, data: dict, **kwargs): + return {"result": "moderation_executed"} + + guardrail = TestGuardrail() + request_data = {"metadata": {}} + + await guardrail.async_moderation_hook(data=request_data) + + # Check that the guardrail_mode was set to during_call (not the full list) + logged_info = request_data["metadata"]["standard_logging_guardrail_information"] + assert len(logged_info) == 1 + assert logged_info[0]["guardrail_mode"] == GuardrailEventHooks.during_call + + @pytest.mark.asyncio + async def test_log_guardrail_information_infers_event_type_from_async_post_call_streaming_hook( + self, + ): + """ + Test that log_guardrail_information decorator correctly infers GuardrailEventHooks.post_call + from async_post_call_streaming_hook function name. + """ + from litellm.integrations.custom_guardrail import log_guardrail_information + from litellm.types.guardrails import GuardrailEventHooks + + class TestGuardrail(CustomGuardrail): + def __init__(self): + super().__init__( + guardrail_name="test_event_type_guardrail", + event_hook=[ + GuardrailEventHooks.pre_call, + GuardrailEventHooks.post_call, + ], + ) + + @log_guardrail_information + async def async_post_call_streaming_hook(self, data: dict, **kwargs): + return {"result": "streaming_executed"} + + guardrail = TestGuardrail() + request_data = {"metadata": {}} + + await guardrail.async_post_call_streaming_hook(data=request_data) + + # Check that the guardrail_mode was set to post_call (not the full list) + logged_info = request_data["metadata"]["standard_logging_guardrail_information"] + assert len(logged_info) == 1 + assert logged_info[0]["guardrail_mode"] == GuardrailEventHooks.post_call + + @pytest.mark.asyncio + async def test_log_guardrail_information_returns_none_for_unknown_function_name( + self, + ): + """ + Test that log_guardrail_information decorator returns None for event_type + when function name doesn't match known patterns, and falls back to self.event_hook. + """ + from litellm.integrations.custom_guardrail import log_guardrail_information + from litellm.types.guardrails import GuardrailEventHooks + + class TestGuardrail(CustomGuardrail): + def __init__(self): + super().__init__( + guardrail_name="test_event_type_guardrail", + event_hook=GuardrailEventHooks.pre_call, + ) + + @log_guardrail_information + async def some_other_hook(self, data: dict, **kwargs): + return {"result": "other_hook_executed"} + + guardrail = TestGuardrail() + request_data = {"metadata": {}} + + await guardrail.some_other_hook(data=request_data) + + # Check that the guardrail_mode falls back to self.event_hook + logged_info = request_data["metadata"]["standard_logging_guardrail_information"] + assert len(logged_info) == 1 + assert logged_info[0]["guardrail_mode"] == GuardrailEventHooks.pre_call + + def test_add_standard_logging_uses_event_type_over_event_hook(self): + """ + Test that add_standard_logging_guardrail_information_to_request_data + prioritizes event_type parameter over self.event_hook. + """ + from litellm.types.guardrails import GuardrailEventHooks + + guardrail = CustomGuardrail( + guardrail_name="test_guardrail", + event_hook=[GuardrailEventHooks.pre_call, GuardrailEventHooks.post_call], + ) + + request_data = {"metadata": {}} + + # Call with explicit event_type + guardrail.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response={"result": "ok"}, + request_data=request_data, + guardrail_status="success", + event_type=GuardrailEventHooks.post_call, + ) + + # Should use the provided event_type (post_call), not the full event_hook list + logged_info = request_data["metadata"]["standard_logging_guardrail_information"] + assert len(logged_info) == 1 + assert logged_info[0]["guardrail_mode"] == GuardrailEventHooks.post_call + + def test_add_standard_logging_falls_back_to_event_hook_when_event_type_is_none( + self, + ): + """ + Test that add_standard_logging_guardrail_information_to_request_data + falls back to self.event_hook when event_type is None. + """ + from litellm.types.guardrails import GuardrailEventHooks + + guardrail = CustomGuardrail( + guardrail_name="test_guardrail", + event_hook=GuardrailEventHooks.pre_call, + ) + + request_data = {"metadata": {}} + + # Call with event_type=None + guardrail.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response={"result": "ok"}, + request_data=request_data, + guardrail_status="success", + event_type=None, + ) + + # Should fall back to self.event_hook + logged_info = request_data["metadata"]["standard_logging_guardrail_information"] + assert len(logged_info) == 1 + assert logged_info[0]["guardrail_mode"] == GuardrailEventHooks.pre_call diff --git a/tests/test_litellm/interactions/base_interactions_test.py b/tests/test_litellm/interactions/base_interactions_test.py new file mode 100644 index 00000000000..b7748a45f32 --- /dev/null +++ b/tests/test_litellm/interactions/base_interactions_test.py @@ -0,0 +1,111 @@ +""" +Abstract base class for Interactions API tests. + +This class provides common test cases that can be inherited by provider-specific +test classes. Subclasses must implement get_model() and get_api_key(). +""" + +import os +from abc import ABC, abstractmethod + +import pytest + +import litellm.interactions as interactions + + +class BaseInteractionsTest(ABC): + """Abstract base class for interactions API tests. + + Subclasses must implement get_model() and get_api_key(). + All test methods are inherited and run against the specific provider. + """ + + @abstractmethod + def get_model(self) -> str: + """Return the model string for this provider.""" + pass + + @abstractmethod + def get_api_key(self) -> str: + """Return the API key for this provider.""" + pass + + def test_create_simple_string_input(self): + """Test creating an interaction with a simple string input.""" + api_key = self.get_api_key() + if not api_key: + pytest.skip(f"API key not set for {self.__class__.__name__}") + + response = interactions.create( + model=self.get_model(), + input="Hello, what is 2 + 2?", + api_key=api_key, + ) + assert response is not None + assert response.id is not None or response.status is not None + + # Check outputs per OpenAPI spec + if response.outputs: + assert len(response.outputs) > 0 + + # Check usage per OpenAPI spec + if response.usage: + # Usage is a dict in InteractionsAPIResponse + if isinstance(response.usage, dict): + assert response.usage.get("input_tokens") is not None or response.usage.get("output_tokens") is not None + else: + # If it's an object, check attributes + assert hasattr(response.usage, "input_tokens") or hasattr(response.usage, "output_tokens") + + def test_create_with_system_instruction(self): + """Test creating an interaction with system_instruction.""" + api_key = self.get_api_key() + if not api_key: + pytest.skip(f"API key not set for {self.__class__.__name__}") + + response = interactions.create( + model=self.get_model(), + input="What are you?", + system_instruction="You are a helpful pirate assistant. Always respond like a pirate.", + api_key=api_key, + ) + assert response is not None + # Verify the response reflects the system instruction + if response.outputs: + assert len(response.outputs) > 0 + + def test_create_streaming(self): + """Test creating a streaming interaction.""" + api_key = self.get_api_key() + if not api_key: + pytest.skip(f"API key not set for {self.__class__.__name__}") + + response_stream = interactions.create( + model=self.get_model(), + input="Count from 1 to 3.", + stream=True, + api_key=api_key, + ) + + # Collect all chunks + chunks = [] + for chunk in response_stream: + chunks.append(chunk) + + assert len(chunks) > 0 + + @pytest.mark.asyncio + async def test_acreate_simple(self): + """Test async interaction creation.""" + api_key = self.get_api_key() + if not api_key: + pytest.skip(f"API key not set for {self.__class__.__name__}") + + response = await interactions.acreate( + model=self.get_model(), + input="What is the speed of light?", + api_key=api_key, + ) + assert response is not None + assert response.id is not None or response.status is not None + diff --git a/tests/test_litellm/interactions/test_gemini_interactions.py b/tests/test_litellm/interactions/test_gemini_interactions.py new file mode 100644 index 00000000000..c75e1d8a860 --- /dev/null +++ b/tests/test_litellm/interactions/test_gemini_interactions.py @@ -0,0 +1,24 @@ +""" +Tests for Gemini Interactions API. + +Inherits from BaseInteractionsTest to run the same test suite against Gemini. +""" + +import os + +from tests.test_litellm.interactions.base_interactions_test import ( + BaseInteractionsTest, +) + + +class TestGeminiInteractions(BaseInteractionsTest): + """Test Gemini Interactions API using the base test suite.""" + + def get_model(self) -> str: + """Return the Gemini model string.""" + return "gemini/gemini-2.5-flash" + + def get_api_key(self) -> str: + """Return the Gemini API key from environment.""" + return os.getenv("GEMINI_API_KEY", "") + diff --git a/tests/test_litellm/interactions/test_litellm_responses_bridge.py b/tests/test_litellm/interactions/test_litellm_responses_bridge.py new file mode 100644 index 00000000000..f99090f8363 --- /dev/null +++ b/tests/test_litellm/interactions/test_litellm_responses_bridge.py @@ -0,0 +1,29 @@ +""" +Tests for LiteLLM Responses bridge provider. + +Inherits from BaseInteractionsTest to run the same test suite against +the litellm_responses bridge provider, which calls litellm.responses() internally. +""" + +import os + +from tests.test_litellm.interactions.base_interactions_test import ( + BaseInteractionsTest, +) + + +class TestLiteLLMResponsesBridge(BaseInteractionsTest): + """Test LiteLLM Responses bridge using the base test suite.""" + + def get_model(self) -> str: + """Return the model string for the bridge provider. + + The bridge provider uses litellm.responses() internally, so we can + use any model that litellm.responses() supports (e.g., gpt-4o). + """ + return "gpt-4o" + + def get_api_key(self) -> str: + """Return the OpenAI API key from environment.""" + return os.getenv("OPENAI_API_KEY", "") + diff --git a/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py b/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py index f69b9c35236..9e742a83c6a 100644 --- a/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py @@ -40,8 +40,7 @@ context_window_test_cases = [ "`inputs` tokens + `max_new_tokens` must be <= 4096", True, ), - # Gemini context window error format - # See: https://github.com/BerriAI/litellm/issues/XXXX + # Gemini 2.5/3 format ( "The input token count exceeds the maximum number of tokens allowed 1048576.", True, @@ -50,6 +49,15 @@ context_window_test_cases = [ "GeminiException BadRequestError - {\n \"error\": {\n \"code\": 400,\n \"message\": \"The input token count exceeds the maximum number of tokens allowed 1048576.\",\n \"status\": \"INVALID_ARGUMENT\"\n }\n}\n", True, ), + # Gemini 2.0 Flash format (includes input token count in message) + ( + "The input token count (2800010) exceeds the maximum number of tokens allowed (1048575).", + True, + ), + ( + "GeminiException BadRequestError - {\n \"error\": {\n \"code\": 400,\n \"message\": \"The input token count (2800010) exceeds the maximum number of tokens allowed (1048575).\",\n \"status\": \"INVALID_ARGUMENT\"\n }\n}\n", + True, + ), # Test case insensitivity ("ERROR: THIS MODEL'S MAXIMUM CONTEXT LENGTH IS 1024.", True), # Cerebras context window error format @@ -169,6 +177,54 @@ class TestExceptionCheckers: result = ExceptionCheckers.is_azure_content_policy_violation_error(error_str) assert result is False, f"Should NOT detect policy violation in: {error_str}" +gemini_context_window_test_cases = [ + # Gemini 2.0 Flash format (includes input token count in message) + ( + "The input token count (2800010) exceeds the maximum number of tokens allowed (1048575).", + True, + ), + # Gemini 2.5/3 format + ( + "The input token count exceeds the maximum number of tokens allowed (1048576).", + True, + ), + ("A generic error occurred.", False), +] + + +@pytest.mark.parametrize( + "error_message, should_raise_context_window", gemini_context_window_test_cases +) +def test_gemini_context_window_error_mapping(error_message, should_raise_context_window): + """ + Tests that the exception_type function correctly maps Gemini's + context window exceeded errors to litellm.ContextWindowExceededError. + """ + model = "gemini/gemini-2.0-flash" + custom_llm_provider = "gemini" + + # Create a generic exception with the specific error message + original_exception = Exception(error_message) + + if should_raise_context_window: + with pytest.raises(litellm.ContextWindowExceededError) as excinfo: + exception_type( + model=model, + original_exception=original_exception, + custom_llm_provider=custom_llm_provider, + ) + # Check if the raised exception is indeed a ContextWindowExceededError + assert isinstance(excinfo.value, litellm.ContextWindowExceededError) + else: + # For the negative case, we expect it to raise a generic APIConnectionError + with pytest.raises(litellm.APIConnectionError): + exception_type( + model=model, + original_exception=original_exception, + custom_llm_provider=custom_llm_provider, + ) + + # Test cases for Vertex AI RateLimitError mapping # As per https://github.com/BerriAI/litellm/issues/16189 vertex_rate_limit_test_cases = [ diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index 9d6fbf66e48..6aadbc058d1 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -1055,3 +1055,56 @@ def test_translate_anthropic_messages_to_openai_tool_result_single_item_backward f"got {type(tool_message['content'])}" ) assert tool_message["content"] == "72°F and sunny" + + +def test_streaming_chunk_with_both_text_and_tool_calls_issue_18238(): + """ + When a streaming choice contains both text content and tool_calls, + both should be processed (tool_calls should not be ignored). + """ + # streaming choice with both text and tool_calls + choices = [ + StreamingChoices( + finish_reason=None, + index=0, + delta=Delta( + provider_specific_fields=None, + content="Here is some text for litellm", + role=None, + function_call=None, + tool_calls=[ + ChatCompletionDeltaToolCall( + id="toolu_bdrk_013xRVejhv3ybmLEGCoZib2b", + function=Function(arguments='{"cmd": "init"}', name="Bash"), + type="function", + index=0, + ) + ], + audio=None, + ), + logprobs=None, + ) + ] + + adapter = LiteLLMAnthropicMessagesAdapter() + + # When both text and tool_calls exist, tool_calls (input_json_delta) takes priority + ( + type_of_content, + content_block_delta, + ) = adapter._translate_streaming_openai_chunk_to_anthropic(choices=choices) + + assert type_of_content == "input_json_delta" + assert content_block_delta["partial_json"] == '{"cmd": "init"}' + + # When both text and tool_calls exist, tool_use should be detected and tool name captured + ( + block_type, + content_block_start, + ) = adapter._translate_streaming_openai_chunk_to_anthropic_content_block( + choices=choices + ) + + assert block_type == "tool_use" + assert content_block_start["name"] == "Bash" + assert content_block_start["id"] == "toolu_bdrk_013xRVejhv3ybmLEGCoZib2b" diff --git a/tests/test_litellm/llms/azure/image_generation/test_azure_image_generation_init.py b/tests/test_litellm/llms/azure/image_generation/test_azure_image_generation_init.py index f31001ebd36..998510efcd9 100644 --- a/tests/test_litellm/llms/azure/image_generation/test_azure_image_generation_init.py +++ b/tests/test_litellm/llms/azure/image_generation/test_azure_image_generation_init.py @@ -3,7 +3,7 @@ import os import sys import traceback from typing import Callable, Optional -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest @@ -87,3 +87,80 @@ def test_azure_image_generation_flattens_extra_body(): assert data["custom_param"] == "test_value" assert data["n"] == 1 assert data["size"] == "1024x1024" + + +def test_azure_image_generation_creates_token_provider_from_credentials(): + """ + Test that azure_ad_token_provider is created from tenant_id, client_id, client_secret. + + This test verifies the fix in images/main.py where we now create the + azure_ad_token_provider from credentials in litellm_params if it's not already provided. + """ + # Simulate the fix in images/main.py + litellm_params_dict = { + "tenant_id": "test-tenant-id", + "client_id": "test-client-id", + "client_secret": "test-client-secret", + "azure_scope": None, + } + + azure_ad_token_provider = None + + # This is the logic we added in images/main.py + if azure_ad_token_provider is None: + tenant_id = litellm_params_dict.get("tenant_id") + client_id = litellm_params_dict.get("client_id") + client_secret = litellm_params_dict.get("client_secret") + azure_scope = litellm_params_dict.get("azure_scope") or "https://cognitiveservices.azure.com/.default" + + # Verify the credentials are extracted correctly + assert tenant_id == "test-tenant-id" + assert client_id == "test-client-id" + assert client_secret == "test-client-secret" + assert azure_scope == "https://cognitiveservices.azure.com/.default" + + # Verify the condition to create token provider is met + assert tenant_id and client_id and client_secret, "Credentials should be present to create token provider" + + +def test_azure_image_generation_headers_without_api_key(): + """ + Test that when api_key is None, the api-key header is not added to headers. + + This prevents the httpx TypeError: "Header value must be str or bytes, not " + that was occurring when api_key was None and being set in headers. + + This is a unit test for the fix in images/main.py where we now check: + if api_key is not None: + default_headers["api-key"] = api_key + """ + from litellm.images.main import image_generation + + # Test the header building logic directly + api_key = None + + default_headers = { + "Content-Type": "application/json", + } + + # This is the fix: only add api-key if it's not None + if api_key is not None: + default_headers["api-key"] = api_key + + # Verify api-key is not in headers when api_key is None + assert "api-key" not in default_headers + + # Verify Content-Type is still there + assert default_headers["Content-Type"] == "application/json" + + # Test with a valid api_key + api_key = "valid-key-123" + default_headers_with_key = { + "Content-Type": "application/json", + } + if api_key is not None: + default_headers_with_key["api-key"] = api_key + + # Verify api-key is added when api_key is valid + assert "api-key" in default_headers_with_key + assert default_headers_with_key["api-key"] == "valid-key-123" diff --git a/tests/test_litellm/llms/databricks/chat/test_databricks_chat_transformation.py b/tests/test_litellm/llms/databricks/chat/test_databricks_chat_transformation.py index a14683fac17..f437b8405f7 100644 --- a/tests/test_litellm/llms/databricks/chat/test_databricks_chat_transformation.py +++ b/tests/test_litellm/llms/databricks/chat/test_databricks_chat_transformation.py @@ -94,12 +94,13 @@ def test_transform_choices_without_signature(): assert thinking_block["type"] == "thinking" assert thinking_block["thinking"] == "i'm thinking without signature." + def test_convert_anthropic_tool_to_databricks_tool_with_description(): config = DatabricksConfig() anthropic_tool = { "name": "test_tool", "description": "test description", - "input_schema": {"type": "object", "properties": {"test": {"type": "string"}}} + "input_schema": {"type": "object", "properties": {"test": {"type": "string"}}}, } databricks_tool = config.convert_anthropic_tool_to_databricks_tool(anthropic_tool) @@ -113,7 +114,7 @@ def test_convert_anthropic_tool_to_databricks_tool_without_description(): config = DatabricksConfig() anthropic_tool = { "name": "test_tool", - "input_schema": {"type": "object", "properties": {"test": {"type": "string"}}} + "input_schema": {"type": "object", "properties": {"test": {"type": "string"}}}, } databricks_tool = config.convert_anthropic_tool_to_databricks_tool(anthropic_tool) @@ -122,6 +123,7 @@ def test_convert_anthropic_tool_to_databricks_tool_without_description(): assert databricks_tool["type"] == "function" assert databricks_tool["function"].get("description") is None + def test_transform_choices_with_citations(): config = DatabricksConfig() databricks_choices = [ diff --git a/tests/test_litellm/llms/databricks/databricks_config.template.txt b/tests/test_litellm/llms/databricks/databricks_config.template.txt new file mode 100644 index 00000000000..7352fdbc773 --- /dev/null +++ b/tests/test_litellm/llms/databricks/databricks_config.template.txt @@ -0,0 +1,78 @@ +# Databricks Configuration Template for LiteLLM Testing +# ===================================================== +# +# Copy this file to your preferred location and fill in your credentials: +# cp databricks_config.template.txt /path/to/databricks_config.txt +# +# Then update the CONFIG_FILE path in test_databricks_integration.py +# +# Lines starting with # are comments and will be ignored +# Only lines with KEY=VALUE format (where VALUE is not empty) will be read + +# ============================================================================== +# DATABRICKS WORKSPACE CONFIGURATION (Required) +# ============================================================================== + +# Your Databricks workspace URL (without /serving-endpoints suffix) +# Example: https://adb-1234567890123456.7.azuredatabricks.net +DATABRICKS_HOST= + +# API Base URL for serving endpoints (usually {host}/serving-endpoints) +# Example: https://adb-1234567890123456.7.azuredatabricks.net/serving-endpoints +DATABRICKS_API_BASE= + +# ============================================================================== +# AUTHENTICATION METHOD 1: OAuth M2M (Recommended for Production) +# Use Service Principal credentials +# ============================================================================== + +# Service Principal Application/Client ID +# Example: 12345678-1234-1234-1234-123456789012 +DATABRICKS_CLIENT_ID= + +# Service Principal Secret +# Example: your-client-secret-value +DATABRICKS_CLIENT_SECRET= + +# ============================================================================== +# AUTHENTICATION METHOD 2: Personal Access Token (PAT) +# For development and testing +# ============================================================================== + +# Personal Access Token (starts with 'dapi') +# Example: dapi_your_token_here +DATABRICKS_API_KEY= + +# ============================================================================== +# MODEL CONFIGURATION +# ============================================================================== + +# Model to use for testing chat completions +# Example: databricks-gpt-oss-120b, databricks-meta-llama-3-1-70b-instruct +TEST_CHAT_MODEL=databricks-gpt-oss-120b + +# Model to use for testing embeddings (optional) +# Example: databricks-bge-large-en +TEST_EMBEDDING_MODEL=databricks-bge-large-en + +# ============================================================================== +# OPTIONAL: Custom User-Agent for Partner Attribution Testing +# ============================================================================== + +# Custom user agent string to test partner attribution +# Example: mycompany/1.0.0 +# This will result in User-Agent: mycompany_litellm/{version} +# Leave empty to use default: litellm/{version} +CUSTOM_USER_AGENT= + +# ============================================================================== +# TEST SETTINGS +# ============================================================================== + +# Which authentication method to test: oauth, pat, sdk, or all +# oauth = Use DATABRICKS_CLIENT_ID + DATABRICKS_CLIENT_SECRET +# pat = Use DATABRICKS_API_KEY +# sdk = Use Databricks SDK automatic authentication (~/.databrickscfg) +# all = Test all three methods (oauth, pat, sdk) in sequence +TEST_AUTH_METHOD=pat + diff --git a/tests/test_litellm/llms/databricks/test_databricks_e2e.py b/tests/test_litellm/llms/databricks/test_databricks_e2e.py new file mode 100644 index 00000000000..669f9e94639 --- /dev/null +++ b/tests/test_litellm/llms/databricks/test_databricks_e2e.py @@ -0,0 +1,1029 @@ +""" +End-to-End Tests for Databricks LiteLLM Integration +==================================================== + +⚠️ WARNING: These tests require REAL Databricks credentials and make ACTUAL API calls. + They are NOT suitable for automated CI/CD pipelines. + +For unit tests that use mocks and don't require credentials, see: + test_databricks_partner_integration.py + +Purpose: + - Validate actual API connectivity with Databricks + - Test all authentication methods (OAuth M2M, PAT, SDK) + - Verify User-Agent strings appear correctly in Databricks audit logs + - Test chat completions and embeddings with real models + - Test different SDK integration methods with custom user agents + +LiteLLM Integration Tests: + This test file includes tests for different ways of calling Databricks via LiteLLM: + + 1. LiteLLM SDK Direct - Using litellm.completion() with user_agent parameter + 2. LangChain + LiteLLM - Using ChatLiteLLM wrapper (requires langchain-community) + 3. LiteLLM Async - Using litellm.acompletion() async API + 4. LiteLLM Streaming - Using litellm.completion() with stream=True + 5. LiteLLM Embedding - Using litellm.embedding() with user_agent parameter + + All tests use the CUSTOM_USER_AGENT value from the config file and call + Databricks endpoints through LiteLLM's unified interface. + +Prerequisites: + - Valid Databricks workspace access + - Configured credentials (OAuth Service Principal, PAT, or Databricks CLI) + - Access to serving endpoints (e.g., databricks-gpt-oss-120b) + +Optional Dependencies (for LiteLLM integration tests): + - pip install langchain-litellm # For LangChain tests (recommended) + +Setup: + 1. Copy the template to create your config file: + cp databricks_config.template.txt ~/.databricks_litellm_config.txt + + 2. Edit the config file with your Databricks credentials: + - DATABRICKS_API_BASE (required) + - DATABRICKS_HOST (required for Databricks SDK tests) + - DATABRICKS_CLIENT_ID + DATABRICKS_CLIENT_SECRET (for OAuth) + - DATABRICKS_API_KEY (for PAT) + - CUSTOM_USER_AGENT (for partner attribution tests) + + 3. Optionally set a custom config path: + export DATABRICKS_TEST_CONFIG=/path/to/your/config.txt + +Run with: + cd /path/to/litellm + python tests/test_litellm/llms/databricks/test_databricks_e2e.py + +Config Options: + TEST_AUTH_METHOD=oauth # Test OAuth M2M authentication + TEST_AUTH_METHOD=pat # Test Personal Access Token + TEST_AUTH_METHOD=sdk # Test Databricks SDK (~/.databrickscfg) + TEST_AUTH_METHOD=all # Test all three methods sequentially +""" + +import os +import sys + +import pytest + +# Skip all tests in this module during unit test runs (make test-unit) +# These are E2E tests that require real Databricks credentials +pytestmark = pytest.mark.skip( + reason="E2E tests require real Databricks credentials. Run directly with: " + "python tests/test_litellm/llms/databricks/test_databricks_e2e.py" +) + +# Add the litellm package to path +sys.path.insert( + 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../..")) +) + +# Config file path - can be overridden with DATABRICKS_TEST_CONFIG env var +DEFAULT_CONFIG_PATH = os.path.expanduser("~/.databricks_litellm_config.txt") +CONFIG_FILE = os.environ.get("DATABRICKS_TEST_CONFIG", DEFAULT_CONFIG_PATH) + + +def load_config(config_file: str) -> dict: + """Load configuration from file.""" + config = {} + + template_path = os.path.join( + os.path.dirname(__file__), "databricks_config.template.txt" + ) + + if not os.path.exists(config_file): + raise FileNotFoundError( + f"Config file not found: {config_file}\n\n" + f"To set up:\n" + f" 1. Copy the template:\n" + f" cp {template_path} {config_file}\n\n" + f" 2. Edit {config_file} with your Databricks credentials\n\n" + f" 3. Or set a custom path:\n" + f" export DATABRICKS_TEST_CONFIG=/your/path/config.txt" + ) + + with open(config_file, "r") as f: + for line in f: + line = line.strip() + # Skip comments and empty lines + if not line or line.startswith("#"): + continue + + # Parse KEY=VALUE + if "=" in line: + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + if value: # Only set if value is not empty + config[key] = value + + return config + + +def setup_environment(config: dict, auth_method: str): + """Set up environment variables based on auth method.""" + # Clear any existing Databricks env vars (including SDK-specific ones) + for var in [ + "DATABRICKS_API_KEY", + "DATABRICKS_CLIENT_ID", + "DATABRICKS_CLIENT_SECRET", + "DATABRICKS_API_BASE", + "DATABRICKS_USER_AGENT", + "LITELLM_USER_AGENT", + "DATABRICKS_TOKEN", + "DATABRICKS_HOST", + ]: # Added SDK env vars + os.environ.pop(var, None) + + # Set auth based on method + if auth_method == "oauth": + if ( + "DATABRICKS_CLIENT_ID" not in config + or "DATABRICKS_CLIENT_SECRET" not in config + ): + raise ValueError( + "OAuth auth requires DATABRICKS_CLIENT_ID and DATABRICKS_CLIENT_SECRET" + ) + # For OAuth, set the API base + if "DATABRICKS_API_BASE" in config: + os.environ["DATABRICKS_API_BASE"] = config["DATABRICKS_API_BASE"] + os.environ["DATABRICKS_CLIENT_ID"] = config["DATABRICKS_CLIENT_ID"] + os.environ["DATABRICKS_CLIENT_SECRET"] = config["DATABRICKS_CLIENT_SECRET"] + print(" Auth method: OAuth M2M (Service Principal)") + + elif auth_method == "pat": + if "DATABRICKS_API_KEY" not in config: + raise ValueError("PAT auth requires DATABRICKS_API_KEY") + # For PAT, set the API base + if "DATABRICKS_API_BASE" in config: + os.environ["DATABRICKS_API_BASE"] = config["DATABRICKS_API_BASE"] + os.environ["DATABRICKS_API_KEY"] = config["DATABRICKS_API_KEY"] + print(" Auth method: Personal Access Token (PAT)") + + elif auth_method == "sdk": + # For SDK mode, don't set any env vars - let SDK use ~/.databrickscfg + # But we still need to pass api_base to litellm, so set it if provided + if "DATABRICKS_API_BASE" in config: + os.environ["DATABRICKS_API_BASE"] = config["DATABRICKS_API_BASE"] + print(" Auth method: Databricks SDK (automatic from ~/.databrickscfg)") + + else: + raise ValueError(f"Unknown auth method: {auth_method}") + + # Set custom user agent if provided + if "CUSTOM_USER_AGENT" in config: + os.environ["DATABRICKS_USER_AGENT"] = config["CUSTOM_USER_AGENT"] + print(f" Custom User-Agent: {config['CUSTOM_USER_AGENT']}") + + +def test_user_agent_building(): + """Test User-Agent string building.""" + print("\n" + "=" * 60) + print("TEST: User-Agent Building") + print("=" * 60) + + from litellm.llms.databricks.common_utils import DatabricksBase + + # Test 1: Default + ua = DatabricksBase._build_user_agent(None) + print(f" Default: {ua}") + assert ua.startswith("litellm/"), f"Expected litellm/, got {ua}" + print(" ✓ Default user agent works") + + # Test 2: With partner + ua = DatabricksBase._build_user_agent("mycompany/1.0.0") + print(f" With partner: {ua}") + assert ua.startswith("mycompany_litellm/"), f"Expected mycompany_litellm/, got {ua}" + print(" ✓ Partner prefixing works") + + # Test 3: Partner without version + ua = DatabricksBase._build_user_agent("acme") + print(f" Without version: {ua}") + assert ua.startswith("acme_litellm/"), f"Expected acme_litellm/, got {ua}" + print(" ✓ Partner without version works") + + print(" ✓ All user agent tests passed!") + + +def test_token_redaction(): + """Test sensitive data redaction.""" + print("\n" + "=" * 60) + print("TEST: Token Redaction") + print("=" * 60) + + from litellm.llms.databricks.common_utils import DatabricksBase + + # Test header redaction + headers = { + "Authorization": "Bearer dapi123456789abcdef", + "Content-Type": "application/json", + } + redacted = DatabricksBase.redact_headers_for_logging(headers) + print(f" Original: Authorization: Bearer dapi123456789abcdef") + print(f" Redacted: Authorization: {redacted['Authorization']}") + assert "[REDACTED]" in redacted["Authorization"] + assert redacted["Content-Type"] == "application/json" + print(" ✓ Header redaction works") + + # Test dict redaction + data = {"api_key": "secret123", "model": "dbrx"} + redacted = DatabricksBase.redact_sensitive_data(data) + assert redacted["api_key"] == "[REDACTED]" + assert redacted["model"] == "dbrx" + print(" ✓ Dict redaction works") + + # Test PAT redaction + text = "Token: dapi_fake_test_token_for_testing" + redacted = DatabricksBase.redact_sensitive_data(text) + assert "dapi_fake_test" not in redacted + print(" ✓ PAT string redaction works") + + print(" ✓ All redaction tests passed!") + + +def test_chat_completion(config: dict): + """Test chat completion with Databricks.""" + print("\n" + "=" * 60) + print("TEST: Chat Completion") + print("=" * 60) + + import litellm + + model = config.get("TEST_CHAT_MODEL", "databricks-gpt-oss-120b") + full_model = f"databricks/{model}" + + print(f" Model: {full_model}") + print(f" API Base: {os.environ.get('DATABRICKS_API_BASE', 'Not set')}") + + try: + response = litellm.completion( + model=full_model, + messages=[ + { + "role": "user", + "content": "Say 'Hello, LiteLLM test!' in exactly those words.", + } + ], + max_tokens=50, + temperature=0.1, + ) + + content = response.choices[0].message.content + print(f" Response: {content[:100]}...") + print(f" Model returned: {response.model}") + print(f" Usage: {response.usage}") + print(" ✓ Chat completion test passed!") + return True + + except Exception as e: + print(f" ✗ Chat completion failed: {e}") + return False + + +def test_chat_completion_default_user_agent(config: dict): + """Test chat completion with default user agent (no custom agent).""" + print("\n" + "=" * 60) + print("TEST: Chat Completion with DEFAULT User-Agent") + print("=" * 60) + + import litellm + + # Clear any custom user agent from environment + saved_user_agent = os.environ.pop("DATABRICKS_USER_AGENT", None) + saved_litellm_ua = os.environ.pop("LITELLM_USER_AGENT", None) + + try: + from litellm._version import version + except Exception: + version = "unknown" + + model = config.get("TEST_CHAT_MODEL", "databricks-gpt-oss-120b") + full_model = f"databricks/{model}" + + print(f" Model: {full_model}") + print(f" Expected User-Agent: litellm/{version}") + print(f" (No custom user agent set)") + + try: + response = litellm.completion( + model=full_model, + messages=[{"role": "user", "content": "Say 'default' only."}], + max_tokens=10, + # Note: NOT passing user_agent parameter + ) + + print(f" Response: {response.choices[0].message.content}") + print(" ✓ Default user-agent test passed!") + print( + f" Note: Check Databricks Query History to verify User-Agent is 'litellm/{version}'" + ) + return True + + except Exception as e: + print(f" ✗ Default user-agent test failed: {e}") + return False + + finally: + # Restore environment variables + if saved_user_agent: + os.environ["DATABRICKS_USER_AGENT"] = saved_user_agent + if saved_litellm_ua: + os.environ["LITELLM_USER_AGENT"] = saved_litellm_ua + + +def test_chat_completion_with_custom_user_agent(config: dict): + """Test chat completion with custom user agent passed as parameter.""" + print("\n" + "=" * 60) + print("TEST: Chat Completion with Custom User-Agent (parameter)") + print("=" * 60) + + import litellm + + # Clear any env user agent to ensure parameter takes precedence + saved_user_agent = os.environ.pop("DATABRICKS_USER_AGENT", None) + saved_litellm_ua = os.environ.pop("LITELLM_USER_AGENT", None) + + try: + from litellm._version import version + except Exception: + version = "unknown" + + model = config.get("TEST_CHAT_MODEL", "databricks-gpt-oss-120b") + full_model = f"databricks/{model}" + + print(f" Model: {full_model}") + print(f" Custom User-Agent param: testpartner/2.0.0") + print(f" Expected User-Agent: testpartner_litellm/{version}") + + try: + response = litellm.completion( + model=full_model, + messages=[{"role": "user", "content": "Say 'test' only."}], + max_tokens=10, + user_agent="testpartner/2.0.0", # This should result in testpartner_litellm/{version} + ) + + print(f" Response: {response.choices[0].message.content}") + print(" ✓ Custom user-agent test passed!") + print( + f" Note: Check Databricks Query History to verify User-Agent is 'testpartner_litellm/{version}'" + ) + return True + + except Exception as e: + print(f" ✗ Custom user-agent test failed: {e}") + return False + + finally: + # Restore environment variables + if saved_user_agent: + os.environ["DATABRICKS_USER_AGENT"] = saved_user_agent + if saved_litellm_ua: + os.environ["LITELLM_USER_AGENT"] = saved_litellm_ua + + +def test_chat_completion_with_env_user_agent(config: dict): + """Test chat completion with user agent set via environment variable.""" + print("\n" + "=" * 60) + print("TEST: Chat Completion with User-Agent from ENV VAR") + print("=" * 60) + + import litellm + + # Set a specific user agent via environment + test_partner = "envpartner" + os.environ["DATABRICKS_USER_AGENT"] = test_partner + + try: + from litellm._version import version + except Exception: + version = "unknown" + + model = config.get("TEST_CHAT_MODEL", "databricks-gpt-oss-120b") + full_model = f"databricks/{model}" + + print(f" Model: {full_model}") + print(f" DATABRICKS_USER_AGENT env var: {test_partner}") + print(f" Expected User-Agent: {test_partner}_litellm/{version}") + + try: + response = litellm.completion( + model=full_model, + messages=[{"role": "user", "content": "Say 'env' only."}], + max_tokens=10, + # Note: NOT passing user_agent parameter - should use env var + ) + + print(f" Response: {response.choices[0].message.content}") + print(" ✓ Env var user-agent test passed!") + print( + f" Note: Check Databricks Query History to verify User-Agent is '{test_partner}_litellm/{version}'" + ) + return True + + except Exception as e: + print(f" ✗ Env var user-agent test failed: {e}") + return False + + finally: + # Clean up + os.environ.pop("DATABRICKS_USER_AGENT", None) + + +def test_embedding(config: dict): + """Test embeddings with Databricks.""" + print("\n" + "=" * 60) + print("TEST: Embeddings") + print("=" * 60) + + import litellm + + model = config.get("TEST_EMBEDDING_MODEL", "databricks-bge-large-en") + full_model = f"databricks/{model}" + + print(f" Model: {full_model}") + + try: + response = litellm.embedding( + model=full_model, + input=["Hello, world!"], + ) + + # Handle both object and dict response formats + if hasattr(response, "data"): + data = response.data + else: + data = response.get("data", []) + + if data: + first_item = data[0] + if hasattr(first_item, "embedding"): + embedding = first_item.embedding + else: + embedding = first_item.get("embedding", []) + + print(f" Embedding dimensions: {len(embedding)}") + print(f" First 5 values: {embedding[:5]}") + print(" ✓ Embedding test passed!") + return True + else: + print(" ✗ Embedding test failed: No data in response") + return False + + except Exception as e: + print(f" ✗ Embedding test failed: {e}") + print(" (This is expected if embedding model is not available)") + return False + + +def test_oauth_token_retrieval(config: dict): + """Test OAuth M2M token retrieval.""" + print("\n" + "=" * 60) + print("TEST: OAuth M2M Token Retrieval") + print("=" * 60) + + if "DATABRICKS_CLIENT_ID" not in config or "DATABRICKS_CLIENT_SECRET" not in config: + print(" Skipped: OAuth credentials not configured") + return None + + from litellm.llms.databricks.common_utils import DatabricksBase + + try: + db = DatabricksBase() + token = db._get_oauth_m2m_token( + api_base=config["DATABRICKS_API_BASE"], + client_id=config["DATABRICKS_CLIENT_ID"], + client_secret=config["DATABRICKS_CLIENT_SECRET"], + ) + + # Redact token for display + redacted_token = ( + f"{token[:10]}...[REDACTED]" if len(token) > 10 else "[REDACTED]" + ) + print(f" Token obtained: {redacted_token}") + print(" ✓ OAuth M2M token retrieval passed!") + return True + + except Exception as e: + print(f" ✗ OAuth token retrieval failed: {e}") + return False + + +# ============================================================================== +# SDK INTEGRATION TESTS - Different ways of calling Databricks via LiteLLM +# ============================================================================== + + +def test_litellm_sdk_with_config_user_agent(config: dict): + """ + Test 1: LiteLLM SDK with custom user agent from config file. + + This test uses the LiteLLM SDK directly with the CUSTOM_USER_AGENT + specified in the databricks config file. + """ + print("\n" + "=" * 60) + print("TEST: LiteLLM SDK with Config User-Agent") + print("=" * 60) + + import litellm + from litellm.llms.databricks.common_utils import DatabricksBase + + custom_ua = config.get("CUSTOM_USER_AGENT") + if not custom_ua: + print(" Skipped: CUSTOM_USER_AGENT not set in config") + return None + + try: + from litellm._version import version + except Exception: + version = "unknown" + + model = config.get("TEST_CHAT_MODEL", "databricks-gpt-oss-120b") + full_model = f"databricks/{model}" + + # Build and display the final User-Agent that will be sent + final_user_agent = DatabricksBase._build_user_agent(custom_ua) + + print(f" Model: {full_model}") + print(f" Custom User-Agent from config: {custom_ua}") + print(f" >>> Final User-Agent sent: {final_user_agent}") + + try: + response = litellm.completion( + model=full_model, + messages=[{"role": "user", "content": "Say 'LiteLLM SDK test' only."}], + max_tokens=20, + temperature=0.1, + user_agent=custom_ua, # Use config user agent + ) + + content = response.choices[0].message.content + print(f" Response: {content}") + print(" ✓ LiteLLM SDK with config user-agent test passed!") + return True + + except Exception as e: + print(f" ✗ LiteLLM SDK test failed: {e}") + return False + + +def test_langchain_litellm_with_user_agent(config: dict): + """ + Test 2: LangChain with LiteLLM integration. + + This test uses LangChain's ChatLiteLLM wrapper to call Databricks + with custom user agent from config. + + Requires: pip install langchain-litellm (recommended) + or: pip install langchain langchain-community (deprecated) + """ + print("\n" + "=" * 60) + print("TEST: LangChain + LiteLLM with Config User-Agent") + print("=" * 60) + + from litellm.llms.databricks.common_utils import DatabricksBase + + custom_ua = config.get("CUSTOM_USER_AGENT") + if not custom_ua: + print(" Skipped: CUSTOM_USER_AGENT not set in config") + return None + + # Try the new langchain-litellm package first, fall back to deprecated import + ChatLiteLLM = None + HumanMessage = None + + try: + from langchain_litellm import ChatLiteLLM + from langchain_core.messages import HumanMessage + + print(" Using: langchain-litellm package (recommended)") + except ImportError: + try: + # Fall back to deprecated import + import warnings + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + from langchain_community.chat_models import ChatLiteLLM + from langchain_core.messages import HumanMessage + print( + " Using: langchain-community (deprecated, consider: pip install langchain-litellm)" + ) + except ImportError: + print(" Skipped: langchain-litellm not installed") + print(" Install with: pip install langchain-litellm") + return None + + model = config.get("TEST_CHAT_MODEL", "databricks-gpt-oss-120b") + full_model = f"databricks/{model}" + + # Build and display the final User-Agent that will be sent + final_user_agent = DatabricksBase._build_user_agent(custom_ua) + + print(f" Model: {full_model}") + print(f" Custom User-Agent from config: {custom_ua}") + print(f" >>> Final User-Agent sent: {final_user_agent}") + + try: + # Set user agent via environment for LangChain integration + os.environ["DATABRICKS_USER_AGENT"] = custom_ua + + chat = ChatLiteLLM( + model=full_model, + max_tokens=20, + temperature=0.1, + ) + + messages = [HumanMessage(content="Say 'LangChain test' only.")] + response = chat.invoke(messages) + + content = response.content + print(f" Response: {content}") + print(" ✓ LangChain + LiteLLM with config user-agent test passed!") + return True + + except Exception as e: + print(f" ✗ LangChain + LiteLLM test failed: {e}") + import traceback + + traceback.print_exc() + return False + + finally: + # Clean up env var + os.environ.pop("DATABRICKS_USER_AGENT", None) + + +def test_litellm_async_completion(config: dict): + """ + Test 3: LiteLLM Async Completion API with custom User-Agent. + + This test uses LiteLLM's async completion API (acompletion) to call + Databricks with custom user agent from config. + """ + print("\n" + "=" * 60) + print("TEST: LiteLLM Async Completion with Config User-Agent") + print("=" * 60) + + import asyncio + import litellm + from litellm.llms.databricks.common_utils import DatabricksBase + + custom_ua = config.get("CUSTOM_USER_AGENT") + if not custom_ua: + print(" Skipped: CUSTOM_USER_AGENT not set in config") + return None + + model = config.get("TEST_CHAT_MODEL", "databricks-gpt-oss-120b") + full_model = f"databricks/{model}" + + # Build and display the final User-Agent that will be sent + final_user_agent = DatabricksBase._build_user_agent(custom_ua) + + print(f" Model: {full_model}") + print(f" Custom User-Agent from config: {custom_ua}") + print(f" >>> Final User-Agent sent: {final_user_agent}") + + async def run_async_completion(): + response = await litellm.acompletion( + model=full_model, + messages=[{"role": "user", "content": "Say 'LiteLLM async test' only."}], + max_tokens=20, + temperature=0.1, + user_agent=custom_ua, + ) + return response + + try: + response = asyncio.run(run_async_completion()) + + content = response.choices[0].message.content + print(f" Response: {content}") + print(" ✓ LiteLLM async completion with config user-agent test passed!") + return True + + except Exception as e: + print(f" ✗ LiteLLM async completion test failed: {e}") + import traceback + + traceback.print_exc() + return False + + +def test_litellm_streaming_completion(config: dict): + """ + Test 4: LiteLLM Streaming Completion with custom User-Agent. + + This test uses LiteLLM's streaming completion API to call + Databricks with custom user agent from config. + """ + print("\n" + "=" * 60) + print("TEST: LiteLLM Streaming Completion with Config User-Agent") + print("=" * 60) + + import litellm + from litellm.llms.databricks.common_utils import DatabricksBase + + custom_ua = config.get("CUSTOM_USER_AGENT") + if not custom_ua: + print(" Skipped: CUSTOM_USER_AGENT not set in config") + return None + + model = config.get("TEST_CHAT_MODEL", "databricks-gpt-oss-120b") + full_model = f"databricks/{model}" + + # Build and display the final User-Agent that will be sent + final_user_agent = DatabricksBase._build_user_agent(custom_ua) + + print(f" Model: {full_model}") + print(f" Custom User-Agent from config: {custom_ua}") + print(f" >>> Final User-Agent sent: {final_user_agent}") + + try: + # Use streaming completion + response = litellm.completion( + model=full_model, + messages=[ + {"role": "user", "content": "Say 'LiteLLM streaming test' only."} + ], + max_tokens=20, + temperature=0.1, + user_agent=custom_ua, + stream=True, + ) + + # Collect streamed content + collected_content = "" + for chunk in response: + if chunk.choices and chunk.choices[0].delta.content: + collected_content += chunk.choices[0].delta.content + + print(f" Response (streamed): {collected_content}") + print(" ✓ LiteLLM streaming completion with config user-agent test passed!") + return True + + except Exception as e: + print(f" ✗ LiteLLM streaming completion test failed: {e}") + import traceback + + traceback.print_exc() + return False + + +def test_litellm_embedding_with_user_agent(config: dict): + """ + Test 5: LiteLLM Embedding API with custom User-Agent. + + This test uses LiteLLM's embedding API to call Databricks + with custom user agent from config. + """ + print("\n" + "=" * 60) + print("TEST: LiteLLM Embedding with Config User-Agent") + print("=" * 60) + + import litellm + from litellm.llms.databricks.common_utils import DatabricksBase + + custom_ua = config.get("CUSTOM_USER_AGENT") + if not custom_ua: + print(" Skipped: CUSTOM_USER_AGENT not set in config") + return None + + model = config.get("TEST_EMBEDDING_MODEL", "databricks-bge-large-en") + full_model = f"databricks/{model}" + + # Build and display the final User-Agent that will be sent + final_user_agent = DatabricksBase._build_user_agent(custom_ua) + + print(f" Model: {full_model}") + print(f" Custom User-Agent from config: {custom_ua}") + print(f" >>> Final User-Agent sent: {final_user_agent}") + + try: + response = litellm.embedding( + model=full_model, + input=["Hello, this is a LiteLLM embedding test with custom user agent!"], + user_agent=custom_ua, + ) + + # Handle both object and dict response formats + if hasattr(response, "data"): + data = response.data + else: + data = response.get("data", []) + + if data: + first_item = data[0] + if hasattr(first_item, "embedding"): + embedding = first_item.embedding + else: + embedding = first_item.get("embedding", []) + + print(f" Embedding dimensions: {len(embedding)}") + print(f" First 3 values: {embedding[:3]}") + print(" ✓ LiteLLM embedding with config user-agent test passed!") + return True + else: + print(" ✗ LiteLLM embedding test failed: No data in response") + return False + + except Exception as e: + print(f" ✗ LiteLLM embedding test failed: {e}") + print(" (This may fail if embedding model is not available)") + import traceback + + traceback.print_exc() + return False + + +def run_integration_tests_for_auth_method(config: dict, auth_method: str) -> list: + """Run integration tests for a specific auth method. Returns list of (name, result) tuples.""" + results = [] + + print("\n" + "=" * 60) + print(f"INTEGRATION TESTS - {auth_method.upper()} Authentication") + print("=" * 60) + + # Setup environment for this auth method + try: + setup_environment(config, auth_method) + except ValueError as e: + print(f" ✗ Setup failed: {e}") + return [(f"[{auth_method.upper()}] Setup", False)] + + # Test OAuth token retrieval (only for oauth method) + if auth_method == "oauth": + results.append( + ( + f"[{auth_method.upper()}] OAuth Token Retrieval", + test_oauth_token_retrieval(config), + ) + ) + + # Test chat completion + results.append( + (f"[{auth_method.upper()}] Chat Completion", test_chat_completion(config)) + ) + + # Test embeddings + results.append((f"[{auth_method.upper()}] Embeddings", test_embedding(config))) + + return results + + +def main(): + print("=" * 60) + print("DATABRICKS LITELLM INTEGRATION TESTS") + print("=" * 60) + + # Load config + print(f"\nLoading config from: {CONFIG_FILE}") + try: + config = load_config(CONFIG_FILE) + print(f" Loaded {len(config)} configuration values") + except FileNotFoundError as e: + print(f"\nERROR: {e}") + return 1 + + # Validate required config + if "DATABRICKS_API_BASE" not in config: + print("\nERROR: DATABRICKS_API_BASE is required in config file") + return 1 + + auth_method = config.get("TEST_AUTH_METHOD", "pat").lower() + print(f"\nTest Configuration:") + print(f" API Base: {config['DATABRICKS_API_BASE']}") + print(f" Auth Method: {auth_method}") + + # Run unit tests (no credentials needed) + print("\n" + "=" * 60) + print("UNIT TESTS (No credentials needed)") + print("=" * 60) + + test_user_agent_building() + test_token_redaction() + + all_results = [] + + # Determine which auth methods to test + if auth_method == "all": + auth_methods_to_test = ["oauth", "pat", "sdk"] + print("\n" + "#" * 60) + print("# TESTING ALL AUTHENTICATION METHODS") + print("#" * 60) + else: + auth_methods_to_test = [auth_method] + + # Run integration tests for each auth method + for method in auth_methods_to_test: + results = run_integration_tests_for_auth_method(config, method) + all_results.extend(results) + + # Run User-Agent tests (only once, using the last auth method or 'pat' for 'all') + print("\n" + "-" * 60) + print("USER-AGENT INTEGRATION TESTS") + print("-" * 60) + + # Setup environment for user-agent tests (use 'pat' as it's simplest) + if auth_method == "all": + setup_environment(config, "pat") + + # Test 1: Default user agent (no custom agent set) + all_results.append( + ( + "Chat with DEFAULT User-Agent", + test_chat_completion_default_user_agent(config), + ) + ) + + # Test 2: Custom user agent passed as parameter + all_results.append( + ( + "Chat with Custom User-Agent (param)", + test_chat_completion_with_custom_user_agent(config), + ) + ) + + # Test 3: User agent from environment variable + all_results.append( + ( + "Chat with User-Agent from ENV", + test_chat_completion_with_env_user_agent(config), + ) + ) + + # Run SDK Integration Tests with different calling methods + print("\n" + "#" * 60) + print("# SDK INTEGRATION TESTS - DIFFERENT CALLING METHODS") + print("# Using CUSTOM_USER_AGENT from config file") + print("#" * 60) + + # Setup environment for SDK tests (use 'pat' as it's most compatible) + setup_environment(config, "pat") + + # Test 1: LiteLLM SDK with config user agent + all_results.append( + ( + "LiteLLM SDK with Config User-Agent", + test_litellm_sdk_with_config_user_agent(config), + ) + ) + + # Test 2: LangChain + LiteLLM with config user agent + all_results.append( + ( + "LangChain + LiteLLM with Config User-Agent", + test_langchain_litellm_with_user_agent(config), + ) + ) + + # Test 3: LiteLLM Async Completion with config user agent + all_results.append( + ( + "LiteLLM Async Completion with Config User-Agent", + test_litellm_async_completion(config), + ) + ) + + # Test 4: LiteLLM Streaming Completion with config user agent + all_results.append( + ( + "LiteLLM Streaming Completion with Config User-Agent", + test_litellm_streaming_completion(config), + ) + ) + + # Test 5: LiteLLM Embedding with config user agent + all_results.append( + ( + "LiteLLM Embedding with Config User-Agent", + test_litellm_embedding_with_user_agent(config), + ) + ) + + # Summary + print("\n" + "=" * 60) + print("TEST SUMMARY") + print("=" * 60) + + passed = sum(1 for _, r in all_results if r is True) + failed = sum(1 for _, r in all_results if r is False) + skipped = sum(1 for _, r in all_results if r is None) + + for name, result in all_results: + status = ( + "✓ PASSED" + if result is True + else ("✗ FAILED" if result is False else "○ SKIPPED") + ) + print(f" {status}: {name}") + + print(f"\n Total: {passed} passed, {failed} failed, {skipped} skipped") + + if auth_method == "all": + print(f"\n Auth methods tested: {', '.join(auth_methods_to_test)}") + + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_litellm/llms/databricks/test_databricks_partner_integration.py b/tests/test_litellm/llms/databricks/test_databricks_partner_integration.py new file mode 100644 index 00000000000..b4dc6c68bb0 --- /dev/null +++ b/tests/test_litellm/llms/databricks/test_databricks_partner_integration.py @@ -0,0 +1,662 @@ +""" +Unit Tests for Databricks Partner Integration Features +======================================================= + +These tests are designed for automated CI/CD pipelines and do NOT require +real Databricks credentials. All external calls are mocked. + +For integration tests that use real Databricks credentials, see: + test_databricks_integration.py + +Features Tested: + - User-Agent building with partner prefixing (Databricks partner telemetry) + - Token/sensitive data redaction for secure logging + - OAuth M2M (Machine-to-Machine) authentication flow + - Databricks SDK partner telemetry registration + - Authentication priority (OAuth M2M > PAT > SDK) + +Run with: + pytest test_databricks_partner_integration.py -v + +These tests align with Databricks Partner Architecture best practices: + https://github.com/databrickslabs/partner-architecture +""" + +import json +import os +import sys + +import pytest +from unittest.mock import MagicMock, patch, Mock + +sys.path.insert( + 0, os.path.abspath("../../../..") +) # Adds the parent directory to the system path + +from litellm.llms.databricks.common_utils import DatabricksBase, DatabricksException + + +class TestBuildUserAgent: + """Test cases for User-Agent string building.""" + + def test_default_user_agent(self): + """No custom user agent returns litellm/{version}.""" + ua = DatabricksBase._build_user_agent(None) + assert ua.startswith("litellm/") + assert "_" not in ua.split("/")[0] + + def test_custom_user_agent_with_version(self): + """Custom user agent with version extracts partner name.""" + ua = DatabricksBase._build_user_agent("mycompany/1.0.0") + assert ua.startswith("mycompany_litellm/") + # Verify the version is litellm's, not the custom one + assert "/1.0.0" not in ua or "mycompany_litellm/1.0.0" not in ua + + def test_custom_user_agent_without_version(self): + """Custom user agent without version still works.""" + ua = DatabricksBase._build_user_agent("mycompany") + assert ua.startswith("mycompany_litellm/") + + def test_custom_user_agent_with_underscore(self): + """Partner names with underscores are preserved.""" + ua = DatabricksBase._build_user_agent("my_company/2.0.0") + assert ua.startswith("my_company_litellm/") + + def test_custom_user_agent_with_hyphen(self): + """Partner names with hyphens are preserved.""" + ua = DatabricksBase._build_user_agent("my-company/2.0.0") + assert ua.startswith("my-company_litellm/") + + def test_custom_user_agent_ignores_custom_version(self): + """Custom version is ignored, litellm version is used.""" + ua = DatabricksBase._build_user_agent("partner/99.99.99") + parts = ua.split("/") + assert parts[0] == "partner_litellm" + assert parts[1] != "99.99.99" + + def test_empty_string_returns_default(self): + """Empty string returns default user agent.""" + ua = DatabricksBase._build_user_agent("") + assert ua.startswith("litellm/") + assert "_" not in ua.split("/")[0] + + def test_whitespace_only_returns_default(self): + """Whitespace-only string returns default user agent.""" + ua = DatabricksBase._build_user_agent(" ") + assert ua.startswith("litellm/") + assert "_" not in ua.split("/")[0] + + def test_invalid_partner_name_returns_default(self): + """Invalid partner names (special chars) return default.""" + ua = DatabricksBase._build_user_agent("my@company/1.0.0") + assert ua.startswith("litellm/") + + def test_partner_with_numbers(self): + """Partner names with numbers work.""" + ua = DatabricksBase._build_user_agent("company123/1.0.0") + assert ua.startswith("company123_litellm/") + + +class TestRedactSensitiveData: + """Test cases for sensitive data redaction.""" + + def test_redact_bearer_token_in_string(self): + """Bearer tokens are redacted in strings.""" + result = DatabricksBase.redact_sensitive_data("Bearer dapi12345abcdef") + assert "dapi12345abcdef" not in result + assert "[REDACTED]" in result + + def test_redact_dict_with_authorization(self): + """Dict with authorization key is redacted.""" + data = {"Authorization": "Bearer secret123", "other": "value"} + result = DatabricksBase.redact_sensitive_data(data) + assert result["Authorization"] == "[REDACTED]" + assert result["other"] == "value" + + def test_redact_nested_dict(self): + """Nested dicts with sensitive keys are redacted.""" + data = {"config": {"api_key": "secret", "name": "test"}} + result = DatabricksBase.redact_sensitive_data(data) + assert result["config"]["api_key"] == "[REDACTED]" + assert result["config"]["name"] == "test" + + def test_redact_pat_token(self): + """Databricks PAT tokens are redacted.""" + result = DatabricksBase.redact_sensitive_data( + "Using token dapi_fake_test_token_value" + ) + assert "dapi_fake_test_token_value" not in result + assert "[REDACTED_PAT]" in result + + def test_redact_client_secret(self): + """Client secrets are redacted.""" + data = {"client_secret": "my-super-secret-value"} + result = DatabricksBase.redact_sensitive_data(data) + assert result["client_secret"] == "[REDACTED]" + + def test_redact_list_of_dicts(self): + """Lists containing dicts with sensitive data are redacted.""" + data = [{"api_key": "secret1"}, {"name": "test"}] + result = DatabricksBase.redact_sensitive_data(data) + assert result[0]["api_key"] == "[REDACTED]" + assert result[1]["name"] == "test" + + def test_redact_none_returns_none(self): + """None input returns None.""" + assert DatabricksBase.redact_sensitive_data(None) is None + + def test_redact_preserves_non_sensitive_data(self): + """Non-sensitive data is preserved.""" + data = {"model": "dbrx", "temperature": 0.7, "messages": ["hello"]} + result = DatabricksBase.redact_sensitive_data(data) + assert result == data + + +class TestRedactHeadersForLogging: + """Test cases for header redaction.""" + + def test_authorization_header_partially_shown(self): + """Authorization header shows first 8 chars then redacts.""" + headers = {"Authorization": "Bearer dapi123456789abcdef"} + result = DatabricksBase.redact_headers_for_logging(headers) + assert result["Authorization"].startswith("Bearer d") + assert "[REDACTED]" in result["Authorization"] + + def test_short_authorization_header_fully_redacted(self): + """Short authorization values are fully redacted.""" + headers = {"Authorization": "short"} + result = DatabricksBase.redact_headers_for_logging(headers) + assert result["Authorization"] == "[REDACTED]" + + def test_non_sensitive_headers_preserved(self): + """Non-sensitive headers are not modified.""" + headers = {"Content-Type": "application/json", "User-Agent": "test/1.0"} + result = DatabricksBase.redact_headers_for_logging(headers) + assert result["Content-Type"] == "application/json" + assert result["User-Agent"] == "test/1.0" + + def test_empty_headers_returns_empty(self): + """Empty headers dict returns empty dict.""" + assert DatabricksBase.redact_headers_for_logging({}) == {} + + def test_none_headers_returns_empty(self): + """None headers returns empty dict.""" + assert DatabricksBase.redact_headers_for_logging(None) == {} + + def test_x_api_key_header_redacted(self): + """X-API-Key header is redacted.""" + headers = {"X-API-Key": "my-api-key-12345"} + result = DatabricksBase.redact_headers_for_logging(headers) + assert "[REDACTED]" in result["X-API-Key"] + + +class TestOAuthM2M: + """Test cases for OAuth M2M authentication.""" + + def test_oauth_m2m_token_success(self): + """OAuth M2M token is successfully obtained.""" + databricks_base = DatabricksBase() + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"access_token": "test-access-token"} + + with patch("requests.post", return_value=mock_response) as mock_post: + token = databricks_base._get_oauth_m2m_token( + api_base="https://adb-123.azuredatabricks.net/serving-endpoints", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + assert token == "test-access-token" + mock_post.assert_called_once() + call_args = mock_post.call_args + assert "oidc/v1/token" in call_args[0][0] + assert call_args[1]["data"]["grant_type"] == "client_credentials" + + def test_oauth_m2m_token_failure(self): + """OAuth M2M raises exception on failure.""" + databricks_base = DatabricksBase() + + mock_response = Mock() + mock_response.status_code = 401 + mock_response.text = "Unauthorized" + + with patch("requests.post", return_value=mock_response): + with pytest.raises(DatabricksException) as exc_info: + databricks_base._get_oauth_m2m_token( + api_base="https://adb-123.azuredatabricks.net", + client_id="bad-client-id", + client_secret="bad-secret", + ) + assert exc_info.value.status_code == 401 + + def test_oauth_m2m_strips_serving_endpoints(self): + """OAuth M2M correctly strips /serving-endpoints from URL.""" + databricks_base = DatabricksBase() + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"access_token": "token"} + + with patch("requests.post", return_value=mock_response) as mock_post: + databricks_base._get_oauth_m2m_token( + api_base="https://adb-123.azuredatabricks.net/serving-endpoints", + client_id="id", + client_secret="secret", + ) + + call_url = mock_post.call_args[0][0] + assert "/serving-endpoints" not in call_url + assert call_url == "https://adb-123.azuredatabricks.net/oidc/v1/token" + + +class TestValidateEnvironmentWithOAuth: + """Test OAuth M2M is used when credentials are available.""" + + def test_oauth_used_when_credentials_set(self, monkeypatch): + """OAuth M2M is used when client_id and client_secret are set.""" + monkeypatch.setenv("DATABRICKS_CLIENT_ID", "test-client-id") + monkeypatch.setenv("DATABRICKS_CLIENT_SECRET", "test-secret") + monkeypatch.setenv( + "DATABRICKS_API_BASE", "https://adb-123.net/serving-endpoints" + ) + + databricks_base = DatabricksBase() + + with patch.object( + databricks_base, "_get_oauth_m2m_token", return_value="oauth-token" + ) as mock_oauth: + api_base, headers = databricks_base.databricks_validate_environment( + api_key=None, + api_base=None, + endpoint_type="chat_completions", + custom_endpoint=False, + headers=None, + ) + + mock_oauth.assert_called_once() + assert headers["Authorization"] == "Bearer oauth-token" + + def test_pat_used_when_api_key_set(self, monkeypatch): + """PAT is used when api_key is provided.""" + monkeypatch.delenv("DATABRICKS_CLIENT_ID", raising=False) + monkeypatch.delenv("DATABRICKS_CLIENT_SECRET", raising=False) + + databricks_base = DatabricksBase() + + api_base, headers = databricks_base.databricks_validate_environment( + api_key="dapi-test-key", + api_base="https://adb-123.net/serving-endpoints", + endpoint_type="chat_completions", + custom_endpoint=False, + headers=None, + ) + + assert headers["Authorization"] == "Bearer dapi-test-key" + + +class TestValidateEnvironmentUserAgent: + """Test User-Agent is correctly set in validate_environment.""" + + def test_default_user_agent(self, monkeypatch): + """Default user agent is set when no custom agent provided.""" + monkeypatch.delenv("DATABRICKS_USER_AGENT", raising=False) + monkeypatch.delenv("LITELLM_USER_AGENT", raising=False) + + databricks_base = DatabricksBase() + + api_base, headers = databricks_base.databricks_validate_environment( + api_key="test-key", + api_base="https://adb-123.net/serving-endpoints", + endpoint_type="chat_completions", + custom_endpoint=False, + headers=None, + custom_user_agent=None, + ) + + assert headers["User-Agent"].startswith("litellm/") + assert "_" not in headers["User-Agent"].split("/")[0] + + def test_custom_user_agent_via_param(self, monkeypatch): + """Custom user agent is prefixed when passed as parameter.""" + databricks_base = DatabricksBase() + + api_base, headers = databricks_base.databricks_validate_environment( + api_key="test-key", + api_base="https://adb-123.net/serving-endpoints", + endpoint_type="chat_completions", + custom_endpoint=False, + headers=None, + custom_user_agent="mycompany/1.0.0", + ) + + assert headers["User-Agent"].startswith("mycompany_litellm/") + + +class TestSDKPartnerTelemetry: + """Test that SDK partner telemetry is registered.""" + + def test_sdk_partner_registered(self): + """useragent.with_partner is called when using SDK.""" + databricks_base = DatabricksBase() + + mock_workspace_client = MagicMock() + mock_workspace_client.config.host = "https://adb-123.net" + mock_workspace_client.config.authenticate.return_value = { + "Authorization": "Bearer token" + } + + with patch( + "databricks.sdk.WorkspaceClient", return_value=mock_workspace_client + ): + with patch("databricks.sdk.useragent.with_partner") as mock_with_partner: + databricks_base._get_databricks_credentials( + api_key=None, + api_base=None, + headers=None, + ) + + mock_with_partner.assert_called_once_with("litellm") + + +class TestUserAgentFromEnvironment: + """Test User-Agent is correctly picked up from environment variables.""" + + def test_user_agent_from_databricks_env_var(self, monkeypatch): + """DATABRICKS_USER_AGENT environment variable is used.""" + monkeypatch.setenv("DATABRICKS_USER_AGENT", "envpartner") + monkeypatch.delenv("LITELLM_USER_AGENT", raising=False) + + databricks_base = DatabricksBase() + + api_base, headers = databricks_base.databricks_validate_environment( + api_key="test-key", + api_base="https://adb-123.net/serving-endpoints", + endpoint_type="chat_completions", + custom_endpoint=False, + headers=None, + custom_user_agent="envpartner", # Simulating what transformation.py passes + ) + + assert headers["User-Agent"].startswith("envpartner_litellm/") + + def test_custom_param_takes_precedence(self, monkeypatch): + """Custom user_agent parameter takes precedence over environment.""" + monkeypatch.setenv("DATABRICKS_USER_AGENT", "envpartner") + + databricks_base = DatabricksBase() + + api_base, headers = databricks_base.databricks_validate_environment( + api_key="test-key", + api_base="https://adb-123.net/serving-endpoints", + endpoint_type="chat_completions", + custom_endpoint=False, + headers=None, + custom_user_agent="parampartner/1.0.0", + ) + + assert headers["User-Agent"].startswith("parampartner_litellm/") + + +class TestLiteLLMCompletionUserAgent: + """Test User-Agent is correctly passed through LiteLLM completion calls.""" + + def test_completion_passes_user_agent_to_headers(self): + """litellm.completion() correctly passes user_agent to request headers.""" + from litellm.llms.databricks.chat.transformation import DatabricksConfig + + config = DatabricksConfig() + optional_params = {"user_agent": "testpartner/1.0.0"} + + # Mock the validation to capture what headers are set + with patch.object( + config, + "databricks_validate_environment", + return_value=( + "https://test.net/serving-endpoints/chat/completions", + { + "Authorization": "Bearer test", + "User-Agent": "testpartner_litellm/1.0.0", + }, + ), + ) as mock_validate: + result = config.validate_environment( + headers={}, + model="databricks/test-model", + messages=[], + optional_params=optional_params, + litellm_params={}, + api_key="test-key", + api_base="https://test.net/serving-endpoints", + ) + + # Verify user_agent was passed to databricks_validate_environment + mock_validate.assert_called_once() + call_kwargs = mock_validate.call_args[1] + assert call_kwargs.get("custom_user_agent") == "testpartner/1.0.0" + + def test_user_agent_removed_from_optional_params(self): + """user_agent is removed from optional_params so it's not sent to API.""" + from litellm.llms.databricks.chat.transformation import DatabricksConfig + + config = DatabricksConfig() + optional_params = { + "user_agent": "testpartner/1.0.0", + "temperature": 0.7, + } + + with patch.object( + config, + "databricks_validate_environment", + return_value=( + "https://test.net/chat/completions", + {"Authorization": "Bearer test", "User-Agent": "test"}, + ), + ): + config.validate_environment( + headers={}, + model="databricks/test-model", + messages=[], + optional_params=optional_params, + litellm_params={}, + api_key="test-key", + api_base="https://test.net/serving-endpoints", + ) + + # user_agent should be removed from optional_params + assert "user_agent" not in optional_params + # Other params should remain + assert optional_params.get("temperature") == 0.7 + + +class TestLiteLLMEmbeddingUserAgent: + """Test User-Agent is correctly passed through LiteLLM embedding calls.""" + + def test_embedding_passes_user_agent_to_headers(self): + """litellm.embedding() correctly passes user_agent to request headers.""" + from litellm.llms.databricks.embed.handler import DatabricksEmbeddingHandler + + handler = DatabricksEmbeddingHandler() + optional_params = {"user_agent": "embedpartner/1.0.0"} + + with patch.object( + handler, + "databricks_validate_environment", + return_value=( + "https://test.net/serving-endpoints/embeddings", + { + "Authorization": "Bearer test", + "User-Agent": "embedpartner_litellm/1.0.0", + }, + ), + ) as mock_validate: + with patch( + "litellm.llms.openai_like.embedding.handler.OpenAILikeEmbeddingHandler.embedding" + ): + try: + handler.embedding( + model="databricks/test-model", + input=["test"], + timeout=30, + api_key="test-key", + api_base="https://test.net/serving-endpoints", + optional_params=optional_params, + ) + except Exception: + pass # We just want to verify the mock was called + + # Verify user_agent was passed + if mock_validate.called: + call_kwargs = mock_validate.call_args[1] + assert call_kwargs.get("custom_user_agent") == "embedpartner/1.0.0" + + +class TestAuthenticationPriority: + """Test that authentication methods are used in correct priority order.""" + + def test_oauth_used_when_no_api_key_provided(self, monkeypatch): + """OAuth M2M is used when OAuth creds are set and no api_key is provided.""" + monkeypatch.setenv("DATABRICKS_CLIENT_ID", "oauth-client-id") + monkeypatch.setenv("DATABRICKS_CLIENT_SECRET", "oauth-secret") + monkeypatch.setenv("DATABRICKS_API_BASE", "https://test.net/serving-endpoints") + + databricks_base = DatabricksBase() + + with patch.object( + databricks_base, "_get_oauth_m2m_token", return_value="oauth-token" + ) as mock_oauth: + api_base, headers = databricks_base.databricks_validate_environment( + api_key=None, # No PAT provided - OAuth should be used + api_base=None, + endpoint_type="chat_completions", + custom_endpoint=False, + headers=None, + ) + + # OAuth should be used + mock_oauth.assert_called_once() + assert headers["Authorization"] == "Bearer oauth-token" + + def test_explicit_pat_takes_priority_over_oauth_env(self, monkeypatch): + """Explicit api_key takes priority over OAuth token in final headers.""" + monkeypatch.setenv("DATABRICKS_CLIENT_ID", "oauth-client-id") + monkeypatch.setenv("DATABRICKS_CLIENT_SECRET", "oauth-secret") + monkeypatch.setenv("DATABRICKS_API_BASE", "https://test.net/serving-endpoints") + + databricks_base = DatabricksBase() + + # Mock the OAuth call - it will be attempted but PAT should override + with patch.object( + databricks_base, "_get_oauth_m2m_token", return_value="oauth-token" + ): + api_base, headers = databricks_base.databricks_validate_environment( + api_key="dapi-explicit-pat", + api_base=None, + endpoint_type="chat_completions", + custom_endpoint=False, + headers=None, + ) + + # PAT should override OAuth token since api_key was explicitly provided + assert headers["Authorization"] == "Bearer dapi-explicit-pat" + + def test_pat_used_when_no_oauth_credentials(self, monkeypatch): + """PAT is used when OAuth credentials are not set.""" + monkeypatch.delenv("DATABRICKS_CLIENT_ID", raising=False) + monkeypatch.delenv("DATABRICKS_CLIENT_SECRET", raising=False) + + databricks_base = DatabricksBase() + + api_base, headers = databricks_base.databricks_validate_environment( + api_key="dapi-pat-token", + api_base="https://test.net/serving-endpoints", + endpoint_type="chat_completions", + custom_endpoint=False, + headers=None, + ) + + assert headers["Authorization"] == "Bearer dapi-pat-token" + + def test_sdk_fallback_when_no_credentials(self, monkeypatch): + """Databricks SDK is used when no API key or OAuth credentials.""" + monkeypatch.delenv("DATABRICKS_CLIENT_ID", raising=False) + monkeypatch.delenv("DATABRICKS_CLIENT_SECRET", raising=False) + monkeypatch.delenv("DATABRICKS_API_KEY", raising=False) + + databricks_base = DatabricksBase() + + mock_workspace_client = MagicMock() + mock_workspace_client.config.host = "https://adb-123.net" + mock_workspace_client.config.authenticate.return_value = { + "Authorization": "Bearer sdk-token" + } + + with patch( + "databricks.sdk.WorkspaceClient", return_value=mock_workspace_client + ): + with patch("databricks.sdk.useragent.with_partner"): + api_base, headers = databricks_base.databricks_validate_environment( + api_key=None, + api_base=None, + endpoint_type="chat_completions", + custom_endpoint=False, + headers=None, + ) + + assert "Authorization" in headers + + +class TestEndpointURLConstruction: + """Test that endpoint URLs are correctly constructed.""" + + def test_chat_completions_endpoint(self, monkeypatch): + """Chat completions endpoint is correctly appended.""" + monkeypatch.delenv("DATABRICKS_CLIENT_ID", raising=False) + monkeypatch.delenv("DATABRICKS_CLIENT_SECRET", raising=False) + + databricks_base = DatabricksBase() + + api_base, headers = databricks_base.databricks_validate_environment( + api_key="test-key", + api_base="https://test.net/serving-endpoints", + endpoint_type="chat_completions", + custom_endpoint=False, + headers=None, + ) + + assert api_base.endswith("/chat/completions") + + def test_embeddings_endpoint(self, monkeypatch): + """Embeddings endpoint is correctly appended.""" + monkeypatch.delenv("DATABRICKS_CLIENT_ID", raising=False) + monkeypatch.delenv("DATABRICKS_CLIENT_SECRET", raising=False) + + databricks_base = DatabricksBase() + + api_base, headers = databricks_base.databricks_validate_environment( + api_key="test-key", + api_base="https://test.net/serving-endpoints", + endpoint_type="embeddings", + custom_endpoint=False, + headers=None, + ) + + assert api_base.endswith("/embeddings") + + def test_custom_endpoint_not_modified(self, monkeypatch): + """Custom endpoints are not modified.""" + monkeypatch.delenv("DATABRICKS_CLIENT_ID", raising=False) + monkeypatch.delenv("DATABRICKS_CLIENT_SECRET", raising=False) + + databricks_base = DatabricksBase() + + api_base, headers = databricks_base.databricks_validate_environment( + api_key="test-key", + api_base="https://test.net/custom/endpoint", + endpoint_type="chat_completions", + custom_endpoint=True, + headers=None, + ) + + assert api_base == "https://test.net/custom/endpoint" diff --git a/tests/test_litellm/llms/minimax/__init__.py b/tests/test_litellm/llms/minimax/__init__.py new file mode 100644 index 00000000000..19c644e5d98 --- /dev/null +++ b/tests/test_litellm/llms/minimax/__init__.py @@ -0,0 +1,2 @@ +# MiniMax tests + diff --git a/tests/test_litellm/llms/minimax/chat/__init__.py b/tests/test_litellm/llms/minimax/chat/__init__.py new file mode 100644 index 00000000000..6c63920b3ea --- /dev/null +++ b/tests/test_litellm/llms/minimax/chat/__init__.py @@ -0,0 +1,2 @@ +# MiniMax chat tests + diff --git a/tests/test_litellm/llms/minimax/chat/test_transformation.py b/tests/test_litellm/llms/minimax/chat/test_transformation.py new file mode 100644 index 00000000000..aa7105077a0 --- /dev/null +++ b/tests/test_litellm/llms/minimax/chat/test_transformation.py @@ -0,0 +1,225 @@ +""" +Test MiniMax OpenAI-compatible API support +""" +import os +import sys +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert( + 0, os.path.abspath("../") +) # Adds the parent directory to the system path + +import litellm +from litellm import completion +from litellm.llms.minimax.chat.transformation import MinimaxChatConfig + + +def test_minimax_chat_config(): + """Test that MinimaxChatConfig is properly configured""" + config = MinimaxChatConfig() + + # Test get_api_base default + api_base = config.get_api_base() + assert api_base == "https://api.minimax.io/v1" + + # Test get_api_base with custom value + custom_base = config.get_api_base(api_base="https://api.minimaxi.com/v1") + assert custom_base == "https://api.minimaxi.com/v1" + + # Test get_complete_url + complete_url = config.get_complete_url( + api_base="https://api.minimax.io/v1", + api_key=None, + model="MiniMax-M2.1", + optional_params={}, + litellm_params={}, + stream=False + ) + assert complete_url == "https://api.minimax.io/v1/chat/completions" + + +def test_minimax_chat_config_url_variations(): + """Test URL handling with different base URL formats""" + config = MinimaxChatConfig() + + # Test with /v1 ending + url1 = config.get_complete_url( + api_base="https://api.minimax.io/v1", + api_key=None, + model="MiniMax-M2.1", + optional_params={}, + litellm_params={}, + ) + assert url1 == "https://api.minimax.io/v1/chat/completions" + + # Test with trailing slash + url2 = config.get_complete_url( + api_base="https://api.minimax.io/", + api_key=None, + model="MiniMax-M2.1", + optional_params={}, + litellm_params={}, + ) + assert url2 == "https://api.minimax.io/v1/chat/completions" + + # Test without trailing slash + url3 = config.get_complete_url( + api_base="https://api.minimax.io", + api_key=None, + model="MiniMax-M2.1", + optional_params={}, + litellm_params={}, + ) + assert url3 == "https://api.minimax.io/v1/chat/completions" + + # Test with full path already + url4 = config.get_complete_url( + api_base="https://api.minimax.io/v1/chat/completions", + api_key=None, + model="MiniMax-M2.1", + optional_params={}, + litellm_params={}, + ) + assert url4 == "https://api.minimax.io/v1/chat/completions" + + +def test_minimax_provider_routing(): + """Test that minimax provider is properly routed""" + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + # Test with minimax/ prefix + model, provider, api_key, api_base = get_llm_provider( + model="minimax/MiniMax-M2.1", + api_base="https://api.minimax.io/v1" + ) + assert provider == "minimax" + assert model == "MiniMax-M2.1" + + +def test_minimax_provider_config_manager(): + """Test that ProviderConfigManager returns MinimaxChatConfig""" + from litellm.types.utils import LlmProviders + from litellm.utils import ProviderConfigManager + + config = ProviderConfigManager.get_provider_chat_config( + model="MiniMax-M2.1", + provider=LlmProviders.MINIMAX + ) + + assert config is not None + assert isinstance(config, MinimaxChatConfig) + + +@pytest.mark.skip(reason="Requires actual MiniMax API key") +def test_minimax_chat_completion_basic(): + """Test basic chat completion with MiniMax OpenAI-compatible API""" + response = completion( + model="minimax/MiniMax-M2.1", + messages=[ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Hello, how are you?"} + ], + api_key=os.getenv("MINIMAX_API_KEY"), + api_base="https://api.minimax.io/v1" + ) + + assert response is not None + assert hasattr(response, "choices") + assert len(response.choices) > 0 + + +@pytest.mark.skip(reason="Requires actual MiniMax API key") +def test_minimax_chat_completion_with_reasoning_split(): + """Test completion with reasoning_split parameter (MiniMax M2.1 feature)""" + response = completion( + model="minimax/MiniMax-M2.1", + messages=[ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Solve this problem: 2+2=?"} + ], + api_key=os.getenv("MINIMAX_API_KEY"), + api_base="https://api.minimax.io/v1", + extra_body={"reasoning_split": True} + ) + + assert response is not None + # Check if reasoning_details is present in response + if hasattr(response.choices[0].message, "reasoning_details"): + assert response.choices[0].message.reasoning_details is not None + + +@pytest.mark.skip(reason="Requires actual MiniMax API key") +def test_minimax_chat_completion_with_tools(): + """Test completion with tool calling (function calling)""" + tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather in a location", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA", + } + }, + "required": ["location"], + }, + }, + } + ] + + response = completion( + model="minimax/MiniMax-M2.1", + messages=[{"role": "user", "content": "What's the weather in San Francisco?"}], + tools=tools, + api_key=os.getenv("MINIMAX_API_KEY"), + api_base="https://api.minimax.io/v1" + ) + + assert response is not None + assert hasattr(response, "choices") + + +@pytest.mark.skip(reason="Requires actual MiniMax API key") +def test_minimax_chat_completion_streaming(): + """Test streaming completion""" + response = completion( + model="minimax/MiniMax-M2.1", + messages=[{"role": "user", "content": "Count to 5"}], + stream=True, + api_key=os.getenv("MINIMAX_API_KEY"), + api_base="https://api.minimax.io/v1" + ) + + chunks = [] + for chunk in response: + chunks.append(chunk) + + assert len(chunks) > 0 + + +if __name__ == "__main__": + # Run basic tests that don't require API key + print("Testing MiniMax Chat Config...") + test_minimax_chat_config() + print("✓ Config test passed") + + print("\nTesting MiniMax Chat Config URL Variations...") + test_minimax_chat_config_url_variations() + print("✓ URL variations test passed") + + print("\nTesting MiniMax Provider Routing...") + test_minimax_provider_routing() + print("✓ Routing test passed") + + print("\nTesting MiniMax Provider Config Manager...") + test_minimax_provider_config_manager() + print("✓ Provider config manager test passed") + + print("\n✅ All basic tests passed!") + diff --git a/tests/test_litellm/llms/minimax/messages/__init__.py b/tests/test_litellm/llms/minimax/messages/__init__.py new file mode 100644 index 00000000000..8672b141150 --- /dev/null +++ b/tests/test_litellm/llms/minimax/messages/__init__.py @@ -0,0 +1,2 @@ +# MiniMax messages tests + diff --git a/tests/test_litellm/llms/minimax/messages/test_transformation.py b/tests/test_litellm/llms/minimax/messages/test_transformation.py new file mode 100644 index 00000000000..bbb30b652af --- /dev/null +++ b/tests/test_litellm/llms/minimax/messages/test_transformation.py @@ -0,0 +1,147 @@ +""" +Test MiniMax Anthropic-compatible API support +""" +import os +import sys +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert( + 0, os.path.abspath("../") +) # Adds the parent directory to the system path + +import litellm +from litellm import completion +from litellm.llms.minimax.messages.transformation import MinimaxMessagesConfig + + +def test_minimax_anthropic_config(): + """Test that MinimaxMessagesConfig is properly configured""" + config = MinimaxMessagesConfig() + + # Test custom_llm_provider + assert config.custom_llm_provider == "minimax" + + # Test get_api_base default + api_base = config.get_api_base() + assert api_base == "https://api.minimax.io/anthropic/v1/messages" + + # Test get_api_base with custom value + custom_base = config.get_api_base(api_base="https://api.minimaxi.com/anthropic/v1/messages") + assert custom_base == "https://api.minimaxi.com/anthropic/v1/messages" + + +def test_minimax_provider_routing(): + """Test that minimax provider is properly routed""" + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + # Test with minimax/ prefix + model, provider, api_key, api_base = get_llm_provider( + model="minimax/MiniMax-M2.1", + api_base="https://api.minimax.io/anthropic/v1/messages" + ) + assert provider == "minimax" + assert model == "MiniMax-M2.1" + + +def test_minimax_provider_config_manager(): + """Test that ProviderConfigManager returns MinimaxMessagesConfig""" + from litellm.types.utils import LlmProviders + from litellm.utils import ProviderConfigManager + + config = ProviderConfigManager.get_provider_anthropic_messages_config( + model="MiniMax-M2.1", + provider=LlmProviders.MINIMAX + ) + + assert config is not None + assert isinstance(config, MinimaxMessagesConfig) + assert config.custom_llm_provider == "minimax" + + +@pytest.mark.skip(reason="Requires actual MiniMax API key") +def test_minimax_completion_basic(): + """Test basic completion with MiniMax Anthropic-compatible API""" + response = completion( + model="minimax/MiniMax-M2.1", + messages=[{"role": "user", "content": "Hello, how are you?"}], + api_key=os.getenv("MINIMAX_API_KEY"), + api_base="https://api.minimax.io/anthropic/v1/messages" + ) + + assert response is not None + assert hasattr(response, "choices") + assert len(response.choices) > 0 + + +@pytest.mark.skip(reason="Requires actual MiniMax API key") +def test_minimax_completion_with_thinking(): + """Test completion with thinking parameter (MiniMax M2.1 feature)""" + response = completion( + model="minimax/MiniMax-M2.1", + messages=[{"role": "user", "content": "Solve this problem: 2+2=?"}], + api_key=os.getenv("MINIMAX_API_KEY"), + api_base="https://api.minimax.io/anthropic/v1/messages", + thinking={"type": "enabled", "budget_tokens": 1000} + ) + + assert response is not None + # Check if thinking content is present in response + for choice in response.choices: + if hasattr(choice.message, "content"): + # MiniMax returns thinking blocks similar to Anthropic + assert choice.message.content is not None + + +@pytest.mark.skip(reason="Requires actual MiniMax API key") +def test_minimax_completion_with_tools(): + """Test completion with tool calling (function calling)""" + tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather in a location", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA", + } + }, + "required": ["location"], + }, + }, + } + ] + + response = completion( + model="minimax/MiniMax-M2.1", + messages=[{"role": "user", "content": "What's the weather in San Francisco?"}], + tools=tools, + api_key=os.getenv("MINIMAX_API_KEY"), + api_base="https://api.minimax.io/anthropic/v1/messages" + ) + + assert response is not None + assert hasattr(response, "choices") + + +if __name__ == "__main__": + # Run basic tests that don't require API key + print("Testing MiniMax Anthropic Config...") + test_minimax_anthropic_config() + print("✓ Config test passed") + + print("\nTesting MiniMax Provider Routing...") + test_minimax_provider_routing() + print("✓ Routing test passed") + + print("\nTesting MiniMax Provider Config Manager...") + test_minimax_provider_config_manager() + print("✓ Provider config manager test passed") + + print("\n✅ All basic tests passed!") + diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_thought_signature_in_tool_call_id.py b/tests/test_litellm/llms/vertex_ai/gemini/test_thought_signature_in_tool_call_id.py index 46bb8930a7a..5fe51ed23b9 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_thought_signature_in_tool_call_id.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_thought_signature_in_tool_call_id.py @@ -10,15 +10,16 @@ enable_preview_features=True to be enabled. """ import pytest + import litellm -from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( - VertexGeminiConfig, -) from litellm.litellm_core_utils.prompt_templates.factory import ( THOUGHT_SIGNATURE_SEPARATOR, - convert_to_gemini_tool_call_invoke, _encode_tool_call_id_with_signature, _get_thought_signature_from_tool, + convert_to_gemini_tool_call_invoke, +) +from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( + VertexGeminiConfig, ) from litellm.types.llms.vertex_ai import HttpxPartType @@ -71,52 +72,36 @@ def test_tool_call_id_includes_signature_in_response(enable_preview_features): """Test that tool call IDs in responses include embedded thought signatures only when preview features are enabled""" test_signature = "Co4CAdHtim/rWgXbz2Ghp4tShzLeMASrPw6JJyYIC3cbVyZnKzU3uv8/wVzyS2sKRPL2m8QQHHXbNQhEEz500G7n/4ZMmksdTtfQcJMoT76S1DGwhnAiLwTgWCNXs3lEb4M19EVYoWFxhrH5Lr9YMIquoU9U4paydGwvZyIyigamIg4B6WnxrRsf0KZV12gJed0DZuKczvOFtHz3zUnmZRlOiTzd5gBVyQM+5jv1VI8m4WUKd6cN/5a5ZvaA0ggiO6kdVhlpIVs7GczSEVJD8KH4u02X7VSnb7CvykqDntZzV0y8rZFBEFGKrChmeHlWXP4D1IB3F9KQyhuLgWImMzg4BajKVxxMU737JGnNISy5" - # Save original state - original_flag = litellm.enable_preview_features - litellm.enable_preview_features = enable_preview_features - - try: - parts_with_signature = [ - HttpxPartType( - functionCall={ - "name": "get_current_temperature", - "args": {"location": "Paris"}, - }, - thoughtSignature=test_signature, - ) - ] - - function, tools, _ = VertexGeminiConfig._transform_parts( - parts=parts_with_signature, - cumulative_tool_call_idx=0, - is_function_call=False, + parts_with_signature = [ + HttpxPartType( + functionCall={ + "name": "get_current_temperature", + "args": {"location": "Paris"}, + }, + thoughtSignature=test_signature, ) + ] - # Verify tool call exists - assert tools is not None - assert len(tools) == 1 - tool_call_id = tools[0]["id"] - - # Verify signature is always in provider_specific_fields - assert tools[0].get("provider_specific_fields", {}).get("thought_signature") == test_signature + function, tools, _ = VertexGeminiConfig._transform_parts( + parts=parts_with_signature, + cumulative_tool_call_idx=0, + is_function_call=False, + ) - if enable_preview_features: - # When preview features enabled, signature should be embedded in ID - assert THOUGHT_SIGNATURE_SEPARATOR in tool_call_id - # Verify we can decode it using the factory function - tool_obj = {"id": tool_call_id, "type": "function"} - decoded_sig = _get_thought_signature_from_tool(tool_obj) - assert decoded_sig == test_signature - else: - # When preview features disabled, signature should NOT be embedded in ID - assert THOUGHT_SIGNATURE_SEPARATOR not in tool_call_id - # But we can still extract from provider_specific_fields - tool_obj = {"id": tool_call_id, "type": "function", "provider_specific_fields": {"thought_signature": test_signature}} - decoded_sig = _get_thought_signature_from_tool(tool_obj) - assert decoded_sig == test_signature - finally: - # Restore original state - litellm.enable_preview_features = original_flag + # Verify tool call exists + assert tools is not None + assert len(tools) == 1 + tool_call_id = tools[0]["id"] + + # Verify signature is always in provider_specific_fields + assert tools[0].get("provider_specific_fields", {}).get("thought_signature") == test_signature + + # When preview features enabled, signature should be embedded in ID + assert THOUGHT_SIGNATURE_SEPARATOR in tool_call_id + # Verify we can decode it using the factory function + tool_obj = {"id": tool_call_id, "type": "function"} + decoded_sig = _get_thought_signature_from_tool(tool_obj) + assert decoded_sig == test_signature def test_get_thought_signature_backward_compatibility(): @@ -204,90 +189,57 @@ def test_openai_client_e2e_flow(enable_preview_features): """ test_signature = "Co4CAdHtim/rWgXbz2Ghp4tShzLeMASrPw6JJyYIC3cbVyZnKzU3uv8/wVzyS2sKRPL2m8QQHHXbNQhEEz500G7n/4ZMmksdTtfQcJMoT76S1DGwhnAiLwTgWCNXs3lEb4M19EVYoWFxhrH5Lr9YMIquoU9U4paydGwvZyIyigamIg4B6WnxrRsf0KZV12gJed0DZuKczvOFtHz3zUnmZRlOiTzd5gBVyQM+5jv1VI8m4WUKd6cN/5a5ZvaA0ggiO6kdVhlpIVs7GczSEVJD8KH4u02X7VSnb7CvykqDntZzV0y8rZFBEFGKrChmeHlWXP4D1IB3F9KQyhuLgWImMzg4BajKVxxMU737JGnNISy5" - # Save original state - original_flag = litellm.enable_preview_features - litellm.enable_preview_features = enable_preview_features + # Step 1: Gemini returns function call with thought signature + gemini_parts = [ + HttpxPartType( + functionCall={ + "name": "get_current_temperature", + "args": {"location": "Paris"}, + }, + thoughtSignature=test_signature, + ) + ] - try: - # Step 1: Gemini returns function call with thought signature - gemini_parts = [ - HttpxPartType( - functionCall={ + # Step 2: LiteLLM transforms to OpenAI format + function, tools, _ = VertexGeminiConfig._transform_parts( + parts=gemini_parts, + cumulative_tool_call_idx=0, + is_function_call=False, + ) + + assert tools is not None + assert len(tools) == 1 + tool_call_id = tools[0]["id"] + + assert THOUGHT_SIGNATURE_SEPARATOR in tool_call_id + + # Step 3: OpenAI client sends back assistant message + # For the disabled case, we simulate that the client might have provider_specific_fields + # or we use the embedded ID if preview features were enabled + openai_assistant_message = { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": tool_call_id, # Preserved from response (with embedded signature) + "type": "function", + "function": { "name": "get_current_temperature", - "args": {"location": "Paris"}, + "arguments": '{"location": "Paris"}', }, - thoughtSignature=test_signature, - ) - ] - - # Step 2: LiteLLM transforms to OpenAI format - function, tools, _ = VertexGeminiConfig._transform_parts( - parts=gemini_parts, - cumulative_tool_call_idx=0, - is_function_call=False, - ) - - assert tools is not None - assert len(tools) == 1 - tool_call_id = tools[0]["id"] - - if enable_preview_features: - # When preview features enabled, signature should be embedded in ID - assert THOUGHT_SIGNATURE_SEPARATOR in tool_call_id - else: - # When preview features disabled, signature should NOT be embedded in ID - assert THOUGHT_SIGNATURE_SEPARATOR not in tool_call_id - - # Step 3: OpenAI client sends back assistant message - # For the disabled case, we simulate that the client might have provider_specific_fields - # or we use the embedded ID if preview features were enabled - if enable_preview_features: - openai_assistant_message = { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": tool_call_id, # Preserved from response (with embedded signature) - "type": "function", - "function": { - "name": "get_current_temperature", - "arguments": '{"location": "Paris"}', - }, - } - ], - } - else: - # When preview features disabled, simulate that provider_specific_fields might be preserved - # (though in real OpenAI client usage, this might not happen) - # For this test, we'll use provider_specific_fields to show extraction still works - openai_assistant_message = { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": tool_call_id, # ID without embedded signature - "type": "function", - "function": { - "name": "get_current_temperature", - "arguments": '{"location": "Paris"}', - }, - "provider_specific_fields": {"thought_signature": test_signature}, - } - ], } + ], + } + # Step 4: LiteLLM converts back to Gemini format, extracting signature + gemini_parts_converted = convert_to_gemini_tool_call_invoke( + openai_assistant_message + ) - # Step 4: LiteLLM converts back to Gemini format, extracting signature - gemini_parts_converted = convert_to_gemini_tool_call_invoke( - openai_assistant_message - ) + # Verify signature is preserved through the round trip + assert len(gemini_parts_converted) == 1 + assert "thoughtSignature" in gemini_parts_converted[0] + assert gemini_parts_converted[0]["thoughtSignature"] == test_signature - # Verify signature is preserved through the round trip - assert len(gemini_parts_converted) == 1 - assert "thoughtSignature" in gemini_parts_converted[0] - assert gemini_parts_converted[0]["thoughtSignature"] == test_signature - finally: - # Restore original state - litellm.enable_preview_features = original_flag @pytest.mark.parametrize("enable_preview_features", [True, False]) @@ -296,54 +248,36 @@ def test_parallel_tool_calls_with_signatures(enable_preview_features): signature1 = "signature_for_first_call" # Only first call has signature (Gemini behavior for parallel calls) - # Save original state - original_flag = litellm.enable_preview_features - litellm.enable_preview_features = enable_preview_features + gemini_parts = [ + HttpxPartType( + functionCall={"name": "get_temperature", "args": {"location": "Paris"}}, + thoughtSignature=signature1, + ), + HttpxPartType( + functionCall={"name": "get_temperature", "args": {"location": "London"}}, + # No signature for second parallel call + ), + ] - try: - gemini_parts = [ - HttpxPartType( - functionCall={"name": "get_temperature", "args": {"location": "Paris"}}, - thoughtSignature=signature1, - ), - HttpxPartType( - functionCall={"name": "get_temperature", "args": {"location": "London"}}, - # No signature for second parallel call - ), - ] + function, tools, _ = VertexGeminiConfig._transform_parts( + parts=gemini_parts, + cumulative_tool_call_idx=0, + is_function_call=False, + ) - function, tools, _ = VertexGeminiConfig._transform_parts( - parts=gemini_parts, - cumulative_tool_call_idx=0, - is_function_call=False, - ) + assert tools is not None + assert len(tools) == 2 - assert tools is not None - assert len(tools) == 2 + # First tool call should have signature in provider_specific_fields + assert tools[0].get("provider_specific_fields", {}).get("thought_signature") == signature1 + + # When preview features enabled, first tool call has signature in ID + assert THOUGHT_SIGNATURE_SEPARATOR in tools[0]["id"] + sig1 = _get_thought_signature_from_tool({"id": tools[0]["id"], "type": "function"}) + assert sig1 == signature1 - # First tool call should have signature in provider_specific_fields - assert tools[0].get("provider_specific_fields", {}).get("thought_signature") == signature1 - - if enable_preview_features: - # When preview features enabled, first tool call has signature in ID - assert THOUGHT_SIGNATURE_SEPARATOR in tools[0]["id"] - sig1 = _get_thought_signature_from_tool({"id": tools[0]["id"], "type": "function"}) - assert sig1 == signature1 - else: - # When preview features disabled, signature should NOT be in ID - assert THOUGHT_SIGNATURE_SEPARATOR not in tools[0]["id"] - # But we can extract from provider_specific_fields - sig1 = _get_thought_signature_from_tool({ - "id": tools[0]["id"], - "type": "function", - "provider_specific_fields": {"thought_signature": signature1} - }) - assert sig1 == signature1 - # Second tool call has no signature in ID (regardless of flag) - assert THOUGHT_SIGNATURE_SEPARATOR not in tools[1]["id"] - sig2 = _get_thought_signature_from_tool({"id": tools[1]["id"], "type": "function"}) - assert sig2 is None - finally: - # Restore original state - litellm.enable_preview_features = original_flag + # Second tool call has no signature in ID (regardless of flag) + assert THOUGHT_SIGNATURE_SEPARATOR not in tools[1]["id"] + sig2 = _get_thought_signature_from_tool({"id": tools[1]["id"], "type": "function"}) + assert sig2 is None diff --git a/tests/test_litellm/llms/vertex_ai/test_bge_embedding.py b/tests/test_litellm/llms/vertex_ai/test_bge_embedding.py index 4a06e9ea1aa..1f0f3346c2a 100644 --- a/tests/test_litellm/llms/vertex_ai/test_bge_embedding.py +++ b/tests/test_litellm/llms/vertex_ai/test_bge_embedding.py @@ -193,7 +193,7 @@ def test_vertex_ai_bge_psc_endpoint_url_construction(): client = HTTPHandler() def mock_auth_token(*args, **kwargs): - return "fake-token", "gen-lang-client-0682925754" + return "test-token-123", "test-gcp-project-id-123" with patch.object(client, "post") as mock_post, patch( "litellm.llms.vertex_ai.vertex_embeddings.embedding_handler.VertexEmbedding._ensure_access_token", @@ -212,7 +212,7 @@ def test_vertex_ai_bge_psc_endpoint_url_construction(): model="vertex_ai/bge/378943383978115072", input=["The food was delicious and the waiter.."], api_base="http://10.128.16.2", - vertex_project="gen-lang-client-0682925754", + vertex_project="test-gcp-project-id-123", vertex_location="us-central1", client=client, use_psc_endpoint_format=True # Enable PSC endpoint format for this test @@ -239,7 +239,7 @@ def test_vertex_ai_bge_psc_endpoint_url_construction(): print("="*50 + "\n") # Verify the URL is constructed correctly - expected_url = "http://10.128.16.2/v1/projects/gen-lang-client-0682925754/locations/us-central1/endpoints/378943383978115072:predict" + expected_url = "http://10.128.16.2/v1/projects/test-gcp-project-id-123/locations/us-central1/endpoints/378943383978115072:predict" assert api_url_called == expected_url, f"Expected URL: {expected_url}, Got: {api_url_called}" # Verify bge/ prefix is NOT in the URL diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_global_url_support.py b/tests/test_litellm/llms/vertex_ai/test_vertex_global_url_support.py new file mode 100644 index 00000000000..2c0178b3150 --- /dev/null +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_global_url_support.py @@ -0,0 +1,428 @@ +""" +Comprehensive tests for Vertex AI global URL support across all endpoints. + +This test suite ensures that all Vertex AI endpoints properly handle the 'global' location, +which uses a different URL format than regional endpoints. + +Regional: https://{region}-aiplatform.googleapis.com/... +Global: https://aiplatform.googleapis.com/... +""" + +from unittest.mock import patch + +import pytest + +from litellm.llms.vertex_ai.common_utils import ( + _get_embedding_url, + _get_vertex_url, + get_vertex_base_url, +) + + +class TestVertexBaseURL: + """Test the centralized get_vertex_base_url helper function.""" + + @pytest.mark.parametrize( + "vertex_location, expected_base_url", + [ + ("us-central1", "https://us-central1-aiplatform.googleapis.com"), + ("us-east1", "https://us-east1-aiplatform.googleapis.com"), + ("europe-west1", "https://europe-west1-aiplatform.googleapis.com"), + ("asia-northeast1", "https://asia-northeast1-aiplatform.googleapis.com"), + ("global", "https://aiplatform.googleapis.com"), + ], + ) + def test_get_vertex_base_url(self, vertex_location, expected_base_url): + """Test that get_vertex_base_url returns correct URL for all location types.""" + result = get_vertex_base_url(vertex_location) + assert result == expected_base_url + assert not result.endswith("/") # No trailing slash + + +class TestChatCompletionURLs: + """Test chat/completion endpoint URL construction with global location.""" + + @pytest.mark.parametrize( + "vertex_location, stream, expected_url_pattern", + [ + # Regional, non-streaming + ( + "us-central1", + False, + "https://us-central1-aiplatform.googleapis.com/v1/projects/test-project/locations/us-central1/publishers/google/models/gemini-1.5-pro:generateContent", + ), + # Regional, streaming + ( + "us-central1", + True, + "https://us-central1-aiplatform.googleapis.com/v1/projects/test-project/locations/us-central1/publishers/google/models/gemini-1.5-pro:streamGenerateContent?alt=sse", + ), + # Global, non-streaming + ( + "global", + False, + "https://aiplatform.googleapis.com/v1/projects/test-project/locations/global/publishers/google/models/gemini-1.5-pro:generateContent", + ), + # Global, streaming + ( + "global", + True, + "https://aiplatform.googleapis.com/v1/projects/test-project/locations/global/publishers/google/models/gemini-1.5-pro:streamGenerateContent?alt=sse", + ), + ], + ) + def test_chat_url_construction( + self, vertex_location, stream, expected_url_pattern + ): + """Test that chat URLs are correctly constructed for regional and global locations.""" + with patch( + "litellm.VertexGeminiConfig.get_model_for_vertex_ai_url", + side_effect=lambda model: model, + ): + url, endpoint = _get_vertex_url( + mode="chat", + model="gemini-1.5-pro", + stream=stream, + vertex_project="test-project", + vertex_location=vertex_location, + vertex_api_version="v1", + ) + + assert url == expected_url_pattern + if stream: + assert endpoint == "streamGenerateContent" + assert "?alt=sse" in url + else: + assert endpoint == "generateContent" + assert "?alt=sse" not in url + + @pytest.mark.parametrize( + "vertex_location, stream", + [ + ("us-central1", False), + ("us-central1", True), + ("global", False), + ("global", True), + ], + ) + def test_finetuned_model_url_construction(self, vertex_location, stream): + """Test that fine-tuned models (numeric IDs) use endpoints/ path correctly.""" + with patch( + "litellm.VertexGeminiConfig.get_model_for_vertex_ai_url", + side_effect=lambda model: model, + ): + url, endpoint = _get_vertex_url( + mode="chat", + model="1234567890", # Numeric model ID + stream=stream, + vertex_project="test-project", + vertex_location=vertex_location, + vertex_api_version="v1", + ) + + # Should use endpoints/ path instead of publishers/google/models/ + assert "/endpoints/1234567890:" in url + assert "/publishers/google/models/" not in url + + # Check base URL is correct + if vertex_location == "global": + assert url.startswith("https://aiplatform.googleapis.com") + else: + assert url.startswith(f"https://{vertex_location}-aiplatform.googleapis.com") + + +class TestEmbeddingURLs: + """Test embedding endpoint URL construction with global location.""" + + @pytest.mark.parametrize( + "vertex_location, model, expected_url_pattern", + [ + # Regional, regular model + ( + "us-central1", + "text-embedding-004", + "https://us-central1-aiplatform.googleapis.com/v1/projects/test-project/locations/us-central1/publishers/google/models/text-embedding-004:predict", + ), + # Global, regular model + ( + "global", + "text-embedding-004", + "https://aiplatform.googleapis.com/v1/projects/test-project/locations/global/publishers/google/models/text-embedding-004:predict", + ), + # Regional, numeric endpoint + ( + "us-central1", + "1234567890", + "https://us-central1-aiplatform.googleapis.com/v1/projects/test-project/locations/us-central1/endpoints/1234567890:predict", + ), + # Global, numeric endpoint + ( + "global", + "1234567890", + "https://aiplatform.googleapis.com/v1/projects/test-project/locations/global/endpoints/1234567890:predict", + ), + ], + ) + def test_embedding_url_construction( + self, vertex_location, model, expected_url_pattern + ): + """Test that embedding URLs are correctly constructed for regional and global locations.""" + url, endpoint = _get_embedding_url( + model=model, + vertex_project="test-project", + vertex_location=vertex_location, + vertex_api_version="v1", + ) + + assert url == expected_url_pattern + assert endpoint == "predict" + + # Verify base URL format + if vertex_location == "global": + assert url.startswith("https://aiplatform.googleapis.com") + assert "-aiplatform.googleapis.com" not in url + else: + assert url.startswith(f"https://{vertex_location}-aiplatform.googleapis.com") + + @pytest.mark.parametrize( + "vertex_location", + ["us-central1", "europe-west1", "global"], + ) + def test_embedding_url_with_routing_prefix(self, vertex_location): + """Test that routing prefixes (bge/, gemma/, etc.) are stripped from URLs.""" + url, endpoint = _get_embedding_url( + model="bge/1234567890", # Model with routing prefix + vertex_project="test-project", + vertex_location=vertex_location, + vertex_api_version="v1", + ) + + # Routing prefix should be stripped + assert "bge/" not in url + assert "/endpoints/1234567890:" in url + + +class TestCountTokensURLs: + """Test count_tokens endpoint URL construction with global location.""" + + @pytest.mark.parametrize( + "vertex_location, expected_url_pattern", + [ + ( + "us-central1", + "https://us-central1-aiplatform.googleapis.com/v1/projects/test-project/locations/us-central1/publishers/google/models/gemini-1.5-pro:countTokens", + ), + ( + "global", + "https://aiplatform.googleapis.com/v1/projects/test-project/locations/global/publishers/google/models/gemini-1.5-pro:countTokens", + ), + ], + ) + def test_count_tokens_url_construction(self, vertex_location, expected_url_pattern): + """Test that count_tokens URLs are correctly constructed for regional and global locations.""" + with patch( + "litellm.VertexGeminiConfig.get_model_for_vertex_ai_url", + side_effect=lambda model: model, + ): + url, endpoint = _get_vertex_url( + mode="count_tokens", + model="gemini-1.5-pro", + stream=None, + vertex_project="test-project", + vertex_location=vertex_location, + vertex_api_version="v1", + ) + + assert url == expected_url_pattern + assert endpoint == "countTokens" + + +class TestImageGenerationURLs: + """Test image_generation endpoint URL construction with global location.""" + + @pytest.mark.parametrize( + "vertex_location, model, expected_url_pattern", + [ + # Regional, regular model + ( + "us-central1", + "imagen-3.0-generate-001", + "https://us-central1-aiplatform.googleapis.com/v1/projects/test-project/locations/us-central1/publishers/google/models/imagen-3.0-generate-001:predict", + ), + # Global, regular model + ( + "global", + "imagen-3.0-generate-001", + "https://aiplatform.googleapis.com/v1/projects/test-project/locations/global/publishers/google/models/imagen-3.0-generate-001:predict", + ), + # Regional, numeric endpoint + ( + "us-central1", + "9876543210", + "https://us-central1-aiplatform.googleapis.com/v1/projects/test-project/locations/us-central1/endpoints/9876543210:predict", + ), + # Global, numeric endpoint + ( + "global", + "9876543210", + "https://aiplatform.googleapis.com/v1/projects/test-project/locations/global/endpoints/9876543210:predict", + ), + ], + ) + def test_image_generation_url_construction( + self, vertex_location, model, expected_url_pattern + ): + """Test that image_generation URLs are correctly constructed for regional and global locations.""" + with patch( + "litellm.VertexGeminiConfig.get_model_for_vertex_ai_url", + side_effect=lambda model: model, + ): + url, endpoint = _get_vertex_url( + mode="image_generation", + model=model, + stream=None, + vertex_project="test-project", + vertex_location=vertex_location, + vertex_api_version="v1", + ) + + assert url == expected_url_pattern + assert endpoint == "predict" + + +class TestAPIVersions: + """Test that both v1 and v1beta1 API versions work with global location.""" + + @pytest.mark.parametrize( + "api_version, vertex_location", + [ + ("v1", "us-central1"), + ("v1", "global"), + ("v1beta1", "us-central1"), + ("v1beta1", "global"), + ], + ) + def test_api_versions_in_urls(self, api_version, vertex_location): + """Test that API version is correctly included in URLs for all locations.""" + with patch( + "litellm.VertexGeminiConfig.get_model_for_vertex_ai_url", + side_effect=lambda model: model, + ): + url, _ = _get_vertex_url( + mode="chat", + model="gemini-1.5-pro", + stream=False, + vertex_project="test-project", + vertex_location=vertex_location, + vertex_api_version=api_version, + ) + + # API version should be in the URL + assert f"/{api_version}/" in url + + +class TestEdgeCases: + """Test edge cases and special scenarios.""" + + def test_global_location_no_region_prefix(self): + """Ensure global URLs never have a region prefix.""" + base_url = get_vertex_base_url("global") + assert base_url == "https://aiplatform.googleapis.com" + assert "global-aiplatform" not in base_url + assert "-aiplatform.googleapis.com" not in base_url + + @pytest.mark.parametrize( + "mode", + ["chat", "embedding", "count_tokens", "image_generation"], + ) + def test_all_modes_support_global(self, mode): + """Test that all URL modes support global location.""" + with patch( + "litellm.VertexGeminiConfig.get_model_for_vertex_ai_url", + side_effect=lambda model: model, + ): + if mode == "embedding": + url, _ = _get_embedding_url( + model="text-embedding-004", + vertex_project="test-project", + vertex_location="global", + vertex_api_version="v1", + ) + else: + url, _ = _get_vertex_url( + mode=mode, + model="gemini-1.5-pro", + stream=False, + vertex_project="test-project", + vertex_location="global", + vertex_api_version="v1", + ) + + # All URLs should use global format + assert url.startswith("https://aiplatform.googleapis.com") + assert "/locations/global/" in url + + def test_location_in_path_matches_parameter(self): + """Ensure the location in the URL path matches the vertex_location parameter.""" + test_locations = ["us-central1", "europe-west1", "global"] + + for location in test_locations: + with patch( + "litellm.VertexGeminiConfig.get_model_for_vertex_ai_url", + side_effect=lambda model: model, + ): + url, _ = _get_vertex_url( + mode="chat", + model="gemini-1.5-pro", + stream=False, + vertex_project="test-project", + vertex_location=location, + vertex_api_version="v1", + ) + + # Location should appear in the path + assert f"/locations/{location}/" in url + + +class TestBackwardCompatibility: + """Ensure changes don't break existing functionality.""" + + def test_regional_urls_unchanged(self): + """Test that regional URL construction hasn't changed.""" + with patch( + "litellm.VertexGeminiConfig.get_model_for_vertex_ai_url", + side_effect=lambda model: model, + ): + url, _ = _get_vertex_url( + mode="chat", + model="gemini-1.5-pro", + stream=False, + vertex_project="my-project", + vertex_location="us-central1", + vertex_api_version="v1", + ) + + # Should match the traditional regional format + assert ( + url + == "https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini-1.5-pro:generateContent" + ) + + def test_streaming_urls_unchanged(self): + """Test that streaming URL construction hasn't changed.""" + with patch( + "litellm.VertexGeminiConfig.get_model_for_vertex_ai_url", + side_effect=lambda model: model, + ): + url, _ = _get_vertex_url( + mode="chat", + model="gemini-1.5-pro", + stream=True, + vertex_project="my-project", + vertex_location="us-central1", + vertex_api_version="v1", + ) + + # Should include streaming endpoint and alt=sse + assert ":streamGenerateContent?alt=sse" in url + diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py index 21782f42189..e1e4b3a8b6d 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -332,7 +332,7 @@ class TestMCPRequestHandler: async def mock_user_api_key_auth(api_key, request): return UserAPIKeyAuth( token=( - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + "test-token-sha256-empty-hash" if api_key else None ), @@ -691,7 +691,7 @@ class TestMCPCustomHeaderName: # Create an async mock for user_api_key_auth async def mock_user_api_key_auth(api_key, request): return UserAPIKeyAuth( - token="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + token="test-token-sha256-empty-hash", api_key=api_key, user_id="test-user-id", team_id="test-team-id", @@ -866,7 +866,7 @@ class TestMCPAccessGroupsE2E: # Create an async mock for user_api_key_auth async def mock_user_api_key_auth(api_key, request): return UserAPIKeyAuth( - token="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + token="test-token-sha256-empty-hash", api_key=api_key, user_id="test-user-id", team_id="test-team-id", @@ -917,7 +917,7 @@ class TestMCPAccessGroupsE2E: # Create an async mock for user_api_key_auth async def mock_user_api_key_auth(api_key, request): return UserAPIKeyAuth( - token="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + token="test-token-sha256-empty-hash", api_key=api_key, user_id="test-user-id", team_id="test-team-id", diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index 4fc94000d61..a1fbddec586 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -294,6 +294,7 @@ async def test_mcp_get_prompt_success(): arguments={"foo": "bar"}, mcp_auth_header={"Authorization": "token"}, extra_headers={"X-Test": "1"}, + raw_headers=None, ) assert result is prompt_result @@ -349,6 +350,7 @@ async def test_mcp_read_resource_success(): url="https://example.com/resource", mcp_auth_header={"Authorization": "token"}, extra_headers={"X-Test": "1"}, + raw_headers=None, ) assert result is read_result @@ -428,7 +430,11 @@ async def test_get_tools_from_mcp_servers_continues_when_one_server_fails(): ) async def mock_get_tools_from_server( - server, mcp_auth_header=None, extra_headers=None, add_prefix=True + server, + mcp_auth_header=None, + extra_headers=None, + add_prefix=True, + raw_headers=None, ): if server.name == "working_server": # Working server returns tools @@ -524,7 +530,11 @@ async def test_get_tools_from_mcp_servers_handles_all_servers_failing(): ) async def mock_get_tools_from_server( - server, mcp_auth_header=None, extra_headers=None, add_prefix=True + server, + mcp_auth_header=None, + extra_headers=None, + add_prefix=True, + raw_headers=None, ): # All servers fail raise Exception(f"Server {server.name} connection failed") @@ -839,13 +849,19 @@ async def test_oauth2_headers_passed_to_mcp_client(): # This will capture the arguments passed to _create_mcp_client captured_client_args = {} - def mock_create_mcp_client(server, mcp_auth_header=None, extra_headers=None): + def mock_create_mcp_client( + server, + mcp_auth_header=None, + extra_headers=None, + stdio_env=None, + ): # Capture the arguments for verification captured_client_args.update( { "server": server, "mcp_auth_header": mcp_auth_header, "extra_headers": extra_headers, + "stdio_env": stdio_env, } ) # Return a mock client that doesn't actually connect @@ -934,7 +950,11 @@ async def test_list_tools_single_server_unprefixed_names(): mock_manager.get_mcp_server_by_id = MagicMock(return_value=server) async def mock_get_tools_from_server( - server, mcp_auth_header=None, extra_headers=None, add_prefix=False + server, + mcp_auth_header=None, + extra_headers=None, + add_prefix=False, + raw_headers=None, ): tool = MagicMock() tool.name = f"{server.alias}-toolA" if add_prefix else "toolA" @@ -1006,7 +1026,11 @@ async def test_list_tools_multiple_servers_prefixed_names(): ) async def mock_get_tools_from_server( - server, mcp_auth_header=None, extra_headers=None, add_prefix=True + server, + mcp_auth_header=None, + extra_headers=None, + add_prefix=True, + raw_headers=None, ): tool = MagicMock() # When multiple servers, add_prefix should be True -> prefixed names @@ -1147,7 +1171,11 @@ async def test_list_tools_filters_by_key_team_permissions(): mock_manager.get_mcp_server_by_id = lambda server_id: server async def mock_get_tools_from_server( - server, mcp_auth_header=None, extra_headers=None, add_prefix=False + server, + mcp_auth_header=None, + extra_headers=None, + add_prefix=False, + raw_headers=None, ): # Return 4 tools, but only 2 should be allowed tool1 = MagicMock() @@ -1248,7 +1276,11 @@ async def test_list_tools_with_team_tool_permissions_inheritance(): mock_manager.get_mcp_server_by_id = lambda server_id: server async def mock_get_tools_from_server( - server, mcp_auth_header=None, extra_headers=None, add_prefix=False + server, + mcp_auth_header=None, + extra_headers=None, + add_prefix=False, + raw_headers=None, ): # Return 4 tools tool1 = MagicMock() @@ -1334,7 +1366,11 @@ async def test_list_tools_with_no_tool_permissions_shows_all(): mock_manager.get_mcp_server_by_id = lambda server_id: server async def mock_get_tools_from_server( - server, mcp_auth_header=None, extra_headers=None, add_prefix=False + server, + mcp_auth_header=None, + extra_headers=None, + add_prefix=False, + raw_headers=None, ): # Return 3 tools tool1 = MagicMock() @@ -1423,7 +1459,11 @@ async def test_list_tools_strips_prefix_when_matching_permissions(): mock_manager.get_mcp_server_by_id = MagicMock(return_value=server) async def mock_get_tools_from_server( - server, mcp_auth_header=None, extra_headers=None, add_prefix=True + server, + mcp_auth_header=None, + extra_headers=None, + add_prefix=True, + raw_headers=None, ): # Return tools WITH prefix (as they come from MCP server) tool1 = MagicMock() diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index 7a6e5ad17f6..37a93441513 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -8,6 +8,7 @@ from fastapi import HTTPException # Add the parent directory to the path so we can import litellm sys.path.insert(0, "../../../../../") + import httpx from mcp import ReadResourceResult, Resource from mcp.types import ( @@ -99,6 +100,53 @@ class TestMCPServerManager: assert client.stdio_config["args"] == ["server.js"] assert client.stdio_config["env"] == {"NODE_ENV": "test"} + def test_build_stdio_env_only_accepts_x_prefixed_placeholders(self): + """Ensure only ${X-*} placeholders are substituted from headers.""" + manager = MCPServerManager() + server = MCPServer( + server_id="stdio-server-env", + name="stdio_env", + transport=MCPTransport.stdio, + command="node", + args=["server.js"], + env={ + "PASSTHROUGH": "${X-Test-Header}", + "STATIC": "value", + "IGNORED": "${Not-Allowed}", + }, + ) + + env = manager._build_stdio_env( + server, + raw_headers={ + "x-test-header": "resolved-value", + "x-not-used": "other", + }, + ) + + assert env == { + "PASSTHROUGH": "resolved-value", + "STATIC": "value", + "IGNORED": "${Not-Allowed}", + } + + def test_build_stdio_env_missing_header_skips_entry(self): + """Ensure missing headers drop the placeholder from the resolved env.""" + manager = MCPServerManager() + server = MCPServer( + server_id="stdio-server-env-miss", + name="stdio_env_miss", + transport=MCPTransport.stdio, + command="node", + args=["server.js"], + env={"EXPECTED": "${X-Missing}"}, + ) + + env = manager._build_stdio_env(server, raw_headers={}) + + # When the header isn't provided, the key is omitted entirely + assert env == {} + @pytest.mark.asyncio async def test_list_tools_with_server_specific_auth_headers(self): """Test list_tools method with server-specific auth headers""" @@ -123,7 +171,10 @@ class TestMCPServerManager: # Mock _get_tools_from_server to return different results async def mock_get_tools_from_server( - server, mcp_auth_header=None, mcp_protocol_version=None + server, + mcp_auth_header=None, + mcp_protocol_version=None, + raw_headers=None, ): if server.name == "github": tool1 = MagicMock() @@ -174,7 +225,10 @@ class TestMCPServerManager: # Mock _get_tools_from_server async def mock_get_tools_from_server( - server, mcp_auth_header=None, mcp_protocol_version=None + server, + mcp_auth_header=None, + mcp_protocol_version=None, + raw_headers=None, ): assert mcp_auth_header == "legacy-token" # Should use legacy header tool = MagicMock() @@ -209,7 +263,10 @@ class TestMCPServerManager: # Mock _get_tools_from_server async def mock_get_tools_from_server( - server, mcp_auth_header=None, mcp_protocol_version=None + server, + mcp_auth_header=None, + mcp_protocol_version=None, + raw_headers=None, ): assert ( mcp_auth_header == "server-specific-token" @@ -373,6 +430,7 @@ class TestMCPServerManager: server=server, mcp_auth_header="auth", extra_headers=None, + stdio_env=None, ) mock_client.list_resource_templates.assert_awaited_once() mock_prefix.assert_called_once_with(mock_templates, server, add_prefix=False) @@ -554,7 +612,10 @@ class TestMCPServerManager: # Mock _get_tools_from_server async def mock_get_tools_from_server( - server, mcp_auth_header=None, mcp_protocol_version=None + server, + mcp_auth_header=None, + mcp_protocol_version=None, + raw_headers=None, ): assert ( mcp_auth_header == "server-specific-token" @@ -580,33 +641,31 @@ class TestMCPServerManager: manager = MCPServerManager() # Mock server - server = MagicMock() - server.server_id = "test-server" - server.name = "test-server" + server = MCPServer( + server_id="test-server", + name="test-server", + transport=MCPTransport.http, + auth_type=None, + authentication_token="test-token", + url="http://test-server.com", + ) manager.get_mcp_server_by_id = MagicMock(return_value=server) - # Mock successful _get_tools_from_server - async def mock_get_tools_from_server(server, mcp_auth_header=None): - tool1 = MagicMock() - tool1.name = "tool1" - tool2 = MagicMock() - tool2.name = "tool2" - return [tool1, tool2] - - manager._get_tools_from_server = mock_get_tools_from_server + # Mock successful client.run_with_session + mock_client = AsyncMock() + mock_client.run_with_session = AsyncMock(return_value="ok") + manager._create_mcp_client = MagicMock(return_value=mock_client) # Perform health check result = await manager.health_check_server("test-server") - # Verify results - assert result["server_id"] == "test-server" - assert result["status"] == "healthy" - assert result["tools_count"] == 2 - assert result["error"] is None - assert "last_health_check" in result - assert "response_time_ms" in result - assert result["response_time_ms"] >= 0 # Allow 0 for very fast mocks + # Verify results - result is now LiteLLM_MCPServerTable + assert isinstance(result, LiteLLM_MCPServerTable) + assert result.server_id == "test-server" + assert result.status == "healthy" + assert result.health_check_error is None + assert result.last_health_check is not None @pytest.mark.asyncio async def test_health_check_server_unhealthy(self): @@ -614,28 +673,33 @@ class TestMCPServerManager: manager = MCPServerManager() # Mock server - server = MagicMock() - server.server_id = "test-server" - server.name = "test-server" + server = MCPServer( + server_id="test-server", + name="test-server", + transport=MCPTransport.http, + auth_type=None, + authentication_token="test-token", + url="http://test-server.com", + ) manager.get_mcp_server_by_id = MagicMock(return_value=server) - # Mock failed _get_tools_from_server - async def mock_get_tools_from_server(server, mcp_auth_header=None): - raise Exception("Connection timeout") - - manager._get_tools_from_server = mock_get_tools_from_server + # Mock failed client.run_with_session + mock_client = AsyncMock() + mock_client.run_with_session = AsyncMock( + side_effect=Exception("Connection timeout") + ) + manager._create_mcp_client = MagicMock(return_value=mock_client) # Perform health check result = await manager.health_check_server("test-server") # Verify results - assert result["server_id"] == "test-server" - assert result["status"] == "unhealthy" - assert result["error"] == "Connection timeout" - assert "last_health_check" in result - assert "response_time_ms" in result - assert result["response_time_ms"] >= 0 # Allow 0 for very fast mocks + assert isinstance(result, LiteLLM_MCPServerTable) + assert result.server_id == "test-server" + assert result.status == "unhealthy" + assert result.health_check_error == "Connection timeout" + assert result.last_health_check is not None @pytest.mark.asyncio async def test_health_check_server_not_found(self): @@ -649,96 +713,121 @@ class TestMCPServerManager: result = await manager.health_check_server("non-existent-server") # Verify results - assert result["server_id"] == "non-existent-server" - assert result["status"] == "unknown" - assert result["error"] == "Server not found" - assert result["response_time_ms"] is None - assert "last_health_check" in result + assert isinstance(result, LiteLLM_MCPServerTable) + assert result.server_id == "non-existent-server" + assert result.server_name is None + assert result.status == "unknown" + assert result.health_check_error == "Server not found" + assert result.last_health_check is not None @pytest.mark.asyncio - async def test_health_check_all_servers(self): - """Test health check for all servers""" + async def test_health_check_server_oauth2_skips_check(self): + """Test that health check is skipped for OAuth2 servers and returns unknown status""" manager = MCPServerManager() - # Mock servers - server1 = MagicMock() - server1.server_id = "server1" - server1.name = "server1" - - server2 = MagicMock() - server2.server_id = "server2" - server2.name = "server2" - - # Mock registry - manager.registry = {"server1": server1, "server2": server2} - - # Mock get_mcp_server_by_id - def mock_get_server_by_id(server_id): - if server_id == "server1": - return server1 - elif server_id == "server2": - return server2 - return None - - manager.get_mcp_server_by_id = mock_get_server_by_id - - # Mock _get_tools_from_server with different results - async def mock_get_tools_from_server(server, mcp_auth_header=None): - if server.server_id == "server1": - tool = MagicMock() - tool.name = "tool1" - return [tool] - elif server.server_id == "server2": - raise Exception("Connection failed") - return [] - - manager._get_tools_from_server = mock_get_tools_from_server - - # Perform health check for all servers - result = await manager.health_check_all_servers() - - # Verify results - assert len(result) == 2 - assert "server1" in result - assert "server2" in result - - # Check server1 (healthy) - assert result["server1"]["status"] == "healthy" - assert result["server1"]["tools_count"] == 1 - assert result["server1"]["error"] is None - - # Check server2 (unhealthy) - assert result["server2"]["status"] == "unhealthy" - assert result["server2"]["error"] == "Connection failed" - - @pytest.mark.asyncio - async def test_health_check_server_with_auth_header(self): - """Test health check with authentication header""" - manager = MCPServerManager() - - # Mock server - server = MagicMock() - server.server_id = "test-server" - server.name = "test-server" + # Mock OAuth2 server + server = MCPServer( + server_id="oauth2-server", + name="oauth2-server", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + url="http://oauth2-server.com", + ) manager.get_mcp_server_by_id = MagicMock(return_value=server) - # Mock _get_tools_from_server to verify auth header is passed - async def mock_get_tools_from_server(server, mcp_auth_header=None): - assert mcp_auth_header == "test-token" - tool = MagicMock() - tool.name = "tool1" - return [tool] + # _create_mcp_client should not be called for OAuth2 servers + manager._create_mcp_client = MagicMock() - manager._get_tools_from_server = mock_get_tools_from_server + # Perform health check + result = await manager.health_check_server("oauth2-server") - # Perform health check with auth header - result = await manager.health_check_server("test-server", "test-token") + # Verify that client was not created (health check was skipped) + manager._create_mcp_client.assert_not_called() # Verify results - assert result["server_id"] == "test-server" - assert result["status"] == "healthy" - assert result["tools_count"] == 1 + assert isinstance(result, LiteLLM_MCPServerTable) + assert result.server_id == "oauth2-server" + assert result.status == "unknown" + assert result.health_check_error is None + assert result.last_health_check is not None + + @pytest.mark.asyncio + async def test_health_check_server_no_token_skips_check(self): + """Test that health check is skipped when auth_type is set but authentication_token is missing""" + manager = MCPServerManager() + + # Mock server with auth_type but no authentication_token + server = MCPServer( + server_id="no-token-server", + name="no-token-server", + transport=MCPTransport.http, + auth_type=MCPAuth.bearer_token, + authentication_token=None, # No token + url="http://no-token-server.com", + ) + + manager.get_mcp_server_by_id = MagicMock(return_value=server) + + # _create_mcp_client should not be called + manager._create_mcp_client = MagicMock() + + # Perform health check + result = await manager.health_check_server("no-token-server") + + # Verify that client was not created (health check was skipped) + manager._create_mcp_client.assert_not_called() + + # Verify results + assert isinstance(result, LiteLLM_MCPServerTable) + assert result.server_id == "no-token-server" + assert result.status == "unknown" + assert result.health_check_error is None + assert result.last_health_check is not None + + @pytest.mark.asyncio + async def test_health_check_server_with_static_headers(self): + """Test health check with static headers configured""" + manager = MCPServerManager() + + # Mock server with static_headers + server = MCPServer( + server_id="test-server", + name="test-server", + transport=MCPTransport.http, + auth_type=None, + authentication_token="test-token", + url="http://test-server.com", + static_headers={"X-Custom-Header": "custom-value"}, + ) + + manager.get_mcp_server_by_id = MagicMock(return_value=server) + + # Mock successful client + mock_client = AsyncMock() + mock_client.run_with_session = AsyncMock(return_value="ok") + + # Capture the extra_headers passed to _create_mcp_client + captured_extra_headers = None + + def capture_create_mcp_client(server, mcp_auth_header, extra_headers, stdio_env): + nonlocal captured_extra_headers + captured_extra_headers = extra_headers + return mock_client + + manager._create_mcp_client = MagicMock(side_effect=capture_create_mcp_client) + + # Perform health check + result = await manager.health_check_server("test-server") + + # Verify static headers were passed + assert captured_extra_headers == {"X-Custom-Header": "custom-value"} + + # Verify results + assert isinstance(result, LiteLLM_MCPServerTable) + assert result.server_id == "test-server" + assert result.status == "healthy" + assert result.health_check_error is None @pytest.mark.asyncio async def test_pre_call_tool_check_allowed_tools_list_allows_tool(self): diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py index a0c09663a88..d9aba73de8f 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py @@ -7,6 +7,7 @@ from litellm.proxy._experimental.mcp_server import rest_endpoints from litellm.proxy._experimental.mcp_server.auth import ( user_api_key_auth_mcp as auth_mcp, ) +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy._types import NewMCPServerRequest, UserAPIKeyAuth from litellm.types.mcp import MCPAuth @@ -31,13 +32,73 @@ def _build_request(headers: Optional[Dict[str, str]] = None) -> Request: return Request(scope, receive=receive) +def _get_route(path: str, method: str): + for route in rest_endpoints.router.routes: + if getattr(route, "path", None) == path and method in getattr( + route, "methods", set() + ): + return route + raise AssertionError(f"Route {method} {path} not found") + + +def _route_has_dependency(route, dependency) -> bool: + if any( + getattr(dep, "dependency", None) == dependency + for dep in getattr(route, "dependencies", []) + ): + return True + dependant = getattr(route, "dependant", None) + if dependant is None: + return False + return any(getattr(dep, "call", None) == dependency for dep in dependant.dependencies) + + +@pytest.mark.asyncio +async def test_execute_with_mcp_client_redacts_stack_trace(monkeypatch): + def fake_create_client(*args, **kwargs): + return object() + + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "_create_mcp_client", + fake_create_client, + ) + + async def failing_operation(client): + raise RuntimeError("boom") + + payload = NewMCPServerRequest( + server_name="example", + url="https://example.com", + auth_type=MCPAuth.none, + ) + + result = await rest_endpoints._execute_with_mcp_client( + payload, failing_operation + ) + + assert result["status"] == "error" + assert "stack_trace" not in result + + +def test_test_connection_requires_auth_dependency(): + route = _get_route("/test/connection", "POST") + assert _route_has_dependency(route, user_api_key_auth) + + @pytest.mark.asyncio async def test_test_tools_list_forwards_mcp_auth_header(monkeypatch): """Ensure credential-based auth forwards the auth_value to the MCP client.""" captured: dict = {} - async def fake_execute(request, operation, mcp_auth_header=None, oauth2_headers=None): + async def fake_execute( + request, + operation, + mcp_auth_header=None, + oauth2_headers=None, + raw_headers=None, + ): captured["mcp_auth_header"] = mcp_auth_header captured["oauth2_headers"] = oauth2_headers return { @@ -87,7 +148,13 @@ async def test_test_tools_list_extracts_oauth2_headers(monkeypatch): captured: dict = {} - async def fake_execute(request, operation, mcp_auth_header=None, oauth2_headers=None): + async def fake_execute( + request, + operation, + mcp_auth_header=None, + oauth2_headers=None, + raw_headers=None, + ): captured["mcp_auth_header"] = mcp_auth_header captured["oauth2_headers"] = oauth2_headers return { diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index b4b7ddbd9ea..ef7f2f3c30d 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -181,6 +181,74 @@ def test_virtual_key_llm_api_routes_allows_google_routes(route): assert result is True +@pytest.mark.parametrize( + "route", + [ + "/v1beta/models/google-gemini-2-5-pro-code-reviewer-k8s:generateContent", + "/v1beta/models/gemini-2.5-flash-exp:countTokens", + "/v1beta/models/custom-model-name-123:streamGenerateContent", + "/models/google-gemini-2-5-pro-code-reviewer-k8s:generateContent", + "/models/gemini-2.5-flash-exp:countTokens", + "/models/custom-model-name-123:streamGenerateContent", + ], +) +def test_google_routes_with_dynamic_model_names_recognized_as_llm_api_route(route): + """ + Test that Google routes with dynamic model names (including custom names) are recognized as LLM API routes. + + This test verifies the fix for the issue where routes like: + /v1beta/models/google-gemini-2-5-pro-code-reviewer-k8s:generateContent + were incorrectly classified as "custom admin only route" instead of LLM API routes. + + The fix adds pattern matching for Google routes with placeholders like {model_name}. + """ + + # Test that the route is recognized as an LLM API route + assert RouteChecks.is_llm_api_route(route) is True + + +def test_google_routes_with_dynamic_model_names_accessible_to_internal_users(): + """ + Test that internal users can access Google routes with dynamic model names. + + This ensures that routes like /v1beta/models/{model_name}:generateContent + are properly accessible to internal users and not blocked as admin-only routes. + """ + + # Create an internal user object + user_obj = LiteLLM_UserTable( + user_id="test_user", + user_email="test@example.com", + user_role=LitellmUserRoles.INTERNAL_USER.value, + ) + + # Create an internal user API key auth + valid_token = UserAPIKeyAuth( + user_id="test_user", + user_role=LitellmUserRoles.INTERNAL_USER.value, + ) + + # Create a mock request + request = MagicMock(spec=Request) + request.query_params = {} + + # Test that calling Google route with dynamic model name does NOT raise an exception + try: + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.INTERNAL_USER.value, + route="/v1beta/models/google-gemini-2-5-pro-code-reviewer-k8s:generateContent", + request=request, + valid_token=valid_token, + request_data={"contents": [{"parts": [{"text": "test"}]}]}, + ) + # If no exception is raised, the test passes + except Exception as e: + pytest.fail( + f"Internal user should be able to access Google generateContent route. Got error: {str(e)}" + ) + + def test_virtual_key_allowed_routes_with_multiple_litellm_routes_member_names(): """Test that virtual key works with multiple LiteLLMRoutes member names in allowed_routes""" diff --git a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py index 04aeddb8f28..fcc8c1f0f2e 100644 --- a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py +++ b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py @@ -278,8 +278,8 @@ async def test_proxy_admin_expired_key_from_cache(): mock_proxy_logging_obj.internal_usage_cache = MagicMock() mock_proxy_logging_obj.internal_usage_cache.dual_cache = AsyncMock() mock_proxy_logging_obj.internal_usage_cache.dual_cache.async_delete_cache = AsyncMock() - # Mock post_call_failure_hook as async function - mock_proxy_logging_obj.post_call_failure_hook = AsyncMock() + # Mock post_call_failure_hook as async function returning None (no transformation) + mock_proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None) # Mock prisma_client mock_prisma_client = MagicMock() diff --git a/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py b/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py index 2361decc5af..324a58acfa9 100644 --- a/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py @@ -24,6 +24,7 @@ from litellm.proxy.common_utils.http_parsing_utils import ( get_form_data, get_request_body, get_tags_from_request_body, + populate_request_with_path_params, ) @@ -630,3 +631,69 @@ def test_get_tags_from_request_body_with_null_metadata(): assert result == [] assert isinstance(result, list) + + +def test_populate_request_with_path_params_adds_query_params(): + """ + Test that populate_request_with_path_params correctly adds query parameters + like organization_id to the request data. + """ + # Create a mock request with query parameters + mock_request = MagicMock() + # Mock query_params as a dict-like object that can be converted to dict + mock_request.query_params = { + "organization_id": "org-123", + "user_id": "user-456" + } + mock_request.path_params = {} + # Mock url.path to avoid errors in _add_vector_store_id_from_path + mock_request.url.path = "/v1/chat/completions" + + # Initial request data without query params + request_data = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "Hello"}] + } + + # Call the function + result = populate_request_with_path_params(request_data, mock_request) + + # Verify query params were added + assert result["organization_id"] == "org-123" + assert result["user_id"] == "user-456" + # Verify original data is preserved + assert result["model"] == "gpt-4" + assert result["messages"] == [{"role": "user", "content": "Hello"}] + + +def test_populate_request_with_path_params_does_not_overwrite_existing_values(): + """ + Test that populate_request_with_path_params does not overwrite existing values + in request_data when query params contain the same keys. + """ + # Create a mock request with query parameters + mock_request = MagicMock() + # Mock query_params as a dict-like object that can be converted to dict + mock_request.query_params = { + "organization_id": "org-query-param", + "model": "gpt-3.5-turbo" + } + mock_request.path_params = {} + # Mock url.path to avoid errors in _add_vector_store_id_from_path + mock_request.url.path = "/v1/chat/completions" + + # Initial request data with existing values + request_data = { + "model": "gpt-4", # This should NOT be overwritten + "organization_id": "org-existing", # This should NOT be overwritten + "messages": [{"role": "user", "content": "Hello"}] + } + + # Call the function + result = populate_request_with_path_params(request_data, mock_request) + + # Verify existing values were NOT overwritten + assert result["model"] == "gpt-4" # Should keep original, not "gpt-3.5-turbo" + assert result["organization_id"] == "org-existing" # Should keep original, not "org-query-param" + # Verify other data is preserved + assert result["messages"] == [{"role": "user", "content": "Hello"}] diff --git a/tests/test_litellm/proxy/discovery_endpoints/test_ui_discovery_endpoints.py b/tests/test_litellm/proxy/discovery_endpoints/test_ui_discovery_endpoints.py index 599d5437589..88d31e993dd 100644 --- a/tests/test_litellm/proxy/discovery_endpoints/test_ui_discovery_endpoints.py +++ b/tests/test_litellm/proxy/discovery_endpoints/test_ui_discovery_endpoints.py @@ -21,7 +21,7 @@ def test_ui_discovery_endpoints_with_defaults(): with patch("litellm.proxy.utils.get_server_root_path", return_value="/"), \ patch("litellm.proxy.utils.get_proxy_base_url", return_value=None), \ patch("litellm.proxy.auth.auth_utils._has_user_setup_sso", return_value=False), \ - patch.dict(os.environ, {}, clear=False): + patch.dict(os.environ, {"DISABLE_ADMIN_UI": "false"}, clear=False): response = client.get("/.well-known/litellm-ui-config") @@ -30,6 +30,7 @@ def test_ui_discovery_endpoints_with_defaults(): assert data["server_root_path"] == "/" assert data["proxy_base_url"] is None assert data["auto_redirect_to_sso"] is False + assert data["admin_ui_disabled"] is False def test_ui_discovery_endpoints_with_custom_server_root_path(): @@ -40,7 +41,7 @@ def test_ui_discovery_endpoints_with_custom_server_root_path(): with patch("litellm.proxy.utils.get_server_root_path", return_value="/litellm"), \ patch("litellm.proxy.utils.get_proxy_base_url", return_value=None), \ patch("litellm.proxy.auth.auth_utils._has_user_setup_sso", return_value=False), \ - patch.dict(os.environ, {}, clear=False): + patch.dict(os.environ, {"DISABLE_ADMIN_UI": "false"}, clear=False): response = client.get("/.well-known/litellm-ui-config") @@ -59,7 +60,7 @@ def test_ui_discovery_endpoints_with_proxy_base_url_when_set(): with patch("litellm.proxy.utils.get_server_root_path", return_value="/"), \ patch("litellm.proxy.utils.get_proxy_base_url", return_value="https://proxy.example.com"), \ patch("litellm.proxy.auth.auth_utils._has_user_setup_sso", return_value=False), \ - patch.dict(os.environ, {}, clear=False): + patch.dict(os.environ, {"DISABLE_ADMIN_UI": "false"}, clear=False): response = client.get("/litellm/.well-known/litellm-ui-config") @@ -78,7 +79,7 @@ def test_ui_discovery_endpoints_with_sso_configured_and_auto_redirect_enabled(): with patch("litellm.proxy.utils.get_server_root_path", return_value="/litellm"), \ patch("litellm.proxy.utils.get_proxy_base_url", return_value="https://proxy.example.com"), \ patch("litellm.proxy.auth.auth_utils._has_user_setup_sso", return_value=True), \ - patch.dict(os.environ, {"AUTO_REDIRECT_UI_LOGIN_TO_SSO": "true"}, clear=False): + patch.dict(os.environ, {"AUTO_REDIRECT_UI_LOGIN_TO_SSO": "true", "DISABLE_ADMIN_UI": "false"}, clear=False): response = client.get("/.well-known/litellm-ui-config") @@ -97,7 +98,7 @@ def test_ui_discovery_endpoints_with_sso_configured_but_auto_redirect_disabled() with patch("litellm.proxy.utils.get_server_root_path", return_value="/litellm"), \ patch("litellm.proxy.utils.get_proxy_base_url", return_value="https://proxy.example.com"), \ patch("litellm.proxy.auth.auth_utils._has_user_setup_sso", return_value=True), \ - patch.dict(os.environ, {"AUTO_REDIRECT_UI_LOGIN_TO_SSO": "false"}, clear=False): + patch.dict(os.environ, {"AUTO_REDIRECT_UI_LOGIN_TO_SSO": "false", "DISABLE_ADMIN_UI": "false"}, clear=False): response = client.get("/.well-known/litellm-ui-config") @@ -116,7 +117,7 @@ def test_ui_discovery_endpoints_with_sso_not_configured_but_auto_redirect_enable with patch("litellm.proxy.utils.get_server_root_path", return_value="/"), \ patch("litellm.proxy.utils.get_proxy_base_url", return_value=None), \ patch("litellm.proxy.auth.auth_utils._has_user_setup_sso", return_value=False), \ - patch.dict(os.environ, {"AUTO_REDIRECT_UI_LOGIN_TO_SSO": "true"}, clear=False): + patch.dict(os.environ, {"AUTO_REDIRECT_UI_LOGIN_TO_SSO": "true", "DISABLE_ADMIN_UI": "false"}, clear=False): response = client.get("/.well-known/litellm-ui-config") @@ -135,7 +136,7 @@ def test_ui_discovery_endpoints_both_routes_return_same_data(): with patch("litellm.proxy.utils.get_server_root_path", return_value="/litellm"), \ patch("litellm.proxy.utils.get_proxy_base_url", return_value="https://proxy.example.com"), \ patch("litellm.proxy.auth.auth_utils._has_user_setup_sso", return_value=True), \ - patch.dict(os.environ, {"AUTO_REDIRECT_UI_LOGIN_TO_SSO": "true"}, clear=False): + patch.dict(os.environ, {"AUTO_REDIRECT_UI_LOGIN_TO_SSO": "true", "DISABLE_ADMIN_UI": "false"}, clear=False): response1 = client.get("/.well-known/litellm-ui-config") response2 = client.get("/litellm/.well-known/litellm-ui-config") @@ -144,3 +145,43 @@ def test_ui_discovery_endpoints_both_routes_return_same_data(): assert response2.status_code == 200 assert response1.json() == response2.json() + +def test_ui_discovery_endpoints_with_admin_ui_disabled(): + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + with patch("litellm.proxy.utils.get_server_root_path", return_value="/"), \ + patch("litellm.proxy.utils.get_proxy_base_url", return_value=None), \ + patch("litellm.proxy.auth.auth_utils._has_user_setup_sso", return_value=False), \ + patch.dict(os.environ, {"DISABLE_ADMIN_UI": "true"}, clear=False): + + response = client.get("/.well-known/litellm-ui-config") + + assert response.status_code == 200 + data = response.json() + assert data["server_root_path"] == "/" + assert data["proxy_base_url"] is None + assert data["auto_redirect_to_sso"] is False + assert data["admin_ui_disabled"] is True + + +def test_ui_discovery_endpoints_with_admin_ui_enabled(): + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + with patch("litellm.proxy.utils.get_server_root_path", return_value="/"), \ + patch("litellm.proxy.utils.get_proxy_base_url", return_value=None), \ + patch("litellm.proxy.auth.auth_utils._has_user_setup_sso", return_value=False), \ + patch.dict(os.environ, {"DISABLE_ADMIN_UI": "false"}, clear=False): + + response = client.get("/.well-known/litellm-ui-config") + + assert response.status_code == 200 + data = response.json() + assert data["server_root_path"] == "/" + assert data["proxy_base_url"] is None + assert data["auto_redirect_to_sso"] is False + assert data["admin_ui_disabled"] is False + diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py index eeae0ece02c..f3de89d6d6c 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py @@ -43,8 +43,8 @@ def mock_user_api_key_dict(): team_id="test-team", team_alias=None, user_role=None, - api_key="88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b", - token="88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b", + api_key="a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456", + token="a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456", permissions={}, models=[], spend=0.0, @@ -71,7 +71,7 @@ def mock_request_data_input(): ], "litellm_call_id": "test-call-id", "metadata": { - "user_api_key_hash": "88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b", + "user_api_key_hash": "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456", "user_api_key_user_id": "default_user_id", "user_api_key_user_email": "test@example.com", "user_api_key_team_id": "test-team", @@ -197,7 +197,7 @@ class TestMetadataExtraction: # Verify metadata was extracted from request_data["metadata"] assert ( request_metadata["user_api_key_hash"] - == "88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b" + == "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456" ) assert request_metadata["user_api_key_user_id"] == "default_user_id" assert request_metadata["user_api_key_user_email"] == "test@example.com" diff --git a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py index 23b3b0287ee..edfdd9e4065 100644 --- a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py +++ b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py @@ -2,7 +2,8 @@ import os import sys import time from datetime import datetime, timedelta -from unittest.mock import MagicMock, patch, AsyncMock +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch sys.path.insert( 0, os.path.abspath("../../..") @@ -10,10 +11,14 @@ sys.path.insert( import pytest from prisma.errors import ClientNotConnectedError, HTTPClientClosedError, PrismaError + from litellm.proxy.health_endpoints._health_endpoints import ( _db_health_readiness_check, db_health_cache, + health_license_endpoint, health_services_endpoint, +) +from litellm.proxy.health_endpoints._health_endpoints import ( test_model_connection as health_test_model_connection, ) @@ -128,6 +133,68 @@ async def test_health_services_endpoint_sqs(status, error_message): mock_instance.async_health_check.assert_awaited_once() +@pytest.mark.asyncio +async def test_health_license_endpoint_with_active_license(): + license_data = { + "expiration_date": "2099-01-01", + "allowed_features": ["feature-a"], + "max_users": 100, + "max_teams": 5, + } + mock_license_check = SimpleNamespace( + license_str="test-license", + public_key=None, + airgapped_license_data=license_data, + verify_license_without_api_request=MagicMock(return_value=True), + ) + + with patch( + "litellm.proxy.proxy_server._license_check", + mock_license_check, + ), patch( + "litellm.proxy.proxy_server.premium_user", + True, + ), patch( + "litellm.proxy.proxy_server.premium_user_data", + license_data, + ): + response = await health_license_endpoint(user_api_key_dict=MagicMock()) + + assert response["has_license"] is True + assert response["license_type"] == "enterprise" + assert response["expiration_date"] == "2099-01-01" + assert response["allowed_features"] == ["feature-a"] + assert response["limits"] == {"max_users": 100, "max_teams": 5} + + +@pytest.mark.asyncio +async def test_health_license_endpoint_without_valid_license(): + mock_license_check = SimpleNamespace( + license_str="invalid-key", + public_key=None, + airgapped_license_data=None, + verify_license_without_api_request=MagicMock(return_value=False), + ) + + with patch( + "litellm.proxy.proxy_server._license_check", + mock_license_check, + ), patch( + "litellm.proxy.proxy_server.premium_user", + False, + ), patch( + "litellm.proxy.proxy_server.premium_user_data", + None, + ): + response = await health_license_endpoint(user_api_key_dict=MagicMock()) + + assert response["has_license"] is True + assert response["license_type"] == "community" + assert response["expiration_date"] is None + assert response["allowed_features"] == [] + assert response["limits"] == {"max_users": None, "max_teams": None} + + @pytest.mark.asyncio async def test_test_model_connection_loads_config_from_router(): """ @@ -374,4 +441,3 @@ def test_health_readiness(proxy_client): f"Unexpected db status: {db_status}" print("="*60 + "\n") - diff --git a/tests/test_litellm/proxy/hooks/test_post_call_failure_hook_integration.py b/tests/test_litellm/proxy/hooks/test_post_call_failure_hook_integration.py new file mode 100644 index 00000000000..7223c2e1f02 --- /dev/null +++ b/tests/test_litellm/proxy/hooks/test_post_call_failure_hook_integration.py @@ -0,0 +1,146 @@ +""" +Integration tests for async_post_call_failure_hook. + +Tests verify that the failure hook can transform error responses sent to clients, +similar to how async_post_call_success_hook can transform successful responses. +""" + +import os +import sys +import pytest +from typing import Optional +from unittest.mock import patch + +sys.path.insert(0, os.path.abspath("../../../..")) + +from fastapi import HTTPException +from litellm.integrations.custom_logger import CustomLogger +from litellm.proxy._types import UserAPIKeyAuth + + +class ErrorTransformerLogger(CustomLogger): + """Logger that transforms errors into user-friendly messages""" + + def __init__(self): + self.called = False + self.transformed_exception = None + + async def async_post_call_failure_hook( + self, + request_data: dict, + original_exception: Exception, + user_api_key_dict: UserAPIKeyAuth, + traceback_str: Optional[str] = None, + ): + self.called = True + self.transformed_exception = HTTPException( + status_code=400, + detail="User-friendly error: Your request could not be processed." + ) + return self.transformed_exception + + +@pytest.mark.asyncio +async def test_failure_hook_transforms_error_response(): + """ + Test that async_post_call_failure_hook can transform error responses. + This mirrors how async_post_call_success_hook can transform successful responses. + """ + transformer = ErrorTransformerLogger() + + # Mock litellm.callbacks to include our transformer + with patch("litellm.callbacks", [transformer]): + from litellm.proxy.utils import ProxyLogging + from litellm.caching.caching import DualCache + + proxy_logging = ProxyLogging(user_api_key_cache=DualCache()) + original_exception = Exception("Technical error message") + request_data = {"model": "test-model"} + user_api_key_dict = UserAPIKeyAuth(api_key="test-key") + + # Call the hook + result = await proxy_logging.post_call_failure_hook( + request_data=request_data, + original_exception=original_exception, + user_api_key_dict=user_api_key_dict, + ) + + # Verify hook was called + assert transformer.called is True + + # Verify transformed exception is returned + assert result is not None + assert isinstance(result, HTTPException) + assert result.detail == "User-friendly error: Your request could not be processed." + + +@pytest.mark.asyncio +async def test_failure_hook_returns_none_when_no_transformation(): + """ + Test that hook returning None uses original exception. + """ + class NoOpLogger(CustomLogger): + def __init__(self): + self.called = False + + async def async_post_call_failure_hook(self, *args, **kwargs): + self.called = True + return None + + logger = NoOpLogger() + + with patch("litellm.callbacks", [logger]): + from litellm.proxy.utils import ProxyLogging + from litellm.caching.caching import DualCache + + proxy_logging = ProxyLogging(user_api_key_cache=DualCache()) + original_exception = Exception("Original error") + request_data = {"model": "test"} + user_api_key_dict = UserAPIKeyAuth(api_key="test") + + result = await proxy_logging.post_call_failure_hook( + request_data=request_data, + original_exception=original_exception, + user_api_key_dict=user_api_key_dict, + ) + + # Should return None (original exception will be used) + assert result is None + assert logger.called is True + + +@pytest.mark.asyncio +async def test_failure_hook_handles_exceptions_gracefully(): + """ + Test that hook failures don't break the error flow. + """ + class FailingLogger(CustomLogger): + def __init__(self): + self.called = False + + async def async_post_call_failure_hook(self, *args, **kwargs): + self.called = True + raise RuntimeError("Hook crashed!") + + logger = FailingLogger() + + with patch("litellm.callbacks", [logger]): + from litellm.proxy.utils import ProxyLogging + from litellm.caching.caching import DualCache + + proxy_logging = ProxyLogging(user_api_key_cache=DualCache()) + original_exception = Exception("Original error") + request_data = {"model": "test"} + user_api_key_dict = UserAPIKeyAuth(api_key="test") + + # Should not raise, should handle gracefully + result = await proxy_logging.post_call_failure_hook( + request_data=request_data, + original_exception=original_exception, + user_api_key_dict=user_api_key_dict, + ) + + # Should return None (original exception will be used) + assert result is None + assert logger.called is True + diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index ff85e6d9e73..cba06e7fb3f 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -815,6 +815,37 @@ async def test_update_service_account_works_with_team_id(): await prepare_key_update_data(data=data, existing_key_row=existing_key) +@pytest.mark.asyncio +async def test_prepare_key_update_data_duration_never_expires(): + """Test that duration="-1" sets expires to None (never expires).""" + from litellm.proxy._types import UpdateKeyRequest + from litellm.proxy.management_endpoints.key_management_endpoints import ( + prepare_key_update_data, + ) + + # Mock existing key + existing_key = LiteLLM_VerificationToken( + token="test-token", + key_alias="test-key", + models=["gpt-3.5-turbo"], + user_id="test-user", + team_id=None, + auto_rotate=False, + rotation_interval=None, + metadata={}, + ) + + # Test setting duration to "-1" (never expires) + update_request = UpdateKeyRequest(key="test-token", duration="-1") + + result = await prepare_key_update_data( + data=update_request, existing_key_row=existing_key + ) + + # Verify that expires is set to None + assert result["expires"] is None + + @pytest.mark.asyncio async def test_validate_team_id_used_in_service_account_request_requires_team_id(): """ @@ -3374,3 +3405,115 @@ async def test_can_modify_verification_token_personal_key_no_user_id(monkeypatch ) assert result is False + + +@pytest.mark.asyncio +async def test_list_keys_with_expand_user(): + """ + Test that expand=user parameter correctly includes user information in the response. + """ + mock_prisma_client = AsyncMock() + + # Create mock keys with user_ids + mock_key1 = MagicMock() + mock_key1.token = "token1" + mock_key1.user_id = "user123" + mock_key1.dict.return_value = { + "token": "token1", + "user_id": "user123", + "key_alias": "key1", + "models": ["gpt-4"], + } + + mock_key2 = MagicMock() + mock_key2.token = "token2" + mock_key2.user_id = "user456" + mock_key2.dict.return_value = { + "token": "token2", + "user_id": "user456", + "key_alias": "key2", + "models": ["gpt-3.5-turbo"], + } + + mock_find_many_keys = AsyncMock(return_value=[mock_key1, mock_key2]) + mock_count_keys = AsyncMock(return_value=2) + + # Create mock users + mock_user1 = MagicMock() + mock_user1.user_id = "user123" + mock_user1.user_email = "user1@example.com" + mock_user1.dict.return_value = { + "user_id": "user123", + "user_email": "user1@example.com", + "user_alias": "User One", + } + + mock_user2 = MagicMock() + mock_user2.user_id = "user456" + mock_user2.user_email = "user2@example.com" + mock_user2.dict.return_value = { + "user_id": "user456", + "user_email": "user2@example.com", + "user_alias": "User Two", + } + + mock_find_many_users = AsyncMock(return_value=[mock_user1, mock_user2]) + + mock_prisma_client.db.litellm_verificationtoken.find_many = mock_find_many_keys + mock_prisma_client.db.litellm_verificationtoken.count = mock_count_keys + mock_prisma_client.db.litellm_usertable.find_many = mock_find_many_users + + args = { + "prisma_client": mock_prisma_client, + "page": 1, + "size": 50, + "user_id": None, + "team_id": None, + "organization_id": None, + "key_alias": None, + "key_hash": None, + "exclude_team_id": None, + "return_full_object": False, # This should be overridden by expand=user + "admin_team_ids": None, + "include_created_by_keys": False, + "expand": ["user"], # Test the expand parameter + } + + result = await _list_key_helper(**args) + + # Verify that keys were fetched + mock_find_many_keys.assert_called_once() + mock_count_keys.assert_called_once() + + # Verify that users were fetched + # Note: Order doesn't matter for the 'in' query, so we just check that both user_ids are present + call_args = mock_find_many_users.call_args + assert call_args is not None + where_clause = call_args.kwargs["where"] + assert "user_id" in where_clause + assert "in" in where_clause["user_id"] + user_ids_in_query = set(where_clause["user_id"]["in"]) + assert user_ids_in_query == {"user123", "user456"} + + # Verify response structure + assert len(result["keys"]) == 2 + assert result["total_count"] == 2 + assert result["current_page"] == 1 + assert result["total_pages"] == 1 + + # Verify that user data is included in the response + # Since expand=user is specified, keys should be full objects + assert isinstance(result["keys"][0], UserAPIKeyAuth) + assert isinstance(result["keys"][1], UserAPIKeyAuth) + + # Verify user data is attached to keys + assert result["keys"][0].user == { + "user_id": "user123", + "user_email": "user1@example.com", + "user_alias": "User One", + } + assert result["keys"][1].user == { + "user_id": "user456", + "user_email": "user2@example.com", + "user_alias": "User Two", + } diff --git a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py index 61342e8025b..cd1a1f5e10d 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py @@ -169,8 +169,8 @@ class TestListMCPServers: return_value=["config_server_1", "config_server_2"] ) - # Mock the new method that returns servers with health and team data - mock_servers_with_health = [ + # Mock the new method that returns servers without health check + mock_servers = [ generate_mock_mcp_server_db_record( server_id="config_server_1", alias="Zapier MCP", @@ -184,11 +184,11 @@ class TestListMCPServers: transport="http", ), ] - mock_manager.get_all_mcp_servers_with_health_and_teams = AsyncMock( - return_value=mock_servers_with_health + mock_manager.get_all_allowed_mcp_servers = AsyncMock( + return_value=mock_servers ) - for idx, server in enumerate(mock_servers_with_health): + for idx, server in enumerate(mock_servers): server.credentials = {"auth_value": f"secret_{idx}"} with patch( @@ -200,6 +200,9 @@ class TestListMCPServers: ), patch( "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", return_value=mock_prisma_client, + ), patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.build_effective_auth_contexts", + AsyncMock(return_value=[mock_user_auth]), ): # Import and call the function from litellm.proxy.management_endpoints.mcp_management_endpoints import ( @@ -300,8 +303,8 @@ class TestListMCPServers: ] ) - # Mock the new method that returns servers with health and team data - mock_servers_with_health = [ + # Mock the new method that returns servers without health check + mock_servers = [ db_server_1, db_server_2, generate_mock_mcp_server_db_record( @@ -317,11 +320,11 @@ class TestListMCPServers: transport="http", ), ] - mock_manager.get_all_mcp_servers_with_health_and_teams = AsyncMock( - return_value=mock_servers_with_health + mock_manager.get_all_allowed_mcp_servers = AsyncMock( + return_value=mock_servers ) - for idx, server in enumerate(mock_servers_with_health): + for idx, server in enumerate(mock_servers): server.credentials = {"auth_value": f"secret_{idx}"} with patch( @@ -333,6 +336,9 @@ class TestListMCPServers: ), patch( "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", return_value=mock_prisma_client, + ), patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.build_effective_auth_contexts", + AsyncMock(return_value=[mock_user_auth]), ): # Import and call the function from litellm.proxy.management_endpoints.mcp_management_endpoints import ( @@ -425,8 +431,8 @@ class TestListMCPServers: return_value=["db_server_allowed", "config_server_allowed"] ) - # Mock the new method that returns servers with health and team data - mock_servers_with_health = [ + # Mock the new method that returns servers without health check + mock_servers = [ db_server_allowed, generate_mock_mcp_server_db_record( server_id="config_server_allowed", @@ -434,11 +440,11 @@ class TestListMCPServers: url="https://actions.zapier.com/mcp/sse", ), ] - mock_manager.get_all_mcp_servers_with_health_and_teams = AsyncMock( - return_value=mock_servers_with_health + mock_manager.get_all_allowed_mcp_servers = AsyncMock( + return_value=mock_servers ) - for idx, server in enumerate(mock_servers_with_health): + for idx, server in enumerate(mock_servers): server.credentials = {"auth_value": f"secret_{idx}"} with patch( @@ -450,6 +456,9 @@ class TestListMCPServers: ), patch( "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", return_value=mock_prisma_client, + ), patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.build_effective_auth_contexts", + AsyncMock(return_value=[mock_user_auth]), ): # Import and call the function from litellm.proxy.management_endpoints.mcp_management_endpoints import ( @@ -486,11 +495,14 @@ class TestListMCPServers: mock_server.credentials = {"auth_value": "top-secret"} mock_prisma_client = MagicMock() - mock_health_result = { - "status": "healthy", - "last_health_check": datetime.now().isoformat(), - "error": None, - } + + # Mock health check result as LiteLLM_MCPServerTable + mock_health_result = generate_mock_mcp_server_db_record( + server_id="server-1", alias="Server 1" + ) + mock_health_result.status = "healthy" + mock_health_result.last_health_check = datetime.now() + mock_health_result.health_check_error = None mock_user_auth = generate_mock_user_api_key_auth( user_role=LitellmUserRoles.PROXY_ADMIN @@ -531,11 +543,14 @@ class TestListMCPServers: delattr(mock_server, "credentials") mock_prisma_client = MagicMock() - mock_health_result = { - "status": "healthy", - "last_health_check": datetime.now().isoformat(), - "error": None, - } + + # Mock health check result as LiteLLM_MCPServerTable + mock_health_result = generate_mock_mcp_server_db_record( + server_id="server-2", alias="Server 2" + ) + mock_health_result.status = "healthy" + mock_health_result.last_health_check = datetime.now() + mock_health_result.health_check_error = None mock_user_auth = generate_mock_user_api_key_auth( user_role=LitellmUserRoles.PROXY_ADMIN @@ -568,296 +583,6 @@ class TestListMCPServers: assert result.status == "healthy" -class TestMCPHealthCheckEndpoints: - """Test MCP health check endpoints""" - - @pytest.mark.asyncio - async def test_health_check_mcp_server_success(self): - """Test successful health check for a specific MCP server""" - # Mock server - mock_server = generate_mock_mcp_server_db_record( - server_id="test-server", alias="Test Server" - ) - - # Mock dependencies - mock_prisma_client = MagicMock() - - # Mock global MCP server manager - mock_manager = MagicMock() - mock_manager.health_check_server = AsyncMock( - return_value={ - "server_id": "test-server", - "server_name": "Test Server", - "status": "healthy", - "tools_count": 3, - "last_health_check": "2024-01-01T12:00:00", - "response_time_ms": 150.5, - "error": None, - } - ) - - mock_user_auth = generate_mock_user_api_key_auth( - user_role=LitellmUserRoles.PROXY_ADMIN - ) - - with patch( - "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", - return_value=mock_prisma_client, - ), patch( - "litellm.proxy.management_endpoints.mcp_management_endpoints._user_has_admin_view", - return_value=True, - ), patch( - "litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager", - mock_manager, - ), patch( - "litellm.proxy.management_endpoints.mcp_management_endpoints.get_mcp_server", - AsyncMock(return_value=mock_server), - ): - # Import and call the function - from litellm.proxy.management_endpoints.mcp_management_endpoints import ( - health_check_mcp_server, - ) - - result = await health_check_mcp_server( - server_id="test-server", user_api_key_dict=mock_user_auth - ) - - # Verify results - assert result["server_id"] == "test-server" - assert result["server_name"] == "Test Server" - assert result["status"] == "healthy" - assert result["tools_count"] == 3 - assert result["response_time_ms"] == 150.5 - assert result["error"] is None - - @pytest.mark.asyncio - async def test_health_check_mcp_server_not_found(self): - """Test health check for a server that doesn't exist""" - # Mock dependencies - mock_prisma_client = MagicMock() - - mock_user_auth = generate_mock_user_api_key_auth( - user_role=LitellmUserRoles.PROXY_ADMIN - ) - - with patch( - "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", - return_value=mock_prisma_client, - ), patch( - "litellm.proxy.management_endpoints.mcp_management_endpoints.get_mcp_server", - AsyncMock(return_value=None), - ): - # Import and call the function - from litellm.proxy.management_endpoints.mcp_management_endpoints import ( - health_check_mcp_server, - ) - - # Should raise HTTPException - with pytest.raises(Exception) as exc_info: - await health_check_mcp_server( - server_id="non-existent-server", user_api_key_dict=mock_user_auth - ) - - assert "not found" in str(exc_info.value) - - @pytest.mark.asyncio - async def test_health_check_mcp_server_unauthorized(self): - """Test health check for a server user doesn't have access to""" - # Mock server - mock_server = generate_mock_mcp_server_db_record( - server_id="test-server", alias="Test Server" - ) - - # Mock dependencies - mock_prisma_client = MagicMock() - - mock_user_auth = generate_mock_user_api_key_auth( - user_role=LitellmUserRoles.INTERNAL_USER # Non-admin user - ) - - # Mock user doesn't have access to this server - mock_user_servers = [] - - with patch( - "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", - return_value=mock_prisma_client, - ), patch( - "litellm.proxy.management_endpoints.mcp_management_endpoints._user_has_admin_view", - return_value=False, - ), patch( - "litellm.proxy.management_endpoints.mcp_management_endpoints.get_all_mcp_servers_for_user", - return_value=mock_user_servers, - ), patch( - "litellm.proxy.management_endpoints.mcp_management_endpoints.get_mcp_server", - AsyncMock(return_value=mock_server), - ): - # Import and call the function - from litellm.proxy.management_endpoints.mcp_management_endpoints import ( - health_check_mcp_server, - ) - - # Should raise HTTPException - with pytest.raises(Exception) as exc_info: - await health_check_mcp_server( - server_id="test-server", user_api_key_dict=mock_user_auth - ) - - assert "permission" in str(exc_info.value) - - @pytest.mark.asyncio - async def test_health_check_all_mcp_servers(self): - """Test health check for all accessible MCP servers""" - # Mock team records - team_records = [ - generate_mock_team_record( - team_id="team1", - team_alias="Team 1", - organization_id="org1", - mcp_servers=["server1", "server2"], - ) - ] - - # Mock DB servers - db_servers = [ - generate_mock_mcp_server_db_record(server_id="server1"), - generate_mock_mcp_server_db_record(server_id="server2"), - ] - - # Mock dependencies - mock_prisma_client = MagicMock() - mock_prisma_client = setup_mock_prisma_client( - mock_prisma_client=mock_prisma_client, - team_records=team_records, - mcp_servers=db_servers, - ) - - # Mock global MCP server manager - mock_manager = MagicMock() - mock_manager.health_check_allowed_servers = AsyncMock( - return_value={ - "server1": { - "server_id": "server1", - "server_name": "Test DB Server", - "status": "healthy", - "tools_count": 2, - "last_health_check": "2024-01-01T12:00:00", - "response_time_ms": 100.0, - "error": None, - }, - "server2": { - "server_id": "server2", - "server_name": "Test DB Server", - "status": "unhealthy", - "last_health_check": "2024-01-01T12:00:00", - "response_time_ms": 5000.0, - "error": "Connection timeout", - }, - } - ) - mock_manager.get_allowed_mcp_servers = AsyncMock( - return_value=["server1", "server2"] - ) - - mock_user_auth = generate_mock_user_api_key_auth( - user_role=LitellmUserRoles.INTERNAL_USER - ) - - with patch( - "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", - return_value=mock_prisma_client, - ), patch( - "litellm.proxy.management_endpoints.mcp_management_endpoints._user_has_admin_view", - return_value=False, - ), patch( - "litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager", - mock_manager, - ): - # Import and call the function - from litellm.proxy.management_endpoints.mcp_management_endpoints import ( - health_check_all_mcp_servers, - ) - - result = await health_check_all_mcp_servers( - user_api_key_dict=mock_user_auth - ) - - # Verify results - assert result["total_servers"] == 2 - assert result["healthy_count"] == 1 - assert result["unhealthy_count"] == 1 - assert result["unknown_count"] == 0 - assert "server1" in result["servers"] - assert "server2" in result["servers"] - - # Check individual server results - assert result["servers"]["server1"]["status"] == "healthy" - assert result["servers"]["server1"]["tools_count"] == 2 - assert result["servers"]["server1"]["server_name"] == "Test DB Server" - assert result["servers"]["server2"]["status"] == "unhealthy" - assert result["servers"]["server2"]["error"] == "Connection timeout" - assert result["servers"]["server2"]["server_name"] == "Test DB Server" - - @pytest.mark.asyncio - async def test_fetch_all_mcp_servers_with_health_status(self): - """Test that fetch_all_mcp_servers includes health check status""" - # Mock server with health status - mock_server = generate_mock_mcp_server_db_record( - server_id="test-server", alias="Test Server" - ) - # Add health status to the mock server - mock_server.status = "healthy" - mock_server.last_health_check = datetime.now() - mock_server.health_check_error = None - - # Mock dependencies - mock_prisma_client = MagicMock() - mock_prisma_client = setup_mock_prisma_client( - mock_prisma_client=mock_prisma_client, - team_records=[], - mcp_servers=[], # Don't add servers here since we're mocking get_all_mcp_servers - ) - - # Mock global MCP server manager - mock_manager = MagicMock() - mock_manager.config_mcp_servers = {} - mock_manager.get_allowed_mcp_servers = AsyncMock(return_value=[]) - mock_manager.get_all_mcp_servers_with_health_and_teams = AsyncMock( - return_value=[mock_server] - ) - - mock_server.credentials = {"auth_value": "secret"} - - mock_user_auth = generate_mock_user_api_key_auth( - user_role=LitellmUserRoles.PROXY_ADMIN - ) - - with patch( - "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", - return_value=mock_prisma_client, - ), patch( - "litellm.proxy.management_endpoints.mcp_management_endpoints._user_has_admin_view", - return_value=True, - ), patch( - "litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager", - mock_manager, - ): - # Import and call the function - from litellm.proxy.management_endpoints.mcp_management_endpoints import ( - fetch_all_mcp_servers, - ) - - result = await fetch_all_mcp_servers(user_api_key_dict=mock_user_auth) - - # Verify health check status is included - assert len(result) == 1 - server = result[0] - assert server.server_id == "test-server" - assert server.status == "healthy" - assert server.last_health_check is not None - assert server.health_check_error is None - assert server.credentials is None - - class TestTemporaryMCPSessionEndpoints: def test_inherit_credentials_from_existing_server(self): payload = NewMCPServerRequest( @@ -1170,7 +895,6 @@ class TestTemporaryMCPSessionEndpoints: fallback_client_id="server-1", ) - class TestUpdateMCPServer: """Test suite for update MCP server functionality""" @@ -1260,3 +984,163 @@ class TestUpdateMCPServer: # Verify the result includes extra_headers assert result.extra_headers == ["X-Custom-Header", "X-Another-Header"] assert result.alias == "Updated Test Server" + + +class TestHealthCheckServers: + """Test suite for health check servers endpoint""" + + @pytest.mark.asyncio + async def test_health_check_all_servers(self): + """ + Test health check for all accessible servers + + Scenario: User has access to 2 servers, checks all + Expected: Returns health status for both servers + """ + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + health_check_servers, + ) + + # Mock user auth + mock_user_auth = generate_mock_user_api_key_auth() + + # Mock health check results + mock_health_result_1 = generate_mock_mcp_server_db_record( + server_id="server-1", + alias="Server 1", + url="https://server1.example.com", + ) + mock_health_result_1.status = "healthy" + mock_health_result_1.last_health_check = datetime.now() + mock_health_result_1.health_check_error = None + + mock_health_result_2 = generate_mock_mcp_server_db_record( + server_id="server-2", + alias="Server 2", + url="https://server2.example.com", + ) + mock_health_result_2.status = "unhealthy" + mock_health_result_2.last_health_check = datetime.now() + mock_health_result_2.health_check_error = "Connection timeout" + + # Mock manager + mock_manager = MagicMock() + mock_manager.get_all_mcp_servers_with_health_and_teams = AsyncMock( + return_value=[mock_health_result_1, mock_health_result_2] + ) + + with patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager", + mock_manager, + ), patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.build_effective_auth_contexts", + AsyncMock(return_value=[mock_user_auth]), + ): + result = await health_check_servers( + server_ids=None, + user_api_key_dict=mock_user_auth, + ) + + # Verify results + assert len(result) == 2 + assert result[0]["server_id"] == "server-1" + assert result[0]["status"] == "healthy" + assert result[1]["server_id"] == "server-2" + assert result[1]["status"] == "unhealthy" + + @pytest.mark.asyncio + async def test_health_check_specific_servers(self): + """ + Test health check for specific servers + + Scenario: User requests health check for specific server IDs + Expected: Returns health status only for requested servers + """ + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + health_check_servers, + ) + + # Mock user auth + mock_user_auth = generate_mock_user_api_key_auth() + + # Mock health check result + mock_health_result = generate_mock_mcp_server_db_record( + server_id="server-1", + alias="Server 1", + url="https://server1.example.com", + ) + mock_health_result.status = "healthy" + mock_health_result.last_health_check = datetime.now() + mock_health_result.health_check_error = None + + # Mock manager + mock_manager = MagicMock() + mock_manager.get_all_mcp_servers_with_health_and_teams = AsyncMock( + return_value=[mock_health_result] + ) + + with patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager", + mock_manager, + ), patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.build_effective_auth_contexts", + AsyncMock(return_value=[mock_user_auth]), + ): + result = await health_check_servers( + server_ids=["server-1"], + user_api_key_dict=mock_user_auth, + ) + + # Verify results + assert len(result) == 1 + assert result[0]["server_id"] == "server-1" + assert result[0]["status"] == "healthy" + + @pytest.mark.asyncio + async def test_health_check_unauthorized_servers(self): + """ + Test health check with unauthorized servers + + Scenario: User requests health check for servers they don't have access to + Expected: Only checks accessible servers, unauthorized servers are filtered out + """ + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + health_check_servers, + ) + + # Mock user auth + mock_user_auth = generate_mock_user_api_key_auth() + + # Mock health check result for authorized server + mock_health_result = generate_mock_mcp_server_db_record( + server_id="server-1", + alias="Server 1", + url="https://server1.example.com", + ) + mock_health_result.status = "healthy" + mock_health_result.last_health_check = datetime.now() + mock_health_result.health_check_error = None + + # Mock manager - server_ids filter is applied inside get_all_mcp_servers_with_health_and_teams + # So it only returns servers the user has access to + mock_manager = MagicMock() + mock_manager.get_all_mcp_servers_with_health_and_teams = AsyncMock( + return_value=[mock_health_result] # Only server-1 is returned (accessible) + ) + + with patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager", + mock_manager, + ), patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.build_effective_auth_contexts", + AsyncMock(return_value=[mock_user_auth]), + ): + result = await health_check_servers( + server_ids=["server-1", "server-unauthorized"], + user_api_key_dict=mock_user_auth, + ) + + # Verify results - only accessible server is returned + assert len(result) == 1 + assert result[0]["server_id"] == "server-1" + assert result[0]["status"] == "healthy" diff --git a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py index c996f5aa10e..829e76108c4 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py +++ b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py @@ -2050,7 +2050,7 @@ class TestProcessSSOJWTAccessToken: @pytest.fixture def sample_jwt_token(self): """Create a sample JWT token string""" - return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + return "test-jwt-token-header.payload.signature" @pytest.fixture def sample_jwt_payload(self): @@ -3045,6 +3045,111 @@ class TestAddMissingTeamMember: ), f"Expected teams {expected_teams_added}, but got {added_teams}" +@pytest.mark.asyncio +async def test_role_mappings_override_default_internal_user_params(): + """ + Test that when role_mappings is configured in SSO settings, + the SSO-extracted role overrides default_internal_user_params role. + """ + from litellm.proxy._types import NewUserResponse, SSOUserDefinedValues + from litellm.proxy.management_endpoints.ui_sso import insert_sso_user + + # Save original default_internal_user_params + original_default_params = getattr(litellm, "default_internal_user_params", None) + + try: + # Set default_internal_user_params with a role that should be overridden + litellm.default_internal_user_params = { + "user_role": "internal_user", + "max_budget": 100, + "budget_duration": "30d", + "models": ["gpt-3.5-turbo"], + } + + # Mock SSO result + mock_result_openid = CustomOpenID( + id="test-user-123", + email="test@example.com", + display_name="Test User", + provider="microsoft", + team_ids=[], + ) + + # User defined values with SSO-extracted role (from role_mappings) + user_defined_values: SSOUserDefinedValues = { + "user_id": "test-user-123", + "user_email": "test@example.com", + "user_role": "proxy_admin", # Role from SSO role_mappings + "max_budget": None, + "budget_duration": None, + "models": [], + } + + # Mock Prisma client with SSO config that has role_mappings configured + mock_prisma = MagicMock() + mock_sso_config = MagicMock() + mock_sso_config.sso_settings = { + "role_mappings": { + "Admin": "proxy_admin", + "User": "internal_user", + } + } + mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock( + return_value=mock_sso_config + ) + + # Mock new_user function + mock_new_user_response = NewUserResponse( + user_id="test-user-123", + key="sk-xxxxx", + teams=None, + ) + + with patch( + "litellm.proxy.utils.get_prisma_client_or_throw", + return_value=mock_prisma, + ), patch( + "litellm.proxy.management_endpoints.ui_sso.new_user", + return_value=mock_new_user_response, + ) as mock_new_user: + # Act + result = await insert_sso_user( + result_openid=mock_result_openid, + user_defined_values=user_defined_values, + ) + + # Assert - verify new_user was called with preserved SSO role + mock_new_user.assert_called_once() + call_args = mock_new_user.call_args + new_user_request = call_args.kwargs["data"] + + # The role from SSO should be preserved, not overridden by default_internal_user_params + assert ( + new_user_request.user_role == "proxy_admin" + ), "SSO-extracted role should override default_internal_user_params role" + + # Other default params should still be applied + assert ( + new_user_request.max_budget == 100 + ), "max_budget from default_internal_user_params should be applied" + assert ( + new_user_request.budget_duration == "30d" + ), "budget_duration from default_internal_user_params should be applied" + + # Note: models are applied via _update_internal_new_user_params inside new_user, + # not in insert_sso_user, so we verify user_defined_values was updated correctly + # by checking that the function completed successfully and other defaults were applied + # The models will be applied when new_user processes the request + + finally: + # Restore original default_internal_user_params + if original_default_params is not None: + litellm.default_internal_user_params = original_default_params + else: + if hasattr(litellm, "default_internal_user_params"): + delattr(litellm, "default_internal_user_params") + + class TestSSOReadinessEndpoint: """Test the /sso/readiness endpoint""" diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index b0e198d5e7e..0bb9924af82 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -1148,7 +1148,7 @@ class TestBedrockLLMProxyRoute: mock_user_api_key_dict.allowed_model_region = None mock_proxy_logging_obj = Mock() - mock_proxy_logging_obj.post_call_failure_hook = AsyncMock() + mock_proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None) endpoint = "model/test-model/converse" model = "test-model" @@ -1291,7 +1291,7 @@ class TestBedrockLLMProxyRoute: mock_user_api_key_dict = Mock() mock_user_api_key_dict.api_key = "test-key" mock_proxy_logging_obj = Mock() - mock_proxy_logging_obj.post_call_failure_hook = AsyncMock() + mock_proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None) with patch( "litellm.passthrough.main.llm_passthrough_route", diff --git a/tests/test_litellm/proxy/spend_tracking/test_cloudzero_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_cloudzero_endpoints.py index 8ff5774bf50..6d460f63332 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_cloudzero_endpoints.py +++ b/tests/test_litellm/proxy/spend_tracking/test_cloudzero_endpoints.py @@ -1,6 +1,6 @@ import os import sys -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi.testclient import TestClient @@ -77,3 +77,112 @@ async def test_delete_cloudzero_settings_not_found(client, monkeypatch): finally: app.dependency_overrides.pop(ps.user_api_key_auth, None) + +@pytest.mark.asyncio +async def test_get_cloudzero_settings_success(client, monkeypatch): + """Test GET /cloudzero/settings returns settings when configured""" + mock_config = MagicMock() + mock_config.param_name = "cloudzero_settings" + mock_config.param_value = { + "api_key": "encrypted_key", + "connection_id": "conn_123", + "timezone": "UTC" + } + + mock_litellm_config = MagicMock() + mock_litellm_config.find_first = AsyncMock(return_value=mock_config) + + mock_prisma = MagicMock() + mock_prisma.db = MagicMock() + mock_prisma.db.litellm_config = mock_litellm_config + + monkeypatch.setattr(ps, "prisma_client", mock_prisma) + + # Mock the decrypt function to return a decrypted key + with patch("litellm.proxy.spend_tracking.cloudzero_endpoints.decrypt_value_helper") as mock_decrypt: + mock_decrypt.return_value = "decrypted_api_key" + + # Mock the masker + with patch("litellm.proxy.spend_tracking.cloudzero_endpoints._sensitive_masker") as mock_masker: + mock_masker.mask_dict.return_value = {"api_key": "test****key"} + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user" + ) + + try: + response = client.get("/cloudzero/settings") + assert response.status_code == 200 + data = response.json() + assert data["connection_id"] == "conn_123" + assert data["timezone"] == "UTC" + assert data["status"] == "configured" + assert data["api_key_masked"] == "test****key" + mock_litellm_config.find_first.assert_awaited_once() + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +@pytest.mark.asyncio +async def test_get_cloudzero_settings_not_configured(client, monkeypatch): + """Test GET /cloudzero/settings returns 200 with null values when not configured (consistent with other endpoints)""" + mock_litellm_config = MagicMock() + mock_litellm_config.find_first = AsyncMock(return_value=None) + + mock_prisma = MagicMock() + mock_prisma.db = MagicMock() + mock_prisma.db.litellm_config = mock_litellm_config + + monkeypatch.setattr(ps, "prisma_client", mock_prisma) + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user" + ) + + try: + response = client.get("/cloudzero/settings") + # Should return 200 with null values (not 404) - consistent with other settings endpoints + assert response.status_code == 200 + data = response.json() + assert data["api_key_masked"] is None + assert data["connection_id"] is None + assert data["timezone"] is None + assert data["status"] is None + mock_litellm_config.find_first.assert_awaited_once() + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +@pytest.mark.asyncio +async def test_get_cloudzero_settings_empty_param_value(client, monkeypatch): + """Test GET /cloudzero/settings returns 200 with null values when param_value is None""" + mock_config = MagicMock() + mock_config.param_name = "cloudzero_settings" + mock_config.param_value = None + + mock_litellm_config = MagicMock() + mock_litellm_config.find_first = AsyncMock(return_value=mock_config) + + mock_prisma = MagicMock() + mock_prisma.db = MagicMock() + mock_prisma.db.litellm_config = mock_litellm_config + + monkeypatch.setattr(ps, "prisma_client", mock_prisma) + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user" + ) + + try: + response = client.get("/cloudzero/settings") + # Should return 200 with null values (not 404) - consistent with other settings endpoints + assert response.status_code == 200 + data = response.json() + assert data["api_key_masked"] is None + assert data["connection_id"] is None + assert data["timezone"] is None + assert data["status"] is None + mock_litellm_config.find_first.assert_awaited_once() + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py index e08f2ad98dd..5e3652c6d9d 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -1857,3 +1857,203 @@ async def test_view_spend_logs_with_date_range_summarized(client, monkeypatch): assert "spend" in data[0] assert "users" in data[0] assert "models" in data[0] + + +@pytest.mark.asyncio +async def test_ui_view_spend_logs_with_error_code(client): + """Test filtering spend logs by error code""" + mock_spend_logs = [ + { + "id": "log1", + "request_id": "req1", + "api_key": "sk-test-key", + "user": "test_user_1", + "team_id": "team1", + "spend": 0.05, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-3.5-turbo", + "metadata": '{"error_information": {"error_code": "404"}}', + }, + { + "id": "log2", + "request_id": "req2", + "api_key": "sk-test-key", + "user": "test_user_2", + "team_id": "team1", + "spend": 0.10, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-4", + "metadata": '{"error_information": {"error_code": "500"}}', + }, + ] + + with patch.object(ps, "prisma_client") as mock_prisma: + # Mock the find_many method to return filtered results + async def mock_find_many(*args, **kwargs): + where_conditions = kwargs.get("where", {}) + if "metadata" in where_conditions: + metadata_filter = where_conditions["metadata"] + if metadata_filter.get("path") == ["error_information", "error_code"]: + error_code = metadata_filter.get("equals") + # Handle both string and integer error codes + # The endpoint wraps error_code in quotes, so strip them for comparison + error_code_value = str(error_code).strip('"') + if error_code_value == "404": + return [mock_spend_logs[0]] + elif error_code_value == "500": + return [mock_spend_logs[1]] + return mock_spend_logs + + async def mock_count(*args, **kwargs): + where_conditions = kwargs.get("where", {}) + if "metadata" in where_conditions: + metadata_filter = where_conditions["metadata"] + if metadata_filter.get("path") == ["error_information", "error_code"]: + error_code = metadata_filter.get("equals") + # Handle both string and integer error codes + # The endpoint wraps error_code in quotes, so strip them for comparison + error_code_value = str(error_code).strip('"') + if error_code_value == "404": + return 1 + elif error_code_value == "500": + return 1 + return len(mock_spend_logs) + + mock_prisma.db.litellm_spendlogs.find_many = mock_find_many + mock_prisma.db.litellm_spendlogs.count = mock_count + + start_date = ( + datetime.datetime.now(timezone.utc) - datetime.timedelta(days=7) + ).strftime("%Y-%m-%d %H:%M:%S") + end_date = datetime.datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + response = client.get( + "/spend/logs/ui", + params={ + "error_code": "404", + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert len(data["data"]) == 1 + assert data["data"][0]["id"] == "log1" + metadata = json.loads(data["data"][0]["metadata"]) + assert "error_information" in metadata + assert metadata["error_information"]["error_code"] == "404" + + +@pytest.mark.asyncio +async def test_ui_view_spend_logs_with_error_code_and_key_alias(client): + """Test merging error_code and key_alias filters with AND logic""" + mock_spend_logs = [ + { + "id": "log1", + "request_id": "req1", + "api_key": "sk-test-key", + "user": "test_user_1", + "team_id": "team1", + "spend": 0.05, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-3.5-turbo", + "metadata": '{"user_api_key_alias": "test-key-1", "error_information": {"error_code": "404"}}', + }, + { + "id": "log2", + "request_id": "req2", + "api_key": "sk-test-key", + "user": "test_user_2", + "team_id": "team1", + "spend": 0.10, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-4", + "metadata": '{"user_api_key_alias": "test-key-2", "error_information": {"error_code": "500"}}', + }, + { + "id": "log3", + "request_id": "req3", + "api_key": "sk-test-key", + "user": "test_user_3", + "team_id": "team1", + "spend": 0.15, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-4", + "metadata": '{"user_api_key_alias": "test-key-1", "error_information": {"error_code": "500"}}', + }, + ] + + with patch.object(ps, "prisma_client") as mock_prisma: + # Mock the find_many method to handle AND conditions + async def mock_find_many(*args, **kwargs): + where_conditions = kwargs.get("where", {}) + if "AND" in where_conditions: + key_alias_filter = None + error_code_filter = None + for condition in where_conditions["AND"]: + if "metadata" in condition: + metadata_filter = condition["metadata"] + if metadata_filter.get("path") == ["user_api_key_alias"]: + key_alias_filter = metadata_filter.get("string_contains") + elif metadata_filter.get("path") == ["error_information", "error_code"]: + error_code_filter = metadata_filter.get("equals") + + # Handle both string and integer error codes + # The endpoint wraps error_code in quotes, so strip them for comparison + error_code_value = str(error_code_filter).strip('"') + if key_alias_filter == "test-key-1" and error_code_value == "500": + return [mock_spend_logs[2]] # Only log3 matches both conditions + return mock_spend_logs + + async def mock_count(*args, **kwargs): + where_conditions = kwargs.get("where", {}) + if "AND" in where_conditions: + key_alias_filter = None + error_code_filter = None + for condition in where_conditions["AND"]: + if "metadata" in condition: + metadata_filter = condition["metadata"] + if metadata_filter.get("path") == ["user_api_key_alias"]: + key_alias_filter = metadata_filter.get("string_contains") + elif metadata_filter.get("path") == ["error_information", "error_code"]: + error_code_filter = metadata_filter.get("equals") + + # Handle both string and integer error codes + # The endpoint wraps error_code in quotes, so strip them for comparison + error_code_value = str(error_code_filter).strip('"') + if key_alias_filter == "test-key-1" and error_code_value == "500": + return 1 + return len(mock_spend_logs) + + mock_prisma.db.litellm_spendlogs.find_many = mock_find_many + mock_prisma.db.litellm_spendlogs.count = mock_count + + start_date = ( + datetime.datetime.now(timezone.utc) - datetime.timedelta(days=7) + ).strftime("%Y-%m-%d %H:%M:%S") + end_date = datetime.datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + response = client.get( + "/spend/logs/ui", + params={ + "error_code": "500", + "key_alias": "test-key-1", + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert len(data["data"]) == 1 + assert data["data"][0]["id"] == "log3" + metadata = json.loads(data["data"][0]["metadata"]) + assert "user_api_key_alias" in metadata + assert metadata["user_api_key_alias"] == "test-key-1" + assert "error_information" in metadata + assert metadata["error_information"]["error_code"] == "500" diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index 2e7046319ed..b5d44385698 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -271,6 +271,99 @@ class TestProxyBaseLLMRequestProcessing: assert "x-litellm-response-cost-original" not in headers assert "x-litellm-response-cost-discount-amount" not in headers + def test_get_custom_headers_with_margin_info(self): + """ + Test that margin headers are included when margin is applied. + """ + from litellm.litellm_core_utils.litellm_logging import ( + Logging as LiteLLMLoggingObj, + ) + + # Create mock user API key dict + mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth) + mock_user_api_key_dict.tpm_limit = None + mock_user_api_key_dict.rpm_limit = None + mock_user_api_key_dict.max_budget = None + mock_user_api_key_dict.spend = 0 + + # Create logging object with margin + logging_obj = LiteLLMLoggingObj( + model="gpt-4", + messages=[], + stream=False, + call_type="completion", + start_time=None, + litellm_call_id="test-call-id-margin", + function_id="test-function", + ) + logging_obj.set_cost_breakdown( + input_cost=0.00005, + output_cost=0.00005, + total_cost=0.00011, + cost_for_built_in_tools_cost_usd_dollar=0.0, + original_cost=0.0001, + margin_percent=0.10, + margin_total_amount=0.00001, + ) + + headers = ProxyBaseLLMRequestProcessing.get_custom_headers( + user_api_key_dict=mock_user_api_key_dict, + response_cost=0.00011, + litellm_logging_obj=logging_obj, + ) + + # Verify margin headers are present + assert "x-litellm-response-cost" in headers + assert float(headers["x-litellm-response-cost"]) == 0.00011 + + assert "x-litellm-response-cost-margin-amount" in headers + assert float(headers["x-litellm-response-cost-margin-amount"]) == 0.00001 + + assert "x-litellm-response-cost-margin-percent" in headers + assert float(headers["x-litellm-response-cost-margin-percent"]) == 0.10 + + def test_get_custom_headers_without_margin_info(self): + """ + Test that when no margin is applied, margin headers are not included. + """ + from litellm.litellm_core_utils.litellm_logging import ( + Logging as LiteLLMLoggingObj, + ) + + # Create mock user API key dict + mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth) + mock_user_api_key_dict.tpm_limit = None + mock_user_api_key_dict.rpm_limit = None + mock_user_api_key_dict.max_budget = None + mock_user_api_key_dict.spend = 0 + + # Create logging object without margin + logging_obj = LiteLLMLoggingObj( + model="gpt-4", + messages=[], + stream=False, + call_type="completion", + start_time=None, + litellm_call_id="test-call-id-no-margin", + function_id="test-function", + ) + logging_obj.set_cost_breakdown( + input_cost=0.00005, + output_cost=0.00005, + total_cost=0.0001, + cost_for_built_in_tools_cost_usd_dollar=0.0, + ) + + headers = ProxyBaseLLMRequestProcessing.get_custom_headers( + user_api_key_dict=mock_user_api_key_dict, + response_cost=0.0001, + litellm_logging_obj=logging_obj, + ) + + # Verify margin headers are not present + assert "x-litellm-response-cost-margin-amount" not in headers + assert "x-litellm-response-cost-margin-percent" not in headers + def test_get_cost_breakdown_from_logging_obj_helper(self): """ Test the helper function that extracts cost breakdown information. @@ -299,11 +392,39 @@ class TestProxyBaseLLMRequestProcessing: discount_amount=0.000005, ) - original_cost, discount_amount = _get_cost_breakdown_from_logging_obj(logging_obj) + original_cost, discount_amount, margin_total_amount, margin_percent = _get_cost_breakdown_from_logging_obj(logging_obj) assert original_cost == 0.0001 assert discount_amount == 0.000005 + assert margin_total_amount is None + assert margin_percent is None - # Test with no discount info + # Test with margin info + logging_obj_with_margin = LiteLLMLoggingObj( + model="gpt-4", + messages=[{"role": "user", "content": "test"}], + stream=False, + call_type="completion", + start_time=None, + litellm_call_id="test-call-id-margin", + function_id="test-function-id-margin", + ) + logging_obj_with_margin.set_cost_breakdown( + input_cost=0.00005, + output_cost=0.00005, + total_cost=0.00011, + cost_for_built_in_tools_cost_usd_dollar=0.0, + original_cost=0.0001, + margin_percent=0.10, + margin_total_amount=0.00001, + ) + + original_cost, discount_amount, margin_total_amount, margin_percent = _get_cost_breakdown_from_logging_obj(logging_obj_with_margin) + assert original_cost == 0.0001 + assert discount_amount is None + assert margin_total_amount == 0.00001 + assert margin_percent == 0.10 + + # Test with no discount or margin info logging_obj_no_discount = LiteLLMLoggingObj( model="gpt-3.5-turbo", messages=[{"role": "user", "content": "test"}], @@ -320,14 +441,18 @@ class TestProxyBaseLLMRequestProcessing: cost_for_built_in_tools_cost_usd_dollar=0.0, ) - original_cost, discount_amount = _get_cost_breakdown_from_logging_obj(logging_obj_no_discount) + original_cost, discount_amount, margin_total_amount, margin_percent = _get_cost_breakdown_from_logging_obj(logging_obj_no_discount) assert original_cost is None assert discount_amount is None + assert margin_total_amount is None + assert margin_percent is None # Test with None logging object - original_cost, discount_amount = _get_cost_breakdown_from_logging_obj(None) + original_cost, discount_amount, margin_total_amount, margin_percent = _get_cost_breakdown_from_logging_obj(None) assert original_cost is None assert discount_amount is None + assert margin_total_amount is None + assert margin_percent is None def test_get_custom_headers_key_spend_includes_response_cost(self): """ diff --git a/tests/test_litellm/proxy/test_proxy_cli.py b/tests/test_litellm/proxy/test_proxy_cli.py index 90d958e711d..5f03ef18171 100644 --- a/tests/test_litellm/proxy/test_proxy_cli.py +++ b/tests/test_litellm/proxy/test_proxy_cli.py @@ -180,7 +180,7 @@ class TestProxyInitializationHelpers: test_env = { "DATABASE_HOST": "localhost:5432", "DATABASE_USERNAME": "user@with+special", - "DATABASE_PASSWORD": "pass&word!@#$%", + "DATABASE_PASSWORD": "test-password-special-chars", "DATABASE_NAME": "db_name/test", } @@ -205,7 +205,7 @@ class TestProxyInitializationHelpers: database_url = f"postgresql://{database_username_enc}:{database_password_enc}@{database_host}/{database_name_enc}" # Assert the correct URL was constructed with properly escaped characters - expected_url = "postgresql://user%40with%2Bspecial:pass%26word%21%40%23%24%25@localhost:5432/db_name%2Ftest" + expected_url = "postgresql://user%40with%2Bspecial:test-password-special-chars@localhost:5432/db_name%2Ftest" assert database_url == expected_url # Test appending query parameters @@ -381,13 +381,13 @@ class TestProxyInitializationHelpers: test_env_special = { "DATABASE_HOST": "localhost:5432", "DATABASE_USERNAME": "user@with+special", - "DATABASE_PASSWORD": "pass&word!@#$%", + "DATABASE_PASSWORD": "test-password-special-chars", "DATABASE_NAME": "db_name/test", } with patch.dict(os.environ, test_env_special): result = construct_database_url_from_env_vars() - expected_url = "postgresql://user%40with%2Bspecial:pass%26word%21%40%23%24%25@localhost:5432/db_name%2Ftest" + expected_url = "postgresql://user%40with%2Bspecial:test-password-special-chars@localhost:5432/db_name%2Ftest" assert result == expected_url # Test without password (should still work) diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index d0a0ed5522c..5c7ece04513 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -273,6 +273,10 @@ def test_sso_key_generate_shows_deprecation_banner(client_no_auth, monkeypatch): def test_restructure_ui_html_files_handles_nested_routes(tmp_path): + """ + Test that _restructure_ui_html_files correctly restructures HTML files. + Note: This function is always called now, both in development and non-root Docker environments. + """ from litellm.proxy import proxy_server ui_root = tmp_path / "ui" @@ -306,7 +310,10 @@ def test_restructure_ui_html_files_handles_nested_routes(tmp_path): def test_ui_extensionless_route_requires_restructure(tmp_path): - """Regression for non-root fallback: /ui/login expects login/index.html.""" + """ + Regression for non-root fallback: /ui/login expects login/index.html. + Note: Restructuring always happens now, both in development and non-root Docker environments. + """ from litellm.proxy import proxy_server @@ -331,6 +338,50 @@ def test_ui_extensionless_route_requires_restructure(tmp_path): assert "login" in response.text +def test_restructure_always_happens(monkeypatch): + """ + Test that restructuring logic always executes regardless of LITELLM_NON_ROOT setting. + In development (is_non_root=False), restructuring happens directly in _experimental/out. + In non-root Docker (is_non_root=True), restructuring happens in /var/lib/litellm/ui. + """ + # Test Case 1: is_non_root is True - restructuring happens in /var/lib/litellm/ui + monkeypatch.setenv("LITELLM_NON_ROOT", "true") + + runtime_ui_path = "/var/lib/litellm/ui" + packaged_ui_path = "/some/packaged/ui/path" + + # Simulate the logic from proxy_server.py + is_non_root = os.getenv("LITELLM_NON_ROOT", "").lower() == "true" + if is_non_root: + ui_path = runtime_ui_path + else: + ui_path = packaged_ui_path + + # Restructuring always happens now, regardless of ui_path vs packaged_ui_path + should_restructure = True + + assert is_non_root is True + assert should_restructure is True + assert ui_path == runtime_ui_path + + # Test Case 2: is_non_root is False - restructuring happens directly in packaged_ui_path + monkeypatch.delenv("LITELLM_NON_ROOT", raising=False) + + # Simulate the logic from proxy_server.py + is_non_root = os.getenv("LITELLM_NON_ROOT", "").lower() == "true" + if is_non_root: + ui_path = runtime_ui_path + else: + ui_path = packaged_ui_path + + # Restructuring always happens now, even when ui_path == packaged_ui_path + should_restructure = True + + assert is_non_root is False + assert should_restructure is True + assert ui_path == packaged_ui_path + + @pytest.mark.asyncio async def test_initialize_scheduled_jobs_credentials(monkeypatch): """ @@ -559,7 +610,7 @@ async def test_aaaproxy_startup_master_key(mock_prisma, monkeypatch, tmp_path): assert master_key == test_master_key # Test Case 2: Master key from environment variable - test_env_master_key = "sk-67890" + test_env_master_key = "sk-test-67890" # Create empty config empty_config = {"general_settings": {}} @@ -2856,9 +2907,9 @@ def test_root_redirect_when_docs_url_not_root_and_redirect_url_set(monkeypatch): assert response.headers["location"] == test_redirect_url -def test_get_image_non_root_uses_tmp_assets_dir(monkeypatch): +def test_get_image_non_root_uses_var_lib_assets_dir(monkeypatch): """ - Test that get_image uses /tmp/litellm_assets when LITELLM_NON_ROOT is true. + Test that get_image uses /var/lib/litellm/assets when LITELLM_NON_ROOT is true. """ from unittest.mock import patch @@ -2887,14 +2938,14 @@ def test_get_image_non_root_uses_tmp_assets_dir(monkeypatch): # Call the function get_image() - # Verify makedirs was called with /tmp/litellm_assets - mock_makedirs.assert_called_once_with("/tmp/litellm_assets", exist_ok=True) + # Verify makedirs was called with /var/lib/litellm/assets + mock_makedirs.assert_called_once_with("/var/lib/litellm/assets", exist_ok=True) def test_get_image_non_root_fallback_to_default_logo(monkeypatch): """ Test that get_image falls back to default_site_logo when logo doesn't exist - in /tmp/litellm_assets for non-root case. + in /var/lib/litellm/assets for non-root case. """ from unittest.mock import patch @@ -2904,13 +2955,13 @@ def test_get_image_non_root_fallback_to_default_logo(monkeypatch): monkeypatch.setenv("LITELLM_NON_ROOT", "true") monkeypatch.delenv("UI_LOGO_PATH", raising=False) - # Track path.exists calls to verify it checks /tmp/litellm_assets/logo.jpg + # Track path.exists calls to verify it checks /var/lib/litellm/assets/logo.jpg exists_calls = [] def exists_side_effect(path): exists_calls.append(path) - # Return False for /tmp/litellm_assets/logo.jpg to trigger fallback - if "/tmp/litellm_assets/logo.jpg" in path: + # Return False for /var/lib/litellm/assets/logo.jpg to trigger fallback + if "/var/lib/litellm/assets/logo.jpg" in path: return False return True @@ -2933,13 +2984,13 @@ def test_get_image_non_root_fallback_to_default_logo(monkeypatch): # Call the function get_image() - # Verify makedirs was called with /tmp/litellm_assets - mock_makedirs.assert_called_once_with("/tmp/litellm_assets", exist_ok=True) + # Verify makedirs was called with /var/lib/litellm/assets + mock_makedirs.assert_called_once_with("/var/lib/litellm/assets", exist_ok=True) - # Verify that exists was called to check /tmp/litellm_assets/logo.jpg - tmp_logo_path = "/tmp/litellm_assets/logo.jpg" - assert any(tmp_logo_path in str(call) for call in exists_calls), \ - f"Should check if {tmp_logo_path} exists" + # Verify that exists was called to check /var/lib/litellm/assets/logo.jpg + assets_logo_path = "/var/lib/litellm/assets/logo.jpg" + assert any(assets_logo_path in str(call) for call in exists_calls), \ + f"Should check if {assets_logo_path} exists" # Verify FileResponse was called (with fallback logo) assert mock_file_response.called, "FileResponse should be called" @@ -2976,12 +3027,12 @@ def test_get_image_root_case_uses_current_dir(monkeypatch): # Call the function get_image() - # Verify makedirs was NOT called with /tmp/litellm_assets (should not create it for root case) - tmp_assets_calls = [ + # Verify makedirs was NOT called with /var/lib/litellm/assets (should not create it for root case) + var_lib_assets_calls = [ call for call in mock_makedirs.call_args_list - if "/tmp/litellm_assets" in str(call) + if "/var/lib/litellm/assets" in str(call) ] - assert len(tmp_assets_calls) == 0, "Should not create /tmp/litellm_assets for root case" + assert len(var_lib_assets_calls) == 0, "Should not create /var/lib/litellm/assets for root case" # Verify FileResponse was called assert mock_file_response.called, "FileResponse should be called" diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index d3c99151195..8fdfd6897a8 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -290,6 +290,10 @@ class TestProxySettingEndpoints: assert "google_client_id" in data["field_schema"]["properties"] assert "description" in data["field_schema"]["properties"]["google_client_id"] + # Verify role_mappings is present in response (can be None if not set) + assert "role_mappings" in values + assert values["role_mappings"] is None + # Verify find_unique was called with correct parameters mock_prisma.db.litellm_ssoconfig.find_unique.assert_called_once() call_args = mock_prisma.db.litellm_ssoconfig.find_unique.call_args @@ -863,6 +867,10 @@ class TestProxySettingEndpoints: assert values["google_client_secret"] == "decrypted_google_secret" assert values["microsoft_client_id"] == "decrypted_microsoft_id" assert values["proxy_base_url"] == "https://decrypted.example.com" + + # Verify role_mappings is present in response (can be None if not set) + assert "role_mappings" in values + assert values["role_mappings"] is None def test_update_sso_settings_to_database(self, mock_proxy_config, mock_auth, monkeypatch): """Test updating SSO settings saves to the dedicated database table""" @@ -1062,6 +1070,7 @@ class TestProxySettingEndpoints: assert values.get("google_client_id") is None assert values.get("google_client_secret") is None assert values.get("microsoft_client_id") is None + assert values.get("role_mappings") is None def test_update_sso_settings_no_database_connection(self, mock_proxy_config, mock_auth, monkeypatch): """Test updating SSO settings when database is not connected""" @@ -1088,3 +1097,129 @@ class TestProxySettingEndpoints: data = response.json() assert "error" in data["detail"] assert "Database not connected" in data["detail"]["error"] + + def test_get_sso_settings_with_role_mappings(self, mock_proxy_config, mock_auth, monkeypatch): + """Test getting SSO settings when role_mappings is present in database""" + from unittest.mock import AsyncMock, MagicMock + from litellm.proxy._types import LitellmUserRoles + + # Mock the prisma client with database record containing role_mappings + mock_prisma = MagicMock() + mock_db_record = MagicMock() + mock_db_record.sso_settings = { + "google_client_id": "test_google_client_id", + "role_mappings": { + "provider": "google", + "group_claim": "groups", + "default_role": LitellmUserRoles.INTERNAL_USER, + "roles": { + LitellmUserRoles.PROXY_ADMIN: ["admin-group"], + }, + }, + } + mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=mock_db_record) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) + + # Mock decryption to return the values as-is (role_mappings should not be passed to decryption) + from litellm.proxy.proxy_server import proxy_config + def mock_decrypt(environment_variables): + # role_mappings should not be in environment_variables since it's extracted before decryption + assert "role_mappings" not in environment_variables + return environment_variables + + monkeypatch.setattr( + proxy_config, "_decrypt_and_set_db_env_variables", mock_decrypt + ) + + response = client.get("/get/sso_settings") + + assert response.status_code == 200 + data = response.json() + + # Verify role_mappings is returned correctly + values = data["values"] + assert "role_mappings" in values + assert values["role_mappings"] is not None + assert values["role_mappings"]["provider"] == "google" + assert values["role_mappings"]["group_claim"] == "groups" + assert values["role_mappings"]["default_role"] == LitellmUserRoles.INTERNAL_USER + assert values["role_mappings"]["roles"][LitellmUserRoles.PROXY_ADMIN] == ["admin-group"] + + def test_role_mappings_stored_and_retrieved(self, mock_proxy_config, mock_auth, monkeypatch): + """Test that role_mappings is properly stored and retrieved from SSO settings""" + import json + from unittest.mock import AsyncMock, MagicMock + from litellm.proxy._types import LitellmUserRoles + + monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key") + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + + # Mock the prisma client + mock_prisma = MagicMock() + mock_prisma.db.litellm_ssoconfig.upsert = AsyncMock() + mock_prisma.db.litellm_config = MagicMock() + mock_prisma.db.litellm_config.find_unique = AsyncMock(return_value=None) + mock_prisma.db.litellm_config.update = AsyncMock() + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) + + # Mock encryption to return values as-is + from litellm.proxy.proxy_server import proxy_config + monkeypatch.setattr(proxy_config, "_encrypt_env_variables", lambda environment_variables: environment_variables) + + # SSO settings with role_mappings + role_mappings_data = { + "provider": "google", + "group_claim": "groups", + "default_role": LitellmUserRoles.INTERNAL_USER, + "roles": { + LitellmUserRoles.PROXY_ADMIN: ["admin-group"], + LitellmUserRoles.INTERNAL_USER: ["user-group"], + }, + } + + new_sso_settings = { + "google_client_id": "test_google_id", + "role_mappings": role_mappings_data, + } + + response = client.patch("/update/sso_settings", json=new_sso_settings) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert "role_mappings" in data["settings"] + + # Verify role_mappings structure in response + returned_role_mappings = data["settings"]["role_mappings"] + assert returned_role_mappings["provider"] == "google" + assert returned_role_mappings["group_claim"] == "groups" + assert returned_role_mappings["default_role"] == LitellmUserRoles.INTERNAL_USER + assert returned_role_mappings["roles"][LitellmUserRoles.PROXY_ADMIN] == ["admin-group"] + + # Verify upsert was called with role_mappings in the data + assert mock_prisma.db.litellm_ssoconfig.upsert.called + call_args = mock_prisma.db.litellm_ssoconfig.upsert.call_args + create_data = call_args.kwargs["data"]["create"] + stored_sso_settings = json.loads(create_data["sso_settings"]) + assert "role_mappings" in stored_sso_settings + assert stored_sso_settings["role_mappings"]["provider"] == "google" + + # Now test retrieving role_mappings + mock_db_record = MagicMock() + mock_db_record.sso_settings = stored_sso_settings + mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=mock_db_record) + monkeypatch.setattr( + proxy_config, "_decrypt_and_set_db_env_variables", lambda environment_variables: environment_variables + ) + + get_response = client.get("/get/sso_settings") + assert get_response.status_code == 200 + get_data = get_response.json() + + # Verify role_mappings is returned correctly + assert "role_mappings" in get_data["values"] + retrieved_role_mappings = get_data["values"]["role_mappings"] + assert retrieved_role_mappings is not None + assert retrieved_role_mappings["provider"] == "google" + assert retrieved_role_mappings["group_claim"] == "groups" + assert retrieved_role_mappings["default_role"] == LitellmUserRoles.INTERNAL_USER diff --git a/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py b/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py index 69cf7de55bc..352e84719f1 100644 --- a/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py +++ b/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py @@ -648,7 +648,7 @@ class TestIsAllowedToCallVectorStoreEndpoint: mock_request.method = "GET" mock_request.url.path = "/azure_ai/indexes/dall-e-4/docs/search" mock_user_api_key = UserAPIKeyAuth( - token="b637312ebffb9745321224644430ba9e4916a291c8281f293d21182c5e80bc5a", + token="sk-test-mock-token-404", key_name="sk-...plNQ", metadata={ "allowed_vector_store_indexes": [ diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_session_handler.py b/tests/test_litellm/responses/litellm_completion_transformation/test_session_handler.py index bd6bab9d61e..b0a232a7bf4 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_session_handler.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_session_handler.py @@ -27,7 +27,7 @@ async def test_get_chat_completion_message_history_for_previous_response_id(): { "request_id": "chatcmpl-935b8dad-fdc2-466e-a8ca-e26e5a8a21bb", "call_type": "aresponses", - "api_key": "88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b", + "api_key": "sk-test-mock-api-key-123", "spend": 0.004803, "total_tokens": 329, "prompt_tokens": 11, @@ -68,7 +68,7 @@ async def test_get_chat_completion_message_history_for_previous_response_id(): { "request_id": "chatcmpl-370760c9-39fa-4db7-b034-d1f8d933c935", "call_type": "aresponses", - "api_key": "88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b", + "api_key": "sk-test-mock-api-key-123", "spend": 0.010437, "total_tokens": 967, "prompt_tokens": 339, diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index c26801ac3f6..7036a953b83 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -837,6 +837,317 @@ def test_cost_discount_not_applied_to_other_providers(): print(f" - Cost remains unchanged: ${cost_with_selective_discount:.6f}") +def test_cost_margin_percentage(): + """ + Test that percentage-based cost margin is applied correctly + """ + from litellm import completion_cost + from litellm.types.utils import Usage + + # Save original config + original_margin_config = litellm.cost_margin_config.copy() + + # Create mock response + response = ModelResponse( + id="test-id", + choices=[], + created=1234567890, + model="gpt-4", + object="chat.completion", + usage=Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150), + ) + + # Calculate cost without margin + litellm.cost_margin_config = {} + cost_without_margin = completion_cost( + completion_response=response, + model="gpt-4", + custom_llm_provider="openai", + ) + + # Set 10% margin for openai + litellm.cost_margin_config = {"openai": 0.10} + + # Calculate cost with margin + cost_with_margin = completion_cost( + completion_response=response, + model="gpt-4", + custom_llm_provider="openai", + ) + + # Restore original config + litellm.cost_margin_config = original_margin_config + + # Verify margin is applied (10% margin means 110% of original cost) + expected_cost = cost_without_margin * 1.10 + assert cost_with_margin == pytest.approx(expected_cost, rel=1e-9) + + print(f"✓ Cost margin percentage test passed:") + print(f" - Original cost: ${cost_without_margin:.6f}") + print(f" - Cost with margin (10%): ${cost_with_margin:.6f}") + print(f" - Margin added: ${cost_with_margin - cost_without_margin:.6f}") + + +def test_cost_margin_fixed_amount(): + """ + Test that fixed amount cost margin is applied correctly + """ + from litellm import completion_cost + from litellm.types.utils import Usage + + # Save original config + original_margin_config = litellm.cost_margin_config.copy() + + # Create mock response + response = ModelResponse( + id="test-id", + choices=[], + created=1234567890, + model="gpt-4", + object="chat.completion", + usage=Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150), + ) + + # Calculate cost without margin + litellm.cost_margin_config = {} + cost_without_margin = completion_cost( + completion_response=response, + model="gpt-4", + custom_llm_provider="openai", + ) + + # Set $0.001 fixed margin for openai + litellm.cost_margin_config = {"openai": {"fixed_amount": 0.001}} + + # Calculate cost with margin + cost_with_margin = completion_cost( + completion_response=response, + model="gpt-4", + custom_llm_provider="openai", + ) + + # Restore original config + litellm.cost_margin_config = original_margin_config + + # Verify fixed margin is applied + expected_cost = cost_without_margin + 0.001 + assert cost_with_margin == pytest.approx(expected_cost, rel=1e-9) + + print(f"✓ Cost margin fixed amount test passed:") + print(f" - Original cost: ${cost_without_margin:.6f}") + print(f" - Cost with margin ($0.001): ${cost_with_margin:.6f}") + print(f" - Margin added: ${cost_with_margin - cost_without_margin:.6f}") + + +def test_cost_margin_combined(): + """ + Test that combined percentage and fixed amount margin is applied correctly + """ + from litellm import completion_cost + from litellm.types.utils import Usage + + # Save original config + original_margin_config = litellm.cost_margin_config.copy() + + # Create mock response + response = ModelResponse( + id="test-id", + choices=[], + created=1234567890, + model="gpt-4", + object="chat.completion", + usage=Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150), + ) + + # Calculate cost without margin + litellm.cost_margin_config = {} + cost_without_margin = completion_cost( + completion_response=response, + model="gpt-4", + custom_llm_provider="openai", + ) + + # Set 8% margin + $0.0005 fixed for openai + litellm.cost_margin_config = {"openai": {"percentage": 0.08, "fixed_amount": 0.0005}} + + # Calculate cost with margin + cost_with_margin = completion_cost( + completion_response=response, + model="gpt-4", + custom_llm_provider="openai", + ) + + # Restore original config + litellm.cost_margin_config = original_margin_config + + # Verify combined margin is applied + expected_cost = cost_without_margin * 1.08 + 0.0005 + assert cost_with_margin == pytest.approx(expected_cost, rel=1e-9) + + print(f"✓ Cost margin combined test passed:") + print(f" - Original cost: ${cost_without_margin:.6f}") + print(f" - Cost with margin (8% + $0.0005): ${cost_with_margin:.6f}") + print(f" - Margin added: ${cost_with_margin - cost_without_margin:.6f}") + + +def test_cost_margin_global(): + """ + Test that global margin is applied when no provider-specific margin is configured + """ + from litellm import completion_cost + from litellm.types.utils import Usage + + # Save original config + original_margin_config = litellm.cost_margin_config.copy() + + # Create mock response + response = ModelResponse( + id="test-id", + choices=[], + created=1234567890, + model="gpt-4", + object="chat.completion", + usage=Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150), + ) + + # Calculate cost without margin + litellm.cost_margin_config = {} + cost_without_margin = completion_cost( + completion_response=response, + model="gpt-4", + custom_llm_provider="openai", + ) + + # Set 5% global margin (no provider-specific margin) + litellm.cost_margin_config = {"global": 0.05} + + # Calculate cost with global margin + cost_with_global_margin = completion_cost( + completion_response=response, + model="gpt-4", + custom_llm_provider="openai", + ) + + # Restore original config + litellm.cost_margin_config = original_margin_config + + # Verify global margin is applied + expected_cost = cost_without_margin * 1.05 + assert cost_with_global_margin == pytest.approx(expected_cost, rel=1e-9) + + print(f"✓ Cost margin global test passed:") + print(f" - Original cost: ${cost_without_margin:.6f}") + print(f" - Cost with global margin (5%): ${cost_with_global_margin:.6f}") + print(f" - Margin added: ${cost_with_global_margin - cost_without_margin:.6f}") + + +def test_cost_margin_provider_overrides_global(): + """ + Test that provider-specific margin overrides global margin + """ + from litellm import completion_cost + from litellm.types.utils import Usage + + # Save original config + original_margin_config = litellm.cost_margin_config.copy() + + # Create mock response + response = ModelResponse( + id="test-id", + choices=[], + created=1234567890, + model="gpt-4", + object="chat.completion", + usage=Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150), + ) + + # Calculate cost without margin + litellm.cost_margin_config = {} + cost_without_margin = completion_cost( + completion_response=response, + model="gpt-4", + custom_llm_provider="openai", + ) + + # Set 5% global margin and 10% provider-specific margin + litellm.cost_margin_config = {"global": 0.05, "openai": 0.10} + + # Calculate cost - should use provider-specific margin (10%), not global (5%) + cost_with_provider_margin = completion_cost( + completion_response=response, + model="gpt-4", + custom_llm_provider="openai", + ) + + # Restore original config + litellm.cost_margin_config = original_margin_config + + # Verify provider-specific margin is used (not global) + expected_cost = cost_without_margin * 1.10 # 10% from provider, not 5% from global + assert cost_with_provider_margin == pytest.approx(expected_cost, rel=1e-9) + + print(f"✓ Cost margin provider override test passed:") + print(f" - Original cost: ${cost_without_margin:.6f}") + print(f" - Cost with provider margin (10%, overrides 5% global): ${cost_with_provider_margin:.6f}") + print(f" - Margin added: ${cost_with_provider_margin - cost_without_margin:.6f}") + + +def test_cost_margin_with_discount(): + """ + Test that margin is applied after discount (independent calculation) + """ + from litellm import completion_cost + from litellm.types.utils import Usage + + # Save original configs + original_margin_config = litellm.cost_margin_config.copy() + original_discount_config = litellm.cost_discount_config.copy() + + # Create mock response + response = ModelResponse( + id="test-id", + choices=[], + created=1234567890, + model="gpt-4", + object="chat.completion", + usage=Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150), + ) + + # Calculate base cost + litellm.cost_margin_config = {} + litellm.cost_discount_config = {} + base_cost = completion_cost( + completion_response=response, + model="gpt-4", + custom_llm_provider="openai", + ) + + # Set 5% discount and 10% margin + litellm.cost_discount_config = {"openai": 0.05} + litellm.cost_margin_config = {"openai": 0.10} + + # Calculate cost with both discount and margin + cost_with_both = completion_cost( + completion_response=response, + model="gpt-4", + custom_llm_provider="openai", + ) + + # Restore original configs + litellm.cost_margin_config = original_margin_config + litellm.cost_discount_config = original_discount_config + + # Verify: discount applied first, then margin + # Base cost -> discount: base * 0.95 -> margin: (base * 0.95) * 1.10 + expected_cost = base_cost * 0.95 * 1.10 + assert cost_with_both == pytest.approx(expected_cost, rel=1e-9) + + print(f"✓ Cost margin with discount test passed:") + print(f" - Base cost: ${base_cost:.6f}") + print(f" - Cost with 5% discount + 10% margin: ${cost_with_both:.6f}") + print(f" - Expected: ${expected_cost:.6f}") + + def test_azure_image_generation_cost_calculator(): from unittest.mock import MagicMock @@ -855,7 +1166,7 @@ def test_azure_image_generation_cost_calculator(): ImageObject( b64_json=None, revised_prompt="A futuristic, techno-inspired green duck wearing cool modern sunglasses. The duck has a sleek, metallic appearance with glowing neon green accents, standing on a high-tech urban background with holographic billboards and illuminated city lights in the distance. The duck's feathers have a glossy, high-tech sheen, resembling a robotic design but still maintaining its avian features. The scene has a vibrant, cyberpunk aesthetic with a neon color palette.", - url="https://dalleprodsec.blob.core.windows.net/private/images/caa17dc4-357d-4257-8938-eeea9baa8d0a/generated_00.png?se=2025-10-31T00%3A47%3A59Z&sig=KHRjLz3vMahbw94JtxL02S6t2AueeRMaiqj4z35HKDM%3D&ske=2025-11-05T00%3A26%3A20Z&skoid=e52d5ed7-0657-4f62-bc12-7e5dbb260a96&sks=b&skt=2025-10-29T00%3A26%3A20Z&sktid=33e01921-4d64-4f8c-a055-5bdaffd5e33d&skv=2020-10-02&sp=r&spr=https&sr=b&sv=2020-10-02", + url="test-azure-blob-url-with-sas-token", ) ], output_format=None, diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 285dd23e5a2..cd76c438ded 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -871,8 +871,6 @@ SKIP_MODELS = [ "jamba", "deepinfra", "mistral.", - "groq/llama-guard-3-8b", - "groq/gemma2-9b-it", ] # Bedrock models to block - organized by type diff --git a/tests/test_spend_logs.py b/tests/test_spend_logs.py index 80dd8c9bcca..8aec1d5cc60 100644 --- a/tests/test_spend_logs.py +++ b/tests/test_spend_logs.py @@ -198,7 +198,7 @@ async def get_predict_spend_logs(session): { "date": "2024-03-09", "spend": 200000, - "api_key": "f19bdeb945164278fc11c1020d8dfd70465bffd931ed3cb2e1efa6326225b8b7", + "api_key": "sk-test-mock-api-key-456", } ] } diff --git a/tests/unified_google_tests/base_interactions_test.py b/tests/unified_google_tests/base_interactions_test.py new file mode 100644 index 00000000000..0a07fe87fa5 --- /dev/null +++ b/tests/unified_google_tests/base_interactions_test.py @@ -0,0 +1,113 @@ +""" +Abstract base class for Interactions API tests. + +This class provides common test cases that can be inherited by provider-specific +test classes. Subclasses must implement get_model() and get_api_key(). +""" + +import os +from abc import ABC, abstractmethod + +import pytest +import litellm +import litellm.interactions as interactions + + +class BaseInteractionsTest(ABC): + """Abstract base class for interactions API tests. + + Subclasses must implement get_model() and get_api_key(). + All test methods are inherited and run against the specific provider. + """ + + @abstractmethod + def get_model(self) -> str: + """Return the model string for this provider.""" + pass + + @abstractmethod + def get_api_key(self) -> str: + """Return the API key for this provider.""" + pass + + def test_create_simple_string_input(self): + """Test creating an interaction with a simple string input.""" + litellm._turn_on_debug() + api_key = self.get_api_key() + if not api_key: + pytest.skip(f"API key not set for {self.__class__.__name__}") + + response = interactions.create( + model=self.get_model(), + input="Hello, what is 2 + 2?", + api_key=api_key, + ) + assert response is not None + assert response.id is not None or response.status is not None + + # Check outputs per OpenAPI spec + if response.outputs: + assert len(response.outputs) > 0 + + # Check usage per OpenAPI spec + # The spec defines: total_input_tokens, total_output_tokens + if response.usage: + # Usage is a dict in InteractionsAPIResponse + if isinstance(response.usage, dict): + assert response.usage.get("total_input_tokens") is not None or response.usage.get("total_output_tokens") is not None + else: + # If it's an object, check attributes + assert hasattr(response.usage, "total_input_tokens") or hasattr(response.usage, "total_output_tokens") + + def test_create_with_system_instruction(self): + """Test creating an interaction with system_instruction.""" + api_key = self.get_api_key() + if not api_key: + pytest.skip(f"API key not set for {self.__class__.__name__}") + + response = interactions.create( + model=self.get_model(), + input="What are you?", + system_instruction="You are a helpful pirate assistant. Always respond like a pirate.", + api_key=api_key, + ) + assert response is not None + # Verify the response reflects the system instruction + if response.outputs: + assert len(response.outputs) > 0 + + def test_create_streaming(self): + """Test creating a streaming interaction.""" + api_key = self.get_api_key() + if not api_key: + pytest.skip(f"API key not set for {self.__class__.__name__}") + + response_stream = interactions.create( + model=self.get_model(), + input="Count from 1 to 3.", + stream=True, + api_key=api_key, + ) + + # Collect all chunks + chunks = [] + for chunk in response_stream: + chunks.append(chunk) + + assert len(chunks) > 0 + + @pytest.mark.asyncio + async def test_acreate_simple(self): + """Test async interaction creation.""" + api_key = self.get_api_key() + if not api_key: + pytest.skip(f"API key not set for {self.__class__.__name__}") + + response = await interactions.acreate( + model=self.get_model(), + input="What is the speed of light?", + api_key=api_key, + ) + assert response is not None + assert response.id is not None or response.status is not None + diff --git a/tests/unified_google_tests/test_gemini_interactions.py b/tests/unified_google_tests/test_gemini_interactions.py new file mode 100644 index 00000000000..eb1e104d80f --- /dev/null +++ b/tests/unified_google_tests/test_gemini_interactions.py @@ -0,0 +1,24 @@ +""" +Tests for Gemini Interactions API. + +Inherits from BaseInteractionsTest to run the same test suite against Gemini. +""" + +import os + +from tests.unified_google_tests.base_interactions_test import ( + BaseInteractionsTest, +) + + +class TestGeminiInteractions(BaseInteractionsTest): + """Test Gemini Interactions API using the base test suite.""" + + def get_model(self) -> str: + """Return the Gemini model string.""" + return "gemini/gemini-2.5-flash" + + def get_api_key(self) -> str: + """Return the Gemini API key from environment.""" + return os.getenv("GEMINI_API_KEY", "") + diff --git a/tests/unified_google_tests/test_litellm_responses_bridge.py b/tests/unified_google_tests/test_litellm_responses_bridge.py new file mode 100644 index 00000000000..3c1342f650c --- /dev/null +++ b/tests/unified_google_tests/test_litellm_responses_bridge.py @@ -0,0 +1,29 @@ +""" +Tests for LiteLLM Responses bridge provider. + +Inherits from BaseInteractionsTest to run the same test suite against +the litellm_responses bridge provider, which calls litellm.responses() internally. +""" + +import os + +from tests.unified_google_tests.base_interactions_test import ( + BaseInteractionsTest, +) + + +class TestLiteLLMResponsesBridge(BaseInteractionsTest): + """Test LiteLLM Responses bridge using the base test suite.""" + + def get_model(self) -> str: + """Return the model string for the bridge provider. + + The bridge provider uses litellm.responses() internally, so we can + use any model that litellm.responses() supports (e.g., gpt-4o). + """ + return "gpt-4o" + + def get_api_key(self) -> str: + """Return the OpenAI API key from environment.""" + return os.getenv("OPENAI_API_KEY", "") + diff --git a/ui/litellm-dashboard/e2e_tests/fixtures/roles.ts b/ui/litellm-dashboard/e2e_tests/fixtures/roles.ts new file mode 100644 index 00000000000..913230ad44b --- /dev/null +++ b/ui/litellm-dashboard/e2e_tests/fixtures/roles.ts @@ -0,0 +1,6 @@ +export enum Role { + ProxyAdmin = "proxy_admin", + ProxyAdminViewer = "proxy_admin_viewer", + InternalUser = "internal_user", + InternalUserViewer = "internal_user_viewer", +} diff --git a/ui/litellm-dashboard/e2e_tests/fixtures/users.ts b/ui/litellm-dashboard/e2e_tests/fixtures/users.ts new file mode 100644 index 00000000000..d1f1eab00e5 --- /dev/null +++ b/ui/litellm-dashboard/e2e_tests/fixtures/users.ts @@ -0,0 +1,10 @@ +import { Role } from "./roles"; + +const isCI = !!process.env.CI; + +export const users = { + [Role.ProxyAdmin]: { + email: "admin", + password: isCI ? "gm" : "sk-1234", + }, +}; diff --git a/ui/litellm-dashboard/e2e_tests/globalSetup.ts b/ui/litellm-dashboard/e2e_tests/globalSetup.ts new file mode 100644 index 00000000000..a725c58f35b --- /dev/null +++ b/ui/litellm-dashboard/e2e_tests/globalSetup.ts @@ -0,0 +1,18 @@ +import { chromium } from "@playwright/test"; +import { users } from "./fixtures/users"; +import { Role } from "./fixtures/roles"; + +async function globalSetup() { + const browser = await chromium.launch(); + const page = await browser.newPage(); + await page.goto("http://localhost:4000/ui/login"); + await page.getByPlaceholder("Enter your username").fill(users[Role.ProxyAdmin].email); + await page.getByPlaceholder("Enter your password").fill(users[Role.ProxyAdmin].password); + const loginButton = page.getByRole("button", { name: "Login" }); + await loginButton.click(); + await page.waitForSelector("text=AI Gateway"); + await page.context().storageState({ path: "admin.storageState.json" }); + await browser.close(); +} + +export default globalSetup; diff --git a/ui/litellm-dashboard/e2e_tests/playwright.config.ts b/ui/litellm-dashboard/e2e_tests/playwright.config.ts new file mode 100644 index 00000000000..329bb7f7afc --- /dev/null +++ b/ui/litellm-dashboard/e2e_tests/playwright.config.ts @@ -0,0 +1,48 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: ".", + testMatch: ["**/*.spec.ts", "**/*.setup.ts"], + testIgnore: ["**/*.test.*"], + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "html", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: "http://localhost:4000", + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + ], + + /* Timeout settings */ + timeout: 4 * 60 * 1000, + expect: { + timeout: 10 * 1000, + }, + globalSetup: require.resolve("./globalSetup"), +}); diff --git a/ui/litellm-dashboard/e2e_tests/tests/auth/unauthenticatedRedirect.spec.ts b/ui/litellm-dashboard/e2e_tests/tests/auth/unauthenticatedRedirect.spec.ts new file mode 100644 index 00000000000..d8cc26f8642 --- /dev/null +++ b/ui/litellm-dashboard/e2e_tests/tests/auth/unauthenticatedRedirect.spec.ts @@ -0,0 +1,11 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Authentication Checks", () => { + test("should redirect unauthenticated user from a protected page", async ({ page }) => { + const protectedPageUrl = "http://localhost:4000/ui?page=llm-playground"; + const expectedRedirectUrl = "http://localhost:4000/ui/login/"; + await page.goto(protectedPageUrl, { waitUntil: "domcontentloaded" }); + await expect(page).toHaveURL(expectedRedirectUrl); + await expect(page.getByRole("heading", { name: "Login" })).toBeVisible(); + }); +}); diff --git a/ui/litellm-dashboard/e2e_tests/tests/login/login.spec.ts b/ui/litellm-dashboard/e2e_tests/tests/login/login.spec.ts new file mode 100644 index 00000000000..5ac977ff0c8 --- /dev/null +++ b/ui/litellm-dashboard/e2e_tests/tests/login/login.spec.ts @@ -0,0 +1,13 @@ +import { expect, test } from "@playwright/test"; +import { users } from "../../fixtures/users"; +import { Role } from "../../fixtures/roles"; + +test("user can log in", async ({ page }) => { + await page.goto("http://localhost:4000/ui/login"); + await page.getByPlaceholder("Enter your username").fill(users[Role.ProxyAdmin].email); + await page.getByPlaceholder("Enter your password").fill(users[Role.ProxyAdmin].password); + const loginButton = page.getByRole("button", { name: "Login" }); + await expect(loginButton).toBeEnabled(); + await loginButton.click(); + await expect(page.getByText("AI Gateway")).toBeVisible(); +}); diff --git a/ui/litellm-dashboard/e2e_tests/tests/navigation/sidebar.spec.ts b/ui/litellm-dashboard/e2e_tests/tests/navigation/sidebar.spec.ts new file mode 100644 index 00000000000..dafb03a7cbd --- /dev/null +++ b/ui/litellm-dashboard/e2e_tests/tests/navigation/sidebar.spec.ts @@ -0,0 +1,34 @@ +import test, { expect } from "@playwright/test"; +import { Role } from "../../fixtures/roles"; + +const sidebarButtons = { + [Role.ProxyAdmin]: [ + "Virtual Keys", + "Playground", + "Models", + "Usage", + "Teams", + "Internal User", + "Settings", + "Experimental", + "API Reference", + "AI Hub", + ], +}; + +const roles = [{ role: Role.ProxyAdmin, storage: "admin.storageState.json" }]; + +for (const { role, storage } of roles) { + test.describe(`${role} sidebar`, () => { + test.use({ storageState: storage }); + + test("can see and navigate all sidebar buttons", async ({ page }) => { + await page.goto("http://localhost:4000/ui"); + for (const button of sidebarButtons[role as keyof typeof sidebarButtons]) { + const tab = page.getByRole("menuitem", { name: button }); + await expect(tab).toBeVisible(); + await tab.click(); + } + }); + }); +} diff --git a/ui/litellm-dashboard/e2e_tests/tests/users/searchUsers.spec.ts b/ui/litellm-dashboard/e2e_tests/tests/users/searchUsers.spec.ts new file mode 100644 index 00000000000..5873bb3125c --- /dev/null +++ b/ui/litellm-dashboard/e2e_tests/tests/users/searchUsers.spec.ts @@ -0,0 +1,90 @@ +import { test, expect, Page } from "@playwright/test"; +test.describe("Internal Users Search", () => { + test.use({ storageState: "admin.storageState.json" }); + + async function goToInternalUsers(page: Page) { + await page.goto("http://localhost:4000/ui"); + + const tab = page.getByRole("menuitem", { name: "Internal User" }); + await expect(tab).toBeVisible(); + await tab.click(); + + await expect(page.locator("tbody tr").first()).toBeVisible(); + await expect(page.locator(".ant-skeleton")).toHaveCount(0); + } + + test("can search users by email", async ({ page }) => { + await goToInternalUsers(page); + + const rows = page.locator("tbody tr"); + const searchInput = page.getByPlaceholder("Search by email..."); + + await expect(searchInput).toBeVisible(); + + // Ensure initial data is loaded + const initialCount = await rows.count(); + expect(initialCount).toBeGreaterThan(0); + + // 🔹 Apply filter + wait for backend response + await Promise.all([ + page.waitForResponse( + (res) => + res.url().includes("/user/list") && + res.url().includes("user_email=test%40") && // encoded "test@" + res.status() === 200, + ), + searchInput.fill("test@"), + ]); + await page.waitForTimeout(5000); + const filteredCount = await rows.count(); + await expect(filteredCount).toBeLessThan(initialCount); + + // 🔹 Clear filter + wait for unfiltered request + await Promise.all([ + page.waitForResponse( + (res) => res.url().includes("/user/list") && !res.url().includes("user_email=") && res.status() === 200, + ), + searchInput.clear(), + ]); + + const resetCount = await rows.count(); + await expect(resetCount).toBe(initialCount); + }); + + test("can filter users by user ID and SSO ID", async ({ page }) => { + await goToInternalUsers(page); + const rows = page.locator("tbody tr"); + + // Ensure initial data is loaded + const initialCount = await rows.count(); + expect(initialCount).toBeGreaterThan(0); + + const filtersButton = page.getByRole("button", { + name: "Filters", + exact: true, + }); + await filtersButton.click(); + + const userIdInput = page.getByPlaceholder("Filter by User ID"); + const ssoIdInput = page.getByPlaceholder("Filter by SSO ID"); + await Promise.all([ + page.waitForResponse( + (res) => res.url().includes("/user/list") && res.url().includes("user_ids=user") && res.status() === 200, + ), + userIdInput.fill("user"), + ]); + + await Promise.all([ + page.waitForResponse( + (res) => + res.url().includes("/user/list") && + res.url().includes("user_ids=user") && + res.url().includes("sso_user_ids=sso") && + res.status() === 200, + ), + ssoIdInput.fill("sso"), + ]); + const combinedFilteredCount = await rows.count(); + await expect(combinedFilteredCount).toBeLessThan(initialCount); + }); +}); diff --git a/ui/litellm-dashboard/e2e_tests/tests/users/viewInternalUsers.spec.ts b/ui/litellm-dashboard/e2e_tests/tests/users/viewInternalUsers.spec.ts new file mode 100644 index 00000000000..980c7233e42 --- /dev/null +++ b/ui/litellm-dashboard/e2e_tests/tests/users/viewInternalUsers.spec.ts @@ -0,0 +1,52 @@ +import { test, expect, Page } from "@playwright/test"; + +test.describe("Internal Users Page", () => { + test.use({ storageState: "admin.storageState.json" }); + + async function goToInternalUsers(page: Page) { + await page.goto("http://localhost:4000/ui"); + + const internalUserTab = page.getByRole("menuitem", { name: "Internal User" }); + await expect(internalUserTab).toBeVisible(); + await internalUserTab.click(); + + const firstRow = page.locator("tbody tr").first(); + await expect(firstRow).toBeVisible(); + await expect(page.locator(".ant-skeleton")).toHaveCount(0); + } + + test("renders internal users table correctly", async ({ page }) => { + await goToInternalUsers(page); + + const rows = page.locator("tbody tr"); + const rowCount = await rows.count(); + expect(rowCount).toBeGreaterThan(0); + + const userIdHeader = page.getByRole("columnheader", { name: "User ID" }); + await expect(userIdHeader).toBeVisible(); + + const virtualKeysHeader = page.getByRole("columnheader", { name: "Virtual Keys" }); + await expect(virtualKeysHeader).toBeVisible(); + }); + + test("pagination controls work correctly", async ({ page }) => { + await goToInternalUsers(page); + + const paginationInfo = page.locator(".text-sm.text-gray-700"); + const prevButton = page.getByRole("button", { name: "Previous" }); + const nextButton = page.getByRole("button", { name: "Next" }); + + const infoText = (await paginationInfo.textContent()) || ""; + + // On first page, Previous should be disabled + if (infoText.includes("1 -")) { + await expect(prevButton).toBeDisabled(); + } + + // Check if there are more pages + const hasMorePages = infoText.includes("of") && !infoText.endsWith("25 of 25"); + if (hasMorePages) { + await expect(nextButton).toBeEnabled(); + } + }); +}); diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index b65c35e78d8..cc21b516e24 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -39,6 +39,7 @@ "uuid": "^11.1.0" }, "devDependencies": { + "@playwright/test": "^1.57.0", "@tailwindcss/forms": "^0.5.7", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.8.0", @@ -4927,6 +4928,22 @@ "node": ">=12.4.0" } }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", @@ -19219,6 +19236,53 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index ce42d0ba41a..a018a4e7a58 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -10,7 +10,9 @@ "test": "vitest", "test:watch": "vitest -w", "format": "prettier --write .", - "format:check": "prettier --check ." + "format:check": "prettier --check .", + "e2e": "playwright test --config e2e_tests/playwright.config.ts", + "e2e:ui": "playwright test --ui --config e2e_tests/playwright.config.ts" }, "dependencies": { "@anthropic-ai/sdk": "^0.54.0", @@ -44,6 +46,7 @@ "uuid": "^11.1.0" }, "devDependencies": { + "@playwright/test": "^1.57.0", "@tailwindcss/forms": "^0.5.7", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.8.0", diff --git a/ui/litellm-dashboard/public/assets/logos/sap.png b/ui/litellm-dashboard/public/assets/logos/sap.png new file mode 100644 index 00000000000..7d3c4604c4c Binary files /dev/null and b/ui/litellm-dashboard/public/assets/logos/sap.png differ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx index c522d4ce1e5..8b934e10779 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx @@ -1,4 +1,3 @@ -import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import Sidebar from "@/components/leftnav"; interface SidebarProviderProps { @@ -8,17 +7,7 @@ interface SidebarProviderProps { } const SidebarProvider = ({ setPage, defaultSelectedKey, sidebarCollapsed }: SidebarProviderProps) => { - const { accessToken, userRole } = useAuthorized(); - - return ( - - ); + return ; }; export default SidebarProvider; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/agents/useAgents.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/agents/useAgents.ts index f2b7e76777d..d30eb345a0b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/agents/useAgents.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/agents/useAgents.ts @@ -3,10 +3,12 @@ import { AgentsResponse } from "@/components/agents/types"; import { useQuery } from "@tanstack/react-query"; import { createQueryKeys } from "../common/queryKeysFactory"; import { all_admin_roles } from "@/utils/roles"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; const agentsKeys = createQueryKeys("agents"); -export const useAgents = (accessToken: string | null, userRole: string | null) => { +export const useAgents = () => { + const { accessToken, userRole } = useAuthorized(); return useQuery({ queryKey: agentsKeys.list({}), queryFn: async () => await getAgentsList(accessToken!), diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings.ts index 5ccbe244e60..96f5ab2f944 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings.ts @@ -17,19 +17,35 @@ const getCloudZeroSettings = async (accessToken: string): Promise ({})); - const errorMessage = - errorData?.error?.message || errorData?.message || errorData?.detail || "Failed to fetch CloudZero settings"; + let errorMessage = "Failed to fetch CloudZero settings"; + try { + const errorData = await response.json(); + // Handle different error response formats + if (typeof errorData === "object" && errorData !== null) { + errorMessage = + errorData?.error?.message || + errorData?.error || + errorData?.message || + errorData?.detail || + (typeof errorData?.error === "string" ? errorData.error : errorMessage); + } else if (typeof errorData === "string") { + errorMessage = errorData; + } + } catch { + // If JSON parsing fails, use the status text + errorMessage = response.statusText || errorMessage; + } throw new Error(errorMessage); } const data = await response.json(); + + // Check if settings are actually configured (all required fields are present) + if (!data || (!data.api_key_masked && !data.connection_id)) { + return null; + } + return data; }; @@ -77,9 +93,22 @@ const updateCloudZeroSettings = async (accessToken: string, params: UpdateParams }); if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - const errorMessage = - errorData?.error?.message || errorData?.message || errorData?.detail || "Failed to update CloudZero settings"; + let errorMessage = "Failed to update CloudZero settings"; + try { + const errorData = await response.json(); + if (typeof errorData === "object" && errorData !== null) { + errorMessage = + errorData?.error?.message || + errorData?.error || + errorData?.message || + errorData?.detail || + (typeof errorData?.error === "string" ? errorData.error : errorMessage); + } else if (typeof errorData === "string") { + errorMessage = errorData; + } + } catch { + errorMessage = response.statusText || errorMessage; + } throw new Error(errorMessage); } @@ -117,9 +146,22 @@ const deleteCloudZeroSettings = async (accessToken: string): Promise ({})); - const errorMessage = - errorData?.error?.message || errorData?.message || errorData?.detail || "Failed to delete CloudZero settings"; + let errorMessage = "Failed to delete CloudZero settings"; + try { + const errorData = await response.json(); + if (typeof errorData === "object" && errorData !== null) { + errorMessage = + errorData?.error?.message || + errorData?.error || + errorData?.message || + errorData?.detail || + (typeof errorData?.error === "string" ? errorData.error : errorMessage); + } else if (typeof errorData === "string") { + errorMessage = errorData; + } + } catch { + errorMessage = response.statusText || errorMessage; + } throw new Error(errorMessage); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/credentials/useCredentials.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/credentials/useCredentials.ts index aa0a6c2c9fb..e3266de4fbc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/credentials/useCredentials.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/credentials/useCredentials.ts @@ -1,10 +1,12 @@ import { credentialListCall, CredentialsResponse } from "@/components/networking"; import { useQuery } from "@tanstack/react-query"; import { createQueryKeys } from "../common/queryKeysFactory"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; const credentialsKeys = createQueryKeys("credentials"); -export const useCredentials = (accessToken: string | null) => { +export const useCredentials = () => { + const { accessToken } = useAuthorized(); return useQuery({ queryKey: credentialsKeys.list({}), queryFn: async () => await credentialListCall(accessToken!), diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useCustomers.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useCustomers.ts index 10cbedc04d3..d9f3e7cbb36 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useCustomers.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useCustomers.ts @@ -2,7 +2,7 @@ import { allEndUsersCall } from "@/components/networking"; import { useQuery } from "@tanstack/react-query"; import { createQueryKeys } from "../common/queryKeysFactory"; import { all_admin_roles } from "@/utils/roles"; - +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; const customersKeys = createQueryKeys("customers"); export interface Customer { @@ -32,10 +32,11 @@ export interface Customer { export type CustomersResponse = Customer[]; -export const useCustomers = (accessToken: string | null, userRole: string | null) => { +export const useCustomers = () => { + const { accessToken, userRole } = useAuthorized(); return useQuery({ queryKey: customersKeys.list({}), queryFn: async () => await allEndUsersCall(accessToken!), - enabled: Boolean(accessToken) && all_admin_roles.includes(userRole || ""), + enabled: Boolean(accessToken) && all_admin_roles.includes(userRole!), }); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrails.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrails.ts new file mode 100644 index 00000000000..9786b7fa359 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrails.ts @@ -0,0 +1,18 @@ +import { useQuery, UseQueryResult } from "@tanstack/react-query"; +import { createQueryKeys } from "../common/queryKeysFactory"; +import { getGuardrailsList } from "@/components/networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +const guardrailKeys = createQueryKeys("guardrails"); + +export const useGuardrails = (): UseQueryResult => { + const { accessToken, userId, userRole } = useAuthorized(); + return useQuery({ + queryKey: guardrailKeys.list({}), + queryFn: async () => { + const response = await getGuardrailsList(accessToken!); + return response.guardrails.map((g: { guardrail_name: string }) => g.guardrail_name); + }, + enabled: Boolean(accessToken && userId && userRole), + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts new file mode 100644 index 00000000000..8ae4d76ff5d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts @@ -0,0 +1,36 @@ +import { keepPreviousData, useQuery, UseQueryResult } from "@tanstack/react-query"; +import { createQueryKeys } from "../common/queryKeysFactory"; +import { keyListCall } from "@/components/networking"; +import { KeyResponse } from "@/components/key_team_helpers/key_list"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +const keyKeys = createQueryKeys("keys"); + +export interface KeysResponse { + keys: KeyResponse[]; + total_count: number; + current_page: number; + total_pages: number; +} + +export const useKeys = (page: number, pageSize: number): UseQueryResult => { + const { accessToken } = useAuthorized(); + + return useQuery({ + queryKey: keyKeys.list({ page, limit: pageSize }), + queryFn: async () => + await keyListCall( + accessToken!, + null, // organizationID + null, // teamID + null, // selectedKeyAlias + null, // userID + null, // keyHash + page, + pageSize, + ), + enabled: Boolean(accessToken), + staleTime: 30000, // 30 seconds + placeholderData: keepPreviousData, + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPAccessGroups.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPAccessGroups.ts new file mode 100644 index 00000000000..0e88b62b0f3 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPAccessGroups.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; +import { createQueryKeys } from "../common/queryKeysFactory"; +import { fetchMCPAccessGroups } from "@/components/networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +const mcpAccessGroupsKeys = createQueryKeys("mcpAccessGroups"); + +export const useMCPAccessGroups = () => { + const { accessToken } = useAuthorized(); + return useQuery({ + queryKey: mcpAccessGroupsKeys.list({}), + queryFn: async () => await fetchMCPAccessGroups(accessToken!), + enabled: Boolean(accessToken), + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerHealth.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerHealth.test.ts new file mode 100644 index 00000000000..be910acf7e4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerHealth.test.ts @@ -0,0 +1,127 @@ +/* @vitest-environment jsdom */ +import React from "react"; +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useMCPServerHealth } from "./useMCPServerHealth"; +import * as networking from "@/components/networking"; + +// Mock the networking module +vi.mock("@/components/networking", () => ({ + fetchMCPServerHealth: vi.fn(), +})); + +// Mock useAuthorized hook +vi.mock("../useAuthorized", () => ({ + default: vi.fn(() => ({ + accessToken: "test-token-123", + })), +})); + +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }); + +const wrapper = ({ children }: { children: React.ReactNode }) => { + const queryClient = createQueryClient(); + return React.createElement(QueryClientProvider, { client: queryClient }, children); +}; + +describe("useMCPServerHealth", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should fetch health status for given server IDs", async () => { + const mockHealthStatuses = [ + { server_id: "server-1", status: "healthy" }, + { server_id: "server-2", status: "unhealthy" }, + ]; + + vi.mocked(networking.fetchMCPServerHealth).mockResolvedValue(mockHealthStatuses); + + const { result } = renderHook(() => useMCPServerHealth(["server-1", "server-2"]), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(networking.fetchMCPServerHealth).toHaveBeenCalledWith("test-token-123", ["server-1", "server-2"]); + expect(result.current.data).toEqual(mockHealthStatuses); + }); + + it("should fetch health status for all servers when no server IDs provided", async () => { + const mockHealthStatuses = [ + { server_id: "server-1", status: "healthy" }, + { server_id: "server-2", status: "healthy" }, + { server_id: "server-3", status: "unhealthy" }, + ]; + + vi.mocked(networking.fetchMCPServerHealth).mockResolvedValue(mockHealthStatuses); + + const { result } = renderHook(() => useMCPServerHealth(), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(networking.fetchMCPServerHealth).toHaveBeenCalledWith("test-token-123", undefined); + expect(result.current.data).toEqual(mockHealthStatuses); + }); + + it("should handle empty server list", async () => { + vi.mocked(networking.fetchMCPServerHealth).mockResolvedValue([]); + + const { result } = renderHook(() => useMCPServerHealth([]), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(networking.fetchMCPServerHealth).toHaveBeenCalledWith("test-token-123", []); + expect(result.current.data).toEqual([]); + }); + + it("should handle errors when fetching health status", async () => { + const mockError = new Error("Failed to fetch health status"); + vi.mocked(networking.fetchMCPServerHealth).mockRejectedValue(mockError); + + const { result } = renderHook(() => useMCPServerHealth(["server-1"]), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(mockError); + }); + + it("should not fetch when accessToken is not available", async () => { + // Mock useAuthorized to return no token + const useAuthorizedModule = await import("../useAuthorized"); + vi.mocked(useAuthorizedModule.default).mockReturnValue({ + accessToken: null, + } as any); + + const { result } = renderHook(() => useMCPServerHealth(["server-1"]), { + wrapper, + }); + + // Should remain in idle state since query is not enabled + expect(result.current.status).toBe("pending"); + expect(networking.fetchMCPServerHealth).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerHealth.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerHealth.ts new file mode 100644 index 00000000000..95d7f3bcee0 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerHealth.ts @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; +import { createQueryKeys } from "../common/queryKeysFactory"; +import { fetchMCPServerHealth } from "@/components/networking"; +import useAuthorized from "../useAuthorized"; + +const mcpServerHealthKeys = createQueryKeys("mcpServerHealth"); + +interface MCPServerHealth { + server_id: string; + status: string; +} + +export const useMCPServerHealth = (serverIds?: string[]) => { + const { accessToken } = useAuthorized(); + return useQuery({ + queryKey: [...mcpServerHealthKeys.lists(), { serverIds }], + queryFn: async () => await fetchMCPServerHealth(accessToken!, serverIds), + enabled: !!accessToken, + // Refetch health status every 30 seconds to keep it up to date + refetchInterval: 30000, + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServers.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServers.ts new file mode 100644 index 00000000000..8746baae148 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServers.ts @@ -0,0 +1,16 @@ +import { useQuery } from "@tanstack/react-query"; +import { createQueryKeys } from "../common/queryKeysFactory"; +import { fetchMCPServers } from "@/components/networking"; +import { MCPServer } from "@/components/mcp_tools/types"; +import useAuthorized from "../useAuthorized"; + +const mcpServersKeys = createQueryKeys("mcpServers"); + +export const useMCPServers = () => { + const { accessToken } = useAuthorized(); + return useQuery({ + queryKey: mcpServersKeys.list({}), + queryFn: async () => await fetchMCPServers(accessToken!), + enabled: !!accessToken, + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts index aef05b1af2a..9c7ddf18f54 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts @@ -1,24 +1,26 @@ import { useQuery } from "@tanstack/react-query"; import { createQueryKeys } from "../common/queryKeysFactory"; import { modelInfoCall, modelHubCall } from "@/components/networking"; - +import useAuthorized from "../useAuthorized"; const modelKeys = createQueryKeys("models"); const modelHubKeys = createQueryKeys("modelHub"); -export const useModelsInfo = (accessToken: string | null, userID: string | null, userRole: string | null) => { +export const useModelsInfo = () => { + const { accessToken, userId, userRole } = useAuthorized(); return useQuery({ queryKey: modelKeys.list({ filters: { - ...(userID && { userID }), + ...(userId && { userId }), ...(userRole && { userRole }), }, }), - queryFn: async () => await modelInfoCall(accessToken!, userID!, userRole!), - enabled: Boolean(accessToken && userID && userRole), + queryFn: async () => await modelInfoCall(accessToken!, userId!, userRole!), + enabled: Boolean(accessToken && userId && userRole), }); }; -export const useModelHub = (accessToken: string | null) => { +export const useModelHub = () => { + const { accessToken } = useAuthorized(); return useQuery({ queryKey: modelHubKeys.list({}), queryFn: async () => await modelHubCall(accessToken!), diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/organizations/useOrganizations.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/organizations/useOrganizations.ts new file mode 100644 index 00000000000..57c9c057652 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/organizations/useOrganizations.ts @@ -0,0 +1,16 @@ +import { useQuery, UseQueryResult } from "@tanstack/react-query"; +import { createQueryKeys } from "../common/queryKeysFactory"; +import { organizationListCall, Organization } from "@/components/networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +const organizationKeys = createQueryKeys("organizations"); + +export const useOrganizations = (): UseQueryResult => { + const { accessToken } = useAuthorized(); + const { userId, userRole } = useAuthorized(); + return useQuery({ + queryKey: organizationKeys.list({}), + queryFn: async () => await organizationListCall(accessToken!), + enabled: Boolean(accessToken && userId && userRole), + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/tags/useTags.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/tags/useTags.ts new file mode 100644 index 00000000000..8f82502a74c --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/tags/useTags.ts @@ -0,0 +1,16 @@ +import { useQuery, UseQueryResult } from "@tanstack/react-query"; +import { createQueryKeys } from "../common/queryKeysFactory"; +import { tagListCall } from "@/components/networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { TagListResponse } from "@/components/tag_management/types"; + +const tagKeys = createQueryKeys("tags"); + +export const useTags = (): UseQueryResult => { + const { accessToken, userId, userRole } = useAuthorized(); + return useQuery({ + queryKey: tagKeys.list({}), + queryFn: async () => await tagListCall(accessToken!), + enabled: Boolean(accessToken && userId && userRole), + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts new file mode 100644 index 00000000000..5d2008a4d29 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts @@ -0,0 +1,17 @@ +import { useQuery, UseQueryResult } from "@tanstack/react-query"; +import { Team } from "@/components/key_team_helpers/key_list"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { fetchTeams } from "@/app/(dashboard)/networking"; +import { createQueryKeys } from "@/app/(dashboard)/hooks/common/queryKeysFactory"; + +const teamKeys = createQueryKeys("teams"); + +export const useTeams = (): UseQueryResult => { + const { accessToken, userId, userRole } = useAuthorized(); + + return useQuery({ + queryKey: teamKeys.list({}), + queryFn: async () => await fetchTeams(accessToken!, userId, userRole, null), + enabled: Boolean(accessToken), + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts index 9198450a63d..3da27d3ff9b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts @@ -1,12 +1,18 @@ /* @vitest-environment jsdom */ -import { renderHook } from "@testing-library/react"; +import React from "react"; +import { renderHook, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import useAuthorized from "./useAuthorized"; -const { replaceMock, clearTokenCookiesMock, getProxyBaseUrlMock } = vi.hoisted(() => ({ +// Unmock useAuthorized to test the actual implementation +vi.unmock("@/app/(dashboard)/hooks/useAuthorized"); + +const { replaceMock, clearTokenCookiesMock, getProxyBaseUrlMock, getUiConfigMock } = vi.hoisted(() => ({ replaceMock: vi.fn(), clearTokenCookiesMock: vi.fn(), getProxyBaseUrlMock: vi.fn(() => "http://proxy.example"), + getUiConfigMock: vi.fn(), })); vi.mock("next/navigation", () => ({ @@ -15,9 +21,14 @@ vi.mock("next/navigation", () => ({ }), })); -vi.mock("@/components/networking", () => ({ - getProxyBaseUrl: getProxyBaseUrlMock, -})); +vi.mock("@/components/networking", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getProxyBaseUrl: getProxyBaseUrlMock, + getUiConfig: getUiConfigMock, + }; +}); vi.mock("@/utils/cookieUtils", async (importOriginal) => { const actual = await importOriginal(); @@ -27,6 +38,21 @@ vi.mock("@/utils/cookieUtils", async (importOriginal) => { }; }); +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }); + +const wrapper = ({ children }: { children: React.ReactNode }) => { + const queryClient = createQueryClient(); + return React.createElement(QueryClientProvider, { client: queryClient }, children); +}; + const createJwt = (payload: Record) => { const base64Url = btoa(JSON.stringify(payload)).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_"); return `eyJhbGciOiJub25lIn0.${base64Url}.signature`; @@ -41,10 +67,18 @@ describe("useAuthorized", () => { replaceMock.mockReset(); clearTokenCookiesMock.mockReset(); getProxyBaseUrlMock.mockClear(); + getUiConfigMock.mockReset(); clearCookie(); }); - it("should decode the token and expose user details", () => { + it("should decode the token and expose user details", async () => { + getUiConfigMock.mockResolvedValue({ + server_root_path: "/", + proxy_base_url: null, + auto_redirect_to_sso: false, + admin_ui_disabled: false, + }); + const token = createJwt({ key: "api-key-123", user_id: "user-1", @@ -56,9 +90,12 @@ describe("useAuthorized", () => { }); document.cookie = `token=${token}; path=/;`; - const { result } = renderHook(() => useAuthorized()); + const { result } = renderHook(() => useAuthorized(), { wrapper }); + + await waitFor(() => { + expect(result.current.token).toBe(token); + }); - expect(result.current.token).toBe(token); expect(result.current.accessToken).toBe("api-key-123"); expect(result.current.userId).toBe("user-1"); expect(result.current.userEmail).toBe("user@example.com"); @@ -69,14 +106,54 @@ describe("useAuthorized", () => { expect(replaceMock).not.toHaveBeenCalled(); }); - it("should clear cookies and redirect on an invalid token", () => { + it("should clear cookies and redirect on an invalid token", async () => { + getUiConfigMock.mockResolvedValue({ + server_root_path: "/", + proxy_base_url: null, + auto_redirect_to_sso: false, + admin_ui_disabled: false, + }); + document.cookie = "token=invalid-token; path=/;"; - const { result } = renderHook(() => useAuthorized()); + const { result } = renderHook(() => useAuthorized(), { wrapper }); + + await waitFor(() => { + expect(clearTokenCookiesMock).toHaveBeenCalled(); + }); - expect(clearTokenCookiesMock).toHaveBeenCalled(); expect(replaceMock).toHaveBeenCalledWith("http://proxy.example/ui/login"); expect(result.current.accessToken).toBeNull(); expect(result.current.userRole).toBe("Undefined Role"); }); + + it("should redirect even with valid token if admin_ui_disabled is true", async () => { + getUiConfigMock.mockResolvedValue({ + server_root_path: "/", + proxy_base_url: null, + auto_redirect_to_sso: false, + admin_ui_disabled: true, + }); + + const token = createJwt({ + key: "api-key-123", + user_id: "user-1", + user_email: "user@example.com", + user_role: "app_admin", + premium_user: true, + disabled_non_admin_personal_key_creation: false, + login_method: "username_password", + }); + document.cookie = `token=${token}; path=/;`; + + const { result } = renderHook(() => useAuthorized(), { wrapper }); + + await waitFor(() => { + expect(replaceMock).toHaveBeenCalledWith("http://proxy.example/ui/login"); + }); + + expect(result.current.accessToken).toBe("api-key-123"); + expect(result.current.userId).toBe("user-1"); + expect(result.current.userEmail).toBe("user@example.com"); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts index 7610c6346be..62d514f0668 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts @@ -1,10 +1,11 @@ "use client"; -import { useEffect, useMemo } from "react"; -import { useRouter } from "next/navigation"; -import { jwtDecode } from "jwt-decode"; -import { clearTokenCookies, getCookie } from "@/utils/cookieUtils"; import { getProxyBaseUrl } from "@/components/networking"; +import { clearTokenCookies, getCookie } from "@/utils/cookieUtils"; +import { jwtDecode } from "jwt-decode"; +import { useRouter } from "next/navigation"; +import { useEffect, useMemo } from "react"; +import { useUIConfig } from "./uiConfig/useUIConfig"; function formatUserRole(userRole: string) { if (!userRole) { @@ -37,15 +38,19 @@ function formatUserRole(userRole: string) { const useAuthorized = () => { const router = useRouter(); + const { data: uiConfig, isLoading: isUIConfigLoading } = useUIConfig(); const token = typeof document !== "undefined" ? getCookie("token") : null; // Redirect after mount if missing/invalid token useEffect(() => { - if (!token) { + if (isUIConfigLoading) { + return; + } + if (!token || uiConfig?.admin_ui_disabled) { router.replace(`${getProxyBaseUrl()}/ui/login`); } - }, [token, router]); + }, [token, router, isUIConfigLoading, uiConfig]); // Decode safely const decoded = useMemo(() => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useTeams.tsx b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useTeams.tsx index 64cbf624f9c..0b3768505f5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useTeams.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useTeams.tsx @@ -3,6 +3,10 @@ import { Team } from "@/components/key_team_helpers/key_list"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { fetchTeams } from "@/app/(dashboard)/networking"; +/** + * @deprecated This hook is deprecated. Use the react-query implementation from `@/app/(dashboard)/hooks/teams/useTeams` instead. + * This version will be removed in a future release. + */ const useTeams = () => { const [teams, setTeams] = useState([]); const { accessToken, userId: userID, userRole } = useAuthorized(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.test.tsx index 428f52dd98c..8dc7d1ff3d6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.test.tsx @@ -37,19 +37,6 @@ vi.mock("@/app/(dashboard)/models-and-endpoints/components/ModelAnalyticsTab/Mod default: () => null, })); -vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ - default: () => ({ - token: "123", - accessToken: "123", - userId: "user-1", - userEmail: "user@example.com", - userRole: "Admin", - premiumUser: false, - disabledPersonalKeyCreation: null, - showSSOBanner: false, - }), -})); - vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({ default: () => ({ teams: [], diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx index 4b71554ce22..4b62ce8cf88 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx @@ -152,12 +152,8 @@ const ModelsAndEndpointsView: React.FC = ({ const [selectedTabIndex, setSelectedTabIndex] = useState(0); const queryClient = useQueryClient(); - const { - data: modelDataResponse, - isLoading: isLoadingModels, - refetch: refetchModels, - } = useModelsInfo(accessToken, userID, userRole); - const { data: credentialsResponse } = useCredentials(accessToken); + const { data: modelDataResponse, isLoading: isLoadingModels, refetch: refetchModels } = useModelsInfo(); + const { data: credentialsResponse } = useCredentials(); const credentialsList = credentialsResponse?.credentials || []; const { data: uiSettings } = useUISettings(accessToken || ""); @@ -540,21 +536,19 @@ const ModelsAndEndpointsView: React.FC = ({ ); }; - const handleOk = () => { - addModelForm - .validateFields() - .then((values: any) => { - handleAddModelSubmit(values, accessToken, addModelForm, handleRefreshClick); - }) - .catch((error: any) => { - const errorMessages = - error.errorFields - ?.map((field: any) => { - return `${field.name.join(".")}: ${field.errors.join(", ")}`; - }) - .join(" | ") || "Unknown validation error"; - NotificationsManager.fromBackend(`Please fill in the following required fields: ${errorMessages}`); - }); + const handleOk = async () => { + try { + const values = await addModelForm.validateFields(); + await handleAddModelSubmit(values, accessToken, addModelForm, handleRefreshClick); + } catch (error: any) { + const errorMessages = + error.errorFields + ?.map((field: any) => { + return `${field.name.join(".")}: ${field.errors.join(", ")}`; + }) + .join(" | ") || "Unknown validation error"; + NotificationsManager.fromBackend(`Please fill in the following required fields: ${errorMessages}`); + } }; Object.keys(Providers).find((key) => (Providers as { [index: string]: any })[key] === selectedProvider); @@ -665,7 +659,6 @@ const ModelsAndEndpointsView: React.FC = ({ setSelectedModelId={setSelectedModelId} setSelectedTeamId={setSelectedTeamId} setEditModel={setEditModel} - modelData={modelData} /> {!shouldHideAddModelTab && ( @@ -684,7 +677,6 @@ const ModelsAndEndpointsView: React.FC = ({ credentials={credentialsList} accessToken={accessToken} userRole={userRole} - premiumUser={premiumUser} /> )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx index a4bb20128e0..dfa400e6ea9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx @@ -1,9 +1,27 @@ import * as useAuthorizedModule from "@/app/(dashboard)/hooks/useAuthorized"; -import * as useTeamsModule from "@/app/(dashboard)/hooks/useTeams"; import { render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import AllModelsTab from "./AllModelsTab"; +// Mock the useModelsInfo hook +const mockUseModelsInfo = vi.fn(() => ({ data: { data: [] } })) as any; + +vi.mock("../../hooks/models/useModels", () => ({ + useModelsInfo: () => mockUseModelsInfo(), +})); + +// Mock the useTeams hook (react-query implementation) +const mockUseTeams = vi.fn(() => ({ + data: [], + isLoading: false, + error: null, + refetch: vi.fn(), +})) as any; + +vi.mock("../../hooks/teams/useTeams", () => ({ + useTeams: () => mockUseTeams(), +})); + describe("AllModelsTab", () => { const mockSetSelectedModelGroup = vi.fn(); const mockSetSelectedModelId = vi.fn(); @@ -18,9 +36,6 @@ describe("AllModelsTab", () => { setSelectedModelId: mockSetSelectedModelId, setSelectedTeamId: mockSetSelectedTeamId, setEditModel: mockSetEditModel, - modelData: { - data: [], - }, }; const mockUseAuthorized = { @@ -40,9 +55,13 @@ describe("AllModelsTab", () => { }); it("should render with empty data", () => { - vi.spyOn(useTeamsModule, "default").mockReturnValue({ - teams: [], - setTeams: vi.fn(), + mockUseModelsInfo.mockReturnValueOnce({ data: { data: [] } }); + + mockUseTeams.mockReturnValueOnce({ + data: [], + isLoading: false, + error: null, + refetch: vi.fn(), }); render(); @@ -66,9 +85,11 @@ describe("AllModelsTab", () => { }, ]; - vi.spyOn(useTeamsModule, "default").mockReturnValue({ - teams: mockTeams, - setTeams: vi.fn(), + mockUseTeams.mockReturnValueOnce({ + data: mockTeams, + isLoading: false, + error: null, + refetch: vi.fn(), }); const modelData = { @@ -92,7 +113,9 @@ describe("AllModelsTab", () => { ], }; - render(); + mockUseModelsInfo.mockReturnValue({ data: modelData }); + + render(); await waitFor(() => { expect(screen.getByText("Showing 0 results")).toBeInTheDocument(); @@ -116,9 +139,11 @@ describe("AllModelsTab", () => { }, ]; - vi.spyOn(useTeamsModule, "default").mockReturnValue({ - teams: mockTeams, - setTeams: vi.fn(), + mockUseTeams.mockReturnValue({ + data: mockTeams, + isLoading: false, + error: null, + refetch: vi.fn(), }); const modelData = { @@ -142,7 +167,9 @@ describe("AllModelsTab", () => { ], }; - render(); + mockUseModelsInfo.mockReturnValue({ data: modelData }); + + render(); await waitFor(() => { expect(screen.getByText("Showing 0 results")).toBeInTheDocument(); @@ -150,9 +177,11 @@ describe("AllModelsTab", () => { }); it("should filter models by direct_access for personal team", async () => { - vi.spyOn(useTeamsModule, "default").mockReturnValue({ - teams: [], - setTeams: vi.fn(), + mockUseTeams.mockReturnValue({ + data: [], + isLoading: false, + error: null, + refetch: vi.fn(), }); const modelData = { @@ -178,7 +207,9 @@ describe("AllModelsTab", () => { ], }; - render(); + mockUseModelsInfo.mockReturnValue({ data: modelData }); + + render(); await waitFor(() => { expect(screen.getByText("Showing 1 - 1 of 1 results")).toBeInTheDocument(); @@ -186,9 +217,11 @@ describe("AllModelsTab", () => { }); it("should show config model status for models defined in configs", async () => { - vi.spyOn(useTeamsModule, "default").mockReturnValue({ - teams: [], - setTeams: vi.fn(), + mockUseTeams.mockReturnValue({ + data: [], + isLoading: false, + error: null, + refetch: vi.fn(), }); const modelData = { @@ -226,7 +259,9 @@ describe("AllModelsTab", () => { ], }; - render(); + mockUseModelsInfo.mockReturnValue({ data: modelData }); + + render(); await waitFor(() => { expect(screen.getByText("Config Model")).toBeInTheDocument(); @@ -235,19 +270,21 @@ describe("AllModelsTab", () => { }); it("should show 'Defined in config' for models defined in configs", async () => { - vi.spyOn(useTeamsModule, "default").mockReturnValue({ - teams: [], - setTeams: vi.fn(), + mockUseTeams.mockReturnValue({ + data: [], + isLoading: false, + error: null, + refetch: vi.fn(), }); const modelData = { data: [ { - model_name: "gpt-4-config-model", - litellm_model_name: "gpt-4-config-model", + model_name: "gpt-4-config", + litellm_model_name: "gpt-4-config", provider: "openai", model_info: { - id: "model-config-defined", + id: "model-config-1", db_model: false, direct_access: true, access_via_team_ids: [], @@ -260,8 +297,12 @@ describe("AllModelsTab", () => { ], }; - render(); + mockUseModelsInfo.mockReturnValue({ data: modelData }); - expect(screen.getByText("Defined in config")).toBeInTheDocument(); + render(); + + await waitFor(() => { + expect(screen.getByText("Defined in config")).toBeInTheDocument(); + }); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx index 87fa0b1e3b6..04c05ede5c2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx @@ -1,13 +1,14 @@ +import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import useTeams from "@/app/(dashboard)/hooks/useTeams"; import { Team } from "@/components/key_team_helpers/key_list"; import { ModelDataTable } from "@/components/model_dashboard/table"; import { columns } from "@/components/molecules/models/columns"; import { getDisplayModelName } from "@/components/view_model/model_name_display"; import { InfoCircleOutlined } from "@ant-design/icons"; -import { PaginationState, Table as TableInstance } from "@tanstack/react-table"; +import { PaginationState } from "@tanstack/react-table"; import { Grid, Select, SelectItem, TabPanel, Text } from "@tremor/react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { useModelsInfo } from "../../hooks/models/useModels"; type ModelViewMode = "all" | "current_team"; @@ -19,7 +20,6 @@ interface AllModelsTabProps { setSelectedModelId: (id: string) => void; setSelectedTeamId: (id: string) => void; setEditModel: (edit: boolean) => void; - modelData: any; } const AllModelsTab = ({ @@ -30,10 +30,10 @@ const AllModelsTab = ({ setSelectedModelId, setSelectedTeamId, setEditModel, - modelData, }: AllModelsTabProps) => { + const { data: modelData } = useModelsInfo(); const { userId, userRole, premiumUser } = useAuthorized(); - const { teams } = useTeams(); + const { data: teams } = useTeams(); const [modelNameSearch, setModelNameSearch] = useState(""); const [modelViewMode, setModelViewMode] = useState("current_team"); @@ -45,7 +45,6 @@ const AllModelsTab = ({ pageIndex: 0, pageSize: 50, }); - const tableRef = useRef>(null); const filteredData = useMemo(() => { if (!modelData || !modelData.data || modelData.data.length === 0) { @@ -88,12 +87,6 @@ const AllModelsTab = ({ }); }, [modelData, modelNameSearch, selectedModelGroup, selectedModelAccessGroupFilter, currentTeam, modelViewMode]); - const paginatedData = useMemo(() => { - const startIndex = pagination.pageIndex * pagination.pageSize; - const endIndex = startIndex + pagination.pageSize; - return filteredData.slice(startIndex, endIndex); - }, [filteredData, pagination.pageIndex, pagination.pageSize]); - useEffect(() => { setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 })); }, [modelNameSearch, selectedModelGroup, selectedModelAccessGroupFilter, currentTeam, modelViewMode]); @@ -370,9 +363,11 @@ const AllModelsTab = ({ expandedRows, setExpandedRows, )} - data={paginatedData} + data={filteredData} isLoading={false} - table={tableRef} + pagination={pagination} + onPaginationChange={setPagination} + enablePagination={true} /> diff --git a/ui/litellm-dashboard/src/app/login/LoginPage.test.tsx b/ui/litellm-dashboard/src/app/login/LoginPage.test.tsx index cce063eceb7..79834512605 100644 --- a/ui/litellm-dashboard/src/app/login/LoginPage.test.tsx +++ b/ui/litellm-dashboard/src/app/login/LoginPage.test.tsx @@ -169,4 +169,27 @@ describe("LoginPage", () => { expect(mockPush).not.toHaveBeenCalled(); }); + + it("should show alert when admin_ui_disabled is true", async () => { + (useUIConfig as ReturnType).mockReturnValue({ + data: { admin_ui_disabled: true, server_root_path: "/", proxy_base_url: null }, + isLoading: false, + }); + (getCookie as ReturnType).mockReturnValue(null); + + const queryClient = createQueryClient(); + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.getByText("Admin UI Disabled")).toBeInTheDocument(); + }); + + expect(mockPush).not.toHaveBeenCalled(); + expect(mockReplace).not.toHaveBeenCalled(); + }); }); diff --git a/ui/litellm-dashboard/src/app/login/LoginPage.tsx b/ui/litellm-dashboard/src/app/login/LoginPage.tsx index 85f2c6dd870..620cb41dfee 100644 --- a/ui/litellm-dashboard/src/app/login/LoginPage.tsx +++ b/ui/litellm-dashboard/src/app/login/LoginPage.tsx @@ -25,6 +25,12 @@ function LoginPageContent() { return; } + // Check if admin UI is disabled + if (uiConfig && uiConfig.admin_ui_disabled) { + setIsLoading(false); + return; + } + const rawToken = getCookie("token"); if (rawToken && !isJwtExpired(rawToken)) { router.replace(`${getProxyBaseUrl()}/ui`); @@ -59,6 +65,38 @@ function LoginPageContent() { return ; } + // Show disabled message if admin UI is disabled + if (uiConfig && uiConfig.admin_ui_disabled) { + return ( +
+ + +
+ 🚅 LiteLLM +
+ + + + The Admin UI has been disabled by the administrator. To re-enable it, please update the following + environment variable: + + + DISABLE_ADMIN_UI=False + + + } + type="warning" + showIcon + /> +
+
+
+ ); + } + return (
diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCostTracking.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCostTracking.tsx index fbb892cb1d8..db3ea94bbf9 100644 --- a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCostTracking.tsx +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCostTracking.tsx @@ -36,7 +36,9 @@ export default function CloudZeroCostTracking() { if (error) { return ( - Error loading CloudZero settings: {error.message} + + Error loading CloudZero settings: {error instanceof Error ? error.message : String(error)} + ); } diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.test.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.test.tsx index 04e0a67dea6..f7b90884006 100644 --- a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.test.tsx +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.test.tsx @@ -9,6 +9,6 @@ describe("CloudZeroEmptyPlaceholder", () => { expect(screen.getByText("No CloudZero Integration Found")).toBeInTheDocument(); expect(screen.getByText(/Connect your CloudZero account/)).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Create Integration" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Add CloudZero Integration" })).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.tsx index 1719a949b86..aca074dc290 100644 --- a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.tsx +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.tsx @@ -21,7 +21,7 @@ export default function CloudZeroEmptyPlaceholder({ startCreation }: CloudZeroEm } >
diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroIntegrationSettings.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroIntegrationSettings.tsx index 780fa83652a..c161d241f7d 100644 --- a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroIntegrationSettings.tsx +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroIntegrationSettings.tsx @@ -134,10 +134,14 @@ export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: Cl }} > - {settings.api_key_masked} + + {settings.api_key_masked || Not configured} + - {settings.connection_id} + + {settings.connection_id || Not configured} + {settings.timezone || Default (UTC)} diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/types.ts b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/types.ts index a41afee4f72..ed3c76cc3b1 100644 --- a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/types.ts +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/types.ts @@ -1,6 +1,6 @@ export interface CloudZeroSettings { - api_key_masked: string; - connection_id: string; - timezone?: string; - status?: string; + api_key_masked: string | null; + connection_id: string | null; + timezone?: string | null; + status?: string | null; } diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/add_margin_form.tsx b/ui/litellm-dashboard/src/components/CostTrackingSettings/add_margin_form.tsx new file mode 100644 index 00000000000..8c6237a0c9b --- /dev/null +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/add_margin_form.tsx @@ -0,0 +1,202 @@ +import React from "react"; +import { TextInput, Button } from "@tremor/react"; +import { Select as AntdSelect, Form, Tooltip, Radio } from "antd"; +import { InfoCircleOutlined } from "@ant-design/icons"; +import { Providers, provider_map, providerLogoMap } from "../provider_info_helpers"; +import { MarginConfig } from "./types"; +import { handleImageError } from "./provider_display_helpers"; + +interface AddMarginFormProps { + marginConfig: MarginConfig; + selectedProvider: string | undefined; + marginType: "percentage" | "fixed"; + percentageValue: string; + fixedAmountValue: string; + onProviderChange: (provider: string | undefined) => void; + onMarginTypeChange: (type: "percentage" | "fixed") => void; + onPercentageChange: (value: string) => void; + onFixedAmountChange: (value: string) => void; + onAddProvider: () => void; +} + +const AddMarginForm: React.FC = ({ + marginConfig, + selectedProvider, + marginType, + percentageValue, + fixedAmountValue, + onProviderChange, + onMarginTypeChange, + onPercentageChange, + onFixedAmountChange, + onAddProvider, +}) => { + return ( +
+ + Provider + + + + + } + rules={[{ required: true, message: "Please select a provider" }]} + > + + String(option?.label ?? "").toLowerCase().includes(input.toLowerCase()) + } + > + +
+ Global (All Providers) +
+
+ {Object.entries(Providers).map(([providerEnum, providerDisplayName]) => { + const providerValue = provider_map[providerEnum as keyof typeof provider_map]; + // Only show providers that don't already have a margin configured + if (providerValue && marginConfig[providerValue]) { + return null; + } + return ( + +
+ {`${providerEnum} handleImageError(e, providerDisplayName)} + /> + {providerDisplayName} +
+
+ ); + })} +
+
+ + + Margin Type + + + + + } + rules={[{ required: true, message: "Please select a margin type" }]} + > + onMarginTypeChange(e.target.value)} + className="w-full" + > + Percentage-based + Fixed Amount + + + + {marginType === "percentage" && ( + + Margin Percentage + + + + + } + rules={[ + { required: true, message: "Please enter a margin percentage" }, + { + validator: (_, value) => { + if (!value) { + return Promise.reject(new Error("Please enter a margin percentage")); + } + const numValue = parseFloat(value); + if (isNaN(numValue) || numValue < 0 || numValue > 1000) { + return Promise.reject(new Error("Percentage must be between 0 and 1000")); + } + return Promise.resolve(); + }, + }, + ]} + > +
+ + % +
+
+ )} + + {marginType === "fixed" && ( + + Fixed Margin Amount + + + + + } + rules={[ + { required: true, message: "Please enter a fixed amount" }, + { + validator: (_, value) => { + if (!value) { + return Promise.reject(new Error("Please enter a fixed amount")); + } + const numValue = parseFloat(value); + if (isNaN(numValue) || numValue < 0) { + return Promise.reject(new Error("Fixed amount must be non-negative")); + } + return Promise.resolve(); + }, + }, + ]} + > +
+ $ + +
+
+ )} + +
+ +
+
+ ); +}; + +export default AddMarginForm; + diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/add_provider_form.tsx b/ui/litellm-dashboard/src/components/CostTrackingSettings/add_provider_form.tsx index 2d530be71eb..7f79a6848bb 100644 --- a/ui/litellm-dashboard/src/components/CostTrackingSettings/add_provider_form.tsx +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/add_provider_form.tsx @@ -2,7 +2,6 @@ import React from "react"; import { TextInput, Button } from "@tremor/react"; import { Select as AntdSelect, Form, Tooltip } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; -import Image from "next/image"; import { Providers, provider_map, providerLogoMap } from "../provider_info_helpers"; import { DiscountConfig } from "./types"; import { handleImageError } from "./provider_display_helpers"; @@ -58,11 +57,9 @@ const AddProviderForm: React.FC = ({ return (
- {`${providerEnum} handleImageError(e, providerDisplayName)} /> diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/cost_tracking_settings.tsx b/ui/litellm-dashboard/src/components/CostTrackingSettings/cost_tracking_settings.tsx index c356982f189..32ffd55efa0 100644 --- a/ui/litellm-dashboard/src/components/CostTrackingSettings/cost_tracking_settings.tsx +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/cost_tracking_settings.tsx @@ -1,16 +1,16 @@ -import React, { useState, useEffect, useCallback } from "react"; -import { Title, Text, Button, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react"; +import React, { useState, useEffect } from "react"; +import { Title, Text, Button, Accordion, AccordionHeader, AccordionBody, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react"; import { Modal, Form } from "antd"; -import { getProxyBaseUrl } from "@/components/networking"; -import NotificationsManager from "../molecules/notifications_manager"; -import { Providers } from "../provider_info_helpers"; -import { CostTrackingSettingsProps, DiscountConfig } from "./types"; -import { getProviderBackendValue } from "./provider_display_helpers"; +import { CostTrackingSettingsProps } from "./types"; import ProviderDiscountTable from "./provider_discount_table"; import AddProviderForm from "./add_provider_form"; +import ProviderMarginTable from "./provider_margin_table"; +import AddMarginForm from "./add_margin_form"; import { ExclamationCircleOutlined } from "@ant-design/icons"; import { DocsMenu } from "../HelpLink"; import HowItWorks from "./how_it_works"; +import { useDiscountConfig } from "./use_discount_config"; +import { useMarginConfig } from "./use_margin_config"; const DOCS_LINKS = [ { label: "Custom pricing for models", href: "https://docs.litellm.ai/docs/proxy/custom_pricing" }, @@ -22,118 +22,51 @@ const CostTrackingSettings: React.FC = ({ userRole, accessToken }) => { - const [discountConfig, setDiscountConfig] = useState({}); const [selectedProvider, setSelectedProvider] = useState(undefined); const [newDiscount, setNewDiscount] = useState(""); const [isFetching, setIsFetching] = useState(true); const [isModalVisible, setIsModalVisible] = useState(false); + const [isMarginModalVisible, setIsMarginModalVisible] = useState(false); + const [selectedMarginProvider, setSelectedMarginProvider] = useState(undefined); + const [marginType, setMarginType] = useState<"percentage" | "fixed">("percentage"); + const [percentageValue, setPercentageValue] = useState(""); + const [fixedAmountValue, setFixedAmountValue] = useState(""); const [form] = Form.useForm(); + const [marginForm] = Form.useForm(); const [modal, contextHolder] = Modal.useModal(); - const fetchDiscountConfig = useCallback(async () => { - setIsFetching(true); - try { - const proxyBaseUrl = getProxyBaseUrl(); - const url = proxyBaseUrl - ? `${proxyBaseUrl}/config/cost_discount_config` - : "/config/cost_discount_config"; - - const response = await fetch(url, { - method: "GET", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - }); + // Use custom hooks for discount and margin config + const { + discountConfig, + fetchDiscountConfig, + handleAddProvider: addProvider, + handleRemoveProvider: removeProvider, + handleDiscountChange, + } = useDiscountConfig({ accessToken }); - if (response.ok) { - const data = await response.json(); - setDiscountConfig(data.values || {}); - } else { - console.error("Failed to fetch discount config"); - } - } catch (error) { - console.error("Error fetching discount config:", error); - NotificationsManager.fromBackend("Failed to fetch discount configuration"); - } finally { - setIsFetching(false); - } - }, [accessToken]); + const { + marginConfig, + fetchMarginConfig, + handleAddMargin: addMargin, + handleRemoveMargin: removeMargin, + handleMarginChange, + } = useMarginConfig({ accessToken }); useEffect(() => { if (accessToken) { - fetchDiscountConfig(); - } - }, [accessToken, fetchDiscountConfig]); - - const saveDiscountConfig = async (config: DiscountConfig) => { - try { - const proxyBaseUrl = getProxyBaseUrl(); - const url = proxyBaseUrl - ? `${proxyBaseUrl}/config/cost_discount_config` - : "/config/cost_discount_config"; - - const response = await fetch(url, { - method: "PATCH", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(config), + Promise.all([fetchDiscountConfig(), fetchMarginConfig()]).finally(() => { + setIsFetching(false); }); - - if (response.ok) { - NotificationsManager.success("Discount configuration updated successfully"); - await fetchDiscountConfig(); - } else { - const errorData = await response.json(); - const errorMessage = errorData.detail?.error || errorData.detail || "Failed to update settings"; - NotificationsManager.fromBackend(errorMessage); - } - } catch (error) { - console.error("Error updating discount config:", error); - NotificationsManager.fromBackend("Failed to update discount configuration"); } - }; + }, [accessToken, fetchDiscountConfig, fetchMarginConfig]); const handleAddProvider = async () => { - if (!selectedProvider || !newDiscount) { - NotificationsManager.fromBackend("Please select a provider and enter discount percentage"); - return; + const success = await addProvider(selectedProvider, newDiscount); + if (success) { + setSelectedProvider(undefined); + setNewDiscount(""); + setIsModalVisible(false); } - - const percentageValue = parseFloat(newDiscount); - if (isNaN(percentageValue) || percentageValue < 0 || percentageValue > 100) { - NotificationsManager.fromBackend("Discount must be between 0% and 100%"); - return; - } - - const providerValue = getProviderBackendValue(selectedProvider); - - if (!providerValue) { - NotificationsManager.fromBackend("Invalid provider selected"); - return; - } - - if (discountConfig[providerValue]) { - NotificationsManager.fromBackend( - `Discount for ${Providers[selectedProvider as keyof typeof Providers]} already exists. Edit it in the table above.` - ); - return; - } - - // Convert percentage to decimal for storage - const discountValue = percentageValue / 100; - const updatedConfig = { - ...discountConfig, - [providerValue]: discountValue, - }; - - setDiscountConfig(updatedConfig); - await saveDiscountConfig(updatedConfig); - setSelectedProvider(undefined); - setNewDiscount(""); - setIsModalVisible(false); }; const handleModalCancel = () => { @@ -143,7 +76,7 @@ const CostTrackingSettings: React.FC = ({ setNewDiscount(""); }; - const handleFormSubmit = (values: any) => { + const handleFormSubmit = () => { handleAddProvider(); }; @@ -155,27 +88,47 @@ const CostTrackingSettings: React.FC = ({ okText: 'Remove', okType: 'danger', cancelText: 'Cancel', - onOk: async () => { - const updatedConfig = { ...discountConfig }; - delete updatedConfig[provider]; - setDiscountConfig(updatedConfig); - await saveDiscountConfig(updatedConfig); - }, + onOk: () => removeProvider(provider), }); }; - const handleDiscountChange = async (provider: string, value: string) => { - const discountValue = parseFloat(value); - if (!isNaN(discountValue) && discountValue >= 0 && discountValue <= 1) { - const updatedConfig = { - ...discountConfig, - [provider]: discountValue, - }; - setDiscountConfig(updatedConfig); - await saveDiscountConfig(updatedConfig); + const handleAddMargin = async () => { + const success = await addMargin({ + selectedProvider: selectedMarginProvider, + marginType, + percentageValue, + fixedAmountValue, + }); + if (success) { + setSelectedMarginProvider(undefined); + setPercentageValue(""); + setFixedAmountValue(""); + setMarginType("percentage"); + setIsMarginModalVisible(false); } }; + const handleMarginModalCancel = () => { + setIsMarginModalVisible(false); + marginForm.resetFields(); + setSelectedMarginProvider(undefined); + setPercentageValue(""); + setFixedAmountValue(""); + setMarginType("percentage"); + }; + + const handleRemoveMargin = async (provider: string, providerDisplayName: string) => { + modal.confirm({ + title: 'Remove Provider Margin', + icon: , + content: `Are you sure you want to remove the margin for ${providerDisplayName}?`, + okText: 'Remove', + okType: 'danger', + cancelText: 'Cancel', + onOk: () => removeMargin(provider), + }); + }; + if (!accessToken) { return null; } @@ -192,38 +145,113 @@ const CostTrackingSettings: React.FC = ({
- Configure cost discounts for different LLM providers. Changes are saved automatically. + Configure cost discounts and margins for different LLM providers. Changes are saved automatically. - - {/* Main Content Card with Tabs */} -
- - - Provider Discounts - Test It - - - + {/* Main Content Card with Accordions */} +
+ {/* Accordion 1: Provider Discounts */} + + +
+ Provider Discounts + + Apply percentage-based discounts to reduce costs for specific providers + +
+
+ + + + Discounts + Test It + + + +
+
+ +
+ {isFetching ? ( +
+ Loading configuration... +
+ ) : Object.keys(discountConfig).length > 0 ? ( + + ) : ( +
+ + + + + No provider discounts configured + + + Click "Add Provider Discount" to get started + +
+ )} +
+
+ +
+ +
+
+
+
+
+
+ + {/* Accordion 2: Fee/Price Margin */} + + +
+ Fee/Price Margin + + Add fees or margins to LLM costs for internal billing and cost recovery + +
+
+ +
+
+ +
{isFetching ? (
Loading configuration...
- ) : Object.keys(discountConfig).length > 0 ? ( -
- -
+ ) : Object.keys(marginConfig).length > 0 ? ( + ) : (
= ({ /> - No provider discounts configured + No provider margins configured - Click "Add Provider Discount" to get started + Click "Add Provider Margin" to get started
)} - - -
- -
-
- - +
+
+
= ({
+ + +

Add Provider Margin

+ + } + open={isMarginModalVisible} + width={1000} + onCancel={handleMarginModalCancel} + footer={null} + className="top-8" + styles={{ + body: { padding: "24px" }, + header: { padding: "24px 24px 0 24px", border: "none" }, + }} + > +
+ + Select a provider (or "Global" for all providers) and configure the margin. You can use percentage-based or fixed amount. + +
+ + +
+
); }; diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/index.ts b/ui/litellm-dashboard/src/components/CostTrackingSettings/index.ts index 11adc414664..feba943154b 100644 --- a/ui/litellm-dashboard/src/components/CostTrackingSettings/index.ts +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/index.ts @@ -1,8 +1,12 @@ export { default as CostTrackingSettings } from "./cost_tracking_settings"; export { default as ProviderDiscountTable } from "./provider_discount_table"; export { default as AddProviderForm } from "./add_provider_form"; +export { default as ProviderMarginTable } from "./provider_margin_table"; +export { default as AddMarginForm } from "./add_margin_form"; export { default as HowItWorks } from "./how_it_works"; -export type { CostTrackingSettingsProps, DiscountConfig, CostDiscountResponse } from "./types"; +export type { CostTrackingSettingsProps, DiscountConfig, CostDiscountResponse, MarginConfig, CostMarginResponse } from "./types"; export type { ProviderDisplayInfo } from "./provider_display_helpers"; export * from "./provider_display_helpers"; +export { useDiscountConfig } from "./use_discount_config"; +export { useMarginConfig } from "./use_margin_config"; diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/provider_margin_table.tsx b/ui/litellm-dashboard/src/components/CostTrackingSettings/provider_margin_table.tsx new file mode 100644 index 00000000000..f75fefef3e1 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/provider_margin_table.tsx @@ -0,0 +1,206 @@ +import React, { useState } from "react"; +import { TextInput, Icon, Text } from "@tremor/react"; +import { TrashIcon, PencilAltIcon, CheckIcon, XIcon } from "@heroicons/react/outline"; +import { SimpleTable } from "../common_components/simple_table"; +import { MarginConfig } from "./types"; +import { getProviderDisplayInfo, handleImageError } from "./provider_display_helpers"; + +interface ProviderMarginTableProps { + marginConfig: MarginConfig; + onMarginChange: (provider: string, value: number | { percentage?: number; fixed_amount?: number }) => void; + onRemoveProvider: (provider: string, providerDisplayName: string) => void; +} + +interface ProviderMarginRow { + provider: string; + margin: number | { percentage?: number; fixed_amount?: number }; +} + +const ProviderMarginTable: React.FC = ({ + marginConfig, + onMarginChange, + onRemoveProvider, +}) => { + const [editingProvider, setEditingProvider] = useState(null); + const [editPercentage, setEditPercentage] = useState(""); + const [editFixedAmount, setEditFixedAmount] = useState(""); + + const handleStartEdit = (provider: string, currentMargin: number | { percentage?: number; fixed_amount?: number }) => { + setEditingProvider(provider); + if (typeof currentMargin === "number") { + // Simple percentage format + setEditPercentage((currentMargin * 100).toString()); + setEditFixedAmount(""); + } else { + // Complex format with percentage and/or fixed_amount + setEditPercentage(currentMargin.percentage ? (currentMargin.percentage * 100).toString() : ""); + setEditFixedAmount(currentMargin.fixed_amount ? currentMargin.fixed_amount.toString() : ""); + } + }; + + const handleSaveEdit = (provider: string) => { + const percentValue = editPercentage ? parseFloat(editPercentage) : undefined; + const fixedValue = editFixedAmount ? parseFloat(editFixedAmount) : undefined; + + if (percentValue !== undefined && !isNaN(percentValue) && percentValue >= 0 && percentValue <= 1000) { + if (fixedValue !== undefined && !isNaN(fixedValue) && fixedValue >= 0) { + // Both percentage and fixed amount + onMarginChange(provider, { percentage: percentValue / 100, fixed_amount: fixedValue }); + } else { + // Only percentage + onMarginChange(provider, percentValue / 100); + } + } else if (fixedValue !== undefined && !isNaN(fixedValue) && fixedValue >= 0) { + // Only fixed amount + onMarginChange(provider, { fixed_amount: fixedValue }); + } + setEditingProvider(null); + setEditPercentage(""); + setEditFixedAmount(""); + }; + + const handleCancelEdit = () => { + setEditingProvider(null); + setEditPercentage(""); + setEditFixedAmount(""); + }; + + const handleKeyDown = (e: React.KeyboardEvent, provider: string) => { + if (e.key === 'Enter') { + handleSaveEdit(provider); + } else if (e.key === 'Escape') { + handleCancelEdit(); + } + }; + + const formatMargin = (margin: number | { percentage?: number; fixed_amount?: number }): string => { + if (typeof margin === "number") { + return `${(margin * 100).toFixed(1)}%`; + } + const parts: string[] = []; + if (margin.percentage !== undefined) { + parts.push(`${(margin.percentage * 100).toFixed(1)}%`); + } + if (margin.fixed_amount !== undefined) { + parts.push(`$${margin.fixed_amount.toFixed(6)}`); + } + return parts.join(" + ") || "0%"; + }; + + // Convert margin config to array and sort (global first, then alphabetically) + const data: ProviderMarginRow[] = Object.entries(marginConfig) + .map(([provider, margin]) => ({ provider, margin })) + .sort((a, b) => { + if (a.provider === "global") return -1; + if (b.provider === "global") return 1; + const displayA = getProviderDisplayInfo(a.provider).displayName; + const displayB = getProviderDisplayInfo(b.provider).displayName; + return displayA.localeCompare(displayB); + }); + + return ( + { + if (row.provider === "global") { + return ( +
+ Global (All Providers) +
+ ); + } + const { displayName, logo } = getProviderDisplayInfo(row.provider); + return ( +
+ {logo && ( + {`${displayName} handleImageError(e, displayName)} + /> + )} + {displayName} +
+ ); + }, + }, + { + header: "Margin", + cell: (row) => ( +
+ {editingProvider === row.provider ? ( + <> +
+ + % + + + $ + +
+ handleSaveEdit(row.provider)} + className="cursor-pointer text-green-600 hover:text-green-700" + /> + + + ) : ( + <> + {formatMargin(row.margin)} + handleStartEdit(row.provider, row.margin)} + className="cursor-pointer text-blue-600 hover:text-blue-700" + /> + + )} +
+ ), + width: "350px", + }, + { + header: "Actions", + cell: (row) => { + const displayName = row.provider === "global" ? "Global" : getProviderDisplayInfo(row.provider).displayName; + return ( + onRemoveProvider(row.provider, displayName)} + className="cursor-pointer hover:text-red-600" + /> + ); + }, + width: "80px", + }, + ]} + getRowKey={(row) => row.provider} + emptyMessage="No provider margins configured" + /> + ); +}; + +export default ProviderMarginTable; + diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/types.ts b/ui/litellm-dashboard/src/components/CostTrackingSettings/types.ts index 55d49ecffd9..1e79110dfb3 100644 --- a/ui/litellm-dashboard/src/components/CostTrackingSettings/types.ts +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/types.ts @@ -12,3 +12,11 @@ export interface CostDiscountResponse { values: DiscountConfig; } +export interface MarginConfig { + [provider: string]: number | { percentage?: number; fixed_amount?: number }; +} + +export interface CostMarginResponse { + values: MarginConfig; +} + diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/use_discount_config.ts b/ui/litellm-dashboard/src/components/CostTrackingSettings/use_discount_config.ts new file mode 100644 index 00000000000..0ed57aa8cc2 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/use_discount_config.ts @@ -0,0 +1,151 @@ +import { useState, useCallback } from "react"; +import { getProxyBaseUrl } from "@/components/networking"; +import NotificationsManager from "../molecules/notifications_manager"; +import { DiscountConfig } from "./types"; +import { getProviderBackendValue } from "./provider_display_helpers"; +import { Providers } from "../provider_info_helpers"; + +export interface UseDiscountConfigProps { + accessToken: string | null; +} + +export interface UseDiscountConfigReturn { + discountConfig: DiscountConfig; + setDiscountConfig: React.Dispatch>; + fetchDiscountConfig: () => Promise; + saveDiscountConfig: (config: DiscountConfig) => Promise; + handleAddProvider: (selectedProvider: string | undefined, newDiscount: string) => Promise; + handleRemoveProvider: (provider: string) => Promise; + handleDiscountChange: (provider: string, value: string) => Promise; +} + +export function useDiscountConfig({ accessToken }: UseDiscountConfigProps): UseDiscountConfigReturn { + const [discountConfig, setDiscountConfig] = useState({}); + + const fetchDiscountConfig = useCallback(async () => { + try { + const proxyBaseUrl = getProxyBaseUrl(); + const url = proxyBaseUrl + ? `${proxyBaseUrl}/config/cost_discount_config` + : "/config/cost_discount_config"; + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (response.ok) { + const data = await response.json(); + setDiscountConfig(data.values || {}); + } else { + console.error("Failed to fetch discount config"); + } + } catch (error) { + console.error("Error fetching discount config:", error); + NotificationsManager.fromBackend("Failed to fetch discount configuration"); + } + }, [accessToken]); + + const saveDiscountConfig = useCallback(async (config: DiscountConfig) => { + try { + const proxyBaseUrl = getProxyBaseUrl(); + const url = proxyBaseUrl + ? `${proxyBaseUrl}/config/cost_discount_config` + : "/config/cost_discount_config"; + + const response = await fetch(url, { + method: "PATCH", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(config), + }); + + if (response.ok) { + NotificationsManager.success("Discount configuration updated successfully"); + await fetchDiscountConfig(); + } else { + const errorData = await response.json(); + const errorMessage = errorData.detail?.error || errorData.detail || "Failed to update settings"; + NotificationsManager.fromBackend(errorMessage); + } + } catch (error) { + console.error("Error updating discount config:", error); + NotificationsManager.fromBackend("Failed to update discount configuration"); + } + }, [accessToken, fetchDiscountConfig]); + + const handleAddProvider = useCallback(async ( + selectedProvider: string | undefined, + newDiscount: string + ): Promise => { + if (!selectedProvider || !newDiscount) { + NotificationsManager.fromBackend("Please select a provider and enter discount percentage"); + return false; + } + + const percentageValue = parseFloat(newDiscount); + if (isNaN(percentageValue) || percentageValue < 0 || percentageValue > 100) { + NotificationsManager.fromBackend("Discount must be between 0% and 100%"); + return false; + } + + const providerValue = getProviderBackendValue(selectedProvider); + + if (!providerValue) { + NotificationsManager.fromBackend("Invalid provider selected"); + return false; + } + + if (discountConfig[providerValue]) { + NotificationsManager.fromBackend( + `Discount for ${Providers[selectedProvider as keyof typeof Providers]} already exists. Edit it in the table above.` + ); + return false; + } + + const discountValue = percentageValue / 100; + const updatedConfig = { + ...discountConfig, + [providerValue]: discountValue, + }; + + setDiscountConfig(updatedConfig); + await saveDiscountConfig(updatedConfig); + return true; + }, [discountConfig, saveDiscountConfig]); + + const handleRemoveProvider = useCallback(async (provider: string) => { + const updatedConfig = { ...discountConfig }; + delete updatedConfig[provider]; + setDiscountConfig(updatedConfig); + await saveDiscountConfig(updatedConfig); + }, [discountConfig, saveDiscountConfig]); + + const handleDiscountChange = useCallback(async (provider: string, value: string) => { + const discountValue = parseFloat(value); + if (!isNaN(discountValue) && discountValue >= 0 && discountValue <= 1) { + const updatedConfig = { + ...discountConfig, + [provider]: discountValue, + }; + setDiscountConfig(updatedConfig); + await saveDiscountConfig(updatedConfig); + } + }, [discountConfig, saveDiscountConfig]); + + return { + discountConfig, + setDiscountConfig, + fetchDiscountConfig, + saveDiscountConfig, + handleAddProvider, + handleRemoveProvider, + handleDiscountChange, + }; +} + diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/use_margin_config.ts b/ui/litellm-dashboard/src/components/CostTrackingSettings/use_margin_config.ts new file mode 100644 index 00000000000..f443e1c121e --- /dev/null +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/use_margin_config.ts @@ -0,0 +1,176 @@ +import { useState, useCallback } from "react"; +import { getProxyBaseUrl } from "@/components/networking"; +import NotificationsManager from "../molecules/notifications_manager"; +import { MarginConfig } from "./types"; +import { getProviderBackendValue } from "./provider_display_helpers"; +import { Providers } from "../provider_info_helpers"; + +export interface UseMarginConfigProps { + accessToken: string | null; +} + +export interface UseMarginConfigReturn { + marginConfig: MarginConfig; + setMarginConfig: React.Dispatch>; + fetchMarginConfig: () => Promise; + saveMarginConfig: (config: MarginConfig) => Promise; + handleAddMargin: (params: AddMarginParams) => Promise; + handleRemoveMargin: (provider: string) => Promise; + handleMarginChange: ( + provider: string, + value: number | { percentage?: number; fixed_amount?: number } + ) => Promise; +} + +export interface AddMarginParams { + selectedProvider: string | undefined; + marginType: "percentage" | "fixed"; + percentageValue: string; + fixedAmountValue: string; +} + +export function useMarginConfig({ accessToken }: UseMarginConfigProps): UseMarginConfigReturn { + const [marginConfig, setMarginConfig] = useState({}); + + const fetchMarginConfig = useCallback(async () => { + try { + const proxyBaseUrl = getProxyBaseUrl(); + const url = proxyBaseUrl + ? `${proxyBaseUrl}/config/cost_margin_config` + : "/config/cost_margin_config"; + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (response.ok) { + const data = await response.json(); + setMarginConfig(data.values || {}); + } else { + console.error("Failed to fetch margin config"); + } + } catch (error) { + console.error("Error fetching margin config:", error); + NotificationsManager.fromBackend("Failed to fetch margin configuration"); + } + }, [accessToken]); + + const saveMarginConfig = useCallback(async (config: MarginConfig) => { + try { + const proxyBaseUrl = getProxyBaseUrl(); + const url = proxyBaseUrl + ? `${proxyBaseUrl}/config/cost_margin_config` + : "/config/cost_margin_config"; + + const response = await fetch(url, { + method: "PATCH", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(config), + }); + + if (response.ok) { + NotificationsManager.success("Margin configuration updated successfully"); + await fetchMarginConfig(); + } else { + const errorData = await response.json(); + const errorMessage = errorData.detail?.error || errorData.detail || "Failed to update settings"; + NotificationsManager.fromBackend(errorMessage); + } + } catch (error) { + console.error("Error updating margin config:", error); + NotificationsManager.fromBackend("Failed to update margin configuration"); + } + }, [accessToken, fetchMarginConfig]); + + const handleAddMargin = useCallback(async (params: AddMarginParams): Promise => { + const { selectedProvider, marginType, percentageValue, fixedAmountValue } = params; + + if (!selectedProvider) { + NotificationsManager.fromBackend("Please select a provider"); + return false; + } + + let providerValue: string; + if (selectedProvider === "global") { + providerValue = "global"; + } else { + const backendValue = getProviderBackendValue(selectedProvider); + if (!backendValue) { + NotificationsManager.fromBackend("Invalid provider selected"); + return false; + } + providerValue = backendValue; + } + + if (marginConfig[providerValue]) { + const displayName = providerValue === "global" ? "Global" : Providers[selectedProvider as keyof typeof Providers]; + NotificationsManager.fromBackend( + `Margin for ${displayName} already exists. Edit it in the table above.` + ); + return false; + } + + let marginValue: number | { fixed_amount?: number }; + if (marginType === "percentage") { + const percentValue = parseFloat(percentageValue); + if (isNaN(percentValue) || percentValue < 0 || percentValue > 1000) { + NotificationsManager.fromBackend("Percentage must be between 0% and 1000%"); + return false; + } + marginValue = percentValue / 100; + } else { + const fixedValue = parseFloat(fixedAmountValue); + if (isNaN(fixedValue) || fixedValue < 0) { + NotificationsManager.fromBackend("Fixed amount must be non-negative"); + return false; + } + marginValue = { fixed_amount: fixedValue }; + } + + const updatedConfig = { + ...marginConfig, + [providerValue]: marginValue, + }; + + setMarginConfig(updatedConfig); + await saveMarginConfig(updatedConfig); + return true; + }, [marginConfig, saveMarginConfig]); + + const handleRemoveMargin = useCallback(async (provider: string) => { + const updatedConfig = { ...marginConfig }; + delete updatedConfig[provider]; + setMarginConfig(updatedConfig); + await saveMarginConfig(updatedConfig); + }, [marginConfig, saveMarginConfig]); + + const handleMarginChange = useCallback(async ( + provider: string, + value: number | { percentage?: number; fixed_amount?: number } + ) => { + const updatedConfig = { + ...marginConfig, + [provider]: value, + }; + setMarginConfig(updatedConfig); + await saveMarginConfig(updatedConfig); + }, [marginConfig, saveMarginConfig]); + + return { + marginConfig, + setMarginConfig, + fetchMarginConfig, + saveMarginConfig, + handleAddMargin, + handleRemoveMargin, + handleMarginChange, + }; +} + diff --git a/ui/litellm-dashboard/src/components/OldTeams.test.tsx b/ui/litellm-dashboard/src/components/OldTeams.test.tsx index f3b4ec82d53..76fc26a8847 100644 --- a/ui/litellm-dashboard/src/components/OldTeams.test.tsx +++ b/ui/litellm-dashboard/src/components/OldTeams.test.tsx @@ -1,10 +1,13 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { fetchAvailableModelsForTeamOrKey } from "./key_team_helpers/fetch_available_models_team_key"; import { fetchMCPAccessGroups, getGuardrailsList, teamCreateCall } from "./networking"; import OldTeams from "./OldTeams"; const mockTeamInfoView = vi.fn(); +const mockUseOrganizations = vi.fn(); vi.mock("./networking", () => ({ teamCreateCall: vi.fn(), @@ -57,6 +60,25 @@ vi.mock("@/components/team/team_info", () => ({ }, })); +vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ + useOrganizations: () => mockUseOrganizations(), +})); + +const createQueryClient = () => { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); +}; + +const renderWithQueryClient = (component: React.ReactElement) => { + const queryClient = createQueryClient(); + return render({component}); +}; + describe("OldTeams - handleCreate organization handling", () => { beforeEach(() => { vi.clearAllMocks(); @@ -64,6 +86,7 @@ describe("OldTeams - handleCreate organization handling", () => { vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue([]); vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]); vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] }); + mockUseOrganizations.mockReturnValue({ data: null }); }); it("should not include organization_id when it's an empty string", async () => { @@ -274,7 +297,8 @@ describe("OldTeams - handleCreate organization handling", () => { }); it("should clear the delete modal when the cancel button is clicked", async () => { - render( + mockUseOrganizations.mockReturnValue({ data: [] }); + renderWithQueryClient( { describe("OldTeams - empty state", () => { beforeEach(() => { vi.clearAllMocks(); + mockUseOrganizations.mockReturnValue({ data: [] }); }); it("should display empty state message when teams array is empty", () => { - render( + renderWithQueryClient( { }); it("should display empty state message when teams is null", () => { - render( + renderWithQueryClient( { }); it("should not display empty state when teams array has items", () => { - render( + renderWithQueryClient( { vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue([]); vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]); vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] }); + mockUseOrganizations.mockReturnValue({ data: [] }); }); it("passes premiumUser flag to TeamInfoView", async () => { - render( + renderWithQueryClient( { describe("OldTeams - Default Team Settings tab visibility", () => { beforeEach(() => { vi.clearAllMocks(); + mockUseOrganizations.mockReturnValue({ data: [] }); }); it("should show Default Team Settings tab for Admin role", () => { - render( + renderWithQueryClient( { }); it("should show Default Team Settings tab for proxy_admin role", () => { - render( + renderWithQueryClient( { }); it("should not show Default Team Settings tab for proxy_admin_viewer role", () => { - render( + renderWithQueryClient( { }); it("should not show Default Team Settings tab for Admin Viewer role", () => { - render( + renderWithQueryClient( { beforeEach(() => { vi.clearAllMocks(); vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4", "gpt-3.5-turbo"]); + mockUseOrganizations.mockReturnValue({ data: [] }); }); it("should not render all-proxy-models option in models select", async () => { vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4", "gpt-3.5-turbo"]); - render( + renderWithQueryClient( { expect(allProxyModelsOption).not.toBeInTheDocument(); }); }); + +describe("OldTeams - organization alias display", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseOrganizations.mockReturnValue({ data: [] }); + }); + + it("should display organization alias instead of organization id", () => { + const mockOrganizations = [ + { + organization_id: "org-123", + organization_alias: "Test Organization", + budget_id: "budget-1", + metadata: {}, + models: [], + spend: 0, + model_spend: {}, + created_at: new Date().toISOString(), + created_by: "user-1", + updated_at: new Date().toISOString(), + updated_by: "user-1", + litellm_budget_table: null, + teams: null, + users: null, + members: null, + }, + ]; + + mockUseOrganizations.mockReturnValue({ data: mockOrganizations }); + + renderWithQueryClient( + , + ); + + expect(screen.getByText("Test Organization")).toBeInTheDocument(); + expect(screen.queryByText("org-123")).not.toBeInTheDocument(); + }); + + it("should display organization id when alias is not found", () => { + mockUseOrganizations.mockReturnValue({ data: [] }); + + renderWithQueryClient( + , + ); + + expect(screen.getByText("org-unknown")).toBeInTheDocument(); + }); + + it("should display N/A when organization_id is null", () => { + mockUseOrganizations.mockReturnValue({ data: [] }); + + renderWithQueryClient( + , + ); + + expect(screen.getByText("N/A")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/OldTeams.tsx b/ui/litellm-dashboard/src/components/OldTeams.tsx index 562d75c327a..10a38f0285c 100644 --- a/ui/litellm-dashboard/src/components/OldTeams.tsx +++ b/ui/litellm-dashboard/src/components/OldTeams.tsx @@ -1,3 +1,4 @@ +import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import AvailableTeamsPanel from "@/components/team/available_teams"; import TeamInfoView from "@/components/team/team_info"; import TeamSSOSettings from "@/components/TeamSSOSettings"; @@ -149,6 +150,18 @@ const getAdminOrganizations = ( return []; }; +const getOrganizationAlias = ( + organizationId: string | null | undefined, + organizations: Organization[] | null | undefined, +): string => { + if (!organizationId || !organizations) { + return organizationId || "N/A"; + } + + const organization = organizations.find((org) => org.organization_id === organizationId); + return organization?.organization_alias || organizationId; +}; + // @deprecated const Teams: React.FC = ({ teams, @@ -161,6 +174,7 @@ const Teams: React.FC = ({ premiumUser = false, }) => { console.log(`organizations: ${JSON.stringify(organizations)}`); + const { data: organizationsData } = useOrganizations(); const [lastRefreshed, setLastRefreshed] = useState(""); const [currentOrg, setCurrentOrg] = useState(null); const [currentOrgForCreateTeam, setCurrentOrgForCreateTeam] = useState(null); @@ -940,7 +954,9 @@ const Teams: React.FC = ({ - {team.organization_id} + + {getOrganizationAlias(team.organization_id, organizationsData || organizations)} + {perTeamInfo && diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx index 8f6bc411630..db268286007 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx @@ -268,16 +268,7 @@ const TopKeyView: React.FC = ({ topKeys, teams, showTags = fals {/* Content */}
- +
diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx index 9766983c36a..920955138b9 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx @@ -80,8 +80,8 @@ const UsagePage: React.FC = ({ teams, organizations }) => { }); const [allTags, setAllTags] = useState([]); - const { data: customers = [] } = useCustomers(accessToken, userRole); - const { data: agentsResponse } = useAgents(accessToken, userRole); + const { data: customers = [] } = useCustomers(); + const { data: agentsResponse } = useAgents(); const [modelViewType, setModelViewType] = useState<"groups" | "individual">("groups"); const [isCloudZeroModalOpen, setIsCloudZeroModalOpen] = useState(false); const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false); diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx new file mode 100644 index 00000000000..3f55b11769c --- /dev/null +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx @@ -0,0 +1,266 @@ +import { screen, waitFor } from "@testing-library/react"; +import { vi, it, expect, beforeEach, MockedFunction } from "vitest"; +import { renderWithProviders } from "../../../tests/test-utils"; +import { VirtualKeysTable } from "./VirtualKeysTable"; +import { KeyResponse, Team } from "../key_team_helpers/key_list"; +import { Organization } from "../networking"; +import { KeysResponse, useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; +import { useFilterLogic } from "../key_team_helpers/filter_logic"; + +// Mock network calls +vi.mock("./networking", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + userListCall: vi.fn().mockResolvedValue({ + users: [ + { + user_id: "user-1", + user_email: "user@example.com", + user_role: "user", + }, + ], + }), + }; +}); + +// Mock filter helpers +vi.mock("./key_team_helpers/filter_helpers", () => ({ + fetchAllKeyAliases: vi.fn().mockResolvedValue(["test-key-alias"]), + fetchAllTeams: vi.fn().mockResolvedValue([ + { + team_id: "team-1", + team_alias: "Test Team", + }, + ]), + fetchAllOrganizations: vi.fn().mockResolvedValue([ + { + organization_id: "org-1", + organization_alias: "Test Organization", + }, + ]), +})); + +// Mock useKeys hook +vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({ + useKeys: vi.fn(), +})); + +// Mock useFilterLogic hook +vi.mock("../key_team_helpers/filter_logic", () => ({ + useFilterLogic: vi.fn(), +})); + +const mockKey: KeyResponse = { + token: "sk-1234567890abcdef", + token_id: "key-1", + key_name: "test-key", + key_alias: "Test Key Alias", + spend: 5.5, + max_budget: 100, + expires: "2024-12-31T23:59:59Z", + models: ["gpt-3.5-turbo", "gpt-4"], + aliases: {}, + config: {}, + user_id: "user-1", + team_id: "team-1", + max_parallel_requests: 10, + metadata: {}, + tpm_limit: 1000, + rpm_limit: 100, + duration: "30d", + budget_duration: "1m", + budget_reset_at: "2024-12-01T00:00:00Z", + allowed_cache_controls: [], + allowed_routes: [], + permissions: {}, + model_spend: { "gpt-3.5-turbo": 2.5, "gpt-4": 3.0 }, + model_max_budget: { "gpt-3.5-turbo": 50, "gpt-4": 50 }, + soft_budget_cooldown: false, + blocked: false, + litellm_budget_table: {}, + organization_id: "org-1", + created_at: "2024-11-01T10:00:00Z", + updated_at: "2024-11-15T10:00:00Z", + team_spend: 5.5, + team_alias: "Test Team", + team_tpm_limit: 5000, + team_rpm_limit: 500, + team_max_budget: 500, + team_models: ["gpt-3.5-turbo", "gpt-4"], + team_blocked: false, + soft_budget: 50, + team_model_aliases: {}, + team_member_spend: 0, + team_metadata: {}, + end_user_id: "end-user-1", + end_user_tpm_limit: 100, + end_user_rpm_limit: 10, + end_user_max_budget: 10, + last_refreshed_at: Date.now(), + api_key: "sk-1234567890abcdef", + user_role: "user", + rpm_limit_per_model: {}, + tpm_limit_per_model: {}, + user_tpm_limit: 1000, + user_rpm_limit: 100, + user_email: "user@example.com", + user: { + user_email: "user@example.com", + user_id: "user-1", + }, +}; + +const mockTeam: Team = { + team_id: "team-1", + team_alias: "Test Team", + models: ["gpt-3.5-turbo", "gpt-4"], + max_budget: 500, + budget_duration: "1m", + tpm_limit: 5000, + rpm_limit: 500, + organization_id: "org-1", + created_at: "2024-10-01T10:00:00Z", + keys: [], + members_with_roles: [], +}; + +const mockOrganization: Organization = { + organization_id: "org-1", + organization_alias: "Test Organization", + budget_id: "budget-1", + metadata: {}, + models: ["gpt-3.5-turbo", "gpt-4"], + spend: 100, + model_spend: { "gpt-3.5-turbo": 50, "gpt-4": 50 }, + created_at: "2024-10-01T10:00:00Z", + created_by: "user-1", + updated_at: "2024-11-01T10:00:00Z", + updated_by: "user-1", + litellm_budget_table: {}, + teams: [], + users: [], + members: [], +}; + +// Mock hook implementations +const mockUseKeys = useKeys as MockedFunction; +const mockUseFilterLogic = useFilterLogic as MockedFunction; + +beforeEach(() => { + // Reset mocks before each test + vi.clearAllMocks(); + + // Setup default mock implementations + mockUseKeys.mockReturnValue({ + data: { + keys: [mockKey], + total_count: 1, + current_page: 1, + total_pages: 1, + } as KeysResponse, + isPending: false, + refetch: vi.fn(), + } as any); + + mockUseFilterLogic.mockReturnValue({ + filters: { + "Team ID": "team-1", + "Organization ID": "org-1", + "Key Alias": "Test Key Alias", + "User ID": "user-1", + "User Email": "user@example.com", + "User Role": "user", + "Sort By": "created_at", + "Sort Order": "desc", + }, + filteredKeys: [mockKey], + allKeyAliases: ["test-key-alias"], + allTeams: [mockTeam], + allOrganizations: [mockOrganization], + handleFilterChange: vi.fn(), + handleFilterReset: vi.fn(), + }); +}); + +it("should render VirtualKeysTable component", () => { + const mockProps = { + teams: [mockTeam], + organizations: [mockOrganization], + onSortChange: vi.fn(), + currentSort: { + sortBy: "created_at", + sortOrder: "desc" as const, + }, + }; + + renderWithProviders(); + + expect(screen.getByText("Test Key Alias")).toBeInTheDocument(); +}); + +it("should display key information correctly", async () => { + const mockProps = { + teams: [mockTeam], + organizations: [mockOrganization], + onSortChange: vi.fn(), + currentSort: { + sortBy: "created_at", + sortOrder: "desc" as const, + }, + }; + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Test Key Alias")).toBeInTheDocument(); + expect(screen.getByText("Test Team")).toBeInTheDocument(); + expect(screen.getByText("5.5000")).toBeInTheDocument(); + }); +}); + +it("should display user email correctly", async () => { + const mockProps = { + teams: [mockTeam], + organizations: [mockOrganization], + onSortChange: vi.fn(), + currentSort: { + sortBy: "created_at", + sortOrder: "desc" as const, + }, + }; + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("user@example.com")).toBeInTheDocument(); + }); +}); + +it("should show skeleton loaders when isLoading is true", () => { + // Mock loading state + mockUseKeys.mockReturnValue({ + data: null, + isPending: true, + refetch: vi.fn(), + } as any); + + const mockProps = { + teams: [mockTeam], + organizations: [mockOrganization], + onSortChange: vi.fn(), + currentSort: { + sortBy: "created_at", + sortOrder: "desc" as const, + }, + }; + + renderWithProviders(); + + // Check that loading message is shown + expect(screen.getByText("🚅 Loading keys...")).toBeInTheDocument(); + + // Check that actual key data is not shown + expect(screen.queryByText("Test Key Alias")).not.toBeInTheDocument(); + expect(screen.queryByText("Test Team")).not.toBeInTheDocument(); +}); diff --git a/ui/litellm-dashboard/src/components/all_keys_table.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx similarity index 71% rename from ui/litellm-dashboard/src/components/all_keys_table.tsx rename to ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index a915fe06179..b95d675979c 100644 --- a/ui/litellm-dashboard/src/components/all_keys_table.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -1,130 +1,55 @@ "use client"; -import React, { useEffect, useState } from "react"; -import { ColumnDef } from "@tanstack/react-table"; -import { Select, SelectItem } from "@tremor/react"; -import { Button } from "@tremor/react"; -import KeyInfoView from "./templates/key_info_view"; -import { Tooltip } from "antd"; -import { Team, KeyResponse } from "./key_team_helpers/key_list"; -import FilterComponent from "./molecules/filter"; -import { FilterOption } from "./molecules/filter"; -import { Organization, userListCall } from "./networking"; -import { useFilterLogic } from "./key_team_helpers/filter_logic"; -import { Setter } from "@/types"; -import { updateExistingKeys } from "@/utils/dataUtils"; -import { flexRender, getCoreRowModel, getSortedRowModel, SortingState, useReactTable } from "@tanstack/react-table"; -import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell, Icon } from "@tremor/react"; -import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline"; -import { Badge, Text } from "@tremor/react"; -import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key"; +import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + PaginationState, + SortingState, + useReactTable, +} from "@tanstack/react-table"; +import { + Badge, + Button, + Icon, + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, + Text, +} from "@tremor/react"; +import { Skeleton, Tooltip } from "antd"; +import React, { useEffect, useState } from "react"; +import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; +import { useFilterLogic } from "../key_team_helpers/filter_logic"; +import { KeyResponse, Team } from "../key_team_helpers/key_list"; +import FilterComponent, { FilterOption } from "../molecules/filter"; +import { Organization } from "../networking"; +import KeyInfoView from "../templates/key_info_view"; -interface AllKeysTableProps { - keys: KeyResponse[]; - setKeys: (keys: KeyResponse[] | ((prev: KeyResponse[]) => KeyResponse[])) => void; - isLoading?: boolean; - pagination: { - currentPage: number; - totalPages: number; - totalCount: number; - }; - onPageChange: (page: number) => void; - pageSize?: number; +interface VirtualKeysTableProps { teams: Team[] | null; - selectedTeam: Team | null; - setSelectedTeam: (team: Team | null) => void; - selectedKeyAlias: string | null; - setSelectedKeyAlias: Setter; - accessToken: string | null; - userID: string | null; - userRole: string | null; organizations: Organization[] | null; - setCurrentOrg: React.Dispatch>; - refresh?: () => void; onSortChange?: (sortBy: string, sortOrder: "asc" | "desc") => void; currentSort?: { sortBy: string; sortOrder: "asc" | "desc"; }; - premiumUser: boolean; - setAccessToken?: (token: string) => void; } -// Define columns similar to our logs table - -interface UserResponse { - user_id: string; - user_email: string; - user_role: string; -} - -const TeamFilter = ({ - teams, - selectedTeam, - setSelectedTeam, -}: { - teams: Team[] | null; - selectedTeam: Team | null; - setSelectedTeam: (team: Team | null) => void; -}) => { - const handleTeamChange = (value: string) => { - const team = teams?.find((t) => t.team_id === value); - setSelectedTeam(team || null); - }; - - return ( -
-
- Where Team is - -
-
- ); -}; - /** - * AllKeysTable – a new table for keys that mimics the table styling used in view_logs. + * VirtualKeysTable – a new table for keys that mimics the table styling used in view_logs. * The team selector and filtering have been removed so that all keys are shown. */ -export function AllKeysTable({ - keys, - setKeys, - isLoading = false, - pagination, - onPageChange, - pageSize = 50, - teams, - selectedTeam, - setSelectedTeam, - selectedKeyAlias, - setSelectedKeyAlias, - accessToken, - userID, - userRole, - organizations, - setCurrentOrg, - refresh, - onSortChange, - currentSort, - premiumUser, - setAccessToken, -}: AllKeysTableProps) { - const [selectedKeyId, setSelectedKeyId] = useState(null); - const [userList, setUserList] = useState([]); +export function VirtualKeysTable({ teams, organizations, onSortChange, currentSort }: VirtualKeysTableProps) { + const [selectedKey, setSelectedKey] = useState(null); const [sorting, setSorting] = React.useState(() => { if (currentSort) { return [ @@ -141,34 +66,33 @@ export function AllKeysTable({ }, ]; }); + const [tablePagination, setTablePagination] = React.useState({ + pageIndex: 0, + pageSize: 100, + }); + + const { + data: keys, + isPending: isLoading, + refetch, + } = useKeys(tablePagination.pageIndex + 1, tablePagination.pageSize); + const totalCount = keys?.total_count || 0; const [expandedAccordions, setExpandedAccordions] = useState>({}); // Use the filter logic hook const { filters, filteredKeys, allKeyAliases, allTeams, allOrganizations, handleFilterChange, handleFilterReset } = useFilterLogic({ - keys, + keys: keys?.keys || [], teams, organizations, - accessToken, }); - useEffect(() => { - if (accessToken) { - const user_IDs = keys.map((key) => key.user_id).filter((id) => id !== null); - const fetchUserList = async () => { - const userListData = await userListCall(accessToken, user_IDs, 1, 100); - setUserList(userListData.users); - }; - fetchUserList(); - } - }, [accessToken, keys]); - // Add a useEffect to call refresh when a key is created useEffect(() => { - if (refresh) { + if (refetch) { const handleStorageChange = () => { - refresh(); + refetch(); }; // Listen for storage events that might indicate a key was created @@ -178,12 +102,13 @@ export function AllKeysTable({ window.removeEventListener("storage", handleStorageChange); }; } - }, [refresh]); + }, [refetch]); const columns: ColumnDef[] = [ { id: "expander", header: () => null, + size: 40, cell: ({ row }) => row.getCanExpand() ? ( @@ -214,10 +140,16 @@ export function AllKeysTable({ id: "key_alias", accessorKey: "key_alias", header: "Key Alias", + size: 150, cell: (info) => { const value = info.getValue() as string; + const width = info.cell.column.getSize(); return ( - {value ? (value.length > 20 ? `${value.slice(0, 20)}...` : value) : "-"} + + + {value ?? "-"} + + ); }, }, @@ -225,12 +157,14 @@ export function AllKeysTable({ id: "key_name", accessorKey: "key_name", header: "Secret Key", + size: 120, cell: (info) => {info.getValue() as string}, }, { id: "team_alias", accessorKey: "team_id", header: "Team Alias", + size: 120, cell: ({ row, getValue }) => { const teamId = getValue() as string; const team = teams?.find((t) => t.team_id === teamId); @@ -241,6 +175,7 @@ export function AllKeysTable({ id: "team_id", accessorKey: "team_id", header: "Team ID", + size: 120, cell: (info) => ( {info.getValue() ? `${(info.getValue() as string).slice(0, 7)}...` : "-"} @@ -251,21 +186,24 @@ export function AllKeysTable({ id: "organization_id", accessorKey: "organization_id", header: "Organization ID", + size: 140, cell: (info) => (info.getValue() ? info.renderValue() : "-"), }, { id: "user_email", - accessorKey: "user_id", + accessorKey: "user", header: "User Email", + size: 160, cell: (info) => { - const userId = info.getValue() as string; - const user = userList.find((u) => u.user_id === userId); - return user?.user_email ? ( - - {user?.user_email.slice(0, 20)}... + const user = info.getValue() as any; + const value = user?.user_email; + const width = info.cell.column.getSize(); + return ( + + + {value ?? "-"} + - ) : ( - "-" ); }, }, @@ -273,6 +211,7 @@ export function AllKeysTable({ id: "user_id", accessorKey: "user_id", header: "User ID", + size: 120, cell: (info) => { const userId = info.getValue() as string | null; if (userId && userId.length > 15) { @@ -289,6 +228,7 @@ export function AllKeysTable({ id: "created_at", accessorKey: "created_at", header: "Created At", + size: 120, cell: (info) => { const value = info.getValue(); return value ? new Date(value as string).toLocaleDateString() : "-"; @@ -298,6 +238,7 @@ export function AllKeysTable({ id: "created_by", accessorKey: "created_by", header: "Created By", + size: 120, cell: (info) => { const value = info.getValue() as string | null; if (value && value.length > 15) { @@ -314,6 +255,7 @@ export function AllKeysTable({ id: "updated_at", accessorKey: "updated_at", header: "Updated At", + size: 120, cell: (info) => { const value = info.getValue(); return value ? new Date(value as string).toLocaleDateString() : "Never"; @@ -323,6 +265,7 @@ export function AllKeysTable({ id: "expires", accessorKey: "expires", header: "Expires", + size: 120, cell: (info) => { const value = info.getValue(); return value ? new Date(value as string).toLocaleDateString() : "Never"; @@ -332,12 +275,14 @@ export function AllKeysTable({ id: "spend", accessorKey: "spend", header: "Spend (USD)", + size: 100, cell: (info) => formatNumberWithCommas(info.getValue() as number, 4), }, { id: "max_budget", accessorKey: "max_budget", header: "Budget (USD)", + size: 110, cell: (info) => { const maxBudget = info.getValue() as number | null; if (maxBudget === null) { @@ -350,6 +295,7 @@ export function AllKeysTable({ id: "budget_reset_at", accessorKey: "budget_reset_at", header: "Budget Reset", + size: 130, cell: (info) => { const value = info.getValue(); return value ? new Date(value as string).toLocaleString() : "Never"; @@ -359,6 +305,7 @@ export function AllKeysTable({ id: "models", accessorKey: "models", header: "Models", + size: 200, cell: (info) => { const models = info.getValue() as string[]; return ( @@ -442,6 +389,7 @@ export function AllKeysTable({ { id: "rate_limits", header: "Rate Limits", + size: 140, cell: ({ row }) => { const key = row.original; return ( @@ -527,8 +475,11 @@ export function AllKeysTable({ const table = useReactTable({ data: filteredKeys, columns: columns.filter((col) => col.id !== "expander"), + columnResizeMode: "onChange", + columnResizeDirection: "ltr", state: { sorting, + pagination: tablePagination, }, onSortingChange: (updaterOrValue) => { const newSorting = typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue; @@ -547,10 +498,14 @@ export function AllKeysTable({ onSortChange?.(sortBy, sortOrder); } }, + onPaginationChange: setTablePagination, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), enableSorting: true, manualSorting: false, + manualPagination: true, + pageCount: Math.ceil(totalCount / tablePagination.pageSize), }); // Update local sorting state when currentSort prop changes @@ -565,34 +520,18 @@ export function AllKeysTable({ } }, [currentSort]); + const { pageIndex, pageSize } = table.getState().pagination; + const start = pageIndex * pageSize + 1; + const end = Math.min((pageIndex + 1) * pageSize, totalCount); + const rangeLabel = `${start} - ${end}`; return (
- {selectedKeyId ? ( + {selectedKey ? ( setSelectedKeyId(null)} - keyData={filteredKeys.find((k) => k.token === selectedKeyId)} - onKeyDataUpdate={(updatedKeyData) => { - setKeys((keys) => - keys.map((key) => { - if (key.token === updatedKeyData.token) { - return updateExistingKeys(key, updatedKeyData); - } - return key; - }), - ); - if (refresh) refresh(); // Minimal fix: refresh the full key list after an update - }} - onDelete={() => { - setKeys((keys) => keys.filter((key) => key.token !== selectedKeyId)); - if (refresh) refresh(); // Minimal fix: refresh the full key list after a delete - }} - accessToken={accessToken} - userID={userID} - userRole={userRole} + keyId={selectedKey.token} + onClose={() => setSelectedKey(null)} + keyData={selectedKey} teams={allTeams} - premiumUser={premiumUser} - setAccessToken={setAccessToken} /> ) : (
@@ -606,51 +545,80 @@ export function AllKeysTable({
- - Showing{" "} - {isLoading - ? "..." - : `${(pagination.currentPage - 1) * pageSize + 1} - ${Math.min(pagination.currentPage * pageSize, pagination.totalCount)}`}{" "} - of {isLoading ? "..." : pagination.totalCount} results - + {isLoading ? ( + + ) : ( + + Showing {rangeLabel} of {totalCount} results + + )}
- - Page {isLoading ? "..." : pagination.currentPage} of {isLoading ? "..." : pagination.totalPages} - + {isLoading ? ( + + ) : ( + + Page {pageIndex + 1} of {table.getPageCount()} + + )} - + {isLoading ? ( + + ) : ( + + )} - + {isLoading ? ( + + ) : ( + + )}
- +
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( { + const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`); + if (resizer) { + (resizer as HTMLElement).style.opacity = "0.5"; + } + }} + onMouseLeave={() => { + const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`); + if (resizer && !header.column.getIsResizing()) { + (resizer as HTMLElement).style.opacity = "0"; + } + }} onClick={header.column.getToggleSortingHandler()} >
@@ -671,6 +639,24 @@ export function AllKeysTable({ )}
)} +
header.column.resetSize()} + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + className={`resizer ${table.options.columnResizeDirection} ${header.column.getIsResizing() ? "isResizing" : ""}`} + style={{ + position: "absolute", + right: 0, + top: 0, + height: "100%", + width: "5px", + background: header.column.getIsResizing() ? "#3b82f6" : "transparent", + cursor: "col-resize", + userSelect: "none", + touchAction: "none", + opacity: header.column.getIsResizing() ? 1 : 0, + }} + />
))} @@ -693,6 +679,7 @@ export function AllKeysTable({ ({ + ProviderLogo: ({ provider, className }: { provider: string; className?: string }) => ( +
+ {provider} +
+ ), +})); + +vi.mock("../networking", async () => { + const actual = await vi.importActual("../networking"); + return { + ...actual, + getGuardrailsList: vi.fn().mockResolvedValue({ + guardrails: [{ guardrail_name: "test-guardrail-1" }, { guardrail_name: "test-guardrail-2" }], + }), + tagListCall: vi.fn().mockResolvedValue({}), + modelAvailableCall: vi.fn().mockResolvedValue({ + data: [{ id: "model-group-1" }, { id: "model-group-2" }], + }), + modelHubCall: vi.fn().mockResolvedValue({ + data: [ + { model_group: "gpt-4", mode: "chat" }, + { model_group: "gpt-3.5-turbo", mode: "chat" }, + ], + }), + getProviderCreateMetadata: vi.fn().mockResolvedValue([ + { + provider: "OpenAI", + provider_display_name: "OpenAI", + litellm_provider: "openai", + default_model_placeholder: "gpt-3.5-turbo", + credential_fields: [], + }, + ]), + }; +}); + +vi.mock("@/app/(dashboard)/hooks/providers/useProviderFields", () => ({ + useProviderFields: vi.fn().mockReturnValue({ + data: [ + { + provider: "OpenAI", + provider_display_name: "OpenAI", + litellm_provider: "openai", + default_model_placeholder: "gpt-3.5-turbo", + credential_fields: [], + }, + ], + isLoading: false, + error: null, + }), +})); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: vi.fn(), +})); + +vi.mock("@/app/(dashboard)/hooks/guardrails/useGuardrails", () => ({ + useGuardrails: vi.fn().mockReturnValue({ + data: [{ guardrail_name: "test-guardrail" }], + isLoading: false, + error: null, + }), +})); + +vi.mock("@/app/(dashboard)/hooks/tags/useTags", () => ({ + useTags: vi.fn().mockReturnValue({ + data: { tag1: ["model1", "model2"] }, + isLoading: false, + error: null, + }), +})); + +const mockAuthorizedUser = (userRole: string, userId: string, premiumUser: boolean) => ({ + token: "test-token", + accessToken: "test-access-token", + userId, + userEmail: "test@example.com", + userRole, + premiumUser, + disabledPersonalKeyCreation: false, + showSSOBanner: false, +}); + +const testTeam: Team = { + team_id: "team-1", + team_alias: "Test Team", + models: ["gpt-4"], + max_budget: 100, + budget_duration: "monthly", + tpm_limit: null, + rpm_limit: null, + organization_id: "org-1", + created_at: "2024-01-01T00:00:00Z", + keys: [], + members_with_roles: [], +}; + +const createTestProps = (userRole = "proxy_admin", userId = "user-1", isTeamAdmin = false) => { + const { result } = renderHook(() => Form.useForm()); + const [form] = result.current; + + const teams = [ + { + ...testTeam, + members_with_roles: isTeamAdmin ? [{ user_id: userId, role: "admin" }] : [], + }, + ]; + + const credentials: CredentialItem[] = [ + { + credential_name: "test-credential", + credential_values: {}, + credential_info: { + custom_llm_provider: "openai", + description: "Test credential", + }, + }, + ]; + + const uploadProps: UploadProps = { + beforeUpload: () => false, + showUploadList: false, + }; + + return { + form, + handleOk: vi.fn(), + setSelectedProvider: vi.fn(), + setProviderModelsFn: vi.fn(), + getPlaceholder: vi.fn((provider: Providers) => `Enter ${provider} model name`), + setShowAdvancedSettings: vi.fn(), + selectedProvider: Providers.OpenAI, + providerModels: ["gpt-4", "gpt-3.5-turbo"], + showAdvancedSettings: false, + teams, + credentials, + uploadProps, + userRole, + userId, + }; +}; + +describe("AddModelForm", () => { + it("should render", async () => { + const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized")); + mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser("proxy_admin", "user-1", true)); + + const props = createTestProps(); + + renderWithProviders(); + + expect(await screen.findByRole("heading", { name: "Add Model" })).toBeInTheDocument(); + }); + + it("should show proxy admin only (not team admin) - should not see Select Team dropdown unless switch is toggled", async () => { + const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized")); + mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser("proxy_admin", "user-1", true)); + + const props = createTestProps("proxy_admin", "user-1", false); + + renderWithProviders(); + + await screen.findByText("Provider"); + + expect(screen.queryByText("Team Selection Required")).not.toBeInTheDocument(); + expect(screen.queryByText("Select Team")).not.toBeInTheDocument(); + + const teamSwitch = screen.getByRole("switch"); + expect(teamSwitch).toBeInTheDocument(); + + expect(screen.queryByText("Select Team")).not.toBeInTheDocument(); + + await userEvent.click(teamSwitch); + + expect(await screen.findByText("Select Team")).toBeInTheDocument(); + }); + + it("should show proxy admin who is also team admin - should not see Select Team dropdown unless switch is toggled", async () => { + const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized")); + mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser("proxy_admin", "user-1", true)); + + const props = createTestProps("proxy_admin", "user-1", true); + + renderWithProviders(); + + await screen.findByText("Provider"); + + expect(screen.queryByText("Team Selection Required")).not.toBeInTheDocument(); + expect(screen.queryByText("Select Team")).not.toBeInTheDocument(); + + const teamSwitch = screen.getByRole("switch"); + expect(teamSwitch).toBeInTheDocument(); + + expect(screen.queryByText("Select Team")).not.toBeInTheDocument(); + + await userEvent.click(teamSwitch); + + expect(await screen.findByText("Select Team")).toBeInTheDocument(); + }); + + it("should show team admin (not proxy admin) - should see alert and team select, must select team before seeing remaining fields", async () => { + const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized")); + mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser("team_member", "user-1", true)); + + const props = createTestProps("team_member", "user-1", true); + + renderWithProviders(); + + await screen.findByRole("heading", { name: "Add Model" }); + + expect(screen.getByText("Team Selection Required")).toBeInTheDocument(); + + expect(screen.getByText("Select Team")).toBeInTheDocument(); + + expect(screen.queryByText("Provider")).not.toBeInTheDocument(); + + const teamSelect = screen.getByRole("combobox"); + await userEvent.click(teamSelect); + await userEvent.click(screen.getByText("Test Team")); + + await waitFor(() => { + expect(screen.getByText("Provider")).toBeInTheDocument(); + }); + }); + + it("should show team admin (not proxy admin) - should not see team-BYOK switch", async () => { + const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized")); + mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser("team_member", "user-1", true)); + + const props = createTestProps("team_member", "user-1", true); + + renderWithProviders(); + + await screen.findByText("Select Team"); + + const teamSelect = screen.getByRole("combobox"); + await userEvent.click(teamSelect); + await userEvent.click(screen.getByText("Test Team")); + + await waitFor(() => { + expect(screen.getByText("Provider")).toBeInTheDocument(); + }); + + expect(screen.queryByRole("switch")).not.toBeInTheDocument(); + }); + + it("should handle non-admin, non-team-admin users - should not see team selection or switch", async () => { + const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized")); + mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser("user", "user-1", false)); + + const props = createTestProps("user", "user-1", false); + + renderWithProviders(); + + await screen.findByRole("heading", { name: "Add Model" }); + + expect(screen.queryByText("Team Selection Required")).not.toBeInTheDocument(); + + expect(screen.queryByText("Select Team")).not.toBeInTheDocument(); + + expect(screen.queryByText("Provider")).not.toBeInTheDocument(); + + expect(screen.queryByRole("switch")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx new file mode 100644 index 00000000000..59ac63cffe6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx @@ -0,0 +1,421 @@ +import { useProviderFields } from "@/app/(dashboard)/hooks/providers/useProviderFields"; +import { useGuardrails } from "@/app/(dashboard)/hooks/guardrails/useGuardrails"; +import { useTags } from "@/app/(dashboard)/hooks/tags/useTags"; +import { all_admin_roles, isUserTeamAdminForAnyTeam } from "@/utils/roles"; +import { Switch, Text } from "@tremor/react"; +import type { FormInstance } from "antd"; +import { Select as AntdSelect, Button, Card, Col, Form, Modal, Row, Tooltip, Typography, Alert } from "antd"; +import type { UploadProps } from "antd/es/upload"; +import React, { useEffect, useMemo, useState } from "react"; +import TeamDropdown from "../common_components/team_dropdown"; +import type { Team } from "../key_team_helpers/key_list"; +import { type CredentialItem, type ProviderCreateInfo, modelAvailableCall } from "../networking"; +import { Providers, providerLogoMap } from "../provider_info_helpers"; +import { ProviderLogo } from "../molecules/models/ProviderLogo"; +import AdvancedSettings from "./advanced_settings"; +import ConditionalPublicModelName from "./conditional_public_model_name"; +import LiteLLMModelNameField from "./litellm_model_name"; +import ConnectionErrorDisplay from "./model_connection_test"; +import ProviderSpecificFields from "./provider_specific_fields"; +import { TEST_MODES } from "./add_model_modes"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +interface AddModelFormProps { + form: FormInstance; // For the Add Model tab + handleOk: () => Promise; + selectedProvider: Providers; + setSelectedProvider: (provider: Providers) => void; + providerModels: string[]; + setProviderModelsFn: (provider: Providers) => void; + getPlaceholder: (provider: Providers) => string; + uploadProps: UploadProps; + showAdvancedSettings: boolean; + setShowAdvancedSettings: (show: boolean) => void; + teams: Team[] | null; + credentials: CredentialItem[]; +} + +const { Title, Link } = Typography; + +const AddModelForm: React.FC = ({ + form, + handleOk, + selectedProvider, + setSelectedProvider, + providerModels, + setProviderModelsFn, + getPlaceholder, + uploadProps, + showAdvancedSettings, + setShowAdvancedSettings, + teams, + credentials, +}) => { + const [testMode, setTestMode] = useState("chat"); + const [isResultModalVisible, setIsResultModalVisible] = useState(false); + const [isTestingConnection, setIsTestingConnection] = useState(false); + // Using a unique ID to force the ConnectionErrorDisplay to remount and run a fresh test + const [connectionTestId, setConnectionTestId] = useState(""); + + const { accessToken, userRole, premiumUser, userId } = useAuthorized(); + const { + data: providerMetadata, + isLoading: isProviderMetadataLoading, + error: providerMetadataError, + } = useProviderFields(); + const { data: guardrailsList, isLoading: isGuardrailsLoading, error: guardrailsError } = useGuardrails(); + const { data: tagsList, isLoading: isTagsLoading, error: tagsError } = useTags(); + + const handleTestConnection = async () => { + setIsTestingConnection(true); + setConnectionTestId(`test-${Date.now()}`); + setIsResultModalVisible(true); + }; + + const [isTeamOnly, setIsTeamOnly] = useState(false); + const [modelAccessGroups, setModelAccessGroups] = useState([]); + // Team admin specific state + const [teamAdminSelectedTeam, setTeamAdminSelectedTeam] = useState(null); + + useEffect(() => { + const fetchModelAccessGroups = async () => { + const response = await modelAvailableCall(accessToken, "", "", false, null, true, true); + setModelAccessGroups(response["data"].map((model: any) => model["id"])); + }; + fetchModelAccessGroups(); + }, [accessToken]); + + const sortedProviderMetadata: ProviderCreateInfo[] = useMemo(() => { + if (!providerMetadata) { + return []; + } + return [...providerMetadata].sort((a, b) => a.provider_display_name.localeCompare(b.provider_display_name)); + }, [providerMetadata]); + + const providerMetadataErrorText = providerMetadataError + ? providerMetadataError instanceof Error + ? providerMetadataError.message + : "Failed to load providers" + : null; + + const isAdmin = all_admin_roles.includes(userRole); + const isTeamAdmin = isUserTeamAdminForAnyTeam(teams, userId); + + return ( + <> + Add Model + + +
{ + console.log("🔥 Form onFinish triggered with values:", values); + await handleOk().then(() => { + setTeamAdminSelectedTeam(null); + }); + }} + onFinishFailed={(errorInfo) => { + console.log("💥 Form onFinishFailed triggered:", errorInfo); + }} + labelCol={{ span: 10 }} + wrapperCol={{ span: 16 }} + labelAlign="left" + > + <> + {isTeamAdmin && !isAdmin && ( + <> + + { + setTeamAdminSelectedTeam(value); + }} + /> + + {!teamAdminSelectedTeam && ( + + )} + + )} + {(isAdmin || (isTeamAdmin && teamAdminSelectedTeam)) && ( + <> + + { + setSelectedProvider(value as Providers); + setProviderModelsFn(value as Providers); + form.setFieldsValue({ + custom_llm_provider: value, + }); + form.setFieldsValue({ + model: [], + model_name: undefined, + }); + }} + > + {providerMetadataErrorText && sortedProviderMetadata.length === 0 && ( + + {providerMetadataErrorText} + + )} + {sortedProviderMetadata.map((providerInfo) => { + const displayName = providerInfo.provider_display_name; + const providerKey = providerInfo.provider; + const logoSrc = providerLogoMap[displayName] ?? ""; + + return ( + +
+ + {displayName} +
+
+ ); + })} +
+
+ + + {/* Conditionally Render "Public Model Name" */} + + + {/* Select Mode */} + + setTestMode(value)} + options={TEST_MODES} + /> + + +
+ + + Optional - LiteLLM endpoint to use when health checking this model{" "} + + Learn more + + + + + + {/* Credentials */} +
+ + Either select existing credentials OR enter new provider credentials below + +
+ + + (option?.label ?? "").toLowerCase().includes(input.toLowerCase())} + options={[ + { value: null, label: "None" }, + ...credentials.map((credential) => ({ + value: credential.credential_name, + label: credential.credential_name, + })), + ]} + allowClear + /> + + + + prevValues.litellm_credential_name !== currentValues.litellm_credential_name || + prevValues.provider !== currentValues.provider + } + > + {({ getFieldValue }) => { + const credentialName = getFieldValue("litellm_credential_name"); + console.log("🔑 Credential Name Changed:", credentialName); + // Only show provider specific fields if no credentials selected + if (!credentialName) { + return ( + <> +
+
+ OR +
+
+ + + ); + } + return null; + }} +
+
+
+ Additional Model Info Settings +
+
+ {/* Team-only Model Switch - Only show for proxy admins, not team admins */} + {(isAdmin || !isTeamAdmin) && ( + + + { + setIsTeamOnly(checked); + if (!checked) { + form.setFieldValue("team_id", undefined); + } + }} + disabled={!premiumUser} + /> + + + )} + + {/* Conditional Team Selection */} + {isTeamOnly && (isAdmin || !isTeamAdmin) && ( + + + + )} + {isAdmin && ( + <> + + ({ + value: group, + label: group, + }))} + maxTagCount="responsive" + allowClear + /> + + + )} + + + )} +
+ + Need Help? + +
+ + +
+
+ + + + + {/* Test Connection Results Modal */} + { + setIsResultModalVisible(false); + setIsTestingConnection(false); + }} + footer={[ + , + ]} + width={700} + > + {/* Only render the ConnectionErrorDisplay when modal is visible and we have a test ID */} + {isResultModalVisible && ( + { + setIsResultModalVisible(false); + setIsTestingConnection(false); + }} + onTestComplete={() => setIsTestingConnection(false)} + /> + )} + + + ); +}; + +export default AddModelForm; diff --git a/ui/litellm-dashboard/src/components/add_model/add_model_tab.test.tsx b/ui/litellm-dashboard/src/components/add_model/add_model_tab.test.tsx index 0c353621654..197bcd6569f 100644 --- a/ui/litellm-dashboard/src/components/add_model/add_model_tab.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/add_model_tab.test.tsx @@ -1,5 +1,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, renderHook, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { Form } from "antd"; import type { UploadProps } from "antd/es/upload"; import { describe, expect, it, vi } from "vitest"; @@ -8,6 +9,14 @@ import type { CredentialItem } from "../networking"; import { Providers } from "../provider_info_helpers"; import AddModelTab from "./add_model_tab"; +vi.mock("../molecules/models/ProviderLogo", () => ({ + ProviderLogo: ({ provider, className }: { provider: string; className?: string }) => ( +
+ {provider} +
+ ), +})); + vi.mock("../networking", async () => { const actual = await vi.importActual("../networking"); return { @@ -53,6 +62,14 @@ vi.mock("@/app/(dashboard)/hooks/providers/useProviderFields", () => ({ }), })); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: vi.fn().mockReturnValue({ + accessToken: "test-access-token", + userRole: "Admin", + premiumUser: true, + }), +})); + const createQueryClient = () => new QueryClient({ defaultOptions: { @@ -128,7 +145,6 @@ const createTestProps = () => { uploadProps, accessToken: "test-access-token", userRole: "Admin", - premiumUser: true, }; }; @@ -154,7 +170,6 @@ describe("Add Model Tab", () => { credentials={props.credentials} accessToken={props.accessToken} userRole={props.userRole} - premiumUser={props.premiumUser} /> , ); @@ -183,7 +198,6 @@ describe("Add Model Tab", () => { credentials={props.credentials} accessToken={props.accessToken} userRole={props.userRole} - premiumUser={props.premiumUser} /> , ); @@ -213,7 +227,6 @@ describe("Add Model Tab", () => { credentials={props.credentials} accessToken={props.accessToken} userRole={props.userRole} - premiumUser={props.premiumUser} /> , ); @@ -242,7 +255,6 @@ describe("Add Model Tab", () => { credentials={props.credentials} accessToken={props.accessToken} userRole={props.userRole} - premiumUser={props.premiumUser} /> , ); @@ -258,4 +270,46 @@ describe("Add Model Tab", () => { { timeout: 10000 }, ); }, 15000); // 15 second timeout to allow waitFor to complete + + it("should show team selection when team-only switch is enabled", async () => { + const props = createTestProps(); + const queryClient = createQueryClient(); + + render( + + + , + ); + + // Wait for component to load + await screen.findByText("Provider"); + + // Find the team-BYOK switch by its role + const teamSwitch = screen.getByRole("switch"); + expect(teamSwitch).toBeInTheDocument(); + + // Initially, team selection should not be visible + expect(screen.queryByText("Select Team")).not.toBeInTheDocument(); + + // Click the switch to enable team-only mode + await userEvent.click(teamSwitch!); + + // Now team selection should be visible + expect(await screen.findByText("Select Team")).toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/components/add_model/add_model_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_model_tab.tsx index b2e1dec2827..f9b6533ac60 100644 --- a/ui/litellm-dashboard/src/components/add_model/add_model_tab.tsx +++ b/ui/litellm-dashboard/src/components/add_model/add_model_tab.tsx @@ -1,33 +1,18 @@ -import { useProviderFields } from "@/app/(dashboard)/hooks/providers/useProviderFields"; -import { all_admin_roles } from "@/utils/roles"; -import { Switch, Tab, TabGroup, TabList, TabPanel, TabPanels, Text } from "@tremor/react"; +import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react"; import type { FormInstance } from "antd"; -import { Select as AntdSelect, Button, Card, Col, Form, Modal, Row, Tooltip, Typography } from "antd"; +import { Form } from "antd"; import type { UploadProps } from "antd/es/upload"; -import React, { useEffect, useMemo, useState } from "react"; -import TeamDropdown from "../common_components/team_dropdown"; +import React from "react"; import type { Team } from "../key_team_helpers/key_list"; -import { - type CredentialItem, - type ProviderCreateInfo, - getGuardrailsList, - modelAvailableCall, - tagListCall, -} from "../networking"; -import { Providers, providerLogoMap } from "../provider_info_helpers"; -import { Tag } from "../tag_management/types"; +import { type CredentialItem } from "../networking"; +import { Providers } from "../provider_info_helpers"; import AddAutoRouterTab from "./add_auto_router_tab"; -import { TEST_MODES } from "./add_model_modes"; -import AdvancedSettings from "./advanced_settings"; -import ConditionalPublicModelName from "./conditional_public_model_name"; +import AddModelForm from "./AddModelForm"; import { handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit"; -import LiteLLMModelNameField from "./litellm_model_name"; -import ConnectionErrorDisplay from "./model_connection_test"; -import ProviderSpecificFields from "./provider_specific_fields"; interface AddModelTabProps { form: FormInstance; // For the Add Model tab - handleOk: () => void; + handleOk: (values?: any) => Promise; selectedProvider: Providers; setSelectedProvider: (provider: Providers) => void; providerModels: string[]; @@ -40,11 +25,8 @@ interface AddModelTabProps { credentials: CredentialItem[]; accessToken: string; userRole: string; - premiumUser: boolean; } -const { Title, Link } = Typography; - const AddModelTab: React.FC = ({ form, handleOk, @@ -60,90 +42,9 @@ const AddModelTab: React.FC = ({ credentials, accessToken, userRole, - premiumUser, }) => { // Create separate form instance for auto router const [autoRouterForm] = Form.useForm(); - // State for test mode and connection testing - const [testMode, setTestMode] = useState("chat"); - const [isResultModalVisible, setIsResultModalVisible] = useState(false); - const [isTestingConnection, setIsTestingConnection] = useState(false); - const [guardrailsList, setGuardrailsList] = useState([]); - const [tagsList, setTagsList] = useState>({}); - // Using a unique ID to force the ConnectionErrorDisplay to remount and run a fresh test - const [connectionTestId, setConnectionTestId] = useState(""); - - // Provider metadata for driving the provider select from backend config - const { - data: providerMetadata, - isLoading: isProviderMetadataLoading, - error: providerMetadataError, - } = useProviderFields(); - - useEffect(() => { - const fetchGuardrails = async () => { - try { - const response = await getGuardrailsList(accessToken); - const guardrailNames = response.guardrails.map((g: { guardrail_name: string }) => g.guardrail_name); - setGuardrailsList(guardrailNames); - } catch (error) { - console.error("Failed to fetch guardrails:", error); - } - }; - - fetchGuardrails(); - }, [accessToken]); - - useEffect(() => { - const fetchTags = async () => { - try { - const response = await tagListCall(accessToken); - setTagsList(response); - } catch (error) { - console.error("Failed to fetch tags:", error); - } - }; - - fetchTags(); - }, [accessToken]); - - // Test connection when button is clicked - const handleTestConnection = async () => { - setIsTestingConnection(true); - // Generate a new test ID (using timestamp for uniqueness) - // This forces React to create a new instance of ConnectionErrorDisplay - setConnectionTestId(`test-${Date.now()}`); - // Show the modal with the fresh test - setIsResultModalVisible(true); - }; - - // State for team-only switch - const [isTeamOnly, setIsTeamOnly] = useState(false); - - const [modelAccessGroups, setModelAccessGroups] = useState([]); - - useEffect(() => { - const fetchModelAccessGroups = async () => { - const response = await modelAvailableCall(accessToken, "", "", false, null, true, true); - setModelAccessGroups(response["data"].map((model: any) => model["id"])); - }; - fetchModelAccessGroups(); - }, [accessToken]); - - const sortedProviderMetadata: ProviderCreateInfo[] = useMemo(() => { - if (!providerMetadata) { - return []; - } - return [...providerMetadata].sort((a, b) => a.provider_display_name.localeCompare(b.provider_display_name)); - }, [providerMetadata]); - - const providerMetadataErrorText = providerMetadataError - ? providerMetadataError instanceof Error - ? providerMetadataError.message - : "Failed to load providers" - : null; - - const isAdmin = all_admin_roles.includes(userRole); const handleAutoRouterOk = () => { autoRouterForm @@ -165,273 +66,20 @@ const AddModelTab: React.FC = ({ - Add Model - -
{ - console.log("🔥 Form onFinish triggered with values:", values); - handleOk(); - }} - onFinishFailed={(errorInfo) => { - console.log("💥 Form onFinishFailed triggered:", errorInfo); - }} - labelCol={{ span: 10 }} - wrapperCol={{ span: 16 }} - labelAlign="left" - > - <> - {/* Provider Selection */} - - { - setSelectedProvider(value as Providers); - setProviderModelsFn(value as Providers); - form.setFieldsValue({ - custom_llm_provider: value, - }); - form.setFieldsValue({ - model: [], - model_name: undefined, - }); - }} - > - {providerMetadataErrorText && sortedProviderMetadata.length === 0 && ( - - {providerMetadataErrorText} - - )} - {sortedProviderMetadata.map((providerInfo) => { - const displayName = providerInfo.provider_display_name; - const providerKey = providerInfo.provider; - const logoSrc = providerLogoMap[displayName] ?? ""; - - return ( - -
- {logoSrc ? ( - {`${displayName} { - const target = e.currentTarget as HTMLImageElement; - const parent = target.parentElement; - if (!parent || !parent.contains(target)) { - return; - } - - try { - const fallbackDiv = document.createElement("div"); - fallbackDiv.className = - "w-5 h-5 rounded-full bg-gray-200 flex items-center justify-center text-xs"; - fallbackDiv.textContent = displayName.charAt(0); - parent.replaceChild(fallbackDiv, target); - } catch (error) { - console.error("Failed to replace provider logo fallback:", error); - } - }} - /> - ) : ( -
- {displayName.charAt(0)} -
- )} - {displayName} -
-
- ); - })} -
-
- - - {/* Conditionally Render "Public Model Name" */} - - - {/* Select Mode */} - - setTestMode(value)} - options={TEST_MODES} - /> - - -
- - - Optional - LiteLLM endpoint to use when health checking this model{" "} - - Learn more - - - - - - {/* Credentials */} -
- - Either select existing credentials OR enter new provider credentials below - -
- - - - (option?.label ?? "").toLowerCase().includes(input.toLowerCase()) - } - options={[ - { value: null, label: "None" }, - ...credentials.map((credential) => ({ - value: credential.credential_name, - label: credential.credential_name, - })), - ]} - allowClear - /> - - - - prevValues.litellm_credential_name !== currentValues.litellm_credential_name || - prevValues.provider !== currentValues.provider - } - > - {({ getFieldValue }) => { - const credentialName = getFieldValue("litellm_credential_name"); - console.log("🔑 Credential Name Changed:", credentialName); - // Only show provider specific fields if no credentials selected - if (!credentialName) { - return ( - <> -
-
- OR -
-
- - - ); - } - return null; - }} -
-
-
- Additional Model Info Settings -
-
- {/* Team-only Model Switch */} - - - { - setIsTeamOnly(checked); - if (!checked) { - form.setFieldValue("team_id", undefined); - } - }} - disabled={!premiumUser} - /> - - - - {/* Conditional Team Selection */} - {isTeamOnly && ( - - - - )} - {isAdmin && ( - <> - - ({ - value: group, - label: group, - }))} - maxTagCount="responsive" - allowClear - /> - - - )} - - -
- - Need Help? - -
- - -
-
- - - + = ({ - - {/* Test Connection Results Modal */} - { - setIsResultModalVisible(false); - setIsTestingConnection(false); - }} - footer={[ - , - ]} - width={700} - > - {/* Only render the ConnectionErrorDisplay when modal is visible and we have a test ID */} - {isResultModalVisible && ( - { - setIsResultModalVisible(false); - setIsTestingConnection(false); - }} - onTestComplete={() => setIsTestingConnection(false)} - /> - )} - ); }; diff --git a/ui/litellm-dashboard/src/components/cache_dashboard.tsx b/ui/litellm-dashboard/src/components/cache_dashboard.tsx index 38c0f1a8f41..874cb43276e 100644 --- a/ui/litellm-dashboard/src/components/cache_dashboard.tsx +++ b/ui/litellm-dashboard/src/components/cache_dashboard.tsx @@ -1,23 +1,23 @@ -import React, { useState, useEffect } from "react"; import { - Card, BarChart, - Subtitle, - Grid, + Card, Col, DateRangePickerValue, + Grid, + Icon, MultiSelect, MultiSelectItem, - TabPanel, - TabPanels, + Subtitle, + Tab, TabGroup, TabList, - Tab, - Icon, + TabPanel, + TabPanels, Text, } from "@tremor/react"; -import UsageDatePicker from "./shared/usage_date_picker"; +import React, { useEffect, useState } from "react"; import NotificationsManager from "./molecules/notifications_manager"; +import UsageDatePicker from "./shared/usage_date_picker"; import { RefreshIcon } from "@heroicons/react/outline"; import { adminGlobalCacheActivity, cachingHealthCheckCall } from "./networking"; @@ -162,13 +162,13 @@ const CacheDashboard: React.FC = ({ accessToken, token, userRole /* Data looks like this - [{"api_key":"147dba2181f28914eea90eb484926c293cdcf7f5b5c9c3dd6a004d9e0f9fdb21","call_type":"acompletion","model":"llama3-8b-8192","total_rows":13,"cache_hit_true_rows":0}, - {"api_key":"8c23f021d0535c2e59abb7d83d0e03ccfb8db1b90e231ff082949d95df419e86","call_type":"None","model":"chatgpt-v-2","total_rows":1,"cache_hit_true_rows":0}, - {"api_key":"88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b","call_type":"acompletion","model":"gpt-3.5-turbo","total_rows":19,"cache_hit_true_rows":0}, - {"api_key":"88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b","call_type":"aimage_generation","model":"","total_rows":3,"cache_hit_true_rows":0}, - {"api_key":"0ad4b3c03dcb6de0b5b8f761db798c6a8ae80be3fd1e2ea30c07ce6d5e3bf870","call_type":"None","model":"chatgpt-v-2","total_rows":1,"cache_hit_true_rows":0}, - {"api_key":"034224b36e9769bc50e2190634abc3f97cad789b17ca80ac43b82f46cd5579b3","call_type":"","model":"chatgpt-v-2","total_rows":1,"cache_hit_true_rows":0}, - {"api_key":"4f9c71cce0a2bb9a0b62ce6f0ebb3245b682702a8851d26932fa7e3b8ebfc755","call_type":"","model":"chatgpt-v-2","total_rows":1,"cache_hit_true_rows":0}, + [{"api_key":"sk-test-mock-key-001","call_type":"acompletion","model":"llama3-8b-8192","total_rows":13,"cache_hit_true_rows":0}, + {"api_key":"sk-test-mock-key-002","call_type":"None","model":"chatgpt-v-2","total_rows":1,"cache_hit_true_rows":0}, + {"api_key":"sk-test-mock-key-123","call_type":"acompletion","model":"gpt-3.5-turbo","total_rows":19,"cache_hit_true_rows":0}, + {"api_key":"sk-test-mock-key-123","call_type":"aimage_generation","model":"","total_rows":3,"cache_hit_true_rows":0}, + {"api_key":"sk-test-mock-key-003","call_type":"None","model":"chatgpt-v-2","total_rows":1,"cache_hit_true_rows":0}, + {"api_key":"sk-test-mock-key-004","call_type":"","model":"chatgpt-v-2","total_rows":1,"cache_hit_true_rows":0}, + {"api_key":"sk-test-mock-key-005","call_type":"","model":"chatgpt-v-2","total_rows":1,"cache_hit_true_rows":0}, */ // What data we need for bar chat @@ -271,9 +271,7 @@ const CacheDashboard: React.FC = ({ accessToken, token, userRole
Cache Analytics - -
Cache Health
-
+ Cache Health Cache Settings
diff --git a/ui/litellm-dashboard/src/components/common_components/KeyLifecycleSettings.tsx b/ui/litellm-dashboard/src/components/common_components/KeyLifecycleSettings.tsx index 8129f4314fc..81d22b56347 100644 --- a/ui/litellm-dashboard/src/components/common_components/KeyLifecycleSettings.tsx +++ b/ui/litellm-dashboard/src/components/common_components/KeyLifecycleSettings.tsx @@ -64,13 +64,13 @@ const KeyLifecycleSettings: React.FC = ({
; +} diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.tsx index a074a5484f6..5428efb28aa 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.tsx @@ -6,6 +6,7 @@ import { useQuery } from "@tanstack/react-query"; import { fetchAllKeyAliases, fetchAllOrganizations, fetchAllTeams } from "./filter_helpers"; import { debounce } from "lodash"; import { defaultPageSize } from "../constants"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export interface FilterState { "Team ID": string; @@ -21,12 +22,10 @@ export function useFilterLogic({ keys, teams, organizations, - accessToken, }: { keys: KeyResponse[]; teams: Team[] | null; organizations: Organization[] | null; - accessToken: string | null; }) { const defaultFilters: FilterState = { "Team ID": "", @@ -36,6 +35,7 @@ export function useFilterLogic({ "Sort By": "created_at", "Sort Order": "desc", }; + const { accessToken } = useAuthorized(); const [filters, setFilters] = useState(defaultFilters); const [allTeams, setAllTeams] = useState(teams || []); const [allOrganizations, setAllOrganizations] = useState(organizations || []); 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 6bb014e6187..a04fbf3943d 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,6 +1,6 @@ -import { useState, useEffect } from "react"; -import { keyListCall, Member, Organization } from "../networking"; import { Setter } from "@/types"; +import { useEffect, useState } from "react"; +import { keyListCall, Member, Organization } from "../networking"; export interface Team { team_id: string; @@ -91,6 +91,10 @@ export interface KeyResponse { last_rotation_at?: string; key_rotation_at?: string; next_rotation_at?: string; + user?: { + user_id: string; + user_email: string; + }; } interface KeyListResponse { @@ -106,6 +110,7 @@ interface UseKeyListProps { selectedKeyAlias: string | null; accessToken: string; createClicked: boolean; + expand?: string[]; } interface PaginationData { @@ -129,6 +134,7 @@ const useKeyList = ({ selectedKeyAlias, accessToken, createClicked, + expand = [], }: UseKeyListProps): UseKeyListReturn => { const [keyData, setKeyData] = useState({ keys: [], @@ -151,7 +157,19 @@ const useKeyList = ({ const page = typeof params.page === "number" ? params.page : 1; const pageSize = typeof params.pageSize === "number" ? params.pageSize : 100; - const data = await keyListCall(accessToken, null, null, null, null, null, page, pageSize); + const data = await keyListCall( + accessToken, + null, + null, + null, + null, + null, + page, + pageSize, + null, + null, + expand.join(","), + ); console.log("data", data); setKeyData(data); setError(null); diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index 1512c8b9350..09109300dce 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -1,15 +1,8 @@ -import { act, fireEvent, render, waitFor } from "@testing-library/react"; +import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "../../tests/test-utils"; import Sidebar from "./leftnav"; -// Stub ResizeObserver used by antd in jsdom -class ResizeObserver { - observe() {} - unobserve() {} - disconnect() {} -} -(global as any).ResizeObserver = ResizeObserver; - vi.mock("../utils/roles", () => { return { all_admin_roles: ["admin"], @@ -19,17 +12,53 @@ vi.mock("../utils/roles", () => { }; }); +const { mockUseAuthorized, mockUseOrganizations } = vi.hoisted(() => { + const mockUseAuthorized = vi.fn(() => ({ + userId: "test-user-id", + accessToken: "test-access-token", + userRole: "admin", + token: "test-token", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: false, + showSSOBanner: false, + })); + + const mockUseOrganizations = vi.fn(() => ({ + data: [], + isLoading: false, + error: null, + })); + + return { mockUseAuthorized, mockUseOrganizations }; +}); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: mockUseAuthorized, +})); + +vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ + useOrganizations: mockUseOrganizations, +})); + +vi.mock("@/app/(dashboard)/hooks/uiConfig/useUIConfig", () => { + return { + useUIConfig: () => ({ + data: { admin_ui_disabled: false }, + isLoading: false, + }), + }; +}); + describe("Sidebar (leftnav)", () => { const defaultProps = { - accessToken: null as string | null, setPage: vi.fn(), - userRole: "admin", defaultSelectedKey: "api-keys", collapsed: false, }; it("renders all top-level (non-nested) tabs for admin", () => { - const { getByText } = render(); + renderWithProviders(); const topLevelLabels = [ "Virtual Keys", @@ -51,19 +80,19 @@ describe("Sidebar (leftnav)", () => { ]; topLevelLabels.forEach((label) => { - expect(getByText(label)).toBeInTheDocument(); + expect(screen.getByText(label)).toBeInTheDocument(); }); }); it("expands a nested tab to reveal its children (Tools > Search Tools)", async () => { - const { getByText, queryByText } = render(); + renderWithProviders(); - expect(queryByText("Search Tools")).not.toBeInTheDocument(); + expect(screen.queryByText("Search Tools")).not.toBeInTheDocument(); act(() => { - fireEvent.click(getByText("Tools")); + fireEvent.click(screen.getByText("Tools")); }); await waitFor(() => { - expect(getByText("Search Tools")).toBeInTheDocument(); + expect(screen.getByText("Search Tools")).toBeInTheDocument(); }); }); it("has no duplicate keys among all menu items and their children", () => { @@ -82,7 +111,7 @@ describe("Sidebar (leftnav)", () => { return allKeys; } - const { container } = render(); + const { container } = renderWithProviders(); const allRenderedKeys = getAllKeysFromMenu(container); const keySet = new Set(); @@ -95,4 +124,43 @@ describe("Sidebar (leftnav)", () => { } expect(duplicates).toHaveLength(0); }); + + it("should show Organizations tab for organization admins", () => { + mockUseAuthorized.mockReturnValueOnce({ + userId: "org-admin-user-id", + accessToken: "test-access-token", + userRole: "viewer", + token: "test-token", + userEmail: "orgadmin@example.com", + premiumUser: false, + disabledPersonalKeyCreation: false, + showSSOBanner: false, + }); + + mockUseOrganizations.mockReturnValueOnce({ + data: [ + { + organization_id: "org-1", + organization_name: "Test Organization", + spend: 0, + max_budget: null, + models: [], + tpm_limit: null, + rpm_limit: null, + members: [ + { + user_id: "org-admin-user-id", + user_role: "org_admin", + }, + ], + }, + ], + isLoading: false, + error: null, + } as any); + + renderWithProviders(); + + expect(screen.getByText("Organizations")).toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index ec000f7582e..fc248ee049a 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -1,3 +1,5 @@ +import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { ApiOutlined, AppstoreOutlined, @@ -21,17 +23,18 @@ import { ToolOutlined, UserOutlined, } from "@ant-design/icons"; -import { Badge, ConfigProvider, Layout, Menu } from "antd"; import type { MenuProps } from "antd"; +import { ConfigProvider, Layout, Menu } from "antd"; +import { useMemo } from "react"; import { all_admin_roles, internalUserRoles, isAdminRole, rolesWithWriteAccess } from "../utils/roles"; +import type { Organization } from "./networking"; import UsageIndicator from "./usage_indicator"; +import NewBadge from "./common_components/NewBadge"; const { Sider } = Layout; // Define the props type interface SidebarProps { - accessToken: string | null; setPage: (page: string) => void; - userRole: string; defaultSelectedKey: string; collapsed?: boolean; } @@ -53,7 +56,18 @@ interface MenuGroup { roles?: string[]; } -const Sidebar: React.FC = ({ accessToken, setPage, userRole, defaultSelectedKey, collapsed = false }) => { +const Sidebar: React.FC = ({ setPage, defaultSelectedKey, collapsed = false }) => { + const { userId, accessToken, userRole } = useAuthorized(); + const { data: organizations } = useOrganizations(); + + // Check if user is an org_admin + const isOrgAdmin = useMemo(() => { + if (!userId || !organizations) return false; + return organizations.some((org: Organization) => + org.members?.some((member) => member.user_id === userId && member.user_role === "org_admin"), + ); + }, [userId, organizations]); + // Navigate to page helper const navigateToPage = (page: string) => { const newSearchParams = new URLSearchParams(window.location.search); @@ -90,11 +104,7 @@ const Sidebar: React.FC = ({ accessToken, setPage, userRole, defau { key: "agents", page: "agents", - label: ( - - Agents - - ), + label: Agents, icon: , roles: rolesWithWriteAccess, }, @@ -142,11 +152,7 @@ const Sidebar: React.FC = ({ accessToken, setPage, userRole, defau page: "new_usage", icon: , roles: [...all_admin_roles, ...internalUserRoles], - label: ( - - Usage - - ), + label: Usage, }, { key: "logs", @@ -254,7 +260,11 @@ const Sidebar: React.FC = ({ accessToken, setPage, userRole, defau { key: "settings", page: "settings", - label: "Settings", + label: ( + + Settings + + ), icon: , roles: all_admin_roles, children: [ @@ -302,7 +312,13 @@ const Sidebar: React.FC = ({ accessToken, setPage, userRole, defau // Filter items based on user role const filterItemsByRole = (items: MenuItem[]): MenuItem[] => { return items - .filter((item) => !item.roles || item.roles.includes(userRole)) + .filter((item) => { + // Special handling for organizations menu item - allow org_admins + if (item.key === "organizations") { + return !item.roles || item.roles.includes(userRole) || isOrgAdmin; + } + return !item.roles || item.roles.includes(userRole); + }) .map((item) => ({ ...item, children: item.children ? filterItemsByRole(item.children) : undefined, diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx b/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx index 7b79f5cc707..ed429622ff8 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx +++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx @@ -1,15 +1,12 @@ -import React, { useEffect, useState } from "react"; +import { useMCPAccessGroups } from "@/app/(dashboard)/hooks/mcpServers/useMCPAccessGroups"; +import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers"; import { Select } from "antd"; -import { fetchMCPServers, fetchMCPAccessGroups } from "../networking"; -import { MCPServer } from "../mcp_tools/types"; +import React from "react"; interface MCPServerSelectorProps { - onChange: (selected: { - servers: string[]; - accessGroups: string[]; - }) => void; - value?: { - servers: string[]; + onChange: (selected: { servers: string[]; accessGroups: string[] }) => void; + value?: { + servers: string[]; accessGroups: string[]; }; className?: string; @@ -26,31 +23,10 @@ const MCPServerSelector: React.FC = ({ placeholder = "Select MCP servers", disabled = false, }) => { - const [mcpServers, setMCPServers] = useState([]); - const [accessGroups, setAccessGroups] = useState([]); - const [loading, setLoading] = useState(false); + const { data: mcpServers = [], isLoading: serversLoading } = useMCPServers(); + const { data: accessGroups = [], isLoading: groupsLoading } = useMCPAccessGroups(); - useEffect(() => { - const fetchData = async () => { - if (!accessToken) return; - setLoading(true); - try { - const [serversRes, groupsRes] = await Promise.all([ - fetchMCPServers(accessToken), - fetchMCPAccessGroups(accessToken), - ]); - let servers = Array.isArray(serversRes) ? serversRes : serversRes.data || []; - let groups = Array.isArray(groupsRes) ? groupsRes : groupsRes.data || []; - setMCPServers(servers); - setAccessGroups(groups); - } catch (error) { - console.error("Error fetching MCP servers or access groups:", error); - } finally { - setLoading(false); - } - }; - fetchData(); - }, [accessToken]); + const loading = serversLoading || groupsLoading; // Combine options, access groups first const options = [ diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx index 93f96966d0a..07b19cc5552 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../../tests/test-utils"; import MCPToolPermissions from "./MCPToolPermissions"; import * as networking from "../networking"; @@ -28,15 +29,13 @@ describe("MCPToolPermissions", () => { ]; // Mock fetchMCPServers to return server details - vi.mocked(networking.fetchMCPServers).mockResolvedValue({ - data: [ - { - server_id: mockServerId, - server_name: mockServerName, - alias: mockServerName, - }, - ], - }); + vi.mocked(networking.fetchMCPServers).mockResolvedValue([ + { + server_id: mockServerId, + server_name: mockServerName, + alias: mockServerName, + }, + ]); // Mock listMCPTools to return tools for the server vi.mocked(networking.listMCPTools).mockResolvedValue({ @@ -44,13 +43,13 @@ describe("MCPToolPermissions", () => { error: false, }); - render( + renderWithProviders( + />, ); // Wait for server and tools to load @@ -72,8 +71,111 @@ describe("MCPToolPermissions", () => { }); // Verify API calls - expect(networking.fetchMCPServers).toHaveBeenCalledWith(mockAccessToken); + // Note: useMCPServers uses useAuthorized() internally, which returns "123" from global mock + expect(networking.fetchMCPServers).toHaveBeenCalledWith("123"); + // listMCPTools uses the accessToken prop directly expect(networking.listMCPTools).toHaveBeenCalledWith(mockAccessToken, mockServerId); }); -}); + it("should select all tools when Select All button is clicked", async () => { + const mockOnChange = vi.fn(); + const mockTools = [ + { name: "read_wiki_structure", description: "Get documentation topics" }, + { name: "read_wiki_contents", description: "View documentation" }, + { name: "ask_question", description: "Ask questions" }, + ]; + + // Mock fetchMCPServers to return server details + vi.mocked(networking.fetchMCPServers).mockResolvedValue([ + { + server_id: mockServerId, + server_name: mockServerName, + alias: mockServerName, + }, + ]); + + // Mock listMCPTools to return tools for the server + vi.mocked(networking.listMCPTools).mockResolvedValue({ + tools: mockTools, + error: false, + }); + + renderWithProviders( + , + ); + + // Wait for server and tools to load + await waitFor(() => { + expect(screen.getByText(mockServerName)).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByText("read_wiki_structure")).toBeInTheDocument(); + }); + + // Click the Select All button + const selectAllButton = screen.getByRole("button", { name: "Select All" }); + await userEvent.click(selectAllButton); + + // Verify onChange was called with all tools selected + expect(mockOnChange).toHaveBeenCalledWith({ + [mockServerId]: ["read_wiki_structure", "read_wiki_contents", "ask_question"], + }); + }); + + it("should deselect all tools when Deselect All button is clicked", async () => { + const mockOnChange = vi.fn(); + const mockTools = [ + { name: "read_wiki_structure", description: "Get documentation topics" }, + { name: "read_wiki_contents", description: "View documentation" }, + { name: "ask_question", description: "Ask questions" }, + ]; + + // Mock fetchMCPServers to return server details + vi.mocked(networking.fetchMCPServers).mockResolvedValue([ + { + server_id: mockServerId, + server_name: mockServerName, + alias: mockServerName, + }, + ]); + + // Mock listMCPTools to return tools for the server + vi.mocked(networking.listMCPTools).mockResolvedValue({ + tools: mockTools, + error: false, + }); + + renderWithProviders( + , + ); + + // Wait for server and tools to load + await waitFor(() => { + expect(screen.getByText(mockServerName)).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByText("read_wiki_structure")).toBeInTheDocument(); + }); + + // Click the Deselect All button + const deselectAllButton = screen.getByRole("button", { name: "Deselect All" }); + await userEvent.click(deselectAllButton); + + // Verify onChange was called with no tools selected + expect(mockOnChange).toHaveBeenCalledWith({ + [mockServerId]: [], + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx index 3f36caf4670..4f884d3303b 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx +++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx @@ -1,9 +1,10 @@ -import React, { useEffect, useState } from "react"; -import { listMCPTools, fetchMCPServers } from "../networking"; +import React, { useEffect, useState, useMemo } from "react"; +import { listMCPTools } from "../networking"; import { MCPTool, MCPServer } from "../mcp_tools/types"; import { Text } from "@tremor/react"; import { Spin, Checkbox } from "antd"; import { XIcon } from "lucide-react"; +import { useMCPServers } from "../../app/(dashboard)/hooks/mcpServers/useMCPServers"; interface MCPToolPermissionsProps { accessToken: string; @@ -20,63 +21,43 @@ const MCPToolPermissions: React.FC = ({ onChange, disabled = false, }) => { - const [servers, setServers] = useState([]); + const { data: allServers = [] } = useMCPServers(); const [serverTools, setServerTools] = useState>({}); const [loadingTools, setLoadingTools] = useState>({}); const [toolErrors, setToolErrors] = useState>({}); - // Fetch server details - useEffect(() => { - const loadServerDetails = async () => { - if (selectedServers.length === 0) { - setServers([]); - return; - } - - try { - const response = await fetchMCPServers(accessToken); - const allServers = Array.isArray(response) ? response : response.data || []; - - const filteredServers = allServers.filter((server: MCPServer) => - selectedServers.includes(server.server_id) - ); - - setServers(filteredServers); - } catch (error) { - console.error("Error fetching MCP servers:", error); - setServers([]); - } - }; - - loadServerDetails(); - }, [selectedServers, accessToken]); + // Filter servers based on selectedServers + const servers = useMemo(() => { + if (selectedServers.length === 0) return []; + return allServers.filter((server: MCPServer) => selectedServers.includes(server.server_id)); + }, [allServers, selectedServers]); // Fetch tools for a specific server const fetchToolsForServer = async (serverId: string) => { - setLoadingTools(prev => ({ ...prev, [serverId]: true })); - setToolErrors(prev => ({ ...prev, [serverId]: "" })); - + setLoadingTools((prev) => ({ ...prev, [serverId]: true })); + setToolErrors((prev) => ({ ...prev, [serverId]: "" })); + try { const response = await listMCPTools(accessToken, serverId); - + if (response.error) { - setToolErrors(prev => ({ ...prev, [serverId]: response.message || "Failed to fetch tools" })); - setServerTools(prev => ({ ...prev, [serverId]: [] })); + setToolErrors((prev) => ({ ...prev, [serverId]: response.message || "Failed to fetch tools" })); + setServerTools((prev) => ({ ...prev, [serverId]: [] })); } else { - setServerTools(prev => ({ ...prev, [serverId]: response.tools || [] })); + setServerTools((prev) => ({ ...prev, [serverId]: response.tools || [] })); } } catch (err) { console.error(`Error fetching tools for server ${serverId}:`, err); - setToolErrors(prev => ({ ...prev, [serverId]: "Failed to fetch tools" })); - setServerTools(prev => ({ ...prev, [serverId]: [] })); + setToolErrors((prev) => ({ ...prev, [serverId]: "Failed to fetch tools" })); + setServerTools((prev) => ({ ...prev, [serverId]: [] })); } finally { - setLoadingTools(prev => ({ ...prev, [serverId]: false })); + setLoadingTools((prev) => ({ ...prev, [serverId]: false })); } }; // Auto-fetch tools when servers change useEffect(() => { - servers.forEach(server => { + servers.forEach((server) => { if (!serverTools[server.server_id] && !loadingTools[server.server_id]) { fetchToolsForServer(server.server_id); } @@ -87,9 +68,9 @@ const MCPToolPermissions: React.FC = ({ const handleToolToggle = (serverId: string, toolName: string) => { const currentTools = toolPermissions[serverId] || []; const newTools = currentTools.includes(toolName) - ? currentTools.filter(name => name !== toolName) + ? currentTools.filter((name) => name !== toolName) : [...currentTools, toolName]; - + const updatedPermissions = { ...toolPermissions, [serverId]: newTools, @@ -99,17 +80,19 @@ const MCPToolPermissions: React.FC = ({ const handleSelectAll = (serverId: string) => { const tools = serverTools[serverId] || []; - onChange({ + const newPermissions = { ...toolPermissions, - [serverId]: tools.map(t => t.name), - }); + [serverId]: tools.map((t) => t.name), + }; + onChange(newPermissions); }; const handleDeselectAll = (serverId: string) => { - onChange({ + const newPermissions = { ...toolPermissions, [serverId]: [], - }); + }; + onChange(newPermissions); }; if (selectedServers.length === 0) { @@ -131,12 +114,11 @@ const MCPToolPermissions: React.FC = ({
{serverName} - {server.description && ( - {server.description} - )} + {server.description && {server.description}}
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx index 1bf719ef904..f6a5d6622d4 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx @@ -10,6 +10,7 @@ export const mcpServerColumns = ( onView: (serverId: string) => void, onEdit: (serverId: string) => void, onDelete: (serverId: string) => void, + isLoadingHealth?: boolean, ): ColumnDef[] => [ { accessorKey: "server_id", @@ -58,6 +59,19 @@ export const mcpServerColumns = ( const lastCheck = server.last_health_check; const error = server.health_check_error; + // Show loading spinner if health check is in progress + if (isLoadingHealth) { + return ( +
+ + + + + Loading... +
+ ); + } + const getStatusColor = (status: string) => { switch (status) { case "healthy": diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.test.tsx index b6323397524..4b8698b9762 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.test.tsx @@ -8,6 +8,7 @@ import * as networking from "../networking"; // Mock the networking module vi.mock("../networking", () => ({ fetchMCPServers: vi.fn(), + fetchMCPServerHealth: vi.fn(), deleteMCPServer: vi.fn(), getProxyBaseUrl: vi.fn().mockReturnValue("http://localhost:4000"), })); @@ -32,7 +33,7 @@ const createQueryClient = () => describe("MCPServers", () => { const defaultProps = { - accessToken: "test-token", + accessToken: "123", userRole: "Admin", userID: "admin-user-id", }; @@ -120,6 +121,111 @@ describe("MCPServers", () => { expect(getByText("test-server-2")).toBeInTheDocument(); // Verify the API was called - expect(networking.fetchMCPServers).toHaveBeenCalledWith("test-token"); + // Note: useMCPServers uses useAuthorized() internally, which returns "123" from global mock + expect(networking.fetchMCPServers).toHaveBeenCalledWith("123"); + }); + + it("should fetch and merge health status for servers", async () => { + // Mock MCP servers data without health status + const mockServers = [ + { + server_id: "server-1", + server_name: "Test Server 1", + alias: "test-server-1", + url: "https://example.com/mcp", + transport: "http", + auth_type: "none", + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + teams: [], + mcp_access_groups: [], + status: undefined, + }, + { + server_id: "server-2", + server_name: "Test Server 2", + alias: "test-server-2", + url: "https://example2.com/mcp", + transport: "sse", + auth_type: "api_key", + created_at: "2024-01-02T00:00:00Z", + created_by: "user-2", + updated_at: "2024-01-02T00:00:00Z", + updated_by: "user-2", + teams: [], + mcp_access_groups: ["group-1"], + status: undefined, + }, + ]; + + // Mock health status data + const mockHealthStatuses = [ + { server_id: "server-1", status: "healthy" }, + { server_id: "server-2", status: "unhealthy" }, + ]; + + vi.mocked(networking.fetchMCPServers).mockResolvedValue(mockServers); + vi.mocked(networking.fetchMCPServerHealth).mockResolvedValue(mockHealthStatuses); + + const queryClient = createQueryClient(); + const { getByText } = render( + + + , + ); + + // Wait for the component to load + await waitFor(() => { + expect(getByText("MCP Servers")).toBeInTheDocument(); + }); + + // Verify the health check API was called with server IDs + await waitFor(() => { + expect(networking.fetchMCPServerHealth).toHaveBeenCalledWith("123", ["server-1", "server-2"]); + }); + }); + + it("should display loading state while health check is in progress", async () => { + const mockServers = [ + { + server_id: "server-1", + server_name: "Test Server 1", + alias: "test-server-1", + url: "https://example.com/mcp", + transport: "http", + auth_type: "none", + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + teams: [], + mcp_access_groups: [], + }, + ]; + + vi.mocked(networking.fetchMCPServers).mockResolvedValue(mockServers); + // Mock health check to never resolve (to test loading state) + vi.mocked(networking.fetchMCPServerHealth).mockImplementation( + () => new Promise(() => {}), // Never resolves + ); + + const queryClient = createQueryClient(); + const { getByText } = render( + + + , + ); + + // Wait for the component to load + await waitFor(() => { + expect(getByText("MCP Servers")).toBeInTheDocument(); + }); + + // Verify that health check was initiated + await waitFor(() => { + expect(networking.fetchMCPServerHealth).toHaveBeenCalled(); + }); }); }); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx index a46738ab365..f6669fb2829 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx @@ -1,11 +1,12 @@ import { isAdminRole } from "@/utils/roles"; import { QuestionCircleOutlined } from "@ant-design/icons"; -import { useQuery } from "@tanstack/react-query"; import { Button, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react"; import { Descriptions, Modal, Select, Tooltip, Typography } from "antd"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useMemo } from "react"; +import { useMCPServers } from "../../app/(dashboard)/hooks/mcpServers/useMCPServers"; +import { useMCPServerHealth } from "../../app/(dashboard)/hooks/mcpServers/useMCPServerHealth"; import NotificationsManager from "../molecules/notifications_manager"; -import { deleteMCPServer, fetchMCPServers } from "../networking"; +import { deleteMCPServer } from "../networking"; import { DataTable } from "../view_logs/table"; import CreateMCPServer from "./create_mcp_server"; import MCPConnect from "./mcp_connect"; @@ -19,19 +20,29 @@ const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state"; const { Option } = Select; const MCPServers: React.FC = ({ accessToken, userRole, userID }) => { - const { - data: mcpServers, - isLoading: isLoadingServers, - refetch, - dataUpdatedAt, - } = useQuery({ - queryKey: ["mcpServers"], - queryFn: () => { - if (!accessToken) throw new Error("Access Token required"); - return fetchMCPServers(accessToken); - }, - enabled: !!accessToken, - }) as { data: MCPServer[]; isLoading: boolean; refetch: () => void; dataUpdatedAt: number }; + const { data: mcpServers, isLoading: isLoadingServers, refetch } = useMCPServers(); + + // Fetch health status for all servers + const serverIds = useMemo(() => mcpServers?.map((server) => server.server_id), [mcpServers]); + const { data: healthStatuses, isLoading: isLoadingHealth } = useMCPServerHealth(serverIds); + + // Merge health status data into servers + const serversWithHealth = useMemo(() => { + if (!mcpServers) return []; + if (!healthStatuses) return mcpServers; + + const healthMap = new Map(healthStatuses.map((h) => [h.server_id, h.status])); + + return mcpServers.map((server) => { + const healthStatus = healthMap.get(server.server_id); + return { + ...server, + status: healthStatus + ? (healthStatus as "healthy" | "unhealthy" | "unknown") + : server.status, + }; + }); + }, [mcpServers, healthStatuses]); // Log allowed_tools from fetched servers React.useEffect(() => { @@ -77,10 +88,10 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) // Get unique teams from all servers const uniqueTeams = React.useMemo(() => { - if (!mcpServers) return []; + if (!serversWithHealth) return []; const teamsSet = new Set(); const uniqueTeamsArray: Team[] = []; - mcpServers.forEach((server: MCPServer) => { + serversWithHealth.forEach((server: MCPServer) => { if (server.teams) { server.teams.forEach((team: Team) => { const teamKey = team.team_id; @@ -92,17 +103,17 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) } }); return uniqueTeamsArray; - }, [mcpServers]); + }, [serversWithHealth]); // Get unique MCP access groups from all servers const uniqueMcpAccessGroups = React.useMemo(() => { - if (!mcpServers) return []; + if (!serversWithHealth) return []; return Array.from( new Set( - mcpServers.flatMap((server) => server.mcp_access_groups).filter((group): group is string => group != null), + serversWithHealth.flatMap((server) => server.mcp_access_groups).filter((group): group is string => group != null), ), ); - }, [mcpServers]); + }, [serversWithHealth]); // Handle team filter change const handleTeamChange = (teamId: string) => { @@ -118,8 +129,8 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) // Filtering logic for both team and access group const filterServers = (teamId: string, group: string) => { - if (!mcpServers) return setFilteredServers([]); - let filtered = mcpServers; + if (!serversWithHealth) return setFilteredServers([]); + let filtered = serversWithHealth; if (teamId === "personal") { setFilteredServers([]); return; @@ -135,10 +146,10 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) setFilteredServers(filtered); }; - // Initial and effect-based filtering (trigger on query data updates) + // Initial and effect-based filtering (trigger on query data updates and health data updates) useEffect(() => { filterServers(selectedTeam, selectedMcpAccessGroup); - }, [dataUpdatedAt]); + }, [serversWithHealth, selectedTeam, selectedMcpAccessGroup]); const columns = React.useMemo( () => @@ -153,8 +164,9 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) setEditServer(true); }, handleDelete, + isLoadingHealth, ), - [userRole], + [userRole, isLoadingHealth], ); function handleDelete(server_id: string) { diff --git a/ui/litellm-dashboard/src/components/model_add/credentials.tsx b/ui/litellm-dashboard/src/components/model_add/credentials.tsx index 3887e340daa..af3c757955e 100644 --- a/ui/litellm-dashboard/src/components/model_add/credentials.tsx +++ b/ui/litellm-dashboard/src/components/model_add/credentials.tsx @@ -32,7 +32,7 @@ interface CredentialsPanelProps { const CredentialsPanel: React.FC = ({ uploadProps }) => { const { accessToken } = useAuthorized(); - const { data: credentialsResponse, refetch: refetchCredentials } = useCredentials(accessToken); + const { data: credentialsResponse, refetch: refetchCredentials } = useCredentials(); const credentialList = credentialsResponse?.credentials || []; const [isAddModalOpen, setIsAddModalOpen] = useState(false); diff --git a/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx b/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx index 994ea8adfc0..5d35b92684c 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx +++ b/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx @@ -596,7 +596,6 @@ const HealthCheckComponent: React.FC = ({ }; })} isLoading={false} - table={healthTableRef} />
diff --git a/ui/litellm-dashboard/src/components/model_dashboard/table.tsx b/ui/litellm-dashboard/src/components/model_dashboard/table.tsx index 344ff2e94f2..79224edba43 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/table.tsx +++ b/ui/litellm-dashboard/src/components/model_dashboard/table.tsx @@ -3,10 +3,13 @@ import { flexRender, getCoreRowModel, getSortedRowModel, + getPaginationRowModel, SortingState, useReactTable, ColumnResizeMode, VisibilityState, + PaginationState, + OnChangeFn, } from "@tanstack/react-table"; import React from "react"; import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } from "@tremor/react"; @@ -23,16 +26,20 @@ interface ModelDataTableProps { data: TData[]; columns: ColumnDef[]; isLoading?: boolean; - table: any; // Add table prop to access column visibility controls defaultSorting?: SortingState; + pagination?: PaginationState; + onPaginationChange?: OnChangeFn; + enablePagination?: boolean; } export function ModelDataTable({ data = [], columns, isLoading = false, - table, defaultSorting = [], + pagination, + onPaginationChange, + enablePagination = false, }: ModelDataTableProps) { const [sorting, setSorting] = React.useState(defaultSorting); const [columnResizeMode] = React.useState("onChange"); @@ -46,13 +53,16 @@ export function ModelDataTable({ sorting, columnSizing, columnVisibility, + ...(enablePagination && pagination ? { pagination } : {}), }, columnResizeMode, onSortingChange: setSorting, onColumnSizingChange: setColumnSizing, onColumnVisibilityChange: setColumnVisibility, + ...(enablePagination && onPaginationChange ? { onPaginationChange } : {}), getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), + ...(enablePagination ? { getPaginationRowModel: getPaginationRowModel() } : {}), enableSorting: true, enableColumnResizing: true, defaultColumn: { @@ -61,13 +71,6 @@ export function ModelDataTable({ }, }); - // Expose table instance to parent - React.useEffect(() => { - if (table) { - table.current = tableInstance; - } - }, [tableInstance, table]); - const getHeaderText = (header: any): string => { if (typeof header === "string") { return header; diff --git a/ui/litellm-dashboard/src/components/model_hub_table.tsx b/ui/litellm-dashboard/src/components/model_hub_table.tsx index 7d48bf68aed..f45e44ce905 100644 --- a/ui/litellm-dashboard/src/components/model_hub_table.tsx +++ b/ui/litellm-dashboard/src/components/model_hub_table.tsx @@ -1,10 +1,9 @@ import { CopyOutlined } from "@ant-design/icons"; -import { Table as TableInstance } from "@tanstack/react-table"; import { Badge, Button, Card, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react"; import { Modal } from "antd"; import { Copy } from "lucide-react"; import { useRouter } from "next/navigation"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { isAdminRole } from "../utils/roles"; import { agentHubColumns, AgentHubData } from "./agent_hub_table_columns"; @@ -76,9 +75,6 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, const [isMcpModalVisible, setIsMcpModalVisible] = useState(false); const [isMakeMcpPublicModalVisible, setIsMakeMcpPublicModalVisible] = useState(false); const router = useRouter(); - const tableRef = useRef>(null); - const agentTableRef = useRef>(null); - const mcpTableRef = useRef>(null); useEffect(() => { const fetchData = async (accessToken: string) => { @@ -404,7 +400,6 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, columns={modelHubColumns(showModal, copyToClipboard, publicPage)} data={filteredData} isLoading={loading} - table={tableRef} defaultSorting={[{ id: "model_group", desc: false }]} /> @@ -431,7 +426,6 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, columns={agentHubColumns(showAgentModal, copyToClipboard, publicPage)} data={agentHubData || []} isLoading={agentLoading} - table={agentTableRef} defaultSorting={[{ id: "name", desc: false }]} /> @@ -458,7 +452,6 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, columns={mcpHubColumns(showMcpModal, copyToClipboard, publicPage)} data={mcpHubData || []} isLoading={mcpLoading} - table={mcpTableRef} defaultSorting={[{ id: "server_name", desc: false }]} /> diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index f66fd005ae1..01acc8d78ef 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -86,7 +86,7 @@ export default function ModelInfoView({ const isAdmin = userRole === "Admin"; const isAutoRouter = modelData?.litellm_params?.auto_router_config != null; - const { data: modelsInfoData } = useModelsInfo(accessToken, userID, userRole); + const { data: modelsInfoData } = useModelsInfo(); console.log("modelsInfoData, ", modelsInfoData); const usingExistingCredential = modelData?.litellm_params?.litellm_credential_name != null && diff --git a/ui/litellm-dashboard/src/components/molecules/filter.tsx b/ui/litellm-dashboard/src/components/molecules/filter.tsx index d581fe9d2ec..d7c40ae0399 100644 --- a/ui/litellm-dashboard/src/components/molecules/filter.tsx +++ b/ui/litellm-dashboard/src/components/molecules/filter.tsx @@ -129,6 +129,7 @@ const FilterComponent: React.FC = ({ "Key Alias", "User ID", "End User", + "Error Code", "Key Hash", "Model", ]; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index f0464c61f88..6fd828dfc25 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -230,6 +230,7 @@ export interface LiteLLMWellKnownUiConfig { server_root_path: string; proxy_base_url: string | null; auto_redirect_to_sso: boolean; + admin_ui_disabled: boolean; } export interface CredentialsResponse { @@ -2687,6 +2688,7 @@ export const uiSpendLogsCall = async ( status_filter?: string, model?: string, keyAlias?: string, + error_code?: string, ) => { try { // Construct base URL @@ -2706,6 +2708,7 @@ export const uiSpendLogsCall = async ( if (status_filter) queryParams.append("status_filter", status_filter); if (model) queryParams.append("model", model); if (keyAlias) queryParams.append("key_alias", keyAlias); + if (error_code) queryParams.append("error_code", error_code); // Append query parameters to URL if any exist const queryString = queryParams.toString(); if (queryString) { @@ -3247,6 +3250,7 @@ export const keyListCall = async ( pageSize: number, sortBy: string | null = null, sortOrder: string | null = null, + expand: string | null = null, ) => { /** * Get all available teams on proxy @@ -3291,6 +3295,11 @@ export const keyListCall = async ( if (sortOrder) { queryParams.append("sort_order", sortOrder); } + + if (expand) { + queryParams.append("expand", expand); + } + queryParams.append("return_full_object", "true"); queryParams.append("include_team_keys", "true"); queryParams.append("include_created_by_keys", "true"); @@ -5684,6 +5693,44 @@ export const fetchMCPServers = async (accessToken: string) => { } }; +export const fetchMCPServerHealth = async (accessToken: string, serverIds?: string[]) => { + try { + // Construct base URL + let url = proxyBaseUrl ? `${proxyBaseUrl}/v1/mcp/server/health` : `/v1/mcp/server/health`; + + // Add server_ids query parameters if provided + if (serverIds && serverIds.length > 0) { + const params = new URLSearchParams(); + serverIds.forEach((id) => params.append("server_ids", id)); + url = `${url}?${params.toString()}`; + } + + console.log("Fetching MCP server health from:", url); + + const response = await fetch(url, { + method: HTTP_REQUEST.GET, + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + const data = await response.json(); + console.log("Fetched MCP server health:", data); + return data; + } catch (error) { + console.error("Failed to fetch MCP server health:", error); + throw error; + } +}; + export const fetchMCPAccessGroups = async (accessToken: string) => { try { // Construct base URL diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index 3c0a0f520d4..3cd8a04e067 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -1,55 +1,49 @@ "use client"; -import React, { useState, useEffect, useCallback } from "react"; -import { Button, TextInput, Grid, Col } from "@tremor/react"; -import { Text, Title, Accordion, AccordionHeader, AccordionBody } from "@tremor/react"; -import { CopyToClipboard } from "react-copy-to-clipboard"; -import { Button as Button2, Modal, Form, Input, Select, Radio, Switch } from "antd"; -import NumericalInput from "../shared/numerical_input"; -import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; -import SchemaFormFields from "../common_components/check_openapi_schema"; -import { - keyCreateCall, - modelAvailableCall, - getGuardrailsList, - proxyBaseUrl, - getPossibleUserRoles, - userFilterUICall, - keyCreateServiceAccountCall, - fetchMCPAccessGroups, - getPromptsList, -} from "../networking"; -import VectorStoreSelector from "../vector_store_management/VectorStoreSelector"; -import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSelector"; -import { Team } from "../key_team_helpers/key_list"; -import TeamDropdown from "../common_components/team_dropdown"; -import { InfoCircleOutlined } from "@ant-design/icons"; -import { Tooltip } from "antd"; -import PremiumLoggingSettings from "../common_components/PremiumLoggingSettings"; -import Createuser from "../create_user_button"; -import debounce from "lodash/debounce"; -import { rolesWithWriteAccess } from "../../utils/roles"; -import BudgetDurationDropdown from "../common_components/budget_duration_dropdown"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { InfoCircleOutlined } from "@ant-design/icons"; +import { Accordion, AccordionBody, AccordionHeader, Button, Col, Grid, Text, TextInput, Title } from "@tremor/react"; +import { Button as Button2, Form, Input, Modal, Radio, Select, Switch, Tooltip } from "antd"; +import debounce from "lodash/debounce"; +import React, { useCallback, useEffect, useState } from "react"; +import { CopyToClipboard } from "react-copy-to-clipboard"; +import { rolesWithWriteAccess } from "../../utils/roles"; +import AgentSelector from "../agent_management/AgentSelector"; import { mapDisplayToInternalNames } from "../callback_info_helpers"; +import BudgetDurationDropdown from "../common_components/budget_duration_dropdown"; +import SchemaFormFields from "../common_components/check_openapi_schema"; +import KeyLifecycleSettings from "../common_components/KeyLifecycleSettings"; +import ModelAliasManager from "../common_components/ModelAliasManager"; +import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSelector"; +import PremiumLoggingSettings from "../common_components/PremiumLoggingSettings"; +import RateLimitTypeFormItem from "../common_components/RateLimitTypeFormItem"; +import TeamDropdown from "../common_components/team_dropdown"; +import Createuser from "../create_user_button"; +import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; +import { Team } from "../key_team_helpers/key_list"; import MCPServerSelector from "../mcp_server_management/MCPServerSelector"; import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions"; -import AgentSelector from "../agent_management/AgentSelector"; -import ModelAliasManager from "../common_components/ModelAliasManager"; import NotificationsManager from "../molecules/notifications_manager"; -import KeyLifecycleSettings from "../common_components/KeyLifecycleSettings"; -import RateLimitTypeFormItem from "../common_components/RateLimitTypeFormItem"; +import { + getGuardrailsList, + getPossibleUserRoles, + getPromptsList, + keyCreateCall, + keyCreateServiceAccountCall, + modelAvailableCall, + proxyBaseUrl, + userFilterUICall, +} from "../networking"; +import NumericalInput from "../shared/numerical_input"; +import VectorStoreSelector from "../vector_store_management/VectorStoreSelector"; const { Option } = Select; interface CreateKeyProps { - userID: string; team: Team | null; - userRole: string | null; - accessToken: string; data: any[] | null; teams: Team[] | null; addKey: (data: any) => void; - premiumUser?: boolean; } interface User { @@ -140,16 +134,8 @@ export const fetchUserModels = async ( * Please contribute to the new refactor. * ───────────────────────────────────────────────────────────────────────── */ -const CreateKey: React.FC = ({ - userID, - team, - teams, - userRole, - accessToken, - data, - addKey, - premiumUser = false, -}) => { +const CreateKey: React.FC = ({ team, teams, data, addKey }) => { + const { accessToken, userId: userID, userRole, premiumUser } = useAuthorized(); const [form] = Form.useForm(); const [isModalVisible, setIsModalVisible] = useState(false); const [apiKey, setApiKey] = useState(null); @@ -168,13 +154,11 @@ const CreateKey: React.FC = ({ const [userOptions, setUserOptions] = useState([]); const [userSearchLoading, setUserSearchLoading] = useState(false); const [mcpAccessGroups, setMcpAccessGroups] = useState([]); - const [mcpAccessGroupsLoaded, setMcpAccessGroupsLoaded] = useState(false); const [disabledCallbacks, setDisabledCallbacks] = useState([]); const [keyType, setKeyType] = useState("default"); const [modelAliases, setModelAliases] = useState<{ [key: string]: string }>({}); const [autoRotationEnabled, setAutoRotationEnabled] = useState(false); const [rotationInterval, setRotationInterval] = useState("30d"); - const handleOk = () => { setIsModalVisible(false); form.resetFields(); @@ -205,22 +189,6 @@ const CreateKey: React.FC = ({ } }, [accessToken, userID, userRole]); - const fetchMcpAccessGroups = async () => { - try { - if (accessToken == null) { - return; - } - const groups = await fetchMCPAccessGroups(accessToken); - setMcpAccessGroups(groups); - } catch (error) { - console.error("Failed to fetch MCP access groups:", error); - } - }; - - useEffect(() => { - fetchMcpAccessGroups(); - }, [accessToken]); - useEffect(() => { const fetchGuardrails = async () => { try { @@ -421,7 +389,7 @@ const CreateKey: React.FC = ({ console.log("key create Response:", response); // Add the data to the state in the parent component - // Also directly update the keys list in AllKeysTable without an API call + // Also directly update the keys list in VirtualKeysTable without an API call addKey(response); setApiKey(response["key"]); @@ -510,14 +478,7 @@ const CreateKey: React.FC = ({ + Create New Key )} - +
{/* Section 1: Key Ownership */}
@@ -1053,15 +1014,7 @@ const CreateKey: React.FC = ({ options={predefinedTags} /> - { - if (!mcpAccessGroupsLoaded) { - fetchMcpAccessGroups(); - setMcpAccessGroupsLoaded(true); - } - }} - > + MCP Settings diff --git a/ui/litellm-dashboard/src/components/organisms/regenerate_key_modal.tsx b/ui/litellm-dashboard/src/components/organisms/regenerate_key_modal.tsx index 583a097449f..a4339e11920 100644 --- a/ui/litellm-dashboard/src/components/organisms/regenerate_key_modal.tsx +++ b/ui/litellm-dashboard/src/components/organisms/regenerate_key_modal.tsx @@ -1,31 +1,22 @@ -import React, { useEffect, useState } from "react"; -import { Button, Text, TextInput, Title, Grid, Col } from "@tremor/react"; -import { Modal, Form, InputNumber } from "antd"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { Button, Col, Grid, Text, TextInput, Title } from "@tremor/react"; +import { Form, InputNumber, Modal } from "antd"; import { add } from "date-fns"; -import { regenerateKeyCall } from "../networking"; -import { KeyResponse } from "../key_team_helpers/key_list"; +import { useEffect, useState } from "react"; import { CopyToClipboard } from "react-copy-to-clipboard"; +import { KeyResponse } from "../key_team_helpers/key_list"; import NotificationManager from "../molecules/notifications_manager"; +import { regenerateKeyCall } from "../networking"; interface RegenerateKeyModalProps { selectedToken: KeyResponse | null; visible: boolean; onClose: () => void; - accessToken: string | null; - premiumUser: boolean; - setAccessToken?: (token: string) => void; onKeyUpdate?: (updatedKeyData: Partial) => void; } -export function RegenerateKeyModal({ - selectedToken, - visible, - onClose, - accessToken, - premiumUser, - setAccessToken, - onKeyUpdate, -}: RegenerateKeyModalProps) { +export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdate }: RegenerateKeyModalProps) { + const { accessToken } = useAuthorized(); const [form] = Form.useForm(); const [regeneratedKey, setRegeneratedKey] = useState(null); const [regenerateFormData, setRegenerateFormData] = useState(null); @@ -132,14 +123,6 @@ export function RegenerateKeyModal({ console.log("Updated key data with new token:", updatedKeyData); // Debug log - // If user regenerated their own auth key, update both local and global access tokens - if (isOwnKey) { - setCurrentAccessToken(response.key); // Update local token immediately - if (setAccessToken) { - setAccessToken(response.key); // Update global token - } - } - // Update the parent component with new key data if (onKeyUpdate) { onKeyUpdate(updatedKeyData); diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx index 0be03169e89..4320ad6c698 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { render, waitFor } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import { vi, test, expect } from "vitest"; import OrganizationInfoView from "./organization_view"; @@ -40,6 +40,24 @@ vi.mock("../mcp_server_management/MCPServerSelector", () => ({ __esModule: true, default: () => null, })); +const mockUseTeamsData = { + data: [ + { + team_id: "team_123", + team_alias: "Engineering Team", + }, + { + team_id: "team_456", + team_alias: "Marketing Team", + }, + ], +}; + +const mockUseTeams = vi.fn(() => mockUseTeamsData); + +vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ + useTeams: () => mockUseTeams(), +})); const mockOrg = { organization_alias: "Acme Corp", @@ -82,3 +100,84 @@ test("renders organization view after loading data", async () => { expect(findAllByText("Acme Corp")).toBeTruthy(); }); }); + +test("should display empty state when organization has no members", async () => { + const { organizationInfoCall } = await import("../networking"); + (organizationInfoCall as unknown as ReturnType).mockResolvedValueOnce(mockOrg); + + render( + {}} + accessToken="test-token" + is_org_admin={false} + is_proxy_admin={false} + userModels={[]} + editOrg={false} + />, + ); + + await waitFor(() => { + expect(screen.getByText("No members found")).toBeInTheDocument(); + }); +}); + +test("should display team aliases when teams are available", async () => { + const { organizationInfoCall } = await import("../networking"); + const orgWithTeams = { + ...mockOrg, + teams: [{ team_id: "team_123" }, { team_id: "team_456" }], + }; + (organizationInfoCall as unknown as ReturnType).mockResolvedValueOnce(orgWithTeams); + + render( + {}} + accessToken="test-token" + is_org_admin={false} + is_proxy_admin={false} + userModels={[]} + editOrg={false} + />, + ); + + await waitFor(() => { + expect(screen.getByText("Engineering Team")).toBeInTheDocument(); + expect(screen.getByText("Marketing Team")).toBeInTheDocument(); + }); +}); + +test("should display team ID as fallback when alias is not found", async () => { + const { organizationInfoCall } = await import("../networking"); + mockUseTeams.mockReturnValueOnce({ + data: [ + { + team_id: "team_123", + team_alias: "Engineering Team", + }, + ], + }); + + const orgWithUnknownTeam = { + ...mockOrg, + teams: [{ team_id: "team_999" }], + }; + (organizationInfoCall as unknown as ReturnType).mockResolvedValueOnce(orgWithUnknownTeam); + + render( + {}} + accessToken="test-token" + is_org_admin={false} + is_proxy_admin={false} + userModels={[]} + editOrg={false} + />, + ); + + await waitFor(() => { + expect(screen.getByText("team_999")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.tsx index 962ec6fa4ea..91b2b4ce59b 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.tsx @@ -23,7 +23,7 @@ import { } from "@tremor/react"; import { Button, Form, Input, Select } from "antd"; import { CheckIcon, CopyIcon } from "lucide-react"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useMemo } from "react"; import UserSearchModal from "../common_components/user_search_modal"; import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; import MCPServerSelector from "../mcp_server_management/MCPServerSelector"; @@ -41,6 +41,8 @@ import ObjectPermissionsView from "../object_permissions_view"; import NumericalInput from "../shared/numerical_input"; import MemberModal from "../team/EditMembership"; import VectorStoreSelector from "../vector_store_management/VectorStoreSelector"; +import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { createTeamAliasMap } from "@/utils/teamUtils"; interface OrganizationInfoProps { organizationId: string; @@ -71,6 +73,9 @@ const OrganizationInfoView: React.FC = ({ const [copiedStates, setCopiedStates] = useState>({}); const [isOrgSaving, setIsOrgSaving] = useState(false); const canEditOrg = is_org_admin || is_proxy_admin; + const { data: teams } = useTeams(); + + const teamAliasMap = useMemo(() => createTeamAliasMap(teams), [teams]); const fetchOrgInfo = async () => { try { @@ -310,7 +315,7 @@ const OrganizationInfoView: React.FC = ({
{orgData.teams?.map((team, index) => ( - {team.team_id} + {teamAliasMap[team.team_id] || team.team_id} ))}
@@ -324,7 +329,6 @@ const OrganizationInfoView: React.FC = ({ - {/* Budget Panel */}
@@ -340,47 +344,55 @@ const OrganizationInfoView: React.FC = ({ - {orgData.members?.map((member, index) => ( - - - {member.user_id} - - - {member.user_role} - - - ${formatNumberWithCommas(member.spend, 4)} - - - {new Date(member.created_at).toLocaleString()} - - - {canEditOrg && ( - <> - { - setSelectedEditMember({ - role: member.user_role, - user_email: member.user_email, - user_id: member.user_id, - }); - setIsEditMemberModalVisible(true); - }} - /> - { - handleMemberDelete(member); - }} - /> - - )} + {orgData.members && orgData.members.length > 0 ? ( + orgData.members.map((member, index) => ( + + + {member.user_id} + + + {member.user_role} + + + ${formatNumberWithCommas(member.spend, 4)} + + + {new Date(member.created_at).toLocaleString()} + + + {canEditOrg && ( + <> + { + setSelectedEditMember({ + role: member.user_role, + user_email: member.user_email, + user_id: member.user_id, + }); + setIsEditMemberModalVisible(true); + }} + /> + { + handleMemberDelete(member); + }} + /> + + )} + + + )) + ) : ( + + + No members found - ))} + )}
diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.test.tsx b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.test.tsx index 2de324499e1..8c85fb3bb2b 100644 --- a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.test.tsx +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.test.tsx @@ -218,4 +218,56 @@ describe("ChatUI", () => { expect(options[0]).toHaveTextContent("Enter custom model"); }); }); + + it("should enable the MCP tools selector for chat completions", async () => { + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Test Key")).toBeInTheDocument(); + }); + + const endpointTypeText = screen.getByText("Endpoint Type"); + const endpointSelect = endpointTypeText.parentElement?.querySelector(".ant-select-selector") as HTMLElement | null; + expect(endpointSelect).not.toBeNull(); + + const selectEndpointOption = async (label: string) => { + act(() => { + fireEvent.mouseDown(endpointSelect!); + }); + + await waitFor(() => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + act(() => { + fireEvent.click(screen.getByText(label)); + }); + }; + + const getMcpSelect = () => + screen.getByText("MCP Tool").closest("div")?.querySelector(".ant-select") as HTMLElement | null; + + await selectEndpointOption("/v1/embeddings"); + + const mcpSelect = getMcpSelect(); + expect(mcpSelect).not.toBeNull(); + + await waitFor(() => { + expect(mcpSelect).toHaveClass("ant-select-disabled"); + }); + + await selectEndpointOption("/v1/chat/completions"); + + await waitFor(() => { + expect(mcpSelect).not.toHaveClass("ant-select-disabled"); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx index a963aa706be..1cf9c7eb8df 100644 --- a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx @@ -84,6 +84,11 @@ interface ChatUIProps { }; } +const MCP_SUPPORTED_ENDPOINTS = new Set([ + EndpointType.CHAT, + EndpointType.RESPONSES, +]); + const ChatUI: React.FC = ({ accessToken, token, @@ -743,8 +748,19 @@ const ChatUI: React.FC = ({ return; } - // Require model selection for Responses API - if (endpointType === EndpointType.RESPONSES && !selectedModel) { + // Require model selection for all model-based endpoints + const modelRequiredEndpoints = [ + EndpointType.CHAT, + EndpointType.IMAGE, + EndpointType.SPEECH, + EndpointType.IMAGE_EDITS, + EndpointType.RESPONSES, + EndpointType.ANTHROPIC_MESSAGES, + EndpointType.EMBEDDINGS, + EndpointType.TRANSCRIPTION, + ]; + + if (modelRequiredEndpoints.includes(endpointType as EndpointType) && !selectedModel) { NotificationsManager.fromBackend("Please select a model before sending a request"); return; } @@ -1319,7 +1335,7 @@ const ChatUI: React.FC = ({ MCP Tool @@ -1334,7 +1350,7 @@ const ChatUI: React.FC = ({ className="mb-4" allowClear optionLabelProp="label" - disabled={!(endpointType === EndpointType.RESPONSES)} + disabled={!MCP_SUPPORTED_ENDPOINTS.has(endpointType as EndpointType)} maxTagCount="responsive" > {Array.isArray(mcpTools) && diff --git a/ui/litellm-dashboard/src/components/playground/llm_calls/chat_completion.tsx b/ui/litellm-dashboard/src/components/playground/llm_calls/chat_completion.tsx index 799f050fc8c..8bdaa94ec65 100644 --- a/ui/litellm-dashboard/src/components/playground/llm_calls/chat_completion.tsx +++ b/ui/litellm-dashboard/src/components/playground/llm_calls/chat_completion.tsx @@ -60,7 +60,7 @@ export async function makeOpenAIChatCompletionRequest( { type: "mcp", server_label: "litellm", - server_url: `${proxyBaseUrl}/mcp`, + server_url: 'litellm_proxy/mcp', require_approval: "never", allowed_tools: selectedMCPTools, headers: { diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx index 277786aa8ce..1b5473934bd 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx @@ -42,6 +42,7 @@ export enum Providers { VolcEngine = "VolcEngine", Voyage = "Voyage AI", xAI = "xAI", + SAP = "SAP Generative AI Hub", } export const provider_map: Record = { @@ -87,6 +88,7 @@ export const provider_map: Record = { DeepInfra: "deepinfra", Hosted_Vllm: "hosted_vllm", Infinity: "infinity", + SAP: "sap", }; const asset_logos_folder = "../ui/assets/logos/"; @@ -134,6 +136,7 @@ export const providerLogoMap: Record = { [Providers.JinaAI]: `${asset_logos_folder}jina.png`, [Providers.VolcEngine]: `${asset_logos_folder}volcengine.png`, [Providers.DeepInfra]: `${asset_logos_folder}deepinfra.png`, + [Providers.SAP]: `${asset_logos_folder}sap.png`, }; export const getProviderLogoAndName = (providerValue: string): { logo: string; displayName: string } => { diff --git a/ui/litellm-dashboard/src/components/public_model_hub.tsx b/ui/litellm-dashboard/src/components/public_model_hub.tsx index 3493f0bf93f..4678dbe3f94 100644 --- a/ui/litellm-dashboard/src/components/public_model_hub.tsx +++ b/ui/litellm-dashboard/src/components/public_model_hub.tsx @@ -1,25 +1,24 @@ -import React, { useEffect, useState, useRef, useMemo } from "react"; -import { - modelHubPublicModelsCall, - getPublicModelHubInfo, - agentHubPublicModelsCall, - mcpHubPublicServersCall, - getUiConfig, -} from "./networking"; -import { ModelDataTable } from "./model_dashboard/table"; -import { ColumnDef } from "@tanstack/react-table"; -import { Card, Text, Title, Button } from "@tremor/react"; -import { Tag, Tooltip, Modal, Select, Tabs } from "antd"; +import { ThemeProvider } from "@/contexts/ThemeContext"; import { ExternalLinkIcon, SearchIcon } from "@heroicons/react/outline"; +import { ColumnDef } from "@tanstack/react-table"; +import { Button, Card, Text, Title } from "@tremor/react"; +import { Modal, Select, Tabs, Tag, Tooltip } from "antd"; import { Copy, Info } from "lucide-react"; -import { Table as TableInstance } from "@tanstack/react-table"; +import React, { useEffect, useMemo, useState } from "react"; +import { ModelDataTable } from "./model_dashboard/table"; +import NotificationsManager from "./molecules/notifications_manager"; +import Navbar from "./navbar"; +import { + agentHubPublicModelsCall, + getPublicModelHubInfo, + getUiConfig, + mcpHubPublicServersCall, + modelHubPublicModelsCall, +} from "./networking"; import { generateCodeSnippet } from "./playground/chat_ui/CodeSnippets"; import { getEndpointType } from "./playground/chat_ui/mode_endpoint_mapping"; import { MessageType } from "./playground/chat_ui/types"; import { getProviderLogoAndName } from "./provider_info_helpers"; -import Navbar from "./navbar"; -import { ThemeProvider } from "@/contexts/ThemeContext"; -import NotificationsManager from "./molecules/notifications_manager"; const { TabPane } = Tabs; @@ -118,9 +117,6 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded const [selectedMcpServer, setSelectedMcpServer] = useState(null); const [proxySettings, setProxySettings] = useState({}); const [activeTab, setActiveTab] = useState("models"); - const tableRef = useRef>(null); - const agentTableRef = useRef>(null); - const mcpTableRef = useRef>(null); useEffect(() => { const initializeAndFetch = async () => { @@ -1121,7 +1117,6 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded columns={publicModelHubColumns()} data={filteredData} isLoading={loading} - table={tableRef} defaultSorting={[{ id: "model_group", desc: false }]} /> @@ -1184,7 +1179,6 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded columns={publicAgentHubColumns()} data={filteredAgentData} isLoading={agentLoading} - table={agentTableRef} defaultSorting={[{ id: "name", desc: false }]} /> @@ -1248,7 +1242,6 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded columns={publicMCPHubColumns()} data={filteredMcpData} isLoading={mcpLoading} - table={mcpTableRef} defaultSorting={[{ id: "server_name", desc: false }]} /> diff --git a/ui/litellm-dashboard/src/components/settings.tsx b/ui/litellm-dashboard/src/components/settings.tsx index dfe141b2a4b..735ee6ee75c 100644 --- a/ui/litellm-dashboard/src/components/settings.tsx +++ b/ui/litellm-dashboard/src/components/settings.tsx @@ -40,6 +40,7 @@ import { AlertingObject } from "./Settings/LoggingAndAlerts/LoggingCallbacks/typ import { parseErrorMessage } from "./shared/errorUtils"; import DeleteResourceModal from "./common_components/DeleteResourceModal"; import CloudZeroCostTracking from "./CloudZeroCostTracking/CloudZeroCostTracking"; +import NewBadge from "./common_components/NewBadge"; interface SettingsPageProps { accessToken: string | null; userRole: string | null; @@ -569,7 +570,9 @@ const Settings: React.FC = ({ accessToken, userRole, userID, Logging Callbacks - CloudZero Cost Tracking + + CloudZero Cost Tracking + Alerting Types Alerting Settings Email Alerts diff --git a/ui/litellm-dashboard/src/components/team/member_permissions.tsx b/ui/litellm-dashboard/src/components/team/member_permissions.tsx index 6a7ab541ddf..167c3228d56 100644 --- a/ui/litellm-dashboard/src/components/team/member_permissions.tsx +++ b/ui/litellm-dashboard/src/components/team/member_permissions.tsx @@ -1,21 +1,10 @@ -import React, { useState, useEffect } from "react"; -import { - Card, - Title, - Text, - Button as TremorButton, - Table, - TableHead, - TableHeaderCell, - TableBody, - TableRow, - TableCell, -} from "@tremor/react"; -import { Button, Checkbox, Empty } from "antd"; -import { ReloadOutlined, SaveOutlined } from "@ant-design/icons"; import { getTeamPermissionsCall, teamPermissionsUpdateCall } from "@/components/networking"; -import { getPermissionInfo } from "./permission_definitions"; +import { ReloadOutlined, SaveOutlined } from "@ant-design/icons"; +import { Card, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Text, Title } from "@tremor/react"; +import { Button, Checkbox, Empty } from "antd"; +import React, { useEffect, useState } from "react"; import NotificationsManager from "../molecules/notifications_manager"; +import { getPermissionInfo } from "./permission_definitions"; interface MemberPermissionsProps { teamId: string; @@ -94,9 +83,9 @@ const MemberPermissions: React.FC = ({ teamId, accessTok - +
)}
diff --git a/ui/litellm-dashboard/src/components/team/team_info.test.tsx b/ui/litellm-dashboard/src/components/team/team_info.test.tsx index 9b19611828f..4f7d70ba6ab 100644 --- a/ui/litellm-dashboard/src/components/team/team_info.test.tsx +++ b/ui/litellm-dashboard/src/components/team/team_info.test.tsx @@ -1,5 +1,6 @@ import * as networking from "@/components/networking"; -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, screen, waitFor } from "@testing-library/react"; +import { renderWithProviders } from "../../../tests/test-utils"; import { afterEach, describe, expect, it, vi } from "vitest"; import TeamInfoView from "./team_info"; @@ -62,7 +63,7 @@ describe("TeamInfoView", () => { vi.mocked(networking.getGuardrailsList).mockResolvedValue({ guardrails: [] }); vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]); - render( + renderWithProviders( {}} @@ -124,7 +125,7 @@ describe("TeamInfoView", () => { vi.mocked(networking.getGuardrailsList).mockResolvedValue({ guardrails: [] }); vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]); - render( + renderWithProviders( {}} @@ -219,7 +220,7 @@ describe("TeamInfoView", () => { vi.mocked(networking.getGuardrailsList).mockResolvedValue({ guardrails: [] }); vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]); - render( + renderWithProviders( {}} @@ -310,7 +311,7 @@ describe("TeamInfoView", () => { vi.mocked(networking.getGuardrailsList).mockResolvedValue({ guardrails: [] }); vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]); - render( + renderWithProviders( {}} @@ -373,7 +374,7 @@ describe("TeamInfoView", () => { vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]); vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: teamResponse.team_info, team_id: "123" } as any); - render( + renderWithProviders( {}} @@ -450,7 +451,7 @@ describe("TeamInfoView", () => { vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]); vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: teamResponse.team_info, team_id: "123" } as any); - render( + renderWithProviders( {}} diff --git a/ui/litellm-dashboard/src/components/team/team_info.tsx b/ui/litellm-dashboard/src/components/team/team_info.tsx index 49a04cce1d1..d2d1c885931 100644 --- a/ui/litellm-dashboard/src/components/team/team_info.tsx +++ b/ui/litellm-dashboard/src/components/team/team_info.tsx @@ -508,7 +508,7 @@ const TeamInfoView: React.FC = ({ Back to Teams {info.team_alias} -
+
{info.team_id}
@@ -1347,7 +1354,6 @@ const OldModelDashboard: React.FC = ({ credentials={credentialsList} accessToken={accessToken} userRole={userRole} - premiumUser={premiumUser} /> diff --git a/ui/litellm-dashboard/src/components/templates/view_key_table.tsx b/ui/litellm-dashboard/src/components/templates/view_key_table.tsx index 5defc287a07..0344edaa85d 100644 --- a/ui/litellm-dashboard/src/components/templates/view_key_table.tsx +++ b/ui/litellm-dashboard/src/components/templates/view_key_table.tsx @@ -1,34 +1,9 @@ "use client"; -import React, { useEffect, useState } from "react"; -import { keyDeleteCall, Organization } from "../networking"; -import { add } from "date-fns"; -import { regenerateKeyCall } from "../networking"; -import { Grid, Col, Button, Text, Title, TextInput } from "@tremor/react"; -import { fetchAvailableModelsForTeamOrKey } from "../key_team_helpers/fetch_available_models_team_key"; -import { Modal, Form, InputNumber } from "antd"; -import { CopyToClipboard } from "react-copy-to-clipboard"; -import useKeyList from "../key_team_helpers/key_list"; -import { KeyResponse } from "../key_team_helpers/key_list"; -import { AllKeysTable } from "../all_keys_table"; -import { Team } from "../key_team_helpers/key_list"; import { Setter } from "@/types"; - -import NotificationManager from "../molecules/notifications_manager"; - -interface EditKeyModalProps { - visible: boolean; - onCancel: () => void; - token: any; // Assuming TeamType is a type representing your team object - onSubmit: (data: FormData) => void; // Assuming FormData is the type of data to be submitted -} - -interface ModelLimitModalProps { - visible: boolean; - onCancel: () => void; - token: KeyResponse; - onSubmit: (updatedMetadata: any) => void; - accessToken: string; -} +import React from "react"; +import { VirtualKeysTable } from "../VirtualKeysPage/VirtualKeysTable"; +import useKeyList, { KeyResponse, Team } from "../key_team_helpers/key_list"; +import { Organization } from "../networking"; // Define the props type interface ViewKeyTableProps { @@ -50,493 +25,34 @@ interface ViewKeyTableProps { setAccessToken?: (token: string) => void; } -interface ItemData { - key_alias: string | null; - key_name: string; - spend: string; - max_budget: string | null; - models: string[]; - tpm_limit: string | null; - rpm_limit: string | null; - token: string; - token_id: string | null; - id: number; - team_id: string; - metadata: any; - user_id: string | null; - expires: any; - budget_duration: string | null; - budget_reset_at: string | null; - // Add any other properties that exist in the item data -} - -interface ModelLimits { - [key: string]: number; // Index signature allowing string keys -} - -interface CombinedLimit { - tpm: number; - rpm: number; -} - -interface CombinedLimits { - [key: string]: CombinedLimit; // Index signature allowing string keys -} - const ViewKeyTable: React.FC = ({ - userID, - userRole, accessToken, selectedTeam, setSelectedTeam, - data, - setData, teams, - premiumUser, currentOrg, organizations, setCurrentOrg, selectedKeyAlias, setSelectedKeyAlias, createClicked, - setAccessToken, }) => { - const [isButtonClicked, setIsButtonClicked] = useState(false); - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [keyToDelete, setKeyToDelete] = useState(null); - const [deleteConfirmInput, setDeleteConfirmInput] = useState(""); - const [selectedItem, setSelectedItem] = useState(null); - const [spendData, setSpendData] = useState<{ day: string; spend: number }[] | null>(null); - - // NEW: Declare filter states for team and key alias. - const [teamFilter, setTeamFilter] = useState(selectedTeam?.team_id || ""); - - // Keep the team filter in sync with the incoming prop. - useEffect(() => { - setTeamFilter(selectedTeam?.team_id || ""); - }, [selectedTeam]); - - // Build a memoized filters object for the backend call. - - // Pass filters into the hook so the API call includes these query parameters. const { keys, isLoading, error, pagination, refresh, setKeys } = useKeyList({ selectedTeam: selectedTeam || undefined, currentOrg, selectedKeyAlias, accessToken: accessToken || "", createClicked, + expand: ["user"], }); const handlePageChange = (newPage: number) => { refresh({ page: newPage }); }; - const [editModalVisible, setEditModalVisible] = useState(false); - const [infoDialogVisible, setInfoDialogVisible] = useState(false); - const [selectedToken, setSelectedToken] = useState(null); - const [userModels, setUserModels] = useState([]); - const initialKnownTeamIDs: Set = new Set(); - const [modelLimitModalVisible, setModelLimitModalVisible] = useState(false); - const [regenerateDialogVisible, setRegenerateDialogVisible] = useState(false); - const [regeneratedKey, setRegeneratedKey] = useState(null); - const [regenerateFormData, setRegenerateFormData] = useState(null); - const [regenerateForm] = Form.useForm(); - const [newExpiryTime, setNewExpiryTime] = useState(null); - - const [knownTeamIDs, setKnownTeamIDs] = useState(initialKnownTeamIDs); - const [guardrailsList, setGuardrailsList] = useState([]); - - useEffect(() => { - const calculateNewExpiryTime = (duration: string | undefined) => { - if (!duration) { - return null; - } - - try { - const now = new Date(); - let newExpiry: Date; - - if (duration.endsWith("s")) { - newExpiry = add(now, { seconds: parseInt(duration) }); - } else if (duration.endsWith("h")) { - newExpiry = add(now, { hours: parseInt(duration) }); - } else if (duration.endsWith("d")) { - newExpiry = add(now, { days: parseInt(duration) }); - } else { - throw new Error("Invalid duration format"); - } - - return newExpiry.toLocaleString("en-US", { - year: "numeric", - month: "numeric", - day: "numeric", - hour: "numeric", - minute: "numeric", - second: "numeric", - hour12: true, - }); - } catch (error) { - return null; - } - }; - - console.log("in calculateNewExpiryTime for selectedToken", selectedToken); - - // When a new duration is entered - if (regenerateFormData?.duration) { - setNewExpiryTime(calculateNewExpiryTime(regenerateFormData.duration)); - } else { - setNewExpiryTime(null); - } - - console.log("calculateNewExpiryTime:", newExpiryTime); - }, [selectedToken, regenerateFormData?.duration]); - - useEffect(() => { - const fetchUserModels = async () => { - try { - if (userID === null || userRole === null || accessToken === null) { - return; - } - - const models = await fetchAvailableModelsForTeamOrKey(userID, userRole, accessToken); - if (models) { - setUserModels(models); - } - } catch (error) { - NotificationManager.error({ description: "Error fetching user models" }); - } - }; - - fetchUserModels(); - }, [accessToken, userID, userRole]); - - useEffect(() => { - if (teams) { - const teamIDSet: Set = new Set(); - teams.forEach((team: any, index: number) => { - const team_obj: string = team.team_id; - teamIDSet.add(team_obj); - }); - setKnownTeamIDs(teamIDSet); - } - }, [teams]); - - const confirmDelete = async () => { - if (keyToDelete == null || keys == null) { - return; - } - - try { - if (!accessToken) return; - await keyDeleteCall(accessToken, keyToDelete); - // Successfully completed the deletion. Update the state to trigger a rerender. - const filteredKeys = keys.filter((item) => item.token !== keyToDelete); - setKeys(filteredKeys); - } catch (error) { - NotificationManager.error({ description: "Error deleting the key" }); - } - - // Close the confirmation modal and reset the keyToDelete - setIsDeleteModalOpen(false); - setKeyToDelete(null); - setDeleteConfirmInput(""); - }; - - const cancelDelete = () => { - // Close the confirmation modal and reset the keyToDelete - setIsDeleteModalOpen(false); - setKeyToDelete(null); - setDeleteConfirmInput(""); - }; - - const handleRegenerateClick = (token: any) => { - setSelectedToken(token); - setNewExpiryTime(null); - regenerateForm.setFieldsValue({ - key_alias: token.key_alias, - max_budget: token.max_budget, - tpm_limit: token.tpm_limit, - rpm_limit: token.rpm_limit, - duration: token.duration || "", - }); - setRegenerateDialogVisible(true); - }; - - const handleRegenerateFormChange = (field: string, value: any) => { - setRegenerateFormData((prev: any) => ({ - ...prev, - [field]: value, - })); - }; - - const handleRegenerateKey = async () => { - if (!premiumUser) { - NotificationManager.warning({ - description: "Regenerate Virtual Key is an Enterprise feature. Please upgrade to use this feature.", - }); - return; - } - - if (selectedToken == null) { - return; - } - - try { - const formValues = await regenerateForm.validateFields(); - if (!accessToken) return; - const response = await regenerateKeyCall(accessToken, selectedToken.token || selectedToken.token_id, formValues); - setRegeneratedKey(response.key); - - // Update the data state with the new key_name - if (data) { - const updatedData = data.map((item) => - item.token === selectedToken?.token ? { ...item, key_name: response.key_name, ...formValues } : item, - ); - setData(updatedData); - } - - setRegenerateDialogVisible(false); - regenerateForm.resetFields(); - NotificationManager.success({ description: "Virtual Key regenerated successfully" }); - } catch (error) { - console.error("Error regenerating key:", error); - NotificationManager.error({ description: "Failed to regenerate Virtual Key" }); - } - }; - return (
- - - {isDeleteModalOpen && - (() => { - const keyData = keys?.find((k) => k.token === keyToDelete); - const keyName = keyData?.key_alias || keyData?.token_id || keyToDelete; - const isValid = deleteConfirmInput === keyName; - return ( -
-
-
-
-

Delete Key

- -
-
-
-
- - - -
-
-

- Warning: You are about to delete this Virtual Key. -

-

- This action is irreversible and will immediately revoke access for any applications using this - key. -

-
-
-

Are you sure you want to delete this Virtual Key?

-
- - setDeleteConfirmInput(e.target.value)} - placeholder="Enter key name exactly" - className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-base" - autoFocus - /> -
-
-
-
- - -
-
-
- ); - })()} - - {/* Regenerate Key Form Modal */} - { - setRegenerateDialogVisible(false); - regenerateForm.resetFields(); - }} - footer={[ - , - , - ]} - > - {premiumUser ? ( - { - if ("duration" in changedValues) { - handleRegenerateFormChange("duration", changedValues.duration); - } - }} - > - - - - - - - - - - - - - - - -
- Current expiry:{" "} - {selectedToken?.expires != null ? new Date(selectedToken.expires).toLocaleString() : "Never"} -
- {newExpiryTime &&
New expiry: {newExpiryTime}
} - - ) : ( -
-

Upgrade to use this feature

- -
- )} -
- - {/* Regenerated Key Display Modal */} - {regeneratedKey && ( - setRegeneratedKey(null)} - footer={[ - , - ]} - > - - Regenerated Key - -

- Please replace your old key with the new key generated. For security reasons,{" "} - you will not be able to view it again through your LiteLLM account. If you lose this secret key, - you will need to generate a new one. -

- - - Key Alias: -
-
-                  {selectedToken?.key_alias || "No alias set"}
-                
-
- New Virtual Key: -
-
{regeneratedKey}
-
- NotificationManager.success({ description: "Virtual Key copied to clipboard" })} - > - - - -
-
- )} +
); }; diff --git a/ui/litellm-dashboard/src/components/user_dashboard.tsx b/ui/litellm-dashboard/src/components/user_dashboard.tsx index 4f910452bd8..ec6de82fdf9 100644 --- a/ui/litellm-dashboard/src/components/user_dashboard.tsx +++ b/ui/litellm-dashboard/src/components/user_dashboard.tsx @@ -1,23 +1,23 @@ "use client"; -import React, { useState, useEffect } from "react"; -import { - userInfoCall, - modelAvailableCall, - getProxyUISettings, - Organization, - keyInfoCall, - getProxyBaseUrl, -} from "./networking"; -import { fetchTeams } from "./common_components/fetch_teams"; -import { Grid, Col } from "@tremor/react"; -import CreateKey from "./organisms/create_key_button"; -import ViewKeyTable from "./templates/view_key_table"; -import Onboarding from "../app/onboarding/page"; -import { useSearchParams } from "next/navigation"; -import { KeyResponse, Team } from "./key_team_helpers/key_list"; -import { jwtDecode } from "jwt-decode"; -import { Typography } from "antd"; import { clearTokenCookies } from "@/utils/cookieUtils"; +import { Col, Grid } from "@tremor/react"; +import { Typography } from "antd"; +import { jwtDecode } from "jwt-decode"; +import { useSearchParams } from "next/navigation"; +import React, { useEffect, useState } from "react"; +import Onboarding from "../app/onboarding/page"; +import { fetchTeams } from "./common_components/fetch_teams"; +import { KeyResponse, Team } from "./key_team_helpers/key_list"; +import { + getProxyBaseUrl, + getProxyUISettings, + keyInfoCall, + modelAvailableCall, + Organization, + userInfoCall, +} from "./networking"; +import CreateKey from "./organisms/create_key_button"; +import { VirtualKeysTable } from "./VirtualKeysPage/VirtualKeysTable"; export interface ProxySettings { PROXY_BASE_URL: string | null; @@ -92,13 +92,7 @@ const UserDashboard: React.FC = ({ const [teamSpend, setTeamSpend] = useState(null); const [userModels, setUserModels] = useState([]); const [proxySettings, setProxySettings] = useState(null); - const defaultTeam: TeamInterface = { - models: [], - team_alias: "Default Team", - team_id: null, - }; const [selectedTeam, setSelectedTeam] = useState(null); - const [selectedKeyAlias, setSelectedKeyAlias] = useState(null); // check if window is not undefined if (typeof window !== "undefined") { window.addEventListener("beforeunload", function () { @@ -352,34 +346,12 @@ const UserDashboard: React.FC = ({ - - +
diff --git a/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx b/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx new file mode 100644 index 00000000000..affe28e0b25 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx @@ -0,0 +1,158 @@ +import React from "react"; +import { Accordion, AccordionHeader, AccordionBody } from "@tremor/react"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; + +export interface CostBreakdown { + input_cost?: number; + output_cost?: number; + total_cost?: number; + tool_usage_cost?: number; + original_cost?: number; + discount_percent?: number; + discount_amount?: number; + margin_percent?: number; + margin_fixed_amount?: number; + margin_total_amount?: number; +} + +interface CostBreakdownViewerProps { + costBreakdown: CostBreakdown | null | undefined; + totalSpend: number; +} + +const formatCost = (cost: number | undefined): string => { + if (cost === undefined || cost === null) return "-"; + return `$${formatNumberWithCommas(cost, 8)}`; +}; + +const formatPercent = (percent: number | undefined): string => { + if (percent === undefined || percent === null) return "-"; + return `${(percent * 100).toFixed(2)}%`; +}; + +export const CostBreakdownViewer: React.FC = ({ + costBreakdown, + totalSpend, +}) => { + if (!costBreakdown) { + return null; + } + + const hasDiscount = + (costBreakdown.discount_percent !== undefined && costBreakdown.discount_percent !== 0) || + (costBreakdown.discount_amount !== undefined && costBreakdown.discount_amount !== 0); + + const hasMargin = + (costBreakdown.margin_percent !== undefined && costBreakdown.margin_percent !== 0) || + (costBreakdown.margin_fixed_amount !== undefined && costBreakdown.margin_fixed_amount !== 0) || + (costBreakdown.margin_total_amount !== undefined && costBreakdown.margin_total_amount !== 0); + + // Don't show if there's no meaningful breakdown data + const hasMeaningfulData = + costBreakdown.input_cost !== undefined || + costBreakdown.output_cost !== undefined || + hasDiscount || + hasMargin; + + if (!hasMeaningfulData) { + return null; + } + + return ( +
+ + +
+

Cost Breakdown

+
+ Total: + {formatCost(totalSpend)} +
+
+
+ +
+ {/* Step 1: Base Token Costs */} +
+
+ Input Cost: + {formatCost(costBreakdown.input_cost)} +
+
+ Output Cost: + {formatCost(costBreakdown.output_cost)} +
+ {costBreakdown.tool_usage_cost !== undefined && costBreakdown.tool_usage_cost > 0 && ( +
+ Tool Usage Cost: + {formatCost(costBreakdown.tool_usage_cost)} +
+ )} +
+ + {/* Subtotal / Original Cost */} +
+
+ Original LLM Cost: + {formatCost(costBreakdown.original_cost)} +
+
+ + {/* Step 2: Adjustments (Discount & Margin) */} + {(hasDiscount || hasMargin) && ( +
+ {/* Discounts */} + {hasDiscount && ( +
+ {costBreakdown.discount_percent !== undefined && costBreakdown.discount_percent !== 0 && ( +
+ Discount ({formatPercent(costBreakdown.discount_percent)}): + -{formatCost(costBreakdown.discount_amount)} +
+ )} + {costBreakdown.discount_amount !== undefined && costBreakdown.discount_percent === undefined && ( +
+ Discount Amount: + -{formatCost(costBreakdown.discount_amount)} +
+ )} +
+ )} + + {/* Margins */} + {hasMargin && ( +
+ {costBreakdown.margin_percent !== undefined && costBreakdown.margin_percent !== 0 && ( +
+ Margin ({formatPercent(costBreakdown.margin_percent)}): + +{formatCost((costBreakdown.margin_total_amount || 0) - (costBreakdown.margin_fixed_amount || 0))} +
+ )} + {costBreakdown.margin_fixed_amount !== undefined && costBreakdown.margin_fixed_amount !== 0 && ( +
+ Margin: + +{formatCost(costBreakdown.margin_fixed_amount)} +
+ )} +
+ )} +
+ )} + + {/* Final Summary */} +
+
+ Final Calculated Cost: + + {formatCost(costBreakdown.total_cost ?? totalSpend)} + +
+
+
+
+
+
+ ); +}; + +export default CostBreakdownViewer; diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index f6a969a9eed..2a94284fca5 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -18,6 +18,7 @@ import KeyInfoView from "../templates/key_info_view"; import { SessionView } from "./SessionView"; import { VectorStoreViewer } from "./VectorStoreViewer"; import GuardrailViewer from "@/components/view_logs/GuardrailViewer/GuardrailViewer"; +import { CostBreakdownViewer } from "./CostBreakdownViewer"; import FilterComponent from "../molecules/filter"; import { FilterOption } from "../molecules/filter"; import { useLogFilterLogic } from "./log_filter_logic"; @@ -366,6 +367,24 @@ export default function SpendLogsTable({ setExpandedRequestId(requestId); }; + // Function to extract unique error codes from logs + const extractErrorCodes = (logs: LogEntry[], searchText: string = "") => { + const errorCodes = new Set(); + logs.forEach((log) => { + const metadata = log.metadata || {}; + if (metadata.status === "failure" && metadata.error_information) { + const errorCode = metadata.error_information.error_code; + if (errorCode && (!searchText || errorCode.toLowerCase().includes(searchText.toLowerCase()))) { + errorCodes.add(errorCode); + } + } + }); + return Array.from(errorCodes).map((code) => ({ + label: code, + value: code, + })); + }; + const logFilterOptions: FilterOption[] = [ { name: "Team ID", @@ -426,6 +445,14 @@ export default function SpendLogsTable({ return filtered.map((u: string) => ({ label: u, value: u })); }, }, + { + name: "Error Code", + label: "Error Code", + isSearchable: true, + searchFn: async (searchText: string) => { + return extractErrorCodes(logsData.data, searchText); + }, + }, { name: "Key Hash", label: "Key Hash", @@ -499,12 +526,8 @@ export default function SpendLogsTable({ setSelectedKeyIdInfoView(null)} - premiumUser={premiumUser} backButtonText="Back to Logs" /> ) : selectedSessionId ? ( @@ -940,6 +963,9 @@ export function RequestViewer({ row }: { row: Row }) {
+ {/* Cost Breakdown - Show if cost breakdown data is available */} + + {/* Configuration Info Message - Show when data is missing */} diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx index c46908d44d8..f56e72df496 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx @@ -17,6 +17,7 @@ export const FILTER_KEYS = { END_USER: "End User", STATUS: "Status", KEY_ALIAS: "Key Alias", + ERROR_CODE: "Error Code", } as const; export type FilterKey = keyof typeof FILTER_KEYS; @@ -53,6 +54,7 @@ export function useLogFilterLogic({ [FILTER_KEYS.END_USER]: "", [FILTER_KEYS.STATUS]: "", [FILTER_KEYS.KEY_ALIAS]: "", + [FILTER_KEYS.ERROR_CODE]: "", }), [], ); @@ -94,6 +96,7 @@ export function useLogFilterLogic({ filters[FILTER_KEYS.STATUS] || undefined, filters[FILTER_KEYS.MODEL] || undefined, filters[FILTER_KEYS.KEY_ALIAS] || undefined, + filters[FILTER_KEYS.ERROR_CODE] || undefined, ); if (currentTimestamp === lastSearchTimestamp.current && response.data) { @@ -133,7 +136,8 @@ export function useLogFilterLogic({ filters[FILTER_KEYS.KEY_HASH] || filters[FILTER_KEYS.REQUEST_ID] || filters[FILTER_KEYS.USER_ID] || - filters[FILTER_KEYS.END_USER] + filters[FILTER_KEYS.END_USER] || + filters[FILTER_KEYS.ERROR_CODE] ), [filters], ); @@ -182,6 +186,14 @@ export function useLogFilterLogic({ filteredData = filteredData.filter((log) => log.end_user === filters[FILTER_KEYS.END_USER]); } + if (filters[FILTER_KEYS.ERROR_CODE]) { + filteredData = filteredData.filter((log) => { + const metadata = log.metadata || {}; + const errorInfo = metadata.error_information; + return errorInfo && errorInfo.error_code === filters[FILTER_KEYS.ERROR_CODE]; + }); + } + return { data: filteredData, total: logs.total, diff --git a/ui/litellm-dashboard/src/utils/teamUtils.test.ts b/ui/litellm-dashboard/src/utils/teamUtils.test.ts index 1151d532611..6c82da61854 100644 --- a/ui/litellm-dashboard/src/utils/teamUtils.test.ts +++ b/ui/litellm-dashboard/src/utils/teamUtils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { resolveTeamAliasFromTeamID } from "./teamUtils"; -import type { Team } from "@/components/networking"; +import { resolveTeamAliasFromTeamID, createTeamAliasMap } from "./teamUtils"; +import type { Team } from "@/components/key_team_helpers/key_list"; describe("resolveTeamAliasFromTeamID", () => { it("should return team alias when team is found", () => { @@ -35,3 +35,30 @@ describe("resolveTeamAliasFromTeamID", () => { expect(result).toBeNull(); }); }); + +describe("createTeamAliasMap", () => { + it("should create a map from team_id to team_alias", () => { + const teams = [ + { + team_id: "team1", + team_alias: "Team One", + }, + { + team_id: "team2", + team_alias: "Team Two", + }, + ] as unknown as Team[]; + + const result = createTeamAliasMap(teams); + expect(result).toEqual({ + team1: "Team One", + team2: "Team Two", + }); + }); + + it("should return empty object when teams is null or undefined", () => { + expect(createTeamAliasMap(null)).toEqual({}); + expect(createTeamAliasMap(undefined)).toEqual({}); + expect(createTeamAliasMap([])).toEqual({}); + }); +}); diff --git a/ui/litellm-dashboard/src/utils/teamUtils.ts b/ui/litellm-dashboard/src/utils/teamUtils.ts index 1916e1c98aa..67100661a6f 100644 --- a/ui/litellm-dashboard/src/utils/teamUtils.ts +++ b/ui/litellm-dashboard/src/utils/teamUtils.ts @@ -1,5 +1,27 @@ -import { Team } from "@/components/networking"; +import { Team } from "@/components/key_team_helpers/key_list"; +/** + * Creates a map from team_id to team_alias for efficient lookups. + * @param teams - Array of Team objects + * @returns Record mapping team_id to team_alias + */ +export const createTeamAliasMap = (teams: Team[] | null | undefined): Record => { + if (!teams) return {}; + return teams.reduce( + (acc, team) => { + acc[team.team_id] = team.team_alias; + return acc; + }, + {} as Record, + ); +}; + +/** + * Resolves a team alias from a team ID. + * @param teamID - The team ID to look up + * @param teams - Array of Team objects + * @returns The team alias if found, null otherwise + */ export const resolveTeamAliasFromTeamID = (teamID: string, teams: Team[]): string | null => { const team = teams.find((team) => team.team_id === teamID); return team ? team.team_alias : null; diff --git a/ui/litellm-dashboard/storageState.json b/ui/litellm-dashboard/storageState.json new file mode 100644 index 00000000000..f2f012b34f8 --- /dev/null +++ b/ui/litellm-dashboard/storageState.json @@ -0,0 +1,15 @@ +{ + "cookies": [ + { + "name": "token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiZGVmYXVsdF91c2VyX2lkIiwia2V5Ijoic2stUkJVZGFncVZuY0VWMDNVdnNDZkstdyIsInVzZXJfZW1haWwiOm51bGwsInVzZXJfcm9sZSI6InByb3h5X2FkbWluIiwibG9naW5fbWV0aG9kIjoidXNlcm5hbWVfcGFzc3dvcmQiLCJwcmVtaXVtX3VzZXIiOnRydWUsImF1dGhfaGVhZGVyX25hbWUiOiJBdXRob3JpemF0aW9uIiwiZGlzYWJsZWRfbm9uX2FkbWluX3BlcnNvbmFsX2tleV9jcmVhdGlvbiI6ZmFsc2UsInNlcnZlcl9yb290X3BhdGgiOiIvIn0.aW_1-qDI9HuZvDq53ufTX_HrE6EJ7XSCPBb-N136KjQ", + "domain": "localhost", + "path": "/", + "expires": -1, + "httpOnly": false, + "secure": false, + "sameSite": "Lax" + } + ], + "origins": [] +} \ No newline at end of file diff --git a/ui/litellm-dashboard/tests/setupTests.ts b/ui/litellm-dashboard/tests/setupTests.ts index cce37d79057..e1ae45e8c58 100644 --- a/ui/litellm-dashboard/tests/setupTests.ts +++ b/ui/litellm-dashboard/tests/setupTests.ts @@ -33,6 +33,20 @@ vi.mock("@tremor/react", async (importOriginal) => { }; }); +// Global mock for useAuthorized hook to avoid repeating the same mock in every test file +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ + token: "123", + accessToken: "123", + userId: "user-1", + userEmail: "user@example.com", + userRole: "Admin", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }), +})); + afterEach(() => { cleanup(); }); diff --git a/ui/litellm-dashboard/tests/test-utils.tsx b/ui/litellm-dashboard/tests/test-utils.tsx index cf7fbaf0d8f..ed1f248648e 100644 --- a/ui/litellm-dashboard/tests/test-utils.tsx +++ b/ui/litellm-dashboard/tests/test-utils.tsx @@ -1,9 +1,26 @@ import React, { PropsWithChildren } from "react"; import { render, RenderOptions } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +// Create a client for testing +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: Infinity, + staleTime: Infinity, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + }, + mutations: { + retry: false, + }, + }, +}); const Providers: React.FC = ({ children }) => { - // Add future providers here (Theme/Router/QueryClient/etc.) - return <>{children}; + return {children}; }; export const renderWithProviders = (ui: React.ReactElement, options?: RenderOptions) => diff --git a/ui/litellm-dashboard/vitest.config.ts b/ui/litellm-dashboard/vitest.config.ts index 194b6fa691f..51e3df7a280 100644 --- a/ui/litellm-dashboard/vitest.config.ts +++ b/ui/litellm-dashboard/vitest.config.ts @@ -8,6 +8,8 @@ export default defineConfig({ globals: true, css: true, // lets you import CSS/modules without extra mocks coverage: { reporter: ["text", "lcov"] }, + exclude: ["e2e_tests/**,", "node_modules/**"], + include: ["src/**/*.test.ts", "src/**/*.test.tsx", "tests/**/*.test.ts", "tests/**/*.test.tsx"], }, resolve: { alias: {