[Docs] Fix "Page Not Found" link for Anthropic endpoint (#23349)
* 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: <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 <noreply@anthropic.com> * Rename 'Team-Based Guardrails' to 'Team Bring-Your-Own Guardrails' (#23307) Co-authored-by: Cursor Agent <cursoragent@cursor.com> * 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 <ishaan-jaff@users.noreply.github.com> * 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 <ishaan-jaff@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * Fix mcp_tool_permissions JSON string deserialization in _resolve_team_allowed_mcp_servers Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * [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 <noreply@anthropic.com> * 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 <ywsong.dev@kakao.com> Co-authored-by: yuneng-jiang <yuneng.jiang@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Krish Dholakia <krrishdholakia@gmail.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Ishaan Jaff <ishaanjaffer0324@gmail.com> Co-authored-by: Sameer Kankute <sameer@berri.ai> Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com> Co-authored-by: Joe Reyna <joseph.reyna@gmail.com>
@@ -34,8 +34,6 @@ jobs:
|
||||
build-mode: none
|
||||
- language: python
|
||||
build-mode: none
|
||||
- language: ruby
|
||||
build-mode: none
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M8 24c2.208 0 4-1.792 4-4v-4H8c-2.208 0-4 1.792-4 4s1.792 4 4 4z" fill="#0ACF83"/>
|
||||
<path d="M4 12c0-2.208 1.792-4 4-4h4v8H8c-2.208 0-4-1.792-4-4z" fill="#A259FF"/>
|
||||
<path d="M4 4c0-2.208 1.792-4 4-4h4v8H8C5.792 8 4 6.208 4 4z" fill="#F24E1E"/>
|
||||
<path d="M12 0h4c2.208 0 4 1.792 4 4s-1.792 4-4 4h-4V0z" fill="#FF7262"/>
|
||||
<path d="M20 12c0 2.208-1.792 4-4 4s-4-1.792-4-4 1.792-4 4-4 4 1.792 4 4z" fill="#1ABCFE"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M23.955 13.587l-1.342-4.135-2.664-8.189a.455.455 0 0 0-.867 0L16.418 9.45H7.582L4.918 1.263a.455.455 0 0 0-.867 0L1.386 9.452.044 13.587a.924.924 0 0 0 .331 1.03L12 23.054l11.625-8.436a.92.92 0 0 0 .33-1.03z" fill="#E24329"/>
|
||||
<path d="M12 23.054L16.418 9.45H7.582L12 23.054z" fill="#FC6D26"/>
|
||||
<path d="M12 23.054L7.582 9.452H1.386L12 23.054z" fill="#FCA326"/>
|
||||
<path d="M1.386 9.452L.044 13.587a.924.924 0 0 0 .331 1.03L12 23.054 1.386 9.452z" fill="#E24329"/>
|
||||
<path d="M12 23.054l4.418-13.602h5.036L12 23.054z" fill="#FCA326"/>
|
||||
<path d="M22.614 9.452l1.341 4.135a.924.924 0 0 1-.33 1.03L12 23.054l10.614-13.602z" fill="#E24329"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 719 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M24 5.457v13.909c0 .904-.732 1.636-1.636 1.636h-3.819V11.73L12 16.64l-6.545-4.91v9.273H1.636A1.636 1.636 0 0 1 0 19.366V5.457c0-2.023 2.309-3.178 3.927-1.964L5.455 4.64 12 9.548l6.545-4.91 1.528-1.145C21.69 2.28 24 3.434 24 5.457z" fill="#EA4335"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 328 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M7.71 14.29L2 22h7.65l5.71-7.71H7.71z" fill="#0066DA"/>
|
||||
<path d="M22 22l-5.71-7.71H8.65L14.35 22H22z" fill="#00AC47"/>
|
||||
<path d="M8.16 2L2.45 14.29h7.65L15.81 2H8.16z" fill="#FFBA00"/>
|
||||
<path d="M15.84 2l-5.71 12.29h7.65L23.49 2H15.84z" fill="#EA4335"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 337 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M18.164 7.93V5.084a2.198 2.198 0 0 0 1.267-1.984v-.066A2.2 2.2 0 0 0 17.237.84h-.066a2.2 2.2 0 0 0-2.194 2.194v.066c0 .844.48 1.574 1.18 1.94V7.93a6.152 6.152 0 0 0-2.866 1.388L5.962 3.72a2.385 2.385 0 0 0 .07-.557A2.37 2.37 0 0 0 3.662.793a2.37 2.37 0 0 0-2.37 2.37 2.37 2.37 0 0 0 2.37 2.37c.432 0 .836-.12 1.183-.325l7.47 5.5A6.175 6.175 0 0 0 11.19 14.1a6.2 6.2 0 0 0 1.097 3.504l-2.12 2.12a1.786 1.786 0 0 0-.52-.082A1.803 1.803 0 1 0 11.45 21.445l2.172-2.172a6.175 6.175 0 0 0 3.572 1.135 6.2 6.2 0 1 0 .97-12.478zM17.204 17.2a3.004 3.004 0 1 1 0-6.008 3.004 3.004 0 0 1 0 6.008z" fill="#FF7A59"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 683 B |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M11.571 11.513H0a5.218 5.218 0 0 0 5.232 5.215h2.13v2.057A5.215 5.215 0 0 0 12.575 24V12.518a1.005 1.005 0 0 0-1.005-1.005z" fill="#2684FF"/>
|
||||
<path d="M6.262 6.259H17.793a5.218 5.218 0 0 0-5.232-5.214H6.26A5.218 5.218 0 0 0 1.03 6.259v6.268a1.005 1.005 0 0 0 1.005 1.005h9.527V8.318a2.06 2.06 0 0 0-2.06-2.059H6.262z" fill="url(#jiraGrad1)"/>
|
||||
<path d="M17.53 6.259a5.218 5.218 0 0 1 5.232 5.214v6.268a1.005 1.005 0 0 1-1.005 1.005H12.23v-5.214a2.06 2.06 0 0 1 2.06-2.059h3.24V6.259z" fill="url(#jiraGrad2)"/>
|
||||
<defs>
|
||||
<linearGradient id="jiraGrad1" x1="6" y1="1" x2="1" y2="13">
|
||||
<stop stop-color="#0052CC"/>
|
||||
<stop offset="1" stop-color="#2684FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="jiraGrad2" x1="18" y1="6" x2="23" y2="18">
|
||||
<stop stop-color="#0052CC"/>
|
||||
<stop offset="1" stop-color="#2684FF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 949 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M2.886 10.449a.64.64 0 0 1 .078.848L1.337 13.56a.32.32 0 0 1-.526-.036A11.044 11.044 0 0 1 .103 11.53a.32.32 0 0 1 .238-.37l2.186-.535a.64.64 0 0 1 .36-.176zm-.803 3.753a.32.32 0 0 0-.492.097 11.127 11.127 0 0 0-.747 2.148.32.32 0 0 0 .108.331l1.638 1.319a.32.32 0 0 0 .476-.07l1.18-1.87a.64.64 0 0 0-.033-.733L2.083 14.2zm.79 5.275a.32.32 0 0 0-.42.02l-.367.354a.32.32 0 0 0-.022.43 11.18 11.18 0 0 0 2.854 2.67.32.32 0 0 0 .42-.044l.404-.431a.32.32 0 0 0-.006-.445L2.873 19.477zM7.55 22.553a.32.32 0 0 0 .014.458l.28.232a.32.32 0 0 0 .427-.023 11.2 11.2 0 0 0 3.084-4.807.32.32 0 0 0-.19-.394l-.672-.25a.32.32 0 0 0-.405.18A9.614 9.614 0 0 1 7.55 22.553zM23.988 12c0 6.627-5.373 12-12 12-.742 0-1.47-.067-2.176-.197a.32.32 0 0 1-.218-.494L22.006 8.523a.32.32 0 0 1 .552.108c.283.882.43 1.818.43 2.79V12zm-.836-4.488a.32.32 0 0 0-.56-.05L10.163 22.68a.32.32 0 0 0 .096.472c.83.43 1.73.74 2.679.914a.32.32 0 0 0 .333-.14L23.195 8.204a.32.32 0 0 0-.043-.412v-.28zM20.684 5.216a.32.32 0 0 0 .484.013l.257-.269a.32.32 0 0 0 .017-.427A11.955 11.955 0 0 0 12 .007C5.373.007 0 5.38 0 12.007c0 .414.021.824.063 1.229a.32.32 0 0 0 .547.197l18.13-20.07a.32.32 0 0 1 .453-.014l1.49 1.368v.5z" fill="#5E6AD2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M4.459 4.208c.746.606 1.026.56 2.428.466l13.215-.793c.28 0 .047-.28-.046-.326L18.57 2.33c-.42-.326-.98-.7-2.055-.607L3.62 2.79c-.466.046-.56.28-.374.466l1.213.952zm.793 3.08v13.904c0 .747.373 1.027 1.214.98l14.523-.84c.84-.046.933-.56.933-1.167V6.354c0-.606-.233-.933-.746-.886l-15.177.886c-.56.047-.747.327-.747.934zm14.337.745c.093.42 0 .84-.42.888l-.7.14v10.264c-.608.327-1.168.514-1.635.514-.747 0-.933-.234-1.494-.934l-4.577-7.186v6.952l1.448.327s0 .84-1.168.84l-3.222.186c-.093-.186 0-.653.327-.746l.84-.233V9.854L7.822 9.76c-.094-.42.14-1.026.793-1.073l3.456-.233 4.764 7.279v-6.44l-1.215-.14c-.093-.513.28-.886.747-.933l3.222-.187zM2.1 1.424L15.856.466c1.68-.14 2.1.093 2.8.606l3.876 2.753c.467.326.607.42.607.793v16.844c0 1.026-.374 1.633-1.68 1.726l-15.457.933c-.98.047-1.448-.093-1.962-.747l-3.13-4.06c-.56-.747-.793-1.306-.793-1.96V2.917c0-.84.374-1.54 1.782-1.493z" fill="#000000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 975 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M10.006 5.15a4.678 4.678 0 0 1 3.358-1.426 4.7 4.7 0 0 1 4.273 2.775 5.476 5.476 0 0 1 2.163-.444C22.108 6.055 24 7.954 24 10.288a4.258 4.258 0 0 1-3.8 4.225h-.2a3.756 3.756 0 0 1-3.468 2.308 3.726 3.726 0 0 1-1.76-.44 4.418 4.418 0 0 1-3.89 2.32 4.418 4.418 0 0 1-3.798-2.15 3.678 3.678 0 0 1-.844.098A3.68 3.68 0 0 1 2.56 12.97c0-.77.237-1.484.642-2.074A4.448 4.448 0 0 1 0 7.2a4.448 4.448 0 0 1 4.448-4.448c1.11 0 2.13.41 2.91 1.086A4.672 4.672 0 0 1 10.006 5.15z" fill="#00A1E0"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 564 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M13.91 2.505c-.873-1.553-3.066-1.553-3.94 0L7.092 7.67a10.783 10.783 0 0 1 4.376 3.033l1.72-3.027a4.23 4.23 0 0 0-.207-.257L13.91 2.505zM3.6 20.3h2.685a8.782 8.782 0 0 0-.094-5.725l-2.59 4.613c-.25.445.109 1.003.613 1.003L3.6 20.3zm16.595.108c.5 0 .862-.557.613-1.003L14.075 7.5l-1.72 3.027A8.782 8.782 0 0 1 17.35 20.3h2.845v.108z" fill="#362D59"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 429 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M15.337 23.979l7.216-1.561s-2.604-17.613-2.625-17.73c-.018-.116-.114-.2-.2-.2s-1.848-.138-1.848-.138-1.225-1.197-1.363-1.338c-.04-.04-.085-.06-.132-.074l-.793 18.867 -.255.174zm-3.07-16.938s-.718-.378-1.594-.378c-1.29 0-1.353.809-1.353 1.013 0 1.112 2.9 1.538 2.9 4.145 0 2.051-1.3 3.37-3.053 3.37-2.105 0-3.18-1.31-3.18-1.31l.564-1.865s1.105.949 2.036.949c.607 0 .856-.479.856-.829 0-1.453-2.38-1.519-2.38-3.906 0-2.008 1.441-3.953 4.351-3.953 1.12 0 1.674.321 1.674.321l-.82 2.443z" fill="#95BF47"/>
|
||||
<path d="M14.998 6.268c-.082-.025-.18-.048-.252-.048-.028 0-.062.002-.092.006l-.76 18.07.663-.143 3.377-23.182c-.138.14-1.363 1.337-1.363 1.337s-.95-.093-1.573-.04z" fill="#5E8E3E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 766 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z" fill="#E01E5A"/>
|
||||
<path d="M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312z" fill="#36C5F0"/>
|
||||
<path d="M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.27 0a2.528 2.528 0 0 1-2.522 2.521 2.527 2.527 0 0 1-2.521-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.522 2.522v6.312z" fill="#2EB67D"/>
|
||||
<path d="M15.165 18.956a2.528 2.528 0 0 1 2.522 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.521-2.522v-2.522h2.521zm0-1.27a2.527 2.527 0 0 1-2.521-2.522 2.527 2.527 0 0 1 2.521-2.521h6.313A2.528 2.528 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.521h-6.313z" fill="#ECB22E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M13.976 9.15c-2.172-.806-3.356-1.426-3.356-2.409 0-.831.683-1.305 1.901-1.305 2.227 0 4.515.858 6.09 1.631l.89-5.494C18.252.975 15.697 0 12.165 0 9.667 0 7.589.654 6.104 1.872 4.56 3.147 3.757 4.992 3.757 7.218c0 4.039 2.467 5.76 6.476 7.219 2.585.92 3.445 1.574 3.445 2.583 0 .98-.84 1.545-2.354 1.545-1.875 0-4.965-.921-6.99-2.109l-.9 5.555C5.175 22.99 8.385 24 11.714 24c2.641 0 4.843-.624 6.328-1.813 1.664-1.305 2.525-3.236 2.525-5.732 0-4.128-2.524-5.851-6.591-7.305z" fill="#635BFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 571 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M12 0C5.381 0 0 5.381 0 12s5.381 12 12 12 12-5.381 12-12S18.619 0 12 0zm0 20.4c-4.636 0-8.4-3.764-8.4-8.4S7.364 3.6 12 3.6s8.4 3.764 8.4 8.4-3.764 8.4-8.4 8.4zm3.6-10.8c0 .993-.807 1.8-1.8 1.8s-1.8-.807-1.8-1.8.807-1.8 1.8-1.8 1.8.807 1.8 1.8zm0 4.8c0 .993-.807 1.8-1.8 1.8s-1.8-.807-1.8-1.8.807-1.8 1.8-1.8 1.8.807 1.8 1.8zm-4.8-4.8c0 .993-.807 1.8-1.8 1.8s-1.8-.807-1.8-1.8.807-1.8 1.8-1.8 1.8.807 1.8 1.8zm0 4.8c0 .993-.807 1.8-1.8 1.8s-1.8-.807-1.8-1.8.807-1.8 1.8-1.8 1.8.807 1.8 1.8z" fill="#F22F46"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 587 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M15.535 8.465l-1.263 1.264a4.502 4.502 0 0 0-4.544 0L8.465 8.465a6.51 6.51 0 0 1 2.534-1.796V4.5h2v2.17a6.51 6.51 0 0 1 2.536 1.795zM12 10.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm3.535 5.035l1.263-1.264a6.51 6.51 0 0 1-1.796 2.536V19h-2v-2.17a6.51 6.51 0 0 1-2.534-1.795l1.263-1.264a4.502 4.502 0 0 0 4.544 0h-.74zM19 11h-2.17a6.51 6.51 0 0 1-1.795 2.534l1.264 1.263a4.502 4.502 0 0 0 0-4.544l-1.264 1.263A6.51 6.51 0 0 1 16.831 11H19v2zm-12.17 2H5v-2h2.17a6.51 6.51 0 0 1 1.795-2.534L7.7 7.203a4.502 4.502 0 0 0 0 4.544l1.264-1.263A6.51 6.51 0 0 1 7.17 13H6.83z" fill="#FF4A00"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 659 B |
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
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},
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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: <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
|
||||
|
||||
@@ -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") == {}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M8 24c2.208 0 4-1.792 4-4v-4H8c-2.208 0-4 1.792-4 4s1.792 4 4 4z" fill="#0ACF83"/>
|
||||
<path d="M4 12c0-2.208 1.792-4 4-4h4v8H8c-2.208 0-4-1.792-4-4z" fill="#A259FF"/>
|
||||
<path d="M4 4c0-2.208 1.792-4 4-4h4v8H8C5.792 8 4 6.208 4 4z" fill="#F24E1E"/>
|
||||
<path d="M12 0h4c2.208 0 4 1.792 4 4s-1.792 4-4 4h-4V0z" fill="#FF7262"/>
|
||||
<path d="M20 12c0 2.208-1.792 4-4 4s-4-1.792-4-4 1.792-4 4-4 4 1.792 4 4z" fill="#1ABCFE"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M23.955 13.587l-1.342-4.135-2.664-8.189a.455.455 0 0 0-.867 0L16.418 9.45H7.582L4.918 1.263a.455.455 0 0 0-.867 0L1.386 9.452.044 13.587a.924.924 0 0 0 .331 1.03L12 23.054l11.625-8.436a.92.92 0 0 0 .33-1.03z" fill="#E24329"/>
|
||||
<path d="M12 23.054L16.418 9.45H7.582L12 23.054z" fill="#FC6D26"/>
|
||||
<path d="M12 23.054L7.582 9.452H1.386L12 23.054z" fill="#FCA326"/>
|
||||
<path d="M1.386 9.452L.044 13.587a.924.924 0 0 0 .331 1.03L12 23.054 1.386 9.452z" fill="#E24329"/>
|
||||
<path d="M12 23.054l4.418-13.602h5.036L12 23.054z" fill="#FCA326"/>
|
||||
<path d="M22.614 9.452l1.341 4.135a.924.924 0 0 1-.33 1.03L12 23.054l10.614-13.602z" fill="#E24329"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 719 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M24 5.457v13.909c0 .904-.732 1.636-1.636 1.636h-3.819V11.73L12 16.64l-6.545-4.91v9.273H1.636A1.636 1.636 0 0 1 0 19.366V5.457c0-2.023 2.309-3.178 3.927-1.964L5.455 4.64 12 9.548l6.545-4.91 1.528-1.145C21.69 2.28 24 3.434 24 5.457z" fill="#EA4335"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 328 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M7.71 14.29L2 22h7.65l5.71-7.71H7.71z" fill="#0066DA"/>
|
||||
<path d="M22 22l-5.71-7.71H8.65L14.35 22H22z" fill="#00AC47"/>
|
||||
<path d="M8.16 2L2.45 14.29h7.65L15.81 2H8.16z" fill="#FFBA00"/>
|
||||
<path d="M15.84 2l-5.71 12.29h7.65L23.49 2H15.84z" fill="#EA4335"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 337 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M18.164 7.93V5.084a2.198 2.198 0 0 0 1.267-1.984v-.066A2.2 2.2 0 0 0 17.237.84h-.066a2.2 2.2 0 0 0-2.194 2.194v.066c0 .844.48 1.574 1.18 1.94V7.93a6.152 6.152 0 0 0-2.866 1.388L5.962 3.72a2.385 2.385 0 0 0 .07-.557A2.37 2.37 0 0 0 3.662.793a2.37 2.37 0 0 0-2.37 2.37 2.37 2.37 0 0 0 2.37 2.37c.432 0 .836-.12 1.183-.325l7.47 5.5A6.175 6.175 0 0 0 11.19 14.1a6.2 6.2 0 0 0 1.097 3.504l-2.12 2.12a1.786 1.786 0 0 0-.52-.082A1.803 1.803 0 1 0 11.45 21.445l2.172-2.172a6.175 6.175 0 0 0 3.572 1.135 6.2 6.2 0 1 0 .97-12.478zM17.204 17.2a3.004 3.004 0 1 1 0-6.008 3.004 3.004 0 0 1 0 6.008z" fill="#FF7A59"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 683 B |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M11.571 11.513H0a5.218 5.218 0 0 0 5.232 5.215h2.13v2.057A5.215 5.215 0 0 0 12.575 24V12.518a1.005 1.005 0 0 0-1.005-1.005z" fill="#2684FF"/>
|
||||
<path d="M6.262 6.259H17.793a5.218 5.218 0 0 0-5.232-5.214H6.26A5.218 5.218 0 0 0 1.03 6.259v6.268a1.005 1.005 0 0 0 1.005 1.005h9.527V8.318a2.06 2.06 0 0 0-2.06-2.059H6.262z" fill="url(#jiraGrad1)"/>
|
||||
<path d="M17.53 6.259a5.218 5.218 0 0 1 5.232 5.214v6.268a1.005 1.005 0 0 1-1.005 1.005H12.23v-5.214a2.06 2.06 0 0 1 2.06-2.059h3.24V6.259z" fill="url(#jiraGrad2)"/>
|
||||
<defs>
|
||||
<linearGradient id="jiraGrad1" x1="6" y1="1" x2="1" y2="13">
|
||||
<stop stop-color="#0052CC"/>
|
||||
<stop offset="1" stop-color="#2684FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="jiraGrad2" x1="18" y1="6" x2="23" y2="18">
|
||||
<stop stop-color="#0052CC"/>
|
||||
<stop offset="1" stop-color="#2684FF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 949 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M2.886 10.449a.64.64 0 0 1 .078.848L1.337 13.56a.32.32 0 0 1-.526-.036A11.044 11.044 0 0 1 .103 11.53a.32.32 0 0 1 .238-.37l2.186-.535a.64.64 0 0 1 .36-.176zm-.803 3.753a.32.32 0 0 0-.492.097 11.127 11.127 0 0 0-.747 2.148.32.32 0 0 0 .108.331l1.638 1.319a.32.32 0 0 0 .476-.07l1.18-1.87a.64.64 0 0 0-.033-.733L2.083 14.2zm.79 5.275a.32.32 0 0 0-.42.02l-.367.354a.32.32 0 0 0-.022.43 11.18 11.18 0 0 0 2.854 2.67.32.32 0 0 0 .42-.044l.404-.431a.32.32 0 0 0-.006-.445L2.873 19.477zM7.55 22.553a.32.32 0 0 0 .014.458l.28.232a.32.32 0 0 0 .427-.023 11.2 11.2 0 0 0 3.084-4.807.32.32 0 0 0-.19-.394l-.672-.25a.32.32 0 0 0-.405.18A9.614 9.614 0 0 1 7.55 22.553zM23.988 12c0 6.627-5.373 12-12 12-.742 0-1.47-.067-2.176-.197a.32.32 0 0 1-.218-.494L22.006 8.523a.32.32 0 0 1 .552.108c.283.882.43 1.818.43 2.79V12zm-.836-4.488a.32.32 0 0 0-.56-.05L10.163 22.68a.32.32 0 0 0 .096.472c.83.43 1.73.74 2.679.914a.32.32 0 0 0 .333-.14L23.195 8.204a.32.32 0 0 0-.043-.412v-.28zM20.684 5.216a.32.32 0 0 0 .484.013l.257-.269a.32.32 0 0 0 .017-.427A11.955 11.955 0 0 0 12 .007C5.373.007 0 5.38 0 12.007c0 .414.021.824.063 1.229a.32.32 0 0 0 .547.197l18.13-20.07a.32.32 0 0 1 .453-.014l1.49 1.368v.5z" fill="#5E6AD2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M4.459 4.208c.746.606 1.026.56 2.428.466l13.215-.793c.28 0 .047-.28-.046-.326L18.57 2.33c-.42-.326-.98-.7-2.055-.607L3.62 2.79c-.466.046-.56.28-.374.466l1.213.952zm.793 3.08v13.904c0 .747.373 1.027 1.214.98l14.523-.84c.84-.046.933-.56.933-1.167V6.354c0-.606-.233-.933-.746-.886l-15.177.886c-.56.047-.747.327-.747.934zm14.337.745c.093.42 0 .84-.42.888l-.7.14v10.264c-.608.327-1.168.514-1.635.514-.747 0-.933-.234-1.494-.934l-4.577-7.186v6.952l1.448.327s0 .84-1.168.84l-3.222.186c-.093-.186 0-.653.327-.746l.84-.233V9.854L7.822 9.76c-.094-.42.14-1.026.793-1.073l3.456-.233 4.764 7.279v-6.44l-1.215-.14c-.093-.513.28-.886.747-.933l3.222-.187zM2.1 1.424L15.856.466c1.68-.14 2.1.093 2.8.606l3.876 2.753c.467.326.607.42.607.793v16.844c0 1.026-.374 1.633-1.68 1.726l-15.457.933c-.98.047-1.448-.093-1.962-.747l-3.13-4.06c-.56-.747-.793-1.306-.793-1.96V2.917c0-.84.374-1.54 1.782-1.493z" fill="#000000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 975 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M10.006 5.15a4.678 4.678 0 0 1 3.358-1.426 4.7 4.7 0 0 1 4.273 2.775 5.476 5.476 0 0 1 2.163-.444C22.108 6.055 24 7.954 24 10.288a4.258 4.258 0 0 1-3.8 4.225h-.2a3.756 3.756 0 0 1-3.468 2.308 3.726 3.726 0 0 1-1.76-.44 4.418 4.418 0 0 1-3.89 2.32 4.418 4.418 0 0 1-3.798-2.15 3.678 3.678 0 0 1-.844.098A3.68 3.68 0 0 1 2.56 12.97c0-.77.237-1.484.642-2.074A4.448 4.448 0 0 1 0 7.2a4.448 4.448 0 0 1 4.448-4.448c1.11 0 2.13.41 2.91 1.086A4.672 4.672 0 0 1 10.006 5.15z" fill="#00A1E0"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 564 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M13.91 2.505c-.873-1.553-3.066-1.553-3.94 0L7.092 7.67a10.783 10.783 0 0 1 4.376 3.033l1.72-3.027a4.23 4.23 0 0 0-.207-.257L13.91 2.505zM3.6 20.3h2.685a8.782 8.782 0 0 0-.094-5.725l-2.59 4.613c-.25.445.109 1.003.613 1.003L3.6 20.3zm16.595.108c.5 0 .862-.557.613-1.003L14.075 7.5l-1.72 3.027A8.782 8.782 0 0 1 17.35 20.3h2.845v.108z" fill="#362D59"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 429 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M15.337 23.979l7.216-1.561s-2.604-17.613-2.625-17.73c-.018-.116-.114-.2-.2-.2s-1.848-.138-1.848-.138-1.225-1.197-1.363-1.338c-.04-.04-.085-.06-.132-.074l-.793 18.867 -.255.174zm-3.07-16.938s-.718-.378-1.594-.378c-1.29 0-1.353.809-1.353 1.013 0 1.112 2.9 1.538 2.9 4.145 0 2.051-1.3 3.37-3.053 3.37-2.105 0-3.18-1.31-3.18-1.31l.564-1.865s1.105.949 2.036.949c.607 0 .856-.479.856-.829 0-1.453-2.38-1.519-2.38-3.906 0-2.008 1.441-3.953 4.351-3.953 1.12 0 1.674.321 1.674.321l-.82 2.443z" fill="#95BF47"/>
|
||||
<path d="M14.998 6.268c-.082-.025-.18-.048-.252-.048-.028 0-.062.002-.092.006l-.76 18.07.663-.143 3.377-23.182c-.138.14-1.363 1.337-1.363 1.337s-.95-.093-1.573-.04z" fill="#5E8E3E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 766 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z" fill="#E01E5A"/>
|
||||
<path d="M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312z" fill="#36C5F0"/>
|
||||
<path d="M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.27 0a2.528 2.528 0 0 1-2.522 2.521 2.527 2.527 0 0 1-2.521-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.522 2.522v6.312z" fill="#2EB67D"/>
|
||||
<path d="M15.165 18.956a2.528 2.528 0 0 1 2.522 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.521-2.522v-2.522h2.521zm0-1.27a2.527 2.527 0 0 1-2.521-2.522 2.527 2.527 0 0 1 2.521-2.521h6.313A2.528 2.528 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.521h-6.313z" fill="#ECB22E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M13.976 9.15c-2.172-.806-3.356-1.426-3.356-2.409 0-.831.683-1.305 1.901-1.305 2.227 0 4.515.858 6.09 1.631l.89-5.494C18.252.975 15.697 0 12.165 0 9.667 0 7.589.654 6.104 1.872 4.56 3.147 3.757 4.992 3.757 7.218c0 4.039 2.467 5.76 6.476 7.219 2.585.92 3.445 1.574 3.445 2.583 0 .98-.84 1.545-2.354 1.545-1.875 0-4.965-.921-6.99-2.109l-.9 5.555C5.175 22.99 8.385 24 11.714 24c2.641 0 4.843-.624 6.328-1.813 1.664-1.305 2.525-3.236 2.525-5.732 0-4.128-2.524-5.851-6.591-7.305z" fill="#635BFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 571 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M12 0C5.381 0 0 5.381 0 12s5.381 12 12 12 12-5.381 12-12S18.619 0 12 0zm0 20.4c-4.636 0-8.4-3.764-8.4-8.4S7.364 3.6 12 3.6s8.4 3.764 8.4 8.4-3.764 8.4-8.4 8.4zm3.6-10.8c0 .993-.807 1.8-1.8 1.8s-1.8-.807-1.8-1.8.807-1.8 1.8-1.8 1.8.807 1.8 1.8zm0 4.8c0 .993-.807 1.8-1.8 1.8s-1.8-.807-1.8-1.8.807-1.8 1.8-1.8 1.8.807 1.8 1.8zm-4.8-4.8c0 .993-.807 1.8-1.8 1.8s-1.8-.807-1.8-1.8.807-1.8 1.8-1.8 1.8.807 1.8 1.8zm0 4.8c0 .993-.807 1.8-1.8 1.8s-1.8-.807-1.8-1.8.807-1.8 1.8-1.8 1.8.807 1.8 1.8z" fill="#F22F46"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 587 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M15.535 8.465l-1.263 1.264a4.502 4.502 0 0 0-4.544 0L8.465 8.465a6.51 6.51 0 0 1 2.534-1.796V4.5h2v2.17a6.51 6.51 0 0 1 2.536 1.795zM12 10.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm3.535 5.035l1.263-1.264a6.51 6.51 0 0 1-1.796 2.536V19h-2v-2.17a6.51 6.51 0 0 1-2.534-1.795l1.263-1.264a4.502 4.502 0 0 0 4.544 0h-.74zM19 11h-2.17a6.51 6.51 0 0 1-1.795 2.534l1.264 1.263a4.502 4.502 0 0 0 0-4.544l-1.264 1.263A6.51 6.51 0 0 1 16.831 11H19v2zm-12.17 2H5v-2h2.17a6.51 6.51 0 0 1 1.795-2.534L7.7 7.203a4.502 4.502 0 0 0 0 4.544l1.264-1.263A6.51 6.51 0 0 1 7.17 13H6.83z" fill="#FF4A00"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 659 B |
@@ -20,6 +20,7 @@ import {
|
||||
ToolOutlined,
|
||||
TagsOutlined,
|
||||
AuditOutlined,
|
||||
MessageOutlined,
|
||||
} from "@ant-design/icons";
|
||||
// import {
|
||||
// all_admin_roles,
|
||||
@@ -47,6 +48,7 @@ interface SidebarProps {
|
||||
|
||||
interface MenuItemCfg {
|
||||
key: string;
|
||||
newTab?: boolean;
|
||||
page: string; // legacy id; we map this to a path below
|
||||
label: string;
|
||||
roles?: string[];
|
||||
@@ -105,6 +107,8 @@ const routeFor = (slug: string): string => {
|
||||
return "guardrails";
|
||||
case "policies":
|
||||
return "policies";
|
||||
case "chat":
|
||||
return "chat";
|
||||
|
||||
// tools
|
||||
case "mcp-servers":
|
||||
@@ -371,19 +375,29 @@ const Sidebar2: React.FC<SidebarProps> = ({ accessToken, userRole, defaultSelect
|
||||
}, [pathname, filteredMenuItems, defaultSelectedKey]);
|
||||
|
||||
// ----- Navigation -----
|
||||
const goTo = (slug: string) => {
|
||||
const goTo = (slug: string, newTab?: boolean) => {
|
||||
const href = toHref(slug);
|
||||
router.push(href);
|
||||
if (newTab) {
|
||||
window.open(href, "_blank");
|
||||
} else {
|
||||
router.push(href);
|
||||
}
|
||||
};
|
||||
|
||||
// Wrap label in <a> so every nav item supports right-click → "Open in new tab"
|
||||
// and Ctrl/Cmd+click to open in a new tab, while preserving SPA navigation for normal clicks.
|
||||
const renderNavLink = (label: string, page: string): React.ReactNode => {
|
||||
const renderNavLink = (label: string, page: string, newTab?: boolean): React.ReactNode => {
|
||||
const href = toHref(page);
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={newTab ? "_blank" : undefined}
|
||||
rel={newTab ? "noopener noreferrer" : undefined}
|
||||
onClick={(e) => {
|
||||
if (newTab) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
@@ -409,6 +423,8 @@ const Sidebar2: React.FC<SidebarProps> = ({ accessToken, userRole, defaultSelect
|
||||
style={{
|
||||
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<ConfigProvider
|
||||
@@ -431,22 +447,60 @@ const Sidebar2: React.FC<SidebarProps> = ({ accessToken, userRole, defaultSelect
|
||||
borderRight: 0,
|
||||
backgroundColor: "transparent",
|
||||
fontSize: "14px",
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
items={filteredMenuItems.map((item) => ({
|
||||
key: item.key,
|
||||
icon: item.icon,
|
||||
label: renderNavLink(item.label, item.page),
|
||||
label: renderNavLink(item.label, item.page, item.newTab),
|
||||
children: item.children?.map((child) => ({
|
||||
key: child.key,
|
||||
icon: child.icon,
|
||||
label: renderNavLink(child.label, child.page),
|
||||
onClick: () => goTo(child.page),
|
||||
label: renderNavLink(child.label, child.page, child.newTab),
|
||||
onClick: () => goTo(child.page, child.newTab),
|
||||
})),
|
||||
onClick: !item.children ? () => goTo(item.page) : undefined,
|
||||
onClick: !item.children ? () => goTo(item.page, item.newTab) : undefined,
|
||||
}))}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
{isAdminRole(userRole) && !collapsed && <UsageIndicator accessToken={accessToken} width={220} />}
|
||||
|
||||
{/* Pinned "Open Chat" button at bottom */}
|
||||
<div style={{
|
||||
padding: collapsed ? "10px 8px" : "10px 12px",
|
||||
borderTop: "1px solid #f0f0f0",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<a
|
||||
href={toHref("chat")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: collapsed ? "center" : "flex-start",
|
||||
gap: 8,
|
||||
padding: collapsed ? "8px 0" : "8px 10px",
|
||||
borderRadius: 8,
|
||||
background: "#1677ff",
|
||||
color: "#fff",
|
||||
textDecoration: "none",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLAnchorElement).style.background = "#0958d9";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLAnchorElement).style.background = "#1677ff";
|
||||
}}
|
||||
>
|
||||
<MessageOutlined style={{ fontSize: 16, flexShrink: 0 }} />
|
||||
{!collapsed && <span>Open Chat</span>}
|
||||
</a>
|
||||
</div>
|
||||
</Sider>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -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<MCPServerHealth[]>({
|
||||
const queryClient = useQueryClient();
|
||||
const [recheckingServerIds, setRecheckingServerIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const query = useQuery<MCPServerHealth[]>({
|
||||
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<MCPServerHealth[]>(
|
||||
{ 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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<MCPServer[]>({
|
||||
queryKey: mcpServersKeys.list({}),
|
||||
queryFn: async () => await fetchMCPServers(accessToken!),
|
||||
queryKey: mcpServersKeys.list(teamId ? { filters: { teamId } } : undefined),
|
||||
queryFn: async () => await fetchMCPServers(accessToken!, teamId),
|
||||
enabled: !!accessToken,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import remarkGfm from "remark-gfm";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import ReasoningContent from "../playground/chat_ui/ReasoningContent";
|
||||
import MCPEventsDisplay from "../playground/chat_ui/MCPEventsDisplay";
|
||||
import { ChatMessage } from "./types";
|
||||
|
||||
const { Panel } = Collapse;
|
||||
@@ -237,6 +238,8 @@ interface AssistantBubbleProps {
|
||||
isLastMessage: boolean;
|
||||
isStreaming: boolean;
|
||||
isTypingIndicator: boolean;
|
||||
/** MCP events stored on the message — rendered inline below the response. */
|
||||
mcpEvents?: ChatMessage["mcpEvents"];
|
||||
}
|
||||
|
||||
function AssistantBubble({
|
||||
@@ -244,6 +247,7 @@ function AssistantBubble({
|
||||
isLastMessage,
|
||||
isStreaming,
|
||||
isTypingIndicator,
|
||||
mcpEvents,
|
||||
}: AssistantBubbleProps) {
|
||||
// Ref to control ReasoningContent collapse on streaming end.
|
||||
// ReasoningContent manages its own expanded state; we use a key to
|
||||
@@ -321,6 +325,11 @@ function AssistantBubble({
|
||||
</div>
|
||||
|
||||
<CopyButton text={mainContent} />
|
||||
{mcpEvents && mcpEvents.length > 0 && (
|
||||
<div style={{ marginTop: 8, maxWidth: "100%" }}>
|
||||
<MCPEventsDisplay events={mcpEvents} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -566,6 +575,7 @@ const ChatMessages: React.FC<Props> = ({ messages, isStreaming, onEditMessage })
|
||||
isLastMessage={isLastMessage}
|
||||
isStreaming={isStreaming}
|
||||
isTypingIndicator={isLastMessage && isTypingIndicator}
|
||||
mcpEvents={msg.mcpEvents}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -26,6 +26,8 @@ import MCPConnectPicker from "./MCPConnectPicker";
|
||||
import MCPAppsPanel from "./MCPAppsPanel";
|
||||
import { fetchAvailableModels } from "../playground/llm_calls/fetch_models";
|
||||
import { makeOpenAIChatCompletionRequest } from "../playground/llm_calls/chat_completion";
|
||||
import { makeOpenAIResponsesRequest } from "../playground/llm_calls/responses_api";
|
||||
import type { MCPEvent } from "./types";
|
||||
import { getProxyBaseUrl } from "@/components/networking";
|
||||
import { useUIConfig } from "@/app/(dashboard)/hooks/uiConfig/useUIConfig";
|
||||
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
|
||||
@@ -135,6 +137,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
||||
const [modelSearchText, setModelSearchText] = useState("");
|
||||
|
||||
const [selectedMCPServers, setSelectedMCPServers] = useState<string[]>([]);
|
||||
const [responsesSessionId, setResponsesSessionId] = useState<string | null>(null);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [inputText, setInputText] = useState("");
|
||||
const [mcpPopoverOpen, setMcpPopoverOpen] = useState(false);
|
||||
@@ -162,7 +165,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
||||
createConversation,
|
||||
appendMessage,
|
||||
updateLastAssistantMessage,
|
||||
truncateAfterMessage,
|
||||
truncateFromMessage,
|
||||
deleteConversation,
|
||||
renameConversation,
|
||||
} = useChatHistory(activeConversationId);
|
||||
@@ -203,6 +206,12 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
||||
if (staleId) router.replace(getChatUrl(uiRoot));
|
||||
}, [staleId, router]);
|
||||
|
||||
// Reset the responses session when switching between conversations so that
|
||||
// previous_response_id from conversation A is never sent for conversation B.
|
||||
useEffect(() => {
|
||||
setResponsesSessionId(null);
|
||||
}, [activeConversationId]);
|
||||
|
||||
const toggleModel = useCallback((model: string) => {
|
||||
setSelectedModels((prev) => {
|
||||
let next: string[];
|
||||
@@ -231,6 +240,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
||||
let convId = activeConversationId;
|
||||
if (!convId) {
|
||||
convId = createConversation(model);
|
||||
setResponsesSessionId(null); // new conversation starts a fresh session
|
||||
router.push(getChatUrl(uiRoot, convId));
|
||||
}
|
||||
|
||||
@@ -240,29 +250,56 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
||||
setIsStreaming(true);
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
const history = [
|
||||
...(historyOverride ?? (activeConversation?.messages ?? [])
|
||||
.filter((m) => m.role === "user" || m.role === "assistant")
|
||||
.map((m) => ({
|
||||
role: m.role as "user" | "assistant",
|
||||
content: m.content,
|
||||
}))),
|
||||
{ role: "user" as const, content: trimmed },
|
||||
];
|
||||
// When historyOverride is set (edit / retry), the existing server-side
|
||||
// session chain covers messages that were just truncated and is no longer
|
||||
// valid for the rewritten history. Eagerly clear the session so that a
|
||||
// failed/aborted edit does not leave a stale session ID that contaminates
|
||||
// the next regular send.
|
||||
if (historyOverride) {
|
||||
setResponsesSessionId(null);
|
||||
}
|
||||
|
||||
// On a normal continuation turn with an active session, the Responses API
|
||||
// already holds the prior context server-side, so we only pass the new
|
||||
// user message (sending the full history would double-count it).
|
||||
//
|
||||
// On the very first turn (no session yet), we send the full history.
|
||||
const previousResponseId = historyOverride ? null : responsesSessionId;
|
||||
|
||||
const history: Array<{ role: "user" | "assistant"; content: string }> =
|
||||
historyOverride
|
||||
? [...historyOverride, { role: "user" as const, content: trimmed }]
|
||||
: previousResponseId
|
||||
? [{ role: "user" as const, content: trimmed }]
|
||||
: [
|
||||
// Explicitly filter to only user/assistant roles — tool messages
|
||||
// lack a required tool_call_id and would cause API errors.
|
||||
...(activeConversation?.messages ?? [])
|
||||
.filter((m): m is typeof m & { role: "user" | "assistant" } =>
|
||||
m.role === "user" || m.role === "assistant"
|
||||
)
|
||||
.map((m) => ({ role: m.role, content: m.content })),
|
||||
{ role: "user" as const, content: trimmed },
|
||||
];
|
||||
|
||||
let accumulatedContent = "";
|
||||
let accumulatedReasoning = "";
|
||||
// MCP events accumulated locally so we can persist them to the message
|
||||
// without relying on component state (which would cause stale closures).
|
||||
const accumulatedMCPEvents: MCPEvent[] = [];
|
||||
// Track clean completion so partial events are not shown on error/abort.
|
||||
let streamCompletedCleanly = false;
|
||||
|
||||
try {
|
||||
await makeOpenAIChatCompletionRequest(
|
||||
await makeOpenAIResponsesRequest(
|
||||
history,
|
||||
(chunk: string) => {
|
||||
(_role: string, chunk: string) => {
|
||||
accumulatedContent += chunk;
|
||||
updateLastAssistantMessage(convId!, { content: accumulatedContent });
|
||||
},
|
||||
model,
|
||||
accessToken,
|
||||
undefined,
|
||||
undefined, // tags
|
||||
abortControllerRef.current.signal,
|
||||
(rc: string) => {
|
||||
accumulatedReasoning += rc;
|
||||
@@ -270,7 +307,15 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
||||
},
|
||||
undefined, undefined, undefined, undefined, undefined, undefined,
|
||||
selectedMCPServers.length > 0 ? selectedMCPServers : undefined,
|
||||
previousResponseId,
|
||||
(id: string) => setResponsesSessionId(id),
|
||||
(event: MCPEvent) => {
|
||||
// Accumulate locally only — persisted once in finally to avoid
|
||||
// one full localStorage write per MCP event during streaming.
|
||||
accumulatedMCPEvents.push(event);
|
||||
},
|
||||
);
|
||||
streamCompletedCleanly = true;
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
updateLastAssistantMessage(convId!, {
|
||||
@@ -282,12 +327,17 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
// Only persist MCP events on clean completion — partial events from an
|
||||
// aborted or errored turn would show incomplete tool calls to the user.
|
||||
if (accumulatedMCPEvents.length > 0 && streamCompletedCleanly) {
|
||||
updateLastAssistantMessage(convId!, { mcpEvents: accumulatedMCPEvents });
|
||||
}
|
||||
setIsStreaming(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
},
|
||||
[activeConversationId, activeConversation, selectedModels, selectedMCPServers, accessToken,
|
||||
createConversation, appendMessage, updateLastAssistantMessage, router, isStreaming],
|
||||
createConversation, appendMessage, updateLastAssistantMessage, router, isStreaming, responsesSessionId],
|
||||
);
|
||||
|
||||
const handleSendComparison = useCallback(
|
||||
@@ -355,10 +405,10 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
||||
const priorMessages = (idx === -1 ? msgs : msgs.slice(0, idx))
|
||||
.filter((m) => m.role === "user" || m.role === "assistant")
|
||||
.map((m) => ({ role: m.role as "user" | "assistant", content: m.content }));
|
||||
truncateAfterMessage(activeConversationId, messageId);
|
||||
truncateFromMessage(activeConversationId, messageId);
|
||||
handleSend(newContent, priorMessages);
|
||||
},
|
||||
[activeConversationId, isStreaming, activeConversation, truncateAfterMessage, handleSend],
|
||||
[activeConversationId, isStreaming, activeConversation, truncateFromMessage, handleSend],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
|
||||
@@ -170,9 +170,25 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
|
||||
|
||||
{/* Avatar + name + connect */}
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 20, marginBottom: 28 }}>
|
||||
{detailServer.mcp_info?.logo_url ? (
|
||||
<img
|
||||
src={detailServer.mcp_info.logo_url}
|
||||
alt={`${name} logo`}
|
||||
style={{
|
||||
width: 64, height: 64, borderRadius: 16,
|
||||
objectFit: "contain", flexShrink: 0,
|
||||
background: "#f9fafb",
|
||||
}}
|
||||
onError={(e) => {
|
||||
const el = e.target as HTMLImageElement;
|
||||
el.style.display = "none";
|
||||
if (el.nextElementSibling) (el.nextElementSibling as HTMLElement).style.display = "flex";
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{
|
||||
width: 64, height: 64, borderRadius: 16,
|
||||
background: color, display: "flex",
|
||||
background: color, display: detailServer.mcp_info?.logo_url ? "none" : "flex",
|
||||
alignItems: "center", justifyContent: "center",
|
||||
color: "#fff", fontWeight: 700, fontSize: 28, flexShrink: 0,
|
||||
}}>
|
||||
@@ -351,9 +367,26 @@ const MCPAppsPanel: React.FC<Props> = ({ 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 ? (
|
||||
<img
|
||||
src={server.mcp_info.logo_url}
|
||||
alt={`${name} logo`}
|
||||
style={{
|
||||
width: 38, height: 38, borderRadius: 10,
|
||||
objectFit: "contain", flexShrink: 0,
|
||||
background: "#f9fafb",
|
||||
}}
|
||||
onError={(e) => {
|
||||
const el = e.target as HTMLImageElement;
|
||||
el.style.display = "none";
|
||||
if (el.nextElementSibling) (el.nextElementSibling as HTMLElement).style.display = "flex";
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div style={{
|
||||
width: 38, height: 38, borderRadius: 10, background: color,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
display: server.mcp_info?.logo_url ? "none" : "flex",
|
||||
alignItems: "center", justifyContent: "center",
|
||||
color: "#fff", fontWeight: 700, fontSize: 16, flexShrink: 0,
|
||||
}}>
|
||||
{name.charAt(0).toUpperCase()}
|
||||
|
||||
@@ -112,6 +112,18 @@ const MCPConnectPicker: React.FC<Props> = ({ accessToken, selectedServers, onCha
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{server.mcp_info?.logo_url && (
|
||||
<img
|
||||
src={server.mcp_info.logo_url}
|
||||
alt={`${name} logo`}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 6,
|
||||
objectFit: "contain", flexShrink: 0,
|
||||
marginTop: 1,
|
||||
}}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
export type { MCPEvent } from "../mcp_tools/types";
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "tool";
|
||||
content: string;
|
||||
reasoningContent?: string;
|
||||
/** MCP tool events that occurred during this assistant turn, in order. */
|
||||
mcpEvents?: MCPEvent[];
|
||||
toolName?: string;
|
||||
toolArgs?: Record<string, unknown>;
|
||||
toolResult?: string;
|
||||
|
||||
@@ -51,8 +51,9 @@ export function useChatHistory(activeConversationId: string | null): {
|
||||
staleId: boolean;
|
||||
createConversation: (model: string) => string;
|
||||
appendMessage: (conversationId: string, message: Omit<ChatMessage, "id" | "timestamp">) => void;
|
||||
updateLastAssistantMessage: (conversationId: string, updates: Partial<Pick<ChatMessage, "content" | "reasoningContent">>) => void;
|
||||
truncateAfterMessage: (conversationId: string, messageId: string) => void;
|
||||
updateLastAssistantMessage: (conversationId: string, updates: Partial<Pick<ChatMessage, "content" | "reasoningContent" | "mcpEvents">>) => void;
|
||||
/** Remove the message with `messageId` and all subsequent messages from the conversation. */
|
||||
truncateFromMessage: (conversationId: string, messageId: string) => void;
|
||||
deleteConversation: (id: string) => void;
|
||||
renameConversation: (id: string, newTitle: string) => void;
|
||||
setActiveConversationId: (id: string | null) => void;
|
||||
@@ -148,7 +149,7 @@ export function useChatHistory(activeConversationId: string | null): {
|
||||
const updateLastAssistantMessage = useCallback(
|
||||
(
|
||||
conversationId: string,
|
||||
updates: Partial<Pick<ChatMessage, "content" | "reasoningContent">>,
|
||||
updates: Partial<Pick<ChatMessage, "content" | "reasoningContent" | "mcpEvents">>,
|
||||
) => {
|
||||
setConversations((prev) => {
|
||||
const updated = prev.map((conv) => {
|
||||
@@ -168,7 +169,7 @@ export function useChatHistory(activeConversationId: string | null): {
|
||||
[],
|
||||
);
|
||||
|
||||
const truncateAfterMessage = useCallback(
|
||||
const truncateFromMessage = useCallback(
|
||||
(conversationId: string, messageId: string) => {
|
||||
setConversations((prev) => {
|
||||
const updated = prev.map((conv) => {
|
||||
@@ -222,7 +223,7 @@ export function useChatHistory(activeConversationId: string | null): {
|
||||
createConversation,
|
||||
appendMessage,
|
||||
updateLastAssistantMessage,
|
||||
truncateAfterMessage,
|
||||
truncateFromMessage,
|
||||
deleteConversation,
|
||||
renameConversation,
|
||||
setActiveConversationId,
|
||||
|
||||
@@ -13,6 +13,7 @@ interface MCPServerSelectorProps {
|
||||
accessToken: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
teamId?: string | null;
|
||||
}
|
||||
|
||||
const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
|
||||
@@ -22,8 +23,9 @@ const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
|
||||
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;
|
||||
|
||||
@@ -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<MCPLogoSelectorProps> = ({ value, onChange }) => {
|
||||
const [imgErrors, setImgErrors] = useState<Set<string>>(new Set());
|
||||
|
||||
const handleSelect = (url: string) => {
|
||||
onChange?.(value === url ? undefined : url);
|
||||
};
|
||||
|
||||
const handleImgError = (url: string) => {
|
||||
setImgErrors((prev) => new Set(prev).add(url));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">Logo</span>
|
||||
<Tooltip title="Select a well-known logo or paste a URL to any image. The logo is shown on the admin and chat pages.">
|
||||
<InfoCircleOutlined className="text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{value && (
|
||||
<div className="flex items-center gap-3 mb-3 p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<img
|
||||
src={value}
|
||||
alt="Selected logo"
|
||||
className="w-10 h-10 object-contain rounded"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-gray-500 truncate">{value}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange?.(undefined)}
|
||||
className="text-xs text-gray-400 hover:text-red-500 cursor-pointer bg-transparent border-none"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Well-known logo grid */}
|
||||
<div className="grid grid-cols-10 gap-1.5 mb-3">
|
||||
{WELL_KNOWN_LOGOS.map((logo) => {
|
||||
const isSelected = value === logo.url;
|
||||
const hasFailed = imgErrors.has(logo.url);
|
||||
if (hasFailed) return null;
|
||||
return (
|
||||
<Tooltip key={logo.name} title={logo.name}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect(logo.url)}
|
||||
className={`flex items-center justify-center p-2 rounded-lg border transition-all cursor-pointer
|
||||
${isSelected
|
||||
? "border-blue-500 bg-blue-50 shadow-sm"
|
||||
: "border-gray-200 hover:border-blue-300 hover:bg-gray-50"
|
||||
}`}
|
||||
style={{ width: 40, height: 40 }}
|
||||
>
|
||||
<img
|
||||
src={logo.url}
|
||||
alt={logo.name}
|
||||
className="w-5 h-5 object-contain"
|
||||
onError={() => handleImgError(logo.url)}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Custom URL input */}
|
||||
<Input
|
||||
prefix={<LinkOutlined className="text-gray-400" />}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MCPLogoSelector;
|
||||
@@ -12,6 +12,8 @@ interface OpenAPIFormSectionProps {
|
||||
onValuesChange: (updates: Record<string, any>) => 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<OpenAPIFormSectionProps> = ({
|
||||
accessToken,
|
||||
onValuesChange,
|
||||
onKeyToolsChange,
|
||||
onLogoUrlChange,
|
||||
onOAuthDocsUrlChange,
|
||||
}) => {
|
||||
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
||||
@@ -33,6 +36,7 @@ const OpenAPIFormSection: React.FC<OpenAPIFormSectionProps> = ({
|
||||
const handlePresetSelect = (entry: OpenAPIRegistryEntry) => {
|
||||
setSelectedPreset(entry.name);
|
||||
onKeyToolsChange?.(entry.key_tools ?? []);
|
||||
onLogoUrlChange?.(entry.icon_url || undefined);
|
||||
const updates: Record<string, any> = {
|
||||
spec_path: entry.spec_url,
|
||||
};
|
||||
|
||||
@@ -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<CreateMCPServerProps> = ({
|
||||
const [keyTools, setKeyTools] = useState<OpenAPIKeyTool[]>([]);
|
||||
const [searchValue, setSearchValue] = useState<string>("");
|
||||
const [oauthAccessToken, setOauthAccessToken] = useState<string | null>(null);
|
||||
const [logoUrl, setLogoUrl] = useState<string | undefined>(undefined);
|
||||
const [oauthDocsUrl, setOauthDocsUrl] = useState<string | null>(null);
|
||||
|
||||
// Single hook call shared by MCPConnectionStatus and MCPToolConfiguration to avoid duplicate requests.
|
||||
@@ -101,6 +103,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
||||
allowedTools,
|
||||
searchValue,
|
||||
aliasManuallyEdited,
|
||||
logoUrl,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -202,6 +205,9 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
||||
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<CreateMCPServerProps> = ({
|
||||
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<CreateMCPServerProps> = ({
|
||||
clearTools();
|
||||
setAllowedTools([]);
|
||||
setAliasManuallyEdited(false);
|
||||
setLogoUrl(undefined);
|
||||
setModalVisible(false);
|
||||
onCreateSuccess(response);
|
||||
}
|
||||
@@ -414,6 +422,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
||||
clearTools();
|
||||
setAllowedTools([]);
|
||||
setAliasManuallyEdited(false);
|
||||
setLogoUrl(undefined);
|
||||
setModalVisible(false);
|
||||
};
|
||||
|
||||
@@ -590,6 +599,8 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<MCPLogoSelector value={logoUrl} onChange={setLogoUrl} />
|
||||
|
||||
<Form.Item
|
||||
label={<span className="text-sm font-medium text-gray-700">GitHub / Source URL</span>}
|
||||
name="source_url"
|
||||
@@ -645,6 +656,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
||||
setFormValues((prev) => ({ ...prev, ...updates }))
|
||||
}
|
||||
onKeyToolsChange={setKeyTools}
|
||||
onLogoUrlChange={setLogoUrl}
|
||||
onOAuthDocsUrlChange={setOauthDocsUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-gray-400 px-2 py-0.5 rounded-full bg-gray-50 border border-gray-100">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-gray-300 animate-pulse"></span>
|
||||
Checking
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
<div className="max-w-xs">
|
||||
<div className="font-semibold mb-1">Health Status: {status}</div>
|
||||
{lastCheck && <div className="text-xs mb-1">Last Check: {new Date(lastCheck).toLocaleString()}</div>}
|
||||
{error && (
|
||||
<div className="text-xs">
|
||||
<div className="font-medium text-red-400 mb-1">Error:</div>
|
||||
<div className="break-words">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
{!lastCheck && !error && <div className="text-xs text-gray-400">No health check data available</div>}
|
||||
{isClickable && <div className="text-xs text-gray-400 mt-1">Click to recheck</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip title={tooltipContent} placement="top">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full ${getStatusColor(status)} ${isClickable ? "cursor-pointer hover:opacity-80" : "cursor-default"}`}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={isClickable ? () => onRecheck(server.server_id) : undefined}
|
||||
>
|
||||
<span>{isHovered && isClickable ? "↻" : getStatusIcon(status)}</span>
|
||||
{isHovered && isClickable
|
||||
? "Recheck"
|
||||
: status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
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<string>,
|
||||
): ColumnDef<MCPServer>[] => [
|
||||
{
|
||||
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 (
|
||||
<div className="flex items-center gap-2">
|
||||
{logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={`${name ?? "MCP"} logo`}
|
||||
className="h-5 w-5 rounded object-contain flex-shrink-0"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
) : null}
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-gray-400 px-2 py-0.5 rounded-full bg-gray-50 border border-gray-100">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-gray-300 animate-pulse"></span>
|
||||
Checking
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
<div className="max-w-xs">
|
||||
<div className="font-semibold mb-1">Health Status: {status}</div>
|
||||
{lastCheck && <div className="text-xs mb-1">Last Check: {new Date(lastCheck).toLocaleString()}</div>}
|
||||
{error && (
|
||||
<div className="text-xs">
|
||||
<div className="font-medium text-red-400 mb-1">Error:</div>
|
||||
<div className="break-words">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
{!lastCheck && !error && <div className="text-xs text-gray-400">No health check data available</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip title={tooltipContent} placement="top">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full cursor-default ${getStatusColor(status)}`}
|
||||
>
|
||||
<span>{getStatusIcon(status)}</span>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<HealthStatusBadge
|
||||
server={row.original}
|
||||
isLoadingHealth={isLoadingHealth}
|
||||
isRechecking={recheckingServerIds?.has(row.original.server_id)}
|
||||
onRecheck={onRecheckHealth}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "mcp_access_groups",
|
||||
|
||||
@@ -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<MCPServerEditProps> = ({
|
||||
const [toolNameToDisplayName, setToolNameToDisplayName] = useState<Record<string, string>>({});
|
||||
const [toolNameToDescription, setToolNameToDescription] = useState<Record<string, string>>({});
|
||||
const [pendingRestoredValues, setPendingRestoredValues] = useState<Record<string, any> | null>(null);
|
||||
const [logoUrl, setLogoUrl] = useState<string | undefined>(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<MCPServerEditProps> = ({
|
||||
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<MCPServerEditProps> = ({
|
||||
<Form.Item label="Description" name="description">
|
||||
<Input className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" />
|
||||
</Form.Item>
|
||||
<MCPLogoSelector value={logoUrl} onChange={setLogoUrl} />
|
||||
<Form.Item label="Transport Type" name="transport" rules={[{ required: true }]}>
|
||||
<Select onChange={handleTransportChange}>
|
||||
<Select.Option value="http">Streamable HTTP (Recommended)</Select.Option>
|
||||
|
||||
@@ -29,7 +29,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
||||
const { data: mcpServers, isLoading: isLoadingServers, refetch } = useMCPServers();
|
||||
|
||||
// Fetch health status for all servers
|
||||
const { data: healthStatuses, isLoading: isLoadingHealth } = useMCPServerHealth();
|
||||
const { data: healthStatuses, isLoading: isLoadingHealth, recheckServerHealth, recheckingServerIds } = useMCPServerHealth();
|
||||
|
||||
// Merge health status data into servers
|
||||
const serversWithHealth = useMemo(() => {
|
||||
@@ -163,8 +163,10 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
||||
handleDelete,
|
||||
isLoadingHealth,
|
||||
(server: MCPServer) => setByokModalServer(server),
|
||||
recheckServerHealth,
|
||||
recheckingServerIds,
|
||||
),
|
||||
[userRole, isLoadingHealth],
|
||||
[userRole, isLoadingHealth, recheckServerHealth, recheckingServerIds],
|
||||
);
|
||||
|
||||
function handleDelete(server_id: string) {
|
||||
|
||||
@@ -1,3 +1,30 @@
|
||||
/** A single MCP tool event emitted by the LiteLLM proxy during a Responses API turn. */
|
||||
export interface MCPEvent {
|
||||
type: string;
|
||||
sequence_number?: number;
|
||||
output_index?: number;
|
||||
item_id?: string;
|
||||
item?: {
|
||||
id?: string;
|
||||
type?: string;
|
||||
server_label?: string;
|
||||
tools?: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
annotations?: {
|
||||
read_only?: boolean;
|
||||
};
|
||||
input_schema?: unknown;
|
||||
}>;
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
output?: string;
|
||||
};
|
||||
delta?: string;
|
||||
arguments?: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
team_id: string;
|
||||
team_alias?: string;
|
||||
|
||||
@@ -6320,10 +6320,15 @@ export const fetchDiscoverableMCPServers = async (accessToken: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchMCPServers = async (accessToken: string) => {
|
||||
export const fetchMCPServers = async (accessToken: string, teamId?: string | null) => {
|
||||
try {
|
||||
// Construct base URL
|
||||
const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/mcp/server` : `/v1/mcp/server`;
|
||||
// Construct base URL with optional team_id filter
|
||||
let url = proxyBaseUrl ? `${proxyBaseUrl}/v1/mcp/server` : `/v1/mcp/server`;
|
||||
if (teamId) {
|
||||
const params = new URLSearchParams();
|
||||
params.append("team_id", teamId);
|
||||
url = `${url}?${params.toString()}`;
|
||||
}
|
||||
|
||||
console.log("Fetching MCP servers from:", url);
|
||||
|
||||
|
||||
@@ -563,6 +563,8 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
||||
if (!pendingPrefillModels) {
|
||||
form.setFieldValue("models", []);
|
||||
}
|
||||
// Clear MCP server selection when team changes (available servers may differ)
|
||||
form.setFieldValue("allowed_mcp_servers_and_groups", { servers: [], accessGroups: [] });
|
||||
}, [selectedCreateKeyTeam, selectedProjectId, accessToken, userID, userRole, form]);
|
||||
|
||||
// Apply deferred model prefill once the available model list arrives.
|
||||
@@ -1323,6 +1325,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
||||
onChange={(val: any) => form.setFieldValue("allowed_mcp_servers_and_groups", val)}
|
||||
value={form.getFieldValue("allowed_mcp_servers_and_groups")}
|
||||
accessToken={accessToken}
|
||||
teamId={selectedCreateKeyTeam?.team_id ?? null}
|
||||
placeholder="Select MCP servers or access groups (optional)"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -1,35 +1,10 @@
|
||||
import React from "react";
|
||||
import { Typography, Collapse } from "antd";
|
||||
import type { MCPEvent } from "../../mcp_tools/types";
|
||||
|
||||
const { Text } = Typography;
|
||||
const { Panel } = Collapse;
|
||||
|
||||
export interface MCPEvent {
|
||||
type: string;
|
||||
sequence_number?: number;
|
||||
output_index?: number;
|
||||
item_id?: string;
|
||||
item?: {
|
||||
id?: string;
|
||||
type?: string;
|
||||
server_label?: string;
|
||||
tools?: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
annotations?: {
|
||||
read_only?: boolean;
|
||||
};
|
||||
input_schema?: any;
|
||||
}>;
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
output?: string;
|
||||
};
|
||||
delta?: string;
|
||||
arguments?: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
interface MCPEventsDisplayProps {
|
||||
events: MCPEvent[];
|
||||
className?: string;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { MessageType } from "../chat_ui/types";
|
||||
import { TokenUsage } from "../chat_ui/ResponseMetrics";
|
||||
import { getProxyBaseUrl } from "@/components/networking";
|
||||
import NotificationManager from "@/components/molecules/notifications_manager";
|
||||
import { MCPEvent } from "../chat_ui/MCPEventsDisplay";
|
||||
import type { MCPEvent } from "../../mcp_tools/types";
|
||||
import { MCPServer } from "../../mcp_tools/types";
|
||||
import {
|
||||
CodeInterpreterResult,
|
||||
@@ -98,20 +98,22 @@ export async function makeOpenAIResponsesRequest(
|
||||
tools.push({
|
||||
type: "mcp",
|
||||
server_label: "litellm",
|
||||
server_url: "litellm_proxy/mcp",
|
||||
server_url: `${proxyBaseUrl}/mcp`,
|
||||
require_approval: "never",
|
||||
});
|
||||
} else {
|
||||
// Individual servers selected - create one entry per server
|
||||
selectedMCPServers.forEach((serverId) => {
|
||||
const server = mcpServers?.find((s) => s.server_id === serverId);
|
||||
const serverName = server?.alias || server?.server_name || serverId;
|
||||
// Use server_name for both routing and labelling. server_name is the
|
||||
// unique registered identifier; aliases can collide across servers.
|
||||
const routeName = server?.server_name || serverId;
|
||||
const allowedTools = mcpServerToolRestrictions?.[serverId] || [];
|
||||
|
||||
tools.push({
|
||||
type: "mcp",
|
||||
server_label: "litellm",
|
||||
server_url: `litellm_proxy/mcp/${serverName}`,
|
||||
server_label: routeName, // unique per request — collisions cause silent tool-routing failures
|
||||
server_url: `${proxyBaseUrl}/mcp/${encodeURIComponent(routeName)}`,
|
||||
require_approval: "never",
|
||||
...(allowedTools.length > 0 ? { allowed_tools: allowedTools } : {}),
|
||||
});
|
||||
@@ -197,8 +199,7 @@ export async function makeOpenAIResponsesRequest(
|
||||
if (event.type === "response.output_text.delta" && typeof event.delta === "string") {
|
||||
const delta = event.delta;
|
||||
console.log("Text delta", delta);
|
||||
// skip pure whitespace/newlines
|
||||
if (delta.trim().length > 0) {
|
||||
if (delta.length > 0) {
|
||||
updateTextUI("assistant", delta, selectedModel);
|
||||
|
||||
// Calculate time to first token
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ConfigInfoMessage } from "./ConfigInfoMessage";
|
||||
|
||||
describe("ConfigInfoMessage", () => {
|
||||
it("should render the info message when show is true", () => {
|
||||
render(<ConfigInfoMessage show={true} />);
|
||||
expect(screen.getByText("Request/Response Data Not Available")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render nothing when show is false", () => {
|
||||
const { container } = render(<ConfigInfoMessage show={false} />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("should display the YAML config snippet", () => {
|
||||
render(<ConfigInfoMessage show={true} />);
|
||||
expect(screen.getByText(/store_prompts_in_spend_logs: true/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the settings button when onOpenSettings is provided", () => {
|
||||
render(<ConfigInfoMessage show={true} onOpenSettings={() => {}} />);
|
||||
expect(screen.getByText("open the settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render the settings button when onOpenSettings is omitted", () => {
|
||||
render(<ConfigInfoMessage show={true} />);
|
||||
expect(screen.queryByText("open the settings")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onOpenSettings when the settings button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenSettings = vi.fn();
|
||||
|
||||
render(<ConfigInfoMessage show={true} onOpenSettings={onOpenSettings} />);
|
||||
await user.click(screen.getByText("open the settings"));
|
||||
|
||||
expect(onOpenSettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ErrorViewer } from "./ErrorViewer";
|
||||
|
||||
const basicError = {
|
||||
error_class: "NotFoundError",
|
||||
error_message: "Model gpt-5 not found",
|
||||
};
|
||||
|
||||
const errorWithTraceback = {
|
||||
error_class: "AuthenticationError",
|
||||
error_message: "Invalid API key",
|
||||
traceback: `Traceback (most recent call last):
|
||||
File "/app/main.py", line 42, in handle_request
|
||||
result = await client.chat(model="gpt-4")
|
||||
File "/app/llms/openai.py", line 100, in chat
|
||||
response = self._make_request(payload)
|
||||
File "/app/llms/base.py", line 55, in _make_request
|
||||
raise AuthenticationError("Invalid API key")`,
|
||||
};
|
||||
|
||||
describe("ErrorViewer", () => {
|
||||
it("should render error type and message", () => {
|
||||
render(<ErrorViewer errorInfo={basicError} />);
|
||||
expect(screen.getByText("NotFoundError")).toBeInTheDocument();
|
||||
expect(screen.getByText("Model gpt-5 not found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show 'Unknown Error' when error_class is missing", () => {
|
||||
render(<ErrorViewer errorInfo={{ error_message: "something broke" }} />);
|
||||
expect(screen.getByText("Unknown Error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show 'Unknown error occurred' when error_message is missing", () => {
|
||||
render(<ErrorViewer errorInfo={{ error_class: "RuntimeError" }} />);
|
||||
expect(screen.getByText("Unknown error occurred")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render traceback frames when traceback is present", () => {
|
||||
render(<ErrorViewer errorInfo={errorWithTraceback} />);
|
||||
expect(screen.getByText("Traceback")).toBeInTheDocument();
|
||||
expect(screen.getByText("main.py")).toBeInTheDocument();
|
||||
expect(screen.getByText("openai.py")).toBeInTheDocument();
|
||||
expect(screen.getByText("base.py")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should expand a frame when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ErrorViewer errorInfo={errorWithTraceback} />);
|
||||
|
||||
await user.click(screen.getByText("main.py"));
|
||||
|
||||
expect(
|
||||
screen.getByText("result = await client.chat(model=\"gpt-4\")")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should expand all frames when 'Expand All' is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ErrorViewer errorInfo={errorWithTraceback} />);
|
||||
|
||||
await user.click(screen.getByText("Expand All"));
|
||||
|
||||
expect(screen.getByText("Collapse All")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should copy traceback to clipboard when copy button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockWriteText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText: mockWriteText },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
render(<ErrorViewer errorInfo={errorWithTraceback} />);
|
||||
|
||||
await user.click(screen.getByTitle("Copy traceback"));
|
||||
expect(mockWriteText).toHaveBeenCalledWith(errorWithTraceback.traceback);
|
||||
});
|
||||
|
||||
it("should not render traceback section when traceback is absent", () => {
|
||||
render(<ErrorViewer errorInfo={basicError} />);
|
||||
expect(screen.queryByText("Traceback")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TruncatedValue } from "./TruncatedValue";
|
||||
|
||||
describe("TruncatedValue", () => {
|
||||
it("should render the value text", () => {
|
||||
render(<TruncatedValue value="chatcmpl-abc123" />);
|
||||
expect(screen.getByText("chatcmpl-abc123")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a dash when value is undefined", () => {
|
||||
render(<TruncatedValue />);
|
||||
expect(screen.getByText("-")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a dash when value is empty string", () => {
|
||||
render(<TruncatedValue value="" />);
|
||||
expect(screen.getByText("-")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should apply the default maxWidth when not specified", () => {
|
||||
render(<TruncatedValue value="some-long-id-value" />);
|
||||
const el = screen.getByText("some-long-id-value");
|
||||
expect(el).toHaveStyle({ maxWidth: "180px" });
|
||||
});
|
||||
|
||||
it("should apply custom maxWidth when provided", () => {
|
||||
render(<TruncatedValue value="test-value" maxWidth={300} />);
|
||||
const el = screen.getByText("test-value");
|
||||
expect(el).toHaveStyle({ maxWidth: "300px" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { LlmBadge, McpBadge, AgentBadge } from "./TypeBadges";
|
||||
|
||||
describe("TypeBadges", () => {
|
||||
describe("LlmBadge", () => {
|
||||
it("should render with default 'LLM' text when no count is provided", () => {
|
||||
render(<LlmBadge />);
|
||||
expect(screen.getByText("LLM")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the count when provided", () => {
|
||||
render(<LlmBadge count={5} />);
|
||||
expect(screen.getByText("5")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render count of 0 instead of default text", () => {
|
||||
render(<LlmBadge count={0} />);
|
||||
expect(screen.getByText("0")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("McpBadge", () => {
|
||||
it("should render with default 'MCP' text when no count is provided", () => {
|
||||
render(<McpBadge />);
|
||||
expect(screen.getByText("MCP")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the count when provided", () => {
|
||||
render(<McpBadge count={3} />);
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentBadge", () => {
|
||||
it("should render with default 'Agent' text when no count is provided", () => {
|
||||
render(<AgentBadge />);
|
||||
expect(screen.getByText("Agent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the count when provided", () => {
|
||||
render(<AgentBadge count={12} />);
|
||||
expect(screen.getByText("12")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TimeCell, getTimeZone } from "./time_cell";
|
||||
|
||||
describe("TimeCell", () => {
|
||||
it("should render a formatted time string", () => {
|
||||
render(<TimeCell utcTime="2025-06-15T14:30:00Z" />);
|
||||
// The global toLocaleString mock in setupTests returns "YYYY-MM-DD HH:MM:SS"
|
||||
expect(screen.getByText(/2025/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render 'Error converting time' for invalid dates", () => {
|
||||
// toLocaleString on an Invalid Date returns "Invalid Date", not throwing,
|
||||
// but the component catches exceptions. Force an error by passing something
|
||||
// that causes Date constructor to produce NaN.
|
||||
render(<TimeCell utcTime="not-a-date" />);
|
||||
// The mock returns "NaN-NaN-NaN NaN:NaN:NaN" for invalid dates
|
||||
// The component has a try/catch that returns "Error converting time" on exception
|
||||
const el = screen.getByText(/NaN|Error/);
|
||||
expect(el).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with monospace font", () => {
|
||||
render(<TimeCell utcTime="2025-06-15T14:30:00Z" />);
|
||||
const span = screen.getByText(/2025/);
|
||||
expect(span).toHaveStyle({ fontFamily: "monospace" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTimeZone", () => {
|
||||
it("should return a non-empty timezone string", () => {
|
||||
const tz = getTimeZone();
|
||||
expect(typeof tz).toBe("string");
|
||||
expect(tz.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||