Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/anthropic_endpoints/skills_endpoints.py: 80%

106 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 12:01 +0000

1""" 

2Anthropic Skills API endpoints - /v1/skills 

3""" 

4 

5from types import MappingProxyType 

6from typing import Annotated, Final 

7 

8import orjson 

9from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response 

10from typing_extensions import ReadOnly, TypedDict, assert_never 

11 

12import litellm 

13from litellm.llms.litellm_proxy.skills.skill_search import ( 

14 DEFAULT_SKILL_SEARCH_TOP_K, 

15 SkillSearchEmbeddingFailed, 

16 SkillSearchHits, 

17 SkillSearchNotConfigured, 

18 SkillSearchUnsupportedProvider, 

19 global_skill_search_index, 

20 search_hosted_skills, 

21) 

22from litellm.proxy._types import UserAPIKeyAuth 

23from litellm.proxy.auth.user_api_key_auth import user_api_key_auth 

24from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing 

25from litellm.proxy.common_utils.http_parsing_utils import ( 

26 convert_upload_files_to_file_data, 

27 get_form_data, 

28) 

29from litellm.types.llms.anthropic_skills import ( 

30 DeleteSkillResponse, 

31 ListSkillsResponse, 

32 Skill, 

33) 

34 

35router: Final = APIRouter() 

36 

37 

38class _SkillSearchErrorDetail(TypedDict): 

39 error: ReadOnly[str] 

40 message: ReadOnly[str] 

41 

42 

43def _skill_search_error(status_code: int, error: str, message: str) -> HTTPException: 

44 detail: Final[_SkillSearchErrorDetail] = {"error": error, "message": message} 

45 return HTTPException(status_code=status_code, detail=detail) 

46 

47 

48async def _search_skills( 

49 custom_llm_provider: str | None, query: str, top_k: int, user_api_key_dict: UserAPIKeyAuth 

50) -> ListSkillsResponse: 

51 from litellm.llms.litellm_proxy.skills.transformation import ( 

52 LiteLLMSkillsTransformationHandler, 

53 ) 

54 from litellm.proxy.proxy_server import llm_router, proxy_logging_obj 

55 

56 outcome: Final = await search_hosted_skills( 

57 custom_llm_provider=custom_llm_provider, 

58 query=query, 

59 top_k=top_k, 

60 router=llm_router, 

61 embedding_model=litellm.skill_search_embedding_model, 

62 index=global_skill_search_index, 

63 user_api_key_dict=user_api_key_dict, 

64 proxy_logging_obj=proxy_logging_obj, 

65 ) 

66 to_response: Final = LiteLLMSkillsTransformationHandler().db_skill_to_response 

67 match outcome: 

68 case SkillSearchHits(hits): 68 ↛ 69line 68 didn't jump to line 69 because the pattern on line 68 never matched

69 skills: Final = [ # mutable-ok: ListSkillsResponse.data requires list[Skill]; never mutated after 

70 to_response(hit.skill).model_copy(update=MappingProxyType({"search_score": hit.score})) for hit in hits 

71 ] 

72 return ListSkillsResponse(data=skills, has_more=False, next_page=None) 

73 case SkillSearchUnsupportedProvider(reason): 73 ↛ 75line 73 didn't jump to line 75 because the pattern on line 73 always matched

74 raise _skill_search_error(400, "skill_search_unsupported_provider", reason) 

75 case SkillSearchNotConfigured(reason): 

76 raise _skill_search_error(400, "skill_search_not_configured", reason) 

77 case SkillSearchEmbeddingFailed(reason): 

78 raise _skill_search_error(503, "skill_search_unavailable", reason) 

79 case _: 

80 assert_never(outcome) 

81 

82 

83@router.post( 

84 "/v1/skills", 

85 tags=["[beta] Anthropic Skills API"], 

86 dependencies=[Depends(user_api_key_auth)], 

87 response_model=Skill, 

88) 

