154 lines
5.3 KiB
Python
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"
|