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:
Neha Prasad 2026-02-01 04:16:10 +05:30 committed by GitHub
parent aac9907ab8
commit 9dc55ea69a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 4974 additions and 433 deletions

View file

@ -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
![Navigate to Models](./img/azure_model_router_01.jpeg)
#### Click Provider Dropdown
##### Click Provider Dropdown
![Click Provider](./img/azure_model_router_02.jpeg)
#### Choose Azure AI Foundry
##### Choose Azure AI Foundry
![Select Azure AI Foundry](./img/azure_model_router_03.jpeg)
### 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`
![Click Model Field](./img/azure_model_router_04.jpeg)
#### Select Custom Model Name
![Select Custom Model](./img/azure_model_router_05.jpeg)
#### Enter LiteLLM Model Name
![LiteLLM Model Name](./img/azure_model_router_06.jpeg)
#### Click Custom Model Name Field
![Enter Custom Name Field](./img/azure_model_router_07.jpeg)
#### Type Model Prefix
Type `azure_ai/` as the prefix.
![Type azure_ai prefix](./img/azure_model_router_08.jpeg)
#### 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.
![Copy Model Name](./img/azure_model_router_10.jpeg)
#### 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.
![Paste Model Name](./img/azure_model_router_11.jpeg)
![Enter Deployment Name](./img/azure_model_router_04.jpeg)
### 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
![Copy API Base](./img/azure_model_router_12.jpeg)
#### Enter API Base in LiteLLM
##### Enter API Base in LiteLLM
![Click API Base Field](./img/azure_model_router_13.jpeg)
![Paste API Base](./img/azure_model_router_14.jpeg)
#### Copy API Key from Azure
##### Copy API Key from Azure
![Copy API Key](./img/azure_model_router_15.jpeg)
#### Enter API Key in LiteLLM
##### Enter API Key in LiteLLM
![Enter API Key](./img/azure_model_router_16.jpeg)
### Test and Add Model
#### Step 4: Test and Add Model
Verify your configuration works and save the model.
#### Test Connection
##### Test Connection
![Test Connection](./img/azure_model_router_17.jpeg)
#### Close Test Dialog
##### Close Test Dialog
![Close Dialog](./img/azure_model_router_18.jpeg)
#### Add Model
##### Add Model
![Add Model](./img/azure_model_router_19.jpeg)
### Verify in Playground
#### Step 5: Verify in Playground
Test your model and verify cost tracking is working.
#### Open Playground
##### Open Playground
![Go to Playground](./img/azure_model_router_20.jpeg)
#### Select Model
##### Select Model
![Select Model](./img/azure_model_router_21.jpeg)
#### Send Test Message
##### Send Test Message
![Send Message](./img/azure_model_router_22.jpeg)
#### View Logs
##### View Logs
![View Logs](./img/azure_model_router_23.jpeg)
#### 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.
![Verify Cost](./img/azure_model_router_24.jpeg)
@ -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.

View file

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

View file

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

View file

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

View file

@ -0,0 +1,4 @@
"""Azure AI Foundry Model Router support."""
from .transformation import AzureModelRouterConfig
__all__ = ["AzureModelRouterConfig"]

View 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

View file

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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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}")

View file

@ -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():

View file

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

View file

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

View file

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

View file

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

View file

@ -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 () => {

View file

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

View file

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

View file

@ -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 () => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,2 @@
export { LogDetailsDrawer } from "./LogDetailsDrawer";
export type { LogDetailsDrawerProps } from "./LogDetailsDrawer";

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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