89async def create_skill( 

90 fastapi_response: Response, 

91 request: Request, 

92 custom_llm_provider: str | None = "anthropic", 

93 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

94): 

95 """ 

96 Create a new skill on Anthropic. 

97  

98 Requires `?beta=true` query parameter. 

99  

100 Model-based routing (for multi-account support): 

101 - Pass model via header: `x-litellm-model: claude-account-1` 

102 - Pass model via query: `?model=claude-account-1` 

103 - Pass model via form field: `model=claude-account-1` 

104  

105 Example usage: 

106 ```bash 

107 # Basic usage 

108 curl -X POST "http://localhost:4000/v1/skills?beta=true" \ 

109 -H "Content-Type: multipart/form-data" \ 

110 -H "Authorization: Bearer your-key" \ 

111 -F "display_title=My Skill" \ 

112 -F "files[]=@skill.zip" 

113  

114 # With model-based routing 

115 curl -X POST "http://localhost:4000/v1/skills?beta=true" \ 

116 -H "Content-Type: multipart/form-data" \ 

117 -H "Authorization: Bearer your-key" \ 

118 -H "x-litellm-model: claude-account-1" \ 

119 -F "display_title=My Skill" \ 

120 -F "files[]=@skill.zip" 

121 ``` 

122  

123 Returns: Skill object with id, display_title, etc. 

124 """ 

125 from litellm.proxy.proxy_server import ( 

126 general_settings, 

127 llm_router, 

128 proxy_config, 

129 proxy_logging_obj, 

130 select_data_generator, 

131 user_api_base, 

132 user_max_tokens, 

133 user_model, 

134 user_request_timeout, 

135 user_temperature, 

136 version, 

137 ) 

138 

139 # Read form data and convert UploadFile objects to file data tuples 

140 form_data: Final = await get_form_data(request) 

141 data: Final = await convert_upload_files_to_file_data(form_data) 

142 

143 # Extract model for routing (header > query > body) 

144 model: Final = data.get("model") or request.query_params.get("model") or request.headers.get("x-litellm-model") 

145 if model: 145 ↛ 146line 145 didn't jump to line 146 because the condition on line 145 was never true

146 data["model"] = model 

147 

148 if "custom_llm_provider" not in data: 148 ↛ 152line 148 didn't jump to line 152 because the condition on line 148 was always true

149 data["custom_llm_provider"] = custom_llm_provider 

150 

151 # Process request using ProxyBaseLLMRequestProcessing 

152 processor: Final = ProxyBaseLLMRequestProcessing(data=data) 

153 try: 

154 return await processor.base_process_llm_request( 

155 request=request, 

156 fastapi_response=fastapi_response, 

157 user_api_key_dict=user_api_key_dict, 

158 route_type="acreate_skill", 

159 proxy_logging_obj=proxy_logging_obj, 

160 llm_router=llm_router, 

161 general_settings=general_settings, 

162 proxy_config=proxy_config, 

163 select_data_generator=select_data_generator, 

164 model=data.get("model"), 

165 user_model=user_model, 

166 user_temperature=user_temperature, 

167 user_request_timeout=user_request_timeout, 

168 user_max_tokens=user_max_tokens, 

169 user_api_base=user_api_base, 

170 version=version, 

171 ) 

172 except Exception as e: 

173 raise await processor._handle_llm_api_exception( 

174 e=e, 

175 user_api_key_dict=user_api_key_dict, 

176 proxy_logging_obj=proxy_logging_obj, 

177 version=version, 

178 ) 

179 

180 

181@router.get( 

182 "/v1/skills", 

183 tags=["[beta] Anthropic Skills API"], 

184 dependencies=[Depends(user_api_key_auth)], 

185 response_model=ListSkillsResponse, 

186) 

