vedika-agent-sdk 0.2.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.
@@ -0,0 +1,65 @@
1
+ """Public entry point for the Vedika agent SDK."""
2
+
3
+ from .client import Transport, VedikaAgent, create_agent
4
+ from .errors import (
5
+ AuthenticationError,
6
+ InvalidRequestError,
7
+ NotFoundError,
8
+ ProtocolError,
9
+ RateLimitError,
10
+ ServerError,
11
+ StreamError,
12
+ VedikaAgentError,
13
+ is_vedika_agent_error,
14
+ )
15
+ from .stream import (
16
+ Turn,
17
+ format_last_event_id,
18
+ parse_chunk,
19
+ parse_sse_chunk,
20
+ UI_MESSAGE_STREAM_HEADER,
21
+ )
22
+ from .types import (
23
+ Artifact,
24
+ CancelTurnOptions,
25
+ CreateThreadOptions,
26
+ Json,
27
+ MemoryToggles,
28
+ ResumeTurnOptions,
29
+ SendTurnOptions,
30
+ Thread,
31
+ TurnInput,
32
+ TurnOutcome,
33
+ UIMessageStreamChunk,
34
+ )
35
+
36
+ __all__ = [
37
+ "VedikaAgent",
38
+ "create_agent",
39
+ "Transport",
40
+ "VedikaAgentError",
41
+ "AuthenticationError",
42
+ "NotFoundError",
43
+ "RateLimitError",
44
+ "InvalidRequestError",
45
+ "ServerError",
46
+ "StreamError",
47
+ "ProtocolError",
48
+ "is_vedika_agent_error",
49
+ "parse_chunk",
50
+ "parse_sse_chunk",
51
+ "format_last_event_id",
52
+ "UI_MESSAGE_STREAM_HEADER",
53
+ "Artifact",
54
+ "CancelTurnOptions",
55
+ "CreateThreadOptions",
56
+ "Json",
57
+ "MemoryToggles",
58
+ "ResumeTurnOptions",
59
+ "SendTurnOptions",
60
+ "Thread",
61
+ "Turn",
62
+ "TurnInput",
63
+ "TurnOutcome",
64
+ "UIMessageStreamChunk",
65
+ ]
@@ -0,0 +1,138 @@
1
+ """The "server says your SDK is too old" warning.
2
+
3
+ Every harness response may carry ``X-Harness-Min-Sdk: <major.minor.patch>``.
4
+ When this SDK's own version is lower, one ``SdkVersionWarning`` is emitted per
5
+ process and the SDK keeps working. The check only ever warns: it never raises,
6
+ never delays a call and never changes retry or error behaviour.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import functools
12
+ import re
13
+ import threading
14
+ import warnings
15
+ from typing import Any, Mapping, Optional, Tuple
16
+
17
+ #: Response header (lower-case; header names are case-insensitive).
18
+ MIN_SDK_HEADER = "x-harness-min-sdk"
19
+
20
+ #: Distribution name from ``pyproject.toml``.
21
+ PACKAGE_NAME = "vedika-agent-sdk"
22
+
23
+ # Three dot-separated groups of 1 to 9 ASCII digits. Written as [0-9], not \d,
24
+ # because \d also matches non-ASCII digits in Python.
25
+ _VERSION_CORE = re.compile(r"[0-9]{1,9}\.[0-9]{1,9}\.[0-9]{1,9}")
26
+
27
+ Version = Tuple[int, int, int]
28
+
29
+
30
+ class SdkVersionWarning(UserWarning):
31
+ """This SDK is older than the minimum version the server supports."""
32
+
33
+
34
+ def parse_version(value: Any) -> Optional[Version]:
35
+ """Parse ``major.minor.patch``, ignoring a prerelease or build suffix.
36
+
37
+ Returns ``None`` for anything that is not exactly three dot-separated
38
+ digit groups of at most nine digits each.
39
+ """
40
+ if not isinstance(value, str):
41
+ return None
42
+ core = re.split(r"[-+]", value.strip(), maxsplit=1)[0]
43
+ if _VERSION_CORE.fullmatch(core) is None:
44
+ return None
45
+ major, minor, patch = core.split(".")
46
+ return int(major), int(minor), int(patch)
47
+
48
+
49
+ def compare_versions(own: Any, minimum: Any) -> Optional[int]:
50
+ """Compare two versions numerically.
51
+
52
+ Returns -1 when ``own`` is lower than ``minimum``, 0 when equal, 1 when
53
+ higher, and ``None`` when either side cannot be compared.
54
+ """
55
+ a = parse_version(own)
56
+ b = parse_version(minimum)
57
+ if a is None or b is None:
58
+ return None
59
+ return (a > b) - (a < b)
60
+
61
+
62
+ def warning_message(package_name: str, own_version: str, min_version: str) -> str:
63
+ return (
64
+ f"{package_name} {own_version} is older than the minimum supported version "
65
+ f"{min_version}. Upgrade to {min_version} or later."
66
+ )
67
+
68
+
69
+ @functools.lru_cache(maxsize=1)
70
+ def _own_version() -> Optional[str]:
71
+ try:
72
+ from importlib.metadata import version
73
+
74
+ return version(PACKAGE_NAME)
75
+ except Exception:
76
+ return None
77
+
78
+
79
+ def _header_value(headers: Optional[Mapping[str, Any]]) -> Optional[str]:
80
+ if not headers:
81
+ return None
82
+ value = headers.get(MIN_SDK_HEADER)
83
+ if value is None:
84
+ for key, candidate in headers.items():
85
+ if isinstance(key, str) and key.lower() == MIN_SDK_HEADER:
86
+ value = candidate
87
+ break
88
+ if isinstance(value, bytes):
89
+ value = value.decode("ascii", errors="replace")
90
+ return value if isinstance(value, str) else None
91
+
92
+
93
+ _lock = threading.Lock()
94
+ _warned = False
95
+
96
+
97
+ def _reset_min_sdk_warning_for_tests() -> None:
98
+ """Test-only: allow the once-per-process warning to fire again."""
99
+ global _warned
100
+ with _lock:
101
+ _warned = False
102
+
103
+
104
+ def check_min_sdk_header(
105
+ headers: Optional[Mapping[str, Any]],
106
+ *,
107
+ own_version: Optional[str] = None,
108
+ stacklevel: int = 4,
109
+ ) -> None:
110
+ """Warn once per process when the server's minimum SDK is above ours.
111
+
112
+ ``stacklevel`` counts from this function: 2 is the caller, so the default
113
+ of 4 points at the code that called ``agent.threads.<method>()``.
114
+ Swallows every failure, including a warnings filter set to ``error``.
115
+ """
116
+ global _warned
117
+ if _warned:
118
+ return
119
+ try:
120
+ minimum = parse_version(_header_value(headers))
121
+ if minimum is None:
122
+ return
123
+ own_text = own_version if own_version is not None else _own_version()
124
+ own = parse_version(own_text)
125
+ if own is None or own >= minimum:
126
+ return
127
+ with _lock:
128
+ if _warned:
129
+ return
130
+ _warned = True
131
+ warnings.warn(
132
+ warning_message(PACKAGE_NAME, str(own_text), ".".join(map(str, minimum))),
133
+ SdkVersionWarning,
134
+ stacklevel=stacklevel,
135
+ )
136
+ except Exception:
137
+ # The check must never affect the call that triggered it.
138
+ pass
vedika_agent/client.py ADDED
@@ -0,0 +1,401 @@
1
+ """HTTP client for the Vedika agent harness.
2
+
3
+ Talks to a harness-server (see vedika-harness/docs/CONTRACT.md) over the
4
+ UI Message Stream v1 wire. The default transport uses ``httpx.AsyncClient``;
5
+ callers can inject any object with a compatible ``stream`` / ``post`` /
6
+ ``get`` interface.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import uuid
12
+ from dataclasses import asdict, dataclass, field
13
+ from typing import (
14
+ Any,
15
+ AsyncIterator,
16
+ Awaitable,
17
+ Callable,
18
+ Dict,
19
+ Mapping,
20
+ Optional,
21
+ )
22
+
23
+ from ._min_sdk import check_min_sdk_header
24
+ from .errors import (
25
+ AuthenticationError,
26
+ InvalidRequestError,
27
+ NotFoundError,
28
+ ProtocolError,
29
+ RateLimitError,
30
+ ServerError,
31
+ VedikaAgentError,
32
+ )
33
+ from .stream import (
34
+ Turn,
35
+ assert_ui_message_stream,
36
+ parse_chunk,
37
+ )
38
+ from .types import (
39
+ CancelTurnOptions,
40
+ CreateThreadOptions,
41
+ MemoryToggles,
42
+ ResumeTurnOptions,
43
+ SendTurnOptions,
44
+ Thread,
45
+ TurnInput,
46
+ )
47
+
48
+ # Header names that callers are not allowed to override.
49
+ RESERVED_HEADERS = frozenset(
50
+ {"authorization", "accept", "content-type", "idempotency-key", "last-event-id",
51
+ "x-vercel-ai-ui-message-stream"}
52
+ )
53
+
54
+
55
+ def _uuid4() -> str:
56
+ return str(uuid.uuid4())
57
+
58
+
59
+ def _filter_headers(extra: Optional[Mapping[str, str]]) -> Dict[str, str]:
60
+ out: Dict[str, str] = {}
61
+ if not extra:
62
+ return out
63
+ for key, value in extra.items():
64
+ if key.lower() in RESERVED_HEADERS:
65
+ continue
66
+ out[key] = value
67
+ return out
68
+
69
+
70
+ @dataclass
71
+ class VedikaAgentConfig:
72
+ base_url: str
73
+ api_key: str
74
+ default_headers: Dict[str, str] = field(default_factory=dict)
75
+
76
+
77
+ class _HttpResponse:
78
+ __slots__ = ("status", "headers", "body", "_stream_factory")
79
+
80
+ def __init__(
81
+ self,
82
+ status: int,
83
+ headers: Mapping[str, str],
84
+ body: bytes,
85
+ stream: Optional[Callable[[int], AsyncIterator[bytes]]] = None,
86
+ ) -> None:
87
+ self.status = status
88
+ self.headers = dict(headers)
89
+ self.body = body
90
+ self._stream_factory = stream
91
+
92
+ @property
93
+ def ok(self) -> bool:
94
+ return 200 <= self.status < 300
95
+
96
+ async def text(self) -> str:
97
+ return self.body.decode("utf-8", errors="replace")
98
+
99
+ async def json(self) -> Any:
100
+ import json
101
+
102
+ return json.loads(self.body.decode("utf-8"))
103
+
104
+ def iter_bytes(self) -> AsyncIterator[bytes]:
105
+ if self._stream_factory is None:
106
+ async def _empty() -> AsyncIterator[bytes]:
107
+ if False:
108
+ yield b""
109
+ return _empty()
110
+ return self._stream_factory(self.status)
111
+
112
+
113
+ class _HttpTransport:
114
+ """Minimal async transport. Default uses ``httpx.AsyncClient``."""
115
+
116
+ def __init__(self) -> None:
117
+ try:
118
+ import httpx # noqa: F401 - presence check
119
+ self._has_httpx = True
120
+ except ImportError: # pragma: no cover
121
+ self._has_httpx = False
122
+
123
+ async def request(
124
+ self,
125
+ method: str,
126
+ url: str,
127
+ *,
128
+ headers: Mapping[str, str],
129
+ body: Optional[bytes] = None,
130
+ stream: bool = False,
131
+ timeout: float = 60.0,
132
+ ) -> _HttpResponse:
133
+ if not self._has_httpx: # pragma: no cover
134
+ raise VedikaAgentError(
135
+ "no async HTTP transport available; install httpx or inject a custom transport",
136
+ )
137
+ import httpx
138
+
139
+ if stream:
140
+ # The client and the response must outlive this call: the caller
141
+ # reads the body later, chunk by chunk. Both are closed when the
142
+ # stream is exhausted, abandoned or fails.
143
+ client = httpx.AsyncClient(timeout=timeout)
144
+ try:
145
+ request = client.build_request(method, url, headers=dict(headers), content=body)
146
+ response = await client.send(request, stream=True)
147
+ except BaseException:
148
+ await client.aclose()
149
+ raise
150
+ status = response.status_code
151
+ hdrs = {k: v for k, v in response.headers.items()}
152
+ if not 200 <= status < 300:
153
+ # An error answer is small and the caller maps it from the body.
154
+ try:
155
+ error_body = await response.aread()
156
+ finally:
157
+ await response.aclose()
158
+ await client.aclose()
159
+ return _HttpResponse(status, hdrs, error_body)
160
+
161
+ async def stream_factory(_status: int) -> AsyncIterator[bytes]:
162
+ try:
163
+ async for chunk in response.aiter_bytes():
164
+ if chunk:
165
+ yield chunk
166
+ finally:
167
+ await response.aclose()
168
+ await client.aclose()
169
+
170
+ return _HttpResponse(status, hdrs, b"", stream=stream_factory)
171
+ async with httpx.AsyncClient(timeout=timeout) as client:
172
+ request = client.build_request(method, url, headers=dict(headers), content=body)
173
+ response = await client.send(request, stream=False)
174
+ return _HttpResponse(
175
+ response.status_code,
176
+ {k: v for k, v in response.headers.items()},
177
+ response.content,
178
+ )
179
+
180
+
181
+ class Transport:
182
+ """Protocol-style transport that callers can inject for tests.
183
+
184
+ Implementations must return an object with:
185
+
186
+ - ``status: int``
187
+ - ``headers: Mapping[str, str]``
188
+ - ``body: bytes``
189
+ - ``iter_bytes() -> AsyncIterator[bytes]``
190
+ - ``ok: bool`` (property)
191
+ - ``text() -> Awaitable[str]``
192
+ - ``json() -> Awaitable[Any]``
193
+ """
194
+
195
+ async def request(
196
+ self,
197
+ method: str,
198
+ url: str,
199
+ *,
200
+ headers: Mapping[str, str],
201
+ body: Optional[bytes] = None,
202
+ stream: bool = False,
203
+ ) -> _HttpResponse: # pragma: no cover - interface only
204
+ raise NotImplementedError
205
+
206
+
207
+ def _map_response_error(status: int, headers: Mapping[str, str], body: bytes) -> VedikaAgentError:
208
+ import json
209
+
210
+ try:
211
+ parsed = json.loads(body.decode("utf-8"))
212
+ except Exception:
213
+ parsed = None
214
+ if isinstance(parsed, dict):
215
+ message = str(parsed.get("message") or parsed.get("error") or "") or None
216
+ code = parsed.get("code")
217
+ else:
218
+ message = None
219
+ code = None
220
+ if message is None:
221
+ message = body.decode("utf-8", errors="replace")[:256] or "request failed"
222
+ if status in (401, 403):
223
+ return AuthenticationError(message, status=status)
224
+ if status in (404, 410):
225
+ return NotFoundError(message)
226
+ if status == 429:
227
+ retry_after = headers.get("retry-after")
228
+ seconds: Optional[int] = None
229
+ if retry_after:
230
+ try:
231
+ seconds = int(retry_after)
232
+ except ValueError:
233
+ seconds = None
234
+ return RateLimitError(message, status=status, retry_after_seconds=seconds)
235
+ if 400 <= status < 500:
236
+ return InvalidRequestError(message, status=status, code=code)
237
+ return ServerError(message, status=status, retryable=status >= 500)
238
+
239
+
240
+ def _build_thread(payload: Mapping[str, Any]) -> Thread:
241
+ return Thread(
242
+ id=str(payload["id"]),
243
+ created_at=str(payload.get("createdAt") or payload.get("created_at") or ""),
244
+ metadata=dict(payload.get("metadata") or {}),
245
+ )
246
+
247
+
248
+ class _ThreadsNamespace:
249
+ def __init__(self, parent: "VedikaAgent") -> None:
250
+ self._parent = parent
251
+
252
+ async def create(self, opts: Optional[CreateThreadOptions] = None) -> Thread:
253
+ opts = opts or CreateThreadOptions()
254
+ idem = opts.idempotency_key or _uuid4()
255
+ headers = self._parent._headers(opts.headers)
256
+ headers["idempotency-key"] = idem
257
+ headers["content-type"] = "application/json"
258
+ body_dict: Dict[str, Any] = {"metadata": dict(opts.metadata)}
259
+ import json
260
+
261
+ res = await self._parent._request(
262
+ "POST",
263
+ self._parent._url("/api/v1/agent/threads"),
264
+ headers=headers,
265
+ body=json.dumps(body_dict).encode("utf-8"),
266
+ stream=False,
267
+ )
268
+ if not res.ok:
269
+ raise _map_response_error(res.status, res.headers, res.body)
270
+ try:
271
+ payload = await res.json()
272
+ except Exception as exc:
273
+ raise ProtocolError("response body is not valid JSON") from exc
274
+ return _build_thread(payload)
275
+
276
+ async def send_turn(
277
+ self,
278
+ thread_id: str,
279
+ input: TurnInput,
280
+ opts: Optional[SendTurnOptions] = None,
281
+ ) -> Turn:
282
+ opts = opts or SendTurnOptions()
283
+ idem = opts.idempotency_key or _uuid4()
284
+ headers = self._parent._headers(opts.headers)
285
+ headers["idempotency-key"] = idem
286
+ headers["accept"] = "text/event-stream"
287
+ headers["content-type"] = "application/json"
288
+ if opts.model:
289
+ headers["x-vedika-model"] = opts.model
290
+ if opts.memory:
291
+ if not isinstance(opts.memory, MemoryToggles):
292
+ raise VedikaAgentError("memory option must be a MemoryToggles instance")
293
+ headers["x-vedika-memory-old"] = "true" if opts.memory.old_memory else "false"
294
+ headers["x-vedika-memory-sensitive"] = "true" if opts.memory.sensitive_info else "false"
295
+ import json
296
+
297
+ body = json.dumps({"input": input}).encode("utf-8")
298
+ res = await self._parent._request(
299
+ "POST",
300
+ self._parent._url(f"/api/v1/agent/threads/{thread_id}/turns"),
301
+ headers=headers,
302
+ body=body,
303
+ stream=True,
304
+ )
305
+ if not res.ok:
306
+ text = await res.text()
307
+ raise _map_response_error(res.status, res.headers, text.encode("utf-8"))
308
+ assert_ui_message_stream(res.headers)
309
+ turn_id = res.headers.get("x-vedika-turn-id") or "pending"
310
+ return Turn(
311
+ turn_id=turn_id,
312
+ thread_id=thread_id,
313
+ source=res.iter_bytes(),
314
+ cancel=lambda: self.cancel_turn(turn_id),
315
+ )
316
+
317
+ async def resume_turn(self, turn_id: str, opts: ResumeTurnOptions) -> Turn:
318
+ headers = self._parent._headers(opts.headers)
319
+ headers["accept"] = "text/event-stream"
320
+ headers["last-event-id"] = opts.last_event_id
321
+ res = await self._parent._request(
322
+ "GET",
323
+ self._parent._url(f"/api/v1/agent/turns/{turn_id}/events"),
324
+ headers=headers,
325
+ stream=True,
326
+ )
327
+ if not res.ok:
328
+ text = await res.text()
329
+ raise _map_response_error(res.status, res.headers, text.encode("utf-8"))
330
+ assert_ui_message_stream(res.headers)
331
+ thread_id = res.headers.get("x-vedika-thread-id") or "unknown"
332
+ return Turn(
333
+ turn_id=turn_id,
334
+ thread_id=thread_id,
335
+ source=res.iter_bytes(),
336
+ cancel=lambda: self.cancel_turn(turn_id),
337
+ )
338
+
339
+ async def cancel_turn(
340
+ self,
341
+ turn_id: str,
342
+ opts: Optional[CancelTurnOptions] = None,
343
+ ) -> None:
344
+ opts = opts or CancelTurnOptions()
345
+ headers = self._parent._headers(opts.headers)
346
+ res = await self._parent._request(
347
+ "POST",
348
+ self._parent._url(f"/api/v1/agent/turns/{turn_id}/cancel"),
349
+ headers=headers,
350
+ stream=False,
351
+ )
352
+ if not res.ok and res.status not in (404, 410):
353
+ raise _map_response_error(res.status, res.headers, res.body)
354
+
355
+
356
+ class VedikaAgent:
357
+ """Top-level client. Construct with {@link create_agent}."""
358
+
359
+ def __init__(
360
+ self,
361
+ config: VedikaAgentConfig,
362
+ *,
363
+ transport: Optional[Transport] = None,
364
+ ) -> None:
365
+ self._config = config
366
+ self._transport: Any = transport or _HttpTransport()
367
+ self.threads = _ThreadsNamespace(self)
368
+
369
+ async def _request(self, method: str, url: str, **kwargs: Any) -> Any:
370
+ """Single choke point: every HTTP response the SDK receives passes here."""
371
+ res = await self._transport.request(method, url, **kwargs)
372
+ check_min_sdk_header(getattr(res, "headers", None))
373
+ return res
374
+
375
+ def _url(self, path: str) -> str:
376
+ from urllib.parse import urljoin
377
+
378
+ return urljoin(self._config.base_url + "/", path.lstrip("/"))
379
+
380
+ def _headers(self, extra: Optional[Mapping[str, str]]) -> Dict[str, str]:
381
+ out: Dict[str, str] = {
382
+ "authorization": f"Bearer {self._config.api_key}",
383
+ **self._config.default_headers,
384
+ }
385
+ out.update(_filter_headers(extra))
386
+ return out
387
+
388
+
389
+ def create_agent(
390
+ base_url: str,
391
+ api_key: str,
392
+ *,
393
+ default_headers: Optional[Mapping[str, str]] = None,
394
+ transport: Optional[Transport] = None,
395
+ ) -> VedikaAgent:
396
+ config = VedikaAgentConfig(
397
+ base_url=base_url,
398
+ api_key=api_key,
399
+ default_headers=dict(default_headers or {}),
400
+ )
401
+ return VedikaAgent(config, transport=transport)