mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
fix: emit first chunk text delta for anthropic in responses API stream (#20136)
* update image and bounded logo in navbar * refactoring user dropdown * new utils * address feedback * [Feat] v2 - Logs view with side panel and improved UX (#20091) * init: azure_ai/azure-model-router * show additional_costs in CostBreakdown * UI show cost breakdown fields * feat: dedicated cost calc for azure ai * test_azure_ai_model_router * docs azure model router * test azure model router * fix transfrom * Add transform file * fix:feat: route to config * v0 - looks decen view * refactored code * fix ui * fixes ui * complete v2 viewer * address feedback * address feedback * [Feat] UI - New View to render "Tools" on Logs View (#20093) * v1 - tool viewer in logs page * add preview for tool sections * ui fixes * new tool view * Refactor: Address code review feedback - use Antd components Changes: - Use Antd Space component instead of manual flex layouts - Use Antd Text.copyable prop instead of custom clipboard utilities - Extract helper functions to utils.ts for testability - Remove clipboardUtils.ts (replaced with Antd built-in) - Update DrawerHeader, LogDetailsDrawer, and constants Benefits: - Cleaner code using standard Antd patterns - Better testability with separated utils - Consistent UX with Antd's copy tooltips - Reduced custom code maintenance Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> * [Feat] UI - Add Pretty print view of request/response (#20096) * v1 - tool viewer in logs page * add preview for tool sections * ui fixes * new tool view * v1 - new pretty view * clean ui * polish fixes * nice view input/output * working i/o cards * fixes for log view --------- Co-authored-by: Warp <agent@warp.dev> * remove md * fixed mcp tools instructions on ui to show comma seprated str instead of list * docs: cleanup docs * litellm_fix: add missing timezone import to proxy_server.py (#20121) * fix(proxy): reduce PLR0915 complexity in base_process_llm_request (#20127) * litellm_fix(ui): remove unused ToolOutlined import (#20129) * litellm_fix(e2e): disable bedrock-converse-claude-sonnet-4.5 model in tests (#20131) * litellm_fix(test): fix Azure AI cost calculator test - use Logging class (#20134) * litellm_fix(test): fix Bedrock tool search header test regression (#20135) * fix: emit first chunk text delta for Anthropic in responses API stream --------- Co-authored-by: yuneng-jiang <yuneng.jiang@gmail.com> Co-authored-by: Ishaan Jaffer <ishaanjaffer0324@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Warp <agent@warp.dev> Co-authored-by: shivam <shivam@uni.minerva.edu> Co-authored-by: Krrish Dholakia <krrishdholakia@gmail.com> Co-authored-by: Shivam Rawat <161387515+shivamrawat1@users.noreply.github.com> Co-authored-by: shin-bot-litellm <shin-bot-litellm@berri.ai>
This commit is contained in:
parent
aac9907ab8
commit
9dc55ea69a
69 changed files with 4974 additions and 433 deletions
|
|
@ -5,19 +5,38 @@ Azure Model Router is a feature in Azure AI Foundry that automatically routes yo
|
|||
## Key Features
|
||||
|
||||
- **Automatic Model Selection**: Azure Model Router dynamically selects the best model for your request
|
||||
- **Cost Tracking**: LiteLLM automatically tracks costs based on the actual model used (e.g., `gpt-4.1-nano`), not the router endpoint
|
||||
- **Cost Tracking**: LiteLLM automatically tracks costs based on the actual model used (e.g., `gpt-4.1-nano`), plus the Model Router infrastructure fee
|
||||
- **Streaming Support**: Full support for streaming responses with accurate cost calculation
|
||||
- **Simple Configuration**: Easy to set up via UI or config file
|
||||
|
||||
## Model Naming Pattern
|
||||
|
||||
Use the pattern: `azure_ai/model_router/<deployment-name>`
|
||||
|
||||
**Components:**
|
||||
- `azure_ai` - The provider identifier
|
||||
- `model_router` - Indicates this is a Model Router deployment
|
||||
- `<deployment-name>` - Your actual deployment name from Azure AI Foundry (e.g., `azure-model-router`)
|
||||
|
||||
**Example:** `azure_ai/model_router/azure-model-router`
|
||||
|
||||
**How it works:**
|
||||
- LiteLLM automatically strips the `model_router/` prefix when sending requests to Azure
|
||||
- Only your deployment name (e.g., `azure-model-router`) is sent to the Azure API
|
||||
- The full path is preserved in responses and logs for proper cost tracking
|
||||
|
||||
## LiteLLM Python SDK
|
||||
|
||||
### Basic Usage
|
||||
|
||||
Use the pattern `azure_ai/model_router/<deployment-name>` where `<deployment-name>` is your Azure deployment name:
|
||||
|
||||
```python
|
||||
import litellm
|
||||
import os
|
||||
|
||||
response = litellm.completion(
|
||||
model="azure_ai/azure-model-router",
|
||||
model="azure_ai/model_router/azure-model-router", # Use your deployment name
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
api_base="https://your-endpoint.cognitiveservices.azure.com/openai/v1/",
|
||||
api_key=os.getenv("AZURE_MODEL_ROUTER_API_KEY"),
|
||||
|
|
@ -26,6 +45,13 @@ response = litellm.completion(
|
|||
print(response)
|
||||
```
|
||||
|
||||
**Pattern Explanation:**
|
||||
- `azure_ai` - The provider
|
||||
- `model_router` - Indicates this is a model router deployment
|
||||
- `azure-model-router` - Your actual deployment name from Azure AI Foundry
|
||||
|
||||
LiteLLM will automatically strip the `model_router/` prefix when sending the request to Azure, so only `azure-model-router` is sent to the API.
|
||||
|
||||
### Streaming with Usage Tracking
|
||||
|
||||
```python
|
||||
|
|
@ -33,7 +59,7 @@ import litellm
|
|||
import os
|
||||
|
||||
response = await litellm.acompletion(
|
||||
model="azure_ai/azure-model-router",
|
||||
model="azure_ai/model_router/azure-model-router", # Use your deployment name
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
api_base="https://your-endpoint.cognitiveservices.azure.com/openai/v1/",
|
||||
api_key=os.getenv("AZURE_MODEL_ROUTER_API_KEY"),
|
||||
|
|
@ -51,13 +77,15 @@ async for chunk in response:
|
|||
|
||||
```yaml
|
||||
model_list:
|
||||
- model_name: azure-model-router
|
||||
- model_name: azure-model-router # Public name for your users
|
||||
litellm_params:
|
||||
model: azure_ai/azure-model-router
|
||||
model: azure_ai/model_router/azure-model-router # Use your deployment name
|
||||
api_base: https://your-endpoint.cognitiveservices.azure.com/openai/v1/
|
||||
api_key: os.environ/AZURE_MODEL_ROUTER_API_KEY
|
||||
```
|
||||
|
||||
**Note:** Replace `azure-model-router` in the model path with your actual deployment name from Azure AI Foundry.
|
||||
|
||||
### Start Proxy
|
||||
|
||||
```bash
|
||||
|
|
@ -80,49 +108,42 @@ curl -X POST http://localhost:4000/chat/completions \
|
|||
|
||||
This walkthrough shows how to add an Azure Model Router endpoint to LiteLLM using the Admin Dashboard.
|
||||
|
||||
### Select Provider
|
||||
### Quick Start
|
||||
|
||||
1. Navigate to the **Models** page in the LiteLLM UI
|
||||
2. Select **"Azure AI Foundry (Studio)"** as the provider
|
||||
3. Enter your deployment name (e.g., `azure-model-router`)
|
||||
4. LiteLLM will automatically format it as `azure_ai/model_router/azure-model-router`
|
||||
5. Add your API base URL and API key
|
||||
6. Test and save
|
||||
|
||||
### Detailed Walkthrough
|
||||
|
||||
#### Step 1: Select Provider
|
||||
|
||||
Navigate to the Models page and select "Azure AI Foundry (Studio)" as the provider.
|
||||
|
||||
#### Navigate to Models Page
|
||||
##### Navigate to Models Page
|
||||
|
||||

|
||||
|
||||
#### Click Provider Dropdown
|
||||
##### Click Provider Dropdown
|
||||
|
||||

|
||||
|
||||
#### Choose Azure AI Foundry
|
||||
##### Choose Azure AI Foundry
|
||||
|
||||

|
||||
|
||||
### Configure Model Name
|
||||
#### Step 2: Enter Deployment Name
|
||||
|
||||
Set up the model name by entering `azure_ai/` followed by your model router deployment name from Azure.
|
||||
**New Simplified Method:** Just enter your deployment name directly in the text field. If your deployment name contains "model-router" or "model_router", LiteLLM will automatically format it as `azure_ai/model_router/<deployment-name>`.
|
||||
|
||||
#### Click Model Name Field
|
||||
**Example:**
|
||||
- Enter: `azure-model-router`
|
||||
- LiteLLM creates: `azure_ai/model_router/azure-model-router`
|
||||
|
||||

|
||||
|
||||
#### Select Custom Model Name
|
||||
|
||||

|
||||
|
||||
#### Enter LiteLLM Model Name
|
||||
|
||||

|
||||
|
||||
#### Click Custom Model Name Field
|
||||
|
||||

|
||||
|
||||
#### Type Model Prefix
|
||||
|
||||
Type `azure_ai/` as the prefix.
|
||||
|
||||

|
||||
|
||||
#### Copy Model Name from Azure Portal
|
||||
##### Copy Deployment Name from Azure Portal
|
||||
|
||||
Switch to Azure AI Foundry and copy your model router deployment name.
|
||||
|
||||
|
|
@ -130,73 +151,79 @@ Switch to Azure AI Foundry and copy your model router deployment name.
|
|||
|
||||

|
||||
|
||||
#### Paste Model Name
|
||||
##### Enter Deployment Name in LiteLLM
|
||||
|
||||
Paste to get `azure_ai/azure-model-router`.
|
||||
Paste your deployment name (e.g., `azure-model-router`) directly into the text field.
|
||||
|
||||

|
||||

|
||||
|
||||
### Configure API Base and Key
|
||||
**What happens behind the scenes:**
|
||||
- You enter: `azure-model-router`
|
||||
- LiteLLM automatically detects this is a model router deployment
|
||||
- The full model path becomes: `azure_ai/model_router/azure-model-router`
|
||||
- When making API calls, only `azure-model-router` is sent to Azure
|
||||
|
||||
#### Step 3: Configure API Base and Key
|
||||
|
||||
Copy the endpoint URL and API key from Azure portal.
|
||||
|
||||
#### Copy API Base URL from Azure
|
||||
##### Copy API Base URL from Azure
|
||||
|
||||

|
||||
|
||||
#### Enter API Base in LiteLLM
|
||||
##### Enter API Base in LiteLLM
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
#### Copy API Key from Azure
|
||||
##### Copy API Key from Azure
|
||||
|
||||

|
||||
|
||||
#### Enter API Key in LiteLLM
|
||||
##### Enter API Key in LiteLLM
|
||||
|
||||

|
||||
|
||||
### Test and Add Model
|
||||
#### Step 4: Test and Add Model
|
||||
|
||||
Verify your configuration works and save the model.
|
||||
|
||||
#### Test Connection
|
||||
##### Test Connection
|
||||
|
||||

|
||||
|
||||
#### Close Test Dialog
|
||||
##### Close Test Dialog
|
||||
|
||||

|
||||
|
||||
#### Add Model
|
||||
##### Add Model
|
||||
|
||||

|
||||
|
||||
### Verify in Playground
|
||||
#### Step 5: Verify in Playground
|
||||
|
||||
Test your model and verify cost tracking is working.
|
||||
|
||||
#### Open Playground
|
||||
##### Open Playground
|
||||
|
||||

|
||||
|
||||
#### Select Model
|
||||
##### Select Model
|
||||
|
||||

|
||||
|
||||
#### Send Test Message
|
||||
##### Send Test Message
|
||||
|
||||

|
||||
|
||||
#### View Logs
|
||||
##### View Logs
|
||||
|
||||

|
||||
|
||||
#### Verify Cost Tracking
|
||||
##### Verify Cost Tracking
|
||||
|
||||
Cost is tracked based on the actual model used (e.g., `gpt-4.1-nano`).
|
||||
Cost is tracked based on the actual model used (e.g., `gpt-4.1-nano`), plus a flat infrastructure cost of $0.14 per million input tokens for using the Model Router.
|
||||
|
||||

|
||||
|
||||
|
|
@ -205,28 +232,50 @@ Cost is tracked based on the actual model used (e.g., `gpt-4.1-nano`).
|
|||
LiteLLM automatically handles cost tracking for Azure Model Router by:
|
||||
|
||||
1. **Detecting the actual model**: When Azure Model Router routes your request to a specific model (e.g., `gpt-4.1-nano-2025-04-14`), LiteLLM extracts this from the response
|
||||
2. **Calculating accurate costs**: Costs are calculated based on the actual model used, not the router endpoint name
|
||||
2. **Calculating accurate costs**: Costs are calculated based on:
|
||||
- The actual model used (e.g., `gpt-4.1-nano` token costs)
|
||||
- Plus a flat infrastructure cost of **$0.14 per million input tokens** for using the Model Router
|
||||
3. **Streaming support**: Cost tracking works correctly for both streaming and non-streaming requests
|
||||
|
||||
### Cost Breakdown
|
||||
|
||||
When you use Azure Model Router, the total cost includes:
|
||||
|
||||
- **Model Cost**: Based on the actual model that handled your request (e.g., `gpt-4.1-nano`)
|
||||
- **Router Flat Cost**: $0.14 per million input tokens (Azure AI Foundry infrastructure fee)
|
||||
|
||||
### Example Response with Cost
|
||||
|
||||
```python
|
||||
import litellm
|
||||
|
||||
response = litellm.completion(
|
||||
model="azure_ai/azure-model-router",
|
||||
model="azure_ai/model_router/azure-model-router",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
api_base="https://your-endpoint.cognitiveservices.azure.com/openai/v1/",
|
||||
api_key="your-api-key",
|
||||
)
|
||||
|
||||
# The response will show the actual model used
|
||||
print(f"Model used: {response.model}") # e.g., "gpt-4.1-nano-2025-04-14"
|
||||
print(f"Model used: {response.model}") # e.g., "azure_ai/gpt-4.1-nano-2025-04-14"
|
||||
|
||||
# Get cost
|
||||
# Get cost (includes both model cost and router flat cost)
|
||||
from litellm import completion_cost
|
||||
cost = completion_cost(completion_response=response)
|
||||
print(f"Cost: ${cost}")
|
||||
print(f"Total cost: ${cost}")
|
||||
|
||||
# Access detailed cost breakdown
|
||||
if hasattr(response, '_hidden_params') and 'response_cost' in response._hidden_params:
|
||||
print(f"Response cost: ${response._hidden_params['response_cost']}")
|
||||
```
|
||||
|
||||
### Viewing Cost Breakdown in UI
|
||||
|
||||
When viewing logs in the LiteLLM UI, you'll see:
|
||||
- **Model Cost**: The cost for the actual model used
|
||||
- **Azure Model Router Flat Cost**: The $0.14/M input tokens infrastructure fee
|
||||
- **Total Cost**: Sum of both costs
|
||||
|
||||
This breakdown helps you understand exactly what you're paying for when using the Model Router.
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import Image from '@theme/IdealImage';
|
|||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
# Claude Code Plugin Marketplace
|
||||
# Claude Code Plugin Marketplace (Managed Skills)
|
||||
|
||||
LiteLLM AI Gateway acts as a central registry for Claude Code plugins. Admins can govern which plugins are available across the organization, and engineers can discover and install approved plugins from a single source.
|
||||
|
||||
|
|
@ -252,7 +252,7 @@ curl -X POST http://localhost:4000/claude-code/plugins \
|
|||
}'
|
||||
```
|
||||
|
||||
### 3. Share with Your Team
|
||||
### 3. Use in Claude Code
|
||||
|
||||
Send engineers the marketplace URL:
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ from litellm.llms.anthropic.cost_calculation import (
|
|||
from litellm.llms.azure.cost_calculation import (
|
||||
cost_per_token as azure_openai_cost_per_token,
|
||||
)
|
||||
from litellm.llms.azure_ai.cost_calculator import (
|
||||
cost_per_token as azure_ai_cost_per_token,
|
||||
)
|
||||
from litellm.llms.base_llm.search.transformation import SearchResponse
|
||||
from litellm.llms.bedrock.cost_calculation import (
|
||||
cost_per_token as bedrock_cost_per_token,
|
||||
|
|
@ -138,6 +141,51 @@ def _cost_per_token_custom_pricing_helper(
|
|||
return None
|
||||
|
||||
|
||||
def _get_additional_costs(
|
||||
model: str,
|
||||
custom_llm_provider: Optional[str],
|
||||
prompt_tokens: int,
|
||||
completion_tokens: int,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Calculate additional costs beyond standard token costs.
|
||||
|
||||
This function delegates to provider-specific config classes to calculate
|
||||
any additional costs like routing fees, infrastructure costs, etc.
|
||||
|
||||
Args:
|
||||
model: The model name
|
||||
custom_llm_provider: The provider name (optional)
|
||||
prompt_tokens: Number of prompt tokens
|
||||
completion_tokens: Number of completion tokens
|
||||
|
||||
Returns:
|
||||
Optional dictionary with cost names and amounts, or None if no additional costs
|
||||
"""
|
||||
if not custom_llm_provider:
|
||||
return None
|
||||
|
||||
try:
|
||||
config_class = None
|
||||
if custom_llm_provider == "azure_ai":
|
||||
from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo
|
||||
config_class = AzureFoundryModelInfo.get_azure_ai_config_for_model(model)
|
||||
# Add more providers here as needed
|
||||
# elif custom_llm_provider == "other_provider":
|
||||
# config_class = get_other_provider_config(model)
|
||||
|
||||
if config_class and hasattr(config_class, 'calculate_additional_costs'):
|
||||
return config_class.calculate_additional_costs(
|
||||
model=model,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_logger.debug(f"Error calculating additional costs: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _transcription_usage_has_token_details(
|
||||
usage_block: Optional[Usage],
|
||||
) -> bool:
|
||||
|
|
@ -427,8 +475,8 @@ def cost_per_token( # noqa: PLR0915
|
|||
|
||||
return dashscope_cost_per_token(model=model, usage=usage_block)
|
||||
elif custom_llm_provider == "azure_ai":
|
||||
return generic_cost_per_token(
|
||||
model=model, usage=usage_block, custom_llm_provider=custom_llm_provider
|
||||
return azure_ai_cost_per_token(
|
||||
model=model, usage=usage_block, response_time_ms=response_time_ms
|
||||
)
|
||||
else:
|
||||
model_info = _cached_get_model_info_helper(
|
||||
|
|
@ -805,6 +853,7 @@ def _store_cost_breakdown_in_logging_obj(
|
|||
completion_tokens_cost_usd_dollar: float,
|
||||
cost_for_built_in_tools_cost_usd_dollar: float,
|
||||
total_cost_usd_dollar: float,
|
||||
additional_costs: Optional[dict] = None,
|
||||
original_cost: Optional[float] = None,
|
||||
discount_percent: Optional[float] = None,
|
||||
discount_amount: Optional[float] = None,
|
||||
|
|
@ -821,6 +870,7 @@ def _store_cost_breakdown_in_logging_obj(
|
|||
completion_tokens_cost_usd_dollar: Cost of completion tokens (includes reasoning if applicable)
|
||||
cost_for_built_in_tools_cost_usd_dollar: Cost of built-in tools
|
||||
total_cost_usd_dollar: Total cost of request
|
||||
additional_costs: Free-form additional costs dict (e.g., {"azure_model_router_flat_cost": 0.00014})
|
||||
original_cost: Cost before discount
|
||||
discount_percent: Discount percentage applied (0.05 = 5%)
|
||||
discount_amount: Discount amount in USD
|
||||
|
|
@ -838,6 +888,7 @@ def _store_cost_breakdown_in_logging_obj(
|
|||
output_cost=completion_tokens_cost_usd_dollar,
|
||||
total_cost=total_cost_usd_dollar,
|
||||
cost_for_built_in_tools_cost_usd_dollar=cost_for_built_in_tools_cost_usd_dollar,
|
||||
additional_costs=additional_costs,
|
||||
original_cost=original_cost,
|
||||
discount_percent=discount_percent,
|
||||
discount_amount=discount_amount,
|
||||
|
|
@ -1335,6 +1386,15 @@ def completion_cost( # noqa: PLR0915
|
|||
service_tier=service_tier,
|
||||
response=completion_response,
|
||||
)
|
||||
|
||||
# Get additional costs from provider (e.g., routing fees, infrastructure costs)
|
||||
additional_costs = _get_additional_costs(
|
||||
model=model,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
)
|
||||
|
||||
_final_cost = (
|
||||
prompt_tokens_cost_usd_dollar + completion_tokens_cost_usd_dollar
|
||||
)
|
||||
|
|
@ -1374,6 +1434,7 @@ def completion_cost( # noqa: PLR0915
|
|||
completion_tokens_cost_usd_dollar=completion_tokens_cost_usd_dollar,
|
||||
cost_for_built_in_tools_cost_usd_dollar=cost_for_built_in_tools,
|
||||
total_cost_usd_dollar=_final_cost,
|
||||
additional_costs=additional_costs,
|
||||
original_cost=original_cost,
|
||||
discount_percent=discount_percent,
|
||||
discount_amount=discount_amount,
|
||||
|
|
|
|||
|
|
@ -1297,6 +1297,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
output_cost: float,
|
||||
total_cost: float,
|
||||
cost_for_built_in_tools_cost_usd_dollar: float,
|
||||
additional_costs: Optional[dict] = None,
|
||||
original_cost: Optional[float] = None,
|
||||
discount_percent: Optional[float] = None,
|
||||
discount_amount: Optional[float] = None,
|
||||
|
|
@ -1312,6 +1313,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
output_cost: Cost of output/completion tokens
|
||||
cost_for_built_in_tools_cost_usd_dollar: Cost of built-in tools
|
||||
total_cost: Total cost of request
|
||||
additional_costs: Free-form additional costs dict (e.g., {"azure_model_router_flat_cost": 0.00014})
|
||||
original_cost: Cost before discount
|
||||
discount_percent: Discount percentage (0.05 = 5%)
|
||||
discount_amount: Discount amount in USD
|
||||
|
|
@ -1327,6 +1329,10 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
tool_usage_cost=cost_for_built_in_tools_cost_usd_dollar,
|
||||
)
|
||||
|
||||
# Store additional costs if provided (free-form dict for extensibility)
|
||||
if additional_costs and isinstance(additional_costs, dict) and len(additional_costs) > 0:
|
||||
self.cost_breakdown["additional_costs"] = additional_costs
|
||||
|
||||
# Store discount information if provided
|
||||
if original_cost is not None:
|
||||
self.cost_breakdown["original_cost"] = original_cost
|
||||
|
|
|
|||
4
litellm/llms/azure_ai/azure_model_router/__init__.py
Normal file
4
litellm/llms/azure_ai/azure_model_router/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""Azure AI Foundry Model Router support."""
|
||||
from .transformation import AzureModelRouterConfig
|
||||
|
||||
__all__ = ["AzureModelRouterConfig"]
|
||||
125
litellm/llms/azure_ai/azure_model_router/transformation.py
Normal file
125
litellm/llms/azure_ai/azure_model_router/transformation.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""
|
||||
Transformation for Azure AI Foundry Model Router.
|
||||
|
||||
The Model Router is a special Azure AI deployment that automatically routes requests
|
||||
to the best available model. It has specific cost tracking requirements.
|
||||
"""
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from httpx import Response
|
||||
|
||||
from litellm.llms.azure_ai.chat.transformation import AzureAIStudioConfig
|
||||
from litellm.llms.base_llm.chat.transformation import LiteLLMLoggingObj
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
from litellm.types.utils import ModelResponse
|
||||
|
||||
|
||||
class AzureModelRouterConfig(AzureAIStudioConfig):
|
||||
"""
|
||||
Configuration for Azure AI Foundry Model Router.
|
||||
|
||||
Handles:
|
||||
- Stripping model_router prefix before sending to Azure API
|
||||
- Preserving full model path in responses for cost tracking
|
||||
- Calculating flat infrastructure costs for Model Router
|
||||
"""
|
||||
|
||||
def transform_request(
|
||||
self,
|
||||
model: str,
|
||||
messages: List[AllMessageValues],
|
||||
optional_params: dict,
|
||||
litellm_params: dict,
|
||||
headers: dict,
|
||||
) -> dict:
|
||||
"""
|
||||
Transform request for Model Router.
|
||||
|
||||
Strips the model_router/ prefix so only the deployment name is sent to Azure.
|
||||
Example: model_router/azure-model-router -> azure-model-router
|
||||
"""
|
||||
from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo
|
||||
|
||||
# Get base model name (strips routing prefixes like model_router/)
|
||||
base_model: str = AzureFoundryModelInfo.get_base_model(model)
|
||||
|
||||
return super().transform_request(
|
||||
base_model, messages, optional_params, litellm_params, headers
|
||||
)
|
||||
|
||||
def transform_response(
|
||||
self,
|
||||
model: str,
|
||||
raw_response: Response,
|
||||
model_response: ModelResponse,
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
request_data: dict,
|
||||
messages: List[AllMessageValues],
|
||||
optional_params: dict,
|
||||
litellm_params: dict,
|
||||
encoding: Any,
|
||||
api_key: Optional[str] = None,
|
||||
json_mode: Optional[bool] = None,
|
||||
) -> ModelResponse:
|
||||
"""
|
||||
Transform response for Model Router.
|
||||
|
||||
Preserves the original model path (including model_router/ prefix) in the response
|
||||
for proper cost tracking and logging.
|
||||
"""
|
||||
from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo
|
||||
|
||||
# Preserve the original model from litellm_params (includes routing prefixes like model_router/)
|
||||
# This ensures cost tracking and logging use the full model path
|
||||
original_model: str = litellm_params.get("model") or model
|
||||
if not original_model.startswith("azure_ai/"):
|
||||
# Add provider prefix if not already present
|
||||
model_response.model = f"azure_ai/{original_model}"
|
||||
else:
|
||||
model_response.model = original_model
|
||||
|
||||
# Get base model for the parent call (strips routing prefixes for API compatibility)
|
||||
base_model: str = AzureFoundryModelInfo.get_base_model(model)
|
||||
|
||||
return super().transform_response(
|
||||
model=base_model,
|
||||
raw_response=raw_response,
|
||||
model_response=model_response,
|
||||
logging_obj=logging_obj,
|
||||
request_data=request_data,
|
||||
messages=messages,
|
||||
optional_params=optional_params,
|
||||
litellm_params=litellm_params,
|
||||
encoding=encoding,
|
||||
api_key=api_key,
|
||||
json_mode=json_mode,
|
||||
)
|
||||
|
||||
def calculate_additional_costs(
|
||||
self, model: str, prompt_tokens: int, completion_tokens: int
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Calculate additional costs for Azure Model Router.
|
||||
|
||||
Adds a flat infrastructure cost of $0.14 per M input tokens for using the Model Router.
|
||||
|
||||
Args:
|
||||
model: The model name (should be a model router model)
|
||||
prompt_tokens: Number of prompt tokens
|
||||
completion_tokens: Number of completion tokens
|
||||
|
||||
Returns:
|
||||
Dictionary with additional costs, or None if not applicable.
|
||||
"""
|
||||
from litellm.llms.azure_ai.cost_calculator import (
|
||||
calculate_azure_model_router_flat_cost,
|
||||
)
|
||||
|
||||
flat_cost = calculate_azure_model_router_flat_cost(
|
||||
model=model, prompt_tokens=prompt_tokens
|
||||
)
|
||||
|
||||
if flat_cost > 0:
|
||||
return {"Azure Model Router Flat Cost": flat_cost}
|
||||
|
||||
return None
|
||||
|
|
@ -13,14 +13,21 @@ class AzureFoundryModelInfo(BaseLLMModelInfo):
|
|||
self._model = model
|
||||
|
||||
@staticmethod
|
||||
def get_azure_ai_route(model: str) -> Literal["agents", "default"]:
|
||||
def get_azure_ai_route(model: str) -> Literal["agents", "model_router", "default"]:
|
||||
"""
|
||||
Get the Azure AI route for the given model.
|
||||
|
||||
Similar to BedrockModelInfo.get_bedrock_route().
|
||||
|
||||
Supported routes:
|
||||
- agents: azure_ai/agents/<agent_id>
|
||||
- model_router: azure_ai/model_router/<actual-model-name>
|
||||
- default: standard models
|
||||
"""
|
||||
if "agents/" in model:
|
||||
return "agents"
|
||||
if "model_router/" in model or "model-router/" in model:
|
||||
return "model_router"
|
||||
return "default"
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -75,8 +82,73 @@ class AzureFoundryModelInfo(BaseLLMModelInfo):
|
|||
#########################################################
|
||||
|
||||
@staticmethod
|
||||
def get_base_model(model: str) -> Optional[str]:
|
||||
raise NotImplementedError("Azure Foundry does not support base model")
|
||||
def strip_model_router_prefix(model: str) -> str:
|
||||
"""
|
||||
Strip the model_router prefix from model name.
|
||||
|
||||
Examples:
|
||||
- "model_router/gpt-4o" -> "gpt-4o"
|
||||
- "model-router/gpt-4o" -> "gpt-4o"
|
||||
- "gpt-4o" -> "gpt-4o"
|
||||
|
||||
Args:
|
||||
model: Model name potentially with model_router prefix
|
||||
|
||||
Returns:
|
||||
Model name without the prefix
|
||||
"""
|
||||
if "model_router/" in model:
|
||||
return model.split("model_router/", 1)[1]
|
||||
if "model-router/" in model:
|
||||
return model.split("model-router/", 1)[1]
|
||||
return model
|
||||
|
||||
@staticmethod
|
||||
def get_base_model(model: str) -> str:
|
||||
"""
|
||||
Get the base model name, stripping any Azure AI routing prefixes.
|
||||
|
||||
Args:
|
||||
model: Model name potentially with routing prefixes
|
||||
|
||||
Returns:
|
||||
Base model name
|
||||
"""
|
||||
# Strip model_router prefix if present
|
||||
model = AzureFoundryModelInfo.strip_model_router_prefix(model)
|
||||
return model
|
||||
|
||||
@staticmethod
|
||||
def get_azure_ai_config_for_model(model: str):
|
||||
"""
|
||||
Get the appropriate Azure AI config class for the given model.
|
||||
|
||||
Routes to specialized configs based on model type:
|
||||
- Model Router: AzureModelRouterConfig
|
||||
- Claude models: AzureAnthropicConfig
|
||||
- Default: AzureAIStudioConfig
|
||||
|
||||
Args:
|
||||
model: The model name
|
||||
|
||||
Returns:
|
||||
The appropriate config instance
|
||||
"""
|
||||
azure_ai_route = AzureFoundryModelInfo.get_azure_ai_route(model)
|
||||
|
||||
if azure_ai_route == "model_router":
|
||||
from litellm.llms.azure_ai.azure_model_router.transformation import (
|
||||
AzureModelRouterConfig,
|
||||
)
|
||||
return AzureModelRouterConfig()
|
||||
elif "claude" in model.lower():
|
||||
from litellm.llms.azure_ai.anthropic.transformation import (
|
||||
AzureAnthropicConfig,
|
||||
)
|
||||
return AzureAnthropicConfig()
|
||||
else:
|
||||
from litellm.llms.azure_ai.chat.transformation import AzureAIStudioConfig
|
||||
return AzureAIStudioConfig()
|
||||
|
||||
def validate_environment(
|
||||
self,
|
||||
|
|
|
|||
102
litellm/llms/azure_ai/cost_calculator.py
Normal file
102
litellm/llms/azure_ai/cost_calculator.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""
|
||||
Azure AI cost calculation helper.
|
||||
Handles Azure AI Foundry Model Router flat cost and other Azure AI specific pricing.
|
||||
"""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import generic_cost_per_token
|
||||
from litellm.types.utils import Usage
|
||||
from litellm.utils import get_model_info
|
||||
|
||||
|
||||
def _is_azure_model_router(model: str) -> bool:
|
||||
"""
|
||||
Check if the model is Azure AI Foundry Model Router.
|
||||
|
||||
Detects patterns like:
|
||||
- "azure-model-router"
|
||||
- "model-router"
|
||||
- "model_router/<actual-model>"
|
||||
- "model-router/<actual-model>"
|
||||
|
||||
Args:
|
||||
model: The model name
|
||||
|
||||
Returns:
|
||||
bool: True if this is a model router model
|
||||
"""
|
||||
model_lower = model.lower()
|
||||
return (
|
||||
"model-router" in model_lower
|
||||
or "model_router" in model_lower
|
||||
or model_lower == "azure-model-router"
|
||||
)
|
||||
|
||||
|
||||
def calculate_azure_model_router_flat_cost(model: str, prompt_tokens: int) -> float:
|
||||
"""
|
||||
Calculate the flat cost for Azure AI Foundry Model Router.
|
||||
|
||||
Args:
|
||||
model: The model name (should be a model router model)
|
||||
prompt_tokens: Number of prompt tokens
|
||||
|
||||
Returns:
|
||||
float: The flat cost in USD, or 0.0 if not applicable
|
||||
"""
|
||||
if not _is_azure_model_router(model):
|
||||
return 0.0
|
||||
|
||||
# Get the model router pricing from model_prices_and_context_window.json
|
||||
# Use "model_router" as the key (without actual model name suffix)
|
||||
model_info = get_model_info(model="model_router", custom_llm_provider="azure_ai")
|
||||
router_flat_cost_per_token = model_info.get("input_cost_per_token", 0)
|
||||
|
||||
if router_flat_cost_per_token > 0:
|
||||
return prompt_tokens * router_flat_cost_per_token
|
||||
|
||||
return 0.0
|
||||
|
||||
|
||||
def cost_per_token(
|
||||
model: str, usage: Usage, response_time_ms: Optional[float] = 0.0
|
||||
) -> Tuple[float, float]:
|
||||
"""
|
||||
Calculate the cost per token for Azure AI models.
|
||||
|
||||
For Azure AI Foundry Model Router:
|
||||
- Adds a flat cost of $0.14 per million input tokens (from model_prices_and_context_window.json)
|
||||
- Plus the cost of the actual model used (handled by generic_cost_per_token)
|
||||
|
||||
Args:
|
||||
model: str, the model name without provider prefix
|
||||
usage: LiteLLM Usage block
|
||||
response_time_ms: Optional response time in milliseconds
|
||||
|
||||
Returns:
|
||||
Tuple[float, float] - prompt_cost_in_usd, completion_cost_in_usd
|
||||
"""
|
||||
# Calculate base cost using generic cost calculator
|
||||
prompt_cost, completion_cost = generic_cost_per_token(
|
||||
model=model,
|
||||
usage=usage,
|
||||
custom_llm_provider="azure_ai",
|
||||
)
|
||||
|
||||
# Add flat cost for Azure Model Router
|
||||
# The flat cost is defined in model_prices_and_context_window.json for azure_ai/azure-model-router
|
||||
if _is_azure_model_router(model):
|
||||
router_flat_cost = calculate_azure_model_router_flat_cost(model, usage.prompt_tokens)
|
||||
|
||||
if router_flat_cost > 0:
|
||||
verbose_logger.debug(
|
||||
f"Azure AI Model Router flat cost: ${router_flat_cost:.6f} "
|
||||
f"({usage.prompt_tokens} tokens × ${router_flat_cost / usage.prompt_tokens:.9f}/token)"
|
||||
)
|
||||
|
||||
# Add flat cost to prompt cost
|
||||
prompt_cost += router_flat_cost
|
||||
|
||||
return prompt_cost, completion_cost
|
||||
|
|
@ -437,3 +437,23 @@ class BaseConfig(ABC):
|
|||
By default, this is true for almost all providers.
|
||||
"""
|
||||
return True
|
||||
|
||||
def calculate_additional_costs(
|
||||
self, model: str, prompt_tokens: int, completion_tokens: int
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Calculate any additional costs beyond standard token costs.
|
||||
|
||||
This is used for provider-specific infrastructure costs, routing fees, etc.
|
||||
|
||||
Args:
|
||||
model: The model name
|
||||
prompt_tokens: Number of prompt tokens
|
||||
completion_tokens: Number of completion tokens
|
||||
|
||||
Returns:
|
||||
Optional dictionary with cost names and amounts, e.g.:
|
||||
{"Infrastructure Fee": 0.001, "Routing Cost": 0.0005}
|
||||
Returns None if no additional costs apply.
|
||||
"""
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1517,6 +1517,14 @@
|
|||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"azure_ai/model_router": {
|
||||
"input_cost_per_token": 1.4e-07,
|
||||
"output_cost_per_token": 0,
|
||||
"litellm_provider": "azure_ai",
|
||||
"mode": "chat",
|
||||
"source": "https://azure.microsoft.com/en-us/pricing/details/ai-services/",
|
||||
"comment": "Flat cost of $0.14 per M input tokens for Azure AI Foundry Model Router infrastructure. Use pattern: azure_ai/model_router/<deployment-name> where deployment-name is your Azure deployment (e.g., azure-model-router)"
|
||||
},
|
||||
"azure/eu/gpt-4o-2024-08-06": {
|
||||
"deprecation_date": "2026-02-27",
|
||||
"cache_read_input_token_cost": 1.375e-06,
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 24 KiB |
|
|
@ -623,6 +623,16 @@ class ProxyBaseLLMRequestProcessing:
|
|||
|
||||
return self.data, logging_obj
|
||||
|
||||
@staticmethod
|
||||
def _get_model_id_from_response(hidden_params: dict, data: dict) -> str:
|
||||
"""Extract model_id from hidden_params with fallback to litellm_metadata."""
|
||||
model_id = hidden_params.get("model_id", None) or ""
|
||||
if not model_id:
|
||||
litellm_metadata = data.get("litellm_metadata", {}) or {}
|
||||
model_info = litellm_metadata.get("model_info", {}) or {}
|
||||
model_id = model_info.get("id", "") or ""
|
||||
return model_id
|
||||
|
||||
async def base_process_llm_request(
|
||||
self,
|
||||
request: Request,
|
||||
|
|
@ -757,13 +767,7 @@ class ProxyBaseLLMRequestProcessing:
|
|||
response = responses[1]
|
||||
|
||||
hidden_params = getattr(response, "_hidden_params", {}) or {}
|
||||
model_id = hidden_params.get("model_id", None) or ""
|
||||
|
||||
# Fallback: extract model_id from litellm_metadata if not in hidden_params
|
||||
if not model_id:
|
||||
litellm_metadata = self.data.get("litellm_metadata", {}) or {}
|
||||
model_info = litellm_metadata.get("model_info", {}) or {}
|
||||
model_id = model_info.get("id", "") or ""
|
||||
model_id = self._get_model_id_from_response(hidden_params, self.data)
|
||||
|
||||
cache_key, api_base, response_cost = (
|
||||
hidden_params.get("cache_key", None) or "",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import sys
|
|||
import time
|
||||
import traceback
|
||||
import warnings
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import enum
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
|
|
|
|||
|
|
@ -869,9 +869,6 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
|
|||
try:
|
||||
chunk = self.litellm_custom_stream_wrapper.__next__()
|
||||
self._ensure_output_item_for_chunk(chunk)
|
||||
# Emit any just-queued output_item event
|
||||
if self._pending_response_events:
|
||||
return self._pending_response_events.pop(0)
|
||||
self.collected_chat_completion_chunks.append(chunk)
|
||||
response_api_chunk = (
|
||||
self._transform_chat_completion_chunk_to_response_api_chunk(
|
||||
|
|
@ -879,7 +876,9 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
|
|||
)
|
||||
)
|
||||
if response_api_chunk:
|
||||
return response_api_chunk
|
||||
self._pending_response_events.append(response_api_chunk)
|
||||
if self._pending_response_events:
|
||||
return self._pending_response_events.pop(0)
|
||||
# Otherwise, loop to next chunk
|
||||
except StopIteration:
|
||||
return self.common_done_event_logic(sync_mode=True)
|
||||
|
|
|
|||
|
|
@ -2637,6 +2637,7 @@ class CostBreakdown(TypedDict, total=False):
|
|||
)
|
||||
total_cost: float # Total cost (input + output + tool usage)
|
||||
tool_usage_cost: float # Cost of usage of built-in tools
|
||||
additional_costs: Dict[str, float] # Free-form additional costs (e.g., {"azure_model_router_flat_cost": 0.00014})
|
||||
original_cost: float # Cost before discount (optional)
|
||||
discount_percent: float # Discount percentage applied (e.g., 0.05 = 5%) (optional)
|
||||
discount_amount: float # Discount amount in USD (optional)
|
||||
|
|
|
|||
|
|
@ -7914,9 +7914,8 @@ class ProviderConfigManager:
|
|||
@staticmethod
|
||||
def _get_azure_ai_config(model: str) -> BaseConfig:
|
||||
"""Get Azure AI config based on model type."""
|
||||
if "claude" in model.lower():
|
||||
return litellm.AzureAnthropicConfig()
|
||||
return litellm.AzureAIStudioConfig()
|
||||
from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo
|
||||
return AzureFoundryModelInfo.get_azure_ai_config_for_model(model)
|
||||
|
||||
@staticmethod
|
||||
def _get_vertex_ai_config(model: str) -> BaseConfig:
|
||||
|
|
|
|||
|
|
@ -1517,6 +1517,14 @@
|
|||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"azure_ai/model_router": {
|
||||
"input_cost_per_token": 1.4e-07,
|
||||
"output_cost_per_token": 0,
|
||||
"litellm_provider": "azure_ai",
|
||||
"mode": "chat",
|
||||
"source": "https://azure.microsoft.com/en-us/pricing/details/ai-services/",
|
||||
"comment": "Flat cost of $0.14 per M input tokens for Azure AI Foundry Model Router infrastructure. Use pattern: azure_ai/model_router/<deployment-name> where deployment-name is your Azure deployment (e.g., azure-model-router)"
|
||||
},
|
||||
"azure/eu/gpt-4o-2024-08-06": {
|
||||
"deprecation_date": "2026-02-27",
|
||||
"cache_read_input_token_cost": 1.375e-06,
|
||||
|
|
|
|||
|
|
@ -373,10 +373,17 @@ def test_completion_azure_ai_gpt_4o_with_flexible_api_base(api_base):
|
|||
async def test_azure_ai_model_router():
|
||||
"""
|
||||
Test Azure AI model router non-streaming response cost tracking.
|
||||
Verifies that the flat cost of $0.14 per M input tokens is applied.
|
||||
|
||||
Tests the pattern: azure_ai/model_router/<deployment-name>
|
||||
Where deployment-name is the Azure deployment (e.g., "azure-model-router").
|
||||
The model_router prefix is stripped before sending to Azure API.
|
||||
"""
|
||||
from litellm.llms.azure_ai.cost_calculator import calculate_azure_model_router_flat_cost
|
||||
|
||||
litellm._turn_on_debug()
|
||||
response = await litellm.acompletion(
|
||||
model="azure_ai/azure-model-router",
|
||||
model="azure_ai/model_router/azure-model-router",
|
||||
messages=[{"role": "user", "content": "hi who is this"}],
|
||||
api_base="https://ishaa-mh6uutut-swedencentral.cognitiveservices.azure.com/openai/v1/",
|
||||
api_key=os.getenv("AZURE_MODEL_ROUTER_API_KEY"),
|
||||
|
|
@ -387,6 +394,25 @@ async def test_azure_ai_model_router():
|
|||
tracked_cost = response._hidden_params["response_cost"]
|
||||
assert tracked_cost > 0
|
||||
print("Tracked cost: ", tracked_cost)
|
||||
|
||||
# Verify flat cost is included using the helper function
|
||||
usage = response.usage
|
||||
if usage and usage.prompt_tokens:
|
||||
expected_flat_cost = calculate_azure_model_router_flat_cost(
|
||||
model="model_router/azure-model-router",
|
||||
prompt_tokens=usage.prompt_tokens
|
||||
)
|
||||
print(f"Prompt tokens: {usage.prompt_tokens}")
|
||||
print(f"Expected flat cost: ${expected_flat_cost:.9f}")
|
||||
print(f"Total tracked cost: ${tracked_cost:.9f}")
|
||||
|
||||
# Total cost should be at least the flat cost
|
||||
assert tracked_cost >= expected_flat_cost, (
|
||||
f"Cost ${tracked_cost:.9f} should be >= flat cost ${expected_flat_cost:.9f}"
|
||||
)
|
||||
|
||||
# Verify the flat cost is non-zero
|
||||
assert expected_flat_cost > 0, "Flat cost should be greater than 0"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -14,9 +14,11 @@ from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions
|
|||
|
||||
|
||||
# Test models from proxy_config.yaml
|
||||
# Note: bedrock-converse-claude-sonnet-4.5 removed temporarily as the Bedrock Converse API
|
||||
# for Claude Sonnet 4.5 may not be available in all regions/accounts
|
||||
TEST_MODELS = [
|
||||
("bedrock-claude-sonnet-4.5", "Bedrock Invoke API"),
|
||||
("bedrock-converse-claude-sonnet-4.5", "Bedrock Converse API"),
|
||||
# ("bedrock-converse-claude-sonnet-4.5", "Bedrock Converse API"), # Disabled: not yet available in CI
|
||||
("bedrock-nova-premier", "AWS Nova Premier"),
|
||||
]
|
||||
|
||||
|
|
|
|||
345
tests/test_litellm/llms/azure_ai/test_cost_calculator.py
Normal file
345
tests/test_litellm/llms/azure_ai/test_cost_calculator.py
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
"""
|
||||
Test Azure AI cost calculator, especially Model Router flat cost.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.llms.azure_ai.cost_calculator import (
|
||||
_is_azure_model_router,
|
||||
cost_per_token,
|
||||
)
|
||||
from litellm.types.utils import Usage
|
||||
from litellm.utils import get_model_info
|
||||
|
||||
# Get the flat cost from model_prices_and_context_window.json
|
||||
_model_info = get_model_info(model="model_router", custom_llm_provider="azure_ai")
|
||||
AZURE_MODEL_ROUTER_FLAT_COST_PER_M_INPUT_TOKENS = _model_info.get("input_cost_per_token", 0) * 1_000_000
|
||||
|
||||
|
||||
class TestAzureModelRouterDetection:
|
||||
"""Test that we correctly identify Azure Model Router models.
|
||||
|
||||
Model Router deployments follow the pattern: model_router/<deployment-name>
|
||||
where deployment-name is the Azure deployment (e.g., 'azure-model-router', 'prod-router')
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model,expected",
|
||||
[
|
||||
# Deployment names containing 'model-router' or 'model_router'
|
||||
("azure-model-router", True),
|
||||
("AZURE-MODEL-ROUTER", True),
|
||||
("model-router", True),
|
||||
("MODEL-ROUTER", True),
|
||||
("my-model-router-deployment", True),
|
||||
("prod-model_router", True),
|
||||
# New pattern: model_router/<deployment-name>
|
||||
("model_router/azure-model-router", True),
|
||||
("model-router/prod-router", True),
|
||||
("model_router/my-deployment", True),
|
||||
("MODEL_ROUTER/AZURE-MODEL-ROUTER", True),
|
||||
# Non-router models
|
||||
("gpt-4o", False),
|
||||
("gpt-4o-mini", False),
|
||||
("claude-sonnet-4-5", False),
|
||||
("my-regular-deployment", False),
|
||||
],
|
||||
)
|
||||
def test_is_azure_model_router(self, model: str, expected: bool):
|
||||
"""Test Azure Model Router detection."""
|
||||
assert _is_azure_model_router(model) == expected
|
||||
|
||||
|
||||
class TestAzureModelRouterPrefix:
|
||||
"""Test Azure Model Router prefix stripping."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model,expected",
|
||||
[
|
||||
# Model router deployments - the deployment name comes after model_router/
|
||||
("model_router/azure-model-router", "azure-model-router"),
|
||||
("model-router/my-router-deployment", "my-router-deployment"),
|
||||
("model_router/prod-router", "prod-router"),
|
||||
# Non-router models - should pass through unchanged
|
||||
("gpt-4o", "gpt-4o"),
|
||||
("azure-model-router", "azure-model-router"),
|
||||
("claude-sonnet-4", "claude-sonnet-4"),
|
||||
],
|
||||
)
|
||||
def test_strip_model_router_prefix(self, model: str, expected: str):
|
||||
"""Test that model_router prefix is stripped correctly.
|
||||
|
||||
The pattern is: model_router/<deployment-name>
|
||||
where deployment-name is the Azure deployment (e.g., 'azure-model-router', 'prod-router')
|
||||
"""
|
||||
from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo
|
||||
|
||||
result = AzureFoundryModelInfo.strip_model_router_prefix(model)
|
||||
assert result == expected
|
||||
|
||||
|
||||
class TestAzureModelRouterFlatCost:
|
||||
"""Test Azure AI Foundry Model Router flat cost calculation."""
|
||||
|
||||
def test_model_router_flat_cost_basic(self):
|
||||
"""Test that flat cost is added for Model Router requests."""
|
||||
model = "azure-model-router"
|
||||
usage = Usage(
|
||||
prompt_tokens=1000,
|
||||
completion_tokens=500,
|
||||
total_tokens=1500,
|
||||
)
|
||||
|
||||
prompt_cost, completion_cost = cost_per_token(model=model, usage=usage)
|
||||
|
||||
# Calculate expected flat cost
|
||||
expected_flat_cost = (
|
||||
usage.prompt_tokens * AZURE_MODEL_ROUTER_FLAT_COST_PER_M_INPUT_TOKENS / 1_000_000
|
||||
)
|
||||
|
||||
# Flat cost should be $0.00014 (1000 tokens × $0.14 / 1M tokens)
|
||||
assert expected_flat_cost == pytest.approx(0.00014, rel=1e-9)
|
||||
|
||||
# Prompt cost should include the flat cost
|
||||
# (plus any base cost from the actual model used, which might be 0 if not in model_cost)
|
||||
assert prompt_cost >= expected_flat_cost
|
||||
print(
|
||||
f"Model Router flat cost for {usage.prompt_tokens} tokens: ${expected_flat_cost:.6f}"
|
||||
)
|
||||
print(f"Total prompt cost: ${prompt_cost:.6f}")
|
||||
|
||||
def test_model_router_flat_cost_large_request(self):
|
||||
"""Test flat cost calculation for larger requests."""
|
||||
model = "model-router"
|
||||
usage = Usage(
|
||||
prompt_tokens=100_000,
|
||||
completion_tokens=50_000,
|
||||
total_tokens=150_000,
|
||||
)
|
||||
|
||||
prompt_cost, completion_cost = cost_per_token(model=model, usage=usage)
|
||||
|
||||
# Calculate expected flat cost
|
||||
expected_flat_cost = (
|
||||
usage.prompt_tokens * AZURE_MODEL_ROUTER_FLAT_COST_PER_M_INPUT_TOKENS / 1_000_000
|
||||
)
|
||||
|
||||
# Flat cost should be $0.014 (100k tokens × $0.14 / 1M tokens)
|
||||
assert expected_flat_cost == pytest.approx(0.014, rel=1e-9)
|
||||
assert prompt_cost >= expected_flat_cost
|
||||
print(
|
||||
f"Model Router flat cost for {usage.prompt_tokens} tokens: ${expected_flat_cost:.6f}"
|
||||
)
|
||||
print(f"Total prompt cost: ${prompt_cost:.6f}")
|
||||
|
||||
def test_model_router_flat_cost_1m_tokens(self):
|
||||
"""Test flat cost for exactly 1 million input tokens."""
|
||||
model = "azure-model-router"
|
||||
usage = Usage(
|
||||
prompt_tokens=1_000_000,
|
||||
completion_tokens=100_000,
|
||||
total_tokens=1_100_000,
|
||||
)
|
||||
|
||||
prompt_cost, completion_cost = cost_per_token(model=model, usage=usage)
|
||||
|
||||
# Calculate expected flat cost
|
||||
expected_flat_cost = AZURE_MODEL_ROUTER_FLAT_COST_PER_M_INPUT_TOKENS
|
||||
|
||||
# Flat cost should be exactly $0.14 for 1M tokens
|
||||
assert expected_flat_cost == pytest.approx(0.14, rel=1e-9)
|
||||
assert prompt_cost >= expected_flat_cost
|
||||
print(f"Model Router flat cost for 1M tokens: ${expected_flat_cost:.6f}")
|
||||
print(f"Total prompt cost: ${prompt_cost:.6f}")
|
||||
|
||||
def test_non_model_router_no_flat_cost(self):
|
||||
"""Test that non-Model Router models don't get the flat cost."""
|
||||
model = "gpt-4o"
|
||||
usage = Usage(
|
||||
prompt_tokens=1000,
|
||||
completion_tokens=500,
|
||||
total_tokens=1500,
|
||||
)
|
||||
|
||||
prompt_cost, completion_cost = cost_per_token(model=model, usage=usage)
|
||||
|
||||
# No flat cost should be added for non-Model Router models
|
||||
# The cost might be 0 or based on the model's pricing
|
||||
print(f"Non-Model Router prompt cost: ${prompt_cost:.6f}")
|
||||
# We just ensure it doesn't crash and returns valid values
|
||||
assert prompt_cost >= 0
|
||||
assert completion_cost >= 0
|
||||
|
||||
def test_model_router_with_cached_tokens(self):
|
||||
"""Test Model Router flat cost with cached tokens."""
|
||||
model = "azure-model-router"
|
||||
usage = Usage(
|
||||
prompt_tokens=2000,
|
||||
completion_tokens=800,
|
||||
total_tokens=2800,
|
||||
cache_read_input_tokens=500,
|
||||
cache_creation_input_tokens=200,
|
||||
)
|
||||
|
||||
prompt_cost, completion_cost = cost_per_token(model=model, usage=usage)
|
||||
|
||||
# Flat cost is based on ALL prompt tokens (including cached)
|
||||
expected_flat_cost = (
|
||||
usage.prompt_tokens * AZURE_MODEL_ROUTER_FLAT_COST_PER_M_INPUT_TOKENS / 1_000_000
|
||||
)
|
||||
|
||||
assert expected_flat_cost == pytest.approx(0.00028, rel=1e-9)
|
||||
assert prompt_cost >= expected_flat_cost
|
||||
print(
|
||||
f"Model Router flat cost with caching for {usage.prompt_tokens} tokens: ${expected_flat_cost:.6f}"
|
||||
)
|
||||
print(f"Total prompt cost: ${prompt_cost:.6f}")
|
||||
|
||||
|
||||
class TestAzureModelRouterCostBreakdown:
|
||||
"""Test that Azure Model Router flat cost is tracked in cost breakdown."""
|
||||
|
||||
def test_flat_cost_calculation_helper(self):
|
||||
"""Test that flat cost can be calculated using the helper function."""
|
||||
from litellm.llms.azure_ai.cost_calculator import (
|
||||
calculate_azure_model_router_flat_cost,
|
||||
)
|
||||
|
||||
model = "azure-model-router"
|
||||
prompt_tokens = 10000
|
||||
|
||||
# Calculate flat cost using helper function
|
||||
flat_cost = calculate_azure_model_router_flat_cost(
|
||||
model=model, prompt_tokens=prompt_tokens
|
||||
)
|
||||
|
||||
# Expected flat cost
|
||||
expected_flat_cost = (
|
||||
prompt_tokens * AZURE_MODEL_ROUTER_FLAT_COST_PER_M_INPUT_TOKENS / 1_000_000
|
||||
)
|
||||
|
||||
assert flat_cost > 0
|
||||
assert flat_cost == pytest.approx(expected_flat_cost, rel=1e-9)
|
||||
print(f"Flat cost calculated: ${flat_cost:.6f}")
|
||||
|
||||
def test_flat_cost_integration_with_completion_cost(self):
|
||||
"""Test that flat cost is properly integrated into completion_cost calculation."""
|
||||
import litellm
|
||||
from litellm.cost_calculator import completion_cost
|
||||
from litellm.types.utils import Choices, Message, ModelResponse, Usage
|
||||
|
||||
# Create a mock response for azure_ai model router
|
||||
response = ModelResponse(
|
||||
id="test-123",
|
||||
choices=[
|
||||
Choices(
|
||||
finish_reason="stop",
|
||||
index=0,
|
||||
message=Message(
|
||||
role="assistant",
|
||||
content="Test response",
|
||||
),
|
||||
)
|
||||
],
|
||||
created=1234567890,
|
||||
model="azure-model-router",
|
||||
object="chat.completion",
|
||||
usage=Usage(
|
||||
prompt_tokens=5000,
|
||||
completion_tokens=2000,
|
||||
total_tokens=7000,
|
||||
),
|
||||
)
|
||||
|
||||
# Set hidden params for provider
|
||||
response._hidden_params = {"custom_llm_provider": "azure_ai"}
|
||||
|
||||
# Calculate cost
|
||||
cost = completion_cost(
|
||||
completion_response=response,
|
||||
model="azure-model-router",
|
||||
custom_llm_provider="azure_ai",
|
||||
)
|
||||
|
||||
# Expected flat cost
|
||||
expected_flat_cost = (
|
||||
5000 * AZURE_MODEL_ROUTER_FLAT_COST_PER_M_INPUT_TOKENS / 1_000_000
|
||||
)
|
||||
|
||||
# Cost should include the flat cost
|
||||
assert cost > expected_flat_cost
|
||||
print(f"Total cost with flat fee: ${cost:.6f}")
|
||||
print(f"Expected minimum flat cost: ${expected_flat_cost:.6f}")
|
||||
|
||||
def test_additional_costs_in_cost_breakdown(self):
|
||||
"""Test that Azure Model Router flat cost appears in additional_costs dict."""
|
||||
from datetime import datetime
|
||||
|
||||
from litellm.cost_calculator import completion_cost
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.types.utils import Choices, Message, ModelResponse, Usage
|
||||
|
||||
# Create logging object with required parameters
|
||||
logging_obj = Logging(
|
||||
model="azure-model-router",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
stream=False,
|
||||
call_type="completion",
|
||||
start_time=datetime.now(),
|
||||
litellm_call_id="test-123",
|
||||
function_id="test-function",
|
||||
)
|
||||
|
||||
# Create a mock response for azure_ai model router
|
||||
response = ModelResponse(
|
||||
id="test-123",
|
||||
choices=[
|
||||
Choices(
|
||||
finish_reason="stop",
|
||||
index=0,
|
||||
message=Message(
|
||||
role="assistant",
|
||||
content="Test response",
|
||||
),
|
||||
)
|
||||
],
|
||||
created=1234567890,
|
||||
model="azure-model-router",
|
||||
object="chat.completion",
|
||||
usage=Usage(
|
||||
prompt_tokens=5000,
|
||||
completion_tokens=2000,
|
||||
total_tokens=7000,
|
||||
),
|
||||
)
|
||||
|
||||
# Set hidden params for provider
|
||||
response._hidden_params = {"custom_llm_provider": "azure_ai"}
|
||||
|
||||
# Calculate cost with logging object
|
||||
cost = completion_cost(
|
||||
completion_response=response,
|
||||
model="azure-model-router",
|
||||
custom_llm_provider="azure_ai",
|
||||
litellm_logging_obj=logging_obj,
|
||||
)
|
||||
|
||||
# Check that cost breakdown contains additional_costs
|
||||
assert hasattr(logging_obj, "cost_breakdown")
|
||||
assert logging_obj.cost_breakdown is not None
|
||||
assert "additional_costs" in logging_obj.cost_breakdown
|
||||
assert isinstance(logging_obj.cost_breakdown["additional_costs"], dict)
|
||||
|
||||
# Check that the Azure Model Router flat cost is in additional_costs
|
||||
additional_costs = logging_obj.cost_breakdown["additional_costs"]
|
||||
assert "Azure Model Router Flat Cost" in additional_costs
|
||||
|
||||
# Verify the flat cost value
|
||||
expected_flat_cost = (
|
||||
5000 * AZURE_MODEL_ROUTER_FLAT_COST_PER_M_INPUT_TOKENS / 1_000_000
|
||||
)
|
||||
actual_flat_cost = additional_costs["Azure Model Router Flat Cost"]
|
||||
assert actual_flat_cost == pytest.approx(expected_flat_cost, rel=1e-9)
|
||||
|
||||
print(f"Additional costs in breakdown: {additional_costs}")
|
||||
print(f"Azure Model Router Flat Cost: ${actual_flat_cost:.6f}")
|
||||
|
|
@ -335,11 +335,11 @@ def test_advanced_tool_use_header_translation_for_opus_4_5():
|
|||
|
||||
def test_advanced_tool_use_header_filtered_for_non_opus_4_5():
|
||||
"""
|
||||
Test that advanced-tool-use-2025-11-20 header is filtered out for non-Opus 4.5 models
|
||||
without adding Bedrock-specific headers.
|
||||
Test that advanced-tool-use-2025-11-20 header is filtered out for models
|
||||
that don't support tool search on Bedrock.
|
||||
|
||||
The translation to tool-search-tool-2025-10-19 and tool-examples-2025-10-29 should
|
||||
only happen for Claude Opus 4.5.
|
||||
Tool search is supported on: Claude Opus 4.5, Claude Sonnet 4.5
|
||||
Tool search is NOT supported on: Claude 3.5 Sonnet and earlier
|
||||
"""
|
||||
from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation import (
|
||||
AmazonAnthropicClaudeMessagesConfig,
|
||||
|
|
@ -360,9 +360,9 @@ def test_advanced_tool_use_header_filtered_for_non_opus_4_5():
|
|||
"anthropic-beta": "advanced-tool-use-2025-11-20"
|
||||
}
|
||||
|
||||
# Test with Claude Sonnet 4.5 (not Opus 4.5)
|
||||
# Test with Claude 3.5 Sonnet (does NOT support tool search on Bedrock)
|
||||
result = config.transform_anthropic_messages_request(
|
||||
model="anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
model="anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
messages=messages,
|
||||
anthropic_messages_optional_request_params=anthropic_messages_optional_request_params,
|
||||
litellm_params={},
|
||||
|
|
@ -374,11 +374,11 @@ def test_advanced_tool_use_header_filtered_for_non_opus_4_5():
|
|||
assert "advanced-tool-use-2025-11-20" not in beta_headers, \
|
||||
"advanced-tool-use header should be removed for Bedrock"
|
||||
|
||||
# Verify Bedrock-specific headers were NOT added (only for Opus 4.5)
|
||||
# Verify Bedrock-specific headers were NOT added (only for Opus 4.5 and Sonnet 4.5)
|
||||
assert "tool-search-tool-2025-10-19" not in beta_headers, \
|
||||
"tool-search-tool should not be added for non-Opus 4.5 models"
|
||||
"tool-search-tool should not be added for models without tool search support"
|
||||
assert "tool-examples-2025-10-29" not in beta_headers, \
|
||||
"tool-examples should not be added for non-Opus 4.5 models"
|
||||
"tool-examples should not be added for models without tool search support"
|
||||
|
||||
|
||||
def test_advanced_tool_use_header_translation_with_multiple_beta_headers():
|
||||
|
|
|
|||
|
|
@ -1566,4 +1566,82 @@ class TestStreamingIDConsistency:
|
|||
|
||||
# Verify it matches the cached ID
|
||||
assert iterator._cached_item_id is not None
|
||||
assert iterator._cached_item_id == text_done_id
|
||||
assert iterator._cached_item_id == text_done_id
|
||||
|
||||
def test_first_chunk_text_delta_emitted_anthropic_style(self):
|
||||
"""
|
||||
When the first chunk has content (e.g. Anthropic content_block_start with initial text),
|
||||
that content must be emitted as output_text.delta. Previously the first chunk was
|
||||
dropped because we returned output_item_added and never transformed the chunk.
|
||||
"""
|
||||
from unittest.mock import Mock
|
||||
|
||||
import litellm
|
||||
from litellm.responses.litellm_completion_transformation.streaming_iterator import (
|
||||
LiteLLMCompletionStreamingIterator,
|
||||
)
|
||||
from litellm.types.llms.openai import ResponsesAPIStreamEvents
|
||||
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices
|
||||
|
||||
chunk_with_initial_text = ModelResponseStream(
|
||||
id="msg_abc",
|
||||
choices=[
|
||||
StreamingChoices(
|
||||
index=0,
|
||||
delta=Delta(content="I", role="assistant"),
|
||||
finish_reason=None,
|
||||
)
|
||||
],
|
||||
created=1234567890,
|
||||
model="anthropic/claude-sonnet-4-5",
|
||||
object="chat.completion.chunk",
|
||||
)
|
||||
chunk_continuation = ModelResponseStream(
|
||||
id="msg_abc",
|
||||
choices=[
|
||||
StreamingChoices(
|
||||
index=0,
|
||||
delta=Delta(content="'m happy", role=None),
|
||||
finish_reason=None,
|
||||
)
|
||||
],
|
||||
created=1234567890,
|
||||
model="anthropic/claude-sonnet-4-5",
|
||||
object="chat.completion.chunk",
|
||||
)
|
||||
|
||||
mock_stream_wrapper = Mock(spec=litellm.CustomStreamWrapper)
|
||||
mock_stream_wrapper.logging_obj = Mock()
|
||||
mock_stream_wrapper.logging_obj._response_cost_calculator = Mock(return_value=0.001)
|
||||
chunk_list = [chunk_with_initial_text, chunk_continuation]
|
||||
|
||||
def next_chunk():
|
||||
if not chunk_list:
|
||||
raise StopIteration
|
||||
return chunk_list.pop(0)
|
||||
|
||||
mock_stream_wrapper.__next__ = next_chunk
|
||||
|
||||
iterator = LiteLLMCompletionStreamingIterator(
|
||||
model="anthropic/claude-sonnet-4-5",
|
||||
litellm_custom_stream_wrapper=mock_stream_wrapper,
|
||||
request_input=[{"type": "message", "role": "user", "content": [{"type": "text", "text": "repeat after me: I'm happy to be here"}]}],
|
||||
responses_api_request={},
|
||||
custom_llm_provider="anthropic",
|
||||
)
|
||||
|
||||
collected_deltas = []
|
||||
max_events = 20
|
||||
for i, event in enumerate(iterator):
|
||||
if i >= max_events:
|
||||
break
|
||||
if getattr(event, "type", None) == ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA and hasattr(event, "delta"):
|
||||
collected_deltas.append(event.delta)
|
||||
if getattr(event, "type", None) == ResponsesAPIStreamEvents.RESPONSE_COMPLETED:
|
||||
break
|
||||
|
||||
full_text = "".join(collected_deltas)
|
||||
assert full_text == "I'm happy", (
|
||||
f"First character from Anthropic content_block_start was truncated. "
|
||||
f"Expected \"I'm happy\", got {repr(full_text)}"
|
||||
)
|
||||
|
|
@ -0,0 +1,289 @@
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders, screen, waitFor } from "../../../../tests/test-utils";
|
||||
import UserDropdown from "./UserDropdown";
|
||||
|
||||
let mockUseAuthorizedImpl = () => ({
|
||||
userId: "test-user-id",
|
||||
userEmail: "test@example.com",
|
||||
userRole: "Admin",
|
||||
premiumUser: false,
|
||||
});
|
||||
|
||||
let mockUseDisableShowPromptsImpl = () => false;
|
||||
|
||||
let mockGetLocalStorageItemImpl = (key: string): string | null => {
|
||||
if (key === "disableShowNewBadge") return null;
|
||||
if (key === "disableShowPrompts") return null;
|
||||
return null;
|
||||
};
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: () => mockUseAuthorizedImpl(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useDisableShowPrompts", () => ({
|
||||
useDisableShowPrompts: () => mockUseDisableShowPromptsImpl(),
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/localStorageUtils", () => ({
|
||||
LOCAL_STORAGE_EVENT: "local-storage-change",
|
||||
getLocalStorageItem: (key: string) => mockGetLocalStorageItemImpl(key),
|
||||
setLocalStorageItem: vi.fn(),
|
||||
removeLocalStorageItem: vi.fn(),
|
||||
emitLocalStorageChange: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("UserDropdown", () => {
|
||||
const mockOnLogout = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseAuthorizedImpl = () => ({
|
||||
userId: "test-user-id",
|
||||
userEmail: "test@example.com",
|
||||
userRole: "Admin",
|
||||
premiumUser: false,
|
||||
});
|
||||
mockUseDisableShowPromptsImpl = () => false;
|
||||
mockGetLocalStorageItemImpl = (key: string): string | null => {
|
||||
if (key === "disableShowNewBadge") return null;
|
||||
if (key === "disableShowPrompts") return null;
|
||||
return null;
|
||||
};
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display user button with User text", () => {
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
expect(screen.getByText("User")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show user email when dropdown is opened", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test@example.com")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show user ID when dropdown is opened", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test-user-id")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show user role when dropdown is opened", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Admin")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display Standard badge for non-premium users", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Standard")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display Premium badge for premium users", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseAuthorizedImpl = () => ({
|
||||
userId: "test-user-id",
|
||||
userEmail: "test@example.com",
|
||||
userRole: "Admin",
|
||||
premiumUser: true,
|
||||
});
|
||||
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Premium")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should call onLogout when logout is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("Logout"));
|
||||
|
||||
expect(mockOnLogout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should toggle hide new feature indicators switch", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const toggle = screen.getByLabelText("Toggle hide new feature indicators");
|
||||
expect(toggle).not.toBeChecked();
|
||||
|
||||
await user.click(toggle);
|
||||
|
||||
const localStorageUtils = vi.mocked(await import("@/utils/localStorageUtils"));
|
||||
expect(localStorageUtils.setLocalStorageItem).toHaveBeenCalledWith("disableShowNewBadge", "true");
|
||||
expect(localStorageUtils.emitLocalStorageChange).toHaveBeenCalledWith("disableShowNewBadge");
|
||||
});
|
||||
|
||||
it("should toggle hide new feature indicators switch off", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockGetLocalStorageItemImpl = (key: string): string | null => {
|
||||
if (key === "disableShowNewBadge") return "true";
|
||||
return null;
|
||||
};
|
||||
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const toggle = screen.getByLabelText("Toggle hide new feature indicators");
|
||||
expect(toggle).toBeChecked();
|
||||
|
||||
await user.click(toggle);
|
||||
|
||||
const localStorageUtils = vi.mocked(await import("@/utils/localStorageUtils"));
|
||||
expect(localStorageUtils.removeLocalStorageItem).toHaveBeenCalledWith("disableShowNewBadge");
|
||||
expect(localStorageUtils.emitLocalStorageChange).toHaveBeenCalledWith("disableShowNewBadge");
|
||||
});
|
||||
|
||||
it("should toggle hide all prompts switch", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const toggle = screen.getByLabelText("Toggle hide all prompts");
|
||||
expect(toggle).not.toBeChecked();
|
||||
|
||||
await user.click(toggle);
|
||||
|
||||
const localStorageUtils = vi.mocked(await import("@/utils/localStorageUtils"));
|
||||
expect(localStorageUtils.setLocalStorageItem).toHaveBeenCalledWith("disableShowPrompts", "true");
|
||||
expect(localStorageUtils.emitLocalStorageChange).toHaveBeenCalledWith("disableShowPrompts");
|
||||
});
|
||||
|
||||
it("should toggle hide all prompts switch off", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseDisableShowPromptsImpl = () => true;
|
||||
mockGetLocalStorageItemImpl = (key: string): string | null => {
|
||||
if (key === "disableShowPrompts") return "true";
|
||||
return null;
|
||||
};
|
||||
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const toggle = screen.getByLabelText("Toggle hide all prompts");
|
||||
expect(toggle).toBeChecked();
|
||||
|
||||
await user.click(toggle);
|
||||
|
||||
const localStorageUtils = vi.mocked(await import("@/utils/localStorageUtils"));
|
||||
expect(localStorageUtils.removeLocalStorageItem).toHaveBeenCalledWith("disableShowPrompts");
|
||||
expect(localStorageUtils.emitLocalStorageChange).toHaveBeenCalledWith("disableShowPrompts");
|
||||
});
|
||||
|
||||
it("should display dash when user email is not available", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseAuthorizedImpl = () => ({
|
||||
userId: "test-user-id",
|
||||
userEmail: null as any,
|
||||
userRole: "Admin",
|
||||
premiumUser: false,
|
||||
});
|
||||
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("-")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display dash when user ID is not available", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseAuthorizedImpl = () => ({
|
||||
userId: null as any,
|
||||
userEmail: "test@example.com",
|
||||
userRole: "Admin",
|
||||
premiumUser: false,
|
||||
});
|
||||
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
|
||||
await waitFor(() => {
|
||||
const dashElements = screen.getAllByText("-");
|
||||
expect(dashElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("should initialize hide new feature indicators from localStorage", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockGetLocalStorageItemImpl = (key: string): string | null => {
|
||||
if (key === "disableShowNewBadge") return "true";
|
||||
return null;
|
||||
};
|
||||
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const toggle = screen.getByLabelText("Toggle hide new feature indicators");
|
||||
expect(toggle).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
|
||||
import {
|
||||
emitLocalStorageChange,
|
||||
getLocalStorageItem,
|
||||
removeLocalStorageItem,
|
||||
setLocalStorageItem,
|
||||
} from "@/utils/localStorageUtils";
|
||||
import {
|
||||
CrownOutlined,
|
||||
DownOutlined,
|
||||
LogoutOutlined,
|
||||
MailOutlined,
|
||||
SafetyOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { MenuProps } from "antd";
|
||||
import { Button, Divider, Dropdown, Space, Switch, Tag, Tooltip, Typography } from "antd";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface UserDropdownProps {
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
|
||||
const { userId, userEmail, userRole, premiumUser } = useAuthorized();
|
||||
const disableShowPrompts = useDisableShowPrompts();
|
||||
const [disableShowNewBadge, setDisableShowNewBadge] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const storedValue = getLocalStorageItem("disableShowNewBadge");
|
||||
setDisableShowNewBadge(storedValue === "true");
|
||||
}, []);
|
||||
|
||||
const userItems: MenuProps["items"] = [
|
||||
{
|
||||
key: "logout",
|
||||
label: (
|
||||
<Space>
|
||||
<LogoutOutlined />
|
||||
Logout
|
||||
</Space>
|
||||
),
|
||||
onClick: onLogout,
|
||||
},
|
||||
];
|
||||
|
||||
const renderUserInfoSection = () => (
|
||||
<Space direction="vertical" size="small" style={{ width: "100%", padding: "12px" }}>
|
||||
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Space>
|
||||
<MailOutlined />
|
||||
<Text type="secondary">{userEmail || "-"}</Text>
|
||||
</Space>
|
||||
{premiumUser ? (
|
||||
<Tag
|
||||
icon={<CrownOutlined />}
|
||||
color="gold"
|
||||
>
|
||||
Premium
|
||||
</Tag>
|
||||
) : (
|
||||
<Tooltip title="Upgrade to Premium for advanced features" placement="left">
|
||||
<Tag
|
||||
icon={<CrownOutlined />}
|
||||
>
|
||||
Standard
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
<Divider style={{ margin: "8px 0" }} />
|
||||
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Space>
|
||||
<UserOutlined />
|
||||
<Text type="secondary">User ID</Text>
|
||||
</Space>
|
||||
<Text
|
||||
copyable
|
||||
ellipsis
|
||||
style={{ maxWidth: "150px" }}
|
||||
title={userId || "-"}
|
||||
>
|
||||
{userId || "-"}
|
||||
</Text>
|
||||
</Space>
|
||||
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Space>
|
||||
<SafetyOutlined />
|
||||
<Text type="secondary">Role</Text>
|
||||
</Space>
|
||||
<Text>{userRole}</Text>
|
||||
</Space>
|
||||
<Divider style={{ margin: "8px 0" }} />
|
||||
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Text type="secondary">Hide New Feature Indicators</Text>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={disableShowNewBadge}
|
||||
onChange={(checked) => {
|
||||
setDisableShowNewBadge(checked);
|
||||
if (checked) {
|
||||
setLocalStorageItem("disableShowNewBadge", "true");
|
||||
emitLocalStorageChange("disableShowNewBadge");
|
||||
} else {
|
||||
removeLocalStorageItem("disableShowNewBadge");
|
||||
emitLocalStorageChange("disableShowNewBadge");
|
||||
}
|
||||
}}
|
||||
aria-label="Toggle hide new feature indicators"
|
||||
/>
|
||||
</Space>
|
||||
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Text type="secondary">Hide All Prompts</Text>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={disableShowPrompts}
|
||||
onChange={(checked) => {
|
||||
if (checked) {
|
||||
setLocalStorageItem("disableShowPrompts", "true");
|
||||
emitLocalStorageChange("disableShowPrompts");
|
||||
} else {
|
||||
removeLocalStorageItem("disableShowPrompts");
|
||||
emitLocalStorageChange("disableShowPrompts");
|
||||
}
|
||||
}}
|
||||
aria-label="Toggle hide all prompts"
|
||||
/>
|
||||
</Space>
|
||||
</Space>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
menu={{ items: userItems }}
|
||||
popupRender={(menu) => (
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-lg"
|
||||
>
|
||||
{renderUserInfoSection()}
|
||||
<Divider style={{ margin: 0 }} />
|
||||
{React.cloneElement(menu as React.ReactElement, {
|
||||
style: { boxShadow: "none" },
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Button type="text" >
|
||||
<Space>
|
||||
<UserOutlined />
|
||||
<Text>User</Text>
|
||||
<DownOutlined />
|
||||
</Space>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserDropdown;
|
||||
|
|
@ -32,7 +32,7 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
|
|||
description,
|
||||
children,
|
||||
serverName,
|
||||
accessGroups = ["dev"],
|
||||
accessGroups = ["dev-group"],
|
||||
}) => {
|
||||
const [useServerHeader, setUseServerHeader] = useState(false);
|
||||
|
||||
|
|
@ -42,9 +42,9 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
|
|||
};
|
||||
if (useServerHeader && serverName) {
|
||||
const formattedServerName = serverName.replace(/\s+/g, "_");
|
||||
// Include both server name and access groups in the same header
|
||||
// Include both server name and access groups in the same header (comma-separated string)
|
||||
const serverAndGroups = [formattedServerName, ...accessGroups].join(",");
|
||||
headers["x-mcp-servers"] = [serverAndGroups];
|
||||
headers["x-mcp-servers"] = serverAndGroups;
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
|
@ -77,13 +77,13 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
|
|||
description={
|
||||
<div>
|
||||
<p>
|
||||
<strong>Option 1:</strong> Get a specific server: <code>["{serverName.replace(/\s+/g, "_")}"]</code>
|
||||
<strong>Option 1:</strong> Get a specific server: <code>"{serverName.replace(/\s+/g, "_")}"</code>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Option 2:</strong> Get a group of MCPs: <code>["dev-group"]</code>
|
||||
<strong>Option 2:</strong> Get a group of MCPs: <code>"dev-group"</code>
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
You can also mix both: <code>["Server1,dev-group"]</code>
|
||||
You can also mix both: <code>"Server1,dev-group"</code>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
|
@ -144,8 +144,8 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
|
|||
// Format server names (replace spaces with underscores)
|
||||
const formattedServers = serverHeaders[type].map((s) => s.replace(/\s+/g, "_"));
|
||||
|
||||
// Use comma-separated format (can include both servers and access groups)
|
||||
headers["x-mcp-servers"] = [formattedServers.join(",")];
|
||||
// Use comma-separated string (can include both servers and access groups)
|
||||
headers["x-mcp-servers"] = formattedServers.join(",");
|
||||
}
|
||||
|
||||
return headers;
|
||||
|
|
@ -244,7 +244,7 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
|
|||
title="Implementation Example"
|
||||
description="Complete cURL example for using the LiteLLM Proxy Responses API"
|
||||
serverName={currentServer}
|
||||
accessGroups={["dev"]}
|
||||
accessGroups={["dev-group"]}
|
||||
>
|
||||
<CodeBlock
|
||||
code={`curl --location '${proxyBaseUrl}/v1/responses' \\
|
||||
|
|
@ -260,7 +260,7 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
|
|||
"require_approval": "never",
|
||||
"headers": {
|
||||
"x-litellm-api-key": "Bearer YOUR_LITELLM_VIRTUAL_KEY",
|
||||
"x-mcp-servers": ["Zapier_MCP,dev"]
|
||||
"x-mcp-servers": "Zapier_MCP,dev-group"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
@ -327,7 +327,7 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
|
|||
title="Implementation Example"
|
||||
description="Complete cURL example for using the Responses API"
|
||||
serverName="Zapier Gmail"
|
||||
accessGroups={["dev"]}
|
||||
accessGroups={["dev-group"]}
|
||||
>
|
||||
<CodeBlock
|
||||
code={`curl --location 'https://api.openai.com/v1/responses' \\
|
||||
|
|
@ -343,7 +343,7 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
|
|||
"require_approval": "never",
|
||||
"headers": {
|
||||
"x-litellm-api-key": "Bearer YOUR_LITELLM_API_KEY",
|
||||
"x-mcp-servers": ["Zapier_MCP,dev"]
|
||||
"x-mcp-servers": "Zapier_MCP,dev-group"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
@ -400,7 +400,7 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
|
|||
title="Configuration"
|
||||
description="Cursor MCP configuration"
|
||||
serverName="Zapier Gmail"
|
||||
accessGroups={["dev"]}
|
||||
accessGroups={["dev-group"]}
|
||||
>
|
||||
<CodeBlock
|
||||
code={`{
|
||||
|
|
@ -409,7 +409,7 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
|
|||
"url": "${proxyBaseUrl}/mcp",
|
||||
"headers": {
|
||||
"x-litellm-api-key": "Bearer YOUR_LITELLM_API_KEY",
|
||||
"x-mcp-servers": ["Zapier_MCP,dev"]
|
||||
"x-mcp-servers": "Zapier_MCP,dev-group"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,8 +15,14 @@ vi.mock("@/utils/proxyUtils", () => ({
|
|||
// Create mock functions that can be controlled in tests
|
||||
let mockUseThemeImpl = () => ({ logoUrl: null as string | null });
|
||||
let mockUseHealthReadinessImpl = () => ({ data: null as any });
|
||||
let mockGetLocalStorageItemImpl = () => null as string | null;
|
||||
let mockGetLocalStorageItemImpl = (key: string) => null as string | null;
|
||||
let mockUseDisableShowPromptsImpl = () => false;
|
||||
let mockUseAuthorizedImpl = () => ({
|
||||
userId: "test-user",
|
||||
userEmail: "test@example.com",
|
||||
userRole: "Admin",
|
||||
premiumUser: false,
|
||||
});
|
||||
|
||||
vi.mock("@/contexts/ThemeContext", () => ({
|
||||
useTheme: () => mockUseThemeImpl(),
|
||||
|
|
@ -30,9 +36,13 @@ vi.mock("@/app/(dashboard)/hooks/useDisableShowPrompts", () => ({
|
|||
useDisableShowPrompts: () => mockUseDisableShowPromptsImpl(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: () => mockUseAuthorizedImpl(),
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/localStorageUtils", () => ({
|
||||
LOCAL_STORAGE_EVENT: "local-storage-change",
|
||||
getLocalStorageItem: () => mockGetLocalStorageItemImpl(),
|
||||
getLocalStorageItem: (key: string) => mockGetLocalStorageItemImpl(key),
|
||||
setLocalStorageItem: vi.fn(),
|
||||
removeLocalStorageItem: vi.fn(),
|
||||
emitLocalStorageChange: vi.fn(),
|
||||
|
|
@ -123,14 +133,27 @@ describe("Navbar", () => {
|
|||
|
||||
it("should show premium user badge when premiumUser is true", async () => {
|
||||
const user = userEvent.setup();
|
||||
const premiumProps = { ...defaultProps, premiumUser: true };
|
||||
renderWithProviders(<Navbar {...premiumProps} />);
|
||||
mockUseAuthorizedImpl = () => ({
|
||||
userId: "test-user",
|
||||
userEmail: "test@example.com",
|
||||
userRole: "Admin",
|
||||
premiumUser: true,
|
||||
});
|
||||
renderWithProviders(<Navbar {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Premium")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Reset mock
|
||||
mockUseAuthorizedImpl = () => ({
|
||||
userId: "test-user",
|
||||
userEmail: "test@example.com",
|
||||
userRole: "Admin",
|
||||
premiumUser: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should show version badge when health data contains version", () => {
|
||||
|
|
@ -167,7 +190,10 @@ describe("Navbar", () => {
|
|||
const user = userEvent.setup();
|
||||
|
||||
// Initially disabled
|
||||
mockGetLocalStorageItemImpl = () => "false";
|
||||
mockGetLocalStorageItemImpl = (key: string) => {
|
||||
if (key === "disableShowNewBadge") return "false";
|
||||
return null;
|
||||
};
|
||||
|
||||
renderWithProviders(<Navbar {...defaultProps} />);
|
||||
|
||||
|
|
@ -186,6 +212,9 @@ describe("Navbar", () => {
|
|||
const localStorageUtils = vi.mocked(await import("@/utils/localStorageUtils"));
|
||||
expect(localStorageUtils.setLocalStorageItem).toHaveBeenCalledWith("disableShowNewBadge", "true");
|
||||
expect(localStorageUtils.emitLocalStorageChange).toHaveBeenCalledWith("disableShowNewBadge");
|
||||
|
||||
// Reset mock
|
||||
mockGetLocalStorageItemImpl = (key: string) => null;
|
||||
});
|
||||
|
||||
it("should handle logout functionality", async () => {
|
||||
|
|
|
|||
|
|
@ -1,32 +1,20 @@
|
|||
import { useHealthReadiness } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness";
|
||||
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
|
||||
import { getProxyBaseUrl } from "@/components/networking";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { clearTokenCookies } from "@/utils/cookieUtils";
|
||||
import {
|
||||
emitLocalStorageChange,
|
||||
getLocalStorageItem,
|
||||
removeLocalStorageItem,
|
||||
setLocalStorageItem,
|
||||
} from "@/utils/localStorageUtils";
|
||||
import { fetchProxySettings } from "@/utils/proxyUtils";
|
||||
import {
|
||||
CrownOutlined,
|
||||
GithubOutlined,
|
||||
LogoutOutlined,
|
||||
MailOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
MoonOutlined,
|
||||
SafetyOutlined,
|
||||
SlackOutlined,
|
||||
SunOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { MenuProps } from "antd";
|
||||
import { Button, Dropdown, Switch, Tooltip } from "antd";
|
||||
import { Button, Switch, Tag } from "antd";
|
||||
import Link from "next/link";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import UserDropdown from "./Navbar/UserDropdown/UserDropdown";
|
||||
|
||||
interface NavbarProps {
|
||||
userID: string | null;
|
||||
|
|
@ -59,8 +47,6 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
}) => {
|
||||
const baseUrl = getProxyBaseUrl();
|
||||
const [logoutUrl, setLogoutUrl] = useState("");
|
||||
const [disableShowNewBadge, setDisableShowNewBadge] = useState(false);
|
||||
const disableShowPrompts = useDisableShowPrompts();
|
||||
const { logoUrl } = useTheme();
|
||||
const { data: healthData } = useHealthReadiness();
|
||||
const version = healthData?.litellm_version;
|
||||
|
|
@ -82,11 +68,6 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
initializeProxySettings();
|
||||
}, [accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const storedValue = getLocalStorageItem("disableShowNewBadge");
|
||||
setDisableShowNewBadge(storedValue === "true");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLogoutUrl(proxySettings?.PROXY_LOGOUT_URL || "");
|
||||
}, [proxySettings]);
|
||||
|
|
@ -96,105 +77,6 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
window.location.href = logoutUrl;
|
||||
};
|
||||
|
||||
const userItems: MenuProps["items"] = [
|
||||
{
|
||||
key: "user-info",
|
||||
// Prevent dropdown from closing when interacting with the toggle
|
||||
onClick: (info) => info.domEvent?.stopPropagation(),
|
||||
label: (
|
||||
<div className="px-3 py-3 border-b border-gray-100">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center">
|
||||
<UserOutlined className="mr-2 text-gray-700" />
|
||||
<span className="text-sm font-semibold text-gray-900">{userID}</span>
|
||||
</div>
|
||||
{premiumUser ? (
|
||||
<Tooltip title="Premium User" placement="left">
|
||||
<div className="flex items-center bg-gradient-to-r from-amber-500 to-yellow-500 text-white px-2 py-0.5 rounded-full cursor-help">
|
||||
<CrownOutlined className="mr-1 text-xs" />
|
||||
<span className="text-xs font-medium">Premium</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="Upgrade to Premium for advanced features" placement="left">
|
||||
<div className="flex items-center bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full cursor-help">
|
||||
<CrownOutlined className="mr-1 text-xs" />
|
||||
<span className="text-xs font-medium">Standard</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center text-sm">
|
||||
<SafetyOutlined className="mr-2 text-gray-400 text-xs" />
|
||||
<span className="text-gray-500 text-xs">Role</span>
|
||||
<span className="ml-auto text-gray-700 font-medium">{userRole}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm">
|
||||
<MailOutlined className="mr-2 text-gray-400 text-xs" />
|
||||
<span className="text-gray-500 text-xs">Email</span>
|
||||
<span className="ml-auto text-gray-700 font-medium truncate max-w-[150px]" title={userEmail || "Unknown"}>
|
||||
{userEmail || "Unknown"}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center text-sm pt-2 mt-2 border-t border-gray-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="text-gray-500 text-xs">Hide New Feature Indicators</span>
|
||||
<Switch
|
||||
className="ml-auto"
|
||||
size="small"
|
||||
checked={disableShowNewBadge}
|
||||
onChange={(checked) => {
|
||||
setDisableShowNewBadge(checked);
|
||||
if (checked) {
|
||||
setLocalStorageItem("disableShowNewBadge", "true");
|
||||
emitLocalStorageChange("disableShowNewBadge");
|
||||
} else {
|
||||
removeLocalStorageItem("disableShowNewBadge");
|
||||
emitLocalStorageChange("disableShowNewBadge");
|
||||
}
|
||||
}}
|
||||
aria-label="Toggle hide new feature indicators"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center text-sm pt-2 mt-2 border-t border-gray-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="text-gray-500 text-xs">Hide All Prompts</span>
|
||||
<Switch
|
||||
className="ml-auto"
|
||||
size="small"
|
||||
checked={disableShowPrompts}
|
||||
onChange={(checked) => {
|
||||
if (checked) {
|
||||
setLocalStorageItem("disableShowPrompts", "true");
|
||||
emitLocalStorageChange("disableShowPrompts");
|
||||
} else {
|
||||
removeLocalStorageItem("disableShowPrompts");
|
||||
emitLocalStorageChange("disableShowPrompts");
|
||||
}
|
||||
}}
|
||||
aria-label="Toggle hide all prompts"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "logout",
|
||||
label: (
|
||||
<div className="flex items-center py-2 px-3 hover:bg-gray-50 rounded-md mx-1 my-1" onClick={handleLogout}>
|
||||
<LogoutOutlined className="mr-3 text-gray-600" />
|
||||
<span className="text-gray-800">Logout</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
||||
<div className="w-full">
|
||||
|
|
@ -210,28 +92,38 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={baseUrl ? baseUrl : "/"} className="flex items-center">
|
||||
<div className="relative">
|
||||
<img src={imageUrl} alt="LiteLLM Brand" className="h-10 w-auto" />
|
||||
<span
|
||||
className="absolute -top-1 -right-2 text-lg animate-bounce"
|
||||
style={{ animationDuration: "2s" }}
|
||||
title="Happy Holidays!"
|
||||
>
|
||||
❄️
|
||||
</span>
|
||||
<div className="h-10 max-w-48 flex items-center justify-center overflow-hidden">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="LiteLLM Brand"
|
||||
className="max-w-full max-h-full w-auto h-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
{version && (
|
||||
<a
|
||||
href="https://docs.litellm.ai/release_notes"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-gray-500 border border-gray-200 rounded-lg px-2 py-0.5 bg-gray-50 font-medium -ml-2 hover:bg-gray-100 transition-colors cursor-pointer z-10"
|
||||
>
|
||||
v{version}
|
||||
</a>
|
||||
<div className="relative">
|
||||
<span
|
||||
className="absolute -top-1 -left-2 text-lg animate-bounce"
|
||||
style={{ animationDuration: "2s" }}
|
||||
title="Thanks for using LiteLLM!"
|
||||
>
|
||||
❄️
|
||||
</span>
|
||||
<Tag className="relative text-xs font-medium cursor-pointer z-10">
|
||||
<a
|
||||
href="https://docs.litellm.ai/release_notes"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
v{version}
|
||||
</a>
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -274,28 +166,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
</a>
|
||||
|
||||
{!isPublicPage && (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: userItems,
|
||||
className: "min-w-[200px]",
|
||||
style: {
|
||||
padding: "8px",
|
||||
marginTop: "8px",
|
||||
borderRadius: "12px",
|
||||
boxShadow: "0 4px 24px rgba(0, 0, 0, 0.08)",
|
||||
},
|
||||
}}
|
||||
overlayStyle={{
|
||||
minWidth: "200px",
|
||||
}}
|
||||
>
|
||||
<button className="inline-flex items-center text-sm text-gray-600 hover:text-gray-900 transition-colors">
|
||||
User
|
||||
<svg className="ml-1 w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</Dropdown>
|
||||
<UserDropdown onLogout={handleLogout} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import { Accordion, AccordionHeader, AccordionBody } from "@tremor/react";
|
||||
import { Collapse } from "antd";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
|
||||
export interface CostBreakdown {
|
||||
|
|
@ -7,6 +7,7 @@ export interface CostBreakdown {
|
|||
output_cost?: number;
|
||||
total_cost?: number;
|
||||
tool_usage_cost?: number;
|
||||
additional_costs?: Record<string, number>;
|
||||
original_cost?: number;
|
||||
discount_percent?: number;
|
||||
discount_amount?: number;
|
||||
|
|
@ -59,19 +60,23 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden">
|
||||
<Accordion>
|
||||
<AccordionHeader className="p-4 border-b hover:bg-gray-50 transition-colors text-left">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h3 className="text-lg font-medium text-gray-900">Cost Breakdown</h3>
|
||||
<div className="flex items-center space-x-2 mr-4">
|
||||
<span className="text-sm text-gray-500">Total:</span>
|
||||
<span className="text-sm font-semibold text-gray-900">{formatCost(totalSpend)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionBody className="px-0">
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
|
||||
<Collapse
|
||||
expandIconPosition="start"
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h3 className="text-lg font-medium text-gray-900">Cost Breakdown</h3>
|
||||
<div className="flex items-center space-x-2 mr-4">
|
||||
<span className="text-sm text-gray-500">Total:</span>
|
||||
<span className="text-sm font-semibold text-gray-900">{formatCost(totalSpend)}</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Step 1: Base Token Costs */}
|
||||
<div className="space-y-2 max-w-2xl">
|
||||
<div className="flex text-sm">
|
||||
|
|
@ -88,6 +93,17 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
|||
<span className="text-gray-900">{formatCost(costBreakdown.tool_usage_cost)}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Additional Costs (free-form) */}
|
||||
{costBreakdown.additional_costs && Object.keys(costBreakdown.additional_costs).length > 0 && (
|
||||
<>
|
||||
{Object.entries(costBreakdown.additional_costs).map(([key, value]) => (
|
||||
<div key={key} className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">{key}:</span>
|
||||
<span className="text-gray-900">{formatCost(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subtotal / Original Cost */}
|
||||
|
|
@ -149,8 +165,10 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders, screen } from "../../../../tests/test-utils";
|
||||
import { renderWithProviders, screen, waitFor } from "../../../../tests/test-utils";
|
||||
import {
|
||||
makeBedrockResponse,
|
||||
makeEntity,
|
||||
|
|
@ -62,20 +62,26 @@ describe("GuardrailViewer", () => {
|
|||
it("toggles main section open/closed and chevron rotation class", async () => {
|
||||
const user = userEvent.setup();
|
||||
const data = makeGuardrailInformation();
|
||||
renderWithProviders(<GuardrailViewer data={data} />);
|
||||
const { container } = renderWithProviders(<GuardrailViewer data={data} />);
|
||||
|
||||
const header = screen.getByText("Guardrail Information").closest("div")!;
|
||||
// Initially expanded
|
||||
expect(screen.getByText("Click to collapse")).toBeInTheDocument();
|
||||
const header = screen.getByText("Guardrail Information").closest(".ant-collapse-header")!;
|
||||
// Initially expanded (content is visible)
|
||||
expect(screen.getByText("Masked Entity Summary")).toBeInTheDocument();
|
||||
|
||||
// Click to collapse
|
||||
await user.click(header);
|
||||
expect(screen.getByText("Click to expand")).toBeInTheDocument();
|
||||
// Details gone
|
||||
expect(screen.queryByText("Masked Entity Summary")).not.toBeInTheDocument();
|
||||
// Wait for collapse animation and content to be hidden
|
||||
await waitFor(() => {
|
||||
const contentBox = container.querySelector(".ant-collapse-content-box");
|
||||
expect(contentBox).not.toBeVisible();
|
||||
});
|
||||
|
||||
// Click to expand again
|
||||
await user.click(header);
|
||||
expect(screen.getByText("Click to collapse")).toBeInTheDocument();
|
||||
// Wait for expand animation
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Masked Entity Summary")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults to presidio provider when guardrail_provider is undefined", async () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState } from "react";
|
||||
import { Tooltip } from "antd";
|
||||
import { Tooltip, Collapse } from "antd";
|
||||
import PresidioDetectedEntities from "./PresidioDetectedEntities";
|
||||
import BedrockGuardrailDetails, {
|
||||
BedrockGuardrailResponse,
|
||||
|
|
@ -207,8 +207,6 @@ const GuardrailViewer = ({ data }: GuardrailViewerProps) => {
|
|||
? [data]
|
||||
: [];
|
||||
|
||||
const [sectionExpanded, setSectionExpanded] = useState(true);
|
||||
|
||||
const primaryName =
|
||||
guardrailEntries.length === 1 ? guardrailEntries[0].guardrail_name : `${guardrailEntries.length} guardrails`;
|
||||
const statuses = Array.from(new Set(guardrailEntries.map((entry) => entry.guardrail_status)));
|
||||
|
|
@ -231,55 +229,51 @@ const GuardrailViewer = ({ data }: GuardrailViewerProps) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow mb-6">
|
||||
<div
|
||||
className="flex justify-between items-center p-4 border-b cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => setSectionExpanded(!sectionExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-600 transition-transform ${sectionExpanded ? "transform rotate-90" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium">Guardrail Information</h3>
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
|
||||
<Collapse
|
||||
defaultActiveKey={["1"]}
|
||||
expandIconPosition="start"
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-medium text-gray-900">Guardrail Information</h3>
|
||||
|
||||
<Tooltip title={tooltipTitle} placement="top" arrow destroyTooltipOnHide>
|
||||
<span
|
||||
className={`ml-2 px-2 py-1 rounded-md text-xs font-medium inline-block ${
|
||||
allSucceeded ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800 cursor-help"
|
||||
}`}
|
||||
>
|
||||
{aggregatedStatus}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={tooltipTitle} placement="top" arrow destroyTooltipOnHide>
|
||||
<span
|
||||
className={`px-2 py-1 rounded-md text-xs font-medium inline-block ${
|
||||
allSucceeded ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800 cursor-help"
|
||||
}`}
|
||||
>
|
||||
{aggregatedStatus}
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<span className="ml-2 font-mono text-sm text-gray-600">{primaryName}</span>
|
||||
<span className="font-mono text-sm text-gray-600">{primaryName}</span>
|
||||
|
||||
{totalMaskedEntities > 0 && (
|
||||
<span className="ml-2 px-2 py-1 bg-blue-50 text-blue-700 rounded-md text-xs font-medium">
|
||||
{totalMaskedEntities} masked {totalMaskedEntities === 1 ? "entity" : "entities"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">{sectionExpanded ? "Click to collapse" : "Click to expand"}</span>
|
||||
</div>
|
||||
|
||||
{sectionExpanded && (
|
||||
<div className="p-4 space-y-6">
|
||||
{guardrailEntries.map((entry, index) => (
|
||||
<GuardrailDetails
|
||||
key={`${entry.guardrail_name ?? "guardrail"}-${index}`}
|
||||
entry={entry}
|
||||
index={index}
|
||||
total={guardrailEntries.length}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{totalMaskedEntities > 0 && (
|
||||
<span className="px-2 py-1 bg-blue-50 text-blue-700 rounded-md text-xs font-medium">
|
||||
{totalMaskedEntities} masked {totalMaskedEntities === 1 ? "entity" : "entities"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div className="p-4 space-y-6">
|
||||
{guardrailEntries.map((entry, index) => (
|
||||
<GuardrailDetails
|
||||
key={`${entry.guardrail_name ?? "guardrail"}-${index}`}
|
||||
entry={entry}
|
||||
index={index}
|
||||
total={guardrailEntries.length}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* CollapsibleMessage - Collapsible message with arrow and char count
|
||||
* Used for system messages
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Typography } from 'antd';
|
||||
import { DownOutlined, RightOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface CollapsibleMessageProps {
|
||||
label: string;
|
||||
content?: string;
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
export function CollapsibleMessage({
|
||||
label,
|
||||
content,
|
||||
defaultExpanded = false
|
||||
}: CollapsibleMessageProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const charCount = content?.length || 0;
|
||||
|
||||
if (!content || charCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
{/* Clickable Header with hover state */}
|
||||
<div
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
cursor: 'pointer',
|
||||
padding: '4px 0',
|
||||
borderRadius: 4,
|
||||
background: isHovered ? '#f5f5f5' : 'transparent',
|
||||
transition: 'background 0.15s ease',
|
||||
marginBottom: isExpanded ? 4 : 0,
|
||||
}}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<DownOutlined style={{ fontSize: 10, color: '#8c8c8c' }} />
|
||||
) : (
|
||||
<RightOutlined style={{ fontSize: 10, color: '#8c8c8c' }} />
|
||||
)}
|
||||
<Text type="secondary" style={{ fontSize: 10, letterSpacing: '0.5px', textTransform: 'uppercase' }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 10 }}>
|
||||
({charCount.toLocaleString()} chars)
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Content with smooth animation */}
|
||||
<div
|
||||
style={{
|
||||
maxHeight: isExpanded ? '2000px' : '0px',
|
||||
overflow: 'hidden',
|
||||
transition: 'max-height 0.2s ease-out, opacity 0.2s ease-out',
|
||||
opacity: isExpanded ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
paddingLeft: 16,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.7,
|
||||
color: '#262626',
|
||||
borderLeft: '1px solid #f0f0f0',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
import { Button, Space, Tag, Tooltip, Typography } from "antd";
|
||||
import { CloseOutlined, UpOutlined, DownOutlined } from "@ant-design/icons";
|
||||
import moment from "moment";
|
||||
import { LogEntry } from "../columns";
|
||||
import { getProviderLogoAndName } from "../../provider_info_helpers";
|
||||
import {
|
||||
DRAWER_HEADER_PADDING,
|
||||
COLOR_BORDER,
|
||||
COLOR_BACKGROUND,
|
||||
SPACING_MEDIUM,
|
||||
SPACING_LARGE,
|
||||
FONT_SIZE_HEADER,
|
||||
FONT_SIZE_MEDIUM,
|
||||
FONT_FAMILY_MONO,
|
||||
SPACING_SMALL,
|
||||
} from "./constants";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface DrawerHeaderProps {
|
||||
log: LogEntry;
|
||||
onClose: () => void;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
statusLabel: string;
|
||||
statusColor: "error" | "success";
|
||||
environment: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header component for the log details drawer.
|
||||
* Displays model/provider, request ID, navigation controls, status, environment, and timestamp.
|
||||
*/
|
||||
export function DrawerHeader({
|
||||
log,
|
||||
onClose,
|
||||
onPrevious,
|
||||
onNext,
|
||||
statusLabel,
|
||||
statusColor,
|
||||
environment,
|
||||
}: DrawerHeaderProps) {
|
||||
const provider = log.custom_llm_provider || "";
|
||||
const providerInfo = provider ? getProviderLogoAndName(provider) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: DRAWER_HEADER_PADDING,
|
||||
borderBottom: `1px solid ${COLOR_BORDER}`,
|
||||
backgroundColor: COLOR_BACKGROUND,
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{/* Row 0: Model + Provider with Logo */}
|
||||
<ModelProviderSection model={log.model} providerLogo={providerInfo?.logo} providerName={providerInfo?.displayName} />
|
||||
|
||||
{/* Row 1: Request ID + Actions */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: SPACING_MEDIUM }}>
|
||||
<RequestIdSection requestId={log.request_id} />
|
||||
<NavigationSection onPrevious={onPrevious} onNext={onNext} onClose={onClose} />
|
||||
</div>
|
||||
|
||||
{/* Row 2: Status + Env + Timestamp */}
|
||||
<StatusBar log={log} statusLabel={statusLabel} statusColor={statusColor} environment={environment} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Model and Provider display with logo
|
||||
*/
|
||||
function ModelProviderSection({
|
||||
model,
|
||||
providerLogo,
|
||||
providerName,
|
||||
}: {
|
||||
model: string;
|
||||
providerLogo?: string;
|
||||
providerName?: string;
|
||||
}) {
|
||||
return (
|
||||
<Space size={SPACING_MEDIUM} style={{ marginBottom: SPACING_MEDIUM }}>
|
||||
{providerLogo && (
|
||||
<img
|
||||
src={providerLogo}
|
||||
alt={providerName || "Provider"}
|
||||
style={{ width: 24, height: 24 }}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Space size={SPACING_MEDIUM} direction="horizontal">
|
||||
<Text strong style={{ fontSize: 14 }}>
|
||||
{model}
|
||||
</Text>
|
||||
{providerName && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{providerName}
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request ID display with copy functionality
|
||||
*/
|
||||
function RequestIdSection({ requestId }: { requestId: string }) {
|
||||
return (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Tooltip title={requestId}>
|
||||
<Text
|
||||
strong
|
||||
copyable={{ text: requestId, tooltips: ["Copy Request ID", "Copied!"] }}
|
||||
style={{
|
||||
fontSize: FONT_SIZE_HEADER,
|
||||
fontFamily: FONT_FAMILY_MONO,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{requestId}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation controls (previous, next, close)
|
||||
* Shows keyboard shortcuts with bounding boxes for visibility
|
||||
*/
|
||||
function NavigationSection({
|
||||
onPrevious,
|
||||
onNext,
|
||||
onClose,
|
||||
}: {
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const keyboardShortcutStyle = {
|
||||
border: "1px solid #d9d9d9",
|
||||
borderRadius: 4,
|
||||
padding: "0 4px",
|
||||
fontSize: 12,
|
||||
fontFamily: "monospace",
|
||||
marginLeft: 4,
|
||||
background: "#fafafa",
|
||||
};
|
||||
|
||||
return (
|
||||
<Space size={SPACING_SMALL} split={<div style={{ width: 1, height: 20, background: COLOR_BORDER }} />}>
|
||||
<Button type="text" size="small" onClick={onPrevious}>
|
||||
<UpOutlined />
|
||||
<span style={keyboardShortcutStyle}>K</span>
|
||||
</Button>
|
||||
<Button type="text" size="small" onClick={onNext}>
|
||||
<DownOutlined />
|
||||
<span style={keyboardShortcutStyle}>J</span>
|
||||
</Button>
|
||||
<Tooltip title="ESC to close">
|
||||
<Button type="text" icon={<CloseOutlined />} onClick={onClose} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status bar with tags and timestamp
|
||||
*/
|
||||
function StatusBar({
|
||||
log,
|
||||
statusLabel,
|
||||
statusColor,
|
||||
environment,
|
||||
}: {
|
||||
log: LogEntry;
|
||||
statusLabel: string;
|
||||
statusColor: "error" | "success";
|
||||
environment: string;
|
||||
}) {
|
||||
return (
|
||||
<Space size={SPACING_LARGE}>
|
||||
<Tag color={statusColor}>{statusLabel}</Tag>
|
||||
<Tag>Env: {environment}</Tag>
|
||||
<Space size={SPACING_MEDIUM}>
|
||||
<Text type="secondary" style={{ fontSize: FONT_SIZE_MEDIUM }}>
|
||||
{moment(log.startTime).format("MMM D, YYYY h:mm:ss A")}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: FONT_SIZE_MEDIUM }}>
|
||||
({moment(log.startTime).fromNow()})
|
||||
</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* HistoryDivider - Collapsible divider for message history
|
||||
* Dashed line with expandable content
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Typography } from 'antd';
|
||||
import { UpOutlined, DownOutlined } from '@ant-design/icons';
|
||||
import { ParsedMessage } from './prettyMessagesTypes';
|
||||
import { MessageBlock } from './MessageBlock';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface HistoryDividerProps {
|
||||
messages: ParsedMessage[];
|
||||
}
|
||||
|
||||
export function HistoryDivider({ messages }: HistoryDividerProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (messages.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{ margin: '12px 0' }}>
|
||||
{/* Dashed Divider with Label */}
|
||||
<div
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, borderTop: '1px dashed #d9d9d9' }} />
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
History ({messages.length})
|
||||
</Text>
|
||||
{isExpanded ? (
|
||||
<UpOutlined style={{ fontSize: 10, color: '#8c8c8c' }} />
|
||||
) : (
|
||||
<DownOutlined style={{ fontSize: 10, color: '#8c8c8c' }} />
|
||||
)}
|
||||
<div style={{ flex: 1, borderTop: '1px dashed #d9d9d9' }} />
|
||||
</div>
|
||||
|
||||
{/* Expanded View - Full Messages */}
|
||||
{isExpanded && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{messages.map((msg, index) => (
|
||||
<MessageBlock
|
||||
key={index}
|
||||
role={msg.role.toUpperCase()}
|
||||
content={msg.content}
|
||||
toolCalls={msg.toolCalls}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* HistoryTree - Collapsible tree view for message history
|
||||
* Shows arrow indicator and message count
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Typography } from 'antd';
|
||||
import { DownOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { ParsedMessage } from './prettyMessagesTypes';
|
||||
import { SimpleMessageBlock } from './SimpleMessageBlock';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface HistoryTreeProps {
|
||||
messages: ParsedMessage[];
|
||||
}
|
||||
|
||||
export function HistoryTree({ messages }: HistoryTreeProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
{/* Clickable Header with hover state */}
|
||||
<div
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
cursor: 'pointer',
|
||||
padding: '4px 0',
|
||||
borderRadius: 4,
|
||||
background: isHovered ? '#f5f5f5' : 'transparent',
|
||||
transition: 'background 0.15s ease',
|
||||
marginBottom: isExpanded ? 4 : 0,
|
||||
}}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<DownOutlined style={{ fontSize: 10, color: '#8c8c8c' }} />
|
||||
) : (
|
||||
<RightOutlined style={{ fontSize: 10, color: '#8c8c8c' }} />
|
||||
)}
|
||||
<Text type="secondary" style={{ fontSize: 10, letterSpacing: '0.5px', textTransform: 'uppercase' }}>
|
||||
HISTORY ({messages.length} message{messages.length !== 1 ? 's' : ''})
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Expanded Tree Content with smooth animation */}
|
||||
<div
|
||||
style={{
|
||||
maxHeight: isExpanded ? '2000px' : '0px',
|
||||
overflow: 'hidden',
|
||||
transition: 'max-height 0.2s ease-out, opacity 0.2s ease-out',
|
||||
opacity: isExpanded ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
paddingLeft: 16,
|
||||
borderLeft: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
{messages.map((msg, index) => (
|
||||
<SimpleMessageBlock
|
||||
key={index}
|
||||
label={msg.role.toUpperCase()}
|
||||
content={msg.content}
|
||||
toolCalls={msg.toolCalls}
|
||||
isCompact={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* InputCard - Displays all input messages with token count and cost
|
||||
* Datadog-style: header with icon/metrics, content below
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { message } from 'antd';
|
||||
import { ParsedMessage } from './prettyMessagesTypes';
|
||||
import { SectionHeader } from './SectionHeader';
|
||||
import { CollapsibleMessage } from './CollapsibleMessage';
|
||||
import { HistoryTree } from './HistoryTree';
|
||||
import { SimpleMessageBlock } from './SimpleMessageBlock';
|
||||
|
||||
interface InputCardProps {
|
||||
messages: ParsedMessage[];
|
||||
promptTokens?: number;
|
||||
inputCost?: number;
|
||||
}
|
||||
|
||||
export function InputCard({ messages, promptTokens, inputCost }: InputCardProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Separate system, history, and last message
|
||||
const systemMessage = messages.find((m) => m.role === 'system');
|
||||
const nonSystemMessages = messages.filter((m) => m.role !== 'system');
|
||||
const lastMessage = nonSystemMessages.length > 0 ? nonSystemMessages[nonSystemMessages.length - 1] : null;
|
||||
const historyMessages = nonSystemMessages.slice(0, -1);
|
||||
|
||||
const handleCopy = () => {
|
||||
const content = JSON.stringify(messages, null, 2);
|
||||
navigator.clipboard.writeText(content);
|
||||
message.success('Input copied');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 6,
|
||||
marginBottom: 8,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Datadog-style Header */}
|
||||
<SectionHeader
|
||||
type="input"
|
||||
tokens={promptTokens}
|
||||
cost={inputCost}
|
||||
onCopy={handleCopy}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleCollapse={() => setIsCollapsed(!isCollapsed)}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
style={{
|
||||
maxHeight: isCollapsed ? '0px' : '10000px',
|
||||
overflow: 'hidden',
|
||||
transition: 'max-height 0.3s ease-out, opacity 0.3s ease-out',
|
||||
opacity: isCollapsed ? 0 : 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '12px 16px' }}>
|
||||
{/* System Message - Collapsible with arrow */}
|
||||
{systemMessage && (
|
||||
<CollapsibleMessage
|
||||
label="SYSTEM"
|
||||
content={systemMessage.content}
|
||||
defaultExpanded={!!(systemMessage.content && systemMessage.content.length < 200)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* History - Tree style, collapsed by default */}
|
||||
{historyMessages.length > 0 && <HistoryTree messages={historyMessages} />}
|
||||
|
||||
{/* Last User Message - Always visible */}
|
||||
{lastMessage && (
|
||||
<SimpleMessageBlock
|
||||
label={lastMessage.role.toUpperCase()}
|
||||
content={lastMessage.content}
|
||||
toolCalls={lastMessage.toolCalls}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { Typography } from "antd";
|
||||
import { JsonView, defaultStyles } from "react-json-view-lite";
|
||||
import "react-json-view-lite/dist/index.css";
|
||||
import { JSON_MAX_HEIGHT, COLOR_BG_LIGHT, SPACING_LARGE } from "./constants";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface JsonViewerProps {
|
||||
data: any;
|
||||
mode: "formatted";
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays JSON data in formatted tree view.
|
||||
* Uses an interactive tree component for easy navigation.
|
||||
*/
|
||||
export function JsonViewer({ data }: JsonViewerProps) {
|
||||
if (!data) return <Text type="secondary">No data</Text>;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxHeight: JSON_MAX_HEIGHT,
|
||||
overflow: "auto",
|
||||
background: COLOR_BG_LIGHT,
|
||||
padding: SPACING_LARGE,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<div className="[&_[role='tree']]:bg-white [&_[role='tree']]:text-slate-900">
|
||||
<JsonView data={data} style={defaultStyles} clickToExpandNode={true} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,507 @@
|
|||
import { useState } from "react";
|
||||
import { Drawer, Typography, Descriptions, Card, Tag, Tabs, Alert, Collapse, Radio, Space } from "antd";
|
||||
import moment from "moment";
|
||||
import { LogEntry } from "../columns";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import GuardrailViewer from "../GuardrailViewer/GuardrailViewer";
|
||||
import { CostBreakdownViewer } from "../CostBreakdownViewer";
|
||||
import { ConfigInfoMessage } from "../ConfigInfoMessage";
|
||||
import { VectorStoreViewer } from "../VectorStoreViewer";
|
||||
import { TruncatedValue } from "./TruncatedValue";
|
||||
import { TokenFlow } from "./TokenFlow";
|
||||
import { JsonViewer } from "./JsonViewer";
|
||||
import { DrawerHeader } from "./DrawerHeader";
|
||||
import { useKeyboardNavigation } from "./useKeyboardNavigation";
|
||||
import {
|
||||
formatData,
|
||||
checkHasMessages,
|
||||
checkHasResponse,
|
||||
normalizeGuardrailEntries,
|
||||
calculateTotalMaskedEntities,
|
||||
getGuardrailLabel,
|
||||
checkHasVectorStoreData,
|
||||
} from "./utils";
|
||||
import {
|
||||
DRAWER_WIDTH,
|
||||
DRAWER_CONTENT_PADDING,
|
||||
API_BASE_MAX_WIDTH,
|
||||
METADATA_MAX_HEIGHT,
|
||||
TAB_REQUEST,
|
||||
TAB_RESPONSE,
|
||||
FONT_SIZE_SMALL,
|
||||
FONT_FAMILY_MONO,
|
||||
SPACING_XLARGE,
|
||||
SPACING_MEDIUM,
|
||||
} from "./constants";
|
||||
import { ToolsSection } from "../ToolsSection";
|
||||
import { PrettyMessagesView } from "./PrettyMessagesView";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export interface LogDetailsDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
logEntry: LogEntry | null;
|
||||
onOpenSettings?: () => void;
|
||||
allLogs?: LogEntry[];
|
||||
onSelectLog?: (log: LogEntry) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-side drawer panel for displaying detailed log information.
|
||||
* Features:
|
||||
* - Request ID prominently displayed with copy functionality
|
||||
* - Keyboard navigation (J/K for next/prev, Escape to close)
|
||||
* - Formatted and JSON view toggle for request/response
|
||||
* - Smart display of cache fields (hidden when zero)
|
||||
* - Error alerts for failed requests
|
||||
* - Collapsible sections for guardrails, vector store, metadata
|
||||
*/
|
||||
export function LogDetailsDrawer({
|
||||
open,
|
||||
onClose,
|
||||
logEntry,
|
||||
onOpenSettings,
|
||||
allLogs = [],
|
||||
onSelectLog,
|
||||
}: LogDetailsDrawerProps) {
|
||||
const [activeTab, setActiveTab] = useState<typeof TAB_REQUEST | typeof TAB_RESPONSE>(TAB_REQUEST);
|
||||
|
||||
// Keyboard navigation
|
||||
const { selectNextLog, selectPreviousLog } = useKeyboardNavigation({
|
||||
isOpen: open,
|
||||
currentLog: logEntry,
|
||||
allLogs,
|
||||
onClose,
|
||||
onSelectLog,
|
||||
});
|
||||
|
||||
if (!logEntry) return null;
|
||||
|
||||
const metadata = logEntry.metadata || {};
|
||||
const hasError = metadata.status === "failure";
|
||||
const errorInfo = hasError ? metadata.error_information : null;
|
||||
|
||||
// Check if request/response data is present
|
||||
const hasMessages = checkHasMessages(logEntry.messages);
|
||||
const hasResponse = checkHasResponse(logEntry.response);
|
||||
const missingData = !hasMessages && !hasResponse;
|
||||
|
||||
// Guardrail data
|
||||
const guardrailInfo = metadata?.guardrail_information;
|
||||
const guardrailEntries = normalizeGuardrailEntries(guardrailInfo);
|
||||
const hasGuardrailData = guardrailEntries.length > 0;
|
||||
const totalMaskedEntities = calculateTotalMaskedEntities(guardrailEntries);
|
||||
const primaryGuardrailLabel = getGuardrailLabel(guardrailEntries);
|
||||
|
||||
// Vector store data
|
||||
const hasVectorStoreData = checkHasVectorStoreData(metadata);
|
||||
|
||||
// Status display values
|
||||
const statusLabel = metadata.status === "failure" ? "Failure" : "Success";
|
||||
const statusColor = metadata.status === "failure" ? ("error" as const) : ("success" as const);
|
||||
const environment = metadata?.user_api_key_team_alias || "default";
|
||||
|
||||
const getRawRequest = () => {
|
||||
return formatData(logEntry.proxy_server_request || logEntry.messages);
|
||||
};
|
||||
|
||||
const getFormattedResponse = () => {
|
||||
if (hasError && errorInfo) {
|
||||
return {
|
||||
error: {
|
||||
message: errorInfo.error_message || "An error occurred",
|
||||
type: errorInfo.error_class || "error",
|
||||
code: errorInfo.error_code || "unknown",
|
||||
param: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
return formatData(logEntry.response);
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={null}
|
||||
placement="right"
|
||||
onClose={onClose}
|
||||
open={open}
|
||||
width={DRAWER_WIDTH}
|
||||
closable={false}
|
||||
mask={true}
|
||||
maskClosable={true}
|
||||
styles={{
|
||||
body: { padding: 0, overflow: "hidden" },
|
||||
header: { display: "none" },
|
||||
}}
|
||||
>
|
||||
<DrawerHeader
|
||||
log={logEntry}
|
||||
onClose={onClose}
|
||||
onPrevious={selectPreviousLog}
|
||||
onNext={selectNextLog}
|
||||
statusLabel={statusLabel}
|
||||
statusColor={statusColor}
|
||||
environment={environment}
|
||||
/>
|
||||
|
||||
<div style={{ height: "calc(100vh - 100px)", overflowY: "auto", padding: `${DRAWER_CONTENT_PADDING} ${DRAWER_CONTENT_PADDING} 0` }}>
|
||||
{/* Error Alert - Show prominently at top for failures */}
|
||||
{hasError && errorInfo && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="Request Failed"
|
||||
description={<ErrorDescription errorInfo={errorInfo} />}
|
||||
className="mb-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tags - Only show if present */}
|
||||
{logEntry.request_tags && Object.keys(logEntry.request_tags).length > 0 && (
|
||||
<TagsSection tags={logEntry.request_tags} />
|
||||
)}
|
||||
|
||||
{/* Request Details Section */}
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
|
||||
<Card title="Request Details" size="small" bordered={false} style={{ marginBottom: 0 }}>
|
||||
<Descriptions column={2} size="small">
|
||||
<Descriptions.Item label="Model">{logEntry.model}</Descriptions.Item>
|
||||
<Descriptions.Item label="Provider">{logEntry.custom_llm_provider || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="Call Type">{logEntry.call_type}</Descriptions.Item>
|
||||
<Descriptions.Item label="Model ID">
|
||||
<TruncatedValue value={logEntry.model_id} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="API Base">
|
||||
<TruncatedValue value={logEntry.api_base} maxWidth={API_BASE_MAX_WIDTH} />
|
||||
</Descriptions.Item>
|
||||
{logEntry.requester_ip_address && (
|
||||
<Descriptions.Item label="IP Address">{logEntry.requester_ip_address}</Descriptions.Item>
|
||||
)}
|
||||
{hasGuardrailData && (
|
||||
<Descriptions.Item label="Guardrail">
|
||||
<GuardrailLabel label={primaryGuardrailLabel} maskedCount={totalMaskedEntities} />
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Metrics Section */}
|
||||
<MetricsSection logEntry={logEntry} metadata={metadata} />
|
||||
|
||||
{/* Cost Breakdown - Show if cost breakdown data is available */}
|
||||
<CostBreakdownViewer costBreakdown={metadata?.cost_breakdown} totalSpend={logEntry.spend || 0} />
|
||||
|
||||
{/* Tools Section - Show if tools are present in request */}
|
||||
<ToolsSection log={logEntry} />
|
||||
|
||||
{/* Configuration Info Message - Show when data is missing */}
|
||||
{missingData && (
|
||||
<div className="mb-6">
|
||||
<ConfigInfoMessage show={missingData} onOpenSettings={onOpenSettings} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Request/Response JSON - Collapsible */}
|
||||
<RequestResponseSection
|
||||
hasResponse={hasResponse}
|
||||
getRawRequest={getRawRequest}
|
||||
getFormattedResponse={getFormattedResponse}
|
||||
logEntry={logEntry}
|
||||
/>
|
||||
|
||||
{/* Guardrail Data - Show only if present */}
|
||||
{hasGuardrailData && <GuardrailViewer data={guardrailInfo} />}
|
||||
|
||||
{/* Vector Store Request Data - Show only if present */}
|
||||
{hasVectorStoreData && <VectorStoreViewer data={metadata.vector_store_request_metadata} />}
|
||||
|
||||
{/* Metadata Card - Only show if there's metadata */}
|
||||
{logEntry.metadata && Object.keys(logEntry.metadata).length > 0 && (
|
||||
<MetadataSection metadata={logEntry.metadata} />
|
||||
)}
|
||||
|
||||
{/* Bottom spacing for scroll area */}
|
||||
<div style={{ height: DRAWER_CONTENT_PADDING }} />
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Components
|
||||
// ============================================================================
|
||||
|
||||
function ErrorDescription({ errorInfo }: { errorInfo: any }) {
|
||||
return (
|
||||
<div>
|
||||
{errorInfo.error_code && (
|
||||
<div>
|
||||
<Text strong>Error Code:</Text> {errorInfo.error_code}
|
||||
</div>
|
||||
)}
|
||||
{errorInfo.error_message && (
|
||||
<div>
|
||||
<Text strong>Message:</Text> {errorInfo.error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TagsSection({ tags }: { tags: Record<string, any> }) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden p-4 mb-6">
|
||||
<Text strong style={{ display: "block", marginBottom: 8, fontSize: 16 }}>
|
||||
Tags
|
||||
</Text>
|
||||
<Space size={SPACING_MEDIUM} wrap>
|
||||
{Object.entries(tags).map(([key, value]) => (
|
||||
<Tag key={key}>
|
||||
{key}: {String(value)}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GuardrailLabel({ label, maskedCount }: { label: string; maskedCount: number }) {
|
||||
return (
|
||||
<Space size={SPACING_MEDIUM}>
|
||||
<span>{label}</span>
|
||||
{maskedCount > 0 && (
|
||||
<Tag color="blue">
|
||||
{maskedCount} masked
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricsSection({ logEntry, metadata }: { logEntry: LogEntry; metadata: Record<string, any> }) {
|
||||
const hasCacheActivity =
|
||||
logEntry.cache_hit ||
|
||||
(metadata?.additional_usage_values?.cache_read_input_tokens &&
|
||||
metadata.additional_usage_values.cache_read_input_tokens > 0);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
|
||||
<Card title="Metrics" size="small" bordered={false} style={{ marginBottom: 0 }}>
|
||||
<Descriptions column={2} size="small">
|
||||
<Descriptions.Item label="Tokens">
|
||||
<TokenFlow
|
||||
prompt={logEntry.prompt_tokens}
|
||||
completion={logEntry.completion_tokens}
|
||||
total={logEntry.total_tokens}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Cost">${formatNumberWithCommas(logEntry.spend || 0, 8)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Duration">{logEntry.duration?.toFixed(3)} s</Descriptions.Item>
|
||||
|
||||
{/* Only show cache fields if there's cache activity */}
|
||||
{hasCacheActivity && (
|
||||
<>
|
||||
<Descriptions.Item label="Cache Hit">
|
||||
<Tag color={logEntry.cache_hit ? "green" : "default"}>{logEntry.cache_hit || "None"}</Tag>
|
||||
</Descriptions.Item>
|
||||
{metadata?.additional_usage_values?.cache_read_input_tokens > 0 && (
|
||||
<Descriptions.Item label="Cache Read Tokens">
|
||||
{formatNumberWithCommas(metadata.additional_usage_values.cache_read_input_tokens)}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{metadata?.additional_usage_values?.cache_creation_input_tokens > 0 && (
|
||||
<Descriptions.Item label="Cache Creation Tokens">
|
||||
{formatNumberWithCommas(metadata.additional_usage_values.cache_creation_input_tokens)}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{metadata?.litellm_overhead_time_ms !== undefined && metadata.litellm_overhead_time_ms !== null && (
|
||||
<Descriptions.Item label="LiteLLM Overhead">
|
||||
{metadata.litellm_overhead_time_ms.toFixed(2)} ms
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
|
||||
<Descriptions.Item label="Start Time">
|
||||
{moment(logEntry.startTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="End Time">
|
||||
{moment(logEntry.endTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RequestResponseSectionProps {
|
||||
hasResponse: boolean;
|
||||
getRawRequest: () => any;
|
||||
getFormattedResponse: () => any;
|
||||
logEntry: LogEntry;
|
||||
}
|
||||
|
||||
function RequestResponseSection({
|
||||
hasResponse,
|
||||
getRawRequest,
|
||||
getFormattedResponse,
|
||||
logEntry,
|
||||
}: RequestResponseSectionProps) {
|
||||
const [activeTab, setActiveTab] = useState<typeof TAB_REQUEST | typeof TAB_RESPONSE>(TAB_REQUEST);
|
||||
const [viewMode, setViewMode] = useState<'pretty' | 'json'>('pretty');
|
||||
|
||||
const getCopyText = () => {
|
||||
const data = activeTab === TAB_REQUEST ? getRawRequest() : getFormattedResponse();
|
||||
return JSON.stringify(data, null, 2);
|
||||
};
|
||||
|
||||
// Calculate input and output costs
|
||||
// Assume average cost if not explicitly provided
|
||||
const totalSpend = logEntry.spend || 0;
|
||||
const promptTokens = logEntry.prompt_tokens || 0;
|
||||
const completionTokens = logEntry.completion_tokens || 0;
|
||||
const totalTokens = promptTokens + completionTokens;
|
||||
|
||||
// Estimate input/output costs proportionally if not available
|
||||
const inputCost = totalTokens > 0 ? (totalSpend * promptTokens) / totalTokens : 0;
|
||||
const outputCost = totalTokens > 0 ? (totalSpend * completionTokens) / totalTokens : 0;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
|
||||
<Collapse
|
||||
defaultActiveKey={["1"]}
|
||||
expandIconPosition="start"
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: (
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}
|
||||
onClick={(e) => {
|
||||
// Only prevent if clicking on the Radio.Group area
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.ant-radio-group')) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<h3 className="text-lg font-medium text-gray-900" style={{ margin: 0 }}>Request & Response</h3>
|
||||
{/* View Mode Toggle - In the header */}
|
||||
<Radio.Group
|
||||
size="small"
|
||||
value={viewMode}
|
||||
onChange={(e) => setViewMode(e.target.value)}
|
||||
>
|
||||
<Radio.Button value="pretty">Pretty</Radio.Button>
|
||||
<Radio.Button value="json">JSON</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
{viewMode === 'pretty' ? (
|
||||
<PrettyMessagesView
|
||||
request={getRawRequest()}
|
||||
response={getFormattedResponse()}
|
||||
metrics={{
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: completionTokens,
|
||||
input_cost: inputCost,
|
||||
output_cost: outputCost,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key as typeof TAB_REQUEST | typeof TAB_RESPONSE)}
|
||||
tabBarExtraContent={
|
||||
<Text
|
||||
copyable={{
|
||||
text: getCopyText(),
|
||||
tooltips: ["Copy JSON", "Copied!"]
|
||||
}}
|
||||
disabled={activeTab === TAB_RESPONSE && !hasResponse}
|
||||
/>
|
||||
}
|
||||
items={[
|
||||
{
|
||||
key: TAB_REQUEST,
|
||||
label: "Request",
|
||||
children: (
|
||||
<div style={{ paddingTop: SPACING_XLARGE, paddingBottom: SPACING_XLARGE }}>
|
||||
<JsonViewer data={getRawRequest()} mode="formatted" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: TAB_RESPONSE,
|
||||
label: "Response",
|
||||
children: (
|
||||
<div style={{ paddingTop: SPACING_XLARGE, paddingBottom: SPACING_XLARGE }}>
|
||||
{hasResponse ? (
|
||||
<JsonViewer data={getFormattedResponse()} mode="formatted" />
|
||||
) : (
|
||||
<div style={{ textAlign: "center", padding: 20, color: "#999", fontStyle: "italic" }}>
|
||||
Response data not available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetadataSection({ metadata }: { metadata: Record<string, any> }) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
|
||||
<Collapse
|
||||
defaultActiveKey={["1"]}
|
||||
expandIconPosition="start"
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: <h3 className="text-lg font-medium text-gray-900">Metadata</h3>,
|
||||
children: (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 8 }}>
|
||||
<Text
|
||||
copyable={{
|
||||
text: JSON.stringify(metadata, null, 2),
|
||||
tooltips: ["Copy Metadata", "Copied!"]
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
maxHeight: METADATA_MAX_HEIGHT,
|
||||
overflowY: "auto",
|
||||
fontSize: FONT_SIZE_SMALL,
|
||||
fontFamily: FONT_FAMILY_MONO,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* MessageBlock - Displays a single message with role label
|
||||
* No colors, minimal gray styling
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Typography, Button } from 'antd';
|
||||
import { ToolCall } from './prettyMessagesTypes';
|
||||
import { ToolCallBlock } from './ToolCallBlock';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface MessageBlockProps {
|
||||
role: string;
|
||||
content?: string;
|
||||
toolCalls?: ToolCall[];
|
||||
}
|
||||
|
||||
const TRUNCATE_LENGTH = 500;
|
||||
|
||||
export function MessageBlock({ role, content, toolCalls }: MessageBlockProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const hasContent = content && content.length > 0;
|
||||
const hasToolCalls = toolCalls && toolCalls.length > 0;
|
||||
const isLong = hasContent && content.length > TRUNCATE_LENGTH;
|
||||
const shouldTruncate = isLong && !isExpanded;
|
||||
|
||||
// If no content and no tool calls, don't render anything
|
||||
if (!hasContent && !hasToolCalls) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
{/* Role Label */}
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
display: 'block',
|
||||
marginBottom: 4,
|
||||
color: '#8c8c8c',
|
||||
}}
|
||||
>
|
||||
{role}
|
||||
</Text>
|
||||
|
||||
{/* Content */}
|
||||
{hasContent && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
color: '#262626',
|
||||
marginBottom: hasToolCalls ? 8 : 0,
|
||||
}}
|
||||
>
|
||||
{shouldTruncate ? (
|
||||
<>
|
||||
{content.slice(0, TRUNCATE_LENGTH)}...
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
style={{ padding: '0 4px', fontSize: 12 }}
|
||||
>
|
||||
Show more
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{content}
|
||||
{isLong && isExpanded && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
style={{
|
||||
padding: '0 4px',
|
||||
fontSize: 12,
|
||||
display: 'block',
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
Show less
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool Calls */}
|
||||
{hasToolCalls && (
|
||||
<div>
|
||||
{toolCalls.map((tool, index) => (
|
||||
<ToolCallBlock key={tool.id || index} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
/**
|
||||
* MessageCard - Display individual message with role-based styling
|
||||
* Features: collapsible long content, copy button, tool calls display
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button, Typography, message as antdMessage } from 'antd';
|
||||
import { CopyOutlined } from '@ant-design/icons';
|
||||
import { ParsedMessage } from './prettyMessagesTypes';
|
||||
import { ROLE_STYLES } from './prettyMessagesUtils';
|
||||
import { ToolCallCard } from './ToolCallCard';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface MessageCardProps {
|
||||
message: ParsedMessage;
|
||||
defaultCollapsed?: boolean;
|
||||
showToolCalls?: boolean;
|
||||
}
|
||||
|
||||
const TRUNCATE_LENGTH = 500;
|
||||
|
||||
export function MessageCard({
|
||||
message,
|
||||
defaultCollapsed = false,
|
||||
showToolCalls = false,
|
||||
}: MessageCardProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const style = ROLE_STYLES[message.role] || ROLE_STYLES.user;
|
||||
const content = message.content || '';
|
||||
const isLong = content.length > TRUNCATE_LENGTH;
|
||||
const shouldTruncate = isCollapsed && isLong;
|
||||
|
||||
// Don't show empty content for assistant messages with tool calls
|
||||
const hasContent = content.length > 0;
|
||||
const hasToolCalls = showToolCalls && message.toolCalls && message.toolCalls.length > 0;
|
||||
|
||||
// If assistant message with no content but has tool calls, skip null display
|
||||
if (message.role === 'assistant' && !hasContent && hasToolCalls) {
|
||||
return (
|
||||
<div
|
||||
style={{ marginBottom: 16 }}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Role Label Row */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: style.labelColor,
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
{style.label}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Tool Calls with left border */}
|
||||
<div
|
||||
style={{
|
||||
borderLeft: `2px solid ${style.borderColor}`,
|
||||
paddingLeft: 12,
|
||||
}}
|
||||
>
|
||||
{message.toolCalls!.map((tool, index) => (
|
||||
<ToolCallCard key={tool.id || index} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(content);
|
||||
antdMessage.success('Message copied');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ marginBottom: 16 }}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Role Label Row */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: style.labelColor,
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
{style.label}
|
||||
{isLong && (
|
||||
<Text type="secondary" style={{ marginLeft: 8, fontWeight: 'normal', fontSize: 11 }}>
|
||||
({content.length.toLocaleString()} chars)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{/* Copy Button - Show on hover */}
|
||||
{hasContent && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
style={{
|
||||
opacity: isHovered ? 1 : 0,
|
||||
transition: 'opacity 0.2s',
|
||||
}}
|
||||
onClick={handleCopy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content with left border accent */}
|
||||
{hasContent && (
|
||||
<div
|
||||
style={{
|
||||
borderLeft: `2px solid ${style.borderColor}`,
|
||||
paddingLeft: 12,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
color: '#262626',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{shouldTruncate ? (
|
||||
<>
|
||||
{content.slice(0, TRUNCATE_LENGTH)}...
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => setIsCollapsed(false)}
|
||||
style={{ padding: '0 4px', fontSize: 12 }}
|
||||
>
|
||||
Show more
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{content}
|
||||
{isLong && !isCollapsed && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => setIsCollapsed(true)}
|
||||
style={{
|
||||
padding: '0 4px',
|
||||
display: 'block',
|
||||
marginTop: 4,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
Show less
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool Calls (for assistant messages) */}
|
||||
{hasToolCalls && hasContent && (
|
||||
<div
|
||||
style={{
|
||||
borderLeft: `2px solid ${style.borderColor}`,
|
||||
paddingLeft: 12,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
{message.toolCalls!.map((tool, index) => (
|
||||
<ToolCallCard key={tool.id || index} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* OutputCard - Displays output message with token count and cost
|
||||
* Datadog-style: header with icon/metrics, content below
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Typography, message as antdMessage } from 'antd';
|
||||
import { ParsedMessage } from './prettyMessagesTypes';
|
||||
import { SectionHeader } from './SectionHeader';
|
||||
import { SimpleMessageBlock } from './SimpleMessageBlock';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface OutputCardProps {
|
||||
message: ParsedMessage | null;
|
||||
completionTokens?: number;
|
||||
outputCost?: number;
|
||||
}
|
||||
|
||||
export function OutputCard({ message, completionTokens, outputCost }: OutputCardProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!message) return;
|
||||
|
||||
const content = JSON.stringify(message, null, 2);
|
||||
navigator.clipboard.writeText(content);
|
||||
antdMessage.success('Output copied');
|
||||
};
|
||||
|
||||
if (!message) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 6,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<SectionHeader
|
||||
type="output"
|
||||
tokens={completionTokens}
|
||||
cost={outputCost}
|
||||
onCopy={handleCopy}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleCollapse={() => setIsCollapsed(!isCollapsed)}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: isCollapsed ? '0px' : '10000px',
|
||||
overflow: 'hidden',
|
||||
transition: 'max-height 0.3s ease-out, opacity 0.3s ease-out',
|
||||
opacity: isCollapsed ? 0 : 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '12px 16px' }}>
|
||||
<Text type="secondary" style={{ fontSize: 13, fontStyle: 'italic' }}>
|
||||
No response data available
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 6,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Datadog-style Header */}
|
||||
<SectionHeader
|
||||
type="output"
|
||||
tokens={completionTokens}
|
||||
cost={outputCost}
|
||||
onCopy={handleCopy}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleCollapse={() => setIsCollapsed(!isCollapsed)}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
style={{
|
||||
maxHeight: isCollapsed ? '0px' : '10000px',
|
||||
overflow: 'hidden',
|
||||
transition: 'max-height 0.3s ease-out, opacity 0.3s ease-out',
|
||||
opacity: isCollapsed ? 0 : 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '12px 16px' }}>
|
||||
<SimpleMessageBlock
|
||||
label="ASSISTANT"
|
||||
content={message.content}
|
||||
toolCalls={message.toolCalls}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* PrettyMessagesView - Datadog-style view with Input/Output cards
|
||||
* Two main cards showing request and response with token counts and costs
|
||||
*/
|
||||
|
||||
import { parseMessages } from './prettyMessagesUtils';
|
||||
import { InputCard } from './InputCard';
|
||||
import { OutputCard } from './OutputCard';
|
||||
|
||||
interface PrettyMessagesViewProps {
|
||||
request: any;
|
||||
response: any;
|
||||
metrics?: {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
input_cost?: number;
|
||||
output_cost?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function PrettyMessagesView({ request, response, metrics }: PrettyMessagesViewProps) {
|
||||
const { requestMessages, responseMessage } = parseMessages(request, response);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Input Card */}
|
||||
<InputCard
|
||||
messages={requestMessages}
|
||||
promptTokens={metrics?.prompt_tokens}
|
||||
inputCost={metrics?.input_cost}
|
||||
/>
|
||||
|
||||
{/* Output Card */}
|
||||
<OutputCard
|
||||
message={responseMessage}
|
||||
completionTokens={metrics?.completion_tokens}
|
||||
outputCost={metrics?.output_cost}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* SectionHeader - Datadog-style header with icon, label, metrics, and copy
|
||||
*/
|
||||
|
||||
import { Typography, Button, Tooltip } from 'antd';
|
||||
import {
|
||||
MessageOutlined,
|
||||
CopyOutlined,
|
||||
DownOutlined,
|
||||
UpOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface SectionHeaderProps {
|
||||
type: 'input' | 'output';
|
||||
tokens?: number;
|
||||
cost?: number;
|
||||
onCopy: () => void;
|
||||
isCollapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
export function SectionHeader({ type, tokens, cost, onCopy, isCollapsed, onToggleCollapse }: SectionHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onToggleCollapse}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 16px',
|
||||
borderBottom: isCollapsed ? 'none' : '1px solid #f0f0f0',
|
||||
background: '#fafafa',
|
||||
cursor: onToggleCollapse ? 'pointer' : 'default',
|
||||
transition: 'background 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (onToggleCollapse) {
|
||||
e.currentTarget.style.background = '#f5f5f5';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#fafafa';
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
{/* Collapse Arrow */}
|
||||
{onToggleCollapse && (
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
{isCollapsed ? (
|
||||
<DownOutlined style={{ fontSize: 10, color: '#8c8c8c' }} />
|
||||
) : (
|
||||
<UpOutlined style={{ fontSize: 10, color: '#8c8c8c' }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Icon + Label */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{type === 'input' ? (
|
||||
<MessageOutlined style={{ color: '#8c8c8c', fontSize: 14 }} />
|
||||
) : (
|
||||
<span style={{ fontSize: 14, filter: 'grayscale(1)', opacity: 0.6 }}>✨</span>
|
||||
)}
|
||||
<Text style={{ fontWeight: 500, fontSize: 14 }}>
|
||||
{type === 'input' ? 'Input' : 'Output'}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Tokens */}
|
||||
{tokens !== undefined && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Tokens: {tokens.toLocaleString()}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Cost */}
|
||||
{cost !== undefined && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Cost: ${cost.toFixed(6)}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Copy Button */}
|
||||
<Tooltip title="Copy">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent triggering collapse
|
||||
onCopy();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* SimpleMessageBlock - Simple message display without collapsing
|
||||
* Used for messages in tree view and last user message
|
||||
*/
|
||||
|
||||
import { Typography } from 'antd';
|
||||
import { ToolCall } from './prettyMessagesTypes';
|
||||
import { SimpleToolCallBlock } from './SimpleToolCallBlock';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface SimpleMessageBlockProps {
|
||||
label: string;
|
||||
content?: string;
|
||||
toolCalls?: ToolCall[];
|
||||
isCompact?: boolean;
|
||||
}
|
||||
|
||||
export function SimpleMessageBlock({
|
||||
label,
|
||||
content,
|
||||
toolCalls,
|
||||
isCompact = false
|
||||
}: SimpleMessageBlockProps) {
|
||||
// Don't show "null" for empty content
|
||||
const displayContent = content && content !== 'null' && content.length > 0 ? content : null;
|
||||
const hasToolCalls = toolCalls && toolCalls.length > 0;
|
||||
|
||||
// If no content and no tool calls, don't render
|
||||
if (!displayContent && !hasToolCalls) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: isCompact ? 8 : 0 }}>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
display: 'block',
|
||||
marginBottom: 3
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
{displayContent && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
lineHeight: 1.7,
|
||||
color: '#262626',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
marginBottom: hasToolCalls ? 6 : 0,
|
||||
}}
|
||||
>
|
||||
{displayContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline tool calls for assistant messages in history */}
|
||||
{hasToolCalls && (
|
||||
<div>
|
||||
{toolCalls.map((tc, index) => (
|
||||
<SimpleToolCallBlock key={tc.id || index} tool={tc} compact={isCompact} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* SimpleToolCallBlock - Simple tool call display without copy button
|
||||
* Used in compact/tree views
|
||||
*/
|
||||
|
||||
import { Typography } from 'antd';
|
||||
import { ToolCall } from './prettyMessagesTypes';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface SimpleToolCallBlockProps {
|
||||
tool: ToolCall;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function SimpleToolCallBlock({ tool, compact = false }: SimpleToolCallBlockProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: '#f8f9fa',
|
||||
border: '1px solid #e9ecef',
|
||||
borderRadius: 6,
|
||||
padding: compact ? '6px 10px' : '10px 14px',
|
||||
marginTop: 8,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Function badge */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
left: 12,
|
||||
background: '#fff',
|
||||
padding: '0 6px',
|
||||
fontSize: 10,
|
||||
color: '#8c8c8c',
|
||||
border: '1px solid #e9ecef',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
function
|
||||
</div>
|
||||
|
||||
<Text strong style={{ fontSize: 13, display: 'block', marginBottom: 6 }}>
|
||||
{tool.name}
|
||||
</Text>
|
||||
|
||||
{Object.keys(tool.arguments).length > 0 && (
|
||||
<div>
|
||||
{Object.entries(tool.arguments).map(([key, value]) => (
|
||||
<div key={key} style={{ marginBottom: 2 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{key}:{' '}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12 }}>{JSON.stringify(value)}</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { Typography } from "antd";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface TokenFlowProps {
|
||||
prompt?: number;
|
||||
completion?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays token usage in LiteLLM format: "12 (9 prompt tokens + 3 completion tokens)"
|
||||
* Shows total with breakdown of prompt and completion tokens.
|
||||
*/
|
||||
export function TokenFlow({ prompt = 0, completion = 0, total = 0 }: TokenFlowProps) {
|
||||
return (
|
||||
<Text>
|
||||
{total.toLocaleString()} ({prompt.toLocaleString()} prompt tokens + {completion.toLocaleString()} completion
|
||||
tokens)
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* ToolCallBlock - Displays tool call with white background
|
||||
* Minimal, monochrome styling
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Typography, Button, message } from 'antd';
|
||||
import { CopyOutlined } from '@ant-design/icons';
|
||||
import { ToolCall } from './prettyMessagesTypes';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface ToolCallBlockProps {
|
||||
tool: ToolCall;
|
||||
}
|
||||
|
||||
export function ToolCallBlock({ tool }: ToolCallBlockProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(JSON.stringify(tool.arguments, null, 2));
|
||||
message.success('Tool arguments copied');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: '#fff',
|
||||
border: '1px solid #e8e8e8',
|
||||
borderRadius: 4,
|
||||
padding: '8px 12px',
|
||||
marginTop: 8,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Tool Name Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: Object.keys(tool.arguments).length > 0 ? 6 : 0,
|
||||
}}
|
||||
>
|
||||
<Text strong style={{ fontSize: 12, color: '#262626' }}>
|
||||
{tool.name}
|
||||
</Text>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
style={{
|
||||
opacity: isHovered ? 1 : 0,
|
||||
transition: 'opacity 0.2s',
|
||||
}}
|
||||
onClick={handleCopy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tool Arguments */}
|
||||
{Object.keys(tool.arguments).length > 0 && (
|
||||
<div>
|
||||
{Object.entries(tool.arguments).map(([key, value]) => (
|
||||
<div key={key} style={{ marginBottom: 2 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{key}:{' '}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12 }}>{JSON.stringify(value)}</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* ToolCallCard - Display tool call information inline in assistant messages
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button, Typography, message } from 'antd';
|
||||
import { CopyOutlined } from '@ant-design/icons';
|
||||
import { ToolCall } from './prettyMessagesTypes';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface ToolCallCardProps {
|
||||
tool: ToolCall;
|
||||
}
|
||||
|
||||
export function ToolCallCard({ tool }: ToolCallCardProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(JSON.stringify(tool.arguments, null, 2));
|
||||
message.success('Tool arguments copied');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: '#fafafa',
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 4,
|
||||
padding: '8px 12px',
|
||||
marginBottom: 8,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Tool Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: Object.keys(tool.arguments).length > 0 ? 6 : 0,
|
||||
}}
|
||||
>
|
||||
<Text strong style={{ fontSize: 12, color: '#262626' }}>
|
||||
{tool.name}
|
||||
</Text>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
style={{
|
||||
opacity: isHovered ? 1 : 0,
|
||||
transition: 'opacity 0.2s',
|
||||
}}
|
||||
onClick={handleCopy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tool Arguments - Simple key: value format */}
|
||||
{Object.keys(tool.arguments).length > 0 && (
|
||||
<div>
|
||||
{Object.entries(tool.arguments).map(([key, value]) => (
|
||||
<div key={key} style={{ marginBottom: 2 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{key}:
|
||||
</Text>{' '}
|
||||
<Text code style={{ background: 'transparent', fontSize: 12 }}>
|
||||
{JSON.stringify(value)}
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { Typography, Tooltip } from "antd";
|
||||
import { DEFAULT_MAX_WIDTH, FONT_FAMILY_MONO, FONT_SIZE_SMALL } from "./constants";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface TruncatedValueProps {
|
||||
value?: string;
|
||||
maxWidth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a truncated value with tooltip and copy functionality.
|
||||
* Useful for displaying long IDs, URLs, or other text that may overflow.
|
||||
*/
|
||||
export function TruncatedValue({ value, maxWidth = DEFAULT_MAX_WIDTH }: TruncatedValueProps) {
|
||||
if (!value) return <Text type="secondary">-</Text>;
|
||||
|
||||
return (
|
||||
<Tooltip title={value}>
|
||||
<Text
|
||||
copyable={{ text: value, tooltips: ["Copy", "Copied!"] }}
|
||||
style={{
|
||||
maxWidth,
|
||||
display: "inline-block",
|
||||
verticalAlign: "bottom",
|
||||
fontFamily: FONT_FAMILY_MONO,
|
||||
fontSize: FONT_SIZE_SMALL,
|
||||
}}
|
||||
ellipsis
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
// Drawer configuration constants
|
||||
export const DRAWER_WIDTH = "60%";
|
||||
export const DRAWER_HEADER_PADDING = "16px 24px";
|
||||
export const DRAWER_CONTENT_PADDING = "24px";
|
||||
|
||||
// Truncation and display limits
|
||||
export const DEFAULT_MAX_WIDTH = 180;
|
||||
export const API_BASE_MAX_WIDTH = 200;
|
||||
export const JSON_MAX_HEIGHT = 400;
|
||||
export const METADATA_MAX_HEIGHT = 300;
|
||||
|
||||
// Tab keys (kept for backwards compatibility if needed)
|
||||
export const TAB_REQUEST = "request" as const;
|
||||
export const TAB_RESPONSE = "response" as const;
|
||||
|
||||
// Keyboard shortcuts
|
||||
export const KEY_ESCAPE = "Escape";
|
||||
export const KEY_J_LOWER = "j";
|
||||
export const KEY_J_UPPER = "J";
|
||||
export const KEY_K_LOWER = "k";
|
||||
export const KEY_K_UPPER = "K";
|
||||
|
||||
// Typography
|
||||
export const FONT_FAMILY_MONO = "monospace";
|
||||
export const FONT_SIZE_SMALL = 12;
|
||||
export const FONT_SIZE_MEDIUM = 13;
|
||||
export const FONT_SIZE_HEADER = 16;
|
||||
|
||||
// Colors
|
||||
export const COLOR_BORDER = "#f0f0f0";
|
||||
export const COLOR_BACKGROUND = "#fff";
|
||||
export const COLOR_SECONDARY = "#8c8c8c";
|
||||
export const COLOR_BG_LIGHT = "#fafafa";
|
||||
|
||||
// Spacing
|
||||
export const SPACING_SMALL = 4;
|
||||
export const SPACING_MEDIUM = 8;
|
||||
export const SPACING_LARGE = 12;
|
||||
export const SPACING_XLARGE = 16;
|
||||
export const SPACING_XXLARGE = 24;
|
||||
|
||||
// Messages (kept for backwards compatibility if needed elsewhere)
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { LogDetailsDrawer } from "./LogDetailsDrawer";
|
||||
export type { LogDetailsDrawerProps } from "./LogDetailsDrawer";
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* Type definitions for pretty messages view
|
||||
*/
|
||||
|
||||
export interface ParsedMessage {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||
content: string;
|
||||
toolCalls?: ToolCall[];
|
||||
toolCallId?: string;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ParsedMessages {
|
||||
requestMessages: ParsedMessage[];
|
||||
responseMessage: ParsedMessage | null;
|
||||
}
|
||||
|
||||
export interface RoleStyle {
|
||||
background: string;
|
||||
borderColor: string;
|
||||
label: string;
|
||||
labelColor: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* Utility functions for parsing and formatting messages for pretty view
|
||||
*/
|
||||
|
||||
import { ParsedMessage, ParsedMessages, RoleStyle } from './prettyMessagesTypes';
|
||||
|
||||
/**
|
||||
* Role color styles for message cards - minimal, professional design
|
||||
* Color only used for labels and left border accent
|
||||
*/
|
||||
export const ROLE_STYLES: Record<string, RoleStyle> = {
|
||||
system: {
|
||||
background: 'transparent',
|
||||
borderColor: '#8c8c8c',
|
||||
label: 'SYSTEM',
|
||||
labelColor: '#8c8c8c',
|
||||
},
|
||||
user: {
|
||||
background: 'transparent',
|
||||
borderColor: '#1677ff',
|
||||
label: 'USER',
|
||||
labelColor: '#1677ff',
|
||||
},
|
||||
assistant: {
|
||||
background: 'transparent',
|
||||
borderColor: '#52c41a',
|
||||
label: 'ASSISTANT',
|
||||
labelColor: '#52c41a',
|
||||
},
|
||||
tool: {
|
||||
background: 'transparent',
|
||||
borderColor: '#fa8c16',
|
||||
label: 'TOOL RESULT',
|
||||
labelColor: '#fa8c16',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse request messages and response message from log data
|
||||
*/
|
||||
export const parseMessages = (request: any, response: any): ParsedMessages => {
|
||||
// Parse request messages
|
||||
const requestMessages: ParsedMessage[] = [];
|
||||
|
||||
if (request?.messages && Array.isArray(request.messages)) {
|
||||
request.messages.forEach((msg: any) => {
|
||||
requestMessages.push({
|
||||
role: msg.role || 'user',
|
||||
content: parseMessageContent(msg.content),
|
||||
toolCallId: msg.tool_call_id,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Parse response message
|
||||
let responseMessage: ParsedMessage | null = null;
|
||||
const responseMsg = response?.choices?.[0]?.message;
|
||||
|
||||
if (responseMsg) {
|
||||
responseMessage = {
|
||||
role: responseMsg.role || 'assistant',
|
||||
content: responseMsg.content || '',
|
||||
toolCalls: parseToolCalls(responseMsg.tool_calls),
|
||||
};
|
||||
}
|
||||
|
||||
return { requestMessages, responseMessage };
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse message content - handle strings and content arrays (for vision, etc.)
|
||||
*/
|
||||
const parseMessageContent = (content: any): string => {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
// Handle content arrays (vision API format)
|
||||
return content
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') return item;
|
||||
if (item.type === 'text') return item.text;
|
||||
if (item.type === 'image_url') return '[Image]';
|
||||
return JSON.stringify(item);
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// Fallback to JSON string for complex content
|
||||
return JSON.stringify(content);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse tool calls from response message
|
||||
*/
|
||||
const parseToolCalls = (toolCalls: any[]): Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, any>;
|
||||
}> | undefined => {
|
||||
if (!toolCalls || !Array.isArray(toolCalls)) return undefined;
|
||||
|
||||
return toolCalls.map((tc) => ({
|
||||
id: tc.id || '',
|
||||
name: tc.function?.name || 'unknown',
|
||||
arguments: parseToolArguments(tc.function?.arguments),
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse tool arguments - handle both string and object formats
|
||||
*/
|
||||
const parseToolArguments = (args: any): Record<string, any> => {
|
||||
if (!args) return {};
|
||||
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
return JSON.parse(args);
|
||||
} catch {
|
||||
return { raw: args };
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
};
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import { useEffect } from "react";
|
||||
import { LogEntry } from "../columns";
|
||||
import { KEY_ESCAPE, KEY_J_LOWER, KEY_J_UPPER, KEY_K_LOWER, KEY_K_UPPER } from "./constants";
|
||||
|
||||
interface UseKeyboardNavigationProps {
|
||||
isOpen: boolean;
|
||||
currentLog: LogEntry | null;
|
||||
allLogs: LogEntry[];
|
||||
onClose: () => void;
|
||||
onSelectLog?: (log: LogEntry) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for keyboard navigation in the log details drawer.
|
||||
* Handles J/K for next/previous and Escape for close.
|
||||
*
|
||||
* Keyboard shortcuts:
|
||||
* - J: Navigate to previous log (up)
|
||||
* - K: Navigate to next log (down)
|
||||
* - Escape: Close drawer
|
||||
*/
|
||||
export function useKeyboardNavigation({
|
||||
isOpen,
|
||||
currentLog,
|
||||
allLogs,
|
||||
onClose,
|
||||
onSelectLog,
|
||||
}: UseKeyboardNavigationProps) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Don't trigger if user is typing in an input
|
||||
if (isUserTyping(e.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOpen) return;
|
||||
|
||||
switch (e.key) {
|
||||
case KEY_ESCAPE:
|
||||
onClose();
|
||||
break;
|
||||
case KEY_J_LOWER:
|
||||
case KEY_J_UPPER:
|
||||
selectPreviousLog();
|
||||
break;
|
||||
case KEY_K_LOWER:
|
||||
case KEY_K_UPPER:
|
||||
selectNextLog();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, currentLog, allLogs]);
|
||||
|
||||
const selectNextLog = () => {
|
||||
if (!currentLog || !allLogs.length || !onSelectLog) return;
|
||||
|
||||
const currentIndex = allLogs.findIndex((l) => l.request_id === currentLog.request_id);
|
||||
if (currentIndex < allLogs.length - 1) {
|
||||
onSelectLog(allLogs[currentIndex + 1]);
|
||||
}
|
||||
};
|
||||
|
||||
const selectPreviousLog = () => {
|
||||
if (!currentLog || !allLogs.length || !onSelectLog) return;
|
||||
|
||||
const currentIndex = allLogs.findIndex((l) => l.request_id === currentLog.request_id);
|
||||
if (currentIndex > 0) {
|
||||
onSelectLog(allLogs[currentIndex - 1]);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
selectNextLog,
|
||||
selectPreviousLog,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user is currently typing in an input field.
|
||||
* Used to prevent keyboard shortcuts from interfering with text input.
|
||||
*/
|
||||
function isUserTyping(target: EventTarget | null): boolean {
|
||||
return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement;
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* Utility functions for LogDetailsDrawer component.
|
||||
* These functions handle data formatting, validation, and guardrail calculations.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Formats data for display. If input is a string, attempts to parse as JSON.
|
||||
* @param input - Data to format (string or object)
|
||||
* @returns Parsed JSON object or original input
|
||||
*/
|
||||
export function formatData(input: any) {
|
||||
if (typeof input === "string") {
|
||||
try {
|
||||
return JSON.parse(input);
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if messages array/object contains data.
|
||||
* @param messages - Messages to check
|
||||
* @returns True if messages exist and have content
|
||||
*/
|
||||
export function checkHasMessages(messages: any): boolean {
|
||||
if (!messages) return false;
|
||||
if (Array.isArray(messages)) return messages.length > 0;
|
||||
if (typeof messages === "object") return Object.keys(messages).length > 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if response object contains data.
|
||||
* @param response - Response to check
|
||||
* @returns True if response exists and has content
|
||||
*/
|
||||
export function checkHasResponse(response: any): boolean {
|
||||
if (!response) return false;
|
||||
return Object.keys(formatData(response)).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes guardrail information into an array.
|
||||
* @param guardrailInfo - Guardrail data (may be array, object, or null)
|
||||
* @returns Array of guardrail entries
|
||||
*/
|
||||
export function normalizeGuardrailEntries(guardrailInfo: any): any[] {
|
||||
if (Array.isArray(guardrailInfo)) return guardrailInfo;
|
||||
if (guardrailInfo) return [guardrailInfo];
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates total number of masked entities across all guardrail entries.
|
||||
* @param entries - Array of guardrail entries
|
||||
* @returns Total count of masked entities
|
||||
*/
|
||||
export function calculateTotalMaskedEntities(entries: any[]): number {
|
||||
return entries.reduce((sum, entry) => {
|
||||
const maskedCounts = entry?.masked_entity_count;
|
||||
if (!maskedCounts) return sum;
|
||||
return (
|
||||
sum +
|
||||
Object.values(maskedCounts).reduce<number>((acc, count) => (typeof count === "number" ? acc + count : acc), 0)
|
||||
);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a display label for guardrail(s).
|
||||
* @param entries - Array of guardrail entries
|
||||
* @returns Display string for guardrail label
|
||||
*/
|
||||
export function getGuardrailLabel(entries: any[]): string {
|
||||
if (entries.length === 0) return "-";
|
||||
if (entries.length === 1) return entries[0]?.guardrail_name ?? "-";
|
||||
return `${entries.length} guardrails`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if vector store data exists in metadata.
|
||||
* @param metadata - Metadata object to check
|
||||
* @returns True if vector store data exists and is non-empty
|
||||
*/
|
||||
export function checkHasVectorStoreData(metadata: Record<string, any>): boolean {
|
||||
return (
|
||||
metadata.vector_store_request_metadata &&
|
||||
Array.isArray(metadata.vector_store_request_metadata) &&
|
||||
metadata.vector_store_request_metadata.length > 0
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* Formatted view of tool definition with parameters table and call data
|
||||
*/
|
||||
|
||||
import { Typography, Table } from "antd";
|
||||
import { ParsedTool, ParameterRow } from "./types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface FormattedToolViewProps {
|
||||
tool: ParsedTool;
|
||||
}
|
||||
|
||||
export function FormattedToolView({ tool }: FormattedToolViewProps) {
|
||||
// Parse parameters for table display
|
||||
const parameterRows: ParameterRow[] = Object.entries(
|
||||
tool.parameters?.properties || {}
|
||||
).map(([name, schema]: [string, any]) => ({
|
||||
key: name,
|
||||
name: name,
|
||||
type: schema.type || "any",
|
||||
description: schema.description || "-",
|
||||
required: tool.parameters?.required?.includes(name) || false,
|
||||
}));
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "Parameter",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
render: (name: string, record: ParameterRow) => (
|
||||
<Text code>
|
||||
{name}
|
||||
{record.required && <Text type="danger">*</Text>}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Type",
|
||||
dataIndex: "type",
|
||||
key: "type",
|
||||
render: (type: string) => (
|
||||
<Text code style={{ color: "#1890ff" }}>
|
||||
{type}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Description",
|
||||
dataIndex: "description",
|
||||
key: "description",
|
||||
render: (desc: string) => <Text type="secondary">{desc}</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Description */}
|
||||
{tool.description && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text style={{ lineHeight: 1.6 }}>{tool.description}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parameters Table */}
|
||||
{parameterRows.length > 0 && (
|
||||
<div>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
display: "block",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Parameters
|
||||
</Text>
|
||||
<Table
|
||||
dataSource={parameterRows}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
size="small"
|
||||
bordered
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* If tool was called, show the arguments used */}
|
||||
{tool.called && tool.callData && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
display: "block",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Called With
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
background: "#f6ffed",
|
||||
border: "1px solid #b7eb8f",
|
||||
borderRadius: 4,
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 12,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(tool.callData.arguments, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* JSON view of tool definition
|
||||
*/
|
||||
|
||||
import { ParsedTool } from "./types";
|
||||
|
||||
interface JsonToolViewProps {
|
||||
tool: ParsedTool;
|
||||
}
|
||||
|
||||
export function JsonToolView({ tool }: JsonToolViewProps) {
|
||||
// Reconstruct the original tool definition
|
||||
const toolJson = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
fontSize: 12,
|
||||
background: "#fafafa",
|
||||
padding: 12,
|
||||
borderRadius: 4,
|
||||
maxHeight: 300,
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(toolJson, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* Expanded content for a tool with view mode toggle
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Typography, Radio } from "antd";
|
||||
import { ParsedTool } from "./types";
|
||||
import { FormattedToolView } from "./FormattedToolView";
|
||||
import { JsonToolView } from "./JsonToolView";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type ViewMode = "formatted" | "json";
|
||||
|
||||
interface ToolExpandedContentProps {
|
||||
tool: ParsedTool;
|
||||
}
|
||||
|
||||
export function ToolExpandedContent({ tool }: ToolExpandedContentProps) {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("formatted");
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* View Mode Toggle - Top Right */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Description
|
||||
</Text>
|
||||
<Radio.Group
|
||||
size="small"
|
||||
value={viewMode}
|
||||
onChange={(e) => setViewMode(e.target.value)}
|
||||
>
|
||||
<Radio.Button value="formatted">Formatted</Radio.Button>
|
||||
<Radio.Button value="json">JSON</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
{viewMode === "formatted" ? (
|
||||
<FormattedToolView tool={tool} />
|
||||
) : (
|
||||
<JsonToolView tool={tool} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* Individual tool item component with expandable details
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Typography, Tag } from "antd";
|
||||
import { ToolOutlined, RightOutlined, DownOutlined } from "@ant-design/icons";
|
||||
import { ParsedTool } from "./types";
|
||||
import { ToolExpandedContent } from "./ToolExpandedContent";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface ToolItemProps {
|
||||
tool: ParsedTool;
|
||||
}
|
||||
|
||||
export function ToolItem({ tool }: ToolItemProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #f0f0f0",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Header Row - Always Visible */}
|
||||
<div
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "12px 16px",
|
||||
cursor: "pointer",
|
||||
background: expanded ? "#fafafa" : "#fff",
|
||||
transition: "background 0.2s",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<ToolOutlined style={{ color: "#8c8c8c", fontSize: 14 }} />
|
||||
<Text style={{ fontSize: 14 }}>
|
||||
{tool.index}. {tool.name}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Tag color={tool.called ? "blue" : "default"}>
|
||||
{tool.called ? "called" : "not called"}
|
||||
</Tag>
|
||||
{expanded ? (
|
||||
<DownOutlined style={{ fontSize: 12, color: "#8c8c8c" }} />
|
||||
) : (
|
||||
<RightOutlined style={{ fontSize: 12, color: "#8c8c8c" }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{expanded && (
|
||||
<div
|
||||
style={{
|
||||
padding: "16px",
|
||||
borderTop: "1px solid #f0f0f0",
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
<ToolExpandedContent tool={tool} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* Core tests for Tools section
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseToolsFromLog } from "./utils";
|
||||
import { LogEntry } from "../columns";
|
||||
|
||||
describe("ToolsSection", () => {
|
||||
it("should parse tools from request and match with response tool calls", () => {
|
||||
const mockLog: LogEntry = {
|
||||
request_id: "test-123",
|
||||
api_key: "key",
|
||||
team_id: "team",
|
||||
model: "gpt-4",
|
||||
model_id: "gpt-4",
|
||||
call_type: "completion",
|
||||
spend: 0.01,
|
||||
total_tokens: 100,
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 50,
|
||||
startTime: "2024-01-01T00:00:00Z",
|
||||
endTime: "2024-01-01T00:00:01Z",
|
||||
cache_hit: "none",
|
||||
messages: JSON.stringify({
|
||||
model: "gpt-4",
|
||||
messages: [{ role: "user", content: "What's the weather?" }],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
description: "Get the current weather",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["location"],
|
||||
properties: {
|
||||
location: { type: "string", description: "City name" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "search_web",
|
||||
description: "Search the web",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["query"],
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
response: JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_123",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
arguments: '{"location": "San Francisco"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
const tools = parseToolsFromLog(mockLog);
|
||||
|
||||
expect(tools).toHaveLength(2);
|
||||
expect(tools[0].name).toBe("get_weather");
|
||||
expect(tools[0].called).toBe(true);
|
||||
expect(tools[0].callData?.arguments).toEqual({ location: "San Francisco" });
|
||||
expect(tools[1].name).toBe("search_web");
|
||||
expect(tools[1].called).toBe(false);
|
||||
});
|
||||
|
||||
it("should return empty array when no tools in request", () => {
|
||||
const mockLog: LogEntry = {
|
||||
request_id: "test-456",
|
||||
api_key: "key",
|
||||
team_id: "team",
|
||||
model: "gpt-4",
|
||||
model_id: "gpt-4",
|
||||
call_type: "completion",
|
||||
spend: 0.01,
|
||||
total_tokens: 100,
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 50,
|
||||
startTime: "2024-01-01T00:00:00Z",
|
||||
endTime: "2024-01-01T00:00:01Z",
|
||||
cache_hit: "none",
|
||||
messages: JSON.stringify({
|
||||
model: "gpt-4",
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
}),
|
||||
response: JSON.stringify({
|
||||
choices: [{ message: { content: "Hi there!" } }],
|
||||
}),
|
||||
};
|
||||
|
||||
const tools = parseToolsFromLog(mockLog);
|
||||
|
||||
expect(tools).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* Tools section component that displays all available tools from the request
|
||||
* and indicates which ones were actually called in the response
|
||||
*/
|
||||
|
||||
import { Collapse, Typography } from "antd";
|
||||
import { LogEntry } from "../columns";
|
||||
import { parseToolsFromLog } from "./utils";
|
||||
import { ToolItem } from "./ToolItem";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface ToolsSectionProps {
|
||||
log: LogEntry;
|
||||
}
|
||||
|
||||
export function ToolsSection({ log }: ToolsSectionProps) {
|
||||
const tools = parseToolsFromLog(log);
|
||||
|
||||
// Don't render if no tools
|
||||
if (tools.length === 0) return null;
|
||||
|
||||
// Calculate summary stats
|
||||
const totalTools = tools.length;
|
||||
const calledTools = tools.filter((t) => t.called).length;
|
||||
|
||||
// Get preview of first 2 tool names
|
||||
const toolNamePreview = tools
|
||||
.slice(0, 2)
|
||||
.map((t) => t.name)
|
||||
.join(", ");
|
||||
const hasMoreTools = tools.length > 2;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
|
||||
<Collapse
|
||||
expandIconPosition="start"
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
|
||||
<h3 className="text-lg font-medium text-gray-900">Tools</h3>
|
||||
<Text type="secondary" style={{ fontSize: 14 }}>
|
||||
{totalTools} provided, {calledTools} called
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 14 }}>
|
||||
• {toolNamePreview}
|
||||
{hasMoreTools && "..."}
|
||||
</Text>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{tools.map((tool) => (
|
||||
<ToolItem key={tool.name} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* Export main components and utilities for the Tools section
|
||||
*/
|
||||
|
||||
export { ToolsSection } from "./ToolsSection";
|
||||
export { parseToolsFromLog, hasTools } from "./utils";
|
||||
export type { ParsedTool, ToolDefinition, ToolCall } from "./types";
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Type definitions for the Tools section
|
||||
*/
|
||||
|
||||
export interface ToolDefinition {
|
||||
type: string;
|
||||
function: {
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
type: string;
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ParsedTool {
|
||||
index: number;
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, any>;
|
||||
called: boolean;
|
||||
callData?: {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ParameterRow {
|
||||
key: string;
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
|
@ -0,0 +1,293 @@
|
|||
/**
|
||||
* Tests for tool parsing utilities
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseToolsFromLog, hasTools } from "./utils";
|
||||
import { LogEntry } from "../columns";
|
||||
|
||||
describe("ToolsSection utils", () => {
|
||||
describe("parseToolsFromLog", () => {
|
||||
it("should return empty array when no tools in request", () => {
|
||||
const log: Partial<LogEntry> = {
|
||||
request_id: "test-1",
|
||||
messages: [],
|
||||
response: {},
|
||||
};
|
||||
|
||||
const result = parseToolsFromLog(log as LogEntry);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should parse tools from proxy_server_request", () => {
|
||||
const log: Partial<LogEntry> = {
|
||||
request_id: "test-2",
|
||||
proxy_server_request: {
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
description: "Get the current weather",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
location: { type: "string" },
|
||||
},
|
||||
required: ["location"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
response: {},
|
||||
} as any;
|
||||
|
||||
const result = parseToolsFromLog(log as LogEntry);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
index: 1,
|
||||
name: "get_weather",
|
||||
description: "Get the current weather",
|
||||
called: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse tools from messages object format", () => {
|
||||
const log: Partial<LogEntry> = {
|
||||
request_id: "test-3",
|
||||
messages: {
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "search_web",
|
||||
description: "Search the web",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
response: {},
|
||||
} as any;
|
||||
|
||||
const result = parseToolsFromLog(log as LogEntry);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe("search_web");
|
||||
});
|
||||
|
||||
it("should mark tools as called when present in response", () => {
|
||||
const log: Partial<LogEntry> = {
|
||||
request_id: "test-4",
|
||||
proxy_server_request: {
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
description: "Get weather",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "send_email",
|
||||
description: "Send email",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
response: {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_123",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
arguments: '{"location": "San Francisco"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = parseToolsFromLog(log as LogEntry);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].called).toBe(true);
|
||||
expect(result[0].callData).toBeDefined();
|
||||
expect(result[0].callData?.arguments).toEqual({
|
||||
location: "San Francisco",
|
||||
});
|
||||
expect(result[1].called).toBe(false);
|
||||
expect(result[1].callData).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle string format request and response", () => {
|
||||
const log: Partial<LogEntry> = {
|
||||
request_id: "test-5",
|
||||
proxy_server_request: JSON.stringify({
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "calculate",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
response: JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_456",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "calculate",
|
||||
arguments: '{"x": 5}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as any;
|
||||
|
||||
const result = parseToolsFromLog(log as LogEntry);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].called).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle tools with no description or parameters", () => {
|
||||
const log: Partial<LogEntry> = {
|
||||
request_id: "test-6",
|
||||
proxy_server_request: {
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "minimal_tool",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
response: {},
|
||||
} as any;
|
||||
|
||||
const result = parseToolsFromLog(log as LogEntry);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
index: 1,
|
||||
name: "minimal_tool",
|
||||
description: "",
|
||||
parameters: {},
|
||||
called: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle invalid JSON in tool call arguments gracefully", () => {
|
||||
const log: Partial<LogEntry> = {
|
||||
request_id: "test-7",
|
||||
proxy_server_request: {
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "test_tool",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
response: {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_789",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "test_tool",
|
||||
arguments: "invalid json",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = parseToolsFromLog(log as LogEntry);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].called).toBe(true);
|
||||
expect(result[0].callData?.arguments).toEqual({});
|
||||
});
|
||||
|
||||
it("should assign correct indices to multiple tools", () => {
|
||||
const log: Partial<LogEntry> = {
|
||||
request_id: "test-8",
|
||||
proxy_server_request: {
|
||||
tools: [
|
||||
{ type: "function", function: { name: "tool1" } },
|
||||
{ type: "function", function: { name: "tool2" } },
|
||||
{ type: "function", function: { name: "tool3" } },
|
||||
],
|
||||
},
|
||||
response: {},
|
||||
} as any;
|
||||
|
||||
const result = parseToolsFromLog(log as LogEntry);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].index).toBe(1);
|
||||
expect(result[1].index).toBe(2);
|
||||
expect(result[2].index).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasTools", () => {
|
||||
it("should return false when no tools in request", () => {
|
||||
const log: Partial<LogEntry> = {
|
||||
request_id: "test-9",
|
||||
messages: [],
|
||||
response: {},
|
||||
};
|
||||
|
||||
expect(hasTools(log as LogEntry)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when tools present in request", () => {
|
||||
const log: Partial<LogEntry> = {
|
||||
request_id: "test-10",
|
||||
proxy_server_request: {
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "test_tool",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
response: {},
|
||||
} as any;
|
||||
|
||||
expect(hasTools(log as LogEntry)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* Utility functions for parsing and processing tool data from log entries
|
||||
*/
|
||||
|
||||
import { LogEntry } from "../columns";
|
||||
import { ParsedTool, ToolDefinition, ToolCall } from "./types";
|
||||
|
||||
/**
|
||||
* Parse raw data that might be a string or object
|
||||
*/
|
||||
function parseData(input: any): any {
|
||||
if (typeof input === "string") {
|
||||
try {
|
||||
return JSON.parse(input);
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tools array from request data
|
||||
*/
|
||||
function extractToolsFromRequest(log: LogEntry): ToolDefinition[] {
|
||||
// Check proxy_server_request first (most complete), then messages
|
||||
const requestData = parseData(log.proxy_server_request || log.messages);
|
||||
|
||||
if (!requestData) return [];
|
||||
|
||||
// Handle array format (messages array)
|
||||
if (Array.isArray(requestData)) {
|
||||
// Tools are not typically in messages array, return empty
|
||||
return [];
|
||||
}
|
||||
|
||||
// Handle object format (request body)
|
||||
if (typeof requestData === "object" && requestData.tools) {
|
||||
return Array.isArray(requestData.tools) ? requestData.tools : [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tool calls from response data
|
||||
*/
|
||||
function extractToolCallsFromResponse(log: LogEntry): ToolCall[] {
|
||||
const responseData = parseData(log.response);
|
||||
|
||||
if (!responseData || typeof responseData !== "object") return [];
|
||||
|
||||
// OpenAI format: response.choices[0].message.tool_calls
|
||||
const choices = responseData.choices;
|
||||
if (Array.isArray(choices) && choices.length > 0) {
|
||||
const firstChoice = choices[0];
|
||||
const message = firstChoice.message;
|
||||
if (message && Array.isArray(message.tool_calls)) {
|
||||
return message.tool_calls;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse safe JSON with fallback
|
||||
*/
|
||||
function parseSafeJson(jsonString: string): Record<string, any> {
|
||||
try {
|
||||
return JSON.parse(jsonString);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to parse tools from a log entry
|
||||
* Returns an array of tools with their definition and call status
|
||||
*/
|
||||
export function parseToolsFromLog(log: LogEntry): ParsedTool[] {
|
||||
// Get tools from request
|
||||
const requestTools = extractToolsFromRequest(log);
|
||||
|
||||
if (requestTools.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get tool calls from response
|
||||
const toolCalls = extractToolCallsFromResponse(log);
|
||||
const calledToolNames = new Set(
|
||||
toolCalls.map((tc: ToolCall) => tc.function?.name).filter(Boolean)
|
||||
);
|
||||
|
||||
// Map tool calls by name for quick lookup
|
||||
const toolCallMap = new Map<string, any>();
|
||||
toolCalls.forEach((tc: ToolCall) => {
|
||||
const name = tc.function?.name;
|
||||
if (name) {
|
||||
toolCallMap.set(name, {
|
||||
id: tc.id,
|
||||
name: name,
|
||||
arguments: parseSafeJson(tc.function?.arguments || "{}"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Parse each tool definition
|
||||
return requestTools.map((tool: ToolDefinition, index: number) => {
|
||||
const func = tool.function || { name: `Tool ${index + 1}` };
|
||||
const name = func.name || `Tool ${index + 1}`;
|
||||
|
||||
return {
|
||||
index: index + 1,
|
||||
name: name,
|
||||
description: func.description || "",
|
||||
parameters: func.parameters || {},
|
||||
called: calledToolNames.has(name),
|
||||
callData: toolCallMap.get(name),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a log entry has any tools
|
||||
*/
|
||||
export function hasTools(log: LogEntry): boolean {
|
||||
const requestTools = extractToolsFromRequest(log);
|
||||
return requestTools.length > 0;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState } from "react";
|
||||
import { Collapse } from "antd";
|
||||
import { getProviderLogoAndName } from "../provider_info_helpers";
|
||||
|
||||
interface VectorStoreContent {
|
||||
|
|
@ -30,7 +31,6 @@ interface VectorStoreViewerProps {
|
|||
}
|
||||
|
||||
export function VectorStoreViewer({ data }: VectorStoreViewerProps) {
|
||||
const [sectionExpanded, setSectionExpanded] = useState(true);
|
||||
const [expandedResults, setExpandedResults] = useState<Record<string, boolean>>({});
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
|
|
@ -56,27 +56,16 @@ export function VectorStoreViewer({ data }: VectorStoreViewerProps) {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow mb-6">
|
||||
<div
|
||||
className="flex justify-between items-center p-4 border-b cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => setSectionExpanded(!sectionExpanded)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className={`w-5 h-5 mr-2 text-gray-600 transition-transform ${sectionExpanded ? "transform rotate-90" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium">Vector Store Requests</h3>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">{sectionExpanded ? "Click to collapse" : "Click to expand"}</span>
|
||||
</div>
|
||||
|
||||
{sectionExpanded && (
|
||||
<div className="p-4">
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
|
||||
<Collapse
|
||||
defaultActiveKey={["1"]}
|
||||
expandIconPosition="start"
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: <h3 className="text-lg font-medium text-gray-900">Vector Store Requests</h3>,
|
||||
children: (
|
||||
<div className="p-4">
|
||||
{data.map((request, index) => (
|
||||
<div key={index} className="mb-6 last:mb-0">
|
||||
<div className="bg-white rounded-lg border p-4 mb-4">
|
||||
|
|
@ -168,7 +157,10 @@ export function VectorStoreViewer({ data }: VectorStoreViewerProps) {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,46 +49,6 @@ export type LogEntry = {
|
|||
};
|
||||
|
||||
export const columns: ColumnDef<LogEntry>[] = [
|
||||
{
|
||||
id: "expander",
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
// Convert the cell function to a React component to properly use hooks
|
||||
const ExpanderCell = () => {
|
||||
const [localExpanded, setLocalExpanded] = React.useState(row.getIsExpanded());
|
||||
|
||||
// Memoize the toggle handler to prevent unnecessary re-renders
|
||||
const toggleHandler = React.useCallback(() => {
|
||||
setLocalExpanded((prev) => !prev);
|
||||
row.getToggleExpandedHandler()();
|
||||
}, [row]);
|
||||
|
||||
return row.getCanExpand() ? (
|
||||
<button
|
||||
onClick={toggleHandler}
|
||||
style={{ cursor: "pointer" }}
|
||||
aria-label={localExpanded ? "Collapse row" : "Expand row"}
|
||||
className="w-6 h-6 flex items-center justify-center focus:outline-none"
|
||||
>
|
||||
<svg
|
||||
className={`w-4 h-4 transform transition-transform duration-75 ${localExpanded ? "rotate-90" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-6 h-6 flex items-center justify-center">●</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Return the component
|
||||
return <ExpanderCell />;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Time",
|
||||
accessorKey: "startTime",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import SpendLogsSettingsModal from "./SpendLogsSettingsModal/SpendLogsSettingsMo
|
|||
import { DataTable } from "./table";
|
||||
import { VectorStoreViewer } from "./VectorStoreViewer";
|
||||
import NewBadge from "../common_components/NewBadge";
|
||||
import { LogDetailsDrawer } from "./LogDetailsDrawer";
|
||||
|
||||
interface SpendLogsTableProps {
|
||||
accessToken: string | null;
|
||||
|
|
@ -89,7 +90,8 @@ export default function SpendLogsTable({
|
|||
const [filterByCurrentUser, setFilterByCurrentUser] = useState(userRole && internalUserRoles.includes(userRole));
|
||||
const [activeTab, setActiveTab] = useState("request logs");
|
||||
|
||||
const [expandedRequestId, setExpandedRequestId] = useState<string | null>(null);
|
||||
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null);
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
|
||||
const [isSpendLogsSettingsModalVisible, setIsSpendLogsSettingsModalVisible] = useState(false);
|
||||
|
||||
|
|
@ -317,17 +319,6 @@ export default function SpendLogsTable({
|
|||
enabled: !!accessToken && !!selectedSessionId,
|
||||
});
|
||||
|
||||
// Add this effect to preserve expanded state when data refreshes
|
||||
useEffect(() => {
|
||||
if (logs.data?.data && expandedRequestId) {
|
||||
// Check if the expanded request ID still exists in the new data
|
||||
const stillExists = logs.data.data.some((log) => log.request_id === expandedRequestId);
|
||||
if (!stillExists) {
|
||||
// If the request ID no longer exists in the data, clear the expanded state
|
||||
setExpandedRequestId(null);
|
||||
}
|
||||
}
|
||||
}, [logs.data?.data, expandedRequestId]);
|
||||
|
||||
if (!accessToken || !token || !userRole || !userID) {
|
||||
return null;
|
||||
|
|
@ -367,8 +358,18 @@ export default function SpendLogsTable({
|
|||
logs.refetch();
|
||||
};
|
||||
|
||||
const handleRowExpand = (requestId: string | null) => {
|
||||
setExpandedRequestId(requestId);
|
||||
const handleRowClick = (log: LogEntry) => {
|
||||
setSelectedLog(log);
|
||||
setIsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDrawer = () => {
|
||||
setIsDrawerOpen(false);
|
||||
// Optionally keep selectedLog for animation purposes
|
||||
};
|
||||
|
||||
const handleSelectLog = (log: LogEntry) => {
|
||||
setSelectedLog(log);
|
||||
};
|
||||
|
||||
// Function to extract unique error codes from logs
|
||||
|
|
@ -554,9 +555,7 @@ export default function SpendLogsTable({
|
|||
<DataTable
|
||||
columns={columns}
|
||||
data={sessionData}
|
||||
renderSubComponent={({ row }) => <RequestViewer row={row} onOpenSettings={() => setIsSpendLogsSettingsModalVisible(true)} />}
|
||||
getRowCanExpand={() => true}
|
||||
// Optionally: add session-specific row expansion state
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -753,8 +752,7 @@ export default function SpendLogsTable({
|
|||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredData}
|
||||
renderSubComponent={({ row }) => <RequestViewer row={row} onOpenSettings={() => setIsSpendLogsSettingsModalVisible(true)} />}
|
||||
getRowCanExpand={() => true}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -775,6 +773,16 @@ export default function SpendLogsTable({
|
|||
<TabPanel><DeletedTeamsPage /></TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
|
||||
{/* Log Details Drawer */}
|
||||
<LogDetailsDrawer
|
||||
open={isDrawerOpen}
|
||||
onClose={handleCloseDrawer}
|
||||
logEntry={selectedLog}
|
||||
onOpenSettings={() => setIsSpendLogsSettingsModalVisible(true)}
|
||||
allLogs={filteredData}
|
||||
onSelectLog={handleSelectLog}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } fro
|
|||
interface DataTableProps<TData, TValue> {
|
||||
data: TData[];
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
renderSubComponent: (props: { row: Row<TData> }) => React.ReactElement;
|
||||
getRowCanExpand: (row: Row<TData>) => boolean;
|
||||
onRowClick?: (row: TData) => void;
|
||||
// Legacy props for backward compatibility (audit logs)
|
||||
renderSubComponent?: (props: { row: Row<TData> }) => React.ReactElement;
|
||||
getRowCanExpand?: (row: Row<TData>) => boolean;
|
||||
isLoading?: boolean;
|
||||
loadingMessage?: string;
|
||||
noDataMessage?: string;
|
||||
|
|
@ -16,22 +18,26 @@ interface DataTableProps<TData, TValue> {
|
|||
export function DataTable<TData, TValue>({
|
||||
data = [],
|
||||
columns,
|
||||
getRowCanExpand,
|
||||
onRowClick,
|
||||
renderSubComponent,
|
||||
getRowCanExpand,
|
||||
isLoading = false,
|
||||
loadingMessage = "🚅 Loading logs...",
|
||||
noDataMessage = "No logs found",
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
// Determine if we're in legacy expansion mode or new drawer mode
|
||||
const isLegacyMode = !!renderSubComponent && !!getRowCanExpand;
|
||||
|
||||
const table = useReactTable<TData>({
|
||||
data,
|
||||
columns,
|
||||
getRowCanExpand,
|
||||
...(isLegacyMode && { getRowCanExpand }),
|
||||
getRowId: (row: TData, index: number) => {
|
||||
const _row: any = row as any;
|
||||
return _row?.request_id ?? String(index);
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
...(isLegacyMode && { getExpandedRowModel: getExpandedRowModel() }),
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
@ -62,7 +68,10 @@ export function DataTable<TData, TValue>({
|
|||
) : table.getRowModel().rows.length > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<Fragment key={row.id}>
|
||||
<TableRow className="h-8">
|
||||
<TableRow
|
||||
className={`h-8 ${!isLegacyMode ? "cursor-pointer hover:bg-gray-50" : ""}`}
|
||||
onClick={() => !isLegacyMode && onRowClick?.(row.original)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
|
|
@ -70,7 +79,8 @@ export function DataTable<TData, TValue>({
|
|||
))}
|
||||
</TableRow>
|
||||
|
||||
{row.getIsExpanded() && (
|
||||
{/* Legacy expansion mode for audit logs */}
|
||||
{isLegacyMode && row.getIsExpanded() && renderSubComponent && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={row.getVisibleCells().length} className="p-0">
|
||||
<div className="w-full max-w-full overflow-hidden box-border">{renderSubComponent({ row })}</div>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue