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

1""" 

2Skills Injection Hook for LiteLLM Proxy 

3 

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 

9 

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 

15 

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""" 

26 

27import base64 

28import json 

29from collections.abc import Mapping, Sequence 

30from typing import TYPE_CHECKING, Any, Final, Protocol 

31 

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 

42 

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 

45 

46 

47class _ToolCallFunction(Protocol): 

48 @property 

49 def name(self) -> str: ... 49 ↛ exitline 49 didn't return from function 'name' because

50 

51 @property 

52 def arguments(self) -> str: ... 52 ↛ exitline 52 didn't return from function 'arguments' because

53 

54 

55class _ChatToolCall(Protocol): 

56 @property 

57 def id(self) -> str: ... 57 ↛ exitline 57 didn't return from function 'id' because

58 

59 @property 

60 def function(self) -> _ToolCallFunction: ... 60 ↛ exitline 60 didn't return from function 'function' because

61 

62 

63class _ChatMessage(Protocol): 

64 @property 

65 def content(self) -> str | None: ... 65 ↛ exitline 65 didn't return from function 'content' because

66 

67 @property 

68 def tool_calls(self) -> Sequence[_ChatToolCall] | None: ... 68 ↛ exitline 68 didn't return from function 'tool_calls' because

69 

70 

71class _ChatChoice(Protocol): 

72 @property 

73 def message(self) -> _ChatMessage: ... 73 ↛ exitline 73 didn't return from function 'message' because

74 

75 @property 

76 def finish_reason(self) -> str | None: ... 76 ↛ exitline 76 didn't return from function 'finish_reason' because

77 

78 

79class _ChatCompletion(Protocol): 

80 @property 

81 def choices(self) -> Sequence[_ChatChoice]: ... 81 ↛ exitline 81 didn't return from function 'choices' because

82 

83 

84def _first_choice(response: _ChatCompletion) -> _ChatChoice: 

85 """The first choice of an OpenAI shaped completion response.""" 

86 return response.choices[0] 

87 

88 

89class SkillsInjectionHook(CustomLogger): 

90 """ 

91 Pre/Post-call hook that processes skills from container.skills parameter. 

92 

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 

97 

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 

102 

103 This hook is called automatically by litellm during completion calls. 

104 """ 

105 

106 def __init__(self, **kwargs): 

107 from litellm.llms.litellm_proxy.skills.constants import ( 

108 DEFAULT_MAX_ITERATIONS, 

109 DEFAULT_SANDBOX_TIMEOUT, 

110 ) 

111 

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) 

117 

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. 

127 

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 

137 

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 

141 

142 skills: Final = container.get("skills") 

143 if not skills or not isinstance(skills, list): 

144 return data 

145 

146 verbose_proxy_logger.debug("SkillsInjectionHook: Processing %s skills", len(skills)) 

147 

148 litellm_skills: Final[list[LiteLLM_SkillsTable]] = [] 

149 anthropic_skills: Final[list[dict[str, object]]] = [] 

150 

151 # Separate skills by prefix 

152 for skill in skills: 

153 if not isinstance(skill, dict): 

154 continue 

155 

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) 

170 

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" 

174 

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 ) 

181 

182 return data 

183 

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). 

192 

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 ) 

201 

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]] = [] 

206 

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)) 

210 

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) 

215 

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) 

223 

224 if tools: 

225 data["tools"] = tools 

226 

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 ) 

233 

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] 

238 

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 

243 

244 # Remove container (not supported by underlying providers) 

245 data.pop("container", None) 

246 

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 ) 

253 

254 return data 

255 

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). 

263 

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]] = [] 

273 

274 for skill in litellm_skills: 

275 # Convert skill to OpenAI-style tool 

276 tools.append(self.prompt_handler.convert_skill_to_tool(skill)) 

277 

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) 

282 

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) 

291 

292 if tools: 

293 data["tools"] = tools 

294 

295 # Inject skill content into system prompt 

296 if skill_contents: 

297 data = self.prompt_handler.inject_skill_content_to_messages(data, skill_contents) 

298 

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 ) 

304 

305 data["tools"] = data.get("tools", []) + [get_litellm_code_execution_tool()] 

306 

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 

312 

313 # Remove container for non-Anthropic (they don't support it) 

314 data.pop("container", None) 

315 

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 ) 

322 

323 return data 

324 

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. 

332 

333 Args: 

334 skill_id: The skill ID (including the 'litellm_skill_' prefix) 

335 

336 Returns: 

337 LiteLLM_SkillsTable or None if not found 

338 """ 

339 try: 

340 from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler 

341 

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 

349 

350 def _is_anthropic_model(self, model: str) -> bool: 

351 """ 

352 Check if the model is an Anthropic model using get_llm_provider. 

353 

354 Args: 

355 model: The model name/identifier 

356 

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 ) 

364 

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/") 

370 

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. 

379 

