chore: initial snapshot for gitea/github upload

This commit is contained in:
Your Name
2026-03-26 16:04:46 +08:00
commit a699a1ac98
3497 changed files with 1586237 additions and 0 deletions

View File

@@ -0,0 +1,148 @@
"""
Translate from OpenAI's `/v1/chat/completions` to VLLM's `/v1/chat/completions`
"""
from typing import TYPE_CHECKING, List, Optional, Tuple
from litellm.constants import OPENAI_CHAT_COMPLETION_PARAMS
from litellm.secret_managers.main import get_secret_bool, get_secret_str
from litellm.types.router import LiteLLM_Params
from ...openai.chat.gpt_transformation import OpenAIGPTConfig
if TYPE_CHECKING:
from litellm.types.llms.openai import AllMessageValues
class LiteLLMProxyChatConfig(OpenAIGPTConfig):
def get_supported_openai_params(self, model: str) -> List:
params_list = super().get_supported_openai_params(model)
params_list.extend(OPENAI_CHAT_COMPLETION_PARAMS)
return params_list
def _map_openai_params(
self,
non_default_params: dict,
optional_params: dict,
model: str,
drop_params: bool,
) -> dict:
supported_openai_params = self.get_supported_openai_params(model)
for param, value in non_default_params.items():
if param == "thinking":
optional_params.setdefault("extra_body", {})["thinking"] = value
elif param in supported_openai_params:
optional_params[param] = value
return optional_params
def _get_openai_compatible_provider_info(
self, api_base: Optional[str], api_key: Optional[str]
) -> Tuple[Optional[str], Optional[str]]:
api_base = api_base or get_secret_str("LITELLM_PROXY_API_BASE") # type: ignore
dynamic_api_key = api_key or get_secret_str("LITELLM_PROXY_API_KEY")
return api_base, dynamic_api_key
def get_models(
self, api_key: Optional[str] = None, api_base: Optional[str] = None
) -> List[str]:
api_base, api_key = self._get_openai_compatible_provider_info(api_base, api_key)
if api_base is None:
raise ValueError(
"api_base not set for LiteLLM Proxy route. Set in env via `LITELLM_PROXY_API_BASE`"
)
models = super().get_models(api_key=api_key, api_base=api_base)
return [f"litellm_proxy/{model}" for model in models]
@staticmethod
def get_api_key(api_key: Optional[str] = None) -> Optional[str]:
return api_key or get_secret_str("LITELLM_PROXY_API_KEY")
@staticmethod
def _should_use_litellm_proxy_by_default(
litellm_params: Optional[LiteLLM_Params] = None,
):
"""
Returns True if litellm proxy should be used by default for a given request
Issue: https://github.com/BerriAI/litellm/issues/10559
Use case:
- When using Google ADK, users want a flag to dynamically enable sending the request to litellm proxy or not
- Allow the model name to be passed in original format and still use litellm proxy:
"gemini/gemini-1.5-pro", "openai/gpt-4", "mistral/llama-2-70b-chat" etc.
"""
import litellm
if get_secret_bool("USE_LITELLM_PROXY") is True:
return True
if litellm_params and litellm_params.use_litellm_proxy is True:
return True
if litellm.use_litellm_proxy is True:
return True
return False
@staticmethod
def litellm_proxy_get_custom_llm_provider_info(
model: str, api_base: Optional[str] = None, api_key: Optional[str] = None
) -> Tuple[str, str, Optional[str], Optional[str]]:
"""
Force use litellm proxy for all models
Issue: https://github.com/BerriAI/litellm/issues/10559
Expected behavior:
- custom_llm_provider will be 'litellm_proxy'
- api_base = api_base OR LITELLM_PROXY_API_BASE
- api_key = api_key OR LITELLM_PROXY_API_KEY
Use case:
- When using Google ADK, users want a flag to dynamically enable sending the request to litellm proxy or not
- Allow the model name to be passed in original format and still use litellm proxy:
"gemini/gemini-1.5-pro", "openai/gpt-4", "mistral/llama-2-70b-chat" etc.
Return model, custom_llm_provider, dynamic_api_key, api_base
"""
import litellm
custom_llm_provider = "litellm_proxy"
if model.startswith("litellm_proxy/"):
model = model.split("/", 1)[1]
(
api_base,
api_key,
) = litellm.LiteLLMProxyChatConfig()._get_openai_compatible_provider_info(
api_base=api_base, api_key=api_key
)
return model, custom_llm_provider, api_key, api_base
def transform_request(
self,
model: str,
messages: List["AllMessageValues"],
optional_params: dict,
litellm_params: dict,
headers: dict,
) -> dict:
# don't transform the request
return {
"model": model,
"messages": messages,
**optional_params,
}
async def async_transform_request(
self,
model: str,
messages: List["AllMessageValues"],
optional_params: dict,
litellm_params: dict,
headers: dict,
) -> dict:
# don't transform the request
return {
"model": model,
"messages": messages,
**optional_params,
}

View File

@@ -0,0 +1,26 @@
from typing import Optional
from litellm.llms.openai.image_edit.transformation import OpenAIImageEditConfig
from litellm.secret_managers.main import get_secret_str
class LiteLLMProxyImageEditConfig(OpenAIImageEditConfig):
"""Configuration for image edit requests routed through LiteLLM Proxy."""
def validate_environment(
self, headers: dict, model: str, api_key: Optional[str] = None
) -> dict:
api_key = api_key or get_secret_str("LITELLM_PROXY_API_KEY")
headers.update({"Authorization": f"Bearer {api_key}"})
return headers
def get_complete_url(
self, model: str, api_base: Optional[str], litellm_params: dict
) -> str:
api_base = api_base or get_secret_str("LITELLM_PROXY_API_BASE")
if api_base is None:
raise ValueError(
"api_base not set for LiteLLM Proxy route. Set in env via `LITELLM_PROXY_API_BASE`"
)
api_base = api_base.rstrip("/")
return f"{api_base}/images/edits"

View File

@@ -0,0 +1,41 @@
from typing import Optional
from litellm.llms.openai.image_generation.gpt_transformation import (
GPTImageGenerationConfig,
)
from litellm.secret_managers.main import get_secret_str
class LiteLLMProxyImageGenerationConfig(GPTImageGenerationConfig):
"""Configuration for image generation requests routed through LiteLLM Proxy."""
def validate_environment(
self,
headers: dict,
model: str,
messages,
optional_params: dict,
litellm_params: dict,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
) -> dict:
api_key = api_key or get_secret_str("LITELLM_PROXY_API_KEY")
headers.update({"Authorization": f"Bearer {api_key}"})
return headers
def get_complete_url(
self,
api_base: Optional[str],
api_key: Optional[str],
model: str,
optional_params: dict,
litellm_params: dict,
stream: Optional[bool] = None,
) -> str:
api_base = api_base or get_secret_str("LITELLM_PROXY_API_BASE")
if api_base is None:
raise ValueError(
"api_base not set for LiteLLM Proxy route. Set in env via `LITELLM_PROXY_API_BASE`"
)
api_base = api_base.rstrip("/")
return f"{api_base}/images/generations"

View File

