chore: initial snapshot for gitea/github upload
This commit is contained in:
@@ -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,
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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."""
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
)
|
||||
Reference in New Issue
Block a user