380 Handles both OpenAI format (response.choices) and Anthropic/messages API 

381 format (response["content"]). 

382 

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 ) 

393 

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 {} 

397 

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 

403 

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) 

409 

410 if not all_skill_files: 

411 verbose_proxy_logger.warning("SkillsInjectionHook: No skill files found, cannot execute code") 

412 return None 

413 

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 

418 

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 

427 

428 if not has_executable_tool: 

429 return None 

430 

431 verbose_proxy_logger.debug("SkillsInjectionHook: Detected tool call, starting execution loop") 

432 

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 ) 

439 

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 = [] 

443 

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 

450 

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 ) 

470 

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 ) 

483 

484 return tool_calls 

485 

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). 

494 

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 ) 

503 

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 

508 

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) 

513 

514 executor: Final = SkillsSandboxExecutor(timeout=self.sandbox_timeout) 

515 generated_files: Final[list[dict[str, object]]] = [] 

516 current_response = response 

517 

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 ) 

526 

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)}) 

543 

544 # Build assistant message for conversation history (Anthropic format) 

545 assistant_msg = {"role": "assistant", "content": content_blocks} 

546 messages.append(assistant_msg) 

547 

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) 

556 

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", {}) 

563 

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" 

575 

576 tool_results.append( 

577 { 

578 "type": "tool_result", 

579 "tool_use_id": tool_id, 

580 "content": result, 

581 } 

582 ) 

583 

584 # Add tool results to messages (Anthropic format) 

585 messages.append({"role": "user", "content": tool_results}) 

586 

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) 

602 

603 verbose_proxy_logger.warning("SkillsInjectionHook: Max iterations (%s) reached", self.max_iterations) 

604 return self._attach_files_to_response(current_response, generated_files) 

605 

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)) 

616 

617 exec_result: Final = executor.execute(code=code, skill_files=skill_files) 

618 

619 result = exec_result.get("output", "") or "" 

620 

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']}" 

634 

635 if exec_result.get("error"): 

636 result += f"\n\nError: {exec_result['error']}" 

637 

638 return result or "Code executed successfully" 

639 except Exception as e: 

640 return f"Code execution failed: {e}" 

641 

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")] 

654 

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 

661 

662 if not main_module and python_modules: 

663 # Use first non-init module 

664 main_module = python_modules[0] 

665 

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", "") 

669 

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') 

675 

676from {import_path} import * 

677 

678# Try to find and use a Builder/Creator class 

679import inspect 

680module = __import__('{import_path}', fromlist=['']) 

681 

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 

700 

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""" 

712 

713 return await self._execute_code(code, skill_files, executor, generated_files) 

714 

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. 

723 

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 ) 

732 

733 model: Final = data.get("model", "") 

734 messages: Final = list(data.get("messages", [])) 

735 tools: Final = data.get("tools", []) 

736 

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 ) 

749 

750 kwargs: Final = {k: v for k, v in data.items() if k not in _EXCLUDED_ACOMPLETION_KEYS} 

751 

752 executor: Final = SkillsSandboxExecutor(timeout=self.sandbox_timeout) 

753 generated_files: Final[list[dict[str, object]]] = [] 

754 current_response: Any = response 

755 

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 

761 

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) 

780 

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) 

790 

791 # Process tool calls 

792 for tool_call in assistant_message.tool_calls: 

793 tool_name = tool_call.function.name 

794 

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" 

805 

806 messages.append( 

807 { 

808 "role": "tool", 

809 "tool_call_id": tool_call.id, 

810 "content": tool_result, 

811 } 

812 ) 

813 

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 ) 

822 

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) 

826 

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", "") 

838 

839 verbose_proxy_logger.debug("SkillsInjectionHook: Executing code (%s chars)", len(code)) 

840 

841 exec_result: Final = executor.execute( 

842 code=code, 

843 skill_files=skill_files, 

844 ) 

845 

846 # Build tool result content 

847 tool_result = exec_result.get("output", "") or "" 

848 

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)" 

864 

865 verbose_proxy_logger.debug( 

866 "SkillsInjectionHook: Generated file %s (%s bytes)", f["name"], len(file_content) 

867 ) 

868 

869 if exec_result.get("error"): 

870 tool_result += f"\n\nError:\n{exec_result['error']}" 

871 

872 return tool_result 

873 

874 except Exception as e: 

875 verbose_proxy_logger.error("SkillsInjectionHook: Code execution failed: %s", e) 

876 return f"Code execution failed: {e}" 

877 

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. 

885 

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 

891 

892 raw_response: Final = response 

893 

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 

899 

900 # Handle object response (OpenAI format) 

901 try: 

902 response._litellm_generated_files = generated_files 

903 except AttributeError: 

904 pass 

905 

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 

911 

912 verbose_proxy_logger.debug("SkillsInjectionHook: Attached %s files to response", len(generated_files)) 

913 

914 return response