@@ -0,0 +1,52 @@
"""
Responses API transformation for LiteLLM Proxy provider.
LiteLLM Proxy supports the OpenAI Responses API natively when the underlying model supports it.
This config enables pass-through behavior to the proxy's /v1/responses endpoint.
"""
from typing import Optional
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.secret_managers.main import get_secret_str
from litellm.types.utils import LlmProviders
class LiteLLMProxyResponsesAPIConfig(OpenAIResponsesAPIConfig):
"""
Configuration for LiteLLM Proxy Responses API support.
Extends OpenAI's config since the proxy follows OpenAI's API spec,
but uses LITELLM_PROXY_API_BASE for the base URL.
"""
@property
def custom_llm_provider(self) -> LlmProviders:
return LlmProviders.LITELLM_PROXY
def get_complete_url(
self,
api_base: Optional[str],
litellm_params: dict,
) -> str:
"""
Get the endpoint for LiteLLM Proxy responses API.
Uses LITELLM_PROXY_API_BASE environment variable if api_base is not provided.
"""
api_base = api_base or get_secret_str("LITELLM_PROXY_API_BASE")
if api_base is None:
raise ValueError(
"api_base not set for LiteLLM Proxy responses API. "
"Set via api_base parameter or LITELLM_PROXY_API_BASE environment variable"
)
# Remove trailing slashes
api_base = api_base.rstrip("/")
return f"{api_base}/responses"
def supports_native_websocket(self) -> bool:
"""LiteLLM Proxy does not support native WebSocket for Responses API"""
return False

View File

@@ -0,0 +1,381 @@
# LiteLLM Skills - Database-Backed Skills Storage
This module provides database-backed skills storage as an alternative to Anthropic's cloud-based Skills API. It enables using skills with **any LLM provider** (Bedrock, OpenAI, Azure, etc.) by storing skills locally and converting them to tools + system prompt injection.
## Architecture
```mermaid
flowchart TB
subgraph "Skill Creation"
A[User creates skill with ZIP file] --> B{custom_llm_provider?}
B -->|anthropic| C[Forward to Anthropic API]
B -->|litellm_proxy| D[Store in LiteLLM Database]
D --> E[Extract & store:<br/>- display_title<br/>- description<br/>- instructions<br/>- file_content ZIP]
end
subgraph "Skill Usage in Messages API"
F[Request with container.skills] --> G[SkillsInjectionHook]
G --> H{skill_id prefix?}
H -->|"litellm:skill_abc"| I[Fetch from LiteLLM DB]
H -->|"skill_xyz" no prefix| J[Pass to Anthropic as native skill]
I --> K{Model provider?}
K -->|Anthropic API| L[Convert to tools]
K -->|Bedrock/OpenAI/etc| M[Convert to tools +<br/>Inject SKILL.md into system prompt]
J --> N[Keep in container.skills]
end
subgraph "Skill Resolution for Non-Anthropic"
M --> O[Extract SKILL.md from ZIP]
O --> P[Add to system prompt:<br/># Available Skills<br/>## Skill: My Skill<br/>SKILL.md content...]
P --> Q[Create OpenAI-style tool:<br/>type: function<br/>name: skill_id<br/>description: instructions]
Q --> R[Send to LLM Provider]
end
```
## Automatic Code Execution
For skills that include executable code (Python files), LiteLLM automatically handles:
1. **Pre-call hook** (`async_pre_call_hook`): Adds `litellm_code_execution` tool, injects SKILL.md content
2. **Post-call hook** (`async_post_call_success_deployment_hook`): Detects tool calls, executes code in Docker sandbox, continues loop
3. **Returns files**: Generated files (GIFs, images, etc.) returned directly on response
```mermaid
sequenceDiagram
participant User
participant LiteLLM as LiteLLM SDK
participant PreHook as async_pre_call_hook
participant LLM as LLM Provider
participant PostHook as async_post_call_success_deployment_hook
participant Sandbox as Docker Sandbox
User->>LiteLLM: litellm.acompletion(model, messages, container={skills: [...]})
Note over LiteLLM,PreHook: PRE-CALL HOOK
LiteLLM->>PreHook: Intercept request
PreHook->>PreHook: Fetch skill from DB (litellm:skill_id)
PreHook->>PreHook: Extract SKILL.md from ZIP
PreHook->>PreHook: Inject SKILL.md into system prompt
PreHook->>PreHook: Add litellm_code_execution tool
PreHook->>PreHook: Store skill files in metadata
PreHook-->>LiteLLM: Modified request
LiteLLM->>LLM: Forward to provider (OpenAI/Bedrock/etc)
LLM-->>LiteLLM: Response with tool_calls
Note over LiteLLM,PostHook: POST-CALL HOOK (Agentic Loop)
LiteLLM->>PostHook: Check response
loop Until no more tool calls
PostHook->>PostHook: Check for litellm_code_execution tool call
alt Has code execution tool call
PostHook->>Sandbox: Execute Python code
Sandbox->>Sandbox: Copy skill files to /sandbox
Sandbox->>Sandbox: Install requirements.txt
Sandbox->>Sandbox: Run code
Sandbox-->>PostHook: Result + generated files
PostHook->>PostHook: Add tool result to messages
PostHook->>LLM: Make another LLM call
LLM-->>PostHook: New response
else No code execution
PostHook->>PostHook: Break loop
end
end
PostHook->>PostHook: Attach files to response._litellm_generated_files
PostHook-->>LiteLLM: Modified response with files
LiteLLM-->>User: Final response with generated files
```
```python
import litellm
from litellm.proxy.hooks.litellm_skills import SkillsInjectionHook
# Register the hook (done once at startup)
hook = SkillsInjectionHook()
litellm.callbacks.append(hook)
# ONE request - LiteLLM handles everything automatically
# The container parameter triggers the SkillsInjectionHook
response = await litellm.acompletion(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Create a bouncing ball GIF"}],
container={
"skills": [{"type": "custom", "skill_id": "litellm:skill_abc123"}]
},
)
# Files are attached directly to response
generated_files = response._litellm_generated_files
for f in generated_files:
print(f"Generated: {f['name']} ({f['size']} bytes)")
# f['content_base64'] contains the file data
```
This mimics Anthropic's behavior - no manual agentic loop needed!
### How it works
The `SkillsInjectionHook` uses two hooks:
1. **`async_pre_call_hook`** (proxy only): Transforms the request before LLM call
- Fetches skills from DB
- Injects SKILL.md into system prompt
- Adds `litellm_code_execution` tool
- Sets `_litellm_code_execution_enabled=True` in metadata
2. **`async_post_call_success_deployment_hook`** (SDK + proxy): Called after LLM response
- Checks if response has `litellm_code_execution` tool call
- Executes code in Docker sandbox
- Adds result to messages, makes another LLM call
- Repeats until model gives final response
- Attaches generated files to `response._litellm_generated_files`
## File Structure
```
litellm/llms/litellm_proxy/skills/
├── __init__.py # Exports all skill components
├── handler.py # LiteLLMSkillsHandler - database CRUD operations (Prisma)
├── transformation.py # LiteLLMSkillsTransformationHandler - SDK transformation layer
├── prompt_injection.py # SkillPromptInjectionHandler - SKILL.md extraction and injection
├── sandbox_executor.py # SkillsSandboxExecutor - Docker sandbox code execution
├── code_execution.py # CodeExecutionHandler - automatic agentic loop
└── README.md # This file
litellm/proxy/hooks/litellm_skills/
├── __init__.py # Re-exports from SDK + SkillsInjectionHook
└── main.py # SkillsInjectionHook - CustomLogger hook for proxy
```
## Components
### 1. `handler.py` - LiteLLMSkillsHandler
Database operations for skills CRUD:
```python
from litellm.llms.litellm_proxy.skills import LiteLLMSkillsHandler
# Create skill
skill = await LiteLLMSkillsHandler.create_skill(
data=NewSkillRequest(
display_title="My Skill",
description="A helpful skill",
instructions="Use this skill when...",
file_content=zip_bytes, # ZIP file content
file_name="my-skill.zip",
file_type="application/zip",
),
user_id="user_123"
)
# List skills
skills = await LiteLLMSkillsHandler.list_skills(limit=10, offset=0)
# Get skill
skill = await LiteLLMSkillsHandler.get_skill(skill_id="skill_abc123")
# Delete skill
await LiteLLMSkillsHandler.delete_skill(skill_id="skill_abc123")
```
### 2. `transformation.py` - LiteLLMSkillsTransformationHandler
SDK-level transformation layer that wraps handler operations:
```python
from litellm.llms.litellm_proxy.skills import LiteLLMSkillsTransformationHandler
handler = LiteLLMSkillsTransformationHandler()
# Async create
skill = await handler.create_skill_handler(
display_title="My Skill",
files=[zip_file],
_is_async=True
)
```
## Skill ZIP Format
Skills must be packaged as ZIP files with a `SKILL.md` file:
```
my-skill.zip
└── my-skill/
└── SKILL.md
```
### SKILL.md Format
```markdown
---
name: my-skill
description: A brief description of what this skill does
---
# My Skill
Detailed instructions for the LLM on how to use this skill.
## Usage
When the user asks about X, use this skill to...
## Examples
- Example 1: ...
- Example 2: ...
```
## SDK Usage
### Create Skill in LiteLLM Database
```python
import litellm
# Create skill stored in LiteLLM DB
skill = litellm.create_skill(
display_title="Data Analysis Skill",
files=[open("data-analysis.zip", "rb")],
custom_llm_provider="litellm_proxy", # Store in LiteLLM DB
)
print(f"Created skill: {skill.id}") # skill_abc123
```
### Use Skill with Any Provider
```python
import litellm
# Use LiteLLM-stored skill with Bedrock
response = litellm.completion(
model="bedrock/anthropic.claude-3-sonnet-20240229-v1:0",
messages=[{"role": "user", "content": "Analyze this data..."}],
container={
"skills": [
{"type": "custom", "skill_id": "litellm:skill_abc123"} # litellm: prefix
]
}
)
```
## How Skill Resolution Works
### Step 1: Request with Skills
```python
{
"model": "bedrock/claude-3-sonnet",
"messages": [{"role": "user", "content": "Help me analyze data"}],
"container": {
"skills": [
{"type": "custom", "skill_id": "litellm:skill_abc123"}
]
}
}
```
### Step 2: SkillsInjectionHook Processing
The hook (`litellm/proxy/hooks/litellm_skills/main.py`) intercepts the request:
1. **Detects `litellm:` prefix** → Fetches skill from database
2. **Checks model provider** → Bedrock is not Anthropic
3. **Extracts SKILL.md** from stored ZIP file
4. **Converts skill to tool** + **Injects content into system prompt**
### Step 3: Transformed Request
```python
{
"model": "bedrock/claude-3-sonnet",
"messages": [
{
"role": "system",
"content": """
---
# Available Skills
## Skill: Data Analysis Skill
# Data Analysis Skill
This skill helps with data analysis tasks...
## Usage
When the user asks about data analysis...
"""
},
{"role": "user", "content": "Help me analyze data"}
],
"tools": [
{
"type": "function",
"function": {
"name": "skill_abc123",
"description": "This skill helps with data analysis tasks...",
"parameters": {"type": "object", "properties": {}, "required": []}
}
}
]
# container is removed for non-Anthropic providers
}
```
## Database Schema
Skills are stored in `LiteLLM_SkillsTable`:
```prisma
model LiteLLM_SkillsTable {
skill_id String @id @default(uuid())
display_title String?
description String?
instructions String?
source String @default("custom")
latest_version String?
metadata Json? @default("{}")
file_content Bytes? // ZIP file binary content
file_name String? // Original filename
file_type String? // MIME type
created_at DateTime @default(now())
created_by String?
updated_at DateTime @default(now()) @updatedAt
updated_by String?
}
```
## Routing Summary
| Scenario | custom_llm_provider | skill_id Format | Behavior |
|----------|---------------------|-----------------|----------|
| Create skill on Anthropic | `anthropic` | N/A | Forward to Anthropic API |
| Create skill in LiteLLM DB | `litellm_proxy` | N/A | Store in database |
| Use Anthropic native skill | N/A | `skill_xyz` | Pass to Anthropic container.skills |
| Use LiteLLM skill on Anthropic | N/A | `litellm:skill_abc` | Convert to tools |
| Use LiteLLM skill on Bedrock/OpenAI | N/A | `litellm:skill_abc` | Convert to tools + inject SKILL.md |
## Testing
Run the tests:
```bash
pytest tests/proxy_unit_tests/test_skills_db.py -v
```
Tests cover:
- Creating skills with file content
- Listing and retrieving skills
- Deleting skills
- Hook resolution with ZIP file extraction
- System prompt injection for non-Anthropic models

View File

@@ -0,0 +1,54 @@
"""
LiteLLM Proxy Skills - Database-backed skills storage and execution
This module provides:
- Database-backed skills storage (alternative to Anthropic's cloud-based skills API)
- Skill content extraction and prompt injection
- Sandboxed code execution for skills
- Automatic code execution handler
Main components:
- handler.py: LiteLLMSkillsHandler - database CRUD operations
- transformation.py: LiteLLMSkillsTransformationHandler - SDK transformation layer
- prompt_injection.py: SkillPromptInjectionHandler - SKILL.md extraction and injection
- sandbox_executor.py: SkillsSandboxExecutor - Docker sandbox execution
- code_execution.py: CodeExecutionHandler - automatic agentic loop
"""
from litellm.llms.litellm_proxy.skills.code_execution import (
LITELLM_CODE_EXECUTION_TOOL,
CodeExecutionHandler,
LiteLLMInternalTools,
add_code_execution_tool,
code_execution_handler,
get_litellm_code_execution_tool,
has_code_execution_tool,
)
from litellm.llms.litellm_proxy.skills.constants import (
DEFAULT_MAX_ITERATIONS,
DEFAULT_SANDBOX_TIMEOUT,
)
from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler
from litellm.llms.litellm_proxy.skills.prompt_injection import (
SkillPromptInjectionHandler,
)
from litellm.llms.litellm_proxy.skills.sandbox_executor import SkillsSandboxExecutor
from litellm.llms.litellm_proxy.skills.transformation import (
LiteLLMSkillsTransformationHandler,
)
__all__ = [
"LiteLLMSkillsHandler",
"LiteLLMSkillsTransformationHandler",
"SkillPromptInjectionHandler",
"SkillsSandboxExecutor",
"CodeExecutionHandler",
"LiteLLMInternalTools",
"LITELLM_CODE_EXECUTION_TOOL",
"get_litellm_code_execution_tool",
"code_execution_handler",
"has_code_execution_tool",
"add_code_execution_tool",
"DEFAULT_MAX_ITERATIONS",
"DEFAULT_SANDBOX_TIMEOUT",
]

View File

