fix merge conflicts: keep both onLogoUrlChange and onOAuthDocsUrlChange props

This commit is contained in:
Ishaan Jaffer 2026-03-10 20:25:16 -07:00
commit 6609dba650
29 changed files with 976 additions and 551 deletions

View file

@ -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
<Tabs>
<TabItem value="ui" label="UI (LiteLLM Dashboard)">
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
</TabItem>
<TabItem value="config" label="config.yaml">
```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: "*"
```
</TabItem>
</Tabs>
## 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

View file

@ -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
<Tabs>
@ -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`

View file

@ -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.

View file

@ -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)**

View file

@ -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",
],

View file

@ -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",

View file

@ -618,12 +618,17 @@ class MCPServerManager:
mcp_info["description"] = mcp_server.description
auth_type = cast(MCPAuthType, mcp_server.auth_type)
if mcp_server.url and auth_type == MCPAuth.oauth2:
mcp_oauth_metadata = await self._descovery_metadata(
server_url=mcp_server.url,
)
else:
mcp_oauth_metadata = None
server_url = mcp_server.url
needs_discovery = (
bool(server_url)
and auth_type == MCPAuth.oauth2
and not mcp_server.authorization_url
)
mcp_oauth_metadata = (
await self._descovery_metadata(server_url=server_url) # type: ignore[arg-type]
if needs_discovery
else None
)
resolved_scopes = scopes or (
mcp_oauth_metadata.scopes if mcp_oauth_metadata else None

View file

@ -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",

View file

@ -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"
)

View file

@ -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<SidebarProps> = ({ 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 <a> 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 (
<a
href={href}
target={newTab ? "_blank" : undefined}
rel={newTab ? "noopener noreferrer" : undefined}
onClick={(e) => {
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<SidebarProps> = ({ accessToken, userRole, defaultSelect
style={{
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
position: "relative",
display: "flex",
flexDirection: "column",
}}
>
<ConfigProvider
@ -431,22 +447,60 @@ const Sidebar2: React.FC<SidebarProps> = ({ 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,
}))}
/>
</ConfigProvider>
{isAdminRole(userRole) && !collapsed && <UsageIndicator accessToken={accessToken} width={220} />}
{/* Pinned "Open Chat" button at bottom */}
<div style={{
padding: collapsed ? "10px 8px" : "10px 12px",
borderTop: "1px solid #f0f0f0",
flexShrink: 0,
}}>
<a
href={toHref("chat")}
target="_blank"
rel="noopener noreferrer"
style={{
display: "flex",
alignItems: "center",
justifyContent: collapsed ? "center" : "flex-start",
gap: 8,
padding: collapsed ? "8px 0" : "8px 10px",
borderRadius: 8,
background: "#1677ff",
color: "#fff",
textDecoration: "none",
fontSize: 13,
fontWeight: 600,
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLAnchorElement).style.background = "#0958d9";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLAnchorElement).style.background = "#1677ff";
}}
>
<MessageOutlined style={{ fontSize: 16, flexShrink: 0 }} />
{!collapsed && <span>Open Chat</span>}
</a>
</div>
</Sider>
</Layout>
);

View file

@ -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);
});
});

View file

@ -10,11 +10,11 @@ interface MCPServerHealth {
status: string;
}
export const useMCPServerHealth = (serverIds?: string[]) => {
export const useMCPServerHealth = () => {
const { accessToken } = useAuthorized();
return useQuery<MCPServerHealth[]>({
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,

View file

@ -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<typeof import("@/app/(dashboard)/networking")>();
@ -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({

View file

@ -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<KeyResponse | null>(null);
const [sorting, setSorting] = React.useState<SortingState>(() => {
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<Record<string, boolean>>({});
@ -172,11 +177,9 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
const value = info.getValue() as string;
const width = info.cell.column.getSize();
return (
<Tooltip title={value}>
<span className={`font-mono text-xs truncate block`} style={{ maxWidth: width, overflow: "hidden" }}>
{value ?? "-"}
</span>
</Tooltip>
<span className="font-mono text-xs truncate block" style={{ maxWidth: width, overflow: "hidden" }}>
{value ?? "-"}
</span>
);
},
},
@ -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 (
<Tooltip title={value}>
<span className={`font-mono text-xs truncate block`} style={{ maxWidth: width, overflow: "hidden" }}>
{value ?? "-"}
</span>
</Tooltip>
<span className="font-mono text-xs truncate block" style={{ maxWidth: width, overflow: "hidden" }}>
{displayValue}
</span>
);
},
},
{
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 (
<Tooltip title={value}>
<span className={`font-mono text-xs truncate block`} style={{ maxWidth: width, overflow: "hidden" }}>
{value ?? "-"}
</span>
</Tooltip>
<span className="font-mono text-xs truncate block" style={{ maxWidth: width, overflow: "hidden" }}>
{displayValue}
</span>
);
},
},
{
id: "user_id",
accessorKey: "user_id",
header: "User ID",
size: 70,
id: "user",
accessorKey: "user",
header: () => (
<span className="flex items-center gap-1">
User
<Popover
content="Displays the first available value: User Alias, User Email, or User ID."
trigger="hover"
>
<InfoCircleOutlined className="text-gray-400 text-xs cursor-help" />
</Popover>
</span>
),
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 = (
<div className="flex flex-col gap-2 text-xs min-w-[200px] max-w-[300px]">
{[
{ label: "User Alias", value: userAlias },
{ label: "User Email", value: userEmail },
{ label: "User ID", value: userId },
].map(({ label, value }) => (
<div key={label} className="flex flex-col min-w-0">
<span className="text-gray-400">{label}</span>
{value ? (
<Typography.Text
className="font-mono text-xs"
ellipsis={{ tooltip: value }}
copyable
>
{value}
</Typography.Text>
) : (
<span className="font-mono">-</span>
)}
</div>
))}
</div>
);
if (isDefaultAdmin && !userAlias && !userEmail) {
return (
<Popover content={popoverContent} trigger="hover" placement="bottomLeft">
<span className="cursor-default">
<DefaultProxyAdminTag userId={userId} />
</span>
</Popover>
);
}
return (
<Tooltip title={displayValue}>
<span className={`font-mono text-xs truncate block`} style={{ maxWidth: width, overflow: "hidden" }}>
{displayValue ?? "-"}
<Popover content={popoverContent} trigger="hover" placement="bottomLeft">
<span
className="font-mono text-xs truncate block cursor-default"
style={{ maxWidth: width, overflow: "hidden" }}
>
{displayValue || "-"}
</span>
</Tooltip>
</Popover>
);
},
},
@ -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 = (
<div className="flex flex-col gap-2 text-xs min-w-[200px] max-w-[300px]">
<div className="flex flex-col min-w-0">
<span className="text-gray-400">User ID</span>
<Typography.Text
className="font-mono text-xs"
ellipsis={{ tooltip: userId }}
copyable
>
{userId}
</Typography.Text>
</div>
</div>
);
if (isDefaultAdmin) {
return (
<Popover content={popoverContent} trigger="hover" placement="bottomLeft">
<span className="cursor-default">
<DefaultProxyAdminTag userId={userId} />
</span>
</Popover>
);
}
return (
<Tooltip title={displayValue}>
<span className={`font-mono text-xs truncate block`} style={{ maxWidth: width, overflow: "hidden" }}>
{displayValue ?? "-"}
<Popover content={popoverContent} trigger="hover" placement="bottomLeft">
<span
className="font-mono text-xs truncate block cursor-default"
style={{ maxWidth: width, overflow: "hidden" }}
>
{userId}
</span>
</Tooltip>
</Popover>
);
},
},
@ -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(

View file

@ -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({
</div>
<CopyButton text={mainContent} />
{mcpEvents && mcpEvents.length > 0 && (
<div style={{ marginTop: 8, maxWidth: "100%" }}>
<MCPEventsDisplay events={mcpEvents} />
</div>
)}
</div>
);
}
@ -566,6 +575,7 @@ const ChatMessages: React.FC<Props> = ({ messages, isStreaming, onEditMessage })
isLastMessage={isLastMessage}
isStreaming={isStreaming}
isTypingIndicator={isLastMessage && isTypingIndicator}
mcpEvents={msg.mcpEvents}
/>
);
})}

View file

@ -26,6 +26,8 @@ import MCPConnectPicker from "./MCPConnectPicker";
import MCPAppsPanel from "./MCPAppsPanel";
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";
@ -135,6 +137,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
const [modelSearchText, setModelSearchText] = useState("");
const [selectedMCPServers, setSelectedMCPServers] = useState<string[]>([]);
const [responsesSessionId, setResponsesSessionId] = useState<string | null>(null);
const [isStreaming, setIsStreaming] = useState(false);
const [inputText, setInputText] = useState("");
const [mcpPopoverOpen, setMcpPopoverOpen] = useState(false);
@ -162,7 +165,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
createConversation,
appendMessage,
updateLastAssistantMessage,
truncateAfterMessage,
truncateFromMessage,
deleteConversation,
renameConversation,
} = useChatHistory(activeConversationId);
@ -203,6 +206,12 @@ const ChatPage: React.FC<ChatPageProps> = ({ 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[];
@ -231,6 +240,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ 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));
}
@ -240,29 +250,56 @@ const ChatPage: React.FC<ChatPageProps> = ({ 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;
@ -270,7 +307,15 @@ const ChatPage: React.FC<ChatPageProps> = ({ 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!, {
@ -282,12 +327,17 @@ const ChatPage: React.FC<ChatPageProps> = ({ 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(
@ -355,10 +405,10 @@ const ChatPage: React.FC<ChatPageProps> = ({ 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(

View file

@ -1,8 +1,12 @@
export type { MCPEvent } from "../mcp_tools/types";
export interface ChatMessage {
id: string;
role: "user" | "assistant" | "tool";
content: string;
reasoningContent?: string;
/** MCP tool events that occurred during this assistant turn, in order. */
mcpEvents?: MCPEvent[];
toolName?: string;
toolArgs?: Record<string, unknown>;
toolResult?: string;

View file

@ -51,8 +51,9 @@ export function useChatHistory(activeConversationId: string | null): {
staleId: boolean;
createConversation: (model: string) => string;
appendMessage: (conversationId: string, message: Omit<ChatMessage, "id" | "timestamp">) => void;
updateLastAssistantMessage: (conversationId: string, updates: Partial<Pick<ChatMessage, "content" | "reasoningContent">>) => void;
truncateAfterMessage: (conversationId: string, messageId: string) => void;
updateLastAssistantMessage: (conversationId: string, updates: Partial<Pick<ChatMessage, "content" | "reasoningContent" | "mcpEvents">>) => 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<Pick<ChatMessage, "content" | "reasoningContent">>,
updates: Partial<Pick<ChatMessage, "content" | "reasoningContent" | "mcpEvents">>,
) => {
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,

View file

@ -99,6 +99,7 @@ export interface KeyResponse {
user?: {
user_id: string;
user_email: string;
user_alias: string | null;
};
}

View file

@ -16,6 +16,8 @@ interface OAuthFormFieldsProps {
isEditing?: boolean;
oauthFlow?: OAuthFlowStatus;
initialFlowType?: string;
/** Link to provider docs for creating an OAuth app (e.g. GitHub). */
docsUrl?: string | null;
}
const fieldClassName = "rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500";
@ -34,6 +36,7 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
isEditing = false,
oauthFlow,
initialFlowType,
docsUrl,
}) => {
const placeholderSuffix = isEditing ? " (leave blank to keep existing)" : "";
@ -98,7 +101,22 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
) : (
<>
<Form.Item
label={<FieldLabel label="Client ID (optional)" tooltip="Provide only if your MCP server cannot handle dynamic client registration." />}
label={
<span className="flex items-center justify-between w-full">
<FieldLabel label="Client ID (optional)" tooltip="Provide only if your MCP server cannot handle dynamic client registration." />
{docsUrl && (
<a
href={docsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-500 hover:text-blue-700 ml-2 font-normal"
onClick={(e) => e.stopPropagation()}
>
Create OAuth App →
</a>
)}
</span>
}
name={["credentials", "client_id"]}
>
<TextInput type="password" placeholder={`Enter client ID${placeholderSuffix}`} className={fieldClassName} />

View file

@ -2,7 +2,7 @@ import React, { useState } from "react";
import { Form, Input, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { FormInstance } from "antd/es/form";
import { AUTH_TYPE } from "./types";
import { AUTH_TYPE, OAUTH_FLOW } from "./types";
import OpenAPIQuickPicker, { OpenAPIRegistryEntry, OpenAPIKeyTool } from "./OpenAPIQuickPicker";
interface OpenAPIFormSectionProps {
@ -14,6 +14,8 @@ interface OpenAPIFormSectionProps {
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;
}
/**
@ -27,6 +29,7 @@ const OpenAPIFormSection: React.FC<OpenAPIFormSectionProps> = ({
onValuesChange,
onKeyToolsChange,
onLogoUrlChange,
onOAuthDocsUrlChange,
}) => {
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
@ -39,14 +42,19 @@ const OpenAPIFormSection: React.FC<OpenAPIFormSectionProps> = ({
};
if (entry.oauth) {
updates.auth_type = AUTH_TYPE.OAUTH2;
// OAuth2 registry entries always use the interactive (PKCE) flow — users
// authorize via their browser, not machine-to-machine client credentials.
updates.oauth_flow_type = OAUTH_FLOW.INTERACTIVE;
updates.authorization_url = entry.oauth.authorization_url;
updates.token_url = entry.oauth.token_url;
form.setFieldsValue(updates);
onOAuthDocsUrlChange?.(entry.oauth.docs_url ?? null);
} else {
// resetFields is required to visually clear Ant Design form fields —
// setFieldsValue with undefined silently skips undefined keys.
form.resetFields(["auth_type", "authorization_url", "token_url"]);
form.setFieldsValue(updates);
onOAuthDocsUrlChange?.(null);
}
onValuesChange(updates);
};
@ -79,6 +87,7 @@ const OpenAPIFormSection: React.FC<OpenAPIFormSectionProps> = ({
// so stale suggested tools from a previous preset don't persist.
setSelectedPreset(null);
onKeyToolsChange?.([]);
onOAuthDocsUrlChange?.(null);
}}
/>
</Form.Item>

View file

@ -1,5 +1,5 @@
import React, { useState } from "react";
import { Modal, Tooltip, Form, Select, Input, Switch } from "antd";
import { Modal, Tooltip, Form, Select, Input, Switch, Collapse } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button, TextInput } from "@tremor/react";
import { createMCPServer, registerMCPServer } from "../networking";
@ -36,6 +36,15 @@ const AUTH_TYPES_REQUIRING_AUTH_VALUE = [AUTH_TYPE.API_KEY, AUTH_TYPE.BEARER_TOK
const AUTH_TYPES_REQUIRING_CREDENTIALS = [...AUTH_TYPES_REQUIRING_AUTH_VALUE, AUTH_TYPE.OAUTH2];
const CREATE_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-create-state";
const reduceStaticHeaders = (list: unknown): Record<string, string> => {
if (!Array.isArray(list)) return {};
return list.reduce((acc: Record<string, string>, entry: Record<string, string>) => {
const header = entry?.header?.trim();
if (header) acc[header] = entry?.value ?? "";
return acc;
}, {});
};
const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
userRole,
accessToken,
@ -63,6 +72,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
const [searchValue, setSearchValue] = useState<string>("");
const [oauthAccessToken, setOauthAccessToken] = useState<string | null>(null);
const [logoUrl, setLogoUrl] = useState<string | undefined>(undefined);
const [oauthDocsUrl, setOauthDocsUrl] = useState<string | null>(null);
// Single hook call shared by MCPConnectionStatus and MCPToolConfiguration to avoid duplicate requests.
const { tools, isLoadingTools, toolsError, toolsErrorStackTrace, canFetchTools, fetchTools, clearTools } = useTestMCPConnection({
@ -111,21 +121,16 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
getCredentials: () => form.getFieldValue("credentials"),
getTemporaryPayload: () => {
const values = form.getFieldsValue(true);
const url = values.url;
const transport = values.transport || transportType;
// For OpenAPI transport the form has spec_path instead of url.
// We pass the spec_path as url so the temp-session endpoint has something
// to store; the backend uses authorization_url / token_url for the actual
// OAuth redirect, so the spec_path value is never used for OAuth itself.
const url = values.url || (transport === TRANSPORT.OPENAPI ? values.spec_path : undefined);
if (!url || !transport) {
return null;
}
const staticHeaders = Array.isArray(values.static_headers)
? values.static_headers.reduce((acc: Record<string, string>, entry: Record<string, string>) => {
const header = entry?.header?.trim();
if (!header) {
return acc;
}
acc[header] = entry?.value ?? "";
return acc;
}, {})
: ({} as Record<string, string>);
const staticHeaders = reduceStaticHeaders(values.static_headers);
return {
server_id: undefined,
@ -133,7 +138,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
alias: values.alias,
description: values.description,
url,
transport,
transport: transport === TRANSPORT.OPENAPI ? "http" : transport,
auth_type: AUTH_TYPE.OAUTH2,
credentials: values.credentials,
authorization_url: values.authorization_url,
@ -283,16 +288,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
// Transform access groups into objects with name property
const accessGroups = restValues.mcp_access_groups;
const staticHeaders = Array.isArray(staticHeadersList)
? staticHeadersList.reduce((acc: Record<string, string>, entry: Record<string, string>) => {
const header = entry?.header?.trim();
if (!header) {
return acc;
}
acc[header] = entry?.value ?? "";
return acc;
}, {})
: ({} as Record<string, string>);
const staticHeaders = reduceStaticHeaders(staticHeadersList);
const credentialsPayload =
credentialValues && typeof credentialValues === "object"
@ -661,6 +657,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
}
onKeyToolsChange={setKeyTools}
onLogoUrlChange={setLogoUrl}
onOAuthDocsUrlChange={setOauthDocsUrl}
/>
)}
@ -750,60 +747,74 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
{/* Authentication - show for HTTP, SSE, and OpenAPI */}
{transportType !== "stdio" && transportType !== "" && (
<Form.Item
label={<span className="text-sm font-medium text-gray-700">Authentication</span>}
name="auth_type"
rules={[{ required: true, message: "Please select an auth type" }]}
>
<Select placeholder="Select auth type" className="rounded-lg" size="large">
<Select.Option value="none">None</Select.Option>
<Select.Option value="api_key">API Key</Select.Option>
<Select.Option value="bearer_token">Bearer Token</Select.Option>
<Select.Option value="token">Token</Select.Option>
<Select.Option value="basic">Basic Auth</Select.Option>
<Select.Option value="oauth2">OAuth</Select.Option>
</Select>
</Form.Item>
)}
{transportType !== "stdio" && transportType !== "" && shouldShowAuthValueField && (
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
Authentication Value
<Tooltip title="Token, password, or header value to send with each request for the selected auth type.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "auth_value"]}
rules={[
<Collapse
defaultActiveKey={["auth"]}
className="mb-4"
items={[
{
validator: (_, value) =>
value && typeof value === "string" && value.trim() === ""
? Promise.reject(new Error("Authentication value cannot be empty whitespace"))
: Promise.resolve(),
key: "auth",
label: <span className="text-sm font-semibold text-gray-700">Authentication</span>,
children: (
<>
<Form.Item
name="auth_type"
rules={[{ required: true, message: "Please select an auth type" }]}
>
<Select placeholder="Select auth type" className="rounded-lg" size="large">
<Select.Option value="none">None</Select.Option>
<Select.Option value="api_key">API Key</Select.Option>
<Select.Option value="bearer_token">Bearer Token</Select.Option>
<Select.Option value="token">Token</Select.Option>
<Select.Option value="basic">Basic Auth</Select.Option>
<Select.Option value="oauth2">OAuth</Select.Option>
</Select>
</Form.Item>
{shouldShowAuthValueField && (
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
Authentication Value
<Tooltip title="Token, password, or header value to send with each request for the selected auth type.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "auth_value"]}
rules={[
{
validator: (_, value) =>
value && typeof value === "string" && value.trim() === ""
? Promise.reject(new Error("Authentication value cannot be empty whitespace"))
: Promise.resolve(),
},
]}
>
<TextInput
type="password"
placeholder="Enter token or secret"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
)}
{isOAuthAuthType && (
<OAuthFormFields
isM2M={isM2MFlow}
initialFlowType={OAUTH_FLOW.INTERACTIVE}
docsUrl={oauthDocsUrl}
oauthFlow={{
startOAuthFlow,
status: oauthStatus,
error: oauthError,
tokenResponse: oauthTokenResponse,
}}
/>
)}
</>
),
},
]}
>
<TextInput
type="password"
placeholder="Enter token or secret"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
)}
{transportType !== "stdio" && transportType !== "" && isOAuthAuthType && (
<OAuthFormFields
isM2M={isM2MFlow}
initialFlowType={OAUTH_FLOW.INTERACTIVE}
oauthFlow={{
startOAuthFlow,
status: oauthStatus,
error: oauthError,
tokenResponse: oauthTokenResponse,
}}
/>
)}

View file

@ -185,9 +185,10 @@ describe("MCPServers", () => {
expect(getByText("MCP Servers")).toBeInTheDocument();
});
// Verify the health check API was called with server IDs
// Verify the health check API was called (without a server ID filter — the hook always
// fetches health for all servers so the query key stays stable)
await waitFor(() => {
expect(networking.fetchMCPServerHealth).toHaveBeenCalledWith("123", ["server-1", "server-2"]);
expect(networking.fetchMCPServerHealth).toHaveBeenCalledWith("123");
});
});
@ -348,4 +349,86 @@ describe("MCPServers", () => {
// Team B server should not be visible
expect(screen.queryByText("Team B Server")).not.toBeInTheDocument();
});
it("should not trigger an extra health check when the server list changes after deletion", async () => {
// Regression test: previously useMCPServerHealth received serverIds derived from the
// server list. Deleting a server changed serverIds, which changed the React Query key,
// which caused a new health check request for every remaining server.
//
// Fix: useMCPServerHealth uses a stable, argument-free query key. The component
// re-rendering with a shorter server list must NOT produce a second health fetch.
const twoServers = [
{
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: [],
},
{
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: [],
},
];
const oneServer = twoServers.slice(0, 1);
// First call returns two servers; second (after deletion) returns one
vi.mocked(networking.fetchMCPServers)
.mockResolvedValueOnce(twoServers)
.mockResolvedValueOnce(oneServer);
vi.mocked(networking.fetchMCPServerHealth).mockResolvedValue([
{ server_id: "server-1", status: "healthy" },
{ server_id: "server-2", status: "healthy" },
]);
// Use a shared queryClient with a non-zero gcTime so cached health data survives
// the re-render triggered by the server list refresh
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 60_000 } },
});
const { rerender } = render(
<QueryClientProvider client={queryClient}>
<MCPServers {...defaultProps} />
</QueryClientProvider>,
);
// Wait for the initial health fetch to complete
await waitFor(() => {
expect(networking.fetchMCPServerHealth).toHaveBeenCalledTimes(1);
});
// Simulate what happens after a server is deleted: the server list query is
// refetched (returns oneServer), causing the component to re-render with the
// shorter list.
await act(async () => {
await queryClient.invalidateQueries({ queryKey: ["mcpServers"] });
});
rerender(
<QueryClientProvider client={queryClient}>
<MCPServers {...defaultProps} />
</QueryClientProvider>,
);
// The server list refresh must NOT trigger a second health check
expect(networking.fetchMCPServerHealth).toHaveBeenCalledTimes(1);
});
});

View file

@ -29,8 +29,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
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);
const { data: healthStatuses, isLoading: isLoadingHealth } = useMCPServerHealth();
// Merge health status data into servers
const serversWithHealth = useMemo(() => {

View file

@ -1,3 +1,30 @@
/** A single MCP tool event emitted by the LiteLLM proxy during a Responses API turn. */
export interface MCPEvent {
type: string;
sequence_number?: number;
output_index?: number;
item_id?: string;
item?: {
id?: string;
type?: string;
server_label?: string;
tools?: Array<{
name: string;
description: string;
annotations?: {
read_only?: boolean;
};
input_schema?: unknown;
}>;
name?: string;
arguments?: string;
output?: string;
};
delta?: string;
arguments?: string;
timestamp?: number;
}
export interface Team {
team_id: string;
team_alias?: string;

View file

@ -8946,11 +8946,16 @@ export const perUserAnalyticsCall = async (
};
export const deriveErrorMessage = (errorData: any): string => {
const detail = errorData?.detail;
const detailStr = Array.isArray(detail)
? detail.map((d: any) => d?.msg || JSON.stringify(d)).join("; ")
: typeof detail === "string"
? detail
: undefined;
return (
(errorData?.error && (errorData.error.message || errorData.error)) ||
(errorData?.error && (errorData.error.message || (typeof errorData.error === "string" ? errorData.error : undefined))) ||
errorData?.message ||
errorData?.detail ||
errorData?.error ||
detailStr ||
JSON.stringify(errorData)
);
};
@ -9556,3 +9561,95 @@ export const deleteToolPolicyOverride = async (
}
return response.json();
};
// ── MCP OAuth user-credential helpers ────────────────────────────────────────
export interface MCPOAuthUserCredentialStatus {
server_id: string;
has_credential: boolean;
expires_at?: string | null;
is_expired: boolean;
connected_at?: string | null;
}
export interface MCPUserCredentialListItem {
server_id: string;
server_name?: string | null;
alias?: string | null;
credential_type: string;
has_credential: boolean;
expires_at?: string | null;
connected_at?: string | null;
}
export const storeMCPOAuthUserCredential = async (
accessToken: string,
serverId: string,
tokenResponse: { access_token: string; refresh_token?: string; expires_in?: number; scopes?: string[] },
): Promise<MCPOAuthUserCredentialStatus> => {
const url = proxyBaseUrl
? `${proxyBaseUrl}/v1/mcp/server/${serverId}/oauth-user-credential`
: `/v1/mcp/server/${serverId}/oauth-user-credential`;
const response = await fetch(url, {
method: "POST",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(tokenResponse),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error((err as { detail?: { error?: string } })?.detail?.error || "Failed to store OAuth credential");
}
return response.json();
};
export const deleteMCPOAuthUserCredential = async (
accessToken: string,
serverId: string,
): Promise<MCPOAuthUserCredentialStatus> => {
const url = proxyBaseUrl
? `${proxyBaseUrl}/v1/mcp/server/${serverId}/oauth-user-credential`
: `/v1/mcp/server/${serverId}/oauth-user-credential`;
const response = await fetch(url, {
method: "DELETE",
headers: { [globalLitellmHeaderName]: `Bearer ${accessToken}` },
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error((err as { detail?: { error?: string } })?.detail?.error || "Failed to revoke OAuth credential");
}
return response.json();
};
export const getMCPOAuthUserCredentialStatus = async (
accessToken: string,
serverId: string,
): Promise<MCPOAuthUserCredentialStatus> => {
const url = proxyBaseUrl
? `${proxyBaseUrl}/v1/mcp/server/${serverId}/oauth-user-credential/status`
: `/v1/mcp/server/${serverId}/oauth-user-credential/status`;
const response = await fetch(url, {
method: "GET",
headers: { [globalLitellmHeaderName]: `Bearer ${accessToken}` },
});
if (!response.ok) {
return { server_id: serverId, has_credential: false, is_expired: false };
}
return response.json();
};
export const listMCPUserCredentials = async (
accessToken: string,
): Promise<MCPUserCredentialListItem[]> => {
const url = proxyBaseUrl
? `${proxyBaseUrl}/v1/mcp/user-credentials`
: `/v1/mcp/user-credentials`;
const response = await fetch(url, {
method: "GET",
headers: { [globalLitellmHeaderName]: `Bearer ${accessToken}` },
});
if (!response.ok) return [];
return response.json();
};

View file

@ -1,35 +1,10 @@
import React from "react";
import { Typography, Collapse } from "antd";
import type { MCPEvent } from "../../mcp_tools/types";
const { Text } = Typography;
const { Panel } = Collapse;
export interface MCPEvent {
type: string;
sequence_number?: number;
output_index?: number;
item_id?: string;
item?: {
id?: string;
type?: string;
server_label?: string;
tools?: Array<{
name: string;
description: string;
annotations?: {
read_only?: boolean;
};
input_schema?: any;
}>;
name?: string;
arguments?: string;
output?: string;
};
delta?: string;
arguments?: string;
timestamp?: number;
}
interface MCPEventsDisplayProps {
events: MCPEvent[];
className?: string;

View file

@ -3,7 +3,7 @@ import { MessageType } from "../chat_ui/types";
import { TokenUsage } from "../chat_ui/ResponseMetrics";
import { getProxyBaseUrl } from "@/components/networking";
import NotificationManager from "@/components/molecules/notifications_manager";
import { MCPEvent } from "../chat_ui/MCPEventsDisplay";
import type { MCPEvent } from "../../mcp_tools/types";
import { MCPServer } from "../../mcp_tools/types";
import {
CodeInterpreterResult,
@ -98,20 +98,22 @@ export async function makeOpenAIResponsesRequest(
tools.push({
type: "mcp",
server_label: "litellm",
server_url: "litellm_proxy/mcp",
server_url: `${proxyBaseUrl}/mcp`,
require_approval: "never",
});
} else {
// Individual servers selected - create one entry per server
selectedMCPServers.forEach((serverId) => {
const server = mcpServers?.find((s) => s.server_id === serverId);
const serverName = server?.alias || server?.server_name || serverId;
// Use server_name for both routing and labelling. server_name is the
// unique registered identifier; aliases can collide across servers.
const routeName = server?.server_name || serverId;
const allowedTools = mcpServerToolRestrictions?.[serverId] || [];
tools.push({
type: "mcp",
server_label: "litellm",
server_url: `litellm_proxy/mcp/${serverName}`,
server_label: routeName, // unique per request — collisions cause silent tool-routing failures
server_url: `${proxyBaseUrl}/mcp/${encodeURIComponent(routeName)}`,
require_approval: "never",
...(allowedTools.length > 0 ? { allowed_tools: allowedTools } : {}),
});
@ -197,8 +199,7 @@ export async function makeOpenAIResponsesRequest(
if (event.type === "response.output_text.delta" && typeof event.delta === "string") {
const delta = event.delta;
console.log("Text delta", delta);
// skip pure whitespace/newlines
if (delta.trim().length > 0) {
if (delta.length > 0) {
updateTextUI("assistant", delta, selectedModel);
// Calculate time to first token

View file

@ -13,6 +13,17 @@ import {
export type McpOAuthStatus = "idle" | "authorizing" | "exchanging" | "success" | "error";
function extractErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
if (err && typeof err === "object") {
const e = err as Record<string, unknown>;
if (typeof e.detail === "string") return e.detail;
if (typeof e.message === "string") return e.message;
return JSON.stringify(err);
}
return String(err);
}
interface UseMcpOAuthFlowOptions {
accessToken: string | null;
getCredentials: () => {
@ -223,7 +234,7 @@ export const useMcpOAuthFlow = ({
} catch (err) {
console.error("Failed to start OAuth flow", err);
setStatus("error");
const message = err instanceof Error ? err.message : String(err);
const message = extractErrorMessage(err);
setError(message);
NotificationsManager.error(message);
}
@ -310,7 +321,7 @@ export const useMcpOAuthFlow = ({
setError(null);
NotificationsManager.success("OAuth token retrieved successfully");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message = extractErrorMessage(err);
setError(message);
setStatus("error");
NotificationsManager.error(message);