splox 0.2.4__tar.gz → 0.2.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {splox-0.2.4 → splox-0.2.6}/PKG-INFO +44 -1
  2. {splox-0.2.4 → splox-0.2.6}/README.md +43 -0
  3. {splox-0.2.4 → splox-0.2.6}/pyproject.toml +1 -1
  4. {splox-0.2.4 → splox-0.2.6}/src/splox/__init__.py +14 -1
  5. {splox-0.2.4 → splox-0.2.6}/src/splox/_client.py +43 -1
  6. {splox-0.2.4 → splox-0.2.6}/src/splox/_models.py +133 -0
  7. splox-0.2.6/tests/test_capabilities.py +196 -0
  8. {splox-0.2.4 → splox-0.2.6}/.github/workflows/publish.yml +0 -0
  9. {splox-0.2.4 → splox-0.2.6}/.github/workflows/test.yml +0 -0
  10. {splox-0.2.4 → splox-0.2.6}/.gitignore +0 -0
  11. {splox-0.2.4 → splox-0.2.6}/CHANGELOG.md +0 -0
  12. {splox-0.2.4 → splox-0.2.6}/LICENSE +0 -0
  13. {splox-0.2.4 → splox-0.2.6}/src/splox/_ids.py +0 -0
  14. {splox-0.2.4 → splox-0.2.6}/src/splox/_interactions.py +0 -0
  15. {splox-0.2.4 → splox-0.2.6}/src/splox/_mcp.py +0 -0
  16. {splox-0.2.4 → splox-0.2.6}/src/splox/_resources.py +0 -0
  17. {splox-0.2.4 → splox-0.2.6}/src/splox/_runs.py +0 -0
  18. {splox-0.2.4 → splox-0.2.6}/src/splox/_transport.py +0 -0
  19. {splox-0.2.4 → splox-0.2.6}/src/splox/_workflows.py +0 -0
  20. {splox-0.2.4 → splox-0.2.6}/src/splox/exceptions.py +0 -0
  21. {splox-0.2.4 → splox-0.2.6}/src/splox/py.typed +0 -0
  22. {splox-0.2.4 → splox-0.2.6}/tests/__init__.py +0 -0
  23. {splox-0.2.4 → splox-0.2.6}/tests/integration_test.py +0 -0
  24. {splox-0.2.4 → splox-0.2.6}/tests/integration_test_mcp.py +0 -0
  25. {splox-0.2.4 → splox-0.2.6}/tests/integration_test_v2.py +0 -0
  26. {splox-0.2.4 → splox-0.2.6}/tests/test_client.py +0 -0
  27. {splox-0.2.4 → splox-0.2.6}/tests/test_codemode_contract.py +0 -0
  28. {splox-0.2.4 → splox-0.2.6}/tests/test_exceptions.py +0 -0
  29. {splox-0.2.4 → splox-0.2.6}/tests/test_ids.py +0 -0
  30. {splox-0.2.4 → splox-0.2.6}/tests/test_models.py +0 -0
  31. {splox-0.2.4 → splox-0.2.6}/tests/test_runs.py +0 -0
  32. {splox-0.2.4 → splox-0.2.6}/tests/test_sse.py +0 -0
  33. {splox-0.2.4 → splox-0.2.6}/tests/test_workflows.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: splox
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: Official Python SDK for the Splox API — run workflows, manage chats, and monitor execution
5
5
  Project-URL: Homepage, https://splox.io
6
6
  Project-URL: Documentation, https://docs.splox.io
@@ -220,6 +220,49 @@ connection errors, 429 and 5xx. POSTs are retried only when they carry an
220
220
  `Idempotency-Key` (always true for `runs.create` and `interactions.respond`),
221
221
  reusing the same key so replays are safe.
222
222
 
