v2.17.1 (#823)
Co-authored-by: Jean Burellier <sheplu@users.noreply.github.com> Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Nelson PROIA <144663685+Nelson-PROIA@users.noreply.github.com> Co-authored-by: renovate-mistral[bot] <253709520+renovate-mistral[bot]@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
6bedf271ce
commit
725d3a56ce
35 changed files with 1330 additions and 288 deletions
195
tests/skills/registry/test_client.py
Normal file
195
tests/skills/registry/test_client.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from vibe.core.skills.registry._client import RegistrySkillsClient, RegistrySkillsError
|
||||
|
||||
_URL = "https://api.mistral.ai/v1/skills"
|
||||
|
||||
|
||||
def _page(skill_id: str, *, next_token: str = "") -> dict[str, object]:
|
||||
return {
|
||||
"data": [
|
||||
{"skillId": skill_id, "skill": {"skillName": skill_id, "skillBody": "b"}}
|
||||
],
|
||||
"nextPageToken": next_token,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_list_catalog_paginates() -> None:
|
||||
route = respx.get(_URL)
|
||||
route.side_effect = [
|
||||
httpx.Response(200, json=_page("a", next_token="p2")),
|
||||
httpx.Response(200, json=_page("b")),
|
||||
]
|
||||
|
||||
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
||||
items = await client.list_catalog(page_size=50)
|
||||
|
||||
assert [item.skill_id for item in items] == ["a", "b"]
|
||||
assert route.calls[0].request.url.params["pageSize"] == "50"
|
||||
assert "pageToken" not in route.calls[0].request.url.params
|
||||
assert route.calls[1].request.url.params["pageToken"] == "p2"
|
||||
assert route.calls[0].request.headers["Authorization"] == "Bearer key"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_list_catalog_single_page() -> None:
|
||||
respx.get(_URL).mock(return_value=httpx.Response(200, json=_page("only")))
|
||||
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
||||
items = await client.list_catalog(page_size=100)
|
||||
assert [item.skill_id for item in items] == ["only"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_unauthorized_raises() -> None:
|
||||
respx.get(_URL).mock(return_value=httpx.Response(401, json={"message": "no"}))
|
||||
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
||||
with pytest.raises(RegistrySkillsError, match="unauthorized"):
|
||||
await client.list_catalog(page_size=10)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_server_error_raises() -> None:
|
||||
respx.get(_URL).mock(return_value=httpx.Response(503))
|
||||
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
||||
with pytest.raises(RegistrySkillsError, match="unexpected status"):
|
||||
await client.list_catalog(page_size=10)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_non_json_raises() -> None:
|
||||
respx.get(_URL).mock(return_value=httpx.Response(200, text="not json"))
|
||||
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
||||
with pytest.raises(RegistrySkillsError, match="valid JSON"):
|
||||
await client.list_catalog(page_size=10)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_network_error_raises() -> None:
|
||||
respx.get(_URL).mock(side_effect=httpx.ConnectError("boom"))
|
||||
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
||||
with pytest.raises(RegistrySkillsError, match="request failed"):
|
||||
await client.list_catalog(page_size=10)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_list_catalog_sends_fields_mask() -> None:
|
||||
route = respx.get(_URL).mock(return_value=httpx.Response(200, json=_page("a")))
|
||||
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
||||
await client.list_catalog(page_size=10)
|
||||
params = route.calls[0].request.url.params
|
||||
assert params["pageSize"] == "10"
|
||||
assert "skillBody" not in params["fields"]
|
||||
assert "skillName" in params["fields"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_list_versions_parses_aliases_and_sorts_desc() -> None:
|
||||
respx.get(f"{_URL}/sid/versions").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"items": [
|
||||
{"version": 1, "versionAttributes": {"aliases": ["old"]}},
|
||||
{
|
||||
"version": 3,
|
||||
"versionAttributes": {"aliases": ["stable", "main"]},
|
||||
},
|
||||
{"version": 2},
|
||||
]
|
||||
},
|
||||
)
|
||||
)
|
||||
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
||||
versions = await client.list_versions("sid")
|
||||
|
||||
assert [v.version for v in versions] == [3, 2, 1]
|
||||
assert versions[0].aliases == ["stable", "main"]
|
||||
assert versions[1].aliases == []
|
||||
assert versions[2].aliases == ["old"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_list_versions_invalid_payload_raises() -> None:
|
||||
respx.get(f"{_URL}/sid/versions").mock(
|
||||
return_value=httpx.Response(200, json={"items": [{"noVersion": 1}]})
|
||||
)
|
||||
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
||||
with pytest.raises(RegistrySkillsError, match="invalid versions response"):
|
||||
await client.list_versions("sid")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_get_skill_by_id() -> None:
|
||||
respx.get(f"{_URL}/sid").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"skillId": "sid",
|
||||
"skill": {"skillName": "n", "skillBody": "b"},
|
||||
"version": 3,
|
||||
},
|
||||
)
|
||||
)
|
||||
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
||||
item = await client.get_skill("sid")
|
||||
assert item.skill_id == "sid"
|
||||
assert item.version == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_get_skill_not_found_raises() -> None:
|
||||
respx.get(f"{_URL}/missing").mock(return_value=httpx.Response(404))
|
||||
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
||||
with pytest.raises(RegistrySkillsError, match="not found"):
|
||||
await client.get_skill("missing")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_get_skill_invalid_payload_raises() -> None:
|
||||
# A 200 whose body isn't a valid skill object must surface as a registry
|
||||
# error, not a bare pydantic ValidationError.
|
||||
respx.get(f"{_URL}/sid").mock(
|
||||
return_value=httpx.Response(200, json="not-an-object")
|
||||
)
|
||||
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
||||
with pytest.raises(RegistrySkillsError, match="invalid skill response"):
|
||||
await client.get_skill("sid")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_list_catalog_invalid_payload_raises() -> None:
|
||||
respx.get(_URL).mock(return_value=httpx.Response(200, json="not-an-object"))
|
||||
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
||||
with pytest.raises(RegistrySkillsError, match="invalid catalog response"):
|
||||
await client.list_catalog(page_size=10)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_list_catalog_raises_when_page_cap_exceeded() -> None:
|
||||
# Every page reports more pages -> the cap is hit with data remaining, which
|
||||
# must raise rather than silently return a truncated catalog.
|
||||
respx.get(_URL).mock(
|
||||
return_value=httpx.Response(200, json=_page("a", next_token="more"))
|
||||
)
|
||||
async with RegistrySkillsClient("https://api.mistral.ai/v1", "key") as client:
|
||||
with pytest.raises(RegistrySkillsError, match="maximum number of pages"):
|
||||
await client.list_catalog(page_size=10)
|
||||
169
tests/skills/registry/test_models.py
Normal file
169
tests/skills/registry/test_models.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.core.skills.registry.models import (
|
||||
ListSkillsResponse,
|
||||
RegistryAssetContent,
|
||||
RegistrySkillItem,
|
||||
sanitize_skill_name,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("my_skill", "my-skill"),
|
||||
("My Skill", "my-skill"),
|
||||
(" weird__name--ok ", "weird-name-ok"),
|
||||
("grill-me", "grill-me"),
|
||||
("___", None),
|
||||
("", None),
|
||||
],
|
||||
)
|
||||
def test_sanitize_skill_name(raw: str, expected: str | None) -> None:
|
||||
assert sanitize_skill_name(raw) == expected
|
||||
|
||||
|
||||
def test_asset_to_bytes_text() -> None:
|
||||
asset = RegistryAssetContent(text_content="hello")
|
||||
assert asset.to_bytes() == b"hello"
|
||||
|
||||
|
||||
def test_asset_to_bytes_raw_base64() -> None:
|
||||
encoded = base64.b64encode(b"\x00\x01binary").decode("ascii")
|
||||
asset = RegistryAssetContent(raw_content=encoded, is_executable=True)
|
||||
assert asset.to_bytes() == b"\x00\x01binary"
|
||||
assert asset.is_executable is True
|
||||
|
||||
|
||||
def test_asset_to_bytes_invalid_base64_returns_none() -> None:
|
||||
assert RegistryAssetContent(raw_content="not!base64!").to_bytes() is None
|
||||
|
||||
|
||||
def test_asset_to_bytes_empty_returns_none() -> None:
|
||||
assert RegistryAssetContent().to_bytes() is None
|
||||
|
||||
|
||||
def test_item_parses_camel_case() -> None:
|
||||
item = RegistrySkillItem.model_validate({
|
||||
"skillId": "abc",
|
||||
"skill": {
|
||||
"skillName": "Free Form",
|
||||
"skillDescription": "does things",
|
||||
"skillBody": "# body",
|
||||
"skillAssets": {"ref.txt": {"textContent": "x", "isExecutable": False}},
|
||||
},
|
||||
"metadata": {"name": "my_skill", "latestVersion": 3},
|
||||
"version": 3,
|
||||
})
|
||||
assert item.skill_id == "abc"
|
||||
assert item.resolved_name == "my-skill"
|
||||
assert item.resolved_description == "does things"
|
||||
assert item.skill.skill_assets["ref.txt"].text_content == "x"
|
||||
assert item.version == 3
|
||||
|
||||
|
||||
def test_item_parses_snake_case_fallback() -> None:
|
||||
item = RegistrySkillItem.model_validate({
|
||||
"skill_id": "abc",
|
||||
"skill": {"skill_name": "n", "skill_body": "b"},
|
||||
"metadata": {"name": "snake_name"},
|
||||
})
|
||||
assert item.resolved_name == "snake-name"
|
||||
|
||||
|
||||
def test_item_name_falls_back_to_skill_name() -> None:
|
||||
item = RegistrySkillItem.model_validate({
|
||||
"skill": {"skillName": "Fallback Name", "skillBody": "b"}
|
||||
})
|
||||
assert item.resolved_name == "fallback-name"
|
||||
|
||||
|
||||
def test_item_name_falls_back_to_title() -> None:
|
||||
item = RegistrySkillItem.model_validate({
|
||||
"skillId": "abc",
|
||||
"skill": {"skillBody": "b"},
|
||||
"attributes": {"title": "My Cool Skill"},
|
||||
})
|
||||
assert item.resolved_name == "my-cool-skill"
|
||||
|
||||
|
||||
def test_item_name_falls_back_to_skill_id() -> None:
|
||||
item = RegistrySkillItem.model_validate({
|
||||
"skillId": "019de91a-84f5-76af-84a2-5f4389f372c7",
|
||||
"skill": {"skillBody": "b"},
|
||||
})
|
||||
assert item.resolved_name == "skill-019de91a84f576af84a25f4389f372c7"
|
||||
|
||||
|
||||
def test_item_name_none_without_any_identifier() -> None:
|
||||
item = RegistrySkillItem.model_validate({"skill": {"skillBody": "b"}})
|
||||
assert item.resolved_name is None
|
||||
|
||||
|
||||
def test_item_description_falls_back_to_attributes() -> None:
|
||||
item = RegistrySkillItem.model_validate({
|
||||
"skill": {"skillName": "n", "skillBody": "b"},
|
||||
"attributes": {"title": "T", "description": "attr desc"},
|
||||
})
|
||||
assert item.resolved_description == "attr desc"
|
||||
|
||||
|
||||
def test_list_response_parses_pagination() -> None:
|
||||
response = ListSkillsResponse.model_validate({
|
||||
"data": [{"skillId": "1", "skill": {"skillName": "a", "skillBody": "b"}}],
|
||||
"nextPageToken": "next",
|
||||
})
|
||||
assert len(response.data) == 1
|
||||
assert response.next_page_token == "next"
|
||||
|
||||
|
||||
def test_list_response_defaults_empty() -> None:
|
||||
response = ListSkillsResponse.model_validate({})
|
||||
assert response.data == []
|
||||
assert response.next_page_token == ""
|
||||
|
||||
|
||||
# Resolved/sanitized names must always satisfy SkillMetadata.name's pattern.
|
||||
_NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
||||
|
||||
|
||||
def test_sanitize_skill_name_caps_at_64_chars() -> None:
|
||||
name = sanitize_skill_name("x" * 200)
|
||||
assert name is not None
|
||||
assert len(name) <= 64
|
||||
assert _NAME_RE.match(name)
|
||||
|
||||
|
||||
def test_resolved_name_id_fallback_is_lowercase_and_valid() -> None:
|
||||
# No usable name anywhere -> falls back to the skill id, which may be
|
||||
# uppercase; it must still be a valid, lowercase skill name.
|
||||
item = RegistrySkillItem.model_validate({
|
||||
"skillId": "AB12CD34-EF56-7890-ABCD-EF1234567890"
|
||||
})
|
||||
assert item.resolved_name == "skill-ab12cd34ef567890abcdef1234567890"
|
||||
assert _NAME_RE.match(item.resolved_name or "")
|
||||
|
||||
|
||||
def test_resolved_name_id_fallback_uses_full_id_to_avoid_collisions() -> None:
|
||||
# Ids sharing a prefix (common for time-ordered UUIDv7) must not collapse
|
||||
# to the same fallback name.
|
||||
a = RegistrySkillItem.model_validate({
|
||||
"skillId": "ab12cd34-0000-0000-0000-000000000001"
|
||||
})
|
||||
b = RegistrySkillItem.model_validate({
|
||||
"skillId": "ab12cd34-0000-0000-0000-000000000002"
|
||||
})
|
||||
assert a.resolved_name != b.resolved_name
|
||||
|
||||
|
||||
def test_resolved_name_id_fallback_handles_degenerate_id() -> None:
|
||||
# A hyphen-only id must collapse to a valid name, not a trailing-hyphen
|
||||
# "skill-" that would fail validation.
|
||||
item = RegistrySkillItem.model_validate({"skillId": "----"})
|
||||
assert item.resolved_name == "skill"
|
||||
assert _NAME_RE.match(item.resolved_name or "")
|
||||
Loading…
Add table
Add a link
Reference in a new issue