187async def list_skills( 

188 fastapi_response: Response, 

189 request: Request, 

190 limit: int | None = 10, 

191 after_id: str | None = None, 

192 before_id: str | None = None, 

193 custom_llm_provider: str | None = "anthropic", 

194 query: Annotated[ 

195 str | None, 

196 Query( 

197 min_length=1, 

198 description="Describe what you need in natural language to rank the skills you can access by " 

199 "semantic similarity over their title and description. Each result carries a search_score. " 

200 "Only supported for custom_llm_provider=litellm_proxy. Requires " 

201 "litellm_settings.skill_search_embedding_model.", 

202 ), 

203 ] = None, 

204 top_k: Annotated[ 

205 int, 

206 Query(ge=1, le=100, description="With query: the maximum number of ranked skills to return."), 

207 ] = DEFAULT_SKILL_SEARCH_TOP_K, 

208 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

209): 

210 """ 

211 List skills on Anthropic. 

212 

213 Requires `?beta=true` query parameter. 

214 

215 Model-based routing (for multi-account support): 

216 - Pass model via header: `x-litellm-model: claude-account-1` 

217 - Pass model via query: `?model=claude-account-1` 

218 - Pass model via body: `{"model": "claude-account-1"}` 

219 

220 Example usage: 

221 ```bash 

222 # Basic usage 

223 curl "http://localhost:4000/v1/skills?beta=true&limit=10" \ 

224 -H "Authorization: Bearer your-key" 

225 

226 # With model-based routing 

227 curl "http://localhost:4000/v1/skills?beta=true&limit=10" \ 

228 -H "Authorization: Bearer your-key" \ 

229 -H "x-litellm-model: claude-account-1" 

230 ``` 

231 

232 Pass `?custom_llm_provider=litellm_proxy&query=<task>` to rank the LiteLLM-hosted skills you can 

233 access by semantic similarity instead of paging through the whole registry: 

234 ```bash 

235 curl "http://localhost:4000/v1/skills?custom_llm_provider=litellm_proxy&query=summarize+a+pdf&top_k=5" \ 

236 -H "Authorization: Bearer your-key" 

237 ``` 

238 

239 Returns: ListSkillsResponse with list of skills 

240 """ 

241 if query is not None: 

242 return await _search_skills( 

243 custom_llm_provider=custom_llm_provider, query=query, top_k=top_k, user_api_key_dict=user_api_key_dict 

244 ) 

245 

246 from litellm.proxy.proxy_server import ( 

247 general_settings, 

248 llm_router, 

249 proxy_config, 

250 proxy_logging_obj, 

251 select_data_generator, 

252 user_api_base, 

253 user_max_tokens, 

254 user_model, 

255 user_request_timeout, 

256 user_temperature, 

257 version, 

258 ) 

259 

260 # Read request body 

261 body: Final = await request.body() 

262 data: Final = orjson.loads(body) if body else {} 

263 

264 # Use query params if not in body 

265 if "limit" not in data and limit is not None: 265 ↛ 267line 265 didn't jump to line 267 because the condition on line 265 was always true

266 data["limit"] = limit 

267 if "after_id" not in data and after_id is not None: 

268 data["after_id"] = after_id 

269 if "before_id" not in data and before_id is not None: 

270 data["before_id"] = before_id 

271 

272 # Extract model for routing (header > query > body) 

273 model: Final = data.get("model") or request.query_params.get("model") or request.headers.get("x-litellm-model") 

274 if model: 274 ↛ 275line 274 didn't jump to line 275 because the condition on line 274 was never true

275 data["model"] = model 

276 

277 # Set custom_llm_provider: body > query param > default 

278 if "custom_llm_provider" not in data: 278 ↛ 282line 278 didn't jump to line 282 because the condition on line 278 was always true

279 data["custom_llm_provider"] = custom_llm_provider 

280 

281 # Process request using ProxyBaseLLMRequestProcessing 

282 processor: Final = ProxyBaseLLMRequestProcessing(data=data) 

283 try: 