223
+ ## Discovery & building an agent
224
+
225
+ `client.capabilities()` (`GET /v2/capabilities`) returns everything needed to
226
+ assemble a runnable workflow graph: available LLM endpoints (one is marked
227
+ `is_default` — the endpoint new agent nodes get automatically), the default
228
+ model, and the MCP tool servers usable in tool nodes (system `system:*`
229
+ servers include their tool list).
230
+
231
+ ```python
232
+ caps = client.capabilities()
233
+
234
+ default_ep = next(e for e in caps.llm_endpoints if e.is_default)
235
+ compute = next(s for s in caps.tool_servers if s.id == "system:compute")
236
+ print(caps.default_model, default_ep.client, [t.name for t in compute.tools])
237
+
238
+ # Pick a model from the endpoint's light list, then fetch its parameter schema:
239
+ model = default_ep.models[0] # LLMModelSummary(id, name)
240
+ schema = next(m for m in client.models(default_ep.id) if m.id == model.id).input_schema
241
+ # schema is the JSON Schema of the agent node's "additional_llm_config":
242
+ # {"text_llm_model": model.id, "additional_llm_config": {"temperature": 0.2, ...}}
243
+
244
+ # Build a workflow: an agent wired to compute tools via a tool edge.
245
+ wf = client.workflows.create("researcher")
246
+ client.workflows.set_graph(
247
+ wf.id,
248
+ nodes=[
249
+ {"id": "agent", "type": "agent", "data": {
250
+ "system_prompt": "You are a research assistant.",
251
+ # optional — omitted fields fall back to the defaults above:
252
+ "text_llm_endpoint_id": default_ep.id,
253
+ "text_llm_model": caps.default_model,
254
+ }},
255
+ {"id": "tools", "type": "tool", "data": {
256
+ "mcp_server_id": compute.id, # "system:compute"
257
+ "allowed_tools": ["compute_exec", "compute_read_file"],
258
+ }},
259
+ ],
260
+ edges=[{"source": "agent", "target": "tools"}], # tool edge (inferred)
261
+ )
262
+
263
+ run = client.runs.create(wf.id, "How many CPUs does this sandbox have?").wait()
264
+ ```
265
+
223
266
  ## Workflow provisioning (v1, unchanged)
224
267
 
225
268
  Workflow CRUD reads remain available for provisioning:
@@ -187,6 +187,49 @@ connection errors, 429 and 5xx. POSTs are retried only when they carry an
187
187
  `Idempotency-Key` (always true for `runs.create` and `interactions.respond`),
188
188
  reusing the same key so replays are safe.
189
189
 
190
+ ## Discovery & building an agent
191
+
192
+ `client.capabilities()` (`GET /v2/capabilities`) returns everything needed to
193
+ assemble a runnable workflow graph: available LLM endpoints (one is marked
194
+ `is_default` — the endpoint new agent nodes get automatically), the default
195
+ model, and the MCP tool servers usable in tool nodes (system `system:*`
196
+ servers include their tool list).
197
+
198
+ ```python
199
+ caps = client.capabilities()
200
+
201
+ default_ep = next(e for e in caps.llm_endpoints if e.is_default)
202
+ compute = next(s for s in caps.tool_servers if s.id == "system:compute")
203
+ print(caps.default_model, default_ep.client, [t.name for t in compute.tools])
204
+
205
+ # Pick a model from the endpoint's light list, then fetch its parameter schema:
206
+ model = default_ep.models[0] # LLMModelSummary(id, name)
207
+ schema = next(m for m in client.models(default_ep.id) if m.id == model.id).input_schema
208
+ # schema is the JSON Schema of the agent node's "additional_llm_config":
209
+ # {"text_llm_model": model.id, "additional_llm_config": {"temperature": 0.2, ...}}
210
+
211
+ # Build a workflow: an agent wired to compute tools via a tool edge.
212
+ wf = client.workflows.create("researcher")
213
+ client.workflows.set_graph(
214
+ wf.id,
215
+ nodes=[
216
+ {"id": "agent", "type": "agent", "data": {
217
+ "system_prompt": "You are a research assistant.",
218
+ # optional — omitted fields fall back to the defaults above:
219
+ "text_llm_endpoint_id": default_ep.id,
220
+ "text_llm_model": caps.default_model,
221
+ }},
222
+ {"id": "tools", "type": "tool", "data": {
223
+ "mcp_server_id": compute.id, # "system:compute"
224
+ "allowed_tools": ["compute_exec", "compute_read_file"],
225
+ }},
226
+ ],
227
+ edges=[{"source": "agent", "target": "tools"}], # tool edge (inferred)
228
+ )
229
+
230
+ run = client.runs.create(wf.id, "How many CPUs does this sandbox have?").wait()
231
+ ```
232
+
190
233
  ## Workflow provisioning (v1, unchanged)