@@ -0,0 +1,317 @@
"""
Automatic Code Execution Handler for LiteLLM Skills
When `litellm_code_execution` tool is present, this handler automatically:
1. Makes the LLM call
2. Executes any code the model generates
3. Continues the conversation with results
4. Returns final response with generated files inline (base64)
This mimics Anthropic's behavior where code execution happens automatically.
Generated files are returned directly in the response - no separate storage needed.
"""
import base64
import json
from enum import Enum
from typing import Any, Dict, List, Optional
from litellm._logging import verbose_logger
class LiteLLMInternalTools(str, Enum):
"""
Enum for internal LiteLLM tools that are injected into requests.
These tools are handled automatically by LiteLLM hooks and are not
passed to the underlying LLM provider directly.
"""
CODE_EXECUTION = "litellm_code_execution"
def get_litellm_code_execution_tool() -> Dict[str, Any]:
"""
Returns the litellm_code_execution tool definition in OpenAI format.
This tool enables automatic code execution in a sandboxed environment
when skills include executable Python code.
"""
return {
"type": "function",
"function": {
"name": LiteLLMInternalTools.CODE_EXECUTION.value,
"description": "Execute Python code in a sandboxed environment. Use this to run code that generates files, processes data, or performs computations. Generated files will be returned directly.",
"parameters": {
"type": "object",
"properties": {
"code": {"type": "string", "description": "Python code to execute"}
},
"required": ["code"],
},
},
}
def get_litellm_code_execution_tool_anthropic() -> Dict[str, Any]:
"""
Returns the litellm_code_execution tool definition in Anthropic/messages API format.
This tool enables automatic code execution in a sandboxed environment
when skills include executable Python code.
"""
return {
"name": LiteLLMInternalTools.CODE_EXECUTION.value,
"description": "Execute Python code in a sandboxed environment. Use this to run code that generates files, processes data, or performs computations. Generated files will be returned directly.",
"input_schema": {
"type": "object",
"properties": {
"code": {"type": "string", "description": "Python code to execute"}
},
"required": ["code"],
},
}
# Singleton tool definition for backwards compatibility
LITELLM_CODE_EXECUTION_TOOL = get_litellm_code_execution_tool()
class CodeExecutionHandler:
"""
Handles automatic code execution for LiteLLM skills.
When enabled, this handler intercepts LLM responses with code execution
tool calls, executes them in a sandbox, and continues the conversation
automatically until completion.
"""
def __init__(
self,
max_iterations: Optional[int] = None,
sandbox_timeout: Optional[int] = None,
):
from litellm.llms.litellm_proxy.skills.constants import (
DEFAULT_MAX_ITERATIONS,
DEFAULT_SANDBOX_TIMEOUT,
)
self.max_iterations = max_iterations or DEFAULT_MAX_ITERATIONS
self.sandbox_timeout = sandbox_timeout or DEFAULT_SANDBOX_TIMEOUT
async def execute_with_code_execution(
self,
model: str,
messages: List[Dict],
tools: List[Dict],
skill_files: Dict[str, bytes],
skill_id: Optional[str] = None,
**kwargs,
) -> Dict[str, Any]:
"""
Execute an LLM call with automatic code execution handling.
This method:
1. Makes the initial LLM call
2. If model calls litellm_code_execution, executes the code
3. Continues conversation with results
4. Repeats until model stops calling tools
5. Returns final response with generated files inline
Args:
model: Model to use
messages: Initial messages
tools: Tools including litellm_code_execution
skill_files: Dict of skill files for execution
skill_id: Optional skill ID for tracking
**kwargs: Additional args for litellm.acompletion
Returns:
Dict with:
- response: Final LLM response
- files: List of generated files with content (base64)
- execution_results: List of code execution results
"""
import litellm
from litellm.llms.litellm_proxy.skills.sandbox_executor import (
SkillsSandboxExecutor,
)
current_messages = list(messages)
generated_files: List[Dict[str, Any]] = [] # Files returned directly
execution_results: List[Dict] = []
executor = SkillsSandboxExecutor(timeout=self.sandbox_timeout)
response: Any = None # Initialize to avoid possibly unbound error
for iteration in range(self.max_iterations):
verbose_logger.debug(
f"CodeExecutionHandler: Iteration {iteration + 1}/{self.max_iterations}"
)
# Make LLM call
response = await litellm.acompletion(
model=model,
messages=current_messages,
tools=tools,
**kwargs,
)
assistant_message = response.choices[0].message # type: ignore
stop_reason = response.choices[0].finish_reason # type: ignore
# Build assistant message for conversation history
assistant_msg_dict: Dict[str, Any] = {
"role": "assistant",
"content": assistant_message.content,
}
if assistant_message.tool_calls:
assistant_msg_dict["tool_calls"] = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in assistant_message.tool_calls
]
current_messages.append(assistant_msg_dict)
# Check if we're done (no tool calls or not tool_calls finish reason)
if stop_reason != "tool_calls" or not assistant_message.tool_calls:
verbose_logger.debug(
f"CodeExecutionHandler: Completed after {iteration + 1} iterations"
)
return {
"response": response,
"files": generated_files, # Files returned directly with base64 content
"execution_results": execution_results,
"messages": current_messages,
}
# Handle tool calls
for tool_call in assistant_message.tool_calls:
tool_name = tool_call.function.name
if tool_name == LiteLLMInternalTools.CODE_EXECUTION.value:
# Execute code in sandbox
try:
args = json.loads(tool_call.function.arguments)
code = args.get("code", "")
verbose_logger.debug(
f"CodeExecutionHandler: Executing code ({len(code)} chars)"
)
exec_result = executor.execute(
code=code,
skill_files=skill_files,
)
verbose_logger.debug(
f"CodeExecutionHandler: Execution result: {exec_result}"
)
execution_results.append(
{
"iteration": iteration,
"success": exec_result["success"],
"output": exec_result["output"],
"error": exec_result["error"],
"files": [f["name"] for f in exec_result["files"]],
}
)
# Build tool result content
tool_result = exec_result["output"] or ""
# Collect generated files (returned directly, no storage)
if exec_result["files"]:
tool_result += "\n\nGenerated files:"
for f in exec_result["files"]:
file_content = base64.b64decode(f["content_base64"])
# Add to generated files list (returned in response)
generated_files.append(
{
"name": f["name"],
"mime_type": f["mime_type"],
"content_base64": f["content_base64"],
"size": len(file_content),
}
)
tool_result += (
f"\n- {f['name']} ({len(file_content)} bytes)"
)
verbose_logger.debug(
f"CodeExecutionHandler: Generated file {f['name']} ({len(file_content)} bytes)"
)
if exec_result["error"]:
tool_result += f"\n\nError:\n{exec_result['error']}"
except Exception as e:
tool_result = f"Code execution failed: {str(e)}"
execution_results.append(
{
"iteration": iteration,
"success": False,
"error": str(e),
}
)
# Add tool result to messages
current_messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": tool_result,
}
)
else:
# Non-code-execution tool - pass through
# In a full implementation, this would call other tool handlers
current_messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": f"Tool '{tool_name}' not handled by code execution handler",
}
)
# Max iterations reached
verbose_logger.warning(
f"CodeExecutionHandler: Max iterations ({self.max_iterations}) reached"
)
return {
"response": response,
"files": generated_files,
"execution_results": execution_results,
"messages": current_messages,
"max_iterations_reached": True,
}
def has_code_execution_tool(tools: Optional[List[Dict]]) -> bool:
"""Check if litellm_code_execution tool is in the tools list."""
if not tools:
return False
for tool in tools:
func = tool.get("function", {})
if func.get("name") == LiteLLMInternalTools.CODE_EXECUTION.value:
return True
return False
def add_code_execution_tool(tools: Optional[List[Dict]]) -> List[Dict]:
"""Add litellm_code_execution tool if not already present."""
tools = tools or []
if not has_code_execution_tool(tools):
tools.append(LITELLM_CODE_EXECUTION_TOOL)
return tools
# Global handler instance
code_execution_handler = CodeExecutionHandler()

