mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
fix merge conflicts: keep both onLogoUrlChange and onOAuthDocsUrlChange props
This commit is contained in:
commit
6609dba650
29 changed files with 976 additions and 551 deletions
|
|
@ -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
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)**
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
127
tests/test_litellm/test_chat_ui_responses_session.py
Normal file
127
tests/test_litellm/test_chat_ui_responses_session.py
Normal 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"
|
||||
)
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ export interface KeyResponse {
|
|||
user?: {
|
||||
user_id: string;
|
||||
user_email: string;
|
||||
user_alias: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue