diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 3d11345e850..0b7cce2e4be 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -34,8 +34,6 @@ jobs: build-mode: none - language: python build-mode: none - - language: ruby - build-mode: none steps: - name: Checkout repository diff --git a/docs/my-website/docs/proxy/guardrails/guardrail_pipeline_flow_builder.md b/docs/my-website/docs/proxy/guardrails/guardrail_pipeline_flow_builder.md deleted file mode 100644 index 0ea56eef081..00000000000 --- a/docs/my-website/docs/proxy/guardrails/guardrail_pipeline_flow_builder.md +++ /dev/null @@ -1,270 +0,0 @@ -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - -# Guardrail Pipeline Flow Builder - -The **Flow Builder** lets you design guardrail policies with **conditional, sequential execution**. Instead of running guardrails independently, you chain them into a pipeline where each step has configurable **ON PASS** and **ON FAIL** actions. This enables multi-tier fallbacks, retries, and escalation paths. - -## When to use the Flow Builder - -| Use Case | Simple Policy | Pipeline (Flow Builder) | -|----------|---------------|-------------------------| -| Run multiple guardrails together | ✅ | ✅ | -| All guardrails run independently | ✅ | ❌ | -| Conditional execution (if A fails → try B) | ❌ | ✅ | -| Fallback to different guardrail on failure | ❌ | ✅ | -| Retry same guardrail before blocking | ❌ | ✅ | -| Pass modified data (e.g., PII-masked) to next step | ❌ | ✅ | - -**Use the Flow Builder when** you need: -- **Fallbacks** — Try a fast/simple guardrail first; if it fails, escalate to a stricter one -- **Retries** — Run the same guardrail multiple times before blocking (e.g., for flaky APIs) -- **Escalation** — Route to different guardrails based on pass/fail outcomes - -## Quick Start - - - - -1. Go to **Policies** → **+ Create New Policy** -2. Choose **Flow Builder** (instead of Simple) -3. Click **Continue to Builder** to open the full-screen Flow Builder -4. Add steps, select guardrails, and configure ON PASS / ON FAIL actions -5. Use **Test** to run a sample message through the pipeline before saving -6. Save the policy - - - - -```yaml showLineNumbers title="config.yaml" -guardrails: - - guardrail_name: strict-filter - litellm_params: - guardrail: lakera - mode: pre_call - api_key: os.environ/LAKERA_API_KEY - - guardrail_name: permissive-filter - litellm_params: - guardrail: presidio - mode: pre_call - -policies: - content-safety: - guardrails: - add: [strict-filter, permissive-filter] - pipeline: - mode: pre_call - steps: - - guardrail: strict-filter - on_fail: next - on_pass: allow - - guardrail: permissive-filter - on_fail: block - on_pass: allow - -policy_attachments: - - policy: content-safety - scope: "*" -``` - - - - -## Step Actions - -Each pipeline step has two actions: - -| Action | When | Description | -|--------|------|-------------| -| **Next Step** | ON PASS or ON FAIL | Continue to the next step in the pipeline | -| **Allow** | ON PASS or ON FAIL | Stop the pipeline and allow the request | -| **Block** | ON PASS or ON FAIL | Stop the pipeline and block the request | -| **Custom Response** | ON PASS or ON FAIL | Stop and return a custom message instead of the default block/allow | - -### Common patterns - -**Fallback chain** — Try strict first, escalate to permissive on failure: - -```yaml -steps: - - guardrail: strict-filter - on_fail: next # strict failed → try next - on_pass: allow - - guardrail: permissive-filter - on_fail: block # permissive failed → block - on_pass: allow -``` - -**Retry same guardrail** — Run the same guardrail twice before blocking: - -```yaml -steps: - - guardrail: lakera-pii - on_fail: next - on_pass: allow - - guardrail: lakera-pii - on_fail: block - on_pass: allow -``` - -**Pass modified data** — Forward PII-masked content to the next step: - -```yaml -steps: - - guardrail: presidio-pii - on_fail: block - on_pass: next - pass_data: true # PII-masked request/response sent to next step - - guardrail: prompt-injection - on_fail: block - on_pass: allow -``` - -## Pipeline Fields - -### `pipeline` - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `mode` | `pre_call` \| `post_call` | Yes | When the pipeline runs (before or after the LLM call) | -| `steps` | `list[PipelineStep]` | Yes | Ordered list of steps (at least 1) | - -### `PipelineStep` - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `guardrail` | `string` | — | Name of the guardrail to run | -| `on_fail` | `next` \| `block` \| `allow` \| `modify_response` | `block` | Action when guardrail rejects | -| `on_pass` | `next` \| `block` \| `allow` \| `modify_response` | `allow` | Action when guardrail passes | -| `pass_data` | `bool` | `false` | Forward modified request/response to next step | -| `modify_response_message` | `string` | Optional | Custom message for `modify_response` action | - -## Example: Multi-tier content safety - -```yaml showLineNumbers title="config.yaml" -guardrails: - - guardrail_name: "pii-masking" - litellm_params: - guardrail: presidio - mode: pre_call - - guardrail_name: "prompt-injection" - litellm_params: - guardrail: lakera - mode: pre_call - api_key: os.environ/LAKERA_API_KEY - -policies: - content-safety-pipeline: - description: "PII mask → prompt injection check → allow or block" - guardrails: - add: [pii-masking, prompt-injection] - pipeline: - mode: pre_call - steps: - - guardrail: pii-masking - on_fail: block - on_pass: next - pass_data: true - - guardrail: prompt-injection - on_fail: block - on_pass: allow - -policy_attachments: - - policy: content-safety-pipeline - scope: "*" -``` - -**Flow:** 1) Mask PII → 2) Check masked content for prompt injection → 3) Allow or block. - -## Example: Retry with same guardrail - -Useful when a guardrail API is flaky or rate-limited: - -```yaml showLineNumbers title="config.yaml" -policies: - retry-on-failure: - guardrails: - add: [pii_masking] - pipeline: - mode: pre_call - steps: - - guardrail: pii_masking - on_fail: next - on_pass: allow - - guardrail: pii_masking - on_fail: block - on_pass: allow -``` - -**Flow:** Run `pii_masking` twice. Block only if it fails both times. - -## Example: Custom response on failure - -Return a branded message instead of the default block: - -```yaml -steps: - - guardrail: strict-filter - on_fail: modify_response - modify_response_message: "Your request was blocked. Please remove sensitive content and try again." - on_pass: allow -``` - -## Pipeline vs Simple Policy - -**Simple policy** — All guardrails run independently. If any fail, the request is blocked (or handled per guardrail config). - -**Pipeline policy** — Guardrails run in order. Each step has conditional actions. You control the flow (fallback, retry, escalate). - -```mermaid -flowchart TD - subgraph Simple["Simple Policy"] - S1[Guardrail A] --> R[Result: block if any fail] - S2[Guardrail B] --> R - end - - subgraph Pipeline["Pipeline Policy"] - P1[Step 1: Guardrail A] -->|on_fail: next| P2[Step 2: Guardrail B] - P1 -->|on_pass: allow| Allow - P2 -->|on_fail: block| Block - P2 -->|on_pass: allow| Allow - end - -## Testing the pipeline - -### In the UI - -The Flow Builder includes a **Test** panel. Enter a sample message and click **Run** to see which steps pass or fail and what action is taken. - -### Via API - -Use the [Test Playground](/docs/proxy/guardrails/test_playground) or send a request with the policy attached: - -```bash -curl -X POST http://localhost:4000/v1/chat/completions \ - -H "Authorization: Bearer sk-..." \ - -H "Content-Type: application/json" \ - -d '{ - "model": "gpt-4", - "messages": [{"role": "user", "content": "Test message"}], - "guardrails": ["content-safety-pipeline"] - }' -``` - -## Response headers - -When a pipeline runs, response headers include: - -| Header | Description | -|--------|-------------| -| `x-litellm-applied-policies` | Policies that matched | -| `x-litellm-applied-guardrails` | Guardrails that ran | -| `x-litellm-policy-sources` | Why each policy matched | - -## Related - -- [Guardrail Policies](/docs/proxy/guardrails/guardrail_policies) — Policies overview, attachments, inheritance -- [Policy Templates](/docs/proxy/guardrails/policy_templates) — Pre-configured policy templates -- [Guardrails Quick Start](/docs/proxy/guardrails/quick_start) — Defining guardrails diff --git a/docs/my-website/docs/proxy/guardrails/guardrail_policies.md b/docs/my-website/docs/proxy/guardrails/guardrail_policies.md index 72cb15afe0a..e2cb839203e 100644 --- a/docs/my-website/docs/proxy/guardrails/guardrail_policies.md +++ b/docs/my-website/docs/proxy/guardrails/guardrail_policies.md @@ -12,17 +12,6 @@ Use policies to group guardrails and control which ones run for specific teams, - Group guardrails into a single policy - Inherit from existing policies and override what you need -## Policy types - -LiteLLM supports two ways to run guardrails in a policy: - -| Type | Description | Use when | -|------|--------------|----------| -| **Simple** | All guardrails run independently. If any fail, the request is blocked. | You want a flat list of guardrails with no conditional logic. | -| **Pipeline (Flow Builder)** | Guardrails run sequentially with configurable ON PASS / ON FAIL actions per step. Supports fallbacks, retries, and escalation. | You need conditional execution (e.g., try strict filter first, fallback to permissive on failure). | - -For pipelines, see [Guardrail Pipeline Flow Builder](/docs/proxy/guardrails/guardrail_pipeline_flow_builder) for detailed documentation. - ## Quick Start @@ -332,9 +321,6 @@ policies: guardrails: add: [...] remove: [...] - pipeline: # Optional. Enables sequential, conditional execution. - mode: pre_call # or post_call - steps: [...] condition: model: ... ``` @@ -345,7 +331,6 @@ policies: | `inherit` | `string` | Optional. Parent policy to inherit guardrails from. | | `guardrails.add` | `list[string]` | Guardrails to enable. | | `guardrails.remove` | `list[string]` | Guardrails to disable (useful with inheritance). | -| `pipeline` | `object` | Optional. Enables [Flow Builder](/docs/proxy/guardrails/guardrail_pipeline_flow_builder) — sequential execution with ON PASS/ON FAIL actions. | | `condition.model` | `string` or `list[string]` | Optional. Only apply when model matches. Supports regex. | ### `policy_attachments` diff --git a/docs/my-website/docs/proxy/guardrails/team_based_guardrails.md b/docs/my-website/docs/proxy/guardrails/team_based_guardrails.md index 2d55294a711..0e610b6e445 100644 --- a/docs/my-website/docs/proxy/guardrails/team_based_guardrails.md +++ b/docs/my-website/docs/proxy/guardrails/team_based_guardrails.md @@ -1,6 +1,6 @@ import Image from '@theme/IdealImage'; -# Team-Based Guardrails +# Team Bring-Your-Own Guardrails Team-based guardrails let **developers** register a guardrail for their team via the API; an **admin** then reviews and approves or rejects it in the LiteLLM UI. Only [Generic Guardrail API](/docs/adding_provider/generic_guardrail_api) guardrails can be registered this way. diff --git a/docs/my-website/release_notes/v1.81.9.md b/docs/my-website/release_notes/v1.81.9.md index c7659442c4c..80be4179b46 100644 --- a/docs/my-website/release_notes/v1.81.9.md +++ b/docs/my-website/release_notes/v1.81.9.md @@ -279,7 +279,7 @@ Let's dive in. - Add HTTP support to custom code guardrails + Unified guardrails for MCP + Agent guardrail support - [PR #20619](https://github.com/BerriAI/litellm/pull/20619) - Custom Code Guardrails UI Playground - [PR #20377](https://github.com/BerriAI/litellm/pull/20377) -- **Team-Based Guardrails** +- **Team Bring-Your-Own Guardrails** - Implement team-based isolation guardrails management - [PR #20318](https://github.com/BerriAI/litellm/pull/20318) - **[OpenAI Moderations](../../docs/apply_guardrail)** diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index c36c0cd167e..758fdc82a98 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -100,7 +100,6 @@ const sidebars = { label: "Policies", items: [ "proxy/guardrails/guardrail_policies", - "proxy/guardrails/guardrail_pipeline_flow_builder", "proxy/guardrails/policy_templates", "proxy/guardrails/policy_tags", ], diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index bb4a678b541..d3dd6b3d992 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -27665,6 +27665,92 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "openrouter/qwen/qwen3.5-35b-a3b": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-35b-a3b", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/qwen/qwen3.5-27b": { + "input_cost_per_token": 3e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 2.4e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-27b", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/qwen/qwen3.5-122b-a10b": { + "input_cost_per_token": 4e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-122b-a10b", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/qwen/qwen3.5-flash-02-23": { + "input_cost_per_token": 1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 4e-07, + "source": "https://openrouter.ai/qwen/qwen3.5-flash-02-23", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/qwen/qwen3.5-plus-02-15": { + "input_cost_per_token": 4e-07, + "input_cost_per_token_above_256k_tokens": 5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 2.4e-06, + "output_cost_per_token_above_256k_tokens": 3e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-plus-02-15", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/qwen/qwen3.5-397b-a17b": { + "input_cost_per_token": 6e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 3.6e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-397b-a17b", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, "openrouter/switchpoint/router": { "input_cost_per_token": 8.5e-07, "litellm_provider": "openrouter", diff --git a/litellm/proxy/_experimental/out/assets/logos/figma.svg b/litellm/proxy/_experimental/out/assets/logos/figma.svg new file mode 100644 index 00000000000..2d8b70457d9 --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/figma.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/gitlab.svg b/litellm/proxy/_experimental/out/assets/logos/gitlab.svg new file mode 100644 index 00000000000..18a89fa328d --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/gitlab.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/gmail.svg b/litellm/proxy/_experimental/out/assets/logos/gmail.svg new file mode 100644 index 00000000000..d702890620d --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/gmail.svg @@ -0,0 +1,3 @@ + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/google_drive.svg b/litellm/proxy/_experimental/out/assets/logos/google_drive.svg new file mode 100644 index 00000000000..7048af9915e --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/google_drive.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/hubspot.svg b/litellm/proxy/_experimental/out/assets/logos/hubspot.svg new file mode 100644 index 00000000000..b993945ac6b --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/hubspot.svg @@ -0,0 +1,3 @@ + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/jira.svg b/litellm/proxy/_experimental/out/assets/logos/jira.svg new file mode 100644 index 00000000000..fb10ca75173 --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/jira.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/linear.svg b/litellm/proxy/_experimental/out/assets/logos/linear.svg new file mode 100644 index 00000000000..83662a1f9ff --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/linear.svg @@ -0,0 +1,3 @@ + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/notion.svg b/litellm/proxy/_experimental/out/assets/logos/notion.svg new file mode 100644 index 00000000000..170b9bb4140 --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/notion.svg @@ -0,0 +1,3 @@ + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/salesforce.svg b/litellm/proxy/_experimental/out/assets/logos/salesforce.svg new file mode 100644 index 00000000000..1a541a004f1 --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/salesforce.svg @@ -0,0 +1,3 @@ + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/sentry.svg b/litellm/proxy/_experimental/out/assets/logos/sentry.svg new file mode 100644 index 00000000000..9c3733dc43e --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/sentry.svg @@ -0,0 +1,3 @@ + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/shopify.svg b/litellm/proxy/_experimental/out/assets/logos/shopify.svg new file mode 100644 index 00000000000..fcc7547269d --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/shopify.svg @@ -0,0 +1,4 @@ + + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/slack.svg b/litellm/proxy/_experimental/out/assets/logos/slack.svg new file mode 100644 index 00000000000..801de4f70c8 --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/slack.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/stripe.svg b/litellm/proxy/_experimental/out/assets/logos/stripe.svg new file mode 100644 index 00000000000..ac16a6fb170 --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/stripe.svg @@ -0,0 +1,3 @@ + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/twilio.svg b/litellm/proxy/_experimental/out/assets/logos/twilio.svg new file mode 100644 index 00000000000..3517a2824d9 --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/twilio.svg @@ -0,0 +1,3 @@ + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/zapier.svg b/litellm/proxy/_experimental/out/assets/logos/zapier.svg new file mode 100644 index 00000000000..8428ba82a5b --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/zapier.svg @@ -0,0 +1,3 @@ + + + diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index bb4a678b541..d3dd6b3d992 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -27665,6 +27665,92 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "openrouter/qwen/qwen3.5-35b-a3b": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-35b-a3b", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/qwen/qwen3.5-27b": { + "input_cost_per_token": 3e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 2.4e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-27b", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/qwen/qwen3.5-122b-a10b": { + "input_cost_per_token": 4e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-122b-a10b", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/qwen/qwen3.5-flash-02-23": { + "input_cost_per_token": 1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 4e-07, + "source": "https://openrouter.ai/qwen/qwen3.5-flash-02-23", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/qwen/qwen3.5-plus-02-15": { + "input_cost_per_token": 4e-07, + "input_cost_per_token_above_256k_tokens": 5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 2.4e-06, + "output_cost_per_token_above_256k_tokens": 3e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-plus-02-15", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/qwen/qwen3.5-397b-a17b": { + "input_cost_per_token": 6e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 3.6e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-397b-a17b", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, "openrouter/switchpoint/router": { "input_cost_per_token": 8.5e-07, "litellm_provider": "openrouter", diff --git a/tests/test_litellm/test_chat_ui_responses_session.py b/tests/test_litellm/test_chat_ui_responses_session.py new file mode 100644 index 00000000000..09ef003ebdb --- /dev/null +++ b/tests/test_litellm/test_chat_ui_responses_session.py @@ -0,0 +1,127 @@ +""" +Tests for responses API session chaining used by the chat UI. + +Verifies that: +1. previous_response_id is correctly forwarded when provided +2. Absence of previous_response_id does not break the call +3. The aresponses function signature exposes the expected parameters +""" +import inspect +import json +import os +import sys +import unittest.mock as mock + +# Use __file__ so the import path is correct regardless of the pytest working directory. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +import httpx +import pytest + +import litellm + + +class TestResponsesSessionChaining: + """Test previous_response_id session chaining for the chat UI.""" + + def test_responses_api_signature_accepts_previous_response_id(self): + """aresponses must accept previous_response_id and onResponseId-like params.""" + sig = inspect.signature(litellm.aresponses) + assert "previous_response_id" in sig.parameters, ( + "aresponses must accept previous_response_id for multi-turn session chaining" + ) + assert "input" in sig.parameters, "aresponses must accept input" + assert "model" in sig.parameters, "aresponses must accept model" + + @pytest.mark.asyncio + async def test_previous_response_id_included_in_request_body(self): + """previous_response_id must appear in the outgoing HTTP request body.""" + captured_body: dict = {} + + async def mock_send(self_transport, request: httpx.Request, **kwargs): + try: + captured_body.update(json.loads(request.content)) + except Exception: + pass + # Return a minimal valid responses API response + response_json = { + "id": "resp_test123", + "object": "response", + "model": "gpt-4o-mini", + "output": [ + { + "type": "message", + "id": "msg_001", + "role": "assistant", + "content": [{"type": "output_text", "text": "hi", "annotations": []}], + "status": "completed", + } + ], + "usage": {"input_tokens": 5, "output_tokens": 3, "total_tokens": 8}, + "status": "completed", + "created_at": 1700000000, + } + return httpx.Response( + 200, + json=response_json, + request=request, + ) + + with mock.patch("httpx.AsyncClient.send", mock_send): + try: + await litellm.aresponses( + input="hello", + model="gpt-4o-mini", + previous_response_id="resp_prev_abc", + api_key="sk-test-fake", + ) + except Exception: + pass # response parsing may fail; we only care about the outgoing body + + assert captured_body.get("previous_response_id") == "resp_prev_abc", ( + f"Expected previous_response_id in request body, got: {captured_body}" + ) + + @pytest.mark.asyncio + async def test_no_previous_response_id_omitted_from_request(self): + """When previous_response_id is None, it must not appear in the request body.""" + captured_body: dict = {} + + async def mock_send(self_transport, request: httpx.Request, **kwargs): + try: + captured_body.update(json.loads(request.content)) + except Exception: + pass + response_json = { + "id": "resp_new001", + "object": "response", + "model": "gpt-4o-mini", + "output": [ + { + "type": "message", + "id": "msg_001", + "role": "assistant", + "content": [{"type": "output_text", "text": "hi", "annotations": []}], + "status": "completed", + } + ], + "usage": {"input_tokens": 5, "output_tokens": 3, "total_tokens": 8}, + "status": "completed", + "created_at": 1700000000, + } + return httpx.Response(200, json=response_json, request=request) + + with mock.patch("httpx.AsyncClient.send", mock_send): + try: + await litellm.aresponses( + input="hello", + model="gpt-4o-mini", + previous_response_id=None, + api_key="sk-test-fake", + ) + except Exception: + pass + + assert "previous_response_id" not in captured_body, ( + "previous_response_id must be omitted from the request body when None" + ) diff --git a/ui/litellm-dashboard/public/assets/logos/figma.svg b/ui/litellm-dashboard/public/assets/logos/figma.svg new file mode 100644 index 00000000000..2d8b70457d9 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/figma.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ui/litellm-dashboard/public/assets/logos/gitlab.svg b/ui/litellm-dashboard/public/assets/logos/gitlab.svg new file mode 100644 index 00000000000..18a89fa328d --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/gitlab.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/ui/litellm-dashboard/public/assets/logos/gmail.svg b/ui/litellm-dashboard/public/assets/logos/gmail.svg new file mode 100644 index 00000000000..d702890620d --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/gmail.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/public/assets/logos/google_drive.svg b/ui/litellm-dashboard/public/assets/logos/google_drive.svg new file mode 100644 index 00000000000..7048af9915e --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/google_drive.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui/litellm-dashboard/public/assets/logos/hubspot.svg b/ui/litellm-dashboard/public/assets/logos/hubspot.svg new file mode 100644 index 00000000000..b993945ac6b --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/hubspot.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/public/assets/logos/jira.svg b/ui/litellm-dashboard/public/assets/logos/jira.svg new file mode 100644 index 00000000000..fb10ca75173 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/jira.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/ui/litellm-dashboard/public/assets/logos/linear.svg b/ui/litellm-dashboard/public/assets/logos/linear.svg new file mode 100644 index 00000000000..83662a1f9ff --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/linear.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/public/assets/logos/notion.svg b/ui/litellm-dashboard/public/assets/logos/notion.svg new file mode 100644 index 00000000000..170b9bb4140 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/notion.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/public/assets/logos/salesforce.svg b/ui/litellm-dashboard/public/assets/logos/salesforce.svg new file mode 100644 index 00000000000..1a541a004f1 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/salesforce.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/public/assets/logos/sentry.svg b/ui/litellm-dashboard/public/assets/logos/sentry.svg new file mode 100644 index 00000000000..9c3733dc43e --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/sentry.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/public/assets/logos/shopify.svg b/ui/litellm-dashboard/public/assets/logos/shopify.svg new file mode 100644 index 00000000000..fcc7547269d --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/shopify.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui/litellm-dashboard/public/assets/logos/slack.svg b/ui/litellm-dashboard/public/assets/logos/slack.svg new file mode 100644 index 00000000000..801de4f70c8 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/slack.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui/litellm-dashboard/public/assets/logos/stripe.svg b/ui/litellm-dashboard/public/assets/logos/stripe.svg new file mode 100644 index 00000000000..ac16a6fb170 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/stripe.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/public/assets/logos/twilio.svg b/ui/litellm-dashboard/public/assets/logos/twilio.svg new file mode 100644 index 00000000000..3517a2824d9 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/twilio.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/public/assets/logos/zapier.svg b/ui/litellm-dashboard/public/assets/logos/zapier.svg new file mode 100644 index 00000000000..8428ba82a5b --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/zapier.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx index dbc1c4d10e2..ceb14864ad9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx @@ -20,6 +20,7 @@ import { ToolOutlined, TagsOutlined, AuditOutlined, + MessageOutlined, } from "@ant-design/icons"; // import { // all_admin_roles, @@ -47,6 +48,7 @@ interface SidebarProps { interface MenuItemCfg { key: string; + newTab?: boolean; page: string; // legacy id; we map this to a path below label: string; roles?: string[]; @@ -105,6 +107,8 @@ const routeFor = (slug: string): string => { return "guardrails"; case "policies": return "policies"; + case "chat": + return "chat"; // tools case "mcp-servers": @@ -371,19 +375,29 @@ const Sidebar2: React.FC = ({ accessToken, userRole, defaultSelect }, [pathname, filteredMenuItems, defaultSelectedKey]); // ----- Navigation ----- - const goTo = (slug: string) => { + const goTo = (slug: string, newTab?: boolean) => { const href = toHref(slug); - router.push(href); + if (newTab) { + window.open(href, "_blank"); + } else { + router.push(href); + } }; // Wrap label in so every nav item supports right-click → "Open in new tab" // and Ctrl/Cmd+click to open in a new tab, while preserving SPA navigation for normal clicks. - const renderNavLink = (label: string, page: string): React.ReactNode => { + const renderNavLink = (label: string, page: string, newTab?: boolean): React.ReactNode => { const href = toHref(page); return ( { + if (newTab) { + e.stopPropagation(); + return; + } if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) { e.stopPropagation(); return; @@ -409,6 +423,8 @@ const Sidebar2: React.FC = ({ accessToken, userRole, defaultSelect style={{ transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", position: "relative", + display: "flex", + flexDirection: "column", }} > = ({ accessToken, userRole, defaultSelect borderRight: 0, backgroundColor: "transparent", fontSize: "14px", + flex: 1, + overflowY: "auto", }} items={filteredMenuItems.map((item) => ({ key: item.key, icon: item.icon, - label: renderNavLink(item.label, item.page), + label: renderNavLink(item.label, item.page, item.newTab), children: item.children?.map((child) => ({ key: child.key, icon: child.icon, - label: renderNavLink(child.label, child.page), - onClick: () => goTo(child.page), + label: renderNavLink(child.label, child.page, child.newTab), + onClick: () => goTo(child.page, child.newTab), })), - onClick: !item.children ? () => goTo(item.page) : undefined, + onClick: !item.children ? () => goTo(item.page, item.newTab) : undefined, }))} /> {isAdminRole(userRole) && !collapsed && } + + {/* Pinned "Open Chat" button at bottom */} +
+ { + (e.currentTarget as HTMLAnchorElement).style.background = "#0958d9"; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLAnchorElement).style.background = "#1677ff"; + }} + > + + {!collapsed && Open Chat} + +
); 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 index be910acf7e4..567e1d23013 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerHealth.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerHealth.test.ts @@ -38,7 +38,7 @@ describe("useMCPServerHealth", () => { vi.clearAllMocks(); }); - it("should fetch health status for given server IDs", async () => { + it("should fetch health status for all servers", async () => { const mockHealthStatuses = [ { server_id: "server-1", status: "healthy" }, { server_id: "server-2", status: "unhealthy" }, @@ -46,27 +46,6 @@ describe("useMCPServerHealth", () => { 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, }); @@ -75,30 +54,15 @@ describe("useMCPServerHealth", () => { expect(result.current.isSuccess).toBe(true); }); - expect(networking.fetchMCPServerHealth).toHaveBeenCalledWith("test-token-123", undefined); + expect(networking.fetchMCPServerHealth).toHaveBeenCalledWith("test-token-123"); 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"]), { + const { result } = renderHook(() => useMCPServerHealth(), { wrapper, }); @@ -116,7 +80,7 @@ describe("useMCPServerHealth", () => { accessToken: null, } as any); - const { result } = renderHook(() => useMCPServerHealth(["server-1"]), { + const { result } = renderHook(() => useMCPServerHealth(), { wrapper, }); @@ -124,4 +88,18 @@ describe("useMCPServerHealth", () => { expect(result.current.status).toBe("pending"); expect(networking.fetchMCPServerHealth).not.toHaveBeenCalled(); }); + + it("should use a stable query key that does not include server IDs", () => { + // Regression test: deleting a server used to pass a changing serverIds array into the + // hook, which was embedded in the query key. React Query would see a new key and fire + // a health check for every remaining server. + // + // The fix: the hook takes no serverIds parameter and uses a constant query key, so + // deleting (or adding) a server never causes an extra health check request. + // + // We verify the contract here by confirming the hook accepts no arguments. + // The stable-key behaviour is further exercised by mcp_servers.test.tsx. + const hookLength = useMCPServerHealth.length; + expect(hookLength).toBe(0); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerHealth.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerHealth.ts index 95d7f3bcee0..f81ade047e2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerHealth.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerHealth.ts @@ -10,11 +10,11 @@ interface MCPServerHealth { status: string; } -export const useMCPServerHealth = (serverIds?: string[]) => { +export const useMCPServerHealth = () => { const { accessToken } = useAuthorized(); return useQuery({ - queryKey: [...mcpServerHealthKeys.lists(), { serverIds }], - queryFn: async () => await fetchMCPServerHealth(accessToken!, serverIds), + queryKey: mcpServerHealthKeys.lists(), + queryFn: async () => await fetchMCPServerHealth(accessToken!), enabled: !!accessToken, // Refetch health status every 30 seconds to keep it up to date refetchInterval: 30000, diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx index 35f87e8770e..418bca64ea9 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx @@ -57,6 +57,18 @@ vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({ default: vi.fn(), })); +// Mock useOrganizations hook +vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ + useOrganizations: vi.fn().mockReturnValue({ + data: [ + { + organization_id: "org-1", + organization_alias: "Test Organization", + }, + ], + }), +})); + // Mock fetchTeams to prevent network calls vi.mock("@/app/(dashboard)/networking", async (importOriginal) => { const actual = await importOriginal(); @@ -125,6 +137,7 @@ const mockKey: KeyResponse = { user: { user_email: "user@example.com", user_id: "user-1", + user_alias: null, }, }; @@ -380,7 +393,7 @@ it("should render table headers correctly", () => { // Check that main headers are rendered (testing the header.isPlaceholder condition path) expect(screen.getByText("Key ID")).toBeInTheDocument(); expect(screen.getByText("Key Alias")).toBeInTheDocument(); - expect(screen.getByText("Team Alias")).toBeInTheDocument(); + expect(screen.getByText("Team")).toBeInTheDocument(); expect(screen.getByText("Models")).toBeInTheDocument(); expect(screen.getByText("Spend (USD)")).toBeInTheDocument(); }); @@ -463,6 +476,8 @@ it("should display 'Default Proxy Admin' for user_id when value is 'default_user const keyWithDefaultUserId = { ...mockKey, user_id: "default_user_id", + user_email: "", + user: { user_id: "default_user_id", user_email: "", user_alias: null }, }; mockUseFilterLogic.mockReturnValue({ diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index d9c590938fb..20cc1b8153c 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -1,5 +1,6 @@ "use client"; import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; +import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline"; import { @@ -25,13 +26,14 @@ import { Text, } from "@tremor/react"; import { InfoCircleOutlined, SyncOutlined } from "@ant-design/icons"; -import { Button as AntButton, Popover, Skeleton, Tooltip } from "antd"; +import { Button as AntButton, Popover, Skeleton, Tooltip, Typography } from "antd"; import React, { useEffect, useDeferredValue, useMemo, useState } from "react"; import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; import { useFilterLogic } from "../key_team_helpers/filter_logic"; import { PaginatedKeyAliasSelect } from "../KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect"; import { KeyResponse, Team } from "../key_team_helpers/key_list"; import FilterComponent, { FilterOption } from "../molecules/filter"; +import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag"; import { Organization } from "../networking"; import KeyInfoView from "../templates/key_info_view"; @@ -51,6 +53,8 @@ interface VirtualKeysTableProps { */ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSort }: VirtualKeysTableProps) { + const { data: fetchedOrganizations } = useOrganizations(); + const resolvedOrganizations = fetchedOrganizations ?? organizations ?? []; const [selectedKey, setSelectedKey] = useState(null); const [sorting, setSorting] = React.useState(() => { if (currentSort) { @@ -86,6 +90,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo } = useKeys(tablePagination.pageIndex + 1, tablePagination.pageSize, { sortBy: sortBy || undefined, sortOrder: sortOrder || undefined, + expand: "user", }); const [expandedAccordions, setExpandedAccordions] = useState>({}); @@ -172,11 +177,9 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo const value = info.getValue() as string; const width = info.cell.column.getSize(); return ( - - - {value ?? "-"} - - + + {value ?? "-"} + ); }, }, @@ -191,76 +194,110 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo { id: "team_alias", accessorKey: "team_id", - header: "Team Alias", + header: "Team", size: 120, enableSorting: false, - cell: ({ row, getValue }) => { - const teamId = getValue() as string; - const team = teams?.find((t) => t.team_id === teamId); - return team?.team_alias || "Unknown"; - }, - }, - { - id: "team_id", - accessorKey: "team_id", - header: "Team ID", - size: 80, - enableSorting: false, cell: (info) => { - const value = info.getValue() as string | null; + const teamId = info.getValue() as string | null; + if (!teamId) return "-"; + const team = teams?.find((t) => t.team_id === teamId); + const displayValue = team?.team_alias || teamId; const width = info.cell.column.getSize(); return ( - - - {value ?? "-"} - - + + {displayValue} + ); }, }, { - id: "organization_id", + id: "organization_alias", accessorKey: "org_id", - header: "Organization ID", + header: "Organization", size: 140, enableSorting: false, - cell: (info) => (info.getValue() ? info.renderValue() : "-"), - }, - { - id: "user_email", - accessorKey: "user", - header: "User Email", - size: 160, - enableSorting: false, cell: (info) => { - const user = info.getValue() as any; - const value = user?.user_email; + const orgId = info.getValue() as string | null; + if (!orgId) return "-"; + const org = resolvedOrganizations.find((o) => o.organization_id === orgId); + const displayValue = org?.organization_alias || orgId; const width = info.cell.column.getSize(); return ( - - - {value ?? "-"} - - + + {displayValue} + ); }, }, { - id: "user_id", - accessorKey: "user_id", - header: "User ID", - size: 70, + id: "user", + accessorKey: "user", + header: () => ( + + User + + + + + ), + size: 160, enableSorting: false, - cell: (info) => { - const userId = info.getValue() as string | null; - const displayValue = userId === "default_user_id" ? "Default Proxy Admin" : userId; - const width = info.cell.column.getSize(); + cell: ({ row }) => { + const key = row.original; + const userAlias = key.user?.user_alias ?? null; + const userEmail = key.user?.user_email ?? key.user_email ?? null; + const userId = key.user_id ?? null; + const isDefaultAdmin = userId === "default_user_id"; + const displayValue = userAlias || userEmail || userId; + const width = 160; + + const popoverContent = ( +
+ {[ + { label: "User Alias", value: userAlias }, + { label: "User Email", value: userEmail }, + { label: "User ID", value: userId }, + ].map(({ label, value }) => ( +
+ {label} + {value ? ( + + {value} + + ) : ( + - + )} +
+ ))} +
+ ); + + if (isDefaultAdmin && !userAlias && !userEmail) { + return ( + + + + + + ); + } + return ( - - - {displayValue ?? "-"} + + + {displayValue || "-"} - + ); }, }, @@ -279,18 +316,48 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo id: "created_by", accessorKey: "created_by", header: "Created By", - size: 70, + size: 160, enableSorting: false, cell: (info) => { - const value = info.getValue() as string | null; - const displayValue = value === "default_user_id" ? "Default Proxy Admin" : value; - const width = info.cell.column.getSize(); + const userId = info.getValue() as string | null; + if (!userId) return "-"; + const isDefaultAdmin = userId === "default_user_id"; + const width = 160; + + const popoverContent = ( +
+
+ User ID + + {userId} + +
+
+ ); + + if (isDefaultAdmin) { + return ( + + + + + + ); + } + return ( - - - {displayValue ?? "-"} + + + {userId} - + ); }, }, @@ -477,7 +544,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo ); }, }, - ], []); + ], [teams, resolvedOrganizations]); const filterOptions: FilterOption[] = [ { @@ -535,8 +602,6 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo }, ]; - console.log(`keys: ${JSON.stringify(keys)}`); - const table = useReactTable({ data: filteredKeys, columns: columns.filter((col) => col.id !== "expander"), @@ -548,13 +613,11 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo }, onSortingChange: (updaterOrValue) => { const newSorting = typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue; - console.log(`newSorting: ${JSON.stringify(newSorting)}`); setSorting(newSorting); if (newSorting && newSorting.length > 0) { const sortState = newSorting[0]; const sortBy = sortState.id; const sortOrder = sortState.desc ? "desc" : "asc"; - console.log(`sortBy: ${sortBy}, sortOrder: ${sortOrder}`); // Update filters state without triggering debouncedSearch // The useKeys hook will automatically refetch with the new sort parameters handleFilterChange( diff --git a/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx b/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx index 10bf6b61927..2ff07c7065b 100644 --- a/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx +++ b/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx @@ -8,6 +8,7 @@ import remarkGfm from "remark-gfm"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { coy } from "react-syntax-highlighter/dist/esm/styles/prism"; import ReasoningContent from "../playground/chat_ui/ReasoningContent"; +import MCPEventsDisplay from "../playground/chat_ui/MCPEventsDisplay"; import { ChatMessage } from "./types"; const { Panel } = Collapse; @@ -237,6 +238,8 @@ interface AssistantBubbleProps { isLastMessage: boolean; isStreaming: boolean; isTypingIndicator: boolean; + /** MCP events stored on the message — rendered inline below the response. */ + mcpEvents?: ChatMessage["mcpEvents"]; } function AssistantBubble({ @@ -244,6 +247,7 @@ function AssistantBubble({ isLastMessage, isStreaming, isTypingIndicator, + mcpEvents, }: AssistantBubbleProps) { // Ref to control ReasoningContent collapse on streaming end. // ReasoningContent manages its own expanded state; we use a key to @@ -321,6 +325,11 @@ function AssistantBubble({ + {mcpEvents && mcpEvents.length > 0 && ( +
+ +
+ )} ); } @@ -566,6 +575,7 @@ const ChatMessages: React.FC = ({ messages, isStreaming, onEditMessage }) isLastMessage={isLastMessage} isStreaming={isStreaming} isTypingIndicator={isLastMessage && isTypingIndicator} + mcpEvents={msg.mcpEvents} /> ); })} diff --git a/ui/litellm-dashboard/src/components/chat/ChatPage.tsx b/ui/litellm-dashboard/src/components/chat/ChatPage.tsx index 1dcc47342b6..8a33a2487f4 100644 --- a/ui/litellm-dashboard/src/components/chat/ChatPage.tsx +++ b/ui/litellm-dashboard/src/components/chat/ChatPage.tsx @@ -28,6 +28,8 @@ import MCPAppsPanel from "./MCPAppsPanel"; import MCPCredentialsTab from "./MCPCredentialsTab"; import { fetchAvailableModels } from "../playground/llm_calls/fetch_models"; import { makeOpenAIChatCompletionRequest } from "../playground/llm_calls/chat_completion"; +import { makeOpenAIResponsesRequest } from "../playground/llm_calls/responses_api"; +import type { MCPEvent } from "./types"; import { getProxyBaseUrl } from "@/components/networking"; import { useUIConfig } from "@/app/(dashboard)/hooks/uiConfig/useUIConfig"; import { getProviderLogoAndName } from "@/components/provider_info_helpers"; @@ -137,6 +139,7 @@ const ChatPage: React.FC = ({ accessToken, userRole, userId, user const [modelSearchText, setModelSearchText] = useState(""); const [selectedMCPServers, setSelectedMCPServers] = useState([]); + const [responsesSessionId, setResponsesSessionId] = useState(null); const [isStreaming, setIsStreaming] = useState(false); const [inputText, setInputText] = useState(""); const [mcpPopoverOpen, setMcpPopoverOpen] = useState(false); @@ -164,7 +167,7 @@ const ChatPage: React.FC = ({ accessToken, userRole, userId, user createConversation, appendMessage, updateLastAssistantMessage, - truncateAfterMessage, + truncateFromMessage, deleteConversation, renameConversation, } = useChatHistory(activeConversationId); @@ -205,6 +208,12 @@ const ChatPage: React.FC = ({ accessToken, userRole, userId, user if (staleId) router.replace(getChatUrl(uiRoot)); }, [staleId, router]); + // Reset the responses session when switching between conversations so that + // previous_response_id from conversation A is never sent for conversation B. + useEffect(() => { + setResponsesSessionId(null); + }, [activeConversationId]); + const toggleModel = useCallback((model: string) => { setSelectedModels((prev) => { let next: string[]; @@ -233,6 +242,7 @@ const ChatPage: React.FC = ({ accessToken, userRole, userId, user let convId = activeConversationId; if (!convId) { convId = createConversation(model); + setResponsesSessionId(null); // new conversation starts a fresh session router.push(getChatUrl(uiRoot, convId)); } @@ -242,29 +252,56 @@ const ChatPage: React.FC = ({ accessToken, userRole, userId, user setIsStreaming(true); abortControllerRef.current = new AbortController(); - const history = [ - ...(historyOverride ?? (activeConversation?.messages ?? []) - .filter((m) => m.role === "user" || m.role === "assistant") - .map((m) => ({ - role: m.role as "user" | "assistant", - content: m.content, - }))), - { role: "user" as const, content: trimmed }, - ]; + // When historyOverride is set (edit / retry), the existing server-side + // session chain covers messages that were just truncated and is no longer + // valid for the rewritten history. Eagerly clear the session so that a + // failed/aborted edit does not leave a stale session ID that contaminates + // the next regular send. + if (historyOverride) { + setResponsesSessionId(null); + } + + // On a normal continuation turn with an active session, the Responses API + // already holds the prior context server-side, so we only pass the new + // user message (sending the full history would double-count it). + // + // On the very first turn (no session yet), we send the full history. + const previousResponseId = historyOverride ? null : responsesSessionId; + + const history: Array<{ role: "user" | "assistant"; content: string }> = + historyOverride + ? [...historyOverride, { role: "user" as const, content: trimmed }] + : previousResponseId + ? [{ role: "user" as const, content: trimmed }] + : [ + // Explicitly filter to only user/assistant roles — tool messages + // lack a required tool_call_id and would cause API errors. + ...(activeConversation?.messages ?? []) + .filter((m): m is typeof m & { role: "user" | "assistant" } => + m.role === "user" || m.role === "assistant" + ) + .map((m) => ({ role: m.role, content: m.content })), + { role: "user" as const, content: trimmed }, + ]; let accumulatedContent = ""; let accumulatedReasoning = ""; + // MCP events accumulated locally so we can persist them to the message + // without relying on component state (which would cause stale closures). + const accumulatedMCPEvents: MCPEvent[] = []; + // Track clean completion so partial events are not shown on error/abort. + let streamCompletedCleanly = false; try { - await makeOpenAIChatCompletionRequest( + await makeOpenAIResponsesRequest( history, - (chunk: string) => { + (_role: string, chunk: string) => { accumulatedContent += chunk; updateLastAssistantMessage(convId!, { content: accumulatedContent }); }, model, accessToken, - undefined, + undefined, // tags abortControllerRef.current.signal, (rc: string) => { accumulatedReasoning += rc; @@ -272,7 +309,15 @@ const ChatPage: React.FC = ({ accessToken, userRole, userId, user }, undefined, undefined, undefined, undefined, undefined, undefined, selectedMCPServers.length > 0 ? selectedMCPServers : undefined, + previousResponseId, + (id: string) => setResponsesSessionId(id), + (event: MCPEvent) => { + // Accumulate locally only — persisted once in finally to avoid + // one full localStorage write per MCP event during streaming. + accumulatedMCPEvents.push(event); + }, ); + streamCompletedCleanly = true; } catch (err: unknown) { if (err instanceof Error && err.name === "AbortError") { updateLastAssistantMessage(convId!, { @@ -284,12 +329,17 @@ const ChatPage: React.FC = ({ accessToken, userRole, userId, user }); } } finally { + // Only persist MCP events on clean completion — partial events from an + // aborted or errored turn would show incomplete tool calls to the user. + if (accumulatedMCPEvents.length > 0 && streamCompletedCleanly) { + updateLastAssistantMessage(convId!, { mcpEvents: accumulatedMCPEvents }); + } setIsStreaming(false); abortControllerRef.current = null; } }, [activeConversationId, activeConversation, selectedModels, selectedMCPServers, accessToken, - createConversation, appendMessage, updateLastAssistantMessage, router, isStreaming], + createConversation, appendMessage, updateLastAssistantMessage, router, isStreaming, responsesSessionId], ); const handleSendComparison = useCallback( @@ -357,10 +407,10 @@ const ChatPage: React.FC = ({ accessToken, userRole, userId, user const priorMessages = (idx === -1 ? msgs : msgs.slice(0, idx)) .filter((m) => m.role === "user" || m.role === "assistant") .map((m) => ({ role: m.role as "user" | "assistant", content: m.content })); - truncateAfterMessage(activeConversationId, messageId); + truncateFromMessage(activeConversationId, messageId); handleSend(newContent, priorMessages); }, - [activeConversationId, isStreaming, activeConversation, truncateAfterMessage, handleSend], + [activeConversationId, isStreaming, activeConversation, truncateFromMessage, handleSend], ); const handleSubmit = useCallback( diff --git a/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx b/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx index 0c93b93ca54..9a3bc84d5f4 100644 --- a/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx +++ b/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx @@ -243,9 +243,25 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange {/* Avatar + name + connect */}
+ {detailServer.mcp_info?.logo_url ? ( + {`${name} { + const el = e.target as HTMLImageElement; + el.style.display = "none"; + if (el.nextElementSibling) (el.nextElementSibling as HTMLElement).style.display = "flex"; + }} + /> + ) : null}
@@ -446,9 +462,26 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange onMouseEnter={(e) => { (e.currentTarget as HTMLDivElement).style.background = "#fafafa"; }} onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.background = "#fff"; }} > + {server.mcp_info?.logo_url ? ( + {`${name} { + const el = e.target as HTMLImageElement; + el.style.display = "none"; + if (el.nextElementSibling) (el.nextElementSibling as HTMLElement).style.display = "flex"; + }} + /> + ) : null}
{name.charAt(0).toUpperCase()} diff --git a/ui/litellm-dashboard/src/components/chat/MCPConnectPicker.tsx b/ui/litellm-dashboard/src/components/chat/MCPConnectPicker.tsx index 234aa8a5281..a52ce181568 100644 --- a/ui/litellm-dashboard/src/components/chat/MCPConnectPicker.tsx +++ b/ui/litellm-dashboard/src/components/chat/MCPConnectPicker.tsx @@ -112,6 +112,18 @@ const MCPConnectPicker: React.FC = ({ accessToken, selectedServers, onCha gap: 12, }} > + {server.mcp_info?.logo_url && ( + {`${name} { (e.target as HTMLImageElement).style.display = "none"; }} + /> + )}
; toolResult?: string; diff --git a/ui/litellm-dashboard/src/components/chat/useChatHistory.ts b/ui/litellm-dashboard/src/components/chat/useChatHistory.ts index 1f27a15b39f..b0a42d82dea 100644 --- a/ui/litellm-dashboard/src/components/chat/useChatHistory.ts +++ b/ui/litellm-dashboard/src/components/chat/useChatHistory.ts @@ -51,8 +51,9 @@ export function useChatHistory(activeConversationId: string | null): { staleId: boolean; createConversation: (model: string) => string; appendMessage: (conversationId: string, message: Omit) => void; - updateLastAssistantMessage: (conversationId: string, updates: Partial>) => void; - truncateAfterMessage: (conversationId: string, messageId: string) => void; + updateLastAssistantMessage: (conversationId: string, updates: Partial>) => void; + /** Remove the message with `messageId` and all subsequent messages from the conversation. */ + truncateFromMessage: (conversationId: string, messageId: string) => void; deleteConversation: (id: string) => void; renameConversation: (id: string, newTitle: string) => void; setActiveConversationId: (id: string | null) => void; @@ -148,7 +149,7 @@ export function useChatHistory(activeConversationId: string | null): { const updateLastAssistantMessage = useCallback( ( conversationId: string, - updates: Partial>, + updates: Partial>, ) => { setConversations((prev) => { const updated = prev.map((conv) => { @@ -168,7 +169,7 @@ export function useChatHistory(activeConversationId: string | null): { [], ); - const truncateAfterMessage = useCallback( + const truncateFromMessage = useCallback( (conversationId: string, messageId: string) => { setConversations((prev) => { const updated = prev.map((conv) => { @@ -222,7 +223,7 @@ export function useChatHistory(activeConversationId: string | null): { createConversation, appendMessage, updateLastAssistantMessage, - truncateAfterMessage, + truncateFromMessage, deleteConversation, renameConversation, setActiveConversationId, 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 4cc3367f71d..a31162cb2f2 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 @@ -99,6 +99,7 @@ export interface KeyResponse { user?: { user_id: string; user_email: string; + user_alias: string | null; }; } diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPLogoSelector.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPLogoSelector.tsx new file mode 100644 index 00000000000..be05d74ec24 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPLogoSelector.tsx @@ -0,0 +1,123 @@ +import React, { useState } from "react"; +import { Input, Tooltip } from "antd"; +import { InfoCircleOutlined, LinkOutlined } from "@ant-design/icons"; + +const logos = "/ui/assets/logos/"; + +const WELL_KNOWN_LOGOS: { name: string; url: string }[] = [ + { name: "GitHub", url: `${logos}github.svg` }, + { name: "Slack", url: `${logos}slack.svg` }, + { name: "Notion", url: `${logos}notion.svg` }, + { name: "Linear", url: `${logos}linear.svg` }, + { name: "Jira", url: `${logos}jira.svg` }, + { name: "Figma", url: `${logos}figma.svg` }, + { name: "Gmail", url: `${logos}gmail.svg` }, + { name: "Google Drive", url: `${logos}google_drive.svg` }, + { name: "Stripe", url: `${logos}stripe.svg` }, + { name: "Shopify", url: `${logos}shopify.svg` }, + { name: "Salesforce", url: `${logos}salesforce.svg` }, + { name: "HubSpot", url: `${logos}hubspot.svg` }, + { name: "Twilio", url: `${logos}twilio.svg` }, + { name: "Cloudflare", url: `${logos}cloudflare.svg` }, + { name: "Sentry", url: `${logos}sentry.svg` }, + { name: "PostgreSQL", url: `${logos}postgresql.svg` }, + { name: "Snowflake", url: `${logos}snowflake.svg` }, + { name: "Zapier", url: `${logos}zapier.svg` }, + { name: "Google", url: `${logos}google.svg` }, + { name: "GitLab", url: `${logos}gitlab.svg` }, +]; + +interface MCPLogoSelectorProps { + value?: string; + onChange?: (url: string | undefined) => void; +} + +const MCPLogoSelector: React.FC = ({ value, onChange }) => { + const [imgErrors, setImgErrors] = useState>(new Set()); + + const handleSelect = (url: string) => { + onChange?.(value === url ? undefined : url); + }; + + const handleImgError = (url: string) => { + setImgErrors((prev) => new Set(prev).add(url)); + }; + + return ( +
+
+ Logo + + + +
+ + {/* Preview */} + {value && ( +
+ Selected logo { (e.target as HTMLImageElement).style.display = "none"; }} + /> +
+
{value}
+
+ +
+ )} + + {/* Well-known logo grid */} +
+ {WELL_KNOWN_LOGOS.map((logo) => { + const isSelected = value === logo.url; + const hasFailed = imgErrors.has(logo.url); + if (hasFailed) return null; + return ( + + + + ); + })} +
+ + {/* Custom URL input */} + } + placeholder="Or paste a custom logo URL..." + value={value && !WELL_KNOWN_LOGOS.some((l) => l.url === value) ? value : ""} + onChange={(e) => { + const v = e.target.value.trim(); + onChange?.(v || undefined); + }} + className="rounded-lg" + size="small" + /> +
+ ); +}; + +export default MCPLogoSelector; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx b/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx index 23aae6cb14f..b25e1dcadd4 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx @@ -12,6 +12,8 @@ interface OpenAPIFormSectionProps { onValuesChange: (updates: Record) => void; /** Called when key tools change (from registry preset selection). */ onKeyToolsChange?: (tools: OpenAPIKeyTool[]) => void; + /** Called when a preset is selected so the parent can set the logo URL from icon_url. */ + onLogoUrlChange?: (url: string | undefined) => void; /** Called when the OAuth docs URL changes (e.g. link to create a GitHub OAuth App). */ onOAuthDocsUrlChange?: (url: string | null) => void; } @@ -26,6 +28,7 @@ const OpenAPIFormSection: React.FC = ({ accessToken, onValuesChange, onKeyToolsChange, + onLogoUrlChange, onOAuthDocsUrlChange, }) => { const [selectedPreset, setSelectedPreset] = useState(null); @@ -33,6 +36,7 @@ const OpenAPIFormSection: React.FC = ({ const handlePresetSelect = (entry: OpenAPIRegistryEntry) => { setSelectedPreset(entry.name); onKeyToolsChange?.(entry.key_tools ?? []); + onLogoUrlChange?.(entry.icon_url || undefined); const updates: Record = { spec_path: entry.spec_url, }; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index 90ecd4731cf..74945731a24 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -11,6 +11,7 @@ import MCPToolConfiguration from "./mcp_tool_configuration"; import StdioConfiguration from "./StdioConfiguration"; import MCPPermissionManagement from "./MCPPermissionManagement"; import OpenAPIFormSection, { OpenAPIKeyTool } from "./OpenAPIFormSection"; +import MCPLogoSelector from "./MCPLogoSelector"; import { isAdminRole } from "@/utils/roles"; import { validateMCPServerUrl, validateMCPServerName } from "./utils"; import NotificationsManager from "../molecules/notifications_manager"; @@ -70,6 +71,7 @@ const CreateMCPServer: React.FC = ({ const [keyTools, setKeyTools] = useState([]); const [searchValue, setSearchValue] = useState(""); const [oauthAccessToken, setOauthAccessToken] = useState(null); + const [logoUrl, setLogoUrl] = useState(undefined); const [oauthDocsUrl, setOauthDocsUrl] = useState(null); // Single hook call shared by MCPConnectionStatus and MCPToolConfiguration to avoid duplicate requests. @@ -101,6 +103,7 @@ const CreateMCPServer: React.FC = ({ allowedTools, searchValue, aliasManuallyEdited, + logoUrl, }), ); } catch (err) { @@ -202,6 +205,9 @@ const CreateMCPServer: React.FC = ({ if (typeof parsed.aliasManuallyEdited === "boolean") { setAliasManuallyEdited(parsed.aliasManuallyEdited); } + if (parsed.logoUrl) { + setLogoUrl(parsed.logoUrl); + } } catch (err) { console.error("Failed to restore MCP create state", err); } finally { @@ -357,6 +363,7 @@ const CreateMCPServer: React.FC = ({ mcp_info: { server_name: restValues.server_name || restValues.url, description: restValues.description, + logo_url: logoUrl || undefined, mcp_server_cost_info: Object.keys(costConfig).length > 0 ? costConfig : null, }, mcp_access_groups: accessGroups, @@ -394,6 +401,7 @@ const CreateMCPServer: React.FC = ({ clearTools(); setAllowedTools([]); setAliasManuallyEdited(false); + setLogoUrl(undefined); setModalVisible(false); onCreateSuccess(response); } @@ -414,6 +422,7 @@ const CreateMCPServer: React.FC = ({ clearTools(); setAllowedTools([]); setAliasManuallyEdited(false); + setLogoUrl(undefined); setModalVisible(false); }; @@ -590,6 +599,8 @@ const CreateMCPServer: React.FC = ({ /> + + GitHub / Source URL} name="source_url" @@ -645,6 +656,7 @@ const CreateMCPServer: React.FC = ({ setFormValues((prev) => ({ ...prev, ...updates })) } onKeyToolsChange={setKeyTools} + onLogoUrlChange={setLogoUrl} onOAuthDocsUrlChange={setOauthDocsUrl} /> )} 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 8130f96856f..551045765cc 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 @@ -31,6 +31,23 @@ export const mcpServerColumns = ( accessorKey: "server_name", header: "Name", enableSorting: true, + cell: ({ row }) => { + const logoUrl = row.original.mcp_info?.logo_url; + const name = row.original.server_name; + return ( +
+ {logoUrl ? ( + {`${name { (e.target as HTMLImageElement).style.display = "none"; }} + /> + ) : null} + {name} +
+ ); + }, }, { accessorKey: "alias", diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index fc55542a0c9..eadf93d8a96 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -8,6 +8,7 @@ import MCPServerCostConfig from "./mcp_server_cost_config"; import MCPPermissionManagement from "./MCPPermissionManagement"; import MCPToolConfiguration from "./mcp_tool_configuration"; import StdioConfiguration from "./StdioConfiguration"; +import MCPLogoSelector from "./MCPLogoSelector"; import { validateMCPServerUrl, validateMCPServerName } from "./utils"; import NotificationsManager from "../molecules/notifications_manager"; import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow"; @@ -41,6 +42,7 @@ const MCPServerEdit: React.FC = ({ const [toolNameToDisplayName, setToolNameToDisplayName] = useState>({}); const [toolNameToDescription, setToolNameToDescription] = useState>({}); const [pendingRestoredValues, setPendingRestoredValues] = useState | null>(null); + const [logoUrl, setLogoUrl] = useState(mcpServer.mcp_info?.logo_url || undefined); const authType = Form.useWatch("auth_type", form) as string | undefined; const transportType = Form.useWatch("transport", form) as string | undefined; const isStdioTransport = transportType === "stdio"; @@ -538,6 +540,7 @@ const MCPServerEdit: React.FC = ({ mcp_info: { server_name: mcpInfoServerName, description: restValues.description, + logo_url: logoUrl || undefined, mcp_server_cost_info: Object.keys(costConfig).length > 0 ? costConfig : null, }, mcp_access_groups: accessGroups, @@ -604,6 +607,7 @@ const MCPServerEdit: React.FC = ({ +