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/__init__.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CodeSpar Python SDK — commerce infrastructure for AI agents in Latin America.
|
|
3
|
+
|
|
4
|
+
Two import surfaces:
|
|
5
|
+
|
|
6
|
+
* ``CodeSpar`` — sync client. Use from scripts, Jupyter, sync web
|
|
7
|
+
frameworks (Flask, Django views).
|
|
8
|
+
* ``AsyncCodeSpar`` — async client. Use from FastAPI, LangChain,
|
|
9
|
+
anything already running on asyncio.
|
|
10
|
+
|
|
11
|
+
Both wrap the same backend (``api.codespar.dev``) and expose the same
|
|
12
|
+
session API, so you can start with sync and upgrade to async without
|
|
13
|
+
changing the surrounding code.
|
|
14
|
+
|
|
15
|
+
Quick start::
|
|
16
|
+
|
|
17
|
+
from codespar import CodeSpar
|
|
18
|
+
|
|
19
|
+
cs = CodeSpar(api_key="csk_live_...")
|
|
20
|
+
session = cs.create("user_123", preset="brazilian")
|
|
21
|
+
result = session.send("Charge R$500 via Pix to +5511999887766")
|
|
22
|
+
print(result.message)
|
|
23
|
+
session.close()
|
|
24
|
+
cs.close()
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from ._async_client import AsyncCodeSpar
|
|
30
|
+
from ._async_session import AsyncSession
|
|
31
|
+
from ._sync_client import CodeSpar, Session
|
|
32
|
+
from .errors import (
|
|
33
|
+
ApiError,
|
|
34
|
+
CodeSparError,
|
|
35
|
+
ConfigError,
|
|
36
|
+
NotConnectedError,
|
|
37
|
+
StreamError,
|
|
38
|
+
)
|
|
39
|
+
from .types import (
|
|
40
|
+
AssistantTextEvent,
|
|
41
|
+
AuthConfig,
|
|
42
|
+
AuthResult,
|
|
43
|
+
DoneEvent,
|
|
44
|
+
ErrorEvent,
|
|
45
|
+
HttpMethod,
|
|
46
|
+
ManageConnections,
|
|
47
|
+
Preset,
|
|
48
|
+
ProxyRequest,
|
|
49
|
+
ProxyResult,
|
|
50
|
+
SendResult,
|
|
51
|
+
ServerConnection,
|
|
52
|
+
SessionConfig,
|
|
53
|
+
SessionInfo,
|
|
54
|
+
SessionStatus,
|
|
55
|
+
StreamEvent,
|
|
56
|
+
Tool,
|
|
57
|
+
ToolCallRecord,
|
|
58
|
+
ToolResult,
|
|
59
|
+
ToolResultEvent,
|
|
60
|
+
ToolUseEvent,
|
|
61
|
+
UserMessageEvent,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
__version__ = "0.1.0"
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
"ApiError",
|
|
68
|
+
"AssistantTextEvent",
|
|
69
|
+
"AsyncCodeSpar",
|
|
70
|
+
"AsyncSession",
|
|
71
|
+
# Connect Links
|
|
72
|
+
"AuthConfig",
|
|
73
|
+
"AuthResult",
|
|
74
|
+
# Clients
|
|
75
|
+
"CodeSpar",
|
|
76
|
+
# Errors
|
|
77
|
+
"CodeSparError",
|
|
78
|
+
"ConfigError",
|
|
79
|
+
"DoneEvent",
|
|
80
|
+
"ErrorEvent",
|
|
81
|
+
"HttpMethod",
|
|
82
|
+
"ManageConnections",
|
|
83
|
+
"NotConnectedError",
|
|
84
|
+
"Preset",
|
|
85
|
+
# Proxy
|
|
86
|
+
"ProxyRequest",
|
|
87
|
+
"ProxyResult",
|
|
88
|
+
"SendResult",
|
|
89
|
+
"ServerConnection",
|
|
90
|
+
"Session",
|
|
91
|
+
# Configuration
|
|
92
|
+
"SessionConfig",
|
|
93
|
+
# Session output
|
|
94
|
+
"SessionInfo",
|
|
95
|
+
"SessionStatus",
|
|
96
|
+
"StreamError",
|
|
97
|
+
# Streaming events
|
|
98
|
+
"StreamEvent",
|
|
99
|
+
"Tool",
|
|
100
|
+
"ToolCallRecord",
|
|
101
|
+
"ToolResult",
|
|
102
|
+
"ToolResultEvent",
|
|
103
|
+
"ToolUseEvent",
|
|
104
|
+
"UserMessageEvent",
|
|
105
|
+
# Version
|
|
106
|
+
"__version__",
|
|
107
|
+
]
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""
|
|
2
|
+
``AsyncCodeSpar`` — the canonical client class.
|
|
3
|
+
|
|
4
|
+
Holds an httpx.AsyncClient, exposes ``create(user_id, ...)`` to start a
|
|
5
|
+
session, and mirrors the TS ``CodeSpar`` constructor 1:1. The sync
|
|
6
|
+
``CodeSpar`` in ``_sync_client.py`` wraps every call through
|
|
7
|
+
``asyncio.run`` so the lightweight use-case works without the caller
|
|
8
|
+
having to write ``async def``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from types import TracebackType
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from ._async_session import (
|
|
18
|
+
AsyncSession,
|
|
19
|
+
build_session_info,
|
|
20
|
+
wait_for_connections,
|
|
21
|
+
)
|
|
22
|
+
from ._http import DEFAULT_BASE_URL, request_json
|
|
23
|
+
from ._presets import preset_to_servers
|
|
24
|
+
from .errors import ApiError, ConfigError
|
|
25
|
+
from .types import SessionConfig
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AsyncCodeSpar:
|
|
29
|
+
"""
|
|
30
|
+
Async CodeSpar client. Pass an API key, create sessions, run them,
|
|
31
|
+
close them. One client can spawn many sessions in parallel.
|
|
32
|
+
|
|
33
|
+
Example::
|
|
34
|
+
|
|
35
|
+
async with AsyncCodeSpar(api_key="csk_live_...") as cs:
|
|
36
|
+
session = await cs.create("user_123", preset="brazilian")
|
|
37
|
+
result = await session.send("charge R$500 via Pix")
|
|
38
|
+
print(result.message)
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
api_key: str,
|
|
45
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
46
|
+
project_id: str | None = None,
|
|
47
|
+
timeout: float = 60.0,
|
|
48
|
+
client: httpx.AsyncClient | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
if not api_key or not api_key.startswith("csk_"):
|
|
51
|
+
raise ConfigError(
|
|
52
|
+
"api_key is required and must start with 'csk_'. "
|
|
53
|
+
"Get one from https://dashboard.codespar.dev."
|
|
54
|
+
)
|
|
55
|
+
self._api_key = api_key
|
|
56
|
+
self._base_url = base_url.rstrip("/")
|
|
57
|
+
self._project_id = project_id
|
|
58
|
+
# Share one transport across every session spawned by this
|
|
59
|
+
# client. Closing the client closes every in-flight request.
|
|
60
|
+
self._owns_client = client is None
|
|
61
|
+
self._client = client or httpx.AsyncClient(
|
|
62
|
+
base_url=self._base_url,
|
|
63
|
+
timeout=timeout,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def base_url(self) -> str:
|
|
68
|
+
return self._base_url
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def project_id(self) -> str | None:
|
|
72
|
+
return self._project_id
|
|
73
|
+
|
|
74
|
+
async def create(
|
|
75
|
+
self,
|
|
76
|
+
user_id: str,
|
|
77
|
+
config: SessionConfig | None = None,
|
|
78
|
+
/,
|
|
79
|
+
**kwargs: object,
|
|
80
|
+
) -> AsyncSession:
|
|
81
|
+
"""
|
|
82
|
+
Start a session scoped to ``user_id``.
|
|
83
|
+
|
|
84
|
+
``config`` can be passed as a ``SessionConfig`` dataclass or as
|
|
85
|
+
keyword arguments — both shapes work::
|
|
86
|
+
|
|
87
|
+
await cs.create("user_123", preset="brazilian")
|
|
88
|
+
await cs.create("user_123", SessionConfig(preset="brazilian"))
|
|
89
|
+
"""
|
|
90
|
+
resolved = self._resolve_config(config, kwargs)
|
|
91
|
+
servers = resolved.servers or preset_to_servers(resolved.preset)
|
|
92
|
+
project_id = resolved.project_id or self._project_id
|
|
93
|
+
|
|
94
|
+
body: dict[str, object] = {"servers": servers, "user_id": user_id}
|
|
95
|
+
if resolved.metadata:
|
|
96
|
+
body["metadata"] = resolved.metadata
|
|
97
|
+
|
|
98
|
+
data = await request_json(
|
|
99
|
+
self._client,
|
|
100
|
+
"POST",
|
|
101
|
+
"/v1/sessions",
|
|
102
|
+
api_key=self._api_key,
|
|
103
|
+
project_id=project_id,
|
|
104
|
+
body=body,
|
|
105
|
+
)
|
|
106
|
+
if not isinstance(data, dict):
|
|
107
|
+
raise ApiError("create: malformed response", status=0, body=data)
|
|
108
|
+
|
|
109
|
+
info = build_session_info(
|
|
110
|
+
data,
|
|
111
|
+
base_url=self._base_url,
|
|
112
|
+
api_key=self._api_key,
|
|
113
|
+
project_id=project_id,
|
|
114
|
+
)
|
|
115
|
+
session = AsyncSession(
|
|
116
|
+
info=info,
|
|
117
|
+
client=self._client,
|
|
118
|
+
api_key=self._api_key,
|
|
119
|
+
project_id=project_id,
|
|
120
|
+
base_url=self._base_url,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if resolved.manage_connections and resolved.manage_connections.wait_for_connections:
|
|
124
|
+
await wait_for_connections(
|
|
125
|
+
session,
|
|
126
|
+
timeout_ms=resolved.manage_connections.timeout,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return session
|
|
130
|
+
|
|
131
|
+
# ── lifecycle ───────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
async def aclose(self) -> None:
|
|
134
|
+
"""Close the underlying httpx transport."""
|
|
135
|
+
if self._owns_client:
|
|
136
|
+
await self._client.aclose()
|
|
137
|
+
|
|
138
|
+
async def __aenter__(self) -> AsyncCodeSpar:
|
|
139
|
+
return self
|
|
140
|
+
|
|
141
|
+
async def __aexit__(
|
|
142
|
+
self,
|
|
143
|
+
exc_type: type[BaseException] | None,
|
|
144
|
+
exc: BaseException | None,
|
|
145
|
+
tb: TracebackType | None,
|
|
146
|
+
) -> None:
|
|
147
|
+
await self.aclose()
|
|
148
|
+
|
|
149
|
+
# ── internals ───────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
def _resolve_config(
|
|
152
|
+
self,
|
|
153
|
+
config: SessionConfig | None,
|
|
154
|
+
kwargs: dict[str, object],
|
|
155
|
+
) -> SessionConfig:
|
|
156
|
+
"""Accept either a SessionConfig dataclass or kwargs, never both."""
|
|
157
|
+
if config is not None and kwargs:
|
|
158
|
+
raise ConfigError(
|
|
159
|
+
"Pass SessionConfig or keyword arguments, not both."
|
|
160
|
+
)
|
|
161
|
+
if config is not None:
|
|
162
|
+
return config
|
|
163
|
+
if not kwargs:
|
|
164
|
+
return SessionConfig()
|
|
165
|
+
|
|
166
|
+
allowed = {
|
|
167
|
+
"servers",
|
|
168
|
+
"preset",
|
|
169
|
+
"manage_connections",
|
|
170
|
+
"metadata",
|
|
171
|
+
"project_id",
|
|
172
|
+
}
|
|
173
|
+
unknown = set(kwargs) - allowed
|
|
174
|
+
if unknown:
|
|
175
|
+
raise ConfigError(
|
|
176
|
+
f"create(): unknown keyword argument(s): {', '.join(sorted(unknown))}"
|
|
177
|
+
)
|
|
178
|
+
return SessionConfig(**kwargs) # type: ignore[arg-type]
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async Session implementation.
|
|
3
|
+
|
|
4
|
+
Every public method maps 1:1 to the TypeScript ``Session`` in
|
|
5
|
+
``@codespar/sdk``. The shared httpx client is owned by the parent
|
|
6
|
+
``AsyncCodeSpar`` so closing the CodeSpar instance closes the
|
|
7
|
+
session's transport too.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import contextlib
|
|
14
|
+
from collections.abc import AsyncIterator
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from ._http import request_json, stream_sse
|
|
21
|
+
from .errors import ApiError, ConfigError
|
|
22
|
+
from .types import (
|
|
23
|
+
AssistantTextEvent,
|
|
24
|
+
AuthConfig,
|
|
25
|
+
AuthResult,
|
|
26
|
+
DoneEvent,
|
|
27
|
+
ErrorEvent,
|
|
28
|
+
ProxyRequest,
|
|
29
|
+
ProxyResult,
|
|
30
|
+
SendResult,
|
|
31
|
+
ServerConnection,
|
|
32
|
+
SessionInfo,
|
|
33
|
+
StreamEvent,
|
|
34
|
+
Tool,
|
|
35
|
+
ToolCallRecord,
|
|
36
|
+
ToolResult,
|
|
37
|
+
ToolResultEvent,
|
|
38
|
+
ToolUseEvent,
|
|
39
|
+
UserMessageEvent,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse_tool_call_record(raw: dict[str, Any]) -> ToolCallRecord:
|
|
44
|
+
return ToolCallRecord(
|
|
45
|
+
id=str(raw.get("id", "")),
|
|
46
|
+
tool_name=str(raw.get("tool_name", "")),
|
|
47
|
+
server_id=str(raw.get("server_id", "")),
|
|
48
|
+
status=raw.get("status", "success"),
|
|
49
|
+
duration_ms=int(raw.get("duration_ms", 0) or 0),
|
|
50
|
+
input=raw.get("input"),
|
|
51
|
+
output=raw.get("output"),
|
|
52
|
+
error_code=raw.get("error_code"),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse_send_result(raw: dict[str, Any]) -> SendResult:
|
|
57
|
+
tool_calls_raw = raw.get("tool_calls") or []
|
|
58
|
+
return SendResult(
|
|
59
|
+
message=str(raw.get("message", "")),
|
|
60
|
+
tool_calls=[_parse_tool_call_record(tc) for tc in tool_calls_raw],
|
|
61
|
+
iterations=int(raw.get("iterations", 0) or 0),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _parse_stream_event(raw: dict[str, Any]) -> StreamEvent | None:
|
|
66
|
+
"""
|
|
67
|
+
Normalize a raw SSE payload into a typed ``StreamEvent``.
|
|
68
|
+
|
|
69
|
+
Returns ``None`` for unknown event types so a future backend event
|
|
70
|
+
doesn't crash an SDK that predates it — forwards compatibility
|
|
71
|
+
matters more than strict parsing at this layer.
|
|
72
|
+
"""
|
|
73
|
+
event_type = raw.get("type")
|
|
74
|
+
match event_type:
|
|
75
|
+
case "user_message":
|
|
76
|
+
return UserMessageEvent(content=str(raw.get("content", "")))
|
|
77
|
+
case "assistant_text":
|
|
78
|
+
return AssistantTextEvent(
|
|
79
|
+
content=str(raw.get("content", "")),
|
|
80
|
+
iteration=int(raw.get("iteration", 0) or 0),
|
|
81
|
+
)
|
|
82
|
+
case "tool_use":
|
|
83
|
+
return ToolUseEvent(
|
|
84
|
+
id=str(raw.get("id", "")),
|
|
85
|
+
name=str(raw.get("name", "")),
|
|
86
|
+
input=raw.get("input") or {},
|
|
87
|
+
)
|
|
88
|
+
case "tool_result":
|
|
89
|
+
tc = raw.get("toolCall") or raw.get("tool_call")
|
|
90
|
+
if not isinstance(tc, dict):
|
|
91
|
+
return None
|
|
92
|
+
return ToolResultEvent(tool_call=_parse_tool_call_record(tc))
|
|
93
|
+
case "done":
|
|
94
|
+
result = raw.get("result")
|
|
95
|
+
payload = _parse_send_result(result if isinstance(result, dict) else {})
|
|
96
|
+
return DoneEvent(result=payload)
|
|
97
|
+
case "error":
|
|
98
|
+
return ErrorEvent(
|
|
99
|
+
error=str(raw.get("error", "error")),
|
|
100
|
+
message=raw.get("message") if isinstance(raw.get("message"), str) else None,
|
|
101
|
+
)
|
|
102
|
+
case _:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class AsyncSession:
|
|
107
|
+
"""
|
|
108
|
+
A live CodeSpar session — async interface.
|
|
109
|
+
|
|
110
|
+
Instances are created via ``AsyncCodeSpar.create(user_id, ...)`` and
|
|
111
|
+
hold a reference to the parent client's shared httpx transport.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
*,
|
|
117
|
+
info: SessionInfo,
|
|
118
|
+
client: httpx.AsyncClient,
|
|
119
|
+
api_key: str,
|
|
120
|
+
project_id: str | None,
|
|
121
|
+
base_url: str,
|
|
122
|
+
) -> None:
|
|
123
|
+
self.info = info
|
|
124
|
+
self._client = client
|
|
125
|
+
self._api_key = api_key
|
|
126
|
+
self._project_id = project_id
|
|
127
|
+
self._base_url = base_url
|
|
128
|
+
self._cached_tools: list[Tool] | None = None
|
|
129
|
+
self._cached_connections: list[ServerConnection] | None = None
|
|
130
|
+
|
|
131
|
+
# ── identity passthroughs ───────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def id(self) -> str:
|
|
135
|
+
return self.info.id
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def user_id(self) -> str:
|
|
139
|
+
return self.info.user_id
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def servers(self) -> list[str]:
|
|
143
|
+
return list(self.info.servers)
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def mcp(self) -> dict[str, Any]:
|
|
147
|
+
"""Config for MCP-compatible clients (Claude Desktop, Cursor)."""
|
|
148
|
+
return {
|
|
149
|
+
"url": self.info.mcp_url,
|
|
150
|
+
"headers": dict(self.info.mcp_headers),
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
# ── tools ───────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
async def tools(self) -> list[Tool]:
|
|
156
|
+
"""Return the tools available in this session. Cached after first call."""
|
|
157
|
+
if self._cached_tools is not None:
|
|
158
|
+
return list(self._cached_tools)
|
|
159
|
+
await self.connections()
|
|
160
|
+
return list(self._cached_tools or [])
|
|
161
|
+
|
|
162
|
+
async def find_tools(self, intent: str) -> list[Tool]:
|
|
163
|
+
"""Substring match on tool name + description. Case-insensitive."""
|
|
164
|
+
all_tools = await self.tools()
|
|
165
|
+
q = intent.lower()
|
|
166
|
+
return [t for t in all_tools if q in t.name.lower() or q in t.description.lower()]
|
|
167
|
+
|
|
168
|
+
# ── execution ───────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
async def execute(self, tool_name: str, params: dict[str, Any]) -> ToolResult:
|
|
171
|
+
"""Call a specific tool by name. Always returns a ToolResult, even on error."""
|
|
172
|
+
start = _now_ms()
|
|
173
|
+
try:
|
|
174
|
+
data = await request_json(
|
|
175
|
+
self._client,
|
|
176
|
+
"POST",
|
|
177
|
+
f"/v1/sessions/{self.id}/execute",
|
|
178
|
+
api_key=self._api_key,
|
|
179
|
+
project_id=self._project_id,
|
|
180
|
+
body={"tool": tool_name, "input": params},
|
|
181
|
+
)
|
|
182
|
+
except ApiError as exc:
|
|
183
|
+
return ToolResult(
|
|
184
|
+
success=False,
|
|
185
|
+
data=None,
|
|
186
|
+
error=f"{exc.status}: {exc.body or exc}",
|
|
187
|
+
duration=_now_ms() - start,
|
|
188
|
+
server="",
|
|
189
|
+
tool=tool_name,
|
|
190
|
+
)
|
|
191
|
+
if not isinstance(data, dict):
|
|
192
|
+
return ToolResult(
|
|
193
|
+
success=False,
|
|
194
|
+
data=None,
|
|
195
|
+
error="malformed response",
|
|
196
|
+
duration=_now_ms() - start,
|
|
197
|
+
server="",
|
|
198
|
+
tool=tool_name,
|
|
199
|
+
)
|
|
200
|
+
return ToolResult(
|
|
201
|
+
success=bool(data.get("success", False)),
|
|
202
|
+
data=data.get("data"),
|
|
203
|
+
error=data.get("error"),
|
|
204
|
+
duration=int(data.get("duration", 0) or 0),
|
|
205
|
+
server=str(data.get("server", "")),
|
|
206
|
+
tool=str(data.get("tool", tool_name)),
|
|
207
|
+
tool_call_id=data.get("tool_call_id"),
|
|
208
|
+
called_at=data.get("called_at"),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# ── proxy ───────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
async def proxy_execute(self, request: ProxyRequest) -> ProxyResult:
|
|
214
|
+
"""
|
|
215
|
+
Raw HTTP proxy to a connected server's upstream API. Auth is
|
|
216
|
+
injected by the backend — never send provider keys here.
|
|
217
|
+
"""
|
|
218
|
+
data = await request_json(
|
|
219
|
+
self._client,
|
|
220
|
+
"POST",
|
|
221
|
+
f"/v1/sessions/{self.id}/proxy_execute",
|
|
222
|
+
api_key=self._api_key,
|
|
223
|
+
project_id=self._project_id,
|
|
224
|
+
body={
|
|
225
|
+
"server": request.server,
|
|
226
|
+
"endpoint": request.endpoint,
|
|
227
|
+
"method": request.method,
|
|
228
|
+
"body": request.body,
|
|
229
|
+
"params": request.params,
|
|
230
|
+
"headers": request.headers,
|
|
231
|
+
},
|
|
232
|
+
)
|
|
233
|
+
if not isinstance(data, dict):
|
|
234
|
+
raise ApiError("proxy_execute: malformed response", status=0, body=data)
|
|
235
|
+
return ProxyResult(
|
|
236
|
+
status=int(data.get("status", 0) or 0),
|
|
237
|
+
data=data.get("data"),
|
|
238
|
+
headers=dict(data.get("headers") or {}),
|
|
239
|
+
duration=int(data.get("duration", 0) or 0),
|
|
240
|
+
proxy_call_id=data.get("proxy_call_id"),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# ── natural-language ────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
async def send(self, message: str) -> SendResult:
|
|
246
|
+
"""Send a natural-language message. Blocks until the agent loop finishes."""
|
|
247
|
+
data = await request_json(
|
|
248
|
+
self._client,
|
|
249
|
+
"POST",
|
|
250
|
+
f"/v1/sessions/{self.id}/send",
|
|
251
|
+
api_key=self._api_key,
|
|
252
|
+
project_id=self._project_id,
|
|
253
|
+
body={"message": message},
|
|
254
|
+
)
|
|
255
|
+
if not isinstance(data, dict):
|
|
256
|
+
raise ApiError("send: malformed response", status=0, body=data)
|
|
257
|
+
return _parse_send_result(data)
|
|
258
|
+
|
|
259
|
+
async def send_stream(self, message: str) -> AsyncIterator[StreamEvent]:
|
|
260
|
+
"""
|
|
261
|
+
Stream a natural-language turn. Yields events as they arrive.
|
|
262
|
+
|
|
263
|
+
Usage::
|
|
264
|
+
|
|
265
|
+
async for event in session.send_stream("process this order"):
|
|
266
|
+
match event.type:
|
|
267
|
+
case "assistant_text":
|
|
268
|
+
print(event.content, end="")
|
|
269
|
+
case "tool_use":
|
|
270
|
+
print(f"[tool] {event.name}")
|
|
271
|
+
"""
|
|
272
|
+
async for raw in stream_sse(
|
|
273
|
+
self._client,
|
|
274
|
+
f"/v1/sessions/{self.id}/send",
|
|
275
|
+
api_key=self._api_key,
|
|
276
|
+
project_id=self._project_id,
|
|
277
|
+
body={"message": message},
|
|
278
|
+
):
|
|
279
|
+
event = _parse_stream_event(raw)
|
|
280
|
+
if event is not None:
|
|
281
|
+
yield event
|
|
282
|
+
|
|
283
|
+
# ── Connect Links ───────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
async def authorize(self, server_id: str, config: AuthConfig) -> AuthResult:
|
|
286
|
+
"""
|
|
287
|
+
Start a Connect Link OAuth flow. Returns the URL your UI should
|
|
288
|
+
open for the end user; CodeSpar's callback stores tokens and
|
|
289
|
+
forwards the user to ``config.redirect_uri``.
|
|
290
|
+
"""
|
|
291
|
+
data = await request_json(
|
|
292
|
+
self._client,
|
|
293
|
+
"POST",
|
|
294
|
+
"/v1/connect/start",
|
|
295
|
+
api_key=self._api_key,
|
|
296
|
+
project_id=self._project_id,
|
|
297
|
+
body={
|
|
298
|
+
"server_id": server_id,
|
|
299
|
+
"user_id": self.user_id,
|
|
300
|
+
"redirect_uri": config.redirect_uri,
|
|
301
|
+
"scopes": config.scopes,
|
|
302
|
+
},
|
|
303
|
+
)
|
|
304
|
+
if not isinstance(data, dict):
|
|
305
|
+
raise ApiError("authorize: malformed response", status=0, body=data)
|
|
306
|
+
return AuthResult(
|
|
307
|
+
link_token=str(data.get("link_token", "")),
|
|
308
|
+
authorize_url=str(data.get("authorize_url", "")),
|
|
309
|
+
expires_at=str(data.get("expires_at", "")),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# ── connections ─────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
async def connections(self) -> list[ServerConnection]:
|
|
315
|
+
"""List server connections and refresh the internal tools cache."""
|
|
316
|
+
try:
|
|
317
|
+
data = await request_json(
|
|
318
|
+
self._client,
|
|
319
|
+
"GET",
|
|
320
|
+
f"/v1/sessions/{self.id}/connections",
|
|
321
|
+
api_key=self._api_key,
|
|
322
|
+
project_id=self._project_id,
|
|
323
|
+
)
|
|
324
|
+
except ApiError:
|
|
325
|
+
return list(self._cached_connections or [])
|
|
326
|
+
if not isinstance(data, dict):
|
|
327
|
+
return list(self._cached_connections or [])
|
|
328
|
+
|
|
329
|
+
raw_servers = data.get("servers") or []
|
|
330
|
+
raw_tools = data.get("tools") or []
|
|
331
|
+
servers = [
|
|
332
|
+
ServerConnection(
|
|
333
|
+
id=str(s.get("id", "")),
|
|
334
|
+
name=str(s.get("name", "")),
|
|
335
|
+
category=str(s.get("category", "")),
|
|
336
|
+
country=str(s.get("country", "")),
|
|
337
|
+
auth_type=s.get("auth_type", "none"),
|
|
338
|
+
connected=bool(s.get("connected", False)),
|
|
339
|
+
)
|
|
340
|
+
for s in raw_servers
|
|
341
|
+
if isinstance(s, dict)
|
|
342
|
+
]
|
|
343
|
+
tools = [
|
|
344
|
+
Tool(
|
|
345
|
+
name=str(t.get("name", "")),
|
|
346
|
+
description=str(t.get("description", "")),
|
|
347
|
+
input_schema=dict(t.get("input_schema") or {}),
|
|
348
|
+
server=str(t.get("server", "")),
|
|
349
|
+
)
|
|
350
|
+
for t in raw_tools
|
|
351
|
+
if isinstance(t, dict)
|
|
352
|
+
]
|
|
353
|
+
self._cached_connections = servers
|
|
354
|
+
self._cached_tools = tools
|
|
355
|
+
return list(servers)
|
|
356
|
+
|
|
357
|
+
# ── lifecycle ───────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
async def close(self) -> None:
|
|
360
|
+
"""Close the session on the backend. Safe to call multiple times.
|
|
361
|
+
Best-effort — a 4xx/5xx here shouldn't crash the caller. The
|
|
362
|
+
backend cleans up stale sessions on a timer anyway."""
|
|
363
|
+
with contextlib.suppress(ApiError):
|
|
364
|
+
await request_json(
|
|
365
|
+
self._client,
|
|
366
|
+
"DELETE",
|
|
367
|
+
f"/v1/sessions/{self.id}",
|
|
368
|
+
api_key=self._api_key,
|
|
369
|
+
project_id=self._project_id,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _now_ms() -> int:
|
|
374
|
+
return int(asyncio.get_event_loop().time() * 1000)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
async def wait_for_connections(session: AsyncSession, timeout_ms: int) -> None:
|
|
378
|
+
"""
|
|
379
|
+
Poll ``session.connections()`` until every server reports connected,
|
|
380
|
+
or until ``timeout_ms`` elapses. Matches TS ``manageConnections``.
|
|
381
|
+
"""
|
|
382
|
+
if timeout_ms <= 0:
|
|
383
|
+
raise ConfigError("wait_for_connections: timeout_ms must be positive")
|
|
384
|
+
deadline = _now_ms() + timeout_ms
|
|
385
|
+
while True:
|
|
386
|
+
conns = await session.connections()
|
|
387
|
+
if conns and all(c.connected for c in conns):
|
|
388
|
+
return
|
|
389
|
+
if _now_ms() >= deadline:
|
|
390
|
+
return
|
|
391
|
+
await asyncio.sleep(1.0)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def build_session_info(
|
|
395
|
+
raw: dict[str, Any],
|
|
396
|
+
*,
|
|
397
|
+
base_url: str,
|
|
398
|
+
api_key: str,
|
|
399
|
+
project_id: str | None,
|
|
400
|
+
) -> SessionInfo:
|
|
401
|
+
"""Map backend POST /v1/sessions response into a ``SessionInfo``."""
|
|
402
|
+
created_raw = raw.get("created_at", "")
|
|
403
|
+
try:
|
|
404
|
+
created = datetime.fromisoformat(str(created_raw).replace("Z", "+00:00"))
|
|
405
|
+
except ValueError:
|
|
406
|
+
created = datetime.now()
|
|
407
|
+
|
|
408
|
+
mcp_headers: dict[str, str] = {"Authorization": f"Bearer {api_key}"}
|
|
409
|
+
if project_id:
|
|
410
|
+
mcp_headers["x-codespar-project"] = project_id
|
|
411
|
+
|
|
412
|
+
return SessionInfo(
|
|
413
|
+
id=str(raw.get("id", "")),
|
|
414
|
+
user_id=str(raw.get("user_id", "")),
|
|
415
|
+
servers=list(raw.get("servers") or []),
|
|
416
|
+
created_at=created,
|
|
417
|
+
status=raw.get("status", "active"),
|
|
418
|
+
mcp_url=f"{base_url}/v1/sessions/{raw.get('id', '')}/mcp",
|
|
419
|
+
mcp_headers=mcp_headers,
|
|
420
|
+
)
|