From 3f18cd2fdc43e74eac3cdf750bbfe1a6f3ab02f2 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Wed, 11 Mar 2026 22:47:41 +0800 Subject: [PATCH] [Docs] Fix "Page Not Found" link for Anthropic endpoint (#23349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(anthropic): enforce type:'object' on tool input schemas Anthropic's API requires all tool input_schema to have type:'object' at the root level. When OpenAI-format tools have parameters with a missing or non-'object' type field (common with MCP tool servers), the schema was passed through unchanged, causing Anthropic to reject with: 'tools.N.custom.input_schema.type: Input should be object'. The existing default handles the case where parameters is entirely missing, but does not normalize schemas that ARE provided with a wrong or absent type field. Fix: After extracting _input_schema in _map_tool_helper(), ensure type is set to 'object' and properties exists. This matches the normalization already done implicitly by the Bedrock handler. Added 4 unit tests covering: missing type, wrong type, valid schema (no-op), and entirely missing parameters. Related issues: #12020, #64, #1671 * fix(anthropic): deduplicate tool_result messages by tool_call_id Anthropic requires exactly one tool_result per tool_use. When conversation history (e.g. from session resume/checkpoint restore) contains duplicate tool result messages with the same tool_call_id, the API rejects with: 'each tool_use must have a single result. Found multiple tool_result blocks with id: '. This is already handled for Bedrock via _deduplicate_bedrock_tool_content() but was missing from the Anthropic direct and Vertex AI partner paths, which share sanitize_messages_for_tool_calling(). Fix: Add Case D to sanitize_messages_for_tool_calling() — after the existing orphan detection passes, scan for duplicate tool_call_ids and keep only the last occurrence (most complete result). Added 3 unit tests: dedup with duplicates, no-op with unique IDs, and behavior when modify_params=False. Related issues: #11804, #11029, #6836, #1782, #151 * fix: shallow copy input_schema to avoid caller mutation + add mutation guard test Addresses Greptile review: - dict(_input_schema) before mutation prevents cross-provider state leakage - Test asserts original tool parameters dict is unchanged after call * feat: add qwen3.5 series for openrouter * fix: typo on max_output_tokens and max_tokens from qwen3.5 series * chore: fix * chore: fix * [Test] UI - Logs: Add unit tests for 5 untested view_logs components Add vitest tests for TypeBadges, ErrorViewer, ConfigInfoMessage, TimeCell, and TruncatedValue covering rendering, user interactions, and edge cases. Co-Authored-By: Claude Opus 4.6 * Rename 'Team-Based Guardrails' to 'Team Bring-Your-Own Guardrails' (#23307) Co-authored-by: Cursor Agent * feat(chat-ui): responses API + MCP tool execution in /chat (#23297) * feat(ui): add Chat UI v0 — standalone LiteLLM-branded chat window Adds a full chat UI accessible from the sidebar Chat link (opens in new tab). - Standalone route at /chat (outside dashboard layout — no Navbar/Sidebar chrome) - Claude.ai-style layout: model selector top-left, LiteLLM logo center, settings top-right - Greeting with time-of-day, centered input card, suggestion chips (Write/Learn/Code/Brainstorm) - Sliding conversation history sidebar with Cmd+K search, rename, delete, date grouping - localStorage-backed conversation persistence (litellm_chat_history_v1) - Streaming completions via makeOpenAIChatCompletionRequest with AbortController stop support - MCP server picker (toggle servers on/off per conversation) - LiteLLM aesthetic: white/light-gray background, Ant Design blue (#1677ff) primary, system font - Sidebar2: Chat menu item opens in new tab via window.open * feat(chat-ui): responses API + MCP tool execution display - Switch /chat from chat completions to responses API (previous_response_id session chaining) - Add MCP server picker with search filter in chat input bar - Show MCP tool call events (list_tools + call_tool) inline in chat via MCPEventsDisplay - Add tool chip strip showing available tools when MCP servers are selected - Non-blocking MCP toggle: server added immediately, verification in background (works for no-auth MCPs like deepwiki) - Add truncateAfterMessage to useChatHistory for edit/retry - Sync activeConversationId on URL change (fixes stale conversation on new chat) - Add "Open Chat" shortcut button to sidebar * fix(chat-ui): switch to responses API, remove dead code, add tests - Switch handleSend from makeOpenAIChatCompletionRequest to makeOpenAIResponsesRequest with previous_response_id session chaining - Add responsesSessionId state; reset to null when starting a new conversation - Remove unused ChatInputBar.tsx and ModelSelector.tsx (dead code) - Add tests/test_litellm/test_chat_ui_responses_session.py covering previous_response_id forwarding and signature validation * fix(chat-ui): address greptile review issues - Reset responsesSessionId when activeConversationId changes (not just on new conversation) - Wire onMCPEvent callback into makeOpenAIResponsesRequest; render MCPEventsDisplay below messages - Clear mcpEvents on each new send - Explicitly filter history to user/assistant roles only (no tool-role casting) - Remove duplicate "Chat" menu item from sidebar (pinned button serves same purpose) - Make Sider a flex column so "Open Chat" button actually pins to bottom - Fix tests to intercept real HTTP requests and assert previous_response_id in body * fix(chat-ui): address greptile review feedback (greploop iteration 1) - Fix duplicate context: when responsesSessionId is set, only send the new user message as input (prior context is already server-side via session chaining). Full history is still sent on the first turn. - Fix ephemeral MCP events: store events per-message in ChatMessage.mcpEvents instead of ephemeral component state. Events now survive across turns and render inline below each assistant response via MCPEventsDisplay. - Remove stale mcpEvents useState and ephemeral panel at bottom of chat. * fix(chat-ui): address greptile review feedback (greploop iteration 2) - Fix stale session on edit/retry: derive previousResponseId as null when historyOverride is set so edit/retry always starts a fresh Responses API session rather than chaining off a now-invalid prior session - Fix unsafe MCPEvent cast: import MCPEvent directly from MCPEventsDisplay into types.ts and type ChatMessage.mcpEvents as MCPEvent[], eliminating the bare 'as MCPEvent[]' cast in ChatMessages.tsx * fix(chat-ui): fix MCPEvent layering, batch localStorage writes, module-level test imports - Move MCPEvent interface definition into chat/types.ts (single source of truth) - MCPEventsDisplay.tsx now imports MCPEvent from types.ts instead of defining it locally - Batch MCP event localStorage writes: accumulate during stream, persist once in finally - Move test imports to module level per PEP 8 convention * fix(chat-ui): fix MCPEvent import path and rename truncateFromMessage - responses_api.tsx now imports MCPEvent directly from chat/types (not via MCPEventsDisplay re-export) - Remove the now-unnecessary MCPEvent re-export from MCPEventsDisplay.tsx - Rename truncateAfterMessage → truncateFromMessage: the function removes the target message and all subsequent ones (not just what comes after), so the new name accurately describes the behavior * fix(responses-api): fix whitespace token filter and MCP server URL construction - Drop the delta.trim() whitespace filter that was silently swallowing spaces and newlines during streaming, causing words to concatenate and paragraphs to collapse. Only skip truly empty strings (delta.length > 0). - Use proxyBaseUrl for MCP server_url construction instead of the hardcoded relative path "litellm_proxy/mcp", so non-root deployments route correctly. * fix(responses-api): use unique server_label per MCP server to prevent tool routing collisions * fix(chat-ui): move MCPEvent to shared mcp_tools/types, skip partial events on abort - Move MCPEvent interface to mcp_tools/types.tsx (shared with MCPServer/MCPTool), eliminating the playground→chat cross-module dependency. chat/types.ts and both playground components now import from mcp_tools/types. - Only persist accumulated MCP events when the stream completes cleanly; aborted or errored turns drop partial events to avoid showing incomplete tool calls. * fix(responses-api): use server_name for MCP URL routing, fix test path - Use server_name (not alias) as the URL path segment for MCP server_url; alias is a display name that may differ from the registered proxy route. URL-encode the path to handle names with spaces/special characters. - Fix sys.path.insert in tests to use __file__-relative path so tests pass regardless of which directory pytest is invoked from. * fix(chat-ui): fix stale session after failed edit, clean MCP event persistence, unique server_label - Eagerly call setResponsesSessionId(null) when historyOverride is set so a failed/aborted edit does not leave a stale session contaminating the next turn - Replace abort-signal check with streamCompletedCleanly flag to correctly skip MCP event persistence on both abort and non-abort errors (network/API failures) - Use server_name (unique) as server_label instead of alias to prevent silent tool-routing failures when two MCP servers share the same display name * [Feat] UI - Show logos on MCP Apps page (#23320) * feat(ui): add MCP server logo support across admin and chat UIs - New MCPLogoSelector component with grid of well-known logos (GitHub, Slack, Notion, Linear, Jira, etc.) and custom URL input - Create MCP Server form: logo picker with preview, OpenAPI presets auto-fill logo from registry icon_url - Edit MCP Server form: logo picker pre-populated from mcp_info.logo_url - Admin table: logos rendered next to server name in Name column - Chat MCPAppsPanel: logos on server cards (list + detail view) with graceful fallback to letter avatars - Chat MCPConnectPicker: logos next to server names in toggle list - Fix pre-existing bug: setTools -> clearTools in create form cancel - All 321 vitest files / 3211 tests pass Co-authored-by: Ishaan Jaff * feat(ui): use local SVG logos for MCP services, fix Chat UI rendering - Add 15 new MCP service logo SVGs (Slack, Notion, Linear, Jira, Figma, Gmail, Stripe, Salesforce, Shopify, HubSpot, Twilio, Sentry, Zapier, GitLab, Google Drive) to both source and pre-built directories - Switch MCPLogoSelector from CDN URLs (cdn.simpleicons.org) to local asset paths (/ui/assets/logos/) for reliable rendering - Logos now served by the proxy itself, working from any page path including /ui/chat/ (absolute paths resolve correctly everywhere) Co-authored-by: Ishaan Jaff --------- Co-authored-by: Cursor Agent Co-authored-by: Ishaan Jaff * fix(codeql): remove ruby from language matrix (#23227) * Add team-scoped MCP server filtering for key creation and fix UnboundLocalError When creating a key, the MCP server list now filters by the selected team's allowed servers. Also fixes UnboundLocalError on `is_restricted_virtual_key` when `team_id` query param was provided to GET /v1/mcp/server. Co-Authored-By: Claude Opus 4.6 * Fix cross-team MCP server info disclosure and restricted key bypass The GET /v1/mcp/server endpoint allowed any authenticated user to pass an arbitrary team_id and enumerate another team's MCP server config. Restricted virtual keys could also use the team_id param to bypass their access limitations. Add team membership check for non-admins and block restricted keys from using the team_id filter. Co-Authored-By: Claude Opus 4.6 * Fix mcp_tool_permissions JSON string deserialization in _resolve_team_allowed_mcp_servers Co-Authored-By: Claude Opus 4.6 * [Feature] UI - MCP Servers: Add per-server health recheck Allow users to recheck health for individual MCP servers by clicking the health status badge. On hover the badge text changes to "Recheck" with a refresh icon, and the check runs only for that server. Co-Authored-By: Claude Opus 4.6 * Fix Anthropic docs link for beta endpoint Update the Anthropic /v1/messages beta endpoint docstring to point to its current pass-through documentation. This keeps the change scoped to the incorrect URL and avoids changing unverified wording in the surrounding comment. --------- Co-authored-by: netbrah <162479981+netbrah@users.noreply.github.com> Co-authored-by: Yong woo Song Co-authored-by: yuneng-jiang Co-authored-by: Claude Opus 4.6 Co-authored-by: Krish Dholakia Co-authored-by: Cursor Agent Co-authored-by: Ishaan Jaff Co-authored-by: Sameer Kankute Co-authored-by: Ishaan Jaff Co-authored-by: Joe Reyna --- .github/workflows/codeql.yml | 2 - .../prompt_templates/factory.py | 48 +++ litellm/llms/anthropic/chat/transformation.py | 15 + ...odel_prices_and_context_window_backup.json | 86 +++++ .../_experimental/out/assets/logos/figma.svg | 7 + .../_experimental/out/assets/logos/gitlab.svg | 8 + .../_experimental/out/assets/logos/gmail.svg | 3 + .../out/assets/logos/google_drive.svg | 6 + .../out/assets/logos/hubspot.svg | 3 + .../_experimental/out/assets/logos/jira.svg | 15 + .../_experimental/out/assets/logos/linear.svg | 3 + .../_experimental/out/assets/logos/notion.svg | 3 + .../out/assets/logos/salesforce.svg | 3 + .../_experimental/out/assets/logos/sentry.svg | 3 + .../out/assets/logos/shopify.svg | 4 + .../_experimental/out/assets/logos/slack.svg | 6 + .../_experimental/out/assets/logos/stripe.svg | 3 + .../_experimental/out/assets/logos/twilio.svg | 3 + .../_experimental/out/assets/logos/zapier.svg | 3 + .../proxy/anthropic_endpoints/endpoints.py | 2 +- .../key_management_endpoints.py | 28 ++ .../mcp_management_endpoints.py | 126 ++++++- .../object_permission_utils.py | 184 ++++++++- model_prices_and_context_window.json | 86 +++++ ...llm_core_utils_prompt_templates_factory.py | 288 +++++++++++++- .../test_anthropic_chat_transformation.py | 125 +++++++ .../test_key_management_endpoints.py | 7 + .../test_mcp_management_endpoints.py | 151 ++++++++ .../test_object_permission_utils.py | 353 +++++++++++++++++- .../test_chat_ui_responses_session.py | 127 +++++++ .../public/assets/logos/figma.svg | 7 + .../public/assets/logos/gitlab.svg | 8 + .../public/assets/logos/gmail.svg | 3 + .../public/assets/logos/google_drive.svg | 6 + .../public/assets/logos/hubspot.svg | 3 + .../public/assets/logos/jira.svg | 15 + .../public/assets/logos/linear.svg | 3 + .../public/assets/logos/notion.svg | 3 + .../public/assets/logos/salesforce.svg | 3 + .../public/assets/logos/sentry.svg | 3 + .../public/assets/logos/shopify.svg | 4 + .../public/assets/logos/slack.svg | 6 + .../public/assets/logos/stripe.svg | 3 + .../public/assets/logos/twilio.svg | 3 + .../public/assets/logos/zapier.svg | 3 + .../app/(dashboard)/components/Sidebar2.tsx | 68 +++- .../hooks/mcpServers/useMCPServerHealth.ts | 41 +- .../hooks/mcpServers/useMCPServers.ts | 6 +- .../src/components/chat/ChatMessages.tsx | 10 + .../src/components/chat/ChatPage.tsx | 82 +++- .../src/components/chat/MCPAppsPanel.tsx | 37 +- .../src/components/chat/MCPConnectPicker.tsx | 12 + .../src/components/chat/types.ts | 4 + .../src/components/chat/useChatHistory.ts | 11 +- .../MCPServerSelector.tsx | 4 +- .../components/mcp_tools/MCPLogoSelector.tsx | 123 ++++++ .../mcp_tools/OpenAPIFormSection.tsx | 4 + .../mcp_tools/create_mcp_server.tsx | 12 + .../mcp_tools/mcp_server_columns.tsx | 166 +++++--- .../components/mcp_tools/mcp_server_edit.tsx | 4 + .../src/components/mcp_tools/mcp_servers.tsx | 6 +- .../src/components/mcp_tools/types.tsx | 27 ++ .../src/components/networking.tsx | 11 +- .../organisms/create_key_button.tsx | 3 + .../playground/chat_ui/MCPEventsDisplay.tsx | 27 +- .../playground/llm_calls/responses_api.tsx | 15 +- .../view_logs/ConfigInfoMessage.test.tsx | 41 ++ .../components/view_logs/ErrorViewer.test.tsx | 87 +++++ .../LogDetailsDrawer/TruncatedValue.test.tsx | 32 ++ .../components/view_logs/TypeBadges.test.tsx | 46 +++ .../components/view_logs/time_cell.test.tsx | 36 ++ 71 files changed, 2526 insertions(+), 163 deletions(-) create mode 100644 litellm/proxy/_experimental/out/assets/logos/figma.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/gitlab.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/gmail.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/google_drive.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/hubspot.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/jira.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/linear.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/notion.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/salesforce.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/sentry.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/shopify.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/slack.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/stripe.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/twilio.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/zapier.svg create mode 100644 tests/test_litellm/test_chat_ui_responses_session.py create mode 100644 ui/litellm-dashboard/public/assets/logos/figma.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/gitlab.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/gmail.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/google_drive.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/hubspot.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/jira.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/linear.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/notion.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/salesforce.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/sentry.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/shopify.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/slack.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/stripe.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/twilio.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/zapier.svg create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/MCPLogoSelector.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/ConfigInfoMessage.test.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/ErrorViewer.test.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/TruncatedValue.test.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/TypeBadges.test.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/time_cell.test.tsx diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 3d11345e85..0b7cce2e4b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -34,8 +34,6 @@ jobs: build-mode: none - language: python build-mode: none - - language: ruby - build-mode: none steps: - name: Checkout repository diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index a694cec7d6..5e905da223 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -2221,6 +2221,11 @@ def sanitize_messages_for_tool_calling( Case C: Empty text content - Replace empty or whitespace-only text content with a placeholder message. + Case D: Duplicate tool_result for same tool_use (duplicate results) + - If multiple tool messages reference the same tool_call_id, keep only the last + occurrence. Anthropic requires exactly one tool_result per tool_use and rejects + with: "each tool_use must have a single result". + This function operates on OpenAI format messages before they are converted to provider-specific formats. """ @@ -2256,6 +2261,49 @@ def sanitize_messages_for_tool_calling( sanitized_messages.append(current_message) i += 1 + # Case D: Deduplicate tool results with the same tool_call_id. + # Anthropic requires exactly one tool_result per tool_use. Session history + # (e.g. from conversation resume) can contain duplicate tool_result messages + # for the same tool_call_id. Keep only the last occurrence *within each + # contiguous block of tool results following an assistant message*. This + # avoids dropping results from earlier turns if a tool_call_id is reused. + # + # NOTE: This intentionally keeps the *last* occurrence (most complete for + # session-resume duplicates), unlike _deduplicate_bedrock_content_blocks + # which keeps the *first*. The Bedrock case handles provider-side content + # block duplication where the first is authoritative; here the duplicate + # arises from history replay where the last entry is the final state. + duplicates_to_remove: Set[int] = set() + seen_in_block: Dict[str, int] = {} # tool_call_id -> index (reset per block) + for idx, msg in enumerate(sanitized_messages): + role = msg.get("role") + tcid = msg.get("tool_call_id") if role in ["tool", "function"] else None + if tcid: + if tcid in seen_in_block: + # Mark the earlier occurrence for removal (keep latest) + duplicates_to_remove.add(seen_in_block[tcid]) + verbose_logger.warning( + "sanitize_messages_for_tool_calling: dropping duplicate " + "tool_result with tool_call_id=%s. This may indicate " + "duplicate tool messages in conversation history.", + tcid, + ) + seen_in_block[tcid] = idx + elif role not in ("tool", "function"): + # Non-tool message (user, assistant, system) marks a + # conversational-turn boundary — reset tracking. + # Tool/function messages with no tool_call_id are malformed; + # they should NOT reset the block because they don't represent + # a turn boundary and would mask real within-block duplicates. + seen_in_block = {} + + if duplicates_to_remove: + sanitized_messages = [ + msg + for idx, msg in enumerate(sanitized_messages) + if idx not in duplicates_to_remove + ] + return sanitized_messages diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 04b27e8782..fd1859f7d1 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -395,6 +395,21 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): }, ) + # Anthropic requires input_schema.type to be "object". Normalize + # schemas from external sources (MCP servers, OpenAI callers) that + # may omit the type field or use a non-object type. + if _input_schema.get("type") != "object": + litellm.verbose_logger.debug( + "_map_tool_helper: coercing input_schema type from %r to " + "'object' for Anthropic compatibility (tool: %s)", + _input_schema.get("type"), + tool["function"].get("name"), + ) + _input_schema = dict(_input_schema) # avoid mutating caller's dict + _input_schema["type"] = "object" + if "properties" not in _input_schema: + _input_schema["properties"] = {} + _allowed_properties = set(AnthropicInputSchema.__annotations__.keys()) input_schema_filtered = { k: v for k, v in _input_schema.items() if k in _allowed_properties diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index bb4a678b54..d3dd6b3d99 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -27665,6 +27665,92 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "openrouter/qwen/qwen3.5-35b-a3b": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-35b-a3b", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/qwen/qwen3.5-27b": { + "input_cost_per_token": 3e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 2.4e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-27b", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/qwen/qwen3.5-122b-a10b": { + "input_cost_per_token": 4e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-122b-a10b", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/qwen/qwen3.5-flash-02-23": { + "input_cost_per_token": 1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 4e-07, + "source": "https://openrouter.ai/qwen/qwen3.5-flash-02-23", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/qwen/qwen3.5-plus-02-15": { + "input_cost_per_token": 4e-07, + "input_cost_per_token_above_256k_tokens": 5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 2.4e-06, + "output_cost_per_token_above_256k_tokens": 3e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-plus-02-15", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/qwen/qwen3.5-397b-a17b": { + "input_cost_per_token": 6e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 3.6e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-397b-a17b", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, "openrouter/switchpoint/router": { "input_cost_per_token": 8.5e-07, "litellm_provider": "openrouter", diff --git a/litellm/proxy/_experimental/out/assets/logos/figma.svg b/litellm/proxy/_experimental/out/assets/logos/figma.svg new file mode 100644 index 0000000000..2d8b70457d --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/figma.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/gitlab.svg b/litellm/proxy/_experimental/out/assets/logos/gitlab.svg new file mode 100644 index 0000000000..18a89fa328 --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/gitlab.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/gmail.svg b/litellm/proxy/_experimental/out/assets/logos/gmail.svg new file mode 100644 index 0000000000..d702890620 --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/gmail.svg @@ -0,0 +1,3 @@ + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/google_drive.svg b/litellm/proxy/_experimental/out/assets/logos/google_drive.svg new file mode 100644 index 0000000000..7048af9915 --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/google_drive.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/hubspot.svg b/litellm/proxy/_experimental/out/assets/logos/hubspot.svg new file mode 100644 index 0000000000..b993945ac6 --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/hubspot.svg @@ -0,0 +1,3 @@ + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/jira.svg b/litellm/proxy/_experimental/out/assets/logos/jira.svg new file mode 100644 index 0000000000..fb10ca7517 --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/jira.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/linear.svg b/litellm/proxy/_experimental/out/assets/logos/linear.svg new file mode 100644 index 0000000000..83662a1f9f --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/linear.svg @@ -0,0 +1,3 @@ + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/notion.svg b/litellm/proxy/_experimental/out/assets/logos/notion.svg new file mode 100644 index 0000000000..170b9bb414 --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/notion.svg @@ -0,0 +1,3 @@ + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/salesforce.svg b/litellm/proxy/_experimental/out/assets/logos/salesforce.svg new file mode 100644 index 0000000000..1a541a004f --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/salesforce.svg @@ -0,0 +1,3 @@ + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/sentry.svg b/litellm/proxy/_experimental/out/assets/logos/sentry.svg new file mode 100644 index 0000000000..9c3733dc43 --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/sentry.svg @@ -0,0 +1,3 @@ + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/shopify.svg b/litellm/proxy/_experimental/out/assets/logos/shopify.svg new file mode 100644 index 0000000000..fcc7547269 --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/shopify.svg @@ -0,0 +1,4 @@ + + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/slack.svg b/litellm/proxy/_experimental/out/assets/logos/slack.svg new file mode 100644 index 0000000000..801de4f70c --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/slack.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/stripe.svg b/litellm/proxy/_experimental/out/assets/logos/stripe.svg new file mode 100644 index 0000000000..ac16a6fb17 --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/stripe.svg @@ -0,0 +1,3 @@ + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/twilio.svg b/litellm/proxy/_experimental/out/assets/logos/twilio.svg new file mode 100644 index 0000000000..3517a2824d --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/twilio.svg @@ -0,0 +1,3 @@ + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/zapier.svg b/litellm/proxy/_experimental/out/assets/logos/zapier.svg new file mode 100644 index 0000000000..8428ba82a5 --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/zapier.svg @@ -0,0 +1,3 @@ + + + diff --git a/litellm/proxy/anthropic_endpoints/endpoints.py b/litellm/proxy/anthropic_endpoints/endpoints.py index 5b23b47923..c1f41467d6 100644 --- a/litellm/proxy/anthropic_endpoints/endpoints.py +++ b/litellm/proxy/anthropic_endpoints/endpoints.py @@ -30,7 +30,7 @@ async def anthropic_response( # noqa: PLR0915 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ - Use `{PROXY_BASE_URL}/anthropic/v1/messages` instead - [Docs](https://docs.litellm.ai/docs/anthropic_completion). + Use `{PROXY_BASE_URL}/anthropic/v1/messages` instead - [Docs](https://docs.litellm.ai/docs/pass_through/anthropic_completion). This was a BETA endpoint that calls 100+ LLMs in the anthropic format. """ diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 68f997e29c..b9dcc514d2 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -64,6 +64,7 @@ from litellm.proxy.management_helpers.object_permission_utils import ( _set_object_permission, attach_object_permission_to_dict, handle_update_object_permission_common, + validate_key_mcp_servers_against_team, ) from litellm.proxy.management_helpers.team_member_permission_checks import ( TeamMemberPermissionChecks, @@ -638,6 +639,12 @@ async def _common_key_generation_helper( # noqa: PLR0915 data_json.pop("tags") + # Validate MCP servers in object_permission are within team scope + await validate_key_mcp_servers_against_team( + object_permission=data_json.get("object_permission"), + team_obj=team_table, + ) + data_json = await _set_object_permission( data_json=data_json, prisma_client=prisma_client, @@ -1947,6 +1954,27 @@ async def update_key_fn( # Set Management Endpoint Metadata Fields + # Validate MCP servers in object_permission against the effective team + if data.object_permission is not None: + effective_team_obj = team_obj + # If team_id isn't being changed, resolve the existing key's team + if effective_team_obj is None and existing_key_row.team_id: + effective_team_obj = await get_team_object( + team_id=existing_key_row.team_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + check_db_only=True, + ) + object_permission_dict = ( + data.object_permission.model_dump() + if hasattr(data.object_permission, "model_dump") + else data.object_permission + ) + await validate_key_mcp_servers_against_team( + object_permission=object_permission_dict, + team_obj=effective_team_obj, + ) + non_default_values = await prepare_key_update_data( data=data, existing_key_row=existing_key_row ) diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index 46c59b4879..08f452859f 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -615,6 +615,46 @@ if MCP_AVAILABLE: return "view_all" return "restricted" + async def _get_team_scoped_mcp_server_list( + team_id: str, + ) -> List[LiteLLM_MCPServerTable]: + """ + Return MCP servers scoped to a team: team's allowed servers + allow_all_keys servers. + Used by the Create Key UI to populate the MCP server dropdown. + """ + from litellm.proxy.auth.auth_checks import get_team_object + from litellm.proxy.management_helpers.object_permission_utils import ( + _get_allow_all_keys_server_ids, + _get_team_allowed_mcp_servers, + ) + from litellm.proxy.proxy_server import prisma_client, user_api_key_cache + + team_obj = await get_team_object( + team_id=team_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + check_db_only=True, + ) + + team_server_ids = await _get_team_allowed_mcp_servers(team_obj) + allow_all_server_ids = _get_allow_all_keys_server_ids() + all_allowed_ids = team_server_ids | allow_all_server_ids + + if not all_allowed_ids: + return [] + + # Collect servers from registry + servers: List[LiteLLM_MCPServerTable] = [] + for server_id in all_allowed_ids: + server = global_mcp_server_manager.get_mcp_server_by_id(server_id) + if server is not None: + mcp_server_table = global_mcp_server_manager._build_mcp_server_table( + server + ) + servers.append(mcp_server_table) + + return _redact_mcp_credentials_list(servers) + @router.get( "/server", description="Returns the mcp server list with associated teams", @@ -623,38 +663,88 @@ if MCP_AVAILABLE: ) async def fetch_all_mcp_servers( user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + team_id: Optional[str] = Query( + None, + description="Filter MCP servers by team scope. When provided, returns only " + "servers the team has access to plus globally available (allow_all_keys) servers. " + "Used by the Create Key UI to show team-scoped MCP servers.", + ), ): """ Get all of the configured mcp servers for the user in the db with their associated teams ``` curl --location 'http://localhost:4000/v1/mcp/server' \ --header 'Authorization: Bearer your_api_key_here' + + # Filter by team scope (for Create Key UI) + curl --location 'http://localhost:4000/v1/mcp/server?team_id=team-123' \ + --header 'Authorization: Bearer your_api_key_here' ``` """ - user_mcp_management_mode = _get_user_mcp_management_mode() + # If team_id is provided, return team-scoped servers + allow_all_keys servers is_restricted_virtual_key = _is_restricted_virtual_key_request( user_api_key_dict ) - - if user_mcp_management_mode == "view_all" and not is_restricted_virtual_key: - servers = await global_mcp_server_manager.get_all_mcp_servers_unfiltered() - redacted_mcp_servers = _redact_mcp_credentials_list(servers) - else: - auth_contexts = await build_effective_auth_contexts(user_api_key_dict) - - aggregated_servers: Dict[str, LiteLLM_MCPServerTable] = {} - for auth_context in auth_contexts: - servers = await global_mcp_server_manager.get_all_allowed_mcp_servers( - user_api_key_auth=auth_context + if team_id is not None and isinstance(team_id, str) and team_id.strip(): + # Restricted virtual keys must not use the team_id filter to + # bypass their own access limitations. + if is_restricted_virtual_key: + raise HTTPException( + status_code=403, + detail="Restricted virtual keys cannot query team-scoped MCP servers.", ) - for server in servers: - if server.server_id not in aggregated_servers: - aggregated_servers[server.server_id] = server - redacted_mcp_servers = _redact_mcp_credentials_list( - aggregated_servers.values() - ) + # Only proxy admins may query another team's MCP servers. + # Non-admins must belong to the requested team. + sanitized_team_id = team_id.strip() + is_admin = _user_has_admin_view(user_api_key_dict) + if not is_admin: + from litellm.proxy.auth.auth_checks import get_team_object + from litellm.proxy.proxy_server import ( + prisma_client, + user_api_key_cache, + ) + + team_obj = await get_team_object( + team_id=sanitized_team_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + check_db_only=True, + ) + user_in_team = any( + m.user_id is not None + and m.user_id == user_api_key_dict.user_id + for m in team_obj.members_with_roles + ) + if not user_in_team: + raise HTTPException( + status_code=403, + detail="You do not have permission to view MCP servers for this team.", + ) + + redacted_mcp_servers = await _get_team_scoped_mcp_server_list(sanitized_team_id) + else: + user_mcp_management_mode = _get_user_mcp_management_mode() + + if user_mcp_management_mode == "view_all" and not is_restricted_virtual_key: + servers = await global_mcp_server_manager.get_all_mcp_servers_unfiltered() + redacted_mcp_servers = _redact_mcp_credentials_list(servers) + else: + auth_contexts = await build_effective_auth_contexts(user_api_key_dict) + + aggregated_servers: Dict[str, LiteLLM_MCPServerTable] = {} + for auth_context in auth_contexts: + servers = await global_mcp_server_manager.get_all_allowed_mcp_servers( + user_api_key_auth=auth_context + ) + for server in servers: + if server.server_id not in aggregated_servers: + aggregated_servers[server.server_id] = server + + redacted_mcp_servers = _redact_mcp_credentials_list( + aggregated_servers.values() + ) # augment the mcp servers with public status if litellm.public_mcp_servers is not None: diff --git a/litellm/proxy/management_helpers/object_permission_utils.py b/litellm/proxy/management_helpers/object_permission_utils.py index 9670cdf330..319a0b5eb7 100644 --- a/litellm/proxy/management_helpers/object_permission_utils.py +++ b/litellm/proxy/management_helpers/object_permission_utils.py @@ -4,12 +4,14 @@ organizations, teams, and keys. """ import json -from litellm._uuid import uuid -from typing import Dict, Optional, Union +from typing import Dict, List, Optional, Set, Union + +from fastapi import HTTPException, status from litellm._logging import verbose_proxy_logger -from litellm.proxy.utils import PrismaClient +from litellm._uuid import uuid from litellm.litellm_core_utils.safe_json_dumps import safe_dumps +from litellm.proxy.utils import PrismaClient @@ -177,4 +179,178 @@ async def _set_object_permission( data_json["object_permission_id"] = created_permission.object_permission_id data_json.pop("object_permission") - return data_json \ No newline at end of file + return data_json + + +async def _resolve_team_allowed_mcp_servers( + team_object_permission: "LiteLLM_ObjectPermissionTable", +) -> Set[str]: + """ + Resolve the full set of MCP server IDs a team has access to. + + Combines: + - Direct mcp_servers list + - Servers from mcp_access_groups + - Server IDs referenced in mcp_tool_permissions keys + """ + from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( + MCPRequestHandler, + ) + + direct_servers: List[str] = team_object_permission.mcp_servers or [] + access_group_servers: List[str] = ( + await MCPRequestHandler._get_mcp_servers_from_access_groups( + team_object_permission.mcp_access_groups or [] + ) + ) + raw_tool_perms = team_object_permission.mcp_tool_permissions or {} + if isinstance(raw_tool_perms, str): + raw_tool_perms = json.loads(raw_tool_perms) + tool_perm_servers: List[str] = list(raw_tool_perms.keys()) + return set(direct_servers + access_group_servers + tool_perm_servers) + + +def _get_allow_all_keys_server_ids() -> Set[str]: + """Return the set of MCP server IDs marked with allow_all_keys=True.""" + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + return set(global_mcp_server_manager.get_allow_all_keys_server_ids()) + + +async def _get_team_allowed_mcp_servers( + team_obj: Optional["LiteLLM_TeamTableCachedObj"], +) -> Set[str]: + """ + Get the full set of MCP server IDs a team allows. + + If team has no object_permission or no MCP config, returns empty set + (meaning only allow_all_keys servers are permitted). + """ + if team_obj is None: + return set() + + team_object_permission = team_obj.object_permission + if team_object_permission is None: + return set() + + return await _resolve_team_allowed_mcp_servers(team_object_permission) + + +def _extract_requested_mcp_server_ids( + object_permission: Optional[dict], +) -> Set[str]: + """ + Extract all MCP server IDs referenced in a key's object_permission dict. + + Includes: + - mcp_servers list + - Keys from mcp_tool_permissions + """ + if not object_permission or not isinstance(object_permission, dict): + return set() + + server_ids: Set[str] = set() + mcp_servers = object_permission.get("mcp_servers") + if isinstance(mcp_servers, list): + server_ids.update(mcp_servers) + + mcp_tool_permissions = object_permission.get("mcp_tool_permissions") + if isinstance(mcp_tool_permissions, dict): + server_ids.update(mcp_tool_permissions.keys()) + + return server_ids + + +def _extract_requested_mcp_access_groups( + object_permission: Optional[dict], +) -> Set[str]: + """Extract MCP access groups from a key's object_permission dict.""" + if not object_permission or not isinstance(object_permission, dict): + return set() + + groups = object_permission.get("mcp_access_groups") + if isinstance(groups, list): + return set(groups) + return set() + + +async def validate_key_mcp_servers_against_team( + object_permission: Optional[dict], + team_obj: Optional["LiteLLM_TeamTableCachedObj"], +): + """ + Validate that MCP servers requested on a key are within the allowed scope. + + Rules: + - If key is in a team: key's mcp_servers must be a subset of + (team's allowed servers + allow_all_keys servers) + - If key is NOT in a team: key's mcp_servers must only contain + allow_all_keys servers + - If team has no MCP config: key can only use allow_all_keys servers + + Raises HTTPException(403) if validation fails. + """ + requested_servers = _extract_requested_mcp_server_ids(object_permission) + requested_access_groups = _extract_requested_mcp_access_groups(object_permission) + + # Nothing to validate + if not requested_servers and not requested_access_groups: + return + + allow_all_keys_servers = _get_allow_all_keys_server_ids() + team_allowed_servers = await _get_team_allowed_mcp_servers(team_obj) + + # Combined allowed set = team servers + allow_all_keys servers + all_allowed_servers = team_allowed_servers | allow_all_keys_servers + + # Validate requested server IDs + if requested_servers: + disallowed_servers = requested_servers - all_allowed_servers + if disallowed_servers: + if team_obj is not None: + detail = ( + f"Key requests MCP servers not allowed by team '{team_obj.team_id}': " + f"{sorted(disallowed_servers)}. " + f"Team allows: {sorted(team_allowed_servers)}. " + f"Global (allow_all_keys) servers: {sorted(allow_all_keys_servers)}." + ) + else: + detail = ( + f"Key is not in a team. Only globally available (allow_all_keys) MCP servers " + f"can be assigned: {sorted(allow_all_keys_servers)}. " + f"Disallowed servers: {sorted(disallowed_servers)}." + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"error": detail}, + ) + + # Validate requested access groups (must be subset of team's access groups) + if requested_access_groups: + team_access_groups: Set[str] = set() + if ( + team_obj is not None + and team_obj.object_permission is not None + and team_obj.object_permission.mcp_access_groups + ): + team_access_groups = set(team_obj.object_permission.mcp_access_groups) + + disallowed_groups = requested_access_groups - team_access_groups + if disallowed_groups: + if team_obj is not None: + detail = ( + f"Key requests MCP access groups not allowed by team '{team_obj.team_id}': " + f"{sorted(disallowed_groups)}. " + f"Team allows: {sorted(team_access_groups)}." + ) + else: + detail = ( + f"Key is not in a team. MCP access groups cannot be assigned to " + f"keys outside of a team. Disallowed groups: {sorted(disallowed_groups)}." + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"error": detail}, + ) \ No newline at end of file diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index bb4a678b54..d3dd6b3d99 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -27665,6 +27665,92 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "openrouter/qwen/qwen3.5-35b-a3b": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-35b-a3b", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/qwen/qwen3.5-27b": { + "input_cost_per_token": 3e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 2.4e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-27b", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/qwen/qwen3.5-122b-a10b": { + "input_cost_per_token": 4e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-122b-a10b", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/qwen/qwen3.5-flash-02-23": { + "input_cost_per_token": 1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 4e-07, + "source": "https://openrouter.ai/qwen/qwen3.5-flash-02-23", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/qwen/qwen3.5-plus-02-15": { + "input_cost_per_token": 4e-07, + "input_cost_per_token_above_256k_tokens": 5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 2.4e-06, + "output_cost_per_token_above_256k_tokens": 3e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-plus-02-15", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/qwen/qwen3.5-397b-a17b": { + "input_cost_per_token": 6e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 3.6e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-397b-a17b", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, "openrouter/switchpoint/router": { "input_cost_per_token": 8.5e-07, "litellm_provider": "openrouter", diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index 707b5bdc77..8d68539564 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -10,6 +10,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import ( BedrockImageProcessor, _convert_to_bedrock_tool_call_invoke, ollama_pt, + sanitize_messages_for_tool_calling, ) @@ -1179,7 +1180,7 @@ def test_bedrock_tools_pt_does_not_handle_system_tool(): System tools (nova_grounding) should be added via web_search_options, not via the tools parameter directly. """ - + from litellm.litellm_core_utils.prompt_templates.factory import _bedrock_tools_pt # Regular function tools should still work @@ -1741,3 +1742,288 @@ def test_bedrock_tool_call_invoke_multiple_normal_tools(): assert len(result) == 2 assert result[0]["toolUse"]["toolUseId"] == "call_1" assert result[1]["toolUse"]["toolUseId"] == "call_2" + + +# ======================================================================== +# Tool result deduplication tests (Case D in sanitize_messages_for_tool_calling) +# ======================================================================== + + +def test_sanitize_messages_deduplicates_tool_results(): + """ + Anthropic requires exactly one tool_result per tool_use. When conversation + history (e.g. from session resume) contains duplicate tool result messages + with the same tool_call_id, sanitize_messages_for_tool_calling should keep + only the last occurrence. + + Without this fix, Anthropic rejects with: + each tool_use must have a single result. Found multiple tool_result + blocks with id: + """ + original = litellm.modify_params + litellm.modify_params = True + try: + messages = [ + {"role": "user", "content": "What's the weather?"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_abc123", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"city": "NYC"}', + }, + } + ], + }, + # First tool result (stale/duplicate) + { + "role": "tool", + "tool_call_id": "call_abc123", + "content": "Partial result...", + }, + # Second tool result (final/complete — should be kept) + { + "role": "tool", + "tool_call_id": "call_abc123", + "content": '{"temperature": 72, "condition": "sunny"}', + }, + ] + + result = sanitize_messages_for_tool_calling(messages) + + # Count tool messages with this ID — should be exactly 1 + tool_results = [ + m for m in result if m.get("role") == "tool" and m.get("tool_call_id") == "call_abc123" + ] + assert len(tool_results) == 1 + # Should keep the LAST occurrence (most complete) + assert tool_results[0]["content"] == '{"temperature": 72, "condition": "sunny"}' + finally: + litellm.modify_params = original + + +def test_sanitize_messages_preserves_unique_tool_results(): + """ + When each tool_call_id has exactly one tool_result, no deduplication should + occur. Messages should pass through unchanged. + """ + original = litellm.modify_params + litellm.modify_params = True + try: + messages = [ + {"role": "user", "content": "Get weather for two cities"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"city": "NYC"}', + }, + }, + { + "id": "call_2", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"city": "LA"}', + }, + }, + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "72F"}, + {"role": "tool", "tool_call_id": "call_2", "content": "85F"}, + ] + + result = sanitize_messages_for_tool_calling(messages) + + tool_results = [m for m in result if m.get("role") == "tool"] + assert len(tool_results) == 2 + assert tool_results[0]["tool_call_id"] == "call_1" + assert tool_results[0]["content"] == "72F" + assert tool_results[1]["tool_call_id"] == "call_2" + assert tool_results[1]["content"] == "85F" + finally: + litellm.modify_params = original + + +def test_sanitize_messages_dedup_disabled_when_modify_params_false(): + """ + When litellm.modify_params is False, messages should be returned as-is + even if they contain duplicate tool results. + """ + original = litellm.modify_params + litellm.modify_params = False + try: + messages = [ + {"role": "user", "content": "Test"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_dup", + "type": "function", + "function": {"name": "test", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_dup", "content": "first"}, + {"role": "tool", "tool_call_id": "call_dup", "content": "second"}, + ] + + result = sanitize_messages_for_tool_calling(messages) + + # Should be unchanged — no sanitization when modify_params=False + assert result == messages + finally: + litellm.modify_params = original + + +def test_sanitize_messages_dedup_scoped_per_turn_preserves_cross_turn(): + """ + When the same tool_call_id appears in two different assistant turns + (separated by a user message), both tool results must be preserved. + Deduplication should only apply within a single contiguous tool-result + block, not globally across the conversation. + + Without per-turn scoping this would incorrectly drop the first tool result, + leaving the first assistant message without its required result (which + Anthropic would reject). + """ + original = litellm.modify_params + litellm.modify_params = True + try: + messages = [ + {"role": "user", "content": "First question"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_X", + "type": "function", + "function": {"name": "lookup", "arguments": '{"q": "a"}'}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_X", "content": "result_turn_1"}, + {"role": "user", "content": "Second question"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_X", + "type": "function", + "function": {"name": "lookup", "arguments": '{"q": "b"}'}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_X", "content": "result_turn_2"}, + ] + + result = sanitize_messages_for_tool_calling(messages) + + # Both tool results must survive — one per turn + tool_results = [ + m for m in result + if m.get("role") == "tool" and m.get("tool_call_id") == "call_X" + ] + assert len(tool_results) == 2, ( + f"Expected 2 tool results (one per turn), got {len(tool_results)}. " + "Dedup may be global instead of per-turn scoped." + ) + assert tool_results[0]["content"] == "result_turn_1" + assert tool_results[1]["content"] == "result_turn_2" + finally: + litellm.modify_params = original + + +def test_sanitize_messages_combined_case_a_and_case_d(): + """ + Combined Case A + Case D: an assistant message has two tool_calls — + one with a missing result (Case A should inject a dummy) and one with + duplicate results (Case D should deduplicate to keep only the last). + + This validates that both sanitization passes compose correctly without + interfering with each other. + """ + original = litellm.modify_params + litellm.modify_params = True + try: + messages = [ + {"role": "user", "content": "Do two things"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_missing", + "type": "function", + "function": {"name": "tool_a", "arguments": "{}"}, + }, + { + "id": "call_duped", + "type": "function", + "function": {"name": "tool_b", "arguments": '{"q": "x"}'}, + }, + ], + }, + # No result for call_missing — Case A should inject a dummy + # Duplicate results for call_duped — Case D should keep last + {"role": "tool", "tool_call_id": "call_duped", "content": "stale_result"}, + {"role": "tool", "tool_call_id": "call_duped", "content": "fresh_result"}, + {"role": "user", "content": "Now summarize"}, + ] + + result = sanitize_messages_for_tool_calling(messages) + + # Collect tool results from the output + tool_results = [m for m in result if m.get("role") in ("tool", "function")] + + # Case A: call_missing should have a dummy result injected + missing_results = [ + m for m in tool_results if m.get("tool_call_id") == "call_missing" + ] + assert len(missing_results) == 1, ( + f"Expected 1 dummy result for call_missing (Case A), got {len(missing_results)}" + ) + + # Case D: call_duped should have exactly 1 result (the fresh one) + duped_results = [ + m for m in tool_results if m.get("tool_call_id") == "call_duped" + ] + assert len(duped_results) == 1, ( + f"Expected 1 result for call_duped after dedup (Case D), got {len(duped_results)}" + ) + assert duped_results[0]["content"] == "fresh_result", ( + f"Expected last-wins 'fresh_result', got '{duped_results[0]['content']}'" + ) + + # Verify tool results immediately follow the assistant message + asst_idx = next( + i for i, m in enumerate(result) if m.get("role") == "assistant" + ) + tool_msgs_after_asst = [ + m + for m in result[asst_idx + 1 :] + if m.get("role") in ("tool", "function") + ] + assert len(tool_msgs_after_asst) == 2, ( + f"Expected 2 tool results after assistant, got {len(tool_msgs_after_asst)}" + ) + # Both tool_call_ids should be present (order may vary) + tool_ids = {m["tool_call_id"] for m in tool_msgs_after_asst} + assert tool_ids == {"call_missing", "call_duped"}, ( + f"Expected tool_call_ids {{call_missing, call_duped}}, got {tool_ids}" + ) + finally: + litellm.modify_params = original diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index b540b0d952..6f03f630b5 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -3175,3 +3175,128 @@ def test_map_openai_params_max_tokens_normalized_to_int(): assert "max_tokens" in result assert result["max_tokens"] == 1 + + +# ======================================================================== +# Tool schema normalization tests +# ======================================================================== + + +def test_map_tool_helper_enforces_object_type_when_missing(): + """ + Anthropic requires input_schema.type to be "object". When an OpenAI tool + has parameters without a 'type' field (common with MCP servers), LiteLLM + should inject type:"object" before forwarding to Anthropic. + + Without this fix, Anthropic rejects with: + tools.N.custom.input_schema.type: Input should be 'object' + """ + config = AnthropicConfig() + + # Tool with parameters that has properties but no 'type' field + tool = { + "type": "function", + "function": { + "name": "search_code", + "description": "Search for code patterns", + "parameters": { + "properties": { + "query": {"type": "string", "description": "Search query"} + }, + "required": ["query"], + }, + }, + } + + original_params = tool["function"]["parameters"].copy() + result, _ = config._map_tool_helper(tool) + assert result is not None + assert result["input_schema"]["type"] == "object" + assert "properties" in result["input_schema"] + assert "query" in result["input_schema"]["properties"] + # Original parameters dict must not be modified in place + assert tool["function"]["parameters"] == original_params, ( + "parameters dict was mutated; _map_tool_helper should not modify caller data" + ) + + +def test_map_tool_helper_enforces_object_type_when_wrong_type(): + """ + If a tool schema has type:"string" or type:"array" at the root level, + LiteLLM should normalize it to type:"object" for Anthropic compatibility. + """ + config = AnthropicConfig() + + tool = { + "type": "function", + "function": { + "name": "echo", + "description": "Echo input", + "parameters": { + "type": "string", + "description": "The input to echo", + }, + }, + } + + original_params = tool["function"]["parameters"].copy() + result, _ = config._map_tool_helper(tool) + assert result is not None + assert result["input_schema"]["type"] == "object" + assert result["input_schema"].get("properties") == {}, ( + "properties should be injected as {} when schema has non-object type and no properties key" + ) + # Original parameters dict must not be modified in place + assert tool["function"]["parameters"] == original_params, ( + "parameters dict was mutated; _map_tool_helper should not modify caller data" + ) + + +def test_map_tool_helper_preserves_valid_object_schema(): + """ + When a tool schema already has type:"object", it should be preserved + without modification. + """ + config = AnthropicConfig() + + tool = { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string"}, + }, + "required": ["city"], + }, + }, + } + + result, _ = config._map_tool_helper(tool) + assert result is not None + assert result["input_schema"]["type"] == "object" + assert "city" in result["input_schema"]["properties"] + assert result["input_schema"]["required"] == ["city"] + + +def test_map_tool_helper_empty_parameters_get_default(): + """ + When parameters is entirely missing, the existing default should still + produce a valid {type:"object", properties:{}} schema. + """ + config = AnthropicConfig() + + tool = { + "type": "function", + "function": { + "name": "no_params_tool", + "description": "Tool with no parameters", + }, + } + + result, _ = config._map_tool_helper(tool) + assert result is not None + assert result["input_schema"]["type"] == "object" + assert result["input_schema"].get("properties") == {} diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 55366bbec2..09dfdb81cb 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -605,6 +605,10 @@ async def test_key_generation_with_mcp_tool_permissions(monkeypatch): mock_prisma_client.insert_data = AsyncMock(side_effect=_insert_data_side_effect) monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + monkeypatch.setattr( + "litellm.proxy.management_endpoints.key_management_endpoints.validate_key_mcp_servers_against_team", + AsyncMock(), + ) from litellm.proxy._types import ( GenerateKeyRequest, @@ -2346,6 +2350,9 @@ async def test_generate_key_with_object_permission(): ), patch( "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin", + ), patch( + "litellm.proxy.management_endpoints.key_management_endpoints.validate_key_mcp_servers_against_team", + new_callable=AsyncMock, ): # Execute result = await _common_key_generation_helper( diff --git a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py index 30b3be4a3e..ea51965ebf 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py @@ -797,6 +797,157 @@ class TestListMCPServers: assert result.status == "healthy" +class TestTeamScopedMCPServerAccess: + """Tests for cross-team information disclosure and restricted key bypass fixes.""" + + @pytest.mark.asyncio + async def test_non_member_cannot_query_foreign_team(self): + """Non-admin user who is NOT a member of the target team should get 403.""" + from litellm.proxy._types import Member + + mock_user_auth = generate_mock_user_api_key_auth( + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="attacker_user", + ) + + # Team with a different member + mock_team_obj = MagicMock() + mock_team_obj.members_with_roles = [ + Member(user_id="legitimate_user", role="admin"), + ] + + with ( + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints._user_has_admin_view", + return_value=False, + ), + patch( + "litellm.proxy.auth.auth_checks.get_team_object", + AsyncMock(return_value=mock_team_obj), + ), + ): + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + fetch_all_mcp_servers, + ) + + with pytest.raises(HTTPException) as exc_info: + await fetch_all_mcp_servers( + user_api_key_dict=mock_user_auth, team_id="foreign-team-id" + ) + assert exc_info.value.status_code == 403 + assert "permission" in str(exc_info.value.detail).lower() + + @pytest.mark.asyncio + async def test_team_member_can_query_own_team(self): + """User who IS a member of the team should be able to query it.""" + from litellm.proxy._types import Member + + mock_user_auth = generate_mock_user_api_key_auth( + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="team_member", + ) + + mock_team_obj = MagicMock() + mock_team_obj.members_with_roles = [ + Member(user_id="team_member", role="user"), + ] + mock_team_obj.object_permission = MagicMock(mcp_servers=["server-1"]) + + mock_server = generate_mock_mcp_server_config_record( + server_id="server-1", name="Team Server" + ) + mock_manager = MagicMock() + mock_manager.get_mcp_server_by_id = MagicMock(return_value=mock_server) + mock_manager._build_mcp_server_table = MagicMock( + return_value=generate_mock_mcp_server_db_record(server_id="server-1") + ) + + with ( + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints._user_has_admin_view", + return_value=False, + ), + patch( + "litellm.proxy.auth.auth_checks.get_team_object", + AsyncMock(return_value=mock_team_obj), + ), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints._get_team_scoped_mcp_server_list", + AsyncMock( + return_value=[ + generate_mock_mcp_server_db_record(server_id="server-1") + ] + ), + ), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager", + mock_manager, + ), + ): + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + fetch_all_mcp_servers, + ) + + result = await fetch_all_mcp_servers( + user_api_key_dict=mock_user_auth, team_id="my-team-id" + ) + assert len(result) == 1 + assert result[0].server_id == "server-1" + + @pytest.mark.asyncio + async def test_admin_can_query_any_team(self): + """Proxy admins should be able to query any team's MCP servers.""" + mock_user_auth = generate_mock_user_api_key_auth( + user_role=LitellmUserRoles.PROXY_ADMIN, + user_id="admin_user", + ) + + with ( + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints._user_has_admin_view", + return_value=True, + ), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints._get_team_scoped_mcp_server_list", + AsyncMock( + return_value=[ + generate_mock_mcp_server_db_record(server_id="server-1") + ] + ), + ), + ): + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + fetch_all_mcp_servers, + ) + + # Admin should NOT need to be a team member + result = await fetch_all_mcp_servers( + user_api_key_dict=mock_user_auth, team_id="any-team-id" + ) + assert len(result) == 1 + + @pytest.mark.asyncio + async def test_restricted_virtual_key_cannot_use_team_id_filter(self): + """Restricted virtual keys must not bypass access limits via team_id.""" + mock_user_auth = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="vkey_user", + api_key="sk-restricted", + allowed_routes=["mcp_routes"], + ) + + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + fetch_all_mcp_servers, + ) + + with pytest.raises(HTTPException) as exc_info: + await fetch_all_mcp_servers( + user_api_key_dict=mock_user_auth, team_id="some-team" + ) + assert exc_info.value.status_code == 403 + assert "Restricted virtual key" in str(exc_info.value.detail) + + class TestTemporaryMCPSessionEndpoints: def test_inherit_credentials_from_existing_server(self): payload = NewMCPServerRequest( diff --git a/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py b/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py index 07d89035dc..202b95b319 100644 --- a/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py +++ b/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py @@ -3,15 +3,21 @@ import os import sys import pytest +from fastapi import HTTPException sys.path.insert( 0, os.path.abspath("../../../..") ) -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch +from litellm.proxy._types import LiteLLM_ObjectPermissionTable from litellm.proxy.management_helpers.object_permission_utils import ( + _extract_requested_mcp_access_groups, + _extract_requested_mcp_server_ids, + _resolve_team_allowed_mcp_servers, _set_object_permission, + validate_key_mcp_servers_against_team, ) @@ -82,3 +88,348 @@ async def test_set_object_permission(): assert result["user_id"] == "test_user" assert result["models"] == ["gpt-4"] + +# ---- Tests for _extract_requested_mcp_server_ids ---- + + +def test_extract_requested_mcp_server_ids_from_mcp_servers(): + obj_perm = {"mcp_servers": ["server-1", "server-2"]} + assert _extract_requested_mcp_server_ids(obj_perm) == {"server-1", "server-2"} + + +def test_extract_requested_mcp_server_ids_from_tool_permissions(): + obj_perm = {"mcp_tool_permissions": {"server-a": ["tool1"], "server-b": ["tool2"]}} + assert _extract_requested_mcp_server_ids(obj_perm) == {"server-a", "server-b"} + + +def test_extract_requested_mcp_server_ids_combined(): + obj_perm = { + "mcp_servers": ["server-1"], + "mcp_tool_permissions": {"server-2": ["tool1"]}, + } + assert _extract_requested_mcp_server_ids(obj_perm) == {"server-1", "server-2"} + + +def test_extract_requested_mcp_server_ids_none(): + assert _extract_requested_mcp_server_ids(None) == set() + assert _extract_requested_mcp_server_ids({}) == set() + + +# ---- Tests for _extract_requested_mcp_access_groups ---- + + +def test_extract_requested_mcp_access_groups(): + obj_perm = {"mcp_access_groups": ["group-a", "group-b"]} + assert _extract_requested_mcp_access_groups(obj_perm) == {"group-a", "group-b"} + + +def test_extract_requested_mcp_access_groups_none(): + assert _extract_requested_mcp_access_groups(None) == set() + assert _extract_requested_mcp_access_groups({}) == set() + + +# ---- Tests for validate_key_mcp_servers_against_team ---- + + +def _make_team_obj( + team_id="team-1", + mcp_servers=None, + mcp_access_groups=None, + mcp_tool_permissions=None, +): + """Create a mock team object with the given MCP permissions.""" + mock_team = MagicMock() + mock_team.team_id = team_id + + if mcp_servers is not None or mcp_access_groups is not None or mcp_tool_permissions is not None: + mock_team.object_permission = MagicMock(spec=LiteLLM_ObjectPermissionTable) + mock_team.object_permission.mcp_servers = mcp_servers or [] + mock_team.object_permission.mcp_access_groups = mcp_access_groups or [] + mock_team.object_permission.mcp_tool_permissions = mcp_tool_permissions or {} + else: + mock_team.object_permission = None + + return mock_team + + +@pytest.mark.asyncio +@patch( + "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids", + return_value=set(), +) +@patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups", + new_callable=AsyncMock, + return_value=[], +) +async def test_validate_no_object_permission(mock_access_groups, mock_allow_all): + """No object_permission on key — should pass without error.""" + await validate_key_mcp_servers_against_team( + object_permission=None, + team_obj=_make_team_obj(mcp_servers=["server-1"]), + ) + + +@pytest.mark.asyncio +@patch( + "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids", + return_value=set(), +) +@patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups", + new_callable=AsyncMock, + return_value=[], +) +async def test_validate_key_servers_within_team_scope(mock_access_groups, mock_allow_all): + """Key requests servers that are in the team's scope — should pass.""" + team_obj = _make_team_obj(mcp_servers=["server-1", "server-2", "server-3"]) + await validate_key_mcp_servers_against_team( + object_permission={"mcp_servers": ["server-1", "server-2"]}, + team_obj=team_obj, + ) + + +@pytest.mark.asyncio +@patch( + "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids", + return_value=set(), +) +@patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups", + new_callable=AsyncMock, + return_value=[], +) +async def test_validate_key_servers_outside_team_scope_raises(mock_access_groups, mock_allow_all): + """Key requests servers NOT in the team's scope — should raise 403.""" + team_obj = _make_team_obj(mcp_servers=["server-1"]) + with pytest.raises(HTTPException) as exc_info: + await validate_key_mcp_servers_against_team( + object_permission={"mcp_servers": ["server-1", "server-outside"]}, + team_obj=team_obj, + ) + assert exc_info.value.status_code == 403 + assert "server-outside" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +@patch( + "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids", + return_value={"global-server"}, +) +@patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups", + new_callable=AsyncMock, + return_value=[], +) +async def test_validate_allow_all_keys_servers_always_allowed(mock_access_groups, mock_allow_all): + """allow_all_keys servers should be accessible even if not in team scope.""" + team_obj = _make_team_obj(mcp_servers=["server-1"]) + await validate_key_mcp_servers_against_team( + object_permission={"mcp_servers": ["server-1", "global-server"]}, + team_obj=team_obj, + ) + + +@pytest.mark.asyncio +@patch( + "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids", + return_value={"global-server"}, +) +@patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups", + new_callable=AsyncMock, + return_value=[], +) +async def test_validate_no_team_only_allow_all_keys(mock_access_groups, mock_allow_all): + """Key without a team can only use allow_all_keys servers.""" + # This should pass — requesting a global server without a team + await validate_key_mcp_servers_against_team( + object_permission={"mcp_servers": ["global-server"]}, + team_obj=None, + ) + + +@pytest.mark.asyncio +@patch( + "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids", + return_value={"global-server"}, +) +@patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups", + new_callable=AsyncMock, + return_value=[], +) +async def test_validate_no_team_non_global_server_raises(mock_access_groups, mock_allow_all): + """Key without a team requesting a non-global server — should raise 403.""" + with pytest.raises(HTTPException) as exc_info: + await validate_key_mcp_servers_against_team( + object_permission={"mcp_servers": ["private-server"]}, + team_obj=None, + ) + assert exc_info.value.status_code == 403 + assert "not in a team" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +@patch( + "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids", + return_value=set(), +) +@patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups", + new_callable=AsyncMock, + return_value=[], +) +async def test_validate_team_no_mcp_config_blocks_all(mock_access_groups, mock_allow_all): + """Team with no object_permission — key can't use any non-global MCP servers.""" + team_obj = _make_team_obj() # No object_permission + with pytest.raises(HTTPException) as exc_info: + await validate_key_mcp_servers_against_team( + object_permission={"mcp_servers": ["some-server"]}, + team_obj=team_obj, + ) + assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +@patch( + "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids", + return_value=set(), +) +@patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups", + new_callable=AsyncMock, + return_value=[], +) +async def test_validate_tool_permissions_validated_against_team(mock_access_groups, mock_allow_all): + """Server IDs in mcp_tool_permissions should also be validated.""" + team_obj = _make_team_obj(mcp_servers=["server-1"]) + with pytest.raises(HTTPException) as exc_info: + await validate_key_mcp_servers_against_team( + object_permission={ + "mcp_tool_permissions": {"server-outside": ["tool1"]} + }, + team_obj=team_obj, + ) + assert exc_info.value.status_code == 403 + assert "server-outside" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +@patch( + "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids", + return_value=set(), +) +@patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups", + new_callable=AsyncMock, + return_value=[], +) +async def test_validate_access_groups_within_team_scope(mock_access_groups, mock_allow_all): + """Key requests access groups that are in the team's scope — should pass.""" + team_obj = _make_team_obj(mcp_access_groups=["group-a", "group-b"]) + await validate_key_mcp_servers_against_team( + object_permission={"mcp_access_groups": ["group-a"]}, + team_obj=team_obj, + ) + + +@pytest.mark.asyncio +@patch( + "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids", + return_value=set(), +) +@patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups", + new_callable=AsyncMock, + return_value=[], +) +async def test_validate_access_groups_outside_team_scope_raises(mock_access_groups, mock_allow_all): + """Key requests access groups NOT in the team's scope — should raise 403.""" + team_obj = _make_team_obj(mcp_access_groups=["group-a"]) + with pytest.raises(HTTPException) as exc_info: + await validate_key_mcp_servers_against_team( + object_permission={"mcp_access_groups": ["group-outside"]}, + team_obj=team_obj, + ) + assert exc_info.value.status_code == 403 + assert "group-outside" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +@patch( + "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids", + return_value=set(), +) +@patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups", + new_callable=AsyncMock, + return_value=[], +) +async def test_validate_access_groups_no_team_raises(mock_access_groups, mock_allow_all): + """Key without a team requesting access groups — should raise 403.""" + with pytest.raises(HTTPException) as exc_info: + await validate_key_mcp_servers_against_team( + object_permission={"mcp_access_groups": ["group-a"]}, + team_obj=None, + ) + assert exc_info.value.status_code == 403 + assert "not in a team" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +@patch( + "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids", + return_value=set(), +) +@patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups", + new_callable=AsyncMock, + return_value=["server-from-group"], +) +async def test_validate_team_access_groups_resolve_to_servers(mock_access_groups, mock_allow_all): + """Team access groups should resolve to server IDs and be included in allowed set.""" + team_obj = _make_team_obj(mcp_access_groups=["group-a"]) + # Key requests a server that comes from the team's access group + await validate_key_mcp_servers_against_team( + object_permission={"mcp_servers": ["server-from-group"]}, + team_obj=team_obj, + ) + + +# ---- Tests for _resolve_team_allowed_mcp_servers with JSON string mcp_tool_permissions ---- + + +@pytest.mark.asyncio +@patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups", + new_callable=AsyncMock, + return_value=[], +) +async def test_resolve_team_allowed_mcp_servers_string_tool_permissions(mock_access_groups): + """mcp_tool_permissions stored as a JSON string (via safe_dumps) should be deserialized correctly.""" + mock_perm = MagicMock(spec=LiteLLM_ObjectPermissionTable) + mock_perm.mcp_servers = ["server-1"] + mock_perm.mcp_access_groups = [] + mock_perm.mcp_tool_permissions = json.dumps({"server-2": ["tool1"]}) + + result = await _resolve_team_allowed_mcp_servers(mock_perm) + assert result == {"server-1", "server-2"} + + +@pytest.mark.asyncio +@patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups", + new_callable=AsyncMock, + return_value=[], +) +async def test_resolve_team_allowed_mcp_servers_dict_tool_permissions(mock_access_groups): + """mcp_tool_permissions as a dict should work without deserialization.""" + mock_perm = MagicMock(spec=LiteLLM_ObjectPermissionTable) + mock_perm.mcp_servers = [] + mock_perm.mcp_access_groups = [] + mock_perm.mcp_tool_permissions = {"server-a": ["tool1"]} + + result = await _resolve_team_allowed_mcp_servers(mock_perm) + assert result == {"server-a"} + diff --git a/tests/test_litellm/test_chat_ui_responses_session.py b/tests/test_litellm/test_chat_ui_responses_session.py new file mode 100644 index 0000000000..09ef003ebd --- /dev/null +++ b/tests/test_litellm/test_chat_ui_responses_session.py @@ -0,0 +1,127 @@ +""" +Tests for responses API session chaining used by the chat UI. + +Verifies that: +1. previous_response_id is correctly forwarded when provided +2. Absence of previous_response_id does not break the call +3. The aresponses function signature exposes the expected parameters +""" +import inspect +import json +import os +import sys +import unittest.mock as mock + +# Use __file__ so the import path is correct regardless of the pytest working directory. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +import httpx +import pytest + +import litellm + + +class TestResponsesSessionChaining: + """Test previous_response_id session chaining for the chat UI.""" + + def test_responses_api_signature_accepts_previous_response_id(self): + """aresponses must accept previous_response_id and onResponseId-like params.""" + sig = inspect.signature(litellm.aresponses) + assert "previous_response_id" in sig.parameters, ( + "aresponses must accept previous_response_id for multi-turn session chaining" + ) + assert "input" in sig.parameters, "aresponses must accept input" + assert "model" in sig.parameters, "aresponses must accept model" + + @pytest.mark.asyncio + async def test_previous_response_id_included_in_request_body(self): + """previous_response_id must appear in the outgoing HTTP request body.""" + captured_body: dict = {} + + async def mock_send(self_transport, request: httpx.Request, **kwargs): + try: + captured_body.update(json.loads(request.content)) + except Exception: + pass + # Return a minimal valid responses API response + response_json = { + "id": "resp_test123", + "object": "response", + "model": "gpt-4o-mini", + "output": [ + { + "type": "message", + "id": "msg_001", + "role": "assistant", + "content": [{"type": "output_text", "text": "hi", "annotations": []}], + "status": "completed", + } + ], + "usage": {"input_tokens": 5, "output_tokens": 3, "total_tokens": 8}, + "status": "completed", + "created_at": 1700000000, + } + return httpx.Response( + 200, + json=response_json, + request=request, + ) + + with mock.patch("httpx.AsyncClient.send", mock_send): + try: + await litellm.aresponses( + input="hello", + model="gpt-4o-mini", + previous_response_id="resp_prev_abc", + api_key="sk-test-fake", + ) + except Exception: + pass # response parsing may fail; we only care about the outgoing body + + assert captured_body.get("previous_response_id") == "resp_prev_abc", ( + f"Expected previous_response_id in request body, got: {captured_body}" + ) + + @pytest.mark.asyncio + async def test_no_previous_response_id_omitted_from_request(self): + """When previous_response_id is None, it must not appear in the request body.""" + captured_body: dict = {} + + async def mock_send(self_transport, request: httpx.Request, **kwargs): + try: + captured_body.update(json.loads(request.content)) + except Exception: + pass + response_json = { + "id": "resp_new001", + "object": "response", + "model": "gpt-4o-mini", + "output": [ + { + "type": "message", + "id": "msg_001", + "role": "assistant", + "content": [{"type": "output_text", "text": "hi", "annotations": []}], + "status": "completed", + } + ], + "usage": {"input_tokens": 5, "output_tokens": 3, "total_tokens": 8}, + "status": "completed", + "created_at": 1700000000, + } + return httpx.Response(200, json=response_json, request=request) + + with mock.patch("httpx.AsyncClient.send", mock_send): + try: + await litellm.aresponses( + input="hello", + model="gpt-4o-mini", + previous_response_id=None, + api_key="sk-test-fake", + ) + except Exception: + pass + + assert "previous_response_id" not in captured_body, ( + "previous_response_id must be omitted from the request body when None" + ) diff --git a/ui/litellm-dashboard/public/assets/logos/figma.svg b/ui/litellm-dashboard/public/assets/logos/figma.svg new file mode 100644 index 0000000000..2d8b70457d --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/figma.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ui/litellm-dashboard/public/assets/logos/gitlab.svg b/ui/litellm-dashboard/public/assets/logos/gitlab.svg new file mode 100644 index 0000000000..18a89fa328 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/gitlab.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/ui/litellm-dashboard/public/assets/logos/gmail.svg b/ui/litellm-dashboard/public/assets/logos/gmail.svg new file mode 100644 index 0000000000..d702890620 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/gmail.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/public/assets/logos/google_drive.svg b/ui/litellm-dashboard/public/assets/logos/google_drive.svg new file mode 100644 index 0000000000..7048af9915 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/google_drive.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui/litellm-dashboard/public/assets/logos/hubspot.svg b/ui/litellm-dashboard/public/assets/logos/hubspot.svg new file mode 100644 index 0000000000..b993945ac6 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/hubspot.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/public/assets/logos/jira.svg b/ui/litellm-dashboard/public/assets/logos/jira.svg new file mode 100644 index 0000000000..fb10ca7517 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/jira.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/ui/litellm-dashboard/public/assets/logos/linear.svg b/ui/litellm-dashboard/public/assets/logos/linear.svg new file mode 100644 index 0000000000..83662a1f9f --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/linear.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/public/assets/logos/notion.svg b/ui/litellm-dashboard/public/assets/logos/notion.svg new file mode 100644 index 0000000000..170b9bb414 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/notion.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/public/assets/logos/salesforce.svg b/ui/litellm-dashboard/public/assets/logos/salesforce.svg new file mode 100644 index 0000000000..1a541a004f --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/salesforce.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/public/assets/logos/sentry.svg b/ui/litellm-dashboard/public/assets/logos/sentry.svg new file mode 100644 index 0000000000..9c3733dc43 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/sentry.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/public/assets/logos/shopify.svg b/ui/litellm-dashboard/public/assets/logos/shopify.svg new file mode 100644 index 0000000000..fcc7547269 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/shopify.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui/litellm-dashboard/public/assets/logos/slack.svg b/ui/litellm-dashboard/public/assets/logos/slack.svg new file mode 100644 index 0000000000..801de4f70c --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/slack.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui/litellm-dashboard/public/assets/logos/stripe.svg b/ui/litellm-dashboard/public/assets/logos/stripe.svg new file mode 100644 index 0000000000..ac16a6fb17 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/stripe.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/public/assets/logos/twilio.svg b/ui/litellm-dashboard/public/assets/logos/twilio.svg new file mode 100644 index 0000000000..3517a2824d --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/twilio.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/public/assets/logos/zapier.svg b/ui/litellm-dashboard/public/assets/logos/zapier.svg new file mode 100644 index 0000000000..8428ba82a5 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/zapier.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx index dbc1c4d10e..ceb14864ad 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx @@ -20,6 +20,7 @@ import { ToolOutlined, TagsOutlined, AuditOutlined, + MessageOutlined, } from "@ant-design/icons"; // import { // all_admin_roles, @@ -47,6 +48,7 @@ interface SidebarProps { interface MenuItemCfg { key: string; + newTab?: boolean; page: string; // legacy id; we map this to a path below label: string; roles?: string[]; @@ -105,6 +107,8 @@ const routeFor = (slug: string): string => { return "guardrails"; case "policies": return "policies"; + case "chat": + return "chat"; // tools case "mcp-servers": @@ -371,19 +375,29 @@ const Sidebar2: React.FC = ({ accessToken, userRole, defaultSelect }, [pathname, filteredMenuItems, defaultSelectedKey]); // ----- Navigation ----- - const goTo = (slug: string) => { + const goTo = (slug: string, newTab?: boolean) => { const href = toHref(slug); - router.push(href); + if (newTab) { + window.open(href, "_blank"); + } else { + router.push(href); + } }; // Wrap label in so every nav item supports right-click → "Open in new tab" // and Ctrl/Cmd+click to open in a new tab, while preserving SPA navigation for normal clicks. - const renderNavLink = (label: string, page: string): React.ReactNode => { + const renderNavLink = (label: string, page: string, newTab?: boolean): React.ReactNode => { const href = toHref(page); return ( { + if (newTab) { + e.stopPropagation(); + return; + } if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) { e.stopPropagation(); return; @@ -409,6 +423,8 @@ const Sidebar2: React.FC = ({ accessToken, userRole, defaultSelect style={{ transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", position: "relative", + display: "flex", + flexDirection: "column", }} > = ({ accessToken, userRole, defaultSelect borderRight: 0, backgroundColor: "transparent", fontSize: "14px", + flex: 1, + overflowY: "auto", }} items={filteredMenuItems.map((item) => ({ key: item.key, icon: item.icon, - label: renderNavLink(item.label, item.page), + label: renderNavLink(item.label, item.page, item.newTab), children: item.children?.map((child) => ({ key: child.key, icon: child.icon, - label: renderNavLink(child.label, child.page), - onClick: () => goTo(child.page), + label: renderNavLink(child.label, child.page, child.newTab), + onClick: () => goTo(child.page, child.newTab), })), - onClick: !item.children ? () => goTo(item.page) : undefined, + onClick: !item.children ? () => goTo(item.page, item.newTab) : undefined, }))} /> {isAdminRole(userRole) && !collapsed && } + + {/* Pinned "Open Chat" button at bottom */} + ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerHealth.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerHealth.ts index f81ade047e..681bf4161a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerHealth.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerHealth.ts @@ -1,4 +1,5 @@ -import { useQuery } from "@tanstack/react-query"; +import { useCallback, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { createQueryKeys } from "../common/queryKeysFactory"; import { fetchMCPServerHealth } from "@/components/networking"; import useAuthorized from "../useAuthorized"; @@ -12,11 +13,47 @@ interface MCPServerHealth { export const useMCPServerHealth = () => { const { accessToken } = useAuthorized(); - return useQuery({ + const queryClient = useQueryClient(); + const [recheckingServerIds, setRecheckingServerIds] = useState>(new Set()); + + const query = useQuery({ queryKey: mcpServerHealthKeys.lists(), queryFn: async () => await fetchMCPServerHealth(accessToken!), enabled: !!accessToken, // Refetch health status every 30 seconds to keep it up to date refetchInterval: 30000, }); + + const recheckServerHealth = useCallback(async (serverId: string) => { + if (!accessToken) return; + + setRecheckingServerIds((prev) => new Set(prev).add(serverId)); + + try { + const result: MCPServerHealth[] = await fetchMCPServerHealth(accessToken, [serverId]); + + queryClient.setQueriesData( + { queryKey: mcpServerHealthKeys.lists() }, + (oldData) => { + if (!oldData) return result; + return oldData.map((h) => { + const updated = result.find((r) => r.server_id === h.server_id); + return updated ?? h; + }); + }, + ); + } finally { + setRecheckingServerIds((prev) => { + const next = new Set(prev); + next.delete(serverId); + return next; + }); + } + }, [accessToken, queryClient]); + + return { + ...query, + recheckServerHealth, + recheckingServerIds, + }; }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServers.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServers.ts index 8746baae14..9210e25e1a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServers.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServers.ts @@ -6,11 +6,11 @@ import useAuthorized from "../useAuthorized"; const mcpServersKeys = createQueryKeys("mcpServers"); -export const useMCPServers = () => { +export const useMCPServers = (teamId?: string | null) => { const { accessToken } = useAuthorized(); return useQuery({ - queryKey: mcpServersKeys.list({}), - queryFn: async () => await fetchMCPServers(accessToken!), + queryKey: mcpServersKeys.list(teamId ? { filters: { teamId } } : undefined), + queryFn: async () => await fetchMCPServers(accessToken!, teamId), enabled: !!accessToken, }); }; diff --git a/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx b/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx index 10bf6b6192..2ff07c7065 100644 --- a/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx +++ b/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx @@ -8,6 +8,7 @@ import remarkGfm from "remark-gfm"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { coy } from "react-syntax-highlighter/dist/esm/styles/prism"; import ReasoningContent from "../playground/chat_ui/ReasoningContent"; +import MCPEventsDisplay from "../playground/chat_ui/MCPEventsDisplay"; import { ChatMessage } from "./types"; const { Panel } = Collapse; @@ -237,6 +238,8 @@ interface AssistantBubbleProps { isLastMessage: boolean; isStreaming: boolean; isTypingIndicator: boolean; + /** MCP events stored on the message — rendered inline below the response. */ + mcpEvents?: ChatMessage["mcpEvents"]; } function AssistantBubble({ @@ -244,6 +247,7 @@ function AssistantBubble({ isLastMessage, isStreaming, isTypingIndicator, + mcpEvents, }: AssistantBubbleProps) { // Ref to control ReasoningContent collapse on streaming end. // ReasoningContent manages its own expanded state; we use a key to @@ -321,6 +325,11 @@ function AssistantBubble({ + {mcpEvents && mcpEvents.length > 0 && ( +
+ +
+ )} ); } @@ -566,6 +575,7 @@ const ChatMessages: React.FC = ({ messages, isStreaming, onEditMessage }) isLastMessage={isLastMessage} isStreaming={isStreaming} isTypingIndicator={isLastMessage && isTypingIndicator} + mcpEvents={msg.mcpEvents} /> ); })} diff --git a/ui/litellm-dashboard/src/components/chat/ChatPage.tsx b/ui/litellm-dashboard/src/components/chat/ChatPage.tsx index 34473165bb..6a67814e2c 100644 --- a/ui/litellm-dashboard/src/components/chat/ChatPage.tsx +++ b/ui/litellm-dashboard/src/components/chat/ChatPage.tsx @@ -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 = ({ accessToken, userRole, userId, user const [modelSearchText, setModelSearchText] = useState(""); const [selectedMCPServers, setSelectedMCPServers] = useState([]); + const [responsesSessionId, setResponsesSessionId] = useState(null); const [isStreaming, setIsStreaming] = useState(false); const [inputText, setInputText] = useState(""); const [mcpPopoverOpen, setMcpPopoverOpen] = useState(false); @@ -162,7 +165,7 @@ const ChatPage: React.FC = ({ accessToken, userRole, userId, user createConversation, appendMessage, updateLastAssistantMessage, - truncateAfterMessage, + truncateFromMessage, deleteConversation, renameConversation, } = useChatHistory(activeConversationId); @@ -203,6 +206,12 @@ const ChatPage: React.FC = ({ accessToken, userRole, userId, user if (staleId) router.replace(getChatUrl(uiRoot)); }, [staleId, router]); + // Reset the responses session when switching between conversations so that + // previous_response_id from conversation A is never sent for conversation B. + useEffect(() => { + setResponsesSessionId(null); + }, [activeConversationId]); + const toggleModel = useCallback((model: string) => { setSelectedModels((prev) => { let next: string[]; @@ -231,6 +240,7 @@ const ChatPage: React.FC = ({ accessToken, userRole, userId, user let convId = activeConversationId; if (!convId) { convId = createConversation(model); + setResponsesSessionId(null); // new conversation starts a fresh session router.push(getChatUrl(uiRoot, convId)); } @@ -240,29 +250,56 @@ const ChatPage: React.FC = ({ accessToken, userRole, userId, user setIsStreaming(true); abortControllerRef.current = new AbortController(); - const history = [ - ...(historyOverride ?? (activeConversation?.messages ?? []) - .filter((m) => m.role === "user" || m.role === "assistant") - .map((m) => ({ - role: m.role as "user" | "assistant", - content: m.content, - }))), - { role: "user" as const, content: trimmed }, - ]; + // When historyOverride is set (edit / retry), the existing server-side + // session chain covers messages that were just truncated and is no longer + // valid for the rewritten history. Eagerly clear the session so that a + // failed/aborted edit does not leave a stale session ID that contaminates + // the next regular send. + if (historyOverride) { + setResponsesSessionId(null); + } + + // On a normal continuation turn with an active session, the Responses API + // already holds the prior context server-side, so we only pass the new + // user message (sending the full history would double-count it). + // + // On the very first turn (no session yet), we send the full history. + const previousResponseId = historyOverride ? null : responsesSessionId; + + const history: Array<{ role: "user" | "assistant"; content: string }> = + historyOverride + ? [...historyOverride, { role: "user" as const, content: trimmed }] + : previousResponseId + ? [{ role: "user" as const, content: trimmed }] + : [ + // Explicitly filter to only user/assistant roles — tool messages + // lack a required tool_call_id and would cause API errors. + ...(activeConversation?.messages ?? []) + .filter((m): m is typeof m & { role: "user" | "assistant" } => + m.role === "user" || m.role === "assistant" + ) + .map((m) => ({ role: m.role, content: m.content })), + { role: "user" as const, content: trimmed }, + ]; let accumulatedContent = ""; let accumulatedReasoning = ""; + // MCP events accumulated locally so we can persist them to the message + // without relying on component state (which would cause stale closures). + const accumulatedMCPEvents: MCPEvent[] = []; + // Track clean completion so partial events are not shown on error/abort. + let streamCompletedCleanly = false; try { - await makeOpenAIChatCompletionRequest( + await makeOpenAIResponsesRequest( history, - (chunk: string) => { + (_role: string, chunk: string) => { accumulatedContent += chunk; updateLastAssistantMessage(convId!, { content: accumulatedContent }); }, model, accessToken, - undefined, + undefined, // tags abortControllerRef.current.signal, (rc: string) => { accumulatedReasoning += rc; @@ -270,7 +307,15 @@ const ChatPage: React.FC = ({ accessToken, userRole, userId, user }, undefined, undefined, undefined, undefined, undefined, undefined, selectedMCPServers.length > 0 ? selectedMCPServers : undefined, + previousResponseId, + (id: string) => setResponsesSessionId(id), + (event: MCPEvent) => { + // Accumulate locally only — persisted once in finally to avoid + // one full localStorage write per MCP event during streaming. + accumulatedMCPEvents.push(event); + }, ); + streamCompletedCleanly = true; } catch (err: unknown) { if (err instanceof Error && err.name === "AbortError") { updateLastAssistantMessage(convId!, { @@ -282,12 +327,17 @@ const ChatPage: React.FC = ({ accessToken, userRole, userId, user }); } } finally { + // Only persist MCP events on clean completion — partial events from an + // aborted or errored turn would show incomplete tool calls to the user. + if (accumulatedMCPEvents.length > 0 && streamCompletedCleanly) { + updateLastAssistantMessage(convId!, { mcpEvents: accumulatedMCPEvents }); + } setIsStreaming(false); abortControllerRef.current = null; } }, [activeConversationId, activeConversation, selectedModels, selectedMCPServers, accessToken, - createConversation, appendMessage, updateLastAssistantMessage, router, isStreaming], + createConversation, appendMessage, updateLastAssistantMessage, router, isStreaming, responsesSessionId], ); const handleSendComparison = useCallback( @@ -355,10 +405,10 @@ const ChatPage: React.FC = ({ accessToken, userRole, userId, user const priorMessages = (idx === -1 ? msgs : msgs.slice(0, idx)) .filter((m) => m.role === "user" || m.role === "assistant") .map((m) => ({ role: m.role as "user" | "assistant", content: m.content })); - truncateAfterMessage(activeConversationId, messageId); + truncateFromMessage(activeConversationId, messageId); handleSend(newContent, priorMessages); }, - [activeConversationId, isStreaming, activeConversation, truncateAfterMessage, handleSend], + [activeConversationId, isStreaming, activeConversation, truncateFromMessage, handleSend], ); const handleSubmit = useCallback( diff --git a/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx b/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx index f6d728f788..625478e98a 100644 --- a/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx +++ b/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx @@ -170,9 +170,25 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange {/* Avatar + name + connect */}
+ {detailServer.mcp_info?.logo_url ? ( + {`${name} { + const el = e.target as HTMLImageElement; + el.style.display = "none"; + if (el.nextElementSibling) (el.nextElementSibling as HTMLElement).style.display = "flex"; + }} + /> + ) : null}
@@ -351,9 +367,26 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange onMouseEnter={(e) => { (e.currentTarget as HTMLDivElement).style.background = "#fafafa"; }} onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.background = "#fff"; }} > + {server.mcp_info?.logo_url ? ( + {`${name} { + const el = e.target as HTMLImageElement; + el.style.display = "none"; + if (el.nextElementSibling) (el.nextElementSibling as HTMLElement).style.display = "flex"; + }} + /> + ) : null}
{name.charAt(0).toUpperCase()} diff --git a/ui/litellm-dashboard/src/components/chat/MCPConnectPicker.tsx b/ui/litellm-dashboard/src/components/chat/MCPConnectPicker.tsx index 234aa8a528..a52ce18156 100644 --- a/ui/litellm-dashboard/src/components/chat/MCPConnectPicker.tsx +++ b/ui/litellm-dashboard/src/components/chat/MCPConnectPicker.tsx @@ -112,6 +112,18 @@ const MCPConnectPicker: React.FC = ({ accessToken, selectedServers, onCha gap: 12, }} > + {server.mcp_info?.logo_url && ( + {`${name} { (e.target as HTMLImageElement).style.display = "none"; }} + /> + )}
; toolResult?: string; diff --git a/ui/litellm-dashboard/src/components/chat/useChatHistory.ts b/ui/litellm-dashboard/src/components/chat/useChatHistory.ts index 1f27a15b39..b0a42d82de 100644 --- a/ui/litellm-dashboard/src/components/chat/useChatHistory.ts +++ b/ui/litellm-dashboard/src/components/chat/useChatHistory.ts @@ -51,8 +51,9 @@ export function useChatHistory(activeConversationId: string | null): { staleId: boolean; createConversation: (model: string) => string; appendMessage: (conversationId: string, message: Omit) => void; - updateLastAssistantMessage: (conversationId: string, updates: Partial>) => void; - truncateAfterMessage: (conversationId: string, messageId: string) => void; + updateLastAssistantMessage: (conversationId: string, updates: Partial>) => void; + /** Remove the message with `messageId` and all subsequent messages from the conversation. */ + truncateFromMessage: (conversationId: string, messageId: string) => void; deleteConversation: (id: string) => void; renameConversation: (id: string, newTitle: string) => void; setActiveConversationId: (id: string | null) => void; @@ -148,7 +149,7 @@ export function useChatHistory(activeConversationId: string | null): { const updateLastAssistantMessage = useCallback( ( conversationId: string, - updates: Partial>, + updates: Partial>, ) => { setConversations((prev) => { const updated = prev.map((conv) => { @@ -168,7 +169,7 @@ export function useChatHistory(activeConversationId: string | null): { [], ); - const truncateAfterMessage = useCallback( + const truncateFromMessage = useCallback( (conversationId: string, messageId: string) => { setConversations((prev) => { const updated = prev.map((conv) => { @@ -222,7 +223,7 @@ export function useChatHistory(activeConversationId: string | null): { createConversation, appendMessage, updateLastAssistantMessage, - truncateAfterMessage, + truncateFromMessage, deleteConversation, renameConversation, setActiveConversationId, diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx b/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx index d94a80e502..dc4ed25786 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx +++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx @@ -13,6 +13,7 @@ interface MCPServerSelectorProps { accessToken: string; placeholder?: string; disabled?: boolean; + teamId?: string | null; } const MCPServerSelector: React.FC = ({ @@ -22,8 +23,9 @@ const MCPServerSelector: React.FC = ({ accessToken, placeholder = "Select MCP servers", disabled = false, + teamId, }) => { - const { data: mcpServers = [], isLoading: serversLoading } = useMCPServers(); + const { data: mcpServers = [], isLoading: serversLoading } = useMCPServers(teamId); const { data: accessGroups = [], isLoading: groupsLoading } = useMCPAccessGroups(); const loading = serversLoading || groupsLoading; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPLogoSelector.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPLogoSelector.tsx new file mode 100644 index 0000000000..be05d74ec2 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPLogoSelector.tsx @@ -0,0 +1,123 @@ +import React, { useState } from "react"; +import { Input, Tooltip } from "antd"; +import { InfoCircleOutlined, LinkOutlined } from "@ant-design/icons"; + +const logos = "/ui/assets/logos/"; + +const WELL_KNOWN_LOGOS: { name: string; url: string }[] = [ + { name: "GitHub", url: `${logos}github.svg` }, + { name: "Slack", url: `${logos}slack.svg` }, + { name: "Notion", url: `${logos}notion.svg` }, + { name: "Linear", url: `${logos}linear.svg` }, + { name: "Jira", url: `${logos}jira.svg` }, + { name: "Figma", url: `${logos}figma.svg` }, + { name: "Gmail", url: `${logos}gmail.svg` }, + { name: "Google Drive", url: `${logos}google_drive.svg` }, + { name: "Stripe", url: `${logos}stripe.svg` }, + { name: "Shopify", url: `${logos}shopify.svg` }, + { name: "Salesforce", url: `${logos}salesforce.svg` }, + { name: "HubSpot", url: `${logos}hubspot.svg` }, + { name: "Twilio", url: `${logos}twilio.svg` }, + { name: "Cloudflare", url: `${logos}cloudflare.svg` }, + { name: "Sentry", url: `${logos}sentry.svg` }, + { name: "PostgreSQL", url: `${logos}postgresql.svg` }, + { name: "Snowflake", url: `${logos}snowflake.svg` }, + { name: "Zapier", url: `${logos}zapier.svg` }, + { name: "Google", url: `${logos}google.svg` }, + { name: "GitLab", url: `${logos}gitlab.svg` }, +]; + +interface MCPLogoSelectorProps { + value?: string; + onChange?: (url: string | undefined) => void; +} + +const MCPLogoSelector: React.FC = ({ value, onChange }) => { + const [imgErrors, setImgErrors] = useState>(new Set()); + + const handleSelect = (url: string) => { + onChange?.(value === url ? undefined : url); + }; + + const handleImgError = (url: string) => { + setImgErrors((prev) => new Set(prev).add(url)); + }; + + return ( +
+
+ Logo + + + +
+ + {/* Preview */} + {value && ( +
+ Selected logo { (e.target as HTMLImageElement).style.display = "none"; }} + /> +
+
{value}
+
+ +
+ )} + + {/* Well-known logo grid */} +
+ {WELL_KNOWN_LOGOS.map((logo) => { + const isSelected = value === logo.url; + const hasFailed = imgErrors.has(logo.url); + if (hasFailed) return null; + return ( + + + + ); + })} +
+ + {/* Custom URL input */} + } + placeholder="Or paste a custom logo URL..." + value={value && !WELL_KNOWN_LOGOS.some((l) => l.url === value) ? value : ""} + onChange={(e) => { + const v = e.target.value.trim(); + onChange?.(v || undefined); + }} + className="rounded-lg" + size="small" + /> +
+ ); +}; + +export default MCPLogoSelector; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx b/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx index 23aae6cb14..b25e1dcadd 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx @@ -12,6 +12,8 @@ interface OpenAPIFormSectionProps { onValuesChange: (updates: Record) => void; /** Called when key tools change (from registry preset selection). */ onKeyToolsChange?: (tools: OpenAPIKeyTool[]) => void; + /** Called when a preset is selected so the parent can set the logo URL from icon_url. */ + onLogoUrlChange?: (url: string | undefined) => void; /** Called when the OAuth docs URL changes (e.g. link to create a GitHub OAuth App). */ onOAuthDocsUrlChange?: (url: string | null) => void; } @@ -26,6 +28,7 @@ const OpenAPIFormSection: React.FC = ({ accessToken, onValuesChange, onKeyToolsChange, + onLogoUrlChange, onOAuthDocsUrlChange, }) => { const [selectedPreset, setSelectedPreset] = useState(null); @@ -33,6 +36,7 @@ const OpenAPIFormSection: React.FC = ({ const handlePresetSelect = (entry: OpenAPIRegistryEntry) => { setSelectedPreset(entry.name); onKeyToolsChange?.(entry.key_tools ?? []); + onLogoUrlChange?.(entry.icon_url || undefined); const updates: Record = { spec_path: entry.spec_url, }; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index 90ecd4731c..74945731a2 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -11,6 +11,7 @@ import MCPToolConfiguration from "./mcp_tool_configuration"; import StdioConfiguration from "./StdioConfiguration"; import MCPPermissionManagement from "./MCPPermissionManagement"; import OpenAPIFormSection, { OpenAPIKeyTool } from "./OpenAPIFormSection"; +import MCPLogoSelector from "./MCPLogoSelector"; import { isAdminRole } from "@/utils/roles"; import { validateMCPServerUrl, validateMCPServerName } from "./utils"; import NotificationsManager from "../molecules/notifications_manager"; @@ -70,6 +71,7 @@ const CreateMCPServer: React.FC = ({ const [keyTools, setKeyTools] = useState([]); const [searchValue, setSearchValue] = useState(""); const [oauthAccessToken, setOauthAccessToken] = useState(null); + const [logoUrl, setLogoUrl] = useState(undefined); const [oauthDocsUrl, setOauthDocsUrl] = useState(null); // Single hook call shared by MCPConnectionStatus and MCPToolConfiguration to avoid duplicate requests. @@ -101,6 +103,7 @@ const CreateMCPServer: React.FC = ({ allowedTools, searchValue, aliasManuallyEdited, + logoUrl, }), ); } catch (err) { @@ -202,6 +205,9 @@ const CreateMCPServer: React.FC = ({ if (typeof parsed.aliasManuallyEdited === "boolean") { setAliasManuallyEdited(parsed.aliasManuallyEdited); } + if (parsed.logoUrl) { + setLogoUrl(parsed.logoUrl); + } } catch (err) { console.error("Failed to restore MCP create state", err); } finally { @@ -357,6 +363,7 @@ const CreateMCPServer: React.FC = ({ mcp_info: { server_name: restValues.server_name || restValues.url, description: restValues.description, + logo_url: logoUrl || undefined, mcp_server_cost_info: Object.keys(costConfig).length > 0 ? costConfig : null, }, mcp_access_groups: accessGroups, @@ -394,6 +401,7 @@ const CreateMCPServer: React.FC = ({ clearTools(); setAllowedTools([]); setAliasManuallyEdited(false); + setLogoUrl(undefined); setModalVisible(false); onCreateSuccess(response); } @@ -414,6 +422,7 @@ const CreateMCPServer: React.FC = ({ clearTools(); setAllowedTools([]); setAliasManuallyEdited(false); + setLogoUrl(undefined); setModalVisible(false); }; @@ -590,6 +599,8 @@ const CreateMCPServer: React.FC = ({ /> + + GitHub / Source URL} name="source_url" @@ -645,6 +656,7 @@ const CreateMCPServer: React.FC = ({ setFormValues((prev) => ({ ...prev, ...updates })) } onKeyToolsChange={setKeyTools} + onLogoUrlChange={setLogoUrl} onOAuthDocsUrlChange={setOauthDocsUrl} /> )} diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx index 8130f96856..ea5ccf1c84 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { ColumnDef } from "@tanstack/react-table"; import { MCPServer } from "./types"; import { Icon } from "@tremor/react"; @@ -6,6 +7,82 @@ import { getMaskedAndFullUrl } from "./utils"; import { Tooltip } from "antd"; import { CheckOutlined } from "@ant-design/icons"; +const HealthStatusBadge: React.FC<{ + server: MCPServer; + isLoadingHealth?: boolean; + isRechecking?: boolean; + onRecheck?: (serverId: string) => void; +}> = ({ server, isLoadingHealth, isRechecking, onRecheck }) => { + const [isHovered, setIsHovered] = useState(false); + const status = server.status || "unknown"; + const lastCheck = server.last_health_check; + const error = server.health_check_error; + + if (isLoadingHealth || isRechecking) { + return ( + + + Checking + + ); + } + + const getStatusColor = (status: string) => { + switch (status) { + case "healthy": + return "text-green-700 bg-green-50 border border-green-200"; + case "unhealthy": + return "text-red-700 bg-red-50 border border-red-200"; + default: + return "text-gray-600 bg-gray-50 border border-gray-200"; + } + }; + + const getStatusIcon = (status: string) => { + switch (status) { + case "healthy": + return "✓"; + case "unhealthy": + return "✗"; + default: + return "?"; + } + }; + + const isClickable = !!onRecheck; + + const tooltipContent = ( +
+
Health Status: {status}
+ {lastCheck &&
Last Check: {new Date(lastCheck).toLocaleString()}
} + {error && ( +
+
Error:
+
{error}
+
+ )} + {!lastCheck && !error &&
No health check data available
} + {isClickable &&
Click to recheck
} +
+ ); + + return ( + + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={isClickable ? () => onRecheck(server.server_id) : undefined} + > + {isHovered && isClickable ? "↻" : getStatusIcon(status)} + {isHovered && isClickable + ? "Recheck" + : status.charAt(0).toUpperCase() + status.slice(1)} + + + ); +}; + export const mcpServerColumns = ( userRole: string, onView: (serverId: string) => void, @@ -13,6 +90,8 @@ export const mcpServerColumns = ( onDelete: (serverId: string) => void, isLoadingHealth?: boolean, onByokConnect?: (server: MCPServer) => void, + onRecheckHealth?: (serverId: string) => void, + recheckingServerIds?: Set, ): ColumnDef[] => [ { accessorKey: "server_id", @@ -31,6 +110,23 @@ export const mcpServerColumns = ( accessorKey: "server_name", header: "Name", enableSorting: true, + cell: ({ row }) => { + const logoUrl = row.original.mcp_info?.logo_url; + const name = row.original.server_name; + return ( +
+ {logoUrl ? ( + {`${name { (e.target as HTMLImageElement).style.display = "none"; }} + /> + ) : null} + {name} +
+ ); + }, }, { accessorKey: "alias", @@ -81,68 +177,14 @@ export const mcpServerColumns = ( { id: "health_status", header: "Health Status", - cell: ({ row }) => { - const server = row.original; - const status = server.status || "unknown"; - const lastCheck = server.last_health_check; - const error = server.health_check_error; - - if (isLoadingHealth) { - return ( - - - Checking - - ); - } - - const getStatusColor = (status: string) => { - switch (status) { - case "healthy": - return "text-green-700 bg-green-50 border border-green-200"; - case "unhealthy": - return "text-red-700 bg-red-50 border border-red-200"; - default: - return "text-gray-600 bg-gray-50 border border-gray-200"; - } - }; - - const getStatusIcon = (status: string) => { - switch (status) { - case "healthy": - return "✓"; - case "unhealthy": - return "✗"; - default: - return "?"; - } - }; - - const tooltipContent = ( -
-
Health Status: {status}
- {lastCheck &&
Last Check: {new Date(lastCheck).toLocaleString()}
} - {error && ( -
-
Error:
-
{error}
-
- )} - {!lastCheck && !error &&
No health check data available
} -
- ); - - return ( - - - {getStatusIcon(status)} - {status.charAt(0).toUpperCase() + status.slice(1)} - - - ); - }, + cell: ({ row }) => ( + + ), }, { id: "mcp_access_groups", diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index fc55542a0c..eadf93d8a9 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -8,6 +8,7 @@ import MCPServerCostConfig from "./mcp_server_cost_config"; import MCPPermissionManagement from "./MCPPermissionManagement"; import MCPToolConfiguration from "./mcp_tool_configuration"; import StdioConfiguration from "./StdioConfiguration"; +import MCPLogoSelector from "./MCPLogoSelector"; import { validateMCPServerUrl, validateMCPServerName } from "./utils"; import NotificationsManager from "../molecules/notifications_manager"; import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow"; @@ -41,6 +42,7 @@ const MCPServerEdit: React.FC = ({ const [toolNameToDisplayName, setToolNameToDisplayName] = useState>({}); const [toolNameToDescription, setToolNameToDescription] = useState>({}); const [pendingRestoredValues, setPendingRestoredValues] = useState | null>(null); + const [logoUrl, setLogoUrl] = useState(mcpServer.mcp_info?.logo_url || undefined); const authType = Form.useWatch("auth_type", form) as string | undefined; const transportType = Form.useWatch("transport", form) as string | undefined; const isStdioTransport = transportType === "stdio"; @@ -538,6 +540,7 @@ const MCPServerEdit: React.FC = ({ mcp_info: { server_name: mcpInfoServerName, description: restValues.description, + logo_url: logoUrl || undefined, mcp_server_cost_info: Object.keys(costConfig).length > 0 ? costConfig : null, }, mcp_access_groups: accessGroups, @@ -604,6 +607,7 @@ const MCPServerEdit: React.FC = ({ +