Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/hooks/litellm_skills/main.py: 16%
347 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1"""
2Skills Injection Hook for LiteLLM Proxy
4Main hook that orchestrates skill processing:
5- Fetches skills from LiteLLM DB
6- Injects SKILL.md content into system prompt
7- Adds litellm_code_execution tool for automatic code execution
8- Handles agentic loop internally when litellm_code_execution is called
10For non-Anthropic models (e.g., Bedrock, OpenAI, etc.):
11- Skills are converted to OpenAI-style tools
12- Skill file content (SKILL.md) is extracted and injected into the system prompt
13- litellm_code_execution tool is added - when model calls it, LiteLLM handles
14 execution automatically and returns final response with file_ids
16Usage:
17 # Simple - LiteLLM handles everything automatically via proxy
18 # The container parameter triggers the SkillsInjectionHook
19 response = await litellm.acompletion(
20 model="gpt-4o-mini",
21 messages=[{"role": "user", "content": "Create a bouncing ball GIF"}],
22 container={"skills": [{"skill_id": "litellm_skill_abc123"}]},
23 )
24 # Response includes file_ids for generated files
25"""
27import base64
28import json
29from collections.abc import Mapping, Sequence
30from typing import TYPE_CHECKING, Any, Final, Protocol
32import litellm
33from litellm._logging import verbose_proxy_logger
34from litellm.caching.caching import DualCache
35from litellm.integrations.custom_logger import CustomLogger
36from litellm.llms.litellm_proxy.skills.constants import LITELLM_SKILL_ID_PREFIX
37from litellm.llms.litellm_proxy.skills.prompt_injection import (
38 SkillPromptInjectionHandler,
39)
40from litellm.proxy._types import LiteLLM_SkillsTable, UserAPIKeyAuth
41from litellm.types.utils import CallTypes, CallTypesLiteral, LLMResponseTypes
43if TYPE_CHECKING: 43 ↛ 44line 43 didn't jump to line 44 because the condition on line 43 was never true
44 from litellm.llms.litellm_proxy.skills.sandbox_executor import SkillsSandboxExecutor
47class _ToolCallFunction(Protocol):
48 @property
49 def name(self) -> str: ... 49 ↛ exitline 49 didn't return from function 'name' because
51 @property
52 def arguments(self) -> str: ... 52 ↛ exitline 52 didn't return from function 'arguments' because
55class _ChatToolCall(Protocol):
56 @property
57 def id(self) -> str: ... 57 ↛ exitline 57 didn't return from function 'id' because
59 @property
60 def function(self) -> _ToolCallFunction: ... 60 ↛ exitline 60 didn't return from function 'function' because
63class _ChatMessage(Protocol):
64 @property
65 def content(self) -> str | None: ... 65 ↛ exitline 65 didn't return from function 'content' because
67 @property
68 def tool_calls(self) -> Sequence[_ChatToolCall] | None: ... 68 ↛ exitline 68 didn't return from function 'tool_calls' because
71class _ChatChoice(Protocol):
72 @property
73 def message(self) -> _ChatMessage: ... 73 ↛ exitline 73 didn't return from function 'message' because
75 @property
76 def finish_reason(self) -> str | None: ... 76 ↛ exitline 76 didn't return from function 'finish_reason' because
79class _ChatCompletion(Protocol):
80 @property
81 def choices(self) -> Sequence[_ChatChoice]: ... 81 ↛ exitline 81 didn't return from function 'choices' because
84def _first_choice(response: _ChatCompletion) -> _ChatChoice:
85 """The first choice of an OpenAI shaped completion response."""
86 return response.choices[0]
89class SkillsInjectionHook(CustomLogger):
90 """
91 Pre/Post-call hook that processes skills from container.skills parameter.
93 Pre-call (async_pre_call_hook):
94 - Skills with 'litellm_skill_' prefix are fetched from LiteLLM DB
95 - For Anthropic models: native skills pass through, LiteLLM skills converted to tools
96 - For non-Anthropic models: LiteLLM skills are converted to tools + execute_code tool
98 Post-call (async_post_call_success_deployment_hook):
99 - If response has litellm_code_execution tool call, automatically execute code
100 - Continue conversation loop until model gives final response
101 - Return response with generated files inline
103 This hook is called automatically by litellm during completion calls.
104 """
106 def __init__(self, **kwargs):
107 from litellm.llms.litellm_proxy.skills.constants import (
108 DEFAULT_MAX_ITERATIONS,
109 DEFAULT_SANDBOX_TIMEOUT,
110 )
112 self.optional_params = kwargs
113 self.prompt_handler = SkillPromptInjectionHandler()
114 self.max_iterations = kwargs.get("max_iterations", DEFAULT_MAX_ITERATIONS)
115 self.sandbox_timeout = kwargs.get("sandbox_timeout", DEFAULT_SANDBOX_TIMEOUT)
116 super().__init__(**kwargs)
118 async def async_pre_call_hook(
119 self,
120 user_api_key_dict: UserAPIKeyAuth,
121 cache: DualCache,
122 data: dict,
123 call_type: CallTypesLiteral,
124 ) -> Exception | str | dict | None:
125 """
126 Process skills from container.skills before the LLM call.
128 1. Check if container.skills exists in request
129 2. Separate skills by prefix (litellm_skill_ vs native)
130 3. Fetch LiteLLM skills from database
131 4. For Anthropic: keep native skills in container
132 5. For non-Anthropic: convert LiteLLM skills to tools, inject content, add execute_code
133 """
134 # Only process completion-type calls
135 if call_type not in ["completion", "acompletion", "anthropic_messages"]:
136 return data
138 container: Final = data.get("container")
139 if not container or not isinstance(container, dict): 139 ↛ 142line 139 didn't jump to line 142 because the condition on line 139 was always true
140 return data
142 skills: Final = container.get("skills")
143 if not skills or not isinstance(skills, list):
144 return data
146 verbose_proxy_logger.debug("SkillsInjectionHook: Processing %s skills", len(skills))
148 litellm_skills: Final[list[LiteLLM_SkillsTable]] = []
149 anthropic_skills: Final[list[dict[str, object]]] = []
151 # Separate skills by prefix
152 for skill in skills:
153 if not isinstance(skill, dict):
154 continue
156 skill_id = skill.get("skill_id", "")
157 if skill_id.startswith(LITELLM_SKILL_ID_PREFIX):
158 # Fetch from LiteLLM DB
159 db_skill = await self._fetch_skill_from_db(
160 skill_id,
161 user_api_key_dict=user_api_key_dict,
162 )
163 if db_skill:
164 litellm_skills.append(db_skill)
165 else:
166 verbose_proxy_logger.warning("SkillsInjectionHook: Skill '%s' not found in LiteLLM DB", skill_id)
167 else:
168 # Native Anthropic skill - pass through
169 anthropic_skills.append(skill)
171 # Check if using messages API spec (anthropic_messages call type)
172 # Messages API always uses Anthropic-style tool format
173 use_anthropic_format: Final = call_type == "anthropic_messages"
175 if len(litellm_skills) > 0:
176 data = self._process_for_messages_api(
177 data=data,
178 litellm_skills=litellm_skills,
179 use_anthropic_format=use_anthropic_format,
180 )
182 return data
184 def _process_for_messages_api(
185 self,
186 data: dict,
187 litellm_skills: list[LiteLLM_SkillsTable],
188 use_anthropic_format: bool = True,
189 ) -> dict:
190 """
191 Process skills for messages API (Anthropic format tools).
193 - Converts skills to Anthropic-style tools (name, description, input_schema)
194 - Extracts and injects SKILL.md content into system prompt
195 - Adds litellm_code_execution tool for code execution
196 - Stores skill files in metadata for sandbox execution
197 """
198 from litellm.llms.litellm_proxy.skills.code_execution import (
199 get_litellm_code_execution_tool_anthropic,
200 )
202 tools: Final = data.get("tools", [])
203 skill_contents: Final[list[str]] = []
204 all_skill_files: Final[dict[str, dict[str, bytes]]] = {}
205 all_module_paths: Final[list[str]] = []
207 for skill in litellm_skills:
208 # Convert skill to Anthropic-style tool
209 tools.append(self.prompt_handler.convert_skill_to_anthropic_tool(skill))
211 # Extract skill content from file if available
212 content = self.prompt_handler.extract_skill_content(skill)
213 if content:
214 skill_contents.append(content)
216 # Extract all files for code execution
217 skill_files = self.prompt_handler.extract_all_files(skill)
218 if skill_files:
219 all_skill_files[skill.skill_id] = skill_files
220 for path in skill_files:
221 if path.endswith(".py"):
222 all_module_paths.append(path)
224 if tools:
225 data["tools"] = tools
227 # Inject skill content into system prompt
228 # For Anthropic messages API, use top-level 'system' param instead of messages array
229 if skill_contents:
230 data = self.prompt_handler.inject_skill_content_to_messages(
231 data, skill_contents, use_anthropic_format=use_anthropic_format
232 )
234 # Add litellm_code_execution tool if we have skill files
235 if all_skill_files:
236 code_exec_tool: Final = get_litellm_code_execution_tool_anthropic()
237 data["tools"] = data.get("tools", []) + [code_exec_tool]
239 # Store skill files in litellm_metadata for automatic code execution
240 data["litellm_metadata"] = data.get("litellm_metadata", {})
241 data["litellm_metadata"]["_skill_files"] = all_skill_files
242 data["litellm_metadata"]["_litellm_code_execution_enabled"] = True
244 # Remove container (not supported by underlying providers)
245 data.pop("container", None)
247 verbose_proxy_logger.debug(
248 "SkillsInjectionHook: Messages API - converted %s skills to Anthropic tools, injected %s skill contents, added litellm_code_execution tool with %s modules",
249 len(litellm_skills),
250 len(skill_contents),
251 len(all_module_paths),
252 )
254 return data
256 def _process_non_anthropic_model(
257 self,
258 data: dict,
259 litellm_skills: list[LiteLLM_SkillsTable],
260 ) -> dict:
261 """
262 Process skills for non-Anthropic models (OpenAI format tools).
264 - Converts skills to OpenAI-style tools
265 - Extracts and injects SKILL.md content
266 - Adds execute_code tool for code execution
267 - Stores skill files in metadata for sandbox execution
268 """
269 tools: Final = data.get("tools", [])
270 skill_contents: Final[list[str]] = []
271 all_skill_files: Final[dict[str, dict[str, bytes]]] = {}
272 all_module_paths: Final[list[str]] = []
274 for skill in litellm_skills:
275 # Convert skill to OpenAI-style tool
276 tools.append(self.prompt_handler.convert_skill_to_tool(skill))
278 # Extract skill content from file if available
279 content = self.prompt_handler.extract_skill_content(skill)
280 if content:
281 skill_contents.append(content)
283 # Extract all files for code execution
284 skill_files = self.prompt_handler.extract_all_files(skill)
285 if skill_files:
286 all_skill_files[skill.skill_id] = skill_files
287 # Collect Python module paths
288 for path in skill_files:
289 if path.endswith(".py"):
290 all_module_paths.append(path)
292 if tools:
293 data["tools"] = tools
295 # Inject skill content into system prompt
296 if skill_contents:
297 data = self.prompt_handler.inject_skill_content_to_messages(data, skill_contents)
299 # Add litellm_code_execution tool if we have skill files
300 if all_skill_files:
301 from litellm.llms.litellm_proxy.skills.code_execution import (
302 get_litellm_code_execution_tool,
303 )
305 data["tools"] = data.get("tools", []) + [get_litellm_code_execution_tool()]
307 # Store skill files in litellm_metadata for automatic code execution
308 # Using litellm_metadata instead of metadata to avoid conflicts with user metadata
309 data["litellm_metadata"] = data.get("litellm_metadata", {})
310 data["litellm_metadata"]["_skill_files"] = all_skill_files
311 data["litellm_metadata"]["_litellm_code_execution_enabled"] = True
313 # Remove container for non-Anthropic (they don't support it)
314 data.pop("container", None)
316 verbose_proxy_logger.debug(
317 "SkillsInjectionHook: Non-Anthropic model - converted %s skills to tools, injected %s skill contents, added execute_code tool with %s modules",
318 len(litellm_skills),
319 len(skill_contents),
320 len(all_module_paths),
321 )
323 return data
325 async def _fetch_skill_from_db(
326 self,
327 skill_id: str,
328 user_api_key_dict: UserAPIKeyAuth,
329 ) -> LiteLLM_SkillsTable | None:
330 """
331 Fetch a skill from the LiteLLM database.
333 Args:
334 skill_id: The skill ID (including the 'litellm_skill_' prefix)
336 Returns:
337 LiteLLM_SkillsTable or None if not found
338 """
339 try:
340 from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler
342 return await LiteLLMSkillsHandler.fetch_skill_from_db(
343 skill_id,
344 user_api_key_dict=user_api_key_dict,
345 )
346 except Exception as e:
347 verbose_proxy_logger.warning("SkillsInjectionHook: Error fetching skill %s: %s", skill_id, e)
348 return None
350 def _is_anthropic_model(self, model: str) -> bool:
351 """
352 Check if the model is an Anthropic model using get_llm_provider.
354 Args:
355 model: The model name/identifier
357 Returns:
358 True if Anthropic model, False otherwise
359 """
360 try:
361 from litellm.litellm_core_utils.get_llm_provider_logic import (
362 get_llm_provider,
363 )
365 _, custom_llm_provider, _, _ = get_llm_provider(model=model)
366 return custom_llm_provider == "anthropic"
367 except Exception:
368 # Fallback to simple check if get_llm_provider fails
369 return "claude" in model.lower() or model.lower().startswith("anthropic/")
371 async def async_post_call_success_deployment_hook(
372 self,
373 request_data: dict,
374 response: LLMResponseTypes,
375 call_type: CallTypes | None,
376 ) -> LLMResponseTypes | None:
377 """
378 Post-call hook to handle automatic code execution.
380 Handles both OpenAI format (response.choices) and Anthropic/messages API
381 format (response["content"]).
383 If the response contains a tool call (litellm_code_execution or skill tool):
384 1. Execute the code in sandbox
385 2. Add result to messages
386 3. Make another LLM call
387 4. Repeat until model gives final response
388 5. Return modified response with generated files
389 """
390 from litellm.llms.litellm_proxy.skills.code_execution import (
391 LiteLLMInternalTools,
392 )
394 # Check if code execution is enabled for this request
395 litellm_metadata: Final = request_data.get("litellm_metadata") or {}
396 metadata: Final = request_data.get("metadata") or {}
398 code_exec_enabled: Final = litellm_metadata.get("_litellm_code_execution_enabled") or metadata.get(
399 "_litellm_code_execution_enabled"
400 )
401 if not code_exec_enabled: 401 ↛ 405line 401 didn't jump to line 405 because the condition on line 401 was always true
402 return None
404 # Get skill files
405 skill_files_by_id: Final = litellm_metadata.get("_skill_files") or metadata.get("_skill_files", {})
406 all_skill_files: Final[dict[str, bytes]] = {}
407 for files_dict in skill_files_by_id.values():
408 all_skill_files.update(files_dict)
410 if not all_skill_files:
411 verbose_proxy_logger.warning("SkillsInjectionHook: No skill files found, cannot execute code")
412 return None
414 # Check for tool calls - handle both Anthropic and OpenAI formats
415 tool_calls: Final = self._extract_tool_calls(response)
416 if not tool_calls:
417 return None
419 # Check if any tool call needs execution (litellm_code_execution or skill tool)
420 has_executable_tool = False
421 for tc in tool_calls:
422 tool_name: str = tc.get("name", "")
423 # Execute if it's litellm_code_execution OR a skill tool (litellm_skill_xxx)
424 if tool_name == LiteLLMInternalTools.CODE_EXECUTION.value or tool_name.startswith(LITELLM_SKILL_ID_PREFIX):
425 has_executable_tool = True
426 break
428 if not has_executable_tool:
429 return None
431 verbose_proxy_logger.debug("SkillsInjectionHook: Detected tool call, starting execution loop")
433 # Start the agentic loop
434 return await self._execute_code_loop_messages_api(
435 data=request_data,
436 response=response,
437 skill_files=all_skill_files,
438 )
440 def _extract_tool_calls(self, response: Any) -> list[dict[str, Any]]:
441 """Extract tool calls from response, handling both formats."""
442 tool_calls: Final = []
444 # Get content - handle both dict and object responses
445 content = None
446 if isinstance(response, dict):
447 content = response.get("content", [])
448 elif hasattr(response, "content"):
449 content = response.content
451 # Anthropic/messages API format: response has "content" list with tool_use blocks
452 if content:
453 for block in content:
454 if isinstance(block, dict) and block.get("type") == "tool_use":
455 tool_calls.append(
456 {
457 "id": block.get("id"),
458 "name": block.get("name"),
459 "input": block.get("input", {}),
460 }
461 )
462 elif hasattr(block, "type") and getattr(block, "type", None) == "tool_use":
463 tool_calls.append(
464 {
465 "id": getattr(block, "id", None),
466 "name": getattr(block, "name", None),
467 "input": getattr(block, "input", {}),
468 }
469 )
471 # OpenAI format: response has choices[0].message.tool_calls
472 if not tool_calls and hasattr(response, "choices") and response.choices:
473 msg: Final = response.choices[0].message
474 if hasattr(msg, "tool_calls") and msg.tool_calls:
475 for tc in msg.tool_calls:
476 tool_calls.append(
477 {
478 "id": tc.id,
479 "name": tc.function.name,
480 "input": (json.loads(tc.function.arguments) if tc.function.arguments else {}),
481 }
482 )
484 return tool_calls
486 async def _execute_code_loop_messages_api(
487 self,
488 data: dict,
489 response: object,
490 skill_files: dict[str, bytes],
491 ) -> LLMResponseTypes | None:
492 """
493 Execute the code execution loop for messages API (Anthropic format).
495 Returns the final response with generated files inline.
496 """
497 from litellm.llms.litellm_proxy.skills.code_execution import (
498 LiteLLMInternalTools,
499 )
500 from litellm.llms.litellm_proxy.skills.sandbox_executor import (
501 SkillsSandboxExecutor,
502 )
504 # Ensure response is not None
505 if response is None:
506 verbose_proxy_logger.error("SkillsInjectionHook: Response is None, cannot execute code loop")
507 return None
509 model: Final = data.get("model", "")
510 messages: Final = list(data.get("messages", []))
511 tools: Final = data.get("tools", [])
512 max_tokens: Final = data.get("max_tokens", 4096)
514 executor: Final = SkillsSandboxExecutor(timeout=self.sandbox_timeout)
515 generated_files: Final[list[dict[str, object]]] = []
516 current_response = response
518 for iteration in range(self.max_iterations):
519 # Extract tool calls from current response
520 tool_calls = self._extract_tool_calls(current_response)
521 stop_reason = (
522 current_response.get("stop_reason")
523 if isinstance(current_response, dict)
524 else getattr(current_response, "stop_reason", None)
525 )
527 # Get content for assistant message - convert to plain dicts
528 raw_content = (
529 current_response.get("content", [])
530 if isinstance(current_response, dict)
531 else getattr(current_response, "content", [])
532 )
533 content_blocks = []
534 for block in raw_content or []:
535 if isinstance(block, dict):
536 content_blocks.append(block)
537 elif hasattr(block, "model_dump"):
538 content_blocks.append(block.model_dump())
539 elif hasattr(block, "__dict__"):
540 content_blocks.append(dict(block.__dict__))
541 else:
542 content_blocks.append({"type": "text", "text": str(block)})
544 # Build assistant message for conversation history (Anthropic format)
545 assistant_msg = {"role": "assistant", "content": content_blocks}
546 messages.append(assistant_msg)
548 # Check if we're done (no tool calls)
549 if stop_reason != "tool_use" or not tool_calls:
550 verbose_proxy_logger.debug(
551 "SkillsInjectionHook: Loop completed after %s iterations, %s files generated",
552 iteration + 1,
553 len(generated_files),
554 )
555 return self._attach_files_to_response(current_response, generated_files)
557 # Process tool calls
558 tool_results = []
559 for tc in tool_calls:
560 tool_name: str = tc.get("name", "")
561 tool_id = tc.get("id", "")
562 tool_input: Mapping[str, str] = tc.get("input", {})
564 # Execute if it's litellm_code_execution OR a skill tool
565 if tool_name == LiteLLMInternalTools.CODE_EXECUTION.value:
566 code = tool_input.get("code", "")
567 result = await self._execute_code(code, skill_files, executor, generated_files)
568 elif tool_name.startswith(LITELLM_SKILL_ID_PREFIX):
569 # Skill tool - execute the skill's code
570 result = await self._execute_skill_tool(
571 tool_name, tool_input, skill_files, executor, generated_files
572 )
573 else:
574 result = f"Tool '{tool_name}' not handled"
576 tool_results.append(
577 {
578 "type": "tool_result",
579 "tool_use_id": tool_id,
580 "content": result,
581 }
582 )
584 # Add tool results to messages (Anthropic format)
585 messages.append({"role": "user", "content": tool_results})
587 # Make next LLM call
588 verbose_proxy_logger.debug("SkillsInjectionHook: Making LLM call iteration %s", iteration + 2)
589 try:
590 current_response = await litellm.anthropic.acreate(
591 model=model,
592 messages=messages,
593 tools=tools,
594 max_tokens=max_tokens,
595 )
596 if current_response is None:
597 verbose_proxy_logger.error("SkillsInjectionHook: LLM call returned None")
598 return self._attach_files_to_response(response, generated_files)
599 except Exception as e:
600 verbose_proxy_logger.error("SkillsInjectionHook: LLM call failed: %s", e)
601 return self._attach_files_to_response(response, generated_files)
603 verbose_proxy_logger.warning("SkillsInjectionHook: Max iterations (%s) reached", self.max_iterations)
604 return self._attach_files_to_response(current_response, generated_files)
606 async def _execute_code(
607 self,
608 code: str,
609 skill_files: dict[str, bytes],
610 executor: "SkillsSandboxExecutor",
611 generated_files: list[dict[str, object]],
612 ) -> str:
613 """Execute code in sandbox and return result string."""
614 try:
615 verbose_proxy_logger.debug("SkillsInjectionHook: Executing code (%s chars)", len(code))
617 exec_result: Final = executor.execute(code=code, skill_files=skill_files)
619 result = exec_result.get("output", "") or ""
621 # Collect generated files
622 if exec_result.get("files"):
623 files: Final[Sequence[Mapping[str, str]]] = exec_result["files"]
624 for f in files:
625 generated_files.append(
626 {
627 "name": f["name"],
628 "mime_type": f["mime_type"],
629 "content_base64": f["content_base64"],
630 "size": len(base64.b64decode(f["content_base64"])),
631 }
632 )
633 result += f"\n\nGenerated file: {f['name']}"
635 if exec_result.get("error"):
636 result += f"\n\nError: {exec_result['error']}"
638 return result or "Code executed successfully"
639 except Exception as e:
640 return f"Code execution failed: {e}"
642 async def _execute_skill_tool(
643 self,
644 tool_name: str,
645 tool_input: Mapping[str, str],
646 skill_files: dict[str, bytes],
647 executor: "SkillsSandboxExecutor",
648 generated_files: list[dict[str, object]],
649 ) -> str:
650 """Execute a skill tool by generating and running code based on skill content."""
651 # Generate code based on available skill modules
652 # Look for Python modules in the skill
653 python_modules: Final = [p for p in skill_files if p.endswith(".py") and not p.endswith("__init__.py")]
655 # Try to find the main builder/creator module
656 main_module = None
657 for mod in python_modules:
658 if "builder" in mod.lower() or "creator" in mod.lower() or "generator" in mod.lower():
659 main_module = mod
660 break
662 if not main_module and python_modules:
663 # Use first non-init module
664 main_module = python_modules[0]
666 if main_module:
667 # Convert path to import: "core/gif_builder.py" -> "core.gif_builder"
668 import_path: Final = main_module.replace("/", ".").replace(".py", "")
670 # Generate code that imports and uses the module
671 code = f"""
672# Auto-generated code to execute skill
673import sys
674sys.path.insert(0, '/sandbox')
676from {import_path} import *
678# Try to find and use a Builder/Creator class
679import inspect
680module = __import__('{import_path}', fromlist=[''])
682for name, obj in inspect.getmembers(module):
683 if inspect.isclass(obj) and name != 'object':
684 try:
685 instance = obj()
686 # Try common methods
687 if hasattr(instance, 'create'):
688 result = instance.create()
689 elif hasattr(instance, 'build'):
690 result = instance.build()
691 elif hasattr(instance, 'generate'):
692 result = instance.generate()
693 elif hasattr(instance, 'save'):
694 instance.save('output.gif')
695 print(f'Used {{name}} class')
696 break
697 except Exception as e:
698 print(f'Error with {{name}}: {{e}}')
699 continue
701# List generated files
702import os
703for f in os.listdir('.'):
704 if f.endswith(('.gif', '.png', '.jpg')):
705 print(f'Generated: {{f}}')
706"""
707 else:
708 # Fallback generic code
709 code = """
710print('No executable skill module found')
711"""
713 return await self._execute_code(code, skill_files, executor, generated_files)
715 async def _execute_code_loop(
716 self,
717 data: dict,
718 response: object,
719 skill_files: dict[str, bytes],
720 ) -> LLMResponseTypes:
721 """
722 Execute the code execution loop until model gives final response.
724 Returns the final response with generated files inline.
725 """
726 from litellm.llms.litellm_proxy.skills.code_execution import (
727 LiteLLMInternalTools,
728 )
729 from litellm.llms.litellm_proxy.skills.sandbox_executor import (
730 SkillsSandboxExecutor,
731 )
733 model: Final = data.get("model", "")
734 messages: Final = list(data.get("messages", []))
735 tools: Final = data.get("tools", [])
737 # Keys to exclude when passing through to acompletion
738 # These are either handled explicitly or are internal LiteLLM fields
739 _EXCLUDED_ACOMPLETION_KEYS: Final = frozenset(
740 {
741 "messages",
742 "model",
743 "tools",
744 "metadata",
745 "litellm_metadata",
746 "container",
747 }
748 )
750 kwargs: Final = {k: v for k, v in data.items() if k not in _EXCLUDED_ACOMPLETION_KEYS}
752 executor: Final = SkillsSandboxExecutor(timeout=self.sandbox_timeout)
753 generated_files: Final[list[dict[str, object]]] = []
754 current_response: Any = response
756 for iteration in range(self.max_iterations):
757 # OpenAI format response has choices[0].message
758 choice: _ChatChoice = _first_choice(current_response)
759 assistant_message: _ChatMessage = choice.message
760 stop_reason: str | None = choice.finish_reason
762 # Build assistant message for conversation history
763 assistant_msg_dict: dict[str, object] = {
764 "role": "assistant",
765 "content": assistant_message.content,
766 }
767 if assistant_message.tool_calls:
768 assistant_msg_dict["tool_calls"] = [
769 {
770 "id": tc.id,
771 "type": "function",
772 "function": {
773 "name": tc.function.name,
774 "arguments": tc.function.arguments,
775 },
776 }
777 for tc in assistant_message.tool_calls
778 ]
779 messages.append(assistant_msg_dict)
781 # Check if we're done (no tool calls)
782 if stop_reason != "tool_calls" or not assistant_message.tool_calls:
783 verbose_proxy_logger.debug(
784 "SkillsInjectionHook: Code execution loop completed after %s iterations, %s files generated",
785 iteration + 1,
786 len(generated_files),
787 )
788 # Attach generated files to response
789 return self._attach_files_to_response(current_response, generated_files)
791 # Process tool calls
792 for tool_call in assistant_message.tool_calls:
793 tool_name = tool_call.function.name
795 if tool_name == LiteLLMInternalTools.CODE_EXECUTION.value:
796 tool_result = await self._execute_code_tool(
797 tool_call=tool_call,
798 skill_files=skill_files,
799 executor=executor,
800 generated_files=generated_files,
801 )
802 else:
803 # Non-code-execution tool - cannot handle
804 tool_result = f"Tool '{tool_name}' not handled automatically"
806 messages.append(
807 {
808 "role": "tool",
809 "tool_call_id": tool_call.id,
810 "content": tool_result,
811 }
812 )
814 # Make next LLM call using the messages API
815 verbose_proxy_logger.debug("SkillsInjectionHook: Making LLM call iteration %s", iteration + 2)
816 current_response = await litellm.anthropic.acreate(
817 model=model,
818 messages=messages,
819 tools=tools,
820 max_tokens=kwargs.get("max_tokens", 4096),
821 )
823 # Max iterations reached
824 verbose_proxy_logger.warning("SkillsInjectionHook: Max iterations (%s) reached", self.max_iterations)
825 return self._attach_files_to_response(current_response, generated_files)
827 async def _execute_code_tool(
828 self,
829 tool_call: _ChatToolCall,
830 skill_files: dict[str, bytes],
831 executor: "SkillsSandboxExecutor",
832 generated_files: list[dict[str, object]],
833 ) -> str:
834 """Execute a litellm_code_execution tool call and return result string."""
835 try:
836 args: Final[Mapping[str, str]] = json.loads(tool_call.function.arguments)
837 code: Final[str] = args.get("code", "")
839 verbose_proxy_logger.debug("SkillsInjectionHook: Executing code (%s chars)", len(code))
841 exec_result: Final = executor.execute(
842 code=code,
843 skill_files=skill_files,
844 )
846 # Build tool result content
847 tool_result = exec_result.get("output", "") or ""
849 # Collect generated files
850 if exec_result.get("files"):
851 tool_result += "\n\nGenerated files:"
852 files: Final[Sequence[Mapping[str, str]]] = exec_result["files"]
853 for f in files:
854 file_content = base64.b64decode(f["content_base64"])
855 generated_files.append(
856 {
857 "name": f["name"],
858 "mime_type": f["mime_type"],
859 "content_base64": f["content_base64"],
860 "size": len(file_content),
861 }
862 )
863 tool_result += f"\n- {f['name']} ({len(file_content)} bytes)"
865 verbose_proxy_logger.debug(
866 "SkillsInjectionHook: Generated file %s (%s bytes)", f["name"], len(file_content)
867 )
869 if exec_result.get("error"):
870 tool_result += f"\n\nError:\n{exec_result['error']}"
872 return tool_result
874 except Exception as e:
875 verbose_proxy_logger.error("SkillsInjectionHook: Code execution failed: %s", e)
876 return f"Code execution failed: {e}"
878 def _attach_files_to_response(
879 self,
880 response: Any,
881 generated_files: list[dict[str, object]],
882 ) -> LLMResponseTypes:
883 """
884 Attach generated files to the response object.
886 Files are added to response._litellm_generated_files for easy access.
887 For dict responses, files are added as a key.
888 """
889 if not generated_files:
890 return response
892 raw_response: Final = response
894 # Handle dict response (Anthropic/messages API format)
895 if isinstance(response, dict):
896 response["_litellm_generated_files"] = generated_files
897 verbose_proxy_logger.debug("SkillsInjectionHook: Attached %s files to dict response", len(generated_files))
898 return raw_response
900 # Handle object response (OpenAI format)
901 try:
902 response._litellm_generated_files = generated_files
903 except AttributeError:
904 pass
906 # Also add to model_extra if available (for serialization)
907 if hasattr(response, "model_extra"):
908 if response.model_extra is None:
909 response.model_extra = {}
910 response.model_extra["_litellm_generated_files"] = generated_files
912 verbose_proxy_logger.debug("SkillsInjectionHook: Attached %s files to response", len(generated_files))
914 return response