mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-28 01:32:17 +00:00
* ci: run the unit_selection.sh shard files on every event instead of only fork pull requests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci: rename fork-flag to unit-flag now that it applies on every event * test: move tests/test_litellm root and small trees into tests/unit Pure renames, no content changes. Follow-up commits in this PR fix references, merge the three files that already existed in tests/unit, keep live-provider tests in tests/test_litellm and wire CI. * test: carry tests/test_litellm conftest isolation into tests/unit Callback lists, routing fallbacks, cached HTTP clients, logger state, AWS, proxy-URL and keychain env, and session-end client cleanup now reset for unit tests too. The environment isolation owns its MonkeyPatch so a test's own monkeypatch is undone before the model-cost teardown runs. * test: merge, split and prune the moved root and small-tree tests Merge batches/test_batch_utils.py and the chat_completions and messages dispatch tests into the files that already existed in tests/unit. Keep the live Gemini interactions tests, the async image-fetch format test and the OpenAI embedding scorer test in tests/test_litellm since they need real network or keys. Put test_router.py under tests/unit/test_router so the existing package no longer shadows it. Delete eight tests the audit found superseded by stronger ones kept in this move. * ci: run the moved root and small-tree tests under their legacy flags Add the misc and responses-caching-types flags to unit_selection.sh and CircleCI, extend enterprise-routing and mcp-integration, and point the legacy GHA shards, Makefile, redis-compat workflow, merge smoke manifest and change classifier at the new paths. * test: make the new tests/unit directories packages tests/unit/test_package_layout.py requires every directory to carry an __init__.py, and without one the moved and retained test_litellm_responses_bridge.py modules collide on import. * test: scope the unit socket block to tests/unit in shared sessions The GHA shards collect the legacy test-path and the unit selection in one pytest session. The unit conftest's loopback-only block leaked into legacy modules that reach the network at import. The legacy conftest now lifts the restriction at collect and setup time, and the unit conftest re-applies it when collecting its own modules. * test: give the shard-script tests their own GITHUB_OUTPUT They only passed where the runner set it. The CircleCI unit job's env allowlist drops it, so the script's redirect failed there. * test: point the router and module-deletion checks at tests/unit router_code_coverage and code_qa_check_tests only searched tests/test_litellm, so the moved router tests no longer counted. The two silent-experiment tests the audit deleted were the only direct callers of those methods; they are replaced with tests that assert the forwarded shadow request and the recursion guard. --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
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/unit/test_anthropic_skills_transformation.py
|