mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
* test: drop the cwd-relative sys.path.insert calls from the test suite
TQ003 stands at 1,077 across 1,058 files, and 1,015 of them are the same shape:
sys.path.insert(0, os.path.abspath("../..")) and its deeper siblings. The
argument resolves against the working directory rather than the file, so from
the repo root, where every job runs pytest, it inserts the directory two levels
above the checkout. It has never pointed at litellm. The package is installed
into the environment anyway, which is what actually makes the import work, and
what the rule's message has said all along.
Removing them leaves 1,634 imports of sys and os with no remaining reference,
and those go too, except where another test module imports the name back out of
the file. The rest of TQ003 is 62 call sites that resolve against __file__ or a
variable, which are a different question and are left alone.
Collection is identical either way: 45,871 tests and the same 51 pre-existing
collection errors before and after, and ruff reports no new undefined name.
* test: drop the duplicate imports the sys.path sweep exposed to F811
* test(pre-call-utils): restore the os import the new bedrock tests need
280 lines
8.9 KiB
Python
280 lines
8.9 KiB
Python
"""
|
|
Tests for Skills API operations across providers
|
|
"""
|
|
|
|
import os
|
|
import zipfile
|
|
from abc import ABC, abstractmethod
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import pytest
|
|
|
|
|
|
import litellm
|
|
from litellm.types.llms.anthropic_skills import (
|
|
DeleteSkillResponse,
|
|
ListSkillsResponse,
|
|
Skill,
|
|
)
|
|
|
|
|
|
@contextmanager
|
|
def create_skill_zip(skill_name: str, unique_suffix: Optional[str] = None):
|
|
"""
|
|
Helper context manager to create a zip file for a skill.
|
|
|
|
Args:
|
|
skill_name: Name of the skill directory in test_skills_data/
|
|
unique_suffix: Optional suffix to make the skill name unique in the zip.
|
|
When provided, the SKILL.md frontmatter name is rewritten
|
|
to avoid duplicate-name conflicts on the API side.
|
|
|
|
Yields:
|
|
File handle to the zip file
|
|
|
|
The zip file is automatically cleaned up after use.
|
|
"""
|
|
import time
|
|
|
|
test_dir = Path(__file__).parent / "test_skills_data"
|
|
skill_dir = test_dir / skill_name
|
|
|
|
# Create a zip file containing the skill directory
|
|
# When unique_suffix is set, folder name must match skill name in SKILL.md (Anthropic requirement)
|
|
zip_folder_name = f"{skill_name}-{unique_suffix}" if unique_suffix else skill_name
|
|
zip_path = test_dir / f"{skill_name}.zip"
|
|
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
if unique_suffix is not None:
|
|
# Rewrite SKILL.md with a unique name and use matching folder name
|
|
skill_md = (skill_dir / "SKILL.md").read_text()
|
|
skill_md = skill_md.replace(
|
|
f"name: {skill_name}",
|
|
f"name: {zip_folder_name}",
|
|
)
|
|
zf.writestr(f"{zip_folder_name}/SKILL.md", skill_md)
|
|
# Add any other files in the skill dir (e.g. subdirs) under the new folder name
|
|
for f in skill_dir.rglob("*"):
|
|
if f.is_file() and f.name != "SKILL.md":
|
|
rel = f.relative_to(skill_dir)
|
|
zf.write(f, arcname=f"{zip_folder_name}/{rel}")
|
|
else:
|
|
zf.write(skill_dir, arcname=skill_name)
|
|
zf.write(skill_dir / "SKILL.md", arcname=f"{skill_name}/SKILL.md")
|
|
|
|
try:
|
|
with open(zip_path, "rb") as f:
|
|
yield f
|
|
finally:
|
|
# Clean up zip file
|
|
if zip_path.exists():
|
|
zip_path.unlink()
|
|
|
|
|
|
class BaseSkillsAPITest(ABC):
|
|
"""
|
|
Base test class for Skills API operations.
|
|
Tests create, list, get, and delete operations.
|
|
"""
|
|
|
|
@abstractmethod
|
|
def get_custom_llm_provider(self) -> str:
|
|
"""Return the provider name (e.g., 'anthropic')"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_api_key(self) -> Optional[str]:
|
|
"""Return the API key for the provider"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_api_base(self) -> Optional[str]:
|
|
"""Return the API base URL for the provider"""
|
|
pass
|
|
|
|
def test_create_skill(self):
|
|
"""
|
|
Test creating a skill.
|
|
|
|
Note: This test creates a skill but does not clean it up,
|
|
as we want to verify it was created successfully.
|
|
The test_delete_skill test will handle cleanup.
|
|
"""
|
|
import time
|
|
|
|
custom_llm_provider = self.get_custom_llm_provider()
|
|
api_key = self.get_api_key()
|
|
api_base = self.get_api_base()
|
|
|
|
if not api_key:
|
|
pytest.skip(f"No API key provided for {custom_llm_provider}")
|
|
|
|
litellm.set_verbose = True
|
|
litellm._turn_on_debug()
|
|
|
|
# Use helper to create skill zip
|
|
skill_name = "test-skill-litellm"
|
|
|
|
# Use unique title and unique skill name to avoid conflicts
|
|
# with previous test runs (skills are never cleaned up in CI)
|
|
ts = str(int(time.time()))
|
|
unique_title = f"Test Skill {ts}"
|
|
|
|
# Upload the skill with the zip file
|
|
with create_skill_zip(skill_name, unique_suffix=ts) as zip_file:
|
|
response = litellm.create_skill(
|
|
display_title=unique_title,
|
|
files=[zip_file],
|
|
custom_llm_provider=custom_llm_provider,
|
|
api_key=api_key,
|
|
api_base=api_base,
|
|
)
|
|
|
|
assert response is not None
|
|
assert isinstance(response, Skill)
|
|
assert response.id is not None
|
|
print(f"Created skill: {response}")
|
|
print(f"Skill ID: {response.id}")
|
|
|
|
def test_list_skills(self):
|
|
"""
|
|
Test listing skills.
|
|
"""
|
|
|
|
custom_llm_provider = self.get_custom_llm_provider()
|
|
api_key = self.get_api_key()
|
|
api_base = self.get_api_base()
|
|
|
|
if not api_key:
|
|
pytest.skip(f"No API key provided for {custom_llm_provider}")
|
|
|
|
# Enable debug logging
|
|
os.environ["LITELLM_LOG"] = "DEBUG"
|
|
litellm.set_verbose = True
|
|
|
|
print(f"\n=== Testing list_skills ===")
|
|
print("API Key: [REDACTED]")
|
|
print(f"API Base: {api_base}")
|
|
|
|
response = litellm.list_skills(
|
|
limit=10,
|
|
custom_llm_provider=custom_llm_provider,
|
|
api_key=api_key,
|
|
api_base=api_base,
|
|
)
|
|
|
|
assert response is not None
|
|
assert isinstance(response, ListSkillsResponse)
|
|
assert hasattr(response, "data")
|
|
print(f"Listed skills: {response}")
|
|
|
|
def test_get_skill(self):
|
|
"""
|
|
Test getting a specific skill by ID.
|
|
"""
|
|
custom_llm_provider = self.get_custom_llm_provider()
|
|
api_key = self.get_api_key()
|
|
api_base = self.get_api_base()
|
|
|
|
if not api_key:
|
|
pytest.skip(f"No API key provided for {custom_llm_provider}")
|
|
|
|
litellm.set_verbose = True
|
|
|
|
# First list existing skills to see if any exist
|
|
list_response = litellm.list_skills(
|
|
limit=1,
|
|
custom_llm_provider=custom_llm_provider,
|
|
api_key=api_key,
|
|
api_base=api_base,
|
|
)
|
|
|
|
# Type assertion for linter
|
|
assert isinstance(list_response, ListSkillsResponse)
|
|
print(f"List response: {list_response}")
|
|
|
|
# If there are existing skills, use the first one
|
|
if list_response.data and len(list_response.data) > 0:
|
|
skill_id = list_response.data[0].id
|
|
should_cleanup = False
|
|
print(f"Using existing skill: {skill_id}")
|
|
|
|
# Now get the skill
|
|
response = litellm.get_skill(
|
|
skill_id=skill_id,
|
|
custom_llm_provider=custom_llm_provider,
|
|
api_key=api_key,
|
|
api_base=api_base,
|
|
)
|
|
|
|
assert response is not None
|
|
assert isinstance(response, Skill)
|
|
assert response.id == skill_id
|
|
print(f"GET - Retrieved skill: {response}")
|
|
|
|
def test_delete_skill(self):
|
|
"""
|
|
Test deleting a skill.
|
|
|
|
Note: Anthropic requires deleting all skill versions before deleting the skill itself.
|
|
This test is currently skipped as it would require additional API calls to delete versions.
|
|
"""
|
|
import time
|
|
|
|
custom_llm_provider = self.get_custom_llm_provider()
|
|
api_key = self.get_api_key()
|
|
api_base = self.get_api_base()
|
|
|
|
if not api_key:
|
|
pytest.skip(f"No API key provided for {custom_llm_provider}")
|
|
|
|
pytest.skip(
|
|
"Anthropic requires deleting all skill versions first - skipping for now"
|
|
)
|
|
|
|
litellm.set_verbose = True
|
|
|
|
# Use helper to create skill zip
|
|
skill_name = "test-delete-skill"
|
|
|
|
# Use unique title and skill name to avoid conflicts
|
|
ts = str(int(time.time()))
|
|
unique_title = f"Test Delete Skill {ts}"
|
|
|
|
# Create a skill specifically to delete
|
|
with create_skill_zip(skill_name, unique_suffix=ts) as zip_file:
|
|
created_skill = litellm.create_skill(
|
|
display_title=unique_title,
|
|
files=[zip_file],
|
|
custom_llm_provider=custom_llm_provider,
|
|
api_key=api_key,
|
|
api_base=api_base,
|
|
)
|
|
|
|
# Type assertion for linter
|
|
assert isinstance(created_skill, Skill)
|
|
skill_id = created_skill.id
|
|
print(f"Created skill to delete: {skill_id}")
|
|
|
|
# Now delete the skill
|
|
response = litellm.delete_skill(
|
|
skill_id=skill_id,
|
|
custom_llm_provider=custom_llm_provider,
|
|
api_key=api_key,
|
|
api_base=api_base,
|
|
)
|
|
|
|
assert response is not None
|
|
assert isinstance(response, DeleteSkillResponse)
|
|
assert response.type == "skill_deleted"
|
|
print(f"Deleted skill response: {response}")
|
|
|
|
|
|
# Live integration tests for the Anthropic Skills API are not run in CI because
|
|
# the Skills API requires beta access (anthropic-beta: skills-2025-10-02) that
|
|
# is not available on the standard API key used in CI.
|
|
#
|
|
# Transformation logic (URL construction, headers, request/response parsing) is
|
|
# covered by unit tests in:
|
|
# tests/test_litellm/test_anthropic_skills_transformation.py
|