From 09b5ee94284a38311834dbfd1bf0752c075b6ccc Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Fri, 19 Dec 2025 18:55:59 +0530 Subject: [PATCH] [Feat] Unified Skills API - works across Anthropic, Vertex, Azure, Bedrock (#18232) * init LiteLLM_SkillsTable * init LiteLLMSkillsTransformationHandler * init LiteLLMSkillsTransformationHandler * init skills * init SkillsInjectionHook * init litellm skills handler * _fetch_skill_from_db * LiteLLMSkillsTransformationHandler * add _prisma_skill_to_litellm * use SkillPromptInjectionHandler * refactor skills handler * add slack GIF * test_skill_code_execution_via_deployment_hook * init readme * test_get_skill_sdk * test skills in DB * fix code qa * init with containers param * SkillsInjectionHook * fix type * convert_skill_to_anthropic_tool, get_litellm_code_execution_tool_anthropic * fix messages skills handler * test_slack_gif_skill_creates_gif * init SkillsInjectionHook * clean async_pre_call_hook * fixes * working unified skills API! yeet * fix mypy linting * LiteLLM_SkillsTable * llm-sandbox==0.3.31 * fix --- .../litellm_proxy_extras/schema.prisma | 18 + .../anthropic_interface/messages/__init__.py | 5 + .../messages/handler.py | 4 + litellm/llms/litellm_proxy/skills/README.md | 381 ++++++++ litellm/llms/litellm_proxy/skills/__init__.py | 54 ++ .../litellm_proxy/skills/code_execution.py | 311 +++++++ .../llms/litellm_proxy/skills/constants.py | 13 + litellm/llms/litellm_proxy/skills/handler.py | 219 +++++ .../litellm_proxy/skills/prompt_injection.py | 305 ++++++ .../litellm_proxy/skills/sandbox_executor.py | 286 ++++++ .../litellm_proxy/skills/transformation.py | 336 +++++++ litellm/proxy/_types.py | 54 ++ litellm/proxy/hooks/__init__.py | 2 + .../proxy/hooks/litellm_skills/__init__.py | 39 + litellm/proxy/hooks/litellm_skills/main.py | 869 ++++++++++++++++++ litellm/proxy/proxy_config.yaml | 9 +- litellm/proxy/schema.prisma | 18 + litellm/skills/main.py | 85 +- litellm/types/llms/anthropic.py | 1 + litellm/types/llms/openai.py | 1 + requirements.txt | 1 + schema.prisma | 18 + .../test_skills_data/slack-gif-creator.zip | Bin 0 -> 16748 bytes .../slack-gif-creator/LICENSE.txt | 202 ++++ .../slack-gif-creator/SKILL.md | 254 +++++ .../slack-gif-creator/core/__init__.py | 1 + .../slack-gif-creator/core/easing.py | 234 +++++ .../slack-gif-creator/core/frame_composer.py | 176 ++++ .../slack-gif-creator/core/gif_builder.py | 269 ++++++ .../slack-gif-creator/core/validators.py | 136 +++ .../slack-gif-creator/requirements.txt | 4 + tests/llm_translation/test_skills_e2e.py | 187 ++++ tests/proxy_unit_tests/test_skills_db.py | 257 ++++++ 33 files changed, 4727 insertions(+), 22 deletions(-) create mode 100644 litellm/llms/litellm_proxy/skills/README.md create mode 100644 litellm/llms/litellm_proxy/skills/__init__.py create mode 100644 litellm/llms/litellm_proxy/skills/code_execution.py create mode 100644 litellm/llms/litellm_proxy/skills/constants.py create mode 100644 litellm/llms/litellm_proxy/skills/handler.py create mode 100644 litellm/llms/litellm_proxy/skills/prompt_injection.py create mode 100644 litellm/llms/litellm_proxy/skills/sandbox_executor.py create mode 100644 litellm/llms/litellm_proxy/skills/transformation.py create mode 100644 litellm/proxy/hooks/litellm_skills/__init__.py create mode 100644 litellm/proxy/hooks/litellm_skills/main.py create mode 100644 tests/llm_translation/test_skills_data/slack-gif-creator.zip create mode 100644 tests/llm_translation/test_skills_data/slack-gif-creator/LICENSE.txt create mode 100644 tests/llm_translation/test_skills_data/slack-gif-creator/SKILL.md create mode 100644 tests/llm_translation/test_skills_data/slack-gif-creator/core/__init__.py create mode 100644 tests/llm_translation/test_skills_data/slack-gif-creator/core/easing.py create mode 100644 tests/llm_translation/test_skills_data/slack-gif-creator/core/frame_composer.py create mode 100644 tests/llm_translation/test_skills_data/slack-gif-creator/core/gif_builder.py create mode 100644 tests/llm_translation/test_skills_data/slack-gif-creator/core/validators.py create mode 100644 tests/llm_translation/test_skills_data/slack-gif-creator/requirements.txt create mode 100644 tests/llm_translation/test_skills_e2e.py create mode 100644 tests/proxy_unit_tests/test_skills_db.py 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 0000000000000000000000000000000000000000..15c60e3667d6d62e9c66b17977f7d25889a91571 GIT binary patch literal 16748 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�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 yuTF&D_ 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, + )