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.
- {splox-0.2.4 → splox-0.2.6}/PKG-INFO +44 -1
- {splox-0.2.4 → splox-0.2.6}/README.md +43 -0
- {splox-0.2.4 → splox-0.2.6}/pyproject.toml +1 -1
- {splox-0.2.4 → splox-0.2.6}/src/splox/__init__.py +14 -1
- {splox-0.2.4 → splox-0.2.6}/src/splox/_client.py +43 -1
- {splox-0.2.4 → splox-0.2.6}/src/splox/_models.py +133 -0
- splox-0.2.6/tests/test_capabilities.py +196 -0
- {splox-0.2.4 → splox-0.2.6}/.github/workflows/publish.yml +0 -0
- {splox-0.2.4 → splox-0.2.6}/.github/workflows/test.yml +0 -0
- {splox-0.2.4 → splox-0.2.6}/.gitignore +0 -0
- {splox-0.2.4 → splox-0.2.6}/CHANGELOG.md +0 -0
- {splox-0.2.4 → splox-0.2.6}/LICENSE +0 -0
- {splox-0.2.4 → splox-0.2.6}/src/splox/_ids.py +0 -0
- {splox-0.2.4 → splox-0.2.6}/src/splox/_interactions.py +0 -0
- {splox-0.2.4 → splox-0.2.6}/src/splox/_mcp.py +0 -0
- {splox-0.2.4 → splox-0.2.6}/src/splox/_resources.py +0 -0
- {splox-0.2.4 → splox-0.2.6}/src/splox/_runs.py +0 -0
- {splox-0.2.4 → splox-0.2.6}/src/splox/_transport.py +0 -0
- {splox-0.2.4 → splox-0.2.6}/src/splox/_workflows.py +0 -0
- {splox-0.2.4 → splox-0.2.6}/src/splox/exceptions.py +0 -0
- {splox-0.2.4 → splox-0.2.6}/src/splox/py.typed +0 -0
- {splox-0.2.4 → splox-0.2.6}/tests/__init__.py +0 -0
- {splox-0.2.4 → splox-0.2.6}/tests/integration_test.py +0 -0
- {splox-0.2.4 → splox-0.2.6}/tests/integration_test_mcp.py +0 -0
- {splox-0.2.4 → splox-0.2.6}/tests/integration_test_v2.py +0 -0
- {splox-0.2.4 → splox-0.2.6}/tests/test_client.py +0 -0
- {splox-0.2.4 → splox-0.2.6}/tests/test_codemode_contract.py +0 -0
- {splox-0.2.4 → splox-0.2.6}/tests/test_exceptions.py +0 -0
- {splox-0.2.4 → splox-0.2.6}/tests/test_ids.py +0 -0
- {splox-0.2.4 → splox-0.2.6}/tests/test_models.py +0 -0
- {splox-0.2.4 → splox-0.2.6}/tests/test_runs.py +0 -0
- {splox-0.2.4 → splox-0.2.6}/tests/test_sse.py +0 -0
- {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.
|
|
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:
|
|
@@ -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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|