diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
index fd77a86f42c..aac0b5b35de 100644
--- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
+++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
@@ -727,4 +727,22 @@ model LiteLLM_UISettings {
ui_settings Json
created_at DateTime @default(now())
updated_at DateTime @updatedAt
+}
+
+// Skills table for storing LiteLLM-managed skills
+model LiteLLM_SkillsTable {
+ skill_id String @id @default(uuid())
+ display_title String?
+ description String?
+ instructions String? // The skill instructions/prompt (from SKILL.md)
+ source String @default("custom") // "custom" or "anthropic"
+ latest_version String?
+ file_content Bytes? // Binary content of the skill files (zip)
+ file_name String? // Original filename
+ file_type String? // MIME type (e.g., "application/zip")
+ metadata Json? @default("{}")
+ created_at DateTime @default(now())
+ created_by String?
+ updated_at DateTime @default(now()) @updatedAt
+ updated_by String?
}
\ No newline at end of file
diff --git a/litellm/anthropic_interface/messages/__init__.py b/litellm/anthropic_interface/messages/__init__.py
index 16bb5f3d462..d7ff53a1763 100644
--- a/litellm/anthropic_interface/messages/__init__.py
+++ b/litellm/anthropic_interface/messages/__init__.py
@@ -37,6 +37,7 @@ async def acreate(
tools: Optional[List[Dict]] = None,
top_k: Optional[int] = None,
top_p: Optional[float] = None,
+ container: Optional[Dict] = None,
**kwargs
) -> Union[AnthropicMessagesResponse, AsyncIterator]:
"""
@@ -56,6 +57,7 @@ async def acreate(
tools (List[Dict], optional): List of tool definitions
top_k (int, optional): Top K sampling parameter
top_p (float, optional): Nucleus sampling parameter
+ container (Dict, optional): Container config with skills for code execution
**kwargs: Additional arguments
Returns:
@@ -75,6 +77,7 @@ async def acreate(
tools=tools,
top_k=top_k,
top_p=top_p,
+ container=container,
**kwargs,
)
@@ -93,6 +96,7 @@ def create(
tools: Optional[List[Dict]] = None,
top_k: Optional[int] = None,
top_p: Optional[float] = None,
+ container: Optional[Dict] = None,
**kwargs
) -> Union[
AnthropicMessagesResponse,
@@ -135,5 +139,6 @@ def create(
tools=tools,
top_k=top_k,
top_p=top_p,
+ container=container,
**kwargs,
)
diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py
index cc9334ae68b..908b46c11e2 100644
--- a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py
+++ b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py
@@ -119,6 +119,7 @@ def anthropic_messages_handler(
tools: Optional[List[Dict]] = None,
top_k: Optional[int] = None,
top_p: Optional[float] = None,
+ container: Optional[Dict] = None,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
client: Optional[AsyncHTTPHandler] = None,
@@ -131,6 +132,9 @@ def anthropic_messages_handler(
]:
"""
Makes Anthropic `/v1/messages` API calls In the Anthropic API Spec
+
+ Args:
+ container: Container config with skills for code execution
"""
from litellm.types.utils import LlmProviders
diff --git a/litellm/llms/litellm_proxy/skills/README.md b/litellm/llms/litellm_proxy/skills/README.md
new file mode 100644
index 00000000000..1dfeff1a42c
--- /dev/null
+++ b/litellm/llms/litellm_proxy/skills/README.md
@@ -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:
- display_title
- description
- instructions
- 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 +
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:
# Available Skills
## Skill: My Skill
SKILL.md content...]
+ P --> Q[Create OpenAI-style tool:
type: function
name: skill_id
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
+
diff --git a/litellm/llms/litellm_proxy/skills/__init__.py b/litellm/llms/litellm_proxy/skills/__init__.py
new file mode 100644
index 00000000000..5fb29e96bb9
--- /dev/null
+++ b/litellm/llms/litellm_proxy/skills/__init__.py
@@ -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",
+]
diff --git a/litellm/llms/litellm_proxy/skills/code_execution.py b/litellm/llms/litellm_proxy/skills/code_execution.py
new file mode 100644
index 00000000000..d307b8b36d9
--- /dev/null
+++ b/litellm/llms/litellm_proxy/skills/code_execution.py
@@ -0,0 +1,311 @@
+"""
+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()
+
diff --git a/litellm/llms/litellm_proxy/skills/constants.py b/litellm/llms/litellm_proxy/skills/constants.py
new file mode 100644
index 00000000000..a2be6961db6
--- /dev/null
+++ b/litellm/llms/litellm_proxy/skills/constants.py
@@ -0,0 +1,13 @@
+"""
+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."""
+
diff --git a/litellm/llms/litellm_proxy/skills/handler.py b/litellm/llms/litellm_proxy/skills/handler.py
new file mode 100644
index 00000000000..f44ac4cda92
--- /dev/null
+++ b/litellm/llms/litellm_proxy/skills/handler.py
@@ -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
diff --git a/litellm/llms/litellm_proxy/skills/prompt_injection.py b/litellm/llms/litellm_proxy/skills/prompt_injection.py
new file mode 100644
index 00000000000..17469274c1c
--- /dev/null
+++ b/litellm/llms/litellm_proxy/skills/prompt_injection.py
@@ -0,0 +1,305 @@
+"""
+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,
+ }
+
diff --git a/litellm/llms/litellm_proxy/skills/sandbox_executor.py b/litellm/llms/litellm_proxy/skills/sandbox_executor.py
new file mode 100644
index 00000000000..7676ade5cd0
--- /dev/null
+++ b/litellm/llms/litellm_proxy/skills/sandbox_executor.py
@@ -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")
+
diff --git a/litellm/llms/litellm_proxy/skills/transformation.py b/litellm/llms/litellm_proxy/skills/transformation.py
new file mode 100644
index 00000000000..e7c999eacec
--- /dev/null
+++ b/litellm/llms/litellm_proxy/skills/transformation.py
@@ -0,0 +1,336 @@
+"""
+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",
+ )
+
diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index 1ed52c3dd16..f56801cb2c9 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -1140,6 +1140,60 @@ class MakeMCPServersPublicRequest(LiteLLMPydanticObjectBase):
mcp_server_ids: List[str]
+######## Skills API Types ########
+
+
+class NewSkillRequest(LiteLLMPydanticObjectBase):
+ """Request to create a new skill in LiteLLM database"""
+
+ display_title: Optional[str] = None
+ description: Optional[str] = None
+ instructions: Optional[str] = None
+ file_content: Optional[bytes] = None # Binary content of skill files (zip)
+ file_name: Optional[str] = None # Original filename
+ file_type: Optional[str] = None # MIME type (e.g., "application/zip")
+ metadata: Optional[Dict[str, Any]] = None
+
+
+class UpdateSkillRequest(LiteLLMPydanticObjectBase):
+ """Request to update an existing skill"""
+
+ skill_id: str
+ display_title: Optional[str] = None
+ description: Optional[str] = None
+ instructions: Optional[str] = None
+ file_content: Optional[bytes] = None # Binary content of skill files (zip)
+ file_name: Optional[str] = None # Original filename
+ file_type: Optional[str] = None # MIME type
+ metadata: Optional[Dict[str, Any]] = None
+
+
+class LiteLLM_SkillsTable(LiteLLMPydanticObjectBase):
+ """Represents a LiteLLM_SkillsTable record"""
+
+ skill_id: str
+ display_title: Optional[str] = None
+ description: Optional[str] = None
+ instructions: Optional[str] = None
+ source: str = "custom"
+ latest_version: Optional[str] = None
+ file_content: Optional[bytes] = None # Binary content of skill files (zip)
+ file_name: Optional[str] = None # Original filename
+ file_type: Optional[str] = None # MIME type
+ metadata: Optional[Dict[str, Any]] = None
+ created_at: Optional[datetime] = None
+ created_by: Optional[str] = None
+ updated_at: Optional[datetime] = None
+ updated_by: Optional[str] = None
+
+
+class ListSkillsRequest(LiteLLMPydanticObjectBase):
+ """Request to list skills from LiteLLM database"""
+
+ limit: Optional[int] = 20
+ offset: Optional[int] = 0
+
+
class NewUserRequestTeam(LiteLLMPydanticObjectBase):
team_id: str
max_budget_in_team: Optional[float] = None
diff --git a/litellm/proxy/hooks/__init__.py b/litellm/proxy/hooks/__init__.py
index ccb1d0c7bd7..1d1e559d4be 100644
--- a/litellm/proxy/hooks/__init__.py
+++ b/litellm/proxy/hooks/__init__.py
@@ -3,6 +3,7 @@ from typing import Literal, Union
from . import *
from .cache_control_check import _PROXY_CacheControlCheck
+from .litellm_skills import SkillsInjectionHook
from .max_budget_limiter import _PROXY_MaxBudgetLimiter
from .parallel_request_limiter import _PROXY_MaxParallelRequestsHandler
from .parallel_request_limiter_v3 import _PROXY_MaxParallelRequestsHandler_v3
@@ -21,6 +22,7 @@ PROXY_HOOKS = {
"parallel_request_limiter": _PROXY_MaxParallelRequestsHandler_v3,
"cache_control_check": _PROXY_CacheControlCheck,
"responses_id_security": ResponsesIDSecurity,
+ "litellm_skills": SkillsInjectionHook,
}
## FEATURE FLAG HOOKS ##
diff --git a/litellm/proxy/hooks/litellm_skills/__init__.py b/litellm/proxy/hooks/litellm_skills/__init__.py
new file mode 100644
index 00000000000..057cf3d8b38
--- /dev/null
+++ b/litellm/proxy/hooks/litellm_skills/__init__.py
@@ -0,0 +1,39 @@
+"""
+LiteLLM Skills Hook - Proxy integration for skills
+
+This module provides the CustomLogger hook for skills processing.
+The actual skill logic is in litellm/llms/litellm_proxy/skills/.
+
+Usage:
+ from litellm.proxy.hooks.litellm_skills import SkillsInjectionHook
+
+ # Register hook in proxy
+ litellm.callbacks.append(SkillsInjectionHook())
+"""
+
+# Re-export from the SDK location for convenience
+from litellm.llms.litellm_proxy.skills import (
+ LITELLM_CODE_EXECUTION_TOOL,
+ CodeExecutionHandler,
+ LiteLLMInternalTools,
+ SkillPromptInjectionHandler,
+ SkillsSandboxExecutor,
+ code_execution_handler,
+ get_litellm_code_execution_tool,
+)
+from litellm.proxy.hooks.litellm_skills.main import (
+ SkillsInjectionHook,
+ skills_injection_hook,
+)
+
+__all__ = [
+ "SkillsInjectionHook",
+ "skills_injection_hook",
+ "CodeExecutionHandler",
+ "LiteLLMInternalTools",
+ "LITELLM_CODE_EXECUTION_TOOL",
+ "get_litellm_code_execution_tool",
+ "code_execution_handler",
+ "SkillPromptInjectionHandler",
+ "SkillsSandboxExecutor",
+]
diff --git a/litellm/proxy/hooks/litellm_skills/main.py b/litellm/proxy/hooks/litellm_skills/main.py
new file mode 100644
index 00000000000..26d4cbe1de7
--- /dev/null
+++ b/litellm/proxy/hooks/litellm_skills/main.py
@@ -0,0 +1,869 @@
+"""
+Skills Injection Hook for LiteLLM Proxy
+
+Main hook that orchestrates skill processing:
+- Fetches skills from LiteLLM DB
+- Injects SKILL.md content into system prompt
+- Adds litellm_code_execution tool for automatic code execution
+- Handles agentic loop internally when litellm_code_execution is called
+
+For non-Anthropic models (e.g., Bedrock, OpenAI, etc.):
+- Skills are converted to OpenAI-style tools
+- Skill file content (SKILL.md) is extracted and injected into the system prompt
+- litellm_code_execution tool is added - when model calls it, LiteLLM handles
+ execution automatically and returns final response with file_ids
+
+Usage:
+ # Simple - LiteLLM handles everything automatically via proxy
+ # 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": [{"skill_id": "litellm:skill_abc123"}]},
+ )
+ # Response includes file_ids for generated files
+"""
+
+import base64
+import json
+from typing import Any, Dict, List, Optional, Union
+
+from litellm._logging import verbose_proxy_logger
+from litellm.caching.caching import DualCache
+from litellm.integrations.custom_logger import CustomLogger
+from litellm.llms.litellm_proxy.skills.prompt_injection import (
+ SkillPromptInjectionHandler,
+)
+from litellm.proxy._types import LiteLLM_SkillsTable, UserAPIKeyAuth
+from litellm.types.utils import CallTypes, CallTypesLiteral
+
+
+class SkillsInjectionHook(CustomLogger):
+ """
+ Pre/Post-call hook that processes skills from container.skills parameter.
+
+ Pre-call (async_pre_call_hook):
+ - Skills with 'litellm:' prefix are fetched from LiteLLM DB
+ - For Anthropic models: native skills pass through, LiteLLM skills converted to tools
+ - For non-Anthropic models: LiteLLM skills are converted to tools + execute_code tool
+
+ Post-call (async_post_call_success_deployment_hook):
+ - If response has litellm_code_execution tool call, automatically execute code
+ - Continue conversation loop until model gives final response
+ - Return response with generated files inline
+
+ This hook is called automatically by litellm during completion calls.
+ """
+
+ def __init__(self, **kwargs):
+ from litellm.llms.litellm_proxy.skills.constants import (
+ DEFAULT_MAX_ITERATIONS,
+ DEFAULT_SANDBOX_TIMEOUT,
+ )
+
+ self.optional_params = kwargs
+ self.prompt_handler = SkillPromptInjectionHandler()
+ self.max_iterations = kwargs.get("max_iterations", DEFAULT_MAX_ITERATIONS)
+ self.sandbox_timeout = kwargs.get("sandbox_timeout", DEFAULT_SANDBOX_TIMEOUT)
+ super().__init__(**kwargs)
+
+ async def async_pre_call_hook(
+ self,
+ user_api_key_dict: UserAPIKeyAuth,
+ cache: DualCache,
+ data: dict,
+ call_type: CallTypesLiteral,
+ ) -> Optional[Union[Exception, str, dict]]:
+ """
+ Process skills from container.skills before the LLM call.
+
+ 1. Check if container.skills exists in request
+ 2. Separate skills by prefix (litellm: vs native)
+ 3. Fetch LiteLLM skills from database
+ 4. For Anthropic: keep native skills in container
+ 5. For non-Anthropic: convert LiteLLM skills to tools, inject content, add execute_code
+ """
+ # Only process completion-type calls
+ if call_type not in ["completion", "acompletion", "anthropic_messages"]:
+ return data
+
+ container = data.get("container")
+ if not container or not isinstance(container, dict):
+ return data
+
+ skills = container.get("skills")
+ if not skills or not isinstance(skills, list):
+ return data
+
+ verbose_proxy_logger.debug(f"SkillsInjectionHook: Processing {len(skills)} skills")
+
+ litellm_skills: List[LiteLLM_SkillsTable] = []
+ anthropic_skills: List[Dict[str, Any]] = []
+
+ # Separate skills by prefix
+ for skill in skills:
+ if not isinstance(skill, dict):
+ continue
+
+ skill_id = skill.get("skill_id", "")
+ if skill_id.startswith("litellm_"):
+ # Fetch from LiteLLM DB
+ db_skill = await self._fetch_skill_from_db(skill_id)
+ if db_skill:
+ litellm_skills.append(db_skill)
+ else:
+ verbose_proxy_logger.warning(
+ f"SkillsInjectionHook: Skill '{skill_id}' not found in LiteLLM DB"
+ )
+ else:
+ # Native Anthropic skill - pass through
+ anthropic_skills.append(skill)
+
+ # Check if using messages API spec (anthropic_messages call type)
+ # Messages API always uses Anthropic-style tool format
+ use_anthropic_format = call_type == "anthropic_messages"
+
+ if len(litellm_skills) > 0:
+ data = self._process_for_messages_api(
+ data=data,
+ litellm_skills=litellm_skills,
+ use_anthropic_format=use_anthropic_format,
+ )
+
+ return data
+
+
+ def _process_for_messages_api(
+ self,
+ data: dict,
+ litellm_skills: List[LiteLLM_SkillsTable],
+ use_anthropic_format: bool = True,
+ ) -> dict:
+ """
+ Process skills for messages API (Anthropic format tools).
+
+ - Converts skills to Anthropic-style tools (name, description, input_schema)
+ - Extracts and injects SKILL.md content into system prompt
+ - Adds litellm_code_execution tool for code execution
+ - Stores skill files in metadata for sandbox execution
+ """
+ from litellm.llms.litellm_proxy.skills.code_execution import (
+ get_litellm_code_execution_tool_anthropic,
+ )
+
+ tools = data.get("tools", [])
+ skill_contents: List[str] = []
+ all_skill_files: Dict[str, Dict[str, bytes]] = {}
+ all_module_paths: List[str] = []
+
+ for skill in litellm_skills:
+ # Convert skill to Anthropic-style tool
+ tools.append(self.prompt_handler.convert_skill_to_anthropic_tool(skill))
+
+ # Extract skill content from file if available
+ content = self.prompt_handler.extract_skill_content(skill)
+ if content:
+ skill_contents.append(content)
+
+ # Extract all files for code execution
+ skill_files = self.prompt_handler.extract_all_files(skill)
+ if skill_files:
+ all_skill_files[skill.skill_id] = skill_files
+ for path in skill_files.keys():
+ if path.endswith(".py"):
+ all_module_paths.append(path)
+
+ if tools:
+ data["tools"] = tools
+
+ # Inject skill content into system prompt
+ # For Anthropic messages API, use top-level 'system' param instead of messages array
+ if skill_contents:
+ data = self.prompt_handler.inject_skill_content_to_messages(
+ data, skill_contents, use_anthropic_format=use_anthropic_format
+ )
+
+ # Add litellm_code_execution tool if we have skill files
+ if all_skill_files:
+ code_exec_tool = get_litellm_code_execution_tool_anthropic()
+ data["tools"] = data.get("tools", []) + [code_exec_tool]
+
+ # Store skill files in litellm_metadata for automatic code execution
+ data["litellm_metadata"] = data.get("litellm_metadata", {})
+ data["litellm_metadata"]["_skill_files"] = all_skill_files
+ data["litellm_metadata"]["_litellm_code_execution_enabled"] = True
+
+ # Remove container (not supported by underlying providers)
+ data.pop("container", None)
+
+ verbose_proxy_logger.debug(
+ f"SkillsInjectionHook: Messages API - converted {len(litellm_skills)} skills to Anthropic tools, "
+ f"injected {len(skill_contents)} skill contents, "
+ f"added litellm_code_execution tool with {len(all_module_paths)} modules"
+ )
+
+ return data
+
+ def _process_non_anthropic_model(
+ self,
+ data: dict,
+ litellm_skills: List[LiteLLM_SkillsTable],
+ ) -> dict:
+ """
+ Process skills for non-Anthropic models (OpenAI format tools).
+
+ - Converts skills to OpenAI-style tools
+ - Extracts and injects SKILL.md content
+ - Adds execute_code tool for code execution
+ - Stores skill files in metadata for sandbox execution
+ """
+ tools = data.get("tools", [])
+ skill_contents: List[str] = []
+ all_skill_files: Dict[str, Dict[str, bytes]] = {}
+ all_module_paths: List[str] = []
+
+ for skill in litellm_skills:
+ # Convert skill to OpenAI-style tool
+ tools.append(self.prompt_handler.convert_skill_to_tool(skill))
+
+ # Extract skill content from file if available
+ content = self.prompt_handler.extract_skill_content(skill)
+ if content:
+ skill_contents.append(content)
+
+ # Extract all files for code execution
+ skill_files = self.prompt_handler.extract_all_files(skill)
+ if skill_files:
+ all_skill_files[skill.skill_id] = skill_files
+ # Collect Python module paths
+ for path in skill_files.keys():
+ if path.endswith(".py"):
+ all_module_paths.append(path)
+
+ if tools:
+ data["tools"] = tools
+
+ # Inject skill content into system prompt
+ if skill_contents:
+ data = self.prompt_handler.inject_skill_content_to_messages(data, skill_contents)
+
+ # Add litellm_code_execution tool if we have skill files
+ if all_skill_files:
+ from litellm.llms.litellm_proxy.skills.code_execution import (
+ get_litellm_code_execution_tool,
+ )
+ data["tools"] = data.get("tools", []) + [get_litellm_code_execution_tool()]
+
+ # Store skill files in litellm_metadata for automatic code execution
+ # Using litellm_metadata instead of metadata to avoid conflicts with user metadata
+ data["litellm_metadata"] = data.get("litellm_metadata", {})
+ data["litellm_metadata"]["_skill_files"] = all_skill_files
+ data["litellm_metadata"]["_litellm_code_execution_enabled"] = True
+
+ # Remove container for non-Anthropic (they don't support it)
+ data.pop("container", None)
+
+ verbose_proxy_logger.debug(
+ f"SkillsInjectionHook: Non-Anthropic model - converted {len(litellm_skills)} skills to tools, "
+ f"injected {len(skill_contents)} skill contents, "
+ f"added execute_code tool with {len(all_module_paths)} modules"
+ )
+
+ return data
+
+ async def _fetch_skill_from_db(self, skill_id: str) -> Optional[LiteLLM_SkillsTable]:
+ """
+ Fetch a skill from the LiteLLM database.
+
+ Args:
+ skill_id: The skill ID (without 'litellm:' prefix)
+
+ Returns:
+ LiteLLM_SkillsTable or None if not found
+ """
+ try:
+ from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler
+
+ return await LiteLLMSkillsHandler.fetch_skill_from_db(skill_id)
+ except Exception as e:
+ verbose_proxy_logger.warning(
+ f"SkillsInjectionHook: Error fetching skill {skill_id}: {e}"
+ )
+ return None
+
+ def _is_anthropic_model(self, model: str) -> bool:
+ """
+ Check if the model is an Anthropic model using get_llm_provider.
+
+ Args:
+ model: The model name/identifier
+
+ Returns:
+ True if Anthropic model, False otherwise
+ """
+ try:
+ from litellm.litellm_core_utils.get_llm_provider_logic import (
+ get_llm_provider,
+ )
+
+ _, custom_llm_provider, _, _ = get_llm_provider(model=model)
+ return custom_llm_provider == "anthropic"
+ except Exception:
+ # Fallback to simple check if get_llm_provider fails
+ return "claude" in model.lower() or model.lower().startswith("anthropic/")
+
+ async def async_post_call_success_deployment_hook(
+ self,
+ request_data: dict,
+ response: Any,
+ call_type: Optional[CallTypes],
+ ) -> Optional[Any]:
+ """
+ Post-call hook to handle automatic code execution.
+
+ Handles both OpenAI format (response.choices) and Anthropic/messages API
+ format (response["content"]).
+
+ If the response contains a tool call (litellm_code_execution or skill tool):
+ 1. Execute the code in sandbox
+ 2. Add result to messages
+ 3. Make another LLM call
+ 4. Repeat until model gives final response
+ 5. Return modified response with generated files
+ """
+ from litellm.llms.litellm_proxy.skills.code_execution import (
+ LiteLLMInternalTools,
+ )
+
+ # Check if code execution is enabled for this request
+ litellm_metadata = request_data.get("litellm_metadata", {})
+ metadata = request_data.get("metadata", {})
+
+ code_exec_enabled = (
+ litellm_metadata.get("_litellm_code_execution_enabled") or
+ metadata.get("_litellm_code_execution_enabled")
+ )
+ if not code_exec_enabled:
+ return None
+
+ # Get skill files
+ skill_files_by_id = (
+ litellm_metadata.get("_skill_files") or
+ metadata.get("_skill_files", {})
+ )
+ all_skill_files: Dict[str, bytes] = {}
+ for files_dict in skill_files_by_id.values():
+ all_skill_files.update(files_dict)
+
+ if not all_skill_files:
+ verbose_proxy_logger.warning(
+ "SkillsInjectionHook: No skill files found, cannot execute code"
+ )
+ return None
+
+ # Check for tool calls - handle both Anthropic and OpenAI formats
+ tool_calls = self._extract_tool_calls(response)
+ if not tool_calls:
+ return None
+
+ # Check if any tool call needs execution (litellm_code_execution or skill tool)
+ has_executable_tool = False
+ for tc in tool_calls:
+ tool_name = tc.get("name", "")
+ # Execute if it's litellm_code_execution OR a skill tool (skill_xxx)
+ if tool_name == LiteLLMInternalTools.CODE_EXECUTION.value or tool_name.startswith("skill_"):
+ has_executable_tool = True
+ break
+
+ if not has_executable_tool:
+ return None
+
+ verbose_proxy_logger.debug(
+ "SkillsInjectionHook: Detected tool call, starting execution loop"
+ )
+
+ # Start the agentic loop
+ return await self._execute_code_loop_messages_api(
+ data=request_data,
+ response=response,
+ skill_files=all_skill_files,
+ )
+
+ def _extract_tool_calls(self, response: Any) -> List[Dict[str, Any]]:
+ """Extract tool calls from response, handling both formats."""
+ tool_calls = []
+
+ # Get content - handle both dict and object responses
+ content = None
+ if isinstance(response, dict):
+ content = response.get("content", [])
+ elif hasattr(response, "content"):
+ content = response.content
+
+ # Anthropic/messages API format: response has "content" list with tool_use blocks
+ if content:
+ for block in content:
+ if isinstance(block, dict) and block.get("type") == "tool_use":
+ tool_calls.append({
+ "id": block.get("id"),
+ "name": block.get("name"),
+ "input": block.get("input", {}),
+ })
+ elif hasattr(block, "type") and getattr(block, "type", None) == "tool_use":
+ tool_calls.append({
+ "id": getattr(block, "id", None),
+ "name": getattr(block, "name", None),
+ "input": getattr(block, "input", {}),
+ })
+
+ # OpenAI format: response has choices[0].message.tool_calls
+ if not tool_calls and hasattr(response, "choices") and response.choices: # type: ignore[union-attr]
+ msg = response.choices[0].message # type: ignore[union-attr]
+ if hasattr(msg, "tool_calls") and msg.tool_calls:
+ for tc in msg.tool_calls:
+ tool_calls.append({
+ "id": tc.id,
+ "name": tc.function.name,
+ "input": json.loads(tc.function.arguments) if tc.function.arguments else {},
+ })
+
+ return tool_calls
+
+ async def _execute_code_loop_messages_api(
+ self,
+ data: dict,
+ response: Any,
+ skill_files: Dict[str, bytes],
+ ) -> Any:
+ """
+ Execute the code execution loop for messages API (Anthropic format).
+
+ Returns the final response with generated files inline.
+ """
+ import litellm
+ from litellm.llms.litellm_proxy.skills.code_execution import (
+ LiteLLMInternalTools,
+ )
+ from litellm.llms.litellm_proxy.skills.sandbox_executor import (
+ SkillsSandboxExecutor,
+ )
+
+ # Ensure response is not None
+ if response is None:
+ verbose_proxy_logger.error(
+ "SkillsInjectionHook: Response is None, cannot execute code loop"
+ )
+ return None
+
+ model = data.get("model", "")
+ messages = list(data.get("messages", []))
+ tools = data.get("tools", [])
+ max_tokens = data.get("max_tokens", 4096)
+
+ executor = SkillsSandboxExecutor(timeout=self.sandbox_timeout)
+ generated_files: List[Dict[str, Any]] = []
+ current_response = response
+
+ for iteration in range(self.max_iterations):
+ # Extract tool calls from current response
+ tool_calls = self._extract_tool_calls(current_response)
+ stop_reason = current_response.get("stop_reason") if isinstance(current_response, dict) else getattr(current_response, "stop_reason", None)
+
+ # Get content for assistant message - convert to plain dicts
+ raw_content = current_response.get("content", []) if isinstance(current_response, dict) else getattr(current_response, "content", [])
+ content_blocks = []
+ for block in raw_content or []:
+ if isinstance(block, dict):
+ content_blocks.append(block)
+ elif hasattr(block, "model_dump"):
+ content_blocks.append(block.model_dump())
+ elif hasattr(block, "__dict__"):
+ content_blocks.append(dict(block.__dict__))
+ else:
+ content_blocks.append({"type": "text", "text": str(block)})
+
+ # Build assistant message for conversation history (Anthropic format)
+ assistant_msg = {"role": "assistant", "content": content_blocks}
+ messages.append(assistant_msg)
+
+ # Check if we're done (no tool calls)
+ if stop_reason != "tool_use" or not tool_calls:
+ verbose_proxy_logger.debug(
+ f"SkillsInjectionHook: Loop completed after {iteration + 1} iterations, "
+ f"{len(generated_files)} files generated"
+ )
+ return self._attach_files_to_response(current_response, generated_files)
+
+ # Process tool calls
+ tool_results = []
+ for tc in tool_calls:
+ tool_name = tc.get("name", "")
+ tool_id = tc.get("id", "")
+ tool_input = tc.get("input", {})
+
+ # Execute if it's litellm_code_execution OR a skill tool
+ if tool_name == LiteLLMInternalTools.CODE_EXECUTION.value:
+ code = tool_input.get("code", "")
+ result = await self._execute_code(code, skill_files, executor, generated_files)
+ elif tool_name.startswith("skill_"):
+ # Skill tool - execute the skill's code
+ result = await self._execute_skill_tool(tool_name, tool_input, skill_files, executor, generated_files)
+ else:
+ result = f"Tool '{tool_name}' not handled"
+
+ tool_results.append({
+ "type": "tool_result",
+ "tool_use_id": tool_id,
+ "content": result,
+ })
+
+ # Add tool results to messages (Anthropic format)
+ messages.append({"role": "user", "content": tool_results})
+
+ # Make next LLM call
+ verbose_proxy_logger.debug(
+ f"SkillsInjectionHook: Making LLM call iteration {iteration + 2}"
+ )
+ try:
+ current_response = await litellm.anthropic.acreate(
+ model=model,
+ messages=messages,
+ tools=tools,
+ max_tokens=max_tokens,
+ )
+ if current_response is None:
+ verbose_proxy_logger.error(
+ "SkillsInjectionHook: LLM call returned None"
+ )
+ return self._attach_files_to_response(response, generated_files)
+ except Exception as e:
+ verbose_proxy_logger.error(
+ f"SkillsInjectionHook: LLM call failed: {e}"
+ )
+ return self._attach_files_to_response(response, generated_files)
+
+ verbose_proxy_logger.warning(
+ f"SkillsInjectionHook: Max iterations ({self.max_iterations}) reached"
+ )
+ return self._attach_files_to_response(current_response, generated_files)
+
+ async def _execute_code(
+ self,
+ code: str,
+ skill_files: Dict[str, bytes],
+ executor: Any,
+ generated_files: List[Dict[str, Any]],
+ ) -> str:
+ """Execute code in sandbox and return result string."""
+ try:
+ verbose_proxy_logger.debug(f"SkillsInjectionHook: Executing code ({len(code)} chars)")
+
+ exec_result = executor.execute(code=code, skill_files=skill_files)
+
+ result = exec_result.get("output", "") or ""
+
+ # Collect generated files
+ if exec_result.get("files"):
+ for f in exec_result["files"]:
+ generated_files.append({
+ "name": f["name"],
+ "mime_type": f["mime_type"],
+ "content_base64": f["content_base64"],
+ "size": len(base64.b64decode(f["content_base64"])),
+ })
+ result += f"\n\nGenerated file: {f['name']}"
+
+ if exec_result.get("error"):
+ result += f"\n\nError: {exec_result['error']}"
+
+ return result or "Code executed successfully"
+ except Exception as e:
+ return f"Code execution failed: {str(e)}"
+
+ async def _execute_skill_tool(
+ self,
+ tool_name: str,
+ tool_input: Dict[str, Any],
+ skill_files: Dict[str, bytes],
+ executor: Any,
+ generated_files: List[Dict[str, Any]],
+ ) -> str:
+ """Execute a skill tool by generating and running code based on skill content."""
+ # Generate code based on available skill modules
+ # Look for Python modules in the skill
+ python_modules = [p for p in skill_files.keys() if p.endswith(".py") and not p.endswith("__init__.py")]
+
+ # Try to find the main builder/creator module
+ main_module = None
+ for mod in python_modules:
+ if "builder" in mod.lower() or "creator" in mod.lower() or "generator" in mod.lower():
+ main_module = mod
+ break
+
+ if not main_module and python_modules:
+ # Use first non-init module
+ main_module = python_modules[0]
+
+ if main_module:
+ # Convert path to import: "core/gif_builder.py" -> "core.gif_builder"
+ import_path = main_module.replace("/", ".").replace(".py", "")
+
+ # Generate code that imports and uses the module
+ code = f"""
+# Auto-generated code to execute skill
+import sys
+sys.path.insert(0, '/sandbox')
+
+from {import_path} import *
+
+# Try to find and use a Builder/Creator class
+import inspect
+module = __import__('{import_path}', fromlist=[''])
+
+for name, obj in inspect.getmembers(module):
+ if inspect.isclass(obj) and name != 'object':
+ try:
+ instance = obj()
+ # Try common methods
+ if hasattr(instance, 'create'):
+ result = instance.create()
+ elif hasattr(instance, 'build'):
+ result = instance.build()
+ elif hasattr(instance, 'generate'):
+ result = instance.generate()
+ elif hasattr(instance, 'save'):
+ instance.save('output.gif')
+ print(f'Used {{name}} class')
+ break
+ except Exception as e:
+ print(f'Error with {{name}}: {{e}}')
+ continue
+
+# List generated files
+import os
+for f in os.listdir('.'):
+ if f.endswith(('.gif', '.png', '.jpg')):
+ print(f'Generated: {{f}}')
+"""
+ else:
+ # Fallback generic code
+ code = """
+print('No executable skill module found')
+"""
+
+ return await self._execute_code(code, skill_files, executor, generated_files)
+
+ async def _execute_code_loop(
+ self,
+ data: dict,
+ response: Any,
+ skill_files: Dict[str, bytes],
+ ) -> Any:
+ """
+ Execute the code execution loop until model gives final response.
+
+ Returns the final response with generated files inline.
+ """
+ import litellm
+ from litellm.llms.litellm_proxy.skills.code_execution import (
+ LiteLLMInternalTools,
+ )
+ from litellm.llms.litellm_proxy.skills.sandbox_executor import (
+ SkillsSandboxExecutor,
+ )
+
+ model = data.get("model", "")
+ messages = list(data.get("messages", []))
+ tools = data.get("tools", [])
+
+ # Keys to exclude when passing through to acompletion
+ # These are either handled explicitly or are internal LiteLLM fields
+ _EXCLUDED_ACOMPLETION_KEYS = frozenset({
+ "messages",
+ "model",
+ "tools",
+ "metadata",
+ "litellm_metadata",
+ "container",
+ })
+
+ kwargs = {
+ k: v for k, v in data.items()
+ if k not in _EXCLUDED_ACOMPLETION_KEYS
+ }
+
+ executor = SkillsSandboxExecutor(timeout=self.sandbox_timeout)
+ generated_files: List[Dict[str, Any]] = []
+ current_response: Any = response
+
+ for iteration in range(self.max_iterations):
+ # OpenAI format response has choices[0].message
+ assistant_message = current_response.choices[0].message # type: ignore[union-attr]
+ stop_reason = current_response.choices[0].finish_reason # type: ignore[union-attr]
+
+ # 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
+ ]
+ messages.append(assistant_msg_dict)
+
+ # Check if we're done (no tool calls)
+ if stop_reason != "tool_calls" or not assistant_message.tool_calls:
+ verbose_proxy_logger.debug(
+ f"SkillsInjectionHook: Code execution loop completed after "
+ f"{iteration + 1} iterations, {len(generated_files)} files generated"
+ )
+ # Attach generated files to response
+ return self._attach_files_to_response(current_response, generated_files)
+
+ # Process tool calls
+ for tool_call in assistant_message.tool_calls:
+ tool_name = tool_call.function.name
+
+ if tool_name == LiteLLMInternalTools.CODE_EXECUTION.value:
+ tool_result = await self._execute_code_tool(
+ tool_call=tool_call,
+ skill_files=skill_files,
+ executor=executor,
+ generated_files=generated_files,
+ )
+ else:
+ # Non-code-execution tool - cannot handle
+ tool_result = f"Tool '{tool_name}' not handled automatically"
+
+ messages.append({
+ "role": "tool",
+ "tool_call_id": tool_call.id,
+ "content": tool_result,
+ })
+
+ # Make next LLM call using the messages API
+ verbose_proxy_logger.debug(
+ f"SkillsInjectionHook: Making LLM call iteration {iteration + 2}"
+ )
+ current_response = await litellm.anthropic.acreate(
+ model=model,
+ messages=messages,
+ tools=tools,
+ max_tokens=kwargs.get("max_tokens", 4096),
+ )
+
+ # Max iterations reached
+ verbose_proxy_logger.warning(
+ f"SkillsInjectionHook: Max iterations ({self.max_iterations}) reached"
+ )
+ return self._attach_files_to_response(current_response, generated_files)
+
+ async def _execute_code_tool(
+ self,
+ tool_call: Any,
+ skill_files: Dict[str, bytes],
+ executor: Any,
+ generated_files: List[Dict[str, Any]],
+ ) -> str:
+ """Execute a litellm_code_execution tool call and return result string."""
+ try:
+ args = json.loads(tool_call.function.arguments)
+ code = args.get("code", "")
+
+ verbose_proxy_logger.debug(
+ f"SkillsInjectionHook: Executing code ({len(code)} chars)"
+ )
+
+ exec_result = executor.execute(
+ code=code,
+ skill_files=skill_files,
+ )
+
+ # Build tool result content
+ tool_result = exec_result.get("output", "") or ""
+
+ # Collect generated files
+ if exec_result.get("files"):
+ tool_result += "\n\nGenerated files:"
+ for f in exec_result["files"]:
+ file_content = base64.b64decode(f["content_base64"])
+ 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_proxy_logger.debug(
+ f"SkillsInjectionHook: Generated file {f['name']} "
+ f"({len(file_content)} bytes)"
+ )
+
+ if exec_result.get("error"):
+ tool_result += f"\n\nError:\n{exec_result['error']}"
+
+ return tool_result
+
+ except Exception as e:
+ verbose_proxy_logger.error(
+ f"SkillsInjectionHook: Code execution failed: {e}"
+ )
+ return f"Code execution failed: {str(e)}"
+
+ def _attach_files_to_response(
+ self,
+ response: Any,
+ generated_files: List[Dict[str, Any]],
+ ) -> Any:
+ """
+ Attach generated files to the response object.
+
+ Files are added to response._litellm_generated_files for easy access.
+ For dict responses, files are added as a key.
+ """
+ if not generated_files:
+ return response
+
+ # Handle dict response (Anthropic/messages API format)
+ if isinstance(response, dict):
+ response["_litellm_generated_files"] = generated_files
+ verbose_proxy_logger.debug(
+ f"SkillsInjectionHook: Attached {len(generated_files)} files to dict response"
+ )
+ return response
+
+ # Handle object response (OpenAI format)
+ try:
+ response._litellm_generated_files = generated_files
+ except AttributeError:
+ pass
+
+ # Also add to model_extra if available (for serialization)
+ if hasattr(response, "model_extra"):
+ if response.model_extra is None:
+ response.model_extra = {}
+ response.model_extra["_litellm_generated_files"] = generated_files
+
+ verbose_proxy_logger.debug(
+ f"SkillsInjectionHook: Attached {len(generated_files)} files to response"
+ )
+
+ return response
+
+
+# Global instance for registration
+skills_injection_hook = SkillsInjectionHook()
+
+import litellm
+
+litellm.logging_callback_manager.add_litellm_callback(skills_injection_hook)
diff --git a/litellm/proxy/proxy_config.yaml b/litellm/proxy/proxy_config.yaml
index a773e934ef1..2191968e86c 100644
--- a/litellm/proxy/proxy_config.yaml
+++ b/litellm/proxy/proxy_config.yaml
@@ -1,10 +1,5 @@
model_list:
- - model_name: gemini/*
+ - model_name: anthropic/*
litellm_params:
- model: gemini/*
+ model: anthropic/*
-litellm_settings:
- callbacks: ["dynamic_rate_limiter_v3"]
- priority_reservation:
- "prod": 0.9 # 90% reserved for production
- "dev": 0.1 # 10% reserved for development
diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma
index fd77a86f42c..aac0b5b35de 100644
--- a/litellm/proxy/schema.prisma
+++ b/litellm/proxy/schema.prisma
@@ -727,4 +727,22 @@ model LiteLLM_UISettings {
ui_settings Json
created_at DateTime @default(now())
updated_at DateTime @updatedAt
+}
+
+// Skills table for storing LiteLLM-managed skills
+model LiteLLM_SkillsTable {
+ skill_id String @id @default(uuid())
+ display_title String?
+ description String?
+ instructions String? // The skill instructions/prompt (from SKILL.md)
+ source String @default("custom") // "custom" or "anthropic"
+ latest_version String?
+ file_content Bytes? // Binary content of the skill files (zip)
+ file_name String? // Original filename
+ file_type String? // MIME type (e.g., "application/zip")
+ metadata Json? @default("{}")
+ created_at DateTime @default(now())
+ created_by String?
+ updated_at DateTime @default(now()) @updatedAt
+ updated_by String?
}
\ No newline at end of file
diff --git a/litellm/skills/main.py b/litellm/skills/main.py
index 2baeb60518e..f6abd9043d4 100644
--- a/litellm/skills/main.py
+++ b/litellm/skills/main.py
@@ -23,12 +23,27 @@ from litellm.types.llms.anthropic_skills import (
Skill,
)
from litellm.types.router import GenericLiteLLMParams
+from litellm.types.utils import LlmProviders
from litellm.utils import ProviderConfigManager, client
# Initialize HTTP handler
base_llm_http_handler = BaseLLMHTTPHandler()
DEFAULT_ANTHROPIC_API_BASE = "https://api.anthropic.com/v1"
+# Initialize LiteLLM skills handler (lazy - only used when custom_llm_provider="litellm")
+_litellm_skills_handler = None
+
+
+def _get_litellm_skills_handler():
+ """Lazy initialization of LiteLLM skills handler to avoid import overhead."""
+ global _litellm_skills_handler
+ if _litellm_skills_handler is None:
+ from litellm.llms.litellm_proxy.skills.transformation import (
+ LiteLLMSkillsTransformationHandler,
+ )
+ _litellm_skills_handler = LiteLLMSkillsTransformationHandler()
+ return _litellm_skills_handler
+
@client
async def acreate_skill(
@@ -133,18 +148,6 @@ def create_skill(
if custom_llm_provider is None:
custom_llm_provider = "anthropic"
- # Get provider config
- skills_api_provider_config: Optional[BaseSkillsAPIConfig] = (
- ProviderConfigManager.get_provider_skills_api_config(
- provider=litellm.LlmProviders(custom_llm_provider),
- )
- )
-
- if skills_api_provider_config is None:
- raise ValueError(
- f"CREATE skill is not supported for {custom_llm_provider}"
- )
-
# Build create request
create_request: CreateSkillRequest = {}
if display_title is not None:
@@ -156,6 +159,30 @@ def create_skill(
if extra_body:
create_request.update(extra_body) # type: ignore
+ # Route to LiteLLM DB if custom_llm_provider="litellm_proxy"
+ if custom_llm_provider == LlmProviders.LITELLM_PROXY.value:
+ return _get_litellm_skills_handler().create_skill_handler(
+ display_title=display_title,
+ files=files,
+ metadata=extra_body.get("metadata") if extra_body else None,
+ user_id=kwargs.get("user_id"),
+ _is_async=_is_async,
+ logging_obj=litellm_logging_obj,
+ litellm_call_id=litellm_call_id,
+ )
+
+ # Get provider config for external providers (Anthropic, etc.)
+ skills_api_provider_config: Optional[BaseSkillsAPIConfig] = (
+ ProviderConfigManager.get_provider_skills_api_config(
+ provider=litellm.LlmProviders(custom_llm_provider),
+ )
+ )
+
+ if skills_api_provider_config is None:
+ raise ValueError(
+ f"CREATE skill is not supported for {custom_llm_provider}"
+ )
+
# Validate environment and get headers
headers = extra_headers or {}
headers = skills_api_provider_config.validate_environment(
@@ -316,7 +343,17 @@ def list_skills(
if custom_llm_provider is None:
custom_llm_provider = "anthropic"
- # Get provider config
+ # Route to LiteLLM DB if custom_llm_provider="litellm_proxy"
+ if custom_llm_provider == LlmProviders.LITELLM_PROXY.value:
+ return _get_litellm_skills_handler().list_skills_handler(
+ limit=limit or 20,
+ offset=0,
+ _is_async=_is_async,
+ logging_obj=litellm_logging_obj,
+ litellm_call_id=litellm_call_id,
+ )
+
+ # Get provider config for external providers (Anthropic, etc.)
skills_api_provider_config: Optional[BaseSkillsAPIConfig] = (
ProviderConfigManager.get_provider_skills_api_config(
provider=litellm.LlmProviders(custom_llm_provider),
@@ -481,7 +518,16 @@ def get_skill(
if custom_llm_provider is None:
custom_llm_provider = "anthropic"
- # Get provider config
+ # Route to LiteLLM DB if custom_llm_provider="litellm_proxy"
+ if custom_llm_provider == LlmProviders.LITELLM_PROXY.value:
+ return _get_litellm_skills_handler().get_skill_handler(
+ skill_id=skill_id,
+ _is_async=_is_async,
+ logging_obj=litellm_logging_obj,
+ litellm_call_id=litellm_call_id,
+ )
+
+ # Get provider config for external providers (Anthropic, etc.)
skills_api_provider_config: Optional[BaseSkillsAPIConfig] = (
ProviderConfigManager.get_provider_skills_api_config(
provider=litellm.LlmProviders(custom_llm_provider),
@@ -638,7 +684,16 @@ def delete_skill(
if custom_llm_provider is None:
custom_llm_provider = "anthropic"
- # Get provider config
+ # Route to LiteLLM DB if custom_llm_provider="litellm_proxy"
+ if custom_llm_provider == LlmProviders.LITELLM_PROXY.value:
+ return _get_litellm_skills_handler().delete_skill_handler(
+ skill_id=skill_id,
+ _is_async=_is_async,
+ logging_obj=litellm_logging_obj,
+ litellm_call_id=litellm_call_id,
+ )
+
+ # Get provider config for external providers (Anthropic, etc.)
skills_api_provider_config: Optional[BaseSkillsAPIConfig] = (
ProviderConfigManager.get_provider_skills_api_config(
provider=litellm.LlmProviders(custom_llm_provider),
diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py
index 23dd661e9ad..371f008c04b 100644
--- a/litellm/types/llms/anthropic.py
+++ b/litellm/types/llms/anthropic.py
@@ -358,6 +358,7 @@ class AnthropicMessagesRequestOptionalParams(TypedDict, total=False):
top_p: Optional[float]
mcp_servers: Optional[List[AnthropicMcpServerTool]]
context_management: Optional[Dict[str, Any]]
+ container: Optional[Dict[str, Any]] # Container config with skills for code execution
class AnthropicMessagesRequest(AnthropicMessagesRequestOptionalParams, total=False):
diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py
index dbbab6c1fdc..502bc7e4fe0 100644
--- a/litellm/types/llms/openai.py
+++ b/litellm/types/llms/openai.py
@@ -903,6 +903,7 @@ class ChatCompletionRequest(TypedDict, total=False):
functions: List
user: str
metadata: dict # litellm specific param
+ reasoning_effort: str # OpenAI o1/o3 reasoning parameter
class ChatCompletionDeltaChunk(TypedDict, total=False):
diff --git a/requirements.txt b/requirements.txt
index 239eb707e7f..f222acc46e6 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -48,6 +48,7 @@ detect-secrets==1.5.0 # Enterprise - secret detection / masking in LLM requests
cryptography==44.0.1
tzdata==2025.1 # IANA time zone database
litellm-proxy-extras==0.4.14 # for proxy extras - e.g. prisma migrations
+llm-sandbox==0.3.31 # for skill execution in sandbox
### LITELLM PACKAGE DEPENDENCIES
python-dotenv==1.0.1 # for env
tiktoken==0.8.0 # for calculating usage
diff --git a/schema.prisma b/schema.prisma
index fd77a86f42c..aac0b5b35de 100644
--- a/schema.prisma
+++ b/schema.prisma
@@ -727,4 +727,22 @@ model LiteLLM_UISettings {
ui_settings Json
created_at DateTime @default(now())
updated_at DateTime @updatedAt
+}
+
+// Skills table for storing LiteLLM-managed skills
+model LiteLLM_SkillsTable {
+ skill_id String @id @default(uuid())
+ display_title String?
+ description String?
+ instructions String? // The skill instructions/prompt (from SKILL.md)
+ source String @default("custom") // "custom" or "anthropic"
+ latest_version String?
+ file_content Bytes? // Binary content of the skill files (zip)
+ file_name String? // Original filename
+ file_type String? // MIME type (e.g., "application/zip")
+ metadata Json? @default("{}")
+ created_at DateTime @default(now())
+ created_by String?
+ updated_at DateTime @default(now()) @updatedAt
+ updated_by String?
}
\ No newline at end of file
diff --git a/tests/llm_translation/test_skills_data/slack-gif-creator.zip b/tests/llm_translation/test_skills_data/slack-gif-creator.zip
new file mode 100644
index 00000000000..15c60e3667d
Binary files /dev/null and b/tests/llm_translation/test_skills_data/slack-gif-creator.zip differ
diff --git a/tests/llm_translation/test_skills_data/slack-gif-creator/LICENSE.txt b/tests/llm_translation/test_skills_data/slack-gif-creator/LICENSE.txt
new file mode 100644
index 00000000000..7a4a3ea2424
--- /dev/null
+++ b/tests/llm_translation/test_skills_data/slack-gif-creator/LICENSE.txt
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
\ No newline at end of file
diff --git a/tests/llm_translation/test_skills_data/slack-gif-creator/SKILL.md b/tests/llm_translation/test_skills_data/slack-gif-creator/SKILL.md
new file mode 100644
index 00000000000..16660d8ceb7
--- /dev/null
+++ b/tests/llm_translation/test_skills_data/slack-gif-creator/SKILL.md
@@ -0,0 +1,254 @@
+---
+name: slack-gif-creator
+description: Knowledge and utilities for creating animated GIFs optimized for Slack. Provides constraints, validation tools, and animation concepts. Use when users request animated GIFs for Slack like "make me a GIF of X doing Y for Slack."
+license: Complete terms in LICENSE.txt
+---
+
+# Slack GIF Creator
+
+A toolkit providing utilities and knowledge for creating animated GIFs optimized for Slack.
+
+## Slack Requirements
+
+**Dimensions:**
+- Emoji GIFs: 128x128 (recommended)
+- Message GIFs: 480x480
+
+**Parameters:**
+- FPS: 10-30 (lower is smaller file size)
+- Colors: 48-128 (fewer = smaller file size)
+- Duration: Keep under 3 seconds for emoji GIFs
+
+## Core Workflow
+
+```python
+from core.gif_builder import GIFBuilder
+from PIL import Image, ImageDraw
+
+# 1. Create builder
+builder = GIFBuilder(width=128, height=128, fps=10)
+
+# 2. Generate frames
+for i in range(12):
+ frame = Image.new('RGB', (128, 128), (240, 248, 255))
+ draw = ImageDraw.Draw(frame)
+
+ # Draw your animation using PIL primitives
+ # (circles, polygons, lines, etc.)
+
+ builder.add_frame(frame)
+
+# 3. Save with optimization
+builder.save('output.gif', num_colors=48, optimize_for_emoji=True)
+```
+
+## Drawing Graphics
+
+### Working with User-Uploaded Images
+If a user uploads an image, consider whether they want to:
+- **Use it directly** (e.g., "animate this", "split this into frames")
+- **Use it as inspiration** (e.g., "make something like this")
+
+Load and work with images using PIL:
+```python
+from PIL import Image
+
+uploaded = Image.open('file.png')
+# Use directly, or just as reference for colors/style
+```
+
+### Drawing from Scratch
+When drawing graphics from scratch, use PIL ImageDraw primitives:
+
+```python
+from PIL import ImageDraw
+
+draw = ImageDraw.Draw(frame)
+
+# Circles/ovals
+draw.ellipse([x1, y1, x2, y2], fill=(r, g, b), outline=(r, g, b), width=3)
+
+# Stars, triangles, any polygon
+points = [(x1, y1), (x2, y2), (x3, y3), ...]
+draw.polygon(points, fill=(r, g, b), outline=(r, g, b), width=3)
+
+# Lines
+draw.line([(x1, y1), (x2, y2)], fill=(r, g, b), width=5)
+
+# Rectangles
+draw.rectangle([x1, y1, x2, y2], fill=(r, g, b), outline=(r, g, b), width=3)
+```
+
+**Don't use:** Emoji fonts (unreliable across platforms) or assume pre-packaged graphics exist in this skill.
+
+### Making Graphics Look Good
+
+Graphics should look polished and creative, not basic. Here's how:
+
+**Use thicker lines** - Always set `width=2` or higher for outlines and lines. Thin lines (width=1) look choppy and amateurish.
+
+**Add visual depth**:
+- Use gradients for backgrounds (`create_gradient_background`)
+- Layer multiple shapes for complexity (e.g., a star with a smaller star inside)
+
+**Make shapes more interesting**:
+- Don't just draw a plain circle - add highlights, rings, or patterns
+- Stars can have glows (draw larger, semi-transparent versions behind)
+- Combine multiple shapes (stars + sparkles, circles + rings)
+
+**Pay attention to colors**:
+- Use vibrant, complementary colors
+- Add contrast (dark outlines on light shapes, light outlines on dark shapes)
+- Consider the overall composition
+
+**For complex shapes** (hearts, snowflakes, etc.):
+- Use combinations of polygons and ellipses
+- Calculate points carefully for symmetry
+- Add details (a heart can have a highlight curve, snowflakes have intricate branches)
+
+Be creative and detailed! A good Slack GIF should look polished, not like placeholder graphics.
+
+## Available Utilities
+
+### GIFBuilder (`core.gif_builder`)
+Assembles frames and optimizes for Slack:
+```python
+builder = GIFBuilder(width=128, height=128, fps=10)
+builder.add_frame(frame) # Add PIL Image
+builder.add_frames(frames) # Add list of frames
+builder.save('out.gif', num_colors=48, optimize_for_emoji=True, remove_duplicates=True)
+```
+
+### Validators (`core.validators`)
+Check if GIF meets Slack requirements:
+```python
+from core.validators import validate_gif, is_slack_ready
+
+# Detailed validation
+passes, info = validate_gif('my.gif', is_emoji=True, verbose=True)
+
+# Quick check
+if is_slack_ready('my.gif'):
+ print("Ready!")
+```
+
+### Easing Functions (`core.easing`)
+Smooth motion instead of linear:
+```python
+from core.easing import interpolate
+
+# Progress from 0.0 to 1.0
+t = i / (num_frames - 1)
+
+# Apply easing
+y = interpolate(start=0, end=400, t=t, easing='ease_out')
+
+# Available: linear, ease_in, ease_out, ease_in_out,
+# bounce_out, elastic_out, back_out
+```
+
+### Frame Helpers (`core.frame_composer`)
+Convenience functions for common needs:
+```python
+from core.frame_composer import (
+ create_blank_frame, # Solid color background
+ create_gradient_background, # Vertical gradient
+ draw_circle, # Helper for circles
+ draw_text, # Simple text rendering
+ draw_star # 5-pointed star
+)
+```
+
+## Animation Concepts
+
+### Shake/Vibrate
+Offset object position with oscillation:
+- Use `math.sin()` or `math.cos()` with frame index
+- Add small random variations for natural feel
+- Apply to x and/or y position
+
+### Pulse/Heartbeat
+Scale object size rhythmically:
+- Use `math.sin(t * frequency * 2 * math.pi)` for smooth pulse
+- For heartbeat: two quick pulses then pause (adjust sine wave)
+- Scale between 0.8 and 1.2 of base size
+
+### Bounce
+Object falls and bounces:
+- Use `interpolate()` with `easing='bounce_out'` for landing
+- Use `easing='ease_in'` for falling (accelerating)
+- Apply gravity by increasing y velocity each frame
+
+### Spin/Rotate
+Rotate object around center:
+- PIL: `image.rotate(angle, resample=Image.BICUBIC)`
+- For wobble: use sine wave for angle instead of linear
+
+### Fade In/Out
+Gradually appear or disappear:
+- Create RGBA image, adjust alpha channel
+- Or use `Image.blend(image1, image2, alpha)`
+- Fade in: alpha from 0 to 1
+- Fade out: alpha from 1 to 0
+
+### Slide
+Move object from off-screen to position:
+- Start position: outside frame bounds
+- End position: target location
+- Use `interpolate()` with `easing='ease_out'` for smooth stop
+- For overshoot: use `easing='back_out'`
+
+### Zoom
+Scale and position for zoom effect:
+- Zoom in: scale from 0.1 to 2.0, crop center
+- Zoom out: scale from 2.0 to 1.0
+- Can add motion blur for drama (PIL filter)
+
+### Explode/Particle Burst
+Create particles radiating outward:
+- Generate particles with random angles and velocities
+- Update each particle: `x += vx`, `y += vy`
+- Add gravity: `vy += gravity_constant`
+- Fade out particles over time (reduce alpha)
+
+## Optimization Strategies
+
+Only when asked to make the file size smaller, implement a few of the following methods:
+
+1. **Fewer frames** - Lower FPS (10 instead of 20) or shorter duration
+2. **Fewer colors** - `num_colors=48` instead of 128
+3. **Smaller dimensions** - 128x128 instead of 480x480
+4. **Remove duplicates** - `remove_duplicates=True` in save()
+5. **Emoji mode** - `optimize_for_emoji=True` auto-optimizes
+
+```python
+# Maximum optimization for emoji
+builder.save(
+ 'emoji.gif',
+ num_colors=48,
+ optimize_for_emoji=True,
+ remove_duplicates=True
+)
+```
+
+## Philosophy
+
+This skill provides:
+- **Knowledge**: Slack's requirements and animation concepts
+- **Utilities**: GIFBuilder, validators, easing functions
+- **Flexibility**: Create the animation logic using PIL primitives
+
+It does NOT provide:
+- Rigid animation templates or pre-made functions
+- Emoji font rendering (unreliable across platforms)
+- A library of pre-packaged graphics built into the skill
+
+**Note on user uploads**: This skill doesn't include pre-built graphics, but if a user uploads an image, use PIL to load and work with it - interpret based on their request whether they want it used directly or just as inspiration.
+
+Be creative! Combine concepts (bouncing + rotating, pulsing + sliding, etc.) and use PIL's full capabilities.
+
+## Dependencies
+
+```bash
+pip install pillow imageio numpy
+```
diff --git a/tests/llm_translation/test_skills_data/slack-gif-creator/core/__init__.py b/tests/llm_translation/test_skills_data/slack-gif-creator/core/__init__.py
new file mode 100644
index 00000000000..8b137891791
--- /dev/null
+++ b/tests/llm_translation/test_skills_data/slack-gif-creator/core/__init__.py
@@ -0,0 +1 @@
+
diff --git a/tests/llm_translation/test_skills_data/slack-gif-creator/core/easing.py b/tests/llm_translation/test_skills_data/slack-gif-creator/core/easing.py
new file mode 100644
index 00000000000..772fa830235
--- /dev/null
+++ b/tests/llm_translation/test_skills_data/slack-gif-creator/core/easing.py
@@ -0,0 +1,234 @@
+#!/usr/bin/env python3
+"""
+Easing Functions - Timing functions for smooth animations.
+
+Provides various easing functions for natural motion and timing.
+All functions take a value t (0.0 to 1.0) and return eased value (0.0 to 1.0).
+"""
+
+import math
+
+
+def linear(t: float) -> float:
+ """Linear interpolation (no easing)."""
+ return t
+
+
+def ease_in_quad(t: float) -> float:
+ """Quadratic ease-in (slow start, accelerating)."""
+ return t * t
+
+
+def ease_out_quad(t: float) -> float:
+ """Quadratic ease-out (fast start, decelerating)."""
+ return t * (2 - t)
+
+
+def ease_in_out_quad(t: float) -> float:
+ """Quadratic ease-in-out (slow start and end)."""
+ if t < 0.5:
+ return 2 * t * t
+ return -1 + (4 - 2 * t) * t
+
+
+def ease_in_cubic(t: float) -> float:
+ """Cubic ease-in (slow start)."""
+ return t * t * t
+
+
+def ease_out_cubic(t: float) -> float:
+ """Cubic ease-out (fast start)."""
+ return (t - 1) * (t - 1) * (t - 1) + 1
+
+
+def ease_in_out_cubic(t: float) -> float:
+ """Cubic ease-in-out."""
+ if t < 0.5:
+ return 4 * t * t * t
+ return (t - 1) * (2 * t - 2) * (2 * t - 2) + 1
+
+
+def ease_in_bounce(t: float) -> float:
+ """Bounce ease-in (bouncy start)."""
+ return 1 - ease_out_bounce(1 - t)
+
+
+def ease_out_bounce(t: float) -> float:
+ """Bounce ease-out (bouncy end)."""
+ if t < 1 / 2.75:
+ return 7.5625 * t * t
+ elif t < 2 / 2.75:
+ t -= 1.5 / 2.75
+ return 7.5625 * t * t + 0.75
+ elif t < 2.5 / 2.75:
+ t -= 2.25 / 2.75
+ return 7.5625 * t * t + 0.9375
+ else:
+ t -= 2.625 / 2.75
+ return 7.5625 * t * t + 0.984375
+
+
+def ease_in_out_bounce(t: float) -> float:
+ """Bounce ease-in-out."""
+ if t < 0.5:
+ return ease_in_bounce(t * 2) * 0.5
+ return ease_out_bounce(t * 2 - 1) * 0.5 + 0.5
+
+
+def ease_in_elastic(t: float) -> float:
+ """Elastic ease-in (spring effect)."""
+ if t == 0 or t == 1:
+ return t
+ return -math.pow(2, 10 * (t - 1)) * math.sin((t - 1.1) * 5 * math.pi)
+
+
+def ease_out_elastic(t: float) -> float:
+ """Elastic ease-out (spring effect)."""
+ if t == 0 or t == 1:
+ return t
+ return math.pow(2, -10 * t) * math.sin((t - 0.1) * 5 * math.pi) + 1
+
+
+def ease_in_out_elastic(t: float) -> float:
+ """Elastic ease-in-out."""
+ if t == 0 or t == 1:
+ return t
+ t = t * 2 - 1
+ if t < 0:
+ return -0.5 * math.pow(2, 10 * t) * math.sin((t - 0.1) * 5 * math.pi)
+ return math.pow(2, -10 * t) * math.sin((t - 0.1) * 5 * math.pi) * 0.5 + 1
+
+
+# Convenience mapping
+EASING_FUNCTIONS = {
+ "linear": linear,
+ "ease_in": ease_in_quad,
+ "ease_out": ease_out_quad,
+ "ease_in_out": ease_in_out_quad,
+ "bounce_in": ease_in_bounce,
+ "bounce_out": ease_out_bounce,
+ "bounce": ease_in_out_bounce,
+ "elastic_in": ease_in_elastic,
+ "elastic_out": ease_out_elastic,
+ "elastic": ease_in_out_elastic,
+}
+
+
+def get_easing(name: str = "linear"):
+ """Get easing function by name."""
+ return EASING_FUNCTIONS.get(name, linear)
+
+
+def interpolate(start: float, end: float, t: float, easing: str = "linear") -> float:
+ """
+ Interpolate between two values with easing.
+
+ Args:
+ start: Start value
+ end: End value
+ t: Progress from 0.0 to 1.0
+ easing: Name of easing function
+
+ Returns:
+ Interpolated value
+ """
+ ease_func = get_easing(easing)
+ eased_t = ease_func(t)
+ return start + (end - start) * eased_t
+
+
+def ease_back_in(t: float) -> float:
+ """Back ease-in (slight overshoot backward before forward motion)."""
+ c1 = 1.70158
+ c3 = c1 + 1
+ return c3 * t * t * t - c1 * t * t
+
+
+def ease_back_out(t: float) -> float:
+ """Back ease-out (overshoot forward then settle back)."""
+ c1 = 1.70158
+ c3 = c1 + 1
+ return 1 + c3 * pow(t - 1, 3) + c1 * pow(t - 1, 2)
+
+
+def ease_back_in_out(t: float) -> float:
+ """Back ease-in-out (overshoot at both ends)."""
+ c1 = 1.70158
+ c2 = c1 * 1.525
+ if t < 0.5:
+ return (pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
+ return (pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2
+
+
+def apply_squash_stretch(
+ base_scale: tuple[float, float], intensity: float, direction: str = "vertical"
+) -> tuple[float, float]:
+ """
+ Calculate squash and stretch scales for more dynamic animation.
+
+ Args:
+ base_scale: (width_scale, height_scale) base scales
+ intensity: Squash/stretch intensity (0.0-1.0)
+ direction: 'vertical', 'horizontal', or 'both'
+
+ Returns:
+ (width_scale, height_scale) with squash/stretch applied
+ """
+ width_scale, height_scale = base_scale
+
+ if direction == "vertical":
+ # Compress vertically, expand horizontally (preserve volume)
+ height_scale *= 1 - intensity * 0.5
+ width_scale *= 1 + intensity * 0.5
+ elif direction == "horizontal":
+ # Compress horizontally, expand vertically
+ width_scale *= 1 - intensity * 0.5
+ height_scale *= 1 + intensity * 0.5
+ elif direction == "both":
+ # General squash (both dimensions)
+ width_scale *= 1 - intensity * 0.3
+ height_scale *= 1 - intensity * 0.3
+
+ return (width_scale, height_scale)
+
+
+def calculate_arc_motion(
+ start: tuple[float, float], end: tuple[float, float], height: float, t: float
+) -> tuple[float, float]:
+ """
+ Calculate position along a parabolic arc (natural motion path).
+
+ Args:
+ start: (x, y) starting position
+ end: (x, y) ending position
+ height: Arc height at midpoint (positive = upward)
+ t: Progress (0.0-1.0)
+
+ Returns:
+ (x, y) position along arc
+ """
+ x1, y1 = start
+ x2, y2 = end
+
+ # Linear interpolation for x
+ x = x1 + (x2 - x1) * t
+
+ # Parabolic interpolation for y
+ # y = start + progress * (end - start) + arc_offset
+ # Arc offset peaks at t=0.5
+ arc_offset = 4 * height * t * (1 - t)
+ y = y1 + (y2 - y1) * t - arc_offset
+
+ return (x, y)
+
+
+# Add new easing functions to the convenience mapping
+EASING_FUNCTIONS.update(
+ {
+ "back_in": ease_back_in,
+ "back_out": ease_back_out,
+ "back_in_out": ease_back_in_out,
+ "anticipate": ease_back_in, # Alias
+ "overshoot": ease_back_out, # Alias
+ }
+)
diff --git a/tests/llm_translation/test_skills_data/slack-gif-creator/core/frame_composer.py b/tests/llm_translation/test_skills_data/slack-gif-creator/core/frame_composer.py
new file mode 100644
index 00000000000..1afe434811b
--- /dev/null
+++ b/tests/llm_translation/test_skills_data/slack-gif-creator/core/frame_composer.py
@@ -0,0 +1,176 @@
+#!/usr/bin/env python3
+"""
+Frame Composer - Utilities for composing visual elements into frames.
+
+Provides functions for drawing shapes, text, emojis, and compositing elements
+together to create animation frames.
+"""
+
+from typing import Optional
+
+import numpy as np
+from PIL import Image, ImageDraw, ImageFont
+
+
+def create_blank_frame(
+ width: int, height: int, color: tuple[int, int, int] = (255, 255, 255)
+) -> Image.Image:
+ """
+ Create a blank frame with solid color background.
+
+ Args:
+ width: Frame width
+ height: Frame height
+ color: RGB color tuple (default: white)
+
+ Returns:
+ PIL Image
+ """
+ return Image.new("RGB", (width, height), color)
+
+
+def draw_circle(
+ frame: Image.Image,
+ center: tuple[int, int],
+ radius: int,
+ fill_color: Optional[tuple[int, int, int]] = None,
+ outline_color: Optional[tuple[int, int, int]] = None,
+ outline_width: int = 1,
+) -> Image.Image:
+ """
+ Draw a circle on a frame.
+
+ Args:
+ frame: PIL Image to draw on
+ center: (x, y) center position
+ radius: Circle radius
+ fill_color: RGB fill color (None for no fill)
+ outline_color: RGB outline color (None for no outline)
+ outline_width: Outline width in pixels
+
+ Returns:
+ Modified frame
+ """
+ draw = ImageDraw.Draw(frame)
+ x, y = center
+ bbox = [x - radius, y - radius, x + radius, y + radius]
+ draw.ellipse(bbox, fill=fill_color, outline=outline_color, width=outline_width)
+ return frame
+
+
+def draw_text(
+ frame: Image.Image,
+ text: str,
+ position: tuple[int, int],
+ color: tuple[int, int, int] = (0, 0, 0),
+ centered: bool = False,
+) -> Image.Image:
+ """
+ Draw text on a frame.
+
+ Args:
+ frame: PIL Image to draw on
+ text: Text to draw
+ position: (x, y) position (top-left unless centered=True)
+ color: RGB text color
+ centered: If True, center text at position
+
+ Returns:
+ Modified frame
+ """
+ draw = ImageDraw.Draw(frame)
+
+ # Uses Pillow's default font.
+ # If the font should be changed for the emoji, add additional logic here.
+ font = ImageFont.load_default()
+
+ if centered:
+ bbox = draw.textbbox((0, 0), text, font=font)
+ text_width = bbox[2] - bbox[0]
+ text_height = bbox[3] - bbox[1]
+ x = position[0] - text_width // 2
+ y = position[1] - text_height // 2
+ position = (x, y)
+
+ draw.text(position, text, fill=color, font=font)
+ return frame
+
+
+def create_gradient_background(
+ width: int,
+ height: int,
+ top_color: tuple[int, int, int],
+ bottom_color: tuple[int, int, int],
+) -> Image.Image:
+ """
+ Create a vertical gradient background.
+
+ Args:
+ width: Frame width
+ height: Frame height
+ top_color: RGB color at top
+ bottom_color: RGB color at bottom
+
+ Returns:
+ PIL Image with gradient
+ """
+ frame = Image.new("RGB", (width, height))
+ draw = ImageDraw.Draw(frame)
+
+ # Calculate color step for each row
+ r1, g1, b1 = top_color
+ r2, g2, b2 = bottom_color
+
+ for y in range(height):
+ # Interpolate color
+ ratio = y / height
+ r = int(r1 * (1 - ratio) + r2 * ratio)
+ g = int(g1 * (1 - ratio) + g2 * ratio)
+ b = int(b1 * (1 - ratio) + b2 * ratio)
+
+ # Draw horizontal line
+ draw.line([(0, y), (width, y)], fill=(r, g, b))
+
+ return frame
+
+
+def draw_star(
+ frame: Image.Image,
+ center: tuple[int, int],
+ size: int,
+ fill_color: tuple[int, int, int],
+ outline_color: Optional[tuple[int, int, int]] = None,
+ outline_width: int = 1,
+) -> Image.Image:
+ """
+ Draw a 5-pointed star.
+
+ Args:
+ frame: PIL Image to draw on
+ center: (x, y) center position
+ size: Star size (outer radius)
+ fill_color: RGB fill color
+ outline_color: RGB outline color (None for no outline)
+ outline_width: Outline width
+
+ Returns:
+ Modified frame
+ """
+ import math
+
+ draw = ImageDraw.Draw(frame)
+ x, y = center
+
+ # Calculate star points
+ points = []
+ for i in range(10):
+ angle = (i * 36 - 90) * math.pi / 180 # 36 degrees per point, start at top
+ radius = size if i % 2 == 0 else size * 0.4 # Alternate between outer and inner
+ px = x + radius * math.cos(angle)
+ py = y + radius * math.sin(angle)
+ points.append((px, py))
+
+ # Draw star
+ draw.polygon(points, fill=fill_color, outline=outline_color, width=outline_width)
+
+ return frame
diff --git a/tests/llm_translation/test_skills_data/slack-gif-creator/core/gif_builder.py b/tests/llm_translation/test_skills_data/slack-gif-creator/core/gif_builder.py
new file mode 100644
index 00000000000..5759f144fe3
--- /dev/null
+++ b/tests/llm_translation/test_skills_data/slack-gif-creator/core/gif_builder.py
@@ -0,0 +1,269 @@
+#!/usr/bin/env python3
+"""
+GIF Builder - Core module for assembling frames into GIFs optimized for Slack.
+
+This module provides the main interface for creating GIFs from programmatically
+generated frames, with automatic optimization for Slack's requirements.
+"""
+
+from pathlib import Path
+from typing import Optional
+
+import imageio.v3 as imageio
+import numpy as np
+from PIL import Image
+
+
+class GIFBuilder:
+ """Builder for creating optimized GIFs from frames."""
+
+ def __init__(self, width: int = 480, height: int = 480, fps: int = 15):
+ """
+ Initialize GIF builder.
+
+ Args:
+ width: Frame width in pixels
+ height: Frame height in pixels
+ fps: Frames per second
+ """
+ self.width = width
+ self.height = height
+ self.fps = fps
+ self.frames: list[np.ndarray] = []
+
+ def add_frame(self, frame: np.ndarray | Image.Image):
+ """
+ Add a frame to the GIF.
+
+ Args:
+ frame: Frame as numpy array or PIL Image (will be converted to RGB)
+ """
+ if isinstance(frame, Image.Image):
+ frame = np.array(frame.convert("RGB"))
+
+ # Ensure frame is correct size
+ if frame.shape[:2] != (self.height, self.width):
+ pil_frame = Image.fromarray(frame)
+ pil_frame = pil_frame.resize(
+ (self.width, self.height), Image.Resampling.LANCZOS
+ )
+ frame = np.array(pil_frame)
+
+ self.frames.append(frame)
+
+ def add_frames(self, frames: list[np.ndarray | Image.Image]):
+ """Add multiple frames at once."""
+ for frame in frames:
+ self.add_frame(frame)
+
+ def optimize_colors(
+ self, num_colors: int = 128, use_global_palette: bool = True
+ ) -> list[np.ndarray]:
+ """
+ Reduce colors in all frames using quantization.
+
+ Args:
+ num_colors: Target number of colors (8-256)
+ use_global_palette: Use a single palette for all frames (better compression)
+
+ Returns:
+ List of color-optimized frames
+ """
+ optimized = []
+
+ if use_global_palette and len(self.frames) > 1:
+ # Create a global palette from all frames
+ # Sample frames to build palette
+ sample_size = min(5, len(self.frames))
+ sample_indices = [
+ int(i * len(self.frames) / sample_size) for i in range(sample_size)
+ ]
+ sample_frames = [self.frames[i] for i in sample_indices]
+
+ # Combine sample frames into a single image for palette generation
+ # Flatten each frame to get all pixels, then stack them
+ all_pixels = np.vstack(
+ [f.reshape(-1, 3) for f in sample_frames]
+ ) # (total_pixels, 3)
+
+ # Create a properly-shaped RGB image from the pixel data
+ # We'll make a roughly square image from all the pixels
+ total_pixels = len(all_pixels)
+ width = min(512, int(np.sqrt(total_pixels))) # Reasonable width, max 512
+ height = (total_pixels + width - 1) // width # Ceiling division
+
+ # Pad if necessary to fill the rectangle
+ pixels_needed = width * height
+ if pixels_needed > total_pixels:
+ padding = np.zeros((pixels_needed - total_pixels, 3), dtype=np.uint8)
+ all_pixels = np.vstack([all_pixels, padding])
+
+ # Reshape to proper RGB image format (H, W, 3)
+ img_array = (
+ all_pixels[:pixels_needed].reshape(height, width, 3).astype(np.uint8)
+ )
+ combined_img = Image.fromarray(img_array, mode="RGB")
+
+ # Generate global palette
+ global_palette = combined_img.quantize(colors=num_colors, method=2)
+
+ # Apply global palette to all frames
+ for frame in self.frames:
+ pil_frame = Image.fromarray(frame)
+ quantized = pil_frame.quantize(palette=global_palette, dither=1)
+ optimized.append(np.array(quantized.convert("RGB")))
+ else:
+ # Use per-frame quantization
+ for frame in self.frames:
+ pil_frame = Image.fromarray(frame)
+ quantized = pil_frame.quantize(colors=num_colors, method=2, dither=1)
+ optimized.append(np.array(quantized.convert("RGB")))
+
+ return optimized
+
+ def deduplicate_frames(self, threshold: float = 0.9995) -> int:
+ """
+ Remove duplicate or near-duplicate consecutive frames.
+
+ Args:
+ threshold: Similarity threshold (0.0-1.0). Higher = more strict (0.9995 = nearly identical).
+ Use 0.9995+ to preserve subtle animations, 0.98 for aggressive removal.
+
+ Returns:
+ Number of frames removed
+ """
+ if len(self.frames) < 2:
+ return 0
+
+ deduplicated = [self.frames[0]]
+ removed_count = 0
+
+ for i in range(1, len(self.frames)):
+ # Compare with previous frame
+ prev_frame = np.array(deduplicated[-1], dtype=np.float32)
+ curr_frame = np.array(self.frames[i], dtype=np.float32)
+
+ # Calculate similarity (normalized)
+ diff = np.abs(prev_frame - curr_frame)
+ similarity = 1.0 - (np.mean(diff) / 255.0)
+
+ # Keep frame if sufficiently different
+ # High threshold (0.9995+) means only remove nearly identical frames
+ if similarity < threshold:
+ deduplicated.append(self.frames[i])
+ else:
+ removed_count += 1
+
+ self.frames = deduplicated
+ return removed_count
+
+ def save(
+ self,
+ output_path: str | Path,
+ num_colors: int = 128,
+ optimize_for_emoji: bool = False,
+ remove_duplicates: bool = False,
+ ) -> dict:
+ """
+ Save frames as optimized GIF for Slack.
+
+ Args:
+ output_path: Where to save the GIF
+ num_colors: Number of colors to use (fewer = smaller file)
+ optimize_for_emoji: If True, optimize for emoji size (128x128, fewer colors)
+ remove_duplicates: If True, remove duplicate consecutive frames (opt-in)
+
+ Returns:
+ Dictionary with file info (path, size, dimensions, frame_count)
+ """
+ if not self.frames:
+ raise ValueError("No frames to save. Add frames with add_frame() first.")
+
+ output_path = Path(output_path)
+
+ # Remove duplicate frames to reduce file size
+ if remove_duplicates:
+ removed = self.deduplicate_frames(threshold=0.9995)
+ if removed > 0:
+ print(
+ f" Removed {removed} nearly identical frames (preserved subtle animations)"
+ )
+
+ # Optimize for emoji if requested
+ if optimize_for_emoji:
+ if self.width > 128 or self.height > 128:
+ print(
+ f" Resizing from {self.width}x{self.height} to 128x128 for emoji"
+ )
+ self.width = 128
+ self.height = 128
+ # Resize all frames
+ resized_frames = []
+ for frame in self.frames:
+ pil_frame = Image.fromarray(frame)
+ pil_frame = pil_frame.resize((128, 128), Image.Resampling.LANCZOS)
+ resized_frames.append(np.array(pil_frame))
+ self.frames = resized_frames
+ num_colors = min(num_colors, 48) # More aggressive color limit for emoji
+
+ # More aggressive FPS reduction for emoji
+ if len(self.frames) > 12:
+ print(
+ f" Reducing frames from {len(self.frames)} to ~12 for emoji size"
+ )
+ # Keep every nth frame to get close to 12 frames
+ keep_every = max(1, len(self.frames) // 12)
+ self.frames = [
+ self.frames[i] for i in range(0, len(self.frames), keep_every)
+ ]
+
+ # Optimize colors with global palette
+ optimized_frames = self.optimize_colors(num_colors, use_global_palette=True)
+
+ # Calculate frame duration in milliseconds
+ frame_duration = 1000 / self.fps
+
+ # Save GIF
+ imageio.imwrite(
+ output_path,
+ optimized_frames,
+ duration=frame_duration,
+ loop=0, # Infinite loop
+ )
+
+ # Get file info
+ file_size_kb = output_path.stat().st_size / 1024
+ file_size_mb = file_size_kb / 1024
+
+ info = {
+ "path": str(output_path),
+ "size_kb": file_size_kb,
+ "size_mb": file_size_mb,
+ "dimensions": f"{self.width}x{self.height}",
+ "frame_count": len(optimized_frames),
+ "fps": self.fps,
+ "duration_seconds": len(optimized_frames) / self.fps,
+ "colors": num_colors,
+ }
+
+ # Print info
+ print(f"\n✓ GIF created successfully!")
+ print(f" Path: {output_path}")
+ print(f" Size: {file_size_kb:.1f} KB ({file_size_mb:.2f} MB)")
+ print(f" Dimensions: {self.width}x{self.height}")
+ print(f" Frames: {len(optimized_frames)} @ {self.fps} fps")
+ print(f" Duration: {info['duration_seconds']:.1f}s")
+ print(f" Colors: {num_colors}")
+
+ # Size info
+ if optimize_for_emoji:
+ print(f" Optimized for emoji (128x128, reduced colors)")
+ if file_size_mb > 1.0:
+ print(f"\n Note: Large file size ({file_size_kb:.1f} KB)")
+ print(" Consider: fewer frames, smaller dimensions, or fewer colors")
+
+ return info
+
+ def clear(self):
+ """Clear all frames (useful for creating multiple GIFs)."""
+ self.frames = []
diff --git a/tests/llm_translation/test_skills_data/slack-gif-creator/core/validators.py b/tests/llm_translation/test_skills_data/slack-gif-creator/core/validators.py
new file mode 100644
index 00000000000..a6f5bdf28dd
--- /dev/null
+++ b/tests/llm_translation/test_skills_data/slack-gif-creator/core/validators.py
@@ -0,0 +1,136 @@
+#!/usr/bin/env python3
+"""
+Validators - Check if GIFs meet Slack's requirements.
+
+These validators help ensure your GIFs meet Slack's size and dimension constraints.
+"""
+
+from pathlib import Path
+
+
+def validate_gif(
+ gif_path: str | Path, is_emoji: bool = True, verbose: bool = True
+) -> tuple[bool, dict]:
+ """
+ Validate GIF for Slack (dimensions, size, frame count).
+
+ Args:
+ gif_path: Path to GIF file
+ is_emoji: True for emoji (128x128 recommended), False for message GIF
+ verbose: Print validation details
+
+ Returns:
+ Tuple of (passes: bool, results: dict with all details)
+ """
+ from PIL import Image
+
+ gif_path = Path(gif_path)
+
+ if not gif_path.exists():
+ return False, {"error": f"File not found: {gif_path}"}
+
+ # Get file size
+ size_bytes = gif_path.stat().st_size
+ size_kb = size_bytes / 1024
+ size_mb = size_kb / 1024
+
+ # Get dimensions and frame info
+ try:
+ with Image.open(gif_path) as img:
+ width, height = img.size
+
+ # Count frames
+ frame_count = 0
+ try:
+ while True:
+ img.seek(frame_count)
+ frame_count += 1
+ except EOFError:
+ pass
+
+ # Get duration
+ try:
+ duration_ms = img.info.get("duration", 100)
+ total_duration = (duration_ms * frame_count) / 1000
+ fps = frame_count / total_duration if total_duration > 0 else 0
+ except:
+ total_duration = None
+ fps = None
+
+ except Exception as e:
+ return False, {"error": f"Failed to read GIF: {e}"}
+
+ # Validate dimensions
+ if is_emoji:
+ optimal = width == height == 128
+ acceptable = width == height and 64 <= width <= 128
+ dim_pass = acceptable
+ else:
+ aspect_ratio = (
+ max(width, height) / min(width, height)
+ if min(width, height) > 0
+ else float("inf")
+ )
+ dim_pass = aspect_ratio <= 2.0 and 320 <= min(width, height) <= 640
+
+ results = {
+ "file": str(gif_path),
+ "passes": dim_pass,
+ "width": width,
+ "height": height,
+ "size_kb": size_kb,
+ "size_mb": size_mb,
+ "frame_count": frame_count,
+ "duration_seconds": total_duration,
+ "fps": fps,
+ "is_emoji": is_emoji,
+ "optimal": optimal if is_emoji else None,
+ }
+
+ # Print if verbose
+ if verbose:
+ print(f"\nValidating {gif_path.name}:")
+ print(
+ f" Dimensions: {width}x{height}"
+ + (
+ f" ({'optimal' if optimal else 'acceptable'})"
+ if is_emoji and acceptable
+ else ""
+ )
+ )
+ print(
+ f" Size: {size_kb:.1f} KB"
+ + (f" ({size_mb:.2f} MB)" if size_mb >= 1.0 else "")
+ )
+ print(
+ f" Frames: {frame_count}"
+ + (f" @ {fps:.1f} fps ({total_duration:.1f}s)" if fps else "")
+ )
+
+ if not dim_pass:
+ print(
+ f" Note: {'Emoji should be 128x128' if is_emoji else 'Unusual dimensions for Slack'}"
+ )
+
+ if size_mb > 5.0:
+ print(f" Note: Large file size - consider fewer frames/colors")
+
+ return dim_pass, results
+
+
+def is_slack_ready(
+ gif_path: str | Path, is_emoji: bool = True, verbose: bool = True
+) -> bool:
+ """
+ Quick check if GIF is ready for Slack.
+
+ Args:
+ gif_path: Path to GIF file
+ is_emoji: True for emoji GIF, False for message GIF
+ verbose: Print feedback
+
+ Returns:
+ True if dimensions are acceptable
+ """
+ passes, _ = validate_gif(gif_path, is_emoji, verbose)
+ return passes
diff --git a/tests/llm_translation/test_skills_data/slack-gif-creator/requirements.txt b/tests/llm_translation/test_skills_data/slack-gif-creator/requirements.txt
new file mode 100644
index 00000000000..8bc4493e916
--- /dev/null
+++ b/tests/llm_translation/test_skills_data/slack-gif-creator/requirements.txt
@@ -0,0 +1,4 @@
+pillow>=10.0.0
+imageio>=2.31.0
+imageio-ffmpeg>=0.4.9
+numpy>=1.24.0
\ No newline at end of file
diff --git a/tests/llm_translation/test_skills_e2e.py b/tests/llm_translation/test_skills_e2e.py
new file mode 100644
index 00000000000..9329919ae21
--- /dev/null
+++ b/tests/llm_translation/test_skills_e2e.py
@@ -0,0 +1,187 @@
+"""
+End-to-end test for LiteLLM Skills with Messages API.
+
+Tests the slack-gif-creator skill with GPT-4o via messages API
+to verify skills work correctly and can generate a GIF.
+"""
+
+import os
+import sys
+import zipfile
+from io import BytesIO
+from pathlib import Path
+
+import pytest
+
+sys.path.insert(0, os.path.abspath("../.."))
+
+import litellm
+import litellm.proxy.proxy_server
+from litellm.caching.caching import DualCache
+from litellm.proxy._types import NewSkillRequest, UserAPIKeyAuth
+from litellm.proxy.utils import PrismaClient, ProxyLogging
+
+proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache())
+
+
+def create_skill_zip_from_folder(skill_name: str) -> bytes:
+ """Create a ZIP file from a skill folder in test_skills_data."""
+ test_dir = Path(__file__).parent / "test_skills_data"
+ skill_dir = test_dir / skill_name
+
+ zip_buffer = BytesIO()
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+ for file_path in skill_dir.rglob("*"):
+ if file_path.is_file():
+ arcname = f"{skill_name}/{file_path.relative_to(skill_dir)}"
+ zf.write(file_path, arcname=arcname)
+
+ return zip_buffer.getvalue()
+
+
+@pytest.fixture
+def prisma_client():
+ """Set up prisma client for tests."""
+ from litellm.proxy.proxy_cli import append_query_params
+
+ params = {"connection_limit": 100, "pool_timeout": 60}
+ database_url = os.getenv("DATABASE_URL")
+ if not database_url:
+ pytest.skip("DATABASE_URL not set")
+
+ modified_url = append_query_params(database_url, params)
+ os.environ["DATABASE_URL"] = modified_url
+
+ prisma_client = PrismaClient(
+ database_url=os.environ["DATABASE_URL"], proxy_logging_obj=proxy_logging_obj
+ )
+
+ return prisma_client
+
+
+@pytest.mark.asyncio
+async def test_slack_gif_skill_creates_gif(prisma_client):
+ """
+ Test slack-gif-creator skill generates a GIF using GPT-4o via messages API.
+
+ Flow:
+ 1. Store skill in LiteLLM DB
+ 2. Hook resolves skill, adds litellm_code_execution tool, injects SKILL.md
+ 3. Make GPT-4o call via messages API
+ 4. Hook handles code execution loop
+ 5. Verify GIF is generated
+ """
+ litellm._turn_on_debug()
+ if not os.getenv("OPENAI_API_KEY"):
+ pytest.skip("OPENAI_API_KEY not set")
+
+ setattr(litellm.proxy.proxy_server, "prisma_client", prisma_client)
+ await litellm.proxy.proxy_server.prisma_client.connect()
+
+ from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler
+ from litellm.proxy.hooks.litellm_skills import SkillsInjectionHook
+ from litellm.types.utils import CallTypes
+
+ # 1. Store skill in DB
+ skill_name = "slack-gif-creator"
+ zip_content = create_skill_zip_from_folder(skill_name)
+
+ skill_request = NewSkillRequest(
+ display_title="Slack GIF Creator",
+ description="Create animated GIFs optimized for Slack",
+ instructions="Use this skill to create animated GIFs for Slack emoji",
+ file_content=zip_content,
+ file_name=f"{skill_name}.zip",
+ file_type="application/zip",
+ )
+ created_skill = await LiteLLMSkillsHandler.create_skill(
+ data=skill_request,
+ user_id="test_user",
+ )
+
+ print(f"\nCreated skill: {created_skill.skill_id}")
+
+ hook = SkillsInjectionHook()
+
+ try:
+ # 2. Build request with container.skills (messages API spec)
+ request_data = {
+ "model": "claude-sonnet-4-5",
+ "max_tokens": 4096,
+ "messages": [
+ {
+ "role": "user",
+ "content": "Create a simple bouncing red ball GIF for Slack emoji."
+ }
+ ],
+ "container": {
+ "skills": [
+ {"type": "custom", "skill_id": f"litellm:{created_skill.skill_id}"}
+ ]
+ },
+ }
+
+ # 3. Pre-call hook resolves skill
+ user_api_key_dict = UserAPIKeyAuth(api_key="test-key")
+ cache = DualCache()
+
+ transformed = await hook.async_pre_call_hook(
+ user_api_key_dict=user_api_key_dict,
+ cache=cache,
+ data=request_data,
+ call_type="anthropic_messages",
+ )
+ assert isinstance(transformed, dict)
+
+ # Hook returns Anthropic-format tools for messages API
+ tool_names = [t.get('name') for t in transformed.get('tools', [])]
+ print(f"\nTools after hook: {tool_names}")
+ assert "litellm_code_execution" in tool_names, "Should have litellm_code_execution tool"
+
+ # 4. Make GPT-4o call via messages API (tools already in Anthropic format)
+ print("\n--- Making GPT-4o call via messages API ---")
+ response = await litellm.anthropic.acreate(
+ model=transformed["model"],
+ max_tokens=transformed.get("max_tokens", 4096),
+ messages=transformed["messages"],
+ tools=transformed.get("tools"),
+ )
+
+ print(f"Initial response: {response}")
+
+ # 5. Post-call hook handles code execution loop
+ final_response = await hook.async_post_call_success_deployment_hook(
+ request_data=transformed,
+ response=response,
+ call_type=CallTypes.anthropic_messages,
+ )
+
+ if final_response:
+ response = final_response
+ print("Code execution completed!")
+
+ # 6. Check for generated files (handle both dict and object response)
+ if isinstance(response, dict):
+ generated_files = response.get("_litellm_generated_files", [])
+ else:
+ generated_files = getattr(response, "_litellm_generated_files", [])
+ print(f"\nGenerated files: {len(generated_files)}")
+
+ if generated_files:
+ import base64
+ for f in generated_files:
+ print(f" - {f['name']} ({f['size']} bytes)")
+ if f['name'].endswith('.gif'):
+ content = base64.b64decode(f['content_base64'])
+ assert content[:6] in [b'GIF89a', b'GIF87a'], "Should be valid GIF"
+ print(" Valid GIF!")
+ print("\nSUCCESS - GIF generated!")
+ else:
+ # Print response for debugging
+ if hasattr(response, "choices"):
+ print(f"\nResponse: {response.choices[0].message}")
+ else:
+ print(f"\nResponse: {response}")
+
+ finally:
+ await LiteLLMSkillsHandler.delete_skill(skill_id=created_skill.skill_id)
diff --git a/tests/proxy_unit_tests/test_skills_db.py b/tests/proxy_unit_tests/test_skills_db.py
new file mode 100644
index 00000000000..ec72087849d
--- /dev/null
+++ b/tests/proxy_unit_tests/test_skills_db.py
@@ -0,0 +1,257 @@
+"""
+Test LiteLLM Skills SDK with custom_llm_provider=litellm_proxy
+
+Tests the SDK-level skills methods when using the LiteLLM database backend:
+1. Create a skill using SDK and verify it was stored correctly
+2. List skills using SDK
+3. Get a skill by ID using SDK
+4. Delete a skill using SDK
+5. Skills injection hook correctly resolves skills from database
+"""
+
+import os
+import sys
+import zipfile
+from contextlib import contextmanager
+from io import BytesIO
+from pathlib import Path
+
+import pytest
+
+sys.path.insert(0, os.path.abspath("../.."))
+
+import litellm
+from litellm.caching.caching import DualCache
+from litellm.proxy import proxy_server
+from litellm.proxy._types import UserAPIKeyAuth
+from litellm.proxy.utils import PrismaClient, ProxyLogging
+from litellm.types.utils import LlmProviders
+
+proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache())
+
+
+@contextmanager
+def create_skill_zip(skill_name: str):
+ """
+ Helper context manager to create a zip file for a skill.
+
+ Args:
+ skill_name: Name of the skill directory in test_skills_data/
+
+ Yields:
+ Tuple of (file handle, file content bytes)
+
+ The zip file is automatically cleaned up after use.
+ """
+ test_dir = Path(__file__).parent.parent / "llm_translation" / "test_skills_data"
+ skill_dir = test_dir / skill_name
+
+ # Create a zip file containing the skill directory
+ zip_path = test_dir / f"{skill_name}.zip"
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zip_file:
+ zip_file.write(skill_dir, arcname=skill_name)
+ zip_file.write(skill_dir / "SKILL.md", arcname=f"{skill_name}/SKILL.md")
+
+ try:
+ with open(zip_path, "rb") as f:
+ content = f.read()
+ f.seek(0)
+ yield f, content
+ finally:
+ # Clean up zip file
+ if zip_path.exists():
+ zip_path.unlink()
+
+
+@pytest.fixture
+def prisma_client():
+ """Set up prisma client for tests."""
+ from litellm.proxy.proxy_cli import append_query_params
+
+ params = {"connection_limit": 100, "pool_timeout": 60}
+ database_url = os.getenv("DATABASE_URL")
+ modified_url = append_query_params(database_url, params)
+ os.environ["DATABASE_URL"] = modified_url
+
+ prisma_client = PrismaClient(
+ database_url=os.environ["DATABASE_URL"], proxy_logging_obj=proxy_logging_obj
+ )
+
+ return prisma_client
+
+
+@pytest.mark.asyncio
+async def test_create_skill_sdk(prisma_client):
+ """
+ Test creating a skill using SDK with custom_llm_provider=litellm_proxy.
+
+ Verifies that:
+ - Skill is created with correct display_title
+ - Skill ID is generated and returned
+ - Skill response has correct type
+ """
+ setattr(proxy_server, "prisma_client", prisma_client)
+ await proxy_server.prisma_client.connect()
+
+ from litellm.skills.main import acreate_skill, adelete_skill
+
+ # Create a skill using SDK
+ skill = await acreate_skill(
+ display_title="SDK Test Skill",
+ extra_body={
+ "description": "A test skill created via SDK",
+ "instructions": "Use this skill for SDK testing",
+ },
+ custom_llm_provider=LlmProviders.LITELLM_PROXY.value,
+ )
+
+ # Verify skill was created correctly
+ assert skill is not None
+ assert skill.id is not None
+ assert skill.id.startswith("skill_")
+ assert skill.display_title == "SDK Test Skill"
+ assert skill.type == "skill"
+ assert skill.source == "custom"
+
+ # Clean up
+ await adelete_skill(
+ skill_id=skill.id,
+ custom_llm_provider=LlmProviders.LITELLM_PROXY.value,
+ )
+
+
+@pytest.mark.asyncio
+async def test_list_skills_sdk(prisma_client):
+ """
+ Test listing skills using SDK with custom_llm_provider=litellm_proxy.
+
+ Verifies that:
+ - Multiple skills can be created
+ - List returns the created skills
+ """
+ setattr(proxy_server, "prisma_client", prisma_client)
+ await proxy_server.prisma_client.connect()
+
+ from litellm.skills.main import acreate_skill, adelete_skill, alist_skills
+
+ # Create multiple skills
+ created_skill_ids = []
+ for i in range(3):
+ skill = await acreate_skill(
+ display_title=f"List Test Skill {i}",
+ extra_body={
+ "description": f"Test skill {i} for list test",
+ },
+ custom_llm_provider=LlmProviders.LITELLM_PROXY.value,
+ )
+ created_skill_ids.append(skill.id)
+
+ # List skills using SDK
+ response = await alist_skills(
+ limit=10,
+ custom_llm_provider=LlmProviders.LITELLM_PROXY.value,
+ )
+
+ # Verify we got skills back
+ assert response is not None
+ assert response.data is not None
+ assert len(response.data) >= 3
+
+ # Verify our created skills are in the list
+ skill_ids_in_list = [s.id for s in response.data]
+ for created_id in created_skill_ids:
+ assert created_id in skill_ids_in_list
+
+ # Clean up
+ for skill_id in created_skill_ids:
+ await adelete_skill(
+ skill_id=skill_id,
+ custom_llm_provider=LlmProviders.LITELLM_PROXY.value,
+ )
+
+
+@pytest.mark.asyncio
+async def test_get_skill_sdk(prisma_client):
+ """
+ Test getting a skill by ID using SDK with custom_llm_provider=litellm_proxy.
+
+ Verifies that:
+ - Skill can be retrieved by ID
+ - Retrieved skill has correct data
+ """
+ setattr(proxy_server, "prisma_client", prisma_client)
+ await proxy_server.prisma_client.connect()
+
+ from litellm.skills.main import acreate_skill, adelete_skill, aget_skill
+
+ # Create a skill
+ created_skill = await acreate_skill(
+ display_title="Get Test Skill",
+ extra_body={
+ "description": "A skill for get test",
+ },
+ custom_llm_provider=LlmProviders.LITELLM_PROXY.value,
+ )
+
+ # Get the skill by ID using SDK
+ retrieved_skill = await aget_skill(
+ skill_id=created_skill.id,
+ custom_llm_provider=LlmProviders.LITELLM_PROXY.value,
+ )
+
+ # Verify retrieved skill matches created skill
+ assert retrieved_skill is not None
+ assert retrieved_skill.id == created_skill.id
+ assert retrieved_skill.display_title == "Get Test Skill"
+
+ # Clean up
+ await adelete_skill(
+ skill_id=created_skill.id,
+ custom_llm_provider=LlmProviders.LITELLM_PROXY.value,
+ )
+
+
+@pytest.mark.asyncio
+async def test_delete_skill_sdk(prisma_client):
+ """
+ Test deleting a skill using SDK with custom_llm_provider=litellm_proxy.
+
+ Verifies that:
+ - Skill can be deleted by ID
+ - Deleted skill cannot be retrieved
+ """
+ setattr(proxy_server, "prisma_client", prisma_client)
+ await proxy_server.prisma_client.connect()
+
+ from litellm.skills.main import acreate_skill, adelete_skill, aget_skill
+
+ # Create a skill
+ created_skill = await acreate_skill(
+ display_title="Delete Test Skill",
+ extra_body={
+ "description": "A skill to be deleted",
+ },
+ custom_llm_provider=LlmProviders.LITELLM_PROXY.value,
+ )
+
+ # Verify skill exists
+ retrieved = await aget_skill(
+ skill_id=created_skill.id,
+ custom_llm_provider=LlmProviders.LITELLM_PROXY.value,
+ )
+ assert retrieved is not None
+
+ # Delete the skill using SDK
+ result = await adelete_skill(
+ skill_id=created_skill.id,
+ custom_llm_provider=LlmProviders.LITELLM_PROXY.value,
+ )
+ assert result.id == created_skill.id
+ assert result.type == "skill_deleted"
+
+ # Verify skill no longer exists
+ with pytest.raises(Exception):
+ await aget_skill(
+ skill_id=created_skill.id,
+ custom_llm_provider=LlmProviders.LITELLM_PROXY.value,
+ )