284 return await processor.base_process_llm_request( 

285 request=request, 

286 fastapi_response=fastapi_response, 

287 user_api_key_dict=user_api_key_dict, 

288 route_type="alist_skills", 

289 proxy_logging_obj=proxy_logging_obj, 

290 llm_router=llm_router, 

291 general_settings=general_settings, 

292 proxy_config=proxy_config, 

293 select_data_generator=select_data_generator, 

294 model=data.get("model"), 

295 user_model=user_model, 

296 user_temperature=user_temperature, 

297 user_request_timeout=user_request_timeout, 

298 user_max_tokens=user_max_tokens, 

299 user_api_base=user_api_base, 

300 version=version, 

301 ) 

302 except Exception as e: 

303 raise await processor._handle_llm_api_exception( 

304 e=e, 

305 user_api_key_dict=user_api_key_dict, 

306 proxy_logging_obj=proxy_logging_obj, 

307 version=version, 

308 ) 

309 

310 

311@router.get( 

312 "/v1/skills/{skill_id}", 

313 tags=["[beta] Anthropic Skills API"], 

314 dependencies=[Depends(user_api_key_auth)], 

315 response_model=Skill, 

316) 

317async def get_skill( 

318 skill_id: str, 

319 fastapi_response: Response, 

320 request: Request, 

321 custom_llm_provider: str | None = "anthropic", 

322 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

323): 

324 """ 

325 Get a specific skill by ID from Anthropic. 

326  

327 Requires `?beta=true` query parameter. 

328  

329 Model-based routing (for multi-account support): 

330 - Pass model via header: `x-litellm-model: claude-account-1` 

331 - Pass model via query: `?model=claude-account-1` 

332 - Pass model via body: `{"model": "claude-account-1"}` 

333  

334 Example usage: 

335 ```bash 

336 # Basic usage 

337 curl "http://localhost:4000/v1/skills/skill_123?beta=true" \ 

338 -H "Authorization: Bearer your-key" 

339  

340 # With model-based routing 

341 curl "http://localhost:4000/v1/skills/skill_123?beta=true" \ 

342 -H "Authorization: Bearer your-key" \ 

343 -H "x-litellm-model: claude-account-1" 

344 ``` 

345  

346 Returns: Skill object 

347 """ 

348 from litellm.proxy.proxy_server import ( 

349 general_settings, 

350 llm_router, 

351 proxy_config, 

352 proxy_logging_obj, 

353 select_data_generator, 

354 user_api_base, 

355 user_max_tokens, 

356 user_model, 

357 user_request_timeout, 

358 user_temperature, 

359 version, 

360 ) 

361 

362 # Read request body 

363 body: Final = await request.body() 

364 data: Final = orjson.loads(body) if body else {} 

365 

366 # Set skill_id from path parameter 

367 data["skill_id"] = skill_id 

368 

369 # Extract model for routing (header > query > body) 

370 model: Final = data.get("model") or request.query_params.get("model") or request.headers.get("x-litellm-model") 

371 if model: 371 ↛ 372line 371 didn't jump to line 372 because the condition on line 371 was never true

372 data["model"] = model 

373 

374 # Set custom_llm_provider: body > query param > default 

375 if "custom_llm_provider" not in data: 375 ↛ 379line 375 didn't jump to line 379 because the condition on line 375 was always true

376 data["custom_llm_provider"] = custom_llm_provider 

377 

378 # Process request using ProxyBaseLLMRequestProcessing 

379 processor: Final = ProxyBaseLLMRequestProcessing(data=data) 

380 try: 

381 return await processor.base_process_llm_request( 

382 request=request, 

383 fastapi_response=fastapi_response, 

384 user_api_key_dict=user_api_key_dict, 

385 route_type="aget_skill", 

386 proxy_logging_obj=proxy_logging_obj, 

387 llm_router=llm_router, 

388 general_settings=general_settings, 

389 proxy_config=proxy_config, 

390 select_data_generator=select_data_generator, 

391 model=data.get("model"), 

392 user_model=user_model, 

393 user_temperature=user_temperature, 

394 user_request_timeout=user_request_timeout, 

395 user_max_tokens=user_max_tokens, 

396 user_api_base=user_api_base, 

397 version=version, 

398 ) 