View File

@@ -0,0 +1,12 @@
"""
Constants for LiteLLM Skills
Centralized constants for skills processing, code execution, and sandbox configuration.
"""
# Code execution loop settings
DEFAULT_MAX_ITERATIONS: int = 10
"""Maximum number of iterations for the automatic code execution loop."""
DEFAULT_SANDBOX_TIMEOUT: int = 120
"""Default timeout in seconds for sandbox code execution."""

View File

@@ -0,0 +1,219 @@
"""
Handler for LiteLLM database-backed skills operations.
This module contains the actual database operations for skills CRUD.
Used by the transformation layer and skills injection hook.
"""
import uuid
from typing import Any, Dict, List, Optional
from litellm._logging import verbose_logger
from litellm.proxy._types import LiteLLM_SkillsTable, NewSkillRequest
def _prisma_skill_to_litellm(prisma_skill) -> LiteLLM_SkillsTable:
"""
Convert a Prisma skill record to LiteLLM_SkillsTable.
Handles Base64 decoding of file_content field.
"""
import base64
data = prisma_skill.model_dump()
# Decode Base64 file_content back to bytes
# model_dump() converts Base64 field to base64-encoded string
if data.get("file_content") is not None:
if isinstance(data["file_content"], str):
data["file_content"] = base64.b64decode(data["file_content"])
elif isinstance(data["file_content"], bytes):
# Already bytes, no conversion needed
pass
return LiteLLM_SkillsTable(**data)
class LiteLLMSkillsHandler:
"""
Handler for LiteLLM database-backed skills operations.
This class provides static methods for CRUD operations on skills
stored in the LiteLLM proxy database (LiteLLM_SkillsTable).
"""
@staticmethod
async def _get_prisma_client():
"""Get the prisma client from proxy server."""
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise ValueError(
"Prisma client is not initialized. "
"Database connection required for LiteLLM skills."
)
return prisma_client
@staticmethod
async def create_skill(
data: NewSkillRequest,
user_id: Optional[str] = None,
) -> LiteLLM_SkillsTable:
"""
Create a new skill in the LiteLLM database.
Args:
data: NewSkillRequest with skill details
user_id: Optional user ID for tracking
Returns:
LiteLLM_SkillsTable record
"""
prisma_client = await LiteLLMSkillsHandler._get_prisma_client()
skill_id = f"litellm_skill_{uuid.uuid4()}"
skill_data: Dict[str, Any] = {
"skill_id": skill_id,
"display_title": data.display_title,
"description": data.description,
"instructions": data.instructions,
"source": "custom",
"created_by": user_id,
"updated_by": user_id,
}
# Handle metadata
if data.metadata is not None:
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
skill_data["metadata"] = safe_dumps(data.metadata)
# Handle file content - wrap bytes in Base64 for Prisma
if data.file_content is not None:
from prisma.fields import Base64
skill_data["file_content"] = Base64.encode(data.file_content)
if data.file_name is not None:
skill_data["file_name"] = data.file_name
if data.file_type is not None:
skill_data["file_type"] = data.file_type
verbose_logger.debug(
f"LiteLLMSkillsHandler: Creating skill {skill_id} with title={data.display_title}"
)
new_skill = await prisma_client.db.litellm_skillstable.create(data=skill_data)
return _prisma_skill_to_litellm(new_skill)
@staticmethod
async def list_skills(
limit: int = 20,
offset: int = 0,
) -> List[LiteLLM_SkillsTable]:
"""
List skills from the LiteLLM database.
Args:
limit: Maximum number of skills to return
offset: Number of skills to skip
Returns:
List of LiteLLM_SkillsTable records
"""
prisma_client = await LiteLLMSkillsHandler._get_prisma_client()
verbose_logger.debug(
f"LiteLLMSkillsHandler: Listing skills with limit={limit}, offset={offset}"
)
skills = await prisma_client.db.litellm_skillstable.find_many(
take=limit,
skip=offset,
order={"created_at": "desc"},
)
return [_prisma_skill_to_litellm(s) for s in skills]
@staticmethod
async def get_skill(skill_id: str) -> LiteLLM_SkillsTable:
"""
Get a skill by ID from the LiteLLM database.
Args:
skill_id: The skill ID to retrieve
Returns:
LiteLLM_SkillsTable record
Raises:
ValueError: If skill not found
"""
prisma_client = await LiteLLMSkillsHandler._get_prisma_client()
verbose_logger.debug(f"LiteLLMSkillsHandler: Getting skill {skill_id}")
skill = await prisma_client.db.litellm_skillstable.find_unique(
where={"skill_id": skill_id}
)
if skill is None:
raise ValueError(f"Skill not found: {skill_id}")
return _prisma_skill_to_litellm(skill)
@staticmethod
async def delete_skill(skill_id: str) -> Dict[str, str]:
"""
Delete a skill by ID from the LiteLLM database.
Args:
skill_id: The skill ID to delete
Returns:
Dict with id and type of deleted skill
Raises:
ValueError: If skill not found
"""
prisma_client = await LiteLLMSkillsHandler._get_prisma_client()
verbose_logger.debug(f"LiteLLMSkillsHandler: Deleting skill {skill_id}")
# Check if skill exists
skill = await prisma_client.db.litellm_skillstable.find_unique(
where={"skill_id": skill_id}
)
if skill is None:
raise ValueError(f"Skill not found: {skill_id}")
# Delete the skill
await prisma_client.db.litellm_skillstable.delete(where={"skill_id": skill_id})
return {"id": skill_id, "type": "skill_deleted"}
@staticmethod
async def fetch_skill_from_db(skill_id: str) -> Optional[LiteLLM_SkillsTable]:
"""
Fetch a skill from the database (used by skills injection hook).
This is a convenience method that returns None instead of raising
an exception if the skill is not found.
Args:
skill_id: The skill ID to fetch
Returns:
LiteLLM_SkillsTable or None if not found
"""
try:
return await LiteLLMSkillsHandler.get_skill(skill_id)
except ValueError:
return None
except Exception as e:
verbose_logger.warning(
f"LiteLLMSkillsHandler: Error fetching skill {skill_id}: {e}"
)
return None

View File

