codespar 0.1.0__py3-none-any.whl
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.
- codespar/__init__.py +107 -0
- codespar/_async_client.py +178 -0
- codespar/_async_session.py +420 -0
- codespar/_http.py +140 -0
- codespar/_presets.py +41 -0
- codespar/_sync_client.py +232 -0
- codespar/errors.py +49 -0
- codespar/types.py +209 -0
- codespar-0.1.0.dist-info/METADATA +187 -0
- codespar-0.1.0.dist-info/RECORD +11 -0
- codespar-0.1.0.dist-info/WHEEL +4 -0
codespar/_http.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP primitives shared by the async + sync clients.
|
|
3
|
+
|
|
4
|
+
Every call through the SDK goes through ``request_json`` / ``stream_sse``
|
|
5
|
+
so header injection, auth, project-id threading, and error mapping live
|
|
6
|
+
in one place. Neither the public ``AsyncCodeSpar`` nor ``Session``
|
|
7
|
+
classes import httpx directly — if we ever swap the transport, only
|
|
8
|
+
this file changes.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from collections.abc import AsyncIterator
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from .errors import ApiError, StreamError
|
|
20
|
+
|
|
21
|
+
DEFAULT_BASE_URL = "https://api.codespar.dev"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_headers(
|
|
25
|
+
api_key: str, project_id: str | None, *, accept_sse: bool = False
|
|
26
|
+
) -> dict[str, str]:
|
|
27
|
+
"""Standard header set for every authenticated request."""
|
|
28
|
+
headers = {
|
|
29
|
+
"Content-Type": "application/json",
|
|
30
|
+
"Authorization": f"Bearer {api_key}",
|
|
31
|
+
"User-Agent": "codespar-python/0.1.0",
|
|
32
|
+
}
|
|
33
|
+
if project_id:
|
|
34
|
+
headers["x-codespar-project"] = project_id
|
|
35
|
+
if accept_sse:
|
|
36
|
+
headers["Accept"] = "text/event-stream"
|
|
37
|
+
else:
|
|
38
|
+
headers["Accept"] = "application/json"
|
|
39
|
+
return headers
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def request_json(
|
|
43
|
+
client: httpx.AsyncClient,
|
|
44
|
+
method: str,
|
|
45
|
+
path: str,
|
|
46
|
+
*,
|
|
47
|
+
api_key: str,
|
|
48
|
+
project_id: str | None,
|
|
49
|
+
body: Any = None,
|
|
50
|
+
) -> Any:
|
|
51
|
+
"""Do an authenticated JSON request, return parsed body or raise ApiError."""
|
|
52
|
+
headers = build_headers(api_key, project_id)
|
|
53
|
+
try:
|
|
54
|
+
response = await client.request(
|
|
55
|
+
method,
|
|
56
|
+
path,
|
|
57
|
+
headers=headers,
|
|
58
|
+
json=body if body is not None else None,
|
|
59
|
+
)
|
|
60
|
+
except httpx.HTTPError as exc: # network, timeout, connect failure
|
|
61
|
+
raise ApiError(f"{method} {path} failed: {exc}", status=0) from exc
|
|
62
|
+
|
|
63
|
+
# 204 No Content — used by close()
|
|
64
|
+
if response.status_code == 204:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
raw = response.text
|
|
68
|
+
parsed: Any = None
|
|
69
|
+
if raw:
|
|
70
|
+
try:
|
|
71
|
+
parsed = response.json()
|
|
72
|
+
except json.JSONDecodeError:
|
|
73
|
+
parsed = raw
|
|
74
|
+
|
|
75
|
+
if not response.is_success:
|
|
76
|
+
code: str | None = None
|
|
77
|
+
message = f"{method} {path} failed: {response.status_code}"
|
|
78
|
+
if isinstance(parsed, dict):
|
|
79
|
+
code = parsed.get("error") if isinstance(parsed.get("error"), str) else None
|
|
80
|
+
msg = parsed.get("message")
|
|
81
|
+
if isinstance(msg, str):
|
|
82
|
+
message = f"{message} — {msg}"
|
|
83
|
+
elif code:
|
|
84
|
+
message = f"{message} — {code}"
|
|
85
|
+
raise ApiError(message, status=response.status_code, body=parsed, code=code)
|
|
86
|
+
|
|
87
|
+
return parsed
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def stream_sse(
|
|
91
|
+
client: httpx.AsyncClient,
|
|
92
|
+
path: str,
|
|
93
|
+
*,
|
|
94
|
+
api_key: str,
|
|
95
|
+
project_id: str | None,
|
|
96
|
+
body: Any,
|
|
97
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
98
|
+
"""
|
|
99
|
+
POST a JSON body and yield parsed SSE data frames from the response.
|
|
100
|
+
|
|
101
|
+
Only ``data:`` lines are surfaced to the caller; ``event:`` and
|
|
102
|
+
comment lines (``: keep-alive``) are swallowed. Each yielded dict
|
|
103
|
+
is the JSON payload from a single SSE frame. Callers interpret
|
|
104
|
+
the ``type`` field themselves.
|
|
105
|
+
"""
|
|
106
|
+
headers = build_headers(api_key, project_id, accept_sse=True)
|
|
107
|
+
try:
|
|
108
|
+
async with client.stream(
|
|
109
|
+
"POST",
|
|
110
|
+
path,
|
|
111
|
+
headers=headers,
|
|
112
|
+
json=body,
|
|
113
|
+
) as response:
|
|
114
|
+
if not response.is_success:
|
|
115
|
+
raw = await response.aread()
|
|
116
|
+
text = raw.decode("utf-8", errors="replace")
|
|
117
|
+
raise ApiError(
|
|
118
|
+
f"POST {path} (stream) failed: {response.status_code} — {text[:200]}",
|
|
119
|
+
status=response.status_code,
|
|
120
|
+
body=text,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
buffer = ""
|
|
124
|
+
async for chunk in response.aiter_text():
|
|
125
|
+
buffer += chunk
|
|
126
|
+
# SSE frames are separated by blank lines ("\n\n").
|
|
127
|
+
while "\n\n" in buffer:
|
|
128
|
+
frame, buffer = buffer.split("\n\n", 1)
|
|
129
|
+
payload: str | None = None
|
|
130
|
+
for line in frame.split("\n"):
|
|
131
|
+
if line.startswith("data:"):
|
|
132
|
+
payload = (payload or "") + line[len("data:") :].strip()
|
|
133
|
+
if not payload:
|
|
134
|
+
continue
|
|
135
|
+
try:
|
|
136
|
+
yield json.loads(payload)
|
|
137
|
+
except json.JSONDecodeError as exc:
|
|
138
|
+
raise StreamError(f"malformed SSE payload: {payload[:120]}") from exc
|
|
139
|
+
except httpx.HTTPError as exc:
|
|
140
|
+
raise StreamError(f"POST {path} (stream) transport error: {exc}") from exc
|
codespar/_presets.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Server presets — a shortcut so callers can say ``preset="brazilian"``
|
|
3
|
+
instead of listing every server id.
|
|
4
|
+
|
|
5
|
+
Must stay byte-for-byte identical to the TypeScript SDK
|
|
6
|
+
(``@codespar/sdk``, ``packages/core/src/session.ts``) so the same
|
|
7
|
+
preset yields the same servers across runtimes.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .types import Preset
|
|
13
|
+
|
|
14
|
+
_PRESET_SERVERS: dict[Preset, list[str]] = {
|
|
15
|
+
"brazilian": ["zoop", "nuvem-fiscal", "melhor-envio", "z-api", "omie"],
|
|
16
|
+
"mexican": ["conekta", "facturapi", "skydropx"],
|
|
17
|
+
"argentinian": ["afip", "andreani"],
|
|
18
|
+
"colombian": ["wompi", "siigo", "coordinadora"],
|
|
19
|
+
"all": [
|
|
20
|
+
"zoop",
|
|
21
|
+
"nuvem-fiscal",
|
|
22
|
+
"melhor-envio",
|
|
23
|
+
"z-api",
|
|
24
|
+
"omie",
|
|
25
|
+
"conekta",
|
|
26
|
+
"facturapi",
|
|
27
|
+
"afip",
|
|
28
|
+
"wompi",
|
|
29
|
+
],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Sandbox default when the caller gives no preset + no servers list —
|
|
33
|
+
# enough to demo Brazilian Pix + NF-e without the user picking.
|
|
34
|
+
_SANDBOX_DEFAULT = ["zoop", "nuvem-fiscal"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def preset_to_servers(preset: Preset | None) -> list[str]:
|
|
38
|
+
"""Expand a preset into a list of server ids. Falls back to sandbox default."""
|
|
39
|
+
if preset is None:
|
|
40
|
+
return list(_SANDBOX_DEFAULT)
|
|
41
|
+
return list(_PRESET_SERVERS[preset])
|
codespar/_sync_client.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sync wrappers over ``AsyncCodeSpar`` / ``AsyncSession``.
|
|
3
|
+
|
|
4
|
+
The async implementation is canonical; these classes do one thing:
|
|
5
|
+
run async methods to completion on a dedicated background event loop
|
|
6
|
+
so sync Python code can use the SDK without rewriting to asyncio.
|
|
7
|
+
|
|
8
|
+
Using a dedicated loop (not ``asyncio.run`` per call) keeps the
|
|
9
|
+
underlying httpx connection pool alive across calls, which matters
|
|
10
|
+
for scripts that create a session and then iterate over send() ten
|
|
11
|
+
times — we don't want to tear down TLS on every turn.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import threading
|
|
18
|
+
from collections.abc import Coroutine, Iterator
|
|
19
|
+
from types import TracebackType
|
|
20
|
+
from typing import Any, TypeVar
|
|
21
|
+
|
|
22
|
+
from ._async_client import AsyncCodeSpar
|
|
23
|
+
from ._async_session import AsyncSession
|
|
24
|
+
from .errors import ConfigError
|
|
25
|
+
from .types import (
|
|
26
|
+
AuthConfig,
|
|
27
|
+
AuthResult,
|
|
28
|
+
ProxyRequest,
|
|
29
|
+
ProxyResult,
|
|
30
|
+
SendResult,
|
|
31
|
+
ServerConnection,
|
|
32
|
+
SessionConfig,
|
|
33
|
+
SessionInfo,
|
|
34
|
+
StreamEvent,
|
|
35
|
+
Tool,
|
|
36
|
+
ToolResult,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
T = TypeVar("T")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _LoopRunner:
|
|
43
|
+
"""Background thread hosting a persistent asyncio loop."""
|
|
44
|
+
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
self._loop = asyncio.new_event_loop()
|
|
47
|
+
self._thread = threading.Thread(
|
|
48
|
+
target=self._loop.run_forever,
|
|
49
|
+
name="codespar-async-loop",
|
|
50
|
+
daemon=True,
|
|
51
|
+
)
|
|
52
|
+
self._thread.start()
|
|
53
|
+
|
|
54
|
+
def run(self, coro: Coroutine[Any, Any, T]) -> T:
|
|
55
|
+
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
|
56
|
+
return future.result()
|
|
57
|
+
|
|
58
|
+
def close(self) -> None:
|
|
59
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
60
|
+
self._thread.join(timeout=5)
|
|
61
|
+
self._loop.close()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Session:
|
|
65
|
+
"""Sync Session — blocking wrapper around ``AsyncSession``."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, async_session: AsyncSession, runner: _LoopRunner) -> None:
|
|
68
|
+
self._async = async_session
|
|
69
|
+
self._runner = runner
|
|
70
|
+
|
|
71
|
+
# ── identity passthroughs ───────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def id(self) -> str:
|
|
75
|
+
return self._async.id
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def user_id(self) -> str:
|
|
79
|
+
return self._async.user_id
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def servers(self) -> list[str]:
|
|
83
|
+
return self._async.servers
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def info(self) -> SessionInfo:
|
|
87
|
+
return self._async.info
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def mcp(self) -> dict[str, Any]:
|
|
91
|
+
return self._async.mcp
|
|
92
|
+
|
|
93
|
+
# ── blocking calls ──────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
def tools(self) -> list[Tool]:
|
|
96
|
+
return self._runner.run(self._async.tools())
|
|
97
|
+
|
|
98
|
+
def find_tools(self, intent: str) -> list[Tool]:
|
|
99
|
+
return self._runner.run(self._async.find_tools(intent))
|
|
100
|
+
|
|
101
|
+
def execute(self, tool_name: str, params: dict[str, Any]) -> ToolResult:
|
|
102
|
+
return self._runner.run(self._async.execute(tool_name, params))
|
|
103
|
+
|
|
104
|
+
def proxy_execute(self, request: ProxyRequest) -> ProxyResult:
|
|
105
|
+
return self._runner.run(self._async.proxy_execute(request))
|
|
106
|
+
|
|
107
|
+
def send(self, message: str) -> SendResult:
|
|
108
|
+
return self._runner.run(self._async.send(message))
|
|
109
|
+
|
|
110
|
+
def send_stream(self, message: str) -> Iterator[StreamEvent]:
|
|
111
|
+
"""
|
|
112
|
+
Sync generator that yields stream events as they arrive on the
|
|
113
|
+
background event loop. Bridges the async iterator via a queue
|
|
114
|
+
so calling code stays plain-``for event in session.send_stream``.
|
|
115
|
+
"""
|
|
116
|
+
import queue
|
|
117
|
+
|
|
118
|
+
q: queue.Queue[StreamEvent | object] = queue.Queue()
|
|
119
|
+
sentinel = object()
|
|
120
|
+
|
|
121
|
+
async def pump() -> None:
|
|
122
|
+
try:
|
|
123
|
+
async for event in self._async.send_stream(message):
|
|
124
|
+
q.put(event)
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
q.put(exc)
|
|
127
|
+
finally:
|
|
128
|
+
q.put(sentinel)
|
|
129
|
+
|
|
130
|
+
future = asyncio.run_coroutine_threadsafe(pump(), self._runner._loop)
|
|
131
|
+
try:
|
|
132
|
+
while True:
|
|
133
|
+
item = q.get()
|
|
134
|
+
if item is sentinel:
|
|
135
|
+
break
|
|
136
|
+
if isinstance(item, BaseException):
|
|
137
|
+
raise item
|
|
138
|
+
yield item # type: ignore[misc]
|
|
139
|
+
finally:
|
|
140
|
+
# Make sure pump() finishes before the caller moves on.
|
|
141
|
+
future.result(timeout=1)
|
|
142
|
+
|
|
143
|
+
def authorize(self, server_id: str, config: AuthConfig) -> AuthResult:
|
|
144
|
+
return self._runner.run(self._async.authorize(server_id, config))
|
|
145
|
+
|
|
146
|
+
def connections(self) -> list[ServerConnection]:
|
|
147
|
+
return self._runner.run(self._async.connections())
|
|
148
|
+
|
|
149
|
+
def close(self) -> None:
|
|
150
|
+
self._runner.run(self._async.close())
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class CodeSpar:
|
|
154
|
+
"""
|
|
155
|
+
Sync CodeSpar client. Drop-in replacement for the async client when
|
|
156
|
+
you're writing a script or a sync framework handler.
|
|
157
|
+
|
|
158
|
+
Example::
|
|
159
|
+
|
|
160
|
+
cs = CodeSpar(api_key="csk_live_...")
|
|
161
|
+
try:
|
|
162
|
+
session = cs.create("user_123", preset="brazilian")
|
|
163
|
+
print(session.send("charge R$500 via Pix").message)
|
|
164
|
+
finally:
|
|
165
|
+
cs.close()
|
|
166
|
+
|
|
167
|
+
Or as a context manager::
|
|
168
|
+
|
|
169
|
+
with CodeSpar(api_key="csk_live_...") as cs:
|
|
170
|
+
...
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def __init__(
|
|
174
|
+
self,
|
|
175
|
+
*,
|
|
176
|
+
api_key: str,
|
|
177
|
+
base_url: str | None = None,
|
|
178
|
+
project_id: str | None = None,
|
|
179
|
+
timeout: float = 60.0,
|
|
180
|
+
) -> None:
|
|
181
|
+
self._runner = _LoopRunner()
|
|
182
|
+
# Build the async client *on* the runner's loop so its httpx
|
|
183
|
+
# transport binds to the right loop from day one.
|
|
184
|
+
kwargs: dict[str, Any] = {"api_key": api_key, "timeout": timeout}
|
|
185
|
+
if base_url is not None:
|
|
186
|
+
kwargs["base_url"] = base_url
|
|
187
|
+
if project_id is not None:
|
|
188
|
+
kwargs["project_id"] = project_id
|
|
189
|
+
|
|
190
|
+
async def factory() -> AsyncCodeSpar:
|
|
191
|
+
return AsyncCodeSpar(**kwargs)
|
|
192
|
+
|
|
193
|
+
self._async = self._runner.run(factory())
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def base_url(self) -> str:
|
|
197
|
+
return self._async.base_url
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def project_id(self) -> str | None:
|
|
201
|
+
return self._async.project_id
|
|
202
|
+
|
|
203
|
+
def create(
|
|
204
|
+
self,
|
|
205
|
+
user_id: str,
|
|
206
|
+
config: SessionConfig | None = None,
|
|
207
|
+
/,
|
|
208
|
+
**kwargs: object,
|
|
209
|
+
) -> Session:
|
|
210
|
+
if config is not None and kwargs:
|
|
211
|
+
raise ConfigError("Pass SessionConfig or keyword arguments, not both.")
|
|
212
|
+
async_session = self._runner.run(
|
|
213
|
+
self._async.create(user_id, config, **kwargs)
|
|
214
|
+
)
|
|
215
|
+
return Session(async_session, self._runner)
|
|
216
|
+
|
|
217
|
+
def close(self) -> None:
|
|
218
|
+
try:
|
|
219
|
+
self._runner.run(self._async.aclose())
|
|
220
|
+
finally:
|
|
221
|
+
self._runner.close()
|
|
222
|
+
|
|
223
|
+
def __enter__(self) -> CodeSpar:
|
|
224
|
+
return self
|
|
225
|
+
|
|
226
|
+
def __exit__(
|
|
227
|
+
self,
|
|
228
|
+
exc_type: type[BaseException] | None,
|
|
229
|
+
exc: BaseException | None,
|
|
230
|
+
tb: TracebackType | None,
|
|
231
|
+
) -> None:
|
|
232
|
+
self.close()
|
codespar/errors.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exception hierarchy for the CodeSpar SDK.
|
|
3
|
+
|
|
4
|
+
Kept shallow — one ``CodeSparError`` root with a handful of
|
|
5
|
+
specialised subclasses. Every network / API failure is wrapped so
|
|
6
|
+
callers can ``except CodeSparError`` without catching the raw httpx
|
|
7
|
+
exception tree (which would bleed the transport into user code).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CodeSparError(Exception):
|
|
16
|
+
"""Base class for every error raised by the SDK."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, message: str, *, cause: BaseException | None = None):
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.__cause__ = cause
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ApiError(CodeSparError):
|
|
24
|
+
"""HTTP-level failure returned by the CodeSpar backend."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
message: str,
|
|
29
|
+
*,
|
|
30
|
+
status: int,
|
|
31
|
+
body: Any = None,
|
|
32
|
+
code: str | None = None,
|
|
33
|
+
):
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.status = status
|
|
36
|
+
self.body = body
|
|
37
|
+
self.code = code
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ConfigError(CodeSparError):
|
|
41
|
+
"""Raised when the SDK is constructed with invalid / missing config."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class NotConnectedError(CodeSparError):
|
|
45
|
+
"""Raised when an operation needs a connected session that isn't ready."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class StreamError(CodeSparError):
|
|
49
|
+
"""Raised when the SSE stream itself fails (parse / transport)."""
|
codespar/types.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Type definitions for the CodeSpar Python SDK.
|
|
3
|
+
|
|
4
|
+
Every shape here mirrors the TypeScript @codespar/sdk types so the
|
|
5
|
+
payloads on the wire match byte-for-byte — the backend
|
|
6
|
+
(codespar-enterprise) is the single source of truth, and both SDKs
|
|
7
|
+
are just client-side adapters over the same HTTP contract.
|
|
8
|
+
|
|
9
|
+
Using plain dataclasses (not pydantic) to keep the dependency footprint
|
|
10
|
+
small and imports fast. If we need runtime validation later, we can
|
|
11
|
+
layer pydantic on without changing this surface.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from typing import Any, Literal
|
|
19
|
+
|
|
20
|
+
Preset = Literal["brazilian", "mexican", "argentinian", "colombian", "all"]
|
|
21
|
+
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
|
|
22
|
+
SessionStatus = Literal["active", "closed", "error"]
|
|
23
|
+
AuthType = Literal["oauth", "api_key", "cert", "none"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class ManageConnections:
|
|
28
|
+
"""Options for blocking session creation until servers are connected."""
|
|
29
|
+
|
|
30
|
+
wait_for_connections: bool = False
|
|
31
|
+
timeout: int = 30_000
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(slots=True)
|
|
35
|
+
class SessionConfig:
|
|
36
|
+
"""Per-session configuration passed to ``CodeSpar.create``."""
|
|
37
|
+
|
|
38
|
+
servers: list[str] | None = None
|
|
39
|
+
preset: Preset | None = None
|
|
40
|
+
manage_connections: ManageConnections | None = None
|
|
41
|
+
metadata: dict[str, str] | None = None
|
|
42
|
+
project_id: str | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(slots=True)
|
|
46
|
+
class Tool:
|
|
47
|
+
"""A tool exposed by a connected server."""
|
|
48
|
+
|
|
49
|
+
name: str
|
|
50
|
+
description: str
|
|
51
|
+
input_schema: dict[str, Any]
|
|
52
|
+
server: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(slots=True)
|
|
56
|
+
class ToolResult:
|
|
57
|
+
"""Result of a single tool execution."""
|
|
58
|
+
|
|
59
|
+
success: bool
|
|
60
|
+
data: Any
|
|
61
|
+
error: str | None
|
|
62
|
+
duration: int
|
|
63
|
+
server: str
|
|
64
|
+
tool: str
|
|
65
|
+
tool_call_id: str | None = None
|
|
66
|
+
called_at: str | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(slots=True)
|
|
70
|
+
class ToolCallRecord:
|
|
71
|
+
"""A row in the backend's session_tool_calls table, surfaced on send/sendStream."""
|
|
72
|
+
|
|
73
|
+
id: str
|
|
74
|
+
tool_name: str
|
|
75
|
+
server_id: str
|
|
76
|
+
status: Literal["success", "error"]
|
|
77
|
+
duration_ms: int
|
|
78
|
+
input: Any
|
|
79
|
+
output: Any
|
|
80
|
+
error_code: str | None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(slots=True)
|
|
84
|
+
class SendResult:
|
|
85
|
+
"""Final payload of a Session.send natural-language turn."""
|
|
86
|
+
|
|
87
|
+
message: str
|
|
88
|
+
tool_calls: list[ToolCallRecord] = field(default_factory=list)
|
|
89
|
+
iterations: int = 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(slots=True)
|
|
93
|
+
class ServerConnection:
|
|
94
|
+
"""A server the session can call, as seen by the backend."""
|
|
95
|
+
|
|
96
|
+
id: str
|
|
97
|
+
name: str
|
|
98
|
+
category: str
|
|
99
|
+
country: str
|
|
100
|
+
auth_type: AuthType
|
|
101
|
+
connected: bool
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(slots=True)
|
|
105
|
+
class ProxyRequest:
|
|
106
|
+
"""Raw HTTP proxy call to a connected server's upstream API."""
|
|
107
|
+
|
|
108
|
+
server: str
|
|
109
|
+
endpoint: str
|
|
110
|
+
method: HttpMethod
|
|
111
|
+
body: Any = None
|
|
112
|
+
params: dict[str, Any] | None = None
|
|
113
|
+
headers: dict[str, str] | None = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(slots=True)
|
|
117
|
+
class ProxyResult:
|
|
118
|
+
"""Response of a raw proxy call. `data` is parsed JSON when possible."""
|
|
119
|
+
|
|
120
|
+
status: int
|
|
121
|
+
data: Any
|
|
122
|
+
headers: dict[str, str]
|
|
123
|
+
duration: int
|
|
124
|
+
proxy_call_id: str | None = None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(slots=True)
|
|
128
|
+
class AuthConfig:
|
|
129
|
+
"""Input to ``Session.authorize`` — where the provider redirects the user."""
|
|
130
|
+
|
|
131
|
+
redirect_uri: str
|
|
132
|
+
scopes: str | None = None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(slots=True)
|
|
136
|
+
class AuthResult:
|
|
137
|
+
"""Output of ``Session.authorize`` — the Connect Link the end user opens."""
|
|
138
|
+
|
|
139
|
+
link_token: str
|
|
140
|
+
authorize_url: str
|
|
141
|
+
expires_at: str
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# Streaming event shapes. These mirror the TS StreamEvent discriminated
|
|
145
|
+
# union; Python doesn't have discriminated unions as first-class, so we
|
|
146
|
+
# use a base + subclasses with a literal `type` tag.
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(slots=True)
|
|
150
|
+
class UserMessageEvent:
|
|
151
|
+
content: str
|
|
152
|
+
type: Literal["user_message"] = "user_message"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(slots=True)
|
|
156
|
+
class AssistantTextEvent:
|
|
157
|
+
content: str
|
|
158
|
+
iteration: int
|
|
159
|
+
type: Literal["assistant_text"] = "assistant_text"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(slots=True)
|
|
163
|
+
class ToolUseEvent:
|
|
164
|
+
id: str
|
|
165
|
+
name: str
|
|
166
|
+
input: dict[str, Any]
|
|
167
|
+
type: Literal["tool_use"] = "tool_use"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(slots=True)
|
|
171
|
+
class ToolResultEvent:
|
|
172
|
+
tool_call: ToolCallRecord
|
|
173
|
+
type: Literal["tool_result"] = "tool_result"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(slots=True)
|
|
177
|
+
class DoneEvent:
|
|
178
|
+
result: SendResult
|
|
179
|
+
type: Literal["done"] = "done"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(slots=True)
|
|
183
|
+
class ErrorEvent:
|
|
184
|
+
error: str
|
|
185
|
+
message: str | None = None
|
|
186
|
+
type: Literal["error"] = "error"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
StreamEvent = (
|
|
190
|
+
UserMessageEvent
|
|
191
|
+
| AssistantTextEvent
|
|
192
|
+
| ToolUseEvent
|
|
193
|
+
| ToolResultEvent
|
|
194
|
+
| DoneEvent
|
|
195
|
+
| ErrorEvent
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(slots=True)
|
|
200
|
+
class SessionInfo:
|
|
201
|
+
"""Read-only metadata about a session, attached to the Session instance."""
|
|
202
|
+
|
|
203
|
+
id: str
|
|
204
|
+
user_id: str
|
|
205
|
+
servers: list[str]
|
|
206
|
+
created_at: datetime
|
|
207
|
+
status: SessionStatus
|
|
208
|
+
mcp_url: str
|
|
209
|
+
mcp_headers: dict[str, str]
|