399 except Exception as e: 

400 raise await processor._handle_llm_api_exception( 

401 e=e, 

402 user_api_key_dict=user_api_key_dict, 

403 proxy_logging_obj=proxy_logging_obj, 

404 version=version, 

405 ) 

406 

407 

408@router.delete( 

409 "/v1/skills/{skill_id}", 

410 tags=["[beta] Anthropic Skills API"], 

411 dependencies=[Depends(user_api_key_auth)], 

412 response_model=DeleteSkillResponse, 

413) 

414async def delete_skill( 

415 skill_id: str, 

416 fastapi_response: Response, 

417 request: Request, 

418 custom_llm_provider: str | None = "anthropic", 

419 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

420): 

421 """ 

422 Delete a skill by ID from Anthropic. 

423  

424 Requires `?beta=true` query parameter. 

425  

426 Note: Anthropic does not allow deleting skills with existing versions. 

427  

428 Model-based routing (for multi-account support): 

429 - Pass model via header: `x-litellm-model: claude-account-1` 

430 - Pass model via query: `?model=claude-account-1` 

431 - Pass model via body: `{"model": "claude-account-1"}` 

432  

433 Example usage: 

434 ```bash 

435 # Basic usage 

436 curl -X DELETE "http://localhost:4000/v1/skills/skill_123?beta=true" \ 

437 -H "Authorization: Bearer your-key" 

438  

439 # With model-based routing 

440 curl -X DELETE "http://localhost:4000/v1/skills/skill_123?beta=true" \ 

441 -H "Authorization: Bearer your-key" \ 

442 -H "x-litellm-model: claude-account-1" 

443 ``` 

444  

445 Returns: DeleteSkillResponse with type="skill_deleted" 

446 """ 

447 from litellm.proxy.proxy_server import ( 

448 general_settings, 

449 llm_router, 

450 proxy_config, 

451 proxy_logging_obj, 

452 select_data_generator, 

453 user_api_base, 

454 user_max_tokens, 

455 user_model, 

456 user_request_timeout, 

457 user_temperature, 

458 version, 

459 ) 

460 

461 # Read request body 

462 body: Final = await request.body() 

463 data: Final = orjson.loads(body) if body else {} 

464 

465 # Set skill_id from path parameter 

466 data["skill_id"] = skill_id 

467 

468 # Extract model for routing (header > query > body) 

469 model: Final = data.get("model") or request.query_params.get("model") or request.headers.get("x-litellm-model") 

470 if model: 470 ↛ 471line 470 didn't jump to line 471 because the condition on line 470 was never true

471 data["model"] = model 

472 

473 # Set custom_llm_provider: body > query param > default 

474 if "custom_llm_provider" not in data: 474 ↛ 478line 474 didn't jump to line 478 because the condition on line 474 was always true

475 data["custom_llm_provider"] = custom_llm_provider 

476 

477 # Process request using ProxyBaseLLMRequestProcessing 

478 processor: Final = ProxyBaseLLMRequestProcessing(data=data) 

479 try: 

480 return await processor.base_process_llm_request( 

481 request=request, 

482 fastapi_response=fastapi_response, 

483 user_api_key_dict=user_api_key_dict, 

484 route_type="adelete_skill", 

485 proxy_logging_obj=proxy_logging_obj, 

486 llm_router=llm_router, 

487 general_settings=general_settings, 

488 proxy_config=proxy_config, 

489 select_data_generator=select_data_generator, 

490 model=data.get("model"), 

491 user_model=user_model, 

492 user_temperature=user_temperature, 

493 user_request_timeout=user_request_timeout, 

494 user_max_tokens=user_max_tokens, 

495 user_api_base=user_api_base, 

496 version=version, 

497 ) 

498 except Exception as e: 

499 raise await processor._handle_llm_api_exception( 

500 e=e, 

501 user_api_key_dict=user_api_key_dict, 

502 proxy_logging_obj=proxy_logging_obj, 

503 version=version, 

504 )