@@ -0,0 +1,308 @@
"""
Prompt Injection Handler for LiteLLM Skills
Handles extraction of skill content (SKILL.md) from stored ZIP files
and injection into the system prompt for non-Anthropic models.
"""
import zipfile
from io import BytesIO
from typing import Any, Dict, List, Optional
from litellm._logging import verbose_logger
from litellm.proxy._types import LiteLLM_SkillsTable
class SkillPromptInjectionHandler:
"""
Handles skill content extraction and system prompt injection.
Responsibilities:
- Extract SKILL.md content from skill ZIP files
- Extract ALL files from ZIP for code execution
- Inject skill content into system message
- Create execute_code tool definition
"""
def extract_skill_content(self, skill: LiteLLM_SkillsTable) -> Optional[str]:
"""
Extract skill content from the stored zip file.
Looks for SKILL.md or README.md in the zip and returns its content.
This content describes the skill's capabilities and instructions.
Args:
skill: The skill from LiteLLM database
Returns:
The skill content as a string, or None if not available
"""
if not skill.file_content:
return skill.instructions
try:
zip_buffer = BytesIO(skill.file_content)
with zipfile.ZipFile(zip_buffer, "r") as zf:
# Look for SKILL.md first
for name in zf.namelist():
if name.endswith("SKILL.md"):
content = zf.read(name).decode("utf-8")
if content:
return f"## Skill: {skill.display_title or skill.skill_id}\n\n{content}"
# Fall back to README.md
for name in zf.namelist():
if name.endswith("README.md"):
content = zf.read(name).decode("utf-8")
if content:
return f"## Skill: {skill.display_title or skill.skill_id}\n\n{content}"
# Fall back to any .md file
for name in zf.namelist():
if name.endswith(".md"):
content = zf.read(name).decode("utf-8")
if content:
return f"## Skill: {skill.display_title or skill.skill_id}\n\n{content}"
except Exception as e:
verbose_logger.warning(
f"SkillPromptInjectionHandler: Error extracting content from skill {skill.skill_id}: {e}"
)
return skill.instructions
def extract_all_files(self, skill: LiteLLM_SkillsTable) -> Dict[str, bytes]:
"""
Extract ALL files from skill ZIP for code execution.
Returns a dict mapping file paths to their binary content.
The paths have the skill folder prefix removed (e.g., "slack-gif-creator/core/..." -> "core/...").
Args:
skill: The skill from LiteLLM database
Returns:
Dict mapping file paths to binary content
"""
files: Dict[str, bytes] = {}
if not skill.file_content:
return files
try:
zip_buffer = BytesIO(skill.file_content)
with zipfile.ZipFile(zip_buffer, "r") as zf:
for name in zf.namelist():
# Skip directories
if name.endswith("/"):
continue
# Remove skill folder prefix (first path component)
parts = name.split("/")
if len(parts) > 1:
clean_path = "/".join(parts[1:])
else:
clean_path = name
if clean_path:
files[clean_path] = zf.read(name)
except Exception as e:
verbose_logger.warning(
f"SkillPromptInjectionHandler: Error extracting files from skill {skill.skill_id}: {e}"
)
return files
def inject_skill_content_to_messages(
self, data: dict, skill_contents: List[str], use_anthropic_format: bool = False
) -> dict:
"""
Inject skill content into the system prompt.
For Anthropic messages API (use_anthropic_format=True):
- Injects into top-level 'system' parameter (not in messages array)
For OpenAI-style APIs (use_anthropic_format=False):
- Injects into messages array with role="system"
Args:
data: The request data dict
skill_contents: List of skill content strings to inject
use_anthropic_format: If True, use top-level 'system' param for Anthropic
Returns:
Modified data dict with skill content in system prompt
"""
if not skill_contents:
return data
# Build the skill injection text
skill_section = "\n\n---\n\n# Available Skills\n\n" + "\n\n---\n\n".join(
skill_contents
)
if use_anthropic_format:
# Anthropic messages API: use top-level 'system' parameter
current_system = data.get("system", "")
if current_system:
data["system"] = current_system + skill_section
else:
data["system"] = skill_section.strip()
return data
# OpenAI-style: inject into messages array
messages = data.get("messages", [])
if not messages:
return data
# Find or create system message
system_msg_idx = None
for i, msg in enumerate(messages):
if isinstance(msg, dict) and msg.get("role") == "system":
system_msg_idx = i
break
if system_msg_idx is not None:
# Append to existing system message
current_content = messages[system_msg_idx].get("content", "")
messages[system_msg_idx]["content"] = current_content + skill_section
else:
# Create new system message at the beginning
messages.insert(0, {"role": "system", "content": skill_section.strip()})
data["messages"] = messages
return data
def create_execute_code_tool(self, skill_modules: List[str]) -> Dict[str, Any]:
"""
Create the execute_code tool definition.
This tool allows the model to execute Python code with access
to the skill's modules (e.g., 'from core.gif_builder import GIFBuilder').
Args:
skill_modules: List of available module paths (e.g., ["core/gif_builder.py"])
Returns:
OpenAI-style tool definition
"""
# Format module list for description
module_examples = []
for mod in skill_modules[:5]: # Limit to 5 examples
if mod.endswith(".py"):
# Convert path to import: "core/gif_builder.py" -> "from core.gif_builder import ..."
import_path = mod.replace("/", ".").replace(".py", "")
module_examples.append(f"from {import_path} import ...")
module_hint = ""
if module_examples:
module_hint = f" Available modules: {', '.join(module_examples)}"
return {
"type": "function",
"function": {
"name": "execute_code",
"description": f"Execute Python code in a sandboxed environment. Generated files will be returned.{module_hint}",
"parameters": {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "Python code to execute. You can import skill modules and use standard libraries.",
}
},
"required": ["code"],
},
},
}
def convert_skill_to_tool(self, skill: LiteLLM_SkillsTable) -> Dict[str, Any]:
"""
Convert a LiteLLM skill to an OpenAI-style tool.
The skill's instructions are used as the function description,
allowing the model to understand when and how to use the skill.
Args:
skill: The skill from LiteLLM database
Returns:
OpenAI-style tool definition
"""
# Create a function name from skill_id (sanitize for function naming)
func_name = skill.skill_id.replace("-", "_").replace(" ", "_")
# Use instructions as description, fall back to description or title
description = (
skill.instructions
or skill.description
or skill.display_title
or f"Skill: {skill.skill_id}"
)
# Truncate description if too long (OpenAI has limits)
max_desc_length = 1024
if len(description) > max_desc_length:
description = description[: max_desc_length - 3] + "..."
tool: Dict[str, Any] = {
"type": "function",
"function": {
"name": func_name,
"description": description,
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
},
}
# If skill has metadata with parameter definitions, use them
if skill.metadata and isinstance(skill.metadata, dict):
params = skill.metadata.get("parameters")
if params and isinstance(params, dict):
tool["function"]["parameters"] = params
return tool
def convert_skill_to_anthropic_tool(
self, skill: LiteLLM_SkillsTable
) -> Dict[str, Any]:
"""
Convert a LiteLLM skill to an Anthropic-style tool (messages API format).
Args:
skill: The skill from LiteLLM database
Returns:
Anthropic-style tool definition with name, description, input_schema
"""
func_name = skill.skill_id.replace("-", "_").replace(" ", "_")
description = (
skill.instructions
or skill.description
or skill.display_title
or f"Skill: {skill.skill_id}"
)
max_desc_length = 1024
if len(description) > max_desc_length:
description = description[: max_desc_length - 3] + "..."
input_schema: Dict[str, Any] = {
"type": "object",
"properties": {},
"required": [],
}
if skill.metadata and isinstance(skill.metadata, dict):
params = skill.metadata.get("parameters")
if params and isinstance(params, dict):
input_schema = params
return {
"name": func_name,
"description": description,
"input_schema": input_schema,
}

View File