191
234
 
192
235
  Workflow CRUD reads remain available for provisioning:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "splox"
7
- version = "0.2.4"
7
+ version = "0.2.6"
8
8
  description = "Official Python SDK for the Splox API — run workflows, manage chats, and monitor execution"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -3,6 +3,7 @@
3
3
  from splox._client import AsyncSploxClient, SploxClient
4
4
  from splox._ids import decode_id, encode_id
5
5
  from splox._models import (
6
+ Capabilities,
6
7
  ContentPart,
7
8
  GraphEdge,
8
9
  GraphEdgeInput,
@@ -12,6 +13,8 @@ from splox._models import (
12
13
  Interaction,
13
14
  InteractionPage,
14
15
  InteractionResponse,
16
+ LLMEndpointInfo,
17
+ LLMModelSummary,
15
18
  MCPCatalogItem,
16
19
  MCPCatalogListResponse,
17
20
  MCPConnection,
@@ -20,6 +23,7 @@ from splox._models import (
20
23
  MCPServerToolsResponse,
21
24
  Message,
22
25
  MessagePage,
26
+ ModelInfo,
23
27
  NodeExecution,
24
28
  Output,
25
29
  OutputPage,
@@ -32,6 +36,8 @@ from splox._models import (
32
36
  RunTreeNode,
33
37
  RunUsage,
34
38
  SSEEvent,
39
+ ToolInfo,
40
+ ToolServerInfo,
35
41
  Workflow,
36
42
  WorkflowGraph,
37
43
  WorkflowGraphUpdate,
@@ -79,6 +85,13 @@ __all__ = [
79
85
  "GraphEdge",
80
86
  "GraphNodeInput",
81
87
  "GraphEdgeInput",
88
+ # v2 capabilities (discovery)
89
+ "Capabilities",
90
+ "LLMEndpointInfo",
91
+ "LLMModelSummary",
92
+ "ModelInfo",
93
+ "ToolServerInfo",
94
+ "ToolInfo",
82
95
  # v1 resources (MCP, legacy stream events)
83
96
  "MCPCatalogItem",
84
97
  "MCPCatalogListResponse",
@@ -96,4 +109,4 @@ __all__ = [
96
109
  "async_notify",
97
110
  ]
98
111
 
99
- __version__ = "0.2.4"
112
+ __version__ = "0.2.6"
@@ -3,11 +3,12 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
- from typing import Optional, Any
6
+ from typing import Optional, Any, List
7
7
 
8
8
  from splox._resources import async_notify, notify
9
9
  from splox._interactions import AsyncInteractions, Interactions
10
10
  from splox._mcp import AsyncMCP, MCP
11
+ from splox._models import Capabilities, ModelInfo
11
12
  from splox._runs import AsyncRuns, Runs
12
13
  from splox._transport import DEFAULT_TIMEOUT, AsyncTransport, SyncTransport
13
14
  from splox._workflows import AsyncWorkflows, Workflows
@@ -52,6 +53,35 @@ class SploxClient:
52
53
  self.workflows = Workflows(self._transport)
53
54
  self.mcp = MCP(self._transport)
54
55
 
56
+ def capabilities(self) -> Capabilities:
57
+ """Discover the models, endpoints, and tools available to you.
58
+
59
+ Returns:
60
+ :class:`Capabilities` — ``llm_endpoints`` (for agent nodes'
61
+ ``text_llm_endpoint_id``; one carries ``is_default=True``),
62
+ ``default_model`` (assigned to new agent nodes), and
63
+ ``tool_servers`` (for tool nodes' ``mcp_server_id``; system
64
+ ``system:*`` servers include their tool list).
65
+ """
66
+ data = self._transport.request("GET", "/v2/capabilities")
67
+ return Capabilities.from_dict(data)
68
+
69
+ def models(self, endpoint_id: str) -> List[ModelInfo]:
70
+ """List the full model records of an LLM endpoint.
71
+
72
+ Args:
73
+ endpoint_id: LLM endpoint UUID (``llm_endpoints[].id`` from
74
+ :meth:`capabilities`).
75
+
76
+ Returns:
77
+ All models of the endpoint's provider (every operation), ordered
78
+ by display order. Each :class:`ModelInfo` carries the model's
79
+ ``input_schema`` — the JSON Schema of the parameters an agent
80
+ node accepts in ``additional_llm_config``.
81
+ """
82
+ data = self._transport.request("GET", f"/v2/llm-endpoints/{endpoint_id}/models")
83
+ return [ModelInfo.from_dict(m) for m in (data.get("data") or [])]
84
+
55
85
  @staticmethod
56
86
  def notify(webhook_url: str, data: Any) -> None:
57
87
  """POST data to a webhook URL as JSON."""
@@ -107,6 +137,18 @@ class AsyncSploxClient:
107
137
  self.workflows = AsyncWorkflows(self._transport)
108
138
  self.mcp = AsyncMCP(self._transport)
109
139
 
140
+ async def capabilities(self) -> Capabilities:
141
+ """Discover the models, endpoints, and tools available to you
142
+ (see :meth:`SploxClient.capabilities`)."""
143
+ data = await self._transport.request("GET", "/v2/capabilities")
144
+ return Capabilities.from_dict(data)
145
+
146
+ async def models(self, endpoint_id: str) -> List[ModelInfo]:
147
+ """List the full model records of an LLM endpoint
148
+ (see :meth:`SploxClient.models`)."""
149
+ data = await self._transport.request("GET", f"/v2/llm-endpoints/{endpoint_id}/models")
150
+ return [ModelInfo.from_dict(m) for m in (data.get("data") or [])]
151
+
110
152
  @staticmethod
111
153
  async def notify(webhook_url: str, data: Any) -> None:
112
154
  """Async POST data to a webhook URL as JSON."""
@@ -975,3 +975,136 @@ class WorkflowGraphUpdate:
975
975
  workflow_version=data["workflow_version"],
976
976
  node_ids=data.get("node_ids") or {},
977
977
  )
978
+
979
+
980
+ # ---------------------------------------------------------------------------
981
+ # v2 capabilities (discovery)
982
+ # ---------------------------------------------------------------------------
983
+
984
+
985
+ @dataclass
986
+ class ToolInfo:
987
+ """One tool exposed by an MCP tool server."""
988
+
989
+ name: str
990
+ description: str = ""
991
+
992
+ @classmethod
993
+ def from_dict(cls, data: Dict[str, Any]) -> ToolInfo:
994
+ return cls(name=data["name"], description=data.get("description") or "")
995
+
996
+
997
+ @dataclass
998
+ class LLMModelSummary:
999
+ """Light model listing embedded in :class:`Capabilities`.
1000
+
1001
+ ``id`` is the value for an agent node's ``text_llm_model``.
1002
+ """
1003
+
1004
+ id: str
1005
+ name: str = ""
1006
+
1007
+ @classmethod
1008
+ def from_dict(cls, data: Dict[str, Any]) -> LLMModelSummary:
1009
+ return cls(id=data["id"], name=data.get("name") or "")
1010
+
1011
+
1012
+ @dataclass
1013
+ class ModelInfo:
1014
+ """Full model record from ``client.models(endpoint_id)``.
1015
+
1016
+ ``id`` is the value an agent node takes in ``text_llm_model``;
1017
+ ``input_schema`` is the JSON Schema (raw dict) of the parameters that
1018
+ agent node accepts in ``additional_llm_config`` (temperature, top_p,
1019
+ reasoning, ...). ``capabilities`` and ``input_schema`` are ``None`` when
1020
+ the model has none.
1021
+ """
1022
+
1023
+ id: str
1024
+ name: str = ""
1025
+ operation: str = ""
1026
+ capabilities: Optional[Dict[str, Any]] = None
1027
+ input_schema: Optional[Dict[str, Any]] = None
1028
+
1029
+ @classmethod
1030
+ def from_dict(cls, data: Dict[str, Any]) -> ModelInfo:
1031
+ return cls(
1032
+ id=data["id"],
1033
+ name=data.get("name") or "",
1034
+ operation=data.get("operation") or "",
1035
+ capabilities=data.get("capabilities"),
1036
+ input_schema=data.get("input_schema"),
1037
+ )
1038
+
1039
+
1040
+ @dataclass
1041
+ class LLMEndpointInfo:
1042
+ """An LLM endpoint usable in an agent node's ``text_llm_endpoint_id``.
1043
+
1044
+ ``id`` is a raw UUID (endpoint ids are referenced inside node data
1045
+ documents, not addressable API resources). ``is_default`` marks the
1046
+ endpoint the server stamps into agent nodes created without explicit
1047
+ data. ``models`` is the light chat-model list; fetch full records (all
1048
+ operations + parameter schemas) with ``client.models(endpoint.id)``.
1049
+ """
1050
+
1051
+ id: str
1052
+ name: str
1053
+ client: str
1054
+ is_platform: bool = False
1055
+ is_default: bool = False
1056
+ models: List[LLMModelSummary] = field(default_factory=list)
1057
+
1058
+ @classmethod
1059
+ def from_dict(cls, data: Dict[str, Any]) -> LLMEndpointInfo:
1060
+ return cls(
1061
+ id=data["id"],
1062
+ name=data.get("name", ""),
1063
+ client=data.get("client", ""),
1064
+ is_platform=bool(data.get("is_platform")),
1065
+ is_default=bool(data.get("is_default")),
1066
+ models=[LLMModelSummary.from_dict(m) for m in (data.get("models") or [])],
1067
+ )
1068
+
1069
+
1070
+ @dataclass
1071
+ class ToolServerInfo:
1072
+ """An MCP server usable in a tool node's ``mcp_server_id``.
1073
+
1074
+ System servers use stable ``system:*`` ids and include their tool list;
1075
+ user servers use UUID ids and return ``tools`` empty (listing them
1076
+ requires contacting the remote server).
1077
+ """
1078
+
1079
+ id: str
1080
+ name: str
1081
+ description: str = ""
1082
+ tools: List[ToolInfo] = field(default_factory=list)
1083
+
1084
+ @classmethod
1085
+ def from_dict(cls, data: Dict[str, Any]) -> ToolServerInfo:
1086
+ return cls(
1087
+ id=data["id"],
1088
+ name=data.get("name", ""),
1089
+ description=data.get("description") or "",
1090
+ tools=[ToolInfo.from_dict(t) for t in (data.get("tools") or [])],
1091
+ )
1092
+
1093
+
1094
+ @dataclass
1095
+ class Capabilities:
1096
+ """Result of ``client.capabilities()``: everything needed to assemble a
1097
+ runnable workflow graph — LLM endpoints for agent nodes, the server-side
1098
+ default model, and MCP tool servers for tool nodes."""
1099
+
1100
+ llm_endpoints: List[LLMEndpointInfo] = field(default_factory=list)
1101
+ default_model: str = ""
1102
+ tool_servers: List[ToolServerInfo] = field(default_factory=list)
1103
+
1104
+ @classmethod
1105
+ def from_dict(cls, data: Dict[str, Any]) -> Capabilities:
1106
+ return cls(
1107
+ llm_endpoints=[LLMEndpointInfo.from_dict(e) for e in (data.get("llm_endpoints") or [])],
1108
+ default_model=data.get("default_model", ""),
1109
+ tool_servers=[ToolServerInfo.from_dict(t) for t in (data.get("tool_servers") or [])],
1110
+ )
@@ -0,0 +1,196 @@
1
+ """Unit tests for client.capabilities() (transport stubbed)."""
2
+
3
+ import pytest
4
+
5
+ import splox._transport as transport_mod
6
+ from splox import (
7
+ AsyncSploxClient,
8
+ Capabilities,
9
+ LLMEndpointInfo,
10
+ ModelInfo,
11
+ SploxClient,
12
+ ToolServerInfo,
13
+ )
14
+ from splox.exceptions import SploxAuthError, SploxNotFoundError
15
+
16
+ V2 = "https://splox.io/api/v2"
17
+
18
+
19
+ @pytest.fixture(autouse=True)
20
+ def _no_backoff(monkeypatch):
21
+ """Keep retry/reconnect sleeps out of the unit test suite."""
22
+ monkeypatch.setattr(transport_mod, "_backoff_delay", lambda *a, **k: 0.0)
23
+ monkeypatch.setattr("time.sleep", lambda *_: None)
24
+
25
+
26
+ def capabilities_json():
27
+ return {
28
+ "llm_endpoints": [
29
+ {
30
+ "id": "019e4f9a-a679-7512-86fe-ac78e9b6baf6",
31
+ "name": "Anthropic",
32
+ "client": "anthropic",
33
+ "is_platform": True,
34
+ "is_default": True,
35
+ "models": [
36
+ {"id": "claude-sonnet-4-6", "name": "Claude Sonnet 4.6"},
37
+ {"id": "claude-opus-4-1", "name": "Claude Opus 4.1"},
38
+ ],
39
+ },
40
+ {
41
+ "id": "019e4f9a-a677-7a84-a7de-cdb4ffca85dc",
42
+ "name": "my key",
43
+ "client": "openai",
44
+ "is_platform": False,
45
+ "is_default": False,
46
+ "models": [],
47
+ },
48
+ ],
49
+ "default_model": "claude-sonnet-4-6",
50
+ "tool_servers": [
51
+ {
52
+ "id": "system:compute",
53
+ "name": "Compute",
54
+ "description": "Unified execution surface.",
55
+ "tools": [
56
+ {"name": "compute_exec", "description": "Run a shell command."},
57
+ {"name": "compute_read_file", "description": "Read a file."},
58
+ ],
59
+ },
60
+ {
61
+ "id": "22222222-2222-4222-8222-222222222222",
62
+ "name": "My Server",
63
+ "description": "",
64
+ "tools": [],
65
+ },
66
+ ],
67
+ }
68
+
69
+
70
+ @pytest.fixture
71
+ def client():
72
+ c = SploxClient(api_key="test-key")
73
+ yield c
74
+ c.close()
75
+
76
+
77
+ class TestCapabilities:
78
+ def test_shape_and_types(self, httpx_mock, client) -> None:
79
+ httpx_mock.add_response(method="GET", url=f"{V2}/capabilities", json=capabilities_json())
80
+
81
+ caps = client.capabilities()
82
+
83
+ assert isinstance(caps, Capabilities)
84
+ assert caps.default_model == "claude-sonnet-4-6"
85
+
86
+ assert len(caps.llm_endpoints) == 2
87
+ default = caps.llm_endpoints[0]
88
+ assert isinstance(default, LLMEndpointInfo)
89
+ assert default.is_default and default.is_platform
90
+ assert default.client == "anthropic"
91
+ assert default.id == "019e4f9a-a679-7512-86fe-ac78e9b6baf6"
92
+ assert [m.id for m in default.models] == ["claude-sonnet-4-6", "claude-opus-4-1"]
93
+ assert default.models[0].name == "Claude Sonnet 4.6"
94
+ assert not caps.llm_endpoints[1].is_default
95
+ assert caps.llm_endpoints[1].models == []
96
+
97
+ assert len(caps.tool_servers) == 2
98
+ compute = caps.tool_servers[0]
99
+ assert isinstance(compute, ToolServerInfo)
100
+ assert compute.id == "system:compute"
101
+ assert [t.name for t in compute.tools] == ["compute_exec", "compute_read_file"]
102
+ assert compute.tools[0].description == "Run a shell command."
103
+ assert caps.tool_servers[1].tools == []
104
+
105
+ req = httpx_mock.get_request()
106
+ assert req.headers["Authorization"] == "Bearer test-key"
107
+
108
+ def test_auth_error(self, httpx_mock, client) -> None:
109
+ httpx_mock.add_response(
110
+ method="GET",
111
+ url=f"{V2}/capabilities",
112
+ status_code=401,
113
+ json={"type": "about:blank", "title": "Unauthorized", "status": 401, "code": "unauthorized"},
114
+ )
115
+
116
+ with pytest.raises(SploxAuthError):
117
+ client.capabilities()
118
+
119
+ @pytest.mark.asyncio
120
+ async def test_async_capabilities(self, httpx_mock) -> None:
121
+ httpx_mock.add_response(method="GET", url=f"{V2}/capabilities", json=capabilities_json())
122
+
123
+ async with AsyncSploxClient(api_key="test-key") as client:
124
+ caps = await client.capabilities()
125
+
126
+ assert caps.default_model == "claude-sonnet-4-6"
127
+ assert caps.tool_servers[0].id == "system:compute"
128
+ assert len(caps.tool_servers[0].tools) == 2
129
+
130
+
131
+ ENDPOINT_ID = "019e4f9a-a679-7512-86fe-ac78e9b6baf6"
132
+
133
+
134
+ def models_json():
135
+ return {
136
+ "data": [
137
+ {
138
+ "id": "claude-sonnet-4-6",
139
+ "name": "Claude Sonnet 4.6",
140
+ "operation": "chat_completion",
141
+ "capabilities": {"supports_tools": True},
142
+ "input_schema": {
143
+ "type": "object",
144
+ "properties": {"temperature": {"type": "number"}, "thinking_effort": {"type": "string"}},
145
+ },
146
+ },
147
+ {
148
+ "id": "claude-realtime",
149
+ "name": "Claude Realtime",
150
+ "operation": "realtime",
151
+ "capabilities": None,
152
+ "input_schema": None,
153
+ },
154
+ ]
155
+ }
156
+
157
+
158
+ class TestModels:
159
+ def test_shape_and_raw_schema(self, httpx_mock, client) -> None:
160
+ httpx_mock.add_response(
161
+ method="GET", url=f"{V2}/llm-endpoints/{ENDPOINT_ID}/models", json=models_json()
162
+ )
163
+
164
+ models = client.models(ENDPOINT_ID)
165
+
166
+ assert len(models) == 2
167
+ m = models[0]
168
+ assert isinstance(m, ModelInfo)
169
+ assert m.id == "claude-sonnet-4-6"
170
+ assert m.operation == "chat_completion"
171
+ assert m.capabilities == {"supports_tools": True}
172
+ # input_schema stays a raw dict (JSON Schema of additional_llm_config).
173
+ assert set(m.input_schema["properties"]) == {"temperature", "thinking_effort"}
174
+ assert models[1].input_schema is None and models[1].capabilities is None
175
+
176
+ def test_not_found(self, httpx_mock, client) -> None:
177
+ httpx_mock.add_response(
178
+ method="GET",
179
+ url=f"{V2}/llm-endpoints/{ENDPOINT_ID}/models",
180
+ status_code=404,
181
+ json={"type": "about:blank", "title": "Not Found", "status": 404, "code": "not_found"},
182
+ )
183
+
184
+ with pytest.raises(SploxNotFoundError):
185
+ client.models(ENDPOINT_ID)
186
+
187
+ @pytest.mark.asyncio
188
+ async def test_async_models(self, httpx_mock) -> None:
189
+ httpx_mock.add_response(
190
+ method="GET", url=f"{V2}/llm-endpoints/{ENDPOINT_ID}/models", json=models_json()
191
+ )
192
+
193
+ async with AsyncSploxClient(api_key="test-key") as client:
194
+ models = await client.models(ENDPOINT_ID)
195
+
196
+ assert [m.id for m in models] == ["claude-sonnet-4-6", "claude-realtime"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes