Files
raycast-api/tests/test_models.py
T

154 lines
5.3 KiB
Python

"""Tests for `raycast_api.ai.models.ModelsAPI` and the typed response.
The `/ai/models` endpoint returns a large payload; we only exercise the
shape parsing (`ModelsResponse.from_wire`) and the `by_id` lookup plus
the header that distinguishes our request from older Raycast clients
(`X-Raycast-Experimental: autoModels`).
"""
from __future__ import annotations
from typing import Any
import pytest
from aioresponses import aioresponses
from raycast_api.ai.models import ModelInfo, ModelsResponse
from raycast_api.client import Client
from raycast_api.config import Config
from raycast_api.signing_spec import RotRange, SigningSpec
def _config() -> Config:
return Config(
signature_secret="0" * 64,
signing_spec=SigningSpec(
rot_fn_name="Sur",
signing_fn_name="Nkt",
rot_ranges=[
RotRange(start=65, end=90, shift=13),
RotRange(start=97, end=122, shift=13),
RotRange(start=48, end=57, shift=5),
],
),
app_version="0.60.1.0",
user_agent="Raycast/0.60.1.0 (x-macOS Version 26.3.1)",
bundle_hash="0" * 64,
launcher_hash="0" * 64,
)
def _client(**kwargs: Any) -> Client:
return Client(
config=_config(),
bearer_token="rca_test",
device_id="a" * 64,
clock=lambda: 0,
**kwargs,
)
SAMPLE_RESPONSE = {
"models": [
{
"id": "openai-gpt-5",
"name": "GPT-5",
"model": "gpt-5",
"provider": "openai",
"provider_name": "OpenAI",
"provider_brand": "openai",
"context": 200000,
"description": "Most capable.",
"status": "public",
"availability": "public",
"features": ["chat", "quick_ai", "commands"],
"abilities": {
"system_message": {"supported": True},
"temperature": {"supported": True},
"reasoning_effort": {
"supported": True,
"options": ["minimal", "low", "medium", "high"],
},
},
"in_better_ai_subscription": True,
"requires_better_ai": True,
},
{
"id": "anthropic-claude-sonnet-4-6",
"name": "Claude Sonnet 4.6",
"model": "claude-sonnet-4-6",
"provider": "anthropic",
"context": 200000,
"features": ["chat"],
"abilities": {"temperature": {"supported": True}},
"in_better_ai_subscription": True,
},
],
"default_models": {"chat": "openai-gpt-5", "quick_ai": "openai-gpt-5"},
"free_models": ["openai-gpt-4o-mini", "anthropic-claude-haiku-4-5"],
}
class TestModelsResponse:
def test_parses_model_list(self) -> None:
r = ModelsResponse.from_wire(SAMPLE_RESPONSE)
assert len(r.models) == 2
assert r.models[0].id == "openai-gpt-5"
assert r.models[0].provider == "openai"
assert r.default_models == {"chat": "openai-gpt-5", "quick_ai": "openai-gpt-5"}
assert r.free_model_ids == ["openai-gpt-4o-mini", "anthropic-claude-haiku-4-5"]
def test_free_models_tolerates_object_shape(self) -> None:
"""If the server ever switches back to emitting full objects, parse those too."""
r = ModelsResponse.from_wire(
{
"models": [],
"default_models": {},
"free_models": [{"id": "a"}, {"id": "b"}],
}
)
assert r.free_model_ids == ["a", "b"]
def test_by_id_lookup(self) -> None:
r = ModelsResponse.from_wire(SAMPLE_RESPONSE)
info = r.by_id("openai-gpt-5")
assert info is not None
assert info.name == "GPT-5"
assert r.by_id("missing") is None
def test_abilities_helpers(self) -> None:
r = ModelsResponse.from_wire(SAMPLE_RESPONSE)
gpt5 = r.by_id("openai-gpt-5")
assert gpt5 is not None
assert gpt5.supports_temperature is True
assert gpt5.supports_reasoning_effort is True
assert gpt5.reasoning_effort_options == ["minimal", "low", "medium", "high"]
claude = r.by_id("anthropic-claude-sonnet-4-6")
assert claude is not None
assert claude.supports_reasoning_effort is False
assert claude.reasoning_effort_options == []
class TestModelsAPI:
@pytest.mark.asyncio
async def test_list_sends_experimental_header_and_no_signature(self) -> None:
captured: dict[str, Any] = {}
def _cb(url: Any, **kwargs: Any) -> Any:
captured["headers"] = dict(kwargs.get("headers") or {})
from aioresponses import CallbackResult
return CallbackResult(status=200, payload=SAMPLE_RESPONSE)
with aioresponses() as mocked:
mocked.get("https://backend.raycast.com/api/v1/ai/models", callback=_cb)
async with _client() as client:
resp = await client.models.list()
assert resp.by_id("openai-gpt-5") is not None
assert captured["headers"]["X-Raycast-Experimental"] == "autoModels"
assert "X-Raycast-Signature-v2" not in captured["headers"]
assert "X-Raycast-Timestamp" not in captured["headers"]
assert "X-Raycast-DeviceId" not in captured["headers"]
assert captured["headers"]["Authorization"] == "Bearer rca_test"