@@ -0,0 +1,286 @@
"""
Sandbox Executor for LiteLLM Skills
Executes skill code in a sandboxed environment using llm-sandbox.
Supports Docker, Podman, and Kubernetes backends.
"""
import base64
import os
from typing import Any, Dict, List, Optional
from litellm._logging import verbose_logger
class SkillsSandboxExecutor:
"""
Executes skill code in llm-sandbox Docker container.
Responsibilities:
- Create sandbox session with skill files
- Install requirements
- Execute model-generated code
- Collect generated files (GIFs, images, etc.)
"""
def __init__(
self,
timeout: int = 60,
backend: str = "docker",
image: Optional[str] = None,
):
"""
Initialize the sandbox executor.
Args:
timeout: Maximum execution time in seconds
backend: Sandbox backend ("docker", "podman", "kubernetes")
image: Custom Docker image (default: uses llm-sandbox default)
"""
self.timeout = timeout
self.backend = backend
self.image = image
self._session = None
def execute(
self,
code: str,
skill_files: Dict[str, bytes],
requirements: Optional[str] = None,
) -> Dict[str, Any]:
"""
Execute code with skill files in sandbox.
Args:
code: Python code to execute
skill_files: Dict mapping file paths to binary content
requirements: Optional requirements.txt content
Returns:
{
"success": bool,
"output": str,
"error": str (if failed),
"files": [{"name": str, "content_base64": str, "mime_type": str}]
}
"""
try:
from llm_sandbox import SandboxSession
except ImportError:
verbose_logger.error(
"SkillsSandboxExecutor: llm-sandbox not installed. "
"Install with: pip install llm-sandbox"
)
return {
"success": False,
"output": "",
"error": "llm-sandbox not installed. Install with: pip install llm-sandbox",
"files": [],
}
try:
# Create sandbox session
session_kwargs: Dict[str, Any] = {
"lang": "python",
"verbose": False,
}
if self.image:
session_kwargs["image"] = self.image
with SandboxSession(**session_kwargs) as session:
# 1. Copy skill files into sandbox using copy_to_runtime
import tempfile
# Create a temp directory to stage files
with tempfile.TemporaryDirectory() as tmpdir:
for path, content in skill_files.items():
# Create the file in temp directory
local_path = os.path.join(tmpdir, path)
os.makedirs(os.path.dirname(local_path), exist_ok=True)
with open(local_path, "wb") as f:
f.write(content)
# Copy to sandbox
sandbox_path = f"/sandbox/{path}"
session.copy_to_runtime(local_path, sandbox_path)
verbose_logger.debug(
f"SkillsSandboxExecutor: Copied {len(skill_files)} files to sandbox"
)
# 2. Install requirements if present
req_packages = None
if requirements:
req_packages = requirements.strip().replace("\n", " ")
elif "requirements.txt" in skill_files:
req_content = skill_files["requirements.txt"].decode("utf-8")
req_packages = req_content.strip().replace("\n", " ")
if req_packages:
# Run pip install as code
pip_code = f"""
import subprocess
subprocess.run(['pip', 'install'] + '{req_packages}'.split(), check=True)
"""
result = session.run(pip_code)
verbose_logger.debug(
"SkillsSandboxExecutor: Installed requirements"
)
# 3. Execute the code
# Wrap code to run from /sandbox directory
wrapped_code = f"""
import os
os.chdir('/sandbox')
import sys
sys.path.insert(0, '/sandbox')
{code}
"""
result = session.run(wrapped_code)
success = result.exit_code == 0
output = result.stdout or ""
error = result.stderr or ""
if success:
verbose_logger.debug(
"SkillsSandboxExecutor: Code execution succeeded"
)
else:
verbose_logger.debug(
f"SkillsSandboxExecutor: Code execution failed with exit code {result.exit_code}"
)
verbose_logger.debug(
f"SkillsSandboxExecutor: stderr: {error[:500] if error else 'No stderr'}"
)
verbose_logger.debug(
f"SkillsSandboxExecutor: stdout: {output[:500] if output else 'No stdout'}"
)
# 4. Collect generated files
generated_files = self._collect_generated_files(session, skill_files)
return {
"success": success,
"output": output,
"error": error,
"files": generated_files,
}
except Exception as e:
verbose_logger.error(f"SkillsSandboxExecutor: Execution failed: {e}")
return {
"success": False,
"output": "",
"error": str(e),
"files": [],
}
def _collect_generated_files(
self,
session: Any,
original_files: Dict[str, bytes],
) -> List[Dict[str, Any]]:
"""
Collect files generated during execution.
Looks for new files in /sandbox that weren't in the original skill files.
Focuses on common output types: GIF, PNG, JPG, PDF, CSV, etc.
Args:
session: The sandbox session
original_files: Original skill files (to exclude)
Returns:
List of generated files with base64 content
"""
generated_files: List[Dict[str, Any]] = []
try:
import tempfile
# List files in /sandbox using Python code
list_code = """
import os
import json
files = []
for root, dirs, filenames in os.walk('/sandbox'):
for f in filenames:
if f.endswith(('.gif', '.png', '.jpg', '.jpeg', '.pdf', '.csv', '.json')):
files.append(os.path.join(root, f))
print(json.dumps(files))
"""
result = session.run(list_code)
if result.exit_code == 0 and result.stdout:
import json
try:
filepaths = json.loads(result.stdout.strip())
except json.JSONDecodeError:
filepaths = []
for filepath in filepaths:
if not filepath:
continue
# Get relative path
rel_path = filepath.replace("/sandbox/", "")
# Skip if it was an original file
if rel_path in original_files:
continue
# Copy file from sandbox using copy_from_runtime
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp_path = tmp.name
try:
session.copy_from_runtime(filepath, tmp_path)
with open(tmp_path, "rb") as f:
content = f.read()
content_b64 = base64.b64encode(content).decode("utf-8")
generated_files.append(
{
"name": os.path.basename(filepath),
"path": rel_path,
"content_base64": content_b64,
"mime_type": self._get_mime_type(filepath),
}
)
verbose_logger.debug(
f"SkillsSandboxExecutor: Collected generated file: {rel_path}"
)
except Exception as e:
verbose_logger.warning(
f"SkillsSandboxExecutor: Error copying file {filepath}: {e}"
)
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
except Exception as e:
verbose_logger.warning(
f"SkillsSandboxExecutor: Error collecting generated files: {e}"
)
return generated_files
def _get_mime_type(self, filename: str) -> str:
"""Get MIME type for a file based on extension."""
ext = filename.lower().split(".")[-1]
return {
"gif": "image/gif",
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"pdf": "application/pdf",
"csv": "text/csv",
"json": "application/json",
"txt": "text/plain",
}.get(ext, "application/octet-stream")

View File

