litellm/tests/test_litellm/test_anthropic_skills_transformation.py

348 lines
13 KiB
Python

"""
Unit tests for Anthropic Skills API request/response transformation.
These tests validate URL construction, header generation, request payload
building, and response parsing without requiring a live Anthropic API key
or beta access to the Skills API.
"""
from unittest.mock import MagicMock, patch
import httpx
import pytest
from litellm.constants import ANTHROPIC_SKILLS_API_BETA_VERSION
from litellm.llms.anthropic.skills.transformation import AnthropicSkillsConfig
from litellm.types.llms.anthropic_skills import (
CreateSkillRequest,
DeleteSkillResponse,
ListSkillsParams,
ListSkillsResponse,
Skill,
)
from litellm.types.router import GenericLiteLLMParams
FAKE_API_KEY = "sk-ant-test-key-1234"
FAKE_API_BASE = "https://api.anthropic.com"
def _make_mock_response(
json_data: dict, status_code: int = 200, method: str = "POST"
) -> httpx.Response:
return httpx.Response(
status_code=status_code,
json=json_data,
request=httpx.Request(method, "https://api.anthropic.com/v1/skills"),
)
def _make_skill_payload(**kwargs) -> dict:
defaults = {
"id": "skill_abc123",
"created_at": "2025-10-15T12:00:00Z",
"updated_at": "2025-10-15T12:00:00Z",
"source": "custom",
"type": "skill",
"display_title": "Test Skill",
"latest_version": "v1",
}
defaults.update(kwargs)
return defaults
class TestAnthropicSkillsConfigURLConstruction:
def setup_method(self):
self.config = AnthropicSkillsConfig()
def test_url_without_skill_id(self):
url = self.config.get_complete_url(
api_base=FAKE_API_BASE,
endpoint="skills",
)
assert url == f"{FAKE_API_BASE}/v1/skills"
def test_url_with_skill_id(self):
url = self.config.get_complete_url(
api_base=FAKE_API_BASE,
endpoint="skills",
skill_id="skill_abc123",
)
assert url == f"{FAKE_API_BASE}/v1/skills/skill_abc123"
def test_url_with_skill_id_encodes_path_segment(self):
url = self.config.get_complete_url(
api_base=FAKE_API_BASE,
endpoint="skills",
skill_id="../../files?x=1#frag",
)
assert url == f"{FAKE_API_BASE}/v1/skills/..%2F..%2Ffiles%3Fx%3D1%23frag"
def test_url_falls_back_to_anthropic_default(self):
with patch(
"litellm.llms.anthropic.common_utils.AnthropicModelInfo.get_api_base",
return_value="https://api.anthropic.com",
):
url = self.config.get_complete_url(
api_base=None,
endpoint="skills",
)
assert url == "https://api.anthropic.com/v1/skills"
def test_url_with_custom_api_base(self):
custom_base = "https://my-proxy.example.com"
url = self.config.get_complete_url(
api_base=custom_base,
endpoint="skills",
)
assert url == f"{custom_base}/v1/skills"
class TestAnthropicSkillsConfigHeaderValidation:
def setup_method(self):
self.config = AnthropicSkillsConfig()
def _make_litellm_params(self, api_key=FAKE_API_KEY):
return GenericLiteLLMParams(api_key=api_key)
def test_sets_api_key_header(self):
with patch(
"litellm.llms.anthropic.common_utils.AnthropicModelInfo.get_api_key",
return_value=FAKE_API_KEY,
):
headers = self.config.validate_environment(
headers={}, litellm_params=self._make_litellm_params()
)
assert headers["x-api-key"] == FAKE_API_KEY
def test_sets_anthropic_version_header(self):
with patch(
"litellm.llms.anthropic.common_utils.AnthropicModelInfo.get_api_key",
return_value=FAKE_API_KEY,
):
headers = self.config.validate_environment(
headers={}, litellm_params=self._make_litellm_params()
)
assert headers["anthropic-version"] == "2023-06-01"
def test_sets_skills_beta_header(self):
with patch(
"litellm.llms.anthropic.common_utils.AnthropicModelInfo.get_api_key",
return_value=FAKE_API_KEY,
):
headers = self.config.validate_environment(
headers={}, litellm_params=self._make_litellm_params()
)
assert headers["anthropic-beta"] == ANTHROPIC_SKILLS_API_BETA_VERSION
def test_merges_existing_beta_header_string(self):
with patch(
"litellm.llms.anthropic.common_utils.AnthropicModelInfo.get_api_key",
return_value=FAKE_API_KEY,
):
headers = self.config.validate_environment(
headers={"anthropic-beta": "other-beta-2024-01-01"},
litellm_params=self._make_litellm_params(),
)
assert isinstance(headers["anthropic-beta"], list)
assert "other-beta-2024-01-01" in headers["anthropic-beta"]
assert ANTHROPIC_SKILLS_API_BETA_VERSION in headers["anthropic-beta"]
def test_merges_existing_beta_header_list(self):
with patch(
"litellm.llms.anthropic.common_utils.AnthropicModelInfo.get_api_key",
return_value=FAKE_API_KEY,
):
headers = self.config.validate_environment(
headers={"anthropic-beta": ["other-beta-2024-01-01"]},
litellm_params=self._make_litellm_params(),
)
assert ANTHROPIC_SKILLS_API_BETA_VERSION in headers["anthropic-beta"]
assert "other-beta-2024-01-01" in headers["anthropic-beta"]
def test_does_not_duplicate_beta_header(self):
with patch(
"litellm.llms.anthropic.common_utils.AnthropicModelInfo.get_api_key",
return_value=FAKE_API_KEY,
):
headers = self.config.validate_environment(
headers={"anthropic-beta": ANTHROPIC_SKILLS_API_BETA_VERSION},
litellm_params=self._make_litellm_params(),
)
beta = headers["anthropic-beta"]
if isinstance(beta, list):
assert beta.count(ANTHROPIC_SKILLS_API_BETA_VERSION) == 1
else:
assert beta == ANTHROPIC_SKILLS_API_BETA_VERSION
def test_raises_without_api_key(self):
with patch(
"litellm.llms.anthropic.common_utils.AnthropicModelInfo.get_api_key",
return_value=None,
):
with pytest.raises(ValueError, match="ANTHROPIC_API_KEY"):
self.config.validate_environment(
headers={}, litellm_params=self._make_litellm_params(api_key=None)
)
class TestAnthropicSkillsConfigCreateRequestTransformation:
def setup_method(self):
self.config = AnthropicSkillsConfig()
self.litellm_params = GenericLiteLLMParams(api_key=FAKE_API_KEY)
def test_display_title_included(self):
create_request: CreateSkillRequest = {"display_title": "My Skill"}
body = self.config.transform_create_skill_request(
create_request=create_request,
litellm_params=self.litellm_params,
headers={},
)
assert body["display_title"] == "My Skill"
def test_none_values_excluded(self):
create_request: CreateSkillRequest = {"display_title": None, "files": None}
body = self.config.transform_create_skill_request(
create_request=create_request,
litellm_params=self.litellm_params,
headers={},
)
assert "display_title" not in body
assert "files" not in body
def test_empty_request_produces_empty_body(self):
create_request: CreateSkillRequest = {}
body = self.config.transform_create_skill_request(
create_request=create_request,
litellm_params=self.litellm_params,
headers={},
)
assert body == {}
class TestAnthropicSkillsConfigListRequestTransformation:
def setup_method(self):
self.config = AnthropicSkillsConfig()
self.litellm_params = GenericLiteLLMParams(api_key=FAKE_API_KEY)
def test_limit_included_in_query_params(self):
list_params: ListSkillsParams = {"limit": 25}
with patch(
"litellm.llms.anthropic.common_utils.AnthropicModelInfo.get_api_base",
return_value=FAKE_API_BASE,
):
url, query_params = self.config.transform_list_skills_request(
list_params=list_params,
litellm_params=self.litellm_params,
headers={},
)
assert query_params["limit"] == 25
assert url == f"{FAKE_API_BASE}/v1/skills"
def test_source_filter_included(self):
list_params: ListSkillsParams = {"source": "custom"}
with patch(
"litellm.llms.anthropic.common_utils.AnthropicModelInfo.get_api_base",
return_value=FAKE_API_BASE,
):
_, query_params = self.config.transform_list_skills_request(
list_params=list_params,
litellm_params=self.litellm_params,
headers={},
)
assert query_params["source"] == "custom"
def test_empty_params_produce_empty_query(self):
list_params: ListSkillsParams = {}
with patch(
"litellm.llms.anthropic.common_utils.AnthropicModelInfo.get_api_base",
return_value=FAKE_API_BASE,
):
_, query_params = self.config.transform_list_skills_request(
list_params=list_params,
litellm_params=self.litellm_params,
headers={},
)
assert query_params == {}
class TestAnthropicSkillsConfigResponseTransformation:
def setup_method(self):
self.config = AnthropicSkillsConfig()
self.logging_obj = MagicMock()
def test_create_skill_response_parses_skill(self):
payload = _make_skill_payload()
raw = _make_mock_response(payload)
skill = self.config.transform_create_skill_response(
raw_response=raw, logging_obj=self.logging_obj
)
assert isinstance(skill, Skill)
assert skill.id == "skill_abc123"
assert skill.source == "custom"
assert skill.display_title == "Test Skill"
def test_get_skill_response_parses_skill(self):
payload = _make_skill_payload(id="skill_xyz", display_title="Another")
raw = _make_mock_response(payload, method="GET")
skill = self.config.transform_get_skill_response(
raw_response=raw, logging_obj=self.logging_obj
)
assert isinstance(skill, Skill)
assert skill.id == "skill_xyz"
assert skill.display_title == "Another"
def test_list_skills_response_parses_list(self):
payload = {
"data": [_make_skill_payload(), _make_skill_payload(id="skill_def456")],
"has_more": False,
"next_page": None,
}
raw = _make_mock_response(payload, method="GET")
result = self.config.transform_list_skills_response(
raw_response=raw, logging_obj=self.logging_obj
)
assert isinstance(result, ListSkillsResponse)
assert len(result.data) == 2
assert result.data[0].id == "skill_abc123"
assert result.data[1].id == "skill_def456"
assert result.has_more is False
def test_list_skills_response_with_pagination(self):
payload = {
"data": [_make_skill_payload()],
"has_more": True,
"next_page": "page_token_xyz",
}
raw = _make_mock_response(payload, method="GET")
result = self.config.transform_list_skills_response(
raw_response=raw, logging_obj=self.logging_obj
)
assert result.has_more is True
assert result.next_page == "page_token_xyz"
def test_delete_skill_response_parses_correctly(self):
payload = {"id": "skill_abc123", "type": "skill_deleted"}
raw = _make_mock_response(payload, method="DELETE")
result = self.config.transform_delete_skill_response(
raw_response=raw, logging_obj=self.logging_obj
)
assert isinstance(result, DeleteSkillResponse)
assert result.id == "skill_abc123"
assert result.type == "skill_deleted"
def test_skill_response_optional_fields_default(self):
payload = {
"id": "skill_minimal",
"created_at": "2025-10-15T12:00:00Z",
"updated_at": "2025-10-15T12:00:00Z",
"source": "anthropic",
"type": "skill",
}
raw = _make_mock_response(payload)
skill = self.config.transform_create_skill_response(
raw_response=raw, logging_obj=self.logging_obj
)
assert skill.display_title is None
assert skill.latest_version is None