@@ -0,0 +1,341 @@
"""
Transformation handler for LiteLLM database-backed skills.
This module provides the SDK-level transformation layer that converts
API requests to database operations via LiteLLMSkillsHandler.
Pattern follows litellm/llms/litellm_proxy/responses/transformation.py
"""
from typing import TYPE_CHECKING, Any, Coroutine, Dict, List, Optional, Union
from litellm.types.llms.anthropic_skills import (
DeleteSkillResponse,
ListSkillsResponse,
Skill,
)
from litellm.types.utils import LlmProviders
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
class LiteLLMSkillsTransformationHandler:
"""
Transformation handler for skills API requests to LiteLLM database operations.
This is used when custom_llm_provider="litellm_proxy" to store/retrieve skills
from the LiteLLM proxy database instead of calling an external API.
"""
@property
def custom_llm_provider(self) -> str:
"""Return the provider name for logging."""
return LlmProviders.LITELLM_PROXY.value
def create_skill_handler(
self,
display_title: Optional[str] = None,
description: Optional[str] = None,
instructions: Optional[str] = None,
files: Optional[List[Any]] = None,
file_content: Optional[bytes] = None,
file_name: Optional[str] = None,
file_type: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
user_id: Optional[str] = None,
_is_async: bool = False,
logging_obj: Optional["LiteLLMLoggingObj"] = None,
litellm_call_id: Optional[str] = None,
**kwargs,
) -> Union[Skill, Coroutine[Any, Any, Skill]]:
"""
Create a skill in LiteLLM database.
Args:
display_title: Display title for the skill
description: Description of the skill
instructions: Instructions/prompt for the skill
files: Files to upload - list of tuples (filename, content, content_type)
file_content: Binary content of skill files (alternative to files)
file_name: Original filename (alternative to files)
file_type: MIME type (alternative to files)
metadata: Additional metadata
user_id: User ID for tracking
_is_async: Whether to return a coroutine
Returns:
Skill object or coroutine that returns Skill
"""
# Pre-call logging
if logging_obj:
logging_obj.update_environment_variables(
model=None,
optional_params={"display_title": display_title},
litellm_params={"litellm_call_id": litellm_call_id},
custom_llm_provider=self.custom_llm_provider,
)
# Extract file content from files parameter if provided
# files is a list of tuples: [(filename, content, content_type), ...]
if files and not file_content:
if isinstance(files, list) and len(files) > 0:
first_file = files[0]
if isinstance(first_file, tuple) and len(first_file) >= 2:
file_name = first_file[0]
file_content = first_file[1]
file_type = (
first_file[2] if len(first_file) > 2 else "application/zip"
)
if _is_async:
return self._async_create_skill(
display_title=display_title,
description=description,
instructions=instructions,
file_content=file_content,
file_name=file_name,
file_type=file_type,
metadata=metadata,
user_id=user_id,
)
import asyncio
return asyncio.get_event_loop().run_until_complete(
self._async_create_skill(
display_title=display_title,
description=description,
instructions=instructions,
file_content=file_content,
file_name=file_name,
file_type=file_type,
metadata=metadata,
user_id=user_id,
)
)
async def _async_create_skill(
self,
display_title: Optional[str] = None,
description: Optional[str] = None,
instructions: Optional[str] = None,
file_content: Optional[bytes] = None,
file_name: Optional[str] = None,
file_type: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
user_id: Optional[str] = None,
) -> Skill:
"""Async implementation of create_skill."""
# Lazy import to avoid SDK dependency on proxy
from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler
from litellm.proxy._types import NewSkillRequest
skill_request = NewSkillRequest(
display_title=display_title,
description=description,
instructions=instructions,
file_content=file_content,
file_name=file_name,
file_type=file_type,
metadata=metadata,
)
db_skill = await LiteLLMSkillsHandler.create_skill(
data=skill_request,
user_id=user_id,
)
return self._db_skill_to_response(db_skill)
def list_skills_handler(
self,
limit: int = 20,
offset: int = 0,
_is_async: bool = False,
logging_obj: Optional["LiteLLMLoggingObj"] = None,
litellm_call_id: Optional[str] = None,
**kwargs,
) -> Union[ListSkillsResponse, Coroutine[Any, Any, ListSkillsResponse]]:
"""
List skills from LiteLLM database.
Args:
limit: Maximum number of skills to return
offset: Number of skills to skip
_is_async: Whether to return a coroutine
logging_obj: LiteLLM logging object
litellm_call_id: Call ID for logging
Returns:
ListSkillsResponse or coroutine that returns ListSkillsResponse
"""
# Pre-call logging
if logging_obj:
logging_obj.update_environment_variables(
model=None,
optional_params={"limit": limit, "offset": offset},
litellm_params={"litellm_call_id": litellm_call_id},
custom_llm_provider=self.custom_llm_provider,
)
if _is_async:
return self._async_list_skills(limit=limit, offset=offset)
import asyncio
return asyncio.get_event_loop().run_until_complete(
self._async_list_skills(limit=limit, offset=offset)
)
async def _async_list_skills(
self,
limit: int = 20,
offset: int = 0,
) -> ListSkillsResponse:
"""Async implementation of list_skills."""
# Lazy import to avoid SDK dependency on proxy
from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler
db_skills = await LiteLLMSkillsHandler.list_skills(
limit=limit,
offset=offset,
)
skills = [self._db_skill_to_response(s) for s in db_skills]
return ListSkillsResponse(
data=skills,
has_more=len(skills) >= limit,
next_page=None,
)
def get_skill_handler(
self,
skill_id: str,
_is_async: bool = False,
logging_obj: Optional["LiteLLMLoggingObj"] = None,
litellm_call_id: Optional[str] = None,
**kwargs,
) -> Union[Skill, Coroutine[Any, Any, Skill]]:
"""
Get a skill from LiteLLM database.
Args:
skill_id: The skill ID to retrieve
_is_async: Whether to return a coroutine
logging_obj: LiteLLM logging object
litellm_call_id: Call ID for logging
Returns:
Skill or coroutine that returns Skill
"""
# Pre-call logging
if logging_obj:
logging_obj.update_environment_variables(
model=None,
optional_params={"skill_id": skill_id},
litellm_params={"litellm_call_id": litellm_call_id},
custom_llm_provider=self.custom_llm_provider,
)
if _is_async:
return self._async_get_skill(skill_id=skill_id)
import asyncio
return asyncio.get_event_loop().run_until_complete(
self._async_get_skill(skill_id=skill_id)
)
async def _async_get_skill(self, skill_id: str) -> Skill:
"""Async implementation of get_skill."""
# Lazy import to avoid SDK dependency on proxy
from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler
db_skill = await LiteLLMSkillsHandler.get_skill(skill_id=skill_id)
return self._db_skill_to_response(db_skill)
def delete_skill_handler(
self,
skill_id: str,
_is_async: bool = False,
logging_obj: Optional["LiteLLMLoggingObj"] = None,
litellm_call_id: Optional[str] = None,
**kwargs,
) -> Union[DeleteSkillResponse, Coroutine[Any, Any, DeleteSkillResponse]]:
"""
Delete a skill from LiteLLM database.
Args:
skill_id: The skill ID to delete
_is_async: Whether to return a coroutine
logging_obj: LiteLLM logging object
litellm_call_id: Call ID for logging
Returns:
DeleteSkillResponse or coroutine that returns DeleteSkillResponse
"""
# Pre-call logging
if logging_obj:
logging_obj.update_environment_variables(
model=None,
optional_params={"skill_id": skill_id},
litellm_params={"litellm_call_id": litellm_call_id},
custom_llm_provider=self.custom_llm_provider,
)
if _is_async:
return self._async_delete_skill(skill_id=skill_id)
import asyncio
return asyncio.get_event_loop().run_until_complete(
self._async_delete_skill(skill_id=skill_id)
)
async def _async_delete_skill(self, skill_id: str) -> DeleteSkillResponse:
"""Async implementation of delete_skill."""
# Lazy import to avoid SDK dependency on proxy
from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler
result = await LiteLLMSkillsHandler.delete_skill(skill_id=skill_id)
return DeleteSkillResponse(
id=result["id"],
type=result.get("type", "skill_deleted"),
)
def _db_skill_to_response(self, db_skill: Any) -> Skill:
"""
Convert a database skill record to Anthropic-compatible Skill response.
Args:
db_skill: LiteLLM_SkillsTable record
Returns:
Skill object
"""
created_at = ""
updated_at = ""
if hasattr(db_skill, "created_at") and db_skill.created_at:
created_at = (
db_skill.created_at.isoformat()
if hasattr(db_skill.created_at, "isoformat")
else str(db_skill.created_at)
)
if hasattr(db_skill, "updated_at") and db_skill.updated_at:
updated_at = (
db_skill.updated_at.isoformat()
if hasattr(db_skill.updated_at, "isoformat")
else str(db_skill.updated_at)
)
return Skill(
id=db_skill.skill_id,
created_at=created_at,
updated_at=updated_at,
display_title=db_skill.display_title,
latest_version=db_skill.latest_version,
source=db_skill.source or "custom